Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/late-cups-argue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@ton/mcp': patch
---

Encrypt local TON config files on first read when an existing plaintext config is detected.
4 changes: 4 additions & 0 deletions packages/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion packages/mcp/src/__tests__/config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
27 changes: 21 additions & 6 deletions packages/mcp/src/registry/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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';
Expand Down Expand Up @@ -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'}`,
Expand All @@ -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<TonConfig | null> {
Expand All @@ -395,8 +403,11 @@ export async function loadConfigWithMigration(): Promise<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'}`,
Expand All @@ -418,13 +429,17 @@ export async function loadConfigWithMigration(): Promise<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 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) + '\n', {
encoding: 'utf-8',
mode: 0o600,
});
Expand Down
82 changes: 82 additions & 0 deletions packages/mcp/src/registry/protected-file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* 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 { 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;
};

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?: ProtectedFileWriteOptions,
): void {
const protectedData = encodeProtectedText(data);
writeFileSync(path, protectedData, {
...(options?.mode !== undefined ? { mode: options.mode } : {}),
...(options?.flag ? { flag: options.flag } : {}),
});
}
Loading