@uuaid/pillar 0.2.0-alpha.10 → 0.2.0-alpha.11

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/README.md CHANGED
@@ -77,13 +77,66 @@ to build a private mesh.
77
77
  |---|---|---|
78
78
  | `pillar-responder` | Always-on auto-attendant: acks requests instantly, replies from fixed text or a command hook. Run it as a service; agents anywhere get ack+reply without you being live. | one process, outbound HTTPS |
79
79
  | `pillar-seed` | A carrier seed — makes you part of the p2p mesh. Any agent that lists your URL replicates every envelope to you and polls you in parallel with other seeds. | one process, one SQLite file, TLS-terminating proxy in front |
80
- | `pillar` | The full CLI (`init`, `id`, `send`, `send-group`, `recv`, `inbox`, `peers`) — optional libp2p DHT, or carrier-only. | only when used interactively |
80
+ | `pillar` | The full CLI (`init`, `id`, `send`, `send-group`, `recv`, `inbox`, `trust`, `tier`, `peers`) — optional libp2p DHT, or carrier-only. | only when used interactively |
81
81
 
82
82
  ```sh
83
83
  pillar-responder --home ~/.pillar-responder --reply-text "ack" &
84
84
  pillar-seed --port 8799 --db /var/lib/pillar/seed.db & # loopback by default
85
85
  ```
86
86
 
87
+ ## Abuse resistance — consent at the edge, budgets at the carrier
88
+
89
+ Identity is unforgeable (the sender derives from the signing key), the
90
+ carrier is rate-limited and payload-blind, and **consent lives with the
91
+ recipient**:
92
+
93
+ ```sh
94
+ pillar trust set directory # only address-book senders reach handlers
95
+ pillar trust allow uuaid:... # enroll a specific sender (any policy)
96
+ pillar trust block uuaid:... # never deliver (stored as quarantined evidence)
97
+ pillar trust quarantine # inspect gated mail; --release ID to accept one
98
+ ```
99
+
100
+ Policies: `open` (default — today's behavior), `directory` (address-book
101
+ senders only), `allowlist` (explicit allow list only). Gated mail is
102
+ **quarantined, never dropped** — it stays in the chain-hashed mailbox with a
103
+ reason, invisible to handlers. Genuine `ack`/`reply` receipts for your own
104
+ outbox always pass. `pillar-responder` defaults its command hook to the
105
+ `directory` policy: strangers still get ack + a fixed reply (first contact
106
+ keeps working), but their payload never reaches your hook process; per-sender
107
+ reply rate is capped.
108
+
109
+ ## Tier grants — qualified volume for foundations, orgs, enterprises
110
+
111
+ Default (community) carrier budgets: 120 envelopes/min per sender, 512 KiB
112
+ per envelope. Organizations that qualify through the UUAID Foundation's
113
+ review receive a **signed tier grant** — a portable JSON credential
114
+ (Ed25519, expiry-bound, offline-verifiable) that raises their budgets at any
115
+ carrier that trusts the issuer:
116
+
117
+ | Tier | Envelopes/min | Max body |
118
+ |---|---|---|
119
+ | community (default, no grant) | 120 | 512 KiB |
120
+ | supporter | 300 | 1 MiB |
121
+ | org | 600 | 2 MiB |
122
+ | enterprise | 1200 | 2 MiB |
123
+ | foundation | 3000 | 2 MiB |
124
+
125
+ ```sh
126
+ # Issuer (e.g. the foundation identity) signs a grant:
127
+ pillar tier issue uuaid:foundation:agent:... org --expires 2027-01-01T00:00:00Z
128
+ # Grantee installs it (auto-attached to every carrier deliver):
129
+ pillar tier install tier-grant-<fp>.json
130
+ # Carrier operator opts in:
131
+ pillar-carrier --tier-issuers <issuer-pubkey-hex>
132
+ ```
133
+
134
+ Grants are verified locally (signature + expiry + ceiling clamp) — no
135
+ registry lookup, no account, consistent with the self-authenticating design.
136
+ An invalid or foreign grant silently falls back to community budgets; it can
137
+ never break plain delivery. Wire format unchanged: the grant rides an HTTP
138
+ header (`x-pillar-tier-grant`), not the envelope.
139
+
87
140
  ## Seed topology — how messages survive
88
141
 
89
142
  On send, `deliver()` replicates the envelope to **every** carrier in your set
package/bin/carrier.mjs CHANGED
@@ -29,12 +29,17 @@ const port = Number(args.port ?? process.env.CARRIER_PORT ?? 8799);
29
29
  const host = args.host ?? process.env.CARRIER_HOST ?? "127.0.0.1";
30
30
  const dbPath = args.db ?? process.env.CARRIER_DB ?? "carrier.db";
31
31
  const ttlDays = Number(args.ttl ?? process.env.CARRIER_TTL_DAYS ?? 14);
32
+ // Tier grants: comma-separated issuer pubkeys (hex) whose signed grants this
33
+ // carrier honors for raised budgets. Unset = all senders get community tier.
34
+ const tierIssuers = String(args["tier-issuers"] ?? process.env.CARRIER_TIER_ISSUERS ?? "")
35
+ .split(",").map(s => s.trim()).filter(s => /^[0-9a-f]{64}$/i.test(s));
32
36
 
33
- const server = new CarrierServer({ dbPath, ttlDays });
37
+ const server = new CarrierServer({ dbPath, ttlDays, trustedTierIssuers: tierIssuers });
34
38
  const addr = await server.listen(port, host);
35
39
  console.log(`carrier listening on ${addr.address}:${addr.port}`);
36
40
  console.log(` db: ${dbPath}`);
37
41
  console.log(` ttl: ${ttlDays} days`);
42
+ console.log(` tiers: ${tierIssuers.length ? `${tierIssuers.length} trusted issuer(s)` : "disabled (community budgets for all)"}`);
38
43
 
39
44
  // Vacuum expired envelopes hourly.
