Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Assisted-by: GLM 5.3
148 lines
4.0 KiB
Go
148 lines
4.0 KiB
Go
// 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)
|
|
}
|