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

Summary
  - Replace hard keytar dependency with a 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 asar load failures
  - Refactor cloudHandler to use const (clean code)
  - Fix “Failed to load cloud accounts” in packaged app

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

  Additional changes
  - Replace sync file ops with async (fs/promises)
  - Add atomic file writes to prevent data races
  - Implement concurrency lock for key generation
  - Add security error codes for better diagnostics
  - Add warnings for file-based fallback mode
  - Align error handling after merge
  - Migrate legacy encrypted account data

  References
  - Issue: #38
  - Commit: 92dc2f6
This commit is contained in:
vin authored and GitHub committed 2026-01-27 03:05:41 +08:00
1 parent d5ee08d5a0
commit 02080588b7
11 files changed
+747 -92

No files matched your search

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