@forestrie/receipt-verify 0.8.2 → 0.10.1

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/dist/index.d.ts CHANGED
@@ -24,8 +24,8 @@ export { verifyReceiptOffline } from "./verify-grant-receipt-offline.js";
24
24
  */
25
25
  export { verifyGrantReceiptOfflineWithKeys, verifyReceiptOfflineWithKeys, } from "./verify-grant-receipt-offline.js";
26
26
  export type { VerifyGrantReceiptOfflineWithKeysInput, VerifyReceiptOfflineWithKeysInput, } from "./verify-grant-receipt-offline.js";
27
- export { resolveDelegatedVerifyKey } from "./resolve-delegated-verify-key.js";
28
- export type { DelegatedResolution } from "./resolve-delegated-verify-key.js";
27
+ export { checkDelegationConstraints, idtimestampToUnixSeconds, resolveDelegatedVerifyKey, } from "./resolve-delegated-verify-key.js";
28
+ export type { DelegatedResolution, DelegationConstraints, } from "./resolve-delegated-verify-key.js";
29
29
  /** Import a raw 64-byte x||y P-256 public key as an ES256 verify key. */
30
30
  export { importEs256PublicKeyFromGrantDataXy64 } from "./decode-trust-root-cbor.js";
31
31
  export { decodeForestrieGrantCose } from "./decode-forestrie-grant-cose.js";
@@ -42,4 +42,19 @@ export type { LocatedLeaf } from "./find-grant-leaf.js";
42
42
  /** Re-exported so callers of findGrantLeafInMassif can catch it (FOR-344). */
43
43
  export { MissingIndexError } from "@forestrie/merklelog";
44
44
  export { accumulatorPayload, checkpointConsistencyProof, computeCheckpointAccumulator, verifyCheckpointChain, type CheckpointChainLink, type CheckpointChainResult, type CheckpointConsistencyProof, } from "./checkpoint-chain.js";
45
+ /**
46
+ * Univocity leaf commitment hash. Was CLI-private (forestrie-cli's own
47
+ * mirror, "hoist to the library when the FOR-297 multi-hop resolver lands");
48
+ * exported here as of plan-2607-34 slice 02 Part B so consumers stop
49
+ * duplicating it.
50
+ */
51
+ export { univocityLeafHash } from "./leaf-commitment.js";
52
+ /**
53
+ * Known-accumulator snapshot (FOR-297 D5): verify a receipt against a
54
+ * caller-supplied on-chain accumulator read, fully offline. Hoisted from
55
+ * forestrie-cli (plan-2607-34 slice 02 Part B) — see known-accumulator.ts
56
+ * for the trust model and what this package does NOT do (fetch the
57
+ * snapshot over RPC, or proof-path-extend a stale one via massif nodes).
58
+ */
59
+ export { assertSnapshotBinding, decodeKnownAccumulator, encodeKnownAccumulator, verifyReceiptOfflineAgainstKnownAccumulator, type KnownAccumulator, } from "./known-accumulator.js";
45
60
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EACV,mBAAmB,EACnB,kBAAkB,GACnB,MAAM,4BAA4B,CAAC;AACpC,oFAAoF;AACpF,YAAY,EAAE,KAAK,EAAE,MAAM,qBAAqB,CAAC;AACjD,YAAY,EAAE,8BAA8B,EAAE,MAAM,mCAAmC,CAAC;AACxF,YAAY,EAAE,yBAAyB,EAAE,MAAM,mCAAmC,CAAC;AACnF,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAClD,YAAY,EACV,wBAAwB,EACxB,uBAAuB,EACvB,eAAe,EACf,gBAAgB,GACjB,MAAM,4BAA4B,CAAC;AACpC,OAAO,EACL,wBAAwB,EACxB,mBAAmB,EACnB,sBAAsB,EACtB,mBAAmB,EACnB,eAAe,GAChB,MAAM,4BAA4B,CAAC;AACpC,+EAA+E;AAC/E,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACtD,YAAY,EACV,mBAAmB,EACnB,oBAAoB,GACrB,MAAM,sBAAsB,CAAC;AAC9B;;;GAGG;AACH,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACtD,OAAO,EAAE,0BAA0B,EAAE,MAAM,qCAAqC,CAAC;AACjF,OAAO,EAAE,yBAAyB,EAAE,MAAM,mCAAmC,CAAC;AAC9E,OAAO,EAAE,oBAAoB,EAAE,MAAM,mCAAmC,CAAC;AACzE;;;;GAIG;AACH,OAAO,EACL,iCAAiC,EACjC,4BAA4B,GAC7B,MAAM,mCAAmC,CAAC;AAC3C,YAAY,EACV,sCAAsC,EACtC,iCAAiC,GAClC,MAAM,mCAAmC,CAAC;AAC3C,OAAO,EAAE,yBAAyB,EAAE,MAAM,mCAAmC,CAAC;AAC9E,YAAY,EAAE,mBAAmB,EAAE,MAAM,mCAAmC,CAAC;AAC7E,yEAAyE;AACzE,OAAO,EAAE,qCAAqC,EAAE,MAAM,6BAA6B,CAAC;AACpF,OAAO,EAAE,wBAAwB,EAAE,MAAM,kCAAkC,CAAC;AAC5E,OAAO,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,MAAM,kBAAkB,CAAC;AAE3E,0EAA0E;AAC1E,OAAO,EACL,cAAc,EACd,iDAAiD,GAClD,MAAM,2CAA2C,CAAC;AACnD,OAAO,EAAE,gBAAgB,EAAE,0BAA0B,EAAE,MAAM,eAAe,CAAC;AAC7E,YAAY,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAEpD,6EAA6E;AAC7E,OAAO,EAAE,4BAA4B,EAAE,MAAM,uBAAuB,CAAC;AACrE,oEAAoE;AACpE,OAAO,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAC;AAC7D,YAAY,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AACxD,8EAA8E;AAC9E,OAAO,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AACzD,OAAO,EACL,kBAAkB,EAClB,0BAA0B,EAC1B,4BAA4B,EAC5B,qBAAqB,EACrB,KAAK,mBAAmB,EACxB,KAAK,qBAAqB,EAC1B,KAAK,0BAA0B,GAChC,MAAM,uBAAuB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EACV,mBAAmB,EACnB,kBAAkB,GACnB,MAAM,4BAA4B,CAAC;AACpC,oFAAoF;AACpF,YAAY,EAAE,KAAK,EAAE,MAAM,qBAAqB,CAAC;AACjD,YAAY,EAAE,8BAA8B,EAAE,MAAM,mCAAmC,CAAC;AACxF,YAAY,EAAE,yBAAyB,EAAE,MAAM,mCAAmC,CAAC;AACnF,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAClD,YAAY,EACV,wBAAwB,EACxB,uBAAuB,EACvB,eAAe,EACf,gBAAgB,GACjB,MAAM,4BAA4B,CAAC;AACpC,OAAO,EACL,wBAAwB,EACxB,mBAAmB,EACnB,sBAAsB,EACtB,mBAAmB,EACnB,eAAe,GAChB,MAAM,4BAA4B,CAAC;AACpC,+EAA+E;AAC/E,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACtD,YAAY,EACV,mBAAmB,EACnB,oBAAoB,GACrB,MAAM,sBAAsB,CAAC;AAC9B;;;GAGG;AACH,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACtD,OAAO,EAAE,0BAA0B,EAAE,MAAM,qCAAqC,CAAC;AACjF,OAAO,EAAE,yBAAyB,EAAE,MAAM,mCAAmC,CAAC;AAC9E,OAAO,EAAE,oBAAoB,EAAE,MAAM,mCAAmC,CAAC;AACzE;;;;GAIG;AACH,OAAO,EACL,iCAAiC,EACjC,4BAA4B,GAC7B,MAAM,mCAAmC,CAAC;AAC3C,YAAY,EACV,sCAAsC,EACtC,iCAAiC,GAClC,MAAM,mCAAmC,CAAC;AAC3C,OAAO,EACL,0BAA0B,EAC1B,wBAAwB,EACxB,yBAAyB,GAC1B,MAAM,mCAAmC,CAAC;AAC3C,YAAY,EACV,mBAAmB,EACnB,qBAAqB,GACtB,MAAM,mCAAmC,CAAC;AAC3C,yEAAyE;AACzE,OAAO,EAAE,qCAAqC,EAAE,MAAM,6BAA6B,CAAC;AACpF,OAAO,EAAE,wBAAwB,EAAE,MAAM,kCAAkC,CAAC;AAC5E,OAAO,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,MAAM,kBAAkB,CAAC;AAE3E,0EAA0E;AAC1E,OAAO,EACL,cAAc,EACd,iDAAiD,GAClD,MAAM,2CAA2C,CAAC;AACnD,OAAO,EAAE,gBAAgB,EAAE,0BAA0B,EAAE,MAAM,eAAe,CAAC;AAC7E,YAAY,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAEpD,6EAA6E;AAC7E,OAAO,EAAE,4BAA4B,EAAE,MAAM,uBAAuB,CAAC;AACrE,oEAAoE;AACpE,OAAO,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAC;AAC7D,YAAY,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AACxD,8EAA8E;AAC9E,OAAO,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AACzD,OAAO,EACL,kBAAkB,EAClB,0BAA0B,EAC1B,4BAA4B,EAC5B,qBAAqB,EACrB,KAAK,mBAAmB,EACxB,KAAK,qBAAqB,EAC1B,KAAK,0BAA0B,GAChC,MAAM,uBAAuB,CAAC;AAC/B;;;;;GAKG;AACH,OAAO,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AACzD;;;;;;GAMG;AACH,OAAO,EACL,qBAAqB,EACrB,sBAAsB,EACtB,sBAAsB,EACtB,2CAA2C,EAC3C,KAAK,gBAAgB,GACtB,MAAM,wBAAwB,CAAC"}
package/dist/index.js CHANGED
@@ -16,7 +16,7 @@ export { verifyReceiptOffline } from "./verify-grant-receipt-offline.js";
16
16
  * See the trust-ladder notes on verifyReceiptOfflineWithKeys.
17
17
  */
18
18
  export { verifyGrantReceiptOfflineWithKeys, verifyReceiptOfflineWithKeys, } from "./verify-grant-receipt-offline.js";
19
- export { resolveDelegatedVerifyKey } from "./resolve-delegated-verify-key.js";
19
+ export { checkDelegationConstraints, idtimestampToUnixSeconds, resolveDelegatedVerifyKey, } from "./resolve-delegated-verify-key.js";
20
20
  /** Import a raw 64-byte x||y P-256 public key as an ES256 verify key. */
