@uuaid/pillar 0.2.0-alpha.6

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,109 @@
1
+ /**
2
+ * Transport — libp2p node that speaks the UUAID pillar protocol.
3
+ *
4
+ * Design:
5
+ * - The pillar's Ed25519 identity IS the libp2p PeerId (same private key).
6
+ * One identity, one keypair, one signature primitive across every layer.
7
+ * - Kad-DHT is used for two things:
8
+ * 1. Peer routing (find PeerId -> multiaddrs)
9
+ * 2. Content routing (publish a UUAID -> hash mapping so anyone can
10
+ * resolve a UUAID to a live PeerId without a central directory)
11
+ * - Custom protocol /uuaid/pillar/1.0.0: framed newline-delimited JSON
12
+ * envelopes. Noise encryption is handled by libp2p; the envelope
13
+ * signature is our IAASO-1201 attestation.
14
+ *
15
+ * Two roles:
16
+ * - Peer : ordinary agent. Dials others; listens for direct dials.
17
+ * - Relay : a Peer that ALSO runs circuitRelayServer, letting NATed
18
+ * peers use it as a Circuit Relay v2 rendezvous. Optional
19
+ * opt-in — every Peer can be a Relay; not every Peer needs to be.
20
+ */
21
+ import { createLibp2p } from "libp2p";
22
+ import { tcp } from "@libp2p/tcp";
23
+ import { webSockets } from "@libp2p/websockets";
24
+ import { noise } from "@chainsafe/libp2p-noise";
25
+ import { yamux } from "@chainsafe/libp2p-yamux";
26
+ import { identify, identifyPush } from "@libp2p/identify";
27
+ import { kadDHT, removePrivateAddressesMapper, passthroughMapper } from "@libp2p/kad-dht";
28
+ import { bootstrap } from "@libp2p/bootstrap";
29
+ import { circuitRelayTransport, circuitRelayServer } from "@libp2p/circuit-relay-v2";
30
+ import { dcutr } from "@libp2p/dcutr";
31
+ import { autoNAT } from "@libp2p/autonat";
32
+ import { ping } from "@libp2p/ping";
33
+ import { privateKeyFromRaw, publicKeyFromRaw } from "@libp2p/crypto/keys";
34
+ import { peerIdFromPublicKey } from "@libp2p/peer-id";
35
+ import { createHash } from "node:crypto";
36
+
37
+ export const PILLAR_PROTOCOL = "/uuaid/pillar/1.0.0";
38
+ export const DHT_PROTOCOL_PREFIX = "/uuaid";
39
+
40
+ /**
41
+ * Build a libp2p node bound to the pillar's Ed25519 identity.
42
+ *
43
+ * @param {{seedHex: string, listenAddrs?: string[], bootstrapPeers?: string[], enableRelay?: boolean, dhtClientMode?: boolean}} opts
44
+ */
45
+ export async function createTransport({
46
+ seedHex,
47
+ listenAddrs = ["/ip4/0.0.0.0/tcp/0", "/ip4/0.0.0.0/tcp/0/ws"],
48
+ bootstrapPeers = [],
49
+ enableRelay = false,
50
+ dhtClientMode = true,
51
+ dhtPeerInfoMapper,
52
+ }) {
53
+ const seed = Buffer.from(seedHex, "hex");
54
+ if (seed.length !== 32) throw new Error("seedHex must be 32 raw bytes hex");
55
+ const privateKey = privateKeyFromRaw(seed);
56
+
57
+ const services = {
58
+ identify: identify(),
59
+ identifyPush: identifyPush(),
60
+ ping: ping(),
61
+ dht: kadDHT({
62
+ protocol: `${DHT_PROTOCOL_PREFIX}/kad/1.0.0`,
63
+ clientMode: dhtClientMode,
64
+ // Default filters loopback/RFC1918 addrs; tests and dev fleets override with passthrough.
65
+ peerInfoMapper: dhtPeerInfoMapper ?? removePrivateAddressesMapper,
66
+ }),
67
+ autoNAT: autoNAT(),
68
+ dcutr: dcutr(),
69
+ };
70
+ if (enableRelay) {
71
+ services.relay = circuitRelayServer({
72
+ reservations: { maxReservations: 128, defaultDurationLimit: 5 * 60 * 1000 },
73
+ });
74
+ }
75
+
76
+ const cfg = {
77
+ privateKey,
78
+ addresses: { listen: listenAddrs },
79
+ transports: [tcp(), webSockets(), circuitRelayTransport()],
80
+ connectionEncrypters: [noise()],
81
+ streamMuxers: [yamux()],
82
+ services,
83
+ };
84
+ if (bootstrapPeers.length > 0) {
85
+ cfg.peerDiscovery = [bootstrap({ list: bootstrapPeers })];
86
+ }
87
+
88
+ const node = await createLibp2p(cfg);
89
+ return node;
90
+ }
91
+
92
+ /**
93
+ * Map a UUAID string to the DHT key we advertise under. The DHT key is
94
+ * a raw sha256 of the UUAID string — content-addressed, stable, and
95
+ * doesn't leak PII beyond what the UUAID already does.
96
+ */
97
+ export function uuaidToDhtKey(uuaid) {
98
+ return createHash("sha256").update(uuaid, "utf-8").digest();
99
+ }
100
+
101
+ /**
102
+ * Convert a raw Ed25519 public key hex to a libp2p PeerId. This is how
103
+ * a peer given only a UUAID's public key can dial that peer.
104
+ */
105
+ export function peerIdFromEd25519Hex(publicKeyHex) {
106
+ const raw = Buffer.from(publicKeyHex, "hex");
107
+ const pub = publicKeyFromRaw(raw);
108
+ return peerIdFromPublicKey(pub);
109
+ }
package/src/pillar.mjs ADDED
@@ -0,0 +1,457 @@
1
+ /**
2
+ * Pillar — top-level orchestrator.
3
+ *
4
+ * Wires:
5
+ * - Keychain: identity, signing key
6
+ * - Mailbox: durable local outbox + inbox with chain-hash audit
7
+ * - Transport: libp2p node with DHT + /uuaid/pillar/1.0.0 protocol
8
+ *
9
+ * Public surface:
10
+ * const pillar = new Pillar({ keychain, mailboxPath, listenAddrs, bootstrapPeers, enableRelay });
11
+ * await pillar.start();
12
+ * await pillar.publishSelf(); // advertise our UUAID -> PeerId in the DHT
13
+ * pillar.onEnvelope((env) => { ... }); // subscribe to received envelopes
14
+ * await pillar.send({ recipient, kind, payload });
15
+ * await pillar.stop();
16
+ */
17
+ import { fromString as u8FromString, toString as u8ToString } from "uint8arrays";
18
+ import { CID } from "multiformats/cid";
19
+ import * as raw from "multiformats/codecs/raw";
20
+ import { sha256 } from "multiformats/hashes/sha2";
21
+ import { createTransport, PILLAR_PROTOCOL, uuaidToDhtKey, peerIdFromEd25519Hex } from "./net/transport.mjs";
22
+ import { seal, open, decrypt, envelopeSha, ENVELOPE_VERSION } from "./net/envelope.mjs";
23
+ import { Mailbox } from "./mailbox/mailbox.mjs";
24
+ import { CarrierClient } from "./net/carrier-client.mjs";
25
+
26
+ const VALUE_STORE_PREFIX = "/uuaid/1.0";
27
+
28
+ export class Pillar {
29
+ constructor({
30
+ keychain,
31
+ mailboxPath,
32
+ listenAddrs,
33
+ bootstrapPeers,
34
+ enableRelay,
35
+ dhtClientMode = true,
36
+ dhtPeerInfoMapper,
37
+ carriers = [],
38
+ enableLibp2p = true,
39
+ cryptoInventory = { ed25519: "active", "ml-dsa-65": "reserved", "slh-dsa-128s": "reserved" },
40
+ }) {
41
+ if (!keychain?._identity) throw new Error("Pillar: keychain must be loaded before construction");
42
+ this.keychain = keychain;
43
+ this.mailbox = new Mailbox(mailboxPath);
44
+ this.listenAddrs = listenAddrs;
45
+ this.bootstrapPeers = bootstrapPeers;
46
+ this.enableRelay = enableRelay;
47
+ this.dhtClientMode = dhtClientMode;
48
+ this.dhtPeerInfoMapper = dhtPeerInfoMapper;
49
+ this.carriers = carriers;
50
+ this.carrierClient = carriers.length ? new CarrierClient({ keychain, carriers }) : null;
51
+ this._carrierPoller = null;
52
+ this.enableLibp2p = enableLibp2p;
53
+ this.cryptoInventory = cryptoInventory;
54
+ this.node = null;
55
+ this._envelopeHandlers = new Set();
56
+ this._openStreams = new Map(); // peerIdString -> pushable
57
+ }
58
+
59
+ get uuaid() { return this.keychain._identity.uuaid; }
60
+ get publicKeyHex() { return this.keychain._identity.publicKeyHex; }
61
+
62
+ async start() {
63
+ // Carrier-only mode: no listening socket, no libp2p. This is what runs
64
+ // in sandboxes / serverless / anywhere that cannot open inbound ports and
65
+ // doesn't need direct dials.
66
+ if (!this.enableLibp2p) return this;
67
+ // Rebuild seed from the loaded keychain identity so libp2p PeerId derives
68
+ // from the same private key that the keychain uses for envelope signing.
69
+ const seedDer = this.keychain._identity.privateKey.export({ type: "pkcs8", format: "der" });
70
+ const seed = seedDer.slice(seedDer.length - 32);
71
+ this.node = await createTransport({
72
+ seedHex: Buffer.from(seed).toString("hex"),
73
+ listenAddrs: this.listenAddrs,
74
+ bootstrapPeers: this.bootstrapPeers,
75
+ enableRelay: this.enableRelay,
76
+ dhtClientMode: this.dhtClientMode,
77
+ dhtPeerInfoMapper: this.dhtPeerInfoMapper,
78
+ });
79
+ await this.node.start();
80
+ await this.node.handle(PILLAR_PROTOCOL, this._handleStream.bind(this));
81
+ // Eagerly dial each bootstrap peer so kad-dht's routing table can
82
+ // populate. The @libp2p/bootstrap discovery service by itself does not
83
+ // trigger dials in every libp2p config, so we do it explicitly.
84
+ if (this.bootstrapPeers && this.bootstrapPeers.length > 0) {
85
+ const { multiaddr } = await import("@multiformats/multiaddr");
86
+ await Promise.allSettled(
87
+ this.bootstrapPeers.map(async (ma) => {
88
+ try { await this.node.dial(multiaddr(ma)); }
89
+ catch (e) { /* one bad bootstrap shouldn't kill startup */ }
90
+ }),
91
+ );
92
+ }
93
+ return this;
94
+ }
95
+
96
+ /**
97
+ * Wait until the DHT routing table has at least `minPeers` entries.
98
+ * kad-dht will not answer put/get queries until it has peers in the
99
+ * routing table (not just the peer store). In client mode we usually
100
+ * only need 1 (a bootstrap server). Callers should await this before
101
+ * publishSelf() or resolveUuaid() when the mesh has just started.
102
+ */
103
+ async awaitDhtReady({ minPeers = 1, timeoutMs = 15000, intervalMs = 200 } = {}) {
104
+ if (!this.node) throw new Error("pillar not started");
105
+ const dht = this.node.services.dht;
106
+ if (!dht) throw new Error("dht service not enabled");
107
+ const t0 = Date.now();
108
+ // Trigger a self findPeer against every connected peer. A successful kad
109
+ // response causes kad-dht to add that peer to the routing table. If we
110
+ // are talking to a server-mode peer, that's all we need.
111
+ // Doing findPeer(peerId) both (a) populates OUR routing table when the
112
+ // response comes back and (b) causes the remote server-mode peer to add
113
+ // US to ITS routing table. Without this second-order effect, DHT put/get
114
+ // never propagates because the bootstrap can't route back to the client.
115
+ const trySeedRoutingTable = async () => {
116
+ for (const peerId of this.node.getPeers()) {
117
+ try {
118
+ for await (const _evt of dht.findPeer(peerId, { signal: AbortSignal.timeout(2000) })) { /* drain */ }
119
+ } catch (_e) { /* peer may be client-mode, that's ok */ }
120
+ // Also query for ourselves via this peer — this is the standard
121
+ // "self-query" that Kademlia uses on join, and it announces us to the
122
+ // peer's routing table.
123
+ try {
124
+ for await (const _evt of dht.getClosestPeers(this.node.peerId.toMultihash().bytes, { signal: AbortSignal.timeout(2000) })) { /* drain */ }
125
+ } catch (_e) {}
126
+ }
127
+ };
128
+ while (Date.now() - t0 < timeoutMs) {
129
+ const size = dht.routingTable?.size ?? 0;
130
+ if (size >= minPeers) return { size, waitedMs: Date.now() - t0 };
131
+ await trySeedRoutingTable();
132
+ await new Promise(r => setTimeout(r, intervalMs));
133
+ }
134
+ throw new Error(`awaitDhtReady: routing table did not reach ${minPeers} peers within ${timeoutMs}ms`);
135
+ }
136
+
137
+ async stop() {
138
+ this.stopCarrierPolling();
139
+ if (!this.node) { this.mailbox.close(); return; }
140
+ for (const push of this._openStreams.values()) { try { push.end(); } catch (_e) {} }
141
+ this._openStreams.clear();
142
+ try { await this.node.stop(); } catch (_e) {}
143
+ this.node = null;
144
+ this.mailbox.close();
145
+ }
146
+
147
+ /**
148
+ * Convert a UUAID string to a stable CID. We use `raw` codec + sha256(uuaid)
149
+ * so the CID is content-addressed on the UUAID itself.
150
+ */
151
+ async _uuaidCid(uuaid) {
152
+ const digest = await sha256.digest(u8FromString(uuaid));
153
+ return CID.createV1(raw.code, digest);
154
+ }
155
+
156
+ /**
157
+ * Advertise (UUAID -> our PeerId) in the DHT via provider records.
158
+ *
159
+ * Uses `contentRouting.provide(cid)`, which stores a provider record
160
+ * (peerId + multiaddrs) at the closest peers to `cid`. This is the
161
+ * BitTorrent-style discovery: peers looking for `cid` learn who has it
162
+ * and can dial them directly. Unlike `contentRouting.put/get`, provider
163
+ * records do not require validator registration.
164
+ */
165
+ async publishSelf() {
166
+ if (!this.node) throw new Error("pillar not started");
167
+ const cid = await this._uuaidCid(this.uuaid);
168
+ await this.node.contentRouting.provide(cid);
169
+ return {
170
+ uuaid: this.uuaid,
171
+ peerId: this.node.peerId.toString(),
172
+ publicKey: this.publicKeyHex,
173
+ addrs: this.node.getMultiaddrs().map(a => a.toString()),
174
+ cid: cid.toString(),
175
+ };
176
+ }
177
+
178
+ /**
179
+ * Resolve a UUAID -> live peer via DHT provider lookup.
180
+ *
181
+ * Iterates `findProviders(cid)` until we find a provider whose PeerId
182
+ * matches the hash of a public key we can verify. Verification is done
183
+ * by dialing the peer, letting Noise/libp2p authenticate the PeerId, then
184
+ * reading the peer's public key from libp2p's peer store.
185
+ *
186
+ * Returns { uuaid, peerId, publicKeyHex, addrs }.
187
+ * Throws if no provider is found within `timeoutMs`.
188
+ */
189
+ async resolveUuaid(uuaid, { timeoutMs = 15000 } = {}) {
190
+ if (!this.node) throw new Error("pillar not started");
191
+ const { peerIdFromCID } = await import("@libp2p/peer-id");
192
+ const cid = await this._uuaidCid(uuaid);
193
+ const signal = AbortSignal.timeout(timeoutMs);
194
+ for await (const provider of this.node.contentRouting.findProviders(cid, { signal })) {
195
+ // Provider record gives us peerId + addrs. libp2p's PeerId is a hash
196
+ // of the peer's public key; when we later dial, Noise will authenticate
197
+ // that the peer actually holds the private key. So the trust chain is:
198
+ // UUAID (sha256(pubkey) local-id) == publicKey the peer proves via Noise.
199
+ const providerPubKey = provider.id.publicKey;
200
+ if (!providerPubKey) continue; // shouldn't happen for Ed25519 PeerIds but be safe
201
+ const publicKeyHex = Buffer.from(providerPubKey.raw).toString("hex");
202
+ // Recompute the UUAID that this key would mint.
203
+ const { Keychain } = await import("./identity/keychain.mjs");
204
+ const localId = Keychain._localIdFromKey(Buffer.from(publicKeyHex, "hex"));
205
+ const expectedUuaid = `uuaid:foundation:agent:${localId}`;
206
+ if (expectedUuaid !== uuaid) continue; // provider is squatting on someone else's key
207
+ // Remember addrs so future dials succeed even if the provider drops off DHT.
208
+ if (provider.multiaddrs?.length) {
209
+ try { await this.node.peerStore.patch(provider.id, { multiaddrs: provider.multiaddrs }); } catch (_e) {}
210
+ }
211
+ return {
212
+ uuaid,
213
+ peerId: provider.id.toString(),
214
+ publicKeyHex,
215
+ addrs: (provider.multiaddrs ?? []).map(a => a.toString()),
216
+ };
217
+ }
218
+ throw new Error(`resolveUuaid(${uuaid}): no matching provider found within ${timeoutMs}ms`);
219
+ }
220
+
221
+ onEnvelope(handler) {
222
+ this._envelopeHandlers.add(handler);
223
+ return () => this._envelopeHandlers.delete(handler);
224
+ }
225
+
226
+ /**
227
+ * Send a signed envelope. Resolution order:
228
+ * 1. Address book: if `this.addressBook.get(recipient)` returns {peerId, multiaddr},
229
+ * dial directly (out-of-band trust; used by the fleet runner).
230
+ * 2. DHT: resolveUuaid(recipient) via provider records.
231
+ *
232
+ * The DHT path is FUNCTIONAL for large public meshes but has known slow
233
+ * convergence on tiny 3-peer loopback tests in libp2p 3.x kad-dht. For
234
+ * fleet deployment we bypass DHT via the address book which is populated
235
+ * once at fleet-up time.
236
+ */
237
+ async send({ recipient, recipientPublicKey, kind = "message", payload = {}, resolveTimeoutMs = 5000 }) {
238
+ if (!this.node && !this.carrierClient) throw new Error("pillar not started");
239
+
240
+ const book = this.addressBook?.get(recipient);
241
+
242
+ // E2E: we MUST have the recipient's public key before anything leaves
243
+ // this process. Sources, in order: explicit param, address book, DHT
244
+ // provider record (which carries the pubkey inside the PeerId).
245
+ let pubkey = recipientPublicKey ?? book?.publicKey;
246
+ let dhtAdvert = null;
247
+ if (!pubkey && this.node) {
248
+ try {
249
+ dhtAdvert = await this.resolveUuaid(recipient, { timeoutMs: resolveTimeoutMs });
250
+ pubkey = dhtAdvert.publicKeyHex;
251
+ } catch (_e) { /* fall through to the error below */ }
252
+ }
253
+ if (!pubkey) {
254
+ throw new Error(`send: no public key known for ${recipient} — encryption is mandatory; register the peer (address book / fleet.json) or pass recipientPublicKey`);
255
+ }
256
+
257
+ const envelope = seal(this.keychain, { recipient, recipientPublicKey: pubkey, kind, payload });
258
+ const persisted = this.mailbox.appendOutbound(envelope);
259
+
260
+ // 1. Address book path: direct libp2p dial (lowest latency, no third party).
261
+ if (this.node && book?.peerId && book?.multiaddr) {
262
+ try {
263
+ return { envelope, persisted, via: "direct", delivery: await this._deliverToPeer(envelope, book.peerId, book.multiaddr) };
264
+ } catch (e) {
265
+ if (!this.carrierClient) throw e; // else fall through to carrier
266
+ }
267
+ }
268
+
269
+ // 2. Carrier path: universal store-and-forward.
270
+ if (this.carrierClient) {
271
+ const receipt = await this.carrierClient.deliver(envelope);
272
+ this.mailbox.markOutboundDelivered(envelope.id, envelopeSha(envelope));
273
+ return { envelope, persisted, via: "carrier", delivery: receipt };
274
+ }
275
+
276
+ // 3. DHT path.
277
+ if (!this.node) throw new Error(`send: cannot resolve ${recipient}: no carrier configured and libp2p not started`);
278
+ if (!dhtAdvert) {
279
+ try { dhtAdvert = await this.resolveUuaid(recipient, { timeoutMs: resolveTimeoutMs }); }
280
+ catch (e) { throw new Error(`send: cannot resolve ${recipient}: ${e.message}`); }
281
+ }
282
+ return { envelope, persisted, via: "dht", delivery: await this._deliverToPeer(envelope, dhtAdvert.peerId, dhtAdvert.addrs?.[0]) };
283
+ }
284
+
285
+ /**
286
+ * Group send — fan-out to every member with per-member E2E sealing.
287
+ *
288
+ * There is deliberately NO group key: each member gets their own sealed
289
+ * envelope (same guarantees as 1:1 send), so membership changes never
290
+ * require rekeying and the carrier still can't read anything. The group
291
+ * context travels INSIDE the sealed payload as `_group: { name, members }`
292
+ * so every recipient can reply to the whole group without extra lookups.
293
+ *
294
+ * Failures are isolated: one undeliverable member never blocks the rest.
295
+ * Returns { group, results: [{ member, ok, via?, envelopeId?, error? }] }.
296
+ */
297
+ async sendGroup({ group, members, kind = "message", payload = {}, resolveTimeoutMs = 5000 }) {
298
+ if (!Array.isArray(members) || members.length === 0) {
299
+ throw new Error(`sendGroup: group "${group}" has no members`);
300
+ }
301
+ const others = members.filter(m => m !== this.uuaid);
302
+ const wrapped = { _group: { name: group, members }, ...payload };
303
+ const results = [];
304
+ for (const member of others) {
305
+ try {
306
+ const r = await this.send({ recipient: member, kind, payload: wrapped, resolveTimeoutMs });
307
+ results.push({ member, ok: true, via: r.via, envelopeId: r.envelope.id });
308
+ } catch (e) {
309
+ results.push({ member, ok: false, error: e.message });
310
+ }
311
+ }
312
+ return { group, results };
313
+ }
314
+
315
+ /**
316
+ * Start pulling our inbox from every configured carrier. Received envelopes
317
+ * flow through the same verification + mailbox + handler pipeline as
318
+ * libp2p-received envelopes. Cursors persist in the mailbox DB so restarts
319
+ * do not re-deliver.
320
+ */
321
+ startCarrierPolling({ waitS = 25 } = {}) {
322
+ if (!this.carrierClient) throw new Error("no carriers configured");
323
+ if (this._carrierPoller) return this._carrierPoller;
324
+ const cursors = this.mailbox.getCarrierCursors?.() ?? {};
325
+ this._carrierPoller = this.carrierClient.startPolling({
326
+ cursors,
327
+ waitS,
328
+ onEnvelope: async (envelope, { carrier, seq }) => {
329
+ await this._receive(JSON.stringify(envelope), null);
330
+ this.mailbox.setCarrierCursor?.(carrier, seq);
331
+ },
332
+ onError: (_e) => { /* transient poll errors are expected; backoff is in client */ },
333
+ });
334
+ return this._carrierPoller;
335
+ }
336
+
337
+ stopCarrierPolling() {
338
+ if (this._carrierPoller) { this._carrierPoller.stop(); this._carrierPoller = null; }
339
+ }
340
+
341
+ async _deliverToPeer(envelope, peerIdStr, multiaddrStr) {
342
+ const { peerIdFromString } = await import("@libp2p/peer-id");
343
+ const { multiaddr } = await import("@multiformats/multiaddr");
344
+ const peerId = peerIdFromString(peerIdStr);
345
+ if (multiaddrStr) {
346
+ try { await this.node.peerStore.patch(peerId, { multiaddrs: [multiaddr(multiaddrStr)] }); } catch (_e) {}
347
+ }
348
+ const stream = await this.node.dialProtocol(peerId, PILLAR_PROTOCOL);
349
+ const line = JSON.stringify(envelope) + "\n";
350
+ stream.send(u8FromString(line));
351
+ await stream.close();
352
+ this.mailbox.markOutboundDelivered(envelope.id, envelopeSha(envelope));
353
+ return { peerId: peerIdStr };
354
+ }
355
+
356
+ /**
357
+ * In-memory address book: uuaid -> { peerId?, multiaddr?, publicKey }.
358
+ * publicKey (ed25519 hex) is what makes E2E possible without DHT.
359
+ * Identity binding (pubkey must derive the UUAID) is enforced in seal().
360
+ */
361
+ registerAddress(uuaid, { peerId, multiaddr, publicKey }) {
362
+ if (!this.addressBook) this.addressBook = new Map();
363
+ const prev = this.addressBook.get(uuaid) ?? {};
364
+ this.addressBook.set(uuaid, {
365
+ peerId: peerId ?? prev.peerId,
366
+ multiaddr: multiaddr ?? prev.multiaddr,
367
+ publicKey: publicKey ?? prev.publicKey,
368
+ });
369
+ }
370
+
371
+ /** Register just a peer's public key (enough for carrier-path E2E sends). */
372
+ registerKey(uuaid, publicKeyHex) {
373
+ this.registerAddress(uuaid, { publicKey: publicKeyHex });
374
+ }
375
+
376
+ /**
377
+ * Direct-dial helper for tests: given a peer's UUAID + its libp2p multiaddr,
378
+ * we bypass DHT lookup. Also useful when a caller already has the peer's
379
+ * connection info out of band.
380
+ */
381
+ async sendDirect({ recipient, recipientPublicKey, recipientPeerIdStr, recipientMultiaddr, kind = "message", payload = {} }) {
382
+ if (!this.node) throw new Error("pillar not started");
383
+ const pubkey = recipientPublicKey ?? this.addressBook?.get(recipient)?.publicKey;
384
+ if (!pubkey) throw new Error(`sendDirect: no public key for ${recipient} — encryption is mandatory`);
385
+ const envelope = seal(this.keychain, { recipient, recipientPublicKey: pubkey, kind, payload });
386
+ this.mailbox.appendOutbound(envelope);
387
+ const { peerIdFromString } = await import("@libp2p/peer-id");
388
+ const { multiaddr } = await import("@multiformats/multiaddr");
389
+ const peerId = peerIdFromString(recipientPeerIdStr);
390
+ const ma = multiaddr(recipientMultiaddr);
391
+ await this.node.peerStore.patch(peerId, { multiaddrs: [ma] });
392
+ const stream = await this.node.dialProtocol(peerId, PILLAR_PROTOCOL);
393
+ const line = JSON.stringify(envelope) + "\n";
394
+ stream.send(u8FromString(line));
395
+ await stream.close();
396
+ this.mailbox.markOutboundDelivered(envelope.id, envelopeSha(envelope));
397
+ return envelope;
398
+ }
399
+
400
+ /**
401
+ * libp2p stream handler for incoming envelopes.
402
+ * Wire format: one envelope per stream, JSON + newline.
403
+ */
404
+ async _handleStream(stream, connection) {
405
+ try {
406
+ let buf = "";
407
+ for await (const chunk of stream) {
408
+ const bytes = chunk?.subarray ? chunk.subarray() : chunk;
409
+ buf += u8ToString(bytes);
410
+ const newlineIdx = buf.indexOf("\n");
411
+ if (newlineIdx < 0) continue;
412
+ const line = buf.slice(0, newlineIdx);
413
+ buf = buf.slice(newlineIdx + 1);
414
+ await this._receive(line, connection);
415
+ break; // one envelope per stream for v0.2
416
+ }
417
+ } catch (e) {
418
+ // eslint-disable-next-line no-console
419
+ console.error("pillar: stream error", e?.message ?? e);
420
+ } finally {
421
+ try { await stream.close(); } catch (_e) {}
422
+ }
423
+ }
424
+
425
+ async _receive(rawLine, connection) {
426
+ let envelope;
427
+ try { envelope = JSON.parse(rawLine); }
428
+ catch (_e) { return; }
429
+ const verdict = open(envelope, { cryptoInventory: this.cryptoInventory });
430
+ if (!verdict.ok) {
431
+ // eslint-disable-next-line no-console
432
+ console.warn("pillar: rejected envelope from", connection?.remotePeer?.toString(), verdict.reason);
433
+ return;
434
+ }
435
+ if (envelope.recipient !== this.uuaid) {
436
+ // Not for us. Store-and-forward would go here in v0.3.
437
+ return;
438
+ }
439
+ // E2E: decrypt the sealed payload. Ciphertext-at-rest: the mailbox stores
440
+ // the envelope as received (encrypted); only the handler view is plaintext.
441
+ let payload;
442
+ try {
443
+ payload = decrypt(this.keychain, envelope);
444
+ } catch (e) {
445
+ // eslint-disable-next-line no-console
446
+ console.warn("pillar: envelope decrypt failed (dropping)", envelope.id, e.message);
447
+ return;
448
+ }
449
+ const persisted = this.mailbox.appendInbound(envelope);
450
+ if (persisted.duplicate) return; // replay: do not re-fire handlers
451
+ const view = { ...envelope, payload };
452
+ for (const h of this._envelopeHandlers) {
453
+ try { await h(view, persisted); }
454
+ catch (e) { console.error("pillar: envelope handler threw", e); }
455
+ }
456
+ }
457
+ }