slice 10C: escrow consumption — productionize the spike (v0.17.0)
Add escrow.Consume(blob, R, expectedFingerprint, keyDest): Unwrap -> fingerprint gate -> atomic 0600 install. Bakes in the spike findings — wrong R fails closed (no write), the fingerprint gate runs BEFORE any restore (no install on mismatch), the input blob is read-only (retryable), K is never mutated, R/key bytes never logged. Zero-knowledge holds: the hub serves all but R (by hand). --selftest=escrow-consume invokes the real path live. Agent-only; no hub change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
package escrow
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ---- pure-unit tests (no proxmox-backup-client) ----------------------------------------------
|
||||
|
||||
func TestConsume_InputValidation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dest := filepath.Join(t.TempDir(), "k.json")
|
||||
cases := []struct {
|
||||
name string
|
||||
blob []byte
|
||||
r, fp, dst string
|
||||
}{
|
||||
{"empty blob", nil, "R", "fp", dest},
|
||||
{"empty R", []byte("x"), "", "fp", dest},
|
||||
{"empty fingerprint", []byte("x"), "R", "", dest},
|
||||
{"empty dest", []byte("x"), "R", "fp", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if err := Consume(ctx, c.blob, c.r, c.fp, c.dst); err == nil {
|
||||
t.Errorf("%s: expected an error", c.name)
|
||||
}
|
||||
}
|
||||
// no key written on a validation failure
|
||||
if _, err := os.Stat(dest); !os.IsNotExist(err) {
|
||||
t.Error("a validation failure left a key behind")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFingerprintsEqual_NormalizesFormatting(t *testing.T) {
|
||||
if !fingerprintsEqual("01:36:E9:FE", "0136e9fe") {
|
||||
t.Error("case/colon-insensitive compare failed")
|
||||
}
|
||||
if fingerprintsEqual("", "") {
|
||||
t.Error("two empty fingerprints must NOT compare equal (no gate-bypass on empty)")
|
||||
}
|
||||
if fingerprintsEqual("01:36", "ff:ee") {
|
||||
t.Error("distinct fingerprints compared equal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallKey_AtomicAnd0600(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "src")
|
||||
if err := os.WriteFile(src, []byte("keymaterial"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dest := filepath.Join(dir, "sub", "installed.json") // parent dir must be created
|
||||
if err := installKey(src, dest); err != nil {
|
||||
t.Fatalf("installKey: %v", err)
|
||||
}
|
||||
b, _ := os.ReadFile(dest)
|
||||
if string(b) != "keymaterial" {
|
||||
t.Errorf("installed content = %q", b)
|
||||
}
|
||||
if runtime.GOOS != "windows" { // Windows does not enforce Unix file modes
|
||||
if info, _ := os.Stat(dest); info.Mode().Perm() != 0o600 {
|
||||
t.Errorf("installed key mode = %v, want 0600", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
// no .tmp- siblings left behind
|
||||
entries, _ := os.ReadDir(filepath.Dir(dest))
|
||||
for _, e := range entries {
|
||||
if filepath.Ext(e.Name()) != ".json" && len(e.Name()) > 4 && e.Name()[:4] == "inst" {
|
||||
t.Errorf("temp sibling left behind: %s", e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- integration tests (real proxmox-backup-client wrap/unwrap round-trip) --------------------
|
||||
|
||||
func ensurePbc(t *testing.T) {
|
||||
t.Helper()
|
||||
if !pbcAvailable() {
|
||||
t.Skip("skipping: proxmox-backup-client + linux required (runs on the demo/build host)")
|
||||
}
|
||||
if _, err := os.Stat(pbcBinary); err != nil {
|
||||
if p, e := exec.LookPath("proxmox-backup-client"); e == nil {
|
||||
pbcBinary = p
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// makeBlob creates a throwaway kdf=none key Kt, wraps it under R, and returns (blobBytes, Kt-fp).
|
||||
func makeBlob(t *testing.T, dir, R string) ([]byte, string) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
Kt := filepath.Join(dir, "Kt.json")
|
||||
if out, err := exec.Command(pbcBinary, "key", "create", Kt, "--kdf", "none").CombinedOutput(); err != nil {
|
||||
t.Fatalf("key create: %v: %s", err, out)
|
||||
}
|
||||
fp, err := KeyFingerprint(ctx, Kt)
|
||||
if err != nil {
|
||||
t.Fatalf("fingerprint: %v", err)
|
||||
}
|
||||
blobPath := filepath.Join(dir, "escrow.blob")
|
||||
if err := Wrap(ctx, Kt, blobPath, R); err != nil {
|
||||
t.Fatalf("Wrap: %v", err)
|
||||
}
|
||||
blob, err := os.ReadFile(blobPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return blob, fp
|
||||
}
|
||||
|
||||
// VALID: Consume installs a key whose fingerprint matches the expected; blob is byte-unchanged.
|
||||
func TestConsume_ValidInstallsGatedKey(t *testing.T) {
|
||||
ensurePbc(t)
|
||||
ctx := context.Background()
|
||||
dir := t.TempDir()
|
||||
const R = "throwaway-correct-horse-battery-staple"
|
||||
blob, wantFP := makeBlob(t, dir, R)
|
||||
blobBefore := append([]byte(nil), blob...)
|
||||
|
||||
dest := filepath.Join(dir, "freshbox", "encryption-key.json")
|
||||
if err := Consume(ctx, blob, R, wantFP, dest); err != nil {
|
||||
t.Fatalf("Consume(valid): %v", err)
|
||||
}
|
||||
// key installed + fingerprint matches the expected gate target.
|
||||
gotFP, err := KeyFingerprint(ctx, dest)
|
||||
if err != nil {
|
||||
t.Fatalf("fingerprint(dest): %v", err)
|
||||
}
|
||||
if !fingerprintsEqual(gotFP, wantFP) {
|
||||
t.Errorf("installed key fingerprint %q != expected %q", gotFP, wantFP)
|
||||
}
|
||||
// 0600.
|
||||
if info, _ := os.Stat(dest); info.Mode().Perm() != 0o600 {
|
||||
t.Errorf("installed key mode = %v, want 0600", info.Mode().Perm())
|
||||
}
|
||||
// blob read-only / unchanged (retryable).
|
||||
if !bytes.Equal(blob, blobBefore) {
|
||||
t.Error("Consume mutated the input blob — must be read-only/retryable")
|
||||
}
|
||||
}
|
||||
|
||||
// WRONG R: Unwrap fails closed → clear error, NO file at dest, blob unchanged.
|
||||
func TestConsume_WrongRNoInstall(t *testing.T) {
|
||||
ensurePbc(t)
|
||||
ctx := context.Background()
|
||||
dir := t.TempDir()
|
||||
blob, wantFP := makeBlob(t, dir, "the-correct-code")
|
||||
blobBefore := append([]byte(nil), blob...)
|
||||
|
||||
dest := filepath.Join(dir, "freshbox", "encryption-key.json")
|
||||
err := Consume(ctx, blob, "DEFINITELY-the-wrong-code", wantFP, dest)
|
||||
if err == nil {
|
||||
t.Fatal("a wrong recovery code must fail")
|
||||
}
|
||||
if _, statErr := os.Stat(dest); !os.IsNotExist(statErr) {
|
||||
t.Errorf("a wrong-R Consume left a key at dest (%v) — must install nothing", statErr)
|
||||
}
|
||||
if !bytes.Equal(blob, blobBefore) {
|
||||
t.Error("wrong-R Consume mutated the blob — must be retryable")
|
||||
}
|
||||
}
|
||||
|
||||
// FINGERPRINT MISMATCH: right unwrap, wrong expected fingerprint → fail fast, NO install (proves
|
||||
// the gate runs BEFORE any restore).
|
||||
func TestConsume_FingerprintMismatchNoInstall(t *testing.T) {
|
||||
ensurePbc(t)
|
||||
ctx := context.Background()
|
||||
dir := t.TempDir()
|
||||
const R = "the-correct-code"
|
||||
blob, _ := makeBlob(t, dir, R)
|
||||
|
||||
dest := filepath.Join(dir, "freshbox", "encryption-key.json")
|
||||
// Right R, but an expected fingerprint for a DIFFERENT datastore/key.
|
||||
err := Consume(ctx, blob, R, "ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff", dest)
|
||||
if err == nil {
|
||||
t.Fatal("a fingerprint mismatch must fail")
|
||||
}
|
||||
if _, statErr := os.Stat(dest); !os.IsNotExist(statErr) {
|
||||
t.Errorf("a fingerprint-mismatch Consume left a key at dest — the gate must run before install")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user