21
21
  export { importEs256PublicKeyFromGrantDataXy64 } from "./decode-trust-root-cbor.js";
22
22
  export { decodeForestrieGrantCose } from "./decode-forestrie-grant-cose.js";
@@ -31,3 +31,18 @@ export { findGrantLeafInMassif } from "./find-grant-leaf.js";
31
31
  /** Re-exported so callers of findGrantLeafInMassif can catch it (FOR-344). */
32
32
  export { MissingIndexError } from "@forestrie/merklelog";
33
33
  export { accumulatorPayload, checkpointConsistencyProof, computeCheckpointAccumulator, verifyCheckpointChain, } from "./checkpoint-chain.js";
34
+ /**
35
+ * Univocity leaf commitment hash. Was CLI-private (forestrie-cli's own
36
+ * mirror, "hoist to the library when the FOR-297 multi-hop resolver lands");
37
+ * exported here as of plan-2607-34 slice 02 Part B so consumers stop
38
+ * duplicating it.
39
+ */
40
+ export { univocityLeafHash } from "./leaf-commitment.js";
41
+ /**
42
+ * Known-accumulator snapshot (FOR-297 D5): verify a receipt against a
43
+ * caller-supplied on-chain accumulator read, fully offline. Hoisted from
44
+ * forestrie-cli (plan-2607-34 slice 02 Part B) — see known-accumulator.ts
45
+ * for the trust model and what this package does NOT do (fetch the
46
+ * snapshot over RPC, or proof-path-extend a stale one via massif nodes).
47
+ */
48
+ export { assertSnapshotBinding, decodeKnownAccumulator, encodeKnownAccumulator, verifyReceiptOfflineAgainstKnownAccumulator, } from "./known-accumulator.js";
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Known-accumulator snapshot (FOR-297 D5): a cached, auditable chain read of
3
+ * a Univocity log's `logState`, letting chain-anchored verification run
4
+ * fully offline. Hoisted from `forestrie-cli/src/lib/verify-known-accumulator.ts`
5
+ * (plan-2607-34 slice 02 Part B) so the CLI and any other consumer — e2e
6
+ * harnesses, a future browser relying party — share one implementation
7
+ * instead of duplicating this crypto per caller.
8
+ *
9
+ * Trust model (freshness / split-view): `--rpc-url` was never trust-free —
10
+ * the RPC provider is itself a trusted chain reader. The snapshot makes that
11
+ * trust explicit, portable, and cacheable. It binds `(chainId, univocity,
12
+ * logId, size, block)` so anyone with RPC can re-run the read at that block
13
+ * and confirm or disprove it — auditable, falsifiable trust, unlike a bare
14
+ * known key.
15
+ *
16
+ * Staleness limits coverage, never validity: the contract's consistency
17
+ * gating makes every anchored state a committed prefix of every later one,
18
+ * so a peak match at snapshot size N proves inclusion at N and forever
19
+ * after. Entries newer than the snapshot fail closed with a refresh hint.
20
+ *
21
+ * This module is pure — no network, no `node:*` imports (browser-safety
22
+ * enforced by `tools/check-browser-safe.mjs`). Fetching the snapshot itself
23
+ * over RPC is the caller's concern (forestrie-cli `fetch-accumulator`, or an
24
+ * equivalent reader — see `verifyReceiptOfflineAgainstKnownAccumulator`'s
25
+ * doc for why that stays out of this package).
26
+ *
27
+ * NEVER source the snapshot unauthenticated from the same store as the tiles
28
+ * (the log operator's massif/checkpoint store) — that silently re-internalises
29
+ * the operator trust this anchor exists to remove.
30
+ */
31
+ import type { ReceiptVerifyResult } from "./receipt-verify-result.js";
32
+ export type KnownAccumulator = {
33
+ version: number;
34
+ chainId: bigint;
35
+ /** Univocity contract address (20 bytes). */
36
+ univocity: Uint8Array;
37
+ /** Contract log id (32 bytes, UUID zero-padded on the left). */
38
+ logId: Uint8Array;
39
+ /** Anchored MMR size at the snapshot block. */
40
+ size: bigint;
41
+ /** Anchored accumulator peaks (32 bytes each), contract order. */
42
+ accumulator: Uint8Array[];
43
+ blockNumber: bigint;
44
+ /** Block hash of the read (32 bytes) — the falsifiability handle. */
45
+ blockHash: Uint8Array;
46
+ };
47
+ /** Encode a snapshot as canonical CBOR (RFC 8949 §4.2 — hard policy). */
48
+ export declare function encodeKnownAccumulator(snapshot: KnownAccumulator): Uint8Array;
49
+ /** Strict decode + shape validation of a snapshot artifact. */
50
+ export declare function decodeKnownAccumulator(bytes: Uint8Array): KnownAccumulator;
51
+ /**
52
+ * Reject a snapshot whose binding does not match the caller's stated target
53
+ * BEFORE any peak math — a snapshot for the wrong log or contract must never
54
+ * be silently accepted as an anchor. `univocity`/`logId` are raw
55
+ * caller-supplied strings (a `0x`-address, a UUID, or hex id) — normalized
56
+ * here so callers don't have to.
57
+ */
58
+ export declare function assertSnapshotBinding(snapshot: KnownAccumulator, opts: {
59
+ univocity?: string | undefined;
60
+ logId?: string | undefined;
61
+ }): void;
62
+ /**
63
+ * Offline check: is the receipt's peak one of the known-accumulator's
64
+ * anchored peaks? Covers the two network-free cases from
65
+ * `forestrie-cli`'s `checkReceiptAnchoredToSnapshot`:
66
+ *
67
+ * 1. Fail closed when the receipt's leaf postdates the snapshot (refresh).
68
+ * 2. Exact peak match: the recomputed receipt peak is still a snapshot peak.
69
+ *
70
+ * Proof-path extension for stale snapshots (case 3 — needs a local massif
71
+ * blob) stays CLI-only for now; it is a bigger, file-shaped input this
72
+ * package's pure-bytes API doesn't take today. A receipt whose snapshot
73
+ * covers it but isn't an exact peak returns `peak_not_in_known_accumulator`
74
+ * — honest about not having tried extension, not a false negative dressed
75
+ * up as one.
76
+ *
77
+ * The live RPC read that produces `accumulator`/`size` stays the caller's
78
+ * concern (this package is pure — no network, browser-safety enforced by
79
+ * `tools/check-browser-safe.mjs`); see `system-testing/src/onchain-logstate.ts`
80
+ * or `forestrie-cli fetch-accumulator` for two independent, viem-free
81
+ * readers (`fetch()` + manual ABI decode of `logState(bytes32)`).
82
+ */
83
+ export declare function verifyReceiptOfflineAgainstKnownAccumulator(input: {
84
+ receiptCbor: Uint8Array;
85
+ idtimestampBe8: Uint8Array;
86
+ /** Leaf ContentHash: SHA-256(payload) or the grant commitment hash. */
87
+ inner: Uint8Array;
88
+ /** Trusted accumulator peaks at `size`, contract order. */
89
+ accumulator: Uint8Array[];
90
+ /** Anchored MMR size at the snapshot. */
91
+ size: bigint;
92
+ }): Promise<ReceiptVerifyResult>;
93
+ //# sourceMappingURL=known-accumulator.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"known-accumulator.d.ts","sourceRoot":"","sources":["../src/known-accumulator.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAWH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AAsCtE,MAAM,MAAM,gBAAgB,GAAG;IAC7B,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,6CAA6C;IAC7C,SAAS,EAAE,UAAU,CAAC;IACtB,gEAAgE;IAChE,KAAK,EAAE,UAAU,CAAC;IAClB,+CAA+C;IAC/C,IAAI,EAAE,MAAM,CAAC;IACb,kEAAkE;IAClE,WAAW,EAAE,UAAU,EAAE,CAAC;IAC1B,WAAW,EAAE,MAAM,CAAC;IACpB,qEAAqE;IACrE,SAAS,EAAE,UAAU,CAAC;CACvB,CAAC;AAEF,yEAAyE;AACzE,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,gBAAgB,GAAG,UAAU,CAa7E;AAgBD,+DAA+D;AAC/D,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,UAAU,GAAG,gBAAgB,CAgC1E;AAMD;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CACnC,QAAQ,EAAE,gBAAgB,EAC1B,IAAI,EAAE;IAAE,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;CAAE,GACnE,IAAI,CAiBN;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAsB,2CAA2C,CAAC,KAAK,EAAE;IACvE,WAAW,EAAE,UAAU,CAAC;IACxB,cAAc,EAAE,UAAU,CAAC;IAC3B,uEAAuE;IACvE,KAAK,EAAE,UAAU,CAAC;IAClB,2DAA2D;IAC3D,WAAW,EAAE,UAAU,EAAE,CAAC;IAC1B,yCAAyC;IACzC,IAAI,EAAE,MAAM,CAAC;CACd,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAiD/B"}
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Known-accumulator snapshot (FOR-297 D5): a cached, auditable chain read of
3
+ * a Univocity log's `logState`, letting chain-anchored verification run
4
+ * fully offline. Hoisted from `forestrie-cli/src/lib/verify-known-accumulator.ts`
5
+ * (plan-2607-34 slice 02 Part B) so the CLI and any other consumer — e2e
6
+ * harnesses, a future browser relying party — share one implementation
7
+ * instead of duplicating this crypto per caller.
8
+ *
9
+ * Trust model (freshness / split-view): `--rpc-url` was never trust-free —
10
+ * the RPC provider is itself a trusted chain reader. The snapshot makes that
11
+ * trust explicit, portable, and cacheable. It binds `(chainId, univocity,
12
+ * logId, size, block)` so anyone with RPC can re-run the read at that block
13
+ * and confirm or disprove it — auditable, falsifiable trust, unlike a bare
14
+ * known key.
15
+ *
16
+ * Staleness limits coverage, never validity: the contract's consistency
17
+ * gating makes every anchored state a committed prefix of every later one,
18
+ * so a peak match at snapshot size N proves inclusion at N and forever
19
+ * after. Entries newer than the snapshot fail closed with a refresh hint.
20
+ *
21
+ * This module is pure — no network, no `node:*` imports (browser-safety
22
+ * enforced by `tools/check-browser-safe.mjs`). Fetching the snapshot itself
23
+ * over RPC is the caller's concern (forestrie-cli `fetch-accumulator`, or an
24
+ * equivalent reader — see `verifyReceiptOfflineAgainstKnownAccumulator`'s
25
+ * doc for why that stays out of this package).
26
+ *
27
+ * NEVER source the snapshot unauthenticated from the same store as the tiles
28
+ * (the log operator's massif/checkpoint store) — that silently re-internalises
29
+ * the operator trust this anchor exists to remove.
30
+ */
31
+ import { decodeCborDeterministic, encodeCborDeterministic, } from "@forestrie/encoding";
32
+ import { calculateRoot } from "@forestrie/merklelog";
33
+ import { normalizeHexAddress } from "@forestrie/chain-rpc";
34
+ import { parseReceipt } from "./parse-receipt.js";
35
+ import { univocityLeafHash } from "./leaf-commitment.js";
36
+ import { SubtleHasher } from "./subtle-hasher.js";
37
+ /**
38
+ * 16/32-byte hex or UUID log id -> the 32-byte contract key (UUID in the low
39
+ * bytes, zero-padded on the left — matches Univocity's `ToContractBytes32`).
40
+ * Small, pure, intentionally duplicated from `forestrie-cli`'s copy (same
41
+ * tolerance as this file's local `bytesEqual` — not security-sensitive
42
+ * enough to force a shared-utility package for a six-line formatter).
43
+ */
44
+ function toContractLogId(logId) {
45
+ const hex = logId.replace(/-/g, "").replace(/^0x/, "").toLowerCase();
46
+ if (!/^[0-9a-f]{32}$/.test(hex) && !/^[0-9a-f]{64}$/.test(hex)) {
47
+ throw new Error(`log id must be a UUID or 16/32-byte hex id, got '${logId}'`);
48
+ }
49
+ return "0x" + hex.padStart(64, "0");
50
+ }
51
+ function bytesEqual(a, b) {
52
+ if (a.length !== b.length)
53
+ return false;
54
+ let x = 0;
55
+ for (let i = 0; i < a.length; i++)
56
+ x |= a[i] ^ b[i];
57
+ return x === 0;
58
+ }
59
+ /** CBOR map labels for the snapshot artifact (strict RFC 8949 §4.2). */
60
+ const LABEL_VERSION = 1;
61
+ const LABEL_CHAIN_ID = 2;
62
+ const LABEL_UNIVOCITY = 3;
63
+ const LABEL_LOG_ID = 4;
64
+ const LABEL_SIZE = 5;
65
+ const LABEL_ACCUMULATOR = 6;
66
+ const LABEL_BLOCK_NUMBER = 7;
67
+ const LABEL_BLOCK_HASH = 8;
68
+ const SNAPSHOT_VERSION = 1;
69
+ /** Encode a snapshot as canonical CBOR (RFC 8949 §4.2 — hard policy). */
70
+ export function encodeKnownAccumulator(snapshot) {
71
+ return encodeCborDeterministic(new Map([
72
+ [LABEL_VERSION, snapshot.version],
73
+ [LABEL_CHAIN_ID, snapshot.chainId],
74
+ [LABEL_UNIVOCITY, snapshot.univocity],
75
+ [LABEL_LOG_ID, snapshot.logId],
76
+ [LABEL_SIZE, snapshot.size],
77
+ [LABEL_ACCUMULATOR, snapshot.accumulator],
78
+ [LABEL_BLOCK_NUMBER, snapshot.blockNumber],
79
+ [LABEL_BLOCK_HASH, snapshot.blockHash],
80
+ ]));
81
+ }
82
+ function asBigint(v, what) {
83
+ if (typeof v === "bigint")
84
+ return v;
85
+ if (typeof v === "number" && Number.isSafeInteger(v) && v >= 0)
86
+ return BigInt(v);
87
+ throw new Error(`known accumulator: ${what} must be an unsigned integer`);
88
+ }
89
+ function asBytes(v, length, what) {
90
+ if (!(v instanceof Uint8Array) || v.length !== length) {
91
+ throw new Error(`known accumulator: ${what} must be ${length} bytes`);
92
+ }
93
+ return v;
94
+ }
95
+ /** Strict decode + shape validation of a snapshot artifact. */
96
+ export function decodeKnownAccumulator(bytes) {
97
+ let decoded;
98
+ try {
99
+ decoded = decodeCborDeterministic(bytes);
100
+ }
101
+ catch (err) {
102
+ const message = err instanceof Error ? err.message : String(err);
103
+ throw new Error(`known accumulator is not canonical CBOR: ${message}`);
104
+ }
105
+ if (!(decoded instanceof Map)) {
106
+ throw new Error("known accumulator must be a CBOR map");
107
+ }
108
+ const version = Number(asBigint(decoded.get(LABEL_VERSION), "version"));
109
+ if (version !== SNAPSHOT_VERSION) {
110
+ throw new Error(`known accumulator version ${version} not supported`);
111
+ }
112
+ const accRaw = decoded.get(LABEL_ACCUMULATOR);
113
+ if (!Array.isArray(accRaw)) {
114
+ throw new Error("known accumulator: accumulator must be an array");
115
+ }
116
+ const accumulator = accRaw.map((p, i) => asBytes(p, 32, `accumulator peak ${i}`));
117
+ return {
118
+ version,
119
+ chainId: asBigint(decoded.get(LABEL_CHAIN_ID), "chainId"),
120
+ univocity: asBytes(decoded.get(LABEL_UNIVOCITY), 20, "univocity"),
121
+ logId: asBytes(decoded.get(LABEL_LOG_ID), 32, "logId"),
122
+ size: asBigint(decoded.get(LABEL_SIZE), "size"),
123
+ accumulator,
124
+ blockNumber: asBigint(decoded.get(LABEL_BLOCK_NUMBER), "blockNumber"),
125
+ blockHash: asBytes(decoded.get(LABEL_BLOCK_HASH), 32, "blockHash"),
126
+ };
127
+ }
128
+ function bytesToHex(bytes) {
129
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
130
+ }
131
+ /**
132
+ * Reject a snapshot whose binding does not match the caller's stated target
133
+ * BEFORE any peak math — a snapshot for the wrong log or contract must never
134
+ * be silently accepted as an anchor. `univocity`/`logId` are raw
135
+ * caller-supplied strings (a `0x`-address, a UUID, or hex id) — normalized
136
+ * here so callers don't have to.
137
+ */
138
+ export function assertSnapshotBinding(snapshot, opts) {
139
+ if (opts.univocity !== undefined) {
140
+ const given = normalizeHexAddress(opts.univocity);
141
+ if (given === null || given !== bytesToHex(snapshot.univocity)) {
142
+ throw new Error(`known accumulator is bound to univocity 0x${bytesToHex(snapshot.univocity)}, not --univocity ${opts.univocity}`);
143
+ }
144
+ }
145
+ if (opts.logId !== undefined) {
146
+ const given = toContractLogId(opts.logId).slice(2);
147
+ if (given !== bytesToHex(snapshot.logId)) {
148
+ throw new Error(`known accumulator is bound to log 0x${bytesToHex(snapshot.logId)}, not --log-id ${opts.logId}`);
149
+ }
150
+ }
151
+ }
152
+ /**
153
+ * Offline check: is the receipt's peak one of the known-accumulator's
154
+ * anchored peaks? Covers the two network-free cases from
155
+ * `forestrie-cli`'s `checkReceiptAnchoredToSnapshot`:
156
+ *
157
+ * 1. Fail closed when the receipt's leaf postdates the snapshot (refresh).
158
+ * 2. Exact peak match: the recomputed receipt peak is still a snapshot peak.
159
+ *
160
+ * Proof-path extension for stale snapshots (case 3 — needs a local massif
161
+ * blob) stays CLI-only for now; it is a bigger, file-shaped input this
162
+ * package's pure-bytes API doesn't take today. A receipt whose snapshot
163
+ * covers it but isn't an exact peak returns `peak_not_in_known_accumulator`
164
+ * — honest about not having tried extension, not a false negative dressed
165
+ * up as one.
166
+ *
167
+ * The live RPC read that produces `accumulator`/`size` stays the caller's
168
+ * concern (this package is pure — no network, browser-safety enforced by
169
+ * `tools/check-browser-safe.mjs`); see `system-testing/src/onchain-logstate.ts`
170
+ * or `forestrie-cli fetch-accumulator` for two independent, viem-free
171
+ * readers (`fetch()` + manual ABI decode of `logState(bytes32)`).
172
+ */
173
+ export async function verifyReceiptOfflineAgainstKnownAccumulator(input) {
174
+ let parsed;
175
+ try {
176
+ parsed = parseReceipt(input.receiptCbor);
177
+ }
178
+ catch {
179
+ return { ok: false, stage: "parse", reason: "receipt_malformed" };
180
+ }
181
+ const leafIdx = parsed.proof.leafIndex !== undefined
182
+ ? parsed.proof.leafIndex
183
+ : parsed.proof.mmrIndex;
184
+ // 1. Newer-than-snapshot fails CLOSED — staleness limits coverage, never
185
+ // validity, so the remedy is a refresh, not a pass.
186
+ if (leafIdx >= input.size) {
187
+ return {
188
+ ok: false,
189
+ stage: "signature",
190
+ reason: "receipt_newer_than_known_accumulator",
191
+ };
192
+ }
193
+ let recomputedPeak;
194
+ if (parsed.explicitPeak !== null) {
195
+ recomputedPeak = parsed.explicitPeak;
196
+ }
197
+ else {
198
+ const hasher = new SubtleHasher();
199
+ const leafHash = await univocityLeafHash(input.idtimestampBe8, input.inner);
200
+ recomputedPeak = await calculateRoot(hasher, leafHash, parsed.proof, leafIdx);
201
+ }
202
+ // 2. Exact peak match — receipt state is a snapshot-covered accumulator.
203
+ for (const peak of input.accumulator) {
204
+ if (bytesEqual(recomputedPeak, peak)) {
205
+ return { ok: true, stage: "binding" };
206
+ }
207
+ }
208
+ return {
209
+ ok: false,
210
+ stage: "signature",
211
+ reason: "peak_not_in_known_accumulator",
212
+ };
213
+ }
@@ -20,14 +20,34 @@
20
20
  * (approach A, open) will derive per-log owner keys from genesis + public
21
21
  * tiles, closing that gap without key distribution.
22
22
  *
23
- * Note (parity with the server port source): this establishes that the root
24
- * authorized the delegated key, but does NOT yet enforce the certificate's MMR
25
- * window or expiry-at-issuance against the leaf — that hardening is shared with
26
- * the server path and tracked separately (FOR-323).
23
+ * Constraint enforcement (FOR-420): the certificate authorizes the delegated
24
+ * key only over an MMR coverage window `[mmrStart, mmrEnd]` (payload labels 3/4)
25
+ * and until `expiresAt` (label 9, Unix seconds). {@link checkDelegationConstraints}
26
+ * enforces the soundly-offline-decidable slice against the verified leaf: the
27
+ * `mmrEnd` over-horizon bound (the leaf's index lower-bounds the checkpoint
28
+ * `treeSize-1` the on-chain `delegationVerifier.sol` binds) and expiry-at-issuance
29
+ * against the leaf's snowflake idtimestamp (never wall-clock — receipts must
30
+ * verify forever). The `mmrStart` lower bound, the exact `size-1` bound, and the
31
+ * `issuedAt` lower bound are NOT enforced (see that function). This replaces the
32
+ * earlier "does not yet enforce the window" gap.
27
33
  */
