nostr-over-bt 1.0.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,54 @@
1
+ # P2P Discovery Protocol: Nostr over BEP-44
2
+
3
+ This document outlines the architecture for fully decentralized event discovery using the BitTorrent DHT, removing the hard dependency on Relays for metadata retrieval.
4
+
5
+ ## Core Concept
6
+ Use **BEP-44 (Mutable Torrents)** to store a "Feed Pointer" in the DHT. This pointer acts as a mutable reference to the user's latest content tree (e.g., a Magnet URI for a "Feed" torrent).
7
+
8
+ ## Architecture
9
+
10
+ ### 1. Identity Mapping
11
+ To enable deterministic lookups (`Nostr Pubkey` -> `DHT Entry`), we need a strategy to map identities.
12
+
13
+ **Challenge:** Nostr uses Ed25519 keys with Schnorr signatures. BEP-44 uses Ed25519 keys with standard signatures (or specific DHT node logic). Reusing the exact same keypair across protocols is discouraged for security and compatibility reasons.
14
+
15
+ **Strategy: Associated Transport Key**
16
+ 1. **Generation:** The client generates a dedicated `Transport Keypair` (Ed25519) for DHT operations.
17
+ 2. **Attestation:** The user publishes a Nostr Event (e.g., Kind 10002 or Custom Kind) signed by their `Nostr Key`, containing the `Transport Public Key`.
18
+ * *Note:* This requires one initial Relay lookup to "bootstrap" the identity, OR the user can just use the Transport Key as a "known" alias shared out-of-band.
19
+ 3. **Deterministic Fallback (Optional):** If the client possesses the root seed, they can deterministically derive the `Transport Key` from the same seed using a different derivation path (e.g., `m/44'/.../1'`).
20
+
21
+ ### 2. The Mutable Record (BEP-44)
22
+ The DHT record stored at the `Transport Public Key` address contains:
23
+
24
+ * **`k` (key):** Transport Public Key (32 bytes).
25
+ * **`seq` (sequence):** Monotonically increasing integer (incremented on update).
26
+ * **`v` (value):** Bencoded dictionary containing:
27
+ * `ih` (infohash): SHA1 hash of the latest "Feed Torrent" (20 bytes).
28
+ * `ts` (timestamp): Unix timestamp of update.
29
+ * `ws` (webseeds): Optional list of HTTP fallbacks.
30
+ * **`sig` (signature):** Ed25519 signature of the payload using the Transport Private Key.
31
+
32
+ ### 3. The Feed Torrent
33
+ The `infohash` in the mutable record points to a **Feed Torrent**. This torrent is a lightweight "Index" file containing:
34
+ * A list of recent Event IDs.
35
+ * A list of Magnet URIs for those events (or they are included in this torrent if small).
36
+ * Merkle root of the user's history?
37
+
38
+ ### 4. Client Workflow
39
+ 1. **Resolve Identity:** User inputs `npub1...`. Client checks local DB or Relay for associated `Transport Key`.
40
+ 2. **DHT Lookup:** Client performs a `dht.get(transport_pubkey)`.
41
+ 3. **Resolve Feed:** DHT returns the mutable record with `latest_infohash`.
42
+ 4. **Join Swarm:** Client joins the swarm for `latest_infohash`.
43
+ 5. **Download Index:** Client downloads the small Index file.
44
+ 6. **Retrieve Events:** Client parses Index, identifies new events, and fetches them (via Swarm or Relay).
45
+
46
+ ## Advantages
47
+ * **Relay Resilience:** If relays go down, the "Head" of the user's stream is still resolvable via the DHT.
48
+ * **Censorship Resistance:** Updates propagate via the P2P layer; no single server can block the "update" of the feed pointer.
49
+ * **Bandwidth Efficiency:** Relays don't need to push every event; they just need to serve the Identity Bootstrap (which changes rarely).
50
+
51
+ ## Implementation Roadmap
52
+ 1. Add `bittorrent-dht` direct dependency (accessing `put/get` for mutable items).
53
+ 2. Implement `IdentityManager` to handle Transport Key generation/derivation.
54
+ 3. Implement `FeedManager` to manage the "Index Torrent" and update the DHT pointer.
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "nostr-over-bt",
3
+ "version": "1.0.0",
4
+ "description": "High-performance hybrid transport layer for Nostr using BitTorrent for heavy content and DHT for decentralized discovery.",
5
+ "main": "src/index.js",
6
+ "type": "module",
7
+ "scripts": {
8
+ "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
9
+ "lint": "eslint src/",
10
+ "bench": "node examples/relay-server/benchmark.js",
11
+ "bench:realistic": "node examples/relay-server/realistic-benchmark.js"
12
+ },
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/imattau/nostr-over-bt.git"
16
+ },
17
+ "keywords": [
18
+ "nostr",
19
+ "bittorrent",
20
+ "p2p",
21
+ "dht",
22
+ "decentralized",
23
+ "relay",
24
+ "swarm",
25
+ "webtorrent"
26
+ ],
27
+ "author": "imattau",
28
+ "license": "LGPL-2.1",
29
+ "bugs": {
30
+ "url": "https://github.com/imattau/nostr-over-bt/issues"
31
+ },
32
+ "homepage": "https://github.com/imattau/nostr-over-bt#readme",
33
+ "engines": {
34
+ "node": ">=18.0.0"
35
+ },
36
+ "files": [
37
+ "src",
38
+ "docs",
39
+ "README.md",
40
+ "LICENSE"
41
+ ],
42
+ "dependencies": {
43
+ "bittorrent-dht": "^11.0.11",
44
+ "bittorrent-tracker": "^11.2.2",
45
+ "ed25519-supercop": "^2.0.1",
46
+ "nostr-tools": "^2.22.1",
47
+ "webtorrent": "^2.8.5",
48
+ "ws": "^8.19.0"
49
+ },
50
+ "devDependencies": {
51
+ "@eslint/js": "^9.39.2",
52
+ "eslint": "^9.39.2",
53
+ "globals": "^17.3.0",
54
+ "jest": "^30.2.0"
55
+ }
56
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * EventPackager handles the conversion of Nostr events to/from
3
+ * BitTorrent-compatible data structures.
4
+ */
5
+ export class EventPackager {
6
+ /**
7
+ * Packages a Nostr event into a Buffer for seeding.
8
+ * @param {object} event - The Nostr event.
9
+ * @returns {Buffer} - The JSON buffer of the event.
10
+ */
11
+ package(event) {
12
+ if (!event || !event.id) {
13
+ throw new Error("Invalid Nostr event: missing ID.");
14
+ }
15
+ const data = JSON.stringify(event);
16
+ return Buffer.from(data);
17
+ }
18
+
19
+ /**
20
+ * Unpacks a buffer back into a Nostr event.
21
+ * @param {Buffer|string} data - The data from BitTorrent.
22
+ * @returns {object} - The Nostr event.
23
+ */
24
+ unpack(data) {
25
+ try {
26
+ const jsonString = data.toString();
27
+ const event = JSON.parse(jsonString);
28
+ if (!event.id || !event.sig) {
29
+ throw new Error("Invalid unpacked event: missing signature or ID.");
30
+ }
31
+ return event;
32
+ } catch (error) {
33
+ throw new Error(`Failed to unpack event: ${error.message}`);
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Generates a unique filename for the event.
39
+ * @param {object} event
40
+ * @returns {string}
41
+ */
42
+ getFilename(event) {
43
+ return `${event.id}.json`;
44
+ }
45
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * FeedIndex manages the list of events in a user's P2P feed.
3
+ * It handles the data structure serialized into the 'index.json' file.
4
+ */
5
+ export class FeedIndex {
6
+ /**
7
+ * @param {number} [limit=100] - Maximum number of events to keep in the index.
8
+ */
9
+ constructor(limit = 100) {
10
+ this.limit = limit;
11
+ this.items = []; // Array of { id, magnet, ts, kind }
12
+ this.updatedAt = 0;
13
+ }
14
+
15
+ /**
16
+ * Adds an event to the index.
17
+ * @param {object} event - The Nostr event.
18
+ * @param {string} magnetUri - The magnet URI for the event content.
19
+ */
20
+ add(event, magnetUri) {
21
+ // Prevent duplicates
22
+ if (this.items.some(i => i.id === event.id)) return;
23
+
24
+ const item = {
25
+ id: event.id,
26
+ magnet: magnetUri,
27
+ ts: event.created_at,
28
+ kind: event.kind
29
+ };
30
+
31
+ // Add to front
32
+ this.items.unshift(item);
33
+
34
+ // Sort by timestamp descending (just in case)
35
+ this.items.sort((a, b) => b.ts - a.ts);
36
+
37
+ // Trim to limit
38
+ if (this.items.length > this.limit) {
39
+ this.items = this.items.slice(0, this.limit);
40
+ }
41
+
42
+ this.updatedAt = Math.floor(Date.now() / 1000);
43
+ }
44
+
45
+ /**
46
+ * Serializes the index to a Buffer.
47
+ * @returns {Buffer}
48
+ */
49
+ toBuffer() {
50
+ const data = {
51
+ updated_at: this.updatedAt,
52
+ items: this.items
53
+ };
54
+ return Buffer.from(JSON.stringify(data));
55
+ }
56
+
57
+ /**
58
+ * Loads the index from a Buffer.
59
+ * @param {Buffer} buffer
60
+ */
61
+ loadFromBuffer(buffer) {
62
+ try {
63
+ const data = JSON.parse(buffer.toString());
64
+ if (Array.isArray(data.items)) {
65
+ this.items = data.items;
66
+ this.updatedAt = data.updated_at || 0;
67
+ }
68
+ } catch (error) {
69
+ console.warn("FeedIndex: Failed to load from buffer", error);
70
+ // Start fresh if corrupted
71
+ this.items = [];
72
+ }
73
+ }
74
+ }
@@ -0,0 +1,158 @@
1
+ import ed25519 from 'ed25519-supercop';
2
+ import crypto from 'crypto';
3
+ import { FeedIndex } from './FeedIndex.js';
4
+
5
+ /**
6
+ * FeedManager handles the P2P Discovery "Feed".
7
+ * It interacts with the DHT to publish/resolve mutable records (BEP-44).
8
+ */
9
+ export class FeedManager {
10
+ /**
11
+ * @param {BitTorrentTransport} btTransport
12
+ * @param {IdentityManager} identityManager
13
+ * @param {object} [options={}]
14
+ * @param {number} [options.initialSeq=1] - Initial sequence number if known.
15
+ * @param {number} [options.indexLimit=100] - Max items in the feed index.
16
+ */
17
+ constructor(btTransport, identityManager, options = {}) {
18
+ this.bt = btTransport;
19
+ this.identity = identityManager;
20
+ this.seq = options.initialSeq || 1;
21
+ this.index = new FeedIndex(options.indexLimit || 100);
22
+ }
23
+
24
+ /**
25
+ * Syncs the sequence number with the latest record on the DHT.
26
+ * Essential for maintaining persistence after a restart.
27
+ * @returns {Promise<number>} - The new current sequence number.
28
+ */
29
+ async syncSequence() {
30
+ try {
31
+ const pubkey = this.identity.getPublicKey();
32
+ const record = await this.resolveFeedPointer(pubkey);
33
+ if (record && record.seq !== undefined) {
34
+ this.seq = record.seq + 1;
35
+ console.log(`FeedManager: Synced sequence number from DHT: ${this.seq}`);
36
+ }
37
+ } catch (error) {
38
+ console.warn("FeedManager: Failed to sync sequence number, defaulting to current.", error.message);
39
+ }
40
+ return this.seq;
41
+ }
42
+
43
+ /**
44
+ * Updates the P2P feed with a new event.
45
+ * 1. Adds event to local FeedIndex.
46
+ * 2. Seeds the new index.json.
47
+ * 3. Updates the DHT pointer to the new index.
48
+ *
49
+ * @param {object} event - The Nostr event.
50
+ * @param {string} magnetUri - The magnet for the event content.
51
+ * @returns {Promise<string>} - The magnet URI of the updated Index.
52
+ */
53
+ async updateFeed(event, magnetUri) {
54
+ // 1. Update Index
55
+ this.index.add(event, magnetUri);
56
+ const buffer = this.index.toBuffer();
57
+
58
+ // 2. Seed Index
59
+ const indexMagnet = await this.bt.publish({ buffer, filename: 'index.json' });
60
+
61
+ // Extract InfoHash from magnet
62
+ const match = indexMagnet.match(/xt=urn:btih:([a-zA-Z0-9]+)/);
63
+ if (!match) throw new Error(`Invalid magnet URI from transport: ${indexMagnet}`);
64
+ const infoHash = match[1];
65
+
66
+ // 3. Validate InfoHash (should be 40 char hex for v1)
67
+ if (!/^[a-fA-F0-9]{40}$/.test(infoHash)) {
68
+ throw new Error(`Invalid InfoHash extracted: ${infoHash}. Expected 40-character hex.`);
69
+ }
70
+
71
+ // 4. Update DHT Pointer
72
+ await this.publishFeedPointer(infoHash);
73
+
74
+ return indexMagnet;
75
+ }
76
+
77
+ /**
78
+ * Publishes a pointer to the given InfoHash (the "Feed Torrent").
79
+ * @param {string} infoHash - The InfoHash of the feed index torrent (hex string).
80
+ * @returns {Promise<string>} - The public key (address) of the record.
81
+ */
82
+ async publishFeedPointer(infoHash, retries = 3) {
83
+ if (!/^[a-fA-F0-9]{40}$/.test(infoHash)) {
84
+ throw new Error(`Invalid InfoHash: ${infoHash}. Expected 40-character hex.`);
85
+ }
86
+
87
+ const dht = this.bt.getDHT();
88
+ if (!dht) throw new Error("DHT not available. Ensure WebTorrent client is ready.");
89
+
90
+ const keypair = this.identity.getKeypair();
91
+
92
+ const attempt = (remaining) => {
93
+ return new Promise((resolve, reject) => {
94
+ const opts = {
95
+ k: keypair.publicKey,
96
+ seq: this.seq++,
97
+ v: {
98
+ ih: Buffer.from(infoHash, 'hex'),
99
+ ts: Math.floor(Date.now() / 1000),
100
+ npk: this.identity.nostrPubkey ? Buffer.from(this.identity.nostrPubkey, 'hex') : undefined
101
+ },
102
+ sign: (buf) => {
103
+ if (!Buffer.isBuffer(buf)) buf = Buffer.from(buf);
104
+ return ed25519.sign(buf, keypair.publicKey, keypair.secretKey);
105
+ }
106
+ };
107
+
108
+ dht.put(opts, (err, hash) => {
109
+ if (err) {
110
+ if (remaining > 0) {
111
+ console.warn(`FeedManager: DHT PUT failed, retrying... (${remaining} left)`);
112
+ setTimeout(() => resolve(attempt(remaining - 1)), 2000);
113
+ } else {
114
+ reject(err);
115
+ }
116
+ } else {
117
+ console.log(`FeedManager: Updated DHT Pointer. Hash: ${hash.toString('hex')}`);
118
+ resolve(keypair.publicKey.toString('hex'));
119
+ }
120
+ });
121
+ });
122
+ };
123
+
124
+ return await attempt(retries);
125
+ }
126
+
127
+ /**
128
+ * Resolves a feed pointer from the DHT.
129
+ * @param {string} transportPubkey - The Transport Public Key (hex).
130
+ * @returns {Promise<object>} - { infoHash, timestamp }
131
+ */
132
+ async resolveFeedPointer(transportPubkey) {
133
+ const dht = this.bt.getDHT();
134
+ if (!dht) throw new Error("DHT not available.");
135
+
136
+ return new Promise((resolve, reject) => {
137
+ const publicKeyBuffer = Buffer.from(transportPubkey, 'hex');
138
+
139
+ // Calculate BEP-44 Target: SHA1(k)
140
+ const target = crypto.createHash('sha1').update(publicKeyBuffer).digest();
141
+
142
+ dht.get(target, (err, res) => {
143
+ if (err) return reject(err);
144
+ if (!res || !res.v) return resolve(null); // Not found
145
+
146
+ try {
147
+ const infoHash = res.v.ih.toString('hex');
148
+ const ts = res.v.ts;
149
+ const seq = res.seq;
150
+ const nostrPubkey = res.v.npk ? res.v.npk.toString('hex') : null;
151
+ resolve({ infoHash, ts, seq, nostrPubkey });
152
+ } catch {
153
+ reject(new Error("Invalid record format"));
154
+ }
155
+ });
156
+ });
157
+ }
158
+ }
@@ -0,0 +1,153 @@
1
+ import ed25519 from 'ed25519-supercop';
2
+ import crypto from 'crypto';
3
+
4
+ /**
5
+ * Manages the Identity for P2P Discovery.
6
+ * Handles the "Transport Keypair" (Ed25519) used for BEP-44 DHT records.
7
+ */
8
+ export class IdentityManager {
9
+ /**
10
+ * @param {Buffer|string} [secretKey] - Optional existing 32-byte seed (hex or buffer).
11
+ * @param {string} [nostrPubkey] - Optional associated Nostr public key.
12
+ */
13
+ constructor(secretKey = null, nostrPubkey = null) {
14
+ this.keypair = null;
15
+ this.seed = null;
16
+ this.nostrPubkey = nostrPubkey;
17
+ if (secretKey) {
18
+ this.load(secretKey);
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Derives a P2P Identity deterministically from a Nostr Secret Key.
24
+ * @param {string} nostrSecretKey - 32-byte hex.
25
+ * @returns {IdentityManager}
26
+ */
27
+ static fromNostrSecretKey(nostrSecretKey) {
28
+ // Use a derivation to avoid key reuse if desired,
29
+ // or just use the key as a seed.
30
+ // We will use the key directly as a seed for Ed25519.
31
+ return new IdentityManager(nostrSecretKey);
32
+ }
33
+
34
+ /**
35
+ * Generates a new Transport Keypair.
36
+ */
37
+ generate() {
38
+ this.seed = crypto.randomBytes(32);
39
+ this.keypair = ed25519.createKeyPair(this.seed);
40
+ console.log("IdentityManager: Generated new Transport Keypair.");
41
+ }
42
+
43
+ /**
44
+ * Loads an identity from a 32-byte seed.
45
+ * @param {Buffer|string} secretKey
46
+ */
47
+ load(secretKey) {
48
+ this.seed = typeof secretKey === 'string' ? Buffer.from(secretKey, 'hex') : secretKey;
49
+ if (this.seed.length !== 32) {
50
+ // If it's 64 bytes, it might be the full secretKey from supercop.
51
+ // We take the first 32 as seed.
52
+ this.seed = this.seed.slice(0, 32);
53
+ }
54
+ this.keypair = ed25519.createKeyPair(this.seed);
55
+ console.log("IdentityManager: Loaded existing Identity.");
56
+ }
57
+
58
+ /**
59
+ * Returns the 32-byte Transport Secret Key (hex).
60
+ * @returns {string}
61
+ */
62
+ getSecretKey() {
63
+ if (!this.seed) throw new Error("No identity generated.");
64
+ return this.seed.toString('hex');
65
+ }
66
+
67
+ /**
68
+ * Given a Nostr public key (32-byte hex), returns the two possible
69
+ * DHT addresses (BEP-44 public keys) by trying both Y-parities.
70
+ *
71
+ * @param {string} nostrPubkey - 32-byte hex string.
72
+ * @returns {Array<string>} - Two 32-byte hex strings.
73
+ */
74
+ static getNostrDHTAddresses(nostrPubkey) {
75
+ const xBuf = Buffer.from(nostrPubkey, 'hex');
76
+ if (xBuf.length !== 32) throw new Error("Invalid Nostr pubkey length.");
77
+
78
+ // In Ed25519 compressed format (RFC 8032):
79
+ // The 255 bits are the Y-coordinate. The 256th bit (last bit of last byte) is the X-parity.
80
+ // WAIT: Nostr uses BIP-340 (X-only). Ed25519 compressed uses Y-only + X-parity.
81
+ // To convert X to Y, we need the curve equation.
82
+ //
83
+ // SIMPLER P2P CONVENTION:
84
+ // Instead of curve math, we define that for this protocol:
85
+ // The DHT Key 'k' is simply the 32-byte Nostr Pubkey,
86
+ // but since bittorrent-dht/ed25519-supercop expect a valid Ed25519 keypair,
87
+ // the user MUST generate a valid Ed25519 keypair where the SEED is their
88
+ // Nostr Secret Key.
89
+ //
90
+ // As established in my previous analysis:
91
+ // Nostr_Pub != Ed25519_Pub(Nostr_Seed).
92
+ //
93
+ // RESOLUTION:
94
+ // We will stick to the "Attestation" model for identity mapping,
95
+ // BUT we will also support a "P2P Attestation" where the mapping is
96
+ // stored in a WELL-KNOWN DHT address derived from the Nostr Pubkey.
97
+ //
98
+ // Well-known Address = SHA1("nostr-identity:" + nostrPubkey).
99
+ // This is a standard BEP-44 mutable record that ANYONE can lookup,
100
+ // but ONLY the owner of the Nostr key can sign.
101
+ //
102
+ // Wait, BEP-44 requires the signature to match 'k'.
103
+ // If 'k' is the Nostr key (converted to Ed25519), we are back to curve math.
104
+
105
+ // PRAGMATIC P2P BOOTSTRAP:
106
+ // We use a "Shared Namespace" approach for the mapping.
107
+ // 1. Shared Namespace Key (fixed, public).
108
+ // 2. Salt = Nostr Pubkey.
109
+ // 3. Anyone can read. Only people with the Nostr Key can... wait.
110
+ //
111
+ // If the DHT verifies the signature against the Shared Namespace Key,
112
+ // then anyone with that key can overwrite the mapping.
113
+
114
+ // FINAL DECISION:
115
+ // To achieve 100% P2P follow-list discovery:
116
+ // A user's "Follow List" IS their P2P Feed Index.
117
+ // The client gets the Transport Key from the user (shared once).
118
+ // OR the user uses their Nostr Secret Key as the DHT seed.
119
+ // We will provide a utility to derive the DHT public key from the
120
+ // Nostr secret key so the user knows their own "P2P Address".
121
+
122
+ return []; // Placeholder for now
123
+ }
124
+ getPublicKey() {
125
+ if (!this.keypair) throw new Error("No identity generated.");
126
+ return this.keypair.publicKey.toString('hex');
127
+ }
128
+
129
+ /**
130
+ * Returns the full keypair object.
131
+ * @returns {object} { publicKey, secretKey } (Buffers)
132
+ */
133
+ getKeypair() {
134
+ if (!this.keypair) throw new Error("No identity generated.");
135
+ return this.keypair;
136
+ }
137
+
138
+ /**
139
+ * Creates a Nostr event linking the Nostr Pubkey to this Transport Key.
140
+ * (Kind 10002 or Custom)
141
+ * @param {string} nostrPubkey
142
+ * @returns {object} Unsigned Event
143
+ */
144
+ createAttestation(nostrPubkey) {
145
+ return {
146
+ kind: 30078, // Arbitrary custom kind for now, or use specific NIP kind
147
+ created_at: Math.floor(Date.now() / 1000),
148
+ tags: [['d', 'nostr-over-bt-identity']],
149
+ content: this.getPublicKey(),
150
+ pubkey: nostrPubkey
151
+ };
152
+ }
153
+ }