@forestrie/receipt-verify 3.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build-receipt-offline.d.ts +8 -4
- package/dist/build-receipt-offline.d.ts.map +1 -1
- package/dist/build-receipt-offline.js +13 -18
- 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/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -0
- 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.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/verify-grant-receipt-offline.d.ts +20 -6
- package/dist/verify-grant-receipt-offline.d.ts.map +1 -1
- package/dist/verify-grant-receipt-offline.js +60 -11
- 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/package.json +3 -1
- package/src/build-receipt-offline.ts +21 -20
- package/src/decode-trust-root-cbor.ts +10 -1
- package/src/index.ts +23 -0
- package/src/known-log-key.ts +28 -0
- package/src/ks256-attestation.ts +144 -0
- package/src/resolve-delegated-verify-key.ts +37 -11
- package/src/verify-grant-receipt-offline.ts +92 -19
- package/src/verify-ks256-delegation.ts +120 -0
|
@@ -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" };
|
|
@@ -7,7 +7,10 @@ import {
|
|
|
7
7
|
import type { Grant } from "@forestrie/encoding";
|
|
8
8
|
import { grantCommitmentHashFromGrant } from "./grant-commitment.js";
|
|
9
9
|
import { decodeTrustRootFromGenesis } from "./decode-trust-root-from-genesis.js";
|
|
10
|
-
import {
|
|
10
|
+
import { decodeChainBindingFromGenesis } from "./decode-chain-binding-from-genesis.js";
|
|
11
|
+
import type { Ks256Attestation } from "./ks256-attestation.js";
|
|
12
|
+
import type { Ks256DelegationEvidence } from "./verify-ks256-delegation.js";
|
|
13
|
+
import { isParsedKs256RootKey, type RootVerifyKey } from "./root-verify-key.js";
|
|
11
14
|
import {
|
|
12
15
|
checkDelegationConstraints,
|
|
13
16
|
resolveDelegatedVerifyKey,
|
|
@@ -22,6 +25,8 @@ export type VerifyGrantReceiptOfflineInput = {
|
|
|
22
25
|
receiptCbor: Uint8Array;
|
|
23
26
|
grant: Grant;
|
|
24
27
|
idtimestampBe8: Uint8Array;
|
|
28
|
+
/** See {@link VerifyReceiptOfflineInput.ks256Attestation}. */
|
|
29
|
+
ks256Attestation?: Ks256Attestation;
|
|
25
30
|
};
|
|
26
31
|
|
|
27
32
|
function readIdtimestampBe8(bytes: Uint8Array): bigint {
|
|
@@ -116,16 +121,15 @@ export async function verifyGrantReceiptOffline(
|
|
|
116
121
|
receiptCbor: input.receiptCbor,
|
|
117
122
|
idtimestampBe8: input.idtimestampBe8,
|
|
118
123
|
inner,
|
|
124
|
+
ks256Attestation: input.ks256Attestation,
|
|
119
125
|
});
|
|
120
126
|
}
|
|
121
127
|
|
|
122
128
|
export type VerifyGrantReceiptOfflineWithKeysInput = Omit<
|
|
123
129
|
VerifyGrantReceiptOfflineInput,
|
|
124
130
|
"genesisCbor"
|
|
125
|
-
> &
|
|
126
|
-
|
|
127
|
-
trustKeys: CryptoKey[];
|
|
128
|
-
};
|
|
131
|
+
> &
|
|
132
|
+
Pick<VerifyReceiptOfflineWithKeysInput, "trustKeys" | "forestGenesisCbor">;
|
|
129
133
|
|
|
130
134
|
/**
|
|
131
135
|
* {@link verifyGrantReceiptOffline} under caller-supplied trust keys instead of
|
|
@@ -146,6 +150,7 @@ export async function verifyGrantReceiptOfflineWithKeys(
|
|
|
146
150
|
idtimestampBe8: input.idtimestampBe8,
|
|
147
151
|
inner,
|
|
148
152
|
trustKeys: input.trustKeys,
|
|
153
|
+
ks256Evidence: withKeysKs256Evidence(input),
|
|
149
154
|
});
|
|
150
155
|
}
|
|
151
156
|
|
|
@@ -160,6 +165,12 @@ export type VerifyReceiptOfflineInput = {
|
|
|
160
165
|
*/
|
|
161
166
|
payload: Uint8Array;
|
|
162
167
|
idtimestampBe8: Uint8Array;
|
|
168
|
+
/**
|
|
169
|
+
* ERC-1271 attestation for a KS256 contract root (plan-2610-03 D4). Used
|
|
170
|
+
* only when ecrecover doesn't match the root, and accepted only when it
|
|
171
|
+
* binds the root address, the certificate digest and the genesis chain id.
|
|
172
|
+
*/
|
|
173
|
+
ks256Attestation?: Ks256Attestation;
|
|
163
174
|
};
|
|
164
175
|
|
|
165
176
|
/**
|
|
@@ -183,6 +194,7 @@ export async function verifyReceiptOffline(
|
|
|
183
194
|
receiptCbor: input.receiptCbor,
|
|
184
195
|
idtimestampBe8: input.idtimestampBe8,
|
|
185
196
|
inner,
|
|
197
|
+
ks256Attestation: input.ks256Attestation,
|
|
186
198
|
});
|
|
187
199
|
}
|
|
188
200
|
|
|
@@ -191,12 +203,19 @@ export type VerifyReceiptOfflineWithKeysInput = Omit<
|
|
|
191
203
|
"genesisCbor"
|
|
192
204
|
> & {
|
|
193
205
|
/**
|
|
194
|
-
* Caller-known
|
|
206
|
+
* Caller-known trust keys, tried in order: ES256 `CryptoKey`s or KS256
|
|
207
|
+
* 20-byte addresses ({@link knownLogKeyFromBytes}). For a child-log receipt
|
|
195
208
|
* this is the log OWNER's key (the delegation-cert issuer), NOT the sealer
|
|
196
209
|
* key — the label-1000 cert is verified under these keys and the delegated
|
|
197
210
|
* sealer key is extracted from it, so the anchor survives sealer rotation.
|
|
198
211
|
*/
|
|
199
|
-
trustKeys:
|
|
212
|
+
trustKeys: RootVerifyKey[];
|
|
213
|
+
/**
|
|
214
|
+
* The forest's genesis, read only for the chain id a `ks256Attestation`
|
|
215
|
+
* must match (D6); it supplies no trust keys. Without it an attestation is
|
|
216
|
+
* refused with `ks256_attestation_chain_unbound`.
|
|
217
|
+
*/
|
|
218
|
+
forestGenesisCbor?: Uint8Array;
|
|
200
219
|
};
|
|
201
220
|
|
|
202
221
|
/**
|
|
@@ -230,9 +249,41 @@ export async function verifyReceiptOfflineWithKeys(
|
|
|
230
249
|
idtimestampBe8: input.idtimestampBe8,
|
|
231
250
|
inner,
|
|
232
251
|
trustKeys: input.trustKeys,
|
|
252
|
+
ks256Evidence: withKeysKs256Evidence(input),
|
|
233
253
|
});
|
|
234
254
|
}
|
|
235
255
|
|
|
256
|
+
/**
|
|
257
|
+
* KS256 chain evidence for the caller-known-key entries: the attestation, and
|
|
258
|
+
* the chain id from the forest genesis the caller supplied, if any.
|
|
259
|
+
*/
|
|
260
|
+
function withKeysKs256Evidence(input: {
|
|
261
|
+
ks256Attestation?: Ks256Attestation | undefined;
|
|
262
|
+
forestGenesisCbor?: Uint8Array | undefined;
|
|
263
|
+
}): Ks256DelegationEvidence {
|
|
264
|
+
if (!input.ks256Attestation) return {};
|
|
265
|
+
return {
|
|
266
|
+
attestation: input.ks256Attestation,
|
|
267
|
+
forestChainId: input.forestGenesisCbor
|
|
268
|
+
? genesisChainIdOrUndefined(input.forestGenesisCbor)
|
|
269
|
+
: undefined,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The forest's chain id from its genesis chain binding, or `undefined` when
|
|
275
|
+
* the genesis carries none (an attestation then can't be bound, D6).
|
|
276
|
+
*/
|
|
277
|
+
function genesisChainIdOrUndefined(
|
|
278
|
+
genesisCbor: Uint8Array,
|
|
279
|
+
): bigint | undefined {
|
|
280
|
+
try {
|
|
281
|
+
return BigInt(decodeChainBindingFromGenesis(genesisCbor).chainId);
|
|
282
|
+
} catch {
|
|
283
|
+
return undefined;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
236
287
|
/**
|
|
237
288
|
* Shared offline-verify core: reconstruct the leaf as
|
|
238
289
|
* `univocityLeafHash(idtimestamp, inner)`, resolve the (possibly delegated,
|
|
@@ -245,37 +296,42 @@ async function verifyReceiptOfflineWithLeafInner(input: {
|
|
|
245
296
|
receiptCbor: Uint8Array;
|
|
246
297
|
idtimestampBe8: Uint8Array;
|
|
247
298
|
inner: Uint8Array;
|
|
299
|
+
ks256Attestation?: Ks256Attestation | undefined;
|
|
248
300
|
}): Promise<ReceiptVerifyResult> {
|
|
249
|
-
let trustRoot;
|
|
301
|
+
let trustRoot: RootVerifyKey;
|
|
250
302
|
try {
|
|
251
303
|
trustRoot = await decodeTrustRootFromGenesis(input.genesisCbor);
|
|
252
304
|
} catch {
|
|
253
305
|
return { ok: false, stage: "parse", reason: "genesis_invalid" };
|
|
254
306
|
}
|
|
255
307
|
|
|
256
|
-
const verifyKeys = es256ReceiptVerifyKeys([trustRoot]) as CryptoKey[];
|
|
257
|
-
if (!verifyKeys.length) {
|
|
258
|
-
return { ok: false, stage: "signature", reason: "no_es256_trust_key" };
|
|
259
|
-
}
|
|
260
|
-
|
|
261
308
|
return verifyReceiptOfflineWithLeafInnerKeys({
|
|
262
309
|
receiptCbor: input.receiptCbor,
|
|
263
310
|
idtimestampBe8: input.idtimestampBe8,
|
|
264
311
|
inner: input.inner,
|
|
265
|
-
trustKeys:
|
|
312
|
+
trustKeys: [trustRoot],
|
|
313
|
+
ks256Evidence: input.ks256Attestation
|
|
314
|
+
? {
|
|
315
|
+
attestation: input.ks256Attestation,
|
|
316
|
+
forestChainId: genesisChainIdOrUndefined(input.genesisCbor),
|
|
317
|
+
}
|
|
318
|
+
: {},
|
|
266
319
|
});
|
|
267
320
|
}
|
|
268
321
|
|
|
269
322
|
/**
|
|
270
323
|
* Trust-key-parameterised offline-verify core. Identical to the genesis path
|
|
271
324
|
* once the trust keys are in hand: resolve the (possibly delegated, FOR-297)
|
|
272
|
-
* verify key against `trustKeys`, then check signature + inclusion.
|
|
325
|
+
* verify key against `trustKeys`, then check signature + inclusion. Receipts
|
|
326
|
+
* are always ES256, so a KS256 trust key only ever verifies the delegation
|
|
327
|
+
* certificate; only ES256 trust keys are tried on the receipt itself.
|
|
273
328
|
*/
|
|
274
329
|
async function verifyReceiptOfflineWithLeafInnerKeys(input: {
|
|
275
330
|
receiptCbor: Uint8Array;
|
|
276
331
|
idtimestampBe8: Uint8Array;
|
|
277
332
|
inner: Uint8Array;
|
|
278
|
-
trustKeys:
|
|
333
|
+
trustKeys: RootVerifyKey[];
|
|
334
|
+
ks256Evidence: Ks256DelegationEvidence;
|
|
279
335
|
}): Promise<ReceiptVerifyResult> {
|
|
280
336
|
let parsed: ReturnType<typeof parseReceipt>;
|
|
281
337
|
try {
|
|
@@ -296,14 +352,31 @@ async function verifyReceiptOfflineWithLeafInnerKeys(input: {
|
|
|
296
352
|
const delegation = await resolveDelegatedVerifyKey(
|
|
297
353
|
input.receiptCbor,
|
|
298
354
|
input.trustKeys,
|
|
355
|
+
input.ks256Evidence,
|
|
299
356
|
);
|
|
300
357
|
if (delegation.kind === "broken") {
|
|
301
|
-
return {
|
|
358
|
+
return {
|
|
359
|
+
ok: false,
|
|
360
|
+
stage: "signature",
|
|
361
|
+
reason: delegation.reason ?? "delegation_invalid",
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
const es256TrustKeys = input.trustKeys.filter(
|
|
365
|
+
(k): k is CryptoKey => !isParsedKs256RootKey(k),
|
|
366
|
+
);
|
|
367
|
+
if (delegation.kind === "root-only" && !es256TrustKeys.length) {
|
|
368
|
+
// A KS256 root never signs a receipt; without a certificate there is no
|
|
369
|
+
// ES256 key to check it with.
|
|
370
|
+
return {
|
|
371
|
+
ok: false,
|
|
372
|
+
stage: "signature",
|
|
373
|
+
reason: "ks256_root_requires_delegation",
|
|
374
|
+
};
|
|
302
375
|
}
|
|
303
376
|
const allVerifyKeys =
|
|
304
377
|
delegation.kind === "resolved"
|
|
305
|
-
? [delegation.delegatedKey, ...
|
|
306
|
-
:
|
|
378
|
+
? [delegation.delegatedKey, ...es256TrustKeys]
|
|
379
|
+
: es256TrustKeys;
|
|
307
380
|
|
|
308
381
|
let idtimestamp: bigint;
|
|
309
382
|
try {
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline KS256 root → delegation certificate check (plan-2610-03 D4, D5;
|
|
3
|
+
* protocol ADR-0045 amendment 2026-10-18). Receipts are always ES256, signed
|
|
4
|
+
* by a sealer's P-256 delegated key; what a KS256 root changes is how the
|
|
5
|
+
* label-1000 delegation certificate is checked against it.
|
|
6
|
+
*
|
|
7
|
+
* - An EOA root verifies by ecrecover through `@forestrie/delegation-cose`'s
|
|
8
|
+
* `verifyDelegationCertificateKs256` with no hooks: pure, the same guarantee
|
|
9
|
+
* as an ES256 root. delegation-cose also enforces the KS256 alg binding.
|
|
10
|
+
* - Anything ecrecover doesn't match (a contract root such as a Safe, or a
|
|
11
|
+
* wrong signer: offline you can't tell which) fails with
|
|
12
|
+
* `ks256_root_not_recovered`, unless the caller supplies an ERC-1271
|
|
13
|
+
* attestation (ks256-attestation.ts) bound to this root, this digest, this
|
|
14
|
+
* signature and the forest's genesis chain.
|
|
15
|
+
*
|
|
16
|
+
* No network and no injected callback: the chain read is a separate helper's
|
|
17
|
+
* job (forestrie-cli, mcp-resolve), as for the known accumulator.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { coseUnprotectedToMap, decodeCoseSign1 } from "@forestrie/encoding";
|
|
21
|
+
import {
|
|
22
|
+
extractKs256CertificateDigest,
|
|
23
|
+
verifyDelegationCertificateKs256,
|
|
24
|
+
type Ks256CertificateDigest,
|
|
25
|
+
} from "@forestrie/delegation-cose";
|
|
26
|
+
import { keccak_256 } from "@noble/hashes/sha3";
|
|
27
|
+
import {
|
|
28
|
+
ERC1271_MAGIC_VALUE,
|
|
29
|
+
type Ks256Attestation,
|
|
30
|
+
} from "./ks256-attestation.js";
|
|
31
|
+
|
|
32
|
+
/** Unprotected header label carrying the delegation certificate. */
|
|
33
|
+
const DELEGATION_CERT_LABEL = 1000;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Chain evidence a caller may supply for a KS256 contract root. `forestChainId`
|
|
37
|
+
* is read from the forest's genesis, never from the attestation or a
|
|
38
|
+
* coordinator; an attestation without it is refused.
|
|
39
|
+
*/
|
|
40
|
+
export type Ks256DelegationEvidence = {
|
|
41
|
+
attestation?: Ks256Attestation | undefined;
|
|
42
|
+
forestChainId?: bigint | undefined;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Check a delegation certificate against a KS256 root address.
|
|
47
|
+
*
|
|
48
|
+
* @param certBytes - The receipt's label-1000 COSE_Sign1 certificate.
|
|
49
|
+
* @param rootAddress - 20-byte root from genesis or a caller-known log key.
|
|
50
|
+
* @param evidence - Optional ERC-1271 attestation and the genesis chain id.
|
|
51
|
+
* @returns `ok`, or a stable reason: `delegation_not_ks256` (wrong alg,
|
|
52
|
+
* WebAuthn envelope or malformed), `ks256_root_not_recovered`, or a
|
|
53
|
+
* `ks256_attestation_*` mismatch.
|
|
54
|
+
*/
|
|
55
|
+
export async function verifyKs256DelegationCert(
|
|
56
|
+
certBytes: Uint8Array,
|
|
57
|
+
rootAddress: Uint8Array,
|
|
58
|
+
evidence: Ks256DelegationEvidence,
|
|
59
|
+
): Promise<{ ok: true } | { ok: false; reason: string }> {
|
|
60
|
+
let parts: Ks256CertificateDigest;
|
|
61
|
+
try {
|
|
62
|
+
parts = extractKs256CertificateDigest(certBytes);
|
|
63
|
+
} catch {
|
|
64
|
+
return { ok: false, reason: "delegation_not_ks256" };
|
|
65
|
+
}
|
|
66
|
+
if (await verifyDelegationCertificateKs256(certBytes, rootAddress)) {
|
|
67
|
+
return { ok: true };
|
|
68
|
+
}
|
|
69
|
+
const { attestation, forestChainId } = evidence;
|
|
70
|
+
if (!attestation) {
|
|
71
|
+
return { ok: false, reason: "ks256_root_not_recovered" };
|
|
72
|
+
}
|
|
73
|
+
if (!bytesEqual(attestation.address, rootAddress)) {
|
|
74
|
+
return { ok: false, reason: "ks256_attestation_address_mismatch" };
|
|
75
|
+
}
|
|
76
|
+
if (!bytesEqual(attestation.digest, parts.digest)) {
|
|
77
|
+
return { ok: false, reason: "ks256_attestation_digest_mismatch" };
|
|
78
|
+
}
|
|
79
|
+
if (!bytesEqual(attestation.signatureHash, keccak_256(parts.signature))) {
|
|
80
|
+
return { ok: false, reason: "ks256_attestation_signature_mismatch" };
|
|
81
|
+
}
|
|
82
|
+
if (forestChainId === undefined) {
|
|
83
|
+
return { ok: false, reason: "ks256_attestation_chain_unbound" };
|
|
84
|
+
}
|
|
85
|
+
if (attestation.chainId !== forestChainId) {
|
|
86
|
+
return { ok: false, reason: "ks256_attestation_chain_mismatch" };
|
|
87
|
+
}
|
|
88
|
+
if (!bytesEqual(attestation.magic, ERC1271_MAGIC_VALUE)) {
|
|
89
|
+
return { ok: false, reason: "ks256_attestation_magic_invalid" };
|
|
90
|
+
}
|
|
91
|
+
return { ok: true };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Extract the KS256 delegation certificate's root address, digest and
|
|
96
|
+
* signature from a receipt: what an ERC-1271 attestation helper calls
|
|
97
|
+
* `isValidSignature` with. Does not verify anything.
|
|
98
|
+
*
|
|
99
|
+
* @param receiptCbor - COSE_Sign1 receipt bytes.
|
|
100
|
+
* @returns `null` when the receipt carries no label-1000 certificate.
|
|
101
|
+
* @throws When the certificate is not a KS256 certificate.
|
|
102
|
+
*/
|
|
103
|
+
export function extractKs256DelegationDigest(
|
|
104
|
+
receiptCbor: Uint8Array,
|
|
105
|
+
): Ks256CertificateDigest | null {
|
|
106
|
+
const decoded = decodeCoseSign1(receiptCbor);
|
|
107
|
+
if (!decoded) throw new Error("receipt is not a COSE_Sign1");
|
|
108
|
+
const certRaw = coseUnprotectedToMap(decoded.unprotected).get(
|
|
109
|
+
DELEGATION_CERT_LABEL,
|
|
110
|
+
);
|
|
111
|
+
if (!(certRaw instanceof Uint8Array) || certRaw.length === 0) return null;
|
|
112
|
+
return extractKs256CertificateDigest(certRaw);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
|
|
116
|
+
if (a.length !== b.length) return false;
|
|
117
|
+
let x = 0;
|
|
118
|
+
for (let i = 0; i < a.length; i++) x |= a[i]! ^ b[i]!;
|
|
119
|
+
return x === 0;
|
|
120
|
+
}
|