@forestrie/mcp-register 0.0.0-stage → 0.1.1

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 (139) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +178 -2
  3. package/bin/mcp-register.mjs +2 -0
  4. package/dist/core/base64.d.ts +7 -0
  5. package/dist/core/base64.d.ts.map +1 -0
  6. package/dist/core/base64.js +20 -0
  7. package/dist/core/base64.js.map +1 -0
  8. package/dist/core/delegation.d.ts +56 -0
  9. package/dist/core/delegation.d.ts.map +1 -0
  10. package/dist/core/delegation.js +130 -0
  11. package/dist/core/delegation.js.map +1 -0
  12. package/dist/core/grant.d.ts +41 -0
  13. package/dist/core/grant.d.ts.map +1 -0
  14. package/dist/core/grant.js +78 -0
  15. package/dist/core/grant.js.map +1 -0
  16. package/dist/core/hex.d.ts +4 -0
  17. package/dist/core/hex.d.ts.map +1 -0
  18. package/dist/core/hex.js +18 -0
  19. package/dist/core/hex.js.map +1 -0
  20. package/dist/core/index.d.ts +28 -0
  21. package/dist/core/index.d.ts.map +1 -0
  22. package/dist/core/index.js +21 -0
  23. package/dist/core/index.js.map +1 -0
  24. package/dist/core/issue.d.ts +38 -0
  25. package/dist/core/issue.d.ts.map +1 -0
  26. package/dist/core/issue.js +135 -0
  27. package/dist/core/issue.js.map +1 -0
  28. package/dist/core/limits.d.ts +9 -0
  29. package/dist/core/limits.d.ts.map +1 -0
  30. package/dist/core/limits.js +9 -0
  31. package/dist/core/limits.js.map +1 -0
  32. package/dist/core/scrapi.d.ts +66 -0
  33. package/dist/core/scrapi.d.ts.map +1 -0
  34. package/dist/core/scrapi.js +174 -0
  35. package/dist/core/scrapi.js.map +1 -0
  36. package/dist/core/signer.d.ts +24 -0
  37. package/dist/core/signer.d.ts.map +1 -0
  38. package/dist/core/signer.js +2 -0
  39. package/dist/core/signer.js.map +1 -0
  40. package/dist/core/statement.d.ts +26 -0
  41. package/dist/core/statement.d.ts.map +1 -0
  42. package/dist/core/statement.js +70 -0
  43. package/dist/core/statement.js.map +1 -0
  44. package/dist/core/version.d.ts +23 -0
  45. package/dist/core/version.d.ts.map +1 -0
  46. package/dist/core/version.js +23 -0
  47. package/dist/core/version.js.map +1 -0
  48. package/dist/core/x402.d.ts +99 -0
  49. package/dist/core/x402.d.ts.map +1 -0
  50. package/dist/core/x402.js +202 -0
  51. package/dist/core/x402.js.map +1 -0
  52. package/dist/net/delegate.d.ts +56 -0
  53. package/dist/net/delegate.d.ts.map +1 -0
  54. package/dist/net/delegate.js +182 -0
  55. package/dist/net/delegate.js.map +1 -0
  56. package/dist/net/http.d.ts +17 -0
  57. package/dist/net/http.d.ts.map +1 -0
  58. package/dist/net/http.js +52 -0
  59. package/dist/net/http.js.map +1 -0
  60. package/dist/net/index.d.ts +14 -0
  61. package/dist/net/index.d.ts.map +1 -0
  62. package/dist/net/index.js +13 -0
  63. package/dist/net/index.js.map +1 -0
  64. package/dist/net/write.d.ts +55 -0
  65. package/dist/net/write.d.ts.map +1 -0
  66. package/dist/net/write.js +46 -0
  67. package/dist/net/write.js.map +1 -0
  68. package/dist/node/cli.d.ts +8 -0
  69. package/dist/node/cli.d.ts.map +1 -0
  70. package/dist/node/cli.js +143 -0
  71. package/dist/node/cli.js.map +1 -0
  72. package/dist/node/grant-store.d.ts +49 -0
  73. package/dist/node/grant-store.d.ts.map +1 -0
  74. package/dist/node/grant-store.js +95 -0
  75. package/dist/node/grant-store.js.map +1 -0
  76. package/dist/node/grant-tools.d.ts +29 -0
  77. package/dist/node/grant-tools.d.ts.map +1 -0
  78. package/dist/node/grant-tools.js +498 -0
  79. package/dist/node/grant-tools.js.map +1 -0
  80. package/dist/node/ledger.d.ts +26 -0
  81. package/dist/node/ledger.d.ts.map +1 -0
  82. package/dist/node/ledger.js +78 -0
  83. package/dist/node/ledger.js.map +1 -0
  84. package/dist/node/payer.d.ts +48 -0
  85. package/dist/node/payer.d.ts.map +1 -0
  86. package/dist/node/payer.js +170 -0
  87. package/dist/node/payer.js.map +1 -0
  88. package/dist/node/resolve-input.d.ts +19 -0
  89. package/dist/node/resolve-input.d.ts.map +1 -0
  90. package/dist/node/resolve-input.js +76 -0
  91. package/dist/node/resolve-input.js.map +1 -0
  92. package/dist/node/server.d.ts +47 -0
  93. package/dist/node/server.d.ts.map +1 -0
  94. package/dist/node/server.js +212 -0
  95. package/dist/node/server.js.map +1 -0
  96. package/dist/node/signer-file.d.ts +34 -0
  97. package/dist/node/signer-file.d.ts.map +1 -0
  98. package/dist/node/signer-file.js +183 -0
  99. package/dist/node/signer-file.js.map +1 -0
  100. package/dist/node/store.d.ts +40 -0
  101. package/dist/node/store.d.ts.map +1 -0
  102. package/dist/node/store.js +117 -0
  103. package/dist/node/store.js.map +1 -0
  104. package/dist/node/text.d.ts +29 -0
  105. package/dist/node/text.d.ts.map +1 -0
  106. package/dist/node/text.js +124 -0
  107. package/dist/node/text.js.map +1 -0
  108. package/dist/node/tools.d.ts +118 -0
  109. package/dist/node/tools.d.ts.map +1 -0
  110. package/dist/node/tools.js +548 -0
  111. package/dist/node/tools.js.map +1 -0
  112. package/package.json +108 -3
  113. package/src/core/base64.ts +18 -0
  114. package/src/core/delegation.ts +239 -0
  115. package/src/core/grant.ts +127 -0
  116. package/src/core/hex.ts +18 -0
  117. package/src/core/index.ts +84 -0
  118. package/src/core/issue.ts +209 -0
  119. package/src/core/limits.ts +8 -0
  120. package/src/core/scrapi.ts +236 -0
  121. package/src/core/signer.ts +23 -0
  122. package/src/core/statement.ts +133 -0
  123. package/src/core/version.ts +25 -0
  124. package/src/core/x402.ts +314 -0
  125. package/src/net/delegate.ts +274 -0
  126. package/src/net/http.ts +71 -0
  127. package/src/net/index.ts +26 -0
  128. package/src/net/write.ts +124 -0
  129. package/src/node/cli.ts +170 -0
  130. package/src/node/grant-store.ts +154 -0
  131. package/src/node/grant-tools.ts +707 -0
  132. package/src/node/ledger.ts +98 -0
  133. package/src/node/payer.ts +241 -0
  134. package/src/node/resolve-input.ts +96 -0
  135. package/src/node/server.ts +332 -0
  136. package/src/node/signer-file.ts +251 -0
  137. package/src/node/store.ts +175 -0
  138. package/src/node/text.ts +153 -0
  139. package/src/node/tools.ts +810 -0
