Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 113 additions & 6 deletions src/main/java/org/metricshub/winrm/light/CipherGen.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,15 @@
*/

import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.Charset;
import java.security.Key;
import java.security.MessageDigest;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Random;
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
Expand All @@ -48,7 +53,9 @@ public class CipherGen {

private final String domain;
private final String user;
private final String password;
// char[] end-to-end (diverging from the upstream String-based port): an immutable String copy
// of the password could never be wiped, defeating the char[]-based credentials contract.
private final char[] password;
private final byte[] challenge;
private final byte[] targetInformation;

Expand Down Expand Up @@ -76,13 +83,25 @@ public class CipherGen {
private byte[] ntlm2SessionResponseUserSessionKey = null;
private byte[] lanManagerSessionKey = null;

/**
* Create a generator for the NTLM responses of one authentication exchange.
*
* @param random the random source for the client challenges and secondary key
* @param currentTime the current time in milliseconds since the epoch (for the NTLMv2 timestamp)
* @param domain the authentication domain (may be {@code null})
* @param user the user name
* @param password the password, kept as {@code char[]} by reference and never turned into a
* {@code String}, so the caller retains the single wipeable copy of the secret
* @param challenge the server challenge from the Type 2 message
* @param targetInformation the target information block from the Type 2 message
*/
@SuppressFBWarnings(value = "EI_EXPOSE_REP2", justification = EXPOSE_JUSTIFICATION)
public CipherGen(
final Random random,
final long currentTime,
final String domain,
final String user,
final String password,
final char[] password,
final byte[] challenge,
final byte[] targetInformation
) {
Expand Down Expand Up @@ -339,15 +358,19 @@ private static byte[] lmResponse(final byte[] hash, final byte[] challenge) thro
* @return The LM Hash of the given password, used in the calculation of the
* LM Response.
*/
private static byte[] lmHash(final String password) throws NtlmException {
private static byte[] lmHash(final char[] password) throws NtlmException {
try {
final byte[] oemPassword = password.toUpperCase(Locale.ROOT).getBytes(NTLMEngineUtils.DEFAULT_CHARSET);
final char[] upper = upperCase(password);
final byte[] oemPassword = encode(upper, NTLMEngineUtils.DEFAULT_CHARSET);
Arrays.fill(upper, '\0');

final int length = Math.min(oemPassword.length, 14);
final byte[] keyBytes = new byte[14];
System.arraycopy(oemPassword, 0, keyBytes, 0, length);
Arrays.fill(oemPassword, (byte) 0);
final Key lowKey = createDESKey(keyBytes, 0);
final Key highKey = createDESKey(keyBytes, 7);
Arrays.fill(keyBytes, (byte) 0);
final byte[] magicConstant = "KGS!@#$%".getBytes(NTLMEngineUtils.DEFAULT_CHARSET);
final Cipher des = Cipher.getInstance("DES/ECB/NoPadding");
des.init(Cipher.ENCRYPT_MODE, lowKey);
Expand Down Expand Up @@ -504,16 +527,100 @@ public byte[] getLanManagerSessionKey() throws NtlmException {
* @return The NTLM Hash of the given password, used in the calculation of
* the NTLM Response and the NTLMv2 and LMv2 Hashes.
*/
private static byte[] ntlmHash(final String password) throws NtlmException {
private static byte[] ntlmHash(final char[] password) throws NtlmException {
if (NTLMEngineUtils.UNICODE_LITTLE_UNMARKED == null) {
throw new NtlmException("Unicode not supported");
}
final byte[] unicodePassword = password.getBytes(NTLMEngineUtils.UNICODE_LITTLE_UNMARKED);
final byte[] unicodePassword = encode(password, NTLMEngineUtils.UNICODE_LITTLE_UNMARKED);
final MD4 md4 = new MD4();
md4.update(unicodePassword);
Arrays.fill(unicodePassword, (byte) 0);
return md4.getOutput();
}

/**
* Encode a char[] without going through String — an immutable String copy of the password
* could never be wiped. {@link Charset#encode(CharBuffer)} replaces malformed/unmappable
* input exactly like {@code String.getBytes(Charset)} did, so the produced bytes (and thus
* the derived hashes) are unchanged. The encoder's scratch buffer is zeroed before returning.
*
* @param chars the characters to encode
* @param charset the target charset
* @return the encoded bytes (caller wipes them after use)
*/
static byte[] encode(final char[] chars, final Charset charset) {
final ByteBuffer buffer = charset.encode(CharBuffer.wrap(chars));
final byte[] bytes = new byte[buffer.remaining()];
buffer.get(bytes);
if (buffer.hasArray()) {
Arrays.fill(buffer.array(), (byte) 0);
}
return bytes;
}

// Full Locale.ROOT uppercase mappings that Character.toUpperCase(int) cannot produce — the
// one-to-many expansions (ß -> SS, ligatures, Greek breathing marks) and the few simple
// divergences. Lazily built on the legacy LM-hash path only.
private static volatile Map<Integer, char[]> uppercaseExpansions;

/**
* Uppercase a char[] with the exact semantics of {@code String.toUpperCase(Locale.ROOT)},
* including one-to-many expansions, without ever creating a String of the input — an immutable
* String copy of the password could never be wiped. Locale.ROOT uppercasing is context-free
* (the conditional special casings are all lowercase- or locale-specific), so mapping each code
* point independently reproduces the whole-string result.
*
* @param chars the characters to uppercase
* @return the uppercased characters (caller wipes them after use)
*/
static char[] upperCase(final char[] chars) {
final Map<Integer, char[]> expansions = uppercaseExpansions();
// An uppercase full mapping is at most 3 chars (e.g. U+0390), so 3x cannot overflow.
final char[] scratch = new char[chars.length * 3];
int out = 0;
for (int i = 0; i < chars.length;) {
final int codePoint = Character.codePointAt(chars, i);
i += Character.charCount(codePoint);
final char[] full = expansions.get(codePoint);
if (full != null) {
System.arraycopy(full, 0, scratch, out, full.length);
out += full.length;
} else {
out += Character.toChars(Character.toUpperCase(codePoint), scratch, out);
}
}
final char[] upper = Arrays.copyOf(scratch, out);
Arrays.fill(scratch, '\0');
return upper;
}

/**
* Build (once) the map of code points whose {@code String.toUpperCase(Locale.ROOT)} differs
* from {@code Character.toUpperCase(int)}, by probing every code point through the JDK's own
* casing data. The probing happens on public constants — never on a secret — so this stays
* compatible with the char[]-based credentials contract, and it tracks the running JDK's
* Unicode version by construction. A benign racy double-build yields identical maps.
*
* @return the expansion map
*/
private static Map<Integer, char[]> uppercaseExpansions() {
Map<Integer, char[]> map = uppercaseExpansions;
if (map == null) {
map = new HashMap<>();
for (int codePoint = Character.MIN_CODE_POINT; codePoint <= Character.MAX_CODE_POINT; codePoint++) {
if (codePoint >= Character.MIN_SURROGATE && codePoint <= Character.MAX_SURROGATE) {
continue;
}
final String full = new String(Character.toChars(codePoint)).toUpperCase(Locale.ROOT);
if (full.codePointCount(0, full.length()) != 1 || full.codePointAt(0) != Character.toUpperCase(codePoint)) {
map.put(codePoint, full.toCharArray());
}
}
uppercaseExpansions = map;
}
return map;
}

/**
* Creates the LMv2 Hash of the user's password.
*
Expand Down
10 changes: 7 additions & 3 deletions src/main/java/org/metricshub/winrm/light/KerberosAuthScheme.java
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@ final class KerberosAuthScheme implements AuthScheme {

private final String servicePrincipalHost;
private final String username;
private final String password;
// Kept as char[] by reference (never copied into a String): the caller owns the single wipeable
// copy of the secret and may zero it after closing the client.
private final char[] password;
private final Path ticketCache;

private GSSContext context;
Expand All @@ -78,7 +80,7 @@ final class KerberosAuthScheme implements AuthScheme {
KerberosAuthScheme(
final String servicePrincipalHost,
final String username,
final String password,
final char[] password,
final Path ticketCache
) {
this.servicePrincipalHost = servicePrincipalHost;
Expand Down Expand Up @@ -157,7 +159,9 @@ private CallbackHandler callbackHandler() {
if (callback instanceof NameCallback) {
((NameCallback) callback).setName(username);
} else if (callback instanceof PasswordCallback) {
((PasswordCallback) callback).setPassword(password == null ? null : password.toCharArray());
// PasswordCallback clones the array, so the caller's char[] stays the only
// long-lived copy of the secret outside the JAAS machinery.
((PasswordCallback) callback).setPassword(password);
}
}
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,9 @@ private static AuthScheme resolveAuthScheme(

final String domain = winRMEndpoint.getDomain();
final String username = winRMEndpoint.getUsername();
final String password = new String(winRMEndpoint.getPassword());
// Keep the caller's char[] by reference, never as a String: the credentials contract is that
// wiping that single array after close() leaves no live copy of the secret anywhere.
final char[] password = winRMEndpoint.getPassword();

final List<AuthScheme> schemes = new ArrayList<>();
for (final AuthenticationEnum auth : requested) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ final class NtlmAuthScheme implements AuthScheme {
private final boolean https;
private final WinRMSession session;

NtlmAuthScheme(final String domain, final String username, final String password, final boolean https) {
NtlmAuthScheme(final String domain, final String username, final char[] password, final boolean https) {
this.https = https;
// Uppercase the domain: NTOWFv2 (and thus the NTLM session key) is computed over it, the
// Type 3 DomainName field goes on the wire uppercased, and the server derives its session
Expand Down
2 changes: 1 addition & 1 deletion src/main/java/org/metricshub/winrm/light/Type3Message.java
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ public class Type3Message extends NTLMMessage {
final String domain,
final String host,
final String user,
final String password,
final char[] password,
final byte[] nonce,
final int type2Flags,
final String target,
Expand Down
8 changes: 5 additions & 3 deletions src/main/java/org/metricshub/winrm/light/WinRMSession.java
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@ final class WinRMSession {
private final String domain;
private final String workstation;
private final String username;
private final String password;
// Kept as char[] by reference (never copied into a String): the caller owns the single wipeable
// copy of the secret and may zero it after closing the client.
private final char[] password;

// volatile: the session outlives individual operations, and each operation runs on a fresh
// worker thread (Utils.execute spawns one per call), so state written during the handshake on
Expand All @@ -63,7 +65,7 @@ final class WinRMSession {
private final AtomicLong sequenceOutgoing = new AtomicLong(-1);
private final AtomicLong sequenceIncoming = new AtomicLong(-1);

WinRMSession(final String domain, final String workstation, final String username, final String password) {
WinRMSession(final String domain, final String workstation, final String username, final char[] password) {
this.domain = domain;
this.workstation = workstation;
this.username = username;
Expand All @@ -82,7 +84,7 @@ String getUsername() {
return username;
}

String getPassword() {
char[] getPassword() {
return password;
}

Expand Down
6 changes: 4 additions & 2 deletions src/site/markdown/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,10 @@ remember to escape the backslash in a string literal:
"Administrator" // no domain
```

The password is a `char[]`, and the builder deliberately does **not** copy it: after closing the
client you can wipe the single authoritative copy of the secret (`Arrays.fill(password, '\0')`).
The password is a `char[]`, and the builder deliberately does **not** copy it: the client keeps
that same array by reference end-to-end and never converts it to a `String` internally, so after
closing the client you can wipe the single authoritative copy of the secret
(`Arrays.fill(password, '\0')`).

## NTLM

Expand Down
110 changes: 110 additions & 0 deletions src/test/java/org/metricshub/winrm/light/CipherGenTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package org.metricshub.winrm.light;

/*-
* ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲
* WinRM Java Client
* ჻჻჻჻჻჻
* Copyright 2023 - 2026 MetricsHub
* ჻჻჻჻჻჻
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱
*/

import static org.junit.jupiter.api.Assertions.assertArrayEquals;

import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Locale;
import java.util.Random;
import org.junit.jupiter.api.Test;

/**
* The password flows through {@link CipherGen} as a char[] and is never turned into a String (an
* immutable String copy could not be wiped, breaking the credentials contract). These tests pin
* that the char[]-based preprocessing is byte-identical to the String-based derivation it
* replaced — String.toUpperCase(Locale.ROOT) semantics (including one-to-many expansions like
* ß → SS) and String.getBytes(Charset) replacement semantics.
*/
class CipherGenTest {

@Test
void upperCaseMatchesStringToUpperCase() {
final String[] samples = {
"", // empty
"plain ASCII s3cret!", // the common case
"pässwörd-ß", // ß expands to SS
"straße & ẞ", // sharp s next to capital sharp s
"file flow office stop", // Latin ligatures expand (fi → FI, ...)
"ﬓﬔﬕﬖﬗ", // Armenian ligatures expand
"ΐ and ΰ", // Greek: expand to three chars each
"ᾀᾧᾲῴ", // Greek ypogegrammeni: simple mapping exists AND full mapping expands
"ʼn ǰ ẖ ẗ ẘ ẙ ẚ", // more one-to-many expansions
"𐐷𐑏 supplementary", // surrogate pairs (Deseret, 1:1 mapping)
"broken \ud800 surrogate" // unpaired surrogate passes through unchanged
};
for (final String sample : samples) {
assertArrayEquals(
sample.toUpperCase(Locale.ROOT).toCharArray(),
CipherGen.upperCase(sample.toCharArray()),
sample
);
}
}

@Test
void upperCaseMatchesStringToUpperCaseForEveryCodePoint() {
for (int codePoint = Character.MIN_CODE_POINT; codePoint <= Character.MAX_CODE_POINT; codePoint++) {
if (codePoint >= Character.MIN_SURROGATE && codePoint <= Character.MAX_SURROGATE) {
continue;
}
final String s = new String(Character.toChars(codePoint));
final char[] expected = s.toUpperCase(Locale.ROOT).toCharArray();
final char[] actual = CipherGen.upperCase(s.toCharArray());
// Guard to avoid building 1.1M failure messages; assert only on divergence.
if (!Arrays.equals(expected, actual)) {
assertArrayEquals(expected, actual, "U+" + Integer.toHexString(codePoint).toUpperCase(Locale.ROOT));
}
}
}

@Test
void encodeMatchesStringGetBytes() {
final String[] samples = { "", "ASCII only", "pässw0rd-€-好-😀", "broken \ud800 surrogate", "ß" };
for (final String sample : samples) {
assertArrayEquals(
sample.getBytes(StandardCharsets.UTF_16LE),
CipherGen.encode(sample.toCharArray(), StandardCharsets.UTF_16LE),
sample
);
assertArrayEquals(
sample.getBytes(StandardCharsets.US_ASCII),
CipherGen.encode(sample.toCharArray(), StandardCharsets.US_ASCII),
sample
);
}
}

@Test
void lmResponseAppliesFullUppercaseExpansions() throws Exception {
// The legacy LM hash upper-cases the password before hashing, and ß expands to SS there:
// the LM response for "ß" must therefore equal the one for "ss" (both hash the OEM bytes
// "SS"). A per-char uppercase (Character.toUpperCase) would leave ß in place, turn it into
// '?' in the OEM charset, and derive a different response.
final byte[] challenge = { 0x01, 0x23, 0x45, 0x67, (byte) 0x89, (byte) 0xab, (byte) 0xcd, (byte) 0xef };
final byte[] fromExpansion = new CipherGen(new Random(0), 0L, "DOM", "user", "ß".toCharArray(), challenge, null)
.getLMResponse();
final byte[] fromPlain = new CipherGen(new Random(0), 0L, "DOM", "user", "ss".toCharArray(), challenge, null)
.getLMResponse();
assertArrayEquals(fromPlain, fromExpansion);
}
}
Loading
Loading