@forestrie/forestrie-cli 0.8.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 (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +425 -0
  3. package/TRUST-MODEL.md +8 -0
  4. package/dist/cli.js +33811 -0
  5. package/dist/decode-receipt.d.ts +19 -0
  6. package/dist/decode-receipt.d.ts.map +1 -0
  7. package/dist/decode-receipt.js +19 -0
  8. package/dist/decode-receipt.js.map +1 -0
  9. package/dist/index.d.ts +11 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +11 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/lib/decode-receipt-cbor.d.ts +26 -0
  14. package/dist/lib/decode-receipt-cbor.d.ts.map +1 -0
  15. package/dist/lib/decode-receipt-cbor.js +182 -0
  16. package/dist/lib/decode-receipt-cbor.js.map +1 -0
  17. package/dist/lib/decode-receipt-decode.d.ts +103 -0
  18. package/dist/lib/decode-receipt-decode.d.ts.map +1 -0
  19. package/dist/lib/decode-receipt-decode.js +237 -0
  20. package/dist/lib/decode-receipt-decode.js.map +1 -0
  21. package/dist/lib/decode-receipt-labels.d.ts +54 -0
  22. package/dist/lib/decode-receipt-labels.d.ts.map +1 -0
  23. package/dist/lib/decode-receipt-labels.js +117 -0
  24. package/dist/lib/decode-receipt-labels.js.map +1 -0
  25. package/dist/lib/decode-receipt-render.d.ts +11 -0
  26. package/dist/lib/decode-receipt-render.d.ts.map +1 -0
  27. package/dist/lib/decode-receipt-render.js +242 -0
  28. package/dist/lib/decode-receipt-render.js.map +1 -0
  29. package/package.json +83 -0
  30. package/src/cli.ts +5 -0
  31. package/src/command.ts +51 -0
  32. package/src/commands/admin-onboard-token.ts +51 -0
  33. package/src/commands/admin.ts +20 -0
  34. package/src/commands/complete-grant.ts +41 -0
  35. package/src/commands/create-consistency-proof.ts +44 -0
  36. package/src/commands/create-log.ts +95 -0
  37. package/src/commands/decode-receipt.ts +20 -0
  38. package/src/commands/delegate.ts +55 -0
  39. package/src/commands/deploy.ts +72 -0
  40. package/src/commands/fetch-accumulator.ts +49 -0
  41. package/src/commands/onboard-genesis.ts +70 -0
  42. package/src/commands/onboard-request.ts +59 -0
  43. package/src/commands/register-grant.ts +74 -0
  44. package/src/commands/register.ts +54 -0
  45. package/src/commands/resolve-receipt.ts +101 -0
  46. package/src/commands/sign-statement.ts +53 -0
  47. package/src/commands/verify-grant.ts +89 -0
  48. package/src/commands/verify.ts +92 -0
  49. package/src/commoncli.ts +34 -0
  50. package/src/decode-receipt.ts +56 -0
  51. package/src/index.ts +10 -0
  52. package/src/lib/bytes.ts +7 -0
  53. package/src/lib/checkpoint-provider.ts +297 -0
  54. package/src/lib/complete-grant-derive.ts +237 -0
  55. package/src/lib/consistency-proof.ts +271 -0
  56. package/src/lib/create-log-build.ts +168 -0
  57. package/src/lib/create-log-prepare.ts +75 -0
  58. package/src/lib/create-receipt-chain-verify.ts +267 -0
  59. package/src/lib/create-receipt-derive.ts +208 -0
  60. package/src/lib/create-receipt-inputs.ts +79 -0
  61. package/src/lib/decode-checkpoint-calldata.ts +188 -0
  62. package/src/lib/decode-receipt-cbor.ts +202 -0
  63. package/src/lib/decode-receipt-decode.ts +333 -0
  64. package/src/lib/decode-receipt-labels.ts +132 -0
  65. package/src/lib/decode-receipt-render.ts +284 -0
  66. package/src/lib/delegate-flow.ts +289 -0
  67. package/src/lib/delegate-voucher.ts +122 -0
  68. package/src/lib/deploy-artifact.ts +79 -0
  69. package/src/lib/deploy-bootstrap.ts +100 -0
  70. package/src/lib/deploy-flow.ts +184 -0
  71. package/src/lib/deploy-genesis-log-id.ts +29 -0
  72. package/src/lib/deploy-rpc.ts +40 -0
  73. package/src/lib/deployment-record.ts +65 -0
  74. package/src/lib/fsio.ts +91 -0
  75. package/src/lib/grant-flags-local.ts +19 -0
  76. package/src/lib/onboard-attestation.ts +120 -0
  77. package/src/lib/onboard-genesis-body.ts +94 -0
  78. package/src/lib/openssl-error-queue.ts +56 -0
  79. package/src/lib/register-flow.ts +292 -0
  80. package/src/lib/register-grant-build.ts +173 -0
  81. package/src/lib/register-grant-complete.ts +47 -0
  82. package/src/lib/register-grant-flow.ts +258 -0
  83. package/src/lib/resolve-receipt-freshen.ts +221 -0
  84. package/src/lib/sign-statement-build.ts +117 -0
  85. package/src/lib/sign-statement-key.ts +145 -0
  86. package/src/lib/verify-anchored.ts +326 -0
  87. package/src/lib/verify-checkpoint-chain.ts +160 -0
  88. package/src/lib/verify-eventscan.ts +214 -0
  89. package/src/lib/verify-inputs.ts +143 -0
  90. package/src/lib/verify-known-accumulator.ts +161 -0
  91. package/src/lib/verify-report.ts +265 -0
  92. package/src/lib/x402-payment.ts +115 -0
  93. package/src/main/admin-onboard-token.ts +216 -0
  94. package/src/main/complete-grant.ts +265 -0
  95. package/src/main/create-consistency-proof.ts +83 -0
  96. package/src/main/create-log.ts +347 -0
  97. package/src/main/create-receipt.ts +763 -0
  98. package/src/main/decode-receipt.ts +72 -0
  99. package/src/main/delegate.ts +141 -0
  100. package/src/main/deploy.ts +273 -0
  101. package/src/main/fetch-accumulator.ts +181 -0
  102. package/src/main/not-implemented.ts +43 -0
  103. package/src/main/onboard-genesis.ts +192 -0
  104. package/src/main/onboard-request.ts +333 -0
  105. package/src/main/register-grant.ts +270 -0
  106. package/src/main/register.ts +243 -0
  107. package/src/main/sign-statement.ts +168 -0
  108. package/src/main/verify.ts +767 -0
  109. package/src/options/admin-onboard-token.ts +48 -0
  110. package/src/options/common.ts +69 -0
  111. package/src/options/complete-grant.ts +40 -0
  112. package/src/options/create-consistency-proof.ts +63 -0
  113. package/src/options/create-log.ts +99 -0
  114. package/src/options/create-receipt.ts +243 -0
  115. package/src/options/decode-receipt.ts +25 -0
  116. package/src/options/delegate.ts +93 -0
  117. package/src/options/deploy.ts +105 -0
  118. package/src/options/fetch-accumulator.ts +62 -0
  119. package/src/options/onboard-genesis.ts +74 -0
  120. package/src/options/onboard-request.ts +57 -0
  121. package/src/options/register-grant.ts +72 -0
  122. package/src/options/register.ts +71 -0
  123. package/src/options/sign-statement.ts +64 -0
  124. package/src/options/verify.ts +250 -0
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Human rendering for `forestrie decode-receipt` (FOR-346).
3
+ *
4
+ * The demo money-shot: an annotated tree that makes the receipt legible
5
+ * to an audience — it's just COSE (Sign1 + MMR inclusion). Works purely
6
+ * from the display model so `--json` and the tree always agree.
7
+ */
8
+
9
+ import type {
10
+ DecodedHeaderEntry,
11
+ DecodedReceipt,
12
+ Json,
13
+ } from "./decode-receipt-decode.js";
14
+ import {
15
+ CWT_CLAIMS_LABEL,
16
+ DELEGATION_CERT_LABEL,
17
+ PROOF_KIND_NAMES,
18
+ SEAL_PEAK_RECEIPTS_LABEL,
19
+ VDS_LABEL,
20
+ VERIFIABLE_PROOFS_LABEL,
21
+ } from "./decode-receipt-labels.js";
22
+
23
+ /** Shorten `h'…'` / hex strings for tree display; JSON keeps full bytes. */
24
+ function shortHex(hex: string, keepBytes = 8): string {
25
+ const byteLength = hex.length / 2;
26
+ if (byteLength <= keepBytes * 2) return hex;
27
+ return `${hex.slice(0, keepBytes * 2)}…${hex.slice(-4)}`;
28
+ }
29
+
30
+ function describeJson(value: Json): string {
31
+ if (typeof value === "string") {
32
+ const bytes = /^h'([0-9a-f]*)'$/.exec(value);
33
+ if (bytes) {
34
+ const hex = bytes[1] ?? "";
35
+ return `${shortHex(hex)} (${hex.length / 2} bytes)`;
36
+ }
37
+ return JSON.stringify(value);
38
+ }
39
+ if (value === null) return "null";
40
+ if (typeof value === "number" || typeof value === "boolean") {
41
+ return String(value);
42
+ }
43
+ if (Array.isArray(value)) {
44
+ if (value.length === 0) return "[]";
45
+ return `array of ${value.length}`;
46
+ }
47
+ return `map of ${Object.keys(value).length}`;
48
+ }
49
+
50
+ function labelPrefix(entry: DecodedHeaderEntry): string {
51
+ return entry.name !== null
52
+ ? `${entry.label} (${entry.name})`
53
+ : `${entry.label} (unknown label)`;
54
+ }
55
+
56
+ type Line = { depth: number; text: string; last?: boolean };
57
+
58
+ /** Render one unprotected 396 verifiable-proofs map as sub-lines. */
59
+ function proofLines(
60
+ value: Json,
61
+ receipt: DecodedReceipt,
62
+ depth: number,
63
+ ): Line[] {
64
+ const lines: Line[] = [];
65
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
66
+ lines.push({ depth, text: describeJson(value) });
67
+ return lines;
68
+ }
69
+ for (const [key, entry] of Object.entries(value)) {
70
+ const kind = PROOF_KIND_NAMES.get(Number(key));
71
+ const head = kind ? `${key} (${kind})` : `${key} (unknown proof kind)`;
72
+ if (Number(key) === -1) {
73
+ // Inclusion proofs — expand from the verified summary.
74
+ lines.push({ depth, text: `${head}: 1 entry` });
75
+ lines.push({
76
+ depth: depth + 1,
77
+ text: `1 (mmr index): ${receipt.inclusion.mmrIndex}`,
78
+ });
79
+ lines.push({
80
+ depth: depth + 1,
81
+ text: `2 (path): ${receipt.inclusion.pathLength} × 32-byte hashes`,
82
+ });
83
+ for (const [i, hash] of receipt.inclusion.path.entries()) {
84
+ lines.push({ depth: depth + 2, text: `[${i}] ${shortHex(hash)}` });
85
+ }
86
+ continue;
87
+ }
88
+ lines.push({ depth, text: `${head}: ${describeJson(entry)}` });
89
+ }
90
+ return lines;
91
+ }
92
+
93
+ function unprotectedLines(
94
+ entry: DecodedHeaderEntry,
95
+ receipt: DecodedReceipt,
96
+ depth: number,
97
+ ): Line[] {
98
+ if (entry.label === VERIFIABLE_PROOFS_LABEL) {
99
+ return [
100
+ { depth, text: `${labelPrefix(entry)}:` },
101
+ ...proofLines(entry.value, receipt, depth + 1),
102
+ ];
103
+ }
104
+ if (
105
+ entry.label === DELEGATION_CERT_LABEL &&
106
+ receipt.unprotected.delegation
107
+ ) {
108
+ const d = receipt.unprotected.delegation;
109
+ const nested = d.nestedCoseSign1
110
+ ? " — parses as a nested COSE_Sign1"
111
+ : "";
112
+ return [
113
+ {
114
+ depth,
115
+ text: `${labelPrefix(entry)}: ${d.byteLength} bytes${nested}`,
116
+ },
117
+ ];
118
+ }
119
+ if (
120
+ entry.label === SEAL_PEAK_RECEIPTS_LABEL &&
121
+ receipt.unprotected.peakReceipts
122
+ ) {
123
+ return [
124
+ {
125
+ depth,
126
+ text: `${labelPrefix(entry)}: ${receipt.unprotected.peakReceipts.count} receipts`,
127
+ },
128
+ ];
129
+ }
130
+ return [{ depth, text: `${labelPrefix(entry)}: ${describeJson(entry.value)}` }];
131
+ }
132
+
133
+ function protectedLines(
134
+ entry: DecodedHeaderEntry,
135
+ receipt: DecodedReceipt,
136
+ depth: number,
137
+ ): Line[] {
138
+ const p = receipt.protected;
139
+ if (entry.label === 1 && p.alg) {
140
+ const name = p.alg.name ?? "unknown alg";
141
+ return [{ depth, text: `1 (alg): ${p.alg.value} — ${name}` }];
142
+ }
143
+ if (entry.label === 4 && p.kid) {
144
+ const text =
145
+ "hex" in p.kid
146
+ ? `${shortHex(p.kid.hex)} (${p.kid.byteLength} bytes)`
147
+ : JSON.stringify(p.kid.text);
148
+ return [{ depth, text: `4 (kid): ${text}` }];
149
+ }
150
+ if (entry.label === VDS_LABEL && p.vds) {
151
+ const name = p.vds.name ?? "unknown verifiable data structure";
152
+ return [
153
+ {
154
+ depth,
155
+ text: `${VDS_LABEL} (verifiable data structure): ${p.vds.value} — ${name}`,
156
+ },
157
+ ];
158
+ }
159
+ if (entry.label === CWT_CLAIMS_LABEL && p.cwtClaims) {
160
+ const lines: Line[] = [{ depth, text: `15 (CWT claims):` }];
161
+ for (const claim of p.cwtClaims) {
162
+ const head = claim.name ? `${claim.key} (${claim.name})` : `${claim.key}`;
163
+ lines.push({
164
+ depth: depth + 1,
165
+ text: `${head}: ${describeJson(claim.value)}`,
166
+ });
167
+ if (
168
+ claim.value !== null &&
169
+ typeof claim.value === "object" &&
170
+ !Array.isArray(claim.value)
171
+ ) {
172
+ for (const [k, v] of Object.entries(claim.value)) {
173
+ lines.push({ depth: depth + 2, text: `${k}: ${describeJson(v)}` });
174
+ }
175
+ }
176
+ }
177
+ return lines;
178
+ }
179
+ return [{ depth, text: `${labelPrefix(entry)}: ${describeJson(entry.value)}` }];
180
+ }
181
+
182
+ /** Draw the annotated COSE tree with box-drawing connectors. */
183
+ function drawTree(lines: Line[]): string[] {
184
+ const out: string[] = [];
185
+ for (let i = 0; i < lines.length; i += 1) {
186
+ const line = lines[i]!;
187
+ if (line.depth === 0) {
188
+ out.push(line.text);
189
+ continue;
190
+ }
191
+ // A node is "last" among its siblings if no later line has its depth
192
+ // before a shallower one appears.
193
+ const isLast = (depth: number, from: number): boolean => {
194
+ for (let j = from + 1; j < lines.length; j += 1) {
195
+ const d = lines[j]!.depth;
196
+ if (d < depth) return true;
197
+ if (d === depth) return false;
198
+ }
199
+ return true;
200
+ };
201
+ let prefix = "";
202
+ for (let depth = 1; depth < line.depth; depth += 1) {
203
+ // Find the nearest ancestor line at `depth` at or before i.
204
+ let ancestor = -1;
205
+ for (let j = i; j >= 0; j -= 1) {
206
+ if (lines[j]!.depth === depth) {
207
+ ancestor = j;
208
+ break;
209
+ }
210
+ if (lines[j]!.depth < depth) break;
211
+ }
212
+ prefix += ancestor >= 0 && isLast(depth, ancestor) ? " " : "│ ";
213
+ }
214
+ prefix += isLast(line.depth, i) ? "└─ " : "├─ ";
215
+ out.push(prefix + line.text);
216
+ }
217
+ return out;
218
+ }
219
+
220
+ /** Render the receipt as the annotated human tree. */
221
+ export function renderReceipt(receipt: DecodedReceipt): string {
222
+ const tagText =
223
+ receipt.tag !== null
224
+ ? `tagged ${receipt.tag} (COSE_Sign1)`
225
+ : "untagged (tag 18 omitted)";
226
+ const lines: Line[] = [
227
+ {
228
+ depth: 0,
229
+ text: `COSE_Sign1 — ${tagText} — ${receipt.byteLength} bytes`,
230
+ },
231
+ ];
232
+
233
+ lines.push({
234
+ depth: 1,
235
+ text: `protected: ${receipt.protected.byteLength} bytes (CBOR map, covered by the signature)`,
236
+ });
237
+ if (receipt.protected.entries.length === 0) {
238
+ lines.push({ depth: 2, text: "(empty)" });
239
+ }
240
+ for (const entry of receipt.protected.entries) {
241
+ lines.push(...protectedLines(entry, receipt, 2));
242
+ }
243
+
244
+ lines.push({ depth: 1, text: "unprotected: (not covered by the signature)" });
245
+ if (receipt.unprotected.entries.length === 0) {
246
+ lines.push({ depth: 2, text: "(empty)" });
247
+ }
248
+ for (const entry of receipt.unprotected.entries) {
249
+ lines.push(...unprotectedLines(entry, receipt, 2));
250
+ }
251
+
252
+ if (receipt.payload.detached) {
253
+ lines.push({
254
+ depth: 1,
255
+ text: "payload: detached (nil) — the verifier recomputes the MMR peak from the inclusion path",
256
+ });
257
+ } else {
258
+ lines.push({
259
+ depth: 1,
260
+ text: `payload: ${receipt.payload.byteLength} bytes — MMR peak hash ${shortHex(receipt.payload.hex)}`,
261
+ });
262
+ }
263
+
264
+ lines.push({
265
+ depth: 1,
266
+ text: `signature: ${receipt.signature.byteLength} bytes — ${shortHex(receipt.signature.hex)}`,
267
+ });
268
+
269
+ const tree = drawTree(lines);
270
+
271
+ const inclusion = receipt.inclusion;
272
+ const peak =
273
+ inclusion.peakHex !== null
274
+ ? `${shortHex(inclusion.peakHex)} (from payload)`
275
+ : inclusion.peakSource;
276
+ return [
277
+ ...tree,
278
+ "",
279
+ "MMR inclusion",
280
+ ` mmr index: ${inclusion.mmrIndex}`,
281
+ ` path length: ${inclusion.pathLength}`,
282
+ ` peak: ${peak}`,
283
+ ].join("\n");
284
+ }
@@ -0,0 +1,289 @@
1
+ /**
2
+ * FOR-390 / ADR-0052 delegation flow: authorize a custodian-vouched sealer to
3
+ * publish checkpoints for a log the caller owns (K(L)), using only PUBLIC
4
+ * coordinator endpoints (no operator token, no RPC).
5
+ *
6
+ * Port of canopy's `signAdvanceDelegation` + `buildByokDelegationMaterial`
7
+ * composed on published `@forestrie/delegation-cose` + `@forestrie/encoding`:
8
+ *
9
+ * 1. Import the ES256 root PEM (K(L)) as a WebCrypto CryptoKeyPair.
10
+ * 2. GET the coordinator's pending-delegation entries; find the STANDING
11
+ * delegate-key entry (has `suggestedTtlSeconds`, no `mmrStart`).
12
+ * 3. Verify the registrar voucher against the caller-known registrar key
13
+ * (mandatory — fail closed).
14
+ * 4. Build the delegation certificate + on-chain delegation proof over the
15
+ * horizon `mmr 0..end`.
16
+ * 5. POST the certificate to the coordinator.
17
+ *
18
+ * Node-only (node:crypto PEM import). WebCrypto (`crypto.subtle`) does the
19
+ * ES256 signing inside delegation-cose.
20
+ */
21
+ import { createPrivateKey, createPublicKey } from "node:crypto";
22
+ import {
23
+ buildDelegationCertificateEs256,
24
+ decodeDelegatedCoseKeyFromBytes,
25
+ parseDelegatedCoseKeyFromPayload,
26
+ parseDelegationCertificate,
27
+ signOnchainDelegationEs256,
28
+ type DelegationInput,
29
+ } from "@forestrie/delegation-cose";
30
+ import { uuidToBytes } from "@forestrie/encoding";
31
+ import {
32
+ parseRegistrarKeyXY,
33
+ verifyDelegateKeyVoucher,
34
+ } from "./delegate-voucher.js";
35
+
36
+ /** Fixed inclusive MMR start for a horizon delegation lease. */
37
+ const MMR_START = 0;
38
+
39
+ function b64ToBytes(b64: string): Uint8Array {
40
+ return new Uint8Array(Buffer.from(b64, "base64"));
41
+ }
42
+
43
+ function bytesToB64(bytes: Uint8Array): string {
44
+ return Buffer.from(bytes).toString("base64");
45
+ }
46
+
47
+ /**
48
+ * A coordinator pending-delegation entry. The STANDING delegate-key entry
49
+ * (registered by C1/C3) carries `suggestedTtlSeconds` and no `mmrStart`; lease
50
+ * entries carry `mmrStart`.
51
+ */
52
+ export type StandingDelegationEntry = {
53
+ /** base64 CBOR EC2 P-256 COSE_Key for the delegated checkpoint signer. */
54
+ delegatedPublicKey: string;
55
+ /** Present on the standing entry only. */
56
+ suggestedTtlSeconds?: number;
57
+ /** Present on lease entries only. */
58
+ mmrStart?: number;
59
+ /** base64 COSE Sign1 registrar voucher. */
60
+ voucher?: string;
61
+ /** Sealer identity attested by the voucher. */
62
+ sealerId?: string;
63
+ /** Registrar key epoch attested by the voucher. */
64
+ epoch?: number | string;
65
+ };
66
+
67
+ /** Failure raised by the delegation flow (submit / verify / precondition). */
68
+ export class DelegateFlowError extends Error {
69
+ readonly httpStatus?: number;
70
+ /** Structured error code for `--json` reporting (default: delegation_failed). */
71
+ readonly code?: "key_read_failed" | "delegation_failed";
72
+ constructor(
73
+ message: string,
74
+ httpStatus?: number,
75
+ code?: "key_read_failed" | "delegation_failed",
76
+ ) {
77
+ super(message);
78
+ this.name = "DelegateFlowError";
79
+ if (httpStatus !== undefined) this.httpStatus = httpStatus;
80
+ if (code !== undefined) this.code = code;
81
+ }
82
+ }
83
+
84
+ export type DelegateFlowParams = {
85
+ /** Delegation coordinator origin, no trailing slash. */
86
+ coordinatorUrl: string;
87
+ /** Target log id (UUID). */
88
+ logId: string;
89
+ /** ES256 log-root PEM (K(L)) that authorizes the delegation. */
90
+ rootPem: string;
91
+ /** Pinned registrar key, base64 `x||y` (64 bytes). */
92
+ knownSealerKey: string;
93
+ /** Exclusive MMR end of the horizon lease (mmrStart is fixed 0). */
94
+ horizonMmrEnd: number;
95
+ /** Lease TTL seconds; defaults to the standing entry's suggestedTtlSeconds. */
96
+ ttlSeconds?: number | undefined;
97
+ };
98
+
99
+ export type DelegateFlowDeps = {
100
+ fetchImpl?: typeof fetch;
101
+ };
102
+
103
+ export type DelegateFlowResult = {
104
+ sealerId: string;
105
+ epoch: number | string;
106
+ mmrStart: number;
107
+ mmrEnd: number;
108
+ /** base64 delegated public key COSE_Key passed through to the coordinator. */
109
+ delegatedPublicKey: string;
110
+ expiresAt: number;
111
+ certificate: Uint8Array;
112
+ };
113
+
114
+ /**
115
+ * Import an ES256 PKCS#8/SEC1 PEM as a WebCrypto CryptoKeyPair (private for
116
+ * signing, public for verification). Mirrors canopy's `importEs256PemKeyPair`.
117
+ */
118
+ async function importEs256PemKeyPair(pem: string): Promise<CryptoKeyPair> {
119
+ const priv = createPrivateKey({ key: pem, format: "pem" });
120
+ const pkcs8 = priv.export({ format: "der", type: "pkcs8" });
121
+ const privateKey = await crypto.subtle.importKey(
122
+ "pkcs8",
123
+ pkcs8,
124
+ { name: "ECDSA", namedCurve: "P-256" },
125
+ false,
126
+ ["sign"],
127
+ );
128
+ // Derive the public key from the private PEM (round-trip through PEM keeps
129
+ // the @types/node overloads happy — createPublicKey(KeyObject) is untyped
130
+ // in this toolchain).
131
+ const pubPem = priv.export({ format: "pem", type: "pkcs8" }) as string;
132
+ const spki = createPublicKey({ key: pubPem, format: "pem" }).export({
133
+ format: "der",
134
+ type: "spki",
135
+ });
136
+ const publicKey = await crypto.subtle.importKey(
137
+ "spki",
138
+ spki,
139
+ { name: "ECDSA", namedCurve: "P-256" },
140
+ true,
141
+ ["verify"],
142
+ );
143
+ return { privateKey, publicKey };
144
+ }
145
+
146
+ /**
147
+ * Run the public delegation flow end-to-end, returning the submitted
148
+ * certificate and the bound lease scope.
149
+ */
150
+ export async function runDelegateFlow(
151
+ params: DelegateFlowParams,
152
+ deps: DelegateFlowDeps = {},
153
+ ): Promise<DelegateFlowResult> {
154
+ const fetchImpl = deps.fetchImpl ?? fetch;
155
+
156
+ // 1. Root key pair (K(L)). A malformed or wrong-curve PEM must surface as a
157
+ // structured error (not an unhandled throw that leaks the key path / stack).
158
+ let rootKeyPair: CryptoKeyPair;
159
+ try {
160
+ rootKeyPair = await importEs256PemKeyPair(params.rootPem);
161
+ } catch {
162
+ throw new DelegateFlowError(
163
+ "--sign-with is not a usable ES256 (P-256) root PEM (K(L))",
164
+ undefined,
165
+ "key_read_failed",
166
+ );
167
+ }
168
+
169
+ // 2. Fetch pending delegation entries and find the standing entry.
170
+ const pendingUrl = `${params.coordinatorUrl}/api/logs/${params.logId}/pending-delegation`;
171
+ const pendingRes = await fetchImpl(pendingUrl);
172
+ if (!pendingRes.ok) {
173
+ const preview = (await pendingRes.text()).slice(0, 200);
174
+ throw new DelegateFlowError(
175
+ `pending-delegation fetch failed: HTTP ${pendingRes.status} ${preview}`,
176
+ pendingRes.status,
177
+ );
178
+ }
179
+ const pending = (await pendingRes.json()) as {
180
+ entries?: StandingDelegationEntry[];
181
+ };
182
+ const entries = pending.entries ?? [];
183
+ const standing = entries.find(
184
+ (e) => e.suggestedTtlSeconds !== undefined && e.mmrStart === undefined,
185
+ );
186
+ if (standing === undefined) {
187
+ throw new DelegateFlowError(
188
+ "no standing delegate-key entry for log — register a public root and a sealer delegate key first (C1/C3)",
189
+ );
190
+ }
191
+
192
+ // 3. Verify the registrar voucher (mandatory — fail closed).
193
+ if (
194
+ standing.voucher === undefined ||
195
+ standing.sealerId === undefined ||
196
+ standing.epoch === undefined
197
+ ) {
198
+ throw new DelegateFlowError(
199
+ "standing entry is missing its registrar voucher — refusing to bind",
200
+ );
201
+ }
202
+ const pinned = parseRegistrarKeyXY(b64ToBytes(params.knownSealerKey));
203
+ if (pinned === null) {
204
+ throw new DelegateFlowError(
205
+ "known-sealer-key must be base64 x||y (64 bytes)",
206
+ );
207
+ }
208
+ const voucherResult = await verifyDelegateKeyVoucher(
209
+ b64ToBytes(standing.voucher),
210
+ pinned,
211
+ {
212
+ sealerId: standing.sealerId,
213
+ epoch: standing.epoch,
214
+ publicKey: b64ToBytes(standing.delegatedPublicKey),
215
+ },
216
+ );
217
+ if (!voucherResult.ok) {
218
+ throw new DelegateFlowError(
219
+ `registrar voucher failed verification (${voucherResult.reason}) — refusing to bind`,
220
+ );
221
+ }
222
+
223
+ // 4. Build the delegation certificate + on-chain proof over mmr 0..end.
224
+ const mmrStart = MMR_START;
225
+ const mmrEnd = params.horizonMmrEnd;
226
+ const logIdHex32 = Buffer.from(uuidToBytes(params.logId)).toString("hex");
227
+ const delegatedPublicKeyBytes = b64ToBytes(standing.delegatedPublicKey);
228
+
229
+ const ttlSeconds = params.ttlSeconds ?? standing.suggestedTtlSeconds;
230
+ const certInput: DelegationInput = {
231
+ logIdHex32,
232
+ mmrStart,
233
+ mmrEnd,
234
+ delegatedPublicKeyCbor: delegatedPublicKeyBytes,
235
+ };
236
+ if (ttlSeconds !== undefined) certInput.ttlSeconds = ttlSeconds;
237
+ const certificate = await buildDelegationCertificateEs256(
238
+ certInput,
239
+ rootKeyPair,
240
+ );
241
+ const info = parseDelegationCertificate(certificate);
242
+ const delegated = parseDelegatedCoseKeyFromPayload(
243
+ decodeDelegatedCoseKeyFromBytes(delegatedPublicKeyBytes),
244
+ );
245
+ const onchainProof = await signOnchainDelegationEs256(
246
+ {
247
+ logIdHex: logIdHex32,
248
+ mmrStart,
249
+ mmrEnd,
250
+ delegatedKeyX: delegated.x,
251
+ delegatedKeyY: delegated.y,
252
+ },
253
+ rootKeyPair,
254
+ );
255
+
256
+ // 5. Submit the certificate to the coordinator.
257
+ const submitUrl = `${params.coordinatorUrl}/api/delegations/certificate`;
258
+ const submitRes = await fetchImpl(submitUrl, {
259
+ method: "POST",
260
+ headers: { "Content-Type": "application/json" },
261
+ body: JSON.stringify({
262
+ logId: params.logId,
263
+ mmrStart,
264
+ mmrEnd,
265
+ delegatedPublicKey: standing.delegatedPublicKey,
266
+ certificate: bytesToB64(certificate),
267
+ issuedAt: info.issuedAt,
268
+ expiresAt: info.expiresAt,
269
+ onchainSignature: bytesToB64(onchainProof.signature),
270
+ }),
271
+ });
272
+ if (!submitRes.ok) {
273
+ const preview = (await submitRes.text()).slice(0, 200);
274
+ throw new DelegateFlowError(
275
+ `certificate submit failed: HTTP ${submitRes.status} ${preview}`,
276
+ submitRes.status,
277
+ );
278
+ }
279
+
280
+ return {
281
+ sealerId: standing.sealerId,
282
+ epoch: standing.epoch,
283
+ mmrStart,
284
+ mmrEnd,
285
+ delegatedPublicKey: standing.delegatedPublicKey,
286
+ expiresAt: info.expiresAt,
287
+ certificate,
288
+ };
289
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * FOR-390 / ADR-0052 registrar delegate-key voucher verification, composed
3
+ * from published `@forestrie/encoding` primitives.
4
+ *
5
+ * A voucher is a COSE Sign1 signed by the known registrar key over a CBOR
6
+ * claims map `{1: sealerId, 2: epoch, 3: publicKey}`. It binds a sealer's
7
+ * delegated checkpoint-signing key to a registrar-attested identity; the CLI
8
+ * refuses to bind a delegation certificate to a delegate key whose voucher
9
+ * does not verify against the caller-known registrar key.
10
+ *
11
+ * This mirrors canopy `@forestrie/encoding`'s `verify-delegate-key-voucher.ts`.
12
+ * The published encoding v0.3.0 does NOT yet export
13
+ * `verifyDelegateKeyVoucher` / `parseRegistrarKeyXY`, so they are composed here
14
+ * from `decodeCoseSign1`, `verifyCoseSign1WithParsedKey`, and
15
+ * `decodeCborDeterministic`. Adopt the published helper once encoding ships it.
16
+ */
17
+ import {
18
+ decodeCborDeterministic,
19
+ decodeCoseSign1,
20
+ verifyCoseSign1WithParsedKey,
21
+ type ParsedEcPublicKey,
22
+ } from "@forestrie/encoding";
23
+
24
+ const REGISTRAR_KEY_XY_BYTES = 64;
25
+ const P256_COORD_BYTES = 32;
26
+
27
+ /** Claims map labels in the registrar voucher payload. */
28
+ const CLAIM_SEALER_ID = 1;
29
+ const CLAIM_EPOCH = 2;
30
+ const CLAIM_PUBLIC_KEY = 3;
31
+
32
+ /**
33
+ * Parse a raw registrar key (uncompressed `x||y`, 64 bytes) into a
34
+ * {@link ParsedEcPublicKey}. Returns null when the input is not 64 bytes.
35
+ */
36
+ export function parseRegistrarKeyXY(raw: Uint8Array): ParsedEcPublicKey | null {
37
+ if (raw.length !== REGISTRAR_KEY_XY_BYTES) return null;
38
+ return {
39
+ x: raw.slice(0, P256_COORD_BYTES),
40
+ y: raw.slice(P256_COORD_BYTES),
41
+ curve: "P-256",
42
+ };
43
+ }
44
+
45
+ /** Claims the voucher must attest to for the binding to proceed. */
46
+ export type VoucherExpectation = {
47
+ /** Sealer identity (claim 1). */
48
+ sealerId: string;
49
+ /** Registrar key epoch (claim 2); a JSON string epoch is parsed to bigint. */
50
+ epoch: bigint | number | string;
51
+ /** Delegated public key CBOR bytes (claim 3). */
52
+ publicKey: Uint8Array;
53
+ };
54
+
55
+ /** Voucher verification outcome (fail-closed with a machine reason). */
56
+ export type VoucherResult = { ok: true } | { ok: false; reason: string };
57
+
58
+ /** Constant-time byte comparison (no early exit on first mismatch). */
59
+ function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
60
+ if (a.length !== b.length) return false;
61
+ let diff = 0;
62
+ for (let i = 0; i < a.length; i++) {
63
+ diff |= a[i]! ^ b[i]!;
64
+ }
65
+ return diff === 0;
66
+ }
67
+
68
+ function toBigIntOrNull(value: unknown): bigint | null {
69
+ if (typeof value === "bigint") return value;
70
+ if (typeof value === "number" && Number.isInteger(value)) return BigInt(value);
71
+ if (typeof value === "string" && /^-?\d+$/.test(value)) return BigInt(value);
72
+ return null;
73
+ }
74
+
75
+ /**
76
+ * Verify a registrar delegate-key voucher:
77
+ * 1. COSE Sign1 signature verifies against the known registrar key.
78
+ * 2. The payload decodes to a CBOR claims map.
79
+ * 3. Claims 1/2/3 (sealerId / epoch / publicKey) match {@link expect}, with a
80
+ * constant-time compare on the public key bytes.
81
+ *
82
+ * @returns `{ok:true}` when everything matches, otherwise `{ok:false, reason}`.
83
+ */
84
+ export async function verifyDelegateKeyVoucher(
85
+ voucherBytes: Uint8Array,
86
+ pinnedKey: ParsedEcPublicKey,
87
+ expect: VoucherExpectation,
88
+ ): Promise<VoucherResult> {
89
+ // 1. Signature against the known registrar key.
90
+ const sigOk = await verifyCoseSign1WithParsedKey(voucherBytes, pinnedKey);
91
+ if (!sigOk) return { ok: false, reason: "signature" };
92
+
93
+ // 2. Decode envelope + claims map.
94
+ const decoded = decodeCoseSign1(voucherBytes);
95
+ if (decoded === null) return { ok: false, reason: "decode" };
96
+ let claims: unknown;
97
+ try {
98
+ claims = decodeCborDeterministic(decoded.payloadBstr);
99
+ } catch {
100
+ return { ok: false, reason: "decode" };
101
+ }
102
+ if (!(claims instanceof Map)) return { ok: false, reason: "decode" };
103
+
104
+ // 3. Claim-by-claim comparison.
105
+ const sealerId = claims.get(CLAIM_SEALER_ID);
106
+ if (typeof sealerId !== "string" || sealerId !== expect.sealerId) {
107
+ return { ok: false, reason: "sealerId" };
108
+ }
109
+ const epoch = toBigIntOrNull(claims.get(CLAIM_EPOCH));
110
+ const expectedEpoch = toBigIntOrNull(expect.epoch);
111
+ if (epoch === null || expectedEpoch === null || epoch !== expectedEpoch) {
112
+ return { ok: false, reason: "epoch" };
113
+ }
114
+ const publicKey = claims.get(CLAIM_PUBLIC_KEY);
115
+ if (
116
+ !(publicKey instanceof Uint8Array) ||
117
+ !constantTimeEqual(publicKey, expect.publicKey)
118
+ ) {
119
+ return { ok: false, reason: "publicKey" };
120
+ }
121
+ return { ok: true };
122
+ }