refactor(sync-core): extract encryption primitives

This commit is contained in:
Johannes Millan 2026-05-13 15:54:11 +02:00
parent 4bbcafbbab
commit 4b856b3411
18 changed files with 158 additions and 52 deletions

View file

@ -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<string | undefined> {
| 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 |

4
package-lock.json generated
View file

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

View file

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

View file

@ -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<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 };
@ -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<Uint8Array>;
decrypt(key: DerivedKeyInfo, iv: Uint8Array, data: Uint8Array): Promise<Uint8Array>;
deriveKey(password: string, salt: Uint8Array): Promise<DerivedKeyInfo>;
@ -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<ArrayBuffer> },
keyInfo.key,
data as Uint8Array<ArrayBuffer>,
@ -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<ArrayBuffer> },
keyInfo.key,
data as Uint8Array<ArrayBuffer>,
@ -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<ArrayBuffer> => {
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<CryptoKey> => {
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<string> => {
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<string> {
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<string> =
// 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 };
}
};

View file

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

View file

@ -0,0 +1,7 @@
export class WebCryptoNotAvailableError extends Error {
override name = 'WebCryptoNotAvailableError';
constructor(message = 'Web Crypto API (crypto.subtle) is not available') {
super(message);
}
}

View file

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

View file

@ -3,7 +3,7 @@
"target": "ES2022",
"module": "preserve",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"lib": ["ES2022", "DOM"],
"declaration": true,
"declarationMap": true,
"sourceMap": true,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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