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:
thePR0M3TH3ANandClaude Fable 5 committed 2026-08-04 10:31:36 -04:00
1 parent b616068689
commit 49682dd75c
23 files changed
+1625 -70

No files matched your search

+221 -29
View File
@@ -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>`;
+44
View File
@@ -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.";
+61
View File
@@ -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");
});
});
+61
View File
@@ -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>`;
}
+24 -1
View File
@@ -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");
+58
View File
@@ -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" });
});
});
+54
View File
@@ -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;
}
}
+1 -1
View File
@@ -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
+10
View File
@@ -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; }
`;
+152 -5
View File
@@ -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);
+42
View File
@@ -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;
+60 -4
View File
@@ -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", {});
}