Repository navigation
Release 1.23.0 (build 39): Cloud sync on iOS 15.0 to 15.3, Disconnect ends this iPhone's access - #57
Merged
Merged
Conversation
End-to-end encrypted Cloud vaults (docs/specs/cloud-e2ee.md in the
desktop repo) store every file as one ZNE1 object: a 74-byte header,
then AES-256-GCM chunks of 64 KiB. Files over the inline limit never
cross the WebView bridge, so ZenDirectUpload runs the chunk loop:
- encryptFile({ source, destination, header, objectKey, aad }) seals a
vault file and resolves the ciphertext's length and SHA-256 for the
upload, plus the plaintext's, so the caller can confirm it sealed the
bytes it scanned. An empty file seals one empty chunk.
- decryptFile(...) checks that the file begins with the header
JavaScript verified, opens the chunks holding one back to know which
one ends the input, enforces the length rules, and removes partial
plaintext on any failure. E2EE_MALFORMED and E2EE_TAMPERED describe
the data; bad arguments and I/O keep their own codes, so a full disk
never reads as a damaged file.
- readHeader({ source, length }) returns a file's first bytes, so
JavaScript can check the header and derive the object key without
the rest of the file crossing the bridge.
JavaScript derives every key, builds and checks the header and computes
the AAD; native code runs only the chunk loop and the hashes and never
sees a parent key. Zne1FileCipher sits in CloudFileStream.swift with no
Capacitor dependency, so a host-side runner compiles it with swiftc:
npm run test:native-e2ee, also part of npm test, seals and opens all 8
objects in cloud-e2ee-vectors.json (a verbatim copy of the desktop
repo's vectors) byte for byte, through short reads too, refuses 33
tampered inputs, and checks readHeader. A 200 MB round trip peaks at
7.4 MB resident. Nothing calls these methods until the sync wiring
lands.
crypto.randomUUID arrived in WebKit 15.4 and the app supports iOS 15.0, so on 15.0 to 15.3 Cloud sync threw before staging a download, before replacing a file with its Cloud version, and before retiring a sync state. Those five calls in cloud-sync-staging.ts, cloud-sync-repository.ts and mobile-cloud-sync.ts now go through ./uuid, which falls back to getRandomValues as the rest of the bridge already did. Core 2.67 routes its own ids the same way. src/random-uuid-polyfill.ts, the first import of main.tsx, installs crypto.randomUUID only where it is missing, for dependencies that call it directly. Rolldown still evaluates the shared chunks the entry imports ahead of it, but none of them calls randomUUID at load time; every call runs after boot starts. The new tests hide crypto.randomUUID and run the staging download and publish and the repository write, which now name their files from getRandomValues, plus the polyfill's guard. cloud-sync-staging.ts is shared byte for byte with the Android shell, which takes uuid.ts with it.
Core core-2.67.0-core.hb828dd60259bc376, built by CI (ZenNotes/zennotes boundary run 38278029411) from 58ba24ac, the v2.67.0 tag commit, and adopted with core:adopt pinned to that source (SHA-256, SHA-512, packed versions and clean-source provenance checked). The lockfile moves the three @ZenNotes pins and adds what shared-domain now pins: hash-wasm 4.12.0 and @scure/bip39 2.4.0, with @noble/hashes 2.4.0 under bip39. For the phones it brings: - CloudAuthFlow's revoke hook, so signing out can end the device's access on the service before the credential is deleted. - Cloud sync, backups and the conflict queue make their ids through shared-domain's random-uuid, so they run on iOS 15.0 to 15.3. - The end-to-end encryption core (ZNE1 objects, account and vault keys, recovery words, the vault cipher and the Settings panel), which the next commit wires in. Settings shows it only when the service advertises it, and the service has it off. npm run upstream, 226 tests and npm run test:native-e2ee pass on this commit alone.
With core 2.67's encryption layer, this shell now opens every path an encrypted vault's bytes take, so the client may tell the service it can serve encrypted vaults (MOBILE_END_TO_END_ENCRYPTION). The Settings panel appears only when the account's capabilities advertise end-to-end encryption, and the service has it off, so nothing changes for anyone until it is turned on. - Keys: an account's unlocked key lives in the Keychain (whenUnlockedThisDeviceOnly, no iCloud sync) as cloud-e2ee:<base URL>#<email>, behind the secure-storage ready gate. Signing out removes every such key, and another account signing in removes all but its own. One CloudE2eeKeyring per signed-in credential finds each vault's cipher. - Client: every request carries X-ZenNotes-Encryption: zne1, now that the transport forwards request headers, and listings show an encrypted vault's real name while unlocked and the placeholder otherwise. - Downloads: an encrypted reference downloads as ciphertext checked against its own hash and length; readHeader and the vault cipher check the header and derive the object key; native decryptFile opens it into staging; and the plaintext is checked against the reference before it can publish. The ciphertext is deleted in every path, and a missing key or native chunk loop refuses before anything downloads. - Uploads: inline or direct is decided by the ciphertext length. A large file is sealed natively into a private temp under .zennotes/sync/uploads, its plaintext hash checked against the one sync planned with, and uploaded with directUploadFields; the temp is removed in every path. A file whose bytes are already in memory is sealed there. Conflicts come back under their real paths. - Backups: an encrypted vault's backups are created unlabeled, since a label would reach the service readable, and its archive download is refused with a pointer to the desktop app, which decrypts it. - Settings: status, setup, unlock, a new passphrase or recovery key, lock, and creating an encrypted vault, through shared-domain's cloud-e2ee-account functions. Linking an encrypted vault while locked is refused. Moving a readable vault into an encrypted one stays a desktop action. - Sign-out: Disconnect asks the service to revoke this device (DELETE /api/v1/devices/current; a 401 counts as done), so the iPhone no longer stays listed under Connected apps. The shared flow bounds the wait and signs out whatever the answer. 20 new tests cover the key store, account switch and revoke; the staging decrypt path against a real vault cipher, with an independent node:crypto chunk loop standing in for CryptoKit; sealed direct uploads that the vault key opens back; client headers and names; and backup labels and the archive refusal. On a scratch simulator against a local service, the app signed in through Safari, refused a wrong passphrase, unlocked, pulled a desktop-encrypted vault byte for byte (two attachments through the native decrypt path), pushed edits and a 6 MB file that the service holds only as ciphertext, refused to sync while locked, and unlocked again.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
1.23.0 (build 39) on core 2.67.0.
npm run test:native-e2ee../uuid, and a guarded boot-time polyfill covers dependencies.Local: every commit passes
npm run upstreamand the full test suite on its own (221, 226, 226 and 246 tests) plusnpm run test:native-e2ee; the Vite build, Capacitor sync and the App Debug build for a generic simulator pass, and the app and both extensions report 1.23.0 (39). On a scratch simulator against a local service, the app signed in through Safari, refused a wrong passphrase, unlocked, pulled a desktop-encrypted vault byte for byte, pushed edits and a 6 MB file that the service holds only as ciphertext, refused to sync while locked, and unlocked again.