@forestrie/receipt-verify 0.7.0 → 0.8.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.
@@ -46,6 +46,16 @@ export type BuildReceiptOfflineInput = {
46
46
  * on any failure.
47
47
  */
48
48
  export declare function buildReceiptOffline(input: BuildReceiptOfflineInput): Uint8Array;
49
+ /**
50
+ * Assemble a receipt from a checkpoint's pre-signed peak receipt and a
51
+ * caller-supplied inclusion `proof` for `mmrIndex`: pick the peak receipt the
52
+ * proof roots to, attach the proof at header 396 and copy the label-1000
53
+ * delegation cert. The checkpoint signature covers only the accumulator (peak),
54
+ * so the unprotected 396 header is free to carry the path — this is why a path
55
+ * from ANY source (massif tiles here; a tile-free consistency-proof extension in
56
+ * `freshenReceipt`) yields a `verify --genesis`-equivalent native receipt.
57
+ */
58
+ export declare function assembleReceiptFromProof(checkpoint: ParsedCheckpoint, mmrIndex: bigint, proof: Uint8Array[]): Uint8Array;
49
59
  export type ComputedAccumulatorPeak = {
50
60
  /** Root computed from the massif's leaf value and rebuilt path. */
51
61
  peak: Uint8Array;
@@ -1 +1 @@
1
- {"version":3,"file":"build-receipt-offline.d.ts","sourceRoot":"","sources":["../src/build-receipt-offline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAMH,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"}
1
+ {"version":3,"file":"build-receipt-offline.d.ts","sourceRoot":"","sources":["../src/build-receipt-offline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAMH,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,CAyCZ;AAED;;;;;;;;GAQG;AACH,wBAAgB,wBAAwB,CACtC,UAAU,EAAE,gBAAgB,EAC5B,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,UAAU,EAAE,GAClB,UAAU,CA4CZ;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"}
@@ -79,7 +79,25 @@ export function buildReceiptOffline(input) {
79
79
  `${store.massifIndex.toString(10)}`);
80
80
  }
81
81
  const proof = inclusionProof(store.get, mmrLastIndex, mmrIndex);
82
- const peakIdx = peakIndexForLeafProof(mmrSize, proof.length);
82
+ return assembleReceiptFromProof(checkpoint, mmrIndex, proof);
83
+ }
84
+ /**
85
+ * Assemble a receipt from a checkpoint's pre-signed peak receipt and a
86
+ * caller-supplied inclusion `proof` for `mmrIndex`: pick the peak receipt the
87
+ * proof roots to, attach the proof at header 396 and copy the label-1000
88
+ * delegation cert. The checkpoint signature covers only the accumulator (peak),
89
+ * so the unprotected 396 header is free to carry the path — this is why a path
90
+ * from ANY source (massif tiles here; a tile-free consistency-proof extension in
91
+ * `freshenReceipt`) yields a `verify --genesis`-equivalent native receipt.
92
+ */
93
+ export function assembleReceiptFromProof(checkpoint, mmrIndex, proof) {
94
+ if (checkpoint.mmrSize === null || checkpoint.mmrSize <= 0n) {
95
+ throw new Error("checkpoint carries no sealed size");
96
+ }
97
+ if (!checkpoint.peakReceipts) {
98
+ throw new Error("checkpoint carries no pre-signed peak receipts (label -65931)");
99
+ }
100
+ const peakIdx = peakIndexForLeafProof(checkpoint.mmrSize, proof.length);
83
101
  const receiptBytes = checkpoint.peakReceipts[peakIdx];
84
102
  if (!(receiptBytes instanceof Uint8Array)) {
85
103
  throw new Error(`checkpoint peak receipt slot ${peakIdx} is missing or not bstr`);
@@ -1 +1 @@
1
- {"version":3,"file":"checkpoint-chain.d.ts","sourceRoot":"","sources":["../src/checkpoint-chain.ts"],"names":[],"mappings":"AA6BA,iEAAiE;AACjE,MAAM,MAAM,0BAA0B,GAAG;IACvC,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,sEAAsE;IACtE,KAAK,EAAE,UAAU,EAAE,EAAE,CAAC;IACtB,uEAAuE;IACvE,UAAU,EAAE,UAAU,EAAE,CAAC;CAC1B,CAAC;AAeF,gEAAgE;AAChE,wBAAgB,0BAA0B,CACxC,eAAe,EAAE,UAAU,GAC1B,0BAA0B,CAiC5B;AAED;;;;;GAKG;AACH,wBAAsB,4BAA4B,CAChD,KAAK,EAAE,0BAA0B,EACjC,eAAe,EAAE,UAAU,EAAE,GAC5B,OAAO,CAAC,UAAU,EAAE,CAAC,CAmBvB;AAED;+DAC+D;AAC/D,wBAAgB,kBAAkB,CAAC,WAAW,EAAE,UAAU,EAAE,GAAG,UAAU,CAQxE;AAED,MAAM,MAAM,mBAAmB,GAAG;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,UAAU,EAAE,CAAC;IAC1B,WAAW,EAAE,OAAO,CAAC;CACtB,CAAC;AAEF,MAAM,MAAM,qBAAqB,GAC7B;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,mBAAmB,EAAE,CAAC;IAAC,WAAW,EAAE,UAAU,EAAE,CAAA;CAAE,GACrE;IACE,EAAE,EAAE,KAAK,CAAC;IACV,MAAM,EACF,aAAa,GACb,oBAAoB,GACpB,WAAW,GACX,iBAAiB,CAAC;IACtB,yCAAyC;IACzC,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,mBAAmB,EAAE,CAAC;CAC9B,CAAC;AAEN;;;;;;;;;;;;;GAaG;AACH,wBAAsB,qBAAqB,CAAC,IAAI,EAAE;IAChD,WAAW,EAAE,UAAU,EAAE,CAAC;IAC1B,eAAe,EAAE,CACf,eAAe,EAAE,UAAU,EAC3B,eAAe,EAAE,UAAU,KACxB,OAAO,CAAC,OAAO,CAAC,CAAC;IACtB,4EAA4E;IAC5E,eAAe,CAAC,EAAE,UAAU,EAAE,CAAC;CAChC,GAAG,OAAO,CAAC,qBAAqB,CAAC,CAiGjC"}
1
+ {"version":3,"file":"checkpoint-chain.d.ts","sourceRoot":"","sources":["../src/checkpoint-chain.ts"],"names":[],"mappings":"AA6BA,iEAAiE;AACjE,MAAM,MAAM,0BAA0B,GAAG;IACvC,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,sEAAsE;IACtE,KAAK,EAAE,UAAU,EAAE,EAAE,CAAC;IACtB,uEAAuE;IACvE,UAAU,EAAE,UAAU,EAAE,CAAC;CAC1B,CAAC;AA2BF,gEAAgE;AAChE,wBAAgB,0BAA0B,CACxC,eAAe,EAAE,UAAU,GAC1B,0BAA0B,CA6C5B;AAED;;;;;GAKG;AACH,wBAAsB,4BAA4B,CAChD,KAAK,EAAE,0BAA0B,EACjC,eAAe,EAAE,UAAU,EAAE,GAC5B,OAAO,CAAC,UAAU,EAAE,CAAC,CAmBvB;AAED;+DAC+D;AAC/D,wBAAgB,kBAAkB,CAAC,WAAW,EAAE,UAAU,EAAE,GAAG,UAAU,CAQxE;AAED,MAAM,MAAM,mBAAmB,GAAG;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,UAAU,EAAE,CAAC;IAC1B,WAAW,EAAE,OAAO,CAAC;CACtB,CAAC;AAEF,MAAM,MAAM,qBAAqB,GAC7B;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,mBAAmB,EAAE,CAAC;IAAC,WAAW,EAAE,UAAU,EAAE,CAAA;CAAE,GACrE;IACE,EAAE,EAAE,KAAK,CAAC;IACV,MAAM,EACF,aAAa,GACb,oBAAoB,GACpB,WAAW,GACX,iBAAiB,CAAC;IACtB,yCAAyC;IACzC,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,mBAAmB,EAAE,CAAC;CAC9B,CAAC;AAEN;;;;;;;;;;;;;GAaG;AACH,wBAAsB,qBAAqB,CAAC,IAAI,EAAE;IAChD,WAAW,EAAE,UAAU,EAAE,CAAC;IAC1B,eAAe,EAAE,CACf,eAAe,EAAE,UAAU,EAC3B,eAAe,EAAE,UAAU,KACxB,OAAO,CAAC,OAAO,CAAC,CAAC;IACtB,4EAA4E;IAC5E,eAAe,CAAC,EAAE,UAAU,EAAE,CAAC;CAChC,GAAG,OAAO,CAAC,qBAAqB,CAAC,CAiGjC"}
@@ -26,15 +26,23 @@ import { parseCheckpoint } from "./build-receipt-offline.js";
26
26
  const VDS_COSE_RECEIPT_PROOFS_TAG = 396;
27
27
  const VDP_CONSISTENCY_PROOF_KEY = -2;
28
28
  function asBigint(v, what) {
29
- if (typeof v === "bigint")
29
+ // Unsigned only: a negative size flows into `peakMMRIndexes`, whose
30
+ // `posHeight` spins forever on a non-positive argument — a malformed `.sth`
31
+ // must be rejected in bounded time, before any such call (FOR-414).
32
+ if (typeof v === "bigint") {
33
+ if (v < 0n)
34
+ throw new Error(`${what}: must be an unsigned integer, got ${v}`);
30
35
  return v;
31
- if (typeof v === "number" && Number.isSafeInteger(v))
36
+ }
37
+ if (typeof v === "number" && Number.isSafeInteger(v) && v >= 0) {
32
38
  return BigInt(v);
39
+ }
33
40
  throw new Error(`${what}: expected an unsigned integer`);
34
41
  }
35
42
  function asBytesArray(v, what) {
36
- if (!Array.isArray(v) || v.some((e) => !(e instanceof Uint8Array))) {
37
- throw new Error(`${what}: expected an array of byte strings`);
43
+ if (!Array.isArray(v) ||
44
+ v.some((e) => !(e instanceof Uint8Array) || e.length !== 32)) {
45
+ throw new Error(`${what}: expected an array of 32-byte strings`);
38
46
  }
39
47
  return v;
40
48
  }
@@ -58,9 +66,19 @@ export function checkpointConsistencyProof(checkpointBytes) {
58
66
  pathsRaw.some((p) => !Array.isArray(p) || p.some((n) => !(n instanceof Uint8Array)))) {
59
67
  throw new Error("consistency paths must be arrays of byte strings");
60
68
  }
69
+ const treeSize1 = asBigint(proof[0], "tree-size-1");
70
+ const treeSize2 = asBigint(proof[1], "tree-size-2");
71
+ // A consistency proof strictly grows the tree; enforce `0 <= ts1 < ts2`
72
+ // (ts1 == 0 is a legitimate base-0 first link). This is the primary guard
73
+ // that keeps `treeSize2 - 1` / `treeSize1 - 1` non-negative before they
74
+ // reach `peakMMRIndexes` (FOR-414); the unsigned check in `asBigint` and
75
+ // the `posHeight` guard in @forestrie/merklelog are defence-in-depth.
76
+ if (treeSize2 <= treeSize1) {
77
+ throw new Error(`consistency proof must grow the tree: tree-size-1 ${treeSize1} < tree-size-2 ${treeSize2}`);
78
+ }
61
79
  return {
62
- treeSize1: asBigint(proof[0], "tree-size-1"),
63
- treeSize2: asBigint(proof[1], "tree-size-2"),
80
+ treeSize1,
81
+ treeSize2,
64
82
  paths: pathsRaw,
65
83
  rightPeaks: asBytesArray(proof[3], "right-peaks"),
66
84
  };
@@ -0,0 +1,32 @@
1
+ import { type CheckpointConsistencyProof } from "./checkpoint-chain.js";
2
+ export type FreshenReceiptInput = {
3
+ /** The stale receipt (COSE Sign1 with a 396 inclusion proof). */
4
+ oldReceiptBytes: Uint8Array;
5
+ /** The leaf's committed value: `SHA-256(idtimestamp ‖ inner)` — the same
6
+ * value `verify` recomputes from the entry (caller derives it). */
7
+ leafValue: Uint8Array;
8
+ /** Consistency-proof chain covering [0 or a trusted seed] → the latest sealed
9
+ * size, in ascending contiguous order (the raw per-checkpoint proofs, with
10
+ * `paths`). The chain's last link must end at the checkpoint's sealed size. */
11
+ consistencyProofs: readonly CheckpointConsistencyProof[];
12
+ /** Trusted accumulator seed for a suffix chain; omit for a chain from base 0.
13
+ * Its peak count must match the first link's tree-size-1. */
14
+ accumulatorFrom?: Uint8Array[];
15
+ /** The latest checkpoint (`.sth`): its pre-signed peak receipts + delegation
16
+ * cert become the freshened receipt's signature. */
17
+ latestCheckpointBytes: Uint8Array;
18
+ };
19
+ export type FreshenReceiptResult = {
20
+ /** The freshened native receipt (verifies against the latest state). */
21
+ receipt: Uint8Array;
22
+ /** Sealed size the freshened receipt is anchored at. */
23
+ sealedSize: bigint;
24
+ };
25
+ /**
26
+ * Freshen a stale receipt to the latest sealed state. Throws if the chain is
27
+ * not a contiguous cover from the trusted base to the checkpoint's sealed size,
28
+ * if a climb node is missing from the supplied proofs, or if the recomputed
29
+ * peak does not match the folded latest accumulator.
30
+ */
31
+ export declare function freshenReceipt(input: FreshenReceiptInput): Promise<FreshenReceiptResult>;
32
+ //# sourceMappingURL=freshen-receipt.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"freshen-receipt.d.ts","sourceRoot":"","sources":["../src/freshen-receipt.ts"],"names":[],"mappings":"AA+CA,OAAO,EAEL,KAAK,0BAA0B,EAChC,MAAM,uBAAuB,CAAC;AAI/B,MAAM,MAAM,mBAAmB,GAAG;IAChC,iEAAiE;IACjE,eAAe,EAAE,UAAU,CAAC;IAC5B;uEACmE;IACnE,SAAS,EAAE,UAAU,CAAC;IACtB;;mFAE+E;IAC/E,iBAAiB,EAAE,SAAS,0BAA0B,EAAE,CAAC;IACzD;iEAC6D;IAC7D,eAAe,CAAC,EAAE,UAAU,EAAE,CAAC;IAC/B;wDACoD;IACpD,qBAAqB,EAAE,UAAU,CAAC;CACnC,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,wEAAwE;IACxE,OAAO,EAAE,UAAU,CAAC;IACpB,wDAAwD;IACxD,UAAU,EAAE,MAAM,CAAC;CACpB,CAAC;AASF;;;;;GAKG;AACH,wBAAsB,cAAc,CAClC,KAAK,EAAE,mBAAmB,GACzB,OAAO,CAAC,oBAAoB,CAAC,CAmJ/B"}
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Freshen a stale receipt (FOR-418 Phase 3, plan-2607-32).
3
+ *
4
+ * A receipt goes stale when log growth buries the peak it commits to. Freshening
5
+ * re-anchors it to the CURRENT sealed state without tiles: extend the leaf's
6
+ * inclusion path from its old peak up to the latest accumulator using the
7
+ * checkpoint chain's consistency proofs (the tile-free source of the climb
8
+ * nodes), then attach that fresh path at header 396 to the LATEST checkpoint's
9
+ * pre-signed peak receipt. The result is a native receipt that verifies with
10
+ * plain `verify --genesis` against the current state — the latest checkpoint
11
+ * carries the genesis-verifiable delegation cert (see plan-2607-32's Phase 3
12
+ * finding: the calldata provider supplies the climb material tile-free, but the
13
+ * signature/cert comes from the latest `.sth`).
14
+ *
15
+ * Trust note: the freshened receipt is defined SOLELY by the latest checkpoint —
16
+ * its label-1000 delegation cert and its pre-signed peak receipt. Owner-rooting
17
+ * is established by the verify step's trust anchor: `verify --genesis` rejects a
18
+ * cert whose delegator is not the genesis owner (a hard failure, not a warning).
19
+ * A rotation of the delegated-TO sealer key is routine and within the owner's
20
+ * authority, so freshen does NOT flag it — there is no signer-change gate (this
21
+ * supersedes the earlier `--allow-new-signer` sketch; see the plan-2607-32 F2
22
+ * note). The old receipt's cert is irrelevant to the emitted artifact.
23
+ *
24
+ * Two independent fail-closed guards keep a bad assembly from ever minting:
25
+ * - Cross-checks against the checkpoint being borrowed from: the supplied
26
+ * chain must reach the checkpoint's sealed size and fold to exactly the
27
+ * number of peaks the checkpoint pre-signed (freshen holds no signing key, so
28
+ * the cryptographic peak↔signature tie stays with downstream verify; these
29
+ * structural checks turn a chain/checkpoint mismatch into a mint-time error
30
+ * instead of an unverifiable receipt).
31
+ * - The path self-check: `calculateRoot(leaf, freshPath)` must equal the
32
+ * covering peak of the folded latest accumulator.
33
+ *
34
+ * The climb arithmetic is `@forestrie/merklelog`'s `inclusionProofPath` (a
35
+ * tested go-merklelog port); this module only assembles node values by index
36
+ * from the old receipt path + the consistency proofs.
37
+ */
38
+ import { calculateRoot, inclusionProofPath, peakIndexForLeafProof, peakMMRIndexes, } from "@forestrie/merklelog";
39
+ import { assembleReceiptFromProof, parseCheckpoint, } from "./build-receipt-offline.js";
40
+ import { computeCheckpointAccumulator, } from "./checkpoint-chain.js";
41
+ import { parseReceipt } from "./parse-receipt.js";
42
+ import { SubtleHasher } from "./subtle-hasher.js";
43
+ function bytesEqual(a, b) {
44
+ if (a.length !== b.length)
45
+ return false;
46
+ let x = 0;
47
+ for (let i = 0; i < a.length; i++)
48
+ x |= a[i] ^ b[i];
49
+ return x === 0;
50
+ }
51
+ /**
52
+ * Freshen a stale receipt to the latest sealed state. Throws if the chain is
53
+ * not a contiguous cover from the trusted base to the checkpoint's sealed size,
54
+ * if a climb node is missing from the supplied proofs, or if the recomputed
55
+ * peak does not match the folded latest accumulator.
56
+ */
57
+ export async function freshenReceipt(input) {
58
+ const { proof } = parseReceipt(input.oldReceiptBytes);
59
+ // `parseReceipt` addresses the leaf by MMR index (396 proof entry key 1);
60
+ // `inclusionProofPath` below needs exactly that MMR index. (`Proof.leafIndex`
61
+ // is a distinct leaf-ordinal field that parseReceipt never sets — do not use
62
+ // it here, or a future producer that populated it would feed a non-MMR-index
63
+ // into the climb.)
64
+ const leafMmrIndex = proof.mmrIndex;
65
+ if (leafMmrIndex === undefined) {
66
+ throw new Error("receipt inclusion proof carries no mmr index");
67
+ }
68
+ const oldPath = proof.path;
69
+ const latest = parseCheckpoint(input.latestCheckpointBytes);
70
+ if (latest.mmrSize === null || latest.mmrSize <= 0n) {
71
+ throw new Error("latest checkpoint carries no sealed size");
72
+ }
73
+ const sealedSize = latest.mmrSize;
74
+ if (leafMmrIndex >= sealedSize) {
75
+ throw new Error(`receipt entry ${leafMmrIndex} is newer than the latest sealed size ${sealedSize}`);
76
+ }
77
+ // --- validate the supplied chain shape before folding (F5) ---
78
+ const links = input.consistencyProofs;
79
+ if (links.length === 0) {
80
+ throw new Error("freshen requires at least one consistency proof linking the receipt's era to the checkpoint");
81
+ }
82
+ const firstLink = links[0];
83
+ // Base: a base-0 chain starts from an empty accumulator; a suffix chain's
84
+ // trusted seed must have the peak count of its tree-size-1.
85
+ const baseCount = input.accumulatorFrom?.length ?? 0;
86
+ if (firstLink.treeSize1 === 0n) {
87
+ if (baseCount !== 0) {
88
+ throw new Error("base-0 consistency chain must start from an empty accumulator seed");
89
+ }
90
+ }
91
+ else {
92
+ const wanted = peakMMRIndexes(firstLink.treeSize1 - 1n).length;
93
+ if (baseCount !== wanted) {
94
+ throw new Error(`base accumulator has ${baseCount} peaks; first link size ${firstLink.treeSize1} requires ${wanted}`);
95
+ }
96
+ }
97
+ // Contiguity: each link continues where the previous one sealed.
98
+ for (let i = 1; i < links.length; i++) {
99
+ if (links[i].treeSize1 !== links[i - 1].treeSize2) {
100
+ throw new Error(`consistency chain is not contiguous at link ${i}: base ${links[i].treeSize1} != previous sealed size ${links[i - 1].treeSize2}`);
101
+ }
102
+ }
103
+ // Endpoint: the chain must reach exactly the checkpoint's sealed size (F1).
104
+ const lastLink = links[links.length - 1];
105
+ if (lastLink.treeSize2 !== sealedSize) {
106
+ throw new Error(`consistency chain ends at size ${lastLink.treeSize2} but the checkpoint sealed size ${sealedSize}`);
107
+ }
108
+ // Fold the chain to the latest accumulator (self-check target).
109
+ let accumulator = input.accumulatorFrom ?? [];
110
+ for (const p of links) {
111
+ accumulator = await computeCheckpointAccumulator(p, accumulator);
112
+ }
113
+ const aLatest = accumulator;
114
+ // Cross-check the fold against the checkpoint we are borrowing from (F1): the
115
+ // folded accumulator must have the structural peak count for the sealed size
116
+ // AND match the number of pre-signed peak receipts the checkpoint carries.
117
+ // (The cryptographic peak↔signature tie is enforced by downstream verify;
118
+ // freshen holds no key.)
119
+ const structuralPeaks = peakMMRIndexes(sealedSize - 1n).length;
120
+ if (aLatest.length !== structuralPeaks) {
121
+ throw new Error(`folded accumulator has ${aLatest.length} peaks; sealed size ${sealedSize} requires ${structuralPeaks}`);
122
+ }
123
+ if (!latest.peakReceipts) {
124
+ throw new Error("latest checkpoint carries no pre-signed peak receipts (label -65931)");
125
+ }
126
+ if (latest.peakReceipts.length !== aLatest.length) {
127
+ throw new Error(`checkpoint carries ${latest.peakReceipts.length} peak receipts but the folded accumulator has ${aLatest.length} peaks — chain does not match this checkpoint`);
128
+ }
129
+ // Assemble the leaf's inclusion path at the latest size from index-addressed
130
+ // node values: the old receipt path (leaf → old peak, a prefix by MMR
131
+ // prefix-composability) + the consistency proofs (the climb extension).
132
+ const fullIndices = inclusionProofPath(sealedSize - 1n, leafMmrIndex);
133
+ if (fullIndices.length < oldPath.length) {
134
+ throw new Error("receipt path is longer than the latest inclusion path — stale/forged receipt");
135
+ }
136
+ const store = new Map();
137
+ for (let k = 0; k < oldPath.length; k++) {
138
+ store.set(fullIndices[k], oldPath[k]);
139
+ }
140
+ for (const link of links) {
141
+ const fromPeaks = peakMMRIndexes(link.treeSize1 - 1n);
142
+ fromPeaks.forEach((peakIndex, j) => {
143
+ const climb = link.paths[j];
144
+ if (climb === undefined)
145
+ return;
146
+ const climbIndices = inclusionProofPath(link.treeSize2 - 1n, peakIndex);
147
+ climbIndices.forEach((ix, e) => {
148
+ const v = climb[e];
149
+ if (v !== undefined)
150
+ store.set(ix, v);
151
+ });
152
+ });
153
+ }
154
+ const freshPath = fullIndices.map((ix) => {
155
+ const v = store.get(ix);
156
+ if (v === undefined) {
157
+ throw new Error(`checkpoint chain does not cover the extension node at ${ix} — supply the full chain from the receipt's era`);
158
+ }
159
+ return v;
160
+ });
161
+ // Self-check: the fresh path must recompute the covering peak of the latest
162
+ // accumulator. Fails closed rather than emitting a bad receipt.
163
+ const hasher = new SubtleHasher();
164
+ const root = await calculateRoot(hasher, input.leafValue, { path: freshPath, mmrIndex: leafMmrIndex }, leafMmrIndex);
165
+ const peakIdx = peakIndexForLeafProof(sealedSize, freshPath.length);
166
+ if (peakIdx >= aLatest.length || !bytesEqual(root, aLatest[peakIdx])) {
167
+ throw new Error("freshened path does not recompute the latest accumulator peak — chain/leaf mismatch");
168
+ }
169
+ const receipt = assembleReceiptFromProof(latest, leafMmrIndex, freshPath);
170
+ return { receipt, sealedSize };
171
+ }
package/dist/index.d.ts CHANGED
@@ -5,7 +5,10 @@ export type { VerifyGrantReceiptOfflineInput } from "./verify-grant-receipt-offl
5
5
  export type { VerifyReceiptOfflineInput } from "./verify-grant-receipt-offline.js";
6
6
  export { parseReceipt } from "./parse-receipt.js";
7
7
  export type { BuildReceiptOfflineInput, ComputedAccumulatorPeak, MassifNodeStore, ParsedCheckpoint, } from "./build-receipt-offline.js";
8
- export { buildReceiptOffline, computeAccumulatorPeak, openMassifNodeStore, parseCheckpoint, } from "./build-receipt-offline.js";
8
+ export { assembleReceiptFromProof, buildReceiptOffline, computeAccumulatorPeak, openMassifNodeStore, parseCheckpoint, } from "./build-receipt-offline.js";
9
+ /** Freshen a stale receipt to the latest sealed state, tile-free (FOR-418). */
10
+ export { freshenReceipt } from "./freshen-receipt.js";
11
+ export type { FreshenReceiptInput, FreshenReceiptResult, } from "./freshen-receipt.js";
9
12
  /**
10
13
  * MMR proof math now lives in @forestrie/merklelog (plan-2607-15 §4 hoist);
11
14
  * re-exported here to preserve the receipt-verify public surface.
@@ -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,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;AACzD,OAAO,EACL,kBAAkB,EAClB,0BAA0B,EAC1B,4BAA4B,EAC5B,qBAAqB,EACrB,KAAK,mBAAmB,EACxB,KAAK,qBAAqB,EAC1B,KAAK,0BAA0B,GAChC,MAAM,uBAAuB,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,wBAAwB,EACxB,mBAAmB,EACnB,sBAAsB,EACtB,mBAAmB,EACnB,eAAe,GAChB,MAAM,4BAA4B,CAAC;AACpC,+EAA+E;AAC/E,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACtD,YAAY,EACV,mBAAmB,EACnB,oBAAoB,GACrB,MAAM,sBAAsB,CAAC;AAC9B;;;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;AACzD,OAAO,EACL,kBAAkB,EAClB,0BAA0B,EAC1B,4BAA4B,EAC5B,qBAAqB,EACrB,KAAK,mBAAmB,EACxB,KAAK,qBAAqB,EAC1B,KAAK,0BAA0B,GAChC,MAAM,uBAAuB,CAAC"}
package/dist/index.js CHANGED
@@ -1,5 +1,7 @@
1
1
  export { parseReceipt } from "./parse-receipt.js";
2
- export { buildReceiptOffline, computeAccumulatorPeak, openMassifNodeStore, parseCheckpoint, } from "./build-receipt-offline.js";
2
+ export { assembleReceiptFromProof, buildReceiptOffline, computeAccumulatorPeak, openMassifNodeStore, parseCheckpoint, } from "./build-receipt-offline.js";
3
+ /** Freshen a stale receipt to the latest sealed state, tile-free (FOR-418). */
4
+ export { freshenReceipt } from "./freshen-receipt.js";
3
5
  /**
4
6
  * MMR proof math now lives in @forestrie/merklelog (plan-2607-15 §4 hoist);
5
7
  * re-exported here to preserve the receipt-verify public surface.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forestrie/receipt-verify",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "Offline SCITT grant receipt verification (ADR-0045)",
@@ -29,7 +29,7 @@
29
29
  "dependencies": {
30
30
  "@noble/hashes": "^1.7.1",
31
31
  "@forestrie/encoding": "0.5.0",
32
- "@forestrie/merklelog": "0.2.0"
32
+ "@forestrie/merklelog": "0.3.0"
33
33
  },
34
34
  "devDependencies": {
35
35
  "esbuild": "^0.24.0",
@@ -137,7 +137,32 @@ export function buildReceiptOffline(
137
137
  }
138
138
 
139
139
  const proof = inclusionProof(store.get, mmrLastIndex, mmrIndex);
140
- const peakIdx = peakIndexForLeafProof(mmrSize, proof.length);
140
+ return assembleReceiptFromProof(checkpoint, mmrIndex, proof);
141
+ }
142
+
143
+ /**
144
+ * Assemble a receipt from a checkpoint's pre-signed peak receipt and a
145
+ * caller-supplied inclusion `proof` for `mmrIndex`: pick the peak receipt the
146
+ * proof roots to, attach the proof at header 396 and copy the label-1000
147
+ * delegation cert. The checkpoint signature covers only the accumulator (peak),
148
+ * so the unprotected 396 header is free to carry the path — this is why a path
149
+ * from ANY source (massif tiles here; a tile-free consistency-proof extension in
150
+ * `freshenReceipt`) yields a `verify --genesis`-equivalent native receipt.
151
+ */
152
+ export function assembleReceiptFromProof(
153
+ checkpoint: ParsedCheckpoint,
154
+ mmrIndex: bigint,
155
+ proof: Uint8Array[],
156
+ ): Uint8Array {
157
+ if (checkpoint.mmrSize === null || checkpoint.mmrSize <= 0n) {
158
+ throw new Error("checkpoint carries no sealed size");
159
+ }
160
+ if (!checkpoint.peakReceipts) {
161
+ throw new Error(
162
+ "checkpoint carries no pre-signed peak receipts (label -65931)",
163
+ );
164
+ }
165
+ const peakIdx = peakIndexForLeafProof(checkpoint.mmrSize, proof.length);
141
166
  const receiptBytes = checkpoint.peakReceipts[peakIdx];
142
167
  if (!(receiptBytes instanceof Uint8Array)) {
143
168
  throw new Error(
@@ -38,14 +38,26 @@ export type CheckpointConsistencyProof = {
38
38
  };
39
39
 
40
40
  function asBigint(v: unknown, what: string): bigint {
41
- if (typeof v === "bigint") return v;
42
- if (typeof v === "number" && Number.isSafeInteger(v)) return BigInt(v);
41
+ // Unsigned only: a negative size flows into `peakMMRIndexes`, whose
42
+ // `posHeight` spins forever on a non-positive argument — a malformed `.sth`
43
+ // must be rejected in bounded time, before any such call (FOR-414).
44
+ if (typeof v === "bigint") {
45
+ if (v < 0n)
46
+ throw new Error(`${what}: must be an unsigned integer, got ${v}`);
47
+ return v;
48
+ }
49
+ if (typeof v === "number" && Number.isSafeInteger(v) && v >= 0) {
50
+ return BigInt(v);
51
+ }
43
52
  throw new Error(`${what}: expected an unsigned integer`);
44
53
  }
45
54
 
46
55
  function asBytesArray(v: unknown, what: string): Uint8Array[] {
47
- if (!Array.isArray(v) || v.some((e) => !(e instanceof Uint8Array))) {
48
- throw new Error(`${what}: expected an array of byte strings`);
56
+ if (
57
+ !Array.isArray(v) ||
58
+ v.some((e) => !(e instanceof Uint8Array) || e.length !== 32)
59
+ ) {
60
+ throw new Error(`${what}: expected an array of 32-byte strings`);
49
61
  }
50
62
  return v as Uint8Array[];
51
63
  }
@@ -80,9 +92,21 @@ export function checkpointConsistencyProof(
80
92
  ) {
81
93
  throw new Error("consistency paths must be arrays of byte strings");
82
94
  }
95
+ const treeSize1 = asBigint(proof[0], "tree-size-1");
96
+ const treeSize2 = asBigint(proof[1], "tree-size-2");
97
+ // A consistency proof strictly grows the tree; enforce `0 <= ts1 < ts2`
98
+ // (ts1 == 0 is a legitimate base-0 first link). This is the primary guard
99
+ // that keeps `treeSize2 - 1` / `treeSize1 - 1` non-negative before they
100
+ // reach `peakMMRIndexes` (FOR-414); the unsigned check in `asBigint` and
101
+ // the `posHeight` guard in @forestrie/merklelog are defence-in-depth.
102
+ if (treeSize2 <= treeSize1) {
103
+ throw new Error(
104
+ `consistency proof must grow the tree: tree-size-1 ${treeSize1} < tree-size-2 ${treeSize2}`,
105
+ );
106
+ }
83
107
  return {
84
- treeSize1: asBigint(proof[0], "tree-size-1"),
85
- treeSize2: asBigint(proof[1], "tree-size-2"),
108
+ treeSize1,
109
+ treeSize2,
86
110
  paths: pathsRaw as Uint8Array[][],
87
111
  rightPeaks: asBytesArray(proof[3], "right-peaks"),
88
112
  };
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Freshen a stale receipt (FOR-418 Phase 3, plan-2607-32).
3
+ *
4
+ * A receipt goes stale when log growth buries the peak it commits to. Freshening
5
+ * re-anchors it to the CURRENT sealed state without tiles: extend the leaf's
6
+ * inclusion path from its old peak up to the latest accumulator using the
7
+ * checkpoint chain's consistency proofs (the tile-free source of the climb
8
+ * nodes), then attach that fresh path at header 396 to the LATEST checkpoint's
9
+ * pre-signed peak receipt. The result is a native receipt that verifies with
10
+ * plain `verify --genesis` against the current state — the latest checkpoint
11
+ * carries the genesis-verifiable delegation cert (see plan-2607-32's Phase 3
12
+ * finding: the calldata provider supplies the climb material tile-free, but the
13
+ * signature/cert comes from the latest `.sth`).
14
+ *
15
+ * Trust note: the freshened receipt is defined SOLELY by the latest checkpoint —
16
+ * its label-1000 delegation cert and its pre-signed peak receipt. Owner-rooting
17
+ * is established by the verify step's trust anchor: `verify --genesis` rejects a
18
+ * cert whose delegator is not the genesis owner (a hard failure, not a warning).
19
+ * A rotation of the delegated-TO sealer key is routine and within the owner's
20
+ * authority, so freshen does NOT flag it — there is no signer-change gate (this
21
+ * supersedes the earlier `--allow-new-signer` sketch; see the plan-2607-32 F2
22
+ * note). The old receipt's cert is irrelevant to the emitted artifact.
23
+ *
24
+ * Two independent fail-closed guards keep a bad assembly from ever minting:
25
+ * - Cross-checks against the checkpoint being borrowed from: the supplied
26
+ * chain must reach the checkpoint's sealed size and fold to exactly the
27
+ * number of peaks the checkpoint pre-signed (freshen holds no signing key, so
28
+ * the cryptographic peak↔signature tie stays with downstream verify; these
29
+ * structural checks turn a chain/checkpoint mismatch into a mint-time error
30
+ * instead of an unverifiable receipt).
31
+ * - The path self-check: `calculateRoot(leaf, freshPath)` must equal the
32
+ * covering peak of the folded latest accumulator.
33
+ *
34
+ * The climb arithmetic is `@forestrie/merklelog`'s `inclusionProofPath` (a
35
+ * tested go-merklelog port); this module only assembles node values by index
36
+ * from the old receipt path + the consistency proofs.
37
+ */
38
+ import {
39
+ calculateRoot,
40
+ inclusionProofPath,
41
+ peakIndexForLeafProof,
42
+ peakMMRIndexes,
43
+ } from "@forestrie/merklelog";
44
+ import {
45
+ assembleReceiptFromProof,
46
+ parseCheckpoint,
47
+ } from "./build-receipt-offline.js";
48
+ import {
49
+ computeCheckpointAccumulator,
50
+ type CheckpointConsistencyProof,
51
+ } from "./checkpoint-chain.js";
52
+ import { parseReceipt } from "./parse-receipt.js";
53
+ import { SubtleHasher } from "./subtle-hasher.js";
54
+
55
+ export type FreshenReceiptInput = {
56
+ /** The stale receipt (COSE Sign1 with a 396 inclusion proof). */
57
+ oldReceiptBytes: Uint8Array;
58
+ /** The leaf's committed value: `SHA-256(idtimestamp ‖ inner)` — the same
59
+ * value `verify` recomputes from the entry (caller derives it). */
60
+ leafValue: Uint8Array;
61
+ /** Consistency-proof chain covering [0 or a trusted seed] → the latest sealed
62
+ * size, in ascending contiguous order (the raw per-checkpoint proofs, with
63
+ * `paths`). The chain's last link must end at the checkpoint's sealed size. */
64
+ consistencyProofs: readonly CheckpointConsistencyProof[];
65
+ /** Trusted accumulator seed for a suffix chain; omit for a chain from base 0.
66
+ * Its peak count must match the first link's tree-size-1. */
67
+ accumulatorFrom?: Uint8Array[];
68
+ /** The latest checkpoint (`.sth`): its pre-signed peak receipts + delegation
69
+ * cert become the freshened receipt's signature. */
70
+ latestCheckpointBytes: Uint8Array;
71
+ };
72
+
73
+ export type FreshenReceiptResult = {
74
+ /** The freshened native receipt (verifies against the latest state). */
75
+ receipt: Uint8Array;
76
+ /** Sealed size the freshened receipt is anchored at. */
77
+ sealedSize: bigint;
78
+ };
79
+
80
+ function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
81
+ if (a.length !== b.length) return false;
82
+ let x = 0;
83
+ for (let i = 0; i < a.length; i++) x |= a[i]! ^ b[i]!;
84
+ return x === 0;
85
+ }
86
+
87
+ /**
88
+ * Freshen a stale receipt to the latest sealed state. Throws if the chain is
89
+ * not a contiguous cover from the trusted base to the checkpoint's sealed size,
90
+ * if a climb node is missing from the supplied proofs, or if the recomputed
91
+ * peak does not match the folded latest accumulator.
92
+ */
93
+ export async function freshenReceipt(
94
+ input: FreshenReceiptInput,
95
+ ): Promise<FreshenReceiptResult> {
96
+ const { proof } = parseReceipt(input.oldReceiptBytes);
97
+ // `parseReceipt` addresses the leaf by MMR index (396 proof entry key 1);
98
+ // `inclusionProofPath` below needs exactly that MMR index. (`Proof.leafIndex`
99
+ // is a distinct leaf-ordinal field that parseReceipt never sets — do not use
100
+ // it here, or a future producer that populated it would feed a non-MMR-index
101
+ // into the climb.)
102
+ const leafMmrIndex = proof.mmrIndex;
103
+ if (leafMmrIndex === undefined) {
104
+ throw new Error("receipt inclusion proof carries no mmr index");
105
+ }
106
+ const oldPath = proof.path;
107
+
108
+ const latest = parseCheckpoint(input.latestCheckpointBytes);
109
+ if (latest.mmrSize === null || latest.mmrSize <= 0n) {
110
+ throw new Error("latest checkpoint carries no sealed size");
111
+ }
112
+ const sealedSize = latest.mmrSize;
113
+ if (leafMmrIndex >= sealedSize) {
114
+ throw new Error(
115
+ `receipt entry ${leafMmrIndex} is newer than the latest sealed size ${sealedSize}`,
116
+ );
117
+ }
118
+
119
+ // --- validate the supplied chain shape before folding (F5) ---
120
+ const links = input.consistencyProofs;
121
+ if (links.length === 0) {
122
+ throw new Error(
123
+ "freshen requires at least one consistency proof linking the receipt's era to the checkpoint",
124
+ );
125
+ }
126
+ const firstLink = links[0]!;
127
+ // Base: a base-0 chain starts from an empty accumulator; a suffix chain's
128
+ // trusted seed must have the peak count of its tree-size-1.
129
+ const baseCount = input.accumulatorFrom?.length ?? 0;
130
+ if (firstLink.treeSize1 === 0n) {
131
+ if (baseCount !== 0) {
132
+ throw new Error(
133
+ "base-0 consistency chain must start from an empty accumulator seed",
134
+ );
135
+ }
136
+ } else {
137
+ const wanted = peakMMRIndexes(firstLink.treeSize1 - 1n).length;
138
+ if (baseCount !== wanted) {
139
+ throw new Error(
140
+ `base accumulator has ${baseCount} peaks; first link size ${firstLink.treeSize1} requires ${wanted}`,
141
+ );
142
+ }
143
+ }
144
+ // Contiguity: each link continues where the previous one sealed.
145
+ for (let i = 1; i < links.length; i++) {
146
+ if (links[i]!.treeSize1 !== links[i - 1]!.treeSize2) {
147
+ throw new Error(
148
+ `consistency chain is not contiguous at link ${i}: base ${links[i]!.treeSize1} != previous sealed size ${links[i - 1]!.treeSize2}`,
149
+ );
150
+ }
151
+ }
152
+ // Endpoint: the chain must reach exactly the checkpoint's sealed size (F1).
153
+ const lastLink = links[links.length - 1]!;
154
+ if (lastLink.treeSize2 !== sealedSize) {
155
+ throw new Error(
156
+ `consistency chain ends at size ${lastLink.treeSize2} but the checkpoint sealed size ${sealedSize}`,
157
+ );
158
+ }
159
+
160
+ // Fold the chain to the latest accumulator (self-check target).
161
+ let accumulator = input.accumulatorFrom ?? [];
162
+ for (const p of links) {
163
+ accumulator = await computeCheckpointAccumulator(p, accumulator);
164
+ }
165
+ const aLatest = accumulator;
166
+
167
+ // Cross-check the fold against the checkpoint we are borrowing from (F1): the
168
+ // folded accumulator must have the structural peak count for the sealed size
169
+ // AND match the number of pre-signed peak receipts the checkpoint carries.
170
+ // (The cryptographic peak↔signature tie is enforced by downstream verify;
171
+ // freshen holds no key.)
172
+ const structuralPeaks = peakMMRIndexes(sealedSize - 1n).length;
173
+ if (aLatest.length !== structuralPeaks) {
174
+ throw new Error(
175
+ `folded accumulator has ${aLatest.length} peaks; sealed size ${sealedSize} requires ${structuralPeaks}`,
176
+ );
177
+ }
178
+ if (!latest.peakReceipts) {
179
+ throw new Error(
180
+ "latest checkpoint carries no pre-signed peak receipts (label -65931)",
181
+ );
182
+ }
183
+ if (latest.peakReceipts.length !== aLatest.length) {
184
+ throw new Error(
185
+ `checkpoint carries ${latest.peakReceipts.length} peak receipts but the folded accumulator has ${aLatest.length} peaks — chain does not match this checkpoint`,
186
+ );
187
+ }
188
+
189
+ // Assemble the leaf's inclusion path at the latest size from index-addressed
190
+ // node values: the old receipt path (leaf → old peak, a prefix by MMR
191
+ // prefix-composability) + the consistency proofs (the climb extension).
192
+ const fullIndices = inclusionProofPath(sealedSize - 1n, leafMmrIndex);
193
+ if (fullIndices.length < oldPath.length) {
194
+ throw new Error(
195
+ "receipt path is longer than the latest inclusion path — stale/forged receipt",
196
+ );
197
+ }
198
+ const store = new Map<bigint, Uint8Array>();
199
+ for (let k = 0; k < oldPath.length; k++) {
200
+ store.set(fullIndices[k]!, oldPath[k]!);
201
+ }
202
+ for (const link of links) {
203
+ const fromPeaks = peakMMRIndexes(link.treeSize1 - 1n);
204
+ fromPeaks.forEach((peakIndex, j) => {
205
+ const climb = link.paths[j];
206
+ if (climb === undefined) return;
207
+ const climbIndices = inclusionProofPath(link.treeSize2 - 1n, peakIndex);
208
+ climbIndices.forEach((ix, e) => {
209
+ const v = climb[e];
210
+ if (v !== undefined) store.set(ix, v);
211
+ });
212
+ });
213
+ }
214
+ const freshPath = fullIndices.map((ix) => {
215
+ const v = store.get(ix);
216
+ if (v === undefined) {
217
+ throw new Error(
218
+ `checkpoint chain does not cover the extension node at ${ix} — supply the full chain from the receipt's era`,
219
+ );
220
+ }
221
+ return v;
222
+ });
223
+
224
+ // Self-check: the fresh path must recompute the covering peak of the latest
225
+ // accumulator. Fails closed rather than emitting a bad receipt.
226
+ const hasher = new SubtleHasher();
227
+ const root = await calculateRoot(
228
+ hasher,
229
+ input.leafValue,
230
+ { path: freshPath, mmrIndex: leafMmrIndex },
231
+ leafMmrIndex,
232
+ );
233
+ const peakIdx = peakIndexForLeafProof(sealedSize, freshPath.length);
234
+ if (peakIdx >= aLatest.length || !bytesEqual(root, aLatest[peakIdx]!)) {
235
+ throw new Error(
236
+ "freshened path does not recompute the latest accumulator peak — chain/leaf mismatch",
237
+ );
238
+ }
239
+
240
+ const receipt = assembleReceiptFromProof(latest, leafMmrIndex, freshPath);
241
+ return { receipt, sealedSize };
242
+ }
package/src/index.ts CHANGED
@@ -14,11 +14,18 @@ export type {
14
14
  ParsedCheckpoint,
15
15
  } from "./build-receipt-offline.js";
16
16
  export {
17
+ assembleReceiptFromProof,
17
18
  buildReceiptOffline,
18
19
  computeAccumulatorPeak,
19
20
  openMassifNodeStore,
20
21
  parseCheckpoint,
21
22
  } from "./build-receipt-offline.js";
23
+ /** Freshen a stale receipt to the latest sealed state, tile-free (FOR-418). */
24
+ export { freshenReceipt } from "./freshen-receipt.js";
25
+ export type {
26
+ FreshenReceiptInput,
27
+ FreshenReceiptResult,
28
+ } from "./freshen-receipt.js";
22
29
  /**
23
30
  * MMR proof math now lives in @forestrie/merklelog (plan-2607-15 §4 hoist);
24
31
  * re-exported here to preserve the receipt-verify public surface.