From 7eb97b9adfb97ffaf8ac73027939ddc9729a259c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 15 May 2026 17:30:23 +0100 Subject: [PATCH] fix(auth): harden sqlite device credentials --- src/infra/device-auth-store.test.ts | 6 ++---- src/infra/device-auth-store.ts | 4 ++++ src/infra/device-identity.test.ts | 22 ++++++++++++++++++++++ src/infra/device-identity.ts | 14 +++++++++++++- 4 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/infra/device-auth-store.test.ts b/src/infra/device-auth-store.test.ts index 927d059a1af..63f0565cb54 100644 --- a/src/infra/device-auth-store.test.ts +++ b/src/infra/device-auth-store.test.ts @@ -78,7 +78,7 @@ describe("infra/device-auth-store", () => { }); }); - it("stores one device token without deleting other devices", async () => { + it("drops tokens from previous devices when storing a replacement device token", async () => { await withTempDir("openclaw-device-auth-", async (stateDir) => { const env = createEnv(stateDir); @@ -98,9 +98,7 @@ describe("infra/device-auth-store", () => { expect(loadDeviceAuthToken({ deviceId: "device-1", role: "operator", env })).toMatchObject({ token: "device-1-token", }); - expect(loadDeviceAuthToken({ deviceId: "device-2", role: "operator", env })).toMatchObject({ - token: "device-2-token", - }); + expect(loadDeviceAuthToken({ deviceId: "device-2", role: "operator", env })).toBeNull(); }); }); diff --git a/src/infra/device-auth-store.ts b/src/infra/device-auth-store.ts index 57e10d6406b..e5677936ca7 100644 --- a/src/infra/device-auth-store.ts +++ b/src/infra/device-auth-store.ts @@ -211,6 +211,10 @@ export function storeDeviceAuthToken(params: { const row = deviceAuthEntryToRow(params.deviceId, entry); runOpenClawStateWriteTransaction((database) => { const db = getNodeSqliteKysely(database.db); + executeSqliteQuerySync( + database.db, + db.deleteFrom("device_auth_tokens").where("device_id", "!=", params.deviceId), + ); upsertDeviceAuthTokenRow(db, database.db, row); }, sqliteOptions(params.env)); return entry; diff --git a/src/infra/device-identity.test.ts b/src/infra/device-identity.test.ts index cc90d0e89bf..b98b61cc0ba 100644 --- a/src/infra/device-identity.test.ts +++ b/src/infra/device-identity.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; import { withTempDir } from "../test-utils/temp-dir.js"; import { DeviceIdentityMigrationRequiredError, + DeviceIdentityStorageError, deriveDeviceIdFromPublicKey, loadDeviceIdentityIfPresent, loadOrCreateDeviceIdentity, @@ -96,6 +97,27 @@ describe("device identity crypto helpers", () => { }); }); + it("rejects SQLite identities with mismatched private key material", async () => { + await withTempDir("openclaw-device-identity-mismatched-key-", async (dir) => { + const store = { env: { ...process.env, OPENCLAW_STATE_DIR: dir }, key: "mismatched-key" }; + const original = loadOrCreateDeviceIdentity(store); + const other = loadOrCreateDeviceIdentity({ ...store, key: "other-key" }); + writeStoredDeviceIdentitySnapshot( + { + version: 1, + deviceId: original.deviceId, + publicKeyPem: original.publicKeyPem, + privateKeyPem: other.privateKeyPem, + createdAtMs: Date.now(), + }, + store, + ); + + expect(() => loadOrCreateDeviceIdentity(store)).toThrow(DeviceIdentityStorageError); + expect(loadDeviceIdentityIfPresent(store)).toBeNull(); + }); + }); + it("derives the same canonical raw key and device id from pem and encoded public keys", async () => { await withIdentity((identity) => { const publicKeyRaw = publicKeyRawBase64UrlFromPem(identity.publicKeyPem); diff --git a/src/infra/device-identity.ts b/src/infra/device-identity.ts index 9a65263a65d..dae3877015e 100644 --- a/src/infra/device-identity.ts +++ b/src/infra/device-identity.ts @@ -151,7 +151,7 @@ function storedIdentityToRow( function deriveStoredDeviceIdOrThrow(stored: StoredDeviceIdentity): string { const derivedId = deriveDeviceIdFromPublicKey(stored.publicKeyPem); - if (!derivedId) { + if (!derivedId || !storedPrivateKeyMatchesPublicKey(stored.publicKeyPem, stored.privateKeyPem)) { throw new DeviceIdentityStorageError( 'Stored device identity is invalid. Run "openclaw doctor --fix" before starting the gateway or connecting this client.', ); @@ -159,6 +159,15 @@ function deriveStoredDeviceIdOrThrow(stored: StoredDeviceIdentity): string { return derivedId; } +function storedPrivateKeyMatchesPublicKey(publicKeyPem: string, privateKeyPem: string): boolean { + const payload = "openclaw-device-identity-self-check"; + try { + return verifyDeviceSignature(publicKeyPem, payload, signDevicePayload(privateKeyPem, payload)); + } catch { + return false; + } +} + function readStoredIdentity(options?: DeviceIdentityStoreOptions): StoredDeviceIdentity | null { const store = normalizeIdentityStoreOptions(options); const database = openOpenClawStateDatabase({ env: store.env }); @@ -274,6 +283,9 @@ export function loadDeviceIdentityIfPresent( if (!derivedId || derivedId !== parsed.deviceId) { return null; } + if (!storedPrivateKeyMatchesPublicKey(parsed.publicKeyPem, parsed.privateKeyPem)) { + return null; + } return { deviceId: parsed.deviceId, publicKeyPem: parsed.publicKeyPem,