Skip to content

Commit bcdddad

Browse files
MajorTalclaude
andcommitted
Fix SIWX signature (prehash:false) and add live integration test
@noble/curves v2 defaults to prehash:true (SHA-256 re-hashing), so our keccak256 hash was being double-hashed. Adding {prehash: false} makes the signature match viem's signMessage output byte-for-byte. Also adds core/src/siwx-integration.integ.ts — 5 tests that hit the live Run402 API with no mocks: - Happy path: fresh keypair → sign → server returns 200 - Negative: garbage header → 401 - Negative: tampered signature → 401 - Negative: missing statement → 401 - Negative: numeric chainId → 401 Runs locally via `npm run test:integration` and in CI. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9dc333d commit bcdddad

7 files changed

Lines changed: 172 additions & 7 deletions

File tree

.github/workflows/test.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,4 @@ jobs:
2222
- run: npm run test:sync
2323
- run: node --experimental-test-module-mocks --test --import tsx src/**/*.test.ts
2424
- run: npm run test:e2e
25+
- run: npm run test:integration

cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "run402",
3-
"version": "1.12.2",
3+
"version": "1.12.3",
44
"description": "CLI for Run402 — provision Postgres databases, deploy static sites, generate images, and manage wallets via x402 micropayments.",
55
"type": "module",
66
"bin": {

core/src/allowance-auth.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ function personalSign(privateKeyHex: string, address: string, message: string):
4444
? privateKeyHex.slice(2)
4545
: privateKeyHex;
4646
const pkBytes = Uint8Array.from(Buffer.from(pkHex, "hex"));
47-
const rawSig = secp256k1.sign(hash, pkBytes);
47+
const rawSig = secp256k1.sign(hash, pkBytes, { prehash: false });
4848
const sig = secp256k1.Signature.fromBytes(rawSig);
4949

5050
// Determine recovery bit by trying both and matching the address

core/src/siwx-integration.integ.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/**
2+
* siwx-integration.test.ts — Real integration test for SIWX auth.
3+
*
4+
* Hits the LIVE Run402 API (no mocks) to verify:
5+
* 1. The SIWE message format is byte-for-byte correct
6+
* 2. The EIP-191 signature is valid and recoverable
7+
* 3. The payload JSON matches the server's expected schema
8+
* (chainId as CAIP-2, type "eip191", statement present)
9+
* 4. The server accepts the SIGN-IN-WITH-X header (HTTP 200, not 401)
10+
*
11+
* Uses GET /tiers/v1/status — a read-only, free, idempotent endpoint.
12+
* A fresh random keypair is generated per run (no secrets needed).
13+
*
14+
* Run:
15+
* node --test --import tsx core/src/siwx-integration.test.ts
16+
* npm run test:integration
17+
*/
18+
19+
import { describe, it, beforeEach, afterEach } from "node:test";
20+
import assert from "node:assert/strict";
21+
import { mkdtempSync, rmSync } from "node:fs";
22+
import { join } from "node:path";
23+
import { tmpdir } from "node:os";
24+
import { randomBytes, createECDH } from "node:crypto";
25+
import { keccak_256 } from "@noble/hashes/sha3.js";
26+
import { saveAllowance } from "./allowance.js";
27+
import { getAllowanceAuthHeaders } from "./allowance-auth.js";
28+
29+
const API = "https://api.run402.com";
30+
31+
let tempDir: string;
32+
let allowancePath: string;
33+
34+
/**
35+
* Generate a fresh random EVM keypair (same logic as allowance-create).
36+
*/
37+
function generateKeypair() {
38+
const privateKeyBytes = randomBytes(32);
39+
const privateKey = `0x${privateKeyBytes.toString("hex")}`;
40+
const ecdh = createECDH("secp256k1");
41+
ecdh.setPrivateKey(privateKeyBytes);
42+
const uncompressedPubKey = ecdh.getPublicKey();
43+
const pubKeyBody = uncompressedPubKey.subarray(1);
44+
const hash = keccak_256(pubKeyBody);
45+
const addressBytes = hash.slice(-20);
46+
const address = `0x${Buffer.from(addressBytes).toString("hex")}`;
47+
return { address, privateKey };
48+
}
49+
50+
beforeEach(() => {
51+
tempDir = mkdtempSync(join(tmpdir(), "run402-siwx-integ-"));
52+
allowancePath = join(tempDir, "allowance.json");
53+
process.env.RUN402_CONFIG_DIR = tempDir;
54+
process.env.RUN402_API_BASE = API;
55+
});
56+
57+
afterEach(() => {
58+
rmSync(tempDir, { recursive: true, force: true });
59+
delete process.env.RUN402_CONFIG_DIR;
60+
delete process.env.RUN402_API_BASE;
61+
});
62+
63+
describe("SIWX auth integration (live API)", () => {
64+
it("GET /tiers/v1/status accepts SIWX header and returns 200", async () => {
65+
// 1. Generate a fresh keypair and save it as the allowance
66+
const { address, privateKey } = generateKeypair();
67+
saveAllowance({ address, privateKey, created: new Date().toISOString(), funded: false }, allowancePath);
68+
69+
// 2. Generate the SIWX auth header (the code under test)
70+
const headers = getAllowanceAuthHeaders("/tiers/v1/status", allowancePath);
71+
assert.ok(headers, "getAllowanceAuthHeaders should return headers");
72+
assert.ok(headers["SIGN-IN-WITH-X"], "should have SIGN-IN-WITH-X header");
73+
74+
// 3. Verify payload structure before sending
75+
const payload = JSON.parse(Buffer.from(headers["SIGN-IN-WITH-X"], "base64").toString());
76+
assert.equal(payload.chainId, "eip155:84532", "chainId must be CAIP-2 format");
77+
assert.equal(payload.type, "eip191", "type must be eip191");
78+
assert.equal(payload.statement, "Sign in to Run402", "statement must be present");
79+
assert.ok(payload.signature.startsWith("0x"), "signature must be hex");
80+
assert.equal(payload.signature.length, 132, "signature must be 65 bytes (r+s+v) as hex");
81+
82+
// 4. Hit the real API
83+
const res = await fetch(`${API}/tiers/v1/status`, {
84+
headers: { ...headers },
85+
});
86+
87+
// 5. Assert the server accepted our auth — the key assertion
88+
assert.notEqual(res.status, 401, `Auth rejected (401). Body: ${await res.clone().text()}`);
89+
assert.notEqual(res.status, 403, `Auth forbidden (403). Body: ${await res.clone().text()}`);
90+
assert.ok(res.ok, `Expected 2xx, got ${res.status}. Body: ${await res.clone().text()}`);
91+
92+
// 6. Verify the response is valid JSON with expected shape
93+
const data = await res.json();
94+
assert.ok("tier" in data || "status" in data, "response should have tier or status field");
95+
});
96+
97+
it("server rejects a malformed SIWX header", async () => {
98+
// Send a garbage header to prove the server actually validates
99+
const res = await fetch(`${API}/tiers/v1/status`, {
100+
headers: { "SIGN-IN-WITH-X": Buffer.from("{}").toString("base64") },
101+
});
102+
103+
assert.equal(res.status, 401, "server should reject empty/invalid SIWX payload");
104+
});
105+
106+
it("server rejects a tampered signature", async () => {
107+
const { address, privateKey } = generateKeypair();
108+
saveAllowance({ address, privateKey, created: new Date().toISOString(), funded: false }, allowancePath);
109+
110+
const headers = getAllowanceAuthHeaders("/tiers/v1/status", allowancePath);
111+
assert.ok(headers);
112+
113+
// Tamper with the signature (flip last hex char)
114+
const payload = JSON.parse(Buffer.from(headers["SIGN-IN-WITH-X"], "base64").toString());
115+
const lastChar = payload.signature.slice(-1);
116+
payload.signature = payload.signature.slice(0, -1) + (lastChar === "0" ? "1" : "0");
117+
const tampered = Buffer.from(JSON.stringify(payload)).toString("base64");
118+
119+
const res = await fetch(`${API}/tiers/v1/status`, {
120+
headers: { "SIGN-IN-WITH-X": tampered },
121+
});
122+
123+
assert.equal(res.status, 401, "server should reject tampered signature");
124+
});
125+
126+
it("server rejects missing required fields", async () => {
127+
const { address, privateKey } = generateKeypair();
128+
saveAllowance({ address, privateKey, created: new Date().toISOString(), funded: false }, allowancePath);
129+
130+
const headers = getAllowanceAuthHeaders("/tiers/v1/status", allowancePath);
131+
assert.ok(headers);
132+
133+
// Remove statement from payload
134+
const payload = JSON.parse(Buffer.from(headers["SIGN-IN-WITH-X"], "base64").toString());
135+
delete payload.statement;
136+
const modified = Buffer.from(JSON.stringify(payload)).toString("base64");
137+
138+
const res = await fetch(`${API}/tiers/v1/status`, {
139+
headers: { "SIGN-IN-WITH-X": modified },
140+
});
141+
142+
assert.equal(res.status, 401, "server should reject payload missing statement");
143+
});
144+
145+
it("server rejects numeric chainId (must be CAIP-2)", async () => {
146+
const { address, privateKey } = generateKeypair();
147+
saveAllowance({ address, privateKey, created: new Date().toISOString(), funded: false }, allowancePath);
148+
149+
const headers = getAllowanceAuthHeaders("/tiers/v1/status", allowancePath);
150+
assert.ok(headers);
151+
152+
// Replace CAIP-2 chainId with numeric
153+
const payload = JSON.parse(Buffer.from(headers["SIGN-IN-WITH-X"], "base64").toString());
154+
payload.chainId = 84532;
155+
const modified = Buffer.from(JSON.stringify(payload)).toString("base64");
156+
157+
const res = await fetch(`${API}/tiers/v1/status`, {
158+
headers: { "SIGN-IN-WITH-X": modified },
159+
});
160+
161+
assert.equal(res.status, 401, "server should reject numeric chainId");
162+
});
163+
});

core/tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,5 @@
1414
"rootDir": "src"
1515
},
1616
"include": ["src"],
17-
"exclude": ["src/**/*.test.ts"]
17+
"exclude": ["src/**/*.test.ts", "src/**/*.integ.ts"]
1818
}

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "run402-mcp",
3-
"version": "1.12.2",
3+
"version": "1.12.3",
44
"description": "MCP server for Run402 — AI-native Postgres databases with REST API, auth, storage, and row-level security. Pay with x402 USDC micropayments.",
55
"type": "module",
66
"main": "dist/index.js",
@@ -20,7 +20,8 @@
2020
"test:skill": "node --test --import tsx SKILL.test.ts",
2121
"test:sync": "node --test --import tsx sync.test.ts",
2222
"test": "node --experimental-test-module-mocks --test --import tsx SKILL.test.ts sync.test.ts core/src/**/*.test.ts src/**/*.test.ts && node --test cli-e2e.test.mjs",
23-
"test:e2e": "node --test cli-e2e.test.mjs"
23+
"test:e2e": "node --test cli-e2e.test.mjs",
24+
"test:integration": "node --test --import tsx core/src/siwx-integration.integ.ts"
2425
},
2526
"keywords": [
2627
"mcp",

0 commit comments

Comments
 (0)