a452dc3314
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.
205 lines
6.5 KiB
Go
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 + ")"
|
|
}
|