diff --git a/backup/fingerprint.go b/backup/fingerprint.go new file mode 100644 index 0000000..5437653 --- /dev/null +++ b/backup/fingerprint.go @@ -0,0 +1,57 @@ +// Package backup dumps and restores a whole Vantage MongoDB database. +// +// The archive never contains KEY_ENCRYPTION_KEY. It contains a fingerprint of +// it, which is enough to answer "will this archive restore into this +// deployment" and is not a hint at the value. +package backup + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + + "gitea.hostxtra.co.uk/mrhid6/vantage/shared/cryptobox" +) + +// ErrNoKey is returned when no key was supplied at all. It is distinct from +// ErrBadKey because the operator remedies are different: one is "set the +// variable", the other is "the value you set is wrong". +var ErrNoKey = errors.New("KEY_ENCRYPTION_KEY is not set") + +// ErrBadKey is returned when a key was supplied but is not 64 hex characters. +var ErrBadKey = errors.New("KEY_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)") + +// ParseKey decodes the hex form used by KEY_ENCRYPTION_KEY. +func ParseKey(hexKey string) ([]byte, error) { + if hexKey == "" { + return nil, ErrNoKey + } + key, err := hex.DecodeString(hexKey) + if err != nil { + return nil, fmt.Errorf("%w: not hexadecimal", ErrBadKey) + } + if len(key) != cryptobox.KeySize { + return nil, fmt.Errorf("%w: decoded to %d bytes", ErrBadKey, len(key)) + } + return key, nil +} + +// Fingerprint is the SHA-256 of the raw key bytes, hex encoded. +// +// Of the raw bytes rather than of the hex string, so an operator who writes the +// key in uppercase in one deployment and lowercase in another still gets one +// fingerprint for one key. +func Fingerprint(key []byte) string { + sum := sha256.Sum256(key) + return hex.EncodeToString(sum[:]) +} + +// FingerprintHex parses and fingerprints in one step. +func FingerprintHex(hexKey string) (string, error) { + key, err := ParseKey(hexKey) + if err != nil { + return "", err + } + return Fingerprint(key), nil +} diff --git a/backup/fingerprint_test.go b/backup/fingerprint_test.go new file mode 100644 index 0000000..d05fc7a --- /dev/null +++ b/backup/fingerprint_test.go @@ -0,0 +1,61 @@ +package backup + +import ( + "errors" + "strings" + "testing" +) + +const validKeyHex = "0000000000000000000000000000000000000000000000000000000000000001" + +func TestFingerprintIsStableAndNotTheKey(t *testing.T) { + fp, err := FingerprintHex(validKeyHex) + if err != nil { + t.Fatalf("FingerprintHex: %v", err) + } + if len(fp) != 64 { + t.Fatalf("fingerprint is %d chars, want 64", len(fp)) + } + if strings.EqualFold(fp, validKeyHex) { + t.Fatal("fingerprint equals the key") + } + again, err := FingerprintHex(validKeyHex) + if err != nil { + t.Fatalf("FingerprintHex: %v", err) + } + if fp != again { + t.Fatal("fingerprint is not stable across calls") + } +} + +func TestFingerprintDiffersPerKey(t *testing.T) { + other := "0000000000000000000000000000000000000000000000000000000000000002" + a, err := FingerprintHex(validKeyHex) + if err != nil { + t.Fatalf("FingerprintHex: %v", err) + } + b, err := FingerprintHex(other) + if err != nil { + t.Fatalf("FingerprintHex: %v", err) + } + if a == b { + t.Fatal("two different keys produced the same fingerprint") + } +} + +func TestParseKeyRejections(t *testing.T) { + if _, err := ParseKey(""); !errors.Is(err, ErrNoKey) { + t.Fatalf("empty key: got %v, want ErrNoKey", err) + } + for _, bad := range []string{"zz", validKeyHex[:62], validKeyHex + "00"} { + if _, err := ParseKey(bad); !errors.Is(err, ErrBadKey) { + t.Fatalf("key %q: got %v, want ErrBadKey", bad, err) + } + } +} + +func TestParseKeyAcceptsUppercase(t *testing.T) { + if _, err := ParseKey(strings.ToUpper(validKeyHex)); err != nil { + t.Fatalf("uppercase hex rejected: %v", err) + } +}