@forestrie/receipt-verify 0.12.0 → 1.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.
Files changed (41) hide show
  1. package/dist/chain-binding.d.ts +19 -0
  2. package/dist/chain-binding.d.ts.map +1 -0
  3. package/dist/chain-binding.js +1 -0
  4. package/dist/decode-chain-binding-from-genesis.d.ts +15 -0
  5. package/dist/decode-chain-binding-from-genesis.d.ts.map +1 -0
  6. package/dist/decode-chain-binding-from-genesis.js +55 -0
  7. package/dist/decode-genesis-cbor-map.d.ts +12 -0
  8. package/dist/decode-genesis-cbor-map.d.ts.map +1 -0
  9. package/dist/decode-genesis-cbor-map.js +26 -0
  10. package/dist/decode-trust-root-from-genesis.d.ts +21 -3
  11. package/dist/decode-trust-root-from-genesis.d.ts.map +1 -1
  12. package/dist/decode-trust-root-from-genesis.js +32 -23
  13. package/dist/decoded-trust-root.d.ts +17 -0
  14. package/dist/decoded-trust-root.d.ts.map +1 -0
  15. package/dist/decoded-trust-root.js +1 -0
  16. package/dist/forest-genesis-labels.d.ts +7 -0
  17. package/dist/forest-genesis-labels.d.ts.map +1 -1
  18. package/dist/forest-genesis-labels.js +7 -0
  19. package/dist/index.d.ts +35 -7
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +31 -6
  22. package/dist/resolve-delegated-verify-key.d.ts +6 -0
  23. package/dist/resolve-delegated-verify-key.d.ts.map +1 -1
  24. package/dist/resolve-delegated-verify-key.js +10 -3
  25. package/dist/session-key-endorsement.d.ts +88 -30
  26. package/dist/session-key-endorsement.d.ts.map +1 -1
  27. package/dist/session-key-endorsement.js +151 -40
  28. package/dist/verify-endorsed-leaf.d.ts +102 -0
  29. package/dist/verify-endorsed-leaf.d.ts.map +1 -0
  30. package/dist/verify-endorsed-leaf.js +189 -0
  31. package/package.json +4 -4
  32. package/src/chain-binding.ts +18 -0
  33. package/src/decode-chain-binding-from-genesis.ts +77 -0
  34. package/src/decode-genesis-cbor-map.ts +27 -0
  35. package/src/decode-trust-root-from-genesis.ts +40 -25
  36. package/src/decoded-trust-root.ts +17 -0
  37. package/src/forest-genesis-labels.ts +7 -0
  38. package/src/index.ts +61 -4
  39. package/src/resolve-delegated-verify-key.ts +12 -3
  40. package/src/session-key-endorsement.ts +218 -54
  41. package/src/verify-endorsed-leaf.ts +287 -0
@@ -1,27 +1,38 @@
1
1
  /**
2
- * Passkey session-key endorsement (devdocs ADR-0064, plan-2608-13 Q5).
2
+ * Passkey session-key endorsement, payload v2 (devdocs ADR-0064 as amended by
3
+ * ADR-0065 §3; plan-2608-14 1.2).
3
4
  *
4
5
  * A passkey log root signs ceremonies only; the per-turn user-envelope
5
- * signer is a separate session key, endorsed ONCE by the root via a COSE
6
- * Sign1 in the ADR-0063 WebAuthn envelope. This module owns both sides of
7
- * that artifact's byte-exact shape:
6
+ * signer is a separate session key, endorsed by the root via a COSE Sign1 in
7
+ * the ADR-0063 WebAuthn envelope. This module owns both sides of that
8
+ * artifact's byte-exact shape:
8
9
  *
9
10
  * - build/assemble: the client constructs the TBS, hashes its Sig_structure
10
11
  * into the WebAuthn challenge, and attaches the assertion + signature;
11
12
  * - verify: given the passkey root as trust anchor, validate the artifact
12
- * (typed, challenge-bound, fail-closed) and yield the session public key.
13
+ * (typed, challenge-bound, fail-closed) and yield the session public key
14
+ * and its validity window.
13
15
  *
14
- * The offline trust chain is then:
15
- * grant (root = passkey x‖y) → endorsement → session key → plain-ES256
16
- * per-turn leaves — zero per-leaf format change (ADR-0064 §4).
16
+ * The endorsement rides INSIDE every endorsed leaf (unprotected label
17
+ * `COSE_LABEL_SESSION_KEY_ENDORSEMENT`, -65801) and the chain
18
+ * root → endorsement → session key → leaf → receipt is walked by
19
+ * {@link verifyEndorsedLeaf} (the single offline rung, ADR-0065 §5) and by
20
+ * canopy SCRAPI admission (§4).
17
21
  *
18
- * Domain separation (ADR-0064 §2): the protected header carries the content
19
- * type below, and the payload is EXACTLY `{"sessionKey": bstr .size 64}`.
20
- * Anything else is a verification failure, never a fallback.
22
+ * Domain separation: the protected content type below names payload v2 and
23
+ * the payload is EXACTLY `{"sessionKey": bstr .size 64, "notBefore": uint,
24
+ * "notAfter": uint}` (unix milliseconds, the idtimestamp time domain).
25
+ * Anything else — including a v1 (window-less) artifact — is a verification
26
+ * failure, never a fallback (ADR-0065 §3: v1 is rejected, not grandfathered).
27
+ *
28
+ * The window itself is NOT checked here: the reference time differs per
29
+ * verifier (canopy's clock at admission; the receipted idtimestamp offline).
30
+ * Callers apply {@link checkEndorsementWindow} with the time they hold.
21
31
  */
22
32
 
23
33
  import {
24
34
  COSE_ALG_ES256_WEBAUTHN,
35
+ coseUnprotectedToMap,
25
36
  decodeCborDeterministic,
26
37
  decodeCoseSign1,
27
38
  encodeCborDeterministic,
@@ -34,20 +45,45 @@ import {
34
45
  import { importEs256PublicKeyFromGrantDataXy64 } from "./decode-trust-root-cbor.js";
35
46
 
36
47
  /**
37
- * Protected-header content type (label 3) naming the endorsement artifact.
38
- * Signed via Sig_structure, so the artifact's type is non-malleable.
48
+ * Protected-header content type (label 3) naming the v2 endorsement artifact.
49
+ * Signed via Sig_structure, so both the artifact's type AND its payload
50
+ * version are non-malleable.
39
51
  */
40
52
  export const SESSION_KEY_ENDORSEMENT_CONTENT_TYPE =
53
+ "application/vnd.forestrie.session-key-endorsement.v2+cbor";
54
+
55
+ /**
56
+ * The retired ADR-0064 v1 content type (payload without a window). Exported
57
+ * only so verifiers and tests can name what is being REJECTED; no code path
58
+ * accepts it.
59
+ */
60
+ export const SESSION_KEY_ENDORSEMENT_V1_CONTENT_TYPE =
41
61
  "application/vnd.forestrie.session-key-endorsement+cbor";
42
62
 
43
63
  /** Payload map key holding the endorsed session public key (x‖y, 64 bytes). */
44
64
  export const SESSION_KEY_PAYLOAD_KEY = "sessionKey";
65
+ /** Payload map key: window start, unix milliseconds, inclusive. */
66
+ export const NOT_BEFORE_PAYLOAD_KEY = "notBefore";
67
+ /** Payload map key: window end, unix milliseconds, inclusive. */
68
+ export const NOT_AFTER_PAYLOAD_KEY = "notAfter";
69
+
70
+ /** Default client window length: 7 days (ADR-0065 §3). */
71
+ export const DEFAULT_ENDORSEMENT_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
72
+
73
+ /** WebAuthn authenticatorData UV flag bit (WebAuthn L2 §6.1). */
74
+ const AUTH_FLAG_UV = 0x04;
75
+
76
+ /** Validity window of an endorsement, unix milliseconds, both ends inclusive. */
77
+ export interface EndorsementWindow {
78
+ notBefore: number;
79
+ notAfter: number;
80
+ }
45
81
 
46
82
  /** To-be-signed endorsement halves plus the challenge preimage. */
47
83
  export interface SessionKeyEndorsementTbs {
48
- /** Protected header map bytes `{1: -65800, 3: cty, 4: root x}`. */
84
+ /** Protected header map bytes `{1: -65800, 3: cty(v2), 4: root x}`. */
49
85
  protectedBstr: Uint8Array;
50
- /** Payload bytes: deterministic CBOR `{"sessionKey": x‖y}`. */
86
+ /** Payload bytes: deterministic CBOR `{sessionKey, notBefore, notAfter}`. */
51
87
  payloadBstr: Uint8Array;
52
88
  /**
53
89
  * `Sig_structure` over the halves. The WebAuthn challenge MUST be
@@ -56,16 +92,38 @@ export interface SessionKeyEndorsementTbs {
56
92
  sigStructureBytes: Uint8Array;
57
93
  }
58
94
 
95
+ function isUnixMs(v: unknown): v is number {
96
+ return typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
97
+ }
98
+
99
+ /**
100
+ * Structural window check shared by the builder and the verifier: both ends
101
+ * are unsigned safe integers and the window is non-empty (ADR-0065 §3
102
+ * "malformed windows are rejected": `notAfter ≤ notBefore`).
103
+ */
104
+ function windowWellFormed(w: {
105
+ notBefore: unknown;
106
+ notAfter: unknown;
107
+ }): w is EndorsementWindow {
108
+ return (
109
+ isUnixMs(w.notBefore) && isUnixMs(w.notAfter) && w.notAfter > w.notBefore
110
+ );
111
+ }
112
+
59
113
  /**
60
- * Build the endorsement TBS. The caller hashes `sigStructureBytes` into the
61
- * `navigator.credentials.get` challenge, then assembles with the assertion.
114
+ * Build the v2 endorsement TBS. The caller hashes `sigStructureBytes` into
115
+ * the `navigator.credentials.get` challenge, then assembles with the assertion.
62
116
  *
63
117
  * @param input.rootPublicKeyX - Passkey root x coordinate (kid, 32 bytes)
64
118
  * @param input.sessionPublicKeyXY - Endorsed session public key x‖y (64 bytes)
119
+ * @param input.notBefore - Window start, unix ms inclusive
120
+ * @param input.notAfter - Window end, unix ms inclusive; must exceed notBefore
65
121
  */
66
122
  export function buildSessionKeyEndorsementTbs(input: {
67
123
  rootPublicKeyX: Uint8Array;
68
124
  sessionPublicKeyXY: Uint8Array;
125
+ notBefore: number;
126
+ notAfter: number;
69
127
  }): SessionKeyEndorsementTbs {
70
128
  if (input.rootPublicKeyX.length !== 32) {
71
129
  throw new Error("endorsement kid must be the 32-byte root x coordinate");
@@ -73,6 +131,11 @@ export function buildSessionKeyEndorsementTbs(input: {
73
131
  if (input.sessionPublicKeyXY.length !== 64) {
74
132
  throw new Error("endorsed session key must be 64 bytes (x||y)");
75
133
  }
134
+ if (!windowWellFormed(input)) {
135
+ throw new Error(
136
+ "endorsement window must be unsigned safe-integer unix ms with notAfter > notBefore",
137
+ );
138
+ }
76
139
  const protectedBstr = encodeCborDeterministic(
77
140
  new Map<number, unknown>([
78
141
  [1, COSE_ALG_ES256_WEBAUTHN],
@@ -80,9 +143,13 @@ export function buildSessionKeyEndorsementTbs(input: {
80
143
  [4, input.rootPublicKeyX],
81
144
  ]),
82
145
  );
146
+ // Deterministic CBOR sorts the keys (RFC 8949 §4.2.1); insertion order here
147
+ // is irrelevant to the bytes.
83
148
  const payloadBstr = encodeCborDeterministic(
84
149
  new Map<string, unknown>([
85
150
  [SESSION_KEY_PAYLOAD_KEY, input.sessionPublicKeyXY],
151
+ [NOT_BEFORE_PAYLOAD_KEY, input.notBefore],
152
+ [NOT_AFTER_PAYLOAD_KEY, input.notAfter],
86
153
  ]),
87
154
  );
88
155
  const sigStructureBytes = encodeSigStructure(
@@ -120,34 +187,37 @@ export function assembleSessionKeyEndorsement(input: {
120
187
  );
121
188
  }
122
189
 
190
+ /** Verification failure reasons; the first check that broke. */
191
+ export type SessionKeyEndorsementFailureReason =
192
+ | "endorsement_malformed"
193
+ | "wrong_alg"
194
+ | "wrong_content_type"
195
+ | "kid_invalid"
196
+ | "kid_mismatch"
197
+ | "payload_invalid"
198
+ | "window_invalid"
199
+ | "uv_required"
200
+ | "signature_invalid"
201
+ | "session_key_import_failed";
202
+
123
203
  /** Verification outcome; failures name the first check that broke. */
124
204
  export type SessionKeyEndorsementVerifyResult =
125
- | {
205
+ | ({
126
206
  ok: true;
127
207
  /** Endorsed session public key, raw x‖y (64 bytes). */
128
208
  sessionPublicKeyXY: Uint8Array;
129
209
  /** The same key imported for ES256 verify of per-turn leaves. */
130
210
  sessionKey: CryptoKey;
131
- }
132
- | {
133
- ok: false;
134
- reason:
135
- | "endorsement_malformed"
136
- | "wrong_alg"
137
- | "wrong_content_type"
138
- | "kid_invalid"
139
- | "kid_mismatch"
140
- | "payload_invalid"
141
- | "signature_invalid"
142
- | "session_key_import_failed";
143
- };
211
+ } & EndorsementWindow)
212
+ | { ok: false; reason: SessionKeyEndorsementFailureReason };
144
213
 
145
214
  /** Options for {@link verifySessionKeyEndorsement}. */
146
215
  export interface VerifySessionKeyEndorsementOptions {
147
216
  /**
148
- * Require the assertion's UV flag. Onboarding UV is DEPLOYMENT config,
149
- * not grant policy — the endorsement precedes the grant (ADR-0064 §3).
150
- * User presence is always required regardless.
217
+ * Require the assertion's UV flag. At admission this is the grant's
218
+ * `GF_REQUIRES_USER_VERIFICATION` (ADR-0065 §4); at DO onboarding, where
219
+ * no grant exists yet, deployment config (ADR-0064 §3). User presence is
220
+ * always required regardless.
151
221
  */
152
222
  requireUserVerification?: boolean;
153
223
  /** Emit JSON warning lines on failure paths (no secrets). */
@@ -179,41 +249,89 @@ function protectedHeaderEntries(
179
249
  return null;
180
250
  }
181
251
 
182
- function decodePayloadSessionKeyXy(payloadBstr: Uint8Array): Uint8Array | null {
252
+ type DecodedPayload =
253
+ | { kind: "ok"; sessionPublicKeyXY: Uint8Array; window: EndorsementWindow }
254
+ | { kind: "payload_invalid" }
255
+ | { kind: "window_invalid" };
256
+
257
+ function toNumberIfInt(v: unknown): unknown {
258
+ // Deterministic decode may surface large ints as bigint; the window is
259
+ // unix ms (< 2^53) so a safe bigint is folded back to a number.
260
+ if (
261
+ typeof v === "bigint" &&
262
+ v >= 0n &&
263
+ v <= BigInt(Number.MAX_SAFE_INTEGER)
264
+ ) {
265
+ return Number(v);
266
+ }
267
+ return v;
268
+ }
269
+
270
+ function decodePayloadV2(payloadBstr: Uint8Array): DecodedPayload {
183
271
  let decoded: unknown;
184
272
  try {
185
273
  decoded = decodeCborDeterministic(payloadBstr);
186
274
  } catch {
187
- return null;
275
+ return { kind: "payload_invalid" };
188
276
  }
189
- // Exactly one entry, exactly the sessionKey label, exactly 64 bytes
190
- // (ADR-0064 §2) — anything else is a failure, never a partial read.
277
+ // Exactly three entries, exactly the three labels, exactly these shapes
278
+ // (ADR-0065 §3) — anything else is a failure, never a partial read.
191
279
  let entries: [unknown, unknown][];
192
280
  if (decoded instanceof Map) {
193
281
  entries = [...decoded.entries()];
194
282
  } else if (typeof decoded === "object" && decoded !== null) {
195
283
  entries = Object.entries(decoded);
196
284
  } else {
285
+ return { kind: "payload_invalid" };
286
+ }
287
+ if (entries.length !== 3) return { kind: "payload_invalid" };
288
+ const byKey = new Map<unknown, unknown>(entries);
289
+ const sessionPublicKeyXY = byKey.get(SESSION_KEY_PAYLOAD_KEY);
290
+ if (
291
+ !(sessionPublicKeyXY instanceof Uint8Array) ||
292
+ sessionPublicKeyXY.length !== 64
293
+ ) {
294
+ return { kind: "payload_invalid" };
295
+ }
296
+ const notBefore = toNumberIfInt(byKey.get(NOT_BEFORE_PAYLOAD_KEY));
297
+ const notAfter = toNumberIfInt(byKey.get(NOT_AFTER_PAYLOAD_KEY));
298
+ if (!isUnixMs(notBefore) || !isUnixMs(notAfter)) {
299
+ return { kind: "payload_invalid" };
300
+ }
301
+ const window = { notBefore, notAfter };
302
+ if (!windowWellFormed(window)) return { kind: "window_invalid" };
303
+ return { kind: "ok", sessionPublicKeyXY, window };
304
+ }
305
+
306
+ /** Read the assertion's flags byte from the -65800 envelope, if well-shaped. */
307
+ function envelopeFlags(unprotected: unknown): number | null {
308
+ const envelope = coseUnprotectedToMap(unprotected).get(
309
+ WEBAUTHN_ENVELOPE_LABEL,
310
+ );
311
+ if (!Array.isArray(envelope) || envelope.length !== 2) return null;
312
+ const authenticatorData = envelope[0];
313
+ if (
314
+ !(authenticatorData instanceof Uint8Array) ||
315
+ authenticatorData.length < 37
316
+ ) {
197
317
  return null;
198
318
  }
199
- if (entries.length !== 1) return null;
200
- const [key, value] = entries[0]!;
201
- if (key !== SESSION_KEY_PAYLOAD_KEY) return null;
202
- if (!(value instanceof Uint8Array) || value.length !== 64) return null;
203
- return value;
319
+ return authenticatorData[32]!;
204
320
  }
205
321
 
206
322
  /**
207
- * Verify a session-key endorsement under the passkey root and yield the
208
- * endorsed session key. All the ADR-0063 envelope checks (challenge binding
209
- * to this artifact's `Sig_structure`, UP flag, ceremony type, low-s, both
210
- * fail-closed directions) run inside the shared `-65800` verify branch;
211
- * this function adds the ADR-0064 typing rules on top.
323
+ * Verify a v2 session-key endorsement under the passkey root and yield the
324
+ * endorsed session key plus its window. All the ADR-0063 envelope checks
325
+ * (challenge binding to this artifact's `Sig_structure`, UP flag, ceremony
326
+ * type, low-s, both fail-closed directions) run inside the shared `-65800`
327
+ * verify branch; this function adds the ADR-0064/0065 typing rules on top
328
+ * and reports a missing-UV rejection under its own reason so admission can
329
+ * name it (`endorsement_uv_required`, ADR-0065 §4).
212
330
  *
213
331
  * @param endorsementCbor - The endorsement COSE Sign1 bytes
214
332
  * @param rootKeys - Passkey root trust anchor(s), tried in order. Raw
215
333
  * coordinate anchors additionally pin `kid == root x`.
216
- * @param opts - UV requirement (deployment config) and failure logging
334
+ * @param opts - UV requirement and failure logging
217
335
  */
218
336
  export async function verifySessionKeyEndorsement(
219
337
  endorsementCbor: Uint8Array,
@@ -239,8 +357,17 @@ export async function verifySessionKeyEndorsement(
239
357
  return { ok: false, reason: "kid_invalid" };
240
358
  }
241
359
 
242
- const sessionPublicKeyXY = decodePayloadSessionKeyXy(decoded.payloadBstr);
243
- if (!sessionPublicKeyXY) return { ok: false, reason: "payload_invalid" };
360
+ const payload = decodePayloadV2(decoded.payloadBstr);
361
+ if (payload.kind !== "ok") return { ok: false, reason: payload.kind };
362
+
363
+ if (opts?.requireUserVerification) {
364
+ const flags = envelopeFlags(decoded.unprotected);
365
+ // A malformed envelope is left to the shared branch to reject; only a
366
+ // well-shaped assertion that plainly lacks UV gets the distinct reason.
367
+ if (flags !== null && (flags & AUTH_FLAG_UV) === 0) {
368
+ return { ok: false, reason: "uv_required" };
369
+ }
370
+ }
244
371
 
245
372
  let kidMismatch = false;
246
373
  for (const anchor of anchors) {
@@ -266,12 +393,18 @@ export async function verifySessionKeyEndorsement(
266
393
 
267
394
  let sessionKey: CryptoKey;
268
395
  try {
269
- sessionKey =
270
- await importEs256PublicKeyFromGrantDataXy64(sessionPublicKeyXY);
396
+ sessionKey = await importEs256PublicKeyFromGrantDataXy64(
397
+ payload.sessionPublicKeyXY,
398
+ );
271
399
  } catch {
272
400
  return { ok: false, reason: "session_key_import_failed" };
273
401
  }
274
- return { ok: true, sessionPublicKeyXY, sessionKey };
402
+ return {
403
+ ok: true,
404
+ sessionPublicKeyXY: payload.sessionPublicKeyXY,
405
+ sessionKey,
406
+ ...payload.window,
407
+ };
275
408
  }
276
409
 
277
410
  return {
@@ -279,3 +412,34 @@ export async function verifySessionKeyEndorsement(
279
412
  reason: kidMismatch ? "kid_mismatch" : "signature_invalid",
280
413
  };
281
414
  }
415
+
416
+ /** Outcome of {@link checkEndorsementWindow}. */
417
+ export type EndorsementWindowCheck =
418
+ | { ok: true }
419
+ | { ok: false; reason: "endorsement_not_yet_valid" | "endorsement_expired" };
420
+
421
+ /**
422
+ * Is `atMs` inside the endorsement's window? `notBefore ≤ atMs ≤ notAfter`,
423
+ * both inclusive (ADR-0065 §3). `skewMs` (default 0) tolerates a `notBefore`
424
+ * slightly in the future of a wall-clock verifier; offline verifiers
425
+ * comparing against the receipted idtimestamp should leave it at 0 — that
426
+ * comparison is exact and authoritative.
427
+ *
428
+ * Deliberately a pure function of the two numbers so callers can (and must)
429
+ * run it on every request, structurally outside any verified-endorsement
430
+ * cache (ADR-0065 §4).
431
+ */
432
+ export function checkEndorsementWindow(
433
+ window: EndorsementWindow,
434
+ atMs: number,
435
+ opts?: { skewMs?: number },
436
+ ): EndorsementWindowCheck {
437
+ const skew = opts?.skewMs ?? 0;
438
+ if (atMs + skew < window.notBefore) {
439
+ return { ok: false, reason: "endorsement_not_yet_valid" };
440
+ }
441
+ if (atMs > window.notAfter) {
442
+ return { ok: false, reason: "endorsement_expired" };
443
+ }
444
+ return { ok: true };
445
+ }
@@ -0,0 +1,287 @@
1
+ /**
2
+ * The single offline rung for a passkey-rooted log (devdocs ADR-0065 §5,
3
+ * plan-2608-14 1.2). From public artifacts only — the log root (on-chain
4
+ * `logRootKey` == the grant's `grantData`), the exact registered leaf bytes,
5
+ * the receipt and its idtimestamp — reconstruct:
6
+ *
7
+ * root → endorsement (-65801 inside the leaf; -65800 verify under the
8
+ * root, UV per the grant flag, window from the payload)
9
+ * → session key
10
+ * → leaf: kid == session x, ES256 signature under the session key
11
+ * → receipt: leaf bytes hash to the receipted index (inclusion),
12
+ * receipted idtimestamp ∈ [notBefore, notAfter]
13
+ *
14
+ * There is exactly one route and no fallback: a leaf without an endorsement
15
+ * is not verified under the root here (that is the plain
16
+ * `verifyReceiptOffline*` path for root-signed logs), and the ADR-0064
17
+ * export-fed rung (`resolveEndorsedSessionKey` over a `/receipts` export)
18
+ * is gone — the endorsement an auditor needs is inside the committed leaf.
19
+ *
20
+ * Tampering closes both ways (ADR-0065 §5): editing the endorsement changes
21
+ * `contentHash`, so inclusion fails; substituting a different valid
22
+ * endorsement changes the session key, so the leaf signature fails.
23
+ */
24
+
25
+ import {
26
+ COSE_LABEL_SESSION_KEY_ENDORSEMENT,
27
+ coseUnprotectedToMap,
28
+ decodeCborDeterministic,
29
+ decodeCoseSign1,
30
+ verifyCoseSign1WithParsedKey,
31
+ } from "@forestrie/encoding";
32
+ import { importEs256PublicKeyFromGrantDataXy64 } from "./decode-trust-root-cbor.js";
33
+ import { idtimestampToUnixMs } from "./resolve-delegated-verify-key.js";
34
+ import {
35
+ checkEndorsementWindow,
36
+ verifySessionKeyEndorsement,
37
+ type SessionKeyEndorsementFailureReason,
38
+ } from "./session-key-endorsement.js";
39
+ import { verifyReceiptOfflineWithKeys } from "./verify-grant-receipt-offline.js";
40
+
41
+ /** COSE header label for key id (kid). */
42
+ const COSE_KID = 4;
43
+
44
+ export interface VerifyEndorsedLeafInput {
45
+ /**
46
+ * The log root as raw P-256 x‖y (64 bytes): the grant's `grantData`, the
47
+ * value univocity binds as `logRootKey`. The endorsement's trust anchor;
48
+ * its kid is pinned to this x.
49
+ */
50
+ rootPublicKeyXY: Uint8Array;
51
+ /** The EXACT registered leaf bytes (COSE Sign1 carrying -65801). */
52
+ statementCbor: Uint8Array;
53
+ /** The receipt over that leaf. */
54
+ receiptCbor: Uint8Array;
55
+ /** The receipted idtimestamp (8 bytes, big-endian). */
56
+ idtimestampBe8: Uint8Array;
57
+ /**
58
+ * Receipt trust anchors (the sealer or, for a delegated seal, the
59
+ * delegation-cert issuer — for a user log that is the root itself, which
60
+ * signs the sealing delegation with a passkey gesture). Defaults to the
61
+ * root. See `verifyReceiptOfflineWithKeys` for the trust model.
62
+ */
63
+ trustKeys?: CryptoKey[];
64
+ }
65
+
66
+ export interface VerifyEndorsedLeafOptions {
67
+ /**
68
+ * The grant's `GF_REQUIRES_USER_VERIFICATION` (ADR-0063 §4): when set the
69
+ * endorsement's assertion must carry UV. User presence is always required.
70
+ */
71
+ requireUserVerification?: boolean;
72
+ /** Emit JSON warning lines on failure paths (no secrets). */
73
+ logFailures?: boolean;
74
+ logPrefix?: string;
75
+ }
76
+
77
+ export type VerifyEndorsedLeafStage =
78
+ | "endorsement"
79
+ | "leaf"
80
+ | "window"
81
+ | "receipt";
82
+
83
+ export type VerifyEndorsedLeafResult =
84
+ | {
85
+ ok: true;
86
+ /** The endorsed session public key, raw x‖y (64 bytes). */
87
+ sessionPublicKeyXY: Uint8Array;
88
+ /** The endorsement window, unix ms inclusive. */
89
+ notBefore: number;
90
+ notAfter: number;
91
+ /** The receipted idtimestamp's time component, unix ms. */
92
+ leafIdtimestampMs: number;
93
+ }
94
+ | { ok: false; stage: VerifyEndorsedLeafStage; reason: string };
95
+
96
+ /** ADR-0065 §4 reason vocabulary, shared with canopy admission. */
97
+ export type EndorsementAdmissionReason =
98
+ | "endorsement_missing"
99
+ | "endorsement_invalid"
100
+ | "endorsement_root_mismatch"
101
+ | "endorsement_uv_required"
102
+ | "endorsement_expired"
103
+ | "endorsement_not_yet_valid";
104
+
105
+ /**
106
+ * Fold the endorsement verifier's fine-grained reasons into the ADR-0065 §4
107
+ * admission vocabulary. `signature_invalid` under a coordinate anchor means
108
+ * "a well-formed endorsement that does not chain to THIS root" — the
109
+ * `endorsement_root_mismatch` case; a malformed window is
110
+ * `endorsement_expired` (§4: "window, both directions, and malformed
111
+ * windows" — no instant is inside it); everything else structural is
112
+ * `endorsement_invalid`.
113
+ */
114
+ export function endorsementAdmissionReason(
115
+ reason: SessionKeyEndorsementFailureReason,
116
+ ): EndorsementAdmissionReason {
117
+ switch (reason) {
118
+ case "kid_mismatch":
119
+ case "signature_invalid":
120
+ return "endorsement_root_mismatch";
121
+ case "uv_required":
122
+ return "endorsement_uv_required";
123
+ case "window_invalid":
124
+ return "endorsement_expired";
125
+ default:
126
+ return "endorsement_invalid";
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Read the endorsement bytes from a leaf's unprotected header.
132
+ * `missing` when there is no -65801 entry; `invalid` when the entry is not
133
+ * a byte string (present-but-unusable is never "absent").
134
+ */
135
+ export function extractLeafEndorsement(
136
+ statementCbor: Uint8Array,
137
+ ):
138
+ | { kind: "ok"; endorsement: Uint8Array; kid: Uint8Array | null }
139
+ | { kind: "missing" }
140
+ | { kind: "invalid" } {
141
+ const decoded = decodeCoseSign1(statementCbor);
142
+ if (!decoded) return { kind: "invalid" };
143
+ const entry = coseUnprotectedToMap(decoded.unprotected).get(
144
+ COSE_LABEL_SESSION_KEY_ENDORSEMENT,
145
+ );
146
+ if (entry === undefined) return { kind: "missing" };
147
+ if (!(entry instanceof Uint8Array) || entry.length === 0) {
148
+ return { kind: "invalid" };
149
+ }
150
+ let kid: Uint8Array | null = null;
151
+ try {
152
+ const protectedMap = decodeCborDeterministic(decoded.protectedBstr);
153
+ const raw =
154
+ protectedMap instanceof Map
155
+ ? protectedMap.get(COSE_KID)
156
+ : (protectedMap as Record<number, unknown>)?.[COSE_KID];
157
+ if (raw instanceof Uint8Array) kid = raw;
158
+ } catch {
159
+ kid = null;
160
+ }
161
+ return { kind: "ok", endorsement: entry, kid };
162
+ }
163
+
164
+ function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
165
+ if (a.length !== b.length) return false;
166
+ let d = 0;
167
+ for (let i = 0; i < a.length; i++) d |= a[i]! ^ b[i]!;
168
+ return d === 0;
169
+ }
170
+
171
+ function readIdtimestampBe8(bytes: Uint8Array): bigint {
172
+ if (!bytes || bytes.length < 8) {
173
+ throw new Error("idtimestamp required (8 bytes)");
174
+ }
175
+ const view =
176
+ bytes.length === 8
177
+ ? new DataView(bytes.buffer, bytes.byteOffset, 8)
178
+ : new DataView(bytes.buffer, bytes.byteOffset + bytes.length - 8, 8);
179
+ return view.getBigUint64(0, false);
180
+ }
181
+
182
+ /**
183
+ * Verify an endorsed leaf and its receipt from public artifacts only.
184
+ * Pure over bytes; no network (rules-of-the-road C1).
185
+ */
186
+ export async function verifyEndorsedLeaf(
187
+ input: VerifyEndorsedLeafInput,
188
+ opts?: VerifyEndorsedLeafOptions,
189
+ ): Promise<VerifyEndorsedLeafResult> {
190
+ if (input.rootPublicKeyXY.length !== 64) {
191
+ return { ok: false, stage: "endorsement", reason: "root_invalid" };
192
+ }
193
+ const logPrefix = opts?.logPrefix ?? "verify-endorsed-leaf";
194
+
195
+ // 1. The endorsement, from the leaf itself.
196
+ const extracted = extractLeafEndorsement(input.statementCbor);
197
+ if (extracted.kind === "missing") {
198
+ return { ok: false, stage: "endorsement", reason: "endorsement_missing" };
199
+ }
200
+ if (extracted.kind === "invalid") {
201
+ return { ok: false, stage: "endorsement", reason: "endorsement_invalid" };
202
+ }
203
+
204
+ // 2. Under the root (kid pinned to root x by the coordinate anchor).
205
+ const endorsed = await verifySessionKeyEndorsement(
206
+ extracted.endorsement,
207
+ {
208
+ x: input.rootPublicKeyXY.subarray(0, 32),
209
+ y: input.rootPublicKeyXY.subarray(32, 64),
210
+ curve: "P-256",
211
+ },
212
+ {
213
+ requireUserVerification: opts?.requireUserVerification,
214
+ logFailures: opts?.logFailures,
215
+ logPrefix,
216
+ },
217
+ );
218
+ if (!endorsed.ok) {
219
+ return {
220
+ ok: false,
221
+ stage: "endorsement",
222
+ reason: endorsementAdmissionReason(endorsed.reason),
223
+ };
224
+ }
225
+
226
+ // 3. The leaf: kid == session x, signature under the session key. The
227
+ // shared verify branch rejects a -65800 entry on a plain-ES256 leaf.
228
+ const sessionX = endorsed.sessionPublicKeyXY.subarray(0, 32);
229
+ if (!extracted.kid || !bytesEqual(extracted.kid, sessionX)) {
230
+ return { ok: false, stage: "leaf", reason: "signer_mismatch" };
231
+ }
232
+ const leafOk = await verifyCoseSign1WithParsedKey(
233
+ input.statementCbor,
234
+ endorsed.sessionKey,
235
+ { logFailures: opts?.logFailures, logPrefix: `${logPrefix}:leaf` },
236
+ );
237
+ if (!leafOk) {
238
+ return { ok: false, stage: "leaf", reason: "leaf_signature_invalid" };
239
+ }
240
+
241
+ // 4. The window, against the receipted idtimestamp (never wall-clock —
242
+ // a valid receipt verifies forever). Exact: no skew.
243
+ let idtimestamp: bigint;
244
+ try {
245
+ idtimestamp = readIdtimestampBe8(input.idtimestampBe8);
246
+ } catch {
247
+ return { ok: false, stage: "window", reason: "idtimestamp_invalid" };
248
+ }
249
+ const leafIdtimestampMs = idtimestampToUnixMs(idtimestamp);
250
+ const window = checkEndorsementWindow(endorsed, leafIdtimestampMs);
251
+ if (!window.ok) {
252
+ return { ok: false, stage: "window", reason: window.reason };
253
+ }
254
+
255
+ // 5. Inclusion of the EXACT leaf bytes (the endorsement is inside them).
256
+ let trustKeys = input.trustKeys;
257
+ if (!trustKeys) {
258
+ try {
259
+ trustKeys = [
260
+ await importEs256PublicKeyFromGrantDataXy64(input.rootPublicKeyXY),
261
+ ];
262
+ } catch {
263
+ return { ok: false, stage: "receipt", reason: "root_invalid" };
264
+ }
265
+ }
266
+ const receipt = await verifyReceiptOfflineWithKeys({
267
+ receiptCbor: input.receiptCbor,
268
+ payload: input.statementCbor,
269
+ idtimestampBe8: input.idtimestampBe8,
270
+ trustKeys,
271
+ });
272
+ if (!receipt.ok) {
273
+ return {
274
+ ok: false,
275
+ stage: "receipt",
276
+ reason: receipt.reason ?? "receipt_invalid",
277
+ };
278
+ }
279
+
280
+ return {
281
+ ok: true,
282
+ sessionPublicKeyXY: endorsed.sessionPublicKeyXY,
283
+ notBefore: endorsed.notBefore,
284
+ notAfter: endorsed.notAfter,
285
+ leafIdtimestampMs,
286
+ };
287
+ }