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
+38
View File
@@ -1241,6 +1241,44 @@ func (m *Manager) needsOffsiteCredential(t *settings.OffboxTarget) bool {
return true
}
// OffsiteRecoveryOffer reports whether the customer should be OFFERED the recovery screen (R-193):
// the hub is holding a sealed recovery package for this box, and this box cannot open what that
// package protects.
//
// TWO FACTS, BOTH REQUIRED — and this is the whole correctness of the screen:
//
// 1. **the hub holds a sealed package** (the ACK's identity_blob_present, cached in settings). Without
// it there is nothing to recover, and a box that never had off-site backups must never be greeted
// by a recovery screen for data it never had. Dropping this condition is the plausible wrong fix.
// 2. **this box cannot open the history that package protects** — see the two shapes below.
//
// ⚠ WHY SHAPE (b) EXISTS, recorded because the task specified only shape (a) and the difference is
// load-bearing. The literal reading of "the data area is fresh" is *no repository password on disk*,
// which is true of a rebuilt box — but only until it re-applies its off-site target, because
// `WriteOffboxSecrets` AUTO-GENERATES a repository password when none is present. That is precisely
// R-193's orphaning mechanism, and since hub v0.96.0's credential self-heal the re-apply happens by
// itself within ~1530 minutes. So shape (a) alone would make this screen appear only inside a
// half-hour window that closes on its own, and the customer who logs in the next morning — the actual
// customer — would never see it. Shape (b) is the state they are in: a repository password exists, but
// it is a NEW one and the inherited history cannot be opened with it, which the box has already
// measured and recorded as `RepoState == "orphaned"`.
//
// Shape (b) also happens to be the state the existing move-aside requires (`ResetOrphanedRepo` refuses
// unless orphaned), which is what lets "I do not want the old data" reach the shipped handler rather
// than needing a new one.
//
// Scenario B still holds exactly: a healthy box has its own password and is not orphaned; a box that
// never had off-site backups fails fact 1; an unclaimed box never reaches an authenticated page.
func (m *Manager) OffsiteRecoveryOffer() bool {
if m.settings == nil || !m.settings.GetHubEscrowIdentityPresent() {
return false // the hub holds nothing for us — nothing to recover
}
if _, ok := m.OffboxRepoPasswordHash(); !ok {
return true // (a) no repository password at all — the pristine rebuilt box
}
return m.OffboxOrphaned() // (b) a password exists but the inherited history will not open under it
}
// OffboxReportStatus returns the offsite summary for the hub report.
//
// nil = nothing to say (not configured, and nothing to ask for) — the hub's checker treats absence as
@@ -0,0 +1,125 @@
package backup
import (
"context"
"encoding/json"
"errors"
"sort"
"time"
)
// R-193 Part 3 — WHAT IS IN THERE. After a successful unlock the customer is shown the contents of the
// repository they just opened: which apps, from when, how big.
//
// READ-ONLY, AND THAT IS THE POINT. This restores nothing, puts nothing back, and compares nothing
// against live data. Unlocking and restoring are separate (operator ruling, 2026-08-05): restore is
// already per-app and already lives in the backups area, and a screen that unlocks and then offers to
// overwrite is two decisions wearing one button.
//
// WHY A LISTING AT ALL, rather than a success message: "unlocked" with nothing shown is
// indistinguishable from having unlocked an EMPTY store, and the customer has no way to tell whether
// what came back is the right thing. Seeing their own app names and dates is how they know.
// errNoOffsiteTarget is returned when the repository cannot even be addressed — no off-site target is
// configured on this box yet. Distinguished from a read failure because the remedy differs: this one
// resolves by itself once the tier is re-applied.
var errNoOffsiteTarget = errors.New("no off-site target is configured on this box yet")
// ErrNoOffsiteTarget reports whether err is the not-yet-configured case, so a caller can say the right
// thing rather than showing a generic failure.
func ErrNoOffsiteTarget(err error) bool { return errors.Is(err, errNoOffsiteTarget) }
// OffsiteInventoryApp is one app's presence in the opened repository. Non-secret throughout.
type OffsiteInventoryApp struct {
App string // the restic tag == the stack name
LatestAt time.Time // the newest snapshot's time for this app
SizeBytes int64 // restore size of that newest snapshot (0 = could not be determined)
}
// OffsiteInventory is the whole answer, including the EMPTY case stated explicitly.
type OffsiteInventory struct {
Apps []OffsiteInventoryApp
// Empty is true when the repository opened cleanly and holds no snapshots. It is a real and
// confusing outcome — a bare list there reads as a broken page — so it is named rather than
// inferred from len(Apps)==0, which is also what a failed read looks like.
Empty bool
}
// OffsiteInventoryList opens the repository and reports what is in it, grouped per app. One
// `snapshots --json` call for the whole repo, then one `stats` per app for the newest snapshot's size.
//
// A per-app size failure is NOT fatal: the app is still listed, with SizeBytes 0, because knowing an
// app is in there matters more than knowing how big it is, and dropping it would under-report the
// customer's own data.
func (m *Manager) OffsiteInventoryList(ctx context.Context) (OffsiteInventory, error) {
var inv OffsiteInventory
// A box can hold a recovered key and still have no off-site COORDINATES — the pristine rebuilt
// shape, before its target is re-applied. Reading the repository is impossible then, and saying so
// is the honest answer; without this guard offboxBaseArgs nil-derefs on the missing target.
if !m.OffboxConfigured() {
return inv, errNoOffsiteTarget
}
t := m.settings.GetOffboxTarget()
base, env := m.offboxBaseArgs(t)
sctx, cancel := context.WithTimeout(ctx, offboxProbeTimeout)
defer cancel()
out, err := m.runner()(sctx, env, append(append([]string{}, base...), "snapshots", "--json")...)
if err != nil {
return inv, err
}
var snaps []struct {
ShortID string `json:"short_id"`
ID string `json:"id"`
Time time.Time `json:"time"`
Tags []string `json:"tags"`
}
if uerr := json.Unmarshal(out, &snaps); uerr != nil {
return inv, uerr
}
if len(snaps) == 0 {
inv.Empty = true
return inv, nil
}
// Newest snapshot per tag. A snapshot may carry several tags; each names an app it belongs to.
newest := map[string]struct {
id string
at time.Time
}{}
for _, s := range snaps {
id := s.ShortID
if id == "" {
id = s.ID
}
for _, tag := range s.Tags {
if tag == "" {
continue
}
if cur, ok := newest[tag]; !ok || s.Time.After(cur.at) {
newest[tag] = struct {
id string
at time.Time
}{id: id, at: s.Time}
}
}
}
if len(newest) == 0 {
// Snapshots exist but carry no tags — not "empty", and saying so would be a lie. Report an
// empty app list without the Empty flag; the page renders the honest in-between wording.
return inv, nil
}
for tag, n := range newest {
app := OffsiteInventoryApp{App: tag, LatestAt: n.at}
if size, serr := m.offboxSnapshotSize(ctx, n.id); serr == nil {
app.SizeBytes = size
} else {
m.logger.Printf("[WARN] [offbox] inventory: size of %s's newest snapshot unknown: %v (listing it anyway)", tag, serr)
}
inv.Apps = append(inv.Apps, app)
}
sort.Slice(inv.Apps, func(i, j int) bool { return inv.Apps[i].App < inv.Apps[j].App })
return inv, nil
}
// HumanizeBytes exposes the shared byte formatter to the web layer so the recovery page renders sizes
// the same way every other surface does.
func HumanizeBytes(n int64) string { return humanizeBytes(n) }
@@ -137,6 +137,91 @@ func RunRecoveryCheck(d RecoveryCheckDeps) int {
// refused — a password is present and DIFFERS. Installing would clobber the key this box's CURRENT
// repository is encrypted under, so it is refused. No force option is offered here: that
// decision needs a human who knows which history they intend to keep.
// RecoverInstallOutcome names the terminal states of a recovery+install. Distinct values because
// "it did nothing", "it refused" and "it installed" are different facts and a caller — CLI or web —
// must be able to say which happened without parsing prose.
type RecoverInstallOutcome string
const (
// RecoverInstalled — the box had NO repository password; the recovered one is now in place.
RecoverInstalled RecoverInstallOutcome = "installed"
// RecoverUnchanged — a password was present and is byte-identical to the recovered one.
RecoverUnchanged RecoverInstallOutcome = "unchanged"
// RecoverRefused — a DIFFERENT password is present; installing would clobber the key the box's
// current repository is encrypted under.
RecoverRefused RecoverInstallOutcome = "refused"
// RecoverDryRun — nothing was written because confirm was false.
RecoverDryRun RecoverInstallOutcome = "dry_run"
)
// RecoverInstallResult is the non-secret outcome of a recovery. It carries HASHES ONLY — never the
// password, never R. The hashes are of 256-bit random secrets, non-reversible, and are the same
// values the hub already stores and serves in report ACKs.
type RecoverInstallResult struct {
Outcome RecoverInstallOutcome
LocalPresent bool
LocalSHA256 string
RecoveredSHA256 string
}
// RecoverInstallCore is THE recovery+install path in this codebase — fetch the sealed bundle through
// the agent, unseal it with R, compare against what is on disk, and place it when that is the right
// thing to do.
//
// ONE FUNCTION, TWO CALLERS (R-193). The CLI (`--recover-offsite-install`) and the customer's recovery
// page both call this. They must not each carry a copy: two implementations of the one operation that
// can permanently lose a customer's data would drift, and only one of them would ever be tested.
// `RecoverAndInstall` below is a thin wrapper that maps this result onto the CLI's exit codes and
// printed lines; the web handler maps it onto Hungarian copy. Neither contains recovery logic.
//
// R IS THE CALLER'S TO CLEAR. This function does not retain it: it is passed to the agent seam and
// never stored, logged or returned. The password recovered from the bundle IS cleared here, on every
// path, before returning — it never leaves this function in any form.
//
// The three outcomes and their reasoning are unchanged from the CLI's original implementation; see
// RecoverAndInstall's header, which remains the authority on WHY a differing local password is
// refused rather than forced.
func RecoverInstallCore(ctx context.Context, m *Manager, rec OffsiteKeyRecoverer, R string, confirm bool) (RecoverInstallResult, error) {
var res RecoverInstallResult
if m == nil || rec == nil {
return res, fmt.Errorf("recovery not configured (no backup manager or no agent channel)")
}
pw, recoveredHash, err := rec.RecoverOffsiteRepoPassword(ctx, R)
if err != nil {
return res, err // the agent's message names the step; it carries no secret
}
res.RecoveredSHA256 = recoveredHash
res.LocalSHA256, res.LocalPresent = m.OffboxRepoPasswordHash()
switch {
case res.LocalPresent && res.LocalSHA256 == recoveredHash:
pw = ""
res.Outcome = RecoverUnchanged
return res, nil
case res.LocalPresent:
pw = ""
res.Outcome = RecoverRefused
return res, nil
}
if !confirm {
pw = ""
res.Outcome = RecoverDryRun
return res, nil
}
if err := m.InjectOffboxPassword(pw, false); err != nil {
pw = ""
return res, fmt.Errorf("placing the recovered password: %w", err)
}
pw = ""
// Re-read from disk rather than trusting what we just wrote — the observable is the file's state.
afterHash, ok := m.OffboxRepoPasswordHash()
if !ok || afterHash != recoveredHash {
return res, fmt.Errorf("the password was written but does not read back as expected (on-disk %q)", afterHash)
}
res.Outcome = RecoverInstalled
return res, nil
}
func RecoverAndInstall(d RecoveryCheckDeps, confirm bool) int {
out, errw := d.Out, d.Err
if out == nil {
@@ -172,53 +257,39 @@ func RecoverAndInstall(d RecoveryCheckDeps, confirm bool) int {
defer cancel()
fmt.Fprintln(out, "=== offsite key recovery INSTALL (R-200) ===")
// The recovery itself is the same call the check makes, so there is exactly one fetch+unseal path
// in this codebase and no chance of the two drifting.
pw, recoveredHash, err := d.Recoverer.RecoverOffsiteRepoPassword(ctx, R)
// THE RECOVERY ITSELF IS RecoverInstallCore — the same function the customer's recovery page
// drives (R-193). This wrapper adds the CLI's stdin handling, its printed lines and its exit
// codes, and NOTHING else; there is exactly one fetch→unseal→compare→install path in this
// codebase and no chance of the two callers drifting. Pinned by
// TestRecoverAndInstall_DrivesTheSharedCore and by the AST wiring test.
res, err := RecoverInstallCore(ctx, d.Manager, d.Recoverer, R, confirm)
R = "" // cleared immediately, on every path below
if err != nil {
fmt.Fprintf(errw, " [FAIL] %v\n", err)
fmt.Fprintln(errw, " nothing was written.")
return 1
}
localHash, localPresent := d.Manager.OffboxRepoPasswordHash()
if localPresent {
fmt.Fprintf(out, " on-disk sha256: %s\n", localHash)
if res.LocalPresent {
fmt.Fprintf(out, " on-disk sha256: %s\n", res.LocalSHA256)
} else {
fmt.Fprintln(out, " on-disk sha256: (none — this box has no repository password)")
}
fmt.Fprintf(out, " recovered sha256: %s\n", recoveredHash)
fmt.Fprintf(out, " recovered sha256: %s\n", res.RecoveredSHA256)
switch {
case localPresent && localHash == recoveredHash:
pw = ""
switch res.Outcome {
case RecoverUnchanged:
fmt.Fprintln(out, " [UNCHANGED] the box already holds exactly this key. Nothing written.")
return 0
case localPresent:
pw = ""
case RecoverRefused:
fmt.Fprintln(errw, " [REFUSED] a DIFFERENT repository password is already present.")
fmt.Fprintln(errw, " Installing would clobber the key this box's current repository is encrypted under,")
fmt.Fprintln(errw, " and which history to keep is not a decision this command may take. Nothing written.")
return 2
}
if !confirm {
pw = ""
case RecoverDryRun:
fmt.Fprintln(out, " [DRY RUN] nothing written. The recovered key is ready to install.")
fmt.Fprintln(out, " Re-run with --confirm-install to place it.")
return 0
}
if err := d.Manager.InjectOffboxPassword(pw, false); err != nil {
pw = ""
fmt.Fprintf(errw, " [FAIL] placing the recovered password: %v\n", err)
return 1
}
pw = ""
// Re-read from disk rather than trusting what we just wrote — the observable is the file's state.
afterHash, ok := d.Manager.OffboxRepoPasswordHash()
if !ok || afterHash != recoveredHash {
fmt.Fprintf(errw, " [FAIL] the password was written but does not read back as expected (on-disk %q)\n", afterHash)
return 1
}
fmt.Fprintln(out, " [INSTALLED] the recovered repository password is in place and reads back identical.")
fmt.Fprintln(out, " Re-apply the offsite target and run a backup: the existing repository should open.")
return 0