@forestrie/receipt-verify 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/dist/cose-key.d.ts +10 -0
  3. package/dist/cose-key.d.ts.map +1 -0
  4. package/dist/cose-key.js +9 -0
  5. package/dist/decode-forestrie-grant-cose.d.ts +9 -0
  6. package/dist/decode-forestrie-grant-cose.d.ts.map +1 -0
  7. package/dist/decode-forestrie-grant-cose.js +62 -0
  8. package/dist/decode-trust-root-cbor.d.ts +6 -0
  9. package/dist/decode-trust-root-cbor.d.ts.map +1 -0
  10. package/dist/decode-trust-root-cbor.js +85 -0
  11. package/dist/decode-trust-root-from-genesis.d.ts +7 -0
  12. package/dist/decode-trust-root-from-genesis.d.ts.map +1 -0
  13. package/dist/decode-trust-root-from-genesis.js +64 -0
  14. package/dist/forest-genesis-labels.d.ts +10 -0
  15. package/dist/forest-genesis-labels.d.ts.map +1 -0
  16. package/dist/forest-genesis-labels.js +9 -0
  17. package/dist/grant-codec.d.ts +8 -0
  18. package/dist/grant-codec.d.ts.map +1 -0
  19. package/dist/grant-codec.js +88 -0
  20. package/dist/grant-commitment.d.ts +3 -0
  21. package/dist/grant-commitment.d.ts.map +1 -0
  22. package/dist/grant-commitment.js +55 -0
  23. package/dist/grant-data.d.ts +7 -0
  24. package/dist/grant-data.d.ts.map +1 -0
  25. package/dist/grant-data.js +7 -0
  26. package/dist/grant.d.ts +12 -0
  27. package/dist/grant.d.ts.map +1 -0
  28. package/dist/grant.js +1 -0
  29. package/dist/index.d.ts +9 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +5 -0
  32. package/dist/leaf-commitment.d.ts +2 -0
  33. package/dist/leaf-commitment.d.ts.map +1 -0
  34. package/dist/leaf-commitment.js +27 -0
  35. package/dist/parse-receipt.d.ts +15 -0
  36. package/dist/parse-receipt.d.ts.map +1 -0
  37. package/dist/parse-receipt.js +91 -0
  38. package/dist/receipt-verify-result.d.ts +9 -0
  39. package/dist/receipt-verify-result.d.ts.map +1 -0
  40. package/dist/receipt-verify-result.js +1 -0
  41. package/dist/root-verify-key.d.ts +9 -0
  42. package/dist/root-verify-key.d.ts.map +1 -0
  43. package/dist/root-verify-key.js +7 -0
  44. package/dist/subtle-hasher.d.ts +9 -0
  45. package/dist/subtle-hasher.d.ts.map +1 -0
  46. package/dist/subtle-hasher.js +21 -0
  47. package/dist/uuid-bytes.d.ts +4 -0
  48. package/dist/uuid-bytes.d.ts.map +1 -0
  49. package/dist/uuid-bytes.js +21 -0
  50. package/dist/verify-grant-receipt-offline.d.ts +13 -0
  51. package/dist/verify-grant-receipt-offline.d.ts.map +1 -0
  52. package/dist/verify-grant-receipt-offline.js +101 -0
  53. package/package.json +45 -0
  54. package/src/cose-key.ts +9 -0
  55. package/src/decode-forestrie-grant-cose.ts +79 -0
  56. package/src/decode-trust-root-cbor.ts +113 -0
  57. package/src/decode-trust-root-from-genesis.ts +87 -0
  58. package/src/forest-genesis-labels.ts +10 -0
  59. package/src/grant-codec.ts +106 -0
  60. package/src/grant-commitment.ts +64 -0
  61. package/src/grant-data.ts +12 -0
  62. package/src/grant.ts +12 -0
  63. package/src/index.ts +11 -0
  64. package/src/leaf-commitment.ts +35 -0
  65. package/src/parse-receipt.ts +120 -0
  66. package/src/receipt-verify-result.ts +13 -0
  67. package/src/root-verify-key.ts +19 -0
  68. package/src/subtle-hasher.ts +26 -0
  69. package/src/uuid-bytes.ts +20 -0
  70. package/src/verify-grant-receipt-offline.ts +145 -0
