Skip to content

feat(connect): opt-in callback return for Connect & sign - #3

Merged
TheArtofSatoshi merged 1 commit into
mainfrom
feat/connect-sign-callback
Jul 16, 2026
Merged

feat(connect): opt-in callback return for Connect & sign#3
TheArtofSatoshi merged 1 commit into
mainfrom
feat/connect-sign-callback

Conversation

@cdonnachie

Copy link
Copy Markdown

Implements the Photonic sign-in callback contract, letting 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 it does today — the manual copy/paste and QR return are untouched. SURF.RXD already implements its half of this.

What changed

A request envelope may now carry an optional callback URL. After a successful signature, the result rides back in that URL's fragment:

https://surf.rxd.zone/auth/photonic-callback#nonce=<nonce>&address=<addr>&signature=<sig>

Each value is encodeURIComponent-escaped, since a base64 signature contains +, / and =. 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 into logs and history; a cross-origin POST form is blocked by our own form-action 'self' CSP anyway.

  • packages/app/src/connect/protocol.ts — the callback field, extractChallengeNonce, buildCallbackUrl, and the origin-binding validation.
  • packages/app/src/pages/Connect.tsx — the redirect after signing, plus pre-approval disclosure telling the user they'll be sent back to the named origin, which receives their address and signature.

Security

Origin-binding 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 is what stops site A routing site B's signed challenge to an attacker-controlled callback. Also refused: non-http(s) schemes such as javascript:, relative URLs, and embedded credentials. Any pre-existing fragment is stripped, since the fragment is ours.

Exposure is bounded regardless: the signature is over a single-use, server-issued nonce, which SURF marks consumed on first verify — so a leaked signature can't be replayed against a different challenge.

Judgement call worth a look

Auto-return is gated on the request having arrived via the ?req= deep link, and never fires in the Capacitor shell (canAutoReturn). Photonic ships web, Tauri, and native from one bundle, and in those shells navigating the window to a remote page replaces the wallet app itself with no way back. A pasted or scanned request returns the classic way, which is what the user's other tab or device is waiting for anyway. Happy to drop the gate if you'd rather redirect on any request carrying a valid callback — it's a one-line change.

Testing

  • 35 unit tests pass in src/connect, covering the contract's test vector byte-for-byte, the binding rejections (cross-origin, host-suffix like surf.rxd.zone.evil.example, scheme/port downgrades), fragment-not-query, and nonce omission for freeform challenges.
  • Verified end-to-end against SURF: a deep-linked sign redirects and completes sign-in with no paste.
  • Lint, formatting, and typecheck clean on the changed files.

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.
@TheArtofSatoshi
TheArtofSatoshi merged commit 064eb39 into main Jul 16, 2026
1 check failed
@TheArtofSatoshi
TheArtofSatoshi deleted the feat/connect-sign-callback branch July 26, 2026 16:02
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.

2 participants