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:
2026-06-10 07:37:03 +02:00
parent 6e036e4c75
commit 47dd0bd244
8 changed files with 8449 additions and 6 deletions
+124 -2
View File
@@ -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
}