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 keyssigner
: non-custodial signer storing keys in it's local storage in the browsertop-level signer
: signer opened in a separate tab/popupiframe signer
: signer opened as an iframe inside the clientworker iframe
: iframe signer that handles nip46 calls from the clientstarter iframe
: iframe signer that launches the connection flow to get user confirmation and import keys from top-level signer to iframe signerrebinder 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 signerchecked iframe
: iframe signer that displays the nip46auth_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.
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
.
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 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
.
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,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.
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
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
andrebinder iframe
MAY open atop-level signer
in a popup after user gesture (click on Continue) and MAY then send messages to it usingpostMessage
.- after user confirms in the
top-level signer
it MAY send the private key back to it'swindow.opener
signer
frames should checkorigin
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 toopener
,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 focusiframe signers
MAY be sandboxed, but then MUST at least haveallow-scripts,allow-same-origin,allow-popups-to-escape-sandbox
starter iframe
SHOULD be provided access toreferrer
to let the signer use it as client name/url.iframe signers
MAY useMessageChannel
to create a port that will be transferred totop-level signer
and to it's service-worker so that it could then export the user private key to theiframe signer
.signer
SHOULD use random auxiliary sub-domains to serveiframe_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 exportclient pubkey
toiframe signer
along with user private key to scope theiframe signer
to this particular connection.