From 766efae6937174223922890640fea470e43ad9c1 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Fri, 9 Oct 2026 17:33:25 -0500 Subject: [PATCH 1/8] Fix(cloud): disconnecting the app ends its access on ZenNotes Cloud MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/desktop/src/main/cloud-auth.test.ts | 181 ++++++++++++++++-- apps/desktop/src/main/cloud-auth.ts | 48 ++++- .../shared-domain/src/cloud-auth-flow.test.ts | 112 +++++++++++ packages/shared-domain/src/cloud-auth-flow.ts | 42 ++++ 4 files changed, 371 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/main/cloud-auth.test.ts b/apps/desktop/src/main/cloud-auth.test.ts index a61c857d5..0aef4b49d 100644 --- a/apps/desktop/src/main/cloud-auth.test.ts +++ b/apps/desktop/src/main/cloud-auth.test.ts @@ -18,7 +18,10 @@ afterEach(async () => { ); }); -async function setup(allowInsecureLocalDevelopment = false) { +async function setup( + allowInsecureLocalDevelopment = false, + revokeTimeoutMs?: number, +) { const storageDirectory = await mkdtemp( path.join(os.tmpdir(), "zennotes-cloud-auth-"), ); @@ -44,6 +47,7 @@ async function setup(allowInsecureLocalDevelopment = false) { randomState: () => "fixed-state", randomVerifier: () => "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", allowInsecureLocalDevelopment, + revokeTimeoutMs, }); return { manager, @@ -166,18 +170,10 @@ describe("CloudAuthManager", () => { it("removes the stored credential when logging out", async () => { const { manager, secrets, fetchImplementation } = await setup(); - await manager.connect("https://zennotes.org"); + await connectAccount(manager, fetchImplementation); fetchImplementation.mockResolvedValueOnce( - new Response( - JSON.stringify({ - token: "secret-token", - user: { name: "Ada", email: "ada@example.com" }, - device: { id: "device-1", name: "Test Mac", platform: "desktop" }, - }), - { status: 200 }, - ), + new Response(null, { status: 204 }), ); - await manager.complete({ code: "OneTimeCode", state: "fixed-state" }); await expect(manager.logout()).resolves.toEqual({ state: "disconnected", @@ -207,6 +203,169 @@ describe("CloudAuthManager", () => { }); }); +async function connectAccount( + manager: CloudAuthManager, + fetchImplementation: ReturnType>, +): Promise { + await manager.connect("https://zennotes.org"); + fetchImplementation.mockResolvedValueOnce( + new Response( + JSON.stringify({ + token: "secret-token", + user: { name: "Ada", email: "ada@example.com" }, + device: { id: "device-1", name: "Test Mac", platform: "desktop" }, + }), + { status: 200 }, + ), + ); + await manager.complete({ code: "OneTimeCode", state: "fixed-state" }); + fetchImplementation.mockClear(); +} + +describe("CloudAuthManager logout revokes the device", () => { + it("asks the service with the token before deleting it", async () => { + const { manager, secrets, fetchImplementation, storageDirectory } = + await setup(); + await connectAccount(manager, fetchImplementation); + let tokenWhileRevoking: string | undefined; + fetchImplementation.mockImplementationOnce(async () => { + tokenWhileRevoking = secrets.get("https://zennotes.org"); + return new Response(null, { status: 204 }); + }); + + await expect(manager.logout()).resolves.toEqual({ + state: "disconnected", + account: null, + }); + + expect(fetchImplementation).toHaveBeenCalledOnce(); + const [url, init] = fetchImplementation.mock.calls[0]; + expect(url).toBe("https://zennotes.org/api/v1/devices/current"); + expect(init).toMatchObject({ + method: "DELETE", + redirect: "error", + headers: { + Accept: "application/json", + Authorization: "Bearer secret-token", + }, + }); + expect(init?.signal).toBeInstanceOf(AbortSignal); + expect(tokenWhileRevoking).toBe("secret-token"); + expect(secrets.size).toBe(0); + await expect( + readFile(path.join(storageDirectory, "cloud-account.json"), "utf8"), + ).rejects.toThrow(); + }); + + it("signs out when the service fails or cannot be reached", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + for (const answer of [ + () => Promise.reject(new TypeError("fetch failed")), + () => Promise.resolve(new Response(null, { status: 500 })), + ]) { + const { manager, secrets, fetchImplementation } = await setup(); + await connectAccount(manager, fetchImplementation); + fetchImplementation.mockImplementationOnce(answer); + + await expect(manager.logout()).resolves.toEqual({ + state: "disconnected", + account: null, + }); + expect(secrets.size).toBe(0); + } + expect(warn).toHaveBeenCalledTimes(2); + } finally { + warn.mockRestore(); + } + }); + + it("stops waiting for a service that never answers", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const { manager, secrets, fetchImplementation } = await setup(false, 20); + await connectAccount(manager, fetchImplementation); + fetchImplementation.mockImplementationOnce( + (_url, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => + reject(init.signal?.reason), + ); + }), + ); + + await expect(manager.logout()).resolves.toEqual({ + state: "disconnected", + account: null, + }); + expect(secrets.size).toBe(0); + expect(warn).toHaveBeenCalledOnce(); + } finally { + warn.mockRestore(); + } + }); + + it("takes an already revoked token (401) as done", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const { manager, secrets, fetchImplementation } = await setup(); + await connectAccount(manager, fetchImplementation); + fetchImplementation.mockResolvedValueOnce( + new Response(JSON.stringify({ message: "Unauthenticated." }), { + status: 401, + }), + ); + + await manager.logout(); + + expect(secrets.size).toBe(0); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); + + it("asks zennotes.org for a legacy account and skips an account with no token", async () => { + const { manager, storageDirectory, secrets, fetchImplementation } = + await setup(); + const legacyAccount = { + base_url: "https://zennotes.laravel.cloud", + user: { name: "Niri", email: "niri@example.com" }, + device: { id: "device-3", name: "Test Mac", platform: "desktop" }, + connected_at: "2026-08-12T09:00:00.000Z", + }; + await writeFile( + path.join(storageDirectory, "cloud-account.json"), + JSON.stringify(legacyAccount), + ); + secrets.set("https://zennotes.laravel.cloud", "legacy-token"); + fetchImplementation.mockResolvedValueOnce( + new Response(null, { status: 204 }), + ); + + await manager.logout(); + + expect(fetchImplementation).toHaveBeenCalledOnce(); + expect(fetchImplementation.mock.calls[0][0]).toBe( + "https://zennotes.org/api/v1/devices/current", + ); + expect(fetchImplementation.mock.calls[0][1]?.headers).toMatchObject({ + Authorization: "Bearer legacy-token", + }); + expect(secrets.size).toBe(0); + + fetchImplementation.mockClear(); + await writeFile( + path.join(storageDirectory, "cloud-account.json"), + JSON.stringify({ ...legacyAccount, base_url: "https://zennotes.org" }), + ); + + await manager.logout(); + + expect(fetchImplementation).not.toHaveBeenCalled(); + }); +}); + describe("normalizeCloudBaseUrl", () => { it("requires a clean HTTPS origin", () => { expect(normalizeCloudBaseUrl("https://zennotes.org/")).toBe( diff --git a/apps/desktop/src/main/cloud-auth.ts b/apps/desktop/src/main/cloud-auth.ts index c9876bcf6..39990e2d2 100644 --- a/apps/desktop/src/main/cloud-auth.ts +++ b/apps/desktop/src/main/cloud-auth.ts @@ -11,6 +11,7 @@ import type { CloudAuthDeepLinkRequest } from "./deep-links"; const PENDING_AUTH_FILE = "cloud-auth-pending.json"; const CLOUD_ACCOUNT_FILE = "cloud-account.json"; const AUTH_LIFETIME_MS = 5 * 60 * 1000; +const REVOKE_TIMEOUT_MS = 5_000; const PRODUCTION_CLOUD_BASE_URL = "https://zennotes.org"; const DEVELOPMENT_CLOUD_BASE_URL = PRODUCTION_CLOUD_BASE_URL; /** The Laravel Cloud origin 2.28.0 shipped with. The deployment moved to @@ -45,6 +46,7 @@ export interface CloudAuthManagerDependencies { randomState?: () => string; randomVerifier?: () => string; allowInsecureLocalDevelopment?: boolean; + revokeTimeoutMs?: number; } export class CloudAuthManager { @@ -197,7 +199,10 @@ export class CloudAuthManager { async logout(): Promise { const storedAccount = await this.readJson(CLOUD_ACCOUNT_FILE); const account = isCloudAccount(storedAccount) ? storedAccount : null; - if (account) await this.dependencies.deleteSecret(account.base_url); + if (account) { + await this.revokeDevice(account); + await this.dependencies.deleteSecret(account.base_url); + } await Promise.all([ this.removeFile(CLOUD_ACCOUNT_FILE), this.removeFile(PENDING_AUTH_FILE), @@ -205,6 +210,47 @@ export class CloudAuthManager { return { state: "disconnected", account: null }; } + /** Ask the service to revoke this device and every token it holds, as + * Connected apps on the website does, while the token still exists to ask + * with. Signing out never depends on the answer: offline, after a + * failure, or once the service has kept it waiting a few seconds, it + * signs out anyway, and the device stays listed on the website until it + * is removed there. A legacy account asks the origin its token moved to. */ + private async revokeDevice(account: CloudAccount): Promise { + try { + const token = await this.dependencies.getSecret(account.base_url); + if (!token) return; + const baseUrl = normalizeCloudBaseUrl( + account.base_url === LEGACY_CLOUD_BASE_URL + ? PRODUCTION_CLOUD_BASE_URL + : account.base_url, + this.dependencies.allowInsecureLocalDevelopment ?? false, + ); + const response = await this.fetchImplementation( + `${baseUrl}/api/v1/devices/current`, + { + method: "DELETE", + headers: { + Accept: "application/json", + Authorization: `Bearer ${token}`, + }, + redirect: "error", + signal: AbortSignal.timeout( + this.dependencies.revokeTimeoutMs ?? REVOKE_TIMEOUT_MS, + ), + }, + ); + // 401 means the token is already gone, which is what was asked for. + if (!response.ok && response.status !== 401) { + console.warn( + `[cloud] sign-out could not revoke this device (HTTP ${response.status})`, + ); + } + } catch (error) { + console.warn("[cloud] sign-out could not revoke this device", error); + } + } + /** Move a legacy-origin account to the production origin: rewrite the * stored base_url and re-key the credential. The token is deleted from the * old key only after it is safely stored under the new one; if any step diff --git a/packages/shared-domain/src/cloud-auth-flow.test.ts b/packages/shared-domain/src/cloud-auth-flow.test.ts index 6b3a9d54a..8a83ab5f7 100644 --- a/packages/shared-domain/src/cloud-auth-flow.test.ts +++ b/packages/shared-domain/src/cloud-auth-flow.test.ts @@ -165,6 +165,118 @@ describe('CloudAuthFlow', () => { }) }) +describe('CloudAuthFlow logout revokes the device', () => { + const account: CloudAccount = { + base_url: 'https://zennotes.org', + user: { name: 'Ada', email: 'ada@example.com' }, + device: { id: 'device-1', name: 'Ada’s iPhone', platform: 'ios' }, + connected_at: '2026-08-10T12:00:00.000Z' + } + + function setupRevoke( + revoke: (baseUrl: string, token: string) => Promise, + baseUrl = account.base_url + ) { + const storage = new MemoryCloudAuthStorage() + storage.credential = { + base_url: baseUrl, + token: 'secret-token', + account: { ...account, base_url: baseUrl } + } + const flow = new CloudAuthFlow({ + platform: 'ios', + appVersion: '1.5.0', + deviceName: 'Ada’s iPhone', + storage, + openExternal: vi.fn(async () => {}), + exchange: vi.fn(async () => ({})), + revoke + }) + return { flow, storage } + } + + it('asks the service with the stored token before deleting the credential', async () => { + let credentialWhileRevoking: unknown = 'not called' + const { flow, storage } = setupRevoke(async () => { + credentialWhileRevoking = storage.credential + }) + + await expect(flow.logout()).resolves.toEqual({ state: 'disconnected', account: null }) + + expect(credentialWhileRevoking).toMatchObject({ token: 'secret-token' }) + expect(storage.credential).toBeNull() + }) + + it('passes the stored origin and token', async () => { + const revoke = vi.fn(async () => {}) + const { flow } = setupRevoke(revoke) + + await flow.logout() + + expect(revoke).toHaveBeenCalledExactlyOnceWith('https://zennotes.org', 'secret-token') + }) + + it('still signs out when the service refuses or cannot be reached', async () => { + for (const failure of [ + async () => { + throw new Error('offline') + }, + () => { + throw new Error('thrown before any promise') + } + ]) { + const { flow, storage } = setupRevoke(failure as (baseUrl: string, token: string) => Promise) + + await expect(flow.logout()).resolves.toEqual({ state: 'disconnected', account: null }) + expect(storage.credential).toBeNull() + } + }) + + it('stops waiting for a service that never answers', async () => { + vi.useFakeTimers() + try { + const { flow, storage } = setupRevoke(() => new Promise(() => {})) + + const logout = flow.logout() + await vi.advanceTimersByTimeAsync(4_999) + expect(storage.credential).not.toBeNull() + await vi.advanceTimersByTimeAsync(1) + + await expect(logout).resolves.toEqual({ state: 'disconnected', account: null }) + expect(storage.credential).toBeNull() + } finally { + vi.useRealTimers() + } + }) + + it('never sends the token to an origin it would not connect to', async () => { + const revoke = vi.fn(async () => {}) + const { flow, storage } = setupRevoke(revoke, 'http://zennotes.org') + + await expect(flow.logout()).resolves.toEqual({ state: 'disconnected', account: null }) + + expect(revoke).not.toHaveBeenCalled() + expect(storage.credential).toBeNull() + }) + + it('has nothing to revoke while a sign-in is only pending', async () => { + const revoke = vi.fn(async () => {}) + const { flow, storage } = setupRevoke(revoke) + storage.credential = null + storage.pending = { + base_url: 'https://zennotes.org', + state: 'fixed-state', + code_verifier: 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk', + expires_at: '2999-01-01T00:00:00.000Z' + } + + await expect(flow.logout()).resolves.toEqual({ state: 'disconnected', account: null }) + + expect(revoke).not.toHaveBeenCalled() + expect(storage.pending).toBeNull() + }) +}) + describe('cloud auth input validation', () => { it('derives the RFC 7636 S256 challenge from a verifier', async () => { await expect( diff --git a/packages/shared-domain/src/cloud-auth-flow.ts b/packages/shared-domain/src/cloud-auth-flow.ts index c7ff123d8..4a1a2625a 100644 --- a/packages/shared-domain/src/cloud-auth-flow.ts +++ b/packages/shared-domain/src/cloud-auth-flow.ts @@ -5,6 +5,7 @@ import type { } from '@zennotes/bridge-contract/cloud-sync' const AUTH_LIFETIME_MS = 5 * 60 * 1000 +const REVOKE_TIMEOUT_MS = 5_000 export interface CloudAuthPending { base_url: string @@ -53,6 +54,10 @@ export interface CloudAuthFlowDependencies { storage: CloudAuthStorage openExternal(url: string): Promise exchange(baseUrl: string, request: CloudAuthExchangeRequest): Promise + /** Revoke this device and every token it holds on the service + * (`DELETE /api/v1/devices/current`, authenticated with `token`). Logout + * calls it before deleting the credential; see `revokeDevice`. */ + revoke?(baseUrl: string, token: string): Promise now?: () => Date randomState?: () => string randomVerifier?: () => string @@ -175,6 +180,7 @@ export class CloudAuthFlow { } async logout(): Promise { + await this.revokeDevice() await Promise.all([ this.dependencies.storage.deleteCredential(), this.dependencies.storage.deletePending() @@ -182,6 +188,42 @@ export class CloudAuthFlow { return { state: 'disconnected', account: null } } + /** + * Ask the service to revoke this device while the credential still exists + * to ask with; once it is deleted, only Connected apps on the website can + * end the token. Signing out never depends on the answer: a failure, or a + * service that has not answered within a few seconds, still signs out, and + * the device then stays listed on the website until it is removed there. + * The token goes only to an origin `connect` would accept. + */ + private async revokeDevice(): Promise { + const revoke = this.dependencies.revoke + if (!revoke) return + + let timer: ReturnType | undefined + try { + const credential = await this.credential() + if (!credential) return + const baseUrl = normalizeCloudBaseUrl(credential.base_url, { + allowInsecureLoopback: this.dependencies.allowInsecureLoopback, + allowedInsecureOrigins: this.dependencies.allowedInsecureOrigins + }) + const request = Promise.resolve() + .then(() => revoke(baseUrl, credential.token)) + .catch(() => undefined) + await Promise.race([ + request, + new Promise((resolve) => { + timer = setTimeout(resolve, REVOKE_TIMEOUT_MS) + }) + ]) + } catch { + // An unreadable credential or a refused origin still signs out. + } finally { + if (timer !== undefined) clearTimeout(timer) + } + } + private async pending(): Promise { const stored = await this.dependencies.storage.loadPending() if (stored === null || stored === undefined) return null From 7f94bd534aae20f4b80355948378dd7498283551 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 12 Oct 2026 10:10:08 -0500 Subject: [PATCH 2/8] Docs(specs): end-to-end encryption for Cloud vaults, with vectors every 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. --- docs/specs/cloud-e2ee-vectors.json | 217 +++++++++++++++++++++++++ docs/specs/cloud-e2ee.md | 250 +++++++++++++++++++++++++++++ 2 files changed, 467 insertions(+) create mode 100644 docs/specs/cloud-e2ee-vectors.json create mode 100644 docs/specs/cloud-e2ee.md diff --git a/docs/specs/cloud-e2ee-vectors.json b/docs/specs/cloud-e2ee-vectors.json new file mode 100644 index 000000000..80dc92115 --- /dev/null +++ b/docs/specs/cloud-e2ee-vectors.json @@ -0,0 +1,217 @@ +{ + "format": "ZNE1", + "spec": "docs/specs/cloud-e2ee.md", + "notes": "Generated by packages/shared-domain/src/cloud-e2ee.test.ts with UPDATE_E2EE_VECTORS=1, and checked there against an independent node:crypto implementation and OpenSSL 3.6 Argon2id. Plaintext for objects is byte i = i mod 251.", + "argon2id": [ + { + "passphrase": "password", + "password_hex": "70617373776f7264", + "salt_hex": "736f6d6573616c74", + "memory_kib": 65536, + "iterations": 2, + "parallelism": 1, + "output_hex": "09316115d5cf24ed5a15a31a3ba326e5cf32edc24702987c02b6566f61913cf7" + }, + { + "passphrase": "correct horse battery staple", + "password_hex": "636f727265637420686f727365206261747465727920737461706c65", + "salt_hex": "000102030405060708090a0b0c0d0e0f", + "memory_kib": 65536, + "iterations": 3, + "parallelism": 4, + "output_hex": "853b272a44db1421c02962669a55eb0994f3cab385ed1c4c79253eee19bab49e" + }, + { + "passphrase": "Ångström ☕ file", + "password_hex": "c3856e67737472c3b66d20e298952066696c65", + "salt_hex": "000102030405060708090a0b0c0d0e0f", + "memory_kib": 65536, + "iterations": 3, + "parallelism": 4, + "output_hex": "557b069f56e082ae2aecd39bbb2513634b549ab4215fdbf02974607c65e5462d" + } + ], + "objects": [ + { + "purpose": 1, + "key_version": 1, + "parent_key_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "salt_hex": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f", + "context_hex": "", + "plaintext_length": 0, + "object_key_hex": "6f8af0826a25068000e30dda372bf04bf313f10a2ae5d6b8de07f142aa91c95d", + "aad_hex": "54dd60d0bdc522e759214e6e4386f625bc0cc17dadcd8f1ec76592a8537fd4d6", + "header_hex": "5a4e4531010100000001404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f076aa4bb4461d7cc5c300560ccbb47f7e16106567e3641fa32b7b155b01f22f5", + "ciphertext_length": 90, + "ciphertext_sha256": "406d8d367c9f4584b8947bc9bcfbe61654e601b60450b343a437cdc54ec802b7", + "ciphertext_hex": "5a4e4531010100000001404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f076aa4bb4461d7cc5c300560ccbb47f7e16106567e3641fa32b7b155b01f22f58dd6859d5d96c76debf161861795b2b8" + }, + { + "purpose": 1, + "key_version": 1, + "parent_key_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "salt_hex": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f", + "context_hex": "", + "plaintext_length": 1, + "object_key_hex": "6f8af0826a25068000e30dda372bf04bf313f10a2ae5d6b8de07f142aa91c95d", + "aad_hex": "54dd60d0bdc522e759214e6e4386f625bc0cc17dadcd8f1ec76592a8537fd4d6", + "header_hex": "5a4e4531010100000001404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f076aa4bb4461d7cc5c300560ccbb47f7e16106567e3641fa32b7b155b01f22f5", + "ciphertext_length": 91, + "ciphertext_sha256": "7a077f57ff7045dbdded6c9132488da107b6e3ccbe9a7640b16367dbecfc1cb1", + "ciphertext_hex": "5a4e4531010100000001404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f076aa4bb4461d7cc5c300560ccbb47f7e16106567e3641fa32b7b155b01f22f5a98d5b8c638f404c1965f233907b8e2329" + }, + { + "purpose": 1, + "key_version": 1, + "parent_key_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "salt_hex": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f", + "context_hex": "", + "plaintext_length": 65535, + "object_key_hex": "6f8af0826a25068000e30dda372bf04bf313f10a2ae5d6b8de07f142aa91c95d", + "aad_hex": "54dd60d0bdc522e759214e6e4386f625bc0cc17dadcd8f1ec76592a8537fd4d6", + "header_hex": "5a4e4531010100000001404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f076aa4bb4461d7cc5c300560ccbb47f7e16106567e3641fa32b7b155b01f22f5", + "ciphertext_length": 65625, + "ciphertext_sha256": "58730a31a5351d8fa5282c8d868a6258b6eb54dadc26f7a6f94113402149cb02" + }, + { + "purpose": 1, + "key_version": 1, + "parent_key_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "salt_hex": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f", + "context_hex": "", + "plaintext_length": 65536, + "object_key_hex": "6f8af0826a25068000e30dda372bf04bf313f10a2ae5d6b8de07f142aa91c95d", + "aad_hex": "54dd60d0bdc522e759214e6e4386f625bc0cc17dadcd8f1ec76592a8537fd4d6", + "header_hex": "5a4e4531010100000001404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f076aa4bb4461d7cc5c300560ccbb47f7e16106567e3641fa32b7b155b01f22f5", + "ciphertext_length": 65626, + "ciphertext_sha256": "a8dd2a58944e98bbb8ded713ad9c399037e484ed49a27516eccb33d0795382b2" + }, + { + "purpose": 1, + "key_version": 1, + "parent_key_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "salt_hex": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f", + "context_hex": "", + "plaintext_length": 65537, + "object_key_hex": "6f8af0826a25068000e30dda372bf04bf313f10a2ae5d6b8de07f142aa91c95d", + "aad_hex": "54dd60d0bdc522e759214e6e4386f625bc0cc17dadcd8f1ec76592a8537fd4d6", + "header_hex": "5a4e4531010100000001404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f076aa4bb4461d7cc5c300560ccbb47f7e16106567e3641fa32b7b155b01f22f5", + "ciphertext_length": 65643, + "ciphertext_sha256": "51e01f7545c6b91251d8161beaf97f6e5584f9f0f0743edb7c03000343ab598e" + }, + { + "purpose": 1, + "key_version": 1, + "parent_key_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "salt_hex": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f", + "context_hex": "", + "plaintext_length": 131072, + "object_key_hex": "6f8af0826a25068000e30dda372bf04bf313f10a2ae5d6b8de07f142aa91c95d", + "aad_hex": "54dd60d0bdc522e759214e6e4386f625bc0cc17dadcd8f1ec76592a8537fd4d6", + "header_hex": "5a4e4531010100000001404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f076aa4bb4461d7cc5c300560ccbb47f7e16106567e3641fa32b7b155b01f22f5", + "ciphertext_length": 131178, + "ciphertext_sha256": "b4abfea6809249a8cef96cc2c046a3567f4dc4c834e77e7dfc4ecd8eeb37ba6e" + }, + { + "purpose": 1, + "key_version": 1, + "parent_key_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "salt_hex": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f", + "context_hex": "", + "plaintext_length": 200000, + "object_key_hex": "6f8af0826a25068000e30dda372bf04bf313f10a2ae5d6b8de07f142aa91c95d", + "aad_hex": "54dd60d0bdc522e759214e6e4386f625bc0cc17dadcd8f1ec76592a8537fd4d6", + "header_hex": "5a4e4531010100000001404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f076aa4bb4461d7cc5c300560ccbb47f7e16106567e3641fa32b7b155b01f22f5", + "ciphertext_length": 200138, + "ciphertext_sha256": "fc64aa074ad12237fb34aa10c5c7233f9e0e966b257c859b39eab73f9f13ae9d" + } + ], + "metadata_object": { + "purpose": 2, + "key_version": 1, + "parent_key_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "salt_hex": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f", + "context_hex": "66626437653635653338373931663063366334353938643130386233653534323532383039333033326635396163613833613934323633376265633831366434", + "plaintext_length": 256, + "object_key_hex": "a48338e6ed50b692c9d5941f6ae47852edfcccaa7d21772cef19d613403ed381", + "aad_hex": "147fdebf075db58a5a92923c85aba132a7f85c1dc8dda1c4b9393b9ae7bc8d88", + "header_hex": "5a4e4531010200000001404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f737941db973616cfcc7b549c02f34cf3312d1a8c994e9addfa96f388fe26404a", + "ciphertext_length": 346, + "ciphertext_sha256": "b589793da30135a4c1c868d00e1cc6cffbf7e57c74d09725e8b18a0e063594ca", + "ciphertext_hex": "5a4e4531010200000001404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f737941db973616cfcc7b549c02f34cf3312d1a8c994e9addfa96f388fe26404aa9795acaed813a74477b937f9f33e27256bd5eee12dec43d814a28a512f4077921a65ed6447278213d6ed03c82b17343f00bc5154a2dba50c2e91ce4857653e582d4afbf6df08a17718949d0a7a4ad9388011cba476084aac55a83b2da8f98c5cac509b526eb7fef73836700adc7ec991f720d225a6d4668fc6ee14f589a45ce9e0f8ca59659e95a9a740ab9d426ea453f682c50d1ad74b5d6f062341ce60fef29a0a88ad78844ee56ad077d3ff835bd3f4c8618b0d30951012132c6d6bd15df2ff6242d25e1b1fc7225472dd24472cf0ea01617d48958b7e518d0e4419f502d8528e27223fee930504f69116f02e41b835d7fd7c1ca1df2e896e2be16a31e132dc1e2b5dd1c358fe3d7549f33a25661" + }, + "path_index": { + "vault_key_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "path_index_key_hex": "98cc5742f6f9b05a6d1e8267e9389e3dccc872309f555e2869e0510fc21c89d6", + "paths": [ + { + "path": "Notes/Ideas.md", + "canonical": "notes/ideas.md", + "path_id": "fbd7e65e38791f0c6c4598d108b3e542528093032f59aca83a942637bec816d4" + }, + { + "path": "notes\\IDEAS.md", + "canonical": "notes/ideas.md", + "path_id": "fbd7e65e38791f0c6c4598d108b3e542528093032f59aca83a942637bec816d4" + }, + { + "path": "Journal/2026/Café.md", + "canonical": "journal/2026/café.md", + "path_id": "b07d484e7d0367ecb5be82d7b82047024443d3a875a3c1d39ed02f4321cb9069" + }, + { + "path": "Journal/2026/Café.md", + "canonical": "journal/2026/café.md", + "path_id": "b07d484e7d0367ecb5be82d7b82047024443d3a875a3c1d39ed02f4321cb9069" + }, + { + "path": ".zennotes/vault.json", + "canonical": ".zennotes/vault.json", + "path_id": "efd4756ab4f4f2d063f6bc863b2a7aa8929ffae30550071e4f6fc0469cccde20" + } + ] + }, + "item_meta": { + "vault_key_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "vault_key_version": 1, + "path_id": "fbd7e65e38791f0c6c4598d108b3e542528093032f59aca83a942637bec816d4", + "salt_hex": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f", + "meta": { + "path": "Notes/Ideas.md", + "kind": "text", + "encoding": "utf8", + "media_type": "text/markdown", + "sha256": "25930e05a7c60415fd345391c29a9c525e7489f597e57c2e8ab5383adac4f7b3", + "byte_length": 8 + }, + "envelope_base64": "Wk5FMQECAAAAAUBBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fc3lB25c2Fs/Me1ScAvNM8zEtGoyZTprd+pbziP4mQErSWi7r07UQUT8T7RyxBM4zKdgpjimCtk/4IBzTass1RGruEpFCbXxycD+ONYK+OAKjVZNPEH+uXdilUrmBaUH4r/CJlUjquClJpSGhyZ2GpKx/I4hhXraS6m37xaShrvuYkV30eKwrvSLZPQ7xn+PBDDVPZR8tVnu3I64HHdZYgyfvN0Un7lq4Jcm4Cz6eXf3KzImgd13aQyxcy5ezGvUR6jVsHhEewGXcZtSi9gr3d+GaQMMmXIebmbioXUogi0DPF8bOwQRXG5rMrcY+qZwg/lHk5CB8rkAd4SofvWKu0kXpILHnOy/3mIaj2qPPKtRTjK0EFR/LJTBPOJ43gT03MDpqG1UWNS3/F2+PpYmDbQ==" + }, + "vault_name": { + "vault_key_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "name": "Journal 2026", + "salt_hex": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f", + "envelope_base64": "Wk5FMQEDAAAAAUBBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fLHiyx6Si/QG8HgqhrnakhROrzQGoW0bmmR3pOy7KE8cFzF7OIzXEj9s0CPedczuK4sRBmrMwYxo0Mp1vEpX8yIUsff/YbKqAWpeRSdY96fMXRrKak6NE6b9q128plpb5Ghofauz76o1iNcbuwBoILA==" + }, + "envelopes": { + "account_key_hex": "a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf", + "passphrase_key_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "recovery_key_hex": "9e885d952ad362caeb4efe34a8e91bd2a3cbd94c7bd5b1e8f3cc1e3d7b3c6bc1", + "salt_hex": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f", + "by_passphrase_base64": "Wk5FMQEFAAAAAEBBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fTcKaw5mYUi6jvInv0VeQbb2aczLuYJiqSNcUhBl+wfqP8ncFd1B2KNFfUTdsQW+2pYCCzYUWvXEtJ/2VQ3j2DrdpDzlXCB//A2VjvdgnWIM=", + "by_recovery_base64": "Wk5FMQEFAAAAAEBBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fDvUpUsGPBPmL32+3YbtBfdC9rcqIN8cQkb74Q2nShJlbfYZ2yZF8eS3/XGjfwGODNdj3h5CPBK+9HBtMjGYb2QRNiVOdlOQ3M9b2zvNmw/w=", + "vault_key_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "vault_key_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "account_key_version": 1, + "vault_key_base64": "Wk5FMQEEAAAAAUBBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fU3gAU5SrZkJv67iKTtagJMnXKlTwBMiAHNA1wWIfj7L60vcJFbd5oBHBkIOhTWmSya44pyg5D1oIJEGkIYgD0KcXYKkOitPn65lhTorwT30=" + }, + "recovery_words": [ + { + "entropy_hex": "0000000000000000000000000000000000000000000000000000000000000000", + "words": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art" + }, + { + "entropy_hex": "9e885d952ad362caeb4efe34a8e91bd2a3cbd94c7bd5b1e8f3cc1e3d7b3c6bc1", + "words": "ozone drill grab fiber curtain grace pudding thank cruise elder eight pipe device uncover cradle kingdom rapid elegant vessel detect stumble owner pyramid cupboard" + } + ] +} diff --git a/docs/specs/cloud-e2ee.md b/docs/specs/cloud-e2ee.md new file mode 100644 index 000000000..eed77ca0f --- /dev/null +++ b/docs/specs/cloud-e2ee.md @@ -0,0 +1,250 @@ +# Spec: Cloud end-to-end encryption + +> Status: design, October 12, 2026. Nothing here has shipped. The maintainer decided on October 12: encrypt note text, attachments, file and folder names, and vault names; ship as an opt-in beta first, then make it the default for new vaults; give every user a recovery key; launch the public security page together with the beta. + +## Objective + +ZenNotes Cloud stores what it syncs in a form the service can read. This spec makes Cloud vaults end-to-end encrypted: notes, attachments, file and folder names, and vault names are encrypted on the user's devices with keys the service never holds. The server keeps doing what it does today (sync, conflicts, backups, Restore from date, quotas) on opaque bytes. + +The feature is complete when a user can turn encryption on, move an existing vault over, unlock a second device with a passphrase or the recovery key, and sync, back up and restore from all three apps, while the server holds no readable note content, file name, or vault name for that vault. + +## What the service can and cannot see + +| Data | Encrypted vault | +| --- | --- | +| Note text and attachment bytes | Cannot see | +| File and folder names, vault names | Cannot see | +| Hashes of note content | Cannot see | +| Account email, sign-in method, billing | Can see, needed to run the account | +| Number of vaults and items, each item's encrypted size (close to its real size), revision counts | Can see | +| When items change, and from which connected device | Can see | +| Device names and platforms, IP addresses in request logs | Can see | +| That two items' paths differ only in letter case | Can see (their path ids collide, by design) | + +Non-goals for v1: hiding sizes (content is not padded), hiding access patterns, protecting a compromised device, and detecting a server that withholds or rolls back items (that needs a signed vault manifest; see Later). + +## Threat model + +Encryption protects against anyone who reads the service's database, object storage, backups, recovery copies or logs: ZenNotes staff, hosting providers, an attacker who breaches either, or a legal demand. They get ciphertext and the metadata above. + +A malicious server can refuse service, delete data, roll an item back to an older revision, hide items, and guess the passphrase offline against the stored keyset at Argon2id cost per guess. It cannot read or forge content, move content between files, attach one file's metadata to another path, or swap vault keys between vaults without the client noticing. + +Out of scope: an unlocked or compromised device (the vault on disk is plain Markdown by design), malware in the app process, and shoulder surfing. + +## Keys + +``` +passphrase --Argon2id--> PK ------- wraps --> AK (account key, 32 random bytes) +recovery key (32 random bytes) -- wraps ----> AK +AK ------------------------------ wraps ----> VK (one per vault, 32 random bytes) +VK -- parent of --> content objects, item metadata, the vault name +VK -- HKDF -------> K_path (path index key) +``` + +- PK = Argon2id(password = UTF-8 of the NFKC-normalized passphrase, salt = 16 random bytes, memory = 65536 KiB, iterations = 3, parallelism = 4, version 0x13, output 32 bytes). The parameters live in the keyset so they can be raised later. +- AK, every VK and the recovery key are 32 bytes from the platform CSPRNG. +- Changing the passphrase re-wraps AK. A new recovery key re-wraps AK. Nothing else is re-encrypted. +- Devices keep the unwrapped AK in the platform keychain: Electron safeStorage on desktop, a Keychain item with `WhenUnlockedThisDeviceOnly` on iOS, the Android Keystore. Vault keys are unwrapped when needed and kept in memory. Signing out deletes AK from the device. + +The passphrase is separate from the account password. Accounts that sign in with Google have no password, and the server receives the account password at every sign-in, so it cannot double as the secret. + +## Algorithms + +- AES-256-GCM with 96-bit nonces and 128-bit tags (WebCrypto, node:crypto, CryptoKit, javax.crypto). +- HKDF-SHA256 (RFC 5869), HMAC-SHA256, SHA-256. +- Argon2id (RFC 9106) through hash-wasm, the reference C implementation compiled to WebAssembly, so every platform runs the same code. +- Recovery keys use the BIP-39 English word list through @scure/bip39. + +## ZNE1 object format + +Every encrypted thing (content, item metadata, a vault name, a wrapped key) is one ZNE1 object. Integers are big-endian. + +Header, 74 bytes: + +| Offset | Size | Field | +| --- | --- | --- | +| 0 | 4 | Magic `ZNE1` (0x5A 0x4E 0x45 0x31) | +| 4 | 1 | Algorithm. 0x01 = AES-256-GCM, HKDF-SHA256 subkeys, 65536-byte chunks | +| 5 | 1 | Purpose (table below) | +| 6 | 4 | Version of the parent key, uint32 | +| 10 | 32 | Salt, random per object | +| 42 | 32 | Key commitment | + +| Purpose | Byte | Parent key | Context | Parent key version | +| --- | --- | --- | --- | --- | +| Content | 0x01 | VK | Empty | Vault key version | +| Item metadata | 0x02 | VK | The path id, 64 ASCII bytes | Vault key version | +| Vault name | 0x03 | VK | Empty | Vault key version | +| Vault key | 0x04 | AK | The vault key id, 32 ASCII bytes | Account key version | +| Account key | 0x05 | PK or the recovery key | ASCII `passphrase` or `recovery` | 0 | + +With `p` the purpose byte: + +- object key = HKDF-SHA256(IKM = parent key, salt = header salt, info = ASCII `ZNE1 key` followed by `p`, L = 32) +- commitment = HKDF-SHA256(IKM = parent key, salt = header salt, info = ASCII `ZNE1 commit` followed by `p`, L = 32) +- AAD = SHA-256(header followed by context), the same 32 bytes for every chunk + +Body: the plaintext is split into 65536-byte chunks. The last chunk holds 1 to 65536 bytes; an empty plaintext is one empty chunk. Chunk `i`, counting from 0, is AES-256-GCM(object key, nonce, AAD, chunk) with nonce = `i` as an 11-byte unsigned integer followed by 0x01 for the last chunk or 0x00 otherwise. Each chunk's ciphertext is followed by its 16-byte tag, and chunks follow the header in order. + +An n-byte plaintext encrypts to exactly 74 + n + 16 × max(1, ceil(n / 65536)) bytes, known before encryption starts. + +Decryption, in this order: + +1. Check the length (at least 90 bytes), the magic, the algorithm, the expected purpose and a known parent key version. +2. Recompute the commitment and compare it in constant time. A mismatch means the wrong key; nothing is decrypted. +3. Decrypt chunk by chunk; the chunk that ends the input is the last chunk. Full chunks are 65552 bytes. An input that ends early on a chunk boundary, reordered chunks, appended bytes, or an empty last chunk after a full one all fail authentication. Streaming decryptors hold one chunk back so they know which chunk is last. + +Why this shape: a fresh key per object removes GCM's limit on random nonces and lets nonces be counters; the commitment binds every ciphertext to one key, which defeats partitioning-oracle attacks and gives an exact "wrong passphrase" signal; 64 KiB chunks keep memory flat for 200 MB attachments and fit the streamed uploads and downloads that exist today. + +## Path ids + +The server enforces one live item per path, case-insensitively. Encrypted vaults keep that with a blind index: + +- K_path = HKDF-SHA256(IKM = VK, salt = 32 zero bytes, info = ASCII `ZNE1 path-index`, L = 32). HMAC treats an empty salt the same way, but Java's `SecretKeySpec` refuses an empty key, so the spec names the zeros. +- canonical path = the path with `\` replaced by `/`, Unicode NFC, then lower-cased with JavaScript `toLowerCase`, mirroring the server's `path_key` +- path id = lowercase hex of HMAC-SHA256(K_path, UTF-8 of the canonical path), 64 characters + +Clients send the path id wherever they send `path` today. To the server it is an ordinary single-segment path, so uniqueness, `PATH_CONFLICT`, moves, backups and restores keep working unchanged. Only clients compute path ids, and clients apply the server's path validation (`VaultPathService::normalize`) and sync rules to the real path before encrypting it. + +## Item metadata + +Every upsert and move carries `meta`: an item-metadata object whose context is the item's path id, over UTF-8 JSON padded with trailing spaces (0x20) to a multiple of 256 bytes: + +```json +{"v":1,"path":"Projects/Plan.md","kind":"text","encoding":"utf8","media_type":"text/markdown","sha256":"","byte_length":1834} +``` + +`sha256` and `byte_length` tie a file's bytes to its name: every app already re-hashes decrypted content and refuses bytes that differ from the item's metadata, so content swapped in from another item, or an older revision served under newer metadata, never reaches the vault. A rename sends new metadata with the same `sha256` and never re-encrypts the file, which is why the metadata does not name the ciphertext. Readers ignore fields they do not know. + +A decrypting client also checks that the path inside the metadata produces the item's path id, so the server's uniqueness rules really cover real paths. + +## Vault names + +The vault's `encryption.name` is a vault-name object over `{"v":1,"name":"..."}` padded with trailing spaces to a multiple of 64 bytes. The server stores the placeholder name `Encrypted vault`. + +## Recovery key + +32 random bytes, shown as 24 BIP-39 words (256 bits plus an 8-bit checksum). Input ignores case and extra spaces and accepts the first four letters of each word, which are unique in the list. The checksum catches a mistyped word before any key work runs. + +## Wire protocol + +### Account + +`GET /api/v1/account` adds `capabilities.end_to_end_encryption` (true when the service accepts encrypted vaults for this account) and `encryption.keyset` (true when the account has a keyset). Apps that implement this spec send `X-ZenNotes-Encryption: zne1` on every API request. + +### Keyset + +- `GET /api/v1/encryption/keyset` returns `{ data: Keyset }`, or 404 with `KEYSET_NOT_FOUND`. +- `POST /api/v1/encryption/keyset` creates it, or 409 with `KEYSET_EXISTS`. +- `PUT /api/v1/encryption/keyset` replaces `kdf` and `passphrase_envelope` (a passphrase change) and/or `recovery_envelope` (a new recovery key), guarded by `expected_revision`, or 409 with `KEYSET_REVISION_CONFLICT`. + +```ts +interface CloudEncryptionKeyset { + format: 1 + account_key_version: number + kdf: { + algorithm: 'argon2id' + version: 19 + memory_kib: number + iterations: number + parallelism: number + salt: string // base64, 16 bytes + } + passphrase_envelope: string // base64 ZNE1, purpose 0x05, context "passphrase" + recovery_envelope: string // base64 ZNE1, purpose 0x05, context "recovery" + revision: number + updated_at: string +} +``` + +Deleting a keyset (the reset for someone who lost both the passphrase and the recovery key) happens on the website behind password or two-factor confirmation. It deletes every encrypted vault's server data first. The user's devices keep their vaults and upload them into new encrypted vaults after setting up again. + +### Vaults + +`POST /api/v1/vaults` accepts `encryption: { scheme: 'zne1', key_id, key_version, vault_key_envelope, name }` and stores the placeholder name. Vault resources return the same object, or `null` for a readable vault. A vault's encryption is fixed when it is created and never changes. + +### Items + +For an encrypted vault, every upsert sends: + +- `path`: the path id +- `kind`: `binary` +- `meta`: base64 item metadata +- `content`: `{ encoding: 'aes-gcm', data: , sha256: , byte_length: , media_type: 'application/octet-stream' }` + +Moves send the new path id and new `meta`. Deletes are unchanged. Direct-upload initiation sends the same fields as an upsert, without `data`. + +Every response that describes an item adds `meta` next to `path`: manifest items, changes (with `previous_meta` next to `previous_path`), revisions, conflicts (`current_meta` next to `current_path`), backup snapshot items, and note-restore results. + +`aes-gcm` is the encoding the protocol already reserved. Every app released before this spec refuses it before writing anything ("Encrypted cloud sync content must be decrypted before filesystem apply"), so an old app can never write ciphertext into a vault folder. + +### Server rules for encrypted vaults + +- Refuse a write with any other encoding, kind or media type, without `meta`, or whose `path` is not 64 lowercase hex characters: `E2EE_INVALID_WRITE`. +- Refuse content or metadata that does not begin with the ZNE1 magic, algorithm 0x01 and the matching purpose byte: `E2EE_INVALID_CONTENT`. Check direct uploads at completion, where the server already re-reads the object to hash it. This is the last guard against a client bug that labels plaintext as encrypted. +- Answer any sync, backup or restore request that lacks `X-ZenNotes-Encryption: zne1` with 409 `E2EE_CLIENT_REQUIRED` and the message "This vault is end-to-end encrypted. Update ZenNotes on this device to sync it." +- Readable vaults refuse `meta` and `aes-gcm`. +- Skip the server's own path rules (`shouldSync`, the `.zennotes/` allow list); clients apply them before encrypting. +- Store `meta` on items, revisions, changes, backup snapshot items and recovery manifests, and copy it wherever a path is copied (restores, note restores, incremental backups). +- Keep small encrypted bodies inline in Postgres as readable notes are kept today, so an encrypted vault does not turn every note into an object-storage request. + +## Feature behavior on encrypted vaults + +| Feature | Behavior | +| --- | --- | +| Sync, conflicts, merges, previews | Unchanged for the user. Merges and previews run in the app on decrypted bytes | +| Restore from date, full restore, single-note restore | Unchanged. The server copies encrypted rows | +| Backup list and search | The app fetches the list and searches names itself | +| Backup download | The app downloads the archive and decrypts it locally | +| Web ZIP export | Not offered. The dashboard says to export from a ZenNotes app | +| The 30-day download window after access ends | The apps can still pull the vault and download backups | +| Publishing | Allowed. The app says publishing makes a readable copy until the note is unpublished | +| Usage by file type | Encrypted items count as "Encrypted" | +| Dashboard and owner pages | Show "Encrypted vault" and path ids only | +| Operator recovery copies | Ciphertext plus metadata. Recovery restores into the customer's Cloud vault, where their apps decrypt it | + +## Clients + +One encryption layer, in shared-domain: `CloudSyncApiClient` takes a per-vault cipher and translates between the wire format and the plaintext-shaped items the coordinator handles today. The coordinator keeps comparing the plaintext hashes it reads from decrypted metadata, so change detection, rename detection, conflict detection, and first-sync adoption of identical files behave exactly as they do now. + +- A vault whose resource carries `encryption` never syncs without its cipher. When the device holds no account key, the client stops with a "locked" error instead of falling back to plaintext; the service's rules refuse plaintext writes as a second guard. +- The sync engine attaches the moved file's kind and content metadata to every move it plans, so the cipher can seal the new path's metadata without another request. The client strips these hints before sending a move for a readable vault. +- A content reference for an encrypted item carries plaintext metadata for the coordinator plus `encrypted: { sha256, byte_length }` describing the stored ciphertext. A host checks the network bytes against `encrypted`, decrypts, then checks the plaintext against the reference as it does today. The validators accept `aes-gcm` instructions only for references that carry `encrypted`. +- `CloudSyncApiClient` adds `X-ZenNotes-Encryption: zne1` to every request it sends; host transports forward request headers. +- The inline-or-direct upload decision uses the ciphertext length, since encryption adds 74 bytes plus 16 per chunk and the service caps inline bodies at 5 MiB. + +Streams that bypass the JSON layer encrypt and decrypt in chunks: + +- Desktop direct uploads encrypt to a private temp file first, so the ciphertext's length and hash exist before the upload is initiated. Reference downloads decrypt in the network branch of `cloud-sync-download.ts`, before the plaintext hash check, never in the local-copy path. +- iPhone and Android do the same inside `ZenDirectUpload` with CryptoKit and javax.crypto. JavaScript parses the header, checks the commitment and derives the object key; native code runs only the chunk loop and the hashes. Shared test vectors keep the three implementations byte-identical. + +Desktop keeps every key in the main process; the renderer asks for setup, unlock and status over IPC. Before it stores an account key, the desktop secret store must write atomically with 0600 permissions and must never treat an unreadable file as empty. + +## User flows + +- **Set up** (Settings, Cloud, End-to-end encryption, labeled beta): choose a passphrase (typed twice, with a strength meter and an enforced minimum), then see the 24-word recovery key with copy, save and print, and confirm it by typing three of its words. Setup ends by offering to encrypt the current vault. +- **Encrypt this vault**: needs a fully synced vault with no open conflicts. It warns that the old vault's Cloud version history and backups are not carried over and offers a backup download first. It creates a new encrypted vault with the same name, links the folder to it, uploads everything, then marks the old vault superseded and deletes it through the existing deletion path. Other devices linked to the old vault get 410 `VAULT_SUPERSEDED` with the new vault's id and offer to unlock and switch; files that already match are adopted without downloading. +- **New device**: linking an encrypted vault asks for the passphrase or the recovery key, once per device. +- **Change passphrase** and **New recovery key**: ask for the current passphrase or the recovery key, then re-wrap AK. +- **Lost both**: the website reset above. +- **Keyboard**: command palette entries for unlocking and for encrypting the current vault, reachable like every other Cloud action. + +## Rollout + +1. Service: schema, endpoints and rules behind `CLOUD_E2EE` (`off`, `internal` for an allow list, `beta`, `default`), deployed as `off`. +2. Desktop, iPhone and Android releases carry the client code. The UI appears only when the service advertises the capability. +3. `internal`: the maintainer's accounts, cross-device tests on real hardware. +4. `beta`, opt-in for everyone, together with the public security page and updated privacy pages. +5. `default` for new vaults once the beta has held up. An independent audit happens before the beta label comes off. + +## Testing + +- Known-answer vectors in `docs/specs/cloud-e2ee-vectors.json` (HKDF outputs, ZNE1 objects of 0, 1, 65535, 65536, 65537 and 200000 bytes with fixed salts, item metadata, path ids, Argon2id, recovery words), run by the TypeScript, Swift and Java tests. +- Tamper tests: a flipped bit in every header field and chunk, truncation at and between chunk boundaries, reordered and appended chunks, the wrong purpose, the wrong context, the wrong key. +- Service feature tests for every rule above, and for backups and restores carrying `meta`. +- Desktop driven over CDP with two isolated profiles: set up, encrypt a vault, unlock the second profile, sync both ways, rename, a conflict, a 50 MB attachment, Restore from date, a backup download. +- iPhone and Android against a local website: unlock, sync both ways, large files through the native path. + +## Later + +Device approval (adding a device from an existing one without typing the passphrase), vault key rotation after a device is lost, sharing a vault with another account (X25519 key pairs wrapped by AK), size padding, a signed vault manifest against rollback, and a decrypting import for operator recovery folders. From e02c58cd77c41bcb9eaaf47fea812494dfe937e6 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 12 Oct 2026 10:10:08 -0500 Subject: [PATCH 3/8] Feat(cloud): ZNE1 encryption, keys and recovery words in the shared core 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. --- package-lock.json | 21 +- packages/shared-domain/package.json | 2 + packages/shared-domain/src/cloud-e2ee.test.ts | 527 +++++++++++ packages/shared-domain/src/cloud-e2ee.ts | 851 ++++++++++++++++++ 4 files changed, 1400 insertions(+), 1 deletion(-) create mode 100644 packages/shared-domain/src/cloud-e2ee.test.ts create mode 100644 packages/shared-domain/src/cloud-e2ee.ts diff --git a/package-lock.json b/package-lock.json index 0099081c8..cb33e35c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3732,7 +3732,6 @@ "version": "2.4.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 20.19.0" @@ -5293,6 +5292,18 @@ "win32" ] }, + "node_modules/@scure/bip39": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.4.0.tgz", + "integrity": "sha512-82dxFbZUYboyOf0AXiydsQrFQ5Q4h9mX+O2UkE91ROYmsc0BKMGZLwDmy96Jpa2+vrtoxomjUhy1RPIgH/r2nA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.4.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@sindresorhus/is": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", @@ -9678,6 +9689,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hash-wasm": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/hash-wasm/-/hash-wasm-4.12.0.tgz", + "integrity": "sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ==", + "license": "MIT" + }, "node_modules/hash.js": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", @@ -16456,7 +16473,9 @@ "name": "@zennotes/shared-domain", "version": "2.66.0", "dependencies": { + "@scure/bip39": "2.4.0", "@zennotes/bridge-contract": "*", + "hash-wasm": "4.12.0", "lz-string": "^1.5.0" }, "devDependencies": { diff --git a/packages/shared-domain/package.json b/packages/shared-domain/package.json index 7164bc50c..4f9d5ad6d 100644 --- a/packages/shared-domain/package.json +++ b/packages/shared-domain/package.json @@ -13,7 +13,9 @@ "test:run": "vitest run" }, "dependencies": { + "@scure/bip39": "2.4.0", "@zennotes/bridge-contract": "*", + "hash-wasm": "4.12.0", "lz-string": "^1.5.0" }, "devDependencies": { diff --git a/packages/shared-domain/src/cloud-e2ee.test.ts b/packages/shared-domain/src/cloud-e2ee.test.ts new file mode 100644 index 000000000..55f468afa --- /dev/null +++ b/packages/shared-domain/src/cloud-e2ee.test.ts @@ -0,0 +1,527 @@ +import { createCipheriv, createHash, hkdfSync } from 'node:crypto' +import { readFileSync, writeFileSync } from 'node:fs' +import { argon2id } from 'hash-wasm' +import { describe, expect, it } from 'vitest' +import { + CloudE2eeError, + CloudPathIndex, + type CloudE2eeItemMeta, + type CloudE2eeKdfParams, + type Zne1Purpose, + ZNE1_CHUNK_BYTES, + ZNE1_HEADER_BYTES, + ZNE1_SEALED_CHUNK_BYTES, + beginZne1Encryption, + canonicalCloudPath, + createZne1Decryptor, + createZne1Encryptor, + decryptItemMeta, + decryptVaultName, + decryptZne1, + decryptZne1Stream, + derivePassphraseKey, + derivePathIndexKey, + encryptItemMeta, + encryptVaultName, + encryptZne1, + encryptZne1Stream, + fromBase64, + fromHex, + hkdfSha256, + hmacSha256, + newCloudE2eeKdfParams, + recoveryKeyFromWords, + recoveryKeyToWords, + sha256Hex, + toBase64, + toHex, + unwrapAccountKey, + unwrapVaultKey, + wrapAccountKey, + wrapVaultKey, + zne1CiphertextLength, + zne1PlaintextLength, + Zne1Purpose as Purpose +} from './cloud-e2ee' + +const VECTORS_URL = new URL('../../../docs/specs/cloud-e2ee-vectors.json', import.meta.url) + +/** Fixed inputs, so every implementation can reproduce the vectors. */ +const PARENT_KEY = Uint8Array.from({ length: 32 }, (_, index) => index) +const SALT = Uint8Array.from({ length: 32 }, (_, index) => 0x40 + index) +const OTHER_KEY = Uint8Array.from({ length: 32 }, (_, index) => 0xa0 + index) +const SIZES = [0, 1, 65535, 65536, 65537, 131072, 200000] + +/** Plaintext bytes for the vectors: byte i is i mod 251. */ +function pattern(length: number): Uint8Array { + return Uint8Array.from({ length }, (_, index) => index % 251) +} + +function text(value: string): Uint8Array { + return new TextEncoder().encode(value) +} + +async function expectCode(promise: Promise, code: string): Promise { + await expect(promise).rejects.toSatisfy( + (error: unknown) => error instanceof CloudE2eeError && error.code === code + ) +} + +/** + * A second implementation of ZNE1 encryption on node:crypto's own API, written + * from the spec, so a misuse of WebCrypto (salt and info swapped, tag order, + * nonce layout) cannot hide behind a round trip that agrees with itself. + */ +function referenceEncrypt( + parentKey: Uint8Array, + purpose: number, + keyVersion: number, + salt: Uint8Array, + context: Uint8Array, + plaintext: Uint8Array +): Buffer { + const info = (label: string) => Buffer.concat([Buffer.from(label, 'latin1'), Buffer.from([purpose])]) + const objectKey = Buffer.from(hkdfSync('sha256', parentKey, salt, info('ZNE1 key'), 32)) + const commitment = Buffer.from(hkdfSync('sha256', parentKey, salt, info('ZNE1 commit'), 32)) + const header = Buffer.alloc(ZNE1_HEADER_BYTES) + header.write('ZNE1', 0, 'latin1') + header[4] = 0x01 + header[5] = purpose + header.writeUInt32BE(keyVersion, 6) + Buffer.from(salt).copy(header, 10) + commitment.copy(header, 42) + const aad = createHash('sha256').update(header).update(context).digest() + const parts: Buffer[] = [header] + const chunks = Math.max(1, Math.ceil(plaintext.byteLength / ZNE1_CHUNK_BYTES)) + for (let index = 0; index < chunks; index++) { + const last = index === chunks - 1 + const nonce = Buffer.alloc(12) + nonce.writeUInt32BE(index, 7) + nonce[11] = last ? 1 : 0 + const cipher = createCipheriv('aes-256-gcm', objectKey, nonce) + cipher.setAAD(aad) + const chunk = plaintext.subarray(index * ZNE1_CHUNK_BYTES, (index + 1) * ZNE1_CHUNK_BYTES) + parts.push(cipher.update(chunk), cipher.final(), cipher.getAuthTag()) + } + return Buffer.concat(parts) +} + +describe('primitives against published vectors', () => { + it('derives HKDF-SHA256 like RFC 5869 test cases 1 and 3', async () => { + const ikm = new Uint8Array(22).fill(0x0b) + const caseOne = await hkdfSha256(ikm, fromHex('000102030405060708090a0b0c'), fromHex('f0f1f2f3f4f5f6f7f8f9'), 42) + expect(toHex(caseOne)).toBe( + '3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865' + ) + const caseThree = await hkdfSha256(ikm, new Uint8Array(0), new Uint8Array(0), 42) + expect(toHex(caseThree)).toBe( + '8da4e775a563c18f715f802a063c5a31b8a11f5c5ee1879ec3454e5f3c738d2d9d201395faa4b61a96c8' + ) + // The path index key names 32 zero bytes as its salt; HMAC makes that the + // same as the empty salt above. + const zeros = await hkdfSha256(ikm, new Uint8Array(32), new Uint8Array(0), 42) + expect(toHex(zeros)).toBe(toHex(caseThree)) + }) + + it('computes HMAC-SHA256 like RFC 4231 test case 2', async () => { + const mac = await hmacSha256(text('Jefe'), text('what do ya want for nothing?')) + expect(toHex(mac)).toBe('5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843') + }) + + it('derives Argon2id keys that match OpenSSL 3.6', async () => { + // Reference values from `openssl kdf ... ARGON2ID` (OpenSSL 3.6.5), an + // implementation independent of hash-wasm. The first is the reference + // implementation's own test vector; its 8-byte salt is below what + // derivePassphraseKey accepts, so it checks hash-wasm directly. + const salt = toBase64(fromHex('000102030405060708090a0b0c0d0e0f')) + const reference = await argon2id({ + password: 'password', + salt: 'somesalt', + iterations: 2, + parallelism: 1, + memorySize: 65536, + hashLength: 32, + outputType: 'hex' + }) + expect(reference).toBe('09316115d5cf24ed5a15a31a3ba326e5cf32edc24702987c02b6566f61913cf7') + + const defaults: CloudE2eeKdfParams = { ...newCloudE2eeKdfParams(), salt } + expect(toHex(await derivePassphraseKey('correct horse battery staple', defaults))).toBe( + '853b272a44db1421c02962669a55eb0994f3cab385ed1c4c79253eee19bab49e' + ) + // U+212B ANGSTROM SIGN and U+FB01 LATIN SMALL LIGATURE FI only match after + // NFKC, which every app applies before deriving. + expect(toHex(await derivePassphraseKey('Ångström ☕ file', defaults))).toBe( + '557b069f56e082ae2aecd39bbb2513634b549ab4215fdbf02974607c65e5462d' + ) + }, 30_000) + + it('refuses key derivation settings outside safe bounds', async () => { + const base = newCloudE2eeKdfParams() + for (const kdf of [ + { ...base, memory_kib: 8 }, + { ...base, memory_kib: 4 * 1024 * 1024 }, + { ...base, iterations: 1 }, + { ...base, parallelism: 0 }, + { ...base, salt: toBase64(new Uint8Array(8)) }, + { ...base, algorithm: 'scrypt' as 'argon2id' } + ]) { + await expectCode(derivePassphraseKey('passphrase', kdf), 'invalid-kdf') + } + }) +}) + +describe('recovery words', () => { + it('matches the BIP-39 reference vectors for 256-bit entropy', () => { + expect(recoveryKeyToWords(new Uint8Array(32)).join(' ')).toBe(`${'abandon '.repeat(23)}art`) + expect(recoveryKeyToWords(new Uint8Array(32).fill(0x7f)).join(' ')).toBe( + 'legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth title' + ) + expect(recoveryKeyToWords(new Uint8Array(32).fill(0xff)).join(' ')).toBe(`${'zoo '.repeat(23)}vote`) + }) + + it('reads words back with any case, spacing, numbering, or four-letter prefixes', () => { + const key = fromHex('9e885d952ad362caeb4efe34a8e91bd2a3cbd94c7bd5b1e8f3cc1e3d7b3c6bc1') + const words = recoveryKeyToWords(key) + expect(toHex(recoveryKeyFromWords(words.join(' ')))).toBe(toHex(key)) + const numbered = words.map((word, index) => `${index + 1}. ${word.toUpperCase()}`).join('\n') + expect(toHex(recoveryKeyFromWords(numbered))).toBe(toHex(key)) + const prefixes = words.map((word) => (word.length > 4 ? word.slice(0, 4) : word)).join(' ') + expect(toHex(recoveryKeyFromWords(prefixes))).toBe(toHex(key)) + }) + + it('rejects the wrong count, unknown words, and a failed checksum', () => { + const words = recoveryKeyToWords(new Uint8Array(32).fill(0x7f)) + expect(() => recoveryKeyFromWords(words.slice(1).join(' '))).toThrow(/24 words/) + expect(() => recoveryKeyFromWords([...words.slice(1), 'qqqq'].join(' '))).toThrow(/not a recovery key word/) + const swapped = [...words] + ;[swapped[0], swapped[1]] = [swapped[1]!, swapped[0]!] + expect(() => recoveryKeyFromWords(swapped.join(' '))).toThrow(/mistyped word/) + }) +}) + +describe('ZNE1 objects', () => { + it('encrypts to exactly the bytes an independent node:crypto implementation produces', async () => { + for (const size of SIZES) { + const plaintext = pattern(size) + const context = text('context') + const sealed = await encryptZne1(plaintext, { + parentKey: PARENT_KEY, + purpose: Purpose.content, + keyVersion: 7, + context, + salt: SALT + }) + const reference = referenceEncrypt(PARENT_KEY, Purpose.content, 7, SALT, context, plaintext) + expect(sealed.byteLength).toBe(zne1CiphertextLength(size)) + expect(zne1PlaintextLength(sealed.byteLength)).toBe(size) + expect(Buffer.from(sealed).equals(reference)).toBe(true) + const opened = await decryptZne1(sealed, { parentKey: PARENT_KEY, purpose: Purpose.content, keyVersion: 7, context }) + expect(Buffer.from(opened).equals(Buffer.from(plaintext))).toBe(true) + } + }) + + it('streams chunk by chunk to the same bytes as the one-shot functions', async () => { + const plaintext = pattern(200000) + const encryptor = await createZne1Encryptor({ parentKey: PARENT_KEY, purpose: Purpose.content, keyVersion: 1, salt: SALT }) + const parts: Uint8Array[] = [encryptor.header] + for (let start = 0; start < plaintext.byteLength; start += ZNE1_CHUNK_BYTES) { + const end = Math.min(start + ZNE1_CHUNK_BYTES, plaintext.byteLength) + parts.push(await encryptor.encryptChunk(plaintext.subarray(start, end), end === plaintext.byteLength)) + } + const streamed = Buffer.concat(parts) + const oneShot = await encryptZne1(plaintext, { parentKey: PARENT_KEY, purpose: Purpose.content, keyVersion: 1, salt: SALT }) + expect(streamed.equals(Buffer.from(oneShot))).toBe(true) + + const decryptor = await createZne1Decryptor(streamed, { parentKey: PARENT_KEY, purpose: Purpose.content }) + const opened: Uint8Array[] = [] + for (let offset = ZNE1_HEADER_BYTES; offset < streamed.byteLength; offset += ZNE1_SEALED_CHUNK_BYTES) { + const end = Math.min(offset + ZNE1_SEALED_CHUNK_BYTES, streamed.byteLength) + opened.push(await decryptor.decryptChunk(streamed.subarray(offset, end), end === streamed.byteLength)) + } + expect(Buffer.concat(opened).equals(Buffer.from(plaintext))).toBe(true) + }) + + it('streams any input chunking to the one-shot bytes and back', async () => { + const plaintext = pattern(200000) + const options = { parentKey: PARENT_KEY, purpose: Purpose.content, keyVersion: 1, salt: SALT } as const + const oneShot = Buffer.from(await encryptZne1(plaintext, options)) + const pieces = (data: Uint8Array, sizes: number[]) => { + const out: Uint8Array[] = [] + let offset = 0 + for (let index = 0; offset < data.byteLength; index++) { + const size = sizes[index % sizes.length]! + out.push(data.subarray(offset, offset + size)) + offset += size + } + return out + } + const collect = async (stream: AsyncGenerator) => { + const parts: Uint8Array[] = [] + for await (const part of stream) parts.push(part) + return Buffer.concat(parts) + } + for (const sizes of [[1], [1000, 7], [65536], [70000], [300000], [65535, 2]]) { + const sizesForInput = sizes[0] === 1 ? [4096] : sizes + expect((await collect(encryptZne1Stream(pieces(plaintext, sizesForInput), options))).equals(oneShot)).toBe(true) + const opened = await collect(decryptZne1Stream(pieces(oneShot, sizes[0] === 1 ? [9001] : sizes), { parentKey: PARENT_KEY, purpose: Purpose.content })) + expect(opened.equals(Buffer.from(plaintext))).toBe(true) + } + for (const size of [0, 65536, 131072]) { + const data = pattern(size) + const sealed = Buffer.from(await encryptZne1(data, options)) + expect((await collect(encryptZne1Stream([data], options))).equals(sealed)).toBe(true) + expect((await collect(decryptZne1Stream([sealed], { parentKey: PARENT_KEY, purpose: Purpose.content }))).equals(Buffer.from(data))).toBe(true) + } + }) + + it('stops a stream that ends early or carries no chunks', async () => { + const options = { parentKey: PARENT_KEY, purpose: Purpose.content } as const + const sealed = await encryptZne1(pattern(131072), { ...options, keyVersion: 1 }) + const drain = async (data: Uint8Array) => { + for await (const _ of decryptZne1Stream([data], options)) void _ + } + await expectCode(drain(sealed.subarray(0, ZNE1_HEADER_BYTES + ZNE1_SEALED_CHUNK_BYTES)), 'tampered') + await expectCode(drain(sealed.subarray(0, ZNE1_HEADER_BYTES)), 'tampered') + await expectCode(drain(sealed.subarray(0, 40)), 'malformed') + await expectCode(drain(sealed.subarray(0, sealed.byteLength - 3)), 'tampered') + }) + + it('refuses chunk sizes the format never produces', async () => { + const encryptor = await createZne1Encryptor({ parentKey: PARENT_KEY, purpose: Purpose.content, keyVersion: 1 }) + await expectCode(encryptor.encryptChunk(new Uint8Array(10), false), 'malformed') + await encryptor.encryptChunk(new Uint8Array(ZNE1_CHUNK_BYTES), false) + await expectCode(encryptor.encryptChunk(new Uint8Array(0), true), 'malformed') + await encryptor.encryptChunk(new Uint8Array(1), true) + await expectCode(encryptor.encryptChunk(new Uint8Array(1), true), 'malformed') + }) + + describe('tampering', () => { + const plaintext = pattern(131072 + 100) + const options: { parentKey: Uint8Array; purpose: Zne1Purpose; keyVersion: number; context: Uint8Array } = { + parentKey: PARENT_KEY, + purpose: Purpose.itemMeta, + keyVersion: 3, + context: text('ctx') + } + const seal = () => encryptZne1(plaintext, { ...options, salt: SALT }) + const open = (data: Uint8Array, overrides: Partial = {}) => + decryptZne1(data, { ...options, ...overrides }) + + it('names each header field it rejects', async () => { + const sealed = await seal() + const flip = (offset: number) => { + const copy = Uint8Array.from(sealed) + copy[offset] ^= 0x01 + return copy + } + await expectCode(open(flip(0)), 'malformed') // magic + await expectCode(open(flip(4)), 'unsupported') // algorithm + await expectCode(open(flip(5)), 'wrong-purpose') + await expectCode(open(flip(9)), 'wrong-key-version') + await expectCode(open(flip(10)), 'wrong-key') // salt: the commitment no longer matches + await expectCode(open(flip(73)), 'wrong-key') // commitment + }) + + it('rejects a flipped bit anywhere in a chunk or its tag', async () => { + const sealed = await seal() + for (const offset of [ZNE1_HEADER_BYTES, ZNE1_HEADER_BYTES + 65551, sealed.byteLength - 1, sealed.byteLength - 17]) { + const copy = Uint8Array.from(sealed) + copy[offset] ^= 0x80 + await expectCode(open(copy), 'tampered') + } + }) + + it('rejects truncation, reordering, appended bytes, a wrong context, and a wrong key', async () => { + const sealed = await seal() + const twoChunks = sealed.subarray(0, ZNE1_HEADER_BYTES + 2 * ZNE1_SEALED_CHUNK_BYTES) + await expectCode(open(twoChunks), 'tampered') // ends early on a chunk boundary + await expectCode(open(sealed.subarray(0, sealed.byteLength - 5)), 'tampered') // ends inside the last chunk + await expectCode(open(sealed.subarray(0, ZNE1_HEADER_BYTES + ZNE1_SEALED_CHUNK_BYTES + 3)), 'malformed') + const swapped = Uint8Array.from(sealed) + swapped.set(sealed.subarray(ZNE1_HEADER_BYTES + ZNE1_SEALED_CHUNK_BYTES, ZNE1_HEADER_BYTES + 2 * ZNE1_SEALED_CHUNK_BYTES), ZNE1_HEADER_BYTES) + swapped.set(sealed.subarray(ZNE1_HEADER_BYTES, ZNE1_HEADER_BYTES + ZNE1_SEALED_CHUNK_BYTES), ZNE1_HEADER_BYTES + ZNE1_SEALED_CHUNK_BYTES) + await expectCode(open(swapped), 'tampered') + const appended = new Uint8Array(sealed.byteLength + 40) + appended.set(sealed) + await expectCode(open(appended), 'tampered') + await expectCode(open(sealed, { context: text('other') }), 'tampered') + await expectCode(open(sealed, { parentKey: OTHER_KEY }), 'wrong-key') + await expectCode(open(sealed, { purpose: Purpose.content }), 'wrong-purpose') + await expectCode(open(sealed.subarray(0, 80)), 'malformed') + }) + + it('rejects an empty last chunk after a full one', async () => { + // The format seals 65536 bytes as one final chunk. A crafted object that + // seals it as a full chunk plus an empty final chunk must not open. + const params = await beginZne1Encryption({ ...options, salt: SALT }) + const key = await crypto.subtle.importKey('raw', params.objectKey, 'AES-GCM', false, ['encrypt']) + const nonce = (index: number, last: boolean) => { + const value = new Uint8Array(12) + new DataView(value.buffer).setUint32(7, index) + value[11] = last ? 1 : 0 + return value + } + const first = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce(0, false), additionalData: params.aad }, key, new Uint8Array(ZNE1_CHUNK_BYTES))) + const empty = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce(1, true), additionalData: params.aad }, key, new Uint8Array(0))) + await expectCode(open(Buffer.concat([params.header, first, empty])), 'malformed') + }) + }) +}) + +describe('key hierarchy', () => { + it('wraps the account key under the passphrase and the recovery key independently', async () => { + const accountKey = OTHER_KEY + const passphraseKey = PARENT_KEY + const recoveryKey = fromHex('9e885d952ad362caeb4efe34a8e91bd2a3cbd94c7bd5b1e8f3cc1e3d7b3c6bc1') + const byPassphrase = await wrapAccountKey(accountKey, passphraseKey, 'passphrase') + const byRecovery = await wrapAccountKey(accountKey, recoveryKey, 'recovery') + expect(toHex(await unwrapAccountKey(byPassphrase, passphraseKey, 'passphrase'))).toBe(toHex(accountKey)) + expect(toHex(await unwrapAccountKey(byRecovery, recoveryKey, 'recovery'))).toBe(toHex(accountKey)) + await expectCode(unwrapAccountKey(byPassphrase, recoveryKey, 'passphrase'), 'wrong-key') + await expectCode(unwrapAccountKey(byPassphrase, passphraseKey, 'recovery'), 'tampered') + }) + + it('binds a vault key to its id and the account key version', async () => { + const envelope = await wrapVaultKey(PARENT_KEY, OTHER_KEY, 1, 'a'.repeat(32)) + expect(toHex(await unwrapVaultKey(envelope, OTHER_KEY, 1, 'a'.repeat(32)))).toBe(toHex(PARENT_KEY)) + await expectCode(unwrapVaultKey(envelope, OTHER_KEY, 1, 'b'.repeat(32)), 'tampered') + await expectCode(unwrapVaultKey(envelope, OTHER_KEY, 2, 'a'.repeat(32)), 'wrong-key-version') + }) +}) + +describe('path ids and metadata', () => { + const meta: CloudE2eeItemMeta = { + path: 'Projects/Café Plan.md', + kind: 'text', + encoding: 'utf8', + media_type: 'text/markdown', + sha256: 'a'.repeat(64), + byte_length: 1834 + } + + it('gives paths that the server treats as one the same id, and others different ids', async () => { + const index = await CloudPathIndex.forVaultKey(PARENT_KEY) + const id = await index.id('Projects/Café Plan.md') + expect(id).toMatch(/^[0-9a-f]{64}$/) + expect(await index.id('projects/CAFÉ plan.md')).toBe(id) + expect(await index.id('Projects\\Café Plan.md')).toBe(id) // backslash, decomposed accent + expect(await index.id('Projects/Cafe Plan.md')).not.toBe(id) + const otherVault = await CloudPathIndex.forVaultKey(OTHER_KEY) + expect(await otherVault.id('Projects/Café Plan.md')).not.toBe(id) + expect(canonicalCloudPath('A\\B/Ç.md')).toBe('a/b/ç.md') + }) + + it('round-trips item metadata padded to 256 bytes and bound to its path id', async () => { + const index = await CloudPathIndex.forVaultKey(PARENT_KEY) + const pathId = await index.id(meta.path) + const envelope = await encryptItemMeta(meta, { vaultKey: PARENT_KEY, vaultKeyVersion: 1, pathId }) + expect((fromBase64(envelope).byteLength - ZNE1_HEADER_BYTES - 16) % 256).toBe(0) + expect(await decryptItemMeta(envelope, { vaultKey: PARENT_KEY, pathId, pathIndex: index })).toEqual(meta) + + const otherId = await index.id('Elsewhere.md') + await expectCode(decryptItemMeta(envelope, { vaultKey: PARENT_KEY, pathId: otherId, pathIndex: index }), 'tampered') + + // Metadata sealed under the right id but naming another path is refused. + const mismatched = await encryptItemMeta({ ...meta, path: 'Elsewhere.md' }, { vaultKey: PARENT_KEY, vaultKeyVersion: 1, pathId }) + await expectCode(decryptItemMeta(mismatched, { vaultKey: PARENT_KEY, pathId, pathIndex: index }), 'invalid-metadata') + }) + + it('round-trips vault names', async () => { + const envelope = await encryptVaultName('Journal 2026', { vaultKey: PARENT_KEY, vaultKeyVersion: 1 }) + expect(await decryptVaultName(envelope, { vaultKey: PARENT_KEY })).toBe('Journal 2026') + await expectCode(decryptVaultName(envelope, { vaultKey: OTHER_KEY }), 'wrong-key') + }) +}) + +describe('shared vectors', () => { + async function objectVector(purpose: Zne1Purpose, keyVersion: number, context: Uint8Array, size: number) { + const plaintext = pattern(size) + const params = await beginZne1Encryption({ parentKey: PARENT_KEY, purpose, keyVersion, context, salt: SALT }) + const sealed = await encryptZne1(plaintext, { parentKey: PARENT_KEY, purpose, keyVersion, context, salt: SALT }) + return { + purpose, + key_version: keyVersion, + parent_key_hex: toHex(PARENT_KEY), + salt_hex: toHex(SALT), + context_hex: toHex(context), + plaintext_length: size, + object_key_hex: toHex(params.objectKey), + aad_hex: toHex(params.aad), + header_hex: toHex(params.header), + ciphertext_length: sealed.byteLength, + ciphertext_sha256: await sha256Hex(sealed), + ...(sealed.byteLength <= 2048 ? { ciphertext_hex: toHex(sealed) } : {}) + } + } + + async function buildVectors() { + const objects = [] + for (const size of SIZES) objects.push(await objectVector(Purpose.content, 1, new Uint8Array(0), size)) + const index = await CloudPathIndex.forVaultKey(PARENT_KEY) + const paths = ['Notes/Ideas.md', 'notes\\IDEAS.md', 'Journal/2026/Café.md', 'Journal/2026/Café.md', '.zennotes/vault.json'] + const pathIds = [] + for (const path of paths) pathIds.push({ path, canonical: canonicalCloudPath(path), path_id: await index.id(path) }) + const metaPathId = await index.id('Notes/Ideas.md') + const meta: CloudE2eeItemMeta = { + path: 'Notes/Ideas.md', + kind: 'text', + encoding: 'utf8', + media_type: 'text/markdown', + sha256: await sha256Hex(text('# Ideas\n')), + byte_length: 8 + } + const recoveryKey = fromHex('9e885d952ad362caeb4efe34a8e91bd2a3cbd94c7bd5b1e8f3cc1e3d7b3c6bc1') + return { + format: 'ZNE1', + spec: 'docs/specs/cloud-e2ee.md', + notes: + 'Generated by packages/shared-domain/src/cloud-e2ee.test.ts with UPDATE_E2EE_VECTORS=1, and checked there against an independent node:crypto implementation and OpenSSL 3.6 Argon2id. Plaintext for objects is byte i = i mod 251.', + argon2id: [ + { passphrase: 'password', password_hex: toHex(text('password')), salt_hex: toHex(text('somesalt')), memory_kib: 65536, iterations: 2, parallelism: 1, output_hex: '09316115d5cf24ed5a15a31a3ba326e5cf32edc24702987c02b6566f61913cf7' }, + { passphrase: 'correct horse battery staple', password_hex: toHex(text('correct horse battery staple')), salt_hex: '000102030405060708090a0b0c0d0e0f', memory_kib: 65536, iterations: 3, parallelism: 4, output_hex: '853b272a44db1421c02962669a55eb0994f3cab385ed1c4c79253eee19bab49e' }, + { passphrase: 'Ångström ☕ file', password_hex: toHex(text('Ångström ☕ file'.normalize('NFKC'))), salt_hex: '000102030405060708090a0b0c0d0e0f', memory_kib: 65536, iterations: 3, parallelism: 4, output_hex: '557b069f56e082ae2aecd39bbb2513634b549ab4215fdbf02974607c65e5462d' } + ], + objects, + metadata_object: await objectVector(Purpose.itemMeta, 1, text(metaPathId), 256), + path_index: { vault_key_hex: toHex(PARENT_KEY), path_index_key_hex: toHex(await derivePathIndexKey(PARENT_KEY)), paths: pathIds }, + item_meta: { + vault_key_hex: toHex(PARENT_KEY), + vault_key_version: 1, + path_id: metaPathId, + salt_hex: toHex(SALT), + meta, + envelope_base64: await encryptItemMeta(meta, { vaultKey: PARENT_KEY, vaultKeyVersion: 1, pathId: metaPathId, salt: SALT }) + }, + vault_name: { + vault_key_hex: toHex(PARENT_KEY), + name: 'Journal 2026', + salt_hex: toHex(SALT), + envelope_base64: await encryptVaultName('Journal 2026', { vaultKey: PARENT_KEY, vaultKeyVersion: 1, salt: SALT }) + }, + envelopes: { + account_key_hex: toHex(OTHER_KEY), + passphrase_key_hex: toHex(PARENT_KEY), + recovery_key_hex: toHex(recoveryKey), + salt_hex: toHex(SALT), + by_passphrase_base64: await wrapAccountKey(OTHER_KEY, PARENT_KEY, 'passphrase', SALT), + by_recovery_base64: await wrapAccountKey(OTHER_KEY, recoveryKey, 'recovery', SALT), + vault_key_hex: toHex(PARENT_KEY), + vault_key_id: 'a'.repeat(32), + account_key_version: 1, + vault_key_base64: await wrapVaultKey(PARENT_KEY, OTHER_KEY, 1, 'a'.repeat(32), SALT) + }, + recovery_words: [ + { entropy_hex: '00'.repeat(32), words: recoveryKeyToWords(new Uint8Array(32)).join(' ') }, + { entropy_hex: toHex(recoveryKey), words: recoveryKeyToWords(recoveryKey).join(' ') } + ] + } + } + + it('matches docs/specs/cloud-e2ee-vectors.json', async () => { + const vectors = await buildVectors() + const serialized = `${JSON.stringify(vectors, null, 2)}\n` + if (process.env.UPDATE_E2EE_VECTORS === '1') writeFileSync(VECTORS_URL, serialized) + expect(JSON.parse(readFileSync(VECTORS_URL, 'utf8'))).toEqual(JSON.parse(serialized)) + }, 30_000) +}) diff --git a/packages/shared-domain/src/cloud-e2ee.ts b/packages/shared-domain/src/cloud-e2ee.ts new file mode 100644 index 000000000..e6b886c6c --- /dev/null +++ b/packages/shared-domain/src/cloud-e2ee.ts @@ -0,0 +1,851 @@ +/** + * ZenNotes Cloud end-to-end encryption: the ZNE1 object format, the key + * hierarchy, path ids, item metadata, vault names and recovery words. + * + * docs/specs/cloud-e2ee.md is the normative description. The iPhone and + * Android native chunk loops must produce the same bytes as these functions, + * which docs/specs/cloud-e2ee-vectors.json pins; change the format only by + * adding a new algorithm byte, never by editing what 0x01 means. + */ +import { argon2id } from 'hash-wasm' +import { entropyToMnemonic, mnemonicToEntropy } from '@scure/bip39' +import { wordlist as englishWords } from '@scure/bip39/wordlists/english.js' + +export type Bytes = Uint8Array + +export const ZNE1_ALGORITHM_AES_256_GCM = 0x01 +export const ZNE1_HEADER_BYTES = 74 +export const ZNE1_CHUNK_BYTES = 65536 +export const ZNE1_TAG_BYTES = 16 +export const ZNE1_SEALED_CHUNK_BYTES = ZNE1_CHUNK_BYTES + ZNE1_TAG_BYTES +export const CLOUD_E2EE_KEY_BYTES = 32 + +const MAGIC = [0x5a, 0x4e, 0x45, 0x31] as const + +export const Zne1Purpose = { + content: 0x01, + itemMeta: 0x02, + vaultName: 0x03, + vaultKey: 0x04, + accountKey: 0x05 +} as const +export type Zne1Purpose = (typeof Zne1Purpose)[keyof typeof Zne1Purpose] + +export type CloudE2eeErrorCode = + | 'malformed' + | 'unsupported' + | 'wrong-purpose' + | 'wrong-key-version' + | 'wrong-key' + | 'tampered' + | 'invalid-key' + | 'invalid-kdf' + | 'invalid-recovery-key' + | 'invalid-metadata' + +export class CloudE2eeError extends Error { + constructor( + readonly code: CloudE2eeErrorCode, + message: string + ) { + super(message) + this.name = 'CloudE2eeError' + } +} + +const encoder = new TextEncoder() +const decoder = new TextDecoder('utf-8', { fatal: true }) + +function utf8(value: string): Bytes { + return encoder.encode(value) as Bytes +} + +/** A copy backed by its own ArrayBuffer, which WebCrypto's types require. */ +function own(view: Uint8Array): Bytes { + return new Uint8Array(view) as Bytes +} + +function concat(...parts: Uint8Array[]): Bytes { + const out = new Uint8Array(parts.reduce((total, part) => total + part.byteLength, 0)) + let offset = 0 + for (const part of parts) { + out.set(part, offset) + offset += part.byteLength + } + return out as Bytes +} + +function assertKey(key: Uint8Array, label: string): void { + if (key.byteLength !== CLOUD_E2EE_KEY_BYTES) { + throw new CloudE2eeError('invalid-key', `${label} must be ${CLOUD_E2EE_KEY_BYTES} bytes.`) + } +} + +/** Compares without an early exit, so the time taken says nothing about where two values differ. */ +export function constantTimeEqual(left: Uint8Array, right: Uint8Array): boolean { + if (left.byteLength !== right.byteLength) return false + let difference = 0 + for (let index = 0; index < left.byteLength; index++) difference |= left[index]! ^ right[index]! + return difference === 0 +} + +export function randomBytes(length: number): Bytes { + const out = new Uint8Array(length) as Bytes + crypto.getRandomValues(out) + return out +} + +export function toHex(data: Uint8Array): string { + let hex = '' + for (const byte of data) hex += byte.toString(16).padStart(2, '0') + return hex +} + +export function fromHex(hex: string): Bytes { + if (hex.length % 2 !== 0 || /[^0-9a-f]/i.test(hex)) { + throw new CloudE2eeError('malformed', 'Expected hexadecimal text.') + } + const out = new Uint8Array(hex.length / 2) as Bytes + for (let index = 0; index < out.length; index++) { + out[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16) + } + return out +} + +export function toBase64(data: Uint8Array): string { + let binary = '' + // String.fromCharCode takes its arguments on the stack; 32 KiB slices keep + // a 200 MB attachment from overflowing it. + for (let offset = 0; offset < data.byteLength; offset += 0x8000) { + binary += String.fromCharCode(...data.subarray(offset, offset + 0x8000)) + } + return btoa(binary) +} + +export function fromBase64(text: string): Bytes { + let binary: string + try { + binary = atob(text) + } catch { + throw new CloudE2eeError('malformed', 'Expected base64 text.') + } + const out = new Uint8Array(binary.length) as Bytes + for (let index = 0; index < binary.length; index++) out[index] = binary.charCodeAt(index) + return out +} + +export async function sha256(data: Uint8Array): Promise { + return new Uint8Array(await crypto.subtle.digest('SHA-256', own(data))) as Bytes +} + +export async function sha256Hex(data: Uint8Array): Promise { + return toHex(await sha256(data)) +} + +export async function hkdfSha256( + inputKey: Uint8Array, + salt: Uint8Array, + info: Uint8Array, + length: number +): Promise { + const key = await crypto.subtle.importKey('raw', own(inputKey), 'HKDF', false, ['deriveBits']) + const bits = await crypto.subtle.deriveBits( + { name: 'HKDF', hash: 'SHA-256', salt: own(salt), info: own(info) }, + key, + length * 8 + ) + return new Uint8Array(bits) as Bytes +} + +export async function hmacSha256(key: Uint8Array, data: Uint8Array): Promise { + const imported = await crypto.subtle.importKey( + 'raw', + own(key), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ) + return new Uint8Array(await crypto.subtle.sign('HMAC', imported, own(data))) as Bytes +} + +// --------------------------------------------------------------------------- +// ZNE1 objects +// --------------------------------------------------------------------------- + +export interface Zne1Header { + algorithm: number + purpose: number + keyVersion: number + salt: Bytes + commitment: Bytes + /** The 74 header bytes exactly as they appear in the object. */ + bytes: Bytes +} + +/** What a chunk cipher needs; the native iPhone and Android loops receive exactly this. */ +export interface Zne1ChunkCipherParams { + header: Bytes + /** The per-object AES-256-GCM key. */ + objectKey: Bytes + /** SHA-256 of the header followed by the context, used as AAD for every chunk. */ + aad: Bytes +} + +interface Zne1KeyOptions { + parentKey: Uint8Array + purpose: Zne1Purpose + context?: Uint8Array +} + +export interface Zne1EncryptOptions extends Zne1KeyOptions { + keyVersion: number + /** Tests and vectors pin the salt; everything else must leave it random. */ + salt?: Uint8Array +} + +export interface Zne1DecryptOptions extends Zne1KeyOptions { + /** When set, a header naming another parent key version is refused. */ + keyVersion?: number +} + +async function deriveObjectSecrets( + parentKey: Uint8Array, + salt: Uint8Array, + purpose: number +): Promise<{ objectKey: Bytes; commitment: Bytes }> { + assertKey(parentKey, 'The parent key') + const [objectKey, commitment] = await Promise.all([ + hkdfSha256(parentKey, salt, concat(utf8('ZNE1 key'), Uint8Array.of(purpose)), 32), + hkdfSha256(parentKey, salt, concat(utf8('ZNE1 commit'), Uint8Array.of(purpose)), 32) + ]) + return { objectKey, commitment } +} + +function buildHeader(purpose: number, keyVersion: number, salt: Uint8Array, commitment: Uint8Array): Bytes { + if (!Number.isInteger(keyVersion) || keyVersion < 0 || keyVersion > 0xffffffff) { + throw new CloudE2eeError('malformed', 'Key versions are 32-bit unsigned integers.') + } + if (salt.byteLength !== 32) throw new CloudE2eeError('malformed', 'ZNE1 salts are 32 bytes.') + const header = new Uint8Array(ZNE1_HEADER_BYTES) as Bytes + header.set(MAGIC, 0) + header[4] = ZNE1_ALGORITHM_AES_256_GCM + header[5] = purpose + new DataView(header.buffer).setUint32(6, keyVersion) + header.set(salt, 10) + header.set(commitment, 42) + return header +} + +export function parseZne1Header(data: Uint8Array): Zne1Header { + if (data.byteLength < ZNE1_HEADER_BYTES) { + throw new CloudE2eeError('malformed', 'The encrypted data is shorter than a ZNE1 header.') + } + for (let index = 0; index < MAGIC.length; index++) { + if (data[index] !== MAGIC[index]) { + throw new CloudE2eeError('malformed', 'The data is not a ZNE1 object.') + } + } + if (data[4] !== ZNE1_ALGORITHM_AES_256_GCM) { + throw new CloudE2eeError('unsupported', `ZNE1 algorithm ${data[4]} is not supported by this version of ZenNotes.`) + } + const bytes = own(data.subarray(0, ZNE1_HEADER_BYTES)) + return { + algorithm: bytes[4]!, + purpose: bytes[5]!, + keyVersion: new DataView(bytes.buffer).getUint32(6), + salt: bytes.slice(10, 42) as Bytes, + commitment: bytes.slice(42, 74) as Bytes, + bytes + } +} + +/** The ciphertext size of an n-byte plaintext, known before encrypting. */ +export function zne1CiphertextLength(plaintextLength: number): number { + if (!Number.isSafeInteger(plaintextLength) || plaintextLength < 0) { + throw new CloudE2eeError('malformed', 'Plaintext lengths are non-negative integers.') + } + const chunks = Math.max(1, Math.ceil(plaintextLength / ZNE1_CHUNK_BYTES)) + return ZNE1_HEADER_BYTES + plaintextLength + chunks * ZNE1_TAG_BYTES +} + +/** The plaintext size a well-formed ciphertext of this size holds. */ +export function zne1PlaintextLength(ciphertextLength: number): number { + const body = ciphertextLength - ZNE1_HEADER_BYTES + if (!Number.isSafeInteger(ciphertextLength) || body < ZNE1_TAG_BYTES) { + throw new CloudE2eeError('malformed', 'The encrypted data is too short.') + } + const fullChunks = Math.floor(body / ZNE1_SEALED_CHUNK_BYTES) + const rest = body % ZNE1_SEALED_CHUNK_BYTES + if (rest === 0) return fullChunks * ZNE1_CHUNK_BYTES + if (rest === ZNE1_TAG_BYTES && fullChunks === 0) return 0 + if (rest <= ZNE1_TAG_BYTES) { + // A trailing piece of 16 bytes or fewer after a full chunk would be an + // empty or partial last chunk, which the format never produces. + throw new CloudE2eeError('malformed', 'The encrypted data has an impossible length.') + } + return fullChunks * ZNE1_CHUNK_BYTES + rest - ZNE1_TAG_BYTES +} + +function chunkNonce(index: number, last: boolean): Bytes { + if (!Number.isInteger(index) || index < 0 || index > 0xffffffff) { + throw new CloudE2eeError('malformed', 'Too many ZNE1 chunks.') + } + const nonce = new Uint8Array(12) as Bytes + // Bytes 0 to 10 are the chunk index as an 11-byte big-endian integer. + new DataView(nonce.buffer).setUint32(7, index) + nonce[11] = last ? 0x01 : 0x00 + return nonce +} + +/** Starts an object: a fresh salt, its keys and header. Native chunk loops take it from here. */ +export async function beginZne1Encryption(options: Zne1EncryptOptions): Promise { + const salt = options.salt ? own(options.salt) : randomBytes(32) + const { objectKey, commitment } = await deriveObjectSecrets(options.parentKey, salt, options.purpose) + const header = buildHeader(options.purpose, options.keyVersion, salt, commitment) + const aad = await sha256(concat(header, options.context ?? new Uint8Array(0))) + return { header, objectKey, aad } +} + +/** + * Checks an object's header against the key that should open it and returns + * what its chunks need. Nothing is decrypted when the commitment does not + * match: a wrong key and a wrong passphrase are reported as `wrong-key`. + */ +export async function beginZne1Decryption( + data: Uint8Array, + options: Zne1DecryptOptions +): Promise { + const parsed = parseZne1Header(data) + if (parsed.purpose !== options.purpose) { + throw new CloudE2eeError('wrong-purpose', 'The encrypted data was made for a different purpose.') + } + if (options.keyVersion !== undefined && parsed.keyVersion !== options.keyVersion) { + throw new CloudE2eeError('wrong-key-version', `The encrypted data needs key version ${parsed.keyVersion}.`) + } + const { objectKey, commitment } = await deriveObjectSecrets(options.parentKey, parsed.salt, parsed.purpose) + if (!constantTimeEqual(commitment, parsed.commitment)) { + throw new CloudE2eeError('wrong-key', 'The key does not open this encrypted data.') + } + const aad = await sha256(concat(parsed.bytes, options.context ?? new Uint8Array(0))) + return { header: parsed.bytes, objectKey, aad, parsed } +} + +async function importChunkKey(objectKey: Uint8Array) { + return crypto.subtle.importKey('raw', own(objectKey), 'AES-GCM', false, ['encrypt', 'decrypt']) +} + +export interface Zne1Encryptor { + readonly header: Bytes + /** + * Seals the next chunk. Every chunk but the last is exactly 65536 bytes; the + * last holds 1 to 65536 bytes, or 0 when it is the only one. + */ + encryptChunk(chunk: Uint8Array, last: boolean): Promise +} + +export async function createZne1Encryptor(options: Zne1EncryptOptions): Promise { + const params = await beginZne1Encryption(options) + const key = await importChunkKey(params.objectKey) + let index = 0 + let finished = false + return { + header: params.header, + async encryptChunk(chunk, last) { + if (finished) throw new CloudE2eeError('malformed', 'The last ZNE1 chunk was already sealed.') + const size = chunk.byteLength + const valid = last + ? size <= ZNE1_CHUNK_BYTES && (size > 0 || index === 0) + : size === ZNE1_CHUNK_BYTES + if (!valid) throw new CloudE2eeError('malformed', `A ZNE1 chunk of ${size} bytes is not allowed here.`) + const sealed = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv: chunkNonce(index, last), additionalData: params.aad, tagLength: 128 }, + key, + own(chunk) + ) + index++ + finished = last + return new Uint8Array(sealed) as Bytes + } + } +} + +export interface Zne1Decryptor { + readonly header: Zne1Header + /** + * Opens the next sealed chunk. Callers must say whether it is the last one; + * a stream reader holds one chunk back to know. + */ + decryptChunk(sealed: Uint8Array, last: boolean): Promise +} + +export async function createZne1Decryptor( + data: Uint8Array, + options: Zne1DecryptOptions +): Promise { + const params = await beginZne1Decryption(data, options) + const key = await importChunkKey(params.objectKey) + let index = 0 + let finished = false + return { + header: params.parsed, + async decryptChunk(sealed, last) { + if (finished) throw new CloudE2eeError('tampered', 'The encrypted data continues past its last chunk.') + const size = sealed.byteLength + const valid = last + ? size >= ZNE1_TAG_BYTES && size <= ZNE1_SEALED_CHUNK_BYTES && (size > ZNE1_TAG_BYTES || index === 0) + : size === ZNE1_SEALED_CHUNK_BYTES + if (!valid) throw new CloudE2eeError('malformed', `A sealed ZNE1 chunk of ${size} bytes is not allowed here.`) + let opened: ArrayBuffer + try { + opened = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: chunkNonce(index, last), additionalData: params.aad, tagLength: 128 }, + key, + own(sealed) + ) + } catch { + throw new CloudE2eeError('tampered', 'The encrypted data was changed or is incomplete.') + } + index++ + finished = last + return new Uint8Array(opened) as Bytes + } + } +} + +export async function encryptZne1(plaintext: Uint8Array, options: Zne1EncryptOptions): Promise { + const encryptor = await createZne1Encryptor(options) + const out = new Uint8Array(zne1CiphertextLength(plaintext.byteLength)) as Bytes + out.set(encryptor.header, 0) + let offset = ZNE1_HEADER_BYTES + let start = 0 + do { + const end = Math.min(start + ZNE1_CHUNK_BYTES, plaintext.byteLength) + const last = end === plaintext.byteLength + const sealed = await encryptor.encryptChunk(plaintext.subarray(start, end), last) + out.set(sealed, offset) + offset += sealed.byteLength + start = end + } while (start < plaintext.byteLength) + return out +} + +export async function decryptZne1(data: Uint8Array, options: Zne1DecryptOptions): Promise { + const decryptor = await createZne1Decryptor(data, options) + const plaintext = new Uint8Array(zne1PlaintextLength(data.byteLength)) as Bytes + let offset = ZNE1_HEADER_BYTES + let written = 0 + while (offset < data.byteLength) { + const end = Math.min(offset + ZNE1_SEALED_CHUNK_BYTES, data.byteLength) + const opened = await decryptor.decryptChunk(data.subarray(offset, end), end === data.byteLength) + plaintext.set(opened, written) + written += opened.byteLength + offset = end + } + return plaintext +} + +/** + * Encrypts a byte stream of any chunking into a ZNE1 object: the header, then + * one sealed chunk per 65536 plaintext bytes. A full chunk is sealed only once + * more data follows it, because the last chunk must be marked as last; memory + * stays near two chunks however large the file is. + */ +export async function* encryptZne1Stream( + source: AsyncIterable | Iterable, + options: Zne1EncryptOptions +): AsyncGenerator { + const encryptor = await createZne1Encryptor(options) + yield encryptor.header + let pending: Uint8Array = new Uint8Array(0) + for await (const piece of source) { + pending = pending.byteLength === 0 ? piece : concat(pending, piece) + while (pending.byteLength > ZNE1_CHUNK_BYTES) { + yield await encryptor.encryptChunk(pending.subarray(0, ZNE1_CHUNK_BYTES), false) + pending = pending.subarray(ZNE1_CHUNK_BYTES) + } + } + yield await encryptor.encryptChunk(pending, true) +} + +/** + * Decrypts a ZNE1 byte stream of any chunking. It yields plaintext only for + * chunks that authenticated, so a caller writing to disk never writes forged + * bytes; a stream that stops early fails on its last chunk. + */ +export async function* decryptZne1Stream( + source: AsyncIterable | Iterable, + options: Zne1DecryptOptions +): AsyncGenerator { + let pending: Uint8Array = new Uint8Array(0) + let decryptor: Zne1Decryptor | undefined + for await (const piece of source) { + pending = pending.byteLength === 0 ? piece : concat(pending, piece) + if (!decryptor) { + if (pending.byteLength < ZNE1_HEADER_BYTES) continue + decryptor = await createZne1Decryptor(pending.subarray(0, ZNE1_HEADER_BYTES), options) + pending = pending.subarray(ZNE1_HEADER_BYTES) + } + while (pending.byteLength > ZNE1_SEALED_CHUNK_BYTES) { + yield await decryptor.decryptChunk(pending.subarray(0, ZNE1_SEALED_CHUNK_BYTES), false) + pending = pending.subarray(ZNE1_SEALED_CHUNK_BYTES) + } + } + if (!decryptor) throw new CloudE2eeError('malformed', 'The encrypted data is shorter than a ZNE1 header.') + if (pending.byteLength < ZNE1_TAG_BYTES) { + throw new CloudE2eeError('tampered', 'The encrypted data was changed or is incomplete.') + } + yield await decryptor.decryptChunk(pending, true) +} + +// --------------------------------------------------------------------------- +// Keys +// --------------------------------------------------------------------------- + +export interface CloudE2eeKdfParams { + algorithm: 'argon2id' + version: 19 + memory_kib: number + iterations: number + parallelism: number + /** Base64 of 16 random bytes. */ + salt: string +} + +export const CLOUD_E2EE_KDF_DEFAULTS = { + algorithm: 'argon2id', + version: 19, + memory_kib: 65536, + iterations: 3, + parallelism: 4 +} as const + +// A keyset comes from the server. These bounds keep a hostile or broken one +// from exhausting a phone's memory, or from passing weak parameters off as +// real ones (it could not open the envelope anyway; this just fails early). +const KDF_BOUNDS = { + memory_kib: [19456, 1048576], + iterations: [2, 20], + parallelism: [1, 16] +} as const + +export function newCloudE2eeKdfParams(): CloudE2eeKdfParams { + return { ...CLOUD_E2EE_KDF_DEFAULTS, salt: toBase64(randomBytes(16)) } +} + +function assertKdfParams(kdf: CloudE2eeKdfParams): Bytes { + const inRange = (value: number, [min, max]: readonly [number, number]) => + Number.isInteger(value) && value >= min && value <= max + if ( + kdf.algorithm !== 'argon2id' || + kdf.version !== 19 || + !inRange(kdf.memory_kib, KDF_BOUNDS.memory_kib) || + !inRange(kdf.iterations, KDF_BOUNDS.iterations) || + !inRange(kdf.parallelism, KDF_BOUNDS.parallelism) + ) { + throw new CloudE2eeError('invalid-kdf', 'The encryption settings from ZenNotes Cloud are not usable.') + } + const salt = fromBase64(kdf.salt) + if (salt.byteLength !== 16) throw new CloudE2eeError('invalid-kdf', 'The passphrase salt must be 16 bytes.') + return salt +} + +/** The passphrase key (PK): Argon2id over the NFKC-normalized passphrase. */ +export async function derivePassphraseKey(passphrase: string, kdf: CloudE2eeKdfParams): Promise { + const salt = assertKdfParams(kdf) + const output = await argon2id({ + password: encoder.encode(passphrase.normalize('NFKC')), + salt, + iterations: kdf.iterations, + parallelism: kdf.parallelism, + memorySize: kdf.memory_kib, + hashLength: 32, + outputType: 'binary' + }) + return own(output) +} + +export function generateCloudE2eeKey(): Bytes { + return randomBytes(CLOUD_E2EE_KEY_BYTES) +} + +/** A vault key's public id: 16 random bytes as hex, bound into its envelope. */ +export function newVaultKeyId(): string { + return toHex(randomBytes(16)) +} + +export type AccountKeyWrapping = 'passphrase' | 'recovery' + +export async function wrapAccountKey( + accountKey: Uint8Array, + wrappingKey: Uint8Array, + wrapping: AccountKeyWrapping, + salt?: Uint8Array +): Promise { + assertKey(accountKey, 'The account key') + const sealed = await encryptZne1(accountKey, { + parentKey: wrappingKey, + purpose: Zne1Purpose.accountKey, + keyVersion: 0, + context: utf8(wrapping), + salt + }) + return toBase64(sealed) +} + +export async function unwrapAccountKey( + envelope: string, + wrappingKey: Uint8Array, + wrapping: AccountKeyWrapping +): Promise { + const key = await decryptZne1(fromBase64(envelope), { + parentKey: wrappingKey, + purpose: Zne1Purpose.accountKey, + keyVersion: 0, + context: utf8(wrapping) + }) + assertKey(key, 'The unwrapped account key') + return key +} + +export async function wrapVaultKey( + vaultKey: Uint8Array, + accountKey: Uint8Array, + accountKeyVersion: number, + vaultKeyId: string, + salt?: Uint8Array +): Promise { + assertKey(vaultKey, 'The vault key') + const sealed = await encryptZne1(vaultKey, { + parentKey: accountKey, + purpose: Zne1Purpose.vaultKey, + keyVersion: accountKeyVersion, + context: utf8(vaultKeyId), + salt + }) + return toBase64(sealed) +} + +export async function unwrapVaultKey( + envelope: string, + accountKey: Uint8Array, + accountKeyVersion: number, + vaultKeyId: string +): Promise { + const key = await decryptZne1(fromBase64(envelope), { + parentKey: accountKey, + purpose: Zne1Purpose.vaultKey, + keyVersion: accountKeyVersion, + context: utf8(vaultKeyId) + }) + assertKey(key, 'The unwrapped vault key') + return key +} + +// --------------------------------------------------------------------------- +// Recovery key +// --------------------------------------------------------------------------- + +export function recoveryKeyToWords(recoveryKey: Uint8Array): string[] { + assertKey(recoveryKey, 'The recovery key') + return entropyToMnemonic(own(recoveryKey), englishWords).split(' ') +} + +const wordByPrefix = new Map() +for (const word of englishWords) wordByPrefix.set(word.slice(0, 4), word) +const knownWords = new Set(englishWords) + +/** + * Reads a recovery key typed or pasted back in. Case, extra spaces, line + * breaks and list numbering are ignored, and a word's first four letters are + * enough (BIP-39 words are unique in their first four). The checksum catches + * a mistyped word before any key work runs. + */ +export function recoveryKeyFromWords(input: string): Bytes { + const tokens = input + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter((token) => token !== '' && !/^\d+$/.test(token)) + if (tokens.length !== 24) { + throw new CloudE2eeError('invalid-recovery-key', `A recovery key has 24 words; this has ${tokens.length}.`) + } + const words = tokens.map((token, index) => { + if (knownWords.has(token)) return token + const word = token.length >= 4 ? wordByPrefix.get(token.slice(0, 4)) : undefined + if (!word) { + throw new CloudE2eeError('invalid-recovery-key', `Word ${index + 1} ("${token}") is not a recovery key word.`) + } + return word + }) + try { + return own(mnemonicToEntropy(words.join(' '), englishWords)) + } catch { + throw new CloudE2eeError('invalid-recovery-key', 'These words are not a valid recovery key. Check for a mistyped word.') + } +} + +// --------------------------------------------------------------------------- +// Path ids +// --------------------------------------------------------------------------- + +/** The server's path_key, recomputed by the client: slashes, NFC, lower case. */ +export function canonicalCloudPath(path: string): string { + return path.replace(/\\/g, '/').normalize('NFC').toLowerCase() +} + +export async function derivePathIndexKey(vaultKey: Uint8Array): Promise { + assertKey(vaultKey, 'The vault key') + return hkdfSha256(vaultKey, new Uint8Array(32), utf8('ZNE1 path-index'), 32) +} + +/** Turns real paths into the 64-character ids the server stores in their place. */ +export class CloudPathIndex { + private constructor(private readonly key: Awaited>) {} + + static async forVaultKey(vaultKey: Uint8Array): Promise { + const pathKey = await derivePathIndexKey(vaultKey) + const key = await crypto.subtle.importKey('raw', pathKey, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']) + return new CloudPathIndex(key) + } + + async id(path: string): Promise { + const mac = await crypto.subtle.sign('HMAC', this.key, utf8(canonicalCloudPath(path))) + return toHex(new Uint8Array(mac)) + } +} + +export function isCloudPathId(value: string): boolean { + return /^[0-9a-f]{64}$/.test(value) +} + +// --------------------------------------------------------------------------- +// Item metadata and vault names +// --------------------------------------------------------------------------- + +export interface CloudE2eeItemMeta { + path: string + kind: 'text' | 'binary' + encoding: 'utf8' | 'base64' + media_type: string + /** SHA-256 of the plaintext, hex. */ + sha256: string + byte_length: number +} + +function padWithSpaces(json: string, multiple: number): Bytes { + const raw = encoder.encode(json) + const padded = new Uint8Array(Math.ceil(Math.max(raw.byteLength, 1) / multiple) * multiple) as Bytes + padded.fill(0x20) + padded.set(raw, 0) + return padded +} + +function readPaddedJson(data: Uint8Array): unknown { + try { + return JSON.parse(decoder.decode(data)) + } catch { + throw new CloudE2eeError('invalid-metadata', 'The encrypted metadata is not valid JSON.') + } +} + +const HEX_SHA256 = /^[0-9a-f]{64}$/ + +function assertItemMeta(value: unknown): CloudE2eeItemMeta { + const meta = value as Partial & { v?: unknown } + const valid = + typeof value === 'object' && + value !== null && + meta.v === 1 && + typeof meta.path === 'string' && + meta.path !== '' && + (meta.kind === 'text' || meta.kind === 'binary') && + (meta.encoding === 'utf8' || meta.encoding === 'base64') && + typeof meta.media_type === 'string' && + typeof meta.sha256 === 'string' && + HEX_SHA256.test(meta.sha256) && + Number.isSafeInteger(meta.byte_length) && + meta.byte_length! >= 0 + if (!valid) throw new CloudE2eeError('invalid-metadata', 'The encrypted metadata is incomplete.') + return { + path: meta.path!, + kind: meta.kind!, + encoding: meta.encoding!, + media_type: meta.media_type!, + sha256: meta.sha256!, + byte_length: meta.byte_length! + } +} + +export async function encryptItemMeta( + meta: CloudE2eeItemMeta, + options: { vaultKey: Uint8Array; vaultKeyVersion: number; pathId: string; salt?: Uint8Array } +): Promise { + assertItemMeta({ v: 1, ...meta }) + if (!isCloudPathId(options.pathId)) throw new CloudE2eeError('invalid-metadata', 'Expected a path id.') + const json = JSON.stringify({ + v: 1, + path: meta.path, + kind: meta.kind, + encoding: meta.encoding, + media_type: meta.media_type, + sha256: meta.sha256, + byte_length: meta.byte_length + }) + const sealed = await encryptZne1(padWithSpaces(json, 256), { + parentKey: options.vaultKey, + purpose: Zne1Purpose.itemMeta, + keyVersion: options.vaultKeyVersion, + context: utf8(options.pathId), + salt: options.salt + }) + return toBase64(sealed) +} + +/** + * Opens an item's metadata and checks that its real path is the one its path + * id stands for, so the server's uniqueness rules really cover real paths. + */ +export async function decryptItemMeta( + envelope: string, + options: { vaultKey: Uint8Array; vaultKeyVersion?: number; pathId: string; pathIndex: CloudPathIndex } +): Promise { + const plaintext = await decryptZne1(fromBase64(envelope), { + parentKey: options.vaultKey, + purpose: Zne1Purpose.itemMeta, + keyVersion: options.vaultKeyVersion, + context: utf8(options.pathId) + }) + const meta = assertItemMeta(readPaddedJson(plaintext)) + if ((await options.pathIndex.id(meta.path)) !== options.pathId) { + throw new CloudE2eeError('invalid-metadata', 'The encrypted metadata names a different path than its id.') + } + return meta +} + +export async function encryptVaultName( + name: string, + options: { vaultKey: Uint8Array; vaultKeyVersion: number; salt?: Uint8Array } +): Promise { + const sealed = await encryptZne1(padWithSpaces(JSON.stringify({ v: 1, name }), 64), { + parentKey: options.vaultKey, + purpose: Zne1Purpose.vaultName, + keyVersion: options.vaultKeyVersion, + salt: options.salt + }) + return toBase64(sealed) +} + +export async function decryptVaultName( + envelope: string, + options: { vaultKey: Uint8Array; vaultKeyVersion?: number } +): Promise { + const plaintext = await decryptZne1(fromBase64(envelope), { + parentKey: options.vaultKey, + purpose: Zne1Purpose.vaultName, + keyVersion: options.vaultKeyVersion + }) + const value = readPaddedJson(plaintext) as { v?: unknown; name?: unknown } + if (value?.v !== 1 || typeof value.name !== 'string') { + throw new CloudE2eeError('invalid-metadata', 'The encrypted vault name is incomplete.') + } + return value.name +} From 7fb58df347dbd04363603b3a98d239368803d6f4 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 12 Oct 2026 10:10:26 -0500 Subject: [PATCH 4/8] Feat(cloud): sync seals encrypted vaults on the way out and opens them 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. --- packages/bridge-contract/src/cloud-sync.ts | 128 ++++- .../shared-domain/src/cloud-e2ee-account.ts | 262 ++++++++++ .../src/cloud-e2ee-passphrase.ts | 22 + .../src/cloud-e2ee-vault.test.ts | 422 ++++++++++++++++ .../shared-domain/src/cloud-e2ee-vault.ts | 461 ++++++++++++++++++ packages/shared-domain/src/cloud-sync-api.ts | 213 ++++++-- .../shared-domain/src/cloud-sync-content.ts | 78 ++- .../src/cloud-sync-coordinator.ts | 9 +- .../shared-domain/src/cloud-sync-engine.ts | 23 +- 9 files changed, 1575 insertions(+), 43 deletions(-) create mode 100644 packages/shared-domain/src/cloud-e2ee-account.ts create mode 100644 packages/shared-domain/src/cloud-e2ee-passphrase.ts create mode 100644 packages/shared-domain/src/cloud-e2ee-vault.test.ts create mode 100644 packages/shared-domain/src/cloud-e2ee-vault.ts diff --git a/packages/bridge-contract/src/cloud-sync.ts b/packages/bridge-contract/src/cloud-sync.ts index 045332e7e..0a953a713 100644 --- a/packages/bridge-contract/src/cloud-sync.ts +++ b/packages/bridge-contract/src/cloud-sync.ts @@ -11,9 +11,23 @@ export interface CloudSyncContent { export type CloudSyncContentMetadata = Omit +/** + * The ciphertext an end-to-end encrypted vault stores for a piece of content. + * docs/specs/cloud-e2ee.md describes the format. + */ +export interface CloudSyncEncryptedContent { + sha256: string + byte_length: number +} + export interface CloudSyncContentReference extends CloudSyncContentMetadata { item_id: string revision: number + /** + * Set by the vault cipher for encrypted vaults: the metadata above describes + * the plaintext, this describes the bytes the download actually returns. + */ + encrypted?: CloudSyncEncryptedContent } export interface CloudSyncContentRequestOptions { @@ -49,11 +63,21 @@ export interface CloudSyncUpsertMutation extends CloudSyncMutationBase { path: string; kind: CloudSyncItemKind; content: CloudSyncContent; + /** Encrypted vaults only: the sealed item metadata (base64 ZNE1). */ + meta?: string; } export interface CloudSyncMoveMutation extends CloudSyncMutationBase { type: "move"; path: string; + /** Encrypted vaults only: the sealed item metadata for the new path. */ + meta?: string; + /** + * Client-side hints the sync engine attaches so an encrypted vault can seal + * the new path's metadata. Never sent to the service. + */ + kind?: CloudSyncItemKind; + content_metadata?: CloudSyncContentMetadata; } export interface CloudSyncDeleteMutation extends CloudSyncMutationBase { @@ -99,6 +123,8 @@ export interface CloudSyncConflict { code: CloudSyncConflictCode; current_revision: number | null; current_path: string | null; + /** Encrypted vaults: the sealed metadata behind `current_path`. */ + current_meta?: string | null; capacity?: CloudSyncCapacityConflict; /** * The vault-relative path of the file the rejected operation was about, as @@ -125,6 +151,8 @@ export interface CloudSyncUploadRequest { CloudSyncContent, "encoding" | "sha256" | "byte_length" | "media_type" >; + /** Encrypted vaults only: the sealed item metadata. */ + meta?: string; } export interface CloudSyncUploadInitiationResponse { @@ -161,6 +189,8 @@ export interface CloudSyncManifestItem { media_type: string; content?: CloudSyncContent; content_ref?: CloudSyncContentReference + /** Encrypted vaults: the sealed item metadata. */ + meta?: string | null; } export interface CloudSyncManifestResponse { @@ -178,6 +208,9 @@ export interface CloudSyncChange { revision: number; content?: CloudSyncContent; content_ref?: CloudSyncContentReference + /** Encrypted vaults: sealed metadata for `path` and `previous_path`. */ + meta?: string | null; + previous_meta?: string | null; } export interface CloudSyncChangeResponse { @@ -194,18 +227,34 @@ export interface CloudSyncRevision { deleted: boolean; content?: CloudSyncContent | null; content_ref?: CloudSyncContentReference + /** Encrypted vaults: the sealed item metadata. */ + meta?: string | null; } export interface CloudSyncRevisionResponse { data: CloudSyncRevision; } +/** How an end-to-end encrypted vault's key and name are sealed (docs/specs/cloud-e2ee.md). */ +export interface CloudSyncVaultEncryption { + scheme: "zne1"; + /** 32 lowercase hex characters, bound into the vault key's envelope. */ + key_id: string; + key_version: number; + /** The vault key sealed under the account key, base64 ZNE1. */ + vault_key_envelope: string; + /** The real vault name, base64 ZNE1; `name` holds a placeholder. */ + name: string; +} + export interface CloudSyncVault { id: string; name: string; cursor: number; created_at: string; updated_at: string; + /** Absent or null for a readable vault. Fixed when the vault is created. */ + encryption?: CloudSyncVaultEncryption | null; } export interface CloudSyncVaultResponse { @@ -276,6 +325,8 @@ export interface CloudBackupSnapshotItem { revision: number; content_hash: string | null; media_type: string | null; + /** Encrypted vaults: the sealed item metadata; `path` is a path id. */ + meta?: string | null; } export interface CloudBackupSnapshotItemCollection { @@ -318,6 +369,8 @@ export interface CloudBackupNoteRestore { status: "completed" | "conflict"; item_id: string; path: string; + /** Encrypted vaults: the sealed item metadata; `path` is a path id. */ + meta?: string | null; revision: number | null; cursor: number; error_code: string | null; @@ -434,7 +487,9 @@ export interface CloudUsage { } export interface CloudServiceAccount { - capabilities?: { content_references?: boolean } + capabilities?: { content_references?: boolean; end_to_end_encryption?: boolean } + /** Absent from a service without end-to-end encryption. */ + encryption?: { keyset: boolean } user: CloudAccountUser; device: CloudAccountDevice & { app_version: string | null }; features: Record< @@ -543,6 +598,12 @@ export interface CloudVaultLink { vault_id: string; vault_name: string; linked_at: string; + /** + * Set while this local vault moves from a readable Cloud vault into a new + * end-to-end encrypted one: the readable vault is retired once everything + * has uploaded, and the field is then removed. + */ + replaces_vault_id?: string; } export interface CloudSyncBootstrapConflict { @@ -705,3 +766,68 @@ export interface CloudSyncWindowHandlers { prepare(): Promise finished(summary: CloudSyncRunSummary | null, error: string | null): void } + +/** End-to-end encryption as the Settings page sees it for the signed-in account. */ +export interface CloudEncryptionStatus { + /** ZenNotes Cloud accepts encrypted vaults for this account and this app can open them. */ + available: boolean; + /** The account has set up encryption. */ + keyset: boolean; + /** This device holds the unlocked account key. */ + unlocked: boolean; +} + +/** Recovery words to show and confirm before setup or a new recovery key takes effect. */ +export interface CloudEncryptionRecoveryPrompt { + setupId: string; + recoveryWords: string[]; +} + +/** Either secret proves the person may change the account's encryption. */ +export type CloudEncryptionSecretInput = { passphrase: string } | { recoveryWords: string }; + +/** Argon2id settings for the encryption passphrase, stored with the keyset. */ +export interface CloudEncryptionKdf { + algorithm: "argon2id"; + version: 19; + memory_kib: number; + iterations: number; + parallelism: number; + /** Base64 of 16 random bytes. */ + salt: string; +} + +/** + * An account's end-to-end encryption keyset: the account key sealed under the + * passphrase and, separately, under the recovery key. The service never holds + * either secret. + */ +export interface CloudEncryptionKeyset { + format: 1; + account_key_version: number; + kdf: CloudEncryptionKdf; + passphrase_envelope: string; + recovery_envelope: string; + revision: number; + updated_at: string; +} + +export interface CloudEncryptionKeysetResponse { + data: CloudEncryptionKeyset; +} + +export interface CloudEncryptionKeysetCreate { + format: 1; + account_key_version: number; + kdf: CloudEncryptionKdf; + passphrase_envelope: string; + recovery_envelope: string; +} + +/** A passphrase change replaces kdf and passphrase_envelope; a new recovery key replaces recovery_envelope. */ +export interface CloudEncryptionKeysetUpdate { + expected_revision: number; + kdf?: CloudEncryptionKdf; + passphrase_envelope?: string; + recovery_envelope?: string; +} diff --git a/packages/shared-domain/src/cloud-e2ee-account.ts b/packages/shared-domain/src/cloud-e2ee-account.ts new file mode 100644 index 000000000..a3d0987cc --- /dev/null +++ b/packages/shared-domain/src/cloud-e2ee-account.ts @@ -0,0 +1,262 @@ +/** + * The account side of end-to-end encryption: setting it up, unlocking a + * device with the passphrase or the recovery key, changing the passphrase, + * replacing the recovery key, and finding each vault's cipher + * (docs/specs/cloud-e2ee.md). Hosts supply the keychain the unlocked account + * key lives in and the HTTP calls; nothing here touches a disk or a network. + */ +import type { + CloudEncryptionKeyset, + CloudEncryptionKeysetCreate, + CloudEncryptionKeysetUpdate, + CloudSyncVault +} from '@zennotes/bridge-contract/cloud-sync' +import { + type Bytes, + CloudE2eeError, + type CloudE2eeKdfParams, + constantTimeEqual, + derivePassphraseKey, + fromBase64, + generateCloudE2eeKey, + newCloudE2eeKdfParams, + recoveryKeyFromWords, + recoveryKeyToWords, + toBase64, + unwrapAccountKey, + wrapAccountKey +} from './cloud-e2ee' +import { CloudE2eeLockedError, CloudVaultCipher, type CloudVaultCipherSource } from './cloud-e2ee-vault' +import { cloudPassphraseProblem } from './cloud-e2ee-passphrase' + +const ACCOUNT_KEY_VERSION = 1 + +export { CLOUD_PASSPHRASE_MIN_LENGTH, cloudPassphraseProblem } from './cloud-e2ee-passphrase' + +/** The unlocked account key a device keeps in its platform keychain, never on the server. */ +export interface StoredCloudAccountKey { + format: 1 + /** Base64 of the 32-byte account key. */ + key: string + version: number +} + +export interface CloudAccountKeyStore { + load(): Promise + save(value: StoredCloudAccountKey): Promise + clear(): Promise +} + +export interface CloudKeysetApi { + /** Null when the account has no keyset yet. */ + getKeyset(): Promise + createKeyset(body: CloudEncryptionKeysetCreate): Promise + updateKeyset(body: CloudEncryptionKeysetUpdate): Promise +} + +/** Proof that the person knows one of the account's two secrets. */ +export type CloudEncryptionSecret = { passphrase: string } | { recoveryWords: string } + +function storedKey(accountKey: Uint8Array, version: number): StoredCloudAccountKey { + return { format: 1, key: toBase64(accountKey), version } +} + +async function requireKeyset(api: CloudKeysetApi): Promise { + const keyset = await api.getKeyset() + if (!keyset) throw new CloudE2eeError('invalid-key', 'End-to-end encryption is not set up for this account.') + return keyset +} + +/** Opens the account key with either secret. A wrong secret is reported plainly. */ +async function openAccountKey(keyset: CloudEncryptionKeyset, secret: CloudEncryptionSecret): Promise { + try { + if ('passphrase' in secret) { + const passphraseKey = await derivePassphraseKey(secret.passphrase, keyset.kdf as CloudE2eeKdfParams) + return await unwrapAccountKey(keyset.passphrase_envelope, passphraseKey, 'passphrase') + } + const recoveryKey = recoveryKeyFromWords(secret.recoveryWords) + return await unwrapAccountKey(keyset.recovery_envelope, recoveryKey, 'recovery') + } catch (error) { + if (error instanceof CloudE2eeError && error.code === 'wrong-key') { + throw new CloudE2eeError( + 'wrong-key', + 'passphrase' in secret + ? 'That passphrase does not unlock your encryption.' + : 'That recovery key does not unlock your encryption.' + ) + } + throw error + } +} + +/** + * Before anything reaches the service, the person sees the recovery key and + * proves they kept it. `finish` then creates the keyset and unlocks this + * device; until it runs, the account is unchanged. + */ +export interface PendingCloudEncryptionSetup { + recoveryWords: string[] + finish(): Promise +} + +export async function prepareCloudEncryptionSetup( + api: CloudKeysetApi, + store: CloudAccountKeyStore, + passphrase: string +): Promise { + const problem = cloudPassphraseProblem(passphrase) + if (problem) throw new CloudE2eeError('invalid-key', problem) + if (await api.getKeyset()) { + throw new CloudE2eeError('invalid-key', 'End-to-end encryption is already set up for this account.') + } + const accountKey = generateCloudE2eeKey() + const recoveryKey = generateCloudE2eeKey() + const kdf = newCloudE2eeKdfParams() + const passphraseKey = await derivePassphraseKey(passphrase, kdf) + const body: CloudEncryptionKeysetCreate = { + format: 1, + account_key_version: ACCOUNT_KEY_VERSION, + kdf, + passphrase_envelope: await wrapAccountKey(accountKey, passphraseKey, 'passphrase'), + recovery_envelope: await wrapAccountKey(accountKey, recoveryKey, 'recovery') + } + let finished = false + return { + recoveryWords: recoveryKeyToWords(recoveryKey), + async finish() { + if (finished) return + // The service first, then this device: a keyset that exists without a + // local key just asks for the passphrase; a local key without a keyset + // would be a key nothing can recover. + const created = await api.createKeyset(body) + await store.save(storedKey(accountKey, created.account_key_version)) + finished = true + } + } +} + +export async function unlockCloudEncryption( + api: CloudKeysetApi, + store: CloudAccountKeyStore, + secret: CloudEncryptionSecret +): Promise { + const keyset = await requireKeyset(api) + const accountKey = await openAccountKey(keyset, secret) + await store.save(storedKey(accountKey, keyset.account_key_version)) +} + +/** + * Re-wraps the account key under a new passphrase. The current passphrase or + * the recovery key must open the keyset first, and must open the same key this + * device holds, so a swapped keyset is noticed instead of adopted. + */ +export async function changeCloudEncryptionPassphrase( + api: CloudKeysetApi, + store: CloudAccountKeyStore, + current: CloudEncryptionSecret, + next: string +): Promise { + const problem = cloudPassphraseProblem(next) + if (problem) throw new CloudE2eeError('invalid-key', problem) + const keyset = await requireKeyset(api) + const accountKey = await openAccountKey(keyset, current) + await assertSameStoredKey(store, accountKey) + const kdf = newCloudE2eeKdfParams() + const passphraseKey = await derivePassphraseKey(next, kdf) + await api.updateKeyset({ + expected_revision: keyset.revision, + kdf, + passphrase_envelope: await wrapAccountKey(accountKey, passphraseKey, 'passphrase') + }) + await store.save(storedKey(accountKey, keyset.account_key_version)) +} + +/** Replaces the recovery key. Like setup, nothing changes until `finish` runs. */ +export async function prepareNewCloudRecoveryKey( + api: CloudKeysetApi, + store: CloudAccountKeyStore, + current: CloudEncryptionSecret +): Promise { + const keyset = await requireKeyset(api) + const accountKey = await openAccountKey(keyset, current) + await assertSameStoredKey(store, accountKey) + const recoveryKey = generateCloudE2eeKey() + const envelope = await wrapAccountKey(accountKey, recoveryKey, 'recovery') + let finished = false + return { + recoveryWords: recoveryKeyToWords(recoveryKey), + async finish() { + if (finished) return + await api.updateKeyset({ expected_revision: keyset.revision, recovery_envelope: envelope }) + finished = true + } + } +} + +async function assertSameStoredKey(store: CloudAccountKeyStore, accountKey: Uint8Array): Promise { + const stored = await store.load() + if (stored && !constantTimeEqual(fromBase64(stored.key), accountKey)) { + throw new CloudE2eeError( + 'wrong-key', + 'Your encryption keys on ZenNotes Cloud changed since this device unlocked. Lock this device and unlock it again.' + ) + } +} + +/** + * Finds each vault's cipher for the API client. Whether a vault is encrypted + * is fixed when it is created, so the answer is cached for the session; the + * account key is read from the keychain each time a cipher is first opened. + */ +export class CloudE2eeKeyring implements CloudVaultCipherSource { + private readonly vaults = new Map() + private readonly ciphers = new Map>() + + constructor( + private readonly deps: { + listVaults(): Promise + store: CloudAccountKeyStore + } + ) {} + + /** Hosts that already hold a vault resource can hand it over and skip a request. */ + remember(vault: CloudSyncVault): void { + this.vaults.set(vault.id, vault) + } + + async forVault(vaultId: string): Promise { + let vault = this.vaults.get(vaultId) + if (!vault) { + for (const listed of await this.deps.listVaults()) this.vaults.set(listed.id, listed) + vault = this.vaults.get(vaultId) + if (!vault) throw new Error('ZenNotes Cloud no longer has this vault.') + } + if (!vault.encryption) return null + const cacheKey = `${vault.id}:${vault.encryption.key_id}` + let cipher = this.ciphers.get(cacheKey) + if (!cipher) { + const encrypted = vault + cipher = (async () => { + const stored = await this.deps.store.load() + if (!stored) throw new CloudE2eeLockedError() + return CloudVaultCipher.open(encrypted, fromBase64(stored.key), stored.version) + })() + this.ciphers.set(cacheKey, cipher) + cipher.catch(() => this.ciphers.delete(cacheKey)) + } + return cipher + } + + /** The real name of an encrypted vault, or its stored name for a readable one. */ + async vaultName(vault: CloudSyncVault): Promise { + if (!vault.encryption) return vault.name + this.remember(vault) + const cipher = await this.forVault(vault.id) + return cipher ? cipher.vaultName(vault.encryption) : vault.name + } + + /** Drops every cached cipher, for example after the device locks or signs out. */ + forget(): void { + this.ciphers.clear() + } +} diff --git a/packages/shared-domain/src/cloud-e2ee-passphrase.ts b/packages/shared-domain/src/cloud-e2ee-passphrase.ts new file mode 100644 index 000000000..67ce199ad --- /dev/null +++ b/packages/shared-domain/src/cloud-e2ee-passphrase.ts @@ -0,0 +1,22 @@ +/** + * The encryption passphrase rule, kept apart from the key code so a settings + * screen can check a passphrase as it is typed without loading Argon2 or the + * recovery word list. + */ +export const CLOUD_PASSPHRASE_MIN_LENGTH = 12 + +/** + * Why a passphrase is refused, or null. Twelve characters is a floor, not a + * target: the passphrase is the only thing between a stolen keyset and the + * notes, and an attacker who holds the keyset can guess offline. + */ +export function cloudPassphraseProblem(passphrase: string): string | null { + const normalized = passphrase.normalize('NFKC') + if ([...normalized].length < CLOUD_PASSPHRASE_MIN_LENGTH) { + return `Use at least ${CLOUD_PASSPHRASE_MIN_LENGTH} characters. Four or more unrelated words work well.` + } + if (new Set(normalized.toLowerCase()).size < 6) { + return 'Use a passphrase with more variety; repeated characters are easy to guess.' + } + return null +} diff --git a/packages/shared-domain/src/cloud-e2ee-vault.test.ts b/packages/shared-domain/src/cloud-e2ee-vault.test.ts new file mode 100644 index 000000000..dbd868eb9 --- /dev/null +++ b/packages/shared-domain/src/cloud-e2ee-vault.test.ts @@ -0,0 +1,422 @@ +import type { + CloudBackupSnapshotItem, + CloudEncryptionKeyset, + CloudEncryptionKeysetCreate, + CloudEncryptionKeysetUpdate, + CloudSyncContent, + CloudSyncMutation, + CloudSyncUpsertMutation, + CloudSyncVault +} from '@zennotes/bridge-contract/cloud-sync' +import { describe, expect, it } from 'vitest' +import { + CloudE2eeKeyring, + type CloudAccountKeyStore, + type CloudKeysetApi, + type StoredCloudAccountKey, + changeCloudEncryptionPassphrase, + cloudPassphraseProblem, + prepareCloudEncryptionSetup, + prepareNewCloudRecoveryKey, + unlockCloudEncryption +} from './cloud-e2ee-account' +import { CloudE2eeError, fromBase64, sha256Hex, toBase64 } from './cloud-e2ee' +import { CLOUD_E2EE_HEADER, CloudE2eeLockedError } from './cloud-e2ee-vault' +import { CloudSyncApiClient, type CloudSyncHttpRequest, type CloudSyncHttpTransport } from './cloud-sync-api' +import { validateCloudSyncContentReference, validateCloudSyncDownloadInstruction } from './cloud-sync-content' + +const PASSPHRASE = 'quiet harbor lantern orbit' + +class MemoryKeysetApi implements CloudKeysetApi { + keyset: CloudEncryptionKeyset | null = null + + async getKeyset() { + return this.keyset ? structuredClone(this.keyset) : null + } + + async createKeyset(body: CloudEncryptionKeysetCreate) { + if (this.keyset) throw Object.assign(new Error('exists'), { status: 409, code: 'KEYSET_EXISTS' }) + this.keyset = { ...structuredClone(body), revision: 1, updated_at: new Date().toISOString() } + return structuredClone(this.keyset) + } + + async updateKeyset(body: CloudEncryptionKeysetUpdate) { + if (!this.keyset || body.expected_revision !== this.keyset.revision) { + throw Object.assign(new Error('conflict'), { status: 409, code: 'KEYSET_REVISION_CONFLICT' }) + } + const { expected_revision: _revision, ...changes } = body + this.keyset = { ...this.keyset, ...structuredClone(changes), revision: this.keyset.revision + 1 } + return structuredClone(this.keyset) + } +} + +class MemoryKeyStore implements CloudAccountKeyStore { + value: StoredCloudAccountKey | null = null + async load() { + return this.value + } + async save(value: StoredCloudAccountKey) { + this.value = { ...value } + } + async clear() { + this.value = null + } +} + +interface StoredItem { + item_id: string + path: string + kind: string + revision: number + meta: string | null + content: CloudSyncContent | null +} + +/** + * Plays ZenNotes Cloud with only what the real service can see: whatever the + * client sends. Tests read `requests` to prove nothing readable left. + */ +class FakeCloud implements CloudSyncHttpTransport { + requests: CloudSyncHttpRequest[] = [] + vaults: CloudSyncVault[] = [] + items = new Map() + backupPages: CloudBackupSnapshotItem[][] = [] + conflictNext: { item_id: string; current_path: string; current_meta: string | null } | null = null + + async request(request: CloudSyncHttpRequest): Promise { + this.requests.push(structuredClone(request)) + const path = request.path.split('?')[0]! + const query = new URLSearchParams(request.path.split('?')[1] ?? '') + if (request.method === 'GET' && path === '/api/v1/vaults') return { data: this.vaults } as Response + if (request.method === 'POST' && path === '/api/v1/vaults') { + const body = request.body as { name: string; encryption?: CloudSyncVault['encryption'] } + const vault: CloudSyncVault = { + id: `vault-${this.vaults.length + 1}`, + name: body.name, + cursor: 0, + created_at: '2026-10-12T00:00:00Z', + updated_at: '2026-10-12T00:00:00Z', + encryption: body.encryption ?? null + } + this.vaults.push(vault) + return { data: vault } as Response + } + if (request.method === 'POST' && path.endsWith('/mutations')) { + const { mutations } = request.body as { mutations: CloudSyncMutation[] } + const acknowledged = [] + for (const mutation of mutations) { + const previous = this.items.get(mutation.item_id) + const revision = (previous?.revision ?? 0) + 1 + if (mutation.type === 'upsert') { + this.items.set(mutation.item_id, { + item_id: mutation.item_id, + path: mutation.path, + kind: mutation.kind, + revision, + meta: mutation.meta ?? null, + content: mutation.content + }) + } else if (mutation.type === 'move' && previous) { + this.items.set(mutation.item_id, { ...previous, path: mutation.path, meta: mutation.meta ?? null, revision }) + } else if (mutation.type === 'delete') { + this.items.delete(mutation.item_id) + } + acknowledged.push({ operation_id: mutation.operation_id, item_id: mutation.item_id, revision, sequence: revision }) + } + const conflicts = this.conflictNext + ? [{ operation_id: 'op-conflict', code: 'PATH_CONFLICT', current_revision: 1, ...this.conflictNext }] + : [] + this.conflictNext = null + return { acknowledged, conflicts, cursor: 1 } as Response + } + if (request.method === 'GET' && path.endsWith('/manifest')) { + const data = [...this.items.values()].map((item) => ({ + item_id: item.item_id, + path: item.path, + kind: item.kind, + revision: item.revision, + sha256: item.content!.sha256, + byte_length: item.content!.byte_length, + media_type: item.content!.media_type, + meta: item.meta, + content: item.content + })) + return { data, cursor: 1, next_page: null } as Response + } + if (request.method === 'GET' && path.includes('/backups/') && path.endsWith('/items')) { + const page = Number(query.get('page') ?? '1') + return { + data: this.backupPages[page - 1] ?? [], + meta: { current_page: page, last_page: this.backupPages.length, per_page: 50, total: 0 } + } as Response + } + throw new Error(`Unexpected request ${request.method} ${request.path}`) + } +} + +async function utf8Content(text: string, mediaType = 'text/markdown'): Promise { + const bytes = new TextEncoder().encode(text) + return { encoding: 'utf8', data: text, sha256: await sha256Hex(bytes), byte_length: bytes.byteLength, media_type: mediaType } +} + +async function binaryContent(bytes: Uint8Array, mediaType = 'image/png'): Promise { + return { encoding: 'base64', data: toBase64(bytes), sha256: await sha256Hex(bytes), byte_length: bytes.byteLength, media_type: mediaType } +} + +async function encryptedSetup() { + const api = new MemoryKeysetApi() + const store = new MemoryKeyStore() + const setup = await prepareCloudEncryptionSetup(api, store, PASSPHRASE) + await setup.finish() + const cloud = new FakeCloud() + const keyring = new CloudE2eeKeyring({ listVaults: async () => cloud.vaults, store }) + const client = new CloudSyncApiClient(cloud, { endToEndEncryption: true, vaultCiphers: keyring }) + const stored = store.value! + const vault = (await client.createEncryptedVault('Private journal', fromBase64(stored.key), stored.version)).data + const readable = (await client.createVault('Open notes')).data + return { api, store, cloud, keyring, client, vault, readable, setup } +} + +describe('account setup and unlock', () => { + it('changes nothing on the service until the recovery key is confirmed', async () => { + const api = new MemoryKeysetApi() + const store = new MemoryKeyStore() + const setup = await prepareCloudEncryptionSetup(api, store, PASSPHRASE) + expect(setup.recoveryWords).toHaveLength(24) + expect(api.keyset).toBeNull() + expect(store.value).toBeNull() + await setup.finish() + expect(api.keyset?.revision).toBe(1) + expect(store.value?.version).toBe(1) + // Neither secret is in what the service stores. + const serialized = JSON.stringify(api.keyset) + expect(serialized).not.toContain(PASSPHRASE) + expect(serialized).not.toContain(setup.recoveryWords.join(' ')) + }, 20_000) + + it('unlocks another device with the passphrase or the recovery words, and refuses a wrong one', async () => { + const api = new MemoryKeysetApi() + const first = new MemoryKeyStore() + const setup = await prepareCloudEncryptionSetup(api, first, PASSPHRASE) + await setup.finish() + + const second = new MemoryKeyStore() + await unlockCloudEncryption(api, second, { passphrase: PASSPHRASE }) + expect(second.value?.key).toBe(first.value?.key) + + const third = new MemoryKeyStore() + await unlockCloudEncryption(api, third, { recoveryWords: setup.recoveryWords.join(' ') }) + expect(third.value?.key).toBe(first.value?.key) + + await expect(unlockCloudEncryption(api, new MemoryKeyStore(), { passphrase: 'not the passphrase at all' })) + .rejects.toThrow('That passphrase does not unlock your encryption.') + }, 30_000) + + it('changes the passphrase and replaces the recovery key without touching the account key', async () => { + const api = new MemoryKeysetApi() + const store = new MemoryKeyStore() + const setup = await prepareCloudEncryptionSetup(api, store, PASSPHRASE) + await setup.finish() + const accountKey = store.value!.key + + await changeCloudEncryptionPassphrase(api, store, { passphrase: PASSPHRASE }, 'velvet canyon morning drum') + await expect(unlockCloudEncryption(api, new MemoryKeyStore(), { passphrase: PASSPHRASE })).rejects.toThrow(/does not unlock/) + const afterChange = new MemoryKeyStore() + await unlockCloudEncryption(api, afterChange, { passphrase: 'velvet canyon morning drum' }) + expect(afterChange.value?.key).toBe(accountKey) + + const replacement = await prepareNewCloudRecoveryKey(api, store, { recoveryWords: setup.recoveryWords.join(' ') }) + await replacement.finish() + await expect(unlockCloudEncryption(api, new MemoryKeyStore(), { recoveryWords: setup.recoveryWords.join(' ') })) + .rejects.toThrow(/does not unlock/) + const viaNewWords = new MemoryKeyStore() + await unlockCloudEncryption(api, viaNewWords, { recoveryWords: replacement.recoveryWords.join(' ') }) + expect(viaNewWords.value?.key).toBe(accountKey) + expect(api.keyset?.revision).toBe(3) + }, 60_000) + + it('refuses short or repetitive passphrases', () => { + expect(cloudPassphraseProblem('short one')).toMatch(/at least 12/) + expect(cloudPassphraseProblem('aaaaaaaaaaaaaaaa')).toMatch(/variety/) + expect(cloudPassphraseProblem(PASSPHRASE)).toBeNull() + }) +}) + +describe('encrypted vault sync through the API client', () => { + it('sends only path ids, sealed metadata and ciphertext, and reads them back as plaintext', async () => { + const { cloud, client, vault } = await encryptedSetup() + expect(vault.name).toBe('Encrypted vault') + const note = await utf8Content('# Secret plans\n\nMeet at noon.') + const image = await binaryContent(Uint8Array.from({ length: 3000 }, (_, index) => index % 256)) + await client.mutate(vault.id, { + mutations: [ + { type: 'upsert', operation_id: 'op-1', item_id: 'item-note', base_revision: null, path: 'Plans/Secret.md', kind: 'text', content: note }, + { type: 'upsert', operation_id: 'op-2', item_id: 'item-image', base_revision: null, path: 'attachements/photo.png', kind: 'binary', content: image } + ] + }) + + const sent = cloud.requests.find((request) => request.path.endsWith('/mutations'))! + const wire = JSON.stringify(sent.body) + expect(wire).not.toContain('Secret') + expect(wire).not.toContain('Meet at noon') + expect(wire).not.toContain('photo') + expect(wire).not.toContain(note.sha256) + for (const mutation of (sent.body as { mutations: Array }).mutations) { + expect(mutation.path).toMatch(/^[0-9a-f]{64}$/) + expect(mutation.kind).toBe('binary') + expect(mutation.content.encoding).toBe('aes-gcm') + expect(mutation.content.media_type).toBe('application/octet-stream') + expect(mutation.content.sha256).toBe(await sha256Hex(fromBase64(mutation.content.data))) + } + expect(sent.headers?.[CLOUD_E2EE_HEADER]).toBe('zne1') + + const manifest = await client.manifest(vault.id, { includeContent: true }) + const byId = new Map(manifest.data.map((item) => [item.item_id, item])) + expect(byId.get('item-note')).toMatchObject({ path: 'Plans/Secret.md', kind: 'text', sha256: note.sha256, content: note }) + expect(byId.get('item-image')).toMatchObject({ path: 'attachements/photo.png', kind: 'binary', content: image }) + }, 20_000) + + it('seals a rename from the engine hints and refuses a rename without them', async () => { + const { cloud, client, vault } = await encryptedSetup() + const note = await utf8Content('body') + await client.mutate(vault.id, { + mutations: [{ type: 'upsert', operation_id: 'op-1', item_id: 'item-1', base_revision: null, path: 'Old name.md', kind: 'text', content: note }] + }) + const { data: _data, ...metadata } = note + await client.mutate(vault.id, { + mutations: [{ type: 'move', operation_id: 'op-2', item_id: 'item-1', base_revision: 1, path: 'New name.md', kind: 'text', content_metadata: metadata }] + }) + const move = (cloud.requests.at(-1)!.body as { mutations: Array> }).mutations[0]! + expect(Object.keys(move).sort()).toEqual(['base_revision', 'item_id', 'meta', 'operation_id', 'path', 'type']) + expect((await client.manifest(vault.id, { includeContent: true })).data[0]).toMatchObject({ path: 'New name.md', content: note }) + + await expect( + client.mutate(vault.id, { mutations: [{ type: 'move', operation_id: 'op-3', item_id: 'item-1', base_revision: 2, path: 'Bare.md' }] }) + ).rejects.toThrow(/needs the file details/) + }, 20_000) + + it('leaves readable vaults byte for byte as before, minus the client-only hints', async () => { + const { cloud, client, readable } = await encryptedSetup() + const note = await utf8Content('plain') + const { data: _data, ...metadata } = note + await client.mutate(readable.id, { + mutations: [ + { type: 'upsert', operation_id: 'op-1', item_id: 'item-1', base_revision: null, path: 'Note.md', kind: 'text', content: note }, + { type: 'move', operation_id: 'op-2', item_id: 'item-1', base_revision: 1, path: 'Moved.md', kind: 'text', content_metadata: metadata } + ] + }) + const body = cloud.requests.at(-1)!.body as { mutations: Array> } + expect(body.mutations[0]).toEqual({ type: 'upsert', operation_id: 'op-1', item_id: 'item-1', base_revision: null, path: 'Note.md', kind: 'text', content: note }) + expect(body.mutations[1]).toEqual({ type: 'move', operation_id: 'op-2', item_id: 'item-1', base_revision: 1, path: 'Moved.md' }) + }, 20_000) + + it('stops before any request when an encrypted vault is locked on this device', async () => { + const { cloud, client, vault, store, keyring } = await encryptedSetup() + await store.clear() + keyring.forget() + const before = cloud.requests.length + await expect( + client.mutate(vault.id, { mutations: [{ type: 'upsert', operation_id: 'op-1', item_id: 'item-1', base_revision: null, path: 'A.md', kind: 'text', content: await utf8Content('a') }] }) + ).rejects.toBeInstanceOf(CloudE2eeLockedError) + expect(cloud.requests.slice(before).some((request) => request.path.endsWith('/mutations'))).toBe(false) + }, 20_000) + + it('refuses a readable streamed upload into an encrypted vault', async () => { + const { client, vault } = await encryptedSetup() + const note = await utf8Content('large') + await expect(client.initiateUpload(vault.id, { + operation_id: 'op-1', item_id: 'item-1', base_revision: null, path: 'Big.md', kind: 'text', + content: { encoding: 'utf8', sha256: note.sha256, byte_length: note.byte_length, media_type: 'text/markdown' } + })).rejects.toThrow(/only encrypted uploads/) + }, 20_000) + + it('names the other side of a conflict from its sealed metadata', async () => { + const { cloud, client, vault, keyring } = await encryptedSetup() + const note = await utf8Content('x') + await client.mutate(vault.id, { + mutations: [{ type: 'upsert', operation_id: 'op-1', item_id: 'item-a', base_revision: null, path: 'Taken.md', kind: 'text', content: note }] + }) + const existing = cloud.items.get('item-a')! + cloud.conflictNext = { item_id: 'item-b', current_path: existing.path, current_meta: existing.meta } + const response = await client.mutate(vault.id, { + mutations: [{ type: 'upsert', operation_id: 'op-2', item_id: 'item-b', base_revision: null, path: 'taken.md', kind: 'text', content: note }] + }) + expect(response.conflicts[0]).toMatchObject({ code: 'PATH_CONFLICT', current_path: 'Taken.md' }) + expect(response.conflicts[0]).not.toHaveProperty('current_meta') + expect(await (await keyring.forVault(vault.id))!.pathId('taken.md')).toBe(existing.path) + }, 20_000) + + it('refuses content the service swapped between items or metadata moved to another path', async () => { + const { cloud, client, vault } = await encryptedSetup() + await client.mutate(vault.id, { + mutations: [ + { type: 'upsert', operation_id: 'op-1', item_id: 'item-a', base_revision: null, path: 'A.md', kind: 'text', content: await utf8Content('alpha') }, + { type: 'upsert', operation_id: 'op-2', item_id: 'item-b', base_revision: null, path: 'B.md', kind: 'text', content: await utf8Content('bravo') } + ] + }) + const a = cloud.items.get('item-a')! + const b = cloud.items.get('item-b')! + cloud.items.set('item-a', { ...a, content: b.content }) + await expect(client.manifest(vault.id, { includeContent: true })).rejects.toSatisfy( + (error: unknown) => error instanceof CloudE2eeError && error.code === 'tampered' + ) + cloud.items.set('item-a', { ...a, meta: b.meta }) + await expect(client.manifest(vault.id, { includeContent: true })).rejects.toSatisfy( + (error: unknown) => error instanceof CloudE2eeError && error.code === 'tampered' + ) + }, 20_000) + + it('searches an encrypted backup by decrypting every page here', async () => { + const { cloud, client, vault, keyring } = await encryptedSetup() + const cipher = (await keyring.forVault(vault.id))! + const item = async (id: number, path: string): Promise => { + const content = await utf8Content(path) + const { data: _data, ...metadata } = content + const sealed = await cipher.sealMeta(path, 'text', metadata) + return { id, item_id: `item-${id}`, path: sealed.pathId, kind: 'binary', byte_length: 200, revision: 1, content_hash: null, media_type: 'application/octet-stream', meta: sealed.meta } + } + cloud.backupPages = [[await item(1, 'Recipes/Soup.md'), await item(2, 'Work/Plan.md')], [await item(3, 'Recipes/Bread.md')]] + const result = await client.listBackupItems(vault.id, 'backup-1', { search: 'recipes' }) + expect(result.data.map((entry) => entry.path)).toEqual(['Recipes/Soup.md', 'Recipes/Bread.md']) + expect(cloud.requests.filter((request) => request.path.includes('/items')).every((request) => !request.path.includes('search'))).toBe(true) + }, 20_000) +}) + +describe('encrypted references and download instructions', () => { + const reference = { + item_id: 'item-1', + revision: 3, + encoding: 'base64' as const, + sha256: 'a'.repeat(64), + byte_length: 1000, + media_type: 'image/png', + encrypted: { sha256: 'b'.repeat(64), byte_length: 1090 } + } + const instruction = (content: Record) => ({ + data: { + item_id: 'item-1', + revision: 3, + content, + download: { method: 'GET', url: 'https://objects.example/x', headers: {}, expires_at: '2099-01-01T00:00:00Z' } + } + }) + + it('keeps the ciphertext description on a reference', () => { + expect(validateCloudSyncContentReference(reference)).toEqual(reference) + expect(() => validateCloudSyncContentReference({ ...reference, encrypted: { sha256: 'b'.repeat(64), byte_length: 10 } })).toThrow() + }) + + it('accepts an aes-gcm download only for an encrypted reference, and only for its ciphertext', () => { + const sealed = { encoding: 'aes-gcm', sha256: 'b'.repeat(64), byte_length: 1090, media_type: 'application/octet-stream' } + expect(validateCloudSyncDownloadInstruction(instruction(sealed), reference).content).toEqual(sealed) + expect(() => validateCloudSyncDownloadInstruction(instruction({ ...sealed, byte_length: 1091 }), reference)).toThrow() + const { encrypted: _encrypted, ...readable } = reference + expect(() => validateCloudSyncDownloadInstruction(instruction(sealed), readable)).toThrow() + expect(() => + validateCloudSyncDownloadInstruction( + instruction({ encoding: 'base64', sha256: 'a'.repeat(64), byte_length: 1000, media_type: 'image/png' }), + reference + ) + ).toThrow() + }) +}) diff --git a/packages/shared-domain/src/cloud-e2ee-vault.ts b/packages/shared-domain/src/cloud-e2ee-vault.ts new file mode 100644 index 000000000..90e6b8261 --- /dev/null +++ b/packages/shared-domain/src/cloud-e2ee-vault.ts @@ -0,0 +1,461 @@ +/** + * The per-vault side of end-to-end encryption: turns the plaintext-shaped + * items the sync coordinator works with into the wire format of an encrypted + * vault and back (docs/specs/cloud-e2ee.md). The coordinator never sees a + * path id or ciphertext, so its change, rename and conflict detection keep + * comparing plaintext hashes exactly as they do for readable vaults. + */ +import type { + CloudBackupNoteRestore, + CloudBackupSnapshotItem, + CloudSyncChange, + CloudSyncConflict, + CloudSyncContent, + CloudSyncContentMetadata, + CloudSyncContentReference, + CloudSyncItemKind, + CloudSyncManifestItem, + CloudSyncMutation, + CloudSyncMutationResponse, + CloudSyncRevision, + CloudSyncVault, + CloudSyncVaultEncryption +} from '@zennotes/bridge-contract/cloud-sync' +import { + type Bytes, + CloudE2eeError, + type CloudE2eeItemMeta, + CloudPathIndex, + type Zne1ChunkCipherParams, + Zne1Purpose, + beginZne1Decryption, + beginZne1Encryption, + decryptItemMeta, + decryptVaultName, + decryptZne1, + decryptZne1Stream, + encryptItemMeta, + encryptVaultName, + encryptZne1, + encryptZne1Stream, + fromBase64, + generateCloudE2eeKey, + newVaultKeyId, + sha256Hex, + toBase64, + unwrapVaultKey, + wrapVaultKey +} from './cloud-e2ee' + +export const CLOUD_E2EE_HEADER = 'X-ZenNotes-Encryption' +export const CLOUD_E2EE_HEADER_VALUE = 'zne1' +export const ENCRYPTED_VAULT_PLACEHOLDER_NAME = 'Encrypted vault' +const VAULT_KEY_VERSION = 1 +const CIPHERTEXT_MEDIA_TYPE = 'application/octet-stream' + +/** + * An encrypted vault had to sync but this device holds no account key. Sync + * stops here; it never falls back to sending or accepting readable content. + */ +export class CloudE2eeLockedError extends Error { + readonly code = 'E2EE_LOCKED' + + constructor(message = 'This vault is end-to-end encrypted. Unlock encryption on this device to sync it.') { + super(message) + this.name = 'CloudE2eeLockedError' + } +} + +/** Where the API client finds the cipher for a vault. */ +export interface CloudVaultCipherSource { + /** + * The cipher for an encrypted vault, or null for a readable one. Throws + * CloudE2eeLockedError when the vault is encrypted and this device is locked. + */ + forVault(vaultId: string): Promise +} + +const textEncoder = new TextEncoder() +const strictUtf8 = new TextDecoder('utf-8', { fatal: true }) + +function plaintextBytes(content: CloudSyncContent): Uint8Array { + if (content.encoding === 'utf8') return textEncoder.encode(content.data) + if (content.encoding === 'base64') return fromBase64(content.data) + throw new CloudE2eeError('malformed', 'Content is already encrypted.') +} + +function requireReadableEncoding(encoding: CloudSyncContentMetadata['encoding']): 'utf8' | 'base64' { + if (encoding === 'utf8' || encoding === 'base64') return encoding + throw new CloudE2eeError('malformed', 'Content is already encrypted.') +} + +function mismatch(message: string): CloudE2eeError { + return new CloudE2eeError('tampered', message) +} + +export class CloudVaultCipher { + private constructor( + readonly vaultId: string, + readonly keyId: string, + readonly keyVersion: number, + private readonly vaultKey: Bytes, + private readonly paths: CloudPathIndex + ) {} + + /** Opens an existing encrypted vault with the account key. */ + static async open( + vault: Pick, + accountKey: Uint8Array, + accountKeyVersion: number + ): Promise { + const encryption = vault.encryption + if (!encryption || encryption.scheme !== 'zne1' || !/^[0-9a-f]{32}$/.test(encryption.key_id)) { + throw new CloudE2eeError('unsupported', 'This vault does not use an encryption scheme ZenNotes knows.') + } + if (encryption.key_version !== VAULT_KEY_VERSION) { + throw new CloudE2eeError('unsupported', `Vault key version ${encryption.key_version} needs a newer ZenNotes.`) + } + const vaultKey = await unwrapVaultKey( + encryption.vault_key_envelope, + accountKey, + accountKeyVersion, + encryption.key_id + ) + return new CloudVaultCipher( + vault.id, + encryption.key_id, + encryption.key_version, + vaultKey, + await CloudPathIndex.forVaultKey(vaultKey) + ) + } + + /** Makes the `encryption` object for a new vault: a fresh vault key, sealed, and the sealed name. */ + static async prepareVault( + name: string, + accountKey: Uint8Array, + accountKeyVersion: number + ): Promise { + const vaultKey = generateCloudE2eeKey() + const keyId = newVaultKeyId() + return { + scheme: 'zne1', + key_id: keyId, + key_version: VAULT_KEY_VERSION, + vault_key_envelope: await wrapVaultKey(vaultKey, accountKey, accountKeyVersion, keyId), + name: await encryptVaultName(name, { vaultKey, vaultKeyVersion: VAULT_KEY_VERSION }) + } + } + + async vaultName(encryption: CloudSyncVaultEncryption): Promise { + return decryptVaultName(encryption.name, { vaultKey: this.vaultKey, vaultKeyVersion: this.keyVersion }) + } + + pathId(path: string): Promise { + return this.paths.id(path) + } + + // ------------------------------------------------------------------------- + // Outgoing + // ------------------------------------------------------------------------- + + /** Seals an item's metadata under its path id. */ + async sealMeta( + path: string, + kind: CloudSyncItemKind, + metadata: CloudSyncContentMetadata + ): Promise<{ pathId: string; meta: string }> { + const pathId = await this.paths.id(path) + const meta: CloudE2eeItemMeta = { + path, + kind, + encoding: requireReadableEncoding(metadata.encoding), + media_type: metadata.media_type, + sha256: metadata.sha256, + byte_length: metadata.byte_length + } + return { + pathId, + meta: await encryptItemMeta(meta, { vaultKey: this.vaultKey, vaultKeyVersion: this.keyVersion, pathId }) + } + } + + /** + * Encrypts inline content. The plaintext must still be the bytes its + * metadata describes, so a file that changed after it was hashed is never + * sealed under the old hash. + */ + async sealContent(content: CloudSyncContent): Promise { + if (content.data === '' && content.byte_length > 0) { + throw new CloudE2eeError('malformed', 'Large encrypted files upload through the streamed path.') + } + const plaintext = plaintextBytes(content) + if (plaintext.byteLength !== content.byte_length || (await sha256Hex(plaintext)) !== content.sha256) { + throw mismatch('The file changed while ZenNotes was preparing its encrypted upload.') + } + const sealed = await encryptZne1(plaintext, { + parentKey: this.vaultKey, + purpose: Zne1Purpose.content, + keyVersion: this.keyVersion + }) + return { + encoding: 'aes-gcm', + data: toBase64(sealed), + sha256: await sha256Hex(sealed), + byte_length: sealed.byteLength, + media_type: CIPHERTEXT_MEDIA_TYPE + } + } + + /** The wire form of one mutation. Moves need the engine's content hints. */ + async encryptMutation(mutation: CloudSyncMutation): Promise { + const base = { + operation_id: mutation.operation_id, + item_id: mutation.item_id, + base_revision: mutation.base_revision + } + if (mutation.type === 'delete') return { ...base, type: 'delete' } + if (mutation.type === 'move') { + if (!mutation.kind || !mutation.content_metadata) { + throw new CloudE2eeError('invalid-metadata', 'An encrypted rename needs the file details the sync engine attaches.') + } + const { pathId, meta } = await this.sealMeta(mutation.path, mutation.kind, mutation.content_metadata) + return { ...base, type: 'move', path: pathId, meta } + } + const { pathId, meta } = await this.sealMeta(mutation.path, mutation.kind, mutation.content) + return { + ...base, + type: 'upsert', + path: pathId, + kind: 'binary', + content: await this.sealContent(mutation.content), + meta + } + } + + /** The upload-initiation fields for a streamed upload whose ciphertext is already on disk. */ + async directUploadFields( + path: string, + kind: CloudSyncItemKind, + plaintext: CloudSyncContentMetadata, + ciphertext: { sha256: string; byte_length: number } + ): Promise<{ path: string; kind: CloudSyncItemKind; meta: string; content: CloudSyncContentMetadata }> { + const { pathId, meta } = await this.sealMeta(path, kind, plaintext) + return { + path: pathId, + kind: 'binary', + meta, + content: { + encoding: 'aes-gcm', + sha256: ciphertext.sha256, + byte_length: ciphertext.byte_length, + media_type: CIPHERTEXT_MEDIA_TYPE + } + } + } + + // ------------------------------------------------------------------------- + // Incoming + // ------------------------------------------------------------------------- + + private async openMeta(pathId: string, meta: string | null | undefined): Promise { + if (!meta) { + throw new CloudE2eeError('invalid-metadata', 'ZenNotes Cloud returned an encrypted item without its metadata.') + } + return decryptItemMeta(meta, { + vaultKey: this.vaultKey, + vaultKeyVersion: this.keyVersion, + pathId, + pathIndex: this.paths + }) + } + + /** Decrypts inline content and checks it is exactly the file its metadata names. */ + async openContent(content: CloudSyncContent, meta: CloudE2eeItemMeta): Promise { + if (content.encoding !== 'aes-gcm') { + throw mismatch('ZenNotes Cloud returned readable content for an encrypted vault.') + } + const sealed = fromBase64(content.data) + if (sealed.byteLength !== content.byte_length || (await sha256Hex(sealed)) !== content.sha256) { + throw mismatch('Encrypted content did not match the size and hash ZenNotes Cloud reported.') + } + const plaintext = await decryptZne1(sealed, { + parentKey: this.vaultKey, + purpose: Zne1Purpose.content, + keyVersion: this.keyVersion + }) + if (plaintext.byteLength !== meta.byte_length || (await sha256Hex(plaintext)) !== meta.sha256) { + throw mismatch('Decrypted content is not the file its metadata names.') + } + return { + encoding: meta.encoding, + data: meta.encoding === 'utf8' ? strictUtf8.decode(plaintext) : toBase64(plaintext), + sha256: meta.sha256, + byte_length: meta.byte_length, + media_type: meta.media_type + } + } + + /** + * A reference to large content: plaintext metadata for the coordinator, and + * `encrypted` for the host that downloads and decrypts the stored bytes. + */ + openReference(reference: CloudSyncContentReference, meta: CloudE2eeItemMeta): CloudSyncContentReference { + if (reference.encoding !== 'aes-gcm') { + throw mismatch('ZenNotes Cloud returned a readable reference for an encrypted vault.') + } + return { + item_id: reference.item_id, + revision: reference.revision, + encoding: meta.encoding, + sha256: meta.sha256, + byte_length: meta.byte_length, + media_type: meta.media_type, + encrypted: { sha256: reference.sha256, byte_length: reference.byte_length } + } + } + + async openManifestItem(item: CloudSyncManifestItem): Promise { + const meta = await this.openMeta(item.path, item.meta) + return { + item_id: item.item_id, + path: meta.path, + kind: meta.kind, + revision: item.revision, + sha256: meta.sha256, + byte_length: meta.byte_length, + media_type: meta.media_type, + ...(item.content ? { content: await this.openContent(item.content, meta) } : {}), + ...(item.content_ref ? { content_ref: this.openReference(item.content_ref, meta) } : {}) + } + } + + async openChange(change: CloudSyncChange): Promise { + const meta = await this.openMeta(change.path, change.meta) + const previous = + change.previous_path !== null && change.previous_meta + ? (await this.openMeta(change.previous_path, change.previous_meta)).path + : null + return { + sequence: change.sequence, + item_id: change.item_id, + type: change.type, + path: meta.path, + previous_path: previous, + revision: change.revision, + ...(change.content ? { content: await this.openContent(change.content, meta) } : {}), + ...(change.content_ref ? { content_ref: this.openReference(change.content_ref, meta) } : {}) + } + } + + async openRevision(revision: CloudSyncRevision): Promise { + const meta = await this.openMeta(revision.path, revision.meta) + return { + item_id: revision.item_id, + revision: revision.revision, + path: meta.path, + kind: meta.kind, + deleted: revision.deleted, + ...(revision.content !== undefined + ? { content: revision.content === null ? null : await this.openContent(revision.content, meta) } + : {}), + ...(revision.content_ref ? { content_ref: this.openReference(revision.content_ref, meta) } : {}) + } + } + + async openConflict(conflict: CloudSyncConflict): Promise { + const { current_meta: currentMeta, ...rest } = conflict + return { + ...rest, + current_path: + conflict.current_path !== null && currentMeta + ? (await this.openMeta(conflict.current_path, currentMeta)).path + : null + } + } + + async openMutationResponse(response: CloudSyncMutationResponse): Promise { + return { + ...response, + conflicts: await Promise.all(response.conflicts.map((conflict) => this.openConflict(conflict))) + } + } + + async openBackupItem(item: CloudBackupSnapshotItem): Promise { + const meta = await this.openMeta(item.path, item.meta) + return { + id: item.id, + item_id: item.item_id, + path: meta.path, + kind: meta.kind, + byte_length: meta.byte_length, + revision: item.revision, + content_hash: meta.sha256, + media_type: meta.media_type + } + } + + /** + * One item of a downloaded backup archive, made readable: the real path and + * kind from its metadata and the decrypted content, checked against them. + */ + async openArchiveItem( + item: Record & { path: string; meta?: string | null; content: CloudSyncContent } + ): Promise> { + const { meta: sealedMeta, ...rest } = item + const meta = await this.openMeta(item.path, sealedMeta) + return { ...rest, path: meta.path, kind: meta.kind, content: await this.openContent(item.content, meta) } + } + + async openNoteRestore(restore: CloudBackupNoteRestore): Promise { + const { meta, ...rest } = restore + return { ...rest, path: (await this.openMeta(restore.path, meta)).path } + } + + // ------------------------------------------------------------------------- + // Streams and native chunk loops + // ------------------------------------------------------------------------- + + encryptStream(source: AsyncIterable | Iterable): AsyncGenerator { + return encryptZne1Stream(source, { + parentKey: this.vaultKey, + purpose: Zne1Purpose.content, + keyVersion: this.keyVersion + }) + } + + decryptStream(source: AsyncIterable | Iterable): AsyncGenerator { + return decryptZne1Stream(source, { + parentKey: this.vaultKey, + purpose: Zne1Purpose.content, + keyVersion: this.keyVersion + }) + } + + /** What a native encryption loop needs for one new file; the vault key stays here. */ + beginContentEncryption(): Promise { + return beginZne1Encryption({ parentKey: this.vaultKey, purpose: Zne1Purpose.content, keyVersion: this.keyVersion }) + } + + /** Checks a downloaded file's first 74 bytes and returns what its native decryption loop needs. */ + async openContentHeader(header: Uint8Array): Promise { + const { header: bytes, objectKey, aad } = await beginZne1Decryption(header, { + parentKey: this.vaultKey, + purpose: Zne1Purpose.content, + keyVersion: this.keyVersion + }) + return { header: bytes, objectKey, aad } + } +} + +/** + * Removes the sync engine's client-side move hints, for a readable vault whose + * mutations go to the service unchanged otherwise. + */ +export function withoutClientHints(mutation: CloudSyncMutation): CloudSyncMutation { + if (mutation.type !== 'move' || (mutation.kind === undefined && mutation.content_metadata === undefined)) { + return mutation + } + const { kind: _kind, content_metadata: _metadata, ...wire } = mutation + return wire +} diff --git a/packages/shared-domain/src/cloud-sync-api.ts b/packages/shared-domain/src/cloud-sync-api.ts index 91552e419..03765a3d3 100644 --- a/packages/shared-domain/src/cloud-sync-api.ts +++ b/packages/shared-domain/src/cloud-sync-api.ts @@ -1,5 +1,10 @@ import type { CloudBackupItemsQuery, + CloudBackupSnapshotItem, + CloudEncryptionKeyset, + CloudEncryptionKeysetCreate, + CloudEncryptionKeysetResponse, + CloudEncryptionKeysetUpdate, CloudBackupNoteRestoreRequest, CloudBackupNoteRestoreResponse, CloudBackupRestoreRequest, @@ -29,6 +34,15 @@ import type { CloudSyncVaultResponse } from '@zennotes/bridge-contract/cloud-sync' import type { CloudSyncRateLimitScope } from './cloud-sync-rate-limit' +import type { CloudKeysetApi } from './cloud-e2ee-account' +import { + CLOUD_E2EE_HEADER, + CLOUD_E2EE_HEADER_VALUE, + CloudVaultCipher, + ENCRYPTED_VAULT_PLACEHOLDER_NAME, + type CloudVaultCipherSource, + withoutClientHints +} from './cloud-e2ee-vault' export * from './cloud-sync-content' export type { @@ -73,6 +87,8 @@ export interface CloudSyncHttpRequest { /** Only for explicitly idempotent JSON operations with stable operation/session IDs. * GETs are replayable by default. Other requests are never retried implicitly. */ retryOnRateLimit?: boolean + /** Extra request headers; transports forward them. */ + headers?: Record } export interface CloudSyncHttpTransport { @@ -89,6 +105,14 @@ export interface CloudSyncApiClientOptions { maxInlineBytes?: number signal?: AbortSignal allowInsecureLoopbackDownloads?: boolean + /** + * The host decrypts every path an encrypted vault's bytes can take, + * including its streamed uploads and downloads. Only then does the client + * tell the service it may serve encrypted vaults (docs/specs/cloud-e2ee.md). + */ + endToEndEncryption?: boolean + /** Required with endToEndEncryption: where each vault's cipher comes from. */ + vaultCiphers?: CloudVaultCipherSource } /** Typed API surface shared by Electron and both Capacitor shells. */ @@ -114,14 +138,35 @@ export class CloudSyncApiClient { this.downloadSignal = options.signal this.allowInsecureLoopbackDownloads = options.allowInsecureLoopbackDownloads === true this.requiresContentReferenceHost = options.contentReferences === true + if (options.endToEndEncryption && !options.vaultCiphers) { + throw new Error('End-to-end encryption needs a source of vault ciphers.') + } + } + + /** Every request goes through here, so the encryption header is never forgotten. */ + private send(request: CloudSyncHttpRequest): Promise { + if (!this.options.endToEndEncryption) return this.http.request(request) + return this.http.request({ + ...request, + headers: { ...request.headers, [CLOUD_E2EE_HEADER]: CLOUD_E2EE_HEADER_VALUE } + }) + } + + /** + * The cipher for an encrypted vault, or null for a readable one. Throws when + * the vault is encrypted and this device is locked, before any request. + */ + protected async vaultCipher(vaultId: string): Promise { + if (!this.options.endToEndEncryption || !this.options.vaultCiphers) return null + return this.options.vaultCiphers.forVault(vaultId) } async listVaults(): Promise { - return this.http.request({ method: 'GET', path: '/api/v1/vaults' }) + return this.send({ method: 'GET', path: '/api/v1/vaults' }) } async account(): Promise { - const response = await this.http.request({ method: 'GET', path: '/api/v1/account' }) + const response = await this.send({ method: 'GET', path: '/api/v1/account' }) const key = this.capabilityKey() if (key) contentCapabilities.set(key, { until: Date.now() + 300_000, value: Promise.resolve(response.data.capabilities?.content_references === true) @@ -173,26 +218,79 @@ export class CloudSyncApiClient { } async createVault(name: string): Promise { - return this.http.request({ + return this.send({ method: 'POST', path: '/api/v1/vaults', body: { name } }) } + /** Creates an end-to-end encrypted vault: a fresh vault key sealed under the account key, and the real name sealed. */ + async createEncryptedVault( + name: string, + accountKey: Uint8Array, + accountKeyVersion: number + ): Promise { + if (!this.options.endToEndEncryption) throw new Error('This app cannot create encrypted vaults.') + return this.send({ + method: 'POST', + path: '/api/v1/vaults', + body: { + name: ENCRYPTED_VAULT_PLACEHOLDER_NAME, + encryption: await CloudVaultCipher.prepareVault(name, accountKey, accountKeyVersion) + } + }) + } + + /** Points a readable vault at the encrypted vault that replaces it; the service then deletes it. */ + async supersedeVault(vaultId: string, successorVaultId: string): Promise { + await this.send({ + method: 'POST', + path: `/api/v1/vaults/${encodeURIComponent(vaultId)}/supersede`, + body: { successor_vault_id: successorVaultId } + }) + } + + async getKeyset(): Promise { + try { + return (await this.send({ method: 'GET', path: '/api/v1/encryption/keyset' })).data + } catch (error) { + const failure = error as { status?: number; code?: string | null } + if (failure.status === 404 || failure.code === 'KEYSET_NOT_FOUND') return null + throw error + } + } + + async createKeyset(body: CloudEncryptionKeysetCreate): Promise { + return (await this.send({ method: 'POST', path: '/api/v1/encryption/keyset', body })).data + } + + async updateKeyset(body: CloudEncryptionKeysetUpdate): Promise { + return (await this.send({ method: 'PUT', path: '/api/v1/encryption/keyset', body })).data + } + + /** The keyset calls in the shape the account functions take. */ + keysetApi(): CloudKeysetApi { + return { + getKeyset: () => this.getKeyset(), + createKeyset: (body) => this.createKeyset(body), + updateKeyset: (body) => this.updateKeyset(body) + } + } + async deleteVault(vaultId: string): Promise { - await this.http.request({ + await this.send({ method: 'DELETE', path: `/api/v1/vaults/${encodeURIComponent(vaultId)}` }) } async listPublishedNotes(): Promise { - return this.http.request({ method: 'GET', path: '/api/v1/shares' }) + return this.send({ method: 'GET', path: '/api/v1/shares' }) } async publishNote(input: CloudPublishRequest): Promise { - return this.http.request({ + return this.send({ method: 'POST', path: '/api/v1/shares', body: publishedNoteBody(input), @@ -203,11 +301,11 @@ export class CloudSyncApiClient { /** Stage a publish's attachments: answers one presigned PUT per file, or 404 * where the service has no staged uploads. */ async createPublishUpload(body: CloudPublishUploadRequest): Promise { - return this.http.request({ method: 'POST', path: '/api/v1/shares/uploads', body }) + return this.send({ method: 'POST', path: '/api/v1/shares/uploads', body }) } async abortPublishUpload(uploadId: string): Promise { - await this.http.request({ + await this.send({ method: 'DELETE', path: `/api/v1/shares/uploads/${encodeURIComponent(uploadId)}` }) @@ -217,7 +315,7 @@ export class CloudSyncApiClient { shareId: number, input: CloudPublishRequest ): Promise { - return this.http.request({ + return this.send({ method: 'PUT', path: `/api/v1/shares/${encodeURIComponent(String(shareId))}`, body: publishedNoteBody(input), @@ -226,7 +324,7 @@ export class CloudSyncApiClient { } async unpublishNote(shareId: number): Promise { - await this.http.request({ + await this.send({ method: 'DELETE', path: `/api/v1/shares/${encodeURIComponent(String(shareId))}` }) @@ -236,23 +334,29 @@ export class CloudSyncApiClient { vaultId: string, options: { includeContent?: boolean; page?: number; perPage?: number } & CloudSyncContentRequestOptions = {} ): Promise { + const cipher = await this.vaultCipher(vaultId) const query = encodeQuery({ include_content: options.includeContent, page: options.page, per_page: options.perPage, ...this.contentQuery(options, options.includeContent === false) }) - return this.http.request({ + const response = await this.send({ method: 'GET', path: `/api/v1/vaults/${encodeURIComponent(vaultId)}/manifest${query}` }) + if (!cipher) return response + return { ...response, data: await Promise.all(response.data.map((item) => cipher.openManifestItem(item))) } } async changes(vaultId: string, after: number, limit = 100, options: CloudSyncContentRequestOptions = {}): Promise { - return this.http.request({ + const cipher = await this.vaultCipher(vaultId) + const response = await this.send({ method: 'GET', path: `/api/v1/vaults/${encodeURIComponent(vaultId)}/changes${encodeQuery({ after, limit, ...this.contentQuery(options) })}` }) + if (!cipher) return response + return { ...response, data: await Promise.all(response.data.map((change) => cipher.openChange(change))) } } async revision( @@ -261,15 +365,22 @@ export class CloudSyncApiClient { revision: number, options: CloudSyncContentRequestOptions = {} ): Promise { - return this.http.request({ + const cipher = await this.vaultCipher(vaultId) + const response = await this.send({ method: 'GET', path: `/api/v1/vaults/${encodeURIComponent(vaultId)}/items/${encodeURIComponent(itemId)}/revisions/${encodeURIComponent(String(revision))}${encodeQuery(this.contentQuery(options))}` }) + return cipher ? { ...response, data: await cipher.openRevision(response.data) } : response + } + + /** The coordinator asks for this before staging an `encrypted` reference. */ + async contentDecryptor(vaultId: string): Promise { + return this.vaultCipher(vaultId) } async download(vaultId: string, itemId: string, revision: number): Promise { if (!this.referencesActive) throw new Error('Negotiate Cloud content references before downloading revisions.') - return this.http.request({ method: 'GET', + return this.send({ method: 'GET', path: `/api/v1/vaults/${encodeURIComponent(vaultId)}/items/${encodeURIComponent(itemId)}/revisions/${encodeURIComponent(String(revision))}/download` }) } @@ -278,20 +389,31 @@ export class CloudSyncApiClient { vaultId: string, body: CloudSyncMutationRequest ): Promise { - return this.http.request({ + const cipher = await this.vaultCipher(vaultId) + const mutations = cipher + ? await Promise.all(body.mutations.map((mutation) => cipher.encryptMutation(mutation))) + : body.mutations.map(withoutClientHints) + const response = await this.send({ method: 'POST', path: `/api/v1/vaults/${encodeURIComponent(vaultId)}/mutations`, - body, + body: { mutations }, ...(body.mutations.length > 0 && body.mutations.every((mutation) => mutation.operation_id) ? { retryOnRateLimit: true } : {}) }) + return cipher ? cipher.openMutationResponse(response) : response } async initiateUpload( vaultId: string, body: CloudSyncUploadRequest ): Promise { - return this.http.request({ + const cipher = await this.vaultCipher(vaultId) + if (cipher && (body.content.encoding !== 'aes-gcm' || !body.meta)) { + // The host seals streamed uploads itself; refusing here keeps a host bug + // from sending a readable file into an encrypted vault. + throw new Error('Encrypted vaults accept only encrypted uploads.') + } + return this.send({ method: 'POST', path: `/api/v1/vaults/${encodeURIComponent(vaultId)}/uploads`, body, @@ -303,30 +425,33 @@ export class CloudSyncApiClient { vaultId: string, uploadId: string ): Promise { - return this.http.request({ + const cipher = await this.vaultCipher(vaultId) + const response = await this.send({ method: 'POST', path: `${this.uploadPath(vaultId, uploadId)}/complete`, timeoutMs: 300_000, ...(uploadId ? { retryOnRateLimit: true } : {}) }) + if (!cipher) return response + return { data: { ...response.data, result: await cipher.openMutationResponse(response.data.result) } } } async abortUpload(vaultId: string, uploadId: string): Promise { - await this.http.request({ + await this.send({ method: 'DELETE', path: this.uploadPath(vaultId, uploadId) }) } async listBackups(vaultId: string): Promise { - return this.http.request({ + return this.send({ method: 'GET', path: `/api/v1/vaults/${encodeURIComponent(vaultId)}/backups` }) } async backupSchedule(vaultId: string): Promise { - return this.http.request({ + return this.send({ method: 'GET', path: `/api/v1/vaults/${encodeURIComponent(vaultId)}/backup-schedule` }) @@ -336,7 +461,7 @@ export class CloudSyncApiClient { vaultId: string, enabled: boolean ): Promise { - return this.http.request({ + return this.send({ method: 'PUT', path: `/api/v1/vaults/${encodeURIComponent(vaultId)}/backup-schedule`, body: { enabled } @@ -349,13 +474,31 @@ export class CloudSyncApiClient { query: CloudBackupItemsQuery = {} ): Promise { const search = query.search?.trim() - return this.http.request({ - method: 'GET', - path: `${this.backupPath(vaultId, backupId)}/items${encodeQuery({ - page: query.page, - search: search || undefined - })}` - }) + const cipher = await this.vaultCipher(vaultId) + const page = (number: number | undefined, text: string | undefined, perPage?: number) => + this.send({ + method: 'GET', + path: `${this.backupPath(vaultId, backupId)}/items${encodeQuery({ page: number, per_page: perPage, search: text || undefined })}` + }) + if (!cipher) return page(query.page, search) + + const open = (items: CloudBackupSnapshotItem[]) => Promise.all(items.map((item) => cipher.openBackupItem(item))) + if (!search) { + const response = await page(query.page, undefined) + return { ...response, data: await open(response.data) } + } + // The service cannot read an encrypted vault's names, so it cannot search + // them: read every page and search the decrypted names here. + const needle = search.toLowerCase() + const matches: CloudBackupSnapshotItem[] = [] + // The service pages an encrypted backup up to 500 items at a time, so a + // search reads a large backup without running into the sync rate limit. + for (let number = 1, last = 1; number <= last; number++) { + const response = await page(number, undefined, 500) + matches.push(...(await open(response.data)).filter((item) => item.path.toLowerCase().includes(needle))) + last = response.meta?.last_page ?? 1 + } + return { data: matches, meta: { current_page: 1, last_page: 1, per_page: Math.max(matches.length, 1), total: matches.length } } } async restoreBackupNote( @@ -364,15 +507,17 @@ export class CloudSyncApiClient { snapshotItemId: number, body: CloudBackupNoteRestoreRequest ): Promise { - return this.http.request({ + const cipher = await this.vaultCipher(vaultId) + const response = await this.send({ method: 'POST', path: `${this.backupPath(vaultId, backupId)}/items/${encodeURIComponent(String(snapshotItemId))}/restore`, body }) + return cipher ? { data: await cipher.openNoteRestore(response.data) } : response } async createBackup(vaultId: string, label?: string): Promise { - return this.http.request({ + return this.send({ method: 'POST', path: `/api/v1/vaults/${encodeURIComponent(vaultId)}/backups`, body: label === undefined ? {} : { label } @@ -380,7 +525,7 @@ export class CloudSyncApiClient { } async deleteBackup(vaultId: string, backupId: string): Promise { - await this.http.request({ + await this.send({ method: 'DELETE', path: this.backupPath(vaultId, backupId) }) @@ -391,7 +536,7 @@ export class CloudSyncApiClient { backupId: string, body: CloudBackupRestoreRequest ): Promise { - return this.http.request({ + return this.send({ method: 'POST', path: `${this.backupPath(vaultId, backupId)}/restores`, body @@ -403,7 +548,7 @@ export class CloudSyncApiClient { backupId: string, restoreId: string ): Promise { - return this.http.request({ + return this.send({ method: 'GET', path: `${this.backupPath(vaultId, backupId)}/restores/${encodeURIComponent(restoreId)}` }) diff --git a/packages/shared-domain/src/cloud-sync-content.ts b/packages/shared-domain/src/cloud-sync-content.ts index 99282af26..dff5a1aed 100644 --- a/packages/shared-domain/src/cloud-sync-content.ts +++ b/packages/shared-domain/src/cloud-sync-content.ts @@ -2,7 +2,8 @@ import type { CloudSyncContent, CloudSyncContentMetadata, CloudSyncContentReference, - CloudSyncDownloadInstruction + CloudSyncDownloadInstruction, + CloudSyncEncryptedContent } from '@zennotes/bridge-contract/cloud-sync' /** Host-only token. Its native handle is never serialized or accepted from HTTP. */ @@ -12,6 +13,13 @@ export interface CloudSyncStagedFile { readonly preview?: Readonly } +/** Decrypts an encrypted vault's stored bytes; a CloudVaultCipher is one. */ +export interface CloudContentDecryptor { + decryptStream(source: AsyncIterable | Iterable): AsyncGenerator + /** For native loops: checks a file's first 74 bytes and returns what decrypting it needs. */ + openContentHeader(header: Uint8Array): Promise<{ header: Uint8Array; objectKey: Uint8Array; aad: Uint8Array }> +} + export interface CloudSyncDownloadSource { reference: CloudSyncContentReference /** Obtain just in time; refresh only the same immutable reference. */ @@ -20,6 +28,12 @@ export interface CloudSyncDownloadSource { /** Optional UTF-8 preview; never bridge more than this many bytes. */ previewLimitBytes: number allowInsecureLoopback?: boolean + /** + * Present exactly when the reference is `encrypted`: the host checks the + * downloaded ciphertext against `reference.encrypted`, decrypts it with this, + * then checks the plaintext against the reference as for any download. + */ + decryptor?: CloudContentDecryptor } export interface CloudSyncStagedConflict { @@ -51,7 +65,7 @@ export function validateCloudSyncContentReference( const ref = record(value) if ( !ref || - !onlyKeys(ref, [...metadataKeys, 'item_id', 'revision']) || + !onlyKeys(ref, [...metadataKeys, 'item_id', 'revision', 'encrypted']) || typeof ref.item_id !== 'string' || !ref.item_id || !Number.isSafeInteger(ref.revision) || @@ -59,6 +73,7 @@ export function validateCloudSyncContentReference( ) invalid() const metadata = validatedMetadata(ref) + const encrypted = ref.encrypted === undefined ? undefined : validatedEncrypted(ref.encrypted) if ( expected && (ref.item_id !== expected.item_id || @@ -66,7 +81,31 @@ export function validateCloudSyncContentReference( !sameMetadata(metadata, expected)) ) invalid() - return { item_id: ref.item_id as string, revision: ref.revision as number, ...metadata } + return { + item_id: ref.item_id as string, + revision: ref.revision as number, + ...metadata, + ...(encrypted ? { encrypted } : {}) + } +} + +/** + * An encrypted vault's reference describes the plaintext for the coordinator + * and, in `encrypted`, the stored ciphertext the download returns. The + * smallest ZNE1 object is 90 bytes (an empty file). + */ +function validatedEncrypted(value: unknown): CloudSyncEncryptedContent { + const encrypted = record(value) + if ( + !encrypted || + !onlyKeys(encrypted, ['sha256', 'byte_length']) || + typeof encrypted.sha256 !== 'string' || + !/^[a-f0-9]{64}$/.test(encrypted.sha256) || + !Number.isSafeInteger(encrypted.byte_length) || + (encrypted.byte_length as number) < 90 + ) + invalid() + return { sha256: encrypted.sha256 as string, byte_length: encrypted.byte_length as number } } export function validateCloudSyncDownloadInstruction( @@ -86,7 +125,7 @@ export function validateCloudSyncDownloadInstruction( data.revision !== ref.revision || !content || !onlyKeys(content, metadataKeys) || - !sameMetadata(validatedMetadata(content), ref) || + !instructionContentMatches(content, ref) || !download || !onlyKeys(download, ['method', 'url', 'headers', 'expires_at']) || download.method !== 'GET' || @@ -104,7 +143,7 @@ export function validateCloudSyncDownloadInstruction( return { item_id: ref.item_id, revision: ref.revision, - content: validatedMetadata(content), + content: ref.encrypted ? encryptedInstructionContent(content) : validatedMetadata(content), download: { method: 'GET', url: download.url as string, @@ -114,6 +153,35 @@ export function validateCloudSyncDownloadInstruction( } } +/** + * A readable reference's download must match its metadata exactly. An + * encrypted reference's download is the ciphertext, so it must be `aes-gcm` + * and match `encrypted`; nothing else may arrive as encrypted bytes. + */ +function instructionContentMatches(content: Record, ref: CloudSyncContentReference): boolean { + if (!ref.encrypted) return sameMetadata(validatedMetadata(content), ref) + const sealed = encryptedInstructionContent(content) + return sealed.sha256 === ref.encrypted.sha256 && sealed.byte_length === ref.encrypted.byte_length +} + +function encryptedInstructionContent(content: Record): CloudSyncContentMetadata { + if ( + content.encoding !== 'aes-gcm' || + typeof content.sha256 !== 'string' || + !/^[a-f0-9]{64}$/.test(content.sha256) || + !Number.isSafeInteger(content.byte_length) || + typeof content.media_type !== 'string' || + !content.media_type + ) + invalid() + return { + encoding: 'aes-gcm', + sha256: content.sha256 as string, + byte_length: content.byte_length as number, + media_type: content.media_type as string + } +} + export function validateCloudSyncDownloadUrl(value: string, allowInsecureLoopback = false): void { const url = new URL(value) const host = url.hostname.replace(/^\[|\]$/g, '').toLowerCase() diff --git a/packages/shared-domain/src/cloud-sync-coordinator.ts b/packages/shared-domain/src/cloud-sync-coordinator.ts index e903e52b9..9dbfc6c3b 100644 --- a/packages/shared-domain/src/cloud-sync-coordinator.ts +++ b/packages/shared-domain/src/cloud-sync-coordinator.ts @@ -41,7 +41,7 @@ import { mergeCloudSyncText, resolveCloudSyncMerge } from './cloud-sync-merge' import { validateCloudSyncContentReference, validateCloudSyncDownloadInstruction, cloudSyncStagedHandle, releaseCloudSyncStagedFile, throwIfCloudSyncCancelled, - type CloudSyncDownloadSource, type CloudSyncStagedFile, type CloudSyncStagedConflict + type CloudContentDecryptor, type CloudSyncDownloadSource, type CloudSyncStagedFile, type CloudSyncStagedConflict } from './cloud-sync-content' const MUTATION_BATCH_SIZE = 100 @@ -66,6 +66,8 @@ export interface CloudSyncRemote { readonly requiresContentReferenceHost?: boolean negotiateContentReferences?(): Promise download?(vaultId: string, itemId: string, revision: number): Promise + /** The decryptor for an end-to-end encrypted vault, or null for a readable one. */ + contentDecryptor?(vaultId: string): Promise /** Host memory budget for estimated bulk responses, capped at the shared * default. Hosts must preserve requested manifest page/perPage values. */ readonly bootstrapContentPageBytes?: number @@ -1105,9 +1107,14 @@ export class CloudSyncCoordinator { } const ref = Object.freeze(validateCloudSyncContentReference(reference)) throwIfCloudSyncCancelled(this.remote.downloadSignal) + const decryptor = ref.encrypted ? await this.remote.contentDecryptor?.(this.vaultId) : undefined + if (ref.encrypted && !decryptor) { + throw new Error('This host cannot decrypt content from an end-to-end encrypted vault.') + } const file = await this.repository.stageCloudContent({ reference: ref, signal: this.remote.downloadSignal, previewLimitBytes: CONFLICT_PREVIEW_LIMIT_BYTES, allowInsecureLoopback: this.remote.allowInsecureLoopbackDownloads, + ...(decryptor ? { decryptor } : {}), getInstruction: async () => { throwIfCloudSyncCancelled(this.remote.downloadSignal) return validateCloudSyncDownloadInstruction( diff --git a/packages/shared-domain/src/cloud-sync-engine.ts b/packages/shared-domain/src/cloud-sync-engine.ts index 577751fcc..c1108870b 100644 --- a/packages/shared-domain/src/cloud-sync-engine.ts +++ b/packages/shared-domain/src/cloud-sync-engine.ts @@ -4,6 +4,7 @@ import type { CloudSyncContentReference, CloudSyncContentMetadata, CloudSyncItemKind, + CloudSyncMoveMutation, CloudSyncMutation, CloudSyncMutationResponse } from '@zennotes/bridge-contract/cloud-sync' @@ -16,6 +17,22 @@ export interface CloudSyncLocalItem { content: CloudSyncContent } +/** + * What an encrypted vault needs to seal a moved file's new metadata; the + * client strips it before a readable vault's move goes out. + */ +function moveHints(item: CloudSyncLocalItem): Pick { + return { + kind: item.kind, + content_metadata: { + encoding: item.content.encoding, + sha256: item.content.sha256, + byte_length: item.content.byte_length, + media_type: item.content.media_type + } + } +} + export interface CloudSyncTrackedItem { item_id: string path: string @@ -130,7 +147,8 @@ export function planCloudSyncMutations( operation_id: ids.operationId(), item_id: trackedItem.item_id, base_revision: trackedItem.revision, - path: exactLocalItem.path + path: exactLocalItem.path, + ...moveHints(exactLocalItem) }) } @@ -150,7 +168,8 @@ export function planCloudSyncMutations( operation_id: ids.operationId(), item_id: trackedItem.item_id, base_revision: trackedItem.revision, - path: movedItem.path + path: movedItem.path, + ...moveHints(movedItem) }) } else { mutations.push({ From c8397681fe8cded9c36f51eff6c12d109ff00f27 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 12 Oct 2026 10:10:26 -0500 Subject: [PATCH 5/8] Feat(cloud): the desktop app keeps encryption keys in its main process 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. --- .../src/main/cloud-backup-archive.test.ts | 82 +++++ apps/desktop/src/main/cloud-backup-archive.ts | 147 ++++++++ apps/desktop/src/main/cloud-e2ee.live.test.ts | 208 +++++++++++ apps/desktop/src/main/cloud-sync-client.ts | 176 ++++++++-- apps/desktop/src/main/cloud-sync-download.ts | 39 ++- apps/desktop/src/main/cloud-sync-e2ee.test.ts | 218 ++++++++++++ .../src/main/cloud-sync-service.test.ts | 13 + apps/desktop/src/main/cloud-sync-service.ts | 323 ++++++++++++++++-- apps/desktop/src/main/index.ts | 99 +++++- apps/desktop/src/main/secret-store.ts | 173 ++++++++-- apps/desktop/src/preload/index.ts | 23 +- packages/bridge-contract/src/bridge.ts | 21 +- packages/bridge-contract/src/ipc.ts | 9 + 13 files changed, 1462 insertions(+), 69 deletions(-) create mode 100644 apps/desktop/src/main/cloud-backup-archive.test.ts create mode 100644 apps/desktop/src/main/cloud-backup-archive.ts create mode 100644 apps/desktop/src/main/cloud-e2ee.live.test.ts create mode 100644 apps/desktop/src/main/cloud-sync-e2ee.test.ts diff --git a/apps/desktop/src/main/cloud-backup-archive.test.ts b/apps/desktop/src/main/cloud-backup-archive.test.ts new file mode 100644 index 000000000..1f011e920 --- /dev/null +++ b/apps/desktop/src/main/cloud-backup-archive.test.ts @@ -0,0 +1,82 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { gunzipSync, gzipSync } from 'node:zlib' +import { afterEach, describe, expect, it } from 'vitest' +import { createHash, randomBytes } from 'node:crypto' +import type { CloudSyncContent } from '@zennotes/bridge-contract/cloud-sync' +import { generateCloudE2eeKey } from '@zennotes/shared-domain/cloud-e2ee' +import { CloudVaultCipher } from '@zennotes/shared-domain/cloud-e2ee-vault' +import { rewriteBackupArchive } from './cloud-backup-archive' + +const directories: string[] = [] +afterEach(async () => { + await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))) +}) + +async function scratch(): Promise { + const directory = await mkdtemp(path.join(os.tmpdir(), 'zennotes-archive-')) + directories.push(directory) + return directory +} + +function plain(bytes: Uint8Array, encoding: 'utf8' | 'base64', mediaType: string): CloudSyncContent { + return { + encoding, + data: encoding === 'utf8' ? Buffer.from(bytes).toString('utf8') : Buffer.from(bytes).toString('base64'), + sha256: createHash('sha256').update(bytes).digest('hex'), + byte_length: bytes.byteLength, + media_type: mediaType + } +} + +describe('rewriteBackupArchive', () => { + it('turns an encrypted vault archive into a readable one, item by item', async () => { + const accountKey = generateCloudE2eeKey() + const encryption = await CloudVaultCipher.prepareVault('Private', accountKey, 1) + const cipher = await CloudVaultCipher.open({ id: 'vault-enc', encryption }, accountKey, 1) + const files = [ + { path: 'Notes/Caf\u00e9 "plans".md', kind: 'text' as const, content: plain(Buffer.from('# Plans\n\nBring {braces} and [brackets] and "quotes"\\.\n'), 'utf8', 'text/markdown') }, + { path: 'attachements/photo.png', kind: 'binary' as const, content: plain(randomBytes(200_000), 'base64', 'image/png') }, + { path: 'Empty.md', kind: 'text' as const, content: plain(Buffer.alloc(0), 'utf8', 'text/markdown') } + ] + const sealedItems = [] + for (const [index, file] of files.entries()) { + const { pathId, meta } = await cipher.sealMeta(file.path, file.kind, file.content) + sealedItems.push({ + item_id: `item-${index}`, + path: pathId, + kind: 'binary', + revision: index + 1, + meta, + content: await cipher.sealContent(file.content) + }) + } + const header = { format_version: 1, snapshot_id: 'snap-1', vault_id: 'vault-enc', cursor: 9, created_at: '2026-10-12T00:00:00+00:00' } + const archiveText = `${JSON.stringify(header).slice(0, -1)},"items":[${sealedItems.map((item) => JSON.stringify(item)).join(',')}]}` + const directory = await scratch() + const source = path.join(directory, 'sealed.json.gz') + const target = path.join(directory, 'readable.json.gz') + // Small gzip blocks force items to straddle chunk boundaries. + await writeFile(source, gzipSync(Buffer.from(archiveText), { chunkSize: 1024 })) + + const result = await rewriteBackupArchive(source, target, (item) => cipher.openArchiveItem(item as never)) + expect(result.items).toBe(3) + const readable = JSON.parse(gunzipSync(await readFile(target)).toString('utf8')) + expect(readable).toMatchObject(header) + expect(readable.items.map((item: { path: string }) => item.path)).toEqual(files.map((file) => file.path)) + for (const [index, file] of files.entries()) { + expect(readable.items[index]).toEqual({ item_id: `item-${index}`, path: file.path, kind: file.kind, revision: index + 1, content: file.content }) + } + }) + + it('refuses an archive that is not the service format, without writing a readable copy', async () => { + const directory = await scratch() + const source = path.join(directory, 'odd.json.gz') + const target = path.join(directory, 'out.json.gz') + await writeFile(source, gzipSync(Buffer.from('{"format_version":1,"other":[1,2],"items":[]}'))) + await expect(rewriteBackupArchive(source, target, async (item) => item)).rejects.toThrow(/could not read this backup archive/) + await writeFile(source, gzipSync(Buffer.from('{"format_version":1,"items":[{"a":1}'))) + await expect(rewriteBackupArchive(source, `${target}2`, async (item) => item)).rejects.toThrow(/could not read this backup archive/) + }) +}) diff --git a/apps/desktop/src/main/cloud-backup-archive.ts b/apps/desktop/src/main/cloud-backup-archive.ts new file mode 100644 index 000000000..3ad0b70ad --- /dev/null +++ b/apps/desktop/src/main/cloud-backup-archive.ts @@ -0,0 +1,147 @@ +import { createReadStream, createWriteStream } from 'node:fs' +import { pipeline } from 'node:stream/promises' +import { createGunzip, createGzip } from 'node:zlib' + +type ArchiveItem = Record + +const OPEN_BRACE = 0x7b +const CLOSE_BRACE = 0x7d +const OPEN_BRACKET = 0x5b +const CLOSE_BRACKET = 0x5d +const QUOTE = 0x22 +const BACKSLASH = 0x5c +const COMMA = 0x2c + +function isWhitespace(byte: number): boolean { + return byte === 0x20 || byte === 0x0a || byte === 0x0d || byte === 0x09 +} + +function unexpectedFormat(): Error { + return new Error('ZenNotes could not read this backup archive. Download it again.') +} + +/** + * Rewrites a downloaded backup archive one item at a time: the service's + * archive is a single gzip JSON object whose `items` array carries every + * file's data inline, so it is scanned, not parsed whole, and only one item is + * ever held in memory. An end-to-end encrypted vault's archive goes in, a + * readable archive in the same format comes out. + */ +export async function rewriteBackupArchive( + sourcePath: string, + destinationPath: string, + rewriteItem: (item: ArchiveItem) => Promise +): Promise<{ items: number }> { + let items = 0 + await pipeline( + createReadStream(sourcePath), + createGunzip(), + async function* (source: AsyncIterable) { + for await (const piece of rewriteArchiveItems(source, async (item) => { + items++ + return rewriteItem(item) + })) { + yield piece + } + }, + createGzip(), + createWriteStream(destinationPath, { flags: 'wx', mode: 0o600 }) + ) + return { items } +} + +export async function* rewriteArchiveItems( + source: AsyncIterable, + rewriteItem: (item: ArchiveItem) => Promise +): AsyncGenerator { + let phase = 'header' as 'header' | 'items' | 'footer' + let depth = 0 + let inString = false + let escaped = false + const header: Buffer[] = [] + const footer: Buffer[] = [] + let item: Buffer[] = [] + let written = 0 + + for await (const chunk of source) { + let start = 0 + for (let index = 0; index < chunk.length; index++) { + const byte = chunk[index]! + if (inString) { + if (escaped) escaped = false + else if (byte === BACKSLASH) escaped = true + else if (byte === QUOTE) inString = false + continue + } + if (byte === QUOTE) { + inString = true + continue + } + if (phase === 'header') { + if (byte === OPEN_BRACE || byte === OPEN_BRACKET) depth++ + else if (byte === CLOSE_BRACE || byte === CLOSE_BRACKET) depth-- + // The first array one level into the top object must be `items`. + if (byte === OPEN_BRACKET && depth === 2) { + header.push(chunk.subarray(start, index + 1)) + const text = Buffer.concat(header).toString('utf8') + let parsed: unknown + try { + parsed = JSON.parse(`${text}]}`) + } catch { + throw unexpectedFormat() + } + const keys = parsed && typeof parsed === 'object' ? Object.keys(parsed) : [] + if (keys.at(-1) !== 'items') throw unexpectedFormat() + yield Buffer.from(text, 'utf8') + phase = 'items' + start = index + 1 + } + continue + } + if (phase === 'items') { + if (depth === 2) { + if (byte === OPEN_BRACE) { + depth = 3 + start = index + item = [] + } else if (byte === CLOSE_BRACKET) { + depth = 1 + phase = 'footer' + start = index + 1 + } else if (byte !== COMMA && !isWhitespace(byte)) { + throw unexpectedFormat() + } + continue + } + if (byte === OPEN_BRACE || byte === OPEN_BRACKET) depth++ + else if (byte === CLOSE_BRACE || byte === CLOSE_BRACKET) depth-- + if (depth === 2) { + item.push(chunk.subarray(start, index + 1)) + let parsed: unknown + try { + parsed = JSON.parse(Buffer.concat(item).toString('utf8')) + } catch { + throw unexpectedFormat() + } + item = [] + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw unexpectedFormat() + const rewritten = await rewriteItem(parsed as ArchiveItem) + yield Buffer.from(`${written > 0 ? ',' : ''}${JSON.stringify(rewritten)}`, 'utf8') + written++ + } + continue + } + // Footer: what follows the items array, normally the closing brace. + if (byte === OPEN_BRACE || byte === OPEN_BRACKET) depth++ + else if (byte === CLOSE_BRACE || byte === CLOSE_BRACKET) depth-- + } + if (phase === 'header') header.push(chunk.subarray(start)) + else if (phase === 'items' && depth > 2) item.push(chunk.subarray(start)) + else if (phase === 'footer') footer.push(chunk.subarray(start)) + } + + if (phase !== 'footer' || depth !== 0 || inString) throw unexpectedFormat() + const tail = Buffer.concat(footer).toString('utf8') + if (tail.trim() !== '}') throw unexpectedFormat() + yield Buffer.from(`]${tail}`, 'utf8') +} diff --git a/apps/desktop/src/main/cloud-e2ee.live.test.ts b/apps/desktop/src/main/cloud-e2ee.live.test.ts new file mode 100644 index 000000000..0dda3263d --- /dev/null +++ b/apps/desktop/src/main/cloud-e2ee.live.test.ts @@ -0,0 +1,208 @@ +/** + * End-to-end encryption against a running ZenNotes Cloud service, with two + * desktop "devices" driven through the real sync service, client and crypto. + * Skipped unless ZEN_E2EE_LIVE_BASE_URL (a local website with CLOUD_E2EE on) + * and ZEN_E2EE_LIVE_TOKENS (a JSON file of device tokens A and B) are set. + * ZEN_E2EE_LIVE_DB and ZEN_E2EE_LIVE_MINIO, when set, let it check that no + * readable marker ever reached the database or object storage. + */ +import { execFileSync } from 'node:child_process' +import { randomBytes } from 'node:crypto' +import { readdirSync, readFileSync } from 'node:fs' +import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { gunzipSync } from 'node:zlib' +import { afterAll, describe, expect, it } from 'vitest' +import type { CloudAccountKeyStore, StoredCloudAccountKey } from '@zennotes/shared-domain/cloud-e2ee-account' +import { createCloudSyncClient } from './cloud-sync-client' +import { DesktopCloudSyncService } from './cloud-sync-service' + +const BASE_URL = process.env.ZEN_E2EE_LIVE_BASE_URL +const TOKENS = process.env.ZEN_E2EE_LIVE_TOKENS +const DB = process.env.ZEN_E2EE_LIVE_DB +const MINIO = process.env.ZEN_E2EE_LIVE_MINIO +const PASSPHRASE = 'harbor quiet lantern orbit 42' +const NOTE_MARKER = 'MARKER-ALPHA-7f3e' +const NAME_MARKER = 'SecretPlans9c2b' +const BIG_MARKER = 'BIGFILE-MARKER-41d8' + +const scratch: string[] = [] +afterAll(async () => { + await Promise.all(scratch.map((directory) => rm(directory, { recursive: true, force: true }))) +}) + +async function tempDir(label: string): Promise { + const directory = await mkdtemp(path.join(os.tmpdir(), `zen-e2ee-live-${label}-`)) + scratch.push(directory) + return directory +} + +function memoryKeyStore(): CloudAccountKeyStore & { value: StoredCloudAccountKey | null } { + return { + value: null, + async load() { + return this.value + }, + async save(value) { + this.value = { ...value } + }, + async clear() { + this.value = null + } + } +} + +async function device(label: 'A' | 'B') { + const tokens = JSON.parse(await readFile(TOKENS!, 'utf8')) as Record + const keyStore = memoryKeyStore() + const service = new DesktopCloudSyncService({ + storageDirectory: await tempDir(`state-${label}`), + accountStatus: async () => ({ + state: 'connected', + account: { + base_url: BASE_URL!, + user: { name: 'E2EE Test', email: 'e2ee@example.test' }, + device: { id: `device-${label}`, name: `Device ${label}`, platform: 'desktop' }, + connected_at: new Date().toISOString() + } + }), + getSecret: async () => tokens[label]!, + createClient: (baseUrl, token, options) => createCloudSyncClient(baseUrl, token, fetch, options), + accountKeyStore: () => keyStore + }) + return { service, keyStore } +} + +function filesUnder(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true, recursive: true }) + .filter((entry) => entry.isFile()) + .map((entry) => path.join(entry.parentPath, entry.name)) +} + +/** Every readable marker found in the service's database files or stored objects. */ +async function serverLeaks(markers: string[]): Promise { + const found: string[] = [] + const scan = (label: string, bytes: Buffer) => { + for (const marker of markers) if (bytes.includes(marker)) found.push(`${label}: ${marker}`) + } + if (DB) { + for (const file of [DB, `${DB}-wal`, `${DB}-journal`]) { + try { + scan(path.basename(file), readFileSync(file)) + } catch { + // That journal file does not exist in this mode. + } + } + } + if (MINIO) { + // The MinIO image has no grep: copy its data out and scan it here. + const copy = await tempDir('minio') + execFileSync('docker', ['cp', `${MINIO}:/data/.`, copy]) + const files = filesUnder(copy) + expect(files.length).toBeGreaterThan(0) + for (const file of files) scan(`object ${path.relative(copy, file)}`, readFileSync(file)) + } + return found +} + +function sql(query: string): string { + return execFileSync('sqlite3', [DB!, query]).toString().trim() +} + +describe.skipIf(!BASE_URL || !TOKENS)('end-to-end encryption against a live service', () => { + it('sets up, syncs two devices, renames, edits, backs up, migrates, and locks without leaking plaintext', async () => { + const a = await device('A') + const b = await device('B') + + // Setup on A; nothing reaches the service until the words are confirmed. + expect(await a.service.encryptionStatus()).toEqual({ available: true, keyset: false, unlocked: false }) + const setup = await a.service.prepareEncryptionSetup(PASSPHRASE) + expect(setup.recoveryWords).toHaveLength(24) + expect((await a.service.encryptionStatus()).keyset).toBe(false) + await a.service.finishEncryptionSetup(setup.setupId) + expect(await a.service.encryptionStatus()).toEqual({ available: true, keyset: true, unlocked: true }) + + // An encrypted vault with a note, a nested note, a mid-size attachment + // (downloaded by reference) and a 6 MB file (uploaded directly). + const rootA = await tempDir('vault-A') + await mkdir(path.join(rootA, 'Journal', '2026'), { recursive: true }) + await mkdir(path.join(rootA, 'attachements'), { recursive: true }) + await writeFile(path.join(rootA, `${NAME_MARKER}.md`), `# Plans\n\n${NOTE_MARKER} meet at noon.\n`) + await writeFile(path.join(rootA, 'Journal', '2026', 'Plan.md'), `# Plan\n\n${NOTE_MARKER} second note.\n`) + await writeFile(path.join(rootA, 'attachements', 'photo.png'), randomBytes(300_000)) + const big = Buffer.from(`${BIG_MARKER}\n`.repeat(Math.ceil(6_200_000 / (BIG_MARKER.length + 1)))) + await writeFile(path.join(rootA, 'attachements', 'big.txt'), big) + + const link = await a.service.createAndLink(rootA, 'Private journal', { encrypted: true }) + const first = await a.service.sync(rootA) + expect(first.conflicts).toEqual([]) + expect(first.pushed).toBeGreaterThanOrEqual(4) + + if (DB) { + const rows = sql(`select path || '|' || (meta is not null) || '|' || coalesce(content_encoding, '') from vault_items where vault_id = '${link.vault_id}' and deleted_at is null`).split('\n') + expect(rows.length).toBeGreaterThanOrEqual(4) + for (const row of rows) expect(row).toMatch(/^[0-9a-f]{64}\|1\|aes-gcm$/) + expect(sql(`select name from vaults where id = '${link.vault_id}'`)).toBe('Encrypted vault') + } + expect(await serverLeaks([NOTE_MARKER, NAME_MARKER, BIG_MARKER, 'Private journal', 'Journal/2026'])).toEqual([]) + + // B unlocks with the passphrase, sees the real name, and pulls every file intact. + await expect(b.service.unlockEncryption({ passphrase: 'not the passphrase at all' })).rejects.toThrow(/does not unlock/) + await b.service.unlockEncryption({ passphrase: PASSPHRASE }) + const listed = await b.service.listVaults() + expect(listed.find((vault) => vault.id === link.vault_id)?.name).toBe('Private journal') + const rootB = await tempDir('vault-B') + await b.service.link(rootB, link.vault_id) + await b.service.sync(rootB) + for (const file of [`${NAME_MARKER}.md`, 'Journal/2026/Plan.md', 'attachements/photo.png', 'attachements/big.txt']) { + expect((await readFile(path.join(rootB, file))).equals(await readFile(path.join(rootA, file)))).toBe(true) + } + + // A rename on A reaches B; an edit on B reaches A. + await mkdir(path.join(rootA, 'Archive'), { recursive: true }) + await rename(path.join(rootA, `${NAME_MARKER}.md`), path.join(rootA, 'Archive', `${NAME_MARKER}.md`)) + await a.service.sync(rootA) + await b.service.sync(rootB) + expect(await readFile(path.join(rootB, 'Archive', `${NAME_MARKER}.md`), 'utf8')).toContain(NOTE_MARKER) + await writeFile(path.join(rootB, 'Journal', '2026', 'Plan.md'), `# Plan\n\n${NOTE_MARKER} edited on B.\n`) + await b.service.sync(rootB) + await a.service.sync(rootA) + expect(await readFile(path.join(rootA, 'Journal', '2026', 'Plan.md'), 'utf8')).toContain('edited on B') + + // Backups: unlabeled, searchable by real names here, downloadable readable. + const backup = await a.service.createBackup(rootA, `${NAME_MARKER} label`) + expect(backup.label).toBeNull() + const found = await a.service.listBackupItemsPage(rootA, backup.id, { search: 'plan' }) + expect(found.items.map((item) => item.path).sort()).toEqual([`Archive/${NAME_MARKER}.md`, 'Journal/2026/Plan.md']) + const archive = path.join(await tempDir('archive'), 'backup.json.gz') + await a.service.downloadBackup(rootA, backup.id, archive) + const readable = JSON.parse(gunzipSync(await readFile(archive)).toString('utf8')) as { items: Array<{ path: string; content: { data: string } }> } + expect(readable.items.map((item) => item.path).sort()).toEqual( + [`Archive/${NAME_MARKER}.md`, 'Journal/2026/Plan.md', 'attachements/big.txt', 'attachements/photo.png'].sort() + ) + expect(readable.items.find((item) => item.path === 'Journal/2026/Plan.md')?.content.data).toContain('edited on B') + expect(await serverLeaks([NOTE_MARKER, NAME_MARKER, BIG_MARKER])).toEqual([]) + + // Moving a readable vault: A encrypts it, B's link to the old vault is told. + const rootC = await tempDir('vault-C') + await writeFile(path.join(rootC, 'Readable.md'), '# Readable\n\nmoving soon\n') + const readableLink = await a.service.createAndLink(rootC, 'Old notes') + await a.service.sync(rootC) + const rootD = await tempDir('vault-D') + await b.service.link(rootD, readableLink.vault_id) + await b.service.sync(rootD) + const moved = await a.service.encryptLinkedVault(rootC) + expect(moved.vault_id).not.toBe(readableLink.vault_id) + expect(moved.replaces_vault_id).toBeUndefined() + expect((await a.service.listVaults()).some((vault) => vault.id === readableLink.vault_id)).toBe(false) + await expect(b.service.sync(rootD)).rejects.toThrow(/now end-to-end encrypted/) + + // Locked devices stop; the recovery words unlock again. + await a.service.lockEncryption() + await expect(a.service.sync(rootA)).rejects.toThrow(/Unlock encryption on this device/) + await a.service.unlockEncryption({ recoveryWords: setup.recoveryWords.join(' ') }) + const afterUnlock = await a.service.sync(rootA) + expect(afterUnlock.conflicts).toEqual([]) + }, 600_000) +}) diff --git a/apps/desktop/src/main/cloud-sync-client.ts b/apps/desktop/src/main/cloud-sync-client.ts index 6149276cd..69bb23ba7 100644 --- a/apps/desktop/src/main/cloud-sync-client.ts +++ b/apps/desktop/src/main/cloud-sync-client.ts @@ -8,7 +8,11 @@ import { } from '@zennotes/shared-domain/cloud-sync-api' import { createHash } from 'node:crypto' import { createReadStream, type ReadStream } from 'node:fs' -import { stat } from 'node:fs/promises' +import { chmod, mkdtemp, open, rm, stat } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { zne1CiphertextLength } from '@zennotes/shared-domain/cloud-e2ee' +import type { CloudVaultCipher, CloudVaultCipherSource } from '@zennotes/shared-domain/cloud-e2ee-vault' import type { CloudPublishUploadTarget, CloudSyncCapacityConflict, @@ -56,6 +60,9 @@ export interface DesktopCloudSyncClientOptions { signal?: AbortSignal rateLimits?: CloudSyncRateLimitCoordinator contentReferences?: boolean + /** Desktop seals and opens every path an encrypted vault's bytes take. */ + endToEndEncryption?: boolean + vaultCiphers?: CloudVaultCipherSource } /** One staged publish attachment to PUT: a local file streamed from disk, or a @@ -106,7 +113,9 @@ class DesktopCloudSyncApiClient extends CloudSyncApiClient { super(http, { contentReferences: options.contentReferences, accountScope: { baseUrl: options.baseUrl, accountId: options.accountId }, signal, allowInsecureLoopbackDownloads: url.protocol === 'http:' && - (url.hostname === 'localhost' || url.hostname === '[::1]' || /^127\./.test(url.hostname)) + (url.hostname === 'localhost' || url.hostname === '[::1]' || /^127\./.test(url.hostname)), + endToEndEncryption: options.endToEndEncryption, + vaultCiphers: options.vaultCiphers }) } @@ -114,7 +123,12 @@ class DesktopCloudSyncApiClient extends CloudSyncApiClient { vaultId: string, body: CloudSyncMutationRequest ): Promise { - if (!body.mutations.some(usesDirectUpload)) return super.mutate(vaultId, body) + // Encryption adds a header and a tag per chunk, and the service caps inline + // bodies, so an encrypted vault decides by the size it will actually send. + const encrypted = (await this.vaultCipher(vaultId)) !== null + const direct = (mutation: CloudSyncMutation): mutation is CloudSyncUpsertMutation => + usesDirectUpload(mutation, encrypted) + if (!body.mutations.some(direct)) return super.mutate(vaultId, body) const responses: CloudSyncMutationResponse[] = [] let inlineMutations: CloudSyncMutation[] = [] @@ -125,7 +139,7 @@ class DesktopCloudSyncApiClient extends CloudSyncApiClient { } for (const mutation of body.mutations) { - if (usesDirectUpload(mutation)) { + if (direct(mutation)) { await flushInline() responses.push(await this.directUpload(vaultId, mutation)) } else { @@ -146,20 +160,36 @@ class DesktopCloudSyncApiClient extends CloudSyncApiClient { mutation: CloudSyncUpsertMutation ): Promise { this.signal?.throwIfAborted() - const uploadBody = await prepareDirectUploadBody(mutation) + const cipher = await this.vaultCipher(vaultId) + const prepared = cipher + ? await prepareEncryptedDirectUpload(mutation, cipher, this.signal) + : await preparePlainDirectUpload(mutation) + try { + return await this.sendDirectUpload(vaultId, mutation, prepared, cipher) + } finally { + await prepared.cleanup() + } + } + private async sendDirectUpload( + vaultId: string, + mutation: CloudSyncUpsertMutation, + prepared: PreparedDirectUpload, + cipher: CloudVaultCipher | null + ): Promise { + const uploadBody = prepared let initiation: CloudSyncUploadInitiationResponse try { - initiation = await this.initiateUpload(vaultId, uploadRequest(mutation)) + initiation = await this.initiateUpload(vaultId, prepared.request) } catch (error) { const conflict = directUploadConflict(error, mutation) - if (conflict) return { acknowledged: [], conflicts: [conflict], cursor: 0 } + if (conflict) return { acknowledged: [], conflicts: [cipher ? await cipher.openConflict(conflict) : conflict], cursor: 0 } throw error } let instruction: CloudSyncUploadInitiationResponse['data'] let uploadUrl: string try { - instruction = directUploadInstruction(initiation, mutation) + instruction = directUploadInstruction(initiation, mutation, prepared.contentLength) uploadUrl = secureDirectUploadUrl(instruction.upload.url) } catch (error) { const uploadId = uploadSessionId(initiation) @@ -178,9 +208,9 @@ class DesktopCloudSyncApiClient extends CloudSyncApiClient { // the service reserved. const headers = new Headers(upload.headers) if (!headers.has('content-length')) { - headers.set('content-length', String(mutation.content.byte_length)) + headers.set('content-length', String(prepared.contentLength)) } - if (!headers.has('content-type')) headers.set('content-type', mutation.content.media_type) + if (!headers.has('content-type')) headers.set('content-type', prepared.contentType) let response: Response try { @@ -213,7 +243,7 @@ class DesktopCloudSyncApiClient extends CloudSyncApiClient { ) } - return await this.completeDirectUpload(vaultId, instruction.id, mutation) + return await this.completeDirectUpload(vaultId, instruction.id, mutation, cipher) } /** @@ -273,7 +303,8 @@ class DesktopCloudSyncApiClient extends CloudSyncApiClient { private async completeDirectUpload( vaultId: string, uploadId: string, - mutation: CloudSyncUpsertMutation + mutation: CloudSyncUpsertMutation, + cipher: CloudVaultCipher | null ): Promise { for (let attempt = 1; attempt <= DIRECT_UPLOAD_COMPLETION_ATTEMPTS; attempt++) { this.signal?.throwIfAborted() @@ -281,7 +312,7 @@ class DesktopCloudSyncApiClient extends CloudSyncApiClient { return (await this.completeUpload(vaultId, uploadId)).data.result } catch (error) { const conflict = directUploadConflict(error, mutation) - if (conflict) return { acknowledged: [], conflicts: [conflict], cursor: 0 } + if (conflict) return { acknowledged: [], conflicts: [cipher ? await cipher.openConflict(conflict) : conflict], cursor: 0 } if (attempt === DIRECT_UPLOAD_COMPLETION_ATTEMPTS || !retryableCompletionError(error)) { throw error } @@ -308,6 +339,7 @@ class BearerFetchTransport implements CloudSyncHttpTransport { const response = await this.fetchImplementation(`${this.baseUrl}${request.path}`, { method: request.method, headers: { + ...request.headers, Accept: 'application/json', Authorization: `Bearer ${this.token}`, ...(request.body === undefined || multipart ? {} : { 'Content-Type': 'application/json' }) @@ -397,11 +429,113 @@ function asErrorPayload(payload: unknown): { } } -function usesDirectUpload(mutation: CloudSyncMutation): mutation is CloudSyncUpsertMutation { - return ( - mutation.type === 'upsert' && - mutation.content.byte_length > CLOUD_SYNC_INLINE_UPLOAD_LIMIT_BYTES - ) +function usesDirectUpload(mutation: CloudSyncMutation, encrypted: boolean): mutation is CloudSyncUpsertMutation { + if (mutation.type !== 'upsert') return false + const bytes = encrypted ? zne1CiphertextLength(mutation.content.byte_length) : mutation.content.byte_length + return bytes > CLOUD_SYNC_INLINE_UPLOAD_LIMIT_BYTES +} + +interface PreparedDirectUpload { + request: CloudSyncUploadRequest + /** The exact bytes the PUT sends: the file, or for an encrypted vault its ciphertext. */ + contentLength: number + contentType: string + createBody(): FetchBody + dispose(): void + stream: boolean + cleanup(): Promise +} + +async function preparePlainDirectUpload(mutation: CloudSyncUpsertMutation): Promise { + return { + ...(await prepareDirectUploadBody(mutation)), + request: uploadRequest(mutation), + contentLength: mutation.content.byte_length, + contentType: mutation.content.media_type, + cleanup: async () => {} + } +} + +/** + * Seals the file into a private temp file first: the service must be told the + * ciphertext's length and hash before the PUT starts, and streaming keeps a + * 200 MB attachment from ever sitting in memory. The plaintext is hashed on the + * way through, so a file that changed since sync hashed it is refused instead + * of being sealed under the old hash. + */ +async function prepareEncryptedDirectUpload( + mutation: CloudSyncUpsertMutation, + cipher: CloudVaultCipher, + signal: AbortSignal | undefined +): Promise { + const directory = await mkdtemp(path.join(os.tmpdir(), 'zennotes-upload-')) + const cleanup = () => rm(directory, { recursive: true, force: true }) + try { + await chmod(directory, 0o700) + const sealedPath = path.join(directory, 'sealed') + const sourcePath = cloudSyncUploadSource(mutation.content) + const plaintext: AsyncIterable | Iterable = sourcePath + ? createReadStream(sourcePath) + : [uploadBytes(mutation)] + const plainHash = createHash('sha256') + let plainLength = 0 + async function* hashed(): AsyncGenerator { + for await (const chunk of plaintext) { + plainLength += chunk.byteLength + plainHash.update(chunk) + yield chunk + } + } + const sealedHash = createHash('sha256') + let sealedLength = 0 + const file = await open(sealedPath, 'wx', 0o600) + try { + for await (const piece of cipher.encryptStream(hashed())) { + signal?.throwIfAborted() + sealedHash.update(piece) + sealedLength += piece.byteLength + let offset = 0 + while (offset < piece.byteLength) { + const written = await file.write(piece, offset) + if (written.bytesWritten === 0) throw new Error('Encrypted upload staging made no progress.') + offset += written.bytesWritten + } + } + await file.sync() + } finally { + await file.close() + } + if (plainLength !== mutation.content.byte_length || plainHash.digest('hex') !== mutation.content.sha256) { + throw directUploadSizeMismatch() + } + const fields = await cipher.directUploadFields(mutation.path, mutation.kind, mutation.content, { + sha256: sealedHash.digest('hex'), + byte_length: sealedLength + }) + let reader: ReadStream | undefined + return { + request: { + operation_id: mutation.operation_id, + item_id: mutation.item_id, + base_revision: mutation.base_revision, + path: fields.path, + kind: fields.kind, + content: fields.content, + meta: fields.meta + }, + contentLength: sealedLength, + contentType: fields.content.media_type, + createBody: () => (reader = createReadStream(sealedPath)) as unknown as FetchBody, + dispose: () => { + reader?.destroy() + }, + stream: true, + cleanup + } + } catch (error) { + await cleanup() + throw error + } } function uploadRequest(mutation: CloudSyncUpsertMutation): CloudSyncUploadRequest { @@ -485,7 +619,8 @@ function secureDirectUploadUrl(value: string): string { function directUploadInstruction( initiation: CloudSyncUploadInitiationResponse, - mutation: CloudSyncUpsertMutation + mutation: CloudSyncUpsertMutation, + expectedBytes: number ): CloudSyncUploadInitiationResponse['data'] { const candidate = initiation as unknown as { data?: unknown } if (!isRecord(candidate.data)) throw invalidDirectUploadResponse() @@ -494,7 +629,7 @@ function directUploadInstruction( const upload = data.upload if ( data.operation_id !== mutation.operation_id || - data.expected_bytes !== mutation.content.byte_length || + data.expected_bytes !== expectedBytes || typeof data.id !== 'string' || data.id === '' || upload.method !== 'PUT' || @@ -556,6 +691,7 @@ function directUploadConflict( typeof error.details?.current_revision === 'number' ? error.details.current_revision : null, current_path: typeof error.details?.current_path === 'string' ? error.details.current_path : null, + ...(typeof error.details?.current_meta === 'string' ? { current_meta: error.details.current_meta } : {}), ...(capacity ? { capacity } : {}) } } diff --git a/apps/desktop/src/main/cloud-sync-download.ts b/apps/desktop/src/main/cloud-sync-download.ts index 70c282a3e..729a2b680 100644 --- a/apps/desktop/src/main/cloud-sync-download.ts +++ b/apps/desktop/src/main/cloud-sync-download.ts @@ -29,6 +29,12 @@ export async function stageDesktopCloudContent( options: DesktopCloudDownloadOptions = {} ): Promise { const reference = validateCloudSyncContentReference(source.reference) + // An encrypted vault's download is ciphertext: it is checked against + // `encrypted`, decrypted while it streams, and only then checked against the + // plaintext metadata like any other download. + const encrypted = reference.encrypted + const decryptor = source.decryptor + if (encrypted && !decryptor) throw new Error('This encrypted download has no key to open it.') throwIfCloudSyncCancelled(source.signal) const directory = await fs.mkdtemp( path.join(options.stagingDirectory ?? os.tmpdir(), 'zennotes-download-') @@ -62,7 +68,7 @@ export async function stageDesktopCloudContent( throw Object.assign(new Error('Cloud object download failed.'), { status: response.status }) } const length = response.headers.get('content-length') - if (length !== null && Number(length) !== reference.byte_length) { + if (length !== null && Number(length) !== (encrypted ?? reference).byte_length) { await response.body?.cancel() throw new Error('Cloud download length did not match its reference.') } @@ -77,7 +83,14 @@ export async function stageDesktopCloudContent( } } try { - await writeVerified(chunks(), destination, reference, signal) + await writeVerified( + encrypted && decryptor + ? decryptor.decryptStream(verifiedCiphertext(chunks(), encrypted)) + : chunks(), + destination, + reference, + signal + ) } finally { await reader?.cancel().catch(() => {}) reader?.releaseLock() @@ -110,6 +123,28 @@ export async function stageDesktopCloudContent( } } +/** + * Passes ciphertext through while hashing it, and fails at the end unless it + * was exactly the object the reference describes. The decryptor downstream + * only yields authenticated chunks, so nothing forged reaches the disk either. + */ +async function* verifiedCiphertext( + chunks: AsyncIterable, + expected: { sha256: string; byte_length: number } +): AsyncGenerator { + const hash = createHash('sha256') + let length = 0 + for await (const chunk of chunks) { + length += chunk.byteLength + if (length > expected.byte_length) throw new Error('Cloud download exceeded its encrypted length.') + hash.update(chunk) + yield chunk + } + if (length !== expected.byte_length || hash.digest('hex') !== expected.sha256) { + throw new Error('Cloud download failed length or SHA-256 verification.') + } +} + /** Bounded disk-to-disk copy, also used for large local conflict copies. */ export async function copyVerifiedCloudFile( source: string, diff --git a/apps/desktop/src/main/cloud-sync-e2ee.test.ts b/apps/desktop/src/main/cloud-sync-e2ee.test.ts new file mode 100644 index 000000000..cc23baa9b --- /dev/null +++ b/apps/desktop/src/main/cloud-sync-e2ee.test.ts @@ -0,0 +1,218 @@ +import { createHash, randomBytes } from 'node:crypto' +import { existsSync } from 'node:fs' +import { mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { CloudSyncContentReference, CloudSyncUpsertMutation } from '@zennotes/bridge-contract/cloud-sync' +import { generateCloudE2eeKey, zne1CiphertextLength } from '@zennotes/shared-domain/cloud-e2ee' +import { CloudE2eeLockedError, CloudVaultCipher } from '@zennotes/shared-domain/cloud-e2ee-vault' +import { createCloudSyncClient } from './cloud-sync-client' +import { stageDesktopCloudContent } from './cloud-sync-download' +import { rememberCloudSyncUploadSource } from './cloud-sync-upload-source' +import { cloudSyncStagedHandle, releaseCloudSyncStagedFile } from '@zennotes/shared-domain/cloud-sync-content' + +const INLINE_LIMIT = 5 * 1024 * 1024 +const temporaryDirectories: string[] = [] + +afterEach(async () => { + vi.restoreAllMocks() + await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))) +}) + +async function encryptedVault() { + const accountKey = generateCloudE2eeKey() + const encryption = await CloudVaultCipher.prepareVault('Private', accountKey, 1) + const cipher = await CloudVaultCipher.open({ id: 'vault-enc', encryption }, accountKey, 1) + return { cipher, source: { forVault: async (id: string) => (id === 'vault-enc' ? cipher : null) } } +} + +async function fileMutation(bytes: Buffer, fileName = 'attachements/film.mov'): Promise<{ mutation: CloudSyncUpsertMutation; sourcePath: string }> { + const directory = await mkdtemp(path.join(os.tmpdir(), 'zennotes-e2ee-source-')) + temporaryDirectories.push(directory) + const sourcePath = path.join(directory, path.basename(fileName)) + await writeFile(sourcePath, bytes) + const mutation: CloudSyncUpsertMutation = { + type: 'upsert', + operation_id: 'op-1', + item_id: 'item-1', + base_revision: null, + path: fileName, + kind: 'binary', + content: { + encoding: 'base64', + data: '', + sha256: createHash('sha256').update(bytes).digest('hex'), + byte_length: bytes.byteLength, + media_type: 'video/quicktime' + } + } + rememberCloudSyncUploadSource(mutation.content, sourcePath) + return { mutation, sourcePath } +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }) +} + +async function collect(stream: AsyncIterable): Promise { + const chunks: Uint8Array[] = [] + for await (const chunk of stream) chunks.push(chunk) + return Buffer.concat(chunks) +} + +describe('desktop end-to-end encryption paths', () => { + it('streams a large file to object storage only as ciphertext, under a path id and sealed metadata', async () => { + const { cipher, source } = await encryptedVault() + const bytes = randomBytes(8_100_000) + const { mutation } = await fileMutation(bytes) + const sealedLength = zne1CiphertextLength(bytes.byteLength) + let initiation: Record | undefined + let putBody: Buffer | undefined + let putLength: string | null = null + const fetchImplementation = vi.fn(async (input, options) => { + const url = String(input) + if (url.endsWith('/uploads')) { + initiation = JSON.parse(String(options?.body)) + expect(new Headers(options?.headers).get('X-ZenNotes-Encryption')).toBe('zne1') + return jsonResponse({ + data: { + id: 'upload-1', + operation_id: mutation.operation_id, + status: 'uploading', + expected_bytes: sealedLength, + expires_at: '2099-01-01T00:00:00.000Z', + upload: { method: 'PUT', url: 'https://objects.example.test/upload-1', headers: {} } + } + }, 201) + } + if (url.startsWith('https://objects.example.test/')) { + putLength = new Headers(options?.headers).get('content-length') + putBody = await collect(options?.body as AsyncIterable) + return new Response(null, { status: 200 }) + } + if (url.endsWith('/complete')) { + return jsonResponse({ + data: { + id: 'upload-1', + operation_id: mutation.operation_id, + status: 'completed', + result: { acknowledged: [{ operation_id: 'op-1', item_id: 'item-1', revision: 1, sequence: 1 }], conflicts: [], cursor: 1 } + } + }) + } + throw new Error(`Unexpected request: ${url}`) + }) + const client = createCloudSyncClient('https://zennotes.test', 'token', fetchImplementation, { + endToEndEncryption: true, + vaultCiphers: source + }) + const response = await client.mutate('vault-enc', { mutations: [mutation] }) + expect(response.acknowledged).toHaveLength(1) + + expect(initiation).toMatchObject({ kind: 'binary', content: { encoding: 'aes-gcm', byte_length: sealedLength, media_type: 'application/octet-stream' } }) + expect(String(initiation?.path)).toMatch(/^[0-9a-f]{64}$/) + expect(String(initiation?.path)).toBe(await cipher.pathId('attachements/film.mov')) + expect(typeof initiation?.meta).toBe('string') + expect(JSON.stringify(initiation)).not.toContain('film') + expect(JSON.stringify(initiation)).not.toContain(mutation.content.sha256) + expect(putLength).toBe(String(sealedLength)) + expect(putBody?.byteLength).toBe(sealedLength) + expect((initiation?.content as { sha256: string }).sha256).toBe(createHash('sha256').update(putBody!).digest('hex')) + expect(putBody!.includes(bytes.subarray(0, 64))).toBe(false) + expect((await collect(cipher.decryptStream([putBody!]))).equals(bytes)).toBe(true) + // The sealed temp copy is gone once the upload finished. + expect((await readdir(os.tmpdir())).some((name) => name.startsWith('zennotes-upload-') && existsSync(path.join(os.tmpdir(), name, 'sealed')))).toBe(false) + }, 60_000) + + it('sends a 5 MiB file directly once encryption pushes it past the inline cap', async () => { + const { source } = await encryptedVault() + const bytes = randomBytes(INLINE_LIMIT) + const { mutation } = await fileMutation(bytes) + const requested: string[] = [] + const fetchImplementation = vi.fn(async (input) => { + requested.push(String(input)) + throw new Error('stop here') + }) + const client = createCloudSyncClient('https://zennotes.test', 'token', fetchImplementation, { + endToEndEncryption: true, + vaultCiphers: source + }) + await expect(client.mutate('vault-enc', { mutations: [mutation] })).rejects.toThrow('stop here') + expect(requested).toEqual(['https://zennotes.test/api/v1/vaults/vault-enc/uploads']) + }, 60_000) + + it('refuses a file that changed after sync hashed it, before anything is sent', async () => { + const { source } = await encryptedVault() + const { mutation, sourcePath } = await fileMutation(randomBytes(INLINE_LIMIT + 10)) + await writeFile(sourcePath, randomBytes(INLINE_LIMIT + 10)) + const fetchImplementation = vi.fn() + const client = createCloudSyncClient('https://zennotes.test', 'token', fetchImplementation, { + endToEndEncryption: true, + vaultCiphers: source + }) + await expect(client.mutate('vault-enc', { mutations: [mutation] })).rejects.toMatchObject({ code: 'DIRECT_UPLOAD_SIZE_MISMATCH' }) + expect(fetchImplementation).not.toHaveBeenCalled() + }, 60_000) + + it('stops before any request when the vault is locked on this device', async () => { + const { mutation } = await fileMutation(randomBytes(100)) + const fetchImplementation = vi.fn() + const client = createCloudSyncClient('https://zennotes.test', 'token', fetchImplementation, { + endToEndEncryption: true, + vaultCiphers: { forVault: async () => { throw new CloudE2eeLockedError() } } + }) + await expect(client.mutate('vault-enc', { mutations: [mutation] })).rejects.toBeInstanceOf(CloudE2eeLockedError) + expect(fetchImplementation).not.toHaveBeenCalled() + }) + + it('decrypts a large download into staging and refuses one that was changed', async () => { + const { cipher } = await encryptedVault() + const plaintext = randomBytes(300_000) + const sealed = await collect(cipher.encryptStream([plaintext])) + const reference: CloudSyncContentReference = { + item_id: 'item-1', + revision: 2, + encoding: 'base64', + sha256: createHash('sha256').update(plaintext).digest('hex'), + byte_length: plaintext.byteLength, + media_type: 'image/png', + encrypted: { sha256: createHash('sha256').update(sealed).digest('hex'), byte_length: sealed.byteLength } + } + const stagingDirectory = await mkdtemp(path.join(os.tmpdir(), 'zennotes-e2ee-staging-')) + temporaryDirectories.push(stagingDirectory) + const instruction = { + item_id: 'item-1', + revision: 2, + content: { encoding: 'aes-gcm' as const, sha256: reference.encrypted!.sha256, byte_length: sealed.byteLength, media_type: 'application/octet-stream' }, + download: { method: 'GET' as const, url: 'https://objects.example.test/item-1', headers: {}, expires_at: '2099-01-01T00:00:00.000Z' } + } + const serve = (body: Buffer) => vi.fn(async () => + new Response(body, { status: 200, headers: { 'Content-Length': String(body.byteLength) } }) + ) + const owner = {} + const staged = await stageDesktopCloudContent( + { reference, getInstruction: async () => instruction, previewLimitBytes: 0, decryptor: cipher }, + owner, + { fetchImplementation: serve(sealed), stagingDirectory } + ) + const handle = cloudSyncStagedHandle<{ path: string }>(staged, reference) + expect((await readFile(handle.path)).equals(plaintext)).toBe(true) + await releaseCloudSyncStagedFile(staged) + + const tampered = Buffer.from(sealed) + tampered[tampered.byteLength - 20] ^= 0x01 + await expect(stageDesktopCloudContent( + { reference, getInstruction: async () => instruction, previewLimitBytes: 0, decryptor: cipher }, + owner, + { fetchImplementation: serve(tampered), stagingDirectory } + )).rejects.toThrow() + expect(await readdir(stagingDirectory)).toEqual([]) + + await expect(stageDesktopCloudContent( + { reference, getInstruction: async () => instruction, previewLimitBytes: 0 }, + owner, + { fetchImplementation: serve(sealed), stagingDirectory } + )).rejects.toThrow(/no key/) + }, 60_000) +}) diff --git a/apps/desktop/src/main/cloud-sync-service.test.ts b/apps/desktop/src/main/cloud-sync-service.test.ts index 7fcd5f665..0ad23bbd1 100644 --- a/apps/desktop/src/main/cloud-sync-service.test.ts +++ b/apps/desktop/src/main/cloud-sync-service.test.ts @@ -74,6 +74,19 @@ async function setup( updated_at: '2026-08-10T12:00:00.000Z' } })), + createEncryptedVault: vi.fn(async () => { + throw new Error('Encrypted vaults are not part of these tests.') + }), + supersedeVault: vi.fn(async () => {}), + keysetApi: vi.fn(() => ({ + getKeyset: async () => null, + createKeyset: async () => { + throw new Error('Keysets are not part of these tests.') + }, + updateKeyset: async () => { + throw new Error('Keysets are not part of these tests.') + } + })), deleteVault: vi.fn(async () => {}), manifest: vi.fn( async (): Promise => ({ diff --git a/apps/desktop/src/main/cloud-sync-service.ts b/apps/desktop/src/main/cloud-sync-service.ts index 55b6bc538..b1ccec5bf 100644 --- a/apps/desktop/src/main/cloud-sync-service.ts +++ b/apps/desktop/src/main/cloud-sync-service.ts @@ -45,6 +45,23 @@ import { } from './cloud-publish-assets' import { CLOUD_VAULT_REMOVED_MESSAGE, confirmCloudVaultMissing, isCloudResourceMissing, sameCloudVaultLink } from '@zennotes/shared-domain/cloud-vault-availability' import { createDesktopCloudSyncCoordinator, DesktopCloudSyncStateStore } from './cloud-sync-filesystem' +import { rewriteBackupArchive } from './cloud-backup-archive' +import { + CloudE2eeKeyring, + changeCloudEncryptionPassphrase, + prepareCloudEncryptionSetup, + prepareNewCloudRecoveryKey, + unlockCloudEncryption, + type CloudAccountKeyStore, + type CloudEncryptionSecret, + type PendingCloudEncryptionSetup +} from '@zennotes/shared-domain/cloud-e2ee-account' +import { fromBase64 } from '@zennotes/shared-domain/cloud-e2ee' +import { + CLOUD_E2EE_HEADER, + CLOUD_E2EE_HEADER_VALUE, + ENCRYPTED_VAULT_PLACEHOLDER_NAME +} from '@zennotes/shared-domain/cloud-e2ee-vault' type SyncClient = Pick< CloudSyncApiClient, @@ -57,6 +74,9 @@ type SyncClient = Pick< | 'unpublishNote' | 'listVaults' | 'createVault' + | 'createEncryptedVault' + | 'supersedeVault' + | 'keysetApi' | 'deleteVault' | 'manifest' | 'changes' @@ -82,6 +102,22 @@ export interface DesktopCloudSyncServiceDependencies { fetchImplementation?: typeof fetch now?: () => Date withWindowSync?(root: string, run: () => Promise): Promise + /** + * Where this device keeps an account's unlocked encryption key. Without it + * the service never offers end-to-end encryption and the service refuses + * encrypted vaults to this app. + */ + accountKeyStore?(account: NonNullable): CloudAccountKeyStore +} + +/** What the Settings page shows about end-to-end encryption for the signed-in account. */ +export interface DesktopCloudEncryptionStatus { + /** The service accepts encrypted vaults for this account. */ + available: boolean + /** The account has set up encryption. */ + keyset: boolean + /** This device holds the account key. */ + unlocked: boolean } /** Main-process orchestration for linking one local vault to one cloud vault. */ @@ -89,6 +125,9 @@ export class DesktopCloudSyncService { private readonly runs = new Map>() private readonly operations = new Map>() private readonly linkUpdates = new Map>() + private readonly keyrings = new Map() + /** Setups and recovery-key changes waiting for the person to confirm their words. */ + private readonly pendingRecovery = new Map() private readonly now: () => Date private readonly fetchImplementation: typeof fetch private requestController = new AbortController() @@ -108,8 +147,91 @@ export class DesktopCloudSyncService { } async listVaults(): Promise { - const { client } = await this.connection() - return (await client.listVaults()).data + const { client, keyring } = await this.connection() + const vaults = (await client.listVaults()).data + // An encrypted vault's real name is sealed; show it when this device is + // unlocked, and the placeholder otherwise. + return Promise.all( + vaults.map(async (vault) => { + if (!vault.encryption || !keyring) return vault + try { + return { ...vault, name: await keyring.vaultName(vault) } + } catch { + return { ...vault, name: ENCRYPTED_VAULT_PLACEHOLDER_NAME } + } + }) + ) + } + + async encryptionStatus(): Promise { + const connection = await this.optionalConnection() + if (!connection?.keyStore) return { available: false, keyset: false, unlocked: false } + const account = (await connection.client.account()).data + return { + available: account.capabilities?.end_to_end_encryption === true, + keyset: account.encryption?.keyset === true, + unlocked: (await connection.keyStore.load()) !== null + } + } + + /** + * Derives the keys and returns the recovery words to show. Nothing reaches + * ZenNotes Cloud until finishEncryptionSetup runs with the returned id. + */ + async prepareEncryptionSetup(passphrase: string): Promise<{ setupId: string; recoveryWords: string[] }> { + const { client, keyStore } = await this.encryptionConnection() + const pending = await prepareCloudEncryptionSetup(client.keysetApi(), keyStore, passphrase) + const setupId = randomUUID() + this.pendingRecovery.set(setupId, pending) + return { setupId, recoveryWords: pending.recoveryWords } + } + + async finishEncryptionSetup(setupId: string): Promise { + await this.finishPending(setupId) + } + + async unlockEncryption(secret: CloudEncryptionSecret): Promise { + const { client, keyStore, keyring } = await this.encryptionConnection() + await unlockCloudEncryption(client.keysetApi(), keyStore, secret) + keyring?.forget() + } + + async changeEncryptionPassphrase(current: CloudEncryptionSecret, next: string): Promise { + const { client, keyStore } = await this.encryptionConnection() + await changeCloudEncryptionPassphrase(client.keysetApi(), keyStore, current, next) + } + + async prepareNewRecoveryKey(current: CloudEncryptionSecret): Promise<{ setupId: string; recoveryWords: string[] }> { + const { client, keyStore } = await this.encryptionConnection() + const pending = await prepareNewCloudRecoveryKey(client.keysetApi(), keyStore, current) + const setupId = randomUUID() + this.pendingRecovery.set(setupId, pending) + return { setupId, recoveryWords: pending.recoveryWords } + } + + async finishNewRecoveryKey(setupId: string): Promise { + await this.finishPending(setupId) + } + + /** Forgets this device's account key; encrypted vaults stop syncing until it unlocks again. */ + async lockEncryption(): Promise { + const connection = await this.optionalConnection() + if (!connection?.keyStore) return + await connection.keyStore.clear() + connection.keyring?.forget() + } + + private async finishPending(setupId: string): Promise { + const pending = this.pendingRecovery.get(setupId) + if (!pending) throw new Error('That recovery key is no longer waiting to be confirmed. Start again.') + await pending.finish() + this.pendingRecovery.delete(setupId) + } + + private async encryptionConnection() { + const connection = await this.connection() + if (!connection.keyStore) throw new Error('End-to-end encryption is not available in this build.') + return { ...connection, keyStore: connection.keyStore } } async serviceAccount(): Promise { @@ -156,29 +278,49 @@ export class DesktopCloudSyncService { } async link(localRoot: string, vaultId: string): Promise { - const { account, client } = await this.connection() + const { account, client, keyring } = await this.connection() const vault = (await client.listVaults()).data.find((candidate) => candidate.id === vaultId) if (!vault) throw new Error('That ZenNotes Cloud vault is not available to this account.') + if (vault.encryption && !keyring) { + throw new Error('This vault is end-to-end encrypted, which this version of ZenNotes cannot open.') + } + // Opening an encrypted vault's name needs the account key, so linking a + // locked device stops here instead of at the first sync. + const vaultName = vault.encryption && keyring ? await keyring.vaultName(vault) : vault.name const link: CloudVaultLink = { base_url: account.base_url, vault_id: vault.id, - vault_name: vault.name, + vault_name: vaultName, linked_at: this.now().toISOString() } await this.writeLink(localRoot, link) return link } - async createAndLink(localRoot: string, name: string): Promise { + async createAndLink( + localRoot: string, + name: string, + options: { encrypted?: boolean } = {} + ): Promise { const normalizedName = name.trim() if (!normalizedName) throw new Error('Cloud vault name is required.') - const { account, client } = await this.connection() - const vault = (await client.createVault(normalizedName)).data + const { account, client, keyStore, keyring } = await this.connection() + let vault: CloudSyncVault + if (options.encrypted) { + const stored = await keyStore?.load() + if (!stored) throw new Error('Unlock end-to-end encryption on this device first.') + vault = (await client.createEncryptedVault(normalizedName, fromBase64(stored.key), stored.version)).data + keyring?.remember(vault) + } else { + vault = (await client.createVault(normalizedName)).data + } const link: CloudVaultLink = { base_url: account.base_url, vault_id: vault.id, - vault_name: vault.name, + // An encrypted vault's stored name is a placeholder; the link is local, + // so it keeps the real one. + vault_name: options.encrypted ? normalizedName : vault.name, linked_at: this.now().toISOString() } await this.writeLink(localRoot, link) @@ -237,9 +379,12 @@ export class DesktopCloudSyncService { async createBackup(localRoot: string, label?: string): Promise { const summary = await this.sync(localRoot) assertBackupReady(summary) - const { client, link } = await this.linkedConnection(localRoot) + const { client, link, keyring } = await this.linkedConnection(localRoot) + // A typed label would reach ZenNotes Cloud readable, so encrypted vaults + // keep their backups unlabeled. + const encrypted = keyring ? (await keyring.forVault(link.vault_id)) !== null : false try { - return (await client.createBackup(link.vault_id, label)).data + return (await client.createBackup(link.vault_id, encrypted ? undefined : label)).data } catch (error) { throw cloudBackupCreateError(error) } @@ -255,13 +400,17 @@ export class DesktopCloudSyncService { backupId: string, destinationPath: string ): Promise { - const { account, client, link, token } = await this.linkedConnection(localRoot) + const { account, client, link, token, keyring } = await this.linkedConnection(localRoot) + // An encrypted vault's archive holds ciphertext under path ids; it is + // saved readable, decrypted item by item on this device. + const cipher = keyring ? await keyring.forVault(link.vault_id) : null const response = await this.fetchImplementation( `${account.base_url}${client.backupDownloadPath(link.vault_id, backupId)}`, { headers: { Accept: 'application/gzip', - Authorization: `Bearer ${token}` + Authorization: `Bearer ${token}`, + ...(keyring ? { [CLOUD_E2EE_HEADER]: CLOUD_E2EE_HEADER_VALUE } : {}) }, signal: AbortSignal.timeout(120_000) } @@ -275,10 +424,11 @@ export class DesktopCloudSyncService { const target = path.resolve(destinationPath) const temporary = `${target}.${process.pid}.${randomUUID()}.tmp` + const sealed = cipher ? `${temporary}.sealed` : temporary await fs.mkdir(path.dirname(target), { recursive: true }) try { - const file = await fs.open(temporary, 'wx', 0o600) + const file = await fs.open(sealed, 'wx', 0o600) try { const reader = response.body.getReader() for (;;) { @@ -296,9 +446,16 @@ export class DesktopCloudSyncService { } finally { await file.close() } + if (cipher) { + await rewriteBackupArchive(sealed, temporary, (item) => + cipher.openArchiveItem(item as Parameters[0]) + ) + await fs.rm(sealed, { force: true }) + } await fs.rename(temporary, target) } catch (error) { await fs.rm(temporary, { force: true }).catch(() => {}) + if (sealed !== temporary) await fs.rm(sealed, { force: true }).catch(() => {}) throw error } } @@ -379,12 +536,80 @@ export class DesktopCloudSyncService { }, this.linkUpdates) } + /** + * Moves the linked readable Cloud vault into a new end-to-end encrypted one + * with the same name: this device's copy, fully synced first, uploads into + * the new vault, and only then is the readable vault retired. Until that + * last step the readable vault is untouched, and an interrupted move + * finishes on the next clean sync. + */ + async encryptLinkedVault(localRoot: string): Promise { + const before = await this.sync(localRoot) + if (hasOpenConflicts(before)) { + throw new Error('Resolve sync conflicts before encrypting this vault.') + } + const { client, link, keyStore, keyring } = await this.linkedConnection(localRoot) + if (!keyStore || !keyring) throw new Error('End-to-end encryption is not available in this build.') + if (link.replaces_vault_id) return this.syncAndRetireReplacedVault(localRoot) + if (await keyring.forVault(link.vault_id)) throw new Error('This vault is already end-to-end encrypted.') + const stored = await keyStore.load() + if (!stored) throw new Error('Unlock end-to-end encryption on this device first.') + const vault = (await client.createEncryptedVault(link.vault_name, fromBase64(stored.key), stored.version)).data + keyring.remember(vault) + await this.writeLink(localRoot, { + ...link, + vault_id: vault.id, + linked_at: this.now().toISOString(), + replaces_vault_id: link.vault_id + }) + return this.syncAndRetireReplacedVault(localRoot) + } + + private async syncAndRetireReplacedVault(localRoot: string): Promise { + const summary = await this.sync(localRoot) + if (hasOpenConflicts(summary)) { + throw new Error('Resolve sync conflicts, then sync again to finish encrypting this vault.') + } + const link = await this.readLink(localRoot) + if (!link) throw new Error('Link this local vault to a ZenNotes Cloud vault first.') + return (await this.retireReplacedVault(localRoot, link, summary)) ?? link + } + + /** + * After a clean sync into a replacement vault, retires the readable vault it + * replaces. Returns the updated link, or null when there is nothing to do. + */ + private async retireReplacedVault( + localRoot: string, + link: CloudVaultLink, + summary: CloudSyncRunSummary + ): Promise { + if (!link.replaces_vault_id || hasOpenConflicts(summary)) return null + const { client } = await this.connection() + try { + await client.supersedeVault(link.replaces_vault_id, link.vault_id) + } catch (error) { + // A retry after a lost answer finds the old vault already replaced by + // this one; that is the outcome this step was after. + const successor = (error as { details?: { successor_vault_id?: unknown } | null }).details?.successor_vault_id + if (cloudErrorCode(error) !== 'VAULT_SUPERSEDED' || successor !== link.vault_id) throw error + } + const { replaces_vault_id: _replaced, ...current } = link + await this.writeLink(localRoot, current) + return current + } + sync(localRoot: string): Promise { const runKey = path.resolve(localRoot) const existing = this.runs.get(runKey) if (existing) return existing - const run = () => this.exclusive(runKey, () => this.run(localRoot)) + const run = () => this.exclusive(runKey, async () => { + const summary = await this.run(localRoot) + const link = await this.readLink(localRoot) + if (link?.replaces_vault_id) await this.retireReplacedVault(localRoot, link, summary) + return summary + }) const running = (this.dependencies.withWindowSync?.(runKey, run) ?? run()).finally(() => { this.runs.delete(runKey) }) @@ -424,6 +649,11 @@ export class DesktopCloudSyncService { legacy_conflict_copies: result.legacyConflictCopies } } catch (error) { + if (cloudErrorCode(error) === 'VAULT_SUPERSEDED' && await this.retireMissingLink(localRoot, link)) { + throw new Error( + 'This Cloud vault is now end-to-end encrypted. Unlock encryption in Settings, then open the encrypted vault on this device.' + ) + } if (await confirmCloudVaultMissing(client, link.vault_id, error) && await this.retireMissingLink(localRoot, link)) { throw new Error(CLOUD_VAULT_REMOVED_MESSAGE) @@ -594,6 +824,8 @@ export class DesktopCloudSyncService { account: NonNullable client: SyncClient token: string + keyStore?: CloudAccountKeyStore + keyring?: CloudE2eeKeyring }> { const connection = await this.optionalConnection() if (!connection) throw new Error('Connect ZenNotes Cloud before using sync.') @@ -604,23 +836,59 @@ export class DesktopCloudSyncService { account: NonNullable client: SyncClient token: string + keyStore?: CloudAccountKeyStore + keyring?: CloudE2eeKeyring } | null> { const signal = this.requestController.signal signal.throwIfAborted() const status = await this.dependencies.accountStatus() if (status.state !== 'connected' || !status.account) return null - const token = await this.dependencies.getSecret(status.account.base_url) + const account = status.account + const token = await this.dependencies.getSecret(account.base_url) signal.throwIfAborted() if (!token) throw new Error('The ZenNotes Cloud credential is unavailable. Sign in again.') + const keyStore = this.dependencies.accountKeyStore?.(account) + const keyring = keyStore ? this.keyringFor(account, token, keyStore) : undefined return { - account: status.account, - client: this.dependencies.createClient(status.account.base_url, token, { - accountId: status.account.user.email, + account, + client: this.dependencies.createClient(account.base_url, token, { + accountId: account.user.email, signal, - contentReferences: true + contentReferences: true, + ...(keyring ? { endToEndEncryption: true, vaultCiphers: keyring } : {}) }), - token + token, + ...(keyStore && keyring ? { keyStore, keyring } : {}) + } + } + + /** + * One keyring per signed-in credential, so the "is this vault encrypted" + * answers and opened ciphers last across calls but never across accounts. + */ + private keyringFor( + account: NonNullable, + token: string, + store: CloudAccountKeyStore + ): CloudE2eeKeyring { + const scope = fingerprint(JSON.stringify([account.base_url, account.user.email, token])) + let keyring = this.keyrings.get(scope) + if (!keyring) { + for (const [key, stale] of this.keyrings) { + stale.forget() + this.keyrings.delete(key) + } + keyring = new CloudE2eeKeyring({ + listVaults: async () => + (await this.dependencies.createClient(account.base_url, token, { + accountId: account.user.email, + signal: this.requestController.signal + }).listVaults()).data, + store + }) + this.keyrings.set(scope, keyring) } + return keyring } private async linkedConnection(localRoot: string): Promise<{ @@ -628,6 +896,8 @@ export class DesktopCloudSyncService { client: SyncClient link: CloudVaultLink token: string + keyStore?: CloudAccountKeyStore + keyring?: CloudE2eeKeyring }> { const link = await this.readLink(localRoot) if (!link) throw new Error('Link this local vault to a ZenNotes Cloud vault first.') @@ -708,6 +978,19 @@ function isCloudVaultLink(value: unknown): value is CloudVaultLink { ) } +function hasOpenConflicts(summary: CloudSyncRunSummary): boolean { + return ( + summary.conflicts.length > 0 || + summary.bootstrap_conflicts.length > 0 || + (summary.pending_conflicts?.length ?? 0) > 0 + ) +} + +function cloudErrorCode(error: unknown): string | null { + const code = (error as { code?: unknown } | null)?.code + return typeof code === 'string' ? code : null +} + function assertBackupReady(summary: CloudSyncRunSummary): void { if ( summary.conflicts.length > 0 || diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index f3a4ca711..629df348b 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -186,6 +186,10 @@ import type { } from "@zennotes/bridge-contract/workflows"; import { deleteCloudServiceSecret, + deleteCloudAccountKeySecrets, + getCloudAccountKeySecret, + setCloudAccountKeySecret, + deleteCloudAccountKeySecret, deleteRemoteWorkspaceSecret, getCloudServiceSecret, getRemoteWorkspaceSecret, @@ -198,6 +202,11 @@ import { CloudAuthLoopbackServer } from "./cloud-auth-loopback"; import type { DesktopPublishAssetFiles } from "./cloud-publish-assets"; import { createCloudSyncClient } from "./cloud-sync-client"; import { DesktopCloudSyncService } from "./cloud-sync-service"; +import type { + CloudAccountKeyStore, + CloudEncryptionSecret, + StoredCloudAccountKey, +} from "@zennotes/shared-domain/cloud-e2ee-account"; import { CloudSyncWindowBarrier } from "./cloud-sync-window-barrier"; import { scanAllTasks, scanTasksForPath } from "./tasks"; import { @@ -442,7 +451,12 @@ function getCloudAuthManager(): CloudAuthManager { openExternal: async (url) => await shell.openExternal(url), getSecret: getCloudServiceSecret, setSecret: setCloudServiceSecret, - deleteSecret: deleteCloudServiceSecret, + // Signing out also forgets this device's end-to-end encryption keys for + // that service; the passphrase or recovery key unlocks it again. + deleteSecret: async (baseUrl) => { + await deleteCloudServiceSecret(baseUrl); + await deleteCloudAccountKeySecrets(baseUrl); + }, allowInsecureLocalDevelopment: !app.isPackaged, }); return cloudAuthManager; @@ -461,10 +475,53 @@ function getCloudSyncService(): DesktopCloudSyncService { createClient: (baseUrl, token, options) => createCloudSyncClient(baseUrl, token, fetch, options), withWindowSync: (root, run) => cloudSyncWindowBarrier.run(root, run), + accountKeyStore: (account) => cloudAccountKeyStore(account.base_url, account.user.email), }); return cloudSyncService; } +/** + * This device's unlocked end-to-end encryption key for one account, in the + * OS-protected secret store. It never crosses to the renderer. + */ +function cloudAccountKeyStore(baseUrl: string, email: string): CloudAccountKeyStore { + return { + load: async () => { + const raw = await getCloudAccountKeySecret(baseUrl, email); + if (!raw) return null; + try { + const value = JSON.parse(raw) as StoredCloudAccountKey; + return value?.format === 1 && typeof value.key === "string" && Number.isInteger(value.version) + ? value + : null; + } catch { + return null; + } + }, + save: async (value) => { + if (!(await setCloudAccountKeySecret(baseUrl, email, JSON.stringify(value)))) { + throw new Error( + "ZenNotes could not store the encryption key securely on this device. Check that the system keychain is available.", + ); + } + }, + clear: () => deleteCloudAccountKeySecret(baseUrl, email), + }; +} + +/** IPC arguments are untrusted: exactly one of the two secrets, as text. */ +function encryptionSecret(value: unknown): CloudEncryptionSecret { + const record = + value && typeof value === "object" ? (value as Record) : null; + if (record && typeof record.passphrase === "string" && !("recoveryWords" in record)) { + return { passphrase: record.passphrase }; + } + if (record && typeof record.recoveryWords === "string" && !("passphrase" in record)) { + return { recoveryWords: record.recoveryWords }; + } + throw new Error("Enter your encryption passphrase or recovery key."); +} + /** * A published note's attachments, by the vault-relative paths the renderer * names: resolved through the vault's traversal guard, never trusted as given. @@ -3091,8 +3148,44 @@ function registerIpc(): void { handle(IPC.CLOUD_VAULT_LINK_SET, (_event, vaultId: string) => getCloudSyncService().link(requireLocalCloudVaultRoot(), vaultId), ); - handle(IPC.CLOUD_VAULT_LINK_CREATE, (_event, name: string) => - getCloudSyncService().createAndLink(requireLocalCloudVaultRoot(), name), + handle( + IPC.CLOUD_VAULT_LINK_CREATE, + (_event, name: string, options?: { encrypted?: boolean }) => + getCloudSyncService().createAndLink(requireLocalCloudVaultRoot(), name, { + encrypted: options?.encrypted === true, + }), + ); + handle(IPC.CLOUD_ENCRYPTION_STATUS, () => + getCloudSyncService().encryptionStatus(), + ); + handle(IPC.CLOUD_ENCRYPTION_SETUP_PREPARE, (_event, passphrase: string) => + getCloudSyncService().prepareEncryptionSetup(String(passphrase ?? "")), + ); + handle(IPC.CLOUD_ENCRYPTION_SETUP_FINISH, (_event, setupId: string) => + getCloudSyncService().finishEncryptionSetup(String(setupId ?? "")), + ); + handle(IPC.CLOUD_ENCRYPTION_UNLOCK, (_event, secret: unknown) => + getCloudSyncService().unlockEncryption(encryptionSecret(secret)), + ); + handle( + IPC.CLOUD_ENCRYPTION_PASSPHRASE_CHANGE, + (_event, current: unknown, next: string) => + getCloudSyncService().changeEncryptionPassphrase( + encryptionSecret(current), + String(next ?? ""), + ), + ); + handle(IPC.CLOUD_ENCRYPTION_RECOVERY_PREPARE, (_event, current: unknown) => + getCloudSyncService().prepareNewRecoveryKey(encryptionSecret(current)), + ); + handle(IPC.CLOUD_ENCRYPTION_RECOVERY_FINISH, (_event, setupId: string) => + getCloudSyncService().finishNewRecoveryKey(String(setupId ?? "")), + ); + handle(IPC.CLOUD_ENCRYPTION_LOCK, () => + getCloudSyncService().lockEncryption(), + ); + handle(IPC.CLOUD_VAULT_ENCRYPT, () => + getCloudSyncService().encryptLinkedVault(requireLocalCloudVaultRoot()), ); handle(IPC.CLOUD_VAULT_LINK_DELETE, () => getCloudSyncService().unlink(requireLocalCloudVaultRoot()), diff --git a/apps/desktop/src/main/secret-store.ts b/apps/desktop/src/main/secret-store.ts index 5f66a9f41..6ea8a4cc2 100644 --- a/apps/desktop/src/main/secret-store.ts +++ b/apps/desktop/src/main/secret-store.ts @@ -1,4 +1,5 @@ import { app, safeStorage } from 'electron' +import { randomUUID } from 'node:crypto' import { promises as fs } from 'node:fs' import path from 'node:path' @@ -6,6 +7,7 @@ const KEYTAR_SERVICE = 'ZenNotes Remote Workspace' const KEYTAR_ACCOUNT_PREFIX = 'remote-workspace:' const CLOUD_KEYTAR_SERVICE = 'ZenNotes Cloud' const CLOUD_KEYTAR_ACCOUNT_PREFIX = 'cloud:' +const CLOUD_E2EE_ACCOUNT_PREFIX = 'cloud-e2ee:' const FALLBACK_FILE = 'remote-workspace-secrets.json' let warnedAboutFallback = false @@ -45,23 +47,88 @@ async function loadKeytar(): Promise { return null } -async function loadFallbackSecrets(): Promise { +class UnreadableSecretsFile extends Error {} + +/** A missing file is an empty store; a file that cannot be parsed is not. */ +async function readFallbackSecrets(): Promise { + let raw: string try { - const raw = await fs.readFile(fallbackPath(), 'utf8') - const parsed = JSON.parse(raw) as Record - const out: SecretMap = {} - for (const [key, value] of Object.entries(parsed)) { - if (typeof value === 'string' && value.trim()) out[key] = value - } - return out + raw = await fs.readFile(fallbackPath(), 'utf8') + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {} + throw error + } + let parsed: unknown + try { + parsed = JSON.parse(raw) } catch { + throw new UnreadableSecretsFile('The ZenNotes secrets file could not be read.') + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new UnreadableSecretsFile('The ZenNotes secrets file is not a secrets map.') + } + const out: SecretMap = {} + for (const [key, value] of Object.entries(parsed)) { + if (typeof value === 'string' && value.trim()) out[key] = value + } + return out +} + +/** Reads never fail a caller: an unreadable store simply holds nothing usable. */ +async function loadFallbackSecrets(): Promise { + try { + return await readFallbackSecrets() + } catch (error) { + console.warn('[secrets] could not read the secrets file:', (error as Error).message) return {} } } +let fallbackUpdates: Promise = Promise.resolve() + +/** + * Every change reads, edits and replaces the file inside one queue, so two + * sign-ins at once cannot drop each other's entry. An unreadable file is moved + * aside, never overwritten: its other entries stay recoverable by hand. + */ +function updateFallbackSecrets(edit: (secrets: SecretMap) => void): Promise { + const run = fallbackUpdates.then(async () => { + let secrets: SecretMap + try { + secrets = await readFallbackSecrets() + } catch (error) { + if (!(error instanceof UnreadableSecretsFile)) throw error + const aside = `${fallbackPath()}.unreadable-${Date.now()}` + await fs.rename(fallbackPath(), aside) + console.warn(`[secrets] moved an unreadable secrets file aside to ${aside}`) + secrets = {} + } + edit(secrets) + await saveFallbackSecrets(secrets) + }) + fallbackUpdates = run.catch(() => {}) + return run +} + +/** Written to a private temporary file, synced, then renamed over the old one. */ async function saveFallbackSecrets(next: SecretMap): Promise { - await fs.mkdir(path.dirname(fallbackPath()), { recursive: true }) - await fs.writeFile(fallbackPath(), JSON.stringify(next, null, 2), 'utf8') + const target = fallbackPath() + await fs.mkdir(path.dirname(target), { recursive: true }) + const temporary = `${target}.${process.pid}.${randomUUID()}.tmp` + const handle = await fs.open(temporary, 'wx', 0o600) + try { + await handle.writeFile(JSON.stringify(next, null, 2), 'utf8') + await handle.sync() + } finally { + await handle.close() + } + try { + await fs.rename(temporary, target) + } catch (error) { + await fs.rm(temporary, { force: true }) + throw error + } + await fs.chmod(target, 0o600).catch(() => {}) } function encodeSecret(secret: string): string | null { @@ -121,15 +188,17 @@ export async function setRemoteWorkspaceSecret(id: string, secret: string | null return Boolean(secret && secret.trim()) } - const fallback = await loadFallbackSecrets() if (secret && secret.trim()) { const encoded = encodeSecret(secret.trim()) if (!encoded) return false - fallback[normalizedId] = encoded + await updateFallbackSecrets((fallback) => { + fallback[normalizedId] = encoded + }) } else { - delete fallback[normalizedId] + await updateFallbackSecrets((fallback) => { + delete fallback[normalizedId] + }) } - await saveFallbackSecrets(fallback) return Boolean(secret && secret.trim()) } @@ -145,8 +214,9 @@ export async function deleteRemoteWorkspaceSecret(id: string): Promise { const fallback = await loadFallbackSecrets() if (!(normalizedId in fallback)) return - delete fallback[normalizedId] - await saveFallbackSecrets(fallback) + await updateFallbackSecrets((secrets) => { + delete secrets[normalizedId] + }) } function cloudFallbackKey(baseUrl: string): string { @@ -187,9 +257,9 @@ export async function setCloudServiceSecret(baseUrl: string, secret: string): Pr const encoded = encodeSecret(normalizedSecret) if (!encoded) return false - const fallback = await loadFallbackSecrets() - fallback[cloudFallbackKey(normalizedBaseUrl)] = encoded - await saveFallbackSecrets(fallback) + await updateFallbackSecrets((fallback) => { + fallback[cloudFallbackKey(normalizedBaseUrl)] = encoded + }) return true } @@ -209,6 +279,67 @@ export async function deleteCloudServiceSecret(baseUrl: string): Promise { const fallback = await loadFallbackSecrets() const key = cloudFallbackKey(normalizedBaseUrl) if (!(key in fallback)) return - delete fallback[key] - await saveFallbackSecrets(fallback) + await updateFallbackSecrets((secrets) => { + delete secrets[key] + }) +} + +/** + * The unlocked end-to-end encryption account key for one Cloud account, kept + * only in the OS-protected store. It is a cache: the passphrase or recovery + * key can always unlock the account again, so a lost entry costs a prompt, + * never data. Scoped by account so a second account never reads it. + */ +function cloudAccountKeyName(baseUrl: string, accountEmail: string): string { + return `${CLOUD_E2EE_ACCOUNT_PREFIX}${baseUrl.trim()}#${accountEmail.trim().toLowerCase()}` +} + +export async function getCloudAccountKeySecret(baseUrl: string, accountEmail: string): Promise { + const name = cloudAccountKeyName(baseUrl, accountEmail) + const keytar = await loadKeytar() + if (keytar) return keytar.getPassword(CLOUD_KEYTAR_SERVICE, name) + const encoded = (await loadFallbackSecrets())[name] + return encoded ? decodeSecret(encoded) : null +} + +export async function setCloudAccountKeySecret( + baseUrl: string, + accountEmail: string, + value: string +): Promise { + const name = cloudAccountKeyName(baseUrl, accountEmail) + const keytar = await loadKeytar() + if (keytar) { + await keytar.setPassword(CLOUD_KEYTAR_SERVICE, name, value) + return true + } + const encoded = encodeSecret(value) + if (!encoded) return false + await updateFallbackSecrets((fallback) => { + fallback[name] = encoded + }) + return true +} + +export async function deleteCloudAccountKeySecret(baseUrl: string, accountEmail: string): Promise { + const name = cloudAccountKeyName(baseUrl, accountEmail) + const keytar = await loadKeytar() + if (keytar) { + await keytar.deletePassword(CLOUD_KEYTAR_SERVICE, name) + return + } + if (!(name in (await loadFallbackSecrets()))) return + await updateFallbackSecrets((fallback) => { + delete fallback[name] + }) +} + +/** Deletes every account key stored for one Cloud service, whichever account it belonged to. */ +export async function deleteCloudAccountKeySecrets(baseUrl: string): Promise { + const prefix = `${CLOUD_E2EE_ACCOUNT_PREFIX}${baseUrl.trim()}#` + const stale = Object.keys(await loadFallbackSecrets()).filter((name) => name.startsWith(prefix)) + if (stale.length === 0) return + await updateFallbackSecrets((fallback) => { + for (const name of stale) delete fallback[name] + }) } diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 955fa548f..06db5c49d 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -35,6 +35,9 @@ import type { CloudSyncSettingsChoice, CloudSyncSettingsConflict, CloudSyncVault, + CloudEncryptionRecoveryPrompt, + CloudEncryptionSecretInput, + CloudEncryptionStatus, CloudVaultLink } from '@zennotes/bridge-contract/cloud-sync' import type { @@ -287,8 +290,24 @@ const api: ZenBridge = { ipcRenderer.invoke(IPC.CLOUD_VAULT_LINK_GET), linkCloudVault: (vaultId: string): Promise => ipcRenderer.invoke(IPC.CLOUD_VAULT_LINK_SET, vaultId), - createAndLinkCloudVault: (name: string): Promise => - ipcRenderer.invoke(IPC.CLOUD_VAULT_LINK_CREATE, name), + createAndLinkCloudVault: (name: string, options?: { encrypted?: boolean }): Promise => + ipcRenderer.invoke(IPC.CLOUD_VAULT_LINK_CREATE, name, options), + getCloudEncryptionStatus: (): Promise => + ipcRenderer.invoke(IPC.CLOUD_ENCRYPTION_STATUS), + prepareCloudEncryptionSetup: (passphrase: string): Promise => + ipcRenderer.invoke(IPC.CLOUD_ENCRYPTION_SETUP_PREPARE, passphrase), + finishCloudEncryptionSetup: (setupId: string): Promise => + ipcRenderer.invoke(IPC.CLOUD_ENCRYPTION_SETUP_FINISH, setupId), + unlockCloudEncryption: (secret: CloudEncryptionSecretInput): Promise => + ipcRenderer.invoke(IPC.CLOUD_ENCRYPTION_UNLOCK, secret), + changeCloudEncryptionPassphrase: (current: CloudEncryptionSecretInput, next: string): Promise => + ipcRenderer.invoke(IPC.CLOUD_ENCRYPTION_PASSPHRASE_CHANGE, current, next), + prepareNewCloudRecoveryKey: (current: CloudEncryptionSecretInput): Promise => + ipcRenderer.invoke(IPC.CLOUD_ENCRYPTION_RECOVERY_PREPARE, current), + finishNewCloudRecoveryKey: (setupId: string): Promise => + ipcRenderer.invoke(IPC.CLOUD_ENCRYPTION_RECOVERY_FINISH, setupId), + lockCloudEncryption: (): Promise => ipcRenderer.invoke(IPC.CLOUD_ENCRYPTION_LOCK), + encryptCloudVault: (): Promise => ipcRenderer.invoke(IPC.CLOUD_VAULT_ENCRYPT), unlinkCloudVault: (): Promise => ipcRenderer.invoke(IPC.CLOUD_VAULT_LINK_DELETE), deleteCloudVault: (): Promise => ipcRenderer.invoke(IPC.CLOUD_VAULT_DELETE), syncCloudVault: (): Promise => ipcRenderer.invoke(IPC.CLOUD_VAULT_SYNC), diff --git a/packages/bridge-contract/src/bridge.ts b/packages/bridge-contract/src/bridge.ts index 61435f0ee..ab67435fd 100644 --- a/packages/bridge-contract/src/bridge.ts +++ b/packages/bridge-contract/src/bridge.ts @@ -50,6 +50,9 @@ import type { CloudBackupSchedule, CloudBackupSnapshot, CloudBackupSnapshotItem, + CloudEncryptionRecoveryPrompt, + CloudEncryptionSecretInput, + CloudEncryptionStatus, CloudPublishedNote, CloudPublishedNoteResult, CloudPublishNoteInput, @@ -175,7 +178,23 @@ export interface ZenBridge { listCloudVaults(): Promise getCloudVaultLink(): Promise linkCloudVault(vaultId: string): Promise - createAndLinkCloudVault(name: string): Promise + createAndLinkCloudVault(name: string, options?: { encrypted?: boolean }): Promise + /** End-to-end encryption; hosts that cannot open encrypted vaults leave these out. */ + getCloudEncryptionStatus?(): Promise + prepareCloudEncryptionSetup?(passphrase: string): Promise + finishCloudEncryptionSetup?(setupId: string): Promise + unlockCloudEncryption?(secret: CloudEncryptionSecretInput): Promise + changeCloudEncryptionPassphrase?( + current: CloudEncryptionSecretInput, + next: string + ): Promise + prepareNewCloudRecoveryKey?( + current: CloudEncryptionSecretInput + ): Promise + finishNewCloudRecoveryKey?(setupId: string): Promise + lockCloudEncryption?(): Promise + /** Moves the linked readable Cloud vault into a new end-to-end encrypted one. */ + encryptCloudVault?(): Promise unlinkCloudVault(): Promise deleteCloudVault(): Promise syncCloudVault(): Promise diff --git a/packages/bridge-contract/src/ipc.ts b/packages/bridge-contract/src/ipc.ts index 734589098..e0540fa9a 100644 --- a/packages/bridge-contract/src/ipc.ts +++ b/packages/bridge-contract/src/ipc.ts @@ -117,6 +117,15 @@ export const IPC = { CLOUD_ACCOUNT_LOGOUT: 'cloud-account:logout', CLOUD_ACCOUNT_ON_CHANGE: 'cloud-account:on-change', CLOUD_VAULTS_LIST: 'cloud-vaults:list', + CLOUD_ENCRYPTION_STATUS: 'cloud-encryption:status', + CLOUD_ENCRYPTION_SETUP_PREPARE: 'cloud-encryption:setup-prepare', + CLOUD_ENCRYPTION_SETUP_FINISH: 'cloud-encryption:setup-finish', + CLOUD_ENCRYPTION_UNLOCK: 'cloud-encryption:unlock', + CLOUD_ENCRYPTION_PASSPHRASE_CHANGE: 'cloud-encryption:passphrase-change', + CLOUD_ENCRYPTION_RECOVERY_PREPARE: 'cloud-encryption:recovery-prepare', + CLOUD_ENCRYPTION_RECOVERY_FINISH: 'cloud-encryption:recovery-finish', + CLOUD_ENCRYPTION_LOCK: 'cloud-encryption:lock', + CLOUD_VAULT_ENCRYPT: 'cloud-vault:encrypt', CLOUD_SERVICE_ACCOUNT_GET: 'cloud-service-account:get', CLOUD_PUBLISHED_NOTES_LIST: 'cloud-published-notes:list', CLOUD_PUBLISHED_NOTE_CREATE: 'cloud-published-note:create', From 11923946a4a18dbdbdce818bce7c45ffcc6ac0d7 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 12 Oct 2026 10:10:36 -0500 Subject: [PATCH 6/8] Feat(cloud): set up, unlock and move a vault to end-to-end encryption 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. --- .../src/components/CloudEncryptionPanel.tsx | 738 ++++++++++++++++++ .../app-core/src/components/CloudSettings.tsx | 88 ++- .../app-core/src/components/SettingsModal.tsx | 17 +- packages/app-core/src/lib/help.ts | 5 + packages/app-core/src/lib/ipc-error.test.ts | 3 + packages/app-core/src/lib/ipc-error.ts | 5 +- 6 files changed, 850 insertions(+), 6 deletions(-) create mode 100644 packages/app-core/src/components/CloudEncryptionPanel.tsx diff --git a/packages/app-core/src/components/CloudEncryptionPanel.tsx b/packages/app-core/src/components/CloudEncryptionPanel.tsx new file mode 100644 index 000000000..8a25bdfbc --- /dev/null +++ b/packages/app-core/src/components/CloudEncryptionPanel.tsx @@ -0,0 +1,738 @@ +import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; +import type { + CloudEncryptionRecoveryPrompt, + CloudEncryptionSecretInput, + CloudEncryptionStatus, +} from "@zennotes/bridge-contract/cloud-sync"; +import type { ZenBridge } from "@zennotes/bridge-contract/bridge"; +import { cloudPassphraseProblem } from "@zennotes/shared-domain/cloud-e2ee-passphrase"; +import { confirmApp } from "../lib/confirm-requests"; +import { humanIpcError } from "../lib/ipc-error"; +import { isImeComposing } from "../lib/ime"; +import { Button } from "./ui/Button"; +import { Modal } from "./ui/Modal"; + +const INPUT_CLASS = + "mt-1.5 w-full rounded-lg border border-paper-300 bg-paper-50 px-3 py-2 text-sm text-ink-900 outline-none focus:border-accent"; + +type Dialog = "setup" | "unlock" | "change" | "recovery" | null; + +/** + * End-to-end encryption for the signed-in Cloud account + * (docs/specs/cloud-e2ee.md). The panel never holds a key: setup, unlock and + * every change run in the host, and only the recovery words a person must see + * cross to this screen. + */ +export function CloudEncryptionPanel({ + bridge, + onStatusChange, +}: { + bridge: ZenBridge; + onStatusChange?: (status: CloudEncryptionStatus) => void; +}): JSX.Element | null { + const [status, setStatus] = useState(null); + const [dialog, setDialog] = useState(null); + const [error, setError] = useState(null); + const supported = typeof bridge.getCloudEncryptionStatus === "function"; + + const refresh = useCallback(async () => { + if (!bridge.getCloudEncryptionStatus) return; + try { + const next = await bridge.getCloudEncryptionStatus(); + setStatus(next); + onStatusChange?.(next); + setError(null); + } catch (cause) { + setError(humanIpcError(cause, "ZenNotes could not check end-to-end encryption.")); + } + }, [bridge, onStatusChange]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + if (!supported || !status?.available) return null; + + const lock = async (): Promise => { + const confirmed = await confirmApp({ + title: "Lock encryption on this device?", + description: + "Encrypted vaults stop syncing on this device until you unlock it again with your passphrase or recovery key.", + confirmLabel: "Lock this device", + danger: false, + }); + if (!confirmed) return; + try { + await bridge.lockCloudEncryption?.(); + await refresh(); + } catch (cause) { + setError(humanIpcError(cause, "ZenNotes could not lock encryption on this device.")); + } + }; + + const finished = (): void => { + setDialog(null); + void refresh(); + }; + + return ( +
+
+

+ End-to-end encryption + + Beta + +

+

+ {!status.keyset + ? "Encrypt Cloud vaults on your devices with a passphrase only you know. ZenNotes Cloud stores their notes, attachments, and names in a form it cannot read." + : status.unlocked + ? "Encrypted vaults sync on this device. Your passphrase unlocks your other devices, and your recovery key works if you forget it." + : "Encryption is set up for your account. Unlock it on this device to sync encrypted vaults."} +

+
+ {error && ( +

+ {error} +

+ )} +
+ {!status.keyset && ( + + )} + {status.keyset && !status.unlocked && ( + + )} + {status.keyset && status.unlocked && ( + <> + + + + + )} +
+ + {dialog === "setup" && ( + setDialog(null)} onDone={finished} /> + )} + {dialog === "unlock" && ( + setDialog(null)} onDone={finished} /> + )} + {dialog === "change" && ( + setDialog(null)} onDone={finished} /> + )} + {dialog === "recovery" && ( + setDialog(null)} onDone={finished} /> + )} +
+ ); +} + +function EncryptionSetupDialog({ + bridge, + onCancel, + onDone, +}: { + bridge: ZenBridge; + onCancel: () => void; + onDone: () => void; +}): JSX.Element { + const titleId = useId(); + const [prompt, setPrompt] = useState(null); + const [step, setStep] = useState<"passphrase" | "words" | "confirm">("passphrase"); + + return ( + + {step === "passphrase" && ( + { + if (!bridge.prepareCloudEncryptionSetup) throw new Error("This app cannot set up encryption."); + setPrompt(await bridge.prepareCloudEncryptionSetup(passphrase)); + setStep("words"); + }} + /> + )} + {step === "words" && prompt && ( + setStep("confirm")} + /> + )} + {step === "confirm" && prompt && ( + setStep("words")} + onSubmit={async () => { + await bridge.finishCloudEncryptionSetup?.(prompt.setupId); + onDone(); + }} + /> + )} + + ); +} + +function EncryptionUnlockDialog({ + bridge, + onCancel, + onDone, +}: { + bridge: ZenBridge; + onCancel: () => void; + onDone: () => void; +}): JSX.Element { + const titleId = useId(); + return ( + + { + await bridge.unlockCloudEncryption?.(secret); + onDone(); + }} + /> + + ); +} + +function EncryptionChangePassphraseDialog({ + bridge, + onCancel, + onDone, +}: { + bridge: ZenBridge; + onCancel: () => void; + onDone: () => void; +}): JSX.Element { + const titleId = useId(); + const [current, setCurrent] = useState(null); + return ( + + {!current ? ( + setCurrent(secret)} + /> + ) : ( + { + await bridge.changeCloudEncryptionPassphrase?.(current, next); + onDone(); + }} + /> + )} + + ); +} + +function EncryptionNewRecoveryKeyDialog({ + bridge, + onCancel, + onDone, +}: { + bridge: ZenBridge; + onCancel: () => void; + onDone: () => void; +}): JSX.Element { + const titleId = useId(); + const [prompt, setPrompt] = useState(null); + const [step, setStep] = useState<"secret" | "words" | "confirm">("secret"); + return ( + + {step === "secret" && ( + { + if (!bridge.prepareNewCloudRecoveryKey) throw new Error("This app cannot replace the recovery key."); + setPrompt(await bridge.prepareNewCloudRecoveryKey(secret)); + setStep("words"); + }} + /> + )} + {step === "words" && prompt && ( + setStep("confirm")} + /> + )} + {step === "confirm" && prompt && ( + setStep("words")} + onSubmit={async () => { + await bridge.finishNewCloudRecoveryKey?.(prompt.setupId); + onDone(); + }} + /> + )} + + ); +} + +function EncryptionDialog({ + titleId, + onClose, + children, +}: { + titleId: string; + onClose: () => void; + children: React.ReactNode; +}): JSX.Element { + return ( + + {/* Settings renders this dialog; keep its keys from reaching Settings' + own shortcuts behind it, apart from Tab for the focus trap. */} +
{ + if (event.key !== "Tab") event.stopPropagation(); + }} + > + {children} +
+
+ ); +} + +/** Runs one async step, showing its error in place and keeping the dialog open. */ +function useStep(): { + busy: boolean; + error: string | null; + run: (task: () => Promise) => Promise; + setError: (message: string | null) => void; +} { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const run = useCallback(async (task: () => Promise) => { + setBusy(true); + setError(null); + try { + await task(); + } catch (cause) { + setError(humanIpcError(cause, "That did not work. Try again.")); + } finally { + setBusy(false); + } + }, []); + return { busy, error, run, setError }; +} + +function NewPassphraseStep({ + titleId, + title, + intro, + submitLabel, + busyLabel, + onCancel, + onSubmit, +}: { + titleId: string; + title: string; + intro: string; + submitLabel: string; + busyLabel: string; + onCancel: () => void; + onSubmit: (passphrase: string) => Promise; +}): JSX.Element { + const [passphrase, setPassphrase] = useState(""); + const [repeat, setRepeat] = useState(""); + const [touched, setTouched] = useState(false); + const firstId = useId(); + const repeatId = useId(); + const first = useRef(null); + const { busy, error, run } = useStep(); + const problem = passphrase ? cloudPassphraseProblem(passphrase) : null; + const mismatch = touched && repeat !== "" && repeat !== passphrase; + const ready = passphrase !== "" && problem === null && repeat === passphrase; + + useEffect(() => first.current?.focus(), []); + + const submit = (): void => { + setTouched(true); + if (ready && !busy) void run(() => onSubmit(passphrase)); + }; + + return ( + <> + +
+

{intro}

+
+ + setPassphrase(event.target.value)} + onKeyDown={(event) => submitOnEnter(event, submit)} + className={INPUT_CLASS} + /> + {problem &&

{problem}

} +
+
+ + setTouched(true)} + onChange={(event) => setRepeat(event.target.value)} + onKeyDown={(event) => submitOnEnter(event, submit)} + className={INPUT_CLASS} + /> + {mismatch &&

The two passphrases do not match.

} +
+

