@forestrie/receipt-verify 0.9.0 → 0.11.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/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";
@@ -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;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"}
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";
@@ -1 +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;AAoCtE,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,CA4C/B"}
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"}
@@ -6,9 +6,12 @@
6
6
  * trust root and returns the delegated public key as an additional verify
7
7
  * candidate — the offline port of canopy-api's `resolveReceiptVerifyKey`.
8
8
  *
9
- * ES256 (P-256) only: the offline verifier is ES256-only (genesis alg), and
10
- * sealer delegate keys are always secp256r1. KS256-rooted delegation is a
11
- * server-only concern.
9
+ * P-256 roots only: the genesis alg is ES256 and sealer delegate keys are
10
+ * always secp256r1; KS256-rooted delegation is a server-only concern. A
11
+ * passkey (WebAuthn) root signs the certificate with alg -65800 and the
12
+ * ADR-0063 unprotected-header envelope — `verifyCoseSign1WithParsedKey`
13
+ * verifies that envelope against the same P-256 root keys, so the chain walk
14
+ * here is alg-agnostic (plan-2608-13 1.4).
12
15
  *
13
16
  * Trust anchors (FOR-297 trust ladder): `rootKeys` need not be genesis-derived
14
17
  * — a caller-known log OWNER key works identically (see
@@ -20,14 +23,34 @@
20
23
  * (approach A, open) will derive per-log owner keys from genesis + public
21
24
  * tiles, closing that gap without key distribution.
22
25
  *
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).
26
+ * Constraint enforcement (FOR-420): the certificate authorizes the delegated
27
+ * key only over an MMR coverage window `[mmrStart, mmrEnd]` (payload labels 3/4)
28
+ * and until `expiresAt` (label 9, Unix seconds). {@link checkDelegationConstraints}
29
+ * enforces the soundly-offline-decidable slice against the verified leaf: the
30
+ * `mmrEnd` over-horizon bound (the leaf's index lower-bounds the checkpoint
31
+ * `treeSize-1` the on-chain `delegationVerifier.sol` binds) and expiry-at-issuance
32
+ * against the leaf's snowflake idtimestamp (never wall-clock — receipts must
33
+ * verify forever). The `mmrStart` lower bound, the exact `size-1` bound, and the
34
+ * `issuedAt` lower bound are NOT enforced (see that function). This replaces the
35
+ * earlier "does not yet enforce the window" gap.
27
36
  */
37
+ export declare function idtimestampToUnixSeconds(idtimestamp: bigint): number;
38
+ /**
39
+ * Coverage + validity window a root-signed delegation cert imposes on its
40
+ * delegated key. `mmrStart`/`mmrEnd` are inclusive MMR-index bounds;
41
+ * `issuedAt`/`expiresAt` are Unix seconds.
42
+ */
43
+ export type DelegationConstraints = {
44
+ mmrStart: bigint;
45
+ mmrEnd: bigint;
46
+ issuedAt: number;
47
+ expiresAt: number;
48
+ };
28
49
  /**
29
50
  * root-only: no delegation cert present — verify against the root keys as-is.
30
51
  * resolved: cert verified under a root key; use `delegatedKey` first.
52
+ * `constraints` carries the cert's coverage/validity window when the payload
53
+ * declares it (null for legacy certs that predate labels 3/4/8/9).
31
54
  * broken: a cert is present but did not verify under the root, or its delegated
32
55
  * key could not be parsed — the delegation chain is invalid.
33
56
  */
@@ -36,9 +59,44 @@ export type DelegatedResolution = {
36
59
  } | {
37
60
  kind: "resolved";
38
61
  delegatedKey: CryptoKey;
62
+ constraints: DelegationConstraints | null;
39
63
  } | {
40
64
  kind: "broken";
41
65
  };
