Files
felhom-agent/internal/escrow/escrow_test.go
T
admin a452dc3314 escrow: a recovery code can no longer contain a hyphenated word (v0.93.0)
The EFF large list has exactly 4 entries containing the join separator
(drop-down, felt-tip, t-shirt, yo-yo). Drawing one made a code read as 11
words instead of 10 - ambiguous to transcribe in precisely the situation R
exists for. Filter them at init; the draw space goes 7776 -> 7772 and the
10-word code goes 129.248 -> 129.241 bits, still well over the 128 floor.

Generation-only: already-issued codes stay valid, R is verified as a whole
passphrase and never re-split.

Also fixes the ~1/5 flake this same defect caused: the test counted words by
splitting the joined string. It now counts what the generator drew and
asserts segmentation separately, plus a deterministic red-proof fixture.
2026-07-21 14:46:51 +02:00

205 lines
6.5 KiB
Go

package escrow
import (
"context"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestWordlistLoaded(t *testing.T) {
// The EFF large list is 7776 entries; joinSafe removes the 4 that contain RecoveryCodeSep
// (drop-down, felt-tip, t-shirt, yo-yo), leaving 7772 as the effective draw space.
if got := WordlistSize(); got != 7772 {
t.Fatalf("effective wordlist should be 7772 words (7776 EFF - 4 hyphenated), got %d", got)
}
if got := WordlistFilteredOut(); got != 4 {
t.Fatalf("joinSafe should have removed exactly 4 hyphenated entries, removed %d", got)
}
if got := WordlistSize() + WordlistFilteredOut(); got != 7776 {
t.Fatalf("filtered + removed should reconstitute the 7776-word EFF list, got %d", got)
}
}
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++ {
// Count words by GENERATION count, not by re-splitting the joined string: the two agree
// only because joinSafe holds, and conflating them is what made this test flake ~1/5.
words, err := generateWords(wordlist)
if err != nil {
t.Fatalf("generateWords: %v", err)
}
if len(words) != RecoveryCodeWords {
t.Fatalf("generator must draw %d words, drew %d", RecoveryCodeWords, len(words))
}
for _, w := range words {
if !inList[w] {
t.Errorf("recovery-code word %q is not from the EFF wordlist", w)
}
}
// Separately assert the property joinSafe buys: the joined code segments back to the same
// count. Never print r — it is a live-shaped secret.
r := strings.Join(words, RecoveryCodeSep)
if got := len(strings.Split(r, RecoveryCodeSep)); got != RecoveryCodeWords {
t.Fatalf("joined code must segment into %d words, got %d (a drawn word contained %q)",
RecoveryCodeWords, got, RecoveryCodeSep)
}
}
}
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 + ")"
}