@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,102 @@
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
+ import { type SessionKeyEndorsementFailureReason } from "./session-key-endorsement.js";
25
+ export interface VerifyEndorsedLeafInput {
26
+ /**
27
+ * The log root as raw P-256 x‖y (64 bytes): the grant's `grantData`, the
28
+ * value univocity binds as `logRootKey`. The endorsement's trust anchor;
29
+ * its kid is pinned to this x.
30
+ */
31
+ rootPublicKeyXY: Uint8Array;
32
+ /** The EXACT registered leaf bytes (COSE Sign1 carrying -65801). */
33
+ statementCbor: Uint8Array;
34
+ /** The receipt over that leaf. */
35
+ receiptCbor: Uint8Array;
36
+ /** The receipted idtimestamp (8 bytes, big-endian). */
37
+ idtimestampBe8: Uint8Array;
38
+ /**
39
+ * Receipt trust anchors (the sealer or, for a delegated seal, the
40
+ * delegation-cert issuer — for a user log that is the root itself, which
41
+ * signs the sealing delegation with a passkey gesture). Defaults to the
42
+ * root. See `verifyReceiptOfflineWithKeys` for the trust model.
43
+ */
44
+ trustKeys?: CryptoKey[];
45
+ }
46
+ export interface VerifyEndorsedLeafOptions {
47
+ /**
48
+ * The grant's `GF_REQUIRES_USER_VERIFICATION` (ADR-0063 §4): when set the
49
+ * endorsement's assertion must carry UV. User presence is always required.
50
+ */
51
+ requireUserVerification?: boolean;
52
+ /** Emit JSON warning lines on failure paths (no secrets). */
53
+ logFailures?: boolean;
54
+ logPrefix?: string;
55
+ }
56
+ export type VerifyEndorsedLeafStage = "endorsement" | "leaf" | "window" | "receipt";
57
+ export type VerifyEndorsedLeafResult = {
58
+ ok: true;
59
+ /** The endorsed session public key, raw x‖y (64 bytes). */
60
+ sessionPublicKeyXY: Uint8Array;
61
+ /** The endorsement window, unix ms inclusive. */
62
+ notBefore: number;
63
+ notAfter: number;
64
+ /** The receipted idtimestamp's time component, unix ms. */
65
+ leafIdtimestampMs: number;
66
+ } | {
67
+ ok: false;
68
+ stage: VerifyEndorsedLeafStage;
69
+ reason: string;
70
+ };
71
+ /** ADR-0065 §4 reason vocabulary, shared with canopy admission. */
72
+ export type EndorsementAdmissionReason = "endorsement_missing" | "endorsement_invalid" | "endorsement_root_mismatch" | "endorsement_uv_required" | "endorsement_expired" | "endorsement_not_yet_valid";
73
+ /**
74
+ * Fold the endorsement verifier's fine-grained reasons into the ADR-0065 §4
75
+ * admission vocabulary. `signature_invalid` under a coordinate anchor means
76
+ * "a well-formed endorsement that does not chain to THIS root" — the
77
+ * `endorsement_root_mismatch` case; a malformed window is
78
+ * `endorsement_expired` (§4: "window, both directions, and malformed
79
+ * windows" — no instant is inside it); everything else structural is
80
+ * `endorsement_invalid`.
81
+ */
82
+ export declare function endorsementAdmissionReason(reason: SessionKeyEndorsementFailureReason): EndorsementAdmissionReason;
83
+ /**
84
+ * Read the endorsement bytes from a leaf's unprotected header.
85
+ * `missing` when there is no -65801 entry; `invalid` when the entry is not
86
+ * a byte string (present-but-unusable is never "absent").
87
+ */
88
+ export declare function extractLeafEndorsement(statementCbor: Uint8Array): {
89
+ kind: "ok";
90
+ endorsement: Uint8Array;
91
+ kid: Uint8Array | null;
92
+ } | {
93
+ kind: "missing";
94
+ } | {
95
+ kind: "invalid";
96
+ };
97
+ /**
98
+ * Verify an endorsed leaf and its receipt from public artifacts only.
99
+ * Pure over bytes; no network (rules-of-the-road C1).
100
+ */
101
+ export declare function verifyEndorsedLeaf(input: VerifyEndorsedLeafInput, opts?: VerifyEndorsedLeafOptions): Promise<VerifyEndorsedLeafResult>;
102
+ //# sourceMappingURL=verify-endorsed-leaf.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"verify-endorsed-leaf.d.ts","sourceRoot":"","sources":["../src/verify-endorsed-leaf.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAWH,OAAO,EAGL,KAAK,kCAAkC,EACxC,MAAM,8BAA8B,CAAC;AAMtC,MAAM,WAAW,uBAAuB;IACtC;;;;OAIG;IACH,eAAe,EAAE,UAAU,CAAC;IAC5B,oEAAoE;IACpE,aAAa,EAAE,UAAU,CAAC;IAC1B,kCAAkC;IAClC,WAAW,EAAE,UAAU,CAAC;IACxB,uDAAuD;IACvD,cAAc,EAAE,UAAU,CAAC;IAC3B;;;;;OAKG;IACH,SAAS,CAAC,EAAE,SAAS,EAAE,CAAC;CACzB;AAED,MAAM,WAAW,yBAAyB;IACxC;;;OAGG;IACH,uBAAuB,CAAC,EAAE,OAAO,CAAC;IAClC,6DAA6D;IAC7D,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,MAAM,uBAAuB,GAC/B,aAAa,GACb,MAAM,GACN,QAAQ,GACR,SAAS,CAAC;AAEd,MAAM,MAAM,wBAAwB,GAChC;IACE,EAAE,EAAE,IAAI,CAAC;IACT,2DAA2D;IAC3D,kBAAkB,EAAE,UAAU,CAAC;IAC/B,iDAAiD;IACjD,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,2DAA2D;IAC3D,iBAAiB,EAAE,MAAM,CAAC;CAC3B,GACD;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,uBAAuB,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAElE,mEAAmE;AACnE,MAAM,MAAM,0BAA0B,GAClC,qBAAqB,GACrB,qBAAqB,GACrB,2BAA2B,GAC3B,yBAAyB,GACzB,qBAAqB,GACrB,2BAA2B,CAAC;AAEhC;;;;;;;;GAQG;AACH,wBAAgB,0BAA0B,CACxC,MAAM,EAAE,kCAAkC,GACzC,0BAA0B,CAY5B;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CACpC,aAAa,EAAE,UAAU,GAEvB;IAAE,IAAI,EAAE,IAAI,CAAC;IAAC,WAAW,EAAE,UAAU,CAAC;IAAC,GAAG,EAAE,UAAU,GAAG,IAAI,CAAA;CAAE,GAC/D;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE,GACnB;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE,CAsBtB;AAoBD;;;GAGG;AACH,wBAAsB,kBAAkB,CACtC,KAAK,EAAE,uBAAuB,EAC9B,IAAI,CAAC,EAAE,yBAAyB,GAC/B,OAAO,CAAC,wBAAwB,CAAC,CAkGnC"}
@@ -0,0 +1,189 @@
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
+ import { COSE_LABEL_SESSION_KEY_ENDORSEMENT, coseUnprotectedToMap, decodeCborDeterministic, decodeCoseSign1, verifyCoseSign1WithParsedKey, } from "@forestrie/encoding";
25
+ import { importEs256PublicKeyFromGrantDataXy64 } from "./decode-trust-root-cbor.js";
26
+ import { idtimestampToUnixMs } from "./resolve-delegated-verify-key.js";
27
+ import { checkEndorsementWindow, verifySessionKeyEndorsement, } from "./session-key-endorsement.js";
28
+ import { verifyReceiptOfflineWithKeys } from "./verify-grant-receipt-offline.js";
29
+ /** COSE header label for key id (kid). */
30
+ const COSE_KID = 4;
31
+ /**
32
+ * Fold the endorsement verifier's fine-grained reasons into the ADR-0065 §4
33
+ * admission vocabulary. `signature_invalid` under a coordinate anchor means
34
+ * "a well-formed endorsement that does not chain to THIS root" — the
35
+ * `endorsement_root_mismatch` case; a malformed window is
36
+ * `endorsement_expired` (§4: "window, both directions, and malformed
37
+ * windows" — no instant is inside it); everything else structural is
38
+ * `endorsement_invalid`.
39
+ */
40
+ export function endorsementAdmissionReason(reason) {
41
+ switch (reason) {
42
+ case "kid_mismatch":
43
+ case "signature_invalid":
44
+ return "endorsement_root_mismatch";
45
+ case "uv_required":
46
+ return "endorsement_uv_required";
47
+ case "window_invalid":
48
+ return "endorsement_expired";
49
+ default:
50
+ return "endorsement_invalid";
51
+ }
52
+ }
53
+ /**
54
+ * Read the endorsement bytes from a leaf's unprotected header.
55
+ * `missing` when there is no -65801 entry; `invalid` when the entry is not
56
+ * a byte string (present-but-unusable is never "absent").
57
+ */
58
+ export function extractLeafEndorsement(statementCbor) {
59
+ const decoded = decodeCoseSign1(statementCbor);
60
+ if (!decoded)
61
+ return { kind: "invalid" };
62
+ const entry = coseUnprotectedToMap(decoded.unprotected).get(COSE_LABEL_SESSION_KEY_ENDORSEMENT);
63
+ if (entry === undefined)
64
+ return { kind: "missing" };
65
+ if (!(entry instanceof Uint8Array) || entry.length === 0) {
66
+ return { kind: "invalid" };
67
+ }
68
+ let kid = null;
69
+ try {
70
+ const protectedMap = decodeCborDeterministic(decoded.protectedBstr);
71
+ const raw = protectedMap instanceof Map
72
+ ? protectedMap.get(COSE_KID)
73
+ : protectedMap?.[COSE_KID];
74
+ if (raw instanceof Uint8Array)
75
+ kid = raw;
76
+ }
77
+ catch {
78
+ kid = null;
79
+ }
80
+ return { kind: "ok", endorsement: entry, kid };
81
+ }
82
+ function bytesEqual(a, b) {
83
+ if (a.length !== b.length)
84
+ return false;
85
+ let d = 0;
86
+ for (let i = 0; i < a.length; i++)
87
+ d |= a[i] ^ b[i];
88
+ return d === 0;
89
+ }
90
+ function readIdtimestampBe8(bytes) {
91
+ if (!bytes || bytes.length < 8) {
92
+ throw new Error("idtimestamp required (8 bytes)");
93
+ }
94
+ const view = bytes.length === 8
95
+ ? new DataView(bytes.buffer, bytes.byteOffset, 8)
96
+ : new DataView(bytes.buffer, bytes.byteOffset + bytes.length - 8, 8);
97
+ return view.getBigUint64(0, false);
98
+ }
99
+ /**
100
+ * Verify an endorsed leaf and its receipt from public artifacts only.
101
+ * Pure over bytes; no network (rules-of-the-road C1).
102
+ */
103
+ export async function verifyEndorsedLeaf(input, opts) {
104
+ if (input.rootPublicKeyXY.length !== 64) {
105
+ return { ok: false, stage: "endorsement", reason: "root_invalid" };
106
+ }
107
+ const logPrefix = opts?.logPrefix ?? "verify-endorsed-leaf";
108
+ // 1. The endorsement, from the leaf itself.
109
+ const extracted = extractLeafEndorsement(input.statementCbor);
110
+ if (extracted.kind === "missing") {
111
+ return { ok: false, stage: "endorsement", reason: "endorsement_missing" };
112
+ }
113
+ if (extracted.kind === "invalid") {
114
+ return { ok: false, stage: "endorsement", reason: "endorsement_invalid" };
115
+ }
116
+ // 2. Under the root (kid pinned to root x by the coordinate anchor).
117
+ const endorsed = await verifySessionKeyEndorsement(extracted.endorsement, {
118
+ x: input.rootPublicKeyXY.subarray(0, 32),
119
+ y: input.rootPublicKeyXY.subarray(32, 64),
120
+ curve: "P-256",
121
+ }, {
122
+ requireUserVerification: opts?.requireUserVerification,
123
+ logFailures: opts?.logFailures,
124
+ logPrefix,
125
+ });
126
+ if (!endorsed.ok) {
127
+ return {
128
+ ok: false,
129
+ stage: "endorsement",
130
+ reason: endorsementAdmissionReason(endorsed.reason),
131
+ };
132
+ }
133
+ // 3. The leaf: kid == session x, signature under the session key. The
134
+ // shared verify branch rejects a -65800 entry on a plain-ES256 leaf.
135
+ const sessionX = endorsed.sessionPublicKeyXY.subarray(0, 32);
136
+ if (!extracted.kid || !bytesEqual(extracted.kid, sessionX)) {
137
+ return { ok: false, stage: "leaf", reason: "signer_mismatch" };
138
+ }
139
+ const leafOk = await verifyCoseSign1WithParsedKey(input.statementCbor, endorsed.sessionKey, { logFailures: opts?.logFailures, logPrefix: `${logPrefix}:leaf` });
140
+ if (!leafOk) {
141
+ return { ok: false, stage: "leaf", reason: "leaf_signature_invalid" };
142
+ }
143
+ // 4. The window, against the receipted idtimestamp (never wall-clock —
144
+ // a valid receipt verifies forever). Exact: no skew.
145
+ let idtimestamp;
146
+ try {
147
+ idtimestamp = readIdtimestampBe8(input.idtimestampBe8);
148
+ }
149
+ catch {
150
+ return { ok: false, stage: "window", reason: "idtimestamp_invalid" };
151
+ }
152
+ const leafIdtimestampMs = idtimestampToUnixMs(idtimestamp);
153
+ const window = checkEndorsementWindow(endorsed, leafIdtimestampMs);
154
+ if (!window.ok) {
155
+ return { ok: false, stage: "window", reason: window.reason };
156
+ }
157
+ // 5. Inclusion of the EXACT leaf bytes (the endorsement is inside them).
158
+ let trustKeys = input.trustKeys;
159
+ if (!trustKeys) {
160
+ try {
161
+ trustKeys = [
162
+ await importEs256PublicKeyFromGrantDataXy64(input.rootPublicKeyXY),
163
+ ];
164
+ }
165
+ catch {
166
+ return { ok: false, stage: "receipt", reason: "root_invalid" };
167
+ }
168
+ }
169
+ const receipt = await verifyReceiptOfflineWithKeys({
170
+ receiptCbor: input.receiptCbor,
171
+ payload: input.statementCbor,
172
+ idtimestampBe8: input.idtimestampBe8,
173
+ trustKeys,
174
+ });
175
+ if (!receipt.ok) {
176
+ return {
177
+ ok: false,
178
+ stage: "receipt",
179
+ reason: receipt.reason ?? "receipt_invalid",
180
+ };
181
+ }
182
+ return {
183
+ ok: true,
184
+ sessionPublicKeyXY: endorsed.sessionPublicKeyXY,
185
+ notBefore: endorsed.notBefore,
186
+ notAfter: endorsed.notAfter,
187
+ leafIdtimestampMs,
188
+ };
189
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forestrie/receipt-verify",
3
- "version": "0.11.0",
3
+ "version": "1.0.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "Offline SCITT grant receipt verification (ADR-0045)",
@@ -28,8 +28,8 @@
28
28
  ],