34
+ export declare function idtimestampToUnixSeconds(idtimestamp: bigint): number;
35
+ /**
36
+ * Coverage + validity window a root-signed delegation cert imposes on its
37
+ * delegated key. `mmrStart`/`mmrEnd` are inclusive MMR-index bounds;
38
+ * `issuedAt`/`expiresAt` are Unix seconds.
39
+ */
40
+ export type DelegationConstraints = {
41
+ mmrStart: bigint;
42
+ mmrEnd: bigint;
43
+ issuedAt: number;
44
+ expiresAt: number;
45
+ };
28
46
  /**
29
47
  * root-only: no delegation cert present — verify against the root keys as-is.
30
48
  * resolved: cert verified under a root key; use `delegatedKey` first.
49
+ * `constraints` carries the cert's coverage/validity window when the payload
50
+ * declares it (null for legacy certs that predate labels 3/4/8/9).
31
51
  * broken: a cert is present but did not verify under the root, or its delegated
32
52
  * key could not be parsed — the delegation chain is invalid.
33
53
  */
@@ -36,9 +56,44 @@ export type DelegatedResolution = {
36
56
  } | {
37
57
  kind: "resolved";
38
58
  delegatedKey: CryptoKey;
59
+ constraints: DelegationConstraints | null;
39
60
  } | {
40
61
  kind: "broken";
41
62
  };
