# Private Sync protocol

**Protocol version:** 1  
**Payload schema version:** 1  
**Security status:** **Pending independent cryptographic and protocol review. Not approved for a production E2EE claim.**  
**Code:** `src/private-sync/`, `src/data/sync/engine.ts`, `convex/privateSync.ts`, and `convex/encryptedSync.ts`.

Private Sync is Glide's paid, account-scoped, end-to-end encrypted task synchronization. Glide remains local-first: the UI reads and writes MMKV, and local task work never depends on Clerk, RevenueCat, Convex, an entitlement check, or a network connection.

## Security goals and boundaries

Private Sync v1 is intended to keep synchronized task content confidential from Convex, Glide operators, and a stolen Clerk session. Convex stores ciphertext and authenticated routing metadata, not task plaintext.

Protected task fields include title, status, resistance, ordering, counters, decisions, completion time, deletion tombstones, and related task state. AES-GCM authenticates both the payload and its account/vault/task/version context.

Private Sync does **not** hide:

- account, device, vault, task-record, timestamp, size, and traffic metadata;
- the fact that an account has a subscription or vault;
- task plaintext already held by an approved device;
- task plaintext in Glide's local MMKV store, which relies on operating-system device protection;
- a vault key already copied by a compromised or formerly approved device.

The backend can deny service, delete or replay ciphertext, and return stale data. A valid Clerk session can also request explicit account deletion even without a vault key; stolen-session protection is a confidentiality goal, not an availability guarantee against session-authorized deletion. Authenticated encryption must turn altered or cross-context ciphertext into a visible, fail-closed error rather than plaintext corruption.

## Key hierarchy

Each vault receives an independently random 256-bit vault key from `expo-crypto`. It is never derived from a password, account ID, room code, or recovery phrase.

- **Task key:** the vault key is used by AES-256-GCM with a fresh random 96-bit nonce per sealed task version.
- **Recovery wrapping key:** a 12-word BIP39 phrase produces a BIP39 seed; HKDF-SHA-256 derives a domain-separated 256-bit wrapping key. The wrapping key encrypts the random vault key with AES-256-GCM.
- **Device transfer key:** X25519 shared secret + HKDF-SHA-256 derives a one-request wrapping key. It encrypts the vault key with AES-256-GCM.
- **Vault authorization capability:** HKDF-SHA-256 derives a domain- and vault-bound 256-bit capability from the vault key. Convex stores only its SHA-256 digest. Ciphertext access and privileged device/vault operations require the capability, preventing a Clerk-session holder without the vault key from impersonating an approved device. It is a bearer capability over TLS, not a defense against a malicious backend.
- **Device authorization capability:** HKDF-SHA-256 derives a domain-, vault-, and device-bound capability from each installation's X25519 private seed. Convex stores only its digest. Active operations require both this capability and active device status, so a revoked device cannot impersonate another device merely because it retained the shared vault key.
- **Recovery authorization capability:** a separate domain- and vault-bound HKDF capability is derived from the BIP39 seed, and Convex stores only its digest. Registering a recovered device requires this phrase proof, not the shared vault capability; otherwise any revoked device retaining the vault key could silently authorize itself again.

Long-lived device identity and active vault keys are stored only in Expo SecureStore using `WHEN_UNLOCKED_THIS_DEVICE_ONLY`. Key buffers are overwritten when their owning runtime stops. JavaScript and garbage collection prevent a guarantee that no transient copies ever exist, so claims must not imply hardware-backed key isolation.

## Canonical authenticated contexts

All v1 authenticated data uses the length-prefixed, ordered encoder in `src/private-sync/crypto.ts`; it must never be replaced with ambiguous delimiter concatenation.

A task envelope authenticates:

- protocol and AAD encoding versions;
- payload schema version;
- Clerk-derived `ownerId`;
- vault ID and key version;
- task client ID and `updatedAt`;
- the complete JSON task payload.

Recovery envelopes authenticate protocol, account, vault, and key versions. Device envelopes additionally authenticate request ID, requester device/public key, and approver ephemeral public key. Changing any authenticated field must fail AES-GCM opening.

Transport hexadecimal is strict canonical lowercase with exact or bounded lengths. Decrypted JSON is validated field by field before it can replace local plaintext. Unknown fields, malformed UTF-8, non-finite numbers, unsupported versions, and invalid domain values fail closed.

## Backend ownership and records

Every public Convex function derives ownership from `identity.tokenIdentifier`; no function accepts a client-selected owner ID.

- `privateSyncVaults`: one protocol/key version and recovery envelope per owner, plus an `initializing | active` cutover state and vault-authorization digest.
- `privateSyncDevices`: account/vault-scoped X25519 public identities, device-authorization digests, and `active | revoked` access status.
- `privateSyncLinkRequests`: short-lived, one-account requests and wrapped-key responses. QR/manual payloads contain identifiers and the requester public key, never vault or recovery secrets.
- `encryptedTasks`: `(ownerId, vaultId, clientId)`-scoped ciphertext envelopes and routing metadata.
- `deletingAccounts`: a temporary tombstone that blocks entitlement refresh and cloud access while batched account deletion finishes.

Legacy `tasks` rows contain plaintext and are only a rollout bridge. They must not remain after completed cutover.

## State machine

The client exposes these product states:

1. **Local only / not entitled:** local tasks remain available; no cloud key or data is required.
2. **Connecting:** authenticated entitlement and vault state are being established.
3. **Setup required:** entitled account has no vault.
4. **Phrase confirmation:** first device created an initializing vault; its phrase remains in SecureStore until exact confirmation.
5. **Locked:** vault exists but this installation has no active key.
6. **Awaiting approval:** a short-lived device-link request is pending.
7. **Ready / cutover pending:** the key is open and local rows are being uploaded as ciphertext before plaintext deletion.
8. **Ready:** read/write encrypted sync is active.
9. **Read-only:** grace access can read ciphertext; local edits remain queued and local UI stays writable.
10. **Revoked:** backend access is denied. Locally held tasks and keys cannot honestly be claimed erased.
11. **Error:** local data is retained; retry is safe and idempotent.

Sign-out stops networking, overwrites owned in-memory key buffers, and hides account-owned rows. An entitlement lapse stops cloud writes but preserves the current account owner locally so already-held tasks do not disappear. Entitlement controls cloud service, not offline access to locally held data or keys.

## First-device setup and plaintext cutover

1. Claim local rows for the authenticated owner and import any cloud-only legacy plaintext rows into the local source of truth. This is required on a fresh installation before any destructive cutover.
2. Generate vault key, vault ID, recovery phrase, and recovery envelope locally.
3. Persist pending setup in SecureStore before creating backend records.
4. Create the vault as `initializing` with an active first device and vault-authorization digest. Unconfirmed setup expires after 24 hours and may be explicitly abandoned without touching legacy rows; exact phrase confirmation cancels that escape path and makes migration resumable.
5. Keep legacy plaintext rows readable, but freeze legacy writes to remove the final migration race.
6. Require exact phrase confirmation before activating encrypted sync.
7. Treat every local acknowledgement lacking the current `syncedVaultId` as dirty. This forces all previously plaintext-synced rows to be resealed for the new vault, including tasks whose `updatedAt === syncedAt`.
8. Push encrypted rows in bounded idempotent batches. Only after the initial pull completed and no current-vault dirty row remains may the client call `commitEncryptedCutover`.
9. Mark the vault `active`, reject all legacy reads/writes, and delete legacy plaintext rows in scheduled bounded batches.

Do not deploy the narrowing steps before adoption telemetry and a rollback window show that supported clients complete encrypted cutover. Old clients encountering the write freeze retain their local dirty rows but cannot cloud-sync until updated.

## Device linking

An unapproved signed-in device creates a random request ID and manual code, then shows a QR URI. An approved device obtains the exact pending request, compares six BIP39-derived request words, and requires biometric/device authentication when enrolled before approval.

