// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 package users import ( "os" "path/filepath" "strings" "testing" "time" "sourcedock.dev/petrbalvin/volumen/internal/totp" ) func totpStore(t *testing.T) *Users { t.Helper() return New(filepath.Join(t.TempDir(), "users.toml")) } func TestTotpLifecycle(t *testing.T) { u := totpStore(t) if _, err := u.Add("petr", "correct-horse-9", "admin"); err != nil { t.Fatal(err) } secret := GenerateTotpSecret() if len(secret) != 32 { // 20 bytes as base32 t.Fatalf("secret length = %d", len(secret)) } now := time.Unix(1_000_000_000, 0) code := totpCode(t, secret, now) if u.VerifyTotp("petr", code, now) { t.Fatal("unenabled account accepted a code") } codes, hashes := GenerateRecoveryCodes(10) if len(codes) != 10 || len(hashes) != 10 { t.Fatalf("recovery = %d/%d", len(codes), len(hashes)) } if strings.Contains(strings.Join(codes, " "), "-") == false { t.Fatal("codes are not grouped for reading") } if _, err := u.EnableTotp("petr", secret, hashes); err != nil { t.Fatal(err) } stored := u.Find("petr") if stored.TotpSecret != secret || len(stored.Recovery) != 10 { t.Fatalf("stored = %+v", stored) } // A current code works and advances the floor; the same code is a // replay and is refused. if !u.VerifyTotp("petr", code, now) { t.Fatal("current code refused") } if u.VerifyTotp("petr", code, now) { t.Fatal("replayed code accepted") } later := now.Add(2 * totp.Step) if !u.VerifyTotp("petr", totpCode(t, secret, later), later) { t.Fatal("next window refused") } // A recovery code works exactly once. if !u.ConsumeRecovery("petr", codes[0]) { t.Fatal("recovery code refused") } if u.ConsumeRecovery("petr", codes[0]) { t.Fatal("recovery code accepted twice") } if u.ConsumeRecovery("petr", "not-a-code") { t.Fatal("unknown recovery code accepted") } // Replacement drops the old codes and keeps the secret. fresh, freshHashes := GenerateRecoveryCodes(10) if _, err := u.ReplaceRecovery("petr", freshHashes); err != nil { t.Fatal(err) } if u.ConsumeRecovery("petr", codes[1]) { t.Fatal("old recovery code survived replacement") } if !u.ConsumeRecovery("petr", fresh[0]) { t.Fatal("fresh recovery code refused") } // Clearing removes everything of the factor. if _, err := u.ClearTotp("petr"); err != nil { t.Fatal(err) } if u.VerifyTotp("petr", totpCode(t, secret, later.Add(2*totp.Step)), later.Add(2*totp.Step)) { t.Fatal("cleared account still accepts codes") } } // TestTotpPersistsAcrossReopen proves the file carries the factor: a // reopened store answers with the same secret, floor and codes. func TestTotpPersistsAcrossReopen(t *testing.T) { path := filepath.Join(t.TempDir(), "users.toml") u := New(path) if _, err := u.Add("petr", "correct-horse-9", "admin"); err != nil { t.Fatal(err) } secret := GenerateTotpSecret() codes, hashes := GenerateRecoveryCodes(10) if _, err := u.EnableTotp("petr", secret, hashes); err != nil { t.Fatal(err) } now := time.Unix(1_000_000_000, 0) if !u.VerifyTotp("petr", totpCode(t, secret, now), now) { t.Fatal("first window refused") } reopened := New(path) stored := reopened.Find("petr") if stored.TotpSecret != secret { t.Fatalf("secret lost: %q", stored.TotpSecret) } if stored.TotpStep == 0 { t.Fatal("replay floor lost") } if len(stored.Recovery) != 10 { t.Fatalf("recovery codes lost: %d", len(stored.Recovery)) } if reopened.VerifyTotp("petr", totpCode(t, secret, now), now) { t.Fatal("replay floor lost across reopen") } // The written file holds hashes, never the codes themselves. raw, err := os.ReadFile(path) if err != nil { t.Fatal(err) } if strings.Contains(string(raw), codes[0]) { t.Fatal("a recovery code is stored in the clear") } } func totpCode(t *testing.T, secret string, at time.Time) string { t.Helper() key, err := decodeTotpSecret(secret) if err != nil { t.Fatalf("decode: %v", err) } return totp.Code(key, at) }