Rewrite, switch to nostrconnect and MessageChannel, add checker iframe, clarify details, add mermaid diagrams

This commit is contained in:
artur 2024-11-13 11:39:22 +01:00
parent 2f199e1cdc
commit 609a4f260f

355
146.md
View File

@ -1,12 +1,12 @@
NIP-146 NIP-146
======= =======
Iframe-based Nostr Connect (INC) INC: iframe-based Nostr Connect
-------------------------------- -------------------------------
`draft` `optional` `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. 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 - `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 - `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 - `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 ## 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. This NIP covers interactions between `client` and `iframe signers`. Interactions of `signer` frames (top-level/iframes) are up to implementations (see Appendix for recommendations).
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.
## Starter iframe ## 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=<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<br/>and pass nostrconnect:// string
Starter -->> Starter: user gesture
Starter -->> Starter: import private key<br/>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 ## 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:<requestEvent.id>` to notify the `client`. 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:<requestEvent.id>` to notify the `client`.
If client receives `errorNoKey:<requestEvent.id>` reply matching one of pending requests, it MAY display a `rebinder iframe`. Rebinder iframe MUST be served when `nip46.iframe_url` has `&rebind=<client_pubkey>&pubkey=<remote_signer_pubkey>` parameters, and MUST display a button similar to starter iframe suggesting the users to `Continue`. If `client` receives `errorNoKey:<requestEvent.id>` 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=<client_pubkey>&pubkey=<remote_signer_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<br/>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 ## Client Pseudocode
### Worker iframe usage ### Starter iframe usage
``` ```
// client
const iframeOrigin = new URL(iframeUrl).origin; const iframeOrigin = new URL(iframeUrl).origin;
// helper // helper
const getReply = async () => { const getReply = async (label: string) => {
return new Promise(ok => { return new Promise(ok => {
const handler = (e) => { 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) window.removeEventListener("message", handler)
ok(e.data); ok(e.data?.[1]);
} }
window.addEventListener("message", handler) window.addEventListener("message", handler)
}) })
}; };
// create invisible iframe // nip46 nostrconnect string
const iframe = createIframe(iframeUrl, 'style="display: none"') const secret = "<random-value>";
const nostrconnect = createNostrConnect(secret);
// 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(...);
// display starter iframe // 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 // wait for starter to return valid connect reply or error
while (await getReply() !== "starterDone"); 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 // can delete starter
deleteIframe(iframe); 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 ### Rebinder iframe usage
``` ```
// worker iframe returns error // client
let workerReply = await getReply()
// worker iframe sends an error message
let workerReply = await getMessage(workerPort);
if (workerReply.startsWith("errorNoKey:")) { if (workerReply.startsWith("errorNoKey:")) {
// display starter iframe // display starter iframe
const iframe = createIframe(`${iframeUrl}?rebind=${clientPubkey}&pubkey=${remoteSignerPubkey}`, 'style="width: 180px; height: 80px') const iframe = createIframe(`${iframeUrl}?rebind=${clientPubkey}&pubkey=${remoteSignerPubkey}`, 'style="width: 180px; height: 80px')
// wait till it's done // wait for rebinder
while (await getReply() !== "rebinderDone"); await new Promise((ok, err) => {
getReply("rebinderDone").then(ok);
getReply("rebinderError").then(err);
})
// can delete rebinder // can delete rebinder
deleteIframe(iframe); deleteIframe(iframe);
// re-fetch reply // re-fetch reply
workerReply = await getReply(); workerReply = await getMessage(workerPort);
} }
// process worker reply // 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, "<random_target>", "<options>")`, `<random_target>` is required (instead of `_blank`) to make sure popup has access to `opener`, `noopener` SHOULD NOT be provided for the same reason. Recommendations for `signer` implementations:
- 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` 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`.
- Starter iframe SHOULD be provided access to `referrer` to let the signer use it as client name/url. - after user confirms in the `top-level signer` it MAY send the private key back to it's `window.opener`
- Starter iframe SHOULD check that `auth_url` has valid origin. - `signer` frames should check `origin` of received messages to make sure they're talking to each other
- 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. - popups SHOULD be opened using `window.open(url, "<random_target>", "<options>")`, `<random_target>` is required (instead of `_blank`) to make sure popup has access to `opener`, `noopener` SHOULD NOT be provided for the same reason.
- Client MAY save the `iframe_url` to local storage and reuse it until logout. - `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
- 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. - `iframe signers` MAY be sandboxed, but then MUST at least have `allow-scripts,allow-same-origin,allow-popups-to-escape-sandbox`
- 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. - `starter iframe` SHOULD be provided access to `referrer` to let the signer use it as client name/url.
- Top-level signer MAY export `local client pubkey` to iframe along with user private key to scope the iframe signer to this particular connection. - `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.