feat(wire): #198 · Dapp origin on the wire — attribute browser transactions to the requesting site#214
Conversation
…capability Additive per the #31 evolution rules: App/Agent keep their bare text-string frames (golden-pinned by hand-computed CBOR), the new variant frames as the externally-tagged {"Dapp":{"origin":…}} map, and an old decoder rejects the unknown tag loudly (the E3 valve, proven against a pre-#198 stand-in enum). Registered as capability origin.dapp (rule #2 — no spec_version bump) with its registry row in docs/build/40. Dropping Copy is the one non-additive Rust-level change; the wire is untouched.
…bution, never authorization The exact-approve admission (the ONE place origin steers routing) widens from App to App|Dapp so a browser dapp's ERC-20 approve raises the same clear-signing card an in-app one does — an unwidened match would have silently shunted dapp approves into the agent shaped-approve gate and denied them. Decision parity is pinned by a two-daemon test (identical vaults, same intents, App vs Dapp → byte-identical decisions across the auto-allow, over-cap, exact-approve, and approve-with-value routes), and pending_list/activity_feed carry the origin string back verbatim.
Both propose paths now send ProposalOrigin::Dapp with the session origin — eth_sendTransaction/wallet_sendCalls thread it through BridgeBackend, and personal_sign/eth_signTypedData_v4 reuse SignMessage.origin so the wire and payload attributions can never diverge. New session_origin() shape hygiene: the header value is attacker-supplied text bound for the review card, so anything that does not look like a web origin (http(s)://host, printable, no path, ≤255 bytes) collapses to the honest unknown-origin fallback — stops the literal-"You" masquerade while real origins render verbatim. This is input hygiene, not origin verification (#48, deferred).
… origin A Dapp-origin record renders the pre-built widgets::Origin::Dapp everywhere the wire origin is read: the shared Review's origin rail (via a new pure, unit- tested review_origin mapper — wire origin wins, the App+Message-payload fallback stays for pre-#198 records), the pending rail's request header (the neutral 'Awaiting your approval' stopgap now applies only to ambiguous App records), the feed-row subject and tx receipt (origin_subject, verbatim — unknown-origin shows as unknown-origin), and the settled labels + '→ You' human chain now key on third-party (agent OR dapp), so a dapp record never reads as your own first-person 'sent'.
Adversarial review → one security fix folded inA multi-agent find→verify review ran over the diff (4 lenses: wire-compat, policy-parity, render-trust, bridge-security; each finding independently verified against the real code). Codex was also queued as a cross-model reviewer but hit its API session limit before returning — the Claude review below stands in its place and its findings were verified, not just asserted. Two confirmed findings, same root cause, fixed before this comment:
Fix ( This is shape hygiene, not origin verification — a page can still claim another site's origin. Real attestation / anti-phishing is #48, explicitly out of scope here; Everything else the review probed held: wire frames are byte-additive (golden-pinned), App-vs-Dapp decisions are byte-identical (two-daemon parity test), and no render surface treats a Dapp record as human-amber or agent-cyan. |
Closes #198. Builds on #31's capability discovery + evolution rules (PR #212). Unblocks #199 (Connections) and #201 (browser demo recipe).
What
A dapp's
eth_sendTransactionused to render as "You are sending" — a false human attribution. The wire could not say otherwise:ProposalOriginwas onlyApp | Agent, and both bridge propose calls hardcodedApp. This PR brings transactions to parity with message signing:deckard-contract—ProposalOrigin::Dapp { origin: String }, additive per the five evolution rules:App/Agentframes are byte-unchanged (golden-pinned by hand-computed CBOR), the new variant is the externally-tagged{"Dapp":{"origin":…}}map, and an old decoder rejects the unknown tag loudly (E3 valve, proven against a pre-Dapp origin on the wire: attribute browser transactions to the requesting site #198 stand-in decoder on both CBOR and JSON). Registered as capabilityorigin.dapp(nospec_versionbump) with its registry row indocs/build/40-wire-evolution.md.Copyis dropped (String payload) — a Rust-level ripple only, the wire is untouched.deckard-browser-bridge— both propose paths sendProposalOrigin::Dappwith the session origin. Transactions thread it throughBridgeBackend::send_transaction(incl.wallet_sendCalls); messages reuseSignMessage.origin, so wire and payload attribution can never diverge. Newsession_origin()shape hygiene (from adversarial review, see below).deckard-signerd— the exact-approve admission (the one place origin steers routing) widensApp→App | Dapp, so a dapp's ERC-20 approve raises the same clear-signing human card an in-app one does. Everything else was already origin-blind; pending/activity records echo the origin back verbatim.deckard-app— the pre-builtwidgets::Origin::Dappis wired everywhere the wire origin is read: the shared Review's origin rail (new purereview_originmapper; wire origin wins, the App+Message-payload fallback stays for pre-Dapp origin on the wire: attribute browser transactions to the requesting site #198 records), the pending rail's request header, the feed-row subject and tx receipt (verbatim —unknown-originshows asunknown-origin), and the settled labels + "→ You approved" human chain now key on third-party (agent or dapp), so a dapp record never reads as your own first-person "sent".What did NOT change
Policy evaluation. Origin is attribution, never authorization. Proven by a two-daemon parity test (identical vaults, same intents): App vs Dapp yield byte-identical decisions across the auto-allow, over-cap, exact-approve, and approve-with-value routes.
evaluateitself never sees origin.Old peers stay safe. An old daemon receiving a
Dapp-tagged frame answersmalformed_requestand signs nothing. An old client reading a new daemon's pending/activity list containing one Dapp record rejects the whole frame — the valve working as designed (noted in the build doc).Adversarial review → the hygiene fix
A 4-lens find→verify review (2 confirmed findings) + codex cross-model pass caught that the origin header is attacker-supplied text bound for the trust surface:
x-deckard-origin: Youwould have rendered a dapp feed row/receipt literally as "You", styled like a genuine self-action. Fixed at the bridge boundary:session_origin()collapses anything that doesn't look like a web origin (http(s)://+ printable host, no path, ≤255 bytes) to the honestunknown-originfallback. Real origins render verbatim. This is input shape hygiene, not origin verification — a page can still claim another site's origin until #48 (attestation/anti-phishing, explicitly out of scope here);x-deckard-originremains the source because the extension's own POST carries the extension's origin, not the page's.Tests
Propose/ProposeMessage/records), golden CBOR pins for all three variants, old-decoder-rejects (E3 pattern), capability registry guard.session_originhygiene table (verbatim goods / collapsed crafteds).Origin::Dapp(verbatim, incl.unknown-origin), You/Agent unchanged, message fallback + wire precedence,origin_subject, third-party settled labels.Screenshots
Driven live against a local anvil (chain 31337) with the browser bridge: the same website-requested
eth_sendTransaction(0.5 ETH →0x7099…79C8, held for being over the daily cap), rendered by the same app+daemon build — only the bridge's origin on the wire differs.App)Dapp)Same app+daemon build, same 0.5 ETH tx held over the daily cap — only the bridge's origin on the wire differs. The feed row's subject also flips from "You" to the dapp origin (
https://app.exa… · → You waiting), so the two-actor chain reads right: the site asked, you are the approver.DoD evidence
cargo fmt --all --check— cleanjust check— exit 0 (default +--features tray)TMPDIR=/tmp cargo test --workspace— 441 passed, 0 failed, 2 ignoredCargo.toml/Cargo.lock)