mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
refactor(sync): split encryption primitives
This commit is contained in:
parent
4b856b3411
commit
1d08cb9bc4
14 changed files with 1292 additions and 1438 deletions
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
78
packages/sync-core/src/encryption/argon2.ts
Normal file
78
packages/sync-core/src/encryption/argon2.ts
Normal 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 };
|
||||
};
|
||||
106
packages/sync-core/src/encryption/legacy.ts
Normal file
106
packages/sync-core/src/encryption/legacy.ts
Normal 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);
|
||||
};
|
||||
77
packages/sync-core/src/encryption/session-cache.ts
Normal file
77
packages/sync-core/src/encryption/session-cache.ts
Normal 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);
|
||||
};
|
||||
148
packages/sync-core/src/encryption/web-crypto.ts
Normal file
148
packages/sync-core/src/encryption/web-crypto.ts
Normal 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);
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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]) => {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
97
src/app/op-log/encryption/encryption.browser.spec.ts
Normal file
97
src/app/op-log/encryption/encryption.browser.spec.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
15
src/main.ts
15
src/main.ts
|
|
@ -76,7 +76,8 @@ import { ShortTimePipe } from './app/ui/pipes/short-time.pipe';
|
|||
import { BackgroundTask } from '@capawesome/capacitor-background-task';
|
||||
import { PLUGIN_INITIALIZER_PROVIDER } from './app/plugins/plugin-initializer';
|
||||
import { initializeMatMenuTouchFix } from './app/features/tasks/task-context-menu/mat-menu-touch-monkey-patch';
|
||||
import { Log } from './app/core/log';
|
||||
import { Log, SyncLog } from './app/core/log';
|
||||
import { setLegacyKdfWarningHandler } from '@sp/sync-core';
|
||||
import { OperationWriteFlushService } from './app/op-log/sync/operation-write-flush.service';
|
||||
import { PluginOAuthRedirectHandler } from './app/plugins/oauth/plugin-oauth-redirect.handler';
|
||||
import { OAuthCallbackHandlerService } from './app/imex/sync/oauth-callback-handler.service';
|
||||
|
|
@ -100,6 +101,18 @@ let appInjector: Injector | null = null;
|
|||
// Required on iOS/Android where AudioContext starts suspended.
|
||||
unlockAudioContext();
|
||||
|
||||
// Surface a deprecation warning the first time legacy PBKDF2 ciphertext is
|
||||
// decrypted in this session. The encryption layer invokes this handler on
|
||||
// every legacy decrypt; we throttle to one log per session.
|
||||
let _hasWarnedLegacyKdf = false;
|
||||
setLegacyKdfWarningHandler(() => {
|
||||
if (_hasWarnedLegacyKdf) return;
|
||||
_hasWarnedLegacyKdf = true;
|
||||
SyncLog.log(
|
||||
'[DEPRECATION] Legacy PBKDF2 encryption detected. Consider re-syncing to migrate to Argon2id.',
|
||||
);
|
||||
});
|
||||
|
||||
bootstrapApplication(AppComponent, {
|
||||
providers: [
|
||||
// Provide configuration for TranslateHttpLoader
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue