Files
vantage/shared/backup/verify.go
T

198 lines
6.1 KiB
Go

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
}