mirror of
https://github.com/PR0M3TH3AN/bitlogin.git
synced 2026-10-27 08:31:21 +00:00
NIP-46 sign-in: remote-signer sessions behind the same facade, memory-only
Implements login-methods.md §LM5 phase one. Core gains nostr/nip46.ts -- a Nip46Client speaking kind-24133 RPC with NIP-44 transport encryption, bunker:// parsing, nostrconnect listen (secret echo is proof-of-scan), auth_url surfacing, and typed deadline errors -- plus subscribeLive on RelayConnection with re-REQ on reconnect, and live post-EOSE delivery in the MockRelay so tests exercise real round trips against a fake bunker. The ephemeral client key and Nip46Client live in the crypto worker (§LM5.4); nip46* actions bypass the serial queue so a signer waiting minutes for human approval cannot block other worker calls, and the signer's auth_url reaches the UI as the worker's first notification frame. The widget's connect screen runs both legs at once: a nostrconnect QR -- rendered as dot-style inline SVG with rounded finder eyes, per owner preference -- for phone signers, and a bunker:// paste box (password input; the URI carries a token). window.nostr is now an element-routed provider, so a NIP-46 session claims the slot and hosts reading it get the remote signer transparently; NIP-07 still leaves the slot to the extension. Sessions stay thin per §LM7: no vault, no persistence. Persistence remains gated on the external vault review (§LM5.3). 14 new tests (core fake-bunker e2e, QR geometry, signer delegation) and two policy pins: bunker option below the password path, element stays web-storage-free. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
1 parent
b616068689
commit
49682dd75c
23 files changed
+1624
-69
No files matched your search
+14
-8
@@ -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.
|
||||
|
||||
|
||||
Generated
+16
-9
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,3 +6,4 @@ export * from "./relayUrl.js";
|
||||
export * from "./pool.js";
|
||||
export * from "./bootstrap.js";
|
||||
export * from "./nip19.js";
|
||||
export * from "./nip46.js";
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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."));
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -34,6 +34,50 @@ export interface Nip07Provider {
|
||||
readonly _bitlogin: true;
|
||||
}
|
||||
|
||||
/** The signing surface a <bitlogin-auth> element exposes; the routed provider
|
||||
* below delegates to it so window.nostr always follows the ACTIVE signer. */
|
||||
export interface ElementSignerApi {
|
||||
getPublicKey(): Promise<string>;
|
||||
signEvent(event: UnsignedEventForSigning): Promise<NostrEvent>;
|
||||
nip44Encrypt(peerPublicKey: string, plaintext: string): Promise<string>;
|
||||
nip44Decrypt(peerPublicKey: string, payload: string): Promise<string>;
|
||||
nip04Encrypt(peerPublicKey: string, plaintext: string): Promise<string>;
|
||||
nip04Decrypt(peerPublicKey: string, payload: string): Promise<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A window.nostr provider that routes through the element rather than binding
|
||||
* the worker directly. The element dispatches each call to whichever signer
|
||||
* backs the current session -- the crypto worker for a BitLogin account, the
|
||||
* worker-held NIP-46 client for a remote-signer session (§LM3, §LM5) -- so a
|
||||
* host page reading window.nostr gets the right backend without caring which
|
||||
* method the user picked. (NIP-07 sessions never claim the slot at all; the
|
||||
* extension that IS the backend already owns it, §LM4.)
|
||||
*/
|
||||
export function createElementRoutedProvider(
|
||||
element: ElementSignerApi,
|
||||
configuredRelays: () => string[]
|
||||
): Nip07Provider {
|
||||
return {
|
||||
getPublicKey: () => element.getPublicKey(),
|
||||
signEvent: (event) => element.signEvent(event),
|
||||
async getRelays(): Promise<Record<string, { read: boolean; write: boolean }>> {
|
||||
const out: Record<string, { read: boolean; write: boolean }> = {};
|
||||
for (const url of configuredRelays()) out[url] = { read: true, write: true };
|
||||
return out;
|
||||
},
|
||||
nip44: {
|
||||
encrypt: (peerPublicKey, plaintext) => element.nip44Encrypt(peerPublicKey, plaintext),
|
||||
decrypt: (peerPublicKey, payload) => element.nip44Decrypt(peerPublicKey, payload)
|
||||
},
|
||||
nip04: {
|
||||
encrypt: (peerPublicKey, plaintext) => element.nip04Encrypt(peerPublicKey, plaintext),
|
||||
decrypt: (peerPublicKey, payload) => element.nip04Decrypt(peerPublicKey, payload)
|
||||
},
|
||||
_bitlogin: true
|
||||
};
|
||||
}
|
||||
|
||||
const NOT_UNLOCKED_MESSAGE =
|
||||
"BitLogin: no identity is unlocked yet. Add <bitlogin-auth> to the page and let the user sign in, or call it programmatically before invoking window.nostr.";
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import qrcodeGenerator from "qrcode-generator";
|
||||
import { renderQrSvg } from "./qr.js";
|
||||
|
||||
const SAMPLE =
|
||||
"nostrconnect://" +
|
||||
"ab".repeat(32) +
|
||||
"?relay=wss%3A%2F%2Frelay.example&secret=0123456789abcdef&name=BitLogin";
|
||||
|
||||
describe("renderQrSvg", () => {
|
||||
it("renders a complete standalone SVG with backing, eyes, and dots", () => {
|
||||
const svg = renderQrSvg(SAMPLE);
|
||||
expect(svg.startsWith("<svg ")).toBe(true);
|
||||
expect(svg.endsWith("</svg>")).toBe(true);
|
||||
// One opaque backing rect plus three finder eyes of two rects each.
|
||||
expect(svg.match(/<rect /gu)!.length).toBe(1 + 3 * 2);
|
||||
// Data modules are circles -- the "dots" -- and there are plenty of them.
|
||||
expect(svg.match(/<circle /gu)!.length).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it("draws exactly the dark data modules the encoder produced, minus finder zones", () => {
|
||||
const qr = qrcodeGenerator(0, "M");
|
||||
qr.addData(SAMPLE);
|
||||
qr.make();
|
||||
const n = qr.getModuleCount();
|
||||
let expected = 0;
|
||||
const inEye = (r: number, c: number) =>
|
||||
(r < 7 && c < 7) || (r < 7 && c >= n - 7) || (r >= n - 7 && c < 7);
|
||||
for (let r = 0; r < n; r++) {
|
||||
for (let c = 0; c < n; c++) {
|
||||
if (!inEye(r, c) && qr.isDark(r, c)) expected++;
|
||||
}
|
||||
}
|
||||
const svg = renderQrSvg(SAMPLE);
|
||||
expect(svg.match(/<circle /gu)!.length).toBe(expected);
|
||||
});
|
||||
|
||||
it("keeps the quiet zone: no dot within four modules of the edge", () => {
|
||||
const svg = renderQrSvg(SAMPLE);
|
||||
const centers = [...svg.matchAll(/<circle cx="([\d.]+)" cy="([\d.]+)"/gu)].map((m) => [
|
||||
Number(m[1]),
|
||||
Number(m[2])
|
||||
]);
|
||||
const viewBox = /viewBox="0 0 (\d+) (\d+)"/u.exec(svg)!;
|
||||
const size = Number(viewBox[1]);
|
||||
for (const [cx, cy] of centers) {
|
||||
expect(cx).toBeGreaterThanOrEqual(4);
|
||||
expect(cy).toBeGreaterThanOrEqual(4);
|
||||
expect(cx).toBeLessThanOrEqual(size - 4);
|
||||
expect(cy).toBeLessThanOrEqual(size - 4);
|
||||
}
|
||||
});
|
||||
|
||||
it("escapes nothing it shouldn't: the payload never appears in the markup", () => {
|
||||
// The QR encodes the URI as modules; the raw string (with its secret) must
|
||||
// not leak into the SVG text.
|
||||
const svg = renderQrSvg(SAMPLE);
|
||||
expect(svg).not.toContain("nostrconnect");
|
||||
expect(svg).not.toContain("secret");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
/** Dot-style QR rendering for the nostrconnect connection code (§LM5).
|
||||
*
|
||||
* Renders the module matrix as inline SVG: data modules become circles, and
|
||||
* the three finder "eyes" become rounded squares -- dots everywhere would look
|
||||
* uniform but measurably hurts camera lock-on, since scanners find a code BY
|
||||
* its three square eyes. Inline SVG keeps this self-contained under the
|
||||
* strict deployment CSP (no canvas, no image assets) and crisp at any size.
|
||||
*
|
||||
* The white backing rect is part of the QR, not styling: scanners need quiet
|
||||
* zone and contrast regardless of the host page's theme.
|
||||
*/
|
||||
import qrcodeGenerator from "qrcode-generator";
|
||||
|
||||
const QUIET_ZONE = 4; // modules of white margin, per the QR spec
|
||||
const DOT_RADIUS = 0.42; // of a 1x1 module cell; large enough to scan, round enough to read as dots
|
||||
const DARK = "#14101f";
|
||||
const LIGHT = "#ffffff";
|
||||
|
||||
function inFinderZone(row: number, col: number, moduleCount: number): boolean {
|
||||
const nearStart = (v: number) => v < 7;
|
||||
const nearEnd = (v: number) => v >= moduleCount - 7;
|
||||
return (
|
||||
(nearStart(row) && nearStart(col)) ||
|
||||
(nearStart(row) && nearEnd(col)) ||
|
||||
(nearEnd(row) && nearStart(col))
|
||||
);
|
||||
}
|
||||
|
||||
function finderEye(x: number, y: number): string {
|
||||
// Outer 7x7 ring drawn as a stroked rounded square, inner 3x3 filled.
|
||||
return (
|
||||
`<rect x="${x + 0.5}" y="${y + 0.5}" width="6" height="6" rx="2.1" fill="none" stroke="${DARK}" stroke-width="1"/>` +
|
||||
`<rect x="${x + 2}" y="${y + 2}" width="3" height="3" rx="1.1" fill="${DARK}"/>`
|
||||
);
|
||||
}
|
||||
|
||||
/** Encodes `data` and returns a complete `<svg>` element string. */
|
||||
export function renderQrSvg(data: string, ariaLabel = "QR code"): string {
|
||||
const qr = qrcodeGenerator(0, "M");
|
||||
qr.addData(data);
|
||||
qr.make();
|
||||
const moduleCount = qr.getModuleCount();
|
||||
const size = moduleCount + QUIET_ZONE * 2;
|
||||
|
||||
const parts: string[] = [];
|
||||
parts.push(`<rect x="0" y="0" width="${size}" height="${size}" rx="3" fill="${LIGHT}"/>`);
|
||||
parts.push(finderEye(QUIET_ZONE, QUIET_ZONE));
|
||||
parts.push(finderEye(QUIET_ZONE + moduleCount - 7, QUIET_ZONE));
|
||||
parts.push(finderEye(QUIET_ZONE, QUIET_ZONE + moduleCount - 7));
|
||||
|
||||
for (let row = 0; row < moduleCount; row++) {
|
||||
for (let col = 0; col < moduleCount; col++) {
|
||||
if (inFinderZone(row, col, moduleCount) || !qr.isDark(row, col)) continue;
|
||||
const cx = QUIET_ZONE + col + 0.5;
|
||||
const cy = QUIET_ZONE + row + 0.5;
|
||||
parts.push(`<circle cx="${cx}" cy="${cy}" r="${DOT_RADIUS}" fill="${DARK}"/>`);
|
||||
}
|
||||
}
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" role="img" aria-label="${ariaLabel}" shape-rendering="geometricPrecision">${parts.join("")}</svg>`;
|
||||
}
|
||||
@@ -40,13 +40,36 @@ describe("widget security policy", () => {
|
||||
expect(passwordSignIn).toBeLessThan(extensionSlot);
|
||||
});
|
||||
|
||||
it("keeps alternative methods below the password path on the welcome screen", () => {
|
||||
// Same owner decision as the extension pin above, applied to NIP-46: the
|
||||
// remote-signer option renders after the primary sign-in button.
|
||||
const passwordSignIn = widgetSource.indexOf('data-action="goto-login">Sign in</button>');
|
||||
const bunkerOption = widgetSource.indexOf('data-action="goto-bunker">Use a remote signer');
|
||||
expect(passwordSignIn).toBeGreaterThan(-1);
|
||||
expect(bunkerOption).toBeGreaterThan(-1);
|
||||
expect(passwordSignIn).toBeLessThan(bunkerOption);
|
||||
});
|
||||
|
||||
it("treats the bunker URI as a secret and keeps the element storage-free", () => {
|
||||
// A bunker:// URI carries a connection token; it gets a password input
|
||||
// (no shoulder-surfing, no autofill heuristics), and the element itself
|
||||
// must never touch web storage -- alternative-method sessions are
|
||||
// memory-only by design (§LM5, §LM7), and the ONLY persistence in the
|
||||
// widget lives behind the worker's session cache.
|
||||
expect(widgetSource).toContain('type="password" name="bunkerUri"');
|
||||
expect(widgetSource).not.toContain("localStorage");
|
||||
expect(widgetSource).not.toContain("sessionStorage");
|
||||
});
|
||||
|
||||
it("extension sessions never claim window.nostr and never persist", () => {
|
||||
// §LM4: NIP-07 mode delegates to the extension's provider; fighting it for the
|
||||
// window.nostr slot is reserved for BitLogin-account sessions. There must be no
|
||||
// claimSigner() call in the extension confirm path, and no storage write.
|
||||
const confirmBody = widgetSource.slice(
|
||||
widgetSource.indexOf("private handleExtensionConfirm"),
|
||||
widgetSource.indexOf("private async tryRestoreSession"),
|
||||
// Ends where the NIP-46 handlers begin -- those claim the slot
|
||||
// legitimately (§LM5: the routed provider IS that session's backend).
|
||||
widgetSource.indexOf("private async startNostrconnect"),
|
||||
);
|
||||
expect(confirmBody.length).toBeGreaterThan(0);
|
||||
expect(confirmBody).not.toContain("claimSigner");
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Nip46Signer } from "./nip46.js";
|
||||
import type { WorkerClient } from "../worker/workerClient.js";
|
||||
|
||||
const USER = "ab".repeat(32);
|
||||
|
||||
function fakeWorker(calls: Array<{ action: string; payload: unknown }>): WorkerClient {
|
||||
const record =
|
||||
(action: string, result: unknown) =>
|
||||
(payload: unknown): Promise<unknown> => {
|
||||
calls.push({ action, payload });
|
||||
return Promise.resolve(result);
|
||||
};
|
||||
return {
|
||||
nip46SignEvent: record("nip46SignEvent", { id: "e".repeat(64), pubkey: USER, sig: "f".repeat(128), kind: 1, tags: [], content: "x", created_at: 1 }),
|
||||
nip46Nip44Encrypt: record("nip46Nip44Encrypt", { ciphertext: "c44" }),
|
||||
nip46Nip44Decrypt: record("nip46Nip44Decrypt", { plaintext: "p44" }),
|
||||
nip46Nip04Encrypt: record("nip46Nip04Encrypt", { ciphertext: "c04" }),
|
||||
nip46Nip04Decrypt: record("nip46Nip04Decrypt", { plaintext: "p04" })
|
||||
} as unknown as WorkerClient;
|
||||
}
|
||||
|
||||
describe("Nip46Signer", () => {
|
||||
it("answers getPublicKey locally with the connect-time identity", async () => {
|
||||
const signer = new Nip46Signer(fakeWorker([]), USER);
|
||||
expect(signer.method).toBe("nip46");
|
||||
expect(await signer.getPublicKey()).toBe(USER);
|
||||
});
|
||||
|
||||
it("reports the full RPC capability surface", () => {
|
||||
const signer = new Nip46Signer(fakeWorker([]), USER);
|
||||
expect(signer.capabilities).toEqual({ nip44: true, nip04: true, getRelays: false });
|
||||
});
|
||||
|
||||
it("delegates signing and encryption to the worker-held client, mapping fields", async () => {
|
||||
const calls: Array<{ action: string; payload: unknown }> = [];
|
||||
const signer = new Nip46Signer(fakeWorker(calls), USER);
|
||||
|
||||
await signer.signEvent({ kind: 1, content: "hello", tags: [["t", "x"]], created_at: 123 });
|
||||
expect(calls[0]).toEqual({
|
||||
action: "nip46SignEvent",
|
||||
payload: { kind: 1, content: "hello", tags: [["t", "x"]], created_at: 123 }
|
||||
});
|
||||
|
||||
expect(await signer.nip44Encrypt("cd".repeat(32), "hi")).toBe("c44");
|
||||
expect(await signer.nip44Decrypt("cd".repeat(32), "payload")).toBe("p44");
|
||||
expect(await signer.nip04Encrypt("cd".repeat(32), "hi")).toBe("c04");
|
||||
expect(await signer.nip04Decrypt("cd".repeat(32), "payload")).toBe("p04");
|
||||
expect(calls.map((c) => c.action)).toEqual([
|
||||
"nip46SignEvent",
|
||||
"nip46Nip44Encrypt",
|
||||
"nip46Nip44Decrypt",
|
||||
"nip46Nip04Encrypt",
|
||||
"nip46Nip04Decrypt"
|
||||
]);
|
||||
expect(calls[1]!.payload).toEqual({ peerPublicKey: "cd".repeat(32), plaintext: "hi" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
/** NIP-46 remote-signer sessions (§LM5): the signer backend for a connected bunker.
|
||||
*
|
||||
* Thin by construction: the actual client -- ephemeral keypair, relay
|
||||
* subscriptions, request correlation -- lives in the crypto worker (§LM5.4),
|
||||
* and this class only forwards facade calls to it. The user pubkey is fixed at
|
||||
* connect time (the worker validated it), so getPublicKey answers locally.
|
||||
* Deadlines and typed errors come from the worker/core layers.
|
||||
*/
|
||||
import type { NostrEvent } from "@bitlogin/core/nostr";
|
||||
import type { UnsignedEventForSigning } from "../provider.js";
|
||||
import type { WorkerClient } from "../worker/workerClient.js";
|
||||
import type { Signer, SignerCapabilities } from "./types.js";
|
||||
|
||||
export class Nip46Signer implements Signer {
|
||||
readonly method = "nip46" as const;
|
||||
/** The NIP-46 RPC surface always carries all four encryption calls; whether a
|
||||
* particular signer approves them is a per-request policy decision that
|
||||
* surfaces as its error, not a missing capability. */
|
||||
readonly capabilities: SignerCapabilities = { nip44: true, nip04: true, getRelays: false };
|
||||
|
||||
constructor(
|
||||
private readonly worker: WorkerClient,
|
||||
private readonly userPubkey: string
|
||||
) {}
|
||||
|
||||
getPublicKey(): Promise<string> {
|
||||
return Promise.resolve(this.userPubkey);
|
||||
}
|
||||
|
||||
signEvent(event: UnsignedEventForSigning): Promise<NostrEvent> {
|
||||
return this.worker.nip46SignEvent({
|
||||
kind: event.kind,
|
||||
content: event.content,
|
||||
tags: event.tags,
|
||||
created_at: event.created_at
|
||||
});
|
||||
}
|
||||
|
||||
async nip44Encrypt(peerPublicKey: string, plaintext: string): Promise<string> {
|
||||
return (await this.worker.nip46Nip44Encrypt({ peerPublicKey, plaintext })).ciphertext;
|
||||
}
|
||||
|
||||
async nip44Decrypt(peerPublicKey: string, payload: string): Promise<string> {
|
||||
return (await this.worker.nip46Nip44Decrypt({ peerPublicKey, payload })).plaintext;
|
||||
}
|
||||
|
||||
async nip04Encrypt(peerPublicKey: string, plaintext: string): Promise<string> {
|
||||
return (await this.worker.nip46Nip04Encrypt({ peerPublicKey, plaintext })).ciphertext;
|
||||
}
|
||||
|
||||
async nip04Decrypt(peerPublicKey: string, payload: string): Promise<string> {
|
||||
return (await this.worker.nip46Nip04Decrypt({ peerPublicKey, payload })).plaintext;
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
import type { NostrEvent } from "@bitlogin/core/nostr";
|
||||
import type { UnsignedEventForSigning } from "../provider.js";
|
||||
|
||||
export type SignerMethod = "bitlogin" | "nip07";
|
||||
export type SignerMethod = "bitlogin" | "nip07" | "nip46";
|
||||
|
||||
/** What the active signer actually supports. NIP-07 extensions vary -- some lack nip44,
|
||||
* older ones lack nip04 -- and the facade reports honestly instead of throwing a
|
||||
|
||||
@@ -236,4 +236,14 @@ button.link {
|
||||
margin-right: 6px;
|
||||
}
|
||||
@keyframes bl-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* nostrconnect QR (§LM5). The white backing and quiet zone are drawn inside
|
||||
the SVG itself -- scanners need them regardless of host theme -- so the
|
||||
wrapper only sizes and centers it. */
|
||||
.qr-wrap {
|
||||
display: block;
|
||||
width: min(240px, 100%);
|
||||
margin: 14px auto;
|
||||
}
|
||||
.qr-wrap svg { display: block; width: 100%; height: auto; }
|
||||
`;
|
||||
@@ -18,8 +18,19 @@ import {
|
||||
NostrSigner,
|
||||
type RecoveredIdentity
|
||||
} from "@bitlogin/core/account";
|
||||
import { RelayPool, BUILTIN_VAULT_RELAYS, BUILTIN_DISCOVERY_RELAYS, encodeNsec, encodeNpub, type NostrEvent } from "@bitlogin/core/nostr";
|
||||
import { getPublicKeyHex, base64urlToBytes, wipe } from "@bitlogin/core/crypto";
|
||||
import {
|
||||
RelayPool,
|
||||
BUILTIN_VAULT_RELAYS,
|
||||
BUILTIN_DISCOVERY_RELAYS,
|
||||
encodeNsec,
|
||||
encodeNpub,
|
||||
Nip46Client,
|
||||
parseBunkerUri,
|
||||
buildNostrconnectUri,
|
||||
listenForNostrconnect,
|
||||
type NostrEvent
|
||||
} from "@bitlogin/core/nostr";
|
||||
import { getPublicKeyHex, base64urlToBytes, wipe, generatePrivateKey, randomBytes, bytesToHex } from "@bitlogin/core/crypto";
|
||||
import {
|
||||
VaultSession,
|
||||
parseNwcUri,
|
||||
@@ -48,7 +59,9 @@ import type {
|
||||
Nip44DecryptPayload,
|
||||
Nip04EncryptPayload,
|
||||
Nip04DecryptPayload,
|
||||
PreviewImportKeyPayload
|
||||
PreviewImportKeyPayload,
|
||||
Nip46ConnectPayload,
|
||||
Nip46NostrconnectStartPayload
|
||||
} from "./protocol.js";
|
||||
|
||||
interface SessionState {
|
||||
@@ -232,8 +245,131 @@ async function findConnection(connectionId: string): Promise<{
|
||||
return { vault, connection };
|
||||
}
|
||||
|
||||
// ---- NIP-46 remote-signer session (§LM5) ----
|
||||
// Deliberately separate from `session`: a remote-signer session has no account,
|
||||
// no vault, and nothing persisted. The ephemeral client key never leaves this
|
||||
// worker (§LM5.4); close() ends everything.
|
||||
let nip46: { client: Nip46Client; userPubkey: string } | null = null;
|
||||
let nostrconnectPending: { clientSecretKey: Uint8Array; relayUrls: string[]; secret: string } | null = null;
|
||||
|
||||
function postAuthUrlNotification(url: string): void {
|
||||
(self as unknown as Worker).postMessage({ notify: "nip46-auth-url", url });
|
||||
}
|
||||
|
||||
function requireNip46(): { client: Nip46Client; userPubkey: string } {
|
||||
if (!nip46) throw new Error("No remote signer is connected.");
|
||||
return nip46;
|
||||
}
|
||||
|
||||
function adoptNip46Client(client: Nip46Client, userPubkey: string): void {
|
||||
nip46?.client.close();
|
||||
nip46 = { client, userPubkey };
|
||||
}
|
||||
|
||||
async function handle(action: string, payload: unknown): Promise<unknown> {
|
||||
switch (action) {
|
||||
case "nip46Connect": {
|
||||
const p = payload as Nip46ConnectPayload;
|
||||
const pointer = parseBunkerUri(p.uri);
|
||||
const client = new Nip46Client({
|
||||
clientSecretKey: generatePrivateKey(),
|
||||
pointer,
|
||||
onAuthUrl: postAuthUrlNotification
|
||||
});
|
||||
try {
|
||||
await client.connect();
|
||||
const userPubkey = await client.getUserPublicKey();
|
||||
adoptNip46Client(client, userPubkey);
|
||||
return { userPubkey };
|
||||
} catch (err) {
|
||||
client.close();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
case "nip46NostrconnectStart": {
|
||||
const p = payload as Nip46NostrconnectStartPayload;
|
||||
const relayUrls = vaultRelayUrls;
|
||||
const clientSecretKey = generatePrivateKey();
|
||||
// The secret is the proof-of-scan: whoever echoes it back becomes this
|
||||
// session's signer, so it gets real entropy, not a friendly string.
|
||||
const secret = bytesToHex(randomBytes(16));
|
||||
nostrconnectPending = { clientSecretKey, relayUrls, secret };
|
||||
const uri = buildNostrconnectUri({
|
||||
clientPubkey: getPublicKeyHex(clientSecretKey),
|
||||
relayUrls,
|
||||
secret,
|
||||
name: p.appName?.trim() || "BitLogin",
|
||||
perms: ["sign_event", "nip44_encrypt", "nip44_decrypt", "nip04_encrypt", "nip04_decrypt"]
|
||||
});
|
||||
return { uri };
|
||||
}
|
||||
|
||||
case "nip46NostrconnectAwait": {
|
||||
const pending = nostrconnectPending;
|
||||
if (!pending) throw new Error("No signer-connection attempt is in progress.");
|
||||
const { signerPubkey } = await listenForNostrconnect({
|
||||
clientSecretKey: pending.clientSecretKey,
|
||||
relayUrls: pending.relayUrls,
|
||||
secret: pending.secret,
|
||||
timeoutMs: 180_000
|
||||
});
|
||||
if (nostrconnectPending !== pending) throw new Error("This signer-connection attempt was superseded.");
|
||||
nostrconnectPending = null;
|
||||
const client = new Nip46Client({
|
||||
clientSecretKey: pending.clientSecretKey,
|
||||
pointer: { signerPubkey, relayUrls: pending.relayUrls },
|
||||
onAuthUrl: postAuthUrlNotification
|
||||
});
|
||||
try {
|
||||
// The signer initiated (it echoed the secret), so no connect RPC is
|
||||
// needed -- go straight to asking whose key it signs for.
|
||||
const userPubkey = await client.getUserPublicKey();
|
||||
adoptNip46Client(client, userPubkey);
|
||||
return { userPubkey };
|
||||
} catch (err) {
|
||||
client.close();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
case "nip46SignEvent": {
|
||||
const p = payload as SignEventPayload;
|
||||
return requireNip46().client.signEvent({
|
||||
kind: p.kind,
|
||||
content: p.content,
|
||||
tags: (p.tags ?? []) as string[][],
|
||||
created_at: p.created_at ?? Math.floor(Date.now() / 1000)
|
||||
});
|
||||
}
|
||||
|
||||
case "nip46Nip44Encrypt": {
|
||||
const p = payload as Nip44EncryptPayload;
|
||||
return { ciphertext: await requireNip46().client.nip44Encrypt(p.peerPublicKey, p.plaintext) };
|
||||
}
|
||||
|
||||
case "nip46Nip44Decrypt": {
|
||||
const p = payload as Nip44DecryptPayload;
|
||||
return { plaintext: await requireNip46().client.nip44Decrypt(p.peerPublicKey, p.payload) };
|
||||
}
|
||||
|
||||
case "nip46Nip04Encrypt": {
|
||||
const p = payload as Nip04EncryptPayload;
|
||||
return { ciphertext: await requireNip46().client.nip04Encrypt(p.peerPublicKey, p.plaintext) };
|
||||
}
|
||||
|
||||
case "nip46Nip04Decrypt": {
|
||||
const p = payload as Nip04DecryptPayload;
|
||||
return { plaintext: await requireNip46().client.nip04Decrypt(p.peerPublicKey, p.payload) };
|
||||
}
|
||||
|
||||
case "nip46Disconnect": {
|
||||
nip46?.client.close();
|
||||
nip46 = null;
|
||||
nostrconnectPending = null;
|
||||
return {};
|
||||
}
|
||||
|
||||
case "configure": {
|
||||
const p = payload as ConfigurePayload;
|
||||
if (p.vaultRelayUrls?.length) vaultRelayUrls = p.vaultRelayUrls;
|
||||
@@ -655,7 +791,10 @@ const KNOWN_ACTIONS = new Set<string>([
|
||||
"nip44Encrypt", "nip44Decrypt", "nip04Encrypt", "nip04Decrypt", "exportIdentity",
|
||||
"buildRecoveryExport", "repairReplicas", "getSessionStatus", "restoreSession", "logout",
|
||||
"vaultStatus", "vaultList", "vaultSaveNwc", "vaultFindForOrigin", "vaultRevealNwc",
|
||||
"vaultSetBinding", "vaultDelete", "vaultOfferCheck"
|
||||
"vaultSetBinding", "vaultDelete", "vaultOfferCheck",
|
||||
"nip46Connect", "nip46NostrconnectStart", "nip46NostrconnectAwait", "nip46SignEvent",
|
||||
"nip46Nip44Encrypt", "nip46Nip44Decrypt", "nip46Nip04Encrypt", "nip46Nip04Decrypt",
|
||||
"nip46Disconnect"
|
||||
]);
|
||||
|
||||
self.addEventListener("message", (event: MessageEvent<WorkerRequest>) => {
|
||||
@@ -676,7 +815,15 @@ self.addEventListener("message", (event: MessageEvent<WorkerRequest>) => {
|
||||
return;
|
||||
}
|
||||
const { id, action, payload } = event.data;
|
||||
requestQueue.run(() => handle(action, payload)).then(
|
||||
// NIP-46 calls run OUTSIDE the serial queue: they touch none of the
|
||||
// account/vault state the queue exists to order, and a signer waiting
|
||||
// minutes for a human's approval must not block every other worker call
|
||||
// behind it (getSessionStatus, vault requests, even logout).
|
||||
const run =
|
||||
action.startsWith("nip46")
|
||||
? (operation: () => Promise<unknown>) => operation()
|
||||
: (operation: () => Promise<unknown>) => requestQueue.run(operation);
|
||||
run(() => handle(action, payload)).then(
|
||||
(result) => {
|
||||
const response: WorkerResponse = { id, ok: true, result };
|
||||
(self as unknown as Worker).postMessage(response);
|
||||
|
||||
@@ -235,6 +235,39 @@ export interface VaultOfferCheckResult {
|
||||
connection?: VaultConnectionSummary;
|
||||
}
|
||||
|
||||
// ---- NIP-46 remote-signer session (docs/login-methods.md §LM5) ----
|
||||
// Memory-only: the ephemeral client key and Nip46Client live in the worker
|
||||
// (§LM5.4) and vanish with it. Nothing below touches account/vault state.
|
||||
|
||||
export interface Nip46ConnectPayload {
|
||||
/** A bunker:// URI pasted by the user. */
|
||||
uri: string;
|
||||
}
|
||||
export interface Nip46ConnectResult {
|
||||
userPubkey: string;
|
||||
}
|
||||
export interface Nip46NostrconnectStartPayload {
|
||||
appName?: string;
|
||||
}
|
||||
export interface Nip46NostrconnectStartResult {
|
||||
/** The nostrconnect:// URI to show as a QR / copyable string. */
|
||||
uri: string;
|
||||
}
|
||||
export interface Nip46NostrconnectAwaitResult {
|
||||
userPubkey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsolicited worker -> main-thread frame. Distinguished from WorkerResponse
|
||||
* by having `notify` and no `id`; used where a request/response pair cannot
|
||||
* carry the information -- the signer's interactive-approval URL arrives in
|
||||
* the middle of a still-pending connect call.
|
||||
*/
|
||||
export interface WorkerNotification {
|
||||
notify: "nip46-auth-url";
|
||||
url: string;
|
||||
}
|
||||
|
||||
export type WorkerActionMap = {
|
||||
configure: [ConfigurePayload, Record<string, never>];
|
||||
register: [RegisterPayload, RegisterResult];
|
||||
@@ -264,6 +297,15 @@ export type WorkerActionMap = {
|
||||
vaultSetBinding: [VaultSetBindingPayload, VaultConnectionSummary];
|
||||
vaultDelete: [VaultDeletePayload, Record<string, never>];
|
||||
vaultOfferCheck: [VaultOfferCheckPayload, VaultOfferCheckResult];
|
||||
nip46Connect: [Nip46ConnectPayload, Nip46ConnectResult];
|
||||
nip46NostrconnectStart: [Nip46NostrconnectStartPayload, Nip46NostrconnectStartResult];
|
||||
nip46NostrconnectAwait: [Record<string, never>, Nip46NostrconnectAwaitResult];
|
||||
nip46SignEvent: [SignEventPayload, NostrEvent];
|
||||
nip46Nip44Encrypt: [Nip44EncryptPayload, { ciphertext: string }];
|
||||
nip46Nip44Decrypt: [Nip44DecryptPayload, { plaintext: string }];
|
||||
nip46Nip04Encrypt: [Nip04EncryptPayload, { ciphertext: string }];
|
||||
nip46Nip04Decrypt: [Nip04DecryptPayload, { plaintext: string }];
|
||||
nip46Disconnect: [Record<string, never>, Record<string, never>];
|
||||
};
|
||||
|
||||
export type WorkerAction = keyof WorkerActionMap;
|
||||
@@ -22,7 +22,10 @@ import type {
|
||||
VaultRevealNwcPayload,
|
||||
VaultSetBindingPayload,
|
||||
VaultDeletePayload,
|
||||
VaultOfferCheckPayload
|
||||
VaultOfferCheckPayload,
|
||||
Nip46ConnectPayload,
|
||||
Nip46NostrconnectStartPayload,
|
||||
WorkerNotification
|
||||
} from "./protocol.js";
|
||||
|
||||
/**
|
||||
@@ -32,6 +35,16 @@ import type {
|
||||
*/
|
||||
const CALL_TIMEOUT_MS = 60_000;
|
||||
|
||||
/**
|
||||
* NIP-46 calls wait on a HUMAN approving a prompt on another device, on top of
|
||||
* relay round trips -- the worker-side deadlines (120s per signer request,
|
||||
* 180s for the nostrconnect listen) are the real limits; these exist only so
|
||||
* a dead worker still rejects. Kept above the worst worker-side sum so the
|
||||
* worker's own, better-worded timeout error is the one callers see.
|
||||
*/
|
||||
const NIP46_CALL_TIMEOUT_MS = 260_000;
|
||||
const NIP46_AWAIT_TIMEOUT_MS = 320_000;
|
||||
|
||||
export class WorkerClient {
|
||||
private readonly worker: Worker;
|
||||
private readonly pending = new Map<
|
||||
@@ -40,6 +53,8 @@ export class WorkerClient {
|
||||
>();
|
||||
private counter = 0;
|
||||
private dead = false;
|
||||
/** Set by the element to receive unsolicited worker frames (auth_url). */
|
||||
onNotification: ((notification: WorkerNotification) => void) | null = null;
|
||||
|
||||
constructor() {
|
||||
// Deliberately NOT the literal `new Worker(new URL("./x.js", import.meta.url))` shape: Vite
|
||||
@@ -55,7 +70,15 @@ export class WorkerClient {
|
||||
const msg = event.data;
|
||||
// Shape-check before trusting: a malformed frame used to throw inside
|
||||
// this listener, which posts no response and hangs the caller forever.
|
||||
if (!msg || typeof msg !== "object" || typeof (msg as WorkerResponse).id !== "string") return;
|
||||
if (!msg || typeof msg !== "object") return;
|
||||
// Unsolicited notification frames (no id) -- currently only the NIP-46
|
||||
// auth_url, which arrives mid-call and cannot ride a response.
|
||||
const maybeNotification = msg as unknown as Partial<WorkerNotification>;
|
||||
if (maybeNotification.notify === "nip46-auth-url" && typeof maybeNotification.url === "string") {
|
||||
this.onNotification?.(maybeNotification as WorkerNotification);
|
||||
return;
|
||||
}
|
||||
if (typeof (msg as WorkerResponse).id !== "string") return;
|
||||
const entry = this.pending.get(msg.id);
|
||||
if (!entry) return;
|
||||
this.pending.delete(msg.id);
|
||||
@@ -91,7 +114,11 @@ export class WorkerClient {
|
||||
}
|
||||
}
|
||||
|
||||
private call<A extends WorkerAction>(action: A, payload: WorkerActionMap[A][0]): Promise<WorkerActionMap[A][1]> {
|
||||
private call<A extends WorkerAction>(
|
||||
action: A,
|
||||
payload: WorkerActionMap[A][0],
|
||||
timeoutMs = CALL_TIMEOUT_MS
|
||||
): Promise<WorkerActionMap[A][1]> {
|
||||
if (this.dead) {
|
||||
return Promise.reject(new Error("This BitLogin session was torn down; reload the page to sign in again."));
|
||||
}
|
||||
@@ -100,7 +127,7 @@ export class WorkerClient {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(`BitLogin's ${action} call timed out.`));
|
||||
}, CALL_TIMEOUT_MS);
|
||||
}, timeoutMs);
|
||||
this.pending.set(id, { resolve: resolve as (value: unknown) => void, reject, timer });
|
||||
const request: WorkerRequest = { id, action, payload };
|
||||
this.worker.postMessage(request);
|
||||
@@ -188,6 +215,35 @@ export class WorkerClient {
|
||||
vaultOfferCheck(payload: VaultOfferCheckPayload) {
|
||||
return this.call("vaultOfferCheck", payload);
|
||||
}
|
||||
|
||||
// ---- NIP-46 remote-signer session (§LM5) ----
|
||||
nip46Connect(payload: Nip46ConnectPayload) {
|
||||
return this.call("nip46Connect", payload, NIP46_CALL_TIMEOUT_MS);
|
||||
}
|
||||
nip46NostrconnectStart(payload: Nip46NostrconnectStartPayload) {
|
||||
return this.call("nip46NostrconnectStart", payload);
|
||||
}
|
||||
nip46NostrconnectAwait() {
|
||||
return this.call("nip46NostrconnectAwait", {}, NIP46_AWAIT_TIMEOUT_MS);
|
||||
}
|
||||
nip46SignEvent(payload: SignEventPayload) {
|
||||
return this.call("nip46SignEvent", payload, NIP46_CALL_TIMEOUT_MS);
|
||||
}
|
||||
nip46Nip44Encrypt(payload: Nip44EncryptPayload) {
|
||||
return this.call("nip46Nip44Encrypt", payload, NIP46_CALL_TIMEOUT_MS);
|
||||
}
|
||||
nip46Nip44Decrypt(payload: Nip44DecryptPayload) {
|
||||
return this.call("nip46Nip44Decrypt", payload, NIP46_CALL_TIMEOUT_MS);
|
||||
}
|
||||
nip46Nip04Encrypt(payload: Nip04EncryptPayload) {
|
||||
return this.call("nip46Nip04Encrypt", payload, NIP46_CALL_TIMEOUT_MS);
|
||||
}
|
||||
nip46Nip04Decrypt(payload: Nip04DecryptPayload) {
|
||||
return this.call("nip46Nip04Decrypt", payload, NIP46_CALL_TIMEOUT_MS);
|
||||
}
|
||||
nip46Disconnect() {
|
||||
return this.call("nip46Disconnect", {});
|
||||
}
|
||||
logout() {
|
||||
return this.call("logout", {});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user