v0.5.0 optional tls channel binding for scram
This commit is contained in:
1 parent
5cf0a93676
commit
b5524a3b9d
6 files changed
+139
-16
No files matched your search
@@ -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
|
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
|
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
|
protocol. It does not implement SASLprep. Use authenticated TLS (HTTPS/WSS for
|
||||||
TLS (HTTPS/WSS for HTTP/WebSocket transports), including server certificate and
|
HTTP/WebSocket transports), including server certificate and hostname
|
||||||
hostname verification. SCRAM does not encrypt subsequent application traffic.
|
verification. SCRAM does not encrypt subsequent application traffic.
|
||||||
|
|
||||||
Provision directly; do not create a PHC hash just to convert it:
|
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.
|
to `NewScramClient`. Choose this policy locally; never copy it from a challenge.
|
||||||
Invalid minimum configuration is rejected by `StartAuthentication`.
|
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
|
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
|
||||||
an untrusted request. Bind the exchange to its connection/request context in the
|
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 |
|
| Password | 1024 bytes at every KDF entry |
|
||||||
| SCRAM username | 1–256 UTF-8 bytes; no control characters, comma, or equals |
|
| SCRAM username | 1–256 UTF-8 bytes; no control characters, comma, or equals |
|
||||||
| Client nonce / combined nonce | 256 / 512 printable ASCII bytes; no comma |
|
| 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 |
|
| SCRAM salt / key / proof | 16–64 / 32 / 32 decoded bytes |
|
||||||
| PHC record / salt / digest | 256 encoded / 8–64 / 16–64 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 |
|
| Argon2 execution | memory ≤256 MiB, iterations ≤16, lanes ≤16, memory ≥8×lanes KiB |
|
||||||
|
|||||||
@@ -7,8 +7,9 @@
|
|||||||
// before the application trusts the authentication result.
|
// before the application trusts the authentication result.
|
||||||
//
|
//
|
||||||
// The SCRAM exchange uses a package-specific Argon2id/JSON protocol; it is not
|
// The SCRAM exchange uses a package-specific Argon2id/JSON protocol; it is not
|
||||||
// interoperable with the standard SASL SCRAM-SHA-256 mechanism. Applications must
|
// interoperable with the standard SASL SCRAM-SHA-256 mechanism. WithChannelBinding
|
||||||
// supply authenticated TLS, request limits, rate limits, and authorization.
|
// 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
|
// NewJWT and NewJWTRSA create reusable, concurrent token managers. Configure an
|
||||||
// issuer and audience for each service. ValidateToken requires expiration and a
|
// issuer and audience for each service. ValidateToken requires expiration and a
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ var (
|
|||||||
ErrSCRAMStopped = errors.New("scram: server stopped")
|
ErrSCRAMStopped = errors.New("scram: server stopped")
|
||||||
ErrSCRAMDecoyKey = errors.New("scram: decoy key must be at least 32 bytes")
|
ErrSCRAMDecoyKey = errors.New("scram: decoy key must be at least 32 bytes")
|
||||||
ErrSCRAMCredentialProfile = errors.New("scram: credentials must share Argon2 parameters and salt length")
|
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
|
// Credential import/export errors
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"crypto/hmac"
|
"crypto/hmac"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
@@ -34,6 +35,8 @@ const (
|
|||||||
MaxUsernameLen = 256
|
MaxUsernameLen = 256
|
||||||
MaxClientNonceLen = 256
|
MaxClientNonceLen = 256
|
||||||
MaxFullNonceLen = 512
|
MaxFullNonceLen = 512
|
||||||
|
// MaxChannelBindingLen fits a SHA-512 certificate hash or an RFC 9266 exporter
|
||||||
|
MaxChannelBindingLen = 64
|
||||||
)
|
)
|
||||||
|
|
||||||
// Credential stores SCRAM authentication data
|
// Credential stores SCRAM authentication data
|
||||||
@@ -442,8 +445,13 @@ func (s *ScramServer) ProcessClientFirstMessage(username, clientNonce string) (S
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProcessClientFinalMessage verifies client proof
|
// ProcessClientFinalMessage verifies client proof. A channel binding, when
|
||||||
func (s *ScramServer) ProcessClientFinalMessage(fullNonce, clientProof string) (ServerFinalMessage, error) {
|
// 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) {
|
if !validNonce(fullNonce, MaxFullNonceLen) {
|
||||||
return ServerFinalMessage{}, ErrSCRAMInvalidNonce
|
return ServerFinalMessage{}, ErrSCRAMInvalidNonce
|
||||||
}
|
}
|
||||||
@@ -494,7 +502,7 @@ func (s *ScramServer) ProcessClientFinalMessage(fullNonce, clientProof string) (
|
|||||||
ArgonMemory: state.Credential.ArgonMemory,
|
ArgonMemory: state.Credential.ArgonMemory,
|
||||||
ArgonThreads: state.Credential.ArgonThreads,
|
ArgonThreads: state.Credential.ArgonThreads,
|
||||||
}
|
}
|
||||||
clientFinalBare := fmt.Sprintf("r=%s", fullNonce)
|
clientFinalBare := binding + "r=" + fullNonce
|
||||||
authMessage := clientFirstBare + "," + serverFirst.Marshal() + "," + clientFinalBare
|
authMessage := clientFirstBare + "," + serverFirst.Marshal() + "," + clientFinalBare
|
||||||
|
|
||||||
// Compute client signature
|
// Compute client signature
|
||||||
@@ -662,13 +670,19 @@ func (c *ScramClient) StartAuthentication() (ClientFirstRequest, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProcessServerFirstMessage handles server challenge
|
// ProcessServerFirstMessage handles server challenge. Pass the same channel
|
||||||
func (c *ScramClient) ProcessServerFirstMessage(msg ServerFirstMessage) (result ClientFinalRequest, err error) {
|
// 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() {
|
defer func() {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Reset()
|
c.Reset()
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
binding, err := exchangeBinding(opts)
|
||||||
|
if err != nil {
|
||||||
|
return ClientFinalRequest{}, err
|
||||||
|
}
|
||||||
if c.state != scramClientAwaitChallenge {
|
if c.state != scramClientAwaitChallenge {
|
||||||
return ClientFinalRequest{}, ErrSCRAMInvalidState
|
return ClientFinalRequest{}, ErrSCRAMInvalidState
|
||||||
}
|
}
|
||||||
@@ -707,7 +721,7 @@ func (c *ScramClient) ProcessServerFirstMessage(msg ServerFirstMessage) (result
|
|||||||
|
|
||||||
// Build auth message
|
// Build auth message
|
||||||
clientFirstBare := fmt.Sprintf("u=%s,n=%s", c.handshakeUsername, c.clientNonce)
|
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
|
c.authMessage = clientFirstBare + "," + msg.Marshal() + "," + clientFinalBare
|
||||||
|
|
||||||
// Compute client proof
|
// Compute client proof
|
||||||
@@ -769,6 +783,43 @@ func (c *ScramClient) Reset() {
|
|||||||
c.startTime = time.Time{}
|
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
|
// SCRAM message types
|
||||||
type ClientFirstRequest struct {
|
type ClientFirstRequest struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
|
|||||||
+9
-5
@@ -8,16 +8,20 @@ import (
|
|||||||
func FuzzScramServerMessages(f *testing.F) {
|
func FuzzScramServerMessages(f *testing.F) {
|
||||||
s := NewScramServer()
|
s := NewScramServer()
|
||||||
f.Cleanup(s.Stop)
|
f.Cleanup(s.Stop)
|
||||||
f.Add("unknown", "nonce", base64.StdEncoding.EncodeToString(make([]byte, 32)))
|
f.Add("unknown", "nonce", base64.StdEncoding.EncodeToString(make([]byte, 32)), []byte(nil))
|
||||||
f.Add("", "", "")
|
f.Add("", "", "", []byte{})
|
||||||
f.Add("a,n=b", "nonce", "!!!")
|
f.Add("a,n=b", "nonce", "!!!", make([]byte, 32))
|
||||||
f.Fuzz(func(t *testing.T, username, nonce, proof string) {
|
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)
|
first, err := s.ProcessClientFirstMessage(username, nonce)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// No credential is registered, so even syntactically valid proofs fail.
|
// 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 {
|
if err == nil {
|
||||||
t.Fatal("unknown account authenticated")
|
t.Fatal("unknown account authenticated")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,6 +195,57 @@ func TestScramProofBinding(t *testing.T) {
|
|||||||
noErr(t, err, "handshake b unaffected")
|
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) {
|
func TestScramProofEncoding(t *testing.T) {
|
||||||
s, user, pw, _ := setupScram(t)
|
s, user, pw, _ := setupScram(t)
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user