From f3ebae91b0ac1225b921eee228e6eaf35ee9054f Mon Sep 17 00:00:00 2001 From: cdonnachie Date: Thu, 16 Jul 2026 07:19:50 -0400 Subject: [PATCH] feat(connect): opt-in callback return for Connect & sign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets a requesting site receive the signed result automatically instead of the user copy/pasting the address and signature back. Opt-in and fully backward-compatible: a request without `callback` behaves exactly as today. A request envelope may now carry a `callback` URL. After a successful signature the result rides back in that URL's *fragment* (`#nonce=..&address=..&signature=..`, each value encodeURIComponent'd). The fragment is never sent to a server, so the signature stays out of access logs, proxy logs, and the Referer header — and the requesting page reads it client-side. A query string would leak it; a cross-origin POST form is blocked by our own `form-action 'self'` CSP anyway. Origin-binding is the check that matters and is enforced at parse time, so a callback that fails it never reaches the UI: the field is simply absent and the user falls back to the manual return. A callback is kept only when the envelope declares an `origin` and the callback resolves to that exact origin (scheme, host, and port). This stops site A routing site B's signed challenge to an attacker-controlled callback. Also refused: non-http(s) schemes, relative URLs, and embedded credentials; any pre-existing fragment is stripped since the fragment is ours. Auto-return is gated on the request having arrived via the `?req=` deep link, and never fires in the Capacitor shell. Photonic ships web, Tauri and native from one bundle, where navigating the window to a remote page would replace the wallet app itself with no way back. A pasted or scanned request returns by copy/paste, which is what the other tab or device awaits anyway. Tests cover the contract's test vector byte-for-byte, the binding rejections (cross-origin, host-suffix, scheme/port), fragment-not-query, and nonce omission for freeform challenges. --- .../src/connect/__tests__/protocol.test.ts | 144 ++++++++++++++++++ packages/app/src/connect/protocol.ts | 131 +++++++++++++++- packages/app/src/pages/Connect.tsx | 64 +++++++- 3 files changed, 334 insertions(+), 5 deletions(-) diff --git a/packages/app/src/connect/__tests__/protocol.test.ts b/packages/app/src/connect/__tests__/protocol.test.ts index 00d0bbf..29a399a 100644 --- a/packages/app/src/connect/__tests__/protocol.test.ts +++ b/packages/app/src/connect/__tests__/protocol.test.ts @@ -8,9 +8,11 @@ import { it, expect, describe } from "vitest"; import { parseSignRequest, isRecognizedConnectChallenge, + buildCallbackUrl, buildSignResult, encodeSignResult, encodeReqParam, + extractChallengeNonce, CONNECT_PROTOCOL, CONNECT_VERSION, type SignRequest, @@ -152,6 +154,148 @@ describe("parseSignRequest — base64url envelope + round-trip", () => { }); }); +describe("parseSignRequest — callback origin-binding", () => { + const withCallback = (fields: Record) => + parseSignRequest(JSON.stringify({ challenge: CHALLENGE, ...fields })); + + it("keeps a callback whose origin matches the envelope origin", () => { + const r = withCallback({ + origin: "https://surf.rxd.zone", + callback: "https://surf.rxd.zone/auth/photonic-callback", + }); + expect(r.ok && r.request.callback).toBe( + "https://surf.rxd.zone/auth/photonic-callback" + ); + }); + + it("keeps a matching callback when the origin is given as a bare host", () => { + const r = withCallback({ + origin: "surf.rxd.zone", + callback: "https://surf.rxd.zone/cb", + }); + expect(r.ok && r.request.callback).toBe("https://surf.rxd.zone/cb"); + }); + + it("drops a callback pointing at a different origin", () => { + // The attack this binding exists for: site A routing site B's signature + // to an attacker-controlled callback. + const r = withCallback({ + origin: "https://surf.rxd.zone", + callback: "https://evil.example/steal", + }); + expect(r.ok).toBe(true); + if (r.ok) expect(r.request.callback).toBeUndefined(); + }); + + it("drops a callback that differs only by host suffix, scheme, or port", () => { + const cases = [ + "https://surf.rxd.zone.evil.example/cb", + "https://notsurf.rxd.zone/cb", + "http://surf.rxd.zone/cb", + "https://surf.rxd.zone:8443/cb", + ]; + for (const callback of cases) { + const r = withCallback({ origin: "https://surf.rxd.zone", callback }); + expect(r.ok && r.request.callback, callback).toBeUndefined(); + } + }); + + it("drops a callback when the envelope declares no origin to bind to", () => { + const r = withCallback({ callback: "https://surf.rxd.zone/cb" }); + expect(r.ok).toBe(true); + if (r.ok) expect(r.request.callback).toBeUndefined(); + }); + + it("drops non-http(s), relative, and credentialed callbacks", () => { + const cases = [ + // The literal IS the test: this is the scheme the parser must refuse. + // eslint-disable-next-line no-script-url + "javascript:alert(1)", + "data:text/html,", + "/auth/photonic-callback", + "https://user:pass@surf.rxd.zone/cb", + ]; + for (const callback of cases) { + const r = withCallback({ origin: "https://surf.rxd.zone", callback }); + expect(r.ok && r.request.callback, callback).toBeUndefined(); + } + }); + + it("strips any fragment the callback arrives with — we own the fragment", () => { + const r = withCallback({ + origin: "https://surf.rxd.zone", + callback: "https://surf.rxd.zone/cb#already-here", + }); + expect(r.ok && r.request.callback).toBe("https://surf.rxd.zone/cb"); + }); + + it("keeps the request when the callback is malformed", () => { + const r = withCallback({ + origin: "https://surf.rxd.zone", + callback: "not a url", + }); + expect(r.ok && r.request.challenge).toBe(CHALLENGE); + expect(r.ok && r.request.callback).toBeUndefined(); + }); +}); + +describe("extractChallengeNonce", () => { + it("takes the segment after the wallet-connect version", () => { + expect( + extractChallengeNonce("radiant:wallet-connect:v1:abc123:SURF.RXD sign-in") + ).toBe("abc123"); + }); + + it("returns undefined for an unrecognized challenge", () => { + expect(extractChallengeNonce("just some text")).toBeUndefined(); + expect(extractChallengeNonce("")).toBeUndefined(); + }); +}); + +describe("buildCallbackUrl", () => { + const SIGNED = { + address: "14XmXG3dSBWZUukGT3xzS9zxpiZ53vgx1i", + signature: + "IHdStUu1KegHDyNSnHtD+yRS+A3/0P4xGlyu8yF/HLg9Tjek8tliTbCjbqy1Xi4cMwJuVHQbMBGo5fsPpmZ3W6s=", + }; + + it("matches the contract's test vector", () => { + const url = buildCallbackUrl( + { + challenge: "radiant:wallet-connect:v1:abc123:SURF.RXD sign-in | …", + callback: "https://surf.rxd.zone/auth/photonic-callback", + }, + SIGNED + ); + expect(url).toBe( + "https://surf.rxd.zone/auth/photonic-callback#nonce=abc123&address=14XmXG3dSBWZUukGT3xzS9zxpiZ53vgx1i&signature=IHdStUu1KegHDyNSnHtD%2ByRS%2BA3%2F0P4xGlyu8yF%2FHLg9Tjek8tliTbCjbqy1Xi4cMwJuVHQbMBGo5fsPpmZ3W6s%3D" + ); + }); + + it("puts the result in the fragment, never the query", () => { + const url = buildCallbackUrl( + { challenge: CHALLENGE, callback: "https://surf.rxd.zone/cb" }, + SIGNED + )!; + expect(url.indexOf("#")).toBeGreaterThan(-1); + expect(url.slice(0, url.indexOf("#"))).not.toMatch(/[?&]/); + expect(url.split("#")[1]).toContain("signature="); + }); + + it("omits the nonce when the challenge carries none", () => { + const url = buildCallbackUrl( + { challenge: "freeform text", callback: "https://surf.rxd.zone/cb" }, + SIGNED + ); + expect(url).not.toContain("nonce="); + expect(url).toContain("address="); + }); + + it("returns undefined when the request has no callback", () => { + expect(buildCallbackUrl({ challenge: CHALLENGE }, SIGNED)).toBeUndefined(); + }); +}); + describe("isRecognizedConnectChallenge", () => { it("matches the namespaced wallet-connect shape", () => { expect(isRecognizedConnectChallenge(CHALLENGE)).toBe(true); diff --git a/packages/app/src/connect/protocol.ts b/packages/app/src/connect/protocol.ts index de02a2b..c9d1939 100644 --- a/packages/app/src/connect/protocol.ts +++ b/packages/app/src/connect/protocol.ts @@ -14,6 +14,12 @@ * approval, signs via `@lib/sign`, and returns a {@link SignResult}. * 3. The dApp verifies the signature with radiantjs `Message.verify`. * + * The result normally returns to the dApp by hand (copy/paste or QR). A request + * may instead opt in to an automatic return by carrying a `callback` URL; the + * result then rides back in that URL's fragment (see {@link buildCallbackUrl}). + * A `callback` is honoured ONLY when its origin matches the envelope's declared + * `origin`, so one site can never route another site's signature elsewhere. + * * SECURITY: parsing NEVER trusts unvalidated fields. The challenge is run * through the same guards the signer enforces (`@lib/sign`: length cap + * no control characters) so the UI can render it verbatim and the service can @@ -40,6 +46,13 @@ export type SignRequest = { app?: string; /** Address the requester expects to sign; page warns on mismatch (optional). */ address?: string; + /** + * Where to return the signed result, as a URL fragment (optional). + * + * Only ever populated when its origin matches {@link SignRequest.origin} — + * see `cleanCallback`. Absent means the classic manual copy/paste return. + */ + callback?: string; }; export type SignResult = { @@ -60,12 +73,19 @@ const MAX_ID_LEN = 128; const MAX_LABEL_LEN = 128; const MAX_ORIGIN_LEN = 256; const MAX_ADDRESS_LEN = 128; +const MAX_CALLBACK_LEN = 512; // `:wallet-connect:v:...` — the shape Phase A challenges take. // Used only to badge a request as "recognized" in the UI; non-matching // challenges are still signable (with a warning), never auto-rejected. const CONNECT_CHALLENGE_RE = /^[a-z0-9.-]+:wallet-connect:v\d+:/i; +// Captures the segment straight after `…:wallet-connect:v:` — the nonce, in +// the shape the callback contract specifies +// (`radiant:wallet-connect:v1::