@forestrie/receipt-verify 3.0.1 → 3.2.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.
- package/dist/checkpoint-chain-receipt-outcome.d.ts +56 -0
- package/dist/checkpoint-chain-receipt-outcome.d.ts.map +1 -0
- package/dist/checkpoint-chain-receipt-outcome.js +1 -0
- package/dist/checkpoint-link-record.d.ts +69 -0
- package/dist/checkpoint-link-record.d.ts.map +1 -0
- package/dist/checkpoint-link-record.js +1 -0
- package/dist/checkpoint-link-verifier.d.ts +80 -0
- package/dist/checkpoint-link-verifier.d.ts.map +1 -0
- package/dist/checkpoint-link-verifier.js +181 -0
- package/dist/decode-trust-root-cbor.d.ts +8 -1
- package/dist/decode-trust-root-cbor.d.ts.map +1 -1
- package/dist/decode-trust-root-cbor.js +10 -1
- package/dist/inclusion-walk-end.d.ts +6 -0
- package/dist/inclusion-walk-end.d.ts.map +1 -0
- package/dist/inclusion-walk-end.js +35 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +23 -0
- package/dist/known-accumulator.d.ts.map +1 -1
- package/dist/known-accumulator.js +25 -9
- package/dist/known-log-key.d.ts +15 -0
- package/dist/known-log-key.d.ts.map +1 -0
- package/dist/known-log-key.js +20 -0
- package/dist/ks256-attestation-usage.d.ts +50 -0
- package/dist/ks256-attestation-usage.d.ts.map +1 -0
- package/dist/ks256-attestation-usage.js +51 -0
- package/dist/ks256-attestation.d.ts +50 -0
- package/dist/ks256-attestation.d.ts.map +1 -0
- package/dist/ks256-attestation.js +104 -0
- package/dist/resolve-delegated-verify-key.d.ts +18 -9
- package/dist/resolve-delegated-verify-key.d.ts.map +1 -1
- package/dist/resolve-delegated-verify-key.js +26 -9
- package/dist/select-ks256-attestation.d.ts +57 -0
- package/dist/select-ks256-attestation.d.ts.map +1 -0
- package/dist/select-ks256-attestation.js +119 -0
- package/dist/verify-grant-receipt-offline.d.ts +31 -6
- package/dist/verify-grant-receipt-offline.d.ts.map +1 -1
- package/dist/verify-grant-receipt-offline.js +63 -12
- package/dist/verify-ks256-delegation.d.ts +56 -0
- package/dist/verify-ks256-delegation.d.ts.map +1 -0
- package/dist/verify-ks256-delegation.js +95 -0
- package/dist/verify-receipt-against-checkpoint-chain.d.ts +42 -0
- package/dist/verify-receipt-against-checkpoint-chain.d.ts.map +1 -0
- package/dist/verify-receipt-against-checkpoint-chain.js +205 -0
- package/package.json +4 -2
- package/src/checkpoint-chain-receipt-outcome.ts +67 -0
- package/src/checkpoint-link-record.ts +74 -0
- package/src/checkpoint-link-verifier.ts +241 -0
- package/src/decode-trust-root-cbor.ts +10 -1
- package/src/inclusion-walk-end.ts +38 -0
- package/src/index.ts +53 -0
- package/src/known-accumulator.ts +30 -8
- package/src/known-log-key.ts +28 -0
- package/src/ks256-attestation-usage.ts +103 -0
- package/src/ks256-attestation.ts +144 -0
- package/src/resolve-delegated-verify-key.ts +37 -11
- package/src/select-ks256-attestation.ts +151 -0
- package/src/verify-grant-receipt-offline.ts +109 -20
- package/src/verify-ks256-delegation.ts +120 -0
- package/src/verify-receipt-against-checkpoint-chain.ts +256 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The checkpoint-chain trust root (FOR-368 Phase 3; owner ruling D8): fold a
|
|
3
|
+
* retained `.sth` chain under the caller's roots with per-link delegation
|
|
4
|
+
* limits ({@link makeCheckpointLinkVerifier}), match the receipt's peak
|
|
5
|
+
* against ANY authenticated link, then judge certificate expiry on the
|
|
6
|
+
* links covering the now-bound leaf ({@link checkCheckpointLinkExpiry}).
|
|
7
|
+
* Lifted from mcp-verify 0.6.1's `verifyAtCheckpointChain`; forestrie-cli,
|
|
8
|
+
* which matches peaks its own way, can use the pieces instead.
|
|
9
|
+
*
|
|
10
|
+
* Later links' signed consistency proofs commit an earlier accumulator
|
|
11
|
+
* forward, so burial never turns an honest receipt tamper-shaped. Retention
|
|
12
|
+
* limits coverage, never validity: a receipt newer than the chain fails
|
|
13
|
+
* closed with a refresh remedy.
|
|
14
|
+
*
|
|
15
|
+
* Pure over bytes; no network (canopy rule C1).
|
|
16
|
+
*/
|
|
17
|
+
import { calculateRoot, peakMMRIndexes } from "@forestrie/merklelog";
|
|
18
|
+
import { verifyCheckpointChain, } from "./checkpoint-chain.js";
|
|
19
|
+
import { checkCheckpointLinkExpiry, makeCheckpointLinkVerifier, } from "./checkpoint-link-verifier.js";
|
|
20
|
+
import { decodeChainBindingFromGenesis } from "./decode-chain-binding-from-genesis.js";
|
|
21
|
+
import { summarizeKs256AttestationUsage } from "./ks256-attestation-usage.js";
|
|
22
|
+
import { inclusionWalkEndIndex } from "./inclusion-walk-end.js";
|
|
23
|
+
import { univocityLeafHash } from "./leaf-commitment.js";
|
|
24
|
+
import { parseReceipt } from "./parse-receipt.js";
|
|
25
|
+
import { SubtleHasher } from "./subtle-hasher.js";
|
|
26
|
+
/**
|
|
27
|
+
* Verify a receipt against a retained checkpoint chain.
|
|
28
|
+
*
|
|
29
|
+
* The receipt's own signature is not checked: the authenticated chain is
|
|
30
|
+
* the authority, and the peak is ALWAYS recomputed from the leaf
|
|
31
|
+
* (`idtimestamp ‖ inner`) and the inclusion path, never taken from an
|
|
32
|
+
* attached payload. A match binds the leaf's idtimestamp (through the leaf
|
|
33
|
+
* hash) and its mmrIndex (through the matched peak's POSITION, which must be
|
|
34
|
+
* the node the inclusion walk reaches: the leaf hash alone does not commit
|
|
35
|
+
* its position, and an empty path commits none).
|
|
36
|
+
* Links are searched newest first; the newest cover is reported.
|
|
37
|
+
*/
|
|
38
|
+
export async function verifyReceiptAgainstCheckpointChain(input) {
|
|
39
|
+
const attestations = input.attestations ?? [];
|
|
40
|
+
const empty = {
|
|
41
|
+
links: [],
|
|
42
|
+
records: [],
|
|
43
|
+
attestations: summarizeKs256AttestationUsage(attestations, []),
|
|
44
|
+
};
|
|
45
|
+
if (input.rootKeys.length === 0) {
|
|
46
|
+
return { ...empty, ok: false, stage: "input", reason: "no_trust_key" };
|
|
47
|
+
}
|
|
48
|
+
let forestChainId = input.forestChainId;
|
|
49
|
+
if (input.forestGenesisCbor !== undefined) {
|
|
50
|
+
let fromGenesis;
|
|
51
|
+
try {
|
|
52
|
+
fromGenesis = BigInt(decodeChainBindingFromGenesis(input.forestGenesisCbor).chainId);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return {
|
|
56
|
+
...empty,
|
|
57
|
+
ok: false,
|
|
58
|
+
stage: "input",
|
|
59
|
+
reason: "forest_genesis_invalid",
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
if (forestChainId !== undefined && forestChainId !== fromGenesis) {
|
|
63
|
+
return {
|
|
64
|
+
...empty,
|
|
65
|
+
ok: false,
|
|
66
|
+
stage: "input",
|
|
67
|
+
reason: "forest_chain_id_mismatch",
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
forestChainId = fromGenesis;
|
|
71
|
+
}
|
|
72
|
+
const verifier = makeCheckpointLinkVerifier({
|
|
73
|
+
rootKeys: input.rootKeys,
|
|
74
|
+
attestations,
|
|
75
|
+
forestChainId,
|
|
76
|
+
});
|
|
77
|
+
const chain = await verifyCheckpointChain({
|
|
78
|
+
checkpoints: [...input.checkpoints],
|
|
79
|
+
verifySignature: verifier.verifySignature,
|
|
80
|
+
...(input.trustedBase !== undefined
|
|
81
|
+
? { trustedBase: input.trustedBase }
|
|
82
|
+
: {}),
|
|
83
|
+
});
|
|
84
|
+
const linkFailure = verifier.failure();
|
|
85
|
+
const common = {
|
|
86
|
+
links: chain.links,
|
|
87
|
+
records: verifier.records,
|
|
88
|
+
attestations: summarizeKs256AttestationUsage(attestations, verifier.records, linkFailure),
|
|
89
|
+
};
|
|
90
|
+
if (!chain.ok) {
|
|
91
|
+
return {
|
|
92
|
+
...common,
|
|
93
|
+
ok: false,
|
|
94
|
+
stage: "chain",
|
|
95
|
+
reason: linkFailure?.reason ?? `checkpoint_chain_${chain.reason}`,
|
|
96
|
+
at: chain.at,
|
|
97
|
+
detail: chain.detail,
|
|
98
|
+
...(linkFailure !== undefined ? { linkFailure } : {}),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
let parsed;
|
|
102
|
+
try {
|
|
103
|
+
parsed = parseReceipt(input.receiptCbor);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return {
|
|
107
|
+
...common,
|
|
108
|
+
ok: false,
|
|
109
|
+
stage: "parse",
|
|
110
|
+
reason: "receipt_malformed",
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
if (input.idtimestampBe8.length < 8) {
|
|
114
|
+
return {
|
|
115
|
+
...common,
|
|
116
|
+
ok: false,
|
|
117
|
+
stage: "parse",
|
|
118
|
+
reason: "idtimestamp_invalid",
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
const idtimestamp = readU64Be(input.idtimestampBe8.slice(-8));
|
|
122
|
+
// parseReceipt always sets mmrIndex; leafIndex wins when present, as in
|
|
123
|
+
// every other peak recompute in this package.
|
|
124
|
+
const leafMmrIndex = parsed.proof.leafIndex ?? parsed.proof.mmrIndex;
|
|
125
|
+
if (leafMmrIndex === undefined) {
|
|
126
|
+
return {
|
|
127
|
+
...common,
|
|
128
|
+
ok: false,
|
|
129
|
+
stage: "parse",
|
|
130
|
+
reason: "receipt_malformed",
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const links = chain.links;
|
|
134
|
+
const final = links[links.length - 1];
|
|
135
|
+
if (leafMmrIndex >= final.treeSize2) {
|
|
136
|
+
return {
|
|
137
|
+
...common,
|
|
138
|
+
ok: false,
|
|
139
|
+
stage: "anchor",
|
|
140
|
+
reason: "receipt_newer_than_checkpoint_chain",
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
let peak;
|
|
144
|
+
try {
|
|
145
|
+
peak = await calculateRoot(new SubtleHasher(), await univocityLeafHash(idtimestamp, input.inner), parsed.proof, leafMmrIndex);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return {
|
|
149
|
+
...common,
|
|
150
|
+
ok: false,
|
|
151
|
+
stage: "parse",
|
|
152
|
+
reason: "receipt_malformed",
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
// The peak the path claims to reach. A leaf hash does not commit its
|
|
156
|
+
// position (and an empty path commits none), so a value match alone would
|
|
157
|
+
// leave mmrIndex a caller claim: the matched peak must sit at this index.
|
|
158
|
+
const peakIndex = inclusionWalkEndIndex(leafMmrIndex, parsed.proof.path.length);
|
|
159
|
+
for (let i = links.length - 1; i >= 0; i--) {
|
|
160
|
+
const link = links[i];
|
|
161
|
+
const p = link.treeSize2 > 0n
|
|
162
|
+
? peakMMRIndexes(link.treeSize2 - 1n).indexOf(peakIndex)
|
|
163
|
+
: -1;
|
|
164
|
+
if (p < 0 || !bytesEqual(link.accumulator[p] ?? new Uint8Array(), peak)) {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
// The leaf hash, inclusion path and peak position now bind idtimestamp
|
|
168
|
+
// and mmrIndex to an authenticated accumulator, so expiry can be judged
|
|
169
|
+
// on them.
|
|
170
|
+
const expiry = checkCheckpointLinkExpiry(verifier.records, {
|
|
171
|
+
mmrIndex: leafMmrIndex,
|
|
172
|
+
idtimestamp,
|
|
173
|
+
});
|
|
174
|
+
if (!expiry.ok) {
|
|
175
|
+
return {
|
|
176
|
+
...common,
|
|
177
|
+
ok: false,
|
|
178
|
+
stage: "delegation",
|
|
179
|
+
reason: expiry.reason,
|
|
180
|
+
at: expiry.at,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
return { ...common, ok: true, matchedLink: i, matchedPeak: p };
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
...common,
|
|
187
|
+
ok: false,
|
|
188
|
+
stage: "anchor",
|
|
189
|
+
reason: "peak_not_in_checkpoint_chain",
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
function readU64Be(be8) {
|
|
193
|
+
let v = 0n;
|
|
194
|
+
for (const b of be8)
|
|
195
|
+
v = (v << 8n) | BigInt(b);
|
|
196
|
+
return v;
|
|
197
|
+
}
|
|
198
|
+
function bytesEqual(a, b) {
|
|
199
|
+
if (a.length !== b.length)
|
|
200
|
+
return false;
|
|
201
|
+
let x = 0;
|
|
202
|
+
for (let i = 0; i < a.length; i++)
|
|
203
|
+
x |= a[i] ^ b[i];
|
|
204
|
+
return x === 0;
|
|
205
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forestrie/receipt-verify",
|
|
3
|
-
"version": "3.0
|
|
3
|
+
"version": "3.2.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Offline SCITT grant receipt verification (ADR-0045)",
|
|
@@ -30,9 +30,11 @@
|
|
|
30
30
|
"@noble/hashes": "^1.7.1",
|
|
31
31
|
"@forestrie/chain-rpc": "0.3.0",
|
|
32
32
|
"@forestrie/encoding": "0.9.1",
|
|
33
|
-
"@forestrie/merklelog": "0.5.0"
|
|
33
|
+
"@forestrie/merklelog": "0.5.0",
|
|
34
|
+
"@forestrie/delegation-cose": "0.4.0"
|
|
34
35
|
},
|
|
35
36
|
"devDependencies": {
|
|
37
|
+
"@noble/curves": "^1.9.7",
|
|
36
38
|
"esbuild": "^0.24.0",
|
|
37
39
|
"typescript": "^5.9.2",
|
|
38
40
|
"vitest": "^3.2.4"
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The typed result of {@link verifyReceiptAgainstCheckpointChain}: the
|
|
3
|
+
* verdict, where it was decided, and everything a reporter needs (folded
|
|
4
|
+
* links, per-link delegation records, attestation usage).
|
|
5
|
+
*/
|
|
6
|
+
import type { CheckpointChainLink } from "./checkpoint-chain.js";
|
|
7
|
+
import type {
|
|
8
|
+
CheckpointLinkFailure,
|
|
9
|
+
CheckpointLinkRecord,
|
|
10
|
+
} from "./checkpoint-link-record.js";
|
|
11
|
+
import type { Ks256AttestationUsage } from "./ks256-attestation-usage.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Where a failing run stopped:
|
|
15
|
+
* - `input`: the caller's inputs (no root keys, a forest genesis without a
|
|
16
|
+
* chain binding).
|
|
17
|
+
* - `chain`: the checkpoint chain did not fold or a link was refused.
|
|
18
|
+
* - `parse`: the receipt or idtimestamp did not parse.
|
|
19
|
+
* - `anchor`: the chain folded but the receipt's leaf-derived peak is in no
|
|
20
|
+
* link (`peak_not_in_checkpoint_chain`), or the leaf is past the chain
|
|
21
|
+
* (`receipt_newer_than_checkpoint_chain`; refresh, not tamper).
|
|
22
|
+
* - `delegation`: the receipt matched, but a link covering its leaf was
|
|
23
|
+
* signed under an expired certificate (`delegation_expired`).
|
|
24
|
+
*/
|
|
25
|
+
export type CheckpointChainReceiptStage =
|
|
26
|
+
| "input"
|
|
27
|
+
| "chain"
|
|
28
|
+
| "parse"
|
|
29
|
+
| "anchor"
|
|
30
|
+
| "delegation";
|
|
31
|
+
|
|
32
|
+
/** Fields every outcome carries, pass or fail. */
|
|
33
|
+
type OutcomeCommon = {
|
|
34
|
+
/** The authenticated links (the verified prefix on a chain failure). */
|
|
35
|
+
links: CheckpointChainLink[];
|
|
36
|
+
/** Per-link delegation facts, one per authenticated link. */
|
|
37
|
+
records: CheckpointLinkRecord[];
|
|
38
|
+
/** Every supplied attestation, as used or unused with a reason. */
|
|
39
|
+
attestations: Ks256AttestationUsage;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** A receipt anchored in a retained checkpoint chain, or why not. */
|
|
43
|
+
export type CheckpointChainReceiptOutcome =
|
|
44
|
+
| (OutcomeCommon & {
|
|
45
|
+
ok: true;
|
|
46
|
+
/** Index of the newest link whose accumulator holds the peak. */
|
|
47
|
+
matchedLink: number;
|
|
48
|
+
/** Index of the matching peak within that link's accumulator. */
|
|
49
|
+
matchedPeak: number;
|
|
50
|
+
})
|
|
51
|
+
| (OutcomeCommon & {
|
|
52
|
+
ok: false;
|
|
53
|
+
stage: CheckpointChainReceiptStage;
|
|
54
|
+
/**
|
|
55
|
+
* Stable reason: a {@link CheckpointLinkFailure} reason, a
|
|
56
|
+
* `CheckpointChainResult` reason prefixed `checkpoint_chain_`, a
|
|
57
|
+
* receipt parse reason, or one of the anchor/delegation reasons
|
|
58
|
+
* above.
|
|
59
|
+
*/
|
|
60
|
+
reason: string;
|
|
61
|
+
/** Offending link index, for `chain` and `delegation`. */
|
|
62
|
+
at?: number;
|
|
63
|
+
/** Human detail from the chain fold, when it failed there. */
|
|
64
|
+
detail?: string;
|
|
65
|
+
/** The per-link verifier's refusal, when it caused a `chain` failure. */
|
|
66
|
+
linkFailure?: CheckpointLinkFailure;
|
|
67
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the per-link checkpoint verifier learned about each link of a
|
|
3
|
+
* retained `.sth` chain ({@link makeCheckpointLinkVerifier}). Consumers
|
|
4
|
+
* (mcp-verify, forestrie-cli) report these facts; the post-match expiry
|
|
5
|
+
* check ({@link checkCheckpointLinkExpiry}) reads them.
|
|
6
|
+
*/
|
|
7
|
+
import type { DelegationConstraints } from "./resolve-delegated-verify-key.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Which key verified a link's checkpoint signature, and what vouched for it:
|
|
11
|
+
*
|
|
12
|
+
* - `es256-root`: an ES256 root key verified the checkpoint directly (no
|
|
13
|
+
* certificate, or the root signed despite one).
|
|
14
|
+
* - `delegated`: the label-1000 certificate's delegated key verified it, and
|
|
15
|
+
* an ES256 root (or passkey root) vouched for the certificate.
|
|
16
|
+
* - `ks256-root`: the delegated key verified it, and a KS256 root vouched for
|
|
17
|
+
* the certificate: by ecrecover, or by an ERC-1271 attestation when
|
|
18
|
+
* {@link CheckpointLinkRecord.attestationNeeded}.
|
|
19
|
+
*/
|
|
20
|
+
export type CheckpointLinkVerifiedBy =
|
|
21
|
+
| "es256-root"
|
|
22
|
+
| "ks256-root"
|
|
23
|
+
| "delegated";
|
|
24
|
+
|
|
25
|
+
/** One authenticated link of a checkpoint chain. */
|
|
26
|
+
export type CheckpointLinkRecord = {
|
|
27
|
+
/** Position in the supplied checkpoint list (fold order). */
|
|
28
|
+
index: number;
|
|
29
|
+
/** The checkpoint's SIGNED tree size (protected label -65933). */
|
|
30
|
+
signedSize: bigint;
|
|
31
|
+
/**
|
|
32
|
+
* The delegation certificate's limits, `null` when the link carries no
|
|
33
|
+
* certificate or the certificate declares none of labels 3/4/8/9.
|
|
34
|
+
*/
|
|
35
|
+
constraints: DelegationConstraints | null;
|
|
36
|
+
verifiedBy: CheckpointLinkVerifiedBy;
|
|
37
|
+
/**
|
|
38
|
+
* Hex (no `0x`) of the digest of the attestation this link needed, set
|
|
39
|
+
* only when {@link attestationNeeded}.
|
|
40
|
+
*/
|
|
41
|
+
attestationUsed?: string;
|
|
42
|
+
/**
|
|
43
|
+
* Index, in the caller's attestation list, of the attestation made for
|
|
44
|
+
* this link's certificate (digest and signature hash match), whether or
|
|
45
|
+
* not the link needed it.
|
|
46
|
+
*/
|
|
47
|
+
attestationIndex?: number;
|
|
48
|
+
/**
|
|
49
|
+
* Indexes of attestations made for this link's certificate that were
|
|
50
|
+
* tried before {@link attestationIndex} and failed their own checks
|
|
51
|
+
* (address, chain id, magic).
|
|
52
|
+
*/
|
|
53
|
+
attestationsRejected?: number[];
|
|
54
|
+
/**
|
|
55
|
+
* The link verified only because of an attestation: re-resolving its
|
|
56
|
+
* certificate under the same roots without one fails. Pure and offline.
|
|
57
|
+
*/
|
|
58
|
+
attestationNeeded: boolean;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** Why the per-link verifier refused a link. */
|
|
62
|
+
export type CheckpointLinkFailure = {
|
|
63
|
+
index: number;
|
|
64
|
+
/**
|
|
65
|
+
* `delegation_invalid`, a KS256 reason (`ks256_root_not_recovered`,
|
|
66
|
+
* `ks256_attestation_*`, `delegation_not_ks256`), `delegation_out_of_range`,
|
|
67
|
+
* `signature_invalid` or `proof_malformed`.
|
|
68
|
+
*/
|
|
69
|
+
reason: string;
|
|
70
|
+
/** As {@link CheckpointLinkRecord.attestationIndex}. */
|
|
71
|
+
attestationIndex?: number;
|
|
72
|
+
/** As {@link CheckpointLinkRecord.attestationsRejected}. */
|
|
73
|
+
attestationsRejected?: number[];
|
|
74
|
+
};
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-link trust for a retained checkpoint chain: the `verifySignature`
|
|
3
|
+
* callback {@link verifyCheckpointChain} takes, plus the delegation limits
|
|
4
|
+
* each link's label-1000 certificate imposes. Lifted from mcp-verify 0.6.1
|
|
5
|
+
* (`src/core/verify-shared.ts`) so mcp-verify and forestrie-cli share one
|
|
6
|
+
* copy; see mcp-verify `docs/trust-roots.md` ("Delegation limits under
|
|
7
|
+
* checkpoint-chain").
|
|
8
|
+
*
|
|
9
|
+
* The limits mirror univocity's on-chain check
|
|
10
|
+
* ([delegationVerifier.sol](https://github.com/forestrie/univocity/blob/main/src/checkpoints/lib/delegationVerifier.sol)):
|
|
11
|
+
* a checkpoint of claimed size `S` is anchored only when its delegation
|
|
12
|
+
* covers `mmrIndex = S > 0 ? S - 1 : 0`, i.e. `mmrStart <= mmrIndex <=
|
|
13
|
+
* mmrEnd`. Here `S` is the checkpoint's SIGNED tree size (label -65933), so
|
|
14
|
+
* a link the contract would refuse is refused offline too
|
|
15
|
+
* (`delegation_out_of_range`). Expiry needs a leaf the receipt binds and is
|
|
16
|
+
* judged after the receipt matched ({@link checkCheckpointLinkExpiry}).
|
|
17
|
+
*
|
|
18
|
+
* Pure over bytes; no network (canopy rule C1).
|
|
19
|
+
*/
|
|
20
|
+
import { verifyCoseSign1WithParsedKey } from "@forestrie/encoding";
|
|
21
|
+
import { checkpointConsistencyProof } from "./checkpoint-chain.js";
|
|
22
|
+
import type {
|
|
23
|
+
CheckpointLinkFailure,
|
|
24
|
+
CheckpointLinkRecord,
|
|
25
|
+
CheckpointLinkVerifiedBy,
|
|
26
|
+
} from "./checkpoint-link-record.js";
|
|
27
|
+
import type { Ks256Attestation } from "./ks256-attestation.js";
|
|
28
|
+
import {
|
|
29
|
+
idtimestampToUnixSeconds,
|
|
30
|
+
resolveDelegatedVerifyKey,
|
|
31
|
+
type DelegationConstraints,
|
|
32
|
+
} from "./resolve-delegated-verify-key.js";
|
|
33
|
+
import { isParsedKs256RootKey, type RootVerifyKey } from "./root-verify-key.js";
|
|
34
|
+
import { resolveWithKs256Attestations } from "./select-ks256-attestation.js";
|
|
35
|
+
|
|
36
|
+
/** The per-link verifier and what it has learned so far. */
|
|
37
|
+
export type CheckpointLinkVerifier = {
|
|
38
|
+
/** Pass as `verifyCheckpointChain({ verifySignature })`. */
|
|
39
|
+
verifySignature: (
|
|
40
|
+
checkpointBytes: Uint8Array,
|
|
41
|
+
detachedPayload: Uint8Array,
|
|
42
|
+
) => Promise<boolean>;
|
|
43
|
+
/** One record per link that verified, in fold order. */
|
|
44
|
+
records: CheckpointLinkRecord[];
|
|
45
|
+
/** Set when the verifier refused a link; the chain fold stops there. */
|
|
46
|
+
failure(): CheckpointLinkFailure | undefined;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build the per-link signature verifier for {@link verifyCheckpointChain}.
|
|
51
|
+
*
|
|
52
|
+
* For each checkpoint, in fold order: resolve its label-1000 certificate
|
|
53
|
+
* under `rootKeys` (with the attestation made for that certificate, if any),
|
|
54
|
+
* verify the COSE signature over the FOLDED accumulator (the detached
|
|
55
|
+
* payload the fold computes, never one read from the checkpoint; ADR-0046),
|
|
56
|
+
* then enforce the certificate's size bound against the signed size. KS256
|
|
57
|
+
* roots vouch only for certificates; only ES256 roots may verify a
|
|
58
|
+
* checkpoint directly.
|
|
59
|
+
*
|
|
60
|
+
* The callback must be used for exactly one fold: link indices are the call
|
|
61
|
+
* count.
|
|
62
|
+
*
|
|
63
|
+
* @param opts.rootKeys - ES256 keys and KS256 addresses, from genesis or
|
|
64
|
+
* caller-known log keys.
|
|
65
|
+
* @param opts.attestations - ERC-1271 attestations for KS256 contract
|
|
66
|
+
* roots; each certificate takes the one with its digest and signature
|
|
67
|
+
* hash ({@link selectKs256Attestation}).
|
|
68
|
+
* @param opts.forestChainId - The forest genesis chain id every attestation
|
|
69
|
+
* must carry; without it a contract root fails
|
|
70
|
+
* `ks256_attestation_chain_unbound`.
|
|
71
|
+
*/
|
|
72
|
+
export function makeCheckpointLinkVerifier(opts: {
|
|
73
|
+
rootKeys: readonly RootVerifyKey[];
|
|
74
|
+
attestations?: readonly Ks256Attestation[] | undefined;
|
|
75
|
+
forestChainId?: bigint | undefined;
|
|
76
|
+
}): CheckpointLinkVerifier {
|
|
77
|
+
const rootKeys = [...opts.rootKeys];
|
|
78
|
+
const attestations = opts.attestations ?? [];
|
|
79
|
+
const es256Roots = rootKeys.filter(
|
|
80
|
+
(k): k is CryptoKey => !isParsedKs256RootKey(k),
|
|
81
|
+
);
|
|
82
|
+
const records: CheckpointLinkRecord[] = [];
|
|
83
|
+
let failure: CheckpointLinkFailure | undefined;
|
|
84
|
+
let calls = 0;
|
|
85
|
+
|
|
86
|
+
const verifySignature = async (
|
|
87
|
+
checkpointBytes: Uint8Array,
|
|
88
|
+
detachedPayload: Uint8Array,
|
|
89
|
+
): Promise<boolean> => {
|
|
90
|
+
const index = calls++;
|
|
91
|
+
const chosen = await resolveWithKs256Attestations(
|
|
92
|
+
checkpointBytes,
|
|
93
|
+
rootKeys,
|
|
94
|
+
attestations,
|
|
95
|
+
opts.forestChainId,
|
|
96
|
+
);
|
|
97
|
+
const resolution = chosen.resolution;
|
|
98
|
+
const fail = (reason: string): false => {
|
|
99
|
+
failure = { index, reason };
|
|
100
|
+
if (chosen.selected !== undefined) {
|
|
101
|
+
failure.attestationIndex = chosen.selected;
|
|
102
|
+
}
|
|
103
|
+
if (chosen.rejected.length > 0) {
|
|
104
|
+
failure.attestationsRejected = chosen.rejected;
|
|
105
|
+
}
|
|
106
|
+
return false;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
if (resolution.kind === "broken") {
|
|
110
|
+
return fail(resolution.reason ?? "delegation_invalid");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let signedSize: bigint;
|
|
114
|
+
try {
|
|
115
|
+
signedSize = checkpointConsistencyProof(checkpointBytes).signedTreeSize2;
|
|
116
|
+
} catch {
|
|
117
|
+
// The fold read the same label moments ago; fail closed.
|
|
118
|
+
return fail("proof_malformed");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const candidates: { key: CryptoKey; delegated: boolean }[] = [
|
|
122
|
+
...(resolution.kind === "resolved"
|
|
123
|
+
? [{ key: resolution.delegatedKey, delegated: true }]
|
|
124
|
+
: []),
|
|
125
|
+
...es256Roots.map((key) => ({ key, delegated: false })),
|
|
126
|
+
];
|
|
127
|
+
let signer: { key: CryptoKey; delegated: boolean } | undefined;
|
|
128
|
+
for (const c of candidates) {
|
|
129
|
+
if (
|
|
130
|
+
await verifyCoseSign1WithParsedKey(checkpointBytes, c.key, {
|
|
131
|
+
logPrefix: "checkpoint-chain",
|
|
132
|
+
detachedPayload,
|
|
133
|
+
})
|
|
134
|
+
) {
|
|
135
|
+
signer = c;
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (signer === undefined) return fail("signature_invalid");
|
|
140
|
+
|
|
141
|
+
// A resolved certificate's limits bind the link even when a root key
|
|
142
|
+
// verified it directly: stricter than univocity, which would not accept
|
|
143
|
+
// a root signature alongside a certificate at all.
|
|
144
|
+
const constraints =
|
|
145
|
+
resolution.kind === "resolved" ? resolution.constraints : null;
|
|
146
|
+
if (constraints !== null) {
|
|
147
|
+
const bound = checkCheckpointLinkSizeBound(constraints, signedSize);
|
|
148
|
+
if (!bound.ok) return fail(bound.reason);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let verifiedBy: CheckpointLinkVerifiedBy = "es256-root";
|
|
152
|
+
if (signer.delegated) {
|
|
153
|
+
const viaEs256 = await resolveDelegatedVerifyKey(
|
|
154
|
+
checkpointBytes,
|
|
155
|
+
es256Roots,
|
|
156
|
+
);
|
|
157
|
+
verifiedBy = viaEs256.kind === "resolved" ? "delegated" : "ks256-root";
|
|
158
|
+
}
|
|
159
|
+
// Only a selected attestation can carry a certificate (a diagnostic
|
|
160
|
+
// stand-in never matches), so "needed" implies "selected".
|
|
161
|
+
const attestationNeeded =
|
|
162
|
+
chosen.selected !== undefined &&
|
|
163
|
+
resolution.kind === "resolved" &&
|
|
164
|
+
(await resolveDelegatedVerifyKey(checkpointBytes, rootKeys)).kind ===
|
|
165
|
+
"broken";
|
|
166
|
+
|
|
167
|
+
const record: CheckpointLinkRecord = {
|
|
168
|
+
index,
|
|
169
|
+
signedSize,
|
|
170
|
+
constraints,
|
|
171
|
+
verifiedBy,
|
|
172
|
+
attestationNeeded,
|
|
173
|
+
};
|
|
174
|
+
if (chosen.rejected.length > 0) {
|
|
175
|
+
record.attestationsRejected = chosen.rejected;
|
|
176
|
+
}
|
|
177
|
+
if (chosen.selected !== undefined) {
|
|
178
|
+
record.attestationIndex = chosen.selected;
|
|
179
|
+
if (attestationNeeded) {
|
|
180
|
+
record.attestationUsed = bytesToHex(
|
|
181
|
+
attestations[chosen.selected]!.digest,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
records.push(record);
|
|
186
|
+
return true;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
return { verifySignature, records, failure: () => failure };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The size bound univocity enforces on a checkpoint, judged from signed
|
|
194
|
+
* data alone: with `mmrIndex = signedSize > 0 ? signedSize - 1 : 0`, reject
|
|
195
|
+
* `mmrIndex < mmrStart || mmrIndex > mmrEnd` (`delegationVerifier.sol`,
|
|
196
|
+
* `_Univocity.sol` passes `claimedSize > 0 ? claimedSize - 1 : 0`).
|
|
197
|
+
*
|
|
198
|
+
* @returns `delegation_out_of_range` on either side of the window.
|
|
199
|
+
*/
|
|
200
|
+
export function checkCheckpointLinkSizeBound(
|
|
201
|
+
constraints: DelegationConstraints,
|
|
202
|
+
signedSize: bigint,
|
|
203
|
+
): { ok: true } | { ok: false; reason: "delegation_out_of_range" } {
|
|
204
|
+
const mmrIndex = signedSize > 0n ? signedSize - 1n : 0n;
|
|
205
|
+
if (mmrIndex < constraints.mmrStart || mmrIndex > constraints.mmrEnd) {
|
|
206
|
+
return { ok: false, reason: "delegation_out_of_range" };
|
|
207
|
+
}
|
|
208
|
+
return { ok: true };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Expiry, judged only AFTER a receipt matched the chain, when its leaf's
|
|
213
|
+
* idtimestamp and mmrIndex are bound by the leaf hash and inclusion path
|
|
214
|
+
* rather than being caller claims. A link whose signed size covers the leaf
|
|
215
|
+
* (`signedSize > leaf.mmrIndex`) was signed after the leaf was sequenced, so
|
|
216
|
+
* a leaf time past `expiresAt` proves signing past the key's lifetime. A
|
|
217
|
+
* link that does not cover the leaf says nothing about its time. Never
|
|
218
|
+
* wall-clock; no `issuedAt` bound (a certificate is legitimately signed a
|
|
219
|
+
* few seconds after the leaf it first covers).
|
|
220
|
+
*
|
|
221
|
+
* @param records - From {@link makeCheckpointLinkVerifier}.
|
|
222
|
+
* @param leaf.idtimestamp - The bound leaf's snowflake idtimestamp.
|
|
223
|
+
* @returns The first offending link as `at`.
|
|
224
|
+
*/
|
|
225
|
+
export function checkCheckpointLinkExpiry(
|
|
226
|
+
records: readonly CheckpointLinkRecord[],
|
|
227
|
+
leaf: { mmrIndex: bigint; idtimestamp: bigint },
|
|
228
|
+
): { ok: true } | { ok: false; reason: "delegation_expired"; at: number } {
|
|
229
|
+
const leafSeconds = idtimestampToUnixSeconds(leaf.idtimestamp);
|
|
230
|
+
for (const r of records) {
|
|
231
|
+
if (r.constraints === null) continue;
|
|
232
|
+
if (r.signedSize > leaf.mmrIndex && leafSeconds > r.constraints.expiresAt) {
|
|
233
|
+
return { ok: false, reason: "delegation_expired", at: r.index };
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return { ok: true };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function bytesToHex(bytes: Uint8Array): string {
|
|
240
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
241
|
+
}
|
|
@@ -44,7 +44,16 @@ function parseAlgInt(raw: unknown): number | null {
|
|
|
44
44
|
return null;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Wrap a 20-byte KS256 root address as a {@link ParsedKs256RootKey}.
|
|
49
|
+
*
|
|
50
|
+
* @throws When `address` is not 20 bytes: a key of any other length would
|
|
51
|
+
* fail {@link isParsedKs256RootKey} and be mistaken for an ES256 key.
|
|
52
|
+
*/
|
|
53
|
+
export function ks256RootFromAddress(address: Uint8Array): ParsedKs256RootKey {
|
|
54
|
+
if (address.length !== 20) {
|
|
55
|
+
throw new Error("KS256 root address must be 20 bytes");
|
|
56
|
+
}
|
|
48
57
|
return { kind: "KS256", alg: COSE_ALG_KS256, address };
|
|
49
58
|
}
|
|
50
59
|
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where an MMR inclusion walk ends. A leaf hash
|
|
3
|
+
* (`SHA-256(idtimestamp ‖ inner)`) does not commit the leaf's position, and
|
|
4
|
+
* a path of length 0 commits nothing more, so matching a recomputed peak by
|
|
5
|
+
* VALUE alone leaves the receipt's claimed `mmrIndex` unbound. Requiring the
|
|
6
|
+
* matched accumulator peak to sit at the node index the walk reaches binds
|
|
7
|
+
* it: interior nodes commit their position (`H(pos ‖ left ‖ right)`), and
|
|
8
|
+
* an empty path must start at the peak itself.
|
|
9
|
+
*
|
|
10
|
+
* Mirrors the position arithmetic of `calculateRoot` in
|
|
11
|
+
* [@forestrie/merklelog](https://github.com/forestrie/canopy/blob/main/packages/merklelog/src/mmr/algorithms.ts)
|
|
12
|
+
* (go-merklelog `mmr/proof.go`), without hashing.
|
|
13
|
+
*/
|
|
14
|
+
import { indexHeight } from "@forestrie/merklelog";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The zero-based MMR index of the node an inclusion path of `pathLength`
|
|
18
|
+
* siblings reaches from `leafMmrIndex`: the peak the path claims to prove.
|
|
19
|
+
*/
|
|
20
|
+
export function inclusionWalkEndIndex(
|
|
21
|
+
leafMmrIndex: bigint,
|
|
22
|
+
pathLength: number,
|
|
23
|
+
): bigint {
|
|
24
|
+
let height = indexHeight(leafMmrIndex);
|
|
25
|
+
// 1-based position, as calculateRoot tracks it.
|
|
26
|
+
let pos = leafMmrIndex + 1n;
|
|
27
|
+
for (let i = 0; i < pathLength; i++) {
|
|
28
|
+
// `indexHeight(pos)` is the height of the node AFTER the current one:
|
|
29
|
+
// higher means the current node is a right child.
|
|
30
|
+
if (indexHeight(pos) > height) {
|
|
31
|
+
pos += 1n;
|
|
32
|
+
} else {
|
|
33
|
+
pos += 2n << BigInt(height);
|
|
34
|
+
}
|
|
35
|
+
height += 1;
|
|
36
|
+
}
|
|
37
|
+
return pos - 1n;
|
|
38
|
+
}
|