feat(signing): one-shot signing protocol with durable state machine - #140
Merged
knytcomics-ui merged 1 commit intoAug 30, 2026
Merged
Conversation
Replaces the page-message/runtime-callback signing flow with a versioned protocol: the background now generates the sole authoritative request id (never trusting the page/bridge's own correlation token), persists pending-request identity/binding/deadline to chrome.storage.session before opening any UI, and binds every decision to its originating tab/frame/ document and to the popup's own window. Closes Gryd-lock#124. See docs/adr/0004-one-shot-signing-protocol.md for the full design. Claude-Session: https://claude.ai/code/session_01EpN7pg6WCWSV8iwYrjFJtB
14 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #124
Replaces the best-effort page-message/runtime-callback signing flow with a versioned, durable one-shot protocol. The background service worker now generates the sole authoritative request id (never the page/bridge's own correlation token), persists pending-request identity/binding/deadline to chrome.storage.session before opening any UI, and binds every decision to its originating tab/frame/document and to the popup's own window. Closes #124.
Builds directly on ADR-0003 (#132), which explicitly deferred this: "Browser interception retains its existing request/decision contract."
See docs/adr/0004-one-shot-signing-protocol.md for the full design and the reasoning behind two scope decisions (below).
Non-negotiable invariants
Required scope
A. Versioned protocol — src/intercept/protocol.ts: every runtime message carries protocolVersion; a mismatch gets a typed SIGN_REJECTED { reason: 'protocol-incompatible' }. xdrDigest, networkPassphrase, tab/frame/document identity, createdAt/deadlineAt, and state are all part of the persisted record (PendingRequestRecord).
B. Durable state machine — src/signing/pendingRequestState.ts (pure, mirrors the existing split in src/protection/protectionState.ts): received → validating → assessing → awaiting_review → {proceed | cancel | expired | failed}. Metadata is persisted before the popup opens. The bridge/worker resume handshake is AWAIT_OUTCOME (src/background/background.ts handleAwaitOutcome, src/intercept/awaitOutcome.ts): a two-phase SIGN_REQUEST → SIGN_ACK (closes the port immediately) followed by a retryable long-poll that always answers from durable state, because — as the issue says — callback functions cannot survive suspension.
C. Trusted review capability — the popup URL carries only requestId. See "Scope simplification" below for why this doesn't introduce a second token.
D. Failure and concurrency semantics — deadlines at all three layers (background 90s authoritative, bridge 100s fallback, page 120s fallback — PAGE_DEADLINE_MS/BRIDGE_FALLBACK_DEADLINE_MS/REVIEW_DEADLINE_MS), popup close/creation-failure handlers, tab close/navigation invalidation (extends the existing chrome.tabs.onUpdated/onRemoved listeners), per-frame/global admission limits.
E. Compatibility — protocol-version mismatch → typed failure, never silent. Missing sender.documentId → documentBound: false, falls back to tab/frame-only binding (still checked, not skipped).
Scope simplification: one identifier, not two
The issue's Required Scope C reads as if it wants a capability token separate from the request id. I used one id instead: the background-generated requestId already satisfies "opaque" (UUID), "never page-controlled" (page never sees it — see the invariant above), and is already bound to window/tab/frame/document/digest in the durable record. A second token would track the same state twice for no additional guarantee. Called out explicitly here and in ADR-0004 rather than left implicit — happy to add a distinct token if there's a scenario this doesn't cover.
Also fixes (asides — found while making the above work, not separately requested)
InterceptView silently defaulted to a "low risk" tier when review data failed to load. Before this change, if GET_REVIEW returned no review (which durability now makes more likely to happen legitimately — a worker restart can lose in-memory-only review content), the popup fell back to tierForScore(Number(params.get('score') ?? '0')) — i.e. a reassuring default, not a fail-closed one. This directly violates the threat model's own objective 7 ("malformed... transaction semantics should not be silently misrepresented as low risk"). InterceptView now shows an explicit "could not be loaded for review — reject and retry" state with only a Reject action when review data isn't available. src/popup/App.tsx.
chrome.windows.create was called without a callback, so a popup-creation failure was unobservable and the request would hang. Now wrapped in a promise with chrome.runtime.lastError handling.
DEFAULT_TIMEOUT_MS was defined but never referenced (called out explicitly in the issue's audit). It's now REVIEW_DEADLINE_MS, the real authoritative deadline.
Two verbatim-duplicated requestOutcome() implementations (Freighter's mainWorldEntry.ts and Albedo's albedoMainWorldEntry.ts) are now one shared, tested module
Non-negotiable invariants
A decision is bound to one transaction digest, tab, frame, document, adapter, and review window. xdrDigest is bound into the pending record at awaiting_review; tab/frame/document come from sender at SIGN_REQUEST time; the review window is bound to sender.tab.windowId on first legitimate popup contact and checked on every later message. src/background/background.ts (handleSignRequest, handleDecisionMade, handleGetReview), src/signing/pendingRequestState.ts.
Page-controlled identifiers are never authoritative capabilities. localId (the page/bridge correlation token) is generated in requestOutcome.ts, used only by the bridge to match its own response, and never forwarded past bridgeEntry.ts. SIGN_REQUEST's validator rejects a message that even includes a requestId field. The background generates requestId itself (crypto.randomUUID()) and that's the only id used for pending state or the popup URL.
The first valid terminal transition wins; replay is a no-op. applyTransition in pendingRequestState.ts treats a repeat of the same terminal state as an idempotent no-op and rejects any different terminal transition once settled. Tombstoned for TOMBSTONE_TTL_MS so a retried AWAIT_OUTCOME or duplicate DECISION_MADE resolves from cache.
Unrelated or stale senders cannot resolve a request. handleDecisionMade/handleGetReview check sender.tab.windowId against the bound review window; a mismatch is a silent no-op.
Worker restart cannot release a cancelled request or lose a completed decision. Terminal state is persisted to chrome.storage.session the moment it's reached; restorePendingRequests() reloads it on worker start. A tombstoned/terminal record answers AWAIT_OUTCOME immediately from storage regardless of which worker lifetime created it.
Popup close, creation failure, bridge loss, and timeout settle deterministically. chrome.windows.onRemoved → cancel. chrome.windows.create's callback (previously ignored — the original code called it with no callback at all) is now used to detect creation failure → failed. Bridge loss/timeout: layered deadlines below.
The released XDR is byte-for-byte identical to the reviewed XDR. Structurally unchanged and re-verified: mainWorldEntry.ts/albedoMainWorldEntry.ts never round-trip XDR through the background — they re-post their own captured request object verbatim on proceed. The background only ever returns a proceed/cancel vote, never a (possibly-substituted) XDR string.
Pending state, windows, timers, and tombstones are bounded and pruned. MAX_PENDING_PER_FRAME (5) / MAX_PENDING_GLOBAL (50) admission caps reject before a popup opens; TOMBSTONE_TTL_MS (120s) prunes settled records; pruneStalePendingRequests() runs on every touchpoint.
Required scope
A. Versioned protocol — src/intercept/protocol.ts: every runtime message carries protocolVersion; a mismatch gets a typed SIGN_REJECTED { reason: 'protocol-incompatible' }. xdrDigest, networkPassphrase, tab/frame/document identity, createdAt/deadlineAt, and state are all part of the persisted record (PendingRequestRecord).
B. Durable state machine — src/signing/pendingRequestState.ts (pure, mirrors the existing split in src/protection/protectionState.ts): received → validating → assessing → awaiting_review → {proceed | cancel | expired | failed}. Metadata is persisted before the popup opens. The bridge/worker resume handshake is AWAIT_OUTCOME (src/background/background.ts handleAwaitOutcome, src/intercept/awaitOutcome.ts): a two-phase SIGN_REQUEST → SIGN_ACK (closes the port immediately) followed by a retryable long-poll that always answers from durable state, because — as the issue says — callback functions cannot survive suspension.
C. Trusted review capability — the popup URL carries only requestId. See "Scope simplification" below for why this doesn't introduce a second token.
Required verification
Known gaps
Verifiable evidence
Required verification
Known gaps
Verifiable evidence
Tip: Use /btw to ask a quick side question without interrupting Claude's current work(src/intercept/requestOutcome.ts), which is also where the new page-side deadline lives (previously neither had one).
Acceptance criteria