signet-login 0.5.0 → 0.7.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/amber.d.ts +61 -0
- package/dist/amber.js +181 -0
- package/dist/modal.js +123 -1
- package/dist/signers.d.ts +29 -0
- package/dist/signers.js +45 -0
- package/dist/signet-login.d.ts +4 -1
- package/dist/signet-login.iife.js +43 -29
- package/dist/signet-login.js +18 -2
- package/dist/storage.js +1 -1
- package/dist/types.d.ts +1 -1
- package/package.json +1 -1
package/dist/amber.d.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Amber (NIP-55) sign-in flow.
|
|
3
|
+
*
|
|
4
|
+
* NIP-55 is Android-only: web pages open `nostrsigner:` URLs which the
|
|
5
|
+
* Android intent system routes to Amber (or any compatible signer). The
|
|
6
|
+
* page navigates away during sign-in; Amber signs the event and redirects
|
|
7
|
+
* the browser back to the app's callback URL with the signed event encoded
|
|
8
|
+
* in a `event=` parameter.
|
|
9
|
+
*
|
|
10
|
+
* v1 scope: sign-in only. Each kind-21236 auth event takes one round-trip
|
|
11
|
+
* through Amber. Subsequent event signing during the session is not
|
|
12
|
+
* supported in v1 — every sign would require another `nostrsigner:` round
|
|
13
|
+
* trip, which is awful in-flow UX. Amber sessions surface as auth-only via
|
|
14
|
+
* `EphemeralSigner`, mirroring the same-tab Signet redirect flow.
|
|
15
|
+
*
|
|
16
|
+
* NEEDS-ANDROID-VERIFICATION: this code is unverifiable from a desktop dev
|
|
17
|
+
* environment. Smoke test on a real Android device with Amber installed
|
|
18
|
+
* before promoting to production.
|
|
19
|
+
*/
|
|
20
|
+
import type { SignetSession } from './types.js';
|
|
21
|
+
/** True when running on a likely-Android browser. Lets the picker hide the
|
|
22
|
+
* Amber option on iOS/desktop where the `nostrsigner:` scheme is unhandled. */
|
|
23
|
+
export declare function isAndroid(): boolean;
|
|
24
|
+
export interface AmberStartOptions {
|
|
25
|
+
appName: string;
|
|
26
|
+
challenge: string;
|
|
27
|
+
origin: string;
|
|
28
|
+
/** Optional override for the callback URL. Defaults to current page origin. */
|
|
29
|
+
redirectCallback?: string;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Build the `nostrsigner:` URL that Android dispatches to Amber. The auth
|
|
33
|
+
* event is base64-encoded in the path, params control return shape + the
|
|
34
|
+
* callback URL the browser navigates back to.
|
|
35
|
+
*/
|
|
36
|
+
export declare function buildAmberSignerUrl(opts: AmberStartOptions): string;
|
|
37
|
+
/**
|
|
38
|
+
* Persist pending state, navigate to Amber. Mirror of `startRedirect` but
|
|
39
|
+
* the destination is a `nostrsigner:` URL handled by Amber rather than a
|
|
40
|
+
* web URL handled by signet-app. The promise never resolves — the page is
|
|
41
|
+
* gone before it can.
|
|
42
|
+
*/
|
|
43
|
+
export declare function startAmberSignIn(opts: AmberStartOptions): Promise<never>;
|
|
44
|
+
export type ConsumeAmberResult = {
|
|
45
|
+
kind: 'session';
|
|
46
|
+
session: SignetSession;
|
|
47
|
+
} | {
|
|
48
|
+
kind: 'denied';
|
|
49
|
+
} | {
|
|
50
|
+
kind: 'no-callback';
|
|
51
|
+
} | {
|
|
52
|
+
kind: 'invalid';
|
|
53
|
+
reason: string;
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Consume an Amber callback. Detects `?event=<base64-or-json>` (or the
|
|
57
|
+
* `signet_amber=1` flag) on the URL and reconstructs a session. Idempotent:
|
|
58
|
+
* a second call after a successful consume returns 'no-callback' because
|
|
59
|
+
* the params have been stripped.
|
|
60
|
+
*/
|
|
61
|
+
export declare function consumeAmberCallback(): ConsumeAmberResult;
|
package/dist/amber.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { PENDING_REDIRECT_TTL_MS } from './types.js';
|
|
2
|
+
import { clearPendingRedirect, loadPendingRedirect, savePendingRedirect, } from './storage.js';
|
|
3
|
+
import { EphemeralSigner } from './signers.js';
|
|
4
|
+
/** True when running on a likely-Android browser. Lets the picker hide the
|
|
5
|
+
* Amber option on iOS/desktop where the `nostrsigner:` scheme is unhandled. */
|
|
6
|
+
export function isAndroid() {
|
|
7
|
+
if (typeof navigator === 'undefined')
|
|
8
|
+
return false;
|
|
9
|
+
return /android/i.test(navigator.userAgent);
|
|
10
|
+
}
|
|
11
|
+
const HEX_64 = /^[0-9a-f]{64}$/i;
|
|
12
|
+
/**
|
|
13
|
+
* Build the unsigned kind-21236 auth event template Amber will sign. The
|
|
14
|
+
* shape mirrors what every other path produces, so server-side verification
|
|
15
|
+
* is uniform regardless of which signer the user picked.
|
|
16
|
+
*/
|
|
17
|
+
function buildAuthEventTemplate(opts) {
|
|
18
|
+
return {
|
|
19
|
+
kind: 21236,
|
|
20
|
+
content: '',
|
|
21
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
22
|
+
tags: [
|
|
23
|
+
['challenge', opts.challenge],
|
|
24
|
+
['origin', opts.origin],
|
|
25
|
+
['app', opts.appName],
|
|
26
|
+
],
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Build the `nostrsigner:` URL that Android dispatches to Amber. The auth
|
|
31
|
+
* event is base64-encoded in the path, params control return shape + the
|
|
32
|
+
* callback URL the browser navigates back to.
|
|
33
|
+
*/
|
|
34
|
+
export function buildAmberSignerUrl(opts) {
|
|
35
|
+
const template = buildAuthEventTemplate(opts);
|
|
36
|
+
const json = JSON.stringify(template);
|
|
37
|
+
// btoa handles ASCII; the JSON above is pure ASCII (hex challenge, origin
|
|
38
|
+
// URL, app name passes through if ASCII) so plain btoa is correct here.
|
|
39
|
+
// For non-ASCII appName we'd need TextEncoder + base64 of bytes.
|
|
40
|
+
const eventB64 = typeof btoa === 'function'
|
|
41
|
+
? btoa(json)
|
|
42
|
+
: Buffer.from(json, 'utf-8').toString('base64');
|
|
43
|
+
const callback = opts.redirectCallback ?? `${opts.origin}/?signet_amber=1`;
|
|
44
|
+
const params = new URLSearchParams({
|
|
45
|
+
type: 'sign_event',
|
|
46
|
+
compressionType: 'base64',
|
|
47
|
+
returnType: 'event',
|
|
48
|
+
callbackUrl: callback,
|
|
49
|
+
});
|
|
50
|
+
return `nostrsigner:${eventB64}?${params.toString()}`;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Persist pending state, navigate to Amber. Mirror of `startRedirect` but
|
|
54
|
+
* the destination is a `nostrsigner:` URL handled by Amber rather than a
|
|
55
|
+
* web URL handled by signet-app. The promise never resolves — the page is
|
|
56
|
+
* gone before it can.
|
|
57
|
+
*/
|
|
58
|
+
export function startAmberSignIn(opts) {
|
|
59
|
+
if (typeof window === 'undefined') {
|
|
60
|
+
throw new Error('signet-login: amber mode requires a browser environment');
|
|
61
|
+
}
|
|
62
|
+
const pending = {
|
|
63
|
+
challenge: opts.challenge,
|
|
64
|
+
origin: opts.origin,
|
|
65
|
+
appName: opts.appName,
|
|
66
|
+
createdAt: Date.now(),
|
|
67
|
+
};
|
|
68
|
+
savePendingRedirect(pending);
|
|
69
|
+
window.location.href = buildAmberSignerUrl(opts);
|
|
70
|
+
return new Promise(() => { });
|
|
71
|
+
}
|
|
72
|
+
function cleanupAmberCallbackUrl() {
|
|
73
|
+
if (typeof window === 'undefined')
|
|
74
|
+
return;
|
|
75
|
+
const url = new URL(window.location.href);
|
|
76
|
+
let touched = false;
|
|
77
|
+
for (const key of ['event', 'signet_amber', 'error']) {
|
|
78
|
+
if (url.searchParams.has(key)) {
|
|
79
|
+
url.searchParams.delete(key);
|
|
80
|
+
touched = true;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (!touched)
|
|
84
|
+
return;
|
|
85
|
+
const newHref = url.pathname + (url.search ? url.search : '') + url.hash;
|
|
86
|
+
try {
|
|
87
|
+
window.history.replaceState(window.history.state, document.title, newHref);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// history API blocked — leave URL alone
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Consume an Amber callback. Detects `?event=<base64-or-json>` (or the
|
|
95
|
+
* `signet_amber=1` flag) on the URL and reconstructs a session. Idempotent:
|
|
96
|
+
* a second call after a successful consume returns 'no-callback' because
|
|
97
|
+
* the params have been stripped.
|
|
98
|
+
*/
|
|
99
|
+
export function consumeAmberCallback() {
|
|
100
|
+
if (typeof window === 'undefined')
|
|
101
|
+
return { kind: 'no-callback' };
|
|
102
|
+
const params = new URLSearchParams(window.location.search);
|
|
103
|
+
const flagged = params.has('signet_amber') || params.has('event');
|
|
104
|
+
if (!flagged)
|
|
105
|
+
return { kind: 'no-callback' };
|
|
106
|
+
const finalize = (result) => {
|
|
107
|
+
clearPendingRedirect();
|
|
108
|
+
cleanupAmberCallbackUrl();
|
|
109
|
+
return result;
|
|
110
|
+
};
|
|
111
|
+
if (params.get('error') === 'denied') {
|
|
112
|
+
return finalize({ kind: 'denied' });
|
|
113
|
+
}
|
|
114
|
+
const pending = loadPendingRedirect();
|
|
115
|
+
if (!pending) {
|
|
116
|
+
return finalize({ kind: 'invalid', reason: 'no-pending-state' });
|
|
117
|
+
}
|
|
118
|
+
if (pending.origin !== window.location.origin) {
|
|
119
|
+
return finalize({ kind: 'invalid', reason: 'origin-mismatch' });
|
|
120
|
+
}
|
|
121
|
+
if (Date.now() - pending.createdAt > PENDING_REDIRECT_TTL_MS) {
|
|
122
|
+
return finalize({ kind: 'invalid', reason: 'pending-stale' });
|
|
123
|
+
}
|
|
124
|
+
const eventRaw = params.get('event');
|
|
125
|
+
if (!eventRaw) {
|
|
126
|
+
return finalize({ kind: 'invalid', reason: 'no-event-param' });
|
|
127
|
+
}
|
|
128
|
+
// Amber returns the signed event JSON, base64-encoded by default. Try
|
|
129
|
+
// base64 first; fall back to plain JSON if the consumer overrode the
|
|
130
|
+
// compressionType param.
|
|
131
|
+
let parsed;
|
|
132
|
+
try {
|
|
133
|
+
let json;
|
|
134
|
+
try {
|
|
135
|
+
json = typeof atob === 'function'
|
|
136
|
+
? atob(eventRaw)
|
|
137
|
+
: Buffer.from(eventRaw, 'base64').toString('utf-8');
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
json = eventRaw;
|
|
141
|
+
}
|
|
142
|
+
parsed = JSON.parse(json);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return finalize({ kind: 'invalid', reason: 'event-malformed' });
|
|
146
|
+
}
|
|
147
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
148
|
+
return finalize({ kind: 'invalid', reason: 'event-not-object' });
|
|
149
|
+
}
|
|
150
|
+
const ev = parsed;
|
|
151
|
+
if (typeof ev.id !== 'string' || !HEX_64.test(ev.id) ||
|
|
152
|
+
typeof ev.pubkey !== 'string' || !HEX_64.test(ev.pubkey) ||
|
|
153
|
+
typeof ev.sig !== 'string' || !/^[0-9a-f]{128}$/i.test(ev.sig) ||
|
|
154
|
+
typeof ev.created_at !== 'number' ||
|
|
155
|
+
!Array.isArray(ev.tags) ||
|
|
156
|
+
ev.kind !== 21236 ||
|
|
157
|
+
typeof ev.content !== 'string') {
|
|
158
|
+
return finalize({ kind: 'invalid', reason: 'event-shape-invalid' });
|
|
159
|
+
}
|
|
160
|
+
const challengeTag = ev.tags.find(t => Array.isArray(t) && t[0] === 'challenge');
|
|
161
|
+
if (!challengeTag || challengeTag[1] !== pending.challenge) {
|
|
162
|
+
return finalize({ kind: 'invalid', reason: 'challenge-mismatch' });
|
|
163
|
+
}
|
|
164
|
+
const authEvent = {
|
|
165
|
+
id: ev.id.toLowerCase(),
|
|
166
|
+
pubkey: ev.pubkey.toLowerCase(),
|
|
167
|
+
kind: 21236,
|
|
168
|
+
created_at: ev.created_at,
|
|
169
|
+
tags: ev.tags,
|
|
170
|
+
content: ev.content,
|
|
171
|
+
sig: ev.sig.toLowerCase(),
|
|
172
|
+
};
|
|
173
|
+
const ephemeral = new EphemeralSigner(authEvent.pubkey, authEvent);
|
|
174
|
+
const session = {
|
|
175
|
+
pubkey: authEvent.pubkey,
|
|
176
|
+
method: 'amber',
|
|
177
|
+
signer: ephemeral,
|
|
178
|
+
authEvent,
|
|
179
|
+
};
|
|
180
|
+
return finalize({ kind: 'session', session });
|
|
181
|
+
}
|
package/dist/modal.js
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
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, createLocalSignerFromNsec } from './signers.js';
|
|
8
|
+
import { hasNip07, createNip07Signer, createBunkerSigner, createBunkerSignerFromNostrConnect, buildNostrConnectUri, EphemeralSigner, createLocalSignerFromNsec } from './signers.js';
|
|
9
|
+
import { isAndroid, startAmberSignIn } from './amber.js';
|
|
9
10
|
import { waitForAuthResponse } from 'signet-verify';
|
|
10
11
|
import { schnorr } from '@noble/curves/secp256k1';
|
|
11
12
|
import { bytesToHex } from '@noble/hashes/utils';
|
|
@@ -61,14 +62,17 @@ function renderPicker(refs, appName, theme) {
|
|
|
61
62
|
const dark = isDarkMode(theme);
|
|
62
63
|
const muted = dark ? '#888' : '#666';
|
|
63
64
|
const showNip07 = hasNip07();
|
|
65
|
+
const showAmber = isAndroid();
|
|
64
66
|
refs.dialog.innerHTML = `
|
|
65
67
|
<h2 style="margin:0 0 8px;font-size:1.3rem;">Sign in to ${escapeHtml(appName)}</h2>
|
|
66
68
|
<p style="margin:0 0 24px;color:${muted};font-size:0.9rem;">Choose how you want to sign in. Your keys never leave your control.</p>
|
|
67
69
|
<div style="display:flex;flex-direction:column;">
|
|
68
70
|
${showNip07 ? `<button data-choice="nip07" style="${buttonStyle(dark, true)}"><span style="font-size:1.2rem;">🌐</span><span><strong>Browser extension</strong><br><span style="font-size:0.8rem;opacity:0.8;">bark, Alby, nos2x</span></span></button>` : ''}
|
|
71
|
+
${showAmber ? `<button data-choice="amber" style="${buttonStyle(dark)}"><span style="font-size:1.2rem;">🤖</span><span><strong>Sign in with Amber</strong><br><span style="font-size:0.8rem;color:${muted};">Android signer (NIP-55)</span></span></button>` : ''}
|
|
69
72
|
<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
73
|
<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
74
|
<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>
|
|
75
|
+
<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>
|
|
72
76
|
<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>
|
|
73
77
|
</div>
|
|
74
78
|
<button data-choice="cancel" style="background:none;border:0;color:${muted};padding:12px;cursor:pointer;font-size:0.85rem;margin-top:8px;">Cancel</button>
|
|
@@ -309,6 +313,85 @@ async function runBunkerFlow(refs, opts) {
|
|
|
309
313
|
});
|
|
310
314
|
});
|
|
311
315
|
}
|
|
316
|
+
// ── Connect a Nostr signer (NostrConnect URI, app-initiated NIP-46) ──────────
|
|
317
|
+
/**
|
|
318
|
+
* App-initiated NIP-46. Mirror image of bunker URI: instead of the user
|
|
319
|
+
* pasting a bunker URI from their signer, we generate a `nostrconnect://`
|
|
320
|
+
* URI and the user scans it with their signer (nsec.app, Amber, Keychat…).
|
|
321
|
+
* The signer connects to our chosen relay and signs ad-hoc from there.
|
|
322
|
+
*/
|
|
323
|
+
async function runNostrConnectFlow(refs, opts) {
|
|
324
|
+
const dark = isDarkMode(opts.theme);
|
|
325
|
+
const muted = dark ? '#888' : '#666';
|
|
326
|
+
const sk = schnorr.utils.randomPrivateKey();
|
|
327
|
+
const clientPubkey = bytesToHex(schnorr.getPublicKey(sk));
|
|
328
|
+
const secret = bytesToHex(schnorr.utils.randomPrivateKey()).slice(0, 32);
|
|
329
|
+
const uri = buildNostrConnectUri({
|
|
330
|
+
clientPubkeyHex: clientPubkey,
|
|
331
|
+
relayUrl: opts.relayUrl,
|
|
332
|
+
secret,
|
|
333
|
+
perms: ['sign_event', 'nip44_encrypt', 'nip44_decrypt'],
|
|
334
|
+
appName: opts.appName,
|
|
335
|
+
appUrl: opts.origin,
|
|
336
|
+
});
|
|
337
|
+
refs.dialog.innerHTML = `
|
|
338
|
+
<h2 style="margin:0 0 8px;font-size:1.2rem;">Connect a Nostr signer</h2>
|
|
339
|
+
<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>
|
|
340
|
+
<div style="background:${dark ? '#0f0f1f' : '#f5f5f8'};border-radius:8px;padding:16px;margin-bottom:16px;">
|
|
341
|
+
<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>
|
|
342
|
+
<button data-action="copy" style="${buttonStyle(dark)}width:auto;font-size:0.75rem;padding:6px 10px;margin:0 auto;display:block;">Copy URI</button>
|
|
343
|
+
</div>
|
|
344
|
+
<p id="signet-login-nc-status" style="margin:0 0 12px;color:${muted};font-size:0.85rem;">Waiting for signer to connect…</p>
|
|
345
|
+
<div style="display:flex;gap:8px;justify-content:space-between;">
|
|
346
|
+
<button data-action="back" style="${buttonStyle(dark)}width:auto;flex:0 0 auto;padding:8px 16px;">← Back</button>
|
|
347
|
+
<button data-action="cancel" style="${buttonStyle(dark)}width:auto;flex:0 0 auto;padding:8px 16px;">Cancel</button>
|
|
348
|
+
</div>
|
|
349
|
+
`;
|
|
350
|
+
const qrCanvas = refs.dialog.querySelector('#signet-login-nc-qr');
|
|
351
|
+
if (qrCanvas) {
|
|
352
|
+
void QRCode.toCanvas(qrCanvas, uri, {
|
|
353
|
+
width: 200, margin: 1, errorCorrectionLevel: 'M',
|
|
354
|
+
color: { dark: '#0a0418', light: '#ffffff' },
|
|
355
|
+
}).catch(() => { });
|
|
356
|
+
}
|
|
357
|
+
const copyBtn = refs.dialog.querySelector('[data-action="copy"]');
|
|
358
|
+
copyBtn?.addEventListener('click', () => {
|
|
359
|
+
void navigator.clipboard?.writeText(uri).then(() => {
|
|
360
|
+
copyBtn.textContent = 'Copied ✓';
|
|
361
|
+
window.setTimeout(() => { copyBtn.textContent = 'Copy URI'; }, 1500);
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
const ac = new AbortController();
|
|
365
|
+
const status = refs.dialog.querySelector('#signet-login-nc-status');
|
|
366
|
+
return new Promise(resolve => {
|
|
367
|
+
let settled = false;
|
|
368
|
+
const settle = (v) => {
|
|
369
|
+
if (settled)
|
|
370
|
+
return;
|
|
371
|
+
settled = true;
|
|
372
|
+
resolve(v);
|
|
373
|
+
};
|
|
374
|
+
refs.dialog.querySelector('[data-action="back"]')?.addEventListener('click', () => {
|
|
375
|
+
ac.abort();
|
|
376
|
+
settle(null);
|
|
377
|
+
});
|
|
378
|
+
refs.dialog.querySelector('[data-action="cancel"]')?.addEventListener('click', () => {
|
|
379
|
+
ac.abort();
|
|
380
|
+
settle(null);
|
|
381
|
+
});
|
|
382
|
+
createBunkerSignerFromNostrConnect({ uri, clientSecretKey: sk, abortSignal: ac.signal })
|
|
383
|
+
.then(signer => settle(signer))
|
|
384
|
+
.catch(err => {
|
|
385
|
+
if (settled)
|
|
386
|
+
return; // already cancelled
|
|
387
|
+
if (status) {
|
|
388
|
+
status.textContent = `✗ ${err instanceof Error ? err.message : String(err)}`;
|
|
389
|
+
status.style.color = '#d04848';
|
|
390
|
+
}
|
|
391
|
+
// Don't auto-settle on error — user clicks Back/Cancel.
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
}
|
|
312
395
|
// ── Paste nsec (in-memory only) ───────────────────────────────────────────────
|
|
313
396
|
async function runNsecFlow(refs, opts) {
|
|
314
397
|
const dark = isDarkMode(opts.theme);
|
|
@@ -441,6 +524,18 @@ export async function showLoginModal(opts) {
|
|
|
441
524
|
});
|
|
442
525
|
return null; // unreachable
|
|
443
526
|
}
|
|
527
|
+
if (choice === 'amber') {
|
|
528
|
+
// Same-tab navigation to a `nostrsigner:` URL. Android dispatches
|
|
529
|
+
// it to Amber; the page comes back via callbackUrl with the signed
|
|
530
|
+
// event in `?event=`. Picked up on next boot by handleRedirectCallback.
|
|
531
|
+
await startAmberSignIn({
|
|
532
|
+
appName: resolved.appName,
|
|
533
|
+
challenge: resolved.challenge,
|
|
534
|
+
origin: resolved.origin,
|
|
535
|
+
...(resolved.redirectCallback !== undefined ? { redirectCallback: resolved.redirectCallback } : {}),
|
|
536
|
+
});
|
|
537
|
+
return null; // unreachable
|
|
538
|
+
}
|
|
444
539
|
if (choice === 'qr') {
|
|
445
540
|
const result = await runRedirectFlow(refs, resolved);
|
|
446
541
|
if (!result) {
|
|
@@ -483,6 +578,33 @@ export async function showLoginModal(opts) {
|
|
|
483
578
|
authEvent,
|
|
484
579
|
};
|
|
485
580
|
}
|
|
581
|
+
if (choice === 'nostrconnect') {
|
|
582
|
+
const signer = await runNostrConnectFlow(refs, resolved);
|
|
583
|
+
if (!signer) {
|
|
584
|
+
if (resolved.preferredMethod)
|
|
585
|
+
return null;
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
const authEvent = await signer.signEvent({
|
|
589
|
+
kind: 21236,
|
|
590
|
+
content: '',
|
|
591
|
+
tags: [
|
|
592
|
+
['challenge', resolved.challenge],
|
|
593
|
+
['origin', resolved.origin],
|
|
594
|
+
['app', resolved.appName],
|
|
595
|
+
],
|
|
596
|
+
});
|
|
597
|
+
// Surfaces as 'bunker' since the session shape is identical to a
|
|
598
|
+
// bunker URI session — same signer, same persistence path, same
|
|
599
|
+
// capabilities. The picker choice routed us here; from this point
|
|
600
|
+
// on the rest of the SDK doesn't care about the initiation direction.
|
|
601
|
+
return {
|
|
602
|
+
pubkey: signer.pubkey,
|
|
603
|
+
method: 'bunker',
|
|
604
|
+
signer,
|
|
605
|
+
authEvent,
|
|
606
|
+
};
|
|
607
|
+
}
|
|
486
608
|
if (choice === 'nsec') {
|
|
487
609
|
const signer = await runNsecFlow(refs, resolved);
|
|
488
610
|
if (!signer) {
|
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.
|
package/dist/signers.js
CHANGED
|
@@ -80,6 +80,51 @@ export class BunkerSignerImpl {
|
|
|
80
80
|
await this.bunker.close();
|
|
81
81
|
}
|
|
82
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
|
+
}
|
|
83
128
|
/**
|
|
84
129
|
* Connect a bunker session from a `bunker://` or `nostr+connect://` URI (or a
|
|
85
130
|
* NIP-05 identifier). Generates a fresh client secret key for the session.
|
package/dist/signet-login.d.ts
CHANGED
|
@@ -16,10 +16,13 @@
|
|
|
16
16
|
*/
|
|
17
17
|
export type { NostrEvent, EventTemplate, LoginMethod, SignerCapabilities, SignetSigner, SignetAuthEvent, SignetSession, LoginOptions, RestoreOptions, } from './types.js';
|
|
18
18
|
import type { LoginOptions, RestoreOptions, SignetSession } from './types.js';
|
|
19
|
+
import { type ConsumeAmberResult } from './amber.js';
|
|
19
20
|
import { handleCallback as handlePopupCallback } from './callback.js';
|
|
20
21
|
import type { ConsumeCallbackResult } from './redirect.js';
|
|
21
22
|
export type { CallbackResult } from './callback.js';
|
|
22
23
|
export type { ConsumeCallbackResult } from './redirect.js';
|
|
24
|
+
export type { ConsumeAmberResult } from './amber.js';
|
|
25
|
+
export { isAndroid } from './amber.js';
|
|
23
26
|
/**
|
|
24
27
|
* Show the login picker and resolve to a SignetSession on success, or null on
|
|
25
28
|
* cancel / timeout.
|
|
@@ -80,7 +83,7 @@ export declare const handleCallback: typeof handlePopupCallback;
|
|
|
80
83
|
* code that consumes `restoreSession()` doesn't need to care which path
|
|
81
84
|
* authenticated the user.
|
|
82
85
|
*/
|
|
83
|
-
export declare function handleRedirectCallback(): Promise<ConsumeCallbackResult>;
|
|
86
|
+
export declare function handleRedirectCallback(): Promise<ConsumeCallbackResult | ConsumeAmberResult>;
|
|
84
87
|
/**
|
|
85
88
|
* Clear the stored session and close the active signer.
|
|
86
89
|
*/
|