@forestrie/receipt-verify 0.11.0 → 1.0.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.
@@ -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
+ }