import { execFileSync } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; import { access, mkdir, readFile, writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; import { evalObsidianJson } from "../runner/cli.ts"; import { assertCouchDbReachable, couchDbDatabaseExists, createCouchDbDatabase, deleteCouchDbDatabase, fetchAllCouchDbDocs, fetchCouchDbDocument, loadCouchDbConfig, makeUniqueDatabaseName, putCouchDbDocument, waitForCouchDbDocs, type CouchDbConfig, type CouchDbDocument, } from "../runner/couchdb.ts"; import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; import { createE2eCouchDbPluginData, createE2eObsidianDeviceLocalState, prepareRemote, pushLocalChanges, waitForLiveSyncCoreReady, waitForLocalDatabaseEntry, type LocalDatabaseEntry, } from "../runner/liveSyncWorkflow.ts"; import { SECURITY_SEED_DOCUMENT_ID, changedSynchronisationParameterFields, createSecuritySeed, fingerprintSecuritySeed, replaceSecuritySeed, requireSecuritySeedDocument, snapshotSecuritySeedDocument, type SecuritySeedDocument, type SecuritySeedDocumentSnapshot, } from "../runner/securitySeed.ts"; import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; import { captureObsidianPage } from "../runner/ui.ts"; import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts"; process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000"; process.env.E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS ??= "20000"; process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ??= "15000"; const outboundPath = "E2E/security-seed/device-a.md"; const returnPath = "E2E/security-seed/device-b.md"; const hkdfErrorMessages = [ "Encryption with HKDF failed", "Decryption with HKDF failed", "Failed to initialise the encryption key", "Failed to obtain PBKDF2 salt", ] as const; type RunnerContext = { binary: string; cliBinary: string; artifactRoot: string; couchDb: CouchDbConfig; dbName: string; activeSessions: Set; allSessions: ObsidianLiveSyncSession[]; screenshots: string[]; }; type DeviceLabel = "device-a" | "device-a-return" | "device-b"; type SourceEvidence = { exactCommit: string; revisionSource: "git-worktree" | "provided-artifact"; workingTreeClean: boolean | null; pluginVersion: string; pluginArtifactSha256: string; }; type ReplicationSettingsState = { liveSync: boolean; syncOnStart: boolean; syncOnSave: boolean; periodicReplication: boolean; syncOnFileOpen: boolean; syncOnEditorSave: boolean; }; type SessionHealth = { matchingErrorMessages: string[]; }; type ScenarioEvidence = { source: SourceEvidence; securitySeed: { initial: SecuritySeedDocumentSnapshot; replacement: SecuritySeedDocumentSnapshot; final: SecuritySeedDocumentSnapshot; cachedBeforeReplacement: string; cachedAfterRemoteReplacement: string; cachedAfterReplication: string; replacementChangedFields: string[]; finalChangedFields: string[]; }; synchronisation: { deviceAToDeviceB: boolean; deviceBToDeviceA: boolean; deviceAEncryptedPayload: boolean; deviceBEncryptedPayload: boolean; }; health: { deviceA: SessionHealth; deviceB: SessionHealth; }; screenshots: string[]; }; type TeardownEvidence = { sessionsStopped: boolean; vaultRemoved: boolean; profileRemoved: boolean; databaseRemoved: boolean; remainingTrackedSessions: number; }; class MultipleErrors extends Error { readonly errors: unknown[]; constructor(message: string, errors: unknown[]) { super(message); this.name = "MultipleErrors"; this.errors = errors; } } function assertEqual(actual: unknown, expected: unknown, message: string): void { if (actual !== expected) { throw new Error(`${message}\nExpected: ${String(expected)}\nActual: ${String(actual)}`); } } function inspectGitRevision( artifactRoot: string ): Pick { try { const exactCommit = execFileSync("git", ["-C", artifactRoot, "rev-parse", "HEAD"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], }).trim(); const status = execFileSync("git", ["-C", artifactRoot, "status", "--porcelain"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], }).trim(); return { exactCommit, revisionSource: "git-worktree", workingTreeClean: status.length === 0, }; } catch { const exactCommit = process.env.E2E_OBSIDIAN_ARTIFACT_REVISION?.trim(); if (!exactCommit) { throw new Error( "E2E_OBSIDIAN_ARTIFACT_REVISION is required when the plug-in artefact is not in a Git worktree." ); } return { exactCommit, revisionSource: "provided-artifact", workingTreeClean: null, }; } } async function inspectSourceEvidence(artifactRoot: string): Promise { const manifest = JSON.parse(await readFile(join(artifactRoot, "manifest.json"), "utf-8")) as { version?: unknown; }; if (typeof manifest.version !== "string" || manifest.version.length === 0) { throw new Error("The plug-in manifest does not have a version."); } const mainJs = await readFile(join(artifactRoot, "main.js")); return { ...inspectGitRevision(artifactRoot), pluginVersion: manifest.version, pluginArtifactSha256: createHash("sha256").update(Uint8Array.from(mainJs)).digest("hex"), }; } function e2eeSettings(passphrase: string): Record { return { encrypt: true, passphrase, usePathObfuscation: true, E2EEAlgorithm: "v2", }; } async function captureStage(context: RunnerContext, session: ObsidianLiveSyncSession, filename: string): Promise { const screenshot = await captureObsidianPage(session.remoteDebuggingPort, filename, async () => undefined); context.screenshots.push(screenshot); console.log(`Security Seed E2E screenshot: ${screenshot}`); } async function startConfiguredSession( context: RunnerContext, vault: TemporaryVault, passphrase: string, deviceLabel: DeviceLabel ): Promise { const couchDbSettings = { uri: context.couchDb.uri, username: context.couchDb.username, password: context.couchDb.password, dbName: context.dbName, }; const overrides = e2eeSettings(passphrase); const session = await startObsidianLiveSyncSession({ binary: context.binary, cliBinary: context.cliBinary, artifactRoot: context.artifactRoot, vault, startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), pluginData: createE2eCouchDbPluginData(couchDbSettings, overrides), localStorageEntries: createE2eObsidianDeviceLocalState(vault.name), }); context.activeSessions.add(session); context.allSessions.push(session); try { await captureStage(context, session, `security-seed-${deviceLabel}-startup.png`); await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv); await prepareRemote(context.cliBinary, session.cliEnv); await captureStage(context, session, `security-seed-${deviceLabel}-configured.png`); return session; } catch (error) { await captureStage(context, session, `security-seed-${deviceLabel}-setup-failure.png`).catch(() => undefined); await stopTrackedSession(context, session); throw error; } } async function stopTrackedSession(context: RunnerContext, session: ObsidianLiveSyncSession): Promise { if (!context.activeSessions.has(session)) { return; } await session.app.stop(); context.activeSessions.delete(session); } async function stopTrackedSessions(context: RunnerContext): Promise { const errors: unknown[] = []; for (const session of [...context.activeSessions]) { try { await stopTrackedSession(context, session); } catch (error) { errors.push(error); } } if (errors.length > 0) { throw new MultipleErrors("Could not stop every Real Obsidian session.", errors); } } async function pauseAutomaticReplication(cliBinary: string, env: NodeJS.ProcessEnv): Promise { const state = await evalObsidianJson( cliBinary, [ "(()=>{", "const core=app.plugins.plugins['obsidian-livesync'].core;", "core.services.replicator.getActiveReplicator()?.closeReplication();", "const settings=core.services.setting.currentSettings();", "return JSON.stringify({", "liveSync:Boolean(settings.liveSync),", "syncOnStart:Boolean(settings.syncOnStart),", "syncOnSave:Boolean(settings.syncOnSave),", "periodicReplication:Boolean(settings.periodicReplication),", "syncOnFileOpen:Boolean(settings.syncOnFileOpen),", "syncOnEditorSave:Boolean(settings.syncOnEditorSave),", "});", "})()", ].join(""), env ); for (const [name, enabled] of Object.entries(state)) { if (enabled) { throw new Error(`Automatic replication remained enabled through ${name}.`); } } return state; } async function cachedSecuritySeedFingerprint(cliBinary: string, env: NodeJS.ProcessEnv): Promise { const result = await evalObsidianJson<{ fingerprint: string }>( cliBinary, [ "(async()=>{", "const core=app.plugins.plugins['obsidian-livesync'].core;", "const settings=core.services.setting.currentSettings();", "const replicator=core.services.replicator.getActiveReplicator();", "const seed=await replicator.getReplicationPBKDF2Salt(settings,false);", "const digest=await crypto.subtle.digest('SHA-256',seed);", "const fingerprint='sha256:'+Array.from(new Uint8Array(digest))", ".map((value)=>value.toString(16).padStart(2,'0')).join('').slice(0,16);", "return JSON.stringify({fingerprint});", "})()", ].join(""), env ); return result.fingerprint; } async function writeNoteViaObsidian( cliBinary: string, env: NodeJS.ProcessEnv, path: string, content: string ): Promise { await evalObsidianJson( cliBinary, [ "(async()=>{", `const path=${JSON.stringify(path)};`, `const content=${JSON.stringify(content)};`, "const folder=path.split('/').slice(0,-1).join('/');", "if(folder&&!(await app.vault.adapter.exists(folder))) await app.vault.createFolder(folder);", "const existing=app.vault.getAbstractFileByPath(path);", "if(existing) await app.vault.modify(existing,content);", "else await app.vault.create(path,content);", "return JSON.stringify({ok:true});", "})()", ].join(""), env ); } async function openNoteViaObsidian(cliBinary: string, env: NodeJS.ProcessEnv, path: string): Promise { await evalObsidianJson( cliBinary, [ "(async()=>{", `const path=${JSON.stringify(path)};`, "const file=app.vault.getAbstractFileByPath(path);", "if(!file) throw new Error(`Could not find note to open: ${path}`);", "await app.workspace.getLeaf(false).openFile(file);", "return JSON.stringify({ok:true});", "})()", ].join(""), env ); } async function pathExists(path: string): Promise { try { await access(path); return true; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { return false; } throw error; } } async function waitForPathContent( vaultPath: string, path: string, expected: string, timeoutMs = Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 15000) ): Promise { const fullPath = join(vaultPath, path); const deadline = Date.now() + timeoutMs; let lastContent = ""; while (Date.now() < deadline) { if (await pathExists(fullPath)) { lastContent = await readFile(fullPath, "utf-8"); if (lastContent === expected) { return; } } await new Promise((resolve) => setTimeout(resolve, 250)); } throw new Error(`Timed out waiting for ${path}. Last content:\n${lastContent}`); } function remoteContainsEntry(documents: CouchDbDocument[], entry: LocalDatabaseEntry): boolean { const ids = new Set(documents.map((document) => document._id)); return ids.has(entry.id) && entry.children.every((childId) => ids.has(childId)); } async function assertEntryNotRemote(context: RunnerContext, entry: LocalDatabaseEntry): Promise { const response = await fetchAllCouchDbDocs(context.couchDb, context.dbName); const documents = response.rows.flatMap((row) => (row.doc ? [row.doc] : [])); if (remoteContainsEntry(documents, entry)) { throw new Error("The pending device-A document reached CouchDB before the Security Seed replacement."); } } async function waitForEncryptedRemoteEntry(context: RunnerContext, entry: LocalDatabaseEntry): Promise { const documents = await waitForCouchDbDocs(context.couchDb, context.dbName, (docs) => remoteContainsEntry(docs, entry) ); const byId = new Map(documents.map((document) => [document._id, document])); const encrypted = entry.children.every((childId) => { const data = byId.get(childId)?.data; return typeof data === "string" && data.startsWith("%="); }); if (!encrypted) { throw new Error("A replicated chunk did not use the expected HKDF-encrypted payload format."); } return true; } async function inspectSessionHealth(cliBinary: string, env: NodeJS.ProcessEnv): Promise { return await evalObsidianJson( cliBinary, [ "(async()=>{", `const patterns=${JSON.stringify(hkdfErrorMessages)};`, "const core=app.plugins.plugins['obsidian-livesync'].core;", "await core.services.API.showWindow('log-log');", "const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));", "let text='';", "for(let i=0;i<20;i++){", "text=Array.from(document.querySelectorAll('.logpane .log pre'))", ".map((element)=>element.textContent??'').join('\\n');", "if(text.length>0) break;", "await sleep(50);", "}", "const unresolved=JSON.stringify((await core.services.appLifecycle.getUnresolvedMessages()).flat());", "const matchingErrorMessages=patterns.filter((pattern)=>text.includes(pattern)||unresolved.includes(pattern));", "for(const leaf of app.workspace.getLeavesOfType('log-log')) leaf.detach();", "return JSON.stringify({matchingErrorMessages});", "})()", ].join(""), env ); } async function fetchSecuritySeedDocument(context: RunnerContext): Promise { return requireSecuritySeedDocument( await fetchCouchDbDocument(context.couchDb, context.dbName, SECURITY_SEED_DOCUMENT_ID) ); } async function replaceRemoteSecuritySeed( context: RunnerContext, before: SecuritySeedDocument, replacementSeed: string ): Promise { const replacement = replaceSecuritySeed(before, replacementSeed); const putResult = await putCouchDbDocument(context.couchDb, context.dbName, replacement); const after = await fetchSecuritySeedDocument(context); assertEqual(after._rev, putResult.rev, "The replacement Security Seed revision was not stored."); assertEqual( fingerprintSecuritySeed(after.pbkdf2salt), fingerprintSecuritySeed(replacementSeed), "The replacement Security Seed was not stored." ); const changedFields = changedSynchronisationParameterFields(before, after); assertEqual( JSON.stringify(changedFields), JSON.stringify(["pbkdf2salt"]), "Replacing the remote Security Seed changed another synchronisation parameter." ); return after; } async function runScenario( context: RunnerContext, vaultA: TemporaryVault, vaultB: TemporaryVault ): Promise { const passphrase = `security-seed-e2e-${randomUUID()}`; const source = await inspectSourceEvidence(context.artifactRoot); let sessionA = await startConfiguredSession(context, vaultA, passphrase, "device-a"); await pushLocalChanges(context.cliBinary, sessionA.cliEnv); await captureStage(context, sessionA, "security-seed-device-a-initial-sync.png"); const initialDocument = await fetchSecuritySeedDocument(context); const initial = snapshotSecuritySeedDocument(initialDocument); const cachedBeforeReplacement = await cachedSecuritySeedFingerprint(context.cliBinary, sessionA.cliEnv); assertEqual( cachedBeforeReplacement, initial.fingerprint, "Device A did not cache the initial remote Security Seed." ); await pauseAutomaticReplication(context.cliBinary, sessionA.cliEnv); const outboundContent = `Encrypted from device A: ${randomUUID()}\n`; await writeNoteViaObsidian(context.cliBinary, sessionA.cliEnv, outboundPath, outboundContent); const outboundEntry = await waitForLocalDatabaseEntry(context.cliBinary, sessionA.cliEnv, outboundPath); await assertEntryNotRemote(context, outboundEntry); const replacementSeed = createSecuritySeed(); const replacementDocument = await replaceRemoteSecuritySeed(context, initialDocument, replacementSeed); const replacement = snapshotSecuritySeedDocument(replacementDocument); await openNoteViaObsidian(context.cliBinary, sessionA.cliEnv, outboundPath); await captureStage(context, sessionA, "security-seed-device-a-replacement-pending.png"); const cachedAfterRemoteReplacement = await cachedSecuritySeedFingerprint(context.cliBinary, sessionA.cliEnv); assertEqual( cachedAfterRemoteReplacement, initial.fingerprint, "Device A did not retain the deliberately stale Security Seed before replication." ); assertEqual( replacement.fingerprint, fingerprintSecuritySeed(replacementSeed), "The runner did not install the intended replacement Security Seed." ); await pushLocalChanges(context.cliBinary, sessionA.cliEnv); const cachedAfterReplication = await cachedSecuritySeedFingerprint(context.cliBinary, sessionA.cliEnv); assertEqual( cachedAfterReplication, replacement.fingerprint, "Device A did not refresh the Security Seed before replication." ); const deviceAEncryptedPayload = await waitForEncryptedRemoteEntry(context, outboundEntry); await captureStage(context, sessionA, "security-seed-device-a-refreshed-sync.png"); const deviceAHealthBeforeRestart = await inspectSessionHealth(context.cliBinary, sessionA.cliEnv); await stopTrackedSession(context, sessionA); const sessionB = await startConfiguredSession(context, vaultB, passphrase, "device-b"); await pushLocalChanges(context.cliBinary, sessionB.cliEnv); await waitForPathContent(vaultB.path, outboundPath, outboundContent); await openNoteViaObsidian(context.cliBinary, sessionB.cliEnv, outboundPath); await captureStage(context, sessionB, "security-seed-device-b-received.png"); await pauseAutomaticReplication(context.cliBinary, sessionB.cliEnv); const returnContent = `Encrypted from device B: ${randomUUID()}\n`; await writeNoteViaObsidian(context.cliBinary, sessionB.cliEnv, returnPath, returnContent); const returnEntry = await waitForLocalDatabaseEntry(context.cliBinary, sessionB.cliEnv, returnPath); await pushLocalChanges(context.cliBinary, sessionB.cliEnv); const deviceBEncryptedPayload = await waitForEncryptedRemoteEntry(context, returnEntry); const deviceBHealth = await inspectSessionHealth(context.cliBinary, sessionB.cliEnv); await stopTrackedSession(context, sessionB); sessionA = await startConfiguredSession(context, vaultA, passphrase, "device-a-return"); await pushLocalChanges(context.cliBinary, sessionA.cliEnv); await waitForPathContent(vaultA.path, returnPath, returnContent); await openNoteViaObsidian(context.cliBinary, sessionA.cliEnv, returnPath); await captureStage(context, sessionA, "security-seed-device-a-return-received.png"); const finalDocument = await fetchSecuritySeedDocument(context); const final = snapshotSecuritySeedDocument(finalDocument); assertEqual( final.fingerprint, replacement.fingerprint, "A client rolled the remote Security Seed back after reconnecting." ); const finalChangedFields = changedSynchronisationParameterFields(replacementDocument, finalDocument); if (finalChangedFields.length > 0) { throw new Error( `A client rewrote unexpected synchronisation-parameter fields: ${finalChangedFields.join(", ")}` ); } const deviceAHealthAfterRestart = await inspectSessionHealth(context.cliBinary, sessionA.cliEnv); const deviceAHealth = { matchingErrorMessages: [ ...new Set([ ...deviceAHealthBeforeRestart.matchingErrorMessages, ...deviceAHealthAfterRestart.matchingErrorMessages, ]), ], }; if (deviceAHealth.matchingErrorMessages.length > 0 || deviceBHealth.matchingErrorMessages.length > 0) { throw new Error( `HKDF or Security Seed errors were logged: ${JSON.stringify({ deviceA: deviceAHealth.matchingErrorMessages, deviceB: deviceBHealth.matchingErrorMessages, })}` ); } return { source, securitySeed: { initial, replacement, final, cachedBeforeReplacement, cachedAfterRemoteReplacement, cachedAfterReplication, replacementChangedFields: changedSynchronisationParameterFields(initialDocument, replacementDocument), finalChangedFields, }, synchronisation: { deviceAToDeviceB: true, deviceBToDeviceA: true, deviceAEncryptedPayload, deviceBEncryptedPayload, }, health: { deviceA: deviceAHealth, deviceB: deviceBHealth, }, screenshots: [...context.screenshots], }; } async function cleanupResources( context: RunnerContext, vaults: TemporaryVault[], databaseCreated: boolean ): Promise { const errors: unknown[] = []; try { await stopTrackedSessions(context); } catch (error) { errors.push(error); } for (const vault of vaults) { try { await vault.dispose(); } catch (error) { errors.push(error); } } if (databaseCreated) { try { await deleteCouchDbDatabase(context.couchDb, context.dbName); } catch (error) { errors.push(error); } } const sessionsStopped = context.allSessions.every( (session) => session.app.process.exitCode !== null || session.app.process.signalCode !== null ); const vaultRemoved = (await Promise.all(vaults.map(async (vault) => !(await pathExists(vault.path))))).every( Boolean ); const profileRemoved = (await Promise.all(vaults.map(async (vault) => !(await pathExists(vault.statePath))))).every( Boolean ); let databaseRemoved = !databaseCreated; if (databaseCreated) { try { databaseRemoved = !(await couchDbDatabaseExists(context.couchDb, context.dbName)); } catch (error) { errors.push(error); } } const evidence = { sessionsStopped, vaultRemoved, profileRemoved, databaseRemoved, remainingTrackedSessions: context.activeSessions.size, }; if (!sessionsStopped || !vaultRemoved || !profileRemoved || !databaseRemoved || context.activeSessions.size > 0) { errors.push(new Error(`Security Seed E2E teardown was incomplete: ${JSON.stringify(evidence)}`)); } if (errors.length > 0) { throw Object.assign(new MultipleErrors("Security Seed E2E teardown failed.", errors), { evidence, }); } return evidence; } async function writeResult(result: unknown): Promise { const outputDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e"; const resultPath = join(outputDirectory, "security-seed-reconnect-result.json"); await mkdir(outputDirectory, { recursive: true }); await writeFile(resultPath, `${JSON.stringify(result, null, 2)}\n`, "utf-8"); return resultPath; } async function main(): Promise { if (process.env.E2E_OBSIDIAN_KEEP_VAULT === "true" || process.env.E2E_OBSIDIAN_KEEP_COUCHDB === "true") { throw new Error("The Security Seed reconnect scenario requires strict Vault, profile, and database cleanup."); } const binary = requireObsidianBinary(); const cli = discoverObsidianCli(); if (!cli.binary) { throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`); } const artifactRoot = resolve(process.env.E2E_OBSIDIAN_ARTIFACT_ROOT ?? process.cwd()); const couchDb = await loadCouchDbConfig(); const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "security-seed-reconnect"); const context: RunnerContext = { binary, cliBinary: cli.binary, artifactRoot, couchDb, dbName, activeSessions: new Set(), allSessions: [], screenshots: [], }; const vaults: TemporaryVault[] = []; let databaseCreated = false; let evidence: ScenarioEvidence | undefined; let scenarioError: unknown; let teardown: TeardownEvidence | undefined; let teardownError: unknown; try { await assertCouchDbReachable(couchDb); await createCouchDbDatabase(couchDb, dbName); databaseCreated = true; vaults.push(await createTemporaryVault("obsidian-livesync-security-seed-a-")); vaults.push(await createTemporaryVault("obsidian-livesync-security-seed-b-")); evidence = await runScenario(context, vaults[0], vaults[1]); } catch (error) { scenarioError = error; } finally { try { teardown = await cleanupResources(context, vaults, databaseCreated); } catch (error) { teardownError = error; } } if (scenarioError !== undefined || teardownError !== undefined) { const errors = [scenarioError, teardownError].filter((error) => error !== undefined); if (errors.length === 1) { throw errors[0]; } throw new MultipleErrors("Security Seed reconnect scenario and teardown both failed.", errors); } if (!evidence || !teardown) { throw new Error("Security Seed reconnect evidence was not produced."); } const result = { scenario: "security-seed-reconnect", ...evidence, teardown, limitations: { platformCommonRealObsidian: true, iPadOsBackgroundReconnect: false, androidDeviceLifecycle: false, }, }; const resultPath = await writeResult(result); console.log(`Security Seed E2E result: ${resultPath}`); console.log(JSON.stringify(result, null, 2)); } main().catch((error: unknown) => { console.error(error instanceof Error ? error.stack : error); process.exitCode = 1; });