198 lines
6.1 KiB
Go
198 lines
6.1 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.
|
|
//
|
|
// 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
|
|
}
|