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

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.
@@ -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.12",
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",
@@ -10,10 +10,18 @@
10
10
  "pillar-carrier": "bin/carrier.mjs",
11
11
  "pillar-ask": "bin/pillar-ask.mjs",
12
12
  "pillar-responder": "bin/pillar-responder.mjs",
13
- "pillar-seed": "bin/pillar-seed.mjs"
13
+ "pillar-seed": "bin/pillar-seed.mjs",
14
+ "pillar-escrow": "bin/pillar-escrow.mjs"
14
15
  },
15
16
  "exports": {
16
- ".": "./src/index.mjs"
17
+ ".": "./src/index.mjs",
18
+ "./keychain": "./src/identity/keychain.mjs",
19
+ "./jcs": "./src/identity/jcs.mjs",
20
+ "./tier": "./src/identity/tier.mjs",
21
+ "./envelope": "./src/net/envelope.mjs",
22
+ "./e2e": "./src/crypto/e2e.mjs",
23
+ "./carrier-client":"./src/net/carrier-client.mjs",
24
+ "./package.json": "./package.json"
17
25
  },
18
26
  "files": [
19
27
  "src",
@@ -33,6 +41,8 @@
33
41
  },
34
42
  "dependencies": {
35
43
  "@chainsafe/libp2p-noise": "^17.0.0",
44
+ "@noble/curves": "^2.3.0",
45
+ "@noble/hashes": "^2.3.0",
36
46
  "@chainsafe/libp2p-yamux": "^8.0.1",
37
47
  "@libp2p/autonat": "^3.0.26",
38
48
  "@libp2p/bootstrap": "^12.0.29",
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Content box — symmetric AES-256-GCM under the escrowed 32-byte content
3
+ * key K. The seller seals the deliverable with K and sends the box directly
4
+ * to the buyer; the escrow holds only K (and the sha256 of the plaintext, in
5
+ * the signed terms) — it never sees the payload. The buyer can only open
6
+ * the box after K is released, and the GCM tag makes any tamper or wrong key
7
+ * fail loudly.
8
+ */
9
+ import { randomBytes, createCipheriv, createDecipheriv, createHash } from "node:crypto";
10
+
11
+ export const BOX_ALG = "aes-256-gcm";
12
+
13
+ export function sealBox(key32, plaintext) {
14
+ if (!Buffer.isBuffer(key32) || key32.length !== 32) throw new Error("sealBox: key must be 32 bytes");
15
+ const iv = randomBytes(12);
16
+ const c = createCipheriv(BOX_ALG, key32, iv);
17
+ const ct = Buffer.concat([c.update(plaintext), c.final()]);
18
+ return {
19
+ alg: BOX_ALG,
20
+ iv: iv.toString("hex"),
21
+ ct: Buffer.concat([ct, c.getAuthTag()]).toString("hex"), // tag appended to ct
22
+ };
23
+ }
24
+
25
+ /** Throws on wrong key, tamper, or malformed box — never returns garbage. */
26
+ export function openBox(key32, box) {
27
+ if (!Buffer.isBuffer(key32) || key32.length !== 32) throw new Error("openBox: key must be 32 bytes");
28
+ if (!box || box.alg !== BOX_ALG || typeof box.iv !== "string" || typeof box.ct !== "string") {
29
+ throw new Error("openBox: bad box");
30
+ }
31
+ const iv = Buffer.from(box.iv, "hex");
32
+ const buf = Buffer.from(box.ct, "hex");
33
+ if (iv.length !== 12 || buf.length < 16) throw new Error("openBox: bad lengths");
34
+ const ct = buf.subarray(0, buf.length - 16);
35
+ const tag = buf.subarray(buf.length - 16);
36
+ const d = createDecipheriv(BOX_ALG, key32, iv);
37
+ d.setAuthTag(tag);
38
+ return Buffer.concat([d.update(ct), d.final()]);
39
+ }
40
+
41
+ export function sha256Hex(buf) {
42
+ return createHash("sha256").update(buf).digest("hex");
43
+ }