From 8084b63b0f6804ebb120dd01586f3995f664ff68 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Tue, 11 Aug 2026 17:00:26 +0200 Subject: [PATCH 1/2] Keep the password as char[] end-to-end so the wipe guarantee holds The credentials(String, char[]) contract promises that the caller can wipe the single authoritative copy of the password after closing the client, because the builder deliberately does not copy the array. The light backend broke that promise: resolveAuthScheme() converted the char[] to an immutable String that the NTLM and Kerberos schemes then retained, leaving an un-wipeable copy of the secret on the heap (flagged by Codex review on PR #163). The password now flows as the caller's char[] by reference through LightWinRMService, NtlmAuthScheme, WinRMSession, Type3Message, KerberosAuthScheme and CipherGen. The two hashing sinks encode it without going through String: Charset.encode(CharBuffer) has the same malformed-input replacement semantics as String.getBytes(Charset), so the derived hashes are byte-identical, and the transient encodings are zeroed after use. The Kerberos PasswordCallback takes the char[] directly (it clones internally). The legacy LM hash now uppercases per-char instead of via String.toUpperCase; the two differ only on one-to-many mappings the LM OEM charset cannot represent anyway. A new protocol test authenticates with a non-ASCII password (including a surrogate pair) against FakeWsmanServer, whose NTOWFv2 derivation is still String-based, proving the char[] encoding path is byte-identical. Co-Authored-By: Claude Fable 5 --- .../org/metricshub/winrm/light/CipherGen.java | 60 +++++++++++++++++-- .../winrm/light/KerberosAuthScheme.java | 10 +++- .../winrm/light/LightWinRMService.java | 4 +- .../winrm/light/NtlmAuthScheme.java | 2 +- .../metricshub/winrm/light/Type3Message.java | 2 +- .../metricshub/winrm/light/WinRMSession.java | 8 ++- src/site/markdown/authentication.md | 6 +- .../winrm/light/FakeWsmanServer.java | 2 +- .../winrm/light/WsmanProtocolTest.java | 47 +++++++++++++++ 9 files changed, 123 insertions(+), 18 deletions(-) diff --git a/src/main/java/org/metricshub/winrm/light/CipherGen.java b/src/main/java/org/metricshub/winrm/light/CipherGen.java index 7e08267..ead4837 100644 --- a/src/main/java/org/metricshub/winrm/light/CipherGen.java +++ b/src/main/java/org/metricshub/winrm/light/CipherGen.java @@ -21,6 +21,9 @@ */ 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; @@ -48,7 +51,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 +81,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 +356,25 @@ 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); + // Per-char uppercase (not String.toUpperCase) so no String copy of the password is ever + // created. The two can differ only on chars with one-to-many uppercase mappings (e.g. ß), + // which the legacy LM hash's OEM charset cannot represent anyway. + final char[] upper = new char[password.length]; + for (int i = 0; i < password.length; i++) { + upper[i] = Character.toUpperCase(password[i]); + } + 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 +531,37 @@ 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) + */ + private 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; + } + /** * 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/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 From 90b05e0abbc3fa798c75c016b401d1d009bc4755 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Tue, 11 Aug 2026 17:25:21 +0200 Subject: [PATCH 2/2] Preserve full Unicode uppercase semantics in the LM hash (Codex review) The char[] conversion had replaced String.toUpperCase(Locale.ROOT) with per-char Character.toUpperCase in the legacy LM hash, silently changing the derived LM response for passwords containing one-to-many uppercase mappings (e.g. ss expanded to SS before; per-char left it in place and the OEM charset turned it into ?). CipherGen.upperCase(char[]) now reproduces String.toUpperCase exactly without ever creating a String of the secret: a lazily built map (legacy LM path only) probes every code point through the JDK''s own casing data - on public constants, never on the password - and records those whose full uppercase differs from Character.toUpperCase; the password is then uppercased code point by code point through that map. Locale.ROOT uppercasing is context-free, so per-code-point mapping equals the whole-string result. CipherGenTest locks the equivalence: an exhaustive sweep over all code points against String.toUpperCase, expansion-heavy samples, the Charset.encode/String.getBytes parity, and the concrete regression (LM response of the sharp s equals that of ss). Co-Authored-By: Claude Fable 5 --- .../org/metricshub/winrm/light/CipherGen.java | 75 ++++++++++-- .../metricshub/winrm/light/CipherGenTest.java | 110 ++++++++++++++++++ 2 files changed, 177 insertions(+), 8 deletions(-) create mode 100644 src/test/java/org/metricshub/winrm/light/CipherGenTest.java diff --git a/src/main/java/org/metricshub/winrm/light/CipherGen.java b/src/main/java/org/metricshub/winrm/light/CipherGen.java index ead4837..89b9807 100644 --- a/src/main/java/org/metricshub/winrm/light/CipherGen.java +++ b/src/main/java/org/metricshub/winrm/light/CipherGen.java @@ -27,7 +27,9 @@ 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; @@ -358,13 +360,7 @@ private static byte[] lmResponse(final byte[] hash, final byte[] challenge) thro */ private static byte[] lmHash(final char[] password) throws NtlmException { try { - // Per-char uppercase (not String.toUpperCase) so no String copy of the password is ever - // created. The two can differ only on chars with one-to-many uppercase mappings (e.g. ß), - // which the legacy LM hash's OEM charset cannot represent anyway. - final char[] upper = new char[password.length]; - for (int i = 0; i < password.length; i++) { - upper[i] = Character.toUpperCase(password[i]); - } + final char[] upper = upperCase(password); final byte[] oemPassword = encode(upper, NTLMEngineUtils.DEFAULT_CHARSET); Arrays.fill(upper, '\0'); @@ -552,7 +548,7 @@ private static byte[] ntlmHash(final char[] password) throws NtlmException { * @param charset the target charset * @return the encoded bytes (caller wipes them after use) */ - private static byte[] encode(final char[] chars, final Charset charset) { + 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); @@ -562,6 +558,69 @@ private static byte[] encode(final char[] chars, final Charset charset) { 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/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); + } +}