From 060fa6433913a864c2886b59b35c2d0a90e95175 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 7 Sep 2026 11:26:34 +0000 Subject: [PATCH] feat: Add backup verify with a live decrypt probe A fingerprint comparison proves two archives agree about a key. Only opening real ciphertext from the target proves the key in hand reads the data, which is the question an operator actually has. --- shared/backup/verify.go | 183 +++++++++++++++++++++++++++++++++++ shared/backup/verify_test.go | 156 +++++++++++++++++++++++++++++ 2 files changed, 339 insertions(+) create mode 100644 shared/backup/verify.go create mode 100644 shared/backup/verify_test.go diff --git a/shared/backup/verify.go b/shared/backup/verify.go new file mode 100644 index 0000000..9234d9a --- /dev/null +++ b/shared/backup/verify.go @@ -0,0 +1,183 @@ +package backup + +import ( + "context" + "fmt" + + "gitea.hostxtra.co.uk/mrhid6/vantage/shared/cryptobox" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +// VerifyOptions configures a verification. Client and Database are optional; +// supplying them turns on the live probe. +type VerifyOptions struct { + Archive *Reader + KeyHex string + Client *mongo.Client + Database string +} + +// VerifyReport is what verify found. +type VerifyReport struct { + ArchiveFingerprint *string + KeyFingerprint *string + KeyMatchesArchive bool + + // ProbeAttempted is false when no client was supplied, and also when the + // database holds no ciphertext to probe. + ProbeAttempted bool + ProbeCollection string + ProbeDecrypted bool + + Problems []string +} + +// OK reports whether this archive is usable with the key in hand. +func (r VerifyReport) OK() bool { return len(r.Problems) == 0 } + +func (r *VerifyReport) problem(format string, args ...any) { + r.Problems = append(r.Problems, fmt.Sprintf(format, args...)) +} + +// Verify checks an already-opened archive against the key in hand and, when a +// client is supplied, against a live database. +// +// Open has already verified every member's checksum, so integrity is not +// rechecked here. What this adds is the question an operator actually has: +// will the key I hold open the data this archive carries. A fingerprint +// comparison proves two archives agree; only the probe proves the key opens +// real ciphertext. +func Verify(ctx context.Context, opt VerifyOptions) (VerifyReport, error) { + m := opt.Archive.Manifest() + rep := VerifyReport{ArchiveFingerprint: m.KeyFingerprint} + + if opt.KeyHex != "" { + fp, err := FingerprintHex(opt.KeyHex) + if err != nil { + return rep, err + } + rep.KeyFingerprint = &fp + } + + switch { + case m.KeyFingerprint == nil && rep.KeyFingerprint == nil: + rep.problem("neither the archive nor this environment names a key; nothing here " + + "proves the archive's ciphertext can ever be read") + case m.KeyFingerprint == nil: + rep.problem("the archive carries no key fingerprint, so it cannot be matched " + + "against the key you hold") + case rep.KeyFingerprint == nil: + rep.problem("KEY_ENCRYPTION_KEY is not set, so the archive's fingerprint %s "+ + "cannot be checked against anything", *m.KeyFingerprint) + case *m.KeyFingerprint == *rep.KeyFingerprint: + rep.KeyMatchesArchive = true + default: + rep.problem("key mismatch: archive fingerprint %s, your key fingerprints as %s", + *m.KeyFingerprint, *rep.KeyFingerprint) + } + + if opt.Client == nil || opt.Database == "" || opt.KeyHex == "" { + return rep, nil + } + if err := probe(ctx, opt, &rep); err != nil { + return rep, err + } + return rep, nil +} + +// probe reads one ciphertext field from the live database and tries to open it. +func probe(ctx context.Context, opt VerifyOptions, rep *VerifyReport) error { + key, err := ParseKey(opt.KeyHex) + if err != nil { + return err + } + for _, coll := range CiphertextCollections() { + ciphertext, ok, err := findCiphertext(ctx, opt.Client.Database(opt.Database), coll) + if err != nil { + return err + } + if !ok { + continue + } + rep.ProbeAttempted = true + rep.ProbeCollection = coll + if _, err := cryptobox.Open(key, ciphertext); err != nil { + rep.problem("the key in hand does not decrypt live ciphertext in %s", coll) + return nil + } + rep.ProbeDecrypted = true + return nil + } + // No ciphertext anywhere is an ordinary state — a deployment that has + // stored no secrets, keys or SSO configuration yet — and is not a failure. + return nil +} + +// ciphertextFields names, per collection, the fields that hold hex ciphertext. +// A value is a candidate only if it is a hex string long enough to carry a GCM +// nonce and tag, which is what keeps this from probing a plaintext field. +var ciphertextFields = map[string][]string{ + "keys": {"private_key_enc", "passphrase_enc"}, + "secrets": {"values"}, + "auth_providers": {"client_secret_enc"}, + "console_sessions": {"rdp_password_enc", "vnc_password_enc"}, + "settings": {"secrets_token_hash_enc"}, +} + +func findCiphertext(ctx context.Context, db *mongo.Database, coll string) (string, bool, error) { + fields, ok := ciphertextFields[coll] + if !ok { + return "", false, nil + } + cur, err := db.Collection(coll).Find(ctx, bson.M{}) + if err != nil { + return "", false, fmt.Errorf("probe %s: %w", coll, err) + } + defer cur.Close(ctx) + + for cur.Next(ctx) { + var doc bson.M + if err := cur.Decode(&doc); err != nil { + return "", false, fmt.Errorf("probe %s: %w", coll, err) + } + for _, f := range fields { + if v, ok := looksLikeCiphertext(doc[f]); ok { + return v, true, nil + } + } + } + return "", false, cur.Err() +} + +// looksLikeCiphertext accepts a hex string long enough to be a sealed value, and +// descends one level into a map so secrets' values sub-document is reachable. +func looksLikeCiphertext(v any) (string, bool) { + switch t := v.(type) { + case string: + // 12-byte nonce plus a 16-byte tag is 56 hex characters before any + // plaintext at all, so anything shorter is not a sealed value. + if len(t) < 56 || !isHex(t) { + return "", false + } + return t, true + case bson.M: + for _, inner := range t { + if s, ok := looksLikeCiphertext(inner); ok { + return s, true + } + } + } + return "", false +} + +func isHex(s string) bool { + for _, c := range s { + switch { + case c >= '0' && c <= '9', c >= 'a' && c <= 'f', c >= 'A' && c <= 'F': + default: + return false + } + } + return true +} diff --git a/shared/backup/verify_test.go b/shared/backup/verify_test.go new file mode 100644 index 0000000..a0a63c8 --- /dev/null +++ b/shared/backup/verify_test.go @@ -0,0 +1,156 @@ +package backup + +import ( + "context" + "testing" + + "gitea.hostxtra.co.uk/mrhid6/vantage/shared/cryptobox" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestVerifyMatchingKey(t *testing.T) { + client, _ := testDB(t) + archive := archiveOf(t, client, validKeyHex, false) + + rep, err := Verify(context.Background(), VerifyOptions{Archive: archive, KeyHex: validKeyHex}) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if !rep.KeyMatchesArchive { + t.Fatal("matching key reported as a mismatch") + } + if rep.ProbeAttempted { + t.Fatal("probe ran with no client supplied") + } + if !rep.OK() { + t.Fatalf("report not OK: %v", rep.Problems) + } +} + +func TestVerifyMismatchedKeyIsNotOK(t *testing.T) { + client, _ := testDB(t) + archive := archiveOf(t, client, validKeyHex, false) + other := "0000000000000000000000000000000000000000000000000000000000000002" + + rep, err := Verify(context.Background(), VerifyOptions{Archive: archive, KeyHex: other}) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if rep.KeyMatchesArchive { + t.Fatal("mismatched key reported as matching") + } + if rep.OK() { + t.Fatal("a mismatch must not report OK") + } +} + +func TestVerifyProbeDecryptsLiveCiphertext(t *testing.T) { + client, dbName := testDB(t) + ctx := context.Background() + + key, err := ParseKey(validKeyHex) + if err != nil { + t.Fatalf("ParseKey: %v", err) + } + sealed, err := cryptobox.Seal(key, "s3cret") + if err != nil { + t.Fatalf("Seal: %v", err) + } + if _, err := client.Database(dbName).Collection("secrets").InsertOne(ctx, bson.M{ + "instance_id": "i1", + "values": bson.M{"TOKEN": sealed}, + }); err != nil { + t.Fatalf("insert: %v", err) + } + + path, _ := dumpToFile(t, DumpOptions{Client: client, Database: dbName, KeyHex: validKeyHex}) + archive, err := Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer archive.Close() + + rep, err := Verify(ctx, VerifyOptions{ + Archive: archive, KeyHex: validKeyHex, Client: client, Database: dbName, + }) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if !rep.ProbeAttempted { + t.Fatal("probe did not run with a client supplied") + } + if !rep.ProbeDecrypted { + t.Fatalf("probe failed to decrypt live ciphertext: %v", rep.Problems) + } + if rep.ProbeCollection != "secrets" { + t.Fatalf("probe collection %q, want secrets", rep.ProbeCollection) + } + if !rep.OK() { + t.Fatalf("report not OK: %v", rep.Problems) + } +} + +func TestVerifyProbeFailsWithWrongKey(t *testing.T) { + client, dbName := testDB(t) + ctx := context.Background() + + key, err := ParseKey(validKeyHex) + if err != nil { + t.Fatalf("ParseKey: %v", err) + } + sealed, err := cryptobox.Seal(key, "s3cret") + if err != nil { + t.Fatalf("Seal: %v", err) + } + if _, err := client.Database(dbName).Collection("secrets").InsertOne(ctx, bson.M{ + "values": bson.M{"TOKEN": sealed}, + }); err != nil { + t.Fatalf("insert: %v", err) + } + + other := "0000000000000000000000000000000000000000000000000000000000000002" + path, _ := dumpToFile(t, DumpOptions{Client: client, Database: dbName, KeyHex: other}) + archive, err := Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer archive.Close() + + rep, err := Verify(ctx, VerifyOptions{ + Archive: archive, KeyHex: other, Client: client, Database: dbName, + }) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if rep.ProbeDecrypted { + t.Fatal("probe decrypted with the wrong key") + } + if rep.OK() { + t.Fatal("a failed probe must not report OK") + } +} + +func TestVerifyProbeAbsentCiphertextIsNotAFailure(t *testing.T) { + client, dbName := testDB(t) + seed(t, client, dbName) + + path, _ := dumpToFile(t, DumpOptions{Client: client, Database: dbName, KeyHex: validKeyHex}) + archive, err := Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer archive.Close() + + rep, err := Verify(context.Background(), VerifyOptions{ + Archive: archive, KeyHex: validKeyHex, Client: client, Database: dbName, + }) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if rep.ProbeAttempted { + t.Fatal("probe claims to have run against a database with no ciphertext") + } + if !rep.OK() { + t.Fatalf("a database storing no secrets must still verify: %v", rep.Problems) + } +}