Repository navigation
Conversation
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.
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.
ZenNotes 2.67.0.
Fixes
DELETE /api/v1/devices/current, website4b8f1cb) 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 sharedCloudAuthFlowtakes an optionalrevokefor the phone apps.crypto.randomUUID(missing before WebKit 15.4, so iOS 15.0 to 15.3):randomUUID()in shared-domain falls back tocrypto.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)
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).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-wasm4.12.0,@scure/bip392.4.0.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.end_to_end_encryptioncapability, which it does not untilCLOUD_E2EEis 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-gcmrows), 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=highclean (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, bundledzn v0.6.3inResources/zn-cli(drwxr-xr-x) and the Quick Look extension;hash-wasmand@scure/bip39are bundled intoout/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.