Skip to content

[EXT-0002] Cross-origin signer: a sister origin gets a NAP session without holding the key #54

Closed

Description

@tcheeric

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

  1. 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).
  2. Signer-enforced audience binding. The key origin signs a kind-27235 proof only when the u host 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.
  3. 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.
  4. Scoped, short-lived capability. The relying origin gets a signing grant limited to an allow-list of event kinds and u hosts, with a TTL no longer than the NAP session TTL (§14, §28.4), revocable from the key origin.
  5. No wire change to core NAP. /auth/init and /auth/complete stay 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

  1. The relying origin calls connectCrossOrigin({ keyOrigin, kinds, audiences }) (new in nap-client-web). This opens keyOrigin + /nap/signer with window.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).
  2. The signer page checks event.origin of the first postMessage. 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.
  3. The signer page checks the origin policy: is this relying origin allowed, and are the requested kinds and audiences a subset of what it may have?
  4. 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.
  5. 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.
  6. On approval, the signer page creates the signing grant and replies {grantId, pubkey, expiresAt, kinds, audiences} with postMessage(…, relyingOrigin), always passing an explicit target origin, never *.

2. Sign in (NAP core, unchanged)

  1. The relying origin's page calls POST /auth/init on its own server, as now.
  2. It asks the key origin over the channel to sign the kind-27235 completion proof (§11).
  3. The signer page checks the u tag host against grant.audiences and the kind against grant.kinds, and only then signs. This is the signer-enforced audience binding of §7.1.
  4. 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_revoked and 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 sends frame-ancestors CSP 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_extensions isn't needed, because this is client-only. Say so.
  • nap-client-web: connectCrossOrigin and mountCrossOriginSigner, 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 u host 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-java where 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

  1. tcheeric commented on Oct 28, 2026

    @tcheeric
    OwnerAuthor

    Implemented in #55 (feat/cross-origin-signer).

    • Spec: docs/extensions/0002-cross-origin-signer.md, listed in RFC §22.1 and guide §6.2.
    • API: connectCrossOrigin / mountCrossOriginSigner / createStorageConsentStore in @imani/nap-client-web.
    • Tests: 47 unit tests, including the signer conformance suite and a login against the real nap-server, plus an opt-in Chromium e2e across two real origins.

    Answers to the open questions, as built:

    • X1 The policy is code. A signed, fetched policy is deferred.
    • X2 Popup only. Iframe mode is deferred, and a framed signer page stays silent.
    • X3 Deferred.
    • X4 Kinds plus u origin, and created_at must be within 300 s. Path/method scoping is deferred.

    Found during the browser tests and written into the spec: Cross-Origin-Opener-Policy: same-origin or same-origin-allow-popups on the signer page, or same-origin on the relying page, cuts the link to the opener, and the handshake never completes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions