refactor(sync): split encryption primitives

This commit is contained in:
Johannes Millan 2026-05-13 16:40:15 +02:00
parent 4b856b3411
commit 1d08cb9bc4
14 changed files with 1292 additions and 1438 deletions

View file

@ -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<typeof DEFAULT_ARGON2_PARAMS>,
): 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<Uint8Array>;
decrypt(key: DerivedKeyInfo, iv: Uint8Array, data: Uint8Array): Promise<Uint8Array>;
deriveKey(password: string, salt: Uint8Array): Promise<DerivedKeyInfo>;
}
/**
* Derives raw key bytes using Argon2id.
* Used by both WebCrypto and @noble/ciphers strategies.
*/
const deriveKeyBytesArgon = async (
password: string,
salt: Uint8Array,
): Promise<Uint8Array> => {
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<ArrayBuffer> },
keyInfo.key,
data as Uint8Array<ArrayBuffer>,
);
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<ArrayBuffer> },
keyInfo.key,
data as Uint8Array<ArrayBuffer>,
);
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<string, DerivedKeyInfo>();
// 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<ArrayBuffer> => {
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<CryptoKey> => {
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<string> => {
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<string> {
// 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<string> => {
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<string> => {
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<string> => {
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<string> => {
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<string> => {
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<string> => {
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<string> => {
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<string> => {
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<DecryptResult> => {
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<DerivedKeyInfo> => {
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<string> => {
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<string> => {
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<ArgonItem | LegacyItem> = [];
const saltsNeedingDerivation = new Map<string, Uint8Array>();
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;
};

View file

@ -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<typeof DEFAULT_ARGON2_PARAMS>,
): 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<Uint8Array> =>
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<DerivedKey> => {
const actualSalt = salt ?? getRandomBytes(SALT_LENGTH);
const keyBytes = await deriveKeyBytesArgon(password, actualSalt);
return { keyBytes, salt: actualSalt };
};

View file

@ -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<string, CryptoKey>();
/** Clears the legacy PBKDF2 key cache. Called by clearSessionKeyCache(). */
export const clearLegacyKeyCache = (): void => {
sessionLegacyKeyCache.clear();
};
const getOrDeriveLegacyKey = async (password: string): Promise<CryptoKey> => {
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<string> => {
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);
};

View file

@ -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<string, DerivedKey>();
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<DerivedKey> => {
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);
};

View file

@ -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<ArrayBuffer> =>
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<Uint8Array> => {
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<ArrayBuffer> },
key,
data as Uint8Array<ArrayBuffer>,
);
return new Uint8Array(out);
}
return gcm(keyBytes, iv).encrypt(data);
};
export const aesDecrypt = async (
keyBytes: Uint8Array,
iv: Uint8Array,
data: Uint8Array,
): Promise<Uint8Array> => {
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<ArrayBuffer> },
key,
data as Uint8Array<ArrayBuffer>,
);
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);
};

View file

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

View file

@ -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<string> => {
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', () => {

View file

@ -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]) => {

View file

@ -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';
/**

View file

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

View file

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

View file

@ -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<string> => {
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<string> => {
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);
});
});
});

View file

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

View file

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