slice 10D (agent): DR capstone — identity escrow + restore-mode consumption (v0.18.0)
Identity escrow wraps {tunnel_token,pbs_token} under the SAME R via age
(scrypt+ChaCha20-Poly1305), reusing the K-escrow pty; wrong R fails closed.
escrow.Create optionally emits the identity blob; escrow-create uploads it +
the non-secret directive; identity-consume recovers it (R by hand, never
logged). K-escrow + 10C Consume untouched. Closes slice 10 with hub v0.11.0;
operator-side rotation model (hub holds no Cloudflare write-power).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+100
-16
@@ -42,7 +42,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.17.0"
|
||||
var version = "0.18.0"
|
||||
|
||||
func main() {
|
||||
var (
|
||||
@@ -62,10 +62,12 @@ func main() {
|
||||
custDomain string
|
||||
custName string
|
||||
custEmail string
|
||||
blobPath string
|
||||
expectedFP string
|
||||
keyDest string
|
||||
showVersion bool
|
||||
blobPath string
|
||||
expectedFP string
|
||||
keyDest string
|
||||
idBundlePath string
|
||||
directivePath string
|
||||
showVersion bool
|
||||
)
|
||||
flag.StringVar(&cfgPath, "config", envOr("FELHOM_AGENT_CONFIG", "/etc/felhom-agent/agent.json"), "path to the agent config file (JSON)")
|
||||
flag.Var(&selftest, "selftest", "run a self-test and exit: bare/`read` = read-only queries; `task` = reversible mutating exercise (needs -vmid); `hub` = one collect+report; `storage` = observe storage (+ -watch); `backup` = one-shot backup of -vmid; `restore-test` = restore→boot→verify→teardown of -archive (or newest backup); `pbs-verify` = trigger a PBS verify + print snapshot records; `bring-up` = restore→reset identity→size→start link-up of -archive into -vmid (needs -mode/-archive/-vmid; tears down unless -keep); `provision` = full slice-8A chain: bring-up provision + mint token + populate bootstrap config mount (needs -archive/-vmid/-customer-id/-customer-domain; keeps the guest)")
|
||||
@@ -82,6 +84,8 @@ func main() {
|
||||
flag.StringVar(&blobPath, "blob", "", "for --selftest=escrow-consume: path to the R-wrapped escrow blob file")
|
||||
flag.StringVar(&expectedFP, "fingerprint", "", "for --selftest=escrow-consume: the EXPECTED key fingerprint (the gate target)")
|
||||
flag.StringVar(&keyDest, "keydest", "", "for --selftest=escrow-consume: where to install the recovered key (0600)")
|
||||
flag.StringVar(&idBundlePath, "identity-bundle", "", "for --selftest=escrow-create: a 0600 JSON file {tunnel_token,pbs_token} to ALSO escrow under R (10D)")
|
||||
flag.StringVar(&directivePath, "directive", "", "for --selftest=escrow-create: a JSON file with the non-secret DR directive (pbs repo/ns, expected fingerprint, tunnel id)")
|
||||
flag.StringVar(&custID, "customer-id", "", "for --selftest=provision: the customer id to seed into the guest's bootstrap")
|
||||
flag.StringVar(&custDomain, "customer-domain", "", "for --selftest=provision: the customer domain to seed")
|
||||
flag.StringVar(&custName, "customer-name", "", "for --selftest=provision: the customer display name to seed (optional)")
|
||||
@@ -131,9 +135,11 @@ func main() {
|
||||
customer: provision.DocCustomer{ID: custID, Domain: custDomain, Name: custName, Email: custEmail},
|
||||
}))
|
||||
case "escrow-create":
|
||||
os.Exit(runSelftestEscrowCreate(context.Background(), cfg, logger, pbsStorage, paperkey, offline, upload))
|
||||
os.Exit(runSelftestEscrowCreate(context.Background(), cfg, logger, pbsStorage, paperkey, offline, upload, idBundlePath, directivePath))
|
||||
case "escrow-consume":
|
||||
os.Exit(runSelftestEscrowConsume(context.Background(), logger, blobPath, expectedFP, keyDest))
|
||||
case "identity-consume":
|
||||
os.Exit(runSelftestIdentityConsume(context.Background(), logger, blobPath, keyDest))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1068,7 +1074,7 @@ func runSelftestProvision(ctx context.Context, cfg config.Config, logger *slog.L
|
||||
// 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 {
|
||||
func runSelftestEscrowCreate(ctx context.Context, cfg config.Config, logger *slog.Logger, storage string, paperkey, offline, upload bool, identityBundlePath, directivePath string) int {
|
||||
if storage == "" {
|
||||
storage = cfg.Escrow.PBSStorageID
|
||||
}
|
||||
@@ -1082,15 +1088,40 @@ func runSelftestEscrowCreate(ctx context.Context, cfg config.Config, logger *slo
|
||||
return 1
|
||||
}
|
||||
|
||||
fmt.Printf("=== felhom-agent %s selftest=escrow-create (storage=%s posture=%s) ===\n", version, storage, escrow.DefaultPosture)
|
||||
// Slice 10D.1: optionally ALSO wrap the identity bundle under the same R, and carry the non-secret
|
||||
// directive for the hub. The bundle file is a 0600 secret (tunnel/pbs tokens); the directive is
|
||||
// non-secret (pbs repo/ns, expected fingerprint, tunnel id).
|
||||
var identity *escrow.IdentityBundle
|
||||
var directive json.RawMessage
|
||||
if identityBundlePath != "" {
|
||||
raw, err := os.ReadFile(identityBundlePath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "selftest=escrow-create: reading identity bundle %s: %v\n", identityBundlePath, err)
|
||||
return 1
|
||||
}
|
||||
var b escrow.IdentityBundle
|
||||
if err := json.Unmarshal(raw, &b); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "selftest=escrow-create: identity bundle is not valid JSON {tunnel_token,pbs_token}: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
identity = &b
|
||||
if directivePath != "" {
|
||||
if d, err := os.ReadFile(directivePath); err == nil && json.Valid(d) {
|
||||
directive = d
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("=== felhom-agent %s selftest=escrow-create (storage=%s posture=%s identity=%v) ===\n", version, storage, escrow.DefaultPosture, identity != nil)
|
||||
// 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)
|
||||
logger.Info("escrow: creating zero-knowledge recovery-code escrow", "storage", storage, "key_path", keyPath, "with_identity", identity != nil)
|
||||
|
||||
R, res, err := escrow.Create(ctx, escrow.CreateOptions{
|
||||
KeyPath: keyPath,
|
||||
Posture: escrow.Posture(cfg.Escrow.Posture),
|
||||
WantOfflineCopy: offline,
|
||||
WantPaperkey: paperkey,
|
||||
IdentityBundle: identity,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, " [FAIL] escrow create:", err)
|
||||
@@ -1120,12 +1151,15 @@ func runSelftestEscrowCreate(ctx context.Context, cfg config.Config, logger *slo
|
||||
fmt.Println(res.Paperkey)
|
||||
}
|
||||
|
||||
if len(res.IdentityBlob) > 0 {
|
||||
fmt.Printf(" identity escrow: %d bytes (age-wrapped {tunnel,pbs} under the same R) · self-verify OK\n", len(res.IdentityBlob))
|
||||
}
|
||||
if upload {
|
||||
if err := uploadEscrowBlob(ctx, cfg, res); err != nil {
|
||||
if err := uploadEscrowBlob(ctx, cfg, res, directive); 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(" uploaded the opaque blob(s) to the hub (host record); the hub cannot open them")
|
||||
}
|
||||
fmt.Println("=== selftest=escrow-create OK ===")
|
||||
return 0
|
||||
@@ -1165,6 +1199,44 @@ func runSelftestEscrowConsume(ctx context.Context, logger *slog.Logger, blobPath
|
||||
return 0
|
||||
}
|
||||
|
||||
// runSelftestIdentityConsume recovers the IDENTITY bundle from its age blob with R (slice 10D.1/10D.3)
|
||||
// and writes the recovered {tunnel_token, pbs_token} JSON to -keydest (0600). R is taken BY HAND from
|
||||
// FELHOM_RECOVERY_CODE (off the command line); the recovered tokens are never logged. The drill then
|
||||
// uses the tunnel token to re-establish the tunnel + the pbs token for steady-state.
|
||||
func runSelftestIdentityConsume(ctx context.Context, logger *slog.Logger, blobPath, keyDest string) int {
|
||||
if blobPath == "" || keyDest == "" {
|
||||
fmt.Fprintln(os.Stderr, "selftest=identity-consume requires -blob and -keydest (R via env FELHOM_RECOVERY_CODE)")
|
||||
return 2
|
||||
}
|
||||
R := os.Getenv("FELHOM_RECOVERY_CODE")
|
||||
if R == "" {
|
||||
fmt.Fprintln(os.Stderr, "selftest=identity-consume: set the recovery code in env FELHOM_RECOVERY_CODE (by-hand input)")
|
||||
return 2
|
||||
}
|
||||
blob, err := os.ReadFile(blobPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "selftest=identity-consume: reading blob %s: %v\n", blobPath, err)
|
||||
return 1
|
||||
}
|
||||
fmt.Printf("=== felhom-agent %s selftest=identity-consume (blob=%s → %s) ===\n", version, blobPath, keyDest)
|
||||
logger.Info("escrow: recovering identity bundle from R-wrapped age blob", "blob_bytes", len(blob)) // R + tokens NOT logged
|
||||
bundle, err := escrow.UnwrapIdentityBundle(ctx, blob, R)
|
||||
if err != nil {
|
||||
R = ""
|
||||
fmt.Fprintln(os.Stderr, " [FAIL] identity consume:", err) // never contains R or token bytes
|
||||
return 1
|
||||
}
|
||||
R = ""
|
||||
raw, _ := json.Marshal(bundle)
|
||||
if err := os.WriteFile(keyDest, raw, 0o600); err != nil {
|
||||
fmt.Fprintln(os.Stderr, " [FAIL] writing recovered bundle:", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Printf(" [OK] identity recovered (tunnel_token + pbs_token) → %s (0600) — never printed\n", keyDest)
|
||||
fmt.Println("=== selftest=identity-consume 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.
|
||||
@@ -1172,20 +1244,30 @@ 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"
|
||||
// Slice 10D.1 — optional DR bundle (identity escrow + non-secret directive). Omitted in slice-7.
|
||||
IdentityBlobB64 string `json:"identity_blob_b64,omitempty"`
|
||||
DirectiveJSON json.RawMessage `json:"directive,omitempty"`
|
||||
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 {
|
||||
// uploadEscrowBlob PUTs the opaque blob (and, for 10D, the identity blob + non-secret directive) to
|
||||
// the hub, authed with the per-host key. The hub stores ciphertext + non-secret fields; no usable
|
||||
// secret leaves the agent.
|
||||
func uploadEscrowBlob(ctx context.Context, cfg config.Config, res escrow.CreateResult, directive json.RawMessage) 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{
|
||||
upReq := escrowUploadRequest{
|
||||
BlobB64: base64.StdEncoding.EncodeToString(res.Blob),
|
||||
KeyFingerprint: res.KeyFingerprint,
|
||||
Posture: string(res.Posture),
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
if len(res.IdentityBlob) > 0 {
|
||||
upReq.IdentityBlobB64 = base64.StdEncoding.EncodeToString(res.IdentityBlob)
|
||||
upReq.DirectiveJSON = directive
|
||||
}
|
||||
body, _ := json.Marshal(upReq)
|
||||
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 {
|
||||
@@ -1563,8 +1645,10 @@ func (f *selftestFlag) Set(v string) error {
|
||||
f.mode = "escrow-create"
|
||||
case "escrow-consume":
|
||||
f.mode = "escrow-consume"
|
||||
case "identity-consume":
|
||||
f.mode = "identity-consume"
|
||||
default:
|
||||
return fmt.Errorf("invalid --selftest value %q (want read|task|hub|storage|backup|restore-test|pbs-verify|bring-up|provision|escrow-create|escrow-consume)", v)
|
||||
return fmt.Errorf("invalid --selftest value %q (want read|task|hub|storage|backup|restore-test|pbs-verify|bring-up|provision|escrow-create|escrow-consume|identity-consume)", v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user