diff --git a/README.md b/README.md index 8033f69..f4e7f81 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,9 @@ hashing, Argon2-SCRAM, HS256/RS256 JWTs, and opaque token validation. This is a **package-specific JSON challenge/response protocol using Argon2id and HMAC-SHA256**, not standard SASL SCRAM-SHA-256. Both peers must implement this -protocol. It does not implement SASLprep or TLS channel binding. Use authenticated -TLS (HTTPS/WSS for HTTP/WebSocket transports), including server certificate and -hostname verification. SCRAM does not encrypt subsequent application traffic. +protocol. It does not implement SASLprep. Use authenticated TLS (HTTPS/WSS for +HTTP/WebSocket transports), including server certificate and hostname +verification. SCRAM does not encrypt subsequent application traffic. Provision directly; do not create a PHC hash just to convert it: @@ -47,6 +47,20 @@ deployment with a different profile, pass `WithMinArgonCost(iterations, memoryKi to `NewScramClient`. Choose this policy locally; never copy it from a challenge. Invalid minimum configuration is rejected by `StartAuthentication`. +### Channel binding + +Pass `WithChannelBinding(data)` to both `ProcessServerFirstMessage` (client) and +`ProcessClientFinalMessage` (server) to commit the proof to the transport. Both +peers must derive identical bytes, e.g. SHA-256 of the server's leaf certificate +DER (RFC 5929 style, stable across pooled connections) or an RFC 9266 TLS +exporter (per connection). A relay that terminates TLS with any other +certificate, even one your CA trusts, then fails the proof, so it can neither +complete the login nor obtain a token issued with it. Bound and unbound peers +never agree, so there is no silent downgrade; an empty or oversized (>64 bytes) +binding returns `ErrSCRAMChannelBinding`. A mismatch is reported as +`ErrInvalidCredentials`, like a wrong password. When later requests travel on +new connections (HTTP), the client must also pin the bound certificate on them. + Check every error and abort the exchange on failure. Only a successful **server final** authenticates a user; use its `Username`, never a separate identity from an untrusted request. Bind the exchange to its connection/request context in the @@ -169,6 +183,7 @@ session storage when needed. `AddToken` ignores invalid lengths for API stabilit | Password | 1024 bytes at every KDF entry | | SCRAM username | 1–256 UTF-8 bytes; no control characters, comma, or equals | | Client nonce / combined nonce | 256 / 512 printable ASCII bytes; no comma | +| SCRAM channel binding | 1–64 bytes | | SCRAM salt / key / proof | 16–64 / 32 / 32 decoded bytes | | PHC record / salt / digest | 256 encoded / 8–64 / 16–64 decoded bytes | | Argon2 execution | memory ≤256 MiB, iterations ≤16, lanes ≤16, memory ≥8×lanes KiB | diff --git a/doc.go b/doc.go index 21e7cb7..157e7c0 100644 --- a/doc.go +++ b/doc.go @@ -7,8 +7,9 @@ // before the application trusts the authentication result. // // The SCRAM exchange uses a package-specific Argon2id/JSON protocol; it is not -// interoperable with the standard SASL SCRAM-SHA-256 mechanism. Applications must -// supply authenticated TLS, request limits, rate limits, and authorization. +// interoperable with the standard SASL SCRAM-SHA-256 mechanism. WithChannelBinding +// commits a proof to the TLS channel. Applications must supply authenticated TLS, +// request limits, rate limits, and authorization. // // NewJWT and NewJWTRSA create reusable, concurrent token managers. Configure an // issuer and audience for each service. ValidateToken requires expiration and a diff --git a/error.go b/error.go index adad5fb..bb42d95 100644 --- a/error.go +++ b/error.go @@ -65,6 +65,7 @@ var ( ErrSCRAMStopped = errors.New("scram: server stopped") ErrSCRAMDecoyKey = errors.New("scram: decoy key must be at least 32 bytes") ErrSCRAMCredentialProfile = errors.New("scram: credentials must share Argon2 parameters and salt length") + ErrSCRAMChannelBinding = errors.New("scram: channel binding must be 1-64 bytes") ) // Credential import/export errors diff --git a/scram.go b/scram.go index f3a4fed..c44caaf 100644 --- a/scram.go +++ b/scram.go @@ -1,6 +1,7 @@ package auth import ( + "bytes" "crypto/hmac" "crypto/rand" "crypto/sha256" @@ -34,6 +35,8 @@ const ( MaxUsernameLen = 256 MaxClientNonceLen = 256 MaxFullNonceLen = 512 + // MaxChannelBindingLen fits a SHA-512 certificate hash or an RFC 9266 exporter + MaxChannelBindingLen = 64 ) // Credential stores SCRAM authentication data @@ -442,8 +445,13 @@ func (s *ScramServer) ProcessClientFirstMessage(username, clientNonce string) (S }, nil } -// ProcessClientFinalMessage verifies client proof -func (s *ScramServer) ProcessClientFinalMessage(fullNonce, clientProof string) (ServerFinalMessage, error) { +// ProcessClientFinalMessage verifies client proof. A channel binding, when +// given, must equal the client's; an unbound and a bound peer never agree. +func (s *ScramServer) ProcessClientFinalMessage(fullNonce, clientProof string, opts ...ExchangeOption) (ServerFinalMessage, error) { + binding, err := exchangeBinding(opts) + if err != nil { + return ServerFinalMessage{}, err + } if !validNonce(fullNonce, MaxFullNonceLen) { return ServerFinalMessage{}, ErrSCRAMInvalidNonce } @@ -494,7 +502,7 @@ func (s *ScramServer) ProcessClientFinalMessage(fullNonce, clientProof string) ( ArgonMemory: state.Credential.ArgonMemory, ArgonThreads: state.Credential.ArgonThreads, } - clientFinalBare := fmt.Sprintf("r=%s", fullNonce) + clientFinalBare := binding + "r=" + fullNonce authMessage := clientFirstBare + "," + serverFirst.Marshal() + "," + clientFinalBare // Compute client signature @@ -662,13 +670,19 @@ func (c *ScramClient) StartAuthentication() (ClientFirstRequest, error) { }, nil } -// ProcessServerFirstMessage handles server challenge -func (c *ScramClient) ProcessServerFirstMessage(msg ServerFirstMessage) (result ClientFinalRequest, err error) { +// ProcessServerFirstMessage handles server challenge. Pass the same channel +// binding the server uses; the binding is learned only after the transport +// connects, which is why it is supplied here rather than at construction. +func (c *ScramClient) ProcessServerFirstMessage(msg ServerFirstMessage, opts ...ExchangeOption) (result ClientFinalRequest, err error) { defer func() { if err != nil { c.Reset() } }() + binding, err := exchangeBinding(opts) + if err != nil { + return ClientFinalRequest{}, err + } if c.state != scramClientAwaitChallenge { return ClientFinalRequest{}, ErrSCRAMInvalidState } @@ -707,7 +721,7 @@ func (c *ScramClient) ProcessServerFirstMessage(msg ServerFirstMessage) (result // Build auth message clientFirstBare := fmt.Sprintf("u=%s,n=%s", c.handshakeUsername, c.clientNonce) - clientFinalBare := fmt.Sprintf("r=%s", msg.FullNonce) + clientFinalBare := binding + "r=" + msg.FullNonce c.authMessage = clientFirstBare + "," + msg.Marshal() + "," + clientFinalBare // Compute client proof @@ -769,6 +783,43 @@ func (c *ScramClient) Reset() { c.startTime = time.Time{} } +// ExchangeOption configures the proof step of one exchange, on either side. +type ExchangeOption func(*exchangeOptions) + +type exchangeOptions struct { + binding []byte + bound bool +} + +// WithChannelBinding commits the proof to transport bytes both peers derive +// identically, e.g. SHA-256 of the server's leaf certificate (RFC 5929 style) +// or an RFC 9266 TLS exporter. A relay terminating TLS with any other +// certificate then fails the proof. Length must be 1..MaxChannelBindingLen. +func WithChannelBinding(data []byte) ExchangeOption { + return func(o *exchangeOptions) { + o.binding, o.bound = bytes.Clone(data), true + } +} + +// exchangeBinding renders the client-final-bare prefix. An empty binding is an +// error rather than "unbound", so a caller bug cannot silently downgrade. +// Commas cannot occur in base64 or nonces, so the two forms never collide. +func exchangeBinding(opts []ExchangeOption) (string, error) { + var o exchangeOptions + for _, opt := range opts { + if opt != nil { + opt(&o) + } + } + if !o.bound { + return "", nil + } + if len(o.binding) == 0 || len(o.binding) > MaxChannelBindingLen { + return "", ErrSCRAMChannelBinding + } + return "c=" + base64.StdEncoding.EncodeToString(o.binding) + ",", nil +} + // SCRAM message types type ClientFirstRequest struct { Username string `json:"username"` diff --git a/scram_fuzz_test.go b/scram_fuzz_test.go index 3ea39a2..f3b5993 100644 --- a/scram_fuzz_test.go +++ b/scram_fuzz_test.go @@ -8,16 +8,20 @@ import ( func FuzzScramServerMessages(f *testing.F) { s := NewScramServer() f.Cleanup(s.Stop) - f.Add("unknown", "nonce", base64.StdEncoding.EncodeToString(make([]byte, 32))) - f.Add("", "", "") - f.Add("a,n=b", "nonce", "!!!") - f.Fuzz(func(t *testing.T, username, nonce, proof string) { + f.Add("unknown", "nonce", base64.StdEncoding.EncodeToString(make([]byte, 32)), []byte(nil)) + f.Add("", "", "", []byte{}) + f.Add("a,n=b", "nonce", "!!!", make([]byte, 32)) + f.Fuzz(func(t *testing.T, username, nonce, proof string, binding []byte) { + var opts []ExchangeOption + if len(binding) > 0 && len(binding) <= MaxChannelBindingLen { + opts = append(opts, WithChannelBinding(binding)) + } first, err := s.ProcessClientFirstMessage(username, nonce) if err != nil { return } // No credential is registered, so even syntactically valid proofs fail. - _, err = s.ProcessClientFinalMessage(first.FullNonce, proof) + _, err = s.ProcessClientFinalMessage(first.FullNonce, proof, opts...) if err == nil { t.Fatal("unknown account authenticated") } diff --git a/scram_test.go b/scram_test.go index e95fc2d..a9f3187 100644 --- a/scram_test.go +++ b/scram_test.go @@ -195,6 +195,57 @@ func TestScramProofBinding(t *testing.T) { noErr(t, err, "handshake b unaffected") } +// The binding enters both AuthMessages, so peers authenticate only when they +// derive identical bytes; a one-sided binding fails instead of downgrading. +func TestScramChannelBinding(t *testing.T) { + a, b := bytes.Repeat([]byte{1}, 32), bytes.Repeat([]byte{2}, 32) + cases := []struct { + name string + client, server []ExchangeOption + ok bool + }{ + {"matching", []ExchangeOption{WithChannelBinding(a)}, []ExchangeOption{WithChannelBinding(a)}, true}, + {"mismatched", []ExchangeOption{WithChannelBinding(a)}, []ExchangeOption{WithChannelBinding(b)}, false}, + {"client only", []ExchangeOption{WithChannelBinding(a)}, nil, false}, + {"server only", nil, []ExchangeOption{WithChannelBinding(a)}, false}, + } + s, user, pw, _ := setupScram(t) + for _, tc := range cases { + c := newTestScramClient(user, pw) + first, err := c.StartAuthentication() + noErr(t, err, tc.name+": start") + challenge, err := s.ProcessClientFirstMessage(first.Username, first.ClientNonce) + noErr(t, err, tc.name+": client first") + proof, err := c.ProcessServerFirstMessage(challenge, tc.client...) + noErr(t, err, tc.name+": server first") + final, err := s.ProcessClientFinalMessage(proof.FullNonce, proof.ClientProof, tc.server...) + if !tc.ok { + errIs(t, err, ErrInvalidCredentials, tc.name) + continue + } + noErr(t, err, tc.name+": client final") + noErr(t, c.VerifyServerFinalMessage(final), tc.name+": server final") + } +} + +// An empty or oversized binding is a caller bug: both sides refuse it before +// touching exchange state rather than treating it as "unbound". +func TestScramChannelBindingLength(t *testing.T) { + s, user, pw, _ := setupScram(t) + for _, cb := range [][]byte{nil, {}, make([]byte, MaxChannelBindingLen+1)} { + c := newTestScramClient(user, pw) + first, err := c.StartAuthentication() + noErr(t, err, "start") + challenge, err := s.ProcessClientFirstMessage(first.Username, first.ClientNonce) + noErr(t, err, "client first") + _, err = s.ProcessClientFinalMessage(challenge.FullNonce, "", WithChannelBinding(cb)) + errIs(t, err, ErrSCRAMChannelBinding, "server binding length") + _, err = c.ProcessServerFirstMessage(challenge, WithChannelBinding(cb)) + errIs(t, err, ErrSCRAMChannelBinding, "client binding length") + } + eq(t, handshakeCount(s), 3, "server consumed a handshake on a binding error") +} + func TestScramProofEncoding(t *testing.T) { s, user, pw, _ := setupScram(t)