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`)
|
thread bindings (`thread-bindings.json`), and startup verification state (`startup-verification.json`)
|
||||||
when those features are in use.
|
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
|
## Automatic verification notices
|
||||||
|
|
||||||
Matrix now posts verification lifecycle notices directly into the Matrix room as `m.notice` messages.
|
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[];
|
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> {
|
function idbReq<T>(req: IDBRequest<T>): Promise<T> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
req.onsuccess = () => resolve(req.result);
|
req.onsuccess = () => resolve(req.result);
|
||||||
@@ -132,8 +201,8 @@ export async function restoreIdbFromDisk(snapshotPath?: string): Promise<boolean
|
|||||||
for (const resolvedPath of candidatePaths) {
|
for (const resolvedPath of candidatePaths) {
|
||||||
try {
|
try {
|
||||||
const data = fs.readFileSync(resolvedPath, "utf8");
|
const data = fs.readFileSync(resolvedPath, "utf8");
|
||||||
const snapshot: IdbDatabaseSnapshot[] = JSON.parse(data);
|
const snapshot = parseSnapshotPayload(data);
|
||||||
if (!Array.isArray(snapshot) || snapshot.length === 0) {
|
if (!snapshot) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await restoreIndexedDatabases(snapshot);
|
await restoreIndexedDatabases(snapshot);
|
||||||
@@ -142,7 +211,12 @@ export async function restoreIdbFromDisk(snapshotPath?: string): Promise<boolean
|
|||||||
`Restored ${snapshot.length} IndexedDB database(s) from ${resolvedPath}`,
|
`Restored ${snapshot.length} IndexedDB database(s) from ${resolvedPath}`,
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
LogService.warn(
|
||||||
|
"IdbPersistence",
|
||||||
|
`Failed to restore IndexedDB snapshot from ${resolvedPath}:`,
|
||||||
|
err,
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,6 +233,7 @@ export async function persistIdbToDisk(params?: {
|
|||||||
if (snapshot.length === 0) return;
|
if (snapshot.length === 0) return;
|
||||||
fs.mkdirSync(path.dirname(snapshotPath), { recursive: true });
|
fs.mkdirSync(path.dirname(snapshotPath), { recursive: true });
|
||||||
fs.writeFileSync(snapshotPath, JSON.stringify(snapshot));
|
fs.writeFileSync(snapshotPath, JSON.stringify(snapshot));
|
||||||
|
fs.chmodSync(snapshotPath, 0o600);
|
||||||
LogService.debug(
|
LogService.debug(
|
||||||
"IdbPersistence",
|
"IdbPersistence",
|
||||||
`Persisted ${snapshot.length} IndexedDB database(s) to ${snapshotPath}`,
|
`Persisted ${snapshot.length} IndexedDB database(s) to ${snapshotPath}`,
|
||||||
|
|||||||
Reference in New Issue
Block a user