nips/146.md
2024-11-13 11:56:00 +01:00

16 KiB

NIP-146

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 a MessageChannel.

Due to privacy-related restrictions, local storage of iframes is partitioned 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.

Advantage: minimal latency, no relay involved, no reliance on unreliable webpush, fully cross-platform. User private key is as safe in iframe's local storage as in top-level storage due to browsers' cross-origin restrictions.

Disadvantage: potentially more frequent "confirmations", bigger surface for web-based hacks.

Terms

  • client: a web app trying to access user's keys
  • signer: non-custodial signer storing keys in it's local storage in the browser
  • top-level signer: signer opened in a separate tab/popup
  • iframe signer: signer opened as an iframe inside 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
  • 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

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 initiate a connection, client shows the starter iframe. After a user gesture, starter iframe interacts with top-level signer, acquires user confirmation and imports the private key to iframe signer storage.

After that client embeds invisible worker iframe and exchanges nip46 messages with it using a MessageChannel.

If worker iframe receives a request but has no target keys due to ephemeral partitioned storage (Brave, 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.

If request must be confirmed by the user, worker iframe sends auth_url nip46 response, which is presented by client to the user as a visible checker iframe, instead of a popup as in nip46. After user confirms, worker iframe sends reply to 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

To initiate a connection, a starter iframe MAY be embedded by the client.

starter iframe MUST be served when iframe_url has ?connect= parameter set to nostrconnect:// string defined in nip46.

starter iframe SHOULD display a button suggesting the users to Continue, recommended dimentions are up to 180px width and 80px height.

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.

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.

sequenceDiagram
  participant Client as Client
  participant Starter as Starter iframe
  participant Worker as Worker iframe

  Note over Client,Worker: 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.

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:<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 iframe_url has ?rebind=<client_pubkey>&pubkey=<remote_signer_pubkey> parameters.

rebinder iframe SHOULD display a button suggesting the users to Continue, recommended dimentions are up to 180px width and 80px height.

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.

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.

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
  Checker -->>- Worker: reply detected
  Worker ->>- Client: nip46 reply
  Client -->> Client: can remove checker iframe

Client Pseudocode

Starter iframe usage

// client

const iframeOrigin = new URL(iframeUrl).origin;

// helper
const getReply = async (label: string) => {
  return new Promise(ok => {
    const handler = (e) => {
      if (e.origin !== iframeOrigin || !Array.isArray(e.data) || e.data[0] !== label) return;
      window.removeEventListener("message", handler)
      ok(e.data?.[1]);
    }
    window.addEventListener("message", handler)
  })
};

// nip46 nostrconnect string
const secret = "<random-value>";
const nostrconnect = createNostrConnect(secret);

// display starter iframe 
const iframe = createIframe(`${iframeUrl}?connect=${nostrconnect}`, 'style="width: 180px; height: 80px')

// 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 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

// 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 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 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
...

Appendix

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, "<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.
  • 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.