diff --git a/CHANGELOG.md b/CHANGELOG.md index 38577d8..dab239b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ +### 1.3.4 (unreleased) + +* Added support for WinRS shell command execution (MS-WSMV): `WSManClient.runCommand()` + runs a single command on a short-lived remote shell and captures exit code, stdout, + and stderr. Available from the CLI via `-o SHELL`. +* Added support for Kerberos message encryption (MS-WSMV 2.2.9.1), enabled with + `WSManEndpoint.Builder.withKerberosEncryption()` or the CLI flag `-kerberosEncryption`. + Allows WS-Man over plain HTTP (port 5985) with confidentiality and integrity provided + by GSS-API wrap tokens (AES256-CTS-HMAC-SHA1-96 only). The encrypted transport rides a + dedicated connection owned by the client; all operations on one client share a single + Kerberos session and serialize on it. +* `WSManClient` now extends `AutoCloseable`. Call `close()` when done with a client to + release the Kerberos session (connection, GSS context, JAAS login); the default + implementation is a no-op for clients without such resources. As a backstop, a shared + background daemon thread reaps idle Kerberos sessions: the connection is closed after + 60 seconds of inactivity and the GSS context and JAAS login are released after + 15 minutes, so a client that is never closed does not hold resources forever. A reaped + session re-establishes itself transparently on next use. + ### 1.2.1 * Added support for handling XmlFragment elements in pull responses. diff --git a/README.md b/README.md index 25e18f8..709b7af 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,9 @@ A pure Java WS-Man client implemented using JAX-WS & CXF with support for: * Enumerate and Pull Operations (DSP8037) * Get Operations (DSP8035) * Identify Operation (DSP0226) +* WinRS Shell Command Execution (MS-WSMV) * Basic, NTLM and SPNEGO Authentication +* Kerberos Message Encryption (MS-WSMV 2.2.9.1), allowing encrypted WS-Man over plain HTTP * OSGi Compatible ## Java Example @@ -55,6 +57,34 @@ public class WSManClientExample { } ``` +### Kerberos message encryption and WinRS + +Kerberos message encryption protects the SOAP payload itself (MS-WSMV 2.2.9.1), so it is +typically used over plain HTTP on port 5985. Credentials come either from a +username/password pair passed to the builder, or, when no password is given, from a JAAS +login configuration entry named `WSManClient` (set via +`-Djava.security.auth.login.config`, e.g. for keytab or ticket-cache authentication). +Windows WinRM expects the 2004/08 WS-Addressing namespace, so use `WSManVersion.WSMAN_1_0`. + +All operations on one client share a single Kerberos session; call `close()` (the client +is `AutoCloseable`) to release the connection, GSS context, and JAAS login. Sessions that +are never closed are reaped automatically after sitting idle (the connection after 60 +seconds, everything else after 15 minutes) and re-establish themselves transparently on +next use, so a forgotten `close()` does not pin resources forever; closing promptly is +still preferred. + +```java +WSManEndpoint endpoint = new WSManEndpoint.Builder("http://windows-host:5985/wsman") + .withServerVersion(WSManVersion.WSMAN_1_0) + .withKerberosEncryption() + .build(); +try (WSManClient client = new CXFWSManClientFactory().getClient(endpoint)) { + // WS-Man operations work as usual, and WinRS runs remote commands: + CommandResult result = client.runCommand("ipconfig", new String[] {"/all"}, Duration.ofSeconds(60)); + System.out.println("exit=" + result.exitCode() + " stdout=" + result.stdout()); +} +``` + ## Compiling From Source Requires Java 8 and Maven 3 (tested with 3.3.3) @@ -101,6 +131,48 @@ java -jar $WSMAN_CLI_JAR -w WSMAN_1_0 -r https://idrac/wsman -u root -p calvin " java -jar $WSMAN_CLI_JAR -w WSMAN_1_0 -r https://idrac/wsman -u root -p calvin -o GET -resourceUri http://schemas.dell.com/wbem/wscim/1/cim-schema/2/DCIM_ComputerSystem -s CreationClassName=DCIM_ComputerSystem -s Name=srv:system ``` +#### Running a command via WinRS + +Everything after `--` is passed to the remote shell verbatim, so command flags don't +collide with the CLI's own options. The command's stdout/stderr are passed through and +its exit code becomes the CLI's exit code. Use `-timeout` to bound the run (seconds, +default 60). + +```sh +java -jar $WSMAN_CLI_JAR -w WSMAN_1_0 -r http://win-host:5985/wsman -u Administrator -p PASsW0rdz -o SHELL -- ipconfig /all +java -jar $WSMAN_CLI_JAR -w WSMAN_1_0 -r http://win-host:5985/wsman -u Administrator -p PASsW0rdz -o SHELL -timeout 120 -- powershell -Command "Get-Service" +``` + +#### Kerberos message encryption + +Encrypts the SOAP payload itself (MS-WSMV 2.2.9.1), so plain HTTP on port 5985 is safe +to use. Requires a Kerberos setup: point the JVM at a krb5.conf, and either pass +`-u user@REALM -p password` or, for keytab/ticket-cache authentication, omit `-u`/`-p` +and provide a JAAS login configuration with an entry named `WSManClient` via +`-Djava.security.auth.login.config`. Use `-w WSMAN_1_0` so the WS-Addressing headers are +emitted in the 2004/08 namespace Windows expects. Works with all operations, including +`-o SHELL`. + +```sh +java -Djava.security.krb5.conf=/etc/krb5.conf \ + -Djava.security.auth.login.config=login.conf \ + -jar $WSMAN_CLI_JAR -w WSMAN_1_0 -r http://win-host:5985/wsman -kerberosEncryption \ + -resourceUri http://schemas.microsoft.com/wbem/wsman/1/wmi/root/cimv2/Win32_OperatingSystem +``` + +Example `login.conf` using a keytab: + +``` +WSManClient { + com.sun.security.auth.module.Krb5LoginModule required + useKeyTab=true + keyTab="/path/to/opennms-ws.keytab" + principal="opennms-ws@AD.EXAMPLE.COM" + storeKey=true + doNotPrompt=true; +}; +``` + #### Debugging: Output the WS-Man requests and responses by setting the `-vvv` flag. diff --git a/api/src/main/java/org/opennms/core/wsman/WSManClient.java b/api/src/main/java/org/opennms/core/wsman/WSManClient.java index 0af8705..672fc90 100644 --- a/api/src/main/java/org/opennms/core/wsman/WSManClient.java +++ b/api/src/main/java/org/opennms/core/wsman/WSManClient.java @@ -30,9 +30,14 @@ * * Get (DSP8035) * * Identify (DSP0226) * + *
Clients may hold long-lived resources (e.g. a Kerberos-encrypted transport
+ * session): call {@link #close()} when done with a client instance. The default
+ * implementation is a no-op, so implementations without such resources need not
+ * override it.
+ *
* @author jwhite
*/
-public interface WSManClient {
+public interface WSManClient extends AutoCloseable {
/**
* Discovers the capabilities and version information of the remote service.
@@ -138,4 +143,13 @@ public interface WSManClient {
public default CommandResult runCommand(String executable, String[] args, Duration timeout) {
return runCommand(executable, args, timeout, ShellOptions.defaults());
}
+
+ /**
+ * Releases any long-lived resources held by this client (connections, security
+ * contexts). Safe to call multiple times. No-op by default.
+ */
+ @Override
+ public default void close() {
+ // no resources by default
+ }
}
diff --git a/api/src/main/java/org/opennms/core/wsman/WSManEndpoint.java b/api/src/main/java/org/opennms/core/wsman/WSManEndpoint.java
index bc40cee..df2318f 100644
--- a/api/src/main/java/org/opennms/core/wsman/WSManEndpoint.java
+++ b/api/src/main/java/org/opennms/core/wsman/WSManEndpoint.java
@@ -34,6 +34,7 @@ public class WSManEndpoint {
private final String username;
private final String password;
private final boolean gssAuth;
+ private final boolean kerberosEncryption;
private final boolean strictSSL;
private final WSManVersion serverVersion;
private final Integer maxElements;
@@ -46,6 +47,7 @@ private WSManEndpoint(Builder builder) {
username = builder.username;
password = builder.password;
gssAuth = builder.gssAuth;
+ kerberosEncryption = builder.kerberosEncryption;
strictSSL = builder.strictSSL;
serverVersion = builder.serverVersion;
maxElements = builder.maxElements;
@@ -60,6 +62,7 @@ public static class Builder {
private String username;
private String password;
private boolean gssAuth = false;
+ private boolean kerberosEncryption = false;
private WSManVersion serverVersion = WSManVersion.WSMAN_1_2;
private Integer maxElements;
private Integer maxEnvelopeSize;
@@ -85,6 +88,12 @@ public Builder withGSSAuth() {
return this;
}
+ public Builder withKerberosEncryption() {
+ kerberosEncryption = true;
+ gssAuth = true;
+ return this;
+ }
+
public Builder withStrictSSL(boolean strictSSL) {
this.strictSSL = strictSSL;
return this;
@@ -152,6 +161,10 @@ public boolean isGSSAuth() {
return gssAuth;
}
+ public boolean isKerberosEncryption() {
+ return kerberosEncryption;
+ }
+
public boolean isStrictSSL() {
return strictSSL;
}
@@ -177,10 +190,10 @@ public Integer getReceiveTimeout() {
}
public String toString() {
- return String.format("WSManEndpoint[url='%s', isGSSAuth='%s', isBasicAuth='%s', isStrictSSL='%s', "
+ return String.format("WSManEndpoint[url='%s', isGSSAuth='%s', isKerberosEncryption='%s', isBasicAuth='%s', isStrictSSL='%s', "
+ "serverVersion='%s', maxElements='%s', maxEnvelopeSize='%s'"
+ "connectionTimeout='%s', receiveTimeout='%s']",
- url, isGSSAuth(), isBasicAuth(), isStrictSSL(), serverVersion,
+ url, isGSSAuth(), isKerberosEncryption(), isBasicAuth(), isStrictSSL(), serverVersion,
maxElements, maxEnvelopeSize, connectionTimeout, receiveTimeout);
}
}
diff --git a/cli/pom.xml b/cli/pom.xml
index 9b0d37c..5cdba25 100644
--- a/cli/pom.xml
+++ b/cli/pom.xml
@@ -86,6 +86,27 @@
When Kerberos message encryption (MS-WSMV §2.2.9.1) is enabled on the endpoint,
+ * all operations share a single {@link KerberosHttpSession}: one TCP connection with
+ * one GSS context bound to it, established once and reused. Concurrent operations on
+ * the same client serialize on that connection (a protocol constraint: Windows HTTP.sys
+ * binds the Kerberos session to the connection). Call {@link #close()} when done with
+ * the client to release the connection, the GSS context, and the JAAS login.
+ *
* @author jwhite
*/
public class CXFWSManClient implements WSManClient {
@@ -96,6 +109,13 @@ public class CXFWSManClient implements WSManClient {
private final WSManEndpoint m_endpoint;
+ /**
+ * Shared Kerberos-encryption session (connection + GSS context + JAAS login), created
+ * lazily on the first operation and reused by every proxy and the WinRS shell path.
+ * Guarded by {@code this}.
+ */
+ private KerberosHttpSession m_kerberosSession;
+
public CXFWSManClient(WSManEndpoint endpoint) {
m_endpoint = Objects.requireNonNull(endpoint, "endpoint cannot be null");
}
@@ -334,6 +354,86 @@ private JaxWsProxyFactoryBean createFactoryFor(Class> clazz) {
return factory;
}
+ /**
+ * Installs an {@link HTTPConduitFactory} that builds {@link KerberosHttpConduit}s
+ * backed by this client's shared {@link KerberosHttpSession}.
+ *
+ * Kerberos message encryption (MS-WSMV §2.2.9.1) requires every encrypted POST to
+ * ride the exact TCP connection on which the AP-REQ/AP-REP handshake bound the
+ * Kerberos session. The session owns that connection outright, so the binding holds
+ * regardless of which proxy (or the WinRS shell) issues the request, with no reliance
+ * on any JVM-global connection pool handing back the right socket.
+ *
+ * The factory is scoped to this one endpoint, NOT registered as a bus extension:
+ * {@code HTTPTransportFactory.findFactory()} consults the EndpointInfo property named
+ * {@code HTTPConduitFactory.class.getName()} before falling back to the bus extension.
+ * Scoping matters because the WinRS shell path builds its Dispatch on the JVM's
+ * thread-default bus, which every other CXF client on the thread shares; a bus-level
+ * extension there would hand this host's encrypted conduit to unrelated CXF clients.
+ * Must be called before the conduit is created (the property is only read at
+ * conduit-creation time, i.e. before the first {@code getConduit()} call).
+ */
+ private void installKerberosConduitFactory(EndpointInfo endpointInfo) {
+ final KerberosHttpSession session = getKerberosSession();
+ endpointInfo.setProperty(HTTPConduitFactory.class.getName(), new HTTPConduitFactory() {
+ @Override
+ public HTTPConduit createConduit(HTTPTransportFactory factory, Bus b,
+ EndpointInfo localInfo, EndpointReferenceType target) throws IOException {
+ return new KerberosHttpConduit(b, localInfo, target, session);
+ }
+ });
+ }
+
+ /**
+ * Lazily creates the shared Kerberos session: one JAAS login, one connection, one GSS
+ * context, reused by every operation this client performs.
+ */
+ private synchronized KerberosHttpSession getKerberosSession() {
+ if (m_kerberosSession == null) {
+ LOG.debug("Creating Kerberos message-encryption session (MS-WSMV §2.2.9.1) for {}",
+ m_endpoint.getUrl());
+ GSSContextManager gss = new GSSContextManager(
+ m_endpoint.getUrl().getHost(),
+ m_endpoint.getUsername(),
+ m_endpoint.getPassword());
+ SSLSocketFactory sslSocketFactory = null;
+ if (!m_endpoint.isStrictSSL()) {
+ sslSocketFactory = buildPermissiveSslSocketFactory();
+ }
+ m_kerberosSession = new KerberosHttpSession(
+ m_endpoint.getUrl(), gss, sslSocketFactory, m_endpoint.isStrictSSL());
+ }
+ return m_kerberosSession;
+ }
+
+ /**
+ * Releases the Kerberos session (connection, GSS context, JAAS login) if one was
+ * created. Safe to call multiple times; the session is recreated on demand if the
+ * client is used again.
+ */
+ @Override
+ public synchronized void close() {
+ if (m_kerberosSession != null) {
+ m_kerberosSession.close();
+ m_kerberosSession = null;
+ }
+ }
+
+ private static SSLSocketFactory buildPermissiveSslSocketFactory() {
+ TrustManager[] trustAll = new TrustManager[] { new X509TrustManager() {
+ public void checkClientTrusted(java.security.cert.X509Certificate[] certs, String authType) {}
+ public void checkServerTrusted(java.security.cert.X509Certificate[] certs, String authType) {}
+ public java.security.cert.X509Certificate[] getAcceptedIssuers() { return null; }
+ }};
+ try {
+ SSLContext ctx = SSLContext.getInstance("TLS");
+ ctx.init(null, trustAll, new java.security.SecureRandom());
+ return ctx.getSocketFactory();
+ } catch (java.security.GeneralSecurityException e) {
+ throw new RuntimeException("Failed to build permissive SSLSocketFactory", e);
+ }
+ }
+
/**
* Creates a proxy service for the given JAX-WS annotated interface.
*/
@@ -354,6 +454,12 @@ private The HTTP transport (socket ownership, the Negotiate handshake exchange, and the
+ * multipart/encrypted framing) lives in {@link KerberosHttpSession}; this class only
+ * produces and consumes GSS tokens.
+ *
+ * Because Windows HTTP.sys binds a Kerberos session to a single TCP connection, a
+ * context established over one connection is useless on another. {@link #newHandshake()}
+ * must be called each time the transport opens a fresh connection, and it disposes any
+ * prior context.
+ */
+public class GSSContextManager implements Closeable {
+ private static final Logger LOG = LoggerFactory.getLogger(GSSContextManager.class);
+
+ private static final Oid KERBEROS_OID;
+ private static final Oid SPNEGO_OID;
+ static {
+ try {
+ KERBEROS_OID = new Oid("1.2.840.113554.1.2.2");
+ SPNEGO_OID = new Oid("1.3.6.1.5.5.2");
+ } catch (GSSException e) {
+ throw new RuntimeException("Failed to create GSS OIDs", e);
+ }
+ }
+
+ // SSPI gss_wrap_iov "security trailer" size for AES256-CTS-HMAC-SHA1-96 (etype 18):
+ // 16 (RFC 4121 wrap-token header) + 16 (encrypted confounder)
+ // + 16 (encrypted trailing header replica) + 12 (HMAC-SHA1-96) = 60 bytes.
+ private static final int SSPI_HEADER_LEN_AES256 = 60;
+
+ // RFC 4121 right-rotation count Microsoft uses for AES256: rotate the data portion by
+ // 28 bytes (= 16 enc_header_copy + 12 HMAC) so the trailer sits at the front of the
+ // data and the plaintext ciphertext at the back. RRC is written network byte order
+ // (big-endian) per RFC 4121, which matches what Microsoft sends back in responses.
+ private static final int WINRM_RRC = 28;
+
+ private final String host;
+ private final String username;
+ private final String password;
+
+ private LoginContext loginContext;
+ private Subject subject;
+ private boolean loggedIn;
+ private GSSContext context;
+
+ public GSSContextManager(String host, String username, String password) {
+ this.host = host;
+ this.username = username;
+ this.password = password;
+ }
+
+ /**
+ * Discards any existing context and creates a fresh one for a new TCP connection.
+ * The JAAS login (and therefore the TGT) is reused across handshakes; only the
+ * AP-REQ/AP-REP exchange is redone.
+ */
+ public synchronized void newHandshake() throws GSSException, LoginException {
+ disposeContext();
+ ensureLogin();
+
+ GSSManager manager = GSSManager.getInstance();
+ // SPNEGO OID so initSecContext produces a NegTokenInit wrapping the Kerberos AP-REQ
+ // — what WinRM HTTP.sys expects. wrap()/unwrap() still produce raw RFC 4121 wrap
+ // tokens regardless of the outer OID.
+ final GSSName serverName = manager.createName("http@" + host, GSSName.NT_HOSTBASED_SERVICE, KERBEROS_OID);
+ final GSSManager mgr = manager;
+
+ PrivilegedExceptionAction Java's {@code wrap()} produces
+ * {@code [header(16) RRC=0 || enc_confounder(16) || enc_plaintext(N) || enc_header_copy(16) || HMAC(12)]}.
+ * SSPI's {@code EncryptMessage} emits the same bytes right-rotated by 28 in the data portion,
+ * with RRC=28 in the header — yielding
+ * {@code [header(16) RRC=28 || enc_header_copy(16) || HMAC(12) || enc_confounder(16) || enc_plaintext(N)]}.
+ * The WinRM length prefix is the SSPI security-trailer length (60 bytes for AES256),
+ * so the wire is {@code [4B LE 60][60B signature][N ciphertext]}.
+ */
+ static byte[] toWinRMFormat(byte[] wrapToken, int plaintextLen) throws GSSException {
+ int expected = plaintextLen + SSPI_HEADER_LEN_AES256;
+ if (wrapToken.length != expected) {
+ throw new GSSException(GSSException.DEFECTIVE_TOKEN, -1,
+ "Unexpected wrap-token length " + wrapToken.length + " for plaintext " + plaintextLen
+ + " (expected " + expected + ", AES256-CTS-HMAC-SHA1-96 assumed)");
+ }
+ int dataLen = wrapToken.length - 16;
+ byte[] out = new byte[4 + wrapToken.length];
+ out[0] = (byte) SSPI_HEADER_LEN_AES256;
+ out[1] = 0;
+ out[2] = 0;
+ out[3] = 0;
+ System.arraycopy(wrapToken, 0, out, 4, 16);
+ // RRC field, big-endian per RFC 4121 network byte order
+ out[4 + 6] = 0;
+ out[4 + 7] = (byte) WINRM_RRC;
+ // right-rotate data by WINRM_RRC: last WINRM_RRC bytes (enc_header_copy + HMAC) go to front
+ System.arraycopy(wrapToken, 16 + dataLen - WINRM_RRC, out, 4 + 16, WINRM_RRC);
+ System.arraycopy(wrapToken, 16, out, 4 + 16 + WINRM_RRC, dataLen - WINRM_RRC);
+ return out;
+ }
+
+ /**
+ * Strips the WinRM length prefix, left-rotates the data portion by RRC (read big-endian
+ * per RFC 4121) to recover Java's expected layout, and zeros the RRC field so Java's
+ * {@code unwrap()} doesn't try to rotate again.
+ */
+ static byte[] fromWinRMFormat(byte[] winrmBytes) throws IOException {
+ if (winrmBytes.length < 4) {
+ throw new IOException("WinRM encrypted section too short: " + winrmBytes.length);
+ }
+ int sigLen = (winrmBytes[0] & 0xff)
+ | ((winrmBytes[1] & 0xff) << 8)
+ | ((winrmBytes[2] & 0xff) << 16)
+ | ((winrmBytes[3] & 0xff) << 24);
+ if (sigLen != SSPI_HEADER_LEN_AES256) {
+ throw new IOException("Unexpected SSPI security-trailer length: " + sigLen
+ + " (only AES256-CTS-HMAC-SHA1-96 is supported)");
+ }
+ if (winrmBytes.length < 4 + sigLen) {
+ throw new IOException("WinRM encrypted section truncated (sigLen=" + sigLen
+ + ", total=" + winrmBytes.length + ")");
+ }
+ byte[] wrapToken = new byte[winrmBytes.length - 4];
+ System.arraycopy(winrmBytes, 4, wrapToken, 0, 16);
+ int rrc = ((wrapToken[6] & 0xff) << 8) | (wrapToken[7] & 0xff);
+ wrapToken[6] = 0;
+ wrapToken[7] = 0;
+ int dataLen = wrapToken.length - 16;
+ int shift = rrc % dataLen;
+ if (shift > 0) {
+ System.arraycopy(winrmBytes, 4 + 16 + shift, wrapToken, 16, dataLen - shift);
+ System.arraycopy(winrmBytes, 4 + 16, wrapToken, 16 + dataLen - shift, shift);
+ } else {
+ System.arraycopy(winrmBytes, 4 + 16, wrapToken, 16, dataLen);
+ }
+ return wrapToken;
+ }
+}
diff --git a/cxf/src/main/java/org/opennms/core/wsman/cxf/KerberosHttpConduit.java b/cxf/src/main/java/org/opennms/core/wsman/cxf/KerberosHttpConduit.java
new file mode 100644
index 0000000..b131752
--- /dev/null
+++ b/cxf/src/main/java/org/opennms/core/wsman/cxf/KerberosHttpConduit.java
@@ -0,0 +1,198 @@
+/*
+ * Copyright (C) The OpenNMS Group
+ *
+ * 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.opennms.core.wsman.cxf;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.URISyntaxException;
+
+import org.apache.cxf.Bus;
+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.HTTPConduit;
+import org.apache.cxf.transport.https.HttpsURLConnectionInfo;
+import org.apache.cxf.transports.http.configuration.HTTPClientPolicy;
+import org.apache.cxf.ws.addressing.EndpointReferenceType;
+
+/**
+ * A CXF {@link HTTPConduit} that sends every message through a
+ * {@link KerberosHttpSession}, implementing MS-WSMV §2.2.9.1 Kerberos message
+ * encryption over a connection the session owns.
+ *
+ * The model is "buffer fully, exchange once": the outbound SOAP is collected in
+ * memory (chunking is disabled on WS-Man clients anyway), encrypted, and exchanged on
+ * the session's socket when CXF closes the output stream; the decrypted response is
+ * handed back to the in-chain as a plain byte stream. Because the base class's
+ * {@code doProcessResponseCode()} still sees the real HTTP status, 401s surface as
+ * {@code org.apache.cxf.transport.http.HTTPException} (and therefore as
+ * {@code UnauthorizedException} to callers), and encrypted 500 fault bodies are
+ * decrypted and flow to the SOAP fault chain exactly as they do for the stock
+ * conduits. Cleartext error bodies are unauthenticated and never reach the in-chain:
+ * the session withholds a 401's body and fails hard on any other cleartext response
+ * (see {@link KerberosHttpSession}).
+ *
+ * Retransmit-related hooks are unsupported: redirects and auth-retry are handled
+ * (where meaningful) inside the session, not by CXF's retransmit machinery.
+ */
+public class KerberosHttpConduit extends HTTPConduit {
+
+ private final KerberosHttpSession session;
+
+ public KerberosHttpConduit(Bus b, EndpointInfo ei, EndpointReferenceType t,
+ KerberosHttpSession session) throws IOException {
+ super(b, ei, t);
+ this.session = session;
+ }
+
+ @Override
+ protected void setupConnection(Message message, Address address, HTTPClientPolicy csPolicy) {
+ // Nothing to set up per-request: the session owns the (persistent) connection.
+ }
+
+ @Override
+ protected OutputStream createOutputStream(Message message, boolean needToCacheRequest,
+ boolean isChunking, int chunkThreshold) throws IOException {
+ try {
+ return new KerberosWrappedOutputStream(message);
+ } catch (URISyntaxException e) {
+ throw new IOException(e);
+ }
+ }
+
+ @Override
+ public void close() {
+ // Deliberately does NOT close the session: it is shared across the several
+ // short-lived proxies/conduits a CXFWSManClient creates, and is closed by
+ // CXFWSManClient.close().
+ super.close();
+ }
+
+ class KerberosWrappedOutputStream extends WrappedOutputStream {
+ private ByteArrayOutputStream requestBuffer;
+ private KerberosHttpSession.Response response;
+
+ KerberosWrappedOutputStream(Message message) throws URISyntaxException {
+ // No caching-for-retransmission, no chunking: the buffer IS the message.
+ super(message, false, false, 0, getConduitName(), getURI());
+ }
+
+ @Override
+ protected void setupWrappedStream() {
+ requestBuffer = new ByteArrayOutputStream(4096);
+ wrappedStream = requestBuffer;
+ }
+
+ /** Performs the encrypted exchange exactly once, on first demand. */
+ private KerberosHttpSession.Response exchange() throws IOException {
+ if (response == null) {
+ byte[] soapUtf8 = requestBuffer != null ? requestBuffer.toByteArray() : new byte[0];
+ HTTPClientPolicy policy = getClient(outMessage);
+ int connectTimeout = determineConnectionTimeout(outMessage, policy);
+ int receiveTimeout = determineReceiveTimeout(outMessage, policy);
+ response = session.sendEncrypted(soapUtf8, connectTimeout, receiveTimeout);
+ }
+ return response;
+ }
+
+ @Override
+ protected int getResponseCode() throws IOException {
+ return exchange().getStatusCode();
+ }
+
+ @Override
+ protected String getResponseMessage() throws IOException {
+ return exchange().getReasonPhrase();
+ }
+
+ @Override
+ protected void updateResponseHeaders(Message inMessage) throws IOException {
+ KerberosHttpSession.Response r = exchange();
+ inMessage.put(Message.PROTOCOL_HEADERS, r.getHeaders());
+ inMessage.put(Message.CONTENT_TYPE, r.getContentType());
+ }
+
+ @Override
+ protected InputStream getInputStream() throws IOException {
+ return new ByteArrayInputStream(exchange().getBody());
+ }
+
+ @Override
+ protected InputStream getPartialResponse() {
+ // Only meaningful for oneway/decoupled MEPs, which WS-Man does not use.
+ return null;
+ }
+
+ @Override
+ protected void closeInputStream() {
+ // Byte-array backed; nothing to release.
+ }
+
+ @Override
+ protected void handleResponseAsync() throws IOException {
+ handleResponseOnWorkqueue(true, false);
+ }
+
+ @Override
+ protected HttpsURLConnectionInfo getHttpsURLConnectionInfo() {
+ // Only consulted when a MessageTrustDecider is registered, which this
+ // conduit does not support (TLS policy is fixed at session construction).
+ return null;
+ }
+
+ @Override
+ protected void setProtocolHeaders() {
+ // The session builds the wire headers itself (multipart Content-Type,
+ // Content-Length, Host); CXF's protocol headers are intentionally not
+ // copied onto an encrypted exchange.
+ }
+
+ @Override
+ protected void setFixedLengthStreamingMode(int i) {
+ // Not applicable: the request is fully buffered.
+ }
+
+ @Override
+ protected boolean usingProxy() {
+ return false;
+ }
+
+ @Override
+ public void thresholdReached() {
+ // Chunking is disabled; the buffer simply grows.
+ }
+
+ @Override
+ protected void setupNewConnection(String newURL) throws IOException {
+ throw new IOException("Redirects are not supported over a Kerberos-encrypted session"
+ + " (requested: " + newURL + ")");
+ }
+
+ @Override
+ protected void retransmitStream() throws IOException {
+ throw new IOException("CXF-level retransmission is not supported over a"
+ + " Kerberos-encrypted session");
+ }
+
+ @Override
+ protected void updateCookiesBeforeRetransmit() {
+ // Retransmits unsupported; nothing to do.
+ }
+ }
+}
diff --git a/cxf/src/main/java/org/opennms/core/wsman/cxf/KerberosHttpSession.java b/cxf/src/main/java/org/opennms/core/wsman/cxf/KerberosHttpSession.java
new file mode 100644
index 0000000..1177bcd
--- /dev/null
+++ b/cxf/src/main/java/org/opennms/core/wsman/cxf/KerberosHttpSession.java
@@ -0,0 +1,777 @@
+/*
+ * Copyright (C) The OpenNMS Group
+ *
+ * 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.opennms.core.wsman.cxf;
+
+import java.io.ByteArrayOutputStream;
+import java.io.Closeable;
+import java.io.EOFException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.net.Socket;
+import java.net.SocketTimeoutException;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.TreeMap;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.TimeUnit;
+
+import javax.net.ssl.SSLParameters;
+import javax.net.ssl.SSLSocket;
+import javax.net.ssl.SSLSocketFactory;
+
+import org.ietf.jgss.GSSException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A Kerberos-encrypted WS-Man transport session per MS-WSMV §2.2.9.1: one TCP connection,
+ * the GSS context bound to it, and the HTTP/1.1 exchange logic, owned together.
+ *
+ * Windows HTTP.sys binds the Kerberos session established by the AP-REQ/AP-REP
+ * Negotiate handshake to the TCP connection it arrived on; encrypted bodies on any other
+ * connection are rejected with 401. Earlier revisions of this library performed the
+ * handshake through {@code HttpURLConnection} and relied on the JVM's global keep-alive
+ * pool handing CXF's conduit the same socket back — which held only by luck for
+ * sequential use and broke outright for concurrent use. This class removes the pool from
+ * the picture: the handshake and every encrypted exchange are written on a socket this
+ * object owns, so the session-to-connection binding is guaranteed by construction.
+ *
+ * All exchanges are synchronized: one connection carries one request at a time, which
+ * is inherent to the protocol (the encryption context is per-connection). Concurrent
+ * callers serialize here. A connection that has been idle long enough for the server to
+ * have dropped it is proactively closed and re-established before the next request; the
+ * JAAS login (and TGT) in {@link GSSContextManager} is reused, so a reconnect costs an
+ * AP-REQ exchange, not a full authentication.
+ *
+ * A request is re-sent automatically only when the connection failed before the
+ * request had been fully written (the server cannot have processed a partial HTTP
+ * request). A failure after the request was fully sent is raised to the caller instead:
+ * the server may already have executed the request, and WS-Man operations (WinRS
+ * Command in particular) are not generally idempotent, so a silent resend could
+ * execute them twice.
+ *
+ * Idle sessions are reaped by a shared background daemon thread, so a caller that
+ * abandons a client without calling {@link #close()} does not pin resources forever:
+ * the TCP connection is closed once it passes the idle-reuse threshold (it would be
+ * discarded on next use anyway), and after {@link #MAX_IDLE_SESSION_NANOS} of
+ * inactivity the GSS context and JAAS login are released as well and the reaper
+ * cancels itself, leaving the session eligible for garbage collection. A reaped
+ * session remains usable: the next request re-logins and re-handshakes lazily.
+ * {@link #close()} remains the deterministic way to release resources promptly.
+ */
+public class KerberosHttpSession implements Closeable {
+ private static final Logger LOG = LoggerFactory.getLogger(KerberosHttpSession.class);
+
+ private static final int MAX_HANDSHAKE_LEGS = 10;
+ private static final int DEFAULT_CONNECT_TIMEOUT = 30_000;
+ private static final int DEFAULT_RECEIVE_TIMEOUT = 60_000;
+ /** Cap on response body size (64 MB), guarding against a broken/hostile length header. */
+ private static final int MAX_RESPONSE_BODY = 64 * 1024 * 1024;
+ /** Cap on a single HTTP line (status line, header, chunk-size line): a well-behaved
+ * server stays under a few KB, and an unbounded line is a memory-exhaustion vector. */
+ private static final int MAX_LINE_LENGTH = 64 * 1024;
+ /** Cap on the cumulative size of a response's header block (and of chunked trailers). */
+ private static final int MAX_HEADERS_LENGTH = 1024 * 1024;
+ /** Reconnect proactively when the connection has been idle this long. Windows HTTP.sys
+ * closes idle keep-alive connections (default 120s); sending on a connection the
+ * server may have already closed produces an ambiguous mid-exchange failure that is
+ * not safe to retry, whereas a proactive re-handshake costs one cheap AP-REQ leg. */
+ private static final long MAX_IDLE_REUSE_NANOS = TimeUnit.SECONDS.toNanos(60);
+ /** After this much inactivity the reaper releases everything the session holds
+ * (GSS context, JAAS login) and stops watching it. Long enough that a periodic
+ * monitoring poll (typically every 5 minutes) keeps its login warm. */
+ static final long MAX_IDLE_SESSION_NANOS = TimeUnit.MINUTES.toNanos(15);
+ /** How often the reaper checks an active session for idleness. */
+ private static final long REAPER_INTERVAL_SECONDS = 30;
+
+ /** Lazily-initialized shared daemon thread that reaps idle sessions. */
+ private static final class ReaperHolder {
+ static final ScheduledExecutorService EXECUTOR =
+ Executors.newSingleThreadScheduledExecutor(new ThreadFactory() {
+ @Override
+ public Thread newThread(Runnable r) {
+ Thread t = new Thread(r, "wsman-kerberos-session-reaper");
+ t.setDaemon(true);
+ return t;
+ }
+ });
+ }
+
+ private final String host;
+ private final int port;
+ private final String path;
+ private final boolean https;
+ private final SSLSocketFactory sslSocketFactory;
+ private final boolean verifyHostname;
+ private final GSSContextManager gss;
+
+ private Socket socket;
+ private InputStream in;
+ private OutputStream out;
+ /** Number of successful exchanges on the current connection; > 0 means the connection
+ * is a reused keep-alive, so a failure while writing (only) is safe to retry. */
+ private int exchangesOnConnection;
+ /** {@link System#nanoTime()} of the last successful activity on the current
+ * connection, used for the proactive idle refresh and by the reaper. Volatile so
+ * the reaper can pre-check it without taking the session lock. Initialized at
+ * construction so idle arithmetic is well-defined even if connect() fails before
+ * ever refreshing it. */
+ private volatile long lastUseNanos = System.nanoTime();
+ /** The reaper's periodic check for this session; null when nothing is scheduled.
+ * Guarded by {@code this}. */
+ private ScheduledFuture> reaperTask;
+
+ /**
+ * @param url the WS-Man endpoint
+ * @param gss the GSS layer (owned by this session: closed on {@link #close()})
+ * @param sslSocketFactory factory for HTTPS connections, or {@code null} for the JVM default
+ * @param verifyHostname when true (strict SSL), the TLS handshake performs HTTPS endpoint
+ * identification against the certificate
+ */
+ public KerberosHttpSession(URL url, GSSContextManager gss,
+ SSLSocketFactory sslSocketFactory, boolean verifyHostname) {
+ this.host = url.getHost();
+ this.port = url.getPort() != -1 ? url.getPort() : url.getDefaultPort();
+ String p = url.getPath();
+ this.path = (p == null || p.isEmpty()) ? "/" : p;
+ this.https = "https".equalsIgnoreCase(url.getProtocol());
+ this.sslSocketFactory = sslSocketFactory;
+ this.verifyHostname = verifyHostname;
+ this.gss = gss;
+ }
+
+ /**
+ * The outcome of one encrypted request/response exchange, after decryption and
+ * cleartext-policy checks. {@code body} is the plaintext SOAP when {@code decrypted}
+ * is true; when it is false the response was a cleartext 401 whose (unauthenticated)
+ * body has been withheld, and only the status, reason, and headers are meaningful.
+ */
+ public static final class Response {
+ private final int statusCode;
+ private final String reasonPhrase;
+ private final Map The request is transparently re-sent once when the reused connection died before
+ * the request had been fully written, or on a 401 indicating the server dropped the
+ * session binding (in both cases the server did not process the request). A failure
+ * after the request was fully sent is raised to the caller: the server may already
+ * have executed the request, and re-sending could execute it twice.
+ *
+ * @param soapUtf8 the plaintext SOAP envelope
+ * @param connectTimeout socket connect timeout in ms ({@code <= 0} for the 30s default)
+ * @param receiveTimeout socket read timeout in ms ({@code <= 0} for the 60s default)
+ */
+ public synchronized Response sendEncrypted(byte[] soapUtf8, int connectTimeout, int receiveTimeout)
+ throws IOException {
+ int cTimeout = connectTimeout > 0 ? connectTimeout : DEFAULT_CONNECT_TIMEOUT;
+ int rTimeout = receiveTimeout > 0 ? receiveTimeout : DEFAULT_RECEIVE_TIMEOUT;
+
+ RawResponse raw;
+ try {
+ raw = attempt(soapUtf8, cTimeout, rTimeout);
+ } catch (StaleConnectionException e) {
+ // The reused connection died while the request was still being written, so the
+ // server cannot have processed it. Reconnect, re-handshake, resend; the request
+ // is re-encrypted under the new context by attempt().
+ LOG.debug("Kerberos session connection went stale, reconnecting: {}", e.getCause().toString());
+ invalidateConnection();
+ raw = attempt(soapUtf8, cTimeout, rTimeout);
+ }
+
+ if (raw.statusCode == 401) {
+ // The server no longer honors this connection's session binding (or an
+ // intermediary recycled the connection). One fresh handshake, one resend.
+ LOG.debug("Kerberos session got 401 on an established session, re-handshaking");
+ invalidateConnection();
+ raw = attempt(soapUtf8, cTimeout, rTimeout);
+ }
+
+ return classify(raw);
+ }
+
+ /**
+ * One full try: ensure connection + established context, encrypt, exchange.
+ * Encryption must happen inside the attempt because a reconnect creates a new GSS
+ * context with a new session key: bytes wrapped under the old context are useless.
+ */
+ private RawResponse attempt(byte[] soapUtf8, int connectTimeout, int receiveTimeout) throws IOException {
+ ensureSession(connectTimeout, receiveTimeout);
+
+ byte[] multipart;
+ try {
+ multipart = WinRMEncryptedMultipart.build(gss.wrapWinRM(soapUtf8), soapUtf8.length);
+ } catch (GSSException e) {
+ throw new IOException("Kerberos encryption of the outbound message failed", e);
+ }
+
+ Map The lock-free pre-check keeps the shared reaper thread from blocking behind an
+ * in-flight exchange in the common active case; when it does take the lock, the idle
+ * times are re-checked under it, so an exchange that completed in between (refreshing
+ * {@code lastUseNanos}) is never reaped.
+ */
+ private void reapIfIdle() {
+ if (System.nanoTime() - lastUseNanos < MAX_IDLE_REUSE_NANOS) {
+ return;
+ }
+ synchronized (this) {
+ long idleNanos = System.nanoTime() - lastUseNanos;
+ if (idleNanos < MAX_IDLE_REUSE_NANOS) {
+ return;
+ }
+ if (socket != null) {
+ LOG.debug("Reaping idle Kerberos session connection to {}:{} ({} ms idle)",
+ host, port, TimeUnit.NANOSECONDS.toMillis(idleNanos));
+ invalidateConnection();
+ }
+ if (idleNanos >= MAX_IDLE_SESSION_NANOS) {
+ LOG.debug("Releasing idle Kerberos session to {}:{} ({} ms idle): disposing"
+ + " GSS context and JAAS login", host, port,
+ TimeUnit.NANOSECONDS.toMillis(idleNanos));
+ gss.close();
+ cancelReaper();
+ }
+ }
+ }
+
+ // ------------------------------------------------------------------------------
+ // Minimal HTTP/1.1 codec. Static and package-visible for unit testing.
+ // ------------------------------------------------------------------------------
+
+ private void writeRequest(OutputStream os, Map Everything here is offline: conduit creation constructs objects but opens no
+ * connection, and the Kerberos session performs no login or handshake until first use.
+ */
+public class KerberosConduitScopingTest {
+
+ @Test
+ public void shellConduitFactory_isEndpointScoped_notOnThreadDefaultBus() throws MalformedURLException {
+ WSManEndpoint endpoint = new WSManEndpoint.Builder("http://kerberos-target.example.com:5985/wsman")
+ .withKerberosEncryption()
+ .build();
+ CXFWSManClient client = new CXFWSManClient(endpoint);
+ CxfShellOperations shellOps = new CxfShellOperations(endpoint.getUrl().toExternalForm());
+ CxfShellOperations unrelated = new CxfShellOperations("http://unrelated.example.com:5985/other");
+ try {
+ // Premise of the finding: the shell Dispatch really does live on the JVM's
+ // thread-default bus. If a CXF upgrade changes this, the scoping requirement
+ // needs re-evaluation, so fail loudly here.
+ Bus threadDefaultBus = BusFactory.getThreadDefaultBus();
+ assertSame("CxfShellOperations is expected to build its Dispatch on the"
+ + " thread-default bus", threadDefaultBus, shellOps.getClient().getBus());
+
+ client.configureShellConduit(shellOps.getClient());
+
+ // The shell endpoint itself gets the encrypted conduit...
+ assertTrue("shell endpoint should get a KerberosHttpConduit",
+ shellOps.getClient().getConduit() instanceof KerberosHttpConduit);
+
+ // ...but the shared bus carries no trace of it...
+ assertNull("thread-default bus must not carry the Kerberos conduit factory",
+ threadDefaultBus.getExtension(HTTPConduitFactory.class));
+
+ // ...so an unrelated CXF client on the same bus still gets a stock conduit.
+ assertFalse("unrelated client must not inherit the Kerberos conduit",
+ unrelated.getClient().getConduit() instanceof KerberosHttpConduit);
+ } finally {
+ try {
+ unrelated.getClient().destroy();
+ } catch (Exception ignored) {}
+ try {
+ shellOps.getClient().destroy();
+ } catch (Exception ignored) {}
+ client.close();
+ }
+ }
+}
diff --git a/cxf/src/test/java/org/opennms/core/wsman/cxf/KerberosHttpSessionClassifyTest.java b/cxf/src/test/java/org/opennms/core/wsman/cxf/KerberosHttpSessionClassifyTest.java
new file mode 100644
index 0000000..48a1896
--- /dev/null
+++ b/cxf/src/test/java/org/opennms/core/wsman/cxf/KerberosHttpSessionClassifyTest.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright (C) The OpenNMS Group
+ *
+ * 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.opennms.core.wsman.cxf;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import java.io.IOException;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+
+import org.junit.Test;
+import org.opennms.core.wsman.cxf.KerberosHttpSession.RawResponse;
+import org.opennms.core.wsman.cxf.KerberosHttpSession.Response;
+
+/**
+ * Tests the cleartext-response policy in {@link KerberosHttpSession#classify}: on an
+ * established encrypted session a cleartext body is unauthenticated (an on-path attacker
+ * can forge it), so it must never be handed to the SOAP chain. Only the cleartext
+ * branches are exercised here; they never touch the GSS layer, so no established
+ * context is needed.
+ */
+public class KerberosHttpSessionClassifyTest {
+
+ private KerberosHttpSession session() throws Exception {
+ return new KerberosHttpSession(new URL("http://windows-host:5985/wsman"),
+ new GSSContextManager("windows-host", null, null), null, false);
+ }
+
+ private static RawResponse raw(int status, String reason, String body) {
+ RawResponse r = new RawResponse();
+ r.statusCode = status;
+ r.reasonPhrase = reason;
+ r.body = body.getBytes(StandardCharsets.UTF_8);
+ return r;
+ }
+
+ @Test
+ public void cleartext200_isRejectedAsDowngrade() throws Exception {
+ try {
+ session().classify(raw(200, "OK", "
+ *
+ * Package-visible for unit testing (the cleartext branches never touch the GSS layer).
+ */
+ Response classify(RawResponse raw) throws IOException {
+ if (WinRMEncryptedMultipart.isEncrypted(raw.body)) {
+ byte[] soapUtf8;
+ try {
+ soapUtf8 = gss.unwrapWinRM(WinRMEncryptedMultipart.extractEncryptedSection(raw.body));
+ } catch (GSSException e) {
+ throw new IOException("Kerberos decryption of the response failed (HTTP "
+ + raw.statusCode + ", " + raw.body.length + " bytes)", e);
+ }
+ LOG.debug("Kerberos-decrypted response: HTTP {}, {} encrypted bytes -> {} SOAP bytes",
+ raw.statusCode, raw.body.length, soapUtf8.length);
+ return new Response(raw.statusCode, raw.reasonPhrase, raw.headers, soapUtf8,
+ "application/soap+xml;charset=UTF-8", true);
+ }
+
+ if (raw.statusCode == 401) {
+ if (raw.body.length > 0) {
+ LOG.debug("Cleartext HTTP 401 on the encrypted session; withholding its"
+ + " {} byte unauthenticated body", raw.body.length);
+ }
+ return new Response(raw.statusCode, raw.reasonPhrase, raw.headers, new byte[0],
+ "text/plain", false);
+ }
+
+ if (raw.statusCode >= 400) {
+ throw new IOException("Received a cleartext HTTP " + raw.statusCode + " ("
+ + raw.reasonPhrase + ") error on a Kerberos-encrypted session. The body is"
+ + " unauthenticated and was not parsed; sanitized excerpt of " + raw.body.length
+ + " bytes: " + sanitizeForDiagnostics(raw.body));
+ }
+
+ throw new IOException("Expected a Kerberos-encrypted response but received cleartext"
+ + " (HTTP " + raw.statusCode + ", " + raw.body.length + " byte body)."
+ + " Refusing to process an unauthenticated response on an encrypted session.");
+ }
+
+ /**
+ * Renders untrusted cleartext bytes for inclusion in an exception message: decoded
+ * leniently as UTF-8, control and format characters replaced, whitespace collapsed,
+ * and the result truncated. Never returns raw attacker-controlled bytes verbatim.
+ */
+ static String sanitizeForDiagnostics(byte[] body) {
+ if (body == null || body.length == 0) {
+ return "(empty)";
+ }
+ String text = new String(body, StandardCharsets.UTF_8)
+ .replaceAll("[\\p{Cntrl}\\p{Cf}]", " ")
+ .replaceAll("\\s{2,}", " ")
+ .trim();
+ if (text.isEmpty()) {
+ return "(no printable content)";
+ }
+ return text.length() <= 300 ? text : text.substring(0, 300) + "...";
+ }
+
+ /**
+ * Ensures the socket is connected and the GSS context on it is established, running
+ * the Negotiate handshake (bare POSTs with Authorization/WWW-Authenticate token
+ * exchange, mirroring pywinrm's setup) when needed.
+ */
+ private void ensureSession(int connectTimeout, int receiveTimeout) throws IOException {
+ // From here on the session may hold reapable resources (a connection, and a JAAS
+ // login acquired inside handshake() even when the handshake itself later fails).
+ ensureReaperScheduled();
+ if (socket != null && !socket.isClosed() && gss.isEstablished()) {
+ long idleNanos = System.nanoTime() - lastUseNanos;
+ if (idleNanos < MAX_IDLE_REUSE_NANOS) {
+ return;
+ }
+ // The server may have silently closed this idle keep-alive connection; a send
+ // on it would fail ambiguously (possibly after the request was transmitted),
+ // which is not safe to retry. Refresh proactively instead.
+ LOG.debug("Kerberos session connection idle for {} ms, refreshing proactively",
+ TimeUnit.NANOSECONDS.toMillis(idleNanos));
+ }
+ invalidateConnection();
+ connect(connectTimeout, receiveTimeout);
+ try {
+ handshake(receiveTimeout);
+ } catch (IOException | RuntimeException e) {
+ invalidateConnection();
+ throw e;
+ }
+ }
+
+ private void connect(int connectTimeout, int receiveTimeout) throws IOException {
+ Socket s = new Socket();
+ try {
+ s.connect(new InetSocketAddress(host, port), connectTimeout);
+ s.setSoTimeout(receiveTimeout);
+ s.setTcpNoDelay(true);
+ if (https) {
+ SSLSocketFactory factory = sslSocketFactory != null
+ ? sslSocketFactory : (SSLSocketFactory) SSLSocketFactory.getDefault();
+ SSLSocket ssl = (SSLSocket) factory.createSocket(s, host, port, true);
+ if (verifyHostname) {
+ SSLParameters params = ssl.getSSLParameters();
+ params.setEndpointIdentificationAlgorithm("HTTPS");
+ ssl.setSSLParameters(params);
+ }
+ ssl.startHandshake();
+ s = ssl;
+ }
+ socket = s;
+ in = s.getInputStream();
+ out = s.getOutputStream();
+ exchangesOnConnection = 0;
+ lastUseNanos = System.nanoTime();
+ LOG.debug("Kerberos session connected to {}:{} (tls={})", host, port, https);
+ } catch (IOException e) {
+ try { s.close(); } catch (IOException ignored) {}
+ throw e;
+ }
+ }
+
+ /**
+ * Runs the SPNEGO handshake on this connection: POST with an empty body and
+ * {@code Authorization: Negotiate
+ * --Encrypted Boundary\r\n
+ * Content-Type: application/HTTP-SPNEGO-session-encrypted\r\n
+ * OriginalContent: ...\r\n
+ * --Encrypted Boundary\r\n
+ * Content-Type: application/octet-stream\r\n
+ * <encrypted bytes>\r\n
+ * --Encrypted Boundary--\r\n
+ *
+ */
+ static byte[] extractEncryptedSection(byte[] body) throws IOException {
+ int firstBoundary = indexOf(body, BOUNDARY, 0);
+ if (firstBoundary < 0) throw new IOException("Encrypted boundary not found in response body");
+
+ int secondBoundary = indexOf(body, BOUNDARY, firstBoundary + BOUNDARY.length);
+ if (secondBoundary < 0) throw new IOException("Second encrypted boundary not found in response body");
+
+ // Skip past the boundary line (\r\n after the boundary marker)
+ int pos = secondBoundary + BOUNDARY.length;
+ pos = skipCRLF(body, pos);
+
+ // MS-WSMV §2.2.9.1 tab-indents every header line within a part (\tContent-Type: …).
+ // The encrypted payload follows with no blank-line separator and does NOT start with \t,
+ // so we stop as soon as we see a non-tab byte (or a CRLF we can't find the end of).
+ while (pos < body.length && body[pos] == '\t') {
+ int lineEnd = indexOf(body, CRLF, pos);
+ if (lineEnd < 0) break;
+ pos = lineEnd + CRLF.length;
+ }
+
+ // The section is self-delimiting when the OriginalContent Length is available:
+ // [4B LE sigLen][sigLen bytes][ciphertext], and AES256-CTS ciphertext is exactly
+ // as long as the plaintext, so the section length is 4 + sigLen + Length. Prefer
+ // that over scanning for the closing boundary — ciphertext is opaque bytes and can
+ // legitimately end with \r\n, which a trim-the-trailing-CRLF heuristic would eat.
+ int declaredLength = parseOriginalContentLength(body, firstBoundary, secondBoundary);
+ if (declaredLength >= 0 && pos + 4 <= body.length) {
+ int sigLen = (body[pos] & 0xff)
+ | ((body[pos + 1] & 0xff) << 8)
+ | ((body[pos + 2] & 0xff) << 16)
+ | ((body[pos + 3] & 0xff) << 24);
+ if (sigLen > 0 && sigLen < 1024) {
+ long end = (long) pos + 4 + sigLen + declaredLength;
+ if (end <= body.length) {
+ return Arrays.copyOfRange(body, pos, (int) end);
+ }
+ }
+ }
+
+ // Fallback: everything from here to the closing boundary (minus trailing \r\n)
+ int closingPos = indexOf(body, CLOSING_BOUNDARY, pos);
+ if (closingPos < 0) {
+ closingPos = body.length;
+ }
+
+ int end = closingPos;
+ if (end >= 2 && body[end - 2] == '\r' && body[end - 1] == '\n') {
+ end -= 2;
+ }
+
+ if (end <= pos) {
+ throw new IOException("Encrypted section appears to be empty (pos=" + pos + ", end=" + end + ")");
+ }
+
+ return Arrays.copyOfRange(body, pos, end);
+ }
+
+ /**
+ * Parses the {@code Length=N} attribute of the {@code OriginalContent} header in the
+ * first multipart part (the plaintext SOAP byte count). Returns -1 if absent or
+ * unparseable, in which case the caller falls back to boundary scanning.
+ */
+ private static int parseOriginalContentLength(byte[] body, int firstBoundary, int secondBoundary) {
+ String firstPart = new String(body, firstBoundary, secondBoundary - firstBoundary,
+ StandardCharsets.US_ASCII);
+ int idx = firstPart.indexOf("Length=");
+ if (idx < 0) {
+ return -1;
+ }
+ int start = idx + "Length=".length();
+ int end = start;
+ while (end < firstPart.length() && Character.isDigit(firstPart.charAt(end))) {
+ end++;
+ }
+ if (end == start) {
+ return -1;
+ }
+ try {
+ return Integer.parseInt(firstPart.substring(start, end));
+ } catch (NumberFormatException e) {
+ return -1;
+ }
+ }
+
+ private static int indexOf(byte[] haystack, byte[] needle, int from) {
+ outer:
+ for (int i = from; i <= haystack.length - needle.length; i++) {
+ for (int j = 0; j < needle.length; j++) {
+ if (haystack[i + j] != needle[j]) continue outer;
+ }
+ return i;
+ }
+ return -1;
+ }
+
+ private static int skipCRLF(byte[] buf, int pos) {
+ if (pos < buf.length - 1 && buf[pos] == '\r' && buf[pos + 1] == '\n') {
+ return pos + 2;
+ }
+ if (pos < buf.length && buf[pos] == '\n') {
+ return pos + 1;
+ }
+ return pos;
+ }
+}
diff --git a/cxf/src/test/java/org/opennms/core/wsman/cxf/GSSContextManagerCredentialsTest.java b/cxf/src/test/java/org/opennms/core/wsman/cxf/GSSContextManagerCredentialsTest.java
new file mode 100644
index 0000000..ca4c1a7
--- /dev/null
+++ b/cxf/src/test/java/org/opennms/core/wsman/cxf/GSSContextManagerCredentialsTest.java
@@ -0,0 +1,137 @@
+/*
+ * Copyright (C) The OpenNMS Group
+ *
+ * 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.opennms.core.wsman.cxf;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import java.io.File;
+import java.util.Date;
+
+import javax.security.auth.DestroyFailedException;
+import javax.security.auth.Subject;
+import javax.security.auth.kerberos.KerberosPrincipal;
+import javax.security.auth.kerberos.KerberosTicket;
+import javax.security.auth.kerberos.KeyTab;
+
+import org.junit.Test;
+
+/**
+ * Tests {@link GSSContextManager#hasUsableKerberosCredentials(Subject, long)}: the
+ * TGT-expiry re-login decision. Long-lived busy clients must detect an expired (or
+ * nearly expired) TGT and re-login, while keytab-backed subjects must NOT re-login,
+ * since JGSS acquires fresh tickets from the keytab on its own.
+ */
+public class GSSContextManagerCredentialsTest {
+
+ // Anchored to the real clock because KerberosTicket.isCurrent() consults it too:
+ // a fully fake epoch would make "valid" test tickets read as expired in real time.
+ private static final long NOW = System.currentTimeMillis();
+ private static final long HOUR = 3_600_000L;
+
+ @Test
+ public void currentTgt_isUsable() {
+ Subject subject = subjectWithTgt(NOW + 8 * HOUR);
+ assertTrue(GSSContextManager.hasUsableKerberosCredentials(subject, NOW));
+ }
+
+ @Test
+ public void expiredTgt_isNotUsable() {
+ Subject subject = subjectWithTgt(NOW - HOUR);
+ assertFalse(GSSContextManager.hasUsableKerberosCredentials(subject, NOW));
+ }
+
+ @Test
+ public void tgtWithinExpiryMargin_isNotUsable() {
+ // 30s of life left is inside the 60s margin: re-login proactively rather than
+ // risk the ticket expiring between the check and the AP-REQ.
+ Subject subject = subjectWithTgt(NOW + 30_000);
+ assertFalse(GSSContextManager.hasUsableKerberosCredentials(subject, NOW));
+ }
+
+ @Test
+ public void tgtJustPastExpiryMargin_isUsable() {
+ Subject subject = subjectWithTgt(NOW + GSSContextManager.TGT_EXPIRY_MARGIN_MS + 1_000);
+ assertTrue(GSSContextManager.hasUsableKerberosCredentials(subject, NOW));
+ }
+
+ @Test
+ public void destroyedTgt_isNotUsable() throws DestroyFailedException {
+ KerberosTicket ticket = tgt(NOW + 8 * HOUR);
+ ticket.destroy();
+ Subject subject = new Subject();
+ subject.getPrivateCredentials().add(ticket);
+ assertFalse(GSSContextManager.hasUsableKerberosCredentials(subject, NOW));
+ }
+
+ @Test
+ public void serviceTicketAlone_isNotUsable() {
+ // A leftover service ticket (non-krbtgt) cannot initiate a new handshake.
+ Subject subject = new Subject();
+ subject.getPrivateCredentials().add(ticket(
+ "http/host.example.com@EXAMPLE.COM", NOW + 8 * HOUR));
+ assertFalse(GSSContextManager.hasUsableKerberosCredentials(subject, NOW));
+ }
+
+ @Test
+ public void keytabWithoutTicket_isUsable() {
+ // JGSS acquires TGTs from a keytab on its own; no re-login needed, ever.
+ Subject subject = new Subject();
+ subject.getPrivateCredentials().add(KeyTab.getInstance(new File("/nonexistent.keytab")));
+ assertTrue(GSSContextManager.hasUsableKerberosCredentials(subject, NOW));
+ }
+
+ @Test
+ public void keytabWithExpiredTicket_isUsable() {
+ Subject subject = subjectWithTgt(NOW - HOUR);
+ subject.getPrivateCredentials().add(KeyTab.getInstance(new File("/nonexistent.keytab")));
+ assertTrue(GSSContextManager.hasUsableKerberosCredentials(subject, NOW));
+ }
+
+ @Test
+ public void emptySubject_isNotUsable() {
+ assertFalse(GSSContextManager.hasUsableKerberosCredentials(new Subject(), NOW));
+ }
+
+ @Test
+ public void nullSubject_isNotUsable() {
+ assertFalse(GSSContextManager.hasUsableKerberosCredentials(null, NOW));
+ }
+
+ private static Subject subjectWithTgt(long endTimeMillis) {
+ Subject subject = new Subject();
+ subject.getPrivateCredentials().add(tgt(endTimeMillis));
+ return subject;
+ }
+
+ private static KerberosTicket tgt(long endTimeMillis) {
+ return ticket("krbtgt/EXAMPLE.COM@EXAMPLE.COM", endTimeMillis);
+ }
+
+ private static KerberosTicket ticket(String serverPrincipal, long endTimeMillis) {
+ return new KerberosTicket(
+ new byte[] {1}, // opaque ASN.1 encoding
+ new KerberosPrincipal("user@EXAMPLE.COM"),
+ new KerberosPrincipal(serverPrincipal),
+ new byte[16], 18, // AES256 session key
+ new boolean[8],
+ new Date(NOW - HOUR), // authTime
+ null, // startTime (defaults to authTime)
+ new Date(endTimeMillis),
+ null, // renewTill
+ null); // client addresses
+ }
+}
diff --git a/cxf/src/test/java/org/opennms/core/wsman/cxf/GSSContextManagerFormatTest.java b/cxf/src/test/java/org/opennms/core/wsman/cxf/GSSContextManagerFormatTest.java
new file mode 100644
index 0000000..420cef4
--- /dev/null
+++ b/cxf/src/test/java/org/opennms/core/wsman/cxf/GSSContextManagerFormatTest.java
@@ -0,0 +1,151 @@
+/*
+ * Copyright (C) The OpenNMS Group
+ *
+ * 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.opennms.core.wsman.cxf;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.fail;
+
+import java.io.IOException;
+import java.util.Random;
+
+import org.ietf.jgss.GSSException;
+import org.junit.Test;
+
+/**
+ * Tests the RFC 4121 <-> SSPI gss_wrap_iov byte-layout conversion in
+ * {@link GSSContextManager} with synthetic wrap tokens. The layouts are pure byte
+ * shuffles, so no established GSS context is needed:
+ *
+ * Java layout: [header(16) RRC=0][enc_confounder(16)][enc_plaintext(N)][enc_header_copy(16)][HMAC(12)]
+ * Wire layout: [4B LE 60][header(16) RRC=28][enc_header_copy(16)][HMAC(12)][enc_confounder(16)][enc_plaintext(N)]
+ */
+public class GSSContextManagerFormatTest {
+
+ private static final int TRAILER = 60; // SSPI security-trailer length for AES256
+ private static final int RRC = 28;
+
+ @Test
+ public void toWinRMFormat_layout() throws GSSException {
+ int plaintextLen = 100;
+ byte[] token = syntheticWrapToken(plaintextLen);
+ byte[] wire = GSSContextManager.toWinRMFormat(token, plaintextLen);
+
+ assertEquals(4 + token.length, wire.length);
+
+ // 4-byte little-endian trailer length
+ assertEquals(TRAILER, wire[0] & 0xff);
+ assertEquals(0, wire[1]);
+ assertEquals(0, wire[2]);
+ assertEquals(0, wire[3]);
+
+ // header copied, with the RRC field (bytes 6-7 of the header) set to 28 big-endian
+ for (int i = 0; i < 16; i++) {
+ if (i == 6) {
+ assertEquals(0, wire[4 + i]);
+ } else if (i == 7) {
+ assertEquals(RRC, wire[4 + i] & 0xff);
+ } else {
+ assertEquals("header byte " + i, token[i], wire[4 + i]);
+ }
+ }
+
+ // data portion right-rotated by 28: the last 28 bytes of the Java data portion
+ // (enc_header_copy + HMAC) lead, followed by confounder + ciphertext
+ int dataLen = token.length - 16;
+ for (int i = 0; i < RRC; i++) {
+ assertEquals("rotated byte " + i, token[16 + dataLen - RRC + i], wire[4 + 16 + i]);
+ }
+ for (int i = 0; i < dataLen - RRC; i++) {
+ assertEquals("body byte " + i, token[16 + i], wire[4 + 16 + RRC + i]);
+ }
+ }
+
+ @Test
+ public void fromWinRMFormat_isInverseOfToWinRMFormat() throws GSSException, IOException {
+ for (int plaintextLen : new int[] {1, 28, 29, 100, 4096}) {
+ byte[] token = syntheticWrapToken(plaintextLen);
+ byte[] wire = GSSContextManager.toWinRMFormat(token, plaintextLen);
+ byte[] recovered = GSSContextManager.fromWinRMFormat(wire);
+ // fromWinRMFormat zeros the RRC field, which was already 0 in the synthetic
+ // token, so the round trip must be exact.
+ assertArrayEquals("plaintextLen=" + plaintextLen, token, recovered);
+ }
+ }
+
+ @Test
+ public void toWinRMFormat_wrongTokenLength_throws() {
+ byte[] token = syntheticWrapToken(100);
+ try {
+ GSSContextManager.toWinRMFormat(token, 99); // claims a different plaintext size
+ fail("expected GSSException");
+ } catch (GSSException expected) {
+ // expected: only AES256's fixed 60-byte overhead is supported
+ }
+ }
+
+ @Test
+ public void fromWinRMFormat_rejectsUnknownTrailerLength() {
+ // A 16-byte trailer would be RC4/DES etypes, which we do not support
+ byte[] wire = new byte[4 + 16 + 8];
+ wire[0] = 16;
+ try {
+ GSSContextManager.fromWinRMFormat(wire);
+ fail("expected IOException");
+ } catch (IOException expected) {
+ // expected
+ }
+ }
+
+ @Test
+ public void fromWinRMFormat_rejectsTruncatedInput() {
+ byte[] tooShort = new byte[] {60, 0, 0};
+ try {
+ GSSContextManager.fromWinRMFormat(tooShort);
+ fail("expected IOException");
+ } catch (IOException expected) {
+ // expected
+ }
+
+ byte[] truncated = new byte[4 + 30]; // claims 60-byte trailer, has 30 bytes
+ truncated[0] = 60;
+ try {
+ GSSContextManager.fromWinRMFormat(truncated);
+ fail("expected IOException");
+ } catch (IOException expected) {
+ // expected
+ }
+ }
+
+ /**
+ * Builds a synthetic Java-GSS-layout wrap token: 16-byte RFC 4121 header with RRC=0
+ * followed by (16 confounder + N ciphertext + 16 header-copy + 12 HMAC) random bytes.
+ */
+ private static byte[] syntheticWrapToken(int plaintextLen) {
+ byte[] token = new byte[plaintextLen + TRAILER];
+ new Random(1234).nextBytes(token);
+ // RFC 4121 wrap-token header: TOK_ID 05 04, flags, filler FF, EC, RRC=0, SND_SEQ
+ token[0] = 0x05;
+ token[1] = 0x04;
+ token[2] = 0x06; // flags: sealed | acceptor-subkey
+ token[3] = (byte) 0xFF;
+ token[4] = 0x00; // EC hi
+ token[5] = 0x00; // EC lo
+ token[6] = 0x00; // RRC hi
+ token[7] = 0x00; // RRC lo
+ return token;
+ }
+}
diff --git a/cxf/src/test/java/org/opennms/core/wsman/cxf/KerberosConduitScopingTest.java b/cxf/src/test/java/org/opennms/core/wsman/cxf/KerberosConduitScopingTest.java
new file mode 100644
index 0000000..41c34b0
--- /dev/null
+++ b/cxf/src/test/java/org/opennms/core/wsman/cxf/KerberosConduitScopingTest.java
@@ -0,0 +1,85 @@
+/*
+ * Copyright (C) The OpenNMS Group
+ *
+ * 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.opennms.core.wsman.cxf;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+
+import java.net.MalformedURLException;
+
+import org.apache.cxf.Bus;
+import org.apache.cxf.BusFactory;
+import org.apache.cxf.transport.http.HTTPConduitFactory;
+import org.junit.Test;
+import org.opennms.core.wsman.WSManEndpoint;
+import org.opennms.core.wsman.cxf.shell.CxfShellOperations;
+
+/**
+ * Regression test for the Kerberos conduit-factory scoping: the WinRS shell path builds
+ * its Dispatch via {@code Service.create(...)}, which resolves to the JVM's thread-default
+ * bus, shared with every other CXF client on the thread. The factory that produces
+ * {@link KerberosHttpConduit}s must therefore be installed as an EndpointInfo property
+ * (consulted first by {@code HTTPTransportFactory.findFactory()}), never as a bus
+ * extension: a bus-level registration would permanently hand this WS-Man host's encrypted
+ * conduit, and its session-bound socket, to unrelated CXF clients in the JVM.
+ *
+ *