63
+ /**
64
+ * Enforce a resolved cert's constraints against the verified leaf (FOR-420).
65
+ *
66
+ * Coverage: the delegation authorizes checkpoint positions `treeSize-1 ∈
67
+ * [mmrStart, mmrEnd]` (inclusive, `delegationVerifier.sol`). Offline we hold the
68
+ * verified leaf's `mmrIndex`, and `leafMmrIndex ≤ treeSize-1` (the leaf is
69
+ * included in the checkpoint). So `leafMmrIndex > mmrEnd` SOUNDLY implies
70
+ * `treeSize-1 > mmrEnd` — a key signing beyond its authorized horizon — and is
71
+ * rejected. The `mmrStart` lower bound is deliberately NOT enforced here: an
72
+ * early leaf can legitimately appear in a checkpoint whose `size-1 ≥ mmrStart`,
73
+ * so `leafMmrIndex < mmrStart` does not imply a violation and enforcing it would
74
+ * false-reject valid receipts under a narrow cert. The lower bound and the exact
75
+ * `size-1` upper bound need the checkpoint accumulator (absent in the single-peak
76
+ * offline path) — deferred; in practice lane certs are wide (`mmrStart=0`).
77
+ *
78
+ * Expiry-at-issuance: the leaf's time (from its snowflake idtimestamp) must not
79
+ * be AFTER the cert's `expiresAt` — a key signing past its authorized lifetime.
80
+ * Compared against the checkpoint/leaf time, NOT wall-clock, so a valid receipt
81
+ * verifies forever (the pinned FOR-297 / ADR-0050 requirement).
82
+ *
83
+ * The `issuedAt` lower bound is deliberately NOT enforced. `issuedAt` is when the
84
+ * cert was SIGNED; the leaf's idtimestamp is when it was SEQUENCED. On live lanes
85
+ * the delegation cert is signed a few seconds AFTER the leaf is sequenced (the
86
+ * checkpoint that includes the leaf is sealed reactively), so `issuedAt > leafTime`
87
+ * is normal and correct — the cert is never *used* to sign before it exists.
88
+ * Enforcing `issuedAt ≤ leafTime` false-rejects legitimate receipts (verified on
89
+ * lane A: a fresh bootstrap receipt has `issuedAt − leafTime ≈ 3s`).
90
+ */
91
+ export declare function checkDelegationConstraints(constraints: DelegationConstraints, leafMmrIndex: bigint, leafIdtimestamp: bigint): {
92
+ ok: true;
93
+ } | {
94
+ ok: false;
95
+ reason: string;
96
+ };
42
97
  /**
43
98
  * Resolve the delegated verify key from a receipt's delegation certificate.
44
99
  *
@@ -1 +1 @@
1
- {"version":3,"file":"resolve-delegated-verify-key.d.ts","sourceRoot":"","sources":["../src/resolve-delegated-verify-key.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAuBH;;;;;GAKG;AACH,MAAM,MAAM,mBAAmB,GAC3B;IAAE,IAAI,EAAE,WAAW,CAAA;CAAE,GACrB;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,YAAY,EAAE,SAAS,CAAA;CAAE,GAC7C;IAAE,IAAI,EAAE,QAAQ,CAAA;CAAE,CAAC;AAqEvB;;;;;GAKG;AACH,wBAAsB,yBAAyB,CAC7C,WAAW,EAAE,UAAU,EACvB,QAAQ,EAAE,SAAS,EAAE,GACpB,OAAO,CAAC,mBAAmB,CAAC,CA4B9B"}
1
+ {"version":3,"file":"resolve-delegated-verify-key.d.ts","sourceRoot":"","sources":["../src/resolve-delegated-verify-key.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAqCH,wBAAgB,wBAAwB,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,CAKpE;AAED;;;;GAIG;AACH,MAAM,MAAM,qBAAqB,GAAG;IAClC,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF;;;;;;;GAOG;AACH,MAAM,MAAM,mBAAmB,GAC3B;IAAE,IAAI,EAAE,WAAW,CAAA;CAAE,GACrB;IACE,IAAI,EAAE,UAAU,CAAC;IACjB,YAAY,EAAE,SAAS,CAAC;IACxB,WAAW,EAAE,qBAAqB,GAAG,IAAI,CAAC;CAC3C,GACD;IAAE,IAAI,EAAE,QAAQ,CAAA;CAAE,CAAC;AAsGvB;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,0BAA0B,CACxC,WAAW,EAAE,qBAAqB,EAClC,YAAY,EAAE,MAAM,EACpB,eAAe,EAAE,MAAM,GACtB;IAAE,EAAE,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAS9C;AAED;;;;;GAKG;AACH,wBAAsB,yBAAyB,CAC7C,WAAW,EAAE,UAAU,EACvB,QAAQ,EAAE,SAAS,EAAE,GACpB,OAAO,CAAC,mBAAmB,CAAC,CAgC9B"}
@@ -20,10 +20,16 @@
20
20
  * (approach A, open) will derive per-log owner keys from genesis + public
21
21
  * tiles, closing that gap without key distribution.
22
22
  *
23
- * Note (parity with the server port source): this establishes that the root
24
- * authorized the delegated key, but does NOT yet enforce the certificate's MMR
25
- * window or expiry-at-issuance against the leaf — that hardening is shared with
26
- * the server path and tracked separately (FOR-323).
23
+ * Constraint enforcement (FOR-420): the certificate authorizes the delegated
24
+ * key only over an MMR coverage window `[mmrStart, mmrEnd]` (payload labels 3/4)
25
+ * and until `expiresAt` (label 9, Unix seconds). {@link checkDelegationConstraints}
26
+ * enforces the soundly-offline-decidable slice against the verified leaf: the
27
+ * `mmrEnd` over-horizon bound (the leaf's index lower-bounds the checkpoint
28
+ * `treeSize-1` the on-chain `delegationVerifier.sol` binds) and expiry-at-issuance
29
+ * against the leaf's snowflake idtimestamp (never wall-clock — receipts must
30
+ * verify forever). The `mmrStart` lower bound, the exact `size-1` bound, and the
31
+ * `issuedAt` lower bound are NOT enforced (see that function). This replaces the
32
+ * earlier "does not yet enforce the window" gap.
27
33
  */
28
34
  import { coseUnprotectedToMap, decodeCborDeterministic, decodeCoseSign1, verifyCoseSign1WithParsedKey, } from "@forestrie/encoding";
29
35
  /** Unprotected header label carrying the delegation certificate. */
@@ -35,8 +41,26 @@ const COSE_KEY_X = -2;
35
41
  const COSE_KEY_Y = -3;
36
42
  const COSE_KTY_EC2 = 2;
37
43
  const COSE_CRV_P256 = 1;
38
- /** Delegation-cert payload label for the delegated COSE_Key. */
44
+ /** Delegation-cert payload labels (must match @forestrie/delegation-cose). */
45
+ const PAYLOAD_MMR_START = 3;
46
+ const PAYLOAD_MMR_END = 4;
39
47
  const PAYLOAD_DELEGATED_KEY = 5;
48
+ const PAYLOAD_ISSUED_AT = 8;
49
+ const PAYLOAD_EXPIRES_AT = 9;
50
+ /**
51
+ * Snowflake idtimestamp → Unix seconds (DataTrails scheme; arbor
52
+ * `snowflakeid`). `TimeShift = 24`, per-epoch span `2^40 - 1` ms, current
53
+ * `CommitmentEpoch = 1` (next epoch ~2038). `unixMs = epoch*(2^40-1) +
54
+ * (id >> 24)`.
55
+ */
56
+ const COMMITMENT_EPOCH = 1n;
57
+ const SNOWFLAKE_TIME_SHIFT = 24n;
58
+ const SNOWFLAKE_EPOCH_SPAN_MS = (1n << 40n) - 1n;
59
+ export function idtimestampToUnixSeconds(idtimestamp) {
60
+ const ms = COMMITMENT_EPOCH * SNOWFLAKE_EPOCH_SPAN_MS +
61
+ (idtimestamp >> SNOWFLAKE_TIME_SHIFT);
62
+ return Number(ms / 1000n);
63
+ }
40
64
  function extractDelegationCertBytes(unprotected) {
41
65
  const umap = coseUnprotectedToMap(unprotected);
42
66
  const certRaw = umap.get(DELEGATION_CERT_LABEL);
@@ -84,19 +108,91 @@ async function importDelegatedKey(keyMap) {
84
108
  return null;
85
109
  }
86
110
  }
