@uuaid/pillar 0.2.0-alpha.13 → 0.2.0-alpha.17
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 +192 -0
- package/README.md +276 -12
- package/bin/carrier.mjs +1 -0
- package/bin/pillar-ask.mjs +1 -0
- package/bin/pillar-blob.mjs +113 -0
- package/bin/pillar-directory.mjs +232 -0
- package/bin/pillar-escrow.mjs +1 -0
- package/bin/pillar-responder.mjs +1 -0
- package/bin/pillar-seed.mjs +113 -13
- package/bin/pillar.mjs +7 -2
- package/package.json +26 -15
- package/src/blob/blob.mjs +426 -0
- package/src/blob/broker.mjs +129 -0
- package/src/blob/http.mjs +196 -0
- package/src/blob/store.mjs +55 -0
- package/src/identity/tier.mjs +24 -3
- package/src/index.mjs +24 -1
- package/src/net/carrier-client.mjs +55 -9
- package/src/net/carrier-server.mjs +169 -11
- package/src/net/delegation.mjs +152 -0
- package/src/net/envelope.mjs +81 -0
- package/src/net/seed-registry.mjs +467 -0
- package/src/net/seed-tier.mjs +185 -0
- package/src/node-check.mjs +32 -0
- package/src/pillar.mjs +96 -3
|
@@ -18,14 +18,33 @@
|
|
|
18
18
|
* x-pillar-pubkey: <ed25519 pubkey hex>
|
|
19
19
|
* x-pillar-ts: <unix ms> (rejected if clock skew > 5 min)
|
|
20
20
|
* x-pillar-sig: hex(sign("GET /v1/inbox/<uuaid>?since=<seq>" + "\n" + ts))
|
|
21
|
+
* x-pillar-delegation: (optional) JSON delegation document letting a
|
|
22
|
+
* delegate key read <uuaid>'s inbox; must be signed by <uuaid> and
|
|
23
|
+
* cover kind "inbox" (or "*")
|
|
21
24
|
* - carrier derives UUAID from pubkey; must equal <uuaid> (self-authenticating,
|
|
22
|
-
* zero registration)
|
|
25
|
+
* zero registration) unless a valid inbox delegation is presented
|
|
23
26
|
* - long-polls up to `wait` seconds (max 30)
|
|
24
27
|
* -> 200 { envelopes: [{seq, envelope}], now }
|
|
25
28
|
*
|
|
26
29
|
* GET /v1/health
|
|
27
30
|
* -> 200 { ok, uptime, stored }
|
|
28
31
|
*
|
|
32
|
+
* GET /v1/seed-info
|
|
33
|
+
* -> 200 { v, url, uuaid, publicKey, startedAt, firstSeen, stats, now, sig }
|
|
34
|
+
* Present only when the server was constructed with a keychain + publicUrl
|
|
35
|
+
* (i.e. it is running as a public seed, not a private relay). The
|
|
36
|
+
* signature binds this URL to the operator's key, which is what the seed
|
|
37
|
+
* registry checks at enrollment before listing the URL as a seed.
|
|
38
|
+
*
|
|
39
|
+
* PRIORITY ADMISSION
|
|
40
|
+
*
|
|
41
|
+
* Load shedding is not uniform. A sender presenting a valid tier grant with
|
|
42
|
+
* `priority: true` — issued to durable seed operators by the seed registry —
|
|
43
|
+
* is admitted from reserved headroom that community traffic cannot touch:
|
|
44
|
+
* the last slice of the store, its own per-IP budget, and reserved long-poll
|
|
45
|
+
* slots. Under no load the two lanes are indistinguishable; the privilege
|
|
46
|
+
* only becomes visible at the moment the carrier starts saying no.
|
|
47
|
+
*
|
|
29
48
|
* Durability: SQLite (WAL). Envelopes are retained until TTL (default 14 days).
|
|
30
49
|
* The carrier CANNOT read payloads it relays beyond what the envelope exposes,
|
|
31
50
|
* and CANNOT forge envelopes (no keys). It can only: deliver, delay, or drop —
|
|
@@ -36,12 +55,15 @@ import { createHash } from "node:crypto";
|
|
|
36
55
|
import Database from "better-sqlite3";
|
|
37
56
|
import { open as openEnvelope } from "./envelope.mjs";
|
|
38
57
|
import { Keychain } from "../identity/keychain.mjs";
|
|
58
|
+
import { jcs } from "../identity/jcs.mjs";
|
|
39
59
|
import { verifyTierGrant } from "../identity/tier.mjs";
|
|
60
|
+
import { verifyDelegationDoc, delegationCoversKind } from "./delegation.mjs";
|
|
40
61
|
|
|
41
62
|
const MAX_WAIT_S = 30;
|
|
42
63
|
const MAX_BODY = 512 * 1024; // 512 KiB per envelope (community tier)
|
|
43
64
|
const MAX_BODY_STREAM = 2 * 1024 * 1024 + 8192; // absolute read cap: max tier body + grant/header slack
|
|
44
65
|
const MAX_SKEW_MS = 5 * 60 * 1000;
|
|
66
|
+
const MAX_DELEGATION_HEADER = 8192; // x-pillar-delegation size cap (bytes)
|
|
45
67
|
// Hardening (RedTeam F2/F3):
|
|
46
68
|
const MAX_ENVELOPE_AGE_MS = 24 * 3600_000; // F3: ingest freshness — reject skewed createdAt
|
|
47
69
|
const RATE_WINDOW_MS = 60_000; // F2: sliding window for all rate limits
|
|
@@ -51,9 +73,16 @@ const MAX_INVALID_PER_MIN = 60; // F2: per client-IP budget for rejec
|
|
|
51
73
|
const MAX_CONCURRENT_WAITS = 64; // F2: inbox long-poll concurrency cap
|
|
52
74
|
const MAX_DB_BYTES = 256 * 1024 * 1024; // F2: SQLite size backpressure (503 when full)
|
|
53
75
|
const RATE_MAP_HARD_CAP = 10_000; // F2: bound memory under key spray
|
|
76
|
+
// Priority lanes. Each is a RESERVE carved out of an existing cap, never an
|
|
77
|
+
// increase of it: community traffic sheds earlier so that priority traffic
|
|
78
|
+
// still fits inside the same absolute ceiling. A carrier with no trusted tier
|
|
79
|
+
// issuers configured never resolves a priority grant, so these are inert.
|
|
80
|
+
const COMMUNITY_DB_BYTES = Math.floor(MAX_DB_BYTES * 0.85); // last 15% of the store is reserved
|
|
81
|
+
const COMMUNITY_CONCURRENT_WAITS = 48; // of MAX_CONCURRENT_WAITS
|
|
82
|
+
const MAX_PRIORITY_GOOD_PER_MIN = 600; // priority senders bill to their own per-IP bucket
|
|
54
83
|
|
|
55
84
|
export class CarrierServer {
|
|
56
|
-
constructor({ dbPath = "carrier.db", ttlDays = 14, cryptoInventory, trustedTierIssuers = [] } = {}) {
|
|
85
|
+
constructor({ dbPath = "carrier.db", ttlDays = 14, cryptoInventory, trustedTierIssuers = [], keychain = null, publicUrl = null } = {}) {
|
|
57
86
|
this.db = new Database(dbPath);
|
|
58
87
|
this.db.pragma("journal_mode = WAL");
|
|
59
88
|
this.db.exec(`
|
|
@@ -67,7 +96,16 @@ export class CarrierServer {
|
|
|
67
96
|
created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
68
97
|
);
|
|
69
98
|
create index if not exists idx_envelopes_recipient on envelopes(recipient, seq);
|
|
99
|
+
-- Lifetime service counters. Kept OUTSIDE the envelopes table because
|
|
100
|
+
-- vacuum() deletes envelopes at TTL, and a seed's service history must
|
|
101
|
+
-- survive its own garbage collection.
|
|
102
|
+
create table if not exists seed_stats (
|
|
103
|
+
k text primary key,
|
|
104
|
+
v text not null
|
|
105
|
+
);
|
|
70
106
|
`);
|
|
107
|
+
this.db.prepare("insert or ignore into seed_stats (k, v) values ('first_seen', ?)")
|
|
108
|
+
.run(new Date().toISOString());
|
|
71
109
|
this.ttlDays = ttlDays;
|
|
72
110
|
this.cryptoInventory = cryptoInventory ?? { ed25519: "active" };
|
|
73
111
|
this.startedAt = Date.now();
|
|
@@ -79,6 +117,54 @@ export class CarrierServer {
|
|
|
79
117
|
// sender gets community-tier budgets (pre-tier behavior, default).
|
|
80
118
|
this.trustedTierIssuers = trustedTierIssuers;
|
|
81
119
|
this._grantCache = new Map(); // sender uuaid -> { grant, cachedAt }
|
|
120
|
+
// Seed identity. Optional: a private relay runs without one and simply
|
|
121
|
+
// does not expose /v1/seed-info.
|
|
122
|
+
this.keychain = keychain;
|
|
123
|
+
this.publicUrl = publicUrl ? String(publicUrl).replace(/\/+$/, "") : null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Read a lifetime counter. */
|
|
127
|
+
_stat(k, fallback = 0) {
|
|
128
|
+
const row = this.db.prepare("select v from seed_stats where k = ?").get(k);
|
|
129
|
+
if (row === undefined) return fallback;
|
|
130
|
+
const n = Number(row.v);
|
|
131
|
+
return Number.isFinite(n) ? n : row.v;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Bump a lifetime counter by n (creating it at 0 first). */
|
|
135
|
+
_bump(k, n = 1) {
|
|
136
|
+
this.db.prepare(
|
|
137
|
+
"insert into seed_stats (k, v) values (?, ?) on conflict(k) do update set v = cast(cast(v as integer) + ? as text)"
|
|
138
|
+
).run(k, String(n), n);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The signed service claim served at /v1/seed-info.
|
|
143
|
+
*
|
|
144
|
+
* These counters are the seed's OWN account of its work and are therefore
|
|
145
|
+
* not evidence: an operator can inflate them freely. The registry records
|
|
146
|
+
* them for display and promotes strictly on what it observes itself (see
|
|
147
|
+
* net/seed-tier.mjs). What the signature does prove is the thing enrollment
|
|
148
|
+
* actually needs — that whoever controls this URL also controls this key.
|
|
149
|
+
*/
|
|
150
|
+
seedInfo() {
|
|
151
|
+
if (!this.keychain?._identity || !this.publicUrl) return null;
|
|
152
|
+
const doc = {
|
|
153
|
+
v: 1,
|
|
154
|
+
url: this.publicUrl,
|
|
155
|
+
uuaid: this.keychain._identity.uuaid,
|
|
156
|
+
publicKey: this.keychain._identity.publicKeyHex,
|
|
157
|
+
startedAt: new Date(this.startedAt).toISOString(),
|
|
158
|
+
firstSeen: this.db.prepare("select v from seed_stats where k = 'first_seen'").get()?.v ?? null,
|
|
159
|
+
stats: {
|
|
160
|
+
relayed: this._stat("relayed"),
|
|
161
|
+
served: this._stat("served"),
|
|
162
|
+
stored: this.db.prepare("select count(*) as n from envelopes").get().n,
|
|
163
|
+
},
|
|
164
|
+
now: new Date().toISOString(),
|
|
165
|
+
};
|
|
166
|
+
doc.sig = this.keychain.sign(Buffer.from(jcs(doc), "utf-8")).toString("hex");
|
|
167
|
+
return doc;
|
|
82
168
|
}
|
|
83
169
|
|
|
84
170
|
/**
|
|
@@ -89,7 +175,7 @@ export class CarrierServer {
|
|
|
89
175
|
* cannot break plain delivery.
|
|
90
176
|
*/
|
|
91
177
|
_resolveTier(req, sender) {
|
|
92
|
-
const fallback = { tier: "community", envelopesPerMin: MAX_ENVELOPES_PER_MIN, maxBodyBytes: MAX_BODY };
|
|
178
|
+
const fallback = { tier: "community", envelopesPerMin: MAX_ENVELOPES_PER_MIN, maxBodyBytes: MAX_BODY, priority: false };
|
|
93
179
|
if (!this.trustedTierIssuers.length) return fallback;
|
|
94
180
|
const hdr = req?.headers?.["x-pillar-tier-grant"];
|
|
95
181
|
if (!hdr) return fallback;
|
|
@@ -101,7 +187,16 @@ export class CarrierServer {
|
|
|
101
187
|
if (cached && cached.sig === grant.sig && Date.now() - cached.at < 60_000) return cached.resolved;
|
|
102
188
|
const v = verifyTierGrant(grant, this.trustedTierIssuers);
|
|
103
189
|
const resolved = v.ok
|
|
104
|
-
? {
|
|
190
|
+
? {
|
|
191
|
+
tier: grant.tier,
|
|
192
|
+
envelopesPerMin: grant.envelopesPerMin,
|
|
193
|
+
maxBodyBytes: grant.maxBodyBytes,
|
|
194
|
+
// Only a grant the issuer actually signed as priority opens the
|
|
195
|
+
// reserved lane. verifyTierGrant has already rejected any grant
|
|
196
|
+
// whose `priority` is present but not exactly true.
|
|
197
|
+
priority: grant.priority === true,
|
|
198
|
+
seedTier: grant.seedTier ?? null,
|
|
199
|
+
}
|
|
105
200
|
: fallback;
|
|
106
201
|
if (this._grantCache.size > 4096) this._grantCache.clear(); // bounded
|
|
107
202
|
this._grantCache.set(sender, { sig: grant.sig, at: Date.now(), resolved });
|
|
@@ -175,7 +270,7 @@ export class CarrierServer {
|
|
|
175
270
|
if (!this._rateAllow(`bad:${ip}`, MAX_INVALID_PER_MIN)) {
|
|
176
271
|
return { code: 429, body: { accepted: false, reason: "rate-limited" } };
|
|
177
272
|
}
|
|
178
|
-
return { code: 400, body: { accepted: false, reason: verdict.reason } };
|
|
273
|
+
return { code: verdict.code ?? 400, body: { accepted: false, reason: verdict.reason } };
|
|
179
274
|
}
|
|
180
275
|
if (typeof envelope.recipient !== "string" || !envelope.recipient.startsWith("uuaid:")) {
|
|
181
276
|
return { code: 400, body: { accepted: false, reason: "bad-recipient" } };
|
|
@@ -191,7 +286,12 @@ export class CarrierServer {
|
|
|
191
286
|
}
|
|
192
287
|
// F2: per-client budget on ACCEPTED sends — closes the unlimited
|
|
193
288
|
// fresh-keypair spray (each new key gets a fresh sender budget).
|
|
194
|
-
|
|
289
|
+
// Priority senders bill to a SEPARATE bucket: the point of the lane is
|
|
290
|
+
// that a noisy neighbour behind the same egress IP (or NAT, or CI
|
|
291
|
+
// runner) cannot consume the budget a durable seed operator earned.
|
|
292
|
+
const ipKey = tier.priority ? `p:${ip}` : `g:${ip}`;
|
|
293
|
+
const ipLimit = tier.priority ? MAX_PRIORITY_GOOD_PER_MIN : MAX_GOOD_PER_MIN;
|
|
294
|
+
if (!this._rateAllow(ipKey, ipLimit)) {
|
|
195
295
|
return { code: 429, body: { accepted: false, reason: "rate-limited" } };
|
|
196
296
|
}
|
|
197
297
|
// F3: freshness — reject envelopes whose createdAt is missing or skewed
|
|
@@ -201,10 +301,13 @@ export class CarrierServer {
|
|
|
201
301
|
return { code: 400, body: { accepted: false, reason: "stale-envelope" } };
|
|
202
302
|
}
|
|
203
303
|
// F2: backpressure — refuse ingest when the store is near-full instead of
|
|
204
|
-
// failing at disk-full mid-write.
|
|
304
|
+
// failing at disk-full mid-write. Community traffic sheds at 85% so the
|
|
305
|
+
// remaining slice stays available to priority senders; the absolute cap
|
|
306
|
+
// is identical for both, so the reserve cannot push the store past it.
|
|
205
307
|
const pages = this.db.pragma("page_count", { simple: true });
|
|
206
308
|
const pageBytes = this.db.pragma("page_size", { simple: true });
|
|
207
|
-
|
|
309
|
+
const storeCap = tier.priority ? MAX_DB_BYTES : COMMUNITY_DB_BYTES;
|
|
310
|
+
if (pages * pageBytes > storeCap) {
|
|
208
311
|
return { code: 503, body: { accepted: false, reason: "carrier-full" } };
|
|
209
312
|
}
|
|
210
313
|
const sha = createHash("sha256").update(JSON.stringify(envelope)).digest("hex");
|
|
@@ -212,6 +315,7 @@ export class CarrierServer {
|
|
|
212
315
|
const r = this.db.prepare(
|
|
213
316
|
"insert into envelopes (envelope_id, recipient, sender, sha, body) values (?,?,?,?,?)"
|
|
214
317
|
).run(envelope.id, envelope.recipient, envelope.sender, sha, JSON.stringify(envelope));
|
|
318
|
+
this._bump("relayed");
|
|
215
319
|
return { code: 202, body: { accepted: true, seq: Number(r.lastInsertRowid), sha } };
|
|
216
320
|
} catch (e) {
|
|
217
321
|
if (/UNIQUE constraint/.test(e.message)) {
|
|
@@ -236,7 +340,17 @@ export class CarrierServer {
|
|
|
236
340
|
// Self-authenticating: UUAID must derive from the presented pubkey.
|
|
237
341
|
const localId = Keychain._localIdFromKey(Buffer.from(pubkey, "hex"));
|
|
238
342
|
const derived = `uuaid:foundation:agent:${localId}`;
|
|
239
|
-
if (derived !== uuaid)
|
|
343
|
+
if (derived !== uuaid) {
|
|
344
|
+
// A delegate key may read the principal's inbox only by presenting the
|
|
345
|
+
// principal-signed delegation document that names it. Without the
|
|
346
|
+
// header this stays the legacy mismatch.
|
|
347
|
+
const raw = req.headers["x-pillar-delegation"];
|
|
348
|
+
if (raw === undefined) return { ok: false, reason: "uuaid-pubkey-mismatch" };
|
|
349
|
+
const delegated = this._verifyInboxDelegation(raw, uuaid, pubkey);
|
|
350
|
+
if (!delegated.ok) return delegated;
|
|
351
|
+
}
|
|
352
|
+
// Either the principal's own key or an authorized delegate key: in both
|
|
353
|
+
// cases it must have signed THIS request.
|
|
240
354
|
const message = Buffer.from(`${sincePath}\n${ts}`, "utf-8");
|
|
241
355
|
let verified = false;
|
|
242
356
|
try { verified = Keychain.verifyDetached(pubkey, message, Buffer.from(sig, "hex")); }
|
|
@@ -245,6 +359,37 @@ export class CarrierServer {
|
|
|
245
359
|
return { ok: true };
|
|
246
360
|
}
|
|
247
361
|
|
|
362
|
+
/**
|
|
363
|
+
* Authorize a delegate to poll `uuaid`'s inbox from an `x-pillar-delegation`
|
|
364
|
+
* header. Fails closed on every error and never logs the document.
|
|
365
|
+
*
|
|
366
|
+
* Reasons (all surface as HTTP 401):
|
|
367
|
+
* delegation-too-large header exceeds MAX_DELEGATION_HEADER
|
|
368
|
+
* delegation-unparseable not a string / not JSON
|
|
369
|
+
* delegation-invalid:<reason> verifyDelegationDoc verdict (expired,
|
|
370
|
+
* bad-signature, *-uuaid-binding, ...)
|
|
371
|
+
* delegation-principal-mismatch doc principal is not the path uuaid
|
|
372
|
+
* delegation-delegate-key-mismatch doc names a different delegate key
|
|
373
|
+
* delegation-inbox-denied kinds cover neither "inbox" nor "*"
|
|
374
|
+
*/
|
|
375
|
+
_verifyInboxDelegation(raw, uuaid, pubkey) {
|
|
376
|
+
if (typeof raw !== "string") return { ok: false, reason: "delegation-unparseable" };
|
|
377
|
+
if (Buffer.byteLength(raw, "utf-8") > MAX_DELEGATION_HEADER) {
|
|
378
|
+
return { ok: false, reason: "delegation-too-large" };
|
|
379
|
+
}
|
|
380
|
+
let doc;
|
|
381
|
+
try { doc = JSON.parse(raw); }
|
|
382
|
+
catch (_e) { return { ok: false, reason: "delegation-unparseable" }; }
|
|
383
|
+
const verdict = verifyDelegationDoc(doc);
|
|
384
|
+
if (!verdict.ok) return { ok: false, reason: `delegation-invalid:${verdict.reason}` };
|
|
385
|
+
if (doc.principal !== uuaid) return { ok: false, reason: "delegation-principal-mismatch" };
|
|
386
|
+
if (doc.delegatePublicKey.toLowerCase() !== pubkey.toLowerCase()) {
|
|
387
|
+
return { ok: false, reason: "delegation-delegate-key-mismatch" };
|
|
388
|
+
}
|
|
389
|
+
if (!delegationCoversKind(doc, "inbox")) return { ok: false, reason: "delegation-inbox-denied" };
|
|
390
|
+
return { ok: true };
|
|
391
|
+
}
|
|
392
|
+
|
|
248
393
|
async _handleInbox(req, res, uuaid, url) {
|
|
249
394
|
const since = Number(url.searchParams.get("since") ?? 0);
|
|
250
395
|
const wait = Math.min(Number(url.searchParams.get("wait") ?? 0), MAX_WAIT_S);
|
|
@@ -253,8 +398,13 @@ export class CarrierServer {
|
|
|
253
398
|
if (!auth.ok) return this._json(res, 401, { error: auth.reason });
|
|
254
399
|
|
|
255
400
|
// F2: cap concurrent long-polls so sleeping connections cannot pin the
|
|
256
|
-
// event loop / fd table.
|
|
257
|
-
|
|
401
|
+
// event loop / fd table. The grant is resolved against the PRINCIPAL
|
|
402
|
+
// whose inbox this is (the path uuaid), not the key that signed — a
|
|
403
|
+
// delegate polling for a priority principal inherits the reserved slots,
|
|
404
|
+
// which is the same authority it already has to read the mail.
|
|
405
|
+
const tier = this._resolveTier(req, uuaid);
|
|
406
|
+
const waitCap = tier.priority ? MAX_CONCURRENT_WAITS : COMMUNITY_CONCURRENT_WAITS;
|
|
407
|
+
if (wait > 0 && this.activeWaits >= waitCap) {
|
|
258
408
|
return this._json(res, 503, { error: "busy" });
|
|
259
409
|
}
|
|
260
410
|
if (wait > 0) this.activeWaits++;
|
|
@@ -275,6 +425,7 @@ export class CarrierServer {
|
|
|
275
425
|
await new Promise(r => setTimeout(r, 400));
|
|
276
426
|
rows = fetchRows();
|
|
277
427
|
}
|
|
428
|
+
if (rows.length) this._bump("served", rows.length);
|
|
278
429
|
this._json(res, 200, {
|
|
279
430
|
envelopes: rows.map(r => ({ seq: r.seq, envelope: JSON.parse(r.body) })),
|
|
280
431
|
now: Date.now(),
|
|
@@ -302,6 +453,13 @@ export class CarrierServer {
|
|
|
302
453
|
if (!uuaid.startsWith("uuaid:")) return this._json(res, 404, { error: "not-found" });
|
|
303
454
|
return await this._handleInbox(req, res, uuaid, url);
|
|
304
455
|
}
|
|
456
|
+
if (req.method === "GET" && url.pathname === "/v1/seed-info") {
|
|
457
|
+
const info = this.seedInfo();
|
|
458
|
+
// A relay with no seed identity is not a seed and must not look
|
|
459
|
+
// like one to the registry.
|
|
460
|
+
if (!info) return this._json(res, 404, { error: "not-a-seed" });
|
|
461
|
+
return this._json(res, 200, info);
|
|
462
|
+
}
|
|
305
463
|
if (req.method === "GET" && url.pathname === "/v1/health") {
|
|
306
464
|
const stored = this.db.prepare("select count(*) as n from envelopes").get().n;
|
|
307
465
|
return this._json(res, 200, { ok: true, uptimeMs: Date.now() - this.startedAt, stored });
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device/persona delegation — UACP/1.0 protocol extension (carrier side).
|
|
3
|
+
*
|
|
4
|
+
* A long-lived "persona" UUAID (the principal) authorizes a short-lived
|
|
5
|
+
* device identity (the delegate) to act on its behalf for specific envelope
|
|
6
|
+
* kinds and a bounded time window. The persona signs a JCS-canonical
|
|
7
|
+
* delegation document; the device embeds that document in the envelope's
|
|
8
|
+
* `delegation` field and signs the whole envelope with ITS OWN key.
|
|
9
|
+
*
|
|
10
|
+
* delegation = {
|
|
11
|
+
* version: "uuaid-delegation/1",
|
|
12
|
+
* id: "del-...",
|
|
13
|
+
* principal: persona uuaid, principalPublicKey: hex,
|
|
14
|
+
* delegate: device uuaid, delegatePublicKey: hex,
|
|
15
|
+
* kinds: ["*"] or ["request","reply",...],
|
|
16
|
+
* notBefore, notAfter: ISO 8601,
|
|
17
|
+
* signature: { alg: "ed25519", publicKey, signature },
|
|
18
|
+
* }
|
|
19
|
+
* signature = ed25519(JCS(delegation minus .signature), principalSeed)
|
|
20
|
+
*
|
|
21
|
+
* Wire-compatible with @uuaid/pillar-client's delegation.ts (mintDelegation /
|
|
22
|
+
* verifyDelegation / sealWithDelegation / verifyDelegatedEnvelope): same
|
|
23
|
+
* document shape, same JCS canonicalization, same rejection reason strings,
|
|
24
|
+
* so a carrier and a pillar-client peer reach the same verdict on the same
|
|
25
|
+
* bytes.
|
|
26
|
+
*
|
|
27
|
+
* Envelope acceptance rules (envelope.mjs open() branches here when the
|
|
28
|
+
* optional `delegation` field is present; absent -> legacy behavior,
|
|
29
|
+
* unchanged):
|
|
30
|
+
* (a) envelope transport signature valid under the DELEGATE's pubkey and
|
|
31
|
+
* transportSignature.keyId === delegate uuaid;
|
|
32
|
+
* (b) delegation document signature valid under the PRINCIPAL's pubkey,
|
|
33
|
+
* principal === envelope.sender, delegate === signing key's uuaid;
|
|
34
|
+
* (c) local-id binding for BOTH identities:
|
|
35
|
+
* sha256(pubkey)[:16] (UUID-dash formatted) === uuaid local-id;
|
|
36
|
+
* (d) now within [notBefore, notAfter];
|
|
37
|
+
* (e) envelope.kind covered by the delegation `kinds` allowlist
|
|
38
|
+
* ("*" covers everything), and not a capability-only name such as
|
|
39
|
+
* "inbox" — those are poll capabilities, never deliverable kinds, and
|
|
40
|
+
* are refused even under "*" (see CAPABILITY_ONLY_KINDS).
|
|
41
|
+
* All five must pass; any failure rejects with
|
|
42
|
+
* `delegation-invalid:<reason>` (HTTP 403 at the carrier).
|
|
43
|
+
*
|
|
44
|
+
* Storage/mailbox attribution is unchanged: the envelope's `sender` IS the
|
|
45
|
+
* principal, so carriers key mailboxes, rate limits, and audits to the
|
|
46
|
+
* principal UUAID exactly as before.
|
|
47
|
+
*/
|
|
48
|
+
import { jcs } from "../identity/jcs.mjs";
|
|
49
|
+
import { Keychain } from "../identity/keychain.mjs";
|
|
50
|
+
|
|
51
|
+
export const DELEGATION_VERSION = "uuaid-delegation/1";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Extract the local-id of a UUAID with pillar-client parseUuaid semantics:
|
|
55
|
+
* exactly four colon-separated segments with the "uuaid" prefix.
|
|
56
|
+
* Returns null for anything else (never throws).
|
|
57
|
+
*/
|
|
58
|
+
export function uuaidLocalId(uuaid) {
|
|
59
|
+
if (typeof uuaid !== "string") return null;
|
|
60
|
+
const parts = uuaid.split(":");
|
|
61
|
+
if (parts.length !== 4 || parts[0] !== "uuaid") return null;
|
|
62
|
+
return parts[3];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Self-authenticating identity check: sha256(pubkey)[:16] === uuaid local-id. */
|
|
66
|
+
export function uuaidBindingOk(uuaid, publicKeyHex) {
|
|
67
|
+
const localId = uuaidLocalId(uuaid);
|
|
68
|
+
if (localId === null) return false;
|
|
69
|
+
if (typeof publicKeyHex !== "string" || !/^[0-9a-f]{64}$/i.test(publicKeyHex)) return false;
|
|
70
|
+
return Keychain.localIdFromKey(Buffer.from(publicKeyHex, "hex")) === localId;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Verify a delegation document on its own: shape, identity bindings, time
|
|
75
|
+
* window, and the principal's signature over JCS(doc minus .signature).
|
|
76
|
+
*
|
|
77
|
+
* Reason strings intentionally mirror @uuaid/pillar-client verifyDelegation
|
|
78
|
+
* so tooling sees identical diagnostics on both implementations.
|
|
79
|
+
*
|
|
80
|
+
* Returns { ok: true } or { ok: false, reason }.
|
|
81
|
+
*/
|
|
82
|
+
export function verifyDelegationDoc(d, now = new Date()) {
|
|
83
|
+
if (!d || typeof d !== "object" || Array.isArray(d)) return { ok: false, reason: "not-object" };
|
|
84
|
+
if (d.version !== DELEGATION_VERSION) return { ok: false, reason: `bad-version:${d.version}` };
|
|
85
|
+
if (!/^[0-9a-f]{64}$/i.test(d.principalPublicKey ?? "")) return { ok: false, reason: "bad-principal-pubkey" };
|
|
86
|
+
if (!/^[0-9a-f]{64}$/i.test(d.delegatePublicKey ?? "")) return { ok: false, reason: "bad-delegate-pubkey" };
|
|
87
|
+
if (!uuaidBindingOk(d.principal, d.principalPublicKey)) {
|
|
88
|
+
return { ok: false, reason: "principal-uuaid-binding" };
|
|
89
|
+
}
|
|
90
|
+
if (!uuaidBindingOk(d.delegate, d.delegatePublicKey)) {
|
|
91
|
+
return { ok: false, reason: "delegate-uuaid-binding" };
|
|
92
|
+
}
|
|
93
|
+
if (!Array.isArray(d.kinds) || d.kinds.length === 0 || d.kinds.some(k => typeof k !== "string")) {
|
|
94
|
+
return { ok: false, reason: "bad-kinds" };
|
|
95
|
+
}
|
|
96
|
+
const nb = Date.parse(d.notBefore);
|
|
97
|
+
const na = Date.parse(d.notAfter);
|
|
98
|
+
if (Number.isNaN(nb) || Number.isNaN(na)) return { ok: false, reason: "bad-time" };
|
|
99
|
+
if (na <= nb) return { ok: false, reason: "empty-window" };
|
|
100
|
+
const t = now instanceof Date ? now.getTime() : Number(now);
|
|
101
|
+
if (t < nb) return { ok: false, reason: "not-yet-valid" };
|
|
102
|
+
if (t > na) return { ok: false, reason: "expired" };
|
|
103
|
+
|
|
104
|
+
const sig = d.signature;
|
|
105
|
+
if (!sig || sig.alg !== "ed25519" || sig.publicKey !== d.principalPublicKey) {
|
|
106
|
+
return { ok: false, reason: "bad-signature-shape" };
|
|
107
|
+
}
|
|
108
|
+
if (typeof sig.signature !== "string" || !/^[0-9a-f]{128}$/i.test(sig.signature)) {
|
|
109
|
+
return { ok: false, reason: "bad-signature-shape" };
|
|
110
|
+
}
|
|
111
|
+
// The signature covers JCS of everything except .signature.
|
|
112
|
+
const { signature: _drop, ...unsigned } = d;
|
|
113
|
+
let canonical;
|
|
114
|
+
try { canonical = jcs(unsigned); } catch (_e) { return { ok: false, reason: "bad-canonical" }; }
|
|
115
|
+
let okSig = false;
|
|
116
|
+
try {
|
|
117
|
+
okSig = Keychain.verifyDetached(sig.publicKey, Buffer.from(canonical, "utf-8"), Buffer.from(sig.signature, "hex"));
|
|
118
|
+
} catch (_e) {
|
|
119
|
+
return { ok: false, reason: "verify-threw" };
|
|
120
|
+
}
|
|
121
|
+
if (!okSig) return { ok: false, reason: "bad-signature" };
|
|
122
|
+
return { ok: true };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Names that may appear in a delegation's `kinds` as CAPABILITIES but are not
|
|
127
|
+
* envelope kinds, and so must never be deliverable.
|
|
128
|
+
*
|
|
129
|
+
* `kinds` is a single namespace answering two different questions: "may this
|
|
130
|
+
* delegate SEND envelopes of kind K" (envelope.mjs) and "may this delegate
|
|
131
|
+
* POLL the principal's inbox" (carrier-server.mjs, capability "inbox"). The
|
|
132
|
+
* overlap means a token minted read-only as `kinds: ["inbox"]` also satisfies
|
|
133
|
+
* the send check for an envelope whose `kind` is literally "inbox" — verified
|
|
134
|
+
* against a live alpha.15 carrier, which returned 202 for exactly that forgery
|
|
135
|
+
* while correctly returning 403 kind-not-covered for message/request/reply.
|
|
136
|
+
*
|
|
137
|
+
* Nothing consumes `kind: "inbox"`, so this is latent rather than live. It
|
|
138
|
+
* stops being latent the moment a read-only poll token is issued to anyone,
|
|
139
|
+
* which is what the push relay is being rebuilt to use.
|
|
140
|
+
*
|
|
141
|
+
* Enforced on the send path in envelope.mjs. Deliberately NOT enforced inside
|
|
142
|
+
* delegationCoversKind: that helper serves both questions, and the capability
|
|
143
|
+
* lookup in carrier-server.mjs depends on "inbox" matching there.
|
|
144
|
+
*/
|
|
145
|
+
export const CAPABILITY_ONLY_KINDS = new Set(["inbox"]);
|
|
146
|
+
|
|
147
|
+
/** Does this delegation cover envelopes of `kind`? "*" covers all. */
|
|
148
|
+
export function delegationCoversKind(d, kind) {
|
|
149
|
+
if (!Array.isArray(d?.kinds)) return false;
|
|
150
|
+
if (d.kinds.includes("*")) return true;
|
|
151
|
+
return d.kinds.includes(kind);
|
|
152
|
+
}
|
package/src/net/envelope.mjs
CHANGED
|
@@ -30,6 +30,7 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
30
30
|
import { jcs } from "../identity/jcs.mjs";
|
|
31
31
|
import { Keychain } from "../identity/keychain.mjs";
|
|
32
32
|
import { sealPayload, openPayload, E2E_ALG } from "../crypto/e2e.mjs";
|
|
33
|
+
import { verifyDelegationDoc, delegationCoversKind, CAPABILITY_ONLY_KINDS } from "./delegation.mjs";
|
|
33
34
|
|
|
34
35
|
export const ENVELOPE_VERSION = "uuaid-pillar-envelope/2";
|
|
35
36
|
export { E2E_ALG };
|
|
@@ -92,6 +93,11 @@ export function seal(keychain, body) {
|
|
|
92
93
|
* Verify an envelope's transport signature and structure. Does NOT decrypt.
|
|
93
94
|
* Anyone (carriers, relays, auditors) can call this.
|
|
94
95
|
* Returns { ok: true } or { ok: false, reason }.
|
|
96
|
+
*
|
|
97
|
+
* DELEGATED ENVELOPES (UACP/1.0 ext): when the optional `delegation` field is
|
|
98
|
+
* present, the self-authenticating-sender rule is replaced by the delegation
|
|
99
|
+
* chain — see openDelegated() below and src/net/delegation.mjs. When the
|
|
100
|
+
* field is ABSENT the checks below are byte-for-byte the legacy behavior.
|
|
95
101
|
*/
|
|
96
102
|
export function open(envelope, { cryptoInventory } = {}) {
|
|
97
103
|
if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) return { ok: false, reason: "not-object" };
|
|
@@ -101,6 +107,9 @@ export function open(envelope, { cryptoInventory } = {}) {
|
|
|
101
107
|
if (cryptoInventory && cryptoInventory[sig.alg] !== "active") {
|
|
102
108
|
return { ok: false, reason: `alg-not-active:${sig.alg}` };
|
|
103
109
|
}
|
|
110
|
+
if (envelope.delegation !== undefined) {
|
|
111
|
+
return openDelegated(envelope, { cryptoInventory });
|
|
112
|
+
}
|
|
104
113
|
if (sig.keyId !== envelope.sender) return { ok: false, reason: "keyId-sender-mismatch" };
|
|
105
114
|
if (typeof sig.publicKey !== "string" || !/^[0-9a-f]{64}$/i.test(sig.publicKey)) return { ok: false, reason: "bad-publicKey" };
|
|
106
115
|
if (typeof sig.signature !== "string" || !/^[0-9a-f]{128}$/i.test(sig.signature)) return { ok: false, reason: "bad-signature-encoding" };
|
|
@@ -136,6 +145,78 @@ export function open(envelope, { cryptoInventory } = {}) {
|
|
|
136
145
|
return { ok: true };
|
|
137
146
|
}
|
|
138
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Delegated path of open() — UACP/1.0 extension. Invoked when the envelope
|
|
150
|
+
* carries a `delegation` document; the legacy self-authenticating-sender rule
|
|
151
|
+
* is REPLACED by the five delegation checks (see delegation.mjs):
|
|
152
|
+
*
|
|
153
|
+
* (a) envelope signature valid under the DELEGATE's key,
|
|
154
|
+
* transportSignature.keyId === delegation.delegate;
|
|
155
|
+
* (b) delegation document signature valid under the PRINCIPAL's key,
|
|
156
|
+
* delegation.principal === envelope.sender,
|
|
157
|
+
* delegation.delegate === the signing key's uuaid;
|
|
158
|
+
* (c) local-id binding for BOTH identities (inside verifyDelegationDoc);
|
|
159
|
+
* (d) now within [notBefore, notAfter] (inside verifyDelegationDoc);
|
|
160
|
+
* (e) envelope.kind covered by delegation.kinds.
|
|
161
|
+
*
|
|
162
|
+
* Fail-open is not an option: any failure rejects with
|
|
163
|
+
* { ok: false, reason: "delegation-invalid:<reason>", code: 403 }. Pure
|
|
164
|
+
* wire-format failures (enc block, hex encodings) keep the legacy reasons and
|
|
165
|
+
* 400 status — they are not delegation verdicts.
|
|
166
|
+
*
|
|
167
|
+
* The `delegation` field is part of the SIGNED body (sealed inside the
|
|
168
|
+
* canonicalization by the delegate), so a carrier cannot strip or swap it
|
|
169
|
+
* without breaking the envelope signature.
|
|
170
|
+
*/
|
|
171
|
+
function openDelegated(envelope, { cryptoInventory } = {}) {
|
|
172
|
+
const reject = (reason) => ({ ok: false, reason: `delegation-invalid:${reason}`, code: 403 });
|
|
173
|
+
const sig = envelope.transportSignature;
|
|
174
|
+
if (typeof sig.publicKey !== "string" || !/^[0-9a-f]{64}$/i.test(sig.publicKey)) return { ok: false, reason: "bad-publicKey" };
|
|
175
|
+
if (typeof sig.signature !== "string" || !/^[0-9a-f]{128}$/i.test(sig.signature)) return { ok: false, reason: "bad-signature-encoding" };
|
|
176
|
+
// Structural checks on the encryption block — same rules as the legacy path.
|
|
177
|
+
const enc = envelope.enc;
|
|
178
|
+
if (!enc || typeof enc !== "object") return { ok: false, reason: "missing-enc" };
|
|
179
|
+
if (enc.alg !== E2E_ALG) return { ok: false, reason: `enc-alg-not-supported:${enc.alg}` };
|
|
180
|
+
if (cryptoInventory && cryptoInventory[enc.alg] && cryptoInventory[enc.alg] !== "active") {
|
|
181
|
+
return { ok: false, reason: `enc-alg-not-active:${enc.alg}` };
|
|
182
|
+
}
|
|
183
|
+
if (typeof enc.epk !== "string" || !/^[0-9a-f]{64}$/i.test(enc.epk)) return { ok: false, reason: "bad-epk" };
|
|
184
|
+
if (typeof enc.iv !== "string" || !/^[0-9a-f]{24}$/i.test(enc.iv)) return { ok: false, reason: "bad-iv" };
|
|
185
|
+
if (typeof enc.ct !== "string" || !/^[0-9a-f]{32,}$/i.test(enc.ct)) return { ok: false, reason: "bad-ct" };
|
|
186
|
+
|
|
187
|
+
const d = envelope.delegation;
|
|
188
|
+
// (b-i) + (c) + (d): the delegation document itself — shape, both local-id
|
|
189
|
+
// bindings, time window, and the principal's signature over it.
|
|
190
|
+
const dv = verifyDelegationDoc(d);
|
|
191
|
+
if (!dv.ok) return reject(dv.reason);
|
|
192
|
+
// (b-ii) the persona this envelope acts as must be the delegation principal.
|
|
193
|
+
if (d.principal !== envelope.sender) return reject("sender-not-principal");
|
|
194
|
+
// (b-iii) the actual signer must be the named delegate, key and all.
|
|
195
|
+
if (sig.keyId !== d.delegate) return reject("signer-not-delegate");
|
|
196
|
+
if (sig.publicKey !== d.delegatePublicKey) return reject("signer-key-mismatch");
|
|
197
|
+
// (e) the delegation must cover this envelope kind.
|
|
198
|
+
//
|
|
199
|
+
// A capability name is checked FIRST and unconditionally — including under
|
|
200
|
+
// kinds:["*"] — because these names are not envelope kinds at all. Without
|
|
201
|
+
// this, a read-only kinds:["inbox"] poll token doubles as authority to send
|
|
202
|
+
// envelopes of kind "inbox" as the principal. See CAPABILITY_ONLY_KINDS.
|
|
203
|
+
if (CAPABILITY_ONLY_KINDS.has(envelope.kind)) return reject("kind-reserved-capability");
|
|
204
|
+
if (!delegationCoversKind(d, envelope.kind)) return reject("kind-not-covered");
|
|
205
|
+
// (a) the envelope signature under the delegate key, over the canonical
|
|
206
|
+
// body INCLUDING the delegation field (exactly what the delegate signed).
|
|
207
|
+
const { transportSignature: _sig, ...withoutSig } = envelope;
|
|
208
|
+
let canonical;
|
|
209
|
+
try { canonical = jcs(withoutSig); } catch (_e) { return { ok: false, reason: "bad-canonical" }; }
|
|
210
|
+
let okSig;
|
|
211
|
+
try {
|
|
212
|
+
okSig = Keychain.verifyDetached(sig.publicKey, Buffer.from(canonical, "utf-8"), Buffer.from(sig.signature, "hex"));
|
|
213
|
+
} catch (_e) {
|
|
214
|
+
return { ok: false, reason: "verify-threw" };
|
|
215
|
+
}
|
|
216
|
+
if (!okSig) return reject("bad-envelope-signature");
|
|
217
|
+
return { ok: true, delegated: true, actingFor: d.principal };
|
|
218
|
+
}
|
|
219
|
+
|
|
139
220
|
/**
|
|
140
221
|
* Decrypt an envelope's payload. Only the recipient can do this.
|
|
141
222
|
* keychain must be the recipient's loaded keychain.
|