66
+ /**
67
+ * Enforce a resolved cert's constraints against the verified leaf (FOR-420).
68
+ *
69
+ * Coverage: the delegation authorizes checkpoint positions `treeSize-1 ∈
70
+ * [mmrStart, mmrEnd]` (inclusive, `delegationVerifier.sol`). Offline we hold the
71
+ * verified leaf's `mmrIndex`, and `leafMmrIndex ≤ treeSize-1` (the leaf is
72
+ * included in the checkpoint). So `leafMmrIndex > mmrEnd` SOUNDLY implies
73
+ * `treeSize-1 > mmrEnd` — a key signing beyond its authorized horizon — and is
74
+ * rejected. The `mmrStart` lower bound is deliberately NOT enforced here: an
75
+ * early leaf can legitimately appear in a checkpoint whose `size-1 ≥ mmrStart`,
76
+ * so `leafMmrIndex < mmrStart` does not imply a violation and enforcing it would
77
+ * false-reject valid receipts under a narrow cert. The lower bound and the exact
78
+ * `size-1` upper bound need the checkpoint accumulator (absent in the single-peak
79
+ * offline path) — deferred; in practice lane certs are wide (`mmrStart=0`).
80
+ *
81
+ * Expiry-at-issuance: the leaf's time (from its snowflake idtimestamp) must not
82
+ * be AFTER the cert's `expiresAt` — a key signing past its authorized lifetime.
83
+ * Compared against the checkpoint/leaf time, NOT wall-clock, so a valid receipt
84
+ * verifies forever (the pinned FOR-297 / ADR-0050 requirement).
85
+ *
86
+ * The `issuedAt` lower bound is deliberately NOT enforced. `issuedAt` is when the
87
+ * cert was SIGNED; the leaf's idtimestamp is when it was SEQUENCED. On live lanes
88
+ * the delegation cert is signed a few seconds AFTER the leaf is sequenced (the
89
+ * checkpoint that includes the leaf is sealed reactively), so `issuedAt > leafTime`
90
+ * is normal and correct — the cert is never *used* to sign before it exists.
91
+ * Enforcing `issuedAt ≤ leafTime` false-rejects legitimate receipts (verified on
92
+ * lane A: a fresh bootstrap receipt has `issuedAt − leafTime ≈ 3s`).
93
+ */
94
+ export declare function checkDelegationConstraints(constraints: DelegationConstraints, leafMmrIndex: bigint, leafIdtimestamp: bigint): {
95
+ ok: true;
96
+ } | {
97
+ ok: false;
98
+ reason: string;
99
+ };
42
100
  /**
43
101
  * Resolve the delegated verify key from a receipt's delegation certificate.
44
102
  *
@@ -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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;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"}
@@ -6,9 +6,12 @@
6
6
  * trust root and returns the delegated public key as an additional verify
7
7
  * candidate — the offline port of canopy-api's `resolveReceiptVerifyKey`.
8
8
  *
9
- * ES256 (P-256) only: the offline verifier is ES256-only (genesis alg), and
10
- * sealer delegate keys are always secp256r1. KS256-rooted delegation is a
11
- * server-only concern.
9
+ * P-256 roots only: the genesis alg is ES256 and sealer delegate keys are
10
+ * always secp256r1; KS256-rooted delegation is a server-only concern. A
11
+ * passkey (WebAuthn) root signs the certificate with alg -65800 and the
12
+ * ADR-0063 unprotected-header envelope — `verifyCoseSign1WithParsedKey`
13
+ * verifies that envelope against the same P-256 root keys, so the chain walk
14
+ * here is alg-agnostic (plan-2608-13 1.4).
12
15
  *
13
16
  * Trust anchors (FOR-297 trust ladder): `rootKeys` need not be genesis-derived
14
17
  * — a caller-known log OWNER key works identically (see
@@ -20,10 +23,16 @@
20
23
  * (approach A, open) will derive per-log owner keys from genesis + public
21
24
  * tiles, closing that gap without key distribution.
22
25
  *
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).
26
+ * Constraint enforcement (FOR-420): the certificate authorizes the delegated
27
+ * key only over an MMR coverage window `[mmrStart, mmrEnd]` (payload labels 3/4)
28
+ * and until `expiresAt` (label 9, Unix seconds). {@link checkDelegationConstraints}
29
+ * enforces the soundly-offline-decidable slice against the verified leaf: the
30
+ * `mmrEnd` over-horizon bound (the leaf's index lower-bounds the checkpoint
31
+ * `treeSize-1` the on-chain `delegationVerifier.sol` binds) and expiry-at-issuance
32
+ * against the leaf's snowflake idtimestamp (never wall-clock — receipts must
33
+ * verify forever). The `mmrStart` lower bound, the exact `size-1` bound, and the
34
+ * `issuedAt` lower bound are NOT enforced (see that function). This replaces the
35
+ * earlier "does not yet enforce the window" gap.
27
36
  */