+ If you lose both this passphrase and your recovery key, your encrypted Cloud copy cannot be + recovered by anyone, including ZenNotes. The notes on your devices are not affected. +

+ {error && ( +

+ {error} +

+ )} +
+ + + + + + ); +} + +function SecretStep({ + titleId, + title, + intro, + submitLabel, + busyLabel, + onCancel, + onSubmit, +}: { + titleId: string; + title: string; + intro: string; + submitLabel: string; + busyLabel: string; + onCancel: () => void; + onSubmit: (secret: CloudEncryptionSecretInput) => Promise; +}): JSX.Element { + const [mode, setMode] = useState<"passphrase" | "recovery">("passphrase"); + const [passphrase, setPassphrase] = useState(""); + const [words, setWords] = useState(""); + const fieldId = useId(); + const passphraseField = useRef(null); + const wordsField = useRef(null); + const { busy, error, run } = useStep(); + const value = mode === "passphrase" ? passphrase : words.trim(); + + useEffect(() => { + if (mode === "passphrase") passphraseField.current?.focus(); + else wordsField.current?.focus(); + }, [mode]); + + const submit = (): void => { + if (!value || busy) return; + void run(() => onSubmit(mode === "passphrase" ? { passphrase } : { recoveryWords: words })); + }; + + return ( + <> + +
+

{intro}

+ {mode === "passphrase" ? ( +
+ + setPassphrase(event.target.value)} + onKeyDown={(event) => submitOnEnter(event, submit)} + className={INPUT_CLASS} + /> +
+ ) : ( +
+ +