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.
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Server-side verifier for Signet auth events.
3
+ *
4
+ * Run on your server when the client posts a SignetAuthEvent — this verifies
5
+ * the schnorr signature, the canonical event ID, and the embedded challenge /
6
+ * origin / app tags. Pure Node-friendly: no DOM, no relays, no fetch.
7
+ *
8
+ * import { verifyLogin } from 'signet-login/verify';
9
+ * const result = verifyLogin(authEvent, {
10
+ * expectedChallenge: '...',
11
+ * expectedOrigin: 'https://mygame.com',
12
+ * expectedAppName: 'Asteroid Sats',
13
+ * });
14
+ */
15
+ export interface VerifyLoginOptions {
16
+ /** The challenge the consumer issued. 64 hex. */
17
+ expectedChallenge: string;
18
+ /** The origin the auth event must be bound to (e.g. 'https://mygame.com'). */
19
+ expectedOrigin: string;
20
+ /**
21
+ * Optional: the app name the consumer claimed at login time. If supplied,
22
+ * the auth event's `app` tag must match.
23
+ */
24
+ expectedAppName?: string;
25
+ /** Maximum age of the auth event in seconds. Default: 300 (5 min). */
26
+ maxAgeSeconds?: number;
27
+ /** Override Date.now() for testing. */
28
+ now?: () => number;
29
+ }
30
+ export type VerifyLoginResult = {
31
+ valid: true;
32
+ pubkey: string;
33
+ createdAt: number;
34
+ } | {
35
+ valid: false;
36
+ error: VerifyLoginError;
37
+ };
38
+ export type VerifyLoginError = 'malformed-event' | 'wrong-kind' | 'invalid-event-id' | 'invalid-signature' | 'challenge-mismatch' | 'origin-mismatch' | 'app-mismatch' | 'too-old' | 'in-the-future';
39
+ /**
40
+ * Verify a Signet kind-21236 auth event against the expected challenge, origin,
41
+ * and (optionally) app name.
42
+ */
43
+ export declare function verifyLogin(event: unknown, opts: VerifyLoginOptions): VerifyLoginResult;
package/dist/verify.js ADDED
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Server-side verifier for Signet auth events.
3
+ *
4
+ * Run on your server when the client posts a SignetAuthEvent — this verifies
5
+ * the schnorr signature, the canonical event ID, and the embedded challenge /
6
+ * origin / app tags. Pure Node-friendly: no DOM, no relays, no fetch.
7
+ *
8
+ * import { verifyLogin } from 'signet-login/verify';
9
+ * const result = verifyLogin(authEvent, {
10
+ * expectedChallenge: '...',
11
+ * expectedOrigin: 'https://mygame.com',
12
+ * expectedAppName: 'Asteroid Sats',
13
+ * });
14
+ */
15
+ import { schnorr } from '@noble/curves/secp256k1';
16
+ import { sha256 } from '@noble/hashes/sha256';
17
+ import { bytesToHex } from '@noble/hashes/utils';
18
+ function hexToBytes(hex) {
19
+ const bytes = new Uint8Array(hex.length / 2);
20
+ for (let i = 0; i < bytes.length; i++) {
21
+ bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
22
+ }
23
+ return bytes;
24
+ }
25
+ function isHex(s, len) {
26
+ return typeof s === 'string' && new RegExp(`^[0-9a-f]{${len}}$`, 'i').test(s);
27
+ }
28
+ function getTag(tags, key) {
29
+ if (!Array.isArray(tags))
30
+ return undefined;
31
+ const tag = tags.find(t => Array.isArray(t) && t[0] === key && typeof t[1] === 'string');
32
+ return tag ? tag[1] : undefined;
33
+ }
34
+ /**
35
+ * Canonical Nostr event ID computation per NIP-01:
36
+ * id = SHA-256(JSON.stringify([0, pubkey, created_at, kind, tags, content]))
37
+ */
38
+ function computeEventId(event) {
39
+ const serialised = JSON.stringify([0, event.pubkey, event.created_at, event.kind, event.tags, event.content]);
40
+ return bytesToHex(sha256(new TextEncoder().encode(serialised)));
41
+ }
42
+ /**
43
+ * Verify a Signet kind-21236 auth event against the expected challenge, origin,
44
+ * and (optionally) app name.
45
+ */
46
+ export function verifyLogin(event, opts) {
47
+ // Structural validation
48
+ if (typeof event !== 'object' || event === null)
49
+ return { valid: false, error: 'malformed-event' };
50
+ const e = event;
51
+ if (!isHex(e.id, 64))
52
+ return { valid: false, error: 'malformed-event' };
53
+ if (!isHex(e.pubkey, 64))
54
+ return { valid: false, error: 'malformed-event' };
55
+ if (!isHex(e.sig, 128))
56
+ return { valid: false, error: 'malformed-event' };
57
+ if (typeof e.created_at !== 'number')
58
+ return { valid: false, error: 'malformed-event' };
59
+ if (typeof e.content !== 'string')
60
+ return { valid: false, error: 'malformed-event' };
61
+ if (!Array.isArray(e.tags))
62
+ return { valid: false, error: 'malformed-event' };
63
+ if (e.kind !== 21236)
64
+ return { valid: false, error: 'wrong-kind' };
65
+ const ev = e;
66
+ // Verify event ID = SHA-256 of canonical serialisation
67
+ const expectedId = computeEventId({
68
+ pubkey: ev.pubkey,
69
+ created_at: ev.created_at,
70
+ kind: 21236,
71
+ tags: ev.tags,
72
+ content: ev.content,
73
+ });
74
+ if (expectedId !== ev.id.toLowerCase())
75
+ return { valid: false, error: 'invalid-event-id' };
76
+ // Verify schnorr signature
77
+ let sigOk = false;
78
+ try {
79
+ sigOk = schnorr.verify(hexToBytes(ev.sig), hexToBytes(ev.id), hexToBytes(ev.pubkey));
80
+ }
81
+ catch {
82
+ sigOk = false;
83
+ }
84
+ if (!sigOk)
85
+ return { valid: false, error: 'invalid-signature' };
86
+ // Challenge tag
87
+ const challengeTag = getTag(ev.tags, 'challenge');
88
+ if (!challengeTag)
89
+ return { valid: false, error: 'challenge-mismatch' };
90
+ if (!isHex(opts.expectedChallenge, 64))
91
+ return { valid: false, error: 'challenge-mismatch' };
92
+ if (challengeTag.toLowerCase() !== opts.expectedChallenge.toLowerCase()) {
93
+ return { valid: false, error: 'challenge-mismatch' };
94
+ }
95
+ // Origin tag
96
+ const originTag = getTag(ev.tags, 'origin');
97
+ if (!originTag || originTag !== opts.expectedOrigin) {
98
+ return { valid: false, error: 'origin-mismatch' };
99
+ }
100
+ // App tag (optional check)
101
+ if (opts.expectedAppName !== undefined) {
102
+ const appTag = getTag(ev.tags, 'app');
103
+ if (appTag !== opts.expectedAppName) {
104
+ return { valid: false, error: 'app-mismatch' };
105
+ }
106
+ }
107
+ // Freshness
108
+ const now = (opts.now ?? Date.now)() / 1000;
109
+ const maxAge = opts.maxAgeSeconds ?? 300;
110
+ const age = now - ev.created_at;
111
+ if (age > maxAge)
112
+ return { valid: false, error: 'too-old' };
113
+ // Allow 60 seconds of clock skew into the future
114
+ if (age < -60)
115
+ return { valid: false, error: 'in-the-future' };
116
+ return { valid: true, pubkey: ev.pubkey.toLowerCase(), createdAt: ev.created_at };
117
+ }
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "signet-login",
3
+ "version": "0.1.0",
4
+ "description": "Sign in with Signet — drop-in login SDK for Nostr-aware websites. NIP-07, bunker URI, and Signet redirect/QR in one unified API.",
5
+ "type": "module",
6
+ "main": "./dist/signet-login.js",
7
+ "types": "./dist/signet-login.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/signet-login.d.ts",
11
+ "import": "./dist/signet-login.js",
12
+ "default": "./dist/signet-login.js"
13
+ },
14
+ "./verify": {
15
+ "types": "./dist/verify.d.ts",
16
+ "import": "./dist/verify.js",
17
+ "default": "./dist/verify.js"
18
+ },
19
+ "./iife": "./dist/signet-login.iife.js"
20
+ },
21
+ "files": [
22
+ "dist"
23
+ ],
24
+ "publishConfig": {
25
+ "provenance": true
26
+ },
27
+ "scripts": {
28
+ "clean": "rm -rf dist",
29
+ "build": "npm run clean && tsc && npm run build:iife",
30
+ "build:iife": "esbuild src/signet-login.ts --bundle --format=iife --global-name=__SignetLoginIIFE --outfile=dist/signet-login.iife.js --minify --target=es2020 --footer:js='Object.assign((globalThis.Signet||={}),__SignetLoginIIFE);'",
31
+ "typecheck": "tsc --noEmit",
32
+ "test": "vitest run",
33
+ "test:watch": "vitest"
34
+ },
35
+ "dependencies": {
36
+ "@noble/curves": "^1.8.1",
37
+ "@noble/hashes": "^1.7.1",
38
+ "nostr-tools": "^2.23.3",
39
+ "signet-verify": "^0.3.1"
40
+ },
41
+ "devDependencies": {
42
+ "esbuild": "^0.28.0",
43
+ "jsdom": "^29.1.1",
44
+ "signet-protocol": "^1.6.0",
45
+ "typescript": "^5.7.0",
46
+ "vitest": "^3.2.4"
47
+ },
48
+ "keywords": [
49
+ "nostr",
50
+ "signet",
51
+ "login",
52
+ "authentication",
53
+ "nip-07",
54
+ "nip-46",
55
+ "bunker",
56
+ "decentralised-identity",
57
+ "sign-in-with-signet"
58
+ ],
59
+ "license": "MIT",
60
+ "repository": {
61
+ "type": "git",
62
+ "url": "git+https://github.com/forgesworn/signet-login.git"
63
+ }
64
+ }