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:
@@ -1,3 +1,19 @@
|
|||||||
|
## v0.79.0 — SLICE 3: escrow upload carries sha256 of the sealed restic password (2026-07-09)
|
||||||
|
|
||||||
|
The hub-verified escrow auto-confirm chain, agent third: the escrow-create ceremony now records WHICH
|
||||||
|
offsite repo password the blob covers — as a non-reversible sha256 (a 256-bit random secret's hash is safe
|
||||||
|
to store/serve; the password itself is never logged or uploaded).
|
||||||
|
|
||||||
|
- `internal/escrow.HashResticPassword` — the CANONICAL hasher: sha256 hex over the TRIMMED password string
|
||||||
|
(exactly the value `AttachResticPassword` seals into the blob). **Pinned cross-repo test vector**
|
||||||
|
(`TestHashResticPassword_PinnedVector`, same vector asserted in felhom-controller) so the two hashers can
|
||||||
|
never drift silently.
|
||||||
|
- `cmd/felhom-agent`: `escrowUploadRequest` gains `restic_pw_sha256,omitempty` — set only when a staged
|
||||||
|
password was folded in (no staged file → field OMITTED → the hub stores NULL → the controller stays
|
||||||
|
pending; correct, the blob doesn't cover the key). `TestEscrowUploadContract` updated (the hub mirrors it
|
||||||
|
in the same commit-pair) + asserts the omitted-when-unstaged behavior.
|
||||||
|
- Ceremony flow (create / self-verify / R-banner / staged-file wipe) otherwise untouched.
|
||||||
|
|
||||||
## v0.78.0 — fork-4 hygiene: DELETE /escrow/stage-secret (staged-secret wipe) (2026-07-09)
|
## v0.78.0 — fork-4 hygiene: DELETE /escrow/stage-secret (staged-secret wipe) (2026-07-09)
|
||||||
|
|
||||||
Part of the offsite-provisioning hardening bundle (pairs with controller v0.107.0 + hub v0.39.0). The staged
|
Part of the offsite-provisioning hardening bundle (pairs with controller v0.107.0 + hub v0.39.0). The staged
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
// struct (felhom-hub api.escrowUploadRequest). Cross-repo, no shared module — this is the agent
|
// 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.
|
// half of the contract guard; the hub has the mirror in its own test.
|
||||||
func TestEscrowUploadContract(t *testing.T) {
|
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
|
var m map[string]any
|
||||||
if err := json.Unmarshal(b, &m); err != nil {
|
if err := json.Unmarshal(b, &m); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -21,8 +21,16 @@ func TestEscrowUploadContract(t *testing.T) {
|
|||||||
got = append(got, k)
|
got = append(got, k)
|
||||||
}
|
}
|
||||||
sort.Strings(got)
|
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) {
|
if !reflect.DeepEqual(got, want) {
|
||||||
t.Fatalf("escrow wire contract drift: got %v want %v (must match the hub ingest struct)", 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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
// 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.
|
// Field NAME only in logs. No staged file → clean no-attach (pre-fork-4 behavior). Wiped after create.
|
||||||
resticStaged := false
|
resticStaged := false
|
||||||
|
resticPwSHA256 := "" // SLICE 3: sha256 of the sealed password (safe to upload/serve; value never logged)
|
||||||
{
|
{
|
||||||
probe := identity
|
probe := identity
|
||||||
if probe == nil {
|
if probe == nil {
|
||||||
@@ -1727,6 +1728,9 @@ func runSelftestEscrowCreate(ctx context.Context, cfg config.Config, logger *slo
|
|||||||
if attached {
|
if attached {
|
||||||
identity = probe
|
identity = probe
|
||||||
resticStaged = true
|
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")
|
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))
|
fmt.Printf(" identity escrow: %d bytes (age-wrapped {tunnel,pbs} under the same R) · self-verify OK\n", len(res.IdentityBlob))
|
||||||
}
|
}
|
||||||
if upload {
|
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)
|
fmt.Fprintln(os.Stderr, " [FAIL] upload escrow to hub:", err)
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
@@ -1896,12 +1900,16 @@ type escrowUploadRequest struct {
|
|||||||
IdentityBlobB64 string `json:"identity_blob_b64,omitempty"`
|
IdentityBlobB64 string `json:"identity_blob_b64,omitempty"`
|
||||||
DirectiveJSON json.RawMessage `json:"directive,omitempty"`
|
DirectiveJSON json.RawMessage `json:"directive,omitempty"`
|
||||||
CreatedAt string `json:"created_at"` // RFC3339
|
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
|
// 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
|
// the hub, authed with the per-host key. The hub stores ciphertext + non-secret fields; no usable
|
||||||
// secret leaves the agent.
|
// 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 == "" {
|
if cfg.Hub.URL == "" || cfg.Hub.HostID == "" || cfg.Hub.APIKey == "" {
|
||||||
return fmt.Errorf("hub not configured (url/host_id/api_key)")
|
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,
|
KeyFingerprint: res.KeyFingerprint,
|
||||||
Posture: string(res.Posture),
|
Posture: string(res.Posture),
|
||||||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
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 {
|
if len(res.IdentityBlob) > 0 {
|
||||||
upReq.IdentityBlobB64 = base64.StdEncoding.EncodeToString(res.IdentityBlob)
|
upReq.IdentityBlobB64 = base64.StdEncoding.EncodeToString(res.IdentityBlob)
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package escrow
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
@@ -53,6 +55,17 @@ func WipeStagedResticPassword() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HashResticPassword is the CANONICAL hasher for the offsite restic repo password (SLICE 3 hub-verified
|
||||||
|
// escrow auto-confirm): sha256 hex of the TRIMMED password string — exactly the value AttachResticPassword
|
||||||
|
// seals into the blob and the value the controller uses (both sides TrimSpace their file reads, so the
|
||||||
|
// trimmed string is the drift-free convention; pinned by the SAME test vector in felhom-agent and
|
||||||
|
// felhom-controller). The hash of a 256-bit random secret is non-reversible and non-brute-forceable —
|
||||||
|
// safe to store on the hub and serve in report ACKs; the PASSWORD itself is never logged or served.
|
||||||
|
func HashResticPassword(pw string) string {
|
||||||
|
sum := sha256.Sum256([]byte(strings.TrimSpace(pw)))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
// AttachResticPassword injects the offsite restic repo password from the staged 0600 file into the bundle
|
// AttachResticPassword injects the offsite restic repo password from the staged 0600 file into the bundle
|
||||||
// when it exists (fork-4 escrow-create auto-inject). Returns whether it attached. The VALUE is validated
|
// when it exists (fork-4 escrow-create auto-inject). Returns whether it attached. The VALUE is validated
|
||||||
// (non-empty) but NEVER logged by callers — log the field NAME only (mirrors AttachWGKey). A missing file
|
// (non-empty) but NEVER logged by callers — log the field NAME only (mirrors AttachWGKey). A missing file
|
||||||
|
|||||||
@@ -97,6 +97,21 @@ func TestIdentity_RoundTrip_CarriesResticPassword(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PINNED CROSS-REPO TEST VECTOR (SLICE 3): the same vector is asserted in felhom-controller — if either
|
||||||
|
// side drifts (trailing newline, encoding, trim behavior), its half of this test fails and auto-confirm
|
||||||
|
// can never silently mismatch. Convention: sha256 hex over the TRIMMED password string.
|
||||||
|
func TestHashResticPassword_PinnedVector(t *testing.T) {
|
||||||
|
const vector = "cafef00ddeadbeef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||||
|
const want = "dbfc02f987e1ac0c91911d5761267089b1144628745a3343e4d96194e43c08e4"
|
||||||
|
if got := HashResticPassword(vector); got != want {
|
||||||
|
t.Fatalf("pinned vector drift: got %s want %s", got, want)
|
||||||
|
}
|
||||||
|
// trim convention: surrounding whitespace/newlines do not change the hash (both sides trim)
|
||||||
|
if got := HashResticPassword(" " + vector + "\n"); got != want {
|
||||||
|
t.Fatalf("whitespace must not change the hash (trim convention), got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// AttachResticPassword: missing file → clean no-attach; staged file → trimmed value attached; empty → error.
|
// AttachResticPassword: missing file → clean no-attach; staged file → trimmed value attached; empty → error.
|
||||||
func TestAttachResticPassword(t *testing.T) {
|
func TestAttachResticPassword(t *testing.T) {
|
||||||
b := &IdentityBundle{}
|
b := &IdentityBundle{}
|
||||||
|
|||||||
Reference in New Issue
Block a user