diff --git a/src/main/java/org/metricshub/winrm/WinRMClient.java b/src/main/java/org/metricshub/winrm/WinRMClient.java index 2816ff9..b75bba7 100644 --- a/src/main/java/org/metricshub/winrm/WinRMClient.java +++ b/src/main/java/org/metricshub/winrm/WinRMClient.java @@ -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; /** @@ -326,6 +326,7 @@ public static final class Builder { private String namespace; private List authentication; private Path ticketCache; + private boolean allowDelegation; private boolean trustAllCertificates; private int consoleCodePage; private SSLContext sslContext; @@ -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). + *

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

+ * 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 @@ -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) { @@ -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, diff --git a/src/main/java/org/metricshub/winrm/cli/CliArguments.java b/src/main/java/org/metricshub/winrm/cli/CliArguments.java index 8f523fe..fe21f15 100644 --- a/src/main/java/org/metricshub/winrm/cli/CliArguments.java +++ b/src/main/java/org/metricshub/winrm/cli/CliArguments.java @@ -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 environment; @@ -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; @@ -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; @@ -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"); } @@ -707,6 +715,10 @@ boolean kerberosRealmInferred() { return kerberosRealmInferred; } + boolean allowDelegate() { + return allowDelegate; + } + boolean forwardStdin() { return forwardStdin; } @@ -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 environment = new LinkedHashMap<>(); diff --git a/src/main/java/org/metricshub/winrm/cli/WinRmCli.java b/src/main/java/org/metricshub/winrm/cli/WinRmCli.java index cb8f3c4..842b90c 100644 --- a/src/main/java/org/metricshub/winrm/cli/WinRmCli.java +++ b/src/main/java/org/metricshub/winrm/cli/WinRmCli.java @@ -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 authentications = arguments.authentications(); if (authentications != null && !authentications.isEmpty()) { builder.authentication( @@ -739,6 +742,7 @@ private static String help() { " --basic Use HTTP Basic authentication (use HTTPS to protect the credential)\n" + " --kerberos-kdc Set the Kerberos KDC; infer realm from its DNS suffix\n" + " --kerberos-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" + diff --git a/src/main/java/org/metricshub/winrm/light/KerberosAuthScheme.java b/src/main/java/org/metricshub/winrm/light/KerberosAuthScheme.java index 84bc8bf..aab9973 100644 --- a/src/main/java/org/metricshub/winrm/light/KerberosAuthScheme.java +++ b/src/main/java/org/metricshub/winrm/light/KerberosAuthScheme.java @@ -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; @@ -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; @@ -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/} 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). *

* 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. @@ -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 @@ -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) () -> { - 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) () -> { + // 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); } @@ -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()); diff --git a/src/main/java/org/metricshub/winrm/light/LightWinRMService.java b/src/main/java/org/metricshub/winrm/light/LightWinRMService.java index 400d5ab..f1286ea 100644 --- a/src/main/java/org/metricshub/winrm/light/LightWinRMService.java +++ b/src/main/java/org/metricshub/winrm/light/LightWinRMService.java @@ -192,6 +192,56 @@ public static LightWinRMService createInstance( final int consoleCodePage, final int connectRetries, final long retryDelay + ) throws WinRMException { + return createInstance( + winRMEndpoint, + timeout, + ticketCache, + authentications, + false, + sslContext, + trustAllCertificates, + consoleCodePage, + connectRetries, + retryDelay + ); + } + + /** + * Create a light WinRM executor that may delegate the caller's Kerberos credentials to the host, + * so remote commands can authenticate onward as the caller (the second hop). + * + * @param winRMEndpoint endpoint with credentials (mandatory) + * @param timeout timeout in milliseconds (must be > 0) + * @param ticketCache Kerberos ticket cache path (used by the Kerberos scheme; {@code null} logs + * in with the password) + * @param authentications requested authentication schemes, tried in order (NTLM, Kerberos, and/or Basic); + * {@code null}/empty means NTLM only + * @param allowDelegation whether Kerberos forwards the caller's ticket-granting ticket to the + * host (which must then be forwardable); requires Kerberos among {@code authentications} + * @param sslContext the {@link SSLContext} providing the HTTPS socket factory (hostname + * verification stays on); {@code null} uses the default configuration + * @param trustAllCertificates when {@code true} (and no {@code sslContext} is given), trust every + * server certificate and skip hostname verification — insecure, testing only + * @param consoleCodePage the console code page of the command shell; 0 keeps the default 65001, + * which makes command output UTF-8 whatever the remote locale + * @param connectRetries how many times one round trip may re-attempt to connect and authenticate + * (must be >= 0); 0 keeps the historical fail-fast behavior + * @param retryDelay the pause in milliseconds before each retry (must be >= 0) + * @return a new {@code LightWinRMService} + * @throws WinRMException on invalid arguments or an unsupported authentication request + */ + public static LightWinRMService createInstance( + final WinRMEndpoint winRMEndpoint, + final long timeout, + final java.nio.file.Path ticketCache, + final List authentications, + final boolean allowDelegation, + final SSLContext sslContext, + final boolean trustAllCertificates, + final int consoleCodePage, + final int connectRetries, + final long retryDelay ) throws WinRMException { Utils.checkNonNull(winRMEndpoint, "winRMEndpoint"); Utils.checkArgumentNotZeroOrNegative(timeout, "timeout"); @@ -222,7 +272,13 @@ public static LightWinRMService createInstance( verifyHostname = LightTls.verifyHostname(); } - final AuthScheme authScheme = resolveAuthScheme(winRMEndpoint, authentications, https, ticketCache); + final AuthScheme authScheme = resolveAuthScheme( + winRMEndpoint, + authentications, + https, + ticketCache, + allowDelegation + ); // Use the endpoint's own validated host/port rather than re-parsing the URL: URI.getHost()/getPort() // return null/-1 for names URI cannot classify (underscores, Unicode) that WinRMEndpoint accepts, @@ -250,17 +306,26 @@ public static LightWinRMService createInstance( * for EVERY list that contains it over HTTP — not just a Kerberos-only request: silently dropping * it from a fallback list (e.g. {@code [KERBEROS, NTLM]}) would downgrade the client to another * scheme without the caller's consent, contradicting the builder's "Kerberos requested over HTTP - * is rejected" contract. + * is rejected" contract. Delegation without Kerberos is rejected just as firmly: ignoring it would + * leave the caller believing the second hop is enabled. */ private static AuthScheme resolveAuthScheme( final WinRMEndpoint winRMEndpoint, final List authentications, final boolean https, - final java.nio.file.Path ticketCache + final java.nio.file.Path ticketCache, + final boolean allowDelegation ) throws WinRMException { final List requested = authentications == null || authentications.isEmpty() ? List.of(AuthenticationEnum.NTLM) : authentications; + if (allowDelegation && !requested.contains(AuthenticationEnum.KERBEROS)) { + throw new WinRMException( + "Credential delegation requires Kerberos authentication (requested: " + + requested + + "): NTLM and Basic credentials cannot be delegated." + ); + } final String domain = winRMEndpoint.getDomain(); final String username = winRMEndpoint.getUsername(); @@ -275,7 +340,9 @@ private static AuthScheme resolveAuthScheme( } else if (auth == AuthenticationEnum.KERBEROS) { if (https) { // The SPN is HTTP/, so the caller must connect by the FQDN the KDC knows. - schemes.add(new KerberosAuthScheme(winRMEndpoint.getHostname(), username, password, ticketCache)); + schemes.add( + new KerberosAuthScheme(winRMEndpoint.getHostname(), username, password, ticketCache, allowDelegation) + ); } else { // Fail closed: Kerberos cannot be protected over plain HTTP, so reject it for any // list rather than silently downgrading to the remaining schemes. diff --git a/src/site/markdown/authentication.md b/src/site/markdown/authentication.md index 7bb04ac..481c289 100644 --- a/src/site/markdown/authentication.md +++ b/src/site/markdown/authentication.md @@ -93,6 +93,52 @@ java -Djava.security.krb5.realm=EXAMPLE.COM \ The optional `ticketCache(Path)` builder option points at a Kerberos ticket cache to use for the connection; without it, Kerberos logs in with the user name and password. +### Credential delegation + +A remote command runs under a network logon that cannot use your credentials to reach a *further* +host — a UNC path on a file server, an Active Directory query, a database — so such access fails +with *access denied*: the second hop (see +[Preparing the Windows Host](preparing-the-host.html#the-second-hop)). `allowDelegation()` lifts +that limit, like `winrs -allowdelegate`: Kerberos forwards your ticket-granting ticket (TGT) to the +host, and the commands of the connection authenticate onward as you. + +```java +try (WinRMClient client = WinRMClient.builder("server.internal.example.com") + .https() + .authentication(AuthScheme.KERBEROS) + .allowDelegation() // Kerberos only + .credentials("DOMAIN\\user", password) + .build()) { + client.command("dir \\\\fileserver\\share").execute(); +} +``` + +The TGT must be **forwardable**, and the JDK asks the KDC for a forwardable one only when told to: +set `forwardable = true` in the `[libdefaults]` section of the JDK's `krb5.conf` (see +[Kerberos configuration](#kerberos-configuration)) — or, with `ticketCache(Path)`, get the cached +ticket with `kinit -f`. The `java.security.krb5.realm` and `java.security.krb5.kdc` properties +cannot say it, but the file is read in addition to them, so next to them a file with only these +lines is enough: + +```ini +[libdefaults] + forwardable = true +``` + +Otherwise the first operation fails with a message saying so, instead of the command failing later +on the second hop. An account that Active Directory never lets be delegated (*Account is sensitive +and cannot be delegated*, or a member of *Protected Users*) fails the same way. + +Unlike `winrs`, which delegates only to hosts that Active Directory trusts for delegation, the +client forwards the ticket to any host it is enabled for (verified with a host that is not trusted +for delegation). The host then holds a ticket that lets it act as you on the network until the +ticket expires: enable delegation only for hosts you trust. + +`build()` rejects `allowDelegation()` when Kerberos is not among the schemes: NTLM and Basic +credentials cannot be delegated. In an ordered fallback such as `(KERBEROS, NTLM)`, a connection +that falls back to NTLM is not delegated. CredSSP, the other way `winrs` delegates, is not +supported. + ## Basic HTTP Basic sends the credential in the `Authorization` header of **every** request — there is no @@ -173,6 +219,10 @@ upper-casing the rest. This follows a common Active Directory naming convention guaranteed by Kerberos — pass `--kerberos-realm` when the realm does not match the KDC's DNS suffix, or when the KDC is not a fully qualified DNS name. +`--allow-delegate` turns on [credential delegation](#credential-delegation) for the invocation. It +requires `--kerberos`, and the ticket must still be forwardable: `--kerberos-kdc` sets the KDC and +the realm, not `forwardable = true`. + ## See also * [Preparing the Windows Host](preparing-the-host.html) — the privileges the account needs, and why diff --git a/src/site/markdown/cli.md b/src/site/markdown/cli.md index 6af11e7..22e65a0 100644 --- a/src/site/markdown/cli.md +++ b/src/site/markdown/cli.md @@ -60,6 +60,7 @@ own options, in any order. | `--basic` | Authenticate with HTTP Basic. Use with `--https` so the credential is not sent in the clear. | | `--kerberos-kdc ` | Set the Kerberos KDC for this invocation; the realm is inferred from its DNS suffix (see below). | | `--kerberos-realm ` | Override the realm inferred from `--kerberos-kdc`. | +| `--allow-delegate` | Let the remote command use your Kerberos credentials to reach a further host (a UNC path, another server), like `winrs -allowdelegate`. Requires `--kerberos` and a forwardable ticket (see [Kerberos](#kerberos)). | | `--help` | Print the usage summary. | | `--version` | Print the build version. | @@ -104,6 +105,13 @@ common Active Directory DNS naming convention; it is not guaranteed by Kerberos, fully qualified DNS name. Both options are valid only with `--kerberos`, and `--kerberos-realm` requires `--kerberos-kdc`. +`--allow-delegate` forwards your Kerberos ticket to the host, so the remote command can reach a +further host as you — a UNC path, another server — where it would otherwise get *access denied*. +The ticket must be forwardable, which the JDK asks for only when a `krb5.conf` says +`forwardable = true` in its `[libdefaults]` section (`--kerberos-kdc` does not): otherwise the CLI +exits with an authentication error (77) saying so. Only delegate to hosts you trust; see +[Credential delegation](authentication.html#credential-delegation) for the details. + ## Basic `--basic` authenticates with HTTP Basic, sending the credential in the `Authorization` header of @@ -385,6 +393,16 @@ java -jar ${project.artifactId}-${project.version}-standalone.jar \ command whoami ``` +List a share on a third machine from the remote host, with Kerberos credential delegation (the +`krb5.conf` says `forwardable = true`): + +```bash +java -Djava.security.krb5.conf=krb5.conf -jar ${project.artifactId}-${project.version}-standalone.jar \ + -h server.internal.example.net -u 'DOMAIN\user' -pf password.txt \ + --https --kerberos --allow-delegate \ + exec dir '\\fileserver\share' +``` + Follow a long-running command live and capture the streamed WQL rows with `jq`: ```bash diff --git a/src/site/markdown/files.md b/src/site/markdown/files.md index f7f0659..4113fea 100644 --- a/src/site/markdown/files.md +++ b/src/site/markdown/files.md @@ -267,9 +267,9 @@ DMTF strings. characters (see [the requirements](#errors-and-requirements)); the entries a listing reports can be of any length. * **UNC paths** (`\\server\share\...`) are a *second hop*: the host must authenticate to the file - server with your credentials, which NTLM does not allow. They need Kerberos credential - delegation ([#141](https://github.com/MetricsHub/winrm-java/issues/141)), which this client does - not provide yet: until then, access to a UNC path typically fails with access denied. + server with your credentials, which NTLM does not allow. They need Kerberos + [credential delegation](authentication.html#credential-delegation) (`allowDelegation()`): + without it, access to a UNC path typically fails with access denied. * `lastAccessed()` is only as good as the host keeps it: many Windows versions disable or delay last-access updates. diff --git a/src/site/markdown/preparing-the-host.md b/src/site/markdown/preparing-the-host.md index 56ac03d..8c5b65b 100644 --- a/src/site/markdown/preparing-the-host.md +++ b/src/site/markdown/preparing-the-host.md @@ -361,10 +361,12 @@ mapped drive — fails with access denied, even though the same command works wh the host. The same goes for a UNC path given to [`client.file(...)`](files.html), which runs as a remote command too. -Windows solves this with CredSSP or Kerberos constrained delegation. This client **does not support -CredSSP** ([Authentication](authentication.html)), so the workaround is to avoid the second hop: -copy what you need onto the host first ([File Transfers](file-transfers.html)), or have the command -use credentials it supplies itself. +Windows solves this with CredSSP or Kerberos delegation. This client supports **Kerberos +delegation**: `allowDelegation()` on the builder, or `--allow-delegate` on the command line (see +[Credential delegation](authentication.html#credential-delegation)). It **does not support +CredSSP**, so with NTLM the workaround is to avoid the second hop: copy what you need onto the +host first ([File Transfers](file-transfers.html)), or have the command use credentials it supplies +itself. ## Host quotas worth knowing about diff --git a/src/test/java/org/metricshub/winrm/WinRMClientBuilderTest.java b/src/test/java/org/metricshub/winrm/WinRMClientBuilderTest.java index 8654891..31e8e93 100644 --- a/src/test/java/org/metricshub/winrm/WinRMClientBuilderTest.java +++ b/src/test/java/org/metricshub/winrm/WinRMClientBuilderTest.java @@ -121,6 +121,30 @@ void kerberosOverHttpIsRejectedAtBuildTime() { assertTrue(e.getMessage().contains("HTTPS"), e.getMessage()); } + @Test + void delegationRequiresKerberos() { + // NTLM (the default) and Basic credentials cannot be delegated: refused, never silently ignored + for (final WinRMClient.Builder builder : new WinRMClient.Builder[] { + validBuilder().https().allowDelegation(), + validBuilder().https().authentication(AuthScheme.NTLM, AuthScheme.BASIC).allowDelegation() }) { + final WinRMClientException e = assertThrows(WinRMClientException.class, builder::build); + assertTrue(e.getMessage().contains("delegation requires Kerberos"), e.getMessage()); + } + // Kerberos alone, or in an ordered fallback: accepted (build() does not connect) + try ( + WinRMClient client = validBuilder().https().authentication(AuthScheme.KERBEROS).allowDelegation().build()) { + assertEquals("host", client.hostname()); + } + try ( + WinRMClient client = validBuilder() + .https() + .authentication(AuthScheme.KERBEROS, AuthScheme.NTLM) + .allowDelegation() + .build()) { + assertEquals("host", client.hostname()); + } + } + @Test void basicIsAcceptedOverHttpAndHttps() { // Unlike Kerberos, Basic is a plain-HTTP scheme (the credential rides the Authorization diff --git a/src/test/java/org/metricshub/winrm/WinRMLiveTest.java b/src/test/java/org/metricshub/winrm/WinRMLiveTest.java index 4ee4a67..d4da112 100644 --- a/src/test/java/org/metricshub/winrm/WinRMLiveTest.java +++ b/src/test/java/org/metricshub/winrm/WinRMLiveTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.InputStream; @@ -124,6 +125,10 @@ void commandSucceeds() throws Exception { } private static WinRMClient client() { + return builder().build(); + } + + private static WinRMClient.Builder builder() { final WinRMClient.Builder builder = WinRMClient.builder(host).credentials(username, password) .timeout(Duration.ofSeconds(60)); if (protocol == WinRMHttpProtocolEnum.HTTPS) { @@ -132,7 +137,25 @@ private static WinRMClient client() { if (port != null) { builder.port(port); } - return builder.build(); + return builder; + } + + /** + * Kerberos credential delegation, the second hop: with {@code -Dwinrm.live.delegation.unc} set + * to a UNC path the account can read on a third machine (over HTTPS, with a {@code krb5.conf} + * saying {@code forwardable = true}), the host reaches it with delegation and is denied without. + */ + @Test + @EnabledIfSystemProperty(named = "winrm.live.delegation.unc", matches = ".+") + void kerberosDelegationReachesTheSecondHop() { + final String unc = System.getProperty("winrm.live.delegation.unc"); + try (WinRMClient client = builder().authentication(AuthScheme.KERBEROS).allowDelegation().build()) { + final CommandResult result = client.command("dir " + unc).execute(); + assertEquals(0, result.exitCode(), result::stderr); + } + try (WinRMClient client = builder().authentication(AuthScheme.KERBEROS).build()) { + assertNotEquals(0, client.command("dir " + unc).execute().exitCode()); + } } @Test diff --git a/src/test/java/org/metricshub/winrm/cli/CliArgumentsTest.java b/src/test/java/org/metricshub/winrm/cli/CliArgumentsTest.java index 4c23d95..baaa0cc 100644 --- a/src/test/java/org/metricshub/winrm/cli/CliArgumentsTest.java +++ b/src/test/java/org/metricshub/winrm/cli/CliArgumentsTest.java @@ -46,6 +46,7 @@ void parsesDefaultsAndClearsDirectPassword() throws Exception { assertEquals(5985, parsed.port()); assertEquals(CliArguments.DEFAULT_TIMEOUT, parsed.timeout()); assertEquals(List.of(AuthenticationEnum.NTLM), parsed.authentications()); + assertFalse(parsed.allowDelegate()); final char[] password = parsed.password(); parsed.close(); @@ -66,6 +67,7 @@ void parsesHttpsKerberosAndExplicitValues() throws Exception { "--kerberos", "--kerberos-kdc=kdc.example.net", "--kerberos-realm=CORP.EXAMPLE.NET", + "--allow-delegate", "--port=1234", "--timeout=9876", "wql", @@ -83,6 +85,7 @@ void parsesHttpsKerberosAndExplicitValues() throws Exception { assertEquals("kdc.example.net", parsed.kerberosKdc()); assertEquals("CORP.EXAMPLE.NET", parsed.kerberosRealm()); assertFalse(parsed.kerberosRealmInferred()); + assertTrue(parsed.allowDelegate()); assertEquals("SELECT Name FROM Win32_Service", parsed.input()); } } @@ -372,6 +375,10 @@ void rejectsInvalidArguments() { "--kerberos-kdc and --kerberos-realm require --kerberos", concat(base, "--kerberos-kdc", "kdc.example.net", "command", "whoami") }, + { + "--allow-delegate requires --kerberos", + concat(base, "--https", "--allow-delegate", "command", "whoami") + }, { "--kerberos-realm requires --kerberos-kdc", concat( diff --git a/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java b/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java index c2d5df2..87c5906 100644 --- a/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java +++ b/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java @@ -80,6 +80,7 @@ void helpAndVersionDoNotConnect() throws Exception { assertTrue(help.stdout.contains("--env ")); assertTrue(help.stdout.contains("--kerberos-kdc")); assertTrue(help.stdout.contains("--kerberos-realm")); + assertTrue(help.stdout.contains("--allow-delegate")); assertTrue(help.stdout.contains("[options] ls ")); assertTrue(help.stdout.contains("[options] get []")); assertTrue(help.stdout.contains("--modified-after ")); diff --git a/src/test/java/org/metricshub/winrm/light/KerberosAuthSchemeTest.java b/src/test/java/org/metricshub/winrm/light/KerberosAuthSchemeTest.java new file mode 100644 index 0000000..e032bbc --- /dev/null +++ b/src/test/java/org/metricshub/winrm/light/KerberosAuthSchemeTest.java @@ -0,0 +1,67 @@ +package org.metricshub.winrm.light; + +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * WinRM Java Client + * ჻჻჻჻჻჻ + * Copyright (C) 2023 - 2026 MetricsHub + * ჻჻჻჻჻჻ + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ + */ + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import javax.security.auth.login.CredentialException; +import org.ietf.jgss.GSSContext; +import org.ietf.jgss.GSSManager; +import org.ietf.jgss.GSSName; +import org.junit.jupiter.api.Test; + +/** + * Credential delegation of {@link KerberosAuthScheme}, without a KDC: before its first token, a + * GSS context reports the flags it was asked for, and the delegation check only reads the flag the + * initialized context reports (a live test covers the real exchange). + */ +class KerberosAuthSchemeTest { + + @Test + void requestsDelegationOnlyWhenAllowed() throws Exception { + // A realm-qualified name: a host-based one would need a Kerberos configuration to resolve + final GSSName name = GSSManager + .getInstance() + .createName("HTTP/server.example.net@EXAMPLE.NET", GSSName.NT_USER_NAME); + final GSSContext plain = KerberosAuthScheme.createContext(name, false); + final GSSContext delegating = KerberosAuthScheme.createContext(name, true); + try { + assertFalse(plain.getCredDelegState()); + assertTrue(delegating.getCredDelegState()); + assertTrue(plain.getMutualAuthState()); + assertTrue(delegating.getMutualAuthState()); + + // GSS drops the request silently when the TGT is not forwardable: the check must not + KerberosAuthScheme.checkDelegation(plain, false); + KerberosAuthScheme.checkDelegation(delegating, true); + final CredentialException e = assertThrows( + CredentialException.class, + () -> KerberosAuthScheme.checkDelegation(plain, true) + ); + assertTrue(e.getMessage().contains("forwardable = true"), e.getMessage()); + } finally { + plain.dispose(); + delegating.dispose(); + } + } +}