Skip to content
Merged
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 .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ STELLAR_CLI_PATH=stellar
# Default: info
LOG_LEVEL=info

# ─── Encrypted IPC (optional) ───────────────────────────────────────────────
# 32-byte key to encrypt stdio MCP traffic in shared environments.
# Provide as 64 hex chars or base64. When set, the MCP client must send/receive
# encrypted frames using the same key.
PULSAR_IPC_ENCRYPTION_KEY=
# Tool execution audit log path.
# Default: audit.log
AUDIT_LOG_PATH=audit.log
Expand Down
69 changes: 69 additions & 0 deletions docs/encrypted_ipc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Encrypted IPC Communication

Pulsar supports AES-256-GCM encryption of its stdio MCP transport to prevent sensitive data leakage in shared or multi-tenant environments.

## How It Works

When `PULSAR_IPC_ENCRYPTION_KEY` is set, Pulsar replaces the default plaintext stdio transport with `EncryptedStdioServerTransport`. Every JSON-RPC message (request and response) is wrapped in a versioned encrypted envelope before being written to stdout, and every incoming line from stdin is decrypted and authenticated before processing.

The envelope format is newline-delimited JSON:

```json
{
"v": 1,
"nonce": "<12-byte base64>",
"ciphertext": "<base64>",
"tag": "<16-byte base64>"
}
```

- **Algorithm**: AES-256-GCM
- **Nonce**: 12 random bytes, freshly generated per message
- **Tag**: 16-byte GCM authentication tag — any tampered or miskeyed frame is rejected and the connection is closed

If `PULSAR_IPC_ENCRYPTION_KEY` is not set, Pulsar falls back to the standard plaintext `StdioServerTransport` with no behaviour change.

## Configuration

Add the key to your environment or `.env` file:

```env
# 32-byte key — provide as 64 hex characters or base64
PULSAR_IPC_ENCRYPTION_KEY=<your-64-hex-char-or-base64-key>
```

### Generating a Key

```bash
# hex (recommended)
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

# base64
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
```

## Client Requirements

Any MCP client connecting to an encryption-enabled Pulsar instance **must** send and receive the same versioned envelope format using the same 32-byte key. Plaintext frames are rejected immediately and the transport is closed.

## Security Properties

| Property | Detail |
|---|---|
| Algorithm | AES-256-GCM |
| Key size | 256 bits (32 bytes) |
| Nonce | 96-bit random per message |
| Integrity | GCM authentication tag — rejects tampered/miskeyed frames |
| Key in logs | Redacted via pino `redact` paths (`PULSAR_IPC_ENCRYPTION_KEY`) |
| Key in CLI args | Never passed as a process argument |
| Key in stderr | Never emitted |

## Error Handling

| Condition | Behaviour |
|---|---|
| Malformed JSON frame | `onerror` callback fired; connection closed |
| Missing envelope fields | `onerror` callback fired; connection closed |
| GCM authentication failure | `onerror` callback fired; connection closed |
| Invalid nonce/tag length | `onerror` callback fired; connection closed |
| Plaintext frame received | `onerror` callback fired; connection closed |
2 changes: 2 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,22 @@
stellarSecretKey: z.string().startsWith('S').length(56).optional(),
stellarCliPath: z.string().default('stellar'),
logLevel: z.enum(['error', 'warn', 'info', 'debug']).default('info'),
ipcEncryptionKey: z.string().optional(),
language: z.enum(['en', 'es']).default('en'),
auditLogPath: z.string().default('audit.log'),
sorobanRpcUrls: z.array(z.string().url()).optional().describe("Array of Soroban RPC endpoints for latency-based routing (preferred over sorobanRpcUrl)"),
stellarSecretKey: z.string().startsWith("S").length(56).optional(),

Check failure on line 20 in src/config.ts

View workflow job for this annotation

GitHub Actions / Node 20 on ubuntu-latest

Duplicate key 'stellarSecretKey'

Check failure on line 20 in src/config.ts

View workflow job for this annotation

GitHub Actions / Node 22 on ubuntu-latest

Duplicate key 'stellarSecretKey'

Check failure on line 20 in src/config.ts

View workflow job for this annotation

GitHub Actions / Node 18 on ubuntu-latest

Duplicate key 'stellarSecretKey'
stellarCliPath: z.string().default("stellar"),

Check failure on line 21 in src/config.ts

View workflow job for this annotation

GitHub Actions / Node 20 on ubuntu-latest

Duplicate key 'stellarCliPath'

Check failure on line 21 in src/config.ts

View workflow job for this annotation

GitHub Actions / Node 22 on ubuntu-latest

Duplicate key 'stellarCliPath'

Check failure on line 21 in src/config.ts

View workflow job for this annotation

GitHub Actions / Node 18 on ubuntu-latest

Duplicate key 'stellarCliPath'
logLevel: z.enum(["error", "warn", "info", "debug"]).default("info"),

Check failure on line 22 in src/config.ts

View workflow job for this annotation

GitHub Actions / Node 20 on ubuntu-latest

