hub v0.40.0: store escrow restic_pw_sha256 + serve escrow status in the report ACK (SLICE 3)
Additive host_escrow migration; SaveHostEscrow/HostEscrow gain the hash
(NULL-safe for legacy rows); GetEscrowStatusForCustomer joins hosts;
the report ACK gains escrow:{identity_blob_present,restic_pw_sha256,
created_at} (omitted without a row). Contract test mirrors the agent's
v0.79.0 emit struct.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
@@ -347,6 +347,12 @@ func (s *Store) migrate() error {
|
||||
s.db.Exec(`ALTER TABLE host_escrow ADD COLUMN identity_blob BLOB`)
|
||||
s.db.Exec(`ALTER TABLE host_escrow ADD COLUMN directive_json TEXT NOT NULL DEFAULT '{}'`)
|
||||
|
||||
// SLICE 3 (escrow auto-confirm) — sha256 hex of the offsite restic repo password sealed in the
|
||||
// identity blob. The hash of a 256-bit random secret is non-reversible/non-brute-forceable — safe to
|
||||
// store and serve; it lets the controller VERIFY "the escrow covers the CURRENT repo password"
|
||||
// instead of trusting blob-presence. NULL/'' = a legacy or password-less blob (never auto-confirms).
|
||||
s.db.Exec(`ALTER TABLE host_escrow ADD COLUMN restic_pw_sha256 TEXT`)
|
||||
|
||||
// dr_recipe (SPIKE-dr-recipe-2026-06-16): the secret-free DR reconstruction recipe, stored
|
||||
// PLAINTEXT (it has NO secrets — the clean inverse of the retired infra_backup). Two halves keyed
|
||||
// by customer: the agent's storage/guest/PBS half (host_half_json, from the host-report) and the
|
||||
@@ -1473,21 +1479,26 @@ type HostEscrow struct {
|
||||
Posture string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
// ResticPwSHA256 (SLICE 3) — the non-reversible hash of the offsite repo password the identity blob
|
||||
// covers ("" = legacy/password-less blob). Safe to store/serve; the password itself never reaches the hub.
|
||||
ResticPwSHA256 string
|
||||
}
|
||||
|
||||
// SaveHostEscrow stores (last-write-wins) the OPAQUE escrow blob for a host. The hub keeps the
|
||||
// bytes and NEVER decrypts them — there is no decrypt path. createdAt is the agent's timestamp.
|
||||
func (s *Store) SaveHostEscrow(hostID string, blob []byte, keyFingerprint, posture, createdAt string) error {
|
||||
// resticPwSHA256 is "" when the ceremony sealed no staged password (stored as-is; never auto-confirms).
|
||||
func (s *Store) SaveHostEscrow(hostID string, blob []byte, keyFingerprint, posture, createdAt, resticPwSHA256 string) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO host_escrow (host_id, blob, key_fingerprint, posture, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, datetime('now'))
|
||||
INSERT INTO host_escrow (host_id, blob, key_fingerprint, posture, created_at, restic_pw_sha256, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(host_id) DO UPDATE SET
|
||||
blob = excluded.blob,
|
||||
key_fingerprint = excluded.key_fingerprint,
|
||||
posture = excluded.posture,
|
||||
created_at = excluded.created_at,
|
||||
restic_pw_sha256 = excluded.restic_pw_sha256,
|
||||
updated_at = datetime('now')`,
|
||||
hostID, blob, keyFingerprint, posture, createdAt,
|
||||
hostID, blob, keyFingerprint, posture, createdAt, resticPwSHA256,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -1497,9 +1508,9 @@ func (s *Store) SaveHostEscrow(hostID string, blob []byte, keyFingerprint, postu
|
||||
func (s *Store) GetHostEscrow(hostID string) (*HostEscrow, error) {
|
||||
var e HostEscrow
|
||||
err := s.db.QueryRow(`
|
||||
SELECT host_id, blob, key_fingerprint, posture, created_at, updated_at
|
||||
SELECT host_id, blob, key_fingerprint, posture, created_at, updated_at, COALESCE(restic_pw_sha256, '')
|
||||
FROM host_escrow WHERE host_id = ?`, hostID).
|
||||
Scan(&e.HostID, &e.Blob, &e.KeyFingerprint, &e.Posture, &e.CreatedAt, &e.UpdatedAt)
|
||||
Scan(&e.HostID, &e.Blob, &e.KeyFingerprint, &e.Posture, &e.CreatedAt, &e.UpdatedAt, &e.ResticPwSHA256)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -1509,6 +1520,35 @@ func (s *Store) GetHostEscrow(hostID string) (*HostEscrow, error) {
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
// EscrowStatus (SLICE 3) is the non-secret escrow summary served in the report ACK so the controller can
|
||||
// VERIFY-and-auto-confirm: not "a blob exists" but "the blob covers the CURRENT repo password" (hash match).
|
||||
type EscrowStatus struct {
|
||||
IdentityBlobPresent bool `json:"identity_blob_present"`
|
||||
ResticPwSHA256 string `json:"restic_pw_sha256,omitempty"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
}
|
||||
|
||||
// GetEscrowStatusForCustomer returns the escrow status of the customer's host (nil if the customer has no
|
||||
// escrow row). With multiple hosts (not the current model), the most recently updated escrow wins.
|
||||
func (s *Store) GetEscrowStatusForCustomer(customerID string) (*EscrowStatus, error) {
|
||||
var st EscrowStatus
|
||||
var identityPresent int
|
||||
err := s.db.QueryRow(`
|
||||
SELECT (e.identity_blob IS NOT NULL), COALESCE(e.restic_pw_sha256, ''), e.created_at
|
||||
FROM host_escrow e JOIN hosts h ON h.host_id = e.host_id
|
||||
WHERE h.customer_id = ?
|
||||
ORDER BY e.updated_at DESC LIMIT 1`, customerID).
|
||||
Scan(&identityPresent, &st.ResticPwSHA256, &st.CreatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st.IdentityBlobPresent = identityPresent == 1
|
||||
return &st, nil
|
||||
}
|
||||
|
||||
// SetHostDesired sets a host's desired-state JSON and ATOMICALLY bumps its desired_generation
|
||||
// (slice 10A — the operator "admin-set" write). Returns the NEW generation. The generation is
|
||||
// the cheap change-signal carried on every heartbeat envelope; the agent re-fetches the full
|
||||
|
||||
Reference in New Issue
Block a user