v0.79.0: escrow upload carries restic_pw_sha256 (SLICE 3 auto-confirm, agent third)

HashResticPassword = sha256 hex over the trimmed password (pinned
cross-repo vector). escrowUploadRequest gains restic_pw_sha256,omitempty
— set only when a staged password was sealed into the blob. Contract test
updated; hub mirrors next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-09 23:05:30 +02:00
parent bd9e777f41
commit 301c84d9b5
5 changed files with 65 additions and 4 deletions
+10 -2
View File
@@ -11,7 +11,7 @@ import (
// struct (felhom-hub api.escrowUploadRequest). Cross-repo, no shared module — this is the agent
// half of the contract guard; the hub has the mirror in its own test.
func TestEscrowUploadContract(t *testing.T) {
b, _ := json.Marshal(escrowUploadRequest{BlobB64: "x", KeyFingerprint: "y", Posture: "z", CreatedAt: "t"})
b, _ := json.Marshal(escrowUploadRequest{BlobB64: "x", KeyFingerprint: "y", Posture: "z", CreatedAt: "t", ResticPwSHA256: "h"})
var m map[string]any
if err := json.Unmarshal(b, &m); err != nil {
t.Fatal(err)
@@ -21,8 +21,16 @@ func TestEscrowUploadContract(t *testing.T) {
got = append(got, k)
}
sort.Strings(got)
want := []string{"blob_b64", "created_at", "key_fingerprint", "posture"}
want := []string{"blob_b64", "created_at", "key_fingerprint", "posture", "restic_pw_sha256"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("escrow wire contract drift: got %v want %v (must match the hub ingest struct)", got, want)
}
// SLICE 3: no staged password folded in → the hash field is OMITTED on the wire (the hub stores NULL →
// the controller never matches → stays pending; correct — the blob doesn't cover the key).
b2, _ := json.Marshal(escrowUploadRequest{BlobB64: "x", KeyFingerprint: "y", Posture: "z", CreatedAt: "t"})
var m2 map[string]any
_ = json.Unmarshal(b2, &m2)
if _, present := m2["restic_pw_sha256"]; present {
t.Fatal("restic_pw_sha256 must be omitted when no staged password was sealed")
}
}
+11 -2
View File
@@ -1714,6 +1714,7 @@ func runSelftestEscrowCreate(ctx context.Context, cfg config.Config, logger *slo
// into the escrowed identity, so DR can recover the offsite DATA key with the one recovery code R.
// Field NAME only in logs. No staged file → clean no-attach (pre-fork-4 behavior). Wiped after create.
resticStaged := false
resticPwSHA256 := "" // SLICE 3: sha256 of the sealed password (safe to upload/serve; value never logged)
{
probe := identity
if probe == nil {
@@ -1727,6 +1728,9 @@ func runSelftestEscrowCreate(ctx context.Context, cfg config.Config, logger *slo
if attached {
identity = probe
resticStaged = true
// Hash EXACTLY the value sealed into the blob — the controller matches this against
// sha256(its local repo_password) to auto-confirm the escrow (hub-verified, SLICE 3).
resticPwSHA256 = escrow.HashResticPassword(probe.ResticRepoPassword)
logger.Info("escrow: identity bundle: +restic_repo_password")
}
}
@@ -1781,7 +1785,7 @@ func runSelftestEscrowCreate(ctx context.Context, cfg config.Config, logger *slo
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, directive); err != nil {
if err := uploadEscrowBlob(ctx, cfg, res, directive, resticPwSHA256); err != nil {
fmt.Fprintln(os.Stderr, " [FAIL] upload escrow to hub:", err)
return 1
}
@@ -1896,12 +1900,16 @@ type escrowUploadRequest struct {
IdentityBlobB64 string `json:"identity_blob_b64,omitempty"`
DirectiveJSON json.RawMessage `json:"directive,omitempty"`
CreatedAt string `json:"created_at"` // RFC3339
// SLICE 3 — sha256 hex of the offsite restic repo password sealed in the identity blob (present only
// when a staged password was folded in). Non-reversible hash of a 256-bit random secret — safe to
// store/serve; lets the controller VERIFY "the escrow covers the CURRENT key" and auto-confirm.
ResticPwSHA256 string `json:"restic_pw_sha256,omitempty"`
}
// 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 {
func uploadEscrowBlob(ctx context.Context, cfg config.Config, res escrow.CreateResult, directive json.RawMessage, resticPwSHA256 string) error {
if cfg.Hub.URL == "" || cfg.Hub.HostID == "" || cfg.Hub.APIKey == "" {
return fmt.Errorf("hub not configured (url/host_id/api_key)")
}
@@ -1910,6 +1918,7 @@ func uploadEscrowBlob(ctx context.Context, cfg config.Config, res escrow.CreateR
KeyFingerprint: res.KeyFingerprint,
Posture: string(res.Posture),
CreatedAt: time.Now().UTC().Format(time.RFC3339),
ResticPwSHA256: resticPwSHA256, // "" when no staged password was folded in → omitted on the wire
}
if len(res.IdentityBlob) > 0 {
upReq.IdentityBlobB64 = base64.StdEncoding.EncodeToString(res.IdentityBlob)