/**
* Encryption utilities using AES-256-GCM
*
* Provides secure encryption at rest for sensitive data like
* session tokens, API keys, and credentials.
*/
import { randomBytes, createCipheriv, createDecipheriv, pbkdf2Sync } from 'node:crypto';
import { createLogger } from './logger.js';
const logger = createLogger('crypto');
/**
* Encryption configuration
*/
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12; // 96 bits for GCM
const AUTH_TAG_LENGTH = 16; // 128 bits
const SALT_LENGTH = 32; // 256 bits
const KEY_LENGTH = 32; // 256 bits for AES-256
const PBKDF2_ITERATIONS = 100000;
/**
* Encrypted data envelope
*/
export interface EncryptedData {
/** Base64-encoded ciphertext */
ciphertext: string;
/** Base64-encoded initialization vector */
iv: string;
/** Base64-encoded authentication tag */
authTag: string;
/** Base64-encoded salt (if key derived from password) */
salt?: string;
/** Algorithm identifier */
algorithm: string;
/** Version for future compatibility */
version: number;
}
/**
* Derive a key from a password using PBKDF2
*/
export function deriveKey(password: string, salt?: Buffer): { key: Buffer; salt: Buffer } {
const derivedSalt = salt ?? randomBytes(SALT_LENGTH);
const key = pbkdf2Sync(password, derivedSalt, PBKDF2_ITERATIONS, KEY_LENGTH, 'sha256');
return { key, salt: derivedSalt };
}
/**
* Encrypt data using AES-256-GCM with a raw key
*/
export function encryptWithKey(plaintext: string, key: Buffer): EncryptedData {
if (key.length !== KEY_LENGTH) {
throw new Error(`Invalid key length: expected ${KEY_LENGTH}, got ${key.length}`);
}
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
return {
ciphertext: encrypted.toString('base64'),
iv: iv.toString('base64'),
authTag: authTag.toString('base64'),
algorithm: ALGORITHM,
version: 1,
};
}
/**
* Decrypt data using AES-256-GCM with a raw key
*/
export function decryptWithKey(encrypted: EncryptedData, key: Buffer): string {
if (key.length !== KEY_LENGTH) {
throw new Error(`Invalid key length: expected ${KEY_LENGTH}, got ${key.length}`);
}
if (encrypted.version !== 1) {
throw new Error(`Unsupported encryption version: ${encrypted.version}`);
}
const iv = Buffer.from(encrypted.iv, 'base64');
const authTag = Buffer.from(encrypted.authTag, 'base64');
const ciphertext = Buffer.from(encrypted.ciphertext, 'base64');
const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
return decrypted.toString('utf8');
}
/**
* Encrypt data using a password (derives key with PBKDF2)
*/
export function encrypt(plaintext: string, password: string): EncryptedData {
const { key, salt } = deriveKey(password);
const encrypted = encryptWithKey(plaintext, key);
return {
...encrypted,
salt: salt.toString('base64'),
};
}
/**
* Decrypt data using a password
*/
export function decrypt(encrypted: EncryptedData, password: string): string {
if (!encrypted.salt) {
throw new Error('Salt required for password-based decryption');
}
const salt = Buffer.from(encrypted.salt, 'base64');
const { key } = deriveKey(password, salt);
return decryptWithKey(encrypted, key);
}
/**
* Generate a secure random key
*/
export function generateKey(): Buffer {
return randomBytes(KEY_LENGTH);
}
/**
* Generate a secure random string (for tokens, IDs, etc.)
*/
export function generateSecureToken(length: number = 32): string {
return randomBytes(length).toString('base64url');
}
/**
* Constant-time string comparison to prevent timing attacks
*/
export function secureCompare(a: string, b: string): boolean {
if (a.length !== b.length) {
return false;
}
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
let result = 0;
for (let i = 0; i < bufA.length; i++) {
result |= (bufA[i] ?? 0) ^ (bufB[i] ?? 0);
}
return result === 0;
}
/**
* Hash a value for non-reversible storage (e.g., tokens for lookup)
*/
export function hashForLookup(value: string, salt?: string): string {
const hashSalt = salt ?? '';
const hash = pbkdf2Sync(value, hashSalt, 10000, 32, 'sha256');
return hash.toString('base64url');
}
/**
* Secrets manager for encrypted storage
*/
export class SecretsManager {
private readonly masterKey: Buffer;
constructor(masterPassword: string) {
// Derive master key from password
// In production, consider using a dedicated secret from env
const { key } = deriveKey(masterPassword, Buffer.from('mcp-server-master-salt'));
this.masterKey = key;
logger.debug('Secrets manager initialized');
}
/**
* Encrypt a secret value
*/
encrypt(value: string): string {
const encrypted = encryptWithKey(value, this.masterKey);
return JSON.stringify(encrypted);
}
/**
* Decrypt a secret value
*/
decrypt(encryptedJson: string): string {
const encrypted = JSON.parse(encryptedJson) as EncryptedData;
return decryptWithKey(encrypted, this.masterKey);
}
/**
* Encrypt an object (serializes to JSON first)
*/
encryptObject<T>(obj: T): string {
return this.encrypt(JSON.stringify(obj));
}
/**
* Decrypt an object
*/
decryptObject<T>(encryptedJson: string): T {
const decrypted = this.decrypt(encryptedJson);
return JSON.parse(decrypted) as T;
}
}
// Singleton secrets manager (initialized lazily)
let secretsManager: SecretsManager | null = null;
/**
* Get or create the secrets manager
*/
export function getSecretsManager(): SecretsManager {
if (!secretsManager) {
const masterPassword = process.env['MCP_SERVER_MASTER_KEY'] ?? 'default-dev-key-change-in-production';
secretsManager = new SecretsManager(masterPassword);
}
return secretsManager;
}
/**
* Reset secrets manager (for testing)
*/
export function resetSecretsManager(): void {
secretsManager = null;
}