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
gates / gates (push) Successful in 47s
This commit is contained in:
@@ -92,31 +92,63 @@ func (c *Client) EscrowCeremonyStatus(ctx context.Context) (EscrowCeremonyStatus
|
||||
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
|
||||
// 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
|
||||
// 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 {
|
||||
return "", status, err
|
||||
return EscrowClaim{}, status, err
|
||||
}
|
||||
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 {
|
||||
return "", status, err
|
||||
return EscrowClaim{}, status, err
|
||||
}
|
||||
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 {
|
||||
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 == "" {
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user