@forestrie/receipt-verify 0.3.0 → 0.5.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.
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Self-created SCITT receipts from local artifacts.
3
+ *
4
+ * Mirrors canopy-api resolve-receipt receipt assembly, but over local massif
5
+ * (`.log`) and checkpoint (`.sth`) bytes instead of R2 objects. The checkpoint
6
+ * signature covers only the accumulator, so the inclusion path is free to
7
+ * rebuild client-side: read massif nodes, build the leaf→peak path, attach it
8
+ * at header 396 to the checkpoint's pre-signed peak receipt. The result is
9
+ * verify-equivalent with an API-issued receipt (FOR-334 AC: both pass
10
+ * verifyGrantReceiptOffline identically; byte-equality is intentionally not
11
+ * claimed — encoder-level variation is known-benign, see plan-2607-15 §2).
12
+ *
13
+ * `computeAccumulatorPeak` supports the chain-anchored variant: build the path
14
+ * at an externally attested tree size (e.g. Univocity `logState` on Base
15
+ * Sepolia) and compare the computed peak with the published accumulator.
16
+ */
17
+ import { openMassifNodeStore, type MassifNodeStore } from "@forestrie/merklelog";
18
+ import { type CoseSign1 } from "./parse-receipt.js";
19
+ /**
20
+ * Re-exported for compatibility. The MMR proof math and massif node store now
21
+ * live in `@forestrie/merklelog` (plan-2607-15 §4, phase 2); receipt-verify
22
+ * keeps only the COSE-shaped layer below.
23
+ */
24
+ export { openMassifNodeStore };
25
+ export type { MassifNodeStore };
26
+ export type ParsedCheckpoint = {
27
+ coseSign1: CoseSign1;
28
+ unprotected: Map<number, unknown>;
29
+ /** Pre-signed peak receipts (label -65931), or null when absent. */
30
+ peakReceipts: unknown[] | null;
31
+ /** Sealed tree size (tree-size-2 of the embedded consistency proof). */
32
+ mmrSize: bigint | null;
33
+ delegationCert: Uint8Array | null;
34
+ };
35
+ /** Parse a format-v3 checkpoint (`.sth`) COSE Sign1. */
36
+ export declare function parseCheckpoint(checkpointBytes: Uint8Array): ParsedCheckpoint;
37
+ export type BuildReceiptOfflineInput = {
38
+ massifBytes: Uint8Array;
39
+ checkpointBytes: Uint8Array;
40
+ mmrIndex: bigint;
41
+ };
42
+ /**
43
+ * Assemble a receipt for the entry at `mmrIndex` from a local massif blob and
44
+ * its checkpoint. Pure over bytes; no network, no signing key — the signature
45
+ * is the checkpoint's pre-signed peak receipt. Throws with a specific reason
46
+ * on any failure.
47
+ */
48
+ export declare function buildReceiptOffline(input: BuildReceiptOfflineInput): Uint8Array;
49
+ export type ComputedAccumulatorPeak = {
50
+ /** Root computed from the massif's leaf value and rebuilt path. */
51
+ peak: Uint8Array;
52
+ /** Position of that peak in the accumulator, left to right. */
53
+ peakIndex: number;
54
+ proof: Uint8Array[];
55
+ leafValue: Uint8Array;
56
+ };
57
+ /**
58
+ * Chain-anchored variant: rebuild the inclusion path for `mmrIndex` at an
59
+ * externally attested tree size and compute the accumulator peak it commits
60
+ * to. Compare `peak` with `accumulator[peakIndex]` published by the Univocity
61
+ * contract (`logState`). Requires the local massif to hold nodes up to
62
+ * `mmrSize`.
63
+ */
64
+ export declare function computeAccumulatorPeak(opts: {
65
+ massifBytes: Uint8Array;
66
+ mmrIndex: bigint;
67
+ mmrSize: bigint;
68
+ }): Promise<ComputedAccumulatorPeak>;
69
+ //# sourceMappingURL=build-receipt-offline.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"build-receipt-offline.d.ts","sourceRoot":"","sources":["../src/build-receipt-offline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAGH,OAAO,EAIL,mBAAmB,EAEnB,KAAK,eAAe,EAErB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAIL,KAAK,SAAS,EACf,MAAM,oBAAoB,CAAC;AAQ5B;;;;GAIG;AACH,OAAO,EAAE,mBAAmB,EAAE,CAAC;AAC/B,YAAY,EAAE,eAAe,EAAE,CAAC;AAEhC,MAAM,MAAM,gBAAgB,GAAG;IAC7B,SAAS,EAAE,SAAS,CAAC;IACrB,WAAW,EAAE,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAClC,oEAAoE;IACpE,YAAY,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC;IAC/B,wEAAwE;IACxE,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,cAAc,EAAE,UAAU,GAAG,IAAI,CAAC;CACnC,CAAC;AAEF,wDAAwD;AACxD,wBAAgB,eAAe,CAAC,eAAe,EAAE,UAAU,GAAG,gBAAgB,CAqB7E;AAED,MAAM,MAAM,wBAAwB,GAAG;IACrC,WAAW,EAAE,UAAU,CAAC;IACxB,eAAe,EAAE,UAAU,CAAC;IAC5B,QAAQ,EAAE,MAAM,CAAC;CAClB,CAAC;AAEF;;;;;GAKG;AACH,wBAAgB,mBAAmB,CACjC,KAAK,EAAE,wBAAwB,GAC9B,UAAU,CA2EZ;AAED,MAAM,MAAM,uBAAuB,GAAG;IACpC,mEAAmE;IACnE,IAAI,EAAE,UAAU,CAAC;IACjB,+DAA+D;IAC/D,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,UAAU,EAAE,CAAC;IACpB,SAAS,EAAE,UAAU,CAAC;CACvB,CAAC;AAEF;;;;;;GAMG;AACH,wBAAsB,sBAAsB,CAAC,IAAI,EAAE;IACjD,WAAW,EAAE,UAAU,CAAC;IACxB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;CACjB,GAAG,OAAO,CAAC,uBAAuB,CAAC,CAwBnC"}
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Self-created SCITT receipts from local artifacts.
3
+ *
4
+ * Mirrors canopy-api resolve-receipt receipt assembly, but over local massif
5
+ * (`.log`) and checkpoint (`.sth`) bytes instead of R2 objects. The checkpoint
6
+ * signature covers only the accumulator, so the inclusion path is free to
7
+ * rebuild client-side: read massif nodes, build the leaf→peak path, attach it
8
+ * at header 396 to the checkpoint's pre-signed peak receipt. The result is
9
+ * verify-equivalent with an API-issued receipt (FOR-334 AC: both pass
10
+ * verifyGrantReceiptOffline identically; byte-equality is intentionally not
11
+ * claimed — encoder-level variation is known-benign, see plan-2607-15 §2).
12
+ *
13
+ * `computeAccumulatorPeak` supports the chain-anchored variant: build the path
14
+ * at an externally attested tree size (e.g. Univocity `logState` on Base
15
+ * Sepolia) and compare the computed peak with the published accumulator.
16
+ */
17
+ import { decode as decodeCbor, encode as encodeCbor } from "cbor-x";
18
+ import { calculateRoot, inclusionProof, massifIndexFromMMRIndex, openMassifNodeStore, peakIndexForLeafProof, } from "@forestrie/merklelog";
19
+ import { requireCoseSign1, toHeaderMap, unwrapCoseSign1Tag, } from "./parse-receipt.js";
20
+ import { SubtleHasher } from "./subtle-hasher.js";
21
+ const VDS_COSE_RECEIPT_PROOFS_TAG = 396;
22
+ const SEAL_PEAK_RECEIPTS_LABEL = -65931;
23
+ const DELEGATION_CERT_LABEL = 1000;
24
+ const VDP_CONSISTENCY_PROOF_KEY = -2;
25
+ /**
26
+ * Re-exported for compatibility. The MMR proof math and massif node store now
27
+ * live in `@forestrie/merklelog` (plan-2607-15 §4, phase 2); receipt-verify
28
+ * keeps only the COSE-shaped layer below.
29
+ */
30
+ export { openMassifNodeStore };
31
+ /** Parse a format-v3 checkpoint (`.sth`) COSE Sign1. */
32
+ export function parseCheckpoint(checkpointBytes) {
33
+ const decoded = decodeCbor(checkpointBytes);
34
+ const coseSign1 = requireCoseSign1(unwrapCoseSign1Tag(decoded));
35
+ const unprotected = toHeaderMap(coseSign1[1]);
36
+ const peakReceiptsRaw = unprotected.get(SEAL_PEAK_RECEIPTS_LABEL);
37
+ const peakReceipts = Array.isArray(peakReceiptsRaw) ? peakReceiptsRaw : null;
38
+ const delegationCertRaw = unprotected.get(DELEGATION_CERT_LABEL);
39
+ const delegationCert = delegationCertRaw instanceof Uint8Array && delegationCertRaw.length > 0
40
+ ? delegationCertRaw
41
+ : null;
42
+ return {
43
+ coseSign1,
44
+ unprotected,
45
+ peakReceipts,
46
+ mmrSize: sealedSizeFromCheckpoint(unprotected),
47
+ delegationCert,
48
+ };
49
+ }
50
+ /**
51
+ * Assemble a receipt for the entry at `mmrIndex` from a local massif blob and
52
+ * its checkpoint. Pure over bytes; no network, no signing key — the signature
53
+ * is the checkpoint's pre-signed peak receipt. Throws with a specific reason
54
+ * on any failure.
55
+ */
56
+ export function buildReceiptOffline(input) {
57
+ const { mmrIndex } = input;
58
+ if (mmrIndex < 0n) {
59
+ throw new Error("mmrIndex must be non-negative");
60
+ }
61
+ const checkpoint = parseCheckpoint(input.checkpointBytes);
62
+ if (!checkpoint.peakReceipts) {
63
+ throw new Error("checkpoint carries no pre-signed peak receipts (label -65931)");
64
+ }
65
+ if (checkpoint.mmrSize === null || checkpoint.mmrSize <= 0n) {
66
+ throw new Error("checkpoint carries no consistency proof (cannot determine sealed size)");
67
+ }
68
+ const mmrSize = checkpoint.mmrSize;
69
+ const mmrLastIndex = mmrSize - 1n;
70
+ if (mmrIndex > mmrLastIndex) {
71
+ throw new Error(`checkpoint does not cover entry: mmrIndex ${mmrIndex.toString(10)} ` +
72
+ `>= sealed size ${mmrSize.toString(10)}`);
73
+ }
74
+ const store = openMassifNodeStore(input.massifBytes);
75
+ const expectedMassifIndex = massifIndexFromMMRIndex(store.massifHeight, mmrIndex);
76
+ if (expectedMassifIndex !== store.massifIndex) {
77
+ throw new Error(`entry at mmrIndex ${mmrIndex.toString(10)} lives in massif ` +
78
+ `${expectedMassifIndex.toString(10)}, but this blob is massif ` +
79
+ `${store.massifIndex.toString(10)}`);
80
+ }
81
+ const proof = inclusionProof(store.get, mmrLastIndex, mmrIndex);
82
+ const peakIdx = peakIndexForLeafProof(mmrSize, proof.length);
83
+ const receiptBytes = checkpoint.peakReceipts[peakIdx];
84
+ if (!(receiptBytes instanceof Uint8Array)) {
85
+ throw new Error(`checkpoint peak receipt slot ${peakIdx} is missing or not bstr`);
86
+ }
87
+ const receiptSign1 = requireCoseSign1(unwrapCoseSign1Tag(decodeCbor(receiptBytes)));
88
+ const receiptUnprotected = toHeaderMap(receiptSign1[1]);
89
+ if (checkpoint.delegationCert) {
90
+ receiptUnprotected.set(DELEGATION_CERT_LABEL, checkpoint.delegationCert);
91
+ }
92
+ const inclusionProofEntry = new Map([
93
+ [1, mmrIndex],
94
+ [2, proof],
95
+ ]);
96
+ const verifiableProofs = new Map([
97
+ [-1, [inclusionProofEntry]],
98
+ ]);
99
+ receiptUnprotected.set(VDS_COSE_RECEIPT_PROOFS_TAG, verifiableProofs);
100
+ // Peak receipts are signed with detached payload; emit nil so verify uses
101
+ // the peak derived from the inclusion proof.
102
+ const assembled = [
103
+ receiptSign1[0],
104
+ receiptUnprotected,
105
+ null,
106
+ receiptSign1[3],
107
+ ];
108
+ return cborBytes(assembled);
109
+ }
110
+ /**
111
+ * Chain-anchored variant: rebuild the inclusion path for `mmrIndex` at an
112
+ * externally attested tree size and compute the accumulator peak it commits
113
+ * to. Compare `peak` with `accumulator[peakIndex]` published by the Univocity
114
+ * contract (`logState`). Requires the local massif to hold nodes up to
115
+ * `mmrSize`.
116
+ */
117
+ export async function computeAccumulatorPeak(opts) {
118
+ const { mmrIndex, mmrSize } = opts;
119
+ if (mmrSize <= 0n || mmrIndex >= mmrSize) {
120
+ throw new Error(`mmrIndex ${mmrIndex.toString(10)} is not covered by tree size ` +
121
+ `${mmrSize.toString(10)} (entry not anchored yet?)`);
122
+ }
123
+ const store = openMassifNodeStore(opts.massifBytes);
124
+ const leafValue = store.get(mmrIndex);
125
+ const path = inclusionProof(store.get, mmrSize - 1n, mmrIndex);
126
+ const proof = { path, mmrIndex };
127
+ const peak = await calculateRoot(new SubtleHasher(), leafValue, proof, mmrIndex);
128
+ return {
129
+ peak,
130
+ peakIndex: peakIndexForLeafProof(mmrSize, path.length),
131
+ proof: path,
132
+ leafValue,
133
+ };
134
+ }
135
+ // --- helpers ---
136
+ function cborBytes(value) {
137
+ const encoded = encodeCbor(value);
138
+ return encoded instanceof Uint8Array
139
+ ? encoded
140
+ : new Uint8Array(encoded);
141
+ }
142
+ /**
143
+ * Sealed mmr size from a format-v3 checkpoint: tree-size-2 of the consistency
144
+ * proof (`bstr .cbor [tree-size-1, tree-size-2, paths, right-peaks]`) under
145
+ * the verifiable-proofs unprotected header (label 396, key -2).
146
+ */
147
+ function sealedSizeFromCheckpoint(unprotected) {
148
+ const vdpRaw = unprotected.get(VDS_COSE_RECEIPT_PROOFS_TAG);
149
+ if (vdpRaw === undefined || vdpRaw === null)
150
+ return null;
151
+ const vdp = toHeaderMap(vdpRaw);
152
+ const proofBstr = vdp.get(VDP_CONSISTENCY_PROOF_KEY);
153
+ if (!(proofBstr instanceof Uint8Array))
154
+ return null;
155
+ const proof = decodeCbor(proofBstr);
156
+ if (!Array.isArray(proof) || proof.length < 2)
157
+ return null;
158
+ const treeSize2 = proof[1];
159
+ if (typeof treeSize2 === "bigint")
160
+ return treeSize2;
161
+ if (typeof treeSize2 === "number" && Number.isSafeInteger(treeSize2)) {
162
+ return BigInt(treeSize2);
163
+ }
164
+ return null;
165
+ }
package/dist/index.d.ts CHANGED
@@ -3,6 +3,13 @@ export type { ReceiptVerifyResult, ReceiptVerifyStage, } from "./receipt-verify-
3
3
  export type { Grant } from "@forestrie/encoding";
4
4
  export type { VerifyGrantReceiptOfflineInput } from "./verify-grant-receipt-offline.js";
5
5
  export { parseReceipt } from "./parse-receipt.js";
6
+ export type { BuildReceiptOfflineInput, ComputedAccumulatorPeak, MassifNodeStore, ParsedCheckpoint, } from "./build-receipt-offline.js";
7
+ export { buildReceiptOffline, computeAccumulatorPeak, openMassifNodeStore, parseCheckpoint, } from "./build-receipt-offline.js";
8
+ /**
9
+ * MMR proof math now lives in @forestrie/merklelog (plan-2607-15 §4 hoist);
10
+ * re-exported here to preserve the receipt-verify public surface.
11
+ */
12
+ export { peakMMRIndexes } from "@forestrie/merklelog";
6
13
  export { decodeTrustRootFromGenesis } from "./decode-trust-root-from-genesis.js";
7
14
  export { verifyGrantReceiptOffline } from "./verify-grant-receipt-offline.js";
8
15
  export { decodeForestrieGrantCose } from "./decode-forestrie-grant-cose.js";
@@ -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,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"}
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"}
package/dist/index.js CHANGED
@@ -1,4 +1,10 @@
1
1
  export { parseReceipt } from "./parse-receipt.js";
2
+ export { buildReceiptOffline, computeAccumulatorPeak, openMassifNodeStore, parseCheckpoint, } from "./build-receipt-offline.js";
3
+ /**
4
+ * MMR proof math now lives in @forestrie/merklelog (plan-2607-15 §4 hoist);
5
+ * re-exported here to preserve the receipt-verify public surface.
6
+ */
7
+ export { peakMMRIndexes } from "@forestrie/merklelog";
2
8
  export { decodeTrustRootFromGenesis } from "./decode-trust-root-from-genesis.js";
