v0.4.0 harden jwt, scram, credential handling, remove legacy adapters
test / test (push) Canceled after 0s
test / test (push) Canceled after 0s
This commit is contained in:
1 parent
b28e30ee2e
commit
2bf8715e93
21 files changed
+1562
-687
No files matched your search
@@ -6,11 +6,16 @@ import (
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
@@ -26,6 +31,9 @@ const (
|
||||
// not compute: per client-first server cost is one HMAC. Rate limiting
|
||||
// upstream remains the control for connection floods.
|
||||
ScramMaxHandshakes = 4096
|
||||
MaxUsernameLen = 256
|
||||
MaxClientNonceLen = 256
|
||||
MaxFullNonceLen = 512
|
||||
)
|
||||
|
||||
// Credential stores SCRAM authentication data
|
||||
@@ -63,80 +71,40 @@ func ImportCredential(data map[string]any) (*Credential, error) {
|
||||
if !ok {
|
||||
return nil, ErrCredMissingSalt
|
||||
}
|
||||
salt, err := base64.StdEncoding.DecodeString(saltStr)
|
||||
salt, err := decodeBase64(saltStr, base64.StdEncoding, MaxArgonSaltLen)
|
||||
if err != nil {
|
||||
return nil, ErrCredInvalidSalt
|
||||
}
|
||||
|
||||
// Handle both float64 (from JSON) and int types
|
||||
getUint32 := func(key string) (uint32, error) {
|
||||
val, ok := data[key]
|
||||
getNumber := func(key string, bits int, missing error) (uint64, error) {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
switch key {
|
||||
case "argon_time":
|
||||
return 0, ErrCredMissingTime
|
||||
case "argon_memory":
|
||||
return 0, ErrCredMissingMemory
|
||||
default:
|
||||
return 0, fmt.Errorf("missing %s", key)
|
||||
}
|
||||
return 0, missing
|
||||
}
|
||||
switch v := val.(type) {
|
||||
case float64:
|
||||
// out-of-range float→int conversion is undefined in Go
|
||||
if v < 0 || v > math.MaxUint32 || v != math.Trunc(v) {
|
||||
return 0, fmt.Errorf("%w: %s", ErrCredInvalidType, key)
|
||||
}
|
||||
return uint32(v), nil
|
||||
case int:
|
||||
if v < 0 || int64(v) > math.MaxUint32 {
|
||||
return 0, fmt.Errorf("%w: %s", ErrCredInvalidType, key)
|
||||
}
|
||||
return uint32(v), nil
|
||||
case uint32:
|
||||
return v, nil
|
||||
default:
|
||||
n, err := credentialNumber(v, bits)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%w: %s", ErrCredInvalidType, key)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
argonTime, err := getUint32("argon_time")
|
||||
t, err := getNumber("argon_time", 32, ErrCredMissingTime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
argonMemory, err := getUint32("argon_memory")
|
||||
m, err := getNumber("argon_memory", 32, ErrCredMissingMemory)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
threadsVal, ok := data["argon_threads"]
|
||||
if !ok {
|
||||
return nil, ErrCredMissingThreads
|
||||
}
|
||||
var argonThreads uint8
|
||||
switch v := threadsVal.(type) {
|
||||
case float64:
|
||||
if v < 0 || v > math.MaxUint8 || v != math.Trunc(v) {
|
||||
return nil, fmt.Errorf("%w: argon_threads", ErrCredInvalidType)
|
||||
}
|
||||
argonThreads = uint8(v)
|
||||
case int:
|
||||
if v < 0 || v > math.MaxUint8 {
|
||||
return nil, fmt.Errorf("%w: argon_threads", ErrCredInvalidType)
|
||||
}
|
||||
argonThreads = uint8(v)
|
||||
case uint8:
|
||||
argonThreads = v
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: argon_threads", ErrCredInvalidType)
|
||||
p, err := getNumber("argon_threads", 8, ErrCredMissingThreads)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
storedKeyStr, ok := data["stored_key"].(string)
|
||||
if !ok {
|
||||
return nil, ErrCredMissingStoredKey
|
||||
}
|
||||
storedKey, err := base64.StdEncoding.DecodeString(storedKeyStr)
|
||||
storedKey, err := decodeBase64(storedKeyStr, base64.StdEncoding, sha256.Size)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrCredInvalidStoredKey, err)
|
||||
}
|
||||
@@ -145,54 +113,164 @@ func ImportCredential(data map[string]any) (*Credential, error) {
|
||||
if !ok {
|
||||
return nil, ErrCredMissingServerKey
|
||||
}
|
||||
serverKey, err := base64.StdEncoding.DecodeString(serverKeyStr)
|
||||
serverKey, err := decodeBase64(serverKeyStr, base64.StdEncoding, sha256.Size)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrCredInvalidServerKey, err)
|
||||
}
|
||||
|
||||
// Post-decode validation
|
||||
if argonTime == 0 || argonMemory == 0 || argonThreads == 0 {
|
||||
return nil, ErrSCRAMZeroParams
|
||||
c := &Credential{
|
||||
Username: username, Salt: salt,
|
||||
ArgonTime: uint32(t), ArgonMemory: uint32(m), ArgonThreads: uint8(p),
|
||||
StoredKey: storedKey, ServerKey: serverKey,
|
||||
}
|
||||
if err := checkArgonCost(argonMemory, argonTime, argonThreads); err != nil {
|
||||
return nil, ErrSCRAMParamsTooLarge
|
||||
}
|
||||
if len(salt) < 16 {
|
||||
return nil, ErrSCRAMSaltTooShort
|
||||
}
|
||||
if len(storedKey) != sha256.Size {
|
||||
return nil, ErrCredInvalidStoredKey
|
||||
}
|
||||
if len(serverKey) != sha256.Size {
|
||||
return nil, ErrCredInvalidServerKey
|
||||
if err := c.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
return &Credential{
|
||||
Username: username,
|
||||
Salt: salt,
|
||||
ArgonTime: argonTime,
|
||||
ArgonMemory: argonMemory,
|
||||
ArgonThreads: argonThreads,
|
||||
StoredKey: storedKey,
|
||||
ServerKey: serverKey,
|
||||
}, nil
|
||||
func credentialNumber(value any, bits int) (uint64, error) {
|
||||
var n uint64
|
||||
switch v := value.(type) {
|
||||
case json.Number:
|
||||
return strconv.ParseUint(string(v), 10, bits)
|
||||
case float64:
|
||||
if math.IsNaN(v) || v < 0 || v > math.MaxUint32 || v != math.Trunc(v) {
|
||||
return 0, ErrCredInvalidType
|
||||
}
|
||||
n = uint64(v)
|
||||
case int:
|
||||
if v < 0 {
|
||||
return 0, ErrCredInvalidType
|
||||
}
|
||||
n = uint64(v)
|
||||
case int64:
|
||||
if v < 0 {
|
||||
return 0, ErrCredInvalidType
|
||||
}
|
||||
n = uint64(v)
|
||||
case uint:
|
||||
n = uint64(v)
|
||||
case uint64:
|
||||
n = v
|
||||
case uint32:
|
||||
n = uint64(v)
|
||||
case uint8:
|
||||
n = uint64(v)
|
||||
default:
|
||||
return 0, ErrCredInvalidType
|
||||
}
|
||||
if n > (uint64(1)<<bits)-1 {
|
||||
return 0, ErrCredInvalidType
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Validate checks a credential before persistence or registration. Credentials
|
||||
// use one deployment-wide KDF profile to avoid revealing account existence.
|
||||
func (c *Credential) Validate() error {
|
||||
if c == nil {
|
||||
return ErrInvalidCredentials
|
||||
}
|
||||
if err := validateUsername(c.Username); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSalt(c.Salt); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkScramCost(c.ArgonMemory, c.ArgonTime, c.ArgonThreads); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(c.StoredKey) != sha256.Size {
|
||||
return ErrCredInvalidStoredKey
|
||||
}
|
||||
if len(c.ServerKey) != sha256.Size {
|
||||
return ErrCredInvalidServerKey
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateUsername(username string) error {
|
||||
if username == "" || len(username) > MaxUsernameLen || !utf8.ValidString(username) {
|
||||
return ErrSCRAMInvalidUsername
|
||||
}
|
||||
for _, r := range username {
|
||||
if r == ',' || r == '=' || unicode.IsControl(r) {
|
||||
return ErrSCRAMInvalidUsername
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validNonce(nonce string, maxLen int) bool {
|
||||
if len(nonce) == 0 || len(nonce) > maxLen {
|
||||
return false
|
||||
}
|
||||
for i := range nonce {
|
||||
if nonce[i] < 0x21 || nonce[i] > 0x7e || nonce[i] == ',' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validateSalt(salt []byte) error {
|
||||
if len(salt) < DefaultArgonSaltLen {
|
||||
return ErrSCRAMSaltTooShort
|
||||
}
|
||||
if len(salt) > MaxArgonSaltLen {
|
||||
return ErrSCRAMSaltTooLong
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkScramCost(memory, time uint32, threads uint8) error {
|
||||
if time == 0 || threads == 0 || memory < 8*uint32(threads) {
|
||||
return ErrSCRAMZeroParams
|
||||
}
|
||||
if err := checkArgonCost(memory, time, threads); err != nil {
|
||||
return ErrSCRAMParamsTooLarge
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewCredential provisions a credential with a fresh random salt and one KDF
|
||||
// pass. The same options and password policy as HashPassword apply.
|
||||
func NewCredential(username, password string, opts ...Option) (*Credential, error) {
|
||||
if err := validateUsername(username); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(password) < 8 {
|
||||
return nil, ErrWeakPassword
|
||||
}
|
||||
params := &argonParams{time: DefaultArgonTime, memory: DefaultArgonMemory, threads: DefaultArgonThreads}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(params)
|
||||
}
|
||||
}
|
||||
salt := make([]byte, DefaultArgonSaltLen)
|
||||
rand.Read(salt)
|
||||
return DeriveCredential(username, password, salt, params.time, params.memory, params.threads)
|
||||
}
|
||||
|
||||
// DeriveCredential creates SCRAM credential from password
|
||||
func DeriveCredential(username, password string, salt []byte, time, memory uint32, threads uint8) (*Credential, error) {
|
||||
if len(salt) < 16 {
|
||||
return nil, ErrSCRAMSaltTooShort
|
||||
if err := validateUsername(username); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if time == 0 || memory == 0 || threads == 0 {
|
||||
return nil, ErrSCRAMZeroParams
|
||||
if err := validateSalt(salt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := checkScramCost(memory, time, threads); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(password) > MaxPasswordLen {
|
||||
return nil, ErrPasswordTooLong
|
||||
}
|
||||
|
||||
saltedPassword := argon2.IDKey([]byte(password), salt, time, memory, threads, DefaultArgonKeyLen)
|
||||
defer clear(saltedPassword)
|
||||
return credentialFromSaltedPassword(username, saltedPassword, salt, time, memory, threads), nil
|
||||
}
|
||||
|
||||
@@ -217,26 +295,35 @@ type ScramServer struct {
|
||||
cleanupTicker *time.Ticker
|
||||
cleanupStop chan struct{}
|
||||
stopOnce sync.Once
|
||||
stopped bool
|
||||
}
|
||||
|
||||
// NewScramServer creates SCRAM server
|
||||
// NewScramServer uses a random per-instance decoy key. For restarts or replicas,
|
||||
// use NewScramServerWithDecoyKey with a stable, shared, secret key.
|
||||
func NewScramServer() *ScramServer {
|
||||
decoyKey := make([]byte, 32)
|
||||
rand.Read(decoyKey)
|
||||
s := &ScramServer{
|
||||
credentials: make(map[string]*Credential),
|
||||
handshakes: make(map[string]*HandshakeState),
|
||||
decoyKey: decoyKey,
|
||||
cleanupTicker: time.NewTicker(ScramCleanupInterval),
|
||||
cleanupStop: make(chan struct{}),
|
||||
}
|
||||
|
||||
go s.cleanupLoop()
|
||||
|
||||
key := make([]byte, 32)
|
||||
rand.Read(key)
|
||||
s, _ := NewScramServerWithDecoyKey(key)
|
||||
clear(key)
|
||||
return s
|
||||
}
|
||||
|
||||
// decoySalt generates stable decoy salt; indistinguishable across repeated probes
|
||||
// NewScramServerWithDecoyKey keeps unknown-user salts stable across restarts and
|
||||
// replicas. Persist a cryptographically random key separately from credentials.
|
||||
func NewScramServerWithDecoyKey(key []byte) (*ScramServer, error) {
|
||||
if len(key) < 32 {
|
||||
return nil, ErrSCRAMDecoyKey
|
||||
}
|
||||
s := &ScramServer{
|
||||
credentials: make(map[string]*Credential), handshakes: make(map[string]*HandshakeState),
|
||||
decoyKey: append([]byte(nil), key...),
|
||||
cleanupTicker: time.NewTicker(ScramCleanupInterval), cleanupStop: make(chan struct{}),
|
||||
}
|
||||
go s.cleanupLoop()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// decoySalt generates a stable, username-specific decoy salt.
|
||||
func (s *ScramServer) decoySalt(username string) []byte {
|
||||
n := len(s.decoyTemplate.Salt)
|
||||
if n < 16 {
|
||||
@@ -244,7 +331,7 @@ func (s *ScramServer) decoySalt(username string) []byte {
|
||||
}
|
||||
out := make([]byte, 0, n)
|
||||
for i := 0; len(out) < n; i++ {
|
||||
out = append(out, computeHMAC(s.decoyKey, fmt.Appendf(nil, "%s|%d", username, i))...)
|
||||
out = append(out, computeHMAC(s.decoyKey, fmt.Appendf(nil, "auth/scram/decoy|%s|%d", username, i))...)
|
||||
}
|
||||
return out[:n]
|
||||
}
|
||||
@@ -252,6 +339,12 @@ func (s *ScramServer) decoySalt(username string) []byte {
|
||||
// Stop gracefully shuts down the server and cleanup goroutine
|
||||
func (s *ScramServer) Stop() {
|
||||
s.stopOnce.Do(func() {
|
||||
s.mu.Lock()
|
||||
s.stopped = true
|
||||
clear(s.handshakes)
|
||||
clear(s.credentials)
|
||||
clear(s.decoyKey)
|
||||
s.mu.Unlock()
|
||||
close(s.cleanupStop)
|
||||
s.cleanupTicker.Stop()
|
||||
})
|
||||
@@ -298,9 +391,19 @@ func (s *ScramServer) evictExpiredLocked() {
|
||||
// reached; the cap is applied before credential lookup so the rejection path is
|
||||
// identical for known and unknown users.
|
||||
func (s *ScramServer) ProcessClientFirstMessage(username, clientNonce string) (ServerFirstMessage, error) {
|
||||
if err := validateUsername(username); err != nil {
|
||||
return ServerFirstMessage{}, err
|
||||
}
|
||||
if !validNonce(clientNonce, MaxClientNonceLen) {
|
||||
return ServerFirstMessage{}, ErrSCRAMInvalidNonce
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.stopped {
|
||||
return ServerFirstMessage{}, ErrSCRAMStopped
|
||||
}
|
||||
|
||||
// opportunistic sweep, then hard cap. Applied before the credential
|
||||
// lookup so the rejection path is identical for known and unknown users.
|
||||
if len(s.handshakes) >= ScramMaxHandshakes {
|
||||
@@ -314,54 +417,42 @@ func (s *ScramServer) ProcessClientFirstMessage(username, clientNonce string) (S
|
||||
serverNonce := rand.Text()
|
||||
fullNonce := clientNonce + serverNonce
|
||||
|
||||
// Check if user exists
|
||||
cred, exists := s.credentials[username]
|
||||
if !exists {
|
||||
t := s.decoyTemplate // mirror real parameter shape
|
||||
if t.ArgonTime == 0 {
|
||||
t.ArgonTime, t.ArgonMemory, t.ArgonThreads = DefaultArgonTime, DefaultArgonMemory, DefaultArgonThreads
|
||||
}
|
||||
// Deterministic salt + stored decoy handshake so the final
|
||||
// step fails with ErrInvalidCredentials, matching the wrong-password path.
|
||||
decoy := &Credential{
|
||||
Username: username,
|
||||
Salt: s.decoySalt(username),
|
||||
ArgonTime: t.ArgonTime,
|
||||
ArgonMemory: t.ArgonMemory,
|
||||
ArgonThreads: t.ArgonThreads,
|
||||
StoredKey: make([]byte, sha256.Size), // never matches a real proof
|
||||
ServerKey: make([]byte, sha256.Size),
|
||||
}
|
||||
s.handshakes[fullNonce] = &HandshakeState{
|
||||
Username: username, ClientNonce: clientNonce, ServerNonce: serverNonce,
|
||||
FullNonce: fullNonce, Credential: decoy, CreatedAt: time.Now(),
|
||||
}
|
||||
return ServerFirstMessage{
|
||||
FullNonce: fullNonce,
|
||||
Salt: base64.StdEncoding.EncodeToString(decoy.Salt),
|
||||
ArgonTime: decoy.ArgonTime,
|
||||
ArgonMemory: decoy.ArgonMemory,
|
||||
ArgonThreads: decoy.ArgonThreads,
|
||||
}, nil // No early error → same control flow as valid user
|
||||
// Do the decoy work for known users as well, reducing first-message timing
|
||||
// differences. Uniform response profiles and application rate limits remain
|
||||
// necessary; this is not a constant-time network service.
|
||||
t := s.decoyTemplate
|
||||
if t.ArgonTime == 0 {
|
||||
t.ArgonTime, t.ArgonMemory, t.ArgonThreads = DefaultArgonTime, DefaultArgonMemory, DefaultArgonThreads
|
||||
}
|
||||
cred := &Credential{
|
||||
Username: username, Salt: s.decoySalt(username),
|
||||
ArgonTime: t.ArgonTime, ArgonMemory: t.ArgonMemory, ArgonThreads: t.ArgonThreads,
|
||||
StoredKey: make([]byte, sha256.Size), ServerKey: make([]byte, sha256.Size),
|
||||
}
|
||||
if registered, exists := s.credentials[username]; exists {
|
||||
cred = registered
|
||||
}
|
||||
|
||||
s.handshakes[fullNonce] = &HandshakeState{
|
||||
Username: username, ClientNonce: clientNonce, ServerNonce: serverNonce,
|
||||
FullNonce: fullNonce, Credential: cred, CreatedAt: time.Now(),
|
||||
}
|
||||
return ServerFirstMessage{
|
||||
FullNonce: fullNonce,
|
||||
Salt: base64.StdEncoding.EncodeToString(cred.Salt),
|
||||
ArgonTime: cred.ArgonTime,
|
||||
ArgonMemory: cred.ArgonMemory,
|
||||
ArgonThreads: cred.ArgonThreads,
|
||||
FullNonce: fullNonce, Salt: base64.StdEncoding.EncodeToString(cred.Salt),
|
||||
ArgonTime: cred.ArgonTime, ArgonMemory: cred.ArgonMemory, ArgonThreads: cred.ArgonThreads,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ProcessClientFinalMessage verifies client proof
|
||||
func (s *ScramServer) ProcessClientFinalMessage(fullNonce, clientProof string) (ServerFinalMessage, error) {
|
||||
// ookup + CAS under one write lock; closes the sweep race
|
||||
if !validNonce(fullNonce, MaxFullNonceLen) {
|
||||
return ServerFinalMessage{}, ErrSCRAMInvalidNonce
|
||||
}
|
||||
// Lookup + CAS under one write lock; closes the sweep race
|
||||
s.mu.Lock()
|
||||
if s.stopped {
|
||||
s.mu.Unlock()
|
||||
return ServerFinalMessage{}, ErrSCRAMStopped
|
||||
}
|
||||
state, exists := s.handshakes[fullNonce]
|
||||
if !exists {
|
||||
s.mu.Unlock()
|
||||
@@ -374,8 +465,7 @@ func (s *ScramServer) ProcessClientFinalMessage(fullNonce, clientProof string) (
|
||||
}
|
||||
|
||||
defer func() {
|
||||
state.verifying.Store(0)
|
||||
// Safe to delete after verification completes
|
||||
// Keep the claim set until deletion: resetting it first permits replay.
|
||||
s.mu.Lock()
|
||||
delete(s.handshakes, fullNonce)
|
||||
s.mu.Unlock()
|
||||
@@ -387,7 +477,10 @@ func (s *ScramServer) ProcessClientFinalMessage(fullNonce, clientProof string) (
|
||||
}
|
||||
|
||||
// Decode client proof
|
||||
clientProofBytes, err := base64.StdEncoding.DecodeString(clientProof)
|
||||
if len(clientProof) > base64.StdEncoding.EncodedLen(sha256.Size) {
|
||||
return ServerFinalMessage{}, ErrSCRAMInvalidProofLen
|
||||
}
|
||||
clientProofBytes, err := decodeBase64(clientProof, base64.StdEncoding, sha256.Size+1)
|
||||
if err != nil {
|
||||
return ServerFinalMessage{}, ErrSCRAMInvalidProof
|
||||
}
|
||||
@@ -412,6 +505,7 @@ func (s *ScramServer) ProcessClientFinalMessage(fullNonce, clientProof string) (
|
||||
return ServerFinalMessage{}, ErrSCRAMInvalidProofLen
|
||||
}
|
||||
clientKey := make([]byte, len(clientProofBytes))
|
||||
defer clear(clientKey)
|
||||
subtle.XORBytes(clientKey, clientProofBytes, clientSignature)
|
||||
|
||||
// Verify by computing StoredKey
|
||||
@@ -420,6 +514,18 @@ func (s *ScramServer) ProcessClientFinalMessage(fullNonce, clientProof string) (
|
||||
return ServerFinalMessage{}, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
// Linearize successful authentication against credential replacement/removal.
|
||||
s.mu.RLock()
|
||||
current := s.credentials[state.Username]
|
||||
stopped := s.stopped
|
||||
s.mu.RUnlock()
|
||||
if stopped {
|
||||
return ServerFinalMessage{}, ErrSCRAMStopped
|
||||
}
|
||||
if current != state.Credential {
|
||||
return ServerFinalMessage{}, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
// Generate server signature for mutual auth
|
||||
serverSignature := computeHMAC(state.Credential.ServerKey, []byte(authMessage))
|
||||
|
||||
@@ -429,46 +535,123 @@ func (s *ScramServer) ProcessClientFinalMessage(fullNonce, clientProof string) (
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AddCredential registers user credential
|
||||
func (s *ScramServer) AddCredential(cred *Credential) {
|
||||
// AddCredential validates and copies a credential. Replacing one invalidates
|
||||
// that user's pending handshakes. The caller may reuse its slices after return.
|
||||
func (s *ScramServer) AddCredential(cred *Credential) error {
|
||||
if err := cred.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
copy := *cred
|
||||
copy.Salt = append([]byte(nil), cred.Salt...)
|
||||
copy.StoredKey = append([]byte(nil), cred.StoredKey...)
|
||||
copy.ServerKey = append([]byte(nil), cred.ServerKey...)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.credentials[cred.Username] = cred
|
||||
s.decoyTemplate = Credential{
|
||||
Salt: make([]byte, len(cred.Salt)),
|
||||
ArgonTime: cred.ArgonTime,
|
||||
ArgonMemory: cred.ArgonMemory,
|
||||
ArgonThreads: cred.ArgonThreads,
|
||||
if s.stopped {
|
||||
return ErrSCRAMStopped
|
||||
}
|
||||
t := s.decoyTemplate
|
||||
if t.ArgonTime != 0 && (t.ArgonTime != copy.ArgonTime || t.ArgonMemory != copy.ArgonMemory || t.ArgonThreads != copy.ArgonThreads || len(t.Salt) != len(copy.Salt)) {
|
||||
return ErrSCRAMCredentialProfile
|
||||
}
|
||||
s.invalidateHandshakesLocked(copy.Username)
|
||||
s.credentials[copy.Username] = ©
|
||||
if t.ArgonTime == 0 {
|
||||
s.decoyTemplate = Credential{
|
||||
Salt: make([]byte, len(copy.Salt)), ArgonTime: copy.ArgonTime,
|
||||
ArgonMemory: copy.ArgonMemory, ArgonThreads: copy.ArgonThreads,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveCredential revokes future authentication and all pending handshakes.
|
||||
// Already issued application sessions/tokens must be revoked by the application.
|
||||
func (s *ScramServer) RemoveCredential(username string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.credentials, username)
|
||||
s.invalidateHandshakesLocked(username)
|
||||
}
|
||||
|
||||
func (s *ScramServer) invalidateHandshakesLocked(username string) {
|
||||
for nonce, state := range s.handshakes {
|
||||
if state.Username == username {
|
||||
delete(s.handshakes, nonce)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ScramClient handles client-side SCRAM authentication
|
||||
type scramClientState uint8
|
||||
|
||||
const (
|
||||
scramClientIdle scramClientState = iota
|
||||
scramClientAwaitChallenge
|
||||
scramClientAwaitFinal
|
||||
)
|
||||
|
||||
// ScramClient handles one authentication exchange at a time. It is not safe for
|
||||
// concurrent use; use a separate client per connection.
|
||||
type ScramClient struct {
|
||||
Username string
|
||||
Password string
|
||||
clientNonce string
|
||||
serverFirst *ServerFirstMessage
|
||||
authMessage string
|
||||
serverKey []byte
|
||||
startTime time.Time // Track handshake start
|
||||
Username string
|
||||
Password string
|
||||
clientNonce string
|
||||
state scramClientState
|
||||
handshakeUsername string
|
||||
authMessage string
|
||||
serverKey []byte
|
||||
startTime time.Time // Track handshake start
|
||||
minArgonTime uint32
|
||||
minArgonMemory uint32
|
||||
}
|
||||
|
||||
// NewScramClient creates SCRAM client
|
||||
func NewScramClient(username, password string) *ScramClient {
|
||||
return &ScramClient{
|
||||
Username: username,
|
||||
Password: password,
|
||||
// ScramClientOption configures client policy at construction time.
|
||||
type ScramClientOption func(*ScramClient)
|
||||
|
||||
// WithMinArgonCost sets the minimum accepted iterations and memory (KiB).
|
||||
// Configure from trusted deployment policy, never from a server challenge.
|
||||
// The default is DefaultArgonTime and DefaultArgonMemory. StartAuthentication
|
||||
// rejects invalid configuration; reducing these values weakens password safety.
|
||||
func WithMinArgonCost(time, memory uint32) ScramClientOption {
|
||||
return func(c *ScramClient) {
|
||||
c.minArgonTime, c.minArgonMemory = time, memory
|
||||
}
|
||||
}
|
||||
|
||||
// NewScramClient creates a client with a minimum KDF cost matching the package's
|
||||
// production defaults. Override only for a trusted, explicitly chosen profile.
|
||||
func NewScramClient(username, password string, opts ...ScramClientOption) *ScramClient {
|
||||
c := &ScramClient{
|
||||
Username: username,
|
||||
Password: password,
|
||||
minArgonTime: DefaultArgonTime,
|
||||
minArgonMemory: DefaultArgonMemory,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(c)
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// StartAuthentication generates initial client message
|
||||
func (c *ScramClient) StartAuthentication() (ClientFirstRequest, error) {
|
||||
c.Reset()
|
||||
if err := checkScramCost(c.minArgonMemory, c.minArgonTime, 1); err != nil {
|
||||
return ClientFirstRequest{}, err
|
||||
}
|
||||
if err := validateUsername(c.Username); err != nil {
|
||||
return ClientFirstRequest{}, err
|
||||
}
|
||||
// Reject oversized password before the handshake commits to a KDF pass
|
||||
if len(c.Password) > MaxPasswordLen {
|
||||
return ClientFirstRequest{}, ErrPasswordTooLong
|
||||
}
|
||||
|
||||
c.startTime = time.Now()
|
||||
c.state = scramClientAwaitChallenge
|
||||
c.handshakeUsername = c.Username
|
||||
|
||||
// Generate client nonce
|
||||
c.clientNonce = rand.Text()
|
||||
@@ -480,39 +663,50 @@ func (c *ScramClient) StartAuthentication() (ClientFirstRequest, error) {
|
||||
}
|
||||
|
||||
// ProcessServerFirstMessage handles server challenge
|
||||
func (c *ScramClient) ProcessServerFirstMessage(msg ServerFirstMessage) (ClientFinalRequest, error) {
|
||||
// Check timeout
|
||||
if !c.startTime.IsZero() && time.Since(c.startTime) > ScramHandshakeTimeout {
|
||||
func (c *ScramClient) ProcessServerFirstMessage(msg ServerFirstMessage) (result ClientFinalRequest, err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
c.Reset()
|
||||
}
|
||||
}()
|
||||
if c.state != scramClientAwaitChallenge {
|
||||
return ClientFinalRequest{}, ErrSCRAMInvalidState
|
||||
}
|
||||
if time.Since(c.startTime) > ScramHandshakeTimeout {
|
||||
return ClientFinalRequest{}, ErrSCRAMTimeout
|
||||
}
|
||||
|
||||
c.serverFirst = &msg
|
||||
|
||||
// Decode salt
|
||||
salt, err := base64.StdEncoding.DecodeString(msg.Salt)
|
||||
if !validNonce(msg.FullNonce, MaxFullNonceLen) || !strings.HasPrefix(msg.FullNonce, c.clientNonce) || len(msg.FullNonce) <= len(c.clientNonce) {
|
||||
return ClientFinalRequest{}, ErrSCRAMInvalidNonce
|
||||
}
|
||||
// Password is exported for compatibility; check it again immediately before KDF.
|
||||
if len(c.Password) > MaxPasswordLen {
|
||||
return ClientFinalRequest{}, ErrPasswordTooLong
|
||||
}
|
||||
salt, err := decodeBase64(msg.Salt, base64.StdEncoding, MaxArgonSaltLen)
|
||||
if err != nil {
|
||||
return ClientFinalRequest{}, ErrSCRAMInvalidSalt
|
||||
}
|
||||
|
||||
// Validate parameters
|
||||
if msg.ArgonTime == 0 || msg.ArgonMemory == 0 || msg.ArgonThreads == 0 {
|
||||
return ClientFinalRequest{}, ErrSCRAMZeroParams
|
||||
if err := validateSalt(salt); err != nil {
|
||||
return ClientFinalRequest{}, err
|
||||
}
|
||||
// The peer chooses these values. Unbounded, they are a remote OOM
|
||||
// against every client that talks to a hostile or compromised server.
|
||||
if err := checkArgonCost(msg.ArgonMemory, msg.ArgonTime, msg.ArgonThreads); err != nil {
|
||||
return ClientFinalRequest{}, ErrSCRAMParamsTooLarge
|
||||
if err := checkScramCost(msg.ArgonMemory, msg.ArgonTime, msg.ArgonThreads); err != nil {
|
||||
return ClientFinalRequest{}, err
|
||||
}
|
||||
if msg.ArgonTime < c.minArgonTime || msg.ArgonMemory < c.minArgonMemory {
|
||||
return ClientFinalRequest{}, ErrSCRAMParamsTooSmall
|
||||
}
|
||||
|
||||
// Derive keys using Argon2id
|
||||
saltedPassword := argon2.IDKey([]byte(c.Password), salt, msg.ArgonTime, msg.ArgonMemory, msg.ArgonThreads, 32)
|
||||
defer clear(saltedPassword)
|
||||
|
||||
clientKey := computeHMAC(saltedPassword, []byte("Client Key"))
|
||||
defer clear(clientKey)
|
||||
serverKey := computeHMAC(saltedPassword, []byte("Server Key"))
|
||||
storedKey := sha256.Sum256(clientKey)
|
||||
|
||||
// Build auth message
|
||||
clientFirstBare := fmt.Sprintf("u=%s,n=%s", c.Username, c.clientNonce)
|
||||
clientFirstBare := fmt.Sprintf("u=%s,n=%s", c.handshakeUsername, c.clientNonce)
|
||||
clientFinalBare := fmt.Sprintf("r=%s", msg.FullNonce)
|
||||
c.authMessage = clientFirstBare + "," + msg.Marshal() + "," + clientFinalBare
|
||||
|
||||
@@ -523,6 +717,10 @@ func (c *ScramClient) ProcessServerFirstMessage(msg ServerFirstMessage) (ClientF
|
||||
|
||||
// Store server key for verification
|
||||
c.serverKey = serverKey
|
||||
if time.Since(c.startTime) > ScramHandshakeTimeout {
|
||||
return ClientFinalRequest{}, ErrSCRAMTimeout
|
||||
}
|
||||
c.state = scramClientAwaitFinal
|
||||
|
||||
return ClientFinalRequest{
|
||||
FullNonce: msg.FullNonce,
|
||||
@@ -532,20 +730,22 @@ func (c *ScramClient) ProcessServerFirstMessage(msg ServerFirstMessage) (ClientF
|
||||
|
||||
// VerifyServerFinalMessage validates server signature
|
||||
func (c *ScramClient) VerifyServerFinalMessage(msg ServerFinalMessage) error {
|
||||
// Check timeout
|
||||
if !c.startTime.IsZero() && time.Since(c.startTime) > ScramHandshakeTimeout {
|
||||
defer c.Reset() // consume state on success AND failure
|
||||
if c.state != scramClientAwaitFinal {
|
||||
return ErrSCRAMInvalidState
|
||||
}
|
||||
if time.Since(c.startTime) > ScramHandshakeTimeout {
|
||||
return ErrSCRAMTimeout
|
||||
}
|
||||
|
||||
if c.authMessage == "" || c.serverKey == nil {
|
||||
return ErrSCRAMInvalidState
|
||||
if msg.Username != "" && msg.Username != c.handshakeUsername {
|
||||
return ErrSCRAMServerAuthFailed
|
||||
}
|
||||
|
||||
// Compute expected server signature
|
||||
expectedSig := computeHMAC(c.serverKey, []byte(c.authMessage))
|
||||
|
||||
// Decode received signature
|
||||
receivedSig, err := base64.StdEncoding.DecodeString(msg.ServerSignature)
|
||||
receivedSig, err := decodeBase64(msg.ServerSignature, base64.StdEncoding, sha256.Size)
|
||||
if err != nil {
|
||||
return ErrSCRAMServerAuthFailed
|
||||
}
|
||||
@@ -561,8 +761,10 @@ func (c *ScramClient) VerifyServerFinalMessage(msg ServerFinalMessage) error {
|
||||
// Reset clears client state for retry
|
||||
func (c *ScramClient) Reset() {
|
||||
c.clientNonce = ""
|
||||
c.serverFirst = nil
|
||||
c.state = scramClientIdle
|
||||
c.handshakeUsername = ""
|
||||
c.authMessage = ""
|
||||
clear(c.serverKey)
|
||||
c.serverKey = nil
|
||||
c.startTime = time.Time{}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user