Files
verdande/internal/auth/auth_test.go
T
kristianwindandClaude Opus 5 77994eff09 feat(security): a TOTP code can be used once
RFC 6238 says the verifier must reject a code it has already accepted: a code
stays valid for its whole thirty-second window, so one seen in flight — a
phishing page, a proxy, over the shoulder — could be replayed until it expired.

Tasks now record the counter of the last accepted step (totp_last_step), and a
login accepts a code only when its step is strictly greater. The guard is in the
UPDATE, not a read-then-write, so two logins racing with the same stolen code
cannot both find the old value and both proceed — exactly one moves the row.

auth.TOTPStep is VerifyTOTP that also reports which step matched, walking the
same one-step window; the code entered a little early or late spends the step it
was issued at, not the step it was checked at, so a late-but-valid code is still
single-use. Verified end to end: the same code presented to two logins in one
window is accepted once and refused the second time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 23:34:34 +02:00

346 lines
10 KiB
Go

package auth
import (
"strings"
"testing"
"time"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
)
func TestPasswordRoundTrip(t *testing.T) {
const password = "kaffe & rugbrød, tak!"
hash, err := HashPassword(password)
if err != nil {
t.Fatalf("hash: %v", err)
}
if err := VerifyPassword(hash, password); err != nil {
t.Errorf("verify with the right password: %v", err)
}
if err := VerifyPassword(hash, password+"x"); err != ErrMismatch {
t.Errorf("verify with a wrong password: %v, want ErrMismatch", err)
}
}
// The salt is per-hash, so the same password never produces the same stored value.
// Without that, identical hashes in a leaked table tell an attacker which accounts
// share a password.
func TestSamePasswordHashesDifferently(t *testing.T) {
a, err := HashPassword("hemmeligt")
if err != nil {
t.Fatal(err)
}
b, err := HashPassword("hemmeligt")
if err != nil {
t.Fatal(err)
}
if a == b {
t.Error("two hashes of one password are identical; the salt is not random")
}
// Both must still verify.
for _, h := range []string{a, b} {
if err := VerifyPassword(h, "hemmeligt"); err != nil {
t.Errorf("verify: %v", err)
}
}
}
func TestPasswordHashFormat(t *testing.T) {
hash, err := HashPassword("x")
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(hash, "$argon2id$v=19$m=19456,t=2,p=1$") {
t.Errorf("hash = %q; the parameters must be recorded in the string", hash)
}
if n := len(strings.Split(hash, "$")); n != 6 {
t.Errorf("hash has %d $-separated fields, want 6", n)
}
}
// A stored hash that has been truncated, edited or produced by something else must
// fail closed. The one thing it must never do is verify.
func TestMalformedHashNeverVerifies(t *testing.T) {
good, err := HashPassword("hemmeligt")
if err != nil {
t.Fatal(err)
}
broken := []string{
"",
"not a hash",
"$argon2id$v=19$m=19456,t=2,p=1$onlyfourfields",
"$argon2i$v=19$m=19456,t=2,p=1$c2FsdHNhbHRzYWx0$aGFzaA", // wrong variant
"$argon2id$v=13$m=19456,t=2,p=1$c2FsdHNhbHRzYWx0$aGFzaA", // wrong version
"$argon2id$v=19$m=0,t=0,p=0$c2FsdHNhbHRzYWx0$aGFzaA", // zero parameters
"$argon2id$v=19$m=19456,t=2,p=1$!!!not-base64!!!$aGFzaA",
"$argon2id$v=19$m=19456,t=2,p=1$c2FsdHNhbHRzYWx0$", // empty digest
good[:len(good)-1], // digest truncated by one character
good[:len(good)-8],
}
for _, h := range broken {
t.Run(h, func(t *testing.T) {
if err := VerifyPassword(h, "hemmeligt"); err == nil {
t.Error("a malformed hash verified")
}
})
}
}
func TestNeedsRehash(t *testing.T) {
current, err := HashPassword("x")
if err != nil {
t.Fatal(err)
}
if NeedsRehash(current) {
t.Error("a hash made with the current parameters wants rehashing")
}
// A hash from when the parameters were weaker should be upgraded on next login.
weak := encodeHash("x", []byte("0123456789abcdef"), 8*1024, 1, 1)
if !NeedsRehash(weak) {
t.Error("a hash with weaker parameters was not flagged for rehashing")
}
// It must still verify in the meantime, or the upgrade locks people out.
if err := VerifyPassword(weak, "x"); err != nil {
t.Errorf("an old-parameter hash no longer verifies: %v", err)
}
if !NeedsRehash("garbage") {
t.Error("an unreadable hash should be replaced")
}
}
func TestTokensAreUniqueAndOpaque(t *testing.T) {
seen := map[string]bool{}
for i := 0; i < 1000; i++ {
tok, err := NewToken()
if err != nil {
t.Fatal(err)
}
if seen[tok] {
t.Fatal("NewToken returned a duplicate")
}
seen[tok] = true
if len(tok) < 40 {
t.Fatalf("token %q is shorter than 256 bits of randomness", tok)
}
// URL-safe: these end up in emailed links.
if strings.ContainsAny(tok, "+/= ") {
t.Fatalf("token %q contains characters that need escaping in a URL", tok)
}
}
}
func TestHashTokenIsStableAndDistinct(t *testing.T) {
a, err := NewToken()
if err != nil {
t.Fatal(err)
}
b, err := NewToken()
if err != nil {
t.Fatal(err)
}
if HashToken(a) != HashToken(a) {
t.Error("HashToken is not deterministic; a stored token could never be found again")
}
if HashToken(a) == HashToken(b) {
t.Error("two different tokens hash the same")
}
if strings.Contains(HashToken(a), a) {
t.Error("the hash contains the token it is meant to protect")
}
}
func TestAPITokenIsRecognisable(t *testing.T) {
tok, prefix, err := NewAPIToken()
if err != nil {
t.Fatal(err)
}
if !IsAPIToken(tok) {
t.Errorf("token %q is not recognised as an API token", tok)
}
if !strings.HasPrefix(tok, prefix) {
t.Errorf("prefix %q does not match token %q", prefix, tok)
}
if IsAPIToken("some-session-id") {
t.Error("a session id was mistaken for an API token")
}
}
func TestTOTPRoundTrip(t *testing.T) {
secret, uri, err := NewTOTPSecret("verdande", "kristian@example.com")
if err != nil {
t.Fatalf("generate: %v", err)
}
if _, err := DecodeTOTPSecret(secret); err != nil {
t.Errorf("secret %q is not valid base32: %v", secret, err)
}
if !strings.HasPrefix(uri, "otpauth://totp/") {
t.Errorf("uri = %q", uri)
}
now := time.Date(2026, 3, 10, 10, 0, 0, 0, time.UTC)
code := codeAt(t, secret, now)
if err := VerifyTOTP(secret, code, now); err != nil {
t.Errorf("the current code was rejected: %v", err)
}
// Apps show the code in two groups; people paste it that way.
if err := VerifyTOTP(secret, code[:3]+" "+code[3:], now); err != nil {
t.Errorf("a code with a space in it was rejected: %v", err)
}
if err := VerifyTOTP(secret, "000000", now); err == nil {
t.Error("a wrong code was accepted")
}
if err := VerifyTOTP("", code, now); err != ErrNoTOTPSecret {
t.Errorf("verifying against an empty secret: %v, want ErrNoTOTPSecret", err)
}
}
// One step of tolerance either way covers a code entered as it rolls over and a
// phone whose clock drifts. Two steps out must fail, or a stolen code lives too long.
func TestTOTPWindow(t *testing.T) {
secret, _, err := NewTOTPSecret("verdande", "k@example.com")
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, 3, 10, 10, 0, 0, 0, time.UTC)
code := codeAt(t, secret, now)
for _, delta := range []time.Duration{-30 * time.Second, 0, 30 * time.Second} {
if err := VerifyTOTP(secret, code, now.Add(delta)); err != nil {
t.Errorf("code rejected at %v offset: %v", delta, err)
}
}
for _, delta := range []time.Duration{-90 * time.Second, 90 * time.Second} {
if err := VerifyTOTP(secret, code, now.Add(delta)); err == nil {
t.Errorf("code still accepted %v away from when it was issued", delta)
}
}
}
func TestRecoveryCodes(t *testing.T) {
codes, hashes, err := NewRecoveryCodes()
if err != nil {
t.Fatal(err)
}
if len(codes) != len(hashes) || len(codes) == 0 {
t.Fatalf("got %d codes and %d hashes", len(codes), len(hashes))
}
seen := map[string]bool{}
for _, c := range codes {
if seen[c] {
t.Error("duplicate recovery code")
}
seen[c] = true
}
// Every code must match its own entry, and nothing else.
for i, c := range codes {
if got := MatchRecoveryCode(hashes, c); got != i {
t.Errorf("code %d matched index %d", i, got)
}
}
if got := MatchRecoveryCode(hashes, "AAAAA-AAAAA"); got != -1 {
t.Errorf("an invented code matched index %d", got)
}
// Transcription must be forgiven: read off a printout, typed back in.
c := codes[0]
for _, variant := range []string{
strings.ToLower(c),
strings.ReplaceAll(c, "-", ""),
" " + c + " ",
strings.ToLower(strings.ReplaceAll(c, "-", " ")),
} {
if got := MatchRecoveryCode(hashes, variant); got != 0 {
t.Errorf("variant %q matched index %d, want 0", variant, got)
}
}
}
// TOTPStep returns the step a code matched, and it must be the same step across
// the tolerance window — that number is what makes a code single-use, so a code
// entered a little early or late has to spend the same step, not a new one.
func TestTOTPStepIsStableAcrossTheWindow(t *testing.T) {
secret, _, err := NewTOTPSecret("verdande", "k@example.com")
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, 3, 10, 10, 0, 15, 0, time.UTC)
code := codeAt(t, secret, now)
want, err := TOTPStep(secret, code, now)
if err != nil {
t.Fatalf("the current code did not match: %v", err)
}
// Verified one step late — the code is still valid, and must report the step it
// was issued at, not the step it was checked at.
got, err := TOTPStep(secret, code, now.Add(30*time.Second))
if err != nil {
t.Fatalf("code rejected one step late: %v", err)
}
if got != want {
t.Errorf("same code reported step %d then %d; a replay check keyed on that would not catch it", want, got)
}
if _, err := TOTPStep(secret, "000000", now); err == nil {
t.Error("a wrong code returned a step")
}
if _, err := TOTPStep("", code, now); err != ErrNoTOTPSecret {
t.Errorf("empty secret: %v, want ErrNoTOTPSecret", err)
}
}
// codeAt produces the code an authenticator app would display at that moment,
// using the same settings VerifyTOTP checks against.
func codeAt(t *testing.T, secret string, now time.Time) string {
t.Helper()
code, err := totp.GenerateCodeCustom(secret, now, totp.ValidateOpts{
Period: totpPeriod,
Skew: totpSkew,
Digits: totpDigits,
Algorithm: otp.AlgorithmSHA1,
})
if err != nil {
t.Fatalf("generate code: %v", err)
}
return code
}
// The relying party is derived from the instance's own base URL, and the two ways
// that goes wrong are both silent until a browser refuses.
func TestPasskeysRefuseAnAddressNoBrowserWillAccept(t *testing.T) {
for _, c := range []struct {
base string
ok bool
why string
}{
{"https://verdande.example.dk", true, "a real domain"},
{"http://localhost:5173", true, "localhost, which every browser excepts"},
{"https://verdande.example.dk:8443", true, "a port is dropped from the RP ID"},
// An IP address is not a domain. The browser throws SecurityError long
// after the server has happily issued a challenge, so it is refused here
// and the endpoints answer 503 instead.
{"http://127.0.0.1:8080", false, "an IPv4 literal"},
{"http://[::1]:8080", false, "an IPv6 literal"},
{"", false, "no address at all"},
{"not a url", false, "not an address"},
} {
_, err := NewWebAuthn(c.base, "verdande")
if c.ok && err != nil {
t.Errorf("NewWebAuthn(%q) refused %s: %v", c.base, c.why, err)
}
if !c.ok && err == nil {
t.Errorf("NewWebAuthn(%q) accepted %s, which a browser will not", c.base, c.why)
}
}
}