Skip to content

id(hosted_binding): expired challenge is recorded as consumed — a second presentation returns ChallengeReplay, and create_pairing_code's documented ChallengeReplay branch is unreachable #4868

Closed

Description

@dezren39

Problem

In pkgs/id/src/hosted_binding.rs (hosted account + ID binding ceremony, slice 1 of #3808), the handling of dead challenges (expired or consumed) contradicts the module's own §3.4 failure-semantics table in two places:

  1. An expired challenge is recorded as consumed on first presentation. The module header (hosted_binding.rs:43-44) says "expired challenges die without state", and CeremonyError::ChallengeExpired's own doc (hosted_binding.rs:415) says "State change: none; the challenge is dead." But the expiry branch of submit_proof (hosted_binding.rs:674-678) removes the challenge and inserts its ID into consumed_challenges. Observable consequence: presenting the same expired challenge a second time returns ChallengeReplay ("already presented") instead of ChallengeExpired — even though expiry was specified as creating no state. It also makes the consumed set retain IDs whose only sin was expiring.

  2. create_pairing_code's ChallengeReplay branch is unreachable. Its doc comment (hosted_binding.rs:807-812) promises ChallengeReplay for a dead (consumed) challenge. But the function first looks the challenge up in self.challenges and returns UnknownChallenge if absent (hosted_binding.rs:818-822), and only then checks consumed_challenges (hosted_binding.rs:823-825). Consumed challenges are always removed from self.challenges (consume(), hosted_binding.rs:769-772; the expiry branch above), so a consumed ID can never reach the replay check — the branch is dead code, and callers get UnknownChallenge for a challenge that was in fact consumed.

Evidence (verified this run)

Re-verified by new-issue-creator lane A at origin/main 2194bd4 (laptop worktree ~/git/fleet/probe-2026-11-03-laneA-hosted, since removed; temporary integration test pkgs/id/tests/probe_hosted_a.rs, log /tmp/probe-laneA-hosted.log on the laptop):

  • Expired challenge presented twice: first Err(ChallengeExpired), second Err(ChallengeReplay) — expiry did create state (the consumed entry), contradicting the "state change: none" specification.
  • Challenge consumed by a bad-signature submit (Err(BadSignature)), then create_pairing_code for it: Err(UnknownChallenge) — never the documented ChallengeReplay.
  • Control: create_pairing_code for a live challenge succeeds.

Code reading at the same commit confirms the mechanism for both: expiry inserts into consumed_challenges (hosted_binding.rs:676); create_pairing_code checks the live map (hosted_binding.rs:820) before the consumed set (hosted_binding.rs:824), and consume() (hosted_binding.rs:769-772) always removes from the live map first. Originally found by lane B (creator-b) at 550b611; its filing was blocked by a GitHub secondary rate limit, so lane A re-verified and filed it.

Why it matters

This module is the security ceremony that binds a Google identity to an Iroh root key. Its error taxonomy is the contract the §3.4 table specifies for clients and for the follow-up slices (rebind #3809, Worker API #3811): "expired" and "replayed" drive different client behaviour (plain retry vs. replay handling/logging). Today the two are conflated for expired challenges, one documented error can never be returned, and the consumed-ID set grows with expired IDs it was not specified to hold. The divergence is in brand-new code (landed today in #3808), so fixing the contract now is cheap; after slice 2/3 build on it, it is not.

Acceptance criteria

  • Presenting an expired challenge repeatedly yields the §3.4-specified result for expired challenges on every presentation (per the table, expiry records no consumed state), OR the §3.4 docs/header/ChallengeExpired doc are amended in the same change to specify the recorded-as-consumed behaviour — code and docs agree either way, with the chosen semantics stated on the issue/PR.
  • create_pairing_code for a consumed challenge returns the error its documentation promises (ChallengeReplay), i.e. the consumed check is reachable — regression test covers consumed, expired, unknown, and live challenges.
  • Regression tests in hosted_binding.rs: expired challenge presented twice; pairing-code creation against a challenge consumed by a failed submit.
  • Existing hosted_binding tests still pass (cargo test -p id --lib hosted_binding).

Found by new-issue-creator lane B (creator-b) during a scan of the newly landed #3808 module, re-verified and filed by lane A; not covered by #3808/#3809/#3811 as filed.

Activity

  1. added
    kind/bugSomething isn't working (prefixed form of the classic bug label)
    task/small-fixQueue packet kind: bounded fix with reproduction and targeted tests
    source/creatorFiled by the new-issue-creator job
    priority/normalOrdinary priority (the default)
    queue/queuedHas a queued task in the Supabase agent queue
    app/idPackage/app: id (pkgs/ or apps/ in this repo)
    on Nov 3, 2026
  2. dezren39 commented on Nov 3, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · filed

    • Agent: new-issue-creator job (lane A, slug new-issue-creator-code-a)
    • Session / ID: cron new-issue-creator-code-a run 2026-11-03T11:35Z
    • Found via: filed this run (originally found by creator lane B; verified by lane A in the prior run, filing was blocked then by a GitHub secondary rate limit)
    • Task: Fix the dead-challenge semantics in pkgs/id hosted_binding: expired challenges must not be recorded as consumed, and create_pairing_code's documented ChallengeReplay branch must be reachable.
    • Expectation: effort/s; done = code and the §3.4 docs agree, with regression tests for expired-presented-twice and pairing-code-after-consume.
    • Links: queue packet packet-4868-fix.json on repo-health-code-pilot; parent ceremony issue id: implement hosted account + ID binding ceremony (from #2046/#2852 slice 1) #3808
  3. dezren39 commented on Nov 3, 2026

    @dezren39
    MemberAuthor

    agent-note:v1
    state: reviewed (coverage loop-back)
    Coverage pass 2026-11-03, loop-back review: this issue was re-reviewed (drift / overlap / already-handled / stale / blockers / ordering) so every open issue in the four repos is reviewed and in the system. Verdict: still live; in the system as task 7527 (small_fix, queued, priority 50) in repo-health-code-pilot. Labels audited and synced. If it is already handled at current main, its triage closes it with evidence; if oversized, the triage files the split; blockers belong on this issue.
    Found via: coverage loop-back 2026-11-03

  4. dezren39 commented on Nov 3, 2026

    @dezren39
    MemberAuthor

    agent-note: design-architect pass 45 (2026-11-03) ADOPTED this issue as Phase 1 of design #5047 ("A name is claimed once — the hosted binding lifecycle contract"; design doc files/designs/code-id-hosted-binding-lifecycle.md). Linked as a sub-issue of #5047. Triage: do NOT re-file, re-scope or split — record the phase structure instead.

    Phase 1 scope addition from the design audit (same transition, second gate): verify_challenge_proof (hosted_binding.rs:1171-1205, the rebind paths' shared challenge gate) also consumes on expiry, and RebindError::ChallengeExpired's doc ("it is consumed and dead") contradicts CeremonyError's ("State change: none"). The recorded ruling covers both surfaces: expiry creates no state anywhere; the RebindError doc is amended in the same change. The docs-amended-the-other-way alternative in this issue's acceptance was considered and lost — replay is a security signal, expiry is not; see #5047.

  5. removed
    queue/queuedHas a queued task in the Supabase agent queue
    on Nov 4, 2026
  6. 4 remaining items

  7. added
    queue/parkedQueue task is parked (waiting on CI, time, or an event)
    status/parkedDeliberately paused with a recorded resume condition
    and removed
    queue/claimedQueue worker lifecycle state
    on Nov 4, 2026
  8. dezren39 commented on Nov 4, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · parked

  9. added
    queue/claimedQueue worker lifecycle state
    and removed
    status/parkedDeliberately paused with a recorded resume condition
    queue/parkedQueue task is parked (waiting on CI, time, or an event)
    on Nov 4, 2026
  10. dezren39 commented on Nov 4, 2026

    @dezren39
    MemberAuthor
  11. added a commit that references this issue on Nov 4, 2026
  12. dezren39 commented on Nov 4, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · done

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

app/idPackage/app: id (pkgs/ or apps/ in this repo)kind/bugSomething isn't working (prefixed form of the classic bug label)priority/normalOrdinary priority (the default)source/creatorFiled by the new-issue-creator jobsource/design-architectFiled by the repo design architect jobtask/small-fixQueue packet kind: bounded fix with reproduction and targeted tests

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions