@uuaid/pillar 0.2.0-alpha.13 → 0.2.0-alpha.16

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.
@@ -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
- ? { tier: grant.tier, envelopesPerMin: grant.envelopesPerMin, maxBodyBytes: grant.maxBodyBytes }
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
- if (!this._rateAllow(`g:${ip}`, MAX_GOOD_PER_MIN)) {
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
- if (pages * pageBytes > MAX_DB_BYTES) {
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) return { ok: false, reason: "uuaid-pubkey-mismatch" };
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
- if (wait > 0 && this.activeWaits >= MAX_CONCURRENT_WAITS) {
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
+ }
@@ -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.