signet-login 0.6.0 → 0.7.1

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.
@@ -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
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { DEFAULTS } from './types.js';
8
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,18 +62,20 @@ 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>
72
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>
73
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>
74
77
  </div>
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>
78
+ <button data-choice="cancel" style="background:transparent;color:${dark ? '#e0e0e0' : '#1a1a2e'};border:1px solid ${dark ? '#3a3a4e' : '#d0d0d0'};border-radius:8px;padding:12px;cursor:pointer;font-size:0.95rem;width:100%;margin-top:12px;text-align:center;">Cancel</button>
76
79
  `;
77
80
  return new Promise(resolve => {
78
81
  refs.dialog.querySelectorAll('button[data-choice]').forEach(btn => {
@@ -479,15 +482,28 @@ export async function showLoginModal(opts) {
479
482
  throw new Error('appName-too-long');
480
483
  const resolved = resolveOptions(opts);
481
484
  const refs = buildModalShell(resolved.theme);
485
+ // Escape and the Android / OS back button fire the dialog's native
486
+ // `cancel` event. Unhandled, the dialog closes visually but the
487
+ // in-flight flow promise never resolves — login() hangs forever and
488
+ // the caller's UI is left stuck behind a dead modal. Racing every
489
+ // flow await against this lets the modal exit cleanly.
490
+ let userAborted = false;
491
+ const aborted = new Promise((resolve) => {
492
+ refs.dialog.addEventListener('cancel', () => { userAborted = true; resolve(null); });
493
+ });
482
494
  try {
483
495
  while (true) {
484
496
  const choice = resolved.preferredMethod
485
497
  ? resolved.preferredMethod
486
- : await renderPicker(refs, resolved.appName, resolved.theme);
487
- if (choice === 'cancel')
498
+ : await Promise.race([renderPicker(refs, resolved.appName, resolved.theme), aborted]);
499
+ if (userAborted)
500
+ return null;
501
+ if (choice === null || choice === 'cancel')
488
502
  return null;
489
503
  if (choice === 'nip07') {
490
- const result = await runNip07Flow(refs, resolved);
504
+ const result = await Promise.race([runNip07Flow(refs, resolved), aborted]);
505
+ if (userAborted)
506
+ return null;
491
507
  if (!result) {
492
508
  if (resolved.preferredMethod)
493
509
  return null;
@@ -521,8 +537,22 @@ export async function showLoginModal(opts) {
521
537
  });
522
538
  return null; // unreachable
523
539
  }
540
+ if (choice === 'amber') {
541
+ // Same-tab navigation to a `nostrsigner:` URL. Android dispatches
542
+ // it to Amber; the page comes back via callbackUrl with the signed
543
+ // event in `?event=`. Picked up on next boot by handleRedirectCallback.
544
+ await startAmberSignIn({
545
+ appName: resolved.appName,
546
+ challenge: resolved.challenge,
547
+ origin: resolved.origin,
548
+ ...(resolved.redirectCallback !== undefined ? { redirectCallback: resolved.redirectCallback } : {}),
549
+ });
550
+ return null; // unreachable
551
+ }
524
552
  if (choice === 'qr') {
525
- const result = await runRedirectFlow(refs, resolved);
553
+ const result = await Promise.race([runRedirectFlow(refs, resolved), aborted]);
554
+ if (userAborted)
555
+ return null;
526
556
  if (!result) {
527
557
  if (resolved.preferredMethod)
528
558
  return null;
@@ -540,7 +570,9 @@ export async function showLoginModal(opts) {
540
570
  return session;
541
571
  }
542
572
  if (choice === 'bunker') {
543
- const signer = await runBunkerFlow(refs, resolved);
573
+ const signer = await Promise.race([runBunkerFlow(refs, resolved), aborted]);
574
+ if (userAborted)
575
+ return null;
544
576
  if (!signer) {
545
577
  if (resolved.preferredMethod)
546
578
  return null;
@@ -564,7 +596,9 @@ export async function showLoginModal(opts) {
564
596
  };
565
597
  }
566
598
  if (choice === 'nostrconnect') {
567
- const signer = await runNostrConnectFlow(refs, resolved);
599
+ const signer = await Promise.race([runNostrConnectFlow(refs, resolved), aborted]);
600
+ if (userAborted)
601
+ return null;
568
602
  if (!signer) {
569
603
  if (resolved.preferredMethod)
570
604
  return null;
@@ -591,7 +625,9 @@ export async function showLoginModal(opts) {
591
625
  };
592
626
  }
593
627
  if (choice === 'nsec') {
594
- const signer = await runNsecFlow(refs, resolved);
628
+ const signer = await Promise.race([runNsecFlow(refs, resolved), aborted]);
629
+ if (userAborted)
630
+ return null;
595
631
  if (!signer) {
596
632
  if (resolved.preferredMethod)
597
633
  return null;
@@ -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
  */