Duplicate key 'logLevel'

Check failure on line 22 in src/config.ts

View workflow job for this annotation

GitHub Actions / Node 22 on ubuntu-latest

Duplicate key 'logLevel'

Check failure on line 22 in src/config.ts

View workflow job for this annotation

GitHub Actions / Node 18 on ubuntu-latest

Duplicate key 'logLevel'
rpcHealthCheckIntervalMs: z.number().int().min(5000).max(300000).default(30000).optional(),
rpcLatencyThresholdMs: z.number().int().min(100).max(10000).default(2000).optional(),
stellarSecretKey: z.string().startsWith('S').length(56).optional(),

Check failure on line 25 in src/config.ts

View workflow job for this annotation

GitHub Actions / Node 20 on ubuntu-latest

Duplicate key 'stellarSecretKey'

Check failure on line 25 in src/config.ts

View workflow job for this annotation

GitHub Actions / Node 22 on ubuntu-latest

Duplicate key 'stellarSecretKey'

Check failure on line 25 in src/config.ts

View workflow job for this annotation

GitHub Actions / Node 18 on ubuntu-latest

Duplicate key 'stellarSecretKey'
stellarCliPath: z.string().default('stellar'),

Check failure on line 26 in src/config.ts

View workflow job for this annotation

GitHub Actions / Node 20 on ubuntu-latest

Duplicate key 'stellarCliPath'

Check failure on line 26 in src/config.ts

View workflow job for this annotation

GitHub Actions / Node 22 on ubuntu-latest

Duplicate key 'stellarCliPath'

Check failure on line 26 in src/config.ts

View workflow job for this annotation

GitHub Actions / Node 18 on ubuntu-latest

Duplicate key 'stellarCliPath'
logLevel: z.enum(['error', 'warn', 'info', 'debug']).default('info'),

Check failure on line 27 in src/config.ts

View workflow job for this annotation

GitHub Actions / Node 20 on ubuntu-latest

Duplicate key 'logLevel'

Check failure on line 27 in src/config.ts

View workflow job for this annotation

GitHub Actions / Node 22 on ubuntu-latest

Duplicate key 'logLevel'

Check failure on line 27 in src/config.ts

View workflow job for this annotation

GitHub Actions / Node 18 on ubuntu-latest

Duplicate key 'logLevel'
stellarSecretKey: z.string().startsWith("S").length(56).optional(),

Check failure on line 28 in src/config.ts

View workflow job for this annotation

GitHub Actions / Node 20 on ubuntu-latest

Duplicate key 'stellarSecretKey'

Check failure on line 28 in src/config.ts

View workflow job for this annotation

GitHub Actions / Node 22 on ubuntu-latest

Duplicate key 'stellarSecretKey'

Check failure on line 28 in src/config.ts

View workflow job for this annotation

GitHub Actions / Node 18 on ubuntu-latest

Duplicate key 'stellarSecretKey'
stellarCliPath: z.string().default("stellar"),

Check failure on line 29 in src/config.ts

View workflow job for this annotation

GitHub Actions / Node 20 on ubuntu-latest

Duplicate key 'stellarCliPath'

Check failure on line 29 in src/config.ts

View workflow job for this annotation

GitHub Actions / Node 22 on ubuntu-latest

Duplicate key 'stellarCliPath'

Check failure on line 29 in src/config.ts

View workflow job for this annotation

GitHub Actions / Node 18 on ubuntu-latest

Duplicate key 'stellarCliPath'
logLevel: z.enum(["error", "warn", "info", "debug", "trace"]).default("info"),

Check failure on line 30 in src/config.ts

View workflow job for this annotation

GitHub Actions / Node 20 on ubuntu-latest

Duplicate key 'logLevel'

Check failure on line 30 in src/config.ts

View workflow job for this annotation

GitHub Actions / Node 22 on ubuntu-latest

Duplicate key 'logLevel'

Check failure on line 30 in src/config.ts

View workflow job for this annotation

GitHub Actions / Node 18 on ubuntu-latest

Duplicate key 'logLevel'
logLevel: z.enum(["error", "warn", "info", "debug"]).default("info"),

Check failure on line 31 in src/config.ts

View workflow job for this annotation

GitHub Actions / Node 20 on ubuntu-latest

Duplicate key 'logLevel'

Check failure on line 31 in src/config.ts

View workflow job for this annotation

GitHub Actions / Node 22 on ubuntu-latest

Duplicate key 'logLevel'

Check failure on line 31 in src/config.ts

View workflow job for this annotation

GitHub Actions / Node 18 on ubuntu-latest

Duplicate key 'logLevel'
metricsEnabled: z.boolean().default(true),
metricsPort: z.number().int().min(1).max(65535).default(9090),
restrictedAddresses: z.string().optional(),
Expand All @@ -42,6 +43,7 @@
stellarSecretKey: process.env.STELLAR_SECRET_KEY || undefined,
stellarCliPath: process.env.STELLAR_CLI_PATH || 'stellar',
logLevel: process.env.LOG_LEVEL || 'info',
ipcEncryptionKey: process.env.PULSAR_IPC_ENCRYPTION_KEY || undefined,
language: process.env.LANGUAGE || 'en',
auditLogPath: process.env.AUDIT_LOG_PATH || 'audit.log',
stellarCliPath: process.env.STELLAR_CLI_PATH || "stellar",
Expand All @@ -58,7 +60,7 @@
const parsed = configSchema.safeParse(rawConfig);

