diff --git a/packages/sync-core/src/encryption.ts b/packages/sync-core/src/encryption.ts index 9782623fb6..a54a111410 100644 --- a/packages/sync-core/src/encryption.ts +++ b/packages/sync-core/src/encryption.ts @@ -1,469 +1,184 @@ -import { argon2id } from 'hash-wasm'; -import { gcm } from '@noble/ciphers/aes.js'; -import { WebCryptoNotAvailableError } from './web-crypto-error'; - -const ALGORITHM = 'AES-GCM' as const; -const SALT_LENGTH = 16; -const IV_LENGTH = 12; -const KEY_LENGTH = 32; - -const DEFAULT_ARGON2_PARAMS = { - parallelism: 1, - iterations: 3, - memorySize: 65536, // 64 MB - memorySize is in KiB -}; - -let _argon2Params = { ...DEFAULT_ARGON2_PARAMS }; - -interface EncryptionProcessGlobal { - process?: { - env?: { - NODE_ENV?: string; - }; - }; -} - -interface EncryptionGlobals extends EncryptionProcessGlobal { - crypto?: Crypto; - atob?: (input: string) => string; - btoa?: (input: string) => string; -} - -const globals = (): EncryptionGlobals => globalThis as unknown as EncryptionGlobals; - -const getRequiredCrypto = (): Crypto => { - const cryptoApi = globals().crypto; - if (cryptoApi === undefined) { - throw new WebCryptoNotAvailableError('Crypto API is not available'); - } - return cryptoApi; -}; - -const getRequiredSubtle = (): SubtleCrypto => { - const subtle = getRequiredCrypto().subtle; - if (subtle === undefined) { - throw new WebCryptoNotAvailableError(); - } - return subtle; -}; - -const getRequiredAtob = (): ((input: string) => string) => { - const atob = globals().atob; - if (atob === undefined) { - throw new Error('atob is not available in this runtime'); - } - return atob; -}; - -const getRequiredBtoa = (): ((input: string) => string) => { - const btoa = globals().btoa; - if (btoa === undefined) { - throw new Error('btoa is not available in this runtime'); - } - return btoa; -}; - /** - * Returns the current Argon2 parameters. - * Tests can override these via `setArgon2ParamsForTesting()`. - */ -export const getArgon2Params = (): typeof DEFAULT_ARGON2_PARAMS => _argon2Params; - -/** - * Override Argon2 parameters for testing (use weak params to speed up tests). - * Pass `undefined` to restore defaults. - */ -export const setArgon2ParamsForTesting = ( - params?: Partial, -): void => { - if (globals().process?.env?.NODE_ENV === 'production') { - throw new Error('setArgon2ParamsForTesting must not be called in production'); - } - _argon2Params = params - ? { ...DEFAULT_ARGON2_PARAMS, ...params } - : { ...DEFAULT_ARGON2_PARAMS }; -}; - -// ============================================================================ -// 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, we fall back to @noble/ciphers for AES-GCM. -// ============================================================================ - -/** - * 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 globals().crypto?.subtle !== undefined; -}; - -// ============================================================================ -// CRYPTO STRATEGY PATTERN -// ============================================================================ -// Abstracts the difference between WebCrypto and @noble/ciphers implementations. -// This reduces code duplication and makes the codebase easier to maintain. - -/** - * Discriminated union for derived key info. - * Type-safe: exactly one of the key types is present based on the 'type' discriminator. - */ -export type DerivedKeyInfo = - | { type: 'webcrypto'; key: CryptoKey; salt: Uint8Array } - | { type: 'fallback'; keyBytes: Uint8Array; salt: Uint8Array }; - -/** - * Strategy interface for cryptographic operations. - * Implemented by WebCrypto and @noble/ciphers backends. - */ -export interface CryptoStrategy { - encrypt(key: DerivedKeyInfo, iv: Uint8Array, data: Uint8Array): Promise; - decrypt(key: DerivedKeyInfo, iv: Uint8Array, data: Uint8Array): Promise; - deriveKey(password: string, salt: Uint8Array): Promise; -} - -/** - * Derives raw key bytes using Argon2id. - * Used by both WebCrypto and @noble/ciphers strategies. - */ -const deriveKeyBytesArgon = async ( - password: string, - salt: Uint8Array, -): Promise => { - const params = getArgon2Params(); - return await argon2id({ - password, - salt, - hashLength: KEY_LENGTH, - parallelism: params.parallelism, - iterations: params.iterations, - memorySize: params.memorySize, - outputType: 'binary', - }); -}; - -/** - * WebCrypto strategy implementation. - * Uses native browser crypto APIs for best performance. - */ -const webCryptoStrategy: CryptoStrategy = { - encrypt: async (keyInfo, iv, data) => { - if (keyInfo.type !== 'webcrypto') { - throw new Error('WebCrypto strategy requires webcrypto key type'); - } - const encrypted = await getRequiredSubtle().encrypt( - { name: ALGORITHM, iv: iv as Uint8Array }, - keyInfo.key, - data as Uint8Array, - ); - return new Uint8Array(encrypted); - }, - - decrypt: async (keyInfo, iv, data) => { - if (keyInfo.type !== 'webcrypto') { - throw new Error('WebCrypto strategy requires webcrypto key type'); - } - const decrypted = await getRequiredSubtle().decrypt( - { name: ALGORITHM, iv: iv as Uint8Array }, - keyInfo.key, - data as Uint8Array, - ); - return new Uint8Array(decrypted); - }, - - deriveKey: async (password, salt) => { - const derivedBytes = await deriveKeyBytesArgon(password, salt); - const key = await getRequiredSubtle().importKey( - 'raw', - derivedBytes.buffer as ArrayBuffer, - { name: ALGORITHM }, - false, - ['encrypt', 'decrypt'], - ); - return { type: 'webcrypto', key, salt }; - }, -}; - -/** - * @noble/ciphers fallback strategy implementation. - * Used when WebCrypto is unavailable (Android/iOS Capacitor). + * Encryption facade — Argon2id KDF + AES-GCM, WebCrypto with @noble fallback. * - * PERFORMANCE NOTE: For better mobile performance (~3-4x faster), consider - * implementing a native Capacitor plugin that uses platform crypto APIs - * (Android: javax.crypto.Cipher, iOS: CryptoKit). - * Current @noble/ciphers implementation is ~80ms for 500KB vs ~25ms native. + * Layout: + * encryption/web-crypto.ts — crypto.subtle + @noble glue, base64, constants + * encryption/argon2.ts — Argon2id params, deriveKeyFromPassword, DerivedKey + * encryption/legacy.ts — backward-compat PBKDF2 decryption + warning handler + * encryption/session-cache.ts — session-level key caches + * encryption.ts (this file) — public API: encrypt/decrypt/encryptBatch/decryptBatch + * + * ## Wire format (public contract — do not change without a version-byte migration) + * + * Argon2id ciphertext : [SALT (16)][IV (12)][AES-GCM ciphertext + auth tag] + * Legacy PBKDF2 : [IV (12)][AES-GCM ciphertext + auth tag] + * + * Ciphertext is base64-encoded for transport. `detectFormat()` discriminates + * by length: < 28 bytes is invalid, < 44 bytes is unambiguously legacy, + * >= 44 bytes is treated as Argon2id with a legacy fallback on auth failure. + * + * ## Legacy-decrypt diagnostics + * + * Two complementary mechanisms surface legacy ciphertext to callers; both are + * supported intentionally because they serve different consumers: + * + * 1. **Structural** — `decryptWithMigration()` returns `{ wasLegacyKdf, + * migratedCiphertext }`. Callers that want to persist the re-encrypted + * Argon2id ciphertext (the long-term migration goal) use this entry point. + * 2. **Side-channel** — `setLegacyKdfWarningHandler(h)` registers a host + * callback fired on every successful legacy decrypt, regardless of which + * entry point was used. Callers that just want to surface a deprecation + * UI / log line (e.g. existing code paths that go through `decrypt()` + * without threading a result type) use this. */ -const fallbackStrategy: CryptoStrategy = { - encrypt: async (keyInfo, iv, data) => { - if (keyInfo.type !== 'fallback') { - throw new Error('Fallback strategy requires fallback key type'); - } - const aes = gcm(keyInfo.keyBytes, iv); - return aes.encrypt(data); - }, - decrypt: async (keyInfo, iv, data) => { - if (keyInfo.type !== 'fallback') { - throw new Error('Fallback strategy requires fallback key type'); - } - const aes = gcm(keyInfo.keyBytes, iv); - return aes.decrypt(data); - }, +import { + IV_LENGTH, + SALT_LENGTH, + TEXT_ENCODER, + TEXT_DECODER, + aesDecrypt, + aesEncrypt, + decodeBase64, + detectFormat, + encodeBase64, + getRandomBytes, + hashPasswordForCache, +} from './encryption/web-crypto'; +import { type DerivedKey, deriveKeyFromPassword } from './encryption/argon2'; +import { + getDecryptCache, + getOrDeriveEncryptKey, + hasDecryptCache, + setDecryptCache, +} from './encryption/session-cache'; +import { decryptLegacy } from './encryption/legacy'; - deriveKey: async (password, salt) => { - const keyBytes = await deriveKeyBytesArgon(password, salt); - return { type: 'fallback', keyBytes, salt }; - }, -}; - -/** - * Returns the appropriate crypto strategy based on environment. - * Exported for testing purposes. - */ -export const getCryptoStrategy = (): CryptoStrategy => { - return isCryptoSubtleAvailable() ? webCryptoStrategy : fallbackStrategy; -}; - -// ============================================================================ -// SESSION-LEVEL KEY CACHING -// ============================================================================ -// This cache persists for the entire app session (until close/refresh). -// PERFORMANCE: Reduces mobile sync time from minutes to seconds by avoiding -// repeated Argon2id derivations (each takes 500ms-2000ms on mobile). -// -// Cache structure: -// - For encryption: keyed by password hash (reuses key with its salt) -// - For decryption: keyed by password hash + salt (because each ciphertext may have different salt) -// -// SECURITY: Keys are only stored in memory, cleared on app restart. -// Call clearSessionKeyCache() when user changes their encryption password. - -interface SessionCacheEntry { - keyInfo: DerivedKeyInfo; - passwordHash: string; -} - -// Session cache: password hash -> encryption key (for new encryptions with random salt) -let sessionEncryptKeyCache: SessionCacheEntry | null = null; - -// Session cache: "passwordHash:saltBase64" -> decryption key -const sessionDecryptKeyCache = new Map(); - -// Maximum entries in decrypt cache to prevent memory bloat -const SESSION_DECRYPT_CACHE_MAX_SIZE = 100; - -/** - * Simple hash of password for cache key comparison. - * NOT for security - just for cache invalidation when password changes. - */ -const hashPasswordForCache = (password: string): string => { - // Use a simple djb2 hash for speed (no crypto needed for cache key) - let hash = 5381; - for (let i = 0; i < password.length; i++) { - hash = (hash * 33) ^ password.charCodeAt(i); - } - return (hash >>> 0).toString(16); -}; - -/** - * Clears the session key cache. Call this when: - * - User changes their encryption password - * - User logs out or disables encryption - * - For security-sensitive operations - */ -export const clearSessionKeyCache = (): void => { - sessionEncryptKeyCache = null; - sessionDecryptKeyCache.clear(); -}; - -/** - * Gets statistics about the session key cache (for debugging/monitoring). - */ -export const getSessionKeyCacheStats = (): { - hasEncryptKey: boolean; - decryptKeyCount: number; -} => ({ - hasEncryptKey: sessionEncryptKeyCache !== null, - decryptKeyCount: sessionDecryptKeyCache.size, -}); - -// ============================================================================ -// UTILITY FUNCTIONS -// ============================================================================ - -export const base642ab = (base64: string): ArrayBuffer => { - const binary_string = getRequiredAtob()(base64); - const len = binary_string.length; - const bytes = new Uint8Array(len); - for (let i = 0; i < len; i++) { - bytes[i] = binary_string.charCodeAt(i); - } - return bytes.buffer; -}; - -export const ab2base64 = (buffer: ArrayBuffer): string => { - const binary = Array.prototype.map - .call(new Uint8Array(buffer), (byte: number) => String.fromCharCode(byte)) - .join(''); - return getRequiredBtoa()(binary); -}; - -/** - * Generates cryptographically secure random bytes. - * Uses crypto.getRandomValues which is available even without crypto.subtle. - */ -const getRandomBytes = (length: number): Uint8Array => { - return getRequiredCrypto().getRandomValues(new Uint8Array(length)); -}; - -// Minimum sizes for format detection -// Argon2: [SALT (16)][IV (12)][CIPHERTEXT + AUTH_TAG (min 16)] = 44 bytes -// Legacy: [IV (12)][CIPHERTEXT + AUTH_TAG (min 16)] = 28 bytes -const MIN_ARGON2_SIZE = SALT_LENGTH + IV_LENGTH + 16; -const MIN_LEGACY_SIZE = IV_LENGTH + 16; - -/** - * Detects the likely encryption format based on data length. - * Returns 'argon2' if data is large enough for Argon2 format, - * 'legacy' if it's only large enough for legacy format, - * or 'invalid' if too short for either. - */ -const detectFormat = (dataBuffer: ArrayBuffer): 'argon2' | 'legacy' | 'invalid' => { - if (dataBuffer.byteLength >= MIN_ARGON2_SIZE) { - return 'argon2'; - } else if (dataBuffer.byteLength >= MIN_LEGACY_SIZE) { - return 'legacy'; - } - return 'invalid'; -}; - -// ============================================================================ -// LEGACY FUNCTIONS (PBKDF2) -// ============================================================================ -// PBKDF2 functions are only kept for backward compatibility. -// SECURITY NOTE: PBKDF2 with password-as-salt is cryptographically weak. -// Use decryptWithMigration() to automatically re-encrypt legacy data with Argon2id. - -const _generateKey = async (password: string): Promise => { - const enc = new TextEncoder(); - const passwordBuffer = enc.encode(password); - const ops = { - name: 'PBKDF2', - // Using password as salt is insecure but kept for backward compatibility. - // New data uses Argon2id with random salt via encrypt(). - salt: enc.encode(password), - iterations: 1000, - hash: 'SHA-256', - }; - const key = await getRequiredSubtle().importKey( - 'raw', - passwordBuffer, - { name: 'PBKDF2' }, - false, - ['deriveBits', 'deriveKey'], - ); - return getRequiredSubtle().deriveKey(ops, key, { name: ALGORITHM, length: 256 }, true, [ - 'encrypt', - 'decrypt', - ]); -}; - -export const generateKey = async (password: string): Promise => { - const cryptoKey = await _generateKey(password); - const exportKey = await getRequiredSubtle().exportKey('raw', cryptoKey); - return ab2base64(exportKey); -}; - -// eslint-disable-next-line prefer-arrow/prefer-arrow-functions -async function decryptLegacy(data: string, password: string): Promise { - // Legacy PBKDF2 decryption requires WebCrypto - no fallback available. - // Users with legacy data on mobile must first sync from desktop to migrate. - if (!isCryptoSubtleAvailable()) { - throw new WebCryptoNotAvailableError( - 'Cannot decrypt legacy data on this device. ' + - 'Your encrypted data uses an older format that requires WebCrypto. ' + - 'Please sync from a desktop browser first to migrate your data to the newer format.', - ); - } - - const dataBuffer = base642ab(data); - const iv = new Uint8Array(dataBuffer, 0, IV_LENGTH); - const encryptedData = new Uint8Array(dataBuffer, IV_LENGTH); - const key = await _generateKey(password); - const decryptedContent = await getRequiredSubtle().decrypt( - { name: ALGORITHM, iv: iv }, - key, - encryptedData, - ); - - const dec = new TextDecoder(); - return dec.decode(decryptedContent); -} +// Re-export the test-and-host-facing pieces from submodules. +export { + setArgon2ParamsForTesting, + getArgon2Params, + deriveKeyFromPassword, +} from './encryption/argon2'; +export type { DerivedKey } from './encryption/argon2'; +export { + clearSessionKeyCache, + getSessionKeyCacheStats, +} from './encryption/session-cache'; +export { isCryptoSubtleAvailable } from './encryption/web-crypto'; +export { setLegacyKdfWarningHandler } from './encryption/legacy'; // ============================================================================ // MAIN ENCRYPTION/DECRYPTION FUNCTIONS // ============================================================================ -const decryptArgon = async (data: string, password: string): Promise => { - const strategy = getCryptoStrategy(); - const dataBuffer = base642ab(data); - const salt = new Uint8Array(dataBuffer, 0, SALT_LENGTH); - const iv = new Uint8Array(dataBuffer, SALT_LENGTH, IV_LENGTH); - const encryptedData = new Uint8Array(dataBuffer, SALT_LENGTH + IV_LENGTH); - - const keyInfo = await strategy.deriveKey(password, salt); - const decryptedContent = await strategy.decrypt(keyInfo, iv, encryptedData); - - const dec = new TextDecoder(); - return dec.decode(decryptedContent); -}; - -export const encrypt = async (data: string, password: string): Promise => { - const strategy = getCryptoStrategy(); - const enc = new TextEncoder(); - const dataBuffer = enc.encode(data); - const salt = getRandomBytes(SALT_LENGTH); +/** + * Encrypts data using a pre-derived key. Much faster than encrypt() when + * encrypting multiple items since Argon2id key derivation is skipped. + * + * @returns Base64-encoded ciphertext with embedded salt and IV + * (format: `[SALT (16)][IV (12)][AES-GCM ciphertext + auth tag]`) + */ +export const encryptWithDerivedKey = async ( + data: string, + key: DerivedKey, +): Promise => { + const dataBuffer = TEXT_ENCODER.encode(data); const iv = getRandomBytes(IV_LENGTH); - - const keyInfo = await strategy.deriveKey(password, salt); - const encryptedContent = await strategy.encrypt(keyInfo, iv, dataBuffer); + const encryptedContent = await aesEncrypt(key.keyBytes, iv, dataBuffer); const buffer = new Uint8Array(SALT_LENGTH + IV_LENGTH + encryptedContent.byteLength); - buffer.set(salt, 0); + buffer.set(key.salt, 0); buffer.set(iv, SALT_LENGTH); buffer.set(encryptedContent, SALT_LENGTH + IV_LENGTH); - return ab2base64(buffer.buffer); -}; - -export const decrypt = async (data: string, password: string): Promise => { - try { - return await decryptArgon(data, password); - } catch (e) { - // Fallback to legacy decryption (pre-Argon2 format) - // NOTE: Legacy PBKDF2 decryption requires WebCrypto. If WebCrypto is unavailable - // and this is legacy data, the user will get a clear error. - return await decryptLegacy(data, password); - } + return encodeBase64(buffer); }; /** - * Result of decryption with migration information. - * When wasLegacy is true, migratedCiphertext contains the data - * re-encrypted with Argon2id for improved security. + * Decrypts data using a pre-derived key. + * + * @param data Base64-encoded ciphertext (or an already-decoded buffer to skip + * re-decoding) + * @param key Pre-derived key whose salt matches the ciphertext + */ +export const decryptWithDerivedKey = async ( + data: string | ArrayBuffer, + key: DerivedKey, +): Promise => { + const dataBuffer = typeof data === 'string' ? decodeBase64(data) : data; + // Skip salt (first 16 bytes) since we already have the key + const iv = new Uint8Array(dataBuffer, SALT_LENGTH, IV_LENGTH); + const encryptedData = new Uint8Array(dataBuffer, SALT_LENGTH + IV_LENGTH); + + const decryptedContent = await aesDecrypt(key.keyBytes, iv, encryptedData); + return TEXT_DECODER.decode(decryptedContent); +}; + +export const encrypt = async (data: string, password: string): Promise => { + const key = await getOrDeriveEncryptKey(password); + return encryptWithDerivedKey(data, key); +}; + +/** Decrypts an Argon2id-format ciphertext given an already-decoded buffer. */ +const decryptArgonFromBuffer = async ( + buffer: ArrayBuffer, + password: string, +): Promise => { + const salt = new Uint8Array(buffer, 0, SALT_LENGTH); + const passwordHash = hashPasswordForCache(password); + const saltBase64 = encodeBase64(salt); + const cacheKey = `${passwordHash}:${saltBase64}`; + + let key = getDecryptCache(cacheKey); + if (!key) { + key = await deriveKeyFromPassword(password, salt); + setDecryptCache(cacheKey, key); + } + + return decryptWithDerivedKey(buffer, key); +}; + +export const decrypt = async (data: string, password: string): Promise => { + const buffer = decodeBase64(data); + const format = detectFormat(buffer); + + if (format === 'invalid') { + throw new Error('Encrypted data is too short to be valid'); + } + if (format === 'legacy') { + return decryptLegacy(data, password); + } + + try { + return await decryptArgonFromBuffer(buffer, password); + } catch { + // Argon2 failed — fall back to legacy in case the data is a long legacy + // ciphertext that happens to be >= 44 bytes (the length heuristic can't + // disambiguate). Mobile clients without WebCrypto get a clear error. + return decryptLegacy(data, password); + } +}; + +// ============================================================================ +// MIGRATION-AWARE DECRYPT +// ============================================================================ + +/** + * Result of {@link decryptWithMigration}. + * + * When `wasLegacy` is true, `migratedCiphertext` contains the plaintext + * re-encrypted with Argon2id — callers should persist it to complete the + * migration off the weaker PBKDF2 KDF. */ export interface DecryptResult { - /** The decrypted plaintext data */ + /** Decrypted plaintext. */ plaintext: string; - /** Re-encrypted data using Argon2id. Only set if wasLegacy is true. */ + /** Re-encrypted ciphertext using Argon2id. Only set when `wasLegacy` is true. */ migratedCiphertext?: string; - /** True if the data was encrypted with legacy PBKDF2 */ + /** True if the ciphertext used the legacy on-disk format. */ wasLegacy: boolean; - /** True if the data used the legacy PBKDF2 KDF */ + /** True if the ciphertext used the legacy PBKDF2 KDF (currently equivalent to wasLegacy). */ wasLegacyKdf?: boolean; } @@ -471,22 +186,36 @@ export interface DecryptResult { * Decrypts data and provides migration information for legacy PBKDF2 data. * * When legacy data is detected: - * 1. Decrypts using PBKDF2 (insecure: password used as salt) - * 2. Re-encrypts using Argon2id (secure: random salt) - * 3. Returns the new ciphertext for caller to persist + * 1. Decrypts using PBKDF2 (insecure: password used as salt). + * 2. Re-encrypts using Argon2id (random salt, ~strong KDF). + * 3. Returns the new ciphertext for the caller to persist. * - * Callers should persist migratedCiphertext when available to complete - * the migration from PBKDF2 to Argon2id. + * The {@link setLegacyKdfWarningHandler} side-channel still fires for every + * successful legacy decrypt — including the one inside this function — so a + * host can drive both a structural migration AND a deprecation UI from the + * same code path. */ export const decryptWithMigration = async ( data: string, password: string, ): Promise => { + const buffer = decodeBase64(data); + const format = detectFormat(buffer); + + if (format === 'invalid') { + throw new Error('Encrypted data is too short to be valid'); + } + if (format === 'legacy') { + const plaintext = await decryptLegacy(data, password); + const migratedCiphertext = await encrypt(plaintext, password); + return { plaintext, migratedCiphertext, wasLegacy: true, wasLegacyKdf: true }; + } + try { - const plaintext = await decryptArgon(data, password); + const plaintext = await decryptArgonFromBuffer(buffer, password); return { plaintext, wasLegacy: false }; - } catch (e) { - // Fallback to legacy PBKDF2 format - decrypt and prepare migration + } catch { + // Long legacy ciphertext misclassified as Argon2 — migrate it. const plaintext = await decryptLegacy(data, password); const migratedCiphertext = await encrypt(plaintext, password); return { plaintext, migratedCiphertext, wasLegacy: true, wasLegacyKdf: true }; @@ -496,68 +225,13 @@ export const decryptWithMigration = async ( // ============================================================================ // BATCH ENCRYPTION OPTIMIZATION // ============================================================================ -// The functions below optimize encryption/decryption for multiple operations -// by deriving the Argon2id key only once instead of per-operation. -// This is critical for mobile performance where Argon2id (64MB, 3 iterations) -// can take 500ms-2000ms per key derivation. - -/** - * Derives a key from password using Argon2id. - * Returns the key (CryptoKey or raw bytes) and salt for reuse across multiple encrypt operations. - * - * - When WebCrypto is available: returns webcrypto type with CryptoKey - * - When WebCrypto is unavailable (mobile): returns fallback type with raw Uint8Array - * - * @param password The encryption password - * @param salt Optional salt; if not provided, generates a random 16-byte salt - */ -export const deriveKeyFromPassword = async ( - password: string, - salt?: Uint8Array, -): Promise => { - const strategy = getCryptoStrategy(); - const actualSalt = salt ?? getRandomBytes(SALT_LENGTH); - return strategy.deriveKey(password, actualSalt); -}; - -/** - * Encrypts data using a pre-derived key. Much faster than encrypt() when - * encrypting multiple items since Argon2id key derivation is skipped. - * - * @param data Plaintext string to encrypt - * @param keyInfo Pre-derived key with its salt - * @returns Base64-encoded ciphertext with embedded salt and IV - */ -export const encryptWithDerivedKey = async ( - data: string, - keyInfo: DerivedKeyInfo, -): Promise => { - const strategy = keyInfo.type === 'webcrypto' ? webCryptoStrategy : fallbackStrategy; - const enc = new TextEncoder(); - const dataBuffer = enc.encode(data); - const iv = getRandomBytes(IV_LENGTH); - - const encryptedContent = await strategy.encrypt(keyInfo, iv, dataBuffer); - - // Same format as encrypt(): [SALT (16 bytes)][IV (12 bytes)][ENCRYPTED_DATA] - const buffer = new Uint8Array(SALT_LENGTH + IV_LENGTH + encryptedContent.byteLength); - buffer.set(keyInfo.salt, 0); - buffer.set(iv, SALT_LENGTH); - buffer.set(encryptedContent, SALT_LENGTH + IV_LENGTH); - - return ab2base64(buffer.buffer); -}; +// Derives the Argon2id key once instead of per-operation. +// Critical for mobile where Argon2id (64MB, 3 iterations) can take 500ms-2000ms +// per derivation. Session caches survive across sync cycles. /** * Encrypts multiple strings efficiently by deriving the key only once. * All encrypted strings share the same salt but have unique IVs. - * - * SESSION CACHING: Reuses the derived key across sync cycles if password hasn't changed. - * This dramatically improves mobile performance by avoiding repeated Argon2id derivations. - * - * @param dataItems Array of plaintext strings to encrypt - * @param password The encryption password - * @returns Array of Base64-encoded ciphertexts in the same order */ export const encryptBatch = async ( dataItems: string[], @@ -566,70 +240,18 @@ export const encryptBatch = async ( if (dataItems.length === 0) { return []; } - - const passwordHash = hashPasswordForCache(password); - let keyInfo: DerivedKeyInfo; - - // Check session cache for existing key (no timeout - cached for entire session) - if (sessionEncryptKeyCache && sessionEncryptKeyCache.passwordHash === passwordHash) { - // Reuse cached key (same salt means consistent ciphertext format) - keyInfo = sessionEncryptKeyCache.keyInfo; - } else { - // Derive new key and cache it - keyInfo = await deriveKeyFromPassword(password); - sessionEncryptKeyCache = { - keyInfo, - passwordHash, - }; - } - - // Encrypt all items in parallel using the pre-derived key - // Parallelization provides 10-100x speedup for large batches - const results = await Promise.all( - dataItems.map((data) => encryptWithDerivedKey(data, keyInfo)), - ); - return results; -}; - -/** - * Decrypts data using a pre-derived key. Use when the salt is already known - * and matches the keyInfo's salt. - * - * @param data Base64-encoded ciphertext - * @param keyInfo Pre-derived key that matches the ciphertext's salt - * @returns Decrypted plaintext string - */ -export const decryptWithDerivedKey = async ( - data: string, - keyInfo: DerivedKeyInfo, -): Promise => { - const strategy = keyInfo.type === 'webcrypto' ? webCryptoStrategy : fallbackStrategy; - const dataBuffer = base642ab(data); - // Skip salt (first 16 bytes) since we already have the key - const iv = new Uint8Array(dataBuffer, SALT_LENGTH, IV_LENGTH); - const encryptedData = new Uint8Array(dataBuffer, SALT_LENGTH + IV_LENGTH); - - const decryptedContent = await strategy.decrypt(keyInfo, iv, encryptedData); - - const dec = new TextDecoder(); - return dec.decode(decryptedContent); + const key = await getOrDeriveEncryptKey(password); + return Promise.all(dataItems.map((data) => encryptWithDerivedKey(data, key))); }; /** * Decrypts multiple strings efficiently by caching derived keys by salt. - * Operations with the same salt (e.g., encrypted in the same batch) will - * share the cached key, avoiding redundant Argon2id derivations. + * Operations with the same salt (e.g., encrypted in the same batch) share + * the cached key, avoiding redundant Argon2id derivations. * - * SESSION CACHING: Caches derived keys across sync cycles by password+salt. - * This dramatically improves mobile performance for repeated syncs. - * - * SECURITY NOTE: Unlike the single-item decrypt(), this function uses explicit - * format detection to avoid masking decryption errors as legacy fallbacks. - * Only data that's too short for Argon2 format will attempt legacy decryption. - * - * @param dataItems Array of Base64-encoded ciphertexts to decrypt - * @param password The decryption password - * @returns Array of decrypted plaintext strings in the same order + * SECURITY: Unlike single-item decrypt(), uses explicit format detection so + * Argon2 decryption errors are not silently masked as legacy fallbacks. + * Only ciphertexts too short for Argon2 attempt legacy decryption. */ export const decryptBatch = async ( dataItems: string[], @@ -641,101 +263,87 @@ export const decryptBatch = async ( const passwordHash = hashPasswordForCache(password); - // Phase 1: Analyze all items and collect unique salts that need key derivation - // This phase is fast (no crypto operations) - const itemAnalysis: Array<{ + interface ArgonItem { index: number; + format: 'argon2'; + cacheKey: string; + salt: Uint8Array; + buffer: ArrayBuffer; + } + interface LegacyItem { + index: number; + format: 'legacy'; data: string; - format: 'argon2' | 'legacy'; - saltBase64?: string; - salt?: Uint8Array; - }> = []; + } + // Phase 1: analyze items, decode once, collect unique salts needing derivation. + const itemAnalysis: Array = []; const saltsNeedingDerivation = new Map(); for (let i = 0; i < dataItems.length; i++) { const data = dataItems[i]; - const dataBuffer = base642ab(data); - const format = detectFormat(dataBuffer); + const buffer = decodeBase64(data); + const format = detectFormat(buffer); if (format === 'invalid') { throw new Error('Encrypted data is too short to be valid'); } if (format === 'legacy') { - itemAnalysis.push({ index: i, data, format: 'legacy' }); + itemAnalysis.push({ index: i, format: 'legacy', data }); continue; } - // Argon2 format: extract salt - const salt = new Uint8Array(dataBuffer, 0, SALT_LENGTH); - const saltBase64 = ab2base64(salt.slice().buffer); - const sessionCacheKey = `${passwordHash}:${saltBase64}`; + const salt = new Uint8Array(buffer, 0, SALT_LENGTH); + const saltBase64 = encodeBase64(salt); + const cacheKey = `${passwordHash}:${saltBase64}`; - itemAnalysis.push({ index: i, data, format: 'argon2', saltBase64, salt }); + itemAnalysis.push({ index: i, format: 'argon2', cacheKey, salt, buffer }); - // Check if we need to derive a key for this salt - if (!sessionDecryptKeyCache.has(sessionCacheKey)) { + if (!hasDecryptCache(cacheKey) && !saltsNeedingDerivation.has(saltBase64)) { saltsNeedingDerivation.set(saltBase64, salt); } } - // Phase 2: Derive keys for unique salts in parallel - // This is the expensive phase - parallelize it! - if (saltsNeedingDerivation.size > 0) { - const derivationPromises = Array.from(saltsNeedingDerivation.entries()).map( - async ([saltBase64, salt]) => { - const keyInfo = await deriveKeyFromPassword(password, salt); - return { saltBase64, keyInfo }; - }, - ); - - const derivedKeys = await Promise.all(derivationPromises); - - // Add derived keys to session cache - for (const { saltBase64, keyInfo } of derivedKeys) { - const sessionCacheKey = `${passwordHash}:${saltBase64}`; - - // Enforce cache size limit - if (sessionDecryptKeyCache.size >= SESSION_DECRYPT_CACHE_MAX_SIZE) { - const firstKey = sessionDecryptKeyCache.keys().next().value; - if (firstKey) { - sessionDecryptKeyCache.delete(firstKey); - } - } - sessionDecryptKeyCache.set(sessionCacheKey, keyInfo); + // Phase 2: derive keys for unique salts SERIALLY. + // Argon2id is single-threaded and allocates 64MB per derivation; parallel + // derivations via Promise.all only interleave microtasks (no real parallelism) + // and risk OOM on mobile. + for (const [saltBase64, salt] of saltsNeedingDerivation) { + const cacheKey = `${passwordHash}:${saltBase64}`; + if (hasDecryptCache(cacheKey)) { + continue; } + const key = await deriveKeyFromPassword(password, salt); + setDecryptCache(cacheKey, key); } - // Phase 3: Decrypt all items in parallel using cached keys + // Phase 3: decrypt all items in parallel using cached keys, reusing the + // buffer decoded in phase 1 to avoid a second base64 decode. const decryptionPromises = itemAnalysis.map(async (item) => { if (item.format === 'legacy') { return { index: item.index, result: await decryptLegacy(item.data, password) }; } - const sessionCacheKey = `${passwordHash}:${item.saltBase64}`; - const keyInfo = sessionDecryptKeyCache.get(sessionCacheKey)!; - - // Try Argon2 decryption first, fall back to legacy if it fails - // This handles legacy data that's ≥44 bytes (misclassified as Argon2) + const key = getDecryptCache(item.cacheKey)!; try { return { index: item.index, - result: await decryptWithDerivedKey(item.data, keyInfo), + result: await decryptWithDerivedKey(item.buffer, key), }; } catch { - // Argon2 failed - try legacy format (data might be long legacy ciphertext) - return { index: item.index, result: await decryptLegacy(item.data, password) }; + // Argon2 failed — fall back to legacy in case the data is a long legacy + // ciphertext that happens to be >= 44 bytes. + const data = dataItems[item.index]; + return { index: item.index, result: await decryptLegacy(data, password) }; } }); const decryptedItems = await Promise.all(decryptionPromises); - // Reassemble results in original order const results: string[] = new Array(dataItems.length); for (const { index, result } of decryptedItems) { results[index] = result; } - return results; }; diff --git a/packages/sync-core/src/encryption/argon2.ts b/packages/sync-core/src/encryption/argon2.ts new file mode 100644 index 0000000000..b889942c68 --- /dev/null +++ b/packages/sync-core/src/encryption/argon2.ts @@ -0,0 +1,78 @@ +import { argon2id } from 'hash-wasm'; +import { KEY_LENGTH, SALT_LENGTH, getRandomBytes } from './web-crypto'; + +const DEFAULT_ARGON2_PARAMS = { + parallelism: 1, + iterations: 3, + memorySize: 65536, // 64 MB - memorySize is in KiB +}; + +let _argon2Params = { ...DEFAULT_ARGON2_PARAMS }; + +/** + * Returns a snapshot of the current Argon2 parameters. + * Tests can override via `setArgon2ParamsForTesting()`. + */ +export const getArgon2Params = (): typeof DEFAULT_ARGON2_PARAMS => ({ + ..._argon2Params, +}); + +/** + * Override Argon2 parameters for testing (use weak params to speed up tests). + * Pass `undefined` to restore defaults. + * + * Throws when called in a Node-side production build (`NODE_ENV === 'production'`). + * The check is a no-op in browser bundles where `process` is undefined; the + * function name itself is the contract for those environments. + */ +export const setArgon2ParamsForTesting = ( + params?: Partial, +): void => { + const env = (globalThis as { process?: { env?: { NODE_ENV?: string } } }).process?.env + ?.NODE_ENV; + if (env === 'production') { + throw new Error('setArgon2ParamsForTesting must not be called in production'); + } + _argon2Params = params + ? { ...DEFAULT_ARGON2_PARAMS, ...params } + : { ...DEFAULT_ARGON2_PARAMS }; +}; + +const deriveKeyBytesArgon = async ( + password: string, + salt: Uint8Array, +): Promise => + argon2id({ + password, + salt, + hashLength: KEY_LENGTH, + parallelism: _argon2Params.parallelism, + iterations: _argon2Params.iterations, + memorySize: _argon2Params.memorySize, + outputType: 'binary', + }); + +/** + * A key derived from a password via Argon2id, plus the salt used to derive it. + * Reusable across many encrypt/decrypt calls (only IVs need to be unique). + */ +export interface DerivedKey { + keyBytes: Uint8Array; + salt: Uint8Array; +} + +/** + * Derives a key from password using Argon2id. Returns the derived bytes plus + * the salt for reuse across multiple encrypt operations. + * + * @param password The encryption password + * @param salt Optional salt; if not provided, generates a random 16-byte salt + */ +export const deriveKeyFromPassword = async ( + password: string, + salt?: Uint8Array, +): Promise => { + const actualSalt = salt ?? getRandomBytes(SALT_LENGTH); + const keyBytes = await deriveKeyBytesArgon(password, actualSalt); + return { keyBytes, salt: actualSalt }; +}; diff --git a/packages/sync-core/src/encryption/legacy.ts b/packages/sync-core/src/encryption/legacy.ts new file mode 100644 index 0000000000..9c669b0085 --- /dev/null +++ b/packages/sync-core/src/encryption/legacy.ts @@ -0,0 +1,106 @@ +import { WebCryptoNotAvailableError } from '../web-crypto-error'; +import { + ALGORITHM, + IV_LENGTH, + TEXT_DECODER, + TEXT_ENCODER, + decodeBase64, + getRequiredSubtle, + hashPasswordForCache, + isCryptoSubtleAvailable, +} from './web-crypto'; + +// ============================================================================ +// LEGACY KDF WARNING HANDLER +// ============================================================================ +// PBKDF2-with-password-as-salt is cryptographically weak. When legacy +// ciphertext is decrypted, hosts can register a handler to surface a +// deprecation signal to users (e.g. "consider re-syncing to migrate"). + +type LegacyKdfWarningHandler = () => void; +let _legacyKdfWarningHandler: LegacyKdfWarningHandler | null = null; + +/** + * Registers a handler invoked after a successful legacy PBKDF2 decryption. + * Pass `null` to unregister. The host is responsible for de-duplicating / + * throttling user-facing messages — the handler may fire on every legacy + * decrypt. + */ +export const setLegacyKdfWarningHandler = ( + handler: LegacyKdfWarningHandler | null, +): void => { + _legacyKdfWarningHandler = handler; +}; + +// ============================================================================ +// LEGACY PBKDF2 KEY CACHE +// ============================================================================ + +const sessionLegacyKeyCache = new Map(); + +/** Clears the legacy PBKDF2 key cache. Called by clearSessionKeyCache(). */ +export const clearLegacyKeyCache = (): void => { + sessionLegacyKeyCache.clear(); +}; + +const getOrDeriveLegacyKey = async (password: string): Promise => { + const passwordHash = hashPasswordForCache(password); + const cached = sessionLegacyKeyCache.get(passwordHash); + if (cached) { + return cached; + } + + const subtle = getRequiredSubtle(); + const passwordBuffer = TEXT_ENCODER.encode(password); + const keyMaterial = await subtle.importKey( + 'raw', + passwordBuffer, + { name: 'PBKDF2' }, + false, + ['deriveKey'], + ); + const key = await subtle.deriveKey( + { + name: 'PBKDF2', + // Using password as salt is insecure but kept for backward compatibility. + salt: TEXT_ENCODER.encode(password), + iterations: 1000, + hash: 'SHA-256', + }, + keyMaterial, + { name: ALGORITHM, length: 256 }, + true, + ['encrypt', 'decrypt'], + ); + sessionLegacyKeyCache.set(passwordHash, key); + return key; +}; + +/** + * Decrypts data produced by the legacy PBKDF2 format (kept for backward + * compatibility). Requires WebCrypto — there is no @noble fallback for the + * legacy path; mobile clients without WebCrypto must first sync from desktop + * to migrate. + */ +export const decryptLegacy = async (data: string, password: string): Promise => { + if (!isCryptoSubtleAvailable()) { + throw new WebCryptoNotAvailableError( + 'Cannot decrypt legacy data on this device. ' + + 'Your encrypted data uses an older format that requires WebCrypto. ' + + 'Please sync from a desktop browser first to migrate your data to the newer format.', + ); + } + + const dataBuffer = decodeBase64(data); + const iv = new Uint8Array(dataBuffer, 0, IV_LENGTH); + const encryptedData = new Uint8Array(dataBuffer, IV_LENGTH); + const key = await getOrDeriveLegacyKey(password); + const decryptedContent = await getRequiredSubtle().decrypt( + { name: ALGORITHM, iv }, + key, + encryptedData, + ); + + _legacyKdfWarningHandler?.(); + return TEXT_DECODER.decode(decryptedContent); +}; diff --git a/packages/sync-core/src/encryption/session-cache.ts b/packages/sync-core/src/encryption/session-cache.ts new file mode 100644 index 0000000000..eb767c6d69 --- /dev/null +++ b/packages/sync-core/src/encryption/session-cache.ts @@ -0,0 +1,77 @@ +import { DerivedKey, deriveKeyFromPassword } from './argon2'; +import { hashPasswordForCache } from './web-crypto'; +import { clearLegacyKeyCache } from './legacy'; + +// ============================================================================ +// SESSION-LEVEL KEY CACHING +// ============================================================================ +// Persists for the entire app session (until close/refresh). +// Avoids repeated Argon2id derivations (500ms-2000ms per call on mobile). +// Keys live in memory only; call clearSessionKeyCache() on password change. + +interface SessionCacheEntry { + key: DerivedKey; + passwordHash: string; +} + +// Encrypt cache: most-recently-used key for new encryptions +let sessionEncryptKeyCache: SessionCacheEntry | null = null; + +// Decrypt cache: "passwordHash:saltBase64" -> derived key (LRU-ish) +const sessionDecryptKeyCache = new Map(); + +const SESSION_DECRYPT_CACHE_MAX_SIZE = 100; + +/** + * Clears all session key caches (encrypt + decrypt + legacy PBKDF2). + * Call when: + * - User changes their encryption password + * - User logs out or disables encryption + * - For security-sensitive operations + */ +export const clearSessionKeyCache = (): void => { + sessionEncryptKeyCache = null; + sessionDecryptKeyCache.clear(); + clearLegacyKeyCache(); +}; + +/** + * Gets statistics about the session key cache (for debugging/monitoring). + */ +export const getSessionKeyCacheStats = (): { + hasEncryptKey: boolean; + decryptKeyCount: number; +} => ({ + hasEncryptKey: sessionEncryptKeyCache !== null, + decryptKeyCount: sessionDecryptKeyCache.size, +}); + +/** + * Returns the session-cached encrypt key for the given password, deriving and + * caching a fresh one on miss. + */ +export const getOrDeriveEncryptKey = async (password: string): Promise => { + const passwordHash = hashPasswordForCache(password); + if (sessionEncryptKeyCache && sessionEncryptKeyCache.passwordHash === passwordHash) { + return sessionEncryptKeyCache.key; + } + const key = await deriveKeyFromPassword(password); + sessionEncryptKeyCache = { key, passwordHash }; + return key; +}; + +export const getDecryptCache = (cacheKey: string): DerivedKey | undefined => + sessionDecryptKeyCache.get(cacheKey); + +export const hasDecryptCache = (cacheKey: string): boolean => + sessionDecryptKeyCache.has(cacheKey); + +export const setDecryptCache = (cacheKey: string, key: DerivedKey): void => { + if (sessionDecryptKeyCache.size >= SESSION_DECRYPT_CACHE_MAX_SIZE) { + const firstKey = sessionDecryptKeyCache.keys().next().value; + if (firstKey) { + sessionDecryptKeyCache.delete(firstKey); + } + } + sessionDecryptKeyCache.set(cacheKey, key); +}; diff --git a/packages/sync-core/src/encryption/web-crypto.ts b/packages/sync-core/src/encryption/web-crypto.ts new file mode 100644 index 0000000000..33a1f03b9b --- /dev/null +++ b/packages/sync-core/src/encryption/web-crypto.ts @@ -0,0 +1,148 @@ +import { gcm } from '@noble/ciphers/aes.js'; +import { WebCryptoNotAvailableError } from '../web-crypto-error'; + +export const ALGORITHM = 'AES-GCM' as const; +export const SALT_LENGTH = 16; +export const IV_LENGTH = 12; +export const KEY_LENGTH = 32; + +export const TEXT_ENCODER = new TextEncoder(); +export const TEXT_DECODER = new TextDecoder(); + +// Minimum sizes for format detection +// Argon2: [SALT (16)][IV (12)][CIPHERTEXT + AUTH_TAG (min 16)] = 44 bytes +// Legacy: [IV (12)][CIPHERTEXT + AUTH_TAG (min 16)] = 28 bytes +const MIN_ARGON2_SIZE = SALT_LENGTH + IV_LENGTH + 16; +const MIN_LEGACY_SIZE = IV_LENGTH + 16; + +const getRequiredCrypto = (): Crypto => { + const cryptoApi = (globalThis as { crypto?: Crypto }).crypto; + if (cryptoApi === undefined) { + throw new WebCryptoNotAvailableError('Crypto API is not available'); + } + return cryptoApi; +}; + +export const getRequiredSubtle = (): SubtleCrypto => { + const subtle = getRequiredCrypto().subtle; + if (subtle === undefined) { + throw new WebCryptoNotAvailableError(); + } + return subtle; +}; + +/** + * 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 (globalThis as { crypto?: Crypto }).crypto?.subtle !== undefined; +}; + +export const getRandomBytes = (length: number): Uint8Array => + getRequiredCrypto().getRandomValues(new Uint8Array(length)); + +// ============================================================================ +// AES-GCM PRIMITIVES +// ============================================================================ +// One branch on isCryptoSubtleAvailable() per call. WebCrypto's importKey is +// ~10μs, dwarfed by the surrounding Argon2id derivation (~500ms+ on mobile). + +export const aesEncrypt = async ( + keyBytes: Uint8Array, + iv: Uint8Array, + data: Uint8Array, +): Promise => { + if (isCryptoSubtleAvailable()) { + const subtle = getRequiredSubtle(); + const key = await subtle.importKey( + 'raw', + keyBytes.buffer as ArrayBuffer, + { name: ALGORITHM }, + false, + ['encrypt'], + ); + const out = await subtle.encrypt( + { name: ALGORITHM, iv: iv as Uint8Array }, + key, + data as Uint8Array, + ); + return new Uint8Array(out); + } + return gcm(keyBytes, iv).encrypt(data); +}; + +export const aesDecrypt = async ( + keyBytes: Uint8Array, + iv: Uint8Array, + data: Uint8Array, +): Promise => { + if (isCryptoSubtleAvailable()) { + const subtle = getRequiredSubtle(); + const key = await subtle.importKey( + 'raw', + keyBytes.buffer as ArrayBuffer, + { name: ALGORITHM }, + false, + ['decrypt'], + ); + const out = await subtle.decrypt( + { name: ALGORITHM, iv: iv as Uint8Array }, + key, + data as Uint8Array, + ); + return new Uint8Array(out); + } + return gcm(keyBytes, iv).decrypt(data); +}; + +// ============================================================================ +// BASE64 UTILITIES +// ============================================================================ +// Chunked String.fromCharCode avoids the O(n) intermediate Array of single-char +// strings that .map(...).join('') produces. ~10x faster for large blobs. + +const BASE64_CHUNK = 0x8000; + +export const decodeBase64 = (base64: string): ArrayBuffer => { + const binary = atob(base64); + const len = binary.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; +}; + +export const encodeBase64 = (buffer: ArrayBuffer | Uint8Array): string => { + const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.length; i += BASE64_CHUNK) { + const chunk = bytes.subarray(i, i + BASE64_CHUNK); + binary += String.fromCharCode.apply(null, chunk as unknown as number[]); + } + return btoa(binary); +}; + +export const detectFormat = ( + dataBuffer: ArrayBuffer, +): 'argon2' | 'legacy' | 'invalid' => { + if (dataBuffer.byteLength >= MIN_ARGON2_SIZE) { + return 'argon2'; + } else if (dataBuffer.byteLength >= MIN_LEGACY_SIZE) { + return 'legacy'; + } + return 'invalid'; +}; + +/** + * Simple hash of password for cache key comparison (djb2). + * NOT for security — just for cache invalidation when password changes. + */ +export const hashPasswordForCache = (password: string): string => { + let hash = 5381; + for (let i = 0; i < password.length; i++) { + hash = (hash * 33) ^ password.charCodeAt(i); + } + return (hash >>> 0).toString(16); +}; diff --git a/packages/sync-core/src/index.ts b/packages/sync-core/src/index.ts index c06edadbb5..9764ba0e66 100644 --- a/packages/sync-core/src/index.ts +++ b/packages/sync-core/src/index.ts @@ -56,26 +56,25 @@ export { export type { GzipCompressionLogMessages, GzipCompressionOptions } from './compression'; // Encryption primitives — Argon2id KDF + AES-GCM, Web Crypto with @noble fallback. +// See packages/sync-core/src/encryption.ts for the wire-format contract and +// the structural-vs-side-channel legacy-diagnostics discussion. export { encrypt, decrypt, encryptBatch, decryptBatch, - generateKey, - deriveKeyFromPassword, encryptWithDerivedKey, decryptWithDerivedKey, decryptWithMigration, - getCryptoStrategy, - isCryptoSubtleAvailable, + deriveKeyFromPassword, clearSessionKeyCache, getSessionKeyCacheStats, getArgon2Params, + isCryptoSubtleAvailable, setArgon2ParamsForTesting, - base642ab, - ab2base64, + setLegacyKdfWarningHandler, } from './encryption'; -export type { CryptoStrategy, DerivedKeyInfo, DecryptResult } from './encryption'; +export type { DerivedKey, DecryptResult } from './encryption'; // Generic error helpers. export { extractErrorMessage } from './error.util'; diff --git a/packages/sync-core/tests/encryption.spec.ts b/packages/sync-core/tests/encryption.spec.ts index 201644eb40..d4f3f0a50a 100644 --- a/packages/sync-core/tests/encryption.spec.ts +++ b/packages/sync-core/tests/encryption.spec.ts @@ -1,33 +1,538 @@ -import { describe, expect, it } from 'vitest'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +// Spec imports only from the barrel so the public-API contract is the +// single tested surface. import { WebCryptoNotAvailableError, + clearSessionKeyCache, decrypt, + decryptBatch, + decryptWithDerivedKey, + decryptWithMigration, + deriveKeyFromPassword, encrypt, + encryptBatch, + encryptWithDerivedKey, + getArgon2Params, + getSessionKeyCacheStats, isCryptoSubtleAvailable, setArgon2ParamsForTesting, + setLegacyKdfWarningHandler, } from '../src'; -describe('encryption primitives', () => { +const PASSWORD = 'super_secret_password'; +const DATA = 'some very secret data'; + +// Helper: simulate legacy PBKDF2 ciphertext (password-as-salt, 1000 iter SHA-256) +const encryptLegacy = async (data: string, password: string): Promise => { + const ALGO = 'AES-GCM'; + const IV_LEN = 12; + const enc = new TextEncoder(); + const passwordBuffer = enc.encode(password); + const subtle = globalThis.crypto.subtle; + const keyMaterial = await subtle.importKey( + 'raw', + passwordBuffer, + { name: 'PBKDF2' }, + false, + ['deriveBits', 'deriveKey'], + ); + const key = await subtle.deriveKey( + { + name: 'PBKDF2', + salt: enc.encode(password), + iterations: 1000, + hash: 'SHA-256', + }, + keyMaterial, + { name: ALGO, length: 256 }, + true, + ['encrypt', 'decrypt'], + ); + const dataBuffer = enc.encode(data); + const iv = globalThis.crypto.getRandomValues(new Uint8Array(IV_LEN)); + const encryptedContent = await subtle.encrypt({ name: ALGO, iv }, key, dataBuffer); + + const buffer = new Uint8Array(IV_LEN + encryptedContent.byteLength); + buffer.set(iv, 0); + buffer.set(new Uint8Array(encryptedContent), IV_LEN); + + let binary = ''; + for (let i = 0; i < buffer.length; i++) { + binary += String.fromCharCode(buffer[i]); + } + return btoa(binary); +}; + +const extractSaltHex = (base64: string): string => { + const binary = atob(base64); + let hex = ''; + for (let i = 0; i < 16; i++) { + hex += binary.charCodeAt(i).toString(16).padStart(2, '0'); + } + return hex; +}; + +describe('encryption', () => { + beforeAll(() => { + setArgon2ParamsForTesting({ parallelism: 1, memorySize: 8, iterations: 1 }); + }); + + afterAll(() => { + setArgon2ParamsForTesting(); + }); + + beforeEach(() => { + clearSessionKeyCache(); + }); + + afterEach(() => { + clearSessionKeyCache(); + setLegacyKdfWarningHandler(null); + }); + it('exposes Web Crypto availability check', () => { expect(typeof isCryptoSubtleAvailable()).toBe('boolean'); }); - it('round-trips a string through encrypt/decrypt with the same password', async () => { - if (!isCryptoSubtleAvailable()) { - return; - } + it('round-trips encrypt → decrypt with the same password', async () => { + const encrypted = await encrypt(DATA, PASSWORD); + expect(encrypted).not.toBe(DATA); + await expect(decrypt(encrypted, PASSWORD)).resolves.toBe(DATA); + }); - setArgon2ParamsForTesting({ parallelism: 1, memorySize: 8, iterations: 1 }); - try { - const plaintext = 'hello sync world'; - const ciphertext = await encrypt(plaintext, 'correct horse battery staple'); - expect(ciphertext).not.toBe(plaintext); - await expect(decrypt(ciphertext, 'correct horse battery staple')).resolves.toBe( - plaintext, + it('fails to decrypt with the wrong password', async () => { + const encrypted = await encrypt(DATA, PASSWORD); + await expect(decrypt(encrypted, 'wrong_password')).rejects.toBeDefined(); + }); + + describe('Legacy PBKDF2 compatibility', () => { + it('decrypts data encrypted with legacy PBKDF2', async () => { + const legacy = await encryptLegacy(DATA, PASSWORD); + await expect(decrypt(legacy, PASSWORD)).resolves.toBe(DATA); + }); + + it('invokes the legacy-KDF warning handler on legacy decrypt', async () => { + let calls = 0; + setLegacyKdfWarningHandler(() => { + calls += 1; + }); + + const legacy = await encryptLegacy(DATA, PASSWORD); + await decrypt(legacy, PASSWORD); + + expect(calls).toBeGreaterThan(0); + }); + + it('does not invoke the handler for Argon2id decrypts', async () => { + let calls = 0; + setLegacyKdfWarningHandler(() => { + calls += 1; + }); + + const encrypted = await encrypt(DATA, PASSWORD); + await decrypt(encrypted, PASSWORD); + + expect(calls).toBe(0); + }); + + describe('decryptWithMigration (structural diagnostic)', () => { + it('returns wasLegacy=false + no migration for Argon2id data', async () => { + const encrypted = await encrypt(DATA, PASSWORD); + const result = await decryptWithMigration(encrypted, PASSWORD); + + expect(result.plaintext).toBe(DATA); + expect(result.wasLegacy).toBe(false); + expect(result.migratedCiphertext).toBeUndefined(); + }); + + it('returns wasLegacy=true + migratedCiphertext for legacy data', async () => { + const legacy = await encryptLegacy(DATA, PASSWORD); + const result = await decryptWithMigration(legacy, PASSWORD); + + expect(result.plaintext).toBe(DATA); + expect(result.wasLegacy).toBe(true); + expect(result.wasLegacyKdf).toBe(true); + expect(result.migratedCiphertext).toBeDefined(); + }); + + it('produces migratedCiphertext that round-trips without further migration', async () => { + const legacy = await encryptLegacy(DATA, PASSWORD); + const first = await decryptWithMigration(legacy, PASSWORD); + const second = await decryptWithMigration(first.migratedCiphertext!, PASSWORD); + + expect(second.plaintext).toBe(DATA); + expect(second.wasLegacy).toBe(false); + }); + + it('still invokes the legacy-KDF warning handler', async () => { + let calls = 0; + setLegacyKdfWarningHandler(() => { + calls += 1; + }); + const legacy = await encryptLegacy(DATA, PASSWORD); + await decryptWithMigration(legacy, PASSWORD); + + expect(calls).toBeGreaterThan(0); + }); + }); + }); + + describe('Argon2 params', () => { + it('getArgon2Params returns the active params snapshot', () => { + const params = getArgon2Params(); + expect(params).toMatchObject({ + parallelism: 1, + memorySize: 8, + iterations: 1, + }); + }); + + it('getArgon2Params returns a copy, not the live object', () => { + const a = getArgon2Params(); + const b = getArgon2Params(); + expect(a).not.toBe(b); + }); + }); + + describe('Batch encryption', () => { + const ITEMS = ['item1', 'item2', 'item3', 'item with special chars: 日本語 🎉']; + + it('round-trips a batch', async () => { + const encrypted = await encryptBatch(ITEMS, PASSWORD); + expect(encrypted.length).toBe(ITEMS.length); + const decrypted = await decryptBatch(encrypted, PASSWORD); + expect(decrypted).toEqual(ITEMS); + }); + + it('returns empty array for empty input', async () => { + await expect(encryptBatch([], PASSWORD)).resolves.toEqual([]); + await expect(decryptBatch([], PASSWORD)).resolves.toEqual([]); + }); + + it('produces ciphertext compatible with single-item decrypt', async () => { + const [ct] = await encryptBatch([DATA], PASSWORD); + await expect(decrypt(ct, PASSWORD)).resolves.toBe(DATA); + }); + + it('uses the same salt for all items in a batch', async () => { + const encrypted = await encryptBatch(['a', 'b', 'c'], PASSWORD); + const s0 = extractSaltHex(encrypted[0]); + expect(extractSaltHex(encrypted[1])).toBe(s0); + expect(extractSaltHex(encrypted[2])).toBe(s0); + }); + + it('reuses cached salt across separate batch calls with same password', async () => { + const a = await encryptBatch(['a'], PASSWORD); + const b = await encryptBatch(['b'], PASSWORD); + expect(extractSaltHex(a[0])).toBe(extractSaltHex(b[0])); + }); + + it('uses different salts for different passwords', async () => { + const a = await encryptBatch(['a'], PASSWORD); + clearSessionKeyCache(); + const b = await encryptBatch(['b'], 'different_password'); + expect(extractSaltHex(a[0])).not.toBe(extractSaltHex(b[0])); + }); + + it('decrypts mixed batches with different salts', async () => { + const b1 = await encryptBatch(['a', 'b'], PASSWORD); + clearSessionKeyCache(); + const b2 = await encryptBatch(['c'], PASSWORD); + const individual = await encrypt('d', PASSWORD); + + const decrypted = await decryptBatch([...b1, ...b2, individual], PASSWORD); + expect(decrypted).toEqual(['a', 'b', 'c', 'd']); + }); + + it('fails with wrong password', async () => { + const encrypted = await encryptBatch(ITEMS, PASSWORD); + await expect(decryptBatch(encrypted, 'wrong_password')).rejects.toBeDefined(); + }); + + it('throws on corrupted data instead of falling back to legacy', async () => { + const [encrypted] = await encryptBatch(['test data'], PASSWORD); + const corrupted = encrypted.slice(0, 30) + 'XXXX' + encrypted.slice(34); + await expect(decryptBatch([corrupted], PASSWORD)).rejects.toBeDefined(); + }); + + it('decrypts legacy PBKDF2 entries in batch', async () => { + const legacy = await encryptLegacy(DATA, PASSWORD); + await expect(decryptBatch([legacy], PASSWORD)).resolves.toEqual([DATA]); + }); + + it('decrypts mixed legacy + Argon2 in same batch and preserves order', async () => { + const legacy1 = await encryptLegacy('legacy item 1', PASSWORD); + const legacy2 = await encryptLegacy('legacy item 2', PASSWORD); + const argon2 = await encryptBatch(['argon2 item 1', 'argon2 item 2'], PASSWORD); + + const mixed = [legacy1, argon2[0], legacy2, argon2[1]]; + const decrypted = await decryptBatch(mixed, PASSWORD); + + expect(decrypted).toEqual([ + 'legacy item 1', + 'argon2 item 1', + 'legacy item 2', + 'argon2 item 2', + ]); + }); + + it('handles large batches and preserves order', async () => { + const items = Array.from({ length: 50 }, (_, i) => `item-${i}`); + const encrypted = await encryptBatch(items, PASSWORD); + const decrypted = await decryptBatch(encrypted, PASSWORD); + expect(decrypted.length).toBe(50); + expect(decrypted[0]).toBe('item-0'); + expect(decrypted[49]).toBe('item-49'); + }); + }); + + describe('Session key caching', () => { + it('reports populated encrypt cache after first encryptBatch', async () => { + expect(getSessionKeyCacheStats()).toEqual({ + hasEncryptKey: false, + decryptKeyCount: 0, + }); + await encryptBatch(['test'], PASSWORD); + expect(getSessionKeyCacheStats().hasEncryptKey).toBe(true); + }); + + it('populates encrypt cache after a single-item encrypt', async () => { + await encrypt(DATA, PASSWORD); + expect(getSessionKeyCacheStats().hasEncryptKey).toBe(true); + }); + + it('populates decrypt cache after decryptBatch', async () => { + const encrypted = await encryptBatch(['test'], PASSWORD); + clearSessionKeyCache(); + await decryptBatch(encrypted, PASSWORD); + expect(getSessionKeyCacheStats().decryptKeyCount).toBeGreaterThan(0); + }); + + it('populates decrypt cache after a single-item decrypt', async () => { + const encrypted = await encrypt(DATA, PASSWORD); + clearSessionKeyCache(); + await decrypt(encrypted, PASSWORD); + expect(getSessionKeyCacheStats().decryptKeyCount).toBeGreaterThan(0); + }); + + it('clearSessionKeyCache clears all caches', async () => { + const encrypted = await encryptBatch(['test'], PASSWORD); + await decryptBatch(encrypted, PASSWORD); + clearSessionKeyCache(); + expect(getSessionKeyCacheStats()).toEqual({ + hasEncryptKey: false, + decryptKeyCount: 0, + }); + }); + + it('reuses cached encrypt key across encryptBatch and single-item encrypt', async () => { + const batch = await encryptBatch(['a'], PASSWORD); + const single = await encrypt('b', PASSWORD); + // Both should share the same cached salt + expect(extractSaltHex(batch[0])).toBe(extractSaltHex(single)); + }); + + it('invalidates encrypt cache when password changes', async () => { + const a = await encryptBatch(['x'], PASSWORD); + const b = await encryptBatch(['x'], 'different_password'); + expect(a[0]).not.toBe(b[0]); + await expect(decryptBatch(a, PASSWORD)).resolves.toEqual(['x']); + await expect(decryptBatch(b, 'different_password')).resolves.toEqual(['x']); + }); + }); + + describe('Pre-derived key helpers', () => { + it('encryptWithDerivedKey/decryptWithDerivedKey round-trips', async () => { + const key = await deriveKeyFromPassword(PASSWORD); + const ct = await encryptWithDerivedKey(DATA, key); + await expect(decryptWithDerivedKey(ct, key)).resolves.toBe(DATA); + }); + + it('deriveKeyFromPassword honors the supplied salt', async () => { + const customSalt = globalThis.crypto.getRandomValues(new Uint8Array(16)); + const key = await deriveKeyFromPassword(PASSWORD, customSalt); + expect(key.salt).toEqual(customSalt); + }); + + it('deriveKeyFromPassword returns 32-byte AES key material', async () => { + const key = await deriveKeyFromPassword(PASSWORD); + expect(key.keyBytes.length).toBe(32); + expect(key.salt.length).toBe(16); + }); + + it('encryptWithDerivedKey produces different IVs across calls', async () => { + const key = await deriveKeyFromPassword(PASSWORD); + const a = await encryptWithDerivedKey(DATA, key); + const b = await encryptWithDerivedKey(DATA, key); + expect(a).not.toBe(b); + }); + }); + + describe('Fallback strategy (no WebCrypto)', () => { + let originalDescriptor: PropertyDescriptor | undefined; + let originalSubtle: SubtleCrypto; + + beforeEach(() => { + originalSubtle = globalThis.crypto.subtle; + originalDescriptor = Object.getOwnPropertyDescriptor(globalThis.crypto, 'subtle'); + Object.defineProperty(globalThis.crypto, 'subtle', { + value: undefined, + writable: true, + configurable: true, + }); + }); + + afterEach(() => { + if (originalDescriptor) { + Object.defineProperty(globalThis.crypto, 'subtle', originalDescriptor); + } else { + Object.defineProperty(globalThis.crypto, 'subtle', { + value: originalSubtle, + writable: true, + configurable: true, + }); + } + }); + + it('reports WebCrypto unavailable', () => { + expect(isCryptoSubtleAvailable()).toBe(false); + }); + + it('encrypts and decrypts via @noble/ciphers fallback', async () => { + const encrypted = await encrypt(DATA, PASSWORD); + await expect(decrypt(encrypted, PASSWORD)).resolves.toBe(DATA); + }); + + it('still produces 32-byte AES key material under fallback', async () => { + const key = await deriveKeyFromPassword(PASSWORD); + expect(key.keyBytes.length).toBe(32); + expect(key.salt.length).toBe(16); + }); + + it('round-trips a batch via fallback', async () => { + const items = ['item1', 'item2', 'item3']; + const encrypted = await encryptBatch(items, PASSWORD); + await expect(decryptBatch(encrypted, PASSWORD)).resolves.toEqual(items); + }); + + it('uses the session key cache in fallback mode', async () => { + await encryptBatch(['a'], PASSWORD); + expect(getSessionKeyCacheStats().hasEncryptKey).toBe(true); + + const encrypted = await encryptBatch(['b'], PASSWORD); + await expect(decryptBatch(encrypted, PASSWORD)).resolves.toEqual(['b']); + }); + + it('throws on garbage input under fallback', async () => { + const len = 50; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) bytes[i] = Math.floor(Math.random() * 256); + let binary = ''; + for (let i = 0; i < len; i++) binary += String.fromCharCode(bytes[i]); + const invalid = btoa(binary); + await expect(decrypt(invalid, PASSWORD)).rejects.toBeDefined(); + }); + + it('throws WebCryptoNotAvailableError on legacy decrypt in fallback mode', async () => { + // Re-enable WebCrypto to produce legacy ciphertext, then disable it. + Object.defineProperty(globalThis.crypto, 'subtle', { + value: originalSubtle, + writable: true, + configurable: true, + }); + const legacy = await encryptLegacy(DATA, PASSWORD); + Object.defineProperty(globalThis.crypto, 'subtle', { + value: undefined, + writable: true, + configurable: true, + }); + + await expect(decrypt(legacy, PASSWORD)).rejects.toBeInstanceOf( + WebCryptoNotAvailableError, ); - } finally { - setArgon2ParamsForTesting(); - } + }); + }); + + describe('Cross-compatibility (WebCrypto ↔ Fallback)', () => { + let originalSubtle: SubtleCrypto; + let originalDescriptor: PropertyDescriptor | undefined; + + const disableWebCrypto = (): void => { + Object.defineProperty(globalThis.crypto, 'subtle', { + value: undefined, + writable: true, + configurable: true, + }); + }; + + const enableWebCrypto = (): void => { + if (originalDescriptor) { + Object.defineProperty(globalThis.crypto, 'subtle', originalDescriptor); + } else { + Object.defineProperty(globalThis.crypto, 'subtle', { + value: originalSubtle, + writable: true, + configurable: true, + }); + } + }; + + beforeEach(() => { + originalSubtle = globalThis.crypto.subtle; + originalDescriptor = Object.getOwnPropertyDescriptor(globalThis.crypto, 'subtle'); + }); + + afterEach(() => { + enableWebCrypto(); + }); + + it('decrypts WebCrypto-produced ciphertext with the fallback', async () => { + enableWebCrypto(); + const encrypted = await encrypt(DATA, PASSWORD); + clearSessionKeyCache(); + disableWebCrypto(); + await expect(decrypt(encrypted, PASSWORD)).resolves.toBe(DATA); + }); + + it('decrypts fallback-produced ciphertext with WebCrypto', async () => { + disableWebCrypto(); + const encrypted = await encrypt(DATA, PASSWORD); + clearSessionKeyCache(); + enableWebCrypto(); + await expect(decrypt(encrypted, PASSWORD)).resolves.toBe(DATA); + }); + + it('handles batch cross-compat WebCrypto → fallback', async () => { + enableWebCrypto(); + const items = ['a', 'b', 'c']; + const encrypted = await encryptBatch(items, PASSWORD); + clearSessionKeyCache(); + disableWebCrypto(); + await expect(decryptBatch(encrypted, PASSWORD)).resolves.toEqual(items); + }); + + it('handles batch cross-compat fallback → WebCrypto', async () => { + disableWebCrypto(); + const items = ['a', 'b', 'c']; + const encrypted = await encryptBatch(items, PASSWORD); + clearSessionKeyCache(); + enableWebCrypto(); + await expect(decryptBatch(encrypted, PASSWORD)).resolves.toEqual(items); + }); + + it('produces consistent on-the-wire format across implementations', async () => { + enableWebCrypto(); + const a = await encrypt(DATA, PASSWORD); + clearSessionKeyCache(); + disableWebCrypto(); + const b = await encrypt(DATA, PASSWORD); + + const ab = atob(a); + const bb = atob(b); + expect(ab.length).toBeGreaterThanOrEqual(44); + expect(bb.length).toBeGreaterThanOrEqual(44); + expect(Math.abs(ab.length - bb.length)).toBeLessThan(32); + }); }); it('exports WebCryptoNotAvailableError', () => { diff --git a/src/app/op-log/core/errors/sync-errors.identity.spec.ts b/src/app/op-log/core/errors/sync-errors.identity.spec.ts index b5293557f3..66b0969f7e 100644 --- a/src/app/op-log/core/errors/sync-errors.identity.spec.ts +++ b/src/app/op-log/core/errors/sync-errors.identity.spec.ts @@ -14,6 +14,7 @@ import { TooManyRequestsAPIError as PackageTooManyRequestsAPIError, UploadRevToMatchMismatchAPIError as PackageUploadRevToMatchMismatchAPIError, } from '@sp/sync-providers/errors'; +import { WebCryptoNotAvailableError as PackageWebCryptoNotAvailableError } from '@sp/sync-core'; import { AuthFailSPError, EmptyRemoteBodySPError, @@ -29,6 +30,7 @@ import { RemoteFileNotFoundAPIError, TooManyRequestsAPIError, UploadRevToMatchMismatchAPIError, + WebCryptoNotAvailableError, } from './sync-errors'; // Regression guard against ESM/CJS dual-realm and barrel/dist mis-resolution. @@ -82,6 +84,15 @@ describe('sync-errors identity (single class definition across import paths)', ( RemoteFileChangedUnexpectedly, PackageRemoteFileChangedUnexpectedly, ], + // Re-exported from @sp/sync-core (not @sp/sync-providers/errors), but the + // identity rule is the same: a single class definition must back every + // import path so `instanceof` works in catch blocks. See sync-errors.ts + // around the WebCryptoNotAvailableError re-export. + [ + 'WebCryptoNotAvailableError', + WebCryptoNotAvailableError, + PackageWebCryptoNotAvailableError, + ], ]; PAIRS.forEach(([name, appCtor, packageCtor]) => { diff --git a/src/app/op-log/core/errors/sync-errors.ts b/src/app/op-log/core/errors/sync-errors.ts index 453cc529d1..d3982b4444 100644 --- a/src/app/op-log/core/errors/sync-errors.ts +++ b/src/app/op-log/core/errors/sync-errors.ts @@ -406,6 +406,9 @@ export class BackupImportFailedError extends AdditionalLogErrorBase { override name = 'BackupImportFailedError'; } +// Re-export from @sp/sync-core (the canonical definition). Must remain a +// re-export — never redefine locally — so `instanceof WebCryptoNotAvailableError` +// works across all import paths. See the comment at the top of this file. export { WebCryptoNotAvailableError } from '@sp/sync-core'; /** diff --git a/src/app/op-log/encryption/encrypt-and-compress-handler.service.ts b/src/app/op-log/encryption/encrypt-and-compress-handler.service.ts index 72a70cf83a..fecaaa2c75 100644 --- a/src/app/op-log/encryption/encrypt-and-compress-handler.service.ts +++ b/src/app/op-log/encryption/encrypt-and-compress-handler.service.ts @@ -2,7 +2,7 @@ import { extractSyncFileStateFromPrefix, getSyncFilePrefix, } from '../util/sync-file-prefix'; -import { decryptBatch, encryptBatch, type SyncLogger } from '@sp/sync-core'; +import { decrypt, encrypt, type SyncLogger } from '@sp/sync-core'; import { OP_LOG_SYNC_LOGGER } from '../core/sync-logger.adapter'; import { DecryptError, @@ -85,7 +85,7 @@ export class EncryptAndCompressHandlerService { throw new Error('No encryption password provided'); } - [dataStr] = await encryptBatch([dataStr], encryptKey); + dataStr = await encrypt(dataStr, encryptKey); } return prefix + dataStr; @@ -119,7 +119,7 @@ export class EncryptAndCompressHandlerService { }); } try { - [outStr] = await decryptBatch([outStr], encryptKey); + outStr = await decrypt(outStr, encryptKey); } catch (e) { throw new DecryptError(e); } diff --git a/src/app/op-log/encryption/encryption.browser.spec.ts b/src/app/op-log/encryption/encryption.browser.spec.ts new file mode 100644 index 0000000000..1c73842631 --- /dev/null +++ b/src/app/op-log/encryption/encryption.browser.spec.ts @@ -0,0 +1,97 @@ +/** + * Browser smoke spec for @sp/sync-core encryption primitives. + * + * Depth coverage lives in packages/sync-core/tests/encryption.spec.ts (vitest, + * Node WebCrypto). This spec is the regression net for behavior that only + * shows up under real Chrome/WebCrypto — e.g. subtle differences in the + * SubtleCrypto implementation, BigInt/Uint8Array typing, or bundler/dist + * resolution. + */ + +import { + clearSessionKeyCache, + decrypt, + decryptBatch, + encrypt, + encryptBatch, + isCryptoSubtleAvailable, + setArgon2ParamsForTesting, +} from '@sp/sync-core'; + +describe('Encryption (browser smoke)', () => { + const PASSWORD = 'super_secret_password'; + const DATA = 'some very secret data'; + + beforeAll(() => { + setArgon2ParamsForTesting({ parallelism: 1, memorySize: 8, iterations: 1 }); + }); + + afterAll(() => { + setArgon2ParamsForTesting(); + }); + + beforeEach(() => clearSessionKeyCache()); + afterEach(() => clearSessionKeyCache()); + + it('reports WebCrypto availability matching actual subtle presence', () => { + // Karma's Chrome Headless flags can (rarely) drop crypto.subtle; assert + // consistency with reality rather than a specific value, so this stays + // useful even if the Karma launcher flags change. + expect(isCryptoSubtleAvailable()).toBe( + typeof globalThis.crypto?.subtle !== 'undefined', + ); + }); + + it('round-trips encrypt → decrypt via real WebCrypto', async () => { + const ct = await encrypt(DATA, PASSWORD); + expect(ct).not.toBe(DATA); + expect(await decrypt(ct, PASSWORD)).toBe(DATA); + }); + + it('round-trips encryptBatch → decryptBatch via real WebCrypto', async () => { + const items = ['a', 'b', 'item with special chars: 日本語 🎉']; + const ct = await encryptBatch(items, PASSWORD); + expect(await decryptBatch(ct, PASSWORD)).toEqual(items); + }); + + it('round-trips through the @noble fallback when crypto.subtle is missing', async () => { + // Real-Chrome mock of an insecure context. The Node spec does this too, + // but the type acrobatics around defineProperty on window.crypto vs + // globalThis.crypto are different enough to be worth verifying here. + const originalDescriptor = Object.getOwnPropertyDescriptor(window.crypto, 'subtle'); + Object.defineProperty(window.crypto, 'subtle', { + value: undefined, + writable: true, + configurable: true, + }); + try { + expect(isCryptoSubtleAvailable()).toBe(false); + const ct = await encrypt(DATA, PASSWORD); + expect(await decrypt(ct, PASSWORD)).toBe(DATA); + } finally { + if (originalDescriptor) { + Object.defineProperty(window.crypto, 'subtle', originalDescriptor); + } + } + }); + + it('decrypts WebCrypto-encrypted ciphertext via the @noble fallback', async () => { + // Cross-implementation interop: real Chrome WebCrypto producer, fallback consumer. + const ct = await encrypt(DATA, PASSWORD); + clearSessionKeyCache(); + + const originalDescriptor = Object.getOwnPropertyDescriptor(window.crypto, 'subtle'); + Object.defineProperty(window.crypto, 'subtle', { + value: undefined, + writable: true, + configurable: true, + }); + try { + expect(await decrypt(ct, PASSWORD)).toBe(DATA); + } finally { + if (originalDescriptor) { + Object.defineProperty(window.crypto, 'subtle', originalDescriptor); + } + } + }); +}); diff --git a/src/app/op-log/encryption/encryption.spec.ts b/src/app/op-log/encryption/encryption.spec.ts deleted file mode 100644 index 9cc8d2dd38..0000000000 --- a/src/app/op-log/encryption/encryption.spec.ts +++ /dev/null @@ -1,791 +0,0 @@ -import { - decrypt, - encrypt, - decryptWithMigration, - encryptBatch, - decryptBatch, - deriveKeyFromPassword, - encryptWithDerivedKey, - decryptWithDerivedKey, - clearSessionKeyCache, - getSessionKeyCacheStats, - setArgon2ParamsForTesting, -} from '@sp/sync-core'; - -describe('Encryption', () => { - const PASSWORD = 'super_secret_password'; - const DATA = 'some very secret data'; - - beforeAll(() => { - setArgon2ParamsForTesting({ parallelism: 1, memorySize: 8, iterations: 1 }); - }); - - afterAll(() => { - setArgon2ParamsForTesting(); - }); - - it('should encrypt and decrypt data correctly', async () => { - const encrypted = await encrypt(DATA, PASSWORD); - expect(encrypted).not.toBe(DATA); - const decrypted = await decrypt(encrypted, PASSWORD); - expect(decrypted).toBe(DATA); - }); - - it('should fail to decrypt with wrong password', async () => { - const encrypted = await encrypt(DATA, PASSWORD); - try { - await decrypt(encrypted, 'wrong_password'); - fail('Should have thrown error'); - } catch (e) { - // Success - } - }, 5000); - - describe('Legacy Compatibility', () => { - // Helper to simulate legacy encryption (PBKDF2) - const encryptLegacy = async (data: string, password: string): Promise => { - const ALGORITHM = 'AES-GCM'; - const IV_LENGTH = 12; - - const enc = new TextEncoder(); - const passwordBuffer = enc.encode(password); - const ops = { - name: 'PBKDF2', - salt: enc.encode(password), // Legacy used password as salt - iterations: 1000, - hash: 'SHA-256', - }; - const keyMaterial = await window.crypto.subtle.importKey( - 'raw', - passwordBuffer, - { name: 'PBKDF2' }, - false, - ['deriveBits', 'deriveKey'], - ); - const key = await window.crypto.subtle.deriveKey( - ops, - keyMaterial, - { name: ALGORITHM, length: 256 }, - true, - ['encrypt', 'decrypt'], - ); - - const dataBuffer = enc.encode(data); - const iv = window.crypto.getRandomValues(new Uint8Array(IV_LENGTH)); - const encryptedContent = await window.crypto.subtle.encrypt( - { name: ALGORITHM, iv }, - key, - dataBuffer, - ); - - const buffer = new Uint8Array(IV_LENGTH + encryptedContent.byteLength); - buffer.set(iv, 0); - buffer.set(new Uint8Array(encryptedContent), IV_LENGTH); - - const binary = Array.prototype.map - .call(buffer, (byte: number) => String.fromCharCode(byte)) - .join(''); - return window.btoa(binary); - }; - - it('should decrypt data encrypted with legacy PBKDF2', async () => { - const legacyEncrypted = await encryptLegacy(DATA, PASSWORD); - const decrypted = await decrypt(legacyEncrypted, PASSWORD); - expect(decrypted).toBe(DATA); - }); - - describe('decryptWithMigration', () => { - it('should return wasLegacy: false for Argon2id encrypted data', async () => { - const encrypted = await encrypt(DATA, PASSWORD); - const result = await decryptWithMigration(encrypted, PASSWORD); - - expect(result.plaintext).toBe(DATA); - expect(result.wasLegacy).toBe(false); - expect(result.migratedCiphertext).toBeUndefined(); - }); - - it('should return wasLegacy: true and migratedCiphertext for legacy data', async () => { - const legacyEncrypted = await encryptLegacy(DATA, PASSWORD); - const result = await decryptWithMigration(legacyEncrypted, PASSWORD); - - expect(result.plaintext).toBe(DATA); - expect(result.wasLegacy).toBe(true); - expect(result.wasLegacyKdf).toBe(true); - expect(result.migratedCiphertext).toBeDefined(); - - // Verify migrated ciphertext is valid Argon2id - const decryptedMigrated = await decrypt(result.migratedCiphertext!, PASSWORD); - expect(decryptedMigrated).toBe(DATA); - }); - - it('should produce migrated ciphertext that decrypts without legacy fallback', async () => { - const legacyEncrypted = await encryptLegacy(DATA, PASSWORD); - const result = await decryptWithMigration(legacyEncrypted, PASSWORD); - - // Migrated data should NOT need legacy fallback - const decryptResult = await decryptWithMigration( - result.migratedCiphertext!, - PASSWORD, - ); - expect(decryptResult.wasLegacy).toBe(false); - }); - }); - }); - - describe('Batch Encryption (Performance Optimization)', () => { - const ITEMS = ['item1', 'item2', 'item3', 'item with special chars: 日本語 🎉']; - - describe('deriveKeyFromPassword', () => { - it('should derive a key that can be reused for encryption', async () => { - const keyInfo = await deriveKeyFromPassword(PASSWORD); - // In browser with WebCrypto, should return webcrypto type - expect(keyInfo.type).toBe('webcrypto'); - if (keyInfo.type === 'webcrypto') { - expect(keyInfo.key).toBeDefined(); - } - expect(keyInfo.salt).toBeDefined(); - expect(keyInfo.salt.length).toBe(16); - }); - - it('should use provided salt when given', async () => { - const customSalt = new Uint8Array(16); - window.crypto.getRandomValues(customSalt); - - const keyInfo = await deriveKeyFromPassword(PASSWORD, customSalt); - expect(keyInfo.salt).toEqual(customSalt); - }); - }); - - describe('encryptWithDerivedKey', () => { - it('should encrypt data using pre-derived key', async () => { - const keyInfo = await deriveKeyFromPassword(PASSWORD); - const encrypted = await encryptWithDerivedKey(DATA, keyInfo); - - expect(encrypted).not.toBe(DATA); - // Should be decryptable with normal decrypt (which extracts salt from ciphertext) - const decrypted = await decrypt(encrypted, PASSWORD); - expect(decrypted).toBe(DATA); - }); - - it('should produce different ciphertext due to random IV', async () => { - const keyInfo = await deriveKeyFromPassword(PASSWORD); - const encrypted1 = await encryptWithDerivedKey(DATA, keyInfo); - const encrypted2 = await encryptWithDerivedKey(DATA, keyInfo); - - expect(encrypted1).not.toBe(encrypted2); - }); - }); - - describe('decryptWithDerivedKey', () => { - it('should decrypt data using pre-derived key', async () => { - const keyInfo = await deriveKeyFromPassword(PASSWORD); - const encrypted = await encryptWithDerivedKey(DATA, keyInfo); - const decrypted = await decryptWithDerivedKey(encrypted, keyInfo); - - expect(decrypted).toBe(DATA); - }); - }); - - describe('encryptBatch', () => { - it('should encrypt multiple items', async () => { - const encrypted = await encryptBatch(ITEMS, PASSWORD); - - expect(encrypted.length).toBe(ITEMS.length); - // Each should be decryptable - for (let i = 0; i < ITEMS.length; i++) { - const decrypted = await decrypt(encrypted[i], PASSWORD); - expect(decrypted).toBe(ITEMS[i]); - } - }); - - it('should return empty array for empty input', async () => { - const encrypted = await encryptBatch([], PASSWORD); - expect(encrypted).toEqual([]); - }); - - it('should produce ciphertext compatible with regular decrypt', async () => { - const encrypted = await encryptBatch([DATA], PASSWORD); - const decrypted = await decrypt(encrypted[0], PASSWORD); - expect(decrypted).toBe(DATA); - }); - - it('should use the same salt for all items in a batch', async () => { - const encrypted = await encryptBatch(['a', 'b', 'c'], PASSWORD); - - // Extract salt (first 16 bytes) from each ciphertext - const extractSalt = (base64: string): string => { - const binary = window.atob(base64); - // Return first 16 bytes as hex for comparison - return Array.from(binary.slice(0, 16)) - .map((c) => c.charCodeAt(0).toString(16).padStart(2, '0')) - .join(''); - }; - - const salt1 = extractSalt(encrypted[0]); - const salt2 = extractSalt(encrypted[1]); - const salt3 = extractSalt(encrypted[2]); - - // All items in the same batch should share the same salt - expect(salt1).toBe(salt2); - expect(salt2).toBe(salt3); - }); - - it('should reuse cached salt for separate batch calls with same password', async () => { - // Clear cache to ensure fresh start - clearSessionKeyCache(); - - const batch1 = await encryptBatch(['a'], PASSWORD); - const batch2 = await encryptBatch(['b'], PASSWORD); - - // Extract salt (first 16 bytes) from each ciphertext - const extractSalt = (base64: string): string => { - const binary = window.atob(base64); - return Array.from(binary.slice(0, 16)) - .map((c) => c.charCodeAt(0).toString(16).padStart(2, '0')) - .join(''); - }; - - const salt1 = extractSalt(batch1[0]); - const salt2 = extractSalt(batch2[0]); - - // With session caching, same password reuses the cached key (same salt) - expect(salt1).toBe(salt2); - }); - - it('should use different salts for different passwords', async () => { - clearSessionKeyCache(); - const batch1 = await encryptBatch(['a'], PASSWORD); - - clearSessionKeyCache(); - const batch2 = await encryptBatch(['b'], 'different_password'); - - // Extract salt (first 16 bytes) from each ciphertext - const extractSalt = (base64: string): string => { - const binary = window.atob(base64); - return Array.from(binary.slice(0, 16)) - .map((c) => c.charCodeAt(0).toString(16).padStart(2, '0')) - .join(''); - }; - - const salt1 = extractSalt(batch1[0]); - const salt2 = extractSalt(batch2[0]); - - // Different passwords should have different salts - expect(salt1).not.toBe(salt2); - }); - }); - - describe('decryptBatch', () => { - it('should decrypt multiple items encrypted with encryptBatch', async () => { - const encrypted = await encryptBatch(ITEMS, PASSWORD); - const decrypted = await decryptBatch(encrypted, PASSWORD); - - expect(decrypted).toEqual(ITEMS); - }); - - it('should decrypt items encrypted individually', async () => { - // Encrypt each item separately (different salts) - const encrypted = await Promise.all(ITEMS.map((item) => encrypt(item, PASSWORD))); - const decrypted = await decryptBatch(encrypted, PASSWORD); - - expect(decrypted).toEqual(ITEMS); - }); - - it('should return empty array for empty input', async () => { - const decrypted = await decryptBatch([], PASSWORD); - expect(decrypted).toEqual([]); - }); - - it('should handle mixed batch with same and different salts', async () => { - // First batch: all items share the same salt - const batch1 = await encryptBatch(['a', 'b'], PASSWORD); - // Second batch: different salt - const batch2 = await encryptBatch(['c'], PASSWORD); - // Individual: yet another salt - const individual = await encrypt('d', PASSWORD); - - const allEncrypted = [...batch1, ...batch2, individual]; - const decrypted = await decryptBatch(allEncrypted, PASSWORD); - - expect(decrypted).toEqual(['a', 'b', 'c', 'd']); - }); - - it('should fail with wrong password', async () => { - const encrypted = await encryptBatch(ITEMS, PASSWORD); - - try { - await decryptBatch(encrypted, 'wrong_password'); - fail('Should have thrown error'); - } catch (e) { - // Success - } - }, 5000); - - it('should throw error for corrupted data (not fall back to legacy)', async () => { - // Create valid Argon2 encrypted data and corrupt it - const encrypted = await encryptBatch(['test data'], PASSWORD); - // Corrupt the ciphertext by modifying some bytes (but keep valid base64 length) - const corrupted = encrypted[0].slice(0, 30) + 'XXXX' + encrypted[0].slice(34); - - try { - await decryptBatch([corrupted], PASSWORD); - fail('Should have thrown error for corrupted data'); - } catch (e) { - // Success - should throw error, not silently fall back to legacy - expect(e).toBeDefined(); - } - }); - }); - - describe('decryptBatch with legacy format', () => { - // Helper to simulate legacy encryption (PBKDF2) - const encryptLegacy = async (data: string, password: string): Promise => { - const ALGO = 'AES-GCM'; - const IV_LEN = 12; - - const enc = new TextEncoder(); - const passwordBuffer = enc.encode(password); - const ops = { - name: 'PBKDF2', - salt: enc.encode(password), - iterations: 1000, - hash: 'SHA-256', - }; - const keyMaterial = await window.crypto.subtle.importKey( - 'raw', - passwordBuffer, - { name: 'PBKDF2' }, - false, - ['deriveBits', 'deriveKey'], - ); - const key = await window.crypto.subtle.deriveKey( - ops, - keyMaterial, - { name: ALGO, length: 256 }, - true, - ['encrypt', 'decrypt'], - ); - - const dataBuffer = enc.encode(data); - const iv = window.crypto.getRandomValues(new Uint8Array(IV_LEN)); - const encryptedContent = await window.crypto.subtle.encrypt( - { name: ALGO, iv }, - key, - dataBuffer, - ); - - const buffer = new Uint8Array(IV_LEN + encryptedContent.byteLength); - buffer.set(iv, 0); - buffer.set(new Uint8Array(encryptedContent), IV_LEN); - - const binary = Array.prototype.map - .call(buffer, (byte: number) => String.fromCharCode(byte)) - .join(''); - return window.btoa(binary); - }; - - it('should decrypt legacy PBKDF2 format data in batch', async () => { - const legacyEncrypted = await encryptLegacy(DATA, PASSWORD); - const decrypted = await decryptBatch([legacyEncrypted], PASSWORD); - - expect(decrypted[0]).toBe(DATA); - }); - - it('should handle mixed legacy and Argon2 format in same batch', async () => { - // Create legacy encrypted items - const legacy1 = await encryptLegacy('legacy item 1', PASSWORD); - const legacy2 = await encryptLegacy('legacy item 2', PASSWORD); - - // Create Argon2 encrypted items - const argon2Items = await encryptBatch( - ['argon2 item 1', 'argon2 item 2'], - PASSWORD, - ); - - // Mix them in the batch - const mixed = [legacy1, argon2Items[0], legacy2, argon2Items[1]]; - const decrypted = await decryptBatch(mixed, PASSWORD); - - expect(decrypted).toEqual([ - 'legacy item 1', - 'argon2 item 1', - 'legacy item 2', - 'argon2 item 2', - ]); - }); - }); - }); - - describe('Session Key Caching', () => { - beforeEach(() => { - // Clear cache before each test to ensure isolation - clearSessionKeyCache(); - }); - - afterEach(() => { - // Clean up after tests - clearSessionKeyCache(); - }); - - it('should reuse cached key across multiple encryptBatch calls with same password', async () => { - // First batch - will derive key - const batch1 = await encryptBatch(['item1'], PASSWORD); - - // Second batch - should reuse cached key (same salt) - const batch2 = await encryptBatch(['item2'], PASSWORD); - - // Both should decrypt correctly - const decrypted1 = await decryptBatch(batch1, PASSWORD); - const decrypted2 = await decryptBatch(batch2, PASSWORD); - - expect(decrypted1[0]).toBe('item1'); - expect(decrypted2[0]).toBe('item2'); - - // Verify cache is populated - const stats = getSessionKeyCacheStats(); - expect(stats.hasEncryptKey).toBe(true); - }); - - it('should invalidate encryption cache when password changes', async () => { - const PASSWORD2 = 'different_password'; - - // Encrypt with first password - const batch1 = await encryptBatch(['secret'], PASSWORD); - - // Encrypt with different password - should derive new key - const batch2 = await encryptBatch(['secret'], PASSWORD2); - - // Both should have different encrypted outputs (different keys) - expect(batch1[0]).not.toBe(batch2[0]); - - // Each should only decrypt with its own password - const decrypted1 = await decryptBatch(batch1, PASSWORD); - expect(decrypted1[0]).toBe('secret'); - - const decrypted2 = await decryptBatch(batch2, PASSWORD2); - expect(decrypted2[0]).toBe('secret'); - }); - - it('should clear both encrypt and decrypt caches when clearSessionKeyCache is called', async () => { - // Populate caches - const encrypted = await encryptBatch(['test'], PASSWORD); - await decryptBatch(encrypted, PASSWORD); - - // Verify caches are populated - let stats = getSessionKeyCacheStats(); - expect(stats.hasEncryptKey).toBe(true); - expect(stats.decryptKeyCount).toBeGreaterThan(0); - - // Clear caches - clearSessionKeyCache(); - - // Verify caches are empty - stats = getSessionKeyCacheStats(); - expect(stats.hasEncryptKey).toBe(false); - expect(stats.decryptKeyCount).toBe(0); - }); - - it('should cache decryption keys by salt for reuse', async () => { - // Encrypt items (same salt in batch) - const encrypted = await encryptBatch(['a', 'b', 'c'], PASSWORD); - - // Clear encryption cache to focus on decryption caching - clearSessionKeyCache(); - - // First decryptBatch should cache the key - const decrypted1 = await decryptBatch(encrypted, PASSWORD); - expect(decrypted1).toEqual(['a', 'b', 'c']); - - // Verify decrypt cache is populated - const stats = getSessionKeyCacheStats(); - expect(stats.decryptKeyCount).toBeGreaterThan(0); - - // Second decryptBatch should use cached key - const decrypted2 = await decryptBatch(encrypted, PASSWORD); - expect(decrypted2).toEqual(['a', 'b', 'c']); - }); - - it('should return correct cache stats', async () => { - // Initially empty - let stats = getSessionKeyCacheStats(); - expect(stats.hasEncryptKey).toBe(false); - expect(stats.decryptKeyCount).toBe(0); - - // After encryption - await encryptBatch(['test'], PASSWORD); - stats = getSessionKeyCacheStats(); - expect(stats.hasEncryptKey).toBe(true); - - // After decryption with same salt (from encrypted batch) - const encrypted = await encryptBatch(['test2'], PASSWORD); - await decryptBatch(encrypted, PASSWORD); - stats = getSessionKeyCacheStats(); - expect(stats.decryptKeyCount).toBeGreaterThan(0); - }); - - it('should handle large batches efficiently with parallel processing', async () => { - // Create a large batch to verify parallel processing works - const items = Array.from({ length: 50 }, (_, i) => `item-${i}`); - - const encrypted = await encryptBatch(items, PASSWORD); - expect(encrypted.length).toBe(50); - - // Decrypt and verify all items - const decrypted = await decryptBatch(encrypted, PASSWORD); - expect(decrypted.length).toBe(50); - expect(decrypted[0]).toBe('item-0'); - expect(decrypted[49]).toBe('item-49'); - }); - - it('should maintain order when processing in parallel', async () => { - // Verify that parallel processing maintains correct order - const items = ['first', 'second', 'third', 'fourth', 'fifth']; - - const encrypted = await encryptBatch(items, PASSWORD); - const decrypted = await decryptBatch(encrypted, PASSWORD); - - expect(decrypted).toEqual(items); - }); - }); - - describe('Fallback Encryption (@noble/ciphers)', () => { - // These tests verify the fallback path works when WebCrypto is unavailable. - // We mock crypto.subtle to simulate mobile environments (Android/iOS Capacitor). - - let originalCryptoSubtle: SubtleCrypto | undefined; - let cryptoDescriptor: PropertyDescriptor | undefined; - - beforeEach(() => { - clearSessionKeyCache(); - // Save original crypto.subtle and its descriptor - originalCryptoSubtle = window.crypto.subtle; - cryptoDescriptor = Object.getOwnPropertyDescriptor(window.crypto, 'subtle'); - - // Remove crypto.subtle to simulate insecure context - Object.defineProperty(window.crypto, 'subtle', { - value: undefined, - writable: true, - configurable: true, - }); - }); - - afterEach(() => { - // Restore crypto.subtle with original descriptor or value - if (cryptoDescriptor) { - Object.defineProperty(window.crypto, 'subtle', cryptoDescriptor); - } else if (originalCryptoSubtle) { - Object.defineProperty(window.crypto, 'subtle', { - value: originalCryptoSubtle, - writable: true, - configurable: true, - }); - } - clearSessionKeyCache(); - }); - - it('should encrypt and decrypt when WebCrypto is unavailable', async () => { - const encrypted = await encrypt(DATA, PASSWORD); - expect(encrypted).not.toBe(DATA); - - const decrypted = await decrypt(encrypted, PASSWORD); - expect(decrypted).toBe(DATA); - }); - - it('should derive fallback key type when WebCrypto is unavailable', async () => { - const keyInfo = await deriveKeyFromPassword(PASSWORD); - expect(keyInfo.type).toBe('fallback'); - if (keyInfo.type === 'fallback') { - expect(keyInfo.keyBytes).toBeDefined(); - expect(keyInfo.keyBytes.length).toBe(32); // 256-bit key - } - }); - - it('should encrypt/decrypt batch when WebCrypto is unavailable', async () => { - const items = ['item1', 'item2', 'item3']; - const encrypted = await encryptBatch(items, PASSWORD); - expect(encrypted.length).toBe(3); - - const decrypted = await decryptBatch(encrypted, PASSWORD); - expect(decrypted).toEqual(items); - }); - - it('should use session key cache in fallback mode', async () => { - // First batch - derives key - await encryptBatch(['a'], PASSWORD); - - // Verify cache is populated - const stats = getSessionKeyCacheStats(); - expect(stats.hasEncryptKey).toBe(true); - - // Second batch - should reuse cached key - const encrypted = await encryptBatch(['b'], PASSWORD); - const decrypted = await decryptBatch(encrypted, PASSWORD); - expect(decrypted[0]).toBe('b'); - }); - - it('should throw error when decryption fails in fallback mode', async () => { - // Create invalid encrypted data - const invalidData = window.btoa( - String.fromCharCode( - ...new Array(50).fill(0).map(() => Math.floor(Math.random() * 256)), - ), - ); - - try { - await decrypt(invalidData, PASSWORD); - fail('Should have thrown an error'); - } catch (e: unknown) { - // Should throw some error (decryption failure or authentication error) - expect(e).toBeDefined(); - } - }); - }); - - describe('Cross-Compatibility (WebCrypto ↔ Fallback)', () => { - // These tests verify that data encrypted with one implementation - // can be decrypted by the other. This is critical for mobile/desktop sync. - - let originalCryptoSubtle: SubtleCrypto | undefined; - let cryptoDescriptor: PropertyDescriptor | undefined; - - const disableWebCrypto = (): void => { - if (!originalCryptoSubtle) { - originalCryptoSubtle = window.crypto.subtle; - cryptoDescriptor = Object.getOwnPropertyDescriptor(window.crypto, 'subtle'); - } - Object.defineProperty(window.crypto, 'subtle', { - value: undefined, - writable: true, - configurable: true, - }); - }; - - const enableWebCrypto = (): void => { - if (cryptoDescriptor) { - Object.defineProperty(window.crypto, 'subtle', cryptoDescriptor); - } else if (originalCryptoSubtle) { - Object.defineProperty(window.crypto, 'subtle', { - value: originalCryptoSubtle, - writable: true, - configurable: true, - }); - } - }; - - beforeEach(() => { - clearSessionKeyCache(); - // Save original state at the start of each test - originalCryptoSubtle = window.crypto.subtle; - cryptoDescriptor = Object.getOwnPropertyDescriptor(window.crypto, 'subtle'); - }); - - afterEach(() => { - enableWebCrypto(); - clearSessionKeyCache(); - }); - - it('should decrypt WebCrypto-encrypted data with fallback', async () => { - // Encrypt with WebCrypto (desktop) - enableWebCrypto(); - const encrypted = await encrypt(DATA, PASSWORD); - - // Clear cache to force fresh key derivation - clearSessionKeyCache(); - - // Decrypt with fallback (mobile) - disableWebCrypto(); - const decrypted = await decrypt(encrypted, PASSWORD); - - expect(decrypted).toBe(DATA); - }); - - it('should decrypt fallback-encrypted data with WebCrypto', async () => { - // Encrypt with fallback (mobile) - disableWebCrypto(); - const encrypted = await encrypt(DATA, PASSWORD); - - // Clear cache to force fresh key derivation - clearSessionKeyCache(); - - // Decrypt with WebCrypto (desktop) - enableWebCrypto(); - const decrypted = await decrypt(encrypted, PASSWORD); - - expect(decrypted).toBe(DATA); - }); - - it('should handle batch cross-compatibility: WebCrypto → Fallback', async () => { - // Encrypt batch with WebCrypto - enableWebCrypto(); - const items = ['desktop item 1', 'desktop item 2', 'desktop item 3']; - const encrypted = await encryptBatch(items, PASSWORD); - - clearSessionKeyCache(); - - // Decrypt batch with fallback - disableWebCrypto(); - const decrypted = await decryptBatch(encrypted, PASSWORD); - - expect(decrypted).toEqual(items); - }); - - it('should handle batch cross-compatibility: Fallback → WebCrypto', async () => { - // Encrypt batch with fallback - disableWebCrypto(); - const items = ['mobile item 1', 'mobile item 2', 'mobile item 3']; - const encrypted = await encryptBatch(items, PASSWORD); - - clearSessionKeyCache(); - - // Decrypt batch with WebCrypto - enableWebCrypto(); - const decrypted = await decryptBatch(encrypted, PASSWORD); - - expect(decrypted).toEqual(items); - }); - - it('should handle mixed batch with items from both environments', async () => { - // Encrypt some items with WebCrypto - enableWebCrypto(); - const desktopEncrypted = await encryptBatch(['from desktop'], PASSWORD); - - clearSessionKeyCache(); - - // Encrypt more items with fallback - disableWebCrypto(); - const mobileEncrypted = await encryptBatch(['from mobile'], PASSWORD); - - clearSessionKeyCache(); - - // Decrypt mixed batch with WebCrypto - enableWebCrypto(); - const mixed = [...desktopEncrypted, ...mobileEncrypted]; - const decrypted = await decryptBatch(mixed, PASSWORD); - - expect(decrypted).toEqual(['from desktop', 'from mobile']); - }); - - it('should produce consistent ciphertext format across implementations', async () => { - // Both implementations should produce: [SALT (16)][IV (12)][CIPHERTEXT + TAG] - enableWebCrypto(); - const webCryptoEncrypted = await encrypt(DATA, PASSWORD); - - clearSessionKeyCache(); - - disableWebCrypto(); - const fallbackEncrypted = await encrypt(DATA, PASSWORD); - - // Both should be valid base64 with similar structure - const webCryptoBuffer = window.atob(webCryptoEncrypted); - const fallbackBuffer = window.atob(fallbackEncrypted); - - // Both should have at least 44 bytes (16 salt + 12 IV + 16 tag minimum) - expect(webCryptoBuffer.length).toBeGreaterThanOrEqual(44); - expect(fallbackBuffer.length).toBeGreaterThanOrEqual(44); - - // Ciphertext lengths should be similar (same plaintext) - // Allow small variance due to potential padding differences - expect(Math.abs(webCryptoBuffer.length - fallbackBuffer.length)).toBeLessThan(32); - }); - }); -}); diff --git a/src/app/op-log/testing/helpers/mock-encryption.helper.ts b/src/app/op-log/testing/helpers/mock-encryption.helper.ts index ecc298aedb..68c523e896 100644 --- a/src/app/op-log/testing/helpers/mock-encryption.helper.ts +++ b/src/app/op-log/testing/helpers/mock-encryption.helper.ts @@ -7,7 +7,7 @@ * for tests that don't need to verify actual cryptographic behavior. * * Use real encryption only in: - * - encryption.spec.ts (tests the actual encryption) + * - packages/sync-core/tests/encryption.spec.ts (tests the actual encryption) * - Security-focused integration tests (if any) * * Usage with Jasmine spyOn (recommended): diff --git a/src/main.ts b/src/main.ts index 67807950a7..2674d0aa1b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -76,7 +76,8 @@ import { ShortTimePipe } from './app/ui/pipes/short-time.pipe'; import { BackgroundTask } from '@capawesome/capacitor-background-task'; import { PLUGIN_INITIALIZER_PROVIDER } from './app/plugins/plugin-initializer'; import { initializeMatMenuTouchFix } from './app/features/tasks/task-context-menu/mat-menu-touch-monkey-patch'; -import { Log } from './app/core/log'; +import { Log, SyncLog } from './app/core/log'; +import { setLegacyKdfWarningHandler } from '@sp/sync-core'; import { OperationWriteFlushService } from './app/op-log/sync/operation-write-flush.service'; import { PluginOAuthRedirectHandler } from './app/plugins/oauth/plugin-oauth-redirect.handler'; import { OAuthCallbackHandlerService } from './app/imex/sync/oauth-callback-handler.service'; @@ -100,6 +101,18 @@ let appInjector: Injector | null = null; // Required on iOS/Android where AudioContext starts suspended. unlockAudioContext(); +// Surface a deprecation warning the first time legacy PBKDF2 ciphertext is +// decrypted in this session. The encryption layer invokes this handler on +// every legacy decrypt; we throttle to one log per session. +let _hasWarnedLegacyKdf = false; +setLegacyKdfWarningHandler(() => { + if (_hasWarnedLegacyKdf) return; + _hasWarnedLegacyKdf = true; + SyncLog.log( + '[DEPRECATION] Legacy PBKDF2 encryption detected. Consider re-syncing to migrate to Argon2id.', + ); +}); + bootstrapApplication(AppComponent, { providers: [ // Provide configuration for TranslateHttpLoader