super-productivity/packages/sync-core
Johannes Millan 8e9f7ba10a
fix(sync): accept legacy singleton LWW ops rejected as tampering (#9256) (#9294)
* test(migration): wait for migrated state persistence

* fix(sync): accept legacy singleton LWW operations

Treat payload IDs as canonical only for adapter-backed LWW targets so legacy time-tracking operations can replay without weakening task retarget protection.

* fix(sync): harden singleton LWW replay

Preserve compatibility payload IDs for composite singleton conflicts while keeping adapter IDs canonical. Ignore malformed non-record singleton payloads before they can overwrite state.

* fix(sync): harden malformed LWW payload handling

* fix(sync): scope malformed LWW array handling

Normalize legacy numeric-key singleton payloads while preserving valid array-backed map values such as planner days.

* fix(sync): drop unreproduced LWW array-spread guard; add lockstep test

Review follow-up for the singleton LWW replay fix (#9256).

The #9256 repro (issue log: op 019d1e73, TIME_TRACKING, entityId
PROJECT:eP8tBLmm0tBgJThAZOxcT:2026-03-24) shows the failing payload is a
well-formed { project, tag } record with payloadId "<undefined>" — the
core isLwwPayloadIdCanonical fix already handles it. There is NO
array-spread or non-record payload anywhere in the repro, so the
numeric-key "legacy-array-spread-record" detector had no reproduced
failure behind it and carried a latent footgun (it would zero any future
singleton with all-numeric top-level keys). Remove it, per the repo rule
"start from a reproducible problem":

- Drop isLegacyArraySpreadRecord / isCanonicalArrayIndexKey /
  MAX_ARRAY_INDEX from the converter; keep the footgun-free !isRecord
  normalization (a bare string/array payload still no-ops).
- Drop the producer's isMalformedSingletonState; basePayload again
  includes arrays, which preserves PLANNER (map) day-array spreading and
  normal singletons alike.
- Remove the two tests that only exercised the deleted guard.

Also (no behavior change):
- Add a converter test pinning that an op whose plaintext actionType is
  swapped to a singleton type lands as a whole-slice singleton replace
  (id stripped), never a TASK retarget — gate/converter/reducer branch
  on the same actionType in lockstep.
- Document the integrity gate's adapter-only scope, the compat-id
  sunset, and the un-migrated entityId==='*' check in sync-core.

* test(sync): add end-to-end recovery, back-compat & classification coverage

Confidence-raising tests for #9256 (no production-code change):

- operation-encryption: weld the full seam in one test — real AES-GCM
  decrypt -> convertOpToAction -> lwwUpdateMetaReducer — proving the
  reporter's legacy TIME_TRACKING op actually restores { project, tag }
  state, not merely that it "wasn't rejected".
- conflict-resolution: simulate the shipped v18.15.1 metadata-integrity
  gate (faithful copy, verified via git show) and assert the new
  producer's compatibility id makes old clients accept the op — and,
  non-vacuously, that stripping it reproduces the #9256 rejection.
- entity-registry: exhaustive table asserting isLwwPayloadIdCanonical is
  true for exactly the adapter entities across all 18 configs, guarding
  the misclassification that caused #9256.

* docs(sync): correct singleton-classification comments (#9256)

Review follow-up. Comment-only — no behavior change, verified by diff.

- sync-core convertLocalDeleteRemoteUpdatesToLww: the NOTE claimed
  "singletons never emit per-entity deletes". They do —
  menuTreeDeleteFolder emits MENU_TREE + OpType.Delete with a folderId
  entityId. The branch is still unreachable, but via a different guard:
  extractEntityFromPayload finds no base entity in that delete payload
  (no `menuTree` key, no id-matching array element, and the field is
  named `folderId`, not `id`). Name the real guard, since the stated one
  invites the exact refactor that would arm the line.

- SINGLETON_ENTITY_ID docstring: no shipped singleton producer emits '*'.
  GLOBAL_CONFIG addresses ops by section key, MENU_TREE by tree name /
  folderId, TIME_TRACKING by a composite TYPE🆔date key. Point readers
  at isLwwPayloadIdCanonical for the storage-pattern question.

- createLWWUpdateOp SUNSET note: the compat id covers every singleton,
  not just TIME_TRACKING, so the fleet-age cleanup is broader than the
  note said; the '*' else branch is unreachable in practice.

- validate-operation-payload: flag the third un-migrated
  isSingletonEntityId site (inert — warning-only, and LWW ops carry
  `entityChanges: []`) so it is not the one silent site left.

* test(sync): correct the non-vacuity note on the v18.15.1 gate sim (#9256)

Comment-only. The old note credited the second assertion for the test's
non-vacuity, but that one strips `id` from the test's own copy and re-runs
the test's own helper — it can only prove the simulated predicate still
discriminates.

The load-bearing assertion is the first: it fails when the producer stops
emitting the compat id (i.e. when `|| !isSingletonEntityId(entityId)` is
dropped from createLWWUpdateOp — the SUNSET cleanup performed too early),
which is exactly the regression this test exists to catch. Say so, and
label the second assertion as the guard on the simulation itself.
2026-07-24 21:59:17 +02:00
..
src fix(sync): accept legacy singleton LWW ops rejected as tampering (#9256) (#9294) 2026-07-24 21:59:17 +02:00
tests fix(sync-core): break whole-entity LWW timestamp ties by clientId (#9035) 2026-07-15 13:03:09 +02:00
.gitignore build: ignore files 2026-05-13 19:59:02 +02:00
package.json chore(deps): bump vitest from 3.2.4 to 4.1.6 (#7687) 2026-05-20 12:50:58 +02:00
README.md refactor(sync): post-extraction review cleanup of @sp/sync-core and @sp/sync-providers (#7595) 2026-05-14 13:06:08 +02:00
tsconfig.build.json refactor(sync): post-extraction review cleanup of @sp/sync-core and @sp/sync-providers (#7595) 2026-05-14 13:06:08 +02:00
tsconfig.json refactor(sync): post-extraction review cleanup of @sp/sync-core and @sp/sync-providers (#7595) 2026-05-14 13:06:08 +02:00
tsconfig.spec.json feat: migrate to capacitor 8 2026-05-23 20:42:00 +02:00
tsup.config.ts refactor(sync): post-extraction review cleanup of @sp/sync-core and @sp/sync-providers (#7595) 2026-05-14 13:06:08 +02:00
vitest.config.ts refactor(sync): move vector clocks to sync-core 2026-05-11 15:21:08 +02:00

@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 every encrypt/encryptBatch call in that session. This is intentional — it lets the session cache amortize the ~500 ms2 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 (~5002000 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, VectorClock and friends — op-log primitive types
  • compareVectorClocks, mergeVectorClocks, limitVectorClockSize — clock algebra
  • classifyOpAgainstSyncImport — full-state-import op disposition
  • createSyncFilePrefixHelpers — host-configured file prefix codec
  • compressWithGzip, decompressGzipFromString — gzip helpers
  • replayOperationBatch, applyRemoteOperations — replay and apply coordinators
  • planRegularOpsAfterFullStateUpload, 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).