28
37
  import { coseUnprotectedToMap, decodeCborDeterministic, decodeCoseSign1, verifyCoseSign1WithParsedKey, } from "@forestrie/encoding";
29
38
  /** Unprotected header label carrying the delegation certificate. */
@@ -35,8 +44,26 @@ const COSE_KEY_X = -2;
35
44
  const COSE_KEY_Y = -3;
36
45
  const COSE_KTY_EC2 = 2;
37
46
  const COSE_CRV_P256 = 1;
38
- /** Delegation-cert payload label for the delegated COSE_Key. */
47
+ /** Delegation-cert payload labels (must match @forestrie/delegation-cose). */
48
+ const PAYLOAD_MMR_START = 3;
49
+ const PAYLOAD_MMR_END = 4;
39
50
  const PAYLOAD_DELEGATED_KEY = 5;
51
+ const PAYLOAD_ISSUED_AT = 8;
52
+ const PAYLOAD_EXPIRES_AT = 9;
53
+ /**
54
+ * Snowflake idtimestamp → Unix seconds (DataTrails scheme; arbor
55
+ * `snowflakeid`). `TimeShift = 24`, per-epoch span `2^40 - 1` ms, current
56
+ * `CommitmentEpoch = 1` (next epoch ~2038). `unixMs = epoch*(2^40-1) +
57
+ * (id >> 24)`.
58
+ */
59
+ const COMMITMENT_EPOCH = 1n;
60
+ const SNOWFLAKE_TIME_SHIFT = 24n;
61
+ const SNOWFLAKE_EPOCH_SPAN_MS = (1n << 40n) - 1n;
62
+ export function idtimestampToUnixSeconds(idtimestamp) {
63
+ const ms = COMMITMENT_EPOCH * SNOWFLAKE_EPOCH_SPAN_MS +
64
+ (idtimestamp >> SNOWFLAKE_TIME_SHIFT);
65
+ return Number(ms / 1000n);
66
+ }
40
67
  function extractDelegationCertBytes(unprotected) {
41
68
  const umap = coseUnprotectedToMap(unprotected);
42
69
  const certRaw = umap.get(DELEGATION_CERT_LABEL);
@@ -84,19 +111,91 @@ async function importDelegatedKey(keyMap) {
84
111
  return null;
85
112
  }
86
113
  }