3
9
  export { verifyGrantReceiptOffline } from "./verify-grant-receipt-offline.js";
4
10
  export { decodeForestrieGrantCose } from "./decode-forestrie-grant-cose.js";
@@ -1,15 +1,17 @@
1
1
  import type { Proof } from "@forestrie/merklelog";
2
- type CoseSign1 = [
2
+ export type CoseSign1 = [
3
3
  protectedHeader: Uint8Array,
4
4
  unprotectedHeader: Map<number, unknown> | Record<string, unknown>,
5
5
  payload: Uint8Array | null,
6
6
  signature: Uint8Array
7
7
  ];
8
+ export declare function unwrapCoseSign1Tag(value: unknown): unknown;
9
+ export declare function requireCoseSign1(value: unknown): CoseSign1;
10
+ export declare function toHeaderMap(value: Map<number, unknown> | Record<string, unknown>): Map<number, unknown>;
8
11
  export declare function parseReceipt(receiptBytes: Uint8Array): {
9
12
  explicitPeak: Uint8Array | null;
10
13
  proof: Proof;
11
14
  receiptCbor: Uint8Array;
12
15
  coseSign1: CoseSign1;
13
16
  };
14
- export {};
15
17
  //# sourceMappingURL=parse-receipt.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"parse-receipt.d.ts","sourceRoot":"","sources":["../src/parse-receipt.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,sBAAsB,CAAC;AAIlD,KAAK,SAAS,GAAG;IACf,eAAe,EAAE,UAAU;IAC3B,iBAAiB,EAAE,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IACjE,OAAO,EAAE,UAAU,GAAG,IAAI;IAC1B,SAAS,EAAE,UAAU;CACtB,CAAC;AAgDF,wBAAgB,YAAY,CAAC,YAAY,EAAE,UAAU,GAAG;IACtD,YAAY,EAAE,UAAU,GAAG,IAAI,CAAC;IAChC,KAAK,EAAE,KAAK,CAAC;IACb,WAAW,EAAE,UAAU,CAAC;IACxB,SAAS,EAAE,SAAS,CAAC;CACtB,CAwDA"}
1
+ {"version":3,"file":"parse-receipt.d.ts","sourceRoot":"","sources":["../src/parse-receipt.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,sBAAsB,CAAC;AAIlD,MAAM,MAAM,SAAS,GAAG;IACtB,eAAe,EAAE,UAAU;IAC3B,iBAAiB,EAAE,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IACjE,OAAO,EAAE,UAAU,GAAG,IAAI;IAC1B,SAAS,EAAE,UAAU;CACtB,CAAC;AAEF,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAW1D;AAED,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,SAAS,CAiB1D;AAED,wBAAgB,WAAW,CACzB,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GACpD,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC,CAUtB;AAED,wBAAgB,YAAY,CAAC,YAAY,EAAE,UAAU,GAAG;IACtD,YAAY,EAAE,UAAU,GAAG,IAAI,CAAC;IAChC,KAAK,EAAE,KAAK,CAAC;IACb,WAAW,EAAE,UAAU,CAAC;IACxB,SAAS,EAAE,SAAS,CAAC;CACtB,CAwDA"}
@@ -1,6 +1,6 @@
1
1
  import { decode as decodeCbor } from "cbor-x";
2
2
  const VDS_COSE_RECEIPT_PROOFS_TAG = 396;
3
- function unwrapCoseSign1Tag(value) {
3
+ export function unwrapCoseSign1Tag(value) {
4
4
  if (value && typeof value === "object" && !(value instanceof Map)) {
5
5
  const tagged = value;
6
6
  if (Object.prototype.hasOwnProperty.call(tagged, "value") &&
@@ -10,7 +10,7 @@ function unwrapCoseSign1Tag(value) {
10
10
  }
11
11
  return value;
12
12
  }
13
- function requireCoseSign1(value) {
13
+ export function requireCoseSign1(value) {
14
14
  if (!Array.isArray(value) || value.length !== 4) {
15
15
  throw new Error("Receipt is not a COSE Sign1 array");
16
16
  }
@@ -28,7 +28,7 @@ function requireCoseSign1(value) {
28
28
  sig,
29
29
  ];
30
30
  }
31
- function toHeaderMap(value) {
31
+ export function toHeaderMap(value) {
32
32
  if (value instanceof Map)
33
33
  return value;
34
34
  const out = new Map();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forestrie/receipt-verify",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "Offline SCITT grant receipt verification (ADR-0045)",
@@ -29,8 +29,8 @@
29
29
  "dependencies": {
30
30
  "@noble/hashes": "^1.7.1",
31
31
  "cbor-x": "^1.5.9",
32
- "@forestrie/encoding": "0.1.0",
33
- "@forestrie/merklelog": "0.0.3"
32
+ "@forestrie/merklelog": "0.1.0",
33
+ "@forestrie/encoding": "0.2.0"
34
34
  },
35
35
  "devDependencies": {
36
36
  "esbuild": "^0.24.0",
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Self-created SCITT receipts from local artifacts.
3
+ *
4
+ * Mirrors canopy-api resolve-receipt receipt assembly, but over local massif
5
+ * (`.log`) and checkpoint (`.sth`) bytes instead of R2 objects. The checkpoint
6
+ * signature covers only the accumulator, so the inclusion path is free to
7
+ * rebuild client-side: read massif nodes, build the leaf→peak path, attach it
8
+ * at header 396 to the checkpoint's pre-signed peak receipt. The result is
9
+ * verify-equivalent with an API-issued receipt (FOR-334 AC: both pass
10
+ * verifyGrantReceiptOffline identically; byte-equality is intentionally not
11
+ * claimed — encoder-level variation is known-benign, see plan-2607-15 §2).
12
+ *
13
+ * `computeAccumulatorPeak` supports the chain-anchored variant: build the path
14
+ * at an externally attested tree size (e.g. Univocity `logState` on Base
15
+ * Sepolia) and compare the computed peak with the published accumulator.
16
+ */
17
+
18
+ import { decode as decodeCbor, encode as encodeCbor } from "cbor-x";
19
+ import {
20
+ calculateRoot,
21
+ inclusionProof,
22
+ massifIndexFromMMRIndex,
23
+ openMassifNodeStore,
24
+ peakIndexForLeafProof,
25
+ type MassifNodeStore,
26
+ type Proof,
27
+ } from "@forestrie/merklelog";
28
+ import {
29
+ requireCoseSign1,
30
+ toHeaderMap,
31
+ unwrapCoseSign1Tag,
32
+ type CoseSign1,
33
+ } from "./parse-receipt.js";
34
+ import { SubtleHasher } from "./subtle-hasher.js";
35
+
36
+ const VDS_COSE_RECEIPT_PROOFS_TAG = 396;
37
+ const SEAL_PEAK_RECEIPTS_LABEL = -65931;
38
+ const DELEGATION_CERT_LABEL = 1000;
39
+ const VDP_CONSISTENCY_PROOF_KEY = -2;
40
+
41
+ /**
42
+ * Re-exported for compatibility. The MMR proof math and massif node store now
43
+ * live in `@forestrie/merklelog` (plan-2607-15 §4, phase 2); receipt-verify
44
+ * keeps only the COSE-shaped layer below.
45
+ */
46
+ export { openMassifNodeStore };
47
+ export type { MassifNodeStore };
48
+
49
+ export type ParsedCheckpoint = {
50
+ coseSign1: CoseSign1;
51
+ unprotected: Map<number, unknown>;
52
+ /** Pre-signed peak receipts (label -65931), or null when absent. */
53
+ peakReceipts: unknown[] | null;
54
+ /** Sealed tree size (tree-size-2 of the embedded consistency proof). */
55
+ mmrSize: bigint | null;
56
+ delegationCert: Uint8Array | null;
57
+ };
58
+
59
+ /** Parse a format-v3 checkpoint (`.sth`) COSE Sign1. */
60
+ export function parseCheckpoint(checkpointBytes: Uint8Array): ParsedCheckpoint {
61
+ const decoded = decodeCbor(checkpointBytes) as unknown;
62
+ const coseSign1 = requireCoseSign1(unwrapCoseSign1Tag(decoded));
63
+ const unprotected = toHeaderMap(coseSign1[1]);
64
+
65
+ const peakReceiptsRaw = unprotected.get(SEAL_PEAK_RECEIPTS_LABEL);
66
+ const peakReceipts = Array.isArray(peakReceiptsRaw) ? peakReceiptsRaw : null;
67
+
68
+ const delegationCertRaw = unprotected.get(DELEGATION_CERT_LABEL);
69
+ const delegationCert =
70
+ delegationCertRaw instanceof Uint8Array && delegationCertRaw.length > 0
71
+ ? delegationCertRaw
72
+ : null;
73
+
74
+ return {
75
+ coseSign1,
76
+ unprotected,
77
+ peakReceipts,
78
+ mmrSize: sealedSizeFromCheckpoint(unprotected),
79
+ delegationCert,
80
+ };
81
+ }
82
+
83
+ export type BuildReceiptOfflineInput = {
84
+ massifBytes: Uint8Array;
85
+ checkpointBytes: Uint8Array;
86
+ mmrIndex: bigint;
87
+ };
88
+
89
+ /**
90
+ * Assemble a receipt for the entry at `mmrIndex` from a local massif blob and
91
+ * its checkpoint. Pure over bytes; no network, no signing key — the signature
92
+ * is the checkpoint's pre-signed peak receipt. Throws with a specific reason
93
+ * on any failure.
94
+ */
95
+ export function buildReceiptOffline(
96
+ input: BuildReceiptOfflineInput,
97
+ ): Uint8Array {
98
+ const { mmrIndex } = input;
99
+ if (mmrIndex < 0n) {
100
+ throw new Error("mmrIndex must be non-negative");
101
+ }
102
+
103
+ const checkpoint = parseCheckpoint(input.checkpointBytes);
104
+ if (!checkpoint.peakReceipts) {
105
+ throw new Error(
106
+ "checkpoint carries no pre-signed peak receipts (label -65931)",
107
+ );
108
+ }
109
+ if (checkpoint.mmrSize === null || checkpoint.mmrSize <= 0n) {
110
+ throw new Error(
111
+ "checkpoint carries no consistency proof (cannot determine sealed size)",
112
+ );
113
+ }
114
+ const mmrSize = checkpoint.mmrSize;
115
+ const mmrLastIndex = mmrSize - 1n;
116
+ if (mmrIndex > mmrLastIndex) {
117
+ throw new Error(
118
+ `checkpoint does not cover entry: mmrIndex ${mmrIndex.toString(10)} ` +
119
+ `>= sealed size ${mmrSize.toString(10)}`,
120
+ );
121
+ }
122
+
123
+ const store = openMassifNodeStore(input.massifBytes);
124
+ const expectedMassifIndex = massifIndexFromMMRIndex(
125
+ store.massifHeight,
126
+ mmrIndex,
127
+ );
128
+ if (expectedMassifIndex !== store.massifIndex) {
129
+ throw new Error(
130
+ `entry at mmrIndex ${mmrIndex.toString(10)} lives in massif ` +
131
+ `${expectedMassifIndex.toString(10)}, but this blob is massif ` +
132
+ `${store.massifIndex.toString(10)}`,
133
+ );
134
+ }
135
+
136
+ const proof = inclusionProof(store.get, mmrLastIndex, mmrIndex);
137
+ const peakIdx = peakIndexForLeafProof(mmrSize, proof.length);
138
+ const receiptBytes = checkpoint.peakReceipts[peakIdx];
139
+ if (!(receiptBytes instanceof Uint8Array)) {
140
+ throw new Error(
141
+ `checkpoint peak receipt slot ${peakIdx} is missing or not bstr`,
142
+ );
143
+ }
144
+
145
+ const receiptSign1 = requireCoseSign1(
146
+ unwrapCoseSign1Tag(decodeCbor(receiptBytes) as unknown),
147
+ );
148
+ const receiptUnprotected = toHeaderMap(receiptSign1[1]);
149
+
150
+ if (checkpoint.delegationCert) {
151
+ receiptUnprotected.set(DELEGATION_CERT_LABEL, checkpoint.delegationCert);
152
+ }
153
+
154
+ const inclusionProofEntry = new Map<number, unknown>([
155
+ [1, mmrIndex],
156
+ [2, proof],
157
+ ]);
158
+ const verifiableProofs = new Map<number, unknown>([
159
+ [-1, [inclusionProofEntry]],
160
+ ]);
161
+ receiptUnprotected.set(VDS_COSE_RECEIPT_PROOFS_TAG, verifiableProofs);
162
+
163
+ // Peak receipts are signed with detached payload; emit nil so verify uses
164
+ // the peak derived from the inclusion proof.
165
+ const assembled: CoseSign1 = [
166
+ receiptSign1[0],
167
+ receiptUnprotected,
168
+ null,
169
+ receiptSign1[3],
170
+ ];
171
+ return cborBytes(assembled);
172
+ }
173
+
174
+ export type ComputedAccumulatorPeak = {
175
+ /** Root computed from the massif's leaf value and rebuilt path. */
176
+ peak: Uint8Array;
177
+ /** Position of that peak in the accumulator, left to right. */
178
+ peakIndex: number;
179
+ proof: Uint8Array[];
180
+ leafValue: Uint8Array;
181
+ };
182
+
183
+ /**
184
+ * Chain-anchored variant: rebuild the inclusion path for `mmrIndex` at an
185
+ * externally attested tree size and compute the accumulator peak it commits
186
+ * to. Compare `peak` with `accumulator[peakIndex]` published by the Univocity
187
+ * contract (`logState`). Requires the local massif to hold nodes up to
188
+ * `mmrSize`.
189
+ */
190
+ export async function computeAccumulatorPeak(opts: {
191
+ massifBytes: Uint8Array;
192
+ mmrIndex: bigint;
193
+ mmrSize: bigint;
194
+ }): Promise<ComputedAccumulatorPeak> {
195
+ const { mmrIndex, mmrSize } = opts;
196
+ if (mmrSize <= 0n || mmrIndex >= mmrSize) {
197
+ throw new Error(
198
+ `mmrIndex ${mmrIndex.toString(10)} is not covered by tree size ` +
199
+ `${mmrSize.toString(10)} (entry not anchored yet?)`,
200
+ );
201
+ }
202
+ const store = openMassifNodeStore(opts.massifBytes);
203
+ const leafValue = store.get(mmrIndex);
204
+ const path = inclusionProof(store.get, mmrSize - 1n, mmrIndex);
205
+ const proof: Proof = { path, mmrIndex };
206
+ const peak = await calculateRoot(
207
+ new SubtleHasher(),
208
+ leafValue,
209
+ proof,
210
+ mmrIndex,
211
+ );
212
+ return {
213
+ peak,
214
+ peakIndex: peakIndexForLeafProof(mmrSize, path.length),
215
+ proof: path,
216
+ leafValue,
217
+ };
218
+ }
219
+
220
+ // --- helpers ---
221
+
222
+ function cborBytes(value: unknown): Uint8Array {
223
+ const encoded = encodeCbor(value);
224
+ return encoded instanceof Uint8Array
225
+ ? encoded
226
+ : new Uint8Array(encoded as ArrayLike<number>);
227
+ }
228
+
229
+ /**
230
+ * Sealed mmr size from a format-v3 checkpoint: tree-size-2 of the consistency
231
+ * proof (`bstr .cbor [tree-size-1, tree-size-2, paths, right-peaks]`) under
232
+ * the verifiable-proofs unprotected header (label 396, key -2).
233
+ */
234
+ function sealedSizeFromCheckpoint(
235
+ unprotected: Map<number, unknown>,
236
+ ): bigint | null {
237
+ const vdpRaw = unprotected.get(VDS_COSE_RECEIPT_PROOFS_TAG);
238
+ if (vdpRaw === undefined || vdpRaw === null) return null;
239
+ const vdp = toHeaderMap(vdpRaw as Map<number, unknown>);
240
+ const proofBstr = vdp.get(VDP_CONSISTENCY_PROOF_KEY);
241
+ if (!(proofBstr instanceof Uint8Array)) return null;
242
+ const proof = decodeCbor(proofBstr) as unknown;
243
+ if (!Array.isArray(proof) || proof.length < 2) return null;
244
+ const treeSize2 = proof[1];
245
+ if (typeof treeSize2 === "bigint") return treeSize2;
246
+ if (typeof treeSize2 === "number" && Number.isSafeInteger(treeSize2)) {
247
+ return BigInt(treeSize2);
248
+ }
249
+ return null;
250
+ }
package/src/index.ts CHANGED
@@ -6,6 +6,23 @@ export type {
6
6
  export type { Grant } from "@forestrie/encoding";
7
7
  export type { VerifyGrantReceiptOfflineInput } from "./verify-grant-receipt-offline.js";
8
8
  export { parseReceipt } from "./parse-receipt.js";
9
+ export type {
10
+ BuildReceiptOfflineInput,
11
+ ComputedAccumulatorPeak,
12
+ MassifNodeStore,
13
+ ParsedCheckpoint,
14
+ } from "./build-receipt-offline.js";
15
+ export {
16
+ buildReceiptOffline,
17
+ computeAccumulatorPeak,
18
+ openMassifNodeStore,
19
+ parseCheckpoint,
20
+ } from "./build-receipt-offline.js";
21
+ /**
22
+ * MMR proof math now lives in @forestrie/merklelog (plan-2607-15 §4 hoist);
23
+ * re-exported here to preserve the receipt-verify public surface.
24
+ */
25
+ export { peakMMRIndexes } from "@forestrie/merklelog";
9
26
  export { decodeTrustRootFromGenesis } from "./decode-trust-root-from-genesis.js";
10
27
  export { verifyGrantReceiptOffline } from "./verify-grant-receipt-offline.js";
11
28
  export { decodeForestrieGrantCose } from "./decode-forestrie-grant-cose.js";
@@ -3,14 +3,14 @@ import type { Proof } from "@forestrie/merklelog";
3
3
 
4
4
  const VDS_COSE_RECEIPT_PROOFS_TAG = 396;
5
5
 
6
- type CoseSign1 = [
6
+ export type CoseSign1 = [
7
7
  protectedHeader: Uint8Array,
8
8
  unprotectedHeader: Map<number, unknown> | Record<string, unknown>,
9
9
  payload: Uint8Array | null,
10
10
  signature: Uint8Array,
11
11
  ];
12
12
 
13
- function unwrapCoseSign1Tag(value: unknown): unknown {
13
+ export function unwrapCoseSign1Tag(value: unknown): unknown {
14
14
  if (value && typeof value === "object" && !(value instanceof Map)) {
15
15
  const tagged = value as { tag?: number; value?: unknown };
16
16
  if (
@@ -23,7 +23,7 @@ function unwrapCoseSign1Tag(value: unknown): unknown {
23
23
  return value;
24
24
  }
25
25
 
26
- function requireCoseSign1(value: unknown): CoseSign1 {
26
+ export function requireCoseSign1(value: unknown): CoseSign1 {
27
27
  if (!Array.isArray(value) || value.length !== 4) {
28
28
  throw new Error("Receipt is not a COSE Sign1 array");
29
29
  }
@@ -42,7 +42,7 @@ function requireCoseSign1(value: unknown): CoseSign1 {
42
42
  ] as CoseSign1;
43
43
  }
44
44
 
45
- function toHeaderMap(
45
+ export function toHeaderMap(
46
46
  value: Map<number, unknown> | Record<string, unknown>,
47
47
  ): Map<number, unknown> {
48
48
  if (value instanceof Map) return value as Map<number, unknown>;