@forestrie/receipt-verify 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -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,7 @@ 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";
15
17
  export { decodeForestrieGrantCose } from "./decode-forestrie-grant-cose.js";
16
18
  export { decodeGrantPayload, decodeGrantResponse } from "./grant-codec.js";
17
19
  /** 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,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,7 @@ 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";
10
11
  export { decodeForestrieGrantCose } from "./decode-forestrie-grant-cose.js";
11
12
  export { decodeGrantPayload, decodeGrantResponse } from "./grant-codec.js";
12
13
  /** Deterministic receipt construction (plan-2607-12 Phase 2, FOR-353). */
@@ -0,0 +1,39 @@
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
+ * Note (parity with the server port source): this establishes that the root
14
+ * authorized the delegated key, but does NOT yet enforce the certificate's MMR
15
+ * window or expiry-at-issuance against the leaf — that hardening is shared with
16
+ * the server path and tracked separately (FOR-323).
17
+ */
18
+ /**
19
+ * root-only: no delegation cert present — verify against the root keys as-is.
20
+ * resolved: cert verified under a root key; use `delegatedKey` first.
21
+ * broken: a cert is present but did not verify under the root, or its delegated
22
+ * key could not be parsed — the delegation chain is invalid.
23
+ */
24
+ export type DelegatedResolution = {
25
+ kind: "root-only";
26
+ } | {
27
+ kind: "resolved";
28
+ delegatedKey: CryptoKey;
29
+ } | {
30
+ kind: "broken";
31
+ };
32
+ /**
33
+ * Resolve the delegated verify key from a receipt's delegation certificate.
34
+ *
35
+ * @param receiptCbor - the COSE_Sign1 receipt bytes.
36
+ * @param rootKeys - genesis-derived ES256 root verify keys.
37
+ */
38
+ export declare function resolveDelegatedVerifyKey(receiptCbor: Uint8Array, rootKeys: CryptoKey[]): Promise<DelegatedResolution>;
39
+ //# 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;;;;;;;;;;;;;;;;GAgBG;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,122 @@
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
+ * Note (parity with the server port source): this establishes that the root
14
+ * authorized the delegated key, but does NOT yet enforce the certificate's MMR
15
+ * window or expiry-at-issuance against the leaf — that hardening is shared with
16
+ * the server path and tracked separately (FOR-323).
17
+ */
18
+ import { coseUnprotectedToMap, decodeCborDeterministic, decodeCoseSign1, verifyCoseSign1WithParsedKey, } from "@forestrie/encoding";
19
+ /** Unprotected header label carrying the delegation certificate. */
20
+ const DELEGATION_CERT_LABEL = 1000;
21
+ /** COSE_Key labels (RFC 9052) and EC2/P-256 constants. */
22
+ const COSE_KEY_KTY = 1;
23
+ const COSE_KEY_CRV = -1;
24
+ const COSE_KEY_X = -2;
25
+ const COSE_KEY_Y = -3;
26
+ const COSE_KTY_EC2 = 2;
27
+ const COSE_CRV_P256 = 1;
28
+ /** Delegation-cert payload label for the delegated COSE_Key. */
29
+ const PAYLOAD_DELEGATED_KEY = 5;
30
+ function extractDelegationCertBytes(unprotected) {
31
+ const umap = coseUnprotectedToMap(unprotected);
32
+ const certRaw = umap.get(DELEGATION_CERT_LABEL);
33
+ return certRaw instanceof Uint8Array && certRaw.length > 0 ? certRaw : null;
34
+ }
35
+ function labelGetter(map) {
36
+ if (map instanceof Map)
37
+ return (label) => map.get(label);
38
+ if (typeof map === "object" && map !== null) {
39
+ const obj = map;
40
+ return (label) => obj[label] ?? obj[String(label)];
41
+ }
42
+ return null;
43
+ }
44
+ function parseCoseKeyEC2(keyMap) {
45
+ const get = labelGetter(keyMap);
46
+ if (!get)
47
+ return null;
48
+ const kty = get(COSE_KEY_KTY);
49
+ if (kty !== COSE_KTY_EC2 && kty !== BigInt(COSE_KTY_EC2))
50
+ return null;
51
+ const crv = get(COSE_KEY_CRV);
52
+ if (crv !== COSE_CRV_P256 && crv !== BigInt(COSE_CRV_P256))
53
+ return null;
54
+ const x = get(COSE_KEY_X);
55
+ const y = get(COSE_KEY_Y);
56
+ if (!(x instanceof Uint8Array) || x.length !== 32)
57
+ return null;
58
+ if (!(y instanceof Uint8Array) || y.length !== 32)
59
+ return null;
60
+ return { x, y };
61
+ }
62
+ async function importDelegatedKey(keyMap) {
63
+ const parsed = parseCoseKeyEC2(keyMap);
64
+ if (!parsed)
65
+ return null;
66
+ const uncompressed = new Uint8Array(65);
67
+ uncompressed[0] = 0x04;
68
+ uncompressed.set(parsed.x, 1);
69
+ uncompressed.set(parsed.y, 33);
70
+ try {
71
+ return await crypto.subtle.importKey("raw", uncompressed, { name: "ECDSA", namedCurve: "P-256" }, true, ["verify"]);
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ }
77
+ function extractDelegatedKeyFromPayload(payloadBytes) {
78
+ if (!payloadBytes || payloadBytes.length === 0)
79
+ return null;
80
+ let payloadMap;
81
+ try {
82
+ payloadMap = decodeCborDeterministic(payloadBytes);
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ const get = labelGetter(payloadMap);
88
+ return get ? get(PAYLOAD_DELEGATED_KEY) : null;
89
+ }
90
+ /**
91
+ * Resolve the delegated verify key from a receipt's delegation certificate.
92
+ *
93
+ * @param receiptCbor - the COSE_Sign1 receipt bytes.
94
+ * @param rootKeys - genesis-derived ES256 root verify keys.
95
+ */
96
+ export async function resolveDelegatedVerifyKey(receiptCbor, rootKeys) {
97
+ const decoded = decodeCoseSign1(receiptCbor);
98
+ if (!decoded)
99
+ return { kind: "root-only" };
100
+ const certBytes = extractDelegationCertBytes(decoded.unprotected);
101
+ if (!certBytes)
102
+ return { kind: "root-only" };
103
+ let verified = false;
104
+ for (const rootKey of rootKeys) {
105
+ if (await verifyCoseSign1WithParsedKey(certBytes, rootKey, {
106
+ logPrefix: "delegation-cert-offline",
107
+ })) {
108
+ verified = true;
109
+ break;
110
+ }
111
+ }
112
+ if (!verified)
113
+ return { kind: "broken" };
114
+ const certDecoded = decodeCoseSign1(certBytes);
115
+ if (!certDecoded)
116
+ return { kind: "broken" };
117
+ const keyRaw = extractDelegatedKeyFromPayload(certDecoded.payloadBstr);
118
+ const delegatedKey = keyRaw ? await importDelegatedKey(keyRaw) : null;
119
+ if (!delegatedKey)
120
+ return { kind: "broken" };
121
+ return { kind: "resolved", delegatedKey };
122
+ }
@@ -10,4 +10,24 @@ 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 VerifyReceiptOfflineInput = {
14
+ genesisCbor: Uint8Array;
15
+ receiptCbor: Uint8Array;
16
+ /**
17
+ * The EXACT registered payload bytes whose SHA-256 is the leaf ContentHash.
18
+ * Kind-agnostic: for a SCITT signed statement this is the statement COSE
19
+ * bytes; for a forestrie grant leaf it is the grant commitment preimage
20
+ * (see {@link verifyGrantReceiptOffline}, which derives it).
21
+ */
22
+ payload: Uint8Array;
23
+ idtimestampBe8: Uint8Array;
24
+ };
25
+ /**
26
+ * Offline verify of a receipt against the EXACT registered payload. The log
27
+ * leaf commits `SHA-256(idtimestamp ‖ SHA-256(payload))`; this is the standard,
28
+ * COSE-Receipts-conformant path (a SCITT statement receipt is exactly this with
29
+ * `payload` = the signed statement). Genesis trust root, FOR-297 delegation
30
+ * resolution, inclusion, and signature are all standard.
31
+ */
32
+ export declare function verifyReceiptOffline(input: VerifyReceiptOfflineInput): Promise<ReceiptVerifyResult>;
13
33
  //# 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,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"}
@@ -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,6 +59,45 @@ 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) {
62
+ // A GRANT leaf commits the grant commitment hash (register-grant / create-log).
63
+ let inner;
64
+ try {
65
+ inner = await grantCommitmentHashFromGrant(input.grant);
66
+ }
67
+ catch {
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
+ * Offline verify of a receipt against the EXACT registered payload. The log
79
+ * leaf commits `SHA-256(idtimestamp ‖ SHA-256(payload))`; this is the standard,
80
+ * COSE-Receipts-conformant path (a SCITT statement receipt is exactly this with
81
+ * `payload` = the signed statement). Genesis trust root, FOR-297 delegation
82
+ * resolution, inclusion, and signature are all standard.
83
+ */
84
+ export async function verifyReceiptOffline(input) {
85
+ const inner = new Uint8Array(await crypto.subtle.digest("SHA-256", input.payload));
86
+ return verifyReceiptOfflineWithLeafInner({
87
+ genesisCbor: input.genesisCbor,
88
+ receiptCbor: input.receiptCbor,
89
+ idtimestampBe8: input.idtimestampBe8,
90
+ inner,
91
+ });
92
+ }
93
+ /**
94
+ * Shared offline-verify core: reconstruct the leaf as
95
+ * `univocityLeafHash(idtimestamp, inner)`, resolve the (possibly delegated,
96
+ * FOR-297) verify key from the genesis trust root, and check signature +
97
+ * inclusion. `inner` is the leaf ContentHash = `SHA-256(payload)` — the grant
98
+ * commitment for a grant leaf, or `SHA-256(statement)` for a statement leaf.
99
+ */
100
+ async function verifyReceiptOfflineWithLeafInner(input) {
61
101
  let parsed;
62
102
  try {
63
103
  parsed = parseReceipt(input.receiptCbor);
@@ -76,13 +116,18 @@ export async function verifyGrantReceiptOffline(input) {
76
116
  if (!verifyKeys.length) {
77
117
  return { ok: false, stage: "signature", reason: "no_es256_trust_key" };
78
118
  }
79
- let inner;
80
- try {
81
- inner = await grantCommitmentHashFromGrant(input.grant);
82
- }
83
- catch {
84
- return { ok: false, stage: "binding", reason: "grant_invalid" };
119
+ // FOR-297: when the receipt was signed by a DELEGATED key, verify the
120
+ // label-1000 delegation certificate under the root and try the delegated key
121
+ // first. A cert present but not chaining to the root is a hard failure — do
122
+ // not silently fall back to the root key (which cannot verify a
123
+ // delegated-signed receipt anyway).
124
+ const delegation = await resolveDelegatedVerifyKey(input.receiptCbor, verifyKeys);
125
+ if (delegation.kind === "broken") {
126
+ return { ok: false, stage: "signature", reason: "delegation_invalid" };
85
127
  }
128
+ const allVerifyKeys = delegation.kind === "resolved"
129
+ ? [delegation.delegatedKey, ...verifyKeys]
130
+ : verifyKeys;
86
131
  let idtimestamp;
87
132
  try {
88
133
  idtimestamp = readIdtimestampBe8(input.idtimestampBe8);
@@ -90,12 +135,12 @@ export async function verifyGrantReceiptOffline(input) {
90
135
  catch {
91
136
  return { ok: false, stage: "binding", reason: "idtimestamp_invalid" };
92
137
  }
93
- const leafHash = await univocityLeafHash(idtimestamp, inner);
138
+ const leafHash = await univocityLeafHash(idtimestamp, input.inner);
94
139
  return verifySignatureAndInclusion({
95
140
  receiptCbor: input.receiptCbor,
96
141
  explicitPeak: parsed.explicitPeak,
97
142
  proof: parsed.proof,
98
143
  leafHash,
99
- verifyKeys: verifyKeys,
144
+ verifyKeys: allVerifyKeys,
100
145
  });
101
146
  }
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.1",
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,7 @@ 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";
28
30
  export { decodeForestrieGrantCose } from "./decode-forestrie-grant-cose.js";
29
31
  export { decodeGrantPayload, decodeGrantResponse } from "./grant-codec.js";
30
32
 
@@ -0,0 +1,155 @@
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
+ * Note (parity with the server port source): this establishes that the root
14
+ * authorized the delegated key, but does NOT yet enforce the certificate's MMR
15
+ * window or expiry-at-issuance against the leaf — that hardening is shared with
16
+ * the server path and tracked separately (FOR-323).
17
+ */
18
+
19
+ import {
20
+ coseUnprotectedToMap,
21
+ decodeCborDeterministic,
22
+ decodeCoseSign1,
23
+ verifyCoseSign1WithParsedKey,
24
+ } from "@forestrie/encoding";
25
+
26
+ /** Unprotected header label carrying the delegation certificate. */
27
+ const DELEGATION_CERT_LABEL = 1000;
28
+
29
+ /** COSE_Key labels (RFC 9052) and EC2/P-256 constants. */
30
+ const COSE_KEY_KTY = 1;
31
+ const COSE_KEY_CRV = -1;
32
+ const COSE_KEY_X = -2;
33
+ const COSE_KEY_Y = -3;
34
+ const COSE_KTY_EC2 = 2;
35
+ const COSE_CRV_P256 = 1;
36
+
37
+ /** Delegation-cert payload label for the delegated COSE_Key. */
38
+ const PAYLOAD_DELEGATED_KEY = 5;
39
+
40
+ /**
41
+ * root-only: no delegation cert present — verify against the root keys as-is.
42
+ * resolved: cert verified under a root key; use `delegatedKey` first.
43
+ * broken: a cert is present but did not verify under the root, or its delegated
44
+ * key could not be parsed — the delegation chain is invalid.
45
+ */
46
+ export type DelegatedResolution =
47
+ | { kind: "root-only" }
48
+ | { kind: "resolved"; delegatedKey: CryptoKey }
49
+ | { kind: "broken" };
50
+
51
+ function extractDelegationCertBytes(unprotected: unknown): Uint8Array | null {
52
+ const umap = coseUnprotectedToMap(unprotected);
53
+ const certRaw = umap.get(DELEGATION_CERT_LABEL);
54
+ return certRaw instanceof Uint8Array && certRaw.length > 0 ? certRaw : null;
55
+ }
56
+
57
+ function labelGetter(map: unknown): ((label: number) => unknown) | null {
58
+ if (map instanceof Map) return (label) => map.get(label);
59
+ if (typeof map === "object" && map !== null) {
60
+ const obj = map as Record<string | number, unknown>;
61
+ return (label) => obj[label] ?? obj[String(label)];
62
+ }
63
+ return null;
64
+ }
65
+
66
+ function parseCoseKeyEC2(
67
+ keyMap: unknown,
68
+ ): { x: Uint8Array; y: Uint8Array } | null {
69
+ const get = labelGetter(keyMap);
70
+ if (!get) return null;
71
+
72
+ const kty = get(COSE_KEY_KTY);
73
+ if (kty !== COSE_KTY_EC2 && kty !== BigInt(COSE_KTY_EC2)) return null;
74
+ const crv = get(COSE_KEY_CRV);
75
+ if (crv !== COSE_CRV_P256 && crv !== BigInt(COSE_CRV_P256)) return null;
76
+
77
+ const x = get(COSE_KEY_X);
78
+ const y = get(COSE_KEY_Y);
79
+ if (!(x instanceof Uint8Array) || x.length !== 32) return null;
80
+ if (!(y instanceof Uint8Array) || y.length !== 32) return null;
81
+ return { x, y };
82
+ }
83
+
84
+ async function importDelegatedKey(keyMap: unknown): Promise<CryptoKey | null> {
85
+ const parsed = parseCoseKeyEC2(keyMap);
86
+ if (!parsed) return null;
87
+ const uncompressed = new Uint8Array(65);
88
+ uncompressed[0] = 0x04;
89
+ uncompressed.set(parsed.x, 1);
90
+ uncompressed.set(parsed.y, 33);
91
+ try {
92
+ return await crypto.subtle.importKey(
93
+ "raw",
94
+ uncompressed,
95
+ { name: "ECDSA", namedCurve: "P-256" },
96
+ true,
97
+ ["verify"],
98
+ );
99
+ } catch {
100
+ return null;
101
+ }
102
+ }
103
+
104
+ function extractDelegatedKeyFromPayload(
105
+ payloadBytes: Uint8Array,
106
+ ): unknown | null {
107
+ if (!payloadBytes || payloadBytes.length === 0) return null;
108
+ let payloadMap: unknown;
109
+ try {
110
+ payloadMap = decodeCborDeterministic(payloadBytes);
111
+ } catch {
112
+ return null;
113
+ }
114
+ const get = labelGetter(payloadMap);
115
+ return get ? get(PAYLOAD_DELEGATED_KEY) : null;
116
+ }
117
+
118
+ /**
119
+ * Resolve the delegated verify key from a receipt's delegation certificate.
120
+ *
121
+ * @param receiptCbor - the COSE_Sign1 receipt bytes.
122
+ * @param rootKeys - genesis-derived ES256 root verify keys.
123
+ */
124
+ export async function resolveDelegatedVerifyKey(
125
+ receiptCbor: Uint8Array,
126
+ rootKeys: CryptoKey[],
127
+ ): Promise<DelegatedResolution> {
128
+ const decoded = decodeCoseSign1(receiptCbor);
129
+ if (!decoded) return { kind: "root-only" };
130
+
131
+ const certBytes = extractDelegationCertBytes(decoded.unprotected);
132
+ if (!certBytes) return { kind: "root-only" };
133
+
134
+ let verified = false;
135
+ for (const rootKey of rootKeys) {
136
+ if (
137
+ await verifyCoseSign1WithParsedKey(certBytes, rootKey, {
138
+ logPrefix: "delegation-cert-offline",
139
+ })
140
+ ) {
141
+ verified = true;
142
+ break;
143
+ }
144
+ }
145
+ if (!verified) return { kind: "broken" };
146
+
147
+ const certDecoded = decodeCoseSign1(certBytes);
148
+ if (!certDecoded) return { kind: "broken" };
149
+
150
+ const keyRaw = extractDelegatedKeyFromPayload(certDecoded.payloadBstr);
151
+ const delegatedKey = keyRaw ? await importDelegatedKey(keyRaw) : null;
152
+ if (!delegatedKey) return { kind: "broken" };
153
+
154
+ return { kind: "resolved", delegatedKey };
155
+ }
@@ -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,6 +101,71 @@ async function verifySignatureAndInclusion(opts: {
100
101
  export async function verifyGrantReceiptOffline(
101
102
  input: VerifyGrantReceiptOfflineInput,
102
103
  ): Promise<ReceiptVerifyResult> {
104
+ // A GRANT leaf commits the grant commitment hash (register-grant / create-log).
105
+ let inner: Uint8Array;
106
+ try {
107
+ inner = await grantCommitmentHashFromGrant(input.grant);
108
+ } catch {
109
+ return { ok: false, stage: "binding", reason: "grant_invalid" };
110
+ }
111
+ return verifyReceiptOfflineWithLeafInner({
112
+ genesisCbor: input.genesisCbor,
113
+ receiptCbor: input.receiptCbor,
114
+ idtimestampBe8: input.idtimestampBe8,
115
+ inner,
116
+ });
117
+ }
118
+
119
+ export type VerifyReceiptOfflineInput = {
120
+ genesisCbor: Uint8Array;
121
+ receiptCbor: Uint8Array;
122
+ /**
123
+ * The EXACT registered payload bytes whose SHA-256 is the leaf ContentHash.
124
+ * Kind-agnostic: for a SCITT signed statement this is the statement COSE
125
+ * bytes; for a forestrie grant leaf it is the grant commitment preimage
126
+ * (see {@link verifyGrantReceiptOffline}, which derives it).
127
+ */
128
+ payload: Uint8Array;
129
+ idtimestampBe8: Uint8Array;
130
+ };
131
+
132
+ /**
133
+ * Offline verify of a receipt against the EXACT registered payload. The log
134
+ * leaf commits `SHA-256(idtimestamp ‖ SHA-256(payload))`; this is the standard,
135
+ * COSE-Receipts-conformant path (a SCITT statement receipt is exactly this with
136
+ * `payload` = the signed statement). Genesis trust root, FOR-297 delegation
137
+ * resolution, inclusion, and signature are all standard.
138
+ */
139
+ export async function verifyReceiptOffline(
140
+ input: VerifyReceiptOfflineInput,
141
+ ): Promise<ReceiptVerifyResult> {
142
+ const inner = new Uint8Array(
143
+ await crypto.subtle.digest(
144
+ "SHA-256",
145
+ input.payload as unknown as BufferSource,
146
+ ),
147
+ );
148
+ return verifyReceiptOfflineWithLeafInner({
149
+ genesisCbor: input.genesisCbor,
150
+ receiptCbor: input.receiptCbor,
151
+ idtimestampBe8: input.idtimestampBe8,
152
+ inner,
153
+ });
154
+ }
155
+
156
+ /**
157
+ * Shared offline-verify core: reconstruct the leaf as
158
+ * `univocityLeafHash(idtimestamp, inner)`, resolve the (possibly delegated,
159
+ * FOR-297) verify key from the genesis trust root, and check signature +
160
+ * inclusion. `inner` is the leaf ContentHash = `SHA-256(payload)` — the grant
161
+ * commitment for a grant leaf, or `SHA-256(statement)` for a statement leaf.
162
+ */
163
+ async function verifyReceiptOfflineWithLeafInner(input: {
164
+ genesisCbor: Uint8Array;
165
+ receiptCbor: Uint8Array;
166
+ idtimestampBe8: Uint8Array;
167
+ inner: Uint8Array;
168
+ }): Promise<ReceiptVerifyResult> {
103
169
  let parsed: ReturnType<typeof parseReceipt>;
104
170
  try {
105
171
  parsed = parseReceipt(input.receiptCbor);
@@ -114,17 +180,27 @@ export async function verifyGrantReceiptOffline(
114
180
  return { ok: false, stage: "parse", reason: "genesis_invalid" };
115
181
  }
116
182
 
117
- const verifyKeys = es256ReceiptVerifyKeys([trustRoot]);
183
+ const verifyKeys = es256ReceiptVerifyKeys([trustRoot]) as CryptoKey[];
118
184
  if (!verifyKeys.length) {
119
185
  return { ok: false, stage: "signature", reason: "no_es256_trust_key" };
120
186
  }
121
187
 
122
- let inner: Uint8Array;
123
- try {
124
- inner = await grantCommitmentHashFromGrant(input.grant);
125
- } catch {
126
- return { ok: false, stage: "binding", reason: "grant_invalid" };
188
+ // FOR-297: when the receipt was signed by a DELEGATED key, verify the
189
+ // label-1000 delegation certificate under the root and try the delegated key
190
+ // first. A cert present but not chaining to the root is a hard failure — do
191
+ // not silently fall back to the root key (which cannot verify a
192
+ // delegated-signed receipt anyway).
193
+ const delegation = await resolveDelegatedVerifyKey(
194
+ input.receiptCbor,
195
+ verifyKeys,
196
+ );
197
+ if (delegation.kind === "broken") {
198
+ return { ok: false, stage: "signature", reason: "delegation_invalid" };
127
199
  }
200
+ const allVerifyKeys =
201
+ delegation.kind === "resolved"
202
+ ? [delegation.delegatedKey, ...verifyKeys]
203
+ : verifyKeys;
128
204
 
129
205
  let idtimestamp: bigint;
130
206
  try {
@@ -133,13 +209,13 @@ export async function verifyGrantReceiptOffline(
133
209
  return { ok: false, stage: "binding", reason: "idtimestamp_invalid" };
134
210
  }
135
211
 
136
- const leafHash = await univocityLeafHash(idtimestamp, inner);
212
+ const leafHash = await univocityLeafHash(idtimestamp, input.inner);
137
213
 
138
214
  return verifySignatureAndInclusion({
139
215
  receiptCbor: input.receiptCbor,
140
216
  explicitPeak: parsed.explicitPeak,
141
217
  proof: parsed.proof,
142
218
  leafHash,
143
- verifyKeys: verifyKeys as CryptoKey[],
219
+ verifyKeys: allVerifyKeys,
144
220
  });
145
221
  }