Skip to content

feat(wire): #198 · Dapp origin on the wire — attribute browser transactions to the requesting site#214

Merged
hellno merged 4 commits into
mainfrom
hellno/dapp-origin-wire
Jul 8, 2026
Merged

feat(wire): #198 · Dapp origin on the wire — attribute browser transactions to the requesting site#214
hellno merged 4 commits into
mainfrom
hellno/dapp-origin-wire

Conversation

@hellno

@hellno hellno commented Jul 7, 2026

Copy link
Copy Markdown
Owner

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_sendTransaction used to render as "You are sending" — a false human attribution. The wire could not say otherwise: ProposalOrigin was only App | Agent, and both bridge propose calls hardcoded App. This PR brings transactions to parity with message signing:

  • deckard-contractProposalOrigin::Dapp { origin: String }, additive per the five evolution rules: App/Agent frames 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 capability origin.dapp (no spec_version bump) with its registry row in docs/build/40-wire-evolution.md. Copy is dropped (String payload) — a Rust-level ripple only, the wire is untouched.
  • deckard-browser-bridge — both propose paths send ProposalOrigin::Dapp with the session origin. Transactions thread it through BridgeBackend::send_transaction (incl. wallet_sendCalls); messages reuse SignMessage.origin, so wire and payload attribution can never diverge. New session_origin() shape hygiene (from adversarial review, see below).
  • deckard-signerd — the exact-approve admission (the one place origin steers routing) widens AppApp | 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-built widgets::Origin::Dapp is wired everywhere the wire origin is read: the shared Review's origin rail (new pure review_origin mapper; 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-origin shows as unknown-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. evaluate itself never sees origin.

Old peers stay safe. An old daemon receiving a Dapp-tagged frame answers malformed_request and 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: You would 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 honest unknown-origin fallback. 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-origin remains the source because the extension's own POST carries the extension's origin, not the page's.

Tests

  • Wire: round-trips for the new variant (+ on Propose/ProposeMessage/records), golden CBOR pins for all three variants, old-decoder-rejects (E3 pattern), capability registry guard.
  • Daemon: two-daemon App-vs-Dapp decision parity; pending list + activity feed carry the origin verbatim.
  • Bridge: session_origin hygiene table (verbatim goods / collapsed crafteds).
  • App: pure-mapper units — Tx+Dapp → 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.

Before (bridge sends App) After (bridge sends Dapp)
before after
Origin rail: "You are sending" (amber, human) — a website's tx falsely attributed to you Origin rail: "https://app.example.org requests" (neutral dapp identity) — attributed to the site that asked

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 — clean
  • just check — exit 0 (default + --features tray)
  • TMPDIR=/tmp cargo test --workspace441 passed, 0 failed, 2 ignored
  • No dependency changes (9 source/docs files touched; no Cargo.toml/Cargo.lock)

hellno added 4 commits July 7, 2026 22:07
…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'.
@hellno

hellno commented Jul 8, 2026

Copy link
Copy Markdown
Owner Author

Adversarial review → one security fix folded in

A 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 (session_origin() at the bridge boundary): anything that doesn't look like a web origin — http(s):// + a printable, path-free host, ≤255 bytes — collapses to the honest unknown-origin fallback. Real origins still render verbatim. Covered by session_origin_keeps_real_origins_and_collapses_crafted_ones (the You/control-char/smuggled-path/over-long cases all collapse).

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; x-deckard-origin stays the source because the extension's own POST carries the extension's origin, not the page's.

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.

@hellno
hellno merged commit 1bd6ac6 into main Jul 8, 2026
5 checks passed
@hellno
hellno deleted the hellno/dapp-origin-wire branch July 8, 2026 09:24
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.

Dapp origin on the wire: attribute browser transactions to the requesting site

1 participant