@forestrie/receipt-verify 0.6.0 → 0.6.2

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
@@ -2,6 +2,7 @@ export type { ReceiptVerifyResult, ReceiptVerifyStage, } from "./receipt-verify-
2
2
  /** Canonical grant wire type — owned by @forestrie/encoding (FOR-353, ADR-0048). */
3
3
  export type { Grant } from "@forestrie/encoding";
4
4
  export type { VerifyGrantReceiptOfflineInput } from "./verify-grant-receipt-offline.js";
5
+ export type { VerifyReceiptOfflineInput } from "./verify-grant-receipt-offline.js";
5
6
  export { parseReceipt } from "./parse-receipt.js";
6
7
  export type { BuildReceiptOfflineInput, ComputedAccumulatorPeak, MassifNodeStore, ParsedCheckpoint, } from "./build-receipt-offline.js";
7
8
  export { buildReceiptOffline, computeAccumulatorPeak, openMassifNodeStore, parseCheckpoint, } from "./build-receipt-offline.js";
@@ -12,6 +13,18 @@ export { buildReceiptOffline, computeAccumulatorPeak, openMassifNodeStore, parse
12
13
  export { peakMMRIndexes } from "@forestrie/merklelog";
13
14
  export { decodeTrustRootFromGenesis } from "./decode-trust-root-from-genesis.js";
14
15
  export { verifyGrantReceiptOffline } from "./verify-grant-receipt-offline.js";
16
+ export { verifyReceiptOffline } from "./verify-grant-receipt-offline.js";
17
+ /**
18
+ * Caller-supplied trust anchors (FOR-297 "known log key"): verify offline
19
+ * under keys the caller trusts out of band instead of the genesis trust root.
20
+ * See the trust-ladder notes on verifyReceiptOfflineWithKeys.
21
+ */
22
+ export { verifyGrantReceiptOfflineWithKeys, verifyReceiptOfflineWithKeys, } from "./verify-grant-receipt-offline.js";
23
+ export type { VerifyGrantReceiptOfflineWithKeysInput, VerifyReceiptOfflineWithKeysInput, } from "./verify-grant-receipt-offline.js";
24
+ export { resolveDelegatedVerifyKey } from "./resolve-delegated-verify-key.js";
25
+ export type { DelegatedResolution } from "./resolve-delegated-verify-key.js";
26
+ /** Import a raw 64-byte x||y P-256 public key as an ES256 verify key. */
27
+ export { importEs256PublicKeyFromGrantDataXy64 } from "./decode-trust-root-cbor.js";
15
28
  export { decodeForestrieGrantCose } from "./decode-forestrie-grant-cose.js";
16
29
  export { decodeGrantPayload, decodeGrantResponse } from "./grant-codec.js";
17
30
  /** Deterministic receipt construction (plan-2607-12 Phase 2, FOR-353). */
@@ -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,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAClD,YAAY,EACV,wBAAwB,EACxB,uBAAuB,EACvB,eAAe,EACf,gBAAgB,GACjB,MAAM,4BAA4B,CAAC;AACpC,OAAO,EACL,mBAAmB,EACnB,sBAAsB,EACtB,mBAAmB,EACnB,eAAe,GAChB,MAAM,4BAA4B,CAAC;AACpC;;;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,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"}
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,mBAAmB,EACnB,sBAAsB,EACtB,mBAAmB,EACnB,eAAe,GAChB,MAAM,4BAA4B,CAAC;AACpC;;;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"}
package/dist/index.js CHANGED
@@ -7,6 +7,16 @@ export { buildReceiptOffline, computeAccumulatorPeak, openMassifNodeStore, parse
7
7
  export { peakMMRIndexes } from "@forestrie/merklelog";
8
8
  export { decodeTrustRootFromGenesis } from "./decode-trust-root-from-genesis.js";
9
9
  export { verifyGrantReceiptOffline } from "./verify-grant-receipt-offline.js";
10
+ export { verifyReceiptOffline } from "./verify-grant-receipt-offline.js";
11
+ /**
12
+ * Caller-supplied trust anchors (FOR-297 "known log key"): verify offline
13
+ * under keys the caller trusts out of band instead of the genesis trust root.
14
+ * See the trust-ladder notes on verifyReceiptOfflineWithKeys.
15
+ */
16
+ export { verifyGrantReceiptOfflineWithKeys, verifyReceiptOfflineWithKeys, } from "./verify-grant-receipt-offline.js";
17
+ export { resolveDelegatedVerifyKey } from "./resolve-delegated-verify-key.js";
18
+ /** Import a raw 64-byte x||y P-256 public key as an ES256 verify key. */
19
+ export { importEs256PublicKeyFromGrantDataXy64 } from "./decode-trust-root-cbor.js";
10
20
  export { decodeForestrieGrantCose } from "./decode-forestrie-grant-cose.js";
11
21
  export { decodeGrantPayload, decodeGrantResponse } from "./grant-codec.js";
12
22
  /** Deterministic receipt construction (plan-2607-12 Phase 2, FOR-353). */
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Offline delegation-chain resolution (FOR-297). When a checkpoint/receipt was
3
+ * signed by a DELEGATED key rather than the log's root key, the delegation
4
+ * certificate travels in the receipt's unprotected header at label 1000
5
+ * (sealer-embedded). This module verifies that certificate under the genesis
6
+ * trust root and returns the delegated public key as an additional verify
7
+ * candidate — the offline port of canopy-api's `resolveReceiptVerifyKey`.
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.
12
+ *
13
+ * Trust anchors (FOR-297 trust ladder): `rootKeys` need not be genesis-derived
14
+ * — a caller-known log OWNER key works identically (see
15
+ * `verifyReceiptOfflineWithKeys`), because the certificate issuer for a child
16
+ * log IS its owner. What differs is why you trust the keys: genesis-derived
17
+ * keys prove the key↔log binding from the bootstrap; a caller-known key merely
18
+ * asserts it (provenance of the key is the only defence, and there is no grant
19
+ * lifecycle visibility or split-view protection). The grant-chain walk
20
+ * (approach A, open) will derive per-log owner keys from genesis + public
21
+ * tiles, closing that gap without key distribution.
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).
27
+ */
28
+ /**
29
+ * root-only: no delegation cert present — verify against the root keys as-is.
30
+ * resolved: cert verified under a root key; use `delegatedKey` first.
31
+ * broken: a cert is present but did not verify under the root, or its delegated
32
+ * key could not be parsed — the delegation chain is invalid.
33
+ */
34
+ export type DelegatedResolution = {
35
+ kind: "root-only";
36
+ } | {
37
+ kind: "resolved";
38
+ delegatedKey: CryptoKey;
39
+ } | {
40
+ kind: "broken";
41
+ };
42
+ /**
43
+ * Resolve the delegated verify key from a receipt's delegation certificate.
44
+ *
45
+ * @param receiptCbor - the COSE_Sign1 receipt bytes.
46
+ * @param rootKeys - genesis-derived ES256 root verify keys.
47
+ */
48
+ export declare function resolveDelegatedVerifyKey(receiptCbor: Uint8Array, rootKeys: CryptoKey[]): Promise<DelegatedResolution>;
49
+ //# sourceMappingURL=resolve-delegated-verify-key.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Offline delegation-chain resolution (FOR-297). When a checkpoint/receipt was
3
+ * signed by a DELEGATED key rather than the log's root key, the delegation
4
+ * certificate travels in the receipt's unprotected header at label 1000
5
+ * (sealer-embedded). This module verifies that certificate under the genesis
6
+ * trust root and returns the delegated public key as an additional verify
7
+ * candidate — the offline port of canopy-api's `resolveReceiptVerifyKey`.
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.
12
+ *
13
+ * Trust anchors (FOR-297 trust ladder): `rootKeys` need not be genesis-derived
14
+ * — a caller-known log OWNER key works identically (see
15
+ * `verifyReceiptOfflineWithKeys`), because the certificate issuer for a child
16
+ * log IS its owner. What differs is why you trust the keys: genesis-derived
17
+ * keys prove the key↔log binding from the bootstrap; a caller-known key merely
18
+ * asserts it (provenance of the key is the only defence, and there is no grant
19
+ * lifecycle visibility or split-view protection). The grant-chain walk
20
+ * (approach A, open) will derive per-log owner keys from genesis + public
21
+ * tiles, closing that gap without key distribution.
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).
27
+ */
28
+ import { coseUnprotectedToMap, decodeCborDeterministic, decodeCoseSign1, verifyCoseSign1WithParsedKey, } from "@forestrie/encoding";
29
+ /** Unprotected header label carrying the delegation certificate. */
30
+ const DELEGATION_CERT_LABEL = 1000;
31
+ /** COSE_Key labels (RFC 9052) and EC2/P-256 constants. */
32
+ const COSE_KEY_KTY = 1;
33
+ const COSE_KEY_CRV = -1;
34
+ const COSE_KEY_X = -2;
35
+ const COSE_KEY_Y = -3;
36
+ const COSE_KTY_EC2 = 2;
37
+ const COSE_CRV_P256 = 1;
38
+ /** Delegation-cert payload label for the delegated COSE_Key. */
39
+ const PAYLOAD_DELEGATED_KEY = 5;
40
+ function extractDelegationCertBytes(unprotected) {
41
+ const umap = coseUnprotectedToMap(unprotected);
42
+ const certRaw = umap.get(DELEGATION_CERT_LABEL);
43
+ return certRaw instanceof Uint8Array && certRaw.length > 0 ? certRaw : null;
44
+ }
45
+ function labelGetter(map) {
46
+ if (map instanceof Map)
47
+ return (label) => map.get(label);
48
+ if (typeof map === "object" && map !== null) {
49
+ const obj = map;
50
+ return (label) => obj[label] ?? obj[String(label)];
51
+ }
52
+ return null;
53
+ }
54
+ function parseCoseKeyEC2(keyMap) {
55
+ const get = labelGetter(keyMap);
56
+ if (!get)
57
+ return null;
58
+ const kty = get(COSE_KEY_KTY);
59
+ if (kty !== COSE_KTY_EC2 && kty !== BigInt(COSE_KTY_EC2))
60
+ return null;
61
+ const crv = get(COSE_KEY_CRV);
62
+ if (crv !== COSE_CRV_P256 && crv !== BigInt(COSE_CRV_P256))
63
+ return null;
64
+ const x = get(COSE_KEY_X);
65
+ const y = get(COSE_KEY_Y);
66
+ if (!(x instanceof Uint8Array) || x.length !== 32)
67
+ return null;
68
+ if (!(y instanceof Uint8Array) || y.length !== 32)
69
+ return null;
70
+ return { x, y };
71
+ }
72
+ async function importDelegatedKey(keyMap) {
73
+ const parsed = parseCoseKeyEC2(keyMap);
74
+ if (!parsed)
75
+ return null;
76
+ const uncompressed = new Uint8Array(65);
77
+ uncompressed[0] = 0x04;
78
+ uncompressed.set(parsed.x, 1);
79
+ uncompressed.set(parsed.y, 33);
80
+ try {
81
+ return await crypto.subtle.importKey("raw", uncompressed, { name: "ECDSA", namedCurve: "P-256" }, true, ["verify"]);
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ }
87
+ function extractDelegatedKeyFromPayload(payloadBytes) {
88
+ if (!payloadBytes || payloadBytes.length === 0)
89
+ return null;
90
+ let payloadMap;
91
+ try {
92
+ payloadMap = decodeCborDeterministic(payloadBytes);
93
+ }
94
+ catch {
95
+ return null;
96
+ }
97
+ const get = labelGetter(payloadMap);
98
+ return get ? get(PAYLOAD_DELEGATED_KEY) : null;
99
+ }
100
+ /**
101
+ * Resolve the delegated verify key from a receipt's delegation certificate.
102
+ *
103
+ * @param receiptCbor - the COSE_Sign1 receipt bytes.
104
+ * @param rootKeys - genesis-derived ES256 root verify keys.
105
+ */
106
+ export async function resolveDelegatedVerifyKey(receiptCbor, rootKeys) {
107
+ const decoded = decodeCoseSign1(receiptCbor);
108
+ if (!decoded)
109
+ return { kind: "root-only" };
110
+ const certBytes = extractDelegationCertBytes(decoded.unprotected);
111
+ if (!certBytes)
112
+ return { kind: "root-only" };
113
+ let verified = false;
114
+ for (const rootKey of rootKeys) {
115
+ if (await verifyCoseSign1WithParsedKey(certBytes, rootKey, {
116
+ logPrefix: "delegation-cert-offline",
117
+ })) {
118
+ verified = true;
119
+ break;
120
+ }
121
+ }
122
+ if (!verified)
123
+ return { kind: "broken" };
124
+ const certDecoded = decodeCoseSign1(certBytes);
125
+ if (!certDecoded)
126
+ return { kind: "broken" };
127
+ const keyRaw = extractDelegatedKeyFromPayload(certDecoded.payloadBstr);
128
+ const delegatedKey = keyRaw ? await importDelegatedKey(keyRaw) : null;
129
+ if (!delegatedKey)
130
+ return { kind: "broken" };
131
+ return { kind: "resolved", delegatedKey };
132
+ }
@@ -10,4 +10,61 @@ export type VerifyGrantReceiptOfflineInput = {
10
10
  * Offline grant receipt verify (layers A–C, ADR-0045). Pure over bytes; no network.
11
11
  */
12
12
  export declare function verifyGrantReceiptOffline(input: VerifyGrantReceiptOfflineInput): Promise<ReceiptVerifyResult>;
13
+ export type VerifyGrantReceiptOfflineWithKeysInput = Omit<VerifyGrantReceiptOfflineInput, "genesisCbor"> & {
14
+ /** Caller-known ES256 trust keys — see {@link VerifyReceiptOfflineWithKeysInput}. */
15
+ trustKeys: CryptoKey[];
16
+ };
17
+ /**
18
+ * {@link verifyGrantReceiptOffline} under caller-supplied trust keys instead of
19
+ * the genesis trust root — see {@link verifyReceiptOfflineWithKeys} for the
20
+ * trust model this buys (and what it does not).
21
+ */
22
+ export declare function verifyGrantReceiptOfflineWithKeys(input: VerifyGrantReceiptOfflineWithKeysInput): Promise<ReceiptVerifyResult>;
23
+ export type VerifyReceiptOfflineInput = {
24
+ genesisCbor: Uint8Array;
25
+ receiptCbor: Uint8Array;
26
+ /**
27
+ * The EXACT registered payload bytes whose SHA-256 is the leaf ContentHash.
28
+ * Kind-agnostic: for a SCITT signed statement this is the statement COSE
29
+ * bytes; for a forestrie grant leaf it is the grant commitment preimage
30
+ * (see {@link verifyGrantReceiptOffline}, which derives it).
31
+ */
32
+ payload: Uint8Array;
33
+ idtimestampBe8: Uint8Array;
34
+ };
35
+ /**
36
+ * Offline verify of a receipt against the EXACT registered payload. The log
37
+ * leaf commits `SHA-256(idtimestamp ‖ SHA-256(payload))`; this is the standard,
38
+ * COSE-Receipts-conformant path (a SCITT statement receipt is exactly this with
39
+ * `payload` = the signed statement). Genesis trust root, FOR-297 delegation
40
+ * resolution, inclusion, and signature are all standard.
41
+ */
42
+ export declare function verifyReceiptOffline(input: VerifyReceiptOfflineInput): Promise<ReceiptVerifyResult>;
43
+ export type VerifyReceiptOfflineWithKeysInput = Omit<VerifyReceiptOfflineInput, "genesisCbor"> & {
44
+ /**
45
+ * Caller-known ES256 trust keys, tried in order. For a child-log receipt
46
+ * this is the log OWNER's key (the delegation-cert issuer), NOT the sealer
47
+ * key — the label-1000 cert is verified under these keys and the delegated
48
+ * sealer key is extracted from it, so the anchor survives sealer rotation.
49
+ */
50
+ trustKeys: CryptoKey[];
51
+ };
52
+ /**
53
+ * {@link verifyReceiptOffline} under caller-supplied trust keys instead of the
54
+ * genesis trust root (FOR-297 "known log key"). Standard SCITT relying-party
55
+ * posture: the caller obtained the log owner's key out of band and trusts it.
56
+ *
57
+ * Trust ladder — what this rung gives up relative to its neighbours:
58
+ * - Known log key (this entry): fully offline, but the "key K owns log L"
59
+ * binding is ASSERTED by the caller's key provenance, not proven; no grant
60
+ * lifecycle/expiry visibility; no split-view protection.
61
+ * - Grant-chain walk (approach A, open): derives the binding from
62
+ * `genesis.cbor` + public tiles and adds lifecycle visibility.
63
+ * - Chain-anchored (live or cached accumulator): adds split-view protection —
64
+ * the contract-enforced on-chain accumulator is the strongest anchor.
65
+ *
66
+ * Do NOT fetch the trust key from the log operator's API or tile store — that
67
+ * silently reintroduces the operator trust this entry exists to remove.
68
+ */
69
+ export declare function verifyReceiptOfflineWithKeys(input: VerifyReceiptOfflineWithKeysInput): Promise<ReceiptVerifyResult>;
13
70
  //# sourceMappingURL=verify-grant-receipt-offline.d.ts.map
@@ -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;AAMjD,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,CA2C9B"}
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"}
@@ -3,6 +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
7
  import { univocityLeafHash } from "./leaf-commitment.js";
7
8
  import { parseReceipt } from "./parse-receipt.js";
8
9
  import { SubtleHasher } from "./subtle-hasher.js";
@@ -58,13 +59,91 @@ async function verifySignatureAndInclusion(opts) {
58
59
  * Offline grant receipt verify (layers A–C, ADR-0045). Pure over bytes; no network.
59
60
  */
60
61
  export async function verifyGrantReceiptOffline(input) {
61
- let parsed;
62
+ // A GRANT leaf commits the grant commitment hash (register-grant / create-log).
63
+ let inner;
62
64
  try {
63
- parsed = parseReceipt(input.receiptCbor);
65
+ inner = await grantCommitmentHashFromGrant(input.grant);
64
66
  }
65
67
  catch {
66
- return { ok: false, stage: "parse", reason: "receipt_malformed" };
68
+ return { ok: false, stage: "binding", reason: "grant_invalid" };
69
+ }
70
+ return verifyReceiptOfflineWithLeafInner({
71
+ genesisCbor: input.genesisCbor,
72
+ receiptCbor: input.receiptCbor,
73
+ idtimestampBe8: input.idtimestampBe8,
74
+ inner,
75
+ });
76
+ }
77
+ /**
78
+ * {@link verifyGrantReceiptOffline} under caller-supplied trust keys instead of
79
+ * the genesis trust root — see {@link verifyReceiptOfflineWithKeys} for the
80
+ * trust model this buys (and what it does not).
81
+ */
82
+ export async function verifyGrantReceiptOfflineWithKeys(input) {
83
+ let inner;
84
+ try {
85
+ inner = await grantCommitmentHashFromGrant(input.grant);
86
+ }
87
+ catch {
88
+ return { ok: false, stage: "binding", reason: "grant_invalid" };
67
89
  }
90
+ return verifyReceiptOfflineWithLeafInnerKeys({
91
+ receiptCbor: input.receiptCbor,
92
+ idtimestampBe8: input.idtimestampBe8,
93
+ inner,
94
+ trustKeys: input.trustKeys,
95
+ });
96
+ }
97
+ /**
98
+ * Offline verify of a receipt against the EXACT registered payload. The log
99
+ * leaf commits `SHA-256(idtimestamp ‖ SHA-256(payload))`; this is the standard,
100
+ * COSE-Receipts-conformant path (a SCITT statement receipt is exactly this with
101
+ * `payload` = the signed statement). Genesis trust root, FOR-297 delegation
102
+ * resolution, inclusion, and signature are all standard.
103
+ */
104
+ export async function verifyReceiptOffline(input) {
105
+ const inner = new Uint8Array(await crypto.subtle.digest("SHA-256", input.payload));
106
+ return verifyReceiptOfflineWithLeafInner({
107
+ genesisCbor: input.genesisCbor,
108
+ receiptCbor: input.receiptCbor,
109
+ idtimestampBe8: input.idtimestampBe8,
110
+ inner,
111
+ });
112
+ }
113
+ /**
114
+ * {@link verifyReceiptOffline} under caller-supplied trust keys instead of the
115
+ * genesis trust root (FOR-297 "known log key"). Standard SCITT relying-party
116
+ * posture: the caller obtained the log owner's key out of band and trusts it.
117
+ *
118
+ * Trust ladder — what this rung gives up relative to its neighbours:
119
+ * - Known log key (this entry): fully offline, but the "key K owns log L"
120
+ * binding is ASSERTED by the caller's key provenance, not proven; no grant
121
+ * lifecycle/expiry visibility; no split-view protection.
122
+ * - Grant-chain walk (approach A, open): derives the binding from
123
+ * `genesis.cbor` + public tiles and adds lifecycle visibility.
124
+ * - Chain-anchored (live or cached accumulator): adds split-view protection —
125
+ * the contract-enforced on-chain accumulator is the strongest anchor.
126
+ *
127
+ * Do NOT fetch the trust key from the log operator's API or tile store — that
128
+ * silently reintroduces the operator trust this entry exists to remove.
129
+ */
130
+ export async function verifyReceiptOfflineWithKeys(input) {
131
+ const inner = new Uint8Array(await crypto.subtle.digest("SHA-256", input.payload));
132
+ return verifyReceiptOfflineWithLeafInnerKeys({
133
+ receiptCbor: input.receiptCbor,
134
+ idtimestampBe8: input.idtimestampBe8,
135
+ inner,
136
+ trustKeys: input.trustKeys,
137
+ });
138
+ }
139
+ /**
140
+ * Shared offline-verify core: reconstruct the leaf as
141
+ * `univocityLeafHash(idtimestamp, inner)`, resolve the (possibly delegated,
142
+ * FOR-297) verify key from the genesis trust root, and check signature +
143
+ * inclusion. `inner` is the leaf ContentHash = `SHA-256(payload)` — the grant
144
+ * commitment for a grant leaf, or `SHA-256(statement)` for a statement leaf.
145
+ */
146
+ async function verifyReceiptOfflineWithLeafInner(input) {
68
147
  let trustRoot;
69
148
  try {
70
149
  trustRoot = await decodeTrustRootFromGenesis(input.genesisCbor);
@@ -76,13 +155,41 @@ export async function verifyGrantReceiptOffline(input) {
76
155
  if (!verifyKeys.length) {
77
156
  return { ok: false, stage: "signature", reason: "no_es256_trust_key" };
78
157
  }
79
- let inner;
158
+ return verifyReceiptOfflineWithLeafInnerKeys({
159
+ receiptCbor: input.receiptCbor,
160
+ idtimestampBe8: input.idtimestampBe8,
161
+ inner: input.inner,
162
+ trustKeys: verifyKeys,
163
+ });
164
+ }
165
+ /**
166
+ * Trust-key-parameterised offline-verify core. Identical to the genesis path
167
+ * once the trust keys are in hand: resolve the (possibly delegated, FOR-297)
168
+ * verify key against `trustKeys`, then check signature + inclusion.
169
+ */
170
+ async function verifyReceiptOfflineWithLeafInnerKeys(input) {
171
+ let parsed;
80
172
  try {
81
- inner = await grantCommitmentHashFromGrant(input.grant);
173
+ parsed = parseReceipt(input.receiptCbor);
82
174
  }
83
175
  catch {
84
- return { ok: false, stage: "binding", reason: "grant_invalid" };
176
+ return { ok: false, stage: "parse", reason: "receipt_malformed" };
177
+ }
178
+ if (!input.trustKeys.length) {
179
+ return { ok: false, stage: "signature", reason: "no_es256_trust_key" };
180
+ }
181
+ // FOR-297: when the receipt was signed by a DELEGATED key, verify the
182
+ // label-1000 delegation certificate under the trust keys and try the
183
+ // delegated key first. A cert present but not chaining to a trust key is a
184
+ // hard failure — do not silently fall back to the trust key (which cannot
185
+ // verify a delegated-signed receipt anyway).
186
+ const delegation = await resolveDelegatedVerifyKey(input.receiptCbor, input.trustKeys);
187
+ if (delegation.kind === "broken") {
188
+ return { ok: false, stage: "signature", reason: "delegation_invalid" };
85
189
  }
190
+ const allVerifyKeys = delegation.kind === "resolved"
191
+ ? [delegation.delegatedKey, ...input.trustKeys]
192
+ : input.trustKeys;
86
193
  let idtimestamp;
87
194
  try {
88
195
  idtimestamp = readIdtimestampBe8(input.idtimestampBe8);
@@ -90,12 +197,12 @@ export async function verifyGrantReceiptOffline(input) {
90
197
  catch {
91
198
  return { ok: false, stage: "binding", reason: "idtimestamp_invalid" };
92
199
  }
93
- const leafHash = await univocityLeafHash(idtimestamp, inner);
200
+ const leafHash = await univocityLeafHash(idtimestamp, input.inner);
94
201
  return verifySignatureAndInclusion({
95
202
  receiptCbor: input.receiptCbor,
96
203
  explicitPeak: parsed.explicitPeak,
97
204
  proof: parsed.proof,
98
205
  leafHash,
99
- verifyKeys: verifyKeys,
206
+ verifyKeys: allVerifyKeys,
100
207
  });
101
208
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forestrie/receipt-verify",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "Offline SCITT grant receipt verification (ADR-0045)",
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ export type {
5
5
  /** Canonical grant wire type — owned by @forestrie/encoding (FOR-353, ADR-0048). */
6
6
  export type { Grant } from "@forestrie/encoding";
7
7
  export type { VerifyGrantReceiptOfflineInput } from "./verify-grant-receipt-offline.js";
8
+ export type { VerifyReceiptOfflineInput } from "./verify-grant-receipt-offline.js";
8
9
  export { parseReceipt } from "./parse-receipt.js";
9
10
  export type {
10
11
  BuildReceiptOfflineInput,
@@ -25,6 +26,24 @@ export {
25
26
  export { peakMMRIndexes } from "@forestrie/merklelog";
26
27
  export { decodeTrustRootFromGenesis } from "./decode-trust-root-from-genesis.js";
27
28
  export { verifyGrantReceiptOffline } from "./verify-grant-receipt-offline.js";
29
+ export { verifyReceiptOffline } from "./verify-grant-receipt-offline.js";
30
+ /**
31
+ * Caller-supplied trust anchors (FOR-297 "known log key"): verify offline
32
+ * under keys the caller trusts out of band instead of the genesis trust root.
33
+ * See the trust-ladder notes on verifyReceiptOfflineWithKeys.
34
+ */
35
+ export {
36
+ verifyGrantReceiptOfflineWithKeys,
37
+ verifyReceiptOfflineWithKeys,
38
+ } from "./verify-grant-receipt-offline.js";
39
+ export type {
40
+ VerifyGrantReceiptOfflineWithKeysInput,
41
+ VerifyReceiptOfflineWithKeysInput,
42
+ } from "./verify-grant-receipt-offline.js";
43
+ export { resolveDelegatedVerifyKey } from "./resolve-delegated-verify-key.js";
44
+ export type { DelegatedResolution } from "./resolve-delegated-verify-key.js";
45
+ /** Import a raw 64-byte x||y P-256 public key as an ES256 verify key. */
46
+ export { importEs256PublicKeyFromGrantDataXy64 } from "./decode-trust-root-cbor.js";
28
47
  export { decodeForestrieGrantCose } from "./decode-forestrie-grant-cose.js";
29
48
  export { decodeGrantPayload, decodeGrantResponse } from "./grant-codec.js";
30
49
 
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Offline delegation-chain resolution (FOR-297). When a checkpoint/receipt was
3
+ * signed by a DELEGATED key rather than the log's root key, the delegation
4
+ * certificate travels in the receipt's unprotected header at label 1000
5
+ * (sealer-embedded). This module verifies that certificate under the genesis
6
+ * trust root and returns the delegated public key as an additional verify
7
+ * candidate — the offline port of canopy-api's `resolveReceiptVerifyKey`.
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.
12
+ *
13
+ * Trust anchors (FOR-297 trust ladder): `rootKeys` need not be genesis-derived
14
+ * — a caller-known log OWNER key works identically (see
15
+ * `verifyReceiptOfflineWithKeys`), because the certificate issuer for a child
16
+ * log IS its owner. What differs is why you trust the keys: genesis-derived
17
+ * keys prove the key↔log binding from the bootstrap; a caller-known key merely
18
+ * asserts it (provenance of the key is the only defence, and there is no grant
19
+ * lifecycle visibility or split-view protection). The grant-chain walk
20
+ * (approach A, open) will derive per-log owner keys from genesis + public
21
+ * tiles, closing that gap without key distribution.
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).
27
+ */
28
+
29
+ import {
30
+ coseUnprotectedToMap,
31
+ decodeCborDeterministic,
32
+ decodeCoseSign1,
33
+ verifyCoseSign1WithParsedKey,
34
+ } from "@forestrie/encoding";
35
+
36
+ /** Unprotected header label carrying the delegation certificate. */
37
+ const DELEGATION_CERT_LABEL = 1000;
38
+
39
+ /** COSE_Key labels (RFC 9052) and EC2/P-256 constants. */
40
+ const COSE_KEY_KTY = 1;
41
+ const COSE_KEY_CRV = -1;
42
+ const COSE_KEY_X = -2;
43
+ const COSE_KEY_Y = -3;
44
+ const COSE_KTY_EC2 = 2;
45
+ const COSE_CRV_P256 = 1;
46
+
47
+ /** Delegation-cert payload label for the delegated COSE_Key. */
48
+ const PAYLOAD_DELEGATED_KEY = 5;
49
+
50
+ /**
51
+ * root-only: no delegation cert present — verify against the root keys as-is.
52
+ * resolved: cert verified under a root key; use `delegatedKey` first.
53
+ * broken: a cert is present but did not verify under the root, or its delegated
54
+ * key could not be parsed — the delegation chain is invalid.
55
+ */
56
+ export type DelegatedResolution =
57
+ | { kind: "root-only" }
58
+ | { kind: "resolved"; delegatedKey: CryptoKey }
59
+ | { kind: "broken" };
60
+
61
+ function extractDelegationCertBytes(unprotected: unknown): Uint8Array | null {
62
+ const umap = coseUnprotectedToMap(unprotected);
63
+ const certRaw = umap.get(DELEGATION_CERT_LABEL);
64
+ return certRaw instanceof Uint8Array && certRaw.length > 0 ? certRaw : null;
65
+ }
66
+
67
+ function labelGetter(map: unknown): ((label: number) => unknown) | null {
68
+ if (map instanceof Map) return (label) => map.get(label);
69
+ if (typeof map === "object" && map !== null) {
70
+ const obj = map as Record<string | number, unknown>;
71
+ return (label) => obj[label] ?? obj[String(label)];
72
+ }
73
+ return null;
74
+ }
75
+
76
+ function parseCoseKeyEC2(
77
+ keyMap: unknown,
78
+ ): { x: Uint8Array; y: Uint8Array } | null {
79
+ const get = labelGetter(keyMap);
80
+ if (!get) return null;
81
+
82
+ const kty = get(COSE_KEY_KTY);
83
+ if (kty !== COSE_KTY_EC2 && kty !== BigInt(COSE_KTY_EC2)) return null;
84
+ const crv = get(COSE_KEY_CRV);
85
+ if (crv !== COSE_CRV_P256 && crv !== BigInt(COSE_CRV_P256)) return null;
86
+
87
+ const x = get(COSE_KEY_X);
88
+ const y = get(COSE_KEY_Y);
89
+ if (!(x instanceof Uint8Array) || x.length !== 32) return null;
90
+ if (!(y instanceof Uint8Array) || y.length !== 32) return null;
91
+ return { x, y };
92
+ }
93
+
94
+ async function importDelegatedKey(keyMap: unknown): Promise<CryptoKey | null> {
95
+ const parsed = parseCoseKeyEC2(keyMap);
96
+ if (!parsed) return null;
97
+ const uncompressed = new Uint8Array(65);
98
+ uncompressed[0] = 0x04;
99
+ uncompressed.set(parsed.x, 1);
100
+ uncompressed.set(parsed.y, 33);
101
+ try {
102
+ return await crypto.subtle.importKey(
103
+ "raw",
104
+ uncompressed,
105
+ { name: "ECDSA", namedCurve: "P-256" },
106
+ true,
107
+ ["verify"],
108
+ );
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+
114
+ function extractDelegatedKeyFromPayload(
115
+ payloadBytes: Uint8Array,
116
+ ): unknown | null {
117
+ if (!payloadBytes || payloadBytes.length === 0) return null;
118
+ let payloadMap: unknown;
119
+ try {
120
+ payloadMap = decodeCborDeterministic(payloadBytes);
121
+ } catch {
122
+ return null;
123
+ }
124
+ const get = labelGetter(payloadMap);
125
+ return get ? get(PAYLOAD_DELEGATED_KEY) : null;
126
+ }
127
+
128
+ /**
129
+ * Resolve the delegated verify key from a receipt's delegation certificate.
130
+ *
131
+ * @param receiptCbor - the COSE_Sign1 receipt bytes.
132
+ * @param rootKeys - genesis-derived ES256 root verify keys.
133
+ */
134
+ export async function resolveDelegatedVerifyKey(
135
+ receiptCbor: Uint8Array,
136
+ rootKeys: CryptoKey[],
137
+ ): Promise<DelegatedResolution> {
138
+ const decoded = decodeCoseSign1(receiptCbor);
139
+ if (!decoded) return { kind: "root-only" };
140
+
141
+ const certBytes = extractDelegationCertBytes(decoded.unprotected);
142
+ if (!certBytes) return { kind: "root-only" };
143
+
144
+ let verified = false;
145
+ for (const rootKey of rootKeys) {
146
+ if (
147
+ await verifyCoseSign1WithParsedKey(certBytes, rootKey, {
148
+ logPrefix: "delegation-cert-offline",
149
+ })
150
+ ) {
151
+ verified = true;
152
+ break;
153
+ }
154
+ }
155
+ if (!verified) return { kind: "broken" };
156
+
157
+ const certDecoded = decodeCoseSign1(certBytes);
158
+ if (!certDecoded) return { kind: "broken" };
159
+
160
+ const keyRaw = extractDelegatedKeyFromPayload(certDecoded.payloadBstr);
161
+ const delegatedKey = keyRaw ? await importDelegatedKey(keyRaw) : null;
162
+ if (!delegatedKey) return { kind: "broken" };
163
+
164
+ return { kind: "resolved", delegatedKey };
165
+ }
@@ -8,6 +8,7 @@ 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
12
  import { univocityLeafHash } from "./leaf-commitment.js";
12
13
  import { parseReceipt } from "./parse-receipt.js";
13
14
  import type { ReceiptVerifyResult } from "./receipt-verify-result.js";
@@ -100,13 +101,148 @@ async function verifySignatureAndInclusion(opts: {
100
101
  export async function verifyGrantReceiptOffline(
101
102
  input: VerifyGrantReceiptOfflineInput,
102
103
  ): Promise<ReceiptVerifyResult> {
103
- let parsed: ReturnType<typeof parseReceipt>;
104
+ // A GRANT leaf commits the grant commitment hash (register-grant / create-log).
105
+ let inner: Uint8Array;
104
106
  try {
105
- parsed = parseReceipt(input.receiptCbor);
107
+ inner = await grantCommitmentHashFromGrant(input.grant);
106
108
  } catch {
107
- return { ok: false, stage: "parse", reason: "receipt_malformed" };
109
+ return { ok: false, stage: "binding", reason: "grant_invalid" };
108
110
  }
111
+ return verifyReceiptOfflineWithLeafInner({
112
+ genesisCbor: input.genesisCbor,
113
+ receiptCbor: input.receiptCbor,
114
+ idtimestampBe8: input.idtimestampBe8,
115
+ inner,
116
+ });
117
+ }
118
+
119
+ export type VerifyGrantReceiptOfflineWithKeysInput = Omit<
120
+ VerifyGrantReceiptOfflineInput,
121
+ "genesisCbor"
122
+ > & {
123
+ /** Caller-known ES256 trust keys — see {@link VerifyReceiptOfflineWithKeysInput}. */
124
+ trustKeys: CryptoKey[];
125
+ };
126
+
127
+ /**
128
+ * {@link verifyGrantReceiptOffline} under caller-supplied trust keys instead of
129
+ * the genesis trust root — see {@link verifyReceiptOfflineWithKeys} for the
130
+ * trust model this buys (and what it does not).
131
+ */
132
+ export async function verifyGrantReceiptOfflineWithKeys(
133
+ input: VerifyGrantReceiptOfflineWithKeysInput,
134
+ ): Promise<ReceiptVerifyResult> {
135
+ let inner: Uint8Array;
136
+ try {
137
+ inner = await grantCommitmentHashFromGrant(input.grant);
138
+ } catch {
139
+ return { ok: false, stage: "binding", reason: "grant_invalid" };
140
+ }
141
+ return verifyReceiptOfflineWithLeafInnerKeys({
142
+ receiptCbor: input.receiptCbor,
143
+ idtimestampBe8: input.idtimestampBe8,
144
+ inner,
145
+ trustKeys: input.trustKeys,
146
+ });
147
+ }
148
+
149
+ export type VerifyReceiptOfflineInput = {
150
+ genesisCbor: Uint8Array;
151
+ receiptCbor: Uint8Array;
152
+ /**
153
+ * The EXACT registered payload bytes whose SHA-256 is the leaf ContentHash.
154
+ * Kind-agnostic: for a SCITT signed statement this is the statement COSE
155
+ * bytes; for a forestrie grant leaf it is the grant commitment preimage
156
+ * (see {@link verifyGrantReceiptOffline}, which derives it).
157
+ */
158
+ payload: Uint8Array;
159
+ idtimestampBe8: Uint8Array;
160
+ };
161
+
162
+ /**
163
+ * Offline verify of a receipt against the EXACT registered payload. The log
164
+ * leaf commits `SHA-256(idtimestamp ‖ SHA-256(payload))`; this is the standard,
165
+ * COSE-Receipts-conformant path (a SCITT statement receipt is exactly this with
166
+ * `payload` = the signed statement). Genesis trust root, FOR-297 delegation
167
+ * resolution, inclusion, and signature are all standard.
168
+ */
169
+ export async function verifyReceiptOffline(
170
+ input: VerifyReceiptOfflineInput,
171
+ ): Promise<ReceiptVerifyResult> {
172
+ const inner = new Uint8Array(
173
+ await crypto.subtle.digest(
174
+ "SHA-256",
175
+ input.payload as unknown as BufferSource,
176
+ ),
177
+ );
178
+ return verifyReceiptOfflineWithLeafInner({
179
+ genesisCbor: input.genesisCbor,
180
+ receiptCbor: input.receiptCbor,
181
+ idtimestampBe8: input.idtimestampBe8,
182
+ inner,
183
+ });
184
+ }
109
185
 
186
+ export type VerifyReceiptOfflineWithKeysInput = Omit<
187
+ VerifyReceiptOfflineInput,
188
+ "genesisCbor"
189
+ > & {
190
+ /**
191
+ * Caller-known ES256 trust keys, tried in order. For a child-log receipt
192
+ * this is the log OWNER's key (the delegation-cert issuer), NOT the sealer
193
+ * key — the label-1000 cert is verified under these keys and the delegated
194
+ * sealer key is extracted from it, so the anchor survives sealer rotation.
195
+ */
196
+ trustKeys: CryptoKey[];
197
+ };
198
+
199
+ /**
200
+ * {@link verifyReceiptOffline} under caller-supplied trust keys instead of the
201
+ * genesis trust root (FOR-297 "known log key"). Standard SCITT relying-party
202
+ * posture: the caller obtained the log owner's key out of band and trusts it.
203
+ *
204
+ * Trust ladder — what this rung gives up relative to its neighbours:
205
+ * - Known log key (this entry): fully offline, but the "key K owns log L"
206
+ * binding is ASSERTED by the caller's key provenance, not proven; no grant
207
+ * lifecycle/expiry visibility; no split-view protection.
208
+ * - Grant-chain walk (approach A, open): derives the binding from
209
+ * `genesis.cbor` + public tiles and adds lifecycle visibility.
210
+ * - Chain-anchored (live or cached accumulator): adds split-view protection —
211
+ * the contract-enforced on-chain accumulator is the strongest anchor.
212
+ *
213
+ * Do NOT fetch the trust key from the log operator's API or tile store — that
214
+ * silently reintroduces the operator trust this entry exists to remove.
215
+ */
216
+ export async function verifyReceiptOfflineWithKeys(
217
+ input: VerifyReceiptOfflineWithKeysInput,
218
+ ): Promise<ReceiptVerifyResult> {
219
+ const inner = new Uint8Array(
220
+ await crypto.subtle.digest(
221
+ "SHA-256",
222
+ input.payload as unknown as BufferSource,
223
+ ),
224
+ );
225
+ return verifyReceiptOfflineWithLeafInnerKeys({
226
+ receiptCbor: input.receiptCbor,
227
+ idtimestampBe8: input.idtimestampBe8,
228
+ inner,
229
+ trustKeys: input.trustKeys,
230
+ });
231
+ }
232
+
233
+ /**
234
+ * Shared offline-verify core: reconstruct the leaf as
235
+ * `univocityLeafHash(idtimestamp, inner)`, resolve the (possibly delegated,
236
+ * FOR-297) verify key from the genesis trust root, and check signature +
237
+ * inclusion. `inner` is the leaf ContentHash = `SHA-256(payload)` — the grant
238
+ * commitment for a grant leaf, or `SHA-256(statement)` for a statement leaf.
239
+ */
240
+ async function verifyReceiptOfflineWithLeafInner(input: {
241
+ genesisCbor: Uint8Array;
242
+ receiptCbor: Uint8Array;
243
+ idtimestampBe8: Uint8Array;
244
+ inner: Uint8Array;
245
+ }): Promise<ReceiptVerifyResult> {
110
246
  let trustRoot;
111
247
  try {
112
248
  trustRoot = await decodeTrustRootFromGenesis(input.genesisCbor);
@@ -114,17 +250,57 @@ export async function verifyGrantReceiptOffline(
114
250
  return { ok: false, stage: "parse", reason: "genesis_invalid" };
115
251
  }
116
252
 
117
- const verifyKeys = es256ReceiptVerifyKeys([trustRoot]);
253
+ const verifyKeys = es256ReceiptVerifyKeys([trustRoot]) as CryptoKey[];
118
254
  if (!verifyKeys.length) {
119
255
  return { ok: false, stage: "signature", reason: "no_es256_trust_key" };
120
256
  }
121
257
 
122
- let inner: Uint8Array;
258
+ return verifyReceiptOfflineWithLeafInnerKeys({
259
+ receiptCbor: input.receiptCbor,
260
+ idtimestampBe8: input.idtimestampBe8,
261
+ inner: input.inner,
262
+ trustKeys: verifyKeys,
263
+ });
264
+ }
265
+
266
+ /**
267
+ * Trust-key-parameterised offline-verify core. Identical to the genesis path
268
+ * once the trust keys are in hand: resolve the (possibly delegated, FOR-297)
269
+ * verify key against `trustKeys`, then check signature + inclusion.
270
+ */
271
+ async function verifyReceiptOfflineWithLeafInnerKeys(input: {
272
+ receiptCbor: Uint8Array;
273
+ idtimestampBe8: Uint8Array;
274
+ inner: Uint8Array;
275
+ trustKeys: CryptoKey[];
276
+ }): Promise<ReceiptVerifyResult> {
277
+ let parsed: ReturnType<typeof parseReceipt>;
123
278
  try {
124
- inner = await grantCommitmentHashFromGrant(input.grant);
279
+ parsed = parseReceipt(input.receiptCbor);
125
280
  } catch {
126
- return { ok: false, stage: "binding", reason: "grant_invalid" };
281
+ return { ok: false, stage: "parse", reason: "receipt_malformed" };
282
+ }
283
+
284
+ if (!input.trustKeys.length) {
285
+ return { ok: false, stage: "signature", reason: "no_es256_trust_key" };
286
+ }
287
+
288
+ // FOR-297: when the receipt was signed by a DELEGATED key, verify the
289
+ // label-1000 delegation certificate under the trust keys and try the
290
+ // delegated key first. A cert present but not chaining to a trust key is a
291
+ // hard failure — do not silently fall back to the trust key (which cannot
292
+ // verify a delegated-signed receipt anyway).
293
+ const delegation = await resolveDelegatedVerifyKey(
294
+ input.receiptCbor,
295
+ input.trustKeys,
296
+ );
297
+ if (delegation.kind === "broken") {
298
+ return { ok: false, stage: "signature", reason: "delegation_invalid" };
127
299
  }
300
+ const allVerifyKeys =
301
+ delegation.kind === "resolved"
302
+ ? [delegation.delegatedKey, ...input.trustKeys]
303
+ : input.trustKeys;
128
304
 
129
305
  let idtimestamp: bigint;
130
306
  try {
@@ -133,13 +309,13 @@ export async function verifyGrantReceiptOffline(
133
309
  return { ok: false, stage: "binding", reason: "idtimestamp_invalid" };
134
310
  }
135
311
 
136
- const leafHash = await univocityLeafHash(idtimestamp, inner);
312
+ const leafHash = await univocityLeafHash(idtimestamp, input.inner);
137
313
 
138
314
  return verifySignatureAndInclusion({
139
315
  receiptCbor: input.receiptCbor,
140
316
  explicitPeak: parsed.explicitPeak,
141
317
  proof: parsed.proof,
142
318
  leafHash,
143
- verifyKeys: verifyKeys as CryptoKey[],
319
+ verifyKeys: allVerifyKeys,
144
320
  });
145
321
  }