fix(security): add safeStorage fallback for production builds (#38)

* fix(security): add safeStorage fallback for production builds

- Replace hard keytar dependency with multi-strategy key storage:
  1. safeStorage (Electron built-in) - preferred
  2. keytar (system keychain) - fallback
  3. File-based encryption - last resort
- Dynamically import keytar to avoid hard failure in production
- Refactor cloudHandler to use const instead of let (Clean Code)
- Fix 'Failed to load cloud accounts' error in packaged app

The keytar native module fails to load from asar archives in
production builds. This fix ensures the app works even when
system keychain access is unavailable.

* refactor(security): address code review feedback

- Replace sync file ops with async (fs/promises)
- Add atomic file writes to prevent data races
- Implement concurrency lock for key generation
- Add SecurityError class with specific error codes:
  - KEY_STORAGE_FAILED, ENCRYPTION_FAILED, DECRYPTION_FAILED
  - INVALID_FORMAT, AUTH_TAG_MISMATCH, CORRUPTED_DATA
- Enhance error categorization in decrypt() for better diagnostics
- Add security warnings for file-based fallback mode

* fix: align error handling after merge

* feat: migrate legacy encrypted account data

* chore: enable beta prerelease workflow *skip release*

---------

Co-authored-by: Draculabo <[email protected]>
This commit is contained in:
Erdogan AbacıandDraculabo authored and Draculabo committed 2026-01-26 01:53:25 +08:00
1 parent e844aa9545
commit 92dc2f6f21
8 files changed
+738 -89

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ on:
push:
branches:
- main
- master
- beta
permissions:
contents: write
+1 -1
View File
@@ -194,7 +194,7 @@ const appendReleaseNotesPlugin = {
};
const releaseConfig = {
branches: ['main', 'master'],
branches: ['main', { name: 'beta', prerelease: true }],
plugins: [
[
'@semantic-release/commit-analyzer',
+8 -2
View File
@@ -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 <div className="p-4 text-red-500">Failed to load cloud accounts.</div>;
const errorMessage = getLocalizedErrorMessage(error, t);
return (
<div className="p-4 text-red-500">
<div>{t('cloud.error.loadFailed')}</div>
<div className="mt-1 text-sm text-red-400">{errorMessage}</div>
</div>
);
}
return (
+149 -25
View File
@@ -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<KeySource, number>;
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<ReturnType<typeof decryptAndMigrateField>>;
export class CloudAccountRepo {
static async init(): Promise<void> {
const dbPath = getCloudAccountsDbPath();
@@ -171,6 +230,7 @@ export class CloudAccountRepo {
static async getAccounts(): Promise<CloudAccount[]> {
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(', ')}`;
+16
View File
@@ -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: '同步成功',
+122
View File
@@ -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');
});
});
+39
View File
@@ -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<string, string> = {
[KEYCHAIN_HINT_TRANSLOCATION]: 'error.keychainHint.translocation',
@@ -11,6 +14,11 @@ const KEYCHAIN_HINT_I18N_MAP: Record<string, string> = {
[KEYCHAIN_HINT_SIGN_NOTARIZE]: 'error.keychainHint.signNotarize',
};
const DATA_MIGRATION_HINT_I18N_MAP: Record<string, string> = {
[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);
}
+402 -60
View File
@@ -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<Buffer> {
if (cachedMasterKey) return cachedMasterKey;
// Lock to prevent concurrent key generation
let keyGenerationInProgress: Promise<MasterKeyState> | 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<typeof import('keytar') | null> {
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<Buffer | null> {
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<Buffer | null> {
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<Buffer | null> {
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<MasterKeyState[]> {
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<void> {
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<MasterKeyState> {
const cached = getCachedMasterKey();
if (cached) {
return cached;
}
if (keyGenerationInProgress) {
return keyGenerationInProgress;
}
keyGenerationInProgress = generatePrimaryMasterKey();
try {
return await keyGenerationInProgress;
} finally {
keyGenerationInProgress = null;
}
}
async function generatePrimaryMasterKey(): Promise<MasterKeyState> {
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<string> {
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<string> {
* Input format: "iv_hex:auth_tag_hex:ciphertext_hex"
*/
export async function decrypt(text: string): Promise<string> {
// 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;
}