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 @@ jaxws-api 2.3.1 + + + javax.xml.bind + jaxb-api + 2.3.1 + + + org.glassfish.jaxb + jaxb-runtime + 2.3.9 + + + javax.xml.ws + jaxws-api + 2.3.1 + javax.activation activation diff --git a/cli/src/main/java/org/opennms/core/wsman/WSManCli.java b/cli/src/main/java/org/opennms/core/wsman/WSManCli.java index 9ff6865..055bbc7 100644 --- a/cli/src/main/java/org/opennms/core/wsman/WSManCli.java +++ b/cli/src/main/java/org/opennms/core/wsman/WSManCli.java @@ -68,6 +68,9 @@ public enum WSManOperation { @Option(name="-gssAuth", usage="GSS authentication") private boolean gssAuth = false; + @Option(name="-kerberosEncryption", usage="Kerberos message encryption (MS-WSMV §2.2.9.1)") + private boolean kerberosEncryption = false; + @Option(name="-o", usage="operation") WSManOperation operation = WSManOperation.ENUM; @@ -145,7 +148,12 @@ public void doMain(String[] args) { .withStrictSSL(strictSSL) .withServerVersion(serverVersion) .withMaxElements(100); - if (username != null && password != null) { + if (kerberosEncryption) { + if (username != null && password != null) { + builder.withBasicAuth(username, password); + } + builder.withKerberosEncryption(); + } else if (username != null && password != null) { builder.withBasicAuth(username, password); } else if (gssAuth) { builder.withGSSAuth(); @@ -153,7 +161,15 @@ public void doMain(String[] args) { WSManEndpoint endpoint = builder.build(); LOG.info("Using endpoint: {}", endpoint); WSManClient client = clientFactory.getClient(endpoint); + try { + runOperation(client); + } finally { + // Release any long-lived transport resources (e.g. the Kerberos-encrypted session) + client.close(); + } + } + private void runOperation(WSManClient client) { if (operation == WSManOperation.ENUM) { List nodes = new LinkedList<>(); if (arguments.isEmpty()) { @@ -199,6 +215,8 @@ public void doMain(String[] args) { System.out.print(result.stdout()); System.err.print(result.stderr()); LOG.info("Command exited with code {}", result.exitCode()); + // Close before exiting so the transport session is torn down cleanly + client.close(); System.exit(result.exitCode()); } } diff --git a/cxf/src/main/java/org/opennms/core/wsman/cxf/CXFWSManClient.java b/cxf/src/main/java/org/opennms/core/wsman/cxf/CXFWSManClient.java index f6a2803..87d3ade 100644 --- a/cxf/src/main/java/org/opennms/core/wsman/cxf/CXFWSManClient.java +++ b/cxf/src/main/java/org/opennms/core/wsman/cxf/CXFWSManClient.java @@ -15,6 +15,7 @@ */ package org.opennms.core.wsman.cxf; +import java.io.IOException; import java.math.BigInteger; import java.time.Duration; import java.util.Collections; @@ -23,6 +24,8 @@ import java.util.Map; import java.util.Objects; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocketFactory; import javax.net.ssl.TrustManager; import javax.net.ssl.X509TrustManager; import javax.xml.bind.JAXBElement; @@ -41,7 +44,10 @@ import org.apache.cxf.interceptor.transform.TransformOutInterceptor; import org.apache.cxf.jaxws.JaxWsProxyFactoryBean; import org.apache.cxf.message.Message; +import org.apache.cxf.service.model.EndpointInfo; import org.apache.cxf.transport.http.HTTPConduit; +import org.apache.cxf.transport.http.HTTPConduitFactory; +import org.apache.cxf.transport.http.HTTPTransportFactory; import org.apache.cxf.transport.http.auth.DefaultBasicAuthSupplier; import org.apache.cxf.transport.http.auth.HttpAuthHeader; import org.apache.cxf.transports.http.configuration.HTTPClientPolicy; @@ -85,6 +91,13 @@ /** * A WS-Man client implemented using JAX-WS & CXF. * + *

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 ProxyServiceType createProxyFor(Class ProxyServiceType createProxyFor(Class> headers = new HashMap<>(); - headers.put(CONTENT_TYPE_HEADER, Collections.singletonList(MEDIA_TYPE_SOAP_UTF8)); - requestContext.put(Message.PROTOCOL_HEADERS, headers); + // When Kerberos encryption is active the session builds the wire headers itself + // (multipart/encrypted Content-Type), so the override is unnecessary there. + if (!m_endpoint.isKerberosEncryption()) { + Map> headers = new HashMap<>(); + headers.put(CONTENT_TYPE_HEADER, Collections.singletonList(MEDIA_TYPE_SOAP_UTF8)); + requestContext.put(Message.PROTOCOL_HEADERS, headers); + } // Log incoming and outgoing requests LoggingInInterceptor loggingInInterceptor = new LoggingInInterceptor(); @@ -499,7 +608,17 @@ public CommandResult runCommand(String executable, String[] args, Duration timeo * applies to the JAX-WS proxies. JAX-WS-specific things (transform maps, Content-Type * action-attribute override) are not applied — they don't apply to {@code Dispatch}. */ - private void configureShellConduit(Client cxfClient) { + // Package-visible for KerberosConduitScopingTest, which verifies the conduit factory + // lands on the shell endpoint's EndpointInfo and never on the shared thread-default bus. + void configureShellConduit(Client cxfClient) { + // Must run before getConduit() below, which lazily creates the conduit: the + // endpoint-scoped factory property is only consulted at conduit-creation time. + // Scoped to the EndpointInfo, never the bus: the Dispatch was built on the JVM's + // thread-default bus, which unrelated CXF clients share. The shell uses the same + // KerberosHttpSession as the JAX-WS proxies, so one handshake covers everything. + if (m_endpoint.isKerberosEncryption()) { + installKerberosConduitFactory(cxfClient.getEndpoint().getEndpointInfo()); + } HTTPConduit http = (HTTPConduit) cxfClient.getConduit(); HTTPClientPolicy httpClientPolicy = new HTTPClientPolicy(); @@ -516,18 +635,24 @@ private void configureShellConduit(Client cxfClient) { http.setClient(httpClientPolicy); if (!m_endpoint.isStrictSSL()) { - TrustManager[] simpleTrustManager = new TrustManager[] { new X509TrustManager() { + 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; } }}; TLSClientParameters tlsParams = new TLSClientParameters(); - tlsParams.setTrustManagers(simpleTrustManager); + tlsParams.setSSLSocketFactory(buildPermissiveSslSocketFactory()); + tlsParams.setTrustManagers(trustAll); tlsParams.setDisableCNCheck(true); http.setTlsClientParameters(tlsParams); } - if (m_endpoint.isGSSAuth()) { + if (m_endpoint.isKerberosEncryption()) { + // MS-WSMV §2.2.9.1: handled entirely by the KerberosHttpConduit installed + // above; the shell shares the client's session, so its Create/Command/ + // Receive/Delete requests ride the same established connection. + LOG.debug("Kerberos message encryption for WinRS shell is handled by the KerberosHttpConduit."); + } else if (m_endpoint.isGSSAuth()) { http.getAuthorization().setAuthorizationType(HttpAuthHeader.AUTH_TYPE_NEGOTIATE); http.getAuthorization().setAuthorization("WSManClient"); http.getAuthorization().setUserName(m_endpoint.getUsername()); diff --git a/cxf/src/main/java/org/opennms/core/wsman/cxf/GSSContextManager.java b/cxf/src/main/java/org/opennms/core/wsman/cxf/GSSContextManager.java new file mode 100644 index 0000000..9312c4a --- /dev/null +++ b/cxf/src/main/java/org/opennms/core/wsman/cxf/GSSContextManager.java @@ -0,0 +1,495 @@ +/* + * 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.Closeable; +import java.io.IOException; +import java.security.PrivilegedActionException; +import java.security.PrivilegedExceptionAction; +import java.util.HashMap; +import java.util.Map; + +import javax.security.auth.Subject; +import javax.security.auth.callback.Callback; +import javax.security.auth.callback.NameCallback; +import javax.security.auth.callback.PasswordCallback; +import javax.security.auth.kerberos.KerberosTicket; +import javax.security.auth.kerberos.KeyTab; +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.ietf.jgss.GSSContext; +import org.ietf.jgss.GSSException; +import org.ietf.jgss.GSSManager; +import org.ietf.jgss.GSSName; +import org.ietf.jgss.MessageProp; +import org.ietf.jgss.Oid; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Manages the Kerberos GSS-API security material for a single WS-Man endpoint: the JAAS + * {@link Subject} (acquired once and reused across connections, so reconnects need a new + * AP-REQ but not a new AS-REQ), the per-connection {@link GSSContext}, and the + * wrap/unwrap operations used to implement MS-WSMV §2.2.9.1 KerberosEncryptedMessage. + * + *

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 createContext = () -> { + GSSContext ctx = mgr.createContext(serverName, SPNEGO_OID, null, GSSContext.DEFAULT_LIFETIME); + // Windows HTTP.sys requires mutual auth to complete the AP-REQ/AP-REP + // handshake before it will accept encrypted message bodies. + ctx.requestMutualAuth(true); + ctx.requestConf(true); + ctx.requestInteg(true); + // Defaults, but make the intent explicit: the per-message replay/reorder + // flags checked in unwrap() depend on these being negotiated. + ctx.requestReplayDet(true); + ctx.requestSequenceDet(true); + return ctx; + }; + + if (subject != null) { + try { + context = Subject.doAs(subject, createContext); + } catch (PrivilegedActionException e) { + throw unwrapGSS(e, "Failed to create GSS context"); + } + } else { + try { + context = createContext.run(); + } catch (GSSException | RuntimeException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException("Failed to create GSS context", e); + } + } + } + + /** + * Advances the handshake: feeds the server's token (or an empty token for the initial + * AP-REQ) into {@code initSecContext} and returns the next token to send, or + * {@code null} when there is nothing further to send. + */ + public synchronized byte[] nextToken(byte[] incomingServerToken) throws GSSException { + if (context == null) { + throw new IllegalStateException("newHandshake() must be called before nextToken()"); + } + final byte[] tokenIn = incomingServerToken != null ? incomingServerToken : new byte[0]; + byte[] token; + if (subject != null) { + try { + token = Subject.doAs(subject, (PrivilegedExceptionAction) () -> + context.initSecContext(tokenIn, 0, tokenIn.length)); + } catch (PrivilegedActionException e) { + throw unwrapGSS(e, "GSS initSecContext failed"); + } + } else { + token = context.initSecContext(tokenIn, 0, tokenIn.length); + } + return (token != null && token.length > 0) ? token : null; + } + + public synchronized boolean isEstablished() { + return context != null && context.isEstablished(); + } + + /** + * Verifies that the established context actually negotiated the protections we + * requested. MS-WSMV §2.2.9.1 encryption is meaningless over a context that only + * provides integrity (or neither), so a failed negotiation is a hard error, not + * something to discover one message at a time. + */ + public synchronized void verifyNegotiatedProtections() throws GSSException { + if (context == null || !context.isEstablished()) { + throw new GSSException(GSSException.NO_CONTEXT, -1, "GSS context is not established"); + } + if (!context.getConfState()) { + throw new GSSException(GSSException.UNAVAILABLE, -1, + "Kerberos context did not negotiate confidentiality; refusing to send encrypted messages"); + } + if (!context.getIntegState()) { + throw new GSSException(GSSException.UNAVAILABLE, -1, + "Kerberos context did not negotiate integrity; refusing to send encrypted messages"); + } + if (!context.getMutualAuthState()) { + throw new GSSException(GSSException.UNAVAILABLE, -1, + "Kerberos mutual authentication did not complete; refusing to send encrypted messages"); + } + if (!context.getReplayDetState()) { + LOG.warn("Kerberos context did not negotiate replay detection; " + + "per-message duplicate/old-token checks will not fire"); + } + } + + /** + * Encrypts {@code data} using GSS wrap and verifies that confidentiality was + * actually applied to the token (an integrity-only wrap must not be sent as an + * "encrypted" message). + */ + public synchronized byte[] wrap(byte[] data) throws GSSException { + MessageProp prop = new MessageProp(0, true); + byte[] token = doWrap(data, prop); + if (!prop.getPrivacy()) { + throw new GSSException(GSSException.UNAVAILABLE, -1, + "GSS wrap did not apply confidentiality; refusing to send the message as encrypted"); + } + return token; + } + + /** + * Decrypts {@code data} using GSS unwrap and enforces the per-message security + * state: the token must have been confidential (not integrity-only), and replayed + * or expired tokens are rejected. Out-of-sequence and gap indications are logged + * rather than fatal — HTTP request/response framing already orders messages, and + * benign pipelining differences would otherwise break the session. + */ + public synchronized byte[] unwrap(byte[] data) throws GSSException { + MessageProp prop = new MessageProp(0, false); + byte[] plaintext = doUnwrap(data, prop); + if (!prop.getPrivacy()) { + throw new GSSException(GSSException.BAD_MIC, -1, + "Received a token without confidentiality on an encrypted session; rejecting"); + } + if (prop.isDuplicateToken() || prop.isOldToken()) { + throw new GSSException(GSSException.DUPLICATE_TOKEN, -1, + "Received a duplicate or expired GSS token (possible replay); rejecting"); + } + if (prop.isUnseqToken() || prop.isGapToken()) { + LOG.warn("GSS unwrap reported an out-of-sequence or gap token (unseq={}, gap={})", + prop.isUnseqToken(), prop.isGapToken()); + } + return plaintext; + } + + private byte[] doWrap(byte[] data, MessageProp prop) throws GSSException { + if (subject != null) { + try { + final byte[] d = data; + return Subject.doAs(subject, (PrivilegedExceptionAction) () -> + context.wrap(d, 0, d.length, prop)); + } catch (PrivilegedActionException e) { + throw unwrapGSS(e, "GSS wrap failed"); + } + } + return context.wrap(data, 0, data.length, prop); + } + + private byte[] doUnwrap(byte[] data, MessageProp prop) throws GSSException { + if (subject != null) { + try { + final byte[] d = data; + return Subject.doAs(subject, (PrivilegedExceptionAction) () -> + context.unwrap(d, 0, d.length, prop)); + } catch (PrivilegedActionException e) { + throw unwrapGSS(e, "GSS unwrap failed"); + } + } + return context.unwrap(data, 0, data.length, prop); + } + + /** + * Wraps {@code data} and emits the result in the WinRM/SSPI iov format per MS-WSMV §2.2.9.1: + * {@code [4-byte LE security-trailer-length][security-trailer bytes][ciphertext bytes]}. + * + * Windows expects the {@code gss_wrap_iov} buffer layout: a "header" (SSPI security trailer) + * containing the RFC 4121 wrap-token header, the encrypted confounder, the encrypted + * trailing header replica, and the HMAC; followed by a "data" buffer holding just the + * encrypted plaintext. Java's GSS API only produces the monolithic RFC 4121 wrap token + * (header || enc(confounder || plaintext || header) || HMAC), so this method splits that + * token at the known offsets for AES256-CTS-HMAC-SHA1-96 and reassembles it. + */ + public synchronized byte[] wrapWinRM(byte[] data) throws GSSException { + byte[] wrapToken = wrap(data); + return toWinRMFormat(wrapToken, data.length); + } + + /** + * Inverse of {@link #wrapWinRM}: takes the wire-format + * {@code [4B LE sig-len][sig][ciphertext]} produced by an SSPI {@code gss_wrap_iov} + * sender (Windows WinRM) and re-interleaves the bytes back into the monolithic RFC 4121 + * layout that Java GSS {@code unwrap()} expects. + */ + public synchronized byte[] unwrapWinRM(byte[] winrmBytes) throws GSSException, IOException { + return unwrap(fromWinRMFormat(winrmBytes)); + } + + /** + * Disposes the GSS context and logs out the JAAS session. Safe to call multiple times. + */ + @Override + public synchronized void close() { + disposeContext(); + if (loginContext != null && loggedIn) { + try { + loginContext.logout(); + } catch (LoginException e) { + LOG.debug("JAAS logout failed (ignoring)", e); + } + } + loginContext = null; + subject = null; + loggedIn = false; + } + + private void disposeContext() { + if (context != null) { + try { + context.dispose(); + } catch (GSSException ignored) {} + context = null; + } + } + + private void ensureLogin() throws LoginException { + if (loggedIn && hasUsableKerberosCredentials(subject, System.currentTimeMillis())) { + return; + } + if (loggedIn) { + // The TGT expired (or is about to) and the subject has no keytab to acquire + // a fresh one on its own. Kerberos TGTs typically live around 10 hours, so a + // long-lived busy client must be able to cross that boundary: discard the old + // login and run a fresh one. + LOG.debug("Kerberos TGT expired or near expiry; re-running JAAS login for {}", + username != null ? username : "\"WSManClient\" login config"); + if (loginContext != null) { + try { + loginContext.logout(); + } catch (LoginException e) { + LOG.debug("JAAS logout of the expired login failed (ignoring)", e); + } + } + loginContext = null; + subject = null; + loggedIn = false; + } + if (username != null && password != null) { + loginContext = buildPasswordLoginContext(); + } else { + // Mirror the -gssAuth convention: use the "WSManClient" JAAS login context, + // which is read from java.security.auth.login.config. This is the same name + // CXF's SpnegoAuthSupplier uses when setAuthorization("WSManClient") is called. + loginContext = new LoginContext("WSManClient"); + } + loginContext.login(); + subject = loginContext.getSubject(); + loggedIn = true; + } + + /** Re-login proactively when the TGT has less than this long left, so a ticket + * cannot expire between the check and the AP-REQ built from it. */ + static final long TGT_EXPIRY_MARGIN_MS = 60_000; + + /** + * Returns true when the subject can still initiate a Kerberos handshake: it holds a + * krbtgt ticket that is current and not within {@link #TGT_EXPIRY_MARGIN_MS} of + * expiry, or it holds a keytab, from which JGSS acquires fresh tickets on its own + * (so an expired TGT does not matter and a re-login would be pointless churn). + * Package-visible and clock-parameterized for unit testing. + */ + static boolean hasUsableKerberosCredentials(Subject subject, long nowMillis) { + if (subject == null) { + return false; + } + for (KerberosTicket ticket : subject.getPrivateCredentials(KerberosTicket.class)) { + if (ticket.getServer() != null + && ticket.getServer().getName().startsWith("krbtgt/") + && ticket.isCurrent() + && ticket.getEndTime() != null + && ticket.getEndTime().getTime() - nowMillis > TGT_EXPIRY_MARGIN_MS) { + return true; + } + } + return !subject.getPrivateCredentials(KeyTab.class).isEmpty(); + } + + private LoginContext buildPasswordLoginContext() throws LoginException { + Subject sub = new Subject(); + final String user = username; + final char[] pass = password.toCharArray(); + return new LoginContext("", sub, + callbacks -> { + for (Callback cb : callbacks) { + if (cb instanceof NameCallback) { + ((NameCallback) cb).setName(user); + } else if (cb instanceof PasswordCallback) { + ((PasswordCallback) cb).setPassword(pass); + } + } + }, + new Configuration() { + @Override + public AppConfigurationEntry[] getAppConfigurationEntry(String name) { + Map opts = new HashMap<>(); + opts.put("useKeyTab", "false"); + opts.put("doNotPrompt", "false"); + opts.put("isInitiator", "true"); + opts.put("principal", user); + return new AppConfigurationEntry[]{ + new AppConfigurationEntry( + "com.sun.security.auth.module.Krb5LoginModule", + LoginModuleControlFlag.REQUIRED, + opts) + }; + } + }); + } + + private static GSSException unwrapGSS(PrivilegedActionException e, String message) { + Throwable cause = e.getCause(); + if (cause instanceof GSSException) { + return (GSSException) cause; + } + throw new RuntimeException(message, cause); + } + + /** + * Splits Java's monolithic RFC 4121 wrap token into the SSPI gss_wrap_iov wire layout. + * + *

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> headers; + private final byte[] body; + private final String contentType; + private final boolean decrypted; + + Response(int statusCode, String reasonPhrase, Map> headers, + byte[] body, String contentType, boolean decrypted) { + this.statusCode = statusCode; + this.reasonPhrase = reasonPhrase; + this.headers = headers; + this.body = body; + this.contentType = contentType; + this.decrypted = decrypted; + } + + public int getStatusCode() { return statusCode; } + public String getReasonPhrase() { return reasonPhrase; } + public Map> getHeaders() { return headers; } + public byte[] getBody() { return body; } + public String getContentType() { return contentType; } + public boolean isDecrypted() { return decrypted; } + } + + /** + * Sends one SOAP request (UTF-8 bytes) encrypted per MS-WSMV §2.2.9.1 and returns the + * classified response. Establishes the connection and the Kerberos session on first + * use, and proactively re-establishes them when the connection has sat idle. + * + *

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 headers = new LinkedHashMap<>(); + headers.put("Content-Type", WinRMEncryptedMultipart.CONTENT_TYPE); + headers.put("Content-Length", String.valueOf(multipart.length)); + + boolean freshConnection = exchangesOnConnection == 0; + boolean requestFullySent = false; + try { + socket.setSoTimeout(receiveTimeout); + writeRequest(out, headers, multipart); + requestFullySent = true; + RawResponse raw = readResponse(in); + exchangesOnConnection++; + lastUseNanos = System.nanoTime(); + if (raw.connectionClose) { + // Server is done with this connection; the session binding dies with it. + invalidateConnection(); + } + return raw; + } catch (SocketTimeoutException e) { + // The request reached the server; retrying could execute it twice. + invalidateConnection(); + throw e; + } catch (IOException e) { + invalidateConnection(); + if (!freshConnection && !requestFullySent) { + // The reused keep-alive connection died mid-write. The server cannot have + // processed a partial HTTP request (the body is length-delimited), so a + // resend on a fresh connection is safe. + throw new StaleConnectionException(e); + } + if (requestFullySent) { + throw new IOException("Connection lost after the request was fully sent but" + + " before a response was received; not retrying automatically because" + + " the server may have already executed the request", e); + } + throw e; + } + } + + /** + * Applies the MS-WSMV response policy. Every response classified here arrived on an + * established encrypted session, so a cleartext body is unauthenticated: an on-path + * attacker can forge it freely, and it must never reach the XML parser or be surfaced + * to callers as an authentic server response. + *

+ * 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 }, feeding each {@code WWW-Authenticate} + * token back into the context until it is established. HTTP.sys binds the resulting + * Kerberos session to this TCP connection. + */ + private void handshake(int receiveTimeout) throws IOException { + try { + gss.newHandshake(); + byte[] token = gss.nextToken(null); + int legs = 0; + + while (token != null) { + if (++legs > MAX_HANDSHAKE_LEGS) { + throw new IOException("Kerberos Negotiate handshake did not converge after " + + MAX_HANDSHAKE_LEGS + " legs"); + } + LOG.debug("Kerberos handshake leg {}: sending {} byte token", legs, token.length); + + Map headers = new LinkedHashMap<>(); + headers.put("Content-Type", "application/soap+xml;charset=UTF-8"); + headers.put("Content-Length", "0"); + headers.put("Authorization", "Negotiate " + Base64.getEncoder().encodeToString(token)); + + socket.setSoTimeout(receiveTimeout); + writeRequest(out, headers, new byte[0]); + RawResponse response = readResponse(in); + if (response.connectionClose) { + throw new IOException("Server closed the connection during the Negotiate handshake" + + " (HTTP " + response.statusCode + "); the session binding cannot survive"); + } + + byte[] serverToken = extractNegotiateToken(response.headers); + if (serverToken == null) { + if (gss.isEstablished()) { + break; + } + throw new IOException("Negotiate handshake failed: HTTP " + response.statusCode + + " with no WWW-Authenticate token and context not established"); + } + token = gss.nextToken(serverToken); + } + + if (!gss.isEstablished()) { + throw new IOException("Negotiate handshake ended without an established GSS context"); + } + gss.verifyNegotiatedProtections(); + lastUseNanos = System.nanoTime(); + LOG.debug("Kerberos session established on connection to {}:{}", host, port); + } catch (GSSException e) { + throw new IOException("Kerberos Negotiate handshake failed", e); + } catch (javax.security.auth.login.LoginException e) { + throw new IOException("Kerberos login failed", e); + } + } + + private static byte[] extractNegotiateToken(Map> headers) { + List values = headers.get("WWW-Authenticate"); + if (values == null) { + return null; + } + for (String v : values) { + if (v != null && v.startsWith("Negotiate ")) { + String b64 = v.substring("Negotiate ".length()).trim(); + if (!b64.isEmpty()) { + return Base64.getDecoder().decode(b64); + } + } + } + return null; + } + + private void invalidateConnection() { + if (socket != null) { + try { socket.close(); } catch (IOException ignored) {} + } + socket = null; + in = null; + out = null; + exchangesOnConnection = 0; + } + + /** Closes the connection and disposes the GSS context and JAAS login. */ + @Override + public synchronized void close() { + cancelReaper(); + invalidateConnection(); + gss.close(); + } + + /** Schedules the periodic idle check for this session if none is live. Caller holds + * the session lock (all callers are inside synchronized session methods). */ + private void ensureReaperScheduled() { + if (reaperTask == null || reaperTask.isDone()) { + reaperTask = ReaperHolder.EXECUTOR.scheduleWithFixedDelay(this::reapIfIdle, + REAPER_INTERVAL_SECONDS, REAPER_INTERVAL_SECONDS, TimeUnit.SECONDS); + } + } + + private synchronized void cancelReaper() { + if (reaperTask != null) { + reaperTask.cancel(false); + reaperTask = null; + } + } + + /** + * Periodic reap: closes the connection once it passes the idle-reuse threshold (past + * it, the next request would discard the connection anyway, so keeping it open only + * pins a socket), and after {@link #MAX_IDLE_SESSION_NANOS} releases the GSS context + * and JAAS login too and cancels itself, so a session whose owner never calls + * {@link #close()} holds nothing and becomes garbage-collectable. + * + *

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 headers, byte[] body) throws IOException { + StringBuilder sb = new StringBuilder(256); + sb.append("POST ").append(path).append(" HTTP/1.1\r\n"); + sb.append("Host: ").append(host).append(':').append(port).append("\r\n"); + sb.append("User-Agent: OpenNMS WS-Man Client\r\n"); + sb.append("Connection: Keep-Alive\r\n"); + for (Map.Entry h : headers.entrySet()) { + sb.append(h.getKey()).append(": ").append(h.getValue()).append("\r\n"); + } + sb.append("\r\n"); + os.write(sb.toString().getBytes(StandardCharsets.US_ASCII)); + if (body.length > 0) { + os.write(body); + } + os.flush(); + } + + static final class RawResponse { + int statusCode; + String reasonPhrase = ""; + Map> headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + byte[] body = new byte[0]; + boolean connectionClose; + } + + static RawResponse readResponse(InputStream is) throws IOException { + RawResponse r = new RawResponse(); + + String statusLine = readLine(is); + if (statusLine == null || statusLine.isEmpty()) { + throw new EOFException("Connection closed before an HTTP status line was received"); + } + // "HTTP/1.1 200 OK" — the reason phrase is optional + String[] parts = statusLine.split(" ", 3); + if (parts.length < 2 || !parts[0].startsWith("HTTP/")) { + throw new IOException("Malformed HTTP status line: " + statusLine); + } + try { + r.statusCode = Integer.parseInt(parts[1]); + } catch (NumberFormatException e) { + throw new IOException("Malformed HTTP status code in: " + statusLine); + } + r.reasonPhrase = parts.length > 2 ? parts[2] : ""; + boolean http10 = statusLine.startsWith("HTTP/1.0"); + + String line; + int headerBytes = 0; + while ((line = readLine(is)) != null && !line.isEmpty()) { + headerBytes += line.length() + 2; + if (headerBytes > MAX_HEADERS_LENGTH) { + throw new IOException("HTTP response headers exceed " + MAX_HEADERS_LENGTH + " bytes"); + } + int colon = line.indexOf(':'); + if (colon <= 0) { + continue; + } + String name = line.substring(0, colon).trim(); + String value = line.substring(colon + 1).trim(); + List values = r.headers.get(name); + if (values == null) { + values = new java.util.ArrayList<>(1); + r.headers.put(name, values); + } + values.add(value); + } + + String connection = firstHeader(r.headers, "Connection"); + r.connectionClose = http10 + ? (connection == null || !"keep-alive".equalsIgnoreCase(connection)) + : "close".equalsIgnoreCase(connection); + + String transferEncoding = firstHeader(r.headers, "Transfer-Encoding"); + String contentLength = firstHeader(r.headers, "Content-Length"); + + if (transferEncoding != null && transferEncoding.toLowerCase(Locale.ROOT).contains("chunked")) { + r.body = readChunkedBody(is); + } else if (contentLength != null) { + long len; + try { + len = Long.parseLong(contentLength.trim()); + } catch (NumberFormatException e) { + throw new IOException("Malformed Content-Length: " + contentLength); + } + if (len < 0 || len > MAX_RESPONSE_BODY) { + throw new IOException("Unreasonable Content-Length: " + len); + } + r.body = readFully(is, (int) len); + } else if (r.connectionClose) { + r.body = readToEof(is); + } else { + // Keep-alive with neither framing header: no body (e.g. a bare 401 challenge). + r.body = new byte[0]; + } + return r; + } + + private static String firstHeader(Map> headers, String name) { + List values = headers.get(name); + return (values == null || values.isEmpty()) ? null : values.get(0); + } + + /** Reads one CRLF-terminated line as ISO-8859-1, without the terminator. + * Returns null on EOF before any byte was read; rejects lines over + * {@link #MAX_LINE_LENGTH} so a hostile peer cannot grow the buffer unboundedly. */ + private static String readLine(InputStream is) throws IOException { + ByteArrayOutputStream buf = new ByteArrayOutputStream(64); + int c = is.read(); + if (c < 0) { + return null; + } + while (c >= 0 && c != '\n') { + if (buf.size() >= MAX_LINE_LENGTH) { + throw new IOException("HTTP line exceeds " + MAX_LINE_LENGTH + " bytes"); + } + buf.write(c); + c = is.read(); + } + byte[] bytes = buf.toByteArray(); + int len = bytes.length; + if (len > 0 && bytes[len - 1] == '\r') { + len--; + } + return new String(bytes, 0, len, StandardCharsets.ISO_8859_1); + } + + private static byte[] readFully(InputStream is, int len) throws IOException { + byte[] buf = new byte[len]; + int off = 0; + while (off < len) { + int n = is.read(buf, off, len - off); + if (n < 0) { + throw new EOFException("Connection closed after " + off + " of " + len + " body bytes"); + } + off += n; + } + return buf; + } + + private static byte[] readChunkedBody(InputStream is) throws IOException { + ByteArrayOutputStream body = new ByteArrayOutputStream(); + while (true) { + String sizeLine = readLine(is); + if (sizeLine == null) { + throw new EOFException("Connection closed inside a chunked body"); + } + int semi = sizeLine.indexOf(';'); + String hex = (semi >= 0 ? sizeLine.substring(0, semi) : sizeLine).trim(); + int size; + try { + size = Integer.parseInt(hex, 16); + } catch (NumberFormatException e) { + throw new IOException("Malformed chunk size: " + sizeLine); + } + if (size < 0 || body.size() + size > MAX_RESPONSE_BODY) { + throw new IOException("Unreasonable chunk size: " + size); + } + if (size == 0) { + // consume any trailers up to the blank line, with the same cumulative cap + // as headers so an endless trailer stream cannot spin forever + String line; + int trailerBytes = 0; + while ((line = readLine(is)) != null && !line.isEmpty()) { + trailerBytes += line.length() + 2; + if (trailerBytes > MAX_HEADERS_LENGTH) { + throw new IOException("Chunked trailers exceed " + MAX_HEADERS_LENGTH + " bytes"); + } + } + break; + } + body.write(readFully(is, size)); + String crlf = readLine(is); + if (crlf == null || !crlf.isEmpty()) { + throw new IOException("Missing CRLF after chunk data"); + } + } + return body.toByteArray(); + } + + private static byte[] readToEof(InputStream is) throws IOException { + ByteArrayOutputStream body = new ByteArrayOutputStream(); + byte[] buf = new byte[8192]; + int n; + while ((n = is.read(buf)) >= 0) { + if (body.size() + n > MAX_RESPONSE_BODY) { + throw new IOException("Response body exceeds " + MAX_RESPONSE_BODY + " bytes"); + } + body.write(buf, 0, n); + } + return body.toByteArray(); + } + + /** Marker distinguishing "reused connection died before the request was fully + * written" (provably unprocessed by the server, safe to resend) from other I/O + * failures, which are never retried automatically. */ + private static final class StaleConnectionException extends IOException { + private static final long serialVersionUID = 1L; + StaleConnectionException(IOException cause) { + super(cause); + } + } +} diff --git a/cxf/src/main/java/org/opennms/core/wsman/cxf/WinRMEncryptedMultipart.java b/cxf/src/main/java/org/opennms/core/wsman/cxf/WinRMEncryptedMultipart.java new file mode 100644 index 0000000..fc0ac1a --- /dev/null +++ b/cxf/src/main/java/org/opennms/core/wsman/cxf/WinRMEncryptedMultipart.java @@ -0,0 +1,207 @@ +/* + * 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.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + +/** + * Builds and parses the {@code multipart/encrypted} HTTP body framing used by MS-WSMV + * §2.2.9.1 KerberosEncryptedMessage. The encrypted payload itself (the SSPI + * {@code [4B LE sig-len][sig][ciphertext]} section) is produced and consumed by + * {@link GSSContextManager}; this class only handles the surrounding MIME-ish framing. + */ +final class WinRMEncryptedMultipart { + + static final String CONTENT_TYPE = + "multipart/encrypted;" + + "protocol=\"application/HTTP-SPNEGO-session-encrypted\";" + + "boundary=\"Encrypted Boundary\""; + + private static final byte[] BOUNDARY = "--Encrypted Boundary".getBytes(StandardCharsets.US_ASCII); + private static final byte[] CLOSING_BOUNDARY = "--Encrypted Boundary--".getBytes(StandardCharsets.US_ASCII); + private static final byte[] CRLF = "\r\n".getBytes(StandardCharsets.US_ASCII); + + private WinRMEncryptedMultipart() {} + + /** + * Builds the multipart/encrypted body around an already-encrypted section. + * + * OriginalContent Length is the plaintext SOAP byte count, which WinRM validates + * against the decrypted size. No CRLF between the binary section and the closing + * boundary — matches pywinrm's wire format. + */ + static byte[] build(byte[] encryptedSection, int originalSoapLength) throws IOException { + String header = + "--Encrypted Boundary\r\n" + + "\tContent-Type: application/HTTP-SPNEGO-session-encrypted\r\n" + + "\tOriginalContent: type=application/soap+xml;charset=UTF-8;Length=" + originalSoapLength + "\r\n" + + "--Encrypted Boundary\r\n" + + "\tContent-Type: application/octet-stream\r\n"; + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + baos.write(header.getBytes(StandardCharsets.US_ASCII)); + baos.write(encryptedSection); + baos.write("--Encrypted Boundary--\r\n".getBytes(StandardCharsets.US_ASCII)); + return baos.toByteArray(); + } + + /** + * Returns true if the body starts with the MS-WSMV encrypted-boundary marker + * (allowing leading whitespace/CRLF that some servers prepend). Detection is done + * on body content rather than Content-Type because WinRM servers do not + * consistently send a Content-Type on encrypted responses. + */ + static boolean isEncrypted(byte[] body) { + if (body == null || body.length < BOUNDARY.length) { + return false; + } + int start = 0; + while (start < body.length && (body[start] == '\r' || body[start] == '\n' || body[start] == ' ')) { + start++; + } + if (body.length - start < BOUNDARY.length) { + return false; + } + for (int i = 0; i < BOUNDARY.length; i++) { + if (body[start + i] != BOUNDARY[i]) { + return false; + } + } + return true; + } + + /** + * Locates the encrypted bytes in the multipart body. + * + * Expected layout: + *

+     * --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. + * + *

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", "forged result")); + fail("expected IOException"); + } catch (IOException e) { + assertTrue(e.getMessage(), e.getMessage().contains("cleartext")); + // the forged body must not leak into the message either + assertFalse(e.getMessage(), e.getMessage().contains("forged result")); + } + } + + @Test + public void cleartext401_passesStatusThroughWithBodyWithheld() throws Exception { + RawResponse r = raw(401, "Unauthorized", "attacker controlled junk"); + r.headers.put("WWW-Authenticate", Collections.singletonList("Negotiate")); + Response response = session().classify(r); + assertEquals(401, response.getStatusCode()); + assertEquals("Unauthorized", response.getReasonPhrase()); + assertEquals("Negotiate", response.getHeaders().get("WWW-Authenticate").get(0)); + assertEquals("the unauthenticated body must be withheld", 0, response.getBody().length); + assertFalse(response.isDecrypted()); + } + + @Test + public void cleartext500_isRejectedWithSanitizedExcerpt() throws Exception { + try { + session().classify(raw(500, "Internal Server Error", + "\r\n\tWSManFault: cannot decrypt")); + fail("expected IOException"); + } catch (IOException e) { + String msg = e.getMessage(); + assertTrue(msg, msg.contains("500")); + // diagnostics are preserved, but control characters are not + assertTrue(msg, msg.contains("WSManFault: cannot decrypt")); + assertFalse(msg, msg.contains("\t")); + assertFalse(msg, msg.contains("\r")); + } + } + + @Test + public void sanitizeForDiagnostics_truncatesAndStripsControls() { + assertEquals("(empty)", KerberosHttpSession.sanitizeForDiagnostics(new byte[0])); + assertEquals("(no printable content)", + KerberosHttpSession.sanitizeForDiagnostics(new byte[] {0, 1, 2, '\r', '\n'})); + + StringBuilder big = new StringBuilder(); + for (int i = 0; i < 1000; i++) { + big.append('a'); + } + String sanitized = KerberosHttpSession.sanitizeForDiagnostics( + big.toString().getBytes(StandardCharsets.UTF_8)); + assertEquals(303, sanitized.length()); // 300 chars + "..." + assertTrue(sanitized.endsWith("...")); + } +} diff --git a/cxf/src/test/java/org/opennms/core/wsman/cxf/KerberosHttpSessionCodecTest.java b/cxf/src/test/java/org/opennms/core/wsman/cxf/KerberosHttpSessionCodecTest.java new file mode 100644 index 0000000..3f01f76 --- /dev/null +++ b/cxf/src/test/java/org/opennms/core/wsman/cxf/KerberosHttpSessionCodecTest.java @@ -0,0 +1,256 @@ +/* + * 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.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.io.ByteArrayInputStream; +import java.io.EOFException; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import org.junit.Test; +import org.opennms.core.wsman.cxf.KerberosHttpSession.RawResponse; + +/** + * Tests the minimal HTTP/1.1 response codec inside {@link KerberosHttpSession}. + */ +public class KerberosHttpSessionCodecTest { + + @Test + public void contentLengthBody() throws IOException { + RawResponse r = parse( + "HTTP/1.1 200 OK\r\n" + + "Content-Type: application/soap+xml;charset=UTF-8\r\n" + + "Content-Length: 11\r\n" + + "\r\n" + + "hello world"); + assertEquals(200, r.statusCode); + assertEquals("OK", r.reasonPhrase); + assertEquals("application/soap+xml;charset=UTF-8", r.headers.get("Content-Type").get(0)); + assertArrayEquals("hello world".getBytes(StandardCharsets.US_ASCII), r.body); + assertFalse(r.connectionClose); + } + + @Test + public void headerLookupIsCaseInsensitive() throws IOException { + RawResponse r = parse( + "HTTP/1.1 401 Unauthorized\r\n" + + "www-authenticate: Negotiate\r\n" + + "content-length: 0\r\n" + + "\r\n"); + assertEquals(401, r.statusCode); + assertEquals("Negotiate", r.headers.get("WWW-Authenticate").get(0)); + assertEquals(0, r.body.length); + } + + @Test + public void chunkedBody() throws IOException { + RawResponse r = parse( + "HTTP/1.1 200 OK\r\n" + + "Transfer-Encoding: chunked\r\n" + + "\r\n" + + "5\r\n" + + "hello\r\n" + + "6\r\n" + + " world\r\n" + + "0\r\n" + + "\r\n"); + assertArrayEquals("hello world".getBytes(StandardCharsets.US_ASCII), r.body); + } + + @Test + public void chunkedBody_withChunkExtensionAndTrailers() throws IOException { + RawResponse r = parse( + "HTTP/1.1 200 OK\r\n" + + "Transfer-Encoding: chunked\r\n" + + "\r\n" + + "b;ext=1\r\n" + + "hello world\r\n" + + "0\r\n" + + "X-Trailer: ignored\r\n" + + "\r\n"); + assertArrayEquals("hello world".getBytes(StandardCharsets.US_ASCII), r.body); + } + + @Test + public void connectionClose_readsToEof() throws IOException { + RawResponse r = parse( + "HTTP/1.1 500 Internal Server Error\r\n" + + "Connection: close\r\n" + + "\r\n" + + "fault body"); + assertEquals(500, r.statusCode); + assertTrue(r.connectionClose); + assertArrayEquals("fault body".getBytes(StandardCharsets.US_ASCII), r.body); + } + + @Test + public void keepAliveWithoutFramingHeaders_hasEmptyBody() throws IOException { + // A bare 401 Negotiate challenge: keep-alive, no Content-Length, no chunking. + RawResponse r = parse( + "HTTP/1.1 401 Unauthorized\r\n" + + "WWW-Authenticate: Negotiate YIIabc=\r\n" + + "\r\n"); + assertEquals(401, r.statusCode); + assertEquals(0, r.body.length); + assertFalse(r.connectionClose); + } + + @Test + public void statusLineWithoutReasonPhrase() throws IOException { + RawResponse r = parse( + "HTTP/1.1 200\r\n" + + "Content-Length: 0\r\n" + + "\r\n"); + assertEquals(200, r.statusCode); + assertEquals("", r.reasonPhrase); + } + + @Test + public void multipleHeaderValuesAccumulate() throws IOException { + RawResponse r = parse( + "HTTP/1.1 401 Unauthorized\r\n" + + "WWW-Authenticate: Negotiate\r\n" + + "WWW-Authenticate: Basic realm=\"x\"\r\n" + + "Content-Length: 0\r\n" + + "\r\n"); + assertEquals(2, r.headers.get("WWW-Authenticate").size()); + } + + @Test + public void http10_defaultsToConnectionClose() throws IOException { + RawResponse r = parse( + "HTTP/1.0 200 OK\r\n" + + "Content-Length: 2\r\n" + + "\r\n" + + "ok"); + assertTrue(r.connectionClose); + } + + @Test + public void emptyStream_throwsEof() { + try { + parse(""); + fail("expected EOFException"); + } catch (EOFException expected) { + // expected + } catch (IOException e) { + fail("expected EOFException, got " + e); + } + } + + @Test + public void malformedStatusLine_throws() { + try { + parse("garbage that is not http\r\n\r\n"); + fail("expected IOException"); + } catch (IOException expected) { + // expected + } + } + + @Test + public void truncatedContentLengthBody_throwsEof() { + try { + parse( + "HTTP/1.1 200 OK\r\n" + + "Content-Length: 100\r\n" + + "\r\n" + + "only a few bytes"); + fail("expected EOFException"); + } catch (EOFException expected) { + // expected + } catch (IOException e) { + fail("expected EOFException, got " + e); + } + } + + @Test + public void negativeContentLength_throws() { + try { + parse( + "HTTP/1.1 200 OK\r\n" + + "Content-Length: -5\r\n" + + "\r\n"); + fail("expected IOException"); + } catch (IOException expected) { + // expected + } + } + + @Test + public void oversizedHeaderLine_throws() { + StringBuilder sb = new StringBuilder("HTTP/1.1 200 OK\r\nX-Junk: "); + for (int i = 0; i < 70 * 1024; i++) { + sb.append('a'); + } + sb.append("\r\n\r\n"); + try { + parse(sb.toString()); + fail("expected IOException"); + } catch (IOException expected) { + assertTrue(expected.getMessage(), expected.getMessage().contains("line exceeds")); + } + } + + @Test + public void oversizedHeaderBlock_throws() { + StringBuilder sb = new StringBuilder("HTTP/1.1 200 OK\r\n"); + // ~20k headers of ~60 bytes each: every line is within the per-line cap, but the + // block blows past the cumulative cap + for (int i = 0; i < 20_000; i++) { + sb.append("X-Header-").append(i).append(": aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\r\n"); + } + sb.append("\r\n"); + try { + parse(sb.toString()); + fail("expected IOException"); + } catch (IOException expected) { + assertTrue(expected.getMessage(), expected.getMessage().contains("headers exceed")); + } + } + + @Test + public void oversizedChunkedTrailers_throw() { + StringBuilder sb = new StringBuilder( + "HTTP/1.1 200 OK\r\n" + + "Transfer-Encoding: chunked\r\n" + + "\r\n" + + "2\r\n" + + "ok\r\n" + + "0\r\n"); + for (int i = 0; i < 40_000; i++) { + sb.append("X-Trailer-").append(i).append(": aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\r\n"); + } + sb.append("\r\n"); + try { + parse(sb.toString()); + fail("expected IOException"); + } catch (IOException expected) { + assertTrue(expected.getMessage(), expected.getMessage().contains("trailers exceed")); + } + } + + private static RawResponse parse(String raw) throws IOException { + return KerberosHttpSession.readResponse( + new ByteArrayInputStream(raw.getBytes(StandardCharsets.ISO_8859_1))); + } +} diff --git a/cxf/src/test/java/org/opennms/core/wsman/cxf/WinRMEncryptedMultipartTest.java b/cxf/src/test/java/org/opennms/core/wsman/cxf/WinRMEncryptedMultipartTest.java new file mode 100644 index 0000000..4f39d88 --- /dev/null +++ b/cxf/src/test/java/org/opennms/core/wsman/cxf/WinRMEncryptedMultipartTest.java @@ -0,0 +1,150 @@ +/* + * 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.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Random; + +import org.junit.Test; + +public class WinRMEncryptedMultipartTest { + + @Test + public void buildThenExtract_roundTripsTheEncryptedSection() throws IOException { + byte[] encrypted = syntheticSection(517); + byte[] body = WinRMEncryptedMultipart.build(encrypted, 517); + + assertTrue(WinRMEncryptedMultipart.isEncrypted(body)); + assertArrayEquals(encrypted, WinRMEncryptedMultipart.extractEncryptedSection(body)); + } + + @Test + public void buildThenExtract_roundTripsCiphertextEndingInCRLF() throws IOException { + // Ciphertext is opaque bytes and can legitimately end with \r\n. The parser must + // not trim those as if they were multipart framing (the OriginalContent Length + // makes the section self-delimiting). + byte[] encrypted = syntheticSection(128); + encrypted[encrypted.length - 2] = '\r'; + encrypted[encrypted.length - 1] = '\n'; + byte[] body = WinRMEncryptedMultipart.build(encrypted, 128); + assertArrayEquals(encrypted, WinRMEncryptedMultipart.extractEncryptedSection(body)); + } + + @Test + public void extract_withoutOriginalContentLength_fallsBackToBoundaryScan() throws IOException { + byte[] encrypted = syntheticSection(64); + ByteArrayOutputStream body = new ByteArrayOutputStream(); + body.write(( + "--Encrypted Boundary\r\n" + + "\tContent-Type: application/HTTP-SPNEGO-session-encrypted\r\n" + + // no OriginalContent header at all + "--Encrypted Boundary\r\n" + + "\tContent-Type: application/octet-stream\r\n").getBytes(StandardCharsets.US_ASCII)); + body.write(encrypted); + body.write("--Encrypted Boundary--\r\n".getBytes(StandardCharsets.US_ASCII)); + assertArrayEquals(encrypted, WinRMEncryptedMultipart.extractEncryptedSection(body.toByteArray())); + } + + @Test + public void isEncrypted_withLeadingWhitespace_isDetected() throws IOException { + byte[] inner = WinRMEncryptedMultipart.build(syntheticSection(16), 16); + ByteArrayOutputStream padded = new ByteArrayOutputStream(); + padded.write("\r\n \r\n".getBytes(StandardCharsets.US_ASCII)); + padded.write(inner); + assertTrue(WinRMEncryptedMultipart.isEncrypted(padded.toByteArray())); + } + + @Test + public void isEncrypted_onCleartextSoap_isFalse() { + byte[] soap = "" + .getBytes(StandardCharsets.UTF_8); + assertFalse(WinRMEncryptedMultipart.isEncrypted(soap)); + } + + @Test + public void isEncrypted_onEmptyAndShortBodies_isFalse() { + assertFalse(WinRMEncryptedMultipart.isEncrypted(new byte[0])); + assertFalse(WinRMEncryptedMultipart.isEncrypted(null)); + assertFalse(WinRMEncryptedMultipart.isEncrypted("--Enc".getBytes(StandardCharsets.US_ASCII))); + } + + @Test + public void extract_withoutBoundary_throws() { + try { + WinRMEncryptedMultipart.extractEncryptedSection("no boundary here".getBytes(StandardCharsets.US_ASCII)); + fail("expected IOException"); + } catch (IOException expected) { + // expected + } + } + + @Test + public void extract_withEmptyEncryptedSection_throws() { + String body = + "--Encrypted Boundary\r\n" + + "\tContent-Type: application/HTTP-SPNEGO-session-encrypted\r\n" + + "\tOriginalContent: type=application/soap+xml;charset=UTF-8;Length=0\r\n" + + "--Encrypted Boundary\r\n" + + "\tContent-Type: application/octet-stream\r\n" + + "--Encrypted Boundary--\r\n"; + try { + WinRMEncryptedMultipart.extractEncryptedSection(body.getBytes(StandardCharsets.US_ASCII)); + fail("expected IOException"); + } catch (IOException expected) { + // expected + } + } + + @Test + public void extract_toleratesMissingClosingBoundary() throws IOException { + // Some servers have been observed to omit the trailing boundary; the payload + // then runs to end-of-body. + byte[] encrypted = syntheticSection(64); + byte[] full = WinRMEncryptedMultipart.build(encrypted, 64); + // strip "--Encrypted Boundary--\r\n" (24 bytes) + byte[] truncated = new byte[full.length - 24]; + System.arraycopy(full, 0, truncated, 0, truncated.length); + assertArrayEquals(encrypted, WinRMEncryptedMultipart.extractEncryptedSection(truncated)); + } + + /** + * Builds a structurally valid encrypted section for a given plaintext length: + * {@code [4B LE 60][60B signature][N ciphertext]} filled with pseudo-random bytes, + * mirroring what {@code GSSContextManager.wrapWinRM} produces for AES256. + */ + private static byte[] syntheticSection(int plaintextLen) { + byte[] b = new byte[4 + 60 + plaintextLen]; + new Random(42).nextBytes(b); + b[0] = 60; + b[1] = 0; + b[2] = 0; + b[3] = 0; + // Avoid pathological collisions with the ASCII boundary marker + for (int i = 4; i + 1 < b.length; i++) { + if (b[i] == '-' && b[i + 1] == '-') { + b[i + 1] = 'x'; + } + } + return b; + } +}