@forestrie/receipt-verify 0.12.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.
- package/dist/index.d.ts +14 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -5
- package/dist/resolve-delegated-verify-key.d.ts +6 -0
- package/dist/resolve-delegated-verify-key.d.ts.map +1 -1
- package/dist/resolve-delegated-verify-key.js +10 -3
- package/dist/session-key-endorsement.d.ts +88 -30
- package/dist/session-key-endorsement.d.ts.map +1 -1
- package/dist/session-key-endorsement.js +151 -40
- package/dist/verify-endorsed-leaf.d.ts +102 -0
- package/dist/verify-endorsed-leaf.d.ts.map +1 -0
- package/dist/verify-endorsed-leaf.js +189 -0
- package/package.json +2 -2
- package/src/index.ts +30 -3
- package/src/resolve-delegated-verify-key.ts +12 -3
- package/src/session-key-endorsement.ts +218 -54
- package/src/verify-endorsed-leaf.ts +287 -0
|
@@ -1,27 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Passkey session-key endorsement (devdocs ADR-0064
|
|
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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
|
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 `{
|
|
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
|
|
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.
|
|
149
|
-
*
|
|
150
|
-
*
|
|
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
|
-
|
|
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
|
|
275
|
+
return { kind: "payload_invalid" };
|
|
188
276
|
}
|
|
189
|
-
// Exactly
|
|
190
|
-
// (ADR-
|
|
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
|
-
|
|
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
|
|
209
|
-
* to this artifact's `Sig_structure`, UP flag, ceremony
|
|
210
|
-
* fail-closed directions) run inside the shared `-65800`
|
|
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
|
|
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
|
|
243
|
-
if (
|
|
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
|
-
|
|
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 {
|
|
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
|
+
}
|