R-241 part 2: the comparison the box already makes becomes the thing that offers recovery
THE FACT WAS COMPUTED EVERY CYCLE AND KEPT NOWHERE. EscrowAutoConfirmer.Reconcile
has compared the hub's restic_pw_sha256 against the local key on every ACK since
SLICE 3. On the final-walk venue it logged, at 03:28:03Z and thirty-five minutes
before the customer looked, "the hub's escrow blob does not cover the CURRENT repo
password (hub hash 30ef574f != local 9b4a9a9d)" - and dropped it. The recovery
screen, evaluating in the same process, went on asking a question that could not
see it.
Now persisted: settings.HubEscrowKeySHA256 + HubEscrowKeyCheckedAt, recorded
UNCONDITIONALLY in Reconcile beside RecordPresence and RecordSuperseded - same
place, same reason: the box that needs it most is the rebuilt one with no target,
on which every gate below returns early.
OffsiteRecoveryOffer gains SHAPE (c): the hub holds a package for a key OTHER than
the one we are using. (a) and (b) are both proxies for that question and both have
now been wrong in opposite directions - (a) goes false the moment anything mints,
(b) is unreachable while the escrow is pending.
SEC 7.2, decided deliberately and stated in the code:
- a KNOWN DIFFERENCE offers, however old the reading. Age is not gated on. Both
sides are local; only the hub's half can be stale, and what the hub holds does
not change without a ceremony THIS box runs, which refreshes the hash on the
next ACK. Gating on age would make a box offline from the hub silently stop
offering - the exact failure this session removes. CheckedAt is persisted for
diagnosis, not as a gate.
- an ABSENT hash falls back to (a)/(b) and does NOT offer. "" is the hub
positively saying its package seals no repository password (legacy hash-less
escrow). Nothing to compare, and offering would put a permanent screen in
front of every legacy box.
The write damper: CheckedAt refreshes on every ack carrying a hash, but a save is
skipped when both the hash and the UTC day are unchanged, so an idle box does not
rewrite settings.json every fifteen minutes. It records WHEN WE LAST HEARD, not
when it last changed - the R-100 distinction.
Tests: Scenario C (a differing key offers, with both proxies asserted false first),
Scenario D (a matching key offers nothing), fact 1 still required, shape (a) still
works, and both SEC 7.2 halves.
RED-PROOFS, each with the mutation confirmed present in the file first:
D) hubHash != localHash conjunct dropped -> Scenario D FAILS (a healthy box
offered recovery forever); Scenario C still passes
WIRING) RecordEscrowKeyHash removed from the EscrowAutoConfirmer literal in
main.go -> TestMainWiresRecordEscrowKeyHash FAILS. This is the ships-inert
shape: unwired, everything compiles, every test in the package passes, the
auto-confirm still works, and shape (c) reads an empty hash forever.
Green: go build, go vet, go test ./... all pass.
This commit is contained in:
@@ -86,6 +86,32 @@ type Settings struct {
|
||||
HubEscrowSupersededPresent bool `json:"hub_escrow_superseded_present,omitempty"`
|
||||
HubEscrowSupersededAt string `json:"hub_escrow_superseded_at,omitempty"`
|
||||
|
||||
// HubEscrowKeySHA256 / HubEscrowKeyCheckedAt (v0.206.0, R-241) cache the report ACK's
|
||||
// `escrow.restic_pw_sha256` — the sha256 of the repository password the hub's sealed package
|
||||
// COVERS — and when it was last recorded.
|
||||
//
|
||||
// ⚠ THIS FACT WAS ALREADY COMPUTED EVERY CYCLE AND KEPT NOWHERE, and that is the whole of R-241's
|
||||
// second half. `report.EscrowAutoConfirmer.Reconcile` has compared this hash against the local key
|
||||
// on every ACK since SLICE 3; on the final-walk venue it logged, at 03:28:03Z and thirty-five
|
||||
// minutes before the customer looked, *"the hub's escrow blob does not cover the CURRENT repo
|
||||
// password (hub hash 30ef574f… != local 9b4a9a9d…)"* — and then dropped it on the floor. The
|
||||
// recovery screen, evaluating in the same process, went on asking a question that could not see it.
|
||||
//
|
||||
// It is the ONE fact that answers the screen's real question directly: *does the hub hold a package
|
||||
// for a key other than the one I am using?* Shape (a) ("no key at all") and shape (b) ("a run
|
||||
// proved the repo will not open") are both proxies for it, and both have now been wrong in
|
||||
// opposite directions — (a) goes false the moment anything mints, (b) is unreachable while the
|
||||
// escrow is pending.
|
||||
//
|
||||
// NON-SECRET: the sha256 of a 256-bit random secret is non-reversible and is already logged and
|
||||
// served over the ACK. It must still never reach a customer-facing message.
|
||||
//
|
||||
// EMPTY IS MEANINGFUL AND IS NOT "THEY DIFFER": the hub sends "" for a legacy hash-less escrow
|
||||
// (a package that provably seals no repository password). Shape (c) requires a NON-EMPTY hash —
|
||||
// see backup.OffsiteRecoveryOffer for the staleness reasoning.
|
||||
HubEscrowKeySHA256 string `json:"hub_escrow_key_sha256,omitempty"`
|
||||
HubEscrowKeyCheckedAt string `json:"hub_escrow_key_checked_at,omitempty"` // RFC3339
|
||||
|
||||
// RecoveryNoticePostponed (v0.200.0, R-193) — the customer chose "most nem" on the full-page
|
||||
// recovery screen. It suppresses THE FULL-PAGE INTERRUPTION ONLY. The entry point in the backups
|
||||
// area stays, permanently, for as long as the situation lasts: the data is still there whether or
|
||||
@@ -2052,3 +2078,46 @@ func (s *Settings) GetIntegrationsForTarget(target string) map[string]Integratio
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetHubEscrowKeySHA256 returns the sha256 the hub's sealed package covers, and when it was last
|
||||
// recorded from an ACK ("" / "" when never learned). See the field comment: empty is "the hub never
|
||||
// told us", not "they match".
|
||||
func (s *Settings) GetHubEscrowKeySHA256() (sha, checkedAt string) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.HubEscrowKeySHA256, s.HubEscrowKeyCheckedAt
|
||||
}
|
||||
|
||||
// SetHubEscrowKeySHA256 records the ACK's `escrow.restic_pw_sha256` and stamps when. Same
|
||||
// last-write-wins mirror discipline as SetHubEscrowIdentityPresent — the hub is the authority on
|
||||
// what the hub holds, and a re-ceremony legitimately moves this.
|
||||
//
|
||||
// The timestamp is refreshed on EVERY ack that carries a hash, including an unchanged one, because
|
||||
// it records *when we last heard*, not *when it last changed* — a distinction this project has got
|
||||
// wrong before (R-100: LastRun recorded an attempt and was read as a result). A no-op save is
|
||||
// avoided only when BOTH the hash and the day are unchanged, so an idle box does not rewrite
|
||||
// settings.json every fifteen minutes.
|
||||
func (s *Settings) SetHubEscrowKeySHA256(sha, checkedAt string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.HubEscrowKeySHA256 == sha && sameDayStamp(s.HubEscrowKeyCheckedAt, checkedAt) {
|
||||
return nil
|
||||
}
|
||||
s.HubEscrowKeySHA256, s.HubEscrowKeyCheckedAt = sha, checkedAt
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// sameDayStamp reports whether two RFC3339 stamps fall on the same UTC day — the write-damper for
|
||||
// SetHubEscrowKeySHA256. Unparseable stamps are treated as different, so a malformed value always
|
||||
// gets replaced rather than sticking.
|
||||
func sameDayStamp(a, b string) bool {
|
||||
if a == "" || b == "" {
|
||||
return false
|
||||
}
|
||||
ta, erra := time.Parse(time.RFC3339, a)
|
||||
tb, errb := time.Parse(time.RFC3339, b)
|
||||
if erra != nil || errb != nil {
|
||||
return false
|
||||
}
|
||||
return ta.UTC().Format("2006-01-02") == tb.UTC().Format("2006-01-02")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user