Skip to content

GO-7395 Approve local-link pairing before minting the code - #3225

Open
requilence wants to merge 1 commit into
developfrom
go-7395-link-approval
Open

GO-7395 Approve local-link pairing before minting the code#3225
requilence wants to merge 1 commit into
developfrom
go-7395-link-approval

Conversation

@requilence

Copy link
Copy Markdown
Contributor

Problem

Local-link pairing minted the 4-digit code the moment a challenge was requested and broadcast it to every session. So an unapproved request always had a secret to guess (~0.2% per run against 10⁴), and the per-run counters were the only DoS defence — exhausting them locked pairing for every client until restart.

Change: approve-then-mint

  • StartNewChallenge registers a pending request and mints nothing. The new LinkApprovalRequest event names the caller (process path, browser origin) and carries no code.
  • AccountLocalLinkApproveChallenge is the user's decision — the only place a code is minted, returned to the approving session alone, never on the event bus. Addressed by (processPath, origin) — the caller the prompt displayed; one pending prompt per caller keeps it unambiguous.
  • SolveChallenge gates on approved state; a solve against a pending challenge does not burn the failure budget (else a caller could lock pairing for everyone by solving its own unapproved challenge).
  • Denials are remembered for the app run; pending prompts expire after 60s, approved codes 5 min after approval (SweepExpired).

An unapproved request has no code associated with it, so there is nothing to brute-force before a human has named and accepted the caller.

Proto

LinkChallenge/LinkChallengeHide are replaced by LinkApprovalRequest/LinkApprovalHide — clean fields, no code, no needApprove (the event's arrival is the signal). A distinct name stops a client subscribing to the old code-carrying shape by accident. Field numbers reused (events aren't persisted).

Security

AccountLocalLinkApproveChallenge stays out of noAuthMethods and limitedScopeMethods, so authorization admits full scope alone (the desktop UI). It also rejects any caller carrying an Origin header. Both pinned by tests, so a future edit fails the build rather than the threat model.

Compatibility

  • The requesting client's API (/v1/auth/challenges, /v1/auth/api_keys) is unchanged — no SDK or extension needs updating.
  • Headless deployments use AccountLocalLinkCreateApp, which mints a key with no challenge, so no auto-approve backdoor is added.
  • ⚠️ Blocking: there is no mint-without-approval fallback, so nothing can pair until a desktop client implements the prompt + AccountLocalLinkApproveChallenge. heart and an approval-capable client must ship together.

Docs

  • docs/LocalLinkPairingApproval.md — design/spec
  • docs/LocalLinkApprovalDesktopGuide.md — client integration contract

Tests

New coverage: brute-force is impossible (all 10⁴ answers rejected while pending), approve mints/returns the code, deny is remembered, one-prompt-per-caller, no id sharing, TTL expiry (injected clock), auth-map regression, browser-origin rejection, per-caller vs run budgets. go build ./... clean; core/session, core/application, core, core/api/server, util/localorigin all pass.

Linear: GO-7395

Local-link pairing minted the 4-digit code the moment a challenge was
requested and broadcast it to every session, so an unapproved request
always had a secret to guess and the per-run counters were the only DoS
defence.

Rework the flow to approve-then-mint:

- StartNewChallenge registers a pending request and mints nothing. The
  new LinkApprovalRequest event names the caller (process path, browser
  origin) and carries no code.
- AccountLocalLinkApproveChallenge is the user's decision. It is the only
  place a code is minted and returns it to the approving session alone,
  never on the event bus. Addressed by (processPath, origin) — the caller
  the prompt displayed; one pending prompt per caller keeps it
  unambiguous.
- SolveChallenge gates on approved state; a solve against a pending
  challenge does not count against the failure budget.
- Denials are remembered for the app run; pending prompts expire after
  60s, approved codes 5 min after approval (SweepExpired).

The requesting client's API (/v1/auth/challenges, /v1/auth/api_keys) is
unchanged; only the desktop client gains the approval step. Headless
deployments use AccountLocalLinkCreateApp, which mints a key with no
challenge, so no auto-approve backdoor is added.

Security: AccountLocalLinkApproveChallenge stays out of noAuthMethods and
limitedScopeMethods so authorization admits full scope alone, and it
rejects any caller carrying an Origin header. Both pinned by tests.

Incorporates the origin-attribution groundwork it depends on:
ClientInfo.origin from the browser's Origin header, process resolution
for JSON API callers, and per-caller challenge budgets.

Docs: docs/LocalLinkPairingApproval.md (design),
docs/LocalLinkApprovalDesktopGuide.md (client integration).
requilence added a commit that referenced this pull request Sep 10, 2026
Merges the approve-then-mint pairing flow (branch go-7395-link-approval,
PR #3225) into the API-key grant work on this branch, and adds the consent
surface the grant was missing: the approval prompt collects a space grant
rather than a yes/no.

Records the decisions: requestedGrant on the challenge path becomes
requestedPerm (an external app cannot know a space id, and the field is
unusable today because ValidateAppLinkGrant rejects an empty space list);
ApproveChallenge carries the user's grant and heart persists it verbatim;
allSpaces is a dynamic flag covering spaces created later, never spelled as
an empty list; the tech space is excluded from it; the grant schema version
bumps so an older reader fails closed with a diagnosable error.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant