fix(sync): show clear error when encryption unavailable on Android

WebCrypto (crypto.subtle) is unavailable in insecure contexts like
Android Capacitor (serves from http://localhost). Previously, encryption
operations would fail silently, making sync appear successful when data
wasn't actually syncing.

Changes:
- Add isCryptoSubtleAvailable() check function in encryption.ts
- Add assertCryptoSubtleAvailable() guard to encrypt/decrypt functions
- Add WebCryptoNotAvailableError handler in sync-wrapper.service.ts
- Check crypto availability BEFORE deleting server data in
  encryption-enable.service.ts and import-encryption-handler.service.ts
- Add WEB_CRYPTO_NOT_AVAILABLE translation key with clear error message

This ensures users see a clear error message instead of silent failure
when trying to use encryption on unsupported platforms.
This commit is contained in:
Johannes Millan 2026-01-27 13:45:46 +01:00
parent ab6df00a72
commit 5f44e18aa2
6 changed files with 83 additions and 0 deletions

View file

@ -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);

View file

@ -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...`);

View file

@ -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,

View file

@ -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<string> =>
};
export const encrypt = async (data: string, password: string): Promise<string> => {
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<string> =
};
export const decrypt = async (data: string, password: string): Promise<string> => {
assertCryptoSubtleAvailable();
try {
return await decryptArgon(data, password);
} catch (e) {
@ -296,6 +338,7 @@ export const deriveKeyFromPassword = async (
password: string,
salt?: Uint8Array,
): Promise<DerivedKeyInfo> => {
assertCryptoSubtleAvailable();
const actualSalt = salt ?? window.crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
const key = await _deriveKeyArgon(password, actualSalt);
return { key, salt: actualSalt };

View file

@ -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',

View file

@ -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.",