This commit is contained in:
1 parent
b28e30ee2e
commit
c69c14b783
21 files changed
+1549
-619
No files matched your search
@@ -0,0 +1,313 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"math"
|
||||
"math/big"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestArgonExecutionBounds(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
memory, time uint32
|
||||
threads uint8
|
||||
want error
|
||||
}{
|
||||
{1, 1, 1, ErrArgonInvalidParams},
|
||||
{MaxVerifyArgonMemory + 1, 1, 1, ErrPHCCostTooHigh},
|
||||
{testArgonMemory, MaxVerifyArgonTime + 1, 1, ErrPHCCostTooHigh},
|
||||
{testArgonMemory, 1, MaxVerifyArgonThreads + 1, ErrPHCCostTooHigh},
|
||||
{MaxVerifyArgonMemory, MaxVerifyArgonTime, 1, ErrPHCCostTooHigh},
|
||||
{math.MaxUint32, math.MaxUint32, math.MaxUint8, ErrPHCCostTooHigh},
|
||||
} {
|
||||
_, err := HashPassword("password123", WithMemory(tc.memory), WithTime(tc.time), WithThreads(tc.threads))
|
||||
errIs(t, err, tc.want, "hash bounds")
|
||||
_, err = DeriveCredential("u", "password123", make([]byte, 16), tc.time, tc.memory, tc.threads)
|
||||
if tc.want == ErrArgonInvalidParams {
|
||||
errIs(t, err, ErrSCRAMZeroParams, "derive invalid")
|
||||
} else {
|
||||
errIs(t, err, ErrSCRAMParamsTooLarge, "derive bounds")
|
||||
}
|
||||
}
|
||||
_, err := DeriveCredential("u", "password123", make([]byte, MaxArgonSaltLen+1), 1, 8, 1)
|
||||
errIs(t, err, ErrSCRAMSaltTooLong, "derive salt bound")
|
||||
}
|
||||
|
||||
func TestNewCredentialAndOwnership(t *testing.T) {
|
||||
cred, err := NewCredential("u", "password123", cheapArgon...)
|
||||
noErr(t, err, "new")
|
||||
noErr(t, cred.Validate(), "validate")
|
||||
s := newTestServer(t)
|
||||
noErr(t, s.AddCredential(cred), "add")
|
||||
// Mutation after registration cannot change the server's credential.
|
||||
clear(cred.Salt)
|
||||
clear(cred.StoredKey)
|
||||
clear(cred.ServerKey)
|
||||
cred.Username = "changed"
|
||||
noErr(t, runHandshake(s, newTestScramClient("u", "password123")), "owned credential")
|
||||
|
||||
salt := bytes.Repeat([]byte{1}, 16)
|
||||
derived, err := DeriveCredential("u", "password123", salt, 1, testArgonMemory, 1)
|
||||
noErr(t, err, "derive")
|
||||
clear(salt)
|
||||
if bytes.Equal(derived.Salt, salt) {
|
||||
t.Fatal("derived credential aliases salt")
|
||||
}
|
||||
_, err = NewCredential("u", "short", cheapArgon...)
|
||||
errIs(t, err, ErrWeakPassword, "new credential password policy")
|
||||
}
|
||||
|
||||
func TestCredentialImportNumericTypes(t *testing.T) {
|
||||
cred, err := NewCredential("u", "password123", cheapArgon...)
|
||||
noErr(t, err, "new")
|
||||
for _, values := range [][3]any{
|
||||
{int64(1), int64(testArgonMemory), int64(1)},
|
||||
{uint64(1), uint64(testArgonMemory), uint64(1)},
|
||||
{json.Number("1"), json.Number("8192"), json.Number("1")},
|
||||
} {
|
||||
m := cred.Export()
|
||||
m["argon_time"], m["argon_memory"], m["argon_threads"] = values[0], values[1], values[2]
|
||||
_, err := ImportCredential(m)
|
||||
noErr(t, err, "import numeric types")
|
||||
}
|
||||
for _, bad := range []any{math.NaN(), math.Inf(1), math.Inf(-1), json.Number("1.5"), uint64(math.MaxUint64), int64(-1)} {
|
||||
for _, field := range []string{"argon_time", "argon_memory", "argon_threads"} {
|
||||
m := cred.Export()
|
||||
m[field] = bad
|
||||
_, err := ImportCredential(m)
|
||||
errIs(t, err, ErrCredInvalidType, "invalid number")
|
||||
}
|
||||
}
|
||||
for _, field := range []string{"salt", "stored_key", "server_key"} {
|
||||
m := cred.Export()
|
||||
m[field] = strings.Repeat("A", 10000)
|
||||
_, err := ImportCredential(m)
|
||||
hasErr(t, err, "oversized encoded field")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScramRegistrationRevocationAndStop(t *testing.T) {
|
||||
s, user, pw, cred := setupScram(t)
|
||||
errIs(t, s.AddCredential(nil), ErrInvalidCredentials, "nil credential")
|
||||
bad := *cred
|
||||
bad.StoredKey = nil
|
||||
errIs(t, s.AddCredential(&bad), ErrCredInvalidStoredKey, "missing stored key")
|
||||
bad = *cred
|
||||
bad.ArgonTime++
|
||||
errIs(t, s.AddCredential(&bad), ErrSCRAMCredentialProfile, "mixed profile")
|
||||
final := startHandshake(t, s, user, pw)
|
||||
noErr(t, s.AddCredential(cred), "replace")
|
||||
_, err := s.ProcessClientFinalMessage(final.FullNonce, final.ClientProof)
|
||||
errIs(t, err, ErrSCRAMInvalidNonce, "replacement invalidates pending proof")
|
||||
final = startHandshake(t, s, user, pw)
|
||||
s.RemoveCredential(user)
|
||||
_, err = s.ProcessClientFinalMessage(final.FullNonce, final.ClientProof)
|
||||
errIs(t, err, ErrSCRAMInvalidNonce, "removal invalidates pending proof")
|
||||
errIs(t, runHandshake(s, newTestScramClient(user, pw)), ErrInvalidCredentials, "removed user")
|
||||
noErr(t, s.AddCredential(cred), "restore")
|
||||
final = startHandshake(t, s, user, pw)
|
||||
s.Stop()
|
||||
eq(t, handshakeCount(s), 0, "stop discards pending work")
|
||||
_, err = s.ProcessClientFirstMessage(user, "nonce")
|
||||
errIs(t, err, ErrSCRAMStopped, "first after stop")
|
||||
_, err = s.ProcessClientFinalMessage(final.FullNonce, final.ClientProof)
|
||||
errIs(t, err, ErrSCRAMStopped, "final after stop")
|
||||
errIs(t, s.AddCredential(cred), ErrSCRAMStopped, "register after stop")
|
||||
}
|
||||
|
||||
func TestScramStableDecoyKey(t *testing.T) {
|
||||
key := bytes.Repeat([]byte{1}, 32)
|
||||
a, err := NewScramServerWithDecoyKey(key)
|
||||
noErr(t, err, "server a")
|
||||
defer a.Stop()
|
||||
b, err := NewScramServerWithDecoyKey(key)
|
||||
noErr(t, err, "server b")
|
||||
defer b.Stop()
|
||||
clear(key)
|
||||
first, err := a.ProcessClientFirstMessage("unknown", "a")
|
||||
noErr(t, err, "first a")
|
||||
second, err := b.ProcessClientFirstMessage("unknown", "b")
|
||||
noErr(t, err, "first b")
|
||||
eq(t, first.Salt, second.Salt, "stable salt across instances")
|
||||
_, err = NewScramServerWithDecoyKey(make([]byte, 31))
|
||||
errIs(t, err, ErrSCRAMDecoyKey, "short key")
|
||||
}
|
||||
|
||||
func TestScramInputBounds(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
for _, user := range []string{"", "a,b", "a=b", "a\x00b", string([]byte{0xff}), strings.Repeat("a", MaxUsernameLen+1)} {
|
||||
_, err := s.ProcessClientFirstMessage(user, "n")
|
||||
errIs(t, err, ErrSCRAMInvalidUsername, "username")
|
||||
_, err = newTestScramClient(user, "password123").StartAuthentication()
|
||||
errIs(t, err, ErrSCRAMInvalidUsername, "client username")
|
||||
}
|
||||
for _, nonce := range []string{"", "a,b", "a\nb", "a b", strings.Repeat("x", MaxClientNonceLen+1)} {
|
||||
_, err := s.ProcessClientFirstMessage("u", nonce)
|
||||
errIs(t, err, ErrSCRAMInvalidNonce, "nonce")
|
||||
}
|
||||
eq(t, handshakeCount(s), 0, "invalid input creates no state")
|
||||
c := newTestScramClient("u", "password123")
|
||||
first, err := c.StartAuthentication()
|
||||
noErr(t, err, "start")
|
||||
challenge, err := s.ProcessClientFirstMessage(first.Username, first.ClientNonce)
|
||||
noErr(t, err, "challenge")
|
||||
c.Password = strings.Repeat("x", MaxPasswordLen+1)
|
||||
_, err = c.ProcessServerFirstMessage(challenge)
|
||||
errIs(t, err, ErrPasswordTooLong, "mutable password rechecked")
|
||||
}
|
||||
|
||||
func TestScramConcurrentProofConsumedOnce(t *testing.T) {
|
||||
s, user, pw, _ := setupScram(t)
|
||||
final := startHandshake(t, s, user, pw)
|
||||
var successes atomic.Int32
|
||||
var wg sync.WaitGroup
|
||||
start := make(chan struct{})
|
||||
for range 64 {
|
||||
wg.Go(func() {
|
||||
<-start
|
||||
_, err := s.ProcessClientFinalMessage(final.FullNonce, final.ClientProof)
|
||||
if err == nil {
|
||||
successes.Add(1)
|
||||
} else if !errors.Is(err, ErrSCRAMVerifyInProgress) && !errors.Is(err, ErrSCRAMInvalidNonce) {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
eq(t, successes.Load(), int32(1), "only one proof may succeed")
|
||||
eq(t, handshakeCount(s), 0, "consumed proof")
|
||||
}
|
||||
|
||||
func TestJWTConfigurationOwnership(t *testing.T) {
|
||||
secret := bytes.Clone(testSecret)
|
||||
audience := []string{"vif"}
|
||||
j, err := NewJWT(secret, WithIssuer("issuer"), WithAudience(audience))
|
||||
noErr(t, err, "new")
|
||||
clear(secret)
|
||||
audience[0] = "logwisp"
|
||||
token, err := j.GenerateToken("u", nil)
|
||||
noErr(t, err, "sign")
|
||||
check, err := NewJWT(testSecret, WithIssuer("issuer"), WithAudience([]string{"vif"}))
|
||||
noErr(t, err, "check")
|
||||
_, _, err = check.ValidateToken(token)
|
||||
noErr(t, err, "owned config")
|
||||
_, _, err = j.ValidateToken(token)
|
||||
noErr(t, err, "cached parser")
|
||||
other, err := NewJWT(testSecret, WithIssuer("issuer"), WithAudience([]string{"logwisp"}))
|
||||
noErr(t, err, "other service")
|
||||
_, _, err = other.ValidateToken(token)
|
||||
errIs(t, err, ErrTokenMissingClaim, "cross-service token")
|
||||
}
|
||||
|
||||
func TestJWTBoundedCanonicalEncoding(t *testing.T) {
|
||||
j, err := NewJWT(testSecret)
|
||||
noErr(t, err, "new")
|
||||
token, err := j.GenerateToken("u", nil)
|
||||
noErr(t, err, "sign")
|
||||
// A 32-byte signature has two unused bits in its last base64url character.
|
||||
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
|
||||
last := strings.IndexByte(alphabet, token[len(token)-1])
|
||||
malleated := token[:len(token)-1] + string(alphabet[last+1])
|
||||
for _, bad := range []string{token + "\n", malleated, strings.Repeat("x", MaxTokenLen+1)} {
|
||||
_, _, err = j.ValidateToken(bad)
|
||||
hasErr(t, err, "malformed/oversized token")
|
||||
_, _, err = ValidateHS256Token(testSecret, bad)
|
||||
hasErr(t, err, "standalone malformed/oversized")
|
||||
}
|
||||
header := defaultHeader()
|
||||
header["crit"] = []string{"custom"}
|
||||
critical := signHS256(t, testSecret, header, map[string]any{"sub": "u", "exp": time.Now().Add(time.Hour).Unix()})
|
||||
_, _, err = j.ValidateToken(critical)
|
||||
hasErr(t, err, "unsupported critical extension")
|
||||
_, err = j.GenerateToken("u", map[string]any{"huge": strings.Repeat("x", MaxTokenLen)})
|
||||
errIs(t, err, ErrTokenTooLong, "oversized generated JWT")
|
||||
}
|
||||
|
||||
func TestRSAValidationAndOwnership(t *testing.T) {
|
||||
for _, key := range []*rsa.PublicKey{{}, {N: big.NewInt(3), E: 65537}, {N: testRSAKey().N, E: 2}} {
|
||||
_, err := NewJWTVerifier(key)
|
||||
hasErr(t, err, "invalid/weak public key")
|
||||
}
|
||||
for _, key := range []*rsa.PrivateKey{{}, {PublicKey: testRSAKey().PublicKey}, {PublicKey: testRSAKey().PublicKey, D: big.NewInt(1), Primes: []*big.Int{nil, nil}}} {
|
||||
_, err := NewJWTRSA(key)
|
||||
hasErr(t, err, "invalid private key")
|
||||
}
|
||||
key, err := x509.ParsePKCS1PrivateKey(x509.MarshalPKCS1PrivateKey(testRSAKey()))
|
||||
noErr(t, err, "copy")
|
||||
signer, err := NewJWTRSA(key)
|
||||
noErr(t, err, "signer")
|
||||
verifier, err := NewJWTVerifier(&key.PublicKey)
|
||||
noErr(t, err, "verifier")
|
||||
key.N.SetInt64(3)
|
||||
key.D.SetInt64(0)
|
||||
key.Primes[0].SetInt64(0)
|
||||
token, err := signer.GenerateToken("u", nil)
|
||||
noErr(t, err, "sign with copied key")
|
||||
_, _, err = verifier.ValidateToken(token)
|
||||
noErr(t, err, "verify with copied key")
|
||||
pkcs1 := pem.EncodeToMemory(&pem.Block{Type: "RSA PUBLIC KEY", Bytes: x509.MarshalPKCS1PublicKey(&testRSAKey().PublicKey)})
|
||||
fromPEM, err := NewJWTVerifierFromPEM(pkcs1)
|
||||
noErr(t, err, "PKCS1 public PEM")
|
||||
_, _, err = fromPEM.ValidateToken(token)
|
||||
noErr(t, err, "PEM verifier")
|
||||
}
|
||||
|
||||
func TestSimpleTokenZeroValueAndBounds(t *testing.T) {
|
||||
var v SimpleTokenValidator
|
||||
v.AddToken("")
|
||||
v.AddToken(strings.Repeat("x", MaxTokenLen+1))
|
||||
if v.ValidateToken("") || v.ValidateToken(strings.Repeat("x", MaxTokenLen+1)) {
|
||||
t.Fatal("invalid credential accepted")
|
||||
}
|
||||
v.AddToken("a-token")
|
||||
isTrue(t, v.ValidateToken("a-token"), "zero value initialized")
|
||||
v.RemoveToken("a-token")
|
||||
isTrue(t, !v.ValidateToken("a-token"), "revoked")
|
||||
}
|
||||
|
||||
func BenchmarkCredentialProvisioning(b *testing.B) {
|
||||
b.Run("direct", func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
if _, err := NewCredential("u", "password123", cheapArgon...); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
b.Run("via_phc", func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
hash, err := HashPassword("password123", cheapArgon...)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if _, err := MigrateFromPHC("u", "password123", hash); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestScramClientRejectsKDFDowngrade(t *testing.T) {
|
||||
c := NewScramClient("u", "password123")
|
||||
first, err := c.StartAuthentication()
|
||||
noErr(t, err, "start")
|
||||
msg := ServerFirstMessage{
|
||||
FullNonce: first.ClientNonce + "server", Salt: "AAAAAAAAAAAAAAAAAAAAAA==",
|
||||
ArgonTime: 1, ArgonMemory: 8, ArgonThreads: 1,
|
||||
}
|
||||
_, err = c.ProcessServerFirstMessage(msg)
|
||||
errIs(t, err, ErrSCRAMParamsTooSmall, "hostile KDF downgrade")
|
||||
for _, option := range []ScramClientOption{WithMinArgonCost(0, 8), WithMinArgonCost(1, 0), WithMinArgonCost(1, MaxVerifyArgonMemory+1)} {
|
||||
_, err := NewScramClient("u", "password123", option).StartAuthentication()
|
||||
hasErr(t, err, "invalid client policy")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user