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:
+124
-2
@@ -7,12 +7,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
@@ -23,6 +26,7 @@ import (
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/authz"
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/backup"
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/escrow"
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
|
||||
applog "gitea.dooplex.hu/admin/felhom-agent/internal/log"
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/pbs"
|
||||
@@ -33,7 +37,7 @@ import (
|
||||
|
||||
// version is the agent version. Overridable at build time with
|
||||
// -ldflags "-X main.version=<v>"; defaults to the in-repo CHANGELOG version.
|
||||
var version = "0.8.0"
|
||||
var version = "0.9.0"
|
||||
|
||||
func main() {
|
||||
var (
|
||||
@@ -45,6 +49,10 @@ func main() {
|
||||
mode string
|
||||
hostname string
|
||||
keep bool
|
||||
pbsStorage string
|
||||
paperkey bool
|
||||
offline bool
|
||||
upload bool
|
||||
showVersion bool
|
||||
)
|
||||
flag.StringVar(&cfgPath, "config", envOr("FELHOM_AGENT_CONFIG", "/etc/felhom-agent/agent.json"), "path to the agent config file (JSON)")
|
||||
@@ -55,6 +63,10 @@ func main() {
|
||||
flag.StringVar(&mode, "mode", "provision", "for --selftest=bring-up: `provision` (golden, fresh identity) | `dr` (customer backup, preserve continuity)")
|
||||
flag.StringVar(&hostname, "hostname", "", "for --selftest=bring-up provision: the hostname to set on the new guest")
|
||||
flag.BoolVar(&keep, "keep", false, "for --selftest=bring-up: KEEP the guest instead of tearing it down at the end")
|
||||
flag.StringVar(&pbsStorage, "storage", "", "for --selftest=escrow-create: the pbs storage whose key to escrow (default: escrow.pbs_storage_id)")
|
||||
flag.BoolVar(&paperkey, "paperkey", false, "for --selftest=escrow-create: ALSO emit the raw-key paperkey (opt-in (a); single-factor, unrevocable)")
|
||||
flag.BoolVar(&offline, "offline", false, "for --selftest=escrow-create: ALSO emit the R-wrapped offline copy to print (opt-in (b))")
|
||||
flag.BoolVar(&upload, "upload", false, "for --selftest=escrow-create: upload the opaque blob to the hub")
|
||||
flag.BoolVar(&showVersion, "version", false, "print version and exit")
|
||||
flag.Parse()
|
||||
|
||||
@@ -94,6 +106,8 @@ func main() {
|
||||
os.Exit(runSelftestPBSVerify(context.Background(), cfg, logger))
|
||||
case "bring-up":
|
||||
os.Exit(runSelftestBringUp(context.Background(), cfg, logger, mode, archive, vmid, hostname, keep))
|
||||
case "escrow-create":
|
||||
os.Exit(runSelftestEscrowCreate(context.Background(), cfg, logger, pbsStorage, paperkey, offline, upload))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -800,6 +814,112 @@ func runSelftestBringUp(ctx context.Context, cfg config.Config, logger *slog.Log
|
||||
return 0
|
||||
}
|
||||
|
||||
// runSelftestEscrowCreate creates the PBS recovery-code escrow (slice 7, doc 03 §8a): generate R,
|
||||
// wrap the live PBS key under R (zero-knowledge), self-verify recoverability, and emit the opaque
|
||||
// blob. R is surfaced to stdout EXACTLY ONCE (never to the logger/journald). With -upload it PUTs
|
||||
// the opaque blob to the hub. Enrollment-time, root-capable (reads the 0600 key).
|
||||
func runSelftestEscrowCreate(ctx context.Context, cfg config.Config, logger *slog.Logger, storage string, paperkey, offline, upload bool) int {
|
||||
if storage == "" {
|
||||
storage = cfg.Escrow.PBSStorageID
|
||||
}
|
||||
if storage == "" {
|
||||
fmt.Fprintln(os.Stderr, "selftest=escrow-create requires -storage <pbs-storage-id> (or escrow.pbs_storage_id)")
|
||||
return 2
|
||||
}
|
||||
keyPath := cfg.Backup.PBSEncKeyPath(storage)
|
||||
if _, err := os.Stat(keyPath); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "selftest=escrow-create: PBS key for %q not found (%s): %v\n", storage, keyPath, err)
|
||||
return 1
|
||||
}
|
||||
|
||||
fmt.Printf("=== felhom-agent %s selftest=escrow-create (storage=%s posture=%s) ===\n", version, storage, escrow.DefaultPosture)
|
||||
// NB: nothing about R is logged. The logger never sees R; only stdout does, once.
|
||||
logger.Info("escrow: creating zero-knowledge recovery-code escrow", "storage", storage, "key_path", keyPath)
|
||||
|
||||
R, res, err := escrow.Create(ctx, escrow.CreateOptions{
|
||||
KeyPath: keyPath,
|
||||
Posture: escrow.Posture(cfg.Escrow.Posture),
|
||||
WantOfflineCopy: offline,
|
||||
WantPaperkey: paperkey,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, " [FAIL] escrow create:", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
// Surface R EXACTLY ONCE — to stdout, with a write-it-down banner. Never logged/persisted.
|
||||
fmt.Println()
|
||||
fmt.Println(" ┌──────────────────────────────────────────────────────────────────────┐")
|
||||
fmt.Println(" │ RECOVERY CODE — write it down now. It is shown ONCE and never stored. │")
|
||||
fmt.Println(" │ Without it your offsite backups are unrecoverable, by anyone. │")
|
||||
fmt.Println(" └──────────────────────────────────────────────────────────────────────┘")
|
||||
fmt.Println(" " + R)
|
||||
fmt.Println()
|
||||
R = "" // drop our reference promptly
|
||||
|
||||
fmt.Printf(" blob: %d bytes (opaque, R-wrapped) · key fingerprint %s · posture %s · ~%.0f bits R\n",
|
||||
len(res.Blob), res.KeyFingerprint, res.Posture, res.EntropyBits)
|
||||
fmt.Println(" self-verify: the blob unwraps back to the key with R (recoverability confirmed)")
|
||||
|
||||
if offline && len(res.OfflineCopy) > 0 {
|
||||
fmt.Println(" --- (b) R-wrapped OFFLINE COPY (print + store; still needs R) ---")
|
||||
fmt.Println(base64.StdEncoding.EncodeToString(res.OfflineCopy))
|
||||
}
|
||||
if paperkey && res.Paperkey != "" {
|
||||
fmt.Println(" --- (a) RAW PAPERKEY — single-factor, UNREVOCABLE. Store in a safe only. ---")
|
||||
fmt.Println(res.Paperkey)
|
||||
}
|
||||
|
||||
if upload {
|
||||
if err := uploadEscrowBlob(ctx, cfg, res); err != nil {
|
||||
fmt.Fprintln(os.Stderr, " [FAIL] upload escrow to hub:", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Println(" uploaded the opaque blob to the hub (host record); the hub cannot open it")
|
||||
}
|
||||
fmt.Println("=== selftest=escrow-create OK ===")
|
||||
return 0
|
||||
}
|
||||
|
||||
// escrowUploadRequest is the agent→hub wire shape for the opaque escrow blob. MUST stay in lockstep
|
||||
// with the hub's ingest struct (felhom-hub api.escrowUploadRequest). The hub stores the bytes and
|
||||
// never decrypts them.
|
||||
type escrowUploadRequest struct {
|
||||
BlobB64 string `json:"blob_b64"` // base64 of the opaque R-wrapped blob (ciphertext)
|
||||
KeyFingerprint string `json:"key_fingerprint"` // for operator display only
|
||||
Posture string `json:"posture"` // e.g. "zero_knowledge"
|
||||
CreatedAt string `json:"created_at"` // RFC3339
|
||||
}
|
||||
|
||||
// uploadEscrowBlob PUTs the opaque blob to the hub, authed with the per-host key.
|
||||
func uploadEscrowBlob(ctx context.Context, cfg config.Config, res escrow.CreateResult) error {
|
||||
if cfg.Hub.URL == "" || cfg.Hub.HostID == "" || cfg.Hub.APIKey == "" {
|
||||
return fmt.Errorf("hub not configured (url/host_id/api_key)")
|
||||
}
|
||||
body, _ := json.Marshal(escrowUploadRequest{
|
||||
BlobB64: base64.StdEncoding.EncodeToString(res.Blob),
|
||||
KeyFingerprint: res.KeyFingerprint,
|
||||
Posture: string(res.Posture),
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
url := strings.TrimRight(cfg.Hub.URL, "/") + "/api/v1/hosts/" + cfg.Hub.HostID + "/escrow"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+cfg.Hub.APIKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
return fmt.Errorf("hub returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runSelftestPBSVerify discovers the pbs storages, triggers a verify on each (the new §2
|
||||
// path), then lists + prints the resulting PBSSnapshot records (verify-state included).
|
||||
// Standalone on the host. Covers the runbook's (c) verify and (d) list.
|
||||
@@ -1153,8 +1273,10 @@ func (f *selftestFlag) Set(v string) error {
|
||||
f.mode = "pbs-verify"
|
||||
case "bring-up":
|
||||
f.mode = "bring-up"
|
||||
case "escrow-create":
|
||||
f.mode = "escrow-create"
|
||||
default:
|
||||
return fmt.Errorf("invalid --selftest value %q (want read|task|hub|storage|backup|restore-test|pbs-verify|bring-up)", v)
|
||||
return fmt.Errorf("invalid --selftest value %q (want read|task|hub|storage|backup|restore-test|pbs-verify|bring-up|escrow-create)", v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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