pi-lxmf 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,93 @@
1
+ /**
2
+ * @file identity.js
3
+ *
4
+ * Pure identity-hash helpers (node:crypto only, no @reticulum import, so the
5
+ * bridge and its tests stay light).
6
+ *
7
+ * The daemon identifies people by their **Reticulum identity hash** — the
8
+ * protocol-agnostic 32-hex identifier of an Ed25519 identity — rather than by
9
+ * any single destination hash. One identity can host many destinations
10
+ * (`lxmf.delivery`, `lxmf.propagation`, `nomadnetwork.node`, …), and
11
+ * identity-keyed access maps directly onto future DACAR-style permission
12
+ * management (../dacar grants are made to identities).
13
+ *
14
+ * On the wire, LXMF source/destination hashes are `lxmf.delivery` *destination*
15
+ * hashes, so a configured identity hash is expanded via
16
+ * {@link deriveLxmfDestinationHash} for comparison — the same derivation
17
+ * @reticulum/core's `Destination` performs (`SHA256(nameHash ‖ identityHash)[:16]`,
18
+ * `nameHash = SHA256(full_name)[:10]`), proven in `test/identity.test.js`
19
+ * against the real `Destination.IN`.
20
+ */
21
+
22
+ import { createHash } from "node:crypto";
23
+
24
+ /** Truncated hash length in bytes (SHA-256[:16]). */
25
+ const HASH_BYTES = 16;
26
+
27
+ /** The LXMF single-endpoint full name (app name + aspect). */
28
+ const LXMF_DELIVERY_APP_NAME = "lxmf.delivery";
29
+
30
+ /**
31
+ * Decodes a 32-hex-char hash string to bytes.
32
+ *
33
+ * @param {string} hex
34
+ * @returns {Uint8Array}
35
+ * @throws {Error} on wrong length or non-hex input.
36
+ */
37
+ function hashFromHex(hex) {
38
+ const cleaned = hex.trim().toLowerCase();
39
+ if (!/^[0-9a-f]{32}$/.test(cleaned)) {
40
+ throw new Error(
41
+ `Expected a 32-hex-character hash, got: ${JSON.stringify(hex)}`,
42
+ );
43
+ }
44
+ return new Uint8Array(
45
+ Array.from({ length: 16 }, (_, i) =>
46
+ Number.parseInt(cleaned.slice(i * 2, i * 2 + 2), 16),
47
+ ),
48
+ );
49
+ }
50
+
51
+ /**
52
+ * Encodes bytes as lowercase hex.
53
+ *
54
+ * @param {Uint8Array} bytes
55
+ * @returns {string}
56
+ */
57
+ function hashToHex(bytes) {
58
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
59
+ }
60
+
61
+ /**
62
+ * Derives a destination hash for an identity and application name, matching
63
+ * @reticulum/core's `Destination`: `SHA256(nameHash ‖ identityHash)[:16]`
64
+ * where `nameHash = SHA256(full_name)[:10]`.
65
+ *
66
+ * @param {string} identityHashHex - The identity's 32-hex hash.
67
+ * @param {string} appName - Full destination name (e.g. `"lxmf.delivery"`).
68
+ * @returns {string} 32-hex destination hash.
69
+ */
70
+ export function deriveDestinationHash(identityHashHex, appName) {
71
+ const identityHash = hashFromHex(identityHashHex);
72
+ const nameHash = createHash("sha256")
73
+ .update(appName, "utf8")
74
+ .digest()
75
+ .subarray(0, 10);
76
+ const combined = new Uint8Array(nameHash.length + identityHash.length);
77
+ combined.set(nameHash, 0);
78
+ combined.set(identityHash, nameHash.length);
79
+ const digest = createHash("sha256").update(combined).digest();
80
+ return hashToHex(digest.subarray(0, HASH_BYTES));
81
+ }
82
+
83
+ /**
84
+ * Derives the `lxmf.delivery` destination hash (the "LXMF address" clients
85
+ * display) for an identity hash — the wire form an identity-configured owner
86
+ * is compared against.
87
+ *
88
+ * @param {string} identityHashHex
89
+ * @returns {string} 32-hex `lxmf.delivery` destination hash.
90
+ */
91
+ export function deriveLxmfDestinationHash(identityHashHex) {
92
+ return deriveDestinationHash(identityHashHex, LXMF_DELIVERY_APP_NAME);
93
+ }
package/src/lxmf.js ADDED
@@ -0,0 +1,380 @@
1
+ /**
2
+ * @file lxmf.js
3
+ *
4
+ * The LXMF/mesh side of the bridge (SPEC §5): persistent identity,
5
+ * interface bootstrap (shared instance → AutoInterface → TCP), the
6
+ * `LXMRouter` with periodic announcing, optional propagation-node sync,
7
+ * and chunked outbound message delivery.
8
+ *
9
+ * The wiring mirrors reticulum-js's `examples/lxmf_echobot.js`; see
10
+ * `../reticulum-js/packages/lxmf/src/router.js` for the router API.
11
+ */
12
+
13
+ import { join } from "node:path";
14
+ import { fromHex, Identity, Reticulum, toHex } from "@reticulum/core";
15
+ import { LXMessage, LXMFConstants, LXMRouter } from "@reticulum/lxmf";
16
+ import {
17
+ AutoInterface,
18
+ FileStorageAdapter,
19
+ LocalClientInterface,
20
+ TCPClientInterface,
21
+ } from "@reticulum/node";
22
+ import { createBz2 } from "./bz2.js";
23
+ import { chunkText } from "./text.js";
24
+
25
+ /**
26
+ * Attaches diagnostic logging to the inbound LXMF choke points that the
27
+ * bridge itself can't see: packets that decrypt but never dispatch.
28
+ *
29
+ * The `lxmf.delivery` destination emits a `"data"` event the instant a
30
+ * single-packet message decrypts — before the router has resolved the
31
+ * sender's identity. When the identity is UNKNOWN the router parks the
32
+ * message and solicits a path/announce, and it is only dispatched once
33
+ * that announce arrives. This is the most common reason a sender sees
34
+ * its packet acknowledged but the bridge never logs receiving anything,
35
+ * so we surface it here (along with unparseable packets) rather than at
36
+ * the bridge, which only ever sees successfully dispatched messages.
37
+ *
38
+ * Normal, successfully-dispatched packets are intentionally silent here —
39
+ * the bridge logs their disposition (ignored / command / prompt) where it
40
+ * actually decides what to do with them.
41
+ *
42
+ * The router's `"peer"` event fires when an announce (or inbound-link
43
+ * LINKIDENTIFY) makes an identity available, so a parked message can be
44
+ * correlated with the announce that released it.
45
+ *
46
+ * Ported from signalk-reticulum's `attachInboundDiagnostics`, where this
47
+ * instrumentation proved out the identity-parking failure mode.
48
+ *
49
+ * @param {LXMRouter} lxmf - An initialised router.
50
+ * @param {(msg: string) => void} [log] - Diagnostic sink.
51
+ * @param {string} [ownerIdentityHash] - 32-hex identity hash of the
52
+ * configured owner; when set, only peer-learn events whose announce
53
+ * carries this identity are logged (other peers are routine mesh
54
+ * bookkeeping).
55
+ * @returns {() => void} unsubscribe
56
+ */
57
+ export function attachInboundDiagnostics(
58
+ lxmf,
59
+ log = () => {},
60
+ ownerIdentityHash,
61
+ ) {
62
+ const onData = async (/** @type {any} */ event) => {
63
+ const plaintext = event?.detail?.plaintext;
64
+ if (!plaintext) return;
65
+ try {
66
+ const parsed = await LXMessage.deserialize(
67
+ plaintext,
68
+ lxmf.deliveryDest?.destinationHash ?? undefined,
69
+ );
70
+ const known = await lxmf.rns.transport.recallIdentity(parsed.sourceHash);
71
+ if (!known) {
72
+ log(
73
+ `pi-lxmf: inbound packet from ${toHex(parsed.sourceHash || [])} ` +
74
+ `(${plaintext.length} bytes) parked — sender identity unknown, ` +
75
+ `waiting for announce/path`,
76
+ );
77
+ }
78
+ } catch (e) {
79
+ log(
80
+ `pi-lxmf: inbound packet (${plaintext.length} bytes) could not be ` +
81
+ `parsed: ${e instanceof Error ? e.message : e}`,
82
+ );
83
+ }
84
+ };
85
+ /** @type {any} */ (lxmf.deliveryDest).addEventListener("data", onData);
86
+
87
+ const onPeer = (/** @type {any} */ event) => {
88
+ const destinationHash = event?.detail?.destinationHash;
89
+ const identity = event?.detail?.identity;
90
+ const identityHash = identity?.identityHash;
91
+ if (
92
+ destinationHash &&
93
+ (!ownerIdentityHash ||
94
+ (identityHash &&
95
+ toHex(identityHash) === ownerIdentityHash.toLowerCase()))
96
+ ) {
97
+ log(`pi-lxmf: learned LXMF peer ${toHex(destinationHash)}`);
98
+ }
99
+ };
100
+ lxmf.addEventListener("peer", onPeer);
101
+
102
+ return () => {
103
+ try {
104
+ /** @type {any} */ (lxmf.deliveryDest)?.removeEventListener(
105
+ "data",
106
+ onData,
107
+ );
108
+ } catch {
109
+ /* best effort */
110
+ }
111
+ try {
112
+ lxmf.removeEventListener("peer", onPeer);
113
+ } catch {
114
+ /* best effort */
115
+ }
116
+ };
117
+ }
118
+
119
+ /**
120
+ * Starts the mesh side. Resolves once the LXMF delivery destination is
121
+ * registered and announcing has begun.
122
+ *
123
+ * @param {import("./config.js").PiLxmfConfig} config - Resolved configuration (`loadConfig` output).
124
+ * @param {object} [options]
125
+ * @param {(msg: string) => void} [options.log] - Diagnostic sink.
126
+ * @returns {Promise<{
127
+ * rns: Reticulum,
128
+ * lxmf: LXMRouter,
129
+ * identity: Identity,
130
+ * identityHash: string,
131
+ * deliveryHash: string,
132
+ * interfaceNames: string[],
133
+ * sendText: (destinationHex: string, text: string, options?: {link?: any, title?: string}) => Promise<void>,
134
+ * sendReaction: (destinationHex: string, targetMessageId: Uint8Array, emoji: string, options?: {link?: any}) => Promise<void>,
135
+ * verifySender: (message: LXMessage) => Promise<"verified"|"unknown"|"invalid">,
136
+ * stop: () => void
137
+ * }>}
138
+ */
139
+ export async function startLxmf(config, options = {}) {
140
+ const log = options.log || ((msg) => console.log(msg));
141
+
142
+ // rngit-style Resources (and oversized LXMF bodies) compress with bz2;
143
+ // without a provider they transfer uncompressed over slow links.
144
+ const bz2 = await createBz2();
145
+
146
+ const storageDir = join(config.dataDir, "storage");
147
+ const rns = new Reticulum({
148
+ storageAdapter: new FileStorageAdapter(storageDir),
149
+ compressionProvider: bz2,
150
+ });
151
+
152
+ /** @type {string[]} */
153
+ const interfaceNames = [];
154
+ // Attaching to a local rnsd shared instance only works for 0-hop local
155
+ // traffic in some setups: a shared rnsd that does not forward routed
156
+ // (multi-hop) traffic to its local clients (observed Termux↔Columba,
157
+ // where the daemon's announces reach the mesh but inbound link requests
158
+ // die at the rnsd) silently blackholes every remote message.
159
+ // `skipSharedInstance` opts out in favour of own interfaces.
160
+ const shared = config.skipSharedInstance
161
+ ? null
162
+ : await LocalClientInterface.connectToSharedInstance();
163
+ if (shared) {
164
+ // The generated @reticulum types hold two path identities for Interface
165
+ // (src/ vs types/), which strict tsc rejects; the runtime types match.
166
+ rns.addInterface(/** @type {any} */ (shared), true);
167
+ interfaceNames.push("shared-instance");
168
+ log("pi-lxmf: attached to local rnsd shared instance");
169
+ } else {
170
+ const auto = new AutoInterface({ name: "auto" });
171
+ await /** @type {any} */ (auto).connect();
172
+ rns.addInterface(/** @type {any} */ (auto), true);
173
+ interfaceNames.push("auto");
174
+ if (config.rnsHost && config.rnsPort) {
175
+ const tcp = new TCPClientInterface({
176
+ host: config.rnsHost,
177
+ port: config.rnsPort,
178
+ });
179
+ await /** @type {any} */ (tcp).connect();
180
+ rns.addInterface(/** @type {any} */ (tcp), true);
181
+ interfaceNames.push(`tcp:${config.rnsHost}:${config.rnsPort}`);
182
+ }
183
+ }
184
+
185
+ // The node's stable LXMF identity: loaded from (or created in) the
186
+ // persistent storage directory. This hash IS the node's address.
187
+ const identity = await Identity.loadOrGenerate(rns.storage);
188
+ const identityHash = toHex(identity.identityHash);
189
+ log(`pi-lxmf: identity ${identityHash}`);
190
+
191
+ const lxmf = new LXMRouter(identity, rns);
192
+ await lxmf.init();
193
+ // init() registers the delivery destination; narrow the nullable types
194
+ // and capture a non-null alias usable from the sendText closure.
195
+ const registered = lxmf.deliveryDest;
196
+ if (!registered) {
197
+ throw new Error("LXMRouter.init() did not register lxmf.delivery");
198
+ }
199
+ const deliveryDest = registered;
200
+ const deliveryHash = toHex(
201
+ /** @type {Uint8Array} */ (deliveryDest.destinationHash),
202
+ );
203
+ log(`pi-lxmf: lxmf.delivery destination ${deliveryHash}`);
204
+
205
+ // Instrument the inbound path so a silently-parked or failing message is
206
+ // visible in the daemon log (see attachInboundDiagnostics).
207
+ const detachDiagnostics = attachInboundDiagnostics(lxmf, log, config.owner);
208
+
209
+ // Immediate announce + periodic re-announce so cached mesh paths stay
210
+ // fresh and peers (Sideband/Nomadnet) show our display name.
211
+ await lxmf.startAnnouncing(config.name, {
212
+ intervalMs: config.announceIntervalSec
213
+ ? config.announceIntervalSec * 1000
214
+ : undefined,
215
+ });
216
+ log(`pi-lxmf: announcing as "${config.name}"`);
217
+
218
+ // Optional propagation-node integration: outbound submits go through the
219
+ // node when a direct link cannot be established, and a periodic sync
220
+ // pulls messages that arrived while this daemon was down.
221
+ if (config.propagationNode) {
222
+ lxmf.setOutboundPropagationNode(fromHex(config.propagationNode));
223
+ log(`pi-lxmf: outbound propagation node ${config.propagationNode}`);
224
+ }
225
+ /** @type {NodeJS.Timeout|null} */
226
+ let syncTimer = null;
227
+ if (config.propagationNode && config.syncIntervalSec > 0) {
228
+ syncTimer = setInterval(() => {
229
+ lxmf
230
+ .syncFromPropagationNode(identity)
231
+ .then((/** @type {{received?: number}} */ res) => {
232
+ if ((res?.received ?? 0) > 0) {
233
+ log(
234
+ `pi-lxmf: propagation sync delivered ${res.received} message(s)`,
235
+ );
236
+ }
237
+ })
238
+ .catch((/** @type {Error} */ e) => {
239
+ log(`pi-lxmf: propagation sync failed: ${e.message}`);
240
+ });
241
+ }, config.syncIntervalSec * 1000);
242
+ syncTimer.unref();
243
+ log(`pi-lxmf: propagation sync every ${config.syncIntervalSec}s`);
244
+ }
245
+
246
+ /**
247
+ * Sends `text` to `destinationHex` (a 32-hex lxmf.delivery source hash),
248
+ * chunked to `chunkChars`, titled on the first chunk. A failed send is
249
+ * retried once over the same path, then once more opportunistically
250
+ * (without the link) — battery-conscious mobile clients tear their link
251
+ * down right after their message is acknowledged, so the arrival link can
252
+ * be gone by reply time; the same `LXMessage` object is re-sent so both
253
+ * wire copies share one message id and a deduplicating client shows the
254
+ * reply once (learned in signalk-reticulum's deliverer).
255
+ *
256
+ * @param {string} destinationHex
257
+ * @param {string} text
258
+ * @param {{link?: any, title?: string}} [sendOptions]
259
+ */
260
+ async function sendText(destinationHex, text, sendOptions = {}) {
261
+ const chunks = chunkText(text, config.chunkChars);
262
+ for (let i = 0; i < chunks.length; i++) {
263
+ const isLast = i === chunks.length - 1;
264
+ const content =
265
+ chunks.length > 1 && !isLast
266
+ ? `${chunks[i]}\n\n[… ${i + 1}/${chunks.length}]`
267
+ : chunks[i];
268
+ const message = new LXMessage({
269
+ // destinationHash is always set once init() registered the destination.
270
+ sourceHash: /** @type {Uint8Array} */ (deliveryDest.destinationHash),
271
+ destinationHash: fromHex(destinationHex),
272
+ content,
273
+ ...(i === 0 && sendOptions.title ? { title: sendOptions.title } : {}),
274
+ });
275
+ await sendWithRetry(message, sendOptions.link);
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Sends an LXMF reaction (FIELD_REACTION, §5.9.8) to `destinationHex`,
281
+ * targeting the message whose `messageId` is `targetMessageId`. The
282
+ * reaction field is rendered natively by Sideband/NomadNet (confirmed in
283
+ * live testing); no `content` is set so no separate chat bubble is
284
+ * produced alongside the reaction. Reuses `sendWithRetry` for the same
285
+ * retry-once path as `sendText` (same message object across retries → one
286
+ * message id).
287
+ *
288
+ * @param {string} destinationHex
289
+ * @param {Uint8Array} targetMessageId - The `message_id` of the message being reacted to.
290
+ * @param {string} emoji
291
+ * @param {{link?: any}} [sendOptions]
292
+ */
293
+ async function sendReaction(
294
+ destinationHex,
295
+ targetMessageId,
296
+ emoji,
297
+ sendOptions = {},
298
+ ) {
299
+ const reaction = new Map();
300
+ reaction.set(LXMFConstants.REACTION_TO, targetMessageId);
301
+ reaction.set(
302
+ LXMFConstants.REACTION_CONTENT,
303
+ new TextEncoder().encode(emoji),
304
+ );
305
+ const fields = new Map();
306
+ fields.set(LXMFConstants.FIELD_REACTION, reaction);
307
+ const message = new LXMessage({
308
+ sourceHash: /** @type {Uint8Array} */ (deliveryDest.destinationHash),
309
+ destinationHash: fromHex(destinationHex),
310
+ fields,
311
+ });
312
+ await sendWithRetry(message, sendOptions.link);
313
+ }
314
+
315
+ /**
316
+ * @param {LXMessage} message
317
+ * @param {any} [link]
318
+ */
319
+ async function sendWithRetry(message, link) {
320
+ try {
321
+ await lxmf.send(message, identity, link);
322
+ } catch (e) {
323
+ log(
324
+ `pi-lxmf: LXMF send failed (${e instanceof Error ? e.message : e}), retrying once`,
325
+ );
326
+ try {
327
+ await lxmf.send(message, identity, link);
328
+ } catch (e2) {
329
+ // The arrival link is likely gone (the peer closed it after its
330
+ // message was acknowledged). Retry without it: `LXMRouter.send`
331
+ // then establishes a fresh DIRECT link, falling back to an
332
+ // opportunistic packet. Same message object → same message id, so
333
+ // a deduplicating client renders the reply once.
334
+ log(
335
+ `pi-lxmf: link retry failed (${e2 instanceof Error ? e2.message : e2}), retrying without link`,
336
+ );
337
+ await lxmf.send(message, identity, null);
338
+ }
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Verifies the signature of an inbound `message` against the sender's
344
+ * recalled identity. The router verifies signatures on the direct-delivery
345
+ * path, but a message pulled in via `syncFromPropagationNode` whose sender
346
+ * identity is not yet recalled is dispatched WITHOUT verification
347
+ * (mirroring Python's `SOURCE_UNKNOWN` handling). The bridge must not rely
348
+ * on the router for this on the sync path, so it calls here to close the
349
+ * gap: a message is admitted only when this returns `"verified"`.
350
+ *
351
+ * @param {LXMessage} message
352
+ * @returns {Promise<"verified"|"unknown"|"invalid">}
353
+ * `"verified"` — signature checks against the recalled sender identity.
354
+ * `"unknown"` — sender identity not recalled (parked); admission would
355
+ * be unverified, so the caller should drop and log.
356
+ * `"invalid"` — signature failed cryptographic proof.
357
+ */
358
+ async function verifySender(message) {
359
+ const sender = await lxmf.rns.transport.recallIdentity(message.sourceHash);
360
+ if (!sender) return "unknown";
361
+ return (await message.verifySignature(sender)) ? "verified" : "invalid";
362
+ }
363
+
364
+ return {
365
+ rns,
366
+ lxmf,
367
+ identity,
368
+ identityHash,
369
+ deliveryHash,
370
+ interfaceNames,
371
+ sendText,
372
+ sendReaction,
373
+ verifySender,
374
+ stop() {
375
+ detachDiagnostics();
376
+ lxmf.stopAnnouncing();
377
+ if (syncTimer) clearInterval(syncTimer);
378
+ },
379
+ };
380
+ }