From 0dc81891473a03c98b43103aa8860a0cfbbd8f7ed418368642912ae2f5fb3b8f Mon Sep 17 00:00:00 2001 From: Lixen Wraith Date: Fri, 2 Oct 2026 15:16:39 -0400 Subject: [PATCH] v0.5.1 pin scram wire format and edge tests --- README.md | 5 +++++ scram.go | 1 + scram_fuzz_test.go | 1 + scram_test.go | 36 +++++++++++++++++++++++++++++++++--- 4 files changed, 40 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f4e7f81..22fc960 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,11 @@ 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. +Nil options are ignored; a repeated `WithChannelBinding` keeps the last one. + +v0.5.0 added the variadic `...ExchangeOption` parameter to both methods. Calls +compile unchanged; interfaces or method values declaring the old signatures must +be updated. 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 diff --git a/scram.go b/scram.go index c44caaf..d79dc97 100644 --- a/scram.go +++ b/scram.go @@ -784,6 +784,7 @@ func (c *ScramClient) Reset() { } // ExchangeOption configures the proof step of one exchange, on either side. +// Nil options are ignored; a repeated WithChannelBinding keeps the last one. type ExchangeOption func(*exchangeOptions) type exchangeOptions struct { diff --git a/scram_fuzz_test.go b/scram_fuzz_test.go index f3b5993..4c9b483 100644 --- a/scram_fuzz_test.go +++ b/scram_fuzz_test.go @@ -11,6 +11,7 @@ func FuzzScramServerMessages(f *testing.F) { 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.Add("unknown", "nonce", base64.StdEncoding.EncodeToString(make([]byte, 32)), make([]byte, MaxChannelBindingLen)) f.Fuzz(func(t *testing.T, username, nonce, proof string, binding []byte) { var opts []ExchangeOption if len(binding) > 0 && len(binding) <= MaxChannelBindingLen { diff --git a/scram_test.go b/scram_test.go index a9f3187..9fae7fe 100644 --- a/scram_test.go +++ b/scram_test.go @@ -198,13 +198,14 @@ func TestScramProofBinding(t *testing.T) { // 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) + a, b, max := bytes.Repeat([]byte{1}, 32), bytes.Repeat([]byte{2}, 32), bytes.Repeat([]byte{3}, MaxChannelBindingLen) cases := []struct { name string client, server []ExchangeOption ok bool }{ {"matching", []ExchangeOption{WithChannelBinding(a)}, []ExchangeOption{WithChannelBinding(a)}, true}, + {"matching at the 64-byte limit", []ExchangeOption{WithChannelBinding(max)}, []ExchangeOption{WithChannelBinding(max)}, true}, {"mismatched", []ExchangeOption{WithChannelBinding(a)}, []ExchangeOption{WithChannelBinding(b)}, false}, {"client only", []ExchangeOption{WithChannelBinding(a)}, nil, false}, {"server only", nil, []ExchangeOption{WithChannelBinding(a)}, false}, @@ -228,8 +229,35 @@ func TestScramChannelBinding(t *testing.T) { } } -// An empty or oversized binding is a caller bug: both sides refuse it before -// touching exchange state rather than treating it as "unbound". +// Known-answer proofs pin the AuthMessage wire format: the unbound value is the +// one v0.4.1 produced, so older peers keep interoperating, and the bound form +// cannot drift while both sides change in step. +func TestScramProofKnownAnswers(t *testing.T) { + msg := ServerFirstMessage{ + FullNonce: "client-nonce" + "server-nonce", Salt: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{7}, 16)), + ArgonTime: 1, ArgonMemory: 8, ArgonThreads: 1, + } + for _, tc := range []struct { + name string + opts []ExchangeOption + want string + }{ + {"unbound", nil, "JCVOdKAYwVu3YZx+6nfRfBUcnsTCgydOchnQq1G7Ry0="}, + {"bound", []ExchangeOption{WithChannelBinding(bytes.Repeat([]byte{9}, 32))}, "uhfWaHDQ4yUIcY3qWbaFhBBXWO+HtBlujy7z9u92a20="}, + } { + c := NewScramClient("alice", "password123", WithMinArgonCost(1, 8)) + _, err := c.StartAuthentication() + noErr(t, err, tc.name+": start") + c.clientNonce = "client-nonce" + proof, err := c.ProcessServerFirstMessage(msg, tc.opts...) + noErr(t, err, tc.name) + eq(t, proof.ClientProof, tc.want, tc.name+": proof") + } +} + +// An empty or oversized binding is a caller bug, refused rather than treated as +// "unbound". The server keeps its handshake; the client is consumed, as on any +// client error. func TestScramChannelBindingLength(t *testing.T) { s, user, pw, _ := setupScram(t) for _, cb := range [][]byte{nil, {}, make([]byte, MaxChannelBindingLen+1)} { @@ -242,6 +270,8 @@ func TestScramChannelBindingLength(t *testing.T) { errIs(t, err, ErrSCRAMChannelBinding, "server binding length") _, err = c.ProcessServerFirstMessage(challenge, WithChannelBinding(cb)) errIs(t, err, ErrSCRAMChannelBinding, "client binding length") + _, err = c.ProcessServerFirstMessage(challenge, WithChannelBinding([]byte{1})) + errIs(t, err, ErrSCRAMInvalidState, "client state survived a binding error") } eq(t, handshakeCount(s), 3, "server consumed a handshake on a binding error") }