fix(auth): harden sqlite device credentials

This commit is contained in:
Peter Steinberger
2026-05-15 17:30:23 +01:00
parent 9756ae5c22
commit 7eb97b9adf
4 changed files with 41 additions and 5 deletions

View File

@@ -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();
});
});

View File

@@ -211,6 +211,10 @@ export function storeDeviceAuthToken(params: {
const row = deviceAuthEntryToRow(params.deviceId, entry);
runOpenClawStateWriteTransaction((database) => {
const db = getNodeSqliteKysely<DeviceAuthDatabase>(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;

View File

@@ -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);

View File

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