package escrow import ( "context" "os" "os/exec" "path/filepath" "runtime" "strings" "testing" ) func TestWordlistLoaded(t *testing.T) { if WordlistSize() != 7776 { t.Fatalf("EFF large wordlist should be 7776 words, got %d", WordlistSize()) } } func TestGenerateRecoveryCode_EntropyAndFormat(t *testing.T) { if RecoveryCodeEntropyBits() < 128 { t.Fatalf("recovery code entropy must be ≥128 bits, got %.1f", RecoveryCodeEntropyBits()) } inList := make(map[string]bool, WordlistSize()) for _, w := range wordlist { inList[w] = true } for i := 0; i < 50; i++ { r, err := GenerateRecoveryCode() if err != nil { t.Fatalf("GenerateRecoveryCode: %v", err) } words := strings.Split(r, "-") if len(words) != RecoveryCodeWords { t.Fatalf("recovery code must be %d words, got %d (%q)", RecoveryCodeWords, len(words), r) } for _, w := range words { if !inList[w] { t.Errorf("recovery-code word %q is not from the EFF wordlist", w) } } } } func TestGenerateRecoveryCode_Unique(t *testing.T) { seen := make(map[string]bool) for i := 0; i < 200; i++ { r, err := GenerateRecoveryCode() if err != nil { t.Fatal(err) } if seen[r] { t.Fatalf("recovery code collision within 200 draws — entropy too low") } seen[r] = true } } // --- integration: real PBS key wrap/unwrap round-trip (linux + proxmox-backup-client only) --- func pbcAvailable() bool { if runtime.GOOS != "linux" { return false } _, err := exec.LookPath(strings.TrimPrefix(pbcBinary, "")) if err != nil { // fall back to PATH lookup of the basename _, err = exec.LookPath("proxmox-backup-client") } return err == nil } func TestWrapUnwrapRoundTrip(t *testing.T) { 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 } } ctx := context.Background() dir := t.TempDir() Kt := filepath.Join(dir, "Kt.json") // throwaway unencrypted key (mimics the live K posture). NOT a real K. if out, err := exec.Command(pbcBinary, "key", "create", Kt, "--kdf", "none").CombinedOutput(); err != nil { t.Fatalf("key create: %v: %s", err, out) } ktBefore, _ := os.ReadFile(Kt) fp0, err := KeyFingerprint(ctx, Kt) if err != nil { t.Fatalf("fingerprint: %v", err) } const Rt = "throwaway-test-passphrase-correct-horse" blob := filepath.Join(dir, "escrow.blob") if err := Wrap(ctx, Kt, blob, Rt); err != nil { t.Fatalf("Wrap: %v", err) } // the live key file must be byte-unchanged after wrap. if ktAfter, _ := os.ReadFile(Kt); string(ktAfter) != string(ktBefore) { t.Fatal("the live key file was modified by Wrap — must operate on a copy") } // blob is the passphrase-protected (scrypt) form. if kdf := keyKDF(t, blob); kdf != "scrypt" { t.Fatalf("wrapped blob kdf = %q, want scrypt", kdf) } // unwrap with the RIGHT passphrase → recovered key fingerprint matches the original. rec := filepath.Join(dir, "rec.blob") if err := copyFile(blob, rec, 0o600); err != nil { t.Fatal(err) } if err := Unwrap(ctx, rec, Rt); err != nil { t.Fatalf("Unwrap (correct R): %v", err) } fp2, err := KeyFingerprint(ctx, rec) if err != nil { t.Fatal(err) } if fp2 != fp0 { t.Fatalf("recovered key fingerprint %q != original %q", fp2, fp0) } // unwrap with the WRONG passphrase → must fail (and not produce the key). wrong := filepath.Join(dir, "wrong.blob") if err := copyFile(blob, wrong, 0o600); err != nil { t.Fatal(err) } if err := Unwrap(ctx, wrong, "definitely-the-wrong-code"); err == nil { t.Fatal("Unwrap with the WRONG recovery code must FAIL") } } func TestCreate_SelfVerifiesAndKeepsKey(t *testing.T) { if !pbcAvailable() { t.Skip("skipping: proxmox-backup-client + linux required") } if _, err := os.Stat(pbcBinary); err != nil { if p, e := exec.LookPath("proxmox-backup-client"); e == nil { pbcBinary = p } } ctx := context.Background() dir := t.TempDir() 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) } ktBefore, _ := os.ReadFile(Kt) R, res, err := Create(ctx, CreateOptions{KeyPath: Kt, Posture: PostureZeroKnowledge}) if err != nil { t.Fatalf("Create: %v", err) } if len(R) == 0 || len(strings.Split(R, "-")) != RecoveryCodeWords { t.Errorf("Create returned a malformed recovery code") } if len(res.Blob) == 0 { t.Error("Create returned an empty blob") } if res.KeyFingerprint == "" || res.Posture != PostureZeroKnowledge { t.Errorf("result meta wrong: %+v", res) } // the blob is ciphertext, not the key: it must NOT equal the live key bytes. if string(res.Blob) == string(ktBefore) { t.Fatal("blob equals the plaintext key — wrap did not encrypt") } // the live key file is byte-unchanged. if ktAfter, _ := os.ReadFile(Kt); string(ktAfter) != string(ktBefore) { t.Fatal("Create modified the live key file") } } func keyKDF(t *testing.T, path string) string { t.Helper() out, err := exec.Command(pbcBinary, "key", "show", path, "--output-format", "json").Output() if err != nil { t.Fatalf("key show: %v", err) } // minimal parse to avoid importing json twice; the field is `"kdf":"scrypt"` or `"none"`. s := string(out) for _, k := range []string{"scrypt", "none", "pbkdf2"} { if strings.Contains(s, `"kdf":"`+k+`"`) || strings.Contains(s, `"kdf": "`+k+`"`) { return k } } return "?(" + s + ")" }