@openvtc/trust-tasks 0.12.17 → 0.13.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 +27 -3
- package/dist/_runtime/canonical.d.ts +40 -0
- package/dist/_runtime/canonical.d.ts.map +1 -0
- package/dist/_runtime/canonical.js +147 -0
- package/dist/_runtime/canonical.js.map +1 -0
- package/dist/_runtime/consume.d.ts +108 -2
- package/dist/_runtime/consume.d.ts.map +1 -1
- package/dist/_runtime/consume.js +174 -8
- package/dist/_runtime/consume.js.map +1 -1
- package/dist/_runtime/document.d.ts +17 -4
- package/dist/_runtime/document.d.ts.map +1 -1
- package/dist/_runtime/document.js +17 -4
- package/dist/_runtime/document.js.map +1 -1
- package/dist/_runtime/freshness.d.ts +105 -0
- package/dist/_runtime/freshness.d.ts.map +1 -0
- package/dist/_runtime/freshness.js +142 -0
- package/dist/_runtime/freshness.js.map +1 -0
- package/dist/_runtime/index.d.ts +4 -1
- package/dist/_runtime/index.d.ts.map +1 -1
- package/dist/_runtime/index.js +4 -1
- package/dist/_runtime/index.js.map +1 -1
- package/dist/_runtime/replay.d.ts +176 -0
- package/dist/_runtime/replay.d.ts.map +1 -0
- package/dist/_runtime/replay.js +147 -0
- package/dist/_runtime/replay.js.map +1 -0
- package/package.json +1 -1
- package/src/_runtime/canonical.ts +159 -0
- package/src/_runtime/consume.ts +267 -10
- package/src/_runtime/document.ts +17 -4
- package/src/_runtime/freshness.ts +183 -0
- package/src/_runtime/index.ts +30 -0
- package/src/_runtime/replay.ts +250 -0
package/src/_runtime/consume.ts
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Inbound-document orchestration for SPEC.md §7.2 item 2
|
|
2
|
+
* Inbound-document orchestration for SPEC.md §7.2 item 2, items 4–8, and the
|
|
3
|
+
* two stateful checks — the freshness bound of item 4 and the
|
|
4
|
+
* duplicate-execution record of item 11.
|
|
3
5
|
*
|
|
4
6
|
* Hand-written. Mirrors `consume.rs` in trust-tasks-rs, deliberately closely:
|
|
5
7
|
* a TypeScript consumer and a Rust one must reach the same verdict on the same
|
|
6
8
|
* document, or the two reference implementations disagree about what conforms.
|
|
7
9
|
*
|
|
8
10
|
* ```ts
|
|
9
|
-
* import {
|
|
11
|
+
* import {
|
|
12
|
+
* consequentialChecks, consumeInbound, InMemoryReplayGuard,
|
|
13
|
+
* StaticTransport, AclGrant_v0_1,
|
|
14
|
+
* } from "@openvtc/trust-tasks";
|
|
15
|
+
*
|
|
16
|
+
* // One guard per consumer, not one per document: it *is* the record.
|
|
17
|
+
* const guard = new InMemoryReplayGuard();
|
|
10
18
|
*
|
|
11
19
|
* const outcome = await consumeInbound({
|
|
12
20
|
* transport,
|
|
@@ -14,6 +22,8 @@
|
|
|
14
22
|
* proofPolicy: { kind: "verify", verify: myVerifier },
|
|
15
23
|
* // ajv, or whatever validator you already run — see PayloadPolicy.
|
|
16
24
|
* payloadPolicy: { kind: "validate", validate: myValidator },
|
|
25
|
+
* // acl/grant is consequential: a replayed envelope must not grant twice.
|
|
26
|
+
* checks: consequentialChecks(guard),
|
|
17
27
|
* doc,
|
|
18
28
|
* myVid: "did:web:maintainer.example",
|
|
19
29
|
* now: Date.now(),
|
|
@@ -27,6 +37,11 @@
|
|
|
27
37
|
* case "rejected": emit(outcome.error); break;
|
|
28
38
|
* case "suppressed": logSuppressed(); break;
|
|
29
39
|
* case "accepted": break; // fire-and-forget: nothing to emit
|
|
40
|
+
* // §7.2 item 11: already executed. Not an error — return the prior result
|
|
41
|
+
* // if there is one, otherwise emit nothing.
|
|
42
|
+
* case "duplicate":
|
|
43
|
+
* if (outcome.priorResponse !== undefined) emit(outcome.priorResponse);
|
|
44
|
+
* break;
|
|
30
45
|
* }
|
|
31
46
|
* ```
|
|
32
47
|
*
|
|
@@ -55,6 +70,19 @@ import {
|
|
|
55
70
|
type SpecPolicy,
|
|
56
71
|
type TrustTaskDocument,
|
|
57
72
|
} from "./document.js";
|
|
73
|
+
import {
|
|
74
|
+
CONSEQUENTIAL_FRESHNESS,
|
|
75
|
+
DEFAULT_FRESHNESS,
|
|
76
|
+
STALE_WIRE_MESSAGE,
|
|
77
|
+
recordExpiry,
|
|
78
|
+
validateFreshness,
|
|
79
|
+
type FreshnessPolicy,
|
|
80
|
+
} from "./freshness.js";
|
|
81
|
+
import {
|
|
82
|
+
documentDigest,
|
|
83
|
+
type ReplayGuard,
|
|
84
|
+
type ReplayPolicy,
|
|
85
|
+
} from "./replay.js";
|
|
58
86
|
import {
|
|
59
87
|
identityMismatchReason,
|
|
60
88
|
reject,
|
|
@@ -63,6 +91,53 @@ import {
|
|
|
63
91
|
type TransportHandler,
|
|
64
92
|
} from "./transport.js";
|
|
65
93
|
|
|
94
|
+
/**
|
|
95
|
+
* The two stateful §7.2 checks: the freshness bound over `issuedAt` /
|
|
96
|
+
* `expiresAt`, and the duplicate-execution record of item 11.
|
|
97
|
+
*
|
|
98
|
+
* They travel together because the spec ties them together. §7.2 (*Bounding
|
|
99
|
+
* the record*) makes the acceptance window and the record's retention **the
|
|
100
|
+
* same bound**. Passing them as one option is what stops a deployment
|
|
101
|
+
* configuring a five-minute record and an unbounded acceptance window, which
|
|
102
|
+
* reads as a working replay defence and is not one.
|
|
103
|
+
*
|
|
104
|
+
* Like {@link PayloadPolicy}, this is a *required* option. Whether the task a
|
|
105
|
+
* consumer implements is *consequential* (§2) is a decision only that consumer
|
|
106
|
+
* can make, and the failure mode of getting it wrong silently — an ACL grant
|
|
107
|
+
* applied twice by a mediator retry — is not one to discover after the fact.
|
|
108
|
+
*/
|
|
109
|
+
export interface ConsumeChecks {
|
|
110
|
+
/** Bounds the document in time (SPEC §4.2, §7.2 item 4). */
|
|
111
|
+
readonly freshness: FreshnessPolicy;
|
|
112
|
+
/** Applies (or knowingly disapplies) SPEC §7.2 item 11. */
|
|
113
|
+
readonly replay: ReplayPolicy;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The posture for a *consequential Trust Task* (§2): item 11 enforced against
|
|
118
|
+
* `guard`, and the bounded acceptance window that makes the record droppable.
|
|
119
|
+
*
|
|
120
|
+
* The right choice for any task whose execution grants access, moves value,
|
|
121
|
+
* discloses a secret, or otherwise cannot be undone by ignoring the next
|
|
122
|
+
* document.
|
|
123
|
+
*/
|
|
124
|
+
export function consequentialChecks(guard: ReplayGuard): ConsumeChecks {
|
|
125
|
+
return { freshness: CONSEQUENTIAL_FRESHNESS, replay: { kind: "guard", guard } };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The posture for a task that is **not** consequential, or whose specification
|
|
130
|
+
* "explicitly declares repeated execution safe and intended" — the narrow
|
|
131
|
+
* disapplication item 11 permits.
|
|
132
|
+
*
|
|
133
|
+
* Keeps no record. Still applies the default freshness policy, because a
|
|
134
|
+
* future-dated document and one with an empty validity interval are malformed
|
|
135
|
+
* whatever the task does.
|
|
136
|
+
*/
|
|
137
|
+
export function notConsequentialChecks(): ConsumeChecks {
|
|
138
|
+
return { freshness: DEFAULT_FRESHNESS, replay: { kind: "notConsequential" } };
|
|
139
|
+
}
|
|
140
|
+
|
|
66
141
|
/** Verifies a document's Data Integrity proof (SPEC §4.7). */
|
|
67
142
|
export interface ProofVerifier {
|
|
68
143
|
/** Resolve to `true` when the proof verifies against the in-band `issuer`. */
|
|
@@ -173,7 +248,32 @@ export type ConsumeOutcome<R> =
|
|
|
173
248
|
* return type demanded a document or an error, and returning neither threw a
|
|
174
249
|
* bare `TypeError` out of the pipeline. Emit nothing.
|
|
175
250
|
*/
|
|
176
|
-
| { kind: "accepted" }
|
|
251
|
+
| { kind: "accepted" }
|
|
252
|
+
/**
|
|
253
|
+
* SPEC §7.2 item 11: a document with this `id` and this content was already
|
|
254
|
+
* accepted for execution. **The handler was not called**, and the
|
|
255
|
+
* consequential effect did not happen a second time. This is the §8.4 retry
|
|
256
|
+
* being absorbed, which is what makes retrying safe.
|
|
257
|
+
*
|
|
258
|
+
* **Not an error.** §7.2 (*Disposition of a duplicate*): "In no case is a
|
|
259
|
+
* duplicate reported as `taskFailed`; the task did not fail, it already
|
|
260
|
+
* happened." Folding this into `rejected` would report a failure that did
|
|
261
|
+
* not occur.
|
|
262
|
+
*
|
|
263
|
+
* What to emit:
|
|
264
|
+
*
|
|
265
|
+
* - `priorResponse` present — emit it. It is the response document the first
|
|
266
|
+
* execution produced (a success response, or a non-retryable error
|
|
267
|
+
* response; tell them apart by its `type`), which §7.2 says the consumer
|
|
268
|
+
* SHOULD return.
|
|
269
|
+
* - `priorResponse` absent, `inFlight === false` — the specification defines
|
|
270
|
+
* no success response (§4.4.1 fire-and-forget), or the guard retains none.
|
|
271
|
+
* Emit nothing: that silence is the correct disposition, not an error.
|
|
272
|
+
* - `inFlight === true` — the first execution has not finished. §7.2: the
|
|
273
|
+
* consumer SHOULD "return or expose the existing execution state rather
|
|
274
|
+
* than begin another".
|
|
275
|
+
*/
|
|
276
|
+
| { kind: "duplicate"; priorResponse?: unknown; inFlight: boolean };
|
|
177
277
|
|
|
178
278
|
/** Wire-safe message for the `rejectIfPresent` path. */
|
|
179
279
|
export const PROOF_NOT_ACCEPTED_BY_POLICY =
|
|
@@ -186,6 +286,12 @@ export interface ConsumeOptions<P, R> {
|
|
|
186
286
|
proofPolicy: ProofPolicy;
|
|
187
287
|
/** §7.2 item 2. Required — see {@link PayloadPolicy} for why. */
|
|
188
288
|
payloadPolicy: PayloadPolicy;
|
|
289
|
+
/**
|
|
290
|
+
* §7.2 item 4 (freshness) and item 11 (duplicate execution). Required — see
|
|
291
|
+
* {@link ConsumeChecks}. Use {@link consequentialChecks} or
|
|
292
|
+
* {@link notConsequentialChecks}.
|
|
293
|
+
*/
|
|
294
|
+
checks: ConsumeChecks;
|
|
189
295
|
doc: TrustTaskDocument<P>;
|
|
190
296
|
/** This consumer's own VID, for the §7.2 item 5 recipient check. */
|
|
191
297
|
myVid: string;
|
|
@@ -225,8 +331,19 @@ export interface ConsumeOptions<P, R> {
|
|
|
225
331
|
export async function consumeInbound<P, R>(
|
|
226
332
|
opts: ConsumeOptions<P, R>,
|
|
227
333
|
): Promise<ConsumeOutcome<R>> {
|
|
228
|
-
const {
|
|
229
|
-
|
|
334
|
+
const {
|
|
335
|
+
transport,
|
|
336
|
+
spec,
|
|
337
|
+
proofPolicy,
|
|
338
|
+
payloadPolicy,
|
|
339
|
+
checks,
|
|
340
|
+
doc,
|
|
341
|
+
myVid,
|
|
342
|
+
now,
|
|
343
|
+
newErrorId,
|
|
344
|
+
handler,
|
|
345
|
+
clock,
|
|
346
|
+
} = opts;
|
|
230
347
|
|
|
231
348
|
const route = (reason: RejectReason): ConsumeOutcome<R> => {
|
|
232
349
|
const error = reject(transport, doc, newErrorId(), reason, clock);
|
|
@@ -246,6 +363,19 @@ export async function consumeInbound<P, R>(
|
|
|
246
363
|
);
|
|
247
364
|
}
|
|
248
365
|
|
|
366
|
+
// Same reasoning, for the check that decides whether a replayed envelope
|
|
367
|
+
// executes a second time. A JavaScript caller gets no compile-time check,
|
|
368
|
+
// and defaulting to "keep no record" would silently reproduce the defect
|
|
369
|
+
// this option exists to remove.
|
|
370
|
+
if (checks === undefined) {
|
|
371
|
+
throw new TypeError(
|
|
372
|
+
"consumeInbound: `checks` is required as of 0.12.18 (SPEC §7.2 items 4 and 11). " +
|
|
373
|
+
"Pass consequentialChecks(guard) for a task whose execution grants access, " +
|
|
374
|
+
"moves value or is otherwise irreversible, or notConsequentialChecks() to " +
|
|
375
|
+
"state that repeated execution of this task is safe and intended.",
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
249
379
|
// §7.2 item 2 — payload schema. Runs first, in the spec's own order, and
|
|
250
380
|
// before anything that reasons about what the payload means: a payload that
|
|
251
381
|
// is not the shape the specification declares should be refused as malformed
|
|
@@ -278,6 +408,15 @@ export async function consumeInbound<P, R>(
|
|
|
278
408
|
const basic = validateBasic(doc, now, myVid);
|
|
279
409
|
if (basic !== null) return route(basic);
|
|
280
410
|
|
|
411
|
+
// §7.2 item 4, the other half — the freshness bound over `issuedAt`.
|
|
412
|
+
// `validateBasic` honours `expiresAt`, which is optional and which a
|
|
413
|
+
// producer sets for its own reasons; on its own it leaves a document stamped
|
|
414
|
+
// years ago, or years hence, indefinitely acceptable. It is also what bounds
|
|
415
|
+
// the replay record below: §7.2 makes the acceptance window and the record's
|
|
416
|
+
// retention one bound.
|
|
417
|
+
const fresh = validateFreshness(doc, now, checks.freshness);
|
|
418
|
+
if (fresh !== null) return route(fresh);
|
|
419
|
+
|
|
281
420
|
// §7.2 item 6 — in-band vs transport-derived identity cross-check.
|
|
282
421
|
const resolved = resolveParties(transport, doc);
|
|
283
422
|
if ("error" in resolved) return route(identityMismatchReason(resolved.error));
|
|
@@ -293,9 +432,15 @@ export async function consumeInbound<P, R>(
|
|
|
293
432
|
ok = false;
|
|
294
433
|
}
|
|
295
434
|
if (!ok) {
|
|
435
|
+
// A constant, never the verifier's own error text. SPEC §10.4
|
|
436
|
+
// extends the §8.1 identity rule to every code, and a verifier's
|
|
437
|
+
// vocabulary names DIDs it tried to resolve, whether a resolver
|
|
438
|
+
// answered, and what a fetched DID document contained — a
|
|
439
|
+
// resolver-reachability oracle for a sender who is, by construction,
|
|
440
|
+
// unauthenticated. Log the detail; do not send it.
|
|
296
441
|
return route({
|
|
297
442
|
code: "proofInvalid",
|
|
298
|
-
message:
|
|
443
|
+
message: PROOF_INVALID_WIRE_MESSAGE,
|
|
299
444
|
retryable: false,
|
|
300
445
|
});
|
|
301
446
|
}
|
|
@@ -317,13 +462,125 @@ export async function consumeInbound<P, R>(
|
|
|
317
462
|
const policy = enforceSpecPolicy(doc, spec);
|
|
318
463
|
if (policy !== null) return route(policy);
|
|
319
464
|
|
|
465
|
+
// §7.2 item 11 — the duplicate-execution record. Deliberately **last**:
|
|
466
|
+
// claiming the `id` marks the document as accepted for execution, and a
|
|
467
|
+
// document some earlier check refuses was never accepted. Claiming first
|
|
468
|
+
// would burn the `id` on every malformed or unauthorised arrival, so a
|
|
469
|
+
// corrected resend under the same `id` would come back `idConflict` forever
|
|
470
|
+
// — and an attacker could pre-burn an `id` it had merely observed.
|
|
471
|
+
let claim: { guard: ReplayGuard; digest: string } | undefined;
|
|
472
|
+
if (checks.replay.kind === "guard") {
|
|
473
|
+
const { guard } = checks.replay;
|
|
474
|
+
const digest = documentDigest(doc);
|
|
475
|
+
|
|
476
|
+
// §7.2 (*Bounding the record*): "A consumer that can establish neither an
|
|
477
|
+
// `expiresAt` nor an age for a document has no window in which to place
|
|
478
|
+
// it, and MUST NOT execute a consequential Trust Task on it." A guard
|
|
479
|
+
// asked to retain a record forever is not a guard, so refuse rather than
|
|
480
|
+
// pretend.
|
|
481
|
+
const retainUntil = recordExpiry(doc, checks.freshness, now);
|
|
482
|
+
if (retainUntil === undefined) {
|
|
483
|
+
return route({ code: "expired", message: STALE_WIRE_MESSAGE, retryable: false });
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
let verdict;
|
|
487
|
+
try {
|
|
488
|
+
verdict = await guard.claim(doc.id, digest, retainUntil, now);
|
|
489
|
+
} catch {
|
|
490
|
+
// Fail closed. A consumer that cannot consult its record has not
|
|
491
|
+
// satisfied item 11, and executing anyway is exactly the double
|
|
492
|
+
// execution the rule forbids. `unavailable` is retryable, which is the
|
|
493
|
+
// honest answer: the producer's bit-for-bit resend will be absorbed
|
|
494
|
+
// correctly once the store is back. The thrown detail — a hostname, a
|
|
495
|
+
// connection string — stays out of the message, per §10.4.
|
|
496
|
+
return route({
|
|
497
|
+
code: "unavailable",
|
|
498
|
+
message: REPLAY_RECORD_UNAVAILABLE,
|
|
499
|
+
retryable: true,
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
switch (verdict.kind) {
|
|
504
|
+
case "duplicate":
|
|
505
|
+
return verdict.priorResponse === undefined
|
|
506
|
+
? { kind: "duplicate", inFlight: verdict.inFlight }
|
|
507
|
+
: { kind: "duplicate", priorResponse: verdict.priorResponse, inFlight: verdict.inFlight };
|
|
508
|
+
case "conflict":
|
|
509
|
+
return route({
|
|
510
|
+
code: "idConflict",
|
|
511
|
+
message: ID_CONFLICT_WIRE_MESSAGE,
|
|
512
|
+
retryable: false,
|
|
513
|
+
});
|
|
514
|
+
case "fresh":
|
|
515
|
+
claim = { guard, digest };
|
|
516
|
+
break;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
320
520
|
const result = await handler(doc, resolved.parties);
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
521
|
+
|
|
522
|
+
if (result === undefined || result === null) {
|
|
523
|
+
// Fire-and-forget: nothing to cache, but the claim stands — the effect
|
|
524
|
+
// happened, and item 11 is about the effect, not about the response.
|
|
525
|
+
await settle(claim, doc.id, undefined);
|
|
526
|
+
return { kind: "accepted" };
|
|
527
|
+
}
|
|
528
|
+
if (isErrorResponse(result)) {
|
|
529
|
+
// §8.4: a retryable refusal has just invited the producer to re-send this
|
|
530
|
+
// document bit-for-bit. Holding the claim would answer that invited retry
|
|
531
|
+
// with the cached failure forever. A non-retryable refusal is final, so
|
|
532
|
+
// the record stands and a replay is answered with the same determination.
|
|
533
|
+
if (result.payload.retryable === true) {
|
|
534
|
+
await claim?.guard.release?.(doc.id, claim.digest);
|
|
535
|
+
} else {
|
|
536
|
+
await settle(claim, doc.id, result);
|
|
537
|
+
}
|
|
538
|
+
return { kind: "rejected", error: result };
|
|
539
|
+
}
|
|
540
|
+
await settle(claim, doc.id, result);
|
|
541
|
+
return { kind: "handled", response: result as TrustTaskDocument<R> };
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Record the response for a completed execution, best-effort.
|
|
546
|
+
*
|
|
547
|
+
* The effect has already happened, so a guard that cannot cache the response
|
|
548
|
+
* cannot un-happen it. The record of the *claim* is what item 11 needs and it
|
|
549
|
+
* is already written; all that is lost is the ability to hand the same
|
|
550
|
+
* response back, and the duplicate is still absorbed. Guards should log their
|
|
551
|
+
* own failures — there is nowhere to report this from here.
|
|
552
|
+
*/
|
|
553
|
+
async function settle(
|
|
554
|
+
claim: { guard: ReplayGuard; digest: string } | undefined,
|
|
555
|
+
id: string,
|
|
556
|
+
response: unknown,
|
|
557
|
+
): Promise<void> {
|
|
558
|
+
if (claim === undefined) return;
|
|
559
|
+
try {
|
|
560
|
+
await claim.guard.recordResponse?.(id, response);
|
|
561
|
+
} catch {
|
|
562
|
+
/* see above */
|
|
563
|
+
}
|
|
325
564
|
}
|
|
326
565
|
|
|
566
|
+
/**
|
|
567
|
+
* Wire message for `proofInvalid`.
|
|
568
|
+
*
|
|
569
|
+
* Constant by design — see the §10.4 note at the rejection site. The Rust
|
|
570
|
+
* counterpart is `PROOF_INVALID_WIRE_MESSAGE` in `trust-tasks-rs`, kept equal.
|
|
571
|
+
*/
|
|
572
|
+
export const PROOF_INVALID_WIRE_MESSAGE = "proof verification failed";
|
|
573
|
+
|
|
574
|
+
/** Wire message for `idConflict` (SPEC §7.2 item 11, §8.3). */
|
|
575
|
+
export const ID_CONFLICT_WIRE_MESSAGE =
|
|
576
|
+
"a different document has already been accepted under this id (SPEC §7.2 item 11)";
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Wire message for a replay-record outage. A constant, so a store's hostname
|
|
580
|
+
* or connection string never reaches the wire (SPEC §10.4).
|
|
581
|
+
*/
|
|
582
|
+
export const REPLAY_RECORD_UNAVAILABLE = "temporarily unavailable";
|
|
583
|
+
|
|
327
584
|
/**
|
|
328
585
|
* Whether a handler returned an error response rather than a success response.
|
|
329
586
|
*
|
package/src/_runtime/document.ts
CHANGED
|
@@ -195,10 +195,23 @@ export interface RejectReason {
|
|
|
195
195
|
/**
|
|
196
196
|
* The Type URI a consumer emits error responses under.
|
|
197
197
|
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
198
|
+
* **The single source of truth for the emitted `trust-task-error` version on
|
|
199
|
+
* this side.** Three different answers were in circulation — this runtime and
|
|
200
|
+
* `trust-tasks-rs` emitted `0.5`, the HTTPS server emitted `0.2`, and the
|
|
201
|
+
* READMEs described `0.1` — so a producer could not tell from the
|
|
202
|
+
* documentation which schema an error response would validate against. Build
|
|
203
|
+
* error responses with `rejectWith` / `rejectWithRecipient`, or read the URI
|
|
204
|
+
* from here; do not spell it out again.
|
|
205
|
+
*
|
|
206
|
+
* The Rust counterpart is `trust_task_error_type_uri()` in `trust-tasks-rs`,
|
|
207
|
+
* kept equal to this value.
|
|
208
|
+
*
|
|
209
|
+
* `0.5` because this runtime populates the `inResponseTo` member of §8.2 and
|
|
210
|
+
* can emit `idConflict` (§8.3), which is absent from `0.3`'s code enum and does
|
|
211
|
+
* not match its extended-code pattern — a document carrying it would not
|
|
212
|
+
* validate as `0.3`. Per §5.2 forward-minor compatibility a `0.3` consumer
|
|
213
|
+
* SHOULD accept it. (This comment said `0.3` while the value said `0.5`: the
|
|
214
|
+
* value moved and the prose did not.)
|
|
202
215
|
*/
|
|
203
216
|
export const TRUST_TASK_ERROR_TYPE_URI = "https://trusttasks.org/spec/trust-task-error/0.5";
|
|
204
217
|
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Freshness bounds over `issuedAt` / `expiresAt` (SPEC §4.2, §7.2).
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `freshness.rs` in trust-tasks-rs, deliberately closely: a TypeScript
|
|
5
|
+
* consumer and a Rust one must reach the same verdict on the same document.
|
|
6
|
+
*
|
|
7
|
+
* Until this module, the only temporal check either runtime made was §7.2
|
|
8
|
+
* item 4 — `expiresAt`, and only where the producer chose to set it.
|
|
9
|
+
* `issuedAt` was carried on the type and looked at by nobody. That accepted a
|
|
10
|
+
* document stamped a year in the future (for the whole of that year), and one
|
|
11
|
+
* whose `expiresAt` sat at or before its own `issuedAt` — a validity interval
|
|
12
|
+
* that never contained a valid instant.
|
|
13
|
+
*
|
|
14
|
+
* It is also what makes {@link ReplayGuard} implementable. SPEC §7.2
|
|
15
|
+
* (*Bounding the record*) ties the duplicate-execution record to the
|
|
16
|
+
* acceptance window and says the two bounds are the same bound: a consumer
|
|
17
|
+
* "**MUST NOT** accept for execution a document older than the window over
|
|
18
|
+
* which it retains records". Without {@link FreshnessPolicy.maxAgeMs} there is
|
|
19
|
+
* no window, so a record for a document carrying no `expiresAt` would have to
|
|
20
|
+
* be kept forever.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { RejectReason, TrustTaskDocument } from "./document.js";
|
|
24
|
+
|
|
25
|
+
/** The clock-skew tolerance SPEC §4.2 sanctions ("typically ≤ 60s"), in ms. */
|
|
26
|
+
export const DEFAULT_SKEW_MS = 60_000;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The acceptance window {@link consequentialFreshness} applies, in ms.
|
|
30
|
+
*
|
|
31
|
+
* Five minutes survives a mediator queue, a retry with backoff and a modest
|
|
32
|
+
* clock disagreement, and keeps the record it bounds small. A deployment whose
|
|
33
|
+
* transport buffers for longer must widen it *and* widen its guard's retention
|
|
34
|
+
* to match — §7.2 makes them one bound.
|
|
35
|
+
*/
|
|
36
|
+
export const DEFAULT_MAX_AGE_MS = 5 * 60_000;
|
|
37
|
+
|
|
38
|
+
/** How a consumer bounds a document in time before acting on it. */
|
|
39
|
+
export interface FreshnessPolicy {
|
|
40
|
+
/**
|
|
41
|
+
* Tolerance applied to the document's timestamps against this consumer's
|
|
42
|
+
* clock, per SPEC §4.2. Applied to the future-dating check and to
|
|
43
|
+
* {@link maxAgeMs}; **not** to `expiresAt`, which `validateBasic` compares
|
|
44
|
+
* against the raw `now` it is given.
|
|
45
|
+
*/
|
|
46
|
+
readonly skewMs: number;
|
|
47
|
+
/**
|
|
48
|
+
* Oldest `issuedAt` this consumer accepts, measured back from `now`.
|
|
49
|
+
* `undefined` means unbounded — the pre-existing behaviour, and the only
|
|
50
|
+
* setting under which a document carrying neither timestamp is acceptable.
|
|
51
|
+
*/
|
|
52
|
+
readonly maxAgeMs?: number;
|
|
53
|
+
/** Reject a document carrying no `issuedAt`, with `malformedRequest`. */
|
|
54
|
+
readonly requireIssuedAt?: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The minimum every consumer should apply: reject a future-dated document and
|
|
59
|
+
* one whose stated validity interval is empty. No conforming producer emits
|
|
60
|
+
* either, so this costs a correct deployment nothing.
|
|
61
|
+
*
|
|
62
|
+
* Deliberately sets no `maxAgeMs` — an acceptance window depends on how long
|
|
63
|
+
* the transport may hold a message, which is a deployment fact, and a library
|
|
64
|
+
* that guessed one would start refusing documents that had arrived for years.
|
|
65
|
+
*/
|
|
66
|
+
export const DEFAULT_FRESHNESS: FreshnessPolicy = { skewMs: DEFAULT_SKEW_MS };
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The posture SPEC §7.2 (*Bounding the record*) describes for a *consequential
|
|
70
|
+
* Trust Task*: `issuedAt` REQUIRED and a bounded acceptance window, so every
|
|
71
|
+
* accepted document sits inside a window a {@link ReplayGuard} can retain a
|
|
72
|
+
* record for.
|
|
73
|
+
*/
|
|
74
|
+
export const CONSEQUENTIAL_FRESHNESS: FreshnessPolicy = {
|
|
75
|
+
skewMs: DEFAULT_SKEW_MS,
|
|
76
|
+
maxAgeMs: DEFAULT_MAX_AGE_MS,
|
|
77
|
+
requireIssuedAt: true,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** Wire-safe reason for a `malformedRequest` from a future-dated `issuedAt`. */
|
|
81
|
+
export const FUTURE_ISSUED_AT =
|
|
82
|
+
"issuedAt is in the future beyond the consumer's skew tolerance (SPEC §4.2)";
|
|
83
|
+
|
|
84
|
+
/** Wire-safe reason for a `malformedRequest` from `expiresAt <= issuedAt`. */
|
|
85
|
+
export const EXPIRY_NOT_AFTER_ISSUANCE =
|
|
86
|
+
"expiresAt is not after issuedAt: the document states an empty validity interval (SPEC §4.2)";
|
|
87
|
+
|
|
88
|
+
/** Wire-safe reason for a `malformedRequest` from a missing `issuedAt`. */
|
|
89
|
+
export const ISSUED_AT_REQUIRED =
|
|
90
|
+
"issuedAt is required by consumer policy (SPEC §7.2, bounding the duplicate-execution record)";
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Wire message for a document outside the consumer's acceptance window.
|
|
94
|
+
*
|
|
95
|
+
* A constant, not a rendering of the window or the consumer's clock: §10.4
|
|
96
|
+
* keeps consumer-side state off the wire, and echoing the delta would turn
|
|
97
|
+
* every rejection into a remote `ntpdate` — and a probe for the window's exact
|
|
98
|
+
* boundary — for an unauthenticated sender.
|
|
99
|
+
*/
|
|
100
|
+
export const STALE_WIRE_MESSAGE = "document is outside the consumer's acceptance window (SPEC §7.2)";
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Apply `policy` to this document's `issuedAt` / `expiresAt`. Returns `null`
|
|
104
|
+
* when the document is acceptable.
|
|
105
|
+
*
|
|
106
|
+
* This is the freshness half of SPEC §7.2 item 4 that `validateBasic` does not
|
|
107
|
+
* cover. {@link consumeInbound} calls it for you.
|
|
108
|
+
*
|
|
109
|
+
* @param now Milliseconds since the epoch, as from `Date.now()`.
|
|
110
|
+
*/
|
|
111
|
+
export function validateFreshness<P>(
|
|
112
|
+
doc: TrustTaskDocument<P>,
|
|
113
|
+
now: number,
|
|
114
|
+
policy: FreshnessPolicy,
|
|
115
|
+
): RejectReason | null {
|
|
116
|
+
const malformed = (message: string): RejectReason => ({
|
|
117
|
+
code: "malformedRequest",
|
|
118
|
+
message,
|
|
119
|
+
retryable: false,
|
|
120
|
+
});
|
|
121
|
+
const stale = (): RejectReason => ({
|
|
122
|
+
code: "expired",
|
|
123
|
+
message: STALE_WIRE_MESSAGE,
|
|
124
|
+
retryable: false,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
if (doc.issuedAt !== undefined) {
|
|
128
|
+
const issuedAt = Date.parse(doc.issuedAt);
|
|
129
|
+
if (Number.isNaN(issuedAt)) {
|
|
130
|
+
return malformed("issuedAt is not a valid RFC 3339 timestamp");
|
|
131
|
+
}
|
|
132
|
+
if (issuedAt > now + policy.skewMs) return malformed(FUTURE_ISSUED_AT);
|
|
133
|
+
|
|
134
|
+
if (doc.expiresAt !== undefined) {
|
|
135
|
+
const expiresAt = Date.parse(doc.expiresAt);
|
|
136
|
+
// A malformed `expiresAt` is `validateBasic`'s to report; skip it here
|
|
137
|
+
// rather than raise a second, differently-worded rejection for it.
|
|
138
|
+
if (!Number.isNaN(expiresAt) && expiresAt <= issuedAt) {
|
|
139
|
+
return malformed(EXPIRY_NOT_AFTER_ISSUANCE);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (policy.maxAgeMs !== undefined && issuedAt + policy.maxAgeMs + policy.skewMs < now) {
|
|
144
|
+
return stale();
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (policy.requireIssuedAt === true) return malformed(ISSUED_AT_REQUIRED);
|
|
150
|
+
|
|
151
|
+
// No `issuedAt`. A policy with a window cannot place the document in it
|
|
152
|
+
// unless the producer supplied an `expiresAt` instead (SPEC §7.2, *Bounding
|
|
153
|
+
// the record*).
|
|
154
|
+
if (policy.maxAgeMs !== undefined && doc.expiresAt === undefined) return stale();
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The instant past which a replay record for `doc` may be dropped — the end of
|
|
160
|
+
* this consumer's willingness to execute it, which SPEC §7.2 makes the same
|
|
161
|
+
* instant as the end of the record's required retention.
|
|
162
|
+
*
|
|
163
|
+
* `expiresAt` fixes it where present; otherwise `issuedAt + maxAgeMs`.
|
|
164
|
+
* `undefined` means this policy places no bound on the document, in which case
|
|
165
|
+
* a consumer **MUST NOT** execute a consequential task on it — there is no
|
|
166
|
+
* window in which to keep the record.
|
|
167
|
+
*/
|
|
168
|
+
export function recordExpiry<P>(
|
|
169
|
+
doc: TrustTaskDocument<P>,
|
|
170
|
+
policy: FreshnessPolicy,
|
|
171
|
+
now: number,
|
|
172
|
+
): number | undefined {
|
|
173
|
+
if (doc.expiresAt !== undefined) {
|
|
174
|
+
const expiresAt = Date.parse(doc.expiresAt);
|
|
175
|
+
if (!Number.isNaN(expiresAt)) return expiresAt;
|
|
176
|
+
}
|
|
177
|
+
if (policy.maxAgeMs === undefined) return undefined;
|
|
178
|
+
const issuedAt = doc.issuedAt === undefined ? NaN : Date.parse(doc.issuedAt);
|
|
179
|
+
// Fall back to `now` when the producer stamped no usable `issuedAt`: the
|
|
180
|
+
// record then lives a full window from first sight, which is the longest the
|
|
181
|
+
// document could still be arriving from a queue.
|
|
182
|
+
return (Number.isNaN(issuedAt) ? now : issuedAt) + policy.maxAgeMs;
|
|
183
|
+
}
|
package/src/_runtime/index.ts
CHANGED
|
@@ -51,9 +51,15 @@ export {
|
|
|
51
51
|
} from "./transport.js";
|
|
52
52
|
|
|
53
53
|
export {
|
|
54
|
+
ID_CONFLICT_WIRE_MESSAGE,
|
|
55
|
+
PROOF_INVALID_WIRE_MESSAGE,
|
|
54
56
|
PROOF_NOT_ACCEPTED_BY_POLICY,
|
|
57
|
+
REPLAY_RECORD_UNAVAILABLE,
|
|
58
|
+
consequentialChecks,
|
|
55
59
|
consumeInbound,
|
|
60
|
+
notConsequentialChecks,
|
|
56
61
|
refuse,
|
|
62
|
+
type ConsumeChecks,
|
|
57
63
|
type ConsumeOptions,
|
|
58
64
|
type ConsumeOutcome,
|
|
59
65
|
type PayloadPolicy,
|
|
@@ -61,3 +67,27 @@ export {
|
|
|
61
67
|
type ProofPolicy,
|
|
62
68
|
type ProofVerifier,
|
|
63
69
|
} from "./consume.js";
|
|
70
|
+
|
|
71
|
+
export { canonicalJson, sha256Hex } from "./canonical.js";
|
|
72
|
+
|
|
73
|
+
export {
|
|
74
|
+
CONSEQUENTIAL_FRESHNESS,
|
|
75
|
+
DEFAULT_FRESHNESS,
|
|
76
|
+
DEFAULT_MAX_AGE_MS,
|
|
77
|
+
DEFAULT_SKEW_MS,
|
|
78
|
+
EXPIRY_NOT_AFTER_ISSUANCE,
|
|
79
|
+
FUTURE_ISSUED_AT,
|
|
80
|
+
ISSUED_AT_REQUIRED,
|
|
81
|
+
STALE_WIRE_MESSAGE,
|
|
82
|
+
recordExpiry,
|
|
83
|
+
validateFreshness,
|
|
84
|
+
type FreshnessPolicy,
|
|
85
|
+
} from "./freshness.js";
|
|
86
|
+
|
|
87
|
+
export {
|
|
88
|
+
InMemoryReplayGuard,
|
|
89
|
+
documentDigest,
|
|
90
|
+
type ReplayGuard,
|
|
91
|
+
type ReplayPolicy,
|
|
92
|
+
type ReplayVerdict,
|
|
93
|
+
} from "./replay.js";
|