From 7daf5bd1e1c64f7a6d97ec580457cb02f0fb7fd5 Mon Sep 17 00:00:00 2001 From: yasinlex Date: Mon, 27 Jul 2026 05:33:17 +0700 Subject: [PATCH] fix(security): restrict keystore file permissions to 0o600 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keystore files containing encrypted private keys were written via writeFileSync without specifying file mode, leaving them with the default 0644 permissions — world-readable on POSIX systems. Any local user could read the encrypted keystore and attempt offline brute-force attacks on the password. This affects three code paths: - BaseAction.createKeypairByName() — account creation - ExportAccountAction — exporting an account to a keystore file - ImportAccountAction — importing an account into a keystore file Fix: pass { mode: 0o600 } to writeFileSync and explicitly chmodSync to 0o600 afterwards. The chmod is necessary because writeFileSync does not change the mode of an already-existing file, so a pre-created file with 0644 would keep world-read access. The chmod is wrapped in try/catch because it is a no-op / can fail on Windows (no POSIX permissions). --- src/commands/account/export.ts | 9 +++++++-- src/commands/account/import.ts | 9 +++++++-- src/lib/actions/BaseAction.ts | 13 +++++++++++-- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/commands/account/export.ts b/src/commands/account/export.ts index bed07a48..7cff66f1 100644 --- a/src/commands/account/export.ts +++ b/src/commands/account/export.ts @@ -1,6 +1,6 @@ import {BaseAction} from "../../lib/actions/BaseAction"; import {ethers} from "ethers"; -import {writeFileSync, existsSync, readFileSync} from "fs"; +import {writeFileSync, existsSync, readFileSync, chmodSync} from "fs"; import path from "path"; export interface ExportAccountOptions { @@ -60,7 +60,12 @@ export class ExportAccountAction extends BaseAction { const encryptedJson = await wallet.encrypt(password); // Write standard web3 keystore format (compatible with geth, foundry, etc.) - writeFileSync(outputPath, encryptedJson); + writeFileSync(outputPath, encryptedJson, { mode: 0o600 }); + try { + chmodSync(outputPath, 0o600); + } catch { + // chmod can fail on Windows (no POSIX permissions) + } this.succeedSpinner(`Account '${accountName}' exported to: ${outputPath}`); this.logInfo(`Address: ${wallet.address}`); diff --git a/src/commands/account/import.ts b/src/commands/account/import.ts index a7ec0518..cbdd38c3 100644 --- a/src/commands/account/import.ts +++ b/src/commands/account/import.ts @@ -1,6 +1,6 @@ import {BaseAction} from "../../lib/actions/BaseAction"; import {ethers} from "ethers"; -import {writeFileSync, existsSync, readFileSync} from "fs"; +import {writeFileSync, existsSync, readFileSync, chmodSync} from "fs"; export interface ImportAccountOptions { privateKey?: string; @@ -62,7 +62,12 @@ export class ImportAccountAction extends BaseAction { const encryptedJson = await wallet.encrypt(password); // Write standard web3 keystore format directly - writeFileSync(keystorePath, encryptedJson); + writeFileSync(keystorePath, encryptedJson, { mode: 0o600 }); + try { + chmodSync(keystorePath, 0o600); + } catch { + // chmod can fail on Windows (no POSIX permissions) + } if (options.setActive !== false) { this.setActiveAccount(options.name); diff --git a/src/lib/actions/BaseAction.ts b/src/lib/actions/BaseAction.ts index 92a0d665..6a891dcc 100644 --- a/src/lib/actions/BaseAction.ts +++ b/src/lib/actions/BaseAction.ts @@ -59,7 +59,7 @@ export function resolveNetwork(stored: string | undefined, customNetworks?: Cust } } import { ethers } from "ethers"; -import { writeFileSync, existsSync, readFileSync } from "fs"; +import { writeFileSync, existsSync, readFileSync, chmodSync } from "fs"; export class BaseAction extends ConfigFileManager { private static readonly DEFAULT_ACCOUNT_NAME = "default"; @@ -219,7 +219,16 @@ export class BaseAction extends ConfigFileManager { // Write standard web3 keystore format directly const encryptedJson = await wallet.encrypt(password); - writeFileSync(keystorePath, encryptedJson); + writeFileSync(keystorePath, encryptedJson, { mode: 0o600 }); + // Enforce restrictive permissions even if the file already existed (writeFileSync + // does not change the mode of an existing file, so an attacker who pre-created it + // with 0644 would keep world-read access to the encrypted private key). + try { + chmodSync(keystorePath, 0o600); + } catch { + // chmod can fail on Windows (no POSIX permissions) — the encrypted keystore + // still protects the key, and Windows ACLs govern access there anyway. + } // Set as active account if no active account exists if (!this.getActiveAccount()) {