From 25541345a8c817849b34bb621c6c0cde2f18f566 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Wed, 16 Sep 2026 08:30:55 +0000 Subject: [PATCH] feat(mfa): TOTP secrets, recovery codes and factor lookup --- server/cmd/main.go | 1 + server/go.mod | 2 + server/go.sum | 4 + server/internal/models/user_mfa.go | 4 + server/internal/services/mfa.go | 310 +++++++++++++++++++++++++++ server/internal/services/mfa_test.go | 86 ++++++++ 6 files changed, 407 insertions(+) create mode 100644 server/internal/services/mfa.go create mode 100644 server/internal/services/mfa_test.go diff --git a/server/cmd/main.go b/server/cmd/main.go index 650cc24..74f0f18 100644 --- a/server/cmd/main.go +++ b/server/cmd/main.go @@ -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 diff --git a/server/go.mod b/server/go.mod index ea2cd02..208b6e6 100644 --- a/server/go.mod +++ b/server/go.mod @@ -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 diff --git a/server/go.sum b/server/go.sum index 17d73d4..706114e 100644 --- a/server/go.sum +++ b/server/go.sum @@ -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= diff --git a/server/internal/models/user_mfa.go b/server/internal/models/user_mfa.go index eb7ffef..1b29c45 100644 --- a/server/internal/models/user_mfa.go +++ b/server/internal/models/user_mfa.go @@ -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"` diff --git a/server/internal/services/mfa.go b/server/internal/services/mfa.go new file mode 100644 index 0000000..fa6fa8d --- /dev/null +++ b/server/internal/services/mfa.go @@ -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 +} diff --git a/server/internal/services/mfa_test.go b/server/internal/services/mfa_test.go new file mode 100644 index 0000000..fb4c928 --- /dev/null +++ b/server/internal/services/mfa_test.go @@ -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) + } + }) + } +}