Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 30 additions & 3 deletions src/main/java/org/metricshub/winrm/WinRMClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@
import org.metricshub.winrm.exceptions.WinRMException;
import org.metricshub.winrm.exceptions.WinRMTimeoutException;
import org.metricshub.winrm.exceptions.WindowsRemoteException;
import org.metricshub.winrm.light.LightWinRMService;
import org.metricshub.winrm.service.WinRMEndpoint;
import org.metricshub.winrm.service.WinRMExecutorFactory;
import org.metricshub.winrm.service.client.auth.AuthenticationEnum;

/**
Expand Down Expand Up @@ -326,6 +326,7 @@ public static final class Builder {
private String namespace;
private List<AuthScheme> authentication;
private Path ticketCache;
private boolean allowDelegation;
private boolean trustAllCertificates;
private int consoleCodePage;
private SSLContext sslContext;
Expand Down Expand Up @@ -452,6 +453,31 @@ public Builder ticketCache(final Path ticketCache) {
return this;
}

/**
* Let remote commands use the caller's credentials to reach a further host — a UNC path on a
* file server, a database, another server — like {@code winrs -allowdelegate}. Without it,
* the remote logon cannot authenticate onward, and such access fails with "access denied"
* (the second hop).
* <p>
* Kerberos only: the caller's ticket-granting ticket is forwarded to the host, so it must be
* forwardable — {@code forwardable = true} in the {@code [libdefaults]} section of
* {@code krb5.conf}, or a forwardable ticket in the {@link #ticketCache(Path) ticket cache};
* otherwise the first operation fails with a message saying so. {@link #build()} rejects
* this option unless {@link AuthScheme#KERBEROS} is among the
* {@link #authentication(AuthScheme...) schemes}; in an ordered fallback, a connection that
* falls back to another scheme is not delegated.
* <p>
* Unlike {@code winrs}, the ticket is forwarded whether or not Active Directory trusts the
* host for delegation: only delegate to hosts you trust, since the host can act as the
* caller on the network until the ticket expires.
*
* @return this builder
*/
public Builder allowDelegation() {
this.allowDelegation = true;
return this;
}

