Skip to content

Conversation

arturobernalg
Copy link
Member

Implement SCRAM-SHA-256 auth (RFC 7804/5802/7677) for HttpClient. Full round-trip with constant-time server signature verification from Authentication-Info, SASLprep and zeroized secrets, correct header quoting, optional preemptive client-first, and iteration policy warnings.

@arturobernalg arturobernalg requested a review from ok2c September 5, 2025 15:54
@michael-o
Copy link
Member

As far as I understood the SASL SCRAM mech it was always connection-bound which always contracted the multistream nature of h2. How does this reconcile? E.g., PHA or NTLM on h2 are completely not working.

@michael-o
Copy link
Member

I hope this can finally kill Digest scheme.

@arturobernalg
Copy link
Member Author

As far as I understood the SASL SCRAM mech it was always connection-bound which always contracted the multistream nature of h2. How does this reconcile? E.g., PHA or NTLM on h2 are completely not working.

IMO we’re fine on h2 because this is HTTP SCRAM (RFC 7804) which is per-request—no channel binding (GS2 “n,,” / c=biws)—so each stream carries its own exchange.
The connection-bound pain is NTLM/Negotiate/PHA; if we ever add SCRAM-PLUS, it can bind via the TLS exporter shared by h2.

@michael-o
Copy link
Member

As far as I understood the SASL SCRAM mech it was always connection-bound which always contracted the multistream nature of h2. How does this reconcile? E.g., PHA or NTLM on h2 are completely not working.

IMO we’re fine on h2 because this is HTTP SCRAM (RFC 7804) which is per-request—no channel binding (GS2 “n,,” / c=biws)—so each stream carries its own exchange. The connection-bound pain is NTLM/Negotiate/PHA; if we ever add SCRAM-PLUS, it can bind via the TLS exporter shared by h2.

So one round is enough to complete auth?

@arturobernalg
Copy link
Member Author

As far as I understood the SASL SCRAM mech it was always connection-bound which always contracted the multistream nature of h2. How does this reconcile? E.g., PHA or NTLM on h2 are completely not working.

IMO we’re fine on h2 because this is HTTP SCRAM (RFC 7804) which is per-request—no channel binding (GS2 “n,,” / c=biws)—so each stream carries its own exchange. The connection-bound pain is NTLM/Negotiate/PHA; if we ever add SCRAM-PLUS, it can bind via the TLS exporter shared by h2.

So one round is enough to complete auth?

SCRAM needs two exchanges: client-first → 401 (server-first), then client-final → 200 with Authentication-Info (v=).
For what I understood, if the server sends an empty announce first, it’s one extra 401 (so 3 total); with preemptive client-first it’s 2.

@michael-o
Copy link
Member

michael-o commented Sep 7, 2025

As far as I understood the SASL SCRAM mech it was always connection-bound which always contracted the multistream nature of h2. How does this reconcile? E.g., PHA or NTLM on h2 are completely not working.

IMO we’re fine on h2 because this is HTTP SCRAM (RFC 7804) which is per-request—no channel binding (GS2 “n,,” / c=biws)—so each stream carries its own exchange. The connection-bound pain is NTLM/Negotiate/PHA; if we ever add SCRAM-PLUS, it can bind via the TLS exporter shared by h2.

So one round is enough to complete auth?

SCRAM needs two exchanges: client-first → 401 (server-first), then client-final → 200 with Authentication-Info (v=). For what I understood, if the server sends an empty announce first, it’s one extra 401 (so 3 total); with preemptive client-first it’s 2.

So from a client's perspective, it is always stateful, right? From the server's perspective, it can be stateful. How can this be bound to an h2 stream, if h2 is used?

For instance, SPNEGO/Kerberos only works reliably via h2 IF there is a single roundtrip only, everything else is undefined per sé.

@arturobernalg
Copy link
Member Author

As far as I understood the SASL SCRAM mech it was always connection-bound which always contracted the multistream nature of h2. How does this reconcile? E.g., PHA or NTLM on h2 are completely not working.

IMO we’re fine on h2 because this is HTTP SCRAM (RFC 7804) which is per-request—no channel binding (GS2 “n,,” / c=biws)—so each stream carries its own exchange. The connection-bound pain is NTLM/Negotiate/PHA; if we ever add SCRAM-PLUS, it can bind via the TLS exporter shared by h2.

So one round is enough to complete auth?

SCRAM needs two exchanges: client-first → 401 (server-first), then client-final → 200 with Authentication-Info (v=). For what I understood, if the server sends an empty announce first, it’s one extra 401 (so 3 total); with preemptive client-first it’s 2.

So from a client's perspective, it is always stateful, right? From the server's perspective, it can be stateful. How can this be bound to an h2 stream, if h2 is used?

Client keeps only transient handshake state (nonce/authMessage/expected-v) per request/stream, not per connection.
With h2, all SCRAM messages are headers, so each stream’s Authorization ↔ WWW-Authenticate ↔ Authentication-Info cycle is self-contained; the server can use an opaque sid to correlate, but no connection binding is required.

@michael-o
Copy link
Member

As far as I understood the SASL SCRAM mech it was always connection-bound which always contracted the multistream nature of h2. How does this reconcile? E.g., PHA or NTLM on h2 are completely not working.

IMO we’re fine on h2 because this is HTTP SCRAM (RFC 7804) which is per-request—no channel binding (GS2 “n,,” / c=biws)—so each stream carries its own exchange. The connection-bound pain is NTLM/Negotiate/PHA; if we ever add SCRAM-PLUS, it can bind via the TLS exporter shared by h2.

So one round is enough to complete auth?

SCRAM needs two exchanges: client-first → 401 (server-first), then client-final → 200 with Authentication-Info (v=). For what I understood, if the server sends an empty announce first, it’s one extra 401 (so 3 total); with preemptive client-first it’s 2.

So from a client's perspective, it is always stateful, right? From the server's perspective, it can be stateful. How can this be bound to an h2 stream, if h2 is used?

Client keeps only transient handshake state (nonce/authMessage/expected-v) per request/stream, not per connection. With h2, all SCRAM messages are headers, so each stream’s Authorization ↔ WWW-Authenticate ↔ Authentication-Info cycle is self-contained; the server can use an opaque sid to correlate, but no connection binding is required.

Ah, perfect. This is what I wanted to hear. As long it is associated with the stream only and not the connection I am fine with that in general.

StandardAuthScheme.BASIC));
Collections.unmodifiableList(Arrays.asList(
StandardAuthScheme.BEARER,
StandardAuthScheme.SCRAM_SHA_256,
Copy link
Member

Choose a reason for hiding this comment

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

This order is of course questionable because if Bearer is peformed via client_credentials first it isn't better than SCRAM, from my PoV

@arturobernalg
Copy link
Member Author

Please @michael-o do another pass

Implements HTTP SCRAM with SCRAM-SHA-256 per RFC 7804 and SCRAM mechanics per RFC 5802/7677.
Copy link
Member

@michael-o michael-o left a comment

Choose a reason for hiding this comment

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

I don't have any objections, but at least some other committer should look over!

@arturobernalg
Copy link
Member Author

I don't have any objections, but at least some other committer should look over!

Agree. thank you @michael-o

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants