diff --git a/internal/auth/session.go b/internal/auth/session.go new file mode 100644 index 00000000..c5fdff22 --- /dev/null +++ b/internal/auth/session.go @@ -0,0 +1,40 @@ +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + + "golang.org/x/crypto/bcrypt" +) + +// sessionTokenBytes is the raw entropy per session token. 32 bytes of +// crypto/rand gives ~256 bits; after base64 url-safe encoding the cookie +// value is 43 chars with no padding. +const sessionTokenBytes = 32 + +// MintSessionToken returns a freshly-generated, url-safe opaque token. +// The token is what the client carries; the DB only ever sees its sha256. +func MintSessionToken() (string, error) { + b := make([]byte, sessionTokenBytes) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// HashSessionToken is the single source of truth for mapping a raw token to +// the `sessions.token_hash` column. sha256 is fine here — we're not guarding +// against offline brute force (the token has 256 bits of entropy); we only +// want "leaked DB row can't be replayed without also having the raw token." +func HashSessionToken(token string) []byte { + sum := sha256.Sum256([]byte(token)) + return sum[:] +} + +// VerifyPassword is the canonical bcrypt comparison. Returns false on a +// malformed hash so callers don't need to distinguish "hash invalid" from +// "password wrong" — both are auth failures from the client's perspective. +func VerifyPassword(hash, plaintext string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plaintext)) == nil +} diff --git a/internal/auth/session_test.go b/internal/auth/session_test.go new file mode 100644 index 00000000..4fe7ff6b --- /dev/null +++ b/internal/auth/session_test.go @@ -0,0 +1,52 @@ +package auth + +import ( + "crypto/sha256" + "encoding/base64" + "strings" + "testing" + + "golang.org/x/crypto/bcrypt" +) + +func TestMintSessionToken_ReturnsUrlSafeBase64(t *testing.T) { + token, err := MintSessionToken() + if err != nil { + t.Fatalf("MintSessionToken: %v", err) + } + if len(token) < 40 { + t.Errorf("token length = %d, want >= 40 (32B base64 url-safe)", len(token)) + } + if strings.ContainsAny(token, "+/=") { + t.Errorf("token %q contains non-url-safe chars", token) + } +} + +func TestHashSessionToken_IsDeterministicSHA256(t *testing.T) { + token := "test-token-xyz" + want := sha256.Sum256([]byte(token)) + + got := HashSessionToken(token) + if len(got) != sha256.Size { + t.Fatalf("hash length = %d, want %d", len(got), sha256.Size) + } + if base64.StdEncoding.EncodeToString(got) != base64.StdEncoding.EncodeToString(want[:]) { + t.Errorf("hash = %x, want %x", got, want) + } +} + +func TestVerifyPassword(t *testing.T) { + hash, err := bcrypt.GenerateFromPassword([]byte("hunter2"), bcrypt.MinCost) + if err != nil { + t.Fatalf("GenerateFromPassword: %v", err) + } + if !VerifyPassword(string(hash), "hunter2") { + t.Error("correct password rejected") + } + if VerifyPassword(string(hash), "wrong") { + t.Error("wrong password accepted") + } + if VerifyPassword("not-a-hash", "hunter2") { + t.Error("malformed hash accepted") + } +}