/**
* Trust every server certificate and skip hostname verification over HTTPS — for
* self-signed test hosts. Insecure: do not use in production. This per-client setting
Expand Down Expand Up @@ -567,7 +593,7 @@ public Builder consoleCodePage(final int consoleCodePage) {
*
* @return the client, to use with try-with-resources
* @throws org.metricshub.winrm.exceptions.WinRMClientException when the configuration is
* rejected (e.g. Kerberos requested over HTTP)
* rejected (e.g. Kerberos requested over HTTP, or delegation without Kerberos)
*/
public WinRMClient build() {
if (username == null || password == null) {
Expand Down Expand Up @@ -598,11 +624,12 @@ public WinRMClient build() {
}

try {
final WindowsRemoteExecutor executor = WinRMExecutorFactory.createInstance(
final WindowsRemoteExecutor executor = LightWinRMService.createInstance(
endpoint,
toMillis(timeout),
ticketCache,
authentications,
allowDelegation,
sslContext,
trustAllCertificates,
consoleCodePage,
Expand Down
13 changes: 13 additions & 0 deletions src/main/java/org/metricshub/winrm/cli/CliArguments.java
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ enum Operation {
private final String kerberosKdc;
private final String kerberosRealm;
private final boolean kerberosRealmInferred;
private final boolean allowDelegate;
private final boolean forwardStdin;
private final String directory;
private final Map<String, String> environment;
Expand Down Expand Up @@ -113,6 +114,7 @@ private CliArguments(final Builder builder) {
kerberosKdc = builder.kerberosKdc;
kerberosRealm = builder.kerberosRealm;
kerberosRealmInferred = builder.kerberosRealmInferred;
allowDelegate = builder.allowDelegate;
forwardStdin = builder.forwardStdin;
directory = builder.directory;
environment = builder.environment;
Expand Down Expand Up @@ -219,6 +221,9 @@ private static int parseOption(final Builder builder, final String[] arguments,
case "--kerberos-realm":
builder.kerberosRealm = optionValue(arguments, index, option);
return nextIndex(argument, index);
case "--allow-delegate":
builder.allowDelegate = true;
return index + 1;
case "--https":
builder.https = true;
return index + 1;
Expand Down Expand Up @@ -440,6 +445,9 @@ private static void validate(final Builder builder) throws CliUsageException {
if (!builder.kerberos && (builder.kerberosKdc != null || builder.kerberosRealm != null)) {
throw new CliUsageException("--kerberos-kdc and --kerberos-realm require --kerberos");
}
if (builder.allowDelegate && !builder.kerberos) {
throw new CliUsageException("--allow-delegate requires --kerberos");
}
if (builder.kerberosRealm != null && builder.kerberosKdc == null) {
throw new CliUsageException("--kerberos-realm requires --kerberos-kdc");
}
Expand Down Expand Up @@ -707,6 +715,10 @@ boolean kerberosRealmInferred() {
return kerberosRealmInferred;
}

boolean allowDelegate() {
return allowDelegate;
}

boolean forwardStdin() {
return forwardStdin;
}
Expand Down Expand Up @@ -799,6 +811,7 @@ private static final class Builder {
private String kerberosKdc;
private String kerberosRealm;
private boolean kerberosRealmInferred;
private boolean allowDelegate;
private boolean forwardStdin;
private String directory;
private final Map<String, String> environment = new LinkedHashMap<>();
Expand Down
4 changes: 4 additions & 0 deletions src/main/java/org/metricshub/winrm/cli/WinRmCli.java
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,9 @@ static FluentRemoteOperations connect(final CliArguments arguments, final int co
if (consoleCodePage > 0) {
builder.consoleCodePage(consoleCodePage);
}
if (arguments.allowDelegate()) {
builder.allowDelegation();
}
final List<AuthenticationEnum> authentications = arguments.authentications();
if (authentications != null && !authentications.isEmpty()) {
builder.authentication(
Expand Down Expand Up @@ -739,6 +742,7 @@ private static String help() {
" --basic Use HTTP Basic authentication (use HTTPS to protect the credential)\n" +
" --kerberos-kdc <host> Set the Kerberos KDC; infer realm from its DNS suffix\n" +
" --kerberos-realm <realm> Override the realm inferred from --kerberos-kdc\n" +
" --allow-delegate Let the remote side use your Kerberos credentials (second hop)\n" +
" --help Show this help\n" +
" --version Show the project version\n" +
"\n" +
Expand Down
103 changes: 72 additions & 31 deletions src/main/java/org/metricshub/winrm/light/KerberosAuthScheme.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
*/

import java.nio.file.Path;
import java.security.PrivilegedActionException;
import java.security.PrivilegedExceptionAction;
import java.util.Base64;
import java.util.HashMap;
Expand All @@ -34,6 +35,7 @@
import javax.security.auth.callback.PasswordCallback;
import javax.security.auth.login.AppConfigurationEntry;
import javax.security.auth.login.Configuration;
import javax.security.auth.login.CredentialException;
import javax.security.auth.login.LoginContext;
import org.ietf.jgss.GSSContext;
import org.ietf.jgss.GSSException;
Expand All @@ -45,7 +47,8 @@
* Kerberos (SPNEGO) authentication scheme using the JDK's built-in GSS-API — no Apache/CXF. It
* obtains a TGT via JAAS ({@code Krb5LoginModule}) from a username+password (or a ticket cache),
* then a service ticket for {@code HTTP/<host>} and emits the AP-REQ under the {@code Negotiate}
* header.
* header. With delegation, the AP-REQ also carries a forwarded copy of the TGT, which lets the
* host authenticate onward as the caller (the second hop).
* <p>
* HTTPS only. Like the CXF backend (which never implemented Kerberos message encryption over
* HTTP), the SOAP travels plaintext inside TLS, so {@link #wrap}/{@link #unwrap} are pass-throughs.
Expand All @@ -65,6 +68,7 @@ final class KerberosAuthScheme extends PlaintextSoapAuthScheme {
// copy of the secret and may zero it after closing the client.
private final char[] password;
private final Path ticketCache;
private final boolean delegate;

// The SPNEGO context, claimed atomically on disposal: reset() can run from two threads at once
// (close() disposing the state, and the last operation releasing the connection) with no shared
Expand All @@ -80,54 +84,63 @@ final class KerberosAuthScheme extends PlaintextSoapAuthScheme {
* @param password the account password (unused when {@code ticketCache} is set)
* @param ticketCache a Kerberos credential cache to reuse, or {@code null} to log in with
* the password
* @param delegate whether to forward the TGT to the host (credential delegation)
*/
KerberosAuthScheme(
final String servicePrincipalHost,
final String username,
final char[] password,
final Path ticketCache
final Path ticketCache,
final boolean delegate
) {
this.servicePrincipalHost = servicePrincipalHost;
this.username = username;
this.password = password;
this.ticketCache = ticketCache;
this.delegate = delegate;
}

@Override
public String authenticate(final HttpTransport transport) throws Exception {
final Subject subject = login();
final byte[] apReq = Subject.doAs(
subject,
(PrivilegedExceptionAction<byte[]>) () -> {
final GSSManager manager = GSSManager.getInstance();
final Oid spnego = new Oid(SPNEGO_OID);
// NT_HOSTBASED_SERVICE "HTTP@host" maps to the SPN HTTP/host.
final GSSName serverName = manager.createName("HTTP@" + servicePrincipalHost, GSSName.NT_HOSTBASED_SERVICE);
final GSSContext newContext = manager.createContext(serverName, spnego, null, GSSContext.DEFAULT_LIFETIME);
try {
newContext.requestMutualAuth(true);
newContext.requestCredDeleg(false);
// The AP-REQ is complete after the first call; the KDC issued the service ticket using the
// Subject's TGT. The server validates it on the first real request (and, over HTTPS, TLS
// already authenticates the server, so we do not need to process a mutual-auth reply token).
final byte[] token = newContext.initSecContext(new byte[0], 0, 0);
// Publish only on success: a failed setup (below) must not leave a half-initialized
// context in `context`, and the success path is disposed by the normal reset()/close().
context.set(newContext);
return token;
} catch (final Exception e) {
// Dispose the context on any failure before it is published to `context`: otherwise no
// reset()/close() could ever reach it, and each failed attempt (e.g. an unavailable
// service principal or KDC) would leak implementation/native GSS resources.
final byte[] apReq;
try {
apReq = Subject.doAs(
subject,
(PrivilegedExceptionAction<byte[]>) () -> {
// NT_HOSTBASED_SERVICE "HTTP@host" maps to the SPN HTTP/host.
final GSSName serverName = GSSManager
.getInstance()
.createName("HTTP@" + servicePrincipalHost, GSSName.NT_HOSTBASED_SERVICE);
final GSSContext newContext = createContext(serverName, delegate);
try {
newContext.dispose();
} catch (final GSSException ignored) {
// disposing an already-failed context is best-effort
// The AP-REQ is complete after the first call; the KDC issued the service ticket using the
// Subject's TGT. The server validates it on the first real request (and, over HTTPS, TLS
// already authenticates the server, so we do not need to process a mutual-auth reply token).
final byte[] token = newContext.initSecContext(new byte[0], 0, 0);
checkDelegation(newContext, delegate);
// Publish only on success: a failed setup (below) must not leave a half-initialized
// context in `context`, and the success path is disposed by the normal reset()/close().
context.set(newContext);
return token;
} catch (final Exception e) {
// Dispose the context on any failure before it is published to `context`: otherwise no
// reset()/close() could ever reach it, and each failed attempt (e.g. an unavailable
// service principal or KDC) would leak implementation/native GSS resources.
try {
newContext.dispose();
} catch (final GSSException ignored) {
// disposing an already-failed context is best-effort
}
throw e;
}
throw e;
}
}
);
);
} catch (final PrivilegedActionException e) {
// doAs wraps a checked failure in an exception with no message of its own: rethrow the
// failure itself, or "Server not found in Kerberos database" and the like would be lost.
throw e.getException();
}
authenticated = true;
return "Negotiate " + Base64.getEncoder().encodeToString(apReq);
}
Expand All @@ -151,6 +164,34 @@ public void reset() {
authenticated = false;
}

/**
* The SPNEGO context for the given service principal, requesting mutual authentication, and
* credential delegation iff {@code delegate}.
*/
static GSSContext createContext(final GSSName serverName, final boolean delegate) throws GSSException {
final GSSContext newContext = GSSManager
.getInstance()
.createContext(serverName, new Oid(SPNEGO_OID), null, GSSContext.DEFAULT_LIFETIME);
newContext.requestMutualAuth(true);
newContext.requestCredDeleg(delegate);
return newContext;
}

/**
* Fail when delegation was requested but the initialized context does not delegate: GSS drops
* the request SILENTLY when the TGT is not forwardable, and the remote command would then fail
* much later with a baffling "access denied" on the second hop.
*/
static void checkDelegation(final GSSContext initializedContext, final boolean delegate) throws CredentialException {
if (delegate && !initializedContext.getCredDelegState()) {
throw new CredentialException(
"Kerberos credential delegation needs a forwardable ticket-granting ticket, and the KDC issued " +
"one that is not: set forwardable = true in the [libdefaults] section of krb5.conf (with a " +
"ticket cache: kinit -f). An account that is sensitive and cannot be delegated never gets one."
);
}
}

/** Obtain a Kerberos {@link Subject} (holding the TGT) via a programmatic JAAS login. */
private Subject login() throws Exception {
final LoginContext loginContext = new LoginContext("", null, callbackHandler(), krb5Configuration());
Expand Down
Loading
Loading