47dd0bd244
internal/escrow: zero-knowledge escrow creation. R = 10 EFF-wordlist words (crypto/rand, ~129 bits); wrap K under R via PBS-native key change-passphrase driven over a stdlib pty (x/sys/unix; output discarded so R can't leak, F-A2); self-verify the blob recovers K (fingerprint match) before shipping. Opt-in (b) R-wrapped offline copy + (a) raw paperkey. Live K is byte-unchanged (operates on a copy). --selftest=escrow-create (-storage/-paperkey/-offline/-upload). Posture config field (zero_knowledge default). PBSEncKeyPath helper. Grounded by the escrow spike findings. Tests: R entropy>=128/format/uniqueness; integration round-trip (wrap->unwrap fingerprint match, wrong-R fails, K byte-unchanged) guarded to linux+pbc. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
189 lines
5.5 KiB
Go
189 lines
5.5 KiB
Go
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 + ")"
|
|
}
|