Matrix: harden IndexedDB snapshot persistence

This commit is contained in:
Gustavo Madeira Santana
2026-03-10 14:38:34 -04:00
parent 87834490e8
commit 9720b75191
3 changed files with 270 additions and 3 deletions

View File

@@ -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.

View 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([]);
});
});

View File

@@ -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}`,