@seedhape/local-wallet 0.2.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/agent-identity.d.ts +45 -0
- package/dist/agent-identity.d.ts.map +1 -0
- package/dist/agent-identity.js +300 -0
- package/dist/agent-identity.js.map +1 -0
- package/dist/hosted.d.ts +82 -0
- package/dist/hosted.d.ts.map +1 -0
- package/dist/hosted.js +180 -0
- package/dist/hosted.js.map +1 -0
- package/dist/identity-backup.d.ts +12 -0
- package/dist/identity-backup.d.ts.map +1 -0
- package/dist/identity-backup.js +130 -0
- package/dist/identity-backup.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +429 -0
- package/dist/index.js.map +1 -0
- package/dist/keychain.d.ts +30 -0
- package/dist/keychain.d.ts.map +1 -0
- package/dist/keychain.js +99 -0
- package/dist/keychain.js.map +1 -0
- package/dist/seedhape-identity.d.ts +87 -0
- package/dist/seedhape-identity.d.ts.map +1 -0
- package/dist/seedhape-identity.js +110 -0
- package/dist/seedhape-identity.js.map +1 -0
- package/dist/server.d.ts +43 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +160 -0
- package/dist/server.js.map +1 -0
- package/dist/web-bot-auth.d.ts +37 -0
- package/dist/web-bot-auth.d.ts.map +1 -0
- package/dist/web-bot-auth.js +87 -0
- package/dist/web-bot-auth.js.map +1 -0
- package/package.json +20 -0
- package/src/agent-identity.ts +293 -0
- package/src/hosted.ts +174 -0
- package/src/identity-backup.ts +134 -0
- package/src/index.ts +374 -0
- package/src/keychain.ts +114 -0
- package/src/seedhape-identity.ts +113 -0
- package/src/server.ts +187 -0
- package/src/web-bot-auth.ts +94 -0
- package/test/identity-backup.test.mjs +90 -0
- package/test/identity-fork.test.mjs +128 -0
- package/test/pay-flow.test.mjs +139 -0
- package/test/setup-flow.test.mjs +47 -0
- package/test/web-bot-auth.test.mjs +134 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
4
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
5
|
+
import { generatePrivateKey } from "viem/accounts";
|
|
6
|
+
import { LocalSessionKeySigner, USDC } from "@seedhape/core";
|
|
7
|
+
|
|
8
|
+
test("first wallet request starts browser setup once and returns while setup is pending", async () => {
|
|
9
|
+
process.env.SEEDHAPE_TOKEN = "test-token";
|
|
10
|
+
const { createServer } = await import("../dist/server.js");
|
|
11
|
+
const signer = new LocalSessionKeySigner(generatePrivateKey());
|
|
12
|
+
let finish;
|
|
13
|
+
let setupCalls = 0;
|
|
14
|
+
const server = createServer({
|
|
15
|
+
agentId: "test-agent",
|
|
16
|
+
address: null,
|
|
17
|
+
signer: null,
|
|
18
|
+
asset: USDC["base-sepolia"],
|
|
19
|
+
absoluteMaxPerPayment: 1_000_000n,
|
|
20
|
+
setupNeeded: true,
|
|
21
|
+
finishSetup: () => {
|
|
22
|
+
setupCalls += 1;
|
|
23
|
+
return new Promise((resolve) => { finish = resolve; });
|
|
24
|
+
},
|
|
25
|
+
fetchImpl: async () => new Response("ok", { status: 200 }),
|
|
26
|
+
});
|
|
27
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
28
|
+
await server.connect(serverTransport);
|
|
29
|
+
const client = new Client({ name: "test", version: "1" });
|
|
30
|
+
await client.connect(clientTransport);
|
|
31
|
+
try {
|
|
32
|
+
const pending1 = await client.callTool({ name: "wallet_status", arguments: {} });
|
|
33
|
+
const pending2 = await client.callTool({ name: "quote", arguments: { url: "https://example.com/" } });
|
|
34
|
+
assert.match(pending1.content[0].text, /Finishing setup in your browser/);
|
|
35
|
+
assert.match(pending2.content[0].text, /Finishing setup in your browser/);
|
|
36
|
+
assert.equal(setupCalls, 1, "parallel first-use requests share one setup flow");
|
|
37
|
+
|
|
38
|
+
finish({ address: signer.address, signer, webBotAuth: null, identity: null });
|
|
39
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
40
|
+
const ready = await client.callTool({ name: "quote", arguments: { url: "https://example.com/" } });
|
|
41
|
+
assert.match(ready.content[0].text, /does not ask for payment/);
|
|
42
|
+
assert.equal(setupCalls, 1);
|
|
43
|
+
} finally {
|
|
44
|
+
await client.close();
|
|
45
|
+
delete process.env.SEEDHAPE_TOKEN;
|
|
46
|
+
}
|
|
47
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Web Bot Auth in the local wallet: key generation, registration against a
|
|
2
|
+
// Seedhape-like server, and fresh signatures on every outgoing HTTPS request.
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { createServer as createHttp } from "node:http";
|
|
6
|
+
import { createPublicKey } from "node:crypto";
|
|
7
|
+
import { generatePrivateKey } from "viem/accounts";
|
|
8
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
9
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
10
|
+
import { LocalSessionKeySigner, USDC, verifyWebBotAuth, verifyWebBotAuthProof } from "@seedhape/core";
|
|
11
|
+
import { defaultSignatureAgent, ed25519Thumbprint, generateWebBotAuthKey, registerWebBotAuthKey, revokeWebBotAuthKey, signChallenge, webBotAuthFetch } from "../dist/web-bot-auth.js";
|
|
12
|
+
|
|
13
|
+
test("generated keys use the RFC 7638 thumbprint as key id and never expose private material", () => {
|
|
14
|
+
const key = generateWebBotAuthKey("https://seedhape.example/agents/laptop-1a2b3c4d");
|
|
15
|
+
assert.equal(key.keyId, ed25519Thumbprint(key.publicJwk));
|
|
16
|
+
assert.equal(key.publicJwk.d, undefined);
|
|
17
|
+
assert.match(key.privateKeyPem, /BEGIN PRIVATE KEY/);
|
|
18
|
+
assert.throws(() => generateWebBotAuthKey("http://insecure.example/agent"), /HTTPS/);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("default signature agents live under the Seedhape URL and are unique per agent", () => {
|
|
22
|
+
const a = defaultSignatureAgent("https://www.seedhape.com", "laptop");
|
|
23
|
+
const b = defaultSignatureAgent("https://www.seedhape.com", "laptop");
|
|
24
|
+
assert.match(a, /^https:\/\/www\.seedhape\.com\/agents\/laptop-[0-9a-f]{8}$/);
|
|
25
|
+
assert.notEqual(a, b);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("challenge signatures verify with Seedhape's registration check", () => {
|
|
29
|
+
const key = generateWebBotAuthKey("https://seedhape.example/agents/x");
|
|
30
|
+
const challenge = "Seedhape Web Bot Auth key registration\nuser=u1\nagent=laptop";
|
|
31
|
+
assert.equal(verifyWebBotAuthProof({ publicJwk: key.publicJwk, message: challenge, signature: signChallenge(key, challenge) }), true);
|
|
32
|
+
assert.equal(verifyWebBotAuthProof({ publicJwk: key.publicJwk, message: challenge + "!", signature: signChallenge(key, challenge) }), false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/** A fake hosted MCP that applies the same checks as Seedhape's register/rotate/revoke tools. */
|
|
36
|
+
function fakeHosted() {
|
|
37
|
+
const registered = new Map();
|
|
38
|
+
const challenges = new Set();
|
|
39
|
+
const calls = [];
|
|
40
|
+
const callTool = async (name, args) => {
|
|
41
|
+
calls.push(name);
|
|
42
|
+
if (name === "web_bot_auth_challenge") { const challenge = `challenge:${args.agent_id}:${args.key_id}:${Math.random()}`; challenges.add(challenge); return JSON.stringify({ challenge }); }
|
|
43
|
+
if (name === "register_web_bot_auth_key" || name === "rotate_web_bot_auth_key") {
|
|
44
|
+
if (args.key_id !== ed25519Thumbprint(args.public_jwk)) return "Key registration failed: key_id must be the RFC 7638 thumbprint";
|
|
45
|
+
if (!challenges.delete(args.challenge) || !verifyWebBotAuthProof({ publicJwk: args.public_jwk, message: args.challenge, signature: args.signature })) return JSON.stringify({ ok: false, error: { message: "proof invalid" } });
|
|
46
|
+
registered.set(`${args.signature_agent}|${args.key_id}`, { revoked: false });
|
|
47
|
+
return JSON.stringify({ registered: true, keyId: args.key_id });
|
|
48
|
+
}
|
|
49
|
+
if (name === "revoke_web_bot_auth_key") {
|
|
50
|
+
const entry = registered.get(`${args.signature_agent}|${args.key_id}`);
|
|
51
|
+
if (!entry) return "Web Bot Auth key not revoked: no key with that signature agent and key id is registered to this account.";
|
|
52
|
+
entry.revoked = true; return "Web Bot Auth key revoked.";
|
|
53
|
+
}
|
|
54
|
+
throw new Error(`unexpected tool ${name}`);
|
|
55
|
+
};
|
|
56
|
+
return { callTool, registered, calls };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
test("registration, rotation, and revocation go through Seedhape's challenge flow", async () => {
|
|
60
|
+
const hosted = fakeHosted();
|
|
61
|
+
const first = generateWebBotAuthKey("https://seedhape.example/agents/laptop-1");
|
|
62
|
+
await registerWebBotAuthKey(hosted.callTool, "laptop", first);
|
|
63
|
+
assert.equal(hosted.registered.get(`${first.signatureAgent}|${first.keyId}`).revoked, false);
|
|
64
|
+
|
|
65
|
+
const next = generateWebBotAuthKey(first.signatureAgent);
|
|
66
|
+
await registerWebBotAuthKey(hosted.callTool, "laptop", next, true);
|
|
67
|
+
await revokeWebBotAuthKey(hosted.callTool, first);
|
|
68
|
+
assert.equal(hosted.registered.get(`${first.signatureAgent}|${first.keyId}`).revoked, true);
|
|
69
|
+
assert.equal(hosted.registered.get(`${next.signatureAgent}|${next.keyId}`).revoked, false);
|
|
70
|
+
assert.deepEqual(hosted.calls, ["web_bot_auth_challenge", "register_web_bot_auth_key", "web_bot_auth_challenge", "rotate_web_bot_auth_key", "revoke_web_bot_auth_key"]);
|
|
71
|
+
|
|
72
|
+
const stranger = generateWebBotAuthKey("https://seedhape.example/agents/other");
|
|
73
|
+
await assert.rejects(() => revokeWebBotAuthKey(hosted.callTool, stranger), /not revoked/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("only HTTPS requests are signed, each with its own nonce", async () => {
|
|
77
|
+
const key = generateWebBotAuthKey("https://seedhape.example/agents/laptop-1");
|
|
78
|
+
const seen = [];
|
|
79
|
+
const signedFetch = webBotAuthFetch(async (input, init) => { seen.push({ url: String(input instanceof Request ? input.url : input), headers: new Headers(init?.headers) }); return new Response("ok"); }, key);
|
|
80
|
+
await signedFetch("https://merchant.example/a");
|
|
81
|
+
await signedFetch("https://merchant.example/a");
|
|
82
|
+
await signedFetch("http://localhost:3000/dev");
|
|
83
|
+
const publicKey = createPublicKey({ key: key.publicJwk, format: "jwk" });
|
|
84
|
+
const nonces = [];
|
|
85
|
+
for (const request of seen.slice(0, 2)) {
|
|
86
|
+
const result = await verifyWebBotAuth({ url: request.url, headers: request.headers, publicKey, expectedKeyId: key.keyId });
|
|
87
|
+
nonces.push(result.nonce);
|
|
88
|
+
}
|
|
89
|
+
assert.notEqual(nonces[0], nonces[1]);
|
|
90
|
+
assert.equal(seen[2].headers.get("signature"), null, "plain HTTP is sent unsigned");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("a paid purchase signs both the price probe and the paid retry, and the merchant accepts both", async () => {
|
|
94
|
+
// Fake Seedhape (plain HTTP on loopback) that approves and records settlement.
|
|
95
|
+
const settles = [];
|
|
96
|
+
const seedhape = createHttp(async (request, response) => {
|
|
97
|
+
let raw = ""; for await (const chunk of request) raw += chunk; const body = JSON.parse(raw || "{}");
|
|
98
|
+
if (request.url === "/api/local-wallet/authorize") { response.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ ok: true, authorizationId: "auth-wba", mandateHash: "0x1", amount: body.requirement.maxAmountRequired, payTo: body.requirement.payTo, network: body.requirement.network, expiresAt: 0 })); return; }
|
|
99
|
+
settles.push(body.status); response.writeHead(200, { "content-type": "application/json" }).end("{\"ok\":true}");
|
|
100
|
+
});
|
|
101
|
+
await new Promise((resolve) => seedhape.listen(0, "127.0.0.1", resolve));
|
|
102
|
+
process.env.SEEDHAPE_URL = `http://127.0.0.1:${seedhape.address().port}`;
|
|
103
|
+
process.env.SEEDHAPE_TOKEN = "test-token";
|
|
104
|
+
|
|
105
|
+
// Fake HTTPS merchant (in-process fetch) that REQUIRES a valid, non-replayed Web Bot Auth signature.
|
|
106
|
+
const key = generateWebBotAuthKey("https://seedhape.example/agents/laptop-1");
|
|
107
|
+
const publicKey = createPublicKey({ key: key.publicJwk, format: "jwk" });
|
|
108
|
+
const usedNonces = new Set();
|
|
109
|
+
const asset = USDC["base-sepolia"];
|
|
110
|
+
const merchant = async (input, init) => {
|
|
111
|
+
const url = String(input instanceof Request ? input.url : input);
|
|
112
|
+
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
|
|
113
|
+
try { await verifyWebBotAuth({ url, headers, publicKey, expectedKeyId: key.keyId, consumeNonce: ({ nonce }) => (usedNonces.has(nonce) ? false : (usedNonces.add(nonce), true)) }); }
|
|
114
|
+
catch (error) { return new Response(`bot auth failed: ${error.message}`, { status: 401 }); }
|
|
115
|
+
if (!headers.get("x-payment") && !headers.get("payment-signature")) {
|
|
116
|
+
return new Response(JSON.stringify({ x402Version: 1, accepts: [{ scheme: "exact", network: "base-sepolia", maxAmountRequired: "10000", resource: url, description: "Signal", payTo: "0x1111111111111111111111111111111111111111", asset: asset.address, maxTimeoutSeconds: 60, extra: { name: "USDC", version: "2" } }] }), { status: 402, headers: { "content-type": "application/json" } });
|
|
117
|
+
}
|
|
118
|
+
return new Response("signal: bullish", { status: 200, headers: { "x-payment-response": Buffer.from(JSON.stringify({ success: true, transaction: `0x${"cd".repeat(32)}`, network: "base-sepolia" })).toString("base64") } });
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const { createServer } = await import("../dist/server.js");
|
|
122
|
+
const signer = new LocalSessionKeySigner(generatePrivateKey());
|
|
123
|
+
const server = createServer({ agentId: "laptop", address: signer.address, signer, asset, absoluteMaxPerPayment: 1_000_000n, webBotAuth: key, fetchImpl: merchant, outboundPolicy: { allowPrivateNetworks: true } });
|
|
124
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
125
|
+
await server.connect(serverTransport);
|
|
126
|
+
const client = new Client({ name: "test", version: "1" });
|
|
127
|
+
await client.connect(clientTransport);
|
|
128
|
+
try {
|
|
129
|
+
const result = (await client.callTool({ name: "pay", arguments: { url: "https://merchant.example/signal" } })).content[0].text;
|
|
130
|
+
assert.match(result, /^Paid 0\.010000 USDC/, result);
|
|
131
|
+
assert.equal(usedNonces.size, 2, "probe and paid retry each carried a fresh signature");
|
|
132
|
+
assert.deepEqual(settles, ["settled"]);
|
|
133
|
+
} finally { await client.close(); seedhape.close(); }
|
|
134
|
+
});
|