feat(mfa): TOTP secrets, recovery codes and factor lookup

This commit is contained in:
2026-09-16 08:30:55 +00:00
parent b78a9b3832
commit 25541345a8
6 changed files with 407 additions and 0 deletions
+1
View File
@@ -225,6 +225,7 @@ func serve() {
log.Fatalf("failed to connect to Redis: %v", err)
}
log.Println("connected to Redis")
services.RedisClient = auth.Redis()
// The bus carries agent commands and step results between replicas. It is
// not optional even on a single-replica deployment: dispatch takes the same
+2
View File
@@ -23,12 +23,14 @@ require (
)
require (
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/bytedance/gopkg v0.1.4 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/jsonschema-go v0.4.3 // indirect
github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216 // indirect
github.com/oklog/ulid/v2 v2.1.2 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/pquerna/otp v1.5.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.62.0 // indirect
github.com/samber/lo v1.53.0 // indirect
+4
View File
@@ -6,6 +6,8 @@ github.com/aquasecurity/bolt-fixtures v0.0.0-20200903104109-d34e7f983986 h1:2a30
github.com/aquasecurity/bolt-fixtures v0.0.0-20200903104109-d34e7f983986/go.mod h1:NT+jyeCzXk6vXR5MTkdn4z64TgGfE5HMLC8qfj5unl8=
github.com/aquasecurity/trivy-db v0.0.0-20260813095258-0e0340a01b57 h1:A3Lz/9ip/qigafSxqBWcu7S8i+tJbQS7DB2V0XibOKs=
github.com/aquasecurity/trivy-db v0.0.0-20260813095258-0e0340a01b57/go.mod h1:iIEV2oGuZScvfyX2SMIn78iVMNnepgo0QuJJh/srgVI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
@@ -100,6 +102,8 @@ github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwp
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
+4
View File
@@ -22,6 +22,10 @@ type UserMFA struct {
// vantage-shared's backup.ciphertextFields - change one, change the other.
TOTPSecretEnc string `bson:"totp_secret_enc,omitempty" json:"-"`
// TOTPPendingEnc holds a secret from an unconfirmed StartTOTPSetup call.
// Transient: never mirrored into vantage-shared's backup.ciphertextFields.
TOTPPendingEnc string `bson:"totp_pending_enc,omitempty" json:"-"`
// TOTPConfirmedAt nil means setup was started but never confirmed, which
// does not count as an enrolled factor.
TOTPConfirmedAt *time.Time `bson:"totp_confirmed_at,omitempty" json:"totp_confirmed_at,omitempty"`
+310
View File
@@ -0,0 +1,310 @@
package services
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base32"
"encoding/hex"
"errors"
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
"github.com/redis/go-redis/v9"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
var (
ErrNoMFA = errors.New("no multi-factor authentication is enrolled")
ErrBadCode = errors.New("that code is not valid")
ErrCodeReplayed = errors.New("that code has already been used")
)
// Factor names travel to the browser, which chooses which prompt to draw.
const (
FactorTOTP = "totp"
FactorWebAuthn = "webauthn"
FactorRecovery = "recovery"
FactorPassword = "password"
)
const recoveryCodeCount = 10
// RedisClient is set by main.go after auth.InitRedis succeeds. services must
// not import auth: auth already imports services, and Go has no import
// cycles.
var RedisClient *redis.Client
func mfaCtx() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), 5*time.Second)
}
// GenerateRecoveryCodes returns the codes to show the user once, and the
// hashed records to store. The plaintext is never persisted.
func GenerateRecoveryCodes() ([]string, []models.RecoveryCode, error) {
plain := make([]string, 0, recoveryCodeCount)
stored := make([]models.RecoveryCode, 0, recoveryCodeCount)
for i := 0; i < recoveryCodeCount; i++ {
b := make([]byte, 5)
if _, err := rand.Read(b); err != nil {
return nil, nil, err
}
// Crockford-ish base32 without padding: 8 characters, no case to get
// wrong when read off paper.
code := strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b))
plain = append(plain, code)
stored = append(stored, models.RecoveryCode{Hash: HashRecoveryCode(code)})
}
return plain, stored, nil
}
// NormaliseRecoveryCode makes a code typed off paper comparable: no case, no
// spaces, no dashes.
func NormaliseRecoveryCode(code string) string {
r := strings.NewReplacer(" ", "", "-", "", "\t", "")
return strings.ToLower(r.Replace(strings.TrimSpace(code)))
}
func HashRecoveryCode(code string) string {
sum := sha256.Sum256([]byte(NormaliseRecoveryCode(code)))
return hex.EncodeToString(sum[:])
}
// ConsumeRecoveryCode reports which unused code matches, if any. It does not
// write: the caller marks the index used so the database update and the audit
// event stay in one place.
func ConsumeRecoveryCode(codes []models.RecoveryCode, input string, now time.Time) (int, bool) {
want := HashRecoveryCode(input)
for i, c := range codes {
if c.UsedAt != nil {
continue
}
if subtle.ConstantTimeCompare([]byte(c.Hash), []byte(want)) == 1 {
return i, true
}
}
return 0, false
}
func GetUserMFA(instanceID, userID string) (*models.UserMFA, error) {
ctx, cancel := mfaCtx()
defer cancel()
var m models.UserMFA
err := db.Col("user_mfa").FindOne(ctx, bson.M{"instance_id": instanceID, "user_id": userID}).Decode(&m)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, nil
}
if err != nil {
return nil, err
}
return &m, nil
}
func CountPasskeys(instanceID, userID string) (int64, error) {
ctx, cancel := mfaCtx()
defer cancel()
return db.Col("webauthn_credentials").CountDocuments(ctx, bson.M{"instance_id": instanceID, "user_id": userID})
}
// hasFactor is the single definition of "this user has MFA". An unconfirmed
// TOTP secret does not count: a half-finished setup must not lock anyone out.
func hasFactor(m *models.UserMFA, passkeys int) bool {
if passkeys > 0 {
return true
}
return m != nil && m.TOTPConfirmedAt != nil
}
// MFAMethods lists the factors a user can present, newest-friendly order. An
// empty slice means they have no MFA at all.
func MFAMethods(instanceID, userID string) ([]string, error) {
m, err := GetUserMFA(instanceID, userID)
if err != nil {
return nil, err
}
passkeys, err := CountPasskeys(instanceID, userID)
if err != nil {
return nil, err
}
methods := []string{}
if passkeys > 0 {
methods = append(methods, FactorWebAuthn)
}
if m != nil && m.TOTPConfirmedAt != nil {
methods = append(methods, FactorTOTP)
}
if len(methods) > 0 && m != nil {
for _, c := range m.RecoveryCodes {
if c.UsedAt == nil {
methods = append(methods, FactorRecovery)
break
}
}
}
return methods, nil
}
func HasMFA(instanceID, userID string) (bool, error) {
m, err := GetUserMFA(instanceID, userID)
if err != nil {
return false, err
}
passkeys, err := CountPasskeys(instanceID, userID)
if err != nil {
return false, err
}
return hasFactor(m, int(passkeys)), nil
}
// WebAuthnHandle returns the user's stable random handle, creating the user_mfa
// document on first use.
func WebAuthnHandle(instanceID, userID string) ([]byte, error) {
m, err := GetUserMFA(instanceID, userID)
if err != nil {
return nil, err
}
if m != nil && len(m.WebAuthnHandle) > 0 {
return m.WebAuthnHandle, nil
}
handle := make([]byte, 64)
if _, err := rand.Read(handle); err != nil {
return nil, err
}
ctx, cancel := mfaCtx()
defer cancel()
_, err = db.Col("user_mfa").UpdateOne(ctx,
bson.M{"instance_id": instanceID, "user_id": userID},
bson.M{
"$set": bson.M{"updated_at": time.Now()},
"$setOnInsert": bson.M{"webauthn_handle": handle},
},
options.UpdateOne().SetUpsert(true))
if err != nil {
return nil, err
}
// Re-read: a concurrent caller may have won the upsert.
m, err = GetUserMFA(instanceID, userID)
if err != nil {
return nil, err
}
return m.WebAuthnHandle, nil
}
// StartTOTPSetup writes an unconfirmed secret and returns it with its otpauth
// URI. An existing unconfirmed secret is replaced; a confirmed one is not
// touched until ConfirmTOTP succeeds against the new secret.
func StartTOTPSetup(instanceID, userID, issuer, account string) (string, string, error) {
key, err := totp.Generate(totp.GenerateOpts{Issuer: issuer, AccountName: account})
if err != nil {
return "", "", err
}
enc, err := encryptString(key.Secret())
if err != nil {
return "", "", err
}
ctx, cancel := mfaCtx()
defer cancel()
_, err = db.Col("user_mfa").UpdateOne(ctx,
bson.M{"instance_id": instanceID, "user_id": userID},
bson.M{"$set": bson.M{"totp_pending_enc": enc, "updated_at": time.Now()}},
options.UpdateOne().SetUpsert(true))
if err != nil {
return "", "", err
}
return key.Secret(), key.URL(), nil
}
// ConfirmTOTP promotes the pending secret to the live one.
func ConfirmTOTP(instanceID, userID, code string) error {
ctx, cancel := mfaCtx()
defer cancel()
var raw struct {
Pending string `bson:"totp_pending_enc"`
}
if err := db.Col("user_mfa").FindOne(ctx,
bson.M{"instance_id": instanceID, "user_id": userID}).Decode(&raw); err != nil {
return ErrNoMFA
}
if raw.Pending == "" {
return ErrNoMFA
}
secret, err := decryptString(raw.Pending)
if err != nil {
return err
}
if !totp.Validate(code, secret) {
return ErrBadCode
}
now := time.Now()
_, err = db.Col("user_mfa").UpdateOne(ctx,
bson.M{"instance_id": instanceID, "user_id": userID},
bson.M{
"$set": bson.M{"totp_secret_enc": raw.Pending, "totp_confirmed_at": now, "updated_at": now},
"$unset": bson.M{"totp_pending_enc": ""},
})
return err
}
// VerifyTOTPCode checks a code against the confirmed secret and burns it, so
// the same code cannot be replayed inside its 30-second window by an attacker
// who shoulder-surfed it.
func VerifyTOTPCode(instanceID, userID, code string) error {
m, err := GetUserMFA(instanceID, userID)
if err != nil {
return err
}
if m == nil || m.TOTPConfirmedAt == nil || m.TOTPSecretEnc == "" {
return ErrNoMFA
}
secret, err := decryptString(m.TOTPSecretEnc)
if err != nil {
return err
}
valid, err := totp.ValidateCustom(code, secret, time.Now(), totp.ValidateOpts{
Period: 30, Skew: 1, Digits: otp.DigitsSix, Algorithm: otp.AlgorithmSHA1,
})
if err != nil || !valid {
return ErrBadCode
}
return burnTOTPCode(userID, code)
}
// ClearMFA removes every factor. Used by an owner or admin reset.
func ClearMFA(instanceID, userID string) error {
ctx, cancel := mfaCtx()
defer cancel()
if _, err := db.Col("user_mfa").DeleteOne(ctx,
bson.M{"instance_id": instanceID, "user_id": userID}); err != nil {
return err
}
_, err := db.Col("webauthn_credentials").DeleteMany(ctx,
bson.M{"instance_id": instanceID, "user_id": userID})
return err
}
// burnTOTPCode makes a code single-use for 90 seconds - longer than the +-1
// step window it could still validate in. Redis is already required for
// sessions.
func burnTOTPCode(userID, code string) error {
if RedisClient == nil {
return nil
}
ctx, cancel := mfaCtx()
defer cancel()
key := "km:totp:" + userID + ":" + code
ok, err := RedisClient.SetNX(ctx, key, 1, 90*time.Second).Result()
if err != nil {
return err
}
if !ok {
return ErrCodeReplayed
}
return nil
}
+86
View File
@@ -0,0 +1,86 @@
package services
import (
"strings"
"testing"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
)
func TestGenerateRecoveryCodesReturnsTenUniqueHashedCodes(t *testing.T) {
plain, stored, err := GenerateRecoveryCodes()
if err != nil {
t.Fatalf("generate: %v", err)
}
if len(plain) != 10 || len(stored) != 10 {
t.Fatalf("want 10 codes, got %d plain and %d stored", len(plain), len(stored))
}
seen := map[string]bool{}
for i, p := range plain {
if seen[p] {
t.Fatalf("duplicate code %q", p)
}
seen[p] = true
if stored[i].Hash == p {
t.Fatal("code stored in plaintext")
}
if stored[i].Hash != HashRecoveryCode(p) {
t.Fatalf("stored hash does not match HashRecoveryCode for code %d", i)
}
if stored[i].UsedAt != nil {
t.Fatal("fresh code marked used")
}
}
}
func TestConsumeRecoveryCodeIsSingleUseAndFormatTolerant(t *testing.T) {
plain, stored, _ := GenerateRecoveryCodes()
now := time.Now()
idx, ok := ConsumeRecoveryCode(stored, plain[3], now)
if !ok || idx != 3 {
t.Fatalf("want index 3 consumed, got idx=%d ok=%v", idx, ok)
}
used := now
stored[3].UsedAt = &used
if _, ok := ConsumeRecoveryCode(stored, plain[3], now); ok {
t.Fatal("a used recovery code was accepted a second time")
}
// Users retype codes with different case and stray dashes or spaces.
messy := " " + strings.ToUpper(plain[4]) + " "
if _, ok := ConsumeRecoveryCode(stored, messy, now); !ok {
t.Fatal("normalisation rejected a valid code")
}
}
func TestConsumeRecoveryCodeRejectsUnknownCode(t *testing.T) {
_, stored, _ := GenerateRecoveryCodes()
if _, ok := ConsumeRecoveryCode(stored, "not-a-real-code", time.Now()); ok {
t.Fatal("unknown code accepted")
}
}
func TestHasFactorRequiresConfirmedTOTPOrAPasskey(t *testing.T) {
now := time.Now()
cases := []struct {
name string
mfa *models.UserMFA
passkeys int
want bool
}{
{"nothing", nil, 0, false},
{"unconfirmed totp only", &models.UserMFA{TOTPSecretEnc: "ab"}, 0, false},
{"confirmed totp", &models.UserMFA{TOTPSecretEnc: "ab", TOTPConfirmedAt: &now}, 0, true},
{"passkey only", &models.UserMFA{}, 1, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := hasFactor(tc.mfa, tc.passkeys); got != tc.want {
t.Fatalf("hasFactor = %v, want %v", got, tc.want)
}
})
}
}