159 lines
4.7 KiB
Go
159 lines
4.7 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|