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.
This commit is contained in:
2026-09-07 11:26:34 +00:00
parent 5b7648577e
commit 01844ef285
2 changed files with 339 additions and 0 deletions
+183
View File
@@ -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
}
+156
View File
@@ -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)
}
}