@real-life/trust-protocol 0.1.0 → 0.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/README.md +19 -10
- package/dist/carrier-identity.d.ts +20 -0
- package/dist/carrier-identity.d.ts.map +1 -0
- package/dist/carrier-identity.js +115 -0
- package/dist/carrier-identity.js.map +1 -0
- package/dist/carrier.d.ts +157 -0
- package/dist/carrier.d.ts.map +1 -0
- package/dist/carrier.js +577 -0
- package/dist/carrier.js.map +1 -0
- package/dist/holder.d.ts +42 -0
- package/dist/holder.d.ts.map +1 -0
- package/dist/holder.js +92 -0
- package/dist/holder.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +18 -7
- package/dist/index.js.map +1 -1
- package/dist/probe/acks.d.ts +27 -0
- package/dist/probe/acks.d.ts.map +1 -0
- package/dist/probe/acks.js +119 -0
- package/dist/probe/acks.js.map +1 -0
- package/dist/probe/continuity.d.ts +3 -82
- package/dist/probe/continuity.d.ts.map +1 -1
- package/dist/probe/continuity.js +627 -147
- package/dist/probe/continuity.js.map +1 -1
- package/dist/probe/trust.d.ts +114 -74
- package/dist/probe/trust.d.ts.map +1 -1
- package/dist/probe/trust.js +1090 -232
- package/dist/probe/trust.js.map +1 -1
- package/dist/probe.d.ts +1 -2
- package/dist/probe.d.ts.map +1 -1
- package/dist/probe.js +6 -8
- package/dist/probe.js.map +1 -1
- package/dist/schemas.js +22 -22
- package/dist/schemas.js.map +1 -1
- package/dist/visibility.d.ts +6 -0
- package/dist/visibility.d.ts.map +1 -0
- package/dist/visibility.js +30 -0
- package/dist/visibility.js.map +1 -0
- package/dist/wire.d.ts +575 -0
- package/dist/wire.d.ts.map +1 -0
- package/dist/wire.js +2 -0
- package/dist/wire.js.map +1 -0
- package/package.json +8 -3
package/dist/carrier.js
ADDED
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
// carrier — Delivery §4.4 + §5a: the carrier side of the port, as one
|
|
2
|
+
// transport-agnostic state machine.
|
|
3
|
+
//
|
|
4
|
+
// What this module is: the executable form of the five guarantees, the
|
|
5
|
+
// proof rule of §5a.3, the total binding lifecycle (unbound → live →
|
|
6
|
+
// closing → released), the verdict sets with their normative evaluation
|
|
7
|
+
// orders (s1–s6, r1–r5), the wind-up whose deadline IS the release, and
|
|
8
|
+
// the binding tombstones with their normative eviction order.
|
|
9
|
+
//
|
|
10
|
+
// What it deliberately is NOT: a resource manager. A carrier's own
|
|
11
|
+
// budgets are policy (the 0.65 scope re-cast) and enter through hooks —
|
|
12
|
+
// and the CALL GRAPH enforces the guarantees: a request naming a held
|
|
13
|
+
// binding never invokes the shared gate (guarantee 4), a below-floor
|
|
14
|
+
// submission never reaches the cross-queue hooks (guarantee 5). What no
|
|
15
|
+
// library can prevent is a policy wiring two hooks to one counter; such
|
|
16
|
+
// a policy is a nonconformant carrier, and the port-review triage says
|
|
17
|
+
// so out loud rather than claiming otherwise.
|
|
18
|
+
//
|
|
19
|
+
// Linearization (§4.4 atomicity): every public operation does its
|
|
20
|
+
// asynchronous work (crypto) FIRST and then commits in one synchronous
|
|
21
|
+
// block that re-reads state — the commit is the linearization point, no
|
|
22
|
+
// await crosses it, and time-driven transitions are applied from the
|
|
23
|
+
// operation's own `now` before it reads anything (so a late tick cannot
|
|
24
|
+
// lend a released queue an afterlife).
|
|
25
|
+
//
|
|
26
|
+
// The clock is injected. Nothing here reads Date.now().
|
|
27
|
+
import { jcs } from './core.js';
|
|
28
|
+
import { b64uOf, fromB64u, base58, fromBase58, sha } from './crypto.js';
|
|
29
|
+
import { validCarrierIdentifier } from './carrier-identity.js';
|
|
30
|
+
const S = globalThis.crypto.subtle;
|
|
31
|
+
const te = new TextEncoder();
|
|
32
|
+
const DAY = 86_400_000;
|
|
33
|
+
const DOMAINS = [
|
|
34
|
+
['orphanHorizonMs', 7 * DAY, 365 * DAY],
|
|
35
|
+
['giveUpHorizonMs', 1 * DAY, 90 * DAY],
|
|
36
|
+
['challengeLifetimeMs', 5_000, 300_000],
|
|
37
|
+
['statusHorizonMs', 5_000, 300_000],
|
|
38
|
+
['maxQueueBytes', 1_048_576, 1_073_741_824],
|
|
39
|
+
['maxBindingTombstones', 1, Number.MAX_SAFE_INTEGER],
|
|
40
|
+
];
|
|
41
|
+
/** §11: an out-of-domain declaration REJECTS — it never half-applies. */
|
|
42
|
+
export function validateDeclaration(d) {
|
|
43
|
+
const errs = [];
|
|
44
|
+
if (!validCarrierIdentifier(d.carrier))
|
|
45
|
+
errs.push('carrier identifier violates Identity 7a.2');
|
|
46
|
+
for (const [k, lo, hi] of DOMAINS) {
|
|
47
|
+
const v = d[k];
|
|
48
|
+
if (!Number.isSafeInteger(v) || v < lo || v > hi)
|
|
49
|
+
errs.push(`${k} outside [${lo}, ${hi}]`);
|
|
50
|
+
}
|
|
51
|
+
if (!Number.isSafeInteger(d.queueFloorBytes) ||
|
|
52
|
+
d.queueFloorBytes < 65_536 || d.queueFloorBytes > d.maxQueueBytes) {
|
|
53
|
+
errs.push('queueFloorBytes outside [65536, maxQueueBytes]');
|
|
54
|
+
}
|
|
55
|
+
if (d.orphanHorizonMs < d.giveUpHorizonMs)
|
|
56
|
+
errs.push('orphan-horizon < give-up-horizon');
|
|
57
|
+
return errs;
|
|
58
|
+
}
|
|
59
|
+
// the role key of the registry declaration — a role URI and never a
|
|
60
|
+
// document type (round-44 B-1)
|
|
61
|
+
export const CARRIER_ROLE_URI = 'https://real-life.org/trust-tasks/delivery-carrier/0.1';
|
|
62
|
+
export const PROOF_V = 'rltp-carrier-proof/0.3';
|
|
63
|
+
const REGISTRATION_FIELDS = new Set(['v', 'type', 'purpose', 'carrier', 'principal', 'rkid',
|
|
64
|
+
'generation', 'principalChallenge', 'addressChallenge', 'sig']);
|
|
65
|
+
const SESSION_FIELDS = new Set(['v', 'type', 'purpose', 'carrier', 'principal', 'rkid',
|
|
66
|
+
'principalChallenge', 'sig']);
|
|
67
|
+
// canonical decimal spelling, checked on the RECEIVED bytes (§5a.3):
|
|
68
|
+
// `1.0` and `1e0` parse and JCS-canonicalize to `1`, so the signature
|
|
69
|
+
// verifies over them — only the lexical check catches the spelling.
|
|
70
|
+
export const canonicalGenerationSpelling = (raw) => /^[1-9][0-9]*$/.test(raw) && Number.isSafeInteger(Number(raw));
|
|
71
|
+
const canonicalB64u32 = (s) => {
|
|
72
|
+
if (typeof s !== 'string' || s.length !== 43)
|
|
73
|
+
return false;
|
|
74
|
+
try {
|
|
75
|
+
const b = fromB64u(s);
|
|
76
|
+
return b.length === 32 && b64uOf(b) === s;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
// decode-and-check: schema validity is not acceptance (§11)
|
|
83
|
+
const decodedEd = (did) => {
|
|
84
|
+
if (typeof did !== 'string' || !did.startsWith('did:key:z'))
|
|
85
|
+
return null;
|
|
86
|
+
const b = fromBase58(did.slice('did:key:z'.length));
|
|
87
|
+
return b && b.length === 34 && b[0] === 0xed && b[1] === 0x01 ? b.subarray(2) : null;
|
|
88
|
+
};
|
|
89
|
+
const decodedX = (mk) => {
|
|
90
|
+
if (typeof mk !== 'string' || mk[0] !== 'z')
|
|
91
|
+
return null;
|
|
92
|
+
const b = fromBase58(mk.slice(1));
|
|
93
|
+
return b && b.length === 34 && b[0] === 0xec && b[1] === 0x01 ? b.subarray(2) : null;
|
|
94
|
+
};
|
|
95
|
+
const cmpRkidBytes = (a, b) => {
|
|
96
|
+
const ka = decodedX(a);
|
|
97
|
+
const kb = decodedX(b);
|
|
98
|
+
if (!ka || !kb)
|
|
99
|
+
return a < b ? -1 : 1;
|
|
100
|
+
for (let i = 0; i < 32; i++)
|
|
101
|
+
if (ka[i] !== kb[i])
|
|
102
|
+
return ka[i] - kb[i];
|
|
103
|
+
return 0;
|
|
104
|
+
};
|
|
105
|
+
const decodedSig = (zsig) => {
|
|
106
|
+
if (typeof zsig !== 'string' || zsig[0] !== 'z')
|
|
107
|
+
return null;
|
|
108
|
+
const b = fromBase58(zsig.slice(1));
|
|
109
|
+
if (!b || b.length !== 64)
|
|
110
|
+
return null;
|
|
111
|
+
return 'z' + base58(b) === zsig ? b : null;
|
|
112
|
+
};
|
|
113
|
+
/** r1, the SYNTACTIC half only — no cryptography happens here, so a
|
|
114
|
+
* request refused at r1 or r2 has cost the carrier no key operation
|
|
115
|
+
* (guarantee 3). `rawGeneration` is REQUIRED for register/rebind: a
|
|
116
|
+
* parsed number cannot carry its own spelling, and an adapter that
|
|
117
|
+
* cannot supply the received bytes cannot claim the check (round-44
|
|
118
|
+
* M-4 of the port review: optional was fail-open). */
|
|
119
|
+
export function checkProofShape(p, expectedCarrier, rawGeneration) {
|
|
120
|
+
if (typeof p !== 'object' || p === null)
|
|
121
|
+
return 'refused(malformed)';
|
|
122
|
+
if (p.v !== PROOF_V || p.type !== 'carrier-registration-proof')
|
|
123
|
+
return 'refused(malformed)';
|
|
124
|
+
if (!['register', 'rebind', 'collect', 'conclude'].includes(p.purpose))
|
|
125
|
+
return 'refused(malformed)';
|
|
126
|
+
if (p.carrier !== expectedCarrier)
|
|
127
|
+
return 'refused(malformed)';
|
|
128
|
+
const sessionScoped = p.purpose === 'collect' || p.purpose === 'conclude';
|
|
129
|
+
// the wire format is CLOSED: an extra field is not this artifact
|
|
130
|
+
const allowed = sessionScoped ? SESSION_FIELDS : REGISTRATION_FIELDS;
|
|
131
|
+
for (const k of Object.keys(p))
|
|
132
|
+
if (!allowed.has(k))
|
|
133
|
+
return 'refused(malformed)';
|
|
134
|
+
if (!decodedEd(p.principal) || !decodedX(p.rkid) || !decodedSig(p.sig))
|
|
135
|
+
return 'refused(malformed)';
|
|
136
|
+
if (!canonicalB64u32(p.principalChallenge))
|
|
137
|
+
return 'refused(malformed)';
|
|
138
|
+
if (sessionScoped) {
|
|
139
|
+
if (p.generation !== undefined || p.addressChallenge !== undefined)
|
|
140
|
+
return 'refused(malformed)';
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
if (!Number.isSafeInteger(p.generation) || p.generation < 1)
|
|
144
|
+
return 'refused(malformed)';
|
|
145
|
+
if (rawGeneration === undefined || !canonicalGenerationSpelling(rawGeneration) ||
|
|
146
|
+
Number(rawGeneration) !== p.generation)
|
|
147
|
+
return 'refused(malformed)';
|
|
148
|
+
if (!canonicalB64u32(p.addressChallenge))
|
|
149
|
+
return 'refused(malformed)';
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
/** the asymmetric half of r3 — called only past r1, r2 and the challenge
|
|
154
|
+
* consumption, so one challenge buys at most one verification. */
|
|
155
|
+
async function verifySignature(p) {
|
|
156
|
+
const pub = decodedEd(p.principal);
|
|
157
|
+
const sig = decodedSig(p.sig);
|
|
158
|
+
const { sig: _omit, ...signed } = p;
|
|
159
|
+
const key = await S.importKey('raw', pub, { name: 'Ed25519' }, false, ['verify']);
|
|
160
|
+
return S.verify({ name: 'Ed25519' }, key, sig, te.encode(jcs(signed)));
|
|
161
|
+
}
|
|
162
|
+
/** compatibility shim for the shape+signature question alone (tests,
|
|
163
|
+
* tooling): stateless, no challenge semantics. */
|
|
164
|
+
export async function verifyProofShape(p, expectedCarrier, rawGeneration) {
|
|
165
|
+
const sessionScoped = p?.purpose === 'collect' || p?.purpose === 'conclude';
|
|
166
|
+
const raw = rawGeneration ?? (sessionScoped || p?.generation === undefined ? undefined : String(p.generation));
|
|
167
|
+
const shape = checkProofShape(p, expectedCarrier, raw);
|
|
168
|
+
if (shape)
|
|
169
|
+
return shape;
|
|
170
|
+
return await verifySignature(p) ? null : 'refused(possession-failed)';
|
|
171
|
+
}
|
|
172
|
+
// ── envelope shape for submissions (§5, s2) ─────────────────────────────
|
|
173
|
+
// A carrier is key-blind but not shape-blind: a submission is a Section-5
|
|
174
|
+
// envelope whose rkid names the queue it is submitted to.
|
|
175
|
+
const canonicalB64uAny = (s) => {
|
|
176
|
+
if (typeof s !== 'string' || s.length === 0)
|
|
177
|
+
return null;
|
|
178
|
+
try {
|
|
179
|
+
const b = fromB64u(s);
|
|
180
|
+
return b64uOf(b) === s ? b : null;
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
export function checkEnvelopeBytes(bytes, rkid) {
|
|
187
|
+
let doc;
|
|
188
|
+
try {
|
|
189
|
+
doc = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
if (typeof doc !== 'object' || doc === null)
|
|
195
|
+
return false;
|
|
196
|
+
const e = doc;
|
|
197
|
+
if (Object.keys(e).length !== 4)
|
|
198
|
+
return false;
|
|
199
|
+
if (e.rkid !== rkid)
|
|
200
|
+
return false;
|
|
201
|
+
const epk = canonicalB64uAny(e.epk);
|
|
202
|
+
const nonce = canonicalB64uAny(e.nonce);
|
|
203
|
+
const ct = canonicalB64uAny(e.ciphertext);
|
|
204
|
+
// > 16: exactly the GCM tag is an EMPTY plaintext, which §5 forbids
|
|
205
|
+
return !!epk && epk.length === 32 && !!nonce && nonce.length === 12 && !!ct && ct.length > 16;
|
|
206
|
+
}
|
|
207
|
+
export class Carrier {
|
|
208
|
+
#decl;
|
|
209
|
+
/** the declaration as published — a copy; mutating it changes nothing
|
|
210
|
+
* (port-review-2 M-3: the validated state must not be reachable from
|
|
211
|
+
* outside the validated paths). */
|
|
212
|
+
get decl() { return { ...this.#decl }; }
|
|
213
|
+
policy;
|
|
214
|
+
events;
|
|
215
|
+
bindings = new Map();
|
|
216
|
+
tombstones = new Map();
|
|
217
|
+
releaseSeq = 0;
|
|
218
|
+
challenges = new Map();
|
|
219
|
+
// the carrier's clock high-water mark: async completions commit at the
|
|
220
|
+
// NEWEST time the carrier has seen, never at their caller's stale now
|
|
221
|
+
// (port-review-2 M-1) — normative time is monotone
|
|
222
|
+
clock = 0;
|
|
223
|
+
constructor(decl, policy = {}, events = {}) {
|
|
224
|
+
const errs = validateDeclaration(decl);
|
|
225
|
+
if (errs.length)
|
|
226
|
+
throw new Error(`declaration rejects (§11): ${errs.join('; ')}`);
|
|
227
|
+
this.#decl = { ...decl };
|
|
228
|
+
this.policy = policy;
|
|
229
|
+
this.events = events;
|
|
230
|
+
}
|
|
231
|
+
// ── time (§5a.9): transitions fire at their NORMATIVE instants ───────
|
|
232
|
+
// A late tick applies them retroactively — the closing instant is
|
|
233
|
+
// lastCollection + orphan-horizon and the deadline is that + give-up,
|
|
234
|
+
// never "whenever tick happened to run" (port-review B-5). Every
|
|
235
|
+
// public operation advances time to its own `now` before reading, so
|
|
236
|
+
// the port never serves from a state the clock has already ended.
|
|
237
|
+
advance(rawNow) {
|
|
238
|
+
// fail-closed time (port-review-4 M-3): NaN would poison the
|
|
239
|
+
// high-water mark forever, Infinity would fire every deadline at once
|
|
240
|
+
if (!Number.isFinite(rawNow) || rawNow < 0)
|
|
241
|
+
throw new Error('now must be a finite non-negative instant');
|
|
242
|
+
this.clock = Math.max(this.clock, rawNow);
|
|
243
|
+
const now = this.clock;
|
|
244
|
+
for (const [rkid, b] of this.bindings) {
|
|
245
|
+
if (b.state === 'live') {
|
|
246
|
+
// give-up: a deposit's life ends at its own horizon (§5a.8 —
|
|
247
|
+
// carrier-side storage release; the sender learns through its
|
|
248
|
+
// adapter's ordinary path, never through a new verdict)
|
|
249
|
+
for (const d of b.deposits.filter((d) => now >= d.lifeEndsAt))
|
|
250
|
+
this.events.onGiveUp?.(rkid, d.digest);
|
|
251
|
+
b.deposits = b.deposits.filter((d) => now < d.lifeEndsAt);
|
|
252
|
+
const closingAt = b.lastCollectionAt + this.#decl.orphanHorizonMs;
|
|
253
|
+
if (now >= closingAt) {
|
|
254
|
+
b.state = 'closing';
|
|
255
|
+
b.deadline = closingAt + this.#decl.giveUpHorizonMs;
|
|
256
|
+
for (const d of b.deposits)
|
|
257
|
+
d.lifeEndsAt = Math.min(d.lifeEndsAt, b.deadline);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
// releases due at this advance, ordered by their NORMATIVE instants
|
|
262
|
+
// (port-review-4 B-2: visit order is Map order, and a late tick made
|
|
263
|
+
// the younger release evict as if it were the older one)
|
|
264
|
+
const due = [];
|
|
265
|
+
for (const [rkid, b] of this.bindings) {
|
|
266
|
+
if (b.state === 'closing' && now >= b.deadline)
|
|
267
|
+
due.push({ rkid, b });
|
|
268
|
+
}
|
|
269
|
+
due.sort((x, y) => (x.b.deadline - y.b.deadline) || cmpRkidBytes(x.rkid, y.rkid));
|
|
270
|
+
for (const { rkid, b } of due) {
|
|
271
|
+
// the deadline IS the release — one transition (round-43 B-1)
|
|
272
|
+
for (const d of b.deposits)
|
|
273
|
+
this.events.onGiveUp?.(rkid, d.digest);
|
|
274
|
+
this.bindings.delete(rkid);
|
|
275
|
+
this.installTombstone(rkid, b.generation, b.deadline);
|
|
276
|
+
this.events.onRelease?.(rkid, b.generation);
|
|
277
|
+
}
|
|
278
|
+
for (const [id, ch] of this.challenges)
|
|
279
|
+
if (now >= ch.expiresAt)
|
|
280
|
+
this.challenges.delete(id);
|
|
281
|
+
this.pendingSeqInstant = null;
|
|
282
|
+
}
|
|
283
|
+
tick(now) { this.advance(now); }
|
|
284
|
+
// ── challenges (§5a.3) ───────────────────────────────────────────────
|
|
285
|
+
/** The gated challenge entry — guarantee 3 BY CONSTRUCTION
|
|
286
|
+
* (port-review-3 M-3): the admission gate runs INSIDE, before any
|
|
287
|
+
* entropy is drawn, so a refused request costs neither randomness nor
|
|
288
|
+
* sealing. `draw` is invoked only past the gate; its `addressValue`
|
|
289
|
+
* (for registration challenges) is the value the adapter will seal to
|
|
290
|
+
* the rkid — the carrier remembers it and a proof must return exactly
|
|
291
|
+
* it. Every challenge is BOUND to its rkid (port-review-3 B-1: a
|
|
292
|
+
* null-rkid challenge disabled the address proof).
|
|
293
|
+
* Returns null when the gate refuses — retriable, guarantee 2. */
|
|
294
|
+
issueChallenge(now, rkid, draw) {
|
|
295
|
+
this.advance(now);
|
|
296
|
+
// syntax BEFORE policy and draw (port-review-5 B-1): a syntactically
|
|
297
|
+
// impossible address buys neither a gate consultation nor a single
|
|
298
|
+
// random byte — §5a.3's order is syntax → admission → randomness
|
|
299
|
+
if (!decodedX(rkid))
|
|
300
|
+
throw new Error('malformed rkid — refuse before any work (5a.3)');
|
|
301
|
+
const held = this.bindings.has(rkid);
|
|
302
|
+
const admitted = held ? (this.policy.admitHeld?.(rkid) ?? true) : (this.policy.admitNew?.() ?? true);
|
|
303
|
+
if (!admitted)
|
|
304
|
+
return null;
|
|
305
|
+
const { entropy, addressValue } = draw();
|
|
306
|
+
if (entropy.length !== 32)
|
|
307
|
+
throw new Error('challenge entropy is 32 bytes');
|
|
308
|
+
const value = b64uOf(entropy);
|
|
309
|
+
this.challenges.set(value, {
|
|
310
|
+
rkid, addressValue: addressValue ?? null,
|
|
311
|
+
expiresAt: this.clock + this.#decl.challengeLifetimeMs, // M-2: the monotone clock, never a stale now
|
|
312
|
+
});
|
|
313
|
+
return value;
|
|
314
|
+
}
|
|
315
|
+
/** consume-first (§5a.3): the first attempt takes the challenge before
|
|
316
|
+
* anything is verified; expiry and rkid binding are checked HERE, so a
|
|
317
|
+
* stale or transplanted challenge dies without buying a verification. */
|
|
318
|
+
consume(value, rkid, now) {
|
|
319
|
+
const ch = this.challenges.get(value);
|
|
320
|
+
this.challenges.delete(value);
|
|
321
|
+
if (!ch || Math.max(now, this.clock) >= ch.expiresAt)
|
|
322
|
+
return null;
|
|
323
|
+
if (ch.rkid !== rkid)
|
|
324
|
+
return null; // bound, always (B-1)
|
|
325
|
+
return ch;
|
|
326
|
+
}
|
|
327
|
+
// ── registration / rebind: r1–r5 (§5a.3, rounds 43/44) ───────────────
|
|
328
|
+
async register(proof, opts) {
|
|
329
|
+
// SNAPSHOT at entry (port-review-4 B-1): the caller's object stays
|
|
330
|
+
// the caller's — what was verified is what commits, whatever the
|
|
331
|
+
// caller mutates while the crypto runs
|
|
332
|
+
const p = { ...proof };
|
|
333
|
+
this.advance(opts.now);
|
|
334
|
+
// r1 — syntax and canonical encodings; no crypto has happened yet.
|
|
335
|
+
// The PURPOSE binds the operation (port-review-2 B-2): a session
|
|
336
|
+
// proof at register() is not this artifact, whatever it signs.
|
|
337
|
+
if (p?.purpose !== 'register' && p?.purpose !== 'rebind')
|
|
338
|
+
return { verdict: 'refused(malformed)' };
|
|
339
|
+
if (checkProofShape(p, this.#decl.carrier, opts.rawGeneration))
|
|
340
|
+
return { verdict: 'refused(malformed)' };
|
|
341
|
+
const heldAtGate = this.bindings.has(p.rkid);
|
|
342
|
+
// r2 — the carrier's own admission metering, subject to guarantee 4:
|
|
343
|
+
// a held binding's request has its own gate (round-44 B-2)
|
|
344
|
+
const admitted = heldAtGate ? (this.policy.admitHeld?.(p.rkid) ?? true) : (this.policy.admitNew?.() ?? true);
|
|
345
|
+
if (!admitted)
|
|
346
|
+
return { verdict: 'refused(admission-resource)', retriable: true };
|
|
347
|
+
// r3 — consume, then verify: the challenge is taken by this attempt
|
|
348
|
+
// before the response is verified, checked for expiry and rkid
|
|
349
|
+
// binding at the same instant (port-review B-1/B-2)
|
|
350
|
+
const ch = this.consume(p.principalChallenge, p.rkid, opts.now);
|
|
351
|
+
if (!ch)
|
|
352
|
+
return { verdict: 'refused(possession-failed)' };
|
|
353
|
+
// fail-closed (port-review-2 B-1): a challenge issued WITHOUT a
|
|
354
|
+
// sealed address value cannot prove address possession — it refuses,
|
|
355
|
+
// it never waves through
|
|
356
|
+
if (ch.addressValue === null || p.addressChallenge !== ch.addressValue) {
|
|
357
|
+
return { verdict: 'refused(possession-failed)' };
|
|
358
|
+
}
|
|
359
|
+
if (!await verifySignature(p))
|
|
360
|
+
return { verdict: 'refused(possession-failed)' };
|
|
361
|
+
// ── the commit block: synchronous from here — the linearization
|
|
362
|
+
// point of §4.4. State is RE-read; no await crosses this line.
|
|
363
|
+
this.advance(opts.now);
|
|
364
|
+
const held = this.bindings.get(p.rkid);
|
|
365
|
+
// r2, re-established (port-review-3 B-3): the gate CLASS (held vs
|
|
366
|
+
// new) may have changed while the crypto ran — a binding that
|
|
367
|
+
// released mid-verify makes this a NEW-binding request, and it must
|
|
368
|
+
// pass the gate a new binding passes, in this same sync block
|
|
369
|
+
if ((held !== undefined) !== heldAtGate) {
|
|
370
|
+
const readmit = held ? (this.policy.admitHeld?.(p.rkid) ?? true) : (this.policy.admitNew?.() ?? true);
|
|
371
|
+
if (!readmit)
|
|
372
|
+
return { verdict: 'refused(admission-resource)', retriable: true };
|
|
373
|
+
}
|
|
374
|
+
// r4 — capacity: only a transition that creates a binding consults it
|
|
375
|
+
if (!held && !(this.policy.hasBindingRoom?.() ?? true)) {
|
|
376
|
+
return { verdict: 'registration-refused(capacity)', retriable: true };
|
|
377
|
+
}
|
|
378
|
+
// r5 — the state table
|
|
379
|
+
const g = p.generation;
|
|
380
|
+
const tomb = this.tombstones.get(p.rkid);
|
|
381
|
+
if (held) {
|
|
382
|
+
if (g > held.generation) {
|
|
383
|
+
held.generation = g;
|
|
384
|
+
held.principal = p.principal;
|
|
385
|
+
this.reopen(held, this.clock);
|
|
386
|
+
return { verdict: 'rebound' };
|
|
387
|
+
}
|
|
388
|
+
if (g === held.generation && p.principal === held.principal) {
|
|
389
|
+
this.reopen(held, this.clock);
|
|
390
|
+
return { verdict: 'registered(idempotent)' };
|
|
391
|
+
}
|
|
392
|
+
return { verdict: 'refused(stale-generation)', heldGeneration: held.generation };
|
|
393
|
+
}
|
|
394
|
+
if (tomb) {
|
|
395
|
+
if (g > tomb.generation) {
|
|
396
|
+
this.tombstones.delete(p.rkid); // consumed
|
|
397
|
+
this.bindings.set(p.rkid, this.freshBinding(g, p.principal, this.clock));
|
|
398
|
+
return { verdict: 'registered' };
|
|
399
|
+
}
|
|
400
|
+
return { verdict: 'refused(stale-generation)', heldGeneration: tomb.generation };
|
|
401
|
+
}
|
|
402
|
+
this.bindings.set(p.rkid, this.freshBinding(g, p.principal, this.clock));
|
|
403
|
+
return { verdict: 'registered' };
|
|
404
|
+
}
|
|
405
|
+
freshBinding(generation, principal, now) {
|
|
406
|
+
return { state: 'live', generation, principal, deposits: [], lastCollectionAt: now };
|
|
407
|
+
}
|
|
408
|
+
reopen(b, now) {
|
|
409
|
+
if (b.state === 'closing') {
|
|
410
|
+
b.state = 'live';
|
|
411
|
+
b.deadline = undefined;
|
|
412
|
+
// the return ends the wind-up: deposits revert to their own
|
|
413
|
+
// admission-dated horizons (§5a.9)
|
|
414
|
+
for (const d of b.deposits)
|
|
415
|
+
d.lifeEndsAt = d.admittedAt + this.#decl.giveUpHorizonMs;
|
|
416
|
+
}
|
|
417
|
+
b.lastCollectionAt = now;
|
|
418
|
+
}
|
|
419
|
+
// ── collection / conclusion (§5a.3/5a.7/5a.8) ────────────────────────
|
|
420
|
+
// purpose is part of the signed bytes AND of the dispatch: a proof for
|
|
421
|
+
// one operation is not a proof for the other (port-review B-3).
|
|
422
|
+
async collect(proof, opts) {
|
|
423
|
+
const p = { ...proof };
|
|
424
|
+
const s = await this.session(p, 'collect', opts.now);
|
|
425
|
+
if (s)
|
|
426
|
+
return s;
|
|
427
|
+
const b = this.bindings.get(p.rkid);
|
|
428
|
+
if (!b || b.principal !== p.principal)
|
|
429
|
+
return { verdict: 'refused(no-such-queue)' };
|
|
430
|
+
b.lastCollectionAt = this.clock;
|
|
431
|
+
return { verdict: 'served', deposits: b.deposits.map((d) => ({ bytes: d.bytes.slice(), digest: d.digest })) };
|
|
432
|
+
}
|
|
433
|
+
async conclude(proof, digest, opts) {
|
|
434
|
+
const p = { ...proof };
|
|
435
|
+
const s = await this.session(p, 'conclude', opts.now);
|
|
436
|
+
if (s)
|
|
437
|
+
return s;
|
|
438
|
+
const b = this.bindings.get(p.rkid);
|
|
439
|
+
if (!b || b.principal !== p.principal)
|
|
440
|
+
return { verdict: 'refused(no-such-queue)' };
|
|
441
|
+
b.deposits = b.deposits.filter((d) => d.digest !== digest);
|
|
442
|
+
// deliberately NOT lastCollectionAt: orphan-expiry counts collections
|
|
443
|
+
// (§5a.9), and a principal must not keep a never-collected queue out
|
|
444
|
+
// of its wind-up by concluding (port-review-2 M-2)
|
|
445
|
+
return { verdict: 'served' };
|
|
446
|
+
}
|
|
447
|
+
async session(proofArg, expected, now) {
|
|
448
|
+
const p = { ...proofArg };
|
|
449
|
+
this.advance(now);
|
|
450
|
+
if (p.purpose !== expected)
|
|
451
|
+
return { verdict: 'refused(malformed)' };
|
|
452
|
+
if (checkProofShape(p, this.#decl.carrier))
|
|
453
|
+
return { verdict: 'refused(malformed)' };
|
|
454
|
+
const held = this.bindings.get(p.rkid);
|
|
455
|
+
const admitted = held ? (this.policy.admitHeld?.(p.rkid) ?? true) : (this.policy.admitNew?.() ?? true);
|
|
456
|
+
if (!admitted)
|
|
457
|
+
return { verdict: 'refused(admission-resource)', retriable: true };
|
|
458
|
+
const ch = this.consume(p.principalChallenge, p.rkid, now);
|
|
459
|
+
if (!ch)
|
|
460
|
+
return { verdict: 'refused(possession-failed)' };
|
|
461
|
+
if (!await verifySignature(p))
|
|
462
|
+
return { verdict: 'refused(possession-failed)' };
|
|
463
|
+
// commit side re-reads (the caller checks binding + principal after
|
|
464
|
+
// this returns null, synchronously)
|
|
465
|
+
this.advance(now);
|
|
466
|
+
if (!this.bindings.has(p.rkid))
|
|
467
|
+
return { verdict: 'refused(no-such-queue)' };
|
|
468
|
+
return null;
|
|
469
|
+
}
|
|
470
|
+
// ── submission: s1–s6 (§5a.5) ────────────────────────────────────────
|
|
471
|
+
async submit(rkid, callerBytes, opts) {
|
|
472
|
+
// SNAPSHOT at entry (port-review-4 M-1/M-2): the bytes are copied
|
|
473
|
+
// before anything reads them, and the declaration is pinned for the
|
|
474
|
+
// whole operation — a concurrent revision governs the NEXT
|
|
475
|
+
// submission, never one already running
|
|
476
|
+
const bytes = callerBytes.slice();
|
|
477
|
+
const decl = this.#decl;
|
|
478
|
+
this.advance(opts.now);
|
|
479
|
+
// s1 — queue lookup FIRST: no hashing for an address the carrier
|
|
480
|
+
// does not hold (port-review-2 M-4 — s3 work ran before s1/s2 and
|
|
481
|
+
// let an unauthenticated blob buy a full SHA pass)
|
|
482
|
+
if (!this.bindings.has(rkid))
|
|
483
|
+
return { verdict: 'refused(no-such-queue)' };
|
|
484
|
+
// s2 — shape and size: a Section-5 envelope naming this queue,
|
|
485
|
+
// within declared bounds
|
|
486
|
+
if (bytes.length > decl.maxQueueBytes || !checkEnvelopeBytes(bytes, rkid)) {
|
|
487
|
+
return { verdict: 'refused(bounds)' };
|
|
488
|
+
}
|
|
489
|
+
// s3 needs the digest — hash now, then RE-read state (the await may
|
|
490
|
+
// have crossed a deadline; the commit block below is synchronous)
|
|
491
|
+
const digest = b64uOf(await sha(bytes));
|
|
492
|
+
this.advance(opts.now);
|
|
493
|
+
const b = this.bindings.get(rkid);
|
|
494
|
+
if (!b)
|
|
495
|
+
return { verdict: 'refused(no-such-queue)' };
|
|
496
|
+
// s3 — byte-identity. The digest indexes; the BYTES decide (M-6).
|
|
497
|
+
const same = b.deposits.find((d) => d.digest === digest &&
|
|
498
|
+
d.bytes.length === bytes.length && d.bytes.every((x, i) => x === bytes[i]));
|
|
499
|
+
if (same) {
|
|
500
|
+
// §5a.5: a duplicate consumes admission work exactly like an
|
|
501
|
+
// admitted submission — and stores nothing
|
|
502
|
+
this.policy.meterQueue?.(rkid, bytes.length);
|
|
503
|
+
return { verdict: 'duplicate' };
|
|
504
|
+
}
|
|
505
|
+
const occupancy = b.deposits.reduce((a, d) => a + d.bytes.length, 0) + bytes.length;
|
|
506
|
+
const withinFloor = occupancy <= decl.queueFloorBytes;
|
|
507
|
+
// s4 — the queue's own meter (below the floor: the only one, g5)
|
|
508
|
+
if (!(this.policy.meterQueue?.(rkid, bytes.length) ?? true)) {
|
|
509
|
+
return { verdict: 'refused(admission-resource)', retriable: true };
|
|
510
|
+
}
|
|
511
|
+
if (!withinFloor) {
|
|
512
|
+
// s5 — the queue's own bound
|
|
513
|
+
if (occupancy > decl.maxQueueBytes)
|
|
514
|
+
return { verdict: 'refused(queue-saturated)', retriable: true };
|
|
515
|
+
// s6 — global occupancy, unreachable below the floor
|
|
516
|
+
if (!(this.policy.hasCapacity?.(bytes.length) ?? true)) {
|
|
517
|
+
return { verdict: 'refused(capacity)', retriable: true };
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
const lifeEndsAt = b.state === 'closing' && b.deadline !== undefined
|
|
521
|
+
? b.deadline // inherits the remaining time
|
|
522
|
+
: this.clock + decl.giveUpHorizonMs;
|
|
523
|
+
b.deposits.push({ bytes, digest, admittedAt: this.clock, lifeEndsAt });
|
|
524
|
+
return { verdict: 'admitted' };
|
|
525
|
+
}
|
|
526
|
+
// ── tombstones (§5a.3): normative order, bookkeeping-free ────────────
|
|
527
|
+
// Releases that fall on the SAME normative instant share one release
|
|
528
|
+
// rank, so the bytewise tie-break genuinely decides between them
|
|
529
|
+
// (port-review M-7) — the advance() loop batches them per call.
|
|
530
|
+
pendingSeqInstant = null;
|
|
531
|
+
installTombstone(rkid, generation, releasedAt) {
|
|
532
|
+
if (this.pendingSeqInstant?.instant !== releasedAt) {
|
|
533
|
+
this.pendingSeqInstant = { instant: releasedAt, seq: ++this.releaseSeq };
|
|
534
|
+
}
|
|
535
|
+
this.tombstones.set(rkid, { generation, releaseSeq: this.pendingSeqInstant.seq });
|
|
536
|
+
this.trimTombstones(this.#decl.maxBindingTombstones);
|
|
537
|
+
}
|
|
538
|
+
trimTombstones(bound) {
|
|
539
|
+
while (this.tombstones.size > bound) {
|
|
540
|
+
const victim = [...this.tombstones.entries()].sort((a, b) => (a[1].releaseSeq - b[1].releaseSeq) || cmpRkidBytes(a[0], b[0]))[0][0];
|
|
541
|
+
this.tombstones.delete(victim);
|
|
542
|
+
this.events.onEvict?.(victim);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
/** guarantee 1: the declaration binds, and a revision is itself
|
|
546
|
+
* validated; a lowered tombstone bound trims in the same step
|
|
547
|
+
* (round-45 B-3 — standing state is not a running act). */
|
|
548
|
+
reviseDeclaration(next) {
|
|
549
|
+
// C is the carrier's IDENTITY, not an operational constant: every
|
|
550
|
+
// principal is derived from it (Identity §7a.4), so "the same
|
|
551
|
+
// carrier under a new C" is a contradiction — a new C is a new
|
|
552
|
+
// carrier with no bindings (port-review-3 B-2)
|
|
553
|
+
if (next.carrier !== undefined && next.carrier !== this.#decl.carrier) {
|
|
554
|
+
throw new Error('the carrier identifier is not revisable — a different C is a different carrier (7a.4)');
|
|
555
|
+
}
|
|
556
|
+
const revised = { ...this.#decl, ...next };
|
|
557
|
+
const errs = validateDeclaration(revised);
|
|
558
|
+
if (errs.length)
|
|
559
|
+
throw new Error(`revision rejects (§11): ${errs.join('; ')}`);
|
|
560
|
+
this.#decl = revised;
|
|
561
|
+
this.trimTombstones(revised.maxBindingTombstones);
|
|
562
|
+
}
|
|
563
|
+
// ── introspection (tests and adapters; not port surface) ─────────────
|
|
564
|
+
stateOf(rkid) {
|
|
565
|
+
const b = this.bindings.get(rkid);
|
|
566
|
+
if (b)
|
|
567
|
+
return b.state;
|
|
568
|
+
return this.tombstones.has(rkid) ? 'released' : 'unbound';
|
|
569
|
+
}
|
|
570
|
+
tombstoneOf(rkid) { return this.tombstones.get(rkid)?.generation ?? null; }
|
|
571
|
+
bindingOf(rkid) {
|
|
572
|
+
const b = this.bindings.get(rkid);
|
|
573
|
+
return b ? { generation: b.generation, principal: b.principal, deposits: b.deposits.length, deadline: b.deadline } : null;
|
|
574
|
+
}
|
|
575
|
+
tombstoneLabels() { return [...this.tombstones.keys()]; }
|
|
576
|
+
}
|
|
577
|
+
//# sourceMappingURL=carrier.js.map
|