@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.
@@ -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
- /** Remove envelopes older than TTL. Call periodically. */
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 r = this.db.prepare("delete from envelopes where created_at < ?").run(cutoff);
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
- return { code: 202, body: { accepted: true, seq: Number(r.lastInsertRowid), sha } };
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("select seq, sha from envelopes where envelope_id = ?").get(envelope.id);
324
- return { code: 202, body: { accepted: true, duplicate: true, seq: row.seq, sha: row.sha } };
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
- const localId = Keychain._localIdFromKey(Buffer.from(pubkey, "hex"));
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 ? MAX_CONCURRENT_WAITS : COMMUNITY_CONCURRENT_WAITS;
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
  }
@@ -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;
@@ -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 uuaid:foundation:agent UUAID derived from this key (pillar init), or get a registry badge that names this key",
72
- "sender-key-not-active": "ask the sender to send from a uuaid:foundation:agent UUAID derived from its own key, or to get a registry badge naming its key",
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
  /**