From b1a19ef1498c6047f17084b94e6ff9550bd1f591 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Thu, 23 Jul 2026 12:46:54 +0200 Subject: [PATCH 01/21] Add dependency-free "light" WinRM backend behind a runtime toggle Introduce org.metricshub.winrm.light: a pure-JDK WinRM/WS-Management client that speaks NTLM (masqueraded as Negotiate) with message encryption over HTTP, using templated SOAP envelopes and the JDK-default XML factories. It carries no Apache CXF / JAX-WS / JAXB / Woodstox dependencies and is immune by construction to the JAXP ServiceLoader poisoning that breaks WinRM in some host apps (see MetricsHub/metricshub-community#1271). - LightWinRMService implements WindowsRemoteExecutor, so it is a drop-in for the CXF-based WinRMService (WQL + command execution, same return shapes/validation). - WsmanClient does the NTLM handshake + seal/sign, WQL Enumerate/Pull, and the full command shell lifecycle (Create/Command/Receive/Signal/Delete). The NTLM crypto is ported from the existing (proven) encryption classes; the one subtle fix vs a naive port is uppercasing the domain before NTOWFv2 (Apache NTCredentials does this and the Type 3 domain field is uppercased on the wire, so a lowercase domain passes auth but fails message integrity -> HTTP 400). - WinRMExecutorFactory selects the backend via -Dorg.metricshub.winrm.backend (default "cxf"; "light" opts in). WinRMWqlExecutor and WinRMCommandExecutor now depend on the WindowsRemoteExecutor interface, so callers are backend-agnostic. WindowsRemoteExecutor.close() is narrowed to declare no checked exception. Timeouts are enforced as a wall-clock deadline via Utils.execute (throwing TimeoutException) exactly like the CXF backend; the transport re-authenticates if the connection is dropped, and closes the socket on any I/O error. Verified against a real Windows Server 2008 R2 host over HTTP/5985: light and CXF backends return identical WQL data and command output/exit codes, with light ~5x faster (no CXF init). mvn verify: 35 tests pass, prettier/checkstyle/pmd clean. Part of the winrm-light roadmap (#103, #104); feature parity and the recorded- exchange test rig are tracked in #106 and #107. Co-Authored-By: Claude Opus 4.8 --- .../winrm/WindowsRemoteExecutor.java | 10 +- .../winrm/command/WinRMCommandExecutor.java | 7 +- .../winrm/light/ByteArrayUtils.java | 103 +++ .../org/metricshub/winrm/light/CipherGen.java | 589 ++++++++++++++++++ .../winrm/light/EncryptionUtils.java | 85 +++ .../org/metricshub/winrm/light/Envelopes.java | 221 +++++++ .../org/metricshub/winrm/light/HMACMD5.java | 82 +++ .../metricshub/winrm/light/HttpTransport.java | 254 ++++++++ .../winrm/light/LightWinRMService.java | 187 ++++++ .../java/org/metricshub/winrm/light/MD4.java | 211 +++++++ .../winrm/light/NTLMEngineUtils.java | 103 +++ .../metricshub/winrm/light/NTLMMessage.java | 176 ++++++ .../metricshub/winrm/light/NtlmCrypto.java | 199 ++++++ .../metricshub/winrm/light/NtlmException.java | 35 ++ .../metricshub/winrm/light/Type1Message.java | 119 ++++ .../metricshub/winrm/light/Type2Message.java | 136 ++++ .../metricshub/winrm/light/Type3Message.java | 298 +++++++++ .../metricshub/winrm/light/WinRMSession.java | 162 +++++ .../metricshub/winrm/light/WsmanClient.java | 408 ++++++++++++ .../winrm/service/WinRMExecutorFactory.java | 69 ++ .../winrm/wql/WinRMWqlExecutor.java | 14 +- .../service/WinRMExecutorFactoryTest.java | 89 +++ 22 files changed, 3550 insertions(+), 7 deletions(-) create mode 100644 src/main/java/org/metricshub/winrm/light/ByteArrayUtils.java create mode 100644 src/main/java/org/metricshub/winrm/light/CipherGen.java create mode 100644 src/main/java/org/metricshub/winrm/light/EncryptionUtils.java create mode 100644 src/main/java/org/metricshub/winrm/light/Envelopes.java create mode 100644 src/main/java/org/metricshub/winrm/light/HMACMD5.java create mode 100644 src/main/java/org/metricshub/winrm/light/HttpTransport.java create mode 100644 src/main/java/org/metricshub/winrm/light/LightWinRMService.java create mode 100644 src/main/java/org/metricshub/winrm/light/MD4.java create mode 100644 src/main/java/org/metricshub/winrm/light/NTLMEngineUtils.java create mode 100644 src/main/java/org/metricshub/winrm/light/NTLMMessage.java create mode 100644 src/main/java/org/metricshub/winrm/light/NtlmCrypto.java create mode 100644 src/main/java/org/metricshub/winrm/light/NtlmException.java create mode 100644 src/main/java/org/metricshub/winrm/light/Type1Message.java create mode 100644 src/main/java/org/metricshub/winrm/light/Type2Message.java create mode 100644 src/main/java/org/metricshub/winrm/light/Type3Message.java create mode 100644 src/main/java/org/metricshub/winrm/light/WinRMSession.java create mode 100644 src/main/java/org/metricshub/winrm/light/WsmanClient.java create mode 100644 src/main/java/org/metricshub/winrm/service/WinRMExecutorFactory.java create mode 100644 src/test/java/org/metricshub/winrm/service/WinRMExecutorFactoryTest.java diff --git a/src/main/java/org/metricshub/winrm/WindowsRemoteExecutor.java b/src/main/java/org/metricshub/winrm/WindowsRemoteExecutor.java index c9cbfae..ce87c62 100644 --- a/src/main/java/org/metricshub/winrm/WindowsRemoteExecutor.java +++ b/src/main/java/org/metricshub/winrm/WindowsRemoteExecutor.java @@ -4,7 +4,7 @@ * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ * WinRM Java Client * ჻჻჻჻჻჻ - * Copyright 2023 - 2024 Metricshub + * 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. @@ -81,4 +81,12 @@ public WindowsRemoteCommandResult executeCommand( * @return */ public char[] getPassword(); + + /** + * Close the executor and release its resources. Narrows {@link AutoCloseable#close()} so it does + * not declare a checked exception, letting callers use try-with-resources without catching + * {@link Exception}. + */ + @Override + public void close(); } diff --git a/src/main/java/org/metricshub/winrm/command/WinRMCommandExecutor.java b/src/main/java/org/metricshub/winrm/command/WinRMCommandExecutor.java index 9e21c79..25deae4 100644 --- a/src/main/java/org/metricshub/winrm/command/WinRMCommandExecutor.java +++ b/src/main/java/org/metricshub/winrm/command/WinRMCommandExecutor.java @@ -4,7 +4,7 @@ * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ * WinRM Java Client * ჻჻჻჻჻჻ - * Copyright 2023 - 2024 Metricshub + * 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. @@ -30,11 +30,12 @@ import org.metricshub.winrm.Utils; import org.metricshub.winrm.WinRMHttpProtocolEnum; import org.metricshub.winrm.WindowsRemoteCommandResult; +import org.metricshub.winrm.WindowsRemoteExecutor; import org.metricshub.winrm.WindowsRemoteProcessUtils; import org.metricshub.winrm.exceptions.WindowsRemoteException; import org.metricshub.winrm.exceptions.WqlQuerySyntaxException; import org.metricshub.winrm.service.WinRMEndpoint; -import org.metricshub.winrm.service.WinRMService; +import org.metricshub.winrm.service.WinRMExecutorFactory; import org.metricshub.winrm.service.client.auth.AuthenticationEnum; import org.metricshub.winrm.shares.SmbTempShare; @@ -103,7 +104,7 @@ public static WindowsRemoteCommandResult execute( if (localFileToCopyList == null || localFileToCopyList.isEmpty()) { try ( - final WinRMService winRMService = WinRMService.createInstance( + final WindowsRemoteExecutor winRMService = WinRMExecutorFactory.createInstance( winRMEndpoint, timeout, ticketCache, diff --git a/src/main/java/org/metricshub/winrm/light/ByteArrayUtils.java b/src/main/java/org/metricshub/winrm/light/ByteArrayUtils.java new file mode 100644 index 0000000..d468a14 --- /dev/null +++ b/src/main/java/org/metricshub/winrm/light/ByteArrayUtils.java @@ -0,0 +1,103 @@ +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 java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +/** + * Code from io.cloudsoft.winrm4j.client.encryption.ByteArrayUtils + * release 0.12.3 @link https://github.com/cloudsoft/winrm4j + */ +public class ByteArrayUtils { + + private ByteArrayUtils() {} + + private static final int WIDTH = 32; + + public static String formatHexDump(final byte[] array) { + if (array == null) { + return "null"; + } + + // from https://gist.github.com/jen20/906db194bd97c14d91df + + final StringBuilder builder = new StringBuilder(); + + for (int rowOffset = 0; rowOffset < array.length; rowOffset += WIDTH) { + builder.append(String.format("%06d: ", rowOffset)); + + for (int index = 0; index < WIDTH; index++) { + if (rowOffset + index < array.length) { + builder.append(String.format("%02x", array[rowOffset + index])); + } else { + builder.append(" "); + } + + if (index % 4 == 3) { + builder.append(" "); + } + } + + if (rowOffset < array.length) { + builder.append(" | "); + for (int index = 0; index < WIDTH; index++) { + if (rowOffset + index < array.length) { + final byte c = array[rowOffset + index]; + builder.append((c >= 20 && c < 127) ? (char) c : '.'); + + if (index % 8 == 7) builder.append(" "); + } + } + } + + builder.append("\n"); + } + + return builder.toString(); + } + + public static byte[] getLittleEndianUnsignedInt(final long x) { + final ByteBuffer byteBuffer = ByteBuffer.allocate(4); + byteBuffer.order(ByteOrder.LITTLE_ENDIAN); + byteBuffer.putInt((int) (x & 0xFFFFFFFF)); + return byteBuffer.array(); + } + + public static long readLittleEndianUnsignedInt(final byte[] input, final int offset) { + final ByteBuffer byteBuffer = ByteBuffer.wrap(input); + byteBuffer.order(ByteOrder.LITTLE_ENDIAN); + return Integer.toUnsignedLong(byteBuffer.getInt(offset)); + } + + public static byte[] concat(final byte[]... sequences) { + try (final ByteArrayOutputStream out = new ByteArrayOutputStream()) { + for (byte[] s : sequences) { + out.write(s); + } + return out.toByteArray(); + } catch (final IOException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/src/main/java/org/metricshub/winrm/light/CipherGen.java b/src/main/java/org/metricshub/winrm/light/CipherGen.java new file mode 100644 index 0000000..378670a --- /dev/null +++ b/src/main/java/org/metricshub/winrm/light/CipherGen.java @@ -0,0 +1,589 @@ +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 java.security.Key; +import java.security.MessageDigest; +import java.util.Arrays; +import java.util.Locale; +import java.util.Random; +import javax.crypto.Cipher; +import javax.crypto.spec.SecretKeySpec; + +/** + * Code from io.cloudsoft.winrm4j.client.ntlm.forks.httpclient.NTLMEngineImpl + * release 0.12.3 @link https://github.com/cloudsoft/winrm4j + * io.cloudsoft.winrm4j.client.ntlm.forks.httpclient is a fork of apache-httpclient 4.5.13 + */ +public class CipherGen { + + private final Random random; + private final long currentTime; + + private final String domain; + private final String user; + private final String password; + private final byte[] challenge; + private final byte[] targetInformation; + + // Information we can generate but may be passed in (for testing) + private byte[] clientChallenge; + private byte[] clientChallenge2; + private byte[] secondaryKey; + private byte[] timestamp; + + // Stuff we always generate + private byte[] lmHash = null; + private byte[] lmResponse = null; + private byte[] ntlmHash = null; + private byte[] ntlmResponse = null; + private byte[] ntlmv2Hash = null; + private byte[] lmv2Hash = null; + private byte[] lmv2Response = null; + private byte[] ntlmv2Blob = null; + private byte[] ntlmv2Response = null; + private byte[] ntlm2SessionResponse = null; + private byte[] lm2SessionResponse = null; + private byte[] lmUserSessionKey = null; + private byte[] ntlmUserSessionKey = null; + private byte[] ntlmv2UserSessionKey = null; + private byte[] ntlm2SessionResponseUserSessionKey = null; + private byte[] lanManagerSessionKey = null; + + public CipherGen( + final Random random, + final long currentTime, + final String domain, + final String user, + final String password, + final byte[] challenge, + final String target, + final byte[] targetInformation + ) { + this.random = random; + this.currentTime = currentTime; + + this.domain = domain; + this.user = user; + this.password = password; + this.challenge = challenge; + this.targetInformation = targetInformation; + } + + /** Calculate and return client challenge */ + private byte[] getClientChallenge() { + if (clientChallenge == null) { + clientChallenge = makeRandomChallenge(random); + } + return clientChallenge; + } + + /** Calculate and return second client challenge */ + private byte[] getClientChallenge2() { + if (clientChallenge2 == null) { + clientChallenge2 = makeRandomChallenge(random); + } + return clientChallenge2; + } + + /** Calculate and return random secondary key */ + public byte[] getSecondaryKey() { + if (secondaryKey == null) { + secondaryKey = makeSecondaryKey(random); + } + return secondaryKey; + } + + /** Calculate and return the LMHash */ + private byte[] getLMHash() throws NtlmException { + if (lmHash == null) { + lmHash = lmHash(password); + } + return lmHash; + } + + /** Calculate and return the LMResponse */ + public byte[] getLMResponse() throws NtlmException { + if (lmResponse == null) { + lmResponse = lmResponse(getLMHash(), challenge); + } + return lmResponse; + } + + /** Calculate and return the NTLMHash */ + private byte[] getNTLMHash() throws NtlmException { + if (ntlmHash == null) { + ntlmHash = ntlmHash(password); + } + return ntlmHash; + } + + /** Calculate and return the NTLMResponse */ + public byte[] getNTLMResponse() throws NtlmException { + if (ntlmResponse == null) { + ntlmResponse = lmResponse(getNTLMHash(), challenge); + } + return ntlmResponse; + } + + /** Calculate the LMv2 hash */ + private byte[] getLMv2Hash() throws NtlmException { + if (lmv2Hash == null) { + lmv2Hash = lmv2Hash(domain, user, getNTLMHash()); + } + return lmv2Hash; + } + + /** Calculate the NTLMv2 hash */ + private byte[] getNTLMv2Hash() throws NtlmException { + if (ntlmv2Hash == null) { + ntlmv2Hash = ntlmv2Hash(domain, user, getNTLMHash()); + } + return ntlmv2Hash; + } + + /** Calculate a timestamp */ + private byte[] getTimestamp() { + if (timestamp == null) { + long time = this.currentTime; + time += 11644473600000l; // milliseconds from January 1, 1601 -> epoch. + time *= 10000; // tenths of a microsecond. + // convert to little-endian byte array. + timestamp = new byte[8]; + for (int i = 0; i < 8; i++) { + timestamp[i] = (byte) time; + time >>>= 8; + } + } + return timestamp; + } + + /** Calculate the NTLMv2Blob */ + private byte[] getNTLMv2Blob() { + if (ntlmv2Blob == null) { + ntlmv2Blob = createBlob(getClientChallenge2(), targetInformation, getTimestamp()); + } + return ntlmv2Blob; + } + + /** + * Creates the NTLMv2 blob from the given target information block and + * client challenge. + * + * @param targetInformation + * The target information block from the Type 2 message. + * @param clientChallenge + * The random 8-byte client challenge. + * + * @return The blob, used in the calculation of the NTLMv2 Response. + */ + private static byte[] createBlob( + final byte[] clientChallenge, + final byte[] targetInformation, + final byte[] timestamp + ) { + final byte[] blobSignature = new byte[] { (byte) 0x01, (byte) 0x01, (byte) 0x00, (byte) 0x00 }; + final byte[] reserved = new byte[] { (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00 }; + final byte[] unknown1 = new byte[] { (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00 }; + final byte[] unknown2 = new byte[] { (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00 }; + final byte[] blob = new byte[blobSignature.length + + reserved.length + + timestamp.length + + 8 + + unknown1.length + + targetInformation.length + + unknown2.length]; + int offset = 0; + System.arraycopy(blobSignature, 0, blob, offset, blobSignature.length); + offset += blobSignature.length; + System.arraycopy(reserved, 0, blob, offset, reserved.length); + offset += reserved.length; + System.arraycopy(timestamp, 0, blob, offset, timestamp.length); + offset += timestamp.length; + System.arraycopy(clientChallenge, 0, blob, offset, 8); + offset += 8; + System.arraycopy(unknown1, 0, blob, offset, unknown1.length); + offset += unknown1.length; + System.arraycopy(targetInformation, 0, blob, offset, targetInformation.length); + offset += targetInformation.length; + System.arraycopy(unknown2, 0, blob, offset, unknown2.length); + offset += unknown2.length; + return blob; + } + + /** Calculate the NTLMv2Response */ + public byte[] getNTLMv2Response() throws NtlmException { + if (ntlmv2Response == null) { + ntlmv2Response = lmv2Response(getNTLMv2Hash(), challenge, getNTLMv2Blob()); + } + return ntlmv2Response; + } + + /** Calculate the LMv2Response */ + public byte[] getLMv2Response() throws NtlmException { + if (lmv2Response == null) { + lmv2Response = lmv2Response(getLMv2Hash(), challenge, getClientChallenge()); + } + return lmv2Response; + } + + /** Get NTLM2SessionResponse */ + public byte[] getNTLM2SessionResponse() throws NtlmException { + if (ntlm2SessionResponse == null) { + ntlm2SessionResponse = ntlm2SessionResponse(getNTLMHash(), challenge, getClientChallenge()); + } + return ntlm2SessionResponse; + } + + /** + * Calculates the NTLM2 Session Response for the given challenge, using the + * specified password and client challenge. + * + * @param ntlmHash + * @param challenge + * @param clientChallenge + * @return The NTLM2 Session Response. This is placed in the NTLM response + * field of the Type 3 message; the LM response field contains the + * client challenge, null-padded to 24 bytes. + */ + private static byte[] ntlm2SessionResponse( + final byte[] ntlmHash, + final byte[] challenge, + final byte[] clientChallenge + ) throws NtlmException { + try { + final MessageDigest md5 = EncryptionUtils.getMD5(); + md5.update(challenge); + md5.update(clientChallenge); + final byte[] digest = md5.digest(); + + final byte[] sessionHash = new byte[8]; + System.arraycopy(digest, 0, sessionHash, 0, 8); + return lmResponse(ntlmHash, sessionHash); + } catch (final NtlmException e) { + throw (NtlmException) e; + } catch (final Exception e) { + throw new NtlmException(e.getMessage(), e); + } + } + + /** + * Creates the LM Response from the given hash and Type 2 challenge. + * + * @param hash + * The LM or NTLM Hash. + * @param challenge + * The server challenge from the Type 2 message. + * + * @return The response (either LM or NTLM, depending on the provided hash). + */ + private static byte[] lmResponse(final byte[] hash, final byte[] challenge) throws NtlmException { + try { + final byte[] keyBytes = new byte[21]; + System.arraycopy(hash, 0, keyBytes, 0, 16); + final Key lowKey = createDESKey(keyBytes, 0); + final Key middleKey = createDESKey(keyBytes, 7); + final Key highKey = createDESKey(keyBytes, 14); + final Cipher des = Cipher.getInstance("DES/ECB/NoPadding"); + des.init(Cipher.ENCRYPT_MODE, lowKey); + final byte[] lowResponse = des.doFinal(challenge); + des.init(Cipher.ENCRYPT_MODE, middleKey); + final byte[] middleResponse = des.doFinal(challenge); + des.init(Cipher.ENCRYPT_MODE, highKey); + final byte[] highResponse = des.doFinal(challenge); + final byte[] lmResponse = new byte[24]; + System.arraycopy(lowResponse, 0, lmResponse, 0, 8); + System.arraycopy(middleResponse, 0, lmResponse, 8, 8); + System.arraycopy(highResponse, 0, lmResponse, 16, 8); + return lmResponse; + } catch (final Exception e) { + throw new NtlmException(e.getMessage(), e); + } + } + + /** + * Creates the LM Hash of the user's password. + * + * @param password + * The password. + * + * @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 { + try { + final byte[] oemPassword = password.toUpperCase(Locale.ROOT).getBytes(NTLMEngineUtils.DEFAULT_CHARSET); + + final int length = Math.min(oemPassword.length, 14); + final byte[] keyBytes = new byte[14]; + System.arraycopy(oemPassword, 0, keyBytes, 0, length); + final Key lowKey = createDESKey(keyBytes, 0); + final Key highKey = createDESKey(keyBytes, 7); + final byte[] magicConstant = "KGS!@#$%".getBytes(NTLMEngineUtils.DEFAULT_CHARSET); + final Cipher des = Cipher.getInstance("DES/ECB/NoPadding"); + des.init(Cipher.ENCRYPT_MODE, lowKey); + final byte[] lowHash = des.doFinal(magicConstant); + des.init(Cipher.ENCRYPT_MODE, highKey); + final byte[] highHash = des.doFinal(magicConstant); + final byte[] lmHash = new byte[16]; + System.arraycopy(lowHash, 0, lmHash, 0, 8); + System.arraycopy(highHash, 0, lmHash, 8, 8); + return lmHash; + } catch (final Exception e) { + throw new NtlmException(e.getMessage(), e); + } + } + + /** + * Creates a DES encryption key from the given key material. + * + * @param bytes + * A byte array containing the DES key material. + * @param offset + * The offset in the given byte array at which the 7-byte key + * material starts. + * + * @return A DES encryption key created from the key material starting at + * the specified offset in the given byte array. + */ + private static Key createDESKey(final byte[] bytes, final int offset) { + final byte[] keyBytes = new byte[7]; + System.arraycopy(bytes, offset, keyBytes, 0, 7); + final byte[] material = new byte[8]; + material[0] = keyBytes[0]; + material[1] = (byte) ((keyBytes[0] << 7) | ((keyBytes[1] & 0xff) >>> 1)); + material[2] = (byte) ((keyBytes[1] << 6) | ((keyBytes[2] & 0xff) >>> 2)); + material[3] = (byte) ((keyBytes[2] << 5) | ((keyBytes[3] & 0xff) >>> 3)); + material[4] = (byte) ((keyBytes[3] << 4) | ((keyBytes[4] & 0xff) >>> 4)); + material[5] = (byte) ((keyBytes[4] << 3) | ((keyBytes[5] & 0xff) >>> 5)); + material[6] = (byte) ((keyBytes[5] << 2) | ((keyBytes[6] & 0xff) >>> 6)); + material[7] = (byte) (keyBytes[6] << 1); + oddParity(material); + return new SecretKeySpec(material, "DES"); + } + + /** + * Applies odd parity to the given byte array. + * + * @param bytes + * The data whose parity bits are to be adjusted for odd parity. + */ + private static void oddParity(final byte[] bytes) { + for (int i = 0; i < bytes.length; i++) { + final byte b = bytes[i]; + final boolean needsParity = + (((b >>> 7) ^ (b >>> 6) ^ (b >>> 5) ^ (b >>> 4) ^ (b >>> 3) ^ (b >>> 2) ^ (b >>> 1)) & 0x01) == 0; + if (needsParity) { + bytes[i] |= (byte) 0x01; + } else { + bytes[i] &= (byte) 0xfe; + } + } + } + + /** Calculate and return LM2 session response */ + public byte[] getLM2SessionResponse() { + if (lm2SessionResponse == null) { + final byte[] clntChallenge = getClientChallenge(); + lm2SessionResponse = new byte[24]; + System.arraycopy(clntChallenge, 0, lm2SessionResponse, 0, clntChallenge.length); + Arrays.fill(lm2SessionResponse, clntChallenge.length, lm2SessionResponse.length, (byte) 0x00); + } + return lm2SessionResponse; + } + + /** Get LMUserSessionKey */ + public byte[] getLMUserSessionKey() throws NtlmException { + if (lmUserSessionKey == null) { + lmUserSessionKey = new byte[16]; + System.arraycopy(getLMHash(), 0, lmUserSessionKey, 0, 8); + Arrays.fill(lmUserSessionKey, 8, 16, (byte) 0x00); + } + return lmUserSessionKey; + } + + /** Get NTLMUserSessionKey */ + public byte[] getNTLMUserSessionKey() throws NtlmException { + if (ntlmUserSessionKey == null) { + final MD4 md4 = new MD4(); + md4.update(getNTLMHash()); + ntlmUserSessionKey = md4.getOutput(); + } + return ntlmUserSessionKey; + } + + /** GetNTLMv2UserSessionKey */ + public byte[] getNTLMv2UserSessionKey() throws NtlmException { + if (ntlmv2UserSessionKey == null) { + final byte[] ntlmv2hash = getNTLMv2Hash(); + final byte[] truncatedResponse = new byte[16]; + System.arraycopy(getNTLMv2Response(), 0, truncatedResponse, 0, 16); + ntlmv2UserSessionKey = hmacMD5(truncatedResponse, ntlmv2hash); + } + return ntlmv2UserSessionKey; + } + + /** Get NTLM2SessionResponseUserSessionKey */ + public byte[] getNTLM2SessionResponseUserSessionKey() throws NtlmException { + if (ntlm2SessionResponseUserSessionKey == null) { + final byte[] ntlm2SessionResponseNonce = getLM2SessionResponse(); + final byte[] sessionNonce = new byte[challenge.length + ntlm2SessionResponseNonce.length]; + System.arraycopy(challenge, 0, sessionNonce, 0, challenge.length); + System.arraycopy(ntlm2SessionResponseNonce, 0, sessionNonce, challenge.length, ntlm2SessionResponseNonce.length); + ntlm2SessionResponseUserSessionKey = hmacMD5(sessionNonce, getNTLMUserSessionKey()); + } + return ntlm2SessionResponseUserSessionKey; + } + + /** Get LAN Manager session key */ + public byte[] getLanManagerSessionKey() throws NtlmException { + if (lanManagerSessionKey == null) { + try { + final byte[] keyBytes = new byte[14]; + System.arraycopy(getLMHash(), 0, keyBytes, 0, 8); + Arrays.fill(keyBytes, 8, keyBytes.length, (byte) 0xbd); + final Key lowKey = createDESKey(keyBytes, 0); + final Key highKey = createDESKey(keyBytes, 7); + final byte[] truncatedResponse = new byte[8]; + System.arraycopy(getLMResponse(), 0, truncatedResponse, 0, truncatedResponse.length); + Cipher des = Cipher.getInstance("DES/ECB/NoPadding"); + des.init(Cipher.ENCRYPT_MODE, lowKey); + final byte[] lowPart = des.doFinal(truncatedResponse); + des = Cipher.getInstance("DES/ECB/NoPadding"); + des.init(Cipher.ENCRYPT_MODE, highKey); + final byte[] highPart = des.doFinal(truncatedResponse); + lanManagerSessionKey = new byte[16]; + System.arraycopy(lowPart, 0, lanManagerSessionKey, 0, lowPart.length); + System.arraycopy(highPart, 0, lanManagerSessionKey, lowPart.length, highPart.length); + } catch (final Exception e) { + throw new NtlmException(e.getMessage(), e); + } + } + return lanManagerSessionKey; + } + + /** + * Creates the NTLM Hash of the user's password. + * + * @param password + * The password. + * + * @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 { + if (NTLMEngineUtils.UNICODE_LITTLE_UNMARKED == null) { + throw new NtlmException("Unicode not supported"); + } + final byte[] unicodePassword = password.getBytes(NTLMEngineUtils.UNICODE_LITTLE_UNMARKED); + final MD4 md4 = new MD4(); + md4.update(unicodePassword); + return md4.getOutput(); + } + + /** + * Creates the LMv2 Hash of the user's password. + * + * @return The LMv2 Hash, used in the calculation of the NTLMv2 and LMv2 + * Responses. + */ + private static byte[] lmv2Hash(final String domain, final String user, final byte[] ntlmHash) throws NtlmException { + if (NTLMEngineUtils.UNICODE_LITTLE_UNMARKED == null) { + throw new NtlmException("Unicode not supported"); + } + final HMACMD5 hmacMD5 = new HMACMD5(ntlmHash); + // Upper case username, upper case domain! + hmacMD5.update(user.toUpperCase(Locale.ROOT).getBytes(NTLMEngineUtils.UNICODE_LITTLE_UNMARKED)); + if (domain != null) { + hmacMD5.update(domain.toUpperCase(Locale.ROOT).getBytes(NTLMEngineUtils.UNICODE_LITTLE_UNMARKED)); + } + return hmacMD5.getOutput(); + } + + /** + * Creates the NTLMv2 Hash of the user's password. + * + * @return The NTLMv2 Hash, used in the calculation of the NTLMv2 and LMv2 + * Responses. + */ + private static byte[] ntlmv2Hash(final String domain, final String user, final byte[] ntlmHash) throws NtlmException { + if (NTLMEngineUtils.UNICODE_LITTLE_UNMARKED == null) { + throw new NtlmException("Unicode not supported"); + } + final HMACMD5 hmacMD5 = new HMACMD5(ntlmHash); + // Upper case username, mixed case target!! + hmacMD5.update(user.toUpperCase(Locale.ROOT).getBytes(NTLMEngineUtils.UNICODE_LITTLE_UNMARKED)); + if (domain != null) { + hmacMD5.update(domain.getBytes(NTLMEngineUtils.UNICODE_LITTLE_UNMARKED)); + } + return hmacMD5.getOutput(); + } + + /** + * Creates the LMv2 Response from the given hash, client data, and Type 2 + * challenge. + * + * @param hash + * The NTLMv2 Hash. + * @param clientData + * The client data (blob or client challenge). + * @param challenge + * The server challenge from the Type 2 message. + * + * @return The response (either NTLMv2 or LMv2, depending on the client + * data). + */ + private static byte[] lmv2Response(final byte[] hash, final byte[] challenge, final byte[] clientData) { + final HMACMD5 hmacMD5 = new HMACMD5(hash); + hmacMD5.update(challenge); + hmacMD5.update(clientData); + final byte[] mac = hmacMD5.getOutput(); + final byte[] lmv2Response = new byte[mac.length + clientData.length]; + System.arraycopy(mac, 0, lmv2Response, 0, mac.length); + System.arraycopy(clientData, 0, lmv2Response, mac.length, clientData.length); + return lmv2Response; + } + + /** Calculate a challenge block */ + private static byte[] makeRandomChallenge(final Random random) { + final byte[] rval = new byte[8]; + synchronized (random) { + random.nextBytes(rval); + } + return rval; + } + + /** Calculate a 16-byte secondary key */ + private static byte[] makeSecondaryKey(final Random random) { + final byte[] rval = new byte[16]; + synchronized (random) { + random.nextBytes(rval); + } + return rval; + } + + /** Calculates HMAC-MD5 */ + private static byte[] hmacMD5(final byte[] value, final byte[] key) { + final HMACMD5 hmacMD5 = new HMACMD5(key); + hmacMD5.update(value); + return hmacMD5.getOutput(); + } +} diff --git a/src/main/java/org/metricshub/winrm/light/EncryptionUtils.java b/src/main/java/org/metricshub/winrm/light/EncryptionUtils.java new file mode 100644 index 0000000..9108c14 --- /dev/null +++ b/src/main/java/org/metricshub/winrm/light/EncryptionUtils.java @@ -0,0 +1,85 @@ +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 java.security.InvalidKeyException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import javax.crypto.Cipher; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** + * Code from io.cloudsoft.winrm4j.client.encryption.WinrmEncryptionUtils + * release 0.12.3 @link https://github.com/cloudsoft/winrm4j + */ +public class EncryptionUtils { + + private EncryptionUtils() {} + + private static final String HMAC_MD5 = "HmacMD5"; + private static final String RC4 = "RC4"; + + public static MessageDigest getMD5() { + try { + return MessageDigest.getInstance("MD5"); + } catch (final NoSuchAlgorithmException ex) { + throw new IllegalStateException("MD5 message digest doesn't seem to exist - fatal error: " + ex.getMessage(), ex); + } + } + + public static byte[] md5digest(byte[] bytes) { + final MessageDigest handle = getMD5(); + handle.update(bytes); + return handle.digest(); + } + + public static Cipher arc4(byte[] key) { + // engine needs to be stateful + try { + final Cipher rc4 = Cipher.getInstance(RC4); + rc4.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, RC4)); + return rc4; + } catch (final Exception e) { + throw new IllegalStateException(e); + } + } + + /** Calculates RC4 */ + public static byte[] calculateRC4(final byte[] value, final byte[] key) { + try { + return arc4(key).doFinal(value); + } catch (final Exception e) { + throw new IllegalStateException(e); + } + } + + public static byte[] hmacMd5(byte[] key, byte[] body) { + try { + final SecretKeySpec keySpec = new SecretKeySpec(key, HMAC_MD5); + final Mac mac = Mac.getInstance(HMAC_MD5); + mac.init(keySpec); + return mac.doFinal(body); + } catch (final NoSuchAlgorithmException | InvalidKeyException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/src/main/java/org/metricshub/winrm/light/Envelopes.java b/src/main/java/org/metricshub/winrm/light/Envelopes.java new file mode 100644 index 0000000..1969d57 --- /dev/null +++ b/src/main/java/org/metricshub/winrm/light/Envelopes.java @@ -0,0 +1,221 @@ +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 java.util.UUID; + +/** + * WS-Management SOAP envelope templates — the only "WSDL" the light client needs. + * Covers Identify, WQL Enumerate/Pull, and the command shell lifecycle + * (Create / Command / Receive / Signal / Delete). + */ +final class Envelopes { + + private static final String SOAP = "http://www.w3.org/2003/05/soap-envelope"; + private static final String WSA = "http://schemas.xmlsoap.org/ws/2004/08/addressing"; + private static final String WSMAN = "http://schemas.dmtf.org/wbem/wsman/1/wsman.xsd"; + private static final String WSEN = "http://schemas.xmlsoap.org/ws/2004/09/enumeration"; + private static final String RSP = "http://schemas.microsoft.com/wbem/wsman/1/windows/shell"; + private static final String ANONYMOUS = "http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous"; + + private static final String ACTION_ENUMERATE = "http://schemas.xmlsoap.org/ws/2004/09/enumeration/Enumerate"; + private static final String ACTION_PULL = "http://schemas.xmlsoap.org/ws/2004/09/enumeration/Pull"; + private static final String ACTION_CREATE = "http://schemas.xmlsoap.org/ws/2004/09/transfer/Create"; + private static final String ACTION_DELETE = "http://schemas.xmlsoap.org/ws/2004/09/transfer/Delete"; + private static final String ACTION_COMMAND = RSP + "/Command"; + private static final String ACTION_RECEIVE = RSP + "/Receive"; + private static final String ACTION_SIGNAL = RSP + "/Signal"; + + static final String SHELL_RESOURCE_URI = RSP + "/cmd"; + static final String TERMINATE_CODE = RSP + "/signal/terminate"; + static final String COMMAND_STATE_DONE = RSP + "/CommandState/Done"; + + private static final int MAX_ENVELOPE_SIZE = 153600; + + private Envelopes() {} + + // --- WQL --------------------------------------------------------------- + + static String enumerateWql(final String url, final String namespace, final String wql, final long timeoutMs) { + return ( + envelopeOpen(false) + + header(url, wmiResourceUri(namespace), ACTION_ENUMERATE, timeoutMs, null, null) + + "" + + "" + + "32000" + + "" + + escape(wql) + + "" + + "" + ); + } + + static String pull(final String url, final String namespace, final String context, final long timeoutMs) { + return ( + envelopeOpen(false) + + header(url, wmiResourceUri(namespace), ACTION_PULL, timeoutMs, null, null) + + "" + + "" + + escape(context) + + "" + + "32000" + + "" + ); + } + + // --- Command shell ----------------------------------------------------- + + static String createShell(final String url, final String workingDirectory, final long timeoutMs) { + final String optionSet = + "" + + "TRUE" + + "437" + + ""; + final String workingDir = (workingDirectory == null || workingDirectory.trim().isEmpty()) + ? "" + : "" + escape(workingDirectory) + ""; + return ( + envelopeOpen(true) + + header(url, SHELL_RESOURCE_URI, ACTION_CREATE, timeoutMs, null, optionSet) + + "" + + "stdin" + + "stdout stderr" + + workingDir + + "" + ); + } + + static String command(final String url, final String shellId, final String commandLine, final long timeoutMs) { + final String optionSet = + "" + + "TRUE" + + "FALSE" + + ""; + return ( + envelopeOpen(true) + + header(url, SHELL_RESOURCE_URI, ACTION_COMMAND, timeoutMs, shellSelector(shellId), optionSet) + + "" + + escape(commandLine) + + "" + ); + } + + static String receive(final String url, final String shellId, final String commandId, final long timeoutMs) { + return ( + envelopeOpen(true) + + header(url, SHELL_RESOURCE_URI, ACTION_RECEIVE, timeoutMs, shellSelector(shellId), null) + + "stdout stderr" + ); + } + + static String signal(final String url, final String shellId, final String commandId, final long timeoutMs) { + return ( + envelopeOpen(true) + + header(url, SHELL_RESOURCE_URI, ACTION_SIGNAL, timeoutMs, shellSelector(shellId), null) + + "" + + TERMINATE_CODE + + "" + ); + } + + static String deleteShell(final String url, final String shellId, final long timeoutMs) { + return ( + envelopeOpen(true) + + header(url, SHELL_RESOURCE_URI, ACTION_DELETE, timeoutMs, shellSelector(shellId), null) + + "" + ); + } + + // --- helpers ----------------------------------------------------------- + + private static String wmiResourceUri(final String namespace) { + return "http://schemas.microsoft.com/wbem/wsman/1/wmi/" + namespace + "/*"; + } + + private static String shellSelector(final String shellId) { + return ( + "" + escape(shellId) + "" + ); + } + + private static String header( + final String url, + final String resourceUri, + final String action, + final long timeoutMs, + final String selectorSet, + final String optionSet + ) { + final long seconds = Math.max(1, timeoutMs / 1000); + return ( + "" + + "" + + url + + "" + + "" + + resourceUri + + "" + + "" + + ANONYMOUS + + "" + + "" + + action + + "" + + "" + + MAX_ENVELOPE_SIZE + + "" + + "uuid:" + + UUID.randomUUID().toString().toUpperCase() + + "" + + "" + + (selectorSet == null ? "" : selectorSet) + + (optionSet == null ? "" : optionSet) + + "PT" + + seconds + + "S" + + "" + ); + } + + private static String envelopeOpen(final boolean shell) { + return ( + "" + ); + } + + private static String escape(final String s) { + return s.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """); + } +} diff --git a/src/main/java/org/metricshub/winrm/light/HMACMD5.java b/src/main/java/org/metricshub/winrm/light/HMACMD5.java new file mode 100644 index 0000000..319dbf4 --- /dev/null +++ b/src/main/java/org/metricshub/winrm/light/HMACMD5.java @@ -0,0 +1,82 @@ +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 java.security.MessageDigest; + +/** + * Cryptography support - HMACMD5 - algorithmically based on various web + * resources by Karl Wright + * + * Code from io.cloudsoft.winrm4j.client.ntlm.forks.httpclient.NTLMEngineImpl + * release 0.12.3 @link https://github.com/cloudsoft/winrm4j + * io.cloudsoft.winrm4j.client.ntlm.forks.httpclient is a fork of apache-httpclient 4.5.13 + */ +public class HMACMD5 { + + private final byte[] ipad; + private final byte[] opad; + private final MessageDigest md5; + + HMACMD5(final byte[] input) { + byte[] key = input; + md5 = EncryptionUtils.getMD5(); + + // Initialize the pad buffers with the key + ipad = new byte[64]; + opad = new byte[64]; + + int keyLength = key.length; + if (keyLength > 64) { + // Use MD5 of the key instead, as described in RFC 2104 + md5.update(key); + key = md5.digest(); + keyLength = key.length; + } + int i = 0; + while (i < keyLength) { + ipad[i] = (byte) (key[i] ^ (byte) 0x36); + opad[i] = (byte) (key[i] ^ (byte) 0x5c); + i++; + } + while (i < 64) { + ipad[i] = (byte) 0x36; + opad[i] = (byte) 0x5c; + i++; + } + + // Very important: processChallenge the digest with the ipad buffer + md5.reset(); + md5.update(ipad); + } + + /** Grab the current digest. This is the "answer". */ + byte[] getOutput() { + final byte[] digest = md5.digest(); + md5.update(opad); + return md5.digest(digest); + } + + /** Update by adding a complete array */ + void update(final byte[] input) { + md5.update(input); + } +} diff --git a/src/main/java/org/metricshub/winrm/light/HttpTransport.java b/src/main/java/org/metricshub/winrm/light/HttpTransport.java new file mode 100644 index 0000000..fe39532 --- /dev/null +++ b/src/main/java/org/metricshub/winrm/light/HttpTransport.java @@ -0,0 +1,254 @@ +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 java.io.BufferedInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** + * Minimal HTTP/1.1 client over a single kept-alive TCP socket. WinRM's NTLM authentication is + * bound to the transport connection (not the request), so every request in a session must ride + * the same socket — which is exactly why this does not use a pooling client. + */ +final class HttpTransport implements AutoCloseable { + + private final String host; + private final int port; + private final int timeoutMillis; + private Socket socket; + private OutputStream out; + private BufferedInputStream in; + + HttpTransport(final String host, final int port, final int timeoutMillis) { + this.host = host; + this.port = port; + this.timeoutMillis = timeoutMillis; + } + + static final class Response { + + final int status; + final List headers; // {name, value}, name lower-cased + final byte[] body; + + Response(final int status, final List headers, final byte[] body) { + this.status = status; + this.headers = headers; + this.body = body; + } + + String firstHeader(final String name) { + final String n = name.toLowerCase(Locale.ROOT); + for (final String[] h : headers) { + if (h[0].equals(n)) { + return h[1]; + } + } + return null; + } + } + + /** Whether a live connection is currently held. */ + boolean isConnected() { + return socket != null && !socket.isClosed(); + } + + private void ensureConnected() throws IOException { + if (isConnected()) { + return; + } + final Socket newSocket = new Socket(); + try { + newSocket.setTcpNoDelay(true); + newSocket.connect(new InetSocketAddress(host, port), timeoutMillis); + // Read timeout slightly above the caller's timeout so the WSMan OperationTimeout fault + // (which the Receive loop retries) reliably arrives before a socket read times out. + newSocket.setSoTimeout(timeoutMillis + 10_000); + socket = newSocket; + out = socket.getOutputStream(); + in = new BufferedInputStream(socket.getInputStream()); + } catch (final IOException e) { + // Never leave a half-open socket in the field, or ensureConnected would skip reconnecting. + try { + newSocket.close(); + } catch (final IOException ignore) { + // best effort + } + socket = null; + out = null; + in = null; + throw e; + } + } + + Response post(final String path, final byte[] body, final String contentType, final String authorization) + throws IOException { + ensureConnected(); + try { + final StringBuilder head = new StringBuilder(); + head.append("POST ").append(path).append(" HTTP/1.1\r\n"); + head.append("Accept: */*\r\n"); + head.append("User-Agent: winrm-java-light\r\n"); + head.append("Content-Length: ").append(body == null ? 0 : body.length).append("\r\n"); + if (contentType != null) { + head.append("Content-Type: ").append(contentType).append("\r\n"); + } + head.append("Host: ").append(host).append(':').append(port).append("\r\n"); + head.append("Connection: Keep-Alive\r\n"); + if (authorization != null) { + head.append("Authorization: ").append(authorization).append("\r\n"); + } + head.append("\r\n"); + + // Send head and body in a single write (one TCP segment), matching the reference client. + final byte[] headBytes = head.toString().getBytes(StandardCharsets.ISO_8859_1); + final ByteArrayOutputStream request = new ByteArrayOutputStream( + headBytes.length + (body == null ? 0 : body.length) + ); + request.write(headBytes); + if (body != null && body.length > 0) { + request.write(body); + } + out.write(request.toByteArray()); + out.flush(); + + return readResponse(); + } catch (final IOException | RuntimeException e) { + // A broken write/read leaves the socket in an unknown state and its read position possibly + // corrupted; close it so the next request establishes a fresh (re-authenticated) connection. + close(); + throw e; + } + } + + private Response readResponse() throws IOException { + final String statusLine = readLine(); + if (statusLine == null) { + throw new IOException("Connection closed by server before response"); + } + final String[] statusParts = statusLine.split(" ", 3); + final int status = Integer.parseInt(statusParts[1]); + + final List headers = new ArrayList<>(); + int contentLength = -1; + boolean chunked = false; + boolean close = false; + String line; + while ((line = readLine()) != null && !line.isEmpty()) { + final int colon = line.indexOf(':'); + if (colon < 0) { + continue; + } + final String name = line.substring(0, colon).trim().toLowerCase(Locale.ROOT); + final String value = line.substring(colon + 1).trim(); + headers.add(new String[] { name, value }); + if ("content-length".equals(name)) { + contentLength = Integer.parseInt(value); + } else if ("transfer-encoding".equals(name) && value.toLowerCase(Locale.ROOT).contains("chunked")) { + chunked = true; + } else if ("connection".equals(name) && value.toLowerCase(Locale.ROOT).contains("close")) { + close = true; + } + } + + final byte[] body; + if (chunked) { + body = readChunked(); + } else if (contentLength >= 0) { + body = readFixed(contentLength); + } else { + body = new byte[0]; + } + + if (close) { + close(); + } + return new Response(status, headers, body); + } + + private String readLine() throws IOException { + final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + int b; + int prev = -1; + while ((b = in.read()) != -1) { + if (prev == '\r' && b == '\n') { + final byte[] raw = buffer.toByteArray(); + return new String(raw, 0, raw.length - 1, StandardCharsets.ISO_8859_1); + } + buffer.write(b); + prev = b; + } + return buffer.size() == 0 ? null : buffer.toString("ISO-8859-1"); + } + + private byte[] readFixed(final int length) throws IOException { + final byte[] buffer = new byte[length]; + int read = 0; + while (read < length) { + final int n = in.read(buffer, read, length - read); + if (n < 0) { + throw new IOException("Unexpected EOF: got " + read + " of " + length + " body bytes"); + } + read += n; + } + return buffer; + } + + private byte[] readChunked() throws IOException { + final ByteArrayOutputStream body = new ByteArrayOutputStream(); + while (true) { + final String sizeLine = readLine(); + if (sizeLine == null) { + throw new IOException("Unexpected EOF in chunked body"); + } + final int semicolon = sizeLine.indexOf(';'); + final int size = Integer.parseInt((semicolon < 0 ? sizeLine : sizeLine.substring(0, semicolon)).trim(), 16); + if (size == 0) { + readLine(); // trailing CRLF after the last chunk + break; + } + body.write(readFixed(size)); + readLine(); // CRLF after each chunk + } + return body.toByteArray(); + } + + @Override + public void close() { + try { + if (socket != null) { + socket.close(); + } + } catch (final IOException ignore) { + // best effort + } finally { + socket = null; + } + } +} diff --git a/src/main/java/org/metricshub/winrm/light/LightWinRMService.java b/src/main/java/org/metricshub/winrm/light/LightWinRMService.java new file mode 100644 index 0000000..d2dcedc --- /dev/null +++ b/src/main/java/org/metricshub/winrm/light/LightWinRMService.java @@ -0,0 +1,187 @@ +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 java.net.URI; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; +import org.metricshub.winrm.Utils; +import org.metricshub.winrm.WindowsRemoteCommandResult; +import org.metricshub.winrm.WindowsRemoteExecutor; +import org.metricshub.winrm.WmiHelper; +import org.metricshub.winrm.exceptions.WinRMException; +import org.metricshub.winrm.exceptions.WindowsRemoteException; +import org.metricshub.winrm.exceptions.WqlQuerySyntaxException; +import org.metricshub.winrm.service.WinRMEndpoint; +import org.metricshub.winrm.service.client.auth.AuthenticationEnum; + +/** + * Dependency-free {@link WindowsRemoteExecutor} backed by {@link WsmanClient}. A drop-in + * alternative to the CXF-based {@code WinRMService}: same public behaviour, no Apache CXF / + * JAX-WS / JAXB stack, and immune by construction to JAXP {@code ServiceLoader} poisoning + * (it uses the JDK-default XML factories). + * + *

Currently supports NTLM over HTTP with message encryption. Kerberos and HTTPS are handled + * by the CXF backend until the corresponding light support lands. + */ +public final class LightWinRMService implements WindowsRemoteExecutor { + + private final WinRMEndpoint winRMEndpoint; + private final WsmanClient client; + + private LightWinRMService(final WinRMEndpoint winRMEndpoint, final WsmanClient client) { + this.winRMEndpoint = winRMEndpoint; + this.client = client; + } + + /** + * Create a light WinRM executor. + * + * @param winRMEndpoint endpoint with credentials (mandatory) + * @param timeout timeout in milliseconds (must be > 0) + * @param ticketCache Kerberos ticket cache path (unused by the light backend) + * @param authentications requested authentication schemes; the light backend supports NTLM + * @return a new {@code LightWinRMService} + * @throws WinRMException on invalid arguments or an unsupported authentication request + */ + public static LightWinRMService createInstance( + final WinRMEndpoint winRMEndpoint, + final long timeout, + final java.nio.file.Path ticketCache, + final List authentications + ) throws WinRMException { + Utils.checkNonNull(winRMEndpoint, "winRMEndpoint"); + Utils.checkArgumentNotZeroOrNegative(timeout, "timeout"); + + if (authentications != null && !authentications.isEmpty() && !authentications.contains(AuthenticationEnum.NTLM)) { + throw new WinRMException( + "The light WinRM backend currently supports only NTLM authentication; " + + "use the CXF backend for " + + authentications + + "." + ); + } + + final URI uri = URI.create(winRMEndpoint.getEndpoint()); + if (!"http".equalsIgnoreCase(uri.getScheme())) { + throw new WinRMException( + "The light WinRM backend currently supports only HTTP; endpoint was " + winRMEndpoint.getEndpoint() + ); + } + final int port = uri.getPort() > 0 ? uri.getPort() : 5985; + + final WsmanClient client = new WsmanClient( + uri.getHost(), + port, + winRMEndpoint.getDomain(), + winRMEndpoint.getUsername(), + new String(winRMEndpoint.getPassword()), + timeout + ); + return new LightWinRMService(winRMEndpoint, client); + } + + @Override + public List> executeWql(final String wqlQuery, final long timeout) + throws TimeoutException, WqlQuerySyntaxException, WindowsRemoteException { + Utils.checkNonNull(wqlQuery, "wqlQuery"); + if (!WmiHelper.isValidWql(wqlQuery)) { + throw new WqlQuerySyntaxException(wqlQuery); + } + Utils.checkArgumentNotZeroOrNegative(timeout, "timeout"); + + // Enforce the caller's timeout as a wall-clock deadline (throwing TimeoutException), matching + // the CXF WinRMService and bounding the WSMan Pull loop. + try { + return Utils.execute( + () -> { + final List> rows = client.wql(winRMEndpoint.getNamespace(), wqlQuery); + final List> result = new ArrayList<>(rows.size()); + for (final Map row : rows) { + result.add(new LinkedHashMap<>(row)); + } + return result; + }, + timeout + ); + } catch (final InterruptedException | ExecutionException e) { + if (e.getCause() != null) { + throw new WinRMException(e.getCause(), e.getCause().getMessage()); + } + throw new WinRMException(e); + } + } + + @Override + public WindowsRemoteCommandResult executeCommand( + final String command, + final String workingDirectory, + final Charset charset, + final long timeout + ) throws WindowsRemoteException, TimeoutException { + Utils.checkNonNull(command, "command"); + Utils.checkArgumentNotZeroOrNegative(timeout, "timeout"); + + // Enforce the caller's timeout as a wall-clock deadline (throwing TimeoutException), matching + // the CXF WinRMService and bounding the WSMan Receive loop. + try { + return Utils.execute( + () -> { + final long start = Utils.getCurrentTimeMillis(); + final WsmanClient.CommandOutput output = client.executeCommand(command, workingDirectory, charset); + final float executionTime = (Utils.getCurrentTimeMillis() - start) / 1000.0f; + return new WindowsRemoteCommandResult(output.stdout, output.stderr, executionTime, output.exitCode); + }, + timeout + ); + } catch (final InterruptedException | ExecutionException e) { + if (e.getCause() != null) { + throw new WinRMException(e.getCause(), e.getCause().getMessage()); + } + throw new WinRMException(e); + } + } + + @Override + public String getHostname() { + return winRMEndpoint.getHostname(); + } + + @Override + public String getUsername() { + return winRMEndpoint.getRawUsername(); + } + + @Override + public char[] getPassword() { + return winRMEndpoint.getPassword(); + } + + @Override + public void close() { + client.close(); + } +} diff --git a/src/main/java/org/metricshub/winrm/light/MD4.java b/src/main/java/org/metricshub/winrm/light/MD4.java new file mode 100644 index 0000000..e323247 --- /dev/null +++ b/src/main/java/org/metricshub/winrm/light/MD4.java @@ -0,0 +1,211 @@ +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. + * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ + */ + +/** + * Cryptography support - MD4. The following class was based loosely on the + * RFC and on code found at http://www.cs.umd.edu/~harry/jotp/src/md.java. + * Code correctness was verified by looking at MD4.java from the jcifs + * library (http://jcifs.samba.org). It was massaged extensively to the + * final form found here by Karl Wright (kwright@metacarta.com). + * + * Code from io.cloudsoft.winrm4j.client.ntlm.forks.httpclient.NTLMEngineImpl + * release 0.12.3 @link https://github.com/cloudsoft/winrm4j + * io.cloudsoft.winrm4j.client.ntlm.forks.httpclient is a fork of apache-httpclient 4.5.13 + */ +public class MD4 { + + private int a = 0x67452301; + private int b = 0xefcdab89; + private int c = 0x98badcfe; + private int d = 0x10325476; + private long count = 0L; + private final byte[] dataBuffer = new byte[64]; + + void update(final byte[] input) { + // We always deal with 512 bits at a time. Correspondingly, there is + // a buffer 64 bytes long that we write data into until it gets + // full. + int curBufferPos = (int) (count & 63L); + int inputIndex = 0; + while (input.length - inputIndex + curBufferPos >= dataBuffer.length) { + // We have enough data to do the next step. Do a partial copy + // and a transform, updating inputIndex and curBufferPos + // accordingly + final int transferAmt = dataBuffer.length - curBufferPos; + System.arraycopy(input, inputIndex, dataBuffer, curBufferPos, transferAmt); + count += transferAmt; + curBufferPos = 0; + inputIndex += transferAmt; + processBuffer(); + } + + // If there's anything left, copy it into the buffer and leave it. + // We know there's not enough left to process. + if (inputIndex < input.length) { + final int transferAmt = input.length - inputIndex; + System.arraycopy(input, inputIndex, dataBuffer, curBufferPos, transferAmt); + count += transferAmt; + curBufferPos += transferAmt; + } + } + + byte[] getOutput() { + // Feed pad/length data into engine. This must round out the input + // to a multiple of 512 bits. + final int bufferIndex = (int) (count & 63L); + final int padLen = (bufferIndex < 56) ? (56 - bufferIndex) : (120 - bufferIndex); + final byte[] postBytes = new byte[padLen + 8]; + // Leading 0x80, specified amount of zero padding, then length in + // bits. + postBytes[0] = (byte) 0x80; + // Fill out the last 8 bytes with the length + for (int i = 0; i < 8; i++) { + postBytes[padLen + i] = (byte) ((count * 8) >>> (8 * i)); + } + + // Update the engine + update(postBytes); + + // Calculate final result + final byte[] result = new byte[16]; + writeULong(result, a, 0); + writeULong(result, b, 4); + writeULong(result, c, 8); + writeULong(result, d, 12); + return result; + } + + private static void writeULong(final byte[] buffer, final int value, final int offset) { + buffer[offset] = (byte) (value & 0xff); + buffer[offset + 1] = (byte) ((value >> 8) & 0xff); + buffer[offset + 2] = (byte) ((value >> 16) & 0xff); + buffer[offset + 3] = (byte) ((value >> 24) & 0xff); + } + + private void processBuffer() { + // Convert current buffer to 16 ulongs + final int[] d = new int[16]; + + for (int i = 0; i < 16; i++) { + d[i] = + (dataBuffer[i * 4] & 0xff) + + ((dataBuffer[i * 4 + 1] & 0xff) << 8) + + ((dataBuffer[i * 4 + 2] & 0xff) << 16) + + ((dataBuffer[i * 4 + 3] & 0xff) << 24); + } + + // Do a round of processing + final int aa = a; + final int bb = b; + final int cc = c; + final int dd = this.d; + round1(d); + round2(d); + round3(d); + a += aa; + b += bb; + c += cc; + this.d += dd; + } + + private void round1(final int[] d) { + a = rotintlft((a + f(b, c, this.d) + d[0]), 3); + this.d = rotintlft((this.d + f(a, b, c) + d[1]), 7); + c = rotintlft((c + f(this.d, a, b) + d[2]), 11); + b = rotintlft((b + f(c, this.d, a) + d[3]), 19); + + a = rotintlft((a + f(b, c, this.d) + d[4]), 3); + this.d = rotintlft((this.d + f(a, b, c) + d[5]), 7); + c = rotintlft((c + f(this.d, a, b) + d[6]), 11); + b = rotintlft((b + f(c, this.d, a) + d[7]), 19); + + a = rotintlft((a + f(b, c, this.d) + d[8]), 3); + this.d = rotintlft((this.d + f(a, b, c) + d[9]), 7); + c = rotintlft((c + f(this.d, a, b) + d[10]), 11); + b = rotintlft((b + f(c, this.d, a) + d[11]), 19); + + a = rotintlft((a + f(b, c, this.d) + d[12]), 3); + this.d = rotintlft((this.d + f(a, b, c) + d[13]), 7); + c = rotintlft((c + f(this.d, a, b) + d[14]), 11); + b = rotintlft((b + f(c, this.d, a) + d[15]), 19); + } + + private void round2(final int[] d) { + a = rotintlft((a + g(b, c, this.d) + d[0] + 0x5a827999), 3); + this.d = rotintlft((this.d + g(a, b, c) + d[4] + 0x5a827999), 5); + c = rotintlft((c + g(this.d, a, b) + d[8] + 0x5a827999), 9); + b = rotintlft((b + g(c, this.d, a) + d[12] + 0x5a827999), 13); + + a = rotintlft((a + g(b, c, this.d) + d[1] + 0x5a827999), 3); + this.d = rotintlft((this.d + g(a, b, c) + d[5] + 0x5a827999), 5); + c = rotintlft((c + g(this.d, a, b) + d[9] + 0x5a827999), 9); + b = rotintlft((b + g(c, this.d, a) + d[13] + 0x5a827999), 13); + + a = rotintlft((a + g(b, c, this.d) + d[2] + 0x5a827999), 3); + this.d = rotintlft((this.d + g(a, b, c) + d[6] + 0x5a827999), 5); + c = rotintlft((c + g(this.d, a, b) + d[10] + 0x5a827999), 9); + b = rotintlft((b + g(c, this.d, a) + d[14] + 0x5a827999), 13); + + a = rotintlft((a + g(b, c, this.d) + d[3] + 0x5a827999), 3); + this.d = rotintlft((this.d + g(a, b, c) + d[7] + 0x5a827999), 5); + c = rotintlft((c + g(this.d, a, b) + d[11] + 0x5a827999), 9); + b = rotintlft((b + g(c, this.d, a) + d[15] + 0x5a827999), 13); + } + + private void round3(final int[] d) { + a = rotintlft((a + h(b, c, this.d) + d[0] + 0x6ed9eba1), 3); + this.d = rotintlft((this.d + h(a, b, c) + d[8] + 0x6ed9eba1), 9); + c = rotintlft((c + h(this.d, a, b) + d[4] + 0x6ed9eba1), 11); + b = rotintlft((b + h(c, this.d, a) + d[12] + 0x6ed9eba1), 15); + + a = rotintlft((a + h(b, c, this.d) + d[2] + 0x6ed9eba1), 3); + this.d = rotintlft((this.d + h(a, b, c) + d[10] + 0x6ed9eba1), 9); + c = rotintlft((c + h(this.d, a, b) + d[6] + 0x6ed9eba1), 11); + b = rotintlft((b + h(c, this.d, a) + d[14] + 0x6ed9eba1), 15); + + a = rotintlft((a + h(b, c, this.d) + d[1] + 0x6ed9eba1), 3); + this.d = rotintlft((this.d + h(a, b, c) + d[9] + 0x6ed9eba1), 9); + c = rotintlft((c + h(this.d, a, b) + d[5] + 0x6ed9eba1), 11); + b = rotintlft((b + h(c, this.d, a) + d[13] + 0x6ed9eba1), 15); + + a = rotintlft((a + h(b, c, this.d) + d[3] + 0x6ed9eba1), 3); + this.d = rotintlft((this.d + h(a, b, c) + d[11] + 0x6ed9eba1), 9); + c = rotintlft((c + h(this.d, a, b) + d[7] + 0x6ed9eba1), 11); + b = rotintlft((b + h(c, this.d, a) + d[15] + 0x6ed9eba1), 15); + } + + private static int f(final int x, final int y, final int z) { + return ((x & y) | (~x & z)); + } + + private static int g(final int x, final int y, final int z) { + return ((x & y) | (x & z) | (y & z)); + } + + private static int h(final int x, final int y, final int z) { + return (x ^ y ^ z); + } + + private static int rotintlft(final int val, final int numbits) { + return ((val << numbits) | (val >>> (32 - numbits))); + } +} diff --git a/src/main/java/org/metricshub/winrm/light/NTLMEngineUtils.java b/src/main/java/org/metricshub/winrm/light/NTLMEngineUtils.java new file mode 100644 index 0000000..3eb5b5e --- /dev/null +++ b/src/main/java/org/metricshub/winrm/light/NTLMEngineUtils.java @@ -0,0 +1,103 @@ +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 java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; + +/** + * Code from io.cloudsoft.winrm4j.client.ntlm.forks.httpclient.NTLMEngineImpl + * release 0.12.3 @link https://github.com/cloudsoft/winrm4j + * io.cloudsoft.winrm4j.client.ntlm.forks.httpclient is a fork of apache-httpclient 4.5.13 + */ +public class NTLMEngineUtils { + + private NTLMEngineUtils() {} + + /** Strip everything from the first dot onward (FQDN host/domain -> short name). */ + static String stripDotSuffix(final String value) { + if (value == null) { + return null; + } + final int index = value.indexOf('.'); + return index != -1 ? value.substring(0, index) : value; + } + + /** Convert a host name to its unqualified (short) form. */ + static String convertHost(final String host) { + return stripDotSuffix(host); + } + + /** Convert a domain name to its unqualified (short) form. */ + static String convertDomain(final String domain) { + return stripDotSuffix(domain); + } + + /** Unicode encoding */ + public static final Charset UNICODE_LITTLE_UNMARKED = StandardCharsets.UTF_16LE; + /** Character encoding */ + public static final Charset DEFAULT_CHARSET = StandardCharsets.US_ASCII; + + // Flags we use; descriptions according to: + // http://davenport.sourceforge.net/ntlm.html + // and + // http://msdn.microsoft.com/en-us/library/cc236650%28v=prot.20%29.aspx + // [MS-NLMP] section 2.2.2.5 + static final int FLAG_REQUEST_UNICODE_ENCODING = 0x00000001; // Unicode string encoding requested + static final int FLAG_REQUEST_SIGN = 0x00000010; // Requests all messages have a signature attached, in NEGOTIATE message. + static final int FLAG_REQUEST_LAN_MANAGER_KEY = 0x00000080; // Request Lan Manager key instead of user session key + static final int FLAG_REQUEST_NTLM_V1 = 0x00000200; // Request NTLMv1 security. MUST be set in NEGOTIATE and CHALLENGE both + static final int FLAG_REQUEST_ALWAYS_SIGN = 0x00008000; // Requests a signature block on all messages. Overridden by REQUEST_SIGN and REQUEST_SEAL. + static final int FLAG_REQUEST_NTLM2_SESSION = 0x00080000; // From server in challenge, requesting NTLM2 session security + static final int FLAG_REQUEST_VERSION = 0x02000000; // Request protocol version + static final int FLAG_TARGETINFO_PRESENT = 0x00800000; // From server in challenge message, indicating targetinfo is present + static final int FLAG_REQUEST_128BIT_KEY_EXCH = 0x20000000; // Request explicit 128-bit key exchange + static final int FLAG_REQUEST_EXPLICIT_KEY_EXCH = 0x40000000; // Request explicit key exchange + static final int FLAG_REQUEST_56BIT_ENCRYPTION = 0x80000000; // Must be used in conjunction with SEAL + + // Code from io.cloudsoft.winrm4j.client.ntlm.NtlmKeys.NegotiateFlags + // release 0.12.3 @link https://github.com/cloudsoft/winrm4j + // expanded set of what is in NTLMEngineImpl + // 0b 10100010_10001010_10000010_00000101 + // 0b 10100010_00001000_10000010_00110001 + public static final long NTLMSSP_NEGOTIATE_EXTENDED_SESSIONSECURITY = 0x00080000L; + public static final long NTLMSSP_NEGOTIATE_KEY_EXCH = 0x40000000L; + static final long NTLMSSP_NEGOTIATE_SEAL = 0x00000020L; + static final long NTLMSSP_NEGOTIATE_SIGN = 0x00000010L; + static final long NTLMSSP_NEGOTIATE_56 = 0x80000000L; + static final long NTLMSSP_NEGOTIATE_128 = 0x20000000L; + static final long NTLMSSP_NEGOTIATE_LM_KEY = 0x00000080L; + + /** + * Find the character set based on the flags. + * @param flags is the flags. + * @return the character set. + */ + static Charset getCharset(final int flags) throws NtlmException { + if ((flags & FLAG_REQUEST_UNICODE_ENCODING) == 0) { + return DEFAULT_CHARSET; + } + if (UNICODE_LITTLE_UNMARKED == null) { + throw new NtlmException("Unicode not supported"); + } + return UNICODE_LITTLE_UNMARKED; + } +} diff --git a/src/main/java/org/metricshub/winrm/light/NTLMMessage.java b/src/main/java/org/metricshub/winrm/light/NTLMMessage.java new file mode 100644 index 0000000..10bdd54 --- /dev/null +++ b/src/main/java/org/metricshub/winrm/light/NTLMMessage.java @@ -0,0 +1,176 @@ +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. + * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ + */ + +/** + * NTLM message generation, base class + * + * Code from io.cloudsoft.winrm4j.client.ntlm.forks.httpclient.NTLMEngineImpl + * release 0.12.3 @link https://github.com/cloudsoft/winrm4j + * io.cloudsoft.winrm4j.client.ntlm.forks.httpclient is a fork of apache-httpclient 4.5.13 + */ +class NTLMMessage { + + /** The signature string as bytes in the default encoding */ + private static final byte[] SIGNATURE; + + static { + final byte[] bytesWithoutNull = "NTLMSSP".getBytes(NTLMEngineUtils.DEFAULT_CHARSET); + final byte[] target = new byte[bytesWithoutNull.length + 1]; + System.arraycopy(bytesWithoutNull, 0, target, 0, bytesWithoutNull.length); + target[bytesWithoutNull.length] = (byte) 0x00; + + SIGNATURE = target; + } + + /** The current response */ + protected byte[] messageContents = null; + + /** The current output position */ + protected int currentOutputPosition = 0; + + /** Constructor to use when message contents are not yet known */ + NTLMMessage() {} + + /** Constructor to use when message bytes are known */ + NTLMMessage(final byte[] message, final int expectedType) throws NtlmException { + messageContents = message; + // Look for NTLM message + if (messageContents.length < SIGNATURE.length) { + throw new NtlmException("NTLM message decoding error - packet too short"); + } + int i = 0; + while (i < SIGNATURE.length) { + if (messageContents[i] != SIGNATURE[i]) { + throw new NtlmException("NTLM message expected - instead got unrecognized bytes"); + } + i++; + } + + // Check to be sure there's a type 2 message indicator next + final int type = readULong(SIGNATURE.length); + if (type != expectedType) { + throw new NtlmException(String.format("NTLM type %d message expected - instead got type %d", expectedType, type)); + } + + currentOutputPosition = messageContents.length; + } + + /** Read a ulong from a position within the message buffer */ + int readULong(final int position) { + return readULong(messageContents, position); + } + + static int readULong(final byte[] src, final int index) { + if (src.length < index + 4) { + return 0; + } + return ( + (src[index] & 0xff) | + ((src[index + 1] & 0xff) << 8) | + ((src[index + 2] & 0xff) << 16) | + ((src[index + 3] & 0xff) << 24) + ); + } + + /** + * Prepares the object to create a response of the given length. + * + * @param maxlength + * the maximum length of the response to prepare, + * including the type and the signature (which this method + * adds). + */ + void prepareResponse(final int maxlength, final int messageType) { + messageContents = new byte[maxlength]; + currentOutputPosition = 0; + addBytes(SIGNATURE); + addULong(messageType); + } + + /** + * Adds the given byte to the response. + * + * @param b + * the byte to add. + */ + private void addByte(final byte b) { + messageContents[currentOutputPosition] = b; + currentOutputPosition++; + } + + /** + * Adds the given bytes to the response. + * + * @param bytes + * the bytes to add. + */ + void addBytes(final byte[] bytes) { + if (bytes == null) { + return; + } + for (final byte b : bytes) { + messageContents[currentOutputPosition] = b; + currentOutputPosition++; + } + } + + /** Adds a USHORT to the response */ + void addUShort(final int value) { + addByte((byte) (value & 0xff)); + addByte((byte) ((value >> 8) & 0xff)); + } + + /** Adds a ULong to the response */ + void addULong(final int value) { + addByte((byte) (value & 0xff)); + addByte((byte) ((value >> 8) & 0xff)); + addByte((byte) ((value >> 16) & 0xff)); + addByte((byte) ((value >> 24) & 0xff)); + } + + /** + * Returns the response that has been generated after shrinking the + * array if required and base64 encodes the response. + * + * @return The response as above. + */ + String getResponse() { + return new String(java.util.Base64.getEncoder().encode(getBytes()), NTLMEngineUtils.DEFAULT_CHARSET); + } + + private byte[] getBytes() { + if (messageContents == null) { + buildMessage(); + } + + if (messageContents.length > currentOutputPosition) { + final byte[] tmp = new byte[currentOutputPosition]; + System.arraycopy(messageContents, 0, tmp, 0, currentOutputPosition); + messageContents = tmp; + } + return messageContents; + } + + protected void buildMessage() { + throw new RuntimeException("Message builder not implemented for " + getClass().getName()); + } +} diff --git a/src/main/java/org/metricshub/winrm/light/NtlmCrypto.java b/src/main/java/org/metricshub/winrm/light/NtlmCrypto.java new file mode 100644 index 0000000..4350ed2 --- /dev/null +++ b/src/main/java/org/metricshub/winrm/light/NtlmCrypto.java @@ -0,0 +1,199 @@ +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 java.io.ByteArrayOutputStream; +import java.util.Arrays; +import java.util.zip.CRC32; + +/** + * NTLM message sealing (encrypt+sign) and unsealing (decrypt+verify) for WinRM's + * {@code multipart/encrypted} framing. Ported verbatim in wire behavior from + * NtlmEncryptionUtils + Decryptor, but operating on byte[] and WinRMSession instead of + * CXF Message + apache NTCredentials. + */ +final class NtlmCrypto { + + static final String ENCRYPTED_CONTENT_TYPE = + "multipart/encrypted;protocol=\"application/HTTP-SPNEGO-session-encrypted\";boundary=\"Encrypted Boundary\""; + + private static final String BOUNDARY_CR = "--Encrypted Boundary\r\n"; + private static final String BOUNDARY_END = "--Encrypted Boundary--\r\n"; + + private NtlmCrypto() {} + + static byte[] encryptAndSign(final WinRMSession session, final byte[] messageBody) { + try (final ByteArrayOutputStream out = new ByteArrayOutputStream()) { + out.write(BOUNDARY_CR.getBytes()); + out.write("\tContent-Type: application/HTTP-SPNEGO-session-encrypted\r\n".getBytes()); + out.write( + String + .format("\tOriginalContent: type=application/soap+xml;charset=UTF-8;Length=%d\r\n", messageBody.length) + .getBytes() + ); + out.write(BOUNDARY_CR.getBytes()); + out.write("\tContent-Type: application/octet-stream\r\n".getBytes()); + + final long seqNum = session.getSequenceNumberOutgoing().incrementAndGet(); + // Seal the body FIRST (advances the stateful cipher), even though the signature is written before it. + final byte[] sealed = session.seal(messageBody); + final ByteArrayOutputStream signature = new ByteArrayOutputStream(); + calculateSignature(session, messageBody, seqNum, signature, true); + + out.write(ByteArrayUtils.getLittleEndianUnsignedInt(signature.size())); + out.write(signature.toByteArray()); + out.write(sealed); + + out.write(BOUNDARY_END.getBytes()); + return out.toByteArray(); + } catch (final Exception e) { + throw new IllegalStateException("Cannot encrypt WinRM message", e); + } + } + + static byte[] decrypt(final WinRMSession session, final byte[] rawBytes) { + final byte[] payload = unwrap(rawBytes); + final int signatureLength = (int) ByteArrayUtils.readLittleEndianUnsignedInt(payload, 0); + final byte[] signatureBytes = Arrays.copyOfRange(payload, 4, 4 + signatureLength); + final byte[] sealedBytes = Arrays.copyOfRange(payload, 4 + signatureLength, payload.length); + + final byte[] unsealed = session.unseal(sealedBytes); + verify(session, unsealed, signatureBytes); + return unsealed; + } + + private static void verify(final WinRMSession session, final byte[] unsealed, final byte[] signatureBytes) { + final long seqNum = ByteArrayUtils.readLittleEndianUnsignedInt(signatureBytes, 12); + final int checkSumOffset = session.hasNegotiateFlag(NTLMEngineUtils.NTLMSSP_NEGOTIATE_EXTENDED_SESSIONSECURITY) + ? 4 + : 8; + final byte[] checksum = Arrays.copyOfRange(signatureBytes, checkSumOffset, 12); + final ByteArrayOutputStream expected = new ByteArrayOutputStream(); + calculateSignature(session, unsealed, seqNum, expected, false); + final byte[] expectedChecksum = Arrays.copyOfRange(expected.toByteArray(), checkSumOffset, 12); + final long expectedSeqNum = ByteArrayUtils.readLittleEndianUnsignedInt(expected.toByteArray(), 12); + if (!Arrays.equals(checksum, expectedChecksum)) { + throw new IllegalStateException( + "Checksum mismatch\n" + + ByteArrayUtils.formatHexDump(checksum) + + "--\n" + + ByteArrayUtils.formatHexDump(expectedChecksum) + ); + } + if (expectedSeqNum != seqNum) { + throw new IllegalStateException(String.format("Sequence number mismatch: %d != %d", seqNum, expectedSeqNum)); + } + session.getSequenceNumberIncoming().incrementAndGet(); + } + + /** + * @param outgoing true to sign an outgoing message (client signing key + client sealing stream), + * false to verify an incoming one (server signing key + server sealing stream). + */ + private static void calculateSignature( + final WinRMSession session, + final byte[] messageBody, + final long seqNum, + final ByteArrayOutputStream signature, + final boolean outgoing + ) { + try { + final byte[] signingKey = outgoing ? session.getClientSigningKey() : session.getServerSigningKey(); + if (session.hasNegotiateFlag(NTLMEngineUtils.NTLMSSP_NEGOTIATE_EXTENDED_SESSIONSECURITY)) { + byte[] checksum = EncryptionUtils.hmacMd5( + signingKey, + ByteArrayUtils.concat(ByteArrayUtils.getLittleEndianUnsignedInt(seqNum), messageBody) + ); + checksum = Arrays.copyOfRange(checksum, 0, 8); + if (session.hasNegotiateFlag(NTLMEngineUtils.NTLMSSP_NEGOTIATE_KEY_EXCH)) { + checksum = outgoing ? session.seal(checksum) : session.unseal(checksum); + } + signature.write(new byte[] { 1, 0, 0, 0 }); + signature.write(checksum); + signature.write(ByteArrayUtils.getLittleEndianUnsignedInt(seqNum)); + } else { + final CRC32 crc = new CRC32(); + crc.update(messageBody); + final long messageCrc = crc.getValue(); + signature.write(new byte[] { 1, 0, 0, 0 }); + signature.write(sealPad(session, outgoing, 0)); + signature.write(sealPad(session, outgoing, messageCrc)); + signature.write(sealPad(session, outgoing, seqNum)); + } + } catch (final Exception e) { + throw new IllegalStateException(e); + } + } + + private static byte[] sealPad(final WinRMSession session, final boolean outgoing, final long value) { + final byte[] v = ByteArrayUtils.getLittleEndianUnsignedInt(value); + return outgoing ? session.seal(v) : session.unseal(v); + } + + /** Strip the two MIME parts and return the raw {sig-len | signature | sealed-body} block. */ + private static byte[] unwrap(final byte[] rawBytes) { + final Cursor c = new Cursor(rawBytes); + c.skipOver(BOUNDARY_CR); + c.skipUntil("\n" + BOUNDARY_CR); + c.skipUntil("\r\n"); + final int start = c.index; + final int end = rawBytes.length - BOUNDARY_END.length(); + return Arrays.copyOfRange(rawBytes, start, end); + } + + /** Minimal forward scanner over the response bytes (ported from Decryptor's skip logic). */ + private static final class Cursor { + + private final byte[] bytes; + private int index; + + Cursor(final byte[] bytes) { + this.bytes = bytes; + } + + void skipOver(final String s) { + final byte[] expected = s.getBytes(); + for (int i = 0; i < expected.length; i++) { + if (index >= bytes.length || expected[i] != bytes[index++]) { + throw new IllegalStateException("Unexpected encrypted-response framing at byte " + index); + } + } + } + + void skipUntil(final String s) { + final byte[] expected = s.getBytes(); + int next = index; + outer:while (true) { + for (int i = 0; i < expected.length; i++) { + if (next + i >= bytes.length) { + throw new IllegalStateException("Encrypted-response framing terminated early looking for delimiter"); + } + if (expected[i] != bytes[next + i]) { + next++; + continue outer; + } + } + index = next + expected.length; + return; + } + } + } +} diff --git a/src/main/java/org/metricshub/winrm/light/NtlmException.java b/src/main/java/org/metricshub/winrm/light/NtlmException.java new file mode 100644 index 0000000..5b15d10 --- /dev/null +++ b/src/main/java/org/metricshub/winrm/light/NtlmException.java @@ -0,0 +1,35 @@ +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. + * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ + */ + +/** Replaces org.apache.http.impl.auth.NTLMEngineException in the ported NTLM code. */ +public class NtlmException extends Exception { + + private static final long serialVersionUID = 1L; + + public NtlmException(final String message) { + super(message); + } + + public NtlmException(final String message, final Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/org/metricshub/winrm/light/Type1Message.java b/src/main/java/org/metricshub/winrm/light/Type1Message.java new file mode 100644 index 0000000..cfa3115 --- /dev/null +++ b/src/main/java/org/metricshub/winrm/light/Type1Message.java @@ -0,0 +1,119 @@ +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 java.util.Locale; + +/** + * Type 1 message assembly class + * + * Code from io.cloudsoft.winrm4j.client.ntlm.forks.httpclient.NTLMEngineImpl + * release 0.12.3 @link https://github.com/cloudsoft/winrm4j + * io.cloudsoft.winrm4j.client.ntlm.forks.httpclient is a fork of apache-httpclient 4.5.13 + */ +class Type1Message extends NTLMMessage { + + private final byte[] hostBytes; + private final byte[] domainBytes; + private final int flags; + + Type1Message(final String domain, final String host, final Integer flags) { + super(); + this.flags = flags == null ? getDefaultFlags() : flags; + + // Strip off domain name from the host! + final String unqualifiedHost = NTLMEngineUtils.convertHost(host); + // Use only the base domain name! + final String unqualifiedDomain = NTLMEngineUtils.convertDomain(domain); + + hostBytes = unqualifiedHost != null ? unqualifiedHost.getBytes(NTLMEngineUtils.UNICODE_LITTLE_UNMARKED) : null; + domainBytes = + unqualifiedDomain != null + ? unqualifiedDomain.toUpperCase(Locale.ROOT).getBytes(NTLMEngineUtils.UNICODE_LITTLE_UNMARKED) + : null; + } + + static int getDefaultFlags() { + return ( // Required flags + NTLMEngineUtils.FLAG_REQUEST_NTLM_V1 | + NTLMEngineUtils.FLAG_REQUEST_NTLM2_SESSION | + NTLMEngineUtils.FLAG_REQUEST_VERSION | + NTLMEngineUtils.FLAG_REQUEST_ALWAYS_SIGN | + NTLMEngineUtils.FLAG_REQUEST_128BIT_KEY_EXCH | + NTLMEngineUtils.FLAG_REQUEST_56BIT_ENCRYPTION | + NTLMEngineUtils.FLAG_REQUEST_UNICODE_ENCODING + ); + } + + /** + * Getting the response involves building the message before returning it + */ + @Override + protected void buildMessage() { + int domainBytesLength = 0; + if (domainBytes != null) { + domainBytesLength = domainBytes.length; + } + int hostBytesLength = 0; + if (hostBytes != null) { + hostBytesLength = hostBytes.length; + } + + // Now, build the message. Calculate its length first, including signature or type. + final int finalLength = 32 + 8 + hostBytesLength + domainBytesLength; + + // Set up the response. This will initialize the signature, message, type, and flags. + prepareResponse(finalLength, 1); + + // Flags. These are the complete set of flags we support. + addULong(flags); + + // Domain length (two times). + addUShort(domainBytesLength); + addUShort(domainBytesLength); + + // Domain offset. + addULong(hostBytesLength + 32 + 8); + + // Host length (two times). + addUShort(hostBytesLength); + addUShort(hostBytesLength); + + // Host offset (always 32 + 8). + addULong(32 + 8); + + // Version + addUShort(0x0105); + // Build + addULong(2600); + // NTLM revision + addUShort(0x0f00); + + // Host (workstation) String. + if (hostBytes != null) { + addBytes(hostBytes); + } + // Domain String. + if (domainBytes != null) { + addBytes(domainBytes); + } + } +} diff --git a/src/main/java/org/metricshub/winrm/light/Type2Message.java b/src/main/java/org/metricshub/winrm/light/Type2Message.java new file mode 100644 index 0000000..904f8d1 --- /dev/null +++ b/src/main/java/org/metricshub/winrm/light/Type2Message.java @@ -0,0 +1,136 @@ +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. + * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ + */ + +/** + * Type 2 message class + * + * Code from io.cloudsoft.winrm4j.client.ntlm.forks.httpclient.NTLMEngineImpl + * release 0.12.3 @link https://github.com/cloudsoft/winrm4j + * io.cloudsoft.winrm4j.client.ntlm.forks.httpclient is a fork of apache-httpclient 4.5.13 + */ +class Type2Message extends NTLMMessage { + + private final byte[] challenge; + private String target; + private byte[] targetInfo; + private final int flags; + + Type2Message(final String messageBody) throws NtlmException { + this(java.util.Base64.getDecoder().decode(messageBody.getBytes(NTLMEngineUtils.DEFAULT_CHARSET))); + } + + private Type2Message(final byte[] message) throws NtlmException { + super(message, 2); + // Type 2 message is laid out as follows: + // First 8 bytes: NTLMSSP[0] + // Next 4 bytes: Ulong, value 2 + // Next 8 bytes, starting at offset 12: target field (2 ushort lengths, 1 ulong offset) + // Next 4 bytes, starting at offset 20: Flags, e.g. 0x22890235 + // Next 8 bytes, starting at offset 24: Challenge + // Next 8 bytes, starting at offset 32: ??? (8 bytes of zeros) + // Next 8 bytes, starting at offset 40: targetinfo field (2 ushort lengths, 1 ulong offset) + // Next 2 bytes, major/minor version number (e.g. 0x05 0x02) + // Next 8 bytes, build number + // Next 2 bytes, protocol version number (e.g. 0x00 0x0f) + // Next, various text fields, and a ushort of value 0 at the end + + // Parse out the rest of the info we need from the message + // The nonce is the 8 bytes starting from the byte in position 24. + challenge = new byte[8]; + readBytes(challenge, 24); + + flags = readULong(20); + + // Do the target! + target = null; + // The TARGET_DESIRED flag is said to not have understood semantics + // in Type2 messages, so use the length of the packet to decide how to proceed instead + if (getMessageLength() >= 12 + 8) { + final byte[] bytes = readSecurityBuffer(12); + if (bytes.length != 0) { + target = new String(bytes, NTLMEngineUtils.getCharset(flags)); + } + } + + // Do the target info! + targetInfo = null; + // TARGET_DESIRED flag cannot be relied on, so use packet length + if (getMessageLength() >= 40 + 8) { + final byte[] bytes = readSecurityBuffer(40); + if (bytes.length != 0) { + targetInfo = bytes; + } + } + } + + /** Get the message length */ + private int getMessageLength() { + return currentOutputPosition; + } + + /** Read a bunch of bytes from a position in the message buffer */ + private void readBytes(final byte[] buffer, final int position) throws NtlmException { + if (messageContents.length < position + buffer.length) { + throw new NtlmException("NTLM: Message too short"); + } + System.arraycopy(messageContents, position, buffer, 0, buffer.length); + } + + /** Read a security buffer from a position within the message buffer */ + private byte[] readSecurityBuffer(final int position) { + final int length = readUShort(messageContents, position); + final int offset = readULong(messageContents, position + 4); + if (messageContents.length < offset + length) { + return new byte[length]; + } + final byte[] buffer = new byte[length]; + System.arraycopy(messageContents, offset, buffer, 0, length); + return buffer; + } + + private static int readUShort(final byte[] src, final int index) { + if (src.length < index + 2) { + return 0; + } + return (src[index] & 0xff) | ((src[index + 1] & 0xff) << 8); + } + + /** Retrieve the challenge */ + byte[] getChallenge() { + return challenge; + } + + /** Retrieve the target */ + String getTarget() { + return target; + } + + /** Retrieve the target info */ + byte[] getTargetInfo() { + return targetInfo; + } + + /** Retrieve the response flags */ + int getFlags() { + return flags; + } +} diff --git a/src/main/java/org/metricshub/winrm/light/Type3Message.java b/src/main/java/org/metricshub/winrm/light/Type3Message.java new file mode 100644 index 0000000..bcaca9c --- /dev/null +++ b/src/main/java/org/metricshub/winrm/light/Type3Message.java @@ -0,0 +1,298 @@ +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 java.nio.charset.Charset; +import java.util.Locale; +import java.util.Random; + +/** + * Type 3 message assembly class + * + * Code from io.cloudsoft.winrm4j.client.ntlm.forks.httpclient.NTLMEngineImpl + * release 0.12.3 @link https://github.com/cloudsoft/winrm4j + * io.cloudsoft.winrm4j.client.ntlm.forks.httpclient is a fork of apache-httpclient 4.5.13 + */ +public class Type3Message extends NTLMMessage { + + /** Secure random generator */ + static final java.security.SecureRandom RND_GEN; + + static { + java.security.SecureRandom rnd = null; + try { + rnd = java.security.SecureRandom.getInstance("SHA1PRNG"); + } catch (final Exception ignore) {} + RND_GEN = rnd; + } + + // Response flags from the type2 message + private final int type2Flags; + + private final byte[] domainBytes; + private final byte[] hostBytes; + private final byte[] userBytes; + + private byte[] lmResp; + private byte[] ntResp; + private final byte[] sessionKey; + private final byte[] exportedSessionKey; + + /** More primitive constructor: don't include cert or previous messages. + */ + Type3Message( + final String domain, + final String host, + final String user, + final String password, + final byte[] nonce, + final int type2Flags, + final String target, + final byte[] targetInformation + ) throws NtlmException { + final Random random = RND_GEN; + if (random == null) { + throw new NtlmException("Random generator not available"); + } + + final long currentTime = System.currentTimeMillis(); + + // Save the flags + this.type2Flags = type2Flags; + + // Strip off domain name from the host! + final String unqualifiedHost = NTLMEngineUtils.convertHost(host); + // Use only the base domain name! + final String unqualifiedDomain = NTLMEngineUtils.convertDomain(domain); + + byte[] responseTargetInformation = targetInformation; + + // Create a cipher generator class. Use domain BEFORE it gets modified! + final CipherGen gen = new CipherGen( + random, + currentTime, + unqualifiedDomain, + user, + password, + nonce, + target, + responseTargetInformation + ); + + // Use the new code to calculate the responses, including v2 if that + // seems warranted. + byte[] userSessionKey; + try { + // This conditional may not work on Windows Server 2008 R2 and above, where it has not yet + // been tested + if ( + ((type2Flags & NTLMEngineUtils.FLAG_TARGETINFO_PRESENT) != 0) && targetInformation != null && target != null + ) { + // NTLMv2 + ntResp = gen.getNTLMv2Response(); + lmResp = gen.getLMv2Response(); + if ((type2Flags & NTLMEngineUtils.FLAG_REQUEST_LAN_MANAGER_KEY) != 0) { + userSessionKey = gen.getLanManagerSessionKey(); + } else { + userSessionKey = gen.getNTLMv2UserSessionKey(); + } + } else { + // NTLMv1 + if ((type2Flags & NTLMEngineUtils.FLAG_REQUEST_NTLM2_SESSION) != 0) { + // NTLM2 session stuff is requested + ntResp = gen.getNTLM2SessionResponse(); + lmResp = gen.getLM2SessionResponse(); + if ((type2Flags & NTLMEngineUtils.FLAG_REQUEST_LAN_MANAGER_KEY) != 0) { + userSessionKey = gen.getLanManagerSessionKey(); + } else { + userSessionKey = gen.getNTLM2SessionResponseUserSessionKey(); + } + } else { + ntResp = gen.getNTLMResponse(); + lmResp = gen.getLMResponse(); + if ((type2Flags & NTLMEngineUtils.FLAG_REQUEST_LAN_MANAGER_KEY) != 0) { + userSessionKey = gen.getLanManagerSessionKey(); + } else { + userSessionKey = gen.getNTLMUserSessionKey(); + } + } + } + } catch (final NtlmException e) { + // This likely means we couldn't find the MD4 hash algorithm - + // fail back to just using LM + ntResp = new byte[0]; + lmResp = gen.getLMResponse(); + if ((type2Flags & NTLMEngineUtils.FLAG_REQUEST_LAN_MANAGER_KEY) != 0) { + userSessionKey = gen.getLanManagerSessionKey(); + } else { + userSessionKey = gen.getLMUserSessionKey(); + } + } + + if ((type2Flags & NTLMEngineUtils.FLAG_REQUEST_SIGN) != 0) { + if ((type2Flags & NTLMEngineUtils.FLAG_REQUEST_EXPLICIT_KEY_EXCH) != 0) { + exportedSessionKey = gen.getSecondaryKey(); + try { + sessionKey = EncryptionUtils.calculateRC4(exportedSessionKey, userSessionKey); + } catch (final Exception e) { + throw new NtlmException(e.getMessage(), e); + } + } else { + sessionKey = userSessionKey; + exportedSessionKey = sessionKey; + } + } else { + sessionKey = null; + exportedSessionKey = null; + } + final Charset charset = NTLMEngineUtils.getCharset(type2Flags); + hostBytes = unqualifiedHost != null ? unqualifiedHost.getBytes(charset) : null; + domainBytes = unqualifiedDomain != null ? unqualifiedDomain.toUpperCase(Locale.ROOT).getBytes(charset) : null; + userBytes = user.getBytes(charset); + } + + int getType2Flags() { + return type2Flags; + } + + byte[] getExportedSessionKey() { + return exportedSessionKey; + } + + /** Assemble the response */ + @Override + protected void buildMessage() { + final int ntRespLen = ntResp.length; + final int lmRespLen = lmResp.length; + + final int domainLen = domainBytes != null ? domainBytes.length : 0; + final int hostLen = hostBytes != null ? hostBytes.length : 0; + final int userLen = userBytes.length; + final int sessionKeyLen; + if (sessionKey != null) { + sessionKeyLen = sessionKey.length; + } else { + sessionKeyLen = 0; + } + + // Calculate the layout within the packet + final int lmRespOffset = 72; // allocate space for the version + final int ntRespOffset = lmRespOffset + lmRespLen; + final int domainOffset = ntRespOffset + ntRespLen; + final int userOffset = domainOffset + domainLen; + final int hostOffset = userOffset + userLen; + final int sessionKeyOffset = hostOffset + hostLen; + final int finalLength = sessionKeyOffset + sessionKeyLen; + + // Start the response. Length includes signature and type + prepareResponse(finalLength, 3); + + // LM Resp Length (twice) + addUShort(lmRespLen); + addUShort(lmRespLen); + + // LM Resp Offset + addULong(lmRespOffset); + + // NT Resp Length (twice) + addUShort(ntRespLen); + addUShort(ntRespLen); + + // NT Resp Offset + addULong(ntRespOffset); + + // Domain length (twice) + addUShort(domainLen); + addUShort(domainLen); + + // Domain offset. + addULong(domainOffset); + + // User Length (twice) + addUShort(userLen); + addUShort(userLen); + + // User offset + addULong(userOffset); + + // Host length (twice) + addUShort(hostLen); + addUShort(hostLen); + + // Host offset + addULong(hostOffset); + + // Session key length (twice) + addUShort(sessionKeyLen); + addUShort(sessionKeyLen); + + // Session key offset + addULong(sessionKeyOffset); + + // Flags. + addULong( + /* + //FLAG_WORKSTATION_PRESENT | + //FLAG_DOMAIN_PRESENT | + + // Required flags + (type2Flags & FLAG_REQUEST_LAN_MANAGER_KEY) | + (type2Flags & FLAG_REQUEST_NTLMv1) | + (type2Flags & FLAG_REQUEST_NTLM2_SESSION) | + + // Protocol version request + FLAG_REQUEST_VERSION | + + // Recommended privacy settings + (type2Flags & FLAG_REQUEST_ALWAYS_SIGN) | + (type2Flags & FLAG_REQUEST_SEAL) | + (type2Flags & FLAG_REQUEST_SIGN) | + + // These must be set according to documentation, based on use of SEAL above + (type2Flags & FLAG_REQUEST_128BIT_KEY_EXCH) | + (type2Flags & FLAG_REQUEST_56BIT_ENCRYPTION) | + (type2Flags & FLAG_REQUEST_EXPLICIT_KEY_EXCH) | + + (type2Flags & FLAG_TARGETINFO_PRESENT) | + (type2Flags & FLAG_REQUEST_UNICODE_ENCODING) | + (type2Flags & FLAG_REQUEST_TARGET) + */ + type2Flags + ); + + // Version + addUShort(0x0105); + // Build + addULong(2600); + // NTLM revision + addUShort(0x0f00); + + // Add the actual data + addBytes(lmResp); + addBytes(ntResp); + addBytes(domainBytes); + addBytes(userBytes); + addBytes(hostBytes); + if (sessionKey != null) { + addBytes(sessionKey); + } + } +} diff --git a/src/main/java/org/metricshub/winrm/light/WinRMSession.java b/src/main/java/org/metricshub/winrm/light/WinRMSession.java new file mode 100644 index 0000000..4c05d49 --- /dev/null +++ b/src/main/java/org/metricshub/winrm/light/WinRMSession.java @@ -0,0 +1,162 @@ +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 java.util.Arrays; +import java.util.concurrent.atomic.AtomicLong; +import javax.crypto.Cipher; + +/** + * Holds the negotiated NTLM session state: the exported session key, the derived + * signing/sealing keys, the two stateful RC4 stream ciphers (one per direction), and + * the message sequence counters. Replaces the CXF/apache-coupled NTCredentialsWithEncryption + * and folds in the key-derivation logic from NtlmKeys. + */ +final class WinRMSession { + + private static final byte[] CLIENT_SIGNING = + "session key to client-to-server signing key magic constant\0".getBytes(); + private static final byte[] SERVER_SIGNING = + "session key to server-to-client signing key magic constant\0".getBytes(); + private static final byte[] CLIENT_SEALING = + "session key to client-to-server sealing key magic constant\0".getBytes(); + private static final byte[] SERVER_SEALING = + "session key to server-to-client sealing key magic constant\0".getBytes(); + + private final String domain; + private final String workstation; + private final String username; + private final String password; + + private long negotiateFlags; + private byte[] clientSigningKey; + private byte[] serverSigningKey; + private Cipher encryptor; + private Cipher decryptor; + private boolean authenticated; + + 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) { + this.domain = domain; + this.workstation = workstation; + this.username = username; + this.password = password; + } + + String getDomain() { + return domain; + } + + String getWorkstation() { + return workstation; + } + + String getUsername() { + return username; + } + + String getPassword() { + return password; + } + + boolean isAuthenticated() { + return authenticated; + } + + boolean hasNegotiateFlag(final long flag) { + return (negotiateFlags & flag) == flag; + } + + byte[] getClientSigningKey() { + return clientSigningKey; + } + + byte[] getServerSigningKey() { + return serverSigningKey; + } + + AtomicLong getSequenceNumberOutgoing() { + return sequenceOutgoing; + } + + AtomicLong getSequenceNumberIncoming() { + return sequenceIncoming; + } + + /** Continue the outgoing (client) RC4 keystream. */ + byte[] seal(final byte[] in) { + return encryptor.update(in); + } + + /** Continue the incoming (server) RC4 keystream. */ + byte[] unseal(final byte[] in) { + return decryptor.update(in); + } + + /** + * Reset to the unauthenticated state so a fresh NTLM handshake runs. Required when the + * underlying TCP connection is lost, because NTLM auth and the RC4 keystreams are bound to it. + */ + void reset() { + authenticated = false; + negotiateFlags = 0; + clientSigningKey = null; + serverSigningKey = null; + encryptor = null; + decryptor = null; + sequenceOutgoing.set(-1); + sequenceIncoming.set(-1); + } + + /** Derive signing/sealing keys from the Type 3 exported session key and open both RC4 ciphers. */ + void applyKeys(final Type3Message type3) { + final byte[] exportedSessionKey = type3.getExportedSessionKey(); + negotiateFlags = type3.getType2Flags(); + + clientSigningKey = signKey(exportedSessionKey, CLIENT_SIGNING); + serverSigningKey = signKey(exportedSessionKey, SERVER_SIGNING); + encryptor = EncryptionUtils.arc4(sealKey(exportedSessionKey, CLIENT_SEALING)); + decryptor = EncryptionUtils.arc4(sealKey(exportedSessionKey, SERVER_SEALING)); + authenticated = true; + } + + private static byte[] signKey(final byte[] exportedSessionKey, final byte[] magic) { + return EncryptionUtils.md5digest(ByteArrayUtils.concat(exportedSessionKey, magic)); + } + + private byte[] sealKey(final byte[] exportedSessionKey, final byte[] magic) { + if (hasNegotiateFlag(NTLMEngineUtils.NTLMSSP_NEGOTIATE_EXTENDED_SESSIONSECURITY)) { + if (hasNegotiateFlag(NTLMEngineUtils.NTLMSSP_NEGOTIATE_128)) { + return EncryptionUtils.md5digest(ByteArrayUtils.concat(exportedSessionKey, magic)); + } + if (hasNegotiateFlag(NTLMEngineUtils.NTLMSSP_NEGOTIATE_56)) { + return EncryptionUtils.md5digest(ByteArrayUtils.concat(Arrays.copyOfRange(exportedSessionKey, 0, 7), magic)); + } + return EncryptionUtils.md5digest(ByteArrayUtils.concat(Arrays.copyOfRange(exportedSessionKey, 0, 5), magic)); + } + if (hasNegotiateFlag(NTLMEngineUtils.NTLMSSP_NEGOTIATE_LM_KEY)) { + throw new UnsupportedOperationException("LM KEY negotiate mode not implemented; use extended session security"); + } + return exportedSessionKey; + } +} diff --git a/src/main/java/org/metricshub/winrm/light/WsmanClient.java b/src/main/java/org/metricshub/winrm/light/WsmanClient.java new file mode 100644 index 0000000..f85463a --- /dev/null +++ b/src/main/java/org/metricshub/winrm/light/WsmanClient.java @@ -0,0 +1,408 @@ +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 java.io.ByteArrayInputStream; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +/** + * Dependency-free WinRM/WS-Management client: NTLM (masqueraded as Negotiate) with message + * encryption over HTTP, templated SOAP envelopes, and JDK-default XML parsing (no Apache CXF, + * JAX-WS, JAXB, or Woodstox). Supports Identify, WQL queries, and command execution. + */ +final class WsmanClient implements AutoCloseable { + + // Type 1 flags: engine defaults + SIGN | SEAL | KEY_EXCH (matches NtlmMasqAsSpnegoScheme). + private static final int TYPE1_FLAGS = (int) (Type1Message.getDefaultFlags() | + NTLMEngineUtils.NTLMSSP_NEGOTIATE_SIGN | + NTLMEngineUtils.NTLMSSP_NEGOTIATE_SEAL | + NTLMEngineUtils.NTLMSSP_NEGOTIATE_KEY_EXCH); + + private static final String SOAP_CONTENT_TYPE = "application/soap+xml;charset=UTF-8"; + private static final byte[] PRE_AUTH_BOGUS = "AWAITING_ENCRYPTION_KEYS".getBytes(StandardCharsets.US_ASCII); + + // If no output is available before the OperationTimeout expires, the server returns this WSMan + // fault code and the client is expected to immediately re-issue the Receive request. + private static final String FAULT_OPERATION_TIMEOUT = "2150858793"; + private static final String FAULT_SHELL_NOT_FOUND = "2150858843"; + + private final long timeoutMs; + private final String url; + private final WinRMSession session; + private final HttpTransport transport; + + private String pendingAuthorization; + private String shellId; + + WsmanClient( + final String host, + final int port, + final String domain, + final String username, + final String password, + final long timeoutMs + ) { + this.timeoutMs = timeoutMs; + this.url = "http://" + host + ":" + port + "/wsman"; + // 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 + // key from the uppercased value. A lowercase domain here passes authentication but fails + // message integrity (server-side seal mismatch → HTTP 400). + // Workstation is left empty in the Type 3 message, matching the reference client. + final String upperDomain = domain == null ? null : domain.toUpperCase(Locale.ROOT); + this.session = new WinRMSession(upperDomain, null, username, password); + this.transport = new HttpTransport(host, port, (int) timeoutMs); + } + + /** A decrypted WSMan response: HTTP status plus the (decrypted) SOAP body. */ + private static final class Decoded { + + final int status; + final Document document; + + Decoded(final int status, final Document document) { + this.status = status; + this.document = document; + } + } + + /** Run a WQL query and return the rows as ordered property maps. */ + List> wql(final String namespace, final String query) throws Exception { + // WMI namespaces are case-insensitive, but preserve the caller's case to match the CXF backend. + final String ns = namespace.replace('\\', '/'); + final List> rows = new ArrayList<>(); + + Document doc = expectOk(Envelopes.enumerateWql(url, ns, query, timeoutMs), "Enumerate"); + collectItems(doc, rows); + + // Pull until the server signals EndOfSequence (matching the CXF backend). The aggregate + // timeout in LightWinRMService bounds a misbehaving server that never ends the sequence. + boolean endOfSequence = doc.getElementsByTagNameNS("*", "EndOfSequence").getLength() > 0; + String context = endOfSequence ? null : text(doc, "EnumerationContext"); + while (!endOfSequence && context != null && !context.isEmpty()) { + doc = expectOk(Envelopes.pull(url, ns, context, timeoutMs), "Pull"); + collectItems(doc, rows); + endOfSequence = doc.getElementsByTagNameNS("*", "EndOfSequence").getLength() > 0; + context = endOfSequence ? null : text(doc, "EnumerationContext"); + } + return rows; + } + + /** The result of running a command in the remote shell. */ + static final class CommandOutput { + + final String stdout; + final String stderr; + final int exitCode; + + CommandOutput(final String stdout, final String stderr, final int exitCode) { + this.stdout = stdout; + this.stderr = stderr; + this.exitCode = exitCode; + } + } + + /** Execute a command in the remote command shell, creating the shell on first use. */ + CommandOutput executeCommand(final String commandLine, final String workingDirectory, final Charset charset) + throws Exception { + if (shellId == null) { + createShell(workingDirectory); + } + final Charset cs = charset != null ? charset : StandardCharsets.UTF_8; + final String commandId = startCommand(commandLine); + try { + return receiveLoop(commandId, cs); + } finally { + terminate(commandId); + } + } + + private void createShell(final String workingDirectory) throws Exception { + final Document doc = expectOk(Envelopes.createShell(url, workingDirectory, timeoutMs), "Create shell"); + final NodeList selectors = doc.getElementsByTagNameNS("*", "Selector"); + for (int i = 0; i < selectors.getLength(); i++) { + final Element selector = (Element) selectors.item(i); + if ("ShellId".equals(selector.getAttribute("Name"))) { + shellId = selector.getTextContent(); + return; + } + } + throw new IllegalStateException("Shell ID not found in Create response"); + } + + private String startCommand(final String commandLine) throws Exception { + final Document doc = expectOk(Envelopes.command(url, shellId, commandLine, timeoutMs), "Command"); + final String commandId = text(doc, "CommandId"); + if (commandId == null) { + throw new IllegalStateException("No CommandId in Command response"); + } + return commandId; + } + + private CommandOutput receiveLoop(final String commandId, final Charset charset) throws Exception { + final StringBuilder stdout = new StringBuilder(); + final StringBuilder stderr = new StringBuilder(); + while (true) { + final Decoded resp = request(Envelopes.receive(url, shellId, commandId, timeoutMs)); + if (resp.status != 200) { + final String faultCode = wsmanFaultCode(resp.document); + // No output before OperationTimeout → re-issue Receive immediately. + if (FAULT_OPERATION_TIMEOUT.equals(faultCode)) { + continue; + } + throw new IllegalStateException("Receive failed: " + faultSummary(resp)); + } + collectStreams(resp.document, stdout, stderr, charset); + final Integer exitCode = doneExitCode(resp.document); + if (exitCode != null) { + return new CommandOutput(stdout.toString(), stderr.toString(), exitCode); + } + } + } + + private void terminate(final String commandId) throws Exception { + final Decoded resp = request(Envelopes.signal(url, shellId, commandId, timeoutMs)); + // A missing shell is fine here — the command already finished and the shell may be gone. + if (resp.status != 200 && !FAULT_SHELL_NOT_FOUND.equals(wsmanFaultCode(resp.document))) { + throw new IllegalStateException("Signal failed: " + faultSummary(resp)); + } + } + + // --- transport / crypto ------------------------------------------------- + + /** Send a request, expecting HTTP 200; throw with the WSMan fault detail otherwise. */ + private Document expectOk(final String soap, final String operation) throws Exception { + final Decoded resp = request(soap); + if (resp.status != 200) { + throw new IllegalStateException(operation + " failed: " + faultSummary(resp)); + } + return resp.document; + } + + /** Send one SOAP request (authenticating the connection on first use) and decrypt the response. */ + private Decoded request(final String soap) throws Exception { + // If the connection was dropped (e.g. the server sent "Connection: close"), the NTLM session + // bound to it is dead — re-handshake on the fresh connection rather than sending unauthenticated. + if (session.isAuthenticated() && !transport.isConnected()) { + session.reset(); + } + if (!session.isAuthenticated()) { + authenticate(); + } + // The Type 3 authorization accompanies the first encrypted payload; later requests on the + // already-authenticated connection carry no Authorization header. + final String authorization = pendingAuthorization; + pendingAuthorization = null; + + final byte[] encrypted = NtlmCrypto.encryptAndSign(session, soap.getBytes(StandardCharsets.UTF_8)); + final HttpTransport.Response resp = transport.post( + "/wsman", + encrypted, + NtlmCrypto.ENCRYPTED_CONTENT_TYPE, + authorization + ); + + // 200 = success, 500 = SOAP fault (both bodies are encrypted). Anything else is a protocol + // or authentication failure whose body is not a usable WSMan response. + if (resp.status != 200 && resp.status != 500) { + throw new IllegalStateException("WSMan request failed: HTTP " + resp.status); + } + return new Decoded(resp.status, decryptResponse(resp)); + } + + private void authenticate() throws Exception { + // Request 0: unauthenticated probe (bogus body), mirroring the reference client. + transport.post("/wsman", PRE_AUTH_BOGUS, SOAP_CONTENT_TYPE, null); + + // Request A: Type 1 under the Negotiate header. No keys yet, so send the bogus placeholder. + final String type1 = new Type1Message(null, null, TYPE1_FLAGS).getResponse(); + final HttpTransport.Response challenge = transport.post( + "/wsman", + PRE_AUTH_BOGUS, + SOAP_CONTENT_TYPE, + "Negotiate " + type1 + ); + if (challenge.status != 401) { + throw new IllegalStateException("Expected HTTP 401 with an NTLM challenge, got HTTP " + challenge.status); + } + final String wwwAuth = challenge.firstHeader("www-authenticate"); + if (wwwAuth == null || !wwwAuth.toLowerCase(Locale.ROOT).contains("negotiate ")) { + throw new IllegalStateException("No Negotiate challenge token in response: " + wwwAuth); + } + final String type2 = wwwAuth.substring(wwwAuth.indexOf(' ') + 1).trim(); + + final Type2Message challengeMessage = new Type2Message(type2); + final Type3Message type3Message = new Type3Message( + session.getDomain(), + session.getWorkstation(), + session.getUsername(), + session.getPassword(), + challengeMessage.getChallenge(), + challengeMessage.getFlags(), + challengeMessage.getTarget(), + challengeMessage.getTargetInfo() + ); + final String type3 = type3Message.getResponse(); + session.applyKeys(type3Message); + pendingAuthorization = "Negotiate " + type3; + } + + private Document decryptResponse(final HttpTransport.Response resp) throws Exception { + final String contentType = resp.firstHeader("content-type"); + final byte[] plain = contentType != null && contentType.startsWith("multipart/encrypted") + ? NtlmCrypto.decrypt(session, resp.body) + : resp.body; + return parse(plain); + } + + // --- XML helpers -------------------------------------------------------- + + private static Document parse(final byte[] xml) throws Exception { + final DocumentBuilderFactory factory = DocumentBuilderFactory.newDefaultInstance(); + factory.setNamespaceAware(true); + final DocumentBuilder builder = factory.newDocumentBuilder(); + // Throw parse errors instead of letting the default handler print them to stderr — a request + // abandoned by the timeout may parse a truncated response on a soon-to-die background thread. + builder.setErrorHandler( + new org.xml.sax.helpers.DefaultHandler() { + @Override + public void error(final org.xml.sax.SAXParseException e) throws org.xml.sax.SAXException { + throw e; + } + + @Override + public void fatalError(final org.xml.sax.SAXParseException e) throws org.xml.sax.SAXException { + throw e; + } + } + ); + return builder.parse(new ByteArrayInputStream(xml)); + } + + private static String text(final Document doc, final String localName) { + final NodeList nodes = doc.getElementsByTagNameNS("*", localName); + return nodes.getLength() > 0 ? nodes.item(0).getTextContent() : null; + } + + private static void collectItems(final Document doc, final List> rows) { + final NodeList items = doc.getElementsByTagNameNS("*", "Items"); + for (int i = 0; i < items.getLength(); i++) { + final NodeList instances = items.item(i).getChildNodes(); + for (int j = 0; j < instances.getLength(); j++) { + final Node instance = instances.item(j); + if (instance.getNodeType() != Node.ELEMENT_NODE) { + continue; + } + final Map row = new LinkedHashMap<>(); + final NodeList props = instance.getChildNodes(); + for (int k = 0; k < props.getLength(); k++) { + final Node prop = props.item(k); + if (prop.getNodeType() == Node.ELEMENT_NODE) { + row.put(((Element) prop).getLocalName(), prop.getTextContent()); + } + } + if (!row.isEmpty()) { + rows.add(row); + } + } + } + } + + private static void collectStreams( + final Document doc, + final StringBuilder stdout, + final StringBuilder stderr, + final Charset charset + ) { + final NodeList streams = doc.getElementsByTagNameNS("*", "Stream"); + for (int i = 0; i < streams.getLength(); i++) { + final Element stream = (Element) streams.item(i); + final String value = stream.getTextContent(); + if (value == null || value.isEmpty()) { + continue; + } + final String decoded = new String(Base64.getDecoder().decode(value), charset); + if ("stdout".equals(stream.getAttribute("Name"))) { + stdout.append(decoded); + } else if ("stderr".equals(stream.getAttribute("Name"))) { + stderr.append(decoded); + } + } + } + + /** Return the exit code if the response carries a CommandState of Done, otherwise null. */ + private static Integer doneExitCode(final Document doc) { + final NodeList states = doc.getElementsByTagNameNS("*", "CommandState"); + for (int i = 0; i < states.getLength(); i++) { + final Element state = (Element) states.item(i); + if (Envelopes.COMMAND_STATE_DONE.equals(state.getAttribute("State"))) { + final NodeList exit = state.getElementsByTagNameNS("*", "ExitCode"); + return exit.getLength() > 0 ? Integer.valueOf(exit.item(0).getTextContent().trim()) : 0; + } + } + return null; + } + + private static String wsmanFaultCode(final Document doc) { + final NodeList faults = doc.getElementsByTagNameNS("*", "WSManFault"); + return faults.getLength() > 0 ? ((Element) faults.item(0)).getAttribute("Code") : null; + } + + private static String faultSummary(final Decoded resp) { + final String reason = text(resp.document, "Text"); + final String code = wsmanFaultCode(resp.document); + return ( + "HTTP " + + resp.status + + (code == null ? "" : " (WSManFault " + code + ")") + + (reason == null ? "" : ": " + reason.trim()) + ); + } + + @Override + public void close() { + try { + if (shellId != null) { + try { + request(Envelopes.deleteShell(url, shellId, timeoutMs)); + } catch (final Exception ignore) { + // best-effort shell cleanup + } + shellId = null; + } + } finally { + transport.close(); + } + } +} diff --git a/src/main/java/org/metricshub/winrm/service/WinRMExecutorFactory.java b/src/main/java/org/metricshub/winrm/service/WinRMExecutorFactory.java new file mode 100644 index 0000000..492b43e --- /dev/null +++ b/src/main/java/org/metricshub/winrm/service/WinRMExecutorFactory.java @@ -0,0 +1,69 @@ +package org.metricshub.winrm.service; + +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * 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 java.nio.file.Path; +import java.util.List; +import java.util.Locale; +import org.metricshub.winrm.WindowsRemoteExecutor; +import org.metricshub.winrm.exceptions.WinRMException; +import org.metricshub.winrm.light.LightWinRMService; +import org.metricshub.winrm.service.client.auth.AuthenticationEnum; + +/** + * Selects the WinRM backend that fulfils a request. The default is the mature CXF-based + * {@link WinRMService}; setting the system property {@value #BACKEND_PROPERTY} to + * {@code light} selects the dependency-free {@link LightWinRMService} instead. + * + *

Both backends implement {@link WindowsRemoteExecutor}, so callers are agnostic to the choice. + */ +public final class WinRMExecutorFactory { + + /** System property selecting the backend: {@code cxf} (default) or {@code light}. */ + public static final String BACKEND_PROPERTY = "org.metricshub.winrm.backend"; + + private static final String LIGHT = "light"; + + private WinRMExecutorFactory() {} + + /** + * Create a {@link WindowsRemoteExecutor} using the configured backend. + * + * @param winRMEndpoint endpoint with credentials (mandatory) + * @param timeout timeout in milliseconds (must be > 0) + * @param ticketCache Kerberos ticket cache path (may be {@code null}) + * @param authentications requested authentication schemes (may be {@code null}) + * @return a CXF-backed or light-backed executor depending on {@value #BACKEND_PROPERTY} + * @throws WinRMException for any problem creating the executor + */ + public static WindowsRemoteExecutor createInstance( + final WinRMEndpoint winRMEndpoint, + final long timeout, + final Path ticketCache, + final List authentications + ) throws WinRMException { + final String backend = System.getProperty(BACKEND_PROPERTY, "cxf").trim().toLowerCase(Locale.ROOT); + if (LIGHT.equals(backend)) { + return LightWinRMService.createInstance(winRMEndpoint, timeout, ticketCache, authentications); + } + return WinRMService.createInstance(winRMEndpoint, timeout, ticketCache, authentications); + } +} diff --git a/src/main/java/org/metricshub/winrm/wql/WinRMWqlExecutor.java b/src/main/java/org/metricshub/winrm/wql/WinRMWqlExecutor.java index 95a0795..e16e8e3 100644 --- a/src/main/java/org/metricshub/winrm/wql/WinRMWqlExecutor.java +++ b/src/main/java/org/metricshub/winrm/wql/WinRMWqlExecutor.java @@ -4,7 +4,7 @@ * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ * WinRM Java Client * ჻჻჻჻჻჻ - * Copyright 2023 - 2024 Metricshub + * 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. @@ -27,11 +27,13 @@ import java.util.stream.Collectors; import org.metricshub.winrm.Utils; import org.metricshub.winrm.WinRMHttpProtocolEnum; +import org.metricshub.winrm.WindowsRemoteExecutor; import org.metricshub.winrm.WmiHelper; import org.metricshub.winrm.exceptions.WinRMException; +import org.metricshub.winrm.exceptions.WindowsRemoteException; import org.metricshub.winrm.exceptions.WqlQuerySyntaxException; import org.metricshub.winrm.service.WinRMEndpoint; -import org.metricshub.winrm.service.WinRMService; +import org.metricshub.winrm.service.WinRMExecutorFactory; import org.metricshub.winrm.service.client.auth.AuthenticationEnum; public class WinRMWqlExecutor { @@ -117,7 +119,7 @@ public static WinRMWqlExecutor executeWql( final WinRMEndpoint winRMEndpoint = new WinRMEndpoint(protocol, hostname, port, username, password, namespace); try ( - final WinRMService winRMService = WinRMService.createInstance( + final WindowsRemoteExecutor winRMService = WinRMExecutorFactory.createInstance( winRMEndpoint, timeout, ticketCache, @@ -135,6 +137,12 @@ public static WinRMWqlExecutor executeWql( .collect(Collectors.toList()); return new WinRMWqlExecutor(Utils.getCurrentTimeMillis() - start, headers, rows); + } catch (final WinRMException | WqlQuerySyntaxException | TimeoutException e) { + throw e; + } catch (final WindowsRemoteException e) { + // The WindowsRemoteExecutor interface declares the broader WindowsRemoteException; both + // backends actually throw WinRMException. Preserve the historical checked-exception surface. + throw new WinRMException(e, e.getMessage()); } } } diff --git a/src/test/java/org/metricshub/winrm/service/WinRMExecutorFactoryTest.java b/src/test/java/org/metricshub/winrm/service/WinRMExecutorFactoryTest.java new file mode 100644 index 0000000..8027cc4 --- /dev/null +++ b/src/test/java/org/metricshub/winrm/service/WinRMExecutorFactoryTest.java @@ -0,0 +1,89 @@ +package org.metricshub.winrm.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.metricshub.winrm.WinRMHttpProtocolEnum; +import org.metricshub.winrm.WindowsRemoteExecutor; +import org.metricshub.winrm.exceptions.WinRMException; +import org.metricshub.winrm.light.LightWinRMService; +import org.metricshub.winrm.service.client.auth.AuthenticationEnum; + +/** Verifies backend selection and the light backend's capability guards (no network required). */ +class WinRMExecutorFactoryTest { + + private static WinRMEndpoint endpoint(final WinRMHttpProtocolEnum protocol) { + return new WinRMEndpoint(protocol, "testhost", null, "user", "pwd".toCharArray(), null); + } + + @AfterEach + void clearBackend() { + System.clearProperty(WinRMExecutorFactory.BACKEND_PROPERTY); + } + + @Test + void lightBackendSelectedForHttpNtlm() throws Exception { + System.setProperty(WinRMExecutorFactory.BACKEND_PROPERTY, "light"); + try ( + final WindowsRemoteExecutor executor = WinRMExecutorFactory.createInstance( + endpoint(WinRMHttpProtocolEnum.HTTP), + 30000L, + null, + List.of(AuthenticationEnum.NTLM) + ) + ) { + assertInstanceOf(LightWinRMService.class, executor); + assertEquals("testhost", executor.getHostname()); + } + } + + @Test + void defaultBackendIsCxf() throws Exception { + // No backend property set -> the mature CXF backend. Building the client does not open a + // connection (that happens on the first operation), so this stays offline. + try ( + final WindowsRemoteExecutor executor = WinRMExecutorFactory.createInstance( + endpoint(WinRMHttpProtocolEnum.HTTP), + 30000L, + null, + List.of(AuthenticationEnum.NTLM) + ) + ) { + assertInstanceOf(WinRMService.class, executor); + } + } + + @Test + void lightBackendRejectsHttps() { + System.setProperty(WinRMExecutorFactory.BACKEND_PROPERTY, "light"); + assertThrows( + WinRMException.class, + () -> + WinRMExecutorFactory.createInstance( + endpoint(WinRMHttpProtocolEnum.HTTPS), + 30000L, + null, + List.of(AuthenticationEnum.NTLM) + ) + ); + } + + @Test + void lightBackendRejectsKerberosOnly() { + System.setProperty(WinRMExecutorFactory.BACKEND_PROPERTY, "light"); + assertThrows( + WinRMException.class, + () -> + WinRMExecutorFactory.createInstance( + endpoint(WinRMHttpProtocolEnum.HTTP), + 30000L, + null, + List.of(AuthenticationEnum.KERBEROS) + ) + ); + } +} From bb266b89a97189d4beb892460763d16f8b1517fe Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Thu, 23 Jul 2026 13:36:39 +0200 Subject: [PATCH 02/21] Address Codex review: honor backend toggle for file copy, harden XML parsing - SmbTempShare now obtains its WinRM command executor via WinRMExecutorFactory instead of WinRMService directly, so command-with-file-copy honors -Dorg.metricshub.winrm.backend=light (the SMB transfer stays smbj, which is backend-independent; only the WSMan orchestration follows the toggle). The default (cxf) path is unchanged. - WsmanClient.parse() disables DOCTYPE/external-entity resolution (XXE hardening) before parsing WSMan responses, matching what CXF's DOMUtils already does. WSMan responses never carry a DOCTYPE, so rejecting it is safe. Co-Authored-By: Claude Opus 4.8 --- .../metricshub/winrm/light/WsmanClient.java | 10 ++++++ .../metricshub/winrm/shares/SmbTempShare.java | 31 ++++++++++--------- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/src/main/java/org/metricshub/winrm/light/WsmanClient.java b/src/main/java/org/metricshub/winrm/light/WsmanClient.java index f85463a..1cbc462 100644 --- a/src/main/java/org/metricshub/winrm/light/WsmanClient.java +++ b/src/main/java/org/metricshub/winrm/light/WsmanClient.java @@ -29,6 +29,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import javax.xml.XMLConstants; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; import org.w3c.dom.Document; @@ -291,6 +292,15 @@ private Document decryptResponse(final HttpTransport.Response resp) throws Excep private static Document parse(final byte[] xml) throws Exception { final DocumentBuilderFactory factory = DocumentBuilderFactory.newDefaultInstance(); factory.setNamespaceAware(true); + // Harden against XXE: a malicious/compromised WinRM endpoint must not be able to make us + // resolve external entities (local file read, SSRF, entity-expansion DoS). WSMan responses + // never carry a DOCTYPE, so rejecting it outright is the strongest and safest defence. + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + factory.setXIncludeAware(false); + factory.setExpandEntityReferences(false); final DocumentBuilder builder = factory.newDocumentBuilder(); // Throw parse errors instead of letting the default handler print them to stderr — a request // abandoned by the timeout may parse a truncated response on a soon-to-die background thread. diff --git a/src/main/java/org/metricshub/winrm/shares/SmbTempShare.java b/src/main/java/org/metricshub/winrm/shares/SmbTempShare.java index f6539f7..80b88e1 100644 --- a/src/main/java/org/metricshub/winrm/shares/SmbTempShare.java +++ b/src/main/java/org/metricshub/winrm/shares/SmbTempShare.java @@ -40,7 +40,7 @@ import org.metricshub.winrm.exceptions.WinRMException; import org.metricshub.winrm.exceptions.WindowsRemoteException; import org.metricshub.winrm.service.WinRMEndpoint; -import org.metricshub.winrm.service.WinRMService; +import org.metricshub.winrm.service.WinRMExecutorFactory; import org.metricshub.winrm.service.client.auth.AuthenticationEnum; public class SmbTempShare extends WindowsTempShare implements AutoCloseable { @@ -54,7 +54,7 @@ public class SmbTempShare extends WindowsTempShare implements AutoCloseable { /** * The SmbTempShare constructor. * - * @param winRMService WinRMService instance + * @param windowsRemoteExecutor WinRM executor (CXF or light backend) * @param winRMEndpoint Endpoint with credentials * @param smbClient The SMB client * @param connection The SMB connection @@ -64,7 +64,7 @@ public class SmbTempShare extends WindowsTempShare implements AutoCloseable { * @param remotePath The path on the remote system of the directory being shared */ private SmbTempShare( - final WinRMService winRMService, + final WindowsRemoteExecutor windowsRemoteExecutor, final WinRMEndpoint winRMEndpoint, final SMBClient smbClient, final Connection connection, @@ -73,7 +73,7 @@ private SmbTempShare( final String shareNameOrUnc, final String remotePath ) { - super(winRMService, shareNameOrUnc, remotePath); + super(windowsRemoteExecutor, shareNameOrUnc, remotePath); this.winRMEndpoint = winRMEndpoint; this.smbClient = smbClient; this.connection = connection; @@ -115,17 +115,20 @@ public static SmbTempShare createInstance( winRMEndpoint, (key, smb) -> { if (smb == null) { - WinRMService winRMService = null; + WindowsRemoteExecutor windowsRemoteExecutor = null; SMBClient smbClient = null; Connection connection = null; Session session = null; DiskShare diskShare = null; try { - winRMService = WinRMService.createInstance(winRMEndpoint, timeout, ticketCache, authentications); + // Honour the backend toggle: SMB file transfer is always smbj, but the WinRM command + // orchestration follows the selected backend (so "light" does not fall back to CXF). + windowsRemoteExecutor = + WinRMExecutorFactory.createInstance(winRMEndpoint, timeout, ticketCache, authentications); final WindowsTempShare windowsTempShare = getOrCreateShare( - winRMService, + windowsRemoteExecutor, timeout, (w, r, s, t) -> { try { @@ -154,7 +157,7 @@ public static SmbTempShare createInstance( diskShare = (DiskShare) session.connectShare(windowsTempShare.getShareName()); return new SmbTempShare( - winRMService, + windowsRemoteExecutor, winRMEndpoint, smbClient, connection, @@ -164,11 +167,11 @@ public static SmbTempShare createInstance( windowsTempShare.getRemotePath() ); } catch (final RuntimeException e) { - closeResources(winRMService, smbClient, connection, session, diskShare); + closeResources(windowsRemoteExecutor, smbClient, connection, session, diskShare); throw e; } catch (final Exception e) { - closeResources(winRMService, smbClient, connection, session, diskShare); + closeResources(windowsRemoteExecutor, smbClient, connection, session, diskShare); throw new RuntimeException(e); } @@ -201,7 +204,7 @@ public static SmbTempShare createInstance( } private static void closeResources( - final WinRMService winRMService, + final WindowsRemoteExecutor windowsRemoteExecutor, final SMBClient smbClient, final Connection connection, final Session session, @@ -227,8 +230,8 @@ private static void closeResources( smbClient.close(); } - if (winRMService != null) { - winRMService.close(); + if (windowsRemoteExecutor != null) { + windowsRemoteExecutor.close(); } } @@ -277,7 +280,7 @@ public synchronized void close() throws IOException { smbClient.close(); } - ((WinRMService) getWindowsRemoteExecutor()).close(); + getWindowsRemoteExecutor().close(); } } From 1da7d38df50b6270b288dd7dccf135920901945b Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Thu, 23 Jul 2026 14:54:36 +0200 Subject: [PATCH 03/21] Harden light backend: reject unencrypted responses, fix timeout/close race Address two P1 Codex findings on PR #109: - WsmanClient.decryptResponse now refuses any post-authentication response that is not multipart/encrypted. Over plaintext HTTP the NTLM seal is the only thing protecting response integrity, so a forged/unencrypted 200/500 from a proxy or on-path attacker must never be parsed as trusted SOAP. - WsmanClient.close no longer issues a graceful shell Delete while a request is still in flight (requestInFlight guard). On a command timeout, Utils.execute abandons the worker mid-Receive while try-with-resources calls close(); sending a second SOAP exchange over the same socket and stateful RC4 session would race the worker (cipher-sequence corruption, crossed responses, or a stall until the socket read timeout). Instead close() hard-closes the transport, unblocking the worker; the abandoned shell is reaped by the server IdleTimeout. - HttpTransport.close reads the socket into a local before closing so a concurrent close (main thread unblocking a worker's blocking read) cannot NPE on a check-then-use race. Verified vs anaxagore (light backend): WQL and command still succeed; a command that exceeds a 3s timeout now returns in 3s instead of stalling ~13s. Co-Authored-By: Claude Opus 4.8 --- .../metricshub/winrm/light/HttpTransport.java | 19 ++-- .../metricshub/winrm/light/WsmanClient.java | 103 +++++++++++------- 2 files changed, 75 insertions(+), 47 deletions(-) diff --git a/src/main/java/org/metricshub/winrm/light/HttpTransport.java b/src/main/java/org/metricshub/winrm/light/HttpTransport.java index fe39532..0d79ce8 100644 --- a/src/main/java/org/metricshub/winrm/light/HttpTransport.java +++ b/src/main/java/org/metricshub/winrm/light/HttpTransport.java @@ -241,14 +241,19 @@ private byte[] readChunked() throws IOException { @Override public void close() { - try { - if (socket != null) { - socket.close(); + // Read the field into a local before closing so a concurrent close() (the main thread closing + // the socket to unblock a worker blocked in a socket read) cannot NPE on a check-then-use race. + // Closing an already-closed Socket is a no-op; closing an open one unblocks any pending read. + final Socket doomed = socket; + socket = null; + out = null; + in = null; + if (doomed != null) { + try { + doomed.close(); + } catch (final IOException ignore) { + // best effort } - } catch (final IOException ignore) { - // best effort - } finally { - socket = null; } } } diff --git a/src/main/java/org/metricshub/winrm/light/WsmanClient.java b/src/main/java/org/metricshub/winrm/light/WsmanClient.java index 1cbc462..7d75d5d 100644 --- a/src/main/java/org/metricshub/winrm/light/WsmanClient.java +++ b/src/main/java/org/metricshub/winrm/light/WsmanClient.java @@ -29,6 +29,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; import javax.xml.XMLConstants; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; @@ -66,6 +67,11 @@ final class WsmanClient implements AutoCloseable { private String pendingAuthorization; private String shellId; + // True while a SOAP request is on the wire. close() consults this to avoid racing an abandoned + // worker (e.g. a Receive left blocked on the socket after a command timeout) on the shared + // socket and stateful RC4 session. + private final AtomicBoolean requestInFlight = new AtomicBoolean(false); + WsmanClient( final String host, final int port, @@ -213,33 +219,38 @@ private Document expectOk(final String soap, final String operation) throws Exce /** Send one SOAP request (authenticating the connection on first use) and decrypt the response. */ private Decoded request(final String soap) throws Exception { - // If the connection was dropped (e.g. the server sent "Connection: close"), the NTLM session - // bound to it is dead — re-handshake on the fresh connection rather than sending unauthenticated. - if (session.isAuthenticated() && !transport.isConnected()) { - session.reset(); - } - if (!session.isAuthenticated()) { - authenticate(); - } - // The Type 3 authorization accompanies the first encrypted payload; later requests on the - // already-authenticated connection carry no Authorization header. - final String authorization = pendingAuthorization; - pendingAuthorization = null; - - final byte[] encrypted = NtlmCrypto.encryptAndSign(session, soap.getBytes(StandardCharsets.UTF_8)); - final HttpTransport.Response resp = transport.post( - "/wsman", - encrypted, - NtlmCrypto.ENCRYPTED_CONTENT_TYPE, - authorization - ); - - // 200 = success, 500 = SOAP fault (both bodies are encrypted). Anything else is a protocol - // or authentication failure whose body is not a usable WSMan response. - if (resp.status != 200 && resp.status != 500) { - throw new IllegalStateException("WSMan request failed: HTTP " + resp.status); + requestInFlight.set(true); + try { + // If the connection was dropped (e.g. the server sent "Connection: close"), the NTLM session + // bound to it is dead — re-handshake on the fresh connection rather than sending unauthenticated. + if (session.isAuthenticated() && !transport.isConnected()) { + session.reset(); + } + if (!session.isAuthenticated()) { + authenticate(); + } + // The Type 3 authorization accompanies the first encrypted payload; later requests on the + // already-authenticated connection carry no Authorization header. + final String authorization = pendingAuthorization; + pendingAuthorization = null; + + final byte[] encrypted = NtlmCrypto.encryptAndSign(session, soap.getBytes(StandardCharsets.UTF_8)); + final HttpTransport.Response resp = transport.post( + "/wsman", + encrypted, + NtlmCrypto.ENCRYPTED_CONTENT_TYPE, + authorization + ); + + // 200 = success, 500 = SOAP fault (both bodies are encrypted). Anything else is a protocol + // or authentication failure whose body is not a usable WSMan response. + if (resp.status != 200 && resp.status != 500) { + throw new IllegalStateException("WSMan request failed: HTTP " + resp.status); + } + return new Decoded(resp.status, decryptResponse(resp)); + } finally { + requestInFlight.set(false); } - return new Decoded(resp.status, decryptResponse(resp)); } private void authenticate() throws Exception { @@ -281,10 +292,17 @@ private void authenticate() throws Exception { private Document decryptResponse(final HttpTransport.Response resp) throws Exception { final String contentType = resp.firstHeader("content-type"); - final byte[] plain = contentType != null && contentType.startsWith("multipart/encrypted") - ? NtlmCrypto.decrypt(session, resp.body) - : resp.body; - return parse(plain); + // Once the NTLM session is authenticated, the seal is the ONLY thing protecting response + // integrity over plaintext HTTP. A non-encrypted body (from a proxy, a misconfigured server, + // or an on-path attacker returning a forged HTTP 200/500) has not passed the HMAC check, so it + // must never be parsed as a trusted WSMan response. request() only reaches here after the + // handshake, so an encrypted content type is always required. + if (contentType == null || !contentType.startsWith("multipart/encrypted")) { + throw new IllegalStateException( + "Refusing to parse an unencrypted WSMan response after authentication (Content-Type: " + contentType + ")" + ); + } + return parse(NtlmCrypto.decrypt(session, resp.body)); } // --- XML helpers -------------------------------------------------------- @@ -402,17 +420,22 @@ private static String faultSummary(final Decoded resp) { @Override public void close() { - try { - if (shellId != null) { - try { - request(Envelopes.deleteShell(url, shellId, timeoutMs)); - } catch (final Exception ignore) { - // best-effort shell cleanup - } - shellId = null; + final String shell = shellId; + shellId = null; + // If a request is still in flight, another thread is blocked on this socket — e.g. a command + // that exceeded its timeout, where Utils.execute abandons the worker mid-Receive while + // try-with-resources calls close() here. Issuing a graceful shell Delete now would start a + // second SOAP exchange over the SAME socket and stateful RC4 session, racing the worker + // (cipher-sequence corruption, crossed responses, or a stall until the socket read timeout). + // In that case, just hard-close the transport below: it unblocks the worker's read, and the + // abandoned shell is reaped by the server's IdleTimeout. + if (shell != null && !requestInFlight.get()) { + try { + request(Envelopes.deleteShell(url, shell, timeoutMs)); + } catch (final Exception ignore) { + // best-effort shell cleanup } - } finally { - transport.close(); } + transport.close(); } } From b54918229e2d0d87680dc489e56c6301e8dce4c4 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Thu, 23 Jul 2026 15:25:26 +0200 Subject: [PATCH 04/21] Harden light backend NTLM/HTTP protocol parsing (P2 review) Address three P2 Codex findings on PR #109: - WsmanClient now selects the Negotiate challenge across ALL WWW-Authenticate headers (order-independent) and tolerates challenges combined in one header, matching only the base64 token at a scheme boundary. Previously firstHeader() took only the first header and split on the first space, so a server/proxy that advertised another scheme first, or combined challenges, broke authentication. - NTLM protocol constants (the signing/sealing magic constants in WinRMSession and the multipart framing/boundary strings in NtlmCrypto) are now encoded with an explicit US-ASCII charset. Relying on the platform default charset could derive wrong keys or wrong framing on a JVM whose default charset is not ASCII-compatible. - HttpTransport.readChunked now consumes every trailer line up to the terminating empty line after the final chunk, instead of a single line. A chunked response carrying trailer fields previously left bytes in the kept-alive socket, desyncing the next request on the NTLM-bound connection. Co-Authored-By: Claude Opus 4.8 --- .../metricshub/winrm/light/HttpTransport.java | 18 ++++++++++- .../metricshub/winrm/light/NtlmCrypto.java | 17 +++++----- .../metricshub/winrm/light/WinRMSession.java | 11 ++++--- .../metricshub/winrm/light/WsmanClient.java | 32 ++++++++++++++++--- 4 files changed, 61 insertions(+), 17 deletions(-) diff --git a/src/main/java/org/metricshub/winrm/light/HttpTransport.java b/src/main/java/org/metricshub/winrm/light/HttpTransport.java index 0d79ce8..5055267 100644 --- a/src/main/java/org/metricshub/winrm/light/HttpTransport.java +++ b/src/main/java/org/metricshub/winrm/light/HttpTransport.java @@ -72,6 +72,17 @@ String firstHeader(final String name) { } return null; } + + List allHeaders(final String name) { + final String n = name.toLowerCase(Locale.ROOT); + final List values = new ArrayList<>(); + for (final String[] h : headers) { + if (h[0].equals(n)) { + values.add(h[1]); + } + } + return values; + } } /** Whether a live connection is currently held. */ @@ -230,7 +241,12 @@ private byte[] readChunked() throws IOException { final int semicolon = sizeLine.indexOf(';'); final int size = Integer.parseInt((semicolon < 0 ? sizeLine : sizeLine.substring(0, semicolon)).trim(), 16); if (size == 0) { - readLine(); // trailing CRLF after the last chunk + // After the terminating chunk come zero or more optional trailer fields, then a final + // empty line. Consume them all, or leftover bytes desync the kept-alive NTLM socket. + String trailer; + while ((trailer = readLine()) != null && !trailer.isEmpty()) { + // discard trailer field + } break; } body.write(readFixed(size)); diff --git a/src/main/java/org/metricshub/winrm/light/NtlmCrypto.java b/src/main/java/org/metricshub/winrm/light/NtlmCrypto.java index 4350ed2..f32c5ee 100644 --- a/src/main/java/org/metricshub/winrm/light/NtlmCrypto.java +++ b/src/main/java/org/metricshub/winrm/light/NtlmCrypto.java @@ -21,6 +21,7 @@ */ import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.zip.CRC32; @@ -42,15 +43,15 @@ private NtlmCrypto() {} static byte[] encryptAndSign(final WinRMSession session, final byte[] messageBody) { try (final ByteArrayOutputStream out = new ByteArrayOutputStream()) { - out.write(BOUNDARY_CR.getBytes()); - out.write("\tContent-Type: application/HTTP-SPNEGO-session-encrypted\r\n".getBytes()); + out.write(BOUNDARY_CR.getBytes(StandardCharsets.US_ASCII)); + out.write("\tContent-Type: application/HTTP-SPNEGO-session-encrypted\r\n".getBytes(StandardCharsets.US_ASCII)); out.write( String .format("\tOriginalContent: type=application/soap+xml;charset=UTF-8;Length=%d\r\n", messageBody.length) - .getBytes() + .getBytes(StandardCharsets.US_ASCII) ); - out.write(BOUNDARY_CR.getBytes()); - out.write("\tContent-Type: application/octet-stream\r\n".getBytes()); + out.write(BOUNDARY_CR.getBytes(StandardCharsets.US_ASCII)); + out.write("\tContent-Type: application/octet-stream\r\n".getBytes(StandardCharsets.US_ASCII)); final long seqNum = session.getSequenceNumberOutgoing().incrementAndGet(); // Seal the body FIRST (advances the stateful cipher), even though the signature is written before it. @@ -62,7 +63,7 @@ static byte[] encryptAndSign(final WinRMSession session, final byte[] messageBod out.write(signature.toByteArray()); out.write(sealed); - out.write(BOUNDARY_END.getBytes()); + out.write(BOUNDARY_END.getBytes(StandardCharsets.US_ASCII)); return out.toByteArray(); } catch (final Exception e) { throw new IllegalStateException("Cannot encrypt WinRM message", e); @@ -170,7 +171,7 @@ private static final class Cursor { } void skipOver(final String s) { - final byte[] expected = s.getBytes(); + final byte[] expected = s.getBytes(StandardCharsets.US_ASCII); for (int i = 0; i < expected.length; i++) { if (index >= bytes.length || expected[i] != bytes[index++]) { throw new IllegalStateException("Unexpected encrypted-response framing at byte " + index); @@ -179,7 +180,7 @@ void skipOver(final String s) { } void skipUntil(final String s) { - final byte[] expected = s.getBytes(); + final byte[] expected = s.getBytes(StandardCharsets.US_ASCII); int next = index; outer:while (true) { for (int i = 0; i < expected.length; i++) { diff --git a/src/main/java/org/metricshub/winrm/light/WinRMSession.java b/src/main/java/org/metricshub/winrm/light/WinRMSession.java index 4c05d49..1f0ba76 100644 --- a/src/main/java/org/metricshub/winrm/light/WinRMSession.java +++ b/src/main/java/org/metricshub/winrm/light/WinRMSession.java @@ -20,6 +20,7 @@ * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ */ +import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.concurrent.atomic.AtomicLong; import javax.crypto.Cipher; @@ -32,14 +33,16 @@ */ final class WinRMSession { + // Protocol-defined constants: they MUST encode to the same bytes on every JVM, so pin US-ASCII + // rather than relying on the platform default charset (which could differ, e.g. UTF-16). private static final byte[] CLIENT_SIGNING = - "session key to client-to-server signing key magic constant\0".getBytes(); + "session key to client-to-server signing key magic constant\0".getBytes(StandardCharsets.US_ASCII); private static final byte[] SERVER_SIGNING = - "session key to server-to-client signing key magic constant\0".getBytes(); + "session key to server-to-client signing key magic constant\0".getBytes(StandardCharsets.US_ASCII); private static final byte[] CLIENT_SEALING = - "session key to client-to-server sealing key magic constant\0".getBytes(); + "session key to client-to-server sealing key magic constant\0".getBytes(StandardCharsets.US_ASCII); private static final byte[] SERVER_SEALING = - "session key to server-to-client sealing key magic constant\0".getBytes(); + "session key to server-to-client sealing key magic constant\0".getBytes(StandardCharsets.US_ASCII); private final String domain; private final String workstation; diff --git a/src/main/java/org/metricshub/winrm/light/WsmanClient.java b/src/main/java/org/metricshub/winrm/light/WsmanClient.java index 7d75d5d..cb0cd24 100644 --- a/src/main/java/org/metricshub/winrm/light/WsmanClient.java +++ b/src/main/java/org/metricshub/winrm/light/WsmanClient.java @@ -30,6 +30,8 @@ import java.util.Locale; import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import javax.xml.XMLConstants; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; @@ -59,6 +61,11 @@ final class WsmanClient implements AutoCloseable { private static final String FAULT_OPERATION_TIMEOUT = "2150858793"; private static final String FAULT_SHELL_NOT_FOUND = "2150858843"; + // A WWW-Authenticate value may list several challenges ("Negotiate , NTLM ...") and a server + // or proxy may split them across multiple header lines. Match the Negotiate scheme only at a + // challenge boundary (start of value or right after a comma) and capture just its base64 token. + private static final Pattern NEGOTIATE_TOKEN = Pattern.compile("(?i)(?:^|,)\\s*Negotiate\\s+([A-Za-z0-9+/=]+)"); + private final long timeoutMs; private final String url; private final WinRMSession session; @@ -268,11 +275,12 @@ private void authenticate() throws Exception { if (challenge.status != 401) { throw new IllegalStateException("Expected HTTP 401 with an NTLM challenge, got HTTP " + challenge.status); } - final String wwwAuth = challenge.firstHeader("www-authenticate"); - if (wwwAuth == null || !wwwAuth.toLowerCase(Locale.ROOT).contains("negotiate ")) { - throw new IllegalStateException("No Negotiate challenge token in response: " + wwwAuth); + final String type2 = extractNegotiateToken(challenge); + if (type2 == null) { + throw new IllegalStateException( + "No Negotiate challenge token in response: " + challenge.allHeaders("www-authenticate") + ); } - final String type2 = wwwAuth.substring(wwwAuth.indexOf(' ') + 1).trim(); final Type2Message challengeMessage = new Type2Message(type2); final Type3Message type3Message = new Type3Message( @@ -290,6 +298,22 @@ private void authenticate() throws Exception { pendingAuthorization = "Negotiate " + type3; } + /** + * Extract the Negotiate/NTLM challenge token from the 401 response, scanning every + * {@code WWW-Authenticate} header (order-independent) and tolerating combined challenges. + * + * @return the base64 token, or {@code null} if no Negotiate challenge carries one + */ + private static String extractNegotiateToken(final HttpTransport.Response response) { + for (final String value : response.allHeaders("www-authenticate")) { + final Matcher matcher = NEGOTIATE_TOKEN.matcher(value); + if (matcher.find()) { + return matcher.group(1); + } + } + return null; + } + private Document decryptResponse(final HttpTransport.Response resp) throws Exception { final String contentType = resp.firstHeader("content-type"); // Once the NTLM session is authenticated, the seal is the ONLY thing protecting response From dbb0ad4430b231cfdaa6f53553b4c6cc8eab417e Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Thu, 23 Jul 2026 15:25:39 +0200 Subject: [PATCH 05/21] Make the light backend the default (CXF now opt-in) On this branch, the dependency-free light backend becomes the default WinRM backend; the CXF backend is reachable via -Dorg.metricshub.winrm.backend=cxf. CXF is intentionally NOT removed: it remains the parity baseline (differential testing) and the fallback for HTTPS/Kerberos until the light backend covers them, and is removed only as the final step before this branch merges to main. - WinRMExecutorFactory: default property value is now "light"; explicit value "cxf" selects WinRMService, everything else (incl. default) selects LightWinRMService. Javadoc/field comment updated. - LightWinRMService: the HTTPS and non-NTLM rejection messages now name the escape hatch (-Dorg.metricshub.winrm.backend=cxf), since these are now the default-path failures for those capabilities. - Tests WinRMWqlExecutorTest, WinRMCommandExecutorTest, SmbTempShareTest mocked WinRMService.createInstance, which only worked because the factory delegated to it under the cxf default. They now mock the seam production actually calls, WinRMExecutorFactory.createInstance (and return a WindowsRemoteExecutor), which also survives the eventual CXF removal. - WinRMExecutorFactoryTest: defaultBackendIsCxf -> defaultBackendIsLight; added cxfBackendSelectedViaProperty and defaultBackendRejectsHttps (the latter pins the intentional HTTPS regression so a silent fallback cannot be reintroduced). - README documents both backends and the toggle. Co-Authored-By: Claude Opus 4.8 --- README.md | 16 ++++++++ .../winrm/light/LightWinRMService.java | 9 +++-- .../winrm/service/WinRMExecutorFactory.java | 21 +++++----- .../metricshub/winrm/shares/SmbTempShare.java | 2 +- .../command/WinRMCommandExecutorTest.java | 14 ++++--- .../service/WinRMExecutorFactoryTest.java | 40 +++++++++++++++++-- .../winrm/shares/SmbTempShareTest.java | 19 ++++----- .../winrm/wql/WinRMWqlExecutorTest.java | 16 ++++---- 8 files changed, 96 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index dbbdb42..7f59f29 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,22 @@ The Windows Remote Management (WinRM) Java Client is a library that enables to: * Connect to a remote Windows server using one of the two authentication types (NTLM, KERBEROS) * Execute WMI Query Language (WQL) queries which uses HTTP/HTTPS protocols. +## WinRM backends + +The library ships two interchangeable backends, both implementing the same API so calling code is unaffected by the choice: + +* **light** (default) — a dependency-free client with no Apache CXF / JAX-WS / JAXB stack. It currently supports **NTLM over HTTP** with message encryption, and is immune by construction to JAXP `ServiceLoader` conflicts (it uses the JDK-default XML factories). +* **cxf** — the mature CXF-based backend, additionally covering **HTTPS** and **Kerberos**. + +Select the backend with the `org.metricshub.winrm.backend` system property. When it is unset, the **light** backend is used: + +```bash +# Force the CXF backend (currently required for HTTPS or Kerberos) +java -Dorg.metricshub.winrm.backend=cxf ... +``` + +Requesting HTTPS or Kerberos on the light backend raises an error that points to the `cxf` value above, until the corresponding light support lands. + ## Build instructions This is a simple Maven project. Build with: diff --git a/src/main/java/org/metricshub/winrm/light/LightWinRMService.java b/src/main/java/org/metricshub/winrm/light/LightWinRMService.java index d2dcedc..88fd740 100644 --- a/src/main/java/org/metricshub/winrm/light/LightWinRMService.java +++ b/src/main/java/org/metricshub/winrm/light/LightWinRMService.java @@ -78,17 +78,18 @@ public static LightWinRMService createInstance( if (authentications != null && !authentications.isEmpty() && !authentications.contains(AuthenticationEnum.NTLM)) { throw new WinRMException( - "The light WinRM backend currently supports only NTLM authentication; " + - "use the CXF backend for " + + "The light WinRM backend currently supports only NTLM authentication (requested: " + authentications + - "." + "). Select the CXF backend with -Dorg.metricshub.winrm.backend=cxf until light support lands." ); } final URI uri = URI.create(winRMEndpoint.getEndpoint()); if (!"http".equalsIgnoreCase(uri.getScheme())) { throw new WinRMException( - "The light WinRM backend currently supports only HTTP; endpoint was " + winRMEndpoint.getEndpoint() + "The light WinRM backend currently supports only HTTP (endpoint was " + + winRMEndpoint.getEndpoint() + + "). Select the CXF backend with -Dorg.metricshub.winrm.backend=cxf until light support lands." ); } final int port = uri.getPort() > 0 ? uri.getPort() : 5985; diff --git a/src/main/java/org/metricshub/winrm/service/WinRMExecutorFactory.java b/src/main/java/org/metricshub/winrm/service/WinRMExecutorFactory.java index 492b43e..b2bd187 100644 --- a/src/main/java/org/metricshub/winrm/service/WinRMExecutorFactory.java +++ b/src/main/java/org/metricshub/winrm/service/WinRMExecutorFactory.java @@ -29,18 +29,19 @@ import org.metricshub.winrm.service.client.auth.AuthenticationEnum; /** - * Selects the WinRM backend that fulfils a request. The default is the mature CXF-based - * {@link WinRMService}; setting the system property {@value #BACKEND_PROPERTY} to - * {@code light} selects the dependency-free {@link LightWinRMService} instead. + * Selects the WinRM backend that fulfils a request. The default is the dependency-free + * {@link LightWinRMService}; setting the system property {@value #BACKEND_PROPERTY} to + * {@code cxf} selects the mature CXF-based {@link WinRMService} instead — needed for capabilities + * the light backend does not yet cover (HTTPS and Kerberos). * *

Both backends implement {@link WindowsRemoteExecutor}, so callers are agnostic to the choice. */ public final class WinRMExecutorFactory { - /** System property selecting the backend: {@code cxf} (default) or {@code light}. */ + /** System property selecting the backend: {@code light} (default) or {@code cxf}. */ public static final String BACKEND_PROPERTY = "org.metricshub.winrm.backend"; - private static final String LIGHT = "light"; + private static final String CXF = "cxf"; private WinRMExecutorFactory() {} @@ -51,7 +52,7 @@ private WinRMExecutorFactory() {} * @param timeout timeout in milliseconds (must be > 0) * @param ticketCache Kerberos ticket cache path (may be {@code null}) * @param authentications requested authentication schemes (may be {@code null}) - * @return a CXF-backed or light-backed executor depending on {@value #BACKEND_PROPERTY} + * @return a light-backed or CXF-backed executor depending on {@value #BACKEND_PROPERTY} * @throws WinRMException for any problem creating the executor */ public static WindowsRemoteExecutor createInstance( @@ -60,10 +61,10 @@ public static WindowsRemoteExecutor createInstance( final Path ticketCache, final List authentications ) throws WinRMException { - final String backend = System.getProperty(BACKEND_PROPERTY, "cxf").trim().toLowerCase(Locale.ROOT); - if (LIGHT.equals(backend)) { - return LightWinRMService.createInstance(winRMEndpoint, timeout, ticketCache, authentications); + final String backend = System.getProperty(BACKEND_PROPERTY, "light").trim().toLowerCase(Locale.ROOT); + if (CXF.equals(backend)) { + return WinRMService.createInstance(winRMEndpoint, timeout, ticketCache, authentications); } - return WinRMService.createInstance(winRMEndpoint, timeout, ticketCache, authentications); + return LightWinRMService.createInstance(winRMEndpoint, timeout, ticketCache, authentications); } } diff --git a/src/main/java/org/metricshub/winrm/shares/SmbTempShare.java b/src/main/java/org/metricshub/winrm/shares/SmbTempShare.java index 80b88e1..ee153ef 100644 --- a/src/main/java/org/metricshub/winrm/shares/SmbTempShare.java +++ b/src/main/java/org/metricshub/winrm/shares/SmbTempShare.java @@ -287,7 +287,7 @@ public synchronized void close() throws IOException { /** * Share the remote directory on the host. * - * @param windowsRemoteExecutor WinRMService instance. + * @param windowsRemoteExecutor WinRM executor (CXF or light backend). * @param remotePath The remote path. * @param shareName The Share Name. * @param timeout Timeout in milliseconds. diff --git a/src/test/java/org/metricshub/winrm/command/WinRMCommandExecutorTest.java b/src/test/java/org/metricshub/winrm/command/WinRMCommandExecutorTest.java index df46bf3..590309d 100644 --- a/src/test/java/org/metricshub/winrm/command/WinRMCommandExecutorTest.java +++ b/src/test/java/org/metricshub/winrm/command/WinRMCommandExecutorTest.java @@ -26,8 +26,10 @@ import java.util.List; import org.junit.jupiter.api.Test; import org.metricshub.winrm.WindowsRemoteCommandResult; +import org.metricshub.winrm.WindowsRemoteExecutor; import org.metricshub.winrm.WindowsRemoteProcessUtils; import org.metricshub.winrm.service.WinRMEndpoint; +import org.metricshub.winrm.service.WinRMExecutorFactory; import org.metricshub.winrm.service.WinRMService; import org.metricshub.winrm.service.client.auth.AuthenticationEnum; import org.metricshub.winrm.shares.SmbTempShare; @@ -162,17 +164,17 @@ void testExecute() throws Exception { final MockedStatic mockedWindowsRemoteProcessUtils = mockStatic( WindowsRemoteProcessUtils.class ); - final MockedStatic mockedWinRMService = mockStatic(WinRMService.class) + final MockedStatic mockedFactory = mockStatic(WinRMExecutorFactory.class) ) { mockedWindowsRemoteProcessUtils.when(() -> getWindowsEncodingCharset(any(), anyLong())).thenReturn(UTF_8); - final WinRMService winRMService = mock(WinRMService.class); + final WindowsRemoteExecutor executor = mock(WindowsRemoteExecutor.class); - mockedWinRMService - .when(() -> WinRMService.createInstance(any(WinRMEndpoint.class), anyLong(), isNull(), isNull())) - .thenReturn(winRMService); + mockedFactory + .when(() -> WinRMExecutorFactory.createInstance(any(WinRMEndpoint.class), anyLong(), isNull(), isNull())) + .thenReturn(executor); - doReturn(expected).when(winRMService).executeCommand(eq(command), isNull(), eq(UTF_8), anyLong()); + doReturn(expected).when(executor).executeCommand(eq(command), isNull(), eq(UTF_8), anyLong()); assertEquals( expected, diff --git a/src/test/java/org/metricshub/winrm/service/WinRMExecutorFactoryTest.java b/src/test/java/org/metricshub/winrm/service/WinRMExecutorFactoryTest.java index 8027cc4..fa3985c 100644 --- a/src/test/java/org/metricshub/winrm/service/WinRMExecutorFactoryTest.java +++ b/src/test/java/org/metricshub/winrm/service/WinRMExecutorFactoryTest.java @@ -42,9 +42,26 @@ void lightBackendSelectedForHttpNtlm() throws Exception { } @Test - void defaultBackendIsCxf() throws Exception { - // No backend property set -> the mature CXF backend. Building the client does not open a - // connection (that happens on the first operation), so this stays offline. + void defaultBackendIsLight() throws Exception { + // No backend property set -> the dependency-free light backend. Building the client does not + // open a connection (that happens on the first operation), so this stays offline. + try ( + final WindowsRemoteExecutor executor = WinRMExecutorFactory.createInstance( + endpoint(WinRMHttpProtocolEnum.HTTP), + 30000L, + null, + List.of(AuthenticationEnum.NTLM) + ) + ) { + assertInstanceOf(LightWinRMService.class, executor); + } + } + + @Test + void cxfBackendSelectedViaProperty() throws Exception { + // The CXF backend stays reachable via the property while light matures. Building the client + // does not open a connection, so this stays offline. + System.setProperty(WinRMExecutorFactory.BACKEND_PROPERTY, "cxf"); try ( final WindowsRemoteExecutor executor = WinRMExecutorFactory.createInstance( endpoint(WinRMHttpProtocolEnum.HTTP), @@ -57,6 +74,23 @@ void defaultBackendIsCxf() throws Exception { } } + @Test + void defaultBackendRejectsHttps() { + // Intentional, documented regression on this branch: with light as the default, HTTPS is + // rejected until the light backend supports it. Pinned so a silent CXF fallback cannot be + // reintroduced without updating this test. + assertThrows( + WinRMException.class, + () -> + WinRMExecutorFactory.createInstance( + endpoint(WinRMHttpProtocolEnum.HTTPS), + 30000L, + null, + List.of(AuthenticationEnum.NTLM) + ) + ); + } + @Test void lightBackendRejectsHttps() { System.setProperty(WinRMExecutorFactory.BACKEND_PROPERTY, "light"); diff --git a/src/test/java/org/metricshub/winrm/shares/SmbTempShareTest.java b/src/test/java/org/metricshub/winrm/shares/SmbTempShareTest.java index 9058c9a..7896093 100644 --- a/src/test/java/org/metricshub/winrm/shares/SmbTempShareTest.java +++ b/src/test/java/org/metricshub/winrm/shares/SmbTempShareTest.java @@ -33,9 +33,10 @@ import java.util.List; import org.junit.jupiter.api.Test; import org.metricshub.winrm.ShareRemoteDirectoryConsumer; +import org.metricshub.winrm.WindowsRemoteExecutor; import org.metricshub.winrm.WindowsTempShare; import org.metricshub.winrm.service.WinRMEndpoint; -import org.metricshub.winrm.service.WinRMService; +import org.metricshub.winrm.service.WinRMExecutorFactory; import org.metricshub.winrm.service.client.auth.AuthenticationEnum; import org.mockito.MockedStatic; @@ -63,19 +64,19 @@ void testCreateInstance() throws Exception { assertThrows(IllegalArgumentException.class, () -> createInstance(winRMEndpoint, 0L, ticketCache, authentications)); try ( - final MockedStatic mockedWinRMService = mockStatic(WinRMService.class); + final MockedStatic mockedFactory = mockStatic(WinRMExecutorFactory.class); final MockedStatic mockedSmbTempShare = mockStatic(SmbTempShare.class); final MockedStatic mockedWindowsTempShare = mockStatic(WindowsTempShare.class); final MockedStatic mockedSmbConfig = mockStatic(SmbConfig.class) ) { - final WinRMService winRMService = mock(WinRMService.class); - mockedWinRMService - .when(() -> WinRMService.createInstance(winRMEndpoint, timeout, null, null)) - .thenReturn(winRMService); + final WindowsRemoteExecutor executor = mock(WindowsRemoteExecutor.class); + mockedFactory + .when(() -> WinRMExecutorFactory.createInstance(winRMEndpoint, timeout, null, null)) + .thenReturn(executor); final WindowsTempShare windowsTempShare = mock(WindowsTempShare.class); mockedWindowsTempShare - .when(() -> getOrCreateShare(eq(winRMService), anyLong(), any(ShareRemoteDirectoryConsumer.class))) + .when(() -> getOrCreateShare(eq(executor), anyLong(), any(ShareRemoteDirectoryConsumer.class))) .thenReturn(windowsTempShare); doReturn("\\\\2001-db8--85b-3c51-f5ff-ffdb.ipv6-literal.net\\SEN_ShareFor_PC-TEST$") .when(windowsTempShare) @@ -112,14 +113,14 @@ void testCreateInstance() throws Exception { final SmbTempShare smbTempShare1 = createInstance(winRMEndpoint, timeout, null, null); assertNotNull(smbTempShare1); assertEquals(1, smbTempShare1.getUseCount()); - assertEquals(winRMService, smbTempShare1.getWindowsRemoteExecutor()); + assertEquals(executor, smbTempShare1.getWindowsRemoteExecutor()); assertTrue(smbTempShare1.isConnected()); final SmbTempShare smbTempShare2 = createInstance(winRMEndpoint, timeout, null, null); assertNotNull(smbTempShare2); assertEquals(2, smbTempShare1.getUseCount()); assertEquals(2, smbTempShare2.getUseCount()); - assertEquals(winRMService, smbTempShare2.getWindowsRemoteExecutor()); + assertEquals(executor, smbTempShare2.getWindowsRemoteExecutor()); assertTrue(smbTempShare1.isConnected()); assertTrue(smbTempShare2.isConnected()); diff --git a/src/test/java/org/metricshub/winrm/wql/WinRMWqlExecutorTest.java b/src/test/java/org/metricshub/winrm/wql/WinRMWqlExecutorTest.java index c5f13cc..5090ddd 100644 --- a/src/test/java/org/metricshub/winrm/wql/WinRMWqlExecutorTest.java +++ b/src/test/java/org/metricshub/winrm/wql/WinRMWqlExecutorTest.java @@ -6,7 +6,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.metricshub.winrm.WinRMHttpProtocolEnum.HTTPS; -import static org.metricshub.winrm.service.WinRMService.createInstance; import static org.metricshub.winrm.service.client.auth.AuthenticationEnum.NTLM; import static org.metricshub.winrm.wql.WinRMWqlExecutor.executeWql; import static org.mockito.ArgumentMatchers.any; @@ -23,8 +22,9 @@ import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; +import org.metricshub.winrm.WindowsRemoteExecutor; import org.metricshub.winrm.service.WinRMEndpoint; -import org.metricshub.winrm.service.WinRMService; +import org.metricshub.winrm.service.WinRMExecutorFactory; import org.metricshub.winrm.service.client.auth.AuthenticationEnum; import org.mockito.MockedStatic; @@ -71,8 +71,8 @@ void testExecute() throws Exception { () -> executeWql(HTTPS, hostname, 5986, username, password, null, wqlQuery, 0L, ticketCache, authentications) ); - try (final MockedStatic mockedWinRMService = mockStatic(WinRMService.class)) { - final WinRMService winRMService = mock(WinRMService.class); + try (final MockedStatic mockedFactory = mockStatic(WinRMExecutorFactory.class)) { + final WindowsRemoteExecutor executor = mock(WindowsRemoteExecutor.class); final List> result = new ArrayList<>(); { @@ -88,11 +88,11 @@ void testExecute() throws Exception { result.add(row); } - mockedWinRMService - .when(() -> createInstance(any(WinRMEndpoint.class), anyLong(), isNull(), isNull())) - .thenReturn(winRMService); + mockedFactory + .when(() -> WinRMExecutorFactory.createInstance(any(WinRMEndpoint.class), anyLong(), isNull(), isNull())) + .thenReturn(executor); - doReturn(result).when(winRMService).executeWql(eq(wqlQuery), anyLong()); + doReturn(result).when(executor).executeWql(eq(wqlQuery), anyLong()); final WinRMWqlExecutor actual = executeWql( null, From df65e9787c3b805603954c84e3bde2d39202468c Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Thu, 23 Jul 2026 15:43:01 +0200 Subject: [PATCH 06/21] Harden light default: auth downgrade, hostname parsing, EndOfSequence scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address three Codex findings on the light-as-default change (dbb0ad4): - P1: LightWinRMService.createInstance now rejects ANY authentication list that contains a scheme it cannot honour, even when NTLM is also present. The list is an ordered fallback, so accepting [KERBEROS, NTLM] silently ignored the preferred Kerberos (and ticketCache) and downgraded to NTLM — weaker, and failing against NTLM-disabled servers. It now fails toward the -Dorg.metricshub.winrm.backend=cxf escape hatch instead. - P2: the light backend no longer re-parses the endpoint with java.net.URI, whose getHost()/getPort() return null/-1 for NetBIOS names with underscores (and Unicode hosts) that WinRMEndpoint accepts — which left the default backend unable to reach hosts the CXF backend could. It now uses WinRMEndpoint.getProtocol()/getHostname() and a new WinRMEndpoint.getPort() accessor. - P2: WQL enumeration control elements (EndOfSequence, EnumerationContext) are now matched by the WS-Enumeration namespace instead of local name alone, so a WMI property named "EndOfSequence" inside can no longer end the enumeration early and truncate results. Tests: WinRMExecutorFactoryTest.lightBackendRejectsMixedKerberosNtlm; WinRMEndpointTest.testGetPort and testUnderscoreHostnameEndpoint. Live-verified vs anaxagore: 49-row WQL, single-row WQL, and command all succeed. Co-Authored-By: Claude Opus 4.8 --- .../winrm/light/LightWinRMService.java | 33 ++++++++++++------- .../metricshub/winrm/light/WsmanClient.java | 24 +++++++++++--- .../winrm/service/WinRMEndpoint.java | 7 ++++ .../winrm/service/WinRMEndpointTest.java | 19 +++++++++++ .../service/WinRMExecutorFactoryTest.java | 17 ++++++++++ 5 files changed, 84 insertions(+), 16 deletions(-) diff --git a/src/main/java/org/metricshub/winrm/light/LightWinRMService.java b/src/main/java/org/metricshub/winrm/light/LightWinRMService.java index 88fd740..faac6f4 100644 --- a/src/main/java/org/metricshub/winrm/light/LightWinRMService.java +++ b/src/main/java/org/metricshub/winrm/light/LightWinRMService.java @@ -20,7 +20,6 @@ * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ */ -import java.net.URI; import java.nio.charset.Charset; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -29,6 +28,7 @@ import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeoutException; import org.metricshub.winrm.Utils; +import org.metricshub.winrm.WinRMHttpProtocolEnum; import org.metricshub.winrm.WindowsRemoteCommandResult; import org.metricshub.winrm.WindowsRemoteExecutor; import org.metricshub.winrm.WmiHelper; @@ -76,27 +76,36 @@ public static LightWinRMService createInstance( Utils.checkNonNull(winRMEndpoint, "winRMEndpoint"); Utils.checkArgumentNotZeroOrNegative(timeout, "timeout"); - if (authentications != null && !authentications.isEmpty() && !authentications.contains(AuthenticationEnum.NTLM)) { - throw new WinRMException( - "The light WinRM backend currently supports only NTLM authentication (requested: " + - authentications + - "). Select the CXF backend with -Dorg.metricshub.winrm.backend=cxf until light support lands." - ); + // Reject any list that requests a scheme the light backend cannot honour, even when NTLM is also + // present. The authentications list is an ordered fallback: accepting e.g. [KERBEROS, NTLM] would + // silently ignore the preferred Kerberos (and ticketCache) and downgrade to NTLM, which is weaker + // and fails against NTLM-disabled servers. Fail loudly toward the escape hatch instead. + if (authentications != null) { + for (final AuthenticationEnum requested : authentications) { + if (requested != AuthenticationEnum.NTLM) { + throw new WinRMException( + "The light WinRM backend currently supports only NTLM authentication (requested: " + + authentications + + "). Select the CXF backend with -Dorg.metricshub.winrm.backend=cxf until light support lands." + ); + } + } } - final URI uri = URI.create(winRMEndpoint.getEndpoint()); - if (!"http".equalsIgnoreCase(uri.getScheme())) { + if (winRMEndpoint.getProtocol() != WinRMHttpProtocolEnum.HTTP) { throw new WinRMException( "The light WinRM backend currently supports only HTTP (endpoint was " + winRMEndpoint.getEndpoint() + "). Select the CXF backend with -Dorg.metricshub.winrm.backend=cxf until light support lands." ); } - final int port = uri.getPort() > 0 ? uri.getPort() : 5985; + // Use the endpoint's own validated host/port rather than re-parsing the URL: URI.getHost()/getPort() + // return null/-1 for names URI cannot classify (underscores, Unicode) that WinRMEndpoint accepts, + // which would otherwise make the default backend unable to reach hosts the CXF backend could. final WsmanClient client = new WsmanClient( - uri.getHost(), - port, + winRMEndpoint.getHostname(), + winRMEndpoint.getPort(), winRMEndpoint.getDomain(), winRMEndpoint.getUsername(), new String(winRMEndpoint.getPassword()), diff --git a/src/main/java/org/metricshub/winrm/light/WsmanClient.java b/src/main/java/org/metricshub/winrm/light/WsmanClient.java index cb0cd24..16936b7 100644 --- a/src/main/java/org/metricshub/winrm/light/WsmanClient.java +++ b/src/main/java/org/metricshub/winrm/light/WsmanClient.java @@ -66,6 +66,11 @@ final class WsmanClient implements AutoCloseable { // challenge boundary (start of value or right after a comma) and capture just its base64 token. private static final Pattern NEGOTIATE_TOKEN = Pattern.compile("(?i)(?:^|,)\\s*Negotiate\\s+([A-Za-z0-9+/=]+)"); + // WS-Enumeration namespace: the EndOfSequence / EnumerationContext markers live here. Match them by + // namespace, never by local name alone, so a WMI property that happens to be named "EndOfSequence" + // or "EnumerationContext" inside cannot be mistaken for the enumeration control element. + private static final String WS_ENUMERATION_NS = "http://schemas.xmlsoap.org/ws/2004/09/enumeration"; + private final long timeoutMs; private final String url; private final WinRMSession session; @@ -122,13 +127,13 @@ List> wql(final String namespace, final String query) throws // Pull until the server signals EndOfSequence (matching the CXF backend). The aggregate // timeout in LightWinRMService bounds a misbehaving server that never ends the sequence. - boolean endOfSequence = doc.getElementsByTagNameNS("*", "EndOfSequence").getLength() > 0; - String context = endOfSequence ? null : text(doc, "EnumerationContext"); + boolean endOfSequence = hasEnumerationElement(doc, "EndOfSequence"); + String context = endOfSequence ? null : textNS(doc, WS_ENUMERATION_NS, "EnumerationContext"); while (!endOfSequence && context != null && !context.isEmpty()) { doc = expectOk(Envelopes.pull(url, ns, context, timeoutMs), "Pull"); collectItems(doc, rows); - endOfSequence = doc.getElementsByTagNameNS("*", "EndOfSequence").getLength() > 0; - context = endOfSequence ? null : text(doc, "EnumerationContext"); + endOfSequence = hasEnumerationElement(doc, "EndOfSequence"); + context = endOfSequence ? null : textNS(doc, WS_ENUMERATION_NS, "EnumerationContext"); } return rows; } @@ -367,6 +372,17 @@ private static String text(final Document doc, final String localName) { return nodes.getLength() > 0 ? nodes.item(0).getTextContent() : null; } + /** Whether the document contains the given WS-Enumeration control element (namespace-scoped). */ + private static boolean hasEnumerationElement(final Document doc, final String localName) { + return doc.getElementsByTagNameNS(WS_ENUMERATION_NS, localName).getLength() > 0; + } + + /** First text content of an element matched by both namespace and local name. */ + private static String textNS(final Document doc, final String namespace, final String localName) { + final NodeList nodes = doc.getElementsByTagNameNS(namespace, localName); + return nodes.getLength() > 0 ? nodes.item(0).getTextContent() : null; + } + private static void collectItems(final Document doc, final List> rows) { final NodeList items = doc.getElementsByTagNameNS("*", "Items"); for (int i = 0; i < items.getLength(); i++) { diff --git a/src/main/java/org/metricshub/winrm/service/WinRMEndpoint.java b/src/main/java/org/metricshub/winrm/service/WinRMEndpoint.java index bf2885c..81aa82f 100644 --- a/src/main/java/org/metricshub/winrm/service/WinRMEndpoint.java +++ b/src/main/java/org/metricshub/winrm/service/WinRMEndpoint.java @@ -41,6 +41,7 @@ public class WinRMEndpoint { private final String namespace; private final String rawUsername; private final WinRMHttpProtocolEnum protocol; + private final int port; /** * Constructor of the endpoint and credentials for WinRM. @@ -81,6 +82,7 @@ public WinRMEndpoint( } this.protocol = protocol != null ? protocol : WinRMHttpProtocolEnum.HTTP; + this.port = getEndpointPort(this.protocol, port); final String endpointUrl = buildEndpointUrl(this.protocol, this.hostname, port); endpoint = buildWSManEndpoint(endpointUrl); @@ -126,6 +128,11 @@ public WinRMHttpProtocolEnum getProtocol() { return protocol; } + /** Get the resolved endpoint port (the port provided to the constructor, or the protocol default). */ + public int getPort() { + return port; + } + /** * Build the endpoint URL. * diff --git a/src/test/java/org/metricshub/winrm/service/WinRMEndpointTest.java b/src/test/java/org/metricshub/winrm/service/WinRMEndpointTest.java index 730cd95..17122a1 100644 --- a/src/test/java/org/metricshub/winrm/service/WinRMEndpointTest.java +++ b/src/test/java/org/metricshub/winrm/service/WinRMEndpointTest.java @@ -105,6 +105,25 @@ void testGetEndPointPort() { assertEquals(5986, getEndpointPort(HTTPS, null)); } + @Test + void testGetPort() { + assertEquals(5985, new WinRMEndpoint(HTTP, HOSTNAME, null, USER, PASSWORD, null).getPort()); + assertEquals(5986, new WinRMEndpoint(HTTPS, HOSTNAME, null, USER, PASSWORD, null).getPort()); + assertEquals(PORT, new WinRMEndpoint(HTTP, HOSTNAME, PORT, USER, PASSWORD, null).getPort()); + assertEquals(PORT, new WinRMEndpoint(HTTPS, HOSTNAME, PORT, USER, PASSWORD, null).getPort()); + } + + @Test + void testUnderscoreHostnameEndpoint() { + // Regression guard for the light backend default: WinRMEndpoint accepts NetBIOS-style names with + // underscores and exposes a usable hostname/port, whereas java.net.URI cannot classify such a host + // (URI.create(endpoint).getHost() is null). The light backend must rely on these accessors. + final WinRMEndpoint winRMEndpoint = new WinRMEndpoint(HTTP, "server_name", 5999, USER, PASSWORD, null); + assertEquals("server_name", winRMEndpoint.getHostname()); + assertEquals(5999, winRMEndpoint.getPort()); + assertNull(java.net.URI.create(winRMEndpoint.getEndpoint()).getHost()); + } + @Test void testBuildNamespace() { assertEquals("ROOT/CIMV2", buildNamespace(null)); diff --git a/src/test/java/org/metricshub/winrm/service/WinRMExecutorFactoryTest.java b/src/test/java/org/metricshub/winrm/service/WinRMExecutorFactoryTest.java index fa3985c..a345cac 100644 --- a/src/test/java/org/metricshub/winrm/service/WinRMExecutorFactoryTest.java +++ b/src/test/java/org/metricshub/winrm/service/WinRMExecutorFactoryTest.java @@ -120,4 +120,21 @@ void lightBackendRejectsKerberosOnly() { ) ); } + + @Test + void lightBackendRejectsMixedKerberosNtlm() { + // A fallback list like [KERBEROS, NTLM] must be rejected, not silently downgraded to NTLM: the + // light backend cannot honour the preferred Kerberos scheme, so it points at the CXF escape hatch. + System.setProperty(WinRMExecutorFactory.BACKEND_PROPERTY, "light"); + assertThrows( + WinRMException.class, + () -> + WinRMExecutorFactory.createInstance( + endpoint(WinRMHttpProtocolEnum.HTTP), + 30000L, + null, + List.of(AuthenticationEnum.KERBEROS, AuthenticationEnum.NTLM) + ) + ); + } } From 40d54105ff94dd62226bf11496b3de52101545a3 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Thu, 23 Jul 2026 16:00:18 +0200 Subject: [PATCH 07/21] Serialize operations on the light backend's NTLM connection (P1 review) A single NTLM connection is a serial channel: one socket, stateful RC4 ciphers with sequence numbers, and one shellId. The prior requestInFlight flag only recorded activity; it did not exclude. When one executor is shared across threads (notably the cached SmbTempShare), concurrent callers could interleave on the socket and cipher streams, reading each other's responses or advancing the signing/sealing sequence out of order -> corrupted exchanges and checksum failures. Replace requestInFlight with a ReentrantLock held for the whole high-level operation (wql: Enumerate + all Pulls; executeCommand: Create + Command + Receive loop + Signal), so operations never interleave and the shared shellId stays consistent. close() uses tryLock (never lock): if it cannot acquire the lock an operation is in progress (e.g. a worker abandoned by a command timeout, still blocked on a socket read), so it skips the graceful shell Delete and hard-closes the transport, which unblocks that worker's read exactly as before. Verified vs anaxagore (light backend): 12 concurrent ops on one shared executor all succeed with no corruption; under aggressive load the only failures are clean, decryptable server quota faults (integrity intact), whereas the CXF backend under the same shared-executor load returns HTTP 400s. The command-timeout path still returns in ~3s (not ~13s), confirming close() still unblocks abandoned workers. Co-Authored-By: Claude Opus 4.8 --- .../metricshub/winrm/light/WsmanClient.java | 168 ++++++++++-------- 1 file changed, 94 insertions(+), 74 deletions(-) diff --git a/src/main/java/org/metricshub/winrm/light/WsmanClient.java b/src/main/java/org/metricshub/winrm/light/WsmanClient.java index 16936b7..7b7c620 100644 --- a/src/main/java/org/metricshub/winrm/light/WsmanClient.java +++ b/src/main/java/org/metricshub/winrm/light/WsmanClient.java @@ -29,7 +29,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; -import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.locks.ReentrantLock; import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.xml.XMLConstants; @@ -79,10 +79,12 @@ final class WsmanClient implements AutoCloseable { private String pendingAuthorization; private String shellId; - // True while a SOAP request is on the wire. close() consults this to avoid racing an abandoned - // worker (e.g. a Receive left blocked on the socket after a command timeout) on the shared - // socket and stateful RC4 session. - private final AtomicBoolean requestInFlight = new AtomicBoolean(false); + // A single NTLM connection is a serial channel: one socket, stateful RC4 ciphers with sequence + // numbers, and a single shellId. Concurrent callers (e.g. a cached SmbTempShare shared across + // threads) MUST NOT interleave, or they read each other's responses and desync the cipher streams. + // Every high-level operation (wql/executeCommand) runs while holding this lock; close() only + // tries it, so it can still hard-close the transport to unblock an abandoned, timed-out worker. + private final ReentrantLock operationLock = new ReentrantLock(); WsmanClient( final String host, @@ -118,24 +120,31 @@ private static final class Decoded { /** Run a WQL query and return the rows as ordered property maps. */ List> wql(final String namespace, final String query) throws Exception { - // WMI namespaces are case-insensitive, but preserve the caller's case to match the CXF backend. - final String ns = namespace.replace('\\', '/'); - final List> rows = new ArrayList<>(); - - Document doc = expectOk(Envelopes.enumerateWql(url, ns, query, timeoutMs), "Enumerate"); - collectItems(doc, rows); - - // Pull until the server signals EndOfSequence (matching the CXF backend). The aggregate - // timeout in LightWinRMService bounds a misbehaving server that never ends the sequence. - boolean endOfSequence = hasEnumerationElement(doc, "EndOfSequence"); - String context = endOfSequence ? null : textNS(doc, WS_ENUMERATION_NS, "EnumerationContext"); - while (!endOfSequence && context != null && !context.isEmpty()) { - doc = expectOk(Envelopes.pull(url, ns, context, timeoutMs), "Pull"); + // Serialize the whole enumeration (Enumerate + all Pulls) against any other operation sharing + // this connection; see operationLock. + operationLock.lock(); + try { + // WMI namespaces are case-insensitive, but preserve the caller's case to match the CXF backend. + final String ns = namespace.replace('\\', '/'); + final List> rows = new ArrayList<>(); + + Document doc = expectOk(Envelopes.enumerateWql(url, ns, query, timeoutMs), "Enumerate"); collectItems(doc, rows); - endOfSequence = hasEnumerationElement(doc, "EndOfSequence"); - context = endOfSequence ? null : textNS(doc, WS_ENUMERATION_NS, "EnumerationContext"); + + // Pull until the server signals EndOfSequence (matching the CXF backend). The aggregate + // timeout in LightWinRMService bounds a misbehaving server that never ends the sequence. + boolean endOfSequence = hasEnumerationElement(doc, "EndOfSequence"); + String context = endOfSequence ? null : textNS(doc, WS_ENUMERATION_NS, "EnumerationContext"); + while (!endOfSequence && context != null && !context.isEmpty()) { + doc = expectOk(Envelopes.pull(url, ns, context, timeoutMs), "Pull"); + collectItems(doc, rows); + endOfSequence = hasEnumerationElement(doc, "EndOfSequence"); + context = endOfSequence ? null : textNS(doc, WS_ENUMERATION_NS, "EnumerationContext"); + } + return rows; + } finally { + operationLock.unlock(); } - return rows; } /** The result of running a command in the remote shell. */ @@ -155,15 +164,22 @@ static final class CommandOutput { /** Execute a command in the remote command shell, creating the shell on first use. */ CommandOutput executeCommand(final String commandLine, final String workingDirectory, final Charset charset) throws Exception { - if (shellId == null) { - createShell(workingDirectory); - } - final Charset cs = charset != null ? charset : StandardCharsets.UTF_8; - final String commandId = startCommand(commandLine); + // Serialize the whole shell lifecycle (Create + Command + Receive loop + Signal) against any + // other operation sharing this connection and the shellId field; see operationLock. + operationLock.lock(); try { - return receiveLoop(commandId, cs); + if (shellId == null) { + createShell(workingDirectory); + } + final Charset cs = charset != null ? charset : StandardCharsets.UTF_8; + final String commandId = startCommand(commandLine); + try { + return receiveLoop(commandId, cs); + } finally { + terminate(commandId); + } } finally { - terminate(commandId); + operationLock.unlock(); } } @@ -229,40 +245,39 @@ private Document expectOk(final String soap, final String operation) throws Exce return resp.document; } - /** Send one SOAP request (authenticating the connection on first use) and decrypt the response. */ + /** + * Send one SOAP request (authenticating the connection on first use) and decrypt the response. + * The caller must hold {@link #operationLock}; every path here is reached from a locked + * wql/executeCommand/close, so requests never interleave on the stateful NTLM connection. + */ private Decoded request(final String soap) throws Exception { - requestInFlight.set(true); - try { - // If the connection was dropped (e.g. the server sent "Connection: close"), the NTLM session - // bound to it is dead — re-handshake on the fresh connection rather than sending unauthenticated. - if (session.isAuthenticated() && !transport.isConnected()) { - session.reset(); - } - if (!session.isAuthenticated()) { - authenticate(); - } - // The Type 3 authorization accompanies the first encrypted payload; later requests on the - // already-authenticated connection carry no Authorization header. - final String authorization = pendingAuthorization; - pendingAuthorization = null; - - final byte[] encrypted = NtlmCrypto.encryptAndSign(session, soap.getBytes(StandardCharsets.UTF_8)); - final HttpTransport.Response resp = transport.post( - "/wsman", - encrypted, - NtlmCrypto.ENCRYPTED_CONTENT_TYPE, - authorization - ); + // If the connection was dropped (e.g. the server sent "Connection: close"), the NTLM session + // bound to it is dead — re-handshake on the fresh connection rather than sending unauthenticated. + if (session.isAuthenticated() && !transport.isConnected()) { + session.reset(); + } + if (!session.isAuthenticated()) { + authenticate(); + } + // The Type 3 authorization accompanies the first encrypted payload; later requests on the + // already-authenticated connection carry no Authorization header. + final String authorization = pendingAuthorization; + pendingAuthorization = null; - // 200 = success, 500 = SOAP fault (both bodies are encrypted). Anything else is a protocol - // or authentication failure whose body is not a usable WSMan response. - if (resp.status != 200 && resp.status != 500) { - throw new IllegalStateException("WSMan request failed: HTTP " + resp.status); - } - return new Decoded(resp.status, decryptResponse(resp)); - } finally { - requestInFlight.set(false); + final byte[] encrypted = NtlmCrypto.encryptAndSign(session, soap.getBytes(StandardCharsets.UTF_8)); + final HttpTransport.Response resp = transport.post( + "/wsman", + encrypted, + NtlmCrypto.ENCRYPTED_CONTENT_TYPE, + authorization + ); + + // 200 = success, 500 = SOAP fault (both bodies are encrypted). Anything else is a protocol + // or authentication failure whose body is not a usable WSMan response. + if (resp.status != 200 && resp.status != 500) { + throw new IllegalStateException("WSMan request failed: HTTP " + resp.status); } + return new Decoded(resp.status, decryptResponse(resp)); } private void authenticate() throws Exception { @@ -460,22 +475,27 @@ private static String faultSummary(final Decoded resp) { @Override public void close() { - final String shell = shellId; - shellId = null; - // If a request is still in flight, another thread is blocked on this socket — e.g. a command - // that exceeded its timeout, where Utils.execute abandons the worker mid-Receive while - // try-with-resources calls close() here. Issuing a graceful shell Delete now would start a - // second SOAP exchange over the SAME socket and stateful RC4 session, racing the worker - // (cipher-sequence corruption, crossed responses, or a stall until the socket read timeout). - // In that case, just hard-close the transport below: it unblocks the worker's read, and the - // abandoned shell is reaped by the server's IdleTimeout. - if (shell != null && !requestInFlight.get()) { - try { - request(Envelopes.deleteShell(url, shell, timeoutMs)); - } catch (final Exception ignore) { - // best-effort shell cleanup + // Only attempt a graceful shell Delete if no operation is currently using the connection: a + // blocking tryLock (never a lock()) keeps close() from waiting on an abandoned, timed-out worker + // still holding operationLock while blocked on a socket read. When we cannot acquire the lock, + // or a request would otherwise race the worker, we skip the Delete and just hard-close the + // transport below — which unblocks that worker's read; the shell is reaped by the server IdleTimeout. + final boolean locked = operationLock.tryLock(); + try { + final String shell = shellId; + shellId = null; + if (locked && shell != null) { + try { + request(Envelopes.deleteShell(url, shell, timeoutMs)); + } catch (final Exception ignore) { + // best-effort shell cleanup + } + } + } finally { + if (locked) { + operationLock.unlock(); } + transport.close(); } - transport.close(); } } From 9c8af0b95e8ba602e7ab4916e5602156774dc1c1 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Thu, 23 Jul 2026 16:49:27 +0200 Subject: [PATCH 08/21] Reconnect the light backend after a peer closes an idle keep-alive socket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Socket.isClosed() only reflects LOCAL closure, so a WinRM server or intermediary that silently drops an idle keep-alive connection was invisible: isConnected() stayed true, the next operation reused the stale socket, and its write/read failed — losing the first command or query after an idle gap (most visibly for a cached SmbTempShare executor sitting between polling cycles). HttpTransport.isConnected() now proactively probes a reused connection with a 1ms blocking read once it has been idle past VALIDATE_AFTER_INACTIVITY_MS (1s): a healthy idle keep-alive has nothing to read and times out (alive), whereas EOF or unexpected bytes mean the peer closed it (stale) — in which case the socket is closed and isConnected() returns false. request() already resets the connection- bound NTLM session and reconnects when the transport is not connected, so the handshake re-runs transparently before sending; no request is lost. Chose proactive detection over retry-after-failure deliberately: retrying a failed send could double-execute a non-idempotent command if the drop happened after the server processed it, whereas detecting staleness before sending has no such risk. The 1s threshold keeps the probe from firing between the back-to-back requests of a single operation, so hot paths pay nothing. Verified vs anaxagore (light backend): a shared executor reused across 2s idle gaps succeeds every round (probe classifies the live connection as healthy and does not disturb its stream); a 49-row multi-Pull WQL is unaffected; the command-timeout path still returns in ~3s. Co-Authored-By: Claude Opus 4.8 --- .../metricshub/winrm/light/HttpTransport.java | 59 ++++++++++++++++++- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/metricshub/winrm/light/HttpTransport.java b/src/main/java/org/metricshub/winrm/light/HttpTransport.java index 5055267..ca6eb66 100644 --- a/src/main/java/org/metricshub/winrm/light/HttpTransport.java +++ b/src/main/java/org/metricshub/winrm/light/HttpTransport.java @@ -26,10 +26,12 @@ import java.io.OutputStream; import java.net.InetSocketAddress; import java.net.Socket; +import java.net.SocketTimeoutException; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import java.util.Locale; +import org.metricshub.winrm.Utils; /** * Minimal HTTP/1.1 client over a single kept-alive TCP socket. WinRM's NTLM authentication is @@ -38,12 +40,19 @@ */ final class HttpTransport implements AutoCloseable { + // After a connection has been idle at least this long, validate it before reuse: a peer may have + // silently dropped an idle keep-alive connection, which Socket.isClosed() cannot detect. Kept small + // so it never fires between the back-to-back requests of a single operation, only across idle gaps + // (e.g. a cached executor sitting between polling cycles). + private static final long VALIDATE_AFTER_INACTIVITY_MS = 1000; + private final String host; private final int port; private final int timeoutMillis; private Socket socket; private OutputStream out; private BufferedInputStream in; + private long lastActivityMillis; HttpTransport(final String host, final int port, final int timeoutMillis) { this.host = host; @@ -87,7 +96,50 @@ List allHeaders(final String name) { /** Whether a live connection is currently held. */ boolean isConnected() { - return socket != null && !socket.isClosed(); + if (socket == null || socket.isClosed() || !socket.isConnected()) { + return false; + } + // Socket.isClosed() only reflects LOCAL closure: a server (or intermediary) that dropped an idle + // keep-alive connection is invisible until the next write/read fails, which would silently lose + // the first operation after the idle gap. Once the connection has been idle a while, probe it so + // we treat it as dead here — request() then resets the NTLM session and reconnects before sending. + if (Utils.getCurrentTimeMillis() - lastActivityMillis >= VALIDATE_AFTER_INACTIVITY_MS && isStalePeerClosed()) { + close(); + return false; + } + return true; + } + + /** + * Probe whether the peer has closed the connection, using a very short blocking read. A healthy idle + * keep-alive connection has no readable bytes, so the read times out (returns {@code false}); any + * byte or EOF means the connection is unusable (returns {@code true}). The read only ever consumes + * data on the unusable path, where the socket is discarded anyway, so a live stream is never disturbed. + */ + private boolean isStalePeerClosed() { + final int previousTimeout; + try { + previousTimeout = socket.getSoTimeout(); + } catch (final IOException e) { + return true; + } + try { + socket.setSoTimeout(1); + // A healthy idle keep-alive has nothing to read, so this blocks and times out (caught below). + // Any return — EOF (peer closed) or an unexpected byte (protocol desync) — means it is unusable. + in.read(); + return true; + } catch (final SocketTimeoutException e) { + return false; + } catch (final IOException e) { + return true; + } finally { + try { + socket.setSoTimeout(previousTimeout); + } catch (final IOException ignore) { + // socket is being discarded on the stale path anyway + } + } } private void ensureConnected() throws IOException { @@ -104,6 +156,7 @@ private void ensureConnected() throws IOException { socket = newSocket; out = socket.getOutputStream(); in = new BufferedInputStream(socket.getInputStream()); + lastActivityMillis = Utils.getCurrentTimeMillis(); } catch (final IOException e) { // Never leave a half-open socket in the field, or ensureConnected would skip reconnecting. try { @@ -149,7 +202,9 @@ Response post(final String path, final byte[] body, final String contentType, fi out.write(request.toByteArray()); out.flush(); - return readResponse(); + final Response response = readResponse(); + lastActivityMillis = Utils.getCurrentTimeMillis(); + return response; } catch (final IOException | RuntimeException e) { // A broken write/read leaves the socket in an unknown state and its read position possibly // corrupted; close it so the next request establishes a fresh (re-authenticated) connection. From 6b61322438963d013e03d078026461af69e37942 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Thu, 23 Jul 2026 17:14:17 +0200 Subject: [PATCH 09/21] Harden light backend: reject unknown backend, clamp timeout, closed-state guard (P2 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address three P2 Codex findings on PR #109: - WinRMExecutorFactory now accepts only "light" (default) and "cxf"; any other value (a typo like "cxff", or an unsupported future value) throws WinRMException instead of silently falling through to the light backend — which could run a different implementation than the operator requested and emit misleading escape-hatch hints. - WsmanClient converts the public long timeout to the int a Socket accepts via a clamped helper (min(millis, Integer.MAX_VALUE - 10_000)), so a large but valid timeout no longer narrows to a negative/garbage value, and HttpTransport's +10s read-timeout headroom cannot overflow int. The full long remains authoritative for the WSMan OperationTimeout and the service-level wall-clock deadline. - LightWinRMService tracks a closed flag: close() is idempotent (releases the connection once) and marks the executor closed, and executeWql/executeCommand now reject use after close with IllegalStateException instead of silently reviving the instance with a fresh handshake — matching the close() contract and the CXF path. Tests: WinRMExecutorFactoryTest.unsupportedBackendValueRejected and closedLightExecutorRejectsOperations. mvn verify 42 tests green; live-verified vs anaxagore (wql, cmd, and the unknown-backend rejection). Co-Authored-By: Claude Opus 4.8 --- .../winrm/light/LightWinRMService.java | 16 ++++++++- .../metricshub/winrm/light/WsmanClient.java | 12 ++++++- .../winrm/service/WinRMExecutorFactory.java | 20 +++++++++-- .../service/WinRMExecutorFactoryTest.java | 35 +++++++++++++++++++ 4 files changed, 79 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/metricshub/winrm/light/LightWinRMService.java b/src/main/java/org/metricshub/winrm/light/LightWinRMService.java index faac6f4..04d1a2f 100644 --- a/src/main/java/org/metricshub/winrm/light/LightWinRMService.java +++ b/src/main/java/org/metricshub/winrm/light/LightWinRMService.java @@ -27,6 +27,7 @@ import java.util.Map; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; import org.metricshub.winrm.Utils; import org.metricshub.winrm.WinRMHttpProtocolEnum; import org.metricshub.winrm.WindowsRemoteCommandResult; @@ -51,6 +52,7 @@ public final class LightWinRMService implements WindowsRemoteExecutor { private final WinRMEndpoint winRMEndpoint; private final WsmanClient client; + private final AtomicBoolean closed = new AtomicBoolean(false); private LightWinRMService(final WinRMEndpoint winRMEndpoint, final WsmanClient client) { this.winRMEndpoint = winRMEndpoint; @@ -117,6 +119,7 @@ public static LightWinRMService createInstance( @Override public List> executeWql(final String wqlQuery, final long timeout) throws TimeoutException, WqlQuerySyntaxException, WindowsRemoteException { + checkNotClosed(); Utils.checkNonNull(wqlQuery, "wqlQuery"); if (!WmiHelper.isValidWql(wqlQuery)) { throw new WqlQuerySyntaxException(wqlQuery); @@ -152,6 +155,7 @@ public WindowsRemoteCommandResult executeCommand( final Charset charset, final long timeout ) throws WindowsRemoteException, TimeoutException { + checkNotClosed(); Utils.checkNonNull(command, "command"); Utils.checkArgumentNotZeroOrNegative(timeout, "timeout"); @@ -192,6 +196,16 @@ public char[] getPassword() { @Override public void close() { - client.close(); + // Idempotent: releases the underlying connection exactly once, and marks the executor closed so + // a later operation is rejected rather than silently reviving it with a fresh handshake. + if (closed.compareAndSet(false, true)) { + client.close(); + } + } + + private void checkNotClosed() { + if (closed.get()) { + throw new IllegalStateException("This WinRM executor has been closed; create a new one."); + } } } diff --git a/src/main/java/org/metricshub/winrm/light/WsmanClient.java b/src/main/java/org/metricshub/winrm/light/WsmanClient.java index 7b7c620..175ca15 100644 --- a/src/main/java/org/metricshub/winrm/light/WsmanClient.java +++ b/src/main/java/org/metricshub/winrm/light/WsmanClient.java @@ -103,7 +103,17 @@ final class WsmanClient implements AutoCloseable { // Workstation is left empty in the Type 3 message, matching the reference client. final String upperDomain = domain == null ? null : domain.toUpperCase(Locale.ROOT); this.session = new WinRMSession(upperDomain, null, username, password); - this.transport = new HttpTransport(host, port, (int) timeoutMs); + this.transport = new HttpTransport(host, port, toSocketTimeoutMillis(timeoutMs)); + } + + /** + * Convert the public {@code long} timeout to the {@code int} milliseconds a {@link java.net.Socket} + * accepts. Clamp so a large but valid timeout never narrows to a negative/garbage value, leaving + * headroom for the extra read-timeout seconds {@link HttpTransport} adds. The full {@code long} + * remains authoritative for the WSMan OperationTimeout and the wall-clock deadline in the service. + */ + private static int toSocketTimeoutMillis(final long millis) { + return (int) Math.min(millis, Integer.MAX_VALUE - 10_000L); } /** A decrypted WSMan response: HTTP status plus the (decrypted) SOAP body. */ diff --git a/src/main/java/org/metricshub/winrm/service/WinRMExecutorFactory.java b/src/main/java/org/metricshub/winrm/service/WinRMExecutorFactory.java index b2bd187..c835304 100644 --- a/src/main/java/org/metricshub/winrm/service/WinRMExecutorFactory.java +++ b/src/main/java/org/metricshub/winrm/service/WinRMExecutorFactory.java @@ -41,6 +41,7 @@ public final class WinRMExecutorFactory { /** System property selecting the backend: {@code light} (default) or {@code cxf}. */ public static final String BACKEND_PROPERTY = "org.metricshub.winrm.backend"; + private static final String LIGHT = "light"; private static final String CXF = "cxf"; private WinRMExecutorFactory() {} @@ -61,10 +62,25 @@ public static WindowsRemoteExecutor createInstance( final Path ticketCache, final List authentications ) throws WinRMException { - final String backend = System.getProperty(BACKEND_PROPERTY, "light").trim().toLowerCase(Locale.ROOT); + final String backend = System.getProperty(BACKEND_PROPERTY, LIGHT).trim().toLowerCase(Locale.ROOT); + if (LIGHT.equals(backend)) { + return LightWinRMService.createInstance(winRMEndpoint, timeout, ticketCache, authentications); + } if (CXF.equals(backend)) { return WinRMService.createInstance(winRMEndpoint, timeout, ticketCache, authentications); } - return LightWinRMService.createInstance(winRMEndpoint, timeout, ticketCache, authentications); + // Fail loudly on a typo or unsupported value rather than silently running a backend the operator + // did not ask for (which would also emit misleading "set the property" hints downstream). + throw new WinRMException( + "Unsupported value \"" + + backend + + "\" for system property " + + BACKEND_PROPERTY + + "; expected \"" + + LIGHT + + "\" (default) or \"" + + CXF + + "\"." + ); } } diff --git a/src/test/java/org/metricshub/winrm/service/WinRMExecutorFactoryTest.java b/src/test/java/org/metricshub/winrm/service/WinRMExecutorFactoryTest.java index a345cac..4b9cd45 100644 --- a/src/test/java/org/metricshub/winrm/service/WinRMExecutorFactoryTest.java +++ b/src/test/java/org/metricshub/winrm/service/WinRMExecutorFactoryTest.java @@ -137,4 +137,39 @@ void lightBackendRejectsMixedKerberosNtlm() { ) ); } + + @Test + void unsupportedBackendValueRejected() { + // A typo or unknown value must fail loudly instead of silently falling through to a backend the + // operator did not request. + System.setProperty(WinRMExecutorFactory.BACKEND_PROPERTY, "cxff"); + assertThrows( + WinRMException.class, + () -> + WinRMExecutorFactory.createInstance( + endpoint(WinRMHttpProtocolEnum.HTTP), + 30000L, + null, + List.of(AuthenticationEnum.NTLM) + ) + ); + } + + @Test + void closedLightExecutorRejectsOperations() throws Exception { + // close() must release the executor for good: a later operation is rejected, not silently served + // by a fresh reconnect/handshake. + System.setProperty(WinRMExecutorFactory.BACKEND_PROPERTY, "light"); + final WindowsRemoteExecutor executor = WinRMExecutorFactory.createInstance( + endpoint(WinRMHttpProtocolEnum.HTTP), + 30000L, + null, + List.of(AuthenticationEnum.NTLM) + ); + executor.close(); + assertThrows( + IllegalStateException.class, + () -> executor.executeWql("SELECT Name FROM Win32_OperatingSystem", 30000L) + ); + } } From 1a93929b42e74764d4bfbee8182aa3be4dffe731 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Thu, 23 Jul 2026 18:01:42 +0200 Subject: [PATCH 10/21] Add HTTPS support to the light backend (validate TLS by default) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The light backend now handles NTLM over HTTPS in addition to HTTP. - LightTls: the HTTPS socket factory. Validates by default — the JDK default SSLSocketFactory honors the platform trust store (and -Djavax.net.ssl.trustStore) and HttpTransport enables hostname verification. This is the opposite of the CXF path (which trusts every certificate; see #74). Opt out only via the system property org.metricshub.winrm.tls.insecure=true (trust-all + skip hostname check), for self-signed test hosts — insecure, not for production. - HttpTransport: when given an SSLSocketFactory it wraps the connection in TLS, sets endpointIdentificationAlgorithm=HTTPS (hostname verification) before connect, and forces startHandshake() so certificate failures surface immediately. The connect-failure cleanup now also catches RuntimeException so a TLS-setup failure cannot leak the socket. - WsmanClient: over HTTPS, WinRM exchanges PLAINTEXT SOAP inside TLS. So it authenticates NTLM WITHOUT negotiating SEAL (TYPE1_FLAGS_PLAIN) and marks the session authenticated without deriving RC4 keys; request() sends plaintext and decodeResponse() parses it directly. The "reject unencrypted response after auth" guard stays HTTP-only (over HTTP the seal is the sole integrity guard; over TLS the transport provides it). The HTTP path is byte-identical to before. - LightWinRMService no longer rejects HTTPS; it builds the TLS factory from the endpoint protocol. Build green (44 tests, incl. LightTlsTest and HTTPS acceptance in WinRMExecutorFactoryTest). A 14-agent adversarial review found no security, framing, or HTTP-regression issues (only the socket-leak nit fixed above). NOT yet live-verified end-to-end — pending a WinRM HTTPS listener to test against. Co-Authored-By: Claude Opus 4.8 --- .../metricshub/winrm/light/HttpTransport.java | 37 +++++++- .../org/metricshub/winrm/light/LightTls.java | 94 +++++++++++++++++++ .../winrm/light/LightWinRMService.java | 21 +++-- .../metricshub/winrm/light/WinRMSession.java | 8 ++ .../metricshub/winrm/light/WsmanClient.java | 62 ++++++++---- .../metricshub/winrm/light/LightTlsTest.java | 32 +++++++ .../service/WinRMExecutorFactoryTest.java | 49 +++++----- 7 files changed, 248 insertions(+), 55 deletions(-) create mode 100644 src/main/java/org/metricshub/winrm/light/LightTls.java create mode 100644 src/test/java/org/metricshub/winrm/light/LightTlsTest.java diff --git a/src/main/java/org/metricshub/winrm/light/HttpTransport.java b/src/main/java/org/metricshub/winrm/light/HttpTransport.java index ca6eb66..d33979d 100644 --- a/src/main/java/org/metricshub/winrm/light/HttpTransport.java +++ b/src/main/java/org/metricshub/winrm/light/HttpTransport.java @@ -31,6 +31,9 @@ import java.util.ArrayList; import java.util.List; import java.util.Locale; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; import org.metricshub.winrm.Utils; /** @@ -49,15 +52,30 @@ final class HttpTransport implements AutoCloseable { private final String host; private final int port; private final int timeoutMillis; + // Non-null => HTTPS: the socket is wrapped in TLS. Null => plain HTTP. + private final SSLSocketFactory sslSocketFactory; + private final boolean verifyHostname; private Socket socket; private OutputStream out; private BufferedInputStream in; private long lastActivityMillis; HttpTransport(final String host, final int port, final int timeoutMillis) { + this(host, port, timeoutMillis, null, false); + } + + HttpTransport( + final String host, + final int port, + final int timeoutMillis, + final SSLSocketFactory sslSocketFactory, + final boolean verifyHostname + ) { this.host = host; this.port = port; this.timeoutMillis = timeoutMillis; + this.sslSocketFactory = sslSocketFactory; + this.verifyHostname = verifyHostname; } static final class Response { @@ -146,19 +164,34 @@ private void ensureConnected() throws IOException { if (isConnected()) { return; } - final Socket newSocket = new Socket(); + final Socket newSocket = sslSocketFactory == null ? new Socket() : sslSocketFactory.createSocket(); try { newSocket.setTcpNoDelay(true); + if (newSocket instanceof SSLSocket && verifyHostname) { + // Turn on hostname verification against the server certificate during the handshake + // (raw SSLSockets do not do this by default). + final SSLSocket sslSocket = (SSLSocket) newSocket; + final SSLParameters params = sslSocket.getSSLParameters(); + params.setEndpointIdentificationAlgorithm("HTTPS"); + sslSocket.setSSLParameters(params); + } newSocket.connect(new InetSocketAddress(host, port), timeoutMillis); // Read timeout slightly above the caller's timeout so the WSMan OperationTimeout fault // (which the Receive loop retries) reliably arrives before a socket read times out. newSocket.setSoTimeout(timeoutMillis + 10_000); + if (newSocket instanceof SSLSocket) { + // Force the TLS handshake now so certificate/hostname failures surface here, not on + // the first read after we have already sent the request. + ((SSLSocket) newSocket).startHandshake(); + } socket = newSocket; out = socket.getOutputStream(); in = new BufferedInputStream(socket.getInputStream()); lastActivityMillis = Utils.getCurrentTimeMillis(); - } catch (final IOException e) { + } catch (final IOException | RuntimeException e) { // Never leave a half-open socket in the field, or ensureConnected would skip reconnecting. + // Catch RuntimeException too so an unexpected failure during TLS setup (e.g. setSSLParameters) + // cannot leak the freshly created SSLSocket. try { newSocket.close(); } catch (final IOException ignore) { diff --git a/src/main/java/org/metricshub/winrm/light/LightTls.java b/src/main/java/org/metricshub/winrm/light/LightTls.java new file mode 100644 index 0000000..a42413e --- /dev/null +++ b/src/main/java/org/metricshub/winrm/light/LightTls.java @@ -0,0 +1,94 @@ +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 java.security.GeneralSecurityException; +import java.security.cert.X509Certificate; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; + +/** + * TLS setup for the light backend's HTTPS transport. + * + *

Unlike the legacy CXF path (which trusts every certificate), the light backend validates by + * default: it uses the JDK default {@link SSLSocketFactory}, so the platform trust store (and any + * {@code -Djavax.net.ssl.trustStore}) applies and the server hostname is verified during the + * handshake. Setting the system property {@value #INSECURE_PROPERTY} to {@code true} opts out — + * trusting all certificates and skipping hostname verification — for self-signed test hosts. That + * is insecure and must not be used in production. + */ +final class LightTls { + + /** System property that disables TLS certificate and hostname validation (insecure; testing only). */ + static final String INSECURE_PROPERTY = "org.metricshub.winrm.tls.insecure"; + + private LightTls() {} + + /** Whether TLS validation has been disabled via {@value #INSECURE_PROPERTY}. */ + static boolean isInsecure() { + return Boolean.getBoolean(INSECURE_PROPERTY); + } + + /** Whether the server hostname should be verified during the TLS handshake (true unless insecure). */ + static boolean verifyHostname() { + return !isInsecure(); + } + + /** + * The socket factory for HTTPS connections: the JDK default (validating) factory, or a + * trust-all factory when {@value #INSECURE_PROPERTY} is set. + * + * @return an {@link SSLSocketFactory} + */ + static SSLSocketFactory socketFactory() { + if (!isInsecure()) { + return (SSLSocketFactory) SSLSocketFactory.getDefault(); + } + try { + final SSLContext context = SSLContext.getInstance("TLS"); + context.init(null, new TrustManager[] { trustAllManager() }, null); + return context.getSocketFactory(); + } catch (final GeneralSecurityException e) { + throw new IllegalStateException("Cannot build an insecure (trust-all) TLS context", e); + } + } + + private static X509TrustManager trustAllManager() { + return new X509TrustManager() { + @Override + public void checkClientTrusted(final X509Certificate[] chain, final String authType) { + // insecure mode: accept any client certificate + } + + @Override + public void checkServerTrusted(final X509Certificate[] chain, final String authType) { + // insecure mode: accept any server certificate + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + }; + } +} diff --git a/src/main/java/org/metricshub/winrm/light/LightWinRMService.java b/src/main/java/org/metricshub/winrm/light/LightWinRMService.java index 04d1a2f..b1e434d 100644 --- a/src/main/java/org/metricshub/winrm/light/LightWinRMService.java +++ b/src/main/java/org/metricshub/winrm/light/LightWinRMService.java @@ -28,6 +28,7 @@ import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; +import javax.net.ssl.SSLSocketFactory; import org.metricshub.winrm.Utils; import org.metricshub.winrm.WinRMHttpProtocolEnum; import org.metricshub.winrm.WindowsRemoteCommandResult; @@ -45,8 +46,9 @@ * JAX-WS / JAXB stack, and immune by construction to JAXP {@code ServiceLoader} poisoning * (it uses the JDK-default XML factories). * - *

Currently supports NTLM over HTTP with message encryption. Kerberos and HTTPS are handled - * by the CXF backend until the corresponding light support lands. + *

Supports NTLM over HTTP (with message encryption) and over HTTPS (plaintext SOAP inside TLS, + * validating the server certificate by default; see {@link LightTls}). Kerberos is still handled by + * the CXF backend until light support lands. */ public final class LightWinRMService implements WindowsRemoteExecutor { @@ -94,13 +96,10 @@ public static LightWinRMService createInstance( } } - if (winRMEndpoint.getProtocol() != WinRMHttpProtocolEnum.HTTP) { - throw new WinRMException( - "The light WinRM backend currently supports only HTTP (endpoint was " + - winRMEndpoint.getEndpoint() + - "). Select the CXF backend with -Dorg.metricshub.winrm.backend=cxf until light support lands." - ); - } + // HTTPS wraps the transport in TLS and exchanges plaintext SOAP; HTTP uses NTLM message sealing. + // TLS validates by default (platform trust store + hostname verification); see LightTls. + final boolean https = winRMEndpoint.getProtocol() == WinRMHttpProtocolEnum.HTTPS; + final SSLSocketFactory sslSocketFactory = https ? LightTls.socketFactory() : null; // Use the endpoint's own validated host/port rather than re-parsing the URL: URI.getHost()/getPort() // return null/-1 for names URI cannot classify (underscores, Unicode) that WinRMEndpoint accepts, @@ -111,7 +110,9 @@ public static LightWinRMService createInstance( winRMEndpoint.getDomain(), winRMEndpoint.getUsername(), new String(winRMEndpoint.getPassword()), - timeout + timeout, + sslSocketFactory, + https && LightTls.verifyHostname() ); return new LightWinRMService(winRMEndpoint, client); } diff --git a/src/main/java/org/metricshub/winrm/light/WinRMSession.java b/src/main/java/org/metricshub/winrm/light/WinRMSession.java index 1f0ba76..f7b2748 100644 --- a/src/main/java/org/metricshub/winrm/light/WinRMSession.java +++ b/src/main/java/org/metricshub/winrm/light/WinRMSession.java @@ -131,6 +131,14 @@ void reset() { sequenceIncoming.set(-1); } + /** + * Mark the connection authenticated without deriving sealing keys. Used for HTTPS, where TLS + * provides confidentiality and WinRM exchanges plaintext SOAP (no NTLM message sealing). + */ + void markAuthenticated() { + authenticated = true; + } + /** Derive signing/sealing keys from the Type 3 exported session key and open both RC4 ciphers. */ void applyKeys(final Type3Message type3) { final byte[] exportedSessionKey = type3.getExportedSessionKey(); diff --git a/src/main/java/org/metricshub/winrm/light/WsmanClient.java b/src/main/java/org/metricshub/winrm/light/WsmanClient.java index 175ca15..d88464d 100644 --- a/src/main/java/org/metricshub/winrm/light/WsmanClient.java +++ b/src/main/java/org/metricshub/winrm/light/WsmanClient.java @@ -32,6 +32,7 @@ import java.util.concurrent.locks.ReentrantLock; import java.util.regex.Matcher; import java.util.regex.Pattern; +import javax.net.ssl.SSLSocketFactory; import javax.xml.XMLConstants; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; @@ -47,12 +48,18 @@ */ final class WsmanClient implements AutoCloseable { - // Type 1 flags: engine defaults + SIGN | SEAL | KEY_EXCH (matches NtlmMasqAsSpnegoScheme). - private static final int TYPE1_FLAGS = (int) (Type1Message.getDefaultFlags() | + // Type 1 flags over plain HTTP: engine defaults + SIGN | SEAL | KEY_EXCH (matches + // NtlmMasqAsSpnegoScheme). Message sealing is what protects the SOAP over an unencrypted transport. + private static final int TYPE1_FLAGS_ENCRYPTED = (int) (Type1Message.getDefaultFlags() | NTLMEngineUtils.NTLMSSP_NEGOTIATE_SIGN | NTLMEngineUtils.NTLMSSP_NEGOTIATE_SEAL | NTLMEngineUtils.NTLMSSP_NEGOTIATE_KEY_EXCH); + // Type 1 flags over HTTPS: engine defaults only. TLS already provides confidentiality/integrity, so + // we authenticate WITHOUT negotiating sealing and exchange plaintext SOAP — claiming SEAL but then + // sending plaintext would make the server reject the message. + private static final int TYPE1_FLAGS_PLAIN = (int) Type1Message.getDefaultFlags(); + private static final String SOAP_CONTENT_TYPE = "application/soap+xml;charset=UTF-8"; private static final byte[] PRE_AUTH_BOGUS = "AWAITING_ENCRYPTION_KEYS".getBytes(StandardCharsets.US_ASCII); @@ -73,6 +80,7 @@ final class WsmanClient implements AutoCloseable { private final long timeoutMs; private final String url; + private final boolean https; private final WinRMSession session; private final HttpTransport transport; @@ -92,10 +100,14 @@ final class WsmanClient implements AutoCloseable { final String domain, final String username, final String password, - final long timeoutMs + final long timeoutMs, + final SSLSocketFactory sslSocketFactory, + final boolean verifyHostname ) { this.timeoutMs = timeoutMs; - this.url = "http://" + host + ":" + port + "/wsman"; + // A non-null socket factory selects HTTPS: TLS wraps the transport and the SOAP travels plaintext. + this.https = sslSocketFactory != null; + this.url = (https ? "https" : "http") + "://" + host + ":" + port + "/wsman"; // 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 // key from the uppercased value. A lowercase domain here passes authentication but fails @@ -103,7 +115,7 @@ final class WsmanClient implements AutoCloseable { // Workstation is left empty in the Type 3 message, matching the reference client. final String upperDomain = domain == null ? null : domain.toUpperCase(Locale.ROOT); this.session = new WinRMSession(upperDomain, null, username, password); - this.transport = new HttpTransport(host, port, toSocketTimeoutMillis(timeoutMs)); + this.transport = new HttpTransport(host, port, toSocketTimeoutMillis(timeoutMs), sslSocketFactory, verifyHostname); } /** @@ -274,20 +286,24 @@ private Decoded request(final String soap) throws Exception { final String authorization = pendingAuthorization; pendingAuthorization = null; - final byte[] encrypted = NtlmCrypto.encryptAndSign(session, soap.getBytes(StandardCharsets.UTF_8)); - final HttpTransport.Response resp = transport.post( - "/wsman", - encrypted, - NtlmCrypto.ENCRYPTED_CONTENT_TYPE, - authorization - ); + // Over HTTPS the SOAP travels plaintext inside TLS; over plain HTTP it is NTLM-sealed. + final byte[] payload; + final String contentType; + if (https) { + payload = soap.getBytes(StandardCharsets.UTF_8); + contentType = SOAP_CONTENT_TYPE; + } else { + payload = NtlmCrypto.encryptAndSign(session, soap.getBytes(StandardCharsets.UTF_8)); + contentType = NtlmCrypto.ENCRYPTED_CONTENT_TYPE; + } + final HttpTransport.Response resp = transport.post("/wsman", payload, contentType, authorization); - // 200 = success, 500 = SOAP fault (both bodies are encrypted). Anything else is a protocol - // or authentication failure whose body is not a usable WSMan response. + // 200 = success, 500 = SOAP fault. Anything else is a protocol or authentication failure whose + // body is not a usable WSMan response. if (resp.status != 200 && resp.status != 500) { throw new IllegalStateException("WSMan request failed: HTTP " + resp.status); } - return new Decoded(resp.status, decryptResponse(resp)); + return new Decoded(resp.status, decodeResponse(resp)); } private void authenticate() throws Exception { @@ -295,7 +311,7 @@ private void authenticate() throws Exception { transport.post("/wsman", PRE_AUTH_BOGUS, SOAP_CONTENT_TYPE, null); // Request A: Type 1 under the Negotiate header. No keys yet, so send the bogus placeholder. - final String type1 = new Type1Message(null, null, TYPE1_FLAGS).getResponse(); + final String type1 = new Type1Message(null, null, https ? TYPE1_FLAGS_PLAIN : TYPE1_FLAGS_ENCRYPTED).getResponse(); final HttpTransport.Response challenge = transport.post( "/wsman", PRE_AUTH_BOGUS, @@ -324,7 +340,12 @@ private void authenticate() throws Exception { challengeMessage.getTargetInfo() ); final String type3 = type3Message.getResponse(); - session.applyKeys(type3Message); + if (https) { + // No sealing over TLS: authenticate the connection but derive no RC4 keys. + session.markAuthenticated(); + } else { + session.applyKeys(type3Message); + } pendingAuthorization = "Negotiate " + type3; } @@ -344,7 +365,12 @@ private static String extractNegotiateToken(final HttpTransport.Response respons return null; } - private Document decryptResponse(final HttpTransport.Response resp) throws Exception { + private Document decodeResponse(final HttpTransport.Response resp) throws Exception { + if (https) { + // Over TLS the response body is plaintext application/soap+xml; TLS already guarantees + // confidentiality and integrity, so there is no multipart/encrypted envelope to unseal. + return parse(resp.body); + } final String contentType = resp.firstHeader("content-type"); // Once the NTLM session is authenticated, the seal is the ONLY thing protecting response // integrity over plaintext HTTP. A non-encrypted body (from a proxy, a misconfigured server, diff --git a/src/test/java/org/metricshub/winrm/light/LightTlsTest.java b/src/test/java/org/metricshub/winrm/light/LightTlsTest.java new file mode 100644 index 0000000..233a951 --- /dev/null +++ b/src/test/java/org/metricshub/winrm/light/LightTlsTest.java @@ -0,0 +1,32 @@ +package org.metricshub.winrm.light; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +/** Verifies the light backend's TLS posture: validate by default, insecure only when opted in. */ +class LightTlsTest { + + @AfterEach + void clearInsecure() { + System.clearProperty(LightTls.INSECURE_PROPERTY); + } + + @Test + void validatesByDefault() { + assertFalse(LightTls.isInsecure()); + assertTrue(LightTls.verifyHostname()); + assertNotNull(LightTls.socketFactory()); + } + + @Test + void insecureWhenOptedIn() { + System.setProperty(LightTls.INSECURE_PROPERTY, "true"); + assertTrue(LightTls.isInsecure()); + assertFalse(LightTls.verifyHostname()); + assertNotNull(LightTls.socketFactory()); + } +} diff --git a/src/test/java/org/metricshub/winrm/service/WinRMExecutorFactoryTest.java b/src/test/java/org/metricshub/winrm/service/WinRMExecutorFactoryTest.java index 4b9cd45..22a265e 100644 --- a/src/test/java/org/metricshub/winrm/service/WinRMExecutorFactoryTest.java +++ b/src/test/java/org/metricshub/winrm/service/WinRMExecutorFactoryTest.java @@ -75,35 +75,34 @@ void cxfBackendSelectedViaProperty() throws Exception { } @Test - void defaultBackendRejectsHttps() { - // Intentional, documented regression on this branch: with light as the default, HTTPS is - // rejected until the light backend supports it. Pinned so a silent CXF fallback cannot be - // reintroduced without updating this test. - assertThrows( - WinRMException.class, - () -> - WinRMExecutorFactory.createInstance( - endpoint(WinRMHttpProtocolEnum.HTTPS), - 30000L, - null, - List.of(AuthenticationEnum.NTLM) - ) - ); + void defaultBackendAcceptsHttps() throws Exception { + // Light now supports HTTPS (TLS + plaintext SOAP), so the default backend accepts it. Building + // the client does not open a connection (or a TLS handshake), so this stays offline. + try ( + final WindowsRemoteExecutor executor = WinRMExecutorFactory.createInstance( + endpoint(WinRMHttpProtocolEnum.HTTPS), + 30000L, + null, + List.of(AuthenticationEnum.NTLM) + ) + ) { + assertInstanceOf(LightWinRMService.class, executor); + } } @Test - void lightBackendRejectsHttps() { + void lightBackendAcceptsHttps() throws Exception { System.setProperty(WinRMExecutorFactory.BACKEND_PROPERTY, "light"); - assertThrows( - WinRMException.class, - () -> - WinRMExecutorFactory.createInstance( - endpoint(WinRMHttpProtocolEnum.HTTPS), - 30000L, - null, - List.of(AuthenticationEnum.NTLM) - ) - ); + try ( + final WindowsRemoteExecutor executor = WinRMExecutorFactory.createInstance( + endpoint(WinRMHttpProtocolEnum.HTTPS), + 30000L, + null, + List.of(AuthenticationEnum.NTLM) + ) + ) { + assertInstanceOf(LightWinRMService.class, executor); + } } @Test From c6e924adbd36b35fe6a16ea37155bcd62555aca7 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Thu, 23 Jul 2026 19:14:46 +0200 Subject: [PATCH 11/21] Extract an AuthScheme seam; move NTLM into NtlmAuthScheme (no behavior change) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preparation for Kerberos (#105): factor the mechanism-specific parts of WsmanClient behind a small package-private AuthScheme interface (authenticate / isAuthenticated / reset / wrap / wrapContentType / unwrap). WsmanClient is now mechanism-agnostic and just delegates; a new mechanism is added by implementing the interface rather than branching the client. - NtlmAuthScheme holds the existing NTLM logic verbatim (WinRMSession, Type1/2/3, NtlmCrypto, the HTTP-seal vs HTTPS-plaintext split, and the domain-uppercasing fix). Wire behavior is unchanged — the code moved, it did not change. - The shared Negotiate-token parser moved onto HttpTransport.Response.negotiateToken() (both NTLM and Kerberos ride under the Negotiate scheme). - WsmanClient's constructor now takes an AuthScheme; LightWinRMService builds the NtlmAuthScheme and injects it. Re-verified NTLM end-to-end after the move: HTTP/5985 against anaxagore (seal path) and HTTPS/5986 against a domain host (plaintext-over-TLS) both succeed (WQL + command). mvn verify 44 tests green. Co-Authored-By: Claude Opus 4.8 --- .../metricshub/winrm/light/AuthScheme.java | 75 +++++++++ .../metricshub/winrm/light/HttpTransport.java | 24 +++ .../winrm/light/LightWinRMService.java | 13 +- .../winrm/light/NtlmAuthScheme.java | 146 ++++++++++++++++ .../metricshub/winrm/light/WsmanClient.java | 158 +++--------------- 5 files changed, 276 insertions(+), 140 deletions(-) create mode 100644 src/main/java/org/metricshub/winrm/light/AuthScheme.java create mode 100644 src/main/java/org/metricshub/winrm/light/NtlmAuthScheme.java diff --git a/src/main/java/org/metricshub/winrm/light/AuthScheme.java b/src/main/java/org/metricshub/winrm/light/AuthScheme.java new file mode 100644 index 0000000..753d8ce --- /dev/null +++ b/src/main/java/org/metricshub/winrm/light/AuthScheme.java @@ -0,0 +1,75 @@ +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. + * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ + */ + +/** + * Authentication and message protection for one WSMan connection. A scheme owns its handshake, its + * connection-bound session state, and how it wraps/unwraps the SOAP payload — the two things that + * differ between NTLM and Kerberos. {@link WsmanClient} is otherwise mechanism-agnostic and just + * delegates to the scheme, so a new mechanism is added by implementing this interface rather than + * branching the client. + * + *

All methods are called while {@code WsmanClient} holds its operation lock, so implementations + * need no internal synchronization. + */ +interface AuthScheme { + /** + * Run the full authentication handshake over the given transport (which may involve several + * request/response legs), leaving the connection authenticated. + * + * @param transport the connection to authenticate + * @return the {@code Authorization} header value to attach to the first real request, or + * {@code null} if none is needed + * @throws Exception if the handshake fails + */ + String authenticate(HttpTransport transport) throws Exception; + + /** @return whether the connection is currently authenticated. */ + boolean isAuthenticated(); + + /** + * Drop the authenticated state so the next request re-runs the handshake. Called when the + * underlying connection was lost, since the session state is bound to the TCP connection. + */ + void reset(); + + /** + * Encode an outgoing SOAP body for the wire (sealing it over plain HTTP, or passing it through + * over HTTPS where TLS provides confidentiality). + * + * @param soapUtf8 the SOAP envelope, UTF-8 encoded + * @return the bytes to send as the request body + */ + byte[] wrap(byte[] soapUtf8); + + /** @return the {@code Content-Type} for the body produced by {@link #wrap(byte[])}. */ + String wrapContentType(); + + /** + * Decode a response body back to plaintext SOAP bytes, verifying integrity where the mechanism + * provides it. + * + * @param response the HTTP response + * @return the plaintext SOAP bytes to parse + * @throws Exception if the body cannot be trusted or decoded + */ + byte[] unwrap(HttpTransport.Response response) throws Exception; +} diff --git a/src/main/java/org/metricshub/winrm/light/HttpTransport.java b/src/main/java/org/metricshub/winrm/light/HttpTransport.java index d33979d..8cfd8d4 100644 --- a/src/main/java/org/metricshub/winrm/light/HttpTransport.java +++ b/src/main/java/org/metricshub/winrm/light/HttpTransport.java @@ -31,6 +31,8 @@ import java.util.ArrayList; import java.util.List; import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import javax.net.ssl.SSLParameters; import javax.net.ssl.SSLSocket; import javax.net.ssl.SSLSocketFactory; @@ -110,8 +112,30 @@ List allHeaders(final String name) { } return values; } + + /** + * Extract the Negotiate challenge token from the 401 response, scanning every + * {@code WWW-Authenticate} header (order-independent) and tolerating combined challenges. Both + * NTLM (masqueraded) and Kerberos ride under the {@code Negotiate} scheme, so both schemes use this. + * + * @return the base64 token, or {@code null} if no Negotiate challenge carries one + */ + String negotiateToken() { + for (final String value : allHeaders("www-authenticate")) { + final Matcher matcher = NEGOTIATE_TOKEN.matcher(value); + if (matcher.find()) { + return matcher.group(1); + } + } + return null; + } } + // A WWW-Authenticate value may list several challenges ("Negotiate , NTLM ...") and a server + // or proxy may split them across multiple header lines. Match the Negotiate scheme only at a + // challenge boundary (start of value or right after a comma) and capture just its base64 token. + private static final Pattern NEGOTIATE_TOKEN = Pattern.compile("(?i)(?:^|,)\\s*Negotiate\\s+([A-Za-z0-9+/=]+)"); + /** Whether a live connection is currently held. */ boolean isConnected() { if (socket == null || socket.isClosed() || !socket.isConnected()) { diff --git a/src/main/java/org/metricshub/winrm/light/LightWinRMService.java b/src/main/java/org/metricshub/winrm/light/LightWinRMService.java index b1e434d..b07bd17 100644 --- a/src/main/java/org/metricshub/winrm/light/LightWinRMService.java +++ b/src/main/java/org/metricshub/winrm/light/LightWinRMService.java @@ -101,18 +101,23 @@ public static LightWinRMService createInstance( final boolean https = winRMEndpoint.getProtocol() == WinRMHttpProtocolEnum.HTTPS; final SSLSocketFactory sslSocketFactory = https ? LightTls.socketFactory() : null; + final AuthScheme authScheme = new NtlmAuthScheme( + winRMEndpoint.getDomain(), + winRMEndpoint.getUsername(), + new String(winRMEndpoint.getPassword()), + https + ); + // Use the endpoint's own validated host/port rather than re-parsing the URL: URI.getHost()/getPort() // return null/-1 for names URI cannot classify (underscores, Unicode) that WinRMEndpoint accepts, // which would otherwise make the default backend unable to reach hosts the CXF backend could. final WsmanClient client = new WsmanClient( winRMEndpoint.getHostname(), winRMEndpoint.getPort(), - winRMEndpoint.getDomain(), - winRMEndpoint.getUsername(), - new String(winRMEndpoint.getPassword()), timeout, sslSocketFactory, - https && LightTls.verifyHostname() + https && LightTls.verifyHostname(), + authScheme ); return new LightWinRMService(winRMEndpoint, client); } diff --git a/src/main/java/org/metricshub/winrm/light/NtlmAuthScheme.java b/src/main/java/org/metricshub/winrm/light/NtlmAuthScheme.java new file mode 100644 index 0000000..a81ca80 --- /dev/null +++ b/src/main/java/org/metricshub/winrm/light/NtlmAuthScheme.java @@ -0,0 +1,146 @@ +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 java.nio.charset.StandardCharsets; +import java.util.Locale; + +/** + * NTLM (masqueraded as Negotiate) authentication scheme. Over plain HTTP it seals the SOAP with the + * NTLM session keys ({@code multipart/encrypted}); over HTTPS it authenticates only and sends + * plaintext SOAP inside TLS. + */ +final class NtlmAuthScheme implements AuthScheme { + + // Type 1 flags over plain HTTP: engine defaults + SIGN | SEAL | KEY_EXCH (matches + // NtlmMasqAsSpnegoScheme). Message sealing is what protects the SOAP over an unencrypted transport. + private static final int TYPE1_FLAGS_ENCRYPTED = (int) (Type1Message.getDefaultFlags() | + NTLMEngineUtils.NTLMSSP_NEGOTIATE_SIGN | + NTLMEngineUtils.NTLMSSP_NEGOTIATE_SEAL | + NTLMEngineUtils.NTLMSSP_NEGOTIATE_KEY_EXCH); + + // Type 1 flags over HTTPS: engine defaults only. TLS already provides confidentiality/integrity, so + // we authenticate WITHOUT negotiating sealing and exchange plaintext SOAP — claiming SEAL but then + // sending plaintext would make the server reject the message. + private static final int TYPE1_FLAGS_PLAIN = (int) Type1Message.getDefaultFlags(); + + private static final String SOAP_CONTENT_TYPE = "application/soap+xml;charset=UTF-8"; + private static final byte[] PRE_AUTH_BOGUS = "AWAITING_ENCRYPTION_KEYS".getBytes(StandardCharsets.US_ASCII); + + private final boolean https; + private final WinRMSession session; + + NtlmAuthScheme(final String domain, final String username, final String 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 + // key from the uppercased value. A lowercase domain here passes authentication but fails + // message integrity (server-side seal mismatch → HTTP 400). + // Workstation is left empty in the Type 3 message, matching the reference client. + final String upperDomain = domain == null ? null : domain.toUpperCase(Locale.ROOT); + this.session = new WinRMSession(upperDomain, null, username, password); + } + + @Override + public String authenticate(final HttpTransport transport) throws Exception { + // Request 0: unauthenticated probe (bogus body), mirroring the reference client. + transport.post("/wsman", PRE_AUTH_BOGUS, SOAP_CONTENT_TYPE, null); + + // Request A: Type 1 under the Negotiate header. No keys yet, so send the bogus placeholder. + final String type1 = new Type1Message(null, null, https ? TYPE1_FLAGS_PLAIN : TYPE1_FLAGS_ENCRYPTED).getResponse(); + final HttpTransport.Response challenge = transport.post( + "/wsman", + PRE_AUTH_BOGUS, + SOAP_CONTENT_TYPE, + "Negotiate " + type1 + ); + if (challenge.status != 401) { + throw new IllegalStateException("Expected HTTP 401 with an NTLM challenge, got HTTP " + challenge.status); + } + final String type2 = challenge.negotiateToken(); + if (type2 == null) { + throw new IllegalStateException( + "No Negotiate challenge token in response: " + challenge.allHeaders("www-authenticate") + ); + } + + final Type2Message challengeMessage = new Type2Message(type2); + final Type3Message type3Message = new Type3Message( + session.getDomain(), + session.getWorkstation(), + session.getUsername(), + session.getPassword(), + challengeMessage.getChallenge(), + challengeMessage.getFlags(), + challengeMessage.getTarget(), + challengeMessage.getTargetInfo() + ); + final String type3 = type3Message.getResponse(); + if (https) { + // No sealing over TLS: authenticate the connection but derive no RC4 keys. + session.markAuthenticated(); + } else { + session.applyKeys(type3Message); + } + return "Negotiate " + type3; + } + + @Override + public boolean isAuthenticated() { + return session.isAuthenticated(); + } + + @Override + public void reset() { + session.reset(); + } + + @Override + public byte[] wrap(final byte[] soapUtf8) { + return https ? soapUtf8 : NtlmCrypto.encryptAndSign(session, soapUtf8); + } + + @Override + public String wrapContentType() { + return https ? SOAP_CONTENT_TYPE : NtlmCrypto.ENCRYPTED_CONTENT_TYPE; + } + + @Override + public byte[] unwrap(final HttpTransport.Response response) { + if (https) { + // Over TLS the response body is plaintext application/soap+xml; TLS already guarantees + // confidentiality and integrity, so there is no multipart/encrypted envelope to unseal. + return response.body; + } + final String contentType = response.firstHeader("content-type"); + // Once the NTLM session is authenticated, the seal is the ONLY thing protecting response + // integrity over plaintext HTTP. A non-encrypted body (from a proxy, a misconfigured server, + // or an on-path attacker returning a forged HTTP 200/500) has not passed the HMAC check, so it + // must never be parsed as a trusted WSMan response. This is only reached after the handshake, + // so an encrypted content type is always required. + if (contentType == null || !contentType.startsWith("multipart/encrypted")) { + throw new IllegalStateException( + "Refusing to parse an unencrypted WSMan response after authentication (Content-Type: " + contentType + ")" + ); + } + return NtlmCrypto.decrypt(session, response.body); + } +} diff --git a/src/main/java/org/metricshub/winrm/light/WsmanClient.java b/src/main/java/org/metricshub/winrm/light/WsmanClient.java index d88464d..a54ba55 100644 --- a/src/main/java/org/metricshub/winrm/light/WsmanClient.java +++ b/src/main/java/org/metricshub/winrm/light/WsmanClient.java @@ -48,31 +48,11 @@ */ final class WsmanClient implements AutoCloseable { - // Type 1 flags over plain HTTP: engine defaults + SIGN | SEAL | KEY_EXCH (matches - // NtlmMasqAsSpnegoScheme). Message sealing is what protects the SOAP over an unencrypted transport. - private static final int TYPE1_FLAGS_ENCRYPTED = (int) (Type1Message.getDefaultFlags() | - NTLMEngineUtils.NTLMSSP_NEGOTIATE_SIGN | - NTLMEngineUtils.NTLMSSP_NEGOTIATE_SEAL | - NTLMEngineUtils.NTLMSSP_NEGOTIATE_KEY_EXCH); - - // Type 1 flags over HTTPS: engine defaults only. TLS already provides confidentiality/integrity, so - // we authenticate WITHOUT negotiating sealing and exchange plaintext SOAP — claiming SEAL but then - // sending plaintext would make the server reject the message. - private static final int TYPE1_FLAGS_PLAIN = (int) Type1Message.getDefaultFlags(); - - private static final String SOAP_CONTENT_TYPE = "application/soap+xml;charset=UTF-8"; - private static final byte[] PRE_AUTH_BOGUS = "AWAITING_ENCRYPTION_KEYS".getBytes(StandardCharsets.US_ASCII); - // If no output is available before the OperationTimeout expires, the server returns this WSMan // fault code and the client is expected to immediately re-issue the Receive request. private static final String FAULT_OPERATION_TIMEOUT = "2150858793"; private static final String FAULT_SHELL_NOT_FOUND = "2150858843"; - // A WWW-Authenticate value may list several challenges ("Negotiate , NTLM ...") and a server - // or proxy may split them across multiple header lines. Match the Negotiate scheme only at a - // challenge boundary (start of value or right after a comma) and capture just its base64 token. - private static final Pattern NEGOTIATE_TOKEN = Pattern.compile("(?i)(?:^|,)\\s*Negotiate\\s+([A-Za-z0-9+/=]+)"); - // WS-Enumeration namespace: the EndOfSequence / EnumerationContext markers live here. Match them by // namespace, never by local name alone, so a WMI property that happens to be named "EndOfSequence" // or "EnumerationContext" inside cannot be mistaken for the enumeration control element. @@ -80,8 +60,7 @@ final class WsmanClient implements AutoCloseable { private final long timeoutMs; private final String url; - private final boolean https; - private final WinRMSession session; + private final AuthScheme auth; private final HttpTransport transport; private String pendingAuthorization; @@ -97,24 +76,15 @@ final class WsmanClient implements AutoCloseable { WsmanClient( final String host, final int port, - final String domain, - final String username, - final String password, final long timeoutMs, final SSLSocketFactory sslSocketFactory, - final boolean verifyHostname + final boolean verifyHostname, + final AuthScheme auth ) { this.timeoutMs = timeoutMs; // A non-null socket factory selects HTTPS: TLS wraps the transport and the SOAP travels plaintext. - this.https = sslSocketFactory != null; - this.url = (https ? "https" : "http") + "://" + host + ":" + port + "/wsman"; - // 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 - // key from the uppercased value. A lowercase domain here passes authentication but fails - // message integrity (server-side seal mismatch → HTTP 400). - // Workstation is left empty in the Type 3 message, matching the reference client. - final String upperDomain = domain == null ? null : domain.toUpperCase(Locale.ROOT); - this.session = new WinRMSession(upperDomain, null, username, password); + this.url = (sslSocketFactory != null ? "https" : "http") + "://" + host + ":" + port + "/wsman"; + this.auth = auth; this.transport = new HttpTransport(host, port, toSocketTimeoutMillis(timeoutMs), sslSocketFactory, verifyHostname); } @@ -268,121 +238,37 @@ private Document expectOk(final String soap, final String operation) throws Exce } /** - * Send one SOAP request (authenticating the connection on first use) and decrypt the response. - * The caller must hold {@link #operationLock}; every path here is reached from a locked - * wql/executeCommand/close, so requests never interleave on the stateful NTLM connection. + * Send one SOAP request (authenticating the connection on first use via the {@link AuthScheme}) + * and decode the response. The caller must hold {@link #operationLock}; every path here is reached + * from a locked wql/executeCommand/close, so requests never interleave on the stateful connection. */ private Decoded request(final String soap) throws Exception { - // If the connection was dropped (e.g. the server sent "Connection: close"), the NTLM session - // bound to it is dead — re-handshake on the fresh connection rather than sending unauthenticated. - if (session.isAuthenticated() && !transport.isConnected()) { - session.reset(); + // If the connection was dropped (e.g. the server sent "Connection: close"), the session bound + // to it is dead — re-handshake on the fresh connection rather than sending unauthenticated. + if (auth.isAuthenticated() && !transport.isConnected()) { + auth.reset(); } - if (!session.isAuthenticated()) { - authenticate(); + if (!auth.isAuthenticated()) { + pendingAuthorization = auth.authenticate(transport); } - // The Type 3 authorization accompanies the first encrypted payload; later requests on the + // The handshake's Authorization accompanies the first real request; later requests on the // already-authenticated connection carry no Authorization header. final String authorization = pendingAuthorization; pendingAuthorization = null; - // Over HTTPS the SOAP travels plaintext inside TLS; over plain HTTP it is NTLM-sealed. - final byte[] payload; - final String contentType; - if (https) { - payload = soap.getBytes(StandardCharsets.UTF_8); - contentType = SOAP_CONTENT_TYPE; - } else { - payload = NtlmCrypto.encryptAndSign(session, soap.getBytes(StandardCharsets.UTF_8)); - contentType = NtlmCrypto.ENCRYPTED_CONTENT_TYPE; - } - final HttpTransport.Response resp = transport.post("/wsman", payload, contentType, authorization); + final HttpTransport.Response resp = transport.post( + "/wsman", + auth.wrap(soap.getBytes(StandardCharsets.UTF_8)), + auth.wrapContentType(), + authorization + ); // 200 = success, 500 = SOAP fault. Anything else is a protocol or authentication failure whose // body is not a usable WSMan response. if (resp.status != 200 && resp.status != 500) { throw new IllegalStateException("WSMan request failed: HTTP " + resp.status); } - return new Decoded(resp.status, decodeResponse(resp)); - } - - private void authenticate() throws Exception { - // Request 0: unauthenticated probe (bogus body), mirroring the reference client. - transport.post("/wsman", PRE_AUTH_BOGUS, SOAP_CONTENT_TYPE, null); - - // Request A: Type 1 under the Negotiate header. No keys yet, so send the bogus placeholder. - final String type1 = new Type1Message(null, null, https ? TYPE1_FLAGS_PLAIN : TYPE1_FLAGS_ENCRYPTED).getResponse(); - final HttpTransport.Response challenge = transport.post( - "/wsman", - PRE_AUTH_BOGUS, - SOAP_CONTENT_TYPE, - "Negotiate " + type1 - ); - if (challenge.status != 401) { - throw new IllegalStateException("Expected HTTP 401 with an NTLM challenge, got HTTP " + challenge.status); - } - final String type2 = extractNegotiateToken(challenge); - if (type2 == null) { - throw new IllegalStateException( - "No Negotiate challenge token in response: " + challenge.allHeaders("www-authenticate") - ); - } - - final Type2Message challengeMessage = new Type2Message(type2); - final Type3Message type3Message = new Type3Message( - session.getDomain(), - session.getWorkstation(), - session.getUsername(), - session.getPassword(), - challengeMessage.getChallenge(), - challengeMessage.getFlags(), - challengeMessage.getTarget(), - challengeMessage.getTargetInfo() - ); - final String type3 = type3Message.getResponse(); - if (https) { - // No sealing over TLS: authenticate the connection but derive no RC4 keys. - session.markAuthenticated(); - } else { - session.applyKeys(type3Message); - } - pendingAuthorization = "Negotiate " + type3; - } - - /** - * Extract the Negotiate/NTLM challenge token from the 401 response, scanning every - * {@code WWW-Authenticate} header (order-independent) and tolerating combined challenges. - * - * @return the base64 token, or {@code null} if no Negotiate challenge carries one - */ - private static String extractNegotiateToken(final HttpTransport.Response response) { - for (final String value : response.allHeaders("www-authenticate")) { - final Matcher matcher = NEGOTIATE_TOKEN.matcher(value); - if (matcher.find()) { - return matcher.group(1); - } - } - return null; - } - - private Document decodeResponse(final HttpTransport.Response resp) throws Exception { - if (https) { - // Over TLS the response body is plaintext application/soap+xml; TLS already guarantees - // confidentiality and integrity, so there is no multipart/encrypted envelope to unseal. - return parse(resp.body); - } - final String contentType = resp.firstHeader("content-type"); - // Once the NTLM session is authenticated, the seal is the ONLY thing protecting response - // integrity over plaintext HTTP. A non-encrypted body (from a proxy, a misconfigured server, - // or an on-path attacker returning a forged HTTP 200/500) has not passed the HMAC check, so it - // must never be parsed as a trusted WSMan response. request() only reaches here after the - // handshake, so an encrypted content type is always required. - if (contentType == null || !contentType.startsWith("multipart/encrypted")) { - throw new IllegalStateException( - "Refusing to parse an unencrypted WSMan response after authentication (Content-Type: " + contentType + ")" - ); - } - return parse(NtlmCrypto.decrypt(session, resp.body)); + return new Decoded(resp.status, parse(auth.unwrap(resp))); } // --- XML helpers -------------------------------------------------------- From ab656c7765b55df4e072bac763232798fd4c4148 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Thu, 23 Jul 2026 19:24:44 +0200 Subject: [PATCH 12/21] Add Kerberos (SPNEGO) support to the light backend via JDK JGSS (#105) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KerberosAuthScheme authenticates with the JDK GSS-API only (no Apache/CXF): a JAAS Krb5LoginModule obtains a TGT from the username+password (or a ticket cache), then a GSSContext (SPNEGO) mints a service ticket for HTTP/ and emits the AP-REQ under the Negotiate header. HTTPS only — like the CXF backend, which never implemented Kerberos message encryption over plain HTTP; the SOAP rides plaintext inside TLS, so wrap/unwrap are pass-throughs. Realm/KDC resolution is left to the ambient krb5 config (krb5.conf or -Djava.security.krb5.*), exactly as the CXF path did. LightWinRMService now resolves the requested AuthenticationEnum list into schemes in the caller's order: null/empty -> NTLM only; a single scheme is used directly; several become an ordered FallbackAuthScheme (e.g. [KERBEROS, NTLM] -> try Kerberos, fall back to NTLM). Kerberos is dropped from the candidate list over plain HTTP (unavailable there), so a mixed list over HTTP uses NTLM and a Kerberos-only request over HTTP fails toward the escape hatch. This supersedes the earlier hard-rejection of [KERBEROS, NTLM] (the user chose ordered fallback, since single-scheme-only would break the List API's backward compatibility). The ticketCache argument, previously dropped, is now used. Live-verified against tc-win2016 (SENTRY domain, Win2016, HTTPS/5986) with domain account sentry\dev-admin: Kerberos command + WQL succeed; and with a broken KDC the ordered [KERBEROS, NTLM] list falls back to NTLM and succeeds. mvn verify 45 tests green (incl. kerberosOverHttpsAccepted, mixedKerberosNtlmFallsBackToNtlmOverHttp). Fallback triggers on a client-side handshake failure (the common case: Kerberos unavailable/unconfigured). Falling back on a server-side rejection of an otherwise-valid ticket is not yet handled. Co-Authored-By: Claude Opus 4.8 --- .../winrm/light/FallbackAuthScheme.java | 98 +++++++++ .../winrm/light/KerberosAuthScheme.java | 194 ++++++++++++++++++ .../winrm/light/LightWinRMService.java | 84 +++++--- .../service/WinRMExecutorFactoryTest.java | 47 +++-- 4 files changed, 383 insertions(+), 40 deletions(-) create mode 100644 src/main/java/org/metricshub/winrm/light/FallbackAuthScheme.java create mode 100644 src/main/java/org/metricshub/winrm/light/KerberosAuthScheme.java diff --git a/src/main/java/org/metricshub/winrm/light/FallbackAuthScheme.java b/src/main/java/org/metricshub/winrm/light/FallbackAuthScheme.java new file mode 100644 index 0000000..4ee6094 --- /dev/null +++ b/src/main/java/org/metricshub/winrm/light/FallbackAuthScheme.java @@ -0,0 +1,98 @@ +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 java.util.List; + +/** + * Tries several {@link AuthScheme}s in the caller's order, using the first whose handshake succeeds + * (e.g. {@code [KERBEROS, NTLM]}: attempt Kerberos, fall back to NTLM). Once a scheme authenticates + * it becomes the active one for the rest of the connection; on reconnect the fallback runs again. + * + *

Fallback triggers on a failed handshake — the common case being Kerberos unavailable (no TGT, + * no reachable KDC, unconfigured realm), which fails client-side before any SOAP is sent. + */ +final class FallbackAuthScheme implements AuthScheme { + + private final List candidates; + private AuthScheme active; + + FallbackAuthScheme(final List candidates) { + if (candidates == null || candidates.isEmpty()) { + throw new IllegalArgumentException("At least one authentication scheme is required"); + } + this.candidates = candidates; + } + + @Override + public String authenticate(final HttpTransport transport) throws Exception { + Exception lastFailure = null; + for (int i = 0; i < candidates.size(); i++) { + final AuthScheme candidate = candidates.get(i); + try { + final String authorization = candidate.authenticate(transport); + active = candidate; + return authorization; + } catch (final Exception e) { + lastFailure = e; + candidate.reset(); + // Give the next scheme a clean connection — a partial handshake may have left the socket + // mid-stream or the server may have closed it. + if (i < candidates.size() - 1) { + transport.close(); + } + } + } + throw new IllegalStateException( + "All requested authentication schemes failed" + + (lastFailure == null ? "" : " (last: " + lastFailure.getMessage() + ")"), + lastFailure + ); + } + + @Override + public boolean isAuthenticated() { + return active != null && active.isAuthenticated(); + } + + @Override + public void reset() { + if (active != null) { + active.reset(); + active = null; + } + } + + @Override + public byte[] wrap(final byte[] soapUtf8) { + return active.wrap(soapUtf8); + } + + @Override + public String wrapContentType() { + return active.wrapContentType(); + } + + @Override + public byte[] unwrap(final HttpTransport.Response response) throws Exception { + return active.unwrap(response); + } +} diff --git a/src/main/java/org/metricshub/winrm/light/KerberosAuthScheme.java b/src/main/java/org/metricshub/winrm/light/KerberosAuthScheme.java new file mode 100644 index 0000000..ac3f30f --- /dev/null +++ b/src/main/java/org/metricshub/winrm/light/KerberosAuthScheme.java @@ -0,0 +1,194 @@ +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 java.nio.file.Path; +import java.security.PrivilegedExceptionAction; +import java.util.Base64; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import javax.security.auth.Subject; +import javax.security.auth.callback.Callback; +import javax.security.auth.callback.CallbackHandler; +import javax.security.auth.callback.NameCallback; +import javax.security.auth.callback.PasswordCallback; +import javax.security.auth.login.AppConfigurationEntry; +import javax.security.auth.login.Configuration; +import javax.security.auth.login.LoginContext; +import org.ietf.jgss.GSSContext; +import org.ietf.jgss.GSSException; +import org.ietf.jgss.GSSManager; +import org.ietf.jgss.GSSName; +import org.ietf.jgss.Oid; + +/** + * Kerberos (SPNEGO) authentication scheme using the JDK's built-in GSS-API — no Apache/CXF. It + * obtains a TGT via JAAS ({@code Krb5LoginModule}) from a username+password (or a ticket cache), + * then a service ticket for {@code HTTP/} and emits the AP-REQ under the {@code Negotiate} + * header. + * + *

HTTPS only. Like the CXF backend (which never implemented Kerberos message encryption over + * HTTP), the SOAP travels plaintext inside TLS, so {@link #wrap}/{@link #unwrap} are pass-throughs. + * + *

Realm and KDC resolution is left to the ambient Kerberos configuration (a {@code krb5.conf} or + * the {@code java.security.krb5.*} system properties), exactly as the CXF path did — the library + * sets none itself. + */ +final class KerberosAuthScheme implements AuthScheme { + + private static final String SOAP_CONTENT_TYPE = "application/soap+xml;charset=UTF-8"; + // SPNEGO mechanism OID — the "Negotiate" scheme Windows http.sys expects. + private static final String SPNEGO_OID = "1.3.6.1.5.5.2"; + + private final String servicePrincipalHost; + private final String username; + private final String password; + private final Path ticketCache; + + private GSSContext context; + private boolean authenticated; + + /** + * @param servicePrincipalHost the host whose {@code HTTP/} SPN to target — must be the FQDN + * the KDC knows (never an IP) + * @param username the account name (without any {@code DOMAIN\} prefix) + * @param password the account password (unused when {@code ticketCache} is set) + * @param ticketCache a Kerberos credential cache to reuse, or {@code null} to log in with + * the password + */ + KerberosAuthScheme( + final String servicePrincipalHost, + final String username, + final String password, + final Path ticketCache + ) { + this.servicePrincipalHost = servicePrincipalHost; + this.username = username; + this.password = password; + this.ticketCache = ticketCache; + } + + @Override + public String authenticate(final HttpTransport transport) throws Exception { + final Subject subject = login(); + final byte[] apReq = Subject.doAs( + subject, + (PrivilegedExceptionAction) () -> { + final GSSManager manager = GSSManager.getInstance(); + final Oid spnego = new Oid(SPNEGO_OID); + // NT_HOSTBASED_SERVICE "HTTP@host" maps to the SPN HTTP/host. + final GSSName serverName = manager.createName("HTTP@" + servicePrincipalHost, GSSName.NT_HOSTBASED_SERVICE); + context = manager.createContext(serverName, spnego, null, GSSContext.DEFAULT_LIFETIME); + context.requestMutualAuth(true); + context.requestCredDeleg(false); + // The AP-REQ is complete after the first call; the KDC issued the service ticket using the + // Subject's TGT. The server validates it on the first real request (and, over HTTPS, TLS + // already authenticates the server, so we do not need to process a mutual-auth reply token). + return context.initSecContext(new byte[0], 0, 0); + } + ); + authenticated = true; + return "Negotiate " + Base64.getEncoder().encodeToString(apReq); + } + + @Override + public boolean isAuthenticated() { + return authenticated; + } + + @Override + public void reset() { + if (context != null) { + try { + context.dispose(); + } catch (final GSSException ignore) { + // disposing a dead context is best-effort + } + context = null; + } + authenticated = false; + } + + @Override + public byte[] wrap(final byte[] soapUtf8) { + // HTTPS only: TLS provides confidentiality, so the SOAP travels plaintext. + return soapUtf8; + } + + @Override + public String wrapContentType() { + return SOAP_CONTENT_TYPE; + } + + @Override + public byte[] unwrap(final HttpTransport.Response response) { + return response.body; + } + + /** Obtain a Kerberos {@link Subject} (holding the TGT) via a programmatic JAAS login. */ + private Subject login() throws Exception { + final LoginContext loginContext = new LoginContext("", null, callbackHandler(), krb5Configuration()); + loginContext.login(); + return loginContext.getSubject(); + } + + private CallbackHandler callbackHandler() { + return callbacks -> { + for (final Callback callback : callbacks) { + if (callback instanceof NameCallback) { + ((NameCallback) callback).setName(username); + } else if (callback instanceof PasswordCallback) { + ((PasswordCallback) callback).setPassword(password == null ? null : password.toCharArray()); + } + } + }; + } + + private Configuration krb5Configuration() { + return new Configuration() { + @Override + public AppConfigurationEntry[] getAppConfigurationEntry(final String name) { + final Map options = new HashMap<>(); + options.put("isInitiator", "true"); + options.put("refreshKrb5Config", "true"); + // Uppercase the whole principal: a lowercase UPN domain with an uppercase realm triggers a + // "Message stream modified" KrbException; uppercasing avoids it (as the CXF path does). + options.put("principal", username.toUpperCase(Locale.ROOT)); + if (ticketCache != null) { + options.put("useTicketCache", "true"); + options.put("ticketCache", ticketCache.toString()); + options.put("doNotPrompt", "true"); + } else { + options.put("useTicketCache", "false"); + options.put("doNotPrompt", "false"); + } + return new AppConfigurationEntry[] { + new AppConfigurationEntry( + "com.sun.security.auth.module.Krb5LoginModule", + AppConfigurationEntry.LoginModuleControlFlag.REQUIRED, + options + ) + }; + } + }; + } +} diff --git a/src/main/java/org/metricshub/winrm/light/LightWinRMService.java b/src/main/java/org/metricshub/winrm/light/LightWinRMService.java index b07bd17..aab938d 100644 --- a/src/main/java/org/metricshub/winrm/light/LightWinRMService.java +++ b/src/main/java/org/metricshub/winrm/light/LightWinRMService.java @@ -47,8 +47,9 @@ * (it uses the JDK-default XML factories). * *

Supports NTLM over HTTP (with message encryption) and over HTTPS (plaintext SOAP inside TLS, - * validating the server certificate by default; see {@link LightTls}). Kerberos is still handled by - * the CXF backend until light support lands. + * validating the server certificate by default; see {@link LightTls}), and Kerberos over HTTPS + * (SPNEGO via the JDK GSS-API; see {@link KerberosAuthScheme}). A multi-scheme request such as + * {@code [KERBEROS, NTLM]} is tried in order with fallback. */ public final class LightWinRMService implements WindowsRemoteExecutor { @@ -66,8 +67,10 @@ private LightWinRMService(final WinRMEndpoint winRMEndpoint, final WsmanClient c * * @param winRMEndpoint endpoint with credentials (mandatory) * @param timeout timeout in milliseconds (must be > 0) - * @param ticketCache Kerberos ticket cache path (unused by the light backend) - * @param authentications requested authentication schemes; the light backend supports NTLM + * @param ticketCache Kerberos ticket cache path (used by the Kerberos scheme; {@code null} logs + * in with the password) + * @param authentications requested authentication schemes, tried in order (NTLM and/or Kerberos); + * {@code null}/empty means NTLM only * @return a new {@code LightWinRMService} * @throws WinRMException on invalid arguments or an unsupported authentication request */ @@ -80,33 +83,12 @@ public static LightWinRMService createInstance( Utils.checkNonNull(winRMEndpoint, "winRMEndpoint"); Utils.checkArgumentNotZeroOrNegative(timeout, "timeout"); - // Reject any list that requests a scheme the light backend cannot honour, even when NTLM is also - // present. The authentications list is an ordered fallback: accepting e.g. [KERBEROS, NTLM] would - // silently ignore the preferred Kerberos (and ticketCache) and downgrade to NTLM, which is weaker - // and fails against NTLM-disabled servers. Fail loudly toward the escape hatch instead. - if (authentications != null) { - for (final AuthenticationEnum requested : authentications) { - if (requested != AuthenticationEnum.NTLM) { - throw new WinRMException( - "The light WinRM backend currently supports only NTLM authentication (requested: " + - authentications + - "). Select the CXF backend with -Dorg.metricshub.winrm.backend=cxf until light support lands." - ); - } - } - } - // HTTPS wraps the transport in TLS and exchanges plaintext SOAP; HTTP uses NTLM message sealing. // TLS validates by default (platform trust store + hostname verification); see LightTls. final boolean https = winRMEndpoint.getProtocol() == WinRMHttpProtocolEnum.HTTPS; final SSLSocketFactory sslSocketFactory = https ? LightTls.socketFactory() : null; - final AuthScheme authScheme = new NtlmAuthScheme( - winRMEndpoint.getDomain(), - winRMEndpoint.getUsername(), - new String(winRMEndpoint.getPassword()), - https - ); + final AuthScheme authScheme = resolveAuthScheme(winRMEndpoint, authentications, https, ticketCache); // Use the endpoint's own validated host/port rather than re-parsing the URL: URI.getHost()/getPort() // return null/-1 for names URI cannot classify (underscores, Unicode) that WinRMEndpoint accepts, @@ -122,6 +104,56 @@ public static LightWinRMService createInstance( return new LightWinRMService(winRMEndpoint, client); } + /** + * Resolve the requested authentication schemes into a single {@link AuthScheme}, honoring the + * caller's order. {@code null}/empty means NTLM only. A single scheme is used directly; several + * become an ordered {@link FallbackAuthScheme} (e.g. Kerberos then NTLM). Kerberos requires HTTPS + * (no message encryption over plain HTTP, matching the CXF backend), so it is dropped from the + * candidate list over HTTP — a fallback list then uses its remaining schemes, and a Kerberos-only + * request over HTTP fails toward the escape hatch. + */ + private static AuthScheme resolveAuthScheme( + final WinRMEndpoint winRMEndpoint, + final List authentications, + final boolean https, + final java.nio.file.Path ticketCache + ) throws WinRMException { + final List requested = authentications == null || authentications.isEmpty() + ? List.of(AuthenticationEnum.NTLM) + : authentications; + + final String domain = winRMEndpoint.getDomain(); + final String username = winRMEndpoint.getUsername(); + final String password = new String(winRMEndpoint.getPassword()); + + final List schemes = new ArrayList<>(); + for (final AuthenticationEnum auth : requested) { + if (auth == AuthenticationEnum.NTLM) { + schemes.add(new NtlmAuthScheme(domain, username, password, https)); + } else if (auth == AuthenticationEnum.KERBEROS) { + if (https) { + // The SPN is HTTP/, so the caller must connect by the FQDN the KDC knows. + schemes.add(new KerberosAuthScheme(winRMEndpoint.getHostname(), username, password, ticketCache)); + } + // else: Kerberos is unavailable over plain HTTP — leave it out of the candidate list. + } else { + throw new WinRMException( + "The light WinRM backend supports only NTLM and Kerberos (requested: " + requested + ")." + ); + } + } + + if (schemes.isEmpty()) { + // e.g. Kerberos requested over plain HTTP with no other scheme to fall back to. + throw new WinRMException( + "Kerberos over the light backend requires HTTPS (endpoint was " + + winRMEndpoint.getEndpoint() + + "). Use HTTPS, or select the CXF backend with -Dorg.metricshub.winrm.backend=cxf." + ); + } + return schemes.size() == 1 ? schemes.get(0) : new FallbackAuthScheme(schemes); + } + @Override public List> executeWql(final String wqlQuery, final long timeout) throws TimeoutException, WqlQuerySyntaxException, WindowsRemoteException { diff --git a/src/test/java/org/metricshub/winrm/service/WinRMExecutorFactoryTest.java b/src/test/java/org/metricshub/winrm/service/WinRMExecutorFactoryTest.java index 22a265e..b531fdc 100644 --- a/src/test/java/org/metricshub/winrm/service/WinRMExecutorFactoryTest.java +++ b/src/test/java/org/metricshub/winrm/service/WinRMExecutorFactoryTest.java @@ -106,7 +106,9 @@ void lightBackendAcceptsHttps() throws Exception { } @Test - void lightBackendRejectsKerberosOnly() { + void kerberosOnlyOverHttpRejected() { + // Kerberos requires HTTPS (no message encryption over plain HTTP, matching CXF) and there is no + // other scheme to fall back to, so a Kerberos-only request over HTTP is rejected. System.setProperty(WinRMExecutorFactory.BACKEND_PROPERTY, "light"); assertThrows( WinRMException.class, @@ -121,20 +123,37 @@ void lightBackendRejectsKerberosOnly() { } @Test - void lightBackendRejectsMixedKerberosNtlm() { - // A fallback list like [KERBEROS, NTLM] must be rejected, not silently downgraded to NTLM: the - // light backend cannot honour the preferred Kerberos scheme, so it points at the CXF escape hatch. + void mixedKerberosNtlmFallsBackToNtlmOverHttp() throws Exception { + // Ordered fallback: [KERBEROS, NTLM] over HTTP cannot use Kerberos (HTTPS-only), so it falls back + // to NTLM and constructs successfully. Building the client opens no connection, so this is offline. System.setProperty(WinRMExecutorFactory.BACKEND_PROPERTY, "light"); - assertThrows( - WinRMException.class, - () -> - WinRMExecutorFactory.createInstance( - endpoint(WinRMHttpProtocolEnum.HTTP), - 30000L, - null, - List.of(AuthenticationEnum.KERBEROS, AuthenticationEnum.NTLM) - ) - ); + try ( + final WindowsRemoteExecutor executor = WinRMExecutorFactory.createInstance( + endpoint(WinRMHttpProtocolEnum.HTTP), + 30000L, + null, + List.of(AuthenticationEnum.KERBEROS, AuthenticationEnum.NTLM) + ) + ) { + assertInstanceOf(LightWinRMService.class, executor); + } + } + + @Test + void kerberosOverHttpsAccepted() throws Exception { + // Kerberos is supported over HTTPS; the login/handshake happen on the first operation, so + // constructing the executor stays offline. + System.setProperty(WinRMExecutorFactory.BACKEND_PROPERTY, "light"); + try ( + final WindowsRemoteExecutor executor = WinRMExecutorFactory.createInstance( + endpoint(WinRMHttpProtocolEnum.HTTPS), + 30000L, + null, + List.of(AuthenticationEnum.KERBEROS) + ) + ) { + assertInstanceOf(LightWinRMService.class, executor); + } } @Test From e2261c620237d93b18bd4923e68e2bd726b82002 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Thu, 23 Jul 2026 19:30:38 +0200 Subject: [PATCH 13/21] Remove unused imports left by the AuthScheme extraction (checkstyle) Locale, java.util.regex.Matcher and java.util.regex.Pattern became unused in WsmanClient once the NTLM orchestration (domain-uppercasing, Negotiate-token regex) moved into NtlmAuthScheme and HttpTransport.Response.negotiateToken(). The CI Checkstyle check treats UnusedImports as a failure (the verify-bound checkstyle:check only fails on error severity, so the local build missed it). Co-Authored-By: Claude Opus 4.8 --- src/main/java/org/metricshub/winrm/light/WsmanClient.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/main/java/org/metricshub/winrm/light/WsmanClient.java b/src/main/java/org/metricshub/winrm/light/WsmanClient.java index a54ba55..dcb0aec 100644 --- a/src/main/java/org/metricshub/winrm/light/WsmanClient.java +++ b/src/main/java/org/metricshub/winrm/light/WsmanClient.java @@ -27,11 +27,8 @@ import java.util.Base64; import java.util.LinkedHashMap; import java.util.List; -import java.util.Locale; import java.util.Map; import java.util.concurrent.locks.ReentrantLock; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import javax.net.ssl.SSLSocketFactory; import javax.xml.XMLConstants; import javax.xml.parsers.DocumentBuilder; From 324154b29c9140162b290cceb9142299961447b8 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Thu, 23 Jul 2026 19:43:20 +0200 Subject: [PATCH 14/21] Don't wedge on an auth rejection; fall back on a server-side 401 (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address two findings from the Kerberos adversarial review. NTLM and Kerberos both send their Type 3 / AP-REQ on the FIRST real request, so a credential/token rejection surfaces as an HTTP 401 in WsmanClient.request() AFTER authenticate() has already set the scheme "authenticated" — not during the handshake. - Wedge (the serious one, affected NTLM too): a 401 on a connection we believed authenticated left isAuthenticated() true, so request() skipped re-auth and re-sent with a null Authorization header, 401-looping until the executor was recreated. request() now treats a 401 as a rejection: it drops the connection and resets the auth state, so the next operation cleanly re-handshakes instead of wedging. (The rejected request was not processed server-side, so re-sending is safe.) - Server-side fallback: an ordered [KERBEROS, NTLM] list only fell back when Kerberos failed client-side (no TGT/KDC). If the server rejected an otherwise-valid ticket (clock skew, channel-binding/CBT), NTLM was never tried. AuthScheme gains advance(); FallbackAuthScheme advances past a server-rejected scheme, and request() retries the next scheme once on a fresh connection after a 401. On a dropped connection it still re-handshakes with the same already-accepted scheme rather than restarting fallback. Tests: FallbackAuthSchemeTest (client-side fallback, advance() past a rejected scheme, all-fail). Live-verified unchanged: NTLM HTTP+HTTPS, Kerberos HTTPS, and client-side fallback all still succeed. mvn verify 48 tests green. Co-Authored-By: Claude Opus 4.8 --- .../metricshub/winrm/light/AuthScheme.java | 13 +++ .../winrm/light/FallbackAuthScheme.java | 24 ++++- .../metricshub/winrm/light/WsmanClient.java | 56 ++++++---- .../winrm/light/FallbackAuthSchemeTest.java | 102 ++++++++++++++++++ 4 files changed, 175 insertions(+), 20 deletions(-) create mode 100644 src/test/java/org/metricshub/winrm/light/FallbackAuthSchemeTest.java diff --git a/src/main/java/org/metricshub/winrm/light/AuthScheme.java b/src/main/java/org/metricshub/winrm/light/AuthScheme.java index 753d8ce..6562785 100644 --- a/src/main/java/org/metricshub/winrm/light/AuthScheme.java +++ b/src/main/java/org/metricshub/winrm/light/AuthScheme.java @@ -72,4 +72,17 @@ interface AuthScheme { * @throws Exception if the body cannot be trusted or decoded */ byte[] unwrap(HttpTransport.Response response) throws Exception; + + /** + * After the server rejects this scheme on a real request (HTTP 401) — which for Kerberos/NTLM only + * surfaces after {@link #authenticate} has returned, because the token/Type-3 rides the first real + * request — move to the next candidate of an ordered fallback list, if any. A single scheme cannot + * advance. + * + * @return {@code true} if a further scheme is now available so the caller should re-authenticate and + * retry; {@code false} if there is nothing left to try + */ + default boolean advance() { + return false; + } } diff --git a/src/main/java/org/metricshub/winrm/light/FallbackAuthScheme.java b/src/main/java/org/metricshub/winrm/light/FallbackAuthScheme.java index 4ee6094..435daa2 100644 --- a/src/main/java/org/metricshub/winrm/light/FallbackAuthScheme.java +++ b/src/main/java/org/metricshub/winrm/light/FallbackAuthScheme.java @@ -33,6 +33,8 @@ final class FallbackAuthScheme implements AuthScheme { private final List candidates; + // The next candidate to try when (re)running the fallback; advanced past a server-rejected scheme. + private int startIndex; private AuthScheme active; FallbackAuthScheme(final List candidates) { @@ -44,12 +46,18 @@ final class FallbackAuthScheme implements AuthScheme { @Override public String authenticate(final HttpTransport transport) throws Exception { + // Re-authenticating after a dropped connection: reuse the scheme that already succeeded rather + // than restarting the fallback from the top. + if (active != null) { + return active.authenticate(transport); + } Exception lastFailure = null; - for (int i = 0; i < candidates.size(); i++) { + for (int i = startIndex; i < candidates.size(); i++) { final AuthScheme candidate = candidates.get(i); try { final String authorization = candidate.authenticate(transport); active = candidate; + startIndex = i; return authorization; } catch (final Exception e) { lastFailure = e; @@ -75,10 +83,24 @@ public boolean isAuthenticated() { @Override public void reset() { + // A dropped connection: clear the active scheme's session but keep it selected so the next + // authenticate() re-handshakes with the same (already-accepted) scheme. if (active != null) { active.reset(); + } + } + + @Override + public boolean advance() { + // The server rejected the active scheme (401 on a real request). Drop it and move to the next + // candidate so the next authenticate() runs the remaining schemes. + if (active != null && startIndex + 1 < candidates.size()) { + active.reset(); active = null; + startIndex++; + return true; } + return false; } @Override diff --git a/src/main/java/org/metricshub/winrm/light/WsmanClient.java b/src/main/java/org/metricshub/winrm/light/WsmanClient.java index dcb0aec..dc80e71 100644 --- a/src/main/java/org/metricshub/winrm/light/WsmanClient.java +++ b/src/main/java/org/metricshub/winrm/light/WsmanClient.java @@ -245,27 +245,45 @@ private Decoded request(final String soap) throws Exception { if (auth.isAuthenticated() && !transport.isConnected()) { auth.reset(); } - if (!auth.isAuthenticated()) { - pendingAuthorization = auth.authenticate(transport); - } - // The handshake's Authorization accompanies the first real request; later requests on the - // already-authenticated connection carry no Authorization header. - final String authorization = pendingAuthorization; - pendingAuthorization = null; - - final HttpTransport.Response resp = transport.post( - "/wsman", - auth.wrap(soap.getBytes(StandardCharsets.UTF_8)), - auth.wrapContentType(), - authorization - ); + final byte[] body = soap.getBytes(StandardCharsets.UTF_8); + while (true) { + if (!auth.isAuthenticated()) { + pendingAuthorization = auth.authenticate(transport); + } + // The handshake's Authorization accompanies the first real request; later requests on the + // already-authenticated connection carry no Authorization header. + final String authorization = pendingAuthorization; + pendingAuthorization = null; + + final HttpTransport.Response resp = transport.post( + "/wsman", + auth.wrap(body), + auth.wrapContentType(), + authorization + ); + + // HTTP 401 = the server rejected the credentials/token. For NTLM and Kerberos this can only + // surface here — the Type 3 / AP-REQ rides the first real request, not the handshake — so we + // must NOT keep the "authenticated" connection (it would loop resending with no Authorization + // header, wedging the executor). Drop it and, for an ordered fallback list, retry the next + // scheme once on a fresh connection. A 401'd request was rejected before processing, so + // re-sending it is safe. + if (resp.status == 401) { + transport.close(); + auth.reset(); + if (auth.advance()) { + continue; + } + throw new IllegalStateException("WSMan request failed: authentication rejected (HTTP 401)"); + } - // 200 = success, 500 = SOAP fault. Anything else is a protocol or authentication failure whose - // body is not a usable WSMan response. - if (resp.status != 200 && resp.status != 500) { - throw new IllegalStateException("WSMan request failed: HTTP " + resp.status); + // 200 = success, 500 = SOAP fault. Anything else is a protocol failure whose body is not a + // usable WSMan response. + if (resp.status != 200 && resp.status != 500) { + throw new IllegalStateException("WSMan request failed: HTTP " + resp.status); + } + return new Decoded(resp.status, parse(auth.unwrap(resp))); } - return new Decoded(resp.status, parse(auth.unwrap(resp))); } // --- XML helpers -------------------------------------------------------- diff --git a/src/test/java/org/metricshub/winrm/light/FallbackAuthSchemeTest.java b/src/test/java/org/metricshub/winrm/light/FallbackAuthSchemeTest.java new file mode 100644 index 0000000..7f7a448 --- /dev/null +++ b/src/test/java/org/metricshub/winrm/light/FallbackAuthSchemeTest.java @@ -0,0 +1,102 @@ +package org.metricshub.winrm.light; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import org.junit.jupiter.api.Test; + +/** Verifies the ordered-fallback state machine without a network. */ +class FallbackAuthSchemeTest { + + /** A stand-in AuthScheme whose handshake can be made to fail client-side. */ + private static final class FakeScheme implements AuthScheme { + + private final String name; + private final boolean failHandshake; + private boolean authenticated; + private int authenticateCalls; + + FakeScheme(final String name, final boolean failHandshake) { + this.name = name; + this.failHandshake = failHandshake; + } + + @Override + public String authenticate(final HttpTransport transport) throws Exception { + authenticateCalls++; + if (failHandshake) { + throw new IllegalStateException(name + " handshake failed"); + } + authenticated = true; + return "Negotiate " + name; + } + + @Override + public boolean isAuthenticated() { + return authenticated; + } + + @Override + public void reset() { + authenticated = false; + } + + @Override + public byte[] wrap(final byte[] soapUtf8) { + return soapUtf8; + } + + @Override + public String wrapContentType() { + return "application/soap+xml;charset=UTF-8"; + } + + @Override + public byte[] unwrap(final HttpTransport.Response response) { + return response.body; + } + } + + // Never connects — the fake schemes ignore it, and FallbackAuthScheme only calls close() on it. + private static HttpTransport dummyTransport() { + return new HttpTransport("localhost", 1, 1000); + } + + @Test + void clientSideFailureFallsBackToNextScheme() throws Exception { + final FakeScheme kerberos = new FakeScheme("kerberos", true); // fails during the handshake + final FakeScheme ntlm = new FakeScheme("ntlm", false); + final FallbackAuthScheme fallback = new FallbackAuthScheme(List.of(kerberos, ntlm)); + + assertEquals("Negotiate ntlm", fallback.authenticate(dummyTransport())); + assertTrue(fallback.isAuthenticated()); + assertEquals(1, kerberos.authenticateCalls); + assertEquals(1, ntlm.authenticateCalls); + } + + @Test + void advanceMovesPastAServerRejectedScheme() throws Exception { + // Both handshakes succeed client-side; the first is "rejected server-side" via advance(). + final FakeScheme kerberos = new FakeScheme("kerberos", false); + final FakeScheme ntlm = new FakeScheme("ntlm", false); + final FallbackAuthScheme fallback = new FallbackAuthScheme(List.of(kerberos, ntlm)); + final HttpTransport transport = dummyTransport(); + + assertEquals("Negotiate kerberos", fallback.authenticate(transport)); + assertTrue(fallback.advance()); // server rejected kerberos -> move to ntlm + assertFalse(fallback.isAuthenticated()); // active cleared until re-authenticated + assertEquals("Negotiate ntlm", fallback.authenticate(transport)); + assertFalse(fallback.advance()); // ntlm is the last candidate + } + + @Test + void allSchemesFailingThrows() { + final FallbackAuthScheme fallback = new FallbackAuthScheme( + List.of(new FakeScheme("kerberos", true), new FakeScheme("ntlm", true)) + ); + assertThrows(IllegalStateException.class, () -> fallback.authenticate(dummyTransport())); + } +} From 2625fcfc50d2612971ada2b4131300e6688f0dfe Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Thu, 23 Jul 2026 20:21:18 +0200 Subject: [PATCH 15/21] Fall through on active-scheme re-auth failure; dispose auth on close (P2 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two P2 Codex findings: - FallbackAuthScheme no longer takes a shortcut that reused the previously-selected scheme on reconnect without falling through. It now always runs the candidate loop from startIndex (which still points at the last-successful scheme, so that one is retried first), so if its re-authentication fails — e.g. an expired TGT or a briefly unavailable KDC on a long-lived executor that had been using Kerberos — the loop falls through to the remaining candidates (NTLM) instead of throwing. - WsmanClient.close() now calls auth.reset() (under the same operation-lock guard as the graceful shell Delete) so a successfully authenticated Kerberos client disposes its GSSContext at teardown rather than leaking native GSS/security-context resources until GC. Skipped when the lock is not held (a timed-out worker still owns the auth scheme); the transport hard-close then unblocks it, preserving concurrent-close behavior. Test: FallbackAuthSchemeTest.reAuthFailureOfActiveSchemeFallsThrough. Live-verified unchanged: NTLM HTTP+HTTPS, Kerberos HTTPS, and client-side fallback all still succeed. mvn verify 49 tests green. Co-Authored-By: Claude Opus 4.8 --- .../winrm/light/FallbackAuthScheme.java | 9 ++--- .../metricshub/winrm/light/WsmanClient.java | 16 +++++--- .../winrm/light/FallbackAuthSchemeTest.java | 39 ++++++++++++++----- 3 files changed, 44 insertions(+), 20 deletions(-) diff --git a/src/main/java/org/metricshub/winrm/light/FallbackAuthScheme.java b/src/main/java/org/metricshub/winrm/light/FallbackAuthScheme.java index 435daa2..efe7039 100644 --- a/src/main/java/org/metricshub/winrm/light/FallbackAuthScheme.java +++ b/src/main/java/org/metricshub/winrm/light/FallbackAuthScheme.java @@ -46,11 +46,10 @@ final class FallbackAuthScheme implements AuthScheme { @Override public String authenticate(final HttpTransport transport) throws Exception { - // Re-authenticating after a dropped connection: reuse the scheme that already succeeded rather - // than restarting the fallback from the top. - if (active != null) { - return active.authenticate(transport); - } + // Run the candidates from startIndex. After a dropped connection startIndex still points at the + // scheme that last succeeded, so it is retried first; but if that re-authentication now fails + // (e.g. an expired TGT or a briefly unavailable KDC) we fall through to the remaining candidates + // rather than abandoning the whole fallback list. Exception lastFailure = null; for (int i = startIndex; i < candidates.size(); i++) { final AuthScheme candidate = candidates.get(i); diff --git a/src/main/java/org/metricshub/winrm/light/WsmanClient.java b/src/main/java/org/metricshub/winrm/light/WsmanClient.java index dc80e71..f6f6229 100644 --- a/src/main/java/org/metricshub/winrm/light/WsmanClient.java +++ b/src/main/java/org/metricshub/winrm/light/WsmanClient.java @@ -421,12 +421,18 @@ public void close() { try { final String shell = shellId; shellId = null; - if (locked && shell != null) { - try { - request(Envelopes.deleteShell(url, shell, timeoutMs)); - } catch (final Exception ignore) { - // best-effort shell cleanup + if (locked) { + if (shell != null) { + try { + request(Envelopes.deleteShell(url, shell, timeoutMs)); + } catch (final Exception ignore) { + // best-effort shell cleanup + } } + // Release the connection-bound auth state — notably the Kerberos GSSContext, whose only + // disposal path is reset(). Skipped when not locked: another (timed-out) worker still owns + // the auth scheme, and the transport hard-close below unblocks it. + auth.reset(); } } finally { if (locked) { diff --git a/src/test/java/org/metricshub/winrm/light/FallbackAuthSchemeTest.java b/src/test/java/org/metricshub/winrm/light/FallbackAuthSchemeTest.java index 7f7a448..f1641e9 100644 --- a/src/test/java/org/metricshub/winrm/light/FallbackAuthSchemeTest.java +++ b/src/test/java/org/metricshub/winrm/light/FallbackAuthSchemeTest.java @@ -11,23 +11,24 @@ /** Verifies the ordered-fallback state machine without a network. */ class FallbackAuthSchemeTest { - /** A stand-in AuthScheme whose handshake can be made to fail client-side. */ + /** A stand-in AuthScheme whose handshake can be made to fail on/after a chosen call. */ private static final class FakeScheme implements AuthScheme { private final String name; - private final boolean failHandshake; + private final int failFromCall; // fail on this 1-based authenticate() call onward; MAX_VALUE = never private boolean authenticated; private int authenticateCalls; - FakeScheme(final String name, final boolean failHandshake) { + FakeScheme(final String name, final int failFromCall) { this.name = name; - this.failHandshake = failHandshake; + this.failFromCall = failFromCall; } @Override public String authenticate(final HttpTransport transport) throws Exception { authenticateCalls++; - if (failHandshake) { + if (authenticateCalls >= failFromCall) { + authenticated = false; throw new IllegalStateException(name + " handshake failed"); } authenticated = true; @@ -60,6 +61,9 @@ public byte[] unwrap(final HttpTransport.Response response) { } } + private static final int NEVER = Integer.MAX_VALUE; + private static final int ALWAYS = 1; + // Never connects — the fake schemes ignore it, and FallbackAuthScheme only calls close() on it. private static HttpTransport dummyTransport() { return new HttpTransport("localhost", 1, 1000); @@ -67,8 +71,8 @@ private static HttpTransport dummyTransport() { @Test void clientSideFailureFallsBackToNextScheme() throws Exception { - final FakeScheme kerberos = new FakeScheme("kerberos", true); // fails during the handshake - final FakeScheme ntlm = new FakeScheme("ntlm", false); + final FakeScheme kerberos = new FakeScheme("kerberos", ALWAYS); // fails during the handshake + final FakeScheme ntlm = new FakeScheme("ntlm", NEVER); final FallbackAuthScheme fallback = new FallbackAuthScheme(List.of(kerberos, ntlm)); assertEquals("Negotiate ntlm", fallback.authenticate(dummyTransport())); @@ -80,8 +84,8 @@ void clientSideFailureFallsBackToNextScheme() throws Exception { @Test void advanceMovesPastAServerRejectedScheme() throws Exception { // Both handshakes succeed client-side; the first is "rejected server-side" via advance(). - final FakeScheme kerberos = new FakeScheme("kerberos", false); - final FakeScheme ntlm = new FakeScheme("ntlm", false); + final FakeScheme kerberos = new FakeScheme("kerberos", NEVER); + final FakeScheme ntlm = new FakeScheme("ntlm", NEVER); final FallbackAuthScheme fallback = new FallbackAuthScheme(List.of(kerberos, ntlm)); final HttpTransport transport = dummyTransport(); @@ -92,10 +96,25 @@ void advanceMovesPastAServerRejectedScheme() throws Exception { assertFalse(fallback.advance()); // ntlm is the last candidate } + @Test + void reAuthFailureOfActiveSchemeFallsThrough() throws Exception { + // Kerberos succeeds initially, then fails on the second handshake (e.g. TGT expired on reconnect). + final FakeScheme kerberos = new FakeScheme("kerberos", 2); + final FakeScheme ntlm = new FakeScheme("ntlm", NEVER); + final FallbackAuthScheme fallback = new FallbackAuthScheme(List.of(kerberos, ntlm)); + final HttpTransport transport = dummyTransport(); + + assertEquals("Negotiate kerberos", fallback.authenticate(transport)); // picks kerberos + fallback.reset(); // simulate a dropped connection + // Re-auth of kerberos now fails, so it must fall through to ntlm rather than throwing. + assertEquals("Negotiate ntlm", fallback.authenticate(transport)); + assertEquals(2, kerberos.authenticateCalls); + } + @Test void allSchemesFailingThrows() { final FallbackAuthScheme fallback = new FallbackAuthScheme( - List.of(new FakeScheme("kerberos", true), new FakeScheme("ntlm", true)) + List.of(new FakeScheme("kerberos", ALWAYS), new FakeScheme("ntlm", ALWAYS)) ); assertThrows(IllegalStateException.class, () -> fallback.authenticate(dummyTransport())); } From 782604e9639e4563a5f6160d5a1eca3563501513 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Thu, 23 Jul 2026 20:27:10 +0200 Subject: [PATCH 16/21] Document the light-default upgrade warning (README, site, CHANGELOG) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The light backend is the default and validates TLS certificates by default, unlike the CXF client which trusted all certificates — so WinRM-over-HTTPS to self-signed hosts now fails unless the cert is trusted, TLS validation is disabled (-Dorg.metricshub.winrm.tls.insecure=true), or the CXF backend is selected (-Dorg.metricshub.winrm.backend=cxf). Added a prominent upgrade warning to README.md and the documentation site, a new CHANGELOG.md carrying the release notes, and refreshed the stale "WinRM backends" section (light now does NTLM over HTTP/HTTPS and Kerberos over HTTPS; CXF is opt-in and slated for removal). Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 40 ++++++++++++++++++++++++++++++++++++++ README.md | 25 +++++++++++++++++------- src/site/markdown/index.md | 12 ++++++++++++ 3 files changed, 70 insertions(+), 7 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e6386d1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,40 @@ +# Changelog + +All notable changes to this project are documented in this file. + +## [Unreleased] + +### ⚠️ Upgrade warning — WinRM over HTTPS with self-signed certificates + +The new dependency-free **light** backend is now the **default**. Unlike the previous CXF-based +client — which silently trusted every TLS certificate and skipped hostname verification — the light +backend **validates the server certificate and verifies the hostname by default**. + +As a result, **WinRM-over-HTTPS connections to hosts with self-signed or otherwise untrusted +certificates that worked with earlier versions will now fail** during the TLS handshake. To restore +connectivity, do one of: + +- install the server certificate (or its issuing CA) into a Java trust store + (`-Djavax.net.ssl.trustStore=...`); +- disable TLS validation with `-Dorg.metricshub.winrm.tls.insecure=true` + (**insecure — for testing only**); or +- select the legacy CXF backend with `-Dorg.metricshub.winrm.backend=cxf`. + +### Added + +- Dependency-free "light" WinRM backend with no Apache CXF / JAX-WS / JAXB stack, immune by + construction to JAXP `ServiceLoader` conflicts (it uses the JDK-default XML factories). Supports + NTLM over HTTP and HTTPS, and Kerberos (SPNEGO, via the JDK GSS-API) over HTTPS. +- `org.metricshub.winrm.backend` system property to choose the backend (`light` — default — or `cxf`). +- `org.metricshub.winrm.tls.insecure` system property to trust all TLS certificates and skip hostname + verification on the light backend (insecure — for testing only). + +### Changed + +- The **light** backend is now the default; the CXF backend is opt-in via + `org.metricshub.winrm.backend=cxf`. +- HTTPS connections validate certificates and verify hostnames by default (see the upgrade warning). + +### Deprecated + +- The CXF-based backend is deprecated and will be **removed in a future major release**. diff --git a/README.md b/README.md index 7f59f29..95c9f0a 100644 --- a/README.md +++ b/README.md @@ -13,22 +13,33 @@ The Windows Remote Management (WinRM) Java Client is a library that enables to: * Connect to a remote Windows server using one of the two authentication types (NTLM, KERBEROS) * Execute WMI Query Language (WQL) queries which uses HTTP/HTTPS protocols. +> ## ⚠️ Upgrade warning +> +> The **light** backend is now the **default**, and — unlike the previous CXF-based client, which +> silently trusted every TLS certificate — it **validates the server certificate and verifies the +> hostname by default**. **WinRM-over-HTTPS connections to hosts with self-signed or otherwise +> untrusted certificates will now fail** during the TLS handshake unless you do one of: +> +> * install the server certificate (or its issuing CA) into a Java trust store (e.g. `-Djavax.net.ssl.trustStore=...`); +> * disable TLS validation with `-Dorg.metricshub.winrm.tls.insecure=true` (**insecure — for testing only**); or +> * select the legacy CXF backend with `-Dorg.metricshub.winrm.backend=cxf`. +> +> The CXF backend stays available through that property for now and will be **removed in a future major release**. + ## WinRM backends -The library ships two interchangeable backends, both implementing the same API so calling code is unaffected by the choice: +The library ships two interchangeable backends, both implementing the same public API so calling code is unaffected by the choice: -* **light** (default) — a dependency-free client with no Apache CXF / JAX-WS / JAXB stack. It currently supports **NTLM over HTTP** with message encryption, and is immune by construction to JAXP `ServiceLoader` conflicts (it uses the JDK-default XML factories). -* **cxf** — the mature CXF-based backend, additionally covering **HTTPS** and **Kerberos**. +* **light** (default) — a dependency-free client (no Apache CXF / JAX-WS / JAXB), immune by construction to JAXP `ServiceLoader` conflicts (it uses the JDK-default XML factories). Supports **NTLM over HTTP and HTTPS** and **Kerberos (SPNEGO) over HTTPS**. Over HTTPS it validates the certificate and verifies the hostname by default (see the upgrade warning above); `-Dorg.metricshub.winrm.tls.insecure=true` trusts all certificates (insecure, testing only). Kerberos uses the ambient Kerberos configuration (`krb5.conf` / `-Djava.security.krb5.*`). +* **cxf** — the mature CXF-based backend, still available during the transition and scheduled for removal in a future major release. -Select the backend with the `org.metricshub.winrm.backend` system property. When it is unset, the **light** backend is used: +Select the backend with the `org.metricshub.winrm.backend` system property (`light` is the default): ```bash -# Force the CXF backend (currently required for HTTPS or Kerberos) +# opt into the legacy CXF backend java -Dorg.metricshub.winrm.backend=cxf ... ``` -Requesting HTTPS or Kerberos on the light backend raises an error that points to the `cxf` value above, until the corresponding light support lands. - ## Build instructions This is a simple Maven project. Build with: diff --git a/src/site/markdown/index.md b/src/site/markdown/index.md index 095b574..6fbd045 100644 --- a/src/site/markdown/index.md +++ b/src/site/markdown/index.md @@ -4,6 +4,18 @@ The Windows Remote Management (WinRM) Java Client is a library that enables to: * Connect to a remote Windows server using one of the two authentication types (NTLM, KERBEROS) * Execute WMI Query Language (WQL) queries which uses HTTP/HTTPS protocols. +> ## ⚠️ Upgrade warning +> +> The dependency-free **light** backend is now the **default**. Unlike the previous CXF-based client, +> which silently trusted every TLS certificate, the light backend **validates the server certificate +> and verifies the hostname by default**, so **WinRM-over-HTTPS connections to hosts with self-signed +> or untrusted certificates will now fail** during the TLS handshake. To restore connectivity, either +> install the certificate into a Java trust store, set `-Dorg.metricshub.winrm.tls.insecure=true` +> (insecure — for testing only), or select the legacy backend with +> `-Dorg.metricshub.winrm.backend=cxf`. The light backend supports NTLM over HTTP/HTTPS and Kerberos +> (SPNEGO) over HTTPS; the CXF backend remains available via that property and will be removed in a +> future major release. + # How to run the WinRM Client inside Java Add WinRM in the list of dependencies in your [Maven **pom.xml**](https://maven.apache.org/pom.html): From 38c51ab43546ed2f505809c56d9917c408f7b51b Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Thu, 23 Jul 2026 20:41:20 +0200 Subject: [PATCH 17/21] Decode command output once, not per chunk, to preserve split multibyte chars (P2 review) WSMan may split a multibyte character (e.g. UTF-8) across separate Stream elements or Receive responses. receiveLoop decoded each base64 chunk to a String independently, so a character straddling a chunk boundary was turned into replacement characters, permanently corrupting stdout/stderr. collectStreams now appends the raw decoded bytes per stream into a ByteArrayOutputStream, and receiveLoop decodes the accumulated bytes with the charset once, after the command completes. ASCII output is unaffected; a boundary-split multibyte character is now reassembled correctly. Live-verified unchanged: multi-chunk command output (ipconfig /all) assembles correctly over NTLM, and command execution over Kerberos/HTTPS still works. mvn verify 49 tests green. Co-Authored-By: Claude Opus 4.8 --- .../metricshub/winrm/light/WsmanClient.java | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/src/main/java/org/metricshub/winrm/light/WsmanClient.java b/src/main/java/org/metricshub/winrm/light/WsmanClient.java index f6f6229..ea52de0 100644 --- a/src/main/java/org/metricshub/winrm/light/WsmanClient.java +++ b/src/main/java/org/metricshub/winrm/light/WsmanClient.java @@ -21,6 +21,7 @@ */ import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.ArrayList; @@ -195,8 +196,11 @@ private String startCommand(final String commandLine) throws Exception { } private CommandOutput receiveLoop(final String commandId, final Charset charset) throws Exception { - final StringBuilder stdout = new StringBuilder(); - final StringBuilder stderr = new StringBuilder(); + // Accumulate the raw stream BYTES and decode once at the end: a multibyte character (e.g. UTF-8) + // can be split across Stream elements or Receive responses, and decoding each chunk independently + // would corrupt the boundary bytes into replacement characters. + final ByteArrayOutputStream stdout = new ByteArrayOutputStream(); + final ByteArrayOutputStream stderr = new ByteArrayOutputStream(); while (true) { final Decoded resp = request(Envelopes.receive(url, shellId, commandId, timeoutMs)); if (resp.status != 200) { @@ -207,10 +211,14 @@ private CommandOutput receiveLoop(final String commandId, final Charset charset) } throw new IllegalStateException("Receive failed: " + faultSummary(resp)); } - collectStreams(resp.document, stdout, stderr, charset); + collectStreams(resp.document, stdout, stderr); final Integer exitCode = doneExitCode(resp.document); if (exitCode != null) { - return new CommandOutput(stdout.toString(), stderr.toString(), exitCode); + return new CommandOutput( + new String(stdout.toByteArray(), charset), + new String(stderr.toByteArray(), charset), + exitCode + ); } } } @@ -361,9 +369,8 @@ private static void collectItems(final Document doc, final List Date: Thu, 23 Jul 2026 21:24:11 +0200 Subject: [PATCH 18/21] Match the CXF backend's exception surface and fault mapping (#106) - Authentication rejections now raise the same message as CXF: 'Authentication error on with user name ""'. - Operations on a closed executor raise CXF's exact IllegalStateException message ('This instance has been closed and a new one must be created.'). - WSMan OperationTimeout is formatted exactly like CXF (DecimalFormat PT#.###S, ROOT locale, millisecond precision). - EndOfSequence and Items are recognized in both the WS-Enumeration and the WSMan namespace variants (as CXF does), and in nothing else, so WMI properties named like the markers cannot corrupt an enumeration. - Fault summaries additionally carry the detailed WSManFault Message (provider detail incl. WMI WBEM_E_* mnemonics) next to the SOAP reason text; MetricsHub matches on those mnemonics to classify errors. Live-verified against anaxagore (NTLM/HTTP, encrypted) and tc-win2016 (NTLM/HTTPS): ok-wql/ok-cmd identical, bad-class/bad-namespace carry the same server fault text, bad-creds messages byte-identical across backends. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 7 + .../org/metricshub/winrm/light/Envelopes.java | 21 +- .../winrm/light/LightWinRMService.java | 6 +- .../metricshub/winrm/light/WsmanClient.java | 93 +++++-- .../winrm/light/WsmanClientParityTest.java | 229 ++++++++++++++++++ .../service/WinRMExecutorFactoryTest.java | 4 +- 6 files changed, 337 insertions(+), 23 deletions(-) create mode 100644 src/test/java/org/metricshub/winrm/light/WsmanClientParityTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index e6386d1..5c7e491 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,13 @@ connectivity, do one of: - The **light** backend is now the default; the CXF backend is opt-in via `org.metricshub.winrm.backend=cxf`. - HTTPS connections validate certificates and verify hostnames by default (see the upgrade warning). +- The light backend's exception surface now matches the CXF backend (feature parity, #106): + authentication rejections raise the same `Authentication error on with user name ""` + message, operations on a closed executor raise the same `IllegalStateException` message, the WSMan + `OperationTimeout` header uses the same `PT#.###S` millisecond-precision format, and the + `EndOfSequence` / `Items` enumeration markers are recognized in both their WS-Enumeration and WSMan + namespace variants. WSMan fault exceptions additionally carry the detailed `WSManFault` message + (including the provider-level detail, e.g. WMI `WBEM_E_*` mnemonics) alongside the SOAP reason text. ### Deprecated diff --git a/src/main/java/org/metricshub/winrm/light/Envelopes.java b/src/main/java/org/metricshub/winrm/light/Envelopes.java index 1969d57..001e924 100644 --- a/src/main/java/org/metricshub/winrm/light/Envelopes.java +++ b/src/main/java/org/metricshub/winrm/light/Envelopes.java @@ -20,6 +20,10 @@ * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ */ +import java.math.BigDecimal; +import java.text.DecimalFormat; +import java.text.DecimalFormatSymbols; +import java.util.Locale; import java.util.UUID; /** @@ -160,6 +164,16 @@ private static String shellSelector(final String shellId) { ); } + /** + * Format the WSMan OperationTimeout exactly like the CXF backend: {@code PT#.###S} with ROOT + * locale symbols (always a {@code .} decimal separator, up to millisecond precision, no trailing + * zeros) — e.g. 30000 ms → {@code PT30S}, 1500 ms → {@code PT1.5S}, 1234 ms → {@code PT1.234S}. + */ + private static String operationTimeout(final long timeoutMs) { + final BigDecimal seconds = BigDecimal.valueOf(timeoutMs).divide(BigDecimal.valueOf(1000)); + return new DecimalFormat("PT#.###S", new DecimalFormatSymbols(Locale.ROOT)).format(seconds); + } + private static String header( final String url, final String resourceUri, @@ -168,7 +182,6 @@ private static String header( final String selectorSet, final String optionSet ) { - final long seconds = Math.max(1, timeoutMs / 1000); return ( "" + "" + @@ -192,9 +205,9 @@ private static String header( "" + (selectorSet == null ? "" : selectorSet) + (optionSet == null ? "" : optionSet) + - "PT" + - seconds + - "S" + + "" + + operationTimeout(timeoutMs) + + "" + "" ); } diff --git a/src/main/java/org/metricshub/winrm/light/LightWinRMService.java b/src/main/java/org/metricshub/winrm/light/LightWinRMService.java index aab938d..d8d1017 100644 --- a/src/main/java/org/metricshub/winrm/light/LightWinRMService.java +++ b/src/main/java/org/metricshub/winrm/light/LightWinRMService.java @@ -99,7 +99,8 @@ public static LightWinRMService createInstance( timeout, sslSocketFactory, https && LightTls.verifyHostname(), - authScheme + authScheme, + winRMEndpoint.getRawUsername() ); return new LightWinRMService(winRMEndpoint, client); } @@ -242,8 +243,9 @@ public void close() { } private void checkNotClosed() { + // Same message as the CXF backend's checkConnectedFirst() — part of the exception surface. if (closed.get()) { - throw new IllegalStateException("This WinRM executor has been closed; create a new one."); + throw new IllegalStateException("This instance has been closed and a new one must be created."); } } } diff --git a/src/main/java/org/metricshub/winrm/light/WsmanClient.java b/src/main/java/org/metricshub/winrm/light/WsmanClient.java index ea52de0..3a8b399 100644 --- a/src/main/java/org/metricshub/winrm/light/WsmanClient.java +++ b/src/main/java/org/metricshub/winrm/light/WsmanClient.java @@ -56,8 +56,13 @@ final class WsmanClient implements AutoCloseable { // or "EnumerationContext" inside cannot be mistaken for the enumeration control element. private static final String WS_ENUMERATION_NS = "http://schemas.xmlsoap.org/ws/2004/09/enumeration"; + // WinRM also emits the Items / EndOfSequence markers in its own WSMan namespace (the wsman:Items / + // wsman:EndOfSequence variants); the CXF backend accepts both, so the light backend must too. + private static final String WSMAN_NS = "http://schemas.dmtf.org/wbem/wsman/1/wsman.xsd"; + private final long timeoutMs; private final String url; + private final String rawUsername; private final AuthScheme auth; private final HttpTransport transport; @@ -77,11 +82,13 @@ final class WsmanClient implements AutoCloseable { final long timeoutMs, final SSLSocketFactory sslSocketFactory, final boolean verifyHostname, - final AuthScheme auth + final AuthScheme auth, + final String rawUsername ) { this.timeoutMs = timeoutMs; // A non-null socket factory selects HTTPS: TLS wraps the transport and the SOAP travels plaintext. this.url = (sslSocketFactory != null ? "https" : "http") + "://" + host + ":" + port + "/wsman"; + this.rawUsername = rawUsername; this.auth = auth; this.transport = new HttpTransport(host, port, toSocketTimeoutMillis(timeoutMs), sslSocketFactory, verifyHostname); } @@ -282,7 +289,11 @@ private Decoded request(final String soap) throws Exception { if (auth.advance()) { continue; } - throw new IllegalStateException("WSMan request failed: authentication rejected (HTTP 401)"); + // Same message format as the CXF backend's credential-rejection path — callers (and their + // operators) match on it. + throw new IllegalStateException( + String.format("Authentication error on %s with user name \"%s\"", url, rawUsername) + ); } // 200 = success, 500 = SOAP fault. Anything else is a protocol failure whose body is not a @@ -296,7 +307,7 @@ private Decoded request(final String soap) throws Exception { // --- XML helpers -------------------------------------------------------- - private static Document parse(final byte[] xml) throws Exception { + static Document parse(final byte[] xml) throws Exception { final DocumentBuilderFactory factory = DocumentBuilderFactory.newDefaultInstance(); factory.setNamespaceAware(true); // Harden against XXE: a malicious/compromised WinRM endpoint must not be able to make us @@ -332,9 +343,17 @@ private static String text(final Document doc, final String localName) { return nodes.getLength() > 0 ? nodes.item(0).getTextContent() : null; } - /** Whether the document contains the given WS-Enumeration control element (namespace-scoped). */ - private static boolean hasEnumerationElement(final Document doc, final String localName) { - return doc.getElementsByTagNameNS(WS_ENUMERATION_NS, localName).getLength() > 0; + /** + * Whether the document contains the given enumeration control element (namespace-scoped). + * WinRM emits these markers either in the WS-Enumeration namespace or in its own WSMan namespace + * (e.g. {@code wsen:EndOfSequence} vs {@code wsman:EndOfSequence}); accept both, like the CXF + * backend does. + */ + static boolean hasEnumerationElement(final Document doc, final String localName) { + return ( + doc.getElementsByTagNameNS(WS_ENUMERATION_NS, localName).getLength() > 0 || + doc.getElementsByTagNameNS(WSMAN_NS, localName).getLength() > 0 + ); } /** First text content of an element matched by both namespace and local name. */ @@ -343,8 +362,15 @@ private static String textNS(final Document doc, final String namespace, final S return nodes.getLength() > 0 ? nodes.item(0).getTextContent() : null; } - private static void collectItems(final Document doc, final List> rows) { - final NodeList items = doc.getElementsByTagNameNS("*", "Items"); + static void collectItems(final Document doc, final List> rows) { + // The Items wrapper comes in the WS-Enumeration namespace (EnumerateResponse) or the WSMan + // namespace (PullResponse) depending on the operation; accept both, like the CXF backend, and + // nothing else — a WMI property or class named "Items" must not be mistaken for the wrapper. + collectItems(doc.getElementsByTagNameNS(WS_ENUMERATION_NS, "Items"), rows); + collectItems(doc.getElementsByTagNameNS(WSMAN_NS, "Items"), rows); + } + + private static void collectItems(final NodeList items, final List> rows) { for (int i = 0; i < items.getLength(); i++) { final NodeList instances = items.item(i).getChildNodes(); for (int j = 0; j < instances.getLength(); j++) { @@ -406,15 +432,50 @@ private static String wsmanFaultCode(final Document doc) { return faults.getLength() > 0 ? ((Element) faults.item(0)).getAttribute("Code") : null; } + /** + * The detailed WSManFault Message text, or null. This is where WinRM puts the provider-level + * detail — notably the WMI error mnemonics (WBEM_E_INVALID_CLASS, WBEM_E_INVALID_NAMESPACE, + * WBEM_E_NOT_FOUND, ...) that callers match on to tell a bad query from a broken connection. + * {@code getTextContent()} also flattens any nested ProviderFault detail into the message. + */ + private static String wsmanFaultMessage(final Document doc) { + final NodeList faults = doc.getElementsByTagNameNS("*", "WSManFault"); + if (faults.getLength() == 0) { + return null; + } + final NodeList messages = ((Element) faults.item(0)).getElementsByTagNameNS("*", "Message"); + return messages.getLength() > 0 ? messages.item(0).getTextContent() : null; + } + + static String faultSummary(final int status, final Document doc) { + final String reason = trimToNull(text(doc, "Text")); + final String detail = trimToNull(wsmanFaultMessage(doc)); + final String code = wsmanFaultCode(doc); + final StringBuilder summary = new StringBuilder("HTTP ").append(status); + if (code != null && !code.isEmpty()) { + summary.append(" (WSManFault ").append(code).append(')'); + } + if (reason != null) { + summary.append(": ").append(reason); + } + // Append the detailed fault message when it adds anything beyond the Reason text: the WMI + // mnemonics it carries are part of the exception-message contract inherited from CXF. + if (detail != null && (reason == null || !reason.contains(detail))) { + summary.append(reason == null ? ": " : " - ").append(detail); + } + return summary.toString(); + } + private static String faultSummary(final Decoded resp) { - final String reason = text(resp.document, "Text"); - final String code = wsmanFaultCode(resp.document); - return ( - "HTTP " + - resp.status + - (code == null ? "" : " (WSManFault " + code + ")") + - (reason == null ? "" : ": " + reason.trim()) - ); + return faultSummary(resp.status, resp.document); + } + + private static String trimToNull(final String s) { + if (s == null) { + return null; + } + final String trimmed = s.trim(); + return trimmed.isEmpty() ? null : trimmed; } @Override diff --git a/src/test/java/org/metricshub/winrm/light/WsmanClientParityTest.java b/src/test/java/org/metricshub/winrm/light/WsmanClientParityTest.java new file mode 100644 index 0000000..4ccda7e --- /dev/null +++ b/src/test/java/org/metricshub/winrm/light/WsmanClientParityTest.java @@ -0,0 +1,229 @@ +package org.metricshub.winrm.light; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.w3c.dom.Document; + +/** + * Pins the light backend's WSMan response handling to the behavior of the legacy CXF backend + * (issue #106: feature parity and fault mapping): EndOfSequence/Items namespace variants, and + * fault summaries that carry the WSManFault detail message (with its WBEM_E_* mnemonics). + */ +class WsmanClientParityTest { + + private static final String WSEN = "http://schemas.xmlsoap.org/ws/2004/09/enumeration"; + private static final String WSMAN = "http://schemas.dmtf.org/wbem/wsman/1/wsman.xsd"; + + private static Document parse(final String xml) throws Exception { + return WsmanClient.parse(xml.getBytes(StandardCharsets.UTF_8)); + } + + // --- EndOfSequence variants --------------------------------------------- + + @Test + void endOfSequenceDetectedInWsEnumerationNamespace() throws Exception { + final Document doc = parse( + "" + + "" + + "" + + "" + ); + assertTrue(WsmanClient.hasEnumerationElement(doc, "EndOfSequence")); + } + + @Test + void endOfSequenceDetectedInWsmanNamespaceVariant() throws Exception { + // WinRM can emit the marker in its own WSMan namespace instead of WS-Enumeration; the CXF + // backend accepts both variants, so the light backend must too. + final Document doc = parse( + "" + + "" + + "" + + "" + ); + assertTrue(WsmanClient.hasEnumerationElement(doc, "EndOfSequence")); + } + + @Test + void wmiPropertyNamedEndOfSequenceIsNotMistakenForTheMarker() throws Exception { + // A WMI property that happens to be called EndOfSequence lives in the class's own namespace + // and must not terminate the enumeration. + final Document doc = parse( + "" + + "" + + "" + + "" + + "oops" + + "" + + "" + + "" + ); + assertFalse(WsmanClient.hasEnumerationElement(doc, "EndOfSequence")); + } + + // --- Items variants and row extraction ---------------------------------- + + @Test + void itemsCollectedFromBothNamespaceVariants() throws Exception { + // EnumerateResponse carries wsen:Items, PullResponse carries wsman:Items; both must yield rows. + final String instance = + "" + + "Spooler" + + "Running" + + ""; + final Document wsenItems = parse( + "" + + "" + + "" + + instance + + "" + + "" + ); + final Document wsmanItems = parse( + "" + + "" + + "" + + instance + + "" + + "" + ); + + for (final Document doc : List.of(wsenItems, wsmanItems)) { + final List> rows = new ArrayList<>(); + WsmanClient.collectItems(doc, rows); + assertEquals(1, rows.size()); + assertEquals("Spooler", rows.get(0).get("Name")); + assertEquals("Running", rows.get(0).get("State")); + } + } + + @Test + void wmiPropertyNamedItemsIsNotMistakenForTheWrapper() throws Exception { + // A structured WMI property called Items (in the class's namespace) must not be read as a + // second Items wrapper producing phantom rows. + final Document doc = parse( + "" + + "" + + "" + + "" + + "real-row" + + "phantom" + + "" + + "" + + "" + ); + final List> rows = new ArrayList<>(); + WsmanClient.collectItems(doc, rows); + assertEquals(1, rows.size()); + assertEquals("real-row", rows.get(0).get("Name")); + } + + // --- Fault summaries ------------------------------------------------------ + + @Test + void faultSummaryCarriesWsmanFaultDetailWithWbemMnemonic() throws Exception { + // MetricsHub tells an "acceptable" WMI error (bad class/namespace) from a broken connection by + // matching WBEM_E_* in the exception message — the detail Message text must surface. + final Document doc = parse( + "" + + "" + + "The WS-Management service cannot process the request." + + "" + + "" + + "The WMI service or the WMI provider returned an unknown error: WBEM_E_INVALID_CLASS" + + "" + + "" + + "" + ); + final String summary = WsmanClient.faultSummary(500, doc); + assertTrue(summary.contains("HTTP 500"), summary); + assertTrue(summary.contains("WSManFault 2150858778"), summary); + assertTrue(summary.contains("The WS-Management service cannot process the request."), summary); + assertTrue(summary.contains("WBEM_E_INVALID_CLASS"), summary); + } + + @Test + void faultSummaryDoesNotDuplicateDetailAlreadyInReason() throws Exception { + // WinRM usually repeats the same text in Reason and in the WSManFault Message; it must appear once. + final String message = "The WMI service or the WMI provider returned an unknown error: WBEM_E_INVALID_NAMESPACE"; + final Document doc = parse( + "" + + "" + + "" + + message + + " " + + "" + + "" + + "" + + message + + " " + + "" + + "" + + "" + ); + final String summary = WsmanClient.faultSummary(500, doc); + final int first = summary.indexOf("WBEM_E_INVALID_NAMESPACE"); + final int last = summary.lastIndexOf("WBEM_E_INVALID_NAMESPACE"); + assertTrue(first >= 0, summary); + assertEquals(first, last, "detail text must not be duplicated: " + summary); + } + + @Test + void faultSummaryWithoutWsmanFaultDetailStillReadable() throws Exception { + final Document doc = parse( + "" + + "" + + "Some transport-level failure" + + "" + ); + assertEquals("HTTP 500: Some transport-level failure", WsmanClient.faultSummary(500, doc)); + } + + // --- OperationTimeout format --------------------------------------------- + + @Test + void operationTimeoutFormattedLikeCxf() { + // The CXF backend formats the WSMan OperationTimeout with DecimalFormat("PT#.###S", ROOT): + // millisecond precision, '.' separator, no trailing zeros. Same bytes on the wire from light. + assertTrue(timeoutOf(30000L).contains("PT30S")); + assertTrue(timeoutOf(1500L).contains("PT1.5S")); + assertTrue(timeoutOf(1234L).contains("PT1.234S")); + assertTrue(timeoutOf(90061L).contains("PT90.061S")); + } + + private static String timeoutOf(final long timeoutMs) { + return Envelopes.enumerateWql( + "http://host:5985/wsman", + "root/cimv2", + "SELECT * FROM Win32_OperatingSystem", + timeoutMs + ); + } +} diff --git a/src/test/java/org/metricshub/winrm/service/WinRMExecutorFactoryTest.java b/src/test/java/org/metricshub/winrm/service/WinRMExecutorFactoryTest.java index b531fdc..2eb3ae7 100644 --- a/src/test/java/org/metricshub/winrm/service/WinRMExecutorFactoryTest.java +++ b/src/test/java/org/metricshub/winrm/service/WinRMExecutorFactoryTest.java @@ -185,9 +185,11 @@ void closedLightExecutorRejectsOperations() throws Exception { List.of(AuthenticationEnum.NTLM) ); executor.close(); - assertThrows( + final IllegalStateException e = assertThrows( IllegalStateException.class, () -> executor.executeWql("SELECT Name FROM Win32_OperatingSystem", 30000L) ); + // Same message as the CXF backend (part of the exception-surface parity, issue #106). + assertEquals("This instance has been closed and a new one must be created.", e.getMessage()); } } From b9b41c958427bc1e83e919c6c2ed934bfe71557c Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Thu, 23 Jul 2026 21:42:52 +0200 Subject: [PATCH 19/21] Add the recorded-exchange protocol test rig and differential harness (#107) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FakeWsmanServer (test scope): an in-process WSMan server speaking the real NTLM handshake — fixed server challenge, genuine NTLMv2 proof verification against a configured password, session-key recovery from the wire Type 3 — and real NTLM message encryption, by reusing the light client's crypto primitives through a mirrored WinRMSession (new package-private applyKeys(flags, exportedSessionKey, mirror) seam; the client path delegates to it unchanged). - WsmanProtocolTest: end-to-end CI tests for the full protocol path with no Windows host — NTLM-encrypted WQL Enumerate/Pull paging (both Items and EndOfSequence namespace variants), the command shell lifecycle (multibyte output split across Receive responses, operation-timeout Receive retry, shell-not-found tolerated on the terminate Signal), fault mapping incl. the WBEM_E_* detail, and the exact wrong-password message. Also pins the client's decrypted request bodies on the wire (OperationTimeout format, WINRS options, MaxElements, terminate code). - BackendDifferentialTest: documented one-command differential run (CXF vs light) against a real host, skipped unless winrm.diff.host is set. Live-verified against tc-win2016 (HTTPS/NTLM): WQL, command, and fault text all match. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 6 + README.md | 27 ++ .../metricshub/winrm/light/WinRMSession.java | 22 +- .../winrm/BackendDifferentialTest.java | 201 +++++++++ .../winrm/light/FakeWsmanServer.java | 414 ++++++++++++++++++ .../winrm/light/WsmanProtocolTest.java | 388 ++++++++++++++++ 6 files changed, 1052 insertions(+), 6 deletions(-) create mode 100644 src/test/java/org/metricshub/winrm/BackendDifferentialTest.java create mode 100644 src/test/java/org/metricshub/winrm/light/FakeWsmanServer.java create mode 100644 src/test/java/org/metricshub/winrm/light/WsmanProtocolTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c7e491..0a0318a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,12 @@ connectivity, do one of: ### Added +- In-process protocol tests (`WsmanProtocolTest` + `FakeWsmanServer`) covering the light backend's + full WSMan path — NTLM handshake, message encryption, multipart framing, Enumerate/Pull paging, + shell lifecycle, and fault mapping — with no Windows host required (they run in `mvn verify`). +- `BackendDifferentialTest`: a one-command differential run comparing the CXF and light backends + against a real host (see README), the go/no-go gate for removing CXF. + - Dependency-free "light" WinRM backend with no Apache CXF / JAX-WS / JAXB stack, immune by construction to JAXP `ServiceLoader` conflicts (it uses the JDK-default XML factories). Supports NTLM over HTTP and HTTPS, and Kerberos (SPNEGO, via the JDK GSS-API) over HTTPS. diff --git a/README.md b/README.md index 95c9f0a..44b3ed8 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,33 @@ This is a simple Maven project. Build with: mvn verify ``` +### Protocol tests + +The build includes in-process protocol tests (`WsmanProtocolTest`) that exercise the light +backend's full WSMan path — NTLM handshake, message encryption, `multipart/encrypted` framing, +WQL Enumerate/Pull paging, the command shell lifecycle, and fault mapping — against a fake WSMan +server, so no Windows host is needed in CI. + +### Differential run against a real host + +`BackendDifferentialTest` runs the same operations through the legacy CXF backend and the light +backend against a **real** WinRM host and asserts the results match. It is skipped unless +`winrm.diff.host` is set: + +```bash +mvn test -Dtest=BackendDifferentialTest -Dmaven.javadoc.skip=true \ + -Dwinrm.diff.host=myhost.example.com \ + -Dwinrm.diff.protocol=https \ + -Dwinrm.diff.username='MYDOMAIN\myuser' \ + -Dwinrm.diff.password-file=/path/to/password.txt +``` + +Optional properties: `winrm.diff.port`, `winrm.diff.password` (inline), `winrm.diff.namespace`, +`winrm.diff.wql`, `winrm.diff.command`, `winrm.diff.badcreds=true` (also compare wrong-password +error messages; off by default because it triggers failed logons), and +`winrm.diff.tls.insecure=false` (validate TLS on the light backend instead of matching the CXF +backend's trust-all behavior). + ## Release instructions The artifact is deployed to Sonatype's [Maven Central](https://central.sonatype.com/). diff --git a/src/main/java/org/metricshub/winrm/light/WinRMSession.java b/src/main/java/org/metricshub/winrm/light/WinRMSession.java index f7b2748..6f61260 100644 --- a/src/main/java/org/metricshub/winrm/light/WinRMSession.java +++ b/src/main/java/org/metricshub/winrm/light/WinRMSession.java @@ -141,13 +141,23 @@ void markAuthenticated() { /** Derive signing/sealing keys from the Type 3 exported session key and open both RC4 ciphers. */ void applyKeys(final Type3Message type3) { - final byte[] exportedSessionKey = type3.getExportedSessionKey(); - negotiateFlags = type3.getType2Flags(); + applyKeys(type3.getType2Flags(), type3.getExportedSessionKey(), false); + } + + /** + * Derive the directional signing/sealing keys from the exported session key and open both RC4 + * ciphers. The client passes {@code mirror=false}: it signs and seals outgoing messages with the + * client-to-server keys and verifies/unseals incoming ones with the server-to-client keys. + * {@code mirror=true} swaps the directions — that is the server side of the same handshake, used + * by the in-process protocol-test server to speak real NTLM message encryption to the client. + */ + void applyKeys(final long flags, final byte[] exportedSessionKey, final boolean mirror) { + negotiateFlags = flags; - clientSigningKey = signKey(exportedSessionKey, CLIENT_SIGNING); - serverSigningKey = signKey(exportedSessionKey, SERVER_SIGNING); - encryptor = EncryptionUtils.arc4(sealKey(exportedSessionKey, CLIENT_SEALING)); - decryptor = EncryptionUtils.arc4(sealKey(exportedSessionKey, SERVER_SEALING)); + clientSigningKey = signKey(exportedSessionKey, mirror ? SERVER_SIGNING : CLIENT_SIGNING); + serverSigningKey = signKey(exportedSessionKey, mirror ? CLIENT_SIGNING : SERVER_SIGNING); + encryptor = EncryptionUtils.arc4(sealKey(exportedSessionKey, mirror ? SERVER_SEALING : CLIENT_SEALING)); + decryptor = EncryptionUtils.arc4(sealKey(exportedSessionKey, mirror ? CLIENT_SEALING : SERVER_SEALING)); authenticated = true; } diff --git a/src/test/java/org/metricshub/winrm/BackendDifferentialTest.java b/src/test/java/org/metricshub/winrm/BackendDifferentialTest.java new file mode 100644 index 0000000..dd6feb6 --- /dev/null +++ b/src/test/java/org/metricshub/winrm/BackendDifferentialTest.java @@ -0,0 +1,201 @@ +package org.metricshub.winrm; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.metricshub.winrm.exceptions.WinRMException; +import org.metricshub.winrm.service.WinRMExecutorFactory; +import org.metricshub.winrm.service.client.auth.AuthenticationEnum; +import org.metricshub.winrm.wql.WinRMWqlExecutor; + +/** + * Differential harness (issue #107): runs the same operations through the legacy CXF backend and + * the light backend against a REAL WinRM host and asserts the results match — the go/no-go gate + * for removing CXF. Disabled unless {@code winrm.diff.host} is set, so it never runs in CI. + * + *

One-command run against a lab host: + * + *

+ * mvn test -Dtest=BackendDifferentialTest -Dmaven.javadoc.skip=true \
+ *   -Dwinrm.diff.host=myhost.example.com \
+ *   -Dwinrm.diff.protocol=https \
+ *   -Dwinrm.diff.username='MYDOMAIN\myuser' \
+ *   -Dwinrm.diff.password-file=/path/to/password.txt
+ * 
+ * + *

Optional properties: {@code winrm.diff.port} (defaults to 5985/5986 by protocol), + * {@code winrm.diff.password} (inline, instead of the file), {@code winrm.diff.namespace}, + * {@code winrm.diff.wql}, {@code winrm.diff.command}, {@code winrm.diff.badcreds=true} to also + * exercise the wrong-password parity check (off by default — it triggers failed logons on the + * host), and {@code winrm.diff.tls.insecure=false} to validate TLS on the light backend instead + * of matching the CXF backend's trust-all behavior. + */ +@EnabledIfSystemProperty(named = "winrm.diff.host", matches = ".+") +class BackendDifferentialTest { + + private static String host; + private static WinRMHttpProtocolEnum protocol; + private static Integer port; + private static String username; + private static char[] password; + private static String namespace; + private static String wql; + private static String command; + + @BeforeAll + static void readConfiguration() throws Exception { + host = System.getProperty("winrm.diff.host"); + protocol = + "https".equalsIgnoreCase(System.getProperty("winrm.diff.protocol", "http")) + ? WinRMHttpProtocolEnum.HTTPS + : WinRMHttpProtocolEnum.HTTP; + final String portProperty = System.getProperty("winrm.diff.port"); + port = portProperty == null ? null : Integer.valueOf(portProperty); + username = System.getProperty("winrm.diff.username"); + namespace = System.getProperty("winrm.diff.namespace"); + wql = System.getProperty("winrm.diff.wql", "SELECT Caption FROM Win32_OperatingSystem"); + command = System.getProperty("winrm.diff.command", "echo winrm-diff"); + + final String inline = System.getProperty("winrm.diff.password"); + if (inline != null) { + password = inline.toCharArray(); + } else { + final String file = System.getProperty("winrm.diff.password-file"); + if (file == null) { + throw new IllegalArgumentException("Set winrm.diff.password or winrm.diff.password-file"); + } + password = new String(Files.readAllBytes(Paths.get(file)), StandardCharsets.UTF_8).trim().toCharArray(); + } + + // The CXF backend trusts every certificate; give the light backend the same behavior by + // default so the differential compares the protocol, not the trust policy. + if (!"false".equalsIgnoreCase(System.getProperty("winrm.diff.tls.insecure"))) { + System.setProperty("org.metricshub.winrm.tls.insecure", "true"); + } + } + + @AfterEach + void clearBackend() { + System.clearProperty(WinRMExecutorFactory.BACKEND_PROPERTY); + } + + private static T withBackend(final String backend, final Operation operation) throws Exception { + System.setProperty(WinRMExecutorFactory.BACKEND_PROPERTY, backend); + try { + return operation.run(); + } finally { + System.clearProperty(WinRMExecutorFactory.BACKEND_PROPERTY); + } + } + + private interface Operation { + T run() throws Exception; + } + + private static WinRMWqlExecutor wql(final String query, final char[] pwd) throws Exception { + return WinRMWqlExecutor.executeWql( + protocol, + host, + port, + username, + pwd, + namespace, + query, + 30_000L, + null, + List.of(AuthenticationEnum.NTLM) + ); + } + + @Test + void wqlResultsMatch() throws Exception { + final WinRMWqlExecutor cxf = withBackend("cxf", () -> wql(wql, password)); + final WinRMWqlExecutor light = withBackend("light", () -> wql(wql, password)); + + assertEquals(cxf.getHeaders(), light.getHeaders()); + assertEquals(cxf.getRows(), light.getRows()); + } + + @Test + void commandResultsMatch() throws Exception { + final WindowsRemoteCommandResult cxf = withBackend( + "cxf", + () -> + org.metricshub.winrm.command.WinRMCommandExecutor.execute( + command, + protocol, + host, + port, + username, + password, + null, + 30_000L, + null, + null, + List.of(AuthenticationEnum.NTLM) + ) + ); + final WindowsRemoteCommandResult light = withBackend( + "light", + () -> + org.metricshub.winrm.command.WinRMCommandExecutor.execute( + command, + protocol, + host, + port, + username, + password, + null, + 30_000L, + null, + null, + List.of(AuthenticationEnum.NTLM) + ) + ); + + assertEquals(cxf.getStatusCode(), light.getStatusCode()); + assertEquals(cxf.getStdout(), light.getStdout()); + assertEquals(cxf.getStderr(), light.getStderr()); + } + + @Test + void serverFaultTextMatches() throws Exception { + // Both backends must surface the same server fault text for a bad class (the light backend + // adds an informative prefix and the WSManFault detail on top — a contains()-compatible superset). + final String badClass = "SELECT Name FROM No_Such_Class_Diff_42"; + final WinRMException cxf = assertThrows( + WinRMException.class, + () -> withBackend("cxf", () -> wql(badClass, password)) + ); + final WinRMException light = assertThrows( + WinRMException.class, + () -> withBackend("light", () -> wql(badClass, password)) + ); + assertTrue( + light.getMessage().contains(cxf.getMessage().trim()), + () -> + "light message does not contain the CXF fault text\nCXF: " + + cxf.getMessage() + + "\nlight: " + + light.getMessage() + ); + } + + @Test + @EnabledIfSystemProperty(named = "winrm.diff.badcreds", matches = "true") + void authenticationErrorMessagesAreIdentical() throws Exception { + final char[] wrong = "definitely-wrong-password".toCharArray(); + final WinRMException cxf = assertThrows(WinRMException.class, () -> withBackend("cxf", () -> wql(wql, wrong))); + final WinRMException light = assertThrows(WinRMException.class, () -> withBackend("light", () -> wql(wql, wrong))); + assertEquals(cxf.getMessage(), light.getMessage()); + } +} diff --git a/src/test/java/org/metricshub/winrm/light/FakeWsmanServer.java b/src/test/java/org/metricshub/winrm/light/FakeWsmanServer.java new file mode 100644 index 0000000..bc4d62d --- /dev/null +++ b/src/test/java/org/metricshub/winrm/light/FakeWsmanServer.java @@ -0,0 +1,414 @@ +package org.metricshub.winrm.light; + +import java.io.BufferedInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.Deque; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * In-process WSMan server for protocol tests (issue #107): speaks the real NTLM handshake + * (fixed server challenge, NTLMv2 verification against a configured password, session-key + * recovery from the wire Type 3) and real NTLM message encryption — by reusing the light + * client's own crypto primitives with a mirrored {@link WinRMSession} — then serves scripted + * SOAP response bodies. This exercises the client's full protocol path (transport, handshake + * orchestration, sealing, multipart framing, decryption, XML handling) without a Windows host. + * + *

The NTLMv2 verification is real: a client that derives a wrong hash (e.g. a domain-case + * regression) fails authentication here just like against a real host. + */ +final class FakeWsmanServer implements AutoCloseable { + + /** One scripted HTTP response: status code and the plaintext SOAP body to encrypt and serve. */ + static final class Scripted { + + final int status; + final String soapBody; + + Scripted(final int status, final String soapBody) { + this.status = status; + this.soapBody = soapBody; + } + } + + private static final Charset UTF16LE = StandardCharsets.UTF_16LE; + + // Fixed 8-byte server challenge — "recorded exchange" determinism. + private static final byte[] SERVER_CHALLENGE = { + 0x01, + 0x23, + 0x45, + 0x67, + (byte) 0x89, + (byte) 0xab, + (byte) 0xcd, + (byte) 0xef + }; + + // Type 2 flags: UNICODE | SIGN | SEAL | EXTENDED_SESSIONSECURITY | TARGETINFO | 128 | KEY_EXCH — + // what a real WinRM host negotiates for encrypted HTTP, and what drives the client down the + // NTLMv2 + explicit-key-exchange + extended-session-security path. + private static final int TYPE2_FLAGS = + 0x00000001 | 0x00000010 | 0x00000020 | 0x00080000 | 0x00800000 | 0x20000000 | 0x40000000; + + private final String expectedDomain; + private final String expectedUser; + private final String expectedPassword; + + private final ServerSocket serverSocket; + private final Thread acceptThread; + private final List connectionThreads = new CopyOnWriteArrayList<>(); + + private final Deque script = new ArrayDeque<>(); + private final List decryptedRequests = new CopyOnWriteArrayList<>(); + private volatile boolean closed; + + FakeWsmanServer(final String domain, final String user, final String password) throws IOException { + this.expectedDomain = domain.toUpperCase(Locale.ROOT); + this.expectedUser = user; + this.expectedPassword = password; + this.serverSocket = new ServerSocket(0); + this.acceptThread = new Thread(this::acceptLoop, "fake-wsman-accept"); + this.acceptThread.setDaemon(true); + this.acceptThread.start(); + } + + int port() { + return serverSocket.getLocalPort(); + } + + /** Queue the next scripted response (served in order, one per decrypted request). */ + FakeWsmanServer enqueue(final int status, final String soapBody) { + synchronized (script) { + script.addLast(new Scripted(status, soapBody)); + } + return this; + } + + /** The plaintext SOAP request bodies received so far, in order (after decryption). */ + List decryptedRequests() { + return new ArrayList<>(decryptedRequests); + } + + @Override + public void close() { + closed = true; + try { + serverSocket.close(); + } catch (final IOException ignore) { + // shutting down + } + for (final Thread t : connectionThreads) { + t.interrupt(); + } + } + + // --- connection handling -------------------------------------------------- + + private void acceptLoop() { + while (!closed) { + try { + final Socket socket = serverSocket.accept(); + final Thread t = new Thread(() -> handleConnection(socket), "fake-wsman-conn"); + t.setDaemon(true); + connectionThreads.add(t); + t.start(); + } catch (final IOException e) { + return; // server socket closed + } + } + } + + private void handleConnection(final Socket socket) { + // NTLM state is bound to the TCP connection, exactly like a real WinRM host. + WinRMSession serverSession = null; + try (socket) { + socket.setTcpNoDelay(true); + final BufferedInputStream in = new BufferedInputStream(socket.getInputStream()); + final OutputStream out = socket.getOutputStream(); + while (!closed) { + final HttpRequest request = HttpRequest.read(in); + if (request == null) { + return; // client closed the connection + } + final String authorization = request.header("authorization"); + if (serverSession == null || authorization != null) { + final byte[] token = negotiateToken(authorization); + if (token == null) { + respond(out, 401, "WWW-Authenticate: Negotiate", null, null); + continue; + } + final int messageType = NTLMMessage.readULong(token, 8); + if (messageType == 1) { + respond( + out, + 401, + "WWW-Authenticate: Negotiate " + Base64.getEncoder().encodeToString(buildType2()), + null, + null + ); + continue; + } + if (messageType != 3) { + respond(out, 401, "WWW-Authenticate: Negotiate", null, null); + continue; + } + serverSession = authenticate(token); + if (serverSession == null) { + // Bad credentials: reject like a real host — 401 on the request carrying the Type 3. + respond(out, 401, "WWW-Authenticate: Negotiate", null, null); + continue; + } + // fall through: the request that carried the Type 3 also carries the first sealed body + } + serveScripted(out, serverSession, request.body); + } + } catch (final IOException | RuntimeException e) { + // connection torn down (client close, test shutdown) — nothing to do + } + } + + private void serveScripted(final OutputStream out, final WinRMSession session, final byte[] sealedBody) + throws IOException { + final byte[] plaintext = NtlmCrypto.decrypt(session, sealedBody); + decryptedRequests.add(new String(plaintext, StandardCharsets.UTF_8)); + + Scripted next; + synchronized (script) { + next = script.pollFirst(); + } + if (next == null) { + // Loud, decryptable failure so an over-consuming test fails on an assertion, not a hang. + next = + new Scripted( + 500, + "" + + "FakeWsmanServer: no scripted response left" + + "" + ); + } + final byte[] sealed = NtlmCrypto.encryptAndSign(session, next.soapBody.getBytes(StandardCharsets.UTF_8)); + respond(out, next.status, null, NtlmCrypto.ENCRYPTED_CONTENT_TYPE, sealed); + } + + // --- NTLM server side ------------------------------------------------------- + + private static byte[] negotiateToken(final String authorization) { + if (authorization == null || !authorization.regionMatches(true, 0, "Negotiate ", 0, 10)) { + return null; + } + return Base64.getDecoder().decode(authorization.substring(10).trim()); + } + + /** Fixed Type 2 challenge message: target "FAKE", the fixed server challenge, and target info. */ + private static byte[] buildType2() { + final byte[] targetName = "FAKE".getBytes(UTF16LE); + final byte[] targetInfo = concat( + avPair(2, "FAKE"), // NetBIOS domain + avPair(1, "FAKESRV"), // NetBIOS computer + new byte[] { 0, 0, 0, 0 } // terminator + ); + final ByteArrayOutputStream msg = new ByteArrayOutputStream(); + writeBytes(msg, "NTLMSSP\0".getBytes(StandardCharsets.US_ASCII)); + writeULong(msg, 2); + final int targetNameOffset = 56; + writeSecurityBuffer(msg, targetName.length, targetNameOffset); + writeULong(msg, TYPE2_FLAGS); + writeBytes(msg, SERVER_CHALLENGE); + writeBytes(msg, new byte[8]); // context + writeSecurityBuffer(msg, targetInfo.length, targetNameOffset + targetName.length); + writeBytes(msg, new byte[8]); // version (unparsed by the client) + writeBytes(msg, targetName); + writeBytes(msg, targetInfo); + return msg.toByteArray(); + } + + /** + * Verify the Type 3 NTLMv2 response against the configured credentials and, on success, recover + * the exported session key from the wire and install the mirrored (server-side) session keys. + * Returns null when the proof does not match — i.e. the client used a wrong password/hash. + */ + private WinRMSession authenticate(final byte[] type3) { + final byte[] ntResponse = readSecurityBuffer(type3, 20); + final byte[] encryptedSessionKey = readSecurityBuffer(type3, 52); + final int flags = NTLMMessage.readULong(type3, 60); + final String domain = new String(readSecurityBuffer(type3, 28), UTF16LE); + final String user = new String(readSecurityBuffer(type3, 36), UTF16LE); + + if (!expectedDomain.equals(domain) || !expectedUser.equals(user) || ntResponse.length < 16) { + return null; + } + + // NTOWFv2 = HMAC-MD5(MD4(UTF16LE(password)), UTF16LE(UPPER(user) + domain)) — same derivation + // as the client's CipherGen, computed from the SERVER's copy of the password. + final MD4 md4 = new MD4(); + md4.update(expectedPassword.getBytes(UTF16LE)); + final byte[] ntlmHash = md4.getOutput(); + final byte[] ntowfV2 = EncryptionUtils.hmacMd5( + ntlmHash, + concat(expectedUser.toUpperCase(Locale.ROOT).getBytes(UTF16LE), expectedDomain.getBytes(UTF16LE)) + ); + + // NTProofStr (first 16 bytes) must equal HMAC-MD5(NTOWFv2, serverChallenge || blob). + final byte[] ntProofStr = Arrays.copyOfRange(ntResponse, 0, 16); + final byte[] blob = Arrays.copyOfRange(ntResponse, 16, ntResponse.length); + final byte[] expectedProof = EncryptionUtils.hmacMd5(ntowfV2, concat(SERVER_CHALLENGE, blob)); + if (!Arrays.equals(ntProofStr, expectedProof)) { + return null; + } + + // Session key: userSessionKey = HMAC-MD5(NTOWFv2, NTProofStr); with KEY_EXCH the wire field is + // RC4(exportedSessionKey, userSessionKey) — RC4 is symmetric, so decrypt with the same call. + final byte[] userSessionKey = EncryptionUtils.hmacMd5(ntowfV2, ntProofStr); + final byte[] exportedSessionKey = EncryptionUtils.calculateRC4(encryptedSessionKey, userSessionKey); + + final WinRMSession session = new WinRMSession(expectedDomain, null, expectedUser, expectedPassword); + session.applyKeys(flags, exportedSessionKey, true); + return session; + } + + // --- byte-level helpers ----------------------------------------------------- + + private static byte[] readSecurityBuffer(final byte[] src, final int position) { + final int length = (src[position] & 0xff) | ((src[position + 1] & 0xff) << 8); + final int offset = NTLMMessage.readULong(src, position + 4); + return Arrays.copyOfRange(src, offset, offset + length); + } + + private static byte[] avPair(final int id, final String value) { + final byte[] bytes = value.getBytes(UTF16LE); + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(id & 0xff); + out.write((id >> 8) & 0xff); + out.write(bytes.length & 0xff); + out.write((bytes.length >> 8) & 0xff); + writeBytes(out, bytes); + return out.toByteArray(); + } + + private static byte[] concat(final byte[]... arrays) { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + for (final byte[] a : arrays) { + writeBytes(out, a); + } + return out.toByteArray(); + } + + private static void writeBytes(final ByteArrayOutputStream out, final byte[] bytes) { + out.write(bytes, 0, bytes.length); + } + + private static void writeULong(final ByteArrayOutputStream out, final int value) { + out.write(value & 0xff); + out.write((value >> 8) & 0xff); + out.write((value >> 16) & 0xff); + out.write((value >> 24) & 0xff); + } + + private static void writeSecurityBuffer(final ByteArrayOutputStream out, final int length, final int offset) { + out.write(length & 0xff); + out.write((length >> 8) & 0xff); + out.write(length & 0xff); + out.write((length >> 8) & 0xff); + writeULong(out, offset); + } + + // --- minimal HTTP ----------------------------------------------------------- + + private static void respond( + final OutputStream out, + final int status, + final String extraHeader, + final String contentType, + final byte[] body + ) throws IOException { + final byte[] payload = body == null ? new byte[0] : body; + final StringBuilder head = new StringBuilder(); + head.append("HTTP/1.1 ").append(status).append(' ').append(status == 200 ? "OK" : "Error").append("\r\n"); + head.append("Server: FakeWsmanServer\r\n"); + if (extraHeader != null) { + head.append(extraHeader).append("\r\n"); + } + if (contentType != null) { + head.append("Content-Type: ").append(contentType).append("\r\n"); + } + head.append("Content-Length: ").append(payload.length).append("\r\n"); + head.append("\r\n"); + out.write(head.toString().getBytes(StandardCharsets.ISO_8859_1)); + out.write(payload); + out.flush(); + } + + /** One parsed HTTP request: headers (lower-cased names) and the raw body. */ + private static final class HttpRequest { + + final Map headers = new TreeMap<>(); + byte[] body = new byte[0]; + + String header(final String name) { + return headers.get(name.toLowerCase(Locale.ROOT)); + } + + static HttpRequest read(final InputStream in) throws IOException { + final String requestLine = readLine(in); + if (requestLine == null || requestLine.isEmpty()) { + return null; + } + final HttpRequest request = new HttpRequest(); + String line; + while ((line = readLine(in)) != null && !line.isEmpty()) { + final int colon = line.indexOf(':'); + if (colon > 0) { + request.headers.put( + line.substring(0, colon).trim().toLowerCase(Locale.ROOT), + line.substring(colon + 1).trim() + ); + } + } + final String contentLength = request.header("content-length"); + if (contentLength != null) { + final int length = Integer.parseInt(contentLength); + final byte[] body = new byte[length]; + int read = 0; + while (read < length) { + final int n = in.read(body, read, length - read); + if (n < 0) { + throw new IOException("EOF in request body"); + } + read += n; + } + request.body = body; + } + return request; + } + + private static String readLine(final InputStream in) throws IOException { + final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + int b; + int prev = -1; + while ((b = in.read()) != -1) { + if (prev == '\r' && b == '\n') { + final byte[] raw = buffer.toByteArray(); + return new String(raw, 0, raw.length - 1, StandardCharsets.ISO_8859_1); + } + buffer.write(b); + prev = b; + } + return buffer.size() == 0 ? null : buffer.toString("ISO-8859-1"); + } + } +} diff --git a/src/test/java/org/metricshub/winrm/light/WsmanProtocolTest.java b/src/test/java/org/metricshub/winrm/light/WsmanProtocolTest.java new file mode 100644 index 0000000..3b236cb --- /dev/null +++ b/src/test/java/org/metricshub/winrm/light/WsmanProtocolTest.java @@ -0,0 +1,388 @@ +package org.metricshub.winrm.light; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.metricshub.winrm.WinRMHttpProtocolEnum; +import org.metricshub.winrm.WindowsRemoteCommandResult; +import org.metricshub.winrm.exceptions.WinRMException; +import org.metricshub.winrm.service.WinRMEndpoint; +import org.metricshub.winrm.service.client.auth.AuthenticationEnum; + +/** + * End-to-end protocol tests against {@link FakeWsmanServer} (issue #107): the full NTLM + * handshake, message encryption, multipart framing, WQL Enumerate/Pull paging, the command + * shell lifecycle, and fault mapping — all in-process, no Windows host required. + */ +class WsmanProtocolTest { + + private static final String DOMAIN = "FAKE"; + private static final String USER = "user"; + private static final String PASSWORD = "s3cret-Passw0rd"; + private static final long TIMEOUT = 30_000L; + + private static final String SOAP_NS = "http://www.w3.org/2003/05/soap-envelope"; + private static final String WSEN = "http://schemas.xmlsoap.org/ws/2004/09/enumeration"; + private static final String WSMAN = "http://schemas.dmtf.org/wbem/wsman/1/wsman.xsd"; + private static final String RSP = "http://schemas.microsoft.com/wbem/wsman/1/windows/shell"; + private static final String FAULT_NS = "http://schemas.microsoft.com/wbem/wsman/1/wsmanfault"; + + private FakeWsmanServer server; + + @BeforeEach + void startServer() throws Exception { + server = new FakeWsmanServer(DOMAIN, USER, PASSWORD); + } + + @AfterEach + void stopServer() { + server.close(); + } + + private LightWinRMService client(final String password) throws Exception { + final WinRMEndpoint endpoint = new WinRMEndpoint( + WinRMHttpProtocolEnum.HTTP, + "127.0.0.1", + server.port(), + DOMAIN + "\\" + USER, + password.toCharArray(), + null + ); + return LightWinRMService.createInstance(endpoint, TIMEOUT, null, List.of(AuthenticationEnum.NTLM)); + } + + // --- WQL paging ----------------------------------------------------------- + + @Test + void wqlPagesAcrossEnumerateAndPullsOverEncryptedNtlm() throws Exception { + // Optimized EnumerateResponse (wsman:Items) -> Pull (wsen:Items) -> final Pull with the + // wsman:EndOfSequence variant: covers both Items and both EndOfSequence namespaces end to end. + server + .enqueue( + 200, + envelope( + "" + + "uuid:CTX-1" + + "" + + service("Spooler", "Running") + + "" + + "" + ) + ) + .enqueue( + 200, + envelope( + "" + + "uuid:CTX-2" + + "" + + service("WinRM", "Running") + + service("Wecsvc", "Stopped") + + "" + + "" + ) + ) + .enqueue( + 200, + envelope( + "" + + "" + + "" + ) + ); + + try (LightWinRMService service = client(PASSWORD)) { + final List> rows = service.executeWql("SELECT Name,State FROM Win32_Service", TIMEOUT); + + assertEquals(3, rows.size()); + assertEquals("Spooler", rows.get(0).get("Name")); + assertEquals("Running", rows.get(0).get("State")); + assertEquals("WinRM", rows.get(1).get("Name")); + assertEquals("Wecsvc", rows.get(2).get("Name")); + assertEquals("Stopped", rows.get(2).get("State")); + } + + // The decrypted request bodies pin what the client actually sends on the wire. + final List requests = server.decryptedRequests(); + assertEquals(3, requests.size(), () -> String.join("\n---\n", requests)); + final String enumerate = requests.get(0); + assertTrue(enumerate.contains("PT30S"), enumerate); + assertTrue(enumerate.contains(""), enumerate); + assertTrue(enumerate.contains("32000"), enumerate); + assertTrue(enumerate.contains("http://schemas.microsoft.com/wbem/wsman/1/wmi/ROOT/CIMV2/*"), enumerate); + assertTrue(enumerate.contains("SELECT Name,State FROM Win32_Service"), enumerate); + assertTrue(requests.get(1).contains("uuid:CTX-1"), requests.get(1)); + assertTrue(requests.get(2).contains("uuid:CTX-2"), requests.get(2)); + } + + // --- Command shell lifecycle ------------------------------------------------ + + @Test + void commandLifecycleReassemblesMultibyteOutputSplitAcrossReceives() throws Exception { + // "héllo!" in UTF-8, split in the middle of the 2-byte 'é' across two Receive responses: the + // client must accumulate raw bytes and decode once, or the boundary bytes become U+FFFD. + final byte[] utf8 = "héllo!".getBytes(StandardCharsets.UTF_8); + final byte[] chunk1 = java.util.Arrays.copyOfRange(utf8, 0, 2); // 'h' + first byte of 'é' + final byte[] chunk2 = java.util.Arrays.copyOfRange(utf8, 2, utf8.length); + + server + .enqueue(200, envelope(resourceCreated("SHELL-1"))) + .enqueue(200, envelope(commandResponse("CMD-1"))) + .enqueue(200, envelope(receiveResponse("CMD-1", stream("stdout", chunk1), null))) + .enqueue( + 200, + envelope( + receiveResponse( + "CMD-1", + stream("stdout", chunk2) + stream("stderr", "warn!".getBytes(StandardCharsets.UTF_8)), + done("CMD-1", 7) + ) + ) + ) + .enqueue(200, envelope("")); + + try (LightWinRMService service = client(PASSWORD)) { + final WindowsRemoteCommandResult result = service.executeCommand( + "echo héllo!", + null, + StandardCharsets.UTF_8, + TIMEOUT + ); + + assertEquals("héllo!", result.getStdout()); + assertEquals("warn!", result.getStderr()); + assertEquals(7, result.getStatusCode()); + } + + final List requests = server.decryptedRequests(); + // Create + Command + 2x Receive + Signal (+ the close()-time shell Delete). + assertTrue(requests.size() >= 5, () -> String.join("\n---\n", requests)); + final String create = requests.get(0); + assertTrue(create.contains("TRUE"), create); + assertTrue(create.contains("437"), create); + assertTrue(create.contains("stdout stderr"), create); + final String command = requests.get(1); + assertTrue(command.contains("echo héllo!"), command); + assertTrue(command.contains("Selector Name=\"ShellId\">SHELL-1<"), command); + final String receive = requests.get(2); + assertTrue(receive.contains("CommandId=\"CMD-1\">stdout stderr"), receive); + final String signal = requests.get(4); + assertTrue(signal.contains(RSP + "/signal/terminate"), signal); + } + + @Test + void receiveRetriesOnOperationTimeoutFault() throws Exception { + // No output before OperationTimeout: the server faults with 2150858793 and the client must + // immediately re-issue the Receive rather than fail the command. + server + .enqueue(200, envelope(resourceCreated("SHELL-1"))) + .enqueue(200, envelope(commandResponse("CMD-1"))) + .enqueue( + 500, + fault( + "2150858793", + "The WS-Management service cannot complete the operation within the time specified in OperationTimeout." + ) + ) + .enqueue( + 200, + envelope(receiveResponse("CMD-1", stream("stdout", "late".getBytes(StandardCharsets.UTF_8)), done("CMD-1", 0))) + ) + .enqueue(200, envelope("")); + + try (LightWinRMService service = client(PASSWORD)) { + final WindowsRemoteCommandResult result = service.executeCommand("slow", null, StandardCharsets.UTF_8, TIMEOUT); + assertEquals("late", result.getStdout()); + assertEquals(0, result.getStatusCode()); + } + + // Two Receive requests must have been sent: the faulted one and the retry. + final long receives = server.decryptedRequests().stream().filter(r -> r.contains("")).count(); + assertEquals(2, receives); + } + + @Test + void terminateSignalToleratesShellNotFoundFault() throws Exception { + // The command finished and the shell may already be gone: fault 2150858843 on the terminate + // Signal must not fail the (successful) command. + server + .enqueue(200, envelope(resourceCreated("SHELL-1"))) + .enqueue(200, envelope(commandResponse("CMD-1"))) + .enqueue( + 200, + envelope(receiveResponse("CMD-1", stream("stdout", "ok".getBytes(StandardCharsets.UTF_8)), done("CMD-1", 0))) + ) + .enqueue( + 500, + fault("2150858843", "The WS-Management service cannot process the request because the resource offline.") + ); + + try (LightWinRMService service = client(PASSWORD)) { + final WindowsRemoteCommandResult result = service.executeCommand("whoami", null, StandardCharsets.UTF_8, TIMEOUT); + assertEquals("ok", result.getStdout()); + assertEquals(0, result.getStatusCode()); + } + } + + // --- Fault mapping ------------------------------------------------------------ + + @Test + void wqlFaultSurfacesCodeReasonAndWbemDetail() throws Exception { + server.enqueue( + 500, + fault( + "2150858778", + "The WS-Management service cannot process the request.", + "The WMI service or the WMI provider returned an unknown error: WBEM_E_INVALID_CLASS" + ) + ); + + try (LightWinRMService service = client(PASSWORD)) { + final WinRMException e = assertThrows( + WinRMException.class, + () -> service.executeWql("SELECT Name FROM No_Such_Class", TIMEOUT) + ); + final String message = e.getMessage(); + assertTrue(message.contains("Enumerate failed"), message); + assertTrue(message.contains("WSManFault 2150858778"), message); + assertTrue(message.contains("The WS-Management service cannot process the request."), message); + // The provider-level detail carries the WBEM_E_* mnemonics MetricsHub matches on. + assertTrue(message.contains("WBEM_E_INVALID_CLASS"), message); + } + } + + @Test + void wrongPasswordSurfacesTheCxfAuthenticationErrorMessage() throws Exception { + try (LightWinRMService service = client("wrong-password")) { + final WinRMException e = assertThrows( + WinRMException.class, + () -> service.executeWql("SELECT Name FROM Win32_Service", TIMEOUT) + ); + // Exact CXF-parity message (issue #106): operators and callers match on this format. + assertEquals( + "Authentication error on http://127.0.0.1:" + server.port() + "/wsman with user name \"FAKE\\user\"", + e.getMessage() + ); + } + } + + // --- response body builders ----------------------------------------------------- + + private static String envelope(final String body) { + return "" + body + ""; + } + + private static String service(final String name, final String state) { + return ( + "" + + "" + + name + + "" + + state + + "" + ); + } + + private static String resourceCreated(final String shellId) { + return ( + "" + + "http://127.0.0.1/wsman" + + "" + + "" + + RSP + + "/cmd" + + "" + + shellId + + "" + + "" + ); + } + + private static String commandResponse(final String commandId) { + return ( + "" + + commandId + + "" + ); + } + + private static String receiveResponse(final String commandId, final String streams, final String commandState) { + return ( + "" + + streams + + (commandState == null ? "" : commandState) + + "" + ); + } + + private static String stream(final String name, final byte[] content) { + return ( + "" + + Base64.getEncoder().encodeToString(content) + + "" + ); + } + + private static String done(final String commandId, final int exitCode) { + return ( + "" + + exitCode + + "" + ); + } + + private static String fault(final String code, final String reason) { + return fault(code, reason, null); + } + + private static String fault(final String code, final String reason, final String detailMessage) { + return ( + "" + + "s:Receiver" + + "" + + reason + + "" + + "" + + "" + + (detailMessage == null ? reason : detailMessage) + + "" + + "" + ); + } +} From 70c2546dffa833aa6ce7b94c28be494c3f86ec31 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Thu, 23 Jul 2026 23:31:53 +0200 Subject: [PATCH 20/21] Remove the CXF backend; the dependency-free client is the only one (2.0.0) The light client (introduced in 1.x and proven at parity across #103-#107) becomes the sole implementation. The public API is unchanged. - Delete WinRMService and the service.client CXF internals (invocation handler, interceptors, the Apache-forked NTLM + encryption packages, KerberosUtils, TrustAllX509Manager), plus the dead duplicate copies in service/. Remove KerberosCredentialsException (CXF-only). - Drop the cxf / jaxws / jaxb / jaxws-rt dependencies and the cxf-codegen wsdl2java step; delete the WSDL/XSD/binding/catalog resources. Only smbj remains as a runtime dependency. Main jar: ~10.6 MB shaded -> 101 KB. - WinRMExecutorFactory keeps the org.metricshub.winrm.backend property but now rejects backend=cxf with a clear removal message (and any other value) instead of selecting a backend the operator did not ask for. - Replace the CXF-mocking tests: WinRMServiceTest, WinRMInvocationHandlerTest, KerberosUtilsTest, and CatalogResolutionTest are gone; the command test mocks WindowsRemoteExecutor instead of WinRMService; the differential BackendDifferentialTest is replaced by WinRMLiteTest -> WinRMLiveTest, a single-backend live smoke run. - Version 2.0.00-SNAPSHOT. README/site/CHANGELOG rewritten for the removal. 'mvn verify site' is green again (the javadoc build no longer trips over the CXF-generated sources). Live-smoke-verified against anaxagore (NTLM/HTTP): WQL + command pass. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 80 +- README.md | 76 +- pom.xml | 61 +- .../KerberosCredentialsException.java | 34 - .../winrm/light/LightWinRMService.java | 10 +- .../service/StripShellResponseHandler.java | 95 -- .../winrm/service/WSManHeaderInterceptor.java | 73 -- .../winrm/service/WinRMExecutorFactory.java | 39 +- .../winrm/service/WinRMInvocationHandler.java | 549 ----------- .../winrm/service/WinRMService.java | 886 ------------------ .../client/StripShellResponseHandler.java | 95 -- .../client/WSManHeaderInterceptor.java | 73 -- .../client/WinRMInvocationHandler.java | 561 ----------- .../client/auth/TrustAllX509Manager.java | 43 - .../auth/UsernamePasswordCallbackHandler.java | 64 -- .../client/auth/kerberos/KerberosUtils.java | 209 ----- .../service/client/auth/ntlm/ModeEnum.java | 31 - .../ntlm/NTCredentialsWithEncryption.java | 209 ----- .../service/client/auth/ntlm/NTLMEngine.java | 84 -- .../client/auth/ntlm/NTLMEngineImpl.java | 114 --- .../client/auth/ntlm/NTLMEngineUtils.java | 85 -- .../service/client/auth/ntlm/NTLMMessage.java | 181 ---- .../service/client/auth/ntlm/NTLMScheme.java | 181 ---- .../service/client/auth/ntlm/NtlmKeys.java | 117 --- .../auth/ntlm/NtlmMasqAsSpnegoScheme.java | 70 -- .../ntlm/NtlmMasqAsSpnegoSchemeFactory.java | 37 - .../client/auth/ntlm/Type1Message.java | 119 --- .../client/auth/ntlm/Type2Message.java | 139 --- .../client/auth/ntlm/Type3Message.java | 301 ------ .../AsyncHttpEncryptionAwareConduit.java | 180 ---- ...syncHttpEncryptionAwareConduitFactory.java | 46 - .../client/encryption/ByteArrayUtils.java | 104 -- .../service/client/encryption/CipherGen.java | 593 ------------ .../client/encryption/ContentWithType.java | 54 -- .../DecryptAndVerifyInInterceptor.java | 45 - .../service/client/encryption/Decryptor.java | 217 ----- .../EncryptAndSignOutputStream.java | 145 --- .../encryption/EncryptionAwareHttpEntity.java | 42 - .../client/encryption/EncryptionUtils.java | 85 -- .../service/client/encryption/HMACMD5.java | 82 -- .../winrm/service/client/encryption/MD4.java | 211 ----- .../encryption/NtlmEncryptionUtils.java | 171 ---- .../client/encryption/NullOutputStream.java | 73 -- .../SignAndEncryptOutInterceptor.java | 57 -- .../resources/META-INF/jax-ws-catalog.xml | 30 - src/main/resources/jaxws/bindings.xml | 48 - src/main/resources/wsdl/WinRM.wsdl | 475 ---------- src/main/resources/xsd/dsp8033_1.0.xsd | 307 ------ src/main/resources/xsd/dsp8034_1.0.xsd | 165 ---- src/main/resources/xsd/transfer.xsd | 73 -- src/main/resources/xsd/ws-addr.xsd | 137 --- src/main/resources/xsd/wsman.xsd | 418 --------- src/main/resources/xsd/xml.xsd | 287 ------ src/site/markdown/index.md | 20 +- .../winrm/BackendDifferentialTest.java | 201 ---- .../winrm/CatalogResolutionTest.java | 64 -- .../org/metricshub/winrm/WinRMLiveTest.java | 116 +++ .../command/WinRMCommandExecutorTest.java | 3 +- .../service/WinRMExecutorFactoryTest.java | 28 +- .../winrm/service/WinRMServiceTest.java | 348 ------- .../client/WinRMInvocationHandlerTest.java | 792 ---------------- .../auth/kerberos/KerberosUtilsTest.java | 120 --- 62 files changed, 242 insertions(+), 10111 deletions(-) delete mode 100644 src/main/java/org/metricshub/winrm/exceptions/KerberosCredentialsException.java delete mode 100644 src/main/java/org/metricshub/winrm/service/StripShellResponseHandler.java delete mode 100644 src/main/java/org/metricshub/winrm/service/WSManHeaderInterceptor.java delete mode 100644 src/main/java/org/metricshub/winrm/service/WinRMInvocationHandler.java delete mode 100644 src/main/java/org/metricshub/winrm/service/WinRMService.java delete mode 100644 src/main/java/org/metricshub/winrm/service/client/StripShellResponseHandler.java delete mode 100644 src/main/java/org/metricshub/winrm/service/client/WSManHeaderInterceptor.java delete mode 100644 src/main/java/org/metricshub/winrm/service/client/WinRMInvocationHandler.java delete mode 100644 src/main/java/org/metricshub/winrm/service/client/auth/TrustAllX509Manager.java delete mode 100644 src/main/java/org/metricshub/winrm/service/client/auth/UsernamePasswordCallbackHandler.java delete mode 100644 src/main/java/org/metricshub/winrm/service/client/auth/kerberos/KerberosUtils.java delete mode 100644 src/main/java/org/metricshub/winrm/service/client/auth/ntlm/ModeEnum.java delete mode 100644 src/main/java/org/metricshub/winrm/service/client/auth/ntlm/NTCredentialsWithEncryption.java delete mode 100644 src/main/java/org/metricshub/winrm/service/client/auth/ntlm/NTLMEngine.java delete mode 100644 src/main/java/org/metricshub/winrm/service/client/auth/ntlm/NTLMEngineImpl.java delete mode 100644 src/main/java/org/metricshub/winrm/service/client/auth/ntlm/NTLMEngineUtils.java delete mode 100644 src/main/java/org/metricshub/winrm/service/client/auth/ntlm/NTLMMessage.java delete mode 100644 src/main/java/org/metricshub/winrm/service/client/auth/ntlm/NTLMScheme.java delete mode 100644 src/main/java/org/metricshub/winrm/service/client/auth/ntlm/NtlmKeys.java delete mode 100644 src/main/java/org/metricshub/winrm/service/client/auth/ntlm/NtlmMasqAsSpnegoScheme.java delete mode 100644 src/main/java/org/metricshub/winrm/service/client/auth/ntlm/NtlmMasqAsSpnegoSchemeFactory.java delete mode 100644 src/main/java/org/metricshub/winrm/service/client/auth/ntlm/Type1Message.java delete mode 100644 src/main/java/org/metricshub/winrm/service/client/auth/ntlm/Type2Message.java delete mode 100644 src/main/java/org/metricshub/winrm/service/client/auth/ntlm/Type3Message.java delete mode 100644 src/main/java/org/metricshub/winrm/service/client/encryption/AsyncHttpEncryptionAwareConduit.java delete mode 100644 src/main/java/org/metricshub/winrm/service/client/encryption/AsyncHttpEncryptionAwareConduitFactory.java delete mode 100644 src/main/java/org/metricshub/winrm/service/client/encryption/ByteArrayUtils.java delete mode 100644 src/main/java/org/metricshub/winrm/service/client/encryption/CipherGen.java delete mode 100644 src/main/java/org/metricshub/winrm/service/client/encryption/ContentWithType.java delete mode 100644 src/main/java/org/metricshub/winrm/service/client/encryption/DecryptAndVerifyInInterceptor.java delete mode 100644 src/main/java/org/metricshub/winrm/service/client/encryption/Decryptor.java delete mode 100644 src/main/java/org/metricshub/winrm/service/client/encryption/EncryptAndSignOutputStream.java delete mode 100644 src/main/java/org/metricshub/winrm/service/client/encryption/EncryptionAwareHttpEntity.java delete mode 100644 src/main/java/org/metricshub/winrm/service/client/encryption/EncryptionUtils.java delete mode 100644 src/main/java/org/metricshub/winrm/service/client/encryption/HMACMD5.java delete mode 100644 src/main/java/org/metricshub/winrm/service/client/encryption/MD4.java delete mode 100644 src/main/java/org/metricshub/winrm/service/client/encryption/NtlmEncryptionUtils.java delete mode 100644 src/main/java/org/metricshub/winrm/service/client/encryption/NullOutputStream.java delete mode 100644 src/main/java/org/metricshub/winrm/service/client/encryption/SignAndEncryptOutInterceptor.java delete mode 100644 src/main/resources/META-INF/jax-ws-catalog.xml delete mode 100644 src/main/resources/jaxws/bindings.xml delete mode 100644 src/main/resources/wsdl/WinRM.wsdl delete mode 100644 src/main/resources/xsd/dsp8033_1.0.xsd delete mode 100644 src/main/resources/xsd/dsp8034_1.0.xsd delete mode 100644 src/main/resources/xsd/transfer.xsd delete mode 100644 src/main/resources/xsd/ws-addr.xsd delete mode 100644 src/main/resources/xsd/wsman.xsd delete mode 100644 src/main/resources/xsd/xml.xsd delete mode 100644 src/test/java/org/metricshub/winrm/BackendDifferentialTest.java delete mode 100644 src/test/java/org/metricshub/winrm/CatalogResolutionTest.java create mode 100644 src/test/java/org/metricshub/winrm/WinRMLiveTest.java delete mode 100644 src/test/java/org/metricshub/winrm/service/WinRMServiceTest.java delete mode 100644 src/test/java/org/metricshub/winrm/service/client/WinRMInvocationHandlerTest.java delete mode 100644 src/test/java/org/metricshub/winrm/service/client/auth/kerberos/KerberosUtilsTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a0318a..675af42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,52 +2,56 @@ All notable changes to this project are documented in this file. -## [Unreleased] +## [Unreleased] — 2.0.0 -### ⚠️ Upgrade warning — WinRM over HTTPS with self-signed certificates +### ⚠️ Breaking — the CXF backend was removed -The new dependency-free **light** backend is now the **default**. Unlike the previous CXF-based -client — which silently trusted every TLS certificate and skipped hostname verification — the light -backend **validates the server certificate and verifies the hostname by default**. +Version 2.0.0 removes the legacy Apache CXF backend. The dependency-free client introduced in the +previous release is the only implementation; the public API is unchanged, so calling code is +unaffected. Consequences: -As a result, **WinRM-over-HTTPS connections to hosts with self-signed or otherwise untrusted -certificates that worked with earlier versions will now fail** during the TLS handshake. To restore -connectivity, do one of: +- **WinRM over HTTPS with self-signed certificates**: unlike the CXF-based client — which silently + trusted every TLS certificate and skipped hostname verification — this client **validates the + server certificate and verifies the hostname by default**. Connections to hosts with self-signed + or otherwise untrusted certificates **fail** during the TLS handshake unless you: + - install the server certificate (or its issuing CA) into a Java trust store + (`-Djavax.net.ssl.trustStore=...`); or + - disable TLS validation with `-Dorg.metricshub.winrm.tls.insecure=true` + (**insecure — for testing only**). +- Setting `-Dorg.metricshub.winrm.backend=cxf` now fails with a clear error instead of selecting + the removed backend: remove the property (or stay on winrm-java 1.x). +- The jar shrinks dramatically: the Apache CXF / JAX-WS / JAXB stack is gone and the only runtime + dependency left is `smbj` (used for copying files to remote shares). -- install the server certificate (or its issuing CA) into a Java trust store - (`-Djavax.net.ssl.trustStore=...`); -- disable TLS validation with `-Dorg.metricshub.winrm.tls.insecure=true` - (**insecure — for testing only**); or -- select the legacy CXF backend with `-Dorg.metricshub.winrm.backend=cxf`. +### Removed -### Added +- The Apache CXF-based backend (`WinRMService` and the `service.client` internals), the CXF / + JAX-WS / JAXB / `jaxws-rt` dependencies, and the WSDL/XSD resources and code generation. +- `KerberosCredentialsException` (was thrown only by CXF internals). -- In-process protocol tests (`WsmanProtocolTest` + `FakeWsmanServer`) covering the light backend's - full WSMan path — NTLM handshake, message encryption, multipart framing, Enumerate/Pull paging, - shell lifecycle, and fault mapping — with no Windows host required (they run in `mvn verify`). -- `BackendDifferentialTest`: a one-command differential run comparing the CXF and light backends - against a real host (see README), the go/no-go gate for removing CXF. +### Added -- Dependency-free "light" WinRM backend with no Apache CXF / JAX-WS / JAXB stack, immune by - construction to JAXP `ServiceLoader` conflicts (it uses the JDK-default XML factories). Supports - NTLM over HTTP and HTTPS, and Kerberos (SPNEGO, via the JDK GSS-API) over HTTPS. -- `org.metricshub.winrm.backend` system property to choose the backend (`light` — default — or `cxf`). -- `org.metricshub.winrm.tls.insecure` system property to trust all TLS certificates and skip hostname - verification on the light backend (insecure — for testing only). +- Dependency-free WinRM client with no Apache CXF / JAX-WS / JAXB stack, immune by construction to + JAXP `ServiceLoader` conflicts (it uses the JDK-default XML factories). Supports NTLM over HTTP + (with message encryption) and HTTPS, and Kerberos (SPNEGO, via the JDK GSS-API) over HTTPS. +- `org.metricshub.winrm.tls.insecure` system property to trust all TLS certificates and skip + hostname verification (insecure — for testing only). +- In-process protocol tests (`WsmanProtocolTest` + `FakeWsmanServer`) covering the full WSMan + path — NTLM handshake, message encryption, multipart framing, Enumerate/Pull paging, shell + lifecycle, and fault mapping — with no Windows host required (they run in `mvn verify`). +- `WinRMLiveTest`: a one-command smoke run against a real host (see README). Before the CXF + removal, its predecessor (`BackendDifferentialTest`) proved result parity between the two + backends on live hosts. ### Changed -- The **light** backend is now the default; the CXF backend is opt-in via - `org.metricshub.winrm.backend=cxf`. -- HTTPS connections validate certificates and verify hostnames by default (see the upgrade warning). -- The light backend's exception surface now matches the CXF backend (feature parity, #106): - authentication rejections raise the same `Authentication error on with user name ""` - message, operations on a closed executor raise the same `IllegalStateException` message, the WSMan +- HTTPS connections validate certificates and verify hostnames by default (see the breaking + change above). +- The exception surface matches the pre-2.0.0 CXF backend (feature parity): authentication + rejections raise the same `Authentication error on with user name ""` message, + operations on a closed executor raise the same `IllegalStateException` message, the WSMan `OperationTimeout` header uses the same `PT#.###S` millisecond-precision format, and the - `EndOfSequence` / `Items` enumeration markers are recognized in both their WS-Enumeration and WSMan - namespace variants. WSMan fault exceptions additionally carry the detailed `WSManFault` message - (including the provider-level detail, e.g. WMI `WBEM_E_*` mnemonics) alongside the SOAP reason text. - -### Deprecated - -- The CXF-based backend is deprecated and will be **removed in a future major release**. + `EndOfSequence` / `Items` enumeration markers are recognized in both their WS-Enumeration and + WSMan namespace variants. WSMan fault exceptions additionally carry the detailed `WSManFault` + message (including the provider-level detail, e.g. WMI `WBEM_E_*` mnemonics) alongside the SOAP + reason text. diff --git a/README.md b/README.md index 44b3ed8..4ef5373 100644 --- a/README.md +++ b/README.md @@ -13,32 +13,29 @@ The Windows Remote Management (WinRM) Java Client is a library that enables to: * Connect to a remote Windows server using one of the two authentication types (NTLM, KERBEROS) * Execute WMI Query Language (WQL) queries which uses HTTP/HTTPS protocols. -> ## ⚠️ Upgrade warning +> ## ⚠️ Upgrading from 1.x > -> The **light** backend is now the **default**, and — unlike the previous CXF-based client, which -> silently trusted every TLS certificate — it **validates the server certificate and verifies the -> hostname by default**. **WinRM-over-HTTPS connections to hosts with self-signed or otherwise -> untrusted certificates will now fail** during the TLS handshake unless you do one of: +> Version 2.0.0 **removed the legacy Apache CXF backend**: the dependency-free **light** client is +> the only implementation (same public API — calling code is unaffected). Two consequences: > -> * install the server certificate (or its issuing CA) into a Java trust store (e.g. `-Djavax.net.ssl.trustStore=...`); -> * disable TLS validation with `-Dorg.metricshub.winrm.tls.insecure=true` (**insecure — for testing only**); or -> * select the legacy CXF backend with `-Dorg.metricshub.winrm.backend=cxf`. -> -> The CXF backend stays available through that property for now and will be **removed in a future major release**. - -## WinRM backends - -The library ships two interchangeable backends, both implementing the same public API so calling code is unaffected by the choice: - -* **light** (default) — a dependency-free client (no Apache CXF / JAX-WS / JAXB), immune by construction to JAXP `ServiceLoader` conflicts (it uses the JDK-default XML factories). Supports **NTLM over HTTP and HTTPS** and **Kerberos (SPNEGO) over HTTPS**. Over HTTPS it validates the certificate and verifies the hostname by default (see the upgrade warning above); `-Dorg.metricshub.winrm.tls.insecure=true` trusts all certificates (insecure, testing only). Kerberos uses the ambient Kerberos configuration (`krb5.conf` / `-Djava.security.krb5.*`). -* **cxf** — the mature CXF-based backend, still available during the transition and scheduled for removal in a future major release. - -Select the backend with the `org.metricshub.winrm.backend` system property (`light` is the default): - -```bash -# opt into the legacy CXF backend -java -Dorg.metricshub.winrm.backend=cxf ... -``` +> * Unlike the CXF-based client, which silently trusted every TLS certificate, the light client +> **validates the server certificate and verifies the hostname by default**. +> **WinRM-over-HTTPS connections to hosts with self-signed or otherwise untrusted certificates +> will fail** during the TLS handshake unless you install the server certificate (or its issuing +> CA) into a Java trust store (e.g. `-Djavax.net.ssl.trustStore=...`) or disable TLS validation +> with `-Dorg.metricshub.winrm.tls.insecure=true` (**insecure — for testing only**). +> * Setting `-Dorg.metricshub.winrm.backend=cxf` now fails with a clear error instead of selecting +> the removed backend. Remove the property (or stay on winrm-java 1.x). + +## The WinRM client + +The client is dependency-free (no Apache CXF / JAX-WS / JAXB — the only runtime dependency is +`smbj`, used for copying files to remote shares) and immune by construction to JAXP +`ServiceLoader` conflicts (it uses the JDK-default XML factories). It supports **NTLM over HTTP +(with message encryption) and HTTPS** and **Kerberos (SPNEGO) over HTTPS**. Over HTTPS it +validates the certificate and verifies the hostname by default (see the upgrade warning above); +`-Dorg.metricshub.winrm.tls.insecure=true` trusts all certificates (insecure, testing only). +Kerberos uses the ambient Kerberos configuration (`krb5.conf` / `-Djava.security.krb5.*`). ## Build instructions @@ -50,30 +47,27 @@ mvn verify ### Protocol tests -The build includes in-process protocol tests (`WsmanProtocolTest`) that exercise the light -backend's full WSMan path — NTLM handshake, message encryption, `multipart/encrypted` framing, -WQL Enumerate/Pull paging, the command shell lifecycle, and fault mapping — against a fake WSMan +The build includes in-process protocol tests (`WsmanProtocolTest`) that exercise the client's +full WSMan path — NTLM handshake, message encryption, `multipart/encrypted` framing, WQL +Enumerate/Pull paging, the command shell lifecycle, and fault mapping — against a fake WSMan server, so no Windows host is needed in CI. -### Differential run against a real host +### Live run against a real host -`BackendDifferentialTest` runs the same operations through the legacy CXF backend and the light -backend against a **real** WinRM host and asserts the results match. It is skipped unless -`winrm.diff.host` is set: +`WinRMLiveTest` runs a WQL query and a command against a **real** WinRM host (the successor of +the pre-2.0.0 CXF-vs-light differential harness). It is skipped unless `winrm.live.host` is set: ```bash -mvn test -Dtest=BackendDifferentialTest -Dmaven.javadoc.skip=true \ - -Dwinrm.diff.host=myhost.example.com \ - -Dwinrm.diff.protocol=https \ - -Dwinrm.diff.username='MYDOMAIN\myuser' \ - -Dwinrm.diff.password-file=/path/to/password.txt +mvn test -Dtest=WinRMLiveTest \ + -Dwinrm.live.host=myhost.example.com \ + -Dwinrm.live.protocol=https \ + -Dwinrm.live.username='MYDOMAIN\myuser' \ + -Dwinrm.live.password-file=/path/to/password.txt ``` -Optional properties: `winrm.diff.port`, `winrm.diff.password` (inline), `winrm.diff.namespace`, -`winrm.diff.wql`, `winrm.diff.command`, `winrm.diff.badcreds=true` (also compare wrong-password -error messages; off by default because it triggers failed logons), and -`winrm.diff.tls.insecure=false` (validate TLS on the light backend instead of matching the CXF -backend's trust-all behavior). +Optional properties: `winrm.live.port`, `winrm.live.password` (inline), `winrm.live.namespace`, +`winrm.live.wql`, `winrm.live.command`, and `winrm.live.tls.insecure=true` (skip TLS validation +for hosts with self-signed certificates). ## Release instructions diff --git a/pom.xml b/pom.xml index 36510ec..456a252 100644 --- a/pom.xml +++ b/pom.xml @@ -8,7 +8,7 @@ winrm-java - 1.1.03-SNAPSHOT + 2.0.00-SNAPSHOT WinRM Java Client WinRM Java Client @@ -103,36 +103,11 @@ junit-jupiter-engine test - - jakarta.xml.bind - jakarta.xml.bind-api - 4.0.5 - - - org.apache.cxf - cxf-rt-frontend-jaxws - 4.2.1 - - - org.apache.cxf - cxf-rt-transports-http-hc - 4.2.1 - com.hierynomus smbj 0.14.0 - - jakarta.xml.ws - jakarta.xml.ws-api - 4.0.2 - - - com.sun.xml.ws - jaxws-rt - 4.0.3 - org.mockito mockito-inline @@ -146,40 +121,6 @@ - - org.apache.cxf - cxf-codegen-plugin - 4.2.1 - - - generate-cxf-stubs - generate-sources - - wsdl2java - - - ${project.build.directory}/generated-sources/cxf - - - ${project.basedir}/src/main/resources/wsdl/WinRM.wsdl - - - - ${project.basedir}/src/main/resources/jaxws/bindings.xml - - - - -validate=basic - -keep - - - - - - - - - com.hubspot.maven.plugins diff --git a/src/main/java/org/metricshub/winrm/exceptions/KerberosCredentialsException.java b/src/main/java/org/metricshub/winrm/exceptions/KerberosCredentialsException.java deleted file mode 100644 index 9aeb31c..0000000 --- a/src/main/java/org/metricshub/winrm/exceptions/KerberosCredentialsException.java +++ /dev/null @@ -1,34 +0,0 @@ -package org.metricshub.winrm.exceptions; - -/*- - * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ - * WinRM Java Client - * ჻჻჻჻჻჻ - * Copyright 2023 - 2024 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. - * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ - */ - -public class KerberosCredentialsException extends RuntimeException { - - private static final long serialVersionUID = 1L; - - public KerberosCredentialsException(final Throwable cause) { - super(cause); - } - - public KerberosCredentialsException(final String message, final Throwable cause) { - super(message, cause); - } -} diff --git a/src/main/java/org/metricshub/winrm/light/LightWinRMService.java b/src/main/java/org/metricshub/winrm/light/LightWinRMService.java index d8d1017..7ced0fb 100644 --- a/src/main/java/org/metricshub/winrm/light/LightWinRMService.java +++ b/src/main/java/org/metricshub/winrm/light/LightWinRMService.java @@ -42,9 +42,9 @@ /** * Dependency-free {@link WindowsRemoteExecutor} backed by {@link WsmanClient}. A drop-in - * alternative to the CXF-based {@code WinRMService}: same public behaviour, no Apache CXF / - * JAX-WS / JAXB stack, and immune by construction to JAXP {@code ServiceLoader} poisoning - * (it uses the JDK-default XML factories). + * replacement for the CXF-based {@code WinRMService} that shipped before 2.0.0: same public + * behaviour, no Apache CXF / JAX-WS / JAXB stack, and immune by construction to JAXP + * {@code ServiceLoader} poisoning (it uses the JDK-default XML factories). * *

Supports NTLM over HTTP (with message encryption) and over HTTPS (plaintext SOAP inside TLS, * validating the server certificate by default; see {@link LightTls}), and Kerberos over HTTPS @@ -147,9 +147,9 @@ private static AuthScheme resolveAuthScheme( if (schemes.isEmpty()) { // e.g. Kerberos requested over plain HTTP with no other scheme to fall back to. throw new WinRMException( - "Kerberos over the light backend requires HTTPS (endpoint was " + + "Kerberos over WinRM requires HTTPS (endpoint was " + winRMEndpoint.getEndpoint() + - "). Use HTTPS, or select the CXF backend with -Dorg.metricshub.winrm.backend=cxf." + "): there is no Kerberos message encryption over plain HTTP. Use HTTPS, or add NTLM to the authentication list." ); } return schemes.size() == 1 ? schemes.get(0) : new FallbackAuthScheme(schemes); diff --git a/src/main/java/org/metricshub/winrm/service/StripShellResponseHandler.java b/src/main/java/org/metricshub/winrm/service/StripShellResponseHandler.java deleted file mode 100644 index 5ea4947..0000000 --- a/src/main/java/org/metricshub/winrm/service/StripShellResponseHandler.java +++ /dev/null @@ -1,95 +0,0 @@ -package org.metricshub.winrm.service; - -/*- - * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ - * WinRM Java Client - * ჻჻჻჻჻჻ - * Copyright 2023 - 2024 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 jakarta.xml.ws.handler.MessageContext.WSDL_OPERATION; - -import jakarta.xml.soap.SOAPBody; -import jakarta.xml.soap.SOAPElement; -import jakarta.xml.soap.SOAPEnvelope; -import jakarta.xml.soap.SOAPException; -import jakarta.xml.ws.handler.MessageContext; -import jakarta.xml.ws.handler.soap.SOAPHandler; -import jakarta.xml.ws.handler.soap.SOAPMessageContext; -import java.util.Collections; -import java.util.Iterator; -import java.util.Set; -import javax.xml.namespace.QName; - -/** - * Code from io.cloudsoft.winrm4j.client.StripShellResponseHandler - * release 0.12.3 @link https://github.com/cloudsoft/winrm4j - */ -public class StripShellResponseHandler implements SOAPHandler { - - @Override - public boolean handleMessage(final SOAPMessageContext context) { - final Boolean messageOutbound = (Boolean) context.get(MessageContext.MESSAGE_OUTBOUND_PROPERTY); - if (messageOutbound != null && messageOutbound.booleanValue()) { - return true; - } - - final QName action = (QName) context.get(WSDL_OPERATION); - if (action != null && !"Create".equals(action.getLocalPart())) { - return true; - } - - final Iterator childIterator = getBodyChildren(context); - while (childIterator.hasNext()) { - final Object node = childIterator.next(); - - if (node instanceof SOAPElement) { - final SOAPElement soapElement = (SOAPElement) node; - if ("Shell".equals(soapElement.getLocalName())) { - childIterator.remove(); - } - } - } - - return true; - } - - private Iterator getBodyChildren(final SOAPMessageContext context) { - try { - final SOAPEnvelope envelope = context.getMessage().getSOAPPart().getEnvelope(); - final SOAPBody body = envelope.getBody(); - - return body.getChildElements(); - } catch (final SOAPException e) { - throw new IllegalStateException(e); - } - } - - @Override - public boolean handleFault(final SOAPMessageContext context) { - return true; - } - - @Override - public void close(final MessageContext context) { - // Do nothing - } - - @Override - public Set getHeaders() { - return Collections.emptySet(); - } -} diff --git a/src/main/java/org/metricshub/winrm/service/WSManHeaderInterceptor.java b/src/main/java/org/metricshub/winrm/service/WSManHeaderInterceptor.java deleted file mode 100644 index d2ec8a1..0000000 --- a/src/main/java/org/metricshub/winrm/service/WSManHeaderInterceptor.java +++ /dev/null @@ -1,73 +0,0 @@ -package org.metricshub.winrm.service; - -/*- - * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ - * WinRM Java Client - * ჻჻჻჻჻჻ - * Copyright 2023 - 2024 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 jakarta.xml.bind.JAXBElement; -import jakarta.xml.bind.JAXBException; -import java.util.List; -import org.apache.cxf.binding.soap.SoapMessage; -import org.apache.cxf.binding.soap.interceptor.AbstractSoapInterceptor; -import org.apache.cxf.binding.soap.interceptor.SoapPreProtocolOutInterceptor; -import org.apache.cxf.headers.Header; -import org.apache.cxf.interceptor.Fault; -import org.apache.cxf.jaxb.JAXBDataBinding; -import org.apache.cxf.phase.Phase; -import org.metricshub.winrm.Utils; -import org.metricshub.winrm.service.wsman.AttributableURI; -import org.metricshub.winrm.service.wsman.ObjectFactory; - -/** - * Code from org.opennms.core.wsman.cxf.WSManHeaderInterceptor - * release 1.2.3 @link https://github.com/OpenNMS/wsman - */ -public class WSManHeaderInterceptor extends AbstractSoapInterceptor { - - private static final JAXBDataBinding ATTRIBUTABLE_URI_JAXB_DATA_BINDING; - - static { - try { - ATTRIBUTABLE_URI_JAXB_DATA_BINDING = new JAXBDataBinding(AttributableURI.class); - } catch (final JAXBException e) { - throw new RuntimeException("Failed to create JAXBDataBinding for: AttributableURI" + AttributableURI.class, e); - } - } - - private final String resourceUri; - - public WSManHeaderInterceptor(final String resourceUri) { - super(Phase.POST_LOGICAL); - addAfter(SoapPreProtocolOutInterceptor.class.getName()); - - Utils.checkNonNull(resourceUri, "resourceUri"); - - this.resourceUri = resourceUri; - } - - @Override - public void handleMessage(final SoapMessage message) throws Fault { - final JAXBElement resourceURI = new ObjectFactory().createResourceURI(resourceUri); - - final List

headers = message.getHeaders(); - headers.add(new Header(resourceURI.getName(), resourceURI, ATTRIBUTABLE_URI_JAXB_DATA_BINDING)); - - message.put(Header.HEADER_LIST, headers); - } -} diff --git a/src/main/java/org/metricshub/winrm/service/WinRMExecutorFactory.java b/src/main/java/org/metricshub/winrm/service/WinRMExecutorFactory.java index c835304..d63f227 100644 --- a/src/main/java/org/metricshub/winrm/service/WinRMExecutorFactory.java +++ b/src/main/java/org/metricshub/winrm/service/WinRMExecutorFactory.java @@ -29,16 +29,14 @@ import org.metricshub.winrm.service.client.auth.AuthenticationEnum; /** - * Selects the WinRM backend that fulfils a request. The default is the dependency-free - * {@link LightWinRMService}; setting the system property {@value #BACKEND_PROPERTY} to - * {@code cxf} selects the mature CXF-based {@link WinRMService} instead — needed for capabilities - * the light backend does not yet cover (HTTPS and Kerberos). - * - *

Both backends implement {@link WindowsRemoteExecutor}, so callers are agnostic to the choice. + * Creates the {@link WindowsRemoteExecutor} that fulfils a request. Since 2.0.0 the dependency-free + * {@link LightWinRMService} is the only backend: the legacy CXF-based backend has been removed. + * The {@value #BACKEND_PROPERTY} system property is kept so operators who still set it get a clear + * error ({@code cxf}) or a no-op ({@code light}) instead of a silent behavior change. */ public final class WinRMExecutorFactory { - /** System property selecting the backend: {@code light} (default) or {@code cxf}. */ + /** System property selecting the backend; {@code light} is the only supported value. */ public static final String BACKEND_PROPERTY = "org.metricshub.winrm.backend"; private static final String LIGHT = "light"; @@ -47,14 +45,15 @@ public final class WinRMExecutorFactory { private WinRMExecutorFactory() {} /** - * Create a {@link WindowsRemoteExecutor} using the configured backend. + * Create a {@link WindowsRemoteExecutor} (light backend). * * @param winRMEndpoint endpoint with credentials (mandatory) * @param timeout timeout in milliseconds (must be > 0) * @param ticketCache Kerberos ticket cache path (may be {@code null}) * @param authentications requested authentication schemes (may be {@code null}) - * @return a light-backed or CXF-backed executor depending on {@value #BACKEND_PROPERTY} - * @throws WinRMException for any problem creating the executor + * @return a light-backed executor + * @throws WinRMException for any problem creating the executor, or when {@value #BACKEND_PROPERTY} + * requests the removed CXF backend or an unknown value */ public static WindowsRemoteExecutor createInstance( final WinRMEndpoint winRMEndpoint, @@ -67,20 +66,16 @@ public static WindowsRemoteExecutor createInstance( return LightWinRMService.createInstance(winRMEndpoint, timeout, ticketCache, authentications); } if (CXF.equals(backend)) { - return WinRMService.createInstance(winRMEndpoint, timeout, ticketCache, authentications); + // Fail loudly: an operator who explicitly pinned the legacy backend must not be silently + // switched to another implementation. + throw new WinRMException( + "The CXF WinRM backend was removed in winrm-java 2.0.0; remove the " + + BACKEND_PROPERTY + + " system property to use the light backend (or stay on winrm-java 1.x)." + ); } - // Fail loudly on a typo or unsupported value rather than silently running a backend the operator - // did not ask for (which would also emit misleading "set the property" hints downstream). throw new WinRMException( - "Unsupported value \"" + - backend + - "\" for system property " + - BACKEND_PROPERTY + - "; expected \"" + - LIGHT + - "\" (default) or \"" + - CXF + - "\"." + "Unsupported value \"" + backend + "\" for system property " + BACKEND_PROPERTY + "; expected \"" + LIGHT + "\"." ); } } diff --git a/src/main/java/org/metricshub/winrm/service/WinRMInvocationHandler.java b/src/main/java/org/metricshub/winrm/service/WinRMInvocationHandler.java deleted file mode 100644 index 8e5aadd..0000000 --- a/src/main/java/org/metricshub/winrm/service/WinRMInvocationHandler.java +++ /dev/null @@ -1,549 +0,0 @@ -package org.metricshub.winrm.service; - -/*- - * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ - * WinRM Java Client - * ჻჻჻჻჻჻ - * Copyright 2023 - 2024 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 jakarta.xml.ws.BindingProvider; -import jakarta.xml.ws.WebServiceException; -import jakarta.xml.ws.handler.Handler; -import jakarta.xml.ws.soap.SOAPFaultException; -import java.io.IOException; -import java.lang.reflect.InvocationHandler; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; -import java.net.URL; -import java.nio.file.Path; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Queue; -import java.util.concurrent.ConcurrentHashMap; -import java.util.stream.Collectors; -import java.util.stream.Stream; -import javax.net.ssl.TrustManager; -import javax.xml.namespace.QName; -import org.apache.cxf.Bus; -import org.apache.cxf.binding.soap.SoapBindingConstants; -import org.apache.cxf.configuration.jsse.TLSClientParameters; -import org.apache.cxf.endpoint.Client; -import org.apache.cxf.frontend.ClientProxy; -import org.apache.cxf.jaxws.JaxWsProxyFactoryBean; -import org.apache.cxf.message.Message; -import org.apache.cxf.service.model.ServiceInfo; -import org.apache.cxf.transport.http.HTTPConduitFactory; -import org.apache.cxf.transport.http.asyncclient.AsyncHTTPConduit; -import org.apache.cxf.transports.http.configuration.HTTPClientPolicy; -import org.apache.cxf.ws.addressing.WSAddressingFeature; -import org.apache.cxf.ws.addressing.WSAddressingFeature.AddressingResponses; -import org.apache.cxf.ws.addressing.policy.MetadataConstants; -import org.apache.cxf.ws.policy.PolicyConstants; -import org.apache.http.auth.AuthSchemeProvider; -import org.apache.http.auth.Credentials; -import org.apache.http.auth.NTCredentials; -import org.apache.http.client.config.AuthSchemes; -import org.apache.http.config.Registry; -import org.apache.http.config.RegistryBuilder; -import org.apache.http.impl.auth.KerberosSchemeFactory; -import org.apache.neethi.Policy; -import org.apache.neethi.builders.PrimitiveAssertion; -import org.metricshub.winrm.Utils; -import org.metricshub.winrm.WinRMHttpProtocolEnum; -import org.metricshub.winrm.service.client.auth.AuthenticationEnum; -import org.metricshub.winrm.service.client.auth.TrustAllX509Manager; -import org.metricshub.winrm.service.client.auth.kerberos.KerberosUtils; -import org.metricshub.winrm.service.client.auth.ntlm.NTCredentialsWithEncryption; -import org.metricshub.winrm.service.client.auth.ntlm.NtlmMasqAsSpnegoSchemeFactory; -import org.metricshub.winrm.service.client.encryption.AsyncHttpEncryptionAwareConduitFactory; -import org.metricshub.winrm.service.client.encryption.DecryptAndVerifyInInterceptor; -import org.metricshub.winrm.service.client.encryption.SignAndEncryptOutInterceptor; - -public class WinRMInvocationHandler implements InvocationHandler { - - public static final String WSMAN_SCHEMA_NAMESPACE = "http://schemas.dmtf.org/wbem/wsman/1/wsman.xsd"; - - private static final long PAUSE_TIME_MILLISECONDS = 500; - private static final int MAX_RETRY = 3; - - private static final URL WSDL_LOCATION_URL = - WinRMWebServiceClient.class.getClassLoader().getResource("wsdl/WinRM.wsdl"); - - private static final QName SERVICE = new QName(WSMAN_SCHEMA_NAMESPACE, "WinRMWebServiceClient"); - - private static final QName PORT = new QName(WSMAN_SCHEMA_NAMESPACE, "WinRMPort"); - - private static final List CONTENT_TYPE_LIST = Collections.singletonList("application/soap+xml;charset=UTF-8"); - - @SuppressWarnings("rawtypes") - private static final List HANDLER_CHAIN = Arrays.asList(new StripShellResponseHandler()); - - private static final Registry AUTH_SCHEME_REGISTRY = RegistryBuilder - .create() - .register(AuthSchemes.SPNEGO, new NtlmMasqAsSpnegoSchemeFactory()) - .register(AuthSchemes.KERBEROS, new KerberosSchemeFactory(true)) - .build(); - - private static final Policy POLICY; - - static { - POLICY = new Policy(); - POLICY.addAssertion(new PrimitiveAssertion(MetadataConstants.USING_ADDRESSING_2004_QNAME)); - } - - private static final WSAddressingFeature WS_ADDRESSING_FEATURE; - - static { - WS_ADDRESSING_FEATURE = new WSAddressingFeature(); - WS_ADDRESSING_FEATURE.setResponses(AddressingResponses.ANONYMOUS); - } - - private static final TLSClientParameters TLS_CLIENT_PARAMETERS; - - static { - TLS_CLIENT_PARAMETERS = new TLSClientParameters(); - TLS_CLIENT_PARAMETERS.setDisableCNCheck(true); - // Accept all certificates - TLS_CLIENT_PARAMETERS.setTrustManagers(new TrustManager[] { new TrustAllX509Manager() }); - } - - private static final Map CREDENTIALS = new ConcurrentHashMap<>(); - - private final WinRMWebService winRMWebService; - private final WinRMEndpoint winRMEndpoint; - private final long timeout; - private final String resourceUri; - private final Path ticketCache; - private final Queue authenticationsQueue; - private AuthenticationEnum authentication; - private Client wsClient; - - /** - * WinRMInvocationHandler constructor - * - * @param winRMEndpoint Endpoint with credentials (mandatory) - * @param bus Apache CXF Bus (mandatory) - * @param timeout Timeout used for Connection, Connection Request and Receive Request in milliseconds - * @param resourceUri The enumerate resource URI - * @param ticketCache The Ticket Cache path - * @param authentications List of authentications. (mandatory) - */ - public WinRMInvocationHandler( - final WinRMEndpoint winRMEndpoint, - final Bus bus, - final long timeout, - final String resourceUri, - final Path ticketCache, - final List authentications - ) { - Utils.checkNonNull(winRMEndpoint, "winRMEndpoint"); - Utils.checkNonNull(bus, "bus"); - Utils.checkNonNull(authentications, "authentications"); - - this.winRMEndpoint = winRMEndpoint; - this.timeout = timeout; - this.resourceUri = resourceUri; - this.ticketCache = ticketCache; - authenticationsQueue = authentications.stream().collect(Collectors.toCollection(LinkedList::new)); - - winRMWebService = createWinRMWebService(winRMEndpoint, bus); - - final AuthCredentials authCredentials = computeCredentials(winRMEndpoint, ticketCache, authenticationsQueue); - - authentication = authCredentials.getAuthentication(); - - wsClient = - getWebServiceClient(winRMEndpoint, timeout, resourceUri, winRMWebService, authCredentials.getCredentials()); - } - - public Client getClient() { - return wsClient; - } - - @Override - public Object invoke(final Object proxy, final Method method, final Object[] args) throws Throwable { - Utils.checkNonNull(method, "method"); - - try { - return invokeMethod(method, args); - } catch (final RetryTgtExpirationException e) { - // retry with a new TGT in case of current TGT expiration - authentication = null; - - Credentials credentials; - try { - credentials = - KerberosUtils.createCredentials(winRMEndpoint.getUsername(), winRMEndpoint.getPassword(), ticketCache); - - CREDENTIALS.put(new CredentialsMapKey(winRMEndpoint, ticketCache, AuthenticationEnum.KERBEROS), credentials); - // Normally that should not happen as any other exception on KERBEROs should had been throw - // at the first KERBEROS call - } catch (final Exception e1) { - if (continueToRetry()) { - final AuthCredentials authCredentials = computeCredentials(winRMEndpoint, ticketCache, authenticationsQueue); - - authentication = authCredentials.getAuthentication(); - credentials = authCredentials.getCredentials(); - } else { - throw e1; - } - } - - wsClient = getWebServiceClient(winRMEndpoint, timeout, resourceUri, winRMWebService, credentials); - - return invoke(proxy, method, args); - } catch (final RetryAuthenticationException e) { - if (continueToRetry()) { - final AuthCredentials authCredentials = computeCredentials(winRMEndpoint, ticketCache, authenticationsQueue); - - authentication = authCredentials.getAuthentication(); - - wsClient = - getWebServiceClient(winRMEndpoint, timeout, resourceUri, winRMWebService, authCredentials.getCredentials()); - - return invoke(proxy, method, args); - } - - // No more retries - final Throwable cause = e.getCause(); - if (cause instanceof SOAPFaultException) { - throw new RuntimeException("KERBEROS with encryption over HTTP is not implemented.", cause); - } - throw cause; - } - } - - // this function is only needed for the unit testing - boolean continueToRetry() { - return !authenticationsQueue.isEmpty(); - } - - Object invokeMethod(final Method method, final Object[] args) - throws IllegalAccessException, RetryAuthenticationException { - Throwable firstEx = null; - int retry = 0; - - while (retry < MAX_RETRY) { - retry++; - - try { - return method.invoke(winRMWebService, args); - } catch (final InvocationTargetException ite) { - final Throwable targetEx = ite.getTargetException(); - - if (targetEx instanceof SOAPFaultException) { - // Could retry with a different authentication than NTLM - // because it could be a "WstxEOFException: Unexpected EOF in prolog" - // due to a KERBEROS with HTTP and AllowUnencrypted=false - if (winRMEndpoint.getProtocol() == WinRMHttpProtocolEnum.HTTP && authentication != AuthenticationEnum.NTLM) { - throw new RetryAuthenticationException(targetEx); - } - throw (SOAPFaultException) targetEx; - } - - if (!(targetEx instanceof WebServiceException)) { - throw new IllegalStateException("Failure when calling " + createCallInfos(method, args), targetEx); - } - - final WebServiceException wsEx = (WebServiceException) targetEx; - - if (!(wsEx.getCause() instanceof IOException)) { - throw new RuntimeException( - "Exception occurred while making WinRM WebService call " + createCallInfos(method, args), - wsEx - ); - } - - if ( - wsEx.getCause().getMessage() != null && - wsEx.getCause().getMessage().startsWith("Authorization loop detected on Conduit") - ) { - final RuntimeException authEx = new RuntimeException( - String.format( - "Authentication error on %s with user name \"%s\"", - winRMEndpoint.getEndpoint(), - winRMEndpoint.getRawUsername() - ) - ); - - // Could be due to a TGT expiration - if (authentication == AuthenticationEnum.KERBEROS) { - throw new RetryTgtExpirationException(authEx); - } - // Could retry with a different authentication - throw new RetryAuthenticationException(authEx); - } - - if (firstEx == null) { - firstEx = wsEx; - } - - if (retry < MAX_RETRY) { - try { - Utils.sleep(PAUSE_TIME_MILLISECONDS); - } catch (final InterruptedException ie) { - Thread.currentThread().interrupt(); - throw new RuntimeException( - "Exception occured while making WinRM WebService call " + createCallInfos(method, args), - ie - ); - } - } - } - } - - throw new RuntimeException( - String.format("failed task \"%s\" after %d attempts", createCallInfos(method, args), MAX_RETRY), - firstEx - ); - } - - static String createCallInfos(final Method method, final Object[] args) { - final String name = method != null && method.getName() != null ? method.getName() : Utils.EMPTY; - return args == null - ? name - : Stream - .concat(Stream.of(name), Stream.of(args)) - .filter(Objects::nonNull) - .map(Object::toString) - .collect(Collectors.joining(" ")); - } - - static Credentials createCredentials( - final WinRMEndpoint winRMEndpoint, - final AuthenticationEnum authentication, - final Path ticketCache - ) { - switch (authentication) { - case KERBEROS: - return KerberosUtils.createCredentials(winRMEndpoint.getUsername(), winRMEndpoint.getPassword(), ticketCache); - case NTLM: - default: - final String password = String.valueOf(winRMEndpoint.getPassword()); - return winRMEndpoint.getProtocol() == WinRMHttpProtocolEnum.HTTP - ? new NTCredentialsWithEncryption(winRMEndpoint.getUsername(), password, null, winRMEndpoint.getDomain()) - : new NTCredentials(winRMEndpoint.getUsername(), password, null, winRMEndpoint.getDomain()); - } - } - - static AuthCredentials computeCredentials( - final WinRMEndpoint winRMEndpoint, - final Path ticketCache, - final Queue authenticationsQueue - ) { - try { - final AuthenticationEnum authenticationEnum = authenticationsQueue.remove(); - - final Credentials credentials = CREDENTIALS.compute( - new CredentialsMapKey(winRMEndpoint, ticketCache, authenticationEnum), - (user, cred) -> cred != null ? cred : createCredentials(winRMEndpoint, authenticationEnum, ticketCache) - ); - - return new AuthCredentials(authenticationEnum, credentials); - } catch (final Exception e) { - // if there's still retry - if (!authenticationsQueue.isEmpty()) { - return computeCredentials(winRMEndpoint, ticketCache, authenticationsQueue); - } - throw e; - } - } - - static WinRMWebService createWinRMWebService(final WinRMEndpoint winRMEndpoint, final Bus bus) { - final JaxWsProxyFactoryBean jaxWsProxyFactoryBean = new JaxWsProxyFactoryBean(); - jaxWsProxyFactoryBean.setServiceName(SERVICE); - jaxWsProxyFactoryBean.setEndpointName(PORT); - jaxWsProxyFactoryBean.setBus(bus); - jaxWsProxyFactoryBean.setServiceClass(WinRMWebService.class); - jaxWsProxyFactoryBean.setAddress(winRMEndpoint.getEndpoint()); - jaxWsProxyFactoryBean.getFeatures().add(WS_ADDRESSING_FEATURE); - jaxWsProxyFactoryBean.setBindingId(SoapBindingConstants.SOAP12_BINDING_ID); - jaxWsProxyFactoryBean.getClientFactoryBean().getServiceFactory().setWsdlURL(WSDL_LOCATION_URL); - - return jaxWsProxyFactoryBean.create(WinRMWebService.class); - } - - static Client getWebServiceClient( - final WinRMEndpoint winRMEndpoint, - final long timeout, - final String enumerateResourceUri, - final WinRMWebService winRMWebService, - final Credentials credentials - ) { - final Client client = ClientProxy.getClient(winRMWebService); - - if (enumerateResourceUri != null) { - final WSManHeaderInterceptor interceptor = new WSManHeaderInterceptor(enumerateResourceUri); - client.getOutInterceptors().add(interceptor); - } - - client.getInInterceptors().add(new DecryptAndVerifyInInterceptor()); - client.getOutInterceptors().add(new SignAndEncryptOutInterceptor()); - - // this is different to endpoint properties - client - .getEndpoint() - .getEndpointInfo() - .setProperty(HTTPConduitFactory.class.getName(), new AsyncHttpEncryptionAwareConduitFactory()); - - final ServiceInfo serviceInfo = client.getEndpoint().getEndpointInfo().getService(); - serviceInfo.setProperty("soap.force.doclit.bare", true); - - final BindingProvider bindingProvider = (BindingProvider) winRMWebService; - bindingProvider.getBinding().setHandlerChain(HANDLER_CHAIN); - bindingProvider.getRequestContext().put(PolicyConstants.POLICY_OVERRIDE, POLICY); - bindingProvider.getRequestContext().put("http.autoredirect", true); - - bindingProvider.getRequestContext().put(BindingProvider.ENDPOINT_ADDRESS_PROPERTY, winRMEndpoint.getEndpoint()); - - final Map> headers = new HashMap<>(); - headers.put("Content-Type", CONTENT_TYPE_LIST); - - bindingProvider.getRequestContext().put(Message.PROTOCOL_HEADERS, headers); - - // Setup timeouts - final HTTPClientPolicy httpClientPolicy = new HTTPClientPolicy(); - httpClientPolicy.setConnectionTimeout(timeout); - httpClientPolicy.setConnectionRequestTimeout(timeout); - httpClientPolicy.setReceiveTimeout(timeout); - httpClientPolicy.setAllowChunking(false); - - bindingProvider.getRequestContext().put(Credentials.class.getName(), credentials); - bindingProvider.getRequestContext().put(AuthSchemeProvider.class.getName(), AUTH_SCHEME_REGISTRY); - - final AsyncHTTPConduit asyncHTTPConduit = (AsyncHTTPConduit) client.getConduit(); - asyncHTTPConduit.setClient(httpClientPolicy); - asyncHTTPConduit.getClient().setAutoRedirect(true); - asyncHTTPConduit.setTlsClientParameters(TLS_CLIENT_PARAMETERS); - - return client; - } - - static class RetryAuthenticationException extends Exception { - - private static final long serialVersionUID = 1L; - - RetryAuthenticationException(final Throwable throwable) { - super(throwable); - } - } - - static class RetryTgtExpirationException extends RetryAuthenticationException { - - private static final long serialVersionUID = 1L; - - RetryTgtExpirationException(final Throwable throwable) { - super(throwable); - } - } - - static class AuthCredentials { - - private final AuthenticationEnum authentication; - private final Credentials credentials; - - AuthCredentials(final AuthenticationEnum authentication, final Credentials credentials) { - this.authentication = authentication; - this.credentials = credentials; - } - - public AuthenticationEnum getAuthentication() { - return authentication; - } - - public Credentials getCredentials() { - return credentials; - } - - @Override - public int hashCode() { - return Objects.hash(authentication, credentials); - } - - @Override - public boolean equals(final Object obj) { - if (this == obj) { - return true; - } - if (obj == null) { - return false; - } - if (!(obj instanceof AuthCredentials)) { - return false; - } - final AuthCredentials other = (AuthCredentials) obj; - return authentication == other.authentication && Objects.equals(credentials, other.credentials); - } - } - - static class CredentialsMapKey { - - private final String canonizedRawUsername; - private final char[] password; - private final Path ticketCache; - private final AuthenticationEnum authentication; - - CredentialsMapKey( - final WinRMEndpoint winRMEndpoint, - final Path ticketCache, - final AuthenticationEnum authentication - ) { - this.ticketCache = ticketCache; - this.authentication = authentication; - - password = winRMEndpoint.getPassword(); - canonizedRawUsername = - winRMEndpoint.getRawUsername() != null - ? winRMEndpoint.getRawUsername().replaceAll("\\s", Utils.EMPTY).toUpperCase() - : null; - } - - @Override - public int hashCode() { - final int prime = 31; - int result = 1; - result = prime * result + Arrays.hashCode(password); - result = prime * result + Objects.hash(authentication, canonizedRawUsername, ticketCache); - return result; - } - - @Override - public boolean equals(final Object obj) { - if (this == obj) { - return true; - } - if (obj == null) { - return false; - } - if (!(obj instanceof CredentialsMapKey)) { - return false; - } - final CredentialsMapKey other = (CredentialsMapKey) obj; - return ( - authentication == other.authentication && - Objects.equals(canonizedRawUsername, other.canonizedRawUsername) && - Arrays.equals(password, other.password) && - Objects.equals(ticketCache, other.ticketCache) - ); - } - } -} diff --git a/src/main/java/org/metricshub/winrm/service/WinRMService.java b/src/main/java/org/metricshub/winrm/service/WinRMService.java deleted file mode 100644 index 6819c3c..0000000 --- a/src/main/java/org/metricshub/winrm/service/WinRMService.java +++ /dev/null @@ -1,886 +0,0 @@ -package org.metricshub.winrm.service; - -/*- - * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ - * WinRM Java Client - * ჻჻჻჻჻჻ - * Copyright 2023 - 2024 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 jakarta.xml.bind.JAXBElement; -import jakarta.xml.ws.BindingProvider; -import jakarta.xml.ws.soap.SOAPFaultException; -import java.io.IOException; -import java.io.StringWriter; -import java.io.Writer; -import java.lang.reflect.Proxy; -import java.math.BigDecimal; -import java.nio.charset.Charset; -import java.nio.charset.StandardCharsets; -import java.nio.file.Path; -import java.text.DecimalFormat; -import java.text.DecimalFormatSymbols; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.TimeoutException; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.stream.Collectors; -import java.util.stream.IntStream; -import javax.xml.namespace.QName; -import javax.xml.parsers.DocumentBuilderFactory; -import javax.xml.parsers.ParserConfigurationException; -import javax.xml.xpath.XPath; -import javax.xml.xpath.XPathExpressionException; -import javax.xml.xpath.XPathFactory; -import org.apache.cxf.Bus; -import org.apache.cxf.Bus.BusState; -import org.apache.cxf.BusFactory; -import org.apache.cxf.endpoint.Client; -import org.apache.cxf.transport.http.HTTPConduitFactory; -import org.apache.cxf.transport.http.asyncclient.AsyncHTTPConduit; -import org.apache.cxf.transport.http.asyncclient.AsyncHTTPConduitFactory; -import org.apache.cxf.transport.http.asyncclient.AsyncHTTPConduitFactory.UseAsyncPolicy; -import org.metricshub.winrm.Utils; -import org.metricshub.winrm.WindowsRemoteCommandResult; -import org.metricshub.winrm.WindowsRemoteExecutor; -import org.metricshub.winrm.WmiHelper; -import org.metricshub.winrm.exceptions.WinRMException; -import org.metricshub.winrm.exceptions.WqlQuerySyntaxException; -import org.metricshub.winrm.service.client.WinRMInvocationHandler; -import org.metricshub.winrm.service.client.auth.AuthenticationEnum; -import org.metricshub.winrm.service.enumeration.Enumerate; -import org.metricshub.winrm.service.enumeration.EnumerateResponse; -import org.metricshub.winrm.service.enumeration.EnumerationContextType; -import org.metricshub.winrm.service.enumeration.FilterType; -import org.metricshub.winrm.service.enumeration.Pull; -import org.metricshub.winrm.service.enumeration.PullResponse; -import org.metricshub.winrm.service.shell.CommandLine; -import org.metricshub.winrm.service.shell.CommandStateType; -import org.metricshub.winrm.service.shell.DesiredStreamType; -import org.metricshub.winrm.service.shell.Receive; -import org.metricshub.winrm.service.shell.ReceiveResponse; -import org.metricshub.winrm.service.shell.Shell; -import org.metricshub.winrm.service.shell.StreamType; -import org.metricshub.winrm.service.transfer.ResourceCreated; -import org.metricshub.winrm.service.wsman.AnyListType; -import org.metricshub.winrm.service.wsman.CommandResponse; -import org.metricshub.winrm.service.wsman.Delete; -import org.metricshub.winrm.service.wsman.Locale; -import org.metricshub.winrm.service.wsman.MixedDataType; -import org.metricshub.winrm.service.wsman.OptionSetType; -import org.metricshub.winrm.service.wsman.OptionType; -import org.metricshub.winrm.service.wsman.SelectorSetType; -import org.metricshub.winrm.service.wsman.SelectorType; -import org.metricshub.winrm.service.wsman.Signal; -import org.w3c.dom.Document; -import org.w3c.dom.Element; -import org.w3c.dom.Node; -import org.w3c.dom.NodeList; - -public class WinRMService implements WindowsRemoteExecutor { - - public static final List DEFAULT_AUTHENTICATION = Collections.singletonList( - AuthenticationEnum.NTLM - ); - - private static final String STDERR = "stderr"; - private static final String STDOUT = "stdout"; - - private static final int MAX_ENVELOPE_SIZE = 153600; - - private static final String ENUMERATION_NAMESPACE = "http://schemas.xmlsoap.org/ws/2004/09/enumeration"; - - private static final String WSMAN_URI = "http://schemas.microsoft.com/wbem/wsman/1"; - - private static final String DIALECT_WQL = WSMAN_URI + "/WQL"; - - private static final String SHELL_URI = WSMAN_URI + "/windows/shell"; - private static final String COMMAND_RESOURCE_URI = SHELL_URI + "/cmd"; - private static final String COMMAND_STATE_DONE = SHELL_URI + "/CommandState/Done"; - private static final String TERMINATE_CODE = SHELL_URI + "/signal/terminate"; - - private static final QName WSEN_ITEMS_QNAME = new QName(ENUMERATION_NAMESPACE, "Items"); - - private static final QName WSMAN_ITEMS_QNAME = new QName(WinRMInvocationHandler.WSMAN_SCHEMA_NAMESPACE, "Items"); - - private static final QName WSMAN_END_OF_SEQUENCE_QNAME = new QName( - WinRMInvocationHandler.WSMAN_SCHEMA_NAMESPACE, - "EndOfSequence" - ); - - private static final QName WSEN_END_OF_SEQUENCE_QNAME = new QName(ENUMERATION_NAMESPACE, "EndOfSequence"); - - private static final QName WSMAN_XML_FRAGMENT_QNAME = new QName( - WinRMInvocationHandler.WSMAN_SCHEMA_NAMESPACE, - "XmlFragment" - ); - - private static final DocumentBuilderFactory DOCUMENT_BUILDER_FACTORY = DocumentBuilderFactory.newInstance(); - - /** - * If no output is available before the wsman:OperationTimeout expires, the server MUST return a WSManFault with - * the Code attribute equal to "2150858793" - * https://msdn.microsoft.com/en-us/library/cc251676.aspx - */ - private static final String WSMAN_FAULT_CODE_OPERATION_TIMEOUT_EXPIRED = "2150858793"; - - /** - * Example response: - * [truncated]The request for the Windows Remote Shell with ShellId xxxx-yyyy-ccc... failed because the shell - * was not found on the server. - * Possible causes are: the specified ShellId is incorrect or the shell no longer exist - */ - private static final String WSMAN_FAULT_CODE_SHELL_WAS_NOT_FOUND = "2150858843"; - - private static final Locale LOCALE; - - static { - LOCALE = new Locale(); - LOCALE.setLang(java.util.Locale.US.toLanguageTag()); - } - - private static final OptionSetType OPTION_SET_CREATE; - - static { - final OptionType optNoProfile = new OptionType(); - optNoProfile.setName("WINRS_NOPROFILE"); - optNoProfile.setValue("true"); - - final OptionType optCodepage = new OptionType(); - optCodepage.setName("WINRS_CODEPAGE"); - optCodepage.setValue("437"); - - OPTION_SET_CREATE = new OptionSetType(); - OPTION_SET_CREATE.getOption().add(optNoProfile); - OPTION_SET_CREATE.getOption().add(optCodepage); - } - - private static final OptionSetType OPTION_SET_COMMAND; - - static { - final OptionType optConsoleModeStdin = new OptionType(); - optConsoleModeStdin.setName("WINRS_CONSOLEMODE_STDIN"); - optConsoleModeStdin.setValue("true"); - - final OptionType optSkipCmdShell = new OptionType(); - optSkipCmdShell.setName("WINRS_SKIP_CMD_SHELL"); - optSkipCmdShell.setValue("false"); - - OPTION_SET_COMMAND = new OptionSetType(); - OPTION_SET_COMMAND.getOption().add(optConsoleModeStdin); - OPTION_SET_COMMAND.getOption().add(optSkipCmdShell); - } - - private static final ConcurrentHashMap CONNECTIONS_CACHE = new ConcurrentHashMap<>(); - - private final AtomicInteger useCount = new AtomicInteger(1); - - private final WinRMEndpoint winRMEndpoint; - private final Bus bus; - private final WinRMWebService cmdWS; - private final WinRMWebService wqlWS; - private final Client cmdClient; - private final Client wqlClient; - private final String strTimeout; - - private SelectorSetType shellSelector = null; - - /** - * The WinRMService constructor. - * - * @param winRMEndpoint Endpoint with credentials - * @param bus Apache CXF Bus - * @param cmdInvocation The WinRM web service for executing commands - * @param wqlInvocation The WinRM web service for executing WQL queries - * @param timeout Timeout in milliseconds - */ - private WinRMService( - final WinRMEndpoint winRMEndpoint, - final Bus bus, - final WinRMInvocationHandler cmdInvocation, - final WinRMInvocationHandler wqlInvocation, - final long timeout - ) { - this.winRMEndpoint = winRMEndpoint; - this.bus = bus; - this.cmdWS = createProxyService(cmdInvocation); - this.wqlWS = createProxyService(wqlInvocation); - this.cmdClient = cmdInvocation.getClient(); - this.wqlClient = wqlInvocation.getClient(); - - final BigDecimal timeoutSec = BigDecimal.valueOf(timeout).divide(BigDecimal.valueOf(1000)); - final DecimalFormat decimalFormat = new DecimalFormat("PT#.###S", new DecimalFormatSymbols(java.util.Locale.ROOT)); - this.strTimeout = decimalFormat.format(timeoutSec); - } - - /** - * Create a WinRMService instance - * - * @param winRMEndpoint Endpoint with credentials (mandatory) - * @param timeout Timeout used for Connection, Connection Request and Receive Request - * in milliseconds (throws an IllegalArgumentException if negative or zero) - * @param ticketCache The Ticket Cache path - * @param authentications List of authentications. only NTLM if absent - * - * @return WinRMService instance - * - * @throws WinRMException For any problem encountered - */ - public static WinRMService createInstance( - final WinRMEndpoint winRMEndpoint, - final long timeout, - final Path ticketCache, - final List authentications - ) throws WinRMException { - Utils.checkNonNull(winRMEndpoint, "winRMEndpoint"); - Utils.checkArgumentNotZeroOrNegative(timeout, "timeout"); - - final List normalizedAuthentications = authentications == null - ? DEFAULT_AUTHENTICATION - : authentications.stream().distinct().collect(Collectors.toList()); - - try { - return CONNECTIONS_CACHE.compute( - winRMEndpoint, - (key, win) -> { - if (win == null) { - final Bus bus = BusFactory.newInstance().createBus(); - - // Needed to be async to force the use of Apache HTTP Components client. - // Details at http://cxf.apache.org/docs/asynchronous-client-http-transport.html. - // Apache HTTP Components needed to support NTLM authentication. - bus.getProperties().put(AsyncHTTPConduit.USE_ASYNC, Boolean.TRUE); - bus.getProperties().put(AsyncHTTPConduitFactory.USE_POLICY, UseAsyncPolicy.ALWAYS); - - final WinRMInvocationHandler cmdInvocation = createWinRMInvocationHandlerInstance( - winRMEndpoint, - bus, - timeout, - null, - ticketCache, - normalizedAuthentications - ); - - final WinRMInvocationHandler wqlInvocation = createWinRMInvocationHandlerInstance( - winRMEndpoint, - bus, - timeout, - String.format("%s/wmi/%s/*", WSMAN_URI, winRMEndpoint.getNamespace()), - ticketCache, - normalizedAuthentications - ); - - return new WinRMService(winRMEndpoint, bus, cmdInvocation, wqlInvocation, timeout); - } else { - synchronized (win) { - win.incrementUseCount(); - - return win; - } - } - } - ); - } catch (final RuntimeException e) { - if (e.getCause() != null) { - final String message = e.getMessage() != null - ? String.format( - "%s\n%s: %s", - e.getMessage(), - e.getCause().getClass().getSimpleName(), - e.getCause().getMessage() - ) - : String.format("%s: %s", e.getCause().getClass().getSimpleName(), e.getCause().getMessage()); - throw new WinRMException(e.getCause(), message); - } - - throw new WinRMException(e.getMessage()); - } - } - - public int getUseCount() { - return useCount.get(); - } - - /** - * @return whether this WbemServices instance is connected and usable - */ - public boolean isConnected() { - return getUseCount() > 0; - } - - void incrementUseCount() { - useCount.incrementAndGet(); - } - - /** - * Check if it's connected. If not, throw an IllegalStateException. - */ - public void checkConnectedFirst() { - if (!isConnected()) { - throw new IllegalStateException("This instance has been closed and a new one must be created."); - } - } - - @Override - public void close() { - if (useCount.decrementAndGet() == 0) { - CONNECTIONS_CACHE.remove(winRMEndpoint); - - if (shellSelector != null) { - cmdWS.delete(new Delete(), COMMAND_RESOURCE_URI, MAX_ENVELOPE_SIZE, strTimeout, LOCALE, shellSelector); - - shellSelector = null; - } - - if (cmdClient != null) { - shutdownConduitFactory(cmdClient); - cmdClient.destroy(); - } - - if (wqlClient != null) { - shutdownConduitFactory(wqlClient); - wqlClient.destroy(); - } - - if (bus != null && bus.getState() != BusState.SHUTDOWN) { - bus.shutdown(true); - } - } - } - - /** - * Retrieves the {@link AsyncHTTPConduitFactory} registered on the given client's endpoint and calls - * {@link AsyncHTTPConduitFactory#shutdown()} on it to stop any background threads (e.g. the idle-connection - * reaper thread). This must be done before destroying the client to prevent thread leaks. - * - * @param client the CXF {@link Client} whose conduit factory should be shut down - */ - private void shutdownConduitFactory(final Client client) { - final Object factory = client.getEndpoint().getEndpointInfo().getProperty(HTTPConduitFactory.class.getName()); - if (factory instanceof AsyncHTTPConduitFactory) { - ((AsyncHTTPConduitFactory) factory).shutdown(); - } - } - - @Override - public WindowsRemoteCommandResult executeCommand( - final String command, - final String workingDirectory, - final Charset charset, - final long timeout - ) throws WinRMException, TimeoutException { - Utils.checkNonNull(command, "command"); - Utils.checkArgumentNotZeroOrNegative(timeout, "timeout"); - - checkConnectedFirst(); - - try { - return Utils.execute( - () -> { - if (getShellSelector() == null) { - create(workingDirectory); - } - - try { - final StringWriter stdout = new StringWriter(); - final StringWriter stderr = new StringWriter(); - final Charset cs = charset != null ? charset : StandardCharsets.UTF_8; - - final long start = Utils.getCurrentTimeMillis(); - final int statusCode = execute(command, stdout, stderr, cs); - final float executionTime = (Utils.getCurrentTimeMillis() - start) / 1000.0f; - - return new WindowsRemoteCommandResult(stdout.toString(), stderr.toString(), executionTime, statusCode); - } catch (final WinRMException e) { - throw new RuntimeException(e); - } - }, - timeout - ); - } catch (final InterruptedException | ExecutionException e) { - if (e.getCause() != null) { - throw new WinRMException(e.getCause(), e.getCause().getMessage()); - } - throw new WinRMException(e); - } - } - - @Override - public List> executeWql(final String wqlQuery, final long timeout) - throws WinRMException, WqlQuerySyntaxException, TimeoutException { - Utils.checkNonNull(wqlQuery, "wqlQuery"); - if (!WmiHelper.isValidWql(wqlQuery)) { - throw new WqlQuerySyntaxException(wqlQuery); - } - Utils.checkArgumentNotZeroOrNegative(timeout, "timeout"); - - checkConnectedFirst(); - - try { - return Utils.execute( - () -> { - final List nodes = new ArrayList<>(); - - final EnumerateResponse enumerateResponse = enumerate(wqlQuery); - - final boolean endOfSequence = getItemsFrom(enumerateResponse, nodes); - if (!endOfSequence) { - final String nextContextId = getContextIdFrom(enumerateResponse.getEnumerationContext()); - pull(nextContextId, nodes); - } - - return nodes.stream().map(WinRMService::convertRow).collect(Collectors.toList()); - }, - timeout - ); - } catch (final InterruptedException | ExecutionException e) { - if (e.getCause() != null) { - throw new WinRMException(e.getCause(), e.getCause().getMessage()); - } - throw new WinRMException(e); - } - } - - public static WinRMInvocationHandler createWinRMInvocationHandlerInstance( - final WinRMEndpoint winRMEndpoint, - final Bus bus, - final long timeout, - final String resourceUri, - final Path ticketCache, - final List authentications - ) { - return new WinRMInvocationHandler(winRMEndpoint, bus, timeout, resourceUri, ticketCache, authentications); - } - - private static WinRMWebService createProxyService(final WinRMInvocationHandler winRMInvocationHandler) { - return (WinRMWebService) Proxy.newProxyInstance( - WinRMWebService.class.getClassLoader(), - new Class[] { WinRMWebService.class, BindingProvider.class }, - winRMInvocationHandler - ); - } - - public EnumerateResponse enumerate(final String wqlQuery) { - final FilterType filterType = new FilterType(); - filterType.setDialect(DIALECT_WQL); - filterType.getContent().add(wqlQuery); - - final Enumerate body = new Enumerate(); - body.setFilter(filterType); - - return wqlWS.enumerate(body); - } - - public String pull(final String contextId, final List nodes) throws WinRMException { - final EnumerationContextType enumContext = new EnumerationContextType(); - enumContext.getContent().add(contextId); - - final Pull body = new Pull(); - body.setEnumerationContext(enumContext); - - final PullResponse response = wqlWS.pull(body); - - if (response == null) { - throw new WinRMException(String.format("Pull failed for context id: %s", contextId)); - } - - final boolean endOfSequence = getItemsFrom(response, nodes); - final String nextContextId = response.getEnumerationContext() == null - ? null // The PullResponse will not contain an EnumerationContext if EndOfSequence is set - : getContextIdFrom(response.getEnumerationContext()); - - return endOfSequence ? nextContextId : pull(nextContextId, nodes); // If we're pulling recursively, and we haven't hit the last element, continue pulling - } - - public ResourceCreated create(final String workingDirectory) { - final Shell shell = new Shell(); - shell.getInputStreams().add("stdin"); - shell.getOutputStreams().add(STDOUT); - shell.getOutputStreams().add(STDERR); - - if (Utils.isNotBlank(workingDirectory)) { - shell.setWorkingDirectory(workingDirectory); - } - - final ResourceCreated resourceCreated = cmdWS.create( - shell, - COMMAND_RESOURCE_URI, - MAX_ENVELOPE_SIZE, - strTimeout, - LOCALE, - OPTION_SET_CREATE - ); - - final String shellId = getShellId(resourceCreated); - - shellSelector = new SelectorSetType(); - final SelectorType selectorType = new SelectorType(); - selectorType.setName("ShellId"); - selectorType.getContent().add(shellId); - shellSelector.getSelector().add(selectorType); - - return resourceCreated; - } - - public int execute(final String command, final Writer out, final Writer err, final Charset charset) - throws WinRMException { - final CommandLine body = new CommandLine(); - body.setCommand(command); - - final CommandResponse commandResponse = cmdWS.command( - body, - COMMAND_RESOURCE_URI, - MAX_ENVELOPE_SIZE, - strTimeout, - LOCALE, - shellSelector, - OPTION_SET_COMMAND - ); - - final String commandId = commandResponse.getCommandId(); - - try { - return receiveCommand(commandId, out, err, charset); - } finally { - try { - final Signal signal = new Signal(); - signal.setCommandId(commandId); - signal.setCode(TERMINATE_CODE); - - cmdWS.signal(signal, COMMAND_RESOURCE_URI, MAX_ENVELOPE_SIZE, strTimeout, LOCALE, shellSelector); - } catch (final SOAPFaultException soapFault) { - assertFaultCode(soapFault, WSMAN_FAULT_CODE_SHELL_WAS_NOT_FOUND, true); - } - } - } - - private int receiveCommand(final String commandId, final Writer out, final Writer err, final Charset charset) - throws WinRMException { - while (true) { - final DesiredStreamType stream = new DesiredStreamType(); - stream.setCommandId(commandId); - stream.setValue("stdout stderr"); - - final Receive receive = new Receive(); - receive.setDesiredStream(stream); - - try { - final ReceiveResponse receiveResponse = cmdWS.receive( - receive, - COMMAND_RESOURCE_URI, - MAX_ENVELOPE_SIZE, - strTimeout, - LOCALE, - shellSelector - ); - getStreams(receiveResponse, out, err, charset); - - final CommandStateType state = receiveResponse.getCommandState(); - if (COMMAND_STATE_DONE.equals(state.getState())) { - return state.getExitCode().intValue(); - } - } catch (final SOAPFaultException soapFault) { - // If such Exception which has a code 2150858793 the client is expected to again trigger immediately - // a receive request. https://msdn.microsoft.com/en-us/library/cc251676.aspx - assertFaultCode(soapFault, WSMAN_FAULT_CODE_OPERATION_TIMEOUT_EXPIRED, false); - } - } - } - - private static Map convertRow(final Node node) { - return IntStream - .range(0, node.getChildNodes().getLength()) - .mapToObj(node.getChildNodes()::item) - .filter(Objects::nonNull) - .collect(HashMap::new, (map, child) -> map.put(child.getLocalName(), child.getTextContent()), HashMap::putAll); - } - - private static String getShellId(final ResourceCreated resourceCreated) { - final XPath xpath = XPathFactory.newInstance().newXPath(); - - for (final Element element : resourceCreated.getAny()) { - try { - final String shellId = xpath.evaluate("//*[local-name()='Selector' and @Name='ShellId']", element); - if (shellId != null && !shellId.isEmpty()) { - return shellId; - } - } catch (final XPathExpressionException e) { - throw new IllegalStateException(e); - } - } - throw new IllegalStateException("Shell ID not fount in " + resourceCreated); - } - - private static void assertFaultCode(final SOAPFaultException soapFault, final String code, final boolean retry) { - try { - final NodeList faultDetails = soapFault.getFault().getDetail().getChildNodes(); - - for (int i = 0; i < faultDetails.getLength(); i++) { - final Node item = faultDetails.item(i); - - if ("WSManFault".equals(item.getLocalName())) { - if (retry && code.equals(item.getAttributes().getNamedItem("Code").getNodeValue())) { - return; - } - throw soapFault; - } - } - throw soapFault; - } catch (final NullPointerException e) { - throw soapFault; - } - } - - private void getStreams( - final ReceiveResponse receiveResponse, - final Writer out, - final Writer err, - final Charset charset - ) throws WinRMException { - final List streams = receiveResponse.getStream(); - for (final StreamType streamType : streams) { - final byte[] value = streamType.getValue(); - if (value == null) { - continue; - } - - writeStd(out, STDOUT, streamType, value, charset); - writeStd(err, STDERR, streamType, value, charset); - } - } - - private void writeStd( - final Writer std, - final String name, - final StreamType streamType, - final byte[] value, - final Charset charset - ) throws WinRMException { - if (std == null || !name.equals(streamType.getName())) { - return; - } - - try { - if (value.length > 0) { - std.write(new String(value, charset)); - std.flush(); - } - - if (streamType.isEnd() != null && streamType.isEnd().booleanValue()) { - std.close(); - } - } catch (final IOException e) { - throw new WinRMException(e); - } - } - - /** - * Retrieves the list of items from the given response, adding them to the given - * list and returns true if the response contains an 'end-of-sequence' marker. - * @throws WinRMException - */ - public boolean getItemsFrom(final EnumerateResponse response, final List items) throws WinRMException { - for (final Object object : response.getAny()) { - if (object instanceof JAXBElement) { - final JAXBElement jaxbElement = (JAXBElement) object; - - if (WSEN_ITEMS_QNAME.equals(jaxbElement.getName()) || WSMAN_ITEMS_QNAME.equals(jaxbElement.getName())) { - if (jaxbElement.isNil()) { - // No items - } else if (jaxbElement.getValue() instanceof AnyListType) { - // some items - final AnyListType itemList = (AnyListType) jaxbElement.getValue(); - for (final Object item : itemList.getAny()) { - final Node node = toNode(item) - .orElseThrow(() -> - new WinRMException( - "Unsupported element of type %s in EnumerateResponse: %s", - object.getClass(), - object - ) - ); - - items.add(node); - } - } else { - throw new WinRMException( - "Unsupported value in EnumerateResponse Items: %s of type: %s", - jaxbElement.getValue(), - jaxbElement.getValue().getClass() - ); - } - } else if ( - WSEN_END_OF_SEQUENCE_QNAME.equals(jaxbElement.getName()) || - WSMAN_END_OF_SEQUENCE_QNAME.equals(jaxbElement.getName()) - ) { - return true; - } else { - throw new WinRMException( - "Unsupported element in EnumerateResponse: %s with name: %s", - jaxbElement, - jaxbElement.getName() - ); - } - } else if (object instanceof Node) { - final Node node = (Node) object; - - if ( - (WSEN_END_OF_SEQUENCE_QNAME.getNamespaceURI().equals(node.getNamespaceURI()) && - WSEN_END_OF_SEQUENCE_QNAME.getLocalPart().equals(node.getLocalName())) || - (WSMAN_END_OF_SEQUENCE_QNAME.getNamespaceURI().equals(node.getNamespaceURI()) && - WSMAN_END_OF_SEQUENCE_QNAME.getLocalPart().equals(node.getLocalName())) - ) { - return true; - } - throw new WinRMException( - "Unsupported node in EnumerateResponse: %s with namespace: %s", - node.toString(), - node.getNamespaceURI() - ); - } else { - throw new WinRMException( - "Unsupported element in EnumerateResponse: %s, with type: %s", - object, - object != null ? object.getClass() : null - ); - } - } - - return false; - } - - private static boolean getItemsFrom(final PullResponse response, final List items) throws WinRMException { - for (final Object item : response.getItems().getAny()) { - final Node node = toNode(item) - .orElseThrow(() -> - new WinRMException( - "The pull response contains an unsupported item %s of type %s", - item, - item != null ? item.getClass() : null - ) - ); - - items.add(node); - } - return response.getEndOfSequence() != null; - } - - private static Optional toNode(final Object item) throws WinRMException { - if (item instanceof Node) { - return Optional.of((Node) item); - } - - if (item instanceof JAXBElement) { - final JAXBElement nestedElement = (JAXBElement) item; - if ( - WSMAN_XML_FRAGMENT_QNAME.equals(nestedElement.getName()) && - !nestedElement.isNil() && - nestedElement.getValue() instanceof MixedDataType - ) { - // Create a new document/node that contains the elements within the fragment - final Document document = createNewDocument(); - final Element rootElement = document.createElementNS( - WSMAN_XML_FRAGMENT_QNAME.getNamespaceURI(), - WSMAN_XML_FRAGMENT_QNAME.getLocalPart() - ); - document.appendChild(rootElement); - - final MixedDataType mixed = (MixedDataType) nestedElement.getValue(); - for (final Object nestedItem : mixed.getContent()) { - if (nestedItem instanceof String) { - // Skip over whitespace - } else if (nestedItem instanceof Node) { - // Node's can't belong to two different documents, so we need to import it first - final Node nestedNode = document.importNode((Node) nestedItem, true); - rootElement.appendChild(nestedNode); - } else { - throw new WinRMException( - "Unsupported element of type %s in XmlFragment: %s", - nestedItem.getClass(), - nestedItem - ); - } - } - return Optional.of(rootElement); - } - } - return Optional.empty(); - } - - private static Document createNewDocument() throws WinRMException { - // The DocumentBuilderFactory provides no guarantees on thread safety - // so we lock it in order to avoid creating new or separate instances per thread - synchronized (DOCUMENT_BUILDER_FACTORY) { - try { - return DOCUMENT_BUILDER_FACTORY.newDocumentBuilder().newDocument(); - } catch (final ParserConfigurationException e) { - throw new WinRMException(e); - } - } - } - - public String getContextIdFrom(final EnumerationContextType context) throws WinRMException { - // The content of the EnumerationContext should contain a single string, the context id - if (context == null || context.getContent() == null) { - throw new WinRMException("EnumerationContext %s has no content.", context); - } - - if (context.getContent().isEmpty()) { - // The EnumerationContext can be empty if we issue an optimized enumeration - // and all of the records are immediately returned - return null; - } - - if (context.getContent().size() == 1) { - final Object content = context.getContent().get(0); - if (content instanceof String) { - return (String) content; - } - throw new WinRMException("Unsupported EnumerationContext content: %s", content); - } - - throw new WinRMException( - "EnumerationContext contains too many elements, expected: 1 actual: %d", - context.getContent().size() - ); - } - - public SelectorSetType getShellSelector() { - return shellSelector; - } - - @Override - public String getHostname() { - return winRMEndpoint.getHostname(); - } - - @Override - public String getUsername() { - return winRMEndpoint.getRawUsername(); - } - - @Override - public char[] getPassword() { - return winRMEndpoint.getPassword(); - } -} diff --git a/src/main/java/org/metricshub/winrm/service/client/StripShellResponseHandler.java b/src/main/java/org/metricshub/winrm/service/client/StripShellResponseHandler.java deleted file mode 100644 index cd21e26..0000000 --- a/src/main/java/org/metricshub/winrm/service/client/StripShellResponseHandler.java +++ /dev/null @@ -1,95 +0,0 @@ -package org.metricshub.winrm.service.client; - -/*- - * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ - * WinRM Java Client - * ჻჻჻჻჻჻ - * Copyright 2023 - 2024 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 jakarta.xml.ws.handler.MessageContext.WSDL_OPERATION; - -import jakarta.xml.soap.SOAPBody; -import jakarta.xml.soap.SOAPElement; -import jakarta.xml.soap.SOAPEnvelope; -import jakarta.xml.soap.SOAPException; -import jakarta.xml.ws.handler.MessageContext; -import jakarta.xml.ws.handler.soap.SOAPHandler; -import jakarta.xml.ws.handler.soap.SOAPMessageContext; -import java.util.Collections; -import java.util.Iterator; -import java.util.Set; -import javax.xml.namespace.QName; - -/** - * Code from io.cloudsoft.winrm4j.client.StripShellResponseHandler - * release 0.12.3 @link https://github.com/cloudsoft/winrm4j - */ -public class StripShellResponseHandler implements SOAPHandler { - - @Override - public boolean handleMessage(final SOAPMessageContext context) { - final Boolean messageOutbound = (Boolean) context.get(MessageContext.MESSAGE_OUTBOUND_PROPERTY); - if (messageOutbound != null && messageOutbound.booleanValue()) { - return true; - } - - final QName action = (QName) context.get(WSDL_OPERATION); - if (action != null && !"Create".equals(action.getLocalPart())) { - return true; - } - - final Iterator childIterator = getBodyChildren(context); - while (childIterator.hasNext()) { - final Object node = childIterator.next(); - - if (node instanceof SOAPElement) { - final SOAPElement soapElement = (SOAPElement) node; - if ("Shell".equals(soapElement.getLocalName())) { - childIterator.remove(); - } - } - } - - return true; - } - - private Iterator getBodyChildren(final SOAPMessageContext context) { - try { - final SOAPEnvelope envelope = context.getMessage().getSOAPPart().getEnvelope(); - final SOAPBody body = envelope.getBody(); - - return body.getChildElements(); - } catch (final SOAPException e) { - throw new IllegalStateException(e); - } - } - - @Override - public boolean handleFault(final SOAPMessageContext context) { - return true; - } - - @Override - public void close(final MessageContext context) { - // Do nothing - } - - @Override - public Set getHeaders() { - return Collections.emptySet(); - } -} diff --git a/src/main/java/org/metricshub/winrm/service/client/WSManHeaderInterceptor.java b/src/main/java/org/metricshub/winrm/service/client/WSManHeaderInterceptor.java deleted file mode 100644 index 4753803..0000000 --- a/src/main/java/org/metricshub/winrm/service/client/WSManHeaderInterceptor.java +++ /dev/null @@ -1,73 +0,0 @@ -package org.metricshub.winrm.service.client; - -import jakarta.xml.bind.JAXBElement; -import jakarta.xml.bind.JAXBException; -/*- - * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ - * WinRM Java Client - * ჻჻჻჻჻჻ - * Copyright 2023 - 2024 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 java.util.List; -import org.apache.cxf.binding.soap.SoapMessage; -import org.apache.cxf.binding.soap.interceptor.AbstractSoapInterceptor; -import org.apache.cxf.binding.soap.interceptor.SoapPreProtocolOutInterceptor; -import org.apache.cxf.headers.Header; -import org.apache.cxf.interceptor.Fault; -import org.apache.cxf.jaxb.JAXBDataBinding; -import org.apache.cxf.phase.Phase; -import org.metricshub.winrm.Utils; -import org.metricshub.winrm.service.wsman.AttributableURI; -import org.metricshub.winrm.service.wsman.ObjectFactory; - -/** - * Code from org.opennms.core.wsman.cxf.WSManHeaderInterceptor - * release 1.2.3 @link https://github.com/OpenNMS/wsman - */ -public class WSManHeaderInterceptor extends AbstractSoapInterceptor { - - private static final JAXBDataBinding ATTRIBUTABLE_URI_JAXB_DATA_BINDING; - - static { - try { - ATTRIBUTABLE_URI_JAXB_DATA_BINDING = new JAXBDataBinding(AttributableURI.class); - } catch (final JAXBException e) { - throw new RuntimeException("Failed to create JAXBDataBinding for: AttributableURI" + AttributableURI.class, e); - } - } - - private final String resourceUri; - - public WSManHeaderInterceptor(final String resourceUri) { - super(Phase.POST_LOGICAL); - addAfter(SoapPreProtocolOutInterceptor.class.getName()); - - Utils.checkNonNull(resourceUri, "resourceUri"); - - this.resourceUri = resourceUri; - } - - @Override - public void handleMessage(final SoapMessage message) throws Fault { - final JAXBElement resourceURI = new ObjectFactory().createResourceURI(resourceUri); - - final List

headers = message.getHeaders(); - headers.add(new Header(resourceURI.getName(), resourceURI, ATTRIBUTABLE_URI_JAXB_DATA_BINDING)); - - message.put(Header.HEADER_LIST, headers); - } -} diff --git a/src/main/java/org/metricshub/winrm/service/client/WinRMInvocationHandler.java b/src/main/java/org/metricshub/winrm/service/client/WinRMInvocationHandler.java deleted file mode 100644 index f90de91..0000000 --- a/src/main/java/org/metricshub/winrm/service/client/WinRMInvocationHandler.java +++ /dev/null @@ -1,561 +0,0 @@ -package org.metricshub.winrm.service.client; - -import jakarta.xml.ws.BindingProvider; -import jakarta.xml.ws.WebServiceException; -import jakarta.xml.ws.handler.Handler; -import jakarta.xml.ws.soap.SOAPFaultException; -/*- - * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ - * WinRM Java Client - * ჻჻჻჻჻჻ - * Copyright 2023 - 2024 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 java.io.IOException; -import java.lang.reflect.InvocationHandler; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; -import java.net.URL; -import java.nio.file.Path; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Queue; -import java.util.concurrent.ConcurrentHashMap; -import java.util.stream.Collectors; -import java.util.stream.Stream; -import javax.net.ssl.TrustManager; -import javax.xml.namespace.QName; -import org.apache.cxf.Bus; -import org.apache.cxf.binding.soap.SoapBindingConstants; -import org.apache.cxf.configuration.jsse.TLSClientParameters; -import org.apache.cxf.endpoint.Client; -import org.apache.cxf.frontend.ClientProxy; -import org.apache.cxf.jaxws.JaxWsProxyFactoryBean; -import org.apache.cxf.message.Message; -import org.apache.cxf.service.model.EndpointInfo; -import org.apache.cxf.service.model.ServiceInfo; -import org.apache.cxf.transport.http.HTTPConduitFactory; -import org.apache.cxf.transport.http.asyncclient.AsyncHTTPConduit; -import org.apache.cxf.transports.http.configuration.HTTPClientPolicy; -import org.apache.cxf.ws.addressing.WSAddressingFeature; -import org.apache.cxf.ws.addressing.WSAddressingFeature.AddressingResponses; -import org.apache.cxf.ws.addressing.policy.MetadataConstants; -import org.apache.cxf.ws.policy.PolicyConstants; -import org.apache.http.auth.AuthSchemeProvider; -import org.apache.http.auth.Credentials; -import org.apache.http.auth.NTCredentials; -import org.apache.http.client.config.AuthSchemes; -import org.apache.http.config.Registry; -import org.apache.http.config.RegistryBuilder; -import org.apache.http.impl.auth.KerberosSchemeFactory; -import org.apache.neethi.Policy; -import org.apache.neethi.builders.PrimitiveAssertion; -import org.metricshub.winrm.Utils; -import org.metricshub.winrm.WinRMHttpProtocolEnum; -import org.metricshub.winrm.service.WinRMEndpoint; -import org.metricshub.winrm.service.WinRMWebService; -import org.metricshub.winrm.service.WinRMWebServiceClient; -import org.metricshub.winrm.service.client.auth.AuthenticationEnum; -import org.metricshub.winrm.service.client.auth.TrustAllX509Manager; -import org.metricshub.winrm.service.client.auth.kerberos.KerberosUtils; -import org.metricshub.winrm.service.client.auth.ntlm.NTCredentialsWithEncryption; -import org.metricshub.winrm.service.client.auth.ntlm.NtlmMasqAsSpnegoSchemeFactory; -import org.metricshub.winrm.service.client.encryption.AsyncHttpEncryptionAwareConduitFactory; -import org.metricshub.winrm.service.client.encryption.DecryptAndVerifyInInterceptor; -import org.metricshub.winrm.service.client.encryption.SignAndEncryptOutInterceptor; - -public class WinRMInvocationHandler implements InvocationHandler { - - public static final String WSMAN_SCHEMA_NAMESPACE = "http://schemas.dmtf.org/wbem/wsman/1/wsman.xsd"; - - private static final long PAUSE_TIME_MILLISECONDS = 500; - private static final int MAX_RETRY = 3; - - private static final URL WSDL_LOCATION_URL = - WinRMWebServiceClient.class.getClassLoader().getResource("wsdl/WinRM.wsdl"); - - private static final QName SERVICE = new QName(WSMAN_SCHEMA_NAMESPACE, "WinRMWebServiceClient"); - - private static final QName PORT = new QName(WSMAN_SCHEMA_NAMESPACE, "WinRMPort"); - - private static final List CONTENT_TYPE_LIST = Collections.singletonList("application/soap+xml;charset=UTF-8"); - - @SuppressWarnings("rawtypes") - private static final List HANDLER_CHAIN = Arrays.asList(new StripShellResponseHandler()); - - private static final Registry AUTH_SCHEME_REGISTRY = RegistryBuilder - .create() - .register(AuthSchemes.SPNEGO, new NtlmMasqAsSpnegoSchemeFactory()) - .register(AuthSchemes.KERBEROS, new KerberosSchemeFactory(true)) - .build(); - - private static final Policy POLICY; - - static { - POLICY = new Policy(); - POLICY.addAssertion(new PrimitiveAssertion(MetadataConstants.USING_ADDRESSING_2004_QNAME)); - } - - private static final WSAddressingFeature WS_ADDRESSING_FEATURE; - - static { - WS_ADDRESSING_FEATURE = new WSAddressingFeature(); - WS_ADDRESSING_FEATURE.setResponses(AddressingResponses.ANONYMOUS); - } - - private static final TLSClientParameters TLS_CLIENT_PARAMETERS; - - static { - TLS_CLIENT_PARAMETERS = new TLSClientParameters(); - TLS_CLIENT_PARAMETERS.setDisableCNCheck(true); - // Accept all certificates - TLS_CLIENT_PARAMETERS.setTrustManagers(new TrustManager[] { new TrustAllX509Manager() }); - } - - private static final Map CREDENTIALS = new ConcurrentHashMap<>(); - - private final WinRMWebService winRMWebService; - private final WinRMEndpoint winRMEndpoint; - private final long timeout; - private final String resourceUri; - private final Path ticketCache; - private final Queue authenticationsQueue; - private AuthenticationEnum authentication; - private Client wsClient; - - /** - * WinRMInvocationHandler constructor - * - * @param winRMEndpoint Endpoint with credentials (mandatory) - * @param bus Apache CXF Bus (mandatory) - * @param timeout Timeout used for Connection, Connection Request and Receive Request in milliseconds - * @param resourceUri The enumerate resource URI - * @param ticketCache The Ticket Cache path - * @param authentications List of authentications. (mandatory) - */ - public WinRMInvocationHandler( - final WinRMEndpoint winRMEndpoint, - final Bus bus, - final long timeout, - final String resourceUri, - final Path ticketCache, - final List authentications - ) { - Utils.checkNonNull(winRMEndpoint, "winRMEndpoint"); - Utils.checkNonNull(bus, "bus"); - Utils.checkNonNull(authentications, "authentications"); - - this.winRMEndpoint = winRMEndpoint; - this.timeout = timeout; - this.resourceUri = resourceUri; - this.ticketCache = ticketCache; - authenticationsQueue = authentications.stream().collect(Collectors.toCollection(LinkedList::new)); - - winRMWebService = createWinRMWebService(winRMEndpoint, bus); - - final AuthCredentials authCredentials = computeCredentials(winRMEndpoint, ticketCache, authenticationsQueue); - - authentication = authCredentials.getAuthentication(); - - wsClient = - getWebServiceClient(winRMEndpoint, timeout, resourceUri, winRMWebService, authCredentials.getCredentials()); - } - - public Client getClient() { - return wsClient; - } - - @Override - public Object invoke(final Object proxy, final Method method, final Object[] args) throws Throwable { - Utils.checkNonNull(method, "method"); - - try { - return invokeMethod(method, args); - } catch (final RetryTgtExpirationException e) { - // retry with a new TGT in case of current TGT expiration - authentication = null; - - Credentials credentials; - try { - credentials = - KerberosUtils.createCredentials(winRMEndpoint.getUsername(), winRMEndpoint.getPassword(), ticketCache); - - CREDENTIALS.put(new CredentialsMapKey(winRMEndpoint, ticketCache, AuthenticationEnum.KERBEROS), credentials); - // Normally that should not happen as any other exception on KERBEROs should had been throw - // at the first KERBEROS call - } catch (final Exception e1) { - if (continueToRetry()) { - final AuthCredentials authCredentials = computeCredentials(winRMEndpoint, ticketCache, authenticationsQueue); - - authentication = authCredentials.getAuthentication(); - credentials = authCredentials.getCredentials(); - } else { - throw e1; - } - } - - wsClient = getWebServiceClient(winRMEndpoint, timeout, resourceUri, winRMWebService, credentials); - - return invoke(proxy, method, args); - } catch (final RetryAuthenticationException e) { - if (continueToRetry()) { - final AuthCredentials authCredentials = computeCredentials(winRMEndpoint, ticketCache, authenticationsQueue); - - authentication = authCredentials.getAuthentication(); - - wsClient = - getWebServiceClient(winRMEndpoint, timeout, resourceUri, winRMWebService, authCredentials.getCredentials()); - - return invoke(proxy, method, args); - } - - // No more retries - final Throwable cause = e.getCause(); - if (cause instanceof SOAPFaultException) { - throw new RuntimeException("KERBEROS with encryption over HTTP is not implemented.", cause); - } - throw cause; - } - } - - // this function is only needed for the unit testing - boolean continueToRetry() { - return !authenticationsQueue.isEmpty(); - } - - Object invokeMethod(final Method method, final Object[] args) - throws IllegalAccessException, RetryAuthenticationException { - Throwable firstEx = null; - int retry = 0; - - while (retry < MAX_RETRY) { - retry++; - - try { - return method.invoke(winRMWebService, args); - } catch (final InvocationTargetException ite) { - final Throwable targetEx = ite.getTargetException(); - - if (targetEx instanceof SOAPFaultException) { - // Could retry with a different authentication than NTLM - // because it could be a "WstxEOFException: Unexpected EOF in prolog" - // due to a KERBEROS with HTTP and AllowUnencrypted=false - if (winRMEndpoint.getProtocol() == WinRMHttpProtocolEnum.HTTP && authentication != AuthenticationEnum.NTLM) { - throw new RetryAuthenticationException(targetEx); - } - throw (SOAPFaultException) targetEx; - } - - if (!(targetEx instanceof WebServiceException)) { - throw new IllegalStateException("Failure when calling " + createCallInfos(method, args), targetEx); - } - - final WebServiceException wsEx = (WebServiceException) targetEx; - - if (!(wsEx.getCause() instanceof IOException)) { - throw new RuntimeException( - "Exception occurred while making WinRM WebService call " + createCallInfos(method, args), - wsEx - ); - } - - if ( - wsEx.getCause().getMessage() != null && - wsEx.getCause().getMessage().startsWith("Authorization loop detected on Conduit") - ) { - final RuntimeException authEx = new RuntimeException( - String.format( - "Authentication error on %s with user name \"%s\"", - winRMEndpoint.getEndpoint(), - winRMEndpoint.getRawUsername() - ) - ); - - // Could be due to a TGT expiration - if (authentication == AuthenticationEnum.KERBEROS) { - throw new RetryTgtExpirationException(authEx); - } - // Could retry with a different authentication - throw new RetryAuthenticationException(authEx); - } - - if (firstEx == null) { - firstEx = wsEx; - } - - if (retry < MAX_RETRY) { - try { - Utils.sleep(PAUSE_TIME_MILLISECONDS); - } catch (final InterruptedException ie) { - Thread.currentThread().interrupt(); - throw new RuntimeException( - "Exception occured while making WinRM WebService call " + createCallInfos(method, args), - ie - ); - } - } - } - } - - throw new RuntimeException( - String.format("failed task \"%s\" after %d attempts", createCallInfos(method, args), MAX_RETRY), - firstEx - ); - } - - static String createCallInfos(final Method method, final Object[] args) { - final String name = method != null && method.getName() != null ? method.getName() : Utils.EMPTY; - return args == null - ? name - : Stream - .concat(Stream.of(name), Stream.of(args)) - .filter(Objects::nonNull) - .map(Object::toString) - .collect(Collectors.joining(" ")); - } - - static Credentials createCredentials( - final WinRMEndpoint winRMEndpoint, - final AuthenticationEnum authentication, - final Path ticketCache - ) { - switch (authentication) { - case KERBEROS: - return KerberosUtils.createCredentials(winRMEndpoint.getUsername(), winRMEndpoint.getPassword(), ticketCache); - case NTLM: - default: - final String password = String.valueOf(winRMEndpoint.getPassword()); - return winRMEndpoint.getProtocol() == WinRMHttpProtocolEnum.HTTP - ? new NTCredentialsWithEncryption(winRMEndpoint.getUsername(), password, null, winRMEndpoint.getDomain()) - : new NTCredentials(winRMEndpoint.getUsername(), password, null, winRMEndpoint.getDomain()); - } - } - - static AuthCredentials computeCredentials( - final WinRMEndpoint winRMEndpoint, - final Path ticketCache, - final Queue authenticationsQueue - ) { - try { - final AuthenticationEnum authenticationEnum = authenticationsQueue.remove(); - - final Credentials credentials = CREDENTIALS.compute( - new CredentialsMapKey(winRMEndpoint, ticketCache, authenticationEnum), - (user, cred) -> cred != null ? cred : createCredentials(winRMEndpoint, authenticationEnum, ticketCache) - ); - - return new AuthCredentials(authenticationEnum, credentials); - } catch (final Exception e) { - // if there's still retry - if (!authenticationsQueue.isEmpty()) { - return computeCredentials(winRMEndpoint, ticketCache, authenticationsQueue); - } - throw e; - } - } - - static WinRMWebService createWinRMWebService(final WinRMEndpoint winRMEndpoint, final Bus bus) { - final JaxWsProxyFactoryBean jaxWsProxyFactoryBean = new JaxWsProxyFactoryBean(); - jaxWsProxyFactoryBean.setServiceName(SERVICE); - jaxWsProxyFactoryBean.setEndpointName(PORT); - jaxWsProxyFactoryBean.setBus(bus); - jaxWsProxyFactoryBean.setServiceClass(WinRMWebService.class); - jaxWsProxyFactoryBean.setAddress(winRMEndpoint.getEndpoint()); - jaxWsProxyFactoryBean.getFeatures().add(WS_ADDRESSING_FEATURE); - jaxWsProxyFactoryBean.setBindingId(SoapBindingConstants.SOAP12_BINDING_ID); - jaxWsProxyFactoryBean.getClientFactoryBean().getServiceFactory().setWsdlURL(WSDL_LOCATION_URL); - - return jaxWsProxyFactoryBean.create(WinRMWebService.class); - } - - static Client getWebServiceClient( - final WinRMEndpoint winRMEndpoint, - final long timeout, - final String enumerateResourceUri, - final WinRMWebService winRMWebService, - final Credentials credentials - ) { - final Client client = ClientProxy.getClient(winRMWebService); - - if (enumerateResourceUri != null) { - final WSManHeaderInterceptor interceptor = new WSManHeaderInterceptor(enumerateResourceUri); - client.getOutInterceptors().add(interceptor); - } - - client.getInInterceptors().add(new DecryptAndVerifyInInterceptor()); - client.getOutInterceptors().add(new SignAndEncryptOutInterceptor()); - - // this is different to endpoint properties - // Register the conduit factory only once: on authentication retries this method is re-invoked on the - // same client, whose cached conduit keeps using the factory it was created with. Replacing the property - // would orphan factory instances, and shutting down the in-use factory would silently downgrade the - // conduit to the synchronous transport (AsyncHTTPConduit.setupConnection checks factory.isShutdown()). - // Reusing the factory also guarantees WinRMService.close() shuts down the instance that owns the - // background threads. - final EndpointInfo endpointInfo = client.getEndpoint().getEndpointInfo(); - if ( - !(endpointInfo.getProperty(HTTPConduitFactory.class.getName()) instanceof AsyncHttpEncryptionAwareConduitFactory) - ) { - endpointInfo.setProperty(HTTPConduitFactory.class.getName(), new AsyncHttpEncryptionAwareConduitFactory()); - } - - final ServiceInfo serviceInfo = client.getEndpoint().getEndpointInfo().getService(); - serviceInfo.setProperty("soap.force.doclit.bare", true); - - final BindingProvider bindingProvider = (BindingProvider) winRMWebService; - bindingProvider.getBinding().setHandlerChain(HANDLER_CHAIN); - bindingProvider.getRequestContext().put(PolicyConstants.POLICY_OVERRIDE, POLICY); - bindingProvider.getRequestContext().put("http.autoredirect", true); - - bindingProvider.getRequestContext().put(BindingProvider.ENDPOINT_ADDRESS_PROPERTY, winRMEndpoint.getEndpoint()); - - final Map> headers = new HashMap<>(); - headers.put("Content-Type", CONTENT_TYPE_LIST); - - bindingProvider.getRequestContext().put(Message.PROTOCOL_HEADERS, headers); - - // Setup timeouts - final HTTPClientPolicy httpClientPolicy = new HTTPClientPolicy(); - httpClientPolicy.setConnectionTimeout(timeout); - httpClientPolicy.setConnectionRequestTimeout(timeout); - httpClientPolicy.setReceiveTimeout(timeout); - httpClientPolicy.setAllowChunking(false); - - bindingProvider.getRequestContext().put(Credentials.class.getName(), credentials); - bindingProvider.getRequestContext().put(AuthSchemeProvider.class.getName(), AUTH_SCHEME_REGISTRY); - - final AsyncHTTPConduit asyncHTTPConduit = (AsyncHTTPConduit) client.getConduit(); - asyncHTTPConduit.setClient(httpClientPolicy); - asyncHTTPConduit.getClient().setAutoRedirect(true); - asyncHTTPConduit.setTlsClientParameters(TLS_CLIENT_PARAMETERS); - - return client; - } - - static class RetryAuthenticationException extends Exception { - - private static final long serialVersionUID = 1L; - - RetryAuthenticationException(final Throwable throwable) { - super(throwable); - } - } - - static class RetryTgtExpirationException extends RetryAuthenticationException { - - private static final long serialVersionUID = 1L; - - RetryTgtExpirationException(final Throwable throwable) { - super(throwable); - } - } - - static class AuthCredentials { - - private final AuthenticationEnum authentication; - private final Credentials credentials; - - AuthCredentials(final AuthenticationEnum authentication, final Credentials credentials) { - this.authentication = authentication; - this.credentials = credentials; - } - - public AuthenticationEnum getAuthentication() { - return authentication; - } - - public Credentials getCredentials() { - return credentials; - } - - @Override - public int hashCode() { - return Objects.hash(authentication, credentials); - } - - @Override - public boolean equals(final Object obj) { - if (this == obj) { - return true; - } - if (obj == null) { - return false; - } - if (!(obj instanceof AuthCredentials)) { - return false; - } - final AuthCredentials other = (AuthCredentials) obj; - return authentication == other.authentication && Objects.equals(credentials, other.credentials); - } - } - - static class CredentialsMapKey { - - private final String canonizedRawUsername; - private final char[] password; - private final Path ticketCache; - private final AuthenticationEnum authentication; - - CredentialsMapKey( - final WinRMEndpoint winRMEndpoint, - final Path ticketCache, - final AuthenticationEnum authentication - ) { - this.ticketCache = ticketCache; - this.authentication = authentication; - - password = winRMEndpoint.getPassword(); - canonizedRawUsername = - winRMEndpoint.getRawUsername() != null - ? winRMEndpoint.getRawUsername().replaceAll("\\s", Utils.EMPTY).toUpperCase() - : null; - } - - @Override - public int hashCode() { - final int prime = 31; - int result = 1; - result = prime * result + Arrays.hashCode(password); - result = prime * result + Objects.hash(authentication, canonizedRawUsername, ticketCache); - return result; - } - - @Override - public boolean equals(final Object obj) { - if (this == obj) { - return true; - } - if (obj == null) { - return false; - } - if (!(obj instanceof CredentialsMapKey)) { - return false; - } - final CredentialsMapKey other = (CredentialsMapKey) obj; - return ( - authentication == other.authentication && - Objects.equals(canonizedRawUsername, other.canonizedRawUsername) && - Arrays.equals(password, other.password) && - Objects.equals(ticketCache, other.ticketCache) - ); - } - } -} diff --git a/src/main/java/org/metricshub/winrm/service/client/auth/TrustAllX509Manager.java b/src/main/java/org/metricshub/winrm/service/client/auth/TrustAllX509Manager.java deleted file mode 100644 index 30ea9be..0000000 --- a/src/main/java/org/metricshub/winrm/service/client/auth/TrustAllX509Manager.java +++ /dev/null @@ -1,43 +0,0 @@ -package org.metricshub.winrm.service.client.auth; - -/*- - * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ - * WinRM Java Client - * ჻჻჻჻჻჻ - * Copyright 2023 - 2024 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 java.security.cert.CertificateException; -import java.security.cert.X509Certificate; -import javax.net.ssl.X509TrustManager; - -public class TrustAllX509Manager implements X509TrustManager { - - @Override - public void checkClientTrusted(final X509Certificate[] chain, final String authType) throws CertificateException { - // Do nothing - } - - @Override - public void checkServerTrusted(final X509Certificate[] chain, final String authType) throws CertificateException { - // Do nothing - } - - @Override - public X509Certificate[] getAcceptedIssuers() { - return new X509Certificate[0]; - } -} diff --git a/src/main/java/org/metricshub/winrm/service/client/auth/UsernamePasswordCallbackHandler.java b/src/main/java/org/metricshub/winrm/service/client/auth/UsernamePasswordCallbackHandler.java deleted file mode 100644 index c2210b7..0000000 --- a/src/main/java/org/metricshub/winrm/service/client/auth/UsernamePasswordCallbackHandler.java +++ /dev/null @@ -1,64 +0,0 @@ -package org.metricshub.winrm.service.client.auth; - -/*- - * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ - * WinRM Java Client - * ჻჻჻჻჻჻ - * Copyright 2023 - 2024 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 java.io.IOException; -import javax.security.auth.callback.Callback; -import javax.security.auth.callback.CallbackHandler; -import javax.security.auth.callback.NameCallback; -import javax.security.auth.callback.PasswordCallback; -import javax.security.auth.callback.UnsupportedCallbackException; - -public class UsernamePasswordCallbackHandler implements CallbackHandler { - - private final String username; - private final char[] password; - - /** - * UsernamePasswordCallbackHandler constructor - * - * @param username name of the user to authenticate - * @param password The password - */ - public UsernamePasswordCallbackHandler(final String username, final char[] password) { - this.username = username; - this.password = password; - } - - @Override - public void handle(final Callback[] callbacks) throws IOException, UnsupportedCallbackException { - if (callbacks == null) { - return; - } - - for (final Callback callback : callbacks) { - if (callback instanceof NameCallback) { - final NameCallback nameCallback = (NameCallback) callback; - nameCallback.setName(username); - } else if (callback instanceof PasswordCallback) { - final PasswordCallback passwordCallback = (PasswordCallback) callback; - passwordCallback.setPassword(password); - } else { - throw new UnsupportedCallbackException(callback, "Unknown Callback"); - } - } - } -} diff --git a/src/main/java/org/metricshub/winrm/service/client/auth/kerberos/KerberosUtils.java b/src/main/java/org/metricshub/winrm/service/client/auth/kerberos/KerberosUtils.java deleted file mode 100644 index 83547c7..0000000 --- a/src/main/java/org/metricshub/winrm/service/client/auth/kerberos/KerberosUtils.java +++ /dev/null @@ -1,209 +0,0 @@ -package org.metricshub.winrm.service.client.auth.kerberos; - -/*- - * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ - * WinRM Java Client - * ჻჻჻჻჻჻ - * Copyright 2023 - 2024 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 java.nio.file.Path; -import java.security.PrivilegedAction; -import java.util.HashMap; -import java.util.Map; -import javax.security.auth.Subject; -import javax.security.auth.callback.CallbackHandler; -import javax.security.auth.login.AppConfigurationEntry; -import javax.security.auth.login.AppConfigurationEntry.LoginModuleControlFlag; -import javax.security.auth.login.Configuration; -import javax.security.auth.login.LoginContext; -import javax.security.auth.login.LoginException; -import org.apache.http.auth.KerberosCredentials; -import org.ietf.jgss.GSSContext; -import org.ietf.jgss.GSSCredential; -import org.ietf.jgss.GSSException; -import org.ietf.jgss.GSSManager; -import org.ietf.jgss.GSSName; -import org.ietf.jgss.Oid; -import org.metricshub.winrm.Utils; -import org.metricshub.winrm.exceptions.KerberosCredentialsException; -import org.metricshub.winrm.service.client.auth.UsernamePasswordCallbackHandler; - -public class KerberosUtils { - - private KerberosUtils() {} - - private static final String TRUE = Boolean.TRUE.toString(); - private static final String FALSE = Boolean.FALSE.toString(); - - private static final String DO_NOT_PROMPT = "doNotPrompt"; - private static final String USE_TICKET_CACHE = "useTicketCache"; - private static final String IS_INITIATOR = "isInitiator"; - private static final String CLIENT = "client"; - private static final String REFRESH_KRB5_CONFIG = "refreshKrb5Config"; - - /** - * Object identifier of Kerberos as mechanism used by GSS to obtain the Ticket Granting Ticket (TGT). - * @see http://oid-info.com/get/1.2.840.113554.1.2.2 - */ - private static final String KERBEROS_V5_OID = "1.2.840.113554.1.2.2"; - - private static final Configuration JAAS_CONFIG; - - static { - final Map moduleOptions = new HashMap<>(); - moduleOptions.put(REFRESH_KRB5_CONFIG, TRUE); - moduleOptions.put(CLIENT, TRUE); - moduleOptions.put(IS_INITIATOR, TRUE); - - // useTicketCache = false, The TGT cache is not used, and the user is prompted for credentials login - moduleOptions.put(USE_TICKET_CACHE, FALSE); - moduleOptions.put(DO_NOT_PROMPT, FALSE); - - JAAS_CONFIG = - new Configuration() { - @Override - public AppConfigurationEntry[] getAppConfigurationEntry(String name) { - return createAppConfigurationEntries(moduleOptions); - } - }; - } - - private static AppConfigurationEntry[] createAppConfigurationEntries(final Map moduleOptions) { - return new AppConfigurationEntry[] { - new AppConfigurationEntry( - "com.sun.security.auth.module.Krb5LoginModule", - LoginModuleControlFlag.REQUIRED, - moduleOptions - ) - }; - } - - /** - * Get Kerberos credentials (i.e a TGT) with the username and password provided. - * - * @param username The user name (mandatory) - * @param password The password (mandatory) - * @param ticketCache The Ticket Cache path - * - * @return credentials wrapping the TGT which will be used for obtaining the SPNego token - * @throws KerberosCredentialsException when an error occurred on Kerberos authentication - */ - public static KerberosCredentials createCredentials( - final String username, - final char[] password, - final Path ticketCache - ) { - Utils.checkNonNull(username, "username"); - Utils.checkNonNull(password, "password"); - - try { - // If the Kerberos Realm is in uppercases (which is the norm) and the domain in the user principal (UPN) is in - // lowercases, a KrbException: "Message stream modified" is thrown. - // To avoid this exception we force the UPN in uppercases - final String canonizedUsername = username.trim().toUpperCase(); - - final Configuration configuration = ticketCache != null - ? createConfigurationWithTicketCache(ticketCache) - : JAAS_CONFIG; - - final Subject subject = authenticate(canonizedUsername, password, configuration); - - final PrivilegedAction privilegedAction = createPrivilegedAction(canonizedUsername); - - final GSSCredential gssUserCredential = Subject.doAs(subject, privilegedAction); - - return new KerberosCredentials(gssUserCredential); - } catch (final KerberosCredentialsException e) { - throw e; - } catch (final Exception e) { - throw new KerberosCredentialsException(e); - } - } - - public static Configuration createConfigurationWithTicketCache(final Path ticketCache) { - final Map moduleOptions = new HashMap<>(); - moduleOptions.put(REFRESH_KRB5_CONFIG, TRUE); - moduleOptions.put(CLIENT, TRUE); - moduleOptions.put(IS_INITIATOR, TRUE); - - // useTicketCache = true, The default TGT cache is used, the user is not prompt for authentication - // and then failed if the user TGT is not in the cache. - moduleOptions.put(USE_TICKET_CACHE, TRUE); - moduleOptions.put(DO_NOT_PROMPT, TRUE); - moduleOptions.put("ticketCache", ticketCache.toString()); - - return new Configuration() { - @Override - public AppConfigurationEntry[] getAppConfigurationEntry(String name) { - return createAppConfigurationEntries(moduleOptions); - } - }; - } - - /** - * Authenticate the user with the provided password. The login send a request AS-REQ to the Authentication Server. - * The response will contain the TGT which will be store in the Subject. - * - * @param username name of the user to authenticate - * @param password The password - * @param configuration the {@code Configuration} lists the login modules to be called to perform the authentication - * - * @return subject of the authenticated user - */ - public static Subject authenticate(final String username, final char[] password, final Configuration configuration) { - try { - final CallbackHandler callbackHandler = new UsernamePasswordCallbackHandler(username, password); - - final LoginContext loginContext = createLoginContext(callbackHandler, configuration); - - loginContext.login(); - - return loginContext.getSubject(); - } catch (final LoginException e) { - throw new KerberosCredentialsException("Kerberos Login failure. Make sure Kerberos is properly configured.", e); - } - } - - private static PrivilegedAction createPrivilegedAction(final String username) { - return () -> { - try { - final GSSManager gssManager = GSSManager.getInstance(); - - final GSSName gssUserName = gssManager.createName(username, null); - - return gssManager.createCredential( - gssUserName, - GSSContext.DEFAULT_LIFETIME, - new Oid(KERBEROS_V5_OID), - GSSCredential.INITIATE_ONLY - ); - } catch (final GSSException e) { - throw new KerberosCredentialsException( - String.format("Unable to create credential for user \"%s\" after login", username), - e - ); - } - }; - } - - public static LoginContext createLoginContext( - final CallbackHandler callbackHandler, - final Configuration configuration - ) throws LoginException { - return new LoginContext(Utils.EMPTY, null, callbackHandler, configuration); - } -} diff --git a/src/main/java/org/metricshub/winrm/service/client/auth/ntlm/ModeEnum.java b/src/main/java/org/metricshub/winrm/service/client/auth/ntlm/ModeEnum.java deleted file mode 100644 index 070ef1a..0000000 --- a/src/main/java/org/metricshub/winrm/service/client/auth/ntlm/ModeEnum.java +++ /dev/null @@ -1,31 +0,0 @@ -package org.metricshub.winrm.service.client.auth.ntlm; - -/*- - * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ - * WinRM Java Client - * ჻჻჻჻჻჻ - * Copyright 2023 - 2024 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. - * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ - */ - -/** - * Code from io.cloudsoft.winrm4j.client.ntlm.forks.httpclient.NTLMEngineImpl - * release 0.12.3 @link https://github.com/cloudsoft/winrm4j - * io.cloudsoft.winrm4j.client.ntlm.forks.httpclient is a fork of apache-httpclient 4.5.13 - */ -enum ModeEnum { - CLIENT, - SERVER -} diff --git a/src/main/java/org/metricshub/winrm/service/client/auth/ntlm/NTCredentialsWithEncryption.java b/src/main/java/org/metricshub/winrm/service/client/auth/ntlm/NTCredentialsWithEncryption.java deleted file mode 100644 index 9193cf3..0000000 --- a/src/main/java/org/metricshub/winrm/service/client/auth/ntlm/NTCredentialsWithEncryption.java +++ /dev/null @@ -1,209 +0,0 @@ -package org.metricshub.winrm.service.client.auth.ntlm; - -/*- - * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ - * WinRM Java Client - * ჻჻჻჻჻჻ - * Copyright 2023 - 2024 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 java.util.Arrays; -import java.util.Objects; -import java.util.concurrent.atomic.AtomicLong; -import javax.crypto.Cipher; -import org.apache.http.HttpEntityEnclosingRequest; -import org.apache.http.HttpRequest; -import org.apache.http.auth.NTCredentials; -import org.metricshub.winrm.service.client.encryption.EncryptionAwareHttpEntity; -import org.metricshub.winrm.service.client.encryption.EncryptionUtils; - -/** - * NTCredentials with encryption. - * Code from io.cloudsoft.winrm4j.client.ntlm.NTCredentialsWithEncryption - * release 0.12.3 @link https://github.com/cloudsoft/winrm4j - */ -public class NTCredentialsWithEncryption extends NTCredentials { - - private static final long serialVersionUID = 1L; - - private boolean isAuthenticated = false; - private long negotiateFlags; - private byte[] clientSigningKey; - private byte[] serverSigningKey; - private byte[] clientSealingKey; - private byte[] serverSealingKey; - private AtomicLong sequenceNumberIncoming = new AtomicLong(-1); - private AtomicLong sequenceNumberOutgoing = new AtomicLong(-1); - - public NTCredentialsWithEncryption( - final String userName, - final String password, - final String workstation, - final String domain - ) { - super(userName, password, workstation, domain); - } - - public boolean isAuthenticated() { - return isAuthenticated; - } - - public void setIsAuthenticated(boolean isAuthenticated) { - this.isAuthenticated = isAuthenticated; - } - - public void setClientSigningKey(byte[] clientSigningKey) { - this.clientSigningKey = clientSigningKey; - } - - public void setServerSigningKey(byte[] serverSigningKey) { - this.serverSigningKey = serverSigningKey; - } - - public byte[] getClientSigningKey() { - return clientSigningKey; - } - - public byte[] getServerSigningKey() { - return serverSigningKey; - } - - public void setClientSealingKey(byte[] clientSealingKey) { - this.clientSealingKey = clientSealingKey; - } - - public void setServerSealingKey(byte[] serverSealingKey) { - this.serverSealingKey = serverSealingKey; - } - - public byte[] getClientSealingKey() { - return clientSealingKey; - } - - public byte[] getServerSealingKey() { - return serverSealingKey; - } - - public long getNegotiateFlags() { - return negotiateFlags; - } - - public boolean hasNegotiateFlag(long flag) { - return (getNegotiateFlags() & flag) == flag; - } - - public void setNegotiateFlags(long negotiateFlags) { - this.negotiateFlags = negotiateFlags; - } - - public AtomicLong getSequenceNumberIncoming() { - return sequenceNumberIncoming; - } - - public AtomicLong getSequenceNumberOutgoing() { - return sequenceNumberOutgoing; - } - - private transient Cipher encryptor; - - public Cipher getStatefulEncryptor() { - if (encryptor == null) { - encryptor = EncryptionUtils.arc4(getClientSealingKey()); - } - return encryptor; - } - - private transient Cipher decryptor; - - public Cipher getStatefulDecryptor() { - if (decryptor == null) { - decryptor = EncryptionUtils.arc4(getServerSealingKey()); - } - return decryptor; - } - - void resetEncryption(final HttpRequest request) { - setIsAuthenticated(false); - clientSealingKey = null; - clientSigningKey = null; - serverSealingKey = null; - serverSigningKey = null; - encryptor = null; - decryptor = null; - sequenceNumberIncoming.set(-1); - sequenceNumberOutgoing.set(-1); - - if ( - request instanceof HttpEntityEnclosingRequest && - ((HttpEntityEnclosingRequest) request).getEntity() instanceof EncryptionAwareHttpEntity - ) { - ((EncryptionAwareHttpEntity) ((HttpEntityEnclosingRequest) request).getEntity()).refreshHeaders( - (HttpEntityEnclosingRequest) request - ); - } - } - - void initEncryption(final Type3Message signAndSealData, final HttpRequest request) { - setIsAuthenticated(true); - if (signAndSealData != null && signAndSealData.getExportedSessionKey() != null) { - new NtlmKeys(signAndSealData).apply(this); - } - if ( - request instanceof HttpEntityEnclosingRequest && - ((HttpEntityEnclosingRequest) request).getEntity() instanceof EncryptionAwareHttpEntity - ) { - ((EncryptionAwareHttpEntity) ((HttpEntityEnclosingRequest) request).getEntity()).refreshHeaders( - (HttpEntityEnclosingRequest) request - ); - } - } - - @Override - public String toString() { - return getClass().getSimpleName() + super.toString() + "{auth=" + isAuthenticated() + "}"; - } - - @Override - public int hashCode() { - final int prime = 31; - int result = super.hashCode(); - result = prime * result + Arrays.hashCode(clientSealingKey); - result = prime * result + Arrays.hashCode(clientSigningKey); - result = prime * result + Arrays.hashCode(serverSealingKey); - result = prime * result + Arrays.hashCode(serverSigningKey); - result = - prime * result + Objects.hash(isAuthenticated, negotiateFlags, sequenceNumberIncoming, sequenceNumberOutgoing); - return result; - } - - @Override - public boolean equals(Object obj) { - if (this == obj) return true; - if (!super.equals(obj)) return false; - if (!(obj instanceof NTCredentialsWithEncryption)) return false; - NTCredentialsWithEncryption other = (NTCredentialsWithEncryption) obj; - return ( - Arrays.equals(clientSealingKey, other.clientSealingKey) && - Arrays.equals(clientSigningKey, other.clientSigningKey) && - isAuthenticated == other.isAuthenticated && - negotiateFlags == other.negotiateFlags && - Objects.equals(sequenceNumberIncoming, other.sequenceNumberIncoming) && - Objects.equals(sequenceNumberOutgoing, other.sequenceNumberOutgoing) && - Arrays.equals(serverSealingKey, other.serverSealingKey) && - Arrays.equals(serverSigningKey, other.serverSigningKey) - ); - } -} diff --git a/src/main/java/org/metricshub/winrm/service/client/auth/ntlm/NTLMEngine.java b/src/main/java/org/metricshub/winrm/service/client/auth/ntlm/NTLMEngine.java deleted file mode 100644 index 8cdce9c..0000000 --- a/src/main/java/org/metricshub/winrm/service/client/auth/ntlm/NTLMEngine.java +++ /dev/null @@ -1,84 +0,0 @@ -// copy of code from apache-httpclient 4.5.13 package org.apache.http.impl.auth -// changes: -// - package name, this header, imports -// - expose Type3 message (package-private) so keys can be gathered - -/* - * ==================================================================== - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); -/*- - * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ - * WinRM Java Client - * ჻჻჻჻჻჻ - * Copyright 2023 - 2024 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. - * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ - */ -package org.metricshub.winrm.service.client.auth.ntlm; - -import org.apache.http.impl.auth.NTLMEngineException; - -/** - * Abstract NTLM authentication engine. The engine can be used to - * generate Type1 messages and Type3 messages in response to a Type2 challenge. - * - * Code from io.cloudsoft.winrm4j.client.ntlm.forks.httpclient.NTLMEngine - * release 0.12.3 @link https://github.com/cloudsoft/winrm4j - * io.cloudsoft.winrm4j.client.ntlm.forks.httpclient is a fork of apache-httpclient 4.5.13 - */ -public interface NTLMEngine { - /** - * Generates a Type1 message given the domain and workstation. - * - * @param domain Optional Windows domain name. Can be {@code null}. - * @param workstation Optional Windows workstation name. Can be - * {@code null}. - * @return Type1 message - * @throws NTLMEngineException - */ - String generateType1Msg(final String domain, final String workstation) throws NTLMEngineException; - - /** - * Generates a Type3 message given the user credentials and the - * authentication challenge. - * - * @param username Windows user name - * @param password Password - * @param domain Windows domain name - * @param workstation Windows workstation name - * @param challenge Type2 challenge. - * @return Type3 response. - * @throws NTLMEngineException - */ - String generateType3Msg( - final String username, - final String password, - final String domain, - final String workstation, - final String challenge - ) throws NTLMEngineException; - - Type3Message generateType3MsgObject( - final String username, - final String password, - final String domain, - final String workstation, - final String challenge - ) throws NTLMEngineException; -} diff --git a/src/main/java/org/metricshub/winrm/service/client/auth/ntlm/NTLMEngineImpl.java b/src/main/java/org/metricshub/winrm/service/client/auth/ntlm/NTLMEngineImpl.java deleted file mode 100644 index 659cc0a..0000000 --- a/src/main/java/org/metricshub/winrm/service/client/auth/ntlm/NTLMEngineImpl.java +++ /dev/null @@ -1,114 +0,0 @@ -// copy of code from apache-httpclient 4.5.13 package org.apache.http.impl.auth -// changes: -// - package name, this header, imports -// - fix minor errors/typos -// - allow class to be extended and flags to be customized (increase many things' visibility to protected and make class non-final) -// - expose Type3 message (public) so keys can be gathered -// - expose encryption methods -// - make flags injectable to Type1 message - -/* - * ==================================================================== - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); -/*- - * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ - * WinRM Java Client - * ჻჻჻჻჻჻ - * Copyright 2023 - 2024 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. - * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ - */ -package org.metricshub.winrm.service.client.auth.ntlm; - -import org.apache.http.impl.auth.NTLMEngineException; - -/** - * Provides an implementation for NTLMv1, NTLMv2, and NTLM2 Session forms of the NTLM - * authentication protocol. - * - * Code from io.cloudsoft.winrm4j.client.ntlm.forks.httpclient.NTLMEngineImpl - * release 0.12.3 @link https://github.com/cloudsoft/winrm4j - * io.cloudsoft.winrm4j.client.ntlm.forks.httpclient is a fork of apache-httpclient 4.5.13 - */ -class NTLMEngineImpl implements NTLMEngine { - - /** Strip dot suffix from a name */ - private static String stripDotSuffix(final String value) { - if (value == null) { - return null; - } - final int index = value.indexOf('.'); - if (index != -1) { - return value.substring(0, index); - } - return value; - } - - /** Convert host to standard form */ - static String convertHost(final String host) { - return stripDotSuffix(host); - } - - /** Convert domain to standard form */ - static String convertDomain(final String domain) { - return stripDotSuffix(domain); - } - - @Override - public String generateType1Msg(final String domain, final String workstation) throws NTLMEngineException { - return new Type1Message(null, null, getDefaultFlags()).getResponse(); - } - - // function overriden in NtlmMasqAsSpnegoScheme - Integer getDefaultFlags() { - return Type1Message.getDefaultFlags(); - } - - @Override - public String generateType3Msg( - final String username, - final String password, - final String domain, - final String workstation, - final String challenge - ) throws NTLMEngineException { - return generateType3MsgObject(username, password, domain, workstation, challenge).getResponse(); - } - - @Override - public Type3Message generateType3MsgObject( - final String username, - final String password, - final String domain, - final String workstation, - final String challenge - ) throws NTLMEngineException { - final Type2Message t2m = new Type2Message(challenge); - return new Type3Message( - domain, - workstation, - username, - password, - t2m.getChallenge(), - t2m.getFlags(), - t2m.getTarget(), - t2m.getTargetInfo() - ); - } -} diff --git a/src/main/java/org/metricshub/winrm/service/client/auth/ntlm/NTLMEngineUtils.java b/src/main/java/org/metricshub/winrm/service/client/auth/ntlm/NTLMEngineUtils.java deleted file mode 100644 index f62b475..0000000 --- a/src/main/java/org/metricshub/winrm/service/client/auth/ntlm/NTLMEngineUtils.java +++ /dev/null @@ -1,85 +0,0 @@ -package org.metricshub.winrm.service.client.auth.ntlm; - -/*- - * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ - * WinRM Java Client - * ჻჻჻჻჻჻ - * Copyright 2023 - 2024 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 java.nio.charset.Charset; -import java.nio.charset.StandardCharsets; -import org.apache.http.impl.auth.NTLMEngineException; - -/** - * Code from io.cloudsoft.winrm4j.client.ntlm.forks.httpclient.NTLMEngineImpl - * release 0.12.3 @link https://github.com/cloudsoft/winrm4j - * io.cloudsoft.winrm4j.client.ntlm.forks.httpclient is a fork of apache-httpclient 4.5.13 - */ -public class NTLMEngineUtils { - - private NTLMEngineUtils() {} - - /** Unicode encoding */ - public static final Charset UNICODE_LITTLE_UNMARKED = StandardCharsets.UTF_16LE; - /** Character encoding */ - public static final Charset DEFAULT_CHARSET = StandardCharsets.US_ASCII; - - // Flags we use; descriptions according to: - // http://davenport.sourceforge.net/ntlm.html - // and - // http://msdn.microsoft.com/en-us/library/cc236650%28v=prot.20%29.aspx - // [MS-NLMP] section 2.2.2.5 - static final int FLAG_REQUEST_UNICODE_ENCODING = 0x00000001; // Unicode string encoding requested - static final int FLAG_REQUEST_SIGN = 0x00000010; // Requests all messages have a signature attached, in NEGOTIATE message. - static final int FLAG_REQUEST_LAN_MANAGER_KEY = 0x00000080; // Request Lan Manager key instead of user session key - static final int FLAG_REQUEST_NTLM_V1 = 0x00000200; // Request NTLMv1 security. MUST be set in NEGOTIATE and CHALLENGE both - static final int FLAG_REQUEST_ALWAYS_SIGN = 0x00008000; // Requests a signature block on all messages. Overridden by REQUEST_SIGN and REQUEST_SEAL. - static final int FLAG_REQUEST_NTLM2_SESSION = 0x00080000; // From server in challenge, requesting NTLM2 session security - static final int FLAG_REQUEST_VERSION = 0x02000000; // Request protocol version - static final int FLAG_TARGETINFO_PRESENT = 0x00800000; // From server in challenge message, indicating targetinfo is present - static final int FLAG_REQUEST_128BIT_KEY_EXCH = 0x20000000; // Request explicit 128-bit key exchange - static final int FLAG_REQUEST_EXPLICIT_KEY_EXCH = 0x40000000; // Request explicit key exchange - static final int FLAG_REQUEST_56BIT_ENCRYPTION = 0x80000000; // Must be used in conjunction with SEAL - - // Code from io.cloudsoft.winrm4j.client.ntlm.NtlmKeys.NegotiateFlags - // release 0.12.3 @link https://github.com/cloudsoft/winrm4j - // expanded set of what is in NTLMEngineImpl - // 0b 10100010_10001010_10000010_00000101 - // 0b 10100010_00001000_10000010_00110001 - public static final long NTLMSSP_NEGOTIATE_EXTENDED_SESSIONSECURITY = 0x00080000L; - public static final long NTLMSSP_NEGOTIATE_KEY_EXCH = 0x40000000L; - static final long NTLMSSP_NEGOTIATE_SEAL = 0x00000020L; - static final long NTLMSSP_NEGOTIATE_SIGN = 0x00000010L; - static final long NTLMSSP_NEGOTIATE_56 = 0x80000000L; - static final long NTLMSSP_NEGOTIATE_128 = 0x20000000L; - static final long NTLMSSP_NEGOTIATE_LM_KEY = 0x00000080L; - - /** - * Find the character set based on the flags. - * @param flags is the flags. - * @return the character set. - */ - static Charset getCharset(final int flags) throws NTLMEngineException { - if ((flags & FLAG_REQUEST_UNICODE_ENCODING) == 0) { - return DEFAULT_CHARSET; - } - if (UNICODE_LITTLE_UNMARKED == null) { - throw new NTLMEngineException("Unicode not supported"); - } - return UNICODE_LITTLE_UNMARKED; - } -} diff --git a/src/main/java/org/metricshub/winrm/service/client/auth/ntlm/NTLMMessage.java b/src/main/java/org/metricshub/winrm/service/client/auth/ntlm/NTLMMessage.java deleted file mode 100644 index 9a87f7a..0000000 --- a/src/main/java/org/metricshub/winrm/service/client/auth/ntlm/NTLMMessage.java +++ /dev/null @@ -1,181 +0,0 @@ -package org.metricshub.winrm.service.client.auth.ntlm; - -/*- - * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ - * WinRM Java Client - * ჻჻჻჻჻჻ - * Copyright 2023 - 2024 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 org.apache.commons.codec.binary.Base64; -import org.apache.http.impl.auth.NTLMEngineException; - -/** - * NTLM message generation, base class - * - * Code from io.cloudsoft.winrm4j.client.ntlm.forks.httpclient.NTLMEngineImpl - * release 0.12.3 @link https://github.com/cloudsoft/winrm4j - * io.cloudsoft.winrm4j.client.ntlm.forks.httpclient is a fork of apache-httpclient 4.5.13 - */ -class NTLMMessage { - - /** The signature string as bytes in the default encoding */ - private static final byte[] SIGNATURE; - - static { - final byte[] bytesWithoutNull = "NTLMSSP".getBytes(NTLMEngineUtils.DEFAULT_CHARSET); - final byte[] target = new byte[bytesWithoutNull.length + 1]; - System.arraycopy(bytesWithoutNull, 0, target, 0, bytesWithoutNull.length); - target[bytesWithoutNull.length] = (byte) 0x00; - - SIGNATURE = target; - } - - /** The current response */ - protected byte[] messageContents = null; - - /** The current output position */ - protected int currentOutputPosition = 0; - - /** Constructor to use when message contents are not yet known */ - NTLMMessage() {} - - /** Constructor to use when message bytes are known */ - NTLMMessage(final byte[] message, final int expectedType) throws NTLMEngineException { - messageContents = message; - // Look for NTLM message - if (messageContents.length < SIGNATURE.length) { - throw new NTLMEngineException("NTLM message decoding error - packet too short"); - } - int i = 0; - while (i < SIGNATURE.length) { - if (messageContents[i] != SIGNATURE[i]) { - throw new NTLMEngineException("NTLM message expected - instead got unrecognized bytes"); - } - i++; - } - - // Check to be sure there's a type 2 message indicator next - final int type = readULong(SIGNATURE.length); - if (type != expectedType) { - throw new NTLMEngineException( - String.format("NTLM type %d message expected - instead got type %d", expectedType, type) - ); - } - - currentOutputPosition = messageContents.length; - } - - /** Read a ulong from a position within the message buffer */ - int readULong(final int position) { - return readULong(messageContents, position); - } - - static int readULong(final byte[] src, final int index) { - if (src.length < index + 4) { - return 0; - } - return ( - (src[index] & 0xff) | - ((src[index + 1] & 0xff) << 8) | - ((src[index + 2] & 0xff) << 16) | - ((src[index + 3] & 0xff) << 24) - ); - } - - /** - * Prepares the object to create a response of the given length. - * - * @param maxlength - * the maximum length of the response to prepare, - * including the type and the signature (which this method - * adds). - */ - void prepareResponse(final int maxlength, final int messageType) { - messageContents = new byte[maxlength]; - currentOutputPosition = 0; - addBytes(SIGNATURE); - addULong(messageType); - } - - /** - * Adds the given byte to the response. - * - * @param b - * the byte to add. - */ - private void addByte(final byte b) { - messageContents[currentOutputPosition] = b; - currentOutputPosition++; - } - - /** - * Adds the given bytes to the response. - * - * @param bytes - * the bytes to add. - */ - void addBytes(final byte[] bytes) { - if (bytes == null) { - return; - } - for (final byte b : bytes) { - messageContents[currentOutputPosition] = b; - currentOutputPosition++; - } - } - - /** Adds a USHORT to the response */ - void addUShort(final int value) { - addByte((byte) (value & 0xff)); - addByte((byte) ((value >> 8) & 0xff)); - } - - /** Adds a ULong to the response */ - void addULong(final int value) { - addByte((byte) (value & 0xff)); - addByte((byte) ((value >> 8) & 0xff)); - addByte((byte) ((value >> 16) & 0xff)); - addByte((byte) ((value >> 24) & 0xff)); - } - - /** - * Returns the response that has been generated after shrinking the - * array if required and base64 encodes the response. - * - * @return The response as above. - */ - String getResponse() { - return new String(Base64.encodeBase64(getBytes()), NTLMEngineUtils.DEFAULT_CHARSET); - } - - private byte[] getBytes() { - if (messageContents == null) { - buildMessage(); - } - - if (messageContents.length > currentOutputPosition) { - final byte[] tmp = new byte[currentOutputPosition]; - System.arraycopy(messageContents, 0, tmp, 0, currentOutputPosition); - messageContents = tmp; - } - return messageContents; - } - - protected void buildMessage() { - throw new RuntimeException("Message builder not implemented for " + getClass().getName()); - } -} diff --git a/src/main/java/org/metricshub/winrm/service/client/auth/ntlm/NTLMScheme.java b/src/main/java/org/metricshub/winrm/service/client/auth/ntlm/NTLMScheme.java deleted file mode 100644 index 5f1b0c2..0000000 --- a/src/main/java/org/metricshub/winrm/service/client/auth/ntlm/NTLMScheme.java +++ /dev/null @@ -1,181 +0,0 @@ -// copy of code from apache-httpclient 4.5.13 package org.apache.http.impl.auth -// changes: -// - package name, this header, imports -// - gather NTLM signing key and attach to context - -/* - * ==================================================================== - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); -/*- - * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ - * WinRM Java Client - * ჻჻჻჻჻჻ - * Copyright 2023 - 2024 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. - * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ - */ -package org.metricshub.winrm.service.client.auth.ntlm; - -import org.apache.http.Header; -import org.apache.http.HttpRequest; -import org.apache.http.auth.AUTH; -import org.apache.http.auth.AuthenticationException; -import org.apache.http.auth.Credentials; -import org.apache.http.auth.InvalidCredentialsException; -import org.apache.http.auth.MalformedChallengeException; -import org.apache.http.auth.NTCredentials; -import org.apache.http.impl.auth.AuthSchemeBase; -import org.apache.http.message.BufferedHeader; -import org.apache.http.protocol.HttpContext; -import org.apache.http.util.CharArrayBuffer; -import org.metricshub.winrm.Utils; - -/** - * NTLM is a proprietary authentication scheme developed by Microsoft - * and optimized for Windows platforms. - * - * Code from io.cloudsoft.winrm4j.client.ntlm.forks.httpclient.NTLMScheme - * release 0.12.3 @link https://github.com/cloudsoft/winrm4j - * io.cloudsoft.winrm4j.client.ntlm.forks.httpclient is a fork of apache-httpclient 4.5.13 - */ -public class NTLMScheme extends AuthSchemeBase { - - private enum State { - UNINITIATED, - CHALLENGE_RECEIVED, - MSG_TYPE1_GENERATED, - MSG_TYPE2_RECEVIED, - MSG_TYPE3_GENERATED, - FAILED - } - - private final NTLMEngine engine; - - private State state; - private String challenge; - - public NTLMScheme(final NTLMEngine engine) { - super(); - Utils.checkNonNull(engine, "engine"); - this.engine = engine; - state = State.UNINITIATED; - challenge = null; - } - - @Override - public String getSchemeName() { - return "ntlm"; - } - - @Override - public String getParameter(final String name) { - // String parameters not supported - return null; - } - - @Override - public String getRealm() { - // NTLM does not support the concept of an authentication realm - return null; - } - - @Override - public boolean isConnectionBased() { - return true; - } - - @Override - protected void parseChallenge(final CharArrayBuffer buffer, final int beginIndex, final int endIndex) - throws MalformedChallengeException { - challenge = buffer.substringTrimmed(beginIndex, endIndex); - if (challenge.isEmpty()) { - if (state == State.UNINITIATED) { - state = State.CHALLENGE_RECEIVED; - } else { - state = State.FAILED; - } - } else { - if (state.compareTo(State.MSG_TYPE1_GENERATED) < 0) { - state = State.FAILED; - throw new MalformedChallengeException("Out of sequence NTLM response message"); - } else if (state == State.MSG_TYPE1_GENERATED) { - state = State.MSG_TYPE2_RECEVIED; - } - } - } - - @Override - public Header authenticate(final Credentials credentials, final HttpRequest request) throws AuthenticationException { - NTCredentials ntcredentials = null; - try { - ntcredentials = (NTCredentials) credentials; - } catch (final ClassCastException e) { - throw new InvalidCredentialsException( - "Credentials cannot be used for NTLM authentication: " + credentials.getClass().getName() - ); - } - String response = null; - if (state == State.FAILED) { - throw new AuthenticationException("NTLM authentication failed"); - } else if (state == State.CHALLENGE_RECEIVED) { - response = this.engine.generateType1Msg(ntcredentials.getDomain(), ntcredentials.getWorkstation()); - state = State.MSG_TYPE1_GENERATED; - - if (credentials instanceof NTCredentialsWithEncryption) { - ((NTCredentialsWithEncryption) credentials).resetEncryption(request); - } - } else if (state == State.MSG_TYPE2_RECEVIED) { - final Type3Message responseO = engine.generateType3MsgObject( - ntcredentials.getUserName(), - ntcredentials.getPassword(), - ntcredentials.getDomain(), - ntcredentials.getWorkstation(), - challenge - ); - - response = responseO.getResponse(); - state = State.MSG_TYPE3_GENERATED; - if (credentials instanceof NTCredentialsWithEncryption) { - ((NTCredentialsWithEncryption) credentials).initEncryption(responseO, request); - } - } else { - throw new AuthenticationException("Unexpected state: " + state); - } - final CharArrayBuffer buffer = new CharArrayBuffer(32); - if (isProxy()) { - buffer.append(AUTH.PROXY_AUTH_RESP); - } else { - buffer.append(AUTH.WWW_AUTH_RESP); - } - buffer.append(": NTLM "); - buffer.append(response); - return new BufferedHeader(buffer); - } - - @Override - public boolean isComplete() { - return state == State.MSG_TYPE3_GENERATED || state == State.FAILED; - } - - @Override - public Header authenticate(final Credentials credentials, final HttpRequest request, final HttpContext context) - throws AuthenticationException { - return authenticate(credentials, request); - } -} diff --git a/src/main/java/org/metricshub/winrm/service/client/auth/ntlm/NtlmKeys.java b/src/main/java/org/metricshub/winrm/service/client/auth/ntlm/NtlmKeys.java deleted file mode 100644 index 0ee7d1f..0000000 --- a/src/main/java/org/metricshub/winrm/service/client/auth/ntlm/NtlmKeys.java +++ /dev/null @@ -1,117 +0,0 @@ -package org.metricshub.winrm.service.client.auth.ntlm; - -/*- - * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ - * WinRM Java Client - * ჻჻჻჻჻჻ - * Copyright 2023 - 2024 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 java.util.Arrays; -import org.metricshub.winrm.service.client.encryption.ByteArrayUtils; -import org.metricshub.winrm.service.client.encryption.EncryptionUtils; - -/** - * Code from io.cloudsoft.winrm4j.client.ntlm.NtlmKeys - * release 0.12.3 @link https://github.com/cloudsoft/winrm4j - */ -public class NtlmKeys { - - // adapted from python ntlm-auth - // also see NTLMEngineImpl.Handle - - // # Copyright: (c) 2018, Jordan Borean (@jborean93) - // # MIT License (see LICENSE or https://opensource.org/licenses/MIT) - - private static final byte[] CLIENT_SIGNING = - "session key to client-to-server signing key magic constant\0".getBytes(); - private static final byte[] SERVER_SIGNING = - "session key to server-to-client signing key magic constant\0".getBytes(); - private static final byte[] CLIENT_SEALING = - "session key to client-to-server sealing key magic constant\0".getBytes(); - private static final byte[] SERVER_SEALING = - "session key to server-to-client sealing key magic constant\0".getBytes(); - - private final byte[] exportedSessionKey; - private final long negotiateFlags; - - public NtlmKeys(final Type3Message signAndSealData) { - exportedSessionKey = signAndSealData.getExportedSessionKey(); - negotiateFlags = signAndSealData.getType2Flags(); - } - - public void apply(final NTCredentialsWithEncryption credentials) { - credentials.setNegotiateFlags(negotiateFlags); - - credentials.setClientSigningKey(getSignKey(CLIENT_SIGNING)); - credentials.setServerSigningKey(getSignKey(SERVER_SIGNING)); - credentials.setClientSealingKey(getSealKey(CLIENT_SEALING)); - credentials.setServerSealingKey(getSealKey(SERVER_SEALING)); - } - - /** - * - * @param magicConstant a constant value set in the MS-NLMP documentation (constants.SignSealConstants) - * - * @return Key used to sign messages - */ - private byte[] getSignKey(final byte[] magicConstant) { - return EncryptionUtils.md5digest(ByteArrayUtils.concat(exportedSessionKey, magicConstant)); - } - - /** - * Main method to use to calculate the seal_key used to seal (encrypt) messages. - * This will determine the correct method below to use based on the compatibility flags set - * and should be called instead of the others - * - * @param magicConstant a constant value set in the MS-NLMP documentation (constants.SignSealConstants) - * - * @return Key used to seal messages - */ - private byte[] getSealKey(final byte[] magicConstant) { - // This for authentication where NTLMSSP_NEGOTIATE_EXTENDED_SESSIONSECURITY has been - // negotiated. Will weaken the keys if NTLMSSP_NEGOTIATE_128 is not negotiated, - // will try NEGOTIATE_56 and then will default to the 40-bit key - if (hasNegotiateFlag(NTLMEngineUtils.NTLMSSP_NEGOTIATE_EXTENDED_SESSIONSECURITY)) { - if (hasNegotiateFlag(NTLMEngineUtils.NTLMSSP_NEGOTIATE_128)) { - return EncryptionUtils.md5digest(ByteArrayUtils.concat(exportedSessionKey, magicConstant)); - } - if (hasNegotiateFlag(NTLMEngineUtils.NTLMSSP_NEGOTIATE_56)) { - return EncryptionUtils.md5digest( - ByteArrayUtils.concat(Arrays.copyOfRange(exportedSessionKey, 0, 7), magicConstant) - ); - } - return EncryptionUtils.md5digest( - ByteArrayUtils.concat(Arrays.copyOfRange(exportedSessionKey, 0, 5), magicConstant) - ); - } - - // This for authentication where NTLMSSP_NEGOTIATE_EXTENDED_SESSIONSECURITY - // has not been negotiated. Will weaken the keys if NTLMSSP_NEGOTIATE_56 is not negotiated it will default - // to the 40-bit key. - if (hasNegotiateFlag(NTLMEngineUtils.NTLMSSP_NEGOTIATE_LM_KEY)) { - throw new UnsupportedOperationException( - "LM KEY negotiate mode not implemented; use extended session security instead" - ); - } - - return exportedSessionKey; - } - - private boolean hasNegotiateFlag(long flag) { - return (negotiateFlags & flag) == flag; - } -} diff --git a/src/main/java/org/metricshub/winrm/service/client/auth/ntlm/NtlmMasqAsSpnegoScheme.java b/src/main/java/org/metricshub/winrm/service/client/auth/ntlm/NtlmMasqAsSpnegoScheme.java deleted file mode 100644 index 6dec13d..0000000 --- a/src/main/java/org/metricshub/winrm/service/client/auth/ntlm/NtlmMasqAsSpnegoScheme.java +++ /dev/null @@ -1,70 +0,0 @@ -package org.metricshub.winrm.service.client.auth.ntlm; - -/*- - * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ - * WinRM Java Client - * ჻჻჻჻჻჻ - * Copyright 2023 - 2024 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 java.util.function.LongUnaryOperator; -import org.apache.http.Header; -import org.apache.http.HttpRequest; -import org.apache.http.auth.AuthenticationException; -import org.apache.http.auth.Credentials; -import org.apache.http.client.config.AuthSchemes; -import org.apache.http.message.BasicHeader; - -/** - * Code from io.cloudsoft.winrm4j.client.ntlm.NtlmMasqAsSpnegoScheme - * release 0.12.3 @link https://github.com/cloudsoft/winrm4j - */ -public class NtlmMasqAsSpnegoScheme extends NTLMScheme { - - private static final LongUnaryOperator FLAG_MODIFIER = flags -> - flags | - NTLMEngineUtils.NTLMSSP_NEGOTIATE_SIGN | - NTLMEngineUtils.NTLMSSP_NEGOTIATE_SEAL | - NTLMEngineUtils.NTLMSSP_NEGOTIATE_KEY_EXCH; - - public NtlmMasqAsSpnegoScheme() { - super(newDefaultNtlmEngine()); - } - - private static NTLMEngine newDefaultNtlmEngine() { - return new NTLMEngineImpl() { - @Override - public Integer getDefaultFlags() { - final Long flags = (long) Type1Message.getDefaultFlags(); - return (int) FLAG_MODIFIER.applyAsLong(flags); - } - }; - } - - @Override - public String getSchemeName() { - return AuthSchemes.SPNEGO; - } - - @Override - public Header authenticate(final Credentials credentials, final HttpRequest httpRequest) - throws AuthenticationException { - final Header header = super.authenticate(credentials, httpRequest); - - // code from winrm4j implementation: https://github.com/cloudsoft/winrm4j - return new BasicHeader(header.getName(), header.getValue().replace("NTLM", getSchemeName())); - } -} diff --git a/src/main/java/org/metricshub/winrm/service/client/auth/ntlm/NtlmMasqAsSpnegoSchemeFactory.java b/src/main/java/org/metricshub/winrm/service/client/auth/ntlm/NtlmMasqAsSpnegoSchemeFactory.java deleted file mode 100644 index 9404efe..0000000 --- a/src/main/java/org/metricshub/winrm/service/client/auth/ntlm/NtlmMasqAsSpnegoSchemeFactory.java +++ /dev/null @@ -1,37 +0,0 @@ -package org.metricshub.winrm.service.client.auth.ntlm; - -/*- - * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ - * WinRM Java Client - * ჻჻჻჻჻჻ - * Copyright 2023 - 2024 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 org.apache.http.auth.AuthScheme; -import org.apache.http.impl.auth.NTLMSchemeFactory; -import org.apache.http.protocol.HttpContext; - -/** - * Code from io.cloudsoft.winrm4j.client.ntlm.NtlmMasqAsSpnegoSchemeFactory - * release 0.12.3 @link https://github.com/cloudsoft/winrm4j - */ -public class NtlmMasqAsSpnegoSchemeFactory extends NTLMSchemeFactory { - - @Override - public AuthScheme create(HttpContext context) { - return new NtlmMasqAsSpnegoScheme(); - } -} diff --git a/src/main/java/org/metricshub/winrm/service/client/auth/ntlm/Type1Message.java b/src/main/java/org/metricshub/winrm/service/client/auth/ntlm/Type1Message.java deleted file mode 100644 index 1a3412a..0000000 --- a/src/main/java/org/metricshub/winrm/service/client/auth/ntlm/Type1Message.java +++ /dev/null @@ -1,119 +0,0 @@ -package org.metricshub.winrm.service.client.auth.ntlm; - -/*- - * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ - * WinRM Java Client - * ჻჻჻჻჻჻ - * Copyright 2023 - 2024 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 java.util.Locale; - -/** - * Type 1 message assembly class - * - * Code from io.cloudsoft.winrm4j.client.ntlm.forks.httpclient.NTLMEngineImpl - * release 0.12.3 @link https://github.com/cloudsoft/winrm4j - * io.cloudsoft.winrm4j.client.ntlm.forks.httpclient is a fork of apache-httpclient 4.5.13 - */ -class Type1Message extends NTLMMessage { - - private final byte[] hostBytes; - private final byte[] domainBytes; - private final int flags; - - Type1Message(final String domain, final String host, final Integer flags) { - super(); - this.flags = flags == null ? getDefaultFlags() : flags; - - // Strip off domain name from the host! - final String unqualifiedHost = NTLMEngineImpl.convertHost(host); - // Use only the base domain name! - final String unqualifiedDomain = NTLMEngineImpl.convertDomain(domain); - - hostBytes = unqualifiedHost != null ? unqualifiedHost.getBytes(NTLMEngineUtils.UNICODE_LITTLE_UNMARKED) : null; - domainBytes = - unqualifiedDomain != null - ? unqualifiedDomain.toUpperCase(Locale.ROOT).getBytes(NTLMEngineUtils.UNICODE_LITTLE_UNMARKED) - : null; - } - - static int getDefaultFlags() { - return ( // Required flags - NTLMEngineUtils.FLAG_REQUEST_NTLM_V1 | - NTLMEngineUtils.FLAG_REQUEST_NTLM2_SESSION | - NTLMEngineUtils.FLAG_REQUEST_VERSION | - NTLMEngineUtils.FLAG_REQUEST_ALWAYS_SIGN | - NTLMEngineUtils.FLAG_REQUEST_128BIT_KEY_EXCH | - NTLMEngineUtils.FLAG_REQUEST_56BIT_ENCRYPTION | - NTLMEngineUtils.FLAG_REQUEST_UNICODE_ENCODING - ); - } - - /** - * Getting the response involves building the message before returning it - */ - @Override - protected void buildMessage() { - int domainBytesLength = 0; - if (domainBytes != null) { - domainBytesLength = domainBytes.length; - } - int hostBytesLength = 0; - if (hostBytes != null) { - hostBytesLength = hostBytes.length; - } - - // Now, build the message. Calculate its length first, including signature or type. - final int finalLength = 32 + 8 + hostBytesLength + domainBytesLength; - - // Set up the response. This will initialize the signature, message, type, and flags. - prepareResponse(finalLength, 1); - - // Flags. These are the complete set of flags we support. - addULong(flags); - - // Domain length (two times). - addUShort(domainBytesLength); - addUShort(domainBytesLength); - - // Domain offset. - addULong(hostBytesLength + 32 + 8); - - // Host length (two times). - addUShort(hostBytesLength); - addUShort(hostBytesLength); - - // Host offset (always 32 + 8). - addULong(32 + 8); - - // Version - addUShort(0x0105); - // Build - addULong(2600); - // NTLM revision - addUShort(0x0f00); - - // Host (workstation) String. - if (hostBytes != null) { - addBytes(hostBytes); - } - // Domain String. - if (domainBytes != null) { - addBytes(domainBytes); - } - } -} diff --git a/src/main/java/org/metricshub/winrm/service/client/auth/ntlm/Type2Message.java b/src/main/java/org/metricshub/winrm/service/client/auth/ntlm/Type2Message.java deleted file mode 100644 index 8f92c3d..0000000 --- a/src/main/java/org/metricshub/winrm/service/client/auth/ntlm/Type2Message.java +++ /dev/null @@ -1,139 +0,0 @@ -package org.metricshub.winrm.service.client.auth.ntlm; - -/*- - * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ - * WinRM Java Client - * ჻჻჻჻჻჻ - * Copyright 2023 - 2024 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 org.apache.commons.codec.binary.Base64; -import org.apache.http.impl.auth.NTLMEngineException; - -/** - * Type 2 message class - * - * Code from io.cloudsoft.winrm4j.client.ntlm.forks.httpclient.NTLMEngineImpl - * release 0.12.3 @link https://github.com/cloudsoft/winrm4j - * io.cloudsoft.winrm4j.client.ntlm.forks.httpclient is a fork of apache-httpclient 4.5.13 - */ -class Type2Message extends NTLMMessage { - - private final byte[] challenge; - private String target; - private byte[] targetInfo; - private final int flags; - - Type2Message(final String messageBody) throws NTLMEngineException { - this(Base64.decodeBase64(messageBody.getBytes(NTLMEngineUtils.DEFAULT_CHARSET))); - } - - private Type2Message(final byte[] message) throws NTLMEngineException { - super(message, 2); - // Type 2 message is laid out as follows: - // First 8 bytes: NTLMSSP[0] - // Next 4 bytes: Ulong, value 2 - // Next 8 bytes, starting at offset 12: target field (2 ushort lengths, 1 ulong offset) - // Next 4 bytes, starting at offset 20: Flags, e.g. 0x22890235 - // Next 8 bytes, starting at offset 24: Challenge - // Next 8 bytes, starting at offset 32: ??? (8 bytes of zeros) - // Next 8 bytes, starting at offset 40: targetinfo field (2 ushort lengths, 1 ulong offset) - // Next 2 bytes, major/minor version number (e.g. 0x05 0x02) - // Next 8 bytes, build number - // Next 2 bytes, protocol version number (e.g. 0x00 0x0f) - // Next, various text fields, and a ushort of value 0 at the end - - // Parse out the rest of the info we need from the message - // The nonce is the 8 bytes starting from the byte in position 24. - challenge = new byte[8]; - readBytes(challenge, 24); - - flags = readULong(20); - - // Do the target! - target = null; - // The TARGET_DESIRED flag is said to not have understood semantics - // in Type2 messages, so use the length of the packet to decide how to proceed instead - if (getMessageLength() >= 12 + 8) { - final byte[] bytes = readSecurityBuffer(12); - if (bytes.length != 0) { - target = new String(bytes, NTLMEngineUtils.getCharset(flags)); - } - } - - // Do the target info! - targetInfo = null; - // TARGET_DESIRED flag cannot be relied on, so use packet length - if (getMessageLength() >= 40 + 8) { - final byte[] bytes = readSecurityBuffer(40); - if (bytes.length != 0) { - targetInfo = bytes; - } - } - } - - /** Get the message length */ - private int getMessageLength() { - return currentOutputPosition; - } - - /** Read a bunch of bytes from a position in the message buffer */ - private void readBytes(final byte[] buffer, final int position) throws NTLMEngineException { - if (messageContents.length < position + buffer.length) { - throw new NTLMEngineException("NTLM: Message too short"); - } - System.arraycopy(messageContents, position, buffer, 0, buffer.length); - } - - /** Read a security buffer from a position within the message buffer */ - private byte[] readSecurityBuffer(final int position) { - final int length = readUShort(messageContents, position); - final int offset = readULong(messageContents, position + 4); - if (messageContents.length < offset + length) { - return new byte[length]; - } - final byte[] buffer = new byte[length]; - System.arraycopy(messageContents, offset, buffer, 0, length); - return buffer; - } - - private static int readUShort(final byte[] src, final int index) { - if (src.length < index + 2) { - return 0; - } - return (src[index] & 0xff) | ((src[index + 1] & 0xff) << 8); - } - - /** Retrieve the challenge */ - byte[] getChallenge() { - return challenge; - } - - /** Retrieve the target */ - String getTarget() { - return target; - } - - /** Retrieve the target info */ - byte[] getTargetInfo() { - return targetInfo; - } - - /** Retrieve the response flags */ - int getFlags() { - return flags; - } -} diff --git a/src/main/java/org/metricshub/winrm/service/client/auth/ntlm/Type3Message.java b/src/main/java/org/metricshub/winrm/service/client/auth/ntlm/Type3Message.java deleted file mode 100644 index 235302a..0000000 --- a/src/main/java/org/metricshub/winrm/service/client/auth/ntlm/Type3Message.java +++ /dev/null @@ -1,301 +0,0 @@ -package org.metricshub.winrm.service.client.auth.ntlm; - -/*- - * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ - * WinRM Java Client - * ჻჻჻჻჻჻ - * Copyright 2023 - 2024 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 java.nio.charset.Charset; -import java.util.Locale; -import java.util.Random; -import org.apache.http.impl.auth.NTLMEngineException; -import org.metricshub.winrm.service.client.encryption.CipherGen; -import org.metricshub.winrm.service.client.encryption.EncryptionUtils; - -/** - * Type 3 message assembly class - * - * Code from io.cloudsoft.winrm4j.client.ntlm.forks.httpclient.NTLMEngineImpl - * release 0.12.3 @link https://github.com/cloudsoft/winrm4j - * io.cloudsoft.winrm4j.client.ntlm.forks.httpclient is a fork of apache-httpclient 4.5.13 - */ -public class Type3Message extends NTLMMessage { - - /** Secure random generator */ - static final java.security.SecureRandom RND_GEN; - - static { - java.security.SecureRandom rnd = null; - try { - rnd = java.security.SecureRandom.getInstance("SHA1PRNG"); - } catch (final Exception ignore) {} - RND_GEN = rnd; - } - - // Response flags from the type2 message - private final int type2Flags; - - private final byte[] domainBytes; - private final byte[] hostBytes; - private final byte[] userBytes; - - private byte[] lmResp; - private byte[] ntResp; - private final byte[] sessionKey; - private final byte[] exportedSessionKey; - - /** More primitive constructor: don't include cert or previous messages. - */ - Type3Message( - final String domain, - final String host, - final String user, - final String password, - final byte[] nonce, - final int type2Flags, - final String target, - final byte[] targetInformation - ) throws NTLMEngineException { - final Random random = RND_GEN; - if (random == null) { - throw new NTLMEngineException("Random generator not available"); - } - - final long currentTime = System.currentTimeMillis(); - - // Save the flags - this.type2Flags = type2Flags; - - // Strip off domain name from the host! - final String unqualifiedHost = NTLMEngineImpl.convertHost(host); - // Use only the base domain name! - final String unqualifiedDomain = NTLMEngineImpl.convertDomain(domain); - - byte[] responseTargetInformation = targetInformation; - - // Create a cipher generator class. Use domain BEFORE it gets modified! - final CipherGen gen = new CipherGen( - random, - currentTime, - unqualifiedDomain, - user, - password, - nonce, - target, - responseTargetInformation - ); - - // Use the new code to calculate the responses, including v2 if that - // seems warranted. - byte[] userSessionKey; - try { - // This conditional may not work on Windows Server 2008 R2 and above, where it has not yet - // been tested - if ( - ((type2Flags & NTLMEngineUtils.FLAG_TARGETINFO_PRESENT) != 0) && targetInformation != null && target != null - ) { - // NTLMv2 - ntResp = gen.getNTLMv2Response(); - lmResp = gen.getLMv2Response(); - if ((type2Flags & NTLMEngineUtils.FLAG_REQUEST_LAN_MANAGER_KEY) != 0) { - userSessionKey = gen.getLanManagerSessionKey(); - } else { - userSessionKey = gen.getNTLMv2UserSessionKey(); - } - } else { - // NTLMv1 - if ((type2Flags & NTLMEngineUtils.FLAG_REQUEST_NTLM2_SESSION) != 0) { - // NTLM2 session stuff is requested - ntResp = gen.getNTLM2SessionResponse(); - lmResp = gen.getLM2SessionResponse(); - if ((type2Flags & NTLMEngineUtils.FLAG_REQUEST_LAN_MANAGER_KEY) != 0) { - userSessionKey = gen.getLanManagerSessionKey(); - } else { - userSessionKey = gen.getNTLM2SessionResponseUserSessionKey(); - } - } else { - ntResp = gen.getNTLMResponse(); - lmResp = gen.getLMResponse(); - if ((type2Flags & NTLMEngineUtils.FLAG_REQUEST_LAN_MANAGER_KEY) != 0) { - userSessionKey = gen.getLanManagerSessionKey(); - } else { - userSessionKey = gen.getNTLMUserSessionKey(); - } - } - } - } catch (final NTLMEngineException e) { - // This likely means we couldn't find the MD4 hash algorithm - - // fail back to just using LM - ntResp = new byte[0]; - lmResp = gen.getLMResponse(); - if ((type2Flags & NTLMEngineUtils.FLAG_REQUEST_LAN_MANAGER_KEY) != 0) { - userSessionKey = gen.getLanManagerSessionKey(); - } else { - userSessionKey = gen.getLMUserSessionKey(); - } - } - - if ((type2Flags & NTLMEngineUtils.FLAG_REQUEST_SIGN) != 0) { - if ((type2Flags & NTLMEngineUtils.FLAG_REQUEST_EXPLICIT_KEY_EXCH) != 0) { - exportedSessionKey = gen.getSecondaryKey(); - try { - sessionKey = EncryptionUtils.calculateRC4(exportedSessionKey, userSessionKey); - } catch (final Exception e) { - throw new NTLMEngineException(e.getMessage(), e); - } - } else { - sessionKey = userSessionKey; - exportedSessionKey = sessionKey; - } - } else { - sessionKey = null; - exportedSessionKey = null; - } - final Charset charset = NTLMEngineUtils.getCharset(type2Flags); - hostBytes = unqualifiedHost != null ? unqualifiedHost.getBytes(charset) : null; - domainBytes = unqualifiedDomain != null ? unqualifiedDomain.toUpperCase(Locale.ROOT).getBytes(charset) : null; - userBytes = user.getBytes(charset); - } - - int getType2Flags() { - return type2Flags; - } - - byte[] getExportedSessionKey() { - return exportedSessionKey; - } - - /** Assemble the response */ - @Override - protected void buildMessage() { - final int ntRespLen = ntResp.length; - final int lmRespLen = lmResp.length; - - final int domainLen = domainBytes != null ? domainBytes.length : 0; - final int hostLen = hostBytes != null ? hostBytes.length : 0; - final int userLen = userBytes.length; - final int sessionKeyLen; - if (sessionKey != null) { - sessionKeyLen = sessionKey.length; - } else { - sessionKeyLen = 0; - } - - // Calculate the layout within the packet - final int lmRespOffset = 72; // allocate space for the version - final int ntRespOffset = lmRespOffset + lmRespLen; - final int domainOffset = ntRespOffset + ntRespLen; - final int userOffset = domainOffset + domainLen; - final int hostOffset = userOffset + userLen; - final int sessionKeyOffset = hostOffset + hostLen; - final int finalLength = sessionKeyOffset + sessionKeyLen; - - // Start the response. Length includes signature and type - prepareResponse(finalLength, 3); - - // LM Resp Length (twice) - addUShort(lmRespLen); - addUShort(lmRespLen); - - // LM Resp Offset - addULong(lmRespOffset); - - // NT Resp Length (twice) - addUShort(ntRespLen); - addUShort(ntRespLen); - - // NT Resp Offset - addULong(ntRespOffset); - - // Domain length (twice) - addUShort(domainLen); - addUShort(domainLen); - - // Domain offset. - addULong(domainOffset); - - // User Length (twice) - addUShort(userLen); - addUShort(userLen); - - // User offset - addULong(userOffset); - - // Host length (twice) - addUShort(hostLen); - addUShort(hostLen); - - // Host offset - addULong(hostOffset); - - // Session key length (twice) - addUShort(sessionKeyLen); - addUShort(sessionKeyLen); - - // Session key offset - addULong(sessionKeyOffset); - - // Flags. - addULong( - /* - //FLAG_WORKSTATION_PRESENT | - //FLAG_DOMAIN_PRESENT | - - // Required flags - (type2Flags & FLAG_REQUEST_LAN_MANAGER_KEY) | - (type2Flags & FLAG_REQUEST_NTLMv1) | - (type2Flags & FLAG_REQUEST_NTLM2_SESSION) | - - // Protocol version request - FLAG_REQUEST_VERSION | - - // Recommended privacy settings - (type2Flags & FLAG_REQUEST_ALWAYS_SIGN) | - (type2Flags & FLAG_REQUEST_SEAL) | - (type2Flags & FLAG_REQUEST_SIGN) | - - // These must be set according to documentation, based on use of SEAL above - (type2Flags & FLAG_REQUEST_128BIT_KEY_EXCH) | - (type2Flags & FLAG_REQUEST_56BIT_ENCRYPTION) | - (type2Flags & FLAG_REQUEST_EXPLICIT_KEY_EXCH) | - - (type2Flags & FLAG_TARGETINFO_PRESENT) | - (type2Flags & FLAG_REQUEST_UNICODE_ENCODING) | - (type2Flags & FLAG_REQUEST_TARGET) - */ - type2Flags - ); - - // Version - addUShort(0x0105); - // Build - addULong(2600); - // NTLM revision - addUShort(0x0f00); - - // Add the actual data - addBytes(lmResp); - addBytes(ntResp); - addBytes(domainBytes); - addBytes(userBytes); - addBytes(hostBytes); - if (sessionKey != null) { - addBytes(sessionKey); - } - } -} diff --git a/src/main/java/org/metricshub/winrm/service/client/encryption/AsyncHttpEncryptionAwareConduit.java b/src/main/java/org/metricshub/winrm/service/client/encryption/AsyncHttpEncryptionAwareConduit.java deleted file mode 100644 index 0f83e7d..0000000 --- a/src/main/java/org/metricshub/winrm/service/client/encryption/AsyncHttpEncryptionAwareConduit.java +++ /dev/null @@ -1,180 +0,0 @@ -package org.metricshub.winrm.service.client.encryption; - -/*- - * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ - * WinRM Java Client - * ჻჻჻჻჻჻ - * Copyright 2023 - 2024 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 java.io.ByteArrayInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.net.URI; -import java.util.Arrays; -import java.util.List; -import org.apache.cxf.Bus; -import org.apache.cxf.io.CacheAndWriteOutputStream; -import org.apache.cxf.message.Message; -import org.apache.cxf.service.model.EndpointInfo; -import org.apache.cxf.transport.http.Address; -import org.apache.cxf.transport.http.asyncclient.AsyncHTTPConduit; -import org.apache.cxf.transport.http.asyncclient.CXFHttpRequest; -import org.apache.cxf.transports.http.configuration.HTTPClientPolicy; -import org.apache.cxf.ws.addressing.EndpointReferenceType; -import org.apache.http.auth.Credentials; -import org.apache.http.client.config.AuthSchemes; -import org.apache.http.client.config.RequestConfig; -import org.apache.http.entity.BasicHttpEntity; - -/** - * Creates an output stream which sends back the appropriate encrypted or unencrypted stream, - * based on the {SignAndEncryptOutInterceptor} -- which normally does the right thing, - * but during auth events it will "guess" wrongly, and we have to change the payload and - * the headers. {io.cloudsoft.winrm4j.client.ntlm.NTCredentialsWithEncryption} will do - * that by finding the {@link EncryptionAwareHttpEntity}. - * - * Code from io.cloudsoft.winrm4j.client.encryption.AsyncHttpEncryptionAwareConduit - * release 0.12.3 @link https://github.com/cloudsoft/winrm4j - */ -public class AsyncHttpEncryptionAwareConduit extends AsyncHTTPConduit { - - static final byte[] PRE_AUTH_BOGUS_PAYLOAD = "AWAITING_ENCRYPTION_KEYS".getBytes(); - - private static final List TARGET_AUTH_SCHEMES = Arrays.asList(AuthSchemes.SPNEGO, AuthSchemes.KERBEROS); - - private static ContentWithType getAppropriate(final Message msg) { - final EncryptAndSignOutputStream encryptingStream = msg.getContent(EncryptAndSignOutputStream.class); - if (encryptingStream == null) { - throw new IllegalStateException("No SignAndEncryptOutInterceptor applied to message"); - } - return encryptingStream.getAppropriate(); - } - - public AsyncHttpEncryptionAwareConduit( - final Bus bus, - final EndpointInfo endpointInfo, - final EndpointReferenceType endpointReferenceType, - final AsyncHttpEncryptionAwareConduitFactory factory - ) throws IOException { - super(bus, endpointInfo, endpointReferenceType, factory); - } - - @Override - protected OutputStream createOutputStream( - final Message message, - final boolean needToCacheRequest, - final boolean isChunking, - final int chunkThreshold - ) throws IOException { - final NtlmEncryptionUtils encryptor = NtlmEncryptionUtils.of(message.get(Credentials.class)); - if (encryptor == null) { - return super.createOutputStream(message, needToCacheRequest, isChunking, chunkThreshold); - } - - if (Boolean.TRUE.equals(message.get(USE_ASYNC))) { - // copied from super, but for our class - final CXFHttpRequest requestEntity = message.get(CXFHttpRequest.class); - final AsyncWrappedEncryptionAwareOutputStream out = new AsyncWrappedEncryptionAwareOutputStream( - message, - true, - false, - chunkThreshold, - getConduitName(), - requestEntity.getURI() - ); - - requestEntity.setOutputStream(out); - return out; - } - - throw new IllegalStateException("Encryption only available with ASYNC at present"); - // if needed could also subclass the URL stream used by super.super.createOutput - } - - @Override - protected void setupConnection(final Message message, final Address address, final HTTPClientPolicy csPolicy) - throws IOException { - super.setupConnection(message, address, csPolicy); - - // replace similar logic in super method, but with a refreshHeaders method available - - final CXFHttpRequest requestEntity = message.get(CXFHttpRequest.class); - - final BasicHttpEntity entity = new EncryptionAwareHttpEntity() { - @Override - public boolean isRepeatable() { - return requestEntity.getEntity().isRepeatable(); - } - - @Override - protected ContentWithType getAppropriate() { - return AsyncHttpEncryptionAwareConduit.getAppropriate(message); - } - }; - entity.setChunked(true); - entity.setContentType((String) message.get(Message.CONTENT_TYPE)); - - requestEntity.setEntity(entity); - - requestEntity.setConfig( - RequestConfig.copy(requestEntity.getConfig()).setTargetPreferredAuthSchemes(TARGET_AUTH_SCHEMES).build() - ); - } - - private class AsyncWrappedEncryptionAwareOutputStream extends AsyncWrappedOutputStream { - - public AsyncWrappedEncryptionAwareOutputStream( - final Message message, - final boolean needToCacheRequest, - final boolean isChunking, - final int chunkThreshold, - final String conduitName, - final URI uri - ) { - super(message, needToCacheRequest, isChunking, chunkThreshold, conduitName, uri); - } - - @Override - protected void setupWrappedStream() throws IOException { - super.setupWrappedStream(); - - if (!(cachedStream.getFlowThroughStream() instanceof EncryptionAwareCacheAndWriteOutputStream)) { - cachedStream = new EncryptionAwareCacheAndWriteOutputStream(cachedStream.getFlowThroughStream()); - wrappedStream = cachedStream; - } - } - - private class EncryptionAwareCacheAndWriteOutputStream extends CacheAndWriteOutputStream { - - public EncryptionAwareCacheAndWriteOutputStream(OutputStream outbufFlowThroughStream) { - super(outbufFlowThroughStream); - } - - @Override - public byte[] getBytes() throws IOException { - final ContentWithType appropriate = AsyncHttpEncryptionAwareConduit.getAppropriate(outMessage); - return appropriate.getPayload(); - } - - @Override - public InputStream getInputStream() throws IOException { - return new ByteArrayInputStream(getBytes()); - } - } - } -} diff --git a/src/main/java/org/metricshub/winrm/service/client/encryption/AsyncHttpEncryptionAwareConduitFactory.java b/src/main/java/org/metricshub/winrm/service/client/encryption/AsyncHttpEncryptionAwareConduitFactory.java deleted file mode 100644 index 0d7ec69..0000000 --- a/src/main/java/org/metricshub/winrm/service/client/encryption/AsyncHttpEncryptionAwareConduitFactory.java +++ /dev/null @@ -1,46 +0,0 @@ -package org.metricshub.winrm.service.client.encryption; - -/*- - * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ - * WinRM Java Client - * ჻჻჻჻჻჻ - * Copyright 2023 - 2024 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 java.io.IOException; -import java.util.Map; -import org.apache.cxf.Bus; -import org.apache.cxf.service.model.EndpointInfo; -import org.apache.cxf.transport.http.HTTPConduit; -import org.apache.cxf.transport.http.asyncclient.AsyncHTTPConduitFactory; -import org.apache.cxf.ws.addressing.EndpointReferenceType; - -/** - * Code from io.cloudsoft.winrm4j.client.encryption.AsyncHttpEncryptionAwareConduitFactory - * release 0.12.3 @link https://github.com/cloudsoft/winrm4j - */ -public class AsyncHttpEncryptionAwareConduitFactory extends AsyncHTTPConduitFactory { - - public AsyncHttpEncryptionAwareConduitFactory() { - super((Map) null); - } - - @Override - public HTTPConduit createConduit(final Bus bus, final EndpointInfo localInfo, final EndpointReferenceType target) - throws IOException { - return isShutdown() ? null : new AsyncHttpEncryptionAwareConduit(bus, localInfo, target, this); - } -} diff --git a/src/main/java/org/metricshub/winrm/service/client/encryption/ByteArrayUtils.java b/src/main/java/org/metricshub/winrm/service/client/encryption/ByteArrayUtils.java deleted file mode 100644 index 8b8d620..0000000 --- a/src/main/java/org/metricshub/winrm/service/client/encryption/ByteArrayUtils.java +++ /dev/null @@ -1,104 +0,0 @@ -package org.metricshub.winrm.service.client.encryption; - -/*- - * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ - * WinRM Java Client - * ჻჻჻჻჻჻ - * Copyright 2023 - 2024 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 java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.nio.ByteBuffer; -import java.nio.ByteOrder; -import org.metricshub.winrm.Utils; - -/** - * Code from io.cloudsoft.winrm4j.client.encryption.ByteArrayUtils - * release 0.12.3 @link https://github.com/cloudsoft/winrm4j - */ -public class ByteArrayUtils { - - private ByteArrayUtils() {} - - private static final int WIDTH = 32; - - public static String formatHexDump(final byte[] array) { - if (array == null) { - return "null"; - } - - // from https://gist.github.com/jen20/906db194bd97c14d91df - - final StringBuilder builder = new StringBuilder(); - - for (int rowOffset = 0; rowOffset < array.length; rowOffset += WIDTH) { - builder.append(String.format("%06d: ", rowOffset)); - - for (int index = 0; index < WIDTH; index++) { - if (rowOffset + index < array.length) { - builder.append(String.format("%02x", array[rowOffset + index])); - } else { - builder.append(" "); - } - - if (index % 4 == 3) { - builder.append(" "); - } - } - - if (rowOffset < array.length) { - builder.append(" | "); - for (int index = 0; index < WIDTH; index++) { - if (rowOffset + index < array.length) { - final byte c = array[rowOffset + index]; - builder.append((c >= 20 && c < 127) ? (char) c : '.'); - - if (index % 8 == 7) builder.append(" "); - } - } - } - - builder.append(Utils.NEW_LINE); - } - - return builder.toString(); - } - - public static byte[] getLittleEndianUnsignedInt(final long x) { - final ByteBuffer byteBuffer = ByteBuffer.allocate(4); - byteBuffer.order(ByteOrder.LITTLE_ENDIAN); - byteBuffer.putInt((int) (x & 0xFFFFFFFF)); - return byteBuffer.array(); - } - - public static long readLittleEndianUnsignedInt(final byte[] input, final int offset) { - final ByteBuffer byteBuffer = ByteBuffer.wrap(input); - byteBuffer.order(ByteOrder.LITTLE_ENDIAN); - return Integer.toUnsignedLong(byteBuffer.getInt(offset)); - } - - public static byte[] concat(final byte[]... sequences) { - try (final ByteArrayOutputStream out = new ByteArrayOutputStream()) { - for (byte[] s : sequences) { - out.write(s); - } - return out.toByteArray(); - } catch (final IOException e) { - throw new IllegalStateException(e); - } - } -} diff --git a/src/main/java/org/metricshub/winrm/service/client/encryption/CipherGen.java b/src/main/java/org/metricshub/winrm/service/client/encryption/CipherGen.java deleted file mode 100644 index 2afbec5..0000000 --- a/src/main/java/org/metricshub/winrm/service/client/encryption/CipherGen.java +++ /dev/null @@ -1,593 +0,0 @@ -package org.metricshub.winrm.service.client.encryption; - -/*- - * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ - * WinRM Java Client - * ჻჻჻჻჻჻ - * Copyright 2023 - 2024 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 java.security.Key; -import java.security.MessageDigest; -import java.util.Arrays; -import java.util.Locale; -import java.util.Random; -import javax.crypto.Cipher; -import javax.crypto.spec.SecretKeySpec; -import org.apache.http.impl.auth.NTLMEngineException; -import org.metricshub.winrm.service.client.auth.ntlm.NTLMEngineUtils; - -/** - * Code from io.cloudsoft.winrm4j.client.ntlm.forks.httpclient.NTLMEngineImpl - * release 0.12.3 @link https://github.com/cloudsoft/winrm4j - * io.cloudsoft.winrm4j.client.ntlm.forks.httpclient is a fork of apache-httpclient 4.5.13 - */ -public class CipherGen { - - private final Random random; - private final long currentTime; - - private final String domain; - private final String user; - private final String password; - private final byte[] challenge; - private final byte[] targetInformation; - - // Information we can generate but may be passed in (for testing) - private byte[] clientChallenge; - private byte[] clientChallenge2; - private byte[] secondaryKey; - private byte[] timestamp; - - // Stuff we always generate - private byte[] lmHash = null; - private byte[] lmResponse = null; - private byte[] ntlmHash = null; - private byte[] ntlmResponse = null; - private byte[] ntlmv2Hash = null; - private byte[] lmv2Hash = null; - private byte[] lmv2Response = null; - private byte[] ntlmv2Blob = null; - private byte[] ntlmv2Response = null; - private byte[] ntlm2SessionResponse = null; - private byte[] lm2SessionResponse = null; - private byte[] lmUserSessionKey = null; - private byte[] ntlmUserSessionKey = null; - private byte[] ntlmv2UserSessionKey = null; - private byte[] ntlm2SessionResponseUserSessionKey = null; - private byte[] lanManagerSessionKey = null; - - public CipherGen( - final Random random, - final long currentTime, - final String domain, - final String user, - final String password, - final byte[] challenge, - final String target, - final byte[] targetInformation - ) { - this.random = random; - this.currentTime = currentTime; - - this.domain = domain; - this.user = user; - this.password = password; - this.challenge = challenge; - this.targetInformation = targetInformation; - } - - /** Calculate and return client challenge */ - private byte[] getClientChallenge() { - if (clientChallenge == null) { - clientChallenge = makeRandomChallenge(random); - } - return clientChallenge; - } - - /** Calculate and return second client challenge */ - private byte[] getClientChallenge2() { - if (clientChallenge2 == null) { - clientChallenge2 = makeRandomChallenge(random); - } - return clientChallenge2; - } - - /** Calculate and return random secondary key */ - public byte[] getSecondaryKey() { - if (secondaryKey == null) { - secondaryKey = makeSecondaryKey(random); - } - return secondaryKey; - } - - /** Calculate and return the LMHash */ - private byte[] getLMHash() throws NTLMEngineException { - if (lmHash == null) { - lmHash = lmHash(password); - } - return lmHash; - } - - /** Calculate and return the LMResponse */ - public byte[] getLMResponse() throws NTLMEngineException { - if (lmResponse == null) { - lmResponse = lmResponse(getLMHash(), challenge); - } - return lmResponse; - } - - /** Calculate and return the NTLMHash */ - private byte[] getNTLMHash() throws NTLMEngineException { - if (ntlmHash == null) { - ntlmHash = ntlmHash(password); - } - return ntlmHash; - } - - /** Calculate and return the NTLMResponse */ - public byte[] getNTLMResponse() throws NTLMEngineException { - if (ntlmResponse == null) { - ntlmResponse = lmResponse(getNTLMHash(), challenge); - } - return ntlmResponse; - } - - /** Calculate the LMv2 hash */ - private byte[] getLMv2Hash() throws NTLMEngineException { - if (lmv2Hash == null) { - lmv2Hash = lmv2Hash(domain, user, getNTLMHash()); - } - return lmv2Hash; - } - - /** Calculate the NTLMv2 hash */ - private byte[] getNTLMv2Hash() throws NTLMEngineException { - if (ntlmv2Hash == null) { - ntlmv2Hash = ntlmv2Hash(domain, user, getNTLMHash()); - } - return ntlmv2Hash; - } - - /** Calculate a timestamp */ - private byte[] getTimestamp() { - if (timestamp == null) { - long time = this.currentTime; - time += 11644473600000l; // milliseconds from January 1, 1601 -> epoch. - time *= 10000; // tenths of a microsecond. - // convert to little-endian byte array. - timestamp = new byte[8]; - for (int i = 0; i < 8; i++) { - timestamp[i] = (byte) time; - time >>>= 8; - } - } - return timestamp; - } - - /** Calculate the NTLMv2Blob */ - private byte[] getNTLMv2Blob() { - if (ntlmv2Blob == null) { - ntlmv2Blob = createBlob(getClientChallenge2(), targetInformation, getTimestamp()); - } - return ntlmv2Blob; - } - - /** - * Creates the NTLMv2 blob from the given target information block and - * client challenge. - * - * @param targetInformation - * The target information block from the Type 2 message. - * @param clientChallenge - * The random 8-byte client challenge. - * - * @return The blob, used in the calculation of the NTLMv2 Response. - */ - private static byte[] createBlob( - final byte[] clientChallenge, - final byte[] targetInformation, - final byte[] timestamp - ) { - final byte[] blobSignature = new byte[] { (byte) 0x01, (byte) 0x01, (byte) 0x00, (byte) 0x00 }; - final byte[] reserved = new byte[] { (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00 }; - final byte[] unknown1 = new byte[] { (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00 }; - final byte[] unknown2 = new byte[] { (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00 }; - final byte[] blob = new byte[blobSignature.length + - reserved.length + - timestamp.length + - 8 + - unknown1.length + - targetInformation.length + - unknown2.length]; - int offset = 0; - System.arraycopy(blobSignature, 0, blob, offset, blobSignature.length); - offset += blobSignature.length; - System.arraycopy(reserved, 0, blob, offset, reserved.length); - offset += reserved.length; - System.arraycopy(timestamp, 0, blob, offset, timestamp.length); - offset += timestamp.length; - System.arraycopy(clientChallenge, 0, blob, offset, 8); - offset += 8; - System.arraycopy(unknown1, 0, blob, offset, unknown1.length); - offset += unknown1.length; - System.arraycopy(targetInformation, 0, blob, offset, targetInformation.length); - offset += targetInformation.length; - System.arraycopy(unknown2, 0, blob, offset, unknown2.length); - offset += unknown2.length; - return blob; - } - - /** Calculate the NTLMv2Response */ - public byte[] getNTLMv2Response() throws NTLMEngineException { - if (ntlmv2Response == null) { - ntlmv2Response = lmv2Response(getNTLMv2Hash(), challenge, getNTLMv2Blob()); - } - return ntlmv2Response; - } - - /** Calculate the LMv2Response */ - public byte[] getLMv2Response() throws NTLMEngineException { - if (lmv2Response == null) { - lmv2Response = lmv2Response(getLMv2Hash(), challenge, getClientChallenge()); - } - return lmv2Response; - } - - /** Get NTLM2SessionResponse */ - public byte[] getNTLM2SessionResponse() throws NTLMEngineException { - if (ntlm2SessionResponse == null) { - ntlm2SessionResponse = ntlm2SessionResponse(getNTLMHash(), challenge, getClientChallenge()); - } - return ntlm2SessionResponse; - } - - /** - * Calculates the NTLM2 Session Response for the given challenge, using the - * specified password and client challenge. - * - * @param ntlmHash - * @param challenge - * @param clientChallenge - * @return The NTLM2 Session Response. This is placed in the NTLM response - * field of the Type 3 message; the LM response field contains the - * client challenge, null-padded to 24 bytes. - */ - private static byte[] ntlm2SessionResponse( - final byte[] ntlmHash, - final byte[] challenge, - final byte[] clientChallenge - ) throws NTLMEngineException { - try { - final MessageDigest md5 = EncryptionUtils.getMD5(); - md5.update(challenge); - md5.update(clientChallenge); - final byte[] digest = md5.digest(); - - final byte[] sessionHash = new byte[8]; - System.arraycopy(digest, 0, sessionHash, 0, 8); - return lmResponse(ntlmHash, sessionHash); - } catch (final NTLMEngineException e) { - throw (NTLMEngineException) e; - } catch (final Exception e) { - throw new NTLMEngineException(e.getMessage(), e); - } - } - - /** - * Creates the LM Response from the given hash and Type 2 challenge. - * - * @param hash - * The LM or NTLM Hash. - * @param challenge - * The server challenge from the Type 2 message. - * - * @return The response (either LM or NTLM, depending on the provided hash). - */ - private static byte[] lmResponse(final byte[] hash, final byte[] challenge) throws NTLMEngineException { - try { - final byte[] keyBytes = new byte[21]; - System.arraycopy(hash, 0, keyBytes, 0, 16); - final Key lowKey = createDESKey(keyBytes, 0); - final Key middleKey = createDESKey(keyBytes, 7); - final Key highKey = createDESKey(keyBytes, 14); - final Cipher des = Cipher.getInstance("DES/ECB/NoPadding"); - des.init(Cipher.ENCRYPT_MODE, lowKey); - final byte[] lowResponse = des.doFinal(challenge); - des.init(Cipher.ENCRYPT_MODE, middleKey); - final byte[] middleResponse = des.doFinal(challenge); - des.init(Cipher.ENCRYPT_MODE, highKey); - final byte[] highResponse = des.doFinal(challenge); - final byte[] lmResponse = new byte[24]; - System.arraycopy(lowResponse, 0, lmResponse, 0, 8); - System.arraycopy(middleResponse, 0, lmResponse, 8, 8); - System.arraycopy(highResponse, 0, lmResponse, 16, 8); - return lmResponse; - } catch (final Exception e) { - throw new NTLMEngineException(e.getMessage(), e); - } - } - - /** - * Creates the LM Hash of the user's password. - * - * @param password - * The password. - * - * @return The LM Hash of the given password, used in the calculation of the - * LM Response. - */ - private static byte[] lmHash(final String password) throws NTLMEngineException { - try { - final byte[] oemPassword = password.toUpperCase(Locale.ROOT).getBytes(NTLMEngineUtils.DEFAULT_CHARSET); - - final int length = Math.min(oemPassword.length, 14); - final byte[] keyBytes = new byte[14]; - System.arraycopy(oemPassword, 0, keyBytes, 0, length); - final Key lowKey = createDESKey(keyBytes, 0); - final Key highKey = createDESKey(keyBytes, 7); - final byte[] magicConstant = "KGS!@#$%".getBytes(NTLMEngineUtils.DEFAULT_CHARSET); - final Cipher des = Cipher.getInstance("DES/ECB/NoPadding"); - des.init(Cipher.ENCRYPT_MODE, lowKey); - final byte[] lowHash = des.doFinal(magicConstant); - des.init(Cipher.ENCRYPT_MODE, highKey); - final byte[] highHash = des.doFinal(magicConstant); - final byte[] lmHash = new byte[16]; - System.arraycopy(lowHash, 0, lmHash, 0, 8); - System.arraycopy(highHash, 0, lmHash, 8, 8); - return lmHash; - } catch (final Exception e) { - throw new NTLMEngineException(e.getMessage(), e); - } - } - - /** - * Creates a DES encryption key from the given key material. - * - * @param bytes - * A byte array containing the DES key material. - * @param offset - * The offset in the given byte array at which the 7-byte key - * material starts. - * - * @return A DES encryption key created from the key material starting at - * the specified offset in the given byte array. - */ - private static Key createDESKey(final byte[] bytes, final int offset) { - final byte[] keyBytes = new byte[7]; - System.arraycopy(bytes, offset, keyBytes, 0, 7); - final byte[] material = new byte[8]; - material[0] = keyBytes[0]; - material[1] = (byte) ((keyBytes[0] << 7) | ((keyBytes[1] & 0xff) >>> 1)); - material[2] = (byte) ((keyBytes[1] << 6) | ((keyBytes[2] & 0xff) >>> 2)); - material[3] = (byte) ((keyBytes[2] << 5) | ((keyBytes[3] & 0xff) >>> 3)); - material[4] = (byte) ((keyBytes[3] << 4) | ((keyBytes[4] & 0xff) >>> 4)); - material[5] = (byte) ((keyBytes[4] << 3) | ((keyBytes[5] & 0xff) >>> 5)); - material[6] = (byte) ((keyBytes[5] << 2) | ((keyBytes[6] & 0xff) >>> 6)); - material[7] = (byte) (keyBytes[6] << 1); - oddParity(material); - return new SecretKeySpec(material, "DES"); - } - - /** - * Applies odd parity to the given byte array. - * - * @param bytes - * The data whose parity bits are to be adjusted for odd parity. - */ - private static void oddParity(final byte[] bytes) { - for (int i = 0; i < bytes.length; i++) { - final byte b = bytes[i]; - final boolean needsParity = - (((b >>> 7) ^ (b >>> 6) ^ (b >>> 5) ^ (b >>> 4) ^ (b >>> 3) ^ (b >>> 2) ^ (b >>> 1)) & 0x01) == 0; - if (needsParity) { - bytes[i] |= (byte) 0x01; - } else { - bytes[i] &= (byte) 0xfe; - } - } - } - - /** Calculate and return LM2 session response */ - public byte[] getLM2SessionResponse() { - if (lm2SessionResponse == null) { - final byte[] clntChallenge = getClientChallenge(); - lm2SessionResponse = new byte[24]; - System.arraycopy(clntChallenge, 0, lm2SessionResponse, 0, clntChallenge.length); - Arrays.fill(lm2SessionResponse, clntChallenge.length, lm2SessionResponse.length, (byte) 0x00); - } - return lm2SessionResponse; - } - - /** Get LMUserSessionKey */ - public byte[] getLMUserSessionKey() throws NTLMEngineException { - if (lmUserSessionKey == null) { - lmUserSessionKey = new byte[16]; - System.arraycopy(getLMHash(), 0, lmUserSessionKey, 0, 8); - Arrays.fill(lmUserSessionKey, 8, 16, (byte) 0x00); - } - return lmUserSessionKey; - } - - /** Get NTLMUserSessionKey */ - public byte[] getNTLMUserSessionKey() throws NTLMEngineException { - if (ntlmUserSessionKey == null) { - final MD4 md4 = new MD4(); - md4.update(getNTLMHash()); - ntlmUserSessionKey = md4.getOutput(); - } - return ntlmUserSessionKey; - } - - /** GetNTLMv2UserSessionKey */ - public byte[] getNTLMv2UserSessionKey() throws NTLMEngineException { - if (ntlmv2UserSessionKey == null) { - final byte[] ntlmv2hash = getNTLMv2Hash(); - final byte[] truncatedResponse = new byte[16]; - System.arraycopy(getNTLMv2Response(), 0, truncatedResponse, 0, 16); - ntlmv2UserSessionKey = hmacMD5(truncatedResponse, ntlmv2hash); - } - return ntlmv2UserSessionKey; - } - - /** Get NTLM2SessionResponseUserSessionKey */ - public byte[] getNTLM2SessionResponseUserSessionKey() throws NTLMEngineException { - if (ntlm2SessionResponseUserSessionKey == null) { - final byte[] ntlm2SessionResponseNonce = getLM2SessionResponse(); - final byte[] sessionNonce = new byte[challenge.length + ntlm2SessionResponseNonce.length]; - System.arraycopy(challenge, 0, sessionNonce, 0, challenge.length); - System.arraycopy(ntlm2SessionResponseNonce, 0, sessionNonce, challenge.length, ntlm2SessionResponseNonce.length); - ntlm2SessionResponseUserSessionKey = hmacMD5(sessionNonce, getNTLMUserSessionKey()); - } - return ntlm2SessionResponseUserSessionKey; - } - - /** Get LAN Manager session key */ - public byte[] getLanManagerSessionKey() throws NTLMEngineException { - if (lanManagerSessionKey == null) { - try { - final byte[] keyBytes = new byte[14]; - System.arraycopy(getLMHash(), 0, keyBytes, 0, 8); - Arrays.fill(keyBytes, 8, keyBytes.length, (byte) 0xbd); - final Key lowKey = createDESKey(keyBytes, 0); - final Key highKey = createDESKey(keyBytes, 7); - final byte[] truncatedResponse = new byte[8]; - System.arraycopy(getLMResponse(), 0, truncatedResponse, 0, truncatedResponse.length); - Cipher des = Cipher.getInstance("DES/ECB/NoPadding"); - des.init(Cipher.ENCRYPT_MODE, lowKey); - final byte[] lowPart = des.doFinal(truncatedResponse); - des = Cipher.getInstance("DES/ECB/NoPadding"); - des.init(Cipher.ENCRYPT_MODE, highKey); - final byte[] highPart = des.doFinal(truncatedResponse); - lanManagerSessionKey = new byte[16]; - System.arraycopy(lowPart, 0, lanManagerSessionKey, 0, lowPart.length); - System.arraycopy(highPart, 0, lanManagerSessionKey, lowPart.length, highPart.length); - } catch (final Exception e) { - throw new NTLMEngineException(e.getMessage(), e); - } - } - return lanManagerSessionKey; - } - - /** - * Creates the NTLM Hash of the user's password. - * - * @param password - * The password. - * - * @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 NTLMEngineException { - if (NTLMEngineUtils.UNICODE_LITTLE_UNMARKED == null) { - throw new NTLMEngineException("Unicode not supported"); - } - final byte[] unicodePassword = password.getBytes(NTLMEngineUtils.UNICODE_LITTLE_UNMARKED); - final MD4 md4 = new MD4(); - md4.update(unicodePassword); - return md4.getOutput(); - } - - /** - * Creates the LMv2 Hash of the user's password. - * - * @return The LMv2 Hash, used in the calculation of the NTLMv2 and LMv2 - * Responses. - */ - private static byte[] lmv2Hash(final String domain, final String user, final byte[] ntlmHash) - throws NTLMEngineException { - if (NTLMEngineUtils.UNICODE_LITTLE_UNMARKED == null) { - throw new NTLMEngineException("Unicode not supported"); - } - final HMACMD5 hmacMD5 = new HMACMD5(ntlmHash); - // Upper case username, upper case domain! - hmacMD5.update(user.toUpperCase(Locale.ROOT).getBytes(NTLMEngineUtils.UNICODE_LITTLE_UNMARKED)); - if (domain != null) { - hmacMD5.update(domain.toUpperCase(Locale.ROOT).getBytes(NTLMEngineUtils.UNICODE_LITTLE_UNMARKED)); - } - return hmacMD5.getOutput(); - } - - /** - * Creates the NTLMv2 Hash of the user's password. - * - * @return The NTLMv2 Hash, used in the calculation of the NTLMv2 and LMv2 - * Responses. - */ - private static byte[] ntlmv2Hash(final String domain, final String user, final byte[] ntlmHash) - throws NTLMEngineException { - if (NTLMEngineUtils.UNICODE_LITTLE_UNMARKED == null) { - throw new NTLMEngineException("Unicode not supported"); - } - final HMACMD5 hmacMD5 = new HMACMD5(ntlmHash); - // Upper case username, mixed case target!! - hmacMD5.update(user.toUpperCase(Locale.ROOT).getBytes(NTLMEngineUtils.UNICODE_LITTLE_UNMARKED)); - if (domain != null) { - hmacMD5.update(domain.getBytes(NTLMEngineUtils.UNICODE_LITTLE_UNMARKED)); - } - return hmacMD5.getOutput(); - } - - /** - * Creates the LMv2 Response from the given hash, client data, and Type 2 - * challenge. - * - * @param hash - * The NTLMv2 Hash. - * @param clientData - * The client data (blob or client challenge). - * @param challenge - * The server challenge from the Type 2 message. - * - * @return The response (either NTLMv2 or LMv2, depending on the client - * data). - */ - private static byte[] lmv2Response(final byte[] hash, final byte[] challenge, final byte[] clientData) { - final HMACMD5 hmacMD5 = new HMACMD5(hash); - hmacMD5.update(challenge); - hmacMD5.update(clientData); - final byte[] mac = hmacMD5.getOutput(); - final byte[] lmv2Response = new byte[mac.length + clientData.length]; - System.arraycopy(mac, 0, lmv2Response, 0, mac.length); - System.arraycopy(clientData, 0, lmv2Response, mac.length, clientData.length); - return lmv2Response; - } - - /** Calculate a challenge block */ - private static byte[] makeRandomChallenge(final Random random) { - final byte[] rval = new byte[8]; - synchronized (random) { - random.nextBytes(rval); - } - return rval; - } - - /** Calculate a 16-byte secondary key */ - private static byte[] makeSecondaryKey(final Random random) { - final byte[] rval = new byte[16]; - synchronized (random) { - random.nextBytes(rval); - } - return rval; - } - - /** Calculates HMAC-MD5 */ - private static byte[] hmacMD5(final byte[] value, final byte[] key) { - final HMACMD5 hmacMD5 = new HMACMD5(key); - hmacMD5.update(value); - return hmacMD5.getOutput(); - } -} diff --git a/src/main/java/org/metricshub/winrm/service/client/encryption/ContentWithType.java b/src/main/java/org/metricshub/winrm/service/client/encryption/ContentWithType.java deleted file mode 100644 index 5603a21..0000000 --- a/src/main/java/org/metricshub/winrm/service/client/encryption/ContentWithType.java +++ /dev/null @@ -1,54 +0,0 @@ -package org.metricshub.winrm.service.client.encryption; - -/*- - * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ - * WinRM Java Client - * ჻჻჻჻჻჻ - * Copyright 2023 - 2024 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 org.apache.cxf.message.Message; - -/** - * Code from io.cloudsoft.winrm4j.client.encryption.SignAndEncryptOutInterceptor.ContentWithType - * release 0.12.3 @link https://github.com/cloudsoft/winrm4j - */ -class ContentWithType { - - private final String contentType; - private final byte[] payload; - - private ContentWithType(final String contentType, final byte[] payload) { - this.contentType = contentType; - this.payload = payload; - } - - static ContentWithType of(final Message message, final byte[] payload) { - return new ContentWithType((String) message.get(Message.CONTENT_TYPE), payload); - } - - ContentWithType with(final byte[] payload) { - return new ContentWithType(contentType, payload); - } - - String getContentType() { - return contentType; - } - - byte[] getPayload() { - return payload; - } -} diff --git a/src/main/java/org/metricshub/winrm/service/client/encryption/DecryptAndVerifyInInterceptor.java b/src/main/java/org/metricshub/winrm/service/client/encryption/DecryptAndVerifyInInterceptor.java deleted file mode 100644 index 08ec55e..0000000 --- a/src/main/java/org/metricshub/winrm/service/client/encryption/DecryptAndVerifyInInterceptor.java +++ /dev/null @@ -1,45 +0,0 @@ -package org.metricshub.winrm.service.client.encryption; - -/*- - * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ - * WinRM Java Client - * ჻჻჻჻჻჻ - * Copyright 2023 - 2024 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 org.apache.cxf.interceptor.StaxInInterceptor; -import org.apache.cxf.message.Message; -import org.apache.cxf.phase.AbstractPhaseInterceptor; -import org.apache.cxf.phase.Phase; - -/** - * Code from io.cloudsoft.winrm4j.client.encryption.DecryptAndVerifyInInterceptor - * release 0.12.3 @link https://github.com/cloudsoft/winrm4j - */ -public class DecryptAndVerifyInInterceptor extends AbstractPhaseInterceptor { - - public DecryptAndVerifyInInterceptor() { - super(Phase.POST_STREAM); - addBefore(StaxInInterceptor.class.getName()); - } - - public void handleMessage(final Message message) { - final NtlmEncryptionUtils ntlmEncryptionUtils = NtlmEncryptionUtils.of(message); - if (ntlmEncryptionUtils != null) { - ntlmEncryptionUtils.decrypt(message); - } - } -} diff --git a/src/main/java/org/metricshub/winrm/service/client/encryption/Decryptor.java b/src/main/java/org/metricshub/winrm/service/client/encryption/Decryptor.java deleted file mode 100644 index 18eda04..0000000 --- a/src/main/java/org/metricshub/winrm/service/client/encryption/Decryptor.java +++ /dev/null @@ -1,217 +0,0 @@ -package org.metricshub.winrm.service.client.encryption; - -/*- - * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ - * WinRM Java Client - * ჻჻჻჻჻჻ - * Copyright 2023 - 2024 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 java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.util.Arrays; -import org.apache.cxf.helpers.IOUtils; -import org.apache.cxf.message.Message; -import org.metricshub.winrm.service.client.auth.ntlm.NTCredentialsWithEncryption; -import org.metricshub.winrm.service.client.auth.ntlm.NTLMEngineUtils; - -/** - * Code from io.cloudsoft.winrm4j.client.encryption.NtlmEncryptionUtils.Decryptor - * release 0.12.3 @link https://github.com/cloudsoft/winrm4j - */ -public class Decryptor { - - private final NTCredentialsWithEncryption credentials; - private byte[] rawBytes; - private byte[] encryptedPayloadBytes; - private int index; - private int lastBlockStart; - private int lastBlockEnd; - private byte[] signatureBytes; - private byte[] sealedBytes; - private byte[] unsealedBytes; - - public Decryptor(final NTCredentialsWithEncryption credentials) { - this.credentials = credentials; - } - - public void handle(final Message message) { - final Object contentType = message.get(Message.CONTENT_TYPE); - - final boolean isEncrypted = contentType != null && contentType.toString().startsWith("multipart/encrypted"); - - if (isEncrypted) { - if (credentials == null) { - throw new IllegalStateException("Encrypted payload from server when no credentials with encryption known"); - } - if (!credentials.isAuthenticated()) { - throw new IllegalStateException("Encrypted payload from server when not authenticated"); - } - - try { - decrypt(message); - } catch (final Exception e) { - throw new IllegalStateException(e); - } - } else { - if (credentials != null && credentials.isAuthenticated()) { - throw new IllegalStateException( - "Unencrypted payload from server when authenticated and encryption is required" - ); - } - } - } - - void decrypt(final Message message) throws IOException { - try (final InputStream in = message.getContent(InputStream.class)) { - rawBytes = IOUtils.readBytesFromStream(in); - } - - unwrap(); - - final int signatureLength = (int) ByteArrayUtils.readLittleEndianUnsignedInt(encryptedPayloadBytes, 0); - signatureBytes = Arrays.copyOfRange(encryptedPayloadBytes, 4, 4 + signatureLength); - sealedBytes = Arrays.copyOfRange(encryptedPayloadBytes, 4 + signatureLength, encryptedPayloadBytes.length); - - unseal(); - - // should set length and type headers - but they don't seem to be needed! - - verify(); - - message.setContent(InputStream.class, new ByteArrayInputStream(unsealedBytes)); - } - - private void verify() throws IOException { - final long seqNum = ByteArrayUtils.readLittleEndianUnsignedInt(signatureBytes, 12); - final int checkSumOffset = credentials.hasNegotiateFlag(NTLMEngineUtils.NTLMSSP_NEGOTIATE_EXTENDED_SESSIONSECURITY) - ? 4 - : 8; - - final byte[] checksum = Arrays.copyOfRange(signatureBytes, checkSumOffset, 12); - - try (final ByteArrayOutputStream signature = new ByteArrayOutputStream()) { - NtlmEncryptionUtils.calculateSignature( - unsealedBytes, - seqNum, - signature, - credentials, - NTCredentialsWithEncryption::getServerSigningKey, - credentials.getStatefulDecryptor()::update - ); - - final byte[] expectedChecksum = Arrays.copyOfRange(signature.toByteArray(), checkSumOffset, 12); - final long expectedSeqNum = ByteArrayUtils.readLittleEndianUnsignedInt(signature.toByteArray(), 12); - - if (!Arrays.equals(checksum, expectedChecksum)) { - throw new IllegalStateException( - String.format( - "Checksum mismatch\n%s--\n%s", - ByteArrayUtils.formatHexDump(checksum), - ByteArrayUtils.formatHexDump(expectedChecksum) - ) - ); - } - - if (expectedSeqNum != seqNum) { - throw new IllegalStateException(String.format("Sequence number mismatch: %d != %d", seqNum, expectedSeqNum)); - } - } - - credentials.getSequenceNumberIncoming().incrementAndGet(); - } - - void unwrap() { - index = 0; - skipOver(NtlmEncryptionUtils.ENCRYPTED_BOUNDARY_CR); - skipUntil("\n" + NtlmEncryptionUtils.ENCRYPTED_BOUNDARY_CR); - skipUntil("\r\n"); - - // for credssh de-chunking might be needed, but not for ntlm - - lastBlockStart = index; - lastBlockEnd = rawBytes.length - NtlmEncryptionUtils.ENCRYPTED_BOUNDARY_END.length(); - index = lastBlockEnd; - skipOver(NtlmEncryptionUtils.ENCRYPTED_BOUNDARY_END); - - encryptedPayloadBytes = Arrays.copyOfRange(rawBytes, lastBlockStart, lastBlockEnd); - } - - void skipOver(final String s) { - skipOver(s.getBytes()); - } - - void skipOver(final byte[] expected) { - int i = 0; - while (i < expected.length) { - if (index >= rawBytes.length) { - throw new IllegalStateException( - String.format( - "Invalid format for response from server; terminated early (%d) when expecting '%s'\n%s", - i, - new String(expected), - ByteArrayUtils.formatHexDump(rawBytes) - ) - ); - } - - if (expected[i++] != rawBytes[index++]) { - throw new IllegalStateException( - String.format( - "Invalid format for response from server; mismatch at position %d (%d) when expecting '%s'\n%s", - index, - i, - new String(expected), - ByteArrayUtils.formatHexDump(rawBytes) - ) - ); - } - } - } - - void skipUntil(final String str) { - final byte[] expected = str.getBytes(); - int nextBlock = index; - outer:while (true) { - for (int i = 0; i < expected.length && nextBlock + i < rawBytes.length; i++) { - if (nextBlock + i >= rawBytes.length) { - throw new IllegalStateException( - String.format( - "Invalid format for response from server; terminated early (%d) when looking for '%s'\n%s", - i, - new String(expected), - ByteArrayUtils.formatHexDump(rawBytes) - ) - ); - } - if (expected[i] != rawBytes[nextBlock + i]) { - nextBlock++; - continue outer; - } - } - lastBlockStart = index; - lastBlockEnd = nextBlock; - index = nextBlock + expected.length; - return; - } - } - - private void unseal() { - unsealedBytes = credentials.getStatefulDecryptor().update(sealedBytes); - } -} diff --git a/src/main/java/org/metricshub/winrm/service/client/encryption/EncryptAndSignOutputStream.java b/src/main/java/org/metricshub/winrm/service/client/encryption/EncryptAndSignOutputStream.java deleted file mode 100644 index bca4c97..0000000 --- a/src/main/java/org/metricshub/winrm/service/client/encryption/EncryptAndSignOutputStream.java +++ /dev/null @@ -1,145 +0,0 @@ -package org.metricshub.winrm.service.client.encryption; - -/*- - * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ - * WinRM Java Client - * ჻჻჻჻჻჻ - * Copyright 2023 - 2024 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 java.io.IOException; -import java.io.OutputStream; -import java.util.Objects; -import org.apache.cxf.io.CachedOutputStream; -import org.apache.cxf.message.Message; -import org.apache.http.auth.Credentials; -import org.metricshub.winrm.service.client.auth.ntlm.NTCredentialsWithEncryption; - -/** - * Code from io.cloudsoft.winrm4j.client.encryption.SignAndEncryptOutInterceptor.EncryptAndSignOutputStream - * release 0.12.3 @link https://github.com/cloudsoft/winrm4j - */ -class EncryptAndSignOutputStream extends CachedOutputStream { - - private final CachedOutputStream unencrypted; - private ContentWithType unencryptedResult = null; - private ContentWithType encrypted = null; - private final Message message; - - private OutputStream wrapped; - - private NTCredentialsWithEncryption credentials; - - public EncryptAndSignOutputStream(final Message message, final OutputStream outputStream) { - super(); - this.message = message; - wrapped = outputStream; - unencrypted = new CachedOutputStream(); - - final Object creds = message.get(Credentials.class.getName()); - if (creds instanceof NTCredentialsWithEncryption) { - credentials = (NTCredentialsWithEncryption) creds; - } - } - - @Override - public void resetOut(final OutputStream outputStream, final boolean copyOldContent) throws IOException { - super.resetOut(outputStream, copyOldContent); - } - - @Override - public void close() throws IOException { - super.close(); - unencrypted.write(getBytes()); - currentStream = NullOutputStream.NULL_OUTPUT_STREAM; - - if (wrapped != null) { - processAndShip(wrapped); - wrapped.close(); - } - } - - private synchronized ContentWithType getEncrypted() { - try { - if (encrypted == null) { - final byte[] bytesEncryptedAndSigned = NtlmEncryptionUtils - .of(credentials) - .encryptAndSign(message, unencrypted.getBytes()); - - encrypted = ContentWithType.of(message, bytesEncryptedAndSigned); - } - return encrypted; - } catch (final IOException e) { - throw new IllegalStateException(e); - } - } - - private byte[] getUnencrypted() { - try { - return unencrypted.getBytes(); - } catch (final IOException e) { - throw new IllegalStateException(e); - } - } - - synchronized ContentWithType getAppropriate() { - if (unencryptedResult == null) { - unencryptedResult = ContentWithType.of(message, null); - } - - if (credentials == null || !credentials.isAuthenticated()) { - if (encrypted != null) { - // clear any previous encryption if no longer valid - encrypted = null; - } - - return credentials != null && !credentials.isAuthenticated() - ? unencryptedResult.with(AsyncHttpEncryptionAwareConduit.PRE_AUTH_BOGUS_PAYLOAD) - : unencryptedResult.with(getUnencrypted()); - } - - return getEncrypted(); - } - - private void processAndShip(final OutputStream output) throws IOException { - output.write(getAppropriate().getPayload()); - output.close(); - } - - @Override - public int hashCode() { - final int prime = 31; - int result = super.hashCode(); - result = prime * result + Objects.hash(credentials, encrypted, message, unencrypted, unencryptedResult, wrapped); - return result; - } - - @Override - public boolean equals(Object obj) { - if (this == obj) return true; - if (!super.equals(obj)) return false; - if (!(obj instanceof EncryptAndSignOutputStream)) return false; - EncryptAndSignOutputStream other = (EncryptAndSignOutputStream) obj; - return ( - Objects.equals(credentials, other.credentials) && - Objects.equals(encrypted, other.encrypted) && - Objects.equals(message, other.message) && - Objects.equals(unencrypted, other.unencrypted) && - Objects.equals(unencryptedResult, other.unencryptedResult) && - Objects.equals(wrapped, other.wrapped) - ); - } -} diff --git a/src/main/java/org/metricshub/winrm/service/client/encryption/EncryptionAwareHttpEntity.java b/src/main/java/org/metricshub/winrm/service/client/encryption/EncryptionAwareHttpEntity.java deleted file mode 100644 index 5226a90..0000000 --- a/src/main/java/org/metricshub/winrm/service/client/encryption/EncryptionAwareHttpEntity.java +++ /dev/null @@ -1,42 +0,0 @@ -package org.metricshub.winrm.service.client.encryption; - -/*- - * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ - * WinRM Java Client - * ჻჻჻჻჻჻ - * Copyright 2023 - 2024 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 org.apache.http.HttpEntityEnclosingRequest; -import org.apache.http.entity.BasicHttpEntity; -import org.apache.http.protocol.HTTP; - -/** - * Code from io.cloudsoft.winrm4j.client.encryption.AsyncHttpEncryptionAwareConduit.EncryptionAwareHttpEntity - * release 0.12.3 @link https://github.com/cloudsoft/winrm4j - */ -public abstract class EncryptionAwareHttpEntity extends BasicHttpEntity { - - public void refreshHeaders(final HttpEntityEnclosingRequest request) { - final ContentWithType appropriate = getAppropriate(); - setContentLength(appropriate.getPayload().length); - - request.setHeader(HTTP.CONTENT_LEN, String.valueOf(appropriate.getPayload().length)); - request.setHeader(HTTP.CONTENT_TYPE, appropriate.getContentType()); - } - - protected abstract ContentWithType getAppropriate(); -} diff --git a/src/main/java/org/metricshub/winrm/service/client/encryption/EncryptionUtils.java b/src/main/java/org/metricshub/winrm/service/client/encryption/EncryptionUtils.java deleted file mode 100644 index 4716e8c..0000000 --- a/src/main/java/org/metricshub/winrm/service/client/encryption/EncryptionUtils.java +++ /dev/null @@ -1,85 +0,0 @@ -package org.metricshub.winrm.service.client.encryption; - -/*- - * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ - * WinRM Java Client - * ჻჻჻჻჻჻ - * Copyright 2023 - 2024 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 java.security.InvalidKeyException; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import javax.crypto.Cipher; -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; - -/** - * Code from io.cloudsoft.winrm4j.client.encryption.WinrmEncryptionUtils - * release 0.12.3 @link https://github.com/cloudsoft/winrm4j - */ -public class EncryptionUtils { - - private EncryptionUtils() {} - - private static final String HMAC_MD5 = "HmacMD5"; - private static final String RC4 = "RC4"; - - public static MessageDigest getMD5() { - try { - return MessageDigest.getInstance("MD5"); - } catch (final NoSuchAlgorithmException ex) { - throw new IllegalStateException("MD5 message digest doesn't seem to exist - fatal error: " + ex.getMessage(), ex); - } - } - - public static byte[] md5digest(byte[] bytes) { - final MessageDigest handle = getMD5(); - handle.update(bytes); - return handle.digest(); - } - - public static Cipher arc4(byte[] key) { - // engine needs to be stateful - try { - final Cipher rc4 = Cipher.getInstance(RC4); - rc4.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, RC4)); - return rc4; - } catch (final Exception e) { - throw new IllegalStateException(e); - } - } - - /** Calculates RC4 */ - public static byte[] calculateRC4(final byte[] value, final byte[] key) { - try { - return arc4(key).doFinal(value); - } catch (final Exception e) { - throw new IllegalStateException(e); - } - } - - public static byte[] hmacMd5(byte[] key, byte[] body) { - try { - final SecretKeySpec keySpec = new SecretKeySpec(key, HMAC_MD5); - final Mac mac = Mac.getInstance(HMAC_MD5); - mac.init(keySpec); - return mac.doFinal(body); - } catch (final NoSuchAlgorithmException | InvalidKeyException e) { - throw new IllegalStateException(e); - } - } -} diff --git a/src/main/java/org/metricshub/winrm/service/client/encryption/HMACMD5.java b/src/main/java/org/metricshub/winrm/service/client/encryption/HMACMD5.java deleted file mode 100644 index 41ffbe3..0000000 --- a/src/main/java/org/metricshub/winrm/service/client/encryption/HMACMD5.java +++ /dev/null @@ -1,82 +0,0 @@ -package org.metricshub.winrm.service.client.encryption; - -/*- - * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ - * WinRM Java Client - * ჻჻჻჻჻჻ - * Copyright 2023 - 2024 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 java.security.MessageDigest; - -/** - * Cryptography support - HMACMD5 - algorithmically based on various web - * resources by Karl Wright - * - * Code from io.cloudsoft.winrm4j.client.ntlm.forks.httpclient.NTLMEngineImpl - * release 0.12.3 @link https://github.com/cloudsoft/winrm4j - * io.cloudsoft.winrm4j.client.ntlm.forks.httpclient is a fork of apache-httpclient 4.5.13 - */ -public class HMACMD5 { - - private final byte[] ipad; - private final byte[] opad; - private final MessageDigest md5; - - HMACMD5(final byte[] input) { - byte[] key = input; - md5 = EncryptionUtils.getMD5(); - - // Initialize the pad buffers with the key - ipad = new byte[64]; - opad = new byte[64]; - - int keyLength = key.length; - if (keyLength > 64) { - // Use MD5 of the key instead, as described in RFC 2104 - md5.update(key); - key = md5.digest(); - keyLength = key.length; - } - int i = 0; - while (i < keyLength) { - ipad[i] = (byte) (key[i] ^ (byte) 0x36); - opad[i] = (byte) (key[i] ^ (byte) 0x5c); - i++; - } - while (i < 64) { - ipad[i] = (byte) 0x36; - opad[i] = (byte) 0x5c; - i++; - } - - // Very important: processChallenge the digest with the ipad buffer - md5.reset(); - md5.update(ipad); - } - - /** Grab the current digest. This is the "answer". */ - byte[] getOutput() { - final byte[] digest = md5.digest(); - md5.update(opad); - return md5.digest(digest); - } - - /** Update by adding a complete array */ - void update(final byte[] input) { - md5.update(input); - } -} diff --git a/src/main/java/org/metricshub/winrm/service/client/encryption/MD4.java b/src/main/java/org/metricshub/winrm/service/client/encryption/MD4.java deleted file mode 100644 index aa0eed3..0000000 --- a/src/main/java/org/metricshub/winrm/service/client/encryption/MD4.java +++ /dev/null @@ -1,211 +0,0 @@ -package org.metricshub.winrm.service.client.encryption; - -/*- - * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ - * WinRM Java Client - * ჻჻჻჻჻჻ - * Copyright 2023 - 2024 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. - * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ - */ - -/** - * Cryptography support - MD4. The following class was based loosely on the - * RFC and on code found at http://www.cs.umd.edu/~harry/jotp/src/md.java. - * Code correctness was verified by looking at MD4.java from the jcifs - * library (http://jcifs.samba.org). It was massaged extensively to the - * final form found here by Karl Wright (kwright@metacarta.com). - * - * Code from io.cloudsoft.winrm4j.client.ntlm.forks.httpclient.NTLMEngineImpl - * release 0.12.3 @link https://github.com/cloudsoft/winrm4j - * io.cloudsoft.winrm4j.client.ntlm.forks.httpclient is a fork of apache-httpclient 4.5.13 - */ -public class MD4 { - - private int a = 0x67452301; - private int b = 0xefcdab89; - private int c = 0x98badcfe; - private int d = 0x10325476; - private long count = 0L; - private final byte[] dataBuffer = new byte[64]; - - void update(final byte[] input) { - // We always deal with 512 bits at a time. Correspondingly, there is - // a buffer 64 bytes long that we write data into until it gets - // full. - int curBufferPos = (int) (count & 63L); - int inputIndex = 0; - while (input.length - inputIndex + curBufferPos >= dataBuffer.length) { - // We have enough data to do the next step. Do a partial copy - // and a transform, updating inputIndex and curBufferPos - // accordingly - final int transferAmt = dataBuffer.length - curBufferPos; - System.arraycopy(input, inputIndex, dataBuffer, curBufferPos, transferAmt); - count += transferAmt; - curBufferPos = 0; - inputIndex += transferAmt; - processBuffer(); - } - - // If there's anything left, copy it into the buffer and leave it. - // We know there's not enough left to process. - if (inputIndex < input.length) { - final int transferAmt = input.length - inputIndex; - System.arraycopy(input, inputIndex, dataBuffer, curBufferPos, transferAmt); - count += transferAmt; - curBufferPos += transferAmt; - } - } - - byte[] getOutput() { - // Feed pad/length data into engine. This must round out the input - // to a multiple of 512 bits. - final int bufferIndex = (int) (count & 63L); - final int padLen = (bufferIndex < 56) ? (56 - bufferIndex) : (120 - bufferIndex); - final byte[] postBytes = new byte[padLen + 8]; - // Leading 0x80, specified amount of zero padding, then length in - // bits. - postBytes[0] = (byte) 0x80; - // Fill out the last 8 bytes with the length - for (int i = 0; i < 8; i++) { - postBytes[padLen + i] = (byte) ((count * 8) >>> (8 * i)); - } - - // Update the engine - update(postBytes); - - // Calculate final result - final byte[] result = new byte[16]; - writeULong(result, a, 0); - writeULong(result, b, 4); - writeULong(result, c, 8); - writeULong(result, d, 12); - return result; - } - - private static void writeULong(final byte[] buffer, final int value, final int offset) { - buffer[offset] = (byte) (value & 0xff); - buffer[offset + 1] = (byte) ((value >> 8) & 0xff); - buffer[offset + 2] = (byte) ((value >> 16) & 0xff); - buffer[offset + 3] = (byte) ((value >> 24) & 0xff); - } - - private void processBuffer() { - // Convert current buffer to 16 ulongs - final int[] d = new int[16]; - - for (int i = 0; i < 16; i++) { - d[i] = - (dataBuffer[i * 4] & 0xff) + - ((dataBuffer[i * 4 + 1] & 0xff) << 8) + - ((dataBuffer[i * 4 + 2] & 0xff) << 16) + - ((dataBuffer[i * 4 + 3] & 0xff) << 24); - } - - // Do a round of processing - final int aa = a; - final int bb = b; - final int cc = c; - final int dd = this.d; - round1(d); - round2(d); - round3(d); - a += aa; - b += bb; - c += cc; - this.d += dd; - } - - private void round1(final int[] d) { - a = rotintlft((a + f(b, c, this.d) + d[0]), 3); - this.d = rotintlft((this.d + f(a, b, c) + d[1]), 7); - c = rotintlft((c + f(this.d, a, b) + d[2]), 11); - b = rotintlft((b + f(c, this.d, a) + d[3]), 19); - - a = rotintlft((a + f(b, c, this.d) + d[4]), 3); - this.d = rotintlft((this.d + f(a, b, c) + d[5]), 7); - c = rotintlft((c + f(this.d, a, b) + d[6]), 11); - b = rotintlft((b + f(c, this.d, a) + d[7]), 19); - - a = rotintlft((a + f(b, c, this.d) + d[8]), 3); - this.d = rotintlft((this.d + f(a, b, c) + d[9]), 7); - c = rotintlft((c + f(this.d, a, b) + d[10]), 11); - b = rotintlft((b + f(c, this.d, a) + d[11]), 19); - - a = rotintlft((a + f(b, c, this.d) + d[12]), 3); - this.d = rotintlft((this.d + f(a, b, c) + d[13]), 7); - c = rotintlft((c + f(this.d, a, b) + d[14]), 11); - b = rotintlft((b + f(c, this.d, a) + d[15]), 19); - } - - private void round2(final int[] d) { - a = rotintlft((a + g(b, c, this.d) + d[0] + 0x5a827999), 3); - this.d = rotintlft((this.d + g(a, b, c) + d[4] + 0x5a827999), 5); - c = rotintlft((c + g(this.d, a, b) + d[8] + 0x5a827999), 9); - b = rotintlft((b + g(c, this.d, a) + d[12] + 0x5a827999), 13); - - a = rotintlft((a + g(b, c, this.d) + d[1] + 0x5a827999), 3); - this.d = rotintlft((this.d + g(a, b, c) + d[5] + 0x5a827999), 5); - c = rotintlft((c + g(this.d, a, b) + d[9] + 0x5a827999), 9); - b = rotintlft((b + g(c, this.d, a) + d[13] + 0x5a827999), 13); - - a = rotintlft((a + g(b, c, this.d) + d[2] + 0x5a827999), 3); - this.d = rotintlft((this.d + g(a, b, c) + d[6] + 0x5a827999), 5); - c = rotintlft((c + g(this.d, a, b) + d[10] + 0x5a827999), 9); - b = rotintlft((b + g(c, this.d, a) + d[14] + 0x5a827999), 13); - - a = rotintlft((a + g(b, c, this.d) + d[3] + 0x5a827999), 3); - this.d = rotintlft((this.d + g(a, b, c) + d[7] + 0x5a827999), 5); - c = rotintlft((c + g(this.d, a, b) + d[11] + 0x5a827999), 9); - b = rotintlft((b + g(c, this.d, a) + d[15] + 0x5a827999), 13); - } - - private void round3(final int[] d) { - a = rotintlft((a + h(b, c, this.d) + d[0] + 0x6ed9eba1), 3); - this.d = rotintlft((this.d + h(a, b, c) + d[8] + 0x6ed9eba1), 9); - c = rotintlft((c + h(this.d, a, b) + d[4] + 0x6ed9eba1), 11); - b = rotintlft((b + h(c, this.d, a) + d[12] + 0x6ed9eba1), 15); - - a = rotintlft((a + h(b, c, this.d) + d[2] + 0x6ed9eba1), 3); - this.d = rotintlft((this.d + h(a, b, c) + d[10] + 0x6ed9eba1), 9); - c = rotintlft((c + h(this.d, a, b) + d[6] + 0x6ed9eba1), 11); - b = rotintlft((b + h(c, this.d, a) + d[14] + 0x6ed9eba1), 15); - - a = rotintlft((a + h(b, c, this.d) + d[1] + 0x6ed9eba1), 3); - this.d = rotintlft((this.d + h(a, b, c) + d[9] + 0x6ed9eba1), 9); - c = rotintlft((c + h(this.d, a, b) + d[5] + 0x6ed9eba1), 11); - b = rotintlft((b + h(c, this.d, a) + d[13] + 0x6ed9eba1), 15); - - a = rotintlft((a + h(b, c, this.d) + d[3] + 0x6ed9eba1), 3); - this.d = rotintlft((this.d + h(a, b, c) + d[11] + 0x6ed9eba1), 9); - c = rotintlft((c + h(this.d, a, b) + d[7] + 0x6ed9eba1), 11); - b = rotintlft((b + h(c, this.d, a) + d[15] + 0x6ed9eba1), 15); - } - - private static int f(final int x, final int y, final int z) { - return ((x & y) | (~x & z)); - } - - private static int g(final int x, final int y, final int z) { - return ((x & y) | (x & z) | (y & z)); - } - - private static int h(final int x, final int y, final int z) { - return (x ^ y ^ z); - } - - private static int rotintlft(final int val, final int numbits) { - return ((val << numbits) | (val >>> (32 - numbits))); - } -} diff --git a/src/main/java/org/metricshub/winrm/service/client/encryption/NtlmEncryptionUtils.java b/src/main/java/org/metricshub/winrm/service/client/encryption/NtlmEncryptionUtils.java deleted file mode 100644 index 86490cc..0000000 --- a/src/main/java/org/metricshub/winrm/service/client/encryption/NtlmEncryptionUtils.java +++ /dev/null @@ -1,171 +0,0 @@ -package org.metricshub.winrm.service.client.encryption; - -/*- - * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ - * WinRM Java Client - * ჻჻჻჻჻჻ - * Copyright 2023 - 2024 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 java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.util.Arrays; -import java.util.function.Function; -import java.util.function.UnaryOperator; -import java.util.zip.CRC32; -import org.apache.cxf.message.Message; -import org.apache.http.auth.Credentials; -import org.metricshub.winrm.service.client.auth.ntlm.NTCredentialsWithEncryption; -import org.metricshub.winrm.service.client.auth.ntlm.NTLMEngineUtils; - -/** - * Code from io.cloudsoft.winrm4j.client.encryption.NtlmEncryptionUtils release - * 0.12.3 @link https://github.com/cloudsoft/winrm4j - */ -public class NtlmEncryptionUtils { - - public static final String ENCRYPTED_BOUNDARY_PREFIX = "--Encrypted Boundary"; - public static final String ENCRYPTED_BOUNDARY_CR = ENCRYPTED_BOUNDARY_PREFIX + "\r\n"; - public static final String ENCRYPTED_BOUNDARY_END = ENCRYPTED_BOUNDARY_PREFIX + "--\r\n"; - - protected final NTCredentialsWithEncryption credentials; - - private NtlmEncryptionUtils(final NTCredentialsWithEncryption credentials) { - this.credentials = credentials; - } - - static NtlmEncryptionUtils of(final Credentials credentials) { - return credentials instanceof NTCredentialsWithEncryption - ? new NtlmEncryptionUtils((NTCredentialsWithEncryption) credentials) - : null; - } - - static NtlmEncryptionUtils of(final Message message) { - final Credentials credentials = (Credentials) message.getExchange().get(Credentials.class.getName()); - return of(credentials); - } - - public byte[] encryptAndSign(final Message message, final byte[] messageBody) { - try (final ByteArrayOutputStream out = new ByteArrayOutputStream()) { - out.write(ENCRYPTED_BOUNDARY_CR.getBytes()); - out.write(("\tContent-Type: application/HTTP-SPNEGO-session-encrypted\r\n").getBytes()); - - // message.get(Message.CONTENT_TYPE); - if we need the action - // Content-Type -> application/soap+xml; - // action="http://schemas.xmlsoap.org/ws/2004/09/transfer/Create" - out.write( - String - .format("\tOriginalContent: type=application/soap+xml;charset=UTF-8;Length=%d\r\n", messageBody.length) - .getBytes() - ); - - out.write(ENCRYPTED_BOUNDARY_CR.getBytes()); - out.write("\tContent-Type: application/octet-stream\r\n".getBytes()); - - // for credssh chunking might be needed, but not for ntlm - - writeNtlmEncrypted(messageBody, out); - - out.write(ENCRYPTED_BOUNDARY_END.getBytes()); - - message.put( - Message.CONTENT_TYPE, - "multipart/encrypted;protocol=\"application/HTTP-SPNEGO-session-encrypted\";" + - "boundary=\"Encrypted Boundary\"" - ); - message.put(Message.ENCODING, null); - - return out.toByteArray(); - } catch (final Exception e) { - throw new IllegalStateException("Cannot encrypt WinRM message", e); - } - } - - private byte[] seal(final byte[] in) { - return credentials.getStatefulEncryptor().update(in); - } - - private void writeNtlmEncrypted(final byte[] messageBody, final ByteArrayOutputStream encrypted) throws IOException { - long seqNum = credentials.getSequenceNumberOutgoing().incrementAndGet(); - - try ( - final ByteArrayOutputStream signatureOs = new ByteArrayOutputStream(); - final ByteArrayOutputStream sealedOs = new ByteArrayOutputStream() - ) { - // seal first, even though appended afterwards, because encryptor is stateful - sealedOs.write(seal(messageBody)); - - calculateSignature( - messageBody, - seqNum, - signatureOs, - credentials, - NTCredentialsWithEncryption::getClientSigningKey, - this::seal - ); - - encrypted.write(ByteArrayUtils.getLittleEndianUnsignedInt(signatureOs.size())); - encrypted.write(signatureOs.toByteArray()); - encrypted.write(sealedOs.toByteArray()); - } - } - - public void decrypt(final Message message) { - new Decryptor(credentials).handle(message); - } - - static void calculateSignature( - final byte[] messageBody, - final long seqNum, - final ByteArrayOutputStream signature, - final NTCredentialsWithEncryption credentials, - final Function signingKeyFunction, - final UnaryOperator sealer - ) throws IOException { - if (credentials.hasNegotiateFlag(NTLMEngineUtils.NTLMSSP_NEGOTIATE_EXTENDED_SESSIONSECURITY)) { - // also see HMACMD5 in NTLMEngineIpml - byte[] checksum = EncryptionUtils.hmacMd5( - signingKeyFunction.apply(credentials), - ByteArrayUtils.concat(ByteArrayUtils.getLittleEndianUnsignedInt(seqNum), messageBody) - ); - - checksum = Arrays.copyOfRange(checksum, 0, 8); - - if (credentials.hasNegotiateFlag(NTLMEngineUtils.NTLMSSP_NEGOTIATE_KEY_EXCH)) { - checksum = sealer.apply(checksum); - } - // version - signature.write(new byte[] { 1, 0, 0, 0 }); - // checksum - signature.write(checksum); - // seq num - signature.write(ByteArrayUtils.getLittleEndianUnsignedInt(seqNum)); - } else { - final CRC32 crc = new CRC32(); - crc.update(messageBody); - final long messageCrc = crc.getValue(); - - // version - signature.write(new byte[] { 1, 0, 0, 0 }); - // random pad - signature.write(sealer.apply(ByteArrayUtils.getLittleEndianUnsignedInt(0))); - // checksum - signature.write(sealer.apply(ByteArrayUtils.getLittleEndianUnsignedInt(messageCrc))); - // seq num - signature.write(sealer.apply(ByteArrayUtils.getLittleEndianUnsignedInt(seqNum))); - } - } -} diff --git a/src/main/java/org/metricshub/winrm/service/client/encryption/NullOutputStream.java b/src/main/java/org/metricshub/winrm/service/client/encryption/NullOutputStream.java deleted file mode 100644 index 785dfc7..0000000 --- a/src/main/java/org/metricshub/winrm/service/client/encryption/NullOutputStream.java +++ /dev/null @@ -1,73 +0,0 @@ -package org.metricshub.winrm.service.client.encryption; - -/*- - * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ - * WinRM Java Client - * ჻჻჻჻჻჻ - * Copyright 2023 - 2024 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 java.io.IOException; -import java.io.OutputStream; - -/** - * This OutputStream writes all data to the famous /dev/null. - * - * This output stream has no destination (file/socket etc.) and all - * bytes written to it are ignored and lost. - * - * - * PS: from common-io/NullOutputStream. Embedded to avoid to include common-io. 2.11.0 - */ -class NullOutputStream extends OutputStream { - - private NullOutputStream() {} - - /** - * A singleton. - */ - static final NullOutputStream NULL_OUTPUT_STREAM = new NullOutputStream(); - - /** - * Does nothing - output to /dev/null. - * @param b The bytes to write - * @param off The start offset - * @param len The number of bytes to write - */ - @Override - public void write(final byte[] b, final int off, final int len) { - //to /dev/null - } - - /** - * Does nothing - output to /dev/null. - * @param b The byte to write - */ - @Override - public void write(final int b) { - //to /dev/null - } - - /** - * Does nothing - output to /dev/null. - * @param b The bytes to write - * @throws IOException never - */ - @Override - public void write(final byte[] b) throws IOException { - //to /dev/null - } -} diff --git a/src/main/java/org/metricshub/winrm/service/client/encryption/SignAndEncryptOutInterceptor.java b/src/main/java/org/metricshub/winrm/service/client/encryption/SignAndEncryptOutInterceptor.java deleted file mode 100644 index badb5b4..0000000 --- a/src/main/java/org/metricshub/winrm/service/client/encryption/SignAndEncryptOutInterceptor.java +++ /dev/null @@ -1,57 +0,0 @@ -package org.metricshub.winrm.service.client.encryption; - -/*- - * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ - * WinRM Java Client - * ჻჻჻჻჻჻ - * Copyright 2023 - 2024 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 java.io.OutputStream; -import org.apache.cxf.interceptor.StaxOutInterceptor; -import org.apache.cxf.message.Message; -import org.apache.cxf.phase.AbstractPhaseInterceptor; -import org.apache.cxf.phase.Phase; - -/** - * Not only encrypts if necessary, but must track the payload and make it available to - * {@link AsyncHttpEncryptionAwareConduit} in case we need to subsequently encrypt. - * - * Code from io.cloudsoft.winrm4j.client.encryption.SignAndEncryptOutInterceptor.SignAndEncryptOutInterceptor - * release 0.12.3 @link https://github.com/cloudsoft/winrm4j - */ -public class SignAndEncryptOutInterceptor extends AbstractPhaseInterceptor { - - private static final String APPLIED = SignAndEncryptOutInterceptor.class.getSimpleName() + ".APPLIED"; - - public SignAndEncryptOutInterceptor() { - super(Phase.PRE_STREAM); - // we need to be set before various other output devices, so they write to us - addBefore(StaxOutInterceptor.class.getName()); - } - - @Override - public void handleMessage(final Message message) { - boolean hasApplied = message.containsKey(APPLIED); - if (!hasApplied) { - message.put(APPLIED, Boolean.TRUE); - final OutputStream outputStream = message.getContent(OutputStream.class); - final EncryptAndSignOutputStream newOut = new EncryptAndSignOutputStream(message, outputStream); - message.setContent(OutputStream.class, newOut); - message.setContent(EncryptAndSignOutputStream.class, newOut); - } - } -} diff --git a/src/main/resources/META-INF/jax-ws-catalog.xml b/src/main/resources/META-INF/jax-ws-catalog.xml deleted file mode 100644 index 9a15209..0000000 --- a/src/main/resources/META-INF/jax-ws-catalog.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - diff --git a/src/main/resources/jaxws/bindings.xml b/src/main/resources/jaxws/bindings.xml deleted file mode 100644 index 029b072..0000000 --- a/src/main/resources/jaxws/bindings.xml +++ /dev/null @@ -1,48 +0,0 @@ - - - - - false - false - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/main/resources/wsdl/WinRM.wsdl b/src/main/resources/wsdl/WinRM.wsdl deleted file mode 100644 index dbfaa07..0000000 --- a/src/main/resources/wsdl/WinRM.wsdl +++ /dev/null @@ -1,475 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/main/resources/xsd/dsp8033_1.0.xsd b/src/main/resources/xsd/dsp8033_1.0.xsd deleted file mode 100644 index 6c9bd00..0000000 --- a/src/main/resources/xsd/dsp8033_1.0.xsd +++ /dev/null @@ -1,307 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/main/resources/xsd/dsp8034_1.0.xsd b/src/main/resources/xsd/dsp8034_1.0.xsd deleted file mode 100644 index 5b4dca1..0000000 --- a/src/main/resources/xsd/dsp8034_1.0.xsd +++ /dev/null @@ -1,165 +0,0 @@ - - - - - - - - - - - - - - - - - - If "Policy" elements from namespace - "http://schemas.xmlsoap.org/ws/2002/12/policy#policy" are used, - they must appear first (before any extensibility elements). - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/main/resources/xsd/transfer.xsd b/src/main/resources/xsd/transfer.xsd deleted file mode 100644 index 998fdfd..0000000 --- a/src/main/resources/xsd/transfer.xsd +++ /dev/null @@ -1,73 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/main/resources/xsd/ws-addr.xsd b/src/main/resources/xsd/ws-addr.xsd deleted file mode 100644 index 47362ed..0000000 --- a/src/main/resources/xsd/ws-addr.xsd +++ /dev/null @@ -1,137 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/main/resources/xsd/wsman.xsd b/src/main/resources/xsd/wsman.xsd deleted file mode 100644 index ff97bbf..0000000 --- a/src/main/resources/xsd/wsman.xsd +++ /dev/null @@ -1,418 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - Instances of this type can be only simple types or EPRs, not arbitrary mixed data. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/main/resources/xsd/xml.xsd b/src/main/resources/xsd/xml.xsd deleted file mode 100644 index aea7d0d..0000000 --- a/src/main/resources/xsd/xml.xsd +++ /dev/null @@ -1,287 +0,0 @@ - - - - - - -
-

About the XML namespace

- -
-

- This schema document describes the XML namespace, in a form - suitable for import by other schema documents. -

-

- See - http://www.w3.org/XML/1998/namespace.html and - - http://www.w3.org/TR/REC-xml for information - about this namespace. -

-

- Note that local names in this namespace are intended to be - defined only by the World Wide Web Consortium or its subgroups. - The names currently defined in this namespace are listed below. - They should not be used with conflicting semantics by any Working - Group, specification, or document instance. -

-

- See further below in this document for more information about how to refer to this schema document from your own - XSD schema documents and about the - namespace-versioning policy governing this schema document. -

-
-
-
-
- - - - -
- -

lang (as an attribute name)

-

- denotes an attribute whose value - is a language code for the natural language of the content of - any element; its value is inherited. This name is reserved - by virtue of its definition in the XML specification.

- -
-
-

Notes

-

- Attempting to install the relevant ISO 2- and 3-letter - codes as the enumerated possible values is probably never - going to be a realistic possibility. -

-

- See BCP 47 at - http://www.rfc-editor.org/rfc/bcp/bcp47.txt - and the IANA language subtag registry at - - http://www.iana.org/assignments/language-subtag-registry - for further information. -

-

- The union allows for the 'un-declaration' of xml:lang with - the empty string. -

-
-
-
- - - - - - - - - -
- - - - -
- -

space (as an attribute name)

-

- denotes an attribute whose - value is a keyword indicating what whitespace processing - discipline is intended for the content of the element; its - value is inherited. This name is reserved by virtue of its - definition in the XML specification.

- -
-
-
- - - - - - -
- - - -
- -

base (as an attribute name)

-

- denotes an attribute whose value - provides a URI to be used as the base for interpreting any - relative URIs in the scope of the element on which it - appears; its value is inherited. This name is reserved - by virtue of its definition in the XML Base specification.

- -

- See http://www.w3.org/TR/xmlbase/ - for information about this attribute. -

-
-
-
-
- - - - -
- -

id (as an attribute name)

-

- denotes an attribute whose value - should be interpreted as if declared to be of type ID. - This name is reserved by virtue of its definition in the - xml:id specification.

- -

- See http://www.w3.org/TR/xml-id/ - for information about this attribute. -

-
-
-
-
- - - - - - - - - - -
- -

Father (in any context at all)

- -
-

- denotes Jon Bosak, the chair of - the original XML Working Group. This name is reserved by - the following decision of the W3C XML Plenary and - XML Coordination groups: -

-
-

- In appreciation for his vision, leadership and - dedication the W3C XML Plenary on this 10th day of - February, 2000, reserves for Jon Bosak in perpetuity - the XML name "xml:Father". -

-
-
-
-
-
- - - -
-

About this schema document

- -
-

- This schema defines attributes and an attribute group suitable - for use by schemas wishing to allow xml:base, - xml:lang, xml:space or - xml:id attributes on elements they define. -

-

- To enable this, such a schema must import this schema for - the XML namespace, e.g. as follows: -

-
-          <schema . . .>
-           . . .
-           <import namespace="http://www.w3.org/XML/1998/namespace"
-                      schemaLocation="http://www.w3.org/2001/xml.xsd"/>
-     
-

- or -

-
-           <import namespace="http://www.w3.org/XML/1998/namespace"
-                      schemaLocation="http://www.w3.org/2009/01/xml.xsd"/>
-     
-

- Subsequently, qualified reference to any of the attributes or the - group defined below will have the desired effect, e.g. -

-
-          <type . . .>
-           . . .
-           <attributeGroup ref="xml:specialAttrs"/>
-     
-

- will define a type which will schema-validate an instance element - with any of those attributes. -

-
-
-
-
- - - -
-

Versioning policy for this schema document

-
-

- In keeping with the XML Schema WG's standard versioning - policy, this schema document will persist at - - http://www.w3.org/2009/01/xml.xsd. -

-

- At the date of issue it can also be found at - - http://www.w3.org/2001/xml.xsd. -

-

- The schema document at that URI may however change in the future, - in order to remain compatible with the latest version of XML - Schema itself, or with the XML namespace itself. In other words, - if the XML Schema or XML namespaces change, the version of this - document at - http://www.w3.org/2001/xml.xsd - - will change accordingly; the version at - - http://www.w3.org/2009/01/xml.xsd - - will not change. -

-

- Previous dated (and unchanging) versions of this schema - document are at: -

- -
-
-
-
- -
- diff --git a/src/site/markdown/index.md b/src/site/markdown/index.md index 6fbd045..ad29726 100644 --- a/src/site/markdown/index.md +++ b/src/site/markdown/index.md @@ -4,17 +4,17 @@ The Windows Remote Management (WinRM) Java Client is a library that enables to: * Connect to a remote Windows server using one of the two authentication types (NTLM, KERBEROS) * Execute WMI Query Language (WQL) queries which uses HTTP/HTTPS protocols. -> ## ⚠️ Upgrade warning +> ## ⚠️ Upgrading from 1.x > -> The dependency-free **light** backend is now the **default**. Unlike the previous CXF-based client, -> which silently trusted every TLS certificate, the light backend **validates the server certificate -> and verifies the hostname by default**, so **WinRM-over-HTTPS connections to hosts with self-signed -> or untrusted certificates will now fail** during the TLS handshake. To restore connectivity, either -> install the certificate into a Java trust store, set `-Dorg.metricshub.winrm.tls.insecure=true` -> (insecure — for testing only), or select the legacy backend with -> `-Dorg.metricshub.winrm.backend=cxf`. The light backend supports NTLM over HTTP/HTTPS and Kerberos -> (SPNEGO) over HTTPS; the CXF backend remains available via that property and will be removed in a -> future major release. +> Version 2.0.0 **removed the legacy Apache CXF backend**: the dependency-free client is the only +> implementation (same public API). Unlike the CXF-based client, which silently trusted every TLS +> certificate, it **validates the server certificate and verifies the hostname by default**, so +> **WinRM-over-HTTPS connections to hosts with self-signed or untrusted certificates will fail** +> during the TLS handshake. To restore connectivity, either install the certificate into a Java +> trust store or set `-Dorg.metricshub.winrm.tls.insecure=true` (insecure — for testing only). +> The client supports NTLM over HTTP/HTTPS and Kerberos (SPNEGO) over HTTPS. Setting +> `-Dorg.metricshub.winrm.backend=cxf` now fails with a clear error; remove the property (or stay +> on winrm-java 1.x). # How to run the WinRM Client inside Java diff --git a/src/test/java/org/metricshub/winrm/BackendDifferentialTest.java b/src/test/java/org/metricshub/winrm/BackendDifferentialTest.java deleted file mode 100644 index dd6feb6..0000000 --- a/src/test/java/org/metricshub/winrm/BackendDifferentialTest.java +++ /dev/null @@ -1,201 +0,0 @@ -package org.metricshub.winrm; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Paths; -import java.util.List; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.condition.EnabledIfSystemProperty; -import org.metricshub.winrm.exceptions.WinRMException; -import org.metricshub.winrm.service.WinRMExecutorFactory; -import org.metricshub.winrm.service.client.auth.AuthenticationEnum; -import org.metricshub.winrm.wql.WinRMWqlExecutor; - -/** - * Differential harness (issue #107): runs the same operations through the legacy CXF backend and - * the light backend against a REAL WinRM host and asserts the results match — the go/no-go gate - * for removing CXF. Disabled unless {@code winrm.diff.host} is set, so it never runs in CI. - * - *

One-command run against a lab host: - * - *

- * mvn test -Dtest=BackendDifferentialTest -Dmaven.javadoc.skip=true \
- *   -Dwinrm.diff.host=myhost.example.com \
- *   -Dwinrm.diff.protocol=https \
- *   -Dwinrm.diff.username='MYDOMAIN\myuser' \
- *   -Dwinrm.diff.password-file=/path/to/password.txt
- * 
- * - *

Optional properties: {@code winrm.diff.port} (defaults to 5985/5986 by protocol), - * {@code winrm.diff.password} (inline, instead of the file), {@code winrm.diff.namespace}, - * {@code winrm.diff.wql}, {@code winrm.diff.command}, {@code winrm.diff.badcreds=true} to also - * exercise the wrong-password parity check (off by default — it triggers failed logons on the - * host), and {@code winrm.diff.tls.insecure=false} to validate TLS on the light backend instead - * of matching the CXF backend's trust-all behavior. - */ -@EnabledIfSystemProperty(named = "winrm.diff.host", matches = ".+") -class BackendDifferentialTest { - - private static String host; - private static WinRMHttpProtocolEnum protocol; - private static Integer port; - private static String username; - private static char[] password; - private static String namespace; - private static String wql; - private static String command; - - @BeforeAll - static void readConfiguration() throws Exception { - host = System.getProperty("winrm.diff.host"); - protocol = - "https".equalsIgnoreCase(System.getProperty("winrm.diff.protocol", "http")) - ? WinRMHttpProtocolEnum.HTTPS - : WinRMHttpProtocolEnum.HTTP; - final String portProperty = System.getProperty("winrm.diff.port"); - port = portProperty == null ? null : Integer.valueOf(portProperty); - username = System.getProperty("winrm.diff.username"); - namespace = System.getProperty("winrm.diff.namespace"); - wql = System.getProperty("winrm.diff.wql", "SELECT Caption FROM Win32_OperatingSystem"); - command = System.getProperty("winrm.diff.command", "echo winrm-diff"); - - final String inline = System.getProperty("winrm.diff.password"); - if (inline != null) { - password = inline.toCharArray(); - } else { - final String file = System.getProperty("winrm.diff.password-file"); - if (file == null) { - throw new IllegalArgumentException("Set winrm.diff.password or winrm.diff.password-file"); - } - password = new String(Files.readAllBytes(Paths.get(file)), StandardCharsets.UTF_8).trim().toCharArray(); - } - - // The CXF backend trusts every certificate; give the light backend the same behavior by - // default so the differential compares the protocol, not the trust policy. - if (!"false".equalsIgnoreCase(System.getProperty("winrm.diff.tls.insecure"))) { - System.setProperty("org.metricshub.winrm.tls.insecure", "true"); - } - } - - @AfterEach - void clearBackend() { - System.clearProperty(WinRMExecutorFactory.BACKEND_PROPERTY); - } - - private static T withBackend(final String backend, final Operation operation) throws Exception { - System.setProperty(WinRMExecutorFactory.BACKEND_PROPERTY, backend); - try { - return operation.run(); - } finally { - System.clearProperty(WinRMExecutorFactory.BACKEND_PROPERTY); - } - } - - private interface Operation { - T run() throws Exception; - } - - private static WinRMWqlExecutor wql(final String query, final char[] pwd) throws Exception { - return WinRMWqlExecutor.executeWql( - protocol, - host, - port, - username, - pwd, - namespace, - query, - 30_000L, - null, - List.of(AuthenticationEnum.NTLM) - ); - } - - @Test - void wqlResultsMatch() throws Exception { - final WinRMWqlExecutor cxf = withBackend("cxf", () -> wql(wql, password)); - final WinRMWqlExecutor light = withBackend("light", () -> wql(wql, password)); - - assertEquals(cxf.getHeaders(), light.getHeaders()); - assertEquals(cxf.getRows(), light.getRows()); - } - - @Test - void commandResultsMatch() throws Exception { - final WindowsRemoteCommandResult cxf = withBackend( - "cxf", - () -> - org.metricshub.winrm.command.WinRMCommandExecutor.execute( - command, - protocol, - host, - port, - username, - password, - null, - 30_000L, - null, - null, - List.of(AuthenticationEnum.NTLM) - ) - ); - final WindowsRemoteCommandResult light = withBackend( - "light", - () -> - org.metricshub.winrm.command.WinRMCommandExecutor.execute( - command, - protocol, - host, - port, - username, - password, - null, - 30_000L, - null, - null, - List.of(AuthenticationEnum.NTLM) - ) - ); - - assertEquals(cxf.getStatusCode(), light.getStatusCode()); - assertEquals(cxf.getStdout(), light.getStdout()); - assertEquals(cxf.getStderr(), light.getStderr()); - } - - @Test - void serverFaultTextMatches() throws Exception { - // Both backends must surface the same server fault text for a bad class (the light backend - // adds an informative prefix and the WSManFault detail on top — a contains()-compatible superset). - final String badClass = "SELECT Name FROM No_Such_Class_Diff_42"; - final WinRMException cxf = assertThrows( - WinRMException.class, - () -> withBackend("cxf", () -> wql(badClass, password)) - ); - final WinRMException light = assertThrows( - WinRMException.class, - () -> withBackend("light", () -> wql(badClass, password)) - ); - assertTrue( - light.getMessage().contains(cxf.getMessage().trim()), - () -> - "light message does not contain the CXF fault text\nCXF: " + - cxf.getMessage() + - "\nlight: " + - light.getMessage() - ); - } - - @Test - @EnabledIfSystemProperty(named = "winrm.diff.badcreds", matches = "true") - void authenticationErrorMessagesAreIdentical() throws Exception { - final char[] wrong = "definitely-wrong-password".toCharArray(); - final WinRMException cxf = assertThrows(WinRMException.class, () -> withBackend("cxf", () -> wql(wql, wrong))); - final WinRMException light = assertThrows(WinRMException.class, () -> withBackend("light", () -> wql(wql, wrong))); - assertEquals(cxf.getMessage(), light.getMessage()); - } -} diff --git a/src/test/java/org/metricshub/winrm/CatalogResolutionTest.java b/src/test/java/org/metricshub/winrm/CatalogResolutionTest.java deleted file mode 100644 index e57f7d4..0000000 --- a/src/test/java/org/metricshub/winrm/CatalogResolutionTest.java +++ /dev/null @@ -1,64 +0,0 @@ -package org.metricshub.winrm; - -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.io.InputStream; -import java.net.URI; -import org.apache.cxf.Bus; -import org.apache.cxf.BusFactory; -import org.apache.cxf.catalog.OASISCatalogManager; -import org.junit.jupiter.api.Test; - -/** - * Verifies that the JAX-WS catalog shipped at META-INF/jax-ws-catalog.xml is - * auto-discovered by Apache CXF and remaps the absolute schema URLs referenced - * by wsdl/WinRM.wsdl to local classpath resources. - * - *

Without this mapping the WSDL loader fetches dsp8033 / dsp8034 / xml.xsd / - * ws-addr.xsd from schemas.dmtf.org / www.w3.org over the network. On offline - * or restricted-egress hosts that fetch blocks for ~75 s (OS TCP timeout) and - * the connection attempt fails with - * {@code WSDLException(PARSER_ERROR) ... Caused by: ConnectException}. - */ -class CatalogResolutionTest { - - @Test - void catalogResolvesWsdlImportUrlsToClasspathResources() throws Exception { - // Dedicated Bus so the test neither leaks CXF resources nor mutates the - // JVM-wide default Bus shared with other tests. - final Bus bus = BusFactory.newInstance().createBus(); - try { - final OASISCatalogManager catalog = OASISCatalogManager.getCatalogManager(bus); - assertNotNull(catalog, "CXF OASISCatalogManager must be available"); - - assertResolvesToClasspath(catalog, "http://schemas.dmtf.org/wbem/wsman/1/dsp8033_1.0.xsd", "dsp8033_1.0.xsd"); - assertResolvesToClasspath(catalog, "http://schemas.dmtf.org/wbem/wsman/1/dsp8034_1.0.xsd", "dsp8034_1.0.xsd"); - assertResolvesToClasspath(catalog, "http://www.w3.org/2001/xml.xsd", "xml.xsd"); - assertResolvesToClasspath(catalog, "http://www.w3.org/2006/03/addressing/ws-addr.xsd", "ws-addr.xsd"); - } finally { - bus.shutdown(true); - } - } - - private static void assertResolvesToClasspath( - final OASISCatalogManager catalog, - final String systemId, - final String expectedSuffix - ) throws Exception { - final String resolved = catalog.resolveSystem(systemId); - assertNotNull(resolved, "catalog did not resolve " + systemId); - // Must NOT be a network URL — otherwise CXF will still hit the network at runtime. - assertFalse( - resolved.startsWith("http://") || resolved.startsWith("https://"), - "catalog returned a network URL for " + systemId + " -> " + resolved - ); - assertTrue(resolved.endsWith(expectedSuffix), "resolved URI does not end with " + expectedSuffix + ": " + resolved); - // The mapping must point at a resource that actually exists and is readable, - // not just a well-formed URI. - try (InputStream stream = new URI(resolved).toURL().openStream()) { - assertTrue(stream.read() != -1, "resolved URI is empty: " + resolved); - } - } -} diff --git a/src/test/java/org/metricshub/winrm/WinRMLiveTest.java b/src/test/java/org/metricshub/winrm/WinRMLiveTest.java new file mode 100644 index 0000000..b2c7984 --- /dev/null +++ b/src/test/java/org/metricshub/winrm/WinRMLiveTest.java @@ -0,0 +1,116 @@ +package org.metricshub.winrm; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.List; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.metricshub.winrm.service.client.auth.AuthenticationEnum; +import org.metricshub.winrm.wql.WinRMWqlExecutor; + +/** + * Live smoke test against a REAL WinRM host — the successor of the pre-2.0.0 CXF-vs-light + * differential harness (the CXF baseline was removed with the backend; result parity was + * proven and gated before removal). Disabled unless {@code winrm.live.host} is set, so it + * never runs in CI. + * + *

One-command run against a lab host: + * + *

+ * mvn test -Dtest=WinRMLiveTest \
+ *   -Dwinrm.live.host=myhost.example.com \
+ *   -Dwinrm.live.protocol=https \
+ *   -Dwinrm.live.username='MYDOMAIN\myuser' \
+ *   -Dwinrm.live.password-file=/path/to/password.txt
+ * 
+ * + *

Optional properties: {@code winrm.live.port} (defaults to 5985/5986 by protocol), + * {@code winrm.live.password} (inline, instead of the file), {@code winrm.live.namespace}, + * {@code winrm.live.wql}, {@code winrm.live.command}, and {@code winrm.live.tls.insecure=true} + * to skip TLS validation for hosts with self-signed certificates. + */ +@EnabledIfSystemProperty(named = "winrm.live.host", matches = ".+") +class WinRMLiveTest { + + private static String host; + private static WinRMHttpProtocolEnum protocol; + private static Integer port; + private static String username; + private static char[] password; + private static String namespace; + private static String wql; + private static String command; + + @BeforeAll + static void readConfiguration() throws Exception { + host = System.getProperty("winrm.live.host"); + protocol = + "https".equalsIgnoreCase(System.getProperty("winrm.live.protocol", "http")) + ? WinRMHttpProtocolEnum.HTTPS + : WinRMHttpProtocolEnum.HTTP; + final String portProperty = System.getProperty("winrm.live.port"); + port = portProperty == null ? null : Integer.valueOf(portProperty); + username = System.getProperty("winrm.live.username"); + namespace = System.getProperty("winrm.live.namespace"); + wql = System.getProperty("winrm.live.wql", "SELECT Caption FROM Win32_OperatingSystem"); + command = System.getProperty("winrm.live.command", "echo winrm-live"); + + final String inline = System.getProperty("winrm.live.password"); + if (inline != null) { + password = inline.toCharArray(); + } else { + final String file = System.getProperty("winrm.live.password-file"); + if (file == null) { + throw new IllegalArgumentException("Set winrm.live.password or winrm.live.password-file"); + } + password = new String(Files.readAllBytes(Paths.get(file)), StandardCharsets.UTF_8).trim().toCharArray(); + } + + if ("true".equalsIgnoreCase(System.getProperty("winrm.live.tls.insecure"))) { + System.setProperty("org.metricshub.winrm.tls.insecure", "true"); + } + } + + @Test + void wqlReturnsRows() throws Exception { + final WinRMWqlExecutor result = WinRMWqlExecutor.executeWql( + protocol, + host, + port, + username, + password, + namespace, + wql, + 30_000L, + null, + List.of(AuthenticationEnum.NTLM) + ); + assertFalse(result.getHeaders().isEmpty(), "WQL result must have headers"); + assertFalse(result.getRows().isEmpty(), "WQL result must have rows"); + } + + @Test + void commandSucceeds() throws Exception { + final WindowsRemoteCommandResult result = org.metricshub.winrm.command.WinRMCommandExecutor.execute( + command, + protocol, + host, + port, + username, + password, + null, + 30_000L, + null, + null, + List.of(AuthenticationEnum.NTLM) + ); + assertEquals(0, result.getStatusCode(), () -> "stderr: " + result.getStderr()); + assertTrue(result.getStdout().length() > 0, "command must produce stdout"); + } +} diff --git a/src/test/java/org/metricshub/winrm/command/WinRMCommandExecutorTest.java b/src/test/java/org/metricshub/winrm/command/WinRMCommandExecutorTest.java index 590309d..ef9f08e 100644 --- a/src/test/java/org/metricshub/winrm/command/WinRMCommandExecutorTest.java +++ b/src/test/java/org/metricshub/winrm/command/WinRMCommandExecutorTest.java @@ -30,7 +30,6 @@ import org.metricshub.winrm.WindowsRemoteProcessUtils; import org.metricshub.winrm.service.WinRMEndpoint; import org.metricshub.winrm.service.WinRMExecutorFactory; -import org.metricshub.winrm.service.WinRMService; import org.metricshub.winrm.service.client.auth.AuthenticationEnum; import org.metricshub.winrm.shares.SmbTempShare; import org.mockito.MockedStatic; @@ -201,7 +200,7 @@ void testExecute() throws Exception { .thenReturn("launch remote/localFile"); final SmbTempShare smbTempShare = mock(SmbTempShare.class); - final WinRMService winRMService = mock(WinRMService.class); + final WindowsRemoteExecutor winRMService = mock(WindowsRemoteExecutor.class); mockedSmbTempShare .when(() -> SmbTempShare.createInstance(any(WinRMEndpoint.class), anyLong(), isNull(), isNull())) diff --git a/src/test/java/org/metricshub/winrm/service/WinRMExecutorFactoryTest.java b/src/test/java/org/metricshub/winrm/service/WinRMExecutorFactoryTest.java index 2eb3ae7..554afb5 100644 --- a/src/test/java/org/metricshub/winrm/service/WinRMExecutorFactoryTest.java +++ b/src/test/java/org/metricshub/winrm/service/WinRMExecutorFactoryTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.List; import org.junit.jupiter.api.AfterEach; @@ -58,20 +59,21 @@ void defaultBackendIsLight() throws Exception { } @Test - void cxfBackendSelectedViaProperty() throws Exception { - // The CXF backend stays reachable via the property while light matures. Building the client - // does not open a connection, so this stays offline. + void cxfBackendRejectedWithRemovalMessage() { + // The CXF backend was removed in 2.0.0. An operator who explicitly pinned it must get a clear + // error, not be silently switched to another implementation. System.setProperty(WinRMExecutorFactory.BACKEND_PROPERTY, "cxf"); - try ( - final WindowsRemoteExecutor executor = WinRMExecutorFactory.createInstance( - endpoint(WinRMHttpProtocolEnum.HTTP), - 30000L, - null, - List.of(AuthenticationEnum.NTLM) - ) - ) { - assertInstanceOf(WinRMService.class, executor); - } + final WinRMException e = assertThrows( + WinRMException.class, + () -> + WinRMExecutorFactory.createInstance( + endpoint(WinRMHttpProtocolEnum.HTTP), + 30000L, + null, + List.of(AuthenticationEnum.NTLM) + ) + ); + assertTrue(e.getMessage().contains("removed in winrm-java 2.0.0"), e.getMessage()); } @Test diff --git a/src/test/java/org/metricshub/winrm/service/WinRMServiceTest.java b/src/test/java/org/metricshub/winrm/service/WinRMServiceTest.java deleted file mode 100644 index 543056a..0000000 --- a/src/test/java/org/metricshub/winrm/service/WinRMServiceTest.java +++ /dev/null @@ -1,348 +0,0 @@ -package org.metricshub.winrm.service; - -import static java.nio.charset.StandardCharsets.UTF_8; -import static java.util.Collections.emptyList; -import static java.util.Collections.singletonList; -import static org.junit.jupiter.api.Assertions.assertArrayEquals; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.metricshub.winrm.Utils.EMPTY; -import static org.metricshub.winrm.service.WinRMService.createInstance; -import static org.metricshub.winrm.service.client.auth.AuthenticationEnum.NTLM; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyList; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.isNull; -import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.mockStatic; -import static org.mockito.Mockito.spy; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; - -import java.io.StringWriter; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.List; -import org.apache.cxf.Bus; -import org.apache.cxf.endpoint.Client; -import org.apache.cxf.endpoint.Endpoint; -import org.apache.cxf.service.model.EndpointInfo; -import org.apache.cxf.transport.http.HTTPConduitFactory; -import org.apache.cxf.transport.http.asyncclient.AsyncHTTPConduitFactory; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; -import org.metricshub.winrm.WindowsRemoteCommandResult; -import org.metricshub.winrm.exceptions.WqlQuerySyntaxException; -import org.metricshub.winrm.service.client.WinRMInvocationHandler; -import org.metricshub.winrm.service.client.auth.AuthenticationEnum; -import org.metricshub.winrm.service.enumeration.EnumerateResponse; -import org.metricshub.winrm.service.enumeration.EnumerationContextType; -import org.metricshub.winrm.service.transfer.ResourceCreated; -import org.metricshub.winrm.service.wsman.SelectorSetType; -import org.mockito.MockedStatic; - -class WinRMServiceTest { - - private static final MockedStatic MOCKED_WIN_RM_SERVICE = mockStatic(WinRMService.class); - - @BeforeAll - static void init() { - MOCKED_WIN_RM_SERVICE - .when(() -> createInstance(isNull(), anyLong(), any(Path.class), anyList())) - .thenCallRealMethod(); - - MOCKED_WIN_RM_SERVICE - .when(() -> createInstance(any(WinRMEndpoint.class), anyLong(), any(Path.class), anyList())) - .thenCallRealMethod(); - - MOCKED_WIN_RM_SERVICE - .when(() -> createInstance(any(WinRMEndpoint.class), anyLong(), isNull(), isNull())) - .thenCallRealMethod(); - - MOCKED_WIN_RM_SERVICE - .when(() -> - WinRMService.createWinRMInvocationHandlerInstance( - any(WinRMEndpoint.class), - any(Bus.class), - anyLong(), - anyString(), - isNull(), - anyList() - ) - ) - .thenReturn(mock(WinRMInvocationHandler.class)); - - MOCKED_WIN_RM_SERVICE - .when(() -> - WinRMService.createWinRMInvocationHandlerInstance( - any(WinRMEndpoint.class), - any(Bus.class), - anyLong(), - isNull(), - isNull(), - anyList() - ) - ) - .thenReturn(mock(WinRMInvocationHandler.class)); - } - - @AfterAll - static void closeMockStatics() { - MOCKED_WIN_RM_SERVICE.close(); - } - - @Test - void testCreateInstance() throws Exception { - final String hostname = "host"; - final String username = "user"; - final String rawUsername = "domain\\" + username; - final char[] password = "pwd".toCharArray(); - final WinRMEndpoint winRMEndpoint = new WinRMEndpoint(null, hostname, null, rawUsername, password, null); - final long timeout = 30 * 1000L; - final Path ticketCache = Paths.get("path"); - final List authentications = singletonList(NTLM); - - //check arguments - assertThrows(IllegalArgumentException.class, () -> createInstance(null, timeout, ticketCache, authentications)); - - assertThrows( - IllegalArgumentException.class, - () -> createInstance(winRMEndpoint, -1L, ticketCache, authentications) - ); - - assertThrows(IllegalArgumentException.class, () -> createInstance(winRMEndpoint, 0L, ticketCache, authentications)); - - final WinRMService winRMService1 = createInstance(winRMEndpoint, timeout, null, null); - assertNotNull(winRMService1); - assertEquals(1, winRMService1.getUseCount()); - assertEquals(rawUsername, winRMService1.getUsername()); - assertEquals(hostname, winRMService1.getHostname()); - assertArrayEquals(password, winRMService1.getPassword()); - assertTrue(winRMService1.isConnected()); - - final WinRMService winRMService2 = createInstance(winRMEndpoint, timeout, null, null); - assertNotNull(winRMService2); - assertEquals(2, winRMService1.getUseCount()); - assertEquals(2, winRMService2.getUseCount()); - assertEquals(rawUsername, winRMService2.getUsername()); - assertEquals(hostname, winRMService2.getHostname()); - assertArrayEquals(password, winRMService2.getPassword()); - assertTrue(winRMService1.isConnected()); - assertTrue(winRMService2.isConnected()); - - winRMService1.close(); - assertTrue(winRMService1.isConnected()); - assertTrue(winRMService2.isConnected()); - assertEquals(1, winRMService1.getUseCount()); - assertEquals(1, winRMService2.getUseCount()); - - winRMService2.close(); - assertFalse(winRMService1.isConnected()); - assertFalse(winRMService2.isConnected()); - assertEquals(0, winRMService1.getUseCount()); - assertEquals(0, winRMService2.getUseCount()); - } - - @Test - void testExecuteCommand() throws Exception { - final WinRMEndpoint winRMEndpoint = new WinRMEndpoint( - null, - "host", - null, - "domain\\user", - "pwd".toCharArray(), - null - ); - final long timeout = 30 * 1000L; - final String command = "ipconfig"; - final String workingDirectory = "dir"; - - //check arguments - try (final WinRMService winRMService = createInstance(winRMEndpoint, timeout, null, null)) { - assertThrows( - IllegalArgumentException.class, - () -> winRMService.executeCommand(null, workingDirectory, UTF_8, timeout) - ); - - assertThrows( - IllegalArgumentException.class, - () -> winRMService.executeCommand(command, workingDirectory, UTF_8, -1L) - ); - - assertThrows( - IllegalArgumentException.class, - () -> winRMService.executeCommand(command, workingDirectory, UTF_8, 0L) - ); - } - - try (final WinRMService winRMService = spy(createInstance(winRMEndpoint, timeout, null, null))) { - doNothing().when(winRMService).checkConnectedFirst(); - - doReturn(new ResourceCreated()).when(winRMService).create(null); - - doReturn(0).when(winRMService).execute(eq(command), any(StringWriter.class), any(StringWriter.class), eq(UTF_8)); - - final WindowsRemoteCommandResult actual = winRMService.executeCommand(command, null, null, timeout); - - assertEquals(EMPTY, actual.getStdout()); - assertEquals(EMPTY, actual.getStderr()); - } - - try (final WinRMService winRMService = spy(createInstance(winRMEndpoint, timeout, null, null))) { - doNothing().when(winRMService).checkConnectedFirst(); - - doReturn(new SelectorSetType()).when(winRMService).getShellSelector(); - verify(winRMService, times(0)).create(null); - - doReturn(0).when(winRMService).execute(eq(command), any(StringWriter.class), any(StringWriter.class), eq(UTF_8)); - - final WindowsRemoteCommandResult actual = winRMService.executeCommand(command, null, null, timeout); - - assertEquals(EMPTY, actual.getStdout()); - assertEquals(EMPTY, actual.getStderr()); - } - } - - @Test - void testExecuteWql() throws Exception { - final WinRMEndpoint winRMEndpoint = new WinRMEndpoint( - null, - "host", - null, - "domain\\user", - "pwd".toCharArray(), - null - ); - final long timeout = 30 * 1000L; - final String wqlQuery = "Select Name,Path from Win32_Share"; - - //check arguments - try (final WinRMService winRMService = createInstance(winRMEndpoint, timeout, null, null)) { - assertThrows(IllegalArgumentException.class, () -> winRMService.executeWql(null, timeout)); - - assertThrows(WqlQuerySyntaxException.class, () -> winRMService.executeWql(EMPTY, timeout)); - - assertThrows(WqlQuerySyntaxException.class, () -> winRMService.executeWql("Win32_Share", timeout)); - - assertThrows(IllegalArgumentException.class, () -> winRMService.executeWql(wqlQuery, -1L)); - - assertThrows(IllegalArgumentException.class, () -> winRMService.executeWql(wqlQuery, 0L)); - } - - try (final WinRMService winRMService = spy(createInstance(winRMEndpoint, timeout, null, null))) { - doNothing().when(winRMService).checkConnectedFirst(); - - final EnumerationContextType contextType = mock(EnumerationContextType.class); - final EnumerateResponse enumerateResponse = new EnumerateResponse(); - enumerateResponse.setEnumerationContext(contextType); - - doReturn(enumerateResponse).when(winRMService).enumerate(wqlQuery); - doReturn(emptyList()).when(contextType).getContent(); - - doReturn(true).when(winRMService).getItemsFrom(eq(enumerateResponse), anyList()); - - verify(winRMService, times(0)).getContextIdFrom(eq(contextType)); - verify(winRMService, times(0)).pull(anyString(), anyList()); - - assertEquals(emptyList(), winRMService.executeWql(wqlQuery, timeout)); - } - - try (final WinRMService winRMService = spy(createInstance(winRMEndpoint, timeout, null, null))) { - doNothing().when(winRMService).checkConnectedFirst(); - - final EnumerationContextType contextType = mock(EnumerationContextType.class); - final EnumerateResponse enumerateResponse = new EnumerateResponse(); - enumerateResponse.setEnumerationContext(contextType); - - doReturn(enumerateResponse).when(winRMService).enumerate(wqlQuery); - doReturn(emptyList()).when(contextType).getContent(); - - doReturn(false).when(winRMService).getItemsFrom(eq(enumerateResponse), anyList()); - - doReturn("nextContextId").when(winRMService).getContextIdFrom(eq(contextType)); - doReturn("nextContextId").when(winRMService).pull(anyString(), anyList()); - - assertEquals(emptyList(), winRMService.executeWql(wqlQuery, timeout)); - } - } - - @Test - void testCloseShutdownsConduitFactories() throws Exception { - // Use a unique endpoint to avoid interference with other test stubs - final WinRMEndpoint endpointForFactoryTest = new WinRMEndpoint( - null, - "factory-test-host", - null, - "user", - "pwd".toCharArray(), - null - ); - - // Set up a mock factory that will be stored in the client's endpoint info - final AsyncHTTPConduitFactory mockFactory = mock(AsyncHTTPConduitFactory.class); - - // Set up mock endpoint info containing the factory - final EndpointInfo mockEndpointInfo = mock(EndpointInfo.class); - doReturn(mockFactory).when(mockEndpointInfo).getProperty(HTTPConduitFactory.class.getName()); - - // Set up mock endpoint - final Endpoint mockEndpoint = mock(Endpoint.class); - doReturn(mockEndpointInfo).when(mockEndpoint).getEndpointInfo(); - - // Set up mock client - final Client mockClient = mock(Client.class); - doReturn(mockEndpoint).when(mockClient).getEndpoint(); - - // Set up mock invocation handlers that expose the configured client - final WinRMInvocationHandler cmdHandler = mock(WinRMInvocationHandler.class); - doReturn(mockClient).when(cmdHandler).getClient(); - - final WinRMInvocationHandler wqlHandler = mock(WinRMInvocationHandler.class); - doReturn(mockClient).when(wqlHandler).getClient(); - - // Override the default stubs for this specific endpoint (registered later, so they take precedence) - MOCKED_WIN_RM_SERVICE - .when(() -> - WinRMService.createWinRMInvocationHandlerInstance( - eq(endpointForFactoryTest), - any(Bus.class), - anyLong(), - isNull(), - isNull(), - anyList() - ) - ) - .thenReturn(cmdHandler); - - MOCKED_WIN_RM_SERVICE - .when(() -> - WinRMService.createWinRMInvocationHandlerInstance( - eq(endpointForFactoryTest), - any(Bus.class), - anyLong(), - anyString(), - isNull(), - anyList() - ) - ) - .thenReturn(wqlHandler); - - // Create the service and immediately close it - // (null ticketCache and authentications: the class-level createInstance stub calling the real - // method only matches isNull() for both) - final WinRMService winRMService = createInstance(endpointForFactoryTest, 30000L, null, null); - assertNotNull(winRMService); - winRMService.close(); - - // Verify that shutdown() was called on the factory for both the cmd and wql clients - verify(mockFactory, times(2)).shutdown(); - } -} diff --git a/src/test/java/org/metricshub/winrm/service/client/WinRMInvocationHandlerTest.java b/src/test/java/org/metricshub/winrm/service/client/WinRMInvocationHandlerTest.java deleted file mode 100644 index cef39eb..0000000 --- a/src/test/java/org/metricshub/winrm/service/client/WinRMInvocationHandlerTest.java +++ /dev/null @@ -1,792 +0,0 @@ -package org.metricshub.winrm.service.client; - -import static java.util.Arrays.asList; -import static java.util.Collections.singletonList; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.metricshub.winrm.Utils.EMPTY; -import static org.metricshub.winrm.Utils.checkNonNull; -import static org.metricshub.winrm.Utils.checkNonNullField; -import static org.metricshub.winrm.WinRMHttpProtocolEnum.HTTPS; -import static org.metricshub.winrm.service.client.WinRMInvocationHandler.computeCredentials; -import static org.metricshub.winrm.service.client.WinRMInvocationHandler.createCallInfos; -import static org.metricshub.winrm.service.client.WinRMInvocationHandler.createCredentials; -import static org.metricshub.winrm.service.client.WinRMInvocationHandler.createWinRMWebService; -import static org.metricshub.winrm.service.client.WinRMInvocationHandler.getWebServiceClient; -import static org.metricshub.winrm.service.client.auth.AuthenticationEnum.KERBEROS; -import static org.metricshub.winrm.service.client.auth.AuthenticationEnum.NTLM; -import static org.metricshub.winrm.service.client.auth.kerberos.KerberosUtils.createCredentials; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.isNull; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.mockStatic; -import static org.mockito.Mockito.reset; -import static org.mockito.Mockito.spy; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; - -import jakarta.xml.soap.SOAPFactory; -import jakarta.xml.ws.WebServiceException; -import jakarta.xml.ws.soap.SOAPFaultException; -import java.io.IOException; -import java.lang.reflect.Method; -import java.nio.file.Path; -import java.util.LinkedList; -import java.util.List; -import java.util.Queue; -import java.util.stream.Collectors; -import org.apache.cxf.Bus; -import org.apache.cxf.endpoint.Client; -import org.apache.http.auth.Credentials; -import org.apache.http.auth.KerberosCredentials; -import org.apache.http.auth.NTCredentials; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; -import org.metricshub.winrm.Utils; -import org.metricshub.winrm.exceptions.KerberosCredentialsException; -import org.metricshub.winrm.service.WinRMEndpoint; -import org.metricshub.winrm.service.WinRMWebService; -import org.metricshub.winrm.service.client.WinRMInvocationHandler.AuthCredentials; -import org.metricshub.winrm.service.client.WinRMInvocationHandler.RetryAuthenticationException; -import org.metricshub.winrm.service.client.WinRMInvocationHandler.RetryTgtExpirationException; -import org.metricshub.winrm.service.client.auth.AuthenticationEnum; -import org.metricshub.winrm.service.client.auth.kerberos.KerberosUtils; -import org.metricshub.winrm.service.client.auth.ntlm.NTCredentialsWithEncryption; -import org.metricshub.winrm.service.shell.Receive; -import org.metricshub.winrm.service.shell.ReceiveResponse; -import org.metricshub.winrm.service.wsman.Locale; -import org.metricshub.winrm.service.wsman.SelectorSetType; -import org.mockito.MockedStatic; - -class WinRMInvocationHandlerTest { - - private static final WinRMEndpoint WIN_RM_ENDPOINT = new WinRMEndpoint( - null, - "host", - null, - "JohnDoe", - "pwd".toCharArray(), - null - ); - - private static final WinRMEndpoint WIN_RM_ENDPOINT_2 = new WinRMEndpoint( - null, - "host2", - null, - "JohnDoe2", - "pwd".toCharArray(), - null - ); - - private static final WinRMEndpoint WIN_RM_ENDPOINT_3 = new WinRMEndpoint( - null, - "host3", - null, - "JohnDoe3", - "pwd".toCharArray(), - null - ); - - private static final Bus BUS = mock(Bus.class); - private static final long TIMEOUT = 120L; - private static final WinRMWebService WIN_RM_WS = mock(WinRMWebService.class); - private static final Client WS_CLIENT = mock(Client.class); - private static final KerberosCredentials KERBEROS_CREDENTIALS = mock(KerberosCredentials.class); - private static final NTCredentials NTC_CREDENTIALS = mock(NTCredentialsWithEncryption.class); - private static final Method RECEIVE_METHOD; - private static final Object[] RECEIVE_ARGS; - private static final List AUTHENTICATIONS = singletonList(NTLM); - - static { - try { - RECEIVE_METHOD = - WinRMWebService.class.getMethod( - "receive", - Receive.class, - String.class, - int.class, - String.class, - Locale.class, - SelectorSetType.class - ); - RECEIVE_ARGS = - new Object[] { new Receive(), "resourceURI", 512000, "PT60S", new Locale(), new SelectorSetType() }; - } catch (ReflectiveOperationException e) { - throw new ExceptionInInitializerError(e); - } - } - - private static final Queue AUTHENTICATIONS_KERBEROS_NTLM = asList(KERBEROS, NTLM) - .stream() - .collect(Collectors.toCollection(LinkedList::new)); - - private static final Queue AUTHENTICATIONS_NTLM = AUTHENTICATIONS - .stream() - .collect(Collectors.toCollection(LinkedList::new)); - - private static final Queue AUTHENTICATIONS_KERBEROS = singletonList(KERBEROS) - .stream() - .collect(Collectors.toCollection(LinkedList::new)); - - private static final AuthCredentials AUTH_CREDENTIALS_NTLM = new AuthCredentials(NTLM, NTC_CREDENTIALS); - - private static final AuthCredentials AUTH_CREDENTIALS_KERBEROS = new AuthCredentials(KERBEROS, KERBEROS_CREDENTIALS); - - private static final Object PROXY = new Object(); - private static final Object[] ARGS = {}; - private static final ReceiveResponse RESULT = mock(ReceiveResponse.class); - - /** to neutralize Utils.sleep */ - private static final MockedStatic MOCKED_UTILS = mockStatic(Utils.class); - - private static final MockedStatic MOCKED_WIN_RM_INVOCATION_HANDLER = mockStatic( - WinRMInvocationHandler.class - ); - - @BeforeAll - static void init() { - MOCKED_UTILS.when(() -> checkNonNull(isNull(), anyString())).thenCallRealMethod(); - MOCKED_UTILS.when(() -> checkNonNull(any(), anyString())).thenCallRealMethod(); - - MOCKED_UTILS.when(() -> checkNonNullField(isNull(), anyString())).thenCallRealMethod(); - MOCKED_UTILS.when(() -> checkNonNullField(any(), anyString())).thenCallRealMethod(); - - MOCKED_WIN_RM_INVOCATION_HANDLER.when(() -> createWinRMWebService(WIN_RM_ENDPOINT, BUS)).thenReturn(WIN_RM_WS); - - MOCKED_WIN_RM_INVOCATION_HANDLER - .when(() -> createCredentials(any(WinRMEndpoint.class), eq(NTLM), isNull())) - .thenCallRealMethod(); - - MOCKED_WIN_RM_INVOCATION_HANDLER - .when(() -> createCredentials(WIN_RM_ENDPOINT, KERBEROS, null)) - .thenCallRealMethod(); - - MOCKED_WIN_RM_INVOCATION_HANDLER - .when(() -> createCredentials(WIN_RM_ENDPOINT_2, KERBEROS, null)) - .thenThrow(KerberosCredentialsException.class); - - MOCKED_WIN_RM_INVOCATION_HANDLER - .when(() -> createCredentials(WIN_RM_ENDPOINT_2, NTLM, null)) - .thenReturn(NTC_CREDENTIALS); - - MOCKED_WIN_RM_INVOCATION_HANDLER - .when(() -> createCredentials(WIN_RM_ENDPOINT_3, NTLM, null)) - .thenThrow(IllegalStateException.class); - - MOCKED_WIN_RM_INVOCATION_HANDLER - .when(() -> createCredentials(WIN_RM_ENDPOINT_3, KERBEROS, null)) - .thenReturn(KERBEROS_CREDENTIALS); - - MOCKED_WIN_RM_INVOCATION_HANDLER - .when(() -> computeCredentials(WIN_RM_ENDPOINT, null, AUTHENTICATIONS_NTLM)) - .thenReturn(AUTH_CREDENTIALS_NTLM); - - MOCKED_WIN_RM_INVOCATION_HANDLER - .when(() -> computeCredentials(WIN_RM_ENDPOINT, null, AUTHENTICATIONS_KERBEROS_NTLM)) - .thenReturn(AUTH_CREDENTIALS_NTLM); - - MOCKED_WIN_RM_INVOCATION_HANDLER - .when(() -> computeCredentials(WIN_RM_ENDPOINT, null, AUTHENTICATIONS_KERBEROS)) - .thenReturn(AUTH_CREDENTIALS_KERBEROS); - - MOCKED_WIN_RM_INVOCATION_HANDLER - .when(() -> computeCredentials(eq(WIN_RM_ENDPOINT_2), isNull(), any())) - .thenCallRealMethod(); - - MOCKED_WIN_RM_INVOCATION_HANDLER - .when(() -> computeCredentials(eq(WIN_RM_ENDPOINT_3), isNull(), any())) - .thenCallRealMethod(); - - MOCKED_WIN_RM_INVOCATION_HANDLER - .when(() -> - getWebServiceClient( - eq(WIN_RM_ENDPOINT), - eq(TIMEOUT), - isNull(), - any(WinRMWebService.class), - any(Credentials.class) - ) - ) - .thenReturn(WS_CLIENT); - - MOCKED_WIN_RM_INVOCATION_HANDLER.when(() -> createCallInfos(isNull(), isNull())).thenCallRealMethod(); - - MOCKED_WIN_RM_INVOCATION_HANDLER.when(() -> createCallInfos(isNull(), any(Object[].class))).thenCallRealMethod(); - - MOCKED_WIN_RM_INVOCATION_HANDLER.when(() -> createCallInfos(any(Method.class), isNull())).thenCallRealMethod(); - - MOCKED_WIN_RM_INVOCATION_HANDLER - .when(() -> createCallInfos(any(Method.class), any(Object[].class))) - .thenCallRealMethod(); - } - - @AfterAll - static void closeMockStatics() { - MOCKED_UTILS.close(); - MOCKED_WIN_RM_INVOCATION_HANDLER.close(); - } - - @AfterEach - void resetMocks() { - reset(WIN_RM_WS); - } - - private static void verifyReceiveInvoked(final int times) { - verify(WIN_RM_WS, times(times)) - .receive(any(Receive.class), anyString(), anyInt(), anyString(), any(Locale.class), any(SelectorSetType.class)); - } - - @Test - void testNewWinRMInvocationHandlerInstance() { - final String resourceUri = "resourceURI"; - final Path ticketCache = mock(Path.class); - - final WinRMInvocationHandler winRMInvocationHandler = new WinRMInvocationHandler( - WIN_RM_ENDPOINT, - BUS, - TIMEOUT, - null, - null, - AUTHENTICATIONS - ); - - // check arguments - assertThrows( - IllegalArgumentException.class, - () -> new WinRMInvocationHandler(null, BUS, TIMEOUT, resourceUri, ticketCache, AUTHENTICATIONS) - ); - - assertThrows( - IllegalArgumentException.class, - () -> new WinRMInvocationHandler(WIN_RM_ENDPOINT, null, TIMEOUT, resourceUri, ticketCache, AUTHENTICATIONS) - ); - - assertThrows( - IllegalArgumentException.class, - () -> new WinRMInvocationHandler(WIN_RM_ENDPOINT, BUS, TIMEOUT, resourceUri, ticketCache, null) - ); - - assertNotNull(winRMInvocationHandler); - assertEquals(WS_CLIENT, winRMInvocationHandler.getClient()); - } - - @Test - void testInvoke() throws Throwable { - // check argument method null - { - final WinRMInvocationHandler winRMInvocationHandler = new WinRMInvocationHandler( - WIN_RM_ENDPOINT, - BUS, - TIMEOUT, - null, - null, - AUTHENTICATIONS - ); - - assertThrows(IllegalArgumentException.class, () -> winRMInvocationHandler.invoke(PROXY, null, ARGS)); - } - - // check KERBEROS authentication failure like a ticket validity expiration - try (final MockedStatic mockedKerberosUtils = mockStatic(KerberosUtils.class)) { - final List authentications = singletonList(KERBEROS); - - final WinRMInvocationHandler winRMInvocationHandler = spy( - new WinRMInvocationHandler(WIN_RM_ENDPOINT, BUS, TIMEOUT, null, null, authentications) - ); - - doThrow( - new RetryTgtExpirationException( - new RuntimeException("Authentication error on HTTP://host:5985 with user name \"JohnDoe\"") - ) - ) - .doReturn(RESULT) - .when(winRMInvocationHandler) - .invokeMethod(RECEIVE_METHOD, RECEIVE_ARGS); - - mockedKerberosUtils - .when(() -> KerberosUtils.createCredentials("JohnDoe", "pwd".toCharArray(), null)) - .thenReturn(KERBEROS_CREDENTIALS); - - assertEquals(RESULT, winRMInvocationHandler.invoke(PROXY, RECEIVE_METHOD, RECEIVE_ARGS)); - verify(winRMInvocationHandler, times(2)).invoke(PROXY, RECEIVE_METHOD, RECEIVE_ARGS); - } - - // check KERBEROS authentication failure like a ticket validity expiration with an exception and no more retry - try (final MockedStatic mockedKerberosUtils = mockStatic(KerberosUtils.class)) { - final List authentications = asList(KERBEROS, NTLM); - - final WinRMInvocationHandler winRMInvocationHandler = spy( - new WinRMInvocationHandler(WIN_RM_ENDPOINT, BUS, TIMEOUT, null, null, authentications) - ); - - doThrow( - new RetryTgtExpirationException( - new RuntimeException("Authentication error on HTTP://host:5985 with user name \"JohnDoe\"") - ) - ) - .doReturn(RESULT) - .when(winRMInvocationHandler) - .invokeMethod(RECEIVE_METHOD, RECEIVE_ARGS); - - doReturn(false).when(winRMInvocationHandler).continueToRetry(); - - mockedKerberosUtils - .when(() -> KerberosUtils.createCredentials("JohnDoe", "pwd".toCharArray(), null)) - .thenThrow(KerberosCredentialsException.class); - - assertThrows( - KerberosCredentialsException.class, - () -> winRMInvocationHandler.invoke(PROXY, RECEIVE_METHOD, RECEIVE_ARGS) - ); - verify(winRMInvocationHandler, times(1)).invoke(PROXY, RECEIVE_METHOD, RECEIVE_ARGS); - } - - // check KERBEROS authentication failure like a ticket validity expiration with an exception and a retry - try (final MockedStatic mockedKerberosUtils = mockStatic(KerberosUtils.class)) { - final List authentications = asList(KERBEROS, NTLM); - - final WinRMInvocationHandler winRMInvocationHandler = spy( - new WinRMInvocationHandler(WIN_RM_ENDPOINT, BUS, TIMEOUT, null, null, authentications) - ); - - doThrow( - new RetryTgtExpirationException( - new RuntimeException("Authentication error on HTTP://host:5985 with user name \"JohnDoe\"") - ) - ) - .doReturn(RESULT) - .when(winRMInvocationHandler) - .invokeMethod(RECEIVE_METHOD, RECEIVE_ARGS); - - doReturn(true).when(winRMInvocationHandler).continueToRetry(); - - mockedKerberosUtils - .when(() -> KerberosUtils.createCredentials("JohnDoe", "pwd".toCharArray(), null)) - .thenThrow(KerberosCredentialsException.class); - - assertEquals(RESULT, winRMInvocationHandler.invoke(PROXY, RECEIVE_METHOD, RECEIVE_ARGS)); - verify(winRMInvocationHandler, times(2)).invoke(PROXY, RECEIVE_METHOD, RECEIVE_ARGS); - } - - // check retry - { - final List authentications = asList(KERBEROS, NTLM); - - final WinRMInvocationHandler winRMInvocationHandler = spy( - new WinRMInvocationHandler(WIN_RM_ENDPOINT, BUS, TIMEOUT, null, null, authentications) - ); - - doThrow(new RetryAuthenticationException(new SOAPFaultException(SOAPFactory.newInstance().createFault()))) - .doReturn(RESULT) - .when(winRMInvocationHandler) - .invokeMethod(RECEIVE_METHOD, RECEIVE_ARGS); - - doReturn(true, false).when(winRMInvocationHandler).continueToRetry(); - - assertEquals(RESULT, winRMInvocationHandler.invoke(PROXY, RECEIVE_METHOD, RECEIVE_ARGS)); - verify(winRMInvocationHandler, times(2)).invoke(PROXY, RECEIVE_METHOD, RECEIVE_ARGS); - } - - // check KO no retries KERBEROS over HTTP - { - final List authentications = singletonList(KERBEROS); - - final WinRMInvocationHandler winRMInvocationHandler = spy( - new WinRMInvocationHandler(WIN_RM_ENDPOINT, BUS, TIMEOUT, null, null, authentications) - ); - - doThrow(new RetryAuthenticationException(new SOAPFaultException(SOAPFactory.newInstance().createFault()))) - .when(winRMInvocationHandler) - .invokeMethod(RECEIVE_METHOD, RECEIVE_ARGS); - - doReturn(false).when(winRMInvocationHandler).continueToRetry(); - - final RuntimeException exception = assertThrows( - RuntimeException.class, - () -> winRMInvocationHandler.invoke(PROXY, RECEIVE_METHOD, RECEIVE_ARGS) - ); - assertEquals("KERBEROS with encryption over HTTP is not implemented.", exception.getMessage()); - verify(winRMInvocationHandler, times(1)).invoke(PROXY, RECEIVE_METHOD, RECEIVE_ARGS); - } - - // check KO no retries - { - final List authentications = singletonList(NTLM); - - final WinRMInvocationHandler winRMInvocationHandler = spy( - new WinRMInvocationHandler(WIN_RM_ENDPOINT, BUS, TIMEOUT, null, null, authentications) - ); - - doThrow( - new RetryAuthenticationException( - new RuntimeException("Authentication error on HTTP://host:5985 with user name \"JohnDoe\"") - ) - ) - .when(winRMInvocationHandler) - .invokeMethod(RECEIVE_METHOD, RECEIVE_ARGS); - - doReturn(false).when(winRMInvocationHandler).continueToRetry(); - - assertThrows(RuntimeException.class, () -> winRMInvocationHandler.invoke(PROXY, RECEIVE_METHOD, RECEIVE_ARGS)); - verify(winRMInvocationHandler, times(1)).invoke(PROXY, RECEIVE_METHOD, RECEIVE_ARGS); - } - - // check OK without retry - { - final WinRMInvocationHandler winRMInvocationHandler = spy( - new WinRMInvocationHandler(WIN_RM_ENDPOINT, BUS, TIMEOUT, null, null, AUTHENTICATIONS) - ); - - doReturn(RESULT).when(winRMInvocationHandler).invokeMethod(RECEIVE_METHOD, RECEIVE_ARGS); - - assertEquals(RESULT, winRMInvocationHandler.invoke(PROXY, RECEIVE_METHOD, RECEIVE_ARGS)); - verify(winRMInvocationHandler, times(1)).invoke(PROXY, RECEIVE_METHOD, RECEIVE_ARGS); - } - } - - @Test - void testComputeCredentials() throws Exception { - // check KerberosCredentialsException no retry - { - final Queue authenticationsQueue = singletonList(KERBEROS) - .stream() - .collect(Collectors.toCollection(LinkedList::new)); - - assertThrows( - KerberosCredentialsException.class, - () -> computeCredentials(WIN_RM_ENDPOINT_2, null, authenticationsQueue) - ); - } - - // check KerberosCredentialsException OK with retry - { - final Queue authenticationsQueue = asList(KERBEROS, NTLM) - .stream() - .collect(Collectors.toCollection(LinkedList::new)); - - assertEquals(AUTH_CREDENTIALS_NTLM, computeCredentials(WIN_RM_ENDPOINT_2, null, authenticationsQueue)); - } - - // check IllegalStateException on NTLM no retry - { - final Queue authenticationsQueue = singletonList(NTLM) - .stream() - .collect(Collectors.toCollection(LinkedList::new)); - - assertThrows( - IllegalStateException.class, - () -> computeCredentials(WIN_RM_ENDPOINT_3, null, authenticationsQueue) - ); - } - - // check IllegalStateException on NTLM OK with retry - { - final Queue authenticationsQueue = asList(NTLM, KERBEROS) - .stream() - .collect(Collectors.toCollection(LinkedList::new)); - - assertEquals(AUTH_CREDENTIALS_KERBEROS, computeCredentials(WIN_RM_ENDPOINT_3, null, authenticationsQueue)); - } - - // check OK without Exception - { - final Queue authenticationsQueue = singletonList(NTLM) - .stream() - .collect(Collectors.toCollection(LinkedList::new)); - - assertEquals(AUTH_CREDENTIALS_NTLM, computeCredentials(WIN_RM_ENDPOINT_2, null, authenticationsQueue)); - } - } - - @Test - void testInvokeMethodSOAPFaultExceptionKerberosRetry() throws Exception { - final List authentications = singletonList(KERBEROS); - - final WinRMInvocationHandler winRMInvocationHandler = new WinRMInvocationHandler( - WIN_RM_ENDPOINT, - BUS, - TIMEOUT, - null, - null, - authentications - ); - - doThrow(new SOAPFaultException(SOAPFactory.newInstance().createFault())) - .when(WIN_RM_WS) - .receive(any(Receive.class), anyString(), anyInt(), anyString(), any(Locale.class), any(SelectorSetType.class)); - - assertThrows( - RetryAuthenticationException.class, - () -> winRMInvocationHandler.invokeMethod(RECEIVE_METHOD, RECEIVE_ARGS) - ); - - verifyReceiveInvoked(1); - } - - @Test - void testInvokeMethodSOAPFaultExceptionKO() throws Exception { - final WinRMInvocationHandler winRMInvocationHandler = new WinRMInvocationHandler( - WIN_RM_ENDPOINT, - BUS, - TIMEOUT, - null, - null, - AUTHENTICATIONS - ); - - doThrow(new SOAPFaultException(SOAPFactory.newInstance().createFault())) - .when(WIN_RM_WS) - .receive(any(Receive.class), anyString(), anyInt(), anyString(), any(Locale.class), any(SelectorSetType.class)); - - assertThrows(SOAPFaultException.class, () -> winRMInvocationHandler.invokeMethod(RECEIVE_METHOD, RECEIVE_ARGS)); - - verifyReceiveInvoked(1); - } - - @Test - void testInvokeMethodNotWebServiceExceptionNoRetryKO() throws Exception { - final WinRMInvocationHandler winRMInvocationHandler = new WinRMInvocationHandler( - WIN_RM_ENDPOINT, - BUS, - TIMEOUT, - null, - null, - AUTHENTICATIONS - ); - - doThrow(new IllegalArgumentException()) - .when(WIN_RM_WS) - .receive(any(Receive.class), anyString(), anyInt(), anyString(), any(Locale.class), any(SelectorSetType.class)); - - final IllegalStateException exception = assertThrows( - IllegalStateException.class, - () -> winRMInvocationHandler.invokeMethod(RECEIVE_METHOD, RECEIVE_ARGS) - ); - - assertTrue(exception.getMessage().startsWith("Failure when calling receive")); - - verifyReceiveInvoked(1); - } - - @Test - void testInvokeMethodWebServiceExceptionNotIOExceptionNoRetryKO() throws Exception { - final WinRMInvocationHandler winRMInvocationHandler = new WinRMInvocationHandler( - WIN_RM_ENDPOINT, - BUS, - TIMEOUT, - null, - null, - AUTHENTICATIONS - ); - - doThrow(new WebServiceException()) - .when(WIN_RM_WS) - .receive(any(Receive.class), anyString(), anyInt(), anyString(), any(Locale.class), any(SelectorSetType.class)); - - final RuntimeException exception = assertThrows( - RuntimeException.class, - () -> winRMInvocationHandler.invokeMethod(RECEIVE_METHOD, RECEIVE_ARGS) - ); - - assertTrue(exception.getMessage().startsWith("Exception occurred while making WinRM WebService call receive")); - - verifyReceiveInvoked(1); - } - - @Test - void testInvokeMethodAuthorizationLoopRetryKerberos() throws Exception { - final List authentications = singletonList(KERBEROS); - - final WinRMInvocationHandler winRMInvocationHandler = new WinRMInvocationHandler( - WIN_RM_ENDPOINT, - BUS, - TIMEOUT, - null, - null, - authentications - ); - - doThrow(new WebServiceException(new IOException("Authorization loop detected on Conduit"))) - .when(WIN_RM_WS) - .receive(any(Receive.class), anyString(), anyInt(), anyString(), any(Locale.class), any(SelectorSetType.class)); - - final RetryTgtExpirationException exception = assertThrows( - RetryTgtExpirationException.class, - () -> winRMInvocationHandler.invokeMethod(RECEIVE_METHOD, RECEIVE_ARGS) - ); - - final Throwable cause = exception.getCause(); - assertTrue(cause instanceof RuntimeException); - assertEquals("Authentication error on http://host:5985/wsman with user name \"JohnDoe\"", cause.getMessage()); - - verifyReceiveInvoked(1); - } - - @Test - void testInvokeMethodAuthorizationLoopRetryCredentials() throws Exception { - final WinRMInvocationHandler winRMInvocationHandler = new WinRMInvocationHandler( - WIN_RM_ENDPOINT, - BUS, - TIMEOUT, - null, - null, - AUTHENTICATIONS - ); - - doThrow(new WebServiceException(new IOException("Authorization loop detected on Conduit"))) - .when(WIN_RM_WS) - .receive(any(Receive.class), anyString(), anyInt(), anyString(), any(Locale.class), any(SelectorSetType.class)); - - final RetryAuthenticationException exception = assertThrows( - RetryAuthenticationException.class, - () -> winRMInvocationHandler.invokeMethod(RECEIVE_METHOD, RECEIVE_ARGS) - ); - - final Throwable cause = exception.getCause(); - assertTrue(cause instanceof RuntimeException); - assertEquals("Authentication error on http://host:5985/wsman with user name \"JohnDoe\"", cause.getMessage()); - - verifyReceiveInvoked(1); - } - - @Test - void testInvokeMethod3RetriesKO() throws Exception { - final WinRMInvocationHandler winRMInvocationHandler = new WinRMInvocationHandler( - WIN_RM_ENDPOINT, - BUS, - TIMEOUT, - null, - null, - AUTHENTICATIONS - ); - - doThrow(new WebServiceException(new IOException())) - .doThrow(new WebServiceException(new IOException())) - .doThrow(new WebServiceException(new IOException())) - .doReturn(RESULT) - .when(WIN_RM_WS) - .receive(any(Receive.class), anyString(), anyInt(), anyString(), any(Locale.class), any(SelectorSetType.class)); - - final RuntimeException exception = assertThrows( - RuntimeException.class, - () -> winRMInvocationHandler.invokeMethod(RECEIVE_METHOD, RECEIVE_ARGS) - ); - - assertTrue(exception.getMessage().startsWith("failed task \"receive")); - assertTrue(exception.getMessage().endsWith("after 3 attempts")); - - verifyReceiveInvoked(3); - } - - @Test - void testInvokeMethodOK() throws Exception { - final WinRMInvocationHandler winRMInvocationHandler = new WinRMInvocationHandler( - WIN_RM_ENDPOINT, - BUS, - TIMEOUT, - null, - null, - AUTHENTICATIONS - ); - - doReturn(RESULT) - .when(WIN_RM_WS) - .receive(any(Receive.class), anyString(), anyInt(), anyString(), any(Locale.class), any(SelectorSetType.class)); - - assertEquals(RESULT, winRMInvocationHandler.invokeMethod(RECEIVE_METHOD, RECEIVE_ARGS)); - - verifyReceiveInvoked(1); - } - - @Test - void testInvokeMethod1RetryOK() throws Exception { - final WinRMInvocationHandler winRMInvocationHandler = new WinRMInvocationHandler( - WIN_RM_ENDPOINT, - BUS, - TIMEOUT, - null, - null, - AUTHENTICATIONS - ); - - doThrow(new WebServiceException(new IOException())) - .doReturn(RESULT) - .when(WIN_RM_WS) - .receive(any(Receive.class), anyString(), anyInt(), anyString(), any(Locale.class), any(SelectorSetType.class)); - - assertEquals(RESULT, winRMInvocationHandler.invokeMethod(RECEIVE_METHOD, RECEIVE_ARGS)); - - verifyReceiveInvoked(2); - } - - @Test - void testInvokeMethod2RetriesOK() throws Exception { - final WinRMInvocationHandler winRMInvocationHandler = new WinRMInvocationHandler( - WIN_RM_ENDPOINT, - BUS, - TIMEOUT, - null, - null, - AUTHENTICATIONS - ); - - doThrow(new WebServiceException(new IOException())) - .doThrow(new WebServiceException(new IOException())) - .doReturn(RESULT) - .when(WIN_RM_WS) - .receive(any(Receive.class), anyString(), anyInt(), anyString(), any(Locale.class), any(SelectorSetType.class)); - - assertEquals(RESULT, winRMInvocationHandler.invokeMethod(RECEIVE_METHOD, RECEIVE_ARGS)); - - verifyReceiveInvoked(3); - } - - @Test - void testCreateCredentials() { - // check NTLM HTTP - { - final Credentials credentials = createCredentials(WIN_RM_ENDPOINT, NTLM, null); - - assertTrue(credentials instanceof NTCredentialsWithEncryption); - } - - // check NTLM HTTPS - { - final WinRMEndpoint winRMEndpoint = new WinRMEndpoint(HTTPS, "host", null, "JohnDoe", "pwd".toCharArray(), null); - - final Credentials credentials = createCredentials(winRMEndpoint, NTLM, null); - - assertFalse(credentials instanceof NTCredentialsWithEncryption); - assertTrue(credentials instanceof NTCredentials); - } - - // check KERBEROS - try (final MockedStatic mockedKerberosUtils = mockStatic(KerberosUtils.class)) { - mockedKerberosUtils - .when(() -> createCredentials(anyString(), any(char[].class), isNull())) - .thenReturn(KERBEROS_CREDENTIALS); - - assertEquals(KERBEROS_CREDENTIALS, createCredentials(WIN_RM_ENDPOINT, KERBEROS, null)); - } - } - - @Test - void testCreateCallInfos() { - assertEquals(EMPTY, createCallInfos(null, null)); - assertEquals("receive", createCallInfos(RECEIVE_METHOD, null)); - assertEquals(EMPTY, createCallInfos(null, ARGS)); - - final Object[] args = { "arg1", 2, true }; - assertEquals("receive arg1 2 true", createCallInfos(RECEIVE_METHOD, args)); - } -} diff --git a/src/test/java/org/metricshub/winrm/service/client/auth/kerberos/KerberosUtilsTest.java b/src/test/java/org/metricshub/winrm/service/client/auth/kerberos/KerberosUtilsTest.java deleted file mode 100644 index 36a19be..0000000 --- a/src/test/java/org/metricshub/winrm/service/client/auth/kerberos/KerberosUtilsTest.java +++ /dev/null @@ -1,120 +0,0 @@ -package org.metricshub.winrm.service.client.auth.kerberos; - -import static java.nio.file.Paths.get; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.metricshub.winrm.service.client.auth.kerberos.KerberosUtils.authenticate; -import static org.metricshub.winrm.service.client.auth.kerberos.KerberosUtils.createConfigurationWithTicketCache; -import static org.metricshub.winrm.service.client.auth.kerberos.KerberosUtils.createCredentials; -import static org.metricshub.winrm.service.client.auth.kerberos.KerberosUtils.createLoginContext; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.mockStatic; -import static org.mockito.Mockito.times; - -import java.nio.file.Path; -import javax.security.auth.Subject; -import javax.security.auth.callback.CallbackHandler; -import javax.security.auth.login.Configuration; -import javax.security.auth.login.LoginContext; -import javax.security.auth.login.LoginException; -import org.apache.http.auth.Credentials; -import org.apache.http.auth.KerberosCredentials; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.Test; -import org.metricshub.winrm.exceptions.KerberosCredentialsException; -import org.mockito.MockedStatic; - -class KerberosUtilsTest { - - private static final String USERNAME = "username"; - private static final char[] PASSWORD = "pwd".toCharArray(); - private static final Path TICKET_CACHE = get("path"); - - // To neutralize Subject.doAs - private static final MockedStatic MOCKED_SUBJECT = mockStatic(Subject.class); - - @AfterAll - static void closeMockStatics() { - MOCKED_SUBJECT.close(); - } - - @Test - void testCreateCredentialsArgumentsKO() throws Exception { - assertThrows(IllegalArgumentException.class, () -> createCredentials(null, PASSWORD, TICKET_CACHE)); - - assertThrows(IllegalArgumentException.class, () -> createCredentials(USERNAME, null, TICKET_CACHE)); - - try (final MockedStatic mockedKerberosUtils = mockStatic(KerberosUtils.class)) { - mockedKerberosUtils.when(() -> createCredentials(USERNAME, PASSWORD, null)).thenCallRealMethod(); - - mockedKerberosUtils - .when(() -> authenticate(anyString(), any(char[].class), any(Configuration.class))) - .thenCallRealMethod(); - - final LoginContext loginContext = mock(LoginContext.class); - - mockedKerberosUtils - .when(() -> createLoginContext(any(CallbackHandler.class), any(Configuration.class))) - .thenReturn(loginContext); - - doThrow(new LoginException("KrbException: Cannot locate default realm")).when(loginContext).login(); - - final KerberosCredentialsException exception = assertThrows( - KerberosCredentialsException.class, - () -> createCredentials(USERNAME, PASSWORD, null) - ); - - assertEquals("Kerberos Login failure. Make sure Kerberos is properly configured.", exception.getMessage()); - assertEquals(LoginException.class, exception.getCause().getClass()); - assertEquals("KrbException: Cannot locate default realm", exception.getCause().getMessage()); - } - - try (final MockedStatic mockedKerberosUtils = mockStatic(KerberosUtils.class)) { - mockedKerberosUtils.when(() -> createCredentials(USERNAME, PASSWORD, null)).thenCallRealMethod(); - - mockedKerberosUtils - .when(() -> authenticate(anyString(), any(char[].class), any(Configuration.class))) - .thenThrow(new SecurityException("Security Error")); - - final KerberosCredentialsException exception = assertThrows( - KerberosCredentialsException.class, - () -> createCredentials(USERNAME, PASSWORD, null) - ); - - assertEquals("java.lang.SecurityException: Security Error", exception.getMessage()); - assertEquals(SecurityException.class, exception.getCause().getClass()); - assertEquals("Security Error", exception.getCause().getMessage()); - } - } - - @Test - void testCreateCredentialsArgumentsWithoutTicketCacheOK() { - try (final MockedStatic mockedKerberosUtils = mockStatic(KerberosUtils.class)) { - mockedKerberosUtils.when(() -> createCredentials(USERNAME, PASSWORD, null)).thenCallRealMethod(); - - final Credentials actual = createCredentials(USERNAME, PASSWORD, null); - assertNotNull(actual); - assertTrue(actual instanceof KerberosCredentials); - - mockedKerberosUtils.verify(() -> createConfigurationWithTicketCache(any(Path.class)), times(0)); - } - } - - @Test - void testCreateCredentialsArgumentsWithTicketCacheOK() { - try (final MockedStatic mockedKerberosUtils = mockStatic(KerberosUtils.class)) { - mockedKerberosUtils.when(() -> createCredentials(USERNAME, PASSWORD, TICKET_CACHE)).thenCallRealMethod(); - - final Credentials actual = createCredentials(USERNAME, PASSWORD, TICKET_CACHE); - assertNotNull(actual); - assertTrue(actual instanceof KerberosCredentials); - - mockedKerberosUtils.verify(() -> createConfigurationWithTicketCache(any(Path.class)), times(1)); - } - } -} From 83884ba45f92e790ab211e37f2f954d15cba02c8 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Fri, 24 Jul 2026 00:04:35 +0200 Subject: [PATCH 21/21] Add AGENTS.md and CLAUDE.md agent instructions Co-Authored-By: Claude Fable 5 --- AGENTS.md | 28 ++++++++++++++++++++++++++++ CLAUDE.md | 1 + 2 files changed, 29 insertions(+) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6b97797 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,28 @@ +# Instructions for AI Agents + +## Code format + +You never need to worry about code formatting at all. Simply run `mvn formatter:format` before committing changes to make sure the new code follows this project's code formatting rules. Make sure not to run `mvn formatter:format` separately before other Maven commands, to avoid concurrency issues. + +All files must include the proper license header. When you add a new file, make sure to include the proper license header by running the `mvn license:update-file-header` command before committing (or even before trying the build and test, since the build will fail if a file doesn't include the proper license header). + +All public methods must have proper Javadoc. Check the output of Maven to identify issues with Javadoc and fix these issues. + +## Build + +The project uses Maven to build. A full build is performed with `mvn verify site` (or `mvn clean verify site` when applicable). + +@codex, please don't try to use `mvnw` (Maven Wrapper). Maven is already installed and runs perfectly well. + +## Test + +Whenever required, when you add code or when you modify code that is not covered with unit tests, add the corresponding unit tests. All tests must pass with `mvn test`. Don't use the `-q` (silent) option, as you want to see the result of successful tests. Tests are run with the Maven surefire plugin and results are stored in the ./target/surefire-reports directory. + +## Code quality reports + +Code quality checks are performed during the build with `mvn verify` (checkstyle, pmd, and spotbugs). Always build the project with `mvn verify` and fix any problem reported in ./target/checkstyle-result.xml, ./target/pmd.xml, and ./target/spotbugsXml.xml before committing and submitting your code! + +## Documentation + +Any change that affects the end user of this library must be properly documented in README.md. + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md