@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,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Carrier client — lets a pillar deliver + receive envelopes through one or
|
|
3
|
+
* more carrier servers using only outbound HTTP(S).
|
|
4
|
+
*
|
|
5
|
+
* This is the universal transport: works from sandboxes, NATed laptops,
|
|
6
|
+
* serverless, anywhere. No listening socket required.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export class CarrierClient {
|
|
10
|
+
/**
|
|
11
|
+
* @param {{keychain: import('../identity/keychain.mjs').Keychain, carriers: string[]}} opts
|
|
12
|
+
* carriers: base URLs, e.g. ["https://pillar.uuaid.org"]
|
|
13
|
+
*/
|
|
14
|
+
constructor({ keychain, carriers }) {
|
|
15
|
+
if (!keychain?._identity) throw new Error("CarrierClient needs a loaded keychain");
|
|
16
|
+
if (!carriers?.length) throw new Error("CarrierClient needs at least one carrier URL");
|
|
17
|
+
this.keychain = keychain;
|
|
18
|
+
this.carriers = carriers.map(u => u.replace(/\/+$/, ""));
|
|
19
|
+
this._stopPolling = false;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
get uuaid() { return this.keychain._identity.uuaid; }
|
|
23
|
+
get publicKeyHex() { return this.keychain._identity.publicKeyHex; }
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Deliver an envelope to ALL carriers in parallel — p2p seed replication:
|
|
27
|
+
* the message lands on every configured seed (like a file seeded on many
|
|
28
|
+
* peers), so it survives any single carrier dying before the recipient
|
|
29
|
+
* polls. Duplicate copies are harmless: the recipient mailbox dedups on
|
|
30
|
+
* envelope.id and re-fires no handlers.
|
|
31
|
+
*
|
|
32
|
+
* Resolves once every carrier has answered. Succeeds if at least one
|
|
33
|
+
* accepted; throws if every carrier rejects. Returns the first-in-list
|
|
34
|
+
* accepting receipt as the primary ({ carrier, seq, sha, duplicate }) plus
|
|
35
|
+
* `all` with every carrier's outcome ({ carrier, ok, seq?, sha?,
|
|
36
|
+
* duplicate?, error? }).
|
|
37
|
+
*/
|
|
38
|
+
async deliver(envelope, { timeoutMs = 10000 } = {}) {
|
|
39
|
+
const attempt = async (base) => {
|
|
40
|
+
try {
|
|
41
|
+
const res = await fetch(`${base}/v1/envelopes`, {
|
|
42
|
+
method: "POST",
|
|
43
|
+
headers: { "content-type": "application/json" },
|
|
44
|
+
body: JSON.stringify(envelope),
|
|
45
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
46
|
+
});
|
|
47
|
+
const body = await res.json();
|
|
48
|
+
if (res.status === 202 && body.accepted) {
|
|
49
|
+
return { carrier: base, ok: true, seq: body.seq, sha: body.sha, duplicate: !!body.duplicate };
|
|
50
|
+
}
|
|
51
|
+
return { carrier: base, ok: false, error: `${res.status} ${body.reason ?? body.error ?? ""}` };
|
|
52
|
+
} catch (e) {
|
|
53
|
+
return { carrier: base, ok: false, error: e.message };
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
const all = await Promise.allSettled(this.carriers.map(attempt)).then(rs =>
|
|
57
|
+
rs.map(r => r.status === "fulfilled" ? r.value : { carrier: "?", ok: false, error: String(r.reason) }));
|
|
58
|
+
const accepted = all.filter(r => r.ok);
|
|
59
|
+
if (!accepted.length) {
|
|
60
|
+
throw new Error(`carrier deliver failed on all carriers: ${all.map(r => `${r.carrier}: ${r.error}`).join("; ")}`);
|
|
61
|
+
}
|
|
62
|
+
const { carrier, seq, sha, duplicate } = accepted[0];
|
|
63
|
+
return { carrier, seq, sha, duplicate, all };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Fetch envelopes addressed to us with seq > since from a specific carrier.
|
|
68
|
+
* Signed request; carrier verifies our key before serving.
|
|
69
|
+
*/
|
|
70
|
+
async fetchInbox(base, { since = 0, waitS = 0, timeoutMs = 40000 } = {}) {
|
|
71
|
+
const path = `GET /v1/inbox/${this.uuaid}?since=${since}`;
|
|
72
|
+
const ts = String(Date.now());
|
|
73
|
+
const sig = this.keychain.sign(Buffer.from(`${path}\n${ts}`, "utf-8")).toString("hex");
|
|
74
|
+
const res = await fetch(`${base}/v1/inbox/${encodeURIComponent(this.uuaid)}?since=${since}&wait=${waitS}`, {
|
|
75
|
+
headers: {
|
|
76
|
+
"x-pillar-pubkey": this.publicKeyHex,
|
|
77
|
+
"x-pillar-ts": ts,
|
|
78
|
+
"x-pillar-sig": sig,
|
|
79
|
+
},
|
|
80
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
81
|
+
});
|
|
82
|
+
if (res.status !== 200) {
|
|
83
|
+
const body = await res.json().catch(() => ({}));
|
|
84
|
+
throw new Error(`inbox fetch ${base}: ${res.status} ${body.error ?? ""}`);
|
|
85
|
+
}
|
|
86
|
+
return res.json(); // { envelopes: [{seq, envelope}], now }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Long-poll every carrier in parallel forever, invoking onEnvelope for each
|
|
91
|
+
* new envelope. Tracks per-carrier cursor. Returns a stop() function.
|
|
92
|
+
*/
|
|
93
|
+
startPolling({ onEnvelope, cursors = {}, waitS = 25, onError }) {
|
|
94
|
+
this._stopPolling = false;
|
|
95
|
+
const state = { cursors: { ...cursors } };
|
|
96
|
+
const loops = this.carriers.map(async (base) => {
|
|
97
|
+
while (!this._stopPolling) {
|
|
98
|
+
try {
|
|
99
|
+
const r = await this.fetchInbox(base, { since: state.cursors[base] ?? 0, waitS });
|
|
100
|
+
for (const { seq, envelope } of r.envelopes) {
|
|
101
|
+
state.cursors[base] = Math.max(state.cursors[base] ?? 0, seq);
|
|
102
|
+
try { await onEnvelope(envelope, { carrier: base, seq }); }
|
|
103
|
+
catch (e) { onError?.(new Error(`onEnvelope threw: ${e.message}`)); }
|
|
104
|
+
}
|
|
105
|
+
} catch (e) {
|
|
106
|
+
onError?.(e);
|
|
107
|
+
// Back off on error so a dead carrier doesn't spin the CPU.
|
|
108
|
+
await new Promise(r2 => setTimeout(r2, 2000));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
return {
|
|
113
|
+
stop: () => { this._stopPolling = true; },
|
|
114
|
+
cursors: () => ({ ...state.cursors }),
|
|
115
|
+
done: Promise.allSettled(loops),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Carrier server — the universal store-and-forward relay.
|
|
3
|
+
*
|
|
4
|
+
* An always-on node (e.g. a DO droplet) that lets ANY two agents exchange
|
|
5
|
+
* signed envelopes even when NEITHER can accept an inbound connection.
|
|
6
|
+
* The only capability a participant needs is outbound HTTPS.
|
|
7
|
+
*
|
|
8
|
+
* Protocol (/carrier/v1):
|
|
9
|
+
*
|
|
10
|
+
* POST /v1/envelopes
|
|
11
|
+
* body: envelope JSON (sealed by sender's pillar)
|
|
12
|
+
* - carrier verifies the transport signature before accepting (fail closed)
|
|
13
|
+
* - idempotent on envelope.id
|
|
14
|
+
* -> 202 { accepted: true, seq, sha }
|
|
15
|
+
*
|
|
16
|
+
* GET /v1/inbox/<uuaid>?since=<seq>&wait=<seconds>
|
|
17
|
+
* headers:
|
|
18
|
+
* x-pillar-pubkey: <ed25519 pubkey hex>
|
|
19
|
+
* x-pillar-ts: <unix ms> (rejected if clock skew > 5 min)
|
|
20
|
+
* x-pillar-sig: hex(sign("GET /v1/inbox/<uuaid>?since=<seq>" + "\n" + ts))
|
|
21
|
+
* - carrier derives UUAID from pubkey; must equal <uuaid> (self-authenticating,
|
|
22
|
+
* zero registration)
|
|
23
|
+
* - long-polls up to `wait` seconds (max 30)
|
|
24
|
+
* -> 200 { envelopes: [{seq, envelope}], now }
|
|
25
|
+
*
|
|
26
|
+
* GET /v1/health
|
|
27
|
+
* -> 200 { ok, uptime, stored }
|
|
28
|
+
*
|
|
29
|
+
* Durability: SQLite (WAL). Envelopes are retained until TTL (default 14 days).
|
|
30
|
+
* The carrier CANNOT read payloads it relays beyond what the envelope exposes,
|
|
31
|
+
* and CANNOT forge envelopes (no keys). It can only: deliver, delay, or drop —
|
|
32
|
+
* and drops are detectable by sequence gaps + sender outbox audit.
|
|
33
|
+
*/
|
|
34
|
+
import { createServer } from "node:http";
|
|
35
|
+
import { createHash } from "node:crypto";
|
|
36
|
+
import Database from "better-sqlite3";
|
|
37
|
+
import { open as openEnvelope } from "./envelope.mjs";
|
|
38
|
+
import { Keychain } from "../identity/keychain.mjs";
|
|
39
|
+
|
|
40
|
+
const MAX_WAIT_S = 30;
|
|
41
|
+
const MAX_BODY = 512 * 1024; // 512 KiB per envelope
|
|
42
|
+
const MAX_SKEW_MS = 5 * 60 * 1000;
|
|
43
|
+
// Hardening (RedTeam F2/F3):
|
|
44
|
+
const MAX_ENVELOPE_AGE_MS = 24 * 3600_000; // F3: ingest freshness — reject skewed createdAt
|
|
45
|
+
const RATE_WINDOW_MS = 60_000; // F2: sliding window for all rate limits
|
|
46
|
+
const MAX_ENVELOPES_PER_MIN = 120; // F2: per claimed-sender ingest budget
|
|
47
|
+
const MAX_GOOD_PER_MIN = 300; // F2: per client-IP budget for accepted sends
|
|
48
|
+
const MAX_INVALID_PER_MIN = 60; // F2: per client-IP budget for rejected sends
|
|
49
|
+
const MAX_CONCURRENT_WAITS = 64; // F2: inbox long-poll concurrency cap
|
|
50
|
+
const MAX_DB_BYTES = 256 * 1024 * 1024; // F2: SQLite size backpressure (503 when full)
|
|
51
|
+
const RATE_MAP_HARD_CAP = 10_000; // F2: bound memory under key spray
|
|
52
|
+
|
|
53
|
+
export class CarrierServer {
|
|
54
|
+
constructor({ dbPath = "carrier.db", ttlDays = 14, cryptoInventory } = {}) {
|
|
55
|
+
this.db = new Database(dbPath);
|
|
56
|
+
this.db.pragma("journal_mode = WAL");
|
|
57
|
+
this.db.exec(`
|
|
58
|
+
create table if not exists envelopes (
|
|
59
|
+
seq integer primary key autoincrement,
|
|
60
|
+
envelope_id text not null unique,
|
|
61
|
+
recipient text not null,
|
|
62
|
+
sender text not null,
|
|
63
|
+
sha text not null,
|
|
64
|
+
body text not null,
|
|
65
|
+
created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
66
|
+
);
|
|
67
|
+
create index if not exists idx_envelopes_recipient on envelopes(recipient, seq);
|
|
68
|
+
`);
|
|
69
|
+
this.ttlDays = ttlDays;
|
|
70
|
+
this.cryptoInventory = cryptoInventory ?? { ed25519: "active" };
|
|
71
|
+
this.startedAt = Date.now();
|
|
72
|
+
this.server = null;
|
|
73
|
+
this.rateMap = new Map(); // F2: "<kind>:<key>" -> [ms timestamps]
|
|
74
|
+
this.activeWaits = 0; // F2: long-polls currently sleeping
|
|
75
|
+
this._closed = false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** F2: best-effort client identity, nginx-aware (XFF set by proxy). */
|
|
79
|
+
_clientIp(req) {
|
|
80
|
+
const fwd = req.headers["x-forwarded-for"];
|
|
81
|
+
if (fwd) return String(fwd).split(",")[0].trim();
|
|
82
|
+
return req.socket.remoteAddress ?? "unknown";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** F2: sliding-window allow. Returns false when over budget. */
|
|
86
|
+
_rateAllow(key, limit) {
|
|
87
|
+
const now = Date.now();
|
|
88
|
+
let arr = this.rateMap.get(key);
|
|
89
|
+
if (!arr) {
|
|
90
|
+
if (this.rateMap.size >= RATE_MAP_HARD_CAP) {
|
|
91
|
+
for (const [k, v] of this.rateMap) {
|
|
92
|
+
while (v.length && v[0] < now - RATE_WINDOW_MS) v.shift();
|
|
93
|
+
if (v.length === 0) this.rateMap.delete(k);
|
|
94
|
+
}
|
|
95
|
+
if (this.rateMap.size >= RATE_MAP_HARD_CAP) {
|
|
96
|
+
this.rateMap.delete(this.rateMap.keys().next().value); // shed oldest
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
arr = [];
|
|
100
|
+
this.rateMap.set(key, arr);
|
|
101
|
+
}
|
|
102
|
+
const cutoff = now - RATE_WINDOW_MS;
|
|
103
|
+
while (arr.length && arr[0] < cutoff) arr.shift();
|
|
104
|
+
if (arr.length >= limit) return false;
|
|
105
|
+
arr.push(now);
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Remove envelopes older than TTL. Call periodically. */
|
|
110
|
+
vacuum() {
|
|
111
|
+
const cutoff = new Date(Date.now() - this.ttlDays * 86400_000).toISOString();
|
|
112
|
+
const r = this.db.prepare("delete from envelopes where created_at < ?").run(cutoff);
|
|
113
|
+
return r.changes;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
_json(res, code, obj) {
|
|
117
|
+
// No-op on a dead socket: close() destroys connections, and a long-poll
|
|
118
|
+
// handler whose timer fires afterwards must not throw on write.
|
|
119
|
+
if (res.destroyed || res.writableEnded) return;
|
|
120
|
+
const body = JSON.stringify(obj);
|
|
121
|
+
res.writeHead(code, { "content-type": "application/json", "content-length": Buffer.byteLength(body) });
|
|
122
|
+
res.end(body);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async _readBody(req) {
|
|
126
|
+
return new Promise((resolve, reject) => {
|
|
127
|
+
let size = 0; const chunks = [];
|
|
128
|
+
req.on("data", (c) => {
|
|
129
|
+
size += c.length;
|
|
130
|
+
if (size > MAX_BODY) { reject(new Error("body too large")); req.destroy(); return; }
|
|
131
|
+
chunks.push(c);
|
|
132
|
+
});
|
|
133
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
|
134
|
+
req.on("error", reject);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
_handlePost(envelope, ip = "unknown") {
|
|
139
|
+
const verdict = openEnvelope(envelope, { cryptoInventory: this.cryptoInventory });
|
|
140
|
+
if (!verdict.ok) {
|
|
141
|
+
// F2: cap rejected sends per client (flood the verifier → 429).
|
|
142
|
+
if (!this._rateAllow(`bad:${ip}`, MAX_INVALID_PER_MIN)) {
|
|
143
|
+
return { code: 429, body: { accepted: false, reason: "rate-limited" } };
|
|
144
|
+
}
|
|
145
|
+
return { code: 400, body: { accepted: false, reason: verdict.reason } };
|
|
146
|
+
}
|
|
147
|
+
if (typeof envelope.recipient !== "string" || !envelope.recipient.startsWith("uuaid:")) {
|
|
148
|
+
return { code: 400, body: { accepted: false, reason: "bad-recipient" } };
|
|
149
|
+
}
|
|
150
|
+
// F2: per-sender ingest budget — a sender uuaid cannot flood its carrier.
|
|
151
|
+
if (!this._rateAllow(`s:${envelope.sender}`, MAX_ENVELOPES_PER_MIN)) {
|
|
152
|
+
return { code: 429, body: { accepted: false, reason: "rate-limited" } };
|
|
153
|
+
}
|
|
154
|
+
// F2: per-client budget on ACCEPTED sends — closes the unlimited
|
|
155
|
+
// fresh-keypair spray (each new key gets a fresh sender budget).
|
|
156
|
+
if (!this._rateAllow(`g:${ip}`, MAX_GOOD_PER_MIN)) {
|
|
157
|
+
return { code: 429, body: { accepted: false, reason: "rate-limited" } };
|
|
158
|
+
}
|
|
159
|
+
// F3: freshness — reject envelopes whose createdAt is missing or skewed
|
|
160
|
+
// > MAX_ENVELOPE_AGE_MS (covers both stale replay and future-dated).
|
|
161
|
+
const createdMs = Date.parse(envelope.createdAt);
|
|
162
|
+
if (!Number.isFinite(createdMs) || Math.abs(Date.now() - createdMs) > MAX_ENVELOPE_AGE_MS) {
|
|
163
|
+
return { code: 400, body: { accepted: false, reason: "stale-envelope" } };
|
|
164
|
+
}
|
|
165
|
+
// F2: backpressure — refuse ingest when the store is near-full instead of
|
|
166
|
+
// failing at disk-full mid-write.
|
|
167
|
+
const pages = this.db.pragma("page_count", { simple: true });
|
|
168
|
+
const pageBytes = this.db.pragma("page_size", { simple: true });
|
|
169
|
+
if (pages * pageBytes > MAX_DB_BYTES) {
|
|
170
|
+
return { code: 503, body: { accepted: false, reason: "carrier-full" } };
|
|
171
|
+
}
|
|
172
|
+
const sha = createHash("sha256").update(JSON.stringify(envelope)).digest("hex");
|
|
173
|
+
try {
|
|
174
|
+
const r = this.db.prepare(
|
|
175
|
+
"insert into envelopes (envelope_id, recipient, sender, sha, body) values (?,?,?,?,?)"
|
|
176
|
+
).run(envelope.id, envelope.recipient, envelope.sender, sha, JSON.stringify(envelope));
|
|
177
|
+
return { code: 202, body: { accepted: true, seq: Number(r.lastInsertRowid), sha } };
|
|
178
|
+
} catch (e) {
|
|
179
|
+
if (/UNIQUE constraint/.test(e.message)) {
|
|
180
|
+
const row = this.db.prepare("select seq, sha from envelopes where envelope_id = ?").get(envelope.id);
|
|
181
|
+
return { code: 202, body: { accepted: true, duplicate: true, seq: row.seq, sha: row.sha } };
|
|
182
|
+
}
|
|
183
|
+
throw e;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
_verifyInboxAuth(req, uuaid, sincePath) {
|
|
188
|
+
const pubkey = req.headers["x-pillar-pubkey"];
|
|
189
|
+
const ts = req.headers["x-pillar-ts"];
|
|
190
|
+
const sig = req.headers["x-pillar-sig"];
|
|
191
|
+
if (!pubkey || !ts || !sig) return { ok: false, reason: "missing-auth-headers" };
|
|
192
|
+
if (!/^[0-9a-f]{64}$/i.test(pubkey)) return { ok: false, reason: "bad-pubkey" };
|
|
193
|
+
if (!/^[0-9a-f]{128}$/i.test(sig)) return { ok: false, reason: "bad-sig-encoding" };
|
|
194
|
+
const tsNum = Number(ts);
|
|
195
|
+
if (!Number.isFinite(tsNum) || Math.abs(Date.now() - tsNum) > MAX_SKEW_MS) {
|
|
196
|
+
return { ok: false, reason: "clock-skew" };
|
|
197
|
+
}
|
|
198
|
+
// Self-authenticating: UUAID must derive from the presented pubkey.
|
|
199
|
+
const localId = Keychain._localIdFromKey(Buffer.from(pubkey, "hex"));
|
|
200
|
+
const derived = `uuaid:foundation:agent:${localId}`;
|
|
201
|
+
if (derived !== uuaid) return { ok: false, reason: "uuaid-pubkey-mismatch" };
|
|
202
|
+
const message = Buffer.from(`${sincePath}\n${ts}`, "utf-8");
|
|
203
|
+
let verified = false;
|
|
204
|
+
try { verified = Keychain.verifyDetached(pubkey, message, Buffer.from(sig, "hex")); }
|
|
205
|
+
catch (_e) { return { ok: false, reason: "verify-threw" }; }
|
|
206
|
+
if (!verified) return { ok: false, reason: "bad-signature" };
|
|
207
|
+
return { ok: true };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async _handleInbox(req, res, uuaid, url) {
|
|
211
|
+
const since = Number(url.searchParams.get("since") ?? 0);
|
|
212
|
+
const wait = Math.min(Number(url.searchParams.get("wait") ?? 0), MAX_WAIT_S);
|
|
213
|
+
const sincePath = `GET /v1/inbox/${uuaid}?since=${since}`;
|
|
214
|
+
const auth = this._verifyInboxAuth(req, uuaid, sincePath);
|
|
215
|
+
if (!auth.ok) return this._json(res, 401, { error: auth.reason });
|
|
216
|
+
|
|
217
|
+
// F2: cap concurrent long-polls so sleeping connections cannot pin the
|
|
218
|
+
// event loop / fd table.
|
|
219
|
+
if (wait > 0 && this.activeWaits >= MAX_CONCURRENT_WAITS) {
|
|
220
|
+
return this._json(res, 503, { error: "busy" });
|
|
221
|
+
}
|
|
222
|
+
if (wait > 0) this.activeWaits++;
|
|
223
|
+
|
|
224
|
+
const fetchRows = () => {
|
|
225
|
+
// closed → the DB is gone; a long-poll timer armed before close() can
|
|
226
|
+
// fire afterwards. Never touch a closed connection.
|
|
227
|
+
if (this._closed) return [];
|
|
228
|
+
return this.db.prepare(
|
|
229
|
+
"select seq, body from envelopes where recipient = ? and seq > ? order by seq asc limit 200"
|
|
230
|
+
).all(uuaid, since);
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
let rows = fetchRows();
|
|
235
|
+
const deadline = Date.now() + wait * 1000;
|
|
236
|
+
while (rows.length === 0 && Date.now() < deadline && !this._closed) {
|
|
237
|
+
await new Promise(r => setTimeout(r, 400));
|
|
238
|
+
rows = fetchRows();
|
|
239
|
+
}
|
|
240
|
+
this._json(res, 200, {
|
|
241
|
+
envelopes: rows.map(r => ({ seq: r.seq, envelope: JSON.parse(r.body) })),
|
|
242
|
+
now: Date.now(),
|
|
243
|
+
});
|
|
244
|
+
} finally {
|
|
245
|
+
if (wait > 0) this.activeWaits--;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
listen(port = 0, host = "127.0.0.1") {
|
|
250
|
+
this.server = createServer(async (req, res) => {
|
|
251
|
+
try {
|
|
252
|
+
const url = new URL(req.url, `http://${req.headers.host ?? "localhost"}`);
|
|
253
|
+
if (req.method === "POST" && url.pathname === "/v1/envelopes") {
|
|
254
|
+
const raw = await this._readBody(req);
|
|
255
|
+
let envelope;
|
|
256
|
+
try { envelope = JSON.parse(raw); }
|
|
257
|
+
catch { return this._json(res, 400, { accepted: false, reason: "bad-json" }); }
|
|
258
|
+
const { code, body } = this._handlePost(envelope, this._clientIp(req));
|
|
259
|
+
return this._json(res, code, body);
|
|
260
|
+
}
|
|
261
|
+
const inboxMatch = /^\/v1\/inbox\/([^/]+)$/.exec(url.pathname);
|
|
262
|
+
if (req.method === "GET" && inboxMatch) {
|
|
263
|
+
const uuaid = decodeURIComponent(inboxMatch[1]);
|
|
264
|
+
if (!uuaid.startsWith("uuaid:")) return this._json(res, 404, { error: "not-found" });
|
|
265
|
+
return await this._handleInbox(req, res, uuaid, url);
|
|
266
|
+
}
|
|
267
|
+
if (req.method === "GET" && url.pathname === "/v1/health") {
|
|
268
|
+
const stored = this.db.prepare("select count(*) as n from envelopes").get().n;
|
|
269
|
+
return this._json(res, 200, { ok: true, uptimeMs: Date.now() - this.startedAt, stored });
|
|
270
|
+
}
|
|
271
|
+
this._json(res, 404, { error: "not-found" });
|
|
272
|
+
} catch (e) {
|
|
273
|
+
// F4: never leak internal error details (paths, stack, SQL text).
|
|
274
|
+
console.error(`[carrier] 500: ${e.message}`);
|
|
275
|
+
this._json(res, 500, { error: "internal-error" });
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
return new Promise((resolve) => {
|
|
279
|
+
this.server.listen(port, host, () => resolve(this.server.address()));
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async close() {
|
|
284
|
+
this._closed = true;
|
|
285
|
+
if (this.server) {
|
|
286
|
+
// close() must not wait for in-flight connections: a client long-polling
|
|
287
|
+
// this carrier re-issues requests on its keep-alive socket, so
|
|
288
|
+
// http.Server.close() would never resolve. Stop accepting first, then
|
|
289
|
+
// destroy every socket (Node >= 18.2; no-op below, covered by the race).
|
|
290
|
+
const closed = new Promise(r => this.server.close(r));
|
|
291
|
+
this.server.closeIdleConnections?.();
|
|
292
|
+
this.server.closeAllConnections?.();
|
|
293
|
+
// Belt-and-braces: never let a shutdown path hang on a pinned socket.
|
|
294
|
+
const deadline = new Promise(r => { const t = setTimeout(r, 3000); t.unref?.(); });
|
|
295
|
+
await Promise.race([closed, deadline]);
|
|
296
|
+
}
|
|
297
|
+
// Idempotent: the second close in test teardown (and any double-shutdown
|
|
298
|
+
// path) must be a no-op, not a throw.
|
|
299
|
+
if (this.db.open) this.db.close();
|
|
300
|
+
}
|
|
301
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Envelope v2 — the wire format for pillar-to-pillar messages.
|
|
3
|
+
*
|
|
4
|
+
* ENCRYPTION IS FUNDAMENTAL. There is no plaintext payload mode. Every
|
|
5
|
+
* envelope's payload is sealed to the recipient's key before the envelope
|
|
6
|
+
* is signed. Anyone can VERIFY an envelope (transport signature is public);
|
|
7
|
+
* only the recipient can READ it.
|
|
8
|
+
*
|
|
9
|
+
* Envelope v2 = {
|
|
10
|
+
* version: "uuaid-pillar-envelope/2",
|
|
11
|
+
* id: "env-...",
|
|
12
|
+
* sender: "uuaid:...",
|
|
13
|
+
* recipient: "uuaid:...",
|
|
14
|
+
* kind: "message" | "work-order" | ... (routing hint, NOT secret)
|
|
15
|
+
* createdAt: ISO 8601,
|
|
16
|
+
* enc: { alg: "x25519-hkdf-sha256-aes256gcm", epk, iv, ct }, // sealed payload
|
|
17
|
+
* transportSignature: { alg: "ed25519", keyId, publicKey, signature, created },
|
|
18
|
+
* }
|
|
19
|
+
*
|
|
20
|
+
* The transport signature covers everything (including the ciphertext), so
|
|
21
|
+
* tamper detection works WITHOUT decrypting — carriers verify-then-store.
|
|
22
|
+
* The GCM AAD binds the ciphertext to the envelope id, so a valid ciphertext
|
|
23
|
+
* cannot be transplanted into a different envelope even by the sender.
|
|
24
|
+
*
|
|
25
|
+
* seal() enforces identity binding: recipient UUAID local-id must equal
|
|
26
|
+
* sha256(recipientPublicKey) — you cannot encrypt to a key that doesn't own
|
|
27
|
+
* the UUAID you're addressing.
|
|
28
|
+
*/
|
|
29
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
30
|
+
import { jcs } from "../identity/jcs.mjs";
|
|
31
|
+
import { Keychain } from "../identity/keychain.mjs";
|
|
32
|
+
import { sealPayload, openPayload, E2E_ALG } from "../crypto/e2e.mjs";
|
|
33
|
+
|
|
34
|
+
export const ENVELOPE_VERSION = "uuaid-pillar-envelope/2";
|
|
35
|
+
export { E2E_ALG };
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Build, encrypt, and sign an envelope.
|
|
39
|
+
*
|
|
40
|
+
* keychain: a loaded Keychain (has .sign()).
|
|
41
|
+
* body: {
|
|
42
|
+
* recipient: "uuaid:..." REQUIRED
|
|
43
|
+
* recipientPublicKey: ed25519 pubkey hex REQUIRED (E2E is not optional)
|
|
44
|
+
* kind, payload, id, createdAt optional
|
|
45
|
+
* }
|
|
46
|
+
*/
|
|
47
|
+
export function seal(keychain, body) {
|
|
48
|
+
const identity = keychain._identity;
|
|
49
|
+
if (!identity) throw new Error("keychain not loaded");
|
|
50
|
+
const sender = body.sender ?? identity.uuaid;
|
|
51
|
+
if (sender !== identity.uuaid) {
|
|
52
|
+
throw new Error(`envelope.sender ${sender} does not match keychain identity ${identity.uuaid}`);
|
|
53
|
+
}
|
|
54
|
+
if (!body.recipient || typeof body.recipient !== "string") {
|
|
55
|
+
throw new Error("seal: recipient required");
|
|
56
|
+
}
|
|
57
|
+
if (!body.recipientPublicKey || !/^[0-9a-f]{64}$/i.test(body.recipientPublicKey)) {
|
|
58
|
+
throw new Error("seal: recipientPublicKey (ed25519 hex) is REQUIRED — encryption is not optional");
|
|
59
|
+
}
|
|
60
|
+
// Identity binding: the key must own the UUAID.
|
|
61
|
+
const expectedLocalId = Keychain._localIdFromKey(Buffer.from(body.recipientPublicKey, "hex"));
|
|
62
|
+
const recipientLocalId = body.recipient.split(":")[3];
|
|
63
|
+
if (recipientLocalId !== expectedLocalId) {
|
|
64
|
+
throw new Error(`seal: recipientPublicKey does not derive recipient UUAID (key owns ...${expectedLocalId.slice(0, 13)}, envelope addressed to ...${String(recipientLocalId).slice(0, 13)})`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const id = body.id ?? mkEnvelopeId();
|
|
68
|
+
const enc = sealPayload(body.recipientPublicKey, body.payload ?? {}, { aad: id });
|
|
69
|
+
|
|
70
|
+
const envelope = {
|
|
71
|
+
version: ENVELOPE_VERSION,
|
|
72
|
+
id,
|
|
73
|
+
sender,
|
|
74
|
+
recipient: body.recipient,
|
|
75
|
+
kind: body.kind ?? "message",
|
|
76
|
+
enc,
|
|
77
|
+
createdAt: body.createdAt ?? new Date().toISOString(),
|
|
78
|
+
};
|
|
79
|
+
const canonical = jcs(envelope);
|
|
80
|
+
const sigBytes = keychain.sign(Buffer.from(canonical, "utf-8"));
|
|
81
|
+
envelope.transportSignature = {
|
|
82
|
+
alg: "ed25519",
|
|
83
|
+
keyId: identity.uuaid,
|
|
84
|
+
publicKey: identity.publicKeyHex,
|
|
85
|
+
signature: sigBytes.toString("hex"),
|
|
86
|
+
created: envelope.createdAt,
|
|
87
|
+
};
|
|
88
|
+
return envelope;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Verify an envelope's transport signature and structure. Does NOT decrypt.
|
|
93
|
+
* Anyone (carriers, relays, auditors) can call this.
|
|
94
|
+
* Returns { ok: true } or { ok: false, reason }.
|
|
95
|
+
*/
|
|
96
|
+
export function open(envelope, { cryptoInventory } = {}) {
|
|
97
|
+
if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) return { ok: false, reason: "not-object" };
|
|
98
|
+
if (envelope.version !== ENVELOPE_VERSION) return { ok: false, reason: `bad-version ${envelope.version}` };
|
|
99
|
+
const sig = envelope.transportSignature;
|
|
100
|
+
if (!sig || typeof sig !== "object" || sig.alg !== "ed25519") return { ok: false, reason: "missing-or-bad-alg" };
|
|
101
|
+
if (cryptoInventory && cryptoInventory[sig.alg] !== "active") {
|
|
102
|
+
return { ok: false, reason: `alg-not-active:${sig.alg}` };
|
|
103
|
+
}
|
|
104
|
+
if (sig.keyId !== envelope.sender) return { ok: false, reason: "keyId-sender-mismatch" };
|
|
105
|
+
if (typeof sig.publicKey !== "string" || !/^[0-9a-f]{64}$/i.test(sig.publicKey)) return { ok: false, reason: "bad-publicKey" };
|
|
106
|
+
if (typeof sig.signature !== "string" || !/^[0-9a-f]{128}$/i.test(sig.signature)) return { ok: false, reason: "bad-signature-encoding" };
|
|
107
|
+
// Structural checks on the encryption block — an envelope without one is invalid.
|
|
108
|
+
const enc = envelope.enc;
|
|
109
|
+
if (!enc || typeof enc !== "object") return { ok: false, reason: "missing-enc" };
|
|
110
|
+
if (enc.alg !== E2E_ALG) return { ok: false, reason: `enc-alg-not-supported:${enc.alg}` };
|
|
111
|
+
if (cryptoInventory && cryptoInventory[enc.alg] && cryptoInventory[enc.alg] !== "active") {
|
|
112
|
+
return { ok: false, reason: `enc-alg-not-active:${enc.alg}` };
|
|
113
|
+
}
|
|
114
|
+
if (typeof enc.epk !== "string" || !/^[0-9a-f]{64}$/i.test(enc.epk)) return { ok: false, reason: "bad-epk" };
|
|
115
|
+
if (typeof enc.iv !== "string" || !/^[0-9a-f]{24}$/i.test(enc.iv)) return { ok: false, reason: "bad-iv" };
|
|
116
|
+
if (typeof enc.ct !== "string" || !/^[0-9a-f]{32,}$/i.test(enc.ct)) return { ok: false, reason: "bad-ct" };
|
|
117
|
+
// Verify the signature over the envelope WITHOUT the signature block.
|
|
118
|
+
const { transportSignature: _sig, ...withoutSig } = envelope;
|
|
119
|
+
let canonical;
|
|
120
|
+
try { canonical = jcs(withoutSig); } catch (_e) { return { ok: false, reason: "bad-canonical" }; }
|
|
121
|
+
let okSig;
|
|
122
|
+
try {
|
|
123
|
+
okSig = Keychain.verifyDetached(sig.publicKey, Buffer.from(canonical, "utf-8"), Buffer.from(sig.signature, "hex"));
|
|
124
|
+
} catch (_e) {
|
|
125
|
+
return { ok: false, reason: "verify-threw" };
|
|
126
|
+
}
|
|
127
|
+
if (!okSig) return { ok: false, reason: "bad-signature" };
|
|
128
|
+
// Sender binding (self-authenticating identity): the sender UUAID's
|
|
129
|
+
// local-id MUST derive from the signing key — the sender-side mirror of
|
|
130
|
+
// seal()'s recipient binding. Without this, anyone can sign an envelope
|
|
131
|
+
// with their own key while claiming another agent's sender UUAID.
|
|
132
|
+
const expectedSenderLocalId = Keychain._localIdFromKey(Buffer.from(sig.publicKey, "hex"));
|
|
133
|
+
if (expectedSenderLocalId !== String(envelope.sender).split(":")[3]) {
|
|
134
|
+
return { ok: false, reason: "publicKey-sender-mismatch" };
|
|
135
|
+
}
|
|
136
|
+
return { ok: true };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Decrypt an envelope's payload. Only the recipient can do this.
|
|
141
|
+
* keychain must be the recipient's loaded keychain.
|
|
142
|
+
* Throws on wrong recipient, wrong key, tamper, or AAD mismatch.
|
|
143
|
+
*/
|
|
144
|
+
export function decrypt(keychain, envelope) {
|
|
145
|
+
const identity = keychain._identity;
|
|
146
|
+
if (!identity) throw new Error("decrypt: keychain not loaded");
|
|
147
|
+
if (envelope.recipient !== identity.uuaid) {
|
|
148
|
+
throw new Error(`decrypt: envelope addressed to ${envelope.recipient}, not us (${identity.uuaid})`);
|
|
149
|
+
}
|
|
150
|
+
const seed = keychainSeed(keychain);
|
|
151
|
+
return openPayload(seed, envelope.enc, { aad: envelope.id });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Extract the 32-byte Ed25519 seed from a loaded keychain. */
|
|
155
|
+
export function keychainSeed(keychain) {
|
|
156
|
+
const der = keychain._identity.privateKey.export({ type: "pkcs8", format: "der" });
|
|
157
|
+
return Buffer.from(der.subarray(der.length - 32));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Envelope id: 16 random bytes hex + a small timestamp prefix for
|
|
162
|
+
* human-readable sort. Not sensitive; not secret. Used as GCM AAD.
|
|
163
|
+
*/
|
|
164
|
+
export function mkEnvelopeId() {
|
|
165
|
+
const now = Date.now().toString(36);
|
|
166
|
+
const rnd = randomBytes(8).toString("hex");
|
|
167
|
+
return `env-${now}-${rnd}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Content-hash of an envelope, computable by anyone.
|
|
172
|
+
*/
|
|
173
|
+
export function envelopeSha(envelope) {
|
|
174
|
+
return createHash("sha256").update(jcs(envelope)).digest("hex");
|
|
175
|
+
}
|