- USE_REMOTE: fix guaranteed self-deadlock — flushPendingWrites() was called while already holding the non-reentrant sp_op_log lock its own Phase 2 re-acquires, so every "Use Server Data" hung 30s and aborted. New OperationWriteFlushService.flushThenRunExclusive owns the bounded flush->lock->recheck barrier; ServerMigrationService reuses it, which also bounds its previously unbounded snapshot-cutoff retry loop. - USE_REMOTE: make the rebuild crash-resumable. A raw_rebuild_incomplete META marker is set atomically with the baseline replacement and cleared after the replay commits; the next sync redoes the raw rebuild instead of the normal download (which excludes own ops server-side and would silently lose the un-replayed suffix). The resume keeps the first attempt's pre-replace backup instead of overwriting the single slot with the partial baseline. - Deferred actions: serialize overlapping drains (two concurrent drains could mint two ops for one user intent), stop the drain at the first transient failure instead of persisting successors out of order (the older edit would win LWW everywhere via clock inversion), abandon permanently invalid actions after one attempt (typed PermanentDeferredWriteError) instead of retrying + snacking on every sync forever, and latch the buffer-size warnings to once per stuck window (previously a blocking dev dialog per action past 100); drop the no-op 5000 "hard cap" tier. - writeOperation: post-append bookkeeping failures no longer propagate into the deferred retry loop, which re-appended the same action under a fresh op id (double-apply of additive payloads). - remote-apply: full-state cleanup retains every full-state op of the current batch, so an archive_pending import can no longer be deleted before its archive retry (startup replay would lose a change already visible at runtime). - Hydration: sanitize a malformed stored schemaVersion per-op instead of failing the whole boot into recovery; strict parsing (floor now 1, matching the server contract) stays on the receive/upload paths. - Server: request fingerprints are computed lazily — only when a dedup entry exists, and otherwise after the rate-limit/pre-quota gates but before uploadOps mutates the parsed ops. Fixes the pre-gate hashing of full payloads (authenticated DoS-cost regression) and repairs three sync-fixes retry tests to model true identical-body retries. - Delete dead code: failed-op-ids util (+spec) and MAX_VERSION_SKIP (the removed forward-compat band); fix the stale clock-merge parity comment. sync-core 210/210, shared-schema 50/50, super-sync-server 825/825, and all touched Angular specs pass. |
||
|---|---|---|
| .. | ||
| src | ||
| tests | ||
| .gitignore | ||
| package.json | ||
| README.md | ||
| tsconfig.build.json | ||
| tsconfig.json | ||
| tsconfig.spec.json | ||
| tsup.config.ts | ||
| vitest.config.ts | ||
@sp/sync-core
Framework-agnostic primitives for the Super Productivity sync engine: operation-log types, vector clocks, conflict resolution, gzip compression, and end-to-end encryption. Consumed by the main app and the SuperSync server; no Angular/Electron/Capacitor dependencies.
Encryption
The encryption layer provides Argon2id key derivation and AES-256-GCM authenticated encryption, with a WebCrypto path and an @noble/ciphers fallback for environments where crypto.subtle is unavailable (notably Android Capacitor on http://localhost).
import {
encrypt,
decrypt,
encryptBatch,
decryptBatch,
clearSessionKeyCache,
setLegacyKdfWarningHandler,
} from '@sp/sync-core';
const cipher = await encrypt('hello', password);
const plain = await decrypt(cipher, password);
Wire format (public contract)
| Format | Bytes |
|---|---|
| Argon2id | [SALT (16)] [IV (12)] [AES-GCM ciphertext + auth tag (>= 16)] |
| Legacy | [IV (12)] [AES-GCM ciphertext + auth tag (>= 16)] |
All ciphertexts are base64-encoded for transport. The format is discriminated by length: < 28 bytes is invalid, < 44 bytes is unambiguously legacy, >= 44 bytes is treated as Argon2id with a legacy fallback on auth failure. Do not change this without a versioning migration.
Salt and IV semantics
- The IV (12 bytes) is freshly random per call. AES-GCM security under a fixed key reduces to IV uniqueness, which this guarantees.
- The salt (16 bytes) is derived once per
(process session, password)pair and reused across everyencrypt/encryptBatchcall in that session. This is intentional — it lets the session cache amortize the ~500 ms–2 s Argon2id derivation. Two encryptions of the same plaintext within a session therefore share the salt prefix and differ only in IV and ciphertext. Do not assert per-call salt uniqueness in tests.
Session key caching
encrypt/decrypt/encryptBatch/decryptBatch all share three in-memory caches (encrypt key, decrypt key by salt, legacy PBKDF2 key) that survive across sync cycles. Argon2id derivation is expensive (~500–2000 ms on mobile with the default 64 MiB / 3 iterations); the cache turns repeated syncs from minutes into seconds.
Call clearSessionKeyCache() whenever the user changes their password or logs out. Keys live in memory only and are never persisted.
Legacy-KDF migration
Old data was encrypted with PBKDF2 using the password as its own salt — cryptographically weak. decrypt() and decryptBatch() still read legacy ciphertexts so existing sync data remains accessible.
setLegacyKdfWarningHandler(fn) registers a callback fired on every successful legacy decrypt, regardless of which entry point was used. The host throttles user-facing messages (e.g. show a deprecation banner once per session).
Argon2id parameters
Defaults are OWASP 2023 mobile guidance (parallelism: 1, iterations: 3, memorySize: 64 MiB). Tests can weaken them via setArgon2ParamsForTesting({ ... }) — this throws when called with NODE_ENV === 'production' in Node bundles. Restore defaults by calling with no argument.
Other exports
OpType,Operation,VectorClockand friends — op-log primitive typescompareVectorClocks,mergeVectorClocks,limitVectorClockSize— clock algebraclassifyOpAgainstSyncImport— full-state-import op dispositioncreateSyncFilePrefixHelpers— host-configured file prefix codeccompressWithGzip,decompressGzipFromString— gzip helpersreplayOperationBatch,applyRemoteOperations— replay and apply coordinatorsplanRegularOpsAfterFullStateUpload,planSnapshotHydration, etc. — sync planning
See src/index.ts for the full barrel and the JSDoc on individual symbols for usage.
Tests
npm test # typecheck specs + vitest run, Node WebCrypto + @noble fallback
npm run test:watch # watch mode
npm run build # tsup -> ESM + CJS + .d.ts
Browser-context smoke coverage lives in the consuming app at src/app/op-log/encryption/encryption.browser.spec.ts (Karma + real Chrome).