javascript-solid-server 0.0.158 → 0.0.159
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/clock-updater.mjs +7 -4
- package/package.json +2 -2
- package/src/auth/nostr.js +5 -11
- package/src/nostr/event.js +206 -0
- package/src/nostr/relay.js +1 -1
- package/test/did-nostr.test.js +1 -1
- package/test/nostr-event.test.js +277 -0
- package/test-git-nostr-auth.js +5 -2
- package/test-nostr-auth.js +3 -4
package/clock-updater.mjs
CHANGED
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
* Usage: node clock-updater.mjs
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { getPublicKey,
|
|
7
|
-
import { getToken } from 'nostr-tools/nip98';
|
|
6
|
+
import { getPublicKey, nip98Token } from './src/nostr/event.js';
|
|
8
7
|
|
|
9
8
|
// Nostr keypair (in production, load from env/file)
|
|
10
9
|
const SK_HEX = '3f188544fb81bd324ead7be9697fd9503d18345e233a7b0182915b0b582ddd70';
|
|
@@ -26,7 +25,11 @@ async function updateClock() {
|
|
|
26
25
|
};
|
|
27
26
|
|
|
28
27
|
try {
|
|
29
|
-
|
|
28
|
+
// Serialize once: the same bytes feed both the NIP-98 payload hash
|
|
29
|
+
// and the fetch body. nip98Token requires bytes (not an object) so
|
|
30
|
+
// the `payload` tag matches what the server actually receives.
|
|
31
|
+
const bodyBytes = JSON.stringify(clockData);
|
|
32
|
+
const token = nip98Token(CLOCK_URL, 'PUT', sk, bodyBytes);
|
|
30
33
|
|
|
31
34
|
const res = await fetch(CLOCK_URL, {
|
|
32
35
|
method: 'PUT',
|
|
@@ -34,7 +37,7 @@ async function updateClock() {
|
|
|
34
37
|
'Content-Type': 'application/ld+json',
|
|
35
38
|
'Authorization': 'Nostr ' + token
|
|
36
39
|
},
|
|
37
|
-
body:
|
|
40
|
+
body: bodyBytes
|
|
38
41
|
});
|
|
39
42
|
|
|
40
43
|
const time = isoDate.split('T')[1].replace('Z', '');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "javascript-solid-server",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.159",
|
|
4
4
|
"description": "A minimal, fast Solid server",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"@noble/curves": "^1.2.0",
|
|
28
|
+
"@noble/hashes": "^1.3.2",
|
|
28
29
|
"@fastify/middie": "^8.3.3",
|
|
29
30
|
"@fastify/rate-limit": "^9.1.0",
|
|
30
31
|
"@fastify/websocket": "^8.3.1",
|
|
@@ -36,7 +37,6 @@
|
|
|
36
37
|
"jose": "^6.1.3",
|
|
37
38
|
"microfed": "^0.0.14",
|
|
38
39
|
"n3": "^1.26.0",
|
|
39
|
-
"nostr-tools": "^2.19.4",
|
|
40
40
|
"oidc-provider": "^9.6.0",
|
|
41
41
|
"sql.js": "^1.13.0"
|
|
42
42
|
},
|
package/src/auth/nostr.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* did:nostr:<64-char-hex-pubkey>
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { verifyEvent } from 'nostr
|
|
14
|
+
import { verifyEvent, getEventHash } from '../nostr/event.js';
|
|
15
15
|
import crypto from 'crypto';
|
|
16
16
|
import { resolveDidNostrToWebId } from './did-nostr.js';
|
|
17
17
|
|
|
@@ -216,17 +216,11 @@ export async function verifyNostrAuth(request) {
|
|
|
216
216
|
return { webId: null, error: 'Invalid or missing pubkey' };
|
|
217
217
|
}
|
|
218
218
|
|
|
219
|
-
// Compute event id if missing (lenient mode for nosdav compatibility)
|
|
219
|
+
// Compute event id if missing (lenient mode for nosdav compatibility).
|
|
220
|
+
// Uses the same canonical serialization as `verifyEvent` below so we
|
|
221
|
+
// can't drift out of sync with how the verifier hashes events.
|
|
220
222
|
if (!event.id) {
|
|
221
|
-
|
|
222
|
-
0,
|
|
223
|
-
event.pubkey,
|
|
224
|
-
event.created_at,
|
|
225
|
-
event.kind,
|
|
226
|
-
event.tags,
|
|
227
|
-
event.content
|
|
228
|
-
]);
|
|
229
|
-
event.id = crypto.createHash('sha256').update(serialized).digest('hex');
|
|
223
|
+
event.id = getEventHash(event);
|
|
230
224
|
}
|
|
231
225
|
|
|
232
226
|
// Verify Schnorr signature
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NIP-01 / NIP-98 event utilities — verifier and signer.
|
|
3
|
+
*
|
|
4
|
+
* Node-targeted: uses `node:crypto` (`createHash`) and `Buffer` for
|
|
5
|
+
* SHA-256 and base64. The crypto primitive (`@noble/curves` Schnorr)
|
|
6
|
+
* is itself runtime-portable, but this module is wired for Node and
|
|
7
|
+
* is what JSS runs on. Don't claim Workers/Deno portability without
|
|
8
|
+
* swapping `node:crypto` for `crypto.subtle` and `Buffer` for
|
|
9
|
+
* `btoa`/`TextEncoder`.
|
|
10
|
+
*
|
|
11
|
+
* Replaces the `nostr-tools` dependency tree we previously pulled just
|
|
12
|
+
* for a few functions (#135).
|
|
13
|
+
*
|
|
14
|
+
* Verifier surface (used by production):
|
|
15
|
+
* - `getEventHash`, `validateEvent`, `verifyEvent`
|
|
16
|
+
* - consumed by `src/auth/nostr.js` (NIP-98 HTTP auth) and
|
|
17
|
+
* `src/nostr/relay.js` (in-process relay).
|
|
18
|
+
*
|
|
19
|
+
* Signer surface (used by integration tests + dev scripts):
|
|
20
|
+
* - `generateSecretKey`, `getPublicKey`, `finalizeEvent`, `nip98Token`
|
|
21
|
+
* - consumed by `test/*.js` and the repo-root `*.mjs`/`test-*.js`
|
|
22
|
+
* dev scripts. Living in `src/` rather than `test/helpers/` so
|
|
23
|
+
* non-test consumers don't reach into test-only code paths.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { schnorr, secp256k1 } from '@noble/curves/secp256k1';
|
|
27
|
+
import { createHash } from 'node:crypto';
|
|
28
|
+
|
|
29
|
+
// NIP-01 and BIP-340 specify hex fields in lowercase. We require it
|
|
30
|
+
// strictly — accepting uppercase here would let an event verify but
|
|
31
|
+
// then fail downstream case-sensitive lookups (e.g. relay filters
|
|
32
|
+
// matching `event.id` exactly), so callers would have to remember to
|
|
33
|
+
// normalize. Strict at the gate avoids that whole class of bug.
|
|
34
|
+
const HEX_64 = /^[a-f0-9]{64}$/;
|
|
35
|
+
const HEX_128 = /^[a-f0-9]{128}$/;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Compute the canonical NIP-01 event id.
|
|
39
|
+
* Per NIP-01 the id is `sha256(JSON.stringify([0, pubkey, created_at,
|
|
40
|
+
* kind, tags, content]))` with no whitespace, hex-encoded lowercase.
|
|
41
|
+
*
|
|
42
|
+
* @param {object} event - Event with pubkey/created_at/kind/tags/content
|
|
43
|
+
* @returns {string} 64-char lowercase hex sha256 digest
|
|
44
|
+
*/
|
|
45
|
+
export function getEventHash(event) {
|
|
46
|
+
const serialized = JSON.stringify([
|
|
47
|
+
0,
|
|
48
|
+
event.pubkey,
|
|
49
|
+
event.created_at,
|
|
50
|
+
event.kind,
|
|
51
|
+
event.tags,
|
|
52
|
+
event.content
|
|
53
|
+
]);
|
|
54
|
+
return createHash('sha256').update(serialized, 'utf8').digest('hex');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Structural validation — does the object have the shape required of a
|
|
59
|
+
* NIP-01 event? Doesn't compute the hash or verify the signature.
|
|
60
|
+
*/
|
|
61
|
+
export function validateEvent(event) {
|
|
62
|
+
if (!event || typeof event !== 'object' || Array.isArray(event)) return false;
|
|
63
|
+
if (typeof event.id !== 'string' || !HEX_64.test(event.id)) return false;
|
|
64
|
+
if (typeof event.pubkey !== 'string' || !HEX_64.test(event.pubkey)) return false;
|
|
65
|
+
if (typeof event.sig !== 'string' || !HEX_128.test(event.sig)) return false;
|
|
66
|
+
// NIP-01 doesn't cap kinds at 16 bits — many real kinds (10002, 30023,
|
|
67
|
+
// etc.) are above 65535. Accept any non-negative safe integer.
|
|
68
|
+
if (!Number.isSafeInteger(event.kind) || event.kind < 0) return false;
|
|
69
|
+
// Use isSafeInteger for parity with kind — non-safe ints can't round-trip
|
|
70
|
+
// through JSON without precision loss, which would corrupt the canonical hash.
|
|
71
|
+
if (!Number.isSafeInteger(event.created_at) || event.created_at < 0) return false;
|
|
72
|
+
if (typeof event.content !== 'string') return false;
|
|
73
|
+
if (!Array.isArray(event.tags)) return false;
|
|
74
|
+
for (const tag of event.tags) {
|
|
75
|
+
if (!Array.isArray(tag)) return false;
|
|
76
|
+
for (const v of tag) if (typeof v !== 'string') return false;
|
|
77
|
+
}
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Full event verification: passes structural validation AND the
|
|
83
|
+
* declared `id` matches the recomputed hash AND the Schnorr signature
|
|
84
|
+
* (BIP-340) is valid for that id under `pubkey`.
|
|
85
|
+
*
|
|
86
|
+
* Returns `true` only on full success; any failure or thrown crypto
|
|
87
|
+
* error becomes `false` so callers don't have to wrap in try/catch.
|
|
88
|
+
*/
|
|
89
|
+
export function verifyEvent(event) {
|
|
90
|
+
if (!validateEvent(event)) return false;
|
|
91
|
+
if (event.id !== getEventHash(event)) return false;
|
|
92
|
+
try {
|
|
93
|
+
return schnorr.verify(event.sig, event.id, event.pubkey);
|
|
94
|
+
} catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------
|
|
100
|
+
// Signer-side helpers
|
|
101
|
+
// ---------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Generate a random 32-byte secp256k1 private key.
|
|
105
|
+
*
|
|
106
|
+
* Always uses `secp256k1.utils.randomPrivateKey()` so the result is
|
|
107
|
+
* guaranteed to be in [1, n-1] (a valid secp256k1 scalar). A naive
|
|
108
|
+
* `crypto.randomBytes(32)` fallback would have a vanishing-but-nonzero
|
|
109
|
+
* chance of producing 0 or a value >= curve order, which would manifest
|
|
110
|
+
* as flaky signing/verification.
|
|
111
|
+
*/
|
|
112
|
+
export function generateSecretKey() {
|
|
113
|
+
if (!secp256k1.utils?.randomPrivateKey) {
|
|
114
|
+
throw new Error('secp256k1.utils.randomPrivateKey is unavailable');
|
|
115
|
+
}
|
|
116
|
+
return secp256k1.utils.randomPrivateKey();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Derive the BIP-340 x-only public key as 64-char lowercase hex.
|
|
121
|
+
*/
|
|
122
|
+
export function getPublicKey(secretKey) {
|
|
123
|
+
return Buffer.from(schnorr.getPublicKey(secretKey)).toString('hex');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Take a partial Nostr event, compute its NIP-01 id, sign it with the
|
|
128
|
+
* given secret key, and return the finalized event.
|
|
129
|
+
*
|
|
130
|
+
* Validates the template up-front so we can't produce events that would
|
|
131
|
+
* later fail `verifyEvent` (e.g. `kind: undefined` would JSON-serialize
|
|
132
|
+
* as `null` and the resulting id would round-trip as garbage).
|
|
133
|
+
*
|
|
134
|
+
* @param {object} template - {kind, tags?, content?, created_at?}
|
|
135
|
+
* @param {Uint8Array|string} secretKey
|
|
136
|
+
*/
|
|
137
|
+
export function finalizeEvent(template, secretKey) {
|
|
138
|
+
if (!template || typeof template !== 'object') {
|
|
139
|
+
throw new TypeError('finalizeEvent: template must be an object');
|
|
140
|
+
}
|
|
141
|
+
if (!Number.isSafeInteger(template.kind) || template.kind < 0) {
|
|
142
|
+
throw new TypeError('finalizeEvent: template.kind must be a non-negative safe integer');
|
|
143
|
+
}
|
|
144
|
+
if (template.created_at !== undefined &&
|
|
145
|
+
(!Number.isSafeInteger(template.created_at) || template.created_at < 0)) {
|
|
146
|
+
throw new TypeError('finalizeEvent: template.created_at must be a non-negative safe integer');
|
|
147
|
+
}
|
|
148
|
+
if (template.tags !== undefined && !Array.isArray(template.tags)) {
|
|
149
|
+
throw new TypeError('finalizeEvent: template.tags must be an array');
|
|
150
|
+
}
|
|
151
|
+
if (template.content !== undefined && typeof template.content !== 'string') {
|
|
152
|
+
throw new TypeError('finalizeEvent: template.content must be a string');
|
|
153
|
+
}
|
|
154
|
+
const pubkey = getPublicKey(secretKey);
|
|
155
|
+
const event = {
|
|
156
|
+
pubkey,
|
|
157
|
+
created_at: template.created_at ?? Math.floor(Date.now() / 1000),
|
|
158
|
+
kind: template.kind,
|
|
159
|
+
tags: template.tags ?? [],
|
|
160
|
+
content: template.content ?? ''
|
|
161
|
+
};
|
|
162
|
+
event.id = getEventHash(event);
|
|
163
|
+
event.sig = Buffer.from(schnorr.sign(event.id, secretKey)).toString('hex');
|
|
164
|
+
return event;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Build a NIP-98 HTTP auth header value (the part after `Nostr `):
|
|
169
|
+
* a kind-27235 event signed with `secretKey`, base64-encoded.
|
|
170
|
+
*
|
|
171
|
+
* NIP-98's `payload` tag is the SHA-256 of the *exact bytes* the client
|
|
172
|
+
* will send on the wire. So `body` must be a `string`, `Uint8Array`, or
|
|
173
|
+
* `Buffer` representing those bytes — passing a plain JS object would
|
|
174
|
+
* force us to re-serialize via `JSON.stringify`, which is unlikely to
|
|
175
|
+
* match the bytes `fetch()` would actually send (whitespace, key order,
|
|
176
|
+
* non-JSON payloads). Callers serialize once and pass the bytes here.
|
|
177
|
+
*
|
|
178
|
+
* @param {string} url - Full request URL (becomes the `u` tag)
|
|
179
|
+
* @param {string} method - HTTP method (becomes the `method` tag, uppercased)
|
|
180
|
+
* @param {Uint8Array|string} secretKey - 32-byte secret key
|
|
181
|
+
* @param {string|Uint8Array|Buffer|null} [body] - Optional request body
|
|
182
|
+
* *bytes*; if present the SHA-256 hex hash is added as a `payload` tag
|
|
183
|
+
* per NIP-98.
|
|
184
|
+
* @returns {string} base64-encoded signed event
|
|
185
|
+
*/
|
|
186
|
+
export function nip98Token(url, method, secretKey, body = null) {
|
|
187
|
+
const tags = [
|
|
188
|
+
['u', url],
|
|
189
|
+
['method', method.toUpperCase()]
|
|
190
|
+
];
|
|
191
|
+
if (body !== null && body !== undefined) {
|
|
192
|
+
if (typeof body !== 'string' && !(body instanceof Uint8Array)) {
|
|
193
|
+
throw new TypeError(
|
|
194
|
+
'nip98Token: body must be a string, Uint8Array, or Buffer — ' +
|
|
195
|
+
'pass the exact bytes that will be sent on the wire'
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
const bytes = typeof body === 'string'
|
|
199
|
+
? Buffer.from(body, 'utf8')
|
|
200
|
+
: body;
|
|
201
|
+
const hash = createHash('sha256').update(bytes).digest('hex');
|
|
202
|
+
tags.push(['payload', hash]);
|
|
203
|
+
}
|
|
204
|
+
const event = finalizeEvent({ kind: 27235, tags, content: '' }, secretKey);
|
|
205
|
+
return Buffer.from(JSON.stringify(event)).toString('base64');
|
|
206
|
+
}
|
package/src/nostr/relay.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* Endpoint: wss://your.pod/relay
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { validateEvent, verifyEvent } from '
|
|
11
|
+
import { validateEvent, verifyEvent } from './event.js';
|
|
12
12
|
import websocket from '@fastify/websocket';
|
|
13
13
|
|
|
14
14
|
// Default max events to prevent memory exhaustion
|
package/test/did-nostr.test.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { describe, it, before, after, mock } from 'node:test';
|
|
6
6
|
import assert from 'node:assert';
|
|
7
|
-
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr
|
|
7
|
+
import { generateSecretKey, getPublicKey, finalizeEvent } from '../src/nostr/event.js';
|
|
8
8
|
import {
|
|
9
9
|
startTestServer,
|
|
10
10
|
stopTestServer,
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for src/nostr/event.js — the minimal NIP-01 event verifier
|
|
3
|
+
* that replaced nostr-tools (#135).
|
|
4
|
+
*
|
|
5
|
+
* Coverage focuses on the security-critical paths:
|
|
6
|
+
* - Round-trip: a freshly-signed event verifies.
|
|
7
|
+
* - Tamper rejection: any byte mutation makes it fail.
|
|
8
|
+
* - Wrong-key rejection: a sig from a different key fails.
|
|
9
|
+
* - Structural rejection: malformed events fail validateEvent.
|
|
10
|
+
* - getEventHash matches NIP-01's canonical serialization.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it } from 'node:test';
|
|
14
|
+
import assert from 'node:assert';
|
|
15
|
+
import { schnorr } from '@noble/curves/secp256k1';
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
getEventHash,
|
|
19
|
+
validateEvent,
|
|
20
|
+
verifyEvent,
|
|
21
|
+
generateSecretKey,
|
|
22
|
+
getPublicKey,
|
|
23
|
+
finalizeEvent,
|
|
24
|
+
nip98Token
|
|
25
|
+
} from '../src/nostr/event.js';
|
|
26
|
+
|
|
27
|
+
describe('nostr event utilities (#135)', () => {
|
|
28
|
+
describe('getEventHash', () => {
|
|
29
|
+
it('matches NIP-01 canonical serialization (sha256 of [0,pubkey,...])', () => {
|
|
30
|
+
// Pin a deterministic input AND a precomputed digest so both the
|
|
31
|
+
// canonical serialization and the SHA-256 step are locked in.
|
|
32
|
+
// Recompute via:
|
|
33
|
+
// echo -n '[0,"00...01",1000000,1,[],"hello"]' | sha256sum
|
|
34
|
+
const event = {
|
|
35
|
+
pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
|
|
36
|
+
created_at: 1000000,
|
|
37
|
+
kind: 1,
|
|
38
|
+
tags: [],
|
|
39
|
+
content: 'hello'
|
|
40
|
+
};
|
|
41
|
+
const baseline = getEventHash(event);
|
|
42
|
+
assert.strictEqual(
|
|
43
|
+
baseline,
|
|
44
|
+
'01f1ec62e464146177ccfe8580ae050847b3cc48c7eca3e0678fc7b92cedfef0',
|
|
45
|
+
'NIP-01 canonical hash regression — change here means serialization or SHA-256 has shifted'
|
|
46
|
+
);
|
|
47
|
+
assert.match(baseline, /^[a-f0-9]{64}$/);
|
|
48
|
+
|
|
49
|
+
// Any change to any canonical field must yield a different hash.
|
|
50
|
+
assert.notStrictEqual(getEventHash({ ...event, content: 'hello!' }), baseline);
|
|
51
|
+
assert.notStrictEqual(getEventHash({ ...event, kind: 2 }), baseline);
|
|
52
|
+
assert.notStrictEqual(getEventHash({ ...event, created_at: 1000001 }), baseline);
|
|
53
|
+
assert.notStrictEqual(getEventHash({ ...event, tags: [['t']] }), baseline);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('serializes pubkey verbatim (case-strict per NIP-01)', () => {
|
|
57
|
+
// NIP-01 specifies lowercase hex throughout. We don't normalize
|
|
58
|
+
// inside getEventHash — uppercase pubkey would produce a different
|
|
59
|
+
// hash, but validateEvent/verifyEvent reject uppercase before we
|
|
60
|
+
// get here, so callers never see the divergence in practice.
|
|
61
|
+
// Use a pubkey with hex letters so upper- and lower-case differ.
|
|
62
|
+
const lower = {
|
|
63
|
+
pubkey: 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789',
|
|
64
|
+
created_at: 1, kind: 1, tags: [], content: ''
|
|
65
|
+
};
|
|
66
|
+
const upper = { ...lower, pubkey: lower.pubkey.toUpperCase() };
|
|
67
|
+
assert.notStrictEqual(
|
|
68
|
+
getEventHash(lower), getEventHash(upper),
|
|
69
|
+
'getEventHash is canonical: caller must supply lowercase'
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('verifyEvent — round-trip and tamper rejection', () => {
|
|
75
|
+
it('verifies a freshly-signed event', () => {
|
|
76
|
+
const sk = generateSecretKey();
|
|
77
|
+
const event = finalizeEvent({
|
|
78
|
+
kind: 27235,
|
|
79
|
+
tags: [['u', 'https://example.test/foo'], ['method', 'GET']],
|
|
80
|
+
content: ''
|
|
81
|
+
}, sk);
|
|
82
|
+
assert.strictEqual(verifyEvent(event), true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('rejects an event with a flipped content byte', () => {
|
|
86
|
+
const sk = generateSecretKey();
|
|
87
|
+
const event = finalizeEvent({ kind: 1, tags: [], content: 'original' }, sk);
|
|
88
|
+
// Mutate content but keep id/sig — recomputed hash won't match.
|
|
89
|
+
event.content = 'mutated';
|
|
90
|
+
assert.strictEqual(verifyEvent(event), false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('rejects an event whose declared id is wrong', () => {
|
|
94
|
+
const sk = generateSecretKey();
|
|
95
|
+
const event = finalizeEvent({ kind: 1, tags: [], content: 'x' }, sk);
|
|
96
|
+
event.id = '0'.repeat(64); // wrong id
|
|
97
|
+
assert.strictEqual(verifyEvent(event), false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('rejects an event signed by a different key', () => {
|
|
101
|
+
const sk1 = generateSecretKey();
|
|
102
|
+
const sk2 = generateSecretKey();
|
|
103
|
+
const event = finalizeEvent({ kind: 1, tags: [], content: 'x' }, sk1);
|
|
104
|
+
// Replace pubkey with sk2's pubkey but keep sk1's signature.
|
|
105
|
+
event.pubkey = getPublicKey(sk2);
|
|
106
|
+
// Recompute id since pubkey is part of the canonical hash, then
|
|
107
|
+
// the sig won't match the new id.
|
|
108
|
+
event.id = getEventHash(event);
|
|
109
|
+
assert.strictEqual(verifyEvent(event), false);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('rejects an event with a tampered signature', () => {
|
|
113
|
+
const sk = generateSecretKey();
|
|
114
|
+
const event = finalizeEvent({ kind: 1, tags: [], content: 'x' }, sk);
|
|
115
|
+
// Flip the last hex char of the signature.
|
|
116
|
+
const last = event.sig.slice(-1);
|
|
117
|
+
event.sig = event.sig.slice(0, -1) + (last === '0' ? '1' : '0');
|
|
118
|
+
assert.strictEqual(verifyEvent(event), false);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('validateEvent — structural rejection', () => {
|
|
123
|
+
function valid() {
|
|
124
|
+
return finalizeEvent({ kind: 1, tags: [], content: 'x' }, generateSecretKey());
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
it('accepts a well-formed event', () => {
|
|
128
|
+
assert.strictEqual(validateEvent(valid()), true);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const cases = [
|
|
132
|
+
['null', null],
|
|
133
|
+
['undefined', undefined],
|
|
134
|
+
['array', []],
|
|
135
|
+
['missing id', () => { const e = valid(); delete e.id; return e; }],
|
|
136
|
+
['short id', () => { const e = valid(); e.id = 'abcd'; return e; }],
|
|
137
|
+
['non-hex id', () => { const e = valid(); e.id = 'g'.repeat(64); return e; }],
|
|
138
|
+
['short pubkey', () => { const e = valid(); e.pubkey = 'abcd'; return e; }],
|
|
139
|
+
['short sig', () => { const e = valid(); e.sig = 'abcd'; return e; }],
|
|
140
|
+
['kind not integer', () => { const e = valid(); e.kind = 'one'; return e; }],
|
|
141
|
+
['kind negative', () => { const e = valid(); e.kind = -1; return e; }],
|
|
142
|
+
['negative created_at', () => { const e = valid(); e.created_at = -1; return e; }],
|
|
143
|
+
['content not string', () => { const e = valid(); e.content = 123; return e; }],
|
|
144
|
+
['tags not array', () => { const e = valid(); e.tags = 'oops'; return e; }],
|
|
145
|
+
['nested tag not array', () => { const e = valid(); e.tags = ['oops']; return e; }],
|
|
146
|
+
['tag value not string', () => { const e = valid(); e.tags = [[1, 2]]; return e; }]
|
|
147
|
+
];
|
|
148
|
+
|
|
149
|
+
for (const [label, input] of cases) {
|
|
150
|
+
it(`rejects: ${label}`, () => {
|
|
151
|
+
const e = typeof input === 'function' ? input() : input;
|
|
152
|
+
assert.strictEqual(validateEvent(e), false);
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe('lenient input — round 2 fixes (#341 review)', () => {
|
|
158
|
+
it('rejects uppercase hex in id/pubkey/sig (NIP-01 is case-strict)', () => {
|
|
159
|
+
// We deliberately do NOT lenient-accept uppercase here — accepting
|
|
160
|
+
// would let an event verify but then miss case-sensitive downstream
|
|
161
|
+
// lookups (relay filter ID match, dedupe by pubkey, etc.). Strict
|
|
162
|
+
// at the gate keeps the rest of the codebase from having to remember
|
|
163
|
+
// to normalize.
|
|
164
|
+
const sk = generateSecretKey();
|
|
165
|
+
const event = finalizeEvent({ kind: 1, tags: [], content: 'x' }, sk);
|
|
166
|
+
assert.strictEqual(verifyEvent({ ...event, id: event.id.toUpperCase() }), false);
|
|
167
|
+
assert.strictEqual(verifyEvent({ ...event, pubkey: event.pubkey.toUpperCase() }), false);
|
|
168
|
+
assert.strictEqual(verifyEvent({ ...event, sig: event.sig.toUpperCase() }), false);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('accepts kinds above 65535 (NIP-01 has no 16-bit cap)', () => {
|
|
172
|
+
const sk = generateSecretKey();
|
|
173
|
+
const event = finalizeEvent({ kind: 30023, tags: [], content: 'long-form' }, sk);
|
|
174
|
+
assert.strictEqual(verifyEvent(event), true,
|
|
175
|
+
'kind 30023 (NIP-23 long-form content) must verify');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('still rejects negative kinds', () => {
|
|
179
|
+
const sk = generateSecretKey();
|
|
180
|
+
const event = finalizeEvent({ kind: 1, tags: [], content: 'x' }, sk);
|
|
181
|
+
event.kind = -1;
|
|
182
|
+
assert.strictEqual(validateEvent(event), false);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe('finalizeEvent input validation (#341 review)', () => {
|
|
187
|
+
it('throws when kind is missing', () => {
|
|
188
|
+
const sk = generateSecretKey();
|
|
189
|
+
assert.throws(() => finalizeEvent({ tags: [], content: 'x' }, sk), TypeError);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('throws when kind is not a non-negative safe integer', () => {
|
|
193
|
+
const sk = generateSecretKey();
|
|
194
|
+
assert.throws(() => finalizeEvent({ kind: -1 }, sk), TypeError);
|
|
195
|
+
assert.throws(() => finalizeEvent({ kind: 'one' }, sk), TypeError);
|
|
196
|
+
assert.throws(() => finalizeEvent({ kind: 1.5 }, sk), TypeError);
|
|
197
|
+
assert.throws(() => finalizeEvent({ kind: Number.MAX_SAFE_INTEGER + 1 }, sk), TypeError);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('throws when created_at is invalid', () => {
|
|
201
|
+
const sk = generateSecretKey();
|
|
202
|
+
assert.throws(() => finalizeEvent({ kind: 1, created_at: -1 }, sk), TypeError);
|
|
203
|
+
assert.throws(() => finalizeEvent({ kind: 1, created_at: 'now' }, sk), TypeError);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('throws when tags is not an array', () => {
|
|
207
|
+
const sk = generateSecretKey();
|
|
208
|
+
assert.throws(() => finalizeEvent({ kind: 1, tags: 'oops' }, sk), TypeError);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('throws when content is not a string', () => {
|
|
212
|
+
const sk = generateSecretKey();
|
|
213
|
+
assert.throws(() => finalizeEvent({ kind: 1, content: 123 }, sk), TypeError);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe('nip98Token body must be bytes (#341 review)', () => {
|
|
218
|
+
it('accepts a string body (JSON or text)', () => {
|
|
219
|
+
const sk = generateSecretKey();
|
|
220
|
+
const tok = nip98Token('https://x.test/', 'POST', sk, '{"a":1}');
|
|
221
|
+
assert.ok(typeof tok === 'string' && tok.length > 0);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('accepts a Uint8Array body', () => {
|
|
225
|
+
const sk = generateSecretKey();
|
|
226
|
+
const bytes = new TextEncoder().encode('{"a":1}');
|
|
227
|
+
const tok = nip98Token('https://x.test/', 'POST', sk, bytes);
|
|
228
|
+
assert.ok(typeof tok === 'string' && tok.length > 0);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('accepts no body', () => {
|
|
232
|
+
const sk = generateSecretKey();
|
|
233
|
+
const tok = nip98Token('https://x.test/', 'GET', sk);
|
|
234
|
+
assert.ok(typeof tok === 'string' && tok.length > 0);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('throws on a plain object (would re-serialize and not match wire bytes)', () => {
|
|
238
|
+
const sk = generateSecretKey();
|
|
239
|
+
assert.throws(
|
|
240
|
+
() => nip98Token('https://x.test/', 'POST', sk, { a: 1 }),
|
|
241
|
+
TypeError,
|
|
242
|
+
'Object body must be rejected — caller must pass exact wire bytes'
|
|
243
|
+
);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe('validateEvent — created_at (#341 review)', () => {
|
|
248
|
+
it('rejects created_at beyond Number.MAX_SAFE_INTEGER', () => {
|
|
249
|
+
const sk = generateSecretKey();
|
|
250
|
+
const event = finalizeEvent({ kind: 1 }, sk);
|
|
251
|
+
// Manually inject a non-safe-integer to test validateEvent — we
|
|
252
|
+
// can't construct it via finalizeEvent (which now rejects too).
|
|
253
|
+
event.created_at = Number.MAX_SAFE_INTEGER + 1;
|
|
254
|
+
// Recompute id so the structural check is the only one that fires.
|
|
255
|
+
assert.strictEqual(validateEvent(event), false);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
describe('test helper parity with @noble/curves', () => {
|
|
260
|
+
it('getPublicKey returns 32-byte (64 hex) x-only pubkey', () => {
|
|
261
|
+
const sk = generateSecretKey();
|
|
262
|
+
const pk = getPublicKey(sk);
|
|
263
|
+
assert.match(pk, /^[a-f0-9]{64}$/);
|
|
264
|
+
// schnorr.getPublicKey returns 32 bytes for x-only.
|
|
265
|
+
assert.strictEqual(Buffer.from(pk, 'hex').length, 32);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('schnorr.verify (via verifyEvent) rejects events whose id was not signed', () => {
|
|
269
|
+
const sk = generateSecretKey();
|
|
270
|
+
const event = finalizeEvent({ kind: 1, tags: [], content: 'a' }, sk);
|
|
271
|
+
// Sanity: a separate raw schnorr.verify of the same event should agree.
|
|
272
|
+
assert.strictEqual(schnorr.verify(event.sig, event.id, event.pubkey), true);
|
|
273
|
+
// And our wrapper should agree.
|
|
274
|
+
assert.strictEqual(verifyEvent(event), true);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
});
|
package/test-git-nostr-auth.js
CHANGED
|
@@ -18,8 +18,11 @@
|
|
|
18
18
|
* 6. Cleans up
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { generateSecretKey, getPublicKey } from 'nostr
|
|
22
|
-
|
|
21
|
+
import { generateSecretKey, getPublicKey } from './src/nostr/event.js';
|
|
22
|
+
|
|
23
|
+
// Avoid the @noble/hashes import here — Buffer does hex conversion natively
|
|
24
|
+
// and keeps the script's import surface minimal.
|
|
25
|
+
const bytesToHex = (bytes) => Buffer.from(bytes).toString('hex');
|
|
23
26
|
import { execSync, spawn } from 'child_process';
|
|
24
27
|
import fs from 'fs-extra';
|
|
25
28
|
import path from 'path';
|
package/test-nostr-auth.js
CHANGED
|
@@ -10,8 +10,7 @@
|
|
|
10
10
|
* 4. Verifies the did:nostr identity is recognized
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { generateSecretKey, getPublicKey,
|
|
14
|
-
import { getToken } from 'nostr-tools/nip98';
|
|
13
|
+
import { generateSecretKey, getPublicKey, nip98Token } from './src/nostr/event.js';
|
|
15
14
|
|
|
16
15
|
const BASE_URL = process.env.TEST_URL || 'http://localhost:4000';
|
|
17
16
|
|
|
@@ -32,7 +31,7 @@ async function main() {
|
|
|
32
31
|
|
|
33
32
|
console.log(`2. Creating NIP-98 token for ${method} ${testUrl}`);
|
|
34
33
|
|
|
35
|
-
const token =
|
|
34
|
+
const token = nip98Token(testUrl, method, sk);
|
|
36
35
|
|
|
37
36
|
console.log(` Token length: ${token.length} chars\n`);
|
|
38
37
|
|
|
@@ -74,7 +73,7 @@ async function main() {
|
|
|
74
73
|
const containerUrl = `${BASE_URL}/demo/public/`;
|
|
75
74
|
|
|
76
75
|
try {
|
|
77
|
-
const containerToken =
|
|
76
|
+
const containerToken = nip98Token(containerUrl, 'GET', sk);
|
|
78
77
|
|
|
79
78
|
const response = await fetch(containerUrl, {
|
|
80
79
|
headers: {
|