@@ -0,0 +1,91 @@
1
+ import { decode as decodeCbor } from "cbor-x";
2
+ const VDS_COSE_RECEIPT_PROOFS_TAG = 396;
3
+ function unwrapCoseSign1Tag(value) {
4
+ if (value && typeof value === "object" && !(value instanceof Map)) {
5
+ const tagged = value;
6
+ if (Object.prototype.hasOwnProperty.call(tagged, "value") &&
7
+ tagged.tag === 18) {
8
+ return tagged.value;
9
+ }
10
+ }
11
+ return value;
12
+ }
13
+ function requireCoseSign1(value) {
14
+ if (!Array.isArray(value) || value.length !== 4) {
15
+ throw new Error("Receipt is not a COSE Sign1 array");
16
+ }
17
+ const [p, u, payload, sig] = value;
18
+ if (!(p instanceof Uint8Array) || !(sig instanceof Uint8Array)) {
19
+ throw new Error("Invalid COSE Sign1 structure");
20
+ }
21
+ if (!(payload === null || payload instanceof Uint8Array)) {
22
+ throw new Error("Invalid COSE Sign1 payload");
23
+ }
24
+ return [
25
+ p,
26
+ u,
27
+ payload,
28
+ sig,
29
+ ];
30
+ }
31
+ function toHeaderMap(value) {
32
+ if (value instanceof Map)
33
+ return value;
34
+ const out = new Map();
35
+ if (typeof value === "object" && value !== null) {
36
+ for (const [k, v] of Object.entries(value)) {
37
+ const n = Number(k);
38
+ if (Number.isFinite(n))
39
+ out.set(n, v);
40
+ }
41
+ }
42
+ return out;
43
+ }
44
+ export function parseReceipt(receiptBytes) {
45
+ const decoded = decodeCbor(receiptBytes);
46
+ const unwrapped = unwrapCoseSign1Tag(decoded);
47
+ requireCoseSign1(unwrapped);
48
+ const coseSign1 = requireCoseSign1(unwrapped);
49
+ const payload = coseSign1[2];
50
+ let explicitPeak = null;
51
+ if (payload instanceof Uint8Array) {
52
+ if (payload.length !== 32) {
53
+ throw new Error("Receipt payload must be 32 bytes (peak hash)");
54
+ }
55
+ explicitPeak = payload;
56
+ }
57
+ else if (payload !== null && payload !== undefined) {
58
+ throw new Error("Receipt payload must be nil (detached) or 32-byte peak hash");
59
+ }
60
+ const unprotected = toHeaderMap(coseSign1[1]);
61
+ const proofsRaw = unprotected.get(VDS_COSE_RECEIPT_PROOFS_TAG);
62
+ if (!proofsRaw || typeof proofsRaw !== "object") {
63
+ throw new Error("Receipt missing header 396 (inclusion proof)");
64
+ }
65
+ const proofsMap = proofsRaw;
66
+ const proofList = (proofsMap instanceof Map
67
+ ? proofsMap.get(-1)
68
+ : proofsMap[-1]);
69
+ if (!Array.isArray(proofList) || proofList.length === 0) {
70
+ throw new Error("Receipt proof -1 must be non-empty array");
71
+ }
72
+ const entry = proofList[0];
73
+ const mmrIndexRaw = entry instanceof Map ? entry.get(1) : entry[1];
74
+ const pathRaw = entry instanceof Map ? entry.get(2) : entry[2];
75
+ if (mmrIndexRaw === undefined || !Array.isArray(pathRaw)) {
76
+ throw new Error("Proof entry must have 1: mmrIndex, 2: path");
77
+ }
78
+ const mmrIndex = typeof mmrIndexRaw === "bigint" ? mmrIndexRaw : BigInt(Number(mmrIndexRaw));
79
+ const path = pathRaw.map((h) => {
80
+ if (!(h instanceof Uint8Array) || h.length !== 32) {
81
+ throw new Error("Proof path elements must be 32-byte hashes");
82
+ }
83
+ return h;
84
+ });
85
+ return {
86
+ explicitPeak,
87
+ proof: { path, mmrIndex },
88
+ receiptCbor: receiptBytes,
89
+ coseSign1,
90
+ };
91
+ }
@@ -0,0 +1,9 @@
1
+ /** Failure stage for falsifiable tests and CLI exit messaging (ADR-0045). */
2
+ export type ReceiptVerifyStage = "parse" | "signature" | "inclusion" | "binding";
3
+ export type ReceiptVerifyResult = {
4
+ ok: boolean;
5
+ stage: ReceiptVerifyStage;
6
+ /** Present when ok === false; stable snake_case token for tests. */
7
+ reason?: string;
8
+ };
9
+ //# sourceMappingURL=receipt-verify-result.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"receipt-verify-result.d.ts","sourceRoot":"","sources":["../src/receipt-verify-result.ts"],"names":[],"mappings":"AAAA,6EAA6E;AAC7E,MAAM,MAAM,kBAAkB,GAC1B,OAAO,GACP,WAAW,GACX,WAAW,GACX,SAAS,CAAC;AAEd,MAAM,MAAM,mBAAmB,GAAG;IAChC,EAAE,EAAE,OAAO,CAAC;IACZ,KAAK,EAAE,kBAAkB,CAAC;IAC1B,oEAAoE;IACpE,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC"}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,9 @@
1
+ import { COSE_ALG_KS256 } from "./cose-key.js";
2
+ export interface ParsedKs256RootKey {
3
+ kind: "KS256";
4
+ alg: typeof COSE_ALG_KS256;
5
+ address: Uint8Array;
6
+ }
7
+ export declare function isParsedKs256RootKey(key: unknown): key is ParsedKs256RootKey;
8
+ export type RootVerifyKey = CryptoKey | ParsedKs256RootKey;
9
+ //# sourceMappingURL=root-verify-key.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"root-verify-key.d.ts","sourceRoot":"","sources":["../src/root-verify-key.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAE/C,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,OAAO,CAAC;IACd,GAAG,EAAE,OAAO,cAAc,CAAC;IAC3B,OAAO,EAAE,UAAU,CAAC;CACrB;AAED,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,OAAO,GAAG,GAAG,IAAI,kBAAkB,CAQ5E;AAED,MAAM,MAAM,aAAa,GAAG,SAAS,GAAG,kBAAkB,CAAC"}
@@ -0,0 +1,7 @@
1
+ export function isParsedKs256RootKey(key) {
2
+ return (typeof key === "object" &&
3
+ key !== null &&
4
+ key.kind === "KS256" &&
5
+ key.address instanceof Uint8Array &&
6
+ key.address.length === 20);
7
+ }
@@ -0,0 +1,9 @@
1
+ import type { Hasher } from "@forestrie/merklelog";
2
+ /** Web Crypto SHA-256 hasher for Node and browser offline verify. */
3
+ export declare class SubtleHasher implements Hasher {
4
+ private chunks;
5
+ reset(): void;
6
+ update(data: Uint8Array): void;
7
+ digest(): Promise<Uint8Array>;
8
+ }
9
+ //# sourceMappingURL=subtle-hasher.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"subtle-hasher.d.ts","sourceRoot":"","sources":["../src/subtle-hasher.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAC;AAEnD,qEAAqE;AACrE,qBAAa,YAAa,YAAW,MAAM;IACzC,OAAO,CAAC,MAAM,CAAoB;IAElC,KAAK,IAAI,IAAI;IAIb,MAAM,CAAC,IAAI,EAAE,UAAU,GAAG,IAAI;IAIxB,MAAM,IAAI,OAAO,CAAC,UAAU,CAAC;CAWpC"}
@@ -0,0 +1,21 @@
1
+ /** Web Crypto SHA-256 hasher for Node and browser offline verify. */
2
+ export class SubtleHasher {
3
+ chunks = [];
4
+ reset() {
5
+ this.chunks = [];
6
+ }
7
+ update(data) {
8
+ this.chunks.push(data);
9
+ }
10
+ async digest() {
11
+ const totalLength = this.chunks.reduce((s, c) => s + c.length, 0);
12
+ const combined = new Uint8Array(totalLength);
13
+ let offset = 0;
14
+ for (const c of this.chunks) {
15
+ combined.set(c, offset);
16
+ offset += c.length;
17
+ }
18
+ const h = await crypto.subtle.digest("SHA-256", combined);
19
+ return new Uint8Array(h);
20
+ }
21
+ }
@@ -0,0 +1,4 @@
1
+ export declare const WIRE_LOG_ID_BYTES = 32;
2
+ export declare function toPaddedWire32(bytes: Uint8Array): Uint8Array;
3
+ export declare function fromPaddedWire32(wire: Uint8Array): Uint8Array;
4
+ //# sourceMappingURL=uuid-bytes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"uuid-bytes.d.ts","sourceRoot":"","sources":["../src/uuid-bytes.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,iBAAiB,KAAK,CAAC;AAEpC,wBAAgB,cAAc,CAAC,KAAK,EAAE,UAAU,GAAG,UAAU,CAS5D;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,UAAU,GAAG,UAAU,CAM7D"}
@@ -0,0 +1,21 @@
1
+ export const WIRE_LOG_ID_BYTES = 32;
2
+ export function toPaddedWire32(bytes) {
3
+ if (bytes.length === WIRE_LOG_ID_BYTES)
4
+ return bytes;
5
+ const out = new Uint8Array(WIRE_LOG_ID_BYTES);
6
+ if (bytes.length >= WIRE_LOG_ID_BYTES) {
7
+ out.set(bytes.slice(-WIRE_LOG_ID_BYTES));
8
+ }
9
+ else {
10
+ out.set(bytes, WIRE_LOG_ID_BYTES - bytes.length);
11
+ }
12
+ return out;
13
+ }
14
+ export function fromPaddedWire32(wire) {
15
+ if (wire.length === 16)
16
+ return wire;
17
+ if (wire.length !== WIRE_LOG_ID_BYTES) {
18
+ throw new Error(`Expected ${WIRE_LOG_ID_BYTES}-byte wire log id`);
19
+ }
20
+ return wire.slice(-16);
21
+ }
@@ -0,0 +1,13 @@
1
+ import type { Grant } from "./grant.js";
2
+ import type { ReceiptVerifyResult } from "./receipt-verify-result.js";
3
+ export type VerifyGrantReceiptOfflineInput = {
4
+ genesisCbor: Uint8Array;
5
+ receiptCbor: Uint8Array;
6
+ grant: Grant;
7
+ idtimestampBe8: Uint8Array;
8
+ };
9
+ /**
10
+ * Offline grant receipt verify (layers A–C, ADR-0045). Pure over bytes; no network.
11
+ */
12
+ export declare function verifyGrantReceiptOffline(input: VerifyGrantReceiptOfflineInput): Promise<ReceiptVerifyResult>;
13
+ //# sourceMappingURL=verify-grant-receipt-offline.d.ts.map
@@ -0,0 +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,YAAY,CAAC;AAMxC,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"}
@@ -0,0 +1,101 @@
1
+ import { verifyCoseSign1WithParsedKey } from "@forestrie/encoding";
2
+ import { calculateRoot, verifyInclusion, } from "@forestrie/merklelog";
3
+ import { grantCommitmentHashFromGrant } from "./grant-commitment.js";
4
+ import { decodeTrustRootFromGenesis } from "./decode-trust-root-from-genesis.js";
5
+ import { es256ReceiptVerifyKeys } from "./decode-trust-root-cbor.js";
6
+ import { univocityLeafHash } from "./leaf-commitment.js";
7
+ import { parseReceipt } from "./parse-receipt.js";
8
+ import { SubtleHasher } from "./subtle-hasher.js";
9
+ function readIdtimestampBe8(bytes) {
10
+ if (!bytes || bytes.length < 8) {
11
+ throw new Error("idtimestamp required for receipt verification (8 bytes)");
12
+ }
13
+ const view = bytes.length === 8
14
+ ? new DataView(bytes.buffer, bytes.byteOffset, 8)
15
+ : new DataView(bytes.buffer, bytes.byteOffset + bytes.length - 8, 8);
16
+ return view.getBigUint64(0, false);
17
+ }
18
+ async function verifySignatureAndInclusion(opts) {
19
+ const hasher = new SubtleHasher();
20
+ const leafIdx = opts.proof.leafIndex !== undefined
21
+ ? opts.proof.leafIndex
22
+ : opts.proof.mmrIndex;
23
+ const peak = opts.explicitPeak !== null
24
+ ? opts.explicitPeak
25
+ : await calculateRoot(hasher, opts.leafHash, opts.proof, leafIdx);
26
+ let sigOk = false;
27
+ for (const candidateKey of opts.verifyKeys) {
28
+ sigOk = await verifyCoseSign1WithParsedKey(opts.receiptCbor, candidateKey, {
29
+ logPrefix: "grant-receipt-offline",
30
+ detachedPayload: peak,
31
+ });
32
+ if (sigOk)
33
+ break;
34
+ if (opts.explicitPeak !== null) {
35
+ sigOk = await verifyCoseSign1WithParsedKey(opts.receiptCbor, candidateKey, { logPrefix: "grant-receipt-offline" });
36
+ if (sigOk)
37
+ break;
38
+ }
39
+ }
40
+ if (!sigOk) {
41
+ if (opts.explicitPeak !== null) {
42
+ const inclusionOk = await verifyInclusion(hasher, opts.leafHash, opts.proof, opts.explicitPeak);
43
+ return {
44
+ ok: false,
45
+ stage: "signature",
46
+ reason: inclusionOk ? "signature_invalid" : "signature_invalid",
47
+ };
48
+ }
49
+ return { ok: false, stage: "signature", reason: "signature_invalid" };
50
+ }
51
+ const inclusionOk = await verifyInclusion(hasher, opts.leafHash, opts.proof, peak);
52
+ if (!inclusionOk) {
53
+ return { ok: false, stage: "inclusion", reason: "inclusion_failed" };
54
+ }
55
+ return { ok: true, stage: "binding" };
56
+ }
57
+ /**
58
+ * Offline grant receipt verify (layers A–C, ADR-0045). Pure over bytes; no network.
59
+ */
60
+ export async function verifyGrantReceiptOffline(input) {
61
+ let parsed;
62
+ try {
63
+ parsed = parseReceipt(input.receiptCbor);
64
+ }
65
+ catch {
66
+ return { ok: false, stage: "parse", reason: "receipt_malformed" };
67
+ }
68
+ let trustRoot;
69
+ try {
70
+ trustRoot = await decodeTrustRootFromGenesis(input.genesisCbor);
71
+ }
72
+ catch {
73
+ return { ok: false, stage: "parse", reason: "genesis_invalid" };
74
+ }
75
+ const verifyKeys = es256ReceiptVerifyKeys([trustRoot]);
76
+ if (!verifyKeys.length) {
77
+ return { ok: false, stage: "signature", reason: "no_es256_trust_key" };
78
+ }
79
+ let inner;
80
+ try {
81
+ inner = await grantCommitmentHashFromGrant(input.grant);
82
+ }
83
+ catch {
84
+ return { ok: false, stage: "binding", reason: "grant_invalid" };
85
+ }
86
+ let idtimestamp;
87
+ try {
88
+ idtimestamp = readIdtimestampBe8(input.idtimestampBe8);
89
+ }
90
+ catch {
91
+ return { ok: false, stage: "binding", reason: "idtimestamp_invalid" };
92
+ }
93
+ const leafHash = await univocityLeafHash(idtimestamp, inner);
94
+ return verifySignatureAndInclusion({
95
+ receiptCbor: input.receiptCbor,
96
+ explicitPeak: parsed.explicitPeak,
97
+ proof: parsed.proof,
98
+ leafHash,
99
+ verifyKeys: verifyKeys,
100
+ });
101
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@forestrie/receipt-verify",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "description": "Offline SCITT grant receipt verification (ADR-0045)",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/forestrie/canopy.git",
10
+ "directory": "packages/libs/receipt-verify"
11
+ },
12
+ "main": "./dist/index.js",
13
+ "types": "./dist/index.d.ts",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "import": "./dist/index.js",
18
+ "default": "./dist/index.js"
19
+ }
20
+ },
21
+ "publishConfig": {
22
+ "registry": "https://registry.npmjs.org",
23
+ "access": "public"
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "src"
28
+ ],
29
+ "dependencies": {
30
+ "@noble/hashes": "^1.7.1",
31
+ "cbor-x": "^1.5.9",
32
+ "@forestrie/encoding": "0.0.1",
33
+ "@forestrie/merklelog": "0.0.1"
34
+ },
35
+ "devDependencies": {
36
+ "typescript": "^5.9.2",
37
+ "vitest": "^3.2.4"
38
+ },
39
+ "scripts": {
40
+ "build": "tsc -p tsconfig.build.json",
41
+ "typecheck": "tsc --noEmit",
42
+ "test": "vitest --run",
43
+ "test:watch": "vitest"
44
+ }
45
+ }
@@ -0,0 +1,9 @@
1
+ export const COSE_ALG_ES256 = -7;
2
+ export const COSE_ALG_KS256 = -65799;
3
+ export const COSE_KEY_KTY = 1;
4
+ export const COSE_KEY_ALG = 3;
5
+ export const COSE_EC2_CRV = -1;
6
+ export const COSE_EC2_X = -2;
7
+ export const COSE_EC2_Y = -3;
8
+ export const COSE_KTY_EC2 = 2;
9
+ export const COSE_CRV_P256 = 1;
@@ -0,0 +1,79 @@
1
+ import { sha256 } from "@noble/hashes/sha256";
2
+ import { decode as decodeCbor } from "cbor-x";
3
+ import type { Grant } from "./grant.js";
4
+ import {
5
+ HEADER_FORESTRIE_GRANT_V0,
6
+ HEADER_IDTIMESTAMP,
7
+ } from "./forest-genesis-labels.js";
8
+ import { decodeGrantPayload } from "./grant-codec.js";
9
+
10
+ const IDTIMESTAMP_BYTES = 8;
11
+
12
+ function toHeaderMap(
13
+ value: Map<number, unknown> | Record<string, unknown> | unknown,
14
+ ): Map<number, unknown> {
15
+ if (value instanceof Map) return value as Map<number, unknown>;
16
+ if (
17
+ typeof value === "object" &&
18
+ value !== null &&
19
+ !(value instanceof Uint8Array)
20
+ ) {
21
+ const out = new Map<number, unknown>();
22
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
23
+ const n = Number(k);
24
+ if (Number.isFinite(n)) out.set(n, v);
25
+ }
26
+ return out;
27
+ }
28
+ return new Map();
29
+ }
30
+
31
+ function digestEquals(a: Uint8Array, b: Uint8Array): boolean {
32
+ if (a.length !== b.length) return false;
33
+ let x = 0;
34
+ for (let i = 0; i < a.length; i++) x |= a[i]! ^ b[i]!;
35
+ return x === 0;
36
+ }
37
+
38
+ /**
39
+ * Decode Forestrie-Grant COSE Sign1 (Custodian transparent statement profile).
40
+ */
41
+ export function decodeForestrieGrantCose(bytes: Uint8Array): {
42
+ grant: Grant;
43
+ idtimestampBe8: Uint8Array;
44
+ } {
45
+ const raw = decodeCbor(bytes) as unknown;
46
+ const arr = Array.isArray(raw) ? raw : null;
47
+ if (!arr || arr.length !== 4) {
48
+ throw new Error("Forestrie-Grant must be COSE Sign1 (array of 4)");
49
+ }
50
+ const [, unprotectedRaw, payloadRaw] = arr as [
51
+ unknown,
52
+ Map<number, unknown> | Record<string, unknown>,
53
+ Uint8Array | null,
54
+ unknown,
55
+ ];
56
+ if (!(payloadRaw instanceof Uint8Array) || payloadRaw.length !== 32) {
57
+ throw new Error("Forestrie-Grant COSE payload must be 32-byte digest");
58
+ }
59
+ const unprotected = toHeaderMap(unprotectedRaw);
60
+ const embedded = unprotected.get(HEADER_FORESTRIE_GRANT_V0);
61
+ if (!(embedded instanceof Uint8Array) || embedded.length === 0) {
62
+ throw new Error("Forestrie-Grant missing unprotected -65538 grant CBOR");
63
+ }
64
+ if (!digestEquals(sha256(embedded), payloadRaw)) {
65
+ throw new Error("Forestrie-Grant digest mismatch");
66
+ }
67
+ const idtimestampVal = unprotected.get(HEADER_IDTIMESTAMP);
68
+ const idtimestampBe8 =
69
+ idtimestampVal instanceof Uint8Array &&
70
+ idtimestampVal.length >= IDTIMESTAMP_BYTES
71
+ ? idtimestampVal.length === IDTIMESTAMP_BYTES
72
+ ? idtimestampVal
73
+ : idtimestampVal.slice(-IDTIMESTAMP_BYTES)
74
+ : new Uint8Array(IDTIMESTAMP_BYTES);
75
+ return {
76
+ grant: decodeGrantPayload(embedded),
77
+ idtimestampBe8: new Uint8Array(idtimestampBe8),
78
+ };
79
+ }
@@ -0,0 +1,113 @@
1
+ import { COSE_ALG_ES256, COSE_ALG_KS256 } from "@forestrie/encoding";
2
+ import type { ParsedVerifyKey } from "@forestrie/encoding";
3
+ import {
4
+ isParsedKs256RootKey,
5
+ type ParsedKs256RootKey,
6
+ type RootVerifyKey,
7
+ } from "./root-verify-key.js";
8
+
9
+ export async function importEs256PublicKeyFromGrantDataXy64(
10
+ xy: Uint8Array,
11
+ ): Promise<CryptoKey> {
12
+ if (xy.length !== 64) {
13
+ throw new Error(
14
+ "ES256 grantData must be 64 bytes (x||y) for raw public key import",
15
+ );
16
+ }
17
+ const raw = new Uint8Array(65);
18
+ raw[0] = 0x04;
19
+ raw.set(xy, 1);
20
+ return crypto.subtle.importKey(
21
+ "raw",
22
+ raw,
23
+ { name: "ECDSA", namedCurve: "P-256" },
24
+ false,
25
+ ["verify"],
26
+ );
27
+ }
28
+
29
+ function asUint8Array(v: unknown): Uint8Array | null {
30
+ if (v instanceof Uint8Array) return v;
31
+ return null;
32
+ }
33
+
34
+ function parseAlgInt(raw: unknown): number | null {
35
+ if (typeof raw === "number" && Number.isInteger(raw)) return raw;
36
+ if (typeof raw === "bigint") {
37
+ const n = Number(raw);
38
+ return Number.isSafeInteger(n) ? n : null;
39
+ }
40
+ if (typeof raw === "string") {
41
+ const n = Number(raw);
42
+ if (Number.isInteger(n)) return n;
43
+ }
44
+ return null;
45
+ }
46
+
47
+ function ks256RootFromAddress(address: Uint8Array): ParsedKs256RootKey {
48
+ return { kind: "KS256", alg: COSE_ALG_KS256, address };
49
+ }
50
+
51
+ function recordToTrustRootFields(decoded: unknown): Record<string, unknown> {
52
+ if (decoded instanceof Map) {
53
+ const out: Record<string, unknown> = {};
54
+ for (const [k, v] of decoded.entries()) {
55
+ out[String(k)] = v;
56
+ }
57
+ return out;
58
+ }
59
+ if (typeof decoded === "object" && decoded !== null) {
60
+ return decoded as Record<string, unknown>;
61
+ }
62
+ throw new Error("trust-root CBOR must be a map");
63
+ }
64
+
65
+ export async function decodeTrustRootCbor(
66
+ decoded: unknown,
67
+ ): Promise<RootVerifyKey> {
68
+ const fields = recordToTrustRootFields(decoded);
69
+ const algRaw = fields.alg;
70
+ const key = asUint8Array(fields.key);
71
+ const algInt = parseAlgInt(algRaw);
72
+
73
+ if (algInt === COSE_ALG_KS256) {
74
+ if (!key || key.length !== 20) {
75
+ throw new Error("KS256 trust-root key must be 20 bytes");
76
+ }
77
+ return ks256RootFromAddress(key);
78
+ }
79
+
80
+ if (algInt === COSE_ALG_ES256 && key) {
81
+ if (key.length !== 64) {
82
+ throw new Error("ES256 trust-root key must be 64 bytes (x||y)");
83
+ }
84
+ return importEs256PublicKeyFromGrantDataXy64(key);
85
+ }
86
+
87
+ if (typeof algRaw === "string" && algRaw.toUpperCase() === "KS256") {
88
+ if (!key || key.length !== 20) {
89
+ throw new Error("KS256 trust-root key must be 20 bytes");
90
+ }
91
+ return ks256RootFromAddress(key);
92
+ }
93
+
94
+ if (typeof algRaw === "string" && algRaw.toUpperCase() === "ES256") {
95
+ const x = asUint8Array(fields.x);
96
+ const y = asUint8Array(fields.y);
97
+ if (!x || x.length !== 32 || !y || y.length !== 32) {
98
+ throw new Error("ES256 trust-root x/y must be 32 bytes each");
99
+ }
100
+ const xy = new Uint8Array(64);
101
+ xy.set(x, 0);
102
+ xy.set(y, 32);
103
+ return importEs256PublicKeyFromGrantDataXy64(xy);
104
+ }
105
+
106
+ throw new Error(`unsupported trust-root alg ${String(algRaw)}`);
107
+ }
108
+
109
+ export function es256ReceiptVerifyKeys(
110
+ keys: RootVerifyKey[],
111
+ ): ParsedVerifyKey[] {
112
+ return keys.filter((k): k is CryptoKey => !isParsedKs256RootKey(k));
113
+ }
@@ -0,0 +1,87 @@
1
+ import { decode as decodeCbor } from "cbor-x";
2
+ import {
3
+ COSE_ALG_ES256,
4
+ COSE_CRV_P256,
5
+ COSE_EC2_CRV,
6
+ COSE_EC2_X,
7
+ COSE_EC2_Y,
8
+ COSE_KEY_ALG,
9
+ COSE_KEY_KTY,
10
+ COSE_KTY_EC2,
11
+ } from "./cose-key.js";
12
+ import {
13
+ FOREST_GENESIS_LABEL_BOOTSTRAP_KEY,
14
+ FOREST_GENESIS_LABEL_GENESIS_ALG,
15
+ FOREST_GENESIS_LABEL_GENESIS_VERSION,
16
+ FOREST_GENESIS_SCHEMA_V1,
17
+ FOREST_GENESIS_SCHEMA_V2,
18
+ } from "./forest-genesis-labels.js";
19
+ import { decodeTrustRootCbor } from "./decode-trust-root-cbor.js";
20
+ import type { RootVerifyKey } from "./root-verify-key.js";
21
+
22
+ function decodeBodyAsIntKeyMap(raw: unknown): Map<number, unknown> | null {
23
+ if (raw instanceof Map) return raw as Map<number, unknown>;
24
+ if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
25
+ const out = new Map<number, unknown>();
26
+ for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
27
+ const n = Number(k);
28
+ if (Number.isFinite(n)) out.set(n, v);
29
+ }
30
+ return out;
31
+ }
32
+ return null;
33
+ }
34
+
35
+ function asGenesisUint8Array(v: unknown): Uint8Array | null {
36
+ return v instanceof Uint8Array ? v : null;
37
+ }
38
+
39
+ /**
40
+ * Extract receipt verify key from a forest genesis document CBOR blob.
41
+ * Offline path: genesis-only trust anchor (ADR-0045).
42
+ */
43
+ export async function decodeTrustRootFromGenesis(
44
+ genesisCbor: Uint8Array,
45
+ ): Promise<RootVerifyKey> {
46
+ let raw: unknown;
47
+ try {
48
+ raw = decodeCbor(genesisCbor);
49
+ } catch {
50
+ throw new Error("genesis CBOR decode failed");
51
+ }
52
+ const m = decodeBodyAsIntKeyMap(raw);
53
+ if (!m) throw new Error("genesis document must be a CBOR map");
54
+
55
+ const versionRaw = m.get(FOREST_GENESIS_LABEL_GENESIS_VERSION);
56
+ if (versionRaw === FOREST_GENESIS_SCHEMA_V2) {
57
+ const alg = m.get(FOREST_GENESIS_LABEL_GENESIS_ALG);
58
+ const bootstrapKey = asGenesisUint8Array(
59
+ m.get(FOREST_GENESIS_LABEL_BOOTSTRAP_KEY),
60
+ );
61
+ if (bootstrapKey === null) {
62
+ throw new Error("v2 genesis missing bootstrapKey");
63
+ }
64
+ return decodeTrustRootCbor({ alg, key: bootstrapKey });
65
+ }
66
+
67
+ const kty = m.get(COSE_KEY_KTY);
68
+ const crv = m.get(COSE_EC2_CRV);
69
+ const x = asGenesisUint8Array(m.get(COSE_EC2_X));
70
+ const y = asGenesisUint8Array(m.get(COSE_EC2_Y));
71
+ if (kty === COSE_KTY_EC2 && crv === COSE_CRV_P256 && x && y) {
72
+ const alg = m.get(COSE_KEY_ALG);
73
+ if (alg !== undefined && alg !== COSE_ALG_ES256) {
74
+ throw new Error("genesis EC2 key must be ES256");
75
+ }
76
+ const xy = new Uint8Array(64);
77
+ xy.set(x, 0);
78
+ xy.set(y, 32);
79
+ return decodeTrustRootCbor({ alg: COSE_ALG_ES256, key: xy });
80
+ }
81
+
82
+ if (versionRaw === FOREST_GENESIS_SCHEMA_V1 || versionRaw === undefined) {
83
+ throw new Error("unsupported or invalid genesis schema for trust root");
84
+ }
85
+
86
+ throw new Error("unsupported genesis document");
87
+ }
@@ -0,0 +1,10 @@
1
+ export const FOREST_GENESIS_LABEL_GENESIS_VERSION = -68009;
2
+ export const FOREST_GENESIS_LABEL_GENESIS_ALG = -68014;
3
+ export const FOREST_GENESIS_LABEL_BOOTSTRAP_KEY = -68015;
4
+ export const FOREST_GENESIS_LABEL_UNIVOCITY_ADDR = -68011;
5
+ export const FOREST_GENESIS_LABEL_CHAIN_ID = -68013;
6
+ export const FOREST_GENESIS_SCHEMA_V2 = 2;
7
+ export const FOREST_GENESIS_SCHEMA_V1 = 1;
8
+
9
+ export const HEADER_IDTIMESTAMP = -65537;
10
+ export const HEADER_FORESTRIE_GRANT_V0 = -65538;