Matrix: keep fresh devices out of stale storage roots

This commit is contained in:
Gustavo Madeira Santana 2026-03-13 13:03:48 +00:00
parent c1f4cbf6e7
commit d58e51e46b
No known key found for this signature in database
6 changed files with 159 additions and 2 deletions

View File

@ -31,6 +31,7 @@ export async function createMatrixClient(params: {
userId,
accessToken: params.accessToken,
accountId: params.accountId,
deviceId: params.deviceId,
env,
});
await maybeMigrateLegacyStorage({
@ -44,6 +45,7 @@ export async function createMatrixClient(params: {
homeserver,
userId,
accountId: params.accountId,
deviceId: params.deviceId,
});
const cryptoDatabasePrefix = `openclaw-matrix-${storagePaths.accountKey}-${storagePaths.tokenHash}`;

View File

@ -316,4 +316,54 @@ describe("matrix client storage paths", () => {
expect(resolvedPaths.rootDir).toBe(oldStoragePaths.rootDir);
expect(resolvedPaths.tokenHash).toBe(oldStoragePaths.tokenHash);
});
it("does not reuse a populated sibling storage root from a different device", () => {
const stateDir = setupStateDir();
const oldStoragePaths = resolveMatrixStoragePaths({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "secret-token-old",
deviceId: "OLDDEVICE",
env: {},
});
fs.mkdirSync(oldStoragePaths.rootDir, { recursive: true });
fs.writeFileSync(oldStoragePaths.storagePath, '{"legacy":true}');
fs.writeFileSync(
path.join(oldStoragePaths.rootDir, "startup-verification.json"),
JSON.stringify({ deviceId: "OLDDEVICE" }, null, 2),
);
const newerCanonicalPaths = resolveMatrixAccountStorageRoot({
stateDir,
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "secret-token-new",
});
fs.mkdirSync(newerCanonicalPaths.rootDir, { recursive: true });
fs.writeFileSync(
path.join(newerCanonicalPaths.rootDir, "storage-meta.json"),
JSON.stringify(
{
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accountId: "default",
accessTokenHash: newerCanonicalPaths.tokenHash,
deviceId: "NEWDEVICE",
},
null,
2,
),
);
const resolvedPaths = resolveMatrixStoragePaths({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "secret-token-new",
deviceId: "NEWDEVICE",
env: {},
});
expect(resolvedPaths.rootDir).toBe(newerCanonicalPaths.rootDir);
expect(resolvedPaths.tokenHash).toBe(newerCanonicalPaths.tokenHash);
});
});

View File

