signet-login 0.7.2 → 0.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/modal.js CHANGED
@@ -5,13 +5,15 @@
5
5
  * top-layer placement, theme-aware colours, no third-party UI deps.
6
6
  */
7
7
  import { DEFAULTS } from './types.js';
8
- import { hasNip07, createNip07Signer, createBunkerSigner, createBunkerSignerFromNostrConnect, buildNostrConnectUri, EphemeralSigner, createLocalSignerFromNsec } from './signers.js';
8
+ import { hasNip07, createNip07Signer, createBunkerSigner, createBunkerSignerFromNostrConnect, buildNostrConnectUri, EphemeralSigner, DeferredBunkerSigner, createLocalSignerFromNsec } from './signers.js';
9
9
  import { isAndroid, startAmberSignIn } from './amber.js';
10
+ import { loadOrCreatePersistentClientSk } from './storage.js';
10
11
  import { waitForAuthResponse } from 'signet-verify';
11
12
  import { schnorr } from '@noble/curves/secp256k1';
12
13
  import { bytesToHex } from '@noble/hashes/utils';
13
14
  import { startRedirect } from './redirect.js';
14
15
  import QRCode from 'qrcode';
16
+ const QR_BUNKER_CONNECT_TIMEOUT_MS = 8000;
15
17
  function escapeHtml(str) {
16
18
  return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
17
19
  }
@@ -39,9 +41,95 @@ function buildModalShell(theme) {
39
41
  dialog.style.cssText = `border:none;border-radius:16px;padding:32px;max-width:380px;width:90%;text-align:center;box-shadow:0 20px 60px rgba(0,0,0,0.3);background:${bg};color:${fg};font-family:system-ui,-apple-system,sans-serif;`;
40
42
  document.body.appendChild(dialog);
41
43
  dialog.showModal();
42
- return { dialog, style };
44
+ // Gamepad navigation. A booth/kiosk drives this modal with a gamepad, whose
45
+ // host game dispatches *synthetic* Arrow / Enter / Escape KeyboardEvents on
46
+ // `window` (isTrusted=false). Native <dialog> only moves focus with Tab, and
47
+ // synthetic Enter/Escape don't trigger native button activation or the
48
+ // dialog's cancel — so bridge them here. Real keyboard events (isTrusted)
49
+ // keep their native behaviour; we only fully drive the synthetic ones. Works
50
+ // across every screen because it queries the live buttons each keypress.
51
+ const visibleButtons = () => Array.from(dialog.querySelectorAll('button'))
52
+ .filter((b) => {
53
+ if (b.disabled || !b.isConnected)
54
+ return false;
55
+ const css = window.getComputedStyle(b);
56
+ return css.display !== 'none' && css.visibility !== 'hidden';
57
+ });
58
+ const focusedButton = () => document.activeElement instanceof HTMLButtonElement && dialog.contains(document.activeElement)
59
+ ? document.activeElement
60
+ : null;
61
+ // Tracked cursor. We click THIS index on Enter rather than
62
+ // document.activeElement, because a host page's own menu-nav (still mounted
63
+ // behind the dialog) can clear/move DOM focus between keypresses — which made
64
+ // "A = select the highlighted item" click the wrong (first) button. Tracking
65
+ // the index ourselves makes selection reliable regardless of the host.
66
+ let selIndex = 0;
67
+ const showSel = () => {
68
+ const btns = visibleButtons();
69
+ if (btns.length === 0)
70
+ return;
71
+ selIndex = Math.min(Math.max(selIndex, 0), btns.length - 1);
72
+ btns[selIndex].focus();
73
+ };
74
+ const keyNav = (e) => {
75
+ if (!dialog.isConnected || !dialog.open)
76
+ return;
77
+ const tgt = e.target;
78
+ if (tgt && (tgt.tagName === 'INPUT' || tgt.tagName === 'TEXTAREA') && e.isTrusted)
79
+ return; // real typing owns its keys
80
+ const btns = visibleButtons();
81
+ if (btns.length === 0)
82
+ return;
83
+ const key = e.key || e.code;
84
+ const code = e.code || e.key;
85
+ const dir = key === 'ArrowDown' || code === 'ArrowDown' || key === 'ArrowRight' || code === 'ArrowRight' ? 1
86
+ : key === 'ArrowUp' || code === 'ArrowUp' || key === 'ArrowLeft' || code === 'ArrowLeft' ? -1 : 0;
87
+ if (dir) {
88
+ // The modal owns nav while open — stop the host page's listeners from
89
+ // also grabbing the key and stealing focus.
90
+ e.preventDefault();
91
+ e.stopImmediatePropagation();
92
+ // Re-sync to live DOM focus if the host moved it, else step our index.
93
+ const fb = focusedButton();
94
+ const fi = fb ? btns.indexOf(fb) : -1;
95
+ selIndex = ((fi >= 0 ? fi : selIndex) + dir + btns.length) % btns.length;
96
+ btns[selIndex].focus();
97
+ return;
98
+ }
99
+ // Real keyboard (isTrusted): let native Enter/Escape stand AND keep
100
+ // propagating so the focused button's native activation fires. We only
101
+ // fully drive the SYNTHETIC events a gamepad host dispatches on window.
102
+ if (e.isTrusted)
103
+ return;
104
+ if (e.key === 'Enter' || e.key === ' ' || e.code === 'Space' || e.code === 'Enter') {
105
+ e.preventDefault();
106
+ e.stopImmediatePropagation();
107
+ btns[Math.min(Math.max(selIndex, 0), btns.length - 1)].click(); // tracked cursor, not activeElement
108
+ }
109
+ else if (e.key === 'Escape' || e.code === 'Escape') {
110
+ e.preventDefault();
111
+ e.stopImmediatePropagation();
112
+ // Prefer Back (sub-screen → picker); fall back to Cancel.
113
+ (dialog.querySelector('[data-action="back"]')
114
+ ?? dialog.querySelector('[data-action="cancel"],[data-choice="cancel"]'))?.click();
115
+ }
116
+ };
117
+ // Capture phase: run BEFORE the host page's bubble-phase window keydown
118
+ // handlers, so stopImmediatePropagation above actually pre-empts them.
119
+ window.addEventListener('keydown', keyNav, true);
120
+ // Reset the cursor to the first button when a new SCREEN swaps in. childList
121
+ // (no subtree) so frequent status-text updates inside a screen don't reset it.
122
+ const mo = new MutationObserver(() => { selIndex = 0; showSel(); });
123
+ mo.observe(dialog, { childList: true });
124
+ showSel();
125
+ return {
126
+ dialog,
127
+ style,
128
+ cleanupNav: () => { window.removeEventListener('keydown', keyNav, true); mo.disconnect(); },
129
+ };
43
130
  }
44
131
  function tearDown(refs) {
132
+ refs.cleanupNav?.();
45
133
  try {
46
134
  refs.dialog.close();
47
135
  }
@@ -233,7 +321,8 @@ async function runRedirectFlow(refs, opts) {
233
321
  sessionPrivKey,
234
322
  expectedOrigin: opts.origin,
235
323
  timeout: opts.timeout,
236
- }).then(result => {
324
+ }).then(rawResult => {
325
+ const result = rawResult;
237
326
  const authEvent = {
238
327
  id: result.authEvent.id,
239
328
  pubkey: result.authEvent.pubkey,
@@ -246,6 +335,8 @@ async function runRedirectFlow(refs, opts) {
246
335
  const out = { pubkey: result.pubkey, authEvent };
247
336
  if (result.displayName)
248
337
  out.displayName = result.displayName;
338
+ if (result.bunkerUri)
339
+ out.bunkerUri = result.bunkerUri;
249
340
  settle(out);
250
341
  }).catch(err => {
251
342
  const status = refs.dialog.querySelector('#signet-login-status');
@@ -300,7 +391,7 @@ async function runBunkerFlow(refs, opts) {
300
391
  }
301
392
  connectBtn.disabled = true;
302
393
  try {
303
- const signer = await createBunkerSigner({ uri });
394
+ const signer = await createBunkerSigner({ uri, clientSecretKey: loadOrCreatePersistentClientSk() });
304
395
  settle(signer);
305
396
  }
306
397
  catch (err) {
@@ -323,7 +414,10 @@ async function runBunkerFlow(refs, opts) {
323
414
  async function runNostrConnectFlow(refs, opts) {
324
415
  const dark = isDarkMode(opts.theme);
325
416
  const muted = dark ? '#888' : '#666';
326
- const sk = schnorr.utils.randomPrivateKey();
417
+ // Persistent client key so the advertised client pubkey is stable across
418
+ // logins (bunkers auto-approve a bound client pubkey). The connect `secret`
419
+ // stays fresh per handshake — it's a one-time challenge, not an identity.
420
+ const sk = loadOrCreatePersistentClientSk();
327
421
  const clientPubkey = bytesToHex(schnorr.getPublicKey(sk));
328
422
  const secret = bytesToHex(schnorr.utils.randomPrivateKey()).slice(0, 32);
329
423
  const uri = buildNostrConnectUri({
@@ -558,11 +652,50 @@ export async function showLoginModal(opts) {
558
652
  return null;
559
653
  continue;
560
654
  }
561
- const ephemeral = new EphemeralSigner(result.pubkey, result.authEvent);
655
+ // Default: auth-only ephemeral signer (identity proof, no live signing).
656
+ let signer = new EphemeralSigner(result.pubkey, result.authEvent);
657
+ let method = 'redirect';
658
+ // Cross-device bunker passthrough: when the signer device hands back a
659
+ // `bunker://` URI (its own NIP-46 server, or an upstream hardware
660
+ // bunker), preserve it as a deferred signer. That keeps login responsive
661
+ // and avoids downgrading to auth-only just because a cold ESP32 / relay
662
+ // is not ready during the approval round trip. The first real signing
663
+ // request awaits the connection and reports auth-only only if it fails.
664
+ if (result.bunkerUri) {
665
+ const clientSecretKey = loadOrCreatePersistentClientSk();
666
+ const expected = result.pubkey;
667
+ const authEvent = result.authEvent;
668
+ const upgrade = createBunkerSigner({
669
+ uri: result.bunkerUri,
670
+ clientSecretKey,
671
+ timeoutMs: QR_BUNKER_CONNECT_TIMEOUT_MS,
672
+ })
673
+ .then((bunkerSigner) => {
674
+ if (bunkerSigner.pubkey.toLowerCase() === expected.toLowerCase()) {
675
+ return bunkerSigner;
676
+ }
677
+ console.warn('[signet-login] QR upgrade: bunker pubkey mismatch — staying auth-only (cannot sign)', { connected: bunkerSigner.pubkey, expected });
678
+ void bunkerSigner.close().catch(() => { });
679
+ return null;
680
+ })
681
+ .catch((err) => {
682
+ console.warn('[signet-login] QR upgrade: createBunkerSigner failed — deferred signer will behave auth-only until reconnect. Reconnect/relay issue or signer device unreachable.', err);
683
+ return null;
684
+ });
685
+ // QR handoffs must not advertise signing until the remote bunker is
686
+ // actually connected. Pallasite starts several background signing
687
+ // tasks as soon as canSignEvents=true; an optimistic cold ESP32
688
+ // handoff strands the UI on "Signing…" before the player can act.
689
+ signer = new DeferredBunkerSigner(expected, authEvent, upgrade, result.bunkerUri, clientSecretKey, false);
690
+ method = 'bunker';
691
+ }
692
+ else {
693
+ console.warn('[signet-login] QR login carried no bunkerUri — auth-only ephemeral (cannot sign). The signer device must have its NIP-46 server enabled to hand back a bunker:// URI.');
694
+ }
562
695
  const session = {
563
696
  pubkey: result.pubkey,
564
- method: 'redirect',
565
- signer: ephemeral,
697
+ method,
698
+ signer,
566
699
  authEvent: result.authEvent,
567
700
  };
568
701
  if (result.displayName)
package/dist/signers.d.ts CHANGED
@@ -85,12 +85,25 @@ export declare function buildNostrConnectUri(input: {
85
85
  }): string;
86
86
  /**
87
87
  * Connect a bunker session from a `bunker://` or `nostr+connect://` URI (or a
88
- * NIP-05 identifier). Generates a fresh client secret key for the session.
88
+ * NIP-05 identifier). Pass `clientSecretKey` to bind a stable client pubkey the
89
+ * signer can auto-approve (see `loadOrCreatePersistentClientSk`); when omitted a
90
+ * fresh ephemeral key is generated, which a per-pubkey-approving bunker will
91
+ * treat as a new, unapproved client.
89
92
  */
90
93
  export declare function createBunkerSigner(input: {
91
94
  uri: string;
92
95
  clientSecretKey?: Uint8Array;
93
96
  onauth?: (url: string) => void;
97
+ /**
98
+ * Bound the NIP-46 `connect` + `get_public_key` handshake, in milliseconds.
99
+ * Omit for the interactive paste flow, where a cold remote signer may
100
+ * legitimately take tens of seconds to approve via the `auth_url` callback.
101
+ * Set it for unattended boot-time connects (redirect-bunker auto-pair,
102
+ * session restore) where a non-responding signer must degrade to the
103
+ * auth-only fallback rather than stall. On expiry the half-open signer is
104
+ * closed and the call rejects with `bunker-connect-timeout`.
105
+ */
106
+ timeoutMs?: number;
94
107
  }): Promise<BunkerSignerImpl>;
95
108
  /** Generate a 32-byte secret key. */
96
109
  export declare function generateSecretKey(): Uint8Array;
@@ -129,4 +142,37 @@ export declare class EphemeralSigner implements SignetSigner {
129
142
  signEvent(_template: EventTemplate): Promise<NostrEvent>;
130
143
  close(): Promise<void>;
131
144
  }
145
+ /**
146
+ * A redirect-bunker signer whose bunker connects in the BACKGROUND.
147
+ *
148
+ * `handleRedirectCallback` returns this immediately so the consumer can paint a
149
+ * signed-in UI without waiting on an up-to-8s bunker handshake over flaky
150
+ * relays (the cause of the "blank screen on sign-in"). The authenticated pubkey
151
+ * is known up front; the first `signEvent` / `nip44` call awaits the background
152
+ * connect. If that connect fails, signing rejects with the auth-only error —
153
+ * the session is still valid for identity proof.
154
+ */
155
+ export declare class DeferredBunkerSigner implements SignetSigner {
156
+ readonly pubkey: string;
157
+ readonly authEvent: SignetAuthEvent;
158
+ /** Resolves to the connected bunker, or null if the connect failed. */
159
+ private readonly upgrade;
160
+ /** Original bunker URI — exposed so persistence can reconnect on reload. */
161
+ readonly bunkerUri?: string | undefined;
162
+ /** Stable client key — exposed so persistence keeps the same NIP-46 client pubkey. */
163
+ readonly clientSecretKey?: Uint8Array | undefined;
164
+ readonly method: "bunker";
165
+ readonly capabilities: SignerCapabilities;
166
+ readonly nip44: SignetSigner['nip44'];
167
+ constructor(pubkey: string, authEvent: SignetAuthEvent,
168
+ /** Resolves to the connected bunker, or null if the connect failed. */
169
+ upgrade: Promise<BunkerSignerImpl | null>,
170
+ /** Original bunker URI — exposed so persistence can reconnect on reload. */
171
+ bunkerUri?: string | undefined,
172
+ /** Stable client key — exposed so persistence keeps the same NIP-46 client pubkey. */
173
+ clientSecretKey?: Uint8Array | undefined, optimisticCapabilities?: boolean);
174
+ private live;
175
+ signEvent(template: EventTemplate): Promise<NostrEvent>;
176
+ close(): Promise<void>;
177
+ }
132
178
  export {};
package/dist/signers.js CHANGED
@@ -125,9 +125,39 @@ export function buildNostrConnectUri(input) {
125
125
  params.set('url', input.appUrl);
126
126
  return `nostrconnect://${clientPubkeyHex}?${params.toString()}`;
127
127
  }
128
+ /**
129
+ * Race a bunker handshake against a deadline. nostr-tools' `BunkerSigner`
130
+ * `sendRequest` has no per-request timeout — it publishes the request and only
131
+ * settles when a matching response arrives on the subscription. If the remote
132
+ * signer never replies (relay unreachable, or the bunker server is already gone
133
+ * — e.g. signet-app's in-page NIP-46 server after a same-tab redirect navigated
134
+ * it away), `connect()`/`getPublicKey()` hang forever. On timeout we close the
135
+ * half-open signer (releasing its relay subscription) and reject so the caller
136
+ * can fall back. `Promise.race` keeps a rejection handler attached to `p`, so a
137
+ * late rejection from the abandoned handshake won't surface as unhandled.
138
+ */
139
+ async function raceBunkerHandshake(p, ms, bunker) {
140
+ let timer;
141
+ const timeout = new Promise((_, reject) => {
142
+ timer = setTimeout(() => {
143
+ void bunker.close().catch(() => { });
144
+ reject(new Error('bunker-connect-timeout'));
145
+ }, ms);
146
+ });
147
+ try {
148
+ return await Promise.race([p, timeout]);
149
+ }
150
+ finally {
151
+ if (timer !== undefined)
152
+ clearTimeout(timer);
153
+ }
154
+ }
128
155
  /**
129
156
  * Connect a bunker session from a `bunker://` or `nostr+connect://` URI (or a
130
- * NIP-05 identifier). Generates a fresh client secret key for the session.
157
+ * NIP-05 identifier). Pass `clientSecretKey` to bind a stable client pubkey the
158
+ * signer can auto-approve (see `loadOrCreatePersistentClientSk`); when omitted a
159
+ * fresh ephemeral key is generated, which a per-pubkey-approving bunker will
160
+ * treat as a new, unapproved client.
131
161
  */
132
162
  export async function createBunkerSigner(input) {
133
163
  const trimmed = input.uri.trim();
@@ -140,8 +170,13 @@ export async function createBunkerSigner(input) {
140
170
  if (sk.length !== 32)
141
171
  throw new Error('invalid-client-secret-key');
142
172
  const bunker = BunkerSigner.fromBunker(sk, pointer, { onauth: input.onauth });
143
- await bunker.connect();
144
- const pubkey = await bunker.getPublicKey();
173
+ const handshake = (async () => {
174
+ await bunker.connect();
175
+ return bunker.getPublicKey();
176
+ })();
177
+ const pubkey = input.timeoutMs && input.timeoutMs > 0
178
+ ? await raceBunkerHandshake(handshake, input.timeoutMs, bunker)
179
+ : await handshake;
145
180
  if (!/^[0-9a-f]{64}$/i.test(pubkey)) {
146
181
  await bunker.close().catch(() => { });
147
182
  throw new Error('invalid-pubkey-from-bunker');
@@ -238,3 +273,59 @@ export class EphemeralSigner {
238
273
  // nothing to close
239
274
  }
240
275
  }
276
+ /**
277
+ * A redirect-bunker signer whose bunker connects in the BACKGROUND.
278
+ *
279
+ * `handleRedirectCallback` returns this immediately so the consumer can paint a
280
+ * signed-in UI without waiting on an up-to-8s bunker handshake over flaky
281
+ * relays (the cause of the "blank screen on sign-in"). The authenticated pubkey
282
+ * is known up front; the first `signEvent` / `nip44` call awaits the background
283
+ * connect. If that connect fails, signing rejects with the auth-only error —
284
+ * the session is still valid for identity proof.
285
+ */
286
+ export class DeferredBunkerSigner {
287
+ constructor(pubkey, authEvent,
288
+ /** Resolves to the connected bunker, or null if the connect failed. */
289
+ upgrade,
290
+ /** Original bunker URI — exposed so persistence can reconnect on reload. */
291
+ bunkerUri,
292
+ /** Stable client key — exposed so persistence keeps the same NIP-46 client pubkey. */
293
+ clientSecretKey, optimisticCapabilities = true) {
294
+ this.pubkey = pubkey;
295
+ this.authEvent = authEvent;
296
+ this.upgrade = upgrade;
297
+ this.bunkerUri = bunkerUri;
298
+ this.clientSecretKey = clientSecretKey;
299
+ this.method = 'bunker';
300
+ this.capabilities = {
301
+ canSignEvents: optimisticCapabilities,
302
+ hasNip44: optimisticCapabilities,
303
+ };
304
+ void this.upgrade.then(signer => {
305
+ if (signer) {
306
+ this.capabilities.canSignEvents = true;
307
+ this.capabilities.hasNip44 = true;
308
+ }
309
+ });
310
+ this.nip44 = {
311
+ encrypt: async (peer, pt) => (await this.live()).nip44.encrypt(peer, pt),
312
+ decrypt: async (peer, ct) => (await this.live()).nip44.decrypt(peer, ct),
313
+ };
314
+ }
315
+ async live() {
316
+ const signer = await this.upgrade;
317
+ if (!signer) {
318
+ throw new Error('signer-auth-only: the redirect bunker handoff did not connect, so this ' +
319
+ 'session cannot sign. Reconnect the signer or paste a bunker URI to upgrade.');
320
+ }
321
+ return signer;
322
+ }
323
+ async signEvent(template) {
324
+ return (await this.live()).signEvent(template);
325
+ }
326
+ async close() {
327
+ const signer = await this.upgrade.catch(() => null);
328
+ if (signer)
329
+ await signer.close().catch(() => { });
330
+ }
331
+ }