Skip to content

Commit 759ca05

Browse files
beacon: firehose phase 1 — validated profiles hose (kind 0)
Introduce a composable 'hose' seam for the firehose indexer and ship the first concrete hose: profiles (kind 0). - src/hoses/profiles.js: declares its kinds, owns its Mongo indexes (pubkey, created_at, and the content_text search index so /api/search works on a fresh deploy), and verifies each event's schnorr signature (NIP-01 id recompute + @noble/curves) before a latest-wins upsert. A relay can no longer inject a forged did:nostr profile. - src/indexer.js: generic hose registry — subscribe to the union of registered hoses' kinds and dispatch by kind. Kind 0 routes through the profiles hose; kinds 3 and 10002 keep the legacy upsert path until their own phases (no behaviour change). - deps: @noble/curves, @noble/hashes (keeps the no-nostr-tools approach). - test/profiles-hose.test.js: sign real kind-0 events and assert accept/reject (valid, empty content, tampered content/id, forged sig, non-hex pubkey, wrong kind, non-JSON content, malformed input). Closes #7
1 parent d279a04 commit 759ca05

6 files changed

Lines changed: 189 additions & 6 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ npm run serve # read API only
3939

4040
## Status
4141

42-
v0 — MVP: indexer + read API + the regression-compatible schema. DID-document resolution endpoint (`/.well-known/did/nostr/:pubkey.json` via jss's `buildDidDocument`) is the next step, per the RFC.
42+
v0 — MVP: indexer + read API + the regression-compatible schema, plus the DID-document resolution endpoint (`/.well-known/did/nostr/:pubkey.json` via jss's `buildDidDocument`) and a `/relays` health directory.
43+
44+
The indexer is being reworked into composable **hoses** (`src/hoses/`). Phase 1 is the **profiles hose** (kind 0): it schnorr-verifies each event before storing it, so a relay can't inject a forged `did:nostr` profile, and owns its Mongo indexes (incl. the `content_text` search index). Follows (kind 3) and relay lists (kind 10002) still use the raw upsert path until their phases.
4345

4446
## License
4547

package-lock.json

Lines changed: 29 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,18 @@
33
"version": "0.0.1",
44
"type": "module",
55
"description": "did:nostr social-graph indexer (profiles + follows + relay lists) on MongoDB. Same schema as nostr-beacon for regression; DID-doc generation delegates to jss buildDidDocument per RFC.",
6-
"bin": { "beacon": "index.js" },
6+
"bin": {
7+
"beacon": "index.js"
8+
},
79
"scripts": {
810
"test": "node --test",
911
"start": "node index.js",
1012
"index": "node -e \"import('./src/indexer.js').then(m => m.runIndexer())\"",
1113
"serve": "node -e \"import('./src/server.js').then(m => m.startServer())\""
1214
},
1315
"dependencies": {
16+
"@noble/curves": "^2.2.0",
17+
"@noble/hashes": "^2.2.0",
1418
"dotenv": "^16.4.5",
1519
"express": "^4.19.2",
1620
"mongodb": "^6.8.0"

src/hoses/profiles.js

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// The profiles hose — first concrete "hose" on the firehose indexer.
2+
//
3+
// A hose is a small, self-contained ingest module: it declares the event
4+
// `kinds` it wants, ensures its own Mongo indexes, and ingests one raw event
5+
// at a time. The indexer subscribes to the union of all hoses' kinds and
6+
// dispatches each event to the matching hose. Later phases add sibling hoses
7+
// (follows, relay lists, relay health) without touching the indexer's core.
8+
//
9+
// This hose handles kind 0 (profile metadata). Unlike a blind upsert, it
10+
// verifies each event's schnorr signature before trusting it — beacon is an
11+
// identity/SSO substrate, so a malicious relay must not be able to inject a
12+
// forged did:nostr profile.
13+
import { schnorr } from '@noble/curves/secp256k1.js';
14+
import { sha256 } from '@noble/hashes/sha2.js';
15+
import { hexToBytes, bytesToHex } from '@noble/hashes/utils.js';
16+
import { COLLECTIONS } from '../db.js';
17+
18+
const KIND = 0;
19+
const HEX64 = /^[0-9a-f]{64}$/;
20+
const HEX128 = /^[0-9a-f]{128}$/;
21+
const enc = new TextEncoder();
22+
23+
// NIP-01 event id: sha256 of the canonical serialization
24+
// [0, pubkey, created_at, kind, tags, content].
25+
function eventId(e) {
26+
const serial = JSON.stringify([0, e.pubkey, e.created_at, e.kind, e.tags || [], e.content ?? '']);
27+
return bytesToHex(sha256(enc.encode(serial)));
28+
}
29+
30+
/**
31+
* Structural + cryptographic validation of a raw kind-0 event.
32+
* Rejects wrong kind, malformed fields, non-JSON content, a tampered `id`,
33+
* and any event whose schnorr signature doesn't verify against its pubkey.
34+
*/
35+
export function verifyEvent(e) {
36+
if (!e || e.kind !== KIND) return false;
37+
if (typeof e.pubkey !== 'string' || !HEX64.test(e.pubkey)) return false;
38+
if (!Number.isFinite(e.created_at)) return false;
39+
if (typeof e.sig !== 'string' || !HEX128.test(e.sig)) return false;
40+
// kind-0 content is a JSON object; empty string is allowed (treated as {}).
41+
if (e.content) { try { JSON.parse(e.content); } catch { return false; } }
42+
const id = eventId(e);
43+
if (e.id && e.id !== id) return false; // claimed id must match the content
44+
try { return schnorr.verify(hexToBytes(e.sig), hexToBytes(id), hexToBytes(e.pubkey)); }
45+
catch { return false; }
46+
}
47+
48+
export default {
49+
name: 'profiles',
50+
kinds: [KIND],
51+
52+
// Own the collection's indexes so a fresh deploy is fully functional:
53+
// pubkey (lookup/dedupe), created_at (recency), and the text index that
54+
// /api/search relies on. Tolerate a pre-existing index spec (codes 85/86).
55+
async ensureIndexes(db) {
56+
const col = db.collection(COLLECTIONS[KIND]);
57+
const ok = (e) => { if (e?.code !== 85 && e?.code !== 86) throw e; };
58+
await col.createIndex({ pubkey: 1 }).catch(ok);
59+
await col.createIndex({ created_at: -1 }).catch(ok);
60+
await col.createIndex({ content: 'text' }, { name: 'content_text' }).catch(ok);
61+
},
62+
63+
/** Verify, then latest-wins upsert of the raw event. Returns true if stored. */
64+
async ingest(event, db) {
65+
if (!verifyEvent(event)) return false;
66+
const col = db.collection(COLLECTIONS[KIND]);
67+
const existing = await col.findOne({ pubkey: event.pubkey }, { projection: { created_at: 1 } });
68+
if (existing && existing.created_at >= event.created_at) return false;
69+
await col.updateOne({ pubkey: event.pubkey }, { $set: { ...event } }, { upsert: true });
70+
return true;
71+
},
72+
};

src/indexer.js

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,17 @@
55
// Subscribes to kind 0 (profile), 3 (follows / social graph), 10002 (relay
66
// list) and upserts each raw event into Mongo.
77
import { upsertEvent, connect } from './db.js';
8+
import profilesHose from './hoses/profiles.js';
89
import 'dotenv/config';
910

1011
const RELAYS = (process.env.RELAYS || 'wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net')
1112
.split(',').map((s) => s.trim()).filter(Boolean);
1213

13-
const KINDS = [0, 3, 10002];
14+
// Registered hoses (phase 1: profiles only). Each owns its kinds + ingest.
15+
// Kinds with no hose fall back to the legacy raw upsert until their own phase.
16+
const HOSES = [profilesHose];
17+
const hoseFor = (kind) => HOSES.find((h) => h.kinds.includes(kind));
18+
const KINDS = [...new Set([...HOSES.flatMap((h) => h.kinds), 3, 10002])];
1419
const SUB_ID = 'beacon';
1520
const RECONNECT_MS = 3000;
1621

@@ -34,13 +39,16 @@ function connectRelay(url, onEvent) {
3439
}
3540

3641
export async function runIndexer() {
37-
await connect();
42+
const db = await connect();
43+
for (const h of HOSES) await h.ensureIndexes(db);
3844
console.log(`[beacon] indexing kinds ${KINDS.join(',')} from ${RELAYS.length} relays`);
3945
const onEvent = async (event) => {
4046
try {
41-
if (await upsertEvent(event)) console.log(`[beacon] kind ${event.kind} ${String(event.pubkey).slice(0, 12)}…`);
47+
const hose = hoseFor(event.kind);
48+
const stored = hose ? await hose.ingest(event, db) : await upsertEvent(event);
49+
if (stored) console.log(`[beacon] kind ${event.kind} ${String(event.pubkey).slice(0, 12)}…`);
4250
} catch (e) {
43-
console.error('[beacon] upsert error:', e.message);
51+
console.error('[beacon] ingest error:', e.message);
4452
}
4553
};
4654
const stoppers = RELAYS.map((url) => connectRelay(url, onEvent));

test/profiles-hose.test.js

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Profiles hose: validation + signature verification.
2+
// We sign real kind-0 events with a known key, then assert accept/reject.
3+
import test from 'node:test';
4+
import assert from 'node:assert/strict';
5+
import { schnorr } from '@noble/curves/secp256k1.js';
6+
import { sha256 } from '@noble/hashes/sha2.js';
7+
import { hexToBytes, bytesToHex } from '@noble/hashes/utils.js';
8+
import { verifyEvent } from '../src/hoses/profiles.js';
9+
10+
const enc = new TextEncoder();
11+
const PRIV = hexToBytes('0000000000000000000000000000000000000000000000000000000000000001');
12+
const PUBKEY = bytesToHex(schnorr.getPublicKey(PRIV));
13+
14+
// Build a properly-signed kind-0 event.
15+
function signed({ content = '{"name":"alice"}', created_at = 1000, kind = 0 } = {}) {
16+
const base = { pubkey: PUBKEY, created_at, kind, tags: [], content };
17+
const id = bytesToHex(sha256(enc.encode(JSON.stringify([0, base.pubkey, base.created_at, base.kind, base.tags, base.content]))));
18+
const sig = bytesToHex(schnorr.sign(hexToBytes(id), PRIV));
19+
return { ...base, id, sig };
20+
}
21+
22+
test('accepts a validly-signed kind-0 event', () => {
23+
assert.equal(verifyEvent(signed()), true);
24+
});
25+
26+
test('accepts empty content (treated as {})', () => {
27+
assert.equal(verifyEvent(signed({ content: '' })), true);
28+
});
29+
30+
test('rejects tampered content (sig no longer matches)', () => {
31+
const e = signed();
32+
e.content = '{"name":"mallory"}';
33+
assert.equal(verifyEvent(e), false);
34+
});
35+
36+
test('rejects a tampered id', () => {
37+
const e = signed();
38+
e.id = 'ff' + e.id.slice(2);
39+
assert.equal(verifyEvent(e), false);
40+
});
41+
42+
test('rejects a forged/garbage signature', () => {
43+
const e = signed();
44+
e.sig = 'f'.repeat(128);
45+
assert.equal(verifyEvent(e), false);
46+
});
47+
48+
test('rejects non-hex pubkey', () => {
49+
const e = signed();
50+
e.pubkey = 'npub1xxx';
51+
assert.equal(verifyEvent(e), false);
52+
});
53+
54+
test('rejects the wrong kind', () => {
55+
assert.equal(verifyEvent(signed({ kind: 3 })), false);
56+
});
57+
58+
test('rejects non-JSON content', () => {
59+
// Re-sign so the sig is valid but content is not JSON — must still reject.
60+
const e = signed({ content: 'not json{' });
61+
assert.equal(verifyEvent(e), false);
62+
});
63+
64+
test('rejects malformed input without throwing', () => {
65+
for (const bad of [null, undefined, {}, { kind: 0 }, 42, 'x']) {
66+
assert.equal(verifyEvent(bad), false);
67+
}
68+
});

0 commit comments

Comments
 (0)