mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
Matrix: harden IndexedDB snapshot persistence
This commit is contained in:
@@ -342,6 +342,25 @@ recovery key file (`recovery-key.json`), IndexedDB snapshot (`crypto-idb-snapsho
|
||||
thread bindings (`thread-bindings.json`), and startup verification state (`startup-verification.json`)
|
||||
when those features are in use.
|
||||
|
||||
### Node crypto store model
|
||||
|
||||
Matrix E2EE in this plugin uses the official `matrix-js-sdk` Rust crypto path in Node.
|
||||
That path expects IndexedDB-backed persistence when you want crypto state to survive restarts.
|
||||
|
||||
OpenClaw currently provides that in Node by:
|
||||
|
||||
- using `fake-indexeddb` as the IndexedDB API shim expected by the SDK
|
||||
- restoring the Rust crypto IndexedDB contents from `crypto-idb-snapshot.json` before `initRustCrypto`
|
||||
- persisting the updated IndexedDB contents back to `crypto-idb-snapshot.json` after init and during runtime
|
||||
|
||||
This is compatibility/storage plumbing, not a custom crypto implementation.
|
||||
The snapshot file is sensitive runtime state and is stored with restrictive file permissions.
|
||||
Under OpenClaw's security model, the gateway host and local OpenClaw state directory are already inside the trusted operator boundary, so this is primarily an operational durability concern rather than a separate remote trust boundary.
|
||||
|
||||
Planned improvement:
|
||||
|
||||
- add SecretRef support for persistent Matrix key material so recovery keys and related store-encryption secrets can be sourced from OpenClaw secrets providers instead of only local files
|
||||
|
||||
## Automatic verification notices
|
||||
|
||||
Matrix now posts verification lifecycle notices directly into the Matrix room as `m.notice` messages.
|
||||
|
||||
173
extensions/matrix/src/matrix/sdk/idb-persistence.test.ts
Normal file
173
extensions/matrix/src/matrix/sdk/idb-persistence.test.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import "fake-indexeddb/auto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { persistIdbToDisk, restoreIdbFromDisk } from "./idb-persistence.js";
|
||||
import { LogService } from "./logger.js";
|
||||
|
||||
async function clearAllIndexedDbState(): Promise<void> {
|
||||
const databases = await indexedDB.databases();
|
||||
await Promise.all(
|
||||
databases
|
||||
.map((entry) => entry.name)
|
||||
.filter((name): name is string => Boolean(name))
|
||||
.map(
|
||||
(name) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const req = indexedDB.deleteDatabase(name);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error);
|
||||
req.onblocked = () => resolve();
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function seedDatabase(params: {
|
||||
name: string;
|
||||
version?: number;
|
||||
storeName: string;
|
||||
records: Array<{ key: IDBValidKey; value: unknown }>;
|
||||
}): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const req = indexedDB.open(params.name, params.version ?? 1);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains(params.storeName)) {
|
||||
db.createObjectStore(params.storeName);
|
||||
}
|
||||
};
|
||||
req.onsuccess = () => {
|
||||
const db = req.result;
|
||||
const tx = db.transaction(params.storeName, "readwrite");
|
||||
const store = tx.objectStore(params.storeName);
|
||||
for (const record of params.records) {
|
||||
store.put(record.value, record.key);
|
||||
}
|
||||
tx.oncomplete = () => {
|
||||
db.close();
|
||||
resolve();
|
||||
};
|
||||
tx.onerror = () => reject(tx.error);
|
||||
};
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function readDatabaseRecords(params: {
|
||||
name: string;
|
||||
version?: number;
|
||||
storeName: string;
|
||||
}): Promise<Array<{ key: IDBValidKey; value: unknown }>> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(params.name, params.version ?? 1);
|
||||
req.onsuccess = () => {
|
||||
const db = req.result;
|
||||
const tx = db.transaction(params.storeName, "readonly");
|
||||
const store = tx.objectStore(params.storeName);
|
||||
const keysReq = store.getAllKeys();
|
||||
const valuesReq = store.getAll();
|
||||
let keys: IDBValidKey[] | null = null;
|
||||
let values: unknown[] | null = null;
|
||||
|
||||
const maybeResolve = () => {
|
||||
if (!keys || !values) {
|
||||
return;
|
||||
}
|
||||
db.close();
|
||||
resolve(keys.map((key, index) => ({ key, value: values[index] })));
|
||||
};
|
||||
|
||||
keysReq.onsuccess = () => {
|
||||
keys = keysReq.result;
|
||||
maybeResolve();
|
||||
};
|
||||
valuesReq.onsuccess = () => {
|
||||
values = valuesReq.result;
|
||||
maybeResolve();
|
||||
};
|
||||
keysReq.onerror = () => reject(keysReq.error);
|
||||
valuesReq.onerror = () => reject(valuesReq.error);
|
||||
};
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
describe("Matrix IndexedDB persistence", () => {
|
||||
let tmpDir: string;
|
||||
let warnSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-idb-persist-"));
|
||||
warnSpy = vi.spyOn(LogService, "warn").mockImplementation(() => {});
|
||||
await clearAllIndexedDbState();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
warnSpy.mockRestore();
|
||||
await clearAllIndexedDbState();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("persists and restores database contents for the selected prefix", async () => {
|
||||
const snapshotPath = path.join(tmpDir, "crypto-idb-snapshot.json");
|
||||
await seedDatabase({
|
||||
name: "openclaw-matrix-test::matrix-sdk-crypto",
|
||||
storeName: "sessions",
|
||||
records: [{ key: "room-1", value: { session: "abc123" } }],
|
||||
});
|
||||
await seedDatabase({
|
||||
name: "other-prefix::matrix-sdk-crypto",
|
||||
storeName: "sessions",
|
||||
records: [{ key: "room-2", value: { session: "should-not-restore" } }],
|
||||
});
|
||||
|
||||
await persistIdbToDisk({
|
||||
snapshotPath,
|
||||
databasePrefix: "openclaw-matrix-test",
|
||||
});
|
||||
expect(fs.existsSync(snapshotPath)).toBe(true);
|
||||
|
||||
const mode = fs.statSync(snapshotPath).mode & 0o777;
|
||||
expect(mode).toBe(0o600);
|
||||
|
||||
await clearAllIndexedDbState();
|
||||
|
||||
const restored = await restoreIdbFromDisk(snapshotPath);
|
||||
expect(restored).toBe(true);
|
||||
|
||||
const restoredRecords = await readDatabaseRecords({
|
||||
name: "openclaw-matrix-test::matrix-sdk-crypto",
|
||||
storeName: "sessions",
|
||||
});
|
||||
expect(restoredRecords).toEqual([{ key: "room-1", value: { session: "abc123" } }]);
|
||||
|
||||
const dbs = await indexedDB.databases();
|
||||
expect(dbs.some((entry) => entry.name === "other-prefix::matrix-sdk-crypto")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false and logs a warning for malformed snapshots", async () => {
|
||||
const snapshotPath = path.join(tmpDir, "bad-snapshot.json");
|
||||
fs.writeFileSync(snapshotPath, JSON.stringify([{ nope: true }]), "utf8");
|
||||
|
||||
const restored = await restoreIdbFromDisk(snapshotPath);
|
||||
expect(restored).toBe(false);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
"IdbPersistence",
|
||||
expect.stringContaining(`Failed to restore IndexedDB snapshot from ${snapshotPath}:`),
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false for empty snapshot payloads without restoring databases", async () => {
|
||||
const snapshotPath = path.join(tmpDir, "empty-snapshot.json");
|
||||
fs.writeFileSync(snapshotPath, JSON.stringify([]), "utf8");
|
||||
|
||||
const restored = await restoreIdbFromDisk(snapshotPath);
|
||||
expect(restored).toBe(false);
|
||||
|
||||
const dbs = await indexedDB.databases();
|
||||
expect(dbs).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,75 @@ type IdbDatabaseSnapshot = {
|
||||
stores: IdbStoreSnapshot[];
|
||||
};
|
||||
|
||||
function isValidIdbIndexSnapshot(value: unknown): value is IdbStoreSnapshot["indexes"][number] {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
const candidate = value as Partial<IdbStoreSnapshot["indexes"][number]>;
|
||||
return (
|
||||
typeof candidate.name === "string" &&
|
||||
(typeof candidate.keyPath === "string" ||
|
||||
(Array.isArray(candidate.keyPath) &&
|
||||
candidate.keyPath.every((entry) => typeof entry === "string"))) &&
|
||||
typeof candidate.multiEntry === "boolean" &&
|
||||
typeof candidate.unique === "boolean"
|
||||
);
|
||||
}
|
||||
|
||||
function isValidIdbRecordSnapshot(value: unknown): value is IdbStoreSnapshot["records"][number] {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
return "key" in value && "value" in value;
|
||||
}
|
||||
|
||||
function isValidIdbStoreSnapshot(value: unknown): value is IdbStoreSnapshot {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
const candidate = value as Partial<IdbStoreSnapshot>;
|
||||
const validKeyPath =
|
||||
candidate.keyPath === null ||
|
||||
typeof candidate.keyPath === "string" ||
|
||||
(Array.isArray(candidate.keyPath) &&
|
||||
candidate.keyPath.every((entry) => typeof entry === "string"));
|
||||
return (
|
||||
typeof candidate.name === "string" &&
|
||||
validKeyPath &&
|
||||
typeof candidate.autoIncrement === "boolean" &&
|
||||
Array.isArray(candidate.indexes) &&
|
||||
candidate.indexes.every((entry) => isValidIdbIndexSnapshot(entry)) &&
|
||||
Array.isArray(candidate.records) &&
|
||||
candidate.records.every((entry) => isValidIdbRecordSnapshot(entry))
|
||||
);
|
||||
}
|
||||
|
||||
function isValidIdbDatabaseSnapshot(value: unknown): value is IdbDatabaseSnapshot {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
const candidate = value as Partial<IdbDatabaseSnapshot>;
|
||||
return (
|
||||
typeof candidate.name === "string" &&
|
||||
typeof candidate.version === "number" &&
|
||||
Number.isFinite(candidate.version) &&
|
||||
candidate.version > 0 &&
|
||||
Array.isArray(candidate.stores) &&
|
||||
candidate.stores.every((entry) => isValidIdbStoreSnapshot(entry))
|
||||
);
|
||||
}
|
||||
|
||||
function parseSnapshotPayload(data: string): IdbDatabaseSnapshot[] | null {
|
||||
const parsed = JSON.parse(data) as unknown;
|
||||
if (!Array.isArray(parsed) || parsed.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (!parsed.every((entry) => isValidIdbDatabaseSnapshot(entry))) {
|
||||
throw new Error("Malformed IndexedDB snapshot payload");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function idbReq<T>(req: IDBRequest<T>): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
@@ -132,8 +201,8 @@ export async function restoreIdbFromDisk(snapshotPath?: string): Promise<boolean
|
||||
for (const resolvedPath of candidatePaths) {
|
||||
try {
|
||||
const data = fs.readFileSync(resolvedPath, "utf8");
|
||||
const snapshot: IdbDatabaseSnapshot[] = JSON.parse(data);
|
||||
if (!Array.isArray(snapshot) || snapshot.length === 0) {
|
||||
const snapshot = parseSnapshotPayload(data);
|
||||
if (!snapshot) {
|
||||
continue;
|
||||
}
|
||||
await restoreIndexedDatabases(snapshot);
|
||||
@@ -142,7 +211,12 @@ export async function restoreIdbFromDisk(snapshotPath?: string): Promise<boolean
|
||||
`Restored ${snapshot.length} IndexedDB database(s) from ${resolvedPath}`,
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
} catch (err) {
|
||||
LogService.warn(
|
||||
"IdbPersistence",
|
||||
`Failed to restore IndexedDB snapshot from ${resolvedPath}:`,
|
||||
err,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -159,6 +233,7 @@ export async function persistIdbToDisk(params?: {
|
||||
if (snapshot.length === 0) return;
|
||||
fs.mkdirSync(path.dirname(snapshotPath), { recursive: true });
|
||||
fs.writeFileSync(snapshotPath, JSON.stringify(snapshot));
|
||||
fs.chmodSync(snapshotPath, 0o600);
|
||||
LogService.debug(
|
||||
"IdbPersistence",
|
||||
`Persisted ${snapshot.length} IndexedDB database(s) to ${snapshotPath}`,
|
||||
|
||||
Reference in New Issue
Block a user