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
+1624 -69

No files matched your search

+14 -8
View File
@@ -1,10 +1,14 @@
# BitLogin as the Single Login Surface — NIP-07 and NIP-46 Methods
**Document version:** 0.2
**Status:** §LM4 (NIP-07) is **implemented** (2026-08-04): `signers/` in
`@bitlogin/widget`, thin sessions per §LM7, password-first presentation per
§LM9.1. §LM5 (NIP-46) remains a proposal. §LM6 records a settled rejection
(nsec paste as a login method), kept on the record.
**Document version:** 0.3
**Status:** §LM4 (NIP-07) and §LM5 (NIP-46, memory-only phase) are
**implemented** (2026-08-04): `signers/` in `@bitlogin/widget`, the NIP-46
client in `@bitlogin/core`'s `nostr/nip46.ts` with the ephemeral key held in
the crypto worker, both `bunker://` paste and the nostrconnect QR (rendered
as dot-style inline SVG), thin sessions per §LM7, password-first per §LM9.1.
NIP-46 *persistence* (§LM5.3 phase two) remains gated on the external vault
review. §LM6 records a settled rejection (nsec paste as a login method),
kept on the record.
**Depends on:** `docs/spec.md` (§ references point there unless marked §LM or
§SF), `docs/second-factor.md` §SF10 (imported identities, implemented)
@@ -214,9 +218,11 @@ not propose.
1. **NIP-07** — smallest increment: detection, delegation, capability
probing, arbitration inversion. No new secrets, no relay traffic, no
review gate touched.
2. **NIP-46, memory-only** — connect ceremony, pending/timeout UX, typed
errors. Still nothing persisted.
review gate touched. **Shipped 2026-08-04.**
2. **NIP-46, memory-only** — connect ceremony (bunker:// paste and a
nostrconnect QR), pending/timeout UX, typed errors. Still nothing
persisted. **Shipped 2026-08-04.** Transport is NIP-44 only, per the
current NIP-46 spec; legacy NIP-04-only bunkers are not supported.
3. **NIP-46 persistence as a vault profile** — after the external security
review the vault is already gated on.
+16 -9
View File
@@ -1991,14 +1991,14 @@
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"@noble/ciphers": "^2.2.0",
"@noble/curves": "^1.9.1",
"@noble/hashes": "^1.7.1",
"@scure/base": "^1.2.6",
"@scure/bip39": "^1.5.4",
"canonicalize": "^2.0.0",
"diceware-wordlist-en-eff": "^1.0.1",
"hash-wasm": "^4.12.0"
"@noble/ciphers": "2.2.0",
"@noble/curves": "1.9.7",
"@noble/hashes": "1.8.0",
"@scure/base": "1.2.6",
"@scure/bip39": "1.6.0",
"canonicalize": "2.1.0",
"diceware-wordlist-en-eff": "1.0.1",
"hash-wasm": "4.12.0"
},
"devDependencies": {
"@types/node": "^22.10.0",
@@ -2019,12 +2019,19 @@
"license": "MIT",
"dependencies": {
"@bitlogin/core": "*",
"@getalby/bitcoin-connect": "^3.12.3"
"@getalby/bitcoin-connect": "3.12.3",
"qrcode-generator": "^2.0.4"
},
"devDependencies": {
"typescript": "^5.7.3",
"vite": "^6.1.0"
}
},
"packages/widget/node_modules/qrcode-generator": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-2.0.4.tgz",
"integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==",
"license": "MIT"
}
}
}
+1
View File
@@ -6,3 +6,4 @@ export * from "./relayUrl.js";
export * from "./pool.js";
export * from "./bootstrap.js";
export * from "./nip19.js";
export * from "./nip46.js";
+1
View File
@@ -5,6 +5,7 @@ export const KIND_DELETION_REQUEST = 5; // NIP-09
export const KIND_RELAY_LIST = 10002; // NIP-65
export const KIND_DM_RELAY_LIST = 10050; // NIP-17
export const KIND_AUTH = 22242; // NIP-42
export const KIND_NOSTR_CONNECT = 24133; // NIP-46 remote signing
export const KIND_GIFT_WRAP = 1059; // NIP-59 (Phase 2 messaging, not wired up by this MVP)
export const KIND_APP_DATA = 30078; // NIP-78
+326
View File
@@ -0,0 +1,326 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { MockRelay } from "../test-support/mockRelay.js";
import { RelayConnection } from "./relay.js";
import { signNostrEvent, type NostrEvent } from "./event.js";
import { KIND_NOSTR_CONNECT } from "./kinds.js";
import {
buildNostrconnectUri,
listenForNostrconnect,
Nip46Client,
Nip46ErrorResponse,
Nip46RequestTimeoutError,
parseBunkerUri
} from "./nip46.js";
import { getConversationKey, nip44Decrypt, nip44Encrypt } from "../crypto/nip44.js";
import { generatePrivateKey, getPublicKeyHex } from "../crypto/secp256k1.js";
import { randomBytes } from "../crypto/random.js";
import { bytesToHex } from "../crypto/encoding.js";
const SIGNER_HEX = "ab".repeat(32);
describe("parseBunkerUri", () => {
it("parses signer pubkey, relays, and secret", () => {
const parsed = parseBunkerUri(
`bunker://${SIGNER_HEX}?relay=wss://one.example&relay=wss://two.example&secret=tok3n`
);
expect(parsed.signerPubkey).toBe(SIGNER_HEX);
expect(parsed.relayUrls).toEqual(["wss://one.example", "wss://two.example"]);
expect(parsed.secret).toBe("tok3n");
});
it("accepts an uppercase pubkey and omitted secret", () => {
const parsed = parseBunkerUri(`bunker://${SIGNER_HEX.toUpperCase()}?relay=wss://one.example`);
expect(parsed.signerPubkey).toBe(SIGNER_HEX);
expect(parsed.secret).toBeUndefined();
});
it("rejects wrong schemes, bad pubkeys, and relay-less URIs", () => {
expect(() => parseBunkerUri(`nostrconnect://${SIGNER_HEX}?relay=wss://x.example`)).toThrow(
/bunker:\/\//
);
expect(() => parseBunkerUri("bunker://nothex?relay=wss://x.example")).toThrow(/public key/);
expect(() => parseBunkerUri(`bunker://${SIGNER_HEX}`)).toThrow(/no relays/);
expect(() => parseBunkerUri("not a uri at all")).toThrow(/valid URI/);
});
});
describe("buildNostrconnectUri", () => {
it("round-trips through URL parsing with relays, secret, and name", () => {
const uri = buildNostrconnectUri({
clientPubkey: SIGNER_HEX,
relayUrls: ["wss://one.example", "wss://two.example"],
secret: "s3cret",
name: "BitLogin"
});
expect(uri.startsWith(`nostrconnect://${SIGNER_HEX}?`)).toBe(true);
const url = new URL(uri);
expect(url.searchParams.getAll("relay")).toEqual(["wss://one.example", "wss://two.example"]);
expect(url.searchParams.get("secret")).toBe("s3cret");
expect(url.searchParams.get("name")).toBe("BitLogin");
});
});
/**
* A minimal in-test remote signer: listens for kind-24133 requests addressed
* to its pubkey, decrypts them per client, and answers per `handle`.
*/
class FakeBunker {
readonly signerKey = generatePrivateKey();
readonly signerPubkey = getPublicKeyHex(this.signerKey);
readonly userKey = generatePrivateKey();
readonly userPubkey = getPublicKeyHex(this.userKey);
readonly seen: Array<{ method: string; params: string[] }> = [];
private conn: RelayConnection;
private sub: { close: () => void } | null = null;
/** Per-request interception; return the responses to send (in order). */
handle: (request: { id: string; method: string; params: string[] }) => Array<{
id: string;
result?: string;
error?: string;
}>;
constructor(relayUrl: string, private connectSecret = "") {
this.conn = new RelayConnection(relayUrl);
this.handle = (request) => {
this.seen.push({ method: request.method, params: request.params });
if (request.method === "connect") {
if (this.connectSecret && request.params[1] !== this.connectSecret) {
return [{ id: request.id, error: "invalid secret" }];
}
return [{ id: request.id, result: "ack" }];
}
if (request.method === "get_public_key") return [{ id: request.id, result: this.userPubkey }];
if (request.method === "sign_event") {
const unsigned = JSON.parse(request.params[0]!) as {
kind: number;
content: string;
tags: string[][];
created_at: number;
};
const signed = signNostrEvent(
{
pubkey: this.userPubkey,
created_at: unsigned.created_at,
kind: unsigned.kind,
tags: unsigned.tags,
content: unsigned.content
},
this.userKey
);
return [{ id: request.id, result: JSON.stringify(signed) }];
}
if (request.method === "nip44_encrypt") {
return [{ id: request.id, result: `cipher(${request.params[1]})` }];
}
return [{ id: request.id, error: `unsupported method ${request.method}` }];
};
}
async start(): Promise<void> {
this.sub = await this.conn.subscribeLive(
{ kinds: [KIND_NOSTR_CONNECT], "#p": [this.signerPubkey] },
(event) => void this.onRequest(event)
);
}
async respond(clientPubkey: string, payload: { id: string; result?: string; error?: string }): Promise<void> {
const conversationKey = getConversationKey(this.signerKey, clientPubkey);
const event = signNostrEvent(
{
pubkey: this.signerPubkey,
created_at: Math.floor(Date.now() / 1000),
kind: KIND_NOSTR_CONNECT,
tags: [["p", clientPubkey]],
content: nip44Encrypt(conversationKey, JSON.stringify(payload))
},
this.signerKey
);
await this.conn.publish(event);
}
private async onRequest(event: NostrEvent): Promise<void> {
const conversationKey = getConversationKey(this.signerKey, event.pubkey);
const request = JSON.parse(nip44Decrypt(conversationKey, event.content)) as {
id: string;
method: string;
params: string[];
};
for (const response of this.handle(request)) {
await this.respond(event.pubkey, response);
}
}
close(): void {
this.sub?.close();
this.conn.close();
}
}
describe("Nip46Client against a fake bunker", () => {
let relay: MockRelay;
let bunker: FakeBunker;
let client: Nip46Client;
beforeEach(async () => {
relay = await MockRelay.start();
});
afterEach(async () => {
client?.close();
bunker?.close();
await relay.close();
});
function makeClient(secret?: string, timeoutMs = 5000): Nip46Client {
client = new Nip46Client({
clientSecretKey: generatePrivateKey(),
pointer: { signerPubkey: bunker.signerPubkey, relayUrls: [relay.url], secret },
requestTimeoutMs: timeoutMs
});
return client;
}
it("connects with the secret, fetches the user pubkey, and signs a verifying event", async () => {
bunker = new FakeBunker(relay.url, "tok3n");
await bunker.start();
const c = makeClient("tok3n");
await c.connect();
expect(await c.getUserPublicKey()).toBe(bunker.userPubkey);
const signed = await c.signEvent({
kind: 1,
content: "hello",
tags: [],
created_at: 1_700_000_000
});
expect(signed.pubkey).toBe(bunker.userPubkey);
expect(signed.content).toBe("hello");
// The user's pubkey travels with subsequent sign requests once known.
const signParams = bunker.seen.find((r) => r.method === "sign_event")!.params;
expect(JSON.parse(signParams[0]!).pubkey).toBe(bunker.userPubkey);
});
it("rejects a wrong connect secret with the signer's error", async () => {
bunker = new FakeBunker(relay.url, "tok3n");
await bunker.start();
const c = makeClient("wrong");
await expect(c.connect()).rejects.toBeInstanceOf(Nip46ErrorResponse);
});
it("surfaces auth_url once and still resolves the eventual answer", async () => {
bunker = new FakeBunker(relay.url);
const inner = bunker.handle.bind(bunker);
bunker.handle = (request) => {
if (request.method === "get_public_key") {
return [
{ id: request.id, result: "auth_url", error: "https://approve.example/req" },
// Same id, later: the real answer after "approval".
...inner(request)
];
}
return inner(request);
};
await bunker.start();
const authUrls: string[] = [];
client = new Nip46Client({
clientSecretKey: generatePrivateKey(),
pointer: { signerPubkey: bunker.signerPubkey, relayUrls: [relay.url] },
onAuthUrl: (url) => authUrls.push(url),
requestTimeoutMs: 5000
});
expect(await client.getUserPublicKey()).toBe(bunker.userPubkey);
expect(authUrls).toEqual(["https://approve.example/req"]);
});
it("times out with a typed error when the signer never answers", async () => {
bunker = new FakeBunker(relay.url);
bunker.handle = () => []; // listens, never replies
await bunker.start();
const c = makeClient(undefined, 300);
await expect(c.connect()).rejects.toBeInstanceOf(Nip46RequestTimeoutError);
});
it("rejects a signed event from the wrong identity", async () => {
bunker = new FakeBunker(relay.url);
const impostorKey = generatePrivateKey();
bunker.handle = (request) => {
bunker.seen.push({ method: request.method, params: request.params });
if (request.method === "connect") return [{ id: request.id, result: "ack" }];
if (request.method === "get_public_key") return [{ id: request.id, result: bunker.userPubkey }];
const unsigned = JSON.parse(request.params[0]!) as { kind: number; content: string; tags: string[][]; created_at: number };
const signed = signNostrEvent(
{
pubkey: getPublicKeyHex(impostorKey),
created_at: unsigned.created_at,
kind: unsigned.kind,
tags: unsigned.tags,
content: unsigned.content
},
impostorKey
);
return [{ id: request.id, result: JSON.stringify(signed) }];
};
await bunker.start();
const c = makeClient();
await c.connect();
await c.getUserPublicKey();
await expect(
c.signEvent({ kind: 1, content: "x", tags: [], created_at: 1_700_000_000 })
).rejects.toThrow(/different identity/);
});
it("passes nip44 encryption RPCs through", async () => {
bunker = new FakeBunker(relay.url);
await bunker.start();
const c = makeClient();
await c.connect();
expect(await c.nip44Encrypt("cd".repeat(32), "hi")).toBe("cipher(hi)");
});
});
describe("listenForNostrconnect", () => {
let relay: MockRelay;
beforeEach(async () => {
relay = await MockRelay.start();
});
afterEach(async () => {
await relay.close();
});
it("resolves the signer pubkey when the secret is echoed, ignoring wrong secrets", async () => {
const clientSecretKey = generatePrivateKey();
const clientPubkey = getPublicKeyHex(clientSecretKey);
const secret = bytesToHex(randomBytes(8));
const wait = listenForNostrconnect({
clientSecretKey,
relayUrls: [relay.url],
secret,
timeoutMs: 5000
});
const impostor = new FakeBunker(relay.url);
await impostor.respond(clientPubkey, { id: "x", result: "not-the-secret" });
const genuine = new FakeBunker(relay.url);
await genuine.respond(clientPubkey, { id: "y", result: secret });
const { signerPubkey } = await wait;
expect(signerPubkey).toBe(genuine.signerPubkey);
impostor.close();
genuine.close();
});
it("times out when no signer connects", async () => {
await expect(
listenForNostrconnect({
clientSecretKey: generatePrivateKey(),
relayUrls: [relay.url],
secret: "s",
timeoutMs: 300
})
).rejects.toThrow(/No signer connected/);
});
});
+389
View File
@@ -0,0 +1,389 @@
/**
* NIP-46 remote-signer ("bunker") client (docs/login-methods.md §LM5).
*
* The user's key lives in their signer (Amber, nsec.app, an nsecBunker …); this
* client holds only an ephemeral keypair and a connection secret, and requests
* signatures over relays as kind-24133 events whose content is NIP-44-encrypted
* between the client key and the remote signer's key. Memory-only by design:
* nothing here persists anything, and closing the client ends the session.
*
* Two ways to establish a session:
* - `bunker://` (parseBunkerUri): the user pastes a URI naming the signer's
* pubkey, relays, and an optional secret; the client calls `connect`.
* - nostrconnect (buildNostrconnectUri + listenForNostrconnect): the client
* displays a URI/QR naming ITS pubkey, relays, and a fresh secret; the
* signer initiates and proves itself by echoing the secret.
*
* Transport encryption is NIP-44 only, per the current NIP-46 spec; legacy
* NIP-04-only bunkers are not supported.
*/
import { RelayConnection } from "./relay.js";
import {
signNostrEvent,
verifyNostrEvent,
type NostrEvent,
type NostrTag
} from "./event.js";
import { KIND_NOSTR_CONNECT } from "./kinds.js";
import { getConversationKey, nip44Encrypt, nip44Decrypt } from "../crypto/nip44.js";
import { getPublicKeyHex } from "../crypto/secp256k1.js";
import { randomBytes } from "../crypto/random.js";
import { bytesToHex } from "../crypto/encoding.js";
const HEX64 = /^[0-9a-f]{64}$/u;
export interface BunkerPointer {
signerPubkey: string;
relayUrls: string[];
secret?: string;
}
export class BunkerUriParseError extends Error {
override name = "BunkerUriParseError";
}
export class Nip46RequestTimeoutError extends Error {
override name = "Nip46RequestTimeoutError";
constructor(method: string, timeoutMs: number) {
super(
`The remote signer did not answer ${method} within ${Math.round(timeoutMs / 1000)}s. It may be offline, or waiting for your approval on another device -- check your signer and try again.`
);
}
}
/** The signer answered, and the answer was a refusal or failure. */
export class Nip46ErrorResponse extends Error {
override name = "Nip46ErrorResponse";
}
export function parseBunkerUri(uri: string): BunkerPointer {
let url: URL;
try {
url = new URL(uri.trim());
} catch {
throw new BunkerUriParseError("That doesn't look like a valid URI.");
}
if (url.protocol !== "bunker:") {
throw new BunkerUriParseError("A remote-signer URI must start with bunker://");
}
// Non-special schemes parse inconsistently across engines: the pubkey may
// land in host or in pathname ("//<hex>"). Accept both.
const signerPubkey = (url.host || url.pathname.replace(/^\/+/u, "")).toLowerCase();
if (!HEX64.test(signerPubkey)) {
throw new BunkerUriParseError("The bunker URI does not name a valid signer public key.");
}
const relayUrls = url.searchParams
.getAll("relay")
.map((value) => value.trim())
.filter(Boolean);
if (relayUrls.length === 0) {
throw new BunkerUriParseError(
"The bunker URI names no relays -- without at least one relay= parameter there is no way to reach the signer."
);
}
const secret = url.searchParams.get("secret") ?? undefined;
return { signerPubkey, relayUrls, secret: secret || undefined };
}
/** The URI a signer app scans (or receives via paste) in the nostrconnect flow.
* Names the CLIENT's pubkey, the relays to answer on, and a fresh secret the
* signer must echo back to prove it scanned this exact code. */
export function buildNostrconnectUri(options: {
clientPubkey: string;
relayUrls: string[];
secret: string;
name?: string;
perms?: string[];
}): string {
if (!HEX64.test(options.clientPubkey)) throw new Error("Client public key must be 64 hex characters.");
if (options.relayUrls.length === 0) throw new Error("At least one relay is required.");
if (!options.secret) throw new Error("A nostrconnect secret is required.");
const params = new URLSearchParams();
for (const relay of options.relayUrls) params.append("relay", relay);
params.set("secret", options.secret);
if (options.name) params.set("name", options.name);
if (options.perms?.length) params.set("perms", options.perms.join(","));
return `nostrconnect://${options.clientPubkey}?${params.toString()}`;
}
interface PendingRequest {
resolve: (result: string) => void;
reject: (error: Error) => void;
timer: ReturnType<typeof setTimeout>;
authUrlSeen: boolean;
}
export interface Nip46ClientOptions {
clientSecretKey: Uint8Array;
pointer: BunkerPointer;
/** Called (once per request) when the signer asks for interactive approval
* at a URL before it will answer -- surface it to the user and keep waiting. */
onAuthUrl?: (url: string) => void;
requestTimeoutMs?: number;
}
/** Deadline generous enough for a human to approve a prompt on another device,
* but finite -- no silent hangs (§LM3). */
const DEFAULT_REQUEST_TIMEOUT_MS = 120_000;
export class Nip46Client {
private readonly clientSecretKey: Uint8Array;
private readonly clientPubkey: string;
private readonly pointer: BunkerPointer;
private readonly conversationKey: Uint8Array;
private readonly conns: RelayConnection[];
private readonly onAuthUrl?: (url: string) => void;
private readonly requestTimeoutMs: number;
private readonly pending = new Map<string, PendingRequest>();
private subs: Array<{ close: () => void }> = [];
private startPromise: Promise<void> | null = null;
private userPubkeyKnown: string | null = null;
private closed = false;
constructor(options: Nip46ClientOptions) {
this.clientSecretKey = options.clientSecretKey;
this.clientPubkey = getPublicKeyHex(options.clientSecretKey);
this.pointer = options.pointer;
this.conversationKey = getConversationKey(options.clientSecretKey, options.pointer.signerPubkey);
this.onAuthUrl = options.onAuthUrl;
this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
this.conns = options.pointer.relayUrls.map(
(url) => new RelayConnection(url, { authPrivateKey: options.clientSecretKey })
);
}
/** The user pubkey this session signs for, once get_public_key has answered. */
get userPublicKey(): string | null {
return this.userPubkeyKnown;
}
private async start(): Promise<void> {
if (this.closed) throw new Error("This signer connection is closed.");
if (!this.startPromise) {
this.startPromise = (async () => {
const filter = {
kinds: [KIND_NOSTR_CONNECT],
authors: [this.pointer.signerPubkey],
"#p": [this.clientPubkey]
};
const results = await Promise.allSettled(
this.conns.map((conn) => conn.subscribeLive(filter, (event) => this.handleResponse(event)))
);
this.subs = results
.filter((r): r is PromiseFulfilledResult<{ close: () => void }> => r.status === "fulfilled")
.map((r) => r.value);
if (this.subs.length === 0) {
this.startPromise = null;
throw new Error("Could not reach any of the signer's relays.");
}
})();
}
return this.startPromise;
}
private handleResponse(event: NostrEvent): void {
// The subscription filter already constrains author and #p; re-check the
// author anyway -- relays are untrusted (§16.1) and this is the identity
// the whole session's security rests on.
if (event.pubkey !== this.pointer.signerPubkey) return;
let payload: { id?: unknown; result?: unknown; error?: unknown };
try {
payload = JSON.parse(nip44Decrypt(this.conversationKey, event.content)) as typeof payload;
} catch {
return;
}
if (typeof payload.id !== "string") return;
const request = this.pending.get(payload.id);
if (!request) return; // answered already by a faster relay, or stale
if (payload.result === "auth_url") {
// Interim, not an answer: the signer wants interactive approval at the
// URL (carried in `error`, per NIP-46). Surface once, keep waiting.
if (!request.authUrlSeen) {
request.authUrlSeen = true;
if (typeof payload.error === "string" && payload.error) this.onAuthUrl?.(payload.error);
}
return;
}
this.pending.delete(payload.id);
clearTimeout(request.timer);
if (typeof payload.error === "string" && payload.error) {
request.reject(new Nip46ErrorResponse(payload.error));
return;
}
if (typeof payload.result !== "string") {
request.reject(new Error("The signer returned a malformed response."));
return;
}
request.resolve(payload.result);
}
private async rpc(method: string, params: string[]): Promise<string> {
await this.start();
const id = bytesToHex(randomBytes(8));
const content = nip44Encrypt(this.conversationKey, JSON.stringify({ id, method, params }));
const tags: NostrTag[] = [["p", this.pointer.signerPubkey]];
const event = signNostrEvent(
{
pubkey: this.clientPubkey,
created_at: Math.floor(Date.now() / 1000),
kind: KIND_NOSTR_CONNECT,
tags,
content
},
this.clientSecretKey
);
const wait = new Promise<string>((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new Nip46RequestTimeoutError(method, this.requestTimeoutMs));
}, this.requestTimeoutMs);
this.pending.set(id, { resolve, reject, timer, authUrlSeen: false });
});
// Swallow the local rejection path used below; callers await `wait` only
// when at least one relay accepted the request.
wait.catch(() => {});
const publishes = await Promise.allSettled(this.conns.map((conn) => conn.publish(event)));
const accepted = publishes.some((p) => p.status === "fulfilled" && p.value.ok);
if (!accepted) {
const request = this.pending.get(id);
if (request) {
this.pending.delete(id);
clearTimeout(request.timer);
}
throw new Error("No relay accepted the request to the signer.");
}
return wait;
}
/** bunker:// flow: announce ourselves; any non-error answer is acceptance. */
async connect(): Promise<void> {
await this.rpc("connect", [this.pointer.signerPubkey, this.pointer.secret ?? ""]);
}
async getUserPublicKey(): Promise<string> {
const result = (await this.rpc("get_public_key", [])).trim().toLowerCase();
if (!HEX64.test(result)) throw new Error("The signer returned an invalid public key.");
this.userPubkeyKnown = result;
return result;
}
async signEvent(unsigned: {
kind: number;
content: string;
tags: string[][];
created_at: number;
}): Promise<NostrEvent> {
const toSign = this.userPubkeyKnown ? { ...unsigned, pubkey: this.userPubkeyKnown } : unsigned;
const result = await this.rpc("sign_event", [JSON.stringify(toSign)]);
let event: unknown;
try {
event = JSON.parse(result);
} catch {
throw new Error("The signer returned a malformed signed event.");
}
if (!verifyNostrEvent(event)) {
throw new Error("The signer returned an event that does not verify.");
}
if (this.userPubkeyKnown && event.pubkey !== this.userPubkeyKnown) {
throw new Error("The signer returned an event signed by a different identity.");
}
return event;
}
async nip44Encrypt(peerPublicKey: string, plaintext: string): Promise<string> {
return this.rpc("nip44_encrypt", [peerPublicKey, plaintext]);
}
async nip44Decrypt(peerPublicKey: string, payload: string): Promise<string> {
return this.rpc("nip44_decrypt", [peerPublicKey, payload]);
}
async nip04Encrypt(peerPublicKey: string, plaintext: string): Promise<string> {
return this.rpc("nip04_encrypt", [peerPublicKey, plaintext]);
}
async nip04Decrypt(peerPublicKey: string, payload: string): Promise<string> {
return this.rpc("nip04_decrypt", [peerPublicKey, payload]);
}
close(): void {
this.closed = true;
for (const sub of this.subs) sub.close();
this.subs = [];
for (const conn of this.conns) conn.close();
for (const [, request] of this.pending) {
clearTimeout(request.timer);
request.reject(new Error("The signer connection was closed."));
}
this.pending.clear();
}
}
/**
* nostrconnect flow: after the URI from buildNostrconnectUri is shown to the
* signer (QR or paste), wait for ANY signer to send a kind-24133 event to the
* client pubkey whose decrypted payload echoes the secret. The echo is the
* proof of possession -- an attacker who saw the client pubkey on a relay but
* not the URI cannot produce it. Resolves the signer's pubkey; hand it to
* Nip46Client as a BunkerPointer (same clientSecretKey) to run the session.
*/
export async function listenForNostrconnect(options: {
clientSecretKey: Uint8Array;
relayUrls: string[];
secret: string;
timeoutMs?: number;
}): Promise<{ signerPubkey: string }> {
const clientPubkey = getPublicKeyHex(options.clientSecretKey);
const timeoutMs = options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
const conns = options.relayUrls.map(
(url) => new RelayConnection(url, { authPrivateKey: options.clientSecretKey })
);
const closeAll = () => {
for (const conn of conns) conn.close();
};
return new Promise<{ signerPubkey: string }>((resolve, reject) => {
let settled = false;
const settle = (outcome: { signerPubkey: string } | Error) => {
if (settled) return;
settled = true;
clearTimeout(timer);
closeAll();
if (outcome instanceof Error) reject(outcome);
else resolve(outcome);
};
const timer = setTimeout(
() =>
settle(
new Error(
`No signer connected within ${Math.round(timeoutMs / 1000)}s. Scan or paste the connection code in your signer app, then try again.`
)
),
timeoutMs
);
const filter = { kinds: [KIND_NOSTR_CONNECT], "#p": [clientPubkey] };
const onEvent = (event: NostrEvent) => {
// The sender is unknown until the secret verifies, so the conversation
// key is derived per candidate event.
let payload: { result?: unknown };
try {
const conversationKey = getConversationKey(options.clientSecretKey, event.pubkey);
payload = JSON.parse(nip44Decrypt(conversationKey, event.content)) as typeof payload;
} catch {
return;
}
if (payload.result === options.secret) settle({ signerPubkey: event.pubkey });
};
void Promise.allSettled(conns.map((conn) => conn.subscribeLive(filter, onEvent))).then(
(results) => {
if (!results.some((r) => r.status === "fulfilled")) {
settle(new Error("Could not reach any relay to listen for the signer."));
}
}
);
});
}
+46 -3
View File
@@ -56,7 +56,14 @@ export class RelayConnection {
private connectPromise: Promise<void> | null = null;
private subs = new Map<
string,
{ events: NostrEvent[]; filter: NostrFilter; onEose: () => void }
{
events: NostrEvent[];
filter: NostrFilter;
onEose: () => void;
/** Present on live subscriptions (subscribeLive): each verified,
* filter-matching event is delivered here instead of buffered. */
onEvent?: (event: NostrEvent) => void;
}
>();
private pendingPublishes = new Map<string, (result: PublishResult) => void>();
private readonly authPrivateKey?: Uint8Array;
@@ -86,6 +93,13 @@ export class RelayConnection {
socket.addEventListener("open", () => {
clearTimeout(timer);
// Re-issue REQ for every subscription that predates this (re)connection.
// A socket that dropped and reopened lost its server-side subscriptions;
// without this, a live subscription (subscribeLive) would appear open
// client-side while the relay no longer delivers anything to it.
for (const [subId, sub] of this.subs) {
socket.send(JSON.stringify(["REQ", subId, sub.filter]));
}
resolve();
});
socket.addEventListener("error", () => {
@@ -136,8 +150,10 @@ export class RelayConnection {
sub &&
verifyNostrEvent(event) &&
matchesNostrFilter(event, sub.filter)
)
sub.events.push(event);
) {
if (sub.onEvent) sub.onEvent(event);
else sub.events.push(event);
}
return;
}
if (type === "EOSE") {
@@ -207,6 +223,33 @@ export class RelayConnection {
});
}
/**
* Opens a subscription that stays live past EOSE: every verified,
* filter-matching event is delivered to `onEvent` as it arrives, until the
* returned handle is closed. Survives socket drops -- connect() re-issues
* REQ for all open subscriptions on reopen -- though events published while
* the socket was down are only seen if the relay replays them on the new REQ.
*/
async subscribeLive(
filter: NostrFilter,
onEvent: (event: NostrEvent) => void,
): Promise<{ close: () => void }> {
await this.connect();
const subId = bytesToHex(randomBytes(8));
this.subs.set(subId, { events: [], filter, onEose: () => {}, onEvent });
this.send(["REQ", subId, filter]);
return {
close: () => {
if (!this.subs.delete(subId)) return;
try {
this.send(["CLOSE", subId]);
} catch {
// Socket already gone; the relay-side subscription died with it.
}
},
};
}
async queryOnce(
filter: NostrFilter,
timeoutMs = 8000,
@@ -7,6 +7,7 @@
*/
import { WebSocketServer, type WebSocket } from "ws";
import { compareNip01ReplacementOrder, verifyNostrEvent, findTagValue, type NostrEvent } from "../nostr/event.js";
import { matchesNostrFilter, type NostrFilter } from "../nostr/relay.js";
function storageKey(event: NostrEvent): string {
if (event.kind >= 30000 && event.kind < 40000) {
@@ -28,6 +29,10 @@ export class MockRelay {
private wss: WebSocketServer;
private events = new Map<string, NostrEvent>();
private plainEvents: NostrEvent[] = [];
/** Open subscriptions per socket, for live post-EOSE delivery -- real relays
* push newly published events to every matching open REQ, and NIP-46 round
* trips depend on that. */
private liveSubs = new Map<WebSocket, Map<string, NostrFilter>>();
public requireAuthForKinds: Set<number> = new Set();
/** Pubkeys whose publishes are refused. Models the partially-hostile relay
* that accepts some writes and drops others -- the case that makes write
@@ -88,6 +93,7 @@ export class MockRelay {
}
this.store(event);
socket.send(JSON.stringify(["OK", event.id, true, ""]));
this.broadcast(event);
return;
}
if (type === "REQ") {
@@ -96,12 +102,34 @@ export class MockRelay {
for (const event of this.unsolicitedQueryEvents) socket.send(JSON.stringify(["EVENT", subId, event]));
for (const event of matches) socket.send(JSON.stringify(["EVENT", subId, event]));
socket.send(JSON.stringify(["EOSE", subId]));
let subsForSocket = this.liveSubs.get(socket);
if (!subsForSocket) {
subsForSocket = new Map();
this.liveSubs.set(socket, subsForSocket);
}
subsForSocket.set(subId, (filters[0] ?? {}) as NostrFilter);
return;
}
if (type === "CLOSE") {
const [subId] = rest as [string];
if (typeof subId === "string") this.liveSubs.get(socket)?.delete(subId);
return;
}
});
socket.on("close", () => {
this.liveSubs.delete(socket);
});
}
/** Pushes a just-published event to every open subscription it matches. */
private broadcast(event: NostrEvent): void {
for (const [socket, subs] of this.liveSubs) {
for (const [subId, filter] of subs) {
if (matchesNostrFilter(event, filter)) {
socket.send(JSON.stringify(["EVENT", subId, event]));
}
}
}
}
private store(event: NostrEvent): void {
+4 -3
View File
@@ -63,7 +63,7 @@
bl.addEventListener('bitlogin-login', (e) => {
console.log('signed in as', e.detail.publicKey);
console.log('via', e.detail.method); // 'bitlogin' | 'nip07'
console.log('via', e.detail.method); // 'bitlogin' | 'nip07' | 'nip46'
console.log('supports', e.detail.capabilities); // { nip44, nip04, getRelays }
});
@@ -73,8 +73,9 @@ bl.addEventListener('bitlogin-logout', () => {
<div class="callout">
<strong>One login surface, several methods.</strong> If the visitor's browser has a NIP-07 signer
extension (Alby, nos2x, …), the widget's welcome screen offers "Use your Nostr extension instead"
below the primary username/password sign-in — you embed one element and both kinds of users are
covered. <code>detail.method</code> tells you which way the user came in, and
below the primary username/password sign-in, and every visitor gets "Use a remote signer (bunker)" —
a NIP-46 connect screen with a scannable QR for phone signers (Amber, nsec.app) and a
<code>bunker://</code> paste box. You embed one element and all three kinds of users are covered. <code>detail.method</code> tells you which way the user came in, and
<code>detail.capabilities</code> what the active signer supports (some extensions lack
<code>nip44</code>). Extension sessions are signer-only: signing and encryption work through the
element exactly the same, but BitLogin account features — wallet connections, password rotation,
+10 -6
View File
@@ -30,12 +30,16 @@ deployment above.
password rotation.
- One login surface, several methods: if the visitor's browser has a NIP-07
signer extension, the welcome screen offers "Use your Nostr extension
instead" below the primary username/password path, and the element routes
`getPublicKey`/`signEvent`/`nip44*`/`nip04*` through the extension for that
session. Extension sessions are signer-only: no vault
(`requestNwcConnection` resolves null), no persistence (a reload ends the
session), and `window.nostr` is left to the extension. `bitlogin-login`'s
`detail.method` (`'bitlogin' | 'nip07'`) and `detail.capabilities`
instead" below the primary username/password path, and every visitor gets
"Use a remote signer (bunker)" -- a NIP-46 connect screen with a scannable
nostrconnect QR for phone signers (Amber, nsec.app, ...) and a bunker://
paste box. The element routes `getPublicKey`/`signEvent`/`nip44*`/`nip04*`
through whichever signer backs the session. Alternative-method sessions are
signer-only: no vault (`requestNwcConnection` resolves null), no
persistence (a reload ends the session). NIP-07 sessions leave
`window.nostr` to the extension; NIP-46 sessions claim it, backed by the
worker-held remote-signer client. `bitlogin-login`'s `detail.method`
(`'bitlogin' | 'nip07' | 'nip46'`) and `detail.capabilities`
(`{ nip44, nip04, getRelays }`) tell your app which backend is active and
what it supports.
- A `window.nostr` provider shaped like the de facto NIP-07 interface
+2 -1
View File
@@ -13,7 +13,8 @@
},
"dependencies": {
"@bitlogin/core": "*",
"@getalby/bitcoin-connect": "3.12.3"
"@getalby/bitcoin-connect": "3.12.3",
"qrcode-generator": "^2.0.4"
},
"devDependencies": {
"typescript": "^5.7.3",
+220 -28
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,12 +1619,18 @@ 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>
+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", {});
}