Files
Synapsis/src/lib/bots/encryption.ts
T
2026-01-26 08:34:48 +01:00

344 lines
9.2 KiB
TypeScript

/**
* Bot API Key Encryption Module
*
* Provides AES-256-GCM encryption for API keys and format validation
* for supported LLM providers (OpenRouter, OpenAI, Anthropic).
*
* Requirements: 2.1, 2.2, 2.3, 10.3
*/
import * as crypto from 'crypto';
// ============================================
// TYPES
// ============================================
export type LLMProvider = 'openrouter' | 'openai' | 'anthropic';
export interface EncryptedData {
encrypted: string; // Base64 encoded ciphertext + auth tag
iv: string; // Base64 encoded initialization vector
}
export interface ApiKeyValidationResult {
valid: boolean;
provider?: LLMProvider;
error?: string;
}
// ============================================
// CONSTANTS
// ============================================
/**
* API key format patterns for supported providers
* - OpenRouter: typically starts with "sk-or-"
* - OpenAI: typically starts with "sk-" (but not "sk-or-" or "sk-ant-")
* - Anthropic: typically starts with "sk-ant-"
*/
const API_KEY_PATTERNS: Record<LLMProvider, RegExp> = {
openrouter: /^sk-or-[a-zA-Z0-9_-]+$/,
anthropic: /^sk-ant-[a-zA-Z0-9_-]+$/,
openai: /^sk-[a-zA-Z0-9_-]+$/,
};
/**
* Minimum length for API keys (security requirement)
*/
const MIN_API_KEY_LENGTH = 20;
/**
* Maximum length for API keys (sanity check)
*/
const MAX_API_KEY_LENGTH = 256;
// ============================================
// ENCRYPTION KEY MANAGEMENT
// ============================================
/**
* Get the encryption key from environment variables.
* Uses AUTH_SECRET as the encryption key.
*
* @throws Error if AUTH_SECRET is not set
*/
function getEncryptionKey(): Buffer {
const keyEnv = process.env.AUTH_SECRET;
if (!keyEnv) {
throw new Error('AUTH_SECRET environment variable is not set');
}
// Create a 32-byte key from AUTH_SECRET using SHA-256
return crypto.createHash('sha256').update(keyEnv).digest();
}
// ============================================
// ENCRYPTION FUNCTIONS
// ============================================
/**
* Encrypt an API key using AES-256-GCM.
*
* @param apiKey - The plaintext API key to encrypt
* @returns EncryptedData containing the encrypted key and IV
* @throws Error if encryption fails or encryption key is not configured
*
* Validates: Requirements 2.2, 10.3
*/
export function encryptApiKey(apiKey: string): EncryptedData {
if (!apiKey || typeof apiKey !== 'string') {
throw new Error('API key must be a non-empty string');
}
const key = getEncryptionKey();
// Generate a random 16-byte IV for each encryption
const iv = crypto.randomBytes(16);
// Create cipher with AES-256-GCM
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
// Encrypt the API key
let encrypted = cipher.update(apiKey, 'utf8');
encrypted = Buffer.concat([encrypted, cipher.final()]);
// Get the authentication tag (16 bytes for GCM)
const authTag = cipher.getAuthTag();
// Combine encrypted data with auth tag
const combined = Buffer.concat([encrypted, authTag]);
return {
encrypted: combined.toString('base64'),
iv: iv.toString('base64'),
};
}
/**
* Decrypt an encrypted API key using AES-256-GCM.
*
* @param encryptedData - The encrypted data containing ciphertext and IV
* @returns The decrypted plaintext API key
* @throws Error if decryption fails or data is tampered
*
* Validates: Requirements 2.3
*/
export function decryptApiKey(encryptedData: EncryptedData): string {
if (!encryptedData || !encryptedData.encrypted || !encryptedData.iv) {
throw new Error('Invalid encrypted data: missing required fields');
}
const key = getEncryptionKey();
// Decode the IV
const iv = Buffer.from(encryptedData.iv, 'base64');
if (iv.length !== 16) {
throw new Error('Invalid IV length');
}
// Decode the combined encrypted data + auth tag
const combined = Buffer.from(encryptedData.encrypted, 'base64');
// Separate the auth tag (last 16 bytes) from the encrypted data
if (combined.length < 17) {
throw new Error('Invalid encrypted data: too short');
}
const authTag = combined.subarray(combined.length - 16);
const encryptedBuffer = combined.subarray(0, combined.length - 16);
// Create decipher
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(authTag);
// Decrypt
let decrypted = decipher.update(encryptedBuffer);
decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted.toString('utf8');
}
/**
* Serialize encrypted data to a JSON string for database storage.
*
* @param encryptedData - The encrypted data to serialize
* @returns JSON string representation
*/
export function serializeEncryptedData(encryptedData: EncryptedData): string {
return JSON.stringify(encryptedData);
}
/**
* Deserialize encrypted data from a JSON string (from database).
*
* @param serialized - The JSON string to deserialize
* @returns EncryptedData object
* @throws Error if the string is not valid JSON or missing required fields
*/
export function deserializeEncryptedData(serialized: string): EncryptedData {
if (!serialized || typeof serialized !== 'string') {
throw new Error('Invalid serialized data: must be a non-empty string');
}
try {
const parsed = JSON.parse(serialized);
if (!parsed.encrypted || !parsed.iv) {
throw new Error('Invalid encrypted data format: missing required fields');
}
return {
encrypted: parsed.encrypted,
iv: parsed.iv,
};
} catch (error) {
if (error instanceof SyntaxError) {
throw new Error('Invalid serialized data: not valid JSON');
}
throw error;
}
}
// ============================================
// API KEY VALIDATION
// ============================================
/**
* Validate an API key format for a specific provider.
*
* @param apiKey - The API key to validate
* @param provider - The LLM provider type
* @returns Validation result with validity status and any error message
*
* Validates: Requirements 2.1
*/
export function validateApiKeyFormat(apiKey: string, provider: LLMProvider): ApiKeyValidationResult {
// Basic validation
if (!apiKey || typeof apiKey !== 'string') {
return {
valid: false,
error: 'API key must be a non-empty string',
};
}
// Length validation
if (apiKey.length < MIN_API_KEY_LENGTH) {
return {
valid: false,
error: `API key is too short (minimum ${MIN_API_KEY_LENGTH} characters)`,
};
}
if (apiKey.length > MAX_API_KEY_LENGTH) {
return {
valid: false,
error: `API key is too long (maximum ${MAX_API_KEY_LENGTH} characters)`,
};
}
// Provider validation
if (!API_KEY_PATTERNS[provider]) {
return {
valid: false,
error: `Unsupported provider: ${provider}`,
};
}
// For OpenAI, we need to ensure it doesn't match OpenRouter or Anthropic patterns
if (provider === 'openai') {
// OpenAI keys start with "sk-" but NOT "sk-or-" or "sk-ant-"
if (apiKey.startsWith('sk-or-')) {
return {
valid: false,
error: 'This appears to be an OpenRouter key, not an OpenAI key',
};
}
if (apiKey.startsWith('sk-ant-')) {
return {
valid: false,
error: 'This appears to be an Anthropic key, not an OpenAI key',
};
}
}
// Check against provider pattern
const pattern = API_KEY_PATTERNS[provider];
if (!pattern.test(apiKey)) {
return {
valid: false,
error: `Invalid API key format for ${provider}`,
};
}
return {
valid: true,
provider,
};
}
/**
* Detect the provider type from an API key based on its format.
*
* @param apiKey - The API key to analyze
* @returns The detected provider or null if unknown
*/
export function detectProviderFromApiKey(apiKey: string): LLMProvider | null {
if (!apiKey || typeof apiKey !== 'string') {
return null;
}
// Check in order of specificity (most specific prefixes first)
if (apiKey.startsWith('sk-or-')) {
return 'openrouter';
}
if (apiKey.startsWith('sk-ant-')) {
return 'anthropic';
}
if (apiKey.startsWith('sk-')) {
return 'openai';
}
return null;
}
/**
* Validate an API key and detect its provider automatically.
*
* @param apiKey - The API key to validate
* @returns Validation result with detected provider
*/
export function validateAndDetectApiKey(apiKey: string): ApiKeyValidationResult {
const provider = detectProviderFromApiKey(apiKey);
if (!provider) {
return {
valid: false,
error: 'Unable to detect API key provider. Key must start with sk-or- (OpenRouter), sk-ant- (Anthropic), or sk- (OpenAI)',
};
}
return validateApiKeyFormat(apiKey, provider);
}
/**
* Check if a provider is supported.
*
* @param provider - The provider string to check
* @returns True if the provider is supported
*/
export function isSupportedProvider(provider: string): provider is LLMProvider {
return provider === 'openrouter' || provider === 'openai' || provider === 'anthropic';
}
/**
* Get the list of supported providers.
*
* @returns Array of supported provider names
*/
export function getSupportedProviders(): LLMProvider[] {
return ['openrouter', 'openai', 'anthropic'];
}