signet-login 0.4.0 β 0.6.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 +189 -1
- package/dist/signers.d.ts +51 -0
- package/dist/signers.js +112 -0
- package/dist/signet-login.iife.js +57 -33
- package/dist/signet-login.js +6 -0
- package/dist/types.d.ts +1 -1
- package/package.json +1 -1
package/dist/modal.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
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, createBunkerSignerFromNostrConnect, buildNostrConnectUri, 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';
|
|
@@ -69,6 +69,8 @@ function renderPicker(refs, appName, theme) {
|
|
|
69
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>
|
|
70
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>
|
|
71
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="nostrconnect" style="${buttonStyle(dark)}"><span style="font-size:1.2rem;">π‘</span><span><strong>Connect a Nostr signer</strong><br><span style="font-size:0.8rem;color:${muted};">Scan with nsec.app, Amber, Keychatβ¦</span></span></button>
|
|
73
|
+
<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>
|
|
72
74
|
</div>
|
|
73
75
|
<button data-choice="cancel" style="background:none;border:0;color:${muted};padding:12px;cursor:pointer;font-size:0.85rem;margin-top:8px;">Cancel</button>
|
|
74
76
|
`;
|
|
@@ -308,6 +310,142 @@ async function runBunkerFlow(refs, opts) {
|
|
|
308
310
|
});
|
|
309
311
|
});
|
|
310
312
|
}
|
|
313
|
+
// ββ Connect a Nostr signer (NostrConnect URI, app-initiated NIP-46) ββββββββββ
|
|
314
|
+
/**
|
|
315
|
+
* App-initiated NIP-46. Mirror image of bunker URI: instead of the user
|
|
316
|
+
* pasting a bunker URI from their signer, we generate a `nostrconnect://`
|
|
317
|
+
* URI and the user scans it with their signer (nsec.app, Amber, Keychatβ¦).
|
|
318
|
+
* The signer connects to our chosen relay and signs ad-hoc from there.
|
|
319
|
+
*/
|
|
320
|
+
async function runNostrConnectFlow(refs, opts) {
|
|
321
|
+
const dark = isDarkMode(opts.theme);
|
|
322
|
+
const muted = dark ? '#888' : '#666';
|
|
323
|
+
const sk = schnorr.utils.randomPrivateKey();
|
|
324
|
+
const clientPubkey = bytesToHex(schnorr.getPublicKey(sk));
|
|
325
|
+
const secret = bytesToHex(schnorr.utils.randomPrivateKey()).slice(0, 32);
|
|
326
|
+
const uri = buildNostrConnectUri({
|
|
327
|
+
clientPubkeyHex: clientPubkey,
|
|
328
|
+
relayUrl: opts.relayUrl,
|
|
329
|
+
secret,
|
|
330
|
+
perms: ['sign_event', 'nip44_encrypt', 'nip44_decrypt'],
|
|
331
|
+
appName: opts.appName,
|
|
332
|
+
appUrl: opts.origin,
|
|
333
|
+
});
|
|
334
|
+
refs.dialog.innerHTML = `
|
|
335
|
+
<h2 style="margin:0 0 8px;font-size:1.2rem;">Connect a Nostr signer</h2>
|
|
336
|
+
<p style="margin:0 0 16px;color:${muted};font-size:0.85rem;">Scan or paste this into your signer (nsec.app, Amber, Keychatβ¦). The connection happens over your relay.</p>
|
|
337
|
+
<div style="background:${dark ? '#0f0f1f' : '#f5f5f8'};border-radius:8px;padding:16px;margin-bottom:16px;">
|
|
338
|
+
<canvas id="signet-login-nc-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>
|
|
339
|
+
<button data-action="copy" style="${buttonStyle(dark)}width:auto;font-size:0.75rem;padding:6px 10px;margin:0 auto;display:block;">Copy URI</button>
|
|
340
|
+
</div>
|
|
341
|
+
<p id="signet-login-nc-status" style="margin:0 0 12px;color:${muted};font-size:0.85rem;">Waiting for signer to connectβ¦</p>
|
|
342
|
+
<div style="display:flex;gap:8px;justify-content:space-between;">
|
|
343
|
+
<button data-action="back" style="${buttonStyle(dark)}width:auto;flex:0 0 auto;padding:8px 16px;">β Back</button>
|
|
344
|
+
<button data-action="cancel" style="${buttonStyle(dark)}width:auto;flex:0 0 auto;padding:8px 16px;">Cancel</button>
|
|
345
|
+
</div>
|
|
346
|
+
`;
|
|
347
|
+
const qrCanvas = refs.dialog.querySelector('#signet-login-nc-qr');
|
|
348
|
+
if (qrCanvas) {
|
|
349
|
+
void QRCode.toCanvas(qrCanvas, uri, {
|
|
350
|
+
width: 200, margin: 1, errorCorrectionLevel: 'M',
|
|
351
|
+
color: { dark: '#0a0418', light: '#ffffff' },
|
|
352
|
+
}).catch(() => { });
|
|
353
|
+
}
|
|
354
|
+
const copyBtn = refs.dialog.querySelector('[data-action="copy"]');
|
|
355
|
+
copyBtn?.addEventListener('click', () => {
|
|
356
|
+
void navigator.clipboard?.writeText(uri).then(() => {
|
|
357
|
+
copyBtn.textContent = 'Copied β';
|
|
358
|
+
window.setTimeout(() => { copyBtn.textContent = 'Copy URI'; }, 1500);
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
const ac = new AbortController();
|
|
362
|
+
const status = refs.dialog.querySelector('#signet-login-nc-status');
|
|
363
|
+
return new Promise(resolve => {
|
|
364
|
+
let settled = false;
|
|
365
|
+
const settle = (v) => {
|
|
366
|
+
if (settled)
|
|
367
|
+
return;
|
|
368
|
+
settled = true;
|
|
369
|
+
resolve(v);
|
|
370
|
+
};
|
|
371
|
+
refs.dialog.querySelector('[data-action="back"]')?.addEventListener('click', () => {
|
|
372
|
+
ac.abort();
|
|
373
|
+
settle(null);
|
|
374
|
+
});
|
|
375
|
+
refs.dialog.querySelector('[data-action="cancel"]')?.addEventListener('click', () => {
|
|
376
|
+
ac.abort();
|
|
377
|
+
settle(null);
|
|
378
|
+
});
|
|
379
|
+
createBunkerSignerFromNostrConnect({ uri, clientSecretKey: sk, abortSignal: ac.signal })
|
|
380
|
+
.then(signer => settle(signer))
|
|
381
|
+
.catch(err => {
|
|
382
|
+
if (settled)
|
|
383
|
+
return; // already cancelled
|
|
384
|
+
if (status) {
|
|
385
|
+
status.textContent = `β ${err instanceof Error ? err.message : String(err)}`;
|
|
386
|
+
status.style.color = '#d04848';
|
|
387
|
+
}
|
|
388
|
+
// Don't auto-settle on error β user clicks Back/Cancel.
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
// ββ Paste nsec (in-memory only) βββββββββββββββββββββββββββββββββββββββββββββββ
|
|
393
|
+
async function runNsecFlow(refs, opts) {
|
|
394
|
+
const dark = isDarkMode(opts.theme);
|
|
395
|
+
const muted = dark ? '#888' : '#666';
|
|
396
|
+
const inputBg = dark ? '#0f0f1f' : '#f5f5f8';
|
|
397
|
+
const inputFg = dark ? '#e0e0e0' : '#1a1a2e';
|
|
398
|
+
void opts;
|
|
399
|
+
refs.dialog.innerHTML = `
|
|
400
|
+
<h2 style="margin:0 0 8px;font-size:1.2rem;">Paste private key</h2>
|
|
401
|
+
<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>
|
|
402
|
+
<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>
|
|
403
|
+
<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>
|
|
404
|
+
<p id="signet-login-nsec-status" style="margin:0 0 12px;color:${muted};font-size:0.85rem;min-height:1.2em;"></p>
|
|
405
|
+
<div style="display:flex;gap:8px;justify-content:space-between;">
|
|
406
|
+
<button data-action="back" style="${buttonStyle(dark)}width:auto;flex:0 0 auto;padding:8px 16px;">β Back</button>
|
|
407
|
+
<button data-action="connect" style="${buttonStyle(dark, true)}width:auto;flex:1;padding:8px 16px;text-align:center;">Sign in</button>
|
|
408
|
+
</div>
|
|
409
|
+
`;
|
|
410
|
+
return new Promise(resolve => {
|
|
411
|
+
let settled = false;
|
|
412
|
+
const settle = (v) => {
|
|
413
|
+
if (settled)
|
|
414
|
+
return;
|
|
415
|
+
settled = true;
|
|
416
|
+
resolve(v);
|
|
417
|
+
};
|
|
418
|
+
const input = refs.dialog.querySelector('#signet-login-nsec-input');
|
|
419
|
+
const status = refs.dialog.querySelector('#signet-login-nsec-status');
|
|
420
|
+
const connectBtn = refs.dialog.querySelector('[data-action="connect"]');
|
|
421
|
+
refs.dialog.querySelector('[data-action="back"]')?.addEventListener('click', () => {
|
|
422
|
+
if (input)
|
|
423
|
+
input.value = '';
|
|
424
|
+
settle(null);
|
|
425
|
+
});
|
|
426
|
+
connectBtn?.addEventListener('click', () => {
|
|
427
|
+
const value = input?.value ?? '';
|
|
428
|
+
if (!value.trim()) {
|
|
429
|
+
if (status)
|
|
430
|
+
status.textContent = 'Please paste an nsec.';
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
try {
|
|
434
|
+
const signer = createLocalSignerFromNsec(value);
|
|
435
|
+
// Wipe the textarea ASAP β the key is now in the signer.
|
|
436
|
+
if (input)
|
|
437
|
+
input.value = '';
|
|
438
|
+
settle(signer);
|
|
439
|
+
}
|
|
440
|
+
catch (err) {
|
|
441
|
+
if (status) {
|
|
442
|
+
status.textContent = `β ${err instanceof Error ? err.message : String(err)}`;
|
|
443
|
+
status.style.color = '#d04848';
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
}
|
|
311
449
|
function resolveOptions(opts) {
|
|
312
450
|
const challenge = opts.challenge ?? generateChallenge();
|
|
313
451
|
if (!/^[0-9a-f]{64}$/i.test(challenge))
|
|
@@ -425,6 +563,56 @@ export async function showLoginModal(opts) {
|
|
|
425
563
|
authEvent,
|
|
426
564
|
};
|
|
427
565
|
}
|
|
566
|
+
if (choice === 'nostrconnect') {
|
|
567
|
+
const signer = await runNostrConnectFlow(refs, resolved);
|
|
568
|
+
if (!signer) {
|
|
569
|
+
if (resolved.preferredMethod)
|
|
570
|
+
return null;
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
const authEvent = await signer.signEvent({
|
|
574
|
+
kind: 21236,
|
|
575
|
+
content: '',
|
|
576
|
+
tags: [
|
|
577
|
+
['challenge', resolved.challenge],
|
|
578
|
+
['origin', resolved.origin],
|
|
579
|
+
['app', resolved.appName],
|
|
580
|
+
],
|
|
581
|
+
});
|
|
582
|
+
// Surfaces as 'bunker' since the session shape is identical to a
|
|
583
|
+
// bunker URI session β same signer, same persistence path, same
|
|
584
|
+
// capabilities. The picker choice routed us here; from this point
|
|
585
|
+
// on the rest of the SDK doesn't care about the initiation direction.
|
|
586
|
+
return {
|
|
587
|
+
pubkey: signer.pubkey,
|
|
588
|
+
method: 'bunker',
|
|
589
|
+
signer,
|
|
590
|
+
authEvent,
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
if (choice === 'nsec') {
|
|
594
|
+
const signer = await runNsecFlow(refs, resolved);
|
|
595
|
+
if (!signer) {
|
|
596
|
+
if (resolved.preferredMethod)
|
|
597
|
+
return null;
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
const authEvent = await signer.signEvent({
|
|
601
|
+
kind: 21236,
|
|
602
|
+
content: '',
|
|
603
|
+
tags: [
|
|
604
|
+
['challenge', resolved.challenge],
|
|
605
|
+
['origin', resolved.origin],
|
|
606
|
+
['app', resolved.appName],
|
|
607
|
+
],
|
|
608
|
+
});
|
|
609
|
+
return {
|
|
610
|
+
pubkey: signer.pubkey,
|
|
611
|
+
method: 'nsec',
|
|
612
|
+
signer,
|
|
613
|
+
authEvent,
|
|
614
|
+
};
|
|
615
|
+
}
|
|
428
616
|
// Unknown choice β restart picker
|
|
429
617
|
}
|
|
430
618
|
}
|
package/dist/signers.d.ts
CHANGED
|
@@ -54,6 +54,35 @@ export declare class BunkerSignerImpl implements SignetSigner {
|
|
|
54
54
|
signEvent(template: EventTemplate): Promise<NostrEvent>;
|
|
55
55
|
close(): Promise<void>;
|
|
56
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* App-initiated NIP-46: we generate a `nostrconnect://` URI containing our
|
|
59
|
+
* client pubkey, relay, secret, and requested perms. The user pastes/scans
|
|
60
|
+
* it into their signer, which then connects to the relay and acks. Returns
|
|
61
|
+
* a BunkerSignerImpl once the handshake completes (or rejects on abort).
|
|
62
|
+
*
|
|
63
|
+
* uri β the nostrconnect:// URI shown to the user (built by
|
|
64
|
+
* the caller via buildNostrConnectUri)
|
|
65
|
+
* clientSecretKey β the 32-byte session key the URI was built with
|
|
66
|
+
* abortSignal β cancel a long-running wait when the modal closes
|
|
67
|
+
*/
|
|
68
|
+
export declare function createBunkerSignerFromNostrConnect(input: {
|
|
69
|
+
uri: string;
|
|
70
|
+
clientSecretKey: Uint8Array;
|
|
71
|
+
abortSignal?: AbortSignal;
|
|
72
|
+
}): Promise<BunkerSignerImpl>;
|
|
73
|
+
/**
|
|
74
|
+
* Build a NIP-46 `nostrconnect://` URI for the app-initiated flow. The
|
|
75
|
+
* `secret` is echoed back by the bunker on connect so the app can verify
|
|
76
|
+
* it's talking to the right peer; it must be unguessable.
|
|
77
|
+
*/
|
|
78
|
+
export declare function buildNostrConnectUri(input: {
|
|
79
|
+
clientPubkeyHex: string;
|
|
80
|
+
relayUrl: string;
|
|
81
|
+
secret: string;
|
|
82
|
+
perms?: string[];
|
|
83
|
+
appName?: string;
|
|
84
|
+
appUrl?: string;
|
|
85
|
+
}): string;
|
|
57
86
|
/**
|
|
58
87
|
* Connect a bunker session from a `bunker://` or `nostr+connect://` URI (or a
|
|
59
88
|
* NIP-05 identifier). Generates a fresh client secret key for the session.
|
|
@@ -65,6 +94,28 @@ export declare function createBunkerSigner(input: {
|
|
|
65
94
|
}): Promise<BunkerSignerImpl>;
|
|
66
95
|
/** Generate a 32-byte secret key. */
|
|
67
96
|
export declare function generateSecretKey(): Uint8Array;
|
|
97
|
+
/**
|
|
98
|
+
* Holds a 32-byte private key in memory, signs locally with schnorr, exposes
|
|
99
|
+
* NIP-44 via nostr-tools. The key is never persisted by this signer β the SDK
|
|
100
|
+
* will not call any storage write for an nsec session, so reloads land back
|
|
101
|
+
* on the picker. The consumer must surface the security trade-off in the UI.
|
|
102
|
+
*/
|
|
103
|
+
export declare class LocalSigner implements SignetSigner {
|
|
104
|
+
readonly pubkey: string;
|
|
105
|
+
private readonly privkey;
|
|
106
|
+
readonly method: "nsec";
|
|
107
|
+
readonly capabilities: SignerCapabilities;
|
|
108
|
+
readonly nip44: SignetSigner['nip44'];
|
|
109
|
+
constructor(pubkey: string, privkey: Uint8Array);
|
|
110
|
+
signEvent(template: EventTemplate): Promise<NostrEvent>;
|
|
111
|
+
close(): Promise<void>;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Decode a bech32 nsec into a LocalSigner. Accepts either the `nsec1...`
|
|
115
|
+
* prefix or a raw 64-char hex private key for power-user paste paths.
|
|
116
|
+
* Throws on any malformed input β caller surfaces the error to the user.
|
|
117
|
+
*/
|
|
118
|
+
export declare function createLocalSignerFromNsec(input: string): LocalSigner;
|
|
68
119
|
/**
|
|
69
120
|
* Auth-only signer returned by the redirect/QR flow before Option B is built.
|
|
70
121
|
* 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';
|
|
@@ -77,6 +80,51 @@ export class BunkerSignerImpl {
|
|
|
77
80
|
await this.bunker.close();
|
|
78
81
|
}
|
|
79
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* App-initiated NIP-46: we generate a `nostrconnect://` URI containing our
|
|
85
|
+
* client pubkey, relay, secret, and requested perms. The user pastes/scans
|
|
86
|
+
* it into their signer, which then connects to the relay and acks. Returns
|
|
87
|
+
* a BunkerSignerImpl once the handshake completes (or rejects on abort).
|
|
88
|
+
*
|
|
89
|
+
* uri β the nostrconnect:// URI shown to the user (built by
|
|
90
|
+
* the caller via buildNostrConnectUri)
|
|
91
|
+
* clientSecretKey β the 32-byte session key the URI was built with
|
|
92
|
+
* abortSignal β cancel a long-running wait when the modal closes
|
|
93
|
+
*/
|
|
94
|
+
export async function createBunkerSignerFromNostrConnect(input) {
|
|
95
|
+
const { uri, clientSecretKey, abortSignal } = input;
|
|
96
|
+
if (clientSecretKey.length !== 32)
|
|
97
|
+
throw new Error('invalid-client-secret-key');
|
|
98
|
+
const bunker = abortSignal
|
|
99
|
+
? await BunkerSigner.fromURI(clientSecretKey, uri, undefined, abortSignal)
|
|
100
|
+
: await BunkerSigner.fromURI(clientSecretKey, uri);
|
|
101
|
+
const pubkey = await bunker.getPublicKey();
|
|
102
|
+
if (!/^[0-9a-f]{64}$/i.test(pubkey)) {
|
|
103
|
+
await bunker.close().catch(() => { });
|
|
104
|
+
throw new Error('invalid-pubkey-from-bunker');
|
|
105
|
+
}
|
|
106
|
+
return new BunkerSignerImpl(pubkey.toLowerCase(), bunker, uri, clientSecretKey);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Build a NIP-46 `nostrconnect://` URI for the app-initiated flow. The
|
|
110
|
+
* `secret` is echoed back by the bunker on connect so the app can verify
|
|
111
|
+
* it's talking to the right peer; it must be unguessable.
|
|
112
|
+
*/
|
|
113
|
+
export function buildNostrConnectUri(input) {
|
|
114
|
+
const { clientPubkeyHex, relayUrl, secret } = input;
|
|
115
|
+
if (!/^[0-9a-f]{64}$/i.test(clientPubkeyHex))
|
|
116
|
+
throw new Error('invalid-client-pubkey');
|
|
117
|
+
if (!/^wss?:\/\//.test(relayUrl))
|
|
118
|
+
throw new Error('invalid-relay-url');
|
|
119
|
+
const params = new URLSearchParams({ relay: relayUrl, secret });
|
|
120
|
+
if (input.perms && input.perms.length > 0)
|
|
121
|
+
params.set('perms', input.perms.join(','));
|
|
122
|
+
if (input.appName)
|
|
123
|
+
params.set('name', input.appName);
|
|
124
|
+
if (input.appUrl)
|
|
125
|
+
params.set('url', input.appUrl);
|
|
126
|
+
return `nostrconnect://${clientPubkeyHex}?${params.toString()}`;
|
|
127
|
+
}
|
|
80
128
|
/**
|
|
81
129
|
* Connect a bunker session from a `bunker://` or `nostr+connect://` URI (or a
|
|
82
130
|
* NIP-05 identifier). Generates a fresh client secret key for the session.
|
|
@@ -106,6 +154,70 @@ export function generateSecretKey() {
|
|
|
106
154
|
crypto.getRandomValues(sk);
|
|
107
155
|
return sk;
|
|
108
156
|
}
|
|
157
|
+
// ββ nsec (local privkey, in-memory only) βββββββββββββββββββββββββββββββββββββ
|
|
158
|
+
/**
|
|
159
|
+
* Holds a 32-byte private key in memory, signs locally with schnorr, exposes
|
|
160
|
+
* NIP-44 via nostr-tools. The key is never persisted by this signer β the SDK
|
|
161
|
+
* will not call any storage write for an nsec session, so reloads land back
|
|
162
|
+
* on the picker. The consumer must surface the security trade-off in the UI.
|
|
163
|
+
*/
|
|
164
|
+
export class LocalSigner {
|
|
165
|
+
constructor(pubkey, privkey) {
|
|
166
|
+
this.pubkey = pubkey;
|
|
167
|
+
this.privkey = privkey;
|
|
168
|
+
this.method = 'nsec';
|
|
169
|
+
this.capabilities = { canSignEvents: true, hasNip44: true };
|
|
170
|
+
this.nip44 = {
|
|
171
|
+
encrypt: async (peer, pt) => nip44Encrypt(pt, getConversationKey(this.privkey, peer)),
|
|
172
|
+
decrypt: async (peer, ct) => nip44Decrypt(ct, getConversationKey(this.privkey, peer)),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
async signEvent(template) {
|
|
176
|
+
const filled = {
|
|
177
|
+
kind: template.kind,
|
|
178
|
+
content: template.content,
|
|
179
|
+
created_at: template.created_at ?? Math.floor(Date.now() / 1000),
|
|
180
|
+
tags: template.tags ?? [],
|
|
181
|
+
};
|
|
182
|
+
return finalizeEvent(filled, this.privkey);
|
|
183
|
+
}
|
|
184
|
+
async close() {
|
|
185
|
+
// Best-effort wipe β the engine may already have copies in CoW pages, but
|
|
186
|
+
// zeroing here at least gives a consistent shape with bunker.close().
|
|
187
|
+
this.privkey.fill(0);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Decode a bech32 nsec into a LocalSigner. Accepts either the `nsec1...`
|
|
192
|
+
* prefix or a raw 64-char hex private key for power-user paste paths.
|
|
193
|
+
* Throws on any malformed input β caller surfaces the error to the user.
|
|
194
|
+
*/
|
|
195
|
+
export function createLocalSignerFromNsec(input) {
|
|
196
|
+
const trimmed = input.trim();
|
|
197
|
+
if (!trimmed)
|
|
198
|
+
throw new Error('empty-nsec');
|
|
199
|
+
let sk;
|
|
200
|
+
if (trimmed.startsWith('nsec1')) {
|
|
201
|
+
const decoded = nip19Decode(trimmed);
|
|
202
|
+
if (decoded.type !== 'nsec')
|
|
203
|
+
throw new Error('not-an-nsec');
|
|
204
|
+
sk = decoded.data;
|
|
205
|
+
}
|
|
206
|
+
else if (/^[0-9a-f]{64}$/i.test(trimmed)) {
|
|
207
|
+
sk = new Uint8Array(32);
|
|
208
|
+
for (let i = 0; i < 32; i++)
|
|
209
|
+
sk[i] = parseInt(trimmed.slice(i * 2, i * 2 + 2), 16);
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
throw new Error('invalid-nsec-format');
|
|
213
|
+
}
|
|
214
|
+
if (sk.length !== 32)
|
|
215
|
+
throw new Error('invalid-nsec-length');
|
|
216
|
+
const pubkey = getPublicKey(sk);
|
|
217
|
+
if (!/^[0-9a-f]{64}$/i.test(pubkey))
|
|
218
|
+
throw new Error('invalid-pubkey-from-nsec');
|
|
219
|
+
return new LocalSigner(pubkey.toLowerCase(), sk);
|
|
220
|
+
}
|
|
109
221
|
// ββ Ephemeral (redirect-only) βββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
110
222
|
/**
|
|
111
223
|
* Auth-only signer returned by the redirect/QR flow before Option B is built.
|