package auth import ( "crypto/sha256" "encoding/base64" "encoding/hex" "strings" "testing" ) // 6. Session tokens carry real entropy from crypto/rand. // // Randomness cannot be proved by a test, so this asserts the properties whose // absence would mean the generator is broken: the full 256 bits are present, // the output is not a constant, and the bytes are not all the same value — // which is what a zeroed or unseeded buffer looks like. func TestGenerateTokenIsCryptographicallyRandom(t *testing.T) { const runs = 512 seen := make(map[string]struct{}, runs) bitsSet := make([]int, 8*TokenBytes) // how often each bit position was 1 for i := 0; i < runs; i++ { token, err := GenerateToken() if err != nil { t.Fatalf("GenerateToken: %v", err) } raw, err := base64.RawURLEncoding.DecodeString(token) if err != nil { t.Fatalf("token is not base64url: %v", err) } if len(raw) != TokenBytes { t.Fatalf("token decodes to %d bytes, want %d", len(raw), TokenBytes) } // A cookie value must survive a round trip untouched: no '=' padding, // no '+' or '/' to be re-encoded. if strings.ContainsAny(token, "=+/") { t.Fatalf("token contains a character that is unsafe in a cookie or URL") } if _, dup := seen[token]; dup { t.Fatalf("GenerateToken returned a duplicate within %d calls", runs) } seen[token] = struct{}{} for bit := 0; bit < 8*TokenBytes; bit++ { if raw[bit/8]&(1<<(bit%8)) != 0 { bitsSet[bit]++ } } } // Each bit should be 1 about half the time. A bit that is *always* 0 or // always 1 across 512 draws has a chance of roughly 2^-511 of being random // and is far more likely a stuck generator. The bound is deliberately // loose — this is a smoke test for a broken source, not a statistical // suite, and it must never flake. for bit, count := range bitsSet { if count == 0 || count == runs { t.Errorf("bit %d was constant across %d tokens; the entropy source is broken", bit, runs) } } // 256 bits is the size that makes guessing a live session hopeless. if TokenBytes < 32 { t.Errorf("TokenBytes = %d, want at least 32", TokenBytes) } } // 7. Two generated tokens differ. func TestGenerateTokenReturnsDistinctValues(t *testing.T) { a, err := GenerateToken() if err != nil { t.Fatalf("first: %v", err) } b, err := GenerateToken() if err != nil { t.Fatalf("second: %v", err) } if a == b { t.Fatal("two consecutive tokens were identical") } if HashToken(a) == HashToken(b) { t.Fatal("two distinct tokens hashed to the same value") } } // 8. Hashing is deterministic, and is genuinely SHA-256 rather than something // that merely looks like it. func TestHashTokenIsDeterministicSHA256(t *testing.T) { token, err := GenerateToken() if err != nil { t.Fatalf("GenerateToken: %v", err) } first, second := HashToken(token), HashToken(token) if first != second { t.Fatal("hashing the same token twice produced different values") } // Checked against the standard library directly: lookup only works if the // value stored is exactly this. want := sha256.Sum256([]byte(token)) if first != hex.EncodeToString(want[:]) { t.Fatal("HashToken does not agree with crypto/sha256") } if len(first) != 64 { t.Fatalf("hash is %d characters, want 64 hex characters", len(first)) } if first != strings.ToLower(first) { t.Error("hash is not lowercase; the database CHECK requires lowercase hex") } // The stored value must not be the secret. if strings.Contains(first, token) || first == token { t.Error("the hash contains the token") } if HashToken(token+"x") == first { t.Error("a different token hashed to the same value") } // The empty string has a hash too — that is a property of SHA-256, not a // licence to store one. Guarding against an empty token is the Manager's // job, and TestManagerRejectsEmptyToken covers it. if HashToken("") == "" { t.Error("HashToken returned an empty string") } } // IsTokenHash is the guard that stops a raw token being written where a hash // belongs, so it must reject every raw token and accept every real hash. func TestIsTokenHash(t *testing.T) { token, err := GenerateToken() if err != nil { t.Fatalf("GenerateToken: %v", err) } if !IsTokenHash(HashToken(token)) { t.Error("a real hash was not recognised as one") } if IsTokenHash(token) { t.Error("a raw token was accepted as a hash; this is the check that prevents storing the secret") } for name, s := range map[string]string{ "empty": "", "too short": strings.Repeat("a", 63), "too long": strings.Repeat("a", 65), "uppercase": strings.ToUpper(HashToken(token)), "non-hex": strings.Repeat("g", 64), "trailing": HashToken(token) + "\n", } { if IsTokenHash(s) { t.Errorf("%s was accepted as a token hash", name) } } }