if (!parsed.success) {
console.error(

Check warning on line 63 in src/config.ts

View workflow job for this annotation

GitHub Actions / Node 20 on ubuntu-latest

Unexpected console statement

Check warning on line 63 in src/config.ts

View workflow job for this annotation

GitHub Actions / Node 22 on ubuntu-latest

Unexpected console statement

Check warning on line 63 in src/config.ts

View workflow job for this annotation

GitHub Actions / Node 18 on ubuntu-latest

Unexpected console statement
'❌ Invalid environment variables:',
JSON.stringify(parsed.error.format(), null, 2)
);
Expand Down
18 changes: 18 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ import {

import logger from './logger.js';
import { PulsarError, PulsarNetworkError, PulsarValidationError } from './errors.js';
import { createStdioTransport } from './transport/stdio.js';
import { applyFieldProjection } from './schemas/index.js';
import { initializeI18n } from './i18n/index.js';
import { logToolExecution } from './audit.js';
Expand Down Expand Up @@ -405,6 +406,8 @@ class PulsarServer {
'Fetch the ABI/interface spec of a deployed Soroban contract. Returns decoded function signatures, parameter types, and emitted event schemas.',
description:
'Fetch the ABI/interface spec of a deployed Soroban contract. Returns decoded function signatures, parameter types, and emitted event schemas.',
description:
'Fetch the ABI/interface spec of a deployed Soroban contract. Returns decoded function signatures, parameter types, and emitted event schemas.',
inputSchema: {
type: 'object',
description:
Expand Down Expand Up @@ -455,6 +458,7 @@ class PulsarServer {
properties: {
contract_id: {
type: 'string',
description: 'The Soroban contract address (C...)',
description: 'The Soroban contract address (C...) to observe events for.',
},
event_type: {
Expand Down Expand Up @@ -2208,6 +2212,7 @@ class PulsarServer {
const parsed = SimulateTransactionsSequenceInputSchema.safeParse(args);
if (!parsed.success) {
throw new PulsarValidationError(
`Invalid input for get_account_balance`,
`Invalid input for simulate_transaction`,
`Invalid input for simulate_transactions_sequence`,
parsed.error.format()
Expand All @@ -2227,6 +2232,12 @@ class PulsarServer {
case 'emergency_pause': {
const parsed = EmergencyPauseInputSchema.safeParse(args);
if (!parsed.success) {
throw new PulsarValidationError(
`Invalid input for fetch_contract_spec`,
parsed.error.format()
);
}
const result = await fetchContractSpec(parsed.data);
throw new PulsarValidationError(`Invalid input for emergency_pause`, parsed.error.format());
}
const result = await emergencyPause(parsed.data);
Expand All @@ -2248,6 +2259,7 @@ class PulsarServer {
const parsed = SorobanMathInputSchema.safeParse(args);
if (!parsed.success) {
throw new PulsarValidationError(
`Invalid input for submit_transaction`,
`Invalid input for compute_vesting_schedule`,
parsed.error.format()
);
Expand Down Expand Up @@ -2326,6 +2338,7 @@ class PulsarServer {
const parsed = ExportDataInputSchema.safeParse(args);
if (!parsed.success) {
throw new PulsarValidationError(
`Invalid input for simulate_transaction`,
`Invalid input for export_data`,
parsed.error.format()
);
Expand Down Expand Up @@ -2364,6 +2377,7 @@ class PulsarServer {
const parsed = GenerateContractClientInputSchema.safeParse(args);
if (!parsed.success) {
throw new PulsarValidationError(
`Invalid input for compute_vesting_schedule`,
`Invalid input for deploy_contract`,
`Invalid input for generate_contract_client`,
parsed.error.format()
Expand All @@ -2379,6 +2393,7 @@ class PulsarServer {
const parsed = ManageDaoTreasuryInputSchema.safeParse(args);
if (!parsed.success) {
throw new PulsarValidationError(
`Invalid input for deploy_contract`,
`Invalid input for manage_dao_treasury`,
parsed.error.format()
);
Expand Down Expand Up @@ -2843,6 +2858,9 @@ class PulsarServer {
}

async run() {
const transport = createStdioTransport({
encryptionKey: config.ipcEncryptionKey,
});
// Start metrics recording and endpoint
if (config.metricsEnabled) {
const metricsInterval = startMetricsRecording();
Expand Down
1 change: 1 addition & 0 deletions src/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { config } from './config.js';
*/
const redactPaths = [
'STELLAR_SECRET_KEY',
'PULSAR_IPC_ENCRYPTION_KEY',
'secret',
'privateKey',
'raw_secret',
Expand Down
Loading
Loading