Skip to content
Open
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,21 @@ All notable changes to this project are documented in this file.

## [Unreleased] — 2.0.0

### Added — HTTP Basic authentication

The client now supports **HTTP Basic** as an authentication scheme, in addition to NTLM and
Kerberos:

* `WinRMClient.Builder.authentication(AuthScheme.BASIC)` and the CLI's `--basic` option select it.
* Basic is stateless: the credential rides the `Authorization` header of **every** request, and
there is no message protection — the payload travels as plaintext SOAP. The scheme is accepted
over both transports, but it must be used over **HTTPS** in practice, where TLS protects the
credential and the payload (over plain HTTP both travel in the clear).
* A domain-qualified user name (`DOMAIN\user`) keeps its domain prefix on the wire; the server
must have the `Basic` setting enabled on the WinRM service (`winrm/config/service/auth`).
* `BASIC` joins `AuthenticationEnum` (legacy API) and participates in the ordered-fallback list
like the other schemes.

### ⚠️ Breaking — SMB file copy replaced by a transfer through the WinRM channel

Files passed to `WinRMCommandExecutor.execute(...)` in `localFileToCopyList` are no longer copied
Expand Down
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
See **[Project Documentation](https://metricshub.org/winrm-java)** and the [Javadoc](https://metricshub.org/winrm-java/apidocs) for more information on how to use this library in your code.

The Windows Remote Management (WinRM) Java Client is a library that enables to:
* Connect to a remote Windows server using one of the two authentication types (NTLM, KERBEROS)
* Connect to a remote Windows server using one of three authentication types (NTLM, Kerberos, or Basic)
* Execute WMI Query Language (WQL) queries which uses HTTP/HTTPS protocols.

> ## ⚠️ Upgrading from 1.x
Expand Down Expand Up @@ -54,9 +54,11 @@ WinRM must be enabled on the targeted Windows host, and the account must have su
Non-administrator accounts need an explicit grant on the WinRM listener (`RootSDDL`), plus — only
if they run WQL queries — WMI grants (`WinRMRemoteWMIUsers__` and namespace rights). An account
that only runs commands never reaches WMI and needs nothing there.
* `AllowUnencrypted`, `Basic`, `CredSSP` and `TrustedHosts` do **not** need to be changed: over
* With NTLM, `AllowUnencrypted`, `CredSSP` and `TrustedHosts` do **not** need to be changed: over
plain HTTP the payload is protected by NTLM message encryption, and `TrustedHosts` is a
Windows-client setting that a Java client never reads.
Windows-client setting that a Java client never reads. The HTTP Basic scheme is the exception:
enable the service's `Basic` setting, and — over plain HTTP only — also `AllowUnencrypted=true`
([Preparing the Windows Host](https://metricshub.org/winrm-java/preparing-the-host.html)).

The full prerequisites — enabling WinRM over HTTP or HTTPS, Group Policy, firewall rules, the
privileges each operation requires, configuring a non-administrator account, host quotas, and a
Expand Down Expand Up @@ -100,7 +102,8 @@ try (WinRMClient client = WinRMClient.builder("server01.acme.com")
```

Connection-scoped options on the builder: `https()`, `port(int)`,
`authentication(AuthScheme.KERBEROS, AuthScheme.NTLM)` (ordered fallback; NTLM is the default),
`authentication(AuthScheme.KERBEROS, AuthScheme.NTLM)` (ordered fallback; NTLM is the default —
Kerberos in the list requires `https()`),
`ticketCache(Path)`, `namespace(String)`, `trustAllCertificates()` (per-client alternative to the
`org.metricshub.winrm.tls.insecure` system property; insecure, testing only), and
`sslContext(SSLContext)` for a dedicated trust store.
Expand Down Expand Up @@ -201,7 +204,8 @@ The pre-existing static helpers (`WinRMWqlExecutor.executeWql(...)`,
The client has **zero runtime dependencies** (no Apache CXF / JAX-WS / JAXB, no BouncyCastle, no
SLF4J — problems are reported through exceptions only) and is immune by construction to JAXP
`ServiceLoader` conflicts (it uses the JDK-default XML factories). It supports **NTLM over HTTP
(with message encryption) and HTTPS** and **Kerberos (SPNEGO) over HTTPS**.
(with message encryption) and HTTPS**, **Kerberos (SPNEGO) over HTTPS**, and **HTTP Basic over
HTTPS**.

Files passed to `upload(...)` (or `localFileToCopyList` in the legacy API) are copied to the
remote host **through the WinRM channel itself** (chunked base64 through the command shell,
Expand Down
5 changes: 4 additions & 1 deletion src/main/java/org/metricshub/winrm/AuthScheme.java
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,8 @@ public enum AuthScheme {
NTLM,

/** Kerberos (SPNEGO) authentication — requires HTTPS and connecting by the FQDN the KDC knows. */
KERBEROS
KERBEROS,

/** HTTP Basic authentication — the credential rides the {@code Authorization} header of every request. */
BASIC
}
25 changes: 19 additions & 6 deletions src/main/java/org/metricshub/winrm/WinRMClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -308,8 +308,13 @@ public Builder https() {
}

/**
* Connect over HTTP (port 5985 unless {@link #port(int)} is set) — the default. The SOAP
* messages are NTLM-encrypted on the wire.
* Connect over HTTP (port 5985 unless {@link #port(int)} is set) — the default. With NTLM
* (the default scheme) the SOAP messages are NTLM-encrypted on the wire, so plaintext HTTP
* is still protected. Other schemes change that guarantee: HTTP Basic sends both the
* credential and the SOAP in cleartext, so use {@link #https()} with Basic. Kerberos
* requires HTTPS; it is rejected fail-closed at {@link #build()} for ANY scheme list that
* contains it — including an ordered fallback such as {@code (KERBEROS, NTLM)} — rather
* than being silently dropped and downgraded to another scheme.
*
* @return this builder
*/
Expand Down Expand Up @@ -375,7 +380,7 @@ public Builder namespace(final String namespace) {
* Set the authentication schemes, tried in the given order until one succeeds. Default:
* NTLM only. Kerberos requires HTTPS.
*
* @param schemes the schemes in fallback order, e.g. {@code KERBEROS, NTLM}
* @param schemes the schemes in fallback order, e.g. {@code KERBEROS, NTLM} or {@code BASIC}
* @return this builder
*/
public Builder authentication(final AuthScheme... schemes) {
Expand Down Expand Up @@ -535,9 +540,17 @@ public WinRMClient build() {
if (authentication != null) {
authentications = new ArrayList<>(authentication.size());
for (final AuthScheme scheme : authentication) {
authentications.add(
scheme == AuthScheme.KERBEROS ? AuthenticationEnum.KERBEROS : AuthenticationEnum.NTLM
);
switch (scheme) {
case KERBEROS:
authentications.add(AuthenticationEnum.KERBEROS);
break;
case BASIC:
authentications.add(AuthenticationEnum.BASIC);
break;
default:
authentications.add(AuthenticationEnum.NTLM);
break;
}
}
}

Expand Down
14 changes: 13 additions & 1 deletion src/main/java/org/metricshub/winrm/cli/CliArguments.java
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,9 @@ private CliArguments(final Builder builder) {
port = WinRMEndpoint.getEndpointPort(protocol, builder.port);
timeout = builder.timeout;
permissiveHttps = builder.permissiveHttps;
authentication = builder.kerberos ? AuthenticationEnum.KERBEROS : AuthenticationEnum.NTLM;
authentication = builder.basic
? AuthenticationEnum.BASIC
: builder.kerberos ? AuthenticationEnum.KERBEROS : AuthenticationEnum.NTLM;
kerberosKdc = builder.kerberosKdc;
kerberosRealm = builder.kerberosRealm;
kerberosRealmInferred = builder.kerberosRealmInferred;
Expand Down Expand Up @@ -173,6 +175,9 @@ private static int parseOption(final Builder builder, final String[] arguments,
case "--kerberos":
builder.kerberos = true;
return index + 1;
case "--basic":
builder.basic = true;
return index + 1;
case "--kerberos-kdc":
builder.kerberosKdc = optionValue(arguments, index, option);
return nextIndex(argument, index);
Expand Down Expand Up @@ -253,6 +258,12 @@ private static void validate(final Builder builder) throws CliUsageException {
if (builder.ntlm && builder.kerberos) {
throw new CliUsageException("--ntlm and --kerberos are mutually exclusive");
}
if (builder.ntlm && builder.basic) {
throw new CliUsageException("--ntlm and --basic are mutually exclusive");
}
if (builder.kerberos && builder.basic) {
throw new CliUsageException("--kerberos and --basic are mutually exclusive");
}
if (builder.kerberos && !builder.https) {
throw new CliUsageException("--kerberos requires --https");
}
Expand Down Expand Up @@ -547,6 +558,7 @@ private static final class Builder {
private boolean permissiveHttps;
private boolean ntlm;
private boolean kerberos;
private boolean basic;
private String kerberosKdc;
private String kerberosRealm;
private boolean kerberosRealmInferred;
Expand Down
7 changes: 6 additions & 1 deletion src/main/java/org/metricshub/winrm/cli/WinRmCli.java
Original file line number Diff line number Diff line change
Expand Up @@ -397,7 +397,11 @@ static FluentRemoteOperations connect(final CliArguments arguments, final int co
builder.authentication(
authentications
.stream()
.map(scheme -> scheme == AuthenticationEnum.KERBEROS ? AuthScheme.KERBEROS : AuthScheme.NTLM)
.map(
scheme -> scheme == AuthenticationEnum.KERBEROS
? AuthScheme.KERBEROS
: scheme == AuthenticationEnum.BASIC ? AuthScheme.BASIC : AuthScheme.NTLM
)
.toArray(AuthScheme[]::new)
);
}
Expand Down Expand Up @@ -506,6 +510,7 @@ private static String help() {
" --https-permissive Trust any HTTPS certificate and hostname (insecure)\n" +
" --ntlm Use NTLM authentication (default)\n" +
" --kerberos Use Kerberos authentication (requires HTTPS)\n" +
" --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" +
" --help Show this help\n" +
Expand Down
14 changes: 14 additions & 0 deletions src/main/java/org/metricshub/winrm/light/AuthScheme.java
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,20 @@ interface AuthScheme {
*/
String authenticate(HttpTransport transport) throws Exception;

/**
* The {@code Authorization} header value a request must carry, or {@code null} when the
* connection's authentication state needs no per-request header.
* <p>
* For NTLM and Kerberos the token rides only the first real request (hence the value returned
* by {@link #authenticate(HttpTransport)}), so this is {@code null}; for stateless schemes
* such as Basic the header must repeat on EVERY request.
*
* @return the {@code Authorization} header value, or {@code null}
*/
default String requestAuthorization() {
return null;
}

/** @return whether the connection is currently authenticated. */
boolean isAuthenticated();

Expand Down
145 changes: 145 additions & 0 deletions src/main/java/org/metricshub/winrm/light/BasicAuthScheme.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
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 java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.CoderResult;
import java.nio.charset.CharsetEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Base64;

/**
* HTTP Basic authentication scheme. The credential (base64 of {@code user:password}) rides the
* {@code Authorization} header of EVERY request, so there is no stateful handshake and no message
* protection — the payload travels as plaintext SOAP. Confidentiality therefore relies on the
* transport: over HTTPS (TLS) the credential and the SOAP are protected; over plain HTTP they are
* sent in the clear and must not be used.
* <p>
* The caller's {@code char[]} password is kept only as a reference (never copied into a
* {@code String}), exactly like the NTLM scheme, so the caller remains the single owner of the
* secret and can wipe it after {@code close()}. The derived {@code Authorization} header is held
* as a wipeable {@code byte[]}, because Base64 is reversible: it is erased on
* {@link #reset()}. {@code close()} erases it directly when it can acquire the connection permit;
* when an in-flight operation or streaming handle still holds the permit (a timed-out worker, for
* example) the last operation to release the connection erases it instead, so the credential
* cannot survive the close either way.
*/
final class BasicAuthScheme extends PlaintextSoapAuthScheme {

// The full "Basic <base64>" header, held wipeable: it is a reversible copy of the credential,
// so it must not outlive the caller's own password array (erased in reset()). volatile: reset()
// can be called from another thread (close() or a worker releasing the connection) while a
// worker is reading the header.
private volatile byte[] authorization;
private final String username;
private final char[] password;

/**
* @param username the account name (a domain-qualified name keeps its domain prefix, which is
* how the server locates the account). The caller passes an already whitespace-stripped
* account (the service rebuilds it from the endpoint's normalized domain/username parts),
* so it is used verbatim here.
* @param password the account password, kept as {@code char[]} so the caller owns the single
* wipeable copy of the secret
*/
BasicAuthScheme(final String username, final char[] password) {
this.username = username;
this.password = password;
this.authorization = buildAuthorizationHeader(username, password);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Erase inactive Basic fallback credentials on close

The new wipe in reset() does not cover a Basic scheme that never becomes active: with an ordered list such as [NTLM, BASIC], this constructor eagerly derives the reversible credential, but FallbackAuthScheme.reset() resets only the active NTLM candidate. If NTLM succeeds and the closed client remains referenced, the inactive Basic candidate therefore retains authorization after the caller wipes the password; derive it lazily or dispose every fallback candidate when closing.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e307dcd — I took your second suggested option: FallbackAuthScheme.reset() now walks all candidates rather than just the active one, so a Basic scheme that never became active (e.g. [NTLM, BASIC] where NTLM wins) has its derived credential erased on close() and on a dropped connection. startIndex is left untouched, so the fallback order survives and the next authenticate() still retries the last-accepted scheme first. New regression test: FallbackAuthSchemeTest.resetClearsEveryCandidateNotJustTheActiveOne asserts both candidates are reset and that the first scheme is the one retried.

}

/**
* Build the full {@code Authorization} header value, encoding the password to UTF-8
* straight from the caller's {@code char[]} (a {@link CharBuffer} view) — the secret is
* never copied into a {@code String}, per the credentials contract.
*
* @param username the account name (may be domain-qualified)
* @param password the account password
* @return the ASCII bytes of {@code Basic <base64(user:password)>}
*/
private static byte[] buildAuthorizationHeader(final String username, final char[] password) {
final byte[] user = username.getBytes(StandardCharsets.UTF_8);
final byte[] secret = new byte[password.length * 3]; // UTF-8 never exceeds 3 bytes/char
final ByteBuffer secretBuffer = ByteBuffer.wrap(secret);
final CharsetEncoder encoder = StandardCharsets.UTF_8.newEncoder();
final CoderResult result = encoder.encode(CharBuffer.wrap(password), secretBuffer, true);
// A reported error can only be a malformed-input one: an unpaired surrogate in the password.
if (result.isError()) {
throw new IllegalArgumentException("The password contains unpaired surrogate characters");
}
encoder.flush(secretBuffer);
final int secretLength = secretBuffer.position();
final byte[] raw = new byte[user.length + 1 + secretLength];
System.arraycopy(user, 0, raw, 0, user.length);
raw[user.length] = (byte) ':';
System.arraycopy(secret, 0, raw, user.length + 1, secretLength);
Arrays.fill(secret, (byte) 0);
final byte[] base64 = Base64.getEncoder().encode(raw);
Arrays.fill(raw, (byte) 0);
final byte[] header = new byte["Basic ".length() + base64.length];
System.arraycopy("Basic ".getBytes(StandardCharsets.US_ASCII), 0, header, 0, "Basic ".length());
System.arraycopy(base64, 0, header, "Basic ".length(), base64.length);
Arrays.fill(base64, (byte) 0);
return header;
}

@Override
public String authenticate(final HttpTransport transport) throws Exception {
// No handshake: Basic has no server challenge. Mark the connection authenticated so the
// client proceeds straight to the first real request (which carries the Authorization header).
// A reset() when the connection dropped may have erased the credential, so re-derive it
// from the caller's still-live password array (the same behavior as the NTLM scheme, which
// keeps the password by reference to re-handshake a dropped connection).
if (authorization == null) {
authorization = buildAuthorizationHeader(username, password);
}
Comment on lines +113 to +115

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prevent close from repopulating Basic credentials

When close() races the first request after transport.connect() but before this assignment, it closes the socket and resets authorization; the worker can then rebuild the header here, and send() does not recheck closed before HttpTransport.post() reconnects. If that post successfully returns an open WQL/command handle which is then abandoned, releaseConnection() never runs and the closed client retains the reversible credential indefinitely. Fresh evidence beyond the earlier close-race findings is this post-reset re-derivation path; fence send() after authentication or prevent a disposed Basic scheme from rebuilding its header.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged, and I agree with your own framing here, so I'm leaving the code as-is rather than adding a guard.

The mechanism is real in theory, but it needs two things to coincide, and the second is the disqualifier:

  1. close() must land strictly between transport.connect() and the authorization assignment in authenticate() — a sub-millisecond window — and the worker must then re-derive the header and post() (reconnecting the closed socket).
  2. The caller must then ABANDON the returned WqlEnumeration/RemoteCommand handle — never call next()/close() — so the connection permit is never released and the releaseConnection() backstop never runs.

(2) is a caller leaking an AutoCloseable handle, which we can't protect against in general: any abandoned handle retains whatever state it holds, for any scheme. We deliberately don't build a leak-detection layer for that.

On the cases we can cover, we already do: close() resets the auth state unconditionally in its finally (so the credential is erased the moment the client closes, including when an idle or in-flight handle holds the permit), and releaseConnection() has the post-release backstop for the post-close re-authentication that the Kerberos no-socket-I/O path can produce. The only residual is the abandoned-handle case above, and only for Basic, which is a testing-only scheme (disabled on the WinRM service by default), not a default transport.

So I'm not adding the post-auth closed gate to send() — it would be another layer on the same close-race for a scenario that also requires the caller to drop an auto-closeable handle, and I'd rather keep send() lean. If you'd prefer defense-in-depth there anyway, I'm happy to add the if (closed) { auth.reset(); throw ... } recheck after auth.authenticate() in a follow-up; I just didn't think it warranted the extra code path.

authenticated = true;
return null;
}

@Override
public String requestAuthorization() {
// Stateless: the same header repeats on every request, not just the first.
if (authorization == null) {
throw new IllegalStateException("The Basic credential was erased before the connection was re-authenticated");
}
return new String(authorization, StandardCharsets.US_ASCII);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid recreating the Basic credential as an immutable String

The new byte[] field does not fully resolve the earlier immutable-copy issue because every Basic request converts the reversible credential into a fresh, non-wipeable String here, which HttpTransport then copies through its string-built request header. Resetting only erases authorization, so applications using Basic still cannot rely on the documented post-close wipe guarantee; carry the header through the transport in wipeable byte storage instead.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right that the per-request String is a copy of the reversible credential, but I don't think it breaks the wipe guarantee, and it's the exact mechanism the other schemes already use:

  • The guarantee is about what remains after close(): close() fences the client (closed = true, later requests fail) and runs auth.reset(), which zeroes authorization. From that point no further per-request strings are ever produced from the credential, so the caller can zero its char[] with nothing left behind.
  • The per-request string is transient — immediately folded into the header buffer and garbage-collected — and identical to what NTLM/Kerberos already do: their Negotiate <token> is also a freshly built String per first request, carried through the same HttpTransport.post(..., String authorization). So this is scheme-agnostic transport behavior, not a Basic-specific retention.
  • Carrying the header through the transport in byte storage would mean a new HttpTransport overload and switching every scheme's token path. That's a real interface change I'd rather make as a separate task covering NTLM/Kerberos together, rather than special-casing Basic here.

Happy to file that as a follow-up if the stronger guarantee is wanted.

}

@Override
public void reset() {
// Erase the Base64 credential — a reversible copy of the password — so that wiping the
// caller's char[] afterward leaves no live copy of the password. Called by close() when the
// connection permit is acquirable, and by WsmanClient when the last in-flight operation
// releases the connection after close() could not acquire the permit (a timed-out worker).
// The two callers are different threads with no shared lock, so this must be re-entrant and
// idempotent: capture the reference in a local so a concurrent reset() cannot null the field
// between the null-check and the fill (filling a twice-erased array is harmless).
authenticated = false;
final byte[] header = authorization;
if (header != null) {
Arrays.fill(header, (byte) 0);
authorization = null;
}
}
}
17 changes: 13 additions & 4 deletions src/main/java/org/metricshub/winrm/light/FallbackAuthScheme.java
Original file line number Diff line number Diff line change
Expand Up @@ -75,17 +75,26 @@ public String authenticate(final HttpTransport transport) throws Exception {
);
}

@Override
public String requestAuthorization() {
// A stateless active candidate (Basic) repeats its header on EVERY request, so the wrapper
// must forward it; the interface default of null would drop the header entirely.
return active != null ? active.requestAuthorization() : null;
}

@Override
public boolean isAuthenticated() {
return active != null && active.isAuthenticated();
}

@Override
public void reset() {
// A dropped connection: clear the active scheme's session but keep it selected so the next
// authenticate() re-handshakes with the same (already-accepted) scheme.
if (active != null) {
active.reset();
// Clear EVERY candidate's session state, not just the active one: a dropped connection or
// close() must erase the derived, reversible secrets of every scheme — including a Basic
// credential held by a scheme that never became active — while startIndex keeps the fallback
// order intact, so the next authenticate() still retries the last-accepted scheme first.
for (final AuthScheme candidate : candidates) {
candidate.reset();
}
}

Expand Down
Loading
Loading