Skip to content

Commit c24d2c7

Browse files
MajorTalclaude
andcommitted
Replace legacy 3-header wallet auth with SIWX (EIP-4361) across all interfaces
The Run402 API now expects a single SIGN-IN-WITH-X header using the CAIP-122 / EIP-4361 (SIWE) standard instead of the legacy X-Run402-Wallet/Signature/Timestamp headers. Inlines the SIWE message format in core to avoid heavy deps, and all three interfaces (MCP, CLI, OpenClaw) now use core's single signing code path. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 99afc2d commit c24d2c7

21 files changed

Lines changed: 269 additions & 68 deletions

cli/lib/agent.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ async function contact(args) {
2323
if (args[i] === "--webhook" && args[i + 1]) webhook = args[++i];
2424
}
2525
if (!name) { console.error(JSON.stringify({ status: "error", message: "Missing --name <name>" })); process.exit(1); }
26-
const authHeaders = await allowanceAuthHeaders();
26+
const authHeaders = allowanceAuthHeaders("/agent/v1/contact");
2727

2828
const body = { name };
2929
if (email) body.email = email;

cli/lib/apps.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ async function fork(versionId, name, args) {
4747
if (args[i] === "--tier" && args[i + 1]) opts.tier = args[++i];
4848
if (args[i] === "--subdomain" && args[i + 1]) opts.subdomain = args[++i];
4949
}
50-
const authHeaders = await allowanceAuthHeaders();
50+
const authHeaders = allowanceAuthHeaders("/fork/v1");
5151

5252
const body = { version_id: versionId, name };
5353
if (opts.subdomain) body.subdomain = opts.subdomain;

cli/lib/config.mjs

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import { getApiBase, getConfigDir, getKeystorePath, getAllowancePath } from "../core-dist/config.js";
77
import { readAllowance as coreReadAllowance, saveAllowance as coreSaveAllowance } from "../core-dist/allowance.js";
88
import { loadKeyStore, getProject, saveProject, updateProject, removeProject, saveKeyStore, getActiveProjectId, setActiveProjectId } from "../core-dist/keystore.js";
9+
import { getAllowanceAuthHeaders as coreGetAllowanceAuthHeaders } from "../core-dist/allowance-auth.js";
910

1011
export const CONFIG_DIR = getConfigDir();
1112
export const ALLOWANCE_FILE = getAllowancePath();
@@ -20,14 +21,10 @@ export function saveAllowance(data) {
2021
coreSaveAllowance(data);
2122
}
2223

