signet-login 0.3.0 β†’ 0.5.0

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,11 +5,12 @@
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, EphemeralSigner } from './signers.js';
8
+ import { hasNip07, createNip07Signer, createBunkerSigner, EphemeralSigner, createLocalSignerFromNsec } from './signers.js';
9
9
  import { waitForAuthResponse } from 'signet-verify';
10
10
  import { schnorr } from '@noble/curves/secp256k1';
11
11
  import { bytesToHex } from '@noble/hashes/utils';
12
12
  import { startRedirect } from './redirect.js';
13
+ import QRCode from 'qrcode';
13
14
  function escapeHtml(str) {
14
15
  return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
15
16
  }
@@ -68,6 +69,7 @@ function renderPicker(refs, appName, theme) {
68
69
  <button data-choice="redirect" style="${buttonStyle(dark)}"><span style="font-size:1.2rem;">πŸͺͺ</span><span><strong>Sign in with Signet</strong><br><span style="font-size:0.8rem;color:${muted};">Open Signet on this device</span></span></button>
69
70
  <button data-choice="qr" style="${buttonStyle(dark)}"><span style="font-size:1.2rem;">πŸ“±</span><span><strong>Signet on another device</strong><br><span style="font-size:0.8rem;color:${muted};">Scan QR with your phone</span></span></button>
70
71
  <button data-choice="bunker" style="${buttonStyle(dark)}"><span style="font-size:1.2rem;">πŸ”‘</span><span><strong>Paste bunker URI</strong><br><span style="font-size:0.8rem;color:${muted};">For NIP-46 power users</span></span></button>
72
+ <button data-choice="nsec" style="${buttonStyle(dark)}"><span style="font-size:1.2rem;">⚠️</span><span><strong>Paste private key</strong><br><span style="font-size:0.8rem;color:${muted};">In-memory only β€” risky, last resort</span></span></button>
71
73
  </div>
72
74
  <button data-choice="cancel" style="background:none;border:0;color:${muted};padding:12px;cursor:pointer;font-size:0.85rem;margin-top:8px;">Cancel</button>
73
75
  `;
@@ -183,9 +185,7 @@ async function runRedirectFlow(refs, opts) {
183
185
  <h2 style="margin:0 0 8px;font-size:1.2rem;">Sign in with Signet</h2>
184
186
  <p style="margin:0 0 16px;color:${muted};font-size:0.85rem;">Open the link on your phone, or scan the QR if rendered.</p>
185
187
  <div style="background:${dark ? '#0f0f1f' : '#f5f5f8'};border-radius:8px;padding:16px;margin-bottom:16px;">
186
- <div id="signet-login-qr" style="width:200px;height:200px;margin:0 auto 12px;background:${dark ? '#1a1a2e' : '#ffffff'};border-radius:6px;display:flex;align-items:center;justify-content:center;color:${muted};font-size:0.8rem;text-align:center;padding:12px;box-sizing:border-box;">
187
- QR placeholder<br><span style="font-size:0.7rem;">(bundle qr lib for production)</span>
188
- </div>
188
+ <canvas id="signet-login-qr" width="200" height="200" style="display:block;width:200px;height:200px;margin:0 auto 12px;background:#ffffff;border-radius:6px;box-sizing:border-box;"></canvas>
189
189
  <a href="${escapeHtml(authUrl)}" target="_blank" rel="noopener" style="display:block;color:#5b6dff;font-size:0.75rem;word-break:break-all;text-decoration:none;">${escapeHtml(authUrl.slice(0, 80))}…</a>
190
190
  </div>
191
191
  <p id="signet-login-status" style="margin:0 0 12px;color:${muted};font-size:0.85rem;">Waiting for approval…</p>
@@ -194,6 +194,21 @@ async function runRedirectFlow(refs, opts) {
194
194
  <button data-action="cancel" style="${buttonStyle(dark)}width:auto;flex:0 0 auto;padding:8px 16px;">Cancel</button>
195
195
  </div>
196
196
  `;
197
+ // Render the auth URL into the QR canvas. Async, but the dialog has already
198
+ // surfaced the visible link as a fallback so a slow encode doesn't block UX.
199
+ // M error correction tolerates ~15% damage β€” comfortable for camera scans.
200
+ const qrCanvas = refs.dialog.querySelector('#signet-login-qr');
201
+ if (qrCanvas) {
202
+ void QRCode.toCanvas(qrCanvas, authUrl, {
203
+ width: 200,
204
+ margin: 1,
205
+ errorCorrectionLevel: 'M',
206
+ color: { dark: '#0a0418', light: '#ffffff' },
207
+ }).catch(() => {
208
+ // Encoding failure (URL too long for QR L-Q levels, canvas inaccessible)
209
+ // β€” the visible link below the canvas still gets the user across.
210
+ });
211
+ }
197
212
  return new Promise(resolve => {
198
213
  let settled = false;
199
214
  const settle = (v) => {
@@ -294,6 +309,63 @@ async function runBunkerFlow(refs, opts) {
294
309
  });
295
310
  });
296
311
  }
312
+ // ── Paste nsec (in-memory only) ───────────────────────────────────────────────
313
+ async function runNsecFlow(refs, opts) {
314
+ const dark = isDarkMode(opts.theme);
315
+ const muted = dark ? '#888' : '#666';
316
+ const inputBg = dark ? '#0f0f1f' : '#f5f5f8';
317
+ const inputFg = dark ? '#e0e0e0' : '#1a1a2e';
318
+ void opts;
319
+ refs.dialog.innerHTML = `
320
+ <h2 style="margin:0 0 8px;font-size:1.2rem;">Paste private key</h2>
321
+ <p style="margin:0 0 12px;color:#d04848;font-size:0.85rem;font-weight:600;">⚠️ Last-resort method β€” only paste keys you can afford to lose.</p>
322
+ <p style="margin:0 0 16px;color:${muted};font-size:0.8rem;line-height:1.4;">Held in memory for this session only. Cleared on page reload. Prefer a browser extension or bunker URI for any key with real value.</p>
323
+ <textarea id="signet-login-nsec-input" placeholder="nsec1..." rows="2" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" style="width:100%;background:${inputBg};color:${inputFg};border:1px solid ${dark ? '#3a3a4e' : '#d0d0d0'};border-radius:8px;padding:10px;font-size:0.85rem;font-family:ui-monospace,monospace;box-sizing:border-box;resize:vertical;margin-bottom:12px;-webkit-text-security:disc;text-security:disc;"></textarea>
324
+ <p id="signet-login-nsec-status" style="margin:0 0 12px;color:${muted};font-size:0.85rem;min-height:1.2em;"></p>
325
+ <div style="display:flex;gap:8px;justify-content:space-between;">
326
+ <button data-action="back" style="${buttonStyle(dark)}width:auto;flex:0 0 auto;padding:8px 16px;">← Back</button>
327
+ <button data-action="connect" style="${buttonStyle(dark, true)}width:auto;flex:1;padding:8px 16px;text-align:center;">Sign in</button>
328
+ </div>
329
+ `;
330
+ return new Promise(resolve => {
331
+ let settled = false;
332
+ const settle = (v) => {
333
+ if (settled)
334
+ return;
335
+ settled = true;
336
+ resolve(v);
337
+ };
338
+ const input = refs.dialog.querySelector('#signet-login-nsec-input');
339
+ const status = refs.dialog.querySelector('#signet-login-nsec-status');
340
+ const connectBtn = refs.dialog.querySelector('[data-action="connect"]');
341
+ refs.dialog.querySelector('[data-action="back"]')?.addEventListener('click', () => {
342
+ if (input)
343
+ input.value = '';
344
+ settle(null);
345
+ });
346
+ connectBtn?.addEventListener('click', () => {
347
+ const value = input?.value ?? '';
348
+ if (!value.trim()) {
349
+ if (status)
350
+ status.textContent = 'Please paste an nsec.';
351
+ return;
352
+ }
353
+ try {
354
+ const signer = createLocalSignerFromNsec(value);
355
+ // Wipe the textarea ASAP β€” the key is now in the signer.
356
+ if (input)
357
+ input.value = '';
358
+ settle(signer);
359
+ }
360
+ catch (err) {
361
+ if (status) {
362
+ status.textContent = `βœ— ${err instanceof Error ? err.message : String(err)}`;
363
+ status.style.color = '#d04848';
364
+ }
365
+ }
366
+ });
367
+ });
368
+ }
297
369
  function resolveOptions(opts) {
298
370
  const challenge = opts.challenge ?? generateChallenge();
299
371
  if (!/^[0-9a-f]{64}$/i.test(challenge))
@@ -411,6 +483,29 @@ export async function showLoginModal(opts) {
411
483
  authEvent,
412
484
  };
413
485
  }
486
+ if (choice === 'nsec') {
487
+ const signer = await runNsecFlow(refs, resolved);
488
+ if (!signer) {
489
+ if (resolved.preferredMethod)
490
+ return null;
491
+ continue;
492
+ }
493
+ const authEvent = await signer.signEvent({
494
+ kind: 21236,
495
+ content: '',
496
+ tags: [
497
+ ['challenge', resolved.challenge],
498
+ ['origin', resolved.origin],
499
+ ['app', resolved.appName],
500
+ ],
501
+ });
502
+ return {
503
+ pubkey: signer.pubkey,
504
+ method: 'nsec',
505
+ signer,
506
+ authEvent,
507
+ };
508
+ }
414
509
  // Unknown choice β€” restart picker
415
510
  }
416
511
  }
package/dist/signers.d.ts CHANGED
@@ -65,6 +65,28 @@ export declare function createBunkerSigner(input: {
65
65
  }): Promise<BunkerSignerImpl>;
66
66
  /** Generate a 32-byte secret key. */
67
67
  export declare function generateSecretKey(): Uint8Array;
68
+ /**
69
+ * Holds a 32-byte private key in memory, signs locally with schnorr, exposes
70
+ * NIP-44 via nostr-tools. The key is never persisted by this signer β€” the SDK
71
+ * will not call any storage write for an nsec session, so reloads land back
72
+ * on the picker. The consumer must surface the security trade-off in the UI.
73
+ */
74
+ export declare class LocalSigner implements SignetSigner {
75
+ readonly pubkey: string;
76
+ private readonly privkey;
77
+ readonly method: "nsec";
78
+ readonly capabilities: SignerCapabilities;
79
+ readonly nip44: SignetSigner['nip44'];
80
+ constructor(pubkey: string, privkey: Uint8Array);
81
+ signEvent(template: EventTemplate): Promise<NostrEvent>;
82
+ close(): Promise<void>;
83
+ }
84
+ /**
85
+ * Decode a bech32 nsec into a LocalSigner. Accepts either the `nsec1...`
86
+ * prefix or a raw 64-char hex private key for power-user paste paths.
87
+ * Throws on any malformed input β€” caller surfaces the error to the user.
88
+ */
89
+ export declare function createLocalSignerFromNsec(input: string): LocalSigner;
68
90
  /**
69
91
  * Auth-only signer returned by the redirect/QR flow before Option B is built.
70
92
  * Holds the signed challenge but cannot sign further events.
package/dist/signers.js CHANGED
@@ -6,6 +6,9 @@
6
6
  * EphemeralSigner β€” auth-only fallback when only the redirect signature is available
7
7
  */
8
8
  import { BunkerSigner, parseBunkerInput } from 'nostr-tools/nip46';
9
+ import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
10
+ import { decode as nip19Decode } from 'nostr-tools/nip19';
11
+ import { encrypt as nip44Encrypt, decrypt as nip44Decrypt, getConversationKey } from 'nostr-tools/nip44';
9
12
  /** Returns true if a NIP-07 extension is present on the page. */
10
13
  export function hasNip07() {
11
14
  return typeof window !== 'undefined' && !!window.nostr && typeof window.nostr.signEvent === 'function';
@@ -106,6 +109,70 @@ export function generateSecretKey() {
106
109
  crypto.getRandomValues(sk);
107
110
  return sk;
108
111
  }
112
+ // ── nsec (local privkey, in-memory only) ─────────────────────────────────────
113
+ /**
114
+ * Holds a 32-byte private key in memory, signs locally with schnorr, exposes
115
+ * NIP-44 via nostr-tools. The key is never persisted by this signer β€” the SDK
116
+ * will not call any storage write for an nsec session, so reloads land back
117
+ * on the picker. The consumer must surface the security trade-off in the UI.
118
+ */
119
+ export class LocalSigner {
120
+ constructor(pubkey, privkey) {
121
+ this.pubkey = pubkey;
122
+ this.privkey = privkey;
123
+ this.method = 'nsec';
124
+ this.capabilities = { canSignEvents: true, hasNip44: true };
125
+ this.nip44 = {
126
+ encrypt: async (peer, pt) => nip44Encrypt(pt, getConversationKey(this.privkey, peer)),
127
+ decrypt: async (peer, ct) => nip44Decrypt(ct, getConversationKey(this.privkey, peer)),
128
+ };
129
+ }
130
+ async signEvent(template) {
131
+ const filled = {
132
+ kind: template.kind,
133
+ content: template.content,
134
+ created_at: template.created_at ?? Math.floor(Date.now() / 1000),
135
+ tags: template.tags ?? [],
136
+ };
137
+ return finalizeEvent(filled, this.privkey);
138
+ }
139
+ async close() {
140
+ // Best-effort wipe β€” the engine may already have copies in CoW pages, but
141
+ // zeroing here at least gives a consistent shape with bunker.close().
142
+ this.privkey.fill(0);
143
+ }
144
+ }
145
+ /**
146
+ * Decode a bech32 nsec into a LocalSigner. Accepts either the `nsec1...`
147
+ * prefix or a raw 64-char hex private key for power-user paste paths.
148
+ * Throws on any malformed input β€” caller surfaces the error to the user.
149
+ */
150
+ export function createLocalSignerFromNsec(input) {
151
+ const trimmed = input.trim();
152
+ if (!trimmed)
153
+ throw new Error('empty-nsec');
154
+ let sk;
155
+ if (trimmed.startsWith('nsec1')) {
156
+ const decoded = nip19Decode(trimmed);
157
+ if (decoded.type !== 'nsec')
158
+ throw new Error('not-an-nsec');
159
+ sk = decoded.data;
160
+ }
161
+ else if (/^[0-9a-f]{64}$/i.test(trimmed)) {
162
+ sk = new Uint8Array(32);
163
+ for (let i = 0; i < 32; i++)
164
+ sk[i] = parseInt(trimmed.slice(i * 2, i * 2 + 2), 16);
165
+ }
166
+ else {
167
+ throw new Error('invalid-nsec-format');
168
+ }
169
+ if (sk.length !== 32)
170
+ throw new Error('invalid-nsec-length');
171
+ const pubkey = getPublicKey(sk);
172
+ if (!/^[0-9a-f]{64}$/i.test(pubkey))
173
+ throw new Error('invalid-pubkey-from-nsec');
174
+ return new LocalSigner(pubkey.toLowerCase(), sk);
175
+ }
109
176
  // ── Ephemeral (redirect-only) ─────────────────────────────────────────────────
110
177
  /**
111
178
  * Auth-only signer returned by the redirect/QR flow before Option B is built.