R-193: the recovery screen — unlocking, and only unlocking (v0.200.0)

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.
This commit is contained in:
2026-08-05 12:45:48 +02:00
parent be3c5fa7f6
commit 636c51e542
14 changed files with 1347 additions and 27 deletions
@@ -59,6 +59,10 @@ type EscrowAutoConfirmer struct {
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
@@ -76,6 +80,14 @@ func (c *EscrowAutoConfirmer) StaleBlob() bool {
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...)
@@ -99,6 +111,9 @@ func (c *EscrowAutoConfirmer) Reconcile(es *EscrowStatus) {
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