diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5c14c6d..c86d2f5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,7 +4,7 @@ on: push: branches: - main - - master + - beta permissions: contents: write diff --git a/release.config.cjs b/release.config.cjs index 2301b63..b8370c7 100644 --- a/release.config.cjs +++ b/release.config.cjs @@ -194,7 +194,7 @@ const appendReleaseNotesPlugin = { }; const releaseConfig = { - branches: ['main', 'master'], + branches: ['main', { name: 'beta', prerelease: true }], plugins: [ [ '@semantic-release/commit-analyzer', diff --git a/src/components/CloudAccountList.tsx b/src/components/CloudAccountList.tsx index d4e5a16..6831601 100644 --- a/src/components/CloudAccountList.tsx +++ b/src/components/CloudAccountList.tsx @@ -47,7 +47,7 @@ import { getLocalizedErrorMessage } from '@/utils/errorMessages'; export function CloudAccountList() { const { t } = useTranslation(); - const { data: accounts, isLoading, isError } = useCloudAccounts(); + const { data: accounts, isLoading, isError, error } = useCloudAccounts(); const refreshMutation = useRefreshQuota(); const deleteMutation = useDeleteCloudAccount(); const addMutation = useAddGoogleAccount(); @@ -273,7 +273,13 @@ export function CloudAccountList() { } if (isError) { - return
Failed to load cloud accounts.
; + const errorMessage = getLocalizedErrorMessage(error, t); + return ( +
+
{t('cloud.error.loadFailed')}
+
{errorMessage}
+
+ ); } return ( diff --git a/src/ipc/database/cloudHandler.ts b/src/ipc/database/cloudHandler.ts index df3a936..af06041 100644 --- a/src/ipc/database/cloudHandler.ts +++ b/src/ipc/database/cloudHandler.ts @@ -5,7 +5,7 @@ import { v4 as uuidv4 } from 'uuid'; import { getCloudAccountsDbPath, getAntigravityDbPaths } from '../../utils/paths'; import { logger } from '../../utils/logger'; import { CloudAccount } from '../../types/cloudAccount'; -import { encrypt, decrypt } from '../../utils/security'; +import { decryptWithMigration, encrypt, type KeySource } from '../../utils/security'; import { ProtobufUtils } from '../../utils/protobuf'; import { GoogleAPIService } from '../../services/GoogleAPIService'; @@ -79,6 +79,65 @@ function getDb(): Database.Database { return db; } +interface MigrationStats { + totalFields: number; + fallbackUsedFields: number; + migratedFields: number; + migratedBySource: Record; + failedFields: number; +} + +function createMigrationStats(): MigrationStats { + return { + totalFields: 0, + fallbackUsedFields: 0, + migratedFields: 0, + migratedBySource: { + safeStorage: 0, + keytar: 0, + file: 0, + }, + failedFields: 0, + }; +} + +async function decryptAndMigrateField( + db: Database.Database, + accountId: string, + field: 'token_json' | 'quota_json', + value: string | null, +): Promise<{ value: string | null; migrated: boolean; usedFallback?: KeySource }> { + if (!value) { + return { value: null, migrated: false }; + } + + const result = await decryptWithMigration(value); + if (result.reencrypted) { + if (field === 'token_json') { + db.prepare('UPDATE accounts SET token_json = ? WHERE id = ?').run( + result.reencrypted, + accountId, + ); + } else { + db.prepare('UPDATE accounts SET quota_json = ? WHERE id = ?').run( + result.reencrypted, + accountId, + ); + } + logger.info( + `Migrated ${field} for account ${accountId} from ${result.usedFallback ?? 'unknown'} key`, + ); + } + + return { + value: result.value, + migrated: Boolean(result.reencrypted), + usedFallback: result.usedFallback, + }; +} + +type DecryptFieldResult = Awaited>; + export class CloudAccountRepo { static async init(): Promise { const dbPath = getCloudAccountsDbPath(); @@ -171,6 +230,7 @@ export class CloudAccountRepo { static async getAccounts(): Promise { const db = getDb(); + const migrationStats = createMigrationStats(); try { const stmt = db.prepare('SELECT * FROM accounts ORDER BY last_used DESC'); @@ -183,24 +243,91 @@ export class CloudAccountRepo { ); activeRows.forEach((r) => logger.info(`[DEBUG] Active Account: ${r.email} (${r.id})`)); - const accounts = await Promise.all( - rows.map(async (row) => ({ + const accounts: CloudAccount[] = []; + for (const row of rows) { + let tokenResult: DecryptFieldResult; + try { + tokenResult = await decryptAndMigrateField(db, row.id, 'token_json', row.token_json); + } catch (error) { + migrationStats.failedFields += 1; + logger.error(`Failed to decrypt token for account ${row.id}`, error); + throw error; + } + + let quotaResult: DecryptFieldResult; + try { + quotaResult = await decryptAndMigrateField(db, row.id, 'quota_json', row.quota_json); + } catch (error) { + migrationStats.failedFields += 1; + logger.error(`Failed to decrypt quota for account ${row.id}`, error); + throw error; + } + + if (!tokenResult.value) { + throw new Error(`Missing token data for account ${row.id}`); + } + + if (tokenResult.value) { + migrationStats.totalFields += 1; + } + if (tokenResult.usedFallback) { + migrationStats.fallbackUsedFields += 1; + } + if (tokenResult.migrated) { + migrationStats.migratedFields += 1; + if (tokenResult.usedFallback) { + migrationStats.migratedBySource[tokenResult.usedFallback] += 1; + } + } + + if (quotaResult.value) { + migrationStats.totalFields += 1; + } + if (quotaResult.usedFallback) { + migrationStats.fallbackUsedFields += 1; + } + if (quotaResult.migrated) { + migrationStats.migratedFields += 1; + if (quotaResult.usedFallback) { + migrationStats.migratedBySource[quotaResult.usedFallback] += 1; + } + } + + accounts.push({ id: row.id, provider: row.provider, email: row.email, name: row.name, avatar_url: row.avatar_url, - token: JSON.parse(await decrypt(row.token_json)), - quota: row.quota_json ? JSON.parse(await decrypt(row.quota_json)) : undefined, + token: JSON.parse(tokenResult.value), + quota: quotaResult.value ? JSON.parse(quotaResult.value) : undefined, created_at: row.created_at, last_used: row.last_used, status: row.status, is_active: Boolean(row.is_active), - })), - ); + }); + } return accounts; } finally { + if ( + migrationStats.migratedFields > 0 || + migrationStats.fallbackUsedFields > 0 || + migrationStats.failedFields > 0 + ) { + const summary = { + totalFields: migrationStats.totalFields, + fallbackUsedFields: migrationStats.fallbackUsedFields, + migratedFields: migrationStats.migratedFields, + migratedBySource: migrationStats.migratedBySource, + failedFields: migrationStats.failedFields, + }; + if (migrationStats.failedFields > 0) { + logger.warn('CloudAccountRepo migration summary (with failures)', summary); + } else { + logger.info('CloudAccountRepo migration summary', summary); + } + } db.close(); } } @@ -214,14 +341,21 @@ export class CloudAccountRepo { if (!row) return undefined; + const tokenResult = await decryptAndMigrateField(db, row.id, 'token_json', row.token_json); + const quotaResult = await decryptAndMigrateField(db, row.id, 'quota_json', row.quota_json); + + if (!tokenResult.value) { + throw new Error(`Missing token data for account ${row.id}`); + } + return { id: row.id, provider: row.provider, email: row.email, name: row.name, avatar_url: row.avatar_url, - token: JSON.parse(await decrypt(row.token_json)), - quota: row.quota_json ? JSON.parse(await decrypt(row.quota_json)) : undefined, + token: JSON.parse(tokenResult.value), + quota: quotaResult.value ? JSON.parse(quotaResult.value) : undefined, created_at: row.created_at, last_used: row.last_used, status: row.status, @@ -296,14 +430,7 @@ export class CloudAccountRepo { static injectCloudToken(account: CloudAccount): void { const dbPaths = getAntigravityDbPaths(); - let dbPath: string | null = null; - - for (const p of dbPaths) { - if (fs.existsSync(p)) { - dbPath = p; - break; - } - } + const dbPath = dbPaths.find((p) => fs.existsSync(p)) ?? null; if (!dbPath) { throw new Error(`Antigravity database not found. Checked paths: ${dbPaths.join(', ')}`); @@ -441,14 +568,11 @@ export class CloudAccountRepo { const dbPaths = getAntigravityDbPaths(); logger.info(`SyncLocal: Checking database paths: ${JSON.stringify(dbPaths)}`); - let dbPath: string | null = null; - for (const p of dbPaths) { - logger.info(`SyncLocal: Checking path: ${p}, exists: ${fs.existsSync(p)}`); - if (fs.existsSync(p)) { - dbPath = p; - break; - } - } + const dbPath = + dbPaths.find((p) => { + logger.info(`SyncLocal: Checking path: ${p}, exists: ${fs.existsSync(p)}`); + return fs.existsSync(p); + }) ?? null; if (!dbPath) { const errorMsg = `Antigravity database not found. Please ensure Antigravity IDE is installed. Checked paths: ${dbPaths.join(', ')}`; diff --git a/src/localization/i18n.ts b/src/localization/i18n.ts index 59bc0e2..4aed5ef 100644 --- a/src/localization/i18n.ts +++ b/src/localization/i18n.ts @@ -42,6 +42,11 @@ i18n 'Keychain access denied. The app may be unsigned; see README for the self-signing workaround.', signNotarize: 'Please use a signed and notarized build when available.', }, + dataMigrationFailed: 'Unable to decrypt legacy account data.', + dataMigrationHint: { + relogin: 'Please re-login or re-add your accounts.', + clearData: 'If the issue persists, clear local account data and sign in again.', + }, }, nav: { accounts: 'Accounts', @@ -222,6 +227,9 @@ i18n list: { noAccounts: 'No cloud accounts added yet.', }, + error: { + loadFailed: 'Failed to load cloud accounts.', + }, toast: { syncSuccess: { title: 'Sync Successful', @@ -324,6 +332,11 @@ i18n keychainDenied: 'Keychain 访问被拒绝。应用可能未签名,可参考 README 的“macOS 自签”说明。', signNotarize: '请在可用时使用已签名并公证的版本。', }, + dataMigrationFailed: '旧版账号数据无法解密。', + dataMigrationHint: { + relogin: '请重新登录或重新添加账号。', + clearData: '如仍失败,请清理本地账号数据后再登录。', + }, }, nav: { accounts: '账号', @@ -502,6 +515,9 @@ i18n list: { noAccounts: '暂无云账号。', }, + error: { + loadFailed: '加载云账号失败。', + }, toast: { syncSuccess: { title: '同步成功', diff --git a/src/tests/unit/security-migration.test.ts b/src/tests/unit/security-migration.test.ts new file mode 100644 index 0000000..ee6b5e9 --- /dev/null +++ b/src/tests/unit/security-migration.test.ts @@ -0,0 +1,122 @@ +import crypto from 'crypto'; +import fs from 'fs/promises'; +import keytar from 'keytar'; +import { safeStorage } from 'electron'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { decryptWithMigration } from '../../utils/security'; + +const primaryHex = '11'.repeat(32); +const fallbackHex = '22'.repeat(32); + +vi.mock('electron', () => ({ + safeStorage: { + isEncryptionAvailable: vi.fn(() => true), + decryptString: vi.fn(() => primaryHex), + encryptString: vi.fn((value: string) => Buffer.from(value)), + }, + app: { + getPath: vi.fn(() => 'C:\\test'), + }, +})); + +vi.mock('fs/promises', () => ({ + default: { + readFile: vi.fn(), + writeFile: vi.fn(), + rename: vi.fn(), + unlink: vi.fn(), + }, +})); + +vi.mock('keytar', () => ({ + default: { + findCredentials: vi.fn(async () => []), + getPassword: vi.fn(async () => fallbackHex), + setPassword: vi.fn(), + }, +})); + +const fsMock = vi.mocked(fs, { deep: true }); +const keytarMock = vi.mocked(keytar, { deep: true }); +const safeStorageMock = vi.mocked(safeStorage, { deep: true }); + +function encryptWithKey(key: Buffer, text: string): string { + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); + + let encrypted = cipher.update(text, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + + const authTag = cipher.getAuthTag(); + + return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`; +} + +beforeEach(() => { + vi.clearAllMocks(); + + safeStorageMock.isEncryptionAvailable.mockReturnValue(true); + safeStorageMock.decryptString.mockReturnValue(primaryHex); + + fsMock.readFile.mockImplementation(async (_path, encoding) => { + if (encoding === 'utf8') { + return 'not-hex'; + } + + return Buffer.from('encrypted'); + }); + fsMock.writeFile.mockResolvedValue(undefined); + fsMock.rename.mockResolvedValue(undefined); + fsMock.unlink.mockResolvedValue(undefined); + + keytarMock.findCredentials.mockResolvedValue([]); + keytarMock.getPassword.mockResolvedValue(fallbackHex); +}); + +describe('decryptWithMigration', () => { + it('falls back to legacy key and re-encrypts', async () => { + const plaintext = '{"token":"legacy"}'; + const ciphertext = encryptWithKey(Buffer.from(fallbackHex, 'hex'), plaintext); + + const result = await decryptWithMigration(ciphertext); + + expect(result.value).toBe(plaintext); + expect(result.usedFallback).toBe('keytar'); + expect(result.reencrypted).toBeTypeOf('string'); + expect(result.reencrypted).not.toBe(ciphertext); + expect(keytarMock.getPassword).toHaveBeenCalledTimes(1); + + if (result.reencrypted) { + const migrated = await decryptWithMigration(result.reencrypted); + expect(migrated.value).toBe(plaintext); + expect(migrated.usedFallback).toBeUndefined(); + } + }); + + it('does not use fallback when primary key works', async () => { + const plaintext = '{"token":"primary"}'; + const ciphertext = encryptWithKey(Buffer.from(primaryHex, 'hex'), plaintext); + + const result = await decryptWithMigration(ciphertext); + + expect(result.value).toBe(plaintext); + expect(result.usedFallback).toBeUndefined(); + expect(result.reencrypted).toBeUndefined(); + }); + + it('throws migration error code when legacy keys are unavailable', async () => { + keytarMock.getPassword.mockResolvedValue(null); + fsMock.readFile.mockImplementation(async (_path, encoding) => { + if (encoding === 'utf8') { + return 'not-hex'; + } + + return Buffer.from('encrypted'); + }); + + const plaintext = '{"token":"legacy"}'; + const ciphertext = encryptWithKey(Buffer.from('33'.repeat(32), 'hex'), plaintext); + + await expect(decryptWithMigration(ciphertext)).rejects.toThrow('ERR_DATA_MIGRATION_FAILED'); + }); +}); diff --git a/src/utils/errorMessages.ts b/src/utils/errorMessages.ts index 55efff7..af22484 100644 --- a/src/utils/errorMessages.ts +++ b/src/utils/errorMessages.ts @@ -4,6 +4,9 @@ const KEYCHAIN_ERROR_CODE = 'ERR_KEYCHAIN_UNAVAILABLE'; const KEYCHAIN_HINT_TRANSLOCATION = 'HINT_APP_TRANSLOCATION'; const KEYCHAIN_HINT_KEYCHAIN_DENIED = 'HINT_KEYCHAIN_DENIED'; const KEYCHAIN_HINT_SIGN_NOTARIZE = 'HINT_SIGN_NOTARIZE'; +const DATA_MIGRATION_ERROR_CODE = 'ERR_DATA_MIGRATION_FAILED'; +const DATA_MIGRATION_HINT_RELOGIN = 'HINT_RELOGIN'; +const DATA_MIGRATION_HINT_CLEAR_DATA = 'HINT_CLEAR_DATA'; const KEYCHAIN_HINT_I18N_MAP: Record = { [KEYCHAIN_HINT_TRANSLOCATION]: 'error.keychainHint.translocation', @@ -11,6 +14,11 @@ const KEYCHAIN_HINT_I18N_MAP: Record = { [KEYCHAIN_HINT_SIGN_NOTARIZE]: 'error.keychainHint.signNotarize', }; +const DATA_MIGRATION_HINT_I18N_MAP: Record = { + [DATA_MIGRATION_HINT_RELOGIN]: 'error.dataMigrationHint.relogin', + [DATA_MIGRATION_HINT_CLEAR_DATA]: 'error.dataMigrationHint.clearData', +}; + function resolveKeychainMessage(hintCode: string | undefined, t: TFunction): string { const base = t('error.keychainUnavailable'); if (!hintCode) { @@ -25,6 +33,20 @@ function resolveKeychainMessage(hintCode: string | undefined, t: TFunction): str return `${base} ${t(hintKey)}`; } +function resolveDataMigrationMessage(hintCode: string | undefined, t: TFunction): string { + const base = t('error.dataMigrationFailed'); + if (!hintCode) { + return base; + } + + const hintKey = DATA_MIGRATION_HINT_I18N_MAP[hintCode]; + if (!hintKey) { + return base; + } + + return `${base} ${t(hintKey)}`; +} + export function getLocalizedErrorMessage(error: unknown, t: TFunction): string { if (error instanceof Error) { const rawMessage = error.message; @@ -32,8 +54,25 @@ export function getLocalizedErrorMessage(error: unknown, t: TFunction): string { if (code === KEYCHAIN_ERROR_CODE) { return resolveKeychainMessage(hint, t); } + if (code === DATA_MIGRATION_ERROR_CODE) { + return resolveDataMigrationMessage(hint, t); + } return rawMessage; } + if (error && typeof error === 'object' && 'message' in error) { + const rawMessage = String((error as { message?: unknown }).message ?? ''); + const [code, hint] = rawMessage.split('|'); + if (code === KEYCHAIN_ERROR_CODE) { + return resolveKeychainMessage(hint, t); + } + if (code === DATA_MIGRATION_ERROR_CODE) { + return resolveDataMigrationMessage(hint, t); + } + if (rawMessage) { + return rawMessage; + } + } + return String(error); } diff --git a/src/utils/security.ts b/src/utils/security.ts index bda7c3e..b44c37f 100644 --- a/src/utils/security.ts +++ b/src/utils/security.ts @@ -1,7 +1,8 @@ -import keytar from 'keytar'; import crypto from 'crypto'; -import { app } from 'electron'; import { logger } from './logger'; +import { safeStorage, app } from 'electron'; +import path from 'path'; +import fs from 'fs/promises'; const SERVICE_NAME = 'AntigravityManager'; const ACCOUNT_NAME = 'MasterKey'; @@ -9,9 +10,32 @@ const KEYCHAIN_ERROR_CODE = 'ERR_KEYCHAIN_UNAVAILABLE'; const KEYCHAIN_HINT_TRANSLOCATION = 'HINT_APP_TRANSLOCATION'; const KEYCHAIN_HINT_KEYCHAIN_DENIED = 'HINT_KEYCHAIN_DENIED'; const KEYCHAIN_HINT_SIGN_NOTARIZE = 'HINT_SIGN_NOTARIZE'; +const DATA_MIGRATION_ERROR_CODE = 'ERR_DATA_MIGRATION_FAILED'; +const DATA_MIGRATION_HINT_RELOGIN = 'HINT_RELOGIN'; + +export type KeySource = 'safeStorage' | 'keytar' | 'file'; + +interface MasterKeyState { + key: Buffer; + source: KeySource; +} // Cache the key in memory to avoid frequent system calls let cachedMasterKey: Buffer | null = null; +let cachedMasterKeySource: KeySource | null = null; + +function cacheMasterKey(key: Buffer, source: KeySource) { + cachedMasterKey = key; + cachedMasterKeySource = source; +} + +function getCachedMasterKey() { + if (!cachedMasterKey || !cachedMasterKeySource) { + return null; + } + + return { key: cachedMasterKey, source: cachedMasterKeySource }; +} function buildKeychainAccessHint(error: unknown): string | null { if (process.platform !== 'darwin') { @@ -38,27 +62,274 @@ function buildKeychainAccessHint(error: unknown): string | null { return KEYCHAIN_HINT_SIGN_NOTARIZE; } -async function getOrGenerateMasterKey(): Promise { - if (cachedMasterKey) return cachedMasterKey; +// Lock to prevent concurrent key generation +let keyGenerationInProgress: Promise | null = null; + +// Fallback key file path (used when keytar and safeStorage both fail) +function getFallbackKeyPath(): string { + const userDataPath = app.getPath('userData'); + return path.join(userDataPath, '.mk'); +} + +/** + * Try to load keytar dynamically to avoid hard failure if it's not available + */ +async function tryKeytar(): Promise { + try { + // Native modules may fail to load in production builds + const keytar = await import('keytar'); + // Test if keytar is actually working by calling a method + await keytar.default.findCredentials(SERVICE_NAME); + return keytar.default; + } catch (error) { + logger.warn('Security: keytar not available, using fallback', error); + return null; + } +} + +async function readSafeStorageKey(keyPath: string): Promise { + if (!safeStorage.isEncryptionAvailable()) { + return null; + } try { - let hexKey = await keytar.getPassword(SERVICE_NAME, ACCOUNT_NAME); + const encryptedKey = await fs.readFile(keyPath); + const hexKey = safeStorage.decryptString(encryptedKey); - if (!hexKey) { - logger.info('Security: Generating new master key...'); - // Generate 256-bit key (32 bytes) - const buffer = crypto.randomBytes(32); - hexKey = buffer.toString('hex'); - await keytar.setPassword(SERVICE_NAME, ACCOUNT_NAME, hexKey); + if (!/^[a-f0-9]+$/i.test(hexKey) || hexKey.length !== 64) { + return null; } - cachedMasterKey = Buffer.from(hexKey, 'hex'); - return cachedMasterKey; + return Buffer.from(hexKey, 'hex'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + + logger.warn('Security: Failed to read safeStorage key file', error); + return null; + } +} + +async function getOrCreateSafeStorageKey( + keyPath: string, +): Promise<{ key: Buffer; created: boolean }> { + const existingKey = await readSafeStorageKey(keyPath); + if (existingKey) { + return { key: existingKey, created: false }; + } + + const buffer = crypto.randomBytes(32); + const hexKey = buffer.toString('hex'); + const encrypted = safeStorage.encryptString(hexKey); + await atomicWriteFile(keyPath, encrypted, { mode: 0o600 }); + return { key: buffer, created: true }; +} + +async function readKeytarKey(): Promise { + const keytar = await tryKeytar(); + if (!keytar) { + return null; + } + + const existingKey = await keytar.getPassword(SERVICE_NAME, ACCOUNT_NAME); + if (!existingKey) { + return null; + } + + if (!/^[a-f0-9]+$/i.test(existingKey) || existingKey.length !== 64) { + logger.warn('Security: keytar key has unexpected format'); + return null; + } + + return Buffer.from(existingKey, 'hex'); +} + +async function getOrCreateKeytarKey(): Promise<{ key: Buffer; created: boolean } | null> { + const keytar = await tryKeytar(); + if (!keytar) { + return null; + } + + const existingKey = await keytar.getPassword(SERVICE_NAME, ACCOUNT_NAME); + if (existingKey) { + return { key: Buffer.from(existingKey, 'hex'), created: false }; + } + + const buffer = crypto.randomBytes(32); + const hexKey = buffer.toString('hex'); + await keytar.setPassword(SERVICE_NAME, ACCOUNT_NAME, hexKey); + return { key: buffer, created: true }; +} + +async function readFileFallbackKey(keyPath: string): Promise { + try { + const content = await fs.readFile(keyPath, 'utf8'); + if (content.length === 64 && /^[a-f0-9]+$/i.test(content)) { + return Buffer.from(content, 'hex'); + } + + return null; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + + logger.warn('Security: Error reading fallback key file', error); + return null; + } +} + +async function getOrCreateFileFallbackKey( + keyPath: string, +): Promise<{ key: Buffer; created: boolean }> { + const existingKey = await readFileFallbackKey(keyPath); + if (existingKey) { + return { key: existingKey, created: false }; + } + + const buffer = crypto.randomBytes(32); + const hexKey = buffer.toString('hex'); + await atomicWriteFile(keyPath, hexKey, { mode: 0o600 }); + return { key: buffer, created: true }; +} + +async function getFallbackMasterKeys( + keyPath: string, + primarySource: KeySource, +): Promise { + const fallbackKeys: MasterKeyState[] = []; + + if (primarySource !== 'keytar') { + const keytarKey = await readKeytarKey(); + if (keytarKey) { + fallbackKeys.push({ key: keytarKey, source: 'keytar' }); + } + } + + if (primarySource !== 'file') { + const fileKey = await readFileFallbackKey(keyPath); + if (fileKey) { + fallbackKeys.push({ key: fileKey, source: 'file' }); + } + } + + if (primarySource !== 'safeStorage') { + const safeStorageKey = await readSafeStorageKey(keyPath); + if (safeStorageKey) { + fallbackKeys.push({ key: safeStorageKey, source: 'safeStorage' }); + } + } + + return fallbackKeys; +} + +/** + * Atomic file write to prevent data races + * Writes to a temp file first, then renames atomically + */ +async function atomicWriteFile( + filePath: string, + data: Buffer | string, + options?: { mode?: number }, +): Promise { + const tempPath = `${filePath}.tmp.${process.pid}.${Date.now()}`; + try { + await fs.writeFile(tempPath, data, { mode: options?.mode ?? 0o600 }); + await fs.rename(tempPath, filePath); + } catch (error) { + // Clean up temp file if rename failed + try { + await fs.unlink(tempPath); + } catch { + // Ignore cleanup errors + } + throw error; + } +} + +/** + * Get or generate master encryption key using multiple fallback strategies: + * 1. safeStorage (Electron's built-in secure storage) - preferred + * 2. keytar (system keychain) - fallback + * 3. File-based with safeStorage encryption - last resort (with security warning) + * + * Uses a lock to prevent concurrent key generation (data race prevention) + */ +async function getPrimaryMasterKey(): Promise { + const cached = getCachedMasterKey(); + if (cached) { + return cached; + } + + if (keyGenerationInProgress) { + return keyGenerationInProgress; + } + + keyGenerationInProgress = generatePrimaryMasterKey(); + try { + return await keyGenerationInProgress; + } finally { + keyGenerationInProgress = null; + } +} + +async function generatePrimaryMasterKey(): Promise { + const cached = getCachedMasterKey(); + if (cached) { + return cached; + } + + const keyPath = getFallbackKeyPath(); + + if (safeStorage.isEncryptionAvailable()) { + try { + const result = await getOrCreateSafeStorageKey(keyPath); + cacheMasterKey(result.key, 'safeStorage'); + if (result.created) { + logger.info('Security: Generated new master key via safeStorage'); + } else { + logger.info('Security: Loaded master key via safeStorage'); + } + return { key: result.key, source: 'safeStorage' }; + } catch (error) { + logger.warn('Security: safeStorage failed, trying keytar', error); + } + } + + try { + const result = await getOrCreateKeytarKey(); + if (result) { + cacheMasterKey(result.key, 'keytar'); + if (result.created) { + logger.info('Security: Generated new master key via keytar'); + } else { + logger.info('Security: Loaded master key via keytar'); + } + return { key: result.key, source: 'keytar' }; + } + } catch (error) { + logger.warn('Security: keytar failed', error); + } + + logger.warn( + 'Security: WARNING - Using file-based key storage. ' + + 'This is less secure than system keychain. ' + + 'Ensure the app data directory has restricted permissions.', + ); + + try { + const result = await getOrCreateFileFallbackKey(keyPath); + cacheMasterKey(result.key, 'file'); + if (result.created) { + logger.warn('Security: Generating file-based fallback key (less secure)'); + } else { + logger.warn('Security: Using file-based fallback key (less secure)'); + } + return { key: result.key, source: 'file' }; } catch (error) { const hint = buildKeychainAccessHint(error); logger.error('Security: Failed to access keychain/credential manager', error); - // Fallback? If we can't store the key, we can't persistently encrypt. - // For now, throw to prevent data loss (better not to write than to write something we can't decrypt later or write plain text when promised encrypted) const message = hint ? `${KEYCHAIN_ERROR_CODE}|${hint}` : KEYCHAIN_ERROR_CODE; throw new Error(message); } @@ -73,21 +344,122 @@ const IV_LENGTH = 16; */ export async function encrypt(text: string): Promise { try { - const key = await getOrGenerateMasterKey(); - const iv = crypto.randomBytes(IV_LENGTH); - - const cipher = crypto.createCipheriv(ALGORITHM, key, iv); - - let encrypted = cipher.update(text, 'utf8', 'hex'); - encrypted += cipher.final('hex'); - - const authTag = cipher.getAuthTag(); - - // Format: iv:authTag:encrypted - return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`; + const { key } = await getPrimaryMasterKey(); + return encryptWithKey(key, text); } catch (error) { logger.error('Security: Encryption failed', error); - throw error; + throw new Error('Encryption failed'); + } +} + +function encryptWithKey(key: Buffer, text: string): string { + const iv = crypto.randomBytes(IV_LENGTH); + + const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + + let encrypted = cipher.update(text, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + + const authTag = cipher.getAuthTag(); + + return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`; +} + +function decryptWithKey( + key: Buffer, + ivHex: string, + authTagHex: string, + encryptedHex: string, +): string { + const iv = Buffer.from(ivHex, 'hex'); + const authTag = Buffer.from(authTagHex, 'hex'); + + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + + let decrypted = decipher.update(encryptedHex, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + + return decrypted; +} + +function isAuthTagMismatchError(errorMessage: string): boolean { + const normalized = errorMessage.toLowerCase(); + return ( + normalized.includes('unable to authenticate data') || + normalized.includes('auth tag') || + normalized.includes('bad decrypt') + ); +} + +export async function decryptWithMigration( + text: string, +): Promise<{ value: string; reencrypted?: string; usedFallback?: KeySource }> { + if (text.startsWith('{') || text.startsWith('[')) { + return { value: text }; + } + + const parts = text.split(':'); + if (parts.length !== 3) { + return { value: text }; + } + + const [ivHex, authTagHex, encryptedHex] = parts; + + if ( + !/^[a-f0-9]+$/i.test(ivHex) || + !/^[a-f0-9]+$/i.test(authTagHex) || + !/^[a-f0-9]+$/i.test(encryptedHex) + ) { + logger.warn('Security: Invalid encrypted format - not valid hex'); + throw new Error('Invalid encrypted data format'); + } + + const primary = await getPrimaryMasterKey(); + try { + return { value: decryptWithKey(primary.key, ivHex, authTagHex, encryptedHex) }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + + if (errorMessage.includes('Invalid key length') || errorMessage.includes('Invalid IV length')) { + logger.error('Security: Decryption failed - corrupted encrypted data'); + throw new Error('Decryption failed: Corrupted encrypted data'); + } + + if (!isAuthTagMismatchError(errorMessage)) { + logger.error('Security: Decryption failed', error); + throw new Error('Decryption failed'); + } + + const keyPath = getFallbackKeyPath(); + const fallbackKeys = await getFallbackMasterKeys(keyPath, primary.source); + for (const fallback of fallbackKeys) { + try { + const value = decryptWithKey(fallback.key, ivHex, authTagHex, encryptedHex); + let reencrypted: string | undefined; + try { + reencrypted = encryptWithKey(primary.key, value); + } catch (reencryptError) { + logger.warn( + `Security: Failed to re-encrypt data from ${fallback.source} to ${primary.source}`, + reencryptError, + ); + } + if (reencrypted) { + logger.info( + `Security: Re-encrypted data from ${fallback.source} to ${primary.source}`, + ); + } + return { value, reencrypted, usedFallback: fallback.source }; + } catch { + continue; + } + } + + logger.error( + 'Security: Decryption failed - authentication tag mismatch (wrong key or corrupted data)', + ); + throw new Error(`${DATA_MIGRATION_ERROR_CODE}|${DATA_MIGRATION_HINT_RELOGIN}`); } } @@ -96,36 +468,6 @@ export async function encrypt(text: string): Promise { * Input format: "iv_hex:auth_tag_hex:ciphertext_hex" */ export async function decrypt(text: string): Promise { - // Check if it's plain text (JSON) for backward compatibility - // Very rough check: starts with { or [ - if (text.startsWith('{') || text.startsWith('[')) { - return text; - } - - // Also checking if it follows our pattern - const parts = text.split(':'); - if (parts.length !== 3) { - // Treat as plain text if it doesn't look like our encrypted format - return text; - } - - try { - const key = await getOrGenerateMasterKey(); - - const [ivHex, authTagHex, encryptedHex] = parts; - const iv = Buffer.from(ivHex, 'hex'); - const authTag = Buffer.from(authTagHex, 'hex'); - - const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); - decipher.setAuthTag(authTag); - - let decrypted = decipher.update(encryptedHex, 'hex', 'utf8'); - decrypted += decipher.final('utf8'); - - return decrypted; - } catch (error) { - logger.error('Security: Decryption failed', error); - // If decryption fails, it might be corrupted or using a different key. - throw error; - } + const result = await decryptWithMigration(text); + return result.value; }