@haven_ai/signer 0.1.0-alpha
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/README.md +108 -0
- package/dist/cli.cjs +637 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.js +635 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.cjs +614 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.js +591 -0
- package/dist/index.js.map +1 -0
- package/package.json +47 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import { randomUUID, createHash } from 'crypto';
|
|
5
|
+
import { readFile, mkdir, writeFile, stat, appendFile } from 'fs/promises';
|
|
6
|
+
import { homedir } from 'os';
|
|
7
|
+
import { resolve, dirname } from 'path';
|
|
8
|
+
import { addressFromKey, HavenSigningError, selectStandardPaymentOption, HavenApiError, toStandardPaymentRequirements, x402AuthorizationAmount, buildX402ExpectedMessage, verifySignature, signHash, HavenError } from '@haven_ai/sdk';
|
|
9
|
+
import { z } from 'zod/v3';
|
|
10
|
+
import { hashMessage } from 'viem';
|
|
11
|
+
import { privateKeyToAccount } from 'viem/accounts';
|
|
12
|
+
import { exact } from 'x402/schemes';
|
|
13
|
+
|
|
14
|
+
function defaultSigningAuditPath(credentialsPath) {
|
|
15
|
+
if (credentialsPath) return resolve(`${credentialsPath}.signer-audit.jsonl`);
|
|
16
|
+
return resolve(homedir(), ".haven", "signer-audit.jsonl");
|
|
17
|
+
}
|
|
18
|
+
async function appendSigningAuditEntry(entry, path) {
|
|
19
|
+
await mkdir(dirname(path), { recursive: true });
|
|
20
|
+
await appendFile(path, `${JSON.stringify(entry)}
|
|
21
|
+
`, "utf8");
|
|
22
|
+
}
|
|
23
|
+
function createSigningAuditEntry(tool, payloadHash, context, now = /* @__PURE__ */ new Date()) {
|
|
24
|
+
const entry = {
|
|
25
|
+
version: 1,
|
|
26
|
+
timestamp: now.toISOString(),
|
|
27
|
+
tool,
|
|
28
|
+
payload_hash: payloadHash,
|
|
29
|
+
delegate_address: context.delegateAddress
|
|
30
|
+
};
|
|
31
|
+
if (context.safeAddress) entry.safe_address = context.safeAddress;
|
|
32
|
+
if (typeof context.chainId === "number") entry.chain_id = context.chainId;
|
|
33
|
+
return entry;
|
|
34
|
+
}
|
|
35
|
+
function hashPayloadForAudit(payload) {
|
|
36
|
+
return `0x${createHash("sha256").update(stableStringify(payload)).digest("hex")}`;
|
|
37
|
+
}
|
|
38
|
+
function stableStringify(value) {
|
|
39
|
+
if (value === null || typeof value !== "object") {
|
|
40
|
+
const primitive = JSON.stringify(value);
|
|
41
|
+
return primitive === void 0 ? "undefined" : primitive;
|
|
42
|
+
}
|
|
43
|
+
if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(",")}]`;
|
|
44
|
+
const object = value;
|
|
45
|
+
return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(object[key])}`).join(",")}}`;
|
|
46
|
+
}
|
|
47
|
+
var x402ExpectedSchema = z.object({
|
|
48
|
+
payment_id: z.string().min(1),
|
|
49
|
+
payload_hash: z.string().regex(/^0x[0-9a-fA-F]+$/, "payload_hash must be a 0x-prefixed hex string"),
|
|
50
|
+
resource_url: z.string().url(),
|
|
51
|
+
merchant_to: z.string().min(1),
|
|
52
|
+
amount: z.string().min(1),
|
|
53
|
+
asset: z.string().min(1),
|
|
54
|
+
network: z.string().min(1),
|
|
55
|
+
auth: z.object({
|
|
56
|
+
version: z.literal(1),
|
|
57
|
+
message: z.string().min(1),
|
|
58
|
+
signature: z.string().regex(/^0x[0-9a-fA-F]+$/, "signature must be a 0x-prefixed hex string"),
|
|
59
|
+
signer: z.string().min(1)
|
|
60
|
+
})
|
|
61
|
+
});
|
|
62
|
+
var toolSchemas = {
|
|
63
|
+
haven_sign: {
|
|
64
|
+
// The unsigned hash from haven_pay / haven_x402_authorize (payload_hash).
|
|
65
|
+
payload_hash: z.string().regex(/^0x[0-9a-fA-F]+$/, "payload_hash must be a 0x-prefixed hex string"),
|
|
66
|
+
// Pass x402.expected from hosted haven_x402_authorize when this hash funds
|
|
67
|
+
// a standard x402 merchant retry. The signer records it locally and returns
|
|
68
|
+
// an opaque x402_binding for the later header-signing step.
|
|
69
|
+
x402_expected: x402ExpectedSchema.optional()
|
|
70
|
+
},
|
|
71
|
+
haven_x402_sign_header: {
|
|
72
|
+
// The parsed HTTP 402 PaymentRequired from the merchant.
|
|
73
|
+
payment_required: z.unknown(),
|
|
74
|
+
// Opaque binding returned by haven_sign when x402_expected was supplied.
|
|
75
|
+
x402_binding: z.string().min(1)
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
var SIGN_DESCRIPTION = [
|
|
79
|
+
"Sign an unsigned Haven payment hash with the delegate key on this machine.",
|
|
80
|
+
"Pass the payload_hash returned by haven_pay or haven_x402_authorize. For x402, also pass",
|
|
81
|
+
"x402_expected from haven_x402_authorize; the signer records it locally and returns",
|
|
82
|
+
"{ signature, x402_binding }. Hand signature to haven_submit, then use x402_binding for",
|
|
83
|
+
"haven_x402_sign_header. The delegate key never leaves this process."
|
|
84
|
+
].join(" ");
|
|
85
|
+
var X402_SIGN_HEADER_DESCRIPTION = [
|
|
86
|
+
"Build and sign the EIP-3009 X-PAYMENT header for the merchant leg of an x402 payment, using",
|
|
87
|
+
"the delegate key on this machine. Pass the same payment_required you gave haven_x402_authorize",
|
|
88
|
+
"and the x402_binding returned by haven_sign. The signer consumes the recorded funding context",
|
|
89
|
+
"and rejects mismatched amount, merchant, resource, asset, or network before signing. Returns",
|
|
90
|
+
"{ payment_header } to send to the merchant as the X-PAYMENT header on your retry. Do this only",
|
|
91
|
+
"after the funding step (haven_submit) has confirmed."
|
|
92
|
+
].join(" ");
|
|
93
|
+
var toolDescriptions = {
|
|
94
|
+
haven_sign: SIGN_DESCRIPTION,
|
|
95
|
+
haven_x402_sign_header: X402_SIGN_HEADER_DESCRIPTION
|
|
96
|
+
};
|
|
97
|
+
function createToolHandlers(signer, options = {}) {
|
|
98
|
+
return {
|
|
99
|
+
haven_sign: async (input) => runTool(async () => {
|
|
100
|
+
const args = parse("haven_sign", input);
|
|
101
|
+
const x402Expected = args.x402_expected ? {
|
|
102
|
+
paymentId: args.x402_expected.payment_id,
|
|
103
|
+
payloadHash: args.x402_expected.payload_hash,
|
|
104
|
+
resourceUrl: args.x402_expected.resource_url,
|
|
105
|
+
merchantTo: args.x402_expected.merchant_to,
|
|
106
|
+
amount: args.x402_expected.amount,
|
|
107
|
+
asset: args.x402_expected.asset,
|
|
108
|
+
network: args.x402_expected.network,
|
|
109
|
+
auth: args.x402_expected.auth
|
|
110
|
+
} : null;
|
|
111
|
+
const result = x402Expected ? signer.signX402FundingHash(args.payload_hash, x402Expected) : null;
|
|
112
|
+
if (!result) {
|
|
113
|
+
const signature = signer.signPaymentHash(args.payload_hash);
|
|
114
|
+
await auditSigning("haven_sign", args.payload_hash);
|
|
115
|
+
return { signature };
|
|
116
|
+
}
|
|
117
|
+
await auditSigning("haven_sign", args.payload_hash);
|
|
118
|
+
return { signature: result.signature, x402_binding: result.x402Binding };
|
|
119
|
+
}),
|
|
120
|
+
haven_x402_sign_header: async (input) => runTool(async () => {
|
|
121
|
+
const args = parse("haven_x402_sign_header", input);
|
|
122
|
+
const result = await signer.buildX402PaymentHeader(
|
|
123
|
+
args.payment_required,
|
|
124
|
+
args.x402_binding
|
|
125
|
+
);
|
|
126
|
+
await auditSigning(
|
|
127
|
+
"haven_x402_sign_header",
|
|
128
|
+
hashPayloadForAudit(args.payment_required)
|
|
129
|
+
);
|
|
130
|
+
return { payment_header: result.paymentHeader, accepted: result.accepted };
|
|
131
|
+
})
|
|
132
|
+
};
|
|
133
|
+
async function auditSigning(tool, payloadHash) {
|
|
134
|
+
if (!options.audit) return;
|
|
135
|
+
const { auditPath, ...context } = options.audit;
|
|
136
|
+
await appendSigningAuditEntry(
|
|
137
|
+
createSigningAuditEntry(tool, payloadHash, {
|
|
138
|
+
...context,
|
|
139
|
+
delegateAddress: signer.delegateAddress
|
|
140
|
+
}),
|
|
141
|
+
auditPath
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function parse(name, input) {
|
|
146
|
+
return z.object(toolSchemas[name]).parse(input ?? {});
|
|
147
|
+
}
|
|
148
|
+
async function runTool(fn) {
|
|
149
|
+
try {
|
|
150
|
+
return { success: true, data: await fn() };
|
|
151
|
+
} catch (err) {
|
|
152
|
+
return normalizeError(err);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function normalizeError(err) {
|
|
156
|
+
if (err instanceof z.ZodError) {
|
|
157
|
+
return {
|
|
158
|
+
success: false,
|
|
159
|
+
code: "INVALID_INPUT",
|
|
160
|
+
message: err.errors.map((e) => `${e.path.join(".") || "(root)"}: ${e.message}`).join("; "),
|
|
161
|
+
statusCode: 400
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
if (err instanceof HavenSigningError) {
|
|
165
|
+
return { success: false, code: err.code, message: err.message };
|
|
166
|
+
}
|
|
167
|
+
if (err instanceof HavenApiError) {
|
|
168
|
+
return { success: false, code: err.code, message: err.message, statusCode: err.statusCode };
|
|
169
|
+
}
|
|
170
|
+
if (err instanceof HavenError) {
|
|
171
|
+
return { success: false, code: err.code, message: err.message, statusCode: err.statusCode };
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
success: false,
|
|
175
|
+
code: "UNKNOWN_ERROR",
|
|
176
|
+
message: err instanceof Error ? err.message : String(err)
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// src/consent.ts
|
|
181
|
+
var SIGNER_ACK_ENV = "HAVEN_SIGNER_ACK";
|
|
182
|
+
function computeSignerConsentHash(input) {
|
|
183
|
+
const identity = [
|
|
184
|
+
input.delegateAddress.toLowerCase(),
|
|
185
|
+
(input.safeAddress ?? "").toLowerCase(),
|
|
186
|
+
input.agentId ?? "",
|
|
187
|
+
input.chainId ?? "",
|
|
188
|
+
input.network ?? ""
|
|
189
|
+
].join("|");
|
|
190
|
+
const toolCanonical = [...input.toolNames].sort().join(",");
|
|
191
|
+
return createHash("sha256").update(`${identity}
|
|
192
|
+
${toolCanonical}`).digest("hex").slice(0, 16);
|
|
193
|
+
}
|
|
194
|
+
function renderSignerConsentBlock(input, hash) {
|
|
195
|
+
const lines = [
|
|
196
|
+
"",
|
|
197
|
+
"------------------------------------------------------------",
|
|
198
|
+
"Haven edge signer - first-launch consent",
|
|
199
|
+
"------------------------------------------------------------",
|
|
200
|
+
"",
|
|
201
|
+
`Delegate address: ${input.delegateAddress}`
|
|
202
|
+
];
|
|
203
|
+
lines.push(`Haven wallet: ${input.safeAddress ?? "not provided to this signer"}`);
|
|
204
|
+
if (input.agentId) lines.push(`Agent ID: ${input.agentId}`);
|
|
205
|
+
if (typeof input.chainId === "number") lines.push(`Chain ID: ${input.chainId}`);
|
|
206
|
+
if (input.network) lines.push(`Network: ${input.network}`);
|
|
207
|
+
lines.push("");
|
|
208
|
+
lines.push("This local signer holds the delegate key on this machine and signs");
|
|
209
|
+
lines.push("payment payloads or x402 merchant headers for the delegate address above.");
|
|
210
|
+
lines.push("It does not call the Haven API, so it cannot show a live allowance summary.");
|
|
211
|
+
lines.push("On-chain Safe rules remain the real spend gate, and the wallet owner can");
|
|
212
|
+
lines.push("pause or revoke agent authority outside this signer.");
|
|
213
|
+
lines.push("");
|
|
214
|
+
lines.push("Tools this signer will expose to your agent runtime:");
|
|
215
|
+
for (const name of input.toolNames) {
|
|
216
|
+
lines.push(` - ${name}`);
|
|
217
|
+
lines.push(` ${toolDescriptions[name]}`);
|
|
218
|
+
}
|
|
219
|
+
lines.push("");
|
|
220
|
+
lines.push("A local audit entry is appended for every signing operation. Audit entries");
|
|
221
|
+
lines.push("record timestamp, tool, payload hash, and delegate address; never the key,");
|
|
222
|
+
lines.push("signature, or x402 payment header.");
|
|
223
|
+
lines.push("");
|
|
224
|
+
lines.push(`Consent hash: ${hash}`);
|
|
225
|
+
lines.push("");
|
|
226
|
+
lines.push("To acknowledge, EITHER:");
|
|
227
|
+
lines.push(` - set ${SIGNER_ACK_ENV}=${hash} in this process's environment, OR`);
|
|
228
|
+
lines.push(" - re-run with --ack to write the acknowledgement next to your");
|
|
229
|
+
lines.push(" credential file (sidecar <credentials>.signer-ack.json).");
|
|
230
|
+
lines.push("");
|
|
231
|
+
lines.push("------------------------------------------------------------");
|
|
232
|
+
lines.push("");
|
|
233
|
+
return lines.join("\n");
|
|
234
|
+
}
|
|
235
|
+
async function ensureSignerConsent(input, options = {}) {
|
|
236
|
+
const env = options.env ?? process.env;
|
|
237
|
+
const out = options.out ?? process.stderr;
|
|
238
|
+
const hash = computeSignerConsentHash(input);
|
|
239
|
+
const envAck = env[SIGNER_ACK_ENV];
|
|
240
|
+
if (typeof envAck === "string" && envAck.length > 0) {
|
|
241
|
+
if (envAck === hash) return { ok: true, hash, reason: "env_var_match" };
|
|
242
|
+
out.write(renderSignerConsentBlock(input, hash));
|
|
243
|
+
out.write(
|
|
244
|
+
`${SIGNER_ACK_ENV} was set but did not match the current signer consent hash.
|
|
245
|
+
Expected: ${hash}
|
|
246
|
+
Got: ${envAck}
|
|
247
|
+
Re-acknowledge with the new hash above, or run with --ack.
|
|
248
|
+
|
|
249
|
+
`
|
|
250
|
+
);
|
|
251
|
+
return { ok: false, hash, reason: "env_var_mismatch" };
|
|
252
|
+
}
|
|
253
|
+
const ackPath = sidecarPath(options.credentialsPath);
|
|
254
|
+
if (ackPath) {
|
|
255
|
+
const stored = await readAckFile(ackPath);
|
|
256
|
+
if (stored?.ack === hash) return { ok: true, hash, reason: "ack_file_match" };
|
|
257
|
+
}
|
|
258
|
+
if (options.writeAck && ackPath) {
|
|
259
|
+
out.write(renderSignerConsentBlock(input, hash));
|
|
260
|
+
await writeAckFile(ackPath, hash);
|
|
261
|
+
out.write(`Wrote acknowledgement to ${ackPath}
|
|
262
|
+
|
|
263
|
+
`);
|
|
264
|
+
return { ok: true, hash, reason: "wrote_ack_file" };
|
|
265
|
+
}
|
|
266
|
+
out.write(renderSignerConsentBlock(input, hash));
|
|
267
|
+
return { ok: false, hash, reason: "no_acknowledgement" };
|
|
268
|
+
}
|
|
269
|
+
function registeredSignerToolNames() {
|
|
270
|
+
return Object.keys(toolSchemas);
|
|
271
|
+
}
|
|
272
|
+
function sidecarPath(credentialsPath) {
|
|
273
|
+
if (!credentialsPath) return null;
|
|
274
|
+
return resolve(`${credentialsPath}.signer-ack.json`);
|
|
275
|
+
}
|
|
276
|
+
async function readAckFile(path) {
|
|
277
|
+
try {
|
|
278
|
+
const raw = await readFile(path, "utf8");
|
|
279
|
+
const parsed = JSON.parse(raw);
|
|
280
|
+
return { ack: typeof parsed.ack === "string" ? parsed.ack : void 0 };
|
|
281
|
+
} catch {
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
async function writeAckFile(path, hash) {
|
|
286
|
+
await mkdir(dirname(path), { recursive: true });
|
|
287
|
+
await writeFile(
|
|
288
|
+
path,
|
|
289
|
+
JSON.stringify({ ack: hash, at: (/* @__PURE__ */ new Date()).toISOString() }, null, 2),
|
|
290
|
+
"utf8"
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
function createEdgeSigner(delegateKey, options = {}) {
|
|
294
|
+
let delegateAddress;
|
|
295
|
+
try {
|
|
296
|
+
delegateAddress = addressFromKey(delegateKey);
|
|
297
|
+
} catch (err) {
|
|
298
|
+
throw new HavenSigningError(
|
|
299
|
+
`Invalid delegate key: ${err instanceof Error ? err.message : String(err)}`
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
const x402Bindings = /* @__PURE__ */ new Map();
|
|
303
|
+
function signAndVerify(hash) {
|
|
304
|
+
const signature = signHash(delegateKey, hash);
|
|
305
|
+
if (!verifySignature(hash, signature, delegateAddress)) {
|
|
306
|
+
throw new HavenSigningError(
|
|
307
|
+
"Local signature verification failed \u2014 recovered address does not match the delegate key."
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
return signature;
|
|
311
|
+
}
|
|
312
|
+
return {
|
|
313
|
+
delegateAddress,
|
|
314
|
+
signPaymentHash(hash) {
|
|
315
|
+
return signAndVerify(hash);
|
|
316
|
+
},
|
|
317
|
+
signX402FundingHash(hash, expected) {
|
|
318
|
+
assertExpectedBinding(hash, expected, options.x402BindingSigner);
|
|
319
|
+
const signature = signAndVerify(hash);
|
|
320
|
+
const x402Binding = randomUUID();
|
|
321
|
+
x402Bindings.set(x402Binding, { ...expected });
|
|
322
|
+
return { signature, x402Binding };
|
|
323
|
+
},
|
|
324
|
+
async buildX402PaymentHeader(paymentRequired, x402Binding) {
|
|
325
|
+
const expected = x402Bindings.get(x402Binding);
|
|
326
|
+
if (!expected) {
|
|
327
|
+
throw new HavenSigningError(
|
|
328
|
+
"x402 funding binding is required before signing a merchant header. Sign the hosted funding hash with x402_expected first."
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
const option = selectStandardPaymentOption(paymentRequired.accepts);
|
|
332
|
+
if (!option) {
|
|
333
|
+
throw new HavenApiError(
|
|
334
|
+
"No compatible payment option found in x402 requirements. Haven supports standard x402 exact payments on Base USDC.",
|
|
335
|
+
400
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
assertX402MatchesExpected(paymentRequired, option, expected);
|
|
339
|
+
const account = privateKeyToAccount(delegateKey);
|
|
340
|
+
const requirements = toStandardPaymentRequirements(paymentRequired, option);
|
|
341
|
+
const header = await exact.evm.createPaymentHeader(
|
|
342
|
+
account,
|
|
343
|
+
paymentRequired.x402Version,
|
|
344
|
+
requirements
|
|
345
|
+
);
|
|
346
|
+
if (paymentRequired.x402Version < 2) {
|
|
347
|
+
x402Bindings.delete(x402Binding);
|
|
348
|
+
return { paymentHeader: header, accepted: option };
|
|
349
|
+
}
|
|
350
|
+
try {
|
|
351
|
+
const payment = decodeBase64Json(header);
|
|
352
|
+
const wrapped = encodeBase64Json({
|
|
353
|
+
x402Version: paymentRequired.x402Version,
|
|
354
|
+
accepted: option,
|
|
355
|
+
payload: payment.payload
|
|
356
|
+
});
|
|
357
|
+
return { paymentHeader: wrapped, accepted: option };
|
|
358
|
+
} finally {
|
|
359
|
+
x402Bindings.delete(x402Binding);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
function assertX402MatchesExpected(paymentRequired, option, expected) {
|
|
365
|
+
assertExpectedShape(expected);
|
|
366
|
+
const headerResource = option.resource ?? paymentRequired.resource.url;
|
|
367
|
+
if (headerResource !== expected.resourceUrl) {
|
|
368
|
+
throw new HavenSigningError("x402 payment_required resource does not match the funded intent.");
|
|
369
|
+
}
|
|
370
|
+
if (!sameAddress(option.payTo, expected.merchantTo)) {
|
|
371
|
+
throw new HavenSigningError("x402 merchant recipient does not match the funded intent.");
|
|
372
|
+
}
|
|
373
|
+
if (x402AuthorizationAmount(option) !== expected.amount) {
|
|
374
|
+
throw new HavenSigningError("x402 amount does not match the funded intent.");
|
|
375
|
+
}
|
|
376
|
+
if (!sameAddress(option.asset, expected.asset)) {
|
|
377
|
+
throw new HavenSigningError("x402 asset does not match the funded intent.");
|
|
378
|
+
}
|
|
379
|
+
if (option.network !== expected.network) {
|
|
380
|
+
throw new HavenSigningError("x402 network does not match the funded intent.");
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
function assertExpectedShape(expected) {
|
|
384
|
+
if (!expected || typeof expected !== "object") {
|
|
385
|
+
throw new HavenSigningError("x402 expected funding context is required before signing a merchant header.");
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
function assertExpectedBinding(payloadHash, expected, trustedSigner) {
|
|
389
|
+
assertExpectedShape(expected);
|
|
390
|
+
if (!trustedSigner) {
|
|
391
|
+
throw new HavenSigningError(
|
|
392
|
+
"x402 expected-context verifier is not configured. Set HAVEN_X402_BINDING_SIGNER before signing x402 funding hashes."
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
if (expected.payloadHash.toLowerCase() !== payloadHash.toLowerCase()) {
|
|
396
|
+
throw new HavenSigningError("x402 expected context does not match the funding hash being signed.");
|
|
397
|
+
}
|
|
398
|
+
const message = buildX402ExpectedMessage({
|
|
399
|
+
paymentId: expected.paymentId,
|
|
400
|
+
payloadHash: expected.payloadHash,
|
|
401
|
+
resourceUrl: expected.resourceUrl,
|
|
402
|
+
merchantTo: expected.merchantTo,
|
|
403
|
+
amount: expected.amount,
|
|
404
|
+
asset: expected.asset,
|
|
405
|
+
network: expected.network
|
|
406
|
+
});
|
|
407
|
+
if (expected.auth?.version !== 1 || expected.auth.message !== message) {
|
|
408
|
+
throw new HavenSigningError("x402 expected context authentication message is invalid.");
|
|
409
|
+
}
|
|
410
|
+
if (!sameAddress(expected.auth.signer, trustedSigner)) {
|
|
411
|
+
throw new HavenSigningError("x402 expected context was not signed by the configured Haven signer.");
|
|
412
|
+
}
|
|
413
|
+
if (!verifySignature(hashMessage(message), expected.auth.signature, trustedSigner)) {
|
|
414
|
+
throw new HavenSigningError("x402 expected context signature could not be verified.");
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
function sameAddress(a, b) {
|
|
418
|
+
return a.toLowerCase() === b.toLowerCase();
|
|
419
|
+
}
|
|
420
|
+
function decodeBase64Json(value) {
|
|
421
|
+
return JSON.parse(Buffer.from(value, "base64").toString("utf8"));
|
|
422
|
+
}
|
|
423
|
+
function encodeBase64Json(value) {
|
|
424
|
+
return Buffer.from(JSON.stringify(value), "utf8").toString("base64");
|
|
425
|
+
}
|
|
426
|
+
async function loadSignerCredentials(path = process.env.HAVEN_CREDENTIALS) {
|
|
427
|
+
if (path) return loadFromFile(path);
|
|
428
|
+
const envKey = stringField(process.env.HAVEN_DELEGATE_KEY);
|
|
429
|
+
if (envKey) {
|
|
430
|
+
return {
|
|
431
|
+
delegateKey: envKey,
|
|
432
|
+
agentId: stringField(process.env.HAVEN_AGENT_ID),
|
|
433
|
+
safeAddress: stringField(process.env.HAVEN_SAFE_ADDRESS),
|
|
434
|
+
chainId: numberField(process.env.HAVEN_CHAIN_ID),
|
|
435
|
+
network: stringField(process.env.HAVEN_NETWORK),
|
|
436
|
+
x402BindingSigner: stringField(process.env.HAVEN_X402_BINDING_SIGNER)
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
throw new Error(
|
|
440
|
+
"No delegate key found. Set HAVEN_DELEGATE_KEY, pass --credentials <path>, or set HAVEN_CREDENTIALS to a Haven agent credential JSON file."
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
async function loadFromFile(path) {
|
|
444
|
+
let rawText;
|
|
445
|
+
try {
|
|
446
|
+
rawText = await readFile(path, "utf8");
|
|
447
|
+
} catch (err) {
|
|
448
|
+
throw new Error(
|
|
449
|
+
`Could not read Haven credentials at ${path}: ${err instanceof Error ? err.message : String(err)}`
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
await warnIfCredentialFilePermissive(path);
|
|
453
|
+
let raw;
|
|
454
|
+
try {
|
|
455
|
+
raw = JSON.parse(rawText);
|
|
456
|
+
} catch {
|
|
457
|
+
throw new Error("Haven credentials must be JSON with a delegate_key field.");
|
|
458
|
+
}
|
|
459
|
+
const delegateKey = stringField(raw.delegate_key ?? raw.delegateKey);
|
|
460
|
+
if (!delegateKey) {
|
|
461
|
+
throw new Error("Haven credentials are missing delegate_key \u2014 the edge signer needs it to sign.");
|
|
462
|
+
}
|
|
463
|
+
const chainId = numberField(raw.chain_id ?? raw.chainId);
|
|
464
|
+
return {
|
|
465
|
+
delegateKey,
|
|
466
|
+
agentId: stringField(raw.agent_id ?? raw.agentId),
|
|
467
|
+
safeAddress: stringField(raw.safe_address ?? raw.safeAddress),
|
|
468
|
+
chainId,
|
|
469
|
+
network: stringField(raw.network),
|
|
470
|
+
x402BindingSigner: stringField(
|
|
471
|
+
raw.x402_binding_signer ?? raw.x402BindingSigner ?? process.env.HAVEN_X402_BINDING_SIGNER
|
|
472
|
+
),
|
|
473
|
+
sourcePath: path
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
function stringField(value) {
|
|
477
|
+
return typeof value === "string" && value.trim() ? value.trim() : void 0;
|
|
478
|
+
}
|
|
479
|
+
function numberField(value) {
|
|
480
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
481
|
+
if (typeof value === "string" && value.trim()) {
|
|
482
|
+
const parsed = Number(value);
|
|
483
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
484
|
+
}
|
|
485
|
+
return void 0;
|
|
486
|
+
}
|
|
487
|
+
async function warnIfCredentialFilePermissive(path, log = (message) => process.stderr.write(`${message}
|
|
488
|
+
`), platform = process.platform) {
|
|
489
|
+
if (platform === "win32") return;
|
|
490
|
+
let mode;
|
|
491
|
+
try {
|
|
492
|
+
mode = (await stat(path)).mode;
|
|
493
|
+
} catch {
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
if ((mode & 63) !== 0) {
|
|
497
|
+
const octal = (mode & 511).toString(8).padStart(4, "0");
|
|
498
|
+
log(
|
|
499
|
+
`haven-signer: warning: credential file at ${path} is readable beyond the owner (mode ${octal}). Run: chmod 600 ${path}`
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// src/server.ts
|
|
505
|
+
var SIGNER_NAME = "@haven_ai/signer";
|
|
506
|
+
var SIGNER_VERSION = "0.1.0-alpha";
|
|
507
|
+
async function resolveSignerRuntime(options = {}) {
|
|
508
|
+
if (options.delegateKey) {
|
|
509
|
+
return {
|
|
510
|
+
signer: createEdgeSigner(options.delegateKey, {
|
|
511
|
+
x402BindingSigner: options.x402BindingSigner ?? process.env.HAVEN_X402_BINDING_SIGNER
|
|
512
|
+
})
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
const creds = await loadSignerCredentials(options.credentialsPath);
|
|
516
|
+
return {
|
|
517
|
+
signer: createEdgeSigner(creds.delegateKey, {
|
|
518
|
+
x402BindingSigner: options.x402BindingSigner ?? creds.x402BindingSigner ?? process.env.HAVEN_X402_BINDING_SIGNER
|
|
519
|
+
}),
|
|
520
|
+
credentials: creds
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
function buildSignerMcpServer(signer, options = {}) {
|
|
524
|
+
const server = new McpServer({ name: SIGNER_NAME, version: SIGNER_VERSION });
|
|
525
|
+
const credentialsPath = options.credentials?.sourcePath;
|
|
526
|
+
const handlers = createToolHandlers(signer, {
|
|
527
|
+
audit: {
|
|
528
|
+
auditPath: options.auditPath ?? defaultSigningAuditPath(credentialsPath),
|
|
529
|
+
delegateAddress: signer.delegateAddress,
|
|
530
|
+
safeAddress: options.credentials?.safeAddress,
|
|
531
|
+
chainId: options.credentials?.chainId
|
|
532
|
+
}
|
|
533
|
+
});
|
|
534
|
+
const registerTool = server.tool.bind(server);
|
|
535
|
+
for (const name of Object.keys(toolSchemas)) {
|
|
536
|
+
registerTool(
|
|
537
|
+
name,
|
|
538
|
+
toolDescriptions[name],
|
|
539
|
+
toolSchemas[name],
|
|
540
|
+
async (args) => toMcpResult(await handlers[name](args))
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
return server;
|
|
544
|
+
}
|
|
545
|
+
async function runSignerStdioServer(options = {}) {
|
|
546
|
+
const { signer, credentials } = await resolveSignerRuntime(options);
|
|
547
|
+
if (!options.skipConsent) {
|
|
548
|
+
const decision = await runSignerConsentGate(signer, credentials, options);
|
|
549
|
+
if (!decision.ok) {
|
|
550
|
+
const err = new Error(
|
|
551
|
+
decision.reason === "env_var_mismatch" ? "Haven edge signer consent acknowledgement does not match the current configuration." : "Haven edge signer requires a one-time consent acknowledgement before starting."
|
|
552
|
+
);
|
|
553
|
+
err.code = "HAVEN_SIGNER_NO_CONSENT";
|
|
554
|
+
throw err;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
const server = buildSignerMcpServer(signer, { credentials, auditPath: options.auditPath });
|
|
558
|
+
await server.connect(new StdioServerTransport());
|
|
559
|
+
}
|
|
560
|
+
async function runSignerConsentGate(signer, credentials, options) {
|
|
561
|
+
return ensureSignerConsent(
|
|
562
|
+
{
|
|
563
|
+
delegateAddress: signer.delegateAddress,
|
|
564
|
+
safeAddress: credentials?.safeAddress,
|
|
565
|
+
agentId: credentials?.agentId,
|
|
566
|
+
chainId: credentials?.chainId,
|
|
567
|
+
network: credentials?.network,
|
|
568
|
+
toolNames: registeredSignerToolNames()
|
|
569
|
+
},
|
|
570
|
+
{
|
|
571
|
+
credentialsPath: options.credentialsPath ?? credentials?.sourcePath,
|
|
572
|
+
writeAck: options.writeAck,
|
|
573
|
+
env: options.consentEnv,
|
|
574
|
+
out: options.consentOut
|
|
575
|
+
}
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
function toMcpResult(payload) {
|
|
579
|
+
return {
|
|
580
|
+
isError: !payload.success,
|
|
581
|
+
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }]
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// src/cli.ts
|
|
586
|
+
function parseArgs(argv) {
|
|
587
|
+
const options = {};
|
|
588
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
589
|
+
const arg = argv[i];
|
|
590
|
+
if (arg === "--credentials" || arg === "--credentials-path") {
|
|
591
|
+
options.credentialsPath = argv[i + 1];
|
|
592
|
+
i += 1;
|
|
593
|
+
} else if (arg === "--ack") {
|
|
594
|
+
options.writeAck = true;
|
|
595
|
+
} else if (arg === "--help" || arg === "-h") {
|
|
596
|
+
process.stdout.write(
|
|
597
|
+
[
|
|
598
|
+
"Haven edge signer (local, holds the delegate key)",
|
|
599
|
+
"",
|
|
600
|
+
"Runs a local stdio MCP server exposing sign-only tools (haven_sign,",
|
|
601
|
+
"haven_x402_sign_header). Pair it with the hosted, keyless Haven MCP",
|
|
602
|
+
"server: the hosted server constructs and relays, this one signs.",
|
|
603
|
+
"",
|
|
604
|
+
"Usage:",
|
|
605
|
+
" npx @haven_ai/signer --credentials /path/to/agent.json",
|
|
606
|
+
"",
|
|
607
|
+
"Options:",
|
|
608
|
+
" --credentials <path> Haven credential JSON (delegate_key is read from it).",
|
|
609
|
+
" Also supported: HAVEN_CREDENTIALS, or HAVEN_DELEGATE_KEY.",
|
|
610
|
+
" --ack Acknowledge the first-launch consent block and write",
|
|
611
|
+
" a signer sidecar acknowledgement next to the credential.",
|
|
612
|
+
"",
|
|
613
|
+
"Consent:",
|
|
614
|
+
" On first launch the signer prints the sign-only tool list and delegate",
|
|
615
|
+
" address, then refuses to start unless acknowledged. It has no API access,",
|
|
616
|
+
" so it cannot show a live allowance summary.",
|
|
617
|
+
" Acknowledge with EITHER --ack OR HAVEN_SIGNER_ACK=<hash> in your environment.",
|
|
618
|
+
""
|
|
619
|
+
].join("\n")
|
|
620
|
+
);
|
|
621
|
+
process.exit(0);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
return options;
|
|
625
|
+
}
|
|
626
|
+
async function main() {
|
|
627
|
+
await runSignerStdioServer(parseArgs(process.argv.slice(2)));
|
|
628
|
+
}
|
|
629
|
+
main().catch((err) => {
|
|
630
|
+
process.stderr.write(`${err instanceof Error ? err.message : String(err)}
|
|
631
|
+
`);
|
|
632
|
+
process.exit(1);
|
|
633
|
+
});
|
|
634
|
+
//# sourceMappingURL=cli.js.map
|
|
635
|
+
//# sourceMappingURL=cli.js.map
|