636c51e542
A customer whose machine was rebuilt had everything needed to get their data back and no way to find out: the only route was a command line. This is the screen that closes that. IT UNLOCKS, AND ONLY UNLOCKS (operator ruling). It explains, takes the recovery code, opens the repository and shows what is in there — apps, dates, sizes. It restores nothing: restore is already per-app and lives in the backups area, and a screen that unlocks and then offers to overwrite is two decisions wearing one button. ONE CORE, TWO CALLERS. RecoverInstallCore is split out of RecoverAndInstall; the CLI wrapper keeps its exit codes and printed lines byte-identical, and the handler drives the same function. Two implementations of the one operation that can permanently lose a customer's data would drift, and only one would be tested. Asserted from source on both sides by AST. THREE WAYS OUT, none a dismiss button: recover; 'most nem' (the full page stops interrupting, the backups-area entry point stays PERMANENTLY, bound to the offer and never to the postpone flag); and 'I do not want the old data' — confirmed TWICE and reaching the SHIPPED move-aside, which sets aside and never deletes. THE CODE IS HANDLED NO MORE LOOSELY THAN ON THE COMMAND LINE: POST body only, never logged, never persisted, never echoed, cleared on every path, no-store, autocomplete off. No lockout — the code is a ten-word phrase, and locking a customer out of their own data for a typo is worse than anything it prevents. TWO DEFECTS THE TESTS CAUGHT, both fixed: an UNCLAIMED (legacy-open) box would have been shown the page, because RequireAuth passes such a box through; and the inventory nil-dereferenced when no off-site target was configured, which is exactly the pristine rebuilt shape.
200 lines
9.7 KiB
Go
200 lines
9.7 KiB
Go
package report
|
|
|
|
import (
|
|
"context"
|
|
"log"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// SLICE 3 — hub-verified escrow auto-confirm. Replaces operator trust ("I ran the ceremony, click
|
|
// confirm") with a verified fact: the hub's report ACK carries the sha256 of the repo password the
|
|
// stored escrow blob COVERS (recorded at ceremony time); the controller flips pending→escrowed ONLY
|
|
// when that hash matches sha256 of its CURRENT local repo password. Blob-presence alone must never
|
|
// confirm — a blob can predate the current password (re-provision, inject, drive history) and a
|
|
// truthful-looking claim on a stale blob would re-open the exact un-recoverable-ciphertext gap fork-4
|
|
// closed. Hashes are non-reversible (256-bit random secrets) and safe to log; passwords never are.
|
|
|
|
// EscrowStatus mirrors the hub ACK's `escrow` object (nil when the hub has no escrow row).
|
|
type EscrowStatus struct {
|
|
IdentityBlobPresent bool `json:"identity_blob_present"`
|
|
ResticPwSHA256 string `json:"restic_pw_sha256"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
// EscrowAutoConfirmer runs the auto-confirm check on each report ACK. Long-lived (one per process) so
|
|
// the mismatch warning dedupes per distinct hash instead of firing every 15-minute cycle.
|
|
type EscrowAutoConfirmer struct {
|
|
// Pending reports whether the offbox target is configured AND EscrowState=="pending" — the
|
|
// confirm-flip state. "escrowed" is never flipped back (auto-UN-confirm does not exist), but
|
|
// since v0.127.0 it IS re-checked: see Escrowed + the stale-blob branch (Scenario F).
|
|
Pending func() bool
|
|
// Escrowed reports whether the offbox target is configured AND EscrowState=="escrowed" — the
|
|
// v0.127.0 stale-blob re-check state (Scenario F: a superseding ceremony that did NOT cover
|
|
// the current password — e.g. a CLI run without the staged secret — must be surfaced, not
|
|
// silently ignored; the spike left the drill box in exactly that state). nil → no re-check.
|
|
Escrowed func() bool
|
|
// LocalHash returns the canonical hash of the local repo password (ok=false → no password file).
|
|
LocalHash func() (hash string, ok bool)
|
|
// Flip transitions EscrowState pending→escrowed (settings.UpdateOffboxStatus).
|
|
Flip func() error
|
|
// Wipe removes the agent-staged secret (best-effort — the flip is the primary effect).
|
|
Wipe func(ctx context.Context) error
|
|
// RecordPresence persists the ACK's `identity_blob_present` — whether the HUB holds a sealed
|
|
// recovery package for this box (v0.199.0, R-204 item 4 / R-193).
|
|
//
|
|
// WHY IT LIVES HERE, in the auto-confirmer, rather than in its own ACK consumer: this is already
|
|
// the ONE place the ACK's escrow object arrives, and it is already wired. A second Reconcile call
|
|
// in main.go would be a second wiring point, and this project's count of features built but never
|
|
// wired is six. Pinned by TestEscrowConfirm_RecordsPresenceEvenWhenOffboxUnconfigured and by the
|
|
// wiring test.
|
|
//
|
|
// It is called FIRST, before every gate below, and that ordering is the whole fix: on a box with
|
|
// no off-site target `Pending()` and `Escrowed()` are both false and Reconcile returned
|
|
// immediately, so the one fact that distinguishes a REBUILT box from a box that never had
|
|
// off-site backups was thrown away on every cycle. nil → not recorded (older wiring, tests).
|
|
RecordPresence func(present bool) error
|
|
Logger *log.Logger
|
|
|
|
mu sync.Mutex
|
|
warnedHash string // last mismatched hub hash we warned about (dedupe; shared by both branches)
|
|
stale bool // Scenario F: the hub blob does not cover the CURRENT password (display-only)
|
|
// sealedAt is the ACK's escrow created_at (v0.200.0, R-193) — the ONE non-secret fact the
|
|
// recovery screen may state before a code is entered. Recorded on every ACK that carries an
|
|
// escrow object, including on an unconfigured box, for the same reason RecordPresence is.
|
|
sealedAt string
|
|
}
|
|
|
|
// staleHashlessMarker is the warnedHash dedupe sentinel for the hash-less supersession case
|
|
// (the hub hash is EMPTY there, which must still warn exactly once, and must not collide with
|
|
// the zero value of warnedHash).
|
|
const staleHashlessMarker = "(hashless)"
|
|
|
|
// StaleBlob reports the Scenario-F display flag: EscrowState is escrowed but the hub's CURRENT
|
|
// blob does not cover the current repo password. In-memory only (recomputed from ACKs after a
|
|
// restart); NEVER blocks runs and NEVER flips state — the web card renders the warning + the
|
|
// re-ceremony CTA from it.
|
|
func (c *EscrowAutoConfirmer) StaleBlob() bool {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.stale
|
|
}
|
|
|
|
// SealedAt returns the ACK-reported creation time of the hub's sealed recovery package ("" when no
|
|
// ACK has carried one). In-memory, recomputed from ACKs after a restart — the hub is the authority.
|
|
func (c *EscrowAutoConfirmer) SealedAt() string {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.sealedAt
|
|
}
|
|
|
|
func (c *EscrowAutoConfirmer) logf(f string, a ...any) {
|
|
if c.Logger != nil {
|
|
c.Logger.Printf(f, a...)
|
|
}
|
|
}
|
|
|
|
// Reconcile applies one ACK's escrow status. Scenarios: match → flip+wipe (A); mismatch → stay pending
|
|
// + warn once per hash (B); no status / no hash / no local file → stay pending silently (C, normal
|
|
// onboarding); escrowed → the v0.127.0 stale-blob re-check (F — warn-only, never a state change);
|
|
// otherwise → no-op (E — offbox not configured).
|
|
func (c *EscrowAutoConfirmer) Reconcile(es *EscrowStatus) {
|
|
if es == nil {
|
|
return
|
|
}
|
|
// FIRST, unconditionally — see RecordPresence. Every gate below is allowed to skip the
|
|
// auto-confirm; none of them may skip this, because an unconfigured box is exactly the case that
|
|
// needs the fact. A record failure is logged and does NOT stop the auto-confirm: the two are
|
|
// independent, and swallowing it silently is the shape this project keeps removing.
|
|
if c.RecordPresence != nil {
|
|
if err := c.RecordPresence(es.IdentityBlobPresent); err != nil {
|
|
c.logf("[WARN] [escrow-confirm] could not record the hub's identity-blob presence (present=%v): %v", es.IdentityBlobPresent, err)
|
|
}
|
|
}
|
|
c.mu.Lock()
|
|
c.sealedAt = es.CreatedAt // in-memory only; a timestamp, never a secret
|
|
c.mu.Unlock()
|
|
if !c.Pending() {
|
|
// Scenario F (v0.127.0): an ESCROWED box re-checks the hash on every ACK — a superseding
|
|
// blob that does not cover the current password must be surfaced (warn + card flag), while
|
|
// runs continue and the state stays escrowed (no auto-UN-confirm, ever).
|
|
if c.Escrowed != nil && c.Escrowed() {
|
|
c.reconcileEscrowed(es)
|
|
}
|
|
return
|
|
}
|
|
// Fail-closed: the hash must exist AND ride a present identity blob (the hash-bearing container).
|
|
// A hash-less blob is a legacy/password-less escrow — the deprecated manual confirm covers those.
|
|
if es.ResticPwSHA256 == "" || !es.IdentityBlobPresent {
|
|
return
|
|
}
|
|
localHash, ok := c.LocalHash()
|
|
if !ok {
|
|
return // no local repo password file — nothing to verify against
|
|
}
|
|
if localHash != es.ResticPwSHA256 {
|
|
// The stored escrow does NOT cover the current key — flipping would be a false custody claim.
|
|
c.mu.Lock()
|
|
warned := c.warnedHash == es.ResticPwSHA256
|
|
c.warnedHash = es.ResticPwSHA256
|
|
c.mu.Unlock()
|
|
if !warned {
|
|
c.logf("[WARN] [escrow-confirm] the hub's escrow blob does not cover the CURRENT repo password (hub hash %.12s… != local %.12s…) — run the escrow ceremony (wizard /backup/escrow, or felhom-agent --selftest=escrow-create --upload); staying pending", es.ResticPwSHA256, localHash)
|
|
}
|
|
return
|
|
}
|
|
if err := c.Flip(); err != nil {
|
|
c.logf("[ERROR] [escrow-confirm] hash matched but the escrowed flip failed (retries next cycle): %v", err)
|
|
return
|
|
}
|
|
c.logf("[INFO] [escrow-confirm] hub-verified: the escrow covers the current repo password (hash %.12s…) — EscrowState auto-confirmed escrowed; offsite runs enabled", es.ResticPwSHA256)
|
|
if c.Wipe != nil {
|
|
wctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
if err := c.Wipe(wctx); err != nil {
|
|
c.logf("[ERROR] [escrow-confirm] escrowed but the agent-staged secret was NOT wiped: %v", err)
|
|
}
|
|
}
|
|
c.mu.Lock()
|
|
c.stale = false // a fresh hub-verified confirm clears any earlier stale flag
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
// reconcileEscrowed is the Scenario-F branch (§8 truth table, escrowed rows): compare the ACK's
|
|
// hash exactly as the pending branch does; a mismatch OR a present blob with an EMPTY hash (the
|
|
// hash-less supersession — the spike's exact case) raises the stale flag + ONE warn per distinct
|
|
// hub hash (warnedHash reuse); a match clears the flag. State is never flipped; runs never block
|
|
// (offsite backups still protect against non-total loss).
|
|
func (c *EscrowAutoConfirmer) reconcileEscrowed(es *EscrowStatus) {
|
|
localHash, ok := c.LocalHash()
|
|
if !ok {
|
|
return // no local repo password file — nothing to compare against
|
|
}
|
|
hubHash := es.ResticPwSHA256
|
|
if hubHash != "" && hubHash == localHash {
|
|
c.mu.Lock()
|
|
c.stale = false
|
|
c.mu.Unlock()
|
|
return
|
|
}
|
|
// Stale: hash mismatch, or a blob whose hash is empty (hash-less supersession). Dedupe the
|
|
// warn per distinct hub hash; the empty hash dedupes under a sentinel so it still fires once.
|
|
dedupeKey := hubHash
|
|
if dedupeKey == "" {
|
|
dedupeKey = staleHashlessMarker
|
|
}
|
|
c.mu.Lock()
|
|
warned := c.warnedHash == dedupeKey
|
|
c.warnedHash = dedupeKey
|
|
c.stale = true
|
|
c.mu.Unlock()
|
|
if warned {
|
|
return
|
|
}
|
|
if hubHash == "" {
|
|
c.logf("[WARN] [escrow-confirm] STALE escrow: the hub's current blob carries NO password hash (hash-less supersession) — the stored recovery bundle does not cover the offsite password; create a new recovery code (wizard /backup/escrow). State stays escrowed; runs continue")
|
|
return
|
|
}
|
|
c.logf("[WARN] [escrow-confirm] STALE escrow: the hub's current blob does not cover the CURRENT repo password (hub hash %.12s… != local %.12s…) — create a new recovery code (wizard /backup/escrow). State stays escrowed; runs continue", hubHash, localHash)
|
|
}
|