Merge main and verify ID keys through Setup URI and QR

This commit is contained in:
vorotamoroz
2026-09-29 14:28:51 +00:00
51 changed files with 2823 additions and 181 deletions
+1 -1
View File
@@ -628,7 +628,7 @@ export async function startP2pRelay(): Promise<void> {
//TODO: port mapping should be configurable.
"4000:7777",
"--tmpfs",
"/app/strfry-db:rw,size=256m",
"/app/strfry-db:rw,size=256m,mode=1777",
"--entrypoint",
"sh",
P2P_RELAY_IMAGE,
+16 -8
View File
@@ -13,7 +13,11 @@ export async function initSettingsFile(settingsFile: string): Promise<void> {
* Generate a full setup URI from a settings file via the Commonlib package API.
* Mirrors the bash flow in test-setup-put-cat-linux.sh.
*/
export async function generateSetupUriFromSettings(settingsFile: string, setupPassphrase: string): Promise<string> {
export async function generateSetupUriFromSettings(
settingsFile: string,
setupPassphrase: string,
preserveRemoteSettings = false
): Promise<string> {
const script = [
"import { fs } from '@vrtmrz/livesync-commonlib/node';",
"import { encodeSettingsToSetupURI } from '@vrtmrz/livesync-commonlib/compat/API/processSetting';",
@@ -21,13 +25,17 @@ export async function generateSetupUriFromSettings(settingsFile: string, setupPa
" const settingsPath = process.env.SETTINGS_FILE;",
" const passphrase = process.env.SETUP_PASSPHRASE;",
" const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));",
" settings.couchDB_DBNAME = 'setup-put-cat-db';",
" settings.couchDB_URI = 'http://127.0.0.1:5999';",
" settings.couchDB_USER = 'dummy';",
" settings.couchDB_PASSWORD = 'dummy';",
" settings.liveSync = false;",
" settings.syncOnStart = false;",
" settings.syncOnSave = false;",
...(preserveRemoteSettings
? []
: [
" settings.couchDB_DBNAME = 'setup-put-cat-db';",
" settings.couchDB_URI = 'http://127.0.0.1:5999';",
" settings.couchDB_USER = 'dummy';",
" settings.couchDB_PASSWORD = 'dummy';",
" settings.liveSync = false;",
" settings.syncOnStart = false;",
" settings.syncOnSave = false;",
]),
" const uri = await encodeSettingsToSetupURI(settings, passphrase);",
" process.stdout.write(uri.trim());",
"})();",
+79 -3
View File
@@ -1,6 +1,11 @@
import { assert } from "@std/assert";
import { TempDir } from "./helpers/temp.ts";
import { initSettingsFile, applyP2pSettings, applyP2pTestTweaks } from "./helpers/settings.ts";
import {
initSettingsFile,
applyP2pSettings,
applyP2pTestTweaks,
generateSetupUriFromSettings,
} from "./helpers/settings.ts";
import { startCliInBackground } from "./helpers/backgroundCli.ts";
import {
discoverPeer,
@@ -9,10 +14,10 @@ import {
maybeStartCoturn,
stopCoturnIfStarted,
} from "./helpers/p2p.ts";
import { runCli } from "./helpers/cli.ts";
import { runCli, runCliOrFail, runCliWithInputOrFail, sanitiseCatStdout } from "./helpers/cli.ts";
import { getOptimalLoopbackIp } from "./helpers/net.ts";
Deno.test("p2p-sync: discovers peer and completes sync", async () => {
Deno.test("p2p-sync: transfers with the same ID key and rejects a different document ID key", async () => {
const loopbackIp = await getOptimalLoopbackIp();
const loopbackHost = loopbackIp === "::1" ? "[::1]" : loopbackIp;
@@ -32,14 +37,18 @@ Deno.test("p2p-sync: discovers peer and completes sync", async () => {
const hostSettings = workDir.join("settings-host.json");
const clientVault = workDir.join("vault-sync");
const clientSettings = workDir.join("settings-sync.json");
const rejectedVault = workDir.join("vault-rejected");
const rejectedSettings = workDir.join("settings-rejected.json");
await Deno.mkdir(hostVault, { recursive: true });
await Deno.mkdir(clientVault, { recursive: true });
await Deno.mkdir(rejectedVault, { recursive: true });
const relayStarted = await maybeStartLocalRelay(relay);
const coturnStarted = await maybeStartCoturn(turnServers);
try {
await initSettingsFile(hostSettings);
await initSettingsFile(clientSettings);
await initSettingsFile(rejectedSettings);
await applyP2pSettings(
hostSettings,
roomId,
@@ -58,8 +67,52 @@ Deno.test("p2p-sync: discovers peer and completes sync", async () => {
"~.*",
turnServers
);
await applyP2pSettings(
rejectedSettings,
roomId,
passphrase,
"self-hosted-livesync-cli-tests",
relay,
"~.*",
turnServers
);
await applyP2pTestTweaks(hostSettings, hostPeerName, passphrase);
await applyP2pTestTweaks(clientSettings, clientPeerName, passphrase);
await applyP2pTestTweaks(rejectedSettings, "p2p-rejected-" + nonce, passphrase);
for (const [vault, path, key, label] of [
[hostVault, hostSettings, "ab".repeat(32), "host"],
[clientVault, clientSettings, "ab".repeat(32), "client"],
[rejectedVault, rejectedSettings, "cd".repeat(32), "rejected"],
]) {
const settings = JSON.parse(await Deno.readTextFile(path));
settings.idDerivationVersion = 1;
settings.idDerivationKey = key;
const sourcePath = workDir.join("setup-source-" + label + ".json");
await Deno.writeTextFile(sourcePath, JSON.stringify(settings));
const setupPassphrase = "independent-id-setup-passphrase";
const setupUri = await generateSetupUriFromSettings(sourcePath, setupPassphrase, true);
await runCliWithInputOrFail(setupPassphrase + "\n", vault, "--settings", path, "setup", setupUri);
const persisted = JSON.parse(await Deno.readTextFile(path));
assert(persisted.idDerivationVersion === 1, "The Setup URI lost the ID derivation version.");
assert(persisted.idDerivationKey === "", "The CLI stored the ID key in plain text.");
assert(
typeof persisted.encryptedIdDerivationKey === "string" && persisted.encryptedIdDerivationKey.length > 0,
"The CLI did not encrypt the saved ID key."
);
assert(persisted.P2P_Enabled === true, "The Setup URI disabled P2P.");
assert(persisted.P2P_roomID === roomId, "The Setup URI changed the P2P room.");
assert(persisted.P2P_relays === relay, "The Setup URI changed the P2P relay.");
assert(persisted.remoteType === "ONLY_P2P", "The Setup URI changed the remote type.");
}
const notePath = "p2p/independent-id-note.md";
await runCliWithInputOrFail(
"A note transferred with the saved ID key.\n",
clientVault,
"--settings",
clientSettings,
"put",
notePath
);
const host = startCliInBackground(hostVault, "--settings", hostSettings, "p2p-host");
try {
@@ -82,9 +135,32 @@ Deno.test("p2p-sync: discovers peer and completes sync", async () => {
syncResult.code === 0,
`p2p-sync failed\nstdout: ${syncResult.stdout}\nstderr: ${syncResult.stderr}`
);
const rejectedPeer = await discoverPeer(rejectedVault, rejectedSettings, peersTimeout, hostPeerName);
const rejectedSync = await runCli(
rejectedVault,
"--settings",
rejectedSettings,
"p2p-sync",
rejectedPeer.id,
String(syncTimeout)
);
assert(
rejectedSync.code !== 0,
`P2P accepted a different key for obfuscated document IDs.\nstdout: ${rejectedSync.stdout}\nstderr: ${rejectedSync.stderr}`
);
assert(
rejectedSync.combined.includes("Tweak values are not matched"),
`P2P failed before checking peer settings.\nstdout: ${rejectedSync.stdout}\nstderr: ${rejectedSync.stderr}`
);
} finally {
await host.stop();
}
const received = sanitiseCatStdout(
await runCliOrFail(hostVault, "--settings", hostSettings, "cat", notePath)
).trimEnd();
assert(received === "A note transferred with the saved ID key.", "The host did not receive the keyed note.");
const rejectedRead = await runCli(rejectedVault, "--settings", rejectedSettings, "cat", notePath);
assert(rejectedRead.code !== 0, "The rejected device received the keyed note.");
} finally {
await stopLocalRelayIfStarted(relayStarted);
await stopCoturnIfStarted(coturnStarted);
@@ -207,6 +207,50 @@ export const liveSyncProvisionalEnglishMessages = {
"Repair failed before the source was removed. Run inspection again before retrying.",
"Connection settings": "Connection settings",
"Saved connections": "Saved connections",
"ID generation": "ID generation",
"Keep current configuration": "Keep current configuration",
"Set an ID key": "Set an ID key",
"Current configuration: a saved ID key is used.": "Current configuration: a saved ID key is used.",
"Current configuration: the saved ID key is retained while E2EE is off.":
"Current configuration: the saved ID key is retained while E2EE is off.",
"Current configuration: no ID key is saved. With E2EE enabled, keeping it uses legacy IDs tied to the E2EE passphrase.":
"Current configuration: no ID key is saved. With E2EE enabled, keeping it uses legacy IDs tied to the E2EE passphrase.",
"Changing the E2EE passphrase changes IDs generated by the legacy configuration.":
"Changing the E2EE passphrase changes IDs generated by the legacy configuration.",
"This uses a saved key for new Chunk IDs and obfuscated Metadata document IDs, so changing the E2EE passphrase does not derive a new key automatically.":
"This uses a saved key for new Chunk IDs and obfuscated Metadata document IDs, so changing the E2EE passphrase does not derive a new key automatically.",
Configured: "Configured",
"The saved ID key is configured. Its source cannot be shown again.":
"The saved ID key is configured. Its source cannot be shown again.",
"Leave this input empty to keep the saved ID key.": "Leave this input empty to keep the saved ID key.",
"Generate a random ID key": "Generate a random ID key",
"How to set the ID key": "How to set the ID key",
"Derive from current E2EE passphrase": "Derive from current E2EE passphrase",
"Enter an ID source": "Enter an ID source",
"Import an ID recovery code": "Import an ID recovery code",
"ID source": "ID source",
"ID recovery code": "ID recovery code",
"Enter an ID recovery code": "Enter an ID recovery code",
"Choose a long, unpredictable source. It is used once and cannot be shown again after saving. A recovery code can be displayed on this device later. This input also accepts a tagged recovery code.":
"Choose a long, unpredictable source. It is used once and cannot be shown again after saving. A recovery code can be displayed on this device later. This input also accepts a tagged recovery code.",
"Paste a tagged recovery code from an existing device to restore the same ID key.":
"Paste a tagged recovery code from an existing device to restore the same ID key.",
"For recovery after losing every device, save the recovery code after setup or choose an ID source you can reproduce.":
"For recovery after losing every device, save the recovery code after setup or choose an ID source you can reproduce.",
"Show current recovery code": "Show current recovery code",
"Hide current recovery code": "Hide current recovery code",
"Current ID recovery code": "Current ID recovery code",
"Copy recovery code": "Copy recovery code",
"Recovery code copied.": "Recovery code copied.",
"The displayed recovery code belongs to the current key. Reopen this dialogue after saving to copy the replacement key.":
"The displayed recovery code belongs to the current key. Reopen this dialogue after saving to copy the replacement key.",
"The recovery code could not be copied. Select and copy the visible code instead.":
"The recovery code could not be copied. Select and copy the visible code instead.",
"The ID key is derived from the current E2EE passphrase and saved separately. Changing that passphrase later does not change the saved ID key. To reduce the risk of guessing that passphrase from known IDs, use a separate, unpredictable ID source instead.":
"The ID key is derived from the current E2EE passphrase and saved separately. Changing that passphrase later does not change the saved ID key. To reduce the risk of guessing that passphrase from known IDs, use a separate, unpredictable ID source instead.",
"An ID source is required to enable this option.": "An ID source is required to enable this option.",
"The ID source or recovery code is invalid. Check it and try again.":
"The ID source or recovery code is invalid. Check it and try again.",
} as const;
export type LiveSyncProvisionalMessageKey = keyof typeof liveSyncProvisionalEnglishMessages;
@@ -43,7 +43,10 @@ function projectHeaders(value: string): readonly (readonly [name: string, value:
}
function projectRemoteSecurity(settings: RemoteDBSettings) {
return settings.encrypt
return [
settings.idDerivationVersion,
settings.idDerivationKey,
settings.encrypt
? ([
"encrypted",
settings.passphrase,
@@ -51,7 +54,8 @@ function projectRemoteSecurity(settings: RemoteDBSettings) {
settings.E2EEAlgorithm,
settings.permitEmptyPassphrase,
] as const)
: (["plain"] as const);
: (["plain"] as const),
] as const;
}
/**
@@ -31,6 +31,17 @@ describe("active Replicator configuration identity", () => {
});
}
it("replaces a connection when the independent ID key changes", () => {
const first = configuredSettings({ idDerivationVersion: 1, idDerivationKey: "a".repeat(64) });
const second = { ...first, idDerivationKey: "b".repeat(64) };
expect(getCouchDBReplicatorConfigurationIdentity(second)).not.toBe(
getCouchDBReplicatorConfigurationIdentity(first)
);
expect(getObjectStorageReplicatorConfigurationIdentity(second)).not.toBe(
getObjectStorageReplicatorConfigurationIdentity(first)
);
});
it.each([
["couchDB_URI", "https://other.example.test/base"],
["couchDB_DBNAME", "other-vault"],
+2
View File
@@ -80,6 +80,8 @@ export async function generateReport(settings: ObsidianLiveSyncSettings, core: L
pluginConfig.couchDB_USER = REDACTED;
pluginConfig.passphrase = REDACTED;
pluginConfig.encryptedPassphrase = REDACTED;
pluginConfig.idDerivationKey = REDACTED;
pluginConfig.encryptedIdDerivationKey = REDACTED;
pluginConfig.encryptedCouchDBConnection = REDACTED;
pluginConfig.accessKey = REDACTED;
pluginConfig.secretKey = REDACTED;
+16
View File
@@ -10,6 +10,22 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions", () => ({
}));
describe("TURN credentials in diagnostic reports", () => {
it("redacts the derived ID key and its encrypted local wrapper", async () => {
const key = "f3205cc41d24116d8c2484993c9d9a2e667373af338ba02f2ee71199adb82f2e";
const wrapper = "encrypted-id-key-test-wrapper";
const settings = {
...DEFAULT_SETTINGS,
idDerivationVersion: 1 as const,
idDerivationKey: key,
encryptedIdDerivationKey: wrapper,
};
const core = { services: { vault: { isStorageInsensitive: () => false } } } as unknown as LiveSyncBaseCore;
const report = await generateReport(settings, core);
const text = JSON.stringify(report);
expect(text).not.toContain(key);
expect(text).not.toContain(wrapper);
});
it("redacts provider tokens in all profiles and runtime credentials", async () => {
const token = "private+token/with=symbols";
const provider = { P2P_managedType: "CF", P2P_managedId: "private-key", P2P_managedToken: token };
@@ -1,9 +1,6 @@
import type { ObsidianLiveSyncSettings, SettingsMigrationState } from "@vrtmrz/livesync-commonlib/settings";
import type { CompatibilityPause } from "@/common/databaseCompatibility.ts";
import type {
ReviewHarnessScenarioResult,
ReviewHarnessScenarioStatus,
} from "./reviewHarnessTypes";
import type { ReviewHarnessScenarioResult, ReviewHarnessScenarioStatus } from "./reviewHarnessTypes";
export type { ReviewHarnessScenarioResult, ReviewHarnessScenarioStatus } from "./reviewHarnessTypes";
@@ -32,6 +29,14 @@ export const REVIEW_HARNESS_SCENARIOS = [
mode: "automatic",
access: "dedicated-vault-fixtures",
},
{
id: "id-generation-performance",
title: "ID generation performance",
description:
"Measures legacy and independent IDs with fixed in-memory inputs. Reports time per 1,000 IDs and per ID, key derivation time, and JavaScript heap samples where available. Keep Obsidian in the foreground.",
mode: "automatic",
access: "read-only",
},
] as const;
export const REVIEW_HARNESS_SCENARIO_IDS = REVIEW_HARNESS_SCENARIOS.map(({ id }) => id);
@@ -114,7 +119,9 @@ const NEW_VAULT_RECOMMENDATION_KEYS = [
"E2EEAlgorithm",
] as const;
type LifecycleSettingKey = (typeof PRESERVED_SYNC_SETTING_KEYS)[number] | (typeof NEW_VAULT_RECOMMENDATION_KEYS)[number];
type LifecycleSettingKey =
| (typeof PRESERVED_SYNC_SETTING_KEYS)[number]
| (typeof NEW_VAULT_RECOMMENDATION_KEYS)[number];
type SettingsForLifecycleInspection = Partial<Pick<ObsidianLiveSyncSettings, LifecycleSettingKey>>;
export function inspectSettingsLifecycle(input: {
@@ -130,9 +137,7 @@ export function inspectSettingsLifecycle(input: {
};
}
const invalidSyncSettings = PRESERVED_SYNC_SETTING_KEYS.filter(
(key) => typeof input.settings[key] !== "boolean"
);
const invalidSyncSettings = PRESERVED_SYNC_SETTING_KEYS.filter((key) => typeof input.settings[key] !== "boolean");
if (invalidSyncSettings.length > 0) {
return {
status: "failed",
@@ -205,6 +210,7 @@ export interface ReviewHarnessReportScenario {
readonly mode: ReviewHarnessScenarioMode;
readonly status: ReviewHarnessScenarioStatus;
readonly detail: string;
readonly observations?: readonly string[];
}
export interface ReviewHarnessReportInput {
@@ -248,13 +254,15 @@ export function formatReviewHarnessReport(input: ReviewHarnessReportInput): stri
);
const scenarios = table(
["Scenario", "Mode", "Status", "Detail"],
input.scenarios.map(({ id, title, mode, status, detail }) => [
`${title} (${id})`,
mode,
status,
detail,
])
input.scenarios.map(({ id, title, mode, status, detail }) => [`${title} (${id})`, mode, status, detail])
);
const observations = input.scenarios
.filter((scenario) => scenario.observations?.length)
.map(
({ title, observations }) =>
`### ${title}\n\n${observations!.map((value) => `- ${tableCell(value)}`).join("\n")}`
)
.join("\n\n");
return `## Self-hosted LiveSync Review Harness report
Generated at \`${tableCell(input.generatedAt)}\`.
@@ -267,6 +275,8 @@ ${environment}
${scenarios}
${observations}
<details>
<summary>Event transcript</summary>
@@ -75,6 +75,7 @@ describe("Review Harness contract", () => {
"settings-lifecycle",
"compatibility-review",
"vault-round-trip",
"id-generation-performance",
]);
});
@@ -21,6 +21,7 @@ export interface ReviewHarnessRuntime {
getCompatibilityPause(): CompatibilityPause | undefined;
openCompatibilityReview(): Promise<void>;
runVaultRoundTrip(): Promise<ReviewHarnessScenarioResult>;
runIdBenchmark(): Promise<ReviewHarnessScenarioResult>;
readContinuation(): string | null;
writeContinuation(value: string): void;
deleteContinuation(): void;
@@ -159,6 +160,8 @@ export class ReviewHarnessController {
});
} else if (id === "vault-round-trip") {
result = await this.runtime.runVaultRoundTrip();
} else if (id === "id-generation-performance") {
result = await this.runtime.runIdBenchmark();
} else {
const inspection = this.inspectCompatibilityReview();
result =
@@ -206,10 +209,7 @@ export class ReviewHarnessController {
detail: "The device-local compatibility review remains pending.",
observations: inspection.observations,
};
this.record(
"compatibility-review-updated",
this.results["compatibility-review"].status
);
this.record("compatibility-review-updated", this.results["compatibility-review"].status);
} catch (error) {
this.setUnexpectedFailure("compatibility-review", error);
} finally {
@@ -259,6 +259,7 @@ export class ReviewHarnessController {
mode,
status: this.results[id].status,
detail: this.results[id].detail,
observations: this.results[id].observations,
})),
transcript: this.transcript,
});
@@ -80,6 +80,11 @@ function createRuntime(): ReviewHarnessRuntime & {
detail: "The owned fixture tree was exercised and removed.",
observations: [],
})),
runIdBenchmark: vi.fn(async () => ({
status: "passed" as const,
detail: "ID generation measurements completed.",
observations: ["Chunk 256 B: 1000 IDs total=43.00 ms; per ID=0.0430 ms"],
})),
readContinuation() {
return this.continuation;
},
@@ -150,6 +155,60 @@ describe("ReviewHarnessController", () => {
expect(runtime.reportError).toHaveBeenCalledOnce();
});
it("runs ID measurements on request and includes their units in the copied report", async () => {
const runtime = createRuntime();
const controller = new ReviewHarnessController(runtime);
await controller.runAutomaticScenarios();
expect(runtime.runIdBenchmark).not.toHaveBeenCalled();
await controller.runScenario("id-generation-performance");
await controller.copyReport();
expect(runtime.runIdBenchmark).toHaveBeenCalledOnce();
expect(controller.snapshot().results["id-generation-performance"].status).toBe("passed");
expect(vi.mocked(runtime.copyText).mock.calls[0][0]).toContain("1000 IDs total=43.00 ms; per ID=0.0430 ms");
expect(runtime.runVaultRoundTrip).not.toHaveBeenCalled();
expect(runtime.events).toEqual([]);
expect(runtime.continuation).toBeNull();
});
it("excludes an unexpected measurement error from the copied report", async () => {
const runtime = createRuntime();
runtime.runIdBenchmark = vi.fn().mockRejectedValue(new Error("private measurement error"));
const controller = new ReviewHarnessController(runtime);
await controller.runScenario("id-generation-performance");
expect(controller.snapshot().results["id-generation-performance"].status).toBe("failed");
expect(controller.createReport()).not.toContain("private measurement error");
expect(runtime.reportError).toHaveBeenCalledOnce();
});
it("does not overlap an ID measurement with another scenario", async () => {
const runtime = createRuntime();
let finish!: () => void;
const pending = new Promise<void>((resolve) => {
finish = resolve;
});
runtime.runIdBenchmark = vi.fn(async () => {
await pending;
return { status: "passed" as const, detail: "Measured", observations: [] };
});
const controller = new ReviewHarnessController(runtime);
const running = controller.runScenario("id-generation-performance");
await controller.runScenario("id-generation-performance");
await controller.runScenario("vault-round-trip");
expect(runtime.runIdBenchmark).toHaveBeenCalledOnce();
expect(runtime.runVaultRoundTrip).not.toHaveBeenCalled();
expect(controller.snapshot().running).toBe(true);
finish();
await running;
expect(controller.snapshot().running).toBe(false);
});
it("deletes a one-shot continuation before exposing the resumed guided step", () => {
const runtime = createRuntime();
runtime.continuation = JSON.stringify({
@@ -167,9 +226,7 @@ describe("ReviewHarnessController", () => {
expect(controller.snapshot().results["compatibility-review"]).toMatchObject({
status: "waiting-for-user",
});
expect(controller.snapshot().resumedRequestId).toBe(
"compatibility-review-2026-07-18T11:59:00.000Z"
);
expect(controller.snapshot().resumedRequestId).toBe("compatibility-review-2026-07-18T11:59:00.000Z");
});
it("does not copy rejected continuation values into the report", () => {
@@ -0,0 +1,109 @@
import type { ReviewHarnessScenarioResult } from "./reviewHarnessTypes";
export interface IdBenchmarkOperations {
deriveKey(): Promise<unknown>;
chunkId(piece: string, independent: boolean): Promise<string>;
documentId(path: string, independent: boolean): Promise<string>;
}
type BenchmarkPerformance = Pick<Performance, "now"> & {
readonly memory?: { readonly usedJSHeapSize: number };
};
const ID_COUNT = 1000;
const SAMPLES = 3;
const BATCH_SIZE = 100;
const WARMUP_COUNT = 32;
function readHeap(clock: BenchmarkPerformance): number | undefined {
try {
const bytes = clock.memory?.usedJSHeapSize;
return typeof bytes === "number" && Number.isFinite(bytes) && bytes >= 0 ? bytes : undefined;
} catch {
return undefined;
}
}
function summary(samples: readonly number[]): string {
const sorted = [...samples].sort((a, b) => a - b);
return `median=${sorted[1].toFixed(2)} ms; range=${sorted[0].toFixed(2)}–${sorted[2].toFixed(2)} ms`;
}
export async function runReviewHarnessIdBenchmark(
operations: IdBenchmarkOperations,
clock: BenchmarkPerformance = performance,
yieldControl: () => Promise<void> = () => new Promise((resolve) => window.setTimeout(resolve, 0))
): Promise<ReviewHarnessScenarioResult> {
const before = readHeap(clock);
let highest = before;
const sampleHeap = () => {
const value = readHeap(clock);
if (value !== undefined) highest = Math.max(highest ?? value, value);
return value;
};
const observations = [
"Fixed synthetic inputs; 3 samples, alternating legacy/independent order; 32 warm-up IDs per sample. Legacy Chunk algorithm: xxhash64.",
"Compute timings include input construction and awaited ID generation. Initialisation, warm-up, and pauses between batches are excluded. This does not measure a Rebuild or remote transfer.",
];
const derivationSamples: number[] = [];
for (let sample = 0; sample < SAMPLES; sample++) {
await yieldControl();
const started = clock.now();
await operations.deriveKey();
derivationSamples.push(clock.now() - started);
sampleHeap();
}
observations.push(`ID key derivation at save time: ${summary(derivationSamples)} per derivation.`);
const cases = [
...[256, 4096, 32768].map((bytes) => {
const prefix = "r".repeat(bytes - 8);
return {
label: `Chunk IDs, ${bytes} B`,
run: (i: number, independent: boolean) =>
operations.chunkId(prefix + i.toString(36).padStart(8, "0"), independent),
};
}),
{
label: "Obfuscated document IDs",
run: (i: number, independent: boolean) => operations.documentId(`benchmark/path-${i}.md`, independent),
},
];
for (const scenario of cases) {
const samples: [number[], number[]] = [[], []];
for (let sample = 0; sample < SAMPLES; sample++) {
for (const independent of sample % 2 === 0 ? [false, true] : [true, false]) {
for (let i = 0; i < WARMUP_COUNT; i++) await scenario.run(i, independent);
let elapsed = 0;
for (let batch = 0; batch < ID_COUNT; batch += BATCH_SIZE) {
await yieldControl();
const started = clock.now();
for (let i = batch; i < batch + BATCH_SIZE; i++) await scenario.run(i, independent);
elapsed += clock.now() - started;
sampleHeap();
}
samples[independent ? 1 : 0].push(elapsed);
}
}
for (const [index, values] of samples.entries()) {
const median = [...values].sort((a, b) => a - b)[1];
observations.push(
`${scenario.label}, ${index === 0 ? "legacy" : "independent"}: ${ID_COUNT} IDs total ${summary(values)}; per ID=${(median / ID_COUNT).toFixed(4)} ms.`
);
}
}
const after = sampleHeap();
if (highest === undefined) {
observations.push("JavaScript heap: unavailable on this device.");
} else {
const mib = (bytes: number | undefined) =>
bytes === undefined ? "unavailable" : `${(bytes / 1048576).toFixed(2)} MiB`;
observations.push(
`JavaScript heap: before=${mib(before)}; highest sampled=${mib(highest)}; after=${mib(after)}.`
);
}
observations.push(
"Heap samples are approximate, may include other Obsidian work, and are affected by garbage collection. They are neither total app RAM nor a true peak."
);
return { status: "passed", detail: "ID generation measurements completed.", observations };
}
@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import { runReviewHarnessIdBenchmark, type IdBenchmarkOperations } from "./reviewHarnessIdBenchmark";
function fixture() {
let elapsed = 0;
let derivations = 0;
const chunkCounts = [0, 0];
const documentCounts = [0, 0];
const chunkSizes = new Set<number>();
const operations: IdBenchmarkOperations = {
deriveKey: () => {
derivations++;
elapsed += 42;
return Promise.resolve("private-derived-key");
},
chunkId: (piece, independent) => {
chunkCounts[independent ? 1 : 0]++;
chunkSizes.add(piece.length);
elapsed += independent ? 2 : 1;
return Promise.resolve("private-chunk-id");
},
documentId: (_path, independent) => {
documentCounts[independent ? 1 : 0]++;
elapsed += independent ? 4 : 3;
return Promise.resolve("private-document-id");
},
};
return {
operations,
now: () => elapsed,
yieldControl: () => {
elapsed += 100;
return Promise.resolve();
},
counts: () => ({ derivations, chunkCounts, documentCounts, chunkSizes: [...chunkSizes] }),
};
}
describe("Review Harness ID measurements", () => {
it("reports totals and per-ID timings separately, excluding warm-up and cooperative pauses", async () => {
const f = fixture();
const result = await runReviewHarnessIdBenchmark(f.operations, { now: f.now }, f.yieldControl);
const report = result.observations.join("\n");
expect(result.status).toBe("passed");
expect(report).toContain("1000 IDs total median=1000.00 ms; range=1000.00–1000.00 ms; per ID=1.0000 ms");
expect(report).toContain("1000 IDs total median=2000.00 ms; range=2000.00–2000.00 ms; per ID=2.0000 ms");
expect(report).toContain("Obfuscated document IDs, legacy: 1000 IDs total median=3000.00 ms");
expect(report).toContain("Obfuscated document IDs, independent: 1000 IDs total median=4000.00 ms");
expect(report).toContain("ID key derivation at save time: median=42.00 ms");
expect(report).toContain("JavaScript heap: unavailable on this device.");
expect(report).not.toContain("private-");
expect(f.counts()).toEqual({
derivations: 3,
chunkCounts: [9288, 9288],
documentCounts: [3096, 3096],
chunkSizes: [256, 4096, 32768],
});
});
it("labels the highest sampled heap separately from total app RAM and allows a lower final sample", async () => {
const f = fixture();
let reads = 0;
const clock = {
now: f.now,
get memory() {
return { usedJSHeapSize: (reads++ === 0 ? 2 : reads === 2 ? 5 : 1) * 1048576 };
},
};
const result = await runReviewHarnessIdBenchmark(f.operations, clock, f.yieldControl);
expect(result.observations).toContain(
"JavaScript heap: before=2.00 MiB; highest sampled=5.00 MiB; after=1.00 MiB."
);
expect(result.observations.join("\n")).toContain("neither total app RAM nor a true peak");
});
it.each([Number.NaN, Number.POSITIVE_INFINITY, -1, "throws"])(
"keeps timings usable when the heap API returns %s",
async (value) => {
const f = fixture();
const result = await runReviewHarnessIdBenchmark(
f.operations,
{
now: f.now,
get memory() {
if (value === "throws") throw new Error("Heap API unavailable");
return { usedJSHeapSize: value as number };
},
},
f.yieldControl
);
expect(result.status).toBe("passed");
expect(result.observations).toContain("JavaScript heap: unavailable on this device.");
expect(result.observations.join("\n")).not.toMatch(/NaN|Infinity|private-/u);
}
);
});
@@ -0,0 +1,35 @@
import { DEFAULT_SETTINGS, deriveIdKey } from "@vrtmrz/livesync-commonlib/settings";
import { path2id_base } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { HashManager } from "@vrtmrz/livesync-commonlib/hashing";
import type { IdBenchmarkOperations } from "./reviewHarnessIdBenchmark";
const FIXTURE_PASSPHRASE = "Self-hosted LiveSync ID benchmark passphrase";
const FIXTURE_SOURCE = "Self-hosted LiveSync ID benchmark source";
const FIXTURE_KEY = "ab".repeat(32);
export async function createIdBenchmarkOperations(): Promise<IdBenchmarkOperations> {
const managers: HashManager[] = [];
for (const independent of [false, true]) {
const settings = Object.freeze({
...DEFAULT_SETTINGS,
encrypt: true,
passphrase: FIXTURE_PASSPHRASE,
hashAlg: "xxhash64" as const,
idDerivationVersion: independent ? (1 as const) : (0 as const),
idDerivationKey: independent ? FIXTURE_KEY : "",
});
// HashManager only reads currentSettings; this fixture has no storage or live service access.
const settingService = { currentSettings: () => settings } as HashManager["options"]["settingService"];
const manager = new HashManager({ settingService });
if (!(await manager.initialise())) throw new Error("The benchmark hash manager could not initialise.");
managers.push(manager);
}
return {
deriveKey: () => deriveIdKey(FIXTURE_SOURCE),
chunkId: (piece, independent) => managers[independent ? 1 : 0].computeHash(piece),
// Fixture paths are already normalised; use the same ID calculation as PathService.
documentId: (path, independent) =>
path2id_base(path as FilePath, FIXTURE_PASSPHRASE, false, independent ? FIXTURE_KEY : undefined),
};
}
@@ -0,0 +1,38 @@
import { describe, expect, it, vi } from "vitest";
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/settings";
import { createIdBenchmarkOperations } from "./reviewHarnessIdBenchmarkRuntime";
describe("Review Harness benchmark implementation", () => {
it("uses the packaged legacy and independent algorithms with isolated fixed settings", async () => {
const originalDefaults = structuredClone(DEFAULT_SETTINGS);
const fetch = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("Network access is forbidden"));
try {
const operations = await createIdBenchmarkOperations();
const chunk = "r".repeat(256);
const legacy = await operations.chunkId(chunk, false);
const independent = await operations.chunkId(chunk, true);
expect(legacy).toMatch(/^\+[0-9a-z]{1,13}$/u);
expect(independent).toMatch(/^\+[0-9a-f]{64}$/u);
expect(independent).toBe("+9223e53d99e80c29effee9e95e38ed168d13c14f717054f9e996a1cd0a597000");
expect(await operations.chunkId(chunk, false)).toBe(legacy);
expect(await operations.chunkId(chunk, true)).toBe(independent);
expect(await operations.chunkId("s".repeat(256), true)).not.toBe(independent);
const legacyPath = await operations.documentId("benchmark/path-1.md", false);
const independentPath = await operations.documentId("benchmark/path-1.md", true);
expect(legacyPath).toMatch(/^f:[0-9a-f]{64}$/u);
expect(independentPath).toMatch(/^f:[0-9a-f]{64}$/u);
expect(legacyPath).not.toBe(independentPath);
expect(await operations.documentId("benchmark/path-1.md", true)).toBe(independentPath);
const second = await createIdBenchmarkOperations();
expect(await second.chunkId(chunk, true)).toBe(independent);
expect(await operations.deriveKey()).toMatch(/^[0-9a-f]{64}$/u);
expect(fetch).not.toHaveBeenCalled();
expect(DEFAULT_SETTINGS).toEqual(originalDefaults);
} finally {
fetch.mockRestore();
}
});
});
@@ -99,6 +99,17 @@ function resolutionSettingsSignature(settings: ObsidianLiveSyncSettings): string
}
export class ModuleResolvingMismatchedTweaks extends AbstractModule {
private requiresIdConfigurationReview(assessment: TweakAssessment): boolean {
if (!assessment.entries.some(({ key, relation }) => key === "idDerivationVersion" && relation === "different")) {
return false;
}
Logger(
"The document ID configurations differ. Import the correct Setup URI, or configure the matching ID key, before synchronising.",
LOG_LEVEL_NOTICE
);
return true;
}
private _selectNewerTweakSide(current: TweakValues, preferred: Partial<TweakValues>): "REMOTE" | "CURRENT" {
Logger(`Modified: ${current.tweakModified} (current) vs ${preferred.tweakModified} (preferred)`);
const currentModified = current.tweakModified;
@@ -196,6 +207,7 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
assessment = assessTweakCompatibility(this.settings, preferred)
): Promise<[TweakValues | boolean, boolean]> {
if (assessment.alignment === "matched") return [false, false];
if (this.requiresIdConfigurationReview(assessment)) return [false, false];
const acceptedSettings = settingsAfterAdoption(assessment, "adoptPreferred");
const autoAcceptSide = await this._shouldAutoAcceptCompatibleLossy(assessment);
if (autoAcceptSide === "REMOTE") return [acceptedSettings, false];
@@ -363,6 +375,7 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
const trialSignature = JSON.stringify(trialSetting);
const currentSignature = resolutionSettingsSignature(this.settings);
const assessment = assessTweakCompatibility(trialSetting, preferred);
if (this.requiresIdConfigurationReview(assessment)) return { result: false, requireFetch: false };
if (assessment.alignment === "matched") {
this._log("The settings in the remote database are the same as the local database.", LOG_LEVEL_NOTICE);
return { result: false, requireFetch: false };
@@ -7,7 +7,7 @@ import {
type TweakValues,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { extractObject } from "octagonal-wheels/object";
import { assessTweakCompatibility } from "@vrtmrz/livesync-commonlib/settings";
import { assessTweakCompatibility, configuredIdKey } from "@vrtmrz/livesync-commonlib/settings";
import { ModuleResolvingMismatchedTweaks } from "./ModuleResolveMismatchedTweaks";
import { setLang } from "@/common/translation";
import {
@@ -74,6 +74,68 @@ function createModule(settingsOverride: Partial<typeof DEFAULT_SETTINGS> = {}) {
}
describe("ModuleResolvingMismatchedTweaks", () => {
it.each([0, 1] as const)(
"keeps ID configuration %s when automatically aligning Chunk settings",
async (idDerivationVersion) => {
const idDerivationKey = idDerivationVersion === 1 ? "ab".repeat(32) : "";
const { module, core, askSelectStringDialogue } = createModule({
encrypt: true,
usePathObfuscation: false,
idDerivationVersion,
idDerivationKey,
autoAcceptCompatibleTweak: true,
hashAlg: "xxhash64",
tweakModified: 1,
});
const preferred: TweakValues = {
...extractObject(TweakValuesTemplate, core.settings),
idDerivationVersion: idDerivationVersion === 1 ? 0 : 1,
hashAlg: "xxhash32",
tweakModified: 2,
};
core._services.tweakValue = {
checkAndAskResolvingMismatched: module._checkAndAskResolvingMismatchedTweaks.bind(module),
};
core._services.setting.saveSettingData.mockImplementation(async () => {
configuredIdKey(core.settings);
});
await expect(module._askResolvingMismatchedTweaks(preferred, async () => true)).resolves.toBe("CHECKAGAIN");
expect(core.settings).toMatchObject({ idDerivationVersion, idDerivationKey, hashAlg: "xxhash32" });
expect(askSelectStringDialogue).not.toHaveBeenCalled();
}
);
it.each(["active", "trial"] as const)(
"withholds ordinary tweak adoption for different document ID modes (%s)",
async (route) => {
const { module, core, askSelectStringDialogue } = createModule({
encrypt: true,
usePathObfuscation: true,
idDerivationVersion: 0,
idDerivationKey: "",
});
const preferred: TweakValues = {
...extractObject(TweakValuesTemplate, core.settings),
idDerivationVersion: 1,
};
if (route === "active") {
await expect(module._checkAndAskResolvingMismatchedTweaks(preferred)).resolves.toEqual([false, false]);
} else {
await expect(module._askUseRemoteConfiguration(core.settings, preferred)).resolves.toEqual({
result: false,
requireFetch: false,
});
}
expect(askSelectStringDialogue).not.toHaveBeenCalled();
expect(core._services.setting.saveSettingData).not.toHaveBeenCalled();
expect(core.settings).toMatchObject({ idDerivationVersion: 0, idDerivationKey: "" });
}
);
it("compatibility: offers ordinary application for a missing legacy filename-case setting", async () => {
const { module, askSelectStringDialogue } = createModule({
autoAcceptCompatibleTweak: false,
@@ -140,6 +140,8 @@ export class ModuleObsidianSettingsAsMarkdown extends AbstractModule {
settingToApply.couchDB_USER = this.settings.couchDB_USER;
settingToApply.couchDB_PASSWORD = this.settings.couchDB_PASSWORD;
settingToApply.passphrase = this.settings.passphrase;
settingToApply.idDerivationVersion = this.settings.idDerivationVersion;
settingToApply.idDerivationKey = this.settings.idDerivationKey;
}
const oldSetting = this.generateSettingForMarkdown(
this.settings,
@@ -203,11 +205,13 @@ export class ModuleObsidianSettingsAsMarkdown extends AbstractModule {
const saveData = { ...(settings ? settings : this.settings) } as Partial<ObsidianLiveSyncSettings>;
delete saveData.encryptedCouchDBConnection;
delete saveData.encryptedPassphrase;
delete saveData.encryptedIdDerivationKey;
delete saveData.additionalSuffixOfDatabaseName;
if (!saveData.writeCredentialsForSettingSync && !keepCredential) {
delete saveData.couchDB_USER;
delete saveData.couchDB_PASSWORD;
delete saveData.passphrase;
delete saveData.idDerivationKey;
delete saveData.jwtKey;
delete saveData.jwtKid;
delete saveData.jwtSub;
@@ -47,6 +47,12 @@ function getSettingsFromEditingSettings(editingSettings: AllSettings): ObsidianL
}
return workObj;
}
function syncIdDerivationSettings(target: Partial<ObsidianLiveSyncSettings>, source: ObsidianLiveSyncSettings): void {
target.idDerivationVersion = source.idDerivationVersion;
target.idDerivationKey = source.idDerivationKey;
}
function createRemoteConfigurationId(): string {
return `remote-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
@@ -116,6 +122,8 @@ export function paneRemoteConfig(
.onClick(async () => {
const setupManager = this.core.getModule(SetupManager);
const originalSettings = getSettingsFromEditingSettings(this.editingSettings);
const originalIdDerivationVersion = this.core.settings.idDerivationVersion;
const originalIdDerivationKey = this.core.settings.idDerivationKey;
const applied = await setupManager.onlyE2EEConfiguration(UserMode.Update, originalSettings);
if (applied) {
this.editingSettings.encryptInternalMetadata =
@@ -126,6 +134,16 @@ export function paneRemoteConfig(
}
this.requestUpdate();
}
if (
this.core.settings.idDerivationVersion !== originalIdDerivationVersion ||
this.core.settings.idDerivationKey !== originalIdDerivationKey
) {
syncIdDerivationSettings(this.editingSettings, this.core.settings);
if (this.initialSettings) {
syncIdDerivationSettings(this.initialSettings, this.core.settings);
}
this.requestUpdate();
}
updateE2EESummary();
})
.setButtonText("Configure")
@@ -164,9 +182,11 @@ export function paneRemoteConfig(
const currentConfigs = cloneRemoteConfigurations(this.core.settings.remoteConfigurations);
this.editingSettings.remoteConfigurations = currentConfigs;
this.editingSettings.activeConfigurationId = this.core.settings.activeConfigurationId;
syncIdDerivationSettings(this.editingSettings, this.core.settings);
if (this.initialSettings) {
this.initialSettings.remoteConfigurations = cloneRemoteConfigurations(currentConfigs);
this.initialSettings.activeConfigurationId = this.core.settings.activeConfigurationId;
syncIdDerivationSettings(this.initialSettings, this.core.settings);
}
};
const persistRemoteConfigurations = async (synchroniseActiveRemote: boolean = false) => {
@@ -254,6 +274,8 @@ export function paneRemoteConfig(
usePathObfuscation: this.editingSettings.usePathObfuscation,
encryptInternalMetadata: this.editingSettings.encryptInternalMetadata,
passphrase: this.editingSettings.passphrase,
idDerivationVersion: this.editingSettings.idDerivationVersion,
idDerivationKey: this.editingSettings.idDerivationKey,
configPassphraseStore: this.editingSettings.configPassphraseStore,
});
const addRemoteConfiguration = async () => {
@@ -194,4 +194,51 @@ describe("paneRemoteConfig", () => {
expect(host.initialSettings.encryptInternalMetadata).toBe(true);
expect(host.requestUpdate).toHaveBeenCalledOnce();
});
it("copies applied ID derivation settings into both dialogue buffers", async () => {
const nextIdKey = "ab".repeat(32);
const originalSettings = {
encrypt: true,
passphrase: "passphrase",
E2EEAlgorithm: "v2",
usePathObfuscation: true,
encryptInternalMetadata: false,
idDerivationVersion: 0,
idDerivationKey: "",
remoteConfigurations: {},
};
const setupManager = {
onlyE2EEConfiguration: vi.fn(() => {
host.core.settings.idDerivationVersion = 1;
host.core.settings.idDerivationKey = nextIdKey;
return Promise.resolve(false);
}),
};
const host = {
editingSettings: { ...originalSettings },
initialSettings: { ...originalSettings },
core: {
settings: { ...originalSettings },
getModule: vi.fn(() => setupManager),
},
lifetimeComponent: { register: vi.fn() },
requestUpdate: vi.fn(),
};
const addPanel = vi.fn((_parent: HTMLElement, heading: string) => ({
then(callback: (paneEl: HTMLElement) => void) {
if (heading === "E2EE Configuration") {
callback(createPanelElement());
}
},
}));
paneRemoteConfig.call(host as never, {} as HTMLElement, { addPanel } as never);
await runtime.clickHandlers[0]();
expect(host.editingSettings.idDerivationVersion).toBe(1);
expect(host.editingSettings.idDerivationKey).toBe(nextIdKey);
expect(host.initialSettings.idDerivationVersion).toBe(1);
expect(host.initialSettings.idDerivationKey).toBe(nextIdKey);
expect(host.requestUpdate).toHaveBeenCalledOnce();
});
});
@@ -68,6 +68,7 @@ export function getE2EEConfigSummary(setting: ObsidianLiveSyncSettings, showAdva
export function getSummaryFromPartialSettings(setting: Partial<ObsidianLiveSyncSettings>, showAdvanced = false) {
const outputSummary: Record<string, string> = {};
for (const key of Object.keys(setting) as (keyof ObsidianLiveSyncSettings)[]) {
if (key === "idDerivationKey" || key === "encryptedIdDerivationKey") continue;
const config = getConfig(key as AllSettingItemKey);
if (!config) continue;
if (config.isAdvanced && !showAdvanced) continue;
+36 -8
View File
@@ -1,6 +1,5 @@
import {
type BucketSyncSetting,
type EncryptionSettings,
type ObsidianLiveSyncSettings,
type P2PSyncSetting,
LOG_LEVEL_NOTICE,
@@ -36,6 +35,7 @@ import type {
SetupRemoteCouchDBResultType,
SetupRemoteCouchDBInitialData,
SetupRemoteE2EEResultType,
SetupRemoteE2EEInitialData,
SetupRemoteP2PInitialData,
SetupRemoteP2PResultType,
SetupRemoteResultType,
@@ -58,6 +58,20 @@ function copySettingsForRemoteProfileUpdate(settings: ObsidianLiveSyncSettings):
};
}
function normaliseImportedIdDerivationSettings(settings: ObsidianLiveSyncSettings): ObsidianLiveSyncSettings {
// Setup URIs are complete imports even when their encoder omitted default-valued fields.
// Fill each missing half so a receiving device cannot supply the unrelated saved key.
return {
...settings,
idDerivationVersion: Object.prototype.hasOwnProperty.call(settings, "idDerivationVersion")
? settings.idDerivationVersion
: 0,
idDerivationKey: Object.prototype.hasOwnProperty.call(settings, "idDerivationKey")
? settings.idDerivationKey
: "",
};
}
/**
* User modes for onboarding and setup
*/
@@ -219,7 +233,7 @@ export class SetupManager extends AbstractModule {
return false;
}
this._log("Setup URI dialog closed.", LOG_LEVEL_VERBOSE);
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode);
return await this.onConfirmApplySettingsFromWizard(normaliseImportedIdDerivationSettings(newSetting), userMode);
}
/**
@@ -328,9 +342,12 @@ export class SetupManager extends AbstractModule {
* @returns
*/
async onlyE2EEConfiguration(userMode: UserMode, currentSetting: ObsidianLiveSyncSettings): Promise<boolean> {
const e2eeConf = await this.dialogManager.openWithExplicitCancel<SetupRemoteE2EEResultType, EncryptionSettings>(
const e2eeConf = await this.dialogManager.openWithExplicitCancel<
SetupRemoteE2EEResultType,
SetupRemoteE2EEInitialData
>(
SetupRemoteE2EE,
currentSetting
{ settings: currentSetting, newVault: userMode === UserMode.NewUser }
);
if (e2eeConf === "cancelled") {
this._log("E2EE configuration cancelled.", LOG_LEVEL_NOTICE);
@@ -341,7 +358,9 @@ export class SetupManager extends AbstractModule {
currentSetting.encrypt === e2eeConf.encrypt &&
currentSetting.passphrase === e2eeConf.passphrase &&
currentSetting.E2EEAlgorithm === e2eeConf.E2EEAlgorithm &&
currentSetting.usePathObfuscation === e2eeConf.usePathObfuscation;
currentSetting.usePathObfuscation === e2eeConf.usePathObfuscation &&
currentSetting.idDerivationVersion === e2eeConf.idDerivationVersion &&
currentSetting.idDerivationKey === e2eeConf.idDerivationKey;
if (userMode === UserMode.Update && onlyInternalMetadataPreferenceChanged) {
if (e2eeConf.encryptInternalMetadata && currentSetting.remoteType === REMOTE_COUCHDB) {
const proceed = "Enable without rebuilding — update every other device first";
@@ -375,9 +394,12 @@ export class SetupManager extends AbstractModule {
* @returns
*/
async onConfigureManually(originalSetting: ObsidianLiveSyncSettings, userMode: UserMode): Promise<boolean> {
const e2eeConf = await this.dialogManager.openWithExplicitCancel<SetupRemoteE2EEResultType, EncryptionSettings>(
const e2eeConf = await this.dialogManager.openWithExplicitCancel<
SetupRemoteE2EEResultType,
SetupRemoteE2EEInitialData
>(
SetupRemoteE2EE,
originalSetting
{ settings: originalSetting, newVault: userMode === UserMode.NewUser }
);
if (e2eeConf === "cancelled") {
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
@@ -521,7 +543,13 @@ export class SetupManager extends AbstractModule {
* @returns Promise that resolves to true if settings applied successfully, false otherwise
*/
async decodeQR(qr: string) {
const newSettings = decodeSettingsFromQRCodeData(qr);
let newSettings: ObsidianLiveSyncSettings;
try {
newSettings = normaliseImportedIdDerivationSettings(decodeSettingsFromQRCodeData(qr));
} catch {
this._log("The QR configuration could not be decoded or contains unsupported settings.", LOG_LEVEL_NOTICE);
return false;
}
return await this.onConfirmApplySettingsFromWizard(newSettings, UserMode.Unknown);
}
@@ -193,6 +193,58 @@ describe("SetupManager", () => {
expect(setting.currentSettings().activeConfigurationId).toBe("legacy-couchdb");
});
it("compatibility: treats omitted ID derivation fields in a Setup URI as legacy defaults", async () => {
const { manager, setting, dialogManager } = createSetupManager();
const savedKey = "12".repeat(32);
setting.settings = {
...createLegacyRemoteSetting(),
isConfigured: true,
idDerivationVersion: 1,
idDerivationKey: savedKey,
};
const imported = {
...createLegacyRemoteSetting(),
isConfigured: true,
} as Partial<ObsidianLiveSyncSettings>;
delete imported.idDerivationVersion;
delete imported.idDerivationKey;
vi.spyOn(setting, "adjustSettings").mockImplementation((settings) => Promise.resolve(settings));
dialogManager.openWithExplicitCancel.mockResolvedValueOnce(imported).mockResolvedValueOnce("cancelled");
await manager.onUseSetupURI(UserMode.Unknown, "mock-config://legacy-settings");
const mergedSettings = vi.mocked(setting.adjustSettings).mock.calls[0][0];
expect(mergedSettings.idDerivationVersion).toBe(0);
expect(mergedSettings.idDerivationKey).toBe("");
expect(setting.currentSettings().idDerivationKey).toBe(savedKey);
});
it("does not inherit the missing half of a partially present Setup URI ID configuration", async () => {
const { manager, setting, dialogManager } = createSetupManager();
const savedKey = "34".repeat(32);
setting.settings = {
...createLegacyRemoteSetting(),
isConfigured: true,
idDerivationVersion: 1,
idDerivationKey: savedKey,
};
const imported = {
...createLegacyRemoteSetting(),
isConfigured: true,
idDerivationVersion: 1,
} as Partial<ObsidianLiveSyncSettings>;
delete imported.idDerivationKey;
vi.spyOn(setting, "adjustSettings").mockImplementation((settings) => Promise.resolve(settings));
dialogManager.openWithExplicitCancel.mockResolvedValueOnce(imported).mockResolvedValueOnce("cancelled");
await manager.onUseSetupURI(UserMode.Unknown, "mock-config://partial-settings");
const mergedSettings = vi.mocked(setting.adjustSettings).mock.calls[0][0];
expect(mergedSettings.idDerivationVersion).toBe(1);
expect(mergedSettings.idDerivationKey).toBe("");
expect(setting.currentSettings().idDerivationKey).toBe(savedKey);
});
it("compatibility: normalises imported flat remote settings from QR data before applying", async () => {
const { manager, setting, dialogManager } = createSetupManager();
vi.mocked(decodeSettingsFromQRCodeData).mockReturnValue(createLegacyRemoteSetting());
@@ -208,6 +260,79 @@ describe("SetupManager", () => {
expect(setting.currentSettings().activeConfigurationId).toBe("legacy-couchdb");
});
it("compatibility: applies legacy defaults when QR data omits ID derivation fields", async () => {
const { manager, setting, dialogManager } = createSetupManager();
const savedKey = "56".repeat(32);
setting.settings = {
...createLegacyRemoteSetting(),
isConfigured: true,
idDerivationVersion: 1,
idDerivationKey: savedKey,
};
const imported = { ...createLegacyRemoteSetting(), isConfigured: true } as Partial<ObsidianLiveSyncSettings>;
delete imported.idDerivationVersion;
delete imported.idDerivationKey;
vi.mocked(decodeSettingsFromQRCodeData).mockReturnValue(imported as ObsidianLiveSyncSettings);
vi.spyOn(setting, "adjustSettings").mockImplementation((settings) => Promise.resolve(settings));
dialogManager.openWithExplicitCancel.mockResolvedValueOnce("cancelled");
await manager.decodeQR("qr-data");
const mergedSettings = vi.mocked(setting.adjustSettings).mock.calls[0][0];
expect(mergedSettings.idDerivationVersion).toBe(0);
expect(mergedSettings.idDerivationKey).toBe("");
expect(setting.currentSettings().idDerivationKey).toBe(savedKey);
});
it("rejects invalid QR settings before applying them", async () => {
const { manager, setting } = createSetupManager();
vi.mocked(decodeSettingsFromQRCodeData).mockImplementationOnce(() => {
throw new Error("Invalid ID derivation key");
});
const applyExternalSettings = vi.spyOn(setting, "applyExternalSettings");
await expect(manager.decodeQR("invalid-qr")).resolves.toBe(false);
expect(applyExternalSettings).not.toHaveBeenCalled();
});
it("requires the normal Fetch choice when ID derivation changes with the Metadata preference", async () => {
const { manager, setting, dialogManager, core } = createSetupManager();
const currentSettings: ObsidianLiveSyncSettings = {
...createLegacyRemoteSetting(),
isConfigured: true,
encrypt: true,
passphrase: "e2ee-passphrase",
usePathObfuscation: true,
encryptInternalMetadata: false,
idDerivationVersion: 0,
idDerivationKey: "",
};
const nextIdKey = "78".repeat(32);
setting.settings = currentSettings;
const applyPartial = vi.spyOn(setting, "applyPartial");
core.confirm = {
askSelectStringDialogue: vi.fn(() =>
Promise.resolve("Enable without rebuilding — update every other device first")
),
};
dialogManager.openWithExplicitCancel
.mockResolvedValueOnce({
...currentSettings,
encryptInternalMetadata: true,
idDerivationVersion: 1,
idDerivationKey: nextIdKey,
})
.mockResolvedValueOnce("existing-user")
.mockResolvedValueOnce("apply");
await manager.onlyE2EEConfiguration(UserMode.Update, currentSettings);
expect(applyPartial).not.toHaveBeenCalled();
expect(core.rebuilder.scheduleFetch).toHaveBeenCalledWith(expect.any(Function));
expect(setting.currentSettings().idDerivationVersion).toBe(1);
expect(setting.currentSettings().idDerivationKey).toBe(nextIdKey);
});
it("reserves Rebuild before saving a new-user configuration", async () => {
const { manager, setting, dialogManager, core } = createSetupManager();
setting.settings = { ...setting.currentSettings(), isConfigured: false };
@@ -13,33 +13,64 @@
E2EEAlgorithms,
type EncryptionSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
deriveIdKey,
deriveOrImportIdKey,
formatIdRecoveryCode,
ID_DERIVATION_VERSION,
ID_RECOVERY_CODE_PREFIX,
} from "@vrtmrz/livesync-commonlib/settings";
import { onMount } from "svelte";
import type { GuestDialogProps } from "@/modules/services/LiveSyncUI/svelteDialog";
import { copyTo, pickEncryptionSettings } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { TYPE_CANCELLED, type SetupRemoteE2EEResultType } from "./setupDialogTypes";
import {
TYPE_CANCELLED,
type SetupRemoteE2EEInitialData,
type SetupRemoteE2EEResultType,
} from "./setupDialogTypes";
import { $msg as translateMessage } from "@/common/translation";
type Props = GuestDialogProps<SetupRemoteE2EEResultType, EncryptionSettings>;
type Props = GuestDialogProps<SetupRemoteE2EEResultType, SetupRemoteE2EEInitialData>;
type IdConfigurationChoice = "keep" | "random" | "custom";
type IdCustomChoice = "passphrase" | "source" | "recovery";
const { setResult, getInitialData }: Props = $props();
let default_encryption: EncryptionSettings = {
encrypt: true,
passphrase: "",
idDerivationVersion: DEFAULT_SETTINGS.idDerivationVersion,
idDerivationKey: DEFAULT_SETTINGS.idDerivationKey,
E2EEAlgorithm: DEFAULT_SETTINGS.E2EEAlgorithm,
usePathObfuscation: true,
encryptInternalMetadata: true,
idDerivationVersion: 0,
idDerivationKey: "",
};
let encryptionSettings = $state<EncryptionSettings>({ ...default_encryption });
let newVault = $state(false);
let idConfigurationChoice = $state<IdConfigurationChoice>("keep");
let idCustomChoice = $state<IdCustomChoice>("source");
let idDerivationSource = $state("");
let idDerivationError = $state("");
let recoveryCodeVisible = $state(false);
let recoveryCodeCopied = $state(false);
const idDerivationConfigured = $derived(
encryptionSettings.idDerivationVersion === ID_DERIVATION_VERSION &&
typeof encryptionSettings.idDerivationKey === "string" &&
encryptionSettings.idDerivationKey.length > 0
);
const recoveryCode = $derived.by(() =>
idDerivationConfigured ? formatIdRecoveryCode(encryptionSettings.idDerivationKey) : ""
);
onMount(() => {
if (getInitialData) {
const initialData = getInitialData();
if (initialData) {
copyTo(initialData, encryptionSettings);
copyTo(initialData.settings, encryptionSettings);
newVault = initialData.newVault;
}
}
idConfigurationChoice = !idDerivationConfigured && newVault ? "random" : "keep";
});
let e2eeValid = $derived.by(() => {
if (!encryptionSettings.encrypt) return true;
@@ -51,8 +82,75 @@
encryptionSettings.usePathObfuscation
);
function commit() {
setResult(pickEncryptionSettings(encryptionSettings));
function resetIdDerivationSource() {
idDerivationSource = "";
idDerivationError = "";
}
function toggleEncryption(enabled: boolean) {
encryptionSettings.encrypt = enabled;
if (!enabled) resetIdDerivationSource();
}
function selectIdConfiguration() {
recoveryCodeVisible = false;
recoveryCodeCopied = false;
resetIdDerivationSource();
}
function selectIdCustomSource() {
resetIdDerivationSource();
}
async function copyRecoveryCode() {
try {
await navigator.clipboard.writeText(recoveryCode);
recoveryCodeCopied = true;
} catch {
idDerivationError = translateMessage("The recovery code could not be copied. Select and copy the visible code instead.");
}
}
async function commit() {
idDerivationError = "";
const result = pickEncryptionSettings(encryptionSettings);
if (encryptionSettings.encrypt && idConfigurationChoice !== "keep") {
let source = idDerivationSource;
if (idConfigurationChoice === "random") {
const bytes = crypto.getRandomValues(new Uint8Array(32));
source = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
} else if (idCustomChoice === "passphrase") {
source = encryptionSettings.passphrase;
}
if (source.length === 0) {
if (!idDerivationConfigured) {
idDerivationError = translateMessage("An ID source is required to enable this option.");
return;
}
} else {
try {
result.idDerivationKey =
idConfigurationChoice === "custom" && idCustomChoice !== "passphrase"
? await importOrDeriveEnteredIdKey(source, idCustomChoice)
: await deriveIdKey(source);
result.idDerivationVersion = ID_DERIVATION_VERSION;
} catch {
idDerivationError = translateMessage("The ID source or recovery code is invalid. Check it and try again.");
return;
}
}
}
idDerivationSource = "";
setResult(result);
}
async function importOrDeriveEnteredIdKey(source: string, choice: IdCustomChoice): Promise<string> {
if (choice === "recovery" && !source.trim().startsWith(ID_RECOVERY_CODE_PREFIX)) {
throw new Error("An ID recovery code is required.");
}
return await deriveOrImportIdKey(source);
}
</script>
@@ -60,7 +158,11 @@
<DialogHeader title={translateMessage("End-to-End Encryption")} />
<Guidance>{translateMessage("Please configure your end-to-end encryption settings.")}</Guidance>
<InputRow label={translateMessage("End-to-End Encryption")}>
<input type="checkbox" bind:checked={encryptionSettings.encrypt} />
<input
type="checkbox"
checked={encryptionSettings.encrypt}
onchange={(event) => toggleEncryption(event.currentTarget.checked)}
/>
</InputRow>
<InfoNote title={translateMessage("Strongly Recommended")}>
{translateMessage(
@@ -95,6 +197,164 @@
</InfoNote>
{/if}
<fieldset class="sls-id-choices" disabled={!encryptionSettings.encrypt}>
<legend>{translateMessage("ID generation")}</legend>
<label class="sls-id-choice">
<input
type="radio"
name="id-derivation-choice"
value="keep"
bind:group={idConfigurationChoice}
onchange={selectIdConfiguration}
/>
<div class="sls-id-choice-text">
<span>{translateMessage("Keep current configuration")}</span>
<small class="sls-current-id-configuration">
{#if idDerivationConfigured}
{translateMessage(
encryptionSettings.encrypt
? "Current configuration: a saved ID key is used."
: "Current configuration: the saved ID key is retained while E2EE is off."
)}
{:else}
{translateMessage(
"Current configuration: no ID key is saved. With E2EE enabled, keeping it uses legacy IDs tied to the E2EE passphrase."
)}
{/if}
</small>
</div>
</label>
<label class="sls-id-choice">
<input
type="radio"
name="id-derivation-choice"
value="random"
bind:group={idConfigurationChoice}
onchange={selectIdConfiguration}
/>
<span>{translateMessage("Generate a random ID key")}</span>
</label>
<label class="sls-id-choice">
<input
type="radio"
name="id-derivation-choice"
value="custom"
bind:group={idConfigurationChoice}
onchange={selectIdConfiguration}
/>
<span>{translateMessage("Set an ID key")}</span>
</label>
</fieldset>
{#if encryptionSettings.encrypt && idConfigurationChoice === "keep" && !idDerivationConfigured}
<InfoNote warning>
{translateMessage("Changing the E2EE passphrase changes IDs generated by the legacy configuration.")}
</InfoNote>
{/if}
{#if (encryptionSettings.encrypt && idConfigurationChoice !== "keep") || idDerivationConfigured}
{#if encryptionSettings.encrypt}
<InfoNote>
{translateMessage(
"This uses a saved key for new Chunk IDs and obfuscated Metadata document IDs, so changing the E2EE passphrase does not derive a new key automatically."
)}
</InfoNote>
{/if}
{#if idDerivationConfigured}
<InfoNote title={translateMessage("Configured")}>
{translateMessage("The saved ID key is configured. Its source cannot be shown again.")}
</InfoNote>
<button type="button" onclick={() => (recoveryCodeVisible = !recoveryCodeVisible)}>
{translateMessage(recoveryCodeVisible ? "Hide current recovery code" : "Show current recovery code")}
</button>
{#if recoveryCodeVisible}
<InputRow label={translateMessage("Current ID recovery code")}>
<input type="text" readonly value={recoveryCode} aria-label={translateMessage("Current ID recovery code")} />
<button type="button" onclick={copyRecoveryCode}>{translateMessage("Copy recovery code")}</button>
</InputRow>
{#if recoveryCodeCopied}
<InfoNote>{translateMessage("Recovery code copied.")}</InfoNote>
{/if}
{/if}
{/if}
{#if encryptionSettings.encrypt}
{#if idConfigurationChoice === "custom"}
<fieldset class="sls-id-choices sls-id-custom-choices">
<legend>{translateMessage("How to set the ID key")}</legend>
<label class="sls-id-choice">
<input
type="radio"
name="id-custom-choice"
value="passphrase"
bind:group={idCustomChoice}
onchange={selectIdCustomSource}
/>
<span>{translateMessage("Derive from current E2EE passphrase")}</span>
</label>
<label class="sls-id-choice">
<input
type="radio"
name="id-custom-choice"
value="source"
bind:group={idCustomChoice}
onchange={selectIdCustomSource}
/>
<span>{translateMessage("Enter an ID source")}</span>
</label>
<label class="sls-id-choice">
<input
type="radio"
name="id-custom-choice"
value="recovery"
bind:group={idCustomChoice}
onchange={selectIdCustomSource}
/>
<span>{translateMessage("Import an ID recovery code")}</span>
</label>
</fieldset>
{#if idCustomChoice === "source" || idCustomChoice === "recovery"}
<InputRow
label={translateMessage(idCustomChoice === "source" ? "ID source" : "ID recovery code")}
>
<Password
name="id-derivation-source"
placeholder={translateMessage(
idCustomChoice === "source" ? "Enter an ID source" : "Enter an ID recovery code"
)}
bind:value={idDerivationSource}
/>
</InputRow>
{/if}
{/if}
{#if idDerivationConfigured && idConfigurationChoice !== "keep"}
<InfoNote>
{translateMessage("The displayed recovery code belongs to the current key. Reopen this dialogue after saving to copy the replacement key.")}
</InfoNote>
{/if}
{#if idConfigurationChoice === "custom" && idCustomChoice === "source"}
<InfoNote>
{translateMessage("Choose a long, unpredictable source. It is used once and cannot be shown again after saving. A recovery code can be displayed on this device later. This input also accepts a tagged recovery code.")}
</InfoNote>
{:else if idConfigurationChoice === "custom" && idCustomChoice === "recovery"}
<InfoNote>
{translateMessage("Paste a tagged recovery code from an existing device to restore the same ID key.")}
</InfoNote>
{:else if idConfigurationChoice === "random"}
<InfoNote warning>
{translateMessage("For recovery after losing every device, save the recovery code after setup or choose an ID source you can reproduce.")}
</InfoNote>
{:else if idConfigurationChoice === "custom" && idCustomChoice === "passphrase"}
<InfoNote warning>
{translateMessage(
"The ID key is derived from the current E2EE passphrase and saved separately. Changing that passphrase later does not change the saved ID key. To reduce the risk of guessing that passphrase from known IDs, use a separate, unpredictable ID source instead."
)}
</InfoNote>
{/if}
{#if idDerivationConfigured && idConfigurationChoice === "custom" && idCustomChoice !== "passphrase"}
<InfoNote>{translateMessage("Leave this input empty to keep the saved ID key.")}</InfoNote>
{/if}
{/if}
<InfoNote error visible={idDerivationError !== ""}>{idDerivationError}</InfoNote>
{/if}
<InputRow label="Encrypt internal file Properties">
<input
type="checkbox"
@@ -164,4 +424,41 @@
width: auto;
min-width: 8em;
}
.sls-id-choices {
border: 0;
display: flex;
flex-direction: column;
gap: 0.35em;
margin: 0;
min-width: 0;
padding: 0;
}
.sls-id-choices legend {
margin-bottom: 0.35em;
}
.sls-id-choices:disabled {
opacity: 0.6;
}
.sls-id-custom-choices {
margin-left: 1.5em;
}
.sls-id-choice {
align-items: flex-start;
display: flex;
gap: 0.5em;
}
.sls-id-choice input[type="radio"] {
flex: none;
margin-top: 0.25em;
}
.sls-id-choice-text {
display: flex;
flex-direction: column;
}
.sls-current-id-configuration {
color: var(--text-muted);
display: block;
font-size: var(--font-ui-smaller);
margin-top: 0.15em;
}
</style>
@@ -110,6 +110,10 @@ export type SetupRemoteResultType = typeof TYPE_COUCHDB | typeof TYPE_BUCKET | t
export type UseSetupURIResultType = typeof TYPE_CANCELLED | ObsidianLiveSyncSettings;
export type SetupRemoteE2EEResultType = typeof TYPE_CANCELLED | EncryptionSettings;
export type SetupRemoteE2EEInitialData = {
settings: EncryptionSettings;
newVault: boolean;
};
export type SetupRemoteBucketResultType = typeof TYPE_CANCELLED | BucketSyncSetting;
+4
View File
@@ -15,6 +15,8 @@ import {
runReviewHarnessVaultRoundTrip,
} from "@/features/ReviewHarness/reviewHarnessVaultFixture";
import type { CompatibilityReviewController } from "./compatibilityReview";
import { runReviewHarnessIdBenchmark } from "@/features/ReviewHarness/reviewHarnessIdBenchmark";
import { createIdBenchmarkOperations } from "@/features/ReviewHarness/reviewHarnessIdBenchmarkRuntime";
async function runVaultRoundTrip(plugin: ObsidianLiveSyncPlugin): Promise<ReviewHarnessScenarioResult> {
const vault = plugin.app.vault;
@@ -58,6 +60,8 @@ export function useReviewHarness(
getCompatibilityPause: () => compatibilityReview.pendingPause,
openCompatibilityReview: () => compatibilityReview.openReview(),
runVaultRoundTrip: () => runVaultRoundTrip(plugin),
runIdBenchmark: async () =>
runReviewHarnessIdBenchmark(await createIdBenchmarkOperations(), activeWindow.performance),
readContinuation: () => services.setting.getSmallConfig(REVIEW_HARNESS_STATE_KEY),
writeContinuation: (value) => services.setting.setSmallConfig(REVIEW_HARNESS_STATE_KEY, value),
deleteContinuation: () => services.setting.deleteSmallConfig(REVIEW_HARNESS_STATE_KEY),