40
45
  const vacuumTimer = setInterval(() => {
@@ -38,11 +38,16 @@ import { argv, exit } from "node:process";
38
38
  import { spawn } from "node:child_process";
39
39
  import { Keychain } from "../src/identity/keychain.mjs";
40
40
  import { Pillar } from "../src/pillar.mjs";
41
+ import { loadTrustPolicy, evaluateTrust, POLICIES } from "../src/identity/trust.mjs";
42
+ import { loadFleetAddressBook } from "../src/address-book.mjs";
41
43
 
42
44
  const DEFAULT_REPLY = "pillar-responder: request acked — owner will respond through the normal channel";
45
+ const STRANGER_REPLY = "pillar-responder: request received. This responder runs its command hook only for enrolled senders; ask the operator to `pillar trust allow` your uuaid.";
43
46
  const MAX_REPLY_BYTES = 8192;
44
47
  const HOOK_TIMEOUT_MS = 60_000;
45
48
  const REPLAY_WINDOW_MS = 24 * 3600_000;
49
+ const SENDER_REPLIES_PER_HOUR = 30; // amplification cap: max hook/reply cycles per sender
50
+ const RATE_WINDOW_MS = 3600_000;
46
51
 
47
52
  function parseArgs(argv) {
48
53
  const args = { _: [] };
@@ -162,6 +167,26 @@ if (!carriers.length && !enableLibp2p) {
162
167
 
163
168
  const replyText = args["reply-text"] ?? rcfg.replyText ?? DEFAULT_REPLY;
164
169
  const hookCmd = args.command ?? rcfg.command ?? null;
170
+ // Trust gate. Default policy for the COMMAND HOOK is "directory": strangers
171
+ // still get ack + a fixed reply (universality preserved — first contact
172
+ // works), but attacker-controlled JSON only reaches the hook process from
173
+ // senders the operator knows. --hook-policy open restores old behavior.
174
+ const trust = loadTrustPolicy(home);
175
+ const hookPolicy = args["hook-policy"] ?? rcfg.hookPolicy ?? "directory";
176
+ if (!POLICIES.includes(hookPolicy)) {
177
+ console.error(`--hook-policy must be one of ${POLICIES.join("|")}`);
178
+ exit(2);
179
+ }
180
+ const senderRate = new Map(); // sender uuaid -> [ms timestamps]
181
+ function senderAllowed(sender) {
182
+ const now = Date.now();
183
+ let arr = senderRate.get(sender);
184
+ if (!arr) { arr = []; senderRate.set(sender, arr); }
185
+ while (arr.length && now - arr[0] > RATE_WINDOW_MS) arr.shift();
186
+ if (arr.length >= SENDER_REPLIES_PER_HOUR) return false;
187
+ arr.push(now);
188
+ return true;
189
+ }
165
190
  const hookTimeoutMs = Number(args["timeout-ms"] ?? rcfg.timeoutMs ?? HOOK_TIMEOUT_MS);
166
191
  const hookMaxBytes = Number(args["max-reply-bytes"] ?? MAX_REPLY_BYTES);
167
192
  const fwd = args["forward-to"] || rcfg.forwardTo?.uuaid
@@ -187,10 +212,14 @@ const p = new Pillar({
187
212
 
188
213
  await p.start();
189
214
 
215
+ // Address book feeds the "directory" hook policy (known senders = fleet.json).
216
+ const abCount = loadFleetAddressBook(join(home, "fleet.json"), p);
217
+
190
218
  console.log(`pillar-responder up — ${p.uuaid}`);
191
219
  console.log(` pubkey: ${p.publicKeyHex}`);
192
220
  console.log(` carriers: ${carriers.length ? carriers.join(", ") : "(none — libp2p only)"}`);
193
221
  console.log(` reply: ${hookCmd ? `hook ${hookCmd}` : "builtin text"}`);
222
+ if (hookCmd) console.log(` hook gate: policy=${hookPolicy}, known senders=${abCount}, cap=${SENDER_REPLIES_PER_HOUR}/h per sender`);
194
223
  if (fwd) console.log(` forwardTo: ${fwd.uuaid}`);
195
224
 
196
225
  // Replay journaled-but-unfinished requests first (at-least-once).
@@ -215,7 +244,21 @@ p.onEnvelope(async (env) => {
215
244
  const thread = env.payload?.thread ?? env.id;
216
245
  let status = "ok";
217
246
  let text = replyText;
218
- if (hookCmd) {
247
+ // Amplification cap: over-budget senders get ack only, no reply/hook.
248
+ if (!senderAllowed(env.sender)) {
249
+ await p.send({ recipient: env.sender, recipientPublicKey: senderKey, kind: "ack", payload: { inReplyTo: env.id } });
250
+ journalDone(home, env.id, { text: "(rate-capped: ack only)", status: "capped" });
251
+ console.log(` ${env.sender.slice(-8)} request ${env.id.slice(0, 16)}… → ack only (rate cap)`);
252
+ return;
253
+ }
254
+ // Hook gate: strangers get the fixed reply, never the command hook.
255
+ const gate = evaluateTrust({ ...trust, policy: hookPolicy }, env.sender, new Set(p.addressBook?.keys() ?? []));
256
+ if (gate.reason === "blocked") {
257
+ journalDone(home, env.id, { text: "(blocked sender: dropped)", status: "blocked" });
258
+ console.log(` ${env.sender.slice(-8)} request ${env.id.slice(0, 16)}… → dropped (blocked)`);
259
+ return;
260
+ }
261
+ if (hookCmd && gate.deliver) {
219
262
  const r = await runHook(hookCmd, {
220
263
  id: env.id,
221
264
  sender: env.sender,
@@ -229,6 +272,8 @@ p.onEnvelope(async (env) => {
229
272
  status = "error";
230
273
  text = r.reason;
231
274
  }
275
+ } else if (hookCmd && !gate.deliver) {
276
+ text = STRANGER_REPLY; // stranger: fixed reply, hook never sees the payload
232
277
  }
233
278
  await p.send({ recipient: env.sender, recipientPublicKey: senderKey, kind: "ack", payload: { inReplyTo: env.id } });
234
279
  const replyPayload = { thread, inReplyTo: env.id, status, text };
package/bin/pillar.mjs CHANGED
@@ -9,6 +9,8 @@
9
9
  * pillar recv [--home ~/.pillar] [--follow] listen for envelopes and print them
10
10
  * pillar inbox [--home ~/.pillar] [--unread] [--notify CMD] [--mark-read]
11
11
  * one-shot: pull pending mail, print, exit
12
+ * pillar trust [set|allow|block|unlist|quarantine] recipient consent policy
13
+ * pillar tier [issue|install|verify] signed volume/size tier grants
12
14
  * pillar peers list currently connected peers
13
15
  *
14
16
  * All commands respect --home to point at an alternate pillar directory,
@@ -21,6 +23,7 @@ import { argv, exit } from "node:process";
21
23
  import { Keychain } from "../src/identity/keychain.mjs";
22
24
  import { Pillar } from "../src/pillar.mjs";
23
25
  import { loadFleetAddressBook } from "../src/address-book.mjs";
26
+ import { loadTrustPolicy, POLICIES } from "../src/identity/trust.mjs";
24
27
 
25
28
  function parseArgs(argv) {
26
29
  const args = { _: [] };
@@ -60,6 +63,19 @@ function loadPillar(args) {
60
63
  const carriers = args.carrier
61
64
  ? [args.carrier]
62
65
  : (process.env.PILLAR_CARRIERS ? process.env.PILLAR_CARRIERS.split(",") : (cfg.carriers ?? []));
66
+ // Trust gate: --policy overrides trust.json's policy for this invocation.
67
+ const trust = loadTrustPolicy(home);
68
+ if (args.policy) {
69
+ if (!POLICIES.includes(args.policy)) {
70
+ console.error(`--policy must be one of ${POLICIES.join("|")}`);
71
+ exit(2);
72
+ }
73
+ trust.policy = args.policy;
74
+ }
75
+ // Tier grant: <home>/tier-grant.json (issued via `pillar tier issue`) is
76
+ // attached to carrier delivers — raises budgets at carriers trusting the issuer.
77
+ const tgPath = join(home, "tier-grant.json");
78
+ const tierGrant = existsSync(tgPath) ? JSON.parse(readFileSync(tgPath, "utf-8")) : null;
63
79
  return new Pillar({
64
80
  keychain: kc,
65
81
  mailboxPath: mbPath,
@@ -69,6 +85,8 @@ function loadPillar(args) {
69
85
  dhtClientMode: cfg.dhtClientMode ?? true,
70
86
  carriers,
71
87
  enableLibp2p: !(args["carrier-only"] || cfg.carrierOnly === true),
88
+ trust,
89
+ tierGrant,
72
90
  });
73
91
  }
74
92
 
@@ -414,6 +432,9 @@ async function cmdInbox(args) {
414
432
  const fresh = [];
415
433
  p.onEnvelope((env) => { fresh.push(env); });
416
434
  await p.start();
435
+ // Address book BEFORE the drain: the trust "directory" policy needs known
436
+ // senders loaded, or every fresh envelope counts as a stranger.
437
+ loadFleetAddressBook(fleetPath(args), p);
417
438
  const pulled = await p.drainCarrierInbox();
418
439
  for (const env of fresh) {
419
440
  console.log(JSON.stringify({ new: true, id: env.id, from: env.sender, kind: env.kind, at: env.createdAt, payload: env.payload }));
@@ -430,7 +451,8 @@ async function cmdInbox(args) {
430
451
  console.log(JSON.stringify({ new: false, id: env.id, from: env.sender, kind: env.kind, at: env.createdAt, payload }));
431
452
  }
432
453
  }
433
- console.log(`inbox: ${pulled} new, ${backlog.length} older unread${args.unread ? "" : " (--unread to print)"}`);
454
+ const qCount = (p.mailbox.inboxQuarantined?.(500) ?? []).length;
455
+ console.log(`inbox: ${fresh.length} new, ${backlog.length} older unread${args.unread ? "" : " (--unread to print)"}${qCount ? `, ${qCount} quarantined (pillar trust quarantine)` : ""}`);
434
456
  if (args["mark-read"]) {
435
457
  for (const env of fresh) p.mailbox.markInboxRead?.(env.id);
436
458
  if (args.unread) for (const row of backlog) p.mailbox.markInboxRead?.(row.envelope_id);
@@ -449,6 +471,144 @@ async function cmdInbox(args) {
449
471
  exit(0);
450
472
  }
451
473
 
474
+ async function cmdTrust(args) {
475
+ // pillar trust show policy + lists + quarantine count
476
+ // pillar trust set <open|directory|allowlist>
477
+ // pillar trust allow|block <uuaid> add to list (removes from the other)
478
+ // pillar trust unlist <uuaid> remove from both lists
479
+ // pillar trust quarantine [--release ID] list quarantined mail / release one
480
+ const home = homePath(args);
481
+ const tPath = join(home, "trust.json");
482
+ const t = loadTrustPolicy(home);
483
+ const sub = args._[1];
484
+ const save = () => writeFileSync(tPath, JSON.stringify({ policy: t.policy, allow: t.allow, block: t.block }, null, 2));
485
+ if (!sub) {
486
+ console.log(`policy: ${t.policy}${t.exists ? "" : " (no trust.json — default)"}`);
487
+ console.log(`allow (${t.allow.length}):`); for (const u of t.allow) console.log(` ${u}`);
488
+ console.log(`block (${t.block.length}):`); for (const u of t.block) console.log(` ${u}`);
489
+ const { Mailbox } = await import("../src/mailbox/mailbox.mjs");
490
+ const mb = new Mailbox(join(home, "mailbox.db"));
491
+ const q = mb.inboxQuarantined(500);
492
+ console.log(`quarantined: ${q.length} (pillar trust quarantine to list)`);
493
+ mb.close();
494
+ return;
495
+ }
496
+ if (sub === "set") {
497
+ const pol = args._[2];
498
+ if (!POLICIES.includes(pol)) { console.error(`usage: pillar trust set <${POLICIES.join("|")}>`); exit(2); }
499
+ t.policy = pol; save();
500
+ console.log(`policy: ${pol} (${tPath})`);
501
+ return;
502
+ }
503
+ if (sub === "allow" || sub === "block") {
504
+ const u = args._[2];
505
+ if (!u?.startsWith("uuaid:")) { console.error(`usage: pillar trust ${sub} <uuaid:...>`); exit(2); }
506
+ t.allow = t.allow.filter(x => x !== u);
507
+ t.block = t.block.filter(x => x !== u);
508
+ t[sub === "allow" ? "allow" : "block"].push(u);
509
+ save();
510
+ console.log(`${sub}: ${u}`);
511
+ return;
512
+ }
513
+ if (sub === "unlist") {
514
+ const u = args._[2];
515
+ t.allow = t.allow.filter(x => x !== u);
516
+ t.block = t.block.filter(x => x !== u);
517
+ save();
518
+ console.log(`unlisted: ${u}`);
519
+ return;
520
+ }
521
+ if (sub === "quarantine") {
522
+ const { Mailbox } = await import("../src/mailbox/mailbox.mjs");
523
+ const mb = new Mailbox(join(home, "mailbox.db"));
524
+ if (args.release) {
525
+ mb.db.prepare("update inbox set quarantined = null where envelope_id = ?").run(args.release);
526
+ console.log(`released: ${args.release} (now visible to pillar inbox --unread)`);
527
+ mb.close();
528
+ return;
529
+ }
530
+ for (const row of mb.inboxQuarantined(500)) {
531
+ console.log(JSON.stringify({ id: row.envelope_id, from: row.sender, reason: row.quarantined, at: row.received_at }));
532
+ }
533
+ mb.close();
534
+ return;
535
+ }
536
+ console.error("usage: pillar trust [set <policy>|allow <uuaid>|block <uuaid>|unlist <uuaid>|quarantine [--release ID]]");
537
+ exit(2);
538
+ }
539
+
540
+ async function cmdTier(args) {
541
+ // pillar tier show installed grant
542
+ // pillar tier issue <uuaid> <tier> [--expires ISO] [--rate N] [--max-body N] [--out FILE]
543
+ // sign a grant with THIS home's key (issuer)
544
+ // pillar tier install <grant.json> install a received grant into this home
545
+ // pillar tier verify <grant.json> --issuer HEX verify signature/expiry locally
546
+ const { makeTierGrant, verifyTierGrant, grantFingerprint, TIER_DEFAULTS } =
547
+ await import("../src/identity/tier.mjs");
548
+ const home = homePath(args);
549
+ const sub = args._[1];
550
+ if (!sub) {
551
+ const tgPath = join(home, "tier-grant.json");
552
+ if (!existsSync(tgPath)) { console.log("no tier grant installed (community tier)"); return; }
553
+ const g = JSON.parse(readFileSync(tgPath, "utf-8"));
554
+ console.log(`tier: ${g.tier} rate: ${g.envelopesPerMin}/min maxBody: ${g.maxBodyBytes}B`);
555
+ console.log(`subject: ${g.uuaid}`);
556
+ console.log(`issuer: ${g.issuer}`);
557
+ console.log(`expires: ${g.expires} fingerprint: ${grantFingerprint(g)}`);
558
+ return;
559
+ }
560
+ if (sub === "issue") {
561
+ const [, , uuaid, tier] = args._;
562
+ if (!uuaid || !TIER_DEFAULTS[tier]) {
563
+ console.error(`usage: pillar tier issue <uuaid:...> <${Object.keys(TIER_DEFAULTS).join("|")}> [--expires ISO] [--rate N] [--max-body BYTES] [--out FILE]`);
564
+ exit(2);
565
+ }
566
+ const kc = new Keychain(join(home, "keychain.json"));
567
+ kc.load({ passphrase: process.env.PILLAR_PASSPHRASE ?? "" });
568
+ const expires = args.expires ?? new Date(Date.now() + 365 * 86400_000).toISOString();
569
+ const grant = makeTierGrant(kc, {
570
+ uuaid, tier, expires,
571
+ envelopesPerMin: args.rate ? Number(args.rate) : undefined,
572
+ maxBodyBytes: args["max-body"] ? Number(args["max-body"]) : undefined,
573
+ });
574
+ const out = typeof args.out === "string" ? args.out : `tier-grant-${grantFingerprint(grant)}.json`;
575
+ writeFileSync(out, JSON.stringify(grant, null, 2));
576
+ console.log(`issued ${tier} grant for ${uuaid}`);
577
+ console.log(` rate ${grant.envelopesPerMin}/min, maxBody ${grant.maxBodyBytes}B, expires ${expires}`);
578
+ console.log(` issuer ${grant.issuer}`);
579
+ console.log(` -> ${out} (send to the grantee; they run: pillar tier install ${out})`);
580
+ return;
581
+ }
582
+ if (sub === "install") {
583
+ const file = args._[2];
584
+ if (!file || !existsSync(file)) { console.error("usage: pillar tier install <grant.json>"); exit(2); }
585
+ const g = JSON.parse(readFileSync(file, "utf-8"));
586
+ const kc = new Keychain(join(home, "keychain.json"));
587
+ kc.load({ passphrase: process.env.PILLAR_PASSPHRASE ?? "" });
588
+ if (g.uuaid !== kc._identity.uuaid) {
589
+ console.error(`grant subject ${g.uuaid} != this home's identity ${kc._identity.uuaid}`);
590
+ exit(1);
591
+ }
592
+ const v = verifyTierGrant(g, [g.issuer]); // structural check (sig by named issuer, expiry, ceilings)
593
+ if (!v.ok) { console.error(`grant invalid: ${v.reason}`); exit(1); }
594
+ writeFileSync(join(home, "tier-grant.json"), JSON.stringify(g, null, 2));
595
+ console.log(`installed ${g.tier} grant (fingerprint ${grantFingerprint(g)}) — sent with every carrier deliver`);
596
+ console.log(`note: budgets rise only at carriers that trust issuer ${g.issuer.slice(0, 16)}…`);
597
+ return;
598
+ }
599
+ if (sub === "verify") {
600
+ const file = args._[2];
601
+ const issuer = args.issuer;
602
+ if (!file || !existsSync(file) || !issuer) { console.error("usage: pillar tier verify <grant.json> --issuer HEX"); exit(2); }
603
+ const g = JSON.parse(readFileSync(file, "utf-8"));
604
+ const v = verifyTierGrant(g, [String(issuer)]);
605
+ console.log(v.ok ? `VALID: ${g.tier} for ${g.uuaid} until ${g.expires}` : `INVALID: ${v.reason}`);
606
+ exit(v.ok ? 0 : 1);
607
+ }
608
+ console.error("usage: pillar tier [issue|install|verify]");
609
+ exit(2);
610
+ }
611
+
452
612
  async function cmdPeers(args) {
453
613
  const p = loadPillar(args);
454
614
  await p.start();
@@ -469,9 +629,11 @@ async function main() {
469
629
  case "groups": return cmdGroups(args);
470
630
  case "recv": return cmdRecv(args);
471
631
  case "inbox": return cmdInbox(args);
632
+ case "trust": return cmdTrust(args);
633
+ case "tier": return cmdTier(args);
472
634
  case "peers": return cmdPeers(args);
473
635
  default:
474
- console.error(`usage: pillar <init|id|send|send-group|groups|recv|inbox|peers> [--home DIR]`);
636
+ console.error(`usage: pillar <init|id|send|send-group|groups|recv|inbox|trust|tier|peers> [--home DIR]`);
475
637
  exit(2);
476
638
  }
477
639
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uuaid/pillar",
3
- "version": "0.2.0-alpha.10",
3
+ "version": "0.2.0-alpha.11",
4
4
  "description": "Pillar (Protocol for Inter-agent Linked Ledger And Relay) — the secure delivery layer of UACP, the Universal Agent Communication Protocol. UACP = UUAID (identity) + Envelope (signed, encrypted container) + Pillar (relay, mailbox, routing, store-and-forward). IAASO-3050 reference implementation: one command to mint an identity and join the network.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Tier grants — carrier-side volume/size flexibility for qualified identities.
3
+ *
4
+ * Foundations, orgs, and enterprise supporters that pass the UUAID
5
+ * qualification process receive a SIGNED TIER GRANT: a portable JSON object,
6
+ * Ed25519-signed by a trusted issuer (the UUAID Foundation identity, or any
7
+ * issuer a carrier operator chooses to trust). Carriers verify the signature
8
+ * and expiry locally — no registry lookup, no account, consistent with the
9
+ * self-authenticating design. The wire API is unchanged: a grant only
10
+ * raises the holder's rate/size budgets at carriers that trust the issuer.
11
+ *
12
+ * Qualification itself is off-wire (the foundation's review process decides
13
+ * WHO gets a grant); this module only makes grants unforgeable and portable.
14
+ *
15
+ * Grant shape (JCS-signed over every field except `sig`):
16
+ * { v: 1, uuaid, tier, envelopesPerMin, maxBodyBytes, expires, issuer, sig }
17
+ */
18
+ import { createHash } from "node:crypto";
19
+ import { jcs } from "./jcs.mjs";
20
+ import { Keychain } from "./keychain.mjs";
21
+
22
+ /** Tier ladder. community = the unauthenticated default (F2 hardening values). */
23
+ export const TIER_DEFAULTS = {
24
+ community: { envelopesPerMin: 120, maxBodyBytes: 512 * 1024 },
25
+ supporter: { envelopesPerMin: 300, maxBodyBytes: 1024 * 1024 },
26
+ org: { envelopesPerMin: 600, maxBodyBytes: 2 * 1024 * 1024 },
27
+ enterprise: { envelopesPerMin: 1200, maxBodyBytes: 2 * 1024 * 1024 },
28
+ foundation: { envelopesPerMin: 3000, maxBodyBytes: 2 * 1024 * 1024 },
29
+ };
30
+
31
+ /** Hard ceiling any grant can reach — also the carrier's raw-body stream cap. */
32
+ export const ABSOLUTE_MAX_BODY = 2 * 1024 * 1024;
33
+ export const ABSOLUTE_MAX_RATE = 6000;
34
+
35
+ function signable(grant) {
36
+ const { sig, ...rest } = grant;
37
+ return jcs(rest);
38
+ }
39
+
40
+ /**
41
+ * Issue a grant, signed by the loaded keychain (the issuer identity).
42
+ * Rates/sizes default from the tier ladder; explicit overrides are clamped
43
+ * to the absolute ceilings.
44
+ */
45
+ export function makeTierGrant(keychain, { uuaid, tier, envelopesPerMin, maxBodyBytes, expires }) {
46
+ if (!TIER_DEFAULTS[tier]) throw new Error(`unknown tier "${tier}" (${Object.keys(TIER_DEFAULTS).join("|")})`);
47
+ if (!uuaid?.startsWith("uuaid:")) throw new Error("grant subject must be a uuaid:...");
48
+ if (!expires || !Number.isFinite(Date.parse(expires))) throw new Error("expires must be an ISO date");
49
+ const grant = {
50
+ v: 1,
51
+ uuaid,
52
+ tier,
53
+ envelopesPerMin: Math.min(envelopesPerMin ?? TIER_DEFAULTS[tier].envelopesPerMin, ABSOLUTE_MAX_RATE),
54
+ maxBodyBytes: Math.min(maxBodyBytes ?? TIER_DEFAULTS[tier].maxBodyBytes, ABSOLUTE_MAX_BODY),
55
+ expires,
56
+ issuer: keychain._identity.publicKeyHex,
57
+ };
58
+ grant.sig = keychain.sign(Buffer.from(signable(grant), "utf-8")).toString("hex");
59
+ return grant;
60
+ }
61
+
62
+ /**
63
+ * Verify a grant against a set of trusted issuer pubkeys (hex).
64
+ * Returns { ok: true, grant } or { ok: false, reason }.
65
+ */
66
+ export function verifyTierGrant(grant, trustedIssuers, now = Date.now()) {
67
+ if (grant?.v !== 1) return { ok: false, reason: "bad-version" };
68
+ if (!TIER_DEFAULTS[grant.tier]) return { ok: false, reason: "unknown-tier" };
69
+ if (!trustedIssuers?.includes(grant.issuer)) return { ok: false, reason: "untrusted-issuer" };
70
+ const exp = Date.parse(grant.expires);
71
+ if (!Number.isFinite(exp) || exp < now) return { ok: false, reason: "expired" };
72
+ if (!(grant.envelopesPerMin <= ABSOLUTE_MAX_RATE) || !(grant.maxBodyBytes <= ABSOLUTE_MAX_BODY)) {
73
+ return { ok: false, reason: "over-ceiling" };
74
+ }
75
+ let verified = false;
76
+ try {
77
+ verified = Keychain.verifyDetached(grant.issuer, Buffer.from(signable(grant), "utf-8"), Buffer.from(grant.sig, "hex"));
78
+ } catch (_e) { return { ok: false, reason: "verify-threw" }; }
79
+ if (!verified) return { ok: false, reason: "bad-signature" };
80
+ return { ok: true, grant };
81
+ }
82
+
83
+ export function grantFingerprint(grant) {
84
+ return createHash("sha256").update(signable(grant)).digest("hex").slice(0, 16);
85
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Trust policy — the UUAID-layer consent gate (recipient-side).
3
+ *
4
+ * The envelope layer proves WHO sent a message and that it is intact; it says
5
+ * nothing about whether you WANT it. This module decides, per sender, whether
6
+ * an incoming envelope reaches handlers or is quarantined (stored in the
7
+ * mailbox with a reason, never surfaced to handlers — evidence, not /dev/null).
8
+ *
9
+ * Policy file: <home>/trust.json
10
+ * {
11
+ * "policy": "open" | "directory" | "allowlist",
12
+ * "allow": ["uuaid:...", ...], // always reach handlers
13
+ * "block": ["uuaid:...", ...] // never reach handlers (any policy)
14
+ * }
15
+ *
16
+ * Semantics (evaluated in order):
17
+ * 1. block list -> quarantine("blocked") (all policies)
18
+ * 2. allow list -> deliver (all policies)
19
+ * 3. policy=open -> deliver (default; today's behavior)
20
+ * 4. policy=directory -> deliver if sender is in the address book
21
+ * (fleet.json / directory agents), else quarantine("stranger")
22
+ * 5. policy=allowlist -> quarantine("not-allowlisted")
23
+ *
24
+ * No trust.json file = policy "open" = exactly the pre-alpha.11 behavior.
25
+ * Wire-frozen: this is a local, recipient-side decision only.
26
+ */
27
+ import { existsSync, readFileSync } from "node:fs";
28
+ import { join } from "node:path";
29
+
30
+ export const POLICIES = ["open", "directory", "allowlist"];
31
+
32
+ export function loadTrustPolicy(home) {
33
+ const p = join(home, "trust.json");
34
+ if (!existsSync(p)) return { policy: "open", allow: [], block: [], path: p, exists: false };
35
+ let raw;
36
+ try { raw = JSON.parse(readFileSync(p, "utf-8")); }
37
+ catch (e) { throw new Error(`trust.json unparseable at ${p}: ${e.message}`); }
38
+ const policy = raw.policy ?? "open";
39
+ if (!POLICIES.includes(policy)) {
40
+ throw new Error(`trust.json: unknown policy "${policy}" (expected ${POLICIES.join("|")})`);
41
+ }
42
+ const list = (v) => Array.isArray(v) ? v.filter(s => typeof s === "string") : [];
43
+ return { policy, allow: list(raw.allow), block: list(raw.block), path: p, exists: true };
44
+ }
45
+
46
+ /**
47
+ * Decide an envelope's fate. `knownSenders` is a Set of uuaids the recipient
48
+ * already knows (address book / fleet.json / directory agents).
49
+ * Returns { deliver: true } or { deliver: false, reason }.
50
+ */
51
+ export function evaluateTrust(trust, sender, knownSenders) {
52
+ if (trust.block.includes(sender)) return { deliver: false, reason: "blocked" };
53
+ if (trust.allow.includes(sender)) return { deliver: true };
54
+ switch (trust.policy) {
55
+ case "open": return { deliver: true };
56
+ case "directory":
57
+ return knownSenders?.has(sender)
58
+ ? { deliver: true }
59
+ : { deliver: false, reason: "stranger" };
60
+ case "allowlist": return { deliver: false, reason: "not-allowlisted" };
61
+ default: return { deliver: true }; // unreachable (loadTrustPolicy validates)
62
+ }
63
+ }
@@ -69,6 +69,12 @@ export class Mailbox {
69
69
  this.db.pragma("journal_mode = WAL");
70
70
  this.db.pragma("synchronous = NORMAL");
71
71
  this.db.exec(SCHEMA);
72
+ // v2 migration: quarantine marker (trust-policy gate). Nullable text =
73
+ // reason; null = normal mail. Chain hashes are unaffected (metadata only).
74
+ const cols = this.db.pragma("table_info(inbox)").map(c => c.name);
75
+ if (!cols.includes("quarantined")) {
76
+ this.db.exec("alter table inbox add column quarantined text");
77
+ }
72
78
  }
73
79
 
74
80
  close() { try { this.db.close(); } catch (_e) {} }
@@ -140,6 +146,11 @@ export class Mailbox {
140
146
  })();
141
147
  }
142
148
 
149
+ /** True if we sent this envelope (trust gate: is a receipt answering OUR send?). */
150
+ hasOutbound(envelopeId) {
151
+ return !!this.db.prepare("select 1 from outbox where envelope_id = ?").get(envelopeId);
152
+ }
153
+
143
154
  markOutboundDelivered(envelopeId, ackSha256) {
144
155
  this.db.prepare("update outbox set status = 'delivered', delivered_at = ?, ack_sha256 = ? where envelope_id = ?")
145
156
  .run(new Date().toISOString(), ackSha256 ?? null, envelopeId);
@@ -155,9 +166,19 @@ export class Mailbox {
155
166
  return this.db.prepare("select * from inbox where seq > ? order by seq asc limit ?").all(seq, limit);
156
167
  }
157
168
 
158
- /** Envelopes never shown to the operator (read_at null). Oldest first. */
169
+ /** Envelopes never shown to the operator (read_at null, not quarantined). Oldest first. */
159
170
  inboxUnread(limit = 100) {
160
- return this.db.prepare("select * from inbox where read_at is null order by seq asc limit ?").all(limit);
171
+ return this.db.prepare("select * from inbox where read_at is null and quarantined is null order by seq asc limit ?").all(limit);
172
+ }
173
+
174
+ /** Trust-gated envelopes: stored as evidence, never surfaced to handlers. */
175
+ inboxQuarantined(limit = 100) {
176
+ return this.db.prepare("select * from inbox where quarantined is not null order by seq asc limit ?").all(limit);
177
+ }
178
+
179
+ markQuarantined(envelopeId, reason) {
180
+ this.db.prepare("update inbox set quarantined = ? where envelope_id = ?")
181
+ .run(String(reason).slice(0, 100), envelopeId);
161
182
  }
162
183
 
163
184
  markInboxRead(envelopeId) {
@@ -11,11 +11,16 @@ export class CarrierClient {
11
11
  * @param {{keychain: import('../identity/keychain.mjs').Keychain, carriers: string[]}} opts
12
12
  * carriers: base URLs, e.g. ["https://pillar.uuaid.org"]
13
13
  */
14
- constructor({ keychain, carriers }) {
14
+ constructor({ keychain, carriers, tierGrant = null }) {
15
15
  if (!keychain?._identity) throw new Error("CarrierClient needs a loaded keychain");
16
16
  if (!carriers?.length) throw new Error("CarrierClient needs at least one carrier URL");
17
17
  this.keychain = keychain;
18
18
  this.carriers = carriers.map(u => u.replace(/\/+$/, ""));
19
+ // Optional signed tier grant (tier.mjs) — sent as a header on deliver so
20
+ // qualifying senders get raised budgets at carriers that trust the issuer.
21
+ this.tierGrantB64 = tierGrant
22
+ ? Buffer.from(JSON.stringify(tierGrant), "utf-8").toString("base64")
23
+ : null;
19
24
  this._stopPolling = false;
20
25
  }
21
26
 
@@ -40,7 +45,10 @@ export class CarrierClient {
40
45
  try {
41
46
  const res = await fetch(`${base}/v1/envelopes`, {
42
47
  method: "POST",
43
- headers: { "content-type": "application/json" },
48
+ headers: {
49
+ "content-type": "application/json",
50
+ ...(this.tierGrantB64 ? { "x-pillar-tier-grant": this.tierGrantB64 } : {}),
51
+ },
44
52
  body: JSON.stringify(envelope),
45
53
  signal: AbortSignal.timeout(timeoutMs),
46
54
  });
@@ -36,9 +36,11 @@ import { createHash } from "node:crypto";
36
36
  import Database from "better-sqlite3";
37
37
  import { open as openEnvelope } from "./envelope.mjs";
38
38
  import { Keychain } from "../identity/keychain.mjs";
39
+ import { verifyTierGrant } from "../identity/tier.mjs";
39
40
 
40
41
  const MAX_WAIT_S = 30;
41
- const MAX_BODY = 512 * 1024; // 512 KiB per envelope
42
+ const MAX_BODY = 512 * 1024; // 512 KiB per envelope (community tier)
43
+ const MAX_BODY_STREAM = 2 * 1024 * 1024 + 8192; // absolute read cap: max tier body + grant/header slack
42
44
  const MAX_SKEW_MS = 5 * 60 * 1000;
43
45
  // Hardening (RedTeam F2/F3):
44
46
  const MAX_ENVELOPE_AGE_MS = 24 * 3600_000; // F3: ingest freshness — reject skewed createdAt
@@ -51,7 +53,7 @@ const MAX_DB_BYTES = 256 * 1024 * 1024; // F2: SQLite size backpressure (503
51
53
  const RATE_MAP_HARD_CAP = 10_000; // F2: bound memory under key spray
52
54
 
53
55
  export class CarrierServer {
54
- constructor({ dbPath = "carrier.db", ttlDays = 14, cryptoInventory } = {}) {
56
+ constructor({ dbPath = "carrier.db", ttlDays = 14, cryptoInventory, trustedTierIssuers = [] } = {}) {
55
57
  this.db = new Database(dbPath);
56
58
  this.db.pragma("journal_mode = WAL");
57
59
  this.db.exec(`
@@ -73,6 +75,37 @@ export class CarrierServer {
73
75
  this.rateMap = new Map(); // F2: "<kind>:<key>" -> [ms timestamps]
74
76
  this.activeWaits = 0; // F2: long-polls currently sleeping
75
77
  this._closed = false;
78
+ // Tier grants: issuer pubkeys (hex) this carrier honors. Empty = every
79
+ // sender gets community-tier budgets (pre-tier behavior, default).
80
+ this.trustedTierIssuers = trustedTierIssuers;
81
+ this._grantCache = new Map(); // sender uuaid -> { grant, cachedAt }
82
+ }
83
+
84
+ /**
85
+ * Tier resolution for a POST: verify an optional x-pillar-tier-grant header
86
+ * (base64 JSON, issued by makeTierGrant). A valid grant FOR THE CLAIMED
87
+ * SENDER raises that sender's rate/size budget. Invalid/foreign grants are
88
+ * ignored (fall back to community) — never a hard reject, so a bad grant
89
+ * cannot break plain delivery.
90
+ */
91
+ _resolveTier(req, sender) {
92
+ const fallback = { tier: "community", envelopesPerMin: MAX_ENVELOPES_PER_MIN, maxBodyBytes: MAX_BODY };
93
+ if (!this.trustedTierIssuers.length) return fallback;
94
+ const hdr = req?.headers?.["x-pillar-tier-grant"];
95
+ if (!hdr) return fallback;
96
+ let grant;
97
+ try { grant = JSON.parse(Buffer.from(String(hdr), "base64").toString("utf-8")); }
98
+ catch (_e) { return fallback; }
99
+ if (grant?.uuaid !== sender) return fallback; // grant must name the claimed sender
100
+ const cached = this._grantCache.get(sender);
101
+ if (cached && cached.sig === grant.sig && Date.now() - cached.at < 60_000) return cached.resolved;
102
+ const v = verifyTierGrant(grant, this.trustedTierIssuers);
103
+ const resolved = v.ok
104
+ ? { tier: grant.tier, envelopesPerMin: grant.envelopesPerMin, maxBodyBytes: grant.maxBodyBytes }
105
+ : fallback;
106
+ if (this._grantCache.size > 4096) this._grantCache.clear(); // bounded
107
+ this._grantCache.set(sender, { sig: grant.sig, at: Date.now(), resolved });
108
+ return resolved;
76
109
  }
77
110
 
78
111
  /** F2: best-effort client identity, nginx-aware (XFF set by proxy). */
@@ -127,7 +160,7 @@ export class CarrierServer {
127
160
  let size = 0; const chunks = [];
128
161
  req.on("data", (c) => {
129
162
  size += c.length;
130
- if (size > MAX_BODY) { reject(new Error("body too large")); req.destroy(); return; }
163
+ if (size > MAX_BODY_STREAM) { reject(new Error("body too large")); req.destroy(); return; }
131
164
  chunks.push(c);
132
165
  });
133
166
  req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
@@ -135,7 +168,7 @@ export class CarrierServer {
135
168
  });
136
169
  }
137
170
 
138
- _handlePost(envelope, ip = "unknown") {
171
+ _handlePost(envelope, ip = "unknown", req = null, rawBytes = 0) {
139
172
  const verdict = openEnvelope(envelope, { cryptoInventory: this.cryptoInventory });
140
173
  if (!verdict.ok) {
141
174
  // F2: cap rejected sends per client (flood the verifier → 429).
@@ -147,8 +180,13 @@ export class CarrierServer {
147
180
  if (typeof envelope.recipient !== "string" || !envelope.recipient.startsWith("uuaid:")) {
148
181
  return { code: 400, body: { accepted: false, reason: "bad-recipient" } };
149
182
  }
183
+ // Tier: a verified grant raises this sender's budgets; default community.
184
+ const tier = this._resolveTier(req, envelope.sender);
185
+ if (rawBytes > tier.maxBodyBytes) {
186
+ return { code: 413, body: { accepted: false, reason: "body-too-large", maxBodyBytes: tier.maxBodyBytes, tier: tier.tier } };
187
+ }
150
188
  // F2: per-sender ingest budget — a sender uuaid cannot flood its carrier.
151
- if (!this._rateAllow(`s:${envelope.sender}`, MAX_ENVELOPES_PER_MIN)) {
189
+ if (!this._rateAllow(`s:${envelope.sender}`, tier.envelopesPerMin)) {
152
190
  return { code: 429, body: { accepted: false, reason: "rate-limited" } };
153
191
  }
154
192
  // F2: per-client budget on ACCEPTED sends — closes the unlimited
@@ -255,7 +293,7 @@ export class CarrierServer {
255
293
  let envelope;
256
294
  try { envelope = JSON.parse(raw); }
257
295
  catch { return this._json(res, 400, { accepted: false, reason: "bad-json" }); }
258
- const { code, body } = this._handlePost(envelope, this._clientIp(req));
296
+ const { code, body } = this._handlePost(envelope, this._clientIp(req), req, Buffer.byteLength(raw));
259
297
  return this._json(res, code, body);
260
298
  }
261
299
  const inboxMatch = /^\/v1\/inbox\/([^/]+)$/.exec(url.pathname);
package/src/pillar.mjs CHANGED
@@ -37,6 +37,8 @@ export class Pillar {
37
37
  carriers = [],
38
38
  enableLibp2p = true,
39
39
  cryptoInventory = { ed25519: "active", "ml-dsa-65": "reserved", "slh-dsa-128s": "reserved" },
40
+ trust = null, // { policy, allow, block } from loadTrustPolicy(); null = open
41
+ tierGrant = null, // signed grant object (tier.mjs) sent with carrier delivers
40
42
  }) {
41
43
  if (!keychain?._identity) throw new Error("Pillar: keychain must be loaded before construction");
42
44
  this.keychain = keychain;
@@ -47,11 +49,12 @@ export class Pillar {
47
49
  this.dhtClientMode = dhtClientMode;
48
50
  this.dhtPeerInfoMapper = dhtPeerInfoMapper;
49
51
  this.carriers = carriers;
50
- this.carrierClient = carriers.length ? new CarrierClient({ keychain, carriers }) : null;
52
+ this.carrierClient = carriers.length ? new CarrierClient({ keychain, carriers, tierGrant }) : null;
51
53
  this._carrierPoller = null;
52
54
  this.enableLibp2p = enableLibp2p;
53
55
  this.cryptoInventory = cryptoInventory;
54
56
  this.node = null;
57
+ this.trust = trust;
55
58
  this._envelopeHandlers = new Set();
56
59
  this._openStreams = new Map(); // peerIdString -> pushable
57
60
  }
@@ -474,6 +477,26 @@ export class Pillar {
474
477
  }
475
478
  const persisted = this.mailbox.appendInbound(envelope);
476
479
  if (persisted.duplicate) return; // replay: do not re-fire handlers
480
+ // Trust gate (recipient consent): quarantine-not-drop. The envelope is
481
+ // already persisted (chain-hashed evidence); a gated sender just never
482
+ // reaches handlers. An ack/reply passes the gate ONLY if its inReplyTo
483
+ // names an envelope in OUR outbox — a genuine receipt for our own send.
484
+ // (kind alone is spoofable; the outbox check is not.)
485
+ if (this.trust) {
486
+ const isReceipt = (envelope.kind === "ack" || envelope.kind === "reply") &&
487
+ typeof payload?.inReplyTo === "string" && this.mailbox.hasOutbound(payload.inReplyTo);
488
+ if (!isReceipt) {
489
+ const { evaluateTrust } = await import("./identity/trust.mjs");
490
+ const known = new Set(this.addressBook?.keys() ?? []);
491
+ const verdict = evaluateTrust(this.trust, envelope.sender, known);
492
+ if (!verdict.deliver) {
493
+ this.mailbox.markQuarantined(envelope.id, verdict.reason);
494
+ // eslint-disable-next-line no-console
495
+ console.warn(`pillar: quarantined ${envelope.id} from ${envelope.sender} (${verdict.reason})`);
496
+ return;
497
+ }
498
+ }
499
+ }
477
500
  const view = { ...envelope, payload };
478
501
  for (const h of this._envelopeHandlers) {
479
502
  try { await h(view, persisted); }