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;
}