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.
184 lines
5.4 KiB
Go
184 lines
5.4 KiB
Go
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
|
|
}
|