From b28871ab6798aacb9b12ab046bb45ddfbb618b56 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Sun, 30 Aug 2026 07:24:57 +0000 Subject: [PATCH] Preserve device E2EE settings during remote rebuild --- src/serviceFeatures/redFlag.ts | 9 ++ src/serviceFeatures/redFlag.unit.spec.ts | 38 ++++- .../scripts/couchdb-manual-setup-workflow.ts | 146 +++++++++++++++++- updates.md | 6 + 4 files changed, 193 insertions(+), 6 deletions(-) diff --git a/src/serviceFeatures/redFlag.ts b/src/serviceFeatures/redFlag.ts index e651465c..083b7f6a 100644 --- a/src/serviceFeatures/redFlag.ts +++ b/src/serviceFeatures/redFlag.ts @@ -291,6 +291,15 @@ export async function adjustSettingToRemote( return true; } + if (operation === "rebuild") { + // An overwrite makes this device authoritative for both the Vault contents and the + // shared synchronisation settings. The remote lookup above remains a connection + // preflight, but settings from the database which is about to be replaced must not + // overwrite intentional local changes such as enabling E2EE. + log("Rebuild will use this device's synchronisation settings.", LOG_LEVEL_NOTICE); + return true; + } + const remoteTweaks = remoteResult.values; const necessary = extractObject(TweakValuesShouldMatchedTemplate, remoteTweaks); // Check if any necessary tweak value is different from current config. diff --git a/src/serviceFeatures/redFlag.unit.spec.ts b/src/serviceFeatures/redFlag.unit.spec.ts index 5a05a451..49a24a16 100644 --- a/src/serviceFeatures/redFlag.unit.spec.ts +++ b/src/serviceFeatures/redFlag.unit.spec.ts @@ -1149,6 +1149,33 @@ describe("Red Flag Feature", () => { }); describe("Remote configuration adjustment", () => { + it("keeps this device's E2EE settings when preparing to overwrite the remote", async () => { + const host = createHostMock(); + Object.assign(host.mocks.setting.settings, TweakValuesShouldMatchedTemplate, { + encrypt: true, + passphrase: "local-encryption-passphrase", + }); + host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce( + availableRemoteTweaks({ + ...TweakValuesShouldMatchedTemplate, + encrypt: false, + }) + ); + + const result = await adjustSettingToRemote( + host as any, + createLoggerMock(), + host.mocks.setting.currentSettings(), + "rebuild" + ); + + expect(result).toBe(true); + expect(host.mocks.tweakValue.fetchRemotePreferred).toHaveBeenCalledOnce(); + expect(host.mocks.setting.currentSettings().encrypt).toBe(true); + expect(host.mocks.setting.currentSettings().passphrase).toBe("local-encryption-passphrase"); + expect(host.mocks.setting.applyExternalSettings).not.toHaveBeenCalled(); + }); + it("should skip remote configuration fetch when preventFetchingConfig is true", async () => { const host = createHostMock(); const config = { preventFetchingConfig: true } as any; @@ -1855,8 +1882,15 @@ describe("Red Flag Feature", () => { it("should handle rebuildAll flag with flagHandlerToEventHandler", async () => { const host = createHostMock(); const log = createLoggerMock(); + Object.assign(host.mocks.setting.settings, TweakValuesShouldMatchedTemplate, { + encrypt: true, + passphrase: "local-encryption-passphrase", + }); host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce( - availableRemoteTweaks({ customChunkSize: 1 }) + availableRemoteTweaks({ + ...TweakValuesShouldMatchedTemplate, + encrypt: false, + }) ); host.mocks.storageAccess.files.add(FlagFilesOriginal.REBUILD_ALL); @@ -1868,6 +1902,8 @@ describe("Red Flag Feature", () => { await Promise.resolve(eventHandler()); await new Promise((resolve) => setTimeout(resolve, 10)); expect(host.mocks.rebuilder.$rebuildEverything).toHaveBeenCalled(); + expect(host.mocks.setting.currentSettings().encrypt).toBe(true); + expect(host.mocks.setting.applyExternalSettings).not.toHaveBeenCalled(); expect(host.mocks.ui.dialogManager.openWithExplicitCancel).toHaveBeenCalled(); }); diff --git a/test/e2e-obsidian/scripts/couchdb-manual-setup-workflow.ts b/test/e2e-obsidian/scripts/couchdb-manual-setup-workflow.ts index ef44ae87..6b8a1cff 100644 --- a/test/e2e-obsidian/scripts/couchdb-manual-setup-workflow.ts +++ b/test/e2e-obsidian/scripts/couchdb-manual-setup-workflow.ts @@ -1,12 +1,15 @@ import { randomBytes } from "node:crypto"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; +import { DEVICE_ID_PREFERRED, MILESTONE_DOCID } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { evalObsidianJson } from "../runner/cli.ts"; import { assertCouchDbReachable, deleteCouchDbDatabase, + fetchCouchDbDocument, loadCouchDbConfig, makeUniqueDatabaseName, + putCouchDbDocument, waitForCouchDbDocs, type CouchDbConfig, } from "../runner/couchdb.ts"; @@ -28,7 +31,7 @@ import { continueWithoutRemoteSettings, type SetupArtifact, } from "../runner/setupUri.ts"; -import { captureObsidianPage, withObsidianPage } from "../runner/ui.ts"; +import { captureObsidianPage, openLiveSyncSettings, withObsidianPage } from "../runner/ui.ts"; import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts"; process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "90000"; @@ -44,6 +47,10 @@ const captures = { scenario: "couchdb-manual-setup-workflow", guide: "couchdb-manual", } as const; +const e2eeRebuildCaptures = { + scenario: "couchdb-manual-setup-workflow", + guide: "couchdb-manual-e2ee-rebuild", +} as const; type RunnerContext = { binary: string; @@ -111,9 +118,7 @@ async function enterManualCouchDBSettings(port: number, couchDb: CouchDbConfig, await withObsidianPage(port, async (page) => { const method = modalByTitle(page, "Connection Method"); await selectRadioOption(method, "Configure a remote manually"); - await method - .getByRole("button", { name: "Proceed with manual configuration" }) - .click({ timeout: uiTimeoutMs }); + await method.getByRole("button", { name: "Proceed with manual configuration" }).click({ timeout: uiTimeoutMs }); const encryption = modalByTitle(page, "End-to-End Encryption"); await encryption.waitFor({ state: "visible", timeout: uiTimeoutMs }); @@ -246,6 +251,111 @@ async function waitForRemoteEntry(context: RunnerContext, entry: { id: string; c }); } +async function assertPersistedE2EE(vault: TemporaryVault): Promise { + const persisted = JSON.parse( + await readFile(join(vault.path, ".obsidian", "plugins", "obsidian-livesync", "data.json"), "utf8") + ) as { + encrypt?: unknown; + encryptedPassphrase?: unknown; + passphrase?: unknown; + }; + assertEqual(persisted.encrypt, true, "Manual CouchDB setup did not persist E2EE as enabled."); + assertEqual(persisted.passphrase, "", "Manual CouchDB setup persisted the E2EE passphrase in plain text."); + if (typeof persisted.encryptedPassphrase !== "string" || persisted.encryptedPassphrase.length === 0) { + throw new Error("Manual CouchDB setup did not persist an encrypted E2EE passphrase."); + } +} + +async function setRemotePreferredE2EEDisabled(context: RunnerContext): Promise { + const milestone = await fetchCouchDbDocument(context.couchDb, context.dbName, MILESTONE_DOCID); + const tweakValues = milestone.tweak_values; + if (typeof tweakValues !== "object" || tweakValues === null || Array.isArray(tweakValues)) { + throw new Error("The existing CouchDB milestone did not contain synchronisation settings."); + } + const preferred = (tweakValues as Record)[DEVICE_ID_PREFERRED]; + if (typeof preferred !== "object" || preferred === null || Array.isArray(preferred)) { + throw new Error("The existing CouchDB milestone did not contain preferred synchronisation settings."); + } + await putCouchDbDocument(context.couchDb, context.dbName, { + ...milestone, + tweak_values: { + ...tweakValues, + [DEVICE_ID_PREFERRED]: { + ...(preferred as Record), + encrypt: false, + }, + }, + }); +} + +async function assertRemotePreferredE2EE(context: RunnerContext, expected: boolean): Promise { + const milestone = await fetchCouchDbDocument(context.couchDb, context.dbName, MILESTONE_DOCID); + const tweakValues = milestone.tweak_values; + const preferred = + typeof tweakValues === "object" && tweakValues !== null && !Array.isArray(tweakValues) + ? (tweakValues as Record)[DEVICE_ID_PREFERRED] + : undefined; + const encrypt = + typeof preferred === "object" && preferred !== null && !Array.isArray(preferred) + ? (preferred as Record).encrypt + : undefined; + assertEqual(encrypt, expected, `The remote preferred E2EE setting was not ${expected ? "enabled" : "disabled"}.`); +} + +async function scheduleRemoteOverwrite(port: number): Promise { + await withObsidianPage(port, async (page) => { + const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs); + const maintenance = await settingsNavigator.openPage("Maintenance"); + const overwrite = maintenance + .locator(".setting-item") + .filter({ hasText: "Overwrite Server Data with This Device's Files" }); + await overwrite + .getByRole("button", { name: "Schedule and Restart", exact: true }) + .click({ timeout: uiTimeoutMs }); + }); +} + +async function assertRemoteEntryEncrypted( + context: RunnerContext, + entry: { id: string; path: string; children: string[] }, + plaintextPath: string, + plaintext: string +): Promise { + const remoteMetadata = await fetchCouchDbDocument(context.couchDb, context.dbName, entry.id); + const serialisedMetadata = JSON.stringify(remoteMetadata); + if ( + !remoteMetadata._id.startsWith("f:") || + typeof remoteMetadata.path !== "string" || + !remoteMetadata.path.startsWith("/\\:") || + remoteMetadata.path === entry.path || + serialisedMetadata.includes(plaintextPath) || + !Array.isArray(remoteMetadata.children) || + remoteMetadata.children.length !== 0 || + remoteMetadata.mtime !== 0 || + remoteMetadata.ctime !== 0 || + remoteMetadata.size !== 0 + ) { + throw new Error("The directly fetched CouchDB Metadata document did not protect its properties."); + } + + const childId = entry.children[0]; + if (!childId) { + throw new Error("The local E2EE test entry did not reference a Chunk document."); + } + if (!childId.startsWith("h:+")) { + throw new Error(`The E2EE test entry used an unencrypted Chunk identifier: ${childId}`); + } + const remoteChunk = await fetchCouchDbDocument(context.couchDb, context.dbName, childId); + assertEqual(remoteChunk.e_, true, "The directly fetched CouchDB Chunk was not marked as encrypted."); + if ( + typeof remoteChunk.data !== "string" || + remoteChunk.data === plaintext || + remoteChunk.data.includes(plaintext) + ) { + throw new Error("The directly fetched CouchDB Chunk contained readable Vault content."); + } +} + async function main(): Promise { const binary = requireObsidianBinary(); const cli = discoverObsidianCli(); @@ -288,11 +398,37 @@ async function main(): Promise { 1, "Manual CouchDB setup did not persist exactly one remote profile." ); + await assertPersistedE2EE(vaultA); await writeNoteViaObsidian(context.cliBinary, session.cliEnv, notePath, noteContent); const entry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, notePath); await pushLocalChanges(context.cliBinary, session.cliEnv); await waitForRemoteEntry(context, entry); + } catch (error) { + await captureFailure(session, "first-device"); + throw error; + } finally { + await stopTrackedSession(context, session); + } + + await setRemotePreferredE2EEDisabled(context); + await assertRemotePreferredE2EE(context, false); + + session = await startUnconfiguredSession(context, vaultA); + try { + await scheduleRemoteOverwrite(session.remoteDebuggingPort); + screenshots.push(await confirmRebuild(session.remoteDebuggingPort, e2eeRebuildCaptures)); + screenshots.push( + await acknowledgeDisabledOptionalFeatures(session.remoteDebuggingPort, e2eeRebuildCaptures) + ); + await finishInitialisation(session.remoteDebuggingPort, context.cliBinary, session.cliEnv); + await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort); + await assertPersistedE2EE(vaultA); + + const rebuiltEntry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, notePath); + await waitForRemoteEntry(context, rebuiltEntry); + await assertRemoteEntryEncrypted(context, rebuiltEntry, notePath, noteContent); + await assertRemotePreferredE2EE(context, true); const generated = await generateSetupURIFromDevice( session.remoteDebuggingPort, @@ -302,7 +438,7 @@ async function main(): Promise { secondDeviceArtifact = generated.artifact; screenshots.push(...generated.screenshots); } catch (error) { - await captureFailure(session, "first-device"); + await captureFailure(session, "e2ee-rebuild"); throw error; } finally { await stopTrackedSession(context, session); diff --git a/updates.md b/updates.md index ffdf139e..24048116 100644 --- a/updates.md +++ b/updates.md @@ -12,6 +12,12 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi ## Unreleased +### Synchronisation and storage + +#### Fixed + +- **Overwrite Server Data with This Device's Files** now keeps this device's synchronisation settings instead of reapplying settings from the remote database which is about to be replaced. Enabling E2EE before a rebuild therefore remains enabled and uploads encrypted data. (#1146) + ## 1.0.21 26th August, 2026