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:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user