@uuaid/pillar 2.0.3 → 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 +33 -14
- package/INTERACTION-CONVENTION.md +10 -6
- package/README.md +118 -12
- package/bin/carrier.mjs +51 -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 +11 -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/keychain.mjs +51 -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 +175 -20
- package/src/net/refusals.mjs +4 -2
- package/src/pillar.mjs +416 -35
- package/src/readers.mjs +120 -0
- package/src/receipts.mjs +145 -0
|
@@ -20,6 +20,21 @@ function byTier(tiers) {
|
|
|
20
20
|
return (a, b) => seedTier(tiers[a] ?? "probation").listRank - seedTier(tiers[b] ?? "probation").listRank;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* The carrier's signed acceptance receipt (uuaid.carrier-receipt.v1), kept as
|
|
25
|
+
* the carrier sent it, plus this client's own clock when the answer arrived
|
|
26
|
+
* (design §12 rule 3: record received-at beside the carrier's acceptedAt,
|
|
27
|
+
* which is only the carrier's claim). NOT verified here: verify against the
|
|
28
|
+
* pinned key for this carrier and the exact envelope posted. {} when the
|
|
29
|
+
* carrier sent no receipt (an older or keyless carrier): that never fails a
|
|
30
|
+
* delivery, the outcome is simply unreceipted.
|
|
31
|
+
*/
|
|
32
|
+
function keptReceipt(body) {
|
|
33
|
+
const r = body?.receipt;
|
|
34
|
+
if (!r || typeof r !== "object" || Array.isArray(r) || typeof body.receiptSig !== "string") return {};
|
|
35
|
+
return { receipt: r, receiptSig: body.receiptSig, receivedAt: new Date().toISOString() }; // @rule client.keeps-receipt
|
|
36
|
+
}
|
|
37
|
+
|
|
23
38
|
export class CarrierClient {
|
|
24
39
|
/**
|
|
25
40
|
* @param {{keychain: import('../identity/keychain.mjs').Keychain, carriers: string[]}} opts
|
|
@@ -66,8 +81,13 @@ export class CarrierClient {
|
|
|
66
81
|
* Resolves once every carrier has answered. Succeeds if at least one
|
|
67
82
|
* accepted; throws if every carrier rejects. Returns the receipt from the
|
|
68
83
|
* most PROVEN accepting carrier as the primary ({ carrier, seq, sha,
|
|
69
|
-
* duplicate }) plus `all` with every
|
|
70
|
-
* seq?, sha?, duplicate?,
|
|
84
|
+
* duplicate, receipt?, receiptSig?, receivedAt? }) plus `all` with every
|
|
85
|
+
* carrier's outcome ({ carrier, ok, seq?, sha?, duplicate?, receipt?,
|
|
86
|
+
* receiptSig?, receivedAt?, error?, status?, reason?, fix? }).
|
|
87
|
+
*
|
|
88
|
+
* `receipt` / `receiptSig` are present only when that carrier signed one
|
|
89
|
+
* (uuaid.carrier-receipt.v1); they are unverified carrier output, and
|
|
90
|
+
* `receivedAt` is this client's clock, not the carrier's.
|
|
71
91
|
*
|
|
72
92
|
* When every carrier refuses, the thrown error carries `code` (the most
|
|
73
93
|
* proven carrier's `reason`, else carrier-unreachable / carrier-bad-response),
|
|
@@ -105,7 +125,7 @@ export class CarrierClient {
|
|
|
105
125
|
let body = {};
|
|
106
126
|
try { body = raw ? JSON.parse(raw) : {}; } catch (_error) {}
|
|
107
127
|
if (res.status === 202 && body.accepted) {
|
|
108
|
-
return { carrier: base, ok: true, seq: body.seq, sha: body.sha, duplicate: !!body.duplicate };
|
|
128
|
+
return { carrier: base, ok: true, seq: body.seq, sha: body.sha, duplicate: !!body.duplicate, ...keptReceipt(body) };
|
|
109
129
|
}
|
|
110
130
|
const reason = typeof body.reason === "string" && body.reason ? body.reason
|
|
111
131
|
: typeof body.error === "string" && body.error ? body.error : null;
|
|
@@ -132,15 +152,15 @@ export class CarrierClient {
|
|
|
132
152
|
throw e;
|
|
133
153
|
}
|
|
134
154
|
const ranked = accepted.slice().sort((a, b) => byTier(this.carrierTiers)(a.carrier, b.carrier));
|
|
135
|
-
const { carrier, seq, sha, duplicate } = ranked[0];
|
|
136
|
-
return { carrier, seq, sha, duplicate, all };
|
|
155
|
+
const { carrier, seq, sha, duplicate, receipt, receiptSig, receivedAt } = ranked[0];
|
|
156
|
+
return { carrier, seq, sha, duplicate, ...(receipt ? { receipt, receiptSig, receivedAt } : {}), all };
|
|
137
157
|
}
|
|
138
158
|
|
|
139
159
|
/**
|
|
140
160
|
* Fetch envelopes addressed to us with seq > since from a specific carrier.
|
|
141
161
|
* Signed request; carrier verifies our key before serving.
|
|
142
162
|
*/
|
|
143
|
-
async fetchInbox(base, { since = 0, waitS = 0, timeoutMs = 40000 } = {}) {
|
|
163
|
+
async fetchInbox(base, { since = 0, waitS = 0, timeoutMs = 40000, signal = null } = {}) {
|
|
144
164
|
const path = `GET /v1/inbox/${this.uuaid}?since=${since}`;
|
|
145
165
|
const ts = String(Date.now());
|
|
146
166
|
const sig = this.keychain.sign(Buffer.from(`${path}\n${ts}`, "utf-8")).toString("hex");
|
|
@@ -150,7 +170,9 @@ export class CarrierClient {
|
|
|
150
170
|
"x-pillar-ts": ts,
|
|
151
171
|
"x-pillar-sig": sig,
|
|
152
172
|
},
|
|
153
|
-
signal
|
|
173
|
+
// `signal` lets a caller cancel a long-poll early (peekCarrierInbox stops
|
|
174
|
+
// the other carriers once one has mail).
|
|
175
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs),
|
|
154
176
|
});
|
|
155
177
|
if (res.status !== 200) {
|
|
156
178
|
const body = await res.json().catch(() => ({}));
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* uuaid.carrier-receipt.v1 — the carrier's signed acceptance receipt.
|
|
3
|
+
*
|
|
4
|
+
* Design: uuaid docs/designs/2026-10-18-carrier-acceptance-receipt.md (§2 wire,
|
|
5
|
+
* §12 binding rules). A carrier configured with a receipt key answers an
|
|
6
|
+
* accepted POST /v1/envelopes with `receipt` + `receiptSig`, where receiptSig
|
|
7
|
+
* is Ed25519 (hex) over the UTF-8 bytes of
|
|
8
|
+
*
|
|
9
|
+
* "uuaid.carrier-receipt.v1" + "\n" + JCS({ the 10 fields below })
|
|
10
|
+
*
|
|
11
|
+
* The fixed prefix is domain separation: a receipt can never be mistaken for
|
|
12
|
+
* the seed identity document (bare JCS, first byte "{"), an auth.v1 assertion
|
|
13
|
+
* or a directory listing, even when the same key signs them.
|
|
14
|
+
*
|
|
15
|
+
* A receipt is the carrier key holder's attributable claim of acceptance,
|
|
16
|
+
* sequence, time and a retention commitment. It does not prove delivery, the
|
|
17
|
+
* disk write or the clock (§12 rule 3).
|
|
18
|
+
*
|
|
19
|
+
* Verification lives in the clients (@uuaid/pillar-client verifyCarrierReceipt);
|
|
20
|
+
* this module is what the carrier signs, and is byte-identical to the
|
|
21
|
+
* pillar-client `carrierReceiptSigningString` (checked against the shared
|
|
22
|
+
* vectors in test/unit/carrier-receipt.test.mjs).
|
|
23
|
+
*/
|
|
24
|
+
import { jcs } from "../identity/jcs.mjs";
|
|
25
|
+
import { SELF_CERTIFYING_NAMESPACES, localIdFromKey } from "../identity/keychain.mjs";
|
|
26
|
+
|
|
27
|
+
export const CARRIER_RECEIPT_TYPE = "uuaid.carrier-receipt.v1";
|
|
28
|
+
|
|
29
|
+
/** The exact receipt keys, in JCS (sorted) order. */
|
|
30
|
+
export const CARRIER_RECEIPT_FIELDS = Object.freeze([
|
|
31
|
+
"acceptedAt", "carrier", "duplicate", "envelopeId", "envelopeSha",
|
|
32
|
+
"recipient", "retainUntil", "sender", "seq", "v",
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
const ISO_MS_Z = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
36
|
+
const HEX64 = /^[0-9a-f]{64}$/;
|
|
37
|
+
const AGENT_UUAID = new RegExp(`^uuaid:(${SELF_CERTIFYING_NAMESPACES.join("|")}):agent:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`);
|
|
38
|
+
const isParty = (s) => typeof s === "string" && s.length > "uuaid:".length && s.startsWith("uuaid:");
|
|
39
|
+
|
|
40
|
+
/** The exact string whose UTF-8 bytes the carrier signs. Only the 10 receipt keys are covered. */
|
|
41
|
+
export function carrierReceiptSigningString(fields) {
|
|
42
|
+
const body = {};
|
|
43
|
+
for (const k of CARRIER_RECEIPT_FIELDS) body[k] = fields[k];
|
|
44
|
+
return `${CARRIER_RECEIPT_TYPE}\n${jcs(body)}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The carrier uuaid a receipt key derives: uuaid:foundation:agent:<localIdFromKey(key)>. */
|
|
48
|
+
export function carrierUuaidFromKey(publicKeyHex) {
|
|
49
|
+
return `uuaid:foundation:agent:${localIdFromKey(Buffer.from(publicKeyHex, "hex"))}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isIsoMs(s) {
|
|
53
|
+
return typeof s === "string" && ISO_MS_Z.test(s) && !Number.isNaN(Date.parse(s)) && new Date(s).toISOString() === s;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* True when `r` is a receipt a v1 verifier can accept on shape alone: exactly
|
|
58
|
+
* the 10 keys, v 1, seq a safe integer >= 1, boolean duplicate, ISO-8601 UTC
|
|
59
|
+
* millisecond times with retainUntil after acceptedAt, a key-derived agent
|
|
60
|
+
* uuaid as carrier, a non-empty envelopeId, a 64-hex envelopeSha and uuaid
|
|
61
|
+
* parties: the same schema rules as the pillar-client verifier. The carrier
|
|
62
|
+
* checks this before signing, so it never signs a claim no client could verify.
|
|
63
|
+
*/
|
|
64
|
+
export function carrierReceiptShapeOk(r) {
|
|
65
|
+
if (!r || typeof r !== "object" || Array.isArray(r)) return false;
|
|
66
|
+
const keys = Object.keys(r).sort();
|
|
67
|
+
if (keys.length !== CARRIER_RECEIPT_FIELDS.length || keys.some((k, i) => k !== CARRIER_RECEIPT_FIELDS[i])) return false;
|
|
68
|
+
if (r.v !== 1) return false;
|
|
69
|
+
if (!Number.isSafeInteger(r.seq) || r.seq < 1) return false;
|
|
70
|
+
if (typeof r.duplicate !== "boolean") return false;
|
|
71
|
+
if (!isIsoMs(r.acceptedAt) || !isIsoMs(r.retainUntil)) return false;
|
|
72
|
+
if (!(Date.parse(r.retainUntil) > Date.parse(r.acceptedAt))) return false;
|
|
73
|
+
if (typeof r.carrier !== "string" || !AGENT_UUAID.test(r.carrier)) return false;
|
|
74
|
+
if (typeof r.envelopeId !== "string" || !r.envelopeId) return false;
|
|
75
|
+
if (typeof r.envelopeSha !== "string" || !HEX64.test(r.envelopeSha)) return false;
|
|
76
|
+
return isParty(r.sender) && isParty(r.recipient);
|
|
77
|
+
}
|
|
@@ -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,10 +90,27 @@ 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
|
|
84
110
|
const CHECKPOINT_ATTEMPTS = 3; // TRUNCATE checkpoint tries per vacuum pass
|
|
85
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";
|
|
86
114
|
|
|
87
115
|
/**
|
|
88
116
|
* Secure delete for the envelope store: one switch per broker (--secure-delete
|
|
@@ -108,7 +136,22 @@ export function parseSecureDelete(value) {
|
|
|
108
136
|
}
|
|
109
137
|
|
|
110
138
|
export class CarrierServer {
|
|
111
|
-
constructor({ dbPath = "carrier.db", ttlDays = 14, cryptoInventory, trustedTierIssuers = [], keychain = null, publicUrl = null, secureDelete = true } = {}) {
|
|
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
|
+
}
|
|
112
155
|
this.db = new Database(dbPath);
|
|
113
156
|
this.db.pragma("journal_mode = WAL");
|
|
114
157
|
this.secureDelete = secureDelete !== false;
|
|
@@ -122,7 +165,9 @@ export class CarrierServer {
|
|
|
122
165
|
sender text not null,
|
|
123
166
|
sha text not null,
|
|
124
167
|
body text not null,
|
|
125
|
-
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
|
|
126
171
|
);
|
|
127
172
|
create index if not exists idx_envelopes_recipient on envelopes(recipient, seq);
|
|
128
173
|
-- Lifetime service counters. Kept OUTSIDE the envelopes table because
|
|
@@ -133,14 +178,25 @@ export class CarrierServer {
|
|
|
133
178
|
v text not null
|
|
134
179
|
);
|
|
135
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");
|
|
136
189
|
this.db.prepare("insert or ignore into seed_stats (k, v) values ('first_seen', ?)")
|
|
137
190
|
.run(new Date().toISOString());
|
|
138
191
|
this.ttlDays = ttlDays;
|
|
192
|
+
this._backfillRetention(); // @rule carrier.retention-backfill
|
|
139
193
|
this.cryptoInventory = cryptoInventory ?? { ed25519: "active" };
|
|
140
194
|
this.startedAt = Date.now();
|
|
141
195
|
this.server = null;
|
|
142
196
|
this.rateMap = new Map(); // F2: "<kind>:<key>" -> [ms timestamps]
|
|
143
197
|
this.activeWaits = 0; // F2: long-polls currently sleeping
|
|
198
|
+
this.communityWaitCap = parseCommunityWaits(communityInboxWaits);
|
|
199
|
+
this.priorityWaitCap = this.communityWaitCap + PRIORITY_WAIT_RESERVE;
|
|
144
200
|
this._closed = false;
|
|
145
201
|
// Tier grants: issuer pubkeys (hex) this carrier honors. Empty = every
|
|
146
202
|
// sender gets community-tier budgets (pre-tier behavior, default).
|
|
@@ -150,6 +206,75 @@ export class CarrierServer {
|
|
|
150
206
|
// does not expose /v1/seed-info.
|
|
151
207
|
this.keychain = keychain;
|
|
152
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 };
|
|
153
278
|
}
|
|
154
279
|
|
|
155
280
|
/** Read a lifetime counter. */
|
|
@@ -264,15 +389,22 @@ export class CarrierServer {
|
|
|
264
389
|
}
|
|
265
390
|
|
|
266
391
|
/**
|
|
267
|
-
* Remove envelopes
|
|
268
|
-
*
|
|
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
|
|
269
398
|
* wal_checkpoint(TRUNCATE) until it reports busy=0 (at most
|
|
270
399
|
* CHECKPOINT_ATTEMPTS tries); `lastVacuum` records the outcome, and
|
|
271
400
|
* `lastVacuum.scrubbed` is false when the checkpoint never completed.
|
|
272
401
|
*/
|
|
273
402
|
vacuum() {
|
|
403
|
+
this._backfillRetention();
|
|
404
|
+
const now = new Date().toISOString();
|
|
274
405
|
const cutoff = new Date(Date.now() - this.ttlDays * 86400_000).toISOString();
|
|
275
|
-
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 });
|
|
276
408
|
const report = { deleted: r.changes, secureDelete: this.secureDelete, checkpoint: null, attempts: 0, scrubbed: null };
|
|
277
409
|
if (this.secureDelete && r.changes > 0) {
|
|
278
410
|
// vacuum() runs on the event loop: wait at most CHECKPOINT_BUSY_MS per
|
|
@@ -361,20 +493,45 @@ export class CarrierServer {
|
|
|
361
493
|
const pages = this.db.pragma("page_count", { simple: true });
|
|
362
494
|
const pageBytes = this.db.pragma("page_size", { simple: true });
|
|
363
495
|
const storeCap = tier.priority ? MAX_DB_BYTES : COMMUNITY_DB_BYTES;
|
|
364
|
-
if (pages * pageBytes > storeCap) {
|
|
365
|
-
return { code: 503, body: { accepted: false, reason: "carrier-full" } };
|
|
366
|
-
}
|
|
496
|
+
if (pages * pageBytes > storeCap) return { code: 503, body: { accepted: false, reason: "carrier-full" } }; // @rule carrier.full-refuses-before-store
|
|
367
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);
|
|
368
509
|
try {
|
|
369
510
|
const r = this.db.prepare(
|
|
370
|
-
"insert into envelopes (envelope_id, recipient, sender, sha, body) values (
|
|
371
|
-
).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);
|
|
372
513
|
this._bump("relayed");
|
|
373
|
-
|
|
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 };
|
|
374
523
|
} catch (e) {
|
|
375
524
|
if (/UNIQUE constraint/.test(e.message)) {
|
|
376
|
-
const row = this.db.prepare(
|
|
377
|
-
|
|
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 ?? {}) } };
|
|
378
535
|
}
|
|
379
536
|
throw e;
|
|
380
537
|
}
|
|
@@ -392,9 +549,7 @@ export class CarrierServer {
|
|
|
392
549
|
return { ok: false, reason: "clock-skew" };
|
|
393
550
|
}
|
|
394
551
|
// Self-authenticating: UUAID must derive from the presented pubkey.
|
|
395
|
-
|
|
396
|
-
const derived = `uuaid:foundation:agent:${localId}`;
|
|
397
|
-
if (derived !== uuaid) {
|
|
552
|
+
if (!keyDerivesUuaid(pubkey, uuaid, "agent")) {
|
|
398
553
|
// A delegate key may read the principal's inbox only by presenting the
|
|
399
554
|
// principal-signed delegation document that names it. Without the
|
|
400
555
|
// header this stays the legacy mismatch.
|
|
@@ -457,7 +612,7 @@ export class CarrierServer {
|
|
|
457
612
|
// delegate polling for a priority principal inherits the reserved slots,
|
|
458
613
|
// which is the same authority it already has to read the mail.
|
|
459
614
|
const tier = this._resolveTier(req, uuaid);
|
|
460
|
-
const waitCap = tier.priority ?
|
|
615
|
+
const waitCap = tier.priority ? this.priorityWaitCap : this.communityWaitCap;
|
|
461
616
|
if (wait > 0 && this.activeWaits >= waitCap) {
|
|
462
617
|
return this._json(res, 503, { error: "busy" });
|
|
463
618
|
}
|
package/src/net/refusals.mjs
CHANGED
|
@@ -34,6 +34,7 @@ export const REFUSAL_FIXES = Object.freeze({
|
|
|
34
34
|
"bad-signature": "sign again with this identity's own key over the exact bytes; the signature does not verify",
|
|
35
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",
|
|
36
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",
|
|
37
38
|
// Carrier, envelope shape and crypto (envelope.mjs open()): one fix for all
|
|
38
39
|
"not-object": RESEAL,
|
|
39
40
|
"bad-version": RESEAL,
|
|
@@ -69,8 +70,9 @@ export const REFUSAL_FIXES = Object.freeze({
|
|
|
69
70
|
"carrier-unreachable": "check the carrier URLs and your network, then retry",
|
|
70
71
|
"carrier-bad-response": "retry, or use another carrier; the carrier or its proxy answered without a reason",
|
|
71
72
|
"over-tier-limit": "send the payload as a blob (pillar-blob send, or Pillar.sendBlob)",
|
|
72
|
-
"blob-key-unproven": "use a
|
|
73
|
-
"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",
|
|
74
76
|
});
|
|
75
77
|
|
|
76
78
|
/**
|