From 14c2ce06b11f16e644af7ec9789389ab6ec6ae46 Mon Sep 17 00:00:00 2001 From: Arkadiy Stena Date: Mon, 23 Mar 2026 13:28:36 +0300 Subject: [PATCH 1/5] feat: config file encryption --- packages/mcp/src/__tests__/config.spec.ts | 4 +- packages/mcp/src/registry/config.ts | 3 +- packages/mcp/src/registry/protected-file.ts | 73 +++++++++++++++++++++ 3 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 packages/mcp/src/registry/protected-file.ts 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..712079a70 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 { readFileSync, writeFileSync } from './protected-file.js'; export type TonNetwork = 'mainnet' | 'testnet'; export type StandardWalletVersion = 'v5r1' | 'v4r2'; diff --git a/packages/mcp/src/registry/protected-file.ts b/packages/mcp/src/registry/protected-file.ts new file mode 100644 index 000000000..879babd95 --- /dev/null +++ b/packages/mcp/src/registry/protected-file.ts @@ -0,0 +1,73 @@ +/** + * 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 as nodeReadFileSync, writeFileSync as nodeWriteFileSync } from 'node:fs'; +import type { Mode, 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 ProtectedFileWriteOptions = WriteFileOptions & { + mode?: Mode; + flag?: string; +}; + +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): string { + if ( + value.length < PROTECTED_FILE_MAGIC.length || + !value.subarray(0, PROTECTED_FILE_MAGIC.length).equals(PROTECTED_FILE_MAGIC) + ) { + return value.toString('utf-8'); + } + + 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 Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf-8'); +} + +export function readFileSync(path: PathOrFileDescriptor, _encoding?: BufferEncoding): string { + const raw = nodeReadFileSync(path); + return decodeProtectedText(raw); +} + +export function writeFileSync(path: PathOrFileDescriptor, data: string, options?: ProtectedFileWriteOptions): void { + const protectedData = encodeProtectedText(data); + nodeWriteFileSync(path, protectedData, { + ...(options?.mode !== undefined ? { mode: options.mode } : {}), + ...(options?.flag ? { flag: options.flag } : {}), + }); +} From 4e733085fb35e59b9e7306a8dcdb6a6ba0eb062d Mon Sep 17 00:00:00 2001 From: Arkadiy Stena Date: Mon, 23 Mar 2026 13:47:46 +0300 Subject: [PATCH 2/5] fix(mcp): re-encrypt plaintext config on read --- .changeset/late-cups-argue.md | 5 +++++ packages/mcp/src/registry/config.ts | 22 +++++++++++++++++---- packages/mcp/src/registry/protected-file.ts | 13 ++++++++---- 3 files changed, 32 insertions(+), 8 deletions(-) create mode 100644 .changeset/late-cups-argue.md 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/src/registry/config.ts b/packages/mcp/src/registry/config.ts index 712079a70..4dd19bc71 100644 --- a/packages/mcp/src/registry/config.ts +++ b/packages/mcp/src/registry/config.ts @@ -367,8 +367,11 @@ export function loadConfig(): TonConfig | null { } let raw: unknown; + let isProtected: boolean; try { - raw = JSON.parse(readFileSync(configPath, 'utf-8')); + const readResult = readFileSync(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'}`, @@ -386,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 { @@ -396,8 +403,11 @@ export async function loadConfigWithMigration(): Promise { } let raw: unknown; + let isProtected: boolean; try { - raw = JSON.parse(readFileSync(configPath, 'utf-8')); + const readResult = readFileSync(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'}`, @@ -419,7 +429,11 @@ 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 { diff --git a/packages/mcp/src/registry/protected-file.ts b/packages/mcp/src/registry/protected-file.ts index 879babd95..97a0a78a5 100644 --- a/packages/mcp/src/registry/protected-file.ts +++ b/packages/mcp/src/registry/protected-file.ts @@ -22,6 +22,8 @@ type ProtectedFileWriteOptions = WriteFileOptions & { flag?: string; }; +type ProtectedFileReadResult = { content: string; isProtected: boolean }; + function encodeProtectedText(value: string): Buffer { const key = randomBytes(ENCRYPTION_KEY_BYTES); const iv = randomBytes(ENCRYPTION_IV_BYTES); @@ -32,12 +34,12 @@ function encodeProtectedText(value: string): Buffer { return Buffer.concat([PROTECTED_FILE_MAGIC, key, iv, authTag, encrypted]); } -function decodeProtectedText(value: Buffer): string { +function decodeProtectedText(value: Buffer): ProtectedFileReadResult { if ( value.length < PROTECTED_FILE_MAGIC.length || !value.subarray(0, PROTECTED_FILE_MAGIC.length).equals(PROTECTED_FILE_MAGIC) ) { - return value.toString('utf-8'); + return { content: value.toString('utf-8'), isProtected: false }; } if (value.length < HEADER_LENGTH) { @@ -56,10 +58,13 @@ function decodeProtectedText(value: Buffer): string { const decipher = createDecipheriv(ENCRYPTION_ALGORITHM, key, iv); decipher.setAuthTag(authTag); - return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf-8'); + return { + content: Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf-8'), + isProtected: true, + }; } -export function readFileSync(path: PathOrFileDescriptor, _encoding?: BufferEncoding): string { +export function readFileSync(path: PathOrFileDescriptor): ProtectedFileReadResult { const raw = nodeReadFileSync(path); return decodeProtectedText(raw); } From b2f85a3ea3424d1df745f6d9c9ae6c5a25f761dc Mon Sep 17 00:00:00 2001 From: Arkadiy Stena Date: Mon, 23 Mar 2026 14:17:02 +0300 Subject: [PATCH 3/5] docs(mcp): clarify current key storage --- packages/mcp/README.md | 4 ++++ 1 file changed, 4 insertions(+) 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 From 179cc6f90962f441426091d6ff1a1b9379b78d55 Mon Sep 17 00:00:00 2001 From: Arkadiy Stena Date: Mon, 23 Mar 2026 14:29:48 +0300 Subject: [PATCH 4/5] chore(mcp): rename read and write functions --- packages/mcp/src/registry/config.ts | 8 ++++---- packages/mcp/src/registry/protected-file.ts | 14 +++++++++----- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/mcp/src/registry/config.ts b/packages/mcp/src/registry/config.ts index 4dd19bc71..d7d9b5af8 100644 --- a/packages/mcp/src/registry/config.ts +++ b/packages/mcp/src/registry/config.ts @@ -22,7 +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 { readFileSync, writeFileSync } from './protected-file.js'; +import { readMaybeEncryptedFile, writeMaybeEncryptedFile } from './protected-file.js'; export type TonNetwork = 'mainnet' | 'testnet'; export type StandardWalletVersion = 'v5r1' | 'v4r2'; @@ -369,7 +369,7 @@ export function loadConfig(): TonConfig | null { let raw: unknown; let isProtected: boolean; try { - const readResult = readFileSync(configPath); + const readResult = readMaybeEncryptedFile(configPath); raw = JSON.parse(readResult.content); isProtected = readResult.isProtected; } catch (error) { @@ -405,7 +405,7 @@ export async function loadConfigWithMigration(): Promise { let raw: unknown; let isProtected: boolean; try { - const readResult = readFileSync(configPath); + const readResult = readMaybeEncryptedFile(configPath); raw = JSON.parse(readResult.content); isProtected = readResult.isProtected; } catch (error) { @@ -439,7 +439,7 @@ export async function loadConfigWithMigration(): Promise { export function saveConfig(config: TonConfig): void { mkdirSync(getConfigDir(), { recursive: true, mode: 0o700 }); chmodIfExists(getConfigDir(), 0o700); - writeFileSync(getConfigPath(), JSON.stringify(normalizeConfig(config), null, 2) + '\n', { + writeMaybeEncryptedFile(getConfigPath(), JSON.stringify(normalizeConfig(config), null, 2) + '\n', { encoding: 'utf-8', mode: 0o600, }); diff --git a/packages/mcp/src/registry/protected-file.ts b/packages/mcp/src/registry/protected-file.ts index 97a0a78a5..b09fcf75c 100644 --- a/packages/mcp/src/registry/protected-file.ts +++ b/packages/mcp/src/registry/protected-file.ts @@ -6,7 +6,7 @@ * */ -import { readFileSync as nodeReadFileSync, writeFileSync as nodeWriteFileSync } from 'node:fs'; +import { readFileSync, writeFileSync } from 'node:fs'; import type { Mode, PathOrFileDescriptor, WriteFileOptions } from 'node:fs'; import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'; @@ -64,14 +64,18 @@ function decodeProtectedText(value: Buffer): ProtectedFileReadResult { }; } -export function readFileSync(path: PathOrFileDescriptor): ProtectedFileReadResult { - const raw = nodeReadFileSync(path); +export function readMaybeEncryptedFile(path: PathOrFileDescriptor): ProtectedFileReadResult { + const raw = readFileSync(path); return decodeProtectedText(raw); } -export function writeFileSync(path: PathOrFileDescriptor, data: string, options?: ProtectedFileWriteOptions): void { +export function writeMaybeEncryptedFile( + path: PathOrFileDescriptor, + data: string, + options?: ProtectedFileWriteOptions, +): void { const protectedData = encodeProtectedText(data); - nodeWriteFileSync(path, protectedData, { + writeFileSync(path, protectedData, { ...(options?.mode !== undefined ? { mode: options.mode } : {}), ...(options?.flag ? { flag: options.flag } : {}), }); From 3041fd4913b5a9d8ea25285e294c3f0a3f7bd57c Mon Sep 17 00:00:00 2001 From: Arkadiy Stena Date: Mon, 23 Mar 2026 14:32:44 +0300 Subject: [PATCH 5/5] chore(mcp): fix typo --- packages/mcp/src/registry/config.ts | 4 ++-- packages/mcp/src/registry/protected-file.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/mcp/src/registry/config.ts b/packages/mcp/src/registry/config.ts index d7d9b5af8..a7975c706 100644 --- a/packages/mcp/src/registry/config.ts +++ b/packages/mcp/src/registry/config.ts @@ -22,7 +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, writeMaybeEncryptedFile } from './protected-file.js'; +import { readMaybeEncryptedFile, writeEncryptedFile } from './protected-file.js'; export type TonNetwork = 'mainnet' | 'testnet'; export type StandardWalletVersion = 'v5r1' | 'v4r2'; @@ -439,7 +439,7 @@ export async function loadConfigWithMigration(): Promise { export function saveConfig(config: TonConfig): void { mkdirSync(getConfigDir(), { recursive: true, mode: 0o700 }); chmodIfExists(getConfigDir(), 0o700); - writeMaybeEncryptedFile(getConfigPath(), JSON.stringify(normalizeConfig(config), null, 2) + '\n', { + writeEncryptedFile(getConfigPath(), JSON.stringify(normalizeConfig(config), null, 2) + '\n', { encoding: 'utf-8', mode: 0o600, }); diff --git a/packages/mcp/src/registry/protected-file.ts b/packages/mcp/src/registry/protected-file.ts index b09fcf75c..99635145a 100644 --- a/packages/mcp/src/registry/protected-file.ts +++ b/packages/mcp/src/registry/protected-file.ts @@ -69,7 +69,7 @@ export function readMaybeEncryptedFile(path: PathOrFileDescriptor): ProtectedFil return decodeProtectedText(raw); } -export function writeMaybeEncryptedFile( +export function writeEncryptedFile( path: PathOrFileDescriptor, data: string, options?: ProtectedFileWriteOptions,