import { Inject, Injectable, Logger } from '@nestjs/common'; import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'; import type { Db } from '@mosaic/db'; import { providerCredentials, eq, and } from '@mosaic/db'; import { DB } from '../database/database.module.js'; import type { ProviderCredentialSummaryDto } from './provider-credentials.dto.js'; const ALGORITHM = 'aes-256-gcm'; const IV_LENGTH = 12; // 96-bit IV for GCM const TAG_LENGTH = 16; // 128-bit auth tag /** * Derive a 32-byte AES-256 key from BETTER_AUTH_SECRET using SHA-256. * The secret is assumed to be set in the environment. */ function deriveEncryptionKey(): Buffer { const secret = process.env['BETTER_AUTH_SECRET']; if (!secret) { throw new Error('BETTER_AUTH_SECRET is not set — cannot derive encryption key'); } return createHash('sha256').update(secret).digest(); } /** * Encrypt a plain-text value using AES-256-GCM. * Output format: base64(iv + authTag + ciphertext) */ function encrypt(plaintext: string): string { const key = deriveEncryptionKey(); const iv = randomBytes(IV_LENGTH); const cipher = createCipheriv(ALGORITHM, key, iv); const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); const authTag = cipher.getAuthTag(); // Combine iv (12) + authTag (16) + ciphertext and base64-encode const combined = Buffer.concat([iv, authTag, encrypted]); return combined.toString('base64'); } /** * Decrypt a value encrypted by `encrypt()`. * Throws on authentication failure (tampered data). */ function decrypt(encoded: string): string { const key = deriveEncryptionKey(); const combined = Buffer.from(encoded, 'base64'); const iv = combined.subarray(0, IV_LENGTH); const authTag = combined.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH); const ciphertext = combined.subarray(IV_LENGTH + TAG_LENGTH); const decipher = createDecipheriv(ALGORITHM, key, iv); decipher.setAuthTag(authTag); const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]); return decrypted.toString('utf8'); } @Injectable() export class ProviderCredentialsService { private readonly logger = new Logger(ProviderCredentialsService.name); constructor(@Inject(DB) private readonly db: Db) {} /** * Encrypt and store (or update) a credential for the given user + provider. * Uses an upsert pattern: one row per (userId, provider). */ async store( userId: string, provider: string, type: 'api_key' | 'oauth_token', value: string, metadata?: Record, ): Promise { const encryptedValue = encrypt(value); await this.db .insert(providerCredentials) .values({ userId, provider, credentialType: type, encryptedValue, metadata: metadata ?? null, }) .onConflictDoUpdate({ target: [providerCredentials.userId, providerCredentials.provider], set: { credentialType: type, encryptedValue, metadata: metadata ?? null, updatedAt: new Date(), }, }); this.logger.log(`Credential stored for user=${userId} provider=${provider}`); } /** * Decrypt and return the plain-text credential value for the given user + provider. * Returns null if no credential is stored. */ async retrieve(userId: string, provider: string): Promise { const rows = await this.db .select() .from(providerCredentials) .where( and(eq(providerCredentials.userId, userId), eq(providerCredentials.provider, provider)), ) .limit(1); if (rows.length === 0) return null; const row = rows[0]!; // Skip expired OAuth tokens if (row.expiresAt && row.expiresAt < new Date()) { this.logger.warn(`Credential for user=${userId} provider=${provider} has expired`); return null; } try { return decrypt(row.encryptedValue); } catch (err) { this.logger.error( `Failed to decrypt credential for user=${userId} provider=${provider}`, err instanceof Error ? err.message : String(err), ); return null; } } /** * Delete the stored credential for the given user + provider. */ async remove(userId: string, provider: string): Promise { await this.db .delete(providerCredentials) .where( and(eq(providerCredentials.userId, userId), eq(providerCredentials.provider, provider)), ); this.logger.log(`Credential removed for user=${userId} provider=${provider}`); } /** * List all providers for which the user has stored credentials. * Never returns decrypted values. */ async listProviders(userId: string): Promise { const rows = await this.db .select({ provider: providerCredentials.provider, credentialType: providerCredentials.credentialType, expiresAt: providerCredentials.expiresAt, metadata: providerCredentials.metadata, createdAt: providerCredentials.createdAt, updatedAt: providerCredentials.updatedAt, }) .from(providerCredentials) .where(eq(providerCredentials.userId, userId)); return rows.map((row) => ({ provider: row.provider, credentialType: row.credentialType, exists: true, expiresAt: row.expiresAt?.toISOString() ?? null, metadata: row.metadata as Record | null, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), })); } }