diff --git a/src/main/java/org/metricshub/winrm/light/CipherGen.java b/src/main/java/org/metricshub/winrm/light/CipherGen.java index 7e08267..89b9807 100644 --- a/src/main/java/org/metricshub/winrm/light/CipherGen.java +++ b/src/main/java/org/metricshub/winrm/light/CipherGen.java @@ -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; @@ -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; @@ -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 ) { @@ -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); @@ -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 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 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 uppercaseExpansions() { + Map 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. * diff --git a/src/main/java/org/metricshub/winrm/light/KerberosAuthScheme.java b/src/main/java/org/metricshub/winrm/light/KerberosAuthScheme.java index 3ab3469..a07b6c3 100644 --- a/src/main/java/org/metricshub/winrm/light/KerberosAuthScheme.java +++ b/src/main/java/org/metricshub/winrm/light/KerberosAuthScheme.java @@ -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; @@ -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; @@ -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); } } }; diff --git a/src/main/java/org/metricshub/winrm/light/LightWinRMService.java b/src/main/java/org/metricshub/winrm/light/LightWinRMService.java index 71b7558..639ed28 100644 --- a/src/main/java/org/metricshub/winrm/light/LightWinRMService.java +++ b/src/main/java/org/metricshub/winrm/light/LightWinRMService.java @@ -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 schemes = new ArrayList<>(); for (final AuthenticationEnum auth : requested) { diff --git a/src/main/java/org/metricshub/winrm/light/NtlmAuthScheme.java b/src/main/java/org/metricshub/winrm/light/NtlmAuthScheme.java index 1983677..b259d1e 100644 --- a/src/main/java/org/metricshub/winrm/light/NtlmAuthScheme.java +++ b/src/main/java/org/metricshub/winrm/light/NtlmAuthScheme.java @@ -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 diff --git a/src/main/java/org/metricshub/winrm/light/Type3Message.java b/src/main/java/org/metricshub/winrm/light/Type3Message.java index d313558..f7dd239 100644 --- a/src/main/java/org/metricshub/winrm/light/Type3Message.java +++ b/src/main/java/org/metricshub/winrm/light/Type3Message.java @@ -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, diff --git a/src/main/java/org/metricshub/winrm/light/WinRMSession.java b/src/main/java/org/metricshub/winrm/light/WinRMSession.java index b1a2700..3b86b46 100644 --- a/src/main/java/org/metricshub/winrm/light/WinRMSession.java +++ b/src/main/java/org/metricshub/winrm/light/WinRMSession.java @@ -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 @@ -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; @@ -82,7 +84,7 @@ String getUsername() { return username; } - String getPassword() { + char[] getPassword() { return password; } diff --git a/src/site/markdown/authentication.md b/src/site/markdown/authentication.md index 0abcb85..f784af8 100644 --- a/src/site/markdown/authentication.md +++ b/src/site/markdown/authentication.md @@ -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 diff --git a/src/test/java/org/metricshub/winrm/light/CipherGenTest.java b/src/test/java/org/metricshub/winrm/light/CipherGenTest.java new file mode 100644 index 0000000..a6cba2c --- /dev/null +++ b/src/test/java/org/metricshub/winrm/light/CipherGenTest.java @@ -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); + } +} diff --git a/src/test/java/org/metricshub/winrm/light/FakeWsmanServer.java b/src/test/java/org/metricshub/winrm/light/FakeWsmanServer.java index ae2bfd9..66e9d80 100644 --- a/src/test/java/org/metricshub/winrm/light/FakeWsmanServer.java +++ b/src/test/java/org/metricshub/winrm/light/FakeWsmanServer.java @@ -463,7 +463,7 @@ private WinRMSession authenticate(final byte[] type3) { final byte[] userSessionKey = EncryptionUtils.hmacMd5(ntowfV2, ntProofStr); final byte[] exportedSessionKey = EncryptionUtils.calculateRC4(encryptedSessionKey, userSessionKey); - final WinRMSession session = new WinRMSession(expectedDomain, null, expectedUser, expectedPassword); + final WinRMSession session = new WinRMSession(expectedDomain, null, expectedUser, expectedPassword.toCharArray()); session.applyKeys(flags, exportedSessionKey, true); return session; } diff --git a/src/test/java/org/metricshub/winrm/light/WsmanProtocolTest.java b/src/test/java/org/metricshub/winrm/light/WsmanProtocolTest.java index 8459c31..23f52a7 100644 --- a/src/test/java/org/metricshub/winrm/light/WsmanProtocolTest.java +++ b/src/test/java/org/metricshub/winrm/light/WsmanProtocolTest.java @@ -210,6 +210,53 @@ void wqlPagesOverChunkedResponsesWithTrailers() throws Exception { assertTrue(requests.get(1).contains("uuid:CTX-1"), requests.get(1)); } + @Test + void ntlmAuthenticatesWithNonAsciiPassword() throws Exception { + // The client derives the NTLM hash straight from the caller's char[] — it never builds a + // String copy of the password (which could not be wiped). The fake server computes NTOWFv2 + // from a String via String.getBytes, so a successful handshake proves the char[]-based + // UTF-16LE encoding is byte-identical — including outside ASCII (é, €, 好, and an emoji + // surrogate pair). + server.close(); + final String password = "pässw0rd-€-好-😀"; + server = new FakeWsmanServer(DOMAIN, USER, password); + server + .enqueue( + 200, + envelope( + "" + + "uuid:CTX-1" + + "" + + service("Spooler", "Running") + + "" + + "" + ) + ) + .enqueue( + 200, + envelope( + "" + + "" + + "" + ) + ); + + try (LightWinRMService service = client(password)) { + final List> rows = service.executeWql("SELECT Name,State FROM Win32_Service", TIMEOUT); + + assertEquals(1, rows.size()); + assertEquals("Spooler", rows.get(0).get("Name")); + } + } + // --- Command shell lifecycle ------------------------------------------------ @Test