From 9720b751913e0eb6f8493eb866958eb41bf00481 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Tue, 10 Mar 2026 14:38:34 -0400 Subject: [PATCH] Matrix: harden IndexedDB snapshot persistence --- docs/channels/matrix.md | 19 ++ .../src/matrix/sdk/idb-persistence.test.ts | 173 ++++++++++++++++++ .../matrix/src/matrix/sdk/idb-persistence.ts | 81 +++++++- 3 files changed, 270 insertions(+), 3 deletions(-) create mode 100644 extensions/matrix/src/matrix/sdk/idb-persistence.test.ts diff --git a/docs/channels/matrix.md b/docs/channels/matrix.md index 2911a79f73a..93e94030d81 100644 --- a/docs/channels/matrix.md +++ b/docs/channels/matrix.md @@ -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. diff --git a/extensions/matrix/src/matrix/sdk/idb-persistence.test.ts b/extensions/matrix/src/matrix/sdk/idb-persistence.test.ts new file mode 100644 index 00000000000..054638e2bd8 --- /dev/null +++ b/extensions/matrix/src/matrix/sdk/idb-persistence.test.ts @@ -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 { + const databases = await indexedDB.databases(); + await Promise.all( + databases + .map((entry) => entry.name) + .filter((name): name is string => Boolean(name)) + .map( + (name) => + new Promise((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 { + await new Promise((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> { + 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; + + 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([]); + }); +}); diff --git a/extensions/matrix/src/matrix/sdk/idb-persistence.ts b/extensions/matrix/src/matrix/sdk/idb-persistence.ts index 7686b920662..51f86c8e175 100644 --- a/extensions/matrix/src/matrix/sdk/idb-persistence.ts +++ b/extensions/matrix/src/matrix/sdk/idb-persistence.ts @@ -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; + 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; + 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; + 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(req: IDBRequest): Promise { return new Promise((resolve, reject) => { req.onsuccess = () => resolve(req.result); @@ -132,8 +201,8 @@ export async function restoreIdbFromDisk(snapshotPath?: string): Promise