@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 +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/known-accumulator.d.ts.map +1 -1
- package/dist/resolve-delegated-verify-key.d.ts +65 -7
- package/dist/resolve-delegated-verify-key.d.ts.map +1 -1
- package/dist/resolve-delegated-verify-key.js +116 -13
- package/dist/verify-grant-receipt-offline.d.ts.map +1 -1
- package/dist/verify-grant-receipt-offline.js +12 -1
- package/package.json +4 -4
- package/src/index.ts +9 -2
- package/src/known-accumulator.ts +9 -2
- package/src/resolve-delegated-verify-key.ts +144 -16
- package/src/verify-grant-receipt-offline.ts +20 -1
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";
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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,
|
|
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;
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* the
|
|
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
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* the
|
|
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
|
|
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
|
|
114
|
+
function decodeCertPayloadMap(payloadBytes) {
|
|
88
115
|
if (!payloadBytes || payloadBytes.length === 0)
|
|
89
116
|
return null;
|
|
90
|
-
let payloadMap;
|
|
91
117
|
try {
|
|
92
|
-
|
|
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
|
|
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
|
-
|
|
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;
|
|
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.
|
|
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/
|
|
32
|
-
"@forestrie/chain-rpc": "0.
|
|
33
|
-
"@forestrie/
|
|
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 {
|
|
51
|
-
|
|
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";
|
package/src/known-accumulator.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* the
|
|
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
|
|
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
|
-
| {
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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({
|