From 3ccb5b2cb3b54b998b86146274b987a94b4b0f1f Mon Sep 17 00:00:00 2001 From: tcheeric Date: Wed, 28 Oct 2026 11:05:09 +0000 Subject: [PATCH 1/2] feat(nap-client-web): extension 0002, cross-origin signer (#54) A sister origin gets a NAP session from a key held on another origin, without the key leaving it. connectCrossOrigin() opens the key origin's signer page as a popup; mountCrossOriginSigner() answers only its opener, only for policy origins (browser-reported, not page-asserted), and signs only kinds and u-tag audiences inside a short-lived grant. Later completion proofs (step-up) must pass onSign. Grants end on lock, logout, identity change, TTL, or popup close. Client-only: servers and nap-java are unchanged. Spec in docs/extensions/0002-cross-origin-signer.md; Chromium e2e behind NAP_BROWSER=1 checks origin, framing and COOP behaviour. --- CHANGELOG.md | 16 + README.md | 2 +- docs/NAP-INTEGRATION-GUIDE.md | 38 ++ docs/NAP-v2-RFC.md | 7 + docs/extensions/0002-cross-origin-signer.md | 234 +++++++ package-lock.json | 14 + package.json | 1 + .../nap-client-web/src/crossOriginClient.ts | 338 ++++++++++ .../nap-client-web/src/crossOriginProtocol.ts | 329 +++++++++ .../nap-client-web/src/crossOriginSigner.ts | 538 +++++++++++++++ packages/nap-client-web/src/index.ts | 22 + .../test/crossOrigin.browser.test.ts | 221 ++++++ .../nap-client-web/test/crossOrigin.test.ts | 637 ++++++++++++++++++ packages/nap-client-web/test/fakeWindows.ts | 80 +++ 14 files changed, 2476 insertions(+), 1 deletion(-) create mode 100644 docs/extensions/0002-cross-origin-signer.md create mode 100644 packages/nap-client-web/src/crossOriginClient.ts create mode 100644 packages/nap-client-web/src/crossOriginProtocol.ts create mode 100644 packages/nap-client-web/src/crossOriginSigner.ts create mode 100644 packages/nap-client-web/test/crossOrigin.browser.test.ts create mode 100644 packages/nap-client-web/test/crossOrigin.test.ts create mode 100644 packages/nap-client-web/test/fakeWindows.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d0e9f7c..8fa1c6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,22 @@ All packages in this workspace share a single version. ## [Unreleased] +### Added + +- **Extension 0002, cross-origin signer** (`@imani/nap-client-web`, tcheeric/nap#54). With + `connectCrossOrigin()`, a sister origin opens the key origin's signer page as a popup and + gets a scoped, short-lived signing grant. The returned `grant.signer` is an ordinary + `SessionSigner` for `createNapSession`. On the key origin, `mountCrossOriginSigner()`: + - answers only its opener, and only for origins in an explicit policy, using the + browser-reported origin; + - signs only kinds and `u`-tag audiences inside the grant, with `created_at` near now; + - prompts through `onSign` for every completion proof after the first (step-up); + - revokes every grant on lock, logout or identity change. + + `createStorageConsentStore()` remembers approvals. Client-only: servers and `nap-java` are + unchanged. Spec: `docs/extensions/0002-cross-origin-signer.md`. An opt-in Chromium e2e runs + with `NAP_BROWSER=1`. + ## [0.12.0] - 2026-10-08 The packages are publishable. Each compiles to ESM plus `.d.ts` in `dist/` and is released to diff --git a/README.md b/README.md index f82f1aa..ed12cf5 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Already know Nostr and want the reference instead? Start at | `@imani/nap-core` | Protocol types, hashing, base64/hex codecs, header parsing, NIP-98 completion validation. Runs in browser and server. | | `@imani/nap-server` | `createNapServer()` — challenge issuance, retry-safe completion verification, refresh rotation, ACL resolution, in-memory stores, rate limiter, metrics. | | `@imani/nap-client-http` | Request building for `/auth/init` and `/auth/complete`. | -| `@imani/nap-client-web` | Browser session lifecycle: `createNapSession()`, NIP-07 detection and signers, identity guard, idle lock, re-unlock, cross-tab sync. | +| `@imani/nap-client-web` | Browser session lifecycle: `createNapSession()`, NIP-07 detection and signers, the cross-origin signer (extension 0002), identity guard, idle lock, re-unlock, cross-tab sync. | | `@imani/nap-client-nip46` | NIP-46 remote-signer (`bunker://` / `nostrconnect://`) `SessionSigner`, with encrypted reconnection. **Opt-in** — install it only if you want remote signers; it is the only package that talks to relays. | | `@imani/nap-react` | `NapProvider`, `useNapSession()`, `useReunlock()` over `nap-client-web`. | | `@imani/nap-adapter-express` | `createNapExpressRouter()`, guards, raw-body-safe JSON parser. | diff --git a/docs/NAP-INTEGRATION-GUIDE.md b/docs/NAP-INTEGRATION-GUIDE.md index 27c32f8..69d4041 100644 --- a/docs/NAP-INTEGRATION-GUIDE.md +++ b/docs/NAP-INTEGRATION-GUIDE.md @@ -1979,6 +1979,44 @@ path whose prompt is **your own DOM**. An extension or bunker prompt is browser OS chrome and cannot be overlaid or mimicked by a framing attacker; a passphrase field can. Serve the pages that render it with `frame-ancestors 'none'`. +**Cross-origin signer (extension 0002)** (`packages/nap-client-web/src/crossOriginClient.ts`, +`crossOriginSigner.ts`). + +Use it when a sister site should sign in with a key that lives on another origin, e.g. a shop +on `shop.example` and a wallet on `wallet.example`. The wallet mounts a signer page, and the shop +opens it as a popup. The wallet checks the shop's real origin (from the browser) and the `u` +tag of every event before signing. The key never reaches the shop. + +```ts +// wallet.example/nap/signer +const handle = mountCrossOriginSigner({ + policy: { 'https://shop.example': { kinds: [27235], audiences: ['https://shop.example'] } }, + signer, + ensureUnlocked: () => ui.unlock(), + onConsent: (request) => ui.confirm(request), + onSign: (request) => (request.reason === 'completion' ? ui.confirmStepUp(request) : Promise.resolve(true)), + consentStore: createStorageConsentStore(), +}); + +// shop.example, inside a click handler (window.open needs the gesture) +const grant = await connectCrossOrigin({ keyOrigin: 'https://wallet.example', kinds: [27235], audiences: [location.origin] }); +const session = createNapSession({ baseUrl: location.origin, signer: grant.signer }); +await session.login(); +``` + +Three things bite: + +- **COOP.** The signer page must not send `Cross-Origin-Opener-Policy: same-origin` (or + `same-origin-allow-popups`), and the relying page must not send `same-origin`: either one + severs `window.opener` and the popup never answers (`NOT_AVAILABLE`). +- **User gesture.** Call `connectCrossOrigin` directly from a click, before any `await`, or the + popup is blocked (`POPUP_BLOCKED`). +- **Step-up prompts.** The first completion proof rides the connection consent. Later ones + (step-up, re-login) go through `onSign`, and are refused without it. + +The full rules and threat model are in +[extension 0002](./extensions/0002-cross-origin-signer.md). + ### 6.3 Idle lock, shutdown, and cross-tab sync > **Walk it:** [tutorial 08 §5–§7](./tutorials/08-in-page-keys.md) drives the lock, the passphrase re-unlock, and the cross-tab broadcast in a browser. diff --git a/docs/NAP-v2-RFC.md b/docs/NAP-v2-RFC.md index 5652567..7c97df9 100644 --- a/docs/NAP-v2-RFC.md +++ b/docs/NAP-v2-RFC.md @@ -876,6 +876,13 @@ Each of those should be specified as an extension, not implied by the core profi | Extension | Status | Relates to | | --- | --- | --- | | [0001 — Voucher-Bound Authorization](./extensions/0001-voucher-bound-authorization.md) | Draft, partially implemented | §15, and items 1 and 5 above | +| [0002 — Cross-Origin Signer](./extensions/0002-cross-origin-signer.md) | Draft, implemented (`nap-client-web`) | §7.1, §10.3, §28.2; client-only | + +Extension 0002 lets a sister origin get a session from a key held on another origin, +through a popup that checks the browser-reported caller origin and the `u` audience before +signing. That is the signer-enforced audience binding §7.1 asks for, reached without a +bunker. It is client-only: the server receives an ordinary §11 proof, so nothing in the core +profile or in `nap-java` changes. Extension 0001 lets an Imani-issued Cashu voucher supply a session's roles and permissions in place of a stored ACL row. It changes **authorization only** — diff --git a/docs/extensions/0002-cross-origin-signer.md b/docs/extensions/0002-cross-origin-signer.md new file mode 100644 index 0000000..33b15d6 --- /dev/null +++ b/docs/extensions/0002-cross-origin-signer.md @@ -0,0 +1,234 @@ +# NAP Extension 0002: Cross-Origin Signer + +**Status:** Draft, implemented in `@imani/nap-client-web` (`connectCrossOrigin`, `mountCrossOriginSigner`). Not part of the core profile. +**Registered name:** `cross-origin-signer/1` (wire tag `nap-cross-origin-signer/1`). +**Extends:** `docs/NAP-v2-RFC.md` §7.1 (origin substitution), §10.3 (step-up), §28.2 (signer preference). Relates to §22 item 5 (delegated auth), without delegating the key. +**Applies to:** browser clients only. **Servers are unchanged**, so `nap-java` needs no code change and `supported_extensions` is not used (see §9). +**Tracking:** tcheeric/nap#54. + +--- + +## 1. Summary + +A web app on one origin (the **relying origin**) gets a NAP session for a user whose key +lives on a **different origin** (the **key origin**). The key never leaves the key origin. + +The relying origin opens a page of the key origin (the **signer page**) as a popup and talks +to it with `postMessage`. The signer page learns the caller's origin from the browser +(`MessageEvent.origin`), which the calling page cannot choose. It checks that origin against +a policy, asks the user once, and then signs only events addressed to that origin's own +audiences, for a bounded time. + +The relying origin's server sees an ordinary NIP-98 completion proof. Nothing on the wire of +§10 or §11 changes. + +### 1.1 Why + +Two sister sites (say `wallet.example` and `shop.example`) want one sign-in. Each origin has +its own storage, so without this the second site needs one of: + +- NIP-07: most users don't have it, and its grants are scoped by origin and kind but not by + audience, which is the §7.1 ambient-authority problem; +- a second encrypted copy of the key, with a second passphrase: §28.2 option 3, on one more + origin; +- a QR round trip through a phone for every sign-in. + +### 1.2 What it buys + +| Property | NIP-07 | Local key on each origin | This extension | +| --- | --- | --- | --- | +| Key on the relying origin | No | **Yes** | No | +| Second passphrase | No | **Yes** | No | +| Refusal comes from a signer the page doesn't control (§7.1) | No (blind, per-origin grant) | No | **Yes**: browser-reported origin + audience check | +| Works without an extension | No | Yes | Yes | +| Prompt the page cannot forge | Extension chrome | No (page DOM) | Popup on the key origin, its own URL bar | + +## 2. Terminology + +| Term | Meaning | +| --- | --- | +| **Key origin** | The origin that holds the key, or reaches it (in-page key per §28.3, NIP-07, or NIP-46). | +| **Relying origin** | The origin that wants a session and signatures. It never holds the key. | +| **Signer page** | A page on the key origin, `DEFAULT_SIGNER_PATH = /nap/signer` by default, that runs `mountCrossOriginSigner`. | +| **Policy** | Key-origin configuration: for each relying origin, the kinds it may have signed, the audiences those events may address, and a maximum grant lifetime. | +| **Grant** | `{grantId, pubkey, kinds, audiences, expiresAt}`, held in the signer page's memory. The relying origin holds the `grantId` only. It names the grant but does not unlock it. | + +## 3. Messages + +Every message is a structured-clone object with `nap: "nap-cross-origin-signer/1"`. Anything +else is ignored. **No message carries the sender's origin**: each side reads +`MessageEvent.origin`. Every post uses an explicit target origin, never `*`. + +| `type` | Direction | Fields | +| --- | --- | --- | +| `ready` | signer → relying | none. Sent once on load, to each policy origin in turn. The browser delivers it only to the one that matches the opener. | +| `connect` | relying → signer | `id`, `kinds[]`, `audiences[]`, optional `expectedPubkey` | +| `connected` | signer → relying | `id`, `grant` | +| `sign` | relying → signer | `id`, `grantId`, `template` (`kind`, `created_at`, `content`, `tags`) | +| `signed` | signer → relying | `id`, `event` | +| `error` | signer → relying | optional `id`, `code` | +| `revoked` | signer → relying | `grantId` | +| `disconnect` | relying → signer | `grantId` | + +Error codes: `NOT_AVAILABLE`, `POPUP_BLOCKED`, `DECLINED`, `REFUSED`, `GRANT_EXPIRED`, +`REVOKED`, `CHANNEL_CLOSED`, `TIMEOUT`, `SIGNER_ERROR`. `REFUSED` covers every "outside the +policy or grant" case (kind, audience, `u` tag, `created_at`, malformed), so a probe learns +nothing about which rule it broke. + +Receivers MUST parse defensively: reject unknown types and fields of the wrong type, bound +sizes, and copy accepted values rather than keep references. + +## 4. Flow + +``` +relying origin (shop.example) key origin signer page (wallet.example/nap/signer) + click → window.open(signer page) ───▶ load; opener present, top-level, not framed + ◀─── ready + connect {kinds, audiences} ───▶ origin in policy? request ⊆ policy? + ensureUnlocked() (unlock UI, on the key origin) + consent remembered? else onConsent() + ◀─── connected {grant} + POST /auth/init (own server) + sign {grantId, kind 27235 proof} ───▶ grant live? same window? kind ∈ grant? + single u tag, origin ∈ grant.audiences? + |created_at − now| ≤ skew? + first completion proof? (else onSign) + ◀─── signed {event} + verify sig, pubkey, template + POST /auth/complete ───▶ (relying origin's server: unchanged core NAP) +``` + +## 5. Normative rules + +### 5.1 Signer page + +1. MUST answer only `window.opener`, and only when the browser-reported `event.origin` is a + policy key. It MUST ignore `event.source` values other than the opener. +2. MUST NOT answer at all when framed (`top !== self`), and MUST NOT show consent or unlock UI + in a frame. The page SHOULD send `Content-Security-Policy: frame-ancestors 'none'`. +3. MUST NOT send `Cross-Origin-Opener-Policy: same-origin` (or `same-origin-allow-popups`). These + sever `window.opener` and make the handshake impossible (verified in Chromium, + `crossOrigin.browser.test.ts`). `unsafe-none` is required for this page. Other pages of the + key origin may keep a stricter COOP. +4. MUST refuse an empty policy at mount time. Policy origins and audiences MUST be canonical + bare origins. +5. On `connect`: the requested kinds and audiences MUST be subsets of the policy entry, and the + key MUST be unlocked on the key origin before consent is asked. Consent is per + (relying origin, pubkey, scope). A remembered consent holds no secret. +6. If `expectedPubkey` is given and differs from the signer's identity, MUST refuse, even + after the user consented. Connecting as B when A was asked for is a privilege transfer. +7. On `sign`: the grant MUST exist, belong to this origin and this window, and be unexpired by + the **signer page's** clock. The template's kind MUST be in the grant. It MUST carry exactly + one `u` tag, whose origin is in the grant's audiences. `created_at` MUST be within the skew + (default 300 s), so a live grant cannot pre-sign proofs for later use. +8. **Step-up (§10.3).** The first NAP completion proof (a template with a `challenge` tag) in a + grant is covered by the connection consent. Every later completion proof MUST go through + `onSign`, and is refused if `onSign` is not configured. This is what makes step-up through + this signer a real prompt rather than a silent re-run: the gap §10.3 describes for NIP-07 + and pre-granted NIP-46. +9. The signed event's pubkey MUST equal the grant's. If it does not, the identity behind the + signer changed, and every grant MUST be revoked. +10. Grants live in memory only. `revokeAll` MUST run on the key origin's lock, logout, + shutdown and identity change (`mountCrossOriginSigner` listens on the session's + `BroadcastChannel` for this). Grant lifetime MUST NOT exceed `MAX_GRANT_TTL_SECONDS` (900, + the default session TTL, §28.4). + +### 5.2 Relying origin + +1. MUST call `window.open` synchronously inside a user gesture. `connectCrossOrigin` does so + before its first `await`. +2. MUST accept messages only from the window it opened, with `event.origin` equal to the key + origin. +3. MUST verify every returned event: the Schnorr signature, `pubkey` equals the grant's, and + kind, `created_at`, `content` and `tags` equal what was asked. A signer page is another + origin and is not trusted to return what it was asked for. +4. MUST treat a second `ready` after connection (the signer page reloaded) as revocation. +5. SHOULD `disconnect()` once it no longer needs fresh signatures. After login the NAP session + cookie carries the session, and the popup can close. + +## 6. Security analysis + +| Threat | Outcome | +| --- | --- | +| **§7.1 origin substitution**: a hostile page asks for a proof addressed to a real NAP server | Its browser-reported origin isn't in the policy, so it gets no reply at all. An allow-listed origin can only obtain proofs for its own audiences (rule 5.1.7). | +| XSS on an allow-listed relying origin | It can sign within the grant until the TTL: the same reach as script holding that origin's session cookie. It cannot step up silently (5.1.8), cannot reach other audiences, and cannot pre-sign (skew). State this to integrators as §28.5 does. It is not a defence against script on the relying origin. | +| Message forgery by a third window | Ignored: wrong `source` or `origin` on both sides. | +| Clickjacking consent | No answer and no UI when framed. Consent is in a top-level popup with the key origin's URL bar. | +| Signer page returns a different or forged event | The relying side verifies the signature and template (5.2.3). | +| Replay of a signed proof | Unchanged: §11/§12 challenge and time window. | +| Enumeration (§10.1, §19) | Strangers get silence. Every in-policy refusal shares `REFUSED`, and unknown grants share `REVOKED`. | +| Grant outliving the unlocked key | Revoked on lock, logout and identity change, including from other tabs via `BroadcastChannel`. Expiry is enforced by the signer page's clock, not the relying page's. | + +**Not addressed** (inherent, as in §7.1): there is still no user-presence bit in the event, +and a user who approves a phishing *allow-listed* origin is not protected. The policy is the +allow-list, and keeping it short is the defence. + +## 7. API (`@imani/nap-client-web`) + +```ts +// Key origin, on /nap/signer +import { mountCrossOriginSigner, createStorageConsentStore } from '@imani/nap-client-web'; + +const handle = mountCrossOriginSigner({ + policy: { + 'https://shop.example': { kinds: [27235], audiences: ['https://shop.example'], maxTtlSeconds: 900 }, + }, + signer, // any SessionSigner: in-page key, NIP-07, NIP-46 + ensureUnlocked: () => ui.unlock(), // throw to refuse + onConsent: (request) => ui.confirm(request), + onSign: (request) => request.reason === 'completion' ? ui.confirmStepUp(request) : Promise.resolve(true), + consentStore: createStorageConsentStore(), + onGrantsChanged: (grants) => ui.renderConnectedSites(grants), +}); +// session.onLock / onLogout → handle.revokeAll('lock' | 'logout') (also automatic via BroadcastChannel) + +// Relying origin, in a click handler +import { connectCrossOrigin, createNapSession } from '@imani/nap-client-web'; + +const grant = await connectCrossOrigin({ + keyOrigin: 'https://wallet.example', + kinds: [27235], + audiences: [location.origin], + expectedPubkey, // optional hint, e.g. from ?pubkey= +}); +const session = createNapSession({ baseUrl: location.origin, signer: grant.signer }); +await session.login(); +grant.disconnect(); // once fresh signatures aren't needed +``` + +`grant.signer` is a `SessionSigner` and passes the shared signer conformance suite as a +key-free signer (`isEvictableSigner` is false, `lockRecovery()` is `'unlock'`). + +## 8. Deployment notes + +- Signer page headers: `Cross-Origin-Opener-Policy: unsafe-none`, + `Content-Security-Policy: frame-ancestors 'none'`. +- Relying page COOP: `same-origin-allow-popups` or `unsafe-none`. `same-origin` on the relying + page also severs the popup. All four combinations are checked in Chromium by + `crossOrigin.browser.test.ts`. +- Keep the policy in code or signed configuration on the key origin. A policy the relying + origin can influence is no policy. +- Native apps (webviews) usually can't open a cross-origin popup with an opener. Use NIP-46 + there. +- Third-party storage partitioning doesn't matter here: the signer page is a first-party, + top-level window on the key origin. + +## 9. Why the core profile is untouched + +The server receives a kind-27235 event that is byte-for-byte what any other signer would +produce. It cannot tell, and does not need to tell, that the signature was made in another +window. So: + +- no new endpoint, field or tag. `supported_extensions` (§22.2) describes servers, and there is + nothing for a server to advertise; +- `nap-java` needs no change. Interop is unaffected; +- a deployment that never uses it carries none of it. + +## 10. Not in this version + +- **Iframe mode** (silent re-signing without a popup). It would need the Storage Access API in + partitioned browsers and has a clickjacking surface. It is left out until there is a need. +- **Signed, fetched policy.** Policy is code today. +- **Path/method scoping for NIP-98** beyond the `u` origin. +- **A relay transport for the same policy engine** (the key origin acting as a NIP-46 bunker), + which would also work across devices. diff --git a/package-lock.json b/package-lock.json index 05c0f43..cd2dea6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@types/node": "^22.10.2", "@types/pg": "^8.20.0", "@types/ws": "^8.18.1", + "playwright-core": "^1.62.1", "testcontainers": "^12.0.4", "tsx": "^4.19.2", "typescript": "^5.7.2", @@ -4741,6 +4742,19 @@ "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "license": "MIT" }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/postcss": { "version": "8.5.28", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", diff --git a/package.json b/package.json index 72c1c2a..7f7e616 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@types/node": "^22.10.2", "@types/pg": "^8.20.0", "@types/ws": "^8.18.1", + "playwright-core": "^1.62.1", "testcontainers": "^12.0.4", "tsx": "^4.19.2", "typescript": "^5.7.2", diff --git a/packages/nap-client-web/src/crossOriginClient.ts b/packages/nap-client-web/src/crossOriginClient.ts new file mode 100644 index 0000000..aafd5cf --- /dev/null +++ b/packages/nap-client-web/src/crossOriginClient.ts @@ -0,0 +1,338 @@ +import { nip19, verifyEvent, type EventTemplate } from 'nostr-tools'; +import type { Nip98Event } from '@imani/nap-core'; +import type { SessionSigner } from './types.js'; +import { + CROSS_ORIGIN_PROTOCOL, + CrossOriginSignerError, + DEFAULT_SIGNER_PATH, + normalizeOrigin, + parseMessage, + randomId, + type CrossOriginErrorCode, + type CrossOriginMessage, + type GrantInfo, + type MessageEventLike, + type MessageTarget, + type WindowLike, +} from './crossOriginProtocol.js'; + +export interface ConnectCrossOriginOptions { + /** The origin that holds the key, e.g. `https://wallet.example`. */ + keyOrigin: string; + /** Event kinds to ask for. Usually `[27235]`. */ + kinds: number[]; + /** Origins the signed events will be addressed to (`u` tag). Usually this page's own origin. */ + audiences: string[]; + /** + * The identity this page expects, as hex, e.g. from a `?pubkey=` hint. The key + * origin refuses to connect as anyone else, so a wallet signed in as B never + * silently acts for A. + */ + expectedPubkey?: string; + /** Path of the signer page on the key origin. Default `/nap/signer`. */ + signerPath?: string; + /** Third argument to `window.open`. Default a small popup. */ + windowFeatures?: string; + /** How long to wait for the signer page to load and announce itself. Default 15_000. */ + readyTimeoutMs?: number; + /** How long the user may take over unlock and consent. Default 300_000. */ + connectTimeoutMs?: number; + /** Bound on one signature, including any prompt on the key origin. Default 120_000. */ + requestTimeoutMs?: number; + /** Injectable for tests. Defaults to `window.open`. */ + open?: (url: string, target: string, features: string) => WindowLike | null; + /** Where messages arrive. Defaults to `window`. */ + target?: MessageTarget; + /** Seconds. Defaults to `Date.now() / 1000`. */ + now?: () => number; + /** How often to look for a closed popup, in ms. Default 500. */ + closedPollMs?: number; +} + +export interface CrossOriginGrant { + readonly info: GrantInfo; + /** A `SessionSigner` for `createNapSession`. Holds no key: every signature is made on the key origin. */ + readonly signer: SessionSigner; + /** False once revoked, expired, disconnected, or the signer window closed. */ + isActive(): boolean; + /** Called once, when the grant stops being usable, with the reason. */ + onEnd(listener: (code: CrossOriginErrorCode) => void): () => void; + /** Tell the key origin to drop the grant and close the signer window. */ + disconnect(): void; +} + +const DEFAULT_FEATURES = 'popup=yes,width=420,height=640'; + +interface Pending { + resolve(message: CrossOriginMessage): void; + reject(error: CrossOriginSignerError): void; + timer: ReturnType; +} + +function sameTemplate(template: EventTemplate, event: Nip98Event): boolean { + return ( + event.kind === template.kind && + event.created_at === template.created_at && + event.content === template.content && + JSON.stringify(event.tags) === JSON.stringify(template.tags) + ); +} + +/** + * Open the key origin's signer page and get a signing grant from it + * (extension 0002). + * + * **Call this from a user gesture** (a click handler), before any `await`: + * `window.open` runs synchronously here, and a popup opened after an await is + * blocked by every browser. + * + * The popup must stay open while the grant is used. After login, the NAP + * session cookie is what the page relies on, so `disconnect()` once you no + * longer need fresh signatures. + * + * ```ts + * button.onclick = async () => { + * const grant = await connectCrossOrigin({ + * keyOrigin: 'https://wallet.example', + * kinds: [27235], + * audiences: [location.origin], + * }); + * const session = createNapSession({ baseUrl: location.origin, signer: grant.signer }); + * await session.login(); + * }; + * ``` + */ +export function connectCrossOrigin(options: ConnectCrossOriginOptions): Promise { + let keyOrigin: string; + let audiences: string[]; + try { + keyOrigin = normalizeOrigin(options.keyOrigin); + audiences = options.audiences.map(normalizeOrigin); + } catch (error) { + return Promise.reject(error); + } + if (!options.kinds.length || !audiences.length) { + return Promise.reject(new Error('connectCrossOrigin needs at least one kind and one audience')); + } + + const now = options.now ?? (() => Math.floor(Date.now() / 1000)); + const readyTimeoutMs = options.readyTimeoutMs ?? 15_000; + const connectTimeoutMs = options.connectTimeoutMs ?? 300_000; + const requestTimeoutMs = options.requestTimeoutMs ?? 120_000; + const target = options.target ?? (globalThis as unknown as MessageTarget); + const open = + options.open ?? + ((url: string, name: string, features: string) => + (globalThis.open?.(url, name, features) as unknown as WindowLike | null) ?? null); + + const pending = new Map(); + const endListeners = new Set<(code: CrossOriginErrorCode) => void>(); + let popup: WindowLike | null = null; + let grant: GrantInfo | null = null; + let ended: CrossOriginErrorCode | null = null; + let readyResolve: (() => void) | null = null; + let ready = false; + let closedPoll: ReturnType | undefined; + + const post = (message: CrossOriginMessage) => { + // Exact target origin: if the popup has navigated somewhere else, the browser drops it. + popup?.postMessage(message, keyOrigin); + }; + + const end = (code: CrossOriginErrorCode) => { + if (ended) return; + ended = code; + if (closedPoll) clearInterval(closedPoll); + target.removeEventListener('message', listener); + for (const [id, entry] of pending) { + clearTimeout(entry.timer); + entry.reject(new CrossOriginSignerError(code)); + pending.delete(id); + } + for (const notify of endListeners) { + try { + notify(code); + } catch { + // A listener that throws must not stop the others. + } + } + endListeners.clear(); + }; + + const request = (message: CrossOriginMessage & { id: string }, timeoutMs: number): Promise => + new Promise((resolve, reject) => { + if (ended) { + reject(new CrossOriginSignerError(ended)); + return; + } + const timer = setTimeout(() => { + pending.delete(message.id); + reject(new CrossOriginSignerError('TIMEOUT')); + }, timeoutMs); + pending.set(message.id, { resolve, reject, timer }); + post(message); + }); + + function listener(event: MessageEventLike) { + // Only the window we opened, and only while it is still showing the key origin. + if (!popup || event.source !== popup || event.origin !== keyOrigin) return; + const message = parseMessage(event.data); + if (!message) return; + + switch (message.type) { + case 'ready': + if (!ready) { + ready = true; + readyResolve?.(); + } else if (grant) { + // The signer page reloaded: its grants lived in memory and are gone. + end('REVOKED'); + } + return; + case 'revoked': + if (grant && message.grantId === grant.grantId) end('REVOKED'); + return; + case 'connected': + case 'signed': + case 'error': { + if (!message.id) { + return; + } + const entry = pending.get(message.id); + if (!entry) return; + pending.delete(message.id); + clearTimeout(entry.timer); + if (message.type === 'error') { + entry.reject(new CrossOriginSignerError(message.code)); + if (message.code === 'REVOKED' || message.code === 'GRANT_EXPIRED') end(message.code); + } else { + entry.resolve(message); + } + return; + } + default: + return; + } + } + + target.addEventListener('message', listener); + + const url = new URL(options.signerPath ?? DEFAULT_SIGNER_PATH, keyOrigin).toString(); + // Synchronous, before any await, so the caller's user gesture still counts. + popup = open(url, '_blank', options.windowFeatures ?? DEFAULT_FEATURES); + if (!popup) { + target.removeEventListener('message', listener); + return Promise.reject(new CrossOriginSignerError('POPUP_BLOCKED')); + } + + closedPoll = setInterval(() => { + if (popup?.closed) end('CHANNEL_CLOSED'); + }, options.closedPollMs ?? 500); + + const isActive = () => !ended && grant !== null && now() < grant.expiresAt; + + const signer: SessionSigner = { + getNpub() { + if (!grant || !isActive()) { + throw new CrossOriginSignerError(ended ?? 'GRANT_EXPIRED'); + } + return nip19.npubEncode(grant.pubkey); + }, + async signEvent(template: EventTemplate): Promise { + if (!grant || ended) throw new CrossOriginSignerError(ended ?? 'REVOKED'); + if (now() >= grant.expiresAt) { + end('GRANT_EXPIRED'); + throw new CrossOriginSignerError('GRANT_EXPIRED'); + } + const reply = await request( + { nap: CROSS_ORIGIN_PROTOCOL, type: 'sign', id: randomId(), grantId: grant.grantId, template }, + requestTimeoutMs + ); + if (reply.type !== 'signed') throw new CrossOriginSignerError('SIGNER_ERROR'); + const event = reply.event; + // Trust nothing from the other window: it is a different origin, and a signer page + // that misbehaves must not get a forged or substituted event past this point. + if (event.pubkey !== grant.pubkey || !sameTemplate(template, event) || !verifyEvent(event as never)) { + throw new CrossOriginSignerError('SIGNER_ERROR'); + } + return event; + }, + }; + + const handle: CrossOriginGrant = { + get info() { + return grant!; + }, + signer, + isActive, + onEnd(notify) { + if (ended) { + notify(ended); + return () => {}; + } + endListeners.add(notify); + return () => endListeners.delete(notify); + }, + disconnect() { + if (grant && !ended) { + post({ nap: CROSS_ORIGIN_PROTOCOL, type: 'disconnect', grantId: grant.grantId }); + } + end('REVOKED'); + try { + (popup as { close?: () => void } | null)?.close?.(); + } catch { + // Already gone. + } + }, + }; + + return (async () => { + try { + await new Promise((resolve, reject) => { + if (ready) return resolve(); + const timer = setTimeout(() => reject(new CrossOriginSignerError('NOT_AVAILABLE')), readyTimeoutMs); + readyResolve = () => { + clearTimeout(timer); + resolve(); + }; + endListeners.add((code) => { + clearTimeout(timer); + reject(new CrossOriginSignerError(code)); + }); + }); + + const reply = await request( + { + nap: CROSS_ORIGIN_PROTOCOL, + type: 'connect', + id: randomId(), + kinds: [...options.kinds], + audiences, + ...(options.expectedPubkey ? { expectedPubkey: options.expectedPubkey } : {}), + }, + connectTimeoutMs + ); + if (reply.type !== 'connected') throw new CrossOriginSignerError('SIGNER_ERROR'); + + const info = reply.grant; + const granted = + info.kinds.every((kind) => options.kinds.includes(kind)) && + info.audiences.every((audience) => audiences.includes(audience)) && + (!options.expectedPubkey || info.pubkey === options.expectedPubkey); + if (!granted) throw new CrossOriginSignerError('SIGNER_ERROR'); + + grant = info; + endListeners.clear(); + return handle; + } catch (error) { + const code = error instanceof CrossOriginSignerError ? error.code : 'SIGNER_ERROR'; + end(code); + try { + (popup as { close?: () => void } | null)?.close?.(); + } catch { + // Already gone. + } + throw error instanceof CrossOriginSignerError ? error : new CrossOriginSignerError('SIGNER_ERROR', undefined, { cause: error }); + } + })(); +} diff --git a/packages/nap-client-web/src/crossOriginProtocol.ts b/packages/nap-client-web/src/crossOriginProtocol.ts new file mode 100644 index 0000000..80f7dd8 --- /dev/null +++ b/packages/nap-client-web/src/crossOriginProtocol.ts @@ -0,0 +1,329 @@ +import type { EventTemplate } from 'nostr-tools'; +import type { Nip98Event } from '@imani/nap-core'; + +/** + * Extension 0002 — cross-origin signer. The wire shared by both ends. + * + * Every message carries `nap: CROSS_ORIGIN_PROTOCOL`, so a page that also talks + * to other windows (analytics, payment iframes) cannot mistake their traffic for + * ours, and a future `/2` can be told apart without guessing. + * + * The sender's origin is NEVER in a message. It is whatever the browser reports + * as `MessageEvent.origin`, which the page that sent it cannot choose. That is + * the whole point of the extension: RFC §7.1 says the refusal has to come from a + * signer that knows who is really asking, and the page composes everything + * except that one field. + */ +export const CROSS_ORIGIN_PROTOCOL = 'nap-cross-origin-signer/1'; + +/** Default path of the signer page on the key origin. */ +export const DEFAULT_SIGNER_PATH = '/nap/signer'; + +/** Upper bound on a grant's lifetime, matching the default session TTL (§14, §28.4). */ +export const MAX_GRANT_TTL_SECONDS = 900; + +/** Clock skew tolerated on `created_at` before the signer refuses to sign. */ +export const DEFAULT_CREATED_AT_SKEW_SECONDS = 300; + +export type CrossOriginErrorCode = + /** No signer answered: wrong URL, origin not allowed, or the page never loaded. Deliberately one code (§19). */ + | 'NOT_AVAILABLE' + /** `window.open` returned null. Call `connectCrossOrigin` from a user gesture. */ + | 'POPUP_BLOCKED' + /** The user said no, to the connection or to one signature. */ + | 'DECLINED' + /** Outside the grant or the policy: kind, audience, `u` tag, `created_at`, or a malformed request. One code for all of them. */ + | 'REFUSED' + /** The grant outlived its TTL. Connect again. */ + | 'GRANT_EXPIRED' + /** The key origin ended the grant: locked, logged out, identity changed, or the user disconnected. */ + | 'REVOKED' + /** The signer window was closed. Connect again, from a user gesture. */ + | 'CHANNEL_CLOSED' + /** No answer in time. */ + | 'TIMEOUT' + /** The signer failed, or returned an event that does not match what was asked for. */ + | 'SIGNER_ERROR'; + +export class CrossOriginSignerError extends Error { + readonly code: CrossOriginErrorCode; + + constructor(code: CrossOriginErrorCode, message?: string, options?: { cause?: unknown }) { + super(message ?? code, options); + this.name = 'CrossOriginSignerError'; + this.code = code; + } +} + +/** What both sides know about a live grant. Contains no secret: `grantId` names it, it does not unlock it. */ +export interface GrantInfo { + grantId: string; + pubkey: string; + kinds: number[]; + audiences: string[]; + /** Unix seconds. */ + expiresAt: number; +} + +export type CrossOriginMessage = + | { nap: typeof CROSS_ORIGIN_PROTOCOL; type: 'ready' } + | { + nap: typeof CROSS_ORIGIN_PROTOCOL; + type: 'connect'; + id: string; + kinds: number[]; + audiences: string[]; + expectedPubkey?: string; + } + | { nap: typeof CROSS_ORIGIN_PROTOCOL; type: 'connected'; id: string; grant: GrantInfo } + | { nap: typeof CROSS_ORIGIN_PROTOCOL; type: 'sign'; id: string; grantId: string; template: EventTemplate } + | { nap: typeof CROSS_ORIGIN_PROTOCOL; type: 'signed'; id: string; event: Nip98Event } + | { nap: typeof CROSS_ORIGIN_PROTOCOL; type: 'error'; id?: string; code: CrossOriginErrorCode } + | { nap: typeof CROSS_ORIGIN_PROTOCOL; type: 'revoked'; grantId: string } + | { nap: typeof CROSS_ORIGIN_PROTOCOL; type: 'disconnect'; grantId: string }; + +/** The slice of `Window` this extension posts to. */ +export interface WindowLike { + postMessage(message: unknown, targetOrigin: string): void; + readonly closed?: boolean; +} + +/** The slice of `MessageEvent` this extension reads. */ +export interface MessageEventLike { + data: unknown; + origin: string; + source: unknown; +} + +export interface MessageTarget { + addEventListener(type: 'message', listener: (event: MessageEventLike) => void): void; + removeEventListener(type: 'message', listener: (event: MessageEventLike) => void): void; +} + +const ERROR_CODES: ReadonlySet = new Set([ + 'NOT_AVAILABLE', + 'POPUP_BLOCKED', + 'DECLINED', + 'REFUSED', + 'GRANT_EXPIRED', + 'REVOKED', + 'CHANNEL_CLOSED', + 'TIMEOUT', + 'SIGNER_ERROR', +]); + +const HEX64 = /^[0-9a-f]{64}$/; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isId(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && value.length <= 128; +} + +function isKindList(value: unknown): value is number[] { + return ( + Array.isArray(value) && + value.length > 0 && + value.length <= 32 && + value.every((kind) => Number.isInteger(kind) && kind >= 0 && kind <= 65535) + ); +} + +function isStringList(value: unknown): value is string[] { + return ( + Array.isArray(value) && + value.length > 0 && + value.length <= 32 && + value.every((entry) => typeof entry === 'string' && entry.length <= 512) + ); +} + +export function isEventTemplate(value: unknown): value is EventTemplate { + if (!isRecord(value)) return false; + const { kind, created_at, content, tags } = value; + return ( + Number.isInteger(kind) && + Number.isInteger(created_at) && + typeof content === 'string' && + content.length <= 65536 && + Array.isArray(tags) && + tags.length <= 64 && + tags.every( + (tag) => + Array.isArray(tag) && + tag.length > 0 && + tag.length <= 16 && + tag.every((part) => typeof part === 'string' && part.length <= 4096) + ) + ); +} + +function isGrantInfo(value: unknown): value is GrantInfo { + if (!isRecord(value)) return false; + return ( + isId(value.grantId) && + typeof value.pubkey === 'string' && + HEX64.test(value.pubkey) && + isKindList(value.kinds) && + isStringList(value.audiences) && + Number.isInteger(value.expiresAt) + ); +} + +/** + * Parse an untrusted message. Returns `null` for anything that is not exactly a + * message of this protocol, so callers have one branch for "ignore it". + * + * Structured clone means `data` can be any object graph, including ones with + * getters that throw. Every field is read once, type-checked, and copied into a + * fresh object, so nothing the other side sent is held by reference. + */ +export function parseMessage(data: unknown): CrossOriginMessage | null { + try { + if (!isRecord(data) || data.nap !== CROSS_ORIGIN_PROTOCOL || typeof data.type !== 'string') { + return null; + } + const nap = CROSS_ORIGIN_PROTOCOL; + + switch (data.type) { + case 'ready': + return { nap, type: 'ready' }; + case 'connect': { + if (!isId(data.id) || !isKindList(data.kinds) || !isStringList(data.audiences)) return null; + if (data.expectedPubkey !== undefined && !(typeof data.expectedPubkey === 'string' && HEX64.test(data.expectedPubkey))) { + return null; + } + return { + nap, + type: 'connect', + id: data.id, + kinds: [...data.kinds], + audiences: [...data.audiences], + ...(data.expectedPubkey ? { expectedPubkey: data.expectedPubkey } : {}), + }; + } + case 'connected': + if (!isId(data.id) || !isGrantInfo(data.grant)) return null; + return { + nap, + type: 'connected', + id: data.id, + grant: { + grantId: data.grant.grantId, + pubkey: data.grant.pubkey, + kinds: [...data.grant.kinds], + audiences: [...data.grant.audiences], + expiresAt: data.grant.expiresAt, + }, + }; + case 'sign': + if (!isId(data.id) || !isId(data.grantId) || !isEventTemplate(data.template)) return null; + return { + nap, + type: 'sign', + id: data.id, + grantId: data.grantId, + template: { + kind: data.template.kind, + created_at: data.template.created_at, + content: data.template.content, + tags: data.template.tags.map((tag) => [...tag]), + }, + }; + case 'signed': { + if (!isId(data.id) || !isRecord(data.event)) return null; + const raw = data.event; + if (!isEventTemplate(raw)) return null; + const event = raw as EventTemplate & { id?: unknown; pubkey?: unknown; sig?: unknown }; + if ( + typeof event.id !== 'string' || + typeof event.pubkey !== 'string' || + typeof event.sig !== 'string' + ) { + return null; + } + return { + nap, + type: 'signed', + id: data.id, + event: { + id: event.id, + pubkey: event.pubkey, + sig: event.sig, + kind: event.kind, + created_at: event.created_at, + content: event.content, + tags: event.tags.map((tag) => [...tag]), + } as Nip98Event, + }; + } + case 'error': + if (typeof data.code !== 'string' || !ERROR_CODES.has(data.code)) return null; + if (data.id !== undefined && !isId(data.id)) return null; + return { + nap, + type: 'error', + code: data.code as CrossOriginErrorCode, + ...(data.id ? { id: data.id } : {}), + }; + case 'revoked': + case 'disconnect': + if (!isId(data.grantId)) return null; + return { nap, type: data.type, grantId: data.grantId }; + default: + return null; + } + } catch { + return null; + } +} + +/** + * Normalise an origin: scheme + host + non-default port, nothing else. + * + * Throws on anything that is not a bare http(s) origin. A path, query or + * fragment in a policy entry means somebody thought it would be matched, and it + * would not be, so that is a wiring error rather than something to strip. + */ +export function normalizeOrigin(value: string): string { + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error(`'${value}' is not an origin`); + } + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + throw new Error(`'${value}' must use http or https`); + } + if (url.username || url.password || (url.pathname !== '/' && url.pathname !== '') || url.search || url.hash) { + throw new Error(`'${value}' must be an origin (scheme://host[:port]) with no path, query, userinfo or fragment`); + } + if (value.trim().replace(/\/$/, '') !== url.origin) { + // `new URL` silently fixes things like a trailing dot or uppercase; insist on the canonical form + // so what an operator reads in the policy is exactly what is compared. + throw new Error(`'${value}' is not in canonical form; use '${url.origin}'`); + } + return url.origin; +} + +/** The origin of an event's single `u` tag, or `null` if there is not exactly one parseable http(s) `u` tag. */ +export function audienceOf(template: EventTemplate): string | null { + const uTags = template.tags.filter((tag) => tag[0] === 'u'); + if (uTags.length !== 1 || typeof uTags[0][1] !== 'string') return null; + try { + const url = new URL(uTags[0][1]); + if (url.protocol !== 'https:' && url.protocol !== 'http:') return null; + if (url.username || url.password) return null; + return url.origin; + } catch { + return null; + } +} + +export function randomId(): string { + const bytes = new Uint8Array(16); + globalThis.crypto.getRandomValues(bytes); + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); +} diff --git a/packages/nap-client-web/src/crossOriginSigner.ts b/packages/nap-client-web/src/crossOriginSigner.ts new file mode 100644 index 0000000..d91a64e --- /dev/null +++ b/packages/nap-client-web/src/crossOriginSigner.ts @@ -0,0 +1,538 @@ +import type { EventTemplate } from 'nostr-tools'; +import { nip19 } from 'nostr-tools'; +import type { Nip98Event } from '@imani/nap-core'; +import type { SessionSigner } from './types.js'; +import { + CROSS_ORIGIN_PROTOCOL, + DEFAULT_CREATED_AT_SKEW_SECONDS, + MAX_GRANT_TTL_SECONDS, + audienceOf, + normalizeOrigin, + parseMessage, + randomId, + type CrossOriginErrorCode, + type CrossOriginMessage, + type GrantInfo, + type MessageEventLike, + type MessageTarget, + type WindowLike, +} from './crossOriginProtocol.js'; + +/** What one relying origin may ask for. Everything not listed is refused. */ +export interface CrossOriginPolicyEntry { + /** Event kinds this origin may have signed. Usually `[27235]` (NAP completion and NIP-98). */ + kinds: number[]; + /** + * Origins the signed events may be addressed to, matched exactly against the + * origin of the event's single `u` tag. Usually just the relying origin + * itself, plus its API host if that is separate. + */ + audiences: string[]; + /** Cap on this origin's grants, in seconds. Never above `MAX_GRANT_TTL_SECONDS`. */ + maxTtlSeconds?: number; +} + +/** Keyed by relying origin, exactly as the browser reports it (`https://shop.example`). */ +export type CrossOriginPolicy = Record; + +export interface ConsentRequest { + origin: string; + pubkey: string; + npub: string; + kinds: number[]; + audiences: string[]; + /** The identity the relying origin expected, when it said. Show a mismatch to the user. */ + expectedPubkey?: string; +} + +export interface SignRequest { + origin: string; + grant: GrantInfo; + template: EventTemplate; + /** + * `'completion'`: a NAP completion proof (it carries a `challenge` tag) after + * the one the connection was approved for. That is a step-up or a re-login, + * and RFC §10.3 says only a signer that prompts can make step-up mean + * anything. `'request'`: anything else inside the grant. + */ + reason: 'completion' | 'request'; +} + +/** + * Remembered "yes" answers, so a returning user is not asked again. Holds no + * secret: an origin, a pubkey and a scope. Revoking one is the "Connected + * sites" screen. + */ +export interface ConsentStore { + has(origin: string, pubkey: string, scope: string): boolean | Promise; + remember(origin: string, pubkey: string, scope: string): void | Promise; + forget(origin: string, pubkey?: string): void | Promise; + list(): ConsentRecord[] | Promise; +} + +export interface ConsentRecord { + origin: string; + pubkey: string; + scope: string; +} + +export type RevocationReason = 'lock' | 'logout' | 'identity-changed' | 'user' | 'shutdown'; + +export interface CrossOriginSignerOptions { + /** Which relying origins may connect, and to what. Validated now: a bad entry throws. */ + policy: CrossOriginPolicy; + /** + * The signer that holds (or reaches) the key. Any `SessionSigner`: an in-page + * key, NIP-07, or NIP-46. + */ + signer: SessionSigner; + /** Ask the user. Resolve `true` to approve. Not called when `consentStore` already has a matching yes. */ + onConsent(request: ConsentRequest): Promise; + /** + * Approve one signature. Called for every completion proof after the first one + * in a grant (step-up, re-login). Optional for everything else: if it is set it + * is called for `'request'` signatures too and may approve them silently. + * + * When it is not set, a second completion proof is refused rather than signed. + */ + onSign?(request: SignRequest): Promise; + /** Make sure the key can sign before asking for consent: show the unlock UI if it is locked. Throw to refuse. */ + ensureUnlocked?(): Promise; + consentStore?: ConsentStore; + /** Fired whenever the set of live grants changes, for a "Connected sites" view. */ + onGrantsChanged?(grants: readonly LiveGrant[]): void; + /** The window that opened this page. Defaults to `window.opener`. */ + opener?: WindowLike | null; + /** Where messages arrive. Defaults to `window`. */ + target?: MessageTarget; + /** + * True when this page is inside a frame. Defaults to `window.top !== window`. + * A framed signer page answers nothing: consent in a frame can be clickjacked. + */ + isFramed?: boolean; + /** Seconds. Defaults to `Date.now() / 1000`. */ + now?: () => number; + createdAtSkewSeconds?: number; + /** + * The `BroadcastChannel` the key origin's own `createNapSession` publishes on + * (its `broadcast.channelName`, default `'nap-session'`). A `lock`, `logout`, + * `shutdown` or `identity-changed` there ends every grant, so locking the + * wallet in another tab reaches the popup too. `false` to wire it yourself + * with `revokeAll()`. + */ + sessionChannel?: string | false; +} + +export interface LiveGrant extends GrantInfo { + origin: string; +} + +export interface CrossOriginSignerHandle { + /** Live grants, for a "Connected sites" view. */ + grants(): LiveGrant[]; + /** End one grant, or every grant for an origin. The relying origin is told. */ + revoke(grantIdOrOrigin: string, reason?: RevocationReason): void; + /** + * End every grant. Call it from the key origin's lock and logout handlers: + * a grant must not outlive the unlocked key it signs with (§28.4). + */ + revokeAll(reason: RevocationReason): void; + /** Stop listening and end every grant. */ + close(): void; +} + +interface GrantState extends LiveGrant { + /** The connection's own completion proof is covered by the consent; this tracks whether it was used. */ + completionUsed: boolean; + source: unknown; +} + +function scopeKey(kinds: number[], audiences: string[]): string { + return `${[...kinds].sort((a, b) => a - b).join(',')}|${[...audiences].sort().join(',')}`; +} + +function sameEvent(template: EventTemplate, event: Nip98Event): boolean { + return ( + event.kind === template.kind && + event.created_at === template.created_at && + event.content === template.content && + JSON.stringify(event.tags) === JSON.stringify(template.tags) + ); +} + +function pubkeyOfNpub(npub: string): string { + const decoded = nip19.decode(npub); + if (decoded.type !== 'npub') { + throw new Error('signer returned something other than an npub'); + } + return decoded.data as string; +} + +function validatePolicy(policy: CrossOriginPolicy): Map { + const entries = Object.entries(policy ?? {}); + if (entries.length === 0) { + // No empty-policy escape hatch: "everyone" is the state this extension exists to make unrepresentable. + throw new Error('Cross-origin signer policy must name at least one relying origin'); + } + + const result = new Map(); + for (const [origin, entry] of entries) { + const normalized = normalizeOrigin(origin); + if (!Array.isArray(entry.kinds) || entry.kinds.length === 0 || !entry.kinds.every(Number.isInteger)) { + throw new Error(`Cross-origin signer policy for ${origin} needs a non-empty list of integer kinds`); + } + if (!Array.isArray(entry.audiences) || entry.audiences.length === 0) { + throw new Error(`Cross-origin signer policy for ${origin} needs a non-empty list of audiences`); + } + const ttl = entry.maxTtlSeconds ?? MAX_GRANT_TTL_SECONDS; + if (!Number.isInteger(ttl) || ttl <= 0 || ttl > MAX_GRANT_TTL_SECONDS) { + throw new Error(`Cross-origin signer policy for ${origin}: maxTtlSeconds must be 1..${MAX_GRANT_TTL_SECONDS}`); + } + result.set(normalized, { + kinds: [...entry.kinds], + audiences: entry.audiences.map(normalizeOrigin), + maxTtlSeconds: ttl, + }); + } + return result; +} + +/** + * Run the key-origin end of extension 0002 on a signer page. + * + * Mount this on a dedicated page of the key origin (e.g. `/nap/signer`) that a + * relying origin opens as a popup. The page answers only: + * + * - its opener, and only when the opener's browser-reported origin is in the policy; + * - for kinds and `u`-tag audiences that the policy and the grant both allow; + * - while it is a top-level window, never inside a frame. + * + * Everything else is ignored without a reply, so a stranger cannot tell a + * missing signer page from a refusal (§19). + */ +export function mountCrossOriginSigner(options: CrossOriginSignerOptions): CrossOriginSignerHandle { + const policy = validatePolicy(options.policy); + const now = options.now ?? (() => Math.floor(Date.now() / 1000)); + const skew = options.createdAtSkewSeconds ?? DEFAULT_CREATED_AT_SKEW_SECONDS; + const browserWindow = typeof window === 'undefined' ? undefined : window; + const opener = + options.opener !== undefined ? options.opener : ((browserWindow?.opener as WindowLike | null | undefined) ?? null); + const target = options.target ?? (browserWindow as unknown as MessageTarget | undefined); + const isFramed = options.isFramed ?? (browserWindow ? browserWindow.top !== browserWindow.self : false); + const grants = new Map(); + let closed = false; + + const inert: CrossOriginSignerHandle = { + grants: () => [], + revoke() {}, + revokeAll() {}, + close() {}, + }; + + if (isFramed || !opener || !target) { + // Framed: consent could be clickjacked. No opener: opened directly, nobody to answer. + return inert; + } + + const reply = (origin: string, message: CrossOriginMessage) => { + try { + // Always the exact origin we checked, never '*'. + opener.postMessage(message, origin); + } catch { + // The opener navigated away or closed. Nothing to tell. + } + }; + + const fail = (origin: string, id: string | undefined, code: CrossOriginErrorCode) => + reply(origin, { nap: CROSS_ORIGIN_PROTOCOL, type: 'error', code, ...(id ? { id } : {}) }); + + const snapshot = (): LiveGrant[] => + [...grants.values()].map(({ grantId, origin, pubkey, kinds, audiences, expiresAt }) => ({ + grantId, + origin, + pubkey, + kinds: [...kinds], + audiences: [...audiences], + expiresAt, + })); + + const changed = () => options.onGrantsChanged?.(snapshot()); + + const dropGrant = (grant: GrantState, notify: boolean) => { + grants.delete(grant.grantId); + if (notify) { + reply(grant.origin, { nap: CROSS_ORIGIN_PROTOCOL, type: 'revoked', grantId: grant.grantId }); + } + }; + + const revokeAll = (_reason: RevocationReason) => { + if (grants.size === 0) return; + for (const grant of [...grants.values()]) dropGrant(grant, true); + changed(); + }; + + async function handleConnect(origin: string, source: unknown, message: Extract) { + const entry = policy.get(origin)!; + let audiences: string[]; + try { + audiences = message.audiences.map(normalizeOrigin); + } catch { + return fail(origin, message.id, 'REFUSED'); + } + const kinds = [...new Set(message.kinds)]; + audiences = [...new Set(audiences)]; + + if (!kinds.every((kind) => entry.kinds.includes(kind)) || !audiences.every((a) => entry.audiences.includes(a))) { + return fail(origin, message.id, 'REFUSED'); + } + + try { + await options.ensureUnlocked?.(); + } catch { + return fail(origin, message.id, 'DECLINED'); + } + + let pubkey: string; + let npub: string; + try { + npub = await options.signer.getNpub(); + pubkey = pubkeyOfNpub(npub); + } catch { + return fail(origin, message.id, 'SIGNER_ERROR'); + } + + const scope = scopeKey(kinds, audiences); + let approved = false; + try { + approved = (await options.consentStore?.has(origin, pubkey, scope)) ?? false; + if (!approved) { + approved = await options.onConsent({ + origin, + pubkey, + npub, + kinds: [...kinds], + audiences: [...audiences], + ...(message.expectedPubkey ? { expectedPubkey: message.expectedPubkey } : {}), + }); + if (approved) await options.consentStore?.remember(origin, pubkey, scope); + } + } catch { + approved = false; + } + if (closed) return; + if (!approved) return fail(origin, message.id, 'DECLINED'); + + // The user may have approved while looking at "you're signed in as B". Approving does not + // make B into A: the relying origin asked for A, and silently handing it B is the + // privilege transfer the identity guard exists to stop. + if (message.expectedPubkey && message.expectedPubkey !== pubkey) { + return fail(origin, message.id, 'REFUSED'); + } + + const grant: GrantState = { + grantId: randomId(), + origin, + pubkey, + kinds, + audiences, + expiresAt: now() + (entry.maxTtlSeconds ?? MAX_GRANT_TTL_SECONDS), + completionUsed: false, + source, + }; + grants.set(grant.grantId, grant); + changed(); + + const { grantId, expiresAt } = grant; + reply(origin, { + nap: CROSS_ORIGIN_PROTOCOL, + type: 'connected', + id: message.id, + grant: { grantId, pubkey, kinds: [...kinds], audiences: [...audiences], expiresAt }, + }); + } + + async function handleSign(origin: string, source: unknown, message: Extract) { + const grant = grants.get(message.grantId); + // A grant id from another origin, or another window, is the same as no grant: one answer. + if (!grant || grant.origin !== origin || grant.source !== source) { + return fail(origin, message.id, 'REVOKED'); + } + if (now() >= grant.expiresAt) { + dropGrant(grant, false); + changed(); + return fail(origin, message.id, 'GRANT_EXPIRED'); + } + + const { template } = message; + const audience = audienceOf(template); + if ( + !grant.kinds.includes(template.kind) || + audience === null || + !grant.audiences.includes(audience) || + Math.abs(template.created_at - now()) > skew + ) { + return fail(origin, message.id, 'REFUSED'); + } + + const isCompletion = template.tags.some((tag) => tag[0] === 'challenge'); + const needsPrompt = isCompletion && grant.completionUsed; + if (isCompletion && !grant.completionUsed) { + // Claimed before the await, so two racing completion requests cannot both ride the consent. + grant.completionUsed = true; + } + + if (needsPrompt || options.onSign) { + if (!options.onSign) return fail(origin, message.id, 'REFUSED'); + let ok = false; + try { + ok = await options.onSign({ + origin, + grant: { grantId: grant.grantId, pubkey: grant.pubkey, kinds: [...grant.kinds], audiences: [...grant.audiences], expiresAt: grant.expiresAt }, + template, + reason: needsPrompt ? 'completion' : 'request', + }); + } catch { + ok = false; + } + if (!ok) return fail(origin, message.id, 'DECLINED'); + if (!grants.has(grant.grantId)) return fail(origin, message.id, 'REVOKED'); + } + + let event: Nip98Event; + try { + event = await options.signer.signEvent(template); + } catch { + return fail(origin, message.id, 'SIGNER_ERROR'); + } + if (!grants.has(grant.grantId)) return fail(origin, message.id, 'REVOKED'); + + if (event.pubkey !== grant.pubkey) { + // The key behind the signer changed (account switch, re-paired bunker). Every grant + // names the old identity, so none of them can stand. + fail(origin, message.id, 'REVOKED'); + revokeAll('identity-changed'); + return; + } + if (!sameEvent(template, event)) { + return fail(origin, message.id, 'SIGNER_ERROR'); + } + + reply(origin, { nap: CROSS_ORIGIN_PROTOCOL, type: 'signed', id: message.id, event }); + } + + const listener = (event: MessageEventLike) => { + if (closed || event.source !== opener) return; + const origin = event.origin; + if (!policy.has(origin)) return; + const message = parseMessage(event.data); + if (!message) return; + + switch (message.type) { + case 'connect': + void handleConnect(origin, event.source, message); + return; + case 'sign': + void handleSign(origin, event.source, message); + return; + case 'disconnect': { + const grant = grants.get(message.grantId); + if (grant && grant.origin === origin) { + dropGrant(grant, false); + changed(); + } + return; + } + default: + return; + } + }; + + target.addEventListener('message', listener); + + let sessionBus: BroadcastChannel | undefined; + const channelName = options.sessionChannel ?? 'nap-session'; + if (channelName !== false && typeof BroadcastChannel !== 'undefined') { + sessionBus = new BroadcastChannel(channelName); + sessionBus.addEventListener('message', (event: MessageEvent) => { + // Bare string or `{type}`: the two shapes `broadcast.ts` sends. + const data = event.data as unknown; + const type = typeof data === 'string' ? data : (data as { type?: unknown } | null)?.type; + if (type === 'lock' || type === 'logout' || type === 'shutdown' || type === 'identity-changed') { + revokeAll(type); + } + }); + } + + // Announce to the opener, once per policy origin. The browser drops each post whose + // target origin is not the opener's, so a stranger who opened this page hears nothing. + for (const origin of policy.keys()) { + reply(origin, { nap: CROSS_ORIGIN_PROTOCOL, type: 'ready' }); + } + + return { + grants: snapshot, + revoke(grantIdOrOrigin, _reason = 'user') { + const matched = [...grants.values()].filter( + (grant) => grant.grantId === grantIdOrOrigin || grant.origin === grantIdOrOrigin + ); + if (matched.length === 0) return; + for (const grant of matched) dropGrant(grant, true); + changed(); + }, + revokeAll, + close() { + if (closed) return; + revokeAll('shutdown'); + closed = true; + target.removeEventListener('message', listener); + sessionBus?.close(); + }, + }; +} + +/** + * A `ConsentStore` over Web Storage (`localStorage` by default). + * + * Fine to keep in plain storage because there is nothing secret in it: knowing + * that `alice` once allowed `shop.example` lets nobody sign anything. What it + * buys is a returning user not being asked twice. + */ +export function createStorageConsentStore( + storage: Pick = globalThis.localStorage, + key = 'nap-cross-origin-consent' +): ConsentStore { + const read = (): ConsentRecord[] => { + try { + const parsed = JSON.parse(storage.getItem(key) ?? '[]') as unknown; + return Array.isArray(parsed) + ? parsed.filter( + (record): record is ConsentRecord => + typeof record?.origin === 'string' && typeof record?.pubkey === 'string' && typeof record?.scope === 'string' + ) + : []; + } catch { + return []; + } + }; + const write = (records: ConsentRecord[]) => { + try { + storage.setItem(key, JSON.stringify(records)); + } catch { + // Storage full or blocked: the user is simply asked again next time. + } + }; + + return { + has: (origin, pubkey, scope) => + read().some((record) => record.origin === origin && record.pubkey === pubkey && record.scope === scope), + remember(origin, pubkey, scope) { + const records = read().filter((record) => !(record.origin === origin && record.pubkey === pubkey)); + records.push({ origin, pubkey, scope }); + write(records); + }, + forget(origin, pubkey) { + write(read().filter((record) => !(record.origin === origin && (pubkey === undefined || record.pubkey === pubkey)))); + }, + list: read, + }; +} diff --git a/packages/nap-client-web/src/index.ts b/packages/nap-client-web/src/index.ts index ce466ec..e6d386b 100644 --- a/packages/nap-client-web/src/index.ts +++ b/packages/nap-client-web/src/index.ts @@ -23,6 +23,28 @@ export type { ReunlockOptions, ReunlockResult, } from './reunlock.js'; +export { + CROSS_ORIGIN_PROTOCOL, + CrossOriginSignerError, + DEFAULT_SIGNER_PATH, + MAX_GRANT_TTL_SECONDS, +} from './crossOriginProtocol.js'; +export type { CrossOriginErrorCode, GrantInfo } from './crossOriginProtocol.js'; +export { connectCrossOrigin } from './crossOriginClient.js'; +export type { ConnectCrossOriginOptions, CrossOriginGrant } from './crossOriginClient.js'; +export { createStorageConsentStore, mountCrossOriginSigner } from './crossOriginSigner.js'; +export type { + ConsentRecord, + ConsentRequest, + ConsentStore, + CrossOriginPolicy, + CrossOriginPolicyEntry, + CrossOriginSignerHandle, + CrossOriginSignerOptions, + LiveGrant, + RevocationReason, + SignRequest, +} from './crossOriginSigner.js'; export { IdentityMismatchError, isEvictableSigner } from './types.js'; export type { EvictableSigner } from './types.js'; export type { KeyStore, KeyHolder } from './keyStore.js'; diff --git a/packages/nap-client-web/test/crossOrigin.browser.test.ts b/packages/nap-client-web/test/crossOrigin.browser.test.ts new file mode 100644 index 0000000..734945a --- /dev/null +++ b/packages/nap-client-web/test/crossOrigin.browser.test.ts @@ -0,0 +1,221 @@ +/** + * Extension 0002 in a real browser, across two real origins. + * + * Opt-in: `NAP_BROWSER=1 npm test -- crossOrigin.browser`. Needs a Playwright + * Chromium (`npx playwright-core install chromium`). The fake windows in + * `crossOrigin.test.ts` model the browser; this checks the model against one, + * for the three things only a browser can say: + * + * - `event.origin` really is the sender's origin, and a page cannot choose it; + * - `postMessage(msg, origin)` really is dropped when the receiver is another origin; + * - `window.opener`, `window.open` and `top !== self` behave as the code assumes. + * + * `127.0.0.1` and `localhost` on one port are two origins, which is all this needs. + */ +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { build } from 'esbuild'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const enabled = process.env.NAP_BROWSER === '1'; + +const ENTRY = ` +import { generateSecretKey } from 'nostr-tools'; +import { bytesToHex } from '@imani/nap-core'; +import { connectCrossOrigin, mountCrossOriginSigner, createPrivateKeySessionSigner } from '@imani/nap-client-web'; + +const params = new URLSearchParams(location.search); +window.__nap = { connectCrossOrigin }; + +if (location.pathname === '/nap/signer') { + const relying = params.get('relying') ?? document.documentElement.dataset.relying; + const key = bytesToHex(generateSecretKey()); + window.__handle = mountCrossOriginSigner({ + policy: { [relying]: { kinds: [27235], audiences: [relying] } }, + signer: createPrivateKeySessionSigner(key), + onConsent: async (request) => { window.__consent = request; return true; }, + }); +} +`; + +describe.skipIf(!enabled)('extension 0002 in Chromium', () => { + let server: Server; + let port: number; + let browser: import('playwright-core').Browser; + let bundle = ''; + + const relyingOrigin = () => `http://127.0.0.1:${port}`; + const keyOrigin = () => `http://localhost:${port}`; + + beforeAll(async () => { + const result = await build({ + stdin: { contents: ENTRY, resolveDir: process.cwd(), loader: 'ts' }, + bundle: true, + format: 'esm', + platform: 'browser', + conditions: ['nap-source'], + write: false, + }); + bundle = result.outputFiles[0].text; + + server = createServer((req, res) => { + const url = new URL(req.url ?? '/', 'http://x'); + if (url.pathname === '/bundle.js') { + res.writeHead(200, { 'content-type': 'text/javascript' }); + res.end(bundle); + return; + } + const relying = `http://127.0.0.1:${port}`; + const coop = url.searchParams.get('coop'); + res.writeHead(200, { 'content-type': 'text/html', ...(coop ? { 'cross-origin-opener-policy': coop } : {}) }); + res.end(``); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + port = (server.address() as AddressInfo).port; + + const { chromium } = await import('playwright-core'); + browser = await chromium.launch(); + }); + + afterAll(async () => { + await browser?.close(); + await new Promise((resolve) => server?.close(resolve)); + }); + + async function relyingPage() { + const context = await browser.newContext(); + const page = await context.newPage(); + // localhost resolves to 127.0.0.1 here, but it is a different origin. + await page.goto(`${relyingOrigin()}/app`); + await page.waitForFunction(() => (window as unknown as { __nap?: unknown }).__nap); + return { context, page }; + } + + it('connects and signs across two real origins, and the event carries a valid signature', async () => { + const { context, page } = await relyingPage(); + const popupPromise = context.waitForEvent('page'); + + const result = page.evaluate(async ({ key, relying }) => { + const { connectCrossOrigin } = (window as unknown as { __nap: typeof import('../src/crossOriginClient.js') }).__nap; + const grant = await connectCrossOrigin({ keyOrigin: key, kinds: [27235], audiences: [relying] }); + const event = await grant.signer.signEvent({ + kind: 27235, + created_at: Math.floor(Date.now() / 1000), + content: '', + tags: [['u', `${relying}/auth/complete`], ['method', 'POST']], + }); + let refused = ''; + try { + await grant.signer.signEvent({ + kind: 27235, + created_at: Math.floor(Date.now() / 1000), + content: '', + tags: [['u', 'https://gateway.example/auth/complete'], ['method', 'POST']], + }); + } catch (error) { + refused = (error as { code?: string }).code ?? String(error); + } + return { pubkey: grant.info.pubkey, event, refused }; + }, { key: keyOrigin(), relying: relyingOrigin() }); + + const popup = await popupPromise; + expect(new URL(popup.url()).origin).toBe(keyOrigin()); + + const { pubkey, event, refused } = await result; + expect(event.pubkey).toBe(pubkey); + expect(event.sig).toMatch(/^[0-9a-f]{128}$/); + expect(refused).toBe('REFUSED'); + + const consent = await popup.evaluate(() => (window as unknown as { __consent: { origin: string } }).__consent); + // The origin the signer saw is the browser's, not anything the page sent. + expect(consent.origin).toBe(relyingOrigin()); + await context.close(); + }); + + it('a page on an origin outside the policy gets no answer at all', async () => { + const context = await browser.newContext(); + const page = await context.newPage(); + // The key origin itself, opening the signer page: not in the policy, which names only 127.0.0.1. + await page.goto(`${keyOrigin()}/app`); + await page.waitForFunction(() => (window as unknown as { __nap?: unknown }).__nap); + + const code = await page.evaluate(async (key) => { + const { connectCrossOrigin } = (window as unknown as { __nap: typeof import('../src/crossOriginClient.js') }).__nap; + try { + await connectCrossOrigin({ keyOrigin: key, kinds: [27235], audiences: [key], readyTimeoutMs: 1500 }); + return 'connected'; + } catch (error) { + return (error as { code?: string }).code ?? String(error); + } + }, keyOrigin()); + expect(code).toBe('NOT_AVAILABLE'); + await context.close(); + }); + + it('COOP: same-origin on the signer page severs the opener, so the handshake cannot happen', async () => { + const outcome = async (coop: string) => { + const { context, page } = await relyingPage(); + const code = await page.evaluate(async ({ key, relying, coop }) => { + const { connectCrossOrigin } = (window as unknown as { __nap: typeof import('../src/crossOriginClient.js') }).__nap; + try { + await connectCrossOrigin({ + keyOrigin: key, + kinds: [27235], + audiences: [relying], + signerPath: `/nap/signer?coop=${coop}`, + readyTimeoutMs: 1500, + }); + return 'connected'; + } catch (error) { + return (error as { code?: string }).code ?? String(error); + } + }, { key: keyOrigin(), relying: relyingOrigin(), coop }); + await context.close(); + return code; + }; + + // Recorded in the extension doc: the signer page must not send COOP same-origin. + expect(await outcome('same-origin')).not.toBe('connected'); + expect(await outcome('same-origin-allow-popups')).not.toBe('connected'); + expect(await outcome('unsafe-none')).toBe('connected'); + }); + + it('COOP on the relying page: same-origin-allow-popups works, same-origin does not', async () => { + const outcome = async (coop: string) => { + const context = await browser.newContext(); + const page = await context.newPage(); + await page.goto(`${relyingOrigin()}/app?coop=${coop}`); + await page.waitForFunction(() => (window as unknown as { __nap?: unknown }).__nap); + const code = await page.evaluate(async ({ key, relying }) => { + const { connectCrossOrigin } = (window as unknown as { __nap: typeof import('../src/crossOriginClient.js') }).__nap; + try { + await connectCrossOrigin({ keyOrigin: key, kinds: [27235], audiences: [relying], readyTimeoutMs: 1500 }); + return 'connected'; + } catch (error) { + return (error as { code?: string }).code ?? String(error); + } + }, { key: keyOrigin(), relying: relyingOrigin() }); + await context.close(); + return code; + }; + + expect(await outcome('same-origin')).not.toBe('connected'); + expect(await outcome('same-origin-allow-popups')).toBe('connected'); + }); + + it('a signer page inside an iframe stays silent', async () => { + const { context, page } = await relyingPage(); + const framed = await page.evaluate(async (key) => { + const frame = document.createElement('iframe'); + frame.src = `${key}/nap/signer`; + document.body.append(frame); + const heard: unknown[] = []; + addEventListener('message', (event) => heard.push(event.data)); + await new Promise((resolve) => frame.addEventListener('load', resolve)); + await new Promise((resolve) => setTimeout(resolve, 500)); + return heard.length; + }, keyOrigin()); + expect(framed).toBe(0); + await context.close(); + }); +}); diff --git a/packages/nap-client-web/test/crossOrigin.test.ts b/packages/nap-client-web/test/crossOrigin.test.ts new file mode 100644 index 0000000..390d1cb --- /dev/null +++ b/packages/nap-client-web/test/crossOrigin.test.ts @@ -0,0 +1,637 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { finalizeEvent, generateSecretKey, getPublicKey, nip19, type EventTemplate } from 'nostr-tools'; +import { bytesToHex, utf8Bytes } from '@imani/nap-core'; +import { + InMemoryChallengeStore, + InMemorySessionStore, + issueChallenge, + toPublicAuthSuccess, + verifyCompletion, + type NapServerOptions, +} from '@imani/nap-server'; +import { connectCrossOrigin, type CrossOriginGrant } from '../src/crossOriginClient.js'; +import { + CROSS_ORIGIN_PROTOCOL, + CrossOriginSignerError, + audienceOf, + normalizeOrigin, + parseMessage, +} from '../src/crossOriginProtocol.js'; +import { + createStorageConsentStore, + mountCrossOriginSigner, + type CrossOriginSignerHandle, + type CrossOriginSignerOptions, + type SignRequest, +} from '../src/crossOriginSigner.js'; +import { createNapSession } from '../src/session.js'; +import { createPrivateKeySessionSigner } from '../src/signers.js'; +import type { SessionSigner } from '../src/types.js'; +import { FakeWindow, flush, link, type WindowHandle } from './fakeWindows.js'; +import { runSignerConformance } from './signerConformance.js'; + +const SHOP = 'https://shop.example'; +const WALLET = 'https://wallet.example'; +const EVIL = 'https://evil.example'; + +const KEY = generateSecretKey(); +const PUBKEY = getPublicKey(KEY); +const NPUB = nip19.npubEncode(PUBKEY); + +const POLICY = { + [SHOP]: { kinds: [27235], audiences: [SHOP] }, +}; + +interface World { + relying: FakeWindow; + wallet: FakeWindow; + popup: WindowHandle; + opener: WindowHandle; + handle: () => CrossOriginSignerHandle; + connect: (extra?: Partial[0]>) => Promise; + signer: SessionSigner; + onConsent: ReturnType; +} + +/** + * A relying page on SHOP opening the wallet's signer page on WALLET. The signer page mounts on + * the tick after `window.open`, the way a real popup loads after the call returns. + */ +function world( + overrides: Partial = {}, + signer: SessionSigner = createPrivateKeySessionSigner(bytesToHex(KEY)), + relyingOrigin = SHOP +): World { + const relying = new FakeWindow(relyingOrigin); + const wallet = new FakeWindow(WALLET); + const { forward: popup, back: opener } = link(relying, wallet); + const onConsent = vi.fn(async () => true); + let mounted: CrossOriginSignerHandle | undefined; + + const mount = () => { + mounted = mountCrossOriginSigner({ + policy: POLICY, + signer, + onConsent, + opener, + target: wallet, + isFramed: false, + sessionChannel: false, + ...overrides, + }); + }; + + return { + relying, + wallet, + popup, + opener, + signer, + onConsent, + handle: () => mounted!, + connect: (extra = {}) => + connectCrossOrigin({ + keyOrigin: WALLET, + kinds: [27235], + audiences: [relyingOrigin], + target: relying, + closedPollMs: 5, + readyTimeoutMs: 200, + open: (url) => { + expect(url).toBe(`${WALLET}/nap/signer`); + setTimeout(mount, 0); + return popup; + }, + ...extra, + }), + }; +} + +function template(overrides: Partial = {}, u = `${SHOP}/auth/complete`): EventTemplate { + return { + kind: 27235, + created_at: Math.floor(Date.now() / 1000), + content: '', + tags: [ + ['u', u], + ['method', 'POST'], + ], + ...overrides, + }; +} + +function completionTemplate(u = `${SHOP}/auth/complete`): EventTemplate { + return { + ...template({}, u), + tags: [ + ['u', u], + ['method', 'POST'], + ['payload', 'p'], + ['challenge', 'c'], + ['challenge_id', 'id'], + ], + }; +} + +async function codeOf(promise: Promise): Promise { + try { + await promise; + return undefined; + } catch (error) { + return (error as CrossOriginSignerError).code; + } +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('protocol', () => { + it('ignores anything that is not exactly a message of this protocol', () => { + expect(parseMessage(null)).toBeNull(); + expect(parseMessage('ready')).toBeNull(); + expect(parseMessage({ type: 'ready' })).toBeNull(); + expect(parseMessage({ nap: 'nap-cross-origin-signer/2', type: 'ready' })).toBeNull(); + expect(parseMessage({ nap: CROSS_ORIGIN_PROTOCOL, type: 'connect', id: 'x', kinds: [], audiences: [SHOP] })).toBeNull(); + expect(parseMessage({ nap: CROSS_ORIGIN_PROTOCOL, type: 'error', code: 'MADE_UP' })).toBeNull(); + expect( + parseMessage({ nap: CROSS_ORIGIN_PROTOCOL, type: 'sign', id: 'x', grantId: 'g', template: { kind: 1, tags: 'no', content: '', created_at: 1 } }) + ).toBeNull(); + const hostile = { + nap: CROSS_ORIGIN_PROTOCOL, + get type() { + throw new Error('boom'); + }, + }; + expect(parseMessage(hostile)).toBeNull(); + }); + + it('copies what it parses instead of holding the sender’s objects', () => { + const tags = [['u', `${SHOP}/x`]]; + const parsed = parseMessage({ + nap: CROSS_ORIGIN_PROTOCOL, + type: 'sign', + id: 'x', + grantId: 'g', + template: { kind: 27235, created_at: 1, content: '', tags, extra: 'dropped' }, + }); + expect(parsed?.type).toBe('sign'); + if (parsed?.type !== 'sign') return; + expect(parsed.template.tags).not.toBe(tags); + expect(parsed.template.tags[0]).not.toBe(tags[0]); + expect('extra' in parsed.template).toBe(false); + }); + + it('accepts only canonical bare origins', () => { + expect(normalizeOrigin('https://shop.example')).toBe('https://shop.example'); + expect(normalizeOrigin('https://shop.example/')).toBe('https://shop.example'); + expect(() => normalizeOrigin('https://shop.example/path')).toThrow(); + expect(() => normalizeOrigin('https://SHOP.example')).toThrow(/canonical/); + expect(() => normalizeOrigin('https://user@shop.example')).toThrow(); + expect(() => normalizeOrigin('ftp://shop.example')).toThrow(); + expect(() => normalizeOrigin('shop.example')).toThrow(); + }); + + it('reads the audience from exactly one u tag', () => { + expect(audienceOf(template())).toBe(SHOP); + expect(audienceOf({ ...template(), tags: [['method', 'POST']] })).toBeNull(); + expect(audienceOf({ ...template(), tags: [['u', `${SHOP}/a`], ['u', `${EVIL}/b`]] })).toBeNull(); + expect(audienceOf({ ...template(), tags: [['u', 'javascript:alert(1)']] })).toBeNull(); + expect(audienceOf({ ...template(), tags: [['u', `https://x@${SHOP.slice(8)}/`]] })).toBeNull(); + }); +}); + +describe('policy validation', () => { + const base = { signer: createPrivateKeySessionSigner(bytesToHex(KEY)), onConsent: async () => true, opener: null, isFramed: false, sessionChannel: false as const }; + + it('refuses an empty policy rather than allowing everyone', () => { + expect(() => mountCrossOriginSigner({ ...base, policy: {} })).toThrow(/at least one/); + }); + + it('refuses entries that would not match what they look like', () => { + expect(() => mountCrossOriginSigner({ ...base, policy: { 'https://shop.example/app': POLICY[SHOP] } })).toThrow(); + expect(() => mountCrossOriginSigner({ ...base, policy: { [SHOP]: { kinds: [], audiences: [SHOP] } } })).toThrow(); + expect(() => mountCrossOriginSigner({ ...base, policy: { [SHOP]: { kinds: [27235], audiences: [] } } })).toThrow(); + expect(() => mountCrossOriginSigner({ ...base, policy: { [SHOP]: { kinds: [27235], audiences: [SHOP], maxTtlSeconds: 3600 } } })).toThrow(); + }); +}); + +describe('connect', () => { + it('connects, asks for consent once, and gets a grant scoped to what it asked for', async () => { + const w = world(); + const grant = await w.connect(); + + expect(grant.info.pubkey).toBe(PUBKEY); + expect(grant.info.kinds).toEqual([27235]); + expect(grant.info.audiences).toEqual([SHOP]); + expect(grant.isActive()).toBe(true); + expect(await grant.signer.getNpub()).toBe(NPUB); + expect(w.onConsent).toHaveBeenCalledTimes(1); + expect(w.onConsent.mock.calls[0][0]).toMatchObject({ origin: SHOP, pubkey: PUBKEY, npub: NPUB, kinds: [27235], audiences: [SHOP] }); + expect(w.handle().grants()).toHaveLength(1); + }); + + it('never posts with a wildcard target origin', async () => { + const w = world(); + const grant = await w.connect(); + await grant.signer.signEvent(template()); + + expect(w.opener.targetOrigins.every((origin) => origin === SHOP)).toBe(true); + expect(w.popup.targetOrigins.every((origin) => origin === WALLET)).toBe(true); + }); + + it('asks again only when there is nothing remembered', async () => { + const memory = new Map(); + const storage = { getItem: (k: string) => memory.get(k) ?? null, setItem: (k: string, v: string) => void memory.set(k, v) }; + const consentStore = createStorageConsentStore(storage); + + const first = world({ consentStore }); + await first.connect(); + expect(first.onConsent).toHaveBeenCalledTimes(1); + + const second = world({ consentStore }); + await second.connect(); + expect(second.onConsent).not.toHaveBeenCalled(); + + // A wider scope is a new question. + const widened = world({ consentStore, policy: { [SHOP]: { kinds: [27235, 1], audiences: [SHOP] } } }); + await widened.connect({ kinds: [27235, 1] }); + expect(widened.onConsent).toHaveBeenCalledTimes(1); + + await consentStore.forget(SHOP); + expect(await consentStore.list()).toEqual([]); + }); + + it('reports a declined consent as DECLINED and closes the popup', async () => { + const w = world({ onConsent: async () => false }); + expect(await codeOf(w.connect())).toBe('DECLINED'); + expect(w.popup.closed).toBe(true); + expect(w.handle().grants()).toHaveLength(0); + }); + + it('refuses kinds or audiences the policy does not give this origin', async () => { + expect(await codeOf(world().connect({ kinds: [27235, 4] }))).toBe('REFUSED'); + expect(await codeOf(world().connect({ audiences: [SHOP, EVIL] }))).toBe('REFUSED'); + }); + + it('unlocks before asking for consent, and a refused unlock is DECLINED', async () => { + const order: string[] = []; + const ok = world({ + ensureUnlocked: async () => void order.push('unlock'), + onConsent: async () => (order.push('consent'), true), + }); + await ok.connect(); + expect(order).toEqual(['unlock', 'consent']); + + const refused = world({ ensureUnlocked: async () => { throw new Error('cancelled'); } }); + expect(await codeOf(refused.connect())).toBe('DECLINED'); + expect(refused.onConsent).not.toHaveBeenCalled(); + }); + + it('will not connect as someone other than the expected identity', async () => { + const someoneElse = getPublicKey(generateSecretKey()); + const w = world(); + expect(await codeOf(w.connect({ expectedPubkey: someoneElse }))).toBe('REFUSED'); + expect(w.onConsent.mock.calls[0][0]).toMatchObject({ expectedPubkey: someoneElse, pubkey: PUBKEY }); + expect(w.handle().grants()).toHaveLength(0); + + const matching = world(); + const grant = await matching.connect({ expectedPubkey: PUBKEY }); + expect(grant.info.pubkey).toBe(PUBKEY); + }); + + it('reports a blocked popup without waiting', async () => { + const relying = new FakeWindow(SHOP); + const code = await codeOf( + connectCrossOrigin({ keyOrigin: WALLET, kinds: [27235], audiences: [SHOP], target: relying, open: () => null }) + ); + expect(code).toBe('POPUP_BLOCKED'); + }); + + it('gives up with NOT_AVAILABLE when nothing answers', async () => { + const relying = new FakeWindow(SHOP); + const wallet = new FakeWindow(WALLET); + const { forward } = link(relying, wallet); + const code = await codeOf( + connectCrossOrigin({ keyOrigin: WALLET, kinds: [27235], audiences: [SHOP], target: relying, readyTimeoutMs: 30, open: () => forward }) + ); + expect(code).toBe('NOT_AVAILABLE'); + }); + + it('gives up with CHANNEL_CLOSED when the user closes the popup mid-consent', async () => { + let release!: (value: boolean) => void; + const w = world({ onConsent: () => new Promise((resolve) => (release = resolve)) }); + const pending = w.connect(); + await flush(); + w.popup.close(); + expect(await codeOf(pending)).toBe('CHANNEL_CLOSED'); + release(true); + }); +}); + +describe('who may talk to the signer page', () => { + it('a stranger who opens the signer page hears nothing, not even that it exists', async () => { + const evil = new FakeWindow(EVIL); + const wallet = new FakeWindow(WALLET); + const { forward, back } = link(evil, wallet); + const onConsent = vi.fn(async () => true); + const signer = createPrivateKeySessionSigner(bytesToHex(KEY)); + + mountCrossOriginSigner({ policy: POLICY, signer, onConsent, opener: back, target: wallet, isFramed: false, sessionChannel: false }); + forward.postMessage({ nap: CROSS_ORIGIN_PROTOCOL, type: 'connect', id: 'x', kinds: [27235], audiences: [SHOP] }, WALLET); + forward.postMessage({ nap: CROSS_ORIGIN_PROTOCOL, type: 'connect', id: 'y', kinds: [27235], audiences: [EVIL] }, WALLET); + await flush(); + + expect(evil.received).toEqual([]); + expect(onConsent).not.toHaveBeenCalled(); + }); + + it('ignores an allowed origin that is not the opener', async () => { + const w = world(); + const grant = await w.connect(); + + // A second shop.example window (another tab) messaging the same signer page. + const otherTab = new FakeWindow(SHOP); + const { forward } = link(otherTab, w.wallet); + forward.postMessage( + { nap: CROSS_ORIGIN_PROTOCOL, type: 'sign', id: 'z', grantId: grant.info.grantId, template: template() }, + WALLET + ); + await flush(); + expect(otherTab.received).toEqual([]); + }); + + it('a framed signer page answers nothing, because consent in a frame can be clickjacked', async () => { + const w = world({ isFramed: true }); + expect(await codeOf(w.connect())).toBe('NOT_AVAILABLE'); + expect(w.onConsent).not.toHaveBeenCalled(); + }); + + it('a signer page opened directly, with no opener, answers nothing', () => { + const handle = mountCrossOriginSigner({ + policy: POLICY, + signer: createPrivateKeySessionSigner(bytesToHex(KEY)), + onConsent: async () => true, + opener: null, + target: new FakeWindow(WALLET), + isFramed: false, + sessionChannel: false, + }); + expect(handle.grants()).toEqual([]); + }); + + it('the relying page ignores messages from anything but the popup it opened', async () => { + const w = world(); + const grant = await w.connect(); + const impostor = new FakeWindow(WALLET); + const { forward } = link(impostor, w.relying); + forward.postMessage({ nap: CROSS_ORIGIN_PROTOCOL, type: 'revoked', grantId: grant.info.grantId }, SHOP); + await flush(); + expect(grant.isActive()).toBe(true); + }); +}); + +describe('signing inside a grant', () => { + it('signs a request addressed to an allowed audience, and the event verifies', async () => { + const w = world(); + const grant = await w.connect(); + const event = await grant.signer.signEvent(template()); + + expect(event.pubkey).toBe(PUBKEY); + expect(event.tags).toEqual(template().tags); + }); + + it('refuses a u tag outside the grant: the §7.1 origin-substitution attack', async () => { + const w = world(); + const grant = await w.connect(); + + expect(await codeOf(grant.signer.signEvent(template({}, 'https://gateway.example/auth/complete')))).toBe('REFUSED'); + expect(await codeOf(grant.signer.signEvent({ ...template(), tags: [['method', 'POST']] }))).toBe('REFUSED'); + expect(await codeOf(grant.signer.signEvent({ ...template(), tags: [['u', `${SHOP}/a`], ['u', `${EVIL}/b`]] }))).toBe('REFUSED'); + // Still usable for what it is for. + await expect(grant.signer.signEvent(template())).resolves.toBeTruthy(); + }); + + it('refuses a kind outside the grant', async () => { + const grant = await world().connect(); + expect(await codeOf(grant.signer.signEvent(template({ kind: 1 })))).toBe('REFUSED'); + }); + + it('refuses a created_at far from now, so a grant cannot pre-sign proofs for later', async () => { + const grant = await world().connect(); + const future = Math.floor(Date.now() / 1000) + 3600; + expect(await codeOf(grant.signer.signEvent(template({ created_at: future })))).toBe('REFUSED'); + }); + + it('refuses a grant id it did not issue, the same way as a revoked one', async () => { + const w = world(); + await w.connect(); + w.popup.postMessage({ nap: CROSS_ORIGIN_PROTOCOL, type: 'sign', id: 'q', grantId: 'forged', template: template() }, WALLET); + await flush(); + const reply = w.relying.received.map((event) => parseMessage(event.data)).find((message) => message?.type === 'error'); + expect(reply).toMatchObject({ type: 'error', id: 'q', code: 'REVOKED' }); + }); + + it('the first completion proof rides the consent, the second needs a prompt (step-up, §10.3)', async () => { + const noPrompt = await world().connect(); + await expect(noPrompt.signer.signEvent(completionTemplate())).resolves.toBeTruthy(); + expect(await codeOf(noPrompt.signer.signEvent(completionTemplate()))).toBe('REFUSED'); + + const onSign = vi.fn(async (_request: SignRequest) => true); + const prompted = await world({ onSign }).connect(); + await prompted.signer.signEvent(completionTemplate()); + await prompted.signer.signEvent(completionTemplate()); + expect(onSign.mock.calls.map(([request]) => request.reason)).toEqual(['request', 'completion']); + + const declined = await world({ onSign: async (request) => request.reason !== 'completion' }).connect(); + await declined.signer.signEvent(completionTemplate()); + expect(await codeOf(declined.signer.signEvent(completionTemplate()))).toBe('DECLINED'); + }); + + it('rejects an event the signer page returns that is not what was asked for', async () => { + const honest = createPrivateKeySessionSigner(bytesToHex(KEY)); + const tampering: SessionSigner = { + getNpub: () => honest.getNpub(), + signEvent: async (event) => honest.signEvent({ ...event, content: 'swapped' }), + }; + const grant = await world({}, tampering).connect(); + expect(await codeOf(grant.signer.signEvent(template()))).toBe('SIGNER_ERROR'); + }); + + it('the relying page verifies the signature itself', async () => { + const w = world(); + const grant = await w.connect(); + // Answer the next sign request with a forged signature, ahead of the real signer page. + const real = w.popup.postMessage.bind(w.popup); + w.popup.postMessage = (message: unknown, origin: string) => { + const parsed = parseMessage(message); + if (parsed?.type === 'sign') { + const forged = { ...finalizeEvent(parsed.template, KEY), sig: '0'.repeat(128) }; + setTimeout(() => w.relying.deliver({ origin: WALLET, source: w.popup, data: { nap: CROSS_ORIGIN_PROTOCOL, type: 'signed', id: parsed.id, event: forged } }), 0); + return; + } + real(message, origin); + }; + expect(await codeOf(grant.signer.signEvent(template()))).toBe('SIGNER_ERROR'); + }); +}); + +describe('ending a grant', () => { + it('revokeAll (the key origin locked or logged out) ends it on both sides', async () => { + const w = world(); + const grant = await w.connect(); + const ended = vi.fn(); + grant.onEnd(ended); + + w.handle().revokeAll('lock'); + await flush(); + + expect(grant.isActive()).toBe(false); + expect(ended).toHaveBeenCalledWith('REVOKED'); + expect(w.handle().grants()).toEqual([]); + expect(await codeOf(grant.signer.signEvent(template()))).toBe('REVOKED'); + }); + + it('a lock broadcast by the key origin’s own session reaches the popup', async () => { + const channel = `nap-test-${Math.random()}`; + const w = world({ sessionChannel: channel }); + const grant = await w.connect(); + + const tab = new BroadcastChannel(channel); + tab.postMessage('lock'); + await vi.waitFor(() => expect(grant.isActive()).toBe(false)); + tab.close(); + w.handle().close(); + }); + + it('revoke by origin, for a "Connected sites" screen', async () => { + const changes: number[] = []; + const w = world({ onGrantsChanged: (grants) => changes.push(grants.length) }); + const grant = await w.connect(); + w.handle().revoke(SHOP); + await flush(); + expect(grant.isActive()).toBe(false); + expect(changes).toEqual([1, 0]); + }); + + it('expires at the TTL without the key origin saying anything', async () => { + let clock = Math.floor(Date.now() / 1000); + const w = world({ now: () => clock, policy: { [SHOP]: { kinds: [27235], audiences: [SHOP], maxTtlSeconds: 60 } } }); + const grant = await w.connect({ now: () => clock }); + expect(grant.info.expiresAt).toBe(clock + 60); + + clock += 61; + expect(grant.isActive()).toBe(false); + expect(await codeOf(grant.signer.signEvent(template({ created_at: clock })))).toBe('GRANT_EXPIRED'); + expect(() => grant.signer.getNpub()).toThrow(CrossOriginSignerError); + }); + + it('the key origin enforces the TTL even against a relying page that ignores it', async () => { + let clock = Math.floor(Date.now() / 1000); + const w = world({ now: () => clock, policy: { [SHOP]: { kinds: [27235], audiences: [SHOP], maxTtlSeconds: 60 } } }); + // The relying page's clock never moves, so it keeps believing the grant is live. + const grant = await w.connect({ now: () => Math.floor(Date.now() / 1000) - 3600 }); + clock += 61; + expect(await codeOf(grant.signer.signEvent(template({ created_at: clock })))).toBe('GRANT_EXPIRED'); + expect(w.handle().grants()).toEqual([]); + }); + + it('disconnect tells the key origin and closes the popup', async () => { + const w = world(); + const grant = await w.connect(); + const sent: unknown[] = []; + const real = w.popup.postMessage.bind(w.popup); + w.popup.postMessage = (message: unknown, origin: string) => { + sent.push(message); + real(message, origin); + }; + grant.disconnect(); + expect(sent.map((message) => parseMessage(message)?.type)).toEqual(['disconnect']); + expect(w.popup.closed).toBe(true); + expect(grant.isActive()).toBe(false); + }); + + it('a disconnect message drops the grant on the key origin', async () => { + const w = world(); + const grant = await w.connect(); + w.popup.postMessage({ nap: CROSS_ORIGIN_PROTOCOL, type: 'disconnect', grantId: grant.info.grantId }, WALLET); + await flush(); + expect(w.handle().grants()).toEqual([]); + }); + + it('closing the popup ends the grant', async () => { + const w = world(); + const grant = await w.connect(); + w.popup.close(); + await vi.waitFor(() => expect(grant.isActive()).toBe(false)); + expect(await codeOf(grant.signer.signEvent(template()))).toBe('CHANNEL_CLOSED'); + }); + + it('an identity change behind the signer ends every grant', async () => { + const other = generateSecretKey(); + let current = KEY; + const switching: SessionSigner = { + getNpub: () => nip19.npubEncode(getPublicKey(current)), + signEvent: async (event) => finalizeEvent(event, current), + }; + const w = world({}, switching); + const grant = await w.connect(); + current = other; + expect(await codeOf(grant.signer.signEvent(template()))).toBe('REVOKED'); + await flush(); + expect(grant.isActive()).toBe(false); + expect(w.handle().grants()).toEqual([]); + }); +}); + +describe('a real NAP server cannot tell the difference', () => { + it('logs in end to end through the cross-origin signer, with core NAP unchanged', async () => { + const serverOptions: NapServerOptions = { + challengeStore: new InMemoryChallengeStore(), + sessionStore: new InMemorySessionStore(), + aclResolver: { resolve: async () => ({ allowed: true, roles: ['merchant'], permissions: ['subscription:buy'] }) }, + minAuthResponseMillis: 0, + }; + const authUrl = `${SHOP}/auth/complete`; + const fetchImpl = vi.fn(async (input, init) => { + const url = String(input); + if (url === `${SHOP}/auth/init`) { + const { npub } = JSON.parse(String(init?.body)) as { npub: string }; + const result = await issueChallenge({ npub, authUrl }, serverOptions); + return new Response(JSON.stringify(result.ok ? result.value : {}), { status: result.ok ? 200 : 401 }); + } + if (url === authUrl) { + const headers = init?.headers as Record; + const rawBody = utf8Bytes(String(init?.body)); + const result = await verifyCompletion({ authorization: headers.authorization, method: 'POST', url: authUrl, rawBody }, serverOptions); + return new Response(JSON.stringify(result.ok ? toPublicAuthSuccess(result.session) : {}), { status: result.ok ? 200 : 401 }); + } + return new Response('', { status: 404 }); + }); + + const grant = await world().connect(); + const session = createNapSession({ baseUrl: SHOP, signer: grant.signer, fetch: fetchImpl, broadcast: { enabled: false } }); + const response = await session.login(); + + expect(response.principal.pubkey).toBe(PUBKEY); + expect(response.permissions).toEqual(['subscription:buy']); + }); +}); + +// The grant's signer is a SessionSigner like any other. No rotateIdentity: a grant names one +// identity for its whole life, and a change behind it ends the grant (tested above) rather +// than letting the npub drift. +runSignerConformance({ + name: 'cross-origin signer (extension 0002)', + keyFree: true, + create: async () => { + // The conformance stub server lives on merchant.example.com and signs fixed timestamps. + const merchant = 'https://merchant.example.com'; + const w = world( + { createdAtSkewSeconds: Number.POSITIVE_INFINITY, policy: { [merchant]: { kinds: [27235], audiences: [merchant] } } }, + undefined, + merchant + ); + return (await w.connect()).signer; + }, +}); diff --git a/packages/nap-client-web/test/fakeWindows.ts b/packages/nap-client-web/test/fakeWindows.ts new file mode 100644 index 0000000..88d6393 --- /dev/null +++ b/packages/nap-client-web/test/fakeWindows.ts @@ -0,0 +1,80 @@ +import type { MessageEventLike, MessageTarget, WindowLike } from '../src/crossOriginProtocol.js'; + +/** + * Two windows on two origins, wired the way a browser wires an opener and its + * popup: `postMessage(data, targetOrigin)` is delivered only when + * `targetOrigin` matches the receiver's origin (or is `'*'`), the receiver sees + * the SENDER's origin in `event.origin` (which the sender cannot choose), and + * `event.source` is the receiver's handle on the sender. Data is structured- + * cloned and delivery is asynchronous, as it is in a browser. + */ +export class FakeWindow implements MessageTarget { + readonly origin: string; + private readonly listeners = new Set<(event: MessageEventLike) => void>(); + /** Every message this window received, for asserting what a stranger heard. */ + readonly received: MessageEventLike[] = []; + + constructor(origin: string) { + this.origin = origin; + } + + addEventListener(_type: 'message', listener: (event: MessageEventLike) => void): void { + this.listeners.add(listener); + } + + removeEventListener(_type: 'message', listener: (event: MessageEventLike) => void): void { + this.listeners.delete(listener); + } + + deliver(event: MessageEventLike): void { + this.received.push(event); + for (const listener of [...this.listeners]) listener(event); + } +} + +export interface WindowHandle extends WindowLike { + closed: boolean; + close(): void; + /** Every `targetOrigin` this handle was posted to with. */ + readonly targetOrigins: string[]; +} + +/** + * `from` gets a handle on `to`. Messages posted through it arrive at `to` with + * `origin = from.origin` and `source = back`, the handle `to` holds on `from`. + */ +export function link(from: FakeWindow, to: FakeWindow): { forward: WindowHandle; back: WindowHandle } { + let open = true; + const make = (sender: FakeWindow, receiver: FakeWindow): WindowHandle => { + const handle: WindowHandle = { + closed: false, + targetOrigins: [], + postMessage(message: unknown, targetOrigin: string) { + handle.targetOrigins.push(targetOrigin); + if (!open) return; + if (targetOrigin !== '*' && targetOrigin !== receiver.origin) return; + const data = structuredClone(message); + setTimeout(() => { + if (!open) return; + receiver.deliver({ data, origin: sender.origin, source: sender === from ? back : forward }); + }, 0); + }, + close() { + open = false; + forward.closed = true; + back.closed = true; + }, + }; + return handle; + }; + const forward = make(from, to); + const back = make(to, from); + return { forward, back }; +} + +/** Let every queued delivery and the promises behind it settle. */ +export async function flush(rounds = 10): Promise { + for (let i = 0; i < rounds; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} From 423cee5ec0d2198454862d597ab98808e5aa4672 Mon Sep 17 00:00:00 2001 From: tcheeric Date: Wed, 28 Oct 2026 11:22:34 +0000 Subject: [PATCH 2/2] release: 0.13.0 Bump every package and the sibling ^ranges to 0.13.0, refresh the lockfile, date the CHANGELOG entry, and add the 0.12.0 -> 0.13.0 notes to UPGRADING.md (bump the range; COOP headers only if adopting the cross-origin signer). Install examples in README, RELEASING, the guide and tutorial 01 now name ^0.13.0. --- CHANGELOG.md | 7 ++ CLAUDE.md | 2 +- README.md | 2 +- RELEASING.md | 8 +-- UPGRADING.md | 18 +++++ docs/NAP-INTEGRATION-GUIDE.md | 2 +- docs/tutorials/01-a-server-you-can-curl.md | 2 +- examples/merchant-app/package.json | 18 ++--- package-lock.json | 78 +++++++++++----------- package.json | 2 +- packages/nap-adapter-express/package.json | 6 +- packages/nap-adapter-fastify/package.json | 6 +- packages/nap-client-http/package.json | 4 +- packages/nap-client-nip46/package.json | 6 +- packages/nap-client-web/package.json | 6 +- packages/nap-core/package.json | 2 +- packages/nap-react/package.json | 4 +- packages/nap-server/package.json | 4 +- packages/nap-store-postgres/package.json | 6 +- packages/nap-voucher/package.json | 10 +-- scripts/release.mjs | 2 +- 21 files changed, 110 insertions(+), 85 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fa1c6b..316bf1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ All packages in this workspace share a single version. ## [Unreleased] +## [0.13.0] - 2026-10-28 + +Minor: one additive feature, no behaviour change for existing integrations. Every package +moves to 0.13.0 together, because the workspace shares one version, but only +`@imani/nap-client-web` has new code. In 0.x a caret range does not cross a minor, so a +consumer on `^0.12.0` must change it to `^0.13.0` to get this release. + ### Added - **Extension 0002, cross-origin signer** (`@imani/nap-client-web`, tcheeric/nap#54). With diff --git a/CLAUDE.md b/CLAUDE.md index 4b5ad1a..c29c01b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,7 +26,7 @@ published manifest. A new consumer of a sibling inside this repo must select it **The registry scope is one variable.** Code imports `@imani/nap-*` everywhere. `npm run release` publishes as `NAP_SCOPE` (default `@398ja`), writing sibling dependencies as npm -aliases (`"@imani/nap-core": "npm:@398ja/nap-core@^0.12.0"`) so the import names survive. +aliases (`"@imani/nap-core": "npm:@398ja/nap-core@^0.13.0"`) so the import names survive. Never hand-edit a package name to change scope. See `RELEASING.md`. ## Repository structure diff --git a/README.md b/README.md index ed12cf5..bbc8763 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ the root `package.json` is the single place to read it. > npm alias: > > ```bash -> npm install @imani/nap-client-web@npm:@398ja/nap-client-web@^0.12.0 nostr-tools +> npm install @imani/nap-client-web@npm:@398ja/nap-client-web@^0.13.0 nostr-tools > ``` > > See [RELEASING.md](RELEASING.md) for the scope and the release command. Inside this diff --git a/RELEASING.md b/RELEASING.md index f51d6cb..24ba3f2 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -11,16 +11,16 @@ The scope the packages are **published** under is a separate choice, made in one | `NAP_SCOPE` | Published as | Sibling dependency in the published manifest | | --- | --- | --- | -| `@398ja` (default) | `@398ja/nap-core`, ... | `"@imani/nap-core": "npm:@398ja/nap-core@^0.12.0"` | -| `@imani` | `@imani/nap-core`, ... | `"@imani/nap-core": "^0.12.0"` | +| `@398ja` (default) | `@398ja/nap-core`, ... | `"@imani/nap-core": "npm:@398ja/nap-core@^0.13.0"` | +| `@imani` | `@imani/nap-core`, ... | `"@imani/nap-core": "^0.13.0"` | With `@398ja` the published packages depend on each other through npm aliases, so the `@imani/nap-*` import names keep working with no rewrite of the JavaScript. A consumer declares the same aliases: ```json -"@imani/nap-client-web": "npm:@398ja/nap-client-web@^0.12.0", -"@imani/nap-react": "npm:@398ja/nap-react@^0.12.0" +"@imani/nap-client-web": "npm:@398ja/nap-client-web@^0.13.0", +"@imani/nap-react": "npm:@398ja/nap-react@^0.13.0" ``` and npm installs one copy of each: the consumer's alias and the transitive one resolve to the diff --git a/UPGRADING.md b/UPGRADING.md index 127aa50..eddf130 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -1,5 +1,23 @@ # Upgrading +## 0.12.0 to 0.13.0 + +Nothing to change in your code. 0.13.0 adds the cross-origin signer (extension 0002) to +`@imani/nap-client-web`. Existing sessions, signers and servers behave exactly as before. + +**Bump the range.** In 0.x, `^0.12.0` does not match 0.13.0, so update every `@398ja/nap-*` +alias together (the packages share one version): + +```json +"@imani/nap-client-web": "npm:@398ja/nap-client-web@^0.13.0", +"@imani/nap-react": "npm:@398ja/nap-react@^0.13.0" +``` + +**Only if you adopt the cross-origin signer:** follow +[extension 0002](./docs/extensions/0002-cross-origin-signer.md) §8 for the signer page headers. +The signer page must not send `Cross-Origin-Opener-Policy: same-origin`, and the relying page +must not send `same-origin`. + ## 0.11.0 to 0.12.0 No runtime behaviour changes. What changes is how you get the packages. diff --git a/docs/NAP-INTEGRATION-GUIDE.md b/docs/NAP-INTEGRATION-GUIDE.md index 69d4041..0cfefbf 100644 --- a/docs/NAP-INTEGRATION-GUIDE.md +++ b/docs/NAP-INTEGRATION-GUIDE.md @@ -1225,7 +1225,7 @@ existing resolver as `fallback` rather than wiring two servers. Ten npm-workspace packages sharing one version. Each ships compiled ESM and `.d.ts` from `dist/`; imported as `@imani/nap-*`, published as `@398ja/nap-*` -(install with an alias, e.g. `"@imani/nap-core": "npm:@398ja/nap-core@^0.12.0"`; +(install with an alias, e.g. `"@imani/nap-core": "npm:@398ja/nap-core@^0.13.0"`; see `RELEASING.md`). `nostr-tools`, `@noble/*` and `react` are peer dependencies. diff --git a/docs/tutorials/01-a-server-you-can-curl.md b/docs/tutorials/01-a-server-you-can-curl.md index 8631243..b87ffb4 100644 --- a/docs/tutorials/01-a-server-you-can-curl.md +++ b/docs/tutorials/01-a-server-you-can-curl.md @@ -22,7 +22,7 @@ The example lives in this repository, at `examples/merchant-app`. It is a real n workspace package, so it resolves `@imani/nap-*` straight from `packages/`. > **Starting your own app instead?** Install the published packages under their import -> names: `npm install @imani/nap-server@npm:@398ja/nap-server@^0.12.0`, and likewise for the +> names: `npm install @imani/nap-server@npm:@398ja/nap-server@^0.13.0`, and likewise for the > others you need (see `RELEASING.md`). The tutorials use the workspace copy so that what > you read is what CI tests. diff --git a/examples/merchant-app/package.json b/examples/merchant-app/package.json index 1298d11..809c584 100644 --- a/examples/merchant-app/package.json +++ b/examples/merchant-app/package.json @@ -1,7 +1,7 @@ { "name": "@imani/nap-example-merchant-app", "private": true, - "version": "0.12.0", + "version": "0.13.0", "type": "module", "description": "The runnable example that docs/tutorials/ is built around.", "exports": { @@ -16,13 +16,13 @@ "db:down": "docker compose down -v" }, "dependencies": { - "@imani/nap-adapter-express": "^0.12.0", - "@imani/nap-client-nip46": "^0.12.0", - "@imani/nap-client-web": "^0.12.0", - "@imani/nap-core": "^0.12.0", - "@imani/nap-react": "^0.12.0", - "@imani/nap-server": "^0.12.0", - "@imani/nap-store-postgres": "^0.12.0", + "@imani/nap-adapter-express": "^0.13.0", + "@imani/nap-client-nip46": "^0.13.0", + "@imani/nap-client-web": "^0.13.0", + "@imani/nap-core": "^0.13.0", + "@imani/nap-react": "^0.13.0", + "@imani/nap-server": "^0.13.0", + "@imani/nap-store-postgres": "^0.13.0", "express": "^4.21.2", "nostr-tools": "^2.23.0", "pg": "^8.13.1", @@ -30,7 +30,7 @@ "react-dom": "^19.0.0" }, "devDependencies": { - "@imani/nap-client-http": "^0.12.0", + "@imani/nap-client-http": "^0.13.0", "@types/express": "^5.0.0", "@types/pg": "^8.11.10", "@types/react": "^19.0.0", diff --git a/package-lock.json b/package-lock.json index cd2dea6..bf9819e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nap", - "version": "0.12.0", + "version": "0.13.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nap", - "version": "0.12.0", + "version": "0.13.0", "license": "MIT", "workspaces": [ "packages/*", @@ -26,15 +26,15 @@ }, "examples/merchant-app": { "name": "@imani/nap-example-merchant-app", - "version": "0.12.0", - "dependencies": { - "@imani/nap-adapter-express": "^0.12.0", - "@imani/nap-client-nip46": "^0.12.0", - "@imani/nap-client-web": "^0.12.0", - "@imani/nap-core": "^0.12.0", - "@imani/nap-react": "^0.12.0", - "@imani/nap-server": "^0.12.0", - "@imani/nap-store-postgres": "^0.12.0", + "version": "0.13.0", + "dependencies": { + "@imani/nap-adapter-express": "^0.13.0", + "@imani/nap-client-nip46": "^0.13.0", + "@imani/nap-client-web": "^0.13.0", + "@imani/nap-core": "^0.13.0", + "@imani/nap-react": "^0.13.0", + "@imani/nap-server": "^0.13.0", + "@imani/nap-store-postgres": "^0.13.0", "express": "^4.21.2", "nostr-tools": "^2.23.0", "pg": "^8.13.1", @@ -42,7 +42,7 @@ "react-dom": "^19.0.0" }, "devDependencies": { - "@imani/nap-client-http": "^0.12.0", + "@imani/nap-client-http": "^0.13.0", "@types/express": "^5.0.0", "@types/pg": "^8.11.10", "@types/react": "^19.0.0", @@ -7492,11 +7492,11 @@ }, "packages/nap-adapter-express": { "name": "@imani/nap-adapter-express", - "version": "0.12.0", + "version": "0.13.0", "license": "MIT", "dependencies": { - "@imani/nap-core": "^0.12.0", - "@imani/nap-server": "^0.12.0", + "@imani/nap-core": "^0.13.0", + "@imani/nap-server": "^0.13.0", "express": "^4.21.2" }, "devDependencies": { @@ -7515,11 +7515,11 @@ }, "packages/nap-adapter-fastify": { "name": "@imani/nap-adapter-fastify", - "version": "0.12.0", + "version": "0.13.0", "license": "MIT", "dependencies": { - "@imani/nap-core": "^0.12.0", - "@imani/nap-server": "^0.12.0", + "@imani/nap-core": "^0.13.0", + "@imani/nap-server": "^0.13.0", "cookie": "^1.0.2", "fastify": "^5.2.1" }, @@ -7547,10 +7547,10 @@ }, "packages/nap-client-http": { "name": "@imani/nap-client-http", - "version": "0.12.0", + "version": "0.13.0", "license": "MIT", "dependencies": { - "@imani/nap-core": "^0.12.0" + "@imani/nap-core": "^0.13.0" }, "devDependencies": { "nostr-tools": "^2.23.0" @@ -7567,11 +7567,11 @@ }, "packages/nap-client-nip46": { "name": "@imani/nap-client-nip46", - "version": "0.12.0", + "version": "0.13.0", "license": "MIT", "dependencies": { - "@imani/nap-client-web": "^0.12.0", - "@imani/nap-core": "^0.12.0" + "@imani/nap-client-web": "^0.13.0", + "@imani/nap-core": "^0.13.0" }, "devDependencies": { "nostr-tools": "^2.23.0" @@ -7588,11 +7588,11 @@ }, "packages/nap-client-web": { "name": "@imani/nap-client-web", - "version": "0.12.0", + "version": "0.13.0", "license": "MIT", "dependencies": { - "@imani/nap-client-http": "^0.12.0", - "@imani/nap-core": "^0.12.0" + "@imani/nap-client-http": "^0.13.0", + "@imani/nap-core": "^0.13.0" }, "devDependencies": { "nostr-tools": "^2.23.0" @@ -7609,7 +7609,7 @@ }, "packages/nap-core": { "name": "@imani/nap-core", - "version": "0.12.0", + "version": "0.13.0", "license": "MIT", "devDependencies": { "@noble/hashes": "^2.0.1", @@ -7628,10 +7628,10 @@ }, "packages/nap-react": { "name": "@imani/nap-react", - "version": "0.12.0", + "version": "0.13.0", "license": "MIT", "dependencies": { - "@imani/nap-client-web": "^0.12.0" + "@imani/nap-client-web": "^0.13.0" }, "devDependencies": { "@types/react": "^19.0.0", @@ -7653,10 +7653,10 @@ }, "packages/nap-server": { "name": "@imani/nap-server", - "version": "0.12.0", + "version": "0.13.0", "license": "MIT", "dependencies": { - "@imani/nap-core": "^0.12.0" + "@imani/nap-core": "^0.13.0" }, "devDependencies": { "nostr-tools": "^2.23.0" @@ -7673,11 +7673,11 @@ }, "packages/nap-store-postgres": { "name": "@imani/nap-store-postgres", - "version": "0.12.0", + "version": "0.13.0", "license": "MIT", "dependencies": { - "@imani/nap-core": "^0.12.0", - "@imani/nap-server": "^0.12.0", + "@imani/nap-core": "^0.13.0", + "@imani/nap-server": "^0.13.0", "pg": "^8.13.1" }, "peerDependencies": { @@ -7691,13 +7691,13 @@ }, "packages/nap-voucher": { "name": "@imani/nap-voucher", - "version": "0.12.0", + "version": "0.13.0", "license": "MIT", "devDependencies": { - "@imani/nap-adapter-express": "^0.12.0", - "@imani/nap-client-http": "^0.12.0", - "@imani/nap-core": "^0.12.0", - "@imani/nap-server": "^0.12.0", + "@imani/nap-adapter-express": "^0.13.0", + "@imani/nap-client-http": "^0.13.0", + "@imani/nap-core": "^0.13.0", + "@imani/nap-server": "^0.13.0", "@noble/curves": "^2.0.1", "@noble/hashes": "^2.0.1", "@types/supertest": "^6.0.3", diff --git a/package.json b/package.json index 7f7e616..8a5d72d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "nap", "private": true, - "version": "0.12.0", + "version": "0.13.0", "license": "MIT", "type": "module", "workspaces": [ diff --git a/packages/nap-adapter-express/package.json b/packages/nap-adapter-express/package.json index 7832848..f9b0040 100644 --- a/packages/nap-adapter-express/package.json +++ b/packages/nap-adapter-express/package.json @@ -1,6 +1,6 @@ { "name": "@imani/nap-adapter-express", - "version": "0.12.0", + "version": "0.13.0", "description": "Express adapter for @imani/nap-server.", "license": "MIT", "keywords": [ @@ -36,8 +36,8 @@ "dist" ], "dependencies": { - "@imani/nap-core": "^0.12.0", - "@imani/nap-server": "^0.12.0", + "@imani/nap-core": "^0.13.0", + "@imani/nap-server": "^0.13.0", "express": "^4.21.2" }, "peerDependencies": { diff --git a/packages/nap-adapter-fastify/package.json b/packages/nap-adapter-fastify/package.json index c08354b..430e322 100644 --- a/packages/nap-adapter-fastify/package.json +++ b/packages/nap-adapter-fastify/package.json @@ -1,6 +1,6 @@ { "name": "@imani/nap-adapter-fastify", - "version": "0.12.0", + "version": "0.13.0", "description": "Fastify adapter for @imani/nap-server.", "license": "MIT", "keywords": [ @@ -36,8 +36,8 @@ "dist" ], "dependencies": { - "@imani/nap-core": "^0.12.0", - "@imani/nap-server": "^0.12.0", + "@imani/nap-core": "^0.13.0", + "@imani/nap-server": "^0.13.0", "cookie": "^1.0.2", "fastify": "^5.2.1" }, diff --git a/packages/nap-client-http/package.json b/packages/nap-client-http/package.json index 9a332f2..6b307a4 100644 --- a/packages/nap-client-http/package.json +++ b/packages/nap-client-http/package.json @@ -1,6 +1,6 @@ { "name": "@imani/nap-client-http", - "version": "0.12.0", + "version": "0.13.0", "description": "HTTP request helpers for the NAP login flow. Runtime-agnostic.", "license": "MIT", "keywords": [ @@ -36,7 +36,7 @@ "dist" ], "dependencies": { - "@imani/nap-core": "^0.12.0" + "@imani/nap-core": "^0.13.0" }, "peerDependencies": { "nostr-tools": "^2.23.0", diff --git a/packages/nap-client-nip46/package.json b/packages/nap-client-nip46/package.json index feab1b4..72fa6a9 100644 --- a/packages/nap-client-nip46/package.json +++ b/packages/nap-client-nip46/package.json @@ -1,6 +1,6 @@ { "name": "@imani/nap-client-nip46", - "version": "0.12.0", + "version": "0.13.0", "description": "NIP-46 remote signer (bunker://, nostrconnect://) for NAP.", "license": "MIT", "keywords": [ @@ -36,8 +36,8 @@ "dist" ], "dependencies": { - "@imani/nap-client-web": "^0.12.0", - "@imani/nap-core": "^0.12.0" + "@imani/nap-client-web": "^0.13.0", + "@imani/nap-core": "^0.13.0" }, "peerDependencies": { "nostr-tools": "^2.23.0", diff --git a/packages/nap-client-web/package.json b/packages/nap-client-web/package.json index 9148086..4bed86c 100644 --- a/packages/nap-client-web/package.json +++ b/packages/nap-client-web/package.json @@ -1,6 +1,6 @@ { "name": "@imani/nap-client-web", - "version": "0.12.0", + "version": "0.13.0", "description": "Browser session client for NAP: NIP-07 signers, session lifecycle, idle lock, re-unlock.", "license": "MIT", "keywords": [ @@ -36,8 +36,8 @@ "dist" ], "dependencies": { - "@imani/nap-client-http": "^0.12.0", - "@imani/nap-core": "^0.12.0" + "@imani/nap-client-http": "^0.13.0", + "@imani/nap-core": "^0.13.0" }, "peerDependencies": { "nostr-tools": "^2.23.0", diff --git a/packages/nap-core/package.json b/packages/nap-core/package.json index 0a4ce5e..6126c53 100644 --- a/packages/nap-core/package.json +++ b/packages/nap-core/package.json @@ -1,6 +1,6 @@ { "name": "@imani/nap-core", - "version": "0.12.0", + "version": "0.13.0", "description": "Pure protocol helpers for the NAP (Nostr Authentication Protocol) HTTP profile. Browser and server.", "license": "MIT", "keywords": [ diff --git a/packages/nap-react/package.json b/packages/nap-react/package.json index 77c729b..2274142 100644 --- a/packages/nap-react/package.json +++ b/packages/nap-react/package.json @@ -1,6 +1,6 @@ { "name": "@imani/nap-react", - "version": "0.12.0", + "version": "0.13.0", "description": "React bindings for the NAP browser client.", "license": "MIT", "keywords": [ @@ -36,7 +36,7 @@ "dist" ], "dependencies": { - "@imani/nap-client-web": "^0.12.0" + "@imani/nap-client-web": "^0.13.0" }, "peerDependencies": { "react": "^19.0.0", diff --git a/packages/nap-server/package.json b/packages/nap-server/package.json index 9221ea2..5ec6e04 100644 --- a/packages/nap-server/package.json +++ b/packages/nap-server/package.json @@ -1,6 +1,6 @@ { "name": "@imani/nap-server", - "version": "0.12.0", + "version": "0.13.0", "description": "Stateful server-side NAP flow: challenges, completion, sessions, ACL.", "license": "MIT", "keywords": [ @@ -36,7 +36,7 @@ "dist" ], "dependencies": { - "@imani/nap-core": "^0.12.0" + "@imani/nap-core": "^0.13.0" }, "peerDependencies": { "nostr-tools": "^2.23.0", diff --git a/packages/nap-store-postgres/package.json b/packages/nap-store-postgres/package.json index 6c01093..bb8201b 100644 --- a/packages/nap-store-postgres/package.json +++ b/packages/nap-store-postgres/package.json @@ -1,6 +1,6 @@ { "name": "@imani/nap-store-postgres", - "version": "0.12.0", + "version": "0.13.0", "description": "Postgres stores for @imani/nap-server.", "license": "MIT", "keywords": [ @@ -36,8 +36,8 @@ "dist" ], "dependencies": { - "@imani/nap-core": "^0.12.0", - "@imani/nap-server": "^0.12.0", + "@imani/nap-core": "^0.13.0", + "@imani/nap-server": "^0.13.0", "pg": "^8.13.1" }, "peerDependencies": { diff --git a/packages/nap-voucher/package.json b/packages/nap-voucher/package.json index 47a67c4..1497118 100644 --- a/packages/nap-voucher/package.json +++ b/packages/nap-voucher/package.json @@ -1,6 +1,6 @@ { "name": "@imani/nap-voucher", - "version": "0.12.0", + "version": "0.13.0", "description": "Mint-backed (Cashu voucher) authorisation for NAP.", "license": "MIT", "keywords": [ @@ -47,10 +47,10 @@ } }, "devDependencies": { - "@imani/nap-adapter-express": "^0.12.0", - "@imani/nap-client-http": "^0.12.0", - "@imani/nap-core": "^0.12.0", - "@imani/nap-server": "^0.12.0", + "@imani/nap-adapter-express": "^0.13.0", + "@imani/nap-client-http": "^0.13.0", + "@imani/nap-core": "^0.13.0", + "@imani/nap-server": "^0.13.0", "@noble/curves": "^2.0.1", "@noble/hashes": "^2.0.1", "@types/supertest": "^6.0.3", diff --git a/scripts/release.mjs b/scripts/release.mjs index a4acd20..e1d843b 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -19,7 +19,7 @@ // published as `/nap-*`, and its dependencies on sibling packages are written // as npm aliases that KEEP the import name: // -// "@imani/nap-core": "npm:@398ja/nap-core@^0.12.0" +// "@imani/nap-core": "npm:@398ja/nap-core@^0.13.0" // // so the published JavaScript needs no rewriting, and a consumer that declares the // same aliases gets exactly one copy of each package: its own alias and the