From 10a47888a651516bb75332b1e1628026dcd57ca1 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Thu, 2 Jul 2026 17:17:01 +0200 Subject: [PATCH] feat(sync): offer E2EE before first upload for file-based providers (#8709) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sync): offer E2EE at setup for file-based providers File-based providers (WebDAV, Nextcloud, Dropbox, OneDrive, local file) support optional client-side encryption but, unlike SuperSync, have no mandatory-encryption upload guard — so their first setup sync would ship plaintext before a user could enable E2EE. Offer to set an encryption password during first-time setup and persist it as part of the SAME config save: the key goes to the provider's privateCfg and isEncryptionEnabled to the global config, atomically with isEnabled. The normal first sync then encrypts from the first op via the standard download-first flow — no separate snapshot-overwrite and no plaintext-upload race. Skipping keeps the existing unencrypted behavior; works offline too. Reuses DialogEnableEncryptionComponent in a new side-effect-free collectPasswordOnly mode (returns the password, writes nothing), with a provider-aware Skip action for the disableClose setup modal. * test(sync): skip file-based setup encryption prompt in webdav e2e helper Fresh file-based setup now opens the optional "Encrypt before first upload?" dialog before the config is persisted, so setupWebdavSync would stall on the disableClose modal. Dismiss it via a new Skip selector so every WebDAV spec proceeds unencrypted, exactly as before; encryption specs still enable it afterwards via enableEncryption(). * test(sync): add e2e for file-based setup-time encryption Adds a @webdav @encryption spec that configures WebDAV with the encryption password set in the setup dialog, then asserts the first upload's remote sync-data.json does NOT contain the task title in plaintext (compression is off by default, so a plaintext upload would) — proving the first sync is encrypted — and that a second client with the same password decrypts and receives the task without overwriting the remote. setupWebdavSync gains an encryptAtSetup option (fills the new dialog instead of skipping it); adds e2e selectors on the setup dialog password/confirm/ submit controls. * ci(e2e): allow targeting the webdav job via workflow_dispatch grep The webdav e2e job hardcoded --grep "@webdav"; add a webdav_grep dispatch input (default @webdav, mirroring the supersync job) so a specific WebDAV test can be run on demand. * test(sync): fix remote sync-file path in setup-encryption e2e Non-production builds nest the file under a /DEV segment (sync-providers.factory.ts). The raw-fetch assertion omitted it and 404'd; the CI trace confirmed the app uploaded to /DEV/sync-data.json and that the first upload was encrypted. * test(sync): cover joining an unencrypted remote with setup-time encryption Adds the data-safety edge case for file-based setup-time E2EE: a client that sets a password at setup while joining a remote that already holds UNENCRYPTED data. Asserts the join reads and preserves the existing data (does not overwrite the remote with the joining client's empty state) and that a subsequent write upgrades the remote to encrypted (no plaintext titles remain). --- .github/workflows/e2e-scheduled.yml | 7 +- docs/wiki/3.08-Sync-Integration-Comparison.md | 2 + e2e/pages/sync.page.ts | 65 ++++- .../sync/webdav-setup-encryption.spec.ts | 248 ++++++++++++++++++ .../dialog-enable-encryption.component.html | 33 ++- ...dialog-enable-encryption.component.spec.ts | 77 ++++++ .../dialog-enable-encryption.component.ts | 24 ++ .../dialog-sync-cfg.component.spec.ts | 122 +++++++++ .../dialog-sync-cfg.component.ts | 46 ++++ .../op-log/sync/operation-sync.util.spec.ts | 18 ++ src/app/op-log/sync/operation-sync.util.ts | 11 +- src/app/t.const.ts | 6 + src/assets/i18n/en.json | 7 +- 13 files changed, 654 insertions(+), 12 deletions(-) create mode 100644 e2e/tests/sync/webdav-setup-encryption.spec.ts diff --git a/.github/workflows/e2e-scheduled.yml b/.github/workflows/e2e-scheduled.yml index 86f72f2891..7f5e3c2e2d 100644 --- a/.github/workflows/e2e-scheduled.yml +++ b/.github/workflows/e2e-scheduled.yml @@ -11,6 +11,11 @@ on: required: false type: string default: '@supersync' + webdav_grep: + description: 'Playwright --grep filter for the WebDAV job (default: @webdav)' + required: false + type: string + default: '@webdav' push: branches: [master, release/*] @@ -143,7 +148,7 @@ jobs: - name: Run WebDAV E2E Tests env: E2E_REQUIRE_WEBDAV: 'true' - run: npm run e2e:all -- --grep "@webdav" + run: npm run e2e:all -- --grep "${{ inputs.webdav_grep || '@webdav' }}" - name: Stop Docker Services if: always() diff --git a/docs/wiki/3.08-Sync-Integration-Comparison.md b/docs/wiki/3.08-Sync-Integration-Comparison.md index 7bb83266ed..03f22eec29 100644 --- a/docs/wiki/3.08-Sync-Integration-Comparison.md +++ b/docs/wiki/3.08-Sync-Integration-Comparison.md @@ -20,6 +20,8 @@ Sync providers **synchronize your full Super Productivity data** (tasks, project **Note:** SuperSync is very new and is still in beta. Prefer Nextcloud, WebDAV, Dropbox, or local file for production use until SuperSync is stable. +**Encryption timing:** When you first enable a file-based provider (Nextcloud, WebDAV, Dropbox, OneDrive, or local file), you are offered the option to set an encryption password as part of the setup. If you do, the password is saved with the sync config, so the very first upload — and every upload after — is encrypted; nothing this device uploads leaves it unencrypted. Encryption stays optional: you can skip the prompt and enable, change, or disable it later from the sync settings. + ## Per-Provider Details ### Nextcloud diff --git a/e2e/pages/sync.page.ts b/e2e/pages/sync.page.ts index 0a5860900d..bc6dea04cd 100644 --- a/e2e/pages/sync.page.ts +++ b/e2e/pages/sync.page.ts @@ -46,6 +46,12 @@ export class SyncPage extends BasePage { syncFolderPath: string; isEncryptionEnabled?: boolean; encryptionPassword?: string; + /** + * Set the encryption password in the setup-time "Encrypt before first + * upload?" dialog (instead of the post-setup Enable Encryption button), so + * the very first sync is encrypted. Requires `encryptionPassword`. + */ + encryptAtSetup?: boolean; }): Promise { // Try entire setup flow up to 2 times (dialog-level retry) for (let dialogAttempt = 0; dialogAttempt < 2; dialogAttempt++) { @@ -197,11 +203,27 @@ export class SyncPage extends BasePage { // Save the configuration await this.saveBtn.click(); + // A fresh file-based setup now opens the optional "Encrypt before first + // upload?" dialog (disableClose) before the config is persisted. Either + // set the password here (setup-time E2EE) or skip it so setup proceeds + // unencrypted. + if (config.encryptAtSetup && config.encryptionPassword) { + await this._fillSetupEncryptionDialog(config.encryptionPassword); + } else { + await this._skipSetupEncryptionDialogIfPresent(); + } + // Wait for dialog to close await dialog.waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {}); await this.page.waitForTimeout(500); - if (config.isEncryptionEnabled && config.encryptionPassword) { + // Legacy path: enable encryption via the post-setup Enable Encryption + // button (skipped when it was already set at setup above). + if ( + config.isEncryptionEnabled && + config.encryptionPassword && + !config.encryptAtSetup + ) { await this.waitForSyncReady(); await this.enableEncryption(config.encryptionPassword); } @@ -225,6 +247,47 @@ export class SyncPage extends BasePage { ); } + /** + * Dismisses the optional "Encrypt before first upload?" setup dialog that + * opens on a fresh file-based setup (see DialogSyncCfgComponent.save()). Clicks + * Skip so setup stays unencrypted. No-op if the dialog did not appear. + */ + private async _skipSetupEncryptionDialogIfPresent(): Promise { + const skipBtn = this.page.locator('.e2e-setup-encrypt-skip'); + if (await skipBtn.isVisible({ timeout: 5000 }).catch(() => false)) { + await skipBtn.click(); + } + } + + /** + * Fills the setup-time "Encrypt before first upload?" dialog with the given + * password and submits, so the config save persists the key and the first + * sync is encrypted. Mirrors the robust fill used by enableEncryption(). + */ + private async _fillSetupEncryptionDialog(password: string): Promise { + const passwordInput = this.page.locator('.e2e-setup-encrypt-password'); + const confirmInput = this.page.locator('.e2e-setup-encrypt-confirm-password'); + const submitBtn = this.page.locator('.e2e-setup-encrypt-submit'); + + await passwordInput.waitFor({ state: 'visible', timeout: 5000 }); + await passwordInput.click(); + await passwordInput.fill(password); + await expect(passwordInput).toHaveValue(password); + + // Insert into the focused confirm field — fill() has occasionally left the + // value on the first field in CI (see enableEncryption()). + await confirmInput.click(); + await expect(confirmInput).toBeFocused(); + await this.page.keyboard.press('ControlOrMeta+A'); + await this.page.keyboard.insertText(password); + await expect(confirmInput).toHaveValue(password); + await expect(passwordInput).toHaveValue(password); + + await expect(submitBtn).toBeEnabled({ timeout: 5000 }); + await submitBtn.click(); + await submitBtn.waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {}); + } + async triggerSync(): Promise { // Dismiss any open dialogs/overlays that might block the sync button const overlay = this.page.locator('.cdk-overlay-backdrop'); diff --git a/e2e/tests/sync/webdav-setup-encryption.spec.ts b/e2e/tests/sync/webdav-setup-encryption.spec.ts new file mode 100644 index 0000000000..7b8238a87f --- /dev/null +++ b/e2e/tests/sync/webdav-setup-encryption.spec.ts @@ -0,0 +1,248 @@ +import { test, expect } from '../../fixtures/webdav.fixture'; +import { SyncPage } from '../../pages/sync.page'; +import { WorkViewPage } from '../../pages/work-view.page'; +import { waitForStatePersistence } from '../../utils/waits'; +import { + WEBDAV_CONFIG_TEMPLATE, + setupSyncClient, + createSyncFolder, + waitForSyncComplete, + generateSyncFolderName, + closeContextsSafely, +} from '../../utils/sync-helpers'; + +/** + * WebDAV (File-Based) Setup-Time Encryption E2E Test + * + * Verifies the file-based "E2EE before first upload" feature: when the user sets + * an encryption password DURING first-time setup, the key is persisted with the + * config so the very FIRST sync is already encrypted (no plaintext window), via + * the normal download-first sync flow (no snapshot-overwrite). + * + * Flow: + * 1. Client A configures WebDAV and sets an encryption password at setup. + * 2. Client A adds a task and performs its first sync. + * 3. The raw remote sync file is fetched and asserted to NOT contain the task + * title in plaintext (compression is off by default, so an unencrypted upload + * would contain it verbatim) — proving the first upload was encrypted. + * 4. Client B configures WebDAV with the SAME password at setup and receives + * A's task (round-trips with the key; no overwrite of the remote). + * + * Run with: npm run e2e:file e2e/tests/sync/webdav-setup-encryption.spec.ts + */ + +test.describe('@webdav @encryption WebDAV Setup-Time Encryption', () => { + test.describe.configure({ mode: 'serial' }); + + const SYNC_FOLDER_NAME = generateSyncFolderName('e2e-setup-encrypt'); + const ENCRYPTION_PASSWORD = 'setup-password-123'; + + const WEBDAV_CONFIG = { + ...WEBDAV_CONFIG_TEMPLATE, + syncFolderPath: `/${SYNC_FOLDER_NAME}`, + }; + + // Non-production builds nest the sync file under a `/DEV` segment + // (`environment.production ? undefined : '/DEV'` in sync-providers.factory.ts). + // E2E always runs a non-production build, so include it here. + const SYNC_FILE_URL = `${WEBDAV_CONFIG_TEMPLATE.baseUrl}${SYNC_FOLDER_NAME}/DEV/sync-data.json`; + const AUTH_HEADER = + 'Basic ' + + Buffer.from( + `${WEBDAV_CONFIG_TEMPLATE.username}:${WEBDAV_CONFIG_TEMPLATE.password}`, + ).toString('base64'); + + test('encrypts the first upload when the password is set at setup, and a same-password client can read it', async ({ + browser, + baseURL, + request, + }) => { + test.slow(); // Sync tests take longer + const url = baseURL || 'http://localhost:4242'; + const uniqueId = Date.now(); + const taskTitle = `SetupEncryptedTask-${uniqueId}`; + + await createSyncFolder(request, SYNC_FOLDER_NAME); + + // ============ PHASE 1: Client A sets the password AT SETUP ============ + console.log('[SetupEncrypt] Phase 1: Client A setup with setup-time encryption'); + + const { context: contextA, page: pageA } = await setupSyncClient(browser, url); + const syncPageA = new SyncPage(pageA); + const workViewPageA = new WorkViewPage(pageA); + + await workViewPageA.waitForTaskList(); + + await syncPageA.setupWebdavSync({ + ...WEBDAV_CONFIG, + encryptAtSetup: true, + encryptionPassword: ENCRYPTION_PASSWORD, + }); + await expect(syncPageA.syncBtn).toBeVisible(); + + // Add a task and perform the FIRST sync — this upload must already be encrypted. + await workViewPageA.addTask(taskTitle); + await expect(pageA.locator('task')).toHaveCount(1); + await waitForStatePersistence(pageA); + + await syncPageA.triggerSync(); + await waitForSyncComplete(pageA, syncPageA); + console.log(`[SetupEncrypt] Client A synced first task: ${taskTitle}`); + + // ============ PHASE 2: The remote blob must NOT be plaintext ============ + console.log('[SetupEncrypt] Phase 2: Verifying the remote file is encrypted'); + + const remote = await request.fetch(SYNC_FILE_URL, { + headers: { Authorization: AUTH_HEADER }, + }); + expect(remote.ok()).toBeTruthy(); + const remoteBody = await remote.text(); + expect(remoteBody.length).toBeGreaterThan(0); + // Compression is off by default, so a plaintext upload would contain the + // task title verbatim. Its absence proves the first upload was encrypted. + expect(remoteBody).not.toContain(taskTitle); + console.log('[SetupEncrypt] Remote file does not contain the plaintext task'); + + // ============ PHASE 3: Client B joins with the SAME password ============ + console.log('[SetupEncrypt] Phase 3: Client B joins with the same password'); + + const { context: contextB, page: pageB } = await setupSyncClient(browser, url); + const syncPageB = new SyncPage(pageB); + const workViewPageB = new WorkViewPage(pageB); + + await workViewPageB.waitForTaskList(); + + await syncPageB.setupWebdavSync({ + ...WEBDAV_CONFIG, + encryptAtSetup: true, + encryptionPassword: ENCRYPTION_PASSWORD, + }); + await expect(syncPageB.syncBtn).toBeVisible(); + + await syncPageB.triggerSync(); + await waitForSyncComplete(pageB, syncPageB); + + // B decrypts with the key and receives A's task (and did not overwrite it). + await expect(pageB.locator(`task:has-text("${taskTitle}")`).first()).toBeVisible(); + console.log('[SetupEncrypt] ✓ Client B decrypted and received the task'); + + await closeContextsSafely(contextA, contextB); + }); +}); + +/** + * Guards the data-safety edge case: a client that sets an encryption password at + * setup while joining a remote that ALREADY holds UNENCRYPTED data (returning + * user / new device on an existing plaintext remote). The normal download-first + * sync must read the plaintext remote, keep the data, and re-upload it encrypted + * — never overwrite the remote with the joining client's (empty) state. + */ +test.describe('@webdav @encryption WebDAV Setup-Time Encryption — Unencrypted Remote', () => { + test.describe.configure({ mode: 'serial' }); + + const SYNC_FOLDER_NAME = generateSyncFolderName('e2e-setup-encrypt-migrate'); + const ENCRYPTION_PASSWORD = 'migrate-password-123'; + + const WEBDAV_CONFIG = { + ...WEBDAV_CONFIG_TEMPLATE, + syncFolderPath: `/${SYNC_FOLDER_NAME}`, + }; + + // Non-production builds nest the sync file under a `/DEV` segment (see above). + const SYNC_FILE_URL = `${WEBDAV_CONFIG_TEMPLATE.baseUrl}${SYNC_FOLDER_NAME}/DEV/sync-data.json`; + const AUTH_HEADER = + 'Basic ' + + Buffer.from( + `${WEBDAV_CONFIG_TEMPLATE.username}:${WEBDAV_CONFIG_TEMPLATE.password}`, + ).toString('base64'); + + test('preserves data and upgrades the remote to encrypted when a setup-time-encryption client joins an unencrypted remote', async ({ + browser, + baseURL, + request, + }) => { + test.slow(); // Sync tests take longer + const url = baseURL || 'http://localhost:4242'; + const uniqueId = Date.now(); + const taskFromA = `MigrateTaskA-${uniqueId}`; + const taskFromB = `MigrateTaskB-${uniqueId}`; + + await createSyncFolder(request, SYNC_FOLDER_NAME); + + // ============ PHASE 1: Client A seeds the remote UNENCRYPTED ============ + console.log('[SetupMigrate] Phase 1: Client A (no encryption) seeds the remote'); + + const { context: contextA, page: pageA } = await setupSyncClient(browser, url); + const syncPageA = new SyncPage(pageA); + const workViewPageA = new WorkViewPage(pageA); + + await workViewPageA.waitForTaskList(); + + await syncPageA.setupWebdavSync({ ...WEBDAV_CONFIG }); // skips the setup dialog + await expect(syncPageA.syncBtn).toBeVisible(); + + await workViewPageA.addTask(taskFromA); + await expect(pageA.locator('task')).toHaveCount(1); + await waitForStatePersistence(pageA); + + await syncPageA.triggerSync(); + await waitForSyncComplete(pageA, syncPageA); + + // Baseline: the remote is genuinely UNENCRYPTED (title present in plaintext). + const before = await request.fetch(SYNC_FILE_URL, { + headers: { Authorization: AUTH_HEADER }, + }); + expect(before.ok()).toBeTruthy(); + expect(await before.text()).toContain(taskFromA); + console.log('[SetupMigrate] Remote seeded unencrypted (plaintext title present)'); + + // ============ PHASE 2: Client B joins it WITH setup-time encryption ============ + console.log('[SetupMigrate] Phase 2: Client B joins with setup-time encryption'); + + const { context: contextB, page: pageB } = await setupSyncClient(browser, url); + const syncPageB = new SyncPage(pageB); + const workViewPageB = new WorkViewPage(pageB); + + await workViewPageB.waitForTaskList(); + + await syncPageB.setupWebdavSync({ + ...WEBDAV_CONFIG, + encryptAtSetup: true, + encryptionPassword: ENCRYPTION_PASSWORD, + }); + await expect(syncPageB.syncBtn).toBeVisible(); + + await syncPageB.triggerSync(); + await waitForSyncComplete(pageB, syncPageB); + + // DATA SAFETY: B must have downloaded A's task — not overwritten the remote + // with its own empty state (the failure mode this test guards). + await expect(pageB.locator(`task:has-text("${taskFromA}")`).first()).toBeVisible(); + console.log('[SetupMigrate] Client B preserved A data (received the task)'); + + // ============ PHASE 3: B writes → remote is upgraded to encrypted ============ + await workViewPageB.addTask(taskFromB); + await waitForStatePersistence(pageB); + + await syncPageB.triggerSync(); + await waitForSyncComplete(pageB, syncPageB); + + // The remote is now encrypted: neither title appears in plaintext anymore. + const after = await request.fetch(SYNC_FILE_URL, { + headers: { Authorization: AUTH_HEADER }, + }); + expect(after.ok()).toBeTruthy(); + const afterBody = await after.text(); + expect(afterBody.length).toBeGreaterThan(0); + expect(afterBody).not.toContain(taskFromA); + expect(afterBody).not.toContain(taskFromB); + console.log('[SetupMigrate] Remote upgraded to encrypted (no plaintext titles)'); + + // B still holds both tasks (nothing lost during the upgrade). + await expect(pageB.locator(`task:has-text("${taskFromA}")`).first()).toBeVisible(); + await expect(pageB.locator(`task:has-text("${taskFromB}")`).first()).toBeVisible(); + console.log('[SetupMigrate] ✓ Data preserved and remote encrypted'); + + await closeContextsSafely(contextA, contextB); + }); +}); diff --git a/src/app/imex/sync/dialog-enable-encryption/dialog-enable-encryption.component.html b/src/app/imex/sync/dialog-enable-encryption/dialog-enable-encryption.component.html index def598a210..1e5f4f5fae 100644 --- a/src/app/imex/sync/dialog-enable-encryption/dialog-enable-encryption.component.html +++ b/src/app/imex/sync/dialog-enable-encryption/dialog-enable-encryption.component.html @@ -40,6 +40,7 @@ {{ T.F.SYNC.FORM.L_ENCRYPTION_PASSWORD | translate }} {{ textKeys.L_CONFIRM_PASSWORD | translate }} + @if (providerType === 'file-based') { + + + } @else { + + } -