feat(hub): v0.57.0 reinstall-of-existing-customer arc — claim/offsite/escrow continuity
F2 claim re-issue on clean-slate re-enroll (ReissueForReenroll, host-enroll mint path, single-bump, reset code; hub never stores the password so fork B). F3 offsite re-issue on re-enroll (ReissueOffsiteForCustomer, same machinery as the manual button). 2.3 escrow honesty (red-proofed): re-issuing offsite marks the escrow stale (MarkEscrowStale), withholds the mismatched restic hash from auto-confirm, DR checklist shows stale not done. Events: claim_reissued_reenroll / offsite_reissued / escrow_stale. Controller + scripts unchanged (source contradicted both premises): the controller reads escrow prereqs live from the agent; the installer can't know the descriptor-provisioned storage id. F4 root fix is agent-side -> ROADMAP R-22; demo unblocked live (Part 0 ACL grant). VALIDATION doc F2 erratum + F3/F4 dispositions. Green gate + Scenario-C red-proof pass.
This commit is contained in:
@@ -364,6 +364,12 @@ func (s *Store) migrate() error {
|
||||
// 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`)
|
||||
|
||||
// v0.57.0 (2.3, escrow honesty on offsite re-issue) — stale_at is set when the offsite repo
|
||||
// password is re-issued: the blob then seals a password that no longer opens the repo, so the
|
||||
// hub must stop advertising "ceremony done" and withhold the (now non-matching) restic_pw_sha256
|
||||
// from the auto-confirm ACK. NULL = current; a fresh ceremony (SaveHostEscrow) clears it.
|
||||
s.db.Exec(`ALTER TABLE host_escrow ADD COLUMN stale_at DATETIME`)
|
||||
|
||||
// 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
|
||||
@@ -2057,6 +2063,9 @@ type HostEscrow struct {
|
||||
// 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
|
||||
// StaleAt (v0.57.0, 2.3) — non-empty when the offsite password was re-issued after this blob was
|
||||
// sealed: the blob is stale (seals a password that no longer opens the repo). Cleared by a fresh ceremony.
|
||||
StaleAt string
|
||||
}
|
||||
|
||||
// SaveHostEscrow stores (last-write-wins) the OPAQUE escrow blob for a host. The hub keeps the
|
||||
@@ -2072,20 +2081,30 @@ func (s *Store) SaveHostEscrow(hostID string, blob []byte, keyFingerprint, postu
|
||||
posture = excluded.posture,
|
||||
created_at = excluded.created_at,
|
||||
restic_pw_sha256 = excluded.restic_pw_sha256,
|
||||
stale_at = NULL,
|
||||
updated_at = datetime('now')`,
|
||||
hostID, blob, keyFingerprint, posture, createdAt, resticPwSHA256,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// MarkEscrowStale flags a host's escrow blob as stale (v0.57.0, 2.3) — called when the offsite repo
|
||||
// password is re-issued, because the blob then seals a password that no longer opens the repo. No-op
|
||||
// when no escrow row exists; idempotent (only stamps the first re-issue since the last ceremony; a
|
||||
// fresh ceremony clears stale_at via SaveHostEscrow's ON CONFLICT).
|
||||
func (s *Store) MarkEscrowStale(hostID string) error {
|
||||
_, err := s.db.Exec(`UPDATE host_escrow SET stale_at = datetime('now') WHERE host_id = ? AND stale_at IS NULL`, hostID)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetHostEscrow returns the stored opaque escrow for a host (nil if none). Used by tests and
|
||||
// (future, slice 10) restore-mode serving. The hub returns bytes verbatim; it never decrypts.
|
||||
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, COALESCE(restic_pw_sha256, '')
|
||||
SELECT host_id, blob, key_fingerprint, posture, created_at, updated_at, COALESCE(restic_pw_sha256, ''), COALESCE(stale_at, '')
|
||||
FROM host_escrow WHERE host_id = ?`, hostID).
|
||||
Scan(&e.HostID, &e.Blob, &e.KeyFingerprint, &e.Posture, &e.CreatedAt, &e.UpdatedAt, &e.ResticPwSHA256)
|
||||
Scan(&e.HostID, &e.Blob, &e.KeyFingerprint, &e.Posture, &e.CreatedAt, &e.UpdatedAt, &e.ResticPwSHA256, &e.StaleAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -2101,6 +2120,10 @@ type EscrowStatus struct {
|
||||
IdentityBlobPresent bool `json:"identity_blob_present"`
|
||||
ResticPwSHA256 string `json:"restic_pw_sha256,omitempty"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
// Stale (v0.57.0, 2.3) — true when the offsite password was re-issued after the blob was sealed.
|
||||
// When stale the ResticPwSHA256 is WITHHELD (emptied) so the controller cannot auto-confirm against
|
||||
// a hash that no longer matches the live repo password — the ceremony must run again.
|
||||
Stale bool `json:"escrow_stale,omitempty"`
|
||||
}
|
||||
|
||||
// GetEscrowStatusForCustomer returns the escrow status of the customer's host (nil if the customer has no
|
||||
@@ -2108,12 +2131,13 @@ type EscrowStatus struct {
|
||||
func (s *Store) GetEscrowStatusForCustomer(customerID string) (*EscrowStatus, error) {
|
||||
var st EscrowStatus
|
||||
var identityPresent int
|
||||
var staleAt string
|
||||
err := s.db.QueryRow(`
|
||||
SELECT (e.identity_blob IS NOT NULL), COALESCE(e.restic_pw_sha256, ''), e.created_at
|
||||
SELECT (e.identity_blob IS NOT NULL), COALESCE(e.restic_pw_sha256, ''), e.created_at, COALESCE(e.stale_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)
|
||||
Scan(&identityPresent, &st.ResticPwSHA256, &st.CreatedAt, &staleAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -2121,6 +2145,12 @@ func (s *Store) GetEscrowStatusForCustomer(customerID string) (*EscrowStatus, er
|
||||
return nil, err
|
||||
}
|
||||
st.IdentityBlobPresent = identityPresent == 1
|
||||
// v0.57.0 (2.3): a stale blob must NOT auto-confirm — withhold the hash and flag it so the
|
||||
// controller stays pending and the escrow wizard is offered again.
|
||||
if staleAt != "" {
|
||||
st.Stale = true
|
||||
st.ResticPwSHA256 = ""
|
||||
}
|
||||
return &st, nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user