diff --git a/146.md b/146.md index a98e703..6742332 100644 --- a/146.md +++ b/146.md @@ -1,12 +1,12 @@ NIP-146 ======= -Iframe-based Nostr Connect (INC) --------------------------------- +INC: iframe-based Nostr Connect +------------------------------- `draft` `optional` -This NIP defines a way for web apps to communicate with web signers embedded as iframes using browser APIs. It is applicable to self-custodial signers that store keys in the browser's local storage. Basically, client app does nip-46 RPC with an iframe using [postMessage](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage). +This NIP defines a way for web apps to communicate with web signers embedded as iframes using browser APIs. It is applicable to self-custodial signers that store keys in the browser's local storage. Basically, client app does nip-46 RPC with an iframe using a [`MessageChannel`](https://developer.mozilla.org/en-US/docs/Web/API/MessageChannel). Due to privacy-related restrictions, local storage of iframes is [partitioned](https://developer.mozilla.org/en-US/docs/Web/Privacy/State_Partitioning) from top-frame's storage - iframe of signer.com can't see the storage of top-level signer.com and can't access user's private key. Solution to this challenge is the biggest part of this NIP. @@ -23,138 +23,359 @@ Disadvantage: potentially more frequent "confirmations", bigger surface for web- - `worker iframe`: iframe signer that handles nip46 calls from the client - `starter iframe`: iframe signer that launches the connection flow to get user confirmation and import keys from top-level signer to iframe signer - `rebinder iframe`: iframe signer that launches re-connection flow to check that client has access to keys at the top-level signer and to re-import the keys to iframe signer +- `checked iframe`: iframe signer that displays the nip46 `auth_url` returned by the signer ## Overview -When nip46 connection is established, `client` embeds invisible `worker iframe` and exchanges nip46 messages with it using `postMessage`. +Signer MAY signal support for this NIP by adding `nip46.iframe_url` field in their `/.well-known/nostr.json?name=_` file or their nip89 `kind:31990` event. -To overcome partitioned storage, when client initiates nip46 connection and receives `auth_url`, it shows the `starter iframe` with "Continue" button. After user clicks, starter iframe launches auth_url popup to access `top-level signer`. When user confirms, top-level signer sends the user private key back to starter iframe using postMessage. User private key can then be used by worker iframe. +To initiate a connection, `client` shows the `starter iframe`. After a [`user gesture`](https://developer.mozilla.org/en-US/docs/Web/API/UserActivation), `starter iframe` interacts with `top-level signer`, acquires user confirmation and imports the private key to `iframe signer` storage. -To overcome _ephemeral_ partitioned storage ([Brave](https://brave.com/privacy-updates/7-ephemeral-storage/), Webkit), when worker iframe receives a request but has no target keys, it pauses the call and sends an error code. Client shows `rebinder iframe` with "Continue" button. When user clicks, rebinder iframe launches top-level signer in a popup, which checks if client pubkey has access to the user pubkey and if so sends user private key back to rebinder iframe, without user having to confirm anything. +When connection is established and private key is imported to `iframe signer` storage, `client` embeds invisible `worker iframe` and exchanges nip46 messages with it using a `MessageChannel`. -## Worker iframe +If `worker iframe` receives a request but has no target keys due to _ephemeral_ partitioned storage ([Brave](https://brave.com/privacy-updates/7-ephemeral-storage/), Webkit), it pauses the call and sends an error code to the `client`, which then shows the `rebinder iframe`. After a `user gesture`, `rebinder iframe` interacts with `top-level signer` and if `client` is already approved - imports the private key to `iframe signer` storage. At this point `worker iframe` can retry the paused call and send the reply back to `client`. -Signer may signal support for this NIP by adding `nip46.iframe_url` field in their `/.well-known/nostr.json?name=_` file. If client detects a signer that supports iframes, after the connection has already been established, the client MAY create an invisible `worker iframe` with `src` set to `nip46.iframe_url`. +If `client` request must be confirmed by the user, `worker iframe` sends `auth_url` nip46 response, which is presented to the user as a visible `checker iframe`, instead of a popup as in nip46. This way `checker iframe` can access the request info stored by the `worker iframe`. After user confirms, `worker iframe` sends reply to `client`. -When worker iframe has started and is ready to process requests it must notify the client by calling `window.parent.postMessage("workerReady", "*")`. In turn, the client must subscribe to worker's messages with `window.addEventListener("message", handler)` before creating an iframe, and must wait for the message event with `data` set to `workerReady` and `origin` set to origin of `nip46.iframe_url` before sending requests. - -When `workerReady` is received, the client MAY start sending nip-46 requests to the worker by calling `workerIframe.postMessage(signedNip46RequestEvent, originOf(nip46.iframe_url))` - this will send the request event JS object to the worker iframe and will ensure it is only delivered if iframe still has proper origin (signer). - -Worker iframe processes the request and sends back the reply event by using `requestEvent.source.postMessage(signedNip46ResponseEvent, requestEvent.origin)`, this will deliver the reply to the client. +This NIP covers interactions between `client` and `iframe signers`. Interactions of `signer` frames (top-level/iframes) are up to implementations (see Appendix for recommendations). ## Starter iframe -Due to local storage partitioning, an iframe must import the needed key from the top-level storage of the signer. To do that, `iframe signer` must obtain a reference to a `top-level signer` and exchange messages. This fits well with the need to open top-level signer popup with `auth_url` of the nip-46 connection flow. However, this time the popup opener must be the `signer iframe`, and it must follow a [user gesture](https://developer.mozilla.org/en-US/docs/Web/API/UserActivation) in iframe. +To initiate a connection, a `starter iframe` MAY be embedded by the `client`. -To achieve that, a `starter iframe` MAY be embedded by the client when `auth_url` is received while the client is connecting. Starter iframe MUST be served when `nip46.iframe_url` has `?auth_url=` parameter, and MUST display a button suggesting the users to `Continue`. Recommended button dimentions SHOULD be up to `180px` width and `80px` height. +`starter iframe` MUST be served when `iframe_url` has `?connect=` parameter set to `nostrconnect://` string defined in nip46. -When user clicks on `Continue` button in starter iframe, it MUST open the popup with `auth_url` and MUST accept the user's private key if user confirms in the popup. Details are implementation-defined, see recommendations below. +`starter iframe` SHOULD display a button suggesting the users to `Continue`, recommended dimentions are up to `180px` width and `80px` height. -When starter iframe has finished importing the user private key from the popup, it MUST notify the app by calling `window.parent.postMessage("starterDone", "*")`. When client receives the `starterDone` from iframe_url's origin it MAY destroy the starter iframe and MAY create the `worker iframe` to start processing requests. +If user clicks `Continue`, `starter iframe` SHOULD create a `top-level signer` popup and interact with it to acquire the private key if user confirms, details are up to implementations. + +When `starter iframe` has finished importing the user private key, it MUST notify the `client` using [`postMessage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage). + +The message `data` will be an `Array` with a `starterDone` string and a nip46 `connect`-method reply object (created with a `secret` value from `nostrconnect://` string): + +``` +// starter iframe +const replyObject = await createConnectReply(nostrconnect_secret); +window.parent.postMessage(["starterDone", replyObject], "*"); +``` + +To receive messages from iframes, `client` listens to `message` events. + +When `client` receives a message whose `data` field is an `Array` AND has first element equal to `starterDone` AND has second element equal to valid `connect` reply object AND has message `origin` equal to `iframe_url` origin it MAY destroy the `starter iframe` and assume connection is established. + +`pubkey` from `connect` reply event is nip46 `remote signer pubkey`. + +``` +// client +window.addEventListener("message", (msg) => { + if (Array.isArray(msg.data) + && msg.data[0] === "starterDone" + && isValidConnectReply(msg.data[1], nostrconnect_secret) + && msg.origin === originOf(iframe_url) + ) { + const remote_signer_pubkey = msg.data[1].pubkey; + // destroy starter iframe + // may create worker iframe + } +}) +``` + +In case of error, `starter iframe` SHOULD send a message of `["starterError", "Error text"]`, so that client could notify the user. + +`client` SHOULD save the `iframe_url` to local storage and reuse it until logout, to make sure the `worker iframe` is loaded from the same origin that was used when connection was established. + +```mermaid +sequenceDiagram + participant Client as Client + participant Starter as Starter iframe + participant Worker as Worker iframe + + Note over Client,Starter: Client webpage + Client ->> Starter: embed starter iframe
and pass nostrconnect:// string + Starter -->> Starter: user gesture + Starter -->> Starter: import private key
from top-level signer + Starter ->> Client: nip46 connect reply + Client -->> Client: can remove starter iframe + Note over Starter,Worker: Key in iframe signer storage + Client -->> Worker: can make nip46 requests +``` + +## Worker iframe + +After connection has been established, `client` MAY create an invisible `worker iframe` served by `iframe_url`. + +When created, `worker iframe` MUST create a `MessageChannel` and pass one `port` to the client with a message `["workerReady", port]`: + +``` +// worker iframe +const channel = new MessageChannel(); +window.parent.postMessage(["workerReady", channel.port1], "*", [channel.port1]); +``` + +When `client` receives a message whose `data` field is an `Array` AND has first element equal to `workerReady` AND has message `origin` equal to `iframe_url` origin it MAY use the `port` from the second `data` element to send nip46 request event objects to it, and then MUST listen to replies from the same `port`: + + +``` +// client +window.addEventListener("message", (msg) => { + if (Array.isArray(msg.data) + && msg.data[0] === "workerReady" + && !!msg.data[1] + && msg.origin === originOf(iframe_url) + ) { + const worker_port = msg.data[1]; + // may send requests to worker_port + } +}) + +// when worker_port is received +worker_port.postMessage(nip46request); +worker_port.onmessage = (msg) => {...} +``` + +`worker iframe` will use the second `port` of the `channel` to similarly receive nip46 requests and pass `nip46` replies. + +`MessageChannel` is used instead of `window.postMessage` to let `worker iframe` pass it's port to it's request-processing component (service worker etc) and have `client` talk directly to it without additional latency. + +In case of initialization error, `worker iframe` MAY send a message of `["workerError", "Error text"]` instead of the `workerDone`, so that client could notify the user. + +```mermaid +sequenceDiagram + participant Client as Client + participant Worker as Worker iframe + + Note over Client,Worker: Client webpage + Client ->>+ Worker: embed worker iframe + Worker ->>- Client: return MessagePort + loop + Note over Client,Worker: MessageChannel + Client ->>+ Worker: nip46 request event object + Worker ->>- Client: nip46 reply event object + end +``` ## Rebinder iframe When `worker iframe` receives a nip46 request targeting user pubkey that it doesn't have (local storage deleted) it MAY pause the call and then MUST reply with a string `errorNoKey:` to notify the `client`. -If client receives `errorNoKey:` reply matching one of pending requests, it MAY display a `rebinder iframe`. Rebinder iframe MUST be served when `nip46.iframe_url` has `&rebind=&pubkey=` parameters, and MUST display a button similar to starter iframe suggesting the users to `Continue`. +If `client` receives `errorNoKey:` reply matching one of pending requests, it MAY display a `rebinder iframe`. -When user clicks on `Continue` button in rebinder iframe, it MUST open a `top-level signer` popup. The popup MUST check that provided `client_pubkey` has permissions to access `remote_signer_pubkey` and then MUST pass the user private key to rebinder iframe. Details are implementation-defined, see appendix for recommendations. +`rebinder iframe` MUST be served when `iframe_url` has `?rebind=&pubkey=` parameters. -After rebinder iframe has finished importing the user private key, it MUST notify the client by calling `window.parent.postMessage("rebinderDone", "*")`. When client receives the `rebinderDone` from iframe_url's origin it MAY destroy the rebinder iframe. +`rebinder iframe` SHOULD display a button suggesting the users to `Continue`, recommended dimentions are up to `180px` width and `80px` height. -When worker iframe that has paused the request notices that user private key was imported by rebinder into the local store, it MUST restart the paused call and deliver the reply to the client. +If user clicks `Continue`, `rebinder iframe` SHOULD create a `top-level signer` popup and interact with it to acquire the private key if `client_pubkey` is already permitted to access the `remote_signer_pubkey`, details are up to implementations. + +When `rebinder iframe` has finished importing the user private key, it MUST notify the `client` using `postMessage`. The message `data` will be `["rebinderDone"]`: + +``` +// rebinder iframe +window.parent.postMessage(["rebinderDone"], "*"); +``` + +When `client` receives a message whose `data` field is an `Array` AND has first element equal to `rebinderDone` AND has message `origin` equal to `iframe_url` origin it MAY destroy the `rebinder iframe`. + +``` +// client +window.addEventListener("message", (msg) => { + if (Array.isArray(msg.data) + && msg.data[0] === "rebinderDone" + && msg.origin === originOf(iframe_url) + ) { + // destroy rebinder iframe + } +}) +``` + +In case of error, `rebinder iframe` SHOULD send a message of `["rebinderError", "Error text"]`, so that client could notify the user. + +When `worker iframe` that has paused the request notices that user private key was imported by `rebinder iframe` into the local storage, it MUST restart the paused call and deliver the reply to the client. + +```mermaid +sequenceDiagram + participant Client + participant Worker as Worker iframe + participant Rebinder as Rebinder iframe + + Note over Client,Rebinder: Client webpage + Client ->>+ Worker: nip46 request + Worker -->> Worker: no target key? + Worker ->> Client: errorNoKey + Client ->>+ Rebinder: embed rebinder iframe + Rebinder -->> Rebinder: user gesture + Rebinder -->> Rebinder: import private key
from top-level signer + Note over Worker,Rebinder: Key in iframe signer storage + Rebinder ->>- Client: rebinder finished + Client -->> Client: can remove rebinder iframe + alt Keys imported successfully + Worker ->>- Client: nip46 reply + end +``` + +## Checker iframe + +If `client` request must be confirmed by the user, `worker iframe` MAY pause the call and then MUST reply with `auth_url` nip46 response. + +`auth_url` MAY be presented by `client` to the user, and in that case MUST be shown in a visible `checker iframe` instead of a popup as in nip46 to provide access to `iframe signer` storage. Recommended dimentions are minimum `300px` width and `600px` height. + +After user confirms the request in `checker iframe`, `worker iframe` MUST resume the paused call and deliver the reply to the client. + +After reply is received from `worker iframe`, `client` SHOULD destroy the `checker iframe`. + +```mermaid +sequenceDiagram + participant Client + participant Worker as Worker iframe + participant Checker as Checker iframe + + Note over Client,Checker: Client webpage + Client ->>+ Worker: nip46 request + Worker -->> Worker: need confirm? + Worker ->> Client: auth_url + Client ->>+ Checker: embed checker iframe + Note over Worker,Checker: Request in iframe signer storage + Checker -->> Checker: user confirms + Note over Worker,Checker: Reply in iframe signer storage + Worker ->>- Client: nip46 reply + Client -->> Client: can remove checker iframe +``` ## Client Pseudocode -### Worker iframe usage +### Starter iframe usage ``` +// client + const iframeOrigin = new URL(iframeUrl).origin; // helper -const getReply = async () => { +const getReply = async (label: string) => { return new Promise(ok => { const handler = (e) => { - if (e.origin !== iframeOrigin) return; + if (e.origin !== iframeOrigin || !Array.isArray(e.data) || e.data[0] !== label) return; window.removeEventListener("message", handler) - ok(e.data); + ok(e.data?.[1]); } window.addEventListener("message", handler) }) }; -// create invisible iframe -const iframe = createIframe(iframeUrl, 'style="display: none"') - -// wait for workerReady message -while (await getReply() !== "workerReady"); - -// send nip46 request event -const nip46Req = await createReq("sign_event", {...}); -iframe.contentWindow.postMessage(nip46Req, iframeOrigin); - -// get reply -const nip46Reply = await getReply(); - -// process reply -... -``` - -### Starter iframe usage - -``` -const authUrl = await connectGetAuthUrl(...); +// nip46 nostrconnect string +const secret = ""; +const nostrconnect = createNostrConnect(secret); // display starter iframe -const iframe = createIframe(`${iframeUrl}?auth_url=${authUrl}`, 'style="width: 180px; height: 80px') +const iframe = createIframe(`${iframeUrl}?connect=${nostrconnect}`, 'style="width: 180px; height: 80px') -// wait till it's done -while (await getReply() !== "starterDone"); +// wait for starter to return valid connect reply or error +const remoteSignerPubkey = await new Promise((ok, err) => { + getReply("starterDone").then(r => { + if (isValidConnectReply(r, secret)) ok(r.pubkey) + else err("Invalid connect reply") + }); + getReply("starterError").then(err); +}) // can delete starter deleteIframe(iframe); -// create worker to process requests - +// create worker iframe, +// use remoteSignerPubkey to send nip46 requests ... ``` +### Worker iframe usage + +``` +// client + +// create invisible iframe +const iframe = createIframe(iframeUrl, 'style="display: none"') + +// wait for worker to return a port or an error +const workerPort = await new Promise((ok, err) => { + getReply("workerDone").then(ok); + getReply("workerError").then(err); +}) + +// send nip46 request event +const nip46Req = await createReq("sign_event", {...}); +workerPort.postMessage(nip46req); +workerPort.onmessage = (reply) => { + // process reply +} +... +``` + + ### Rebinder iframe usage ``` -// worker iframe returns error -let workerReply = await getReply() +// client + +// worker iframe sends an error message +let workerReply = await getMessage(workerPort); if (workerReply.startsWith("errorNoKey:")) { // display starter iframe const iframe = createIframe(`${iframeUrl}?rebind=${clientPubkey}&pubkey=${remoteSignerPubkey}`, 'style="width: 180px; height: 80px') - // wait till it's done - while (await getReply() !== "rebinderDone"); + // wait for rebinder + await new Promise((ok, err) => { + getReply("rebinderDone").then(ok); + getReply("rebinderError").then(err); + }) // can delete rebinder deleteIframe(iframe); // re-fetch reply - workerReply = await getReply(); + workerReply = await getMessage(workerPort); } // process worker reply +... +``` +### Checker iframe usage + +``` +// client + +// worker iframe sends 'auth_url' error +let workerReply = await getMessage(workerPort); + +const authUrl = getAuthUrl(workerReply); +if (authUrl) { + + // display checker iframe + const iframe = createIframe(authUrl, 'style="min-width: 300px; min-height: 600px') + + // re-fetch reply + workerReply = await getMessage(workerPort); + + // can delete checker iframe + deleteIframe(iframe); +} + +// process worker reply ... ``` -## Recommendations +## Appendix -- Popups SHOULD be opened using `window.open(url, "", "")`, `` is required (instead of `_blank`) to make sure popup has access to `opener`, `noopener` SHOULD NOT be provided for the same reason. -- Iframes SHOULD NOT launch a popup until their service worker has started, it seems like Safari pauses iframe's SW initialization if the tab loses focus -- Starter/rebinder iframes MAY be sandboxed, but then MUST at least have `allow-scripts,allow-same-origin,allow-popups-to-escape-sandbox` -- Starter iframe SHOULD be provided access to `referrer` to let the signer use it as client name/url. -- Starter iframe SHOULD check that `auth_url` has valid origin. -- Starter/rebinder MAY use `MessageChannel` to create a port that will be transferred to top-level signer and to it's service-worker so that it could then export the user private key to the iframe signer. -- Client MAY save the `iframe_url` to local storage and reuse it until logout. -- Client MAY send normal nip-46 requests over relay in parallel to iframe requests, in which case it SHOULD deduplicate the replies across both channels. -- Signer SHOULD use random auxiliary sub-domains to serve `iframe_url` - Chrome desktop allows users to delete signer's storage on client's tab, which (mistakenly?) removes top-level signer storage too and users' keys might be lost. -- Top-level signer MAY export `local client pubkey` to iframe along with user private key to scope the iframe signer to this particular connection. +Recommendations for `signer` implementations: + +- `starter iframe` and `rebinder iframe` MAY open a `top-level signer` in a popup after user gesture (click on Continue) and MAY then send messages to it using `postMessage`. +- after user confirms in the `top-level signer` it MAY send the private key back to it's `window.opener` +- `signer` frames should check `origin` of received messages to make sure they're talking to each other +- popups SHOULD be opened using `window.open(url, "", "")`, `` is required (instead of `_blank`) to make sure popup has access to `opener`, `noopener` SHOULD NOT be provided for the same reason. +- `iframe signers` SHOULD NOT launch a popup until their service worker has started, it seems like Safari pauses iframe's SW initialization if the tab loses focus +- `iframe signers` MAY be sandboxed, but then MUST at least have `allow-scripts,allow-same-origin,allow-popups-to-escape-sandbox` +- `starter iframe` SHOULD be provided access to `referrer` to let the signer use it as client name/url. +- `iframe signers` MAY use `MessageChannel` to create a port that will be transferred to `top-level signer` and to it's service-worker so that it could then export the user private key to the `iframe signer`. +- `signer` SHOULD use random auxiliary sub-domains to serve `iframe_url` - Chrome desktop allows users to delete signer's storage on client's tab, which (mistakenly?) removes top-level signer storage too and users' keys might be lost. +- `top-level signer` MAY export `client pubkey` to `iframe signer` along with user private key to scope the `iframe signer` to this particular connection.