Skip to content

Commit 57149ff

Browse files
authored
Merge pull request #47 from szhygulin/refactor/reduce-size
refactor + audit hardening: boilerplate dedup + 6 security fixes
2 parents a25a97e + b606722 commit 57149ff

7 files changed

Lines changed: 351 additions & 47 deletions

File tree

Dockerfile

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,10 @@ ENV NODE_ENV=production
1212
COPY package.json package-lock.json ./
1313
RUN npm ci --omit=dev
1414
COPY --from=builder /app/dist ./dist
15+
# The node:alpine image ships a pre-created unprivileged `node` user/group.
16+
# Running as root gives a compromise inside the process write access to the
17+
# whole container filesystem; dropping to `node` keeps the blast radius
18+
# confined to /app and /tmp. No network/USB privileges are needed — TRON
19+
# signing runs on the host, this image is for EVM-only read surfaces.
20+
USER node
1521
ENTRYPOINT ["node", "dist/index.js"]

src/modules/execution/index.ts

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { encodeFunctionData, formatUnits, parseEther, parseUnits } from "viem";
1+
import { encodeFunctionData, formatUnits, isAddress, parseEther, parseUnits } from "viem";
22
import qrcodeTerminal from "qrcode-terminal";
33
import {
44
initiatePairing,
@@ -266,10 +266,29 @@ export async function prepareEigenLayerDeposit(args: PrepareEigenLayerDepositArg
266266

267267
// ----- Native + ERC-20 transfers -----
268268

269+
/**
270+
* Accept recipient addresses that are either all-lowercase hex (no checksum
271+
* intent) or valid EIP-55 checksummed. Reject mixed-case with a wrong
272+
* checksum — that is the class of error where a user pasted an address with
273+
* a single-character case typo; viem's bare `as 0x${string}` cast would
274+
* otherwise pass it through silently. viem's `isAddress(x, { strict: true })`
275+
* encodes exactly this policy.
276+
*/
277+
function assertRecipient(addr: string): `0x${string}` {
278+
if (!isAddress(addr, { strict: true })) {
279+
throw new Error(
280+
`Invalid recipient address ${addr}: failed EIP-55 checksum or malformed hex. ` +
281+
`If you pasted a mixed-case address, a single-character case typo is the most ` +
282+
`likely cause — re-check the source.`,
283+
);
284+
}
285+
return addr as `0x${string}`;
286+
}
287+
269288
export async function prepareNativeSend(args: PrepareNativeSendArgs): Promise<UnsignedTx> {
270289
const wallet = args.wallet as `0x${string}`;
271290
const chain = args.chain as SupportedChain;
272-
const to = args.to as `0x${string}`;
291+
const to = assertRecipient(args.to);
273292
const value = parseEther(args.amount);
274293
return enrichTx({
275294
chain,
@@ -286,7 +305,7 @@ export async function prepareTokenSend(args: PrepareTokenSendArgs): Promise<Unsi
286305
const wallet = args.wallet as `0x${string}`;
287306
const chain = args.chain as SupportedChain;
288307
const token = args.token as `0x${string}`;
289-
const to = args.to as `0x${string}`;
308+
const to = assertRecipient(args.to);
290309
const meta = await resolveTokenMeta(chain, token);
291310

292311
let amountWei: bigint;
@@ -523,9 +542,13 @@ async function runEvmPreSignGuards(tx: UnsignedTx): Promise<void> {
523542
* appears. `send_transaction` then reads the stashed pin verbatim and
524543
* forwards it through WalletConnect, so the on-device hash is deterministic.
525544
*
526-
* Re-entrant: calling `previewSend` twice on the same handle overwrites the
527-
* prior pin. This is intentional — if the user pauses for minutes, gas
528-
* conditions drift and a fresh pin (with a fresh hash) is the right fix.
545+
* Re-entrant with an explicit opt-in: calling `previewSend` a second time on
546+
* the same handle returns the existing pin verbatim. Pass `refresh: true` to
547+
* re-pin (e.g. if the user paused for minutes and wants fresh fees). Without
548+
* this guard, a buggy or adversarial agent could silently swap the pre-sign
549+
* hash between the moment the user reads it in chat and the moment Ledger
550+
* displays it — the hash-match UX would still catch the change, but the
551+
* guard makes the default deterministic.
529552
*/
530553
export async function previewSend(args: PreviewSendArgs): Promise<{
531554
handle: string;
@@ -539,6 +562,7 @@ export async function previewSend(args: PreviewSendArgs): Promise<{
539562
maxPriorityFeePerGas: string;
540563
gas: string;
541564
};
565+
refreshed?: boolean;
542566
}> {
543567
if (hasTronHandle(args.handle)) {
544568
throw new Error(
@@ -548,6 +572,22 @@ export async function previewSend(args: PreviewSendArgs): Promise<{
548572
);
549573
}
550574
const tx = consumeHandle(args.handle);
575+
const existing = getPinnedGas(args.handle);
576+
if (existing && !args.refresh) {
577+
return {
578+
handle: args.handle,
579+
chain: tx.chain,
580+
to: tx.to,
581+
valueWei: tx.value,
582+
preSignHash: existing.preSignHash,
583+
pinned: {
584+
nonce: existing.nonce,
585+
maxFeePerGas: existing.maxFeePerGas.toString(),
586+
maxPriorityFeePerGas: existing.maxPriorityFeePerGas.toString(),
587+
gas: existing.gas.toString(),
588+
},
589+
};
590+
}
551591
await runEvmPreSignGuards(tx);
552592
const from =
553593
tx.from ?? ((await getConnectedAccounts())[0] as `0x${string}` | undefined);
@@ -588,6 +628,7 @@ export async function previewSend(args: PreviewSendArgs): Promise<{
588628
maxPriorityFeePerGas: pinned.maxPriorityFeePerGas.toString(),
589629
gas: pinned.gas.toString(),
590630
},
631+
...(existing ? { refreshed: true } : {}),
591632
};
592633
}
593634

src/modules/execution/schemas.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,17 @@ export const previewSendInput = z.object({
132132
"returns the LEDGER BLIND-SIGN HASH block so the user can see and confirm the " +
133133
"hash BEFORE the Ledger device prompt appears. A follow-up send_transaction " +
134134
"call forwards the pinned fields verbatim. Handles expire 15 minutes after " +
135-
"prepare; a fresh preview_send call can be made to refresh the pin if fees drift."
135+
"prepare. Once a pin exists, re-calling preview_send on the same handle returns " +
136+
"the existing pin unchanged unless `refresh: true` is passed."
137+
),
138+
refresh: z
139+
.boolean()
140+
.optional()
141+
.describe(
142+
"Set to true to re-pin nonce/fees/gas (e.g. after the user paused for minutes and " +
143+
"wants fresh fees). Default is false: the existing pin and its pre-sign hash are " +
144+
"returned verbatim, so the hash the user matched in chat cannot silently drift " +
145+
"between preview and send."
136146
),
137147
});
138148

src/modules/swap/index.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,26 @@ export async function prepareSwap(args: PrepareSwapArgs): Promise<UnsignedTx> {
334334
);
335335
}
336336

337+
// Exact-in invariant: the approval amount and the swap tx's transferFrom target
338+
// are both derived from `quote.action.fromAmount`, while the preview text
339+
// (description, decoded.args) echoes the user's `args.amount`. If LiFi returns
340+
// a `fromAmount` different from what we asked for, those two values drift and
341+
// the user signs bytes that pull more (or less) than the MCP preview shows.
342+
// The existing `toUsd / fromUsd > 10` gate is asymmetric and does not catch
343+
// proportional inflation. Refuse on any drift — we literally passed
344+
// `fromAmount` in, any different value in the response is hostile or buggy.
345+
if (!isExactOut) {
346+
const quotedFromWei = BigInt(quote.action.fromAmount);
347+
if (quotedFromWei !== BigInt(amountWei)) {
348+
throw new Error(
349+
`LiFi returned fromAmount=${quotedFromWei} for an exact-in quote of ${amountWei} ` +
350+
`(${args.amount} ${quote.action.fromToken.symbol}). The approval and swap bytes ` +
351+
`would pull a different amount than the MCP preview displays — refusing to return ` +
352+
`calldata. Re-run get_swap_quote.`
353+
);
354+
}
355+
}
356+
337357
// Sanity-check the quote before returning signable calldata. LiFi has been observed
338358
// returning toAmount scaled wrong on certain aggregator integrations (e.g. 10 USDC →
339359
// ~4500 ETH). The calldata embeds the bogus minOut and won't execute, but we refuse

src/setup.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,11 @@ async function pairLedgerLiveFlow(p: Prompt): Promise<void> {
240240
resolve();
241241
})
242242
);
243-
console.log(`URI: ${pairing.uri}\n`);
243+
// The URI carries the WalletConnect topic + a one-shot symmetric key. It
244+
// isn't long-lived key material, but anyone who pairs with it first wins
245+
// the session, so keep it out of long-lived logs and terminal scrollback
246+
// shared with others.
247+
console.log(`URI (sensitive — don't share; one-time pairing secret): ${pairing.uri}\n`);
244248
console.log("Waiting for you to approve the session in Ledger Live (Ctrl-C to cancel)...");
245249

246250
try {

src/signing/tron-usb-signer.ts

Lines changed: 67 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,30 @@ async function openTronApp() {
153153
return { app, transport, appVersion };
154154
}
155155

156+
/**
157+
* HID handles are exclusive — two concurrent attempts to open the Ledger USB
158+
* transport race and the loser sees "cannot open device". That's only a DoS
159+
* today (never a wrong-tx-signed), but it's a noisy failure the moment two
160+
* MCP tools run in parallel (e.g. `get_ledger_status` refreshes while a sign
161+
* is in flight). Serialize all transport-using calls through a module-local
162+
* queue so a second caller waits for the first to finish closing instead of
163+
* failing.
164+
*/
165+
let usbLock: Promise<void> = Promise.resolve();
166+
async function withUsbLock<T>(fn: () => Promise<T>): Promise<T> {
167+
const prev = usbLock;
168+
let release!: () => void;
169+
usbLock = new Promise<void>((resolve) => {
170+
release = resolve;
171+
});
172+
try {
173+
await prev;
174+
return await fn();
175+
} finally {
176+
release();
177+
}
178+
}
179+
156180
/**
157181
* Query the device for its TRON address at `path`. Used by `pair_ledger_tron`
158182
* to cache the address for subsequent sign calls, and as the identity check
@@ -161,21 +185,23 @@ async function openTronApp() {
161185
export async function getTronLedgerAddress(
162186
path: string = DEFAULT_TRON_PATH
163187
): Promise<{ address: string; publicKey: string; path: string; appVersion: string }> {
164-
const { app, transport, appVersion } = await openTronApp();
165-
try {
166-
const { address, publicKey } = await app.getAddress(path, false);
167-
if (!isTronAddress(address)) {
168-
throw new Error(
169-
`Ledger returned an address that doesn't look like a TRON mainnet address: "${address}". ` +
170-
`Is the TRON (not Tron-classic / testnet) app open on the device?`
171-
);
188+
return withUsbLock(async () => {
189+
const { app, transport, appVersion } = await openTronApp();
190+
try {
191+
const { address, publicKey } = await app.getAddress(path, false);
192+
if (!isTronAddress(address)) {
193+
throw new Error(
194+
`Ledger returned an address that doesn't look like a TRON mainnet address: "${address}". ` +
195+
`Is the TRON (not Tron-classic / testnet) app open on the device?`
196+
);
197+
}
198+
return { address, publicKey, path, appVersion };
199+
} catch (e) {
200+
throw mapLedgerError(e, "getAddress");
201+
} finally {
202+
await transport.close().catch(() => {});
172203
}
173-
return { address, publicKey, path, appVersion };
174-
} catch (e) {
175-
throw mapLedgerError(e, "getAddress");
176-
} finally {
177-
await transport.close().catch(() => {});
178-
}
204+
});
179205
}
180206

181207
export interface TronSignRequest {
@@ -206,31 +232,33 @@ export async function signTronTxOnLedger(
206232
req: TronSignRequest
207233
): Promise<{ signature: string; signerAddress: string }> {
208234
const path = req.path ?? DEFAULT_TRON_PATH;
209-
const { app, transport } = await openTronApp();
210-
try {
211-
const { address } = await app.getAddress(path, false);
212-
if (address !== req.expectedFrom) {
213-
throw new Error(
214-
`Ledger device address (${address}) does not match the prepared tx's \`from\` ` +
215-
`(${req.expectedFrom}). Either connect the Ledger that holds keys for \`from\`, ` +
216-
`or re-prepare the tx for the Ledger-derived address (\`pair_ledger_tron\`).`
217-
);
218-
}
219-
const signature = await app.signTransaction(
220-
path,
221-
req.rawDataHex,
222-
req.tokenSignatures ?? []
223-
);
224-
// Ledger returns the signature as a hex string (65 bytes: r || s || v).
225-
if (!/^[0-9a-fA-F]{130}$/.test(signature)) {
226-
throw new Error(
227-
`Ledger returned an unexpected signature shape (length ${signature.length}). Expected 130 hex chars.`
235+
return withUsbLock(async () => {
236+
const { app, transport } = await openTronApp();
237+
try {
238+
const { address } = await app.getAddress(path, false);
239+
if (address !== req.expectedFrom) {
240+
throw new Error(
241+
`Ledger device address (${address}) does not match the prepared tx's \`from\` ` +
242+
`(${req.expectedFrom}). Either connect the Ledger that holds keys for \`from\`, ` +
243+
`or re-prepare the tx for the Ledger-derived address (\`pair_ledger_tron\`).`
244+
);
245+
}
246+
const signature = await app.signTransaction(
247+
path,
248+
req.rawDataHex,
249+
req.tokenSignatures ?? []
228250
);
251+
// Ledger returns the signature as a hex string (65 bytes: r || s || v).
252+
if (!/^[0-9a-fA-F]{130}$/.test(signature)) {
253+
throw new Error(
254+
`Ledger returned an unexpected signature shape (length ${signature.length}). Expected 130 hex chars.`
255+
);
256+
}
257+
return { signature, signerAddress: address };
258+
} catch (e) {
259+
throw mapLedgerError(e, "signTransaction");
260+
} finally {
261+
await transport.close().catch(() => {});
229262
}
230-
return { signature, signerAddress: address };
231-
} catch (e) {
232-
throw mapLedgerError(e, "signTransaction");
233-
} finally {
234-
await transport.close().catch(() => {});
235-
}
263+
});
236264
}

0 commit comments

Comments
 (0)