Skip to content
Open
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
114 changes: 114 additions & 0 deletions backend/auth/credential-crypto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { debug } from '$shared/utils/logger';

const ALGORITHM = 'AES-GCM';
const KEY_LENGTH = 256;
const IV_LENGTH = 12;
const KEY_FILE = join(homedir(), '.clopen', 'credential-key');
const CIPHER_VERSION = 'v1';

let cachedKey: CryptoKey | null = null;

function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
}

function hexToBytes(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
}
return bytes;
}

function base64Encode(bytes: Uint8Array): string {
return btoa(String.fromCharCode(...bytes));
}

function base64Decode(str: string): Uint8Array {
const binary = atob(str);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}

async function loadOrCreateKey(): Promise<CryptoKey> {
const envKey = process.env.CLOPEN_CREDENTIAL_KEY;
if (envKey) {
const raw = hexToBytes(envKey);
const rawArr = new ArrayBuffer(raw.byteLength);
new Uint8Array(rawArr).set(raw);
return await crypto.subtle.importKey('raw', rawArr, ALGORITHM, false, ['encrypt', 'decrypt']);
}

let raw: Uint8Array;
try {
const stored = await readFile(KEY_FILE, 'utf-8');
raw = hexToBytes(stored.trim());
} catch {
raw = crypto.getRandomValues(new Uint8Array(32));
await mkdir(join(homedir(), '.clopen'), { recursive: true });
await writeFile(KEY_FILE, bytesToHex(raw), 'utf-8');
debug.log('auth', `Generated credential encryption key at ${KEY_FILE}`);
}

const rawArr = new ArrayBuffer(raw.byteLength);
new Uint8Array(rawArr).set(raw);
return await crypto.subtle.importKey('raw', rawArr, ALGORITHM, false, ['encrypt', 'decrypt']);
}

async function getKey(): Promise<CryptoKey> {
if (!cachedKey) {
cachedKey = await loadOrCreateKey();
}
return cachedKey;
}

export function isEncrypted(value: string): boolean {
return value.startsWith(`${CIPHER_VERSION}:`);
}

export async function encryptCredential(plaintext: string): Promise<string> {
const key = await getKey();
const ivRaw = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
const ivArr = new ArrayBuffer(12);
new Uint8Array(ivArr).set(ivRaw);
const encoded = new TextEncoder().encode(plaintext);
const encrypted = await crypto.subtle.encrypt({ name: ALGORITHM, iv: ivArr }, key, encoded);
const encryptedBytes = new Uint8Array(encrypted);
const authTag = encryptedBytes.slice(-16);
const ciphertext = encryptedBytes.slice(0, -16);
const payload = `${CIPHER_VERSION}:${base64Encode(ivRaw)}:${base64Encode(authTag)}:${base64Encode(ciphertext)}`;
return payload;
}

export async function decryptCredential(payload: string): Promise<string> {
if (!isEncrypted(payload)) return payload;

const parts = payload.split(':');
if (parts.length !== 4) return payload;

const ivRaw = base64Decode(parts[1]);
const authTag = base64Decode(parts[2]);
const ciphertext = base64Decode(parts[3]);

const combined = new Uint8Array(ciphertext.length + authTag.length);
combined.set(ciphertext, 0);
combined.set(authTag, ciphertext.length);

const key = await getKey();
const ivArr = new ArrayBuffer(12);
new Uint8Array(ivArr).set(ivRaw);
const combinedBuf = new ArrayBuffer(combined.byteLength);
new Uint8Array(combinedBuf).set(combined);
const decrypted = await crypto.subtle.decrypt({ name: ALGORITHM, iv: ivArr }, key, combinedBuf);
return new TextDecoder().decode(decrypted);
}

export function resetCredentialKeyCache(): void {
cachedKey = null;
}
22 changes: 22 additions & 0 deletions backend/database/migrations/040_encrypt_engine_credentials.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { DatabaseConnection } from '$shared/types/database/connection';
import { encryptCredential, isEncrypted } from '../../auth/credential-crypto';

export const id = '040';
export const description = 'Encrypt existing engine account credentials at rest';

export async function up(db: DatabaseConnection): Promise<void> {
const rows = db.prepare(
`SELECT id, credential FROM engine_accounts`
).all() as { id: number; credential: string }[];

const update = db.prepare(`UPDATE engine_accounts SET credential = ? WHERE id = ?`);

for (const row of rows) {
if (isEncrypted(row.credential)) continue;
const encrypted = await encryptCredential(row.credential);
update.run(encrypted, row.id);
}
}

export async function down(_db: DatabaseConnection): Promise<void> {
}
7 changes: 7 additions & 0 deletions backend/database/migrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import * as migration036 from './036_create_auth_audit_log';
import * as migration037 from './037_repair_auth_audit_log_schema';
import * as migration038 from './038_add_workspace_state_to_user_projects';
import * as migration039 from './039_create_file_audit_log';
import * as migration040 from './040_encrypt_engine_credentials';

