diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 43fab915..79ca7374 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/desktop", "productName": "ZenNotes", - "version": "2.66.0", + "version": "2.67.0", "description": "ZenNotes desktop shell", "private": true, "main": "./out/main/index.js", diff --git a/apps/desktop/src/main/cloud-auth.test.ts b/apps/desktop/src/main/cloud-auth.test.ts index a61c857d..0aef4b49 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 c9876bcf..39990e2d 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/apps/desktop/src/main/cloud-backup-archive.test.ts b/apps/desktop/src/main/cloud-backup-archive.test.ts new file mode 100644 index 00000000..1f011e92 --- /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 00000000..3ad0b70a --- /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 00000000..0dda3263 --- /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 6149276c..69bb23ba 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 70c282a3..729a2b68 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 00000000..cc23baa9 --- /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 7fcd5f66..0ad23bbd 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 55b6bc53..b1ccec5b 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 f3a4ca71..629df348 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 5f66a9f4..6ea8a4cc 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 955fa548..06db5c49 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/apps/quicklook/package.json b/apps/quicklook/package.json index 194723bc..d4fe29ed 100644 --- a/apps/quicklook/package.json +++ b/apps/quicklook/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/quicklook", "private": true, - "version": "2.66.0", + "version": "2.67.0", "type": "module", "description": "The macOS Quick Look preview for Markdown files: the page it renders and the Swift extension that serves it", "homepage": "https://zennotes.org", diff --git a/apps/share-viewer/package.json b/apps/share-viewer/package.json index 6713f540..4fde5ef6 100644 --- a/apps/share-viewer/package.json +++ b/apps/share-viewer/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/share-viewer", "private": true, - "version": "2.66.0", + "version": "2.67.0", "type": "module", "description": "Read-only renderer for publicly shared ZenNotes, embedded by the zennotes.org website", "homepage": "https://zennotes.org", diff --git a/apps/web/package.json b/apps/web/package.json index f10e9503..a3df1170 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/web", "private": true, - "version": "2.66.0", + "version": "2.67.0", "type": "module", "description": "ZenNotes web client for self-hosted and hosted deployments", "homepage": "https://zennotes.org", diff --git a/docs/specs/cloud-e2ee-vectors.json b/docs/specs/cloud-e2ee-vectors.json new file mode 100644 index 00000000..80dc9211 --- /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 00000000..eed77ca0 --- /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. diff --git a/package-lock.json b/package-lock.json index 0099081c..1ee25d8f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-monorepo", - "version": "2.66.0", + "version": "2.67.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-monorepo", - "version": "2.66.0", + "version": "2.67.0", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -23,7 +23,7 @@ }, "apps/desktop": { "name": "@zennotes/desktop", - "version": "2.66.0", + "version": "2.67.0", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -914,7 +914,7 @@ }, "apps/quicklook": { "name": "@zennotes/quicklook", - "version": "2.66.0", + "version": "2.67.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -987,7 +987,7 @@ }, "apps/share-viewer": { "name": "@zennotes/share-viewer", - "version": "2.66.0", + "version": "2.67.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -1058,7 +1058,7 @@ }, "apps/web": { "name": "@zennotes/web", - "version": "2.66.0", + "version": "2.67.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -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", @@ -16360,7 +16377,7 @@ }, "packages/app-core": { "name": "@zennotes/app-core", - "version": "2.66.0", + "version": "2.67.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16447,16 +16464,18 @@ }, "packages/bridge-contract": { "name": "@zennotes/bridge-contract", - "version": "2.66.0", + "version": "2.67.0", "devDependencies": { "typescript": "^5.7.2" } }, "packages/shared-domain": { "name": "@zennotes/shared-domain", - "version": "2.66.0", + "version": "2.67.0", "dependencies": { + "@scure/bip39": "2.4.0", "@zennotes/bridge-contract": "*", + "hash-wasm": "4.12.0", "lz-string": "^1.5.0" }, "devDependencies": { @@ -16466,7 +16485,7 @@ }, "packages/shared-ui": { "name": "@zennotes/shared-ui", - "version": "2.66.0" + "version": "2.67.0" } } } diff --git a/package.json b/package.json index cbf05b14..7e697f7d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-monorepo", "private": true, - "version": "2.66.0", + "version": "2.67.0", "description": "ZenNotes monorepo for desktop, web, and self-hosted server builds", "packageManager": "npm@10.9.2", "engines": { diff --git a/packages/app-core/package.json b/packages/app-core/package.json index 8023576f..70fab4d9 100644 --- a/packages/app-core/package.json +++ b/packages/app-core/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/app-core", "private": true, - "version": "2.66.0", + "version": "2.67.0", "type": "module", "exports": { "./main": "./src/main.tsx", diff --git a/packages/app-core/src/components/CloudEncryptionPanel.tsx b/packages/app-core/src/components/CloudEncryptionPanel.tsx new file mode 100644 index 00000000..8a25bdfb --- /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} + /> +
+ ) : ( +
+ +