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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user