diff --git a/src/app/imex/sync/encryption-enable.service.ts b/src/app/imex/sync/encryption-enable.service.ts index 7a624c544f..6d555be3be 100644 --- a/src/app/imex/sync/encryption-enable.service.ts +++ b/src/app/imex/sync/encryption-enable.service.ts @@ -4,6 +4,8 @@ import { SyncLog } from '../../core/log'; import { SnapshotUploadService } from './snapshot-upload.service'; import { OperationEncryptionService } from '../../op-log/sync/operation-encryption.service'; import { WrappedProviderService } from '../../op-log/sync-providers/wrapped-provider.service'; +import { isCryptoSubtleAvailable } from '../../op-log/encryption/encryption'; +import { WebCryptoNotAvailableError } from '../../op-log/core/errors/sync-errors'; const LOG_PREFIX = 'EncryptionEnableService'; @@ -38,6 +40,16 @@ export class EncryptionEnableService { throw new Error('Encryption key is required'); } + // CRITICAL: Check crypto availability BEFORE deleting server data + // to prevent data loss if encryption will fail + if (!isCryptoSubtleAvailable()) { + throw new WebCryptoNotAvailableError( + 'Cannot enable encryption: WebCrypto API is not available. ' + + 'Encryption requires a secure context (HTTPS). ' + + 'On Android, encryption is not supported.', + ); + } + // Gather all data needed for upload (validates provider) const { syncProvider, existingCfg, state, vectorClock, clientId } = await this._snapshotUploadService.gatherSnapshotData(LOG_PREFIX); diff --git a/src/app/imex/sync/import-encryption-handler.service.ts b/src/app/imex/sync/import-encryption-handler.service.ts index 4a8d1fb7ac..223e836eb2 100644 --- a/src/app/imex/sync/import-encryption-handler.service.ts +++ b/src/app/imex/sync/import-encryption-handler.service.ts @@ -6,6 +6,7 @@ import { SyncLog } from '../../core/log'; import { AppDataComplete } from '../../op-log/model/model-config'; import { OperationEncryptionService } from '../../op-log/sync/operation-encryption.service'; import { SnapshotUploadService } from './snapshot-upload.service'; +import { isCryptoSubtleAvailable } from '../../op-log/encryption/encryption'; export interface EncryptionStateChangeResult { encryptionStateChanged: boolean; @@ -132,6 +133,20 @@ export class ImportEncryptionHandlerService { const { syncProvider, existingCfg, state, vectorClock, clientId } = snapshotData; + // CRITICAL: Check crypto availability BEFORE deleting server data + // to prevent data loss if encryption will fail (only needed when enabling encryption) + if (isEncryptionEnabled && !isCryptoSubtleAvailable()) { + return { + encryptionStateChanged: false, + serverDataDeleted: false, + snapshotUploaded: false, + error: + 'Cannot enable encryption: WebCrypto API is not available. ' + + 'Encryption requires a secure context (HTTPS). ' + + 'On Android, encryption is not supported.', + }; + } + try { // 1. Delete all server data (encrypted ops can't mix with unencrypted) SyncLog.normal(`${LOG_PREFIX}: Deleting server data...`); diff --git a/src/app/imex/sync/sync-wrapper.service.ts b/src/app/imex/sync/sync-wrapper.service.ts index a40c55757c..a1ccff2360 100644 --- a/src/app/imex/sync/sync-wrapper.service.ts +++ b/src/app/imex/sync/sync-wrapper.service.ts @@ -15,6 +15,7 @@ import { toObservable } from '@angular/core/rxjs-interop'; import { SyncAlreadyInProgressError, LocalDataConflictError, + WebCryptoNotAvailableError, } from '../../op-log/core/errors/sync-errors'; import { SyncConfig } from '../../features/config/global-config.model'; import { TranslateService } from '@ngx-translate/core'; @@ -407,6 +408,16 @@ export class SyncWrapperService { // File-based sync: Local data exists and remote snapshot would overwrite it // Show conflict dialog to let user choose between local and remote data return this._handleLocalDataConflict(error); + } else if (error instanceof WebCryptoNotAvailableError) { + // WebCrypto (crypto.subtle) is unavailable in insecure contexts + // (e.g., Android Capacitor serves from http://localhost) + this._providerManager.setSyncStatus('ERROR'); + this._snackService.open({ + msg: T.F.SYNC.S.WEB_CRYPTO_NOT_AVAILABLE, + type: 'ERROR', + config: { duration: 15000 }, + }); + return 'HANDLED_ERROR'; } else if (this._isTimeoutError(error)) { this._snackService.open({ msg: T.F.SYNC.S.TIMEOUT_ERROR, diff --git a/src/app/op-log/encryption/encryption.ts b/src/app/op-log/encryption/encryption.ts index 3845dbec92..06dd90f6a7 100644 --- a/src/app/op-log/encryption/encryption.ts +++ b/src/app/op-log/encryption/encryption.ts @@ -1,10 +1,50 @@ import { argon2id } from 'hash-wasm'; +import { WebCryptoNotAvailableError } from '../core/errors/sync-errors'; const ALGORITHM = 'AES-GCM' as const; const SALT_LENGTH = 16; const IV_LENGTH = 12; const KEY_LENGTH = 32; +// ============================================================================ +// WEBCRYPTO AVAILABILITY CHECK +// ============================================================================ +// WebCrypto (crypto.subtle) is unavailable in insecure contexts: +// - Android Capacitor: serves from http://localhost (not https) +// - iOS Capacitor: capacitor:// scheme may not be recognized as secure +// +// When WebCrypto is unavailable, encryption operations will fail. +// Call isCryptoSubtleAvailable() before attempting encryption to show +// a clear error message to the user. +// ============================================================================ + +/** + * Checks if WebCrypto API (crypto.subtle) is available in the current context. + * Returns false in insecure contexts (http://, some custom schemes like Android Capacitor). + */ +export const isCryptoSubtleAvailable = (): boolean => { + return ( + typeof window !== 'undefined' && + typeof window.crypto !== 'undefined' && + typeof window.crypto.subtle !== 'undefined' + ); +}; + +/** + * Throws WebCryptoNotAvailableError if crypto.subtle is not available. + * Call this at the start of encryption/decryption functions to fail fast + * with a clear error message. + */ +const assertCryptoSubtleAvailable = (): void => { + if (!isCryptoSubtleAvailable()) { + throw new WebCryptoNotAvailableError( + 'WebCrypto API (crypto.subtle) is not available. ' + + 'Encryption requires a secure context (HTTPS). ' + + 'On Android, encryption is not supported.', + ); + } +}; + /** * Holds a derived CryptoKey along with its salt for reuse across operations. * Used to avoid expensive Argon2id key derivation for each operation. @@ -209,6 +249,7 @@ const decryptArgon = async (data: string, password: string): Promise => }; export const encrypt = async (data: string, password: string): Promise => { + assertCryptoSubtleAvailable(); const enc = new TextEncoder(); const dataBuffer = enc.encode(data); const salt = window.crypto.getRandomValues(new Uint8Array(SALT_LENGTH)); @@ -229,6 +270,7 @@ export const encrypt = async (data: string, password: string): Promise = }; export const decrypt = async (data: string, password: string): Promise => { + assertCryptoSubtleAvailable(); try { return await decryptArgon(data, password); } catch (e) { @@ -296,6 +338,7 @@ export const deriveKeyFromPassword = async ( password: string, salt?: Uint8Array, ): Promise => { + assertCryptoSubtleAvailable(); const actualSalt = salt ?? window.crypto.getRandomValues(new Uint8Array(SALT_LENGTH)); const key = await _deriveKeyArgon(password, actualSalt); return { key, salt: actualSalt }; diff --git a/src/app/t.const.ts b/src/app/t.const.ts index 8b63b6e3df..0bbd996aad 100644 --- a/src/app/t.const.ts +++ b/src/app/t.const.ts @@ -1169,6 +1169,7 @@ const T = { ENCRYPTION_PASSWORD_REQUIRED: 'F.SYNC.S.ENCRYPTION_PASSWORD_REQUIRED', ENCRYPTION_DISABLED_ON_OTHER_DEVICE: 'F.SYNC.S.ENCRYPTION_DISABLED_ON_OTHER_DEVICE', + WEB_CRYPTO_NOT_AVAILABLE: 'F.SYNC.S.WEB_CRYPTO_NOT_AVAILABLE', PERSIST_FAILED: 'F.SYNC.S.PERSIST_FAILED', DEFERRED_ACTION_FAILED: 'F.SYNC.S.DEFERRED_ACTION_FAILED', REMOTE_DATA_TOO_OLD: 'F.SYNC.S.REMOTE_DATA_TOO_OLD', diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index af49945e2e..d09b667083 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1127,6 +1127,7 @@ "DECRYPTION_FAILED": "Failed to decrypt synced data. Please check your encryption password.", "ENCRYPTION_PASSWORD_REQUIRED": "Encrypted data received but no encryption password is configured. Please set your encryption password in sync settings.", "ENCRYPTION_DISABLED_ON_OTHER_DEVICE": "Encryption was disabled on another device. Your sync settings have been updated.", + "WEB_CRYPTO_NOT_AVAILABLE": "Encryption is not available on this device. On Android, encryption requires a secure context (HTTPS) which is not available. Please disable encryption in sync settings or sync from a desktop browser.", "PERSIST_FAILED": "Failed to save changes. State may be inconsistent - please reload.", "DEFERRED_ACTION_FAILED": "Some changes made during sync could not be saved. Please reload to recover.", "REMOTE_DATA_TOO_OLD": "Remote data is too old to be processed. Please update your remote app.",