mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-26 14:32:58 +00:00
Test Security Seed refresh in Real Obsidian
This commit is contained in:
@@ -42,6 +42,12 @@ export type CouchDbDatabaseInfo = {
|
||||
update_seq: number | string;
|
||||
};
|
||||
|
||||
export type CouchDbPutResponse = {
|
||||
ok: boolean;
|
||||
id: string;
|
||||
rev: string;
|
||||
};
|
||||
|
||||
function parseEnvFile(content: string): Record<string, string> {
|
||||
const entries = content
|
||||
.split(/\r?\n/u)
|
||||
@@ -79,6 +85,14 @@ function databaseUrl(config: Pick<CouchDbConfig, "uri">, dbName: string, suffix
|
||||
return `${config.uri.replace(/\/+$/u, "")}/${encodeURIComponent(dbName)}${suffix}`;
|
||||
}
|
||||
|
||||
function documentSuffix(documentId: string): string {
|
||||
const localPrefix = "_local/";
|
||||
if (documentId.startsWith(localPrefix)) {
|
||||
return `/_local/${encodeURIComponent(documentId.slice(localPrefix.length))}`;
|
||||
}
|
||||
return `/${encodeURIComponent(documentId)}`;
|
||||
}
|
||||
|
||||
async function couchDbRequest(
|
||||
config: Pick<CouchDbConfig, "uri" | "username" | "password">,
|
||||
path: string,
|
||||
@@ -146,8 +160,8 @@ export async function putCouchDbDocument(
|
||||
config: CouchDbConfig,
|
||||
dbName: string,
|
||||
document: CouchDbDocument
|
||||
): Promise<void> {
|
||||
const response = await fetch(databaseUrl(config, dbName, `/${encodeURIComponent(document._id)}`), {
|
||||
): Promise<CouchDbPutResponse> {
|
||||
const response = await fetch(databaseUrl(config, dbName, documentSuffix(document._id)), {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
authorization: authHeader(config),
|
||||
@@ -160,6 +174,23 @@ export async function putCouchDbDocument(
|
||||
`Failed to write CouchDB document ${document._id}. HTTP ${response.status}: ${await response.text()}`
|
||||
);
|
||||
}
|
||||
return (await response.json()) as CouchDbPutResponse;
|
||||
}
|
||||
|
||||
export async function fetchCouchDbDocument(
|
||||
config: CouchDbConfig,
|
||||
dbName: string,
|
||||
documentId: string
|
||||
): Promise<CouchDbDocument> {
|
||||
const response = await fetch(databaseUrl(config, dbName, documentSuffix(documentId)), {
|
||||
headers: { authorization: authHeader(config) },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to read CouchDB document ${documentId}. HTTP ${response.status}: ${await response.text()}`
|
||||
);
|
||||
}
|
||||
return (await response.json()) as CouchDbDocument;
|
||||
}
|
||||
|
||||
export async function deleteCouchDbDatabase(config: CouchDbConfig, dbName: string): Promise<void> {
|
||||
@@ -174,6 +205,19 @@ export async function deleteCouchDbDatabase(config: CouchDbConfig, dbName: strin
|
||||
}
|
||||
}
|
||||
|
||||
export async function couchDbDatabaseExists(config: CouchDbConfig, dbName: string): Promise<boolean> {
|
||||
const response = await fetch(databaseUrl(config, dbName), {
|
||||
headers: { authorization: authHeader(config) },
|
||||
});
|
||||
if (response.status === 404) {
|
||||
return false;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to inspect CouchDB ${dbName}. HTTP ${response.status}: ${await response.text()}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function fetchAllCouchDbDocs(config: CouchDbConfig, dbName: string): Promise<CouchDbAllDocsResponse> {
|
||||
const response = await fetch(databaseUrl(config, dbName, "/_all_docs?include_docs=true"), {
|
||||
headers: { authorization: authHeader(config) },
|
||||
|
||||
@@ -10,6 +10,7 @@ vi.mock("./cli.ts", () => ({ evalObsidianJson }));
|
||||
import {
|
||||
assertE2eCompatibilityMarker,
|
||||
createE2eCouchDbPluginData,
|
||||
waitForLiveSyncCoreReady,
|
||||
type CompatibilityMarkerState,
|
||||
} from "./liveSyncWorkflow.ts";
|
||||
|
||||
@@ -54,3 +55,25 @@ describe("configured CouchDB fixture", () => {
|
||||
expect(pluginData.activeConfigurationId).toBe(Object.keys(remoteConfigurations ?? {})[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Real Obsidian core readiness", () => {
|
||||
it("retries while the plug-in core is temporarily unavailable during reload", async () => {
|
||||
evalObsidianJson.mockReset();
|
||||
evalObsidianJson
|
||||
.mockRejectedValueOnce(new Error("Cannot read properties of undefined (reading 'core')"))
|
||||
.mockResolvedValueOnce({
|
||||
databaseReady: true,
|
||||
appReady: true,
|
||||
configured: true,
|
||||
remoteType: "",
|
||||
settingVersion: 10,
|
||||
suspended: false,
|
||||
});
|
||||
|
||||
await expect(waitForLiveSyncCoreReady("obsidian-cli", {}, 1000)).resolves.toMatchObject({
|
||||
databaseReady: true,
|
||||
appReady: true,
|
||||
});
|
||||
expect(evalObsidianJson).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -353,31 +353,50 @@ export async function waitForLiveSyncCoreReady(
|
||||
): Promise<CoreReadiness> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastReadiness: CoreReadiness | undefined;
|
||||
let lastError: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
lastReadiness = await evalObsidianJson<CoreReadiness>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const settings=core.services.setting.currentSettings();",
|
||||
"return JSON.stringify({",
|
||||
"databaseReady:core.services.database.isDatabaseReady(),",
|
||||
"appReady:core.services.appLifecycle.isReady(),",
|
||||
"configured:settings?.isConfigured===true,",
|
||||
"remoteType:settings?.remoteType??'',",
|
||||
"settingVersion:settings?.settingVersion,",
|
||||
"suspended:core.services.appLifecycle.isSuspended(),",
|
||||
"});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
try {
|
||||
lastReadiness = await evalObsidianJson<CoreReadiness>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
"const core=app.plugins.plugins['obsidian-livesync']?.core;",
|
||||
"if(!core) return JSON.stringify({databaseReady:false,appReady:false});",
|
||||
"const settings=core.services.setting.currentSettings();",
|
||||
"return JSON.stringify({",
|
||||
"databaseReady:core.services.database.isDatabaseReady(),",
|
||||
"appReady:core.services.appLifecycle.isReady(),",
|
||||
"configured:settings?.isConfigured===true,",
|
||||
"remoteType:settings?.remoteType??'',",
|
||||
"settingVersion:settings?.settingVersion,",
|
||||
"suspended:core.services.appLifecycle.isSuspended(),",
|
||||
"});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
lastError = undefined;
|
||||
} catch (error) {
|
||||
// Obsidian reloads the renderer while enabling the plug-in. During
|
||||
// that short window the CLI can reach the Vault before the plug-in
|
||||
// catalogue has exposed its core. This is a readiness state, not a
|
||||
// failed scenario, so retain the error for the eventual timeout.
|
||||
lastError = error;
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
continue;
|
||||
}
|
||||
if (lastReadiness.databaseReady && lastReadiness.appReady) {
|
||||
return lastReadiness;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
throw new Error(`Timed out waiting for Self-hosted LiveSync core readiness: ${JSON.stringify(lastReadiness)}`);
|
||||
const errorSuffix =
|
||||
lastError === undefined
|
||||
? ""
|
||||
: ` Last error: ${lastError instanceof Error ? lastError.message : String(lastError)}`;
|
||||
throw new Error(
|
||||
`Timed out waiting for Self-hosted LiveSync core readiness: ${JSON.stringify(lastReadiness)}${errorSuffix}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
SECURITY_SEED_DOCUMENT_ID,
|
||||
changedSynchronisationParameterFields,
|
||||
fingerprintSecuritySeed,
|
||||
replaceSecuritySeed,
|
||||
requireSecuritySeedDocument,
|
||||
snapshotSecuritySeedDocument,
|
||||
} from "./securitySeed.ts";
|
||||
|
||||
const seedA = Buffer.alloc(32, 1).toString("base64");
|
||||
const seedB = Buffer.alloc(32, 2).toString("base64");
|
||||
|
||||
describe("Security Seed E2E evidence", () => {
|
||||
it("reports stable, non-secret fingerprints", () => {
|
||||
expect(fingerprintSecuritySeed(seedA)).toMatch(/^sha256:[0-9a-f]{16}$/u);
|
||||
expect(fingerprintSecuritySeed(seedA)).toBe(fingerprintSecuritySeed(seedA));
|
||||
expect(fingerprintSecuritySeed(seedA)).not.toBe(fingerprintSecuritySeed(seedB));
|
||||
});
|
||||
|
||||
it("redacts the Seed from the machine-readable document snapshot", () => {
|
||||
const document = requireSecuritySeedDocument({
|
||||
_id: SECURITY_SEED_DOCUMENT_ID,
|
||||
_rev: "0-1",
|
||||
type: "syncinfo",
|
||||
protocolVersion: 2,
|
||||
pbkdf2salt: seedA,
|
||||
});
|
||||
|
||||
const snapshot = snapshotSecuritySeedDocument(document);
|
||||
|
||||
expect(snapshot).toEqual({
|
||||
id: SECURITY_SEED_DOCUMENT_ID,
|
||||
revision: "0-1",
|
||||
fingerprint: fingerprintSecuritySeed(seedA),
|
||||
fields: {
|
||||
type: "syncinfo",
|
||||
protocolVersion: 2,
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(snapshot)).not.toContain(seedA);
|
||||
});
|
||||
|
||||
it("replaces only the Seed and identifies later synchronisation-parameter changes", () => {
|
||||
const before = requireSecuritySeedDocument({
|
||||
_id: SECURITY_SEED_DOCUMENT_ID,
|
||||
_rev: "0-1",
|
||||
type: "syncinfo",
|
||||
protocolVersion: 2,
|
||||
pbkdf2salt: seedA,
|
||||
});
|
||||
const replaced = replaceSecuritySeed(before, seedB);
|
||||
const laterRevision = {
|
||||
...replaced,
|
||||
_rev: "0-3",
|
||||
};
|
||||
|
||||
expect(before.pbkdf2salt).toBe(seedA);
|
||||
expect(replaced.pbkdf2salt).toBe(seedB);
|
||||
expect(changedSynchronisationParameterFields(before, replaced)).toEqual(["pbkdf2salt"]);
|
||||
expect(changedSynchronisationParameterFields(replaced, laterRevision)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import type { CouchDbDocument } from "./couchdb.ts";
|
||||
|
||||
export const SECURITY_SEED_DOCUMENT_ID = "_local/obsidian_livesync_sync_parameters";
|
||||
|
||||
export type SecuritySeedDocument = CouchDbDocument & {
|
||||
_id: typeof SECURITY_SEED_DOCUMENT_ID;
|
||||
_rev: string;
|
||||
pbkdf2salt: string;
|
||||
};
|
||||
|
||||
export type SecuritySeedDocumentSnapshot = {
|
||||
id: string;
|
||||
revision: string;
|
||||
fingerprint: string;
|
||||
fields: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function decodeSecuritySeed(seed: string): Buffer {
|
||||
const bytes = Buffer.from(seed, "base64");
|
||||
if (seed.length === 0 || bytes.length === 0) {
|
||||
throw new Error("The Security Seed is empty or is not valid base64.");
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function createSecuritySeed(): string {
|
||||
return randomBytes(32).toString("base64");
|
||||
}
|
||||
|
||||
export function fingerprintSecuritySeed(seed: string): string {
|
||||
const bytes = Uint8Array.from(decodeSecuritySeed(seed));
|
||||
return `sha256:${createHash("sha256").update(bytes).digest("hex").slice(0, 16)}`;
|
||||
}
|
||||
|
||||
export function requireSecuritySeedDocument(document: CouchDbDocument): SecuritySeedDocument {
|
||||
if (document._id !== SECURITY_SEED_DOCUMENT_ID) {
|
||||
throw new Error(`Unexpected synchronisation-parameter document: ${document._id}`);
|
||||
}
|
||||
if (typeof document._rev !== "string" || document._rev.length === 0) {
|
||||
throw new Error("The synchronisation-parameter document does not have a revision.");
|
||||
}
|
||||
if (typeof document.pbkdf2salt !== "string") {
|
||||
throw new Error("The synchronisation-parameter document does not have a Security Seed.");
|
||||
}
|
||||
decodeSecuritySeed(document.pbkdf2salt);
|
||||
return document as SecuritySeedDocument;
|
||||
}
|
||||
|
||||
export function replaceSecuritySeed(document: SecuritySeedDocument, replacementSeed: string): SecuritySeedDocument {
|
||||
decodeSecuritySeed(replacementSeed);
|
||||
return {
|
||||
...document,
|
||||
pbkdf2salt: replacementSeed,
|
||||
};
|
||||
}
|
||||
|
||||
export function snapshotSecuritySeedDocument(document: SecuritySeedDocument): SecuritySeedDocumentSnapshot {
|
||||
const { _id, _rev, pbkdf2salt, ...fields } = document;
|
||||
return {
|
||||
id: _id,
|
||||
revision: _rev,
|
||||
fingerprint: fingerprintSecuritySeed(pbkdf2salt),
|
||||
fields,
|
||||
};
|
||||
}
|
||||
|
||||
export function changedSynchronisationParameterFields(
|
||||
before: SecuritySeedDocument,
|
||||
after: SecuritySeedDocument
|
||||
): string[] {
|
||||
const ignoredFields = new Set(["_rev"]);
|
||||
return [...new Set([...Object.keys(before), ...Object.keys(after)])]
|
||||
.filter((key) => !ignoredFields.has(key))
|
||||
.filter((key) => JSON.stringify(before[key]) !== JSON.stringify(after[key]))
|
||||
.sort();
|
||||
}
|
||||
Reference in New Issue
Block a user