87
- function extractDelegatedKeyFromPayload(payloadBytes) {
114
+ function decodeCertPayloadMap(payloadBytes) {
88
115
  if (!payloadBytes || payloadBytes.length === 0)
89
116
  return null;
90
- let payloadMap;
91
117
  try {
92
- payloadMap = decodeCborDeterministic(payloadBytes);
118
+ return decodeCborDeterministic(payloadBytes);
93
119
  }
94
120
  catch {
95
121
  return null;
96
122
  }
123
+ }
124
+ function extractDelegatedKeyFromPayloadMap(payloadMap) {
97
125
  const get = labelGetter(payloadMap);
98
126
  return get ? get(PAYLOAD_DELEGATED_KEY) : null;
99
127
  }
128
+ function toBigIntOrNull(v) {
129
+ if (typeof v === "bigint")
130
+ return v;
131
+ if (typeof v === "number" && Number.isInteger(v))
132
+ return BigInt(v);
133
+ return null;
134
+ }
135
+ function toNumberOrNull(v) {
136
+ if (typeof v === "bigint")
137
+ return Number(v);
138
+ if (typeof v === "number" && Number.isFinite(v))
139
+ return v;
140
+ return null;
141
+ }
142
+ /**
143
+ * Parse the cert's coverage/validity window from its payload map. Returns null
144
+ * when any of labels 3/4/8/9 is absent (legacy certs predating FOR-390 advance
145
+ * delegation) — enforcement is then skipped, preserving pre-constraint trust.
146
+ */
147
+ function parseDelegationConstraints(payloadMap) {
148
+ const get = labelGetter(payloadMap);
149
+ if (!get)
150
+ return null;
151
+ const mmrStart = toBigIntOrNull(get(PAYLOAD_MMR_START));
152
+ const mmrEnd = toBigIntOrNull(get(PAYLOAD_MMR_END));
153
+ const issuedAt = toNumberOrNull(get(PAYLOAD_ISSUED_AT));
154
+ const expiresAt = toNumberOrNull(get(PAYLOAD_EXPIRES_AT));
155
+ if (mmrStart === null || mmrEnd === null)
156
+ return null;
157
+ if (issuedAt === null || expiresAt === null)
158
+ return null;
159
+ return { mmrStart, mmrEnd, issuedAt, expiresAt };
160
+ }
161
+ /**
162
+ * Enforce a resolved cert's constraints against the verified leaf (FOR-420).
163
+ *
164
+ * Coverage: the delegation authorizes checkpoint positions `treeSize-1 ∈
165
+ * [mmrStart, mmrEnd]` (inclusive, `delegationVerifier.sol`). Offline we hold the
166
+ * verified leaf's `mmrIndex`, and `leafMmrIndex ≤ treeSize-1` (the leaf is
167
+ * included in the checkpoint). So `leafMmrIndex > mmrEnd` SOUNDLY implies
168
+ * `treeSize-1 > mmrEnd` — a key signing beyond its authorized horizon — and is
169
+ * rejected. The `mmrStart` lower bound is deliberately NOT enforced here: an
170
+ * early leaf can legitimately appear in a checkpoint whose `size-1 ≥ mmrStart`,
171
+ * so `leafMmrIndex < mmrStart` does not imply a violation and enforcing it would
172
+ * false-reject valid receipts under a narrow cert. The lower bound and the exact
173
+ * `size-1` upper bound need the checkpoint accumulator (absent in the single-peak
174
+ * offline path) — deferred; in practice lane certs are wide (`mmrStart=0`).
175
+ *
176
+ * Expiry-at-issuance: the leaf's time (from its snowflake idtimestamp) must not
177
+ * be AFTER the cert's `expiresAt` — a key signing past its authorized lifetime.
178
+ * Compared against the checkpoint/leaf time, NOT wall-clock, so a valid receipt
179
+ * verifies forever (the pinned FOR-297 / ADR-0050 requirement).
180
+ *
181
+ * The `issuedAt` lower bound is deliberately NOT enforced. `issuedAt` is when the
182
+ * cert was SIGNED; the leaf's idtimestamp is when it was SEQUENCED. On live lanes
183
+ * the delegation cert is signed a few seconds AFTER the leaf is sequenced (the
184
+ * checkpoint that includes the leaf is sealed reactively), so `issuedAt > leafTime`
185
+ * is normal and correct — the cert is never *used* to sign before it exists.
186
+ * Enforcing `issuedAt ≤ leafTime` false-rejects legitimate receipts (verified on
187
+ * lane A: a fresh bootstrap receipt has `issuedAt − leafTime ≈ 3s`).
188
+ */
189
+ export function checkDelegationConstraints(constraints, leafMmrIndex, leafIdtimestamp) {
190
+ if (leafMmrIndex > constraints.mmrEnd) {
191
+ return { ok: false, reason: "delegation_out_of_range" };
192
+ }
193
+ const t = idtimestampToUnixSeconds(leafIdtimestamp);
194
+ if (t > constraints.expiresAt) {
195
+ return { ok: false, reason: "delegation_expired" };
196
+ }
197
+ return { ok: true };
198
+ }
100
199
  /**
101
200
  * Resolve the delegated verify key from a receipt's delegation certificate.
102
201
  *
@@ -124,9 +223,13 @@ export async function resolveDelegatedVerifyKey(receiptCbor, rootKeys) {
124
223
  const certDecoded = decodeCoseSign1(certBytes);
125
224
  if (!certDecoded)
126
225
  return { kind: "broken" };
127
- const keyRaw = extractDelegatedKeyFromPayload(certDecoded.payloadBstr);
226
+ const payloadMap = decodeCertPayloadMap(certDecoded.payloadBstr);
227
+ const keyRaw = payloadMap
228
+ ? extractDelegatedKeyFromPayloadMap(payloadMap)
229
+ : null;
128
230
  const delegatedKey = keyRaw ? await importDelegatedKey(keyRaw) : null;
129
231
  if (!delegatedKey)
130
232
  return { kind: "broken" };
131
- return { kind: "resolved", delegatedKey };
233
+ const constraints = parseDelegationConstraints(payloadMap);
234
+ return { kind: "resolved", delegatedKey, constraints };
132
235
  }
@@ -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.9.0",
3
+ "version": "0.11.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "Offline SCITT grant receipt verification (ADR-0045)",
@@ -28,9 +28,9 @@
28
28
  ],
29
29
  "dependencies": {
30
30
  "@noble/hashes": "^1.7.1",
31
- "@forestrie/merklelog": "0.3.0",
32
- "@forestrie/chain-rpc": "0.1.1",
33
- "@forestrie/encoding": "0.5.0"
31
+ "@forestrie/encoding": "0.6.0",
32
+ "@forestrie/chain-rpc": "0.2.0",
33
+ "@forestrie/merklelog": "0.3.0"
34
34
  },
35
35
  "devDependencies": {
36
36
  "esbuild": "^0.24.0",
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";
@@ -50,7 +50,9 @@ import type { ReceiptVerifyResult } from "./receipt-verify-result.js";
50
50
  function toContractLogId(logId: string): string {
51
51
  const hex = logId.replace(/-/g, "").replace(/^0x/, "").toLowerCase();
52
52
  if (!/^[0-9a-f]{32}$/.test(hex) && !/^[0-9a-f]{64}$/.test(hex)) {
53
- throw new Error(`log id must be a UUID or 16/32-byte hex id, got '${logId}'`);
53
+ throw new Error(
54
+ `log id must be a UUID or 16/32-byte hex id, got '${logId}'`,
55
+ );
54
56
  }
55
57
  return "0x" + hex.padStart(64, "0");
56
58
  }
@@ -247,7 +249,12 @@ export async function verifyReceiptOfflineAgainstKnownAccumulator(input: {
247
249
  } else {
248
250
  const hasher = new SubtleHasher();
249
251
  const leafHash = await univocityLeafHash(input.idtimestampBe8, input.inner);
250
- recomputedPeak = await calculateRoot(hasher, leafHash, parsed.proof, leafIdx);
252
+ recomputedPeak = await calculateRoot(
253
+ hasher,
254
+ leafHash,
255
+ parsed.proof,
256
+ leafIdx,
257
+ );
251
258
  }
252
259
 
253
260
  // 2. Exact peak match — receipt state is a snapshot-covered accumulator.
@@ -6,9 +6,12 @@
6
6
  * trust root and returns the delegated public key as an additional verify
7
7
  * candidate — the offline port of canopy-api's `resolveReceiptVerifyKey`.
8
8
  *
9
- * ES256 (P-256) only: the offline verifier is ES256-only (genesis alg), and
10
- * sealer delegate keys are always secp256r1. KS256-rooted delegation is a
11
- * server-only concern.
9
+ * P-256 roots only: the genesis alg is ES256 and sealer delegate keys are
10
+ * always secp256r1; KS256-rooted delegation is a server-only concern. A
11
+ * passkey (WebAuthn) root signs the certificate with alg -65800 and the
12
+ * ADR-0063 unprotected-header envelope — `verifyCoseSign1WithParsedKey`
13
+ * verifies that envelope against the same P-256 root keys, so the chain walk
14
+ * here is alg-agnostic (plan-2608-13 1.4).
12
15
  *
13
16
  * Trust anchors (FOR-297 trust ladder): `rootKeys` need not be genesis-derived
14
17
  * — a caller-known log OWNER key works identically (see
@@ -20,10 +23,16 @@
20
23
  * (approach A, open) will derive per-log owner keys from genesis + public
21
24
  * tiles, closing that gap without key distribution.
22
25
  *
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).
26
+ * Constraint enforcement (FOR-420): the certificate authorizes the delegated
27
+ * key only over an MMR coverage window `[mmrStart, mmrEnd]` (payload labels 3/4)
28
+ * and until `expiresAt` (label 9, Unix seconds). {@link checkDelegationConstraints}
29
+ * enforces the soundly-offline-decidable slice against the verified leaf: the
30
+ * `mmrEnd` over-horizon bound (the leaf's index lower-bounds the checkpoint
31
+ * `treeSize-1` the on-chain `delegationVerifier.sol` binds) and expiry-at-issuance
32
+ * against the leaf's snowflake idtimestamp (never wall-clock — receipts must
33
+ * verify forever). The `mmrStart` lower bound, the exact `size-1` bound, and the
34
+ * `issuedAt` lower bound are NOT enforced (see that function). This replaces the
35
+ * earlier "does not yet enforce the window" gap.
27
36
  */
28
37
 
29
38
  import {
@@ -44,18 +53,57 @@ const COSE_KEY_Y = -3;
44
53
  const COSE_KTY_EC2 = 2;
45
54
  const COSE_CRV_P256 = 1;
46
55
 
47
- /** Delegation-cert payload label for the delegated COSE_Key. */
56
+ /** Delegation-cert payload labels (must match @forestrie/delegation-cose). */
57
+ const PAYLOAD_MMR_START = 3;
58
+ const PAYLOAD_MMR_END = 4;
48
59
  const PAYLOAD_DELEGATED_KEY = 5;
60
+ const PAYLOAD_ISSUED_AT = 8;
61
+ const PAYLOAD_EXPIRES_AT = 9;
62
+
63
+ /**
64
+ * Snowflake idtimestamp → Unix seconds (DataTrails scheme; arbor
65
+ * `snowflakeid`). `TimeShift = 24`, per-epoch span `2^40 - 1` ms, current
66
+ * `CommitmentEpoch = 1` (next epoch ~2038). `unixMs = epoch*(2^40-1) +
67
+ * (id >> 24)`.
68
+ */
69
+ const COMMITMENT_EPOCH = 1n;
70
+ const SNOWFLAKE_TIME_SHIFT = 24n;
71
+ const SNOWFLAKE_EPOCH_SPAN_MS = (1n << 40n) - 1n;
72
+
73
+ export function idtimestampToUnixSeconds(idtimestamp: bigint): number {
74
+ const ms =
75
+ COMMITMENT_EPOCH * SNOWFLAKE_EPOCH_SPAN_MS +
76
+ (idtimestamp >> SNOWFLAKE_TIME_SHIFT);
77
+ return Number(ms / 1000n);
78
+ }
79
+
80
+ /**
81
+ * Coverage + validity window a root-signed delegation cert imposes on its
82
+ * delegated key. `mmrStart`/`mmrEnd` are inclusive MMR-index bounds;
83
+ * `issuedAt`/`expiresAt` are Unix seconds.
84
+ */
85
+ export type DelegationConstraints = {
86
+ mmrStart: bigint;
87
+ mmrEnd: bigint;
88
+ issuedAt: number;
89
+ expiresAt: number;
90
+ };
49
91
 
50
92
  /**
51
93
  * root-only: no delegation cert present — verify against the root keys as-is.
52
94
  * resolved: cert verified under a root key; use `delegatedKey` first.
95
+ * `constraints` carries the cert's coverage/validity window when the payload
96
+ * declares it (null for legacy certs that predate labels 3/4/8/9).
53
97
  * broken: a cert is present but did not verify under the root, or its delegated
54
98
  * key could not be parsed — the delegation chain is invalid.
55
99
  */
56
100
  export type DelegatedResolution =
57
101
  | { kind: "root-only" }
58
- | { kind: "resolved"; delegatedKey: CryptoKey }
102
+ | {
103
+ kind: "resolved";
104
+ delegatedKey: CryptoKey;
105
+ constraints: DelegationConstraints | null;
106
+ }
59
107
  | { kind: "broken" };
60
108
 
61
109
  function extractDelegationCertBytes(unprotected: unknown): Uint8Array | null {
@@ -111,20 +159,96 @@ async function importDelegatedKey(keyMap: unknown): Promise<CryptoKey | null> {
111
159
  }
112
160
  }
113
161
 
114
- function extractDelegatedKeyFromPayload(
115
- payloadBytes: Uint8Array,
116
- ): unknown | null {
162
+ function decodeCertPayloadMap(payloadBytes: Uint8Array): unknown | null {
117
163
  if (!payloadBytes || payloadBytes.length === 0) return null;
118
- let payloadMap: unknown;
119
164
  try {
120
- payloadMap = decodeCborDeterministic(payloadBytes);
165
+ return decodeCborDeterministic(payloadBytes);
121
166
  } catch {
122
167
  return null;
123
168
  }
169
+ }
170
+
171
+ function extractDelegatedKeyFromPayloadMap(
172
+ payloadMap: unknown,
173
+ ): unknown | null {
124
174
  const get = labelGetter(payloadMap);
125
175
  return get ? get(PAYLOAD_DELEGATED_KEY) : null;
126
176
  }
127
177
 
178
+ function toBigIntOrNull(v: unknown): bigint | null {
179
+ if (typeof v === "bigint") return v;
180
+ if (typeof v === "number" && Number.isInteger(v)) return BigInt(v);
181
+ return null;
182
+ }
183
+
184
+ function toNumberOrNull(v: unknown): number | null {
185
+ if (typeof v === "bigint") return Number(v);
186
+ if (typeof v === "number" && Number.isFinite(v)) return v;
187
+ return null;
188
+ }
189
+
190
+ /**
191
+ * Parse the cert's coverage/validity window from its payload map. Returns null
192
+ * when any of labels 3/4/8/9 is absent (legacy certs predating FOR-390 advance
193
+ * delegation) — enforcement is then skipped, preserving pre-constraint trust.
194
+ */
195
+ function parseDelegationConstraints(
196
+ payloadMap: unknown,
197
+ ): DelegationConstraints | null {
198
+ const get = labelGetter(payloadMap);
199
+ if (!get) return null;
200
+ const mmrStart = toBigIntOrNull(get(PAYLOAD_MMR_START));
201
+ const mmrEnd = toBigIntOrNull(get(PAYLOAD_MMR_END));
202
+ const issuedAt = toNumberOrNull(get(PAYLOAD_ISSUED_AT));
203
+ const expiresAt = toNumberOrNull(get(PAYLOAD_EXPIRES_AT));
204
+ if (mmrStart === null || mmrEnd === null) return null;
205
+ if (issuedAt === null || expiresAt === null) return null;
206
+ return { mmrStart, mmrEnd, issuedAt, expiresAt };
207
+ }
208
+
209
+ /**
210
+ * Enforce a resolved cert's constraints against the verified leaf (FOR-420).
211
+ *
212
+ * Coverage: the delegation authorizes checkpoint positions `treeSize-1 ∈
213
+ * [mmrStart, mmrEnd]` (inclusive, `delegationVerifier.sol`). Offline we hold the
214
+ * verified leaf's `mmrIndex`, and `leafMmrIndex ≤ treeSize-1` (the leaf is
215
+ * included in the checkpoint). So `leafMmrIndex > mmrEnd` SOUNDLY implies
216
+ * `treeSize-1 > mmrEnd` — a key signing beyond its authorized horizon — and is
217
+ * rejected. The `mmrStart` lower bound is deliberately NOT enforced here: an
218
+ * early leaf can legitimately appear in a checkpoint whose `size-1 ≥ mmrStart`,
219
+ * so `leafMmrIndex < mmrStart` does not imply a violation and enforcing it would
220
+ * false-reject valid receipts under a narrow cert. The lower bound and the exact
221
+ * `size-1` upper bound need the checkpoint accumulator (absent in the single-peak
222
+ * offline path) — deferred; in practice lane certs are wide (`mmrStart=0`).
223
+ *
224
+ * Expiry-at-issuance: the leaf's time (from its snowflake idtimestamp) must not
225
+ * be AFTER the cert's `expiresAt` — a key signing past its authorized lifetime.
226
+ * Compared against the checkpoint/leaf time, NOT wall-clock, so a valid receipt
227
+ * verifies forever (the pinned FOR-297 / ADR-0050 requirement).
228
+ *
229
+ * The `issuedAt` lower bound is deliberately NOT enforced. `issuedAt` is when the
230
+ * cert was SIGNED; the leaf's idtimestamp is when it was SEQUENCED. On live lanes
231
+ * the delegation cert is signed a few seconds AFTER the leaf is sequenced (the
232
+ * checkpoint that includes the leaf is sealed reactively), so `issuedAt > leafTime`
233
+ * is normal and correct — the cert is never *used* to sign before it exists.
234
+ * Enforcing `issuedAt ≤ leafTime` false-rejects legitimate receipts (verified on
235
+ * lane A: a fresh bootstrap receipt has `issuedAt − leafTime ≈ 3s`).
236
+ */
237
+ export function checkDelegationConstraints(
238
+ constraints: DelegationConstraints,
239
+ leafMmrIndex: bigint,
240
+ leafIdtimestamp: bigint,
241
+ ): { ok: true } | { ok: false; reason: string } {
242
+ if (leafMmrIndex > constraints.mmrEnd) {
243
+ return { ok: false, reason: "delegation_out_of_range" };
244
+ }
245
+ const t = idtimestampToUnixSeconds(leafIdtimestamp);
246
+ if (t > constraints.expiresAt) {
247
+ return { ok: false, reason: "delegation_expired" };
248
+ }
249
+ return { ok: true };
250
+ }
251
+
128
252
  /**
129
253
  * Resolve the delegated verify key from a receipt's delegation certificate.
130
254
  *
@@ -157,9 +281,13 @@ export async function resolveDelegatedVerifyKey(
157
281
  const certDecoded = decodeCoseSign1(certBytes);
158
282
  if (!certDecoded) return { kind: "broken" };
159
283
 
160
- const keyRaw = extractDelegatedKeyFromPayload(certDecoded.payloadBstr);
284
+ const payloadMap = decodeCertPayloadMap(certDecoded.payloadBstr);
285
+ const keyRaw = payloadMap
286
+ ? extractDelegatedKeyFromPayloadMap(payloadMap)
287
+ : null;
161
288
  const delegatedKey = keyRaw ? await importDelegatedKey(keyRaw) : null;
162
289
  if (!delegatedKey) return { kind: "broken" };
163
290
 
164
- return { kind: "resolved", delegatedKey };
291
+ const constraints = parseDelegationConstraints(payloadMap);
292
+ return { kind: "resolved", delegatedKey, constraints };
165
293
  }
@@ -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({