87
- function extractDelegatedKeyFromPayload(payloadBytes) {
111
+ function decodeCertPayloadMap(payloadBytes) {
88
112
  if (!payloadBytes || payloadBytes.length === 0)
89
113
  return null;
90
- let payloadMap;
91
114
  try {
92
- payloadMap = decodeCborDeterministic(payloadBytes);
115
+ return decodeCborDeterministic(payloadBytes);
93
116
  }
94
117
  catch {
95
118
  return null;
96
119
  }
120
+ }
121
+ function extractDelegatedKeyFromPayloadMap(payloadMap) {
97
122
  const get = labelGetter(payloadMap);
98
123
  return get ? get(PAYLOAD_DELEGATED_KEY) : null;
99
124
  }
125
+ function toBigIntOrNull(v) {
126
+ if (typeof v === "bigint")
127
+ return v;
128
+ if (typeof v === "number" && Number.isInteger(v))
129
+ return BigInt(v);
130
+ return null;
131
+ }
132
+ function toNumberOrNull(v) {
133
+ if (typeof v === "bigint")
134
+ return Number(v);
135
+ if (typeof v === "number" && Number.isFinite(v))
136
+ return v;
137
+ return null;
138
+ }
139
+ /**
140
+ * Parse the cert's coverage/validity window from its payload map. Returns null
141
+ * when any of labels 3/4/8/9 is absent (legacy certs predating FOR-390 advance
142
+ * delegation) — enforcement is then skipped, preserving pre-constraint trust.
143
+ */
144
+ function parseDelegationConstraints(payloadMap) {
145
+ const get = labelGetter(payloadMap);
146
+ if (!get)
147
+ return null;
148
+ const mmrStart = toBigIntOrNull(get(PAYLOAD_MMR_START));
149
+ const mmrEnd = toBigIntOrNull(get(PAYLOAD_MMR_END));
150
+ const issuedAt = toNumberOrNull(get(PAYLOAD_ISSUED_AT));
151
+ const expiresAt = toNumberOrNull(get(PAYLOAD_EXPIRES_AT));
152
+ if (mmrStart === null || mmrEnd === null)
153
+ return null;
154
+ if (issuedAt === null || expiresAt === null)
155
+ return null;
156
+ return { mmrStart, mmrEnd, issuedAt, expiresAt };
157
+ }
158
+ /**
159
+ * Enforce a resolved cert's constraints against the verified leaf (FOR-420).
160
+ *
161
+ * Coverage: the delegation authorizes checkpoint positions `treeSize-1 ∈
162
+ * [mmrStart, mmrEnd]` (inclusive, `delegationVerifier.sol`). Offline we hold the
163
+ * verified leaf's `mmrIndex`, and `leafMmrIndex ≤ treeSize-1` (the leaf is
164
+ * included in the checkpoint). So `leafMmrIndex > mmrEnd` SOUNDLY implies
165
+ * `treeSize-1 > mmrEnd` — a key signing beyond its authorized horizon — and is
166
+ * rejected. The `mmrStart` lower bound is deliberately NOT enforced here: an
167
+ * early leaf can legitimately appear in a checkpoint whose `size-1 ≥ mmrStart`,
168
+ * so `leafMmrIndex < mmrStart` does not imply a violation and enforcing it would
169
+ * false-reject valid receipts under a narrow cert. The lower bound and the exact
170
+ * `size-1` upper bound need the checkpoint accumulator (absent in the single-peak
171
+ * offline path) — deferred; in practice lane certs are wide (`mmrStart=0`).
172
+ *
173
+ * Expiry-at-issuance: the leaf's time (from its snowflake idtimestamp) must not
174
+ * be AFTER the cert's `expiresAt` — a key signing past its authorized lifetime.
175
+ * Compared against the checkpoint/leaf time, NOT wall-clock, so a valid receipt
176
+ * verifies forever (the pinned FOR-297 / ADR-0050 requirement).
177
+ *
178
+ * The `issuedAt` lower bound is deliberately NOT enforced. `issuedAt` is when the
179
+ * cert was SIGNED; the leaf's idtimestamp is when it was SEQUENCED. On live lanes
180
+ * the delegation cert is signed a few seconds AFTER the leaf is sequenced (the
181
+ * checkpoint that includes the leaf is sealed reactively), so `issuedAt > leafTime`
182
+ * is normal and correct — the cert is never *used* to sign before it exists.
183
+ * Enforcing `issuedAt ≤ leafTime` false-rejects legitimate receipts (verified on
184
+ * lane A: a fresh bootstrap receipt has `issuedAt − leafTime ≈ 3s`).
185
+ */
186
+ export function checkDelegationConstraints(constraints, leafMmrIndex, leafIdtimestamp) {
187
+ if (leafMmrIndex > constraints.mmrEnd) {
188
+ return { ok: false, reason: "delegation_out_of_range" };
189
+ }
190
+ const t = idtimestampToUnixSeconds(leafIdtimestamp);
191
+ if (t > constraints.expiresAt) {
192
+ return { ok: false, reason: "delegation_expired" };
193
+ }
194
+ return { ok: true };
195
+ }
100
196
  /**
101
197
  * Resolve the delegated verify key from a receipt's delegation certificate.
102
198
  *
@@ -124,9 +220,13 @@ export async function resolveDelegatedVerifyKey(receiptCbor, rootKeys) {
124
220
  const certDecoded = decodeCoseSign1(certBytes);
125
221
  if (!certDecoded)
126
222
  return { kind: "broken" };
127
- const keyRaw = extractDelegatedKeyFromPayload(certDecoded.payloadBstr);
223
+ const payloadMap = decodeCertPayloadMap(certDecoded.payloadBstr);
224
+ const keyRaw = payloadMap
225
+ ? extractDelegatedKeyFromPayloadMap(payloadMap)
226
+ : null;
128
227
  const delegatedKey = keyRaw ? await importDelegatedKey(keyRaw) : null;
129
228
  if (!delegatedKey)
130
229
  return { kind: "broken" };
131
- return { kind: "resolved", delegatedKey };
230
+ const constraints = parseDelegationConstraints(payloadMap);
231
+ return { kind: "resolved", delegatedKey, constraints };
132
232
  }
@@ -1 +1 @@
1
- {"version":3,"file":"verify-grant-receipt-offline.d.ts","sourceRoot":"","sources":["../src/verify-grant-receipt-offline.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,qBAAqB,CAAC;AAOjD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AAGtE,MAAM,MAAM,8BAA8B,GAAG;IAC3C,WAAW,EAAE,UAAU,CAAC;IACxB,WAAW,EAAE,UAAU,CAAC;IACxB,KAAK,EAAE,KAAK,CAAC;IACb,cAAc,EAAE,UAAU,CAAC;CAC5B,CAAC;AA4EF;;GAEG;AACH,wBAAsB,yBAAyB,CAC7C,KAAK,EAAE,8BAA8B,GACpC,OAAO,CAAC,mBAAmB,CAAC,CAc9B;AAED,MAAM,MAAM,sCAAsC,GAAG,IAAI,CACvD,8BAA8B,EAC9B,aAAa,CACd,GAAG;IACF,qFAAqF;IACrF,SAAS,EAAE,SAAS,EAAE,CAAC;CACxB,CAAC;AAEF;;;;GAIG;AACH,wBAAsB,iCAAiC,CACrD,KAAK,EAAE,sCAAsC,GAC5C,OAAO,CAAC,mBAAmB,CAAC,CAa9B;AAED,MAAM,MAAM,yBAAyB,GAAG;IACtC,WAAW,EAAE,UAAU,CAAC;IACxB,WAAW,EAAE,UAAU,CAAC;IACxB;;;;;OAKG;IACH,OAAO,EAAE,UAAU,CAAC;IACpB,cAAc,EAAE,UAAU,CAAC;CAC5B,CAAC;AAEF;;;;;;GAMG;AACH,wBAAsB,oBAAoB,CACxC,KAAK,EAAE,yBAAyB,GAC/B,OAAO,CAAC,mBAAmB,CAAC,CAa9B;AAED,MAAM,MAAM,iCAAiC,GAAG,IAAI,CAClD,yBAAyB,EACzB,aAAa,CACd,GAAG;IACF;;;;;OAKG;IACH,SAAS,EAAE,SAAS,EAAE,CAAC;CACxB,CAAC;AAEF;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,4BAA4B,CAChD,KAAK,EAAE,iCAAiC,GACvC,OAAO,CAAC,mBAAmB,CAAC,CAa9B"}
1
+ {"version":3,"file":"verify-grant-receipt-offline.d.ts","sourceRoot":"","sources":["../src/verify-grant-receipt-offline.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,qBAAqB,CAAC;AAUjD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AAGtE,MAAM,MAAM,8BAA8B,GAAG;IAC3C,WAAW,EAAE,UAAU,CAAC;IACxB,WAAW,EAAE,UAAU,CAAC;IACxB,KAAK,EAAE,KAAK,CAAC;IACb,cAAc,EAAE,UAAU,CAAC;CAC5B,CAAC;AA4EF;;GAEG;AACH,wBAAsB,yBAAyB,CAC7C,KAAK,EAAE,8BAA8B,GACpC,OAAO,CAAC,mBAAmB,CAAC,CAc9B;AAED,MAAM,MAAM,sCAAsC,GAAG,IAAI,CACvD,8BAA8B,EAC9B,aAAa,CACd,GAAG;IACF,qFAAqF;IACrF,SAAS,EAAE,SAAS,EAAE,CAAC;CACxB,CAAC;AAEF;;;;GAIG;AACH,wBAAsB,iCAAiC,CACrD,KAAK,EAAE,sCAAsC,GAC5C,OAAO,CAAC,mBAAmB,CAAC,CAa9B;AAED,MAAM,MAAM,yBAAyB,GAAG;IACtC,WAAW,EAAE,UAAU,CAAC;IACxB,WAAW,EAAE,UAAU,CAAC;IACxB;;;;;OAKG;IACH,OAAO,EAAE,UAAU,CAAC;IACpB,cAAc,EAAE,UAAU,CAAC;CAC5B,CAAC;AAEF;;;;;;GAMG;AACH,wBAAsB,oBAAoB,CACxC,KAAK,EAAE,yBAAyB,GAC/B,OAAO,CAAC,mBAAmB,CAAC,CAa9B;AAED,MAAM,MAAM,iCAAiC,GAAG,IAAI,CAClD,yBAAyB,EACzB,aAAa,CACd,GAAG;IACF;;;;;OAKG;IACH,SAAS,EAAE,SAAS,EAAE,CAAC;CACxB,CAAC;AAEF;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,4BAA4B,CAChD,KAAK,EAAE,iCAAiC,GACvC,OAAO,CAAC,mBAAmB,CAAC,CAa9B"}
@@ -3,7 +3,7 @@ import { calculateRoot, verifyInclusion, } from "@forestrie/merklelog";
3
3
  import { grantCommitmentHashFromGrant } from "./grant-commitment.js";
4
4
  import { decodeTrustRootFromGenesis } from "./decode-trust-root-from-genesis.js";
5
5
  import { es256ReceiptVerifyKeys } from "./decode-trust-root-cbor.js";
6
- import { resolveDelegatedVerifyKey } from "./resolve-delegated-verify-key.js";
6
+ import { checkDelegationConstraints, resolveDelegatedVerifyKey, } from "./resolve-delegated-verify-key.js";
7
7
  import { univocityLeafHash } from "./leaf-commitment.js";
8
8
  import { parseReceipt } from "./parse-receipt.js";
9
9
  import { SubtleHasher } from "./subtle-hasher.js";
@@ -197,6 +197,17 @@ async function verifyReceiptOfflineWithLeafInnerKeys(input) {
197
197
  catch {
198
198
  return { ok: false, stage: "binding", reason: "idtimestamp_invalid" };
199
199
  }
200
+ // FOR-420: a resolved delegation cert authorizes its key only within an MMR
201
+ // coverage window and validity window. Enforce both before reporting the
202
+ // signature/inclusion as ok — a delegated key signing outside its authorized
203
+ // range, or after its expiry-at-issuance, fails at stage `signature`.
204
+ if (delegation.kind === "resolved" && delegation.constraints) {
205
+ const leafMmrIndex = parsed.proof.mmrIndex ?? parsed.proof.leafIndex ?? 0n;
206
+ const check = checkDelegationConstraints(delegation.constraints, leafMmrIndex, idtimestamp);
207
+ if (!check.ok) {
208
+ return { ok: false, stage: "signature", reason: check.reason };
209
+ }
210
+ }
200
211
  const leafHash = await univocityLeafHash(idtimestamp, input.inner);
201
212
  return verifySignatureAndInclusion({
202
213
  receiptCbor: input.receiptCbor,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forestrie/receipt-verify",
3
- "version": "0.8.2",
3
+ "version": "0.10.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "Offline SCITT grant receipt verification (ADR-0045)",
@@ -28,6 +28,7 @@
28
28
  ],
29
29
  "dependencies": {
30
30
  "@noble/hashes": "^1.7.1",
31
+ "@forestrie/chain-rpc": "0.1.1",
31
32
  "@forestrie/merklelog": "0.3.0",
32
33
  "@forestrie/encoding": "0.5.0"
33
34
  },
package/src/index.ts CHANGED
@@ -47,8 +47,15 @@ export type {
47
47
  VerifyGrantReceiptOfflineWithKeysInput,
48
48
  VerifyReceiptOfflineWithKeysInput,
49
49
  } from "./verify-grant-receipt-offline.js";
50
- export { resolveDelegatedVerifyKey } from "./resolve-delegated-verify-key.js";
51
- export type { DelegatedResolution } from "./resolve-delegated-verify-key.js";
50
+ export {
51
+ checkDelegationConstraints,
52
+ idtimestampToUnixSeconds,
53
+ resolveDelegatedVerifyKey,
54
+ } from "./resolve-delegated-verify-key.js";
55
+ export type {
56
+ DelegatedResolution,
57
+ DelegationConstraints,
58
+ } from "./resolve-delegated-verify-key.js";
52
59
  /** Import a raw 64-byte x||y P-256 public key as an ES256 verify key. */
53
60
  export { importEs256PublicKeyFromGrantDataXy64 } from "./decode-trust-root-cbor.js";
54
61
  export { decodeForestrieGrantCose } from "./decode-forestrie-grant-cose.js";
@@ -78,3 +85,24 @@ export {
78
85
  type CheckpointChainResult,
79
86
  type CheckpointConsistencyProof,
80
87
  } from "./checkpoint-chain.js";
88
+ /**
89
+ * Univocity leaf commitment hash. Was CLI-private (forestrie-cli's own
90
+ * mirror, "hoist to the library when the FOR-297 multi-hop resolver lands");
91
+ * exported here as of plan-2607-34 slice 02 Part B so consumers stop
92
+ * duplicating it.
93
+ */
94
+ export { univocityLeafHash } from "./leaf-commitment.js";
95
+ /**
96
+ * Known-accumulator snapshot (FOR-297 D5): verify a receipt against a
97
+ * caller-supplied on-chain accumulator read, fully offline. Hoisted from
98
+ * forestrie-cli (plan-2607-34 slice 02 Part B) — see known-accumulator.ts
99
+ * for the trust model and what this package does NOT do (fetch the
100
+ * snapshot over RPC, or proof-path-extend a stale one via massif nodes).
101
+ */
102
+ export {
103
+ assertSnapshotBinding,
104
+ decodeKnownAccumulator,
105
+ encodeKnownAccumulator,
106
+ verifyReceiptOfflineAgainstKnownAccumulator,
107
+ type KnownAccumulator,
108
+ } from "./known-accumulator.js";
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Known-accumulator snapshot (FOR-297 D5): a cached, auditable chain read of
3
+ * a Univocity log's `logState`, letting chain-anchored verification run
4
+ * fully offline. Hoisted from `forestrie-cli/src/lib/verify-known-accumulator.ts`
5
+ * (plan-2607-34 slice 02 Part B) so the CLI and any other consumer — e2e
6
+ * harnesses, a future browser relying party — share one implementation
7
+ * instead of duplicating this crypto per caller.
8
+ *
9
+ * Trust model (freshness / split-view): `--rpc-url` was never trust-free —
10
+ * the RPC provider is itself a trusted chain reader. The snapshot makes that
11
+ * trust explicit, portable, and cacheable. It binds `(chainId, univocity,
12
+ * logId, size, block)` so anyone with RPC can re-run the read at that block
13
+ * and confirm or disprove it — auditable, falsifiable trust, unlike a bare
14
+ * known key.
15
+ *
16
+ * Staleness limits coverage, never validity: the contract's consistency
17
+ * gating makes every anchored state a committed prefix of every later one,
18
+ * so a peak match at snapshot size N proves inclusion at N and forever
19
+ * after. Entries newer than the snapshot fail closed with a refresh hint.
20
+ *
21
+ * This module is pure — no network, no `node:*` imports (browser-safety
22
+ * enforced by `tools/check-browser-safe.mjs`). Fetching the snapshot itself
23
+ * over RPC is the caller's concern (forestrie-cli `fetch-accumulator`, or an
24
+ * equivalent reader — see `verifyReceiptOfflineAgainstKnownAccumulator`'s
25
+ * doc for why that stays out of this package).
26
+ *
27
+ * NEVER source the snapshot unauthenticated from the same store as the tiles
28
+ * (the log operator's massif/checkpoint store) — that silently re-internalises
29
+ * the operator trust this anchor exists to remove.
30
+ */
31
+
32
+ import {
33
+ decodeCborDeterministic,
34
+ encodeCborDeterministic,
35
+ } from "@forestrie/encoding";
36
+ import { calculateRoot, type Proof } from "@forestrie/merklelog";
37
+ import { normalizeHexAddress } from "@forestrie/chain-rpc";
38
+ import { parseReceipt } from "./parse-receipt.js";
39
+ import { univocityLeafHash } from "./leaf-commitment.js";
40
+ import { SubtleHasher } from "./subtle-hasher.js";
41
+ import type { ReceiptVerifyResult } from "./receipt-verify-result.js";
42
+
43
+ /**
44
+ * 16/32-byte hex or UUID log id -> the 32-byte contract key (UUID in the low
45
+ * bytes, zero-padded on the left — matches Univocity's `ToContractBytes32`).
46
+ * Small, pure, intentionally duplicated from `forestrie-cli`'s copy (same
47
+ * tolerance as this file's local `bytesEqual` — not security-sensitive
48
+ * enough to force a shared-utility package for a six-line formatter).
49
+ */
50
+ function toContractLogId(logId: string): string {
51
+ const hex = logId.replace(/-/g, "").replace(/^0x/, "").toLowerCase();
52
+ if (!/^[0-9a-f]{32}$/.test(hex) && !/^[0-9a-f]{64}$/.test(hex)) {
53
+ throw new Error(
54
+ `log id must be a UUID or 16/32-byte hex id, got '${logId}'`,
55
+ );
56
+ }
57
+ return "0x" + hex.padStart(64, "0");
58
+ }
59
+
60
+ function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
61
+ if (a.length !== b.length) return false;
62
+ let x = 0;
63
+ for (let i = 0; i < a.length; i++) x |= a[i]! ^ b[i]!;
64
+ return x === 0;
65
+ }
66
+
67
+ /** CBOR map labels for the snapshot artifact (strict RFC 8949 §4.2). */
68
+ const LABEL_VERSION = 1;
69
+ const LABEL_CHAIN_ID = 2;
70
+ const LABEL_UNIVOCITY = 3;
71
+ const LABEL_LOG_ID = 4;
72
+ const LABEL_SIZE = 5;
73
+ const LABEL_ACCUMULATOR = 6;
74
+ const LABEL_BLOCK_NUMBER = 7;
75
+ const LABEL_BLOCK_HASH = 8;
76
+
77
+ const SNAPSHOT_VERSION = 1;
78
+
79
+ export type KnownAccumulator = {
80
+ version: number;
81
+ chainId: bigint;
82
+ /** Univocity contract address (20 bytes). */
83
+ univocity: Uint8Array;
84
+ /** Contract log id (32 bytes, UUID zero-padded on the left). */
85
+ logId: Uint8Array;
86
+ /** Anchored MMR size at the snapshot block. */
87
+ size: bigint;
88
+ /** Anchored accumulator peaks (32 bytes each), contract order. */
89
+ accumulator: Uint8Array[];
90
+ blockNumber: bigint;
91
+ /** Block hash of the read (32 bytes) — the falsifiability handle. */
92
+ blockHash: Uint8Array;
93
+ };
94
+
95
+ /** Encode a snapshot as canonical CBOR (RFC 8949 §4.2 — hard policy). */
96
+ export function encodeKnownAccumulator(snapshot: KnownAccumulator): Uint8Array {
97
+ return encodeCborDeterministic(
98
+ new Map<number, unknown>([
99
+ [LABEL_VERSION, snapshot.version],
100
+ [LABEL_CHAIN_ID, snapshot.chainId],
101
+ [LABEL_UNIVOCITY, snapshot.univocity],
102
+ [LABEL_LOG_ID, snapshot.logId],
103
+ [LABEL_SIZE, snapshot.size],
104
+ [LABEL_ACCUMULATOR, snapshot.accumulator],
105
+ [LABEL_BLOCK_NUMBER, snapshot.blockNumber],
106
+ [LABEL_BLOCK_HASH, snapshot.blockHash],
107
+ ]),
108
+ );
109
+ }
110
+
111
+ function asBigint(v: unknown, what: string): bigint {
112
+ if (typeof v === "bigint") return v;
113
+ if (typeof v === "number" && Number.isSafeInteger(v) && v >= 0)
114
+ return BigInt(v);
115
+ throw new Error(`known accumulator: ${what} must be an unsigned integer`);
116
+ }
117
+
118
+ function asBytes(v: unknown, length: number, what: string): Uint8Array {
119
+ if (!(v instanceof Uint8Array) || v.length !== length) {
120
+ throw new Error(`known accumulator: ${what} must be ${length} bytes`);
121
+ }
122
+ return v;
123
+ }
124
+
125
+ /** Strict decode + shape validation of a snapshot artifact. */
126
+ export function decodeKnownAccumulator(bytes: Uint8Array): KnownAccumulator {
127
+ let decoded: unknown;
128
+ try {
129
+ decoded = decodeCborDeterministic(bytes);
130
+ } catch (err) {
131
+ const message = err instanceof Error ? err.message : String(err);
132
+ throw new Error(`known accumulator is not canonical CBOR: ${message}`);
133
+ }
134
+ if (!(decoded instanceof Map)) {
135
+ throw new Error("known accumulator must be a CBOR map");
136
+ }
137
+ const version = Number(asBigint(decoded.get(LABEL_VERSION), "version"));
138
+ if (version !== SNAPSHOT_VERSION) {
139
+ throw new Error(`known accumulator version ${version} not supported`);
140
+ }
141
+ const accRaw = decoded.get(LABEL_ACCUMULATOR);
142
+ if (!Array.isArray(accRaw)) {
143
+ throw new Error("known accumulator: accumulator must be an array");
144
+ }
145
+ const accumulator = accRaw.map((p, i) =>
146
+ asBytes(p, 32, `accumulator peak ${i}`),
147
+ );
148
+ return {
149
+ version,
150
+ chainId: asBigint(decoded.get(LABEL_CHAIN_ID), "chainId"),
151
+ univocity: asBytes(decoded.get(LABEL_UNIVOCITY), 20, "univocity"),
152
+ logId: asBytes(decoded.get(LABEL_LOG_ID), 32, "logId"),
153
+ size: asBigint(decoded.get(LABEL_SIZE), "size"),
154
+ accumulator,
155
+ blockNumber: asBigint(decoded.get(LABEL_BLOCK_NUMBER), "blockNumber"),
156
+ blockHash: asBytes(decoded.get(LABEL_BLOCK_HASH), 32, "blockHash"),
157
+ };
158
+ }
159
+
160
+ function bytesToHex(bytes: Uint8Array): string {
161
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
162
+ }
163
+
164
+ /**
165
+ * Reject a snapshot whose binding does not match the caller's stated target
166
+ * BEFORE any peak math — a snapshot for the wrong log or contract must never
167
+ * be silently accepted as an anchor. `univocity`/`logId` are raw
168
+ * caller-supplied strings (a `0x`-address, a UUID, or hex id) — normalized
169
+ * here so callers don't have to.
170
+ */
171
+ export function assertSnapshotBinding(
172
+ snapshot: KnownAccumulator,
173
+ opts: { univocity?: string | undefined; logId?: string | undefined },
174
+ ): void {
175
+ if (opts.univocity !== undefined) {
176
+ const given = normalizeHexAddress(opts.univocity);
177
+ if (given === null || given !== bytesToHex(snapshot.univocity)) {
178
+ throw new Error(
179
+ `known accumulator is bound to univocity 0x${bytesToHex(snapshot.univocity)}, not --univocity ${opts.univocity}`,
180
+ );
181
+ }
182
+ }
183
+ if (opts.logId !== undefined) {
184
+ const given = toContractLogId(opts.logId).slice(2);
185
+ if (given !== bytesToHex(snapshot.logId)) {
186
+ throw new Error(
187
+ `known accumulator is bound to log 0x${bytesToHex(snapshot.logId)}, not --log-id ${opts.logId}`,
188
+ );
189
+ }
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Offline check: is the receipt's peak one of the known-accumulator's
195
+ * anchored peaks? Covers the two network-free cases from
196
+ * `forestrie-cli`'s `checkReceiptAnchoredToSnapshot`:
197
+ *
198
+ * 1. Fail closed when the receipt's leaf postdates the snapshot (refresh).
199
+ * 2. Exact peak match: the recomputed receipt peak is still a snapshot peak.
200
+ *
201
+ * Proof-path extension for stale snapshots (case 3 — needs a local massif
202
+ * blob) stays CLI-only for now; it is a bigger, file-shaped input this
203
+ * package's pure-bytes API doesn't take today. A receipt whose snapshot
204
+ * covers it but isn't an exact peak returns `peak_not_in_known_accumulator`
205
+ * — honest about not having tried extension, not a false negative dressed
206
+ * up as one.
207
+ *
208
+ * The live RPC read that produces `accumulator`/`size` stays the caller's
209
+ * concern (this package is pure — no network, browser-safety enforced by
210
+ * `tools/check-browser-safe.mjs`); see `system-testing/src/onchain-logstate.ts`
211
+ * or `forestrie-cli fetch-accumulator` for two independent, viem-free
212
+ * readers (`fetch()` + manual ABI decode of `logState(bytes32)`).
213
+ */
214
+ export async function verifyReceiptOfflineAgainstKnownAccumulator(input: {
215
+ receiptCbor: Uint8Array;
216
+ idtimestampBe8: Uint8Array;
217
+ /** Leaf ContentHash: SHA-256(payload) or the grant commitment hash. */
218
+ inner: Uint8Array;
219
+ /** Trusted accumulator peaks at `size`, contract order. */
220
+ accumulator: Uint8Array[];
221
+ /** Anchored MMR size at the snapshot. */
222
+ size: bigint;
223
+ }): Promise<ReceiptVerifyResult> {
224
+ let parsed: { explicitPeak: Uint8Array | null; proof: Proof };
225
+ try {
226
+ parsed = parseReceipt(input.receiptCbor);
227
+ } catch {
228
+ return { ok: false, stage: "parse", reason: "receipt_malformed" };
229
+ }
230
+
231
+ const leafIdx =
232
+ parsed.proof.leafIndex !== undefined
233
+ ? parsed.proof.leafIndex
234
+ : parsed.proof.mmrIndex!;
235
+
236
+ // 1. Newer-than-snapshot fails CLOSED — staleness limits coverage, never
237
+ // validity, so the remedy is a refresh, not a pass.
238
+ if (leafIdx >= input.size) {
239
+ return {
240
+ ok: false,
241
+ stage: "signature",
242
+ reason: "receipt_newer_than_known_accumulator",
243
+ };
244
+ }
245
+
246
+ let recomputedPeak: Uint8Array;
247
+ if (parsed.explicitPeak !== null) {
248
+ recomputedPeak = parsed.explicitPeak;
249
+ } else {
250
+ const hasher = new SubtleHasher();
251
+ const leafHash = await univocityLeafHash(input.idtimestampBe8, input.inner);
252
+ recomputedPeak = await calculateRoot(
253
+ hasher,
254
+ leafHash,
255
+ parsed.proof,
256
+ leafIdx,
257
+ );
258
+ }
259
+
260
+ // 2. Exact peak match — receipt state is a snapshot-covered accumulator.
261
+ for (const peak of input.accumulator) {
262
+ if (bytesEqual(recomputedPeak, peak)) {
263
+ return { ok: true, stage: "binding" };
264
+ }
265
+ }
266
+
267
+ return {
268
+ ok: false,
269
+ stage: "signature",
270
+ reason: "peak_not_in_known_accumulator",
271
+ };
272
+ }
@@ -20,10 +20,16 @@
20
20
  * (approach A, open) will derive per-log owner keys from genesis + public
21
21
  * tiles, closing that gap without key distribution.
22
22
  *
23
- * Note (parity with the server port source): this establishes that the root
24
- * authorized the delegated key, but does NOT yet enforce the certificate's MMR
25
- * window or expiry-at-issuance against the leaf — that hardening is shared with
26
- * the server path and tracked separately (FOR-323).
23
+ * Constraint enforcement (FOR-420): the certificate authorizes the delegated
24
+ * key only over an MMR coverage window `[mmrStart, mmrEnd]` (payload labels 3/4)
25
+ * and until `expiresAt` (label 9, Unix seconds). {@link checkDelegationConstraints}
26
+ * enforces the soundly-offline-decidable slice against the verified leaf: the
27
+ * `mmrEnd` over-horizon bound (the leaf's index lower-bounds the checkpoint
28
+ * `treeSize-1` the on-chain `delegationVerifier.sol` binds) and expiry-at-issuance
29
+ * against the leaf's snowflake idtimestamp (never wall-clock — receipts must
30
+ * verify forever). The `mmrStart` lower bound, the exact `size-1` bound, and the
31
+ * `issuedAt` lower bound are NOT enforced (see that function). This replaces the
32
+ * earlier "does not yet enforce the window" gap.
27
33
  */
28
34
 
29
35
  import {
@@ -44,18 +50,57 @@ const COSE_KEY_Y = -3;
44
50
  const COSE_KTY_EC2 = 2;
45
51
  const COSE_CRV_P256 = 1;
46
52
 
47
- /** Delegation-cert payload label for the delegated COSE_Key. */
53
+ /** Delegation-cert payload labels (must match @forestrie/delegation-cose). */
54
+ const PAYLOAD_MMR_START = 3;
55
+ const PAYLOAD_MMR_END = 4;
48
56
  const PAYLOAD_DELEGATED_KEY = 5;
57
+ const PAYLOAD_ISSUED_AT = 8;
58
+ const PAYLOAD_EXPIRES_AT = 9;
59
+
60
+ /**
61
+ * Snowflake idtimestamp → Unix seconds (DataTrails scheme; arbor
62
+ * `snowflakeid`). `TimeShift = 24`, per-epoch span `2^40 - 1` ms, current
63
+ * `CommitmentEpoch = 1` (next epoch ~2038). `unixMs = epoch*(2^40-1) +
64
+ * (id >> 24)`.
65
+ */
66
+ const COMMITMENT_EPOCH = 1n;
67
+ const SNOWFLAKE_TIME_SHIFT = 24n;
68
+ const SNOWFLAKE_EPOCH_SPAN_MS = (1n << 40n) - 1n;
69
+
70
+ export function idtimestampToUnixSeconds(idtimestamp: bigint): number {
71
+ const ms =
72
+ COMMITMENT_EPOCH * SNOWFLAKE_EPOCH_SPAN_MS +
73
+ (idtimestamp >> SNOWFLAKE_TIME_SHIFT);
74
+ return Number(ms / 1000n);
75
+ }
76
+
77
+ /**
78
+ * Coverage + validity window a root-signed delegation cert imposes on its
79
+ * delegated key. `mmrStart`/`mmrEnd` are inclusive MMR-index bounds;
80
+ * `issuedAt`/`expiresAt` are Unix seconds.
81
+ */
82
+ export type DelegationConstraints = {
83
+ mmrStart: bigint;
84
+ mmrEnd: bigint;
85
+ issuedAt: number;
86
+ expiresAt: number;
87
+ };
49
88
 
50
89
  /**
51
90
  * root-only: no delegation cert present — verify against the root keys as-is.
52
91
  * resolved: cert verified under a root key; use `delegatedKey` first.
92
+ * `constraints` carries the cert's coverage/validity window when the payload
93
+ * declares it (null for legacy certs that predate labels 3/4/8/9).
53
94
  * broken: a cert is present but did not verify under the root, or its delegated
54
95
  * key could not be parsed — the delegation chain is invalid.
55
96
  */
56
97
  export type DelegatedResolution =
57
98
  | { kind: "root-only" }
58
- | { kind: "resolved"; delegatedKey: CryptoKey }
99
+ | {
100
+ kind: "resolved";
101
+ delegatedKey: CryptoKey;
102
+ constraints: DelegationConstraints | null;
103
+ }
59
104
  | { kind: "broken" };
60
105
 
61
106
  function extractDelegationCertBytes(unprotected: unknown): Uint8Array | null {
@@ -111,20 +156,96 @@ async function importDelegatedKey(keyMap: unknown): Promise<CryptoKey | null> {
111
156
  }
112
157
  }
113
158
 
114
- function extractDelegatedKeyFromPayload(
115
- payloadBytes: Uint8Array,
116
- ): unknown | null {
159
+ function decodeCertPayloadMap(payloadBytes: Uint8Array): unknown | null {
117
160
  if (!payloadBytes || payloadBytes.length === 0) return null;
118
- let payloadMap: unknown;
119
161
  try {
120
- payloadMap = decodeCborDeterministic(payloadBytes);
162
+ return decodeCborDeterministic(payloadBytes);
121
163
  } catch {
122
164
  return null;
123
165
  }
166
+ }
167
+
168
+ function extractDelegatedKeyFromPayloadMap(
169
+ payloadMap: unknown,
170
+ ): unknown | null {
124
171
  const get = labelGetter(payloadMap);
125
172
  return get ? get(PAYLOAD_DELEGATED_KEY) : null;
126
173
  }
127
174
 
175
+ function toBigIntOrNull(v: unknown): bigint | null {
176
+ if (typeof v === "bigint") return v;
177
+ if (typeof v === "number" && Number.isInteger(v)) return BigInt(v);
178
+ return null;
179
+ }
180
+
181
+ function toNumberOrNull(v: unknown): number | null {
182
+ if (typeof v === "bigint") return Number(v);
183
+ if (typeof v === "number" && Number.isFinite(v)) return v;
184
+ return null;
185
+ }
186
+
187
+ /**
188
+ * Parse the cert's coverage/validity window from its payload map. Returns null
189
+ * when any of labels 3/4/8/9 is absent (legacy certs predating FOR-390 advance
190
+ * delegation) — enforcement is then skipped, preserving pre-constraint trust.
191
+ */
192
+ function parseDelegationConstraints(
193
+ payloadMap: unknown,
194
+ ): DelegationConstraints | null {
195
+ const get = labelGetter(payloadMap);
196
+ if (!get) return null;
197
+ const mmrStart = toBigIntOrNull(get(PAYLOAD_MMR_START));
198
+ const mmrEnd = toBigIntOrNull(get(PAYLOAD_MMR_END));
199
+ const issuedAt = toNumberOrNull(get(PAYLOAD_ISSUED_AT));
200
+ const expiresAt = toNumberOrNull(get(PAYLOAD_EXPIRES_AT));
201
+ if (mmrStart === null || mmrEnd === null) return null;
202
+ if (issuedAt === null || expiresAt === null) return null;
203
+ return { mmrStart, mmrEnd, issuedAt, expiresAt };
204
+ }
205
+
206
+ /**
207
+ * Enforce a resolved cert's constraints against the verified leaf (FOR-420).
208
+ *
209
+ * Coverage: the delegation authorizes checkpoint positions `treeSize-1 ∈
210
+ * [mmrStart, mmrEnd]` (inclusive, `delegationVerifier.sol`). Offline we hold the
211
+ * verified leaf's `mmrIndex`, and `leafMmrIndex ≤ treeSize-1` (the leaf is
212
+ * included in the checkpoint). So `leafMmrIndex > mmrEnd` SOUNDLY implies
213
+ * `treeSize-1 > mmrEnd` — a key signing beyond its authorized horizon — and is
214
+ * rejected. The `mmrStart` lower bound is deliberately NOT enforced here: an
215
+ * early leaf can legitimately appear in a checkpoint whose `size-1 ≥ mmrStart`,
216
+ * so `leafMmrIndex < mmrStart` does not imply a violation and enforcing it would
217
+ * false-reject valid receipts under a narrow cert. The lower bound and the exact
218
+ * `size-1` upper bound need the checkpoint accumulator (absent in the single-peak
219
+ * offline path) — deferred; in practice lane certs are wide (`mmrStart=0`).
220
+ *
221
+ * Expiry-at-issuance: the leaf's time (from its snowflake idtimestamp) must not
222
+ * be AFTER the cert's `expiresAt` — a key signing past its authorized lifetime.
223
+ * Compared against the checkpoint/leaf time, NOT wall-clock, so a valid receipt
224
+ * verifies forever (the pinned FOR-297 / ADR-0050 requirement).
225
+ *
226
+ * The `issuedAt` lower bound is deliberately NOT enforced. `issuedAt` is when the
227
+ * cert was SIGNED; the leaf's idtimestamp is when it was SEQUENCED. On live lanes
228
+ * the delegation cert is signed a few seconds AFTER the leaf is sequenced (the
229
+ * checkpoint that includes the leaf is sealed reactively), so `issuedAt > leafTime`
230
+ * is normal and correct — the cert is never *used* to sign before it exists.
231
+ * Enforcing `issuedAt ≤ leafTime` false-rejects legitimate receipts (verified on
232
+ * lane A: a fresh bootstrap receipt has `issuedAt − leafTime ≈ 3s`).
233
+ */
234
+ export function checkDelegationConstraints(
235
+ constraints: DelegationConstraints,
236
+ leafMmrIndex: bigint,
237
+ leafIdtimestamp: bigint,
238
+ ): { ok: true } | { ok: false; reason: string } {
239
+ if (leafMmrIndex > constraints.mmrEnd) {
240
+ return { ok: false, reason: "delegation_out_of_range" };
241
+ }
242
+ const t = idtimestampToUnixSeconds(leafIdtimestamp);
243
+ if (t > constraints.expiresAt) {
244
+ return { ok: false, reason: "delegation_expired" };
245
+ }
246
+ return { ok: true };
247
+ }
248
+
128
249
  /**
129
250
  * Resolve the delegated verify key from a receipt's delegation certificate.
130
251
  *
@@ -157,9 +278,13 @@ export async function resolveDelegatedVerifyKey(
157
278
  const certDecoded = decodeCoseSign1(certBytes);
158
279
  if (!certDecoded) return { kind: "broken" };
159
280
 
160
- const keyRaw = extractDelegatedKeyFromPayload(certDecoded.payloadBstr);
281
+ const payloadMap = decodeCertPayloadMap(certDecoded.payloadBstr);
282
+ const keyRaw = payloadMap
283
+ ? extractDelegatedKeyFromPayloadMap(payloadMap)
284
+ : null;
161
285
  const delegatedKey = keyRaw ? await importDelegatedKey(keyRaw) : null;
162
286
  if (!delegatedKey) return { kind: "broken" };
163
287
 
164
- return { kind: "resolved", delegatedKey };
288
+ const constraints = parseDelegationConstraints(payloadMap);
289
+ return { kind: "resolved", delegatedKey, constraints };
165
290
  }
@@ -8,7 +8,10 @@ import type { Grant } from "@forestrie/encoding";
8
8
  import { grantCommitmentHashFromGrant } from "./grant-commitment.js";
9
9
  import { decodeTrustRootFromGenesis } from "./decode-trust-root-from-genesis.js";
10
10
  import { es256ReceiptVerifyKeys } from "./decode-trust-root-cbor.js";
11
- import { resolveDelegatedVerifyKey } from "./resolve-delegated-verify-key.js";
11
+ import {
12
+ checkDelegationConstraints,
13
+ resolveDelegatedVerifyKey,
14
+ } from "./resolve-delegated-verify-key.js";
12
15
  import { univocityLeafHash } from "./leaf-commitment.js";
13
16
  import { parseReceipt } from "./parse-receipt.js";
14
17
  import type { ReceiptVerifyResult } from "./receipt-verify-result.js";
@@ -309,6 +312,22 @@ async function verifyReceiptOfflineWithLeafInnerKeys(input: {
309
312
  return { ok: false, stage: "binding", reason: "idtimestamp_invalid" };
310
313
  }
311
314
 
315
+ // FOR-420: a resolved delegation cert authorizes its key only within an MMR
316
+ // coverage window and validity window. Enforce both before reporting the
317
+ // signature/inclusion as ok — a delegated key signing outside its authorized
318
+ // range, or after its expiry-at-issuance, fails at stage `signature`.
319
+ if (delegation.kind === "resolved" && delegation.constraints) {
320
+ const leafMmrIndex = parsed.proof.mmrIndex ?? parsed.proof.leafIndex ?? 0n;
321
+ const check = checkDelegationConstraints(
322
+ delegation.constraints,
323
+ leafMmrIndex,
324
+ idtimestamp,
325
+ );
326
+ if (!check.ok) {
327
+ return { ok: false, stage: "signature", reason: check.reason };
328
+ }
329
+ }
330
+
312
331
  const leafHash = await univocityLeafHash(idtimestamp, input.inner);
313
332
 
314
333
  return verifySignatureAndInclusion({