@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 +13 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -0
- package/dist/resolve-delegated-verify-key.d.ts +49 -0
- package/dist/resolve-delegated-verify-key.d.ts.map +1 -0
- package/dist/resolve-delegated-verify-key.js +132 -0
- package/dist/verify-grant-receipt-offline.d.ts +57 -0
- package/dist/verify-grant-receipt-offline.d.ts.map +1 -1
- package/dist/verify-grant-receipt-offline.js +115 -8
- package/package.json +1 -1
- package/src/index.ts +19 -0
- package/src/resolve-delegated-verify-key.ts +165 -0
- package/src/verify-grant-receipt-offline.ts +185 -9
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). */
|
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;;;;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;
|
|
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
|
-
|
|
62
|
+
// A GRANT leaf commits the grant commitment hash (register-grant / create-log).
|
|
63
|
+
let inner;
|
|
62
64
|
try {
|
|
63
|
-
|
|
65
|
+
inner = await grantCommitmentHashFromGrant(input.grant);
|
|
64
66
|
}
|
|
65
67
|
catch {
|
|
66
|
-
return { ok: false, stage: "
|
|
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
|
-
|
|
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
|
-
|
|
173
|
+
parsed = parseReceipt(input.receiptCbor);
|
|
82
174
|
}
|
|
83
175
|
catch {
|
|
84
|
-
return { ok: false, stage: "
|
|
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:
|
|
206
|
+
verifyKeys: allVerifyKeys,
|
|
100
207
|
});
|
|
101
208
|
}
|
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,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
|
-
|
|
104
|
+
// A GRANT leaf commits the grant commitment hash (register-grant / create-log).
|
|
105
|
+
let inner: Uint8Array;
|
|
104
106
|
try {
|
|
105
|
-
|
|
107
|
+
inner = await grantCommitmentHashFromGrant(input.grant);
|
|
106
108
|
} catch {
|
|
107
|
-
return { ok: false, stage: "
|
|
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
|
-
|
|
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
|
-
|
|
279
|
+
parsed = parseReceipt(input.receiptCbor);
|
|
125
280
|
} catch {
|
|
126
|
-
return { ok: false, stage: "
|
|
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:
|
|
319
|
+
verifyKeys: allVerifyKeys,
|
|
144
320
|
});
|
|
145
321
|
}
|