@uuaid/pillar 2.0.2 → 2.2.0
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/HOSTING-LIMITS.md +49 -14
- package/INTERACTION-CONVENTION.md +10 -6
- package/README.md +118 -12
- package/bin/carrier.mjs +64 -2
- package/bin/pillar-directory.mjs +2 -2
- package/bin/pillar-escrow.mjs +2 -2
- package/bin/pillar-group-v2.mjs +17 -0
- package/bin/pillar-seed.mjs +22 -4
- package/bin/pillar.mjs +727 -73
- package/package.json +2 -1
- package/src/address-book.mjs +135 -10
- package/src/blob/blob.mjs +12 -14
- package/src/blob/http.mjs +4 -4
- package/src/group-v2-cli.mjs +231 -0
- package/src/groups-v2.mjs +1108 -0
- package/src/groups.mjs +175 -0
- package/src/identity/jcs.mjs +4 -5
- package/src/identity/keychain.mjs +86 -2
- package/src/index.mjs +1 -1
- package/src/mailbox/mailbox.mjs +296 -12
- package/src/net/carrier-client.mjs +29 -7
- package/src/net/carrier-receipt.mjs +77 -0
- package/src/net/carrier-server.mjs +227 -19
- package/src/net/envelope.mjs +3 -1
- package/src/net/refusals.mjs +5 -2
- package/src/pillar.mjs +416 -35
- package/src/readers.mjs +120 -0
- package/src/receipts.mjs +145 -0
|
@@ -12,6 +12,13 @@
|
|
|
12
12
|
* - carrier verifies the transport signature before accepting (fail closed)
|
|
13
13
|
* - idempotent on envelope.id
|
|
14
14
|
* -> 202 { accepted: true, seq, sha }
|
|
15
|
+
* With a receipt key (constructor `receiptKeychain`), the 202 also carries
|
|
16
|
+
* `receipt` + `receiptSig` (uuaid.carrier-receipt.v1, ./carrier-receipt.mjs),
|
|
17
|
+
* built from the STORED row after the insert commits. A repeated
|
|
18
|
+
* envelope.id whose canonical hash (sha256(JCS(envelope))) equals the
|
|
19
|
+
* stored one gets the original fields with duplicate: true; a different
|
|
20
|
+
* body under a stored id is refused 409 envelope-id-conflict, no receipt.
|
|
21
|
+
* Without a receipt key every response is exactly as before.
|
|
15
22
|
*
|
|
16
23
|
* GET /v1/inbox/<uuaid>?since=<seq>&wait=<seconds>
|
|
17
24
|
* headers:
|
|
@@ -46,6 +53,9 @@
|
|
|
46
53
|
* only becomes visible at the moment the carrier starts saying no.
|
|
47
54
|
*
|
|
48
55
|
* Durability: SQLite (WAL). Envelopes are retained until TTL (default 14 days).
|
|
56
|
+
* Each row stores its own `retain_until` (created_at + TTL at acceptance), and
|
|
57
|
+
* vacuum() deletes a row only once now > that row's retain_until, so a later,
|
|
58
|
+
* smaller TTL never shortens a deadline already issued in a receipt.
|
|
49
59
|
* The carrier CANNOT read payloads it relays beyond what the envelope exposes,
|
|
50
60
|
* and CANNOT forge envelopes (no keys). It can only: deliver, delay, or drop —
|
|
51
61
|
* and drops are detectable by sequence gaps + sender outbox audit.
|
|
@@ -53,12 +63,13 @@
|
|
|
53
63
|
import { createServer } from "node:http";
|
|
54
64
|
import { createHash } from "node:crypto";
|
|
55
65
|
import Database from "better-sqlite3";
|
|
56
|
-
import { open as openEnvelope } from "./envelope.mjs";
|
|
57
|
-
import { Keychain } from "../identity/keychain.mjs";
|
|
66
|
+
import { open as openEnvelope, envelopeSha as canonicalEnvelopeSha } from "./envelope.mjs";
|
|
67
|
+
import { Keychain, keyDerivesUuaid, isStrictEd25519PublicKey } from "../identity/keychain.mjs";
|
|
58
68
|
import { jcs } from "../identity/jcs.mjs";
|
|
59
69
|
import { verifyTierGrant } from "../identity/tier.mjs";
|
|
60
70
|
import { verifyDelegationDoc, delegationCoversKind } from "./delegation.mjs";
|
|
61
71
|
import { withCarrierFix } from "./refusals.mjs"; // additive `fix` on refusals; reason/error/status unchanged
|
|
72
|
+
import { carrierReceiptSigningString, carrierReceiptShapeOk, carrierUuaidFromKey } from "./carrier-receipt.mjs";
|
|
62
73
|
|
|
63
74
|
const MAX_WAIT_S = 30;
|
|
64
75
|
const MAX_BODY = 512 * 1024; // 512 KiB per envelope (community tier)
|
|
@@ -79,13 +90,73 @@ const RATE_MAP_HARD_CAP = 10_000; // F2: bound memory under key spray
|
|
|
79
90
|
// still fits inside the same absolute ceiling. A carrier with no trusted tier
|
|
80
91
|
// issuers configured never resolves a priority grant, so these are inert.
|
|
81
92
|
const COMMUNITY_DB_BYTES = Math.floor(MAX_DB_BYTES * 0.85); // last 15% of the store is reserved
|
|
82
|
-
const COMMUNITY_CONCURRENT_WAITS = 48; // of MAX_CONCURRENT_WAITS
|
|
93
|
+
const COMMUNITY_CONCURRENT_WAITS = 48; // of MAX_CONCURRENT_WAITS (the default)
|
|
94
|
+
// A carrier operator may raise the community long-poll cap (CARRIER_COMMUNITY_WAITS;
|
|
95
|
+
// pillar.uuaid.org runs 192, grant ZZ-DP-2026-10-22-007). The priority reserve is kept
|
|
96
|
+
// ON TOP of it, so a raised community cap never eats the reserved slots.
|
|
97
|
+
const PRIORITY_WAIT_RESERVE = MAX_CONCURRENT_WAITS - COMMUNITY_CONCURRENT_WAITS; // 16
|
|
98
|
+
const MAX_COMMUNITY_WAITS = 4096; // sanity ceiling for the setting
|
|
99
|
+
|
|
100
|
+
/** Parse a community long-poll cap: a whole number from 1 to 4096, else throw. */
|
|
101
|
+
export function parseCommunityWaits(value) {
|
|
102
|
+
if (value === undefined || value === null || value === "") return COMMUNITY_CONCURRENT_WAITS;
|
|
103
|
+
const n = typeof value === "number" ? value : (/^[0-9]+$/.test(String(value)) ? Number(value) : NaN);
|
|
104
|
+
if (!Number.isInteger(n) || n < 1 || n > MAX_COMMUNITY_WAITS) {
|
|
105
|
+
throw new Error(`community waits must be a whole number from 1 to ${MAX_COMMUNITY_WAITS}, not "${value}"`);
|
|
106
|
+
}
|
|
107
|
+
return n;
|
|
108
|
+
}
|
|
83
109
|
const MAX_PRIORITY_GOOD_PER_MIN = 600; // priority senders bill to their own per-IP bucket
|
|
110
|
+
const CHECKPOINT_ATTEMPTS = 3; // TRUNCATE checkpoint tries per vacuum pass
|
|
111
|
+
const CHECKPOINT_BUSY_MS = 250; // per try; a busy pass is retried next hour
|
|
112
|
+
/** Row columns a receipt is built from (plus the legacy `sha` the 202 has always echoed). */
|
|
113
|
+
const ROW_COLUMNS = "seq, envelope_id, recipient, sender, sha, created_at, retain_until, envelope_sha";
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Secure delete for the envelope store: one switch per broker (--secure-delete
|
|
117
|
+
* on|off, CARRIER_SECURE_DELETE / SEED_SECURE_DELETE). ON by default.
|
|
118
|
+
*
|
|
119
|
+
* A plain DELETE leaves an expired envelope's bytes readable in the database
|
|
120
|
+
* file, and, while the database is open, in the WAL (PC-4 delete-residue probe,
|
|
121
|
+
* 2026-10-08). ON sets PRAGMA secure_delete, which zeroes deleted content, and
|
|
122
|
+
* vacuum() follows every expiry delete with wal_checkpoint(TRUNCATE), which
|
|
123
|
+
* empties the WAL of the frames that still hold it. OFF keeps SQLite's default:
|
|
124
|
+
* deleted bytes stay until the page is reused. secure_delete is per connection,
|
|
125
|
+
* so this is a per-broker setting, never a per-tenant one.
|
|
126
|
+
*
|
|
127
|
+
* Returns true or false; throws on anything that is not a recognised spelling,
|
|
128
|
+
* so a typo never silently turns the scrub off.
|
|
129
|
+
*/
|
|
130
|
+
export function parseSecureDelete(value) {
|
|
131
|
+
if (value === undefined || value === null || value === true) return true;
|
|
132
|
+
const v = String(value).trim().toLowerCase();
|
|
133
|
+
if (["on", "1", "true", "yes"].includes(v)) return true;
|
|
134
|
+
if (["off", "0", "false", "no"].includes(v)) return false;
|
|
135
|
+
throw new Error(`secure-delete must be on or off, not "${value}"`);
|
|
136
|
+
}
|
|
84
137
|
|
|
85
138
|
export class CarrierServer {
|
|
86
|
-
constructor({ dbPath = "carrier.db", ttlDays = 14, cryptoInventory, trustedTierIssuers = [], keychain = null, publicUrl = null } = {}) {
|
|
139
|
+
constructor({ dbPath = "carrier.db", ttlDays = 14, cryptoInventory, trustedTierIssuers = [], keychain = null, publicUrl = null, secureDelete = true, receiptKeychain = null, communityInboxWaits = COMMUNITY_CONCURRENT_WAITS } = {}) {
|
|
140
|
+
// Every row's retain_until is created_at + ttlDays, so the TTL must be a
|
|
141
|
+
// real positive duration (a NaN here used to surface only as a vacuum crash).
|
|
142
|
+
if (typeof ttlDays !== "number" || !Number.isFinite(ttlDays) || ttlDays <= 0) {
|
|
143
|
+
throw new Error(`ttlDays must be a positive number of days, not ${ttlDays}`);
|
|
144
|
+
}
|
|
145
|
+
// Receipt key: optional and separate from the seed `keychain`. Fail closed
|
|
146
|
+
// on a key that is not loaded or not a strict Ed25519 key, rather than
|
|
147
|
+
// running a carrier that silently signs nothing or signs under a weak key.
|
|
148
|
+
if (receiptKeychain !== null && receiptKeychain !== undefined) {
|
|
149
|
+
const id = receiptKeychain._identity;
|
|
150
|
+
if (!id?.publicKeyHex || typeof receiptKeychain.sign !== "function") {
|
|
151
|
+
throw new Error("receiptKeychain must be a loaded Keychain");
|
|
152
|
+
}
|
|
153
|
+
if (!isStrictEd25519PublicKey(id.publicKeyHex)) throw new Error("receiptKeychain key is not a strict Ed25519 key");
|
|
154
|
+
}
|
|
87
155
|
this.db = new Database(dbPath);
|
|
88
156
|
this.db.pragma("journal_mode = WAL");
|
|
157
|
+
this.secureDelete = secureDelete !== false;
|
|
158
|
+
this.db.pragma(`secure_delete = ${this.secureDelete ? "ON" : "OFF"}`);
|
|
159
|
+
this.lastVacuum = null;
|
|
89
160
|
this.db.exec(`
|
|
90
161
|
create table if not exists envelopes (
|
|
91
162
|
seq integer primary key autoincrement,
|
|
@@ -94,7 +165,9 @@ export class CarrierServer {
|
|
|
94
165
|
sender text not null,
|
|
95
166
|
sha text not null,
|
|
96
167
|
body text not null,
|
|
97
|
-
created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
168
|
+
created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
169
|
+
retain_until text,
|
|
170
|
+
envelope_sha text
|
|
98
171
|
);
|
|
99
172
|
create index if not exists idx_envelopes_recipient on envelopes(recipient, seq);
|
|
100
173
|
-- Lifetime service counters. Kept OUTSIDE the envelopes table because
|
|
@@ -105,14 +178,25 @@ export class CarrierServer {
|
|
|
105
178
|
v text not null
|
|
106
179
|
);
|
|
107
180
|
`);
|
|
181
|
+
// Migration for stores created before carrier receipts: add the per-row
|
|
182
|
+
// retention deadline and canonical envelope hash. Existing rows get
|
|
183
|
+
// retain_until = created_at + ttlDays (what the old vacuum would have
|
|
184
|
+
// honoured); their canonical hash is computed from the stored body when a
|
|
185
|
+
// duplicate first needs it (_storedCanonicalSha).
|
|
186
|
+
const cols = new Set(this.db.prepare("pragma table_info(envelopes)").all().map(c => c.name));
|
|
187
|
+
if (!cols.has("retain_until")) this.db.exec("alter table envelopes add column retain_until text");
|
|
188
|
+
if (!cols.has("envelope_sha")) this.db.exec("alter table envelopes add column envelope_sha text");
|
|
108
189
|
this.db.prepare("insert or ignore into seed_stats (k, v) values ('first_seen', ?)")
|
|
109
190
|
.run(new Date().toISOString());
|
|
110
191
|
this.ttlDays = ttlDays;
|
|
192
|
+
this._backfillRetention(); // @rule carrier.retention-backfill
|
|
111
193
|
this.cryptoInventory = cryptoInventory ?? { ed25519: "active" };
|
|
112
194
|
this.startedAt = Date.now();
|
|
113
195
|
this.server = null;
|
|
114
196
|
this.rateMap = new Map(); // F2: "<kind>:<key>" -> [ms timestamps]
|
|
115
197
|
this.activeWaits = 0; // F2: long-polls currently sleeping
|
|
198
|
+
this.communityWaitCap = parseCommunityWaits(communityInboxWaits);
|
|
199
|
+
this.priorityWaitCap = this.communityWaitCap + PRIORITY_WAIT_RESERVE;
|
|
116
200
|
this._closed = false;
|
|
117
201
|
// Tier grants: issuer pubkeys (hex) this carrier honors. Empty = every
|
|
118
202
|
// sender gets community-tier budgets (pre-tier behavior, default).
|
|
@@ -122,6 +206,75 @@ export class CarrierServer {
|
|
|
122
206
|
// does not expose /v1/seed-info.
|
|
123
207
|
this.keychain = keychain;
|
|
124
208
|
this.publicUrl = publicUrl ? String(publicUrl).replace(/\/+$/, "") : null;
|
|
209
|
+
// Receipt identity: the carrier uuaid is DERIVED from the receipt key
|
|
210
|
+
// (uuaid:foundation:agent:<localId>), never read from the keychain file,
|
|
211
|
+
// so it is always the uuaid a client's pin check derives.
|
|
212
|
+
this.receiptKeychain = receiptKeychain ?? null;
|
|
213
|
+
this.receiptCarrier = this.receiptKeychain ? carrierUuaidFromKey(this.receiptKeychain._identity.publicKeyHex) : null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** created_at + the current TTL, as ISO-8601 UTC with milliseconds; null if created_at does not parse. */
|
|
217
|
+
_retainUntilFor(createdAt) {
|
|
218
|
+
const ms = Date.parse(createdAt);
|
|
219
|
+
if (!Number.isFinite(ms)) return null;
|
|
220
|
+
return new Date(ms + this.ttlDays * 86400_000).toISOString();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Give every row without a retention deadline one: created_at + ttlDays.
|
|
225
|
+
* Only rows that never had a deadline are touched (rows from before this
|
|
226
|
+
* column existed, or written by hand); an issued deadline is never changed.
|
|
227
|
+
*/
|
|
228
|
+
_backfillRetention() {
|
|
229
|
+
const rows = this.db.prepare("select seq, created_at from envelopes where retain_until is null").all();
|
|
230
|
+
if (!rows.length) return 0;
|
|
231
|
+
const set = this.db.prepare("update envelopes set retain_until = ? where seq = ? and retain_until is null");
|
|
232
|
+
let n = 0;
|
|
233
|
+
this.db.transaction(() => {
|
|
234
|
+
for (const r of rows) {
|
|
235
|
+
const until = this._retainUntilFor(r.created_at);
|
|
236
|
+
if (until !== null) n += set.run(until, r.seq).changes;
|
|
237
|
+
}
|
|
238
|
+
})();
|
|
239
|
+
return n;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The canonical hash (sha256(JCS(envelope))) of a stored row: the column
|
|
244
|
+
* when set, else computed once from the stored body and persisted. Null if
|
|
245
|
+
* the stored body cannot be canonicalized.
|
|
246
|
+
*/
|
|
247
|
+
_storedCanonicalSha(row) {
|
|
248
|
+
if (typeof row.envelope_sha === "string") return row.envelope_sha;
|
|
249
|
+
let h = null;
|
|
250
|
+
try { h = canonicalEnvelopeSha(JSON.parse(row.body)); } catch (_e) { h = null; }
|
|
251
|
+
if (h !== null) this.db.prepare("update envelopes set envelope_sha = ? where seq = ? and envelope_sha is null").run(h, row.seq);
|
|
252
|
+
return h;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* { receipt, receiptSig } for a stored row, or null. Every field comes from
|
|
257
|
+
* the committed row (§12 rule 1: sign the stored object, never the request
|
|
258
|
+
* body); only `duplicate` says which path asked. A row whose fields would
|
|
259
|
+
* not pass a v1 verifier is not signed.
|
|
260
|
+
*/
|
|
261
|
+
_receiptFor(row, duplicate) {
|
|
262
|
+
if (!this.receiptKeychain) return null;
|
|
263
|
+
const receipt = {
|
|
264
|
+
v: 1,
|
|
265
|
+
carrier: this.receiptCarrier,
|
|
266
|
+
envelopeId: row.envelope_id,
|
|
267
|
+
envelopeSha: row.envelope_sha, // @rule carrier.receipt-canonical-sha
|
|
268
|
+
sender: row.sender,
|
|
269
|
+
recipient: row.recipient,
|
|
270
|
+
seq: Number(row.seq),
|
|
271
|
+
duplicate: duplicate === true,
|
|
272
|
+
acceptedAt: row.created_at, // @rule carrier.receipt-stored-time
|
|
273
|
+
retainUntil: row.retain_until,
|
|
274
|
+
};
|
|
275
|
+
if (!carrierReceiptShapeOk(receipt)) return null;
|
|
276
|
+
const receiptSig = this.receiptKeychain.sign(Buffer.from(carrierReceiptSigningString(receipt), "utf-8")).toString("hex");
|
|
277
|
+
return { receipt, receiptSig };
|
|
125
278
|
}
|
|
126
279
|
|
|
127
280
|
/** Read a lifetime counter. */
|
|
@@ -235,10 +388,42 @@ export class CarrierServer {
|
|
|
235
388
|
return true;
|
|
236
389
|
}
|
|
237
390
|
|
|
238
|
-
/**
|
|
391
|
+
/**
|
|
392
|
+
* Remove envelopes whose own retention deadline has passed (now >
|
|
393
|
+
* retain_until). Call periodically. Returns the number deleted. A row's
|
|
394
|
+
* deadline is fixed when it is stored, so lowering ttlDays later never
|
|
395
|
+
* deletes a row before the retainUntil its receipt promised. A row with no
|
|
396
|
+
* parseable created_at (so no deadline) falls back to the old created_at
|
|
397
|
+
* cutoff. With secure delete on, a pass that deleted anything then runs
|
|
398
|
+
* wal_checkpoint(TRUNCATE) until it reports busy=0 (at most
|
|
399
|
+
* CHECKPOINT_ATTEMPTS tries); `lastVacuum` records the outcome, and
|
|
400
|
+
* `lastVacuum.scrubbed` is false when the checkpoint never completed.
|
|
401
|
+
*/
|
|
239
402
|
vacuum() {
|
|
403
|
+
this._backfillRetention();
|
|
404
|
+
const now = new Date().toISOString();
|
|
240
405
|
const cutoff = new Date(Date.now() - this.ttlDays * 86400_000).toISOString();
|
|
241
|
-
const
|
|
406
|
+
const expired = "retain_until < @now or (retain_until is null and created_at < @cutoff)"; // @rule carrier.retention-per-row
|
|
407
|
+
const r = this.db.prepare(`delete from envelopes where ${expired}`).run({ now, cutoff });
|
|
408
|
+
const report = { deleted: r.changes, secureDelete: this.secureDelete, checkpoint: null, attempts: 0, scrubbed: null };
|
|
409
|
+
if (this.secureDelete && r.changes > 0) {
|
|
410
|
+
// vacuum() runs on the event loop: wait at most CHECKPOINT_BUSY_MS per
|
|
411
|
+
// try for a reader to finish, never the connection's 5 s default.
|
|
412
|
+
const busyTimeout = this.db.pragma("busy_timeout", { simple: true });
|
|
413
|
+
this.db.pragma(`busy_timeout = ${CHECKPOINT_BUSY_MS}`);
|
|
414
|
+
try {
|
|
415
|
+
while (report.attempts < CHECKPOINT_ATTEMPTS) {
|
|
416
|
+
report.attempts++;
|
|
417
|
+
const [cp] = this.db.pragma("wal_checkpoint(TRUNCATE)");
|
|
418
|
+
report.checkpoint = cp;
|
|
419
|
+
if (cp && cp.busy === 0) break;
|
|
420
|
+
}
|
|
421
|
+
} finally {
|
|
422
|
+
this.db.pragma(`busy_timeout = ${busyTimeout}`);
|
|
423
|
+
}
|
|
424
|
+
report.scrubbed = report.checkpoint?.busy === 0;
|
|
425
|
+
}
|
|
426
|
+
this.lastVacuum = report;
|
|
242
427
|
return r.changes;
|
|
243
428
|
}
|
|
244
429
|
|
|
@@ -308,20 +493,45 @@ export class CarrierServer {
|
|
|
308
493
|
const pages = this.db.pragma("page_count", { simple: true });
|
|
309
494
|
const pageBytes = this.db.pragma("page_size", { simple: true });
|
|
310
495
|
const storeCap = tier.priority ? MAX_DB_BYTES : COMMUNITY_DB_BYTES;
|
|
311
|
-
if (pages * pageBytes > storeCap) {
|
|
312
|
-
return { code: 503, body: { accepted: false, reason: "carrier-full" } };
|
|
313
|
-
}
|
|
496
|
+
if (pages * pageBytes > storeCap) return { code: 503, body: { accepted: false, reason: "carrier-full" } }; // @rule carrier.full-refuses-before-store
|
|
314
497
|
const sha = createHash("sha256").update(JSON.stringify(envelope)).digest("hex");
|
|
498
|
+
// Canonical hash, independent of the key order the envelope arrived in.
|
|
499
|
+
// A carrier that signs receipts must be able to bind the envelope, so it
|
|
500
|
+
// refuses one it cannot canonicalize; without a receipt key such an
|
|
501
|
+
// envelope is stored exactly as before.
|
|
502
|
+
let canonicalSha = null;
|
|
503
|
+
try { canonicalSha = canonicalEnvelopeSha(envelope); } catch (_e) { canonicalSha = null; }
|
|
504
|
+
if (this.receiptKeychain && canonicalSha === null) {
|
|
505
|
+
return { code: 400, body: { accepted: false, reason: "bad-canonical" } };
|
|
506
|
+
}
|
|
507
|
+
const acceptedAt = new Date().toISOString();
|
|
508
|
+
const retainUntil = this._retainUntilFor(acceptedAt);
|
|
315
509
|
try {
|
|
316
510
|
const r = this.db.prepare(
|
|
317
|
-
"insert into envelopes (envelope_id, recipient, sender, sha, body) values (
|
|
318
|
-
).run(envelope.id, envelope.recipient, envelope.sender, sha, JSON.stringify(envelope));
|
|
511
|
+
"insert into envelopes (envelope_id, recipient, sender, sha, body, created_at, retain_until, envelope_sha) values (?,?,?,?,?,?,?,?)"
|
|
512
|
+
).run(envelope.id, envelope.recipient, envelope.sender, sha, JSON.stringify(envelope), acceptedAt, retainUntil, canonicalSha);
|
|
319
513
|
this._bump("relayed");
|
|
320
|
-
|
|
514
|
+
const seq = Number(r.lastInsertRowid);
|
|
515
|
+
const body = { accepted: true, seq, sha };
|
|
516
|
+
if (this.receiptKeychain) {
|
|
517
|
+
// Re-read the committed row: the receipt is built from what is stored.
|
|
518
|
+
const row = this.db.prepare(`select ${ROW_COLUMNS} from envelopes where seq = ?`).get(seq);
|
|
519
|
+
const signed = row ? this._receiptFor(row, false) : null;
|
|
520
|
+
if (signed) Object.assign(body, signed);
|
|
521
|
+
}
|
|
522
|
+
return { code: 202, body };
|
|
321
523
|
} catch (e) {
|
|
322
524
|
if (/UNIQUE constraint/.test(e.message)) {
|
|
323
|
-
const row = this.db.prepare(
|
|
324
|
-
|
|
525
|
+
const row = this.db.prepare(`select ${ROW_COLUMNS}, body from envelopes where envelope_id = ?`).get(envelope.id);
|
|
526
|
+
// No receipt key: the duplicate answer is exactly the pre-receipt one.
|
|
527
|
+
if (!this.receiptKeychain) return { code: 202, body: { accepted: true, duplicate: true, seq: row.seq, sha: row.sha } }; // @rule carrier.keyless-unchanged
|
|
528
|
+
// §12 rule 1 (P1): the same id with a different body is not a
|
|
529
|
+
// duplicate. Refuse it, unsigned, rather than receipt the stored body
|
|
530
|
+
// to a sender who posted something else.
|
|
531
|
+
const storedSha = this._storedCanonicalSha(row);
|
|
532
|
+
if (storedSha === null || storedSha !== canonicalSha) return { code: 409, body: { accepted: false, reason: "envelope-id-conflict" } }; // @rule carrier.id-conflict
|
|
533
|
+
const signed = this._receiptFor({ ...row, envelope_sha: storedSha }, true);
|
|
534
|
+
return { code: 202, body: { accepted: true, duplicate: true, seq: row.seq, sha: row.sha, ...(signed ?? {}) } };
|
|
325
535
|
}
|
|
326
536
|
throw e;
|
|
327
537
|
}
|
|
@@ -339,9 +549,7 @@ export class CarrierServer {
|
|
|
339
549
|
return { ok: false, reason: "clock-skew" };
|
|
340
550
|
}
|
|
341
551
|
// Self-authenticating: UUAID must derive from the presented pubkey.
|
|
342
|
-
|
|
343
|
-
const derived = `uuaid:foundation:agent:${localId}`;
|
|
344
|
-
if (derived !== uuaid) {
|
|
552
|
+
if (!keyDerivesUuaid(pubkey, uuaid, "agent")) {
|
|
345
553
|
// A delegate key may read the principal's inbox only by presenting the
|
|
346
554
|
// principal-signed delegation document that names it. Without the
|
|
347
555
|
// header this stays the legacy mismatch.
|
|
@@ -404,7 +612,7 @@ export class CarrierServer {
|
|
|
404
612
|
// delegate polling for a priority principal inherits the reserved slots,
|
|
405
613
|
// which is the same authority it already has to read the mail.
|
|
406
614
|
const tier = this._resolveTier(req, uuaid);
|
|
407
|
-
const waitCap = tier.priority ?
|
|
615
|
+
const waitCap = tier.priority ? this.priorityWaitCap : this.communityWaitCap;
|
|
408
616
|
if (wait > 0 && this.activeWaits >= waitCap) {
|
|
409
617
|
return this._json(res, 503, { error: "busy" });
|
|
410
618
|
}
|
package/src/net/envelope.mjs
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
*/
|
|
29
29
|
import { createHash, randomBytes } from "node:crypto";
|
|
30
30
|
import { jcs } from "../identity/jcs.mjs";
|
|
31
|
-
import { Keychain } from "../identity/keychain.mjs";
|
|
31
|
+
import { Keychain, isStrictEd25519PublicKey } from "../identity/keychain.mjs";
|
|
32
32
|
import { sealPayload, openPayload, E2E_ALG } from "../crypto/e2e.mjs";
|
|
33
33
|
import { verifyDelegationDoc, delegationCoversKind, CAPABILITY_ONLY_KINDS } from "./delegation.mjs";
|
|
34
34
|
|
|
@@ -112,6 +112,7 @@ export function open(envelope, { cryptoInventory } = {}) {
|
|
|
112
112
|
}
|
|
113
113
|
if (sig.keyId !== envelope.sender) return { ok: false, reason: "keyId-sender-mismatch" };
|
|
114
114
|
if (typeof sig.publicKey !== "string" || !/^[0-9a-f]{64}$/i.test(sig.publicKey)) return { ok: false, reason: "bad-publicKey" };
|
|
115
|
+
if (!isStrictEd25519PublicKey(sig.publicKey)) return { ok: false, reason: "weak-publicKey" }; // @rule envelope.open-strict-key
|
|
115
116
|
if (typeof sig.signature !== "string" || !/^[0-9a-f]{128}$/i.test(sig.signature)) return { ok: false, reason: "bad-signature-encoding" };
|
|
116
117
|
// Structural checks on the encryption block — an envelope without one is invalid.
|
|
117
118
|
const enc = envelope.enc;
|
|
@@ -172,6 +173,7 @@ function openDelegated(envelope, { cryptoInventory } = {}) {
|
|
|
172
173
|
const reject = (reason) => ({ ok: false, reason: `delegation-invalid:${reason}`, code: 403 });
|
|
173
174
|
const sig = envelope.transportSignature;
|
|
174
175
|
if (typeof sig.publicKey !== "string" || !/^[0-9a-f]{64}$/i.test(sig.publicKey)) return { ok: false, reason: "bad-publicKey" };
|
|
176
|
+
if (!isStrictEd25519PublicKey(sig.publicKey)) return { ok: false, reason: "weak-publicKey" }; // @rule envelope.open-delegated-strict-key
|
|
175
177
|
if (typeof sig.signature !== "string" || !/^[0-9a-f]{128}$/i.test(sig.signature)) return { ok: false, reason: "bad-signature-encoding" };
|
|
176
178
|
// Structural checks on the encryption block — same rules as the legacy path.
|
|
177
179
|
const enc = envelope.enc;
|
package/src/net/refusals.mjs
CHANGED
|
@@ -32,7 +32,9 @@ export const REFUSAL_FIXES = Object.freeze({
|
|
|
32
32
|
"keyId-sender-mismatch": "sign with transportSignature.keyId equal to the sender UUAID, or send under a delegation",
|
|
33
33
|
"publicKey-sender-mismatch": "send from the UUAID your signing key derives, or attach the principal's delegation",
|
|
34
34
|
"bad-signature": "sign again with this identity's own key over the exact bytes; the signature does not verify",
|
|
35
|
+
"weak-publicKey": "use a real Ed25519 identity key; this key is a small-order or non-canonical point that can sign anything, so no signature under it counts",
|
|
35
36
|
"delegation-invalid": "mint a fresh delegation from the principal, then resend",
|
|
37
|
+
"envelope-id-conflict": "seal a new envelope with a fresh id; this carrier already holds a different envelope under this id",
|
|
36
38
|
// Carrier, envelope shape and crypto (envelope.mjs open()): one fix for all
|
|
37
39
|
"not-object": RESEAL,
|
|
38
40
|
"bad-version": RESEAL,
|
|
@@ -68,8 +70,9 @@ export const REFUSAL_FIXES = Object.freeze({
|
|
|
68
70
|
"carrier-unreachable": "check the carrier URLs and your network, then retry",
|
|
69
71
|
"carrier-bad-response": "retry, or use another carrier; the carrier or its proxy answered without a reason",
|
|
70
72
|
"over-tier-limit": "send the payload as a blob (pillar-blob send, or Pillar.sendBlob)",
|
|
71
|
-
"blob-key-unproven": "use a
|
|
72
|
-
"sender-key-not-active": "ask the sender to send from a
|
|
73
|
+
"blob-key-unproven": "use a self-certifying agent UUAID derived from this key (pillar init), or get a registry badge that names this key",
|
|
74
|
+
"sender-key-not-active": "ask the sender to send from a self-certifying agent UUAID derived from its own key, or to get a registry badge naming its key",
|
|
75
|
+
"group-v2-use-v2-send": "send to this pillar-group/2 group with pillar group send <group>; v1 group sends (send-group, sendGroup) do not reach it",
|
|
73
76
|
});
|
|
74
77
|
|
|
75
78
|
/**
|