mirror of
https://github.com/PR0M3TH3AN/bitlogin.git
synced 2026-11-10 15:31:27 +00:00
NIP-46 sign-in: remote-signer sessions behind the same facade, memory-only
Implements login-methods.md §LM5 phase one. Core gains nostr/nip46.ts -- a Nip46Client speaking kind-24133 RPC with NIP-44 transport encryption, bunker:// parsing, nostrconnect listen (secret echo is proof-of-scan), auth_url surfacing, and typed deadline errors -- plus subscribeLive on RelayConnection with re-REQ on reconnect, and live post-EOSE delivery in the MockRelay so tests exercise real round trips against a fake bunker. The ephemeral client key and Nip46Client live in the crypto worker (§LM5.4); nip46* actions bypass the serial queue so a signer waiting minutes for human approval cannot block other worker calls, and the signer's auth_url reaches the UI as the worker's first notification frame. The widget's connect screen runs both legs at once: a nostrconnect QR -- rendered as dot-style inline SVG with rounded finder eyes, per owner preference -- for phone signers, and a bunker:// paste box (password input; the URI carries a token). window.nostr is now an element-routed provider, so a NIP-46 session claims the slot and hosts reading it get the remote signer transparently; NIP-07 still leaves the slot to the extension. Sessions stay thin per §LM7: no vault, no persistence. Persistence remains gated on the external vault review (§LM5.3). 14 new tests (core fake-bunker e2e, QR geometry, signer delegation) and two policy pins: bunker option below the password path, element stays web-storage-free. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
1 parent
b616068689
commit
49682dd75c
23 files changed
+1625
-70
No files matched your search
+221
-29
@@ -9,18 +9,22 @@ import {
|
||||
import { encodeNpub } from "@bitlogin/core/nostr";
|
||||
import { randomUniformInt } from "@bitlogin/core/crypto";
|
||||
import { WorkerClient } from "./worker/workerClient.js";
|
||||
import { createNip07Provider, type Nip07Provider } from "./provider.js";
|
||||
import { createElementRoutedProvider, type Nip07Provider } from "./provider.js";
|
||||
import { readConfigFromElement } from "./config.js";
|
||||
import { WIDGET_STYLES } from "./styles.js";
|
||||
import { chooseWalletViaBitcoinConnect } from "./vault/bitcoinConnect.js";
|
||||
import type { VaultConnectionSummary } from "./worker/protocol.js";
|
||||
import { buildVaultIntegrityWarnings } from "./vault/integrityWarnings.js";
|
||||
import { detectForeignNip07Provider, Nip07Signer } from "./signers/nip07.js";
|
||||
import type { SignerCapabilities, SignerMethod } from "./signers/types.js";
|
||||
import { Nip46Signer } from "./signers/nip46.js";
|
||||
import type { Signer, SignerCapabilities, SignerMethod } from "./signers/types.js";
|
||||
import { renderQrSvg } from "./qr.js";
|
||||
|
||||
type Screen =
|
||||
| "welcome"
|
||||
| "extension-confirm"
|
||||
| "bunker-connect"
|
||||
| "bunker-confirm"
|
||||
| "import-key"
|
||||
| "create-name"
|
||||
| "create-credential"
|
||||
@@ -75,15 +79,22 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
|
||||
private session: { publicKey: string; npub: string; accountId?: string; method: SignerMethod } | null = null;
|
||||
|
||||
// ---- NIP-07 extension sessions (docs/login-methods.md §LM4, §LM7) ----
|
||||
// `pending` holds the signer between "the extension answered getPublicKey" and the
|
||||
// user confirming that npub is theirs; only `active` routes the public API. Thin by
|
||||
// design: no capsule, no vault, no persistence -- a reload simply signs the user out,
|
||||
// and the extension is asked again next visit.
|
||||
// ---- Alternative-method sessions (docs/login-methods.md §LM4, §LM5, §LM7) ----
|
||||
// `pending*` state holds a candidate between "the signer answered" and the user
|
||||
// confirming that npub is theirs; only `altSigner` routes the public API. Thin by
|
||||
// design: no capsule, no vault, no persistence -- a reload simply signs the user
|
||||
// out, and the signer is asked again next visit.
|
||||
private pendingExtensionSigner: Nip07Signer | null = null;
|
||||
private activeExtensionSigner: Nip07Signer | null = null;
|
||||
private altSigner: Signer | null = null;
|
||||
private extensionPreviewNpub = "";
|
||||
private extensionPreviewPubkey = "";
|
||||
|
||||
// NIP-46 connect flow (§LM5). The QR/copyable nostrconnect URI, the signer's
|
||||
// interactive-approval URL if one arrives mid-connect, and the confirmed-but-
|
||||
// not-yet-adopted user identity.
|
||||
private bunkerConnectUri = "";
|
||||
private bunkerAuthUrl = "";
|
||||
private pendingBunker: { userPubkey: string; npub: string } | null = null;
|
||||
private sessionWarnings: string[] = [];
|
||||
private lastSignedEventJson = "";
|
||||
private exportedNsec = "";
|
||||
@@ -149,6 +160,13 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
sheet.replaceSync(WIDGET_STYLES);
|
||||
this.root.adoptedStyleSheets = [sheet];
|
||||
this.worker = new WorkerClient();
|
||||
this.worker.onNotification = (notification) => {
|
||||
if (notification.notify === "nip46-auth-url") {
|
||||
// Arrives mid-connect, while nip46Connect is still pending (§LM5.1).
|
||||
this.bunkerAuthUrl = notification.url;
|
||||
this.render();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
@@ -164,7 +182,10 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
this.root.addEventListener("change", (e) => void this.onFileChange(e));
|
||||
this.render();
|
||||
|
||||
this.installedProvider = createNip07Provider(this.worker, () => this.vaultRelayUrls);
|
||||
// Routed through this element, not bound to the worker directly, so the
|
||||
// provider follows the ACTIVE signer -- worker for BitLogin accounts,
|
||||
// worker-held NIP-46 client for remote-signer sessions (§LM3, §LM5).
|
||||
this.installedProvider = createElementRoutedProvider(this, () => this.vaultRelayUrls);
|
||||
if (!(window as unknown as { nostr?: unknown }).nostr) {
|
||||
// See claimSigner()'s doc comment: an extension can make this property
|
||||
// non-configurable, which makes even this guarded assignment throw.
|
||||
@@ -187,11 +208,11 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
// always), the extension's provider for a NIP-07 session (§LM3). Hosts holding a
|
||||
// reference to this element get the right backend either way.
|
||||
async getPublicKey(): Promise<string> {
|
||||
if (this.activeExtensionSigner) return this.activeExtensionSigner.getPublicKey();
|
||||
if (this.altSigner) return this.altSigner.getPublicKey();
|
||||
return (await this.worker.getPublicKey()).publicKey;
|
||||
}
|
||||
async signEvent(event: { kind: number; tags?: string[][]; content: string; created_at?: number }) {
|
||||
if (this.activeExtensionSigner) return this.activeExtensionSigner.signEvent(event);
|
||||
if (this.altSigner) return this.altSigner.signEvent(event);
|
||||
return this.worker.signEvent(event);
|
||||
}
|
||||
/**
|
||||
@@ -203,11 +224,11 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
* racing other providers for that slot.
|
||||
*/
|
||||
async nip44Encrypt(peerPublicKey: string, plaintext: string): Promise<string> {
|
||||
if (this.activeExtensionSigner) return this.activeExtensionSigner.nip44Encrypt(peerPublicKey, plaintext);
|
||||
if (this.altSigner) return this.altSigner.nip44Encrypt(peerPublicKey, plaintext);
|
||||
return (await this.worker.nip44Encrypt({ peerPublicKey, plaintext })).ciphertext;
|
||||
}
|
||||
async nip44Decrypt(peerPublicKey: string, payload: string): Promise<string> {
|
||||
if (this.activeExtensionSigner) return this.activeExtensionSigner.nip44Decrypt(peerPublicKey, payload);
|
||||
if (this.altSigner) return this.altSigner.nip44Decrypt(peerPublicKey, payload);
|
||||
return (await this.worker.nip44Decrypt({ peerPublicKey, payload })).plaintext;
|
||||
}
|
||||
/**
|
||||
@@ -216,19 +237,23 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
* (or their older DM code paths) that still expect NIP-04 rather than NIP-44.
|
||||
*/
|
||||
async nip04Encrypt(peerPublicKey: string, plaintext: string): Promise<string> {
|
||||
if (this.activeExtensionSigner) return this.activeExtensionSigner.nip04Encrypt(peerPublicKey, plaintext);
|
||||
if (this.altSigner) return this.altSigner.nip04Encrypt(peerPublicKey, plaintext);
|
||||
return (await this.worker.nip04Encrypt({ peerPublicKey, plaintext })).ciphertext;
|
||||
}
|
||||
async nip04Decrypt(peerPublicKey: string, payload: string): Promise<string> {
|
||||
if (this.activeExtensionSigner) return this.activeExtensionSigner.nip04Decrypt(peerPublicKey, payload);
|
||||
if (this.altSigner) return this.altSigner.nip04Decrypt(peerPublicKey, payload);
|
||||
return (await this.worker.nip04Decrypt({ peerPublicKey, payload })).plaintext;
|
||||
}
|
||||
async logout(): Promise<void> {
|
||||
if (this.activeExtensionSigner) {
|
||||
// Nothing to tear down beyond our own reference: the session held no worker
|
||||
// state, persisted nothing, and never claimed window.nostr (§LM4).
|
||||
this.activeExtensionSigner = null;
|
||||
if (this.altSigner) {
|
||||
// Thin sessions persist nothing (§LM7). A NIP-46 session additionally
|
||||
// holds a live client in the worker to close, and claimed window.nostr
|
||||
// (its backend is ours) -- releaseSigner is a safe no-op for NIP-07,
|
||||
// which never claimed the slot (§LM4).
|
||||
if (this.altSigner.method === "nip46") void this.worker.nip46Disconnect();
|
||||
this.altSigner = null;
|
||||
this.session = null;
|
||||
this.releaseSigner();
|
||||
this.dispatchEvent(new CustomEvent("bitlogin-logout"));
|
||||
this.goto("welcome");
|
||||
return;
|
||||
@@ -259,7 +284,7 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
if (this.vaultRequest) throw new Error("A wallet connection request is already in progress.");
|
||||
// Extension sessions are signer-only (§LM7): no vault, and prompting a password
|
||||
// sign-in over an active session would be worse than an honest decline.
|
||||
if (this.activeExtensionSigner) return null;
|
||||
if (this.altSigner) return null;
|
||||
const origin = window.location.origin;
|
||||
const appName = options.appName?.trim() || window.location.hostname || "This app";
|
||||
return new Promise<string | null>((resolve) => {
|
||||
@@ -545,7 +570,7 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
* detail shape: which method granted the session and what it can do (§LM3). The
|
||||
* pre-existing `publicKey` field is unchanged for hosts written before `method`. */
|
||||
private dispatchLogin(): void {
|
||||
const capabilities: SignerCapabilities = this.activeExtensionSigner?.capabilities ?? {
|
||||
const capabilities: SignerCapabilities = this.altSigner?.capabilities ?? {
|
||||
nip44: true,
|
||||
nip04: true,
|
||||
getRelays: true
|
||||
@@ -716,6 +741,15 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
case "goto-welcome":
|
||||
this.pendingRollback = null;
|
||||
this.pendingExtensionSigner = null;
|
||||
// Backing out of an unfinished bunker connect abandons the worker-side
|
||||
// attempt -- but never a live session (altSigner) that's merely
|
||||
// navigating around.
|
||||
if (!this.altSigner && (this.bunkerConnectUri || this.pendingBunker)) {
|
||||
void this.worker.nip46Disconnect();
|
||||
}
|
||||
this.pendingBunker = null;
|
||||
this.bunkerConnectUri = "";
|
||||
this.bunkerAuthUrl = "";
|
||||
this.goto("welcome");
|
||||
return;
|
||||
case "extension-signin":
|
||||
@@ -723,6 +757,36 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
case "extension-continue":
|
||||
this.handleExtensionConfirm();
|
||||
return;
|
||||
case "goto-bunker":
|
||||
this.pendingBunker = null;
|
||||
this.bunkerConnectUri = "";
|
||||
this.bunkerAuthUrl = "";
|
||||
this.errorMessage = undefined;
|
||||
this.goto("bunker-connect");
|
||||
void this.startNostrconnect();
|
||||
return;
|
||||
case "bunker-continue":
|
||||
this.handleBunkerConfirm();
|
||||
return;
|
||||
case "copy-bunker-uri": {
|
||||
const button = target;
|
||||
const original = button.textContent ?? "Copy";
|
||||
const flash = (text: string) => {
|
||||
button.textContent = text;
|
||||
setTimeout(() => {
|
||||
if (button.isConnected) button.textContent = original;
|
||||
}, 2000);
|
||||
};
|
||||
if (!this.bunkerConnectUri || !navigator.clipboard?.writeText) {
|
||||
flash("Copy not available");
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(this.bunkerConnectUri).then(
|
||||
() => flash("Copied"),
|
||||
() => flash("Copy failed")
|
||||
);
|
||||
return;
|
||||
}
|
||||
case "goto-dashboard":
|
||||
this.goto("dashboard");
|
||||
return;
|
||||
@@ -811,6 +875,8 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
return await this.handleChangePasswordSubmit();
|
||||
case "vault-import":
|
||||
return await this.handleVaultImportSubmit();
|
||||
case "bunker-connect":
|
||||
return await this.handleBunkerSubmit();
|
||||
default:
|
||||
return;
|
||||
}
|
||||
@@ -994,7 +1060,7 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
return;
|
||||
}
|
||||
this.pendingExtensionSigner = null;
|
||||
this.activeExtensionSigner = signer;
|
||||
this.altSigner = signer;
|
||||
this.session = { publicKey: this.extensionPreviewPubkey, npub: this.extensionPreviewNpub, method: "nip07" };
|
||||
this.sessionWarnings = [];
|
||||
// A wallet request that was waiting on sign-in expected a BitLogin account; an
|
||||
@@ -1005,6 +1071,84 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
this.flashSuccess("dashboard", "Signed in");
|
||||
}
|
||||
|
||||
/**
|
||||
* nostrconnect leg of the remote-signer flow (§LM5.1): the worker mints an
|
||||
* ephemeral client key and a secret, we show the resulting URI as a QR for a
|
||||
* phone signer to scan, and the worker listens for whichever signer echoes
|
||||
* the secret. Runs alongside the paste form -- whichever leg completes first
|
||||
* wins, and the worker refuses a superseded listen.
|
||||
*/
|
||||
private async startNostrconnect(): Promise<void> {
|
||||
try {
|
||||
const { uri } = await this.worker.nip46NostrconnectStart({
|
||||
appName: window.location.hostname || "BitLogin"
|
||||
});
|
||||
if (this.screen !== "bunker-connect") return; // user navigated away
|
||||
this.bunkerConnectUri = uri;
|
||||
this.render();
|
||||
const { userPubkey } = await this.worker.nip46NostrconnectAwait();
|
||||
if (this.screen !== "bunker-connect" || this.pendingBunker) {
|
||||
// The paste leg (or another flow entirely) won while we listened.
|
||||
return;
|
||||
}
|
||||
this.adoptBunkerPreview(userPubkey);
|
||||
} catch (err) {
|
||||
// Only worth surfacing if the user is still looking at this screen and
|
||||
// nothing else succeeded -- a superseded or abandoned listen is expected.
|
||||
if (this.screen === "bunker-connect" && !this.pendingBunker && !this.session) {
|
||||
this.bunkerConnectUri = "";
|
||||
this.fail(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** bunker:// paste leg (§LM5.1). */
|
||||
private async handleBunkerSubmit(): Promise<void> {
|
||||
const uri = this.field("bunkerUri").trim();
|
||||
if (!uri) {
|
||||
this.errorMessage = "Paste a bunker:// address first.";
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
this.setBusy(true);
|
||||
this.bunkerAuthUrl = "";
|
||||
try {
|
||||
const { userPubkey } = await this.worker.nip46Connect({ uri });
|
||||
this.busy = false;
|
||||
this.adoptBunkerPreview(userPubkey);
|
||||
} catch (err) {
|
||||
this.fail(err);
|
||||
}
|
||||
}
|
||||
|
||||
private adoptBunkerPreview(userPubkey: string): void {
|
||||
this.pendingBunker = { userPubkey, npub: encodeNpub(userPubkey) };
|
||||
this.bunkerConnectUri = "";
|
||||
this.bunkerAuthUrl = "";
|
||||
this.errorMessage = undefined;
|
||||
this.goto("bunker-confirm");
|
||||
}
|
||||
|
||||
/** The user confirmed the identity their remote signer reported (§LM5). */
|
||||
private handleBunkerConfirm(): void {
|
||||
const pending = this.pendingBunker;
|
||||
if (!pending) {
|
||||
this.goto("welcome");
|
||||
return;
|
||||
}
|
||||
this.pendingBunker = null;
|
||||
this.altSigner = new Nip46Signer(this.worker, pending.userPubkey);
|
||||
this.session = { publicKey: pending.userPubkey, npub: pending.npub, method: "nip46" };
|
||||
this.sessionWarnings = [];
|
||||
if (this.vaultRequest) this.finishVaultRequest(null);
|
||||
// Unlike NIP-07 there is no extension owning window.nostr here -- the
|
||||
// element-routed provider IS this session's public surface, so claim the
|
||||
// slot (best-effort, §LM4's claim semantics unchanged).
|
||||
this.noteSignerClaim(this.claimSigner());
|
||||
this.dispatchLogin();
|
||||
this.flashSuccess("dashboard", "Signed in");
|
||||
}
|
||||
|
||||
/**
|
||||
* Called once per connectedCallback, right after "configure" -- restores whatever
|
||||
* a prior login/register/rotate cached locally (§21), so a page reload lands
|
||||
@@ -1253,6 +1397,7 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
<button class="link" data-action="goto-import">Import an existing Nostr key</button>
|
||||
<button class="link" data-action="goto-recover">Forgot password? Recover with phrase</button>
|
||||
${extensionOption}
|
||||
<button class="link" data-action="goto-bunker">Use a remote signer (bunker)</button>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1267,6 +1412,47 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
<button class="link" data-action="goto-welcome">Back</button>
|
||||
`;
|
||||
|
||||
case "bunker-connect": {
|
||||
const authNotice = this.bunkerAuthUrl
|
||||
? `<div class="notice info">Your signer asks you to approve this connection first.
|
||||
<a href="${escapeHtml(this.bunkerAuthUrl)}" target="_blank" rel="noopener noreferrer">Open the approval page</a>, then return here.</div>`
|
||||
: "";
|
||||
const qrBlock = this.bunkerConnectUri
|
||||
? `<div class="qr-wrap" aria-live="polite">${renderQrSvg(this.bunkerConnectUri, "Nostr Connect QR code")}</div>
|
||||
<button class="secondary" type="button" data-action="copy-bunker-uri">Copy connection code</button>
|
||||
<p class="small">Waiting for your signer to connect…</p>`
|
||||
: this.errorMessage
|
||||
? `<button class="secondary" type="button" data-action="goto-bunker">Generate a new code</button>`
|
||||
: `<p class="sub"><span class="spinner"></span>Preparing connection code…</p>`;
|
||||
return `
|
||||
<h2>Use a remote signer</h2>
|
||||
<p class="sub">Scan with a signer app on your phone (Amber, nsec.app, …). Your key stays in the signer; BitLogin only requests signatures.</p>
|
||||
${this.renderError()}
|
||||
${authNotice}
|
||||
${qrBlock}
|
||||
<div class="divider"></div>
|
||||
<form data-form="bunker-connect">
|
||||
<label for="bunkerUri">Or paste a bunker:// address</label>
|
||||
<input type="password" name="bunkerUri" id="bunkerUri" autocomplete="off" placeholder="bunker://…" />
|
||||
<button class="secondary" type="submit" ${this.busy ? "disabled" : ""}>
|
||||
${this.busy ? '<span class="spinner"></span>Contacting your signer…' : "Connect"}
|
||||
</button>
|
||||
</form>
|
||||
<button class="link" data-action="goto-welcome">Back</button>
|
||||
`;
|
||||
}
|
||||
|
||||
case "bunker-confirm":
|
||||
return `
|
||||
<h2>Remote signer connected</h2>
|
||||
<p class="sub">Your signer reports this identity:</p>
|
||||
<div class="credential-box">${escapeHtml(this.pendingBunker?.npub ?? "")}</div>
|
||||
<p class="small">Check this is the profile you expect. Your private key stays in your signer — each signature is requested over relays, and your signer can require approval or be disconnected at any time. This session lasts until you log out or leave the page; BitLogin account features (wallet connections, password rotation, recovery) stay with BitLogin accounts.</p>
|
||||
${this.renderError()}
|
||||
<button class="primary" type="button" data-action="bunker-continue">This is me — continue</button>
|
||||
<button class="link" data-action="goto-welcome">Back</button>
|
||||
`;
|
||||
|
||||
case "import-key": {
|
||||
const previewed = !!this.importPreviewNpub;
|
||||
return `
|
||||
@@ -1433,13 +1619,19 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
}
|
||||
|
||||
case "dashboard": {
|
||||
// Extension sessions are signer-only (§LM7): the account actions below the
|
||||
// divider are all worker-backed (vault, rotation, export) and have no meaning
|
||||
// without a BitLogin account, so they are omitted rather than left to fail.
|
||||
const isExtensionSession = this.session?.method === "nip07";
|
||||
const accountActions = isExtensionSession
|
||||
? `<p class="small">Signed in through your Nostr extension. Wallet connections, password rotation, and identity export are features of BitLogin accounts — create one to get your settings on every device.</p>`
|
||||
: `
|
||||
// Alternative-method sessions are signer-only (§LM7): the account actions
|
||||
// below the divider are all account-backed (vault, rotation, export) and have
|
||||
// no meaning without a BitLogin account, so they are omitted rather than left
|
||||
// to fail.
|
||||
const method = this.session?.method ?? "bitlogin";
|
||||
const altNote =
|
||||
method === "nip07"
|
||||
? "Signed in through your Nostr extension."
|
||||
: "Signed in through your remote signer — each signature is requested from it over relays.";
|
||||
const accountActions =
|
||||
method !== "bitlogin"
|
||||
? `<p class="small">${altNote} Wallet connections, password rotation, and identity export are features of BitLogin accounts — create one to get your settings on every device.</p>`
|
||||
: `
|
||||
<button class="secondary" type="button" data-action="goto-vault-manage">Wallet connections</button>
|
||||
<button class="secondary" type="button" data-action="goto-change-password">Rotate password</button>
|
||||
<button class="secondary" type="button" data-action="goto-export">Export identity</button>`;
|
||||
|
||||
@@ -34,6 +34,50 @@ export interface Nip07Provider {
|
||||
readonly _bitlogin: true;
|
||||
}
|
||||
|
||||
/** The signing surface a <bitlogin-auth> element exposes; the routed provider
|
||||
* below delegates to it so window.nostr always follows the ACTIVE signer. */
|
||||
export interface ElementSignerApi {
|
||||
getPublicKey(): Promise<string>;
|
||||
signEvent(event: UnsignedEventForSigning): Promise<NostrEvent>;
|
||||
nip44Encrypt(peerPublicKey: string, plaintext: string): Promise<string>;
|
||||
nip44Decrypt(peerPublicKey: string, payload: string): Promise<string>;
|
||||
nip04Encrypt(peerPublicKey: string, plaintext: string): Promise<string>;
|
||||
nip04Decrypt(peerPublicKey: string, payload: string): Promise<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A window.nostr provider that routes through the element rather than binding
|
||||
* the worker directly. The element dispatches each call to whichever signer
|
||||
* backs the current session -- the crypto worker for a BitLogin account, the
|
||||
* worker-held NIP-46 client for a remote-signer session (§LM3, §LM5) -- so a
|
||||
* host page reading window.nostr gets the right backend without caring which
|
||||
* method the user picked. (NIP-07 sessions never claim the slot at all; the
|
||||
* extension that IS the backend already owns it, §LM4.)
|
||||
*/
|
||||
export function createElementRoutedProvider(
|
||||
element: ElementSignerApi,
|
||||
configuredRelays: () => string[]
|
||||
): Nip07Provider {
|
||||
return {
|
||||
getPublicKey: () => element.getPublicKey(),
|
||||
signEvent: (event) => element.signEvent(event),
|
||||
async getRelays(): Promise<Record<string, { read: boolean; write: boolean }>> {
|
||||
const out: Record<string, { read: boolean; write: boolean }> = {};
|
||||
for (const url of configuredRelays()) out[url] = { read: true, write: true };
|
||||
return out;
|
||||
},
|
||||
nip44: {
|
||||
encrypt: (peerPublicKey, plaintext) => element.nip44Encrypt(peerPublicKey, plaintext),
|
||||
decrypt: (peerPublicKey, payload) => element.nip44Decrypt(peerPublicKey, payload)
|
||||
},
|
||||
nip04: {
|
||||
encrypt: (peerPublicKey, plaintext) => element.nip04Encrypt(peerPublicKey, plaintext),
|
||||
decrypt: (peerPublicKey, payload) => element.nip04Decrypt(peerPublicKey, payload)
|
||||
},
|
||||
_bitlogin: true
|
||||
};
|
||||
}
|
||||
|
||||
const NOT_UNLOCKED_MESSAGE =
|
||||
"BitLogin: no identity is unlocked yet. Add <bitlogin-auth> to the page and let the user sign in, or call it programmatically before invoking window.nostr.";
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import qrcodeGenerator from "qrcode-generator";
|
||||
import { renderQrSvg } from "./qr.js";
|
||||
|
||||
const SAMPLE =
|
||||
"nostrconnect://" +
|
||||
"ab".repeat(32) +
|
||||
"?relay=wss%3A%2F%2Frelay.example&secret=0123456789abcdef&name=BitLogin";
|
||||
|
||||
describe("renderQrSvg", () => {
|
||||
it("renders a complete standalone SVG with backing, eyes, and dots", () => {
|
||||
const svg = renderQrSvg(SAMPLE);
|
||||
expect(svg.startsWith("<svg ")).toBe(true);
|
||||
expect(svg.endsWith("</svg>")).toBe(true);
|
||||
// One opaque backing rect plus three finder eyes of two rects each.
|
||||
expect(svg.match(/<rect /gu)!.length).toBe(1 + 3 * 2);
|
||||
// Data modules are circles -- the "dots" -- and there are plenty of them.
|
||||
expect(svg.match(/<circle /gu)!.length).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it("draws exactly the dark data modules the encoder produced, minus finder zones", () => {
|
||||
const qr = qrcodeGenerator(0, "M");
|
||||
qr.addData(SAMPLE);
|
||||
qr.make();
|
||||
const n = qr.getModuleCount();
|
||||
let expected = 0;
|
||||
const inEye = (r: number, c: number) =>
|
||||
(r < 7 && c < 7) || (r < 7 && c >= n - 7) || (r >= n - 7 && c < 7);
|
||||
for (let r = 0; r < n; r++) {
|
||||
for (let c = 0; c < n; c++) {
|
||||
if (!inEye(r, c) && qr.isDark(r, c)) expected++;
|
||||
}
|
||||
}
|
||||
const svg = renderQrSvg(SAMPLE);
|
||||
expect(svg.match(/<circle /gu)!.length).toBe(expected);
|
||||
});
|
||||
|
||||
it("keeps the quiet zone: no dot within four modules of the edge", () => {
|
||||
const svg = renderQrSvg(SAMPLE);
|
||||
const centers = [...svg.matchAll(/<circle cx="([\d.]+)" cy="([\d.]+)"/gu)].map((m) => [
|
||||
Number(m[1]),
|
||||
Number(m[2])
|
||||
]);
|
||||
const viewBox = /viewBox="0 0 (\d+) (\d+)"/u.exec(svg)!;
|
||||
const size = Number(viewBox[1]);
|
||||
for (const [cx, cy] of centers) {
|
||||
expect(cx).toBeGreaterThanOrEqual(4);
|
||||
expect(cy).toBeGreaterThanOrEqual(4);
|
||||
expect(cx).toBeLessThanOrEqual(size - 4);
|
||||
expect(cy).toBeLessThanOrEqual(size - 4);
|
||||
}
|
||||
});
|
||||
|
||||
it("escapes nothing it shouldn't: the payload never appears in the markup", () => {
|
||||
// The QR encodes the URI as modules; the raw string (with its secret) must
|
||||
// not leak into the SVG text.
|
||||
const svg = renderQrSvg(SAMPLE);
|
||||
expect(svg).not.toContain("nostrconnect");
|
||||
expect(svg).not.toContain("secret");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
/** Dot-style QR rendering for the nostrconnect connection code (§LM5).
|
||||
*
|
||||
* Renders the module matrix as inline SVG: data modules become circles, and
|
||||
* the three finder "eyes" become rounded squares -- dots everywhere would look
|
||||
* uniform but measurably hurts camera lock-on, since scanners find a code BY
|
||||
* its three square eyes. Inline SVG keeps this self-contained under the
|
||||
* strict deployment CSP (no canvas, no image assets) and crisp at any size.
|
||||
*
|
||||
* The white backing rect is part of the QR, not styling: scanners need quiet
|
||||
* zone and contrast regardless of the host page's theme.
|
||||
*/
|
||||
import qrcodeGenerator from "qrcode-generator";
|
||||
|
||||
const QUIET_ZONE = 4; // modules of white margin, per the QR spec
|
||||
const DOT_RADIUS = 0.42; // of a 1x1 module cell; large enough to scan, round enough to read as dots
|
||||
const DARK = "#14101f";
|
||||
const LIGHT = "#ffffff";
|
||||
|
||||
function inFinderZone(row: number, col: number, moduleCount: number): boolean {
|
||||
const nearStart = (v: number) => v < 7;
|
||||
const nearEnd = (v: number) => v >= moduleCount - 7;
|
||||
return (
|
||||
(nearStart(row) && nearStart(col)) ||
|
||||
(nearStart(row) && nearEnd(col)) ||
|
||||
(nearEnd(row) && nearStart(col))
|
||||
);
|
||||
}
|
||||
|
||||
function finderEye(x: number, y: number): string {
|
||||
// Outer 7x7 ring drawn as a stroked rounded square, inner 3x3 filled.
|
||||
return (
|
||||
`<rect x="${x + 0.5}" y="${y + 0.5}" width="6" height="6" rx="2.1" fill="none" stroke="${DARK}" stroke-width="1"/>` +
|
||||
`<rect x="${x + 2}" y="${y + 2}" width="3" height="3" rx="1.1" fill="${DARK}"/>`
|
||||
);
|
||||
}
|
||||
|
||||
/** Encodes `data` and returns a complete `<svg>` element string. */
|
||||
export function renderQrSvg(data: string, ariaLabel = "QR code"): string {
|
||||
const qr = qrcodeGenerator(0, "M");
|
||||
qr.addData(data);
|
||||
qr.make();
|
||||
const moduleCount = qr.getModuleCount();
|
||||
const size = moduleCount + QUIET_ZONE * 2;
|
||||
|
||||
const parts: string[] = [];
|
||||
parts.push(`<rect x="0" y="0" width="${size}" height="${size}" rx="3" fill="${LIGHT}"/>`);
|
||||
parts.push(finderEye(QUIET_ZONE, QUIET_ZONE));
|
||||
parts.push(finderEye(QUIET_ZONE + moduleCount - 7, QUIET_ZONE));
|
||||
parts.push(finderEye(QUIET_ZONE, QUIET_ZONE + moduleCount - 7));
|
||||
|
||||
for (let row = 0; row < moduleCount; row++) {
|
||||
for (let col = 0; col < moduleCount; col++) {
|
||||
if (inFinderZone(row, col, moduleCount) || !qr.isDark(row, col)) continue;
|
||||
const cx = QUIET_ZONE + col + 0.5;
|
||||
const cy = QUIET_ZONE + row + 0.5;
|
||||
parts.push(`<circle cx="${cx}" cy="${cy}" r="${DOT_RADIUS}" fill="${DARK}"/>`);
|
||||
}
|
||||
}
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" role="img" aria-label="${ariaLabel}" shape-rendering="geometricPrecision">${parts.join("")}</svg>`;
|
||||
}
|
||||
@@ -40,13 +40,36 @@ describe("widget security policy", () => {
|
||||
expect(passwordSignIn).toBeLessThan(extensionSlot);
|
||||
});
|
||||
|
||||
it("keeps alternative methods below the password path on the welcome screen", () => {
|
||||
// Same owner decision as the extension pin above, applied to NIP-46: the
|
||||
// remote-signer option renders after the primary sign-in button.
|
||||
const passwordSignIn = widgetSource.indexOf('data-action="goto-login">Sign in</button>');
|
||||
const bunkerOption = widgetSource.indexOf('data-action="goto-bunker">Use a remote signer');
|
||||
expect(passwordSignIn).toBeGreaterThan(-1);
|
||||
expect(bunkerOption).toBeGreaterThan(-1);
|
||||
expect(passwordSignIn).toBeLessThan(bunkerOption);
|
||||
});
|
||||
|
||||
it("treats the bunker URI as a secret and keeps the element storage-free", () => {
|
||||
// A bunker:// URI carries a connection token; it gets a password input
|
||||
// (no shoulder-surfing, no autofill heuristics), and the element itself
|
||||
// must never touch web storage -- alternative-method sessions are
|
||||
// memory-only by design (§LM5, §LM7), and the ONLY persistence in the
|
||||
// widget lives behind the worker's session cache.
|
||||
expect(widgetSource).toContain('type="password" name="bunkerUri"');
|
||||
expect(widgetSource).not.toContain("localStorage");
|
||||
expect(widgetSource).not.toContain("sessionStorage");
|
||||
});
|
||||
|
||||
it("extension sessions never claim window.nostr and never persist", () => {
|
||||
// §LM4: NIP-07 mode delegates to the extension's provider; fighting it for the
|
||||
// window.nostr slot is reserved for BitLogin-account sessions. There must be no
|
||||
// claimSigner() call in the extension confirm path, and no storage write.
|
||||
const confirmBody = widgetSource.slice(
|
||||
widgetSource.indexOf("private handleExtensionConfirm"),
|
||||
widgetSource.indexOf("private async tryRestoreSession"),
|
||||
// Ends where the NIP-46 handlers begin -- those claim the slot
|
||||
// legitimately (§LM5: the routed provider IS that session's backend).
|
||||
widgetSource.indexOf("private async startNostrconnect"),
|
||||
);
|
||||
expect(confirmBody.length).toBeGreaterThan(0);
|
||||
expect(confirmBody).not.toContain("claimSigner");
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Nip46Signer } from "./nip46.js";
|
||||
import type { WorkerClient } from "../worker/workerClient.js";
|
||||
|
||||
const USER = "ab".repeat(32);
|
||||
|
||||
function fakeWorker(calls: Array<{ action: string; payload: unknown }>): WorkerClient {
|
||||
const record =
|
||||
(action: string, result: unknown) =>
|
||||
(payload: unknown): Promise<unknown> => {
|
||||
calls.push({ action, payload });
|
||||
return Promise.resolve(result);
|
||||
};
|
||||
return {
|
||||
nip46SignEvent: record("nip46SignEvent", { id: "e".repeat(64), pubkey: USER, sig: "f".repeat(128), kind: 1, tags: [], content: "x", created_at: 1 }),
|
||||
nip46Nip44Encrypt: record("nip46Nip44Encrypt", { ciphertext: "c44" }),
|
||||
nip46Nip44Decrypt: record("nip46Nip44Decrypt", { plaintext: "p44" }),
|
||||
nip46Nip04Encrypt: record("nip46Nip04Encrypt", { ciphertext: "c04" }),
|
||||
nip46Nip04Decrypt: record("nip46Nip04Decrypt", { plaintext: "p04" })
|
||||
} as unknown as WorkerClient;
|
||||
}
|
||||
|
||||
describe("Nip46Signer", () => {
|
||||
it("answers getPublicKey locally with the connect-time identity", async () => {
|
||||
const signer = new Nip46Signer(fakeWorker([]), USER);
|
||||
expect(signer.method).toBe("nip46");
|
||||
expect(await signer.getPublicKey()).toBe(USER);
|
||||
});
|
||||
|
||||
it("reports the full RPC capability surface", () => {
|
||||
const signer = new Nip46Signer(fakeWorker([]), USER);
|
||||
expect(signer.capabilities).toEqual({ nip44: true, nip04: true, getRelays: false });
|
||||
});
|
||||
|
||||
it("delegates signing and encryption to the worker-held client, mapping fields", async () => {
|
||||
const calls: Array<{ action: string; payload: unknown }> = [];
|
||||
const signer = new Nip46Signer(fakeWorker(calls), USER);
|
||||
|
||||
await signer.signEvent({ kind: 1, content: "hello", tags: [["t", "x"]], created_at: 123 });
|
||||
expect(calls[0]).toEqual({
|
||||
action: "nip46SignEvent",
|
||||
payload: { kind: 1, content: "hello", tags: [["t", "x"]], created_at: 123 }
|
||||
});
|
||||
|
||||
expect(await signer.nip44Encrypt("cd".repeat(32), "hi")).toBe("c44");
|
||||
expect(await signer.nip44Decrypt("cd".repeat(32), "payload")).toBe("p44");
|
||||
expect(await signer.nip04Encrypt("cd".repeat(32), "hi")).toBe("c04");
|
||||
expect(await signer.nip04Decrypt("cd".repeat(32), "payload")).toBe("p04");
|
||||
expect(calls.map((c) => c.action)).toEqual([
|
||||
"nip46SignEvent",
|
||||
"nip46Nip44Encrypt",
|
||||
"nip46Nip44Decrypt",
|
||||
"nip46Nip04Encrypt",
|
||||
"nip46Nip04Decrypt"
|
||||
]);
|
||||
expect(calls[1]!.payload).toEqual({ peerPublicKey: "cd".repeat(32), plaintext: "hi" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
/** NIP-46 remote-signer sessions (§LM5): the signer backend for a connected bunker.
|
||||
*
|
||||
* Thin by construction: the actual client -- ephemeral keypair, relay
|
||||
* subscriptions, request correlation -- lives in the crypto worker (§LM5.4),
|
||||
* and this class only forwards facade calls to it. The user pubkey is fixed at
|
||||
* connect time (the worker validated it), so getPublicKey answers locally.
|
||||
* Deadlines and typed errors come from the worker/core layers.
|
||||
*/
|
||||
import type { NostrEvent } from "@bitlogin/core/nostr";
|
||||
import type { UnsignedEventForSigning } from "../provider.js";
|
||||
import type { WorkerClient } from "../worker/workerClient.js";
|
||||
import type { Signer, SignerCapabilities } from "./types.js";
|
||||
|
||||
export class Nip46Signer implements Signer {
|
||||
readonly method = "nip46" as const;
|
||||
/** The NIP-46 RPC surface always carries all four encryption calls; whether a
|
||||
* particular signer approves them is a per-request policy decision that
|
||||
* surfaces as its error, not a missing capability. */
|
||||
readonly capabilities: SignerCapabilities = { nip44: true, nip04: true, getRelays: false };
|
||||
|
||||
constructor(
|
||||
private readonly worker: WorkerClient,
|
||||
private readonly userPubkey: string
|
||||
) {}
|
||||
|
||||
getPublicKey(): Promise<string> {
|
||||
return Promise.resolve(this.userPubkey);
|
||||
}
|
||||
|
||||
signEvent(event: UnsignedEventForSigning): Promise<NostrEvent> {
|
||||
return this.worker.nip46SignEvent({
|
||||
kind: event.kind,
|
||||
content: event.content,
|
||||
tags: event.tags,
|
||||
created_at: event.created_at
|
||||
});
|
||||
}
|
||||
|
||||
async nip44Encrypt(peerPublicKey: string, plaintext: string): Promise<string> {
|
||||
return (await this.worker.nip46Nip44Encrypt({ peerPublicKey, plaintext })).ciphertext;
|
||||
}
|
||||
|
||||
async nip44Decrypt(peerPublicKey: string, payload: string): Promise<string> {
|
||||
return (await this.worker.nip46Nip44Decrypt({ peerPublicKey, payload })).plaintext;
|
||||
}
|
||||
|
||||
async nip04Encrypt(peerPublicKey: string, plaintext: string): Promise<string> {
|
||||
return (await this.worker.nip46Nip04Encrypt({ peerPublicKey, plaintext })).ciphertext;
|
||||
}
|
||||
|
||||
async nip04Decrypt(peerPublicKey: string, payload: string): Promise<string> {
|
||||
return (await this.worker.nip46Nip04Decrypt({ peerPublicKey, payload })).plaintext;
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
import type { NostrEvent } from "@bitlogin/core/nostr";
|
||||
import type { UnsignedEventForSigning } from "../provider.js";
|
||||
|
||||
export type SignerMethod = "bitlogin" | "nip07";
|
||||
export type SignerMethod = "bitlogin" | "nip07" | "nip46";
|
||||
|
||||
/** What the active signer actually supports. NIP-07 extensions vary -- some lack nip44,
|
||||
* older ones lack nip04 -- and the facade reports honestly instead of throwing a
|
||||
|
||||
@@ -236,4 +236,14 @@ button.link {
|
||||
margin-right: 6px;
|
||||
}
|
||||
@keyframes bl-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* nostrconnect QR (§LM5). The white backing and quiet zone are drawn inside
|
||||
the SVG itself -- scanners need them regardless of host theme -- so the
|
||||
wrapper only sizes and centers it. */
|
||||
.qr-wrap {
|
||||
display: block;
|
||||
width: min(240px, 100%);
|
||||
margin: 14px auto;
|
||||
}
|
||||
.qr-wrap svg { display: block; width: 100%; height: auto; }
|
||||
`;
|
||||
@@ -18,8 +18,19 @@ import {
|
||||
NostrSigner,
|
||||
type RecoveredIdentity
|
||||
} from "@bitlogin/core/account";
|
||||
import { RelayPool, BUILTIN_VAULT_RELAYS, BUILTIN_DISCOVERY_RELAYS, encodeNsec, encodeNpub, type NostrEvent } from "@bitlogin/core/nostr";
|
||||
import { getPublicKeyHex, base64urlToBytes, wipe } from "@bitlogin/core/crypto";
|
||||
import {
|
||||
RelayPool,
|
||||
BUILTIN_VAULT_RELAYS,
|
||||
BUILTIN_DISCOVERY_RELAYS,
|
||||
encodeNsec,
|
||||
encodeNpub,
|
||||
Nip46Client,
|
||||
parseBunkerUri,
|
||||
buildNostrconnectUri,
|
||||
listenForNostrconnect,
|
||||
type NostrEvent
|
||||
} from "@bitlogin/core/nostr";
|
||||
import { getPublicKeyHex, base64urlToBytes, wipe, generatePrivateKey, randomBytes, bytesToHex } from "@bitlogin/core/crypto";
|
||||
import {
|
||||
VaultSession,
|
||||
parseNwcUri,
|
||||
@@ -48,7 +59,9 @@ import type {
|
||||
Nip44DecryptPayload,
|
||||
Nip04EncryptPayload,
|
||||
Nip04DecryptPayload,
|
||||
PreviewImportKeyPayload
|
||||
PreviewImportKeyPayload,
|
||||
Nip46ConnectPayload,
|
||||
Nip46NostrconnectStartPayload
|
||||
} from "./protocol.js";
|
||||
|
||||
interface SessionState {
|
||||
@@ -232,8 +245,131 @@ async function findConnection(connectionId: string): Promise<{
|
||||
return { vault, connection };
|
||||
}
|
||||
|
||||
// ---- NIP-46 remote-signer session (§LM5) ----
|
||||
// Deliberately separate from `session`: a remote-signer session has no account,
|
||||
// no vault, and nothing persisted. The ephemeral client key never leaves this
|
||||
// worker (§LM5.4); close() ends everything.
|
||||
let nip46: { client: Nip46Client; userPubkey: string } | null = null;
|
||||
let nostrconnectPending: { clientSecretKey: Uint8Array; relayUrls: string[]; secret: string } | null = null;
|
||||
|
||||
function postAuthUrlNotification(url: string): void {
|
||||
(self as unknown as Worker).postMessage({ notify: "nip46-auth-url", url });
|
||||
}
|
||||
|
||||
function requireNip46(): { client: Nip46Client; userPubkey: string } {
|
||||
if (!nip46) throw new Error("No remote signer is connected.");
|
||||
return nip46;
|
||||
}
|
||||
|
||||
function adoptNip46Client(client: Nip46Client, userPubkey: string): void {
|
||||
nip46?.client.close();
|
||||
nip46 = { client, userPubkey };
|
||||
}
|
||||
|
||||
async function handle(action: string, payload: unknown): Promise<unknown> {
|
||||
switch (action) {
|
||||
case "nip46Connect": {
|
||||
const p = payload as Nip46ConnectPayload;
|
||||
const pointer = parseBunkerUri(p.uri);
|
||||
const client = new Nip46Client({
|
||||
clientSecretKey: generatePrivateKey(),
|
||||
pointer,
|
||||
onAuthUrl: postAuthUrlNotification
|
||||
});
|
||||
try {
|
||||
await client.connect();
|
||||
const userPubkey = await client.getUserPublicKey();
|
||||
adoptNip46Client(client, userPubkey);
|
||||
return { userPubkey };
|
||||
} catch (err) {
|
||||
client.close();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
case "nip46NostrconnectStart": {
|
||||
const p = payload as Nip46NostrconnectStartPayload;
|
||||
const relayUrls = vaultRelayUrls;
|
||||
const clientSecretKey = generatePrivateKey();
|
||||
// The secret is the proof-of-scan: whoever echoes it back becomes this
|
||||
// session's signer, so it gets real entropy, not a friendly string.
|
||||
const secret = bytesToHex(randomBytes(16));
|
||||
nostrconnectPending = { clientSecretKey, relayUrls, secret };
|
||||
const uri = buildNostrconnectUri({
|
||||
clientPubkey: getPublicKeyHex(clientSecretKey),
|
||||
relayUrls,
|
||||
secret,
|
||||
name: p.appName?.trim() || "BitLogin",
|
||||
perms: ["sign_event", "nip44_encrypt", "nip44_decrypt", "nip04_encrypt", "nip04_decrypt"]
|
||||
});
|
||||
return { uri };
|
||||
}
|
||||
|
||||
case "nip46NostrconnectAwait": {
|
||||
const pending = nostrconnectPending;
|
||||
if (!pending) throw new Error("No signer-connection attempt is in progress.");
|
||||
const { signerPubkey } = await listenForNostrconnect({
|
||||
clientSecretKey: pending.clientSecretKey,
|
||||
relayUrls: pending.relayUrls,
|
||||
secret: pending.secret,
|
||||
timeoutMs: 180_000
|
||||
});
|
||||
if (nostrconnectPending !== pending) throw new Error("This signer-connection attempt was superseded.");
|
||||
nostrconnectPending = null;
|
||||
const client = new Nip46Client({
|
||||
clientSecretKey: pending.clientSecretKey,
|
||||
pointer: { signerPubkey, relayUrls: pending.relayUrls },
|
||||
onAuthUrl: postAuthUrlNotification
|
||||
});
|
||||
try {
|
||||
// The signer initiated (it echoed the secret), so no connect RPC is
|
||||
// needed -- go straight to asking whose key it signs for.
|
||||
const userPubkey = await client.getUserPublicKey();
|
||||
adoptNip46Client(client, userPubkey);
|
||||
return { userPubkey };
|
||||
} catch (err) {
|
||||
client.close();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
case "nip46SignEvent": {
|
||||
const p = payload as SignEventPayload;
|
||||
return requireNip46().client.signEvent({
|
||||
kind: p.kind,
|
||||
content: p.content,
|
||||
tags: (p.tags ?? []) as string[][],
|
||||
created_at: p.created_at ?? Math.floor(Date.now() / 1000)
|
||||
});
|
||||
}
|
||||
|
||||
case "nip46Nip44Encrypt": {
|
||||
const p = payload as Nip44EncryptPayload;
|
||||
return { ciphertext: await requireNip46().client.nip44Encrypt(p.peerPublicKey, p.plaintext) };
|
||||
}
|
||||
|
||||
case "nip46Nip44Decrypt": {
|
||||
const p = payload as Nip44DecryptPayload;
|
||||
return { plaintext: await requireNip46().client.nip44Decrypt(p.peerPublicKey, p.payload) };
|
||||
}
|
||||
|
||||
case "nip46Nip04Encrypt": {
|
||||
const p = payload as Nip04EncryptPayload;
|
||||
return { ciphertext: await requireNip46().client.nip04Encrypt(p.peerPublicKey, p.plaintext) };
|
||||
}
|
||||
|
||||
case "nip46Nip04Decrypt": {
|
||||
const p = payload as Nip04DecryptPayload;
|
||||
return { plaintext: await requireNip46().client.nip04Decrypt(p.peerPublicKey, p.payload) };
|
||||
}
|
||||
|
||||
case "nip46Disconnect": {
|
||||
nip46?.client.close();
|
||||
nip46 = null;
|
||||
nostrconnectPending = null;
|
||||
return {};
|
||||
}
|
||||
|
||||
case "configure": {
|
||||
const p = payload as ConfigurePayload;
|
||||
if (p.vaultRelayUrls?.length) vaultRelayUrls = p.vaultRelayUrls;
|
||||
@@ -655,7 +791,10 @@ const KNOWN_ACTIONS = new Set<string>([
|
||||
"nip44Encrypt", "nip44Decrypt", "nip04Encrypt", "nip04Decrypt", "exportIdentity",
|
||||
"buildRecoveryExport", "repairReplicas", "getSessionStatus", "restoreSession", "logout",
|
||||
"vaultStatus", "vaultList", "vaultSaveNwc", "vaultFindForOrigin", "vaultRevealNwc",
|
||||
"vaultSetBinding", "vaultDelete", "vaultOfferCheck"
|
||||
"vaultSetBinding", "vaultDelete", "vaultOfferCheck",
|
||||
"nip46Connect", "nip46NostrconnectStart", "nip46NostrconnectAwait", "nip46SignEvent",
|
||||
"nip46Nip44Encrypt", "nip46Nip44Decrypt", "nip46Nip04Encrypt", "nip46Nip04Decrypt",
|
||||
"nip46Disconnect"
|
||||
]);
|
||||
|
||||
self.addEventListener("message", (event: MessageEvent<WorkerRequest>) => {
|
||||
@@ -676,7 +815,15 @@ self.addEventListener("message", (event: MessageEvent<WorkerRequest>) => {
|
||||
return;
|
||||
}
|
||||
const { id, action, payload } = event.data;
|
||||
requestQueue.run(() => handle(action, payload)).then(
|
||||
// NIP-46 calls run OUTSIDE the serial queue: they touch none of the
|
||||
// account/vault state the queue exists to order, and a signer waiting
|
||||
// minutes for a human's approval must not block every other worker call
|
||||
// behind it (getSessionStatus, vault requests, even logout).
|
||||
const run =
|
||||
action.startsWith("nip46")
|
||||
? (operation: () => Promise<unknown>) => operation()
|
||||
: (operation: () => Promise<unknown>) => requestQueue.run(operation);
|
||||
run(() => handle(action, payload)).then(
|
||||
(result) => {
|
||||
const response: WorkerResponse = { id, ok: true, result };
|
||||
(self as unknown as Worker).postMessage(response);
|
||||
|
||||
@@ -235,6 +235,39 @@ export interface VaultOfferCheckResult {
|
||||
connection?: VaultConnectionSummary;
|
||||
}
|
||||
|
||||
// ---- NIP-46 remote-signer session (docs/login-methods.md §LM5) ----
|
||||
// Memory-only: the ephemeral client key and Nip46Client live in the worker
|
||||
// (§LM5.4) and vanish with it. Nothing below touches account/vault state.
|
||||
|
||||
export interface Nip46ConnectPayload {
|
||||
/** A bunker:// URI pasted by the user. */
|
||||
uri: string;
|
||||
}
|
||||
export interface Nip46ConnectResult {
|
||||
userPubkey: string;
|
||||
}
|
||||
export interface Nip46NostrconnectStartPayload {
|
||||
appName?: string;
|
||||
}
|
||||
export interface Nip46NostrconnectStartResult {
|
||||
/** The nostrconnect:// URI to show as a QR / copyable string. */
|
||||
uri: string;
|
||||
}
|
||||
export interface Nip46NostrconnectAwaitResult {
|
||||
userPubkey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsolicited worker -> main-thread frame. Distinguished from WorkerResponse
|
||||
* by having `notify` and no `id`; used where a request/response pair cannot
|
||||
* carry the information -- the signer's interactive-approval URL arrives in
|
||||
* the middle of a still-pending connect call.
|
||||
*/
|
||||
export interface WorkerNotification {
|
||||
notify: "nip46-auth-url";
|
||||
url: string;
|
||||
}
|
||||
|
||||
export type WorkerActionMap = {
|
||||
configure: [ConfigurePayload, Record<string, never>];
|
||||
register: [RegisterPayload, RegisterResult];
|
||||
@@ -264,6 +297,15 @@ export type WorkerActionMap = {
|
||||
vaultSetBinding: [VaultSetBindingPayload, VaultConnectionSummary];
|
||||
vaultDelete: [VaultDeletePayload, Record<string, never>];
|
||||
vaultOfferCheck: [VaultOfferCheckPayload, VaultOfferCheckResult];
|
||||
nip46Connect: [Nip46ConnectPayload, Nip46ConnectResult];
|
||||
nip46NostrconnectStart: [Nip46NostrconnectStartPayload, Nip46NostrconnectStartResult];
|
||||
nip46NostrconnectAwait: [Record<string, never>, Nip46NostrconnectAwaitResult];
|
||||
nip46SignEvent: [SignEventPayload, NostrEvent];
|
||||
nip46Nip44Encrypt: [Nip44EncryptPayload, { ciphertext: string }];
|
||||
nip46Nip44Decrypt: [Nip44DecryptPayload, { plaintext: string }];
|
||||
nip46Nip04Encrypt: [Nip04EncryptPayload, { ciphertext: string }];
|
||||
nip46Nip04Decrypt: [Nip04DecryptPayload, { plaintext: string }];
|
||||
nip46Disconnect: [Record<string, never>, Record<string, never>];
|
||||
};
|
||||
|
||||
export type WorkerAction = keyof WorkerActionMap;
|
||||
@@ -22,7 +22,10 @@ import type {
|
||||
VaultRevealNwcPayload,
|
||||
VaultSetBindingPayload,
|
||||
VaultDeletePayload,
|
||||
VaultOfferCheckPayload
|
||||
VaultOfferCheckPayload,
|
||||
Nip46ConnectPayload,
|
||||
Nip46NostrconnectStartPayload,
|
||||
WorkerNotification
|
||||
} from "./protocol.js";
|
||||
|
||||
/**
|
||||
@@ -32,6 +35,16 @@ import type {
|
||||
*/
|
||||
const CALL_TIMEOUT_MS = 60_000;
|
||||
|
||||
/**
|
||||
* NIP-46 calls wait on a HUMAN approving a prompt on another device, on top of
|
||||
* relay round trips -- the worker-side deadlines (120s per signer request,
|
||||
* 180s for the nostrconnect listen) are the real limits; these exist only so
|
||||
* a dead worker still rejects. Kept above the worst worker-side sum so the
|
||||
* worker's own, better-worded timeout error is the one callers see.
|
||||
*/
|
||||
const NIP46_CALL_TIMEOUT_MS = 260_000;
|
||||
const NIP46_AWAIT_TIMEOUT_MS = 320_000;
|
||||
|
||||
export class WorkerClient {
|
||||
private readonly worker: Worker;
|
||||
private readonly pending = new Map<
|
||||
@@ -40,6 +53,8 @@ export class WorkerClient {
|
||||
>();
|
||||
private counter = 0;
|
||||
private dead = false;
|
||||
/** Set by the element to receive unsolicited worker frames (auth_url). */
|
||||
onNotification: ((notification: WorkerNotification) => void) | null = null;
|
||||
|
||||
constructor() {
|
||||
// Deliberately NOT the literal `new Worker(new URL("./x.js", import.meta.url))` shape: Vite
|
||||
@@ -55,7 +70,15 @@ export class WorkerClient {
|
||||
const msg = event.data;
|
||||
// Shape-check before trusting: a malformed frame used to throw inside
|
||||
// this listener, which posts no response and hangs the caller forever.
|
||||
if (!msg || typeof msg !== "object" || typeof (msg as WorkerResponse).id !== "string") return;
|
||||
if (!msg || typeof msg !== "object") return;
|
||||
// Unsolicited notification frames (no id) -- currently only the NIP-46
|
||||
// auth_url, which arrives mid-call and cannot ride a response.
|
||||
const maybeNotification = msg as unknown as Partial<WorkerNotification>;
|
||||
if (maybeNotification.notify === "nip46-auth-url" && typeof maybeNotification.url === "string") {
|
||||
this.onNotification?.(maybeNotification as WorkerNotification);
|
||||
return;
|
||||
}
|
||||
if (typeof (msg as WorkerResponse).id !== "string") return;
|
||||
const entry = this.pending.get(msg.id);
|
||||
if (!entry) return;
|
||||
this.pending.delete(msg.id);
|
||||
@@ -91,7 +114,11 @@ export class WorkerClient {
|
||||
}
|
||||
}
|
||||
|
||||
private call<A extends WorkerAction>(action: A, payload: WorkerActionMap[A][0]): Promise<WorkerActionMap[A][1]> {
|
||||
private call<A extends WorkerAction>(
|
||||
action: A,
|
||||
payload: WorkerActionMap[A][0],
|
||||
timeoutMs = CALL_TIMEOUT_MS
|
||||
): Promise<WorkerActionMap[A][1]> {
|
||||
if (this.dead) {
|
||||
return Promise.reject(new Error("This BitLogin session was torn down; reload the page to sign in again."));
|
||||
}
|
||||
@@ -100,7 +127,7 @@ export class WorkerClient {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(`BitLogin's ${action} call timed out.`));
|
||||
}, CALL_TIMEOUT_MS);
|
||||
}, timeoutMs);
|
||||
this.pending.set(id, { resolve: resolve as (value: unknown) => void, reject, timer });
|
||||
const request: WorkerRequest = { id, action, payload };
|
||||
this.worker.postMessage(request);
|
||||
@@ -188,6 +215,35 @@ export class WorkerClient {
|
||||
vaultOfferCheck(payload: VaultOfferCheckPayload) {
|
||||
return this.call("vaultOfferCheck", payload);
|
||||
}
|
||||
|
||||
// ---- NIP-46 remote-signer session (§LM5) ----
|
||||
nip46Connect(payload: Nip46ConnectPayload) {
|
||||
return this.call("nip46Connect", payload, NIP46_CALL_TIMEOUT_MS);
|
||||
}
|
||||
nip46NostrconnectStart(payload: Nip46NostrconnectStartPayload) {
|
||||
return this.call("nip46NostrconnectStart", payload);
|
||||
}
|
||||
nip46NostrconnectAwait() {
|
||||
return this.call("nip46NostrconnectAwait", {}, NIP46_AWAIT_TIMEOUT_MS);
|
||||
}
|
||||
nip46SignEvent(payload: SignEventPayload) {
|
||||
return this.call("nip46SignEvent", payload, NIP46_CALL_TIMEOUT_MS);
|
||||
}
|
||||
nip46Nip44Encrypt(payload: Nip44EncryptPayload) {
|
||||
return this.call("nip46Nip44Encrypt", payload, NIP46_CALL_TIMEOUT_MS);
|
||||
}
|
||||
nip46Nip44Decrypt(payload: Nip44DecryptPayload) {
|
||||
return this.call("nip46Nip44Decrypt", payload, NIP46_CALL_TIMEOUT_MS);
|
||||
}
|
||||
nip46Nip04Encrypt(payload: Nip04EncryptPayload) {
|
||||
return this.call("nip46Nip04Encrypt", payload, NIP46_CALL_TIMEOUT_MS);
|
||||
}
|
||||
nip46Nip04Decrypt(payload: Nip04DecryptPayload) {
|
||||
return this.call("nip46Nip04Decrypt", payload, NIP46_CALL_TIMEOUT_MS);
|
||||
}
|
||||
nip46Disconnect() {
|
||||
return this.call("nip46Disconnect", {});
|
||||
}
|
||||
logout() {
|
||||
return this.call("logout", {});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user