29
29
  "dependencies": {
30
30
  "@noble/hashes": "^1.7.1",
31
- "@forestrie/encoding": "0.6.0",
32
31
  "@forestrie/chain-rpc": "0.2.0",
32
+ "@forestrie/encoding": "0.7.0",
33
33
  "@forestrie/merklelog": "0.3.0"
34
34
  },
35
35
  "devDependencies": {
package/src/index.ts CHANGED
@@ -49,6 +49,7 @@ export type {
49
49
  } from "./verify-grant-receipt-offline.js";
50
50
  export {
51
51
  checkDelegationConstraints,
52
+ idtimestampToUnixMs,
52
53
  idtimestampToUnixSeconds,
53
54
  resolveDelegatedVerifyKey,
54
55
  } from "./resolve-delegated-verify-key.js";
@@ -58,6 +59,49 @@ export type {
58
59
  } from "./resolve-delegated-verify-key.js";
59
60
  /** Import a raw 64-byte x||y P-256 public key as an ES256 verify key. */
60
61
  export { importEs256PublicKeyFromGrantDataXy64 } from "./decode-trust-root-cbor.js";
62
+ /**
63
+ * Passkey session-key endorsement v2 (ADR-0064 as amended by ADR-0065):
64
+ * build, assemble, and verify the artifact chaining a passkey log root to the
65
+ * plain-ES256 per-turn session key for a validity window. The endorsement
66
+ * rides inside every endorsed leaf at unprotected label -65801
67
+ * (`COSE_LABEL_SESSION_KEY_ENDORSEMENT`, @forestrie/encoding).
68
+ */
69
+ export {
70
+ assembleSessionKeyEndorsement,
71
+ buildSessionKeyEndorsementTbs,
72
+ checkEndorsementWindow,
73
+ DEFAULT_ENDORSEMENT_WINDOW_MS,
74
+ NOT_AFTER_PAYLOAD_KEY,
75
+ NOT_BEFORE_PAYLOAD_KEY,
76
+ SESSION_KEY_ENDORSEMENT_CONTENT_TYPE,
77
+ SESSION_KEY_ENDORSEMENT_V1_CONTENT_TYPE,
78
+ SESSION_KEY_PAYLOAD_KEY,
79
+ verifySessionKeyEndorsement,
80
+ } from "./session-key-endorsement.js";
81
+ export type {
82
+ EndorsementWindow,
83
+ EndorsementWindowCheck,
84
+ SessionKeyEndorsementFailureReason,
85
+ SessionKeyEndorsementTbs,
86
+ SessionKeyEndorsementVerifyResult,
87
+ VerifySessionKeyEndorsementOptions,
88
+ } from "./session-key-endorsement.js";
89
+ /**
90
+ * The single offline rung for a passkey-rooted log (ADR-0065 §5): root →
91
+ * endorsement (inside the leaf) → session key → leaf → receipt + window.
92
+ */
93
+ export {
94
+ endorsementAdmissionReason,
95
+ extractLeafEndorsement,
96
+ verifyEndorsedLeaf,
97
+ } from "./verify-endorsed-leaf.js";
98
+ export type {
99
+ EndorsementAdmissionReason,
100
+ VerifyEndorsedLeafInput,
101
+ VerifyEndorsedLeafOptions,
102
+ VerifyEndorsedLeafResult,
103
+ VerifyEndorsedLeafStage,
104
+ } from "./verify-endorsed-leaf.js";
61
105
  export { decodeForestrieGrantCose } from "./decode-forestrie-grant-cose.js";
62
106
  export { decodeGrantPayload, decodeGrantResponse } from "./grant-codec.js";
63
107
 
@@ -71,10 +71,19 @@ const SNOWFLAKE_TIME_SHIFT = 24n;
71
71
  const SNOWFLAKE_EPOCH_SPAN_MS = (1n << 40n) - 1n;
72
72
 
73
73
  export function idtimestampToUnixSeconds(idtimestamp: bigint): number {
74
- const ms =
74
+ return Math.floor(idtimestampToUnixMs(idtimestamp) / 1000);
75
+ }
76
+
77
+ /**
78
+ * Snowflake idtimestamp → Unix milliseconds: the time component an
79
+ * ADR-0065 §3 endorsement window is expressed in
80
+ * (`ms = (id >> TimeShift) + EpochMS(epoch)`).
81
+ */
82
+ export function idtimestampToUnixMs(idtimestamp: bigint): number {
83
+ return Number(
75
84
  COMMITMENT_EPOCH * SNOWFLAKE_EPOCH_SPAN_MS +
76
- (idtimestamp >> SNOWFLAKE_TIME_SHIFT);
77
- return Number(ms / 1000n);
85
+ (idtimestamp >> SNOWFLAKE_TIME_SHIFT),
86
+ );
78
87
  }
79
88
 
80
89
  /**