Files
volumen/internal/users/totp_test.go
T

148 lines
4.0 KiB
Go
Raw Normal View History

2026-09-18 12:03:35 +02:00
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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)
}