v0.5.1 pin scram wire format and edge tests
Test and Fuzz / Build, Test, and Fuzz (push) Canceled after 0s

This commit is contained in:
lixen committed 2026-10-02 15:16:39 -04:00
1 parent b5524a3b9d
commit 0dc8189147
4 files changed
+40 -3

No files matched your search

+5
View File
@@ -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 binding returns `ErrSCRAMChannelBinding`. A mismatch is reported as
`ErrInvalidCredentials`, like a wrong password. When later requests travel on `ErrInvalidCredentials`, like a wrong password. When later requests travel on
new connections (HTTP), the client must also pin the bound certificate on them. 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 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 final** authenticates a user; use its `Username`, never a separate identity from
+1
View File
@@ -784,6 +784,7 @@ func (c *ScramClient) Reset() {
} }
// ExchangeOption configures the proof step of one exchange, on either side. // 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 ExchangeOption func(*exchangeOptions)
type exchangeOptions struct { type exchangeOptions struct {
+1
View File
@@ -11,6 +11,7 @@ func FuzzScramServerMessages(f *testing.F) {
f.Add("unknown", "nonce", base64.StdEncoding.EncodeToString(make([]byte, 32)), []byte(nil)) f.Add("unknown", "nonce", base64.StdEncoding.EncodeToString(make([]byte, 32)), []byte(nil))
f.Add("", "", "", []byte{}) f.Add("", "", "", []byte{})
f.Add("a,n=b", "nonce", "!!!", make([]byte, 32)) 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) { f.Fuzz(func(t *testing.T, username, nonce, proof string, binding []byte) {
var opts []ExchangeOption var opts []ExchangeOption
if len(binding) > 0 && len(binding) <= MaxChannelBindingLen { if len(binding) > 0 && len(binding) <= MaxChannelBindingLen {
+33 -3
View File
@@ -198,13 +198,14 @@ func TestScramProofBinding(t *testing.T) {
// The binding enters both AuthMessages, so peers authenticate only when they // The binding enters both AuthMessages, so peers authenticate only when they
// derive identical bytes; a one-sided binding fails instead of downgrading. // derive identical bytes; a one-sided binding fails instead of downgrading.
func TestScramChannelBinding(t *testing.T) { 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 { cases := []struct {
name string name string
client, server []ExchangeOption client, server []ExchangeOption
ok bool ok bool
}{ }{
{"matching", []ExchangeOption{WithChannelBinding(a)}, []ExchangeOption{WithChannelBinding(a)}, true}, {"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}, {"mismatched", []ExchangeOption{WithChannelBinding(a)}, []ExchangeOption{WithChannelBinding(b)}, false},
{"client only", []ExchangeOption{WithChannelBinding(a)}, nil, false}, {"client only", []ExchangeOption{WithChannelBinding(a)}, nil, false},
{"server only", nil, []ExchangeOption{WithChannelBinding(a)}, 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 // Known-answer proofs pin the AuthMessage wire format: the unbound value is the
// touching exchange state rather than treating it as "unbound". // 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) { func TestScramChannelBindingLength(t *testing.T) {
s, user, pw, _ := setupScram(t) s, user, pw, _ := setupScram(t)
for _, cb := range [][]byte{nil, {}, make([]byte, MaxChannelBindingLen+1)} { 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") errIs(t, err, ErrSCRAMChannelBinding, "server binding length")
_, err = c.ProcessServerFirstMessage(challenge, WithChannelBinding(cb)) _, err = c.ProcessServerFirstMessage(challenge, WithChannelBinding(cb))
errIs(t, err, ErrSCRAMChannelBinding, "client binding length") 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") eq(t, handshakeCount(s), 3, "server consumed a handshake on a binding error")
} }