@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 +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/resolve-delegated-verify-key.d.ts +39 -0
- package/dist/resolve-delegated-verify-key.d.ts.map +1 -0
- package/dist/resolve-delegated-verify-key.js +122 -0
- package/dist/verify-grant-receipt-offline.d.ts +20 -0
- package/dist/verify-grant-receipt-offline.d.ts.map +1 -1
- package/dist/verify-grant-receipt-offline.js +53 -8
- package/package.json +1 -1
- package/src/index.ts +2 -0
- package/src/resolve-delegated-verify-key.ts +155 -0
- package/src/verify-grant-receipt-offline.ts +84 -8
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). */
|
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,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;
|
|
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
|
-
|
|
80
|
-
try
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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:
|
|
144
|
+
verifyKeys: allVerifyKeys,
|
|
100
145
|
});
|
|
101
146
|
}
|
package/package.json
CHANGED
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
|
-
|
|
123
|
-
try
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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:
|
|
219
|
+
verifyKeys: allVerifyKeys,
|
|
144
220
|
});
|
|
145
221
|
}
|