@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.
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@uuaid/pillar",
3
+ "version": "0.2.0-alpha.6",
4
+ "description": "IAASO-3050 reference implementation. Every agent runs its own PDP pillar, discovers peers via a Kademlia DHT, and exchanges cryptographically signed + Noise-encrypted messages over libp2p. One command to install and join the network.",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "main": "src/index.mjs",
8
+ "bin": {
9
+ "pillar": "bin/pillar.mjs",
10
+ "pillar-carrier": "bin/carrier.mjs",
11
+ "pillar-ask": "bin/pillar-ask.mjs",
12
+ "pillar-responder": "bin/pillar-responder.mjs",
13
+ "pillar-seed": "bin/pillar-seed.mjs"
14
+ },
15
+ "exports": {
16
+ ".": "./src/index.mjs"
17
+ },
18
+ "files": [
19
+ "src",
20
+ "bin",
21
+ "README.md",
22
+ "INTERACTION-CONVENTION.md",
23
+ "LICENSE"
24
+ ],
25
+ "scripts": {
26
+ "test": "node --test --test-reporter spec test/unit/*.test.mjs",
27
+ "test:int": "node --test --test-reporter spec test/integration/*.test.mjs",
28
+ "test:chaos": "node --test --test-reporter spec test/chaos/*.test.mjs",
29
+ "test:all": "node --test --test-reporter spec test/unit/*.test.mjs test/integration/*.test.mjs test/chaos/*.test.mjs"
30
+ },
31
+ "engines": {
32
+ "node": ">=20"
33
+ },
34
+ "dependencies": {
35
+ "@chainsafe/libp2p-noise": "^17.0.0",
36
+ "@chainsafe/libp2p-yamux": "^8.0.1",
37
+ "@libp2p/autonat": "^3.0.26",
38
+ "@libp2p/bootstrap": "^12.0.29",
39
+ "@libp2p/circuit-relay-v2": "^4.2.11",
40
+ "@libp2p/crypto": "^5.1.22",
41
+ "@libp2p/dcutr": "^3.0.26",
42
+ "@libp2p/identify": "^4.1.12",
43
+ "@libp2p/kad-dht": "^16.4.3",
44
+ "@libp2p/peer-id": "^6.0.14",
45
+ "@libp2p/ping": "^3.1.11",
46
+ "@libp2p/tcp": "^11.0.26",
47
+ "@libp2p/webrtc": "^6.0.29",
48
+ "@libp2p/websockets": "^10.1.19",
49
+ "@multiformats/multiaddr": "^13.0.3",
50
+ "@noble/curves": "^2.3.0",
51
+ "better-sqlite3": "^11.10.0",
52
+ "it-pipe": "^3.0.1",
53
+ "it-pushable": "^3.2.4",
54
+ "libp2p": "^3.3.8",
55
+ "multiformats": "^14.0.5",
56
+ "uint8arrays": "^5.1.1",
57
+ "ws": "^8.21.3"
58
+ }
59
+ }
@@ -0,0 +1,42 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join, dirname } from "node:path";
3
+
4
+ /**
5
+ * Load a fleet.json address book into a Pillar instance.
6
+ *
7
+ * Shape: { "fleet": { "<role>": { uuaid, addrs?, peerId?, publicKey } } }.
8
+ * `pillar init --directory` writes one from the directory's agents[] so a
9
+ * fresh agent can `ask`/`send` with no manual key plumbing.
10
+ */
11
+ export function loadFleetAddressBook(fleetPath, pillar) {
12
+ if (!fleetPath || !existsSync(fleetPath)) return 0;
13
+ const fleet = JSON.parse(readFileSync(fleetPath, "utf-8")).fleet ?? {};
14
+ // Warm handoff: fleet.json addrs come from a short-lived `pillar id` probe
15
+ // on /tcp/0, so a running daemon's REAL addr differs. Each daemon writes
16
+ // <home>/runtime.json on boot; prefer that addr when fresh.
17
+ const fleetRoot = dirname(fleetPath);
18
+ let n = 0;
19
+ for (const [role, id] of Object.entries(fleet)) {
20
+ if (!id.uuaid) continue;
21
+ if (id.uuaid === pillar.uuaid) continue; // don't self-register
22
+ let multiaddr = id.addrs?.[0];
23
+ let peerId = id.peerId;
24
+ const rtPath = join(fleetRoot, role, "runtime.json");
25
+ if (existsSync(rtPath)) {
26
+ try {
27
+ const rt = JSON.parse(readFileSync(rtPath, "utf-8"));
28
+ if (rt.uuaid === id.uuaid && rt.addrs?.[0]) {
29
+ multiaddr = rt.addrs[0];
30
+ peerId = rt.peerId ?? peerId;
31
+ }
32
+ } catch (_e) {}
33
+ }
34
+ pillar.registerAddress(id.uuaid, {
35
+ peerId,
36
+ multiaddr,
37
+ publicKey: id.publicKey, // REQUIRED for E2E sends
38
+ });
39
+ n++;
40
+ }
41
+ return n;
42
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Zestro WebSocket bridge.
3
+ *
4
+ * Wire protocol (from ZZ's Mac audit):
5
+ * ws://127.0.0.1:53522/<token>/ws
6
+ * -> { type: "send_command", id: <uuid>, sessionId: <agentId>, command: <str>, inputMode: "ai" }
7
+ * <- { type: "command_result", id: <uuid>, ... }
8
+ *
9
+ * Token lives in ~/Library/Application Support/zestro/cli-server.json.
10
+ * Agent id for ZZ was verified to be 7fbce535-3a2f-4e6b-96e6-64926403efa5.
11
+ *
12
+ * Correlation on `id` so multiple concurrent sends don't race.
13
+ */
14
+ import { WebSocket } from "ws";
15
+ import { randomUUID } from "node:crypto";
16
+
17
+ export class ZestroBridge {
18
+ constructor({ url, token, sessionId, timeoutMs = 30000 }) {
19
+ if (!url || !token || !sessionId) {
20
+ throw new Error("ZestroBridge requires { url, token, sessionId }");
21
+ }
22
+ // Accept either the token-embedded URL or the base URL.
23
+ if (!url.includes("/ws")) {
24
+ if (!url.endsWith("/")) url += "/";
25
+ url = `${url}${token}/ws`;
26
+ }
27
+ this.url = url;
28
+ this.sessionId = sessionId;
29
+ this.timeoutMs = timeoutMs;
30
+ this._ws = null;
31
+ this._pending = new Map();
32
+ }
33
+
34
+ connect() {
35
+ return new Promise((resolve, reject) => {
36
+ const ws = new WebSocket(this.url);
37
+ ws.on("open", () => { this._ws = ws; resolve(); });
38
+ ws.on("error", (e) => { if (!this._ws) reject(e); });
39
+ ws.on("close", () => {
40
+ for (const p of this._pending.values()) p.reject(new Error("bridge closed"));
41
+ this._pending.clear();
42
+ this._ws = null;
43
+ });
44
+ ws.on("message", (buf) => {
45
+ let msg;
46
+ try { msg = JSON.parse(buf.toString()); } catch { return; }
47
+ if (msg.type === "command_result" && msg.id && this._pending.has(msg.id)) {
48
+ const p = this._pending.get(msg.id);
49
+ this._pending.delete(msg.id);
50
+ p.resolve(msg);
51
+ }
52
+ });
53
+ });
54
+ }
55
+
56
+ sendCommand({ command, inputMode = "ai" }) {
57
+ if (!this._ws || this._ws.readyState !== WebSocket.OPEN) {
58
+ return Promise.reject(new Error("zestro bridge not connected"));
59
+ }
60
+ const id = randomUUID();
61
+ return new Promise((resolve, reject) => {
62
+ const timer = setTimeout(() => {
63
+ this._pending.delete(id);
64
+ reject(new Error(`zestro send_command ${id} timed out after ${this.timeoutMs}ms`));
65
+ }, this.timeoutMs);
66
+ this._pending.set(id, {
67
+ resolve: (msg) => { clearTimeout(timer); resolve(msg); },
68
+ reject: (e) => { clearTimeout(timer); reject(e); },
69
+ });
70
+ this._ws.send(JSON.stringify({
71
+ type: "send_command",
72
+ id,
73
+ sessionId: this.sessionId,
74
+ command,
75
+ inputMode,
76
+ }));
77
+ });
78
+ }
79
+
80
+ close() {
81
+ if (this._ws) { try { this._ws.close(); } catch (_e) {} this._ws = null; }
82
+ }
83
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * End-to-end payload encryption — FUNDAMENTAL, not optional.
3
+ *
4
+ * Every envelope's payload is sealed to the recipient's key. There is no
5
+ * plaintext mode. A carrier (or any relay) sees only ciphertext.
6
+ *
7
+ * Construction (sealed box over the agent's existing Ed25519 identity):
8
+ * 1. Recipient's Ed25519 public key --birational map--> X25519 public key
9
+ * (libsodium crypto_sign_ed25519_pk_to_curve25519 equivalent;
10
+ * @noble/curves ed25519.utils.toMontgomery)
11
+ * 2. Sender mints an EPHEMERAL X25519 keypair per envelope (forward secrecy
12
+ * per message: compromising the sender's long-term key never decrypts
13
+ * past traffic; compromising the recipient's does — that's the classic
14
+ * sealed-box tradeoff, addressed by key rotation policy, not this layer).
15
+ * 3. shared = X25519(ephemeralPriv, recipientX25519Pub)
16
+ * 4. key = HKDF-SHA256(shared, salt = epk || recipientX25519Pub,
17
+ * info = "uuaid-pillar-e2e/1") -> 32 bytes
18
+ * 5. AES-256-GCM(key, iv = random 12 bytes) over JCS(payload)
19
+ * AAD = envelope id (binds ciphertext to THIS envelope; a ciphertext
20
+ * transplanted into another envelope fails the GCM tag).
21
+ *
22
+ * Identity binding: callers MUST verify sha256(recipientEdPub) derives the
23
+ * recipient's UUAID local-id before sealing (seal() in envelope.mjs does it).
24
+ */
25
+ import { createHash, createCipheriv, createDecipheriv, hkdfSync, randomBytes } from "node:crypto";
26
+ import { ed25519, x25519 } from "@noble/curves/ed25519.js";
27
+ import { jcs } from "../identity/jcs.mjs";
28
+
29
+ export const E2E_ALG = "x25519-hkdf-sha256-aes256gcm";
30
+ const HKDF_INFO = "uuaid-pillar-e2e/1";
31
+
32
+ /** Convert an Ed25519 public key (hex) to its X25519 (Montgomery) form. */
33
+ export function edPubToX25519(edPubHex) {
34
+ return Buffer.from(ed25519.utils.toMontgomery(Buffer.from(edPubHex, "hex")));
35
+ }
36
+
37
+ /** Convert an Ed25519 seed (32 raw private bytes) to an X25519 secret. */
38
+ export function edSeedToX25519(seed32) {
39
+ return Buffer.from(ed25519.utils.toMontgomerySecret(seed32));
40
+ }
41
+
42
+ function deriveKey(shared, epk, recipientXPub) {
43
+ const salt = Buffer.concat([epk, recipientXPub]);
44
+ return Buffer.from(hkdfSync("sha256", shared, salt, HKDF_INFO, 32));
45
+ }
46
+
47
+ /**
48
+ * Seal a payload object to a recipient's Ed25519 public key.
49
+ * Returns the `enc` block for the envelope.
50
+ */
51
+ export function sealPayload(recipientEdPubHex, payload, { aad }) {
52
+ if (!aad) throw new Error("sealPayload: aad (envelope id) required");
53
+ const recipientXPub = edPubToX25519(recipientEdPubHex);
54
+ const ephemeralSecret = randomBytes(32);
55
+ const epk = Buffer.from(x25519.getPublicKey(ephemeralSecret));
56
+ const shared = Buffer.from(x25519.getSharedSecret(ephemeralSecret, recipientXPub));
57
+ const key = deriveKey(shared, epk, recipientXPub);
58
+ const iv = randomBytes(12);
59
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
60
+ cipher.setAAD(Buffer.from(aad, "utf-8"));
61
+ const plaintext = Buffer.from(jcs(payload), "utf-8");
62
+ const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]);
63
+ const tag = cipher.getAuthTag();
64
+ return {
65
+ alg: E2E_ALG,
66
+ epk: epk.toString("hex"),
67
+ iv: iv.toString("hex"),
68
+ ct: Buffer.concat([ct, tag]).toString("hex"), // tag appended, last 16 bytes
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Open a sealed payload with the recipient's Ed25519 seed (32 bytes).
74
+ * Throws on any integrity failure (wrong key, tamper, transplanted aad).
75
+ */
76
+ export function openPayload(recipientSeed32, enc, { aad }) {
77
+ if (!enc || enc.alg !== E2E_ALG) throw new Error(`openPayload: unsupported alg ${enc?.alg}`);
78
+ if (!aad) throw new Error("openPayload: aad (envelope id) required");
79
+ const xSecret = edSeedToX25519(recipientSeed32);
80
+ const recipientXPub = Buffer.from(x25519.getPublicKey(xSecret));
81
+ const epk = Buffer.from(enc.epk, "hex");
82
+ const shared = Buffer.from(x25519.getSharedSecret(xSecret, epk));
83
+ const key = deriveKey(shared, epk, recipientXPub);
84
+ const raw = Buffer.from(enc.ct, "hex");
85
+ const ct = raw.subarray(0, raw.length - 16);
86
+ const tag = raw.subarray(raw.length - 16);
87
+ const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(enc.iv, "hex"));
88
+ decipher.setAAD(Buffer.from(aad, "utf-8"));
89
+ decipher.setAuthTag(tag);
90
+ const plaintext = Buffer.concat([decipher.update(ct), decipher.final()]);
91
+ return JSON.parse(plaintext.toString("utf-8"));
92
+ }
93
+
94
+ /** sha256 helper for identity binding checks. */
95
+ export function sha256Hex(buf) {
96
+ return createHash("sha256").update(buf).digest("hex");
97
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * RFC 8785 JCS canonicalization. Same implementation as the pillar-core reference,
3
+ * kept independent so the pillar-node package has no upward dependency.
4
+ *
5
+ * Numbers: integers only (safe for the wire we design; enforce it). Floats
6
+ * throw because deterministic ECMA-262 number serialization requires more code
7
+ * than we want in a reference impl and this package's message payloads never
8
+ * legitimately contain non-integer numbers.
9
+ */
10
+ export function jcs(value) {
11
+ return _ser(value);
12
+ }
13
+
14
+ function _ser(v) {
15
+ if (v === null) return "null";
16
+ if (v === true) return "true";
17
+ if (v === false) return "false";
18
+ if (typeof v === "string") return JSON.stringify(v);
19
+ if (typeof v === "number") {
20
+ if (!Number.isFinite(v)) throw new Error("JCS: NaN/Infinity not permitted");
21
+ if (Number.isInteger(v)) return String(v);
22
+ throw new Error("JCS: non-integer numbers not permitted; use string decimal encoding");
23
+ }
24
+ if (typeof v === "bigint") return v.toString();
25
+ if (Array.isArray(v)) return "[" + v.map(_ser).join(",") + "]";
26
+ if (typeof v === "object") {
27
+ // UTF-16 code unit sort — the RFC 8785 requirement.
28
+ const keys = Object.keys(v).sort((a, b) => {
29
+ const ab = Buffer.from(a, "utf16le"), bb = Buffer.from(b, "utf16le");
30
+ return Buffer.compare(ab, bb);
31
+ });
32
+ return "{" + keys.map(k => JSON.stringify(k) + ":" + _ser(v[k])).join(",") + "}";
33
+ }
34
+ throw new Error("JCS: unsupported type " + typeof v);
35
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Keychain — persistent identity for a pillar peer.
3
+ *
4
+ * Every agent has exactly one identity: an Ed25519 keypair. The private key
5
+ * is stored encrypted at rest with a password derived from the OS keyring
6
+ * (Windows Credential Manager / macOS Keychain / GNOME libsecret) if
7
+ * available, or a user-supplied passphrase otherwise.
8
+ *
9
+ * The keychain is deliberately simple: one keypair, one UUAID, one file.
10
+ * Rotation is a new keypair + a new signed profile in the DHT.
11
+ */
12
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from "node:fs";
13
+ import { dirname } from "node:path";
14
+ import { randomBytes, createHash, generateKeyPairSync, sign, verify, createPublicKey, createPrivateKey, hkdfSync, createCipheriv, createDecipheriv } from "node:crypto";
15
+
16
+ const KEYCHAIN_VERSION = 1;
17
+
18
+ export class Keychain {
19
+ constructor(path) {
20
+ this.path = path;
21
+ this._identity = null; // { uuaid, publicKeyHex, privateKey (KeyObject), createdAt }
22
+ }
23
+
24
+ exists() {
25
+ return existsSync(this.path);
26
+ }
27
+
28
+ /**
29
+ * Generate a fresh identity. Never overwrites.
30
+ *
31
+ * uuaidNamespace: default "foundation" per IAASO-1001 today; will support
32
+ * federated forms once federation profile ratifies.
33
+ * objectType: default "agent"; overridable to "service" etc.
34
+ * localId: default random UUIDv4-like; caller may supply.
35
+ */
36
+ static generate({ uuaidNamespace = "foundation", objectType = "agent", localId = null } = {}) {
37
+ const { publicKey, privateKey } = generateKeyPairSync("ed25519");
38
+ const pubDer = publicKey.export({ type: "spki", format: "der" });
39
+ const rawPub = pubDer.slice(pubDer.length - 32);
40
+ const localIdActual = localId ?? Keychain._localIdFromKey(rawPub);
41
+ const uuaid = `uuaid:${uuaidNamespace}:${objectType}:${localIdActual}`;
42
+ return {
43
+ uuaid,
44
+ publicKey,
45
+ privateKey,
46
+ publicKeyHex: Buffer.from(rawPub).toString("hex"),
47
+ createdAt: new Date().toISOString(),
48
+ };
49
+ }
50
+
51
+ static _localIdFromKey(rawPub) {
52
+ // Derive a stable, publishable local id from the public key. This makes
53
+ // the UUAID self-authenticating: given the UUAID, anyone can independently
54
+ // check that the public key hashes to the expected local-id.
55
+ const h = createHash("sha256").update(rawPub).digest();
56
+ // First 16 bytes -> UUID-style dash format for readability.
57
+ const b = h.slice(0, 16);
58
+ const hex = Buffer.from(b).toString("hex");
59
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
60
+ }
61
+
62
+ /**
63
+ * Load an identity from disk.
64
+ */
65
+ load({ passphrase } = {}) {
66
+ if (!this.exists()) throw new Error("keychain not initialized; run `pillar init` first");
67
+ const blob = JSON.parse(readFileSync(this.path, "utf-8"));
68
+ if (blob.version !== KEYCHAIN_VERSION) throw new Error(`unsupported keychain version ${blob.version}`);
69
+ const salt = Buffer.from(blob.salt, "hex");
70
+ const key = hkdfSync("sha256", Buffer.from(passphrase ?? "", "utf-8"), salt, "uuaid-pillar-keychain-v1", 32);
71
+ const iv = Buffer.from(blob.iv, "hex");
72
+ const ct = Buffer.from(blob.ciphertext, "base64");
73
+ const tag = Buffer.from(blob.tag, "hex");
74
+ const decipher = createDecipheriv("aes-256-gcm", Buffer.from(key), iv);
75
+ decipher.setAuthTag(tag);
76
+ const plain = Buffer.concat([decipher.update(ct), decipher.final()]);
77
+ const seed = plain; // 32-byte raw ed25519 seed
78
+ const pkcs8Prefix = Buffer.from("302e020100300506032b657004220420", "hex");
79
+ const privateKey = createPrivateKey({ key: Buffer.concat([pkcs8Prefix, seed]), format: "der", type: "pkcs8" });
80
+ const publicKey = createPublicKey(privateKey);
81
+ const pubDer = publicKey.export({ type: "spki", format: "der" });
82
+ const rawPub = pubDer.slice(pubDer.length - 32);
83
+ const publicKeyHex = Buffer.from(rawPub).toString("hex");
84
+ // Sanity: derived UUAID must match stored UUAID.
85
+ const derivedLocalId = Keychain._localIdFromKey(rawPub);
86
+ const parts = blob.uuaid.split(":");
87
+ if (parts.length < 4 || parts[3] !== derivedLocalId) {
88
+ throw new Error("keychain integrity failure: UUAID does not match keypair");
89
+ }
90
+ this._identity = { uuaid: blob.uuaid, publicKey, privateKey, publicKeyHex, createdAt: blob.createdAt };
91
+ return this._identity;
92
+ }
93
+
94
+ /**
95
+ * Save identity to disk, encrypted with the passphrase.
96
+ */
97
+ save(identity, { passphrase } = {}) {
98
+ const seedDer = identity.privateKey.export({ type: "pkcs8", format: "der" });
99
+ const seed = seedDer.slice(seedDer.length - 32);
100
+ const salt = randomBytes(16);
101
+ const key = hkdfSync("sha256", Buffer.from(passphrase ?? "", "utf-8"), salt, "uuaid-pillar-keychain-v1", 32);
102
+ const iv = randomBytes(12);
103
+ const cipher = createCipheriv("aes-256-gcm", Buffer.from(key), iv);
104
+ const ct = Buffer.concat([cipher.update(seed), cipher.final()]);
105
+ const tag = cipher.getAuthTag();
106
+ const blob = {
107
+ version: KEYCHAIN_VERSION,
108
+ uuaid: identity.uuaid,
109
+ createdAt: identity.createdAt,
110
+ salt: salt.toString("hex"),
111
+ iv: iv.toString("hex"),
112
+ ciphertext: ct.toString("base64"),
113
+ tag: tag.toString("hex"),
114
+ // Public material is stored in the clear so `pillar profile` doesn't need a passphrase.
115
+ publicKeyHex: identity.publicKeyHex,
116
+ };
117
+ mkdirSync(dirname(this.path), { recursive: true });
118
+ writeFileSync(this.path, JSON.stringify(blob, null, 2));
119
+ chmodSync(this.path, 0o600);
120
+ this._identity = identity;
121
+ }
122
+
123
+ publicView() {
124
+ if (!this.exists()) return null;
125
+ const blob = JSON.parse(readFileSync(this.path, "utf-8"));
126
+ return { uuaid: blob.uuaid, publicKeyHex: blob.publicKeyHex, createdAt: blob.createdAt };
127
+ }
128
+
129
+ sign(data) {
130
+ if (!this._identity) throw new Error("identity not loaded");
131
+ return sign(null, Buffer.isBuffer(data) ? data : Buffer.from(data, "utf-8"), this._identity.privateKey);
132
+ }
133
+
134
+ static verifyDetached(publicKeyHex, data, signatureBytes) {
135
+ const spkiPrefix = Buffer.from("302a300506032b6570032100", "hex");
136
+ const der = Buffer.concat([spkiPrefix, Buffer.from(publicKeyHex, "hex")]);
137
+ const pub = createPublicKey({ key: der, format: "der", type: "spki" });
138
+ return verify(null, Buffer.isBuffer(data) ? data : Buffer.from(data, "utf-8"), pub, signatureBytes);
139
+ }
140
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @uuaid/pillar — v0.2.0-alpha.0
3
+ *
4
+ * IAASO-3050 reference: every agent runs its own PDP pillar, discovers peers
5
+ * via a Kademlia DHT, exchanges cryptographically signed messages over libp2p.
6
+ *
7
+ * Entry point. Not intended for import in browsers today — browser build
8
+ * lives at @uuaid/pillar-browser (later).
9
+ */
10
+ export { Keychain } from "./identity/keychain.mjs";
11
+ export { jcs } from "./identity/jcs.mjs";
12
+ export { seal, open, decrypt, envelopeSha, keychainSeed, ENVELOPE_VERSION, E2E_ALG } from "./net/envelope.mjs";
13
+ export { sealPayload, openPayload, edPubToX25519, edSeedToX25519 } from "./crypto/e2e.mjs";
14
+ export { Mailbox } from "./mailbox/mailbox.mjs";
15
+ export { Pillar } from "./pillar.mjs";
16
+ export { createTransport, PILLAR_PROTOCOL, uuaidToDhtKey, peerIdFromEd25519Hex } from "./net/transport.mjs";
17
+ export { CarrierClient } from "./net/carrier-client.mjs";
18
+ export { CarrierServer } from "./net/carrier-server.mjs";
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Mailbox — per-agent SQLite outbox + inbox with per-log chain hashes.
3
+ *
4
+ * Every agent owns its own state. This is the local truth. Peers observe
5
+ * envelopes in flight, but the sender's outbox chain and the recipient's
6
+ * inbox chain are the authoritative record from each party's perspective.
7
+ * Disagreements are provable via chain-hash comparison.
8
+ *
9
+ * Two tables:
10
+ * outbox: envelopes this agent has sent. Chain-hashed.
11
+ * inbox : envelopes this agent has received. Chain-hashed independently.
12
+ *
13
+ * A "chained hash" over a log is:
14
+ * chain[0] = sha256("uuaid-pillar-mailbox-v1|" + envelope_sha256[0])
15
+ * chain[n] = sha256(chain[n-1] + envelope_sha256[n])
16
+ * Where envelope_sha256[i] = sha256(JCS(envelope[i])).
17
+ */
18
+ import Database from "better-sqlite3";
19
+ import { createHash } from "node:crypto";
20
+ import { mkdirSync } from "node:fs";
21
+ import { dirname } from "node:path";
22
+ import { jcs } from "../identity/jcs.mjs";
23
+
24
+ const SCHEMA = `
25
+ create table if not exists outbox (
26
+ seq integer primary key autoincrement,
27
+ envelope_id text not null unique,
28
+ envelope_sha256 text not null,
29
+ recipient text not null,
30
+ created_at text not null,
31
+ envelope_json text not null,
32
+ chain_hash text not null,
33
+ status text not null default 'pending',
34
+ delivered_at text,
35
+ ack_sha256 text
36
+ );
37
+ create index if not exists outbox_recipient on outbox (recipient);
38
+ create index if not exists outbox_status on outbox (status);
39
+
40
+ create table if not exists inbox (
41
+ seq integer primary key autoincrement,
42
+ envelope_id text not null unique,
43
+ envelope_sha256 text not null,
44
+ sender text not null,
45
+ received_at text not null,
46
+ envelope_json text not null,
47
+ chain_hash text not null,
48
+ read_at text
49
+ );
50
+ create index if not exists inbox_sender on inbox (sender);
51
+
52
+ create table if not exists carrier_cursors (
53
+ carrier text primary key,
54
+ seq integer not null
55
+ );
56
+
57
+ create table if not exists chain_state (
58
+ log_name text primary key,
59
+ chain_head text not null
60
+ );
61
+ `;
62
+
63
+ const CHAIN_SEED = "uuaid-pillar-mailbox-v1|";
64
+
65
+ export class Mailbox {
66
+ constructor(path) {
67
+ mkdirSync(dirname(path), { recursive: true });
68
+ this.db = new Database(path);
69
+ this.db.pragma("journal_mode = WAL");
70
+ this.db.pragma("synchronous = NORMAL");
71
+ this.db.exec(SCHEMA);
72
+ }
73
+
74
+ close() { try { this.db.close(); } catch (_e) {} }
75
+
76
+ /** Carrier polling cursors: carrier URL -> highest seq we have consumed. */
77
+ getCarrierCursors() {
78
+ const rows = this.db.prepare("select carrier, seq from carrier_cursors").all();
79
+ return Object.fromEntries(rows.map(r => [r.carrier, r.seq]));
80
+ }
81
+
82
+ setCarrierCursor(carrier, seq) {
83
+ this.db.prepare(
84
+ "insert into carrier_cursors (carrier, seq) values (?, ?) on conflict(carrier) do update set seq = max(seq, excluded.seq)"
85
+ ).run(carrier, seq);
86
+ }
87
+
88
+ _getHead(logName) {
89
+ const row = this.db.prepare("select chain_head from chain_state where log_name = ?").get(logName);
90
+ return row?.chain_head ?? null;
91
+ }
92
+ _setHead(logName, hash) {
93
+ this.db.prepare("insert into chain_state (log_name, chain_head) values (?, ?) on conflict (log_name) do update set chain_head = excluded.chain_head").run(logName, hash);
94
+ }
95
+
96
+ static envelopeSha(envelope) {
97
+ return createHash("sha256").update(jcs(envelope)).digest("hex");
98
+ }
99
+
100
+ /**
101
+ * Append an outgoing envelope. Envelope must be a JS object that is JCS-canonicalizable
102
+ * and MUST include at least `id`, `recipient`, `createdAt`. Chain hash is computed
103
+ * atomically with the insert; concurrent appends are serialized via the SQLite transaction.
104
+ */
105
+ appendOutbound(envelope) {
106
+ return this.db.transaction(() => {
107
+ const envSha = Mailbox.envelopeSha(envelope);
108
+ const prev = this._getHead("outbox");
109
+ const chainInput = prev == null ? CHAIN_SEED + envSha : prev + envSha;
110
+ const chain = createHash("sha256").update(chainInput).digest("hex");
111
+ this.db.prepare(`
112
+ insert into outbox (envelope_id, envelope_sha256, recipient, created_at, envelope_json, chain_hash, status)
113
+ values (?, ?, ?, ?, ?, ?, 'pending')
114
+ `).run(envelope.id, envSha, envelope.recipient, envelope.createdAt, JSON.stringify(envelope), chain);
115
+ this._setHead("outbox", chain);
116
+ return { envelopeId: envelope.id, envelopeSha256: envSha, chainHash: chain };
117
+ })();
118
+ }
119
+
120
+ /**
121
+ * Append an incoming envelope. Same shape. MUST be idempotent on envelope.id;
122
+ * a duplicate delivery does NOT re-append and does NOT advance the chain.
123
+ */
124
+ appendInbound(envelope) {
125
+ return this.db.transaction(() => {
126
+ const existing = this.db.prepare("select envelope_id, envelope_sha256 from inbox where envelope_id = ?").get(envelope.id);
127
+ if (existing) {
128
+ return { duplicate: true, envelopeId: envelope.id, envelopeSha256: existing.envelope_sha256 };
129
+ }
130
+ const envSha = Mailbox.envelopeSha(envelope);
131
+ const prev = this._getHead("inbox");
132
+ const chainInput = prev == null ? CHAIN_SEED + envSha : prev + envSha;
133
+ const chain = createHash("sha256").update(chainInput).digest("hex");
134
+ this.db.prepare(`
135
+ insert into inbox (envelope_id, envelope_sha256, sender, received_at, envelope_json, chain_hash)
136
+ values (?, ?, ?, ?, ?, ?)
137
+ `).run(envelope.id, envSha, envelope.sender ?? envelope.from ?? "unknown", new Date().toISOString(), JSON.stringify(envelope), chain);
138
+ this._setHead("inbox", chain);
139
+ return { duplicate: false, envelopeId: envelope.id, envelopeSha256: envSha, chainHash: chain };
140
+ })();
141
+ }
142
+
143
+ markOutboundDelivered(envelopeId, ackSha256) {
144
+ this.db.prepare("update outbox set status = 'delivered', delivered_at = ?, ack_sha256 = ? where envelope_id = ?")
145
+ .run(new Date().toISOString(), ackSha256 ?? null, envelopeId);
146
+ }
147
+
148
+ outboxTip() { return this._getHead("outbox") ?? "0".repeat(64); }
149
+ inboxTip() { return this._getHead("inbox") ?? "0".repeat(64); }
150
+
151
+ outboxSince(seq = 0, limit = 100) {
152
+ return this.db.prepare("select * from outbox where seq > ? order by seq asc limit ?").all(seq, limit);
153
+ }
154
+ inboxSince(seq = 0, limit = 100) {
155
+ return this.db.prepare("select * from inbox where seq > ? order by seq asc limit ?").all(seq, limit);
156
+ }
157
+
158
+ /**
159
+ * Recompute the outbox chain from scratch and compare to stored chain_hash column.
160
+ * Returns { ok, badSeq? }. Used by adversarial tests + operator-driven audits.
161
+ */
162
+ auditOutbox() {
163
+ const rows = this.db.prepare("select seq, envelope_sha256, chain_hash from outbox order by seq asc").all();
164
+ let prev = null;
165
+ for (const r of rows) {
166
+ const chainInput = prev == null ? CHAIN_SEED + r.envelope_sha256 : prev + r.envelope_sha256;
167
+ const expected = createHash("sha256").update(chainInput).digest("hex");
168
+ if (expected !== r.chain_hash) return { ok: false, badSeq: r.seq };
169
+ prev = r.chain_hash;
170
+ }
171
+ return { ok: true };
172
+ }
173
+ auditInbox() {
174
+ const rows = this.db.prepare("select seq, envelope_sha256, chain_hash from inbox order by seq asc").all();
175
+ let prev = null;
176
+ for (const r of rows) {
177
+ const chainInput = prev == null ? CHAIN_SEED + r.envelope_sha256 : prev + r.envelope_sha256;
178
+ const expected = createHash("sha256").update(chainInput).digest("hex");
179
+ if (expected !== r.chain_hash) return { ok: false, badSeq: r.seq };
180
+ prev = r.chain_hash;
181
+ }
182
+ return { ok: true };
183
+ }
184
+ }