Files
openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceIdentityStoreTests.swift
2026-05-27 13:27:46 +01:00

284 lines
12 KiB
Swift

import CryptoKit
import Foundation
import Testing
@testable import OpenClawKit
@Suite(.serialized)
struct DeviceIdentityStoreTests {
@Test("persists generated device identity in SQLite without JSON sidecars")
func persistsGeneratedIdentityInSQLite() throws {
try Self.withTempStateDir { stateDir in
let identity = DeviceIdentityStore.loadOrCreate()
let loaded = DeviceIdentityStore.loadOrCreate()
#expect(loaded.deviceId == identity.deviceId)
#expect(loaded.publicKey == identity.publicKey)
#expect(FileManager.default.fileExists(atPath: Self.databaseURL(stateDir: stateDir).path))
#expect(!FileManager.default.fileExists(atPath: Self.legacyIdentityURL(stateDir: stateDir).path))
let stored = try #require(OpenClawSQLiteStateStore.readDeviceIdentity())
#expect(stored.deviceId == identity.deviceId)
#expect(stored.publicKeyPem.contains("BEGIN PUBLIC KEY"))
#expect(stored.privateKeyPem.contains(Self.privateKeyMarker("BEGIN")))
}
}
@Test("surfaces SQLite identity read failures distinctly from missing rows")
func surfacesSQLiteIdentityReadFailures() throws {
try Self.withTempStateDir { stateDir in
try FileManager.default.createDirectory(
at: Self.databaseURL(stateDir: stateDir),
withIntermediateDirectories: true)
#expect(throws: Error.self) {
_ = try OpenClawSQLiteStateStore.readDeviceIdentityChecked()
}
}
}
@Test("loads TypeScript PEM identity schema from SQLite")
func loadsTypeScriptPEMIdentitySchema() throws {
try Self.withTempStateDir { stateDir in
let stored = try Self.identityJSON(
publicKeyPem: Self.pem(
label: "PUBLIC KEY",
body: "MCowBQYDK2VwAyEAA6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg="),
privateKeyPem: Self.pem(
label: "PRIVATE" + " KEY",
body: "MC4CAQAwBQYDK2VwBCIEIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f"))
let object = try #require(try JSONSerialization.jsonObject(with: stored) as? [String: Any])
try OpenClawSQLiteStateStore.writeDeviceIdentity(
identity: OpenClawSQLiteDeviceIdentityRow(
deviceId: try #require(object["deviceId"] as? String),
publicKeyPem: try #require(object["publicKeyPem"] as? String),
privateKeyPem: try #require(object["privateKeyPem"] as? String),
createdAtMs: try #require(object["createdAtMs"] as? Int)))
let identity = DeviceIdentityStore.loadOrCreate()
#expect(identity.deviceId == "56475aa75463474c0285df5dbf2bcab73da651358839e9b77481b2eab107708c")
#expect(identity.publicKey == "A6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=")
#expect(identity.privateKey == "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=")
#expect(DeviceIdentityStore.publicKeyBase64Url(identity) == "A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg")
#expect(!FileManager.default.fileExists(atPath: Self.legacyIdentityURL(stateDir: stateDir).path))
let signature = try #require(DeviceIdentityStore.signPayload("hello", identity: identity))
let publicKeyData = try #require(Data(base64Encoded: identity.publicKey))
let signatureData = try #require(Self.base64UrlDecode(signature))
let publicKey = try Curve25519.Signing.PublicKey(rawRepresentation: publicKeyData)
#expect(publicKey.isValidSignature(signatureData, for: Data("hello".utf8)))
}
}
@Test("migrates legacy raw device identity sidecar into SQLite")
func migratesLegacyRawIdentitySidecarIntoSQLite() throws {
try Self.withTempStateDir { stateDir in
let legacyURL = Self.legacyIdentityURL(stateDir: stateDir)
try FileManager.default.createDirectory(
at: legacyURL.deletingLastPathComponent(),
withIntermediateDirectories: true)
let legacy = Self.legacyRawIdentity()
let data = try JSONEncoder().encode(legacy)
try data.write(to: legacyURL)
#expect(DeviceIdentityStore.legacyIdentityMigrationRequired())
let identity = DeviceIdentityStore.loadOrCreate()
#expect(identity.deviceId == legacy.deviceId)
#expect(identity.publicKey == legacy.publicKey)
#expect(identity.privateKey == legacy.privateKey)
#expect(identity.createdAtMs == legacy.createdAtMs)
#expect(!FileManager.default.fileExists(atPath: legacyURL.path))
let stored = try #require(OpenClawSQLiteStateStore.readDeviceIdentity())
#expect(stored.deviceId == legacy.deviceId)
#expect(FileManager.default.fileExists(atPath: Self.databaseURL(stateDir: stateDir).path))
}
}
@Test("stores device auth tokens in SQLite without JSON sidecars")
func storesDeviceAuthTokensInSQLite() throws {
try Self.withTempStateDir { stateDir in
let entry = DeviceAuthStore.storeToken(
deviceId: "device-1",
role: " gateway ",
token: "token-1",
scopes: ["write", " read ", "write"])
#expect(entry.role == "gateway")
#expect(entry.scopes == ["read", "write"])
#expect(DeviceAuthStore.loadToken(deviceId: "device-1", role: "gateway")?.token == "token-1")
#expect(!FileManager.default.fileExists(atPath: Self.legacyAuthURL(stateDir: stateDir).path))
let stored = try #require(OpenClawSQLiteStateStore.readDeviceAuthToken(
deviceId: "device-1",
role: "gateway"))
#expect(stored.token == "token-1")
#expect(stored.scopesJSON.contains("read"))
DeviceAuthStore.clearToken(deviceId: "device-1", role: "gateway")
#expect(DeviceAuthStore.loadToken(deviceId: "device-1", role: "gateway") == nil)
}
}
@Test("migrates legacy device auth sidecar into SQLite")
func migratesLegacyDeviceAuthSidecarIntoSQLite() throws {
try Self.withTempStateDir { stateDir in
let legacyURL = Self.legacyAuthURL(stateDir: stateDir)
try Self.writeLegacyAuthSidecar(
legacyURL,
deviceId: "device-1",
token: "token-1",
scopes: ["write", " read ", "write"])
let entry = try #require(DeviceAuthStore.loadToken(deviceId: "device-1", role: "gateway"))
#expect(entry.token == "token-1")
#expect(entry.role == "gateway")
#expect(entry.scopes == ["read", "write"])
#expect(entry.updatedAtMs == 1_700_000_000_000)
#expect(!FileManager.default.fileExists(atPath: legacyURL.path))
let stored = try #require(OpenClawSQLiteStateStore.readDeviceAuthToken(
deviceId: "device-1",
role: "gateway"))
#expect(stored.token == "token-1")
}
}
@Test("keeps legacy device auth sidecar when SQLite import fails")
func keepsLegacyDeviceAuthSidecarWhenSQLiteImportFails() throws {
try Self.withTempStateDir { stateDir in
let legacyURL = Self.legacyAuthURL(stateDir: stateDir)
try Self.writeLegacyAuthSidecar(
legacyURL,
deviceId: "device-1",
token: "token-1",
scopes: ["read"])
try FileManager.default.createDirectory(at: Self.databaseURL(stateDir: stateDir), withIntermediateDirectories: true)
#expect(DeviceAuthStore.loadToken(deviceId: "device-1", role: "gateway") == nil)
#expect(FileManager.default.fileExists(atPath: legacyURL.path))
}
}
@Test("drops stale legacy device auth sidecar when storing a different device")
func dropsStaleLegacyDeviceAuthSidecarWhenReplacingDevice() throws {
try Self.withTempStateDir { stateDir in
let legacyURL = Self.legacyAuthURL(stateDir: stateDir)
try Self.writeLegacyAuthSidecar(
legacyURL,
deviceId: "device-1",
token: "stale-token",
scopes: ["read"])
let entry = DeviceAuthStore.storeToken(
deviceId: "device-2",
role: "gateway",
token: "fresh-token",
scopes: ["write"])
#expect(entry.token == "fresh-token")
#expect(DeviceAuthStore.loadToken(deviceId: "device-1", role: "gateway") == nil)
#expect(DeviceAuthStore.loadToken(deviceId: "device-2", role: "gateway")?.token == "fresh-token")
#expect(!FileManager.default.fileExists(atPath: legacyURL.path))
}
}
private static func withTempStateDir(_ body: (URL) throws -> Void) throws {
let previous = DeviceIdentityPaths.testingStateDirURL
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
DeviceIdentityPaths.testingStateDirURL = tempDir
defer {
DeviceIdentityPaths.testingStateDirURL = previous
try? FileManager.default.removeItem(at: tempDir)
}
try body(tempDir)
}
private static func databaseURL(stateDir: URL) -> URL {
stateDir
.appendingPathComponent("state", isDirectory: true)
.appendingPathComponent("openclaw.sqlite")
}
private static func legacyIdentityURL(stateDir: URL) -> URL {
stateDir
.appendingPathComponent("identity", isDirectory: true)
.appendingPathComponent("device.json", isDirectory: false)
}
private static func legacyAuthURL(stateDir: URL) -> URL {
stateDir
.appendingPathComponent("identity", isDirectory: true)
.appendingPathComponent("device-auth.json", isDirectory: false)
}
private static func writeLegacyAuthSidecar(
_ legacyURL: URL,
deviceId: String,
token: String,
scopes: [String]) throws
{
try FileManager.default.createDirectory(
at: legacyURL.deletingLastPathComponent(),
withIntermediateDirectories: true)
let legacy = [
"version": 1,
"deviceId": deviceId,
"tokens": [
"gateway": [
"token": token,
"role": "gateway",
"scopes": scopes,
"updatedAtMs": 1_700_000_000_000,
],
],
] as [String: Any]
let data = try JSONSerialization.data(withJSONObject: legacy, options: [.prettyPrinted, .sortedKeys])
try data.write(to: legacyURL)
}
private static func base64UrlDecode(_ value: String) -> Data? {
let normalized = value
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
let padded = normalized + String(repeating: "=", count: (4 - normalized.count % 4) % 4)
return Data(base64Encoded: padded)
}
private static func legacyRawIdentity() -> DeviceIdentity {
let privateKey = Curve25519.Signing.PrivateKey()
let publicKeyData = privateKey.publicKey.rawRepresentation
let privateKeyData = privateKey.rawRepresentation
let deviceId = SHA256.hash(data: publicKeyData).compactMap {
String(format: "%02x", $0)
}.joined()
return DeviceIdentity(
deviceId: deviceId,
publicKey: publicKeyData.base64EncodedString(),
privateKey: privateKeyData.base64EncodedString(),
createdAtMs: 1_700_000_000_000)
}
private static func identityJSON(publicKeyPem: String, privateKeyPem: String) throws -> Data {
let object: [String: Any] = [
"version": 1,
"deviceId": "stale-device-id",
"publicKeyPem": publicKeyPem,
"privateKeyPem": privateKeyPem,
"createdAtMs": 1_700_000_000_000,
]
return try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys])
}
private static func pem(label: String, body: String) -> String {
"-----BEGIN \(label)-----\n\(body)\n-----END \(label)-----\n"
}
private static func privateKeyMarker(_ boundary: String) -> String {
"-----\(boundary) \("PRIVATE" + " KEY")-----"
}
}