23-
export async function allowanceAuthHeaders() {
24-
const w = readAllowance();
25-
if (!w) { console.error(JSON.stringify({ status: "error", message: "No agent allowance found. Run: run402 allowance create" })); process.exit(1); }
26-
const { privateKeyToAccount } = await import("viem/accounts");
27-
const account = privateKeyToAccount(w.privateKey);
28-
const timestamp = Math.floor(Date.now() / 1000).toString();
29-
const signature = await account.signMessage({ message: `run402:${timestamp}` });
30-
return { "X-Run402-Wallet": account.address, "X-Run402-Signature": signature, "X-Run402-Timestamp": timestamp };
24+
export function allowanceAuthHeaders(path) {
25+
const headers = coreGetAllowanceAuthHeaders(path);
26+
if (!headers) { console.error(JSON.stringify({ status: "error", message: "No agent allowance found. Run: run402 allowance create" })); process.exit(1); }
27+
return headers;
3128
}
3229

3330
export function findProject(id) {

cli/lib/deploy.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export async function run(args) {
6767

6868
const manifest = opts.manifest ? JSON.parse(readFileSync(opts.manifest, "utf-8")) : JSON.parse(await readStdin());
6969

70-
const authHeaders = await allowanceAuthHeaders();
70+
const authHeaders = allowanceAuthHeaders("/deploy/v1");
7171
const res = await fetch(`${API}/deploy/v1`, { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders }, body: JSON.stringify(manifest) });
7272
const result = await res.json();
7373
if (!res.ok) { console.error(JSON.stringify({ status: "error", http: res.status, ...result })); process.exit(1); }

cli/lib/init.mjs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { readAllowance, saveAllowance, loadKeyStore, CONFIG_DIR, ALLOWANCE_FILE, API } from "./config.mjs";
2+
import { getAllowanceAuthHeaders } from "../core-dist/allowance-auth.js";
23
import { mkdirSync } from "fs";
34

45
const USDC_ABI = [{ name: "balanceOf", type: "function", stateMutability: "view", inputs: [{ name: "account", type: "address" }], outputs: [{ name: "", type: "uint256" }] }];
@@ -74,14 +75,13 @@ export async function run() {
7475
const store = loadKeyStore();
7576
let tierInfo = null;
7677
try {
77-
const { privateKeyToAccount } = await import("viem/accounts");
78-
const account = privateKeyToAccount(allowance.privateKey);
79-
const timestamp = Math.floor(Date.now() / 1000).toString();
80-
const signature = await account.signMessage({ message: `run402:${timestamp}` });
81-
const res = await fetch(`${API}/tiers/v1/status`, {
82-
headers: { "X-Run402-Wallet": account.address, "X-Run402-Signature": signature, "X-Run402-Timestamp": timestamp },
83-
});
84-
if (res.ok) tierInfo = await res.json();
78+
const authHeaders = getAllowanceAuthHeaders("/tiers/v1/status");
79+
if (authHeaders) {
80+
const res = await fetch(`${API}/tiers/v1/status`, {
81+
headers: { ...authHeaders },
82+
});
83+
if (res.ok) tierInfo = await res.json();
84+
}
8585
} catch {}
8686

8787
if (tierInfo && tierInfo.tier && tierInfo.status === "active") {

cli/lib/message.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ Examples:
1515

1616
async function send(text) {
1717
if (!text) { console.error(JSON.stringify({ status: "error", message: "Missing message text" })); process.exit(1); }
18-
const authHeaders = await allowanceAuthHeaders();
18+
const authHeaders = allowanceAuthHeaders("/message/v1");
1919

2020
const res = await fetch(`${API}/message/v1`, {
2121
method: "POST",

cli/lib/projects.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ async function provision(args) {
5353
if (args[i] === "--tier" && args[i + 1]) opts.tier = args[++i];
5454
if (args[i] === "--name" && args[i + 1]) opts.name = args[++i];
5555
}
56-
const authHeaders = await allowanceAuthHeaders();
56+
const authHeaders = allowanceAuthHeaders("/projects/v1");
5757
const body = { tier: opts.tier };
5858
if (opts.name) body.name = opts.name;
5959
const res = await fetch(`${API}/projects/v1`, {

cli/lib/sites.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ async function deploy(args) {
5555
const body = { files: manifest.files, project: projectId };
5656
if (opts.target) body.target = opts.target;
5757

58-
const authHeaders = await allowanceAuthHeaders();
58+
const authHeaders = allowanceAuthHeaders("/deployments/v1");
5959
const res = await fetch(`${API}/deployments/v1`, {
6060
method: "POST",
6161
headers: { "Content-Type": "application/json", ...authHeaders },

cli/lib/tier.mjs

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { readAllowance, ALLOWANCE_FILE, API } from "./config.mjs";
1+
import { readAllowance, ALLOWANCE_FILE, API, allowanceAuthHeaders } from "./config.mjs";
22
import { setupPaidFetch } from "./paid-fetch.mjs";
33

44
const HELP = `run402 tier — Manage your Run402 tier subscription
@@ -25,14 +25,9 @@ Examples:
2525
`;
2626

2727
async function status() {
28-
const w = readAllowance();
29-
if (!w) { console.log(JSON.stringify({ status: "error", message: "No agent allowance. Run: run402 allowance create" })); process.exit(1); }
30-
const { privateKeyToAccount } = await import("viem/accounts");
31-
const account = privateKeyToAccount(w.privateKey);
32-
const timestamp = Math.floor(Date.now() / 1000).toString();
33-
const signature = await account.signMessage({ message: `run402:${timestamp}` });
28+
const authHeaders = allowanceAuthHeaders("/tiers/v1/status");
3429
const res = await fetch(`${API}/tiers/v1/status`, {
35-
headers: { "X-Run402-Wallet": account.address, "X-Run402-Signature": signature, "X-Run402-Timestamp": timestamp },
30+
headers: { ...authHeaders },
3631
});
3732
const data = await res.json();
3833
if (!res.ok) { console.error(JSON.stringify({ status: "error", http: res.status, ...data })); process.exit(1); }

core/src/allowance-auth.test.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { describe, it, beforeEach, afterEach } from "node:test";
2+
import assert from "node:assert/strict";
3+
import { mkdtempSync, rmSync } from "node:fs";
4+
import { join } from "node:path";
5+
import { tmpdir } from "node:os";
6+
import { toChecksumAddress, formatSIWEMessage, getAllowanceAuthHeaders } from "./allowance-auth.js";
7+
import { saveAllowance } from "./allowance.js";
8+
9+
// Known test private key and derived address (do NOT use in production)
10+
const TEST_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
11+
const TEST_ADDRESS = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
12+
13+
let tempDir: string;
14+
let allowancePath: string;
15+
16+
beforeEach(() => {
17+
tempDir = mkdtempSync(join(tmpdir(), "run402-siwx-test-"));
18+
allowancePath = join(tempDir, "allowance.json");
19+
process.env.RUN402_CONFIG_DIR = tempDir;
20+
process.env.RUN402_API_BASE = "https://api.run402.com";
21+
});
22+
23+
afterEach(() => {
24+
rmSync(tempDir, { recursive: true, force: true });
25+
delete process.env.RUN402_CONFIG_DIR;
26+
delete process.env.RUN402_API_BASE;
27+
});
28+
29+
describe("toChecksumAddress", () => {
30+
it("checksums a known address correctly", () => {
31+
const input = "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266";
32+
assert.equal(toChecksumAddress(input), TEST_ADDRESS);
33+
});
34+
35+
it("handles already-checksummed address", () => {
36+
assert.equal(toChecksumAddress(TEST_ADDRESS), TEST_ADDRESS);
37+
});
38+
39+
it("checksums all-lowercase address", () => {
40+
const result = toChecksumAddress("0xd8da6bf26964af9d7eed9e03e53415d37aa96045");
41+
assert.equal(result, "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045");
42+
});
43+
});
44+
45+
describe("formatSIWEMessage", () => {
46+
it("produces correct EIP-4361 format", () => {
47+
const msg = formatSIWEMessage(
48+
{
49+
domain: "api.run402.com",
50+
uri: "https://api.run402.com/projects/v1",
51+
statement: "Sign in to Run402",
52+
version: "1",
53+
chainId: 84532,
54+
nonce: "abc123def456abcd",
55+
issuedAt: "2026-03-17T00:00:00.000Z",
56+
},
57+
TEST_ADDRESS,
58+
);
59+
60+
assert.ok(msg.startsWith("api.run402.com wants you to sign in with your Ethereum account:"));
61+
assert.ok(msg.includes(TEST_ADDRESS));
62+
assert.ok(msg.includes("Sign in to Run402"));
63+
assert.ok(msg.includes("URI: https://api.run402.com/projects/v1"));
64+
assert.ok(msg.includes("Version: 1"));
65+
assert.ok(msg.includes("Chain ID: 84532"));
66+
assert.ok(msg.includes("Nonce: abc123def456abcd"));
67+
assert.ok(msg.includes("Issued At: 2026-03-17T00:00:00.000Z"));
68+
assert.ok(!msg.includes("Expiration Time:"));
69+
});
70+
71+
it("includes expiration time when provided", () => {
72+
const msg = formatSIWEMessage(
73+
{
74+
domain: "api.run402.com",
75+
uri: "https://api.run402.com/projects/v1",
76+
statement: "Sign in to Run402",
77+
version: "1",
78+
chainId: 84532,
79+
nonce: "abc123def456abcd",
80+
issuedAt: "2026-03-17T00:00:00.000Z",
81+
expirationTime: "2026-03-17T00:05:00.000Z",
82+
},
83+
TEST_ADDRESS,
84+
);
85+
86+
assert.ok(msg.includes("Expiration Time: 2026-03-17T00:05:00.000Z"));
87+
});
88+
});
89+
90+
describe("getAllowanceAuthHeaders", () => {
91+
it("returns null when no allowance exists", () => {
92+
const result = getAllowanceAuthHeaders("/projects/v1", allowancePath);
93+
assert.equal(result, null);
94+
});
95+
96+
it("returns SIGN-IN-WITH-X header with valid base64 JSON", () => {
97+
saveAllowance({ address: TEST_ADDRESS, privateKey: TEST_PRIVATE_KEY }, allowancePath);
98+
99+
const result = getAllowanceAuthHeaders("/projects/v1", allowancePath);
100+
assert.ok(result);
101+
assert.ok(result["SIGN-IN-WITH-X"]);
102+
103+
const decoded = JSON.parse(Buffer.from(result["SIGN-IN-WITH-X"], "base64").toString());
104+
assert.equal(decoded.domain, "api.run402.com");
105+
assert.equal(decoded.address, TEST_ADDRESS);
106+
assert.equal(decoded.uri, "https://api.run402.com/projects/v1");
107+
assert.equal(decoded.version, "1");
108+
assert.equal(decoded.chainId, 84532);
109+
assert.equal(decoded.type, "eip4361");
110+
assert.ok(decoded.nonce);
111+
assert.ok(decoded.issuedAt);
112+
assert.ok(decoded.expirationTime);
113+
assert.ok(decoded.signature);
114+
assert.ok(decoded.signature.startsWith("0x"));
115+
});
116+
117+
it("generates alphanumeric hex nonce (no hyphens)", () => {
118+
saveAllowance({ address: TEST_ADDRESS, privateKey: TEST_PRIVATE_KEY }, allowancePath);
119+
120+
const result = getAllowanceAuthHeaders("/projects/v1", allowancePath);
121+
assert.ok(result);
122+
const decoded = JSON.parse(Buffer.from(result["SIGN-IN-WITH-X"], "base64").toString());
123+
assert.match(decoded.nonce, /^[0-9a-f]{32}$/);
124+
});
125+
126+
it("uses checksummed address in payload", () => {
127+
saveAllowance({ address: TEST_ADDRESS.toLowerCase(), privateKey: TEST_PRIVATE_KEY }, allowancePath);
128+
129+
const result = getAllowanceAuthHeaders("/projects/v1", allowancePath);
130+
assert.ok(result);
131+
const decoded = JSON.parse(Buffer.from(result["SIGN-IN-WITH-X"], "base64").toString());
132+
assert.equal(decoded.address, TEST_ADDRESS);
133+
});
134+
});

0 commit comments

Comments
 (0)