@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
package/src/index.ts
CHANGED
|
@@ -89,6 +89,29 @@ export type {
|
|
|
89
89
|
} from "./resolve-delegated-verify-key.js";
|
|
90
90
|
/** Import a raw 64-byte x||y P-256 public key as an ES256 verify key. */
|
|
91
91
|
export { importEs256PublicKeyFromGrantDataXy64 } from "./decode-trust-root-cbor.js";
|
|
92
|
+
/**
|
|
93
|
+
* KS256 roots in offline verification (plan-2610-03 D4–D6, protocol ADR-0045
|
|
94
|
+
* amendment 2026-10-18). A KS256 root comes only from genesis, a caller-known
|
|
95
|
+
* log key ({@link knownLogKeyFromBytes}: asserts, does not prove) or, for a
|
|
96
|
+
* contract root, a caller-supplied ERC-1271 attestation; never from the
|
|
97
|
+
* delegation coordinator. EOA roots verify by ecrecover; anything else needs
|
|
98
|
+
* an attestation or fails with `ks256_root_not_recovered`.
|
|
99
|
+
*/
|
|
100
|
+
export { knownLogKeyFromBytes } from "./known-log-key.js";
|
|
101
|
+
export { ks256RootFromAddress } from "./decode-trust-root-cbor.js";
|
|
102
|
+
export type { ParsedKs256RootKey, RootVerifyKey } from "./root-verify-key.js";
|
|
103
|
+
export {
|
|
104
|
+
ERC1271_MAGIC_VALUE,
|
|
105
|
+
decodeKs256Attestation,
|
|
106
|
+
encodeKs256Attestation,
|
|
107
|
+
type Ks256Attestation,
|
|
108
|
+
} from "./ks256-attestation.js";
|
|
109
|
+
export {
|
|
110
|
+
extractKs256DelegationDigest,
|
|
111
|
+
verifyKs256DelegationCert,
|
|
112
|
+
} from "./verify-ks256-delegation.js";
|
|
113
|
+
export type { Ks256DelegationEvidence } from "./verify-ks256-delegation.js";
|
|
114
|
+
export type { Ks256CertificateDigest } from "@forestrie/delegation-cose";
|
|
92
115
|
/**
|
|
93
116
|
* Passkey session-key endorsement v2 (ADR-0064 as amended by ADR-0065):
|
|
94
117
|
* build, assemble, and verify the artifact chaining a passkey log root to the
|
|
@@ -188,3 +211,33 @@ export {
|
|
|
188
211
|
verifyReceiptOfflineAgainstKnownAccumulator,
|
|
189
212
|
type KnownAccumulator,
|
|
190
213
|
} from "./known-accumulator.js";
|
|
214
|
+
/**
|
|
215
|
+
* Checkpoint-chain trust root with per-link delegation limits and several
|
|
216
|
+
* ERC-1271 attestations (owner ruling D8, receipt-verify 3.2.0): the one
|
|
217
|
+
* shared copy mcp-verify and forestrie-cli use. The high-level verifier
|
|
218
|
+
* folds, matches and judges expiry; the pieces serve callers that match
|
|
219
|
+
* peaks their own way.
|
|
220
|
+
*/
|
|
221
|
+
export { verifyReceiptAgainstCheckpointChain } from "./verify-receipt-against-checkpoint-chain.js";
|
|
222
|
+
export type { VerifyReceiptAgainstCheckpointChainInput } from "./verify-receipt-against-checkpoint-chain.js";
|
|
223
|
+
export type {
|
|
224
|
+
CheckpointChainReceiptOutcome,
|
|
225
|
+
CheckpointChainReceiptStage,
|
|
226
|
+
} from "./checkpoint-chain-receipt-outcome.js";
|
|
227
|
+
export {
|
|
228
|
+
checkCheckpointLinkExpiry,
|
|
229
|
+
checkCheckpointLinkSizeBound,
|
|
230
|
+
makeCheckpointLinkVerifier,
|
|
231
|
+
type CheckpointLinkVerifier,
|
|
232
|
+
} from "./checkpoint-link-verifier.js";
|
|
233
|
+
export type {
|
|
234
|
+
CheckpointLinkFailure,
|
|
235
|
+
CheckpointLinkRecord,
|
|
236
|
+
CheckpointLinkVerifiedBy,
|
|
237
|
+
} from "./checkpoint-link-record.js";
|
|
238
|
+
export { selectKs256Attestation } from "./select-ks256-attestation.js";
|
|
239
|
+
export {
|
|
240
|
+
summarizeKs256AttestationUsage,
|
|
241
|
+
type Ks256AttestationUnusedReason,
|
|
242
|
+
type Ks256AttestationUsage,
|
|
243
|
+
} from "./ks256-attestation-usage.js";
|
package/src/known-accumulator.ts
CHANGED
|
@@ -33,8 +33,13 @@ import {
|
|
|
33
33
|
decodeCborDeterministic,
|
|
34
34
|
encodeCborDeterministic,
|
|
35
35
|
} from "@forestrie/encoding";
|
|
36
|
-
import {
|
|
36
|
+
import {
|
|
37
|
+
calculateRoot,
|
|
38
|
+
peakMMRIndexes,
|
|
39
|
+
type Proof,
|
|
40
|
+
} from "@forestrie/merklelog";
|
|
37
41
|
import { normalizeHexAddress } from "@forestrie/chain-rpc";
|
|
42
|
+
import { inclusionWalkEndIndex } from "./inclusion-walk-end.js";
|
|
38
43
|
import { parseReceipt } from "./parse-receipt.js";
|
|
39
44
|
import { univocityLeafHash } from "./leaf-commitment.js";
|
|
40
45
|
import { SubtleHasher } from "./subtle-hasher.js";
|
|
@@ -243,23 +248,40 @@ export async function verifyReceiptOfflineAgainstKnownAccumulator(input: {
|
|
|
243
248
|
};
|
|
244
249
|
}
|
|
245
250
|
|
|
251
|
+
// The leaf (idtimestamp ‖ inner) and inclusion path ALWAYS decide the
|
|
252
|
+
// peak. An attached payload is only a claim of what that peak is: taken on
|
|
253
|
+
// its own, any receipt attaching a real anchored peak would "verify" for
|
|
254
|
+
// any payload and idtimestamp. Same stage and reason as the main offline
|
|
255
|
+
// path's inclusion check (verifySignatureAndInclusion).
|
|
246
256
|
let recomputedPeak: Uint8Array;
|
|
247
|
-
|
|
248
|
-
recomputedPeak = parsed.explicitPeak;
|
|
249
|
-
} else {
|
|
250
|
-
const hasher = new SubtleHasher();
|
|
257
|
+
try {
|
|
251
258
|
const leafHash = await univocityLeafHash(input.idtimestampBe8, input.inner);
|
|
252
259
|
recomputedPeak = await calculateRoot(
|
|
253
|
-
|
|
260
|
+
new SubtleHasher(),
|
|
254
261
|
leafHash,
|
|
255
262
|
parsed.proof,
|
|
256
263
|
leafIdx,
|
|
257
264
|
);
|
|
265
|
+
} catch {
|
|
266
|
+
return { ok: false, stage: "inclusion", reason: "inclusion_failed" };
|
|
267
|
+
}
|
|
268
|
+
if (
|
|
269
|
+
parsed.explicitPeak !== null &&
|
|
270
|
+
!bytesEqual(parsed.explicitPeak, recomputedPeak)
|
|
271
|
+
) {
|
|
272
|
+
return { ok: false, stage: "inclusion", reason: "inclusion_failed" };
|
|
258
273
|
}
|
|
259
274
|
|
|
260
275
|
// 2. Exact peak match — receipt state is a snapshot-covered accumulator.
|
|
261
|
-
|
|
262
|
-
|
|
276
|
+
// The peak must also sit at the node the inclusion walk reaches (contract
|
|
277
|
+
// order is ascending MMR index, as peakMMRIndexes lists them): a leaf hash
|
|
278
|
+
// does not commit its position, so a value match alone would leave the
|
|
279
|
+
// mmrIndex the freshness check above relies on a caller claim.
|
|
280
|
+
const peakIndex = inclusionWalkEndIndex(leafIdx, parsed.proof.path.length);
|
|
281
|
+
const p = peakMMRIndexes(input.size - 1n).indexOf(peakIndex);
|
|
282
|
+
if (p >= 0) {
|
|
283
|
+
const anchored = input.accumulator[p];
|
|
284
|
+
if (anchored !== undefined && bytesEqual(recomputedPeak, anchored)) {
|
|
263
285
|
return { ok: true, stage: "binding" };
|
|
264
286
|
}
|
|
265
287
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Caller-known log key bytes → trust key (FOR-297 "known log key"; plan-2610-03
|
|
3
|
+
* D6). The bytes alone decide the type, as univocity's
|
|
4
|
+
* `GrantDataInvalidKeyLength` and arbor's `grantDataIdentity` do: 64 bytes is
|
|
5
|
+
* an ES256 x||y public key, 20 bytes a KS256 address, anything else is
|
|
6
|
+
* refused. Either way the caller ASSERTS, and does not prove, that the key
|
|
7
|
+
* owns the log — see `verifyReceiptOfflineWithKeys`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
importEs256PublicKeyFromGrantDataXy64,
|
|
12
|
+
ks256RootFromAddress,
|
|
13
|
+
} from "./decode-trust-root-cbor.js";
|
|
14
|
+
import type { RootVerifyKey } from "./root-verify-key.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param bytes - 64-byte ES256 x||y or 20-byte KS256 address.
|
|
18
|
+
* @throws When the length is neither.
|
|
19
|
+
*/
|
|
20
|
+
export async function knownLogKeyFromBytes(
|
|
21
|
+
bytes: Uint8Array,
|
|
22
|
+
): Promise<RootVerifyKey> {
|
|
23
|
+
if (bytes.length === 64) return importEs256PublicKeyFromGrantDataXy64(bytes);
|
|
24
|
+
if (bytes.length === 20) return ks256RootFromAddress(bytes);
|
|
25
|
+
throw new Error(
|
|
26
|
+
`known log key must be 64 bytes (ES256 x||y) or 20 bytes (KS256 address), got ${bytes.length}`,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accounting for a LIST of ERC-1271 attestations over a checkpoint chain
|
|
3
|
+
* (owner ruling D8): every supplied attestation is reported as used or as
|
|
4
|
+
* unused with a reason, never silently ignored.
|
|
5
|
+
*/
|
|
6
|
+
import type { Ks256Attestation } from "./ks256-attestation.js";
|
|
7
|
+
import type {
|
|
8
|
+
CheckpointLinkFailure,
|
|
9
|
+
CheckpointLinkRecord,
|
|
10
|
+
} from "./checkpoint-link-record.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Why a supplied attestation contributed nothing:
|
|
14
|
+
* - `no_matching_certificate`: no link's certificate has its digest and
|
|
15
|
+
* signature hash.
|
|
16
|
+
* - `not_needed`: it matched a certificate that verified without it
|
|
17
|
+
* (ecrecover, or an ES256 root also vouched).
|
|
18
|
+
* - `duplicate`: another supplied attestation for the same certificate
|
|
19
|
+
* (same digest and signature hash) was used, selected or rejected first,
|
|
20
|
+
* so this one was never tried.
|
|
21
|
+
*/
|
|
22
|
+
export type Ks256AttestationUnusedReason =
|
|
23
|
+
| "no_matching_certificate"
|
|
24
|
+
| "not_needed"
|
|
25
|
+
| "duplicate";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Which supplied attestations carried which links. An attestation may be
|
|
29
|
+
* in both `used` and `rejected` (used on earlier links, refused on a later
|
|
30
|
+
* one); it is in `unused` only when it is in neither.
|
|
31
|
+
*/
|
|
32
|
+
export type Ks256AttestationUsage = {
|
|
33
|
+
used: { index: number; digestHex: string; links: number[] }[];
|
|
34
|
+
/** Tried for a link's certificate and failed its own checks there. */
|
|
35
|
+
rejected: { index: number; digestHex: string; links: number[] }[];
|
|
36
|
+
unused: {
|
|
37
|
+
index: number;
|
|
38
|
+
digestHex: string;
|
|
39
|
+
reason: Ks256AttestationUnusedReason;
|
|
40
|
+
}[];
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Classify each supplied attestation against the per-link records.
|
|
45
|
+
*
|
|
46
|
+
* @param attestations - The list the verifier was given, in order.
|
|
47
|
+
* @param records - Records of the links that verified.
|
|
48
|
+
* @param failure - The link the verifier refused, if any.
|
|
49
|
+
*/
|
|
50
|
+
export function summarizeKs256AttestationUsage(
|
|
51
|
+
attestations: readonly Ks256Attestation[],
|
|
52
|
+
records: readonly CheckpointLinkRecord[],
|
|
53
|
+
failure?: CheckpointLinkFailure,
|
|
54
|
+
): Ks256AttestationUsage {
|
|
55
|
+
const usage: Ks256AttestationUsage = { used: [], rejected: [], unused: [] };
|
|
56
|
+
const seen = [...records, ...(failure !== undefined ? [failure] : [])];
|
|
57
|
+
const tried = (i: number) =>
|
|
58
|
+
seen.some(
|
|
59
|
+
(r) =>
|
|
60
|
+
r.attestationIndex === i || (r.attestationsRejected ?? []).includes(i),
|
|
61
|
+
);
|
|
62
|
+
attestations.forEach((a, index) => {
|
|
63
|
+
const digestHex = bytesToHex(a.digest);
|
|
64
|
+
const used = records
|
|
65
|
+
.filter((r) => r.attestationIndex === index && r.attestationNeeded)
|
|
66
|
+
.map((r) => r.index);
|
|
67
|
+
const rejected = seen
|
|
68
|
+
.filter((r) => (r.attestationsRejected ?? []).includes(index))
|
|
69
|
+
.map((r) => r.index);
|
|
70
|
+
if (used.length > 0) usage.used.push({ index, digestHex, links: used });
|
|
71
|
+
if (rejected.length > 0) {
|
|
72
|
+
usage.rejected.push({ index, digestHex, links: rejected });
|
|
73
|
+
}
|
|
74
|
+
if (used.length > 0 || rejected.length > 0) return;
|
|
75
|
+
let reason: Ks256AttestationUnusedReason = "no_matching_certificate";
|
|
76
|
+
if (seen.some((r) => r.attestationIndex === index)) {
|
|
77
|
+
reason = "not_needed";
|
|
78
|
+
} else if (
|
|
79
|
+
attestations.some(
|
|
80
|
+
(b, j) =>
|
|
81
|
+
j !== index &&
|
|
82
|
+
tried(j) &&
|
|
83
|
+
bytesEqual(a.digest, b.digest) &&
|
|
84
|
+
bytesEqual(a.signatureHash, b.signatureHash),
|
|
85
|
+
)
|
|
86
|
+
) {
|
|
87
|
+
reason = "duplicate";
|
|
88
|
+
}
|
|
89
|
+
usage.unused.push({ index, digestHex, reason });
|
|
90
|
+
});
|
|
91
|
+
return usage;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
|
|
95
|
+
if (a.length !== b.length) return false;
|
|
96
|
+
let x = 0;
|
|
97
|
+
for (let i = 0; i < a.length; i++) x |= a[i]! ^ b[i]!;
|
|
98
|
+
return x === 0;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function bytesToHex(bytes: Uint8Array): string {
|
|
102
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
103
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ERC-1271 attestation (plan-2610-03 D4, protocol ADR-0045 amendment
|
|
3
|
+
* 2026-10-18): a caller-supplied record that a contract KS256 root (a Safe)
|
|
4
|
+
* answered `isValidSignature(digest, signature)` with the ERC-1271 magic value
|
|
5
|
+
* at a given block. It lets a contract-rooted delegation certificate verify
|
|
6
|
+
* offline with the narrower guarantee "valid under the contract's signers at
|
|
7
|
+
* the attested block".
|
|
8
|
+
*
|
|
9
|
+
* Same trust model as the known-accumulator snapshot (known-accumulator.ts):
|
|
10
|
+
* the record is the fetching party's chain read, made explicit and auditable.
|
|
11
|
+
* It binds `(chainId, address, digest, signature hash, block)`, so anyone with
|
|
12
|
+
* RPC can re-run exactly that call at that block (the signature itself is in
|
|
13
|
+
* the receipt's certificate) and confirm or disprove it. This package never makes
|
|
14
|
+
* that call; forestrie-cli and mcp-resolve fetch it (slice 04).
|
|
15
|
+
*
|
|
16
|
+
* The verifier accepts an attestation only for the exact root address and
|
|
17
|
+
* certificate digest, and only when `chainId` equals the forest's genesis
|
|
18
|
+
* chain: the same Safe address on another chain can have other owners (D6).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
decodeCborDeterministic,
|
|
23
|
+
encodeCborDeterministic,
|
|
24
|
+
} from "@forestrie/encoding";
|
|
25
|
+
|
|
26
|
+
/** ERC-1271 `isValidSignature` success value, `bytes4(0x1626ba7e)`. */
|
|
27
|
+
export const ERC1271_MAGIC_VALUE = new Uint8Array([0x16, 0x26, 0xba, 0x7e]);
|
|
28
|
+
|
|
29
|
+
/** CBOR map labels for the attestation artifact (strict RFC 8949 §4.2). */
|
|
30
|
+
const LABEL_VERSION = 1;
|
|
31
|
+
const LABEL_CHAIN_ID = 2;
|
|
32
|
+
const LABEL_ADDRESS = 3;
|
|
33
|
+
const LABEL_DIGEST = 4;
|
|
34
|
+
const LABEL_BLOCK_NUMBER = 5;
|
|
35
|
+
const LABEL_BLOCK_HASH = 6;
|
|
36
|
+
const LABEL_MAGIC = 7;
|
|
37
|
+
const LABEL_SIGNATURE_HASH = 8;
|
|
38
|
+
|
|
39
|
+
const KNOWN_LABELS = new Set<number>([
|
|
40
|
+
LABEL_VERSION,
|
|
41
|
+
LABEL_CHAIN_ID,
|
|
42
|
+
LABEL_ADDRESS,
|
|
43
|
+
LABEL_DIGEST,
|
|
44
|
+
LABEL_BLOCK_NUMBER,
|
|
45
|
+
LABEL_BLOCK_HASH,
|
|
46
|
+
LABEL_MAGIC,
|
|
47
|
+
LABEL_SIGNATURE_HASH,
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
const ATTESTATION_VERSION = 1;
|
|
51
|
+
|
|
52
|
+
/** A block-bound ERC-1271 `isValidSignature` result for one digest. */
|
|
53
|
+
export type Ks256Attestation = {
|
|
54
|
+
version: number;
|
|
55
|
+
/** Chain the call was made on; must equal the forest's genesis chain. */
|
|
56
|
+
chainId: bigint;
|
|
57
|
+
/** Contract root address (20 bytes) the call was made to. */
|
|
58
|
+
address: Uint8Array;
|
|
59
|
+
/** keccak256(Sig_structure) of the delegation certificate (32 bytes). */
|
|
60
|
+
digest: Uint8Array;
|
|
61
|
+
/**
|
|
62
|
+
* keccak256 of the signature bytes `isValidSignature` was called with
|
|
63
|
+
* (32 bytes); must match the certificate's signature.
|
|
64
|
+
*/
|
|
65
|
+
signatureHash: Uint8Array;
|
|
66
|
+
/** Block the call was made at. */
|
|
67
|
+
blockNumber: bigint;
|
|
68
|
+
/** Hash of that block (32 bytes): the falsifiability handle. */
|
|
69
|
+
blockHash: Uint8Array;
|
|
70
|
+
/** The call's 4-byte return; only {@link ERC1271_MAGIC_VALUE} attests. */
|
|
71
|
+
magic: Uint8Array;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** Encode an attestation as canonical CBOR (RFC 8949 §4.2, hard policy). */
|
|
75
|
+
export function encodeKs256Attestation(
|
|
76
|
+
attestation: Ks256Attestation,
|
|
77
|
+
): Uint8Array {
|
|
78
|
+
return encodeCborDeterministic(
|
|
79
|
+
new Map<number, unknown>([
|
|
80
|
+
[LABEL_VERSION, attestation.version],
|
|
81
|
+
[LABEL_CHAIN_ID, attestation.chainId],
|
|
82
|
+
[LABEL_ADDRESS, attestation.address],
|
|
83
|
+
[LABEL_DIGEST, attestation.digest],
|
|
84
|
+
[LABEL_BLOCK_NUMBER, attestation.blockNumber],
|
|
85
|
+
[LABEL_BLOCK_HASH, attestation.blockHash],
|
|
86
|
+
[LABEL_MAGIC, attestation.magic],
|
|
87
|
+
[LABEL_SIGNATURE_HASH, attestation.signatureHash],
|
|
88
|
+
]),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function asBigint(v: unknown, what: string): bigint {
|
|
93
|
+
if (typeof v === "bigint") return v;
|
|
94
|
+
if (typeof v === "number" && Number.isSafeInteger(v) && v >= 0)
|
|
95
|
+
return BigInt(v);
|
|
96
|
+
throw new Error(`ks256 attestation: ${what} must be an unsigned integer`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function asBytes(v: unknown, length: number, what: string): Uint8Array {
|
|
100
|
+
if (!(v instanceof Uint8Array) || v.length !== length) {
|
|
101
|
+
throw new Error(`ks256 attestation: ${what} must be ${length} bytes`);
|
|
102
|
+
}
|
|
103
|
+
return v;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Strict decode and shape validation of an attestation artifact: canonical
|
|
108
|
+
* CBOR, known labels only, version 1, exact types and lengths.
|
|
109
|
+
*/
|
|
110
|
+
export function decodeKs256Attestation(bytes: Uint8Array): Ks256Attestation {
|
|
111
|
+
let decoded: unknown;
|
|
112
|
+
try {
|
|
113
|
+
decoded = decodeCborDeterministic(bytes);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
116
|
+
throw new Error(`ks256 attestation is not canonical CBOR: ${message}`);
|
|
117
|
+
}
|
|
118
|
+
if (!(decoded instanceof Map)) {
|
|
119
|
+
throw new Error("ks256 attestation must be a CBOR map");
|
|
120
|
+
}
|
|
121
|
+
for (const label of decoded.keys()) {
|
|
122
|
+
if (!KNOWN_LABELS.has(label as number)) {
|
|
123
|
+
throw new Error(`ks256 attestation: unknown label ${String(label)}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const version = Number(asBigint(decoded.get(LABEL_VERSION), "version"));
|
|
127
|
+
if (version !== ATTESTATION_VERSION) {
|
|
128
|
+
throw new Error(`ks256 attestation version ${version} not supported`);
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
version,
|
|
132
|
+
chainId: asBigint(decoded.get(LABEL_CHAIN_ID), "chainId"),
|
|
133
|
+
address: asBytes(decoded.get(LABEL_ADDRESS), 20, "address"),
|
|
134
|
+
digest: asBytes(decoded.get(LABEL_DIGEST), 32, "digest"),
|
|
135
|
+
blockNumber: asBigint(decoded.get(LABEL_BLOCK_NUMBER), "blockNumber"),
|
|
136
|
+
blockHash: asBytes(decoded.get(LABEL_BLOCK_HASH), 32, "blockHash"),
|
|
137
|
+
magic: asBytes(decoded.get(LABEL_MAGIC), 4, "magic"),
|
|
138
|
+
signatureHash: asBytes(
|
|
139
|
+
decoded.get(LABEL_SIGNATURE_HASH),
|
|
140
|
+
32,
|
|
141
|
+
"signatureHash",
|
|
142
|
+
),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
@@ -6,12 +6,13 @@
|
|
|
6
6
|
* trust root and returns the delegated public key as an additional verify
|
|
7
7
|
* candidate — the offline port of canopy-api's `resolveReceiptVerifyKey`.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
9
|
+
* Sealer delegate keys are always secp256r1. A P-256 root's certificate is
|
|
10
|
+
* checked here; a passkey (WebAuthn) root signs the certificate with alg
|
|
11
|
+
* -65800 and the ADR-0063 unprotected-header envelope, which
|
|
12
|
+
* `verifyCoseSign1WithParsedKey` verifies against the same P-256 root keys
|
|
13
|
+
* (plan-2608-13 1.4). A KS256 root's certificate is checked by
|
|
14
|
+
* {@link verifyKs256DelegationCert}: ecrecover for an EOA, or a caller-supplied
|
|
15
|
+
* ERC-1271 attestation for a contract (plan-2610-03 D4).
|
|
15
16
|
*
|
|
16
17
|
* Trust anchors (FOR-297 trust ladder): `rootKeys` need not be genesis-derived
|
|
17
18
|
* — a caller-known log OWNER key works identically (see
|
|
@@ -41,6 +42,11 @@ import {
|
|
|
41
42
|
decodeCoseSign1,
|
|
42
43
|
verifyCoseSign1WithParsedKey,
|
|
43
44
|
} from "@forestrie/encoding";
|
|
45
|
+
import { isParsedKs256RootKey, type RootVerifyKey } from "./root-verify-key.js";
|
|
46
|
+
import {
|
|
47
|
+
verifyKs256DelegationCert,
|
|
48
|
+
type Ks256DelegationEvidence,
|
|
49
|
+
} from "./verify-ks256-delegation.js";
|
|
44
50
|
|
|
45
51
|
/** Unprotected header label carrying the delegation certificate. */
|
|
46
52
|
const DELEGATION_CERT_LABEL = 1000;
|
|
@@ -104,7 +110,8 @@ export type DelegationConstraints = {
|
|
|
104
110
|
* `constraints` carries the cert's coverage/validity window when the payload
|
|
105
111
|
* declares it (null for legacy certs that predate labels 3/4/8/9).
|
|
106
112
|
* broken: a cert is present but did not verify under the root, or its delegated
|
|
107
|
-
* key could not be parsed — the delegation chain is invalid.
|
|
113
|
+
* key could not be parsed — the delegation chain is invalid. `reason` is set
|
|
114
|
+
* when a KS256 root gives a more specific one than `delegation_invalid`.
|
|
108
115
|
*/
|
|
109
116
|
export type DelegatedResolution =
|
|
110
117
|
| { kind: "root-only" }
|
|
@@ -113,7 +120,7 @@ export type DelegatedResolution =
|
|
|
113
120
|
delegatedKey: CryptoKey;
|
|
114
121
|
constraints: DelegationConstraints | null;
|
|
115
122
|
}
|
|
116
|
-
| { kind: "broken" };
|
|
123
|
+
| { kind: "broken"; reason?: string };
|
|
117
124
|
|
|
118
125
|
function extractDelegationCertBytes(unprotected: unknown): Uint8Array | null {
|
|
119
126
|
const umap = coseUnprotectedToMap(unprotected);
|
|
@@ -262,11 +269,16 @@ export function checkDelegationConstraints(
|
|
|
262
269
|
* Resolve the delegated verify key from a receipt's delegation certificate.
|
|
263
270
|
*
|
|
264
271
|
* @param receiptCbor - the COSE_Sign1 receipt bytes.
|
|
265
|
-
* @param rootKeys -
|
|
272
|
+
* @param rootKeys - trust roots: ES256 keys and KS256 addresses, from genesis
|
|
273
|
+
* or caller-known log keys. A KS256 root never comes from the coordinator
|
|
274
|
+
* (its public roots are last-write-wins, FOR-601/FOR-603).
|
|
275
|
+
* @param ks256Evidence - optional ERC-1271 attestation for a KS256 contract
|
|
276
|
+
* root, with the forest's genesis chain id it must match.
|
|
266
277
|
*/
|
|
267
278
|
export async function resolveDelegatedVerifyKey(
|
|
268
279
|
receiptCbor: Uint8Array,
|
|
269
|
-
rootKeys:
|
|
280
|
+
rootKeys: RootVerifyKey[],
|
|
281
|
+
ks256Evidence: Ks256DelegationEvidence = {},
|
|
270
282
|
): Promise<DelegatedResolution> {
|
|
271
283
|
const decoded = decodeCoseSign1(receiptCbor);
|
|
272
284
|
if (!decoded) return { kind: "root-only" };
|
|
@@ -275,7 +287,21 @@ export async function resolveDelegatedVerifyKey(
|
|
|
275
287
|
if (!certBytes) return { kind: "root-only" };
|
|
276
288
|
|
|
277
289
|
let verified = false;
|
|
290
|
+
let ks256Reason: string | undefined;
|
|
278
291
|
for (const rootKey of rootKeys) {
|
|
292
|
+
if (isParsedKs256RootKey(rootKey)) {
|
|
293
|
+
const check = await verifyKs256DelegationCert(
|
|
294
|
+
certBytes,
|
|
295
|
+
rootKey.address,
|
|
296
|
+
ks256Evidence,
|
|
297
|
+
);
|
|
298
|
+
if (check.ok) {
|
|
299
|
+
verified = true;
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
ks256Reason ??= check.reason;
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
279
305
|
if (
|
|
280
306
|
await verifyCoseSign1WithParsedKey(certBytes, rootKey, {
|
|
281
307
|
logPrefix: "delegation-cert-offline",
|
|
@@ -285,7 +311,7 @@ export async function resolveDelegatedVerifyKey(
|
|
|
285
311
|
break;
|
|
286
312
|
}
|
|
287
313
|
}
|
|
288
|
-
if (!verified) return { kind: "broken" };
|
|
314
|
+
if (!verified) return { kind: "broken", reason: ks256Reason };
|
|
289
315
|
|
|
290
316
|
const certDecoded = decodeCoseSign1(certBytes);
|
|
291
317
|
if (!certDecoded) return { kind: "broken" };
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Choosing an ERC-1271 attestation from several (owner ruling D8,
|
|
3
|
+
* 2026-10-19). A contract KS256 root (a Safe) that rotated its delegate
|
|
4
|
+
* signs one certificate per delegate, and each certificate needs its own
|
|
5
|
+
* attestation; a caller holding a checkpoint chain or several receipts
|
|
6
|
+
* supplies them as a list. Each certificate takes the attestation whose
|
|
7
|
+
* `digest` AND `signatureHash` are that certificate's: keccak256 of its
|
|
8
|
+
* Sig_structure and keccak256 of its signature bytes.
|
|
9
|
+
*
|
|
10
|
+
* Selection never makes an attestation acceptable on its own:
|
|
11
|
+
* `verifyKs256DelegationCert` still checks address, digest, signature hash,
|
|
12
|
+
* genesis chain id and magic.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { keccak_256 } from "@noble/hashes/sha3";
|
|
16
|
+
import type { Ks256CertificateDigest } from "@forestrie/delegation-cose";
|
|
17
|
+
import type { Ks256Attestation } from "./ks256-attestation.js";
|
|
18
|
+
import {
|
|
19
|
+
resolveDelegatedVerifyKey,
|
|
20
|
+
type DelegatedResolution,
|
|
21
|
+
} from "./resolve-delegated-verify-key.js";
|
|
22
|
+
import type { RootVerifyKey } from "./root-verify-key.js";
|
|
23
|
+
import { extractKs256DelegationDigest } from "./verify-ks256-delegation.js";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The attestation in `attestations` made for this certificate: the first
|
|
27
|
+
* whose `digest` equals the certificate digest and whose `signatureHash`
|
|
28
|
+
* equals keccak256 of the certificate signature.
|
|
29
|
+
*
|
|
30
|
+
* @param cert - From `extractKs256CertificateDigest` (delegation-cose) or
|
|
31
|
+
* {@link extractKs256DelegationDigest}.
|
|
32
|
+
* @returns The first matching attestation, or `undefined` when none
|
|
33
|
+
* matches. The verifiers go further: they try EVERY match in order and
|
|
34
|
+
* keep the first that passes its own checks.
|
|
35
|
+
*/
|
|
36
|
+
export function selectKs256Attestation(
|
|
37
|
+
cert: Ks256CertificateDigest,
|
|
38
|
+
attestations: readonly Ks256Attestation[],
|
|
39
|
+
): Ks256Attestation | undefined {
|
|
40
|
+
const i = selectKs256AttestationIndex(cert, attestations);
|
|
41
|
+
return i === undefined ? undefined : attestations[i];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function selectKs256AttestationIndex(
|
|
45
|
+
cert: Ks256CertificateDigest,
|
|
46
|
+
attestations: readonly Ks256Attestation[],
|
|
47
|
+
): number | undefined {
|
|
48
|
+
return matchingIndexes(cert, attestations)[0];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Resolve a COSE_Sign1's label-1000 certificate (a receipt or a checkpoint)
|
|
53
|
+
* under `rootKeys`, trying the supplied attestations made for it.
|
|
54
|
+
*
|
|
55
|
+
* Every attestation whose digest and signature hash match the certificate
|
|
56
|
+
* is tried in list order, and the first that resolves wins: a stale or
|
|
57
|
+
* wrong-chain copy listed before a good one must not mask it. When none
|
|
58
|
+
* resolves, the first failure is returned. When none matches at all, a
|
|
59
|
+
* DIAGNOSTIC stand-in (one with the right digest if any, else the first) is
|
|
60
|
+
* passed so a contract root fails with the precise
|
|
61
|
+
* `ks256_attestation_*_mismatch` reason rather than a bare
|
|
62
|
+
* `ks256_root_not_recovered`; a stand-in can never verify, because
|
|
63
|
+
* `verifyKs256DelegationCert` checks digest and signature hash. With one
|
|
64
|
+
* attestation supplied this is exactly the 3.1.0 behaviour of the singular
|
|
65
|
+
* `ks256Attestation` input.
|
|
66
|
+
*
|
|
67
|
+
* @returns The resolution; `selected`, the index of the attestation it was
|
|
68
|
+
* resolved with (whether or not ecrecover made it unnecessary); and
|
|
69
|
+
* `rejected`, the matching attestations tried that failed their own
|
|
70
|
+
* checks (address, chain id, magic).
|
|
71
|
+
* @internal
|
|
72
|
+
*/
|
|
73
|
+
export async function resolveWithKs256Attestations(
|
|
74
|
+
coseSign1Bytes: Uint8Array,
|
|
75
|
+
rootKeys: RootVerifyKey[],
|
|
76
|
+
attestations: readonly Ks256Attestation[],
|
|
77
|
+
forestChainId: bigint | undefined,
|
|
78
|
+
): Promise<{
|
|
79
|
+
resolution: DelegatedResolution;
|
|
80
|
+
selected?: number;
|
|
81
|
+
rejected: number[];
|
|
82
|
+
}> {
|
|
83
|
+
if (attestations.length === 0) {
|
|
84
|
+
return {
|
|
85
|
+
resolution: await resolveDelegatedVerifyKey(coseSign1Bytes, rootKeys),
|
|
86
|
+
rejected: [],
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
let cert: Ks256CertificateDigest | null = null;
|
|
90
|
+
try {
|
|
91
|
+
cert = extractKs256DelegationDigest(coseSign1Bytes);
|
|
92
|
+
} catch {
|
|
93
|
+
// Not a KS256 certificate (or not a COSE_Sign1): no attestation can be
|
|
94
|
+
// made for it; the stand-in below is never consulted by an ES256 root.
|
|
95
|
+
cert = null;
|
|
96
|
+
}
|
|
97
|
+
const candidates = cert === null ? [] : matchingIndexes(cert, attestations);
|
|
98
|
+
const rejected: number[] = [];
|
|
99
|
+
let firstFailure: DelegatedResolution | undefined;
|
|
100
|
+
for (const i of candidates) {
|
|
101
|
+
const resolution = await resolveDelegatedVerifyKey(
|
|
102
|
+
coseSign1Bytes,
|
|
103
|
+
rootKeys,
|
|
104
|
+
{ attestation: attestations[i], forestChainId },
|
|
105
|
+
);
|
|
106
|
+
if (resolution.kind !== "broken") {
|
|
107
|
+
return { resolution, selected: i, rejected };
|
|
108
|
+
}
|
|
109
|
+
firstFailure ??= resolution;
|
|
110
|
+
// Only an attestation's own checks make it "rejected"; any other
|
|
111
|
+
// failure is the certificate's and no other copy can change it.
|
|
112
|
+
if (!resolution.reason?.startsWith("ks256_attestation_")) break;
|
|
113
|
+
rejected.push(i);
|
|
114
|
+
}
|
|
115
|
+
if (firstFailure !== undefined) return { resolution: firstFailure, rejected };
|
|
116
|
+
const standIn =
|
|
117
|
+
(cert !== null &&
|
|
118
|
+
attestations.find((a) => bytesEqual(a.digest, cert.digest))) ||
|
|
119
|
+
attestations[0];
|
|
120
|
+
return {
|
|
121
|
+
resolution: await resolveDelegatedVerifyKey(coseSign1Bytes, rootKeys, {
|
|
122
|
+
attestation: standIn,
|
|
123
|
+
forestChainId,
|
|
124
|
+
}),
|
|
125
|
+
rejected,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function matchingIndexes(
|
|
130
|
+
cert: Ks256CertificateDigest,
|
|
131
|
+
attestations: readonly Ks256Attestation[],
|
|
132
|
+
): number[] {
|
|
133
|
+
const signatureHash = keccak_256(cert.signature);
|
|
134
|
+
const out: number[] = [];
|
|
135
|
+
attestations.forEach((a, i) => {
|
|
136
|
+
if (
|
|
137
|
+
bytesEqual(a.digest, cert.digest) &&
|
|
138
|
+
bytesEqual(a.signatureHash, signatureHash)
|
|
139
|
+
) {
|
|
140
|
+
out.push(i);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
|
|
147
|
+
if (a.length !== b.length) return false;
|
|
148
|
+
let x = 0;
|
|
149
|
+
for (let i = 0; i < a.length; i++) x |= a[i]! ^ b[i]!;
|
|
150
|
+
return x === 0;
|
|
151
|
+
}
|