slice 7: PBS recovery-code escrow creation (agent, Phase B) (v0.9.0 wip)
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>
This commit is contained in:
@@ -28,9 +28,20 @@ type Config struct {
|
||||
Hub HubConfig `json:"hub"`
|
||||
Storage StorageConfig `json:"storage"`
|
||||
Backup BackupConfig `json:"backup"`
|
||||
Escrow EscrowConfig `json:"escrow"`
|
||||
LogLevel string `json:"log_level"` // debug|info|warn|error (default info)
|
||||
}
|
||||
|
||||
// EscrowConfig configures PBS recovery-code escrow creation (slice 7, doc 03 §8a). Enrollment-time
|
||||
// only (not the steady-state daemon). The default posture is zero-knowledge (Felhom holds the
|
||||
// opaque blob, the customer holds the recovery code).
|
||||
type EscrowConfig struct {
|
||||
// Posture is the key-custody posture; "" → zero_knowledge (the only one implemented this slice).
|
||||
Posture string `json:"posture"`
|
||||
// PBSStorageID is the pbs storage whose client encryption key is escrowed (e.g. "felhom-pbs").
|
||||
PBSStorageID string `json:"pbs_storage_id"`
|
||||
}
|
||||
|
||||
// BackupConfig tunes the slice-6 backup + self-restore-test layer. The restore-test runs on
|
||||
// an agent-internal cadence (no hub policy needed — it's self-validation); the backup
|
||||
// schedule/retention/target-selection policy is hub-manifest-owned and unfed until slice 10.
|
||||
@@ -91,11 +102,20 @@ func (b BackupConfig) PBSVerifyCadence() time.Duration {
|
||||
|
||||
// PBSSecretPath returns the path to a pbs storage's token-secret file.
|
||||
func (b BackupConfig) PBSSecretPath(storageID string) string {
|
||||
dir := b.PBSSecretDir
|
||||
if dir == "" {
|
||||
dir = "/etc/pve/priv/storage"
|
||||
return b.pbsSecretDir() + "/" + storageID + ".pw"
|
||||
}
|
||||
|
||||
// PBSEncKeyPath returns the path to a pbs storage's CLIENT ENCRYPTION KEY (K) file — the key the
|
||||
// escrow wraps (slice 7). PVE stores it alongside the token secret as <id>.enc, 0600 root.
|
||||
func (b BackupConfig) PBSEncKeyPath(storageID string) string {
|
||||
return b.pbsSecretDir() + "/" + storageID + ".enc"
|
||||
}
|
||||
|
||||
func (b BackupConfig) pbsSecretDir() string {
|
||||
if b.PBSSecretDir == "" {
|
||||
return "/etc/pve/priv/storage"
|
||||
}
|
||||
return dir + "/" + storageID + ".pw"
|
||||
return b.PBSSecretDir
|
||||
}
|
||||
|
||||
// ScratchBand returns the effective [min,max] scratch VMID band (defaults applied).
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,184 @@
|
||||
package escrow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// pbcBinary is the PBS client CLI the escrow shells out to (the PBS-native key+passphrase KDF).
|
||||
var pbcBinary = "/usr/bin/proxmox-backup-client"
|
||||
|
||||
// Posture is the customer's key-custody posture (doc 03 §8a). Only the zero-knowledge DEFAULT is
|
||||
// implemented this slice; the other postures are documented (doc 03 §8a) and implemented only when
|
||||
// a customer chooses them.
|
||||
type Posture string
|
||||
|
||||
const (
|
||||
// PostureZeroKnowledge: Felhom storage + customer-only key. The default. Felhom holds the
|
||||
// opaque R-wrapped blob (cannot open it) and the customer holds R.
|
||||
PostureZeroKnowledge Posture = "zero_knowledge"
|
||||
)
|
||||
|
||||
// DefaultPosture is the posture when none is configured.
|
||||
const DefaultPosture = PostureZeroKnowledge
|
||||
|
||||
// CreateOptions parameterizes one escrow creation.
|
||||
type CreateOptions struct {
|
||||
// KeyPath is the live PBS client encryption key K (e.g. /etc/pve/priv/storage/felhom-pbs.enc).
|
||||
// It is read (copied) but NEVER modified.
|
||||
KeyPath string
|
||||
// Posture is recorded for display/audit; this slice implements only zero-knowledge.
|
||||
Posture Posture
|
||||
// WantOfflineCopy: opt-in (b) — also return the wrapped blob for the customer to print
|
||||
// (still two-factor: useless without R). No extra trust.
|
||||
WantOfflineCopy bool
|
||||
// WantPaperkey: opt-in (a) — also return the RAW-key paperkey. Single-factor + unrevocable;
|
||||
// the caller must surface the loud caveat. Off by default.
|
||||
WantPaperkey bool
|
||||
}
|
||||
|
||||
// CreateResult is the non-secret output of escrow creation. NOTE: the recovery code R is returned
|
||||
// SEPARATELY (not in this struct) so it can never be accidentally logged via the result.
|
||||
type CreateResult struct {
|
||||
Blob []byte // the opaque R-wrapped escrow blob → hub (Phase C). Ciphertext, not K.
|
||||
KeyFingerprint string // the PBS key fingerprint (identifies the key; safe to display)
|
||||
Posture Posture // the posture used
|
||||
EntropyBits float64 // R's approximate entropy (for display; never R itself)
|
||||
OfflineCopy []byte // (b) the same wrapped blob, if WantOfflineCopy (for the customer to print)
|
||||
Paperkey string // (a) raw paperkey text, if WantPaperkey — SECRET-adjacent (single factor)
|
||||
}
|
||||
|
||||
// Create generates a recovery code R, produces the R-wrapped escrow blob from the live key, and
|
||||
// SELF-VERIFIES it is recoverable (unwraps a copy with R and checks the key fingerprint matches —
|
||||
// "an escrow you haven't recovered isn't an escrow"). It returns R SEPARATELY: the caller surfaces
|
||||
// R to the customer exactly once and must not log it. The live key file is byte-unchanged.
|
||||
func Create(ctx context.Context, opts CreateOptions) (recoveryCode string, res CreateResult, err error) {
|
||||
if opts.KeyPath == "" {
|
||||
return "", CreateResult{}, fmt.Errorf("escrow: KeyPath (the live PBS key) is required")
|
||||
}
|
||||
posture := opts.Posture
|
||||
if posture == "" {
|
||||
posture = DefaultPosture
|
||||
}
|
||||
if posture != PostureZeroKnowledge {
|
||||
return "", CreateResult{}, fmt.Errorf("escrow: posture %q not implemented this slice (only %q)", posture, PostureZeroKnowledge)
|
||||
}
|
||||
|
||||
keyFP, err := KeyFingerprint(ctx, opts.KeyPath)
|
||||
if err != nil {
|
||||
return "", CreateResult{}, fmt.Errorf("escrow: read key fingerprint: %w", err)
|
||||
}
|
||||
|
||||
R, err := GenerateRecoveryCode()
|
||||
if err != nil {
|
||||
return "", CreateResult{}, err
|
||||
}
|
||||
|
||||
work, err := os.MkdirTemp("", "felhom-escrow-")
|
||||
if err != nil {
|
||||
return "", CreateResult{}, fmt.Errorf("escrow: tempdir: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(work) // the blob bytes are returned in memory; no plaintext-K temp lingers
|
||||
blobPath := filepath.Join(work, "escrow.blob")
|
||||
|
||||
if err := Wrap(ctx, opts.KeyPath, blobPath, R); err != nil {
|
||||
return "", CreateResult{}, err
|
||||
}
|
||||
|
||||
// Self-verify recoverability: unwrap a COPY with R and confirm the recovered key's fingerprint
|
||||
// matches the original. Never ship a blob we can't recover.
|
||||
verifyPath := filepath.Join(work, "verify.blob")
|
||||
if err := copyFile(blobPath, verifyPath, 0o600); err != nil {
|
||||
return "", CreateResult{}, fmt.Errorf("escrow: verify copy: %w", err)
|
||||
}
|
||||
if err := Unwrap(ctx, verifyPath, R); err != nil {
|
||||
return "", CreateResult{}, fmt.Errorf("escrow: self-verify unwrap: %w", err)
|
||||
}
|
||||
recFP, err := KeyFingerprint(ctx, verifyPath)
|
||||
if err != nil {
|
||||
return "", CreateResult{}, fmt.Errorf("escrow: self-verify fingerprint: %w", err)
|
||||
}
|
||||
if recFP != keyFP {
|
||||
return "", CreateResult{}, fmt.Errorf("escrow: self-verify FAILED — recovered key fingerprint mismatch (blob is not recoverable)")
|
||||
}
|
||||
|
||||
blob, err := os.ReadFile(blobPath)
|
||||
if err != nil {
|
||||
return "", CreateResult{}, fmt.Errorf("escrow: read blob: %w", err)
|
||||
}
|
||||
res = CreateResult{Blob: blob, KeyFingerprint: keyFP, Posture: posture, EntropyBits: RecoveryCodeEntropyBits()}
|
||||
if opts.WantOfflineCopy {
|
||||
res.OfflineCopy = blob // same two-factor blob; customer prints it
|
||||
}
|
||||
if opts.WantPaperkey {
|
||||
pk, err := Paperkey(ctx, opts.KeyPath)
|
||||
if err != nil {
|
||||
return "", CreateResult{}, fmt.Errorf("escrow: paperkey: %w", err)
|
||||
}
|
||||
res.Paperkey = pk
|
||||
}
|
||||
return R, res, nil
|
||||
}
|
||||
|
||||
// Wrap produces the R-wrapped escrow blob from the live PBS key file: copy K → blob, then re-key
|
||||
// the copy to kdf=scrypt under R via a pty (spike F-A1/F-A2). The live key file is NOT modified.
|
||||
func Wrap(ctx context.Context, keyPath, blobPath, recoveryCode string) error {
|
||||
if err := copyFile(keyPath, blobPath, 0o600); err != nil {
|
||||
return fmt.Errorf("escrow: copy key: %w", err)
|
||||
}
|
||||
// `key change-passphrase --kdf scrypt` prompts New + Verify → feed R twice.
|
||||
if err := runWithPassphrase(ctx, recoveryCode, 2, pbcBinary, "key", "change-passphrase", blobPath, "--kdf", "scrypt"); err != nil {
|
||||
_ = os.Remove(blobPath)
|
||||
return fmt.Errorf("escrow: wrap: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unwrap recovers the unencrypted key from an R-wrapped blob, in place: re-key to kdf=none under R
|
||||
// via a pty (one prompt). Used by Create's self-verify; the real recovery is slice 10.
|
||||
func Unwrap(ctx context.Context, blobPath, recoveryCode string) error {
|
||||
// `key change-passphrase --kdf none` prompts the Encryption Key Password → feed R once.
|
||||
if err := runWithPassphrase(ctx, recoveryCode, 1, pbcBinary, "key", "change-passphrase", blobPath, "--kdf", "none"); err != nil {
|
||||
return fmt.Errorf("escrow: unwrap: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Paperkey emits the RAW-key paperkey (opt-in (a)) as text. Single-factor + unrevocable — the
|
||||
// caller surfaces the caveat. SECRET-adjacent: do not log it.
|
||||
func Paperkey(ctx context.Context, keyPath string) (string, error) {
|
||||
out, err := exec.CommandContext(ctx, pbcBinary, "key", "paperkey", keyPath, "--output-format", "text").Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("escrow: paperkey: %w", err)
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
// KeyFingerprint returns a PBS key file's fingerprint (identifies the key; safe to display).
|
||||
func KeyFingerprint(ctx context.Context, keyPath string) (string, error) {
|
||||
out, err := exec.CommandContext(ctx, pbcBinary, "key", "show", keyPath, "--output-format", "json").Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("escrow: key show: %w", err)
|
||||
}
|
||||
var meta struct {
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
}
|
||||
if err := json.Unmarshal(out, &meta); err != nil {
|
||||
return "", fmt.Errorf("escrow: parse key show: %w", err)
|
||||
}
|
||||
return meta.Fingerprint, nil
|
||||
}
|
||||
|
||||
// copyFile copies src→dst with the given mode (0600 for key material). Reads the whole file
|
||||
// (PBS key files are tiny).
|
||||
func copyFile(src, dst string, mode os.FileMode) error {
|
||||
b, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(dst, b, mode)
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
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 + ")"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//go:build linux
|
||||
|
||||
package escrow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// runWithPassphrase runs a TTY-requiring command (PBS `key change-passphrase`, F-A1 in the spike
|
||||
// findings) on a pty, feeding `passphrase` `reps` times (once per prompt) and DISCARDING all pty
|
||||
// output so the echoed passphrase can never leak (F-A2). Linux-only — the agent runs on Proxmox
|
||||
// hosts; a non-linux stub returns an error.
|
||||
func runWithPassphrase(ctx context.Context, passphrase string, reps int, name string, args ...string) error {
|
||||
master, err := os.OpenFile("/dev/ptmx", os.O_RDWR, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("escrow: open ptmx: %w", err)
|
||||
}
|
||||
defer master.Close()
|
||||
if err := unix.IoctlSetPointerInt(int(master.Fd()), unix.TIOCSPTLCK, 0); err != nil { // unlock
|
||||
return fmt.Errorf("escrow: unlock pty: %w", err)
|
||||
}
|
||||
ptn, err := unix.IoctlGetInt(int(master.Fd()), unix.TIOCGPTN)
|
||||
if err != nil {
|
||||
return fmt.Errorf("escrow: pty number: %w", err)
|
||||
}
|
||||
slave, err := os.OpenFile(fmt.Sprintf("/dev/pts/%d", ptn), os.O_RDWR|syscall.O_NOCTTY, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("escrow: open pts: %w", err)
|
||||
}
|
||||
defer slave.Close()
|
||||
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
cmd.Stdin, cmd.Stdout, cmd.Stderr = slave, slave, slave
|
||||
// New session + the slave (fd 0) becomes the controlling terminal, so the child's tty prompts
|
||||
// read from / write to the pty rather than failing "no tty".
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("escrow: start %s: %w", name, err)
|
||||
}
|
||||
|
||||
// Feed the passphrase once (the tty line discipline buffers both lines for the sequential
|
||||
// New/Verify prompts), then DISCARD everything the pty emits — the passphrase is echoed back
|
||||
// on the master fd and must never reach a log.
|
||||
go func() { _, _ = master.WriteString(strings.Repeat(passphrase+"\n", reps)) }()
|
||||
go func() { _, _ = io.Copy(io.Discard, master) }()
|
||||
|
||||
return cmd.Wait()
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//go:build !linux
|
||||
|
||||
package escrow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// runWithPassphrase is unsupported off Linux — the agent runs only on Proxmox (Linux) hosts. This
|
||||
// stub lets the package compile (and its pure-Go parts, e.g. recovery-code generation, be tested)
|
||||
// on other platforms.
|
||||
func runWithPassphrase(_ context.Context, _ string, _ int, _ string, _ ...string) error {
|
||||
return fmt.Errorf("escrow: pty-driven key wrap is Linux-only (GOOS=%s)", runtime.GOOS)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Package escrow creates the PBS recovery-code escrow (doc 03 §8a, slice 7): an R-wrapped,
|
||||
// zero-knowledge copy of the live PBS client encryption key K. It is the FIRST code that touches
|
||||
// K and introduces the customer recovery code R.
|
||||
//
|
||||
// Secret discipline (overriding):
|
||||
// - R (recovery code) is generated with crypto/rand, ≥128 bits, word-list form; surfaced to the
|
||||
// customer EXACTLY ONCE (by the caller); NEVER logged, persisted, or returned in a struct that
|
||||
// gets logged. GenerateRecoveryCode returns it as a bare string the caller must handle with care.
|
||||
// - K (PBS key) is read by location; the live unencrypted K is NEVER modified — Wrap operates on
|
||||
// a COPY. K is never logged/printed.
|
||||
// - The wrapped blob is opaque ciphertext (a PBS scrypt key file); the hub stores it and never
|
||||
// decrypts it (it has no R).
|
||||
//
|
||||
// It shells out to `proxmox-backup-client key` (the PBS-native passphrase KDF — no bespoke crypto;
|
||||
// see documentation/tests/slice7-escrow-spike-findings.md). That binary is a runtime dependency.
|
||||
package escrow
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"math"
|
||||
"math/big"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed eff_large_wordlist.txt
|
||||
var wordlistRaw []byte
|
||||
|
||||
// wordlist is the EFF large wordlist (7776 words, 12.92 bits/word) — the diceware standard for
|
||||
// human-transcribed passphrases. Parsed once at init.
|
||||
var wordlist = parseWordlist(wordlistRaw)
|
||||
|
||||
func parseWordlist(raw []byte) []string {
|
||||
var w []string
|
||||
sc := bufio.NewScanner(bytes.NewReader(raw))
|
||||
for sc.Scan() {
|
||||
if t := strings.TrimSpace(sc.Text()); t != "" {
|
||||
w = append(w, t)
|
||||
}
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// RecoveryCodeWords is the number of words in a recovery code R. 10 words from the 7776-word EFF
|
||||
// list ≈ 129.2 bits (≥128) — the chosen entropy floor (slice7-escrow-spike-findings.md §4).
|
||||
const RecoveryCodeWords = 10
|
||||
|
||||
// GenerateRecoveryCode returns a fresh recovery code R: RecoveryCodeWords words chosen uniformly
|
||||
// (crypto/rand via big.Int — no modulo bias) from the EFF large wordlist, hyphen-joined.
|
||||
//
|
||||
// SECRET: the returned string is R. Surface it to the customer exactly once; never log or persist it.
|
||||
func GenerateRecoveryCode() (string, error) {
|
||||
if len(wordlist) < 2 {
|
||||
return "", fmt.Errorf("escrow: wordlist not loaded (%d words)", len(wordlist))
|
||||
}
|
||||
n := big.NewInt(int64(len(wordlist)))
|
||||
words := make([]string, RecoveryCodeWords)
|
||||
for i := range words {
|
||||
idx, err := rand.Int(rand.Reader, n)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("escrow: recovery-code rng: %w", err)
|
||||
}
|
||||
words[i] = wordlist[idx.Int64()]
|
||||
}
|
||||
return strings.Join(words, "-"), nil
|
||||
}
|
||||
|
||||
// RecoveryCodeEntropyBits is the approximate entropy of a generated code, for display/audit only
|
||||
// (never the code itself).
|
||||
func RecoveryCodeEntropyBits() float64 {
|
||||
if len(wordlist) < 2 {
|
||||
return 0
|
||||
}
|
||||
return float64(RecoveryCodeWords) * math.Log2(float64(len(wordlist)))
|
||||
}
|
||||
|
||||
// WordlistSize is the loaded wordlist length (for audit/tests).
|
||||
func WordlistSize() int { return len(wordlist) }
|
||||
Reference in New Issue
Block a user