diff --git a/docs/long-term-plans/jwt-derived-encryption.md b/docs/long-term-plans/jwt-derived-encryption.md index ec460a70e6..b6bdeb6fc4 100644 --- a/docs/long-term-plans/jwt-derived-encryption.md +++ b/docs/long-term-plans/jwt-derived-encryption.md @@ -81,7 +81,7 @@ export interface SuperSyncPrivateCfg extends SyncProviderPrivateCfgBase { ### Phase 2: Encryption Function (0.5 day) -**File:** `src/app/op-log/encryption/encryption.ts` +**File:** `packages/sync-core/src/encryption.ts` **Add:** @@ -196,7 +196,7 @@ async getEncryptKey(): Promise { | File | Changes | | ------------------------------------------------------------ | -------------------------------------------------- | | `packages/sync-providers/src/super-sync/super-sync.model.ts` | Add `isAutoEncryptionEnabled`, `autoEncryptionKey` | -| `src/app/op-log/encryption/encryption.ts` | Add `deriveKeyFromHighEntropy()` | +| `packages/sync-core/src/encryption.ts` | Add `deriveKeyFromHighEntropy()` | | `src/app/op-log/sync-providers/super-sync/super-sync.ts` | Modify `getEncryptKey()` | | `src/app/op-log/sync/operation-encryption.service.ts` | Support pre-derived keys | | `src/app/features/config/form-cfgs/sync-form.const.ts` | Add UI toggle | diff --git a/package-lock.json b/package-lock.json index 000635a3ce..1d3498e4d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29733,6 +29733,10 @@ "packages/sync-core": { "name": "@sp/sync-core", "version": "1.0.0", + "dependencies": { + "@noble/ciphers": "^2.2.0", + "hash-wasm": "^4.12.0" + }, "devDependencies": { "tsup": "^8.0.0", "typescript": "^5.0.0", diff --git a/packages/sync-core/package.json b/packages/sync-core/package.json index 88c0aa5a7a..422d6156a1 100644 --- a/packages/sync-core/package.json +++ b/packages/sync-core/package.json @@ -24,6 +24,10 @@ "test": "vitest run", "test:watch": "vitest" }, + "dependencies": { + "@noble/ciphers": "^2.2.0", + "hash-wasm": "^4.12.0" + }, "devDependencies": { "tsup": "^8.0.0", "typescript": "^5.0.0", diff --git a/src/app/op-log/encryption/encryption.ts b/packages/sync-core/src/encryption.ts similarity index 91% rename from src/app/op-log/encryption/encryption.ts rename to packages/sync-core/src/encryption.ts index 17911d1cab..9782623fb6 100644 --- a/src/app/op-log/encryption/encryption.ts +++ b/packages/sync-core/src/encryption.ts @@ -1,7 +1,6 @@ import { argon2id } from 'hash-wasm'; import { gcm } from '@noble/ciphers/aes.js'; -import { WebCryptoNotAvailableError } from '../core/errors/sync-errors'; -import { Log } from '../../core/log'; +import { WebCryptoNotAvailableError } from './web-crypto-error'; const ALGORITHM = 'AES-GCM' as const; const SALT_LENGTH = 16; @@ -16,6 +15,54 @@ const DEFAULT_ARGON2_PARAMS = { 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()`. @@ -29,6 +76,9 @@ export const getArgon2Params = (): typeof DEFAULT_ARGON2_PARAMS => _argon2Params export const setArgon2ParamsForTesting = ( params?: Partial, ): void => { + if (globals().process?.env?.NODE_ENV === 'production') { + throw new Error('setArgon2ParamsForTesting must not be called in production'); + } _argon2Params = params ? { ...DEFAULT_ARGON2_PARAMS, ...params } : { ...DEFAULT_ARGON2_PARAMS }; @@ -49,11 +99,7 @@ export const setArgon2ParamsForTesting = ( * Returns false in insecure contexts (http://, some custom schemes like Android Capacitor). */ export const isCryptoSubtleAvailable = (): boolean => { - return ( - typeof window !== 'undefined' && - typeof window.crypto !== 'undefined' && - typeof window.crypto.subtle !== 'undefined' - ); + return globals().crypto?.subtle !== undefined; }; // ============================================================================ @@ -74,7 +120,7 @@ export type DerivedKeyInfo = * Strategy interface for cryptographic operations. * Implemented by WebCrypto and @noble/ciphers backends. */ -interface CryptoStrategy { +export interface CryptoStrategy { encrypt(key: DerivedKeyInfo, iv: Uint8Array, data: Uint8Array): Promise; decrypt(key: DerivedKeyInfo, iv: Uint8Array, data: Uint8Array): Promise; deriveKey(password: string, salt: Uint8Array): Promise; @@ -109,7 +155,7 @@ const webCryptoStrategy: CryptoStrategy = { if (keyInfo.type !== 'webcrypto') { throw new Error('WebCrypto strategy requires webcrypto key type'); } - const encrypted = await window.crypto.subtle.encrypt( + const encrypted = await getRequiredSubtle().encrypt( { name: ALGORITHM, iv: iv as Uint8Array }, keyInfo.key, data as Uint8Array, @@ -121,7 +167,7 @@ const webCryptoStrategy: CryptoStrategy = { if (keyInfo.type !== 'webcrypto') { throw new Error('WebCrypto strategy requires webcrypto key type'); } - const decrypted = await window.crypto.subtle.decrypt( + const decrypted = await getRequiredSubtle().decrypt( { name: ALGORITHM, iv: iv as Uint8Array }, keyInfo.key, data as Uint8Array, @@ -131,7 +177,7 @@ const webCryptoStrategy: CryptoStrategy = { deriveKey: async (password, salt) => { const derivedBytes = await deriveKeyBytesArgon(password, salt); - const key = await window.crypto.subtle.importKey( + const key = await getRequiredSubtle().importKey( 'raw', derivedBytes.buffer as ArrayBuffer, { name: ALGORITHM }, @@ -250,7 +296,7 @@ export const getSessionKeyCacheStats = (): { // ============================================================================ export const base642ab = (base64: string): ArrayBuffer => { - const binary_string = window.atob(base64); + const binary_string = getRequiredAtob()(base64); const len = binary_string.length; const bytes = new Uint8Array(len); for (let i = 0; i < len; i++) { @@ -263,7 +309,7 @@ export const ab2base64 = (buffer: ArrayBuffer): string => { const binary = Array.prototype.map .call(new Uint8Array(buffer), (byte: number) => String.fromCharCode(byte)) .join(''); - return window.btoa(binary); + return getRequiredBtoa()(binary); }; /** @@ -271,7 +317,7 @@ export const ab2base64 = (buffer: ArrayBuffer): string => { * Uses crypto.getRandomValues which is available even without crypto.subtle. */ const getRandomBytes = (length: number): Uint8Array => { - return window.crypto.getRandomValues(new Uint8Array(length)); + return getRequiredCrypto().getRandomValues(new Uint8Array(length)); }; // Minimum sizes for format detection @@ -313,25 +359,22 @@ const _generateKey = async (password: string): Promise => { iterations: 1000, hash: 'SHA-256', }; - const key = await window.crypto.subtle.importKey( + const key = await getRequiredSubtle().importKey( 'raw', passwordBuffer, { name: 'PBKDF2' }, false, ['deriveBits', 'deriveKey'], ); - return window.crypto.subtle.deriveKey( - ops, - key, - { name: ALGORITHM, length: 256 }, - true, - ['encrypt', 'decrypt'], - ); + return getRequiredSubtle().deriveKey(ops, key, { name: ALGORITHM, length: 256 }, true, [ + 'encrypt', + 'decrypt', + ]); }; export const generateKey = async (password: string): Promise => { const cryptoKey = await _generateKey(password); - const exportKey = await window.crypto.subtle.exportKey('raw', cryptoKey); + const exportKey = await getRequiredSubtle().exportKey('raw', cryptoKey); return ab2base64(exportKey); }; @@ -351,18 +394,12 @@ async function decryptLegacy(data: string, password: string): Promise { const iv = new Uint8Array(dataBuffer, 0, IV_LENGTH); const encryptedData = new Uint8Array(dataBuffer, IV_LENGTH); const key = await _generateKey(password); - const decryptedContent = await window.crypto.subtle.decrypt( + const decryptedContent = await getRequiredSubtle().decrypt( { name: ALGORITHM, iv: iv }, key, encryptedData, ); - // Only warn AFTER successful decryption, to avoid false positives when - // Argon2id decryption fails for other reasons (wrong password, corrupted data) - Log.warn( - '[DEPRECATION] Legacy PBKDF2 encryption detected. Consider re-syncing to migrate to Argon2id.', - ); - const dec = new TextDecoder(); return dec.decode(decryptedContent); } @@ -410,8 +447,6 @@ export const decrypt = async (data: string, password: string): Promise = // 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. - // The deprecation warning is only emitted if legacy decryption SUCCEEDS, - // avoiding false positives when Argon2id fails for other reasons (wrong password). return await decryptLegacy(data, password); } }; @@ -428,6 +463,8 @@ export interface DecryptResult { migratedCiphertext?: string; /** True if the data was encrypted with legacy PBKDF2 */ wasLegacy: boolean; + /** True if the data used the legacy PBKDF2 KDF */ + wasLegacyKdf?: boolean; } /** @@ -452,7 +489,7 @@ export const decryptWithMigration = async ( // Fallback to legacy PBKDF2 format - decrypt and prepare migration const plaintext = await decryptLegacy(data, password); const migratedCiphertext = await encrypt(plaintext, password); - return { plaintext, migratedCiphertext, wasLegacy: true }; + return { plaintext, migratedCiphertext, wasLegacy: true, wasLegacyKdf: true }; } }; diff --git a/packages/sync-core/src/index.ts b/packages/sync-core/src/index.ts index e0bcc71357..c06edadbb5 100644 --- a/packages/sync-core/src/index.ts +++ b/packages/sync-core/src/index.ts @@ -55,8 +55,31 @@ export { } from './compression'; export type { GzipCompressionLogMessages, GzipCompressionOptions } from './compression'; +// Encryption primitives — Argon2id KDF + AES-GCM, Web Crypto with @noble fallback. +export { + encrypt, + decrypt, + encryptBatch, + decryptBatch, + generateKey, + deriveKeyFromPassword, + encryptWithDerivedKey, + decryptWithDerivedKey, + decryptWithMigration, + getCryptoStrategy, + isCryptoSubtleAvailable, + clearSessionKeyCache, + getSessionKeyCacheStats, + getArgon2Params, + setArgon2ParamsForTesting, + base642ab, + ab2base64, +} from './encryption'; +export type { CryptoStrategy, DerivedKeyInfo, DecryptResult } from './encryption'; + // Generic error helpers. export { extractErrorMessage } from './error.util'; +export { WebCryptoNotAvailableError } from './web-crypto-error'; // Full-state operation classification helper. Hosts supply their own op strings. export { diff --git a/packages/sync-core/src/web-crypto-error.ts b/packages/sync-core/src/web-crypto-error.ts new file mode 100644 index 0000000000..5e729c40d1 --- /dev/null +++ b/packages/sync-core/src/web-crypto-error.ts @@ -0,0 +1,7 @@ +export class WebCryptoNotAvailableError extends Error { + override name = 'WebCryptoNotAvailableError'; + + constructor(message = 'Web Crypto API (crypto.subtle) is not available') { + super(message); + } +} diff --git a/packages/sync-core/tests/encryption.spec.ts b/packages/sync-core/tests/encryption.spec.ts new file mode 100644 index 0000000000..201644eb40 --- /dev/null +++ b/packages/sync-core/tests/encryption.spec.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { + WebCryptoNotAvailableError, + decrypt, + encrypt, + isCryptoSubtleAvailable, + setArgon2ParamsForTesting, +} from '../src'; + +describe('encryption primitives', () => { + 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; + } + + 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, + ); + } finally { + setArgon2ParamsForTesting(); + } + }); + + it('exports WebCryptoNotAvailableError', () => { + expect(new WebCryptoNotAvailableError()).toBeInstanceOf(Error); + }); +}); diff --git a/packages/sync-core/tsconfig.json b/packages/sync-core/tsconfig.json index cabc0b830d..5eeeb1689c 100644 --- a/packages/sync-core/tsconfig.json +++ b/packages/sync-core/tsconfig.json @@ -3,7 +3,7 @@ "target": "ES2022", "module": "preserve", "moduleResolution": "bundler", - "lib": ["ES2022"], + "lib": ["ES2022", "DOM"], "declaration": true, "declarationMap": true, "sourceMap": true, diff --git a/src/app/imex/sync/encryption-password-change.service.ts b/src/app/imex/sync/encryption-password-change.service.ts index cda4fd78dd..041f6fd598 100644 --- a/src/app/imex/sync/encryption-password-change.service.ts +++ b/src/app/imex/sync/encryption-password-change.service.ts @@ -4,7 +4,7 @@ import { isOperationSyncCapable } from '../../op-log/sync/operation-sync.util'; import { SyncProviderId } from '../../op-log/sync-providers/provider.const'; import type { SuperSyncPrivateCfg } from '@sp/sync-providers/super-sync'; import { SyncLog } from '../../core/log'; -import { clearSessionKeyCache } from '../../op-log/encryption/encryption'; +import { clearSessionKeyCache } from '@sp/sync-core'; import { CleanSlateService } from '../../op-log/clean-slate/clean-slate.service'; import { OperationLogUploadService } from '../../op-log/sync/operation-log-upload.service'; import { SyncWrapperService } from './sync-wrapper.service'; diff --git a/src/app/imex/sync/file-based-encryption.service.ts b/src/app/imex/sync/file-based-encryption.service.ts index ff83934f7a..d68c891261 100644 --- a/src/app/imex/sync/file-based-encryption.service.ts +++ b/src/app/imex/sync/file-based-encryption.service.ts @@ -14,7 +14,7 @@ import { FileBasedSyncAdapterService } from '../../op-log/sync-providers/file-ba import { CURRENT_SCHEMA_VERSION } from '../../op-log/persistence/schema-migration.service'; import { uuidv7 } from '../../util/uuid-v7'; import { GlobalConfigService } from '../../features/config/global-config.service'; -import { clearSessionKeyCache } from '../../op-log/encryption/encryption'; +import { clearSessionKeyCache } from '@sp/sync-core'; const LOG_PREFIX = 'FileBasedEncryptionService'; diff --git a/src/app/imex/sync/snapshot-upload.service.ts b/src/app/imex/sync/snapshot-upload.service.ts index 1f427eba79..9b5d4406ed 100644 --- a/src/app/imex/sync/snapshot-upload.service.ts +++ b/src/app/imex/sync/snapshot-upload.service.ts @@ -21,7 +21,7 @@ import { } from '../../op-log/sync-providers/provider.interface'; import { VectorClock } from '../../core/util/vector-clock'; import { OperationEncryptionService } from '../../op-log/sync/operation-encryption.service'; -import { isCryptoSubtleAvailable } from '../../op-log/encryption/encryption'; +import { isCryptoSubtleAvailable } from '@sp/sync-core'; import { WebCryptoNotAvailableError } from '../../op-log/core/errors/sync-errors'; /** diff --git a/src/app/imex/sync/sync-config.service.ts b/src/app/imex/sync/sync-config.service.ts index cbd2e46109..0b17b80942 100644 --- a/src/app/imex/sync/sync-config.service.ts +++ b/src/app/imex/sync/sync-config.service.ts @@ -11,7 +11,7 @@ import { } from '../../op-log/sync-exports'; import { DEFAULT_GLOBAL_CONFIG } from '../../features/config/default-global-config.const'; import { SyncLog } from '../../core/log'; -import { clearSessionKeyCache } from '../../op-log/encryption/encryption'; +import { clearSessionKeyCache } from '@sp/sync-core'; import type { SuperSyncPrivateCfg } from '@sp/sync-providers/super-sync'; import { SyncWrapperService } from './sync-wrapper.service'; diff --git a/src/app/op-log/core/errors/sync-errors.ts b/src/app/op-log/core/errors/sync-errors.ts index 802e6fc722..453cc529d1 100644 --- a/src/app/op-log/core/errors/sync-errors.ts +++ b/src/app/op-log/core/errors/sync-errors.ts @@ -406,9 +406,7 @@ export class BackupImportFailedError extends AdditionalLogErrorBase { override name = 'BackupImportFailedError'; } -export class WebCryptoNotAvailableError extends Error { - override name = 'WebCryptoNotAvailableError'; -} +export { WebCryptoNotAvailableError } from '@sp/sync-core'; /** * Thrown when IndexedDB storage quota is exceeded during operation log write. diff --git a/src/app/op-log/encryption/encrypt-and-compress-handler.service.ts b/src/app/op-log/encryption/encrypt-and-compress-handler.service.ts index 37810c9714..72a70cf83a 100644 --- a/src/app/op-log/encryption/encrypt-and-compress-handler.service.ts +++ b/src/app/op-log/encryption/encrypt-and-compress-handler.service.ts @@ -2,9 +2,8 @@ import { extractSyncFileStateFromPrefix, getSyncFilePrefix, } from '../util/sync-file-prefix'; -import type { SyncLogger } from '@sp/sync-core'; +import { decryptBatch, encryptBatch, type SyncLogger } from '@sp/sync-core'; import { OP_LOG_SYNC_LOGGER } from '../core/sync-logger.adapter'; -import { decryptBatch, encryptBatch } from './encryption'; import { DecryptError, DecryptNoPasswordError, diff --git a/src/app/op-log/encryption/encryption.spec.ts b/src/app/op-log/encryption/encryption.spec.ts index f04e4552dc..9cc8d2dd38 100644 --- a/src/app/op-log/encryption/encryption.spec.ts +++ b/src/app/op-log/encryption/encryption.spec.ts @@ -10,7 +10,7 @@ import { clearSessionKeyCache, getSessionKeyCacheStats, setArgon2ParamsForTesting, -} from './encryption'; +} from '@sp/sync-core'; describe('Encryption', () => { const PASSWORD = 'super_secret_password'; @@ -110,6 +110,7 @@ describe('Encryption', () => { 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 diff --git a/src/app/op-log/sync/operation-encryption.service.ts b/src/app/op-log/sync/operation-encryption.service.ts index 254834833a..75c5dca010 100644 --- a/src/app/op-log/sync/operation-encryption.service.ts +++ b/src/app/op-log/sync/operation-encryption.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { encrypt, decrypt, encryptBatch, decryptBatch } from '../encryption/encryption'; +import { decrypt, decryptBatch, encrypt, encryptBatch } from '@sp/sync-core'; import { SyncOperation } from '../sync-providers/provider.interface'; import { DecryptError } from '../core/errors/sync-errors'; diff --git a/src/app/op-log/testing/helpers/mock-encryption.helper.ts b/src/app/op-log/testing/helpers/mock-encryption.helper.ts index d5c76ea183..ecc298aedb 100644 --- a/src/app/op-log/testing/helpers/mock-encryption.helper.ts +++ b/src/app/op-log/testing/helpers/mock-encryption.helper.ts @@ -12,7 +12,7 @@ * * Usage with Jasmine spyOn (recommended): * ```typescript - * import * as encryptionModule from '../../encryption/encryption'; + * import * as encryptionModule from '@sp/sync-core'; * import { mockEncrypt, mockDecrypt } from './mock-encryption.helper'; * * beforeEach(() => { diff --git a/src/app/op-log/testing/integration/file-based-sync/edge-cases.integration.spec.ts b/src/app/op-log/testing/integration/file-based-sync/edge-cases.integration.spec.ts index b8178bd185..35f377a493 100644 --- a/src/app/op-log/testing/integration/file-based-sync/edge-cases.integration.spec.ts +++ b/src/app/op-log/testing/integration/file-based-sync/edge-cases.integration.spec.ts @@ -12,10 +12,7 @@ import { } from '../../../core/operation.types'; import { OperationLogStoreService } from '../../../persistence/operation-log-store.service'; import { SyncImportFilterService } from '../../../sync/sync-import-filter.service'; -import { - clearSessionKeyCache, - setArgon2ParamsForTesting, -} from '../../../encryption/encryption'; +import { clearSessionKeyCache, setArgon2ParamsForTesting } from '@sp/sync-core'; describe('File-Based Sync Integration - Edge Cases', () => { let harness: FileBasedSyncTestHarness;