The escrow wizard sends the typed-back words to the box; the new package reaches the hub only when they match (R-1151 controller half, unreleased; works with every agent)
gates / gates (push) Successful in 47s

This commit is contained in:
2026-10-19 03:19:02 +02:00
parent 8ae9178138
commit ba4a6f060b
12 changed files with 434 additions and 35 deletions
+23
View File
@@ -1,3 +1,26 @@
## Unreleased (2026-10-19) — the new recovery code replaces the old one only after the household typed it back (R-1151, controller half)
Ships in the next release. **No new MinAgent:** it works with every agent — the agent's claim answer says whether a
confirm is needed.
- **The wizard's typed-back words now go to the box, not only to the browser.** The claim asks the agent to keep the
new package (`{"defer_upload": true}`); an agent ≥ 0.160.0 answers with `confirm_required` and the two word
positions (it draws them now; the page drew them before), and the page's „Szavak ellenőrzése" posts the two words to
the new `POST /api/escrow/confirm` (session + CSRF, like the other `/api/escrow/*` calls), which hands them to the
agent's `POST /escrow/ceremony/confirm`. Only a match stores the new package on the hub; until then the ceremony is not
complete (no „completed" stamp, no report push — both moved from the claim to the confirm). Answers: wrong words →
422, the page says so and the wait goes on; the agent's window ran out (410) or nothing waits any more (404 — a new
start, an agent restart) → 410 with `restart`, the page clears the code and offers „Újraindítás"; a hub failure →
502, try again. Every answer says the previous code still works where it does. New keys
`api.escrow.confirm_expired`, `confirm_lost`, `confirm_store_failed`, `confirm_words_mismatch` (hu + en).
**An agent older than 0.160.0** ignores the claim's body and stores the package at the claim, as before; its answer
has no `confirm_required`, so the page keeps its own check and the claim completes the ceremony (pinned).
Tests `TestR1151_ClaimThatWaitsIsNotCompleteUntilTheWordsComeBack`, `TestR1151_OldAgentClaimCompletesAtOnce`,
`TestR1151_ConfirmAnswersInTheBoxLanguage`, `TestEscrowClaimAndConfirm_R1151` (agentapi). Red-proved: the
`ConfirmRequired` branch removed → `claim: 200 {"data":{"recovery_code":…}}` with no confirm request. i18n fixtures
changed (named): `escrow_first`, `escrow_receremony` (the page's script and the hidden „Újraindítás" link); every
other fixture byte-identical.
## v0.331.0 — OnlyOffice opens and SAVES from FileBrowser and Nextcloud; FileBrowser is back at once after a restart (R-1018); a removal deletes a volume the definition dropped (R-1117) (2026-10-18) ## v0.331.0 — OnlyOffice opens and SAVES from FileBrowser and Nextcloud; FileBrowser is back at once after a restart (R-1018); a removal deletes a volume the definition dropped (R-1117) (2026-10-18)
**MinAgent: 0.158.1** — the floor's, carried (not above the vouched agent 0.159.2). Nothing here calls a new agent route. **MinAgent: 0.158.1** — the floor's, carried (not above the vouched agent 0.159.2). Nothing here calls a new agent route.
+41 -9
View File
@@ -92,31 +92,63 @@ func (c *Client) EscrowCeremonyStatus(ctx context.Context) (EscrowCeremonyStatus
return out, nil return out, nil
} }
// EscrowClaim is a claimed recovery code. ConfirmRequired (agent ≥ 0.160.0, R-1151): the code's package reaches the
// hub only when EscrowCeremonyConfirm brings back the words at Positions (1-based); an older agent answers without
// it — its package is already on the hub, and no confirm is needed.
type EscrowClaim struct {
Code string
ConfirmRequired bool
Positions []int
}
// EscrowCeremonyClaim performs the ONE-SHOT R claim. Returns the recovery code + the agent's // EscrowCeremonyClaim performs the ONE-SHOT R claim. Returns the recovery code + the agent's
// HTTP status (410 = already claimed / expired — the wizard's void state). The code must never // HTTP status (410 = already claimed / expired — the wizard's void state). The code must never
// be logged, persisted, or placed anywhere but the claim XHR response; the error path carries // be logged, persisted, or placed anywhere but the claim XHR response; the error path carries
// the agent's reason text, never the code. // the agent's reason text, never the code.
func (c *Client) EscrowCeremonyClaim(ctx context.Context) (string, int, error) { //
env, status, err := c.postWithStatus(ctx, "/escrow/ceremony/claim", struct{}{}) // R-1151: the claim asks the agent to keep the new package until the household typed the code back
// (`defer_upload`). An agent older than 0.160.0 ignores the body and uploads as before.
func (c *Client) EscrowCeremonyClaim(ctx context.Context) (EscrowClaim, int, error) {
env, status, err := c.postWithStatus(ctx, "/escrow/ceremony/claim", map[string]bool{"defer_upload": true})
if err != nil { if err != nil {
return "", status, err return EscrowClaim{}, status, err
} }
if status == http.StatusGone { if status == http.StatusGone {
return "", status, fmt.Errorf("agentapi: POST /escrow/ceremony/claim: gone (claimed or expired)") return EscrowClaim{}, status, fmt.Errorf("agentapi: POST /escrow/ceremony/claim: gone (claimed or expired)")
} }
if err := refusalError("/escrow/ceremony/claim", status, env); err != nil { if err := refusalError("/escrow/ceremony/claim", status, env); err != nil {
return "", status, err return EscrowClaim{}, status, err
} }
var out struct { var out struct {
RecoveryCode string `json:"recovery_code"` RecoveryCode string `json:"recovery_code"`
ConfirmRequired bool `json:"confirm_required"`
VerifyPositions []int `json:"verify_positions"`
} }
if err := json.Unmarshal(env.Data, &out); err != nil { if err := json.Unmarshal(env.Data, &out); err != nil {
return "", status, fmt.Errorf("agentapi: decode /escrow/ceremony/claim: %w", err) return EscrowClaim{}, status, fmt.Errorf("agentapi: decode /escrow/ceremony/claim: %w", err)
} }
if out.RecoveryCode == "" { if out.RecoveryCode == "" {
return "", status, fmt.Errorf("agentapi: /escrow/ceremony/claim returned no code") return EscrowClaim{}, status, fmt.Errorf("agentapi: /escrow/ceremony/claim returned no code")
} }
return out.RecoveryCode, status, nil if out.ConfirmRequired && len(out.VerifyPositions) != 2 {
return EscrowClaim{}, status, fmt.Errorf("agentapi: /escrow/ceremony/claim asks for a confirmation without two positions")
}
return EscrowClaim{Code: out.RecoveryCode, ConfirmRequired: out.ConfirmRequired, Positions: out.VerifyPositions}, status, nil
}
// EscrowCeremonyConfirm sends the two words the household typed back (R-1151, agent ≥ 0.160.0); on success the agent
// has stored the new package on the hub. The status is returned for the caller to classify: 422 the words do not
// match (still waiting), 410 the window ran out, 404 nothing waits (superseded, restarted), 502 the hub failed (still
// waiting). The words are part of the recovery code — never logged.
func (c *Client) EscrowCeremonyConfirm(ctx context.Context, words []string) (int, error) {
env, status, err := c.postWithStatus(ctx, "/escrow/ceremony/confirm", map[string][]string{"words": words})
if err != nil {
return status, err
}
if err := refusalError("/escrow/ceremony/confirm", status, env); err != nil {
return status, err
}
return status, nil
} }
// RecoverOffsiteRepoPassword asks the agent to open this host's hub-held sealed bundle with the // RecoverOffsiteRepoPassword asks the agent to open this host's hub-held sealed bundle with the
@@ -0,0 +1,63 @@
package agentapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// R-1151 — the claim asks the agent to keep the package until the words come back, and reads the agent's answer;
// the confirm carries the two words. An agent older than 0.160.0 answers without confirm_required.
func TestEscrowClaimAndConfirm_R1151(t *testing.T) {
var claimBody map[string]any
var confirmBody map[string][]string
newAgent := true
mux := http.NewServeMux()
mux.HandleFunc("POST /escrow/ceremony/claim", func(w http.ResponseWriter, r *http.Request) {
claimBody = nil
_ = json.NewDecoder(r.Body).Decode(&claimBody)
if newAgent {
_, _ = w.Write([]byte(`{"ok":true,"data":{"recovery_code":"a-b-c","confirm_required":true,"verify_positions":[1,3]}}`))
return
}
_, _ = w.Write([]byte(`{"ok":true,"data":{"recovery_code":"a-b-c"}}`))
})
mux.HandleFunc("POST /escrow/ceremony/confirm", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&confirmBody)
if confirmBody["words"][0] != "a" {
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = w.Write([]byte(`{"ok":false,"error":"the words do not match the recovery code"}`))
return
}
_, _ = w.Write([]byte(`{"ok":true,"data":{"uploaded":true}}`))
})
s := httptest.NewTLSServer(mux)
defer s.Close()
c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://"))
claim, status, err := c.EscrowCeremonyClaim(context.Background())
if err != nil || status != http.StatusOK || claim.Code != "a-b-c" || !claim.ConfirmRequired || len(claim.Positions) != 2 || claim.Positions[1] != 3 {
t.Fatalf("claim: %+v %d %v", claim, status, err)
}
if claimBody["defer_upload"] != true {
t.Fatalf("the claim did not ask the agent to wait for the words: body %v", claimBody)
}
if st, err := c.EscrowCeremonyConfirm(context.Background(), []string{"a", "c"}); err != nil || st != http.StatusOK {
t.Fatalf("confirm: %d %v", st, err)
}
if strings.Join(confirmBody["words"], ",") != "a,c" {
t.Fatalf("confirm sent %v", confirmBody)
}
if st, err := c.EscrowCeremonyConfirm(context.Background(), []string{"x", "c"}); err == nil || st != http.StatusUnprocessableEntity {
t.Fatalf("a wrong word must come back as a 422 error: %d %v", st, err)
}
newAgent = false // agent 0.159.2: no confirm request
claim, _, err = c.EscrowCeremonyClaim(context.Background())
if err != nil || claim.ConfirmRequired || claim.Code != "a-b-c" {
t.Fatalf("claim on an old agent: %+v %v", claim, err)
}
}
+4
View File
@@ -2634,6 +2634,10 @@
"api.escrow.already_running": "A code is already being created — wait until it finishes.", "api.escrow.already_running": "A code is already being created — wait until it finishes.",
"api.escrow.code_not_shown": "The code was created but not shown — for security it cannot be fetched again. Start the process again: the new code replaces the old one.", "api.escrow.code_not_shown": "The code was created but not shown — for security it cannot be fetched again. Start the process again: the new code replaces the old one.",
"api.escrow.no_active_job": "There is no active recovery process — start creating the code first.", "api.escrow.no_active_job": "There is no active recovery process — start creating the code first.",
"api.escrow.confirm_expired": "The time to type the new code back has run out — your previous code still works. Start the process again.",
"api.escrow.confirm_lost": "The new code no longer waits for confirmation — your previous code still works. Start the process again.",
"api.escrow.confirm_store_failed": "The new code could not be saved — your previous code still works. Try again.",
"api.escrow.confirm_words_mismatch": "The words do not match — check the code you wrote down.",
"api.share.form_invalid": "Invalid form — reload the page.", "api.share.form_invalid": "Invalid form — reload the page.",
"api.share.too_many_attempts": "Too many failed attempts, try again in 1 minute", "api.share.too_many_attempts": "Too many failed attempts, try again in 1 minute",
"api.export.upload_size_mismatch": "The uploaded size does not match — try again.", "api.export.upload_size_mismatch": "The uploaded size does not match — try again.",
+4
View File
@@ -2622,6 +2622,10 @@
"api.escrow.already_running": "Egy kódkészítés már folyamatban van — várd meg, míg befejeződik.", "api.escrow.already_running": "Egy kódkészítés már folyamatban van — várd meg, míg befejeződik.",
"api.escrow.code_not_shown": "A kód létrejött, de nem lett megjelenítve — biztonsági okból újra nem kérhető le. Indítsd újra a folyamatot: az új kód a régit érvényteleníti.", "api.escrow.code_not_shown": "A kód létrejött, de nem lett megjelenítve — biztonsági okból újra nem kérhető le. Indítsd újra a folyamatot: az új kód a régit érvényteleníti.",
"api.escrow.no_active_job": "Nincs aktív helyreállítási folyamat — előbb indítsd el a kódkészítést.", "api.escrow.no_active_job": "Nincs aktív helyreállítási folyamat — előbb indítsd el a kódkészítést.",
"api.escrow.confirm_expired": "Lejárt az idő az új kód visszaírására — a régi kódod továbbra is érvényes. Indítsd újra a folyamatot.",
"api.escrow.confirm_lost": "Az új kód már nem vár megerősítésre — a régi kódod továbbra is érvényes. Indítsd újra a folyamatot.",
"api.escrow.confirm_store_failed": "Az új kódot nem sikerült elmenteni — a régi kódod továbbra is érvényes. Próbáld újra.",
"api.escrow.confirm_words_mismatch": "A megadott szavak nem egyeznek — ellenőrizd a felírt kódot.",
"api.share.form_invalid": "Érvénytelen űrlap — töltsd újra az oldalt.", "api.share.form_invalid": "Érvénytelen űrlap — töltsd újra az oldalt.",
"api.share.too_many_attempts": "Túl sok sikertelen próbálkozás, próbáld újra 1 perc múlva", "api.share.too_many_attempts": "Túl sok sikertelen próbálkozás, próbáld újra 1 perc múlva",
"api.export.upload_size_mismatch": "A feltöltött méret nem egyezik — próbáld újra.", "api.export.upload_size_mismatch": "A feltöltött méret nem egyezik — próbáld újra.",
+62 -5
View File
@@ -32,7 +32,8 @@ type escrowAgent interface {
EscrowPreflight(ctx context.Context) (agentapi.EscrowPreflightResponse, error) EscrowPreflight(ctx context.Context) (agentapi.EscrowPreflightResponse, error)
EscrowCeremonyStart(ctx context.Context) (agentapi.EscrowCeremonyStartResponse, int, error) EscrowCeremonyStart(ctx context.Context) (agentapi.EscrowCeremonyStartResponse, int, error)
EscrowCeremonyStatus(ctx context.Context) (agentapi.EscrowCeremonyStatusResponse, error) EscrowCeremonyStatus(ctx context.Context) (agentapi.EscrowCeremonyStatusResponse, error)
EscrowCeremonyClaim(ctx context.Context) (string, int, error) EscrowCeremonyClaim(ctx context.Context) (agentapi.EscrowClaim, int, error)
EscrowCeremonyConfirm(ctx context.Context, words []string) (int, error)
AgentVersion() string AgentVersion() string
} }
@@ -102,6 +103,8 @@ func (s *Server) ServeEscrowAPI(w http.ResponseWriter, r *http.Request) {
s.escrowStatusAPIHandler(w, r) s.escrowStatusAPIHandler(w, r)
case r.URL.Path == "/api/escrow/claim" && r.Method == http.MethodPost: case r.URL.Path == "/api/escrow/claim" && r.Method == http.MethodPost:
s.escrowClaimAPIHandler(w, r) s.escrowClaimAPIHandler(w, r)
case r.URL.Path == "/api/escrow/confirm" && r.Method == http.MethodPost:
s.escrowConfirmAPIHandler(w, r)
default: default:
escrowJSON(w, http.StatusNotFound, nil, "ismeretlen végpont") escrowJSON(w, http.StatusNotFound, nil, "ismeretlen végpont")
} }
@@ -275,7 +278,8 @@ func (s *Server) escrowClaimAPIHandler(w http.ResponseWriter, r *http.Request) {
escrowJSON(w, http.StatusBadGateway, nil, "Az ügynök nem elérhető.") escrowJSON(w, http.StatusBadGateway, nil, "Az ügynök nem elérhető.")
return return
} }
code, status, err := agent.EscrowCeremonyClaim(r.Context()) claim, status, err := agent.EscrowCeremonyClaim(r.Context())
code := claim.Code
if err != nil { if err != nil {
switch status { switch status {
case http.StatusGone: // 410 — the code was minted but never shown; permanently void. case http.StatusGone: // 410 — the code was minted but never shown; permanently void.
@@ -293,7 +297,63 @@ func (s *Server) escrowClaimAPIHandler(w http.ResponseWriter, r *http.Request) {
} }
return return
} }
if claim.ConfirmRequired {
// R-1151: the agent keeps the new package until the household types the two words back (/api/escrow/confirm);
// the ceremony is not complete yet, so nothing is stamped and no report is pushed here.
s.logger.Printf("[INFO] [web] escrow recovery code claimed (one-shot; not logged) — its package waits for the typed-back words")
escrowJSON(w, http.StatusOK, map[string]any{"recovery_code": code, "confirm_required": true, "verify_positions": claim.Positions}, "")
code, claim = "", agentapi.EscrowClaim{}
_, _ = code, claim
return
}
s.logger.Printf("[INFO] [web] escrow recovery code claimed (one-shot; not logged)") s.logger.Printf("[INFO] [web] escrow recovery code claimed (one-shot; not logged)")
s.escrowCompleted()
escrowJSON(w, http.StatusOK, map[string]any{"recovery_code": code}, "")
code = "" // drop the reference promptly (GC caveat: best-effort)
_ = code
}
// escrowConfirmAPIHandler (R-1151) takes the two words the household typed back and hands them to the agent, which
// stores the new package on the hub only when they match. Body: JSON {"words": [w1, w2]}. The words are part of the
// code: never logged. On success the ceremony is complete (escrowCompleted).
func (s *Server) escrowConfirmAPIHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
var req struct {
Words []string `json:"words"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil || len(req.Words) != 2 {
escrowJSON(w, http.StatusBadRequest, nil, s.msg(r, "api.escrow.confirm_words_mismatch"))
return
}
agent, err := s.escrowAgentConn()
if err != nil {
escrowJSON(w, http.StatusBadGateway, nil, "Az ügynök nem elérhető.")
return
}
status, err := agent.EscrowCeremonyConfirm(r.Context(), req.Words)
req.Words = nil
if err != nil {
switch status {
case http.StatusUnprocessableEntity, http.StatusBadRequest: // the words do not match — the wait goes on
escrowJSON(w, http.StatusUnprocessableEntity, nil, s.msg(r, "api.escrow.confirm_words_mismatch"))
case http.StatusGone:
escrowJSON(w, http.StatusGone, map[string]any{"restart": true}, s.msg(r, "api.escrow.confirm_expired"))
case http.StatusNotFound:
escrowJSON(w, http.StatusGone, map[string]any{"restart": true}, s.msg(r, "api.escrow.confirm_lost"))
default:
s.logger.Printf("[WARN] [web] escrow confirm failed (status %d)", status)
escrowJSON(w, http.StatusBadGateway, nil, s.msg(r, "api.escrow.confirm_store_failed"))
}
return
}
s.logger.Printf("[INFO] [web] escrow recovery code typed back — the new package is on the hub (R-1151)")
s.escrowCompleted()
escrowJSON(w, http.StatusOK, map[string]any{"uploaded": true}, "")
}
// escrowCompleted runs when the new package is on the hub: at the claim for an agent older than 0.160.0, at the
// typed-back confirm since (R-1151).
func (s *Server) escrowCompleted() {
// v0.138.0: stamp the ceremony-completed time so /backups/remote shows the "awaiting hub // v0.138.0: stamp the ceremony-completed time so /backups/remote shows the "awaiting hub
// confirmation" card during the report-cycle gap before the auto-confirmer flips to escrowed // confirmation" card during the report-cycle gap before the auto-confirmer flips to escrowed
// (Phase-0 verdict A: the yellow "szükséges" card during that ~15-min wait was the real gap). // (Phase-0 verdict A: the yellow "szükséges" card during that ~15-min wait was the real gap).
@@ -310,9 +370,6 @@ func (s *Server) escrowClaimAPIHandler(w http.ResponseWriter, r *http.Request) {
// hub's ACK hash-match flip pending→escrowed in seconds (EscrowAutoConfirmer, unchanged) // hub's ACK hash-match flip pending→escrowed in seconds (EscrowAutoConfirmer, unchanged)
// instead of after the next ~15-min cycle. Non-blocking; a failed push degrades to the cycle. // instead of after the next ~15-min cycle. Non-blocking; a failed push degrades to the cycle.
s.reportTriggerNow() s.reportTriggerNow()
escrowJSON(w, http.StatusOK, map[string]any{"recovery_code": code}, "")
code = "" // drop the reference promptly (GC caveat: best-effort)
_ = code
} }
// clearAuthFailures clears the login counter after a right re-auth password — the counter reserveLoginAttempt counts // clearAuthFailures clears the login counter after a right re-auth password — the counter reserveLoginAttempt counts
+14 -3
View File
@@ -35,7 +35,13 @@ type fakeEscrowAgent struct {
claimCode string claimCode string
claimStatus int claimStatus int
claimErr error claimErr error
pf agentapi.EscrowPreflightResponse // R-1151: an agent ≥ 0.160.0 answers the claim with a confirm request; confirmStatus/confirmErr answer the confirm.
claimConfirm bool
claimPos []int
confirmWords []string
confirmStatus int
confirmErr error
pf agentapi.EscrowPreflightResponse
} }
func (f *fakeEscrowAgent) EscrowPreflight(context.Context) (agentapi.EscrowPreflightResponse, error) { func (f *fakeEscrowAgent) EscrowPreflight(context.Context) (agentapi.EscrowPreflightResponse, error) {
@@ -49,9 +55,14 @@ func (f *fakeEscrowAgent) EscrowCeremonyStart(context.Context) (agentapi.EscrowC
func (f *fakeEscrowAgent) EscrowCeremonyStatus(context.Context) (agentapi.EscrowCeremonyStatusResponse, error) { func (f *fakeEscrowAgent) EscrowCeremonyStatus(context.Context) (agentapi.EscrowCeremonyStatusResponse, error) {
return f.status, nil return f.status, nil
} }
func (f *fakeEscrowAgent) EscrowCeremonyClaim(context.Context) (string, int, error) { func (f *fakeEscrowAgent) EscrowCeremonyClaim(context.Context) (agentapi.EscrowClaim, int, error) {
*f.order = append(*f.order, "claim") *f.order = append(*f.order, "claim")
return f.claimCode, f.claimStatus, f.claimErr return agentapi.EscrowClaim{Code: f.claimCode, ConfirmRequired: f.claimConfirm, Positions: f.claimPos}, f.claimStatus, f.claimErr
}
func (f *fakeEscrowAgent) EscrowCeremonyConfirm(_ context.Context, words []string) (int, error) {
*f.order = append(*f.order, "confirm")
f.confirmWords = append([]string(nil), words...)
return f.confirmStatus, f.confirmErr
} }
func (f *fakeEscrowAgent) AgentVersion() string { return f.version } func (f *fakeEscrowAgent) AgentVersion() string { return f.version }
@@ -0,0 +1,123 @@
package web
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// R-1151 — the wizard's typed-back words reach the agent, which stores the new package only when they match. Until
// then the ceremony is NOT complete: no „completed" stamp, no report push — the hub still holds the previous package.
func postConfirm(t *testing.T, s *Server, lang, body string) *httptest.ResponseRecorder {
t.Helper()
r := httptest.NewRequest("POST", "/api/escrow/confirm?lang="+lang, strings.NewReader(body))
r.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
s.escrowConfirmAPIHandler(w, r)
return w
}
func ceremonyStamped(t *testing.T, h *escrowWizardHarness) bool {
t.Helper()
o := h.sett.GetOffboxTarget()
return o != nil && o.CeremonyCompletedAt != ""
}
// COMPANION RED-PROOF (observed 2026-10-19): drop the `if claim.ConfirmRequired { … return }` block in
// escrowClaimAPIHandler → this fails with `claim: 200 {"data":{"recovery_code":"egy-ketto-harom-negy"},…}` — no
// confirm request reaches the page, and the claim stamps the ceremony completed. Restored.
func TestR1151_ClaimThatWaitsIsNotCompleteUntilTheWordsComeBack(t *testing.T) {
h := newEscrowWizardHarness(t)
h.configureOffbox(t, "pending")
h.agent.claimCode, h.agent.claimStatus = "egy-ketto-harom-negy", http.StatusOK
h.agent.claimConfirm, h.agent.claimPos = true, []int{2, 4}
w := httptest.NewRecorder()
h.s.escrowClaimAPIHandler(w, httptest.NewRequest("POST", "/api/escrow/claim", nil))
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"confirm_required":true`) || !strings.Contains(w.Body.String(), `"verify_positions":[2,4]`) {
t.Fatalf("claim: %d %s", w.Code, w.Body.String())
}
if ceremonyStamped(t, h) {
t.Fatal("a claim that waits for the typed-back words stamped the ceremony completed")
}
h.agent.confirmStatus = http.StatusOK
if w := postConfirm(t, h.s, "hu", `{"words":["ketto","negy"]}`); w.Code != http.StatusOK {
t.Fatalf("confirm: %d %s", w.Code, w.Body.String())
}
if got := strings.Join(h.agent.confirmWords, ","); got != "ketto,negy" {
t.Fatalf("the agent got the words %q, want ketto,negy", got)
}
if !ceremonyStamped(t, h) {
t.Fatal("the confirmed ceremony was not stamped completed")
}
if c := w.Header().Get("Cache-Control"); c != "no-store" {
t.Fatalf("confirm Cache-Control = %q", c)
}
}
// An agent older than 0.160.0 answers the claim without a confirm request: its package is on the hub, and the claim
// completes the ceremony as before.
func TestR1151_OldAgentClaimCompletesAtOnce(t *testing.T) {
h := newEscrowWizardHarness(t)
h.configureOffbox(t, "pending")
h.agent.claimCode, h.agent.claimStatus = "egy-ketto-harom-negy", http.StatusOK
w := httptest.NewRecorder()
h.s.escrowClaimAPIHandler(w, httptest.NewRequest("POST", "/api/escrow/claim", nil))
if w.Code != http.StatusOK || strings.Contains(w.Body.String(), "confirm_required") {
t.Fatalf("claim on an old agent: %d %s", w.Code, w.Body.String())
}
if !ceremonyStamped(t, h) {
t.Fatal("an old agent's claim must complete the ceremony")
}
for _, o := range h.order {
if o == "confirm" {
t.Fatal("a confirm was sent to an old agent")
}
}
}
// Every refusal of the confirm answers in the box's language, says the previous code still works where it does,
// and tells the page to start again only when the new code is void.
func TestR1151_ConfirmAnswersInTheBoxLanguage(t *testing.T) {
cases := []struct {
agent int
want int
restart bool
hu, en string
}{
{http.StatusUnprocessableEntity, http.StatusUnprocessableEntity, false, "A megadott szavak nem egyeznek", "The words do not match"},
{http.StatusGone, http.StatusGone, true, "Lejárt az idő", "has run out"},
{http.StatusNotFound, http.StatusGone, true, "már nem vár megerősítésre", "no longer waits"},
{http.StatusBadGateway, http.StatusBadGateway, false, "nem sikerült elmenteni", "could not be saved"},
{0, http.StatusBadGateway, false, "nem sikerült elmenteni", "could not be saved"},
}
for _, c := range cases {
for _, lang := range []string{"hu", "en"} {
h := newEscrowWizardHarness(t)
h.agent.confirmStatus, h.agent.confirmErr = c.agent, fmt.Errorf("agent said %d", c.agent)
w := postConfirm(t, h.s, lang, `{"words":["a","b"]}`)
want := map[string]string{"hu": c.hu, "en": c.en}[lang]
if w.Code != c.want || !strings.Contains(w.Body.String(), want) {
t.Errorf("agent %d, %s: got %d %s — want %d with %q", c.agent, lang, w.Code, w.Body.String(), c.want, want)
}
if got := strings.Contains(w.Body.String(), `"restart":true`); got != c.restart {
t.Errorf("agent %d: restart=%v, want %v", c.agent, got, c.restart)
}
if ceremonyStamped(t, h) {
t.Errorf("agent %d: a refused confirm stamped the ceremony", c.agent)
}
}
}
// A body without two words never reaches the agent.
h := newEscrowWizardHarness(t)
if w := postConfirm(t, h.s, "hu", `{"words":["a"]}`); w.Code != http.StatusBadRequest {
t.Fatalf("one word: got %d", w.Code)
}
if len(h.order) != 0 {
t.Fatalf("a malformed confirm reached the agent: %v", h.order)
}
}
@@ -98,6 +98,7 @@
<div id="verify-error" class="alert alert-error" style="display:none;margin-top:.5rem">{{T "backups_escrow.a_megadott_szavak_nem_egyeznek"}}</div> <div id="verify-error" class="alert alert-error" style="display:none;margin-top:.5rem">{{T "backups_escrow.a_megadott_szavak_nem_egyeznek"}}</div>
</div> </div>
<button class="btn btn-sm" id="verify-btn" onclick="verifyWords()">{{T "backups_escrow.szavak_ellenorzese"}}</button> <button class="btn btn-sm" id="verify-btn" onclick="verifyWords()">{{T "backups_escrow.szavak_ellenorzese"}}</button>
<a href="/backup/escrow" class="btn btn-sm btn-primary" id="verify-restart" style="display:none">{{T "backups_escrow.ujrainditas"}}</a>
<div id="finish-block" style="display:none;margin-top:1rem"> <div id="finish-block" style="display:none;margin-top:1rem">
<h3>{{T "backups_escrow.7_befejezes"}}</h3> <h3>{{T "backups_escrow.7_befejezes"}}</h3>
{{/* v0.138.0: the "what happens next" note — the wizard finishes to /backups/remote, {{/* v0.138.0: the "what happens next" note — the wizard finishes to /backups/remote,
@@ -239,6 +240,9 @@ function pollStatus(){
// finishWizard() clears both. It never leaves the page's JS scope. // finishWizard() clears both. It never leaves the page's JS scope.
var claimedR=''; var claimedR='';
var verifyIdx=[0,0]; var verifyIdx=[0,0];
// R-1151: an agent ≥ 0.160.0 keeps the new package until the two words come back through /api/escrow/confirm, and
// names the two positions itself; an older agent stored it at the claim, and the page picks the positions.
var confirmRequired=false;
async function claimCode(){ async function claimCode(){
var btn=document.getElementById('reveal-btn'); btn.disabled=true; var btn=document.getElementById('reveal-btn'); btn.disabled=true;
@@ -253,9 +257,13 @@ async function claimCode(){
// distinct 1-based word positions of the dash-separated code. // distinct 1-based word positions of the dash-separated code.
var words=claimedR.split('-'); var words=claimedR.split('-');
var n=words.length; var n=words.length;
var a=1+Math.floor(Math.random()*n), b=1+Math.floor(Math.random()*n); confirmRequired=!!j.data.confirm_required;
while(b===a){ b=1+Math.floor(Math.random()*n); } if(confirmRequired){ verifyIdx=[j.data.verify_positions[0], j.data.verify_positions[1]]; }
verifyIdx=[Math.min(a,b),Math.max(a,b)]; else {
var a=1+Math.floor(Math.random()*n), b=1+Math.floor(Math.random()*n);
while(b===a){ b=1+Math.floor(Math.random()*n); }
verifyIdx=[Math.min(a,b),Math.max(a,b)];
}
// Span-per-word DOM build — createElement + textContent + createTextNode ONLY. R never flows // Span-per-word DOM build — createElement + textContent + createTextNode ONLY. R never flows
// through innerHTML/insertAdjacentHTML: with no HTML parse context there is no injection // through innerHTML/insertAdjacentHTML: with no HTML parse context there is no injection
// surface, the same guarantee the old single textContent assignment gave. The two words the // surface, the same guarantee the old single textContent assignment gave. The two words the
@@ -288,13 +296,31 @@ function setCodeBlur(on){
} }
function toggleCodeBlur(){ setCodeBlur(!codeBlurred); } function toggleCodeBlur(){ setCodeBlur(!codeBlurred); }
function verifyWords(){ async function verifyWords(){
var words=claimedR.split('-'); var words=claimedR.split('-');
var w1=(document.getElementById('verify-w1').value||'').trim().toLowerCase(); var w1=(document.getElementById('verify-w1').value||'').trim().toLowerCase();
var w2=(document.getElementById('verify-w2').value||'').trim().toLowerCase(); var w2=(document.getElementById('verify-w2').value||'').trim().toLowerCase();
var ok = w1===words[verifyIdx[0]-1] && w2===words[verifyIdx[1]-1]; var ok = w1===words[verifyIdx[0]-1] && w2===words[verifyIdx[1]-1];
document.getElementById('verify-error').style.display=ok?'none':'block'; var errEl=document.getElementById('verify-error');
if(ok){ document.getElementById('finish-block').style.display='block'; } errEl.textContent='{{T "backups_escrow.a_megadott_szavak_nem_egyeznek"}}';
errEl.style.display=ok?'none':'block';
if(!ok){ return; }
if(confirmRequired){
var btn=document.getElementById('verify-btn'); btn.disabled=true;
try{
var r=await fetch('/api/escrow/confirm',{method:'POST',
headers:Object.assign({'Content-Type':'application/json'},csrfHeaders()),body:JSON.stringify({words:[w1,w2]})});
var j=await r.json();
if(!j.ok){
errEl.textContent=j.error||'{{T "backups_escrow.a_megadott_szavak_nem_egyeznek"}}'; errEl.style.display='block';
if(j.data && j.data.restart){ // the new code is void; the previous one still works — start again
claimedR=''; document.getElementById('code-block').textContent='';
btn.style.display='none'; document.getElementById('verify-restart').style.display='inline-block'; return; }
btn.disabled=false; return;
}
}catch(e){ errEl.textContent='{{T "backups_escrow.hiba_2"}} '+e.message; errEl.style.display='block'; btn.disabled=false; return; }
}
document.getElementById('finish-block').style.display='block';
} }
function finishWizard(){ function finishWizard(){
@@ -244,6 +244,7 @@
<div id="verify-error" class="alert alert-error" style="display:none;margin-top:.5rem">A megadott szavak nem egyeznek — ellenőrizd a felírt kódot.</div> <div id="verify-error" class="alert alert-error" style="display:none;margin-top:.5rem">A megadott szavak nem egyeznek — ellenőrizd a felírt kódot.</div>
</div> </div>
<button class="btn btn-sm" id="verify-btn" onclick="verifyWords()">Szavak ellenőrzése</button> <button class="btn btn-sm" id="verify-btn" onclick="verifyWords()">Szavak ellenőrzése</button>
<a href="/backup/escrow" class="btn btn-sm btn-primary" id="verify-restart" style="display:none">Újraindítás</a>
<div id="finish-block" style="display:none;margin-top:1rem"> <div id="finish-block" style="display:none;margin-top:1rem">
<h3>7. Befejezés</h3> <h3>7. Befejezés</h3>
@@ -385,6 +386,9 @@ function pollStatus(){
var claimedR=''; var claimedR='';
var verifyIdx=[0,0]; var verifyIdx=[0,0];
var confirmRequired=false;
async function claimCode(){ async function claimCode(){
var btn=document.getElementById('reveal-btn'); btn.disabled=true; var btn=document.getElementById('reveal-btn'); btn.disabled=true;
var errEl=document.getElementById('reveal-error'); errEl.style.display='none'; var errEl=document.getElementById('reveal-error'); errEl.style.display='none';
@@ -398,9 +402,13 @@ async function claimCode(){
var words=claimedR.split('-'); var words=claimedR.split('-');
var n=words.length; var n=words.length;
var a=1+Math.floor(Math.random()*n), b=1+Math.floor(Math.random()*n); confirmRequired=!!j.data.confirm_required;
while(b===a){ b=1+Math.floor(Math.random()*n); } if(confirmRequired){ verifyIdx=[j.data.verify_positions[0], j.data.verify_positions[1]]; }
verifyIdx=[Math.min(a,b),Math.max(a,b)]; else {
var a=1+Math.floor(Math.random()*n), b=1+Math.floor(Math.random()*n);
while(b===a){ b=1+Math.floor(Math.random()*n); }
verifyIdx=[Math.min(a,b),Math.max(a,b)];
}
@@ -433,13 +441,31 @@ function setCodeBlur(on){
} }
function toggleCodeBlur(){ setCodeBlur(!codeBlurred); } function toggleCodeBlur(){ setCodeBlur(!codeBlurred); }
function verifyWords(){ async function verifyWords(){
var words=claimedR.split('-'); var words=claimedR.split('-');
var w1=(document.getElementById('verify-w1').value||'').trim().toLowerCase(); var w1=(document.getElementById('verify-w1').value||'').trim().toLowerCase();
var w2=(document.getElementById('verify-w2').value||'').trim().toLowerCase(); var w2=(document.getElementById('verify-w2').value||'').trim().toLowerCase();
var ok = w1===words[verifyIdx[0]-1] && w2===words[verifyIdx[1]-1]; var ok = w1===words[verifyIdx[0]-1] && w2===words[verifyIdx[1]-1];
document.getElementById('verify-error').style.display=ok?'none':'block'; var errEl=document.getElementById('verify-error');
if(ok){ document.getElementById('finish-block').style.display='block'; } errEl.textContent='A megadott szavak nem egyeznek — ellenőrizd a felírt kódot.';
errEl.style.display=ok?'none':'block';
if(!ok){ return; }
if(confirmRequired){
var btn=document.getElementById('verify-btn'); btn.disabled=true;
try{
var r=await fetch('/api/escrow/confirm',{method:'POST',
headers:Object.assign({'Content-Type':'application/json'},csrfHeaders()),body:JSON.stringify({words:[w1,w2]})});
var j=await r.json();
if(!j.ok){
errEl.textContent=j.error||'A megadott szavak nem egyeznek — ellenőrizd a felírt kódot.'; errEl.style.display='block';
if(j.data && j.data.restart){
claimedR=''; document.getElementById('code-block').textContent='';
btn.style.display='none'; document.getElementById('verify-restart').style.display='inline-block'; return; }
btn.disabled=false; return;
}
}catch(e){ errEl.textContent='Hiba: '+e.message; errEl.style.display='block'; btn.disabled=false; return; }
}
document.getElementById('finish-block').style.display='block';
} }
function finishWizard(){ function finishWizard(){
@@ -249,6 +249,7 @@
<div id="verify-error" class="alert alert-error" style="display:none;margin-top:.5rem">A megadott szavak nem egyeznek — ellenőrizd a felírt kódot.</div> <div id="verify-error" class="alert alert-error" style="display:none;margin-top:.5rem">A megadott szavak nem egyeznek — ellenőrizd a felírt kódot.</div>
</div> </div>
<button class="btn btn-sm" id="verify-btn" onclick="verifyWords()">Szavak ellenőrzése</button> <button class="btn btn-sm" id="verify-btn" onclick="verifyWords()">Szavak ellenőrzése</button>
<a href="/backup/escrow" class="btn btn-sm btn-primary" id="verify-restart" style="display:none">Újraindítás</a>
<div id="finish-block" style="display:none;margin-top:1rem"> <div id="finish-block" style="display:none;margin-top:1rem">
<h3>7. Befejezés</h3> <h3>7. Befejezés</h3>
@@ -390,6 +391,9 @@ function pollStatus(){
var claimedR=''; var claimedR='';
var verifyIdx=[0,0]; var verifyIdx=[0,0];
var confirmRequired=false;
async function claimCode(){ async function claimCode(){
var btn=document.getElementById('reveal-btn'); btn.disabled=true; var btn=document.getElementById('reveal-btn'); btn.disabled=true;
var errEl=document.getElementById('reveal-error'); errEl.style.display='none'; var errEl=document.getElementById('reveal-error'); errEl.style.display='none';
@@ -403,9 +407,13 @@ async function claimCode(){
var words=claimedR.split('-'); var words=claimedR.split('-');
var n=words.length; var n=words.length;
var a=1+Math.floor(Math.random()*n), b=1+Math.floor(Math.random()*n); confirmRequired=!!j.data.confirm_required;
while(b===a){ b=1+Math.floor(Math.random()*n); } if(confirmRequired){ verifyIdx=[j.data.verify_positions[0], j.data.verify_positions[1]]; }
verifyIdx=[Math.min(a,b),Math.max(a,b)]; else {
var a=1+Math.floor(Math.random()*n), b=1+Math.floor(Math.random()*n);
while(b===a){ b=1+Math.floor(Math.random()*n); }
verifyIdx=[Math.min(a,b),Math.max(a,b)];
}
@@ -438,13 +446,31 @@ function setCodeBlur(on){
} }
function toggleCodeBlur(){ setCodeBlur(!codeBlurred); } function toggleCodeBlur(){ setCodeBlur(!codeBlurred); }
function verifyWords(){ async function verifyWords(){
var words=claimedR.split('-'); var words=claimedR.split('-');
var w1=(document.getElementById('verify-w1').value||'').trim().toLowerCase(); var w1=(document.getElementById('verify-w1').value||'').trim().toLowerCase();
var w2=(document.getElementById('verify-w2').value||'').trim().toLowerCase(); var w2=(document.getElementById('verify-w2').value||'').trim().toLowerCase();
var ok = w1===words[verifyIdx[0]-1] && w2===words[verifyIdx[1]-1]; var ok = w1===words[verifyIdx[0]-1] && w2===words[verifyIdx[1]-1];
document.getElementById('verify-error').style.display=ok?'none':'block'; var errEl=document.getElementById('verify-error');
if(ok){ document.getElementById('finish-block').style.display='block'; } errEl.textContent='A megadott szavak nem egyeznek — ellenőrizd a felírt kódot.';
errEl.style.display=ok?'none':'block';
if(!ok){ return; }
if(confirmRequired){
var btn=document.getElementById('verify-btn'); btn.disabled=true;
try{
var r=await fetch('/api/escrow/confirm',{method:'POST',
headers:Object.assign({'Content-Type':'application/json'},csrfHeaders()),body:JSON.stringify({words:[w1,w2]})});
var j=await r.json();
if(!j.ok){
errEl.textContent=j.error||'A megadott szavak nem egyeznek — ellenőrizd a felírt kódot.'; errEl.style.display='block';
if(j.data && j.data.restart){
claimedR=''; document.getElementById('code-block').textContent='';
btn.style.display='none'; document.getElementById('verify-restart').style.display='inline-block'; return; }
btn.disabled=false; return;
}
}catch(e){ errEl.textContent='Hiba: '+e.message; errEl.style.display='block'; btn.disabled=false; return; }
}
document.getElementById('finish-block').style.display='block';
} }
function finishWizard(){ function finishWizard(){
+4
View File
@@ -1,6 +1,10 @@
{ {
"_comment": "Localisation slice 2 (R-557). key -> the base-commit Go literal it replaced, or the ORDERED list of literals a concatenation joined. Checked by scripts/i18n_go_parity.py against scripts/i18n_go_base.json, which is frozen at 736f54b49610 (the base commit of release v0.252.0). A key whose Hungarian text is not byte-identical to what the Go code said fails the gate.", "_comment": "Localisation slice 2 (R-557). key -> the base-commit Go literal it replaced, or the ORDERED list of literals a concatenation joined. Checked by scripts/i18n_go_parity.py against scripts/i18n_go_base.json, which is frozen at 736f54b49610 (the base commit of release v0.252.0). A key whose Hungarian text is not byte-identical to what the Go code said fails the gate.",
"_preexisting": { "_preexisting": {
"api.escrow.confirm_words_mismatch": "BORN AS A KEY (R-1151, 2026-10-19) -- the confirm API's answer to words that do not match; the same Hungarian as the page's own backups_escrow.a_megadott_szavak_nem_egyeznek (a template key, which Go may not name). Pinned by TestR1151_ConfirmAnswersInTheBoxLanguage.",
"api.escrow.confirm_expired": "BORN AS A KEY (R-1151, 2026-10-19) -- a NEW sentence, never a Go literal: the wizard's typed-back confirm answer (agent >= 0.160.0 keeps the new package until the words come back). Pinned by TestR1151_ConfirmAnswersInTheBoxLanguage.",
"api.escrow.confirm_lost": "BORN AS A KEY (R-1151, 2026-10-19) -- a NEW sentence, never a Go literal: the wizard's typed-back confirm answer (agent >= 0.160.0 keeps the new package until the words come back). Pinned by TestR1151_ConfirmAnswersInTheBoxLanguage.",
"api.escrow.confirm_store_failed": "BORN AS A KEY (R-1151, 2026-10-19) -- a NEW sentence, never a Go literal: the wizard's typed-back confirm answer (agent >= 0.160.0 keeps the new package until the words come back). Pinned by TestR1151_ConfirmAnswersInTheBoxLanguage.",
"waiting.title": "BORN AS A KEY (R-554, 2026-10-12) -- the title of the page an unconfigured box shows while it retries its configuration, replacing the deleted setup wizard. Pinned by internal/bootstrap TestWaitForConfig_ServesWaitingPageThenStarts.", "waiting.title": "BORN AS A KEY (R-554, 2026-10-12) -- the title of the page an unconfigured box shows while it retries its configuration, replacing the deleted setup wizard. Pinned by internal/bootstrap TestWaitForConfig_ServesWaitingPageThenStarts.",
"waiting.body": "BORN AS A KEY (R-554, 2026-10-12) -- the body of that waiting page. Pinned by internal/bootstrap TestWaitForConfig_ServesWaitingPageThenStarts.", "waiting.body": "BORN AS A KEY (R-554, 2026-10-12) -- the body of that waiting page. Pinned by internal/bootstrap TestWaitForConfig_ServesWaitingPageThenStarts.",
"recovery_info.body": "BORN AS A KEY (R-554, 2026-10-12, `09` §3 decision 222) -- the NEW text of recovery-info.txt, pointing at the dashboard's Restore page and the recovery code instead of the deleted setup wizard. The operator approves the Hungarian before release. Pinned by internal/recovery TestRecoveryInfo_PointsAtDashboardNotWizard.", "recovery_info.body": "BORN AS A KEY (R-554, 2026-10-12, `09` §3 decision 222) -- the NEW text of recovery-info.txt, pointing at the dashboard's Restore page and the recovery code instead of the deleted setup wizard. The operator approves the Hungarian before release. Pinned by internal/recovery TestRecoveryInfo_PointsAtDashboardNotWizard.",