@ -18,6 +18,7 @@ const THREAD_BINDINGS_FILENAME = "thread-bindings.json";
const LEGACY_CRYPTO_MIGRATION_FILENAME = "legacy-crypto-migration.json";
const RECOVERY_KEY_FILENAME = "recovery-key.json";
const IDB_SNAPSHOT_FILENAME = "crypto-idb-snapshot.json";
const STARTUP_VERIFICATION_FILENAME = "startup-verification.json";
type LegacyMoveRecord = {
sourcePath: string;
@ -25,6 +26,14 @@ type LegacyMoveRecord = {
label: string;
};
type StoredRootMetadata = {
homeserver?: string;
userId?: string;
accountId?: string;
accessTokenHash?: string;
deviceId?: string | null;
};
function resolveLegacyStoragePaths(env: NodeJS.ProcessEnv = process.env): {
storagePath: string;
cryptoPath: string;
@ -88,9 +97,84 @@ function resolveStorageRootMtimeMs(rootDir: string): number {
}
}
function readStoredRootMetadata(rootDir: string): StoredRootMetadata {
const metadata: StoredRootMetadata = {};
try {
const parsed = JSON.parse(
fs.readFileSync(path.join(rootDir, STORAGE_META_FILENAME), "utf8"),
) as Partial<StoredRootMetadata>;
if (typeof parsed.homeserver === "string" && parsed.homeserver.trim()) {
metadata.homeserver = parsed.homeserver.trim();
}
if (typeof parsed.userId === "string" && parsed.userId.trim()) {
metadata.userId = parsed.userId.trim();
}
if (typeof parsed.accountId === "string" && parsed.accountId.trim()) {
metadata.accountId = parsed.accountId.trim();
}
if (typeof parsed.accessTokenHash === "string" && parsed.accessTokenHash.trim()) {
metadata.accessTokenHash = parsed.accessTokenHash.trim();
}
if (typeof parsed.deviceId === "string" && parsed.deviceId.trim()) {
metadata.deviceId = parsed.deviceId.trim();
}
} catch {
// ignore missing or malformed storage metadata
}
try {
const parsed = JSON.parse(
fs.readFileSync(path.join(rootDir, STARTUP_VERIFICATION_FILENAME), "utf8"),
) as { deviceId?: unknown };
if (!metadata.deviceId && typeof parsed.deviceId === "string" && parsed.deviceId.trim()) {
metadata.deviceId = parsed.deviceId.trim();
}
} catch {
// ignore missing or malformed verification state
}
return metadata;
}
function isCompatibleStorageRoot(params: {
candidateRootDir: string;
homeserver: string;
userId: string;
accountKey: string;
deviceId?: string | null;
}): boolean {
const metadata = readStoredRootMetadata(params.candidateRootDir);
if (metadata.homeserver && metadata.homeserver !== params.homeserver) {
return false;
}
if (metadata.userId && metadata.userId !== params.userId) {
return false;
}
if (
metadata.accountId &&
normalizeAccountId(metadata.accountId) !== normalizeAccountId(params.accountKey)
) {
return false;
}
if (
params.deviceId &&
metadata.deviceId &&
metadata.deviceId.trim() &&
metadata.deviceId.trim() !== params.deviceId.trim()
) {
return false;
}
return true;
}
function resolvePreferredMatrixStorageRoot(params: {
canonicalRootDir: string;
canonicalTokenHash: string;
homeserver: string;
userId: string;
accountKey: string;
deviceId?: string | null;
}): {
rootDir: string;
tokenHash: string;
@ -122,6 +206,17 @@ function resolvePreferredMatrixStorageRoot(params: {
continue;
}
const candidateRootDir = path.join(parentDir, entry.name);
if (
!isCompatibleStorageRoot({
candidateRootDir,
homeserver: params.homeserver,
userId: params.userId,
accountKey: params.accountKey,
deviceId: params.deviceId,
})
) {
continue;
}
const candidateScore = scoreStorageRoot(candidateRootDir);
if (candidateScore <= 0) {
continue;
@ -153,6 +248,7 @@ export function resolveMatrixStoragePaths(params: {
userId: string;
accessToken: string;
accountId?: string | null;
deviceId?: string | null;
env?: NodeJS.ProcessEnv;
stateDir?: string;
}): MatrixStoragePaths {
@ -168,6 +264,10 @@ export function resolveMatrixStoragePaths(params: {
const { rootDir, tokenHash } = resolvePreferredMatrixStorageRoot({
canonicalRootDir: canonical.rootDir,
canonicalTokenHash: canonical.tokenHash,
homeserver: params.homeserver,
userId: params.userId,
accountKey: canonical.accountKey,
deviceId: params.deviceId,
});
return {
rootDir,
@ -301,6 +401,7 @@ export function writeStorageMeta(params: {
homeserver: string;
userId: string;
accountId?: string | null;
deviceId?: string | null;
}): void {
try {
const payload = {
@ -308,6 +409,7 @@ export function writeStorageMeta(params: {
userId: params.userId,
accountId: params.accountId ?? DEFAULT_ACCOUNT_KEY,
accessTokenHash: params.storagePaths.tokenHash,
deviceId: params.deviceId ?? null,
createdAt: new Date().toISOString(),
};
fs.mkdirSync(params.storagePaths.rootDir, { recursive: true });

View File

@ -43,7 +43,7 @@ function isMigrationState(value: unknown): value is MatrixLegacyCryptoMigrationS
async function resolvePendingMigrationStatePath(params: {
stateDir: string;
auth: Pick<MatrixAuth, "homeserver" | "userId" | "accessToken" | "accountId">;
auth: Pick<MatrixAuth, "homeserver" | "userId" | "accessToken" | "accountId" | "deviceId">;
}): Promise<{
statePath: string;
value: MatrixLegacyCryptoMigrationState | null;
@ -53,6 +53,7 @@ async function resolvePendingMigrationStatePath(params: {
userId: params.auth.userId,
accessToken: params.auth.accessToken,
accountId: params.auth.accountId,
deviceId: params.auth.deviceId,
stateDir: params.stateDir,
});
const directStatePath = path.join(rootDir, "legacy-crypto-migration.json");
@ -89,7 +90,7 @@ async function resolvePendingMigrationStatePath(params: {
export async function maybeRestoreLegacyMatrixBackup(params: {
client: Pick<MatrixClient, "restoreRoomKeyBackup">;
auth: Pick<MatrixAuth, "homeserver" | "userId" | "accessToken" | "accountId">;
auth: Pick<MatrixAuth, "homeserver" | "userId" | "accessToken" | "accountId" | "deviceId">;
env?: NodeJS.ProcessEnv;
stateDir?: string;
}): Promise<MatrixLegacyCryptoRestoreResult> {

View File

@ -51,6 +51,7 @@ function resolveStartupVerificationStatePath(params: {
userId: params.auth.userId,
accessToken: params.auth.accessToken,
accountId: params.auth.accountId,
deviceId: params.auth.deviceId,
env: params.env,
});
return path.join(storagePaths.rootDir, STARTUP_VERIFICATION_STATE_FILENAME);

View File

@ -179,6 +179,7 @@ function resolveBindingsPath(params: {
userId: params.auth.userId,
accessToken: params.auth.accessToken,
accountId: params.accountId,
deviceId: params.auth.deviceId,
env: params.env,
});
return path.join(storagePaths.rootDir, "thread-bindings.json");