diff --git a/.changeset/late-cups-argue.md b/.changeset/late-cups-argue.md new file mode 100644 index 000000000..ac3b54b3c --- /dev/null +++ b/.changeset/late-cups-argue.md @@ -0,0 +1,5 @@ +--- +'@ton/mcp': patch +--- + +Encrypt local TON config files on first read when an existing plaintext config is detected. diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 1d7f82edf..bac37dc05 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -26,6 +26,8 @@ Choose one control path per task: either run `@ton/mcp` as an MCP server (stdio Self-custody wallets for autonomous agents. Your AI agent gets TON wallet capabilities — transfers, swaps, NFTs. User keeps the master key, agent keeps the operator key. +Key storage in this mode: wallet secrets are persisted inside the local config registry at `TON_CONFIG_PATH` or `~/.config/ton/config.json`. The config directory is created with `0700` permissions and the config file with `0600` permissions. The file is not stored as plain text, but the current protected-file format is only obfuscation-in-file, not real cryptographic protection with an external secret or password. + **Learn more about [Agentic Wallets](https://agentic-wallets-dashboard.vercel.app/).** Agentic Wallets mode is the default mode that allows you to manage agentic wallets. To create your first agentic wallet, ask your agent to `create agentic wallet` and follow the instructions. @@ -48,6 +50,8 @@ TON_CONFIG_PATH=/path/to/config.json npx @ton/mcp@alpha Single-wallet mode is a mode where the server starts with one in-memory wallet. This mode is useful when you want to manage a single wallet or when you want to use the server for a one-off task. +Key storage in this mode: `MNEMONIC` / `PRIVATE_KEY` are read from environment variables and used only in memory for the current process; `@ton/mcp` does not persist them to disk in this mode. The values come in as plain environment variables, so any non-plain-text storage or encryption is the responsibility of the caller or host environment, not `@ton/mcp`. + ```bash # Run as stdio MCP server with mnemonic MNEMONIC="word1 word2 ..." npx @ton/mcp@alpha diff --git a/packages/mcp/src/__tests__/config.spec.ts b/packages/mcp/src/__tests__/config.spec.ts index 26cadb4b9..1b1803561 100644 --- a/packages/mcp/src/__tests__/config.spec.ts +++ b/packages/mcp/src/__tests__/config.spec.ts @@ -6,7 +6,7 @@ * */ -import { existsSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdtempSync, readFileSync as rawReadFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; @@ -70,6 +70,8 @@ describe('mcp config registry', () => { expect(loaded?.version).toBe(2); expect(loaded?.wallets).toHaveLength(1); expect(loaded?.active_wallet_id).toBe(standard.id); + expect(rawReadFileSync(process.env.TON_CONFIG_PATH!, 'utf-8')).not.toContain('"version": 2'); + expect(rawReadFileSync(process.env.TON_CONFIG_PATH!, 'utf-8')).not.toContain(standard.mnemonic ?? ''); const fileMode = statSync(process.env.TON_CONFIG_PATH!).mode & 0o777; expect(fileMode).toBe(0o600); diff --git a/packages/mcp/src/registry/config.ts b/packages/mcp/src/registry/config.ts index 678c0986c..cdf2c8673 100644 --- a/packages/mcp/src/registry/config.ts +++ b/packages/mcp/src/registry/config.ts @@ -6,7 +6,7 @@ * */ -import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'; +import { chmodSync, existsSync, mkdirSync, unlinkSync } from 'node:fs'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; @@ -22,6 +22,7 @@ import { import { formatAssetAddress, formatWalletAddress, normalizeAddressForComparison } from '../utils/address.js'; import { parsePrivateKeyInput } from '../utils/private-key.js'; import { createApiClient } from '../utils/ton-client.js'; +import { readMaybeEncryptedFile, writeEncryptedFile } from './protected-file.js'; export type TonNetwork = 'mainnet' | 'testnet'; export type StandardWalletVersion = 'v5r1' | 'v4r2'; @@ -366,8 +367,11 @@ export function loadConfig(): TonConfig | null { } let raw: unknown; + let isProtected: boolean; try { - raw = JSON.parse(readFileSync(configPath, 'utf-8')); + const readResult = readMaybeEncryptedFile(configPath); + raw = JSON.parse(readResult.content); + isProtected = readResult.isProtected; } catch (error) { throw new ConfigError( `Failed to read config at ${configPath}: ${error instanceof Error ? error.message : 'Unknown error'}`, @@ -385,7 +389,11 @@ export function loadConfig(): TonConfig | null { throw new ConfigError(`Unsupported config version ${String(version)} at ${configPath}.`); } - return normalizeConfig(raw as TonConfig); + const normalized = normalizeConfig(raw as TonConfig); + if (!isProtected) { + saveConfig(normalized); + } + return normalized; } export async function loadConfigWithMigration(): Promise { @@ -395,8 +403,11 @@ export async function loadConfigWithMigration(): Promise { } let raw: unknown; + let isProtected: boolean; try { - raw = JSON.parse(readFileSync(configPath, 'utf-8')); + const readResult = readMaybeEncryptedFile(configPath); + raw = JSON.parse(readResult.content); + isProtected = readResult.isProtected; } catch (error) { throw new ConfigError( `Failed to read config at ${configPath}: ${error instanceof Error ? error.message : 'Unknown error'}`, @@ -418,13 +429,17 @@ export async function loadConfigWithMigration(): Promise { throw new ConfigError(`Unsupported config version ${String(version)} at ${configPath}.`); } - return normalizeConfig(raw as TonConfig); + const normalized = normalizeConfig(raw as TonConfig); + if (!isProtected) { + saveConfig(normalized); + } + return normalized; } export function saveConfig(config: TonConfig): void { mkdirSync(getConfigDir(), { recursive: true, mode: 0o700 }); chmodIfExists(getConfigDir(), 0o700); - writeFileSync(getConfigPath(), JSON.stringify(normalizeConfig(config), null, 2) + '\n', { + writeEncryptedFile(getConfigPath(), JSON.stringify(normalizeConfig(config), null, 2), { encoding: 'utf-8', mode: 0o600, }); diff --git a/packages/mcp/src/registry/protected-file.ts b/packages/mcp/src/registry/protected-file.ts new file mode 100644 index 000000000..681a3bec5 --- /dev/null +++ b/packages/mcp/src/registry/protected-file.ts @@ -0,0 +1,70 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { readFileSync, writeFileSync } from 'node:fs'; +import type { PathOrFileDescriptor, WriteFileOptions } from 'node:fs'; +import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'; + +const ENCRYPTION_ALGORITHM = 'aes-256-gcm'; +const PROTECTED_FILE_MAGIC = Buffer.from([0x8a, 0x54, 0x4d, 0x01]); +const ENCRYPTION_KEY_BYTES = 32; +const ENCRYPTION_IV_BYTES = 12; +const ENCRYPTION_TAG_BYTES = 16; +const HEADER_LENGTH = PROTECTED_FILE_MAGIC.length + ENCRYPTION_KEY_BYTES + ENCRYPTION_IV_BYTES + ENCRYPTION_TAG_BYTES; + +type ProtectedFileReadResult = { content: string; isProtected: boolean }; + +function encodeProtectedText(value: string): Buffer { + const key = randomBytes(ENCRYPTION_KEY_BYTES); + const iv = randomBytes(ENCRYPTION_IV_BYTES); + const cipher = createCipheriv(ENCRYPTION_ALGORITHM, key, iv); + const encrypted = Buffer.concat([cipher.update(value, 'utf-8'), cipher.final()]); + const authTag = cipher.getAuthTag(); + + return Buffer.concat([PROTECTED_FILE_MAGIC, key, iv, authTag, encrypted]); +} + +function decodeProtectedText(value: Buffer): ProtectedFileReadResult { + if ( + value.length < PROTECTED_FILE_MAGIC.length || + !value.subarray(0, PROTECTED_FILE_MAGIC.length).equals(PROTECTED_FILE_MAGIC) + ) { + return { content: value.toString('utf-8'), isProtected: false }; + } + + if (value.length < HEADER_LENGTH) { + throw new Error('Invalid protected file format.'); + } + + let offset = PROTECTED_FILE_MAGIC.length; + const key = value.subarray(offset, offset + ENCRYPTION_KEY_BYTES); + offset += ENCRYPTION_KEY_BYTES; + const iv = value.subarray(offset, offset + ENCRYPTION_IV_BYTES); + offset += ENCRYPTION_IV_BYTES; + const authTag = value.subarray(offset, offset + ENCRYPTION_TAG_BYTES); + offset += ENCRYPTION_TAG_BYTES; + const encrypted = value.subarray(offset); + + const decipher = createDecipheriv(ENCRYPTION_ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + + return { + content: Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf-8'), + isProtected: true, + }; +} + +export function readMaybeEncryptedFile(path: PathOrFileDescriptor): ProtectedFileReadResult { + const raw = readFileSync(path); + return decodeProtectedText(raw); +} + +export function writeEncryptedFile(path: PathOrFileDescriptor, data: string, options?: WriteFileOptions): void { + const protectedData = encodeProtectedText(data); + writeFileSync(path, protectedData, options); +}