@uuaid/pillar 0.2.0-alpha.10
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/INTERACTION-CONVENTION.md +44 -0
- package/LICENSE +201 -0
- package/README.md +114 -0
- package/bin/carrier.mjs +51 -0
- package/bin/pillar-ask.mjs +128 -0
- package/bin/pillar-responder.mjs +261 -0
- package/bin/pillar-seed.mjs +82 -0
- package/bin/pillar.mjs +479 -0
- package/package.json +59 -0
- package/src/address-book.mjs +42 -0
- package/src/bridges/zestro.mjs +155 -0
- package/src/crypto/e2e.mjs +97 -0
- package/src/identity/jcs.mjs +35 -0
- package/src/identity/keychain.mjs +140 -0
- package/src/index.mjs +18 -0
- package/src/mailbox/mailbox.mjs +194 -0
- package/src/net/carrier-client.mjs +118 -0
- package/src/net/carrier-server.mjs +301 -0
- package/src/net/envelope.mjs +175 -0
- package/src/net/transport.mjs +109 -0
- package/src/pillar.mjs +483 -0
|
@@ -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,483 @@
|
|
|
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
|
+
/**
|
|
342
|
+
* One-shot inbox pull: fetch every pending envelope from every carrier
|
|
343
|
+
* (no long-poll) through the same verification + mailbox + handler
|
|
344
|
+
* pipeline, then return. Complements startCarrierPolling for CLI /
|
|
345
|
+
* cron-style "check my mail now" callers. Returns envelopes fetched.
|
|
346
|
+
*/
|
|
347
|
+
async drainCarrierInbox() {
|
|
348
|
+
if (!this.carrierClient) return 0;
|
|
349
|
+
let total = 0;
|
|
350
|
+
const cursors = this.mailbox.getCarrierCursors?.() ?? {};
|
|
351
|
+
for (const base of this.carriers) {
|
|
352
|
+
let since = cursors[base] ?? 0;
|
|
353
|
+
for (;;) { // carrier pages at 200/fetch — loop until an empty page
|
|
354
|
+
const r = await this.carrierClient.fetchInbox(base, { since, waitS: 0 });
|
|
355
|
+
if (!r.envelopes.length) break;
|
|
356
|
+
for (const { seq, envelope } of r.envelopes) {
|
|
357
|
+
await this._receive(JSON.stringify(envelope), null);
|
|
358
|
+
this.mailbox.setCarrierCursor?.(base, seq);
|
|
359
|
+
since = Math.max(since, seq);
|
|
360
|
+
total++;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return total;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async _deliverToPeer(envelope, peerIdStr, multiaddrStr) {
|
|
368
|
+
const { peerIdFromString } = await import("@libp2p/peer-id");
|
|
369
|
+
const { multiaddr } = await import("@multiformats/multiaddr");
|
|
370
|
+
const peerId = peerIdFromString(peerIdStr);
|
|
371
|
+
if (multiaddrStr) {
|
|
372
|
+
try { await this.node.peerStore.patch(peerId, { multiaddrs: [multiaddr(multiaddrStr)] }); } catch (_e) {}
|
|
373
|
+
}
|
|
374
|
+
const stream = await this.node.dialProtocol(peerId, PILLAR_PROTOCOL);
|
|
375
|
+
const line = JSON.stringify(envelope) + "\n";
|
|
376
|
+
stream.send(u8FromString(line));
|
|
377
|
+
await stream.close();
|
|
378
|
+
this.mailbox.markOutboundDelivered(envelope.id, envelopeSha(envelope));
|
|
379
|
+
return { peerId: peerIdStr };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* In-memory address book: uuaid -> { peerId?, multiaddr?, publicKey }.
|
|
384
|
+
* publicKey (ed25519 hex) is what makes E2E possible without DHT.
|
|
385
|
+
* Identity binding (pubkey must derive the UUAID) is enforced in seal().
|
|
386
|
+
*/
|
|
387
|
+
registerAddress(uuaid, { peerId, multiaddr, publicKey }) {
|
|
388
|
+
if (!this.addressBook) this.addressBook = new Map();
|
|
389
|
+
const prev = this.addressBook.get(uuaid) ?? {};
|
|
390
|
+
this.addressBook.set(uuaid, {
|
|
391
|
+
peerId: peerId ?? prev.peerId,
|
|
392
|
+
multiaddr: multiaddr ?? prev.multiaddr,
|
|
393
|
+
publicKey: publicKey ?? prev.publicKey,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Register just a peer's public key (enough for carrier-path E2E sends). */
|
|
398
|
+
registerKey(uuaid, publicKeyHex) {
|
|
399
|
+
this.registerAddress(uuaid, { publicKey: publicKeyHex });
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Direct-dial helper for tests: given a peer's UUAID + its libp2p multiaddr,
|
|
404
|
+
* we bypass DHT lookup. Also useful when a caller already has the peer's
|
|
405
|
+
* connection info out of band.
|
|
406
|
+
*/
|
|
407
|
+
async sendDirect({ recipient, recipientPublicKey, recipientPeerIdStr, recipientMultiaddr, kind = "message", payload = {} }) {
|
|
408
|
+
if (!this.node) throw new Error("pillar not started");
|
|
409
|
+
const pubkey = recipientPublicKey ?? this.addressBook?.get(recipient)?.publicKey;
|
|
410
|
+
if (!pubkey) throw new Error(`sendDirect: no public key for ${recipient} — encryption is mandatory`);
|
|
411
|
+
const envelope = seal(this.keychain, { recipient, recipientPublicKey: pubkey, kind, payload });
|
|
412
|
+
this.mailbox.appendOutbound(envelope);
|
|
413
|
+
const { peerIdFromString } = await import("@libp2p/peer-id");
|
|
414
|
+
const { multiaddr } = await import("@multiformats/multiaddr");
|
|
415
|
+
const peerId = peerIdFromString(recipientPeerIdStr);
|
|
416
|
+
const ma = multiaddr(recipientMultiaddr);
|
|
417
|
+
await this.node.peerStore.patch(peerId, { multiaddrs: [ma] });
|
|
418
|
+
const stream = await this.node.dialProtocol(peerId, PILLAR_PROTOCOL);
|
|
419
|
+
const line = JSON.stringify(envelope) + "\n";
|
|
420
|
+
stream.send(u8FromString(line));
|
|
421
|
+
await stream.close();
|
|
422
|
+
this.mailbox.markOutboundDelivered(envelope.id, envelopeSha(envelope));
|
|
423
|
+
return envelope;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* libp2p stream handler for incoming envelopes.
|
|
428
|
+
* Wire format: one envelope per stream, JSON + newline.
|
|
429
|
+
*/
|
|
430
|
+
async _handleStream(stream, connection) {
|
|
431
|
+
try {
|
|
432
|
+
let buf = "";
|
|
433
|
+
for await (const chunk of stream) {
|
|
434
|
+
const bytes = chunk?.subarray ? chunk.subarray() : chunk;
|
|
435
|
+
buf += u8ToString(bytes);
|
|
436
|
+
const newlineIdx = buf.indexOf("\n");
|
|
437
|
+
if (newlineIdx < 0) continue;
|
|
438
|
+
const line = buf.slice(0, newlineIdx);
|
|
439
|
+
buf = buf.slice(newlineIdx + 1);
|
|
440
|
+
await this._receive(line, connection);
|
|
441
|
+
break; // one envelope per stream for v0.2
|
|
442
|
+
}
|
|
443
|
+
} catch (e) {
|
|
444
|
+
// eslint-disable-next-line no-console
|
|
445
|
+
console.error("pillar: stream error", e?.message ?? e);
|
|
446
|
+
} finally {
|
|
447
|
+
try { await stream.close(); } catch (_e) {}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async _receive(rawLine, connection) {
|
|
452
|
+
let envelope;
|
|
453
|
+
try { envelope = JSON.parse(rawLine); }
|
|
454
|
+
catch (_e) { return; }
|
|
455
|
+
const verdict = open(envelope, { cryptoInventory: this.cryptoInventory });
|
|
456
|
+
if (!verdict.ok) {
|
|
457
|
+
// eslint-disable-next-line no-console
|
|
458
|
+
console.warn("pillar: rejected envelope from", connection?.remotePeer?.toString(), verdict.reason);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
if (envelope.recipient !== this.uuaid) {
|
|
462
|
+
// Not for us. Store-and-forward would go here in v0.3.
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
// E2E: decrypt the sealed payload. Ciphertext-at-rest: the mailbox stores
|
|
466
|
+
// the envelope as received (encrypted); only the handler view is plaintext.
|
|
467
|
+
let payload;
|
|
468
|
+
try {
|
|
469
|
+
payload = decrypt(this.keychain, envelope);
|
|
470
|
+
} catch (e) {
|
|
471
|
+
// eslint-disable-next-line no-console
|
|
472
|
+
console.warn("pillar: envelope decrypt failed (dropping)", envelope.id, e.message);
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
const persisted = this.mailbox.appendInbound(envelope);
|
|
476
|
+
if (persisted.duplicate) return; // replay: do not re-fire handlers
|
|
477
|
+
const view = { ...envelope, payload };
|
|
478
|
+
for (const h of this._envelopeHandlers) {
|
|
479
|
+
try { await h(view, persisted); }
|
|
480
|
+
catch (e) { console.error("pillar: envelope handler threw", e); }
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|