Skip to content

ZenNotes 2.67.0 - #924

Merged
adibhanna merged 8 commits into
mainfrom
v2.67.0
Oct 12, 2026
Merged

adibhanna merged 8 commits into
mainfrom
v2.67.0

Conversation

@adibhanna

@adibhanna adibhanna commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

ZenNotes 2.67.0.

Fixes

  • Disconnect in Settings → Cloud now revokes the device on the service (DELETE /api/v1/devices/current, website 4b8f1cb) before it deletes the token, so the computer leaves Connected apps on zennotes.org. A failure, a 401 or five seconds without an answer still signs out. The shared CloudAuthFlow takes an optional revoke for the phone apps.
  • Shared core ids no longer need crypto.randomUUID (missing before WebKit 15.4, so iOS 15.0 to 15.3): randomUUID() in shared-domain falls back to crypto.getRandomValues, and Cloud sync, backup restores, the conflict queue and the portable database and filesystem operations use it. No desktop change.

End-to-end encryption for Cloud vaults (ships dark)

  • Spec and cross-platform vectors: docs/specs/cloud-e2ee.md, docs/specs/cloud-e2ee-vectors.json (ZNE1: AES-256-GCM in 64 KiB chunks, per-object HKDF keys with key commitment, Argon2id passphrase, 24-word recovery key, HMAC path ids, sealed item metadata and vault names).
  • shared-domain: the ZNE1 format, keys and recovery words (cloud-e2ee.ts), the per-vault cipher (cloud-e2ee-vault.ts), setup, unlock, passphrase change and new recovery key (cloud-e2ee-account.ts), and the sync API, coordinator and engine sealing encrypted vaults on the way out and opening them on the way back. New dependencies, bundled: hash-wasm 4.12.0, @scure/bip39 2.4.0.
  • Desktop main keeps the account key in the OS keychain (cloud-e2ee:<base>#<email>, removed on sign-out), seals large uploads into a 0700 temp file, verifies then decrypts downloads, rewrites backup archives readable item by item, and moves a readable vault to a new encrypted one (supersede). The secret store now writes atomically with 0600 and moves an unreadable file aside instead of resetting it.
  • app-core: Settings → Cloud → End-to-end encryption (setup with the recovery words and a three-word check, unlock, change passphrase, new recovery key, lock), "End-to-end encrypt this vault" when creating one, "Encrypt this vault" for a linked one, and a help entry.
  • Everything stays hidden unless the service reports the end_to_end_encryption capability, which it does not until CLOUD_E2EE is switched on (ZenNotes/website#67 deploys off). The switch waits for the iPhone and Android updates that support it.

Cycle verification: a live run of two desktop services against a local copy of the website with encryption on (MinIO for object storage): setup, an encrypted vault with a 6 MB direct upload and 300 KB reference downloads, a second device refused with a wrong passphrase then unlocked, rename, edit, unlabeled backup, search and readable archive download, moving a readable vault with a 410 for the old link, lock, and recovery-word unlock, with no readable marker in the database or the stored objects. The built app over CDP: setup words and the three-word check, an encrypted vault created and synced (path ids, sealed metadata, aes-gcm rows), lock, wrong passphrase, unlock. The sign-out revoke in the built app against a local website (revoked; a server that never answers signed out after 5.06 s with one request).

Local gates on 58ba24ac: typecheck 8/8 and test:run 6/6 with no turbo cache (shared-domain 1,974, app-core 3,140, quicklook 15, desktop 1,071), npm audit --omit=dev --audit-level=high clean (the same 15 low or moderate remain, in dompurify, fast-uri, ip-address, katex, smol-toml and sprintf-js). npm run pack: build:prod green, signed with no timestamp trouble, deep strict codesign OK, "skipped macOS notarization" next to the hook, bundled zn v0.6.3 in Resources/zn-cli (drwxr-xr-x) and the Quick Look extension; hash-wasm and @scure/bip39 are bundled into out/main (no bare imports left); packaged launch check: page target in 1.6 s reporting 2.67.0. Smoke suites on the built app: vim-editor, sidebar-vim and editor-improvements passed on their first run.

Disconnect in Settings → Cloud only forgot the credential on this
device. The device and its token stayed active on the service, listed
under Connected apps on the website with a working token until someone
removed them there: testing 2.66.0's publishing left three behind in
production, one for each connect and disconnect.

The desktop's CloudAuthManager now asks the service to revoke the
device (DELETE /api/v1/devices/current, ZenNotes/website 4b8f1cb) with
the stored token before it deletes the token, and the shared
CloudAuthFlow the phone apps use does the same through a new optional
revoke dependency. Signing out never depends on the answer: a failure,
a 401 (the token was already gone), or a service that has not answered
within five seconds still signs out, and the device then stays listed
on the website as before. The token goes only to an origin connect
would accept, the desktop refuses redirects, and a legacy
zennotes.laravel.cloud account asks zennotes.org, where its token moved.

Checked in the built app against a local copy of the website: Disconnect
revoked the device and deleted its token; against a server that accepts
and never answers, the app signed out after 5.06 seconds having sent
the one request. The phone apps get the revoke once they adopt this core
and pass revoke to CloudAuthFlow; until then they sign out as before.
…ry platform must match

ZenNotes Cloud stores what it syncs in a form the service can read. People
asked how their vaults are secured, and the honest answer was "not end to
end". This spec is the design the apps and the service now build to: notes,
attachments, file and folder names, and vault names are encrypted on the
devices with keys the service never holds, while sync, conflicts, backups,
Restore from date and quotas keep working on opaque bytes.

It fixes the byte format (ZNE1: a 74-byte header with a per-object salt and
a key commitment, AES-256-GCM in 64 KiB chunks with counter nonces), the key
hierarchy (an Argon2id passphrase key and a 24-word recovery key each wrap
one account key, which wraps a key per vault), path ids (an HMAC blind index
the server keeps unique case-insensitively), the wire changes, the rules the
service enforces, how each feature behaves on an encrypted vault, and the
rollout behind a service flag.

cloud-e2ee-vectors.json pins the exact bytes. The TypeScript core generates
it and checks it against an independent node:crypto implementation and
OpenSSL's Argon2id; the iPhone and Android native chunk loops test against
the same file.
The format, the key hierarchy and the recovery words from
docs/specs/cloud-e2ee.md, in shared-domain so the desktop app and both phone
apps run the same code.

Everything goes through the platforms' own primitives (WebCrypto AES-GCM,
HKDF and HMAC); Argon2id comes from hash-wasm, the reference C
implementation compiled to WebAssembly, and the 24 words from @scure/bip39.
Both are pinned exactly. Decryption checks the key commitment before it
decrypts anything, so a wrong passphrase is reported as such and a crafted
ciphertext cannot be used to test guesses; streams only ever yield chunks
that authenticated.

The tests check RFC 5869 and RFC 4231 vectors, the BIP-39 reference words,
Argon2id against OpenSSL 3.6, every chunk size against a second
implementation written on node:crypto's own API, and every tamper case the
format must refuse.
…m on the way back

The sync coordinator compares plaintext hashes to find changes, renames and
conflicts, so encryption lives one layer below it: CloudSyncApiClient takes
a cipher per vault and turns the coordinator's plaintext-shaped items into
path ids, sealed metadata and ciphertext, and back. Change detection,
rename detection, merges and conflict previews behave exactly as they do
for a readable vault.

- An encrypted vault never syncs without its cipher. A locked device stops
  with a "locked" error before any request, never falling back to readable
  content.
- The engine attaches the moved file's details to every rename it plans, so
  the new path's metadata is sealed without another request; the client
  strips them for a readable vault, whose requests stay byte for byte as
  before.
- A large file's reference carries the ciphertext's size and hash next to
  the plaintext's, and the validators accept an aes-gcm download only for a
  reference that expects one.
- The account side (setup in two steps so nothing reaches the service
  before the recovery words are confirmed, unlock by passphrase or words,
  passphrase change, a new recovery key) and the keyring that finds each
  vault's cipher take their keychain and HTTP calls from the host.
- The client sends X-ZenNotes-Encryption only for hosts that opt in, so a
  phone running this core before its native paths decrypt is refused an
  encrypted vault instead of writing ciphertext into a vault folder.

Tests run a fake service that only sees what the client sends and check
that no note text, file name or plaintext hash leaves the client, that a
locked device sends nothing, and that content swapped between items or
metadata moved to another path is refused.
…s and streams large files sealed

The renderer never holds a key: setup, unlock, passphrase and recovery-key
changes, lock, and encrypted vault creation run in the main process behind
new IPC calls, and only the recovery words a person must see cross over.
The unlocked account key lives in the OS-protected secret store, scoped to
the account, and signing out deletes it.

Before that store holds a key it had to stop losing data: it rewrote its
file in place, treated an unreadable file as empty (the next save then
wiped every secret), wrote without 0600, and let two saves race. It now
writes a private temporary file, syncs and renames it, queues every change,
and moves an unreadable file aside instead of overwriting it.

- Files too large to send inline are sealed into a 0700 temporary file
  first, so the service learns the ciphertext's size and hash before the
  upload starts, and the plaintext is re-hashed on the way so a file that
  changed after sync hashed it is refused. Inline or direct is decided by
  the size that is actually sent.
- Large downloads check the network bytes against the ciphertext, decrypt
  while streaming, then check the plaintext as before.
- A backup download of an encrypted vault is rewritten item by item into a
  readable archive in the same format, never held in memory whole.
- "Encrypt this vault" moves a readable vault into a new encrypted one:
  sync, create, re-link, upload, and only then retire the readable vault.
  An interrupted move finishes on the next clean sync, and a device still
  linked to the old vault is told it is now end-to-end encrypted.
- Encrypted vaults keep their backups unlabeled, since a typed label would
  reach the service readable.
- The HTTP transport now forwards request headers; it was dropping the
  encryption header, which a test caught.

cloud-e2ee.live.test.ts drives two of these services against a running
service and object storage and scans both for readable markers; it skips
unless pointed at one.
… from Settings

Settings, Cloud gains an End-to-end encryption section, labeled beta, that
appears only when the service offers encryption to the account:

- Set up: a passphrase typed twice, then the 24-word recovery key with a
  copy button, a "saved it" checkbox, and three of its words typed back.
  The service hears nothing until those words match.
- Unlock this device with the passphrase or the recovery key, once per
  device; a wrong one is refused in place.
- Change passphrase, New recovery key, and Lock this device.

The vault section offers "End-to-end encrypt this vault" when creating a
vault and "Encrypt this vault" for a linked readable one, marks encrypted
vaults in the picker and the link line, and settings search finds the
section by encryption, passphrase or recovery key. The manual describes it.

Driving the built app showed errors arriving as "CloudE2eeError: ...": the
class-name filter in humanIpcError and the Cloud page's own copy allowed
letters only, so a digit in the name let it through. Both accept digits now.
…S 15.0 to 15.3

crypto.randomUUID arrived in WebKit 15.4, and the iPhone app still supports
iOS 15.0. The app has its own fallback for that, but the shared core called
crypto.randomUUID directly for Cloud sync's item and operation ids, backup
and note-restore idempotency keys, case-only rename detours, database row
ids, and the conflict queue's review id, so on those phones a sync, a
restore, or opening the conflict queue would throw.

random-uuid.ts uses crypto.randomUUID when it exists and otherwise builds a
version 4 UUID from getRandomValues, which every supported WebView has; the
five unguarded calls use it. The guarded ones elsewhere already fell back
and are left as they are.

The test hides crypto.randomUUID the way iOS 15.0 does and runs the sync id
source and a real backup restore; restoring the old backup code makes it
fail.
@adibhanna
adibhanna merged commit 58ba24a into main Oct 12, 2026
9 checks passed
@adibhanna
adibhanna deleted the v2.67.0 branch October 12, 2026 15:52
@adibhanna
adibhanna restored the v2.67.0 branch October 12, 2026 15:53
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