@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
package/src/hosted.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Talks to the user's Seedhape account. The local wallet holds the key, but
|
|
3
|
+
* Seedhape holds the rules: every payment is checked and reserved there before
|
|
4
|
+
* the key signs, and reported back afterwards. If Seedhape can't be reached, no
|
|
5
|
+
* payment is made (fail closed).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
9
|
+
import { createServer } from "node:http";
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import type { AddressInfo } from "node:net";
|
|
12
|
+
import keytar from "keytar";
|
|
13
|
+
|
|
14
|
+
const TOKEN_SERVICE = "seedhape-account-token";
|
|
15
|
+
|
|
16
|
+
export const seedhapeUrl = () => (process.env.SEEDHAPE_URL ?? "https://www.seedhape.com").replace(/\/$/, "");
|
|
17
|
+
|
|
18
|
+
export class SeedhapeUnreachableError extends Error {
|
|
19
|
+
constructor(cause: unknown) {
|
|
20
|
+
super(`Seedhape could not be reached (${cause instanceof Error ? cause.message : String(cause)}). No payment was made; try again when you're back online.`);
|
|
21
|
+
this.name = "SeedhapeUnreachableError";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class SeedhapeAuthError extends Error {
|
|
26
|
+
constructor() {
|
|
27
|
+
super("This local wallet isn't signed in to Seedhape. Run `seedhape login` in a terminal, then try again.");
|
|
28
|
+
this.name = "SeedhapeAuthError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function saveToken(token: string, url = seedhapeUrl()): Promise<void> { await keytar.setPassword(TOKEN_SERVICE, url, token); }
|
|
33
|
+
export async function loadToken(url = seedhapeUrl()): Promise<string | null> { return process.env.SEEDHAPE_TOKEN ?? await keytar.getPassword(TOKEN_SERVICE, url); }
|
|
34
|
+
export async function deleteToken(url = seedhapeUrl()): Promise<boolean> { return keytar.deletePassword(TOKEN_SERVICE, url); }
|
|
35
|
+
|
|
36
|
+
/** POST JSON to the Seedhape API with the stored token. Network failures become SeedhapeUnreachableError. */
|
|
37
|
+
export async function hostedPost<T>(path: string, body: unknown): Promise<{ status: number; body: T }> {
|
|
38
|
+
const token = await loadToken();
|
|
39
|
+
if (!token) throw new SeedhapeAuthError();
|
|
40
|
+
let response: Response;
|
|
41
|
+
try {
|
|
42
|
+
response = await fetch(`${seedhapeUrl()}${path}`, {
|
|
43
|
+
method: "POST",
|
|
44
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
|
45
|
+
body: JSON.stringify(body),
|
|
46
|
+
signal: AbortSignal.timeout(20_000),
|
|
47
|
+
});
|
|
48
|
+
} catch (error) {
|
|
49
|
+
throw new SeedhapeUnreachableError(error);
|
|
50
|
+
}
|
|
51
|
+
if (response.status === 401) throw new SeedhapeAuthError();
|
|
52
|
+
if (response.status >= 500) throw new SeedhapeUnreachableError(`HTTP ${response.status}`);
|
|
53
|
+
return { status: response.status, body: await response.json() as T };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Check that the stored token works, returning the account's spending status. */
|
|
57
|
+
export async function whoAmI(): Promise<{ ok: boolean; detail: string }> {
|
|
58
|
+
const token = await loadToken();
|
|
59
|
+
if (!token) return { ok: false, detail: "not signed in" };
|
|
60
|
+
try {
|
|
61
|
+
const response = await fetch(`${seedhapeUrl()}/mcp`, {
|
|
62
|
+
method: "POST",
|
|
63
|
+
headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
|
|
64
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "spending_status", arguments: {} } }),
|
|
65
|
+
signal: AbortSignal.timeout(20_000),
|
|
66
|
+
});
|
|
67
|
+
if (response.status === 401) return { ok: false, detail: "the saved sign-in has expired; run `seedhape login` again" };
|
|
68
|
+
return { ok: response.ok, detail: response.ok ? "signed in" : `HTTP ${response.status}` };
|
|
69
|
+
} catch (error) {
|
|
70
|
+
return { ok: false, detail: new SeedhapeUnreachableError(error).message };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function openBrowser(url: string) {
|
|
75
|
+
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
76
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
77
|
+
try { spawn(command, args, { stdio: "ignore", detached: true }).unref(); } catch { /* the URL is printed as a fallback */ }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Browser sign-in using Seedhape's OAuth server (the same one Claude and ChatGPT
|
|
82
|
+
* use for the hosted MCP): dynamic client registration, a loopback redirect,
|
|
83
|
+
* and PKCE. The token is stored in the OS keychain.
|
|
84
|
+
*/
|
|
85
|
+
export async function loginWithBrowser(log: (line: string) => void): Promise<void> {
|
|
86
|
+
const base = seedhapeUrl();
|
|
87
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
88
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
89
|
+
const state = randomBytes(16).toString("base64url");
|
|
90
|
+
|
|
91
|
+
let resolveCode: (code: string) => void = () => {};
|
|
92
|
+
let rejectCode: (error: Error) => void = () => {};
|
|
93
|
+
const codePromise = new Promise<string>((resolve, reject) => { resolveCode = resolve; rejectCode = reject; });
|
|
94
|
+
const server = createServer((request, response) => {
|
|
95
|
+
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
96
|
+
if (url.pathname !== "/callback") { response.writeHead(404).end(); return; }
|
|
97
|
+
const done = (message: string) => response.writeHead(200, { "content-type": "text/html" }).end(`<!doctype html><meta charset="utf-8"><title>Seedhape</title><body style="font-family:sans-serif;background:#04050a;color:#e9ecf2;display:grid;place-items:center;height:100vh"><p>${message}</p></body>`);
|
|
98
|
+
if (url.searchParams.get("state") !== state) { done("Sign-in failed: state mismatch. Close this tab and run seedhape login again."); rejectCode(new Error("OAuth state mismatch.")); return; }
|
|
99
|
+
const code = url.searchParams.get("code");
|
|
100
|
+
if (!code) { done("Sign-in was cancelled. You can close this tab."); rejectCode(new Error(url.searchParams.get("error") ?? "Sign-in was cancelled.")); return; }
|
|
101
|
+
done("You're signed in. You can close this tab and return to your agent.");
|
|
102
|
+
resolveCode(code);
|
|
103
|
+
});
|
|
104
|
+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
105
|
+
const redirectUri = `http://127.0.0.1:${(server.address() as AddressInfo).port}/callback`;
|
|
106
|
+
try {
|
|
107
|
+
const registration = await fetch(`${base}/oauth/register`, {
|
|
108
|
+
method: "POST", headers: { "content-type": "application/json" },
|
|
109
|
+
body: JSON.stringify({ redirect_uris: [redirectUri], client_name: "Seedhape local wallet" }),
|
|
110
|
+
}).catch((error) => { throw new SeedhapeUnreachableError(error); });
|
|
111
|
+
const client = await registration.json() as { client_id?: string; error?: string };
|
|
112
|
+
if (!registration.ok || !client.client_id) throw new Error(client.error ?? "Seedhape did not register this local wallet.");
|
|
113
|
+
|
|
114
|
+
const authorize = new URL(`${base}/oauth/authorize`);
|
|
115
|
+
authorize.search = new URLSearchParams({ response_type: "code", client_id: client.client_id, redirect_uri: redirectUri, code_challenge: challenge, code_challenge_method: "S256", state, scope: "mcp" }).toString();
|
|
116
|
+
log(`Opening your browser to sign in:\n ${authorize.toString()}`);
|
|
117
|
+
openBrowser(authorize.toString());
|
|
118
|
+
|
|
119
|
+
const timeout = setTimeout(() => rejectCode(new Error("Sign-in timed out after 5 minutes.")), 5 * 60_000);
|
|
120
|
+
const code = await codePromise.finally(() => clearTimeout(timeout));
|
|
121
|
+
const token = await fetch(`${base}/oauth/token`, {
|
|
122
|
+
method: "POST",
|
|
123
|
+
body: new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: redirectUri, client_id: client.client_id, code_verifier: verifier }),
|
|
124
|
+
});
|
|
125
|
+
const issued = await token.json() as { access_token?: string; error?: string };
|
|
126
|
+
if (!token.ok || !issued.access_token) throw new Error(issued.error ?? "Seedhape did not issue a token.");
|
|
127
|
+
await saveToken(issued.access_token, base);
|
|
128
|
+
} finally {
|
|
129
|
+
server.close();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── Payment authorization ──────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
export type HostedRequirement = { scheme: string; network: string; maxAmountRequired: string; payTo: string; asset: string; resource?: string; description?: string; maxTimeoutSeconds?: number };
|
|
136
|
+
export type HostedRefusal = { ok: false; code: string; error: string; hint: string; recovery?: { tool: string }; authorizationId?: string; approvalId?: string; approvalUrl?: string };
|
|
137
|
+
export type HostedAuthorization = { ok: true; authorizationId: string; mandateHash: string; amount: string; payTo: string; network: string; expiresAt: number };
|
|
138
|
+
|
|
139
|
+
export async function authorizePayment(input: { agentId: string; url: string; requirement: HostedRequirement; purchaseId?: string; confirmUnusual?: boolean; approvalId?: string; method?: "GET" | "POST"; bodySha256?: string }): Promise<HostedAuthorization | HostedRefusal> {
|
|
140
|
+
const { body } = await hostedPost<HostedAuthorization | HostedRefusal>("/api/local-wallet/authorize", {
|
|
141
|
+
agent_id: input.agentId, url: input.url, requirement: input.requirement,
|
|
142
|
+
...(input.purchaseId ? { purchase_id: input.purchaseId } : {}), ...(input.confirmUnusual ? { confirm_unusual: true } : {}),
|
|
143
|
+
...(input.approvalId ? { approval_id: input.approvalId } : {}), ...(input.method ? { method: input.method } : {}), ...(input.bodySha256 ? { body_sha256: input.bodySha256 } : {}),
|
|
144
|
+
});
|
|
145
|
+
return body;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function settlePayment(input: { authorizationId: string; status: "settled" | "failed"; txHash?: string; error?: string }): Promise<void> {
|
|
149
|
+
await hostedPost("/api/local-wallet/settle", {
|
|
150
|
+
authorization_id: input.authorizationId, status: input.status,
|
|
151
|
+
...(input.txHash ? { tx_hash: input.txHash } : {}), ...(input.error ? { error: input.error.slice(0, 1000) } : {}),
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Call a tool on the hosted Seedhape MCP and return its text result. */
|
|
156
|
+
export async function callHostedTool(name: string, args: Record<string, unknown>): Promise<string> {
|
|
157
|
+
const token = await loadToken();
|
|
158
|
+
if (!token) throw new SeedhapeAuthError();
|
|
159
|
+
let response: Response;
|
|
160
|
+
try {
|
|
161
|
+
response = await fetch(`${seedhapeUrl()}/mcp`, {
|
|
162
|
+
method: "POST",
|
|
163
|
+
headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
|
|
164
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: randomBytes(4).readUInt32BE(0), method: "tools/call", params: { name, arguments: args } }),
|
|
165
|
+
signal: AbortSignal.timeout(30_000),
|
|
166
|
+
});
|
|
167
|
+
} catch (error) {
|
|
168
|
+
throw new SeedhapeUnreachableError(error);
|
|
169
|
+
}
|
|
170
|
+
if (response.status === 401) throw new SeedhapeAuthError();
|
|
171
|
+
const body = await response.json() as { result?: { content?: Array<{ text?: string }> }; error?: { message?: string } };
|
|
172
|
+
if (body.error) throw new Error(body.error.message ?? `Seedhape tool ${name} failed.`);
|
|
173
|
+
return body.result?.content?.map((item) => item.text ?? "").join("\n") ?? "";
|
|
174
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes, scryptSync } from "node:crypto";
|
|
2
|
+
import { privateKeyToAccount } from "viem/accounts";
|
|
3
|
+
import type { Hex } from "@seedhape/core";
|
|
4
|
+
|
|
5
|
+
const FORMAT = "seedhape-agent-identity-backup";
|
|
6
|
+
const AAD = Buffer.from(`${FORMAT}:v1`);
|
|
7
|
+
const MAX_BACKUP_BYTES = 10 * 1024 * 1024;
|
|
8
|
+
const FILE_PATH = /^(state\/[a-f0-9]{64}\.state\.json|keys\/[a-f0-9]{64}\.json|metadata\/[a-f0-9]{64}\.(directory|registration)\.json)$/;
|
|
9
|
+
|
|
10
|
+
export interface LocalIdentityBackupPayload {
|
|
11
|
+
version: 1;
|
|
12
|
+
accountId: string;
|
|
13
|
+
controllerPrivateKey: Hex;
|
|
14
|
+
keyStoreMasterKey: string;
|
|
15
|
+
files: Record<string, string>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface EncryptedBackup {
|
|
19
|
+
format: typeof FORMAT;
|
|
20
|
+
version: 1;
|
|
21
|
+
kdf: "scrypt-N32768-r8-p1";
|
|
22
|
+
cipher: "aes-256-gcm";
|
|
23
|
+
salt: string;
|
|
24
|
+
iv: string;
|
|
25
|
+
ciphertext: string;
|
|
26
|
+
tag: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function encryptIdentityBackup(payload: LocalIdentityBackupPayload, passphrase: string): string {
|
|
30
|
+
validatePassphrase(passphrase);
|
|
31
|
+
validatePayload(payload);
|
|
32
|
+
const salt = randomBytes(16);
|
|
33
|
+
const iv = randomBytes(12);
|
|
34
|
+
const key = scryptSync(passphrase, salt, 32, { N: 32_768, r: 8, p: 1, maxmem: 64 * 1024 * 1024 });
|
|
35
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
36
|
+
cipher.setAAD(AAD);
|
|
37
|
+
const ciphertext = Buffer.concat([cipher.update(JSON.stringify(payload), "utf8"), cipher.final()]);
|
|
38
|
+
const result: EncryptedBackup = {
|
|
39
|
+
format: FORMAT,
|
|
40
|
+
version: 1,
|
|
41
|
+
kdf: "scrypt-N32768-r8-p1",
|
|
42
|
+
cipher: "aes-256-gcm",
|
|
43
|
+
salt: salt.toString("base64url"),
|
|
44
|
+
iv: iv.toString("base64url"),
|
|
45
|
+
ciphertext: ciphertext.toString("base64url"),
|
|
46
|
+
tag: cipher.getAuthTag().toString("base64url"),
|
|
47
|
+
};
|
|
48
|
+
const encoded = JSON.stringify(result);
|
|
49
|
+
if (Buffer.byteLength(encoded) > MAX_BACKUP_BYTES) throw new Error("Identity backup exceeds the 10 MB limit.");
|
|
50
|
+
return encoded;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function decryptIdentityBackup(encoded: string, passphrase: string): LocalIdentityBackupPayload {
|
|
54
|
+
validatePassphrase(passphrase);
|
|
55
|
+
if (Buffer.byteLength(encoded) > MAX_BACKUP_BYTES) throw new Error("Identity backup exceeds the 10 MB limit.");
|
|
56
|
+
let backup: EncryptedBackup;
|
|
57
|
+
try { backup = JSON.parse(encoded) as EncryptedBackup; }
|
|
58
|
+
catch { throw new Error("Identity backup is not valid JSON."); }
|
|
59
|
+
if (backup.format !== FORMAT || backup.version !== 1 || backup.kdf !== "scrypt-N32768-r8-p1" || backup.cipher !== "aes-256-gcm") {
|
|
60
|
+
throw new Error("Unsupported identity backup format.");
|
|
61
|
+
}
|
|
62
|
+
const salt = decodeBase64Url(backup.salt, 16, "salt");
|
|
63
|
+
const iv = decodeBase64Url(backup.iv, 12, "IV");
|
|
64
|
+
const tag = decodeBase64Url(backup.tag, 16, "authentication tag");
|
|
65
|
+
const ciphertext = decodeBase64Url(backup.ciphertext, undefined, "ciphertext");
|
|
66
|
+
const key = scryptSync(passphrase, salt, 32, { N: 32_768, r: 8, p: 1, maxmem: 64 * 1024 * 1024 });
|
|
67
|
+
try {
|
|
68
|
+
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
|
69
|
+
decipher.setAAD(AAD);
|
|
70
|
+
decipher.setAuthTag(tag);
|
|
71
|
+
const json = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
|
|
72
|
+
const payload = JSON.parse(json) as LocalIdentityBackupPayload;
|
|
73
|
+
validatePayload(payload);
|
|
74
|
+
return payload;
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (error instanceof Error && error.message.startsWith("Invalid identity backup")) throw error;
|
|
77
|
+
throw new Error("Could not decrypt identity backup. Check the passphrase and file integrity.");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function validatePayload(payload: LocalIdentityBackupPayload): void {
|
|
82
|
+
if (!payload || payload.version !== 1 || typeof payload.accountId !== "string" || payload.accountId.length < 1 || payload.accountId.length > 256) {
|
|
83
|
+
throw new Error("Invalid identity backup: account metadata is missing.");
|
|
84
|
+
}
|
|
85
|
+
if (!/^0x[0-9a-fA-F]{64}$/.test(payload.controllerPrivateKey)) throw new Error("Invalid identity backup: controller key is malformed.");
|
|
86
|
+
if (privateKeyToAccount(payload.controllerPrivateKey).address === "0x0000000000000000000000000000000000000000") throw new Error("Invalid identity backup: controller key is invalid.");
|
|
87
|
+
const masterKey = decodeBase64Url(payload.keyStoreMasterKey, 32, "key-store key");
|
|
88
|
+
if (masterKey.length !== 32) throw new Error("Invalid identity backup: key-store key must be 32 bytes.");
|
|
89
|
+
if (!payload.files || typeof payload.files !== "object" || Array.isArray(payload.files)) throw new Error("Invalid identity backup: file manifest is missing.");
|
|
90
|
+
const names = Object.keys(payload.files);
|
|
91
|
+
if (names.length > 256 || names.some((name) => !FILE_PATH.test(name))) throw new Error("Invalid identity backup: file manifest contains an invalid path.");
|
|
92
|
+
if (names.length < 4) throw new Error("Invalid identity backup: file manifest is incomplete.");
|
|
93
|
+
let total = 0;
|
|
94
|
+
for (const [name, contents] of Object.entries(payload.files)) {
|
|
95
|
+
if (typeof contents !== "string") throw new Error("Invalid identity backup: file contents are malformed.");
|
|
96
|
+
const bytes = decodeBase64Url(contents, undefined, `file ${name}`);
|
|
97
|
+
total += bytes.length;
|
|
98
|
+
if (total > MAX_BACKUP_BYTES) throw new Error("Invalid identity backup: files exceed the size limit.");
|
|
99
|
+
}
|
|
100
|
+
const statePaths = names.filter((name) => name.startsWith("state/"));
|
|
101
|
+
const keyPaths = names.filter((name) => name.startsWith("keys/"));
|
|
102
|
+
const metadataPaths = names.filter((name) => name.startsWith("metadata/"));
|
|
103
|
+
if (statePaths.length !== 1 || !keyPaths.length || !metadataPaths.some((path) => path.endsWith(".directory.json")) || !metadataPaths.some((path) => path.endsWith(".registration.json"))) {
|
|
104
|
+
throw new Error("Invalid identity backup: setup state, signing key, and public metadata are all required.");
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
const stateText = Buffer.from(payload.files[statePaths[0]]!, "base64url").toString("utf8");
|
|
108
|
+
const state = JSON.parse(stateText) as { controller?: unknown; stage?: unknown; keyStoreRef?: unknown; identityRef?: unknown; keyId?: unknown };
|
|
109
|
+
const controller = privateKeyToAccount(payload.controllerPrivateKey).address;
|
|
110
|
+
if (typeof state.controller !== "string" || state.controller.toLowerCase() !== controller.toLowerCase() || state.stage !== "ready" || typeof state.keyStoreRef !== "string" || !/^[a-f0-9]{64}$/.test(state.keyStoreRef) || typeof state.identityRef !== "string" || typeof state.keyId !== "string") {
|
|
111
|
+
throw new Error();
|
|
112
|
+
}
|
|
113
|
+
if (!payload.files[`keys/${state.keyStoreRef}.json`]) throw new Error();
|
|
114
|
+
const identityHash = createHash("sha256").update(state.identityRef).digest("hex");
|
|
115
|
+
const directory = JSON.parse(Buffer.from(payload.files[`metadata/${identityHash}.directory.json`] ?? "", "base64url").toString("utf8")) as { identityRef?: string; keyId?: string };
|
|
116
|
+
const registration = JSON.parse(Buffer.from(payload.files[`metadata/${identityHash}.registration.json`] ?? "", "base64url").toString("utf8")) as { services?: Array<Record<string, unknown>> };
|
|
117
|
+
if (directory.identityRef !== state.identityRef || directory.keyId !== state.keyId || !registration.services?.some((service) => service.name === "Web Bot Auth" && service.keyId === state.keyId)) throw new Error();
|
|
118
|
+
} catch {
|
|
119
|
+
throw new Error("Invalid identity backup: controller does not match the saved setup state.");
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function validatePassphrase(passphrase: string) {
|
|
124
|
+
if (typeof passphrase !== "string" || passphrase.length < 12 || Buffer.byteLength(passphrase) > 1024) {
|
|
125
|
+
throw new Error("Backup passphrase must be at least 12 characters and no more than 1 KB.");
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function decodeBase64Url(value: string, expectedLength: number | undefined, label: string): Buffer {
|
|
130
|
+
if (typeof value !== "string" || !/^[A-Za-z0-9_-]*$/.test(value)) throw new Error(`Invalid identity backup: ${label} encoding is malformed.`);
|
|
131
|
+
const decoded = Buffer.from(value, "base64url");
|
|
132
|
+
if (expectedLength !== undefined && decoded.length !== expectedLength) throw new Error(`Invalid identity backup: ${label} must be ${expectedLength} bytes.`);
|
|
133
|
+
return decoded;
|
|
134
|
+
}
|