@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.
- package/dist/chain-binding.d.ts +19 -0
- package/dist/chain-binding.d.ts.map +1 -0
- package/dist/chain-binding.js +1 -0
- package/dist/decode-chain-binding-from-genesis.d.ts +15 -0
- package/dist/decode-chain-binding-from-genesis.d.ts.map +1 -0
- package/dist/decode-chain-binding-from-genesis.js +55 -0
- package/dist/decode-genesis-cbor-map.d.ts +12 -0
- package/dist/decode-genesis-cbor-map.d.ts.map +1 -0
- package/dist/decode-genesis-cbor-map.js +26 -0
- package/dist/decode-trust-root-from-genesis.d.ts +21 -3
- package/dist/decode-trust-root-from-genesis.d.ts.map +1 -1
- package/dist/decode-trust-root-from-genesis.js +32 -23
- package/dist/decoded-trust-root.d.ts +17 -0
- package/dist/decoded-trust-root.d.ts.map +1 -0
- package/dist/decoded-trust-root.js +1 -0
- package/dist/forest-genesis-labels.d.ts +7 -0
- package/dist/forest-genesis-labels.d.ts.map +1 -1
- package/dist/forest-genesis-labels.js +7 -0
- package/dist/index.d.ts +35 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +31 -6
- 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 +4 -4
- package/src/chain-binding.ts +18 -0
- package/src/decode-chain-binding-from-genesis.ts +77 -0
- package/src/decode-genesis-cbor-map.ts +27 -0
- package/src/decode-trust-root-from-genesis.ts +40 -25
- package/src/decoded-trust-root.ts +17 -0
- package/src/forest-genesis-labels.ts +7 -0
- package/src/index.ts +61 -4
- 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
|
@@ -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": "
|
|
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.
|
|
32
|
-
"@forestrie/
|
|
33
|
-
"@forestrie/
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
*
|
|
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
|
|
39
|
+
export async function decodeTrustRootDetailsFromGenesis(
|
|
44
40
|
genesisCbor: Uint8Array,
|
|
45
|
-
): Promise<
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
63
|
-
* the
|
|
64
|
-
* per-turn session key
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
85
|
+
(idtimestamp >> SNOWFLAKE_TIME_SHIFT),
|
|
86
|
+
);
|
|
78
87
|
}
|
|
79
88
|
|
|
80
89
|
/**
|