// Export all migrations in order
export const migrations = [
Expand Down Expand Up @@ -274,6 +275,12 @@ export const migrations = [
description: migration039.description,
up: migration039.up,
down: migration039.down
},
{
id: '040',
description: migration040.description,
up: migration040.up,
down: migration040.down
}
];

Expand Down
53 changes: 37 additions & 16 deletions backend/database/queries/engine-queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

import type { EngineType } from '$shared/types/unified';
import { getDatabase } from '../index';
import { encryptCredential, decryptCredential, isEncrypted } from '../../auth/credential-crypto';

// ============================================================================
// Types
Expand Down Expand Up @@ -46,6 +47,16 @@ export interface EngineProviderWithAccounts extends EngineProvider {
accounts: EngineAccount[];
}

async function decryptAccount(account: EngineAccount | null): Promise<EngineAccount | null> {
if (!account) return null;
if (!isEncrypted(account.credential)) return account;
return { ...account, credential: await decryptCredential(account.credential) };
}

async function decryptAccounts(accounts: EngineAccount[]): Promise<EngineAccount[]> {
return Promise.all(accounts.map(a => decryptAccount(a).then(a => a!)));
}

// ============================================================================
// Queries
// ============================================================================
Expand Down Expand Up @@ -135,43 +146,48 @@ export const engineQueries = {
// Accounts
// ------------------------------------------------------------------

getAccount(id: number): EngineAccount | null {
async getAccount(id: number): Promise<EngineAccount | null> {
const db = getDatabase();
return db.prepare(`SELECT * FROM engine_accounts WHERE id = ?`).get(id) as EngineAccount | null;
const account = db.prepare(`SELECT * FROM engine_accounts WHERE id = ?`).get(id) as EngineAccount | null;
return decryptAccount(account);
},

getAccountsByProvider(providerId: number): EngineAccount[] {
async getAccountsByProvider(providerId: number): Promise<EngineAccount[]> {
const db = getDatabase();
return db.prepare(
const accounts = db.prepare(
`SELECT * FROM engine_accounts WHERE provider_id = ? ORDER BY created_at ASC`
).all(providerId) as EngineAccount[];
return decryptAccounts(accounts);
},

getActiveAccount(providerId: number): EngineAccount | null {
async getActiveAccount(providerId: number): Promise<EngineAccount | null> {
const db = getDatabase();
return db.prepare(
const account = db.prepare(
`SELECT * FROM engine_accounts WHERE provider_id = ? AND is_active = 1`
).get(providerId) as EngineAccount | null;
return decryptAccount(account);
},

/**
* Get the active account for the first enabled provider of the given engine.
* For claude-code this is effectively the active Anthropic account.
*/
getActiveAccountForEngine(engineType: EngineType): EngineAccount | null {
async getActiveAccountForEngine(engineType: EngineType): Promise<EngineAccount | null> {
const db = getDatabase();
return db.prepare(`
const account = db.prepare(`
SELECT a.*
FROM engine_accounts a
JOIN engine_providers p ON p.id = a.provider_id
WHERE p.engine_type = ? AND p.is_enabled = 1 AND a.is_active = 1
ORDER BY p.created_at ASC, a.created_at ASC
LIMIT 1
`).get(engineType) as EngineAccount | null;
return decryptAccount(account);
},

createAccount(providerId: number, name: string, credential: string): EngineAccount {
async createAccount(providerId: number, name: string, credential: string): Promise<EngineAccount> {
const db = getDatabase();
const encrypted = await encryptCredential(credential);

// If it's the first account for this provider → mark active.
const count = (db.prepare(
Expand All @@ -182,10 +198,11 @@ export const engineQueries = {
const result = db.prepare(`
INSERT INTO engine_accounts (provider_id, name, credential, is_active)
VALUES (?, ?, ?, ?)
`).run(providerId, name, credential, isActive) as { lastInsertRowid: number | bigint };
`).run(providerId, name, encrypted, isActive) as { lastInsertRowid: number | bigint };

const id = Number(result.lastInsertRowid);
return db.prepare(`SELECT * FROM engine_accounts WHERE id = ?`).get(id) as EngineAccount;
const account = db.prepare(`SELECT * FROM engine_accounts WHERE id = ?`).get(id) as EngineAccount;
return { ...account, credential };
},

switchAccount(accountId: number): void {
Expand Down Expand Up @@ -227,20 +244,24 @@ export const engineQueries = {
* into `credential` after each stream so refreshed tokens survive across
* account switches.
*/
updateAccountCredential(accountId: number, credential: string): void {
async updateAccountCredential(accountId: number, credential: string): Promise<void> {
const db = getDatabase();
db.prepare(`UPDATE engine_accounts SET credential = ? WHERE id = ?`).run(credential, accountId);
const encrypted = await encryptCredential(credential);
db.prepare(`UPDATE engine_accounts SET credential = ? WHERE id = ?`).run(encrypted, accountId);
},

// ------------------------------------------------------------------
// Composite
// ------------------------------------------------------------------

getProvidersWithAccounts(engineType?: EngineType): EngineProviderWithAccounts[] {
async getProvidersWithAccounts(engineType?: EngineType): Promise<EngineProviderWithAccounts[]> {
const providers = this.getProviders(engineType);
return providers.map(p => ({
const accountsByProvider = await Promise.all(
providers.map(p => this.getAccountsByProvider(p.id))
);
return providers.map((p, i) => ({
...p,
accounts: this.getAccountsByProvider(p.id),
accounts: accountsByProvider[i],
}));
},
};
8 changes: 4 additions & 4 deletions backend/database/utils/migration-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import { debug } from '$shared/utils/logger';
interface Migration {
id: string;
description: string;
up: (db: DatabaseConnection) => void;
down: (db: DatabaseConnection) => void;
up: (db: DatabaseConnection) => void | Promise<void>;
down: (db: DatabaseConnection) => void | Promise<void>;
}

export class MigrationRunner {
Expand Down Expand Up @@ -52,7 +52,7 @@ export class MigrationRunner {

try {
// Execute migration
migration.up(this.db);
await migration.up(this.db);

// Record migration as executed
this.db.prepare(`
Expand Down Expand Up @@ -86,7 +86,7 @@ export class MigrationRunner {

try {
// Execute rollback
migration.down(this.db);
await migration.down(this.db);

// Remove migration record
this.db.prepare(`
Expand Down
39 changes: 18 additions & 21 deletions backend/engine/adapters/claude/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,26 +59,23 @@ export async function setupEnvironmentOnce(): Promise<void> {
* When accountId is provided, overrides the OAuth token with that
* specific account's token instead of the globally active account.
*/
export function getEngineEnv(accountId?: number): Record<string, string> {
// Start from clean env (no Bun/npm/Vite pollution)
const env = getCleanSpawnEnv();
// Apply our overrides
Object.assign(env, _envOverrides);

// Override with specific account token if requested
if (accountId !== undefined) {
try {
const account = engineQueries.getAccount(accountId);
if (account) {
env['CLAUDE_CODE_OAUTH_TOKEN'] = account.credential;
debug.log('engine', `Claude Code: Per-session account override → "${account.name}"`);
}
} catch {
// Ignore — fall back to default token from overrides
}
}

return env;
export async function getEngineEnv(accountId?: number): Promise<Record<string, string>> {
const env = getCleanSpawnEnv();
Object.assign(env, _envOverrides);

if (accountId !== undefined) {
try {
const account = await engineQueries.getAccount(accountId);
if (account) {
env['CLAUDE_CODE_OAUTH_TOKEN'] = account.credential;
debug.log('engine', `Claude Code: Per-session account override → "${account.name}"`);
}
} catch {
// Ignore DB errors during per-session override.
}
}

return env;
}

async function _doSetup(): Promise<void> {
Expand All @@ -94,7 +91,7 @@ async function _doSetup(): Promise<void> {

// Inject OAuth token from active account (if any)
try {
const activeAccount = engineQueries.getActiveAccountForEngine('claude-code');
const activeAccount = await engineQueries.getActiveAccountForEngine('claude-code');
if (activeAccount) {
overrides['CLAUDE_CODE_OAUTH_TOKEN'] = activeAccount.credential;
debug.log('engine', `✅ Claude Code: Using account "${activeAccount.name}"`);
Expand Down
4 changes: 2 additions & 2 deletions backend/engine/adapters/claude/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ export class ClaudeCodeEngine implements AIEngine {
permissionMode: 'bypassPermissions' as PermissionMode,
allowDangerouslySkipPermissions: true,
cwd: resolvedProjectPath,
env: getEngineEnv(accountId),
env: await getEngineEnv(accountId),
systemPrompt: { type: "preset", preset: "claude_code" },
settingSources: ["user", "project", "local"],
forkSession: true,
Expand Down Expand Up @@ -304,7 +304,7 @@ export class ClaudeCodeEngine implements AIEngine {
permissionMode: 'bypassPermissions' as PermissionMode,
allowDangerouslySkipPermissions: true,
cwd: resolvedPath,
env: getEngineEnv(accountId),
env: await getEngineEnv(accountId),
systemPrompt: 'You are a structured data generator. Return JSON matching the provided schema.',
tools: [],
outputFormat: {
Expand Down
Loading
Loading