@@ -0,0 +1,332 @@
1
+ /**
2
+ * `createServer(deps?)` — the MCP adapter. No transport: the caller connects
3
+ * one (stdio from `cli.ts`, `InMemoryTransport` from the smoke test), which
4
+ * is also what lets an embedder mount tools on their own transport without
5
+ * the CLI.
6
+ *
7
+ * Tools register from `TOOL_NAMES` and prompts from `PROMPT_NAMES`
8
+ * (`text.ts`), in that order.
9
+ *
10
+ * The signers are seams: pass `deps.signer` (the log key) or
11
+ * `deps.authoritySigner` (the requester's authority key) to use a KMS or a
12
+ * remote signer. Without them, the defaults open local key files
13
+ * (`MCP_REGISTER_KEY_FILE`, else `log-key.jwk` in the home;
14
+ * `MCP_REGISTER_AUTHORITY_KEY_FILE`, else `authority-key.jwk`) at the first
15
+ * tool call that needs each, creating it then if it does not exist.
16
+ *
17
+ * The payer is a seam too: pass `deps.payer` for a hosted wallet. Without
18
+ * it, `MCP_REGISTER_PAYER_KEY_FILE` names a local EVM key file; with
19
+ * neither, register_grant runs in relay mode. The payer is never created.
20
+ */
21
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
22
+ import { z } from "zod";
23
+ import { PACKAGE_VERSION } from "../core/index.js";
24
+ import type { Es256Signer } from "../core/signer.js";
25
+ import { join } from "node:path";
26
+ import { GrantStore } from "./grant-store.js";
27
+ import {
28
+ issueGrantInputShape,
29
+ makeIssueGrantTool,
30
+ makeRegisterGrantTool,
31
+ registerGrantInputShape,
32
+ } from "./grant-tools.js";
33
+ import { SpendLedger } from "./ledger.js";
34
+ import { openKeyFilePayer, type PayerBackend } from "./payer.js";
35
+ import {
36
+ openKeyFileSigner,
37
+ registerHome,
38
+ type KeyRole,
39
+ } from "./signer-file.js";
40
+ import { RegistrationStore } from "./store.js";
41
+ import {
42
+ INSTRUCTIONS,
43
+ PROMPT_DESCRIPTIONS,
44
+ PROMPT_NAMES,
45
+ PROMPT_TITLES,
46
+ TOOL_DESCRIPTIONS,
47
+ TOOL_NAMES,
48
+ TOOL_TITLES,
49
+ auditPromptText,
50
+ issueAndPayPromptText,
51
+ registerPromptText,
52
+ } from "./text.js";
53
+ import {
54
+ checkRegistrationInputShape,
55
+ delegateSealingInputShape,
56
+ getLogKeyInputShape,
57
+ makeCheckRegistrationTool,
58
+ makeDelegateSealingTool,
59
+ makeGetLogKeyTool,
60
+ makeRegisterStatementTool,
61
+ makeVerifyRegistrationTool,
62
+ registerStatementInputShape,
63
+ verifyRegistrationInputShape,
64
+ type ResolvedDeps,
65
+ } from "./tools.js";
66
+
67
+ export const SERVER_NAME = "forestrie-mcp-register";
68
+
69
+ export type { Es256Signer } from "../core/signer.js";
70
+ export { openKeyFileSigner, publicKeyPem } from "./signer-file.js";
71
+ export { evmPayer, openKeyFilePayer, readPaymentCaps } from "./payer.js";
72
+ export type { PayerBackend } from "./payer.js";
73
+
74
+ /** Every field optional; defaults to `process.env` / `globalThis.fetch` /
75
+ * `() => new Date()` / the local key file, so tests can inject fakes
76
+ * without touching the real network, environment or home directory. */
77
+ export type Deps = {
78
+ fetchImpl?: typeof fetch;
79
+ env?: Record<string, string | undefined>;
80
+ now?: () => Date;
81
+ signer?: Es256Signer;
82
+ /** The requester's authority key; defaults to a local key file. */
83
+ authoritySigner?: Es256Signer;
84
+ /** Pays canopy's 402; defaults to MCP_REGISTER_PAYER_KEY_FILE, else none
85
+ * (relay mode). */
86
+ payer?: PayerBackend;
87
+ /** Where registrations are kept; defaults to MCP_REGISTER_HOME. */
88
+ home?: string;
89
+ sleep?: (ms: number) => Promise<void>;
90
+ };
91
+
92
+ /** Writes to the operator's log and to the local store; never destroys. */
93
+ const WRITE = {
94
+ readOnlyHint: false,
95
+ destructiveHint: false,
96
+ idempotentHint: false,
97
+ openWorldHint: true,
98
+ } as const;
99
+
100
+ export function createServer(deps: Deps = {}): McpServer {
101
+ const env = deps.env ?? process.env;
102
+ const home = deps.home ?? registerHome(env);
103
+ const keyFile = env["MCP_REGISTER_KEY_FILE"] ?? join(home, "log-key.jwk");
104
+ const authorityKeyFile =
105
+ env["MCP_REGISTER_AUTHORITY_KEY_FILE"] ?? join(home, "authority-key.jwk");
106
+ const payerKeyFile = env["MCP_REGISTER_PAYER_KEY_FILE"];
107
+
108
+ /** A lazily opened key-file signer, unless the embedder passed one. */
109
+ const lazySigner = (
110
+ path: string,
111
+ role: KeyRole,
112
+ given: Es256Signer | undefined,
113
+ ): ResolvedDeps["signer"] => {
114
+ let opened: Promise<Es256Signer & { created?: boolean }> | undefined;
115
+ const open = (create: boolean) =>
116
+ openKeyFileSigner(path, { create, role }).catch((err: unknown) => {
117
+ opened = undefined;
118
+ throw err;
119
+ });
120
+ return (options) => {
121
+ if (given !== undefined) return Promise.resolve(given);
122
+ if (opened !== undefined) return opened;
123
+ // A no-create open is not remembered: a later call may create.
124
+ if (options?.create === false) return open(false);
125
+ return (opened = open(true));
126
+ };
127
+ };
128
+
129
+ let payer: PayerBackend | undefined = deps.payer;
130
+ const resolved: ResolvedDeps = {
131
+ fetchImpl: deps.fetchImpl ?? ((...a) => globalThis.fetch(...a)),
132
+ env,
133
+ now: deps.now ?? (() => new Date()),
134
+ signer: lazySigner(keyFile, "log", deps.signer),
135
+ authoritySigner: lazySigner(
136
+ authorityKeyFile,
137
+ "authority",
138
+ deps.authoritySigner,
139
+ ),
140
+ payer: () => {
141
+ if (payer === undefined && payerKeyFile)
142
+ payer = openKeyFilePayer(payerKeyFile);
143
+ return payer;
144
+ },
145
+ protectedPaths: [
146
+ keyFile,
147
+ authorityKeyFile,
148
+ ...(payerKeyFile ? [payerKeyFile] : []),
149
+ home,
150
+ ],
151
+ store: new RegistrationStore(home),
152
+ grants: new GrantStore(home),
153
+ ledger: new SpendLedger(home),
154
+ ...(deps.sleep !== undefined ? { sleep: deps.sleep } : {}),
155
+ };
156
+
157
+ const server = new McpServer(
158
+ { name: SERVER_NAME, version: PACKAGE_VERSION },
159
+ { instructions: INSTRUCTIONS },
160
+ );
161
+
162
+ server.registerTool(
163
+ TOOL_NAMES[0],
164
+ {
165
+ title: TOOL_TITLES.get_log_key,
166
+ description: TOOL_DESCRIPTIONS.get_log_key,
167
+ inputSchema: getLogKeyInputShape,
168
+ // May create the key file on first use; otherwise reads it.
169
+ annotations: {
170
+ readOnlyHint: false,
171
+ destructiveHint: false,
172
+ idempotentHint: true,
173
+ openWorldHint: false,
174
+ },
175
+ },
176
+ makeGetLogKeyTool(resolved),
177
+ );
178
+
179
+ server.registerTool(
180
+ TOOL_NAMES[1],
181
+ {
182
+ title: TOOL_TITLES.register_statement,
183
+ description: TOOL_DESCRIPTIONS.register_statement,
184
+ inputSchema: registerStatementInputShape,
185
+ annotations: WRITE,
186
+ },
187
+ makeRegisterStatementTool(resolved),
188
+ );
189
+
190
+ server.registerTool(
191
+ TOOL_NAMES[2],
192
+ {
193
+ title: TOOL_TITLES.check_registration,
194
+ description: TOOL_DESCRIPTIONS.check_registration,
195
+ inputSchema: checkRegistrationInputShape,
196
+ // Reads canopy; saves the receipt locally once sealed.
197
+ annotations: {
198
+ readOnlyHint: false,
199
+ destructiveHint: false,
200
+ idempotentHint: true,
201
+ openWorldHint: true,
202
+ },
203
+ },
204
+ makeCheckRegistrationTool(resolved),
205
+ );
206
+
207
+ server.registerTool(
208
+ TOOL_NAMES[3],
209
+ {
210
+ title: TOOL_TITLES.verify_registration,
211
+ description: TOOL_DESCRIPTIONS.verify_registration,
212
+ inputSchema: verifyRegistrationInputShape,
213
+ // Offline: reads the local store and the key's public half only; never
214
+ // creates a key.
215
+ annotations: {
216
+ readOnlyHint: true,
217
+ destructiveHint: false,
218
+ idempotentHint: true,
219
+ openWorldHint: false,
220
+ },
221
+ },
222
+ makeVerifyRegistrationTool(resolved),
223
+ );
224
+
225
+ server.registerTool(
226
+ TOOL_NAMES[4],
227
+ {
228
+ title: TOOL_TITLES.delegate_sealing,
229
+ description: TOOL_DESCRIPTIONS.delegate_sealing,
230
+ inputSchema: delegateSealingInputShape,
231
+ annotations: WRITE,
232
+ },
233
+ makeDelegateSealingTool(resolved),
234
+ );
235
+
236
+ server.registerTool(
237
+ TOOL_NAMES[5],
238
+ {
239
+ title: TOOL_TITLES.issue_grant,
240
+ description: TOOL_DESCRIPTIONS.issue_grant,
241
+ inputSchema: issueGrantInputShape,
242
+ // Signs and saves locally; sends nothing.
243
+ annotations: {
244
+ readOnlyHint: false,
245
+ destructiveHint: false,
246
+ idempotentHint: false,
247
+ openWorldHint: false,
248
+ },
249
+ },
250
+ makeIssueGrantTool(resolved),
251
+ );
252
+
253
+ server.registerTool(
254
+ TOOL_NAMES[6],
255
+ {
256
+ title: TOOL_TITLES.register_grant,
257
+ description: TOOL_DESCRIPTIONS.register_grant,
258
+ inputSchema: registerGrantInputShape,
259
+ // Writes to the operator's log, and may spend from the payer.
260
+ annotations: WRITE,
261
+ },
262
+ makeRegisterGrantTool(resolved),
263
+ );
264
+
265
+ server.registerPrompt(
266
+ PROMPT_NAMES[0],
267
+ {
268
+ title: PROMPT_TITLES.register,
269
+ description: PROMPT_DESCRIPTIONS.register,
270
+ argsSchema: {
271
+ what: z
272
+ .string()
273
+ .optional()
274
+ .describe("what to register, in a few words (optional)"),
275
+ },
276
+ },
277
+ (args) => ({
278
+ messages: [
279
+ {
280
+ role: "user",
281
+ content: { type: "text", text: registerPromptText(args) },
282
+ },
283
+ ],
284
+ }),
285
+ );
286
+
287
+ server.registerPrompt(
288
+ PROMPT_NAMES[1],
289
+ {
290
+ title: PROMPT_TITLES.audit,
291
+ description: PROMPT_DESCRIPTIONS.audit,
292
+ argsSchema: {
293
+ contentHash: z
294
+ .string()
295
+ .optional()
296
+ .describe("the registration to audit (optional)"),
297
+ },
298
+ },
299
+ (args) => ({
300
+ messages: [
301
+ {
302
+ role: "user",
303
+ content: { type: "text", text: auditPromptText(args) },
304
+ },
305
+ ],
306
+ }),
307
+ );
308
+
309
+ server.registerPrompt(
310
+ PROMPT_NAMES[2],
311
+ {
312
+ title: PROMPT_TITLES["issue-and-pay"],
313
+ description: PROMPT_DESCRIPTIONS["issue-and-pay"],
314
+ argsSchema: {
315
+ purpose: z
316
+ .string()
317
+ .optional()
318
+ .describe("what the grant is for, in a few words (optional)"),
319
+ },
320
+ },
321
+ (args) => ({
322
+ messages: [
323
+ {
324
+ role: "user",
325
+ content: { type: "text", text: issueAndPayPromptText(args) },
326
+ },
327
+ ],
328
+ }),
329
+ );
330
+
331
+ return server;
332
+ }
@@ -0,0 +1,251 @@
1
+ /**
2
+ * The default signer backend: an ES256 (P-256) private key in a local JWK
3
+ * file, created on first use.
4
+ *
5
+ * Proportionate custody for what the key can do: it owns one data log
6
+ * whose grant someone else issued and bounded, and it holds no funds. The
7
+ * file is written once, `0600` in a `0700` directory, atomically (written
8
+ * aside, then hard-linked into place), so two servers starting together
9
+ * cannot both write one and neither can read a half-written one. It is
10
+ * never overwritten: a key that signed a grant's `grantData` must not
11
+ * silently change under it.
12
+ *
13
+ * The private key is imported non-extractable for signing; nothing in this
14
+ * package reads the `d` field again after import, and no tool result or
15
+ * log line carries it.
16
+ */
17
+ import { webcrypto } from "node:crypto";
18
+ import {
19
+ linkSync,
20
+ lstatSync,
21
+ mkdirSync,
22
+ readFileSync,
23
+ unlinkSync,
24
+ writeFileSync,
25
+ } from "node:fs";
26
+ import { homedir } from "node:os";
27
+ import { dirname, join } from "node:path";
28
+ import type { Es256Signer } from "../core/signer.js";
29
+
30
+ const subtle = webcrypto.subtle;
31
+
32
+ export class KeyFileError extends Error {
33
+ readonly code = "signer_unavailable" as const;
34
+ }
35
+
36
+ /** `$MCP_REGISTER_HOME`, else `$XDG_CONFIG_HOME/forestrie/mcp-register`, else `~/.config/…`. */
37
+ export function registerHome(env: Record<string, string | undefined>): string {
38
+ if (env["MCP_REGISTER_HOME"]) return env["MCP_REGISTER_HOME"];
39
+ const config = env["XDG_CONFIG_HOME"] ?? join(homedir(), ".config");
40
+ return join(config, "forestrie", "mcp-register");
41
+ }
42
+
43
+ type P256Jwk = { kty: "EC"; crv: "P-256"; x: string; y: string; d: string };
44
+
45
+ function b64urlToBytes(s: string): Uint8Array {
46
+ return new Uint8Array(Buffer.from(s, "base64url"));
47
+ }
48
+
49
+ function isP256PrivateJwk(v: unknown): v is P256Jwk {
50
+ const j = v as Partial<P256Jwk>;
51
+ return (
52
+ typeof v === "object" &&
53
+ v !== null &&
54
+ j.kty === "EC" &&
55
+ j.crv === "P-256" &&
56
+ typeof j.x === "string" &&
57
+ typeof j.y === "string" &&
58
+ typeof j.d === "string"
59
+ );
60
+ }
61
+
62
+ /**
63
+ * Write a new key without ever exposing a half-written file: the JWK goes
64
+ * to a private temporary file first, then is hard-linked into place, which
65
+ * fails (EEXIST) if another process got there first, in which case its key
66
+ * is used. The temporary file is always removed.
67
+ */
68
+ async function createKeyFile(path: string, note: string): Promise<void> {
69
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
70
+ const pair = (await subtle.generateKey(
71
+ { name: "ECDSA", namedCurve: "P-256" },
72
+ true,
73
+ ["sign", "verify"],
74
+ )) as CryptoKeyPair;
75
+ const jwk = await subtle.exportKey("jwk", pair.privateKey);
76
+ const record = {
77
+ kty: jwk.kty,
78
+ crv: jwk.crv,
79
+ x: jwk.x,
80
+ y: jwk.y,
81
+ d: jwk.d,
82
+ use: "sig",
83
+ alg: "ES256",
84
+ note,
85
+ };
86
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
87
+ try {
88
+ writeFileSync(tmp, JSON.stringify(record, null, 2) + "\n", {
89
+ flag: "wx",
90
+ mode: 0o600,
91
+ });
92
+ try {
93
+ linkSync(tmp, path);
94
+ } catch (err) {
95
+ // Lost a race to another process creating the same file: use theirs.
96
+ if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
97
+ }
98
+ } finally {
99
+ try {
100
+ unlinkSync(tmp);
101
+ } catch {
102
+ // already gone
103
+ }
104
+ }
105
+ }
106
+
107
+ /**
108
+ * The two ES256 keys this server can hold. The log key owns the performer's
109
+ * data log; the authority key owns the requester's authority log and signs
110
+ * the child grants issue_grant makes. They are separate files so that
111
+ * whoever can write to one log cannot issue grants under the other.
112
+ */
113
+ export type KeyRole = "log" | "authority";
114
+
115
+ const KEY_ROLES: Record<
116
+ KeyRole,
117
+ { label: string; note: string; createdBy: string }
118
+ > = {
119
+ log: {
120
+ label: "log key",
121
+ note: "Forestrie log key for @forestrie/mcp-register. Grants name its public x‖y; keep this file private.",
122
+ createdBy: "get_log_key or register_statement",
123
+ },
124
+ authority: {
125
+ label: "authority key",
126
+ note: "Forestrie authority key for @forestrie/mcp-register: owns the requester's authority log and signs the child grants issue_grant makes. Keep this file private.",
127
+ createdBy: 'get_log_key with which: "authority", or issue_grant,',
128
+ },
129
+ };
130
+
131
+ export interface KeyFileSigner extends Es256Signer {
132
+ readonly path: string;
133
+ /** True when this process created the key file. */
134
+ readonly created: boolean;
135
+ }
136
+
137
+ /**
138
+ * Load the key at `path`. Lazy by design: the server opens the signer at the
139
+ * first tool call that needs it, so starting the server never writes a
140
+ * file. With `create` (the default) a missing key is created; without it a
141
+ * missing key is a `KeyFileError`, so a read-only tool never makes one.
142
+ *
143
+ * Refused, like ssh refuses a loose private key: a key path that is a
144
+ * symlink, or a key file readable or writable by group or others.
145
+ */
146
+ export async function openKeyFileSigner(
147
+ path: string,
148
+ options: { create?: boolean; role?: KeyRole } = {},
149
+ ): Promise<KeyFileSigner> {
150
+ const role = options.role ?? "log";
151
+ const label = KEY_ROLES[role].label;
152
+ let created = false;
153
+ let stat = lstatOrUndefined(path);
154
+ if (stat === undefined) {
155
+ if (options.create === false)
156
+ throw new KeyFileError(
157
+ `no ${label} at ${path} yet: ${KEY_ROLES[role].createdBy} creates it`,
158
+ );
159
+ try {
160
+ await createKeyFile(path, KEY_ROLES[role].note);
161
+ } catch (err) {
162
+ throw new KeyFileError(
163
+ `cannot create the ${label} file ${path}: ${(err as Error).message}`,
164
+ );
165
+ }
166
+ created = true;
167
+ stat = lstatOrUndefined(path);
168
+ }
169
+ if (stat === undefined || stat.isSymbolicLink() || !stat.isFile())
170
+ throw new KeyFileError(
171
+ `the ${label} path ${path} is not a regular file (a symlink or directory is refused)`,
172
+ );
173
+ if ((stat.mode & 0o077) !== 0)
174
+ throw new KeyFileError(
175
+ `the ${label} file ${path} is accessible to other users (mode ${(stat.mode & 0o777).toString(8)}); chmod 600 it`,
176
+ );
177
+ let text: string;
178
+ try {
179
+ text = readFileSync(path, "utf8");
180
+ } catch (err) {
181
+ throw new KeyFileError(
182
+ `cannot read the ${label} file ${path}: ${(err as Error).message}`,
183
+ );
184
+ }
185
+ let jwk: unknown;
186
+ try {
187
+ jwk = JSON.parse(text);
188
+ } catch {
189
+ throw new KeyFileError(`the ${label} file ${path} is not JSON`);
190
+ }
191
+ if (!isP256PrivateJwk(jwk))
192
+ throw new KeyFileError(
193
+ `the ${label} file ${path} is not a P-256 private JWK (kty EC, crv P-256, x, y, d)`,
194
+ );
195
+ let privateKey: CryptoKey;
196
+ try {
197
+ privateKey = await subtle.importKey(
198
+ "jwk",
199
+ { kty: "EC", crv: "P-256", x: jwk.x, y: jwk.y, d: jwk.d, ext: false },
200
+ { name: "ECDSA", namedCurve: "P-256" },
201
+ false,
202
+ ["sign"],
203
+ );
204
+ } catch {
205
+ throw new KeyFileError(
206
+ `the ${label} file ${path} holds an invalid P-256 key`,
207
+ );
208
+ }
209
+ const xy = new Uint8Array(64);
210
+ xy.set(b64urlToBytes(jwk.x), 0);
211
+ xy.set(b64urlToBytes(jwk.y), 32);
212
+
213
+ return {
214
+ path,
215
+ created,
216
+ description: `local ${label} file ${path}`,
217
+ publicKeyXY: async () => xy.slice(),
218
+ sign: async (message) =>
219
+ new Uint8Array(
220
+ await subtle.sign(
221
+ { name: "ECDSA", hash: "SHA-256" },
222
+ privateKey,
223
+ message as BufferSource,
224
+ ),
225
+ ),
226
+ };
227
+ }
228
+
229
+ /** SubjectPublicKeyInfo DER prefix for an uncompressed P-256 point. */
230
+ const P256_SPKI_PREFIX = Buffer.from(
231
+ "3059301306072a8648ce3d020106082a8648ce3d030107034200",
232
+ "hex",
233
+ );
234
+
235
+ /** The public key as an SPKI PEM, the form forestrie-cli's `--signer-pem` reads. */
236
+ export function publicKeyPem(xy: Uint8Array): string {
237
+ const der = Buffer.concat([P256_SPKI_PREFIX, Buffer.from([0x04]), xy]);
238
+ const b64 = der.toString("base64").replace(/(.{64})/g, "$1\n");
239
+ return `-----BEGIN PUBLIC KEY-----\n${b64}\n-----END PUBLIC KEY-----\n`;
240
+ }
241
+
242
+ function lstatOrUndefined(path: string) {
243
+ try {
244
+ return lstatSync(path);
245
+ } catch (err) {
246
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return undefined;
247
+ throw new KeyFileError(
248
+ `cannot stat the key path ${path}: ${(err as Error).message}`,
249
+ );
250
+ }
251
+ }