@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 +54 -1
- package/bin/carrier.mjs +6 -1
- package/bin/pillar-responder.mjs +46 -1
- package/bin/pillar.mjs +164 -2
- package/package.json +1 -1
- package/src/identity/tier.mjs +85 -0
- package/src/identity/trust.mjs +63 -0
- package/src/mailbox/mailbox.mjs +23 -2
- package/src/net/carrier-client.mjs +10 -2
- package/src/net/carrier-server.mjs +44 -6
- package/src/pillar.mjs +24 -1
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(() => {
|
package/bin/pillar-responder.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
+
}
|
package/src/mailbox/mailbox.mjs
CHANGED
|
@@ -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: {
|
|
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 >
|
|
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}`,
|
|
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); }
|