The approver generates an ephemeral X25519 key and fresh AES-GCM nonce, wraps the vault key for the requester's persisted X25519 public key, and submits that envelope with vault-key authorization. Approval alone does not activate the requester. The requester verifies all request metadata, unwraps only into memory, and shows six connection words derived from the complete post-wrap transcript. Both devices must compare those final words; only an explicit match finalizes the backend device, persists the key, and starts sync. A mismatch discards the in-memory key and cancels the approved request. Requests expire after ten minutes and are account/vault scoped.

Room/manual codes are discovery aids, not authorization secrets.

## Recovery and phrase replacement

Recovery opens the server-held recovery envelope locally and derives the vault authorization capability before registering the installation. A wrong phrase, changed context, or altered envelope must produce the same generic recovery failure.

Replacement is two-phase:

1. Persist the new phrase in SecureStore and upload a **pending** recovery envelope. The old envelope remains authoritative, so interruption or device loss cannot invalidate the recorded old phrase.
2. Keep presenting the pending phrase until exact user confirmation.
3. Commit the pending envelope atomically, then clear the local pending phrase.

Replacement prevents future recovery from the current server record using the old phrase. It does not revoke someone who previously cached the old envelope and phrase or otherwise obtained the unchanged vault key.

If all approved devices and the recovery phrase are lost, ciphertext is permanently unrecoverable. The eventual reset UX must say this explicitly and require destructive confirmation before starting a new vault.

## Revocation and rotation

V1 revocation blocks future backend reads, writes, links, and device management for the target device. It does **not** erase that device's local tasks/key and does not provide cryptographic forward revocation.

A claim that a compromised device can no longer decrypt future tasks requires a later audited rotation protocol: create a new random vault key/version, wrap it only for remaining devices/recovery, re-encrypt every live task, handle offline outboxes and concurrent edits, verify completeness, and only then retire the old key/version. The UI must continue to describe v1 removal as access revocation only.

## Sync and corruption behavior

The local store is authoritative for interaction and offline edits. The outbox is the set of owner rows where `updatedAt > syncedAt` **or** `syncedVaultId` differs from the active vault. Pushes upsert by `(ownerId, vaultId, clientId)` with whole-row wall-clock last-write-wins semantics. Acknowledgement is accepted only from a successfully opened current-vault envelope.

Malformed or unauthentic server rows are rejected individually and never overwrite prior local plaintext. Valid rows continue reconciling, while the UI surfaces that some encrypted data could not be read. Retries reseal current plaintext with fresh nonces.

V1 accepts client wall-clock LWW and its skew risk. Key, protocol, and payload-schema version mismatches fail closed; future versions require widened readers, explicit migrations, and only then narrowed writers/readers.

The current implementation deliberately fails closed above 5,000 encrypted/cutover task rows rather than returning a partial vault. Before production supports accounts that can reach that history size, replace the full reactive read and cutover verifier with a reviewed paginated protocol (or enforce and disclose a lower product limit with safe tombstone compaction). Silent truncation is not acceptable.

## Release gates

Before enabling Private Sync against production data:

- independent cryptographic/protocol review and fixes are complete;
- threat model and E2EE marketing/legal copy are reviewed;
- staged Convex rollout and rollback are tested with legacy clients;
- setup interruption, >1 push batch, the 5,000-row limit/pagination decision, reinstall, linking, denial/expiry, recovery, replacement interruption, revocation, grace/expiry, account switching/deletion, malformed ciphertext, and concurrent offline edits pass on iOS and Android;
- backend inspection confirms no task plaintext or reusable vault/device/recovery authorization capability appears in records, app logs, Convex logs, proxy logs, or crash reports;
- App Store export-compliance counsel or a qualified specialist confirms the `ITSAppUsesNonExemptEncryption` answer and any annual mass-market self-classification/reporting obligations. The repository's current value is not legal advice.

Until these gates pass, the implementation is production-oriented engineering work, not production-approved E2EE.
