From d6ecff63774b7dfffb218c37bfd8791675fabe06 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Wed, 16 Sep 2026 08:20:25 +0000 Subject: [PATCH] feat: require_mfa setting and user_mfa ciphertext mirror --- backup/ciphertext_fields_test.go | 22 ++++++++++++++++++++++ backup/verify.go | 2 ++ models/settings.go | 12 ++++++++++++ 3 files changed, 36 insertions(+) create mode 100644 backup/ciphertext_fields_test.go diff --git a/backup/ciphertext_fields_test.go b/backup/ciphertext_fields_test.go new file mode 100644 index 0000000..95b4605 --- /dev/null +++ b/backup/ciphertext_fields_test.go @@ -0,0 +1,22 @@ +package backup + +import "testing" + +// user_mfa holds an encrypted TOTP secret. Absent from this map, verify's live +// probe reports "this database stores no ciphertext yet" and the one gate that +// catches a wrong encryption key becomes a no-op for MFA secrets. +func TestUserMFACiphertextIsMirrored(t *testing.T) { + fields, ok := ciphertextFields["user_mfa"] + if !ok { + t.Fatal("ciphertextFields has no entry for user_mfa") + } + found := false + for _, f := range fields { + if f == "totp_secret_enc" { + found = true + } + } + if !found { + t.Fatalf("user_mfa entry %v does not name totp_secret_enc", fields) + } +} diff --git a/backup/verify.go b/backup/verify.go index 9c92681..6fa76cc 100644 --- a/backup/verify.go +++ b/backup/verify.go @@ -128,6 +128,7 @@ func probe(ctx context.Context, opt VerifyOptions, rep *VerifyReport) error { // secrets - models/secret.go: encrypted_value // auth_providers - models/auth_provider.go: client_secret_enc // console_sessions - models/console_session.go: rdp_user_enc, rdp_pass_enc +// user_mfa - models/user_mfa.go: totp_secret_enc // // settings is deliberately absent: it holds no ciphertext at all. The ESO read // token is stored as a SHA-256 hash, which no key opens. @@ -136,6 +137,7 @@ var ciphertextFields = map[string][]string{ "secrets": {"encrypted_value"}, "auth_providers": {"client_secret_enc"}, "console_sessions": {"rdp_user_enc", "rdp_pass_enc"}, + "user_mfa": {"totp_secret_enc"}, } func findCiphertext(ctx context.Context, db *mongo.Database, coll string) (string, bool, error) { diff --git a/models/settings.go b/models/settings.go index 438eb96..96de794 100644 --- a/models/settings.go +++ b/models/settings.go @@ -35,6 +35,12 @@ type Settings struct { // upgrade. Nil means enabled. LocalLoginEnabled *bool `bson:"local_login_enabled,omitempty" json:"local_login_enabled,omitempty"` + // RequireMFA forces every password-authenticated member to hold a second + // factor. A pointer for the same reason LocalLoginEnabled is: absent must + // mean off, and a plain bool read from an old document would lock out an + // entire instance at upgrade. + RequireMFA *bool `bson:"require_mfa,omitempty" json:"require_mfa,omitempty"` + // VulnFindingRetentionDays is a pointer for the same reason // WorkflowLogRetentionDays is: absent must mean the default, not zero. // Nil is 90 days, 0 is forever. Only "fixed" findings are ever swept. @@ -62,6 +68,12 @@ func LocalLoginEnabled(s *Settings) bool { return *s.LocalLoginEnabled } +// RequireMFA reads the MFA policy with its absent-means-off default. Every +// caller must go through this rather than dereferencing the field. +func RequireMFA(s *Settings) bool { + return s != nil && s.RequireMFA != nil && *s.RequireMFA +} + // APITokenMaxDays reads the token lifetime cap with its absent-means-uncapped // default. 0 means no cap. Every caller must go through this rather than // dereferencing the field.