Skip to content

Release 1.23.0 (build 39): Cloud sync on iOS 15.0 to 15.3, Disconnect ends this iPhone's access - #57

Merged
adibhanna merged 5 commits into
mainfrom
release/1.23.0
Oct 12, 2026
Merged

adibhanna merged 5 commits into
mainfrom
release/1.23.0

Conversation

@adibhanna

Copy link
Copy Markdown
Contributor

1.23.0 (build 39) on core 2.67.0.

  • bd949bf: native ZNE1 chunk cipher on ZenDirectUpload (encryptFile, decryptFile, readHeader), checked byte for byte against the shared vectors by npm run test:native-e2ee.
  • 03cde49: Cloud sync makes its ids on iOS 15.0 to 15.3 (crypto.randomUUID needs WebKit 15.4). The bridge's calls go through ./uuid, and a guarded boot-time polyfill covers dependencies.
  • 2f94377: core-2.67.0-core.hb828dd60259bc376 (CI-built by boundary run 38278029411 from ZenNotes/zennotes 58ba24ac, the v2.67.0 tag commit); the lockfile moves the three @ZenNotes pins and adds hash-wasm 4.12.0, @scure/bip39 2.4.0 and @noble/hashes 2.4.0.
  • 660e85f: end-to-end encrypted vaults wired into Cloud sync, keys, Settings and sign-out, including Disconnect revoking this device on the service. Encryption stays hidden until the service advertises it, and the service has it off.
  • d9e013d: version 1.23.0, build 39.

Local: every commit passes npm run upstream and the full test suite on its own (221, 226, 226 and 246 tests) plus npm 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.

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.
@adibhanna
adibhanna merged commit d9e013d into main Oct 12, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant