Repository navigation
Description
Summary
Add a cross-origin session handshake to NAP, as a new extension, 0002 (proposed name cross-origin-signer/1). With it, a web app on one origin (the relying origin, e.g. gifting.imani.casa or subscription.imani.casa) can get a NAP session for a user whose key is held by a different origin (the key origin, e.g. the Imani wallet), and the key never leaves the key origin.
The key origin acts as a signer the calling page does not control. It is in the same position as a NIP-46 bunker in RFC §7.1, but reached through the browser (window.open / iframe + postMessage) instead of a relay. It knows the real origin of the caller from the browser, not from anything the page writes. So it can apply the per-origin policy that §7.1 says a signer has to apply: refuse kind-27235 proofs whose u host is outside policy, and show a prompt that the page can't forge.
Motivating users (imani-wallet):
gifting.imani.casa: buying vouchers as gifts while signed in (398ja/imani-wallet#410, decision G10).subscription.imani.casa: buying and managing stall subscriptions (398ja/imani-wallet#407 and the follow-up issue).
Today each relying origin would need its own copy of the key (createWebCryptoKeyStore is origin-bound IndexedDB) or a NIP-07 extension. That means a second passphrase, a second encrypted copy of the key, and RFC §28.2 option 3 (locally held key) on every new origin.
Goals
- No key outside the key origin. The relying origin only ever receives signed events, never key material, derived secrets, or an unlocked store (§28.2: this makes the relying origin "never holds a key", so §28 is satisfied trivially).
- Signer-enforced audience binding. The key origin signs a kind-27235 proof only when the
uhost belongs to the caller's real origin's allow-listed audiences. This closes the §7.1 origin-substitution gap for this path, instead of inheriting NIP-07's ambient authority. - One unlock. If the user has an unlocked session on the key origin, the relying origin signs in without a second passphrase. If the key origin is locked, the user unlocks it there (on its own page), never by typing a passphrase into the relying origin.
- Scoped, short-lived capability. The relying origin gets a signing grant limited to an allow-list of event kinds and
uhosts, with a TTL no longer than the NAP session TTL (§14, §28.4), revocable from the key origin. - No wire change to core NAP.
/auth/initand/auth/completestay byte-identical (the same test the RFC applied to extension 0001). The relying origin's server sees an ordinary NIP-98 completion proof.
Non-goals
- Cross-browser or cross-device sessions. That is NIP-46's job, and NIP-46 stays the preferred path where available (§28.2 order unchanged).
- Sharing sessions or cookies between origins. The relying origin's server issues its own NAP session as usual.
- Delegating the key (NIP-26 style). Every event is signed by the user's key, on the key origin, one at a time or within the grant.
- Non-browser clients.
Terminology
| Term | Meaning |
|---|---|
| Key origin | The origin holding the user's key (locally per §28.3, or behind NIP-07/NIP-46 itself). Exposes the signer page. |
| Relying origin | The origin that wants a NAP session and signatures. Never holds the key. |
| Signer page | A page on the key origin, e.g. https://wallet.example/nap/signer, opened as a popup (default) or embedded iframe. |
| Origin policy | Key-origin config: which relying origins may connect, which u hosts each may target, which kinds each may request, max grant TTL. |
| Signing grant | What a connected relying origin holds: {grantId, relyingOrigin, pubkey, kinds[], audiences[], expiresAt}. Lives in the key origin's memory. The relying origin holds only grantId + a channel. |
Protocol sketch
1. Connect
- The relying origin calls
connectCrossOrigin({ keyOrigin, kinds, audiences })(new innap-client-web). This openskeyOrigin + /nap/signerwithwindow.open. A popup is the default, because it gets its own top-level browsing context and the user can see its URL bar. An iframe mode is optional and only for already-granted, silent re-signing (see §5). - The signer page checks
event.originof the firstpostMessage. The origin comes from the browser, never from the message body. Unknown origin: close the window, with no response that reveals whether the user exists. - The signer page checks the origin policy: is this relying origin allowed, and are the requested
kindsandaudiencesa subset of what it may have? - If the key origin is locked, the signer page runs its own unlock UI (passphrase / NIP-46 / NIP-07, whatever it normally uses). The relying origin sees "pending" only.
- Consent prompt on the key origin, the first time per relying origin (and again if the scope grows): "subscription.imani.casa wants to sign you in as alice@imani.casa and sign requests to subscription.imani.casa. Allow?" The decision is remembered per (relying origin, scope) on the key origin, with a "Connected sites" screen to revoke it.
- On approval, the signer page creates the signing grant and replies
{grantId, pubkey, expiresAt, kinds, audiences}withpostMessage(…, relyingOrigin), always passing an explicit target origin, never*.
2. Sign in (NAP core, unchanged)
- The relying origin's page calls
POST /auth/initon its own server, as now. - It asks the key origin over the channel to sign the kind-27235 completion proof (§11).
- The signer page checks the
utag host againstgrant.audiencesand the kind againstgrant.kinds, and only then signs. This is the signer-enforced audience binding of §7.1. - The relying origin posts the proof to
/auth/complete, which works the same as today. The server never knows a cross-origin signer was involved.
3. Signing after login
- Requests within the grant (e.g. NIP-98 for that origin's API, kind 27235 for step-up §10.3) are signed without a prompt while the grant is live.
- Requests outside the grant are refused with one generic error. Step-up (§10.3) always prompts on the key origin, so a hostile script on the relying origin can't silently step up.
- Optional per-grant rate limit on signatures.
4. Lifetime and revocation
grant.expiresAt <= min(key-origin session lock time, policy max TTL, 15 min default), aligned with §14 and §28.4.- The grant ends when the key origin locks (idle, explicit lock, logout, unload of the last key-origin context). The relying origin gets
grant_revokedand falls back to reconnecting. - The user can revoke a relying origin under "Connected sites" on the key origin.
- Grants are in memory only on the key origin. Nothing about a grant is persisted, apart from the remembered consent decision (no secret).
5. Popup vs iframe
- Popup (default): works with third-party storage partitioning (Safari ITP, Chrome CHIPS/partitioning, Firefox TCP), because the key origin runs first-party in its own window. The user can see the URL, which makes phishing harder.
- Iframe (optional): for re-signing within an existing grant without a window flash. In partitioned browsers the embedded key origin can't read its first-party IndexedDB, so it may need the Storage Access API (
document.requestStorageAccess), or it falls back to the popup. The iframe must never show the consent prompt or the unlock UI, because an embedded frame can be clickjacked. The key origin sendsframe-ancestorsCSP limited to policy origins, and the iframe answers only within existing grants.
Security analysis
| Threat | Mitigation |
|---|---|
| Hostile page asks the key origin for a proof addressed to a real NAP server (§7.1 origin substitution) | The browser-supplied event.origin must be in the policy, and the u host must be in that origin's audiences. A hostile origin isn't in the policy, so it gets nothing. |
| XSS on an allow-listed relying origin | It can sign only within the grant (its own audiences and kinds, until the TTL). Same blast radius as a session cookie on that origin. Step-up still prompts on the key origin. Document this honestly, as §28.5 does. |
| Message spoofing / injection | Strict event.origin and event.source checks on both sides. Explicit targetOrigin. Each message carries a nonce plus grantId. Messages are structured-clone JSON, schema-validated, with no eval and no HTML. |
| Clickjacking the consent prompt | Consent and unlock only in a top-level popup, never in an iframe. frame-ancestors 'none' on the signer page in popup mode. |
| Popup opener hijack / tab-nabbing | The signer page keeps the window.opener reference only to reply. Validate opener.origin via the first message's event.origin. Relying side uses noopener only after the handshake reply. |
| Signing blind | The signer page shows a human summary for prompts (kind, target host, method). For NIP-98 within a grant: no prompt, but log to a key-origin "Recent activity" list. |
| Replay | Unchanged: NAP challenge + §12.1 time window. Grant nonces stop replays of channel messages. |
| Policy tampering | The origin policy is code/config on the key origin (shipped with the app, or signed config). It is not user-editable, apart from revoking. |
| Enumeration (§10.1 / §19) | An unknown relying origin or a locked key origin returns the same "not available" response, and nothing reveals whether a key exists. |
API (nap-client-web)
// Relying origin
const grant = await connectCrossOrigin({
keyOrigin: 'https://wallet.imani.casa',
kinds: [27235], // + NIP-98 kinds used by the app
audiences: ['subscription.imani.casa'],
mode: 'popup', // or 'iframe' for silent re-sign
})
const signer: SessionSigner = grant.signer // plugs into createNapSession({ signer })
// Key origin
mountCrossOriginSigner({
policy: { 'https://subscription.imani.casa': { kinds: [27235], audiences: ['subscription.imani.casa'], maxTtlSeconds: 900 } },
signer: walletSigner, // existing EvictableSigner / NIP-46 / NIP-07
onConsent: (req) => ui.prompt(req),
onUnlock: () => ui.unlock(),
})grant.signer implements the existing SessionSigner interface (types.ts), so createNapSession and the adapters work unchanged.
Acceptance criteria
- Extension spec
docs/extensions/0002-cross-origin-signer.md, listed in RFC §22.1, with the rationale for why core is untouched.supported_extensionsisn't needed, because this is client-only. Say so. -
nap-client-web:connectCrossOriginandmountCrossOriginSigner, popup mode, plus an optional iframe mode with the Storage Access fallback. - A relying origin gets a NAP session from an unlocked key origin with no passphrase prompt, and with one consent prompt the first time.
- A locked key origin unlocks in its own window. The relying origin never sees the passphrase.
- Requests from an origin outside the policy, a
uhost outside the audiences, or a kind outside the grant are refused with a uniform error. Tested with a hostile-origin fixture. - Step-up always prompts on the key origin.
- Locking or logging out on the key origin revokes all grants within 1 s. Revoking under "Connected sites" works.
- Works in Chrome, Firefox and Safari with default storage partitioning (popup mode). The iframe mode degrades to popup where storage access is denied.
- Test vectors and an e2e test with two origins (Playwright, two local hosts).
- Mirror the spec in
nap-javawhere relevant (server is unchanged, so this is docs only). Tracking under [EXT-0001] Mirror the extension in nap-java and extend nap-it interop coverage #29. - Tutorial or guide section: "Signing in on a sister site".
Open questions
- X1. Should the policy also be signed and fetched (so new sister sites don't need a key-origin release), or shipped in code only?
- X2. Iframe mode in v1, or popup only?
- X3. Should the key origin itself be able to act as a NIP-46 bunker over relays as an alternative transport (the same policy engine, so it also works cross-device)?
- X4. Grant scope language: kinds + hosts only, or also HTTP methods/paths for NIP-98?
Activity
tcheeric commentedon Oct 28, 2026
Implemented in #55 (
feat/cross-origin-signer).docs/extensions/0002-cross-origin-signer.md, listed in RFC §22.1 and guide §6.2.connectCrossOrigin/mountCrossOriginSigner/createStorageConsentStorein@imani/nap-client-web.nap-server, plus an opt-in Chromium e2e across two real origins.Answers to the open questions, as built:
uorigin, andcreated_atmust be within 300 s. Path/method scoping is deferred.Found during the browser tests and written into the spec:
Cross-Origin-Opener-Policy: same-originorsame-origin-allow-popupson the signer page, orsame-originon the relying page, cuts the link to the opener, and the handshake never completes.