package backup import ( "context" "fmt" "gitea.hostxtra.co.uk/vantage/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. // // This map MIRRORS BY HAND the bson tags in server/internal/models, which this // package cannot import: shared/ is a separate module and models is under // server/internal. It must change in the same commit as any rename of the // fields below — the same mirrored-constant hazard as web/lib/targets.ts and // services.MaxWorkloadLogLines. The sources are: // // keys — models/key.go: private_key_enc, passphrase_enc // 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 // // 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. var ciphertextFields = map[string][]string{ "keys": {"private_key_enc", "passphrase_enc"}, "secrets": {"encrypted_value"}, "auth_providers": {"client_secret_enc"}, "console_sessions": {"rdp_user_enc", "rdp_pass_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. It // descends into a sub-document so a field that holds a map of sealed values is // still 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 }