signet-login 0.1.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/LICENSE +21 -0
- package/README.md +234 -0
- package/dist/callback.d.ts +27 -0
- package/dist/callback.js +46 -0
- package/dist/modal.d.ts +13 -0
- package/dist/modal.js +401 -0
- package/dist/redirect.d.ts +85 -0
- package/dist/redirect.js +226 -0
- package/dist/signers.d.ts +81 -0
- package/dist/signers.js +128 -0
- package/dist/signet-login.d.ts +87 -0
- package/dist/signet-login.iife.js +77 -0
- package/dist/signet-login.js +265 -0
- package/dist/storage.d.ts +38 -0
- package/dist/storage.js +159 -0
- package/dist/types.d.ts +164 -0
- package/dist/types.js +35 -0
- package/dist/verify.d.ts +43 -0
- package/dist/verify.js +117 -0
- package/package.json +64 -0
package/dist/redirect.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Same-tab redirect flow for "Sign in with Signet".
|
|
3
|
+
*
|
|
4
|
+
* Two halves:
|
|
5
|
+
*
|
|
6
|
+
* 1. `startRedirect()` — called from `Signet.login({ mode: 'redirect' })`.
|
|
7
|
+
* Persists pending state to localStorage, builds the signet-app auth URL
|
|
8
|
+
* WITHOUT relay/sessionPubkey (so signet-app falls into its
|
|
9
|
+
* `window.location.href = callbackUrl` path), and navigates the current
|
|
10
|
+
* tab. The caller's promise never resolves in this tab — the page is
|
|
11
|
+
* gone.
|
|
12
|
+
*
|
|
13
|
+
* 2. `consumeCallback()` — called from `Signet.handleCallback()` on boot.
|
|
14
|
+
* Detects auth params in `window.location.search`, validates them
|
|
15
|
+
* against the persisted pending state, reconstructs the kind-21236 auth
|
|
16
|
+
* event, persists the session via the existing storage layer, strips
|
|
17
|
+
* the auth params from the URL, and returns a `SignetSession`.
|
|
18
|
+
*
|
|
19
|
+
* Verification note: the reconstructed auth event has a signature that was
|
|
20
|
+
* produced over the original `created_at` chosen by signet-app at sign time.
|
|
21
|
+
* To rebuild the event hash exactly, signet-app must emit `t` (unix seconds)
|
|
22
|
+
* alongside pubkey/signature/eventId in the redirect URL — see the
|
|
23
|
+
* coordinated change in signet-protocol's `buildAuthCallbackUrl`. When `t`
|
|
24
|
+
* is present, the reconstructed event passes signature verification. When
|
|
25
|
+
* absent (older signet-app deployments), the SDK falls back to "now" and
|
|
26
|
+
* logs a warning — server-side strict verification will fail until the
|
|
27
|
+
* issuer is upgraded.
|
|
28
|
+
*/
|
|
29
|
+
import { DEFAULTS, PENDING_REDIRECT_TTL_MS } from './types.js';
|
|
30
|
+
import { clearPendingRedirect, loadPendingRedirect, savePendingRedirect, } from './storage.js';
|
|
31
|
+
import { EphemeralSigner } from './signers.js';
|
|
32
|
+
/** Hex regexes — kept local to avoid pulling in @noble for two patterns. */
|
|
33
|
+
const HEX_64 = /^[0-9a-f]{64}$/i;
|
|
34
|
+
const HEX_128 = /^[0-9a-f]{128}$/i;
|
|
35
|
+
/**
|
|
36
|
+
* Build the signet-app auth URL for redirect mode. Deliberately omits `relay`
|
|
37
|
+
* and `sessionPubkey` so signet-app's `isRelayMode` check (App.tsx) returns
|
|
38
|
+
* false and the redirect path runs.
|
|
39
|
+
*/
|
|
40
|
+
export function buildRedirectAuthUrl(opts) {
|
|
41
|
+
const callback = opts.redirectCallback ?? `${opts.origin}/`;
|
|
42
|
+
const params = new URLSearchParams({
|
|
43
|
+
auth: '1',
|
|
44
|
+
challenge: opts.challenge,
|
|
45
|
+
origin: opts.origin,
|
|
46
|
+
name: opts.appName,
|
|
47
|
+
callback,
|
|
48
|
+
t: String(Math.floor(Date.now() / 1000)),
|
|
49
|
+
});
|
|
50
|
+
return `${opts.signetAppOrigin}/?${params.toString()}`;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Persist pending state and navigate. Resolves to a never-settling promise on
|
|
54
|
+
* success (the page navigates before it can resolve) so callers using
|
|
55
|
+
* `await Signet.login()` see consistent behaviour with the relay path.
|
|
56
|
+
*
|
|
57
|
+
* Throws synchronously if the environment lacks `window` — calling redirect
|
|
58
|
+
* mode in non-browser code is a programming error, not something to silently
|
|
59
|
+
* swallow.
|
|
60
|
+
*/
|
|
61
|
+
export function startRedirect(opts) {
|
|
62
|
+
if (typeof window === 'undefined') {
|
|
63
|
+
throw new Error('signet-login: redirect mode requires a browser environment');
|
|
64
|
+
}
|
|
65
|
+
const pending = {
|
|
66
|
+
challenge: opts.challenge,
|
|
67
|
+
origin: opts.origin,
|
|
68
|
+
appName: opts.appName,
|
|
69
|
+
createdAt: Date.now(),
|
|
70
|
+
};
|
|
71
|
+
savePendingRedirect(pending);
|
|
72
|
+
const url = buildRedirectAuthUrl(opts);
|
|
73
|
+
// Use assignment (not replace) so the user can hit back to abort. The
|
|
74
|
+
// pending record stays put; consumeCallback will GC it via the freshness
|
|
75
|
+
// window if they never come back.
|
|
76
|
+
window.location.href = url;
|
|
77
|
+
// Page is navigating — return a promise that never resolves. Any code
|
|
78
|
+
// running after `await Signet.login()` won't see a value, but the tab is
|
|
79
|
+
// gone before that matters.
|
|
80
|
+
return new Promise(() => { });
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Strip auth-callback params from the current URL via `history.replaceState`,
|
|
84
|
+
* preserving anything else the consumer has on the URL. No-op when there's
|
|
85
|
+
* no auth-callback param present.
|
|
86
|
+
*/
|
|
87
|
+
function cleanupCallbackUrl() {
|
|
88
|
+
if (typeof window === 'undefined')
|
|
89
|
+
return;
|
|
90
|
+
const url = new URL(window.location.href);
|
|
91
|
+
const removed = ['pubkey', 'npub', 'signature', 'eventId', 'error', 'warnings', 'fromNP', 'display_name', 't'];
|
|
92
|
+
let touched = false;
|
|
93
|
+
for (const key of removed) {
|
|
94
|
+
if (url.searchParams.has(key)) {
|
|
95
|
+
url.searchParams.delete(key);
|
|
96
|
+
touched = true;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (!touched)
|
|
100
|
+
return;
|
|
101
|
+
const newHref = url.pathname + (url.search ? url.search : '') + url.hash;
|
|
102
|
+
try {
|
|
103
|
+
window.history.replaceState(window.history.state, document.title, newHref);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// history API blocked (file:// origin, sandboxed iframe, …) — leave URL alone
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Detect and consume a redirect-back callback. Returns:
|
|
111
|
+
*
|
|
112
|
+
* - { kind: 'session', session } — round-trip valid; clears pending state
|
|
113
|
+
* and strips auth params from the URL
|
|
114
|
+
* - { kind: 'denied' } — signet-app sent `error=denied`
|
|
115
|
+
* - { kind: 'no-callback' } — no auth params in the URL; do nothing
|
|
116
|
+
* - { kind: 'invalid', reason } — params present but failed validation
|
|
117
|
+
* (pending state mismatch, stale, hex
|
|
118
|
+
* malformed, …). Pending state is cleared
|
|
119
|
+
* in this case too — a stale or attacker-
|
|
120
|
+
* supplied URL shouldn't poison the next
|
|
121
|
+
* login attempt.
|
|
122
|
+
*
|
|
123
|
+
* Idempotent: calling it twice on the same loaded page returns 'no-callback'
|
|
124
|
+
* the second time because the URL params have been stripped.
|
|
125
|
+
*/
|
|
126
|
+
export function consumeCallback() {
|
|
127
|
+
if (typeof window === 'undefined')
|
|
128
|
+
return { kind: 'no-callback' };
|
|
129
|
+
const params = new URLSearchParams(window.location.search);
|
|
130
|
+
const error = params.get('error');
|
|
131
|
+
const pubkey = params.get('pubkey');
|
|
132
|
+
const signature = params.get('signature');
|
|
133
|
+
const eventId = params.get('eventId');
|
|
134
|
+
// No callback at all — early return without touching pending state.
|
|
135
|
+
if (!error && !pubkey && !signature && !eventId) {
|
|
136
|
+
return { kind: 'no-callback' };
|
|
137
|
+
}
|
|
138
|
+
const pending = loadPendingRedirect();
|
|
139
|
+
// From here on we're handling a callback — pending state must always be
|
|
140
|
+
// cleared on exit so a stale record can't be reused.
|
|
141
|
+
const finalize = (result) => {
|
|
142
|
+
clearPendingRedirect();
|
|
143
|
+
cleanupCallbackUrl();
|
|
144
|
+
return result;
|
|
145
|
+
};
|
|
146
|
+
if (error === 'denied') {
|
|
147
|
+
return finalize({ kind: 'denied' });
|
|
148
|
+
}
|
|
149
|
+
if (!pending) {
|
|
150
|
+
return finalize({ kind: 'invalid', reason: 'no-pending-state' });
|
|
151
|
+
}
|
|
152
|
+
// Origin sanity — protects against a callback URL fired at a different app
|
|
153
|
+
// (e.g. attacker emails a crafted link). Pending was issued by `origin`
|
|
154
|
+
// matching the calling page; on return the page must still be on that origin.
|
|
155
|
+
if (pending.origin !== window.location.origin) {
|
|
156
|
+
return finalize({ kind: 'invalid', reason: 'origin-mismatch' });
|
|
157
|
+
}
|
|
158
|
+
// Freshness — a callback hours after the user clicked Sign In is almost
|
|
159
|
+
// certainly a stale tab restore. Reject rather than reconstructing an
|
|
160
|
+
// expired auth event.
|
|
161
|
+
if (Date.now() - pending.createdAt > PENDING_REDIRECT_TTL_MS) {
|
|
162
|
+
return finalize({ kind: 'invalid', reason: 'pending-stale' });
|
|
163
|
+
}
|
|
164
|
+
if (!pubkey || !HEX_64.test(pubkey)) {
|
|
165
|
+
return finalize({ kind: 'invalid', reason: 'pubkey-malformed' });
|
|
166
|
+
}
|
|
167
|
+
if (!signature || !HEX_128.test(signature)) {
|
|
168
|
+
return finalize({ kind: 'invalid', reason: 'signature-malformed' });
|
|
169
|
+
}
|
|
170
|
+
if (!eventId || !HEX_64.test(eventId)) {
|
|
171
|
+
return finalize({ kind: 'invalid', reason: 'eventId-malformed' });
|
|
172
|
+
}
|
|
173
|
+
// `t` (created_at unix seconds) — emitted by signet-app for exact event
|
|
174
|
+
// reconstruction. Fall back to "now" with a warning when absent (older
|
|
175
|
+
// signet-app deployments). See module-level note.
|
|
176
|
+
let createdAt;
|
|
177
|
+
const tRaw = params.get('t');
|
|
178
|
+
if (tRaw && /^\d+$/.test(tRaw)) {
|
|
179
|
+
const t = Number(tRaw);
|
|
180
|
+
if (!Number.isFinite(t))
|
|
181
|
+
return finalize({ kind: 'invalid', reason: 't-malformed' });
|
|
182
|
+
createdAt = t;
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
createdAt = Math.floor(Date.now() / 1000);
|
|
186
|
+
// Surface this in dev tools so consumers can spot upstream signet-app
|
|
187
|
+
// versions that don't emit `t`. Doesn't fail the flow because the
|
|
188
|
+
// session is still usable client-side; only strict server-side
|
|
189
|
+
// verification will reject it.
|
|
190
|
+
if (typeof console !== 'undefined') {
|
|
191
|
+
console.warn('signet-login: redirect callback missing `t` param — auth event ' +
|
|
192
|
+
'created_at approximated. Server-side verification may reject. ' +
|
|
193
|
+
'Upgrade signet-app to emit `t` in the redirect URL.');
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const lowerPubkey = pubkey.toLowerCase();
|
|
197
|
+
const lowerSig = signature.toLowerCase();
|
|
198
|
+
const lowerEventId = eventId.toLowerCase();
|
|
199
|
+
const authEvent = {
|
|
200
|
+
id: lowerEventId,
|
|
201
|
+
pubkey: lowerPubkey,
|
|
202
|
+
kind: 21236,
|
|
203
|
+
created_at: createdAt,
|
|
204
|
+
tags: [
|
|
205
|
+
['challenge', pending.challenge],
|
|
206
|
+
['origin', pending.origin],
|
|
207
|
+
['app', pending.appName],
|
|
208
|
+
],
|
|
209
|
+
content: '',
|
|
210
|
+
sig: lowerSig,
|
|
211
|
+
};
|
|
212
|
+
const displayName = params.get('display_name') || undefined;
|
|
213
|
+
const ephemeral = new EphemeralSigner(lowerPubkey, authEvent);
|
|
214
|
+
const session = {
|
|
215
|
+
pubkey: lowerPubkey,
|
|
216
|
+
method: 'redirect',
|
|
217
|
+
signer: ephemeral,
|
|
218
|
+
authEvent,
|
|
219
|
+
};
|
|
220
|
+
if (displayName)
|
|
221
|
+
session.displayName = displayName;
|
|
222
|
+
return finalize({ kind: 'session', session });
|
|
223
|
+
}
|
|
224
|
+
// Re-export DEFAULTS for tree-shaking-friendly callers that want to avoid
|
|
225
|
+
// importing the full types module just for one constant.
|
|
226
|
+
export { DEFAULTS };
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Three signer implementations behind one interface.
|
|
3
|
+
*
|
|
4
|
+
* Nip07Signer — wraps window.nostr (bark, Alby, nos2x, Flamingo, …)
|
|
5
|
+
* BunkerSignerImpl — wraps nostr-tools BunkerSigner (NIP-46 over relay)
|
|
6
|
+
* EphemeralSigner — auth-only fallback when only the redirect signature is available
|
|
7
|
+
*/
|
|
8
|
+
import type { EventTemplate, NostrEvent, SignetSigner, SignerCapabilities, SignetAuthEvent } from './types.js';
|
|
9
|
+
import { BunkerSigner } from 'nostr-tools/nip46';
|
|
10
|
+
/** The shape of `window.nostr` exposed by NIP-07 extensions. */
|
|
11
|
+
interface Nip07Provider {
|
|
12
|
+
getPublicKey(): Promise<string>;
|
|
13
|
+
signEvent(event: EventTemplate): Promise<NostrEvent>;
|
|
14
|
+
nip44?: {
|
|
15
|
+
encrypt(peerPubkey: string, plaintext: string): Promise<string>;
|
|
16
|
+
decrypt(peerPubkey: string, ciphertext: string): Promise<string>;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
declare global {
|
|
20
|
+
interface Window {
|
|
21
|
+
nostr?: Nip07Provider;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** Returns true if a NIP-07 extension is present on the page. */
|
|
25
|
+
export declare function hasNip07(): boolean;
|
|
26
|
+
export declare class Nip07Signer implements SignetSigner {
|
|
27
|
+
readonly pubkey: string;
|
|
28
|
+
private readonly provider;
|
|
29
|
+
readonly method: "nip07";
|
|
30
|
+
readonly capabilities: SignerCapabilities;
|
|
31
|
+
readonly nip44?: SignetSigner['nip44'];
|
|
32
|
+
constructor(pubkey: string, provider: Nip07Provider);
|
|
33
|
+
signEvent(template: EventTemplate): Promise<NostrEvent>;
|
|
34
|
+
close(): Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
/** Connects to the page's NIP-07 provider and returns a Nip07Signer. */
|
|
37
|
+
export declare function createNip07Signer(): Promise<Nip07Signer>;
|
|
38
|
+
/** Wraps nostr-tools' BunkerSigner with our SignetSigner interface. */
|
|
39
|
+
export declare class BunkerSignerImpl implements SignetSigner {
|
|
40
|
+
readonly pubkey: string;
|
|
41
|
+
private readonly bunker;
|
|
42
|
+
/** Original bunker URI — kept for persistence/reconnect. */
|
|
43
|
+
readonly bunkerUri: string;
|
|
44
|
+
/** The 32-byte client secret key used in this session — kept for reconnect. */
|
|
45
|
+
readonly clientSecretKey: Uint8Array;
|
|
46
|
+
readonly method: "bunker";
|
|
47
|
+
readonly capabilities: SignerCapabilities;
|
|
48
|
+
readonly nip44: SignetSigner['nip44'];
|
|
49
|
+
constructor(pubkey: string, bunker: BunkerSigner,
|
|
50
|
+
/** Original bunker URI — kept for persistence/reconnect. */
|
|
51
|
+
bunkerUri: string,
|
|
52
|
+
/** The 32-byte client secret key used in this session — kept for reconnect. */
|
|
53
|
+
clientSecretKey: Uint8Array);
|
|
54
|
+
signEvent(template: EventTemplate): Promise<NostrEvent>;
|
|
55
|
+
close(): Promise<void>;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Connect a bunker session from a `bunker://` or `nostr+connect://` URI (or a
|
|
59
|
+
* NIP-05 identifier). Generates a fresh client secret key for the session.
|
|
60
|
+
*/
|
|
61
|
+
export declare function createBunkerSigner(input: {
|
|
62
|
+
uri: string;
|
|
63
|
+
clientSecretKey?: Uint8Array;
|
|
64
|
+
onauth?: (url: string) => void;
|
|
65
|
+
}): Promise<BunkerSignerImpl>;
|
|
66
|
+
/** Generate a 32-byte secret key. */
|
|
67
|
+
export declare function generateSecretKey(): Uint8Array;
|
|
68
|
+
/**
|
|
69
|
+
* Auth-only signer returned by the redirect/QR flow before Option B is built.
|
|
70
|
+
* Holds the signed challenge but cannot sign further events.
|
|
71
|
+
*/
|
|
72
|
+
export declare class EphemeralSigner implements SignetSigner {
|
|
73
|
+
readonly pubkey: string;
|
|
74
|
+
readonly authEvent: SignetAuthEvent;
|
|
75
|
+
readonly method: "redirect";
|
|
76
|
+
readonly capabilities: SignerCapabilities;
|
|
77
|
+
constructor(pubkey: string, authEvent: SignetAuthEvent);
|
|
78
|
+
signEvent(_template: EventTemplate): Promise<NostrEvent>;
|
|
79
|
+
close(): Promise<void>;
|
|
80
|
+
}
|
|
81
|
+
export {};
|
package/dist/signers.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Three signer implementations behind one interface.
|
|
3
|
+
*
|
|
4
|
+
* Nip07Signer — wraps window.nostr (bark, Alby, nos2x, Flamingo, …)
|
|
5
|
+
* BunkerSignerImpl — wraps nostr-tools BunkerSigner (NIP-46 over relay)
|
|
6
|
+
* EphemeralSigner — auth-only fallback when only the redirect signature is available
|
|
7
|
+
*/
|
|
8
|
+
import { BunkerSigner, parseBunkerInput } from 'nostr-tools/nip46';
|
|
9
|
+
/** Returns true if a NIP-07 extension is present on the page. */
|
|
10
|
+
export function hasNip07() {
|
|
11
|
+
return typeof window !== 'undefined' && !!window.nostr && typeof window.nostr.signEvent === 'function';
|
|
12
|
+
}
|
|
13
|
+
export class Nip07Signer {
|
|
14
|
+
constructor(pubkey, provider) {
|
|
15
|
+
this.pubkey = pubkey;
|
|
16
|
+
this.provider = provider;
|
|
17
|
+
this.method = 'nip07';
|
|
18
|
+
this.capabilities = { canSignEvents: true, hasNip44: !!provider.nip44 };
|
|
19
|
+
if (provider.nip44) {
|
|
20
|
+
this.nip44 = {
|
|
21
|
+
encrypt: (peer, pt) => provider.nip44.encrypt(peer, pt),
|
|
22
|
+
decrypt: (peer, ct) => provider.nip44.decrypt(peer, ct),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
async signEvent(template) {
|
|
27
|
+
return this.provider.signEvent(template);
|
|
28
|
+
}
|
|
29
|
+
async close() {
|
|
30
|
+
// NIP-07 extensions have no concept of disconnect — nothing to do.
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Connects to the page's NIP-07 provider and returns a Nip07Signer. */
|
|
34
|
+
export async function createNip07Signer() {
|
|
35
|
+
if (!hasNip07())
|
|
36
|
+
throw new Error('no-nip07-provider');
|
|
37
|
+
const provider = window.nostr;
|
|
38
|
+
const pubkey = await provider.getPublicKey();
|
|
39
|
+
if (!/^[0-9a-f]{64}$/i.test(pubkey))
|
|
40
|
+
throw new Error('invalid-pubkey-from-nip07');
|
|
41
|
+
return new Nip07Signer(pubkey.toLowerCase(), provider);
|
|
42
|
+
}
|
|
43
|
+
// ── NIP-46 bunker ─────────────────────────────────────────────────────────────
|
|
44
|
+
/** Wraps nostr-tools' BunkerSigner with our SignetSigner interface. */
|
|
45
|
+
export class BunkerSignerImpl {
|
|
46
|
+
constructor(pubkey, bunker,
|
|
47
|
+
/** Original bunker URI — kept for persistence/reconnect. */
|
|
48
|
+
bunkerUri,
|
|
49
|
+
/** The 32-byte client secret key used in this session — kept for reconnect. */
|
|
50
|
+
clientSecretKey) {
|
|
51
|
+
this.pubkey = pubkey;
|
|
52
|
+
this.bunker = bunker;
|
|
53
|
+
this.bunkerUri = bunkerUri;
|
|
54
|
+
this.clientSecretKey = clientSecretKey;
|
|
55
|
+
this.method = 'bunker';
|
|
56
|
+
this.capabilities = { canSignEvents: true, hasNip44: true };
|
|
57
|
+
this.nip44 = {
|
|
58
|
+
encrypt: (peer, pt) => bunker.nip44Encrypt(peer, pt),
|
|
59
|
+
decrypt: (peer, ct) => bunker.nip44Decrypt(peer, ct),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
async signEvent(template) {
|
|
63
|
+
// BunkerSigner.signEvent expects EventTemplate with required created_at + tags.
|
|
64
|
+
// Strip any pubkey field, fill in defaults if omitted.
|
|
65
|
+
const { pubkey: _omit, ...rest } = template;
|
|
66
|
+
void _omit;
|
|
67
|
+
const filled = {
|
|
68
|
+
kind: rest.kind,
|
|
69
|
+
content: rest.content,
|
|
70
|
+
created_at: rest.created_at ?? Math.floor(Date.now() / 1000),
|
|
71
|
+
tags: rest.tags ?? [],
|
|
72
|
+
};
|
|
73
|
+
const verified = await this.bunker.signEvent(filled);
|
|
74
|
+
return verified;
|
|
75
|
+
}
|
|
76
|
+
async close() {
|
|
77
|
+
await this.bunker.close();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Connect a bunker session from a `bunker://` or `nostr+connect://` URI (or a
|
|
82
|
+
* NIP-05 identifier). Generates a fresh client secret key for the session.
|
|
83
|
+
*/
|
|
84
|
+
export async function createBunkerSigner(input) {
|
|
85
|
+
const trimmed = input.uri.trim();
|
|
86
|
+
if (!trimmed)
|
|
87
|
+
throw new Error('empty-bunker-uri');
|
|
88
|
+
const pointer = await parseBunkerInput(trimmed);
|
|
89
|
+
if (!pointer)
|
|
90
|
+
throw new Error('invalid-bunker-uri');
|
|
91
|
+
const sk = input.clientSecretKey ?? generateSecretKey();
|
|
92
|
+
if (sk.length !== 32)
|
|
93
|
+
throw new Error('invalid-client-secret-key');
|
|
94
|
+
const bunker = BunkerSigner.fromBunker(sk, pointer, { onauth: input.onauth });
|
|
95
|
+
await bunker.connect();
|
|
96
|
+
const pubkey = await bunker.getPublicKey();
|
|
97
|
+
if (!/^[0-9a-f]{64}$/i.test(pubkey)) {
|
|
98
|
+
await bunker.close().catch(() => { });
|
|
99
|
+
throw new Error('invalid-pubkey-from-bunker');
|
|
100
|
+
}
|
|
101
|
+
return new BunkerSignerImpl(pubkey.toLowerCase(), bunker, trimmed, sk);
|
|
102
|
+
}
|
|
103
|
+
/** Generate a 32-byte secret key. */
|
|
104
|
+
export function generateSecretKey() {
|
|
105
|
+
const sk = new Uint8Array(32);
|
|
106
|
+
crypto.getRandomValues(sk);
|
|
107
|
+
return sk;
|
|
108
|
+
}
|
|
109
|
+
// ── Ephemeral (redirect-only) ─────────────────────────────────────────────────
|
|
110
|
+
/**
|
|
111
|
+
* Auth-only signer returned by the redirect/QR flow before Option B is built.
|
|
112
|
+
* Holds the signed challenge but cannot sign further events.
|
|
113
|
+
*/
|
|
114
|
+
export class EphemeralSigner {
|
|
115
|
+
constructor(pubkey, authEvent) {
|
|
116
|
+
this.pubkey = pubkey;
|
|
117
|
+
this.authEvent = authEvent;
|
|
118
|
+
this.method = 'redirect';
|
|
119
|
+
this.capabilities = { canSignEvents: false, hasNip44: false };
|
|
120
|
+
}
|
|
121
|
+
async signEvent(_template) {
|
|
122
|
+
throw new Error('signer-auth-only: this session was established via redirect and cannot sign new events. ' +
|
|
123
|
+
'Install a NIP-07 extension (bark, Alby) or paste a bunker URI to upgrade.');
|
|
124
|
+
}
|
|
125
|
+
async close() {
|
|
126
|
+
// nothing to close
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signet Login SDK — Sign in with Signet for Nostr-aware websites.
|
|
3
|
+
*
|
|
4
|
+
* ESM / bundler usage:
|
|
5
|
+
* import { login, restoreSession, logout } from 'signet-login';
|
|
6
|
+
*
|
|
7
|
+
* Script-tag / IIFE usage (additively extends `window.Signet`):
|
|
8
|
+
* <script src="https://cdn.signet.forgesworn.dev/signet-login.iife.js"></script>
|
|
9
|
+
* <script>
|
|
10
|
+
* const session = await Signet.login({ appName: 'Asteroid Sats' });
|
|
11
|
+
* </script>
|
|
12
|
+
*
|
|
13
|
+
* The IIFE bundle does NOT overwrite `window.Signet` — it augments whatever is
|
|
14
|
+
* already there (so `signet-verify.iife.js` and `signet-login.iife.js` coexist
|
|
15
|
+
* in either load order on the same page).
|
|
16
|
+
*/
|
|
17
|
+
export type { NostrEvent, EventTemplate, LoginMethod, SignerCapabilities, SignetSigner, SignetAuthEvent, SignetSession, LoginOptions, RestoreOptions, } from './types.js';
|
|
18
|
+
import type { LoginOptions, RestoreOptions, SignetSession } from './types.js';
|
|
19
|
+
import { handleCallback as handlePopupCallback } from './callback.js';
|
|
20
|
+
import type { ConsumeCallbackResult } from './redirect.js';
|
|
21
|
+
export type { CallbackResult } from './callback.js';
|
|
22
|
+
export type { ConsumeCallbackResult } from './redirect.js';
|
|
23
|
+
/**
|
|
24
|
+
* Show the login picker and resolve to a SignetSession on success, or null on
|
|
25
|
+
* cancel / timeout.
|
|
26
|
+
*
|
|
27
|
+
* When `mode: 'redirect'` is set, the picker is skipped entirely — the current
|
|
28
|
+
* tab navigates to signet-app and this promise NEVER resolves in this tab.
|
|
29
|
+
* Callers should treat the returned promise as "fire and forget" in that case
|
|
30
|
+
* and call `Signet.handleCallback()` on the next page load to receive the
|
|
31
|
+
* session. The other login methods (NIP-07, bunker) don't use redirect at all
|
|
32
|
+
* and are unaffected by this option.
|
|
33
|
+
*/
|
|
34
|
+
export declare function login(opts: LoginOptions): Promise<SignetSession | null>;
|
|
35
|
+
/**
|
|
36
|
+
* Try to restore a session from localStorage. Returns null if no session is
|
|
37
|
+
* stored or it's malformed/expired.
|
|
38
|
+
*
|
|
39
|
+
* For bunker sessions, attempts to reconnect using the stored URI + client SK.
|
|
40
|
+
* If the bunker is unreachable, returns null and clears the stored session.
|
|
41
|
+
*/
|
|
42
|
+
export declare function restoreSession(opts?: RestoreOptions): Promise<SignetSession | null>;
|
|
43
|
+
/**
|
|
44
|
+
* Popup-style callback receiver. Use on the page that signet-app redirects
|
|
45
|
+
* a popup to. Parses URL params and posts them to `window.opener`, then
|
|
46
|
+
* closes the popup. Returns the raw params for non-popup contexts.
|
|
47
|
+
*
|
|
48
|
+
* For the same-tab redirect flow (`mode: 'redirect'` on `login()`), use
|
|
49
|
+
* `Signet.handleRedirectCallback()` instead — that one validates against the
|
|
50
|
+
* persisted pending state and returns a fully-formed `SignetSession`.
|
|
51
|
+
*/
|
|
52
|
+
export declare const handleCallback: typeof handlePopupCallback;
|
|
53
|
+
/**
|
|
54
|
+
* Same-tab redirect callback receiver. Call once on app boot, before
|
|
55
|
+
* `restoreSession()`, to consume an incoming `?pubkey&signature&eventId`
|
|
56
|
+
* payload from signet-app.
|
|
57
|
+
*
|
|
58
|
+
* Behaviour:
|
|
59
|
+
*
|
|
60
|
+
* - `'session'`: validates the round-trip against the pending state saved
|
|
61
|
+
* by `login({ mode: 'redirect' })`, builds and persists a SignetSession
|
|
62
|
+
* (so `restoreSession()` finds it next time), and strips the auth params
|
|
63
|
+
* from the URL via `history.replaceState`. The returned session uses an
|
|
64
|
+
* `EphemeralSigner` — `signer.capabilities.canSignEvents` is false. Pair
|
|
65
|
+
* with NIP-07 / bunker if you need ongoing signing.
|
|
66
|
+
*
|
|
67
|
+
* - `'denied'`: signet-app reported the user rejected the request.
|
|
68
|
+
*
|
|
69
|
+
* - `'no-callback'`: no auth params on the URL — the typical case on most
|
|
70
|
+
* page loads. Idempotent: a second call after success also returns this.
|
|
71
|
+
*
|
|
72
|
+
* - `'invalid'`: params present but failed validation. `reason` is a
|
|
73
|
+
* machine-readable token (`origin-mismatch`, `pending-stale`,
|
|
74
|
+
* `pubkey-malformed`, …). Pending state is cleared either way so a stale
|
|
75
|
+
* URL can't poison the next attempt.
|
|
76
|
+
*
|
|
77
|
+
* The returned shape is intentionally tagged so consumers can distinguish
|
|
78
|
+
* "user denied" from "no callback" without inspecting null. Persistence on
|
|
79
|
+
* success uses the same storage layer as relay-mode sessions, so downstream
|
|
80
|
+
* code that consumes `restoreSession()` doesn't need to care which path
|
|
81
|
+
* authenticated the user.
|
|
82
|
+
*/
|
|
83
|
+
export declare function handleRedirectCallback(): Promise<ConsumeCallbackResult>;
|
|
84
|
+
/**
|
|
85
|
+
* Clear the stored session and close the active signer.
|
|
86
|
+
*/
|
|
87
|
+
export declare function logout(currentSession?: SignetSession): Promise<void>;
|