@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
@@ -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.12.0",
3
+ "version": "1.1.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "Offline SCITT grant receipt verification (ADR-0045)",
@@ -28,9 +28,9 @@
28
28
  ],
29
29
  "dependencies": {
30
30
  "@noble/hashes": "^1.7.1",
31
- "@forestrie/chain-rpc": "0.2.0",
32
- "@forestrie/encoding": "0.6.0",
33
- "@forestrie/merklelog": "0.3.0"
31
+ "@forestrie/chain-rpc": "0.3.0",
32
+ "@forestrie/merklelog": "0.3.0",
33
+ "@forestrie/encoding": "0.7.0"
34
34
  },
35
35
  "devDependencies": {
36
36
  "esbuild": "^0.24.0",
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Genesis-bound chain binding: which univocity contract and chain a
3
+ * forest's on-chain checkpoints are anchored to, plus the forest's own log
4
+ * id (labels -68011 / -68013 / -68010). Bound once at genesis and public
5
+ * (ADR-0045) — the address and chain id are properties of the FOREST, not
6
+ * of the operator serving it. See {@link decodeChainBindingFromGenesis}.
7
+ * Mirrors mcp-resolve's `ChainBinding` (plan-2609-07 L3) so that consumer
8
+ * can delete its own copy, except `logId` here is the raw 32-byte wire
9
+ * value rather than a formatted UUID string.
10
+ */
11
+ export interface ChainBinding {
12
+ /** `0x` + 40 lowercase hex univocity contract address. */
13
+ univocity: string;
14
+ chainId: number;
15
+ /** Raw 32 bytes of label -68010: 16 zero bytes then the 16-byte forest
16
+ * bootstrap log id. */
17
+ logId: Uint8Array;
18
+ }
@@ -0,0 +1,77 @@
1
+ import { decodeCborDeterministic } from "@forestrie/encoding";
2
+ import {
3
+ asGenesisUint8Array,
4
+ decodeGenesisBodyAsIntKeyMap,
5
+ } from "./decode-genesis-cbor-map.js";
6
+ import {
7
+ FOREST_GENESIS_LABEL_CHAIN_ID,
8
+ FOREST_GENESIS_LABEL_GENESIS_VERSION,
9
+ FOREST_GENESIS_LABEL_LOG_ID,
10
+ FOREST_GENESIS_LABEL_UNIVOCITY_ADDR,
11
+ FOREST_GENESIS_SCHEMA_V2,
12
+ } from "./forest-genesis-labels.js";
13
+ import type { ChainBinding } from "./chain-binding.js";
14
+
15
+ function bytesToLowerHex(bytes: Uint8Array): string {
16
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
17
+ }
18
+
19
+ /**
20
+ * Decode the chain binding — univocity contract address, chain id, and the
21
+ * forest's own log id — out of a forest genesis document CBOR blob (labels
22
+ * -68011 / -68013 / -68010). Mirrors mcp-resolve's `genesis-binding.ts`
23
+ * `decodeChainBindingFromGenesis` (plan-2609-07 L3) so that consumer can
24
+ * delete its own copy, except `logId` here stays the raw 32-byte wire value
25
+ * rather than being reformatted as a UUID string.
26
+ *
27
+ * Throws a plain `Error` for any genesis document that does not decode into
28
+ * a usable chain binding: not CBOR, not a map, wrong schema version, or a
29
+ * label absent or mis-sized/mistyped.
30
+ */
31
+ export function decodeChainBindingFromGenesis(
32
+ genesis: Uint8Array,
33
+ ): ChainBinding {
34
+ let raw: unknown;
35
+ try {
36
+ raw = decodeCborDeterministic(genesis);
37
+ } catch (err) {
38
+ throw new Error(
39
+ `genesis CBOR decode failed: ${err instanceof Error ? err.message : String(err)}`,
40
+ );
41
+ }
42
+ const m = decodeGenesisBodyAsIntKeyMap(raw);
43
+ if (!m) throw new Error("genesis document must be a CBOR map");
44
+
45
+ const versionRaw = m.get(FOREST_GENESIS_LABEL_GENESIS_VERSION);
46
+ if (versionRaw === undefined) {
47
+ throw new Error("genesis version label absent");
48
+ }
49
+ const version =
50
+ typeof versionRaw === "bigint" ? Number(versionRaw) : versionRaw;
51
+ if (version !== FOREST_GENESIS_SCHEMA_V2) {
52
+ throw new Error(
53
+ `genesis version ${String(version)} is not ${FOREST_GENESIS_SCHEMA_V2}`,
54
+ );
55
+ }
56
+
57
+ const addr = asGenesisUint8Array(m.get(FOREST_GENESIS_LABEL_UNIVOCITY_ADDR));
58
+ if (!addr || addr.length !== 20) {
59
+ throw new Error("univocity address label absent or not 20 bytes");
60
+ }
61
+
62
+ const chainIdRaw = m.get(FOREST_GENESIS_LABEL_CHAIN_ID);
63
+ if (typeof chainIdRaw !== "string" || !/^[0-9]+$/.test(chainIdRaw)) {
64
+ throw new Error("chain id label absent or not a decimal string");
65
+ }
66
+
67
+ const logId = asGenesisUint8Array(m.get(FOREST_GENESIS_LABEL_LOG_ID));
68
+ if (!logId || logId.length !== 32) {
69
+ throw new Error("log id label absent or not 32 bytes");
70
+ }
71
+
72
+ return {
73
+ univocity: `0x${bytesToLowerHex(addr)}`,
74
+ chainId: Number(chainIdRaw),
75
+ logId,
76
+ };
77
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Shared decode step for a genesis document's CBOR body: deterministic CBOR
3
+ * decodes an integer-keyed map either as a native `Map` or as a plain
4
+ * object (encoder-dependent), so callers that read genesis labels normalise
5
+ * to a `Map<number, unknown>` once here rather than each re-implementing
6
+ * the same fallback. Used by {@link decodeTrustRootFromGenesis} and
7
+ * {@link decodeChainBindingFromGenesis}.
8
+ */
9
+ export function decodeGenesisBodyAsIntKeyMap(
10
+ raw: unknown,
11
+ ): Map<number, unknown> | null {
12
+ if (raw instanceof Map) return raw as Map<number, unknown>;
13
+ if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
14
+ const out = new Map<number, unknown>();
15
+ for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
16
+ const n = Number(k);
17
+ if (Number.isFinite(n)) out.set(n, v);
18
+ }
19
+ return out;
20
+ }
21
+ return null;
22
+ }
23
+
24
+ /** Narrow a decoded genesis field to `Uint8Array`, or `null` if it is not one. */
25
+ export function asGenesisUint8Array(v: unknown): Uint8Array | null {
26
+ return v instanceof Uint8Array ? v : null;
27
+ }
@@ -17,39 +17,35 @@ import {
17
17
  FOREST_GENESIS_SCHEMA_V2,
18
18
  } from "./forest-genesis-labels.js";
19
19
  import { decodeTrustRootCbor } from "./decode-trust-root-cbor.js";
20
- import type { RootVerifyKey } from "./root-verify-key.js";
21
-
22
- function decodeBodyAsIntKeyMap(raw: unknown): Map<number, unknown> | null {
23
- if (raw instanceof Map) return raw as Map<number, unknown>;
24
- if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
25
- const out = new Map<number, unknown>();
26
- for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
27
- const n = Number(k);
28
- if (Number.isFinite(n)) out.set(n, v);
29
- }
30
- return out;
31
- }
32
- return null;
33
- }
34
-
35
- function asGenesisUint8Array(v: unknown): Uint8Array | null {
36
- return v instanceof Uint8Array ? v : null;
37
- }
20
+ import { isParsedKs256RootKey, type RootVerifyKey } from "./root-verify-key.js";
21
+ import {
22
+ asGenesisUint8Array,
23
+ decodeGenesisBodyAsIntKeyMap,
24
+ } from "./decode-genesis-cbor-map.js";
25
+ import type { DecodedTrustRoot } from "./decoded-trust-root.js";
38
26
 
39
27
  /**
40
- * Extract receipt verify key from a forest genesis document CBOR blob.
41
- * Offline path: genesis-only trust anchor (ADR-0045).
28
+ * Extract the receipt verify key from a forest genesis document CBOR blob,
29
+ * alongside `bootstrapKeyXy` when the bootstrap key is ES256. Offline path:
30
+ * genesis-only trust anchor (ADR-0045). See {@link DecodedTrustRoot}.
31
+ * `bootstrapKeyXy` is read directly from the genesis-encoded bytes (never
32
+ * exported from the non-extractable `CryptoKey` in `key`), and is
33
+ * `undefined` for a KS256 v2 bootstrap key (an on-chain address — there is
34
+ * no P-256 public key to give up in that case).
35
+ *
36
+ * This is the single decode path: {@link decodeTrustRootFromGenesis} is
37
+ * `(await decodeTrustRootDetailsFromGenesis(genesisCbor)).key`.
42
38
  */
43
- export async function decodeTrustRootFromGenesis(
39
+ export async function decodeTrustRootDetailsFromGenesis(
44
40
  genesisCbor: Uint8Array,
45
- ): Promise<RootVerifyKey> {
41
+ ): Promise<DecodedTrustRoot> {
46
42
  let raw: unknown;
47
43
  try {
48
44
  raw = decodeCborDeterministic(genesisCbor);
49
45
  } catch {
50
46
  throw new Error("genesis CBOR decode failed");
51
47
  }
52
- const m = decodeBodyAsIntKeyMap(raw);
48
+ const m = decodeGenesisBodyAsIntKeyMap(raw);
53
49
  if (!m) throw new Error("genesis document must be a CBOR map");
54
50
 
55
51
  const versionRaw = m.get(FOREST_GENESIS_LABEL_GENESIS_VERSION);
@@ -61,7 +57,11 @@ export async function decodeTrustRootFromGenesis(
61
57
  if (bootstrapKey === null) {
62
58
  throw new Error("v2 genesis missing bootstrapKey");
63
59
  }
64
- return decodeTrustRootCbor({ alg, key: bootstrapKey });
60
+ const key = await decodeTrustRootCbor({ alg, key: bootstrapKey });
61
+ return {
62
+ key,
63
+ bootstrapKeyXy: isParsedKs256RootKey(key) ? undefined : bootstrapKey,
64
+ };
65
65
  }
66
66
 
67
67
  const kty = m.get(COSE_KEY_KTY);
@@ -76,7 +76,8 @@ export async function decodeTrustRootFromGenesis(
76
76
  const xy = new Uint8Array(64);
77
77
  xy.set(x, 0);
78
78
  xy.set(y, 32);
79
- return decodeTrustRootCbor({ alg: COSE_ALG_ES256, key: xy });
79
+ const key = await decodeTrustRootCbor({ alg: COSE_ALG_ES256, key: xy });
80
+ return { key, bootstrapKeyXy: xy };
80
81
  }
81
82
 
82
83
  if (versionRaw === FOREST_GENESIS_SCHEMA_V1 || versionRaw === undefined) {
@@ -85,3 +86,17 @@ export async function decodeTrustRootFromGenesis(
85
86
 
86
87
  throw new Error("unsupported genesis document");
87
88
  }
89
+
90
+ /**
91
+ * Extract the receipt verify key from a forest genesis document CBOR blob.
92
+ * Offline path: genesis-only trust anchor (ADR-0045). The origin/main
93
+ * signature and behaviour (including a KS256 v2 bootstrap key resolving to
94
+ * the `ParsedKs256RootKey` on-chain address, not a throw): callers pinned
95
+ * to `RootVerifyKey` keep working unchanged. For the raw bootstrap public
96
+ * key bytes as well, use {@link decodeTrustRootDetailsFromGenesis}.
97
+ */
98
+ export async function decodeTrustRootFromGenesis(
99
+ genesisCbor: Uint8Array,
100
+ ): Promise<RootVerifyKey> {
101
+ return (await decodeTrustRootDetailsFromGenesis(genesisCbor)).key;
102
+ }
@@ -0,0 +1,17 @@
1
+ import type { RootVerifyKey } from "./root-verify-key.js";
2
+
3
+ /**
4
+ * Result of {@link decodeTrustRootDetailsFromGenesis}: the decoded verify
5
+ * key (an ES256 `CryptoKey`, or a KS256 on-chain address) plus, for the
6
+ * ES256 case, the raw 64-byte x||y P-256 public key coordinates the genesis
7
+ * document carries. `bootstrapKeyXy` is read directly from the
8
+ * genesis-encoded bytes, not exported from `key` (`key` stays
9
+ * non-extractable) — plan-2609-07 L3, for callers that need the
10
+ * serialisable public key material the `CryptoKey` cannot give up.
11
+ * `bootstrapKeyXy` is `undefined` for a KS256 v2 bootstrap key (an on-chain
12
+ * address — there is no P-256 public key to give up).
13
+ */
14
+ export interface DecodedTrustRoot {
15
+ key: RootVerifyKey;
16
+ bootstrapKeyXy?: Uint8Array;
17
+ }
@@ -1,6 +1,13 @@
1
1
  export const FOREST_GENESIS_LABEL_GENESIS_VERSION = -68009;
2
2
  export const FOREST_GENESIS_LABEL_GENESIS_ALG = -68014;
3
3
  export const FOREST_GENESIS_LABEL_BOOTSTRAP_KEY = -68015;
4
+ /**
5
+ * The forest's own log id, wire-encoded as 32 bytes: 16 zero bytes then the
6
+ * 16-byte forest bootstrap log id (plan-2609-07 L3; mirrors mcp-resolve's
7
+ * `genesis-binding.ts` naming so that consumer can import this instead of
8
+ * defining it locally). See {@link decodeChainBindingFromGenesis}.
9
+ */
10
+ export const FOREST_GENESIS_LABEL_LOG_ID = -68010;
4
11
  export const FOREST_GENESIS_LABEL_UNIVOCITY_ADDR = -68011;
5
12
  export const FOREST_GENESIS_LABEL_CHAIN_ID = -68013;
6
13
  export const FOREST_GENESIS_SCHEMA_V2 = 2;
package/src/index.ts CHANGED
@@ -31,7 +31,37 @@ export type {
31
31
  * re-exported here to preserve the receipt-verify public surface.
32
32
  */
33
33
  export { peakMMRIndexes } from "@forestrie/merklelog";
34
- export { decodeTrustRootFromGenesis } from "./decode-trust-root-from-genesis.js";
34
+ /**
35
+ * `decodeTrustRootFromGenesis` keeps its origin/main signature and
36
+ * behaviour (`Promise<RootVerifyKey>`, including a KS256 v2 bootstrap key
37
+ * resolving without a throw) so pinned consumers (mcp-verify 1.0.0) are
38
+ * unaffected. `decodeTrustRootDetailsFromGenesis` is the additive sibling
39
+ * (plan-2609-07 L3) carrying `bootstrapKeyXy` alongside `key`.
40
+ */
41
+ export {
42
+ decodeTrustRootDetailsFromGenesis,
43
+ decodeTrustRootFromGenesis,
44
+ } from "./decode-trust-root-from-genesis.js";
45
+ export type { DecodedTrustRoot } from "./decoded-trust-root.js";
46
+ /**
47
+ * Genesis document label constants (plan-2609-07 L3): exported from the
48
+ * package root so consumers stop redefining them locally (mcp-resolve's own
49
+ * `genesis-binding.ts` did, before this).
50
+ */
51
+ export {
52
+ FOREST_GENESIS_LABEL_BOOTSTRAP_KEY,
53
+ FOREST_GENESIS_LABEL_CHAIN_ID,
54
+ FOREST_GENESIS_LABEL_GENESIS_ALG,
55
+ FOREST_GENESIS_LABEL_GENESIS_VERSION,
56
+ FOREST_GENESIS_LABEL_LOG_ID,
57
+ FOREST_GENESIS_LABEL_UNIVOCITY_ADDR,
58
+ } from "./forest-genesis-labels.js";
59
+ /**
60
+ * Genesis-bound chain binding (plan-2609-07 L3): mirrors mcp-resolve's own
61
+ * `decodeChainBindingFromGenesis` so that consumer can delete its copy.
62
+ */
63
+ export { decodeChainBindingFromGenesis } from "./decode-chain-binding-from-genesis.js";
64
+ export type { ChainBinding } from "./chain-binding.js";
35
65
  export { verifyGrantReceiptOffline } from "./verify-grant-receipt-offline.js";
36
66
  export { verifyReceiptOffline } from "./verify-grant-receipt-offline.js";
37
67
  /**
@@ -49,6 +79,7 @@ export type {
49
79
  } from "./verify-grant-receipt-offline.js";
50
80
  export {
51
81
  checkDelegationConstraints,
82
+ idtimestampToUnixMs,
52
83
  idtimestampToUnixSeconds,
53
84
  resolveDelegatedVerifyKey,
54
85
  } from "./resolve-delegated-verify-key.js";
@@ -59,22 +90,48 @@ export type {
59
90
  /** Import a raw 64-byte x||y P-256 public key as an ES256 verify key. */
60
91
  export { importEs256PublicKeyFromGrantDataXy64 } from "./decode-trust-root-cbor.js";
61
92
  /**
62
- * Passkey session-key endorsement (ADR-0064): build, assemble, and verify
63
- * the one-gesture artifact chaining a passkey log root to the plain-ES256
64
- * per-turn session key — the offline rung root → endorsement → leaves.
93
+ * Passkey session-key endorsement v2 (ADR-0064 as amended by ADR-0065):
94
+ * build, assemble, and verify the artifact chaining a passkey log root to the
95
+ * plain-ES256 per-turn session key for a validity window. The endorsement
96
+ * rides inside every endorsed leaf at unprotected label -65801
97
+ * (`COSE_LABEL_SESSION_KEY_ENDORSEMENT`, @forestrie/encoding).
65
98
  */
66
99
  export {
67
100
  assembleSessionKeyEndorsement,
68
101
  buildSessionKeyEndorsementTbs,
102
+ checkEndorsementWindow,
103
+ DEFAULT_ENDORSEMENT_WINDOW_MS,
104
+ NOT_AFTER_PAYLOAD_KEY,
105
+ NOT_BEFORE_PAYLOAD_KEY,
69
106
  SESSION_KEY_ENDORSEMENT_CONTENT_TYPE,
107
+ SESSION_KEY_ENDORSEMENT_V1_CONTENT_TYPE,
70
108
  SESSION_KEY_PAYLOAD_KEY,
71
109
  verifySessionKeyEndorsement,
72
110
  } from "./session-key-endorsement.js";
73
111
  export type {
112
+ EndorsementWindow,
113
+ EndorsementWindowCheck,
114
+ SessionKeyEndorsementFailureReason,
74
115
  SessionKeyEndorsementTbs,
75
116
  SessionKeyEndorsementVerifyResult,
76
117
  VerifySessionKeyEndorsementOptions,
77
118
  } from "./session-key-endorsement.js";
119
+ /**
120
+ * The single offline rung for a passkey-rooted log (ADR-0065 §5): root →
121
+ * endorsement (inside the leaf) → session key → leaf → receipt + window.
122
+ */
123
+ export {
124
+ endorsementAdmissionReason,
125
+ extractLeafEndorsement,
126
+ verifyEndorsedLeaf,
127
+ } from "./verify-endorsed-leaf.js";
128
+ export type {
129
+ EndorsementAdmissionReason,
130
+ VerifyEndorsedLeafInput,
131
+ VerifyEndorsedLeafOptions,
132
+ VerifyEndorsedLeafResult,
133
+ VerifyEndorsedLeafStage,
134
+ } from "./verify-endorsed-leaf.js";
78
135
  export { decodeForestrieGrantCose } from "./decode-forestrie-grant-cose.js";
79
136
  export { decodeGrantPayload, decodeGrantResponse } from "./grant-codec.js";
80
137
 
@@ -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
  /**