refactor(state): move device identity into canonical SQLite state (#110392)

* refactor: store device identities in SQLite

* style: satisfy Swift identity lint limits

* test: harden device identity migration fixtures

* fix: harden SQLite device identity recovery

* chore: remove stale identity helper

* refactor: isolate device identity repair

* chore: remove stale migration import

* test: validate migrated identity key types

* fix: harden device identity repair

* style: format device identity assertion

* fix: derive repaired identity key from PEM

* style: remove redundant PEM conversions

* fix: align native identity store with schema v4

* fix(state): satisfy SQLite identity CI gates

* fix(state): coordinate native identity migration

* test(doctor): include native identity claim path

* fix(infra): preserve coordinator release error

* fix(state): clear identity validation gates

* refactor(apple): remove identity test-only APIs

* refactor(apple): remove dead identity error type

* test(apple): use canonical identity test seam

* test(macos): isolate gateway readiness identity
This commit is contained in:
Peter Steinberger
2026-07-18 17:43:56 +01:00
committed by GitHub
parent d022b1f560
commit 98410d986e
73 changed files with 5514 additions and 1290 deletions
+26 -26
View File
@@ -22611,7 +22611,7 @@
},
{
"kind": "ui-localized-call-multiline",
"line": 1281,
"line": 1283,
"path": "apps/ios/Sources/Gateway/GatewayConnectionController.swift",
"source": "Can't reach gateway at %1$@:%2$@. Verify Tailscale Serve is enabled and publishes this Gateway.",
"surface": "apple",
@@ -22619,7 +22619,7 @@
},
{
"kind": "ui-localized-call",
"line": 1290,
"line": 1292,
"path": "apps/ios/Sources/Gateway/GatewayConnectionController.swift",
"source": "Can't reach gateway at %1$@:%2$@. Check Tailscale or LAN.",
"surface": "apple",
@@ -22627,7 +22627,7 @@
},
{
"kind": "ui-localized-call-multiline",
"line": 1296,
"line": 1298,
"path": "apps/ios/Sources/Gateway/GatewayConnectionController.swift",
"source": "TLS fingerprint verification timed out for %1$@:%2$@. Secure endpoint was reached, but TLS did not finish in time.",
"surface": "apple",
@@ -22635,7 +22635,7 @@
},
{
"kind": "ui-localized-call-multiline",
"line": 1304,
"line": 1306,
"path": "apps/ios/Sources/Gateway/GatewayConnectionController.swift",
"source": "No secure gateway endpoint was detected at %1$@:%2$@. Enable gateway TLS or Tailscale Serve, or use a trusted private LAN address with Unencrypted selected.",
"surface": "apple",
@@ -22643,7 +22643,7 @@
},
{
"kind": "ui-localized-call",
"line": 1314,
"line": 1316,
"path": "apps/ios/Sources/Gateway/GatewayConnectionController.swift",
"source": "Could not read the TLS certificate from %1$@:%2$@.",
"surface": "apple",
@@ -23507,7 +23507,7 @@
},
{
"kind": "conditional-branch",
"line": 4909,
"line": 4917,
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
"source": "Connected",
"surface": "apple",
@@ -23515,7 +23515,7 @@
},
{
"kind": "conditional-branch",
"line": 4909,
"line": 4917,
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
"source": "Offline",
"surface": "apple",
@@ -23523,7 +23523,7 @@
},
{
"kind": "conditional-branch",
"line": 6075,
"line": 6087,
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
"source": "No chat messages yet",
"surface": "apple",
@@ -23531,7 +23531,7 @@
},
{
"kind": "conditional-branch",
"line": 6274,
"line": 6286,
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
"source": "Connecting…",
"surface": "apple",
@@ -23539,7 +23539,7 @@
},
{
"kind": "conditional-branch",
"line": 6274,
"line": 6286,
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
"source": "Reconnecting…",
"surface": "apple",
@@ -23547,7 +23547,7 @@
},
{
"kind": "conditional-branch",
"line": 8683,
"line": 8695,
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
"source": "Approval",
"surface": "apple",
@@ -23555,7 +23555,7 @@
},
{
"kind": "conditional-branch",
"line": 8683,
"line": 8695,
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
"source": "This approval was already",
"surface": "apple",
@@ -23563,7 +23563,7 @@
},
{
"kind": "conditional-branch",
"line": 8689,
"line": 8701,
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
"source": "This approval was already set to Always Allow.",
"surface": "apple",
@@ -23571,7 +23571,7 @@
},
{
"kind": "conditional-branch",
"line": 8690,
"line": 8702,
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
"source": "Approval set to Always Allow.",
"surface": "apple",
@@ -23579,7 +23579,7 @@
},
{
"kind": "conditional-branch",
"line": 10028,
"line": 10040,
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
"source": "\\(urlText.prefix(500))…",
"surface": "apple",
@@ -31595,7 +31595,7 @@
},
{
"kind": "conditional-branch",
"line": 385,
"line": 389,
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
"source": "Main",
"surface": "apple",
@@ -31603,7 +31603,7 @@
},
{
"kind": "conditional-branch",
"line": 385,
"line": 389,
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
"source": "Other",
"surface": "apple",
@@ -31611,7 +31611,7 @@
},
{
"kind": "ui-modifier",
"line": 459,
"line": 463,
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
"source": "Show pairing requests",
"surface": "apple",
@@ -31619,7 +31619,7 @@
},
{
"kind": "ui-call",
"line": 484,
"line": 488,
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
"source": "Refreshing microphones…",
"surface": "apple",
@@ -31627,7 +31627,7 @@
},
{
"kind": "ui-call",
"line": 491,
"line": 495,
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
"source": "Microphone",
"surface": "apple",
@@ -31635,7 +31635,7 @@
},
{
"kind": "ui-call",
"line": 516,
"line": 520,
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
"source": "Disconnected (using System default)",
"surface": "apple",
@@ -31779,7 +31779,7 @@
},
{
"kind": "conditional-branch",
"line": 435,
"line": 436,
"path": "apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift",
"source": "Node pairing approved",
"surface": "apple",
@@ -31787,7 +31787,7 @@
},
{
"kind": "conditional-branch",
"line": 435,
"line": 436,
"path": "apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift",
"source": "Node pairing rejected",
"surface": "apple",
@@ -36235,7 +36235,7 @@
},
{
"kind": "conditional-branch",
"line": 455,
"line": 460,
"path": "apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift",
"source": " [\\(initial)]",
"surface": "apple",
@@ -36243,7 +36243,7 @@
},
{
"kind": "conditional-branch",
"line": 502,
"line": 507,
"path": "apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift",
"source": " — \\(option.hint!)",
"surface": "apple",
@@ -36251,7 +36251,7 @@
},
{
"kind": "conditional-branch",
"line": 509,
"line": 514,
"path": "apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift",
"source": " [\\(initialIndices.map(String.init).joined(separator: \",\"))]",
"surface": "apple",
@@ -3,6 +3,10 @@ import OpenClawKit
extension GatewayConnectionController {
static func migrateLegacyDeviceAuth() {
guard
let primaryIdentity = DeviceIdentityStore.loadOrCreatePersisted(),
let shareIdentity = DeviceIdentityStore.loadOrCreatePersisted(profile: .shareExtension)
else { return }
let migrationGatewayID = self.legacyDeviceAuthMigrationGatewayID()
let relay = ShareGatewayRelaySettings.loadConfig()
let instanceID = GatewaySettingsStore.currentInstanceID()
@@ -15,8 +19,6 @@ extension GatewayConnectionController {
} else {
GatewaySettingsStore.discardUnscopedGatewayCredentials(instanceId: instanceID)
}
let primaryIdentity = DeviceIdentityStore.loadOrCreate()
let shareIdentity = DeviceIdentityStore.loadOrCreate(profile: .shareExtension)
// The extension connects independently, so the host's last route cannot prove who
// issued its legacy token. Require one extension re-pair instead of guessing an owner.
DeviceAuthStore.discardUnscopedTokens(
@@ -609,20 +609,22 @@ final class GatewayConnectionController {
}
private static func clearDeviceAuthTokens(gatewayID: String) {
let primaryIdentity = DeviceIdentityStore.loadOrCreate()
DeviceAuthStore.clearToken(deviceId: primaryIdentity.deviceId, role: "node", gatewayID: gatewayID)
DeviceAuthStore.clearToken(deviceId: primaryIdentity.deviceId, role: "operator", gatewayID: gatewayID)
let shareIdentity = DeviceIdentityStore.loadOrCreate(profile: .shareExtension)
DeviceAuthStore.clearToken(
deviceId: shareIdentity.deviceId,
role: "node",
gatewayID: gatewayID,
profile: .shareExtension)
DeviceAuthStore.clearToken(
deviceId: shareIdentity.deviceId,
role: "operator",
gatewayID: gatewayID,
profile: .shareExtension)
if let primaryIdentity = DeviceIdentityStore.loadOrCreatePersisted() {
DeviceAuthStore.clearToken(deviceId: primaryIdentity.deviceId, role: "node", gatewayID: gatewayID)
DeviceAuthStore.clearToken(deviceId: primaryIdentity.deviceId, role: "operator", gatewayID: gatewayID)
}
if let shareIdentity = DeviceIdentityStore.loadOrCreatePersisted(profile: .shareExtension) {
DeviceAuthStore.clearToken(
deviceId: shareIdentity.deviceId,
role: "node",
gatewayID: gatewayID,
profile: .shareExtension)
DeviceAuthStore.clearToken(
deviceId: shareIdentity.deviceId,
role: "operator",
gatewayID: gatewayID,
profile: .shareExtension)
}
}
private func clearLegacyManualGatewayDefaults(matching stableID: String) {
+22 -10
View File
@@ -3918,7 +3918,7 @@ extension NodeAppModel {
}
private func hasStoredGatewayRoleToken(_ role: String, gatewayID: String) -> Bool {
let identity = DeviceIdentityStore.loadOrCreate()
guard let identity = DeviceIdentityStore.loadOrCreatePersisted() else { return false }
return DeviceAuthStore.loadToken(
deviceId: identity.deviceId,
role: role,
@@ -4788,9 +4788,13 @@ extension NodeAppModel {
password: String?,
forceTalkPermissionUpgradeRequest: Bool = false) -> Bool
{
let identity = DeviceIdentityStore.loadOrCreate()
let storedOperatorScopes = DeviceAuthStore
.loadToken(deviceId: identity.deviceId, role: "operator", gatewayID: gatewayID)?
let storedOperatorScopes = DeviceIdentityStore.loadOrCreatePersisted()
.flatMap { identity in
DeviceAuthStore.loadToken(
deviceId: identity.deviceId,
role: "operator",
gatewayID: gatewayID)
}?
.scopes ?? []
return Self.shouldRequestOperatorApprovalScope(
token: token,
@@ -4825,9 +4829,13 @@ extension NodeAppModel {
password: String?,
forceTalkPermissionUpgradeRequest: Bool = false) -> Bool
{
let identity = DeviceIdentityStore.loadOrCreate()
let storedOperatorScopes = DeviceAuthStore
.loadToken(deviceId: identity.deviceId, role: "operator", gatewayID: gatewayID)?
let storedOperatorScopes = DeviceIdentityStore.loadOrCreatePersisted()
.flatMap { identity in
DeviceAuthStore.loadToken(
deviceId: identity.deviceId,
role: "operator",
gatewayID: gatewayID)
}?
.scopes ?? []
return Self.shouldRequestOperatorAdminScope(
token: token,
@@ -4943,9 +4951,13 @@ extension NodeAppModel {
return
}
let gatewayID = config.nodeOptions.deviceAuthGatewayID ?? config.effectiveStableID
let identity = DeviceIdentityStore.loadOrCreate()
self.hasOperatorAdminScope = DeviceAuthStore
.loadToken(deviceId: identity.deviceId, role: "operator", gatewayID: gatewayID)?
self.hasOperatorAdminScope = DeviceIdentityStore.loadOrCreatePersisted()
.flatMap { identity in
DeviceAuthStore.loadToken(
deviceId: identity.deviceId,
role: "operator",
gatewayID: gatewayID)
}?
.scopes
.contains("operator.admin") == true
}
@@ -10,7 +10,7 @@ enum GatewayOnboardingReset {
disconnectGateway: Bool = true,
defaults: UserDefaults = .standard) async
{
await self.prepare(
_ = await self.prepare(
appModel: appModel,
instanceId: instanceId,
gatewayStableID: gatewayStableID,
@@ -24,12 +24,13 @@ enum GatewayOnboardingReset {
instanceId: String,
defaults: UserDefaults = .standard) async
{
await self.prepare(
guard await self.prepare(
appModel: appModel,
instanceId: instanceId,
gatewayStableID: nil,
disconnectGateway: true,
defaults: defaults)
else { return }
self.clearOnboardingState(defaults: defaults)
}
@@ -42,12 +43,13 @@ enum GatewayOnboardingReset {
defaults: UserDefaults = .standard)
{
appModel.purgeChatTranscriptCacheBeforeStartup()
self.preparePairingState(
guard self.preparePairingState(
appModel: appModel,
instanceId: instanceId,
gatewayStableID: nil,
disconnectGateway: true,
defaults: defaults)
else { return }
self.clearOnboardingState(defaults: defaults)
}
@@ -57,10 +59,10 @@ enum GatewayOnboardingReset {
instanceId: String,
gatewayStableID: String?,
disconnectGateway: Bool,
defaults: UserDefaults) async
defaults: UserDefaults) async -> Bool
{
await appModel.purgeChatTranscriptCache(gatewayID: gatewayStableID)
self.preparePairingState(
return self.preparePairingState(
appModel: appModel,
instanceId: instanceId,
gatewayStableID: gatewayStableID,
@@ -74,8 +76,26 @@ enum GatewayOnboardingReset {
instanceId: String,
gatewayStableID: String?,
disconnectGateway: Bool,
defaults: UserDefaults)
defaults: UserDefaults) -> Bool
{
guard let deviceId = DeviceIdentityStore.loadOrCreatePersisted()?.deviceId else {
appModel.gatewayStatusText = "Could not access device identity"
return false
}
let shareDeviceId: String?
if gatewayStableID != nil {
guard let resolvedShareDeviceId = DeviceIdentityStore
.loadOrCreatePersisted(profile: .shareExtension)?
.deviceId
else {
appModel.gatewayStatusText = "Could not access share device identity"
return false
}
shareDeviceId = resolvedShareDeviceId
} else {
shareDeviceId = nil
}
if disconnectGateway {
appModel.disconnectGateway()
}
@@ -91,25 +111,25 @@ enum GatewayOnboardingReset {
}
}
let deviceId = DeviceIdentityStore.loadOrCreate().deviceId
if let gatewayStableID {
let authenticationOwnerID = GatewaySettingsStore.authenticationOwnerID(
routeStableID: gatewayStableID)
let shareDeviceId = DeviceIdentityStore.loadOrCreate(profile: .shareExtension).deviceId
// Bootstrap replacement invalidates only the target. Other paired gateways remain
// usable when the user switches back after reviewing or completing this setup.
DeviceAuthStore.clearToken(deviceId: deviceId, role: "node", gatewayID: authenticationOwnerID)
DeviceAuthStore.clearToken(deviceId: deviceId, role: "operator", gatewayID: authenticationOwnerID)
DeviceAuthStore.clearToken(
deviceId: shareDeviceId,
role: "node",
gatewayID: authenticationOwnerID,
profile: .shareExtension)
DeviceAuthStore.clearToken(
deviceId: shareDeviceId,
role: "operator",
gatewayID: authenticationOwnerID,
profile: .shareExtension)
if let shareDeviceId {
DeviceAuthStore.clearToken(
deviceId: shareDeviceId,
role: "node",
gatewayID: authenticationOwnerID,
profile: .shareExtension)
DeviceAuthStore.clearToken(
deviceId: shareDeviceId,
role: "operator",
gatewayID: authenticationOwnerID,
profile: .shareExtension)
}
GatewayTLSStore.clearFingerprint(stableID: gatewayStableID)
} else {
// Full onboarding reset is the only path that intentionally forgets every gateway.
@@ -126,6 +146,7 @@ enum GatewayOnboardingReset {
GatewaySettingsStore.clearPreferredGatewayStableID(defaults: defaults)
GatewaySettingsStore.clearLastDiscoveredGatewayStableID(defaults: defaults)
defaults.set(false, forKey: "gateway.autoconnect")
return true
}
private static func clearOnboardingState(defaults: UserDefaults) {
@@ -165,7 +165,7 @@ struct TerminalHubScreen: View {
// must honor that boundary or a stale token can override the supplied password.
guard config.nodeOptions.allowStoredDeviceAuth else { return nil }
let gatewayID = config.nodeOptions.deviceAuthGatewayID ?? config.effectiveStableID
let identity = DeviceIdentityStore.loadOrCreate()
guard let identity = DeviceIdentityStore.loadOrCreatePersisted() else { return nil }
return DeviceAuthStore.loadToken(
deviceId: identity.deviceId,
role: "operator",
@@ -1177,7 +1177,7 @@ extension GatewayConnection {
{
return token
}
let identity = DeviceIdentityStore.loadOrCreate()
guard let identity = DeviceIdentityStore.loadOrCreatePersisted() else { return nil }
return DeviceAuthStore.loadToken(
deviceId: identity.deviceId,
role: "operator",
@@ -357,8 +357,12 @@ struct MenuContent: View {
guard self.state.connectionMode != .unconfigured else { return nil }
guard case .connected = self.controlChannel.state else { return nil }
let deviceId = DeviceIdentityStore.loadOrCreate(
profile: MacNodeModeCoordinator.nodeIdentityProfile).deviceId
guard let identity = DeviceIdentityStore.loadOrCreatePersisted(
profile: MacNodeModeCoordinator.nodeIdentityProfile)
else {
return ("Mac identity unavailable", .red)
}
let deviceId = identity.deviceId
if let entry = self.nodesStore.nodes.first(where: { $0.nodeId == deviceId }) {
guard entry.isConnected else {
return ("Mac capabilities offline", .orange)
@@ -319,8 +319,9 @@ final class NodePairingApprovalPrompter {
if req.silent == true {
return true
}
let localNodeId = DeviceIdentityStore.loadOrCreate(
profile: MacNodeModeCoordinator.nodeIdentityProfile).deviceId
guard let localNodeId = DeviceIdentityStore.loadOrCreatePersisted(
profile: MacNodeModeCoordinator.nodeIdentityProfile)?.deviceId
else { return false }
return Self.shouldAutoApproveOwnLocalNode(
connectionMode: AppStateStore.shared.connectionMode,
requestNodeId: req.nodeId,
@@ -451,8 +452,13 @@ final class NodePairingApprovalPrompter {
}
private func tryAutomaticApproveIfPossible(_ req: PendingRequest) async -> Bool {
let localNodeId = DeviceIdentityStore.loadOrCreate(
profile: MacNodeModeCoordinator.nodeIdentityProfile).deviceId
guard let localNodeId = DeviceIdentityStore.loadOrCreatePersisted(
profile: MacNodeModeCoordinator.nodeIdentityProfile)?.deviceId
else {
self.logger.error(
"automatic pairing skipped (device identity unavailable) requestId=\(req.requestId, privacy: .public)")
return false
}
if Self.shouldAutoApproveOwnLocalNode(
connectionMode: AppStateStore.shared.connectionMode,
requestNodeId: req.nodeId,
@@ -272,7 +272,12 @@ actor GatewayWizardClient {
params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)])
}
let connectNonce = try await self.waitForConnectChallenge()
let identity = DeviceIdentityStore.loadOrCreate()
guard let identity = DeviceIdentityStore.loadOrCreatePersisted() else {
throw NSError(
domain: "OpenClawMacCLI",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Could not access the persisted device identity"])
}
let signedAtMs = Int64(Date().timeIntervalSince1970 * 1000)
let payload = GatewayDeviceAuthPayload.buildConnectCompatibilityPayload(
fields: .init(
@@ -1,7 +1,7 @@
import Foundation
import OpenClawKit
import Testing
@testable import OpenClaw
@testable import OpenClawKit
@Suite(.serialized)
@MainActor
@@ -1234,54 +1234,59 @@ struct GatewayProcessManagerTests {
}
@Test func `transient unavailable health response retries until ready`() async throws {
let port = GatewayEnvironment.gatewayPort()
let session = GatewayTestWebSocketSession(
taskFactory: {
GatewayTestWebSocketTask(
sendHook: { task, message, sendIndex in
guard sendIndex > 0 else { return }
guard let id = GatewayWebSocketTestSupport.requestID(from: message) else { return }
if sendIndex == 1 {
let response = Data(
"""
{"type":"res","id":"\(id)","ok":false,
"error":{"code":"UNAVAILABLE","message":"gateway restarting"}}
""".utf8)
task.emitReceiveSuccess(.data(response))
return
}
task.emitReceiveSuccess(.data(GatewayWebSocketTestSupport.okResponseData(id: id)))
})
})
let url = try #require(URL(string: "ws://example.invalid"))
let connection = GatewayConnection(
configProvider: { (url: url, token: nil, password: nil) },
sessionBox: WebSocketSessionBox(session: session))
let descriptor = PortGuardian.Descriptor(
pid: 4242,
command: "openclaw-gateway",
executablePath: "/tmp/openclaw-gateway")
let stateDir = FileManager.default.temporaryDirectory
.appendingPathComponent("openclaw-gateway-ready-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: stateDir) }
try await DeviceIdentityStore.withStateDirectory(stateDir) {
let port = GatewayEnvironment.gatewayPort()
let session = GatewayTestWebSocketSession(
taskFactory: {
GatewayTestWebSocketTask(
sendHook: { task, message, sendIndex in
guard sendIndex > 0 else { return }
guard let id = GatewayWebSocketTestSupport.requestID(from: message) else { return }
if sendIndex == 1 {
let response = Data(
"""
{"type":"res","id":"\(id)","ok":false,
"error":{"code":"UNAVAILABLE","message":"gateway restarting"}}
""".utf8)
task.emitReceiveSuccess(.data(response))
return
}
task.emitReceiveSuccess(.data(GatewayWebSocketTestSupport.okResponseData(id: id)))
})
})
let url = try #require(URL(string: "ws://example.invalid"))
let connection = GatewayConnection(
configProvider: { (url: url, token: nil, password: nil) },
sessionBox: WebSocketSessionBox(session: session))
let descriptor = PortGuardian.Descriptor(
pid: 4242,
command: "openclaw-gateway",
executablePath: "/tmp/openclaw-gateway")
let manager = GatewayProcessManager.shared
manager.setTestingConnection(connection)
manager.setTestingDesiredActive(true)
manager.setTestingStatus(.starting)
manager._testClearLaunchAgentReadinessFailure()
await PortGuardian.shared.setTestingDescriptor(descriptor, forPort: port)
defer {
manager.setTestingConnection(nil)
manager.setTestingDesiredActive(false)
manager.setTestingLastFailureReason(nil)
let manager = GatewayProcessManager.shared
manager.setTestingConnection(connection)
manager.setTestingDesiredActive(true)
manager.setTestingStatus(.starting)
manager._testClearLaunchAgentReadinessFailure()
manager._testSetLastObservedGatewayPID(nil)
await PortGuardian.shared.setTestingDescriptor(descriptor, forPort: port)
defer {
manager.setTestingConnection(nil)
manager.setTestingDesiredActive(false)
manager.setTestingLastFailureReason(nil)
manager._testClearLaunchAgentReadinessFailure()
manager._testSetLastObservedGatewayPID(nil)
}
#expect(await manager.waitForGatewayReady(timeout: 1))
#expect(manager.status == .running(details: "pid 4242"))
#expect(!manager._testHasLaunchAgentReadinessFailure())
await connection.shutdown()
await PortGuardian.shared.setTestingDescriptor(nil, forPort: port)
}
#expect(await manager.waitForGatewayReady(timeout: 1))
#expect(manager.status == .running(details: "pid 4242"))
#expect(!manager._testHasLaunchAgentReadinessFailure())
await connection.shutdown()
await PortGuardian.shared.setTestingDescriptor(nil, forPort: port)
}
@Test func `cancelled readiness probe preserves lifecycle state`() async throws {
@@ -1,6 +1,6 @@
import Foundation
public struct DeviceAuthEntry: Codable, Sendable {
public struct DeviceAuthEntry: Codable, Sendable, Equatable {
public let token: String
public let role: String
public let scopes: [String]
@@ -16,7 +16,7 @@ public struct DeviceAuthEntry: Codable, Sendable {
}
}
private struct DeviceAuthStoreFile: Codable {
struct DeviceAuthStoreFile: Codable, Equatable {
var version: Int
var deviceId: String
var tokens: [String: DeviceAuthEntry]
@@ -215,9 +215,13 @@ public enum DeviceAuthStore {
guard let decoded = try? JSONDecoder().decode(DeviceAuthStoreFile.self, from: data) else {
return nil
}
return self.normalizedStore(decoded)
}
static func normalizedStore(_ decoded: DeviceAuthStoreFile) -> DeviceAuthStoreFile? {
guard decoded.version == 1 else { return nil }
// Entries carry their owner, so legacy raw keys can be safely reindexed on read.
// The next mutation persists only byte-stable v2 keys without changing file shape.
// Entries carry their owner, so reads and identity migration compare one canonical
// role/scope/owner map instead of raw JSON key order or legacy dictionary keys.
var tokens: [String: DeviceAuthEntry] = [:]
for entry in decoded.tokens.values {
let role = self.normalizeRole(entry.role)
@@ -231,8 +235,13 @@ public enum DeviceAuthStore {
scopes: self.normalizeScopes(entry.scopes),
updatedAtMs: entry.updatedAtMs,
gatewayID: gatewayID)
if let existing = tokens[key], existing.updatedAtMs > normalized.updatedAtMs {
continue
if let existing = tokens[key] {
if existing.updatedAtMs > normalized.updatedAtMs {
continue
}
if existing.updatedAtMs == normalized.updatedAtMs, existing != normalized {
return nil
}
}
tokens[key] = normalized
}
@@ -32,7 +32,7 @@ public enum GatewayDeviceIdentityProfile: String, Sendable {
}
}
public struct DeviceIdentity: Codable, Sendable {
public struct DeviceIdentity: Codable, Sendable, Equatable {
public var deviceId: String
public var publicKey: String
public var privateKey: String
@@ -87,7 +87,7 @@ enum DeviceIdentityPaths {
private static func stateDirOverrideURL() -> URL? {
// Test-scoped stores must win over the process environment. Parallel Swift tests
// otherwise race whenever another suite temporarily swaps OPENCLAW_STATE_DIR.
if let scopedStateDirURL = self.scopedStateDirURL {
if let scopedStateDirURL {
return scopedStateDirURL
}
for key in self.stateDirEnv {
@@ -101,7 +101,7 @@ enum DeviceIdentityPaths {
return nil
}
private static func legacyStateDirURL() -> URL? {
static func legacyStateDirURL() -> URL? {
if let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first {
return appSupport.appendingPathComponent("OpenClaw", isDirectory: true)
}
@@ -132,7 +132,7 @@ enum DeviceIdentityPaths {
#endif
}
private static func appGroupStateDirURL() -> URL? {
static func appGroupStateDirURL() -> URL? {
guard
let containerURL = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: OpenClawAppGroup.identifier)
@@ -142,56 +142,64 @@ enum DeviceIdentityPaths {
return containerURL.appendingPathComponent("OpenClaw", isDirectory: true)
}
/// Files a one-time fallback migration may carry from the App Group container into the
/// selected store. Stored device tokens are keyed by deviceId, so the identity file is
/// only useful together with its auth sibling; migrating one without the other forces
/// an unnecessary re-pair even though the deviceId survived.
struct AppGroupMigrationSource {
struct LegacyIdentitySource: Equatable {
let stateDirURL: URL
let identityURL: URL
let authURL: URL
}
static func appGroupMigrationSource(
profile: GatewayDeviceIdentityProfile) -> AppGroupMigrationSource?
static func legacyIdentitySources(
profile: GatewayDeviceIdentityProfile) -> [LegacyIdentitySource]
{
self.appGroupMigrationSource(
appGroupStateDirURL: self.appGroupStateDirURL(),
appGroupStateDirAvailable: self.appGroupStateDirAvailable,
stateDirOverridden: self.stateDirOverrideURL() != nil,
profile: profile)
}
// Node doctor cannot traverse sandboxed Apple App Group/Application Support containers.
// Native startup therefore owns this one-time import before runtime becomes SQLite-only.
let selectedStateDirURL = self.stateDirURL()
let roots: [URL] = if self.scopedStateDirURL != nil || self.stateDirOverrideURL() != nil {
// Explicit and task-local stores must never import the machine's real identity.
[selectedStateDirURL]
} else {
// Unentitled macOS builds intentionally probe the former App Group once: shipped
// installs must carry their identity and auth together instead of rotating/re-pairing.
[selectedStateDirURL, self.appGroupStateDirURL(), self.legacyStateDirURL()]
.compactMap(\.self)
}
/// Non-nil only for unentitled builds whose store selection fell back to legacy storage;
/// entitled builds keep using the App Group container and must never migrate out of it.
/// An explicit OPENCLAW_STATE_DIR override selects a caller-chosen store, not the legacy
/// fallback; importing container identity/tokens there would leak the machine's real
/// pairing into unrelated stores (test dirs, relocated installs).
static func appGroupMigrationSource(
appGroupStateDirURL: URL?,
appGroupStateDirAvailable: Bool,
stateDirOverridden: Bool,
profile: GatewayDeviceIdentityProfile) -> AppGroupMigrationSource?
{
guard !stateDirOverridden, !appGroupStateDirAvailable, let appGroupStateDirURL else {
return nil
var seen = Set<String>()
return roots.compactMap { root in
let standardizedRoot = root.standardizedFileURL
guard seen.insert(standardizedRoot.path).inserted else { return nil }
let identityDirURL = standardizedRoot.appendingPathComponent("identity", isDirectory: true)
return LegacyIdentitySource(
stateDirURL: standardizedRoot,
identityURL: identityDirURL.appendingPathComponent(profile.identityFileName, isDirectory: false),
authURL: identityDirURL.appendingPathComponent(profile.authFileName, isDirectory: false))
}
let identityDirURL = appGroupStateDirURL.appendingPathComponent("identity", isDirectory: true)
return AppGroupMigrationSource(
identityURL: identityDirURL.appendingPathComponent(profile.identityFileName, isDirectory: false),
authURL: identityDirURL.appendingPathComponent(profile.authFileName, isDirectory: false))
}
}
struct DeviceIdentityMaterial: Equatable {
let identity: DeviceIdentity
let publicKeyPEM: String
let privateKeyPEM: String
}
public enum DeviceIdentityStore {
private static let ed25519SPKIPrefix = Data([
static let ed25519SPKIPrefix = Data([
0x30, 0x2A, 0x30, 0x05, 0x06, 0x03, 0x2B, 0x65,
0x70, 0x03, 0x21, 0x00,
])
private static let ed25519PKCS8PrivatePrefix = Data([
static let ed25519PKCS8PrivatePrefix = Data([
0x30, 0x2E, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06,
0x03, 0x2B, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
])
static func storageError(_ message: String) -> NSError {
NSError(
domain: "ai.openclaw.device-identity-store",
code: 1,
userInfo: [NSLocalizedDescriptionKey: message])
}
public static func loadOrCreate() -> DeviceIdentity {
self.loadOrCreate(profile: .primary)
}
@@ -219,131 +227,22 @@ public enum DeviceIdentityStore {
#endif
public static func loadOrCreate(profile: GatewayDeviceIdentityProfile) -> DeviceIdentity {
self.loadOrCreate(
fileURL: self.fileURL(profile: profile),
migrationSource: DeviceIdentityPaths.appGroupMigrationSource(profile: profile))
guard let identity = loadOrCreatePersisted(profile: profile) else {
preconditionFailure("Could not persist the OpenClaw device identity")
}
return identity
}
/// Loads or creates an identity, returning nil unless its key material was durably persisted.
public static func loadOrCreatePersisted(
profile: GatewayDeviceIdentityProfile = .primary) -> DeviceIdentity?
{
self.loadOrCreatePersisted(
fileURL: self.fileURL(profile: profile),
migrationSource: DeviceIdentityPaths.appGroupMigrationSource(profile: profile))
}
static func loadOrCreate(
fileURL url: URL,
migrationSource: DeviceIdentityPaths.AppGroupMigrationSource? = nil) -> DeviceIdentity
{
if let data = try? Data(contentsOf: url) {
switch self.decodeStoredIdentity(data) {
case let .identity(decoded):
return decoded
case .recognizedInvalid, .unknown:
// Existing bytes may hold a newer schema or recoverable key material; never
// overwrite them. Callers run with a transient identity instead.
return self.generate()
}
}
if FileManager.default.fileExists(atPath: url.path) {
return self.generate()
}
if let migrated = self.migratedIdentity(from: migrationSource, to: url) {
return migrated
}
let identity = self.generate()
self.save(identity, to: url)
return identity
}
static func loadOrCreatePersisted(
fileURL url: URL,
migrationSource: DeviceIdentityPaths.AppGroupMigrationSource? = nil) -> DeviceIdentity?
{
let identity = self.loadOrCreate(fileURL: url, migrationSource: migrationSource)
guard let data = try? Data(contentsOf: url),
case let .identity(stored) = self.decodeStoredIdentity(data),
stored.deviceId == identity.deviceId,
stored.publicKey == identity.publicKey,
stored.privateKey == identity.privateKey
else {
return nil
}
return stored
}
/// One-time upgrade path for builds that lost App Group storage: it runs only while the
/// selected store has no identity file, so steady state never re-reads the old container.
private static func migratedIdentity(
from source: DeviceIdentityPaths.AppGroupMigrationSource?,
to destinationURL: URL) -> DeviceIdentity?
{
guard
let source,
let data = try? Data(contentsOf: source.identityURL),
case let .identity(identity) = self.decodeStoredIdentity(data)
else {
return nil
}
self.save(identity, to: destinationURL)
// Stored device tokens only load when their store's deviceId matches (DeviceAuthStore),
// so they must move together with the identity or the install re-pairs for no reason.
// A mismatched copy is inert behind that same check; no validation needed here.
self.copyAuthStoreFile(
from: source.authURL,
toDirectory: destinationURL.deletingLastPathComponent())
return identity
}
private static func copyAuthStoreFile(from sourceURL: URL, toDirectory directoryURL: URL) {
let fileManager = FileManager.default
let destinationURL = directoryURL
.appendingPathComponent(sourceURL.lastPathComponent, isDirectory: false)
guard
!fileManager.fileExists(atPath: destinationURL.path),
fileManager.fileExists(atPath: sourceURL.path)
else {
return
}
try? fileManager.copyItem(at: sourceURL, to: destinationURL)
try? fileManager.setAttributes(
[.posixPermissions: 0o600],
ofItemAtPath: destinationURL.path)
}
private enum DecodeResult {
case identity(DeviceIdentity)
case recognizedInvalid
case unknown
}
private static func decodeStoredIdentity(_ data: Data) -> DecodeResult {
let decoder = JSONDecoder()
if let decoded = try? decoder.decode(DeviceIdentity.self, from: data) {
guard let identity = self.normalizedRawIdentity(decoded) else {
return .recognizedInvalid
}
return .identity(identity)
}
if let decoded = try? decoder.decode(PemDeviceIdentity.self, from: data) {
guard decoded.version == 1,
let publicKeyData = self.rawPublicKey(fromPEM: decoded.publicKeyPem),
let privateKeyData = self.rawPrivateKey(fromPEM: decoded.privateKeyPem),
self.keyPairMatches(publicKeyData: publicKeyData, privateKeyData: privateKeyData)
else {
return .recognizedInvalid
}
return .identity(DeviceIdentity(
deviceId: self.deviceId(publicKeyData: publicKeyData),
publicKey: publicKeyData.base64EncodedString(),
privateKey: privateKeyData.base64EncodedString(),
createdAtMs: decoded.createdAtMs))
}
return self.hasRecognizedIdentityShape(data) ? .recognizedInvalid : .unknown
let stateDirURL = DeviceIdentityPaths.stateDirURL()
return try? DeviceIdentitySQLiteStore.loadOrCreate(
databaseURL: self.databaseURL(stateDirURL: stateDirURL),
destinationStateDirURL: stateDirURL,
profile: profile,
legacySources: DeviceIdentityPaths.legacyIdentitySources(profile: profile))
}
public static func signPayload(_ payload: String, identity: DeviceIdentity) -> String? {
@@ -357,17 +256,21 @@ public enum DeviceIdentityStore {
}
}
private static func generate() -> DeviceIdentity {
static func generateMaterial() -> DeviceIdentityMaterial {
let privateKey = Curve25519.Signing.PrivateKey()
let publicKey = privateKey.publicKey
let publicKeyData = publicKey.rawRepresentation
let privateKeyData = privateKey.rawRepresentation
let deviceId = self.deviceId(publicKeyData: publicKeyData)
return DeviceIdentity(
let identity = DeviceIdentity(
deviceId: deviceId,
publicKey: publicKeyData.base64EncodedString(),
privateKey: privateKeyData.base64EncodedString(),
createdAtMs: Int64(Date().timeIntervalSince1970 * 1000))
return DeviceIdentityMaterial(
identity: identity,
publicKeyPEM: self.pem(label: "PUBLIC KEY", der: self.ed25519SPKIPrefix + publicKeyData),
privateKeyPEM: self.pem(label: "PRIVATE KEY", der: self.ed25519PKCS8PrivatePrefix + privateKeyData))
}
private static func base64UrlEncode(_ data: Data) -> String {
@@ -383,10 +286,77 @@ public enum DeviceIdentityStore {
return self.base64UrlEncode(data)
}
private static func normalizedRawIdentity(_ identity: DeviceIdentity) -> DeviceIdentity? {
guard !identity.deviceId.isEmpty,
let publicKeyData = Data(base64Encoded: identity.publicKey),
let privateKeyData = Data(base64Encoded: identity.privateKey)
static func material(fromLegacyData data: Data) throws -> DeviceIdentityMaterial {
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw DeviceIdentityStore.storageError("Legacy device identity is not a JSON object")
}
let keys = Set(object.keys)
let decoder = JSONDecoder()
if keys == ["deviceId", "publicKey", "privateKey", "createdAtMs"],
let decoded = try? decoder.decode(DeviceIdentity.self, from: data),
decoded.createdAtMs >= 0
{
guard let normalized = normalizedRawIdentity(decoded),
let publicKeyData = Data(base64Encoded: normalized.publicKey),
let privateKeyData = Data(base64Encoded: normalized.privateKey)
else {
throw DeviceIdentityStore
.storageError("Legacy raw device identity has invalid key material or deviceId")
}
return DeviceIdentityMaterial(
identity: normalized,
publicKeyPEM: self.pem(label: "PUBLIC KEY", der: self.ed25519SPKIPrefix + publicKeyData),
privateKeyPEM: self.pem(label: "PRIVATE KEY", der: self.ed25519PKCS8PrivatePrefix + privateKeyData))
}
if keys == ["version", "deviceId", "publicKeyPem", "privateKeyPem", "createdAtMs"],
let decoded = try? decoder.decode(PemDeviceIdentity.self, from: data)
{
guard decoded.version == 1, decoded.createdAtMs >= 0,
let publicKeyData = rawPublicKey(fromPEM: decoded.publicKeyPem),
let privateKeyData = rawPrivateKey(fromPEM: decoded.privateKeyPem),
keyPairMatches(publicKeyData: publicKeyData, privateKeyData: privateKeyData)
else {
throw DeviceIdentityStore.storageError("Legacy PEM device identity has invalid key material")
}
return self.material(
publicKeyData: publicKeyData,
privateKeyData: privateKeyData,
createdAtMs: decoded.createdAtMs)
}
throw DeviceIdentityStore.storageError("Legacy device identity has an unsupported shape")
}
static func material(
deviceId: String,
publicKeyPEM: String,
privateKeyPEM: String,
createdAtMs: Int64) throws -> DeviceIdentityMaterial
{
guard createdAtMs >= 0,
let publicKeyData = rawPublicKey(fromPEM: publicKeyPEM),
let privateKeyData = rawPrivateKey(fromPEM: privateKeyPEM),
keyPairMatches(publicKeyData: publicKeyData, privateKeyData: privateKeyData)
else {
throw DeviceIdentityStore.storageError("SQLite device identity has invalid key material")
}
let canonical = self.material(
publicKeyData: publicKeyData,
privateKeyData: privateKeyData,
createdAtMs: createdAtMs)
guard canonical.identity.deviceId == deviceId else {
throw DeviceIdentityStore.storageError("SQLite device identity deviceId does not match its public key")
}
guard canonical.publicKeyPEM == publicKeyPEM, canonical.privateKeyPEM == privateKeyPEM else {
throw DeviceIdentityStore.storageError("SQLite device identity PEM is not canonical")
}
return canonical
}
private static func normalizedRawIdentity(_ rawIdentity: DeviceIdentity) -> DeviceIdentity? {
let rawKey = rawIdentity.privateKey
guard !rawIdentity.deviceId.isEmpty,
let publicKeyData = Data(base64Encoded: rawIdentity.publicKey),
let privateKeyData = Data(base64Encoded: rawKey)
else { return nil }
guard publicKeyData.count == 32, privateKeyData.count == 32,
@@ -394,28 +364,28 @@ public enum DeviceIdentityStore {
else { return nil }
return DeviceIdentity(
deviceId: self.deviceId(publicKeyData: publicKeyData),
publicKey: identity.publicKey,
privateKey: identity.privateKey,
createdAtMs: identity.createdAtMs)
publicKey: rawIdentity.publicKey,
privateKey: rawKey,
createdAtMs: rawIdentity.createdAtMs)
}
private static func rawPublicKey(fromPEM pem: String) -> Data? {
guard let der = self.derData(fromPEM: pem),
static func rawPublicKey(fromPEM pem: String) -> Data? {
guard let der = derData(fromPEM: pem, label: "PUBLIC KEY"),
der.count == self.ed25519SPKIPrefix.count + 32,
der.prefix(self.ed25519SPKIPrefix.count) == self.ed25519SPKIPrefix
else { return nil }
return der.suffix(32)
}
private static func rawPrivateKey(fromPEM pem: String) -> Data? {
guard let der = self.derData(fromPEM: pem),
static func rawPrivateKey(fromPEM pem: String) -> Data? {
guard let der = derData(fromPEM: pem, label: "PRIVATE KEY"),
der.count == self.ed25519PKCS8PrivatePrefix.count + 32,
der.prefix(self.ed25519PKCS8PrivatePrefix.count) == self.ed25519PKCS8PrivatePrefix
else { return nil }
return der.suffix(32)
}
private static func keyPairMatches(publicKeyData: Data, privateKeyData: Data) -> Bool {
static func keyPairMatches(publicKeyData: Data, privateKeyData: Data) -> Bool {
guard let privateKey = try? Curve25519.Signing.PrivateKey(rawRepresentation: privateKeyData)
else {
return false
@@ -423,45 +393,53 @@ public enum DeviceIdentityStore {
return privateKey.publicKey.rawRepresentation == publicKeyData
}
private static func derData(fromPEM pem: String) -> Data? {
let body = pem
.split(whereSeparator: \.isNewline)
.filter { !$0.hasPrefix("-----") }
.joined()
return Data(base64Encoded: body)
private static func derData(fromPEM pem: String, label: String) -> Data? {
let lines = pem.split(separator: "\n", omittingEmptySubsequences: false)
guard lines.count >= 4,
lines.first == "-----BEGIN \(label)-----",
lines[lines.count - 2] == "-----END \(label)-----",
lines.last?.isEmpty == true
else { return nil }
let body = lines.dropFirst().dropLast(2)
guard !body.isEmpty, body.allSatisfy({ !$0.isEmpty && $0.count <= 64 }) else { return nil }
return Data(base64Encoded: body.joined())
}
private static func hasRecognizedIdentityShape(_ data: Data) -> Bool {
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return false
}
return object.keys.contains("publicKeyPem")
|| object.keys.contains("privateKeyPem")
|| object.keys.contains("publicKey")
|| object.keys.contains("privateKey")
}
private static func deviceId(publicKeyData: Data) -> String {
static func deviceId(publicKeyData: Data) -> String {
SHA256.hash(data: publicKeyData).compactMap { String(format: "%02x", $0) }.joined()
}
private static func save(_ identity: DeviceIdentity, to url: URL) {
do {
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true)
let data = try JSONEncoder().encode(identity)
try data.write(to: url, options: [.atomic])
} catch {
// best-effort only
}
static func material(
publicKeyData: Data,
privateKeyData: Data,
createdAtMs: Int64) -> DeviceIdentityMaterial
{
let identity = DeviceIdentity(
deviceId: deviceId(publicKeyData: publicKeyData),
publicKey: publicKeyData.base64EncodedString(),
privateKey: privateKeyData.base64EncodedString(),
createdAtMs: createdAtMs)
return DeviceIdentityMaterial(
identity: identity,
publicKeyPEM: self.pem(label: "PUBLIC KEY", der: self.ed25519SPKIPrefix + publicKeyData),
privateKeyPEM: self.pem(label: "PRIVATE KEY", der: self.ed25519PKCS8PrivatePrefix + privateKeyData))
}
private static func fileURL(profile: GatewayDeviceIdentityProfile) -> URL {
let base = DeviceIdentityPaths.stateDirURL()
return base
.appendingPathComponent("identity", isDirectory: true)
.appendingPathComponent(profile.identityFileName, isDirectory: false)
private static func pem(label: String, der: Data) -> String {
let base64 = der.base64EncodedString()
let fence = String(repeating: "-", count: 5)
let lines = stride(from: 0, to: base64.count, by: 64).map { offset -> String in
let start = base64.index(base64.startIndex, offsetBy: offset)
let end = base64.index(start, offsetBy: min(64, base64.distance(from: start, to: base64.endIndex)))
return String(base64[start..<end])
}
return "\(fence)BEGIN \(label)\(fence)\n\(lines.joined(separator: "\n"))\n\(fence)END \(label)\(fence)\n"
}
private static func databaseURL(stateDirURL: URL) -> URL {
stateDirURL
.appendingPathComponent("state", isDirectory: true)
.appendingPathComponent("openclaw.sqlite", isDirectory: false)
}
}
@@ -0,0 +1,984 @@
import CryptoKit
import Darwin
import Foundation
import SQLite3
enum DeviceIdentitySQLiteStore {
// Keep aligned with OPENCLAW_STATE_SCHEMA_VERSION. Swift never upgrades this database.
private static let maximumSupportedSchemaVersion: Int64 = 4
private static let busyTimeoutMilliseconds: Int32 = 5000
private static let maximumLegacyIdentityBytes = 64 * 1024
private static let maximumLegacyAuthBytes = 4 * 1024 * 1024
private static let doctorClaimSuffix = ".doctor-importing"
private static let nativeClaimSuffix = ".native-importing"
private static let tableName = "device_identities"
private static let indexName = "idx_device_identities_device"
private static let createSchemaSQL = """
CREATE TABLE IF NOT EXISTS device_identities (
identity_key TEXT NOT NULL PRIMARY KEY,
device_id TEXT NOT NULL,
public_key_pem TEXT NOT NULL,
private_key_pem TEXT NOT NULL,
created_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL
) STRICT;
CREATE INDEX IF NOT EXISTS idx_device_identities_device
ON device_identities(device_id, updated_at_ms DESC);
"""
private struct LegacyClaim {
let source: DeviceIdentityPaths.LegacyIdentitySource
let identityURL: URL
let data: Data
let snapshot: LegacyFileSnapshot
let material: DeviceIdentityMaterial
}
private struct LegacyFileSnapshot: Equatable {
let device: UInt64
let inode: UInt64
let size: UInt64
let modifiedAt: Date?
}
private struct LegacyAuthCandidate {
let data: Data
let store: DeviceAuthStoreFile
}
private struct Column: Equatable {
let name: String
let type: String
let notNull: Bool
let primaryKeyPosition: Int32
let hidden: Int32
}
private final class IdentityCoordinator {
private var database: OpaquePointer?
init(database: OpaquePointer) {
self.database = database
}
func release() throws {
guard let database else { return }
self.database = nil
var releaseError: NSError?
if sqlite3_exec(database, "ROLLBACK", nil, nil, nil) != SQLITE_OK {
releaseError = DeviceIdentityStore.storageError(
"Could not release device identity coordinator: \(String(cString: sqlite3_errmsg(database)))")
}
if sqlite3_close(database) != SQLITE_OK, releaseError == nil {
releaseError = DeviceIdentityStore.storageError("Could not close device identity coordinator")
}
if let releaseError { throw releaseError }
}
}
static func loadOrCreate(
databaseURL: URL,
destinationStateDirURL: URL,
profile: GatewayDeviceIdentityProfile,
legacySources: [DeviceIdentityPaths.LegacyIdentitySource] = [],
beforeLegacyClaim: ((DeviceIdentityPaths.LegacyIdentitySource) throws -> Void)? = nil,
afterLegacyCommit: (() throws -> Void)? = nil) throws
-> DeviceIdentity
{
let coordinator = try self.acquireIdentityCoordinator(databaseURL: databaseURL)
do {
let identity = try self.loadOrCreateOwned(
databaseURL: databaseURL,
destinationStateDirURL: destinationStateDirURL,
profile: profile,
legacySources: legacySources,
beforeLegacyClaim: beforeLegacyClaim,
afterLegacyCommit: afterLegacyCommit)
try coordinator.release()
return identity
} catch {
do {
try coordinator.release()
} catch let releaseError {
throw DeviceIdentityStore.storageError(
"Device identity operation failed: \(error.localizedDescription); " +
"coordinator release failed: \(releaseError.localizedDescription)")
}
throw error
}
}
private static func loadOrCreateOwned(
databaseURL: URL,
destinationStateDirURL: URL,
profile: GatewayDeviceIdentityProfile,
legacySources: [DeviceIdentityPaths.LegacyIdentitySource],
beforeLegacyClaim: ((DeviceIdentityPaths.LegacyIdentitySource) throws -> Void)?,
afterLegacyCommit: (() throws -> Void)?) throws -> DeviceIdentity
{
try self.secureDirectory(destinationStateDirURL)
try self.secureDirectory(databaseURL.deletingLastPathComponent())
var claims: [LegacyClaim] = []
do {
for source in legacySources {
if let claim = try self.claimLegacyIdentity(source, beforeClaim: beforeLegacyClaim) {
claims.append(claim)
}
}
return try self.loadOrCreate(
databaseURL: databaseURL,
destinationStateDirURL: destinationStateDirURL,
profile: profile,
claims: claims,
afterLegacyCommit: afterLegacyCommit)
} catch {
do {
try self.restoreClaimedLegacyIdentities(claims)
} catch let restoreError {
throw DeviceIdentityStore.storageError(
"Device identity migration failed: \(error.localizedDescription); " +
"native claim restoration failed: \(restoreError.localizedDescription)")
}
throw error
}
}
private static func loadOrCreate(
databaseURL: URL,
destinationStateDirURL: URL,
profile: GatewayDeviceIdentityProfile,
claims: [LegacyClaim],
afterLegacyCommit: (() throws -> Void)?) throws -> DeviceIdentity
{
try self.requireConsistentClaims(claims)
let generatedMaterial = claims.isEmpty ? DeviceIdentityStore.generateMaterial() : nil
let writeTimestampMs = Int64(Date().timeIntervalSince1970 * 1000)
var database: OpaquePointer?
let flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX
let openResult = sqlite3_open_v2(databaseURL.path, &database, flags, nil)
guard openResult == SQLITE_OK, let database else {
let message = database.map { String(cString: sqlite3_errmsg($0)) } ?? "unknown SQLite error"
if let database { sqlite3_close(database) }
throw DeviceIdentityStore.storageError("Could not open device identity database: \(message)")
}
defer {
sqlite3_close(database)
try? self.secureDatabaseFiles(databaseURL)
}
guard sqlite3_busy_timeout(database, self.busyTimeoutMilliseconds) == SQLITE_OK else {
throw self.databaseError(database, operation: "configure SQLite busy timeout")
}
try self.secureDatabaseFiles(databaseURL)
try self.execute(database, sql: "BEGIN IMMEDIATE")
var committed = false
defer {
if !committed {
try? self.execute(database, sql: "ROLLBACK")
}
}
try self.ensureSchema(database, allowFreshCreation: true)
let existing = try self.readIdentity(database, key: profile.rawValue)
let selected: DeviceIdentityMaterial
if let existing {
if let migrated = claims.first?.material,
!self.hasSameKeyMaterial(migrated, existing)
{
throw DeviceIdentityStore.storageError(
"Legacy device identity conflicts with SQLite identity key \(profile.rawValue); source preserved")
}
selected = existing
} else {
guard let candidate = claims.first?.material ?? generatedMaterial else {
throw DeviceIdentityStore.storageError("Device identity candidate is unavailable")
}
selected = candidate
try self.insertIdentity(
database,
key: profile.rawValue,
material: selected,
updatedAtMs: writeTimestampMs)
}
// The row reread under the write transaction is authoritative. Never return generated
// or migrated key material unless SQLite reports the exact canonical receipt.
guard let authoritative = try self.readIdentity(database, key: profile.rawValue),
authoritative == selected
else {
throw DeviceIdentityStore.storageError("SQLite did not preserve the authoritative device identity")
}
try self.ensureSchema(database, allowFreshCreation: false)
try self.execute(database, sql: "COMMIT")
committed = true
try self.secureDatabaseFiles(databaseURL)
if !claims.isEmpty {
try afterLegacyCommit?()
// The committed reread is the destructive-cleanup receipt. Doctor cannot alter the
// row while the native claim remains visible to every Node identity entry point.
guard let committedIdentity = try self.readIdentity(database, key: profile.rawValue),
committedIdentity == authoritative
else {
throw DeviceIdentityStore.storageError(
"Committed SQLite identity changed before legacy cleanup; native claim preserved")
}
try self.relocateLegacyAuthIfNeeded(
claims: claims,
destinationStateDirURL: destinationStateDirURL,
profile: profile,
deviceId: authoritative.identity.deviceId)
try self.removeClaimedLegacyIdentities(claims)
}
return authoritative.identity
}
private static func acquireIdentityCoordinator(databaseURL: URL) throws -> IdentityCoordinator {
let canonicalPath = self.canonicalDatabasePath(databaseURL)
let digest = SHA256.hash(data: Data(canonicalPath.utf8))
let pathHash = digest.prefix(4).map { String(format: "%02x", $0) }.joined()
let lockDirectoryURL = FileManager.default.temporaryDirectory
.appendingPathComponent("openclaw-\(getuid())", isDirectory: true)
try self.secureCoordinatorDirectory(lockDirectoryURL)
let coordinatorURL = lockDirectoryURL.appendingPathComponent(
"device-identity.\(pathHash).lock.sqlite",
isDirectory: false)
var database: OpaquePointer?
let flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX
let openResult = sqlite3_open_v2(coordinatorURL.path, &database, flags, nil)
guard openResult == SQLITE_OK, let database else {
let message = database.map { String(cString: sqlite3_errmsg($0)) } ?? "unknown SQLite error"
if let database { sqlite3_close(database) }
throw DeviceIdentityStore.storageError("Could not open device identity coordinator: \(message)")
}
do {
guard sqlite3_busy_timeout(database, self.busyTimeoutMilliseconds) == SQLITE_OK else {
throw self.databaseError(database, operation: "configure device identity coordinator timeout")
}
try self.execute(database, sql: "BEGIN EXCLUSIVE")
try self.secureFile(coordinatorURL)
return IdentityCoordinator(database: database)
} catch {
sqlite3_close(database)
throw error
}
}
private static func secureCoordinatorDirectory(_ url: URL) throws {
var info = stat()
if lstat(url.path, &info) != 0 {
let inspectError = errno
guard inspectError == ENOENT else {
throw POSIXError(POSIXErrorCode(rawValue: inspectError) ?? .EIO)
}
if mkdir(url.path, mode_t(0o700)) != 0, errno != EEXIST {
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
}
guard lstat(url.path, &info) == 0 else {
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
}
}
guard info.st_mode & mode_t(S_IFMT) == mode_t(S_IFDIR),
info.st_uid == geteuid()
else {
throw DeviceIdentityStore.storageError(
"Device identity coordinator directory must be a user-owned real directory")
}
guard chmod(url.path, mode_t(0o700)) == 0 else {
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
}
guard lstat(url.path, &info) == 0,
info.st_mode & mode_t(S_IFMT) == mode_t(S_IFDIR),
info.st_uid == geteuid(),
info.st_mode & mode_t(0o077) == 0
else {
throw DeviceIdentityStore.storageError(
"Device identity coordinator directory permissions are not private")
}
}
private static func canonicalDatabasePath(_ databaseURL: URL) -> String {
let fileManager = FileManager.default
let resolved = databaseURL.standardizedFileURL
var current = resolved
var missingSegments: [String] = []
while !fileManager.fileExists(atPath: current.path) {
let parent = current.deletingLastPathComponent()
guard parent.path != current.path else { return resolved.path }
missingSegments.insert(current.lastPathComponent, at: 0)
current = parent
}
var canonical = current.resolvingSymlinksInPath().standardizedFileURL
for segment in missingSegments {
canonical.appendPathComponent(segment)
}
return canonical.standardizedFileURL.path
}
private static func ensureSchema(_ database: OpaquePointer, allowFreshCreation: Bool) throws {
let userVersion = try self.queryInt64(database, sql: "PRAGMA user_version")
guard userVersion <= self.maximumSupportedSchemaVersion else {
let message =
"Device identity database uses newer schema version \(userVersion); " +
"this build supports \(self.maximumSupportedSchemaVersion)"
throw DeviceIdentityStore.storageError(message)
}
if try !self.schemaObjectExists(database, type: "table", name: self.tableName) {
let objectCount = try self.queryInt64(
database,
sql: "SELECT COUNT(*) FROM sqlite_schema WHERE name NOT LIKE 'sqlite_%'")
guard allowFreshCreation, userVersion == 0, objectCount == 0 else {
throw DeviceIdentityStore.storageError("Nonempty OpenClaw database is missing device_identities")
}
try self.execute(database, sql: self.createSchemaSQL)
}
try self.validateDatabaseOwnership(database, userVersion: userVersion)
let expectedColumns = [
Column(name: "identity_key", type: "TEXT", notNull: true, primaryKeyPosition: 1, hidden: 0),
Column(name: "device_id", type: "TEXT", notNull: true, primaryKeyPosition: 0, hidden: 0),
Column(name: "public_key_pem", type: "TEXT", notNull: true, primaryKeyPosition: 0, hidden: 0),
Column(name: "private_key_pem", type: "TEXT", notNull: true, primaryKeyPosition: 0, hidden: 0),
Column(name: "created_at_ms", type: "INTEGER", notNull: true, primaryKeyPosition: 0, hidden: 0),
Column(name: "updated_at_ms", type: "INTEGER", notNull: true, primaryKeyPosition: 0, hidden: 0),
]
guard try self.tableColumns(database) == expectedColumns else {
throw DeviceIdentityStore.storageError("device_identities has an incompatible schema")
}
let tableSQL = try self.queryText(
database,
sql: "SELECT sql FROM sqlite_schema WHERE type = 'table' AND name = 'device_identities'") ?? ""
let normalizedTableSQL = tableSQL
.split(whereSeparator: \.isWhitespace)
.joined(separator: " ")
.uppercased()
guard normalizedTableSQL.hasSuffix(") STRICT") else {
throw DeviceIdentityStore.storageError("device_identities must be a STRICT table")
}
guard try self.validRequiredIndex(database) else {
throw DeviceIdentityStore.storageError("idx_device_identities_device has an incompatible schema")
}
}
private static func validateDatabaseOwnership(
_ database: OpaquePointer,
userVersion: Int64) throws
{
if userVersion == 0 {
let statement = try self.prepare(
database,
sql: """
SELECT type, name
FROM sqlite_schema
WHERE name NOT LIKE 'sqlite_%'
ORDER BY type, name
""")
defer { sqlite3_finalize(statement) }
var objects: [(String, String)] = []
while true {
let result = sqlite3_step(statement)
if result == SQLITE_DONE { break }
guard result == SQLITE_ROW else {
throw self.databaseError(database, operation: "validate Swift identity database ownership")
}
try objects.append((
self.requiredText(statement, column: 0, field: "schema object type"),
self.requiredText(statement, column: 1, field: "schema object name")))
}
guard objects.count == 2,
objects[0].0 == "index", objects[0].1 == self.indexName,
objects[1].0 == "table", objects[1].1 == self.tableName
else {
throw DeviceIdentityStore.storageError(
"Schema version zero database contains objects not owned by the Swift identity store")
}
return
}
let statement = try self.prepare(
database,
sql: "SELECT role, schema_version FROM schema_meta WHERE meta_key = 'primary' LIMIT 1")
defer { sqlite3_finalize(statement) }
guard sqlite3_step(statement) == SQLITE_ROW,
try self.requiredText(statement, column: 0, field: "schema role") == "global",
sqlite3_column_type(statement, 1) == SQLITE_INTEGER,
sqlite3_column_int64(statement, 1) == userVersion,
sqlite3_step(statement) == SQLITE_DONE
else {
throw DeviceIdentityStore.storageError(
"OpenClaw state database schema metadata does not match its global schema version")
}
}
private static func tableColumns(_ database: OpaquePointer) throws -> [Column] {
let statement = try self.prepare(database, sql: "PRAGMA table_xinfo('device_identities')")
defer { sqlite3_finalize(statement) }
var columns: [Column] = []
while true {
let result = sqlite3_step(statement)
if result == SQLITE_DONE { return columns }
guard result == SQLITE_ROW else {
throw self.databaseError(database, operation: "inspect device identity columns")
}
try columns.append(Column(
name: self.requiredText(statement, column: 1, field: "column name"),
type: self.requiredText(statement, column: 2, field: "column type").uppercased(),
notNull: sqlite3_column_int(statement, 3) == 1,
primaryKeyPosition: sqlite3_column_int(statement, 5),
hidden: sqlite3_column_int(statement, 6)))
}
}
private static func validRequiredIndex(_ database: OpaquePointer) throws -> Bool {
let list = try self.prepare(database, sql: "PRAGMA index_list('device_identities')")
defer { sqlite3_finalize(list) }
var found = false
while true {
let result = sqlite3_step(list)
if result == SQLITE_DONE { break }
guard result == SQLITE_ROW else {
throw self.databaseError(database, operation: "inspect device identity indexes")
}
let name = try self.requiredText(list, column: 1, field: "index name")
if name == self.indexName {
found = sqlite3_column_int(list, 2) == 0 && sqlite3_column_int(list, 4) == 0
}
}
guard found else { return false }
let details = try self.prepare(database, sql: "PRAGMA index_xinfo('idx_device_identities_device')")
defer { sqlite3_finalize(details) }
var keyColumns: [(String, Bool)] = []
while true {
let result = sqlite3_step(details)
if result == SQLITE_DONE { break }
guard result == SQLITE_ROW else {
throw self.databaseError(database, operation: "inspect device identity index columns")
}
guard sqlite3_column_int(details, 5) == 1 else { continue }
try keyColumns.append((
self.requiredText(details, column: 2, field: "index column"),
sqlite3_column_int(details, 3) == 1))
}
return keyColumns.count == 2
&& keyColumns[0].0 == "device_id" && !keyColumns[0].1
&& keyColumns[1].0 == "updated_at_ms" && keyColumns[1].1
}
private static func readIdentity(
_ database: OpaquePointer,
key: String) throws -> DeviceIdentityMaterial?
{
let statement = try self.prepare(
database,
sql: """
SELECT device_id, public_key_pem, private_key_pem, created_at_ms, updated_at_ms
FROM device_identities
WHERE identity_key = ?
""")
defer { sqlite3_finalize(statement) }
try self.bindText(statement, index: 1, value: key, database: database)
let result = sqlite3_step(statement)
if result == SQLITE_DONE { return nil }
guard result == SQLITE_ROW else {
throw self.databaseError(database, operation: "read device identity")
}
guard sqlite3_column_type(statement, 3) == SQLITE_INTEGER,
sqlite3_column_type(statement, 4) == SQLITE_INTEGER,
sqlite3_column_int64(statement, 4) >= 0
else {
throw DeviceIdentityStore.storageError("SQLite device identity timestamps must be integers")
}
let material = try DeviceIdentityStore.material(
deviceId: self.requiredText(statement, column: 0, field: "device_id"),
publicKeyPEM: self.requiredText(statement, column: 1, field: "public_key_pem"),
privateKeyPEM: self.requiredText(statement, column: 2, field: "private_key_pem"),
createdAtMs: sqlite3_column_int64(statement, 3))
guard sqlite3_step(statement) == SQLITE_DONE else {
throw DeviceIdentityStore.storageError("SQLite returned duplicate device identity keys")
}
return material
}
private static func insertIdentity(
_ database: OpaquePointer,
key: String,
material: DeviceIdentityMaterial,
updatedAtMs: Int64) throws
{
let statement = try self.prepare(
database,
sql: """
INSERT INTO device_identities (
identity_key, device_id, public_key_pem, private_key_pem, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?)
""")
defer { sqlite3_finalize(statement) }
try self.bindText(statement, index: 1, value: key, database: database)
try self.bindText(statement, index: 2, value: material.identity.deviceId, database: database)
try self.bindText(statement, index: 3, value: material.publicKeyPEM, database: database)
try self.bindText(statement, index: 4, value: material.privateKeyPEM, database: database)
guard sqlite3_bind_int64(statement, 5, material.identity.createdAtMs) == SQLITE_OK,
sqlite3_bind_int64(statement, 6, updatedAtMs) == SQLITE_OK
else {
throw self.databaseError(database, operation: "bind device identity timestamps")
}
guard sqlite3_step(statement) == SQLITE_DONE, sqlite3_changes(database) == 1 else {
throw self.databaseError(database, operation: "insert device identity")
}
}
private static func claimLegacyIdentity(
_ source: DeviceIdentityPaths.LegacyIdentitySource,
beforeClaim: ((DeviceIdentityPaths.LegacyIdentitySource) throws -> Void)?) throws -> LegacyClaim?
{
let doctorClaimURL = self.claimURL(source.identityURL, suffix: self.doctorClaimSuffix)
let nativeClaimURL = self.claimURL(source.identityURL, suffix: self.nativeClaimSuffix)
try beforeClaim?(source)
// Doctor and native startup atomically rename the same source to distinct claims.
// The loser observes a durable winner instead of treating an in-flight identity as absent.
var ownsNativeClaim = false
for _ in 0..<3 {
if self.pathMayExist(doctorClaimURL) {
throw DeviceIdentityStore.storageError(
"Device identity Doctor import is pending; run openclaw doctor --fix before starting the app")
}
if self.pathMayExist(nativeClaimURL) {
guard !self.pathMayExist(source.identityURL) else {
throw DeviceIdentityStore.storageError(
"Legacy device identity source and interrupted native claim both exist")
}
ownsNativeClaim = true
break
}
// Claims first, source last: a Doctor restore moves claim -> source atomically.
guard self.pathMayExist(source.identityURL) else { return nil }
let renameResult = source.identityURL.path.withCString { sourcePath in
nativeClaimURL.path.withCString { destinationPath in
renamex_np(sourcePath, destinationPath, UInt32(RENAME_EXCL))
}
}
if renameResult == 0 {
ownsNativeClaim = true
break
}
let renameError = errno
guard renameError == ENOENT || renameError == EEXIST else {
throw DeviceIdentityStore.storageError(
"Could not claim legacy device identity: \(String(cString: strerror(renameError)))")
}
}
guard ownsNativeClaim else {
throw DeviceIdentityStore.storageError("Legacy device identity changed while being claimed")
}
do {
let before = try self.legacyFileSnapshot(
nativeClaimURL,
beneath: source.stateDirURL,
maximumBytes: self.maximumLegacyIdentityBytes)
let data = try Data(contentsOf: nativeClaimURL, options: [.mappedIfSafe])
guard data.count <= self.maximumLegacyIdentityBytes else {
throw DeviceIdentityStore.storageError("Legacy device identity exceeds the maximum supported size")
}
let after = try self.legacyFileSnapshot(
nativeClaimURL,
beneath: source.stateDirURL,
maximumBytes: self.maximumLegacyIdentityBytes)
guard before == after, UInt64(data.count) == before.size else {
throw DeviceIdentityStore.storageError("Legacy device identity changed while being claimed")
}
let material = try DeviceIdentityStore.material(fromLegacyData: data)
return LegacyClaim(
source: source,
identityURL: nativeClaimURL,
data: data,
snapshot: before,
material: material)
} catch {
do {
try self.restoreClaimedLegacyIdentity(
identityURL: nativeClaimURL,
sourceURL: source.identityURL)
} catch let restoreError {
throw DeviceIdentityStore.storageError(
"Legacy device identity validation failed: \(error.localizedDescription); " +
"native claim restoration failed: \(restoreError.localizedDescription)")
}
throw error
}
}
private static func claimURL(_ sourceURL: URL, suffix: String) -> URL {
URL(
fileURLWithPath: sourceURL.path + suffix,
isDirectory: false)
}
private static func pathMayExist(_ url: URL) -> Bool {
let fileManager = FileManager.default
return fileManager.fileExists(atPath: url.path)
|| (try? fileManager.destinationOfSymbolicLink(atPath: url.path)) != nil
}
private static func legacyFileSnapshot(
_ url: URL,
beneath rootURL: URL,
maximumBytes: Int) throws -> LegacyFileSnapshot
{
try self.requireNoSymlinkTraversal(url, beneath: rootURL)
let resourceValues = try url.resourceValues(forKeys: [.isSymbolicLinkKey, .isRegularFileKey])
guard resourceValues.isSymbolicLink != true, resourceValues.isRegularFile == true else {
throw DeviceIdentityStore.storageError("Legacy device identity source must be a regular non-symbolic file")
}
let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
guard attributes[.type] as? FileAttributeType == .typeRegular,
let linkCount = attributes[.referenceCount] as? NSNumber,
linkCount.uint64Value == 1,
let device = attributes[.systemNumber] as? NSNumber,
let inode = attributes[.systemFileNumber] as? NSNumber,
let size = attributes[.size] as? NSNumber,
size.uint64Value <= UInt64(maximumBytes)
else {
throw DeviceIdentityStore.storageError(
"Legacy device identity source must be a bounded regular file with exactly one link")
}
return LegacyFileSnapshot(
device: device.uint64Value,
inode: inode.uint64Value,
size: size.uint64Value,
modifiedAt: attributes[.modificationDate] as? Date)
}
private static func requireNoSymlinkTraversal(_ url: URL, beneath rootURL: URL) throws {
let root = rootURL.standardizedFileURL
let candidate = url.standardizedFileURL
let rootPrefix = root.path.hasSuffix("/") ? root.path : root.path + "/"
guard candidate.path.hasPrefix(rootPrefix) else {
throw DeviceIdentityStore.storageError("Legacy device identity path escaped its state directory")
}
let relativePath = String(candidate.path.dropFirst(rootPrefix.count))
let expected = root.resolvingSymlinksInPath()
.appendingPathComponent(relativePath, isDirectory: false)
.standardizedFileURL
guard candidate.resolvingSymlinksInPath().standardizedFileURL == expected else {
throw DeviceIdentityStore.storageError("Legacy device identity path must not traverse symbolic links")
}
}
private static func requireConsistentClaims(_ claims: [LegacyClaim]) throws {
guard let first = claims.first else { return }
guard claims.dropFirst().allSatisfy({ self.hasSameKeyMaterial($0.material, first.material) }) else {
throw DeviceIdentityStore.storageError("Legacy device identity sources conflict; all sources preserved")
}
}
private static func hasSameKeyMaterial(
_ lhs: DeviceIdentityMaterial,
_ rhs: DeviceIdentityMaterial) -> Bool
{
lhs.identity.deviceId == rhs.identity.deviceId
&& lhs.identity.publicKey == rhs.identity.publicKey
&& lhs.identity.privateKey == rhs.identity.privateKey
}
private static func relocateLegacyAuthIfNeeded(
claims: [LegacyClaim],
destinationStateDirURL: URL,
profile: GatewayDeviceIdentityProfile,
deviceId: String) throws
{
let fileManager = FileManager.default
let destinationIdentityDirURL = destinationStateDirURL
.appendingPathComponent("identity", isDirectory: true)
let destinationAuthURL = destinationIdentityDirURL
.appendingPathComponent(profile.authFileName, isDirectory: false)
let sourceAuth = try claims.compactMap { claim -> LegacyAuthCandidate? in
let source = claim.source
guard source.stateDirURL.standardizedFileURL != destinationStateDirURL.standardizedFileURL,
fileManager.fileExists(atPath: source.authURL.path)
else { return nil }
return try self.readDeviceAuth(
source.authURL,
beneath: source.stateDirURL,
deviceId: deviceId)
}
if let firstSourceAuth = sourceAuth.first,
!sourceAuth.dropFirst().allSatisfy({ $0.store == firstSourceAuth.store })
{
throw DeviceIdentityStore.storageError(
"Legacy device auth sources conflict; all identity sources preserved")
}
if fileManager.fileExists(atPath: destinationAuthURL.path) {
let destinationAuth = try self.readDeviceAuth(
destinationAuthURL,
beneath: destinationStateDirURL,
deviceId: deviceId)
guard sourceAuth.allSatisfy({ $0.store == destinationAuth.store }) else {
throw DeviceIdentityStore.storageError(
"Destination device auth differs from legacy auth; identity source preserved")
}
return
}
guard let selectedAuth = sourceAuth.first else { return }
// DeviceAuthStore remains file-backed. Copy it when identity ownership moves between
// Apple containers, but never delete or rewrite the source auth file.
try self.secureDirectory(destinationIdentityDirURL)
let temporaryAuthURL = destinationIdentityDirURL.appendingPathComponent(
".\(profile.authFileName).identity-migrating-\(UUID().uuidString)",
isDirectory: false)
defer { try? fileManager.removeItem(at: temporaryAuthURL) }
try selectedAuth.data.write(to: temporaryAuthURL, options: [.atomic])
try self.secureFile(temporaryAuthURL)
// Publish only complete bytes, and never replace a token another process won first.
// Foundation rejects atomic + withoutOverwriting, so use Darwin's exclusive rename.
let renameResult = temporaryAuthURL.path.withCString { sourcePath in
destinationAuthURL.path.withCString { destinationPath in
renamex_np(sourcePath, destinationPath, UInt32(RENAME_EXCL))
}
}
if renameResult != 0 {
let renameError = errno
guard renameError == EEXIST else {
throw DeviceIdentityStore.storageError(
"Could not publish migrated device auth: \(String(cString: strerror(renameError)))")
}
let destinationAuth = try self.readDeviceAuth(
destinationAuthURL,
beneath: destinationStateDirURL,
deviceId: deviceId)
guard destinationAuth.store == selectedAuth.store else {
throw DeviceIdentityStore.storageError(
"Concurrently created device auth differs from legacy auth; identity source preserved")
}
return
}
try self.secureFile(destinationAuthURL)
}
private static func readDeviceAuth(
_ url: URL,
beneath stateDirURL: URL,
deviceId: String) throws -> LegacyAuthCandidate
{
let before = try self.legacyFileSnapshot(
url,
beneath: stateDirURL,
maximumBytes: self.maximumLegacyAuthBytes)
let data = try Data(contentsOf: url, options: [.mappedIfSafe])
let after = try self.legacyFileSnapshot(
url,
beneath: stateDirURL,
maximumBytes: self.maximumLegacyAuthBytes)
guard before == after, UInt64(data.count) == before.size else {
throw DeviceIdentityStore.storageError("Device auth changed during identity migration")
}
guard let decoded = try? JSONDecoder().decode(DeviceAuthStoreFile.self, from: data),
let normalized = DeviceAuthStore.normalizedStore(decoded),
normalized.deviceId == deviceId
else {
throw DeviceIdentityStore.storageError(
"Device auth does not belong to the migrated device identity; source preserved")
}
return LegacyAuthCandidate(data: data, store: normalized)
}
private static func removeClaimedLegacyIdentities(_ claims: [LegacyClaim]) throws {
let fileManager = FileManager.default
for claim in claims {
guard !self.pathMayExist(claim.source.identityURL) else {
throw DeviceIdentityStore.storageError(
"Legacy device identity source reappeared during migration; native claim preserved")
}
guard fileManager.fileExists(atPath: claim.identityURL.path) else {
if (try? fileManager.destinationOfSymbolicLink(atPath: claim.identityURL.path)) != nil {
throw DeviceIdentityStore.storageError(
"Legacy device identity changed to a symbolic link; source preserved")
}
continue
}
let snapshot = try self.legacyFileSnapshot(
claim.identityURL,
beneath: claim.source.stateDirURL,
maximumBytes: self.maximumLegacyIdentityBytes)
let current = try Data(contentsOf: claim.identityURL, options: [.mappedIfSafe])
guard snapshot == claim.snapshot, current == claim.data else {
throw DeviceIdentityStore
.storageError("Legacy device identity changed during migration; source preserved")
}
}
for claim in claims where fileManager.fileExists(atPath: claim.identityURL.path) {
try fileManager.removeItem(at: claim.identityURL)
}
}
private static func restoreClaimedLegacyIdentities(_ claims: [LegacyClaim]) throws {
var restorationErrors: [String] = []
for claim in claims.reversed() {
do {
try self.restoreClaimedLegacyIdentity(
identityURL: claim.identityURL,
sourceURL: claim.source.identityURL)
} catch {
restorationErrors.append("\(claim.source.identityURL.path): \(error.localizedDescription)")
}
}
if !restorationErrors.isEmpty {
throw DeviceIdentityStore.storageError(
"Could not restore every native device identity claim: " +
restorationErrors.joined(separator: "; "))
}
}
private static func restoreClaimedLegacyIdentity(identityURL: URL, sourceURL: URL) throws {
guard self.pathMayExist(identityURL) else { return }
let renameResult = identityURL.path.withCString { claimedPath in
sourceURL.path.withCString { destinationPath in
renamex_np(claimedPath, destinationPath, UInt32(RENAME_EXCL))
}
}
guard renameResult == 0 else {
let renameError = errno
if renameError == ENOENT, !self.pathMayExist(identityURL) {
return
}
throw DeviceIdentityStore.storageError(
"Could not restore legacy device identity: \(String(cString: strerror(renameError)))")
}
}
private static func schemaObjectExists(
_ database: OpaquePointer,
type: String,
name: String) throws -> Bool
{
let statement = try self.prepare(
database,
sql: "SELECT 1 FROM sqlite_schema WHERE type = ? AND name = ? LIMIT 1")
defer { sqlite3_finalize(statement) }
try self.bindText(statement, index: 1, value: type, database: database)
try self.bindText(statement, index: 2, value: name, database: database)
let result = sqlite3_step(statement)
if result == SQLITE_ROW { return true }
if result == SQLITE_DONE { return false }
throw self.databaseError(database, operation: "inspect SQLite schema")
}
private static func queryInt64(_ database: OpaquePointer, sql: String) throws -> Int64 {
let statement = try self.prepare(database, sql: sql)
defer { sqlite3_finalize(statement) }
guard sqlite3_step(statement) == SQLITE_ROW,
sqlite3_column_type(statement, 0) == SQLITE_INTEGER
else {
throw self.databaseError(database, operation: "read SQLite integer")
}
let value = sqlite3_column_int64(statement, 0)
guard sqlite3_step(statement) == SQLITE_DONE else {
throw DeviceIdentityStore.storageError("SQLite integer query returned multiple rows")
}
return value
}
private static func queryText(_ database: OpaquePointer, sql: String) throws -> String? {
let statement = try self.prepare(database, sql: sql)
defer { sqlite3_finalize(statement) }
let result = sqlite3_step(statement)
if result == SQLITE_DONE { return nil }
guard result == SQLITE_ROW else {
throw self.databaseError(database, operation: "read SQLite text")
}
let value = try self.requiredText(statement, column: 0, field: "query result")
guard sqlite3_step(statement) == SQLITE_DONE else {
throw DeviceIdentityStore.storageError("SQLite text query returned multiple rows")
}
return value
}
private static func execute(_ database: OpaquePointer, sql: String) throws {
var errorMessage: UnsafeMutablePointer<CChar>?
let result = sqlite3_exec(database, sql, nil, nil, &errorMessage)
guard result == SQLITE_OK else {
let detail = errorMessage.map { String(cString: $0) } ?? String(cString: sqlite3_errmsg(database))
sqlite3_free(errorMessage)
throw DeviceIdentityStore.storageError("SQLite operation failed: \(detail)")
}
}
private static func prepare(_ database: OpaquePointer, sql: String) throws -> OpaquePointer {
var statement: OpaquePointer?
guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK, let statement else {
throw self.databaseError(database, operation: "prepare SQLite statement")
}
return statement
}
private static func bindText(
_ statement: OpaquePointer,
index: Int32,
value: String,
database: OpaquePointer) throws
{
let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
guard sqlite3_bind_text(statement, index, value, -1, transient) == SQLITE_OK else {
throw self.databaseError(database, operation: "bind SQLite text")
}
}
private static func requiredText(
_ statement: OpaquePointer,
column: Int32,
field: String) throws -> String
{
guard sqlite3_column_type(statement, column) == SQLITE_TEXT,
let pointer = sqlite3_column_text(statement, column)
else {
throw DeviceIdentityStore.storageError("SQLite \(field) must be text")
}
return String(cString: pointer)
}
private static func databaseError(
_ database: OpaquePointer,
operation: String) -> NSError
{
DeviceIdentityStore.storageError("Could not \(operation): \(String(cString: sqlite3_errmsg(database)))")
}
}
extension DeviceIdentitySQLiteStore {
private static func secureDirectory(_ url: URL) throws {
let fileManager = FileManager.default
try fileManager.createDirectory(at: url, withIntermediateDirectories: true)
var attributes: [FileAttributeKey: Any] = [.posixPermissions: 0o700]
#if os(iOS) || os(watchOS)
attributes[.protectionKey] = FileProtectionType.completeUntilFirstUserAuthentication
#endif
try fileManager.setAttributes(attributes, ofItemAtPath: url.path)
}
private static func secureFile(_ url: URL) throws {
guard FileManager.default.fileExists(atPath: url.path) else { return }
var attributes: [FileAttributeKey: Any] = [.posixPermissions: 0o600]
#if os(iOS) || os(watchOS)
attributes[.protectionKey] = FileProtectionType.completeUntilFirstUserAuthentication
#endif
try FileManager.default.setAttributes(attributes, ofItemAtPath: url.path)
}
private static func secureDatabaseFiles(_ databaseURL: URL) throws {
try self.secureFile(databaseURL)
for suffix in ["-wal", "-shm", "-journal"] {
try self.secureFile(URL(fileURLWithPath: databaseURL.path + suffix, isDirectory: false))
}
}
}
@@ -430,6 +430,20 @@ public actor GatewayChannelActor {
}
}
private static func loadDeviceIdentityForConnect(
includeDeviceIdentity: Bool,
profile: GatewayDeviceIdentityProfile) throws -> DeviceIdentity?
{
guard includeDeviceIdentity else { return nil }
guard let identity = DeviceIdentityStore.loadOrCreatePersisted(profile: profile) else {
throw NSError(
domain: "Gateway",
code: 3,
userInfo: [NSLocalizedDescriptionKey: "Could not access the persisted device identity"])
}
return identity
}
private func sendConnect(
task: WebSocketTaskBox,
attemptID: UUID,
@@ -460,7 +474,9 @@ public actor GatewayChannelActor {
let includeDeviceIdentity = options.includeDeviceIdentity
let allowStoredDeviceAuth = options.allowStoredDeviceAuth
let deviceAuthGatewayID = options.deviceAuthGatewayID
let identity = includeDeviceIdentity ? DeviceIdentityStore.loadOrCreate(profile: deviceIdentityProfile) : nil
let identity = try Self.loadDeviceIdentityForConnect(
includeDeviceIdentity: includeDeviceIdentity,
profile: deviceIdentityProfile)
let selectedAuth = self.selectConnectAuth(
role: role,
includeDeviceIdentity: includeDeviceIdentity,
@@ -52,7 +52,9 @@ public enum ShareGatewayRelaySettings {
if config.gatewayStableID?.isEmpty == false {
return config
}
let identity = DeviceIdentityStore.loadOrCreate(profile: .shareExtension)
guard let identity = DeviceIdentityStore.loadOrCreatePersisted(profile: .shareExtension) else {
return config
}
DeviceAuthStore.discardUnscopedTokens(
deviceId: identity.deviceId,
profile: .shareExtension)
@@ -46,9 +46,12 @@ struct DeviceAuthPayloadTests {
func `signed device dictionary preserves 64-bit timestamp`() throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
let identityURL = tempDir.appendingPathComponent("device.json", isDirectory: false)
let databaseURL = tempDir.appendingPathComponent("openclaw.sqlite", isDirectory: false)
defer { try? FileManager.default.removeItem(at: tempDir) }
let identity = DeviceIdentityStore.loadOrCreate(fileURL: identityURL)
let identity = try DeviceIdentitySQLiteStore.loadOrCreate(
databaseURL: databaseURL,
destinationStateDirURL: tempDir,
profile: .primary)
let signedAtMs: Int64 = 1_800_000_000_000
let payload = GatewayDeviceAuthPayload.buildV3(
fields: .init(
File diff suppressed because it is too large Load Diff
@@ -3076,15 +3076,13 @@ struct GatewayNodeSessionTests {
@Test(.stateDirectoryIsolated)
func `failed device token write is not reported as an issued role`() async throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let blocker = tempDir.appendingPathComponent("not-a-directory", isDirectory: false)
let stateDir = try #require(ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"])
let blocker = URL(fileURLWithPath: stateDir, isDirectory: true)
.appendingPathComponent("identity", isDirectory: false)
try Data().write(to: blocker)
// Repoint the pinned state dir at a plain file to force write failures;
// the isolation trait restores the env var after the test.
setenv("OPENCLAW_STATE_DIR", blocker.path, 1)
defer { try? FileManager.default.removeItem(at: tempDir) }
// Block only the legacy auth directory. SQLite identity creation must
// still succeed so this test reaches the token persistence failure.
defer { try? FileManager.default.removeItem(at: blocker) }
let session = FakeGatewayWebSocketSession(helloAuth: [
"deviceToken": "node-device-token",
+16 -14
View File
@@ -170,8 +170,8 @@ Inspect the local node identity the Gateway verifies against:
openclaw node identity --json
```
It prints the device ID and public key from `identity/device.json` and never
creates or modifies identity files.
It prints the device ID and public key from the `primary` row in
`state/openclaw.sqlite` and never creates the database or a new identity.
On tightly controlled node networks, the Gateway operator can explicitly opt in
to auto-approving first-time node pairing from trusted CIDRs:
@@ -204,11 +204,11 @@ identity that the Gateway uses for pairing and routing. This state lives in the
OpenClaw state directory (`~/.openclaw` by default, or `$OPENCLAW_STATE_DIR`
when set):
| State | Purpose |
| -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `state/openclaw.sqlite` (`node_host_config`) | Client instance ID, display name, and Gateway connection metadata. The client sends this ID as `instanceId`. |
| `identity/device.json` | Signed Ed25519 keypair and derived device ID. For signed connections, this device ID is the routed node ID and pairing identity. |
| `identity/device-auth.json` | Paired device tokens, keyed by cryptographic device ID and role. |
| State | Purpose |
| -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `state/openclaw.sqlite` (`node_host_config`) | Client instance ID, display name, and Gateway connection metadata. The client sends this ID as `instanceId`. |
| `state/openclaw.sqlite` (`device_identities`, `primary`) | Signed Ed25519 keypair and derived device ID. For signed connections, this device ID is the routed node ID and pairing identity. |
| `identity/device-auth.json` | Paired device tokens, keyed by cryptographic device ID and role. |
`--node-id` changes only the client instance ID in shared SQLite state. It does
not change the cryptographic device ID or clear pairing auth. Migrating a retired
@@ -234,13 +234,15 @@ The two request IDs are distinct. An applicable trusted-CIDR policy can
auto-approve the first-time device-pairing step; command-surface approval remains
a separate check.
Older OpenClaw releases stored node-host state in `node.json` and could leave an
obsolete `token` field there. Stop the node host and run `openclaw doctor --fix`
once; Doctor imports the supported identity and connection fields into SQLite,
discards the unused token field, verifies the row, and removes the retired file.
Normal node commands fail closed with this repair instruction while the file or
an interrupted Doctor claim remains. Keep both files under `identity/` private;
they contain the device keypair and auth tokens.
Older OpenClaw releases stored node-host state in `node.json` and the signed
identity in `identity/device.json`. Stop the node host and run
`openclaw doctor --fix` once; Doctor claims each retired source, validates it,
imports and verifies the canonical SQLite row, then removes the old file. Normal
node commands fail closed with this repair instruction while either retired file
or an interrupted Doctor claim remains. Keep `state/openclaw.sqlite` and
`identity/device-auth.json` private; they contain the device keypair and auth
tokens. Device auth remains a separate store and is not rewritten by the
identity migration.
## Exec approvals
+1
View File
@@ -365,6 +365,7 @@ That stages grounded durable candidates into the short-term dreaming store while
- Sessions store + transcripts: from `~/.openclaw/sessions/` to `~/.openclaw/agents/<agentId>/sessions/`
- Agent dir: from `~/.openclaw/agent/` to `~/.openclaw/agents/<agentId>/agent/`
- WhatsApp auth state (Baileys): from legacy `~/.openclaw/credentials/*.json` (except `oauth.json`) to `~/.openclaw/credentials/whatsapp/<accountId>/...` (default account id: `default`)
- Signed device identity: from `~/.openclaw/identity/device.json` into the `primary` `device_identities` row in `state/openclaw.sqlite`; the separate device-auth file is left untouched
These migrations are best-effort and idempotent; doctor emits warnings when it leaves any legacy folders behind as backups. The Gateway/CLI also auto-migrates the legacy sessions + agent dir on startup so history/auth/models land in the per-agent path without a manual doctor run. WhatsApp auth is intentionally only migrated via `openclaw doctor`. Talk provider/provider-map normalization compares by structural equality, so key-order-only diffs no longer trigger repeat no-op `doctor --fix` changes.
+7 -2
View File
@@ -234,7 +234,7 @@ operators can ignore skills from every paired node with
The headless node keeps three separate state records:
- `~/.openclaw/state/openclaw.sqlite` (`node_host_config`): the client instance ID, display name, and Gateway connection metadata.
- `~/.openclaw/identity/device.json`: the signed device keypair and derived cryptographic device ID.
- `~/.openclaw/state/openclaw.sqlite` (`device_identities`, key `primary`): the signed device keypair and derived cryptographic device ID.
- `~/.openclaw/identity/device-auth.json`: paired device auth tokens keyed by cryptographic device ID and role.
For a signed node, the Gateway uses the cryptographic device ID for pairing and
@@ -243,6 +243,11 @@ node routing. The client instance ID is only connection metadata. Changing
[Identity and pairing state](/cli/node#identity-and-pairing-state) for the
supported revoke-and-re-pair flow and upgrade notes.
A retired `identity/device.json` file or interrupted Doctor claim blocks normal
identity use. Stop the node host and run `openclaw doctor --fix`; Doctor imports
the validated keypair into SQLite before removing the old file. The identity
migration leaves `identity/device-auth.json` untouched.
### Allowlist the commands
Exec approvals are **per node host**. Add allowlist entries from the gateway:
@@ -747,7 +752,7 @@ openclaw node run --host <gateway-host> --port 18789
Notes:
- Pairing is still required (the Gateway will show a device pairing prompt).
- Client instance metadata, signed device identity, and pairing auth use separate files; see [Headless identity state](#headless-identity-state).
- Client instance metadata, signed device identity, and pairing auth use separate state records; see [Headless identity state](#headless-identity-state).
- Exec approvals are enforced locally via `~/.openclaw/exec-approvals.json` (see [Exec approvals](/tools/exec-approvals)).
- On macOS, the headless node host executes `system.run` locally by default. Set `OPENCLAW_NODE_EXEC_HOST=app` to route `system.run` through the companion app exec host; add `OPENCLAW_NODE_EXEC_FALLBACK=0` to require the app host and fail closed if it is unavailable.
- Add `--tls` / `--tls-fingerprint` when the Gateway WS uses TLS.
+11 -21
View File
@@ -475,13 +475,10 @@ The branch already has a real shared SQLite base:
- The shared schema reserves an `exec_approvals_config` singleton row, but the
runtime cutover remains pending. TypeScript and the macOS companion still use
the state-scoped JSON file and must move to SQLite together.
- Device identity, device auth, and bootstrap runtime modules now keep their
SQLite snapshot readers/writers separate from doctor-only legacy JSON import
helpers. Device identity uses typed `device_identities` rows and device auth
tokens use typed `device_auth_tokens` rows. Device auth writes reconcile rows
by device/role instead of truncating the token table, and runtime no longer
routes single-token updates through the old whole-store adapter. The legacy
version-1 JSON payloads exist only as doctor import/export shapes.
- TypeScript device identity now uses typed `device_identities` rows, with
doctor-only legacy JSON import kept outside the runtime owner. Device auth is
still file-backed pending a coordinated schema and cross-runtime migration;
`device_auth_tokens` remains reserved for that follow-up.
- GitHub Copilot token exchange cache uses the shared SQLite plugin-state table
under `github-copilot/token-cache/default`. It is provider-owned cache state,
so it intentionally does not add a host schema table.
@@ -490,20 +487,13 @@ The branch already has a real shared SQLite base:
tracked SDK session, and OpenClaw keeps durable session/transcript state in
SQLite instead of compatibility marker files.
- The shared Swift runtime (`OpenClawKit`) uses the same
`state/openclaw.sqlite` rows for device identity and device auth. macOS app
helpers import the shared SQLite helpers instead of owning a second JSON or
SQLite path. A leftover legacy `identity/device.json` blocks identity creation
until doctor imports it into SQLite, matching the TypeScript and Android
startup gate.
- Android device identity uses the same TypeScript-compatible key material
stored in typed `state/openclaw.sqlite#table/device_identities` rows. It never
reads or writes `openclaw/identity/device.json`; a leftover legacy file blocks
startup until doctor imports it into SQLite.
- Android cached device auth tokens also use typed
`state/openclaw.sqlite#table/device_auth_tokens` rows and share the same
version-1 token semantics as TypeScript and Swift. Runtime no longer reads `SecurePrefs`
`gateway.deviceToken*` compatibility keys; those belong to migration/doctor
logic only.
`state/openclaw.sqlite#table/device_identities` shape and row keys for device
identity. Apple-container legacy files are imported by the Swift migration
owner because the TypeScript Doctor cannot access those containers. Swift
device auth remains file-backed for the coordinated auth follow-up.
- Android device identity and cached device auth remain app-local stores. They
require a separate Android-owned migration; the host SQLite claims do not
describe current Android behavior.
- Android notification recent-package history uses typed
`android_notification_recent_packages` rows. Runtime no longer migrates or
reads the old SharedPreferences CSV keys.
@@ -121,6 +121,7 @@ const legacyStorePatterns = [
/\bpush\/(?:apns-registrations|web-push-subscriptions|vapid-keys)\.json\b/u,
/\bmcp-oauth\/[^"'`]*\.json\b/u,
/\bnode\.json\b/u,
/\bidentity\/device\.json\b/u,
/\bsubagents\/runs\.json\b/u,
/\btmp\/skill-uploads\b/u,
/\b(?:crestodian|openclaw)\/rescue-pending\/[^"'`]*\.json\b/u,
@@ -153,6 +154,7 @@ const allowedRuntimeMigrationPaths = [
"src/infra/state-migrations.workspace-setup.ts",
"src/infra/state-migrations.web-push.ts",
"src/infra/state-migrations.node-host.ts",
"src/infra/state-migrations.device-identity.ts",
"src/infra/state-migrations.subagent-registry.ts",
"src/infra/state-migrations.rescue-pending.ts",
"src/commands/session-state-migration.ts",
+1
View File
@@ -56,6 +56,7 @@ const rawSqliteAllowPathGroups = {
"src/state/openclaw-state-db.ts",
"src/state/sqlite-schema-shape.test-support.ts",
],
"cross-process SQLite coordination locks": ["src/infra/device-identity-coordinator.ts"],
"backup snapshot maintenance": [
"src/commands/backup-verify.ts",
"src/infra/backup-create.ts",
@@ -242,12 +242,18 @@ prepare_update_restart_probe_current_install() {
local port="$1"
local log_file="$2"
local command_timeout="${OPENCLAW_UPGRADE_SURVIVOR_COMMAND_TIMEOUT:-900s}"
local doctor_log="${log_file}.doctor"
local start_epoch
local ready_epoch
echo "Preparing candidate-auth gateway for automatic update restart."
install_update_restart_systemctl_shim
seed_update_restart_probe_device_auth
if ! openclaw_e2e_maybe_timeout "$command_timeout" openclaw doctor --fix --non-interactive >"$doctor_log" 2>&1; then
echo "candidate device identity migration failed" >&2
cat "$doctor_log" >&2 || true
return 1
fi
start_epoch="$(node -e "process.stdout.write(String(Date.now()))")"
env -u OPENCLAW_GATEWAY_TOKEN -u OPENCLAW_GATEWAY_PASSWORD openclaw gateway --port "$port" --bind loopback --allow-unconfigured >"$log_file" 2>&1 &
gateway_pid="$!"
-41
View File
@@ -923,47 +923,6 @@ describe("gateway tool defaults", () => {
expect(mocks.callGateway).not.toHaveBeenCalled();
});
it("fails remote approval calls when requester device identity is not persisted", async () => {
mocks.configState.value = {
gateway: {
mode: "remote",
remote: {
url: "ws://127.0.0.1:18789",
token: "remote-token",
},
},
};
mocks.persistedDeviceIdentity = null;
mocks.callGateway.mockResolvedValueOnce({ decision: "allow-once" });
await expect(
callGatewayTool("exec.approval.waitDecision", {}, { id: "approval-id" }),
).rejects.toThrow("remote approval gateway calls require a stable device identity");
expect(mocks.callGateway).not.toHaveBeenCalled();
});
it("fails remote approval calls when requester device identity readback differs", async () => {
mocks.configState.value = {
gateway: {
mode: "remote",
remote: {
url: "wss://gateway.example",
token: "remote-token",
},
},
};
mocks.persistedDeviceIdentity = {
...mocks.deviceIdentity,
deviceId: "other-device",
};
mocks.callGateway.mockResolvedValueOnce({ decision: "allow-once" });
await expect(
callGatewayTool("exec.approval.waitDecision", {}, { id: "approval-id" }),
).rejects.toThrow("remote approval gateway calls require a stable device identity");
expect(mocks.callGateway).not.toHaveBeenCalled();
});
it("does not send the local approval runtime token to gatewayUrl overrides", async () => {
// Approval runtime tokens are local IPC credentials, not bearer tokens for
// user-supplied gateway URLs.
-6
View File
@@ -311,12 +311,6 @@ function resolveApprovalRequesterDeviceIdentityForGatewayTool(params: {
return identity;
}
const identity = loadOrCreateDeviceIdentity();
// Approval registration and wait can use separate gateway connections.
// Reject loadOrCreate's unpersisted fallback so both sides bind the same id.
const persistedIdentity = loadDeviceIdentityIfPresent();
if (persistedIdentity?.deviceId !== identity.deviceId) {
throw new Error("device identity is not persisted");
}
return identity;
} catch (error) {
if (isNodeApprovalReplay) {
+3 -3
View File
@@ -54,11 +54,11 @@ describe("runNodeIdentityShow", () => {
runNodeIdentityShow({});
expect(errorSpy).toHaveBeenCalledOnce();
expect(exitSpy).toHaveBeenCalledWith(1);
expect(fs.existsSync(path.join(stateDir, "identity", "device.json"))).toBe(false);
expect(fs.existsSync(path.join(stateDir, "state", "openclaw.sqlite"))).toBe(false);
});
it("writes deviceId and raw public key JSON to stdout", () => {
const identity = loadOrCreateDeviceIdentity(path.join(stateDir, "identity", "device.json"));
const identity = loadOrCreateDeviceIdentity();
runNodeIdentityShow({ json: true });
expect(exitSpy).not.toHaveBeenCalled();
expect(logSpy).not.toHaveBeenCalled();
@@ -75,7 +75,7 @@ describe("runNodeIdentityShow", () => {
});
it("prints human-readable lines without --json", () => {
const identity = loadOrCreateDeviceIdentity(path.join(stateDir, "identity", "device.json"));
const identity = loadOrCreateDeviceIdentity();
runNodeIdentityShow({});
expect(exitSpy).not.toHaveBeenCalled();
const output = logSpy.mock.calls.map((call: unknown[]) => String(call[0])).join("\n");
+19
View File
@@ -1552,6 +1552,25 @@ describe("doctor config flow", () => {
});
});
it("enables Doctor-only state migrations only for explicit repair", async () => {
await runDoctorConfigWithInput({
config: {},
run: loadAndMaybeMigrateDoctorConfig,
});
expect(runDoctorConfigPreflightOptionsMock).toHaveBeenLastCalledWith(
expect.objectContaining({ doctorOnlyStateMigrations: false }),
);
await runDoctorConfigWithInput({
config: {},
repair: true,
run: loadAndMaybeMigrateDoctorConfig,
});
expect(runDoctorConfigPreflightOptionsMock).toHaveBeenLastCalledWith(
expect.objectContaining({ doctorOnlyStateMigrations: true }),
);
});
it("collects plugin blocker previews from the pre-auto-enable config", async () => {
await runDoctorConfigWithInput({
config: {
+1
View File
@@ -142,6 +142,7 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
const preflight = await runDoctorConfigPreflight({
repairPrefixedConfig: shouldRepair,
recoverCorruptTargetStore: shouldRepair,
doctorOnlyStateMigrations: shouldRepair,
});
const snapshot = preflight.snapshot;
const baseCfg = preflight.baseConfig;
@@ -395,6 +395,7 @@ describe("runDoctorConfigPreflight state migration", () => {
cfg: { gateway: { mode: "local", port: 19091 } },
env: process.env,
recoverCorruptTargetStore: undefined,
doctorOnlyStateMigrations: undefined,
});
expect(note).toHaveBeenCalledWith("- cron-imported", "Doctor changes");
expect(note).toHaveBeenCalledWith("- imported", "Doctor changes");
@@ -884,9 +885,22 @@ describe("runDoctorConfigPreflight state migration", () => {
cfg: { gateway: { mode: "local", port: 19091 } },
env: process.env,
recoverCorruptTargetStore: true,
doctorOnlyStateMigrations: undefined,
});
});
it("passes explicit Doctor-only migration authority only when requested", async () => {
await runDoctorConfigPreflight({
migrateLegacyConfig: false,
invalidConfigNote: false,
doctorOnlyStateMigrations: true,
});
expect(autoMigrateLegacyState).toHaveBeenCalledWith(
expect.objectContaining({ doctorOnlyStateMigrations: true }),
);
});
it("runs plugin state migrations with resolved legacy config before config repair removes retired paths", async () => {
const parsedConfig = { $include: "memory-search.json" };
const resolvedConfig = {
@@ -955,6 +969,7 @@ describe("runDoctorConfigPreflight state migration", () => {
pluginDoctorConfig: resolvedConfig,
env: process.env,
recoverCorruptTargetStore: undefined,
doctorOnlyStateMigrations: undefined,
});
});
+3
View File
@@ -387,6 +387,8 @@ export async function runDoctorConfigPreflight(
skipPristineCoreStateMigrations?: boolean;
/** Prepared before Gateway bootstrap can create files under an otherwise pristine state root. */
skipPristineStartupStateMigrations?: boolean;
/** Enable migrations that may retire security-sensitive stores only during explicit repair. */
doctorOnlyStateMigrations?: boolean;
} = {},
): Promise<DoctorConfigPreflightResult> {
const stateMigrationsRequested = options.migrateState !== false;
@@ -590,6 +592,7 @@ export async function runDoctorConfigPreflight(
: {}),
env: process.env,
recoverCorruptTargetStore: options.recoverCorruptTargetStore,
doctorOnlyStateMigrations: options.doctorOnlyStateMigrations,
}),
);
} else if (stateMigrationInput.pluginDoctorConfig) {
+5 -21
View File
@@ -9,6 +9,7 @@ import { resolveStateDir } from "../config/paths.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { HealthFinding } from "../flows/health-checks.js";
import { callGateway } from "../gateway/call.js";
import { loadDeviceIdentityIfPresent } from "../infra/device-identity.js";
import {
listApprovedPairedDeviceRoles,
listDevicePairing,
@@ -105,11 +106,6 @@ type LocalDeviceAuthIssue = {
fixHint: string;
};
type StoredDeviceIdentity = {
version: 1;
deviceId: string;
};
function hasNumberVersion(value: object): value is { version: number } {
return "version" in value && typeof value.version === "number";
}
@@ -407,24 +403,12 @@ function readJsonFile(filePath: string): unknown {
return tryReadJsonSync(filePath);
}
function readLocalIdentity(env: NodeJS.ProcessEnv = process.env): StoredDeviceIdentity | null {
const filePath = path.join(resolveStateDir(env), "identity", "device.json");
const identity = readJsonFile(filePath);
if (
!identity ||
typeof identity !== "object" ||
!hasNumberVersion(identity) ||
identity.version !== 1 ||
!("deviceId" in identity) ||
typeof identity.deviceId !== "string" ||
!identity.deviceId.trim()
) {
function readLocalIdentity(env: NodeJS.ProcessEnv = process.env): { deviceId: string } | null {
try {
return loadDeviceIdentityIfPresent({ env });
} catch {
return null;
}
return {
version: 1,
deviceId: identity.deviceId,
};
}
function readLocalDeviceAuthStore(env: NodeJS.ProcessEnv = process.env): DeviceAuthStore | null {
+7
View File
@@ -208,6 +208,13 @@ function createLegacyStateMigrationDetectionResult(params?: {
targetScope: undefined,
stateDir: "/tmp/state",
oauthDir: "/tmp/oauth",
deviceIdentity: {
sourcePath: "/tmp/state/identity/device.json",
claimPath: "/tmp/state/identity/device.json.doctor-importing",
nativeClaimPath: "/tmp/state/identity/device.json.native-importing",
hasLegacy: false,
hasInvalidCanonical: false,
},
mcpOauth: {
sourceDir: "/tmp/state/mcp-oauth",
sourcePaths: [],
+25 -1
View File
@@ -1473,6 +1473,29 @@ describe("doctor health contributions", () => {
await contribution.run(ctx);
expect(mocks.detectLegacyStateMigrations).toHaveBeenCalledWith({ cfg });
expect(mocks.runLegacyStateMigrations).toHaveBeenCalledWith({
detected,
config: cfg,
recoverCorruptTargetStore: false,
});
});
it("grants Doctor-only state migration authority only in repair mode", async () => {
const contribution = requireDoctorContribution("doctor:legacy-state");
const cfg = { session: { store: "/tmp/shared-sessions.json" } };
const detected = { preview: ["legacy sessions"], warnings: [], notices: [] };
mocks.detectLegacyStateMigrations.mockResolvedValue(detected);
const ctx = {
cfg,
sourceConfigValid: true,
prompter: buildDoctorPrompter(true),
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
options: { nonInteractive: true, repair: true },
} as unknown as Parameters<(typeof contribution)["run"]>[0];
await contribution.run(ctx);
expect(mocks.detectLegacyStateMigrations).toHaveBeenCalledWith({
cfg,
doctorOnlyStateMigrations: true,
@@ -1480,7 +1503,8 @@ describe("doctor health contributions", () => {
expect(mocks.runLegacyStateMigrations).toHaveBeenCalledWith({
detected,
config: cfg,
recoverCorruptTargetStore: false,
doctorOnlyStateMigrations: true,
recoverCorruptTargetStore: true,
});
});
+3 -1
View File
@@ -521,9 +521,10 @@ async function runLegacyStateHealth(ctx: DoctorHealthFlowContext): Promise<void>
// Settle retired-plugin state cleanup (may replace ctx.cfg) before the
// legacy-state detect/migrate pair reads the config.
await runCoreContributionHealthRepair(ctx, ["core/doctor/removed-workspaces-state"]);
const doctorOnlyStateMigrations = ctx.options.repair === true || ctx.options.yes === true;
const legacyState = await detectLegacyStateMigrations({
cfg: ctx.cfg,
doctorOnlyStateMigrations: true,
...(doctorOnlyStateMigrations ? { doctorOnlyStateMigrations: true } : {}),
});
if (legacyState.warnings.length > 0) {
note(legacyState.warnings.join("\n"), "Doctor warnings");
@@ -548,6 +549,7 @@ async function runLegacyStateHealth(ctx: DoctorHealthFlowContext): Promise<void>
const migrated = await runLegacyStateMigrations({
detected: legacyState,
config: ctx.cfg,
...(doctorOnlyStateMigrations ? { doctorOnlyStateMigrations: true } : {}),
recoverCorruptTargetStore: ctx.options.repair === true || ctx.options.yes === true,
});
if (migrated.changes.length > 0) {
+2 -2
View File
@@ -19,7 +19,7 @@ import { trackConnectChallengeNonce } from "./test-helpers.js";
export function resolveDeviceIdentityPath(name: string): string {
const root = process.env.OPENCLAW_STATE_DIR ?? process.env.HOME ?? os.tmpdir();
return path.join(root, "test-device-identities", `${name}.json`);
return path.join(root, "test-device-identities", `${name}.sqlite`);
}
export function loadDeviceIdentity(name: string): {
@@ -28,7 +28,7 @@ export function loadDeviceIdentity(name: string): {
publicKey: string;
} {
const identityPath = resolveDeviceIdentityPath(name);
const identity = loadOrCreateDeviceIdentity(identityPath);
const identity = loadOrCreateDeviceIdentity({ path: identityPath });
return {
identityPath,
identity,
@@ -22,7 +22,7 @@ const tempDirs = createSuiteTempRootTracker({ prefix: "openclaw-gateway-connect-
async function createTempDeviceIdentity() {
const tempRoot = await tempDirs.make("device");
return loadOrCreateDeviceIdentity(path.join(tempRoot, "device.json"));
return loadOrCreateDeviceIdentity({ path: path.join(tempRoot, "device.sqlite") });
}
async function startMinimalGatewayServer(params: { token: string }) {
@@ -192,15 +192,15 @@ describe("operator approval gateway client e2e", () => {
setTestEnvValue("HOME", tempHome);
setTestEnvValue("OPENCLAW_STATE_DIR", stateDir);
const requesterIdentity = loadOrCreateDeviceIdentity(
path.join(stateDir, "test-device-identities", "approval-requester.json"),
);
const reviewerIdentity = loadOrCreateDeviceIdentity(
path.join(stateDir, "test-device-identities", "approval-reviewer.json"),
);
const underscopedIdentity = loadOrCreateDeviceIdentity(
path.join(stateDir, "test-device-identities", "approval-underscoped.json"),
);
const requesterIdentity = loadOrCreateDeviceIdentity({
path: path.join(stateDir, "test-device-identities", "approval-requester.sqlite"),
});
const reviewerIdentity = loadOrCreateDeviceIdentity({
path: path.join(stateDir, "test-device-identities", "approval-reviewer.sqlite"),
});
const underscopedIdentity = loadOrCreateDeviceIdentity({
path: path.join(stateDir, "test-device-identities", "approval-underscoped.sqlite"),
});
expect(requesterIdentity.deviceId).not.toBe(reviewerIdentity.deviceId);
const port = await getFreeGatewayPort();
+3 -5
View File
@@ -162,8 +162,8 @@ vi.mock("../infra/device-identity.js", () => ({
}
return deviceIdentityState.value;
},
loadDeviceIdentityIfPresent: (filePath: unknown) => {
deviceIdentityState.identityPaths.push(filePath);
loadDeviceIdentityIfPresent: (options: unknown) => {
deviceIdentityState.identityPaths.push(options);
if (deviceIdentityState.throwOnLoad) {
throw new Error("read-only identity dir");
}
@@ -397,9 +397,7 @@ describe("probeGateway", () => {
await runTokenProbe({ env });
expect(deviceIdentityState.identityPaths).toEqual([
"/tmp/openclaw-probe-service-state/identity/device.json",
]);
expect(deviceIdentityState.identityPaths).toEqual([{ env }]);
expect(deviceIdentityState.tokenParams).toEqual([
{
deviceId: "test-device-identity",
+1 -4
View File
@@ -1,12 +1,10 @@
// Gateway reachability probe client.
// Connects to a gateway and summarizes auth, health, status, and presence.
import { randomUUID } from "node:crypto";
import path from "node:path";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
} from "../../packages/gateway-protocol/src/client-info.js";
import { resolveStateDir } from "../config/paths.js";
import { loadDeviceAuthToken } from "../infra/device-auth-store.js";
import { formatErrorMessage } from "../infra/errors.js";
import type { SystemPresence } from "../infra/system-presence.js";
@@ -257,8 +255,7 @@ export async function probeGateway(opts: {
return null;
}
const { loadDeviceIdentityIfPresent } = await import("../infra/device-identity.js");
const stateDir = resolveStateDir(opts.env);
const identity = loadDeviceIdentityIfPresent(path.join(stateDir, "identity", "device.json"));
const identity = loadDeviceIdentityIfPresent({ env: opts.env });
if (!identity) {
return null;
}
@@ -64,7 +64,7 @@ async function createSignedDevice(params: {
signedAtMs?: number;
}) {
const identity = params.identityPath
? loadOrCreateDeviceIdentity(params.identityPath)
? loadOrCreateDeviceIdentity({ path: params.identityPath })
: loadOrCreateDeviceIdentity();
const signedAtMs = params.signedAtMs ?? Date.now();
const payload = buildDeviceAuthPayload({
@@ -169,7 +169,7 @@ async function createSignedBrowserDevice(
scopes: ["operator.admin"],
clientId: client.id,
clientMode: client.mode,
identityPath: path.join(os.tmpdir(), `openclaw-${identityName}-device-${randomUUID()}.json`),
identityPath: path.join(os.tmpdir(), `openclaw-${identityName}-device-${randomUUID()}.sqlite`),
nonce: nonce ?? "",
});
}
@@ -236,14 +236,14 @@ describe("gateway auth compatibility baseline", () => {
test("keeps local backend device-token reconnects out of pairing", async () => {
const identityPath = path.join(
os.tmpdir(),
`openclaw-backend-device-${process.pid}-${port}.json`,
`openclaw-backend-device-${process.pid}-${port}.sqlite`,
);
const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem } =
await import("../infra/device-identity.js");
const { approveDevicePairing, requestDevicePairing, rotateDeviceToken } =
await import("../infra/device-pairing.js");
const identity = loadOrCreateDeviceIdentity(identityPath);
const identity = loadOrCreateDeviceIdentity({ path: identityPath });
const pending = await requestDevicePairing({
deviceId: identity.deviceId,
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
@@ -411,7 +411,7 @@ describe("gateway auth compatibility baseline", () => {
try {
const deviceIdentityPath = path.join(
os.tmpdir(),
`openclaw-auth-none-control-ui-first-${process.pid}-${port}.json`,
`openclaw-auth-none-control-ui-first-${process.pid}-${port}.sqlite`,
);
const res = await connectReq(ws, {
skipDefaultAuth: true,
@@ -443,11 +443,11 @@ describe("gateway auth compatibility baseline", () => {
const nonce = await readConnectChallengeNonce(ws);
const identityPath = path.join(
os.tmpdir(),
`openclaw-auth-none-control-ui-${process.pid}-${port}.json`,
`openclaw-auth-none-control-ui-${process.pid}-${port}.sqlite`,
);
const staleIdentityPath = path.join(
os.tmpdir(),
`openclaw-auth-none-control-ui-stale-${process.pid}-${port}.json`,
`openclaw-auth-none-control-ui-stale-${process.pid}-${port}.sqlite`,
);
const { identity, device } = await createSignedDevice({
token: null,
@@ -457,7 +457,7 @@ describe("gateway auth compatibility baseline", () => {
identityPath,
nonce,
});
const staleIdentity = loadOrCreateDeviceIdentity(staleIdentityPath);
const staleIdentity = loadOrCreateDeviceIdentity({ path: staleIdentityPath });
const pending = await requestDevicePairing({
deviceId: identity.deviceId,
publicKey: publicKeyRawBase64UrlFromPem(staleIdentity.publicKeyPem),
+2 -2
View File
@@ -174,10 +174,10 @@ export function registerControlUiAndPairingSuite(): void {
let identityPath = operatorIdentityPathByPrefix.get(identityPrefix);
if (!identityPath) {
const poolId = process.env.VITEST_POOL_ID ?? "0";
identityPath = path.join(os.tmpdir(), `${identityPrefix}${process.pid}-${poolId}.json`);
identityPath = path.join(os.tmpdir(), `${identityPrefix}${process.pid}-${poolId}.sqlite`);
operatorIdentityPathByPrefix.set(identityPrefix, identityPath);
}
const identity = loadOrCreateDeviceIdentity(identityPath);
const identity = loadOrCreateDeviceIdentity({ path: identityPath });
return {
identityPath,
identity,
@@ -353,7 +353,7 @@ export function registerDefaultAuthTokenSuite(): void {
scopes: [],
clientId: GATEWAY_CLIENT_NAMES.TEST,
clientMode: GATEWAY_CLIENT_MODES.TEST,
identityPath: path.join(os.tmpdir(), `openclaw-test-device-${randomUUID()}.json`),
identityPath: path.join(os.tmpdir(), `openclaw-test-device-${randomUUID()}.sqlite`),
nonce,
});
+3 -3
View File
@@ -39,7 +39,7 @@ function nextAuthIdentityPath(prefix: string): string {
poolId +
"-" +
String(authIdentityPathSeq++) +
".json";
".sqlite";
return path.join(os.tmpdir(), fileName);
}
@@ -181,7 +181,7 @@ async function createSignedDevice(params: {
const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } =
await import("../infra/device-identity.js");
const identity = params.identityPath
? loadOrCreateDeviceIdentity(params.identityPath)
? loadOrCreateDeviceIdentity({ path: params.identityPath })
: loadOrCreateDeviceIdentity();
const signedAtMs = params.signedAtMs ?? Date.now();
const payload = buildDeviceAuthPayload({
@@ -331,7 +331,7 @@ async function resolvePairedTokenForDeviceIdentityPath(deviceIdentityPath: strin
const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js");
const { getPairedDevice } = await import("../infra/device-pairing.js");
const identity = loadOrCreateDeviceIdentity(deviceIdentityPath);
const identity = loadOrCreateDeviceIdentity({ path: deviceIdentityPath });
const paired = await getPairedDevice(identity.deviceId);
const deviceToken = paired?.tokens?.operator?.token;
expect(paired?.deviceId).toBe(identity.deviceId);
@@ -210,7 +210,7 @@ async function expectRpcNodePairingApprovalRejected(params: {
await connectOk(ws, {
token: "secret",
scopes: params.operatorScopes,
deviceIdentityPath: `${await makeNodePairingStateDir()}/${params.operatorName}.json`,
deviceIdentityPath: `${await makeNodePairingStateDir()}/${params.operatorName}.sqlite`,
});
await seedNodeDevice(params.nodeId);
const request = await requestNodePairing({
@@ -734,7 +734,7 @@ describe("gateway node pairing authorization", () => {
await connectOk(ws, {
token: "secret",
scopes: ["operator.read"],
deviceIdentityPath: `${await makeNodePairingStateDir()}/read-only.json`,
deviceIdentityPath: `${await makeNodePairingStateDir()}/read-only.sqlite`,
});
type NodeDiagnostics = {
@@ -69,7 +69,7 @@ async function attemptNodePairing(
}
async function approveNodeIdentity(params: { identityPath: string; caps: string[] }) {
const identity = loadOrCreateDeviceIdentity(params.identityPath);
const identity = loadOrCreateDeviceIdentity({ path: params.identityPath });
// Node surfaces attach to paired devices, so device pairing comes first.
// The stored key must match what the reconnect presents or the handshake
// restarts pairing and burns the rate-limit budget under test.
@@ -112,7 +112,7 @@ describe("node pairing rate limit", () => {
const responses = await Promise.all(
Array.from(
{ length: 8 },
async (_, index) => await attemptNodePairing(port, `${identityPrefix}-${index}.json`),
async (_, index) => await attemptNodePairing(port, `${identityPrefix}-${index}.sqlite`),
),
);
const rateLimited = responses.filter((res) => {
@@ -146,7 +146,7 @@ describe("node pairing rate limit", () => {
os.tmpdir(),
`openclaw-node-pairing-upgrade-${randomUUID()}`,
);
const pairedIdentityPath = `${identityPrefix}-paired.json`;
const pairedIdentityPath = `${identityPrefix}-paired.sqlite`;
const pairedIdentity = await approveNodeIdentity({
identityPath: pairedIdentityPath,
caps: ["camera"],
@@ -155,7 +155,7 @@ describe("node pairing rate limit", () => {
const firstTimeResponses = await Promise.all(
Array.from(
{ length: 3 },
async (_, index) => await attemptNodePairing(port, `${identityPrefix}-${index}.json`),
async (_, index) => await attemptNodePairing(port, `${identityPrefix}-${index}.sqlite`),
),
);
expect(firstTimeResponses.filter((res) => res.ok)).toHaveLength(3);
@@ -203,7 +203,10 @@ describe("node pairing rate limit", () => {
},
};
await withGatewayServer(async ({ port }) => {
const identityPath = path.join(os.tmpdir(), `openclaw-node-reapproval-${randomUUID()}.json`);
const identityPath = path.join(
os.tmpdir(),
`openclaw-node-reapproval-${randomUUID()}.sqlite`,
);
const identity = await approveNodeIdentity({ identityPath, caps: ["camera"] });
const responses = await Promise.all(
@@ -339,9 +339,12 @@ async function respondToInvoke(
}
function createDeviceIdentityForTest(prefix: string) {
return loadOrCreateDeviceIdentity(
path.join(os.tmpdir(), `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}.json`),
);
return loadOrCreateDeviceIdentity({
path: path.join(
os.tmpdir(),
`${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}.sqlite`,
),
});
}
describe("gateway role enforcement", () => {
@@ -486,15 +489,18 @@ describe("gateway node command allowlist", () => {
const invokeCapture = createInvokeCapture();
try {
const systemDeviceIdentity = loadOrCreateDeviceIdentity(
path.join(os.tmpdir(), `openclaw-node-system-run-${Date.now()}-${Math.random()}.json`),
);
const emptyDeviceIdentity = loadOrCreateDeviceIdentity(
path.join(os.tmpdir(), `openclaw-node-empty-${Date.now()}-${Math.random()}.json`),
);
const allowedDeviceIdentity = loadOrCreateDeviceIdentity(
path.join(os.tmpdir(), `openclaw-node-allowed-${Date.now()}-${Math.random()}.json`),
);
const systemDeviceIdentity = loadOrCreateDeviceIdentity({
path: path.join(
os.tmpdir(),
`openclaw-node-system-run-${Date.now()}-${Math.random()}.sqlite`,
),
});
const emptyDeviceIdentity = loadOrCreateDeviceIdentity({
path: path.join(os.tmpdir(), `openclaw-node-empty-${Date.now()}-${Math.random()}.sqlite`),
});
const allowedDeviceIdentity = loadOrCreateDeviceIdentity({
path: path.join(os.tmpdir(), `openclaw-node-allowed-${Date.now()}-${Math.random()}.sqlite`),
});
systemClient = await connectNodeClientWithPairing({
port,
@@ -50,7 +50,7 @@ async function openDeviceTokenWsWithDetails(
auth?: { deviceToken?: unknown };
};
}> {
const identityPath = path.join(os.tmpdir(), `openclaw-shared-auth-${process.pid}-${port}.json`);
const identityPath = path.join(os.tmpdir(), `openclaw-shared-auth-${process.pid}-${port}.sqlite`);
const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem } =
await import("../infra/device-identity.js");
const { approveDevicePairing, ensureDeviceToken, requestDevicePairing, rotateDeviceToken } =
@@ -69,7 +69,7 @@ async function openDeviceTokenWsWithDetails(
mode: "test",
};
const identity = loadOrCreateDeviceIdentity(identityPath);
const identity = loadOrCreateDeviceIdentity({ path: identityPath });
const pending = await requestDevicePairing({
deviceId: identity.deviceId,
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
+6 -3
View File
@@ -62,9 +62,12 @@ afterAll(async () => {
});
async function createFreshOperatorDevice(scopes: string[], nonce: string) {
const identity = loadOrCreateDeviceIdentity(
path.join(os.tmpdir(), `openclaw-talk-config-device-${process.pid}-${talkConfigDeviceSeq++}`),
);
const identity = loadOrCreateDeviceIdentity({
path: path.join(
os.tmpdir(),
`openclaw-talk-config-device-${process.pid}-${talkConfigDeviceSeq++}.sqlite`,
),
});
const signedAtMs = Date.now();
const payload = buildDeviceAuthPayload({
deviceId: identity.deviceId,
+4 -4
View File
@@ -61,17 +61,17 @@ export async function connectGatewayClient(params: {
const identityRoot = process.env.OPENCLAW_STATE_DIR ?? process.env.HOME ?? os.tmpdir();
const deviceIdentity =
params.deviceIdentity ??
loadOrCreateDeviceIdentity(
(() => {
loadOrCreateDeviceIdentity({
path: (() => {
const safe = normalizeLowercaseStringOrEmpty(
`${params.clientName ?? GATEWAY_CLIENT_NAMES.TEST}-${params.mode ?? GATEWAY_CLIENT_MODES.TEST}-${platform}-${params.deviceFamily ?? "none"}-${role}`.replace(
/[^a-zA-Z0-9._-]+/g,
"_",
),
);
return path.join(identityRoot, "test-device-identities", `${safe}.json`);
return path.join(identityRoot, "test-device-identities", `${safe}.sqlite`);
})(),
);
});
return await new Promise<InstanceType<typeof GatewayClient>>((resolve, reject) => {
let settled = false;
const stop = (err?: Error, connectedClient?: InstanceType<typeof GatewayClient>) => {
+2 -2
View File
@@ -872,7 +872,7 @@ function resolveDefaultTestDeviceIdentityPath(params: {
),
);
const suiteRoot = process.env.OPENCLAW_STATE_DIR ?? process.env.HOME ?? os.tmpdir();
return path.join(suiteRoot, "test-device-identities", `${safe}.json`);
return path.join(suiteRoot, "test-device-identities", `${safe}.sqlite`);
}
export async function readConnectChallengeNonce(
@@ -1095,7 +1095,7 @@ export async function connectReq(
deviceFamily: client.deviceFamily,
role,
});
const identity = loadOrCreateDeviceIdentity(identityPath);
const identity = loadOrCreateDeviceIdentity({ path: identityPath });
const signedAtMs = Date.now();
const payload = buildDeviceAuthPayloadV3({
deviceId: identity.deviceId,
+24 -12
View File
@@ -174,7 +174,9 @@ async function waitForLastConnectedMetadata(baseDir: string, nodeId: string): Pr
describe("watch node HTTP transport", () => {
it("rejects capabilities and identities outside the bounded watch surface", async () => {
const baseDir = await tempDirs.make("openclaw-watch-node-surface-");
const identity = loadOrCreateDeviceIdentity(path.join(baseDir, "watch-identity.json"));
const identity = loadOrCreateDeviceIdentity({
path: path.join(baseDir, "watch-identity.sqlite"),
});
const issued = await issueDeviceBootstrapToken({
baseDir,
profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
@@ -231,7 +233,9 @@ describe("watch node HTTP transport", () => {
it("accepts a supported notification permission set to false", async () => {
const baseDir = await tempDirs.make("openclaw-watch-node-permissions-");
const identity = loadOrCreateDeviceIdentity(path.join(baseDir, "watch-identity.json"));
const identity = loadOrCreateDeviceIdentity({
path: path.join(baseDir, "watch-identity.sqlite"),
});
const issued = await issueDeviceBootstrapToken({
baseDir,
profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
@@ -258,7 +262,9 @@ describe("watch node HTTP transport", () => {
it("does not let attacker challenges evict another client nonce", async () => {
const baseDir = await tempDirs.make("openclaw-watch-node-challenge-eviction-");
const identity = loadOrCreateDeviceIdentity(path.join(baseDir, "watch-identity.json"));
const identity = loadOrCreateDeviceIdentity({
path: path.join(baseDir, "watch-identity.sqlite"),
});
const issued = await issueDeviceBootstrapToken({
baseDir,
profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
@@ -295,7 +301,9 @@ describe("watch node HTTP transport", () => {
it("requires an authenticated disconnect and emits one lifecycle teardown", async () => {
const baseDir = await tempDirs.make("openclaw-watch-node-disconnect-");
const identity = loadOrCreateDeviceIdentity(path.join(baseDir, "watch-identity.json"));
const identity = loadOrCreateDeviceIdentity({
path: path.join(baseDir, "watch-identity.sqlite"),
});
const issued = await issueDeviceBootstrapToken({
baseDir,
profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
@@ -353,7 +361,9 @@ describe("watch node HTTP transport", () => {
it("rejects empty shadow credentials without consuming the challenge", async () => {
const baseDir = await tempDirs.make("openclaw-watch-node-auth-fields-");
const identity = loadOrCreateDeviceIdentity(path.join(baseDir, "watch-identity.json"));
const identity = loadOrCreateDeviceIdentity({
path: path.join(baseDir, "watch-identity.sqlite"),
});
const issued = await issueDeviceBootstrapToken({
baseDir,
profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
@@ -397,9 +407,9 @@ describe("watch node HTTP transport", () => {
};
const abortedBaseDir = await tempDirs.make("openclaw-watch-node-aborted-connect-");
const abortedIdentity = loadOrCreateDeviceIdentity(
path.join(abortedBaseDir, "watch-identity.json"),
);
const abortedIdentity = loadOrCreateDeviceIdentity({
path: path.join(abortedBaseDir, "watch-identity.sqlite"),
});
const abortedBootstrap = await issueDeviceBootstrapToken({
baseDir: abortedBaseDir,
profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
@@ -433,9 +443,9 @@ describe("watch node HTTP transport", () => {
}
const completedBaseDir = await tempDirs.make("openclaw-watch-node-completed-connect-");
const completedIdentity = loadOrCreateDeviceIdentity(
path.join(completedBaseDir, "watch-identity.json"),
);
const completedIdentity = loadOrCreateDeviceIdentity({
path: path.join(completedBaseDir, "watch-identity.sqlite"),
});
const completedBootstrap = await issueDeviceBootstrapToken({
baseDir: completedBaseDir,
profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
@@ -471,7 +481,9 @@ describe("watch node HTTP transport", () => {
it("bootstraps, registers, polls an invoke, and accepts its result", async () => {
const baseDir = await tempDirs.make("openclaw-watch-node-http-");
const identity = loadOrCreateDeviceIdentity(path.join(baseDir, "watch-identity.json"));
const identity = loadOrCreateDeviceIdentity({
path: path.join(baseDir, "watch-identity.sqlite"),
});
const issued = await issueDeviceBootstrapToken({
baseDir,
profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
+1 -1
View File
@@ -511,7 +511,7 @@ describe("device bootstrap tokens", () => {
it("accepts equivalent public key encodings after binding the bootstrap token", async () => {
const baseDir = await createTempDir();
const identity = loadOrCreateDeviceIdentity(path.join(baseDir, "device.json"));
const identity = loadOrCreateDeviceIdentity({ path: path.join(baseDir, "device.sqlite") });
const issued = await issueDeviceBootstrapToken({ baseDir });
const rawPublicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem);
+138
View File
@@ -0,0 +1,138 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { resolveGatewayLockDir } from "../config/paths.js";
import { requireNodeSqlite } from "./node-sqlite.js";
const DEFAULT_BUSY_TIMEOUT_MS = 5000;
class DeviceIdentityCoordinatorError extends Error {
constructor(
message: string,
public override readonly cause?: unknown,
) {
super(message);
this.name = "DeviceIdentityCoordinatorError";
}
}
function canonicalizeDatabasePath(databasePath: string): string {
const resolved = path.resolve(databasePath);
try {
return fs.realpathSync.native(resolved);
} catch {
const missingSegments: string[] = [];
let current = resolved;
while (true) {
const parent = path.dirname(current);
if (parent === current) {
return resolved;
}
missingSegments.push(path.basename(current));
current = parent;
try {
return path.join(fs.realpathSync.native(current), ...missingSegments.toReversed());
} catch {
// Existing ancestors can still contain aliases even when the database is absent.
}
}
}
}
function resolveDeviceIdentityCoordinatorPath(
databasePath: string,
lockDir = resolveGatewayLockDir(),
): string {
const canonicalPath = canonicalizeDatabasePath(databasePath);
const databaseHash = crypto.createHash("sha256").update(canonicalPath).digest("hex").slice(0, 8);
return path.join(lockDir, `device-identity.${databaseHash}.lock.sqlite`);
}
function ensurePrivateCoordinatorDirectory(lockDir: string): void {
let stats: fs.Stats;
try {
stats = fs.lstatSync(lockDir);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
try {
fs.mkdirSync(lockDir, { mode: 0o700 });
} catch (mkdirError) {
if ((mkdirError as NodeJS.ErrnoException).code !== "EEXIST") {
throw mkdirError;
}
}
stats = fs.lstatSync(lockDir);
}
if (stats.isSymbolicLink() || !stats.isDirectory()) {
throw new DeviceIdentityCoordinatorError(
"device identity coordinator directory must be a real directory",
);
}
const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
if (uid !== undefined && stats.uid !== uid) {
throw new DeviceIdentityCoordinatorError(
"device identity coordinator directory belongs to another user",
);
}
if (process.platform !== "win32") {
fs.chmodSync(lockDir, 0o700);
const secured = fs.lstatSync(lockDir);
if (secured.isSymbolicLink() || !secured.isDirectory() || (secured.mode & 0o077) !== 0) {
throw new DeviceIdentityCoordinatorError(
"device identity coordinator directory permissions are not private",
);
}
}
}
export function acquireDeviceIdentityCoordinator(params: {
databasePath: string;
busyTimeoutMs?: number;
lockDir?: string;
}): { release: () => void } {
const coordinatorPath = resolveDeviceIdentityCoordinatorPath(params.databasePath, params.lockDir);
ensurePrivateCoordinatorDirectory(path.dirname(coordinatorPath));
const { DatabaseSync } = requireNodeSqlite();
const database = new DatabaseSync(coordinatorPath);
try {
const timeout = Math.max(0, Math.trunc(params.busyTimeoutMs ?? DEFAULT_BUSY_TIMEOUT_MS));
database.exec(`PRAGMA busy_timeout = ${timeout}; BEGIN EXCLUSIVE;`);
} catch (error) {
try {
database.close();
} catch {}
throw new DeviceIdentityCoordinatorError(
"device identity migration or creation already owns this state database",
error,
);
}
let released = false;
return {
release: () => {
if (released) {
return;
}
released = true;
let releaseError: unknown;
try {
database.exec("ROLLBACK");
} catch (error) {
releaseError = error;
}
try {
database.close();
} catch (error) {
releaseError ??= error;
}
if (releaseError) {
throw new DeviceIdentityCoordinatorError(
"failed to release device identity coordinator",
releaseError,
);
}
},
};
}
+97
View File
@@ -0,0 +1,97 @@
// Canonicalizes retired Node and Swift identity payloads for Doctor import.
import { createHash } from "node:crypto";
import {
validateStoredDeviceIdentity,
type StoredDeviceIdentity,
} from "./device-identity-store.js";
import {
decodeCanonicalBase64OrBase64Url,
deriveEd25519PrivateKeyRaw,
deriveEd25519PublicKeyRaw,
ed25519PrivateKeyPemFromRaw,
ed25519PublicKeyPemFromRaw,
} from "./ed25519-signature.js";
export type NormalizedLegacyDeviceIdentity = StoredDeviceIdentity;
function fingerprintPublicKey(publicKeyPem: string): string {
return createHash("sha256").update(deriveEd25519PublicKeyRaw(publicKeyPem)).digest("hex");
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function isValidCreatedAtMs(value: unknown): value is number {
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
}
function normalizeLegacyCreatedAtMs(value: unknown): number {
// Shipped file readers accepted valid keypairs even when this metadata was
// missing or invalid. Doctor preserves that upgrade path without weakening SQLite.
return isValidCreatedAtMs(value) ? value : Date.now();
}
function normalizeLegacyKeyPair(params: {
createdAtMs: number;
privateKeyPem: string;
publicKeyPem: string;
}): NormalizedLegacyDeviceIdentity | null {
try {
const publicKeyRaw = deriveEd25519PublicKeyRaw(params.publicKeyPem);
const privateKeyRaw = deriveEd25519PrivateKeyRaw(params.privateKeyPem);
const publicKeyPem = ed25519PublicKeyPemFromRaw(publicKeyRaw);
const privateKeyPem = ed25519PrivateKeyPemFromRaw(privateKeyRaw);
// Legacy deviceId was derived metadata. Preserve the authoritative key bytes and
// recompute the fingerprint so stale metadata never rotates a shipped identity.
const normalized = {
deviceId: fingerprintPublicKey(publicKeyPem),
publicKeyPem,
privateKeyPem,
createdAtMs: params.createdAtMs,
};
validateStoredDeviceIdentity(normalized);
return normalized;
} catch {
return null;
}
}
/** Normalize a retired Node PEM or Swift raw-key payload for Doctor import. */
export function normalizeLegacyDeviceIdentity(
value: unknown,
): NormalizedLegacyDeviceIdentity | null {
if (
isRecord(value) &&
value.version === 1 &&
typeof value.deviceId === "string" &&
typeof value.publicKeyPem === "string" &&
typeof value.privateKeyPem === "string"
) {
return normalizeLegacyKeyPair({
createdAtMs: normalizeLegacyCreatedAtMs(value.createdAtMs),
privateKeyPem: value.privateKeyPem,
publicKeyPem: value.publicKeyPem,
});
}
if (
isRecord(value) &&
!("version" in value) &&
typeof value.deviceId === "string" &&
typeof value.publicKey === "string" &&
typeof value.privateKey === "string"
) {
try {
const publicKeyRaw = decodeCanonicalBase64OrBase64Url(value.publicKey);
const privateKeyRaw = decodeCanonicalBase64OrBase64Url(value.privateKey);
return normalizeLegacyKeyPair({
createdAtMs: normalizeLegacyCreatedAtMs(value.createdAtMs),
privateKeyPem: ed25519PrivateKeyPemFromRaw(privateKeyRaw),
publicKeyPem: ed25519PublicKeyPemFromRaw(publicKeyRaw),
});
} catch {
return null;
}
}
return null;
}
+426
View File
@@ -0,0 +1,426 @@
// Canonical SQLite storage for gateway/device Ed25519 identities.
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import type { Insertable, Selectable } from "kysely";
import { withOpenClawStateDatabaseReadOnly } from "../state/openclaw-state-db-readonly.js";
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
import {
openOpenClawStateDatabase,
runOpenClawStateWriteTransaction,
type OpenClawStateDatabaseOptions,
} from "../state/openclaw-state-db.js";
import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js";
import {
deriveCanonicalEd25519PrivateKeyRaw,
deriveCanonicalEd25519PublicKeyRaw,
} from "./ed25519-signature.js";
import {
executeSqliteQuerySync,
executeSqliteQueryTakeFirstSync,
getNodeSqliteKysely,
} from "./kysely-sync.js";
export const PRIMARY_DEVICE_IDENTITY_KEY = "primary";
export type DeviceIdentity = {
deviceId: string;
publicKeyPem: string;
privateKeyPem: string;
};
export type StoredDeviceIdentity = DeviceIdentity & {
createdAtMs: number;
};
export type DeviceIdentityStoreOptions = OpenClawStateDatabaseOptions & {
identityKey?: string;
};
type DeviceIdentityDatabase = Pick<OpenClawStateKyselyDatabase, "device_identities">;
type DeviceIdentityRow = Selectable<DeviceIdentityDatabase["device_identities"]>;
type DeviceIdentityInsert = Insertable<DeviceIdentityDatabase["device_identities"]>;
export class DeviceIdentityStorageError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = "DeviceIdentityStorageError";
}
}
function normalizeIdentityKey(key: string | undefined): string {
const normalized = key ?? PRIMARY_DEVICE_IDENTITY_KEY;
if (normalized.length === 0 || normalized !== normalized.trim()) {
throw new DeviceIdentityStorageError(
"Device identity key must be a non-empty string without surrounding whitespace.",
);
}
if (normalized.length > 128) {
throw new DeviceIdentityStorageError("Device identity key exceeds 128 characters.");
}
return normalized;
}
function invalidStoredIdentityError(
identityKey: string,
cause?: unknown,
): DeviceIdentityStorageError {
return new DeviceIdentityStorageError(
`SQLite contains an invalid persisted device identity "${identityKey}". Run "openclaw doctor --fix" before starting the gateway or connecting this client.`,
cause === undefined ? undefined : { cause },
);
}
function fingerprintPublicKey(publicKeyPem: string): string {
const raw = deriveCanonicalEd25519PublicKeyRaw(publicKeyPem);
return crypto.createHash("sha256").update(raw).digest("hex");
}
/** Generate canonical Ed25519 material before entering a synchronous write transaction. */
export function generateStoredDeviceIdentity(now = Date.now()): StoredDeviceIdentity {
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
return {
deviceId: fingerprintPublicKey(publicKeyPem),
publicKeyPem,
privateKeyPem,
createdAtMs: now,
};
}
function keyPairMatches(publicKeyPem: string, privateKeyPem: string): boolean {
try {
deriveCanonicalEd25519PublicKeyRaw(publicKeyPem);
deriveCanonicalEd25519PrivateKeyRaw(privateKeyPem);
const publicKey = crypto.createPublicKey(publicKeyPem);
const privateKey = crypto.createPrivateKey(privateKeyPem);
if (publicKey.asymmetricKeyType !== "ed25519" || privateKey.asymmetricKeyType !== "ed25519") {
return false;
}
const derivedPublicKey = crypto
.createPublicKey(privateKeyPem)
.export({ type: "spki", format: "der" });
const storedPublicKey = publicKey.export({ type: "spki", format: "der" });
return Buffer.from(derivedPublicKey).equals(Buffer.from(storedPublicKey));
} catch {
return false;
}
}
function parseCreatedAtMs(value: unknown): number | null {
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
}
/** Validate persisted key material and return the canonical runtime shape. */
export function validateStoredDeviceIdentity(
value: StoredDeviceIdentity,
identityKey = PRIMARY_DEVICE_IDENTITY_KEY,
): DeviceIdentity {
try {
if (
!value.deviceId ||
!/^[a-f0-9]{64}$/.test(value.deviceId) ||
!value.publicKeyPem ||
!value.privateKeyPem ||
parseCreatedAtMs(value.createdAtMs) === null ||
!keyPairMatches(value.publicKeyPem, value.privateKeyPem)
) {
throw invalidStoredIdentityError(identityKey);
}
const derivedDeviceId = fingerprintPublicKey(value.publicKeyPem);
if (derivedDeviceId !== value.deviceId) {
throw invalidStoredIdentityError(identityKey);
}
return {
deviceId: value.deviceId,
publicKeyPem: value.publicKeyPem,
privateKeyPem: value.privateKeyPem,
};
} catch (error) {
if (error instanceof DeviceIdentityStorageError) {
throw error;
}
throw invalidStoredIdentityError(identityKey, error);
}
}
function rowToStoredIdentity(
row: DeviceIdentityRow,
expectedIdentityKey: string,
): StoredDeviceIdentity {
if (
row.identity_key !== expectedIdentityKey ||
typeof row.device_id !== "string" ||
typeof row.public_key_pem !== "string" ||
typeof row.private_key_pem !== "string" ||
parseCreatedAtMs(row.created_at_ms) === null ||
parseCreatedAtMs(row.updated_at_ms) === null
) {
throw invalidStoredIdentityError(expectedIdentityKey);
}
return {
deviceId: row.device_id,
publicKeyPem: row.public_key_pem,
privateKeyPem: row.private_key_pem,
createdAtMs: row.created_at_ms,
};
}
function salvageStoredIdentityRow(
row: DeviceIdentityRow,
expectedIdentityKey: string,
repairedAtMs: number,
): StoredDeviceIdentity | null {
// Device ids, timestamps, and PEM framing are repairable metadata. Preserve matching
// Ed25519 key bytes because rotating them would invalidate pairing and stored auth.
if (
row.identity_key !== expectedIdentityKey ||
typeof row.public_key_pem !== "string" ||
typeof row.private_key_pem !== "string"
) {
return null;
}
try {
const publicKey = crypto.createPublicKey(row.public_key_pem);
const privateKey = crypto.createPrivateKey(row.private_key_pem);
if (publicKey.asymmetricKeyType !== "ed25519" || privateKey.asymmetricKeyType !== "ed25519") {
return null;
}
const canonicalPublicKeyPem = publicKey.export({ type: "spki", format: "pem" });
const canonicalPrivateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
const derivedPublicKeyPem = crypto
.createPublicKey(canonicalPrivateKeyPem)
.export({ type: "spki", format: "pem" });
if (derivedPublicKeyPem !== canonicalPublicKeyPem) {
return null;
}
const createdAtMs =
parseCreatedAtMs(row.created_at_ms) ?? parseCreatedAtMs(row.updated_at_ms) ?? repairedAtMs;
const salvaged = {
deviceId: fingerprintPublicKey(canonicalPublicKeyPem),
publicKeyPem: canonicalPublicKeyPem,
privateKeyPem: canonicalPrivateKeyPem,
createdAtMs,
};
validateStoredDeviceIdentity(salvaged, expectedIdentityKey);
return salvaged;
} catch {
return null;
}
}
function storedIdentityToRow(
identityKey: string,
stored: StoredDeviceIdentity,
updatedAtMs = stored.createdAtMs,
): DeviceIdentityInsert {
return {
identity_key: identityKey,
device_id: stored.deviceId,
public_key_pem: stored.publicKeyPem,
private_key_pem: stored.privateKeyPem,
created_at_ms: stored.createdAtMs,
updated_at_ms: updatedAtMs,
};
}
function readStoredIdentityRowFromDatabase(
database: { db: Parameters<typeof getNodeSqliteKysely>[0] },
identityKey: string,
): DeviceIdentityRow | null {
const db = getNodeSqliteKysely<DeviceIdentityDatabase>(database.db);
return (
executeSqliteQueryTakeFirstSync(
database.db,
db.selectFrom("device_identities").selectAll().where("identity_key", "=", identityKey),
) ?? null
);
}
function readStoredIdentityFromDatabase(
database: { db: Parameters<typeof getNodeSqliteKysely>[0] },
identityKey: string,
): StoredDeviceIdentity | null {
const row = readStoredIdentityRowFromDatabase(database, identityKey);
return row ? rowToStoredIdentity(row, identityKey) : null;
}
/** Resolve the concrete database and row identity used by process caches and diagnostics. */
export function resolveDeviceIdentityStore(options: DeviceIdentityStoreOptions = {}): {
databasePath: string;
identityKey: string;
} {
return {
databasePath: path.resolve(
options.path ?? resolveOpenClawStateSqlitePath(options.env ?? process.env),
),
identityKey: normalizeIdentityKey(options.identityKey),
};
}
/** Read through the writable shared-state lifecycle, validating any existing row. */
export function readStoredDeviceIdentity(
options: DeviceIdentityStoreOptions = {},
): StoredDeviceIdentity | null {
const resolved = resolveDeviceIdentityStore(options);
const database = openOpenClawStateDatabase({
env: options.env,
path: resolved.databasePath,
});
const stored = readStoredIdentityFromDatabase(database, resolved.identityKey);
if (stored) {
validateStoredDeviceIdentity(stored, resolved.identityKey);
}
return stored;
}
/** Read without creating, repairing, chmodding, or joining the writer lifecycle. */
export function readStoredDeviceIdentityReadOnly(
options: DeviceIdentityStoreOptions = {},
): StoredDeviceIdentity | null {
const resolved = resolveDeviceIdentityStore(options);
try {
fs.lstatSync(resolved.databasePath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
return null;
}
return withOpenClawStateDatabaseReadOnly(
(database) => {
const stored = readStoredIdentityFromDatabase(database, resolved.identityKey);
if (stored) {
validateStoredDeviceIdentity(stored, resolved.identityKey);
}
return stored;
},
{ env: options.env, path: resolved.databasePath },
);
}
/** Insert a candidate only when the key is still absent, then return the authoritative row. */
export function insertStoredDeviceIdentityIfAbsent(
candidate: StoredDeviceIdentity,
options: DeviceIdentityStoreOptions = {},
): StoredDeviceIdentity {
const resolved = resolveDeviceIdentityStore(options);
validateStoredDeviceIdentity(candidate, resolved.identityKey);
return runOpenClawStateWriteTransaction(
({ db }) => {
const existing = readStoredIdentityFromDatabase({ db }, resolved.identityKey);
if (existing) {
validateStoredDeviceIdentity(existing, resolved.identityKey);
} else {
const kysely = getNodeSqliteKysely<DeviceIdentityDatabase>(db);
executeSqliteQuerySync(
db,
kysely
.insertInto("device_identities")
.values(storedIdentityToRow(resolved.identityKey, candidate))
.onConflict((conflict) => conflict.column("identity_key").doNothing()),
);
}
const authoritative = readStoredIdentityFromDatabase({ db }, resolved.identityKey);
if (!authoritative) {
throw new DeviceIdentityStorageError(
`SQLite device identity "${resolved.identityKey}" was not durable after insert.`,
);
}
validateStoredDeviceIdentity(authoritative, resolved.identityKey);
return authoritative;
},
{ env: options.env, path: resolved.databasePath },
{ operationLabel: "device-identity.create" },
);
}
/** Replace only an invalid authoritative row; preserve a valid concurrent winner. */
export function repairInvalidStoredDeviceIdentity(
candidate: StoredDeviceIdentity,
options: DeviceIdentityStoreOptions = {},
): { identity: StoredDeviceIdentity; repaired: boolean; rotated: boolean } {
const resolved = resolveDeviceIdentityStore(options);
validateStoredDeviceIdentity(candidate, resolved.identityKey);
return runOpenClawStateWriteTransaction(
({ db }) => {
let repaired = false;
let rotated = false;
let existingRow: DeviceIdentityRow | null = null;
try {
existingRow = readStoredIdentityRowFromDatabase({ db }, resolved.identityKey);
const existing = existingRow
? rowToStoredIdentity(existingRow, resolved.identityKey)
: null;
if (existing) {
validateStoredDeviceIdentity(existing, resolved.identityKey);
return { identity: existing, repaired, rotated };
}
} catch (error) {
if (!(error instanceof DeviceIdentityStorageError)) {
throw error;
}
}
if (existingRow) {
const salvaged = salvageStoredIdentityRow(
existingRow,
resolved.identityKey,
candidate.createdAtMs,
);
if (salvaged) {
executeSqliteQuerySync(
db,
getNodeSqliteKysely<DeviceIdentityDatabase>(db)
.updateTable("device_identities")
.set({
device_id: salvaged.deviceId,
public_key_pem: salvaged.publicKeyPem,
private_key_pem: salvaged.privateKeyPem,
created_at_ms: salvaged.createdAtMs,
updated_at_ms: candidate.createdAtMs,
})
.where("identity_key", "=", resolved.identityKey),
);
const authoritative = readStoredIdentityFromDatabase({ db }, resolved.identityKey);
if (!authoritative) {
throw new DeviceIdentityStorageError(
`SQLite device identity "${resolved.identityKey}" was not durable after repair.`,
);
}
validateStoredDeviceIdentity(authoritative, resolved.identityKey);
return { identity: authoritative, repaired: true, rotated };
}
executeSqliteQuerySync(
db,
getNodeSqliteKysely<DeviceIdentityDatabase>(db)
.deleteFrom("device_identities")
.where("identity_key", "=", resolved.identityKey),
);
}
// An absent row after an invalid-row detection still means identity continuity was lost.
// Report the generated winner so Doctor always surfaces the required re-approval.
repaired = true;
rotated = true;
executeSqliteQuerySync(
db,
getNodeSqliteKysely<DeviceIdentityDatabase>(db)
.insertInto("device_identities")
.values(storedIdentityToRow(resolved.identityKey, candidate))
.onConflict((conflict) => conflict.column("identity_key").doNothing()),
);
const authoritative = readStoredIdentityFromDatabase({ db }, resolved.identityKey);
if (!authoritative) {
throw new DeviceIdentityStorageError(
`SQLite device identity "${resolved.identityKey}" was not durable after repair.`,
);
}
validateStoredDeviceIdentity(authoritative, resolved.identityKey);
return { identity: authoritative, repaired, rotated };
},
{ env: options.env, path: resolved.databasePath },
{ operationLabel: "device-identity.doctor-repair" },
);
}
+20 -54
View File
@@ -1,76 +1,42 @@
// Covers default device identity path under state dir.
import fs from "node:fs/promises";
// Covers default device identity SQLite path under the state dir.
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import { withStateDirEnv } from "../test-helpers/state-dir-env.js";
import { loadOrCreateDeviceIdentity } from "./device-identity.js";
import { loadDeviceIdentityIfPresent, loadOrCreateDeviceIdentity } from "./device-identity.js";
afterEach(() => {
closeOpenClawStateDatabaseForTest();
});
describe("device identity state dir defaults", () => {
it("writes the default identity file under OPENCLAW_STATE_DIR", async () => {
it("writes the default identity to the shared state database", async () => {
await withStateDirEnv("openclaw-identity-state-", async ({ stateDir }) => {
const identity = loadOrCreateDeviceIdentity();
const identityPath = path.join(stateDir, "identity", "device.json");
const raw = JSON.parse(await fs.readFile(identityPath, "utf8")) as { deviceId?: string };
expect(raw.deviceId).toBe(identity.deviceId);
const databasePath = path.join(stateDir, "state", "openclaw.sqlite");
expect(loadDeviceIdentityIfPresent()).toEqual(identity);
expect(fs.existsSync(databasePath)).toBe(true);
expect(fs.existsSync(path.join(stateDir, "identity", "device.json"))).toBe(false);
});
});
it("reuses the stored identity on subsequent loads", async () => {
await withStateDirEnv("openclaw-identity-state-", async ({ stateDir }) => {
await withStateDirEnv("openclaw-identity-state-", async () => {
const first = loadOrCreateDeviceIdentity();
const second = loadOrCreateDeviceIdentity();
const identityPath = path.join(stateDir, "identity", "device.json");
const raw = JSON.parse(await fs.readFile(identityPath, "utf8")) as {
deviceId?: string;
publicKeyPem?: string;
};
expect(second).toEqual(first);
expect(raw.deviceId).toBe(first.deviceId);
expect(raw.publicKeyPem).toBe(first.publicKeyPem);
});
});
it("repairs stored device IDs that no longer match the public key", async () => {
it("keeps read-only lookup non-creating when the default database is absent", async () => {
await withStateDirEnv("openclaw-identity-state-", async ({ stateDir }) => {
const original = loadOrCreateDeviceIdentity();
const identityPath = path.join(stateDir, "identity", "device.json");
const raw = JSON.parse(await fs.readFile(identityPath, "utf8")) as Record<string, unknown>;
const databasePath = path.join(stateDir, "state", "openclaw.sqlite");
await fs.writeFile(
identityPath,
`${JSON.stringify({ ...raw, deviceId: "stale-device-id" }, null, 2)}\n`,
"utf8",
);
const repaired = loadOrCreateDeviceIdentity();
const stored = JSON.parse(await fs.readFile(identityPath, "utf8")) as { deviceId?: string };
expect(repaired.deviceId).toBe(original.deviceId);
expect(stored.deviceId).toBe(original.deviceId);
});
});
it("returns a transient identity without overwriting an invalid stored file", async () => {
await withStateDirEnv("openclaw-identity-state-", async ({ stateDir }) => {
const identityPath = path.join(stateDir, "identity", "device.json");
await fs.mkdir(path.dirname(identityPath), { recursive: true });
const before = [
"{",
' "version": 1,',
' "deviceId": "broken",',
' "publicKeyPem": "not-a-valid-public-key",',
' "privateKeyPem": "not-a-valid-private-key"',
"}",
"",
].join("\n");
await fs.writeFile(identityPath, before, "utf8");
const regenerated = loadOrCreateDeviceIdentity();
const stored = await fs.readFile(identityPath, "utf8");
expect(regenerated.deviceId).not.toBe("broken");
expect(stored).toBe(before);
expect(loadDeviceIdentityIfPresent()).toBeNull();
expect(fs.existsSync(databasePath)).toBe(false);
});
});
});
+389 -205
View File
@@ -1,8 +1,17 @@
// Covers device identity creation, conversion, signing, and verification.
// Covers SQLite device identity creation, migration boundaries, and crypto helpers.
import { spawn, type ChildProcess } from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import {
closeOpenClawStateDatabaseForTest,
OPENCLAW_STATE_SCHEMA_VERSION,
} from "../state/openclaw-state-db.js";
import { withTempDir } from "../test-utils/temp-dir.js";
import { acquireDeviceIdentityCoordinator } from "./device-identity-coordinator.js";
import { normalizeLegacyDeviceIdentity } from "./device-identity-legacy.js";
import type { DeviceIdentityStoreOptions } from "./device-identity-store.js";
import {
deriveDeviceIdFromPublicKey,
loadDeviceIdentityIfPresent,
@@ -12,6 +21,7 @@ import {
publicKeyRawBase64UrlFromPem,
signDevicePayload,
verifyDeviceSignature,
type DeviceIdentity,
} from "./device-identity.js";
const SWIFT_RAW_DEVICE_ID = "56475aa75463474c0285df5dbf2bcab73da651358839e9b77481b2eab107708c";
@@ -19,218 +29,392 @@ const SWIFT_RAW_PUBLIC_KEY = "A6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=";
const SWIFT_RAW_PRIVATE_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="; // pragma: allowlist secret
const MISMATCHED_SWIFT_RAW_PRIVATE_KEY = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="; // pragma: allowlist secret
async function withIdentity(
run: (identity: ReturnType<typeof loadOrCreateDeviceIdentity>) => void,
) {
await withTempDir("openclaw-device-identity-", async (dir) => {
const identity = loadOrCreateDeviceIdentity(path.join(dir, "device.json"));
run(identity);
afterEach(() => {
closeOpenClawStateDatabaseForTest();
});
function storeOptions(rootDir: string, identityKey?: string): DeviceIdentityStoreOptions {
return {
env: { ...process.env, OPENCLAW_STATE_DIR: rootDir },
path: path.join(rootDir, "state", "openclaw.sqlite"),
...(identityKey ? { identityKey } : {}),
};
}
function waitForChild(child: ChildProcess): Promise<DeviceIdentity> {
let stdout = "";
let stderr = "";
child.stdout?.on("data", (chunk) => (stdout += String(chunk)));
child.stderr?.on("data", (chunk) => (stderr += String(chunk)));
return new Promise((resolve, reject) => {
child.once("error", reject);
child.once("close", (code, signal) => {
if (code !== 0) {
reject(new Error(`identity worker failed (${String(code ?? signal)}): ${stderr}`));
return;
}
const resultLine = stdout.trim().split("\n").at(-1);
if (!resultLine) {
reject(new Error("identity worker produced no result"));
return;
}
resolve(JSON.parse(resultLine) as DeviceIdentity);
});
});
}
describe("device identity crypto helpers", () => {
it("loads an existing identity without creating a missing file", async () => {
await withTempDir("openclaw-device-identity-readonly-", async (dir) => {
const identityPath = path.join(dir, "identity", "device.json");
expect(loadDeviceIdentityIfPresent(identityPath)).toBeNull();
expect(fs.existsSync(identityPath)).toBe(false);
const created = loadOrCreateDeviceIdentity(identityPath);
expect(loadDeviceIdentityIfPresent(identityPath)).toEqual(created);
});
});
it("does not repair mismatched stored device ids in read-only mode", async () => {
await withTempDir("openclaw-device-identity-readonly-", async (dir) => {
const identityPath = path.join(dir, "identity", "device.json");
loadOrCreateDeviceIdentity(identityPath);
const stored = JSON.parse(fs.readFileSync(identityPath, "utf8")) as Record<string, unknown>;
fs.writeFileSync(
identityPath,
`${JSON.stringify({ ...stored, deviceId: "mismatched" }, null, 2)}\n`,
"utf8",
);
const before = fs.readFileSync(identityPath, "utf8");
expect(loadDeviceIdentityIfPresent(identityPath)).toBeNull();
expect(fs.readFileSync(identityPath, "utf8")).toBe(before);
});
});
it("loads Swift raw-key identity files without generating a new device id", async () => {
await withTempDir("openclaw-device-identity-swift-", async (dir) => {
const identityPath = path.join(dir, "identity", "device.json");
fs.mkdirSync(path.dirname(identityPath), { recursive: true });
fs.writeFileSync(
identityPath,
`${JSON.stringify(
{
deviceId: SWIFT_RAW_DEVICE_ID,
publicKey: SWIFT_RAW_PUBLIC_KEY,
privateKey: SWIFT_RAW_PRIVATE_KEY,
createdAtMs: 1_700_000_000_000,
},
null,
2,
)}\n`,
"utf8",
);
const readonly = loadDeviceIdentityIfPresent(identityPath);
const loaded = loadOrCreateDeviceIdentity(identityPath);
const stored = JSON.parse(fs.readFileSync(identityPath, "utf8")) as Record<string, unknown>;
expect(readonly?.deviceId).toBe(SWIFT_RAW_DEVICE_ID);
expect(loaded.deviceId).toBe(SWIFT_RAW_DEVICE_ID);
expect(publicKeyRawBase64UrlFromPem(loaded.publicKeyPem)).toBe(
"A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg",
);
expect(
verifyDeviceSignature(
loaded.publicKeyPem,
"hello",
signDevicePayload(loaded.privateKeyPem, "hello"),
),
).toBe(true);
expect(stored.version).toBe(1);
expect(stored.deviceId).toBe(SWIFT_RAW_DEVICE_ID);
expect(typeof stored.publicKeyPem).toBe("string");
expect(typeof stored.privateKeyPem).toBe("string");
const publicKeyPem = stored.publicKeyPem as string;
const privateKeyPem = stored.privateKeyPem as string;
expect(publicKeyPem.startsWith("-----BEGIN PUBLIC KEY-----\n")).toBe(true);
expect(publicKeyPem.endsWith("-----END PUBLIC KEY-----\n")).toBe(true);
expect(privateKeyPem.startsWith("-----BEGIN PRIVATE KEY-----\n")).toBe(true);
expect(privateKeyPem.endsWith("-----END PRIVATE KEY-----\n")).toBe(true);
expect(stored.createdAtMs).toBe(1_700_000_000_000);
expect(stored).not.toHaveProperty("publicKey");
expect(stored).not.toHaveProperty("privateKey");
});
});
it("does not overwrite recognized invalid identity files", async () => {
await withTempDir("openclaw-device-identity-invalid-", async (dir) => {
const identityPath = path.join(dir, "identity", "device.json");
fs.mkdirSync(path.dirname(identityPath), { recursive: true });
fs.writeFileSync(
identityPath,
`${JSON.stringify(
{
version: 1,
deviceId: "stale-device-id",
publicKeyPem: "not-a-valid-public-key",
privateKeyPem: "not-a-valid-private-key", // pragma: allowlist secret
createdAtMs: 1_700_000_000_000,
},
null,
2,
)}\n`,
"utf8",
);
const before = fs.readFileSync(identityPath, "utf8");
expect(loadDeviceIdentityIfPresent(identityPath)).toBeNull();
const loaded = loadOrCreateDeviceIdentity(identityPath);
const processIdentity = loadOrCreateProcessDeviceIdentity(identityPath);
expect(loaded.deviceId).not.toBe("stale-device-id");
expect(loadOrCreateProcessDeviceIdentity(identityPath)).toBe(processIdentity);
expect(fs.readFileSync(identityPath, "utf8")).toBe(before);
});
});
it("does not overwrite existing unrecognized identity files", async () => {
await withTempDir("openclaw-device-identity-unrecognized-", async (dir) => {
const identityPath = path.join(dir, "identity", "device.json");
fs.mkdirSync(path.dirname(identityPath), { recursive: true });
fs.writeFileSync(
identityPath,
`${JSON.stringify(
{
schema: "future-openclaw-device-identity",
stableDeviceId: "app-group-device-id",
},
null,
2,
)}\n`,
"utf8",
);
const before = fs.readFileSync(identityPath, "utf8");
expect(loadDeviceIdentityIfPresent(identityPath)).toBeNull();
const loaded = loadOrCreateDeviceIdentity(identityPath);
expect(loaded.deviceId).not.toBe("app-group-device-id");
expect(fs.readFileSync(identityPath, "utf8")).toBe(before);
});
});
it("does not migrate Swift raw-key identity files with mismatched key material", async () => {
await withTempDir("openclaw-device-identity-swift-invalid-", async (dir) => {
const identityPath = path.join(dir, "identity", "device.json");
fs.mkdirSync(path.dirname(identityPath), { recursive: true });
fs.writeFileSync(
identityPath,
`${JSON.stringify(
{
deviceId: SWIFT_RAW_DEVICE_ID,
publicKey: SWIFT_RAW_PUBLIC_KEY,
privateKey: MISMATCHED_SWIFT_RAW_PRIVATE_KEY,
createdAtMs: 1_700_000_000_000,
},
null,
2,
)}\n`,
"utf8",
);
const before = fs.readFileSync(identityPath, "utf8");
expect(loadDeviceIdentityIfPresent(identityPath)).toBeNull();
const loaded = loadOrCreateDeviceIdentity(identityPath);
expect(loaded.deviceId).not.toBe(SWIFT_RAW_DEVICE_ID);
expect(fs.readFileSync(identityPath, "utf8")).toBe(before);
});
});
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);
const paddedBase64 = `${publicKeyRaw.replaceAll("-", "+").replaceAll("_", "/")}==`;
expect(normalizeDevicePublicKeyBase64Url(identity.publicKeyPem)).toBe(publicKeyRaw);
expect(normalizeDevicePublicKeyBase64Url(paddedBase64)).toBe(publicKeyRaw);
expect(deriveDeviceIdFromPublicKey(identity.publicKeyPem)).toBe(identity.deviceId);
expect(deriveDeviceIdFromPublicKey(publicKeyRaw)).toBe(identity.deviceId);
});
});
it("signs payloads that verify against pem and raw public key forms", async () => {
await withIdentity((identity) => {
const payload = JSON.stringify({
action: "system.run",
ts: 1234,
async function runConcurrentIdentityLoads(rootDir: string): Promise<DeviceIdentity[]> {
const startPath = path.join(rootDir, "identity-start");
const moduleUrl = new URL("./device-identity.ts", import.meta.url).href;
const workerSource = `
import fs from "node:fs";
const { loadOrCreateDeviceIdentity } = await import(process.env.OPENCLAW_IDENTITY_MODULE);
fs.writeFileSync(process.env.OPENCLAW_IDENTITY_READY_PATH, "ready");
const deadline = Date.now() + 15_000;
while (!fs.existsSync(process.env.OPENCLAW_IDENTITY_START_PATH)) {
if (Date.now() >= deadline) {
throw new Error("timed out waiting for concurrent identity start");
}
await new Promise((resolve) => {
setTimeout(resolve, 2);
});
const signature = signDevicePayload(identity.privateKeyPem, payload);
const publicKeyRaw = publicKeyRawBase64UrlFromPem(identity.publicKeyPem);
}
const identity = loadOrCreateDeviceIdentity({
env: { ...process.env, OPENCLAW_STATE_DIR: process.env.OPENCLAW_IDENTITY_STATE_DIR },
path: process.env.OPENCLAW_IDENTITY_DATABASE_PATH,
});
console.log(JSON.stringify(identity));
`;
const workers = [0, 1].map((index) => {
const readyPath = path.join(rootDir, `identity-ready-${index}`);
const child = spawn(
process.execPath,
["--import", "tsx", "--input-type=module", "-e", workerSource],
{
env: {
...process.env,
OPENCLAW_IDENTITY_DATABASE_PATH: path.join(rootDir, "state", "openclaw.sqlite"),
OPENCLAW_IDENTITY_MODULE: moduleUrl,
OPENCLAW_IDENTITY_READY_PATH: readyPath,
OPENCLAW_IDENTITY_START_PATH: startPath,
OPENCLAW_IDENTITY_STATE_DIR: rootDir,
},
stdio: ["ignore", "pipe", "pipe"],
},
);
return { child, outcome: waitForChild(child), readyPath };
});
expect(verifyDeviceSignature(identity.publicKeyPem, payload, signature)).toBe(true);
expect(verifyDeviceSignature(publicKeyRaw, payload, signature)).toBe(true);
expect(verifyDeviceSignature(publicKeyRaw, `${payload}!`, signature)).toBe(false);
try {
const deadline = Date.now() + 15_000;
while (!workers.every((worker) => fs.existsSync(worker.readyPath))) {
if (workers.some(({ child }) => child.exitCode !== null || child.signalCode !== null)) {
break;
}
if (Date.now() >= deadline) {
throw new Error("timed out waiting for concurrent identity workers");
}
await new Promise<void>((resolve) => {
setTimeout(resolve, 2);
});
}
fs.writeFileSync(startPath, "start");
return await Promise.all(workers.map((worker) => worker.outcome));
} finally {
for (const { child } of workers) {
if (child.exitCode === null && child.signalCode === null) {
child.kill();
}
}
await Promise.allSettled(workers.map((worker) => worker.outcome));
}
}
describe("device identity SQLite store", () => {
it("serializes identity ownership with the shared SQLite coordinator", async () => {
await withTempDir("openclaw-device-identity-coordinator-", async (rootDir) => {
const databasePath = path.join(rootDir, "state", "openclaw.sqlite");
const lockDir = path.join(rootDir, "locks");
const first = acquireDeviceIdentityCoordinator({ databasePath, lockDir, busyTimeoutMs: 0 });
try {
expect(() =>
acquireDeviceIdentityCoordinator({ databasePath, lockDir, busyTimeoutMs: 0 }),
).toThrow(/migration or creation already owns this state database/);
} finally {
first.release();
}
const next = acquireDeviceIdentityCoordinator({ databasePath, lockDir, busyTimeoutMs: 0 });
next.release();
fs.chmodSync(lockDir, 0o755);
const secured = acquireDeviceIdentityCoordinator({ databasePath, lockDir, busyTimeoutMs: 0 });
try {
expect(fs.statSync(lockDir).mode & 0o077).toBe(0);
} finally {
secured.release();
}
const symlinkLockDir = path.join(rootDir, "symlink-locks");
fs.symlinkSync(lockDir, symlinkLockDir);
expect(() =>
acquireDeviceIdentityCoordinator({
databasePath,
lockDir: symlinkLockDir,
busyTimeoutMs: 0,
}),
).toThrow(/real directory/);
});
});
it("fails closed for invalid public keys and signatures", async () => {
await withIdentity((identity) => {
const payload = "hello";
const signature = signDevicePayload(identity.privateKeyPem, payload);
it("reads a missing database without creating files", async () => {
await withTempDir("openclaw-device-identity-readonly-", async (rootDir) => {
const options = storeOptions(rootDir);
expect(loadDeviceIdentityIfPresent(options)).toBeNull();
expect(fs.existsSync(options.path!)).toBe(false);
expect(fs.existsSync(path.dirname(options.path!))).toBe(false);
});
});
expect(normalizeDevicePublicKeyBase64Url("-----BEGIN PUBLIC KEY-----broken")).toBeNull();
expect(deriveDeviceIdFromPublicKey("%%%")).toBeNull();
expect(verifyDeviceSignature("%%%invalid%%%", payload, signature)).toBe(false);
expect(verifyDeviceSignature(identity.publicKeyPem, payload, "%%%invalid%%%")).toBe(false);
it("creates and reuses the primary identity in SQLite", async () => {
await withTempDir("openclaw-device-identity-create-", async (rootDir) => {
const options = storeOptions(rootDir);
const created = loadOrCreateDeviceIdentity(options);
const loaded = loadOrCreateDeviceIdentity(options);
expect(loaded).toEqual(created);
expect(loadDeviceIdentityIfPresent(options)).toEqual(created);
expect(fs.existsSync(options.path!)).toBe(true);
expect(fs.existsSync(path.join(rootDir, "identity", "device.json"))).toBe(false);
});
});
it("adopts a Swift-created version-zero identity database and completes the shared schema", async () => {
await withTempDir("openclaw-device-identity-swift-db-", async (rootDir) => {
const options = storeOptions(rootDir);
const expected = normalizeLegacyDeviceIdentity({
deviceId: SWIFT_RAW_DEVICE_ID,
publicKey: SWIFT_RAW_PUBLIC_KEY,
privateKey: SWIFT_RAW_PRIVATE_KEY,
createdAtMs: 1_700_000_000_000,
});
if (!expected) {
throw new Error("Swift identity fixture must normalize");
}
fs.mkdirSync(path.dirname(options.path!), { recursive: true });
const sqlite = await import("node:sqlite");
const database = new sqlite.DatabaseSync(options.path!);
database.exec(`
CREATE TABLE device_identities (
identity_key TEXT NOT NULL PRIMARY KEY,
device_id TEXT NOT NULL,
public_key_pem TEXT NOT NULL,
private_key_pem TEXT NOT NULL,
created_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL
) STRICT;
CREATE INDEX idx_device_identities_device
ON device_identities(device_id, updated_at_ms DESC);
`);
database
.prepare(`
INSERT INTO device_identities (
identity_key, device_id, public_key_pem, private_key_pem, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?)
`)
.run(
"primary",
expected.deviceId,
expected.publicKeyPem,
expected.privateKeyPem,
expected.createdAtMs,
expected.createdAtMs,
);
database.close();
expect(loadOrCreateDeviceIdentity(options)).toEqual({
deviceId: expected.deviceId,
publicKeyPem: expected.publicKeyPem,
privateKeyPem: expected.privateKeyPem,
});
closeOpenClawStateDatabaseForTest();
const verified = new sqlite.DatabaseSync(options.path!, { readOnly: true });
expect(verified.prepare("PRAGMA user_version").get()).toEqual({
user_version: OPENCLAW_STATE_SCHEMA_VERSION,
});
expect(
verified
.prepare("SELECT role, schema_version FROM schema_meta WHERE meta_key = 'primary'")
.get(),
).toEqual({ role: "global", schema_version: OPENCLAW_STATE_SCHEMA_VERSION });
verified.close();
});
});
it("keeps process identities cached by database path and identity key", async () => {
await withTempDir("openclaw-device-identity-cache-", async (rootDir) => {
const primaryOptions = storeOptions(rootDir);
const secondaryOptions = storeOptions(rootDir, "secondary");
const primary = loadOrCreateProcessDeviceIdentity(primaryOptions);
const secondary = loadOrCreateProcessDeviceIdentity(secondaryOptions);
expect(loadOrCreateProcessDeviceIdentity(primaryOptions)).toBe(primary);
expect(loadOrCreateProcessDeviceIdentity(secondaryOptions)).toBe(secondary);
expect(secondary.deviceId).not.toBe(primary.deviceId);
const claimPath = path.join(rootDir, "identity", "device.json.doctor-importing");
fs.mkdirSync(path.dirname(claimPath), { recursive: true });
fs.writeFileSync(claimPath, "{}\n");
expect(() => loadOrCreateProcessDeviceIdentity(primaryOptions)).toThrow(/doctor --fix/);
});
});
it("returns one authoritative winner to concurrent creators", async () => {
await withTempDir("openclaw-device-identity-concurrent-", async (rootDir) => {
const [first, second] = await runConcurrentIdentityLoads(rootDir);
expect(second).toEqual(first);
expect(loadDeviceIdentityIfPresent(storeOptions(rootDir))).toEqual(first);
});
}, 30_000);
it("fails closed for a corrupt persisted row", async () => {
await withTempDir("openclaw-device-identity-corrupt-", async (rootDir) => {
const options = storeOptions(rootDir);
loadOrCreateDeviceIdentity(options);
closeOpenClawStateDatabaseForTest();
const sqlite = await import("node:sqlite");
const database = new sqlite.DatabaseSync(options.path!);
database
.prepare("UPDATE device_identities SET device_id = ? WHERE identity_key = ?")
.run("corrupt-device-id", "primary");
database.close();
expect(() => loadDeviceIdentityIfPresent(options)).toThrow(
/invalid persisted device identity/,
);
expect(() => loadOrCreateDeviceIdentity(options)).toThrow(
/invalid persisted device identity/,
);
});
});
it.each(["device.json", "device.json.doctor-importing", "device.json.native-importing"])(
"blocks SQLite access while legacy %s may exist",
async (legacyName) => {
await withTempDir("openclaw-device-identity-legacy-", async (rootDir) => {
const options = storeOptions(rootDir);
const legacyPath = path.join(rootDir, "identity", legacyName);
fs.mkdirSync(path.dirname(legacyPath), { recursive: true });
fs.writeFileSync(legacyPath, "{}\n");
expect(() => loadDeviceIdentityIfPresent(options)).toThrow(/doctor --fix/);
expect(() => loadOrCreateDeviceIdentity(options)).toThrow(/doctor --fix/);
expect(fs.existsSync(options.path!)).toBe(false);
});
},
);
it.each([
["canonical", (rootDir: string) => path.join(rootDir, "state", "openclaw.sqlite")],
["arbitrary", (rootDir: string) => path.join(rootDir, "identity-state.sqlite")],
])("derives the legacy root from an explicit %s database path", async (_label, dbPath) => {
await withTempDir("openclaw-device-identity-explicit-path-", async (rootDir) => {
const legacyPath = path.join(rootDir, "identity", "device.json");
fs.mkdirSync(path.dirname(legacyPath), { recursive: true });
fs.writeFileSync(legacyPath, "{}\n");
expect(() => loadOrCreateDeviceIdentity({ path: dbPath(rootDir) })).toThrow(/doctor --fix/);
});
});
});
describe("legacy device identity normalization", () => {
it("normalizes valid Node PEM material and derives its canonical device id", () => {
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
const normalized = normalizeLegacyDeviceIdentity({
version: 1,
deviceId: "stale-device-id",
publicKeyPem,
privateKeyPem,
createdAtMs: 1_700_000_000_000,
});
expect(normalized).toMatchObject({
deviceId: deriveDeviceIdFromPublicKey(publicKeyPem),
publicKeyPem,
privateKeyPem,
createdAtMs: 1_700_000_000_000,
});
});
it("converts valid Swift raw-key material to PEM", () => {
const normalized = normalizeLegacyDeviceIdentity({
deviceId: SWIFT_RAW_DEVICE_ID,
publicKey: SWIFT_RAW_PUBLIC_KEY,
privateKey: SWIFT_RAW_PRIVATE_KEY,
createdAtMs: 1_700_000_000_000,
});
expect(normalized?.deviceId).toBe(SWIFT_RAW_DEVICE_ID);
expect(normalized?.createdAtMs).toBe(1_700_000_000_000);
expect(crypto.createPublicKey(normalized?.publicKeyPem ?? "").asymmetricKeyType).toBe(
"ed25519",
);
expect(crypto.createPrivateKey(normalized?.privateKeyPem ?? "").asymmetricKeyType).toBe(
"ed25519",
);
});
it("rejects mismatched or malformed legacy key material", () => {
expect(
normalizeLegacyDeviceIdentity({
deviceId: SWIFT_RAW_DEVICE_ID,
publicKey: SWIFT_RAW_PUBLIC_KEY,
privateKey: MISMATCHED_SWIFT_RAW_PRIVATE_KEY,
createdAtMs: 1_700_000_000_000,
}),
).toBeNull();
expect(
normalizeLegacyDeviceIdentity({
version: 1,
deviceId: SWIFT_RAW_DEVICE_ID,
publicKeyPem: "not-a-key",
privateKeyPem: "not-a-key",
createdAtMs: Number.NaN,
}),
).toBeNull();
});
});
describe("device identity crypto helpers", () => {
it("preserves existing public-key wire normalization", () => {
const { publicKey } = crypto.generateKeyPairSync("ed25519");
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
const publicKeyRaw = publicKeyRawBase64UrlFromPem(publicKeyPem);
const standardBase64 = `${publicKeyRaw.replaceAll("-", "+").replaceAll("_", "/")}=`;
expect(normalizeDevicePublicKeyBase64Url(publicKeyPem)).toBe(publicKeyRaw);
expect(normalizeDevicePublicKeyBase64Url(standardBase64)).toBe(publicKeyRaw);
expect(normalizeDevicePublicKeyBase64Url(`${standardBase64}=`)).toBe(publicKeyRaw);
expect(deriveDeviceIdFromPublicKey(publicKeyRaw)).toBe(
deriveDeviceIdFromPublicKey(publicKeyPem),
);
});
it("signs payloads that verify against PEM and raw public key forms", () => {
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
const payload = JSON.stringify({ action: "system.run", ts: 1234 });
const signature = signDevicePayload(privateKeyPem, payload);
const publicKeyRaw = publicKeyRawBase64UrlFromPem(publicKeyPem);
expect(verifyDeviceSignature(publicKeyPem, payload, signature)).toBe(true);
expect(verifyDeviceSignature(publicKeyRaw, payload, signature)).toBe(true);
expect(verifyDeviceSignature(publicKeyRaw, `${payload}!`, signature)).toBe(false);
});
});
+147 -243
View File
@@ -1,277 +1,182 @@
// Stores and converts the gateway/device Ed25519 identity.
// Gateway/device Ed25519 identity API backed by canonical shared SQLite state.
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { resolveStateDir } from "../config/paths.js";
import { acquireDeviceIdentityCoordinator } from "./device-identity-coordinator.js";
import {
generateStoredDeviceIdentity,
insertStoredDeviceIdentityIfAbsent,
PRIMARY_DEVICE_IDENTITY_KEY,
readStoredDeviceIdentity,
readStoredDeviceIdentityReadOnly,
resolveDeviceIdentityStore,
type DeviceIdentity,
type DeviceIdentityStoreOptions,
type StoredDeviceIdentity,
} from "./device-identity-store.js";
import {
base64UrlDecode,
deriveEd25519PublicKeyRaw,
ed25519PrivateKeyPemFromRaw,
ed25519PublicKeyPemFromRaw,
normalizeEd25519PublicKeyBase64Url,
publicKeyRawBase64UrlFromEd25519Pem,
signEd25519Payload,
verifyEd25519Signature,
} from "./ed25519-signature.js";
import { privateFileStoreSync } from "./private-file-store.js";
/** Gateway/device Ed25519 identity used for APNs relay and gateway authentication. */
export type DeviceIdentity = {
deviceId: string;
publicKeyPem: string;
privateKeyPem: string;
};
export type { DeviceIdentity } from "./device-identity-store.js";
type StoredIdentity = {
version: 1;
deviceId: string;
publicKeyPem: string;
privateKeyPem: string;
createdAtMs: number;
};
const LEGACY_DEVICE_IDENTITY_RELATIVE_PATH = path.join("identity", "device.json");
const DOCTOR_CLAIM_SUFFIX = ".doctor-importing";
const NATIVE_CLAIM_SUFFIX = ".native-importing";
type StoredSwiftIdentity = {
deviceId: string;
publicKey: string;
privateKey: string;
createdAtMs: number;
};
function resolveDefaultIdentityPath(): string {
return path.join(resolveStateDir(), "identity", "device.json");
}
// Swift stores raw Ed25519 key bytes; Node crypto needs DER/PEM wrappers around them.
const publicKeyPemFromRaw = ed25519PublicKeyPemFromRaw;
const privateKeyPemFromRaw = ed25519PrivateKeyPemFromRaw;
function fingerprintPublicKey(publicKeyPem: string): string {
const raw = deriveEd25519PublicKeyRaw(publicKeyPem);
return crypto.createHash("sha256").update(raw).digest("hex");
}
function tryFingerprintPublicKey(publicKeyPem: string): string | null {
try {
return fingerprintPublicKey(publicKeyPem);
} catch {
return null;
class DeviceIdentityMigrationRequiredError extends Error {
constructor(filePath: string) {
super(
`Legacy device identity exists at ${filePath}. Run "openclaw doctor --fix" before starting the gateway or connecting this client.`,
);
this.name = "DeviceIdentityMigrationRequiredError";
}
}
function keyPairMatches(publicKeyPem: string, privateKeyPem: string): boolean {
function toDeviceIdentity(stored: StoredDeviceIdentity): DeviceIdentity {
return {
deviceId: stored.deviceId,
publicKeyPem: stored.publicKeyPem,
privateKeyPem: stored.privateKeyPem,
};
}
function pathMayExist(filePath: string): boolean {
try {
const payload = Buffer.from("openclaw-device-identity-self-check", "utf8");
const signature = crypto.sign(null, payload, crypto.createPrivateKey(privateKeyPem));
return crypto.verify(null, payload, crypto.createPublicKey(publicKeyPem), signature);
} catch {
return false;
fs.lstatSync(filePath);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code !== "ENOENT";
}
}
function generateIdentity(): DeviceIdentity {
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
const deviceId = fingerprintPublicKey(publicKeyPem);
return { deviceId, publicKeyPem, privateKeyPem };
function resolveLegacyStateDir(options: DeviceIdentityStoreOptions): string {
if (options.env?.OPENCLAW_STATE_DIR?.trim()) {
return resolveStateDir(options.env);
}
if (options.path) {
const databaseDir = path.dirname(path.resolve(options.path));
return path.basename(databaseDir) === "state" ? path.dirname(databaseDir) : databaseDir;
}
return resolveStateDir(options.env ?? process.env);
}
type NormalizedStoredIdentity =
| {
kind: "identity";
identity: DeviceIdentity;
stored?: StoredIdentity;
validForReadOnly: boolean;
/** Exact retired file owned by Doctor migration code. */
function resolveLegacyDeviceIdentityPath(options: DeviceIdentityStoreOptions = {}): string {
return path.join(resolveLegacyStateDir(options), LEGACY_DEVICE_IDENTITY_RELATIVE_PATH);
}
function assertNoPendingLegacyIdentity(options: DeviceIdentityStoreOptions): void {
const { identityKey } = resolveDeviceIdentityStore(options);
if (identityKey !== PRIMARY_DEVICE_IDENTITY_KEY) {
return;
}
const legacyPath = resolveLegacyDeviceIdentityPath(options);
if (
// Claims first, source last: both migration owners restore claim -> source atomically.
pathMayExist(`${legacyPath}${DOCTOR_CLAIM_SUFFIX}`) ||
pathMayExist(`${legacyPath}${NATIVE_CLAIM_SUFFIX}`) ||
pathMayExist(legacyPath)
) {
throw new DeviceIdentityMigrationRequiredError(legacyPath);
}
}
function withDeviceIdentityCoordinator<T>(
options: DeviceIdentityStoreOptions,
operation: (
resolved: ReturnType<typeof resolveDeviceIdentityStore>,
resolvedOptions: DeviceIdentityStoreOptions,
) => T,
): T {
const resolved = resolveDeviceIdentityStore(options);
const resolvedOptions: DeviceIdentityStoreOptions = {
...options,
path: resolved.databasePath,
identityKey: resolved.identityKey,
};
const coordinator = acquireDeviceIdentityCoordinator({ databasePath: resolved.databasePath });
let result: T;
try {
result = operation(resolved, resolvedOptions);
} catch (operationError) {
try {
coordinator.release();
} catch (releaseError) {
const aggregateError = new AggregateError(
[operationError, releaseError],
"device identity operation and coordinator release both failed",
{ cause: releaseError },
);
throw aggregateError;
}
| { kind: "recognized-invalid" };
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object";
throw operationError;
}
coordinator.release();
return result;
}
function hasRecognizedIdentityShape(parsed: unknown): boolean {
return (
isRecord(parsed) &&
("publicKeyPem" in parsed ||
"privateKeyPem" in parsed ||
"publicKey" in parsed ||
"privateKey" in parsed)
function loadOrCreateDeviceIdentityOwned(options: DeviceIdentityStoreOptions): DeviceIdentity {
assertNoPendingLegacyIdentity(options);
const existing = readStoredDeviceIdentity(options);
if (existing) {
return toDeviceIdentity(existing);
}
// Generate outside the write transaction. The transaction rereads the row
// before inserting so concurrent runtimes converge on one authoritative key.
const candidate = generateStoredDeviceIdentity();
return toDeviceIdentity(insertStoredDeviceIdentityIfAbsent(candidate, options));
}
/** Load a valid canonical identity or atomically create its SQLite row. */
export function loadOrCreateDeviceIdentity(
options: DeviceIdentityStoreOptions = {},
): DeviceIdentity {
return withDeviceIdentityCoordinator(options, (_resolved, resolvedOptions) =>
loadOrCreateDeviceIdentityOwned(resolvedOptions),
);
}
function normalizeStoredIdentity(parsed: unknown): NormalizedStoredIdentity | null {
if (
isRecord(parsed) &&
"version" in parsed &&
parsed.version === 1 &&
"deviceId" in parsed &&
typeof parsed.deviceId === "string" &&
"publicKeyPem" in parsed &&
typeof parsed.publicKeyPem === "string" &&
"privateKeyPem" in parsed &&
typeof parsed.privateKeyPem === "string"
) {
const stored = parsed as StoredIdentity;
const derivedId = tryFingerprintPublicKey(stored.publicKeyPem);
if (!derivedId || !keyPairMatches(stored.publicKeyPem, stored.privateKeyPem)) {
return { kind: "recognized-invalid" };
}
const identity = {
deviceId: derivedId,
publicKeyPem: stored.publicKeyPem,
privateKeyPem: stored.privateKeyPem,
};
return derivedId === stored.deviceId
? { kind: "identity", identity, validForReadOnly: true }
: {
kind: "identity",
identity,
validForReadOnly: false,
stored: {
...stored,
deviceId: derivedId,
},
};
}
const processDeviceIdentities = new Map<string, DeviceIdentity>();
const MAX_PROCESS_DEVICE_IDENTITIES = 32;
if (
isRecord(parsed) &&
!("version" in parsed) &&
"deviceId" in parsed &&
typeof parsed.deviceId === "string" &&
"publicKey" in parsed &&
typeof parsed.publicKey === "string" &&
"privateKey" in parsed &&
typeof parsed.privateKey === "string"
) {
const stored = parsed as StoredSwiftIdentity;
const publicKeyRaw = base64UrlDecode(stored.publicKey);
const privateKeyRaw = base64UrlDecode(stored.privateKey);
if (publicKeyRaw.length !== 32 || privateKeyRaw.length !== 32) {
return { kind: "recognized-invalid" };
}
const publicKeyPem = publicKeyPemFromRaw(publicKeyRaw);
const privateKeyPem = privateKeyPemFromRaw(privateKeyRaw);
if (!keyPairMatches(publicKeyPem, privateKeyPem)) {
return { kind: "recognized-invalid" };
}
// Migrate the legacy Swift raw-key shape only after the key pair proves valid.
const derivedId = fingerprintPublicKey(publicKeyPem);
const validForReadOnly = derivedId === stored.deviceId;
const migrated: StoredIdentity = {
version: 1,
deviceId: derivedId,
publicKeyPem,
privateKeyPem,
createdAtMs:
typeof stored.createdAtMs === "number" && Number.isFinite(stored.createdAtMs)
? stored.createdAtMs
: Date.now(),
};
return {
kind: "identity",
identity: {
deviceId: derivedId,
publicKeyPem,
privateKeyPem,
},
validForReadOnly,
stored: migrated,
};
}
return hasRecognizedIdentityShape(parsed) ? { kind: "recognized-invalid" } : null;
}
function identityFileExists(filePath: string): boolean {
try {
return fs.statSync(filePath).isFile();
} catch {
return false;
}
}
/** Load a valid persisted identity, repair/migrate when safe, or create a new one. */
export function loadOrCreateDeviceIdentity(
filePath: string = resolveDefaultIdentityPath(),
): DeviceIdentity {
try {
const store = privateFileStoreSync(path.dirname(filePath));
const parsed = store.readJsonIfExists(path.basename(filePath));
const normalized = normalizeStoredIdentity(parsed);
if (normalized?.kind === "identity") {
if (normalized.stored) {
try {
store.writeJson(path.basename(filePath), normalized.stored, {
trailingNewline: true,
});
} catch {
// Keep using recognized OpenClaw key material even if best-effort normalization fails.
}
}
return normalized.identity;
}
if (normalized?.kind === "recognized-invalid") {
// Avoid overwriting recognizable but invalid identity files; callers can still use a fresh key.
return generateIdentity();
}
if (identityFileExists(filePath)) {
// Unrecognized existing files may hold a newer schema; never overwrite them either.
return generateIdentity();
}
} catch {
if (identityFileExists(filePath)) {
return generateIdentity();
}
}
const identity = generateIdentity();
const stored: StoredIdentity = {
version: 1,
deviceId: identity.deviceId,
publicKeyPem: identity.publicKeyPem,
privateKeyPem: identity.privateKeyPem,
createdAtMs: Date.now(),
};
privateFileStoreSync(path.dirname(filePath)).writeJson(path.basename(filePath), stored, {
trailingNewline: true,
});
return identity;
}
let processDeviceIdentity: { filePath: string; identity: DeviceIdentity } | undefined;
/**
* Keep one identity stable for the lifetime of the active state-dir process.
* Recognizable invalid stores yield transient keys, so independent reloads would split gateway ownership.
*/
/** Keep one authoritative identity stable for the lifetime of a state-dir process. */
export function loadOrCreateProcessDeviceIdentity(
filePath: string = resolveDefaultIdentityPath(),
options: DeviceIdentityStoreOptions = {},
): DeviceIdentity {
if (processDeviceIdentity?.filePath === filePath) {
return processDeviceIdentity.identity;
}
const identity = loadOrCreateDeviceIdentity(filePath);
processDeviceIdentity = { filePath, identity };
return identity;
return withDeviceIdentityCoordinator(options, (resolved, resolvedOptions) => {
assertNoPendingLegacyIdentity(resolvedOptions);
const cacheKey = `${resolved.databasePath}\0${resolved.identityKey}`;
const cached = processDeviceIdentities.get(cacheKey);
if (cached) {
return cached;
}
const identity = loadOrCreateDeviceIdentityOwned(resolvedOptions);
if (processDeviceIdentities.size >= MAX_PROCESS_DEVICE_IDENTITIES) {
const oldestKey = processDeviceIdentities.keys().next().value;
if (oldestKey !== undefined) {
processDeviceIdentities.delete(oldestKey);
}
}
processDeviceIdentities.set(cacheKey, identity);
return identity;
});
}
/** Load a valid persisted device identity without creating, repairing, or migrating files. */
/** Load a valid persisted identity without creating or mutating SQLite state. */
export function loadDeviceIdentityIfPresent(
filePath: string = resolveDefaultIdentityPath(),
options: DeviceIdentityStoreOptions = {},
): DeviceIdentity | null {
try {
const parsed = privateFileStoreSync(path.dirname(filePath)).readJsonIfExists(
path.basename(filePath),
);
const normalized = normalizeStoredIdentity(parsed);
if (normalized?.kind !== "identity" || !normalized.validForReadOnly) {
return null;
}
return normalized.identity;
} catch {
return null;
}
return withDeviceIdentityCoordinator(options, (_resolved, resolvedOptions) => {
assertNoPendingLegacyIdentity(resolvedOptions);
const stored = readStoredDeviceIdentityReadOnly(resolvedOptions);
return stored ? toDeviceIdentity(stored) : null;
});
}
/** Sign a UTF-8 payload with a PEM Ed25519 private key and return base64url bytes. */
@@ -287,12 +192,11 @@ export function normalizeDevicePublicKeyBase64Url(publicKey: string): string | n
/** Derive the stable device id from PEM or raw base64/base64url public key material. */
export function deriveDeviceIdFromPublicKey(publicKey: string): string | null {
try {
const raw = publicKey.includes("BEGIN")
? deriveEd25519PublicKeyRaw(publicKey)
: base64UrlDecode(publicKey);
if (raw.length === 0) {
const normalized = normalizeEd25519PublicKeyBase64Url(publicKey);
if (!normalized) {
return null;
}
const raw = Buffer.from(normalized, "base64url");
return crypto.createHash("sha256").update(raw).digest("hex");
} catch {
return null;
+82 -5
View File
@@ -1,10 +1,87 @@
import crypto from "node:crypto";
import { describe, expect, it } from "vitest";
import { base64UrlDecode } from "./ed25519-signature.ts";
import {
base64UrlDecode,
decodeCanonicalBase64OrBase64Url,
deriveCanonicalEd25519PrivateKeyRaw,
deriveCanonicalEd25519PublicKeyRaw,
deriveEd25519PrivateKeyRaw,
deriveEd25519PublicKeyRaw,
ed25519PrivateKeyPemFromRaw,
ed25519PublicKeyPemFromRaw,
normalizeEd25519PublicKeyBase64Url,
} from "./ed25519-signature.js";
describe("strict base64 decoding", () => {
it("accepts canonical unpadded base64url", () => {
const raw = Buffer.from([0xfb, 0xff, 0x01]);
expect(base64UrlDecode("-_8B")).toEqual(raw);
});
it("accepts canonical standard base64 through the strict mixed decoder", () => {
const raw = Buffer.from([0xfb, 0xff, 0x01]);
expect(decodeCanonicalBase64OrBase64Url("+/8B")).toEqual(raw);
});
it.each(["", "A", "AB==", "AA=", "AA===", "AA==junk", "-_8B="])(
"rejects noncanonical input %j",
(input) => {
expect(() => decodeCanonicalBase64OrBase64Url(input)).toThrow();
},
);
describe("base64UrlDecode", () => {
it("throws on input exceeding the maximum allowed length", () => {
// MAX_BASE64URL_DECODE_INPUT_LENGTH is 4096; 5000 is safely over it.
const oversized = "A".repeat(5000);
expect(() => base64UrlDecode(oversized)).toThrow(/maximum allowed length/);
expect(() => base64UrlDecode("A".repeat(5000))).toThrow(/maximum allowed length/);
});
});
describe("strict Ed25519 keys", () => {
it("round-trips exact 32-byte raw keys", () => {
const raw = Buffer.alloc(32, 7);
const publicKeyPem = ed25519PublicKeyPemFromRaw(raw);
const privateKeyPem = ed25519PrivateKeyPemFromRaw(raw);
expect(deriveEd25519PublicKeyRaw(publicKeyPem)).toEqual(raw);
expect(deriveEd25519PrivateKeyRaw(privateKeyPem)).toEqual(raw);
});
it.each([31, 33])("rejects %i-byte raw keys", (length) => {
const raw = Buffer.alloc(length);
expect(() => ed25519PublicKeyPemFromRaw(raw)).toThrow(/exactly 32 bytes/);
expect(() => ed25519PrivateKeyPemFromRaw(raw)).toThrow(/exactly 32 bytes/);
});
it("rejects non-Ed25519 key types", () => {
const { publicKey, privateKey } = crypto.generateKeyPairSync("rsa", {
modulusLength: 2048,
});
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
expect(() => deriveEd25519PublicKeyRaw(publicKeyPem)).toThrow(/Ed25519/);
expect(() => deriveEd25519PrivateKeyRaw(privateKeyPem)).toThrow(/Ed25519/);
expect(normalizeEd25519PublicKeyBase64Url(publicKeyPem)).toBeNull();
});
it("rejects alternate PEM formatting even when crypto can parse it", () => {
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
const variants = [
publicKeyPem.trimEnd(),
publicKeyPem.replaceAll("\n", "\r\n"),
publicKeyPem.replace(/\n([A-Za-z0-9+/=]{30})/, "\n$1\n"),
];
for (const pem of variants) {
expect(() => crypto.createPublicKey(pem)).not.toThrow();
expect(() => deriveCanonicalEd25519PublicKeyRaw(pem)).toThrow(/canonical PEM/);
expect(deriveEd25519PublicKeyRaw(pem)).toHaveLength(32);
expect(normalizeEd25519PublicKeyBase64Url(pem)).not.toBeNull();
}
expect(() => deriveCanonicalEd25519PrivateKeyRaw(privateKeyPem.trimEnd())).toThrow(
/canonical PEM/,
);
expect(deriveEd25519PrivateKeyRaw(privateKeyPem.trimEnd())).toHaveLength(32);
});
});
+123 -24
View File
@@ -1,10 +1,11 @@
import crypto from "node:crypto";
const ED25519_RAW_KEY_LENGTH = 32;
const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
const ED25519_PKCS8_PRIVATE_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex");
function base64UrlEncode(buf: Buffer): string {
return buf.toString("base64").replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, "");
return buf.toString("base64url");
}
// Ed25519 public keys and signatures are fixed-size (<= ~86 base64url chars),
@@ -12,6 +13,16 @@ function base64UrlEncode(buf: Buffer): string {
// Bound the decoded buffer to keep a single request from allocating arbitrary memory.
const MAX_BASE64URL_DECODE_INPUT_LENGTH = 4096;
function assertBoundedBase64Input(input: string): void {
if (input.length > MAX_BASE64URL_DECODE_INPUT_LENGTH) {
throw new Error("base64url input exceeds the maximum allowed length");
}
if (input.length === 0) {
throw new Error("base64 input must not be empty");
}
}
/** Decode the existing permissive base64url wire shape. */
export function base64UrlDecode(input: string): Buffer {
if (input.length > MAX_BASE64URL_DECODE_INPUT_LENGTH) {
throw new Error("base64url input exceeds the maximum allowed length");
@@ -21,6 +32,26 @@ export function base64UrlDecode(input: string): Buffer {
return Buffer.from(padded, "base64");
}
/** Decode a canonical standard-base64 or unpadded-base64url value. */
export function decodeCanonicalBase64OrBase64Url(input: string): Buffer {
assertBoundedBase64Input(input);
if (/^[A-Za-z0-9_-]+$/.test(input)) {
const decoded = Buffer.from(input, "base64url");
if (base64UrlEncode(decoded) !== input) {
throw new Error("invalid canonical base64url input");
}
return decoded;
}
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(input) || input.length % 4 !== 0) {
throw new Error("invalid canonical base64 input");
}
const decoded = Buffer.from(input, "base64");
if (decoded.toString("base64") !== input) {
throw new Error("invalid canonical base64 input");
}
return decoded;
}
function pemEncode(label: "PUBLIC KEY" | "PRIVATE KEY", der: Buffer): string {
const body =
der
@@ -30,24 +61,92 @@ function pemEncode(label: "PUBLIC KEY" | "PRIVATE KEY", der: Buffer): string {
return `-----BEGIN ${label}-----\n${body}\n-----END ${label}-----\n`;
}
function decodeCanonicalPem(label: "PUBLIC KEY" | "PRIVATE KEY", pem: string): Buffer {
const header = `-----BEGIN ${label}-----\n`;
const footer = `\n-----END ${label}-----\n`;
if (!pem.startsWith(header) || !pem.endsWith(footer)) {
throw new Error(`${label} must use canonical PEM framing`);
}
const body = pem.slice(header.length, -footer.length);
if (!body || !/^[A-Za-z0-9+/=\n]+$/.test(body)) {
throw new Error(`${label} contains invalid PEM body bytes`);
}
const der = Buffer.from(body.replaceAll("\n", ""), "base64");
if (pemEncode(label, der) !== pem) {
throw new Error(`${label} must use canonical PEM base64 encoding`);
}
return der;
}
function assertRawKeyLength(raw: Buffer, label: string): void {
if (raw.length !== ED25519_RAW_KEY_LENGTH) {
throw new Error(`${label} must contain exactly ${ED25519_RAW_KEY_LENGTH} bytes`);
}
}
export function ed25519PublicKeyPemFromRaw(publicKeyRaw: Buffer): string {
assertRawKeyLength(publicKeyRaw, "Ed25519 public key");
return pemEncode("PUBLIC KEY", Buffer.concat([ED25519_SPKI_PREFIX, publicKeyRaw]));
}
export function ed25519PrivateKeyPemFromRaw(privateKeyRaw: Buffer): string {
assertRawKeyLength(privateKeyRaw, "Ed25519 private key");
return pemEncode("PRIVATE KEY", Buffer.concat([ED25519_PKCS8_PRIVATE_PREFIX, privateKeyRaw]));
}
function assertEd25519KeyType(key: crypto.KeyObject, label: string): void {
if (key.asymmetricKeyType !== "ed25519") {
throw new Error(`${label} must be an Ed25519 key`);
}
}
function deriveRawKeyFromDer(params: { der: Buffer; label: string; prefix: Buffer }): Buffer {
const expectedLength = params.prefix.length + ED25519_RAW_KEY_LENGTH;
if (
params.der.length !== expectedLength ||
!params.der.subarray(0, params.prefix.length).equals(params.prefix)
) {
throw new Error(`${params.label} has a noncanonical Ed25519 encoding`);
}
return params.der.subarray(params.prefix.length);
}
export function deriveCanonicalEd25519PublicKeyRaw(publicKeyPem: string): Buffer {
const spki = decodeCanonicalPem("PUBLIC KEY", publicKeyPem);
const key = crypto.createPublicKey({ key: spki, type: "spki", format: "der" });
assertEd25519KeyType(key, "public key");
return deriveRawKeyFromDer({ der: spki, label: "public key", prefix: ED25519_SPKI_PREFIX });
}
export function deriveCanonicalEd25519PrivateKeyRaw(privateKeyPem: string): Buffer {
const pkcs8 = decodeCanonicalPem("PRIVATE KEY", privateKeyPem);
const key = crypto.createPrivateKey({ key: pkcs8, type: "pkcs8", format: "der" });
assertEd25519KeyType(key, "private key");
return deriveRawKeyFromDer({
der: pkcs8,
label: "private key",
prefix: ED25519_PKCS8_PRIVATE_PREFIX,
});
}
/** Parse any Node-compatible Ed25519 PEM and return its canonical raw public key. */
export function deriveEd25519PublicKeyRaw(publicKeyPem: string): Buffer {
const key = crypto.createPublicKey(publicKeyPem);
const spki = key.export({ type: "spki", format: "der" }) as Buffer;
if (
spki.length === ED25519_SPKI_PREFIX.length + 32 &&
spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)
) {
return spki.subarray(ED25519_SPKI_PREFIX.length);
}
return spki;
assertEd25519KeyType(key, "public key");
const spki = key.export({ type: "spki", format: "der" });
return deriveRawKeyFromDer({ der: spki, label: "public key", prefix: ED25519_SPKI_PREFIX });
}
/** Parse any Node-compatible Ed25519 PEM and return its canonical raw private key. */
export function deriveEd25519PrivateKeyRaw(privateKeyPem: string): Buffer {
const key = crypto.createPrivateKey(privateKeyPem);
assertEd25519KeyType(key, "private key");
const pkcs8 = key.export({ type: "pkcs8", format: "der" });
return deriveRawKeyFromDer({
der: pkcs8,
label: "private key",
prefix: ED25519_PKCS8_PRIVATE_PREFIX,
});
}
export function publicKeyRawBase64UrlFromEd25519Pem(publicKeyPem: string): string {
@@ -56,10 +155,9 @@ export function publicKeyRawBase64UrlFromEd25519Pem(publicKeyPem: string): strin
export function normalizeEd25519PublicKeyBase64Url(publicKey: string): string | null {
try {
if (publicKey.includes("BEGIN")) {
return publicKeyRawBase64UrlFromEd25519Pem(publicKey);
}
const raw = base64UrlDecode(publicKey);
const raw = publicKey.includes("BEGIN")
? deriveEd25519PublicKeyRaw(publicKey)
: base64UrlDecode(publicKey);
if (raw.length === 0) {
return null;
}
@@ -71,18 +169,19 @@ export function normalizeEd25519PublicKeyBase64Url(publicKey: string): string |
export function signEd25519Payload(privateKeyPem: string, payload: string): string {
const key = crypto.createPrivateKey(privateKeyPem);
const sig = crypto.sign(null, Buffer.from(payload, "utf8"), key);
return base64UrlEncode(sig);
const signature = crypto.sign(null, Buffer.from(payload, "utf8"), key);
return base64UrlEncode(signature);
}
function createEd25519PublicKey(publicKey: string): crypto.KeyObject {
return publicKey.includes("BEGIN")
? crypto.createPublicKey(publicKey)
: crypto.createPublicKey({
key: Buffer.concat([ED25519_SPKI_PREFIX, base64UrlDecode(publicKey)]),
type: "spki",
format: "der",
});
if (publicKey.includes("BEGIN")) {
return crypto.createPublicKey(publicKey);
}
return crypto.createPublicKey({
key: Buffer.concat([ED25519_SPKI_PREFIX, base64UrlDecode(publicKey)]),
type: "spki",
format: "der",
});
}
export function verifyEd25519Signature(params: {
@@ -104,8 +203,8 @@ export function verifyEd25519SignatureBytes(params: {
}): boolean {
try {
const key = createEd25519PublicKey(params.publicKey);
const sig = base64UrlDecode(params.signatureBase64Url);
return crypto.verify(null, params.payload, key, sig);
const signature = base64UrlDecode(params.signatureBase64Url);
return crypto.verify(null, params.payload, key, signature);
} catch {
return false;
}
@@ -0,0 +1,95 @@
// Doctor-only detection and replacement for invalid canonical device identity rows.
import fs from "node:fs";
import path from "node:path";
import {
DeviceIdentityStorageError,
generateStoredDeviceIdentity,
readStoredDeviceIdentityReadOnly,
repairInvalidStoredDeviceIdentity,
} from "./device-identity-store.js";
import { formatErrorMessage } from "./errors.js";
import type { LegacyDeviceIdentityDetection } from "./state-migrations.device-identity.types.js";
import type { MigrationMessages } from "./state-migrations.types.js";
const LEGACY_IDENTITY_RELATIVE_PATH = path.join("identity", "device.json");
const DOCTOR_CLAIM_SUFFIX = ".doctor-importing";
const NATIVE_CLAIM_SUFFIX = ".native-importing";
const IDENTITY_KEY = "primary";
function pathMayExist(filePath: string): boolean {
try {
fs.lstatSync(filePath);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code !== "ENOENT";
}
}
/** Detect the exact retired paths and invalid canonical row only with Doctor authority. */
export function detectLegacyDeviceIdentity(params: {
stateDir: string;
env?: NodeJS.ProcessEnv;
doctorOnlyStateMigrations?: boolean;
}): LegacyDeviceIdentityDetection {
const sourcePath = path.join(params.stateDir, LEGACY_IDENTITY_RELATIVE_PATH);
const claimPath = `${sourcePath}${DOCTOR_CLAIM_SUFFIX}`;
const nativeClaimPath = `${sourcePath}${NATIVE_CLAIM_SUFFIX}`;
const doctorAuthorized = params.doctorOnlyStateMigrations === true;
let hasInvalidCanonical = false;
if (doctorAuthorized) {
try {
readStoredDeviceIdentityReadOnly({
env: { ...(params.env ?? process.env), OPENCLAW_STATE_DIR: params.stateDir },
identityKey: IDENTITY_KEY,
});
} catch (error) {
hasInvalidCanonical = error instanceof DeviceIdentityStorageError;
}
}
return {
sourcePath,
claimPath,
nativeClaimPath,
hasLegacy:
doctorAuthorized &&
(pathMayExist(claimPath) || pathMayExist(nativeClaimPath) || pathMayExist(sourcePath)),
hasInvalidCanonical,
};
}
export function hasLegacyDeviceIdentityPath(detected: LegacyDeviceIdentityDetection): boolean {
return (
pathMayExist(detected.claimPath) ||
pathMayExist(detected.nativeClaimPath) ||
pathMayExist(detected.sourcePath)
);
}
/** Generate a replacement only after the caller acquires Doctor's exclusive state lock. */
export function repairInvalidCanonicalIdentity(env: NodeJS.ProcessEnv): MigrationMessages {
try {
const result = repairInvalidStoredDeviceIdentity(generateStoredDeviceIdentity(), {
env,
identityKey: IDENTITY_KEY,
});
if (!result.repaired) {
return { changes: [], warnings: [] };
}
if (!result.rotated) {
return {
changes: ["Repaired invalid primary device identity metadata in SQLite."],
warnings: [],
};
}
return {
changes: ["Replaced invalid primary device identity in SQLite."],
warnings: [],
notices: ["The repaired device has a new identity and must be approved again."],
};
} catch (error) {
return {
changes: [],
warnings: [`Failed repairing invalid SQLite device identity: ${formatErrorMessage(error)}`],
};
}
}
@@ -0,0 +1,805 @@
// Covers fail-closed Doctor import of the retired primary device identity JSON.
import { createHash, generateKeyPairSync } from "node:crypto";
import fs from "node:fs";
import fsp from "node:fs/promises";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
import {
closeOpenClawStateDatabaseForTest,
openOpenClawStateDatabase,
} from "../state/openclaw-state-db.js";
import {
normalizeLegacyDeviceIdentity,
type NormalizedLegacyDeviceIdentity,
} from "./device-identity-legacy.js";
import { deriveDeviceIdFromPublicKey } from "./device-identity.js";
import { acquireGatewayLock } from "./gateway-lock.js";
import {
executeSqliteQuerySync,
executeSqliteQueryTakeFirstSync,
getNodeSqliteKysely,
} from "./kysely-sync.js";
import {
detectLegacyDeviceIdentity,
migrateLegacyDeviceIdentity,
} from "./state-migrations.device-identity.js";
type MigrationDatabase = Pick<
OpenClawStateKyselyDatabase,
"device_auth_tokens" | "device_identities" | "migration_sources"
>;
const CREATED_AT_MS = 1_700_000_000_000;
const SWIFT_RAW_DEVICE_ID = "56475aa75463474c0285df5dbf2bcab73da651358839e9b77481b2eab107708c";
const SWIFT_RAW_PUBLIC_KEY = "A6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=";
const SWIFT_RAW_PRIVATE_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="; // pragma: allowlist secret
describe("legacy device identity Doctor migration", () => {
const tempDirs = useAutoCleanupTempDirTracker((cleanup) => {
afterEach(() => {
closeOpenClawStateDatabaseForTest();
cleanup();
});
});
function useStateDir(): { env: NodeJS.ProcessEnv; stateDir: string } {
const stateDir = tempDirs.make("openclaw-device-identity-migration-");
return {
env: { ...process.env, HOME: stateDir, OPENCLAW_STATE_DIR: stateDir },
stateDir,
};
}
function database(env: NodeJS.ProcessEnv) {
return openOpenClawStateDatabase({ env }).db;
}
function swiftIdentity() {
return {
deviceId: SWIFT_RAW_DEVICE_ID,
publicKey: SWIFT_RAW_PUBLIC_KEY,
privateKey: SWIFT_RAW_PRIVATE_KEY,
createdAtMs: CREATED_AT_MS,
};
}
function normalizedSwift(): NormalizedLegacyDeviceIdentity {
const normalized = normalizeLegacyDeviceIdentity(swiftIdentity());
if (!normalized) {
throw new Error("expected valid Swift identity fixture");
}
return normalized;
}
function nodeIdentity() {
const identity = normalizedSwift();
return {
version: 1,
deviceId: identity.deviceId,
publicKeyPem: identity.publicKeyPem,
privateKeyPem: identity.privateKeyPem,
createdAtMs: identity.createdAtMs,
};
}
function anotherIdentity(): NormalizedLegacyDeviceIdentity {
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
const deviceId = deriveDeviceIdFromPublicKey(publicKeyPem);
if (!deviceId) {
throw new Error("expected generated device id");
}
return { deviceId, publicKeyPem, privateKeyPem, createdAtMs: CREATED_AT_MS + 1 };
}
function rewrapPem(pem: string): string {
const [header, ...rest] = pem.trim().split("\n");
const footer = rest.pop();
if (!header || !footer) {
throw new Error("expected PEM fixture");
}
const lines = rest.join("").match(/.{1,20}/g) ?? [];
return `${header}\n${lines.join("\n")}\n${footer}\n`;
}
async function writeLegacy(params: {
stateDir: string;
value?: unknown;
bytes?: Buffer;
}): Promise<string> {
const sourcePath = path.join(params.stateDir, "identity", "device.json");
await fsp.mkdir(path.dirname(sourcePath), { recursive: true });
await fsp.writeFile(
sourcePath,
params.bytes ?? Buffer.from(`${JSON.stringify(params.value ?? nodeIdentity())}\n`, "utf8"),
);
return sourcePath;
}
function identityRow(env: NodeJS.ProcessEnv) {
const db = database(env);
return executeSqliteQueryTakeFirstSync(
db,
getNodeSqliteKysely<MigrationDatabase>(db)
.selectFrom("device_identities")
.selectAll()
.where("identity_key", "=", "primary"),
);
}
function receipt(env: NodeJS.ProcessEnv) {
const db = database(env);
return executeSqliteQueryTakeFirstSync(
db,
getNodeSqliteKysely<MigrationDatabase>(db)
.selectFrom("migration_sources")
.selectAll()
.where("migration_kind", "=", "legacy-device-identity-json"),
);
}
function seedCanonical(env: NodeJS.ProcessEnv, identity: NormalizedLegacyDeviceIdentity): void {
const db = database(env);
executeSqliteQuerySync(
db,
getNodeSqliteKysely<MigrationDatabase>(db)
.insertInto("device_identities")
.values({
identity_key: "primary",
device_id: identity.deviceId,
public_key_pem: identity.publicKeyPem,
private_key_pem: identity.privateKeyPem,
created_at_ms: identity.createdAtMs,
updated_at_ms: identity.createdAtMs + 10,
}),
);
}
function seedInvalidCanonical(env: NodeJS.ProcessEnv): void {
const db = database(env);
executeSqliteQuerySync(
db,
getNodeSqliteKysely<MigrationDatabase>(db)
.insertInto("device_identities")
.values({
identity_key: "primary",
device_id: "0".repeat(64),
public_key_pem: "invalid-public-key",
private_key_pem: "invalid-private-key",
created_at_ms: 1,
updated_at_ms: 2,
}),
);
}
async function migrate(
stateDir: string,
env: NodeJS.ProcessEnv,
overrides: {
beforeClaim?: (sourcePath: string) => void;
beforeCleanup?: () => void;
removeSource?: (sourcePath: string) => Promise<void> | void;
} = {},
) {
return await migrateLegacyDeviceIdentity({
detected: detectLegacyDeviceIdentity({
stateDir,
env,
doctorOnlyStateMigrations: true,
}),
stateDir,
env,
doctorOnlyStateMigrations: true,
...overrides,
});
}
it("detects exact source and claim paths only with explicit Doctor authority", async () => {
const { stateDir } = useStateDir();
const sourcePath = await writeLegacy({ stateDir });
const disabled = detectLegacyDeviceIdentity({ stateDir });
expect(disabled).toEqual({
sourcePath,
claimPath: `${sourcePath}.doctor-importing`,
nativeClaimPath: `${sourcePath}.native-importing`,
hasLegacy: false,
hasInvalidCanonical: false,
});
expect(
detectLegacyDeviceIdentity({ stateDir, doctorOnlyStateMigrations: true }).hasLegacy,
).toBe(true);
await fsp.rename(sourcePath, `${sourcePath}.doctor-importing`);
expect(
detectLegacyDeviceIdentity({ stateDir, doctorOnlyStateMigrations: true }).hasLegacy,
).toBe(true);
await fsp.rename(`${sourcePath}.doctor-importing`, `${sourcePath}.native-importing`);
expect(
detectLegacyDeviceIdentity({ stateDir, doctorOnlyStateMigrations: true }).hasLegacy,
).toBe(true);
});
it("keeps normal migration read-only and imports only with Doctor authority", async () => {
const { env, stateDir } = useStateDir();
const sourcePath = await writeLegacy({ stateDir });
const skipped = await migrateLegacyDeviceIdentity({
detected: detectLegacyDeviceIdentity({ stateDir }),
env,
stateDir,
});
expect(skipped).toEqual({ changes: [], warnings: [] });
expect(fs.existsSync(sourcePath)).toBe(true);
expect(identityRow(env)).toBeUndefined();
closeOpenClawStateDatabaseForTest();
const repaired = await migrateLegacyDeviceIdentity({
detected: detectLegacyDeviceIdentity({ stateDir, doctorOnlyStateMigrations: true }),
env,
stateDir,
doctorOnlyStateMigrations: true,
});
expect(repaired.changes).toContain("Migrated primary device identity to SQLite.");
expect(fs.existsSync(sourcePath)).toBe(false);
expect(identityRow(env)?.device_id).toBe(SWIFT_RAW_DEVICE_ID);
});
for (const [label, value] of [
["Node PEM", nodeIdentity],
["Swift raw-key", swiftIdentity],
] as const) {
it(`imports a valid ${label} identity and preserves device auth bytes`, async () => {
const { env, stateDir } = useStateDir();
const sourcePath = await writeLegacy({ stateDir, value: value() });
const authPath = path.join(stateDir, "identity", "device-auth.json");
const authBytes = Buffer.from([0x7b, 0x0a, 0xff, 0x00, 0x7d]);
await fsp.writeFile(authPath, authBytes);
const result = await migrate(stateDir, env);
expect(result.warnings).toEqual([]);
expect(result.changes).toEqual(["Migrated primary device identity to SQLite."]);
expect(identityRow(env)).toMatchObject({
identity_key: "primary",
device_id: SWIFT_RAW_DEVICE_ID,
created_at_ms: CREATED_AT_MS,
});
expect(fs.existsSync(sourcePath)).toBe(false);
await expect(fsp.readFile(authPath)).resolves.toEqual(authBytes);
expect(receipt(env)).toMatchObject({
removed_source: 1,
source_record_count: 1,
target_table: "device_identities",
});
});
}
for (const [label, value] of [
["Node PEM identity with an invalid timestamp", { ...nodeIdentity(), createdAtMs: -1 }],
[
"Swift raw-key identity without a timestamp",
(() => {
const legacy = { ...swiftIdentity() } as Record<string, unknown>;
delete legacy.createdAtMs;
return legacy;
})(),
],
] as const) {
it(`imports a valid ${label}`, async () => {
const { env, stateDir } = useStateDir();
const sourcePath = await writeLegacy({ stateDir, value });
const startedAt = Date.now();
const result = await migrate(stateDir, env);
const finishedAt = Date.now();
expect(result.warnings).toEqual([]);
expect(identityRow(env)).toMatchObject({
identity_key: "primary",
device_id: SWIFT_RAW_DEVICE_ID,
});
expect(identityRow(env)?.created_at_ms).toBeGreaterThanOrEqual(startedAt);
expect(identityRow(env)?.created_at_ms).toBeLessThanOrEqual(finishedAt);
expect(fs.existsSync(sourcePath)).toBe(false);
});
}
it("repairs noncanonical PEM formatting before retiring JSON", async () => {
const { env, stateDir } = useStateDir();
const expected = normalizedSwift();
const preservedCreatedAtMs = expected.createdAtMs + 50;
seedCanonical(env, {
...expected,
publicKeyPem: rewrapPem(expected.publicKeyPem),
privateKeyPem: rewrapPem(expected.privateKeyPem),
createdAtMs: preservedCreatedAtMs,
});
const sourcePath = await writeLegacy({ stateDir, value: nodeIdentity() });
const result = await migrate(stateDir, env);
expect(result.warnings).toEqual([]);
expect(result.changes).toEqual(["Migrated primary device identity to SQLite."]);
expect(identityRow(env)).toMatchObject({
device_id: expected.deviceId,
public_key_pem: expected.publicKeyPem,
private_key_pem: expected.privateKeyPem,
created_at_ms: expected.createdAtMs,
});
expect(fs.existsSync(sourcePath)).toBe(false);
});
it("repairs an invalid canonical row from a validated legacy identity", async () => {
const { env, stateDir } = useStateDir();
const expected = normalizedSwift();
seedInvalidCanonical(env);
const sourcePath = await writeLegacy({ stateDir, value: nodeIdentity() });
const result = await migrate(stateDir, env);
expect(result.warnings).toEqual([]);
expect(result.changes).toEqual(["Migrated primary device identity to SQLite."]);
expect(identityRow(env)).toMatchObject({
device_id: expected.deviceId,
public_key_pem: expected.publicKeyPem,
private_key_pem: expected.privateKeyPem,
created_at_ms: expected.createdAtMs,
});
expect(fs.existsSync(sourcePath)).toBe(false);
expect(JSON.parse(receipt(env)?.report_json ?? "null")).toMatchObject({
repairedSqliteRecordCount: 1,
});
});
it("replaces an invalid canonical row without legacy JSON only under Doctor authority", async () => {
const { env, stateDir } = useStateDir();
seedInvalidCanonical(env);
expect(detectLegacyDeviceIdentity({ stateDir, env }).hasInvalidCanonical).toBe(false);
const detected = detectLegacyDeviceIdentity({
stateDir,
env,
doctorOnlyStateMigrations: true,
});
expect(detected).toMatchObject({ hasLegacy: false, hasInvalidCanonical: true });
const skipped = await migrateLegacyDeviceIdentity({ detected, env, stateDir });
expect(skipped).toEqual({ changes: [], warnings: [] });
expect(identityRow(env)?.device_id).toBe("0".repeat(64));
const result = await migrateLegacyDeviceIdentity({
detected,
env,
stateDir,
doctorOnlyStateMigrations: true,
});
expect(result.warnings).toEqual([]);
expect(result.changes).toEqual(["Replaced invalid primary device identity in SQLite."]);
expect(result.notices).toEqual([
"The repaired device has a new identity and must be approved again.",
]);
expect(identityRow(env)).toMatchObject({
identity_key: "primary",
device_id: expect.stringMatching(/^[a-f0-9]{64}$/),
});
expect(
detectLegacyDeviceIdentity({
stateDir,
env,
doctorOnlyStateMigrations: true,
}).hasInvalidCanonical,
).toBe(false);
});
it("repairs canonical identity metadata without rotating valid key material", async () => {
const { env, stateDir } = useStateDir();
const expected = normalizedSwift();
seedCanonical(env, expected);
const db = database(env);
executeSqliteQuerySync(
db,
getNodeSqliteKysely<MigrationDatabase>(db)
.updateTable("device_identities")
.set({
device_id: "0".repeat(64),
public_key_pem: rewrapPem(expected.publicKeyPem),
private_key_pem: rewrapPem(expected.privateKeyPem),
created_at_ms: -1,
updated_at_ms: -1,
})
.where("identity_key", "=", "primary"),
);
const detected = detectLegacyDeviceIdentity({
stateDir,
env,
doctorOnlyStateMigrations: true,
});
const result = await migrateLegacyDeviceIdentity({
detected,
env,
stateDir,
doctorOnlyStateMigrations: true,
});
expect(result.warnings).toEqual([]);
expect(result.changes).toEqual([
"Repaired invalid primary device identity metadata in SQLite.",
]);
expect(result.notices ?? []).toEqual([]);
expect(identityRow(env)).toMatchObject({
device_id: expected.deviceId,
public_key_pem: expected.publicKeyPem,
private_key_pem: expected.privateKeyPem,
created_at_ms: expect.any(Number),
});
});
it("prefers legacy key material that appears after invalid-row detection", async () => {
const { env, stateDir } = useStateDir();
seedInvalidCanonical(env);
const detected = detectLegacyDeviceIdentity({
stateDir,
env,
doctorOnlyStateMigrations: true,
});
expect(detected).toMatchObject({ hasLegacy: false, hasInvalidCanonical: true });
const sourcePath = await writeLegacy({ stateDir, value: nodeIdentity() });
const result = await migrateLegacyDeviceIdentity({
detected,
env,
stateDir,
doctorOnlyStateMigrations: true,
});
expect(result.warnings).toEqual([]);
expect(result.changes).toEqual(["Migrated primary device identity to SQLite."]);
expect(identityRow(env)?.device_id).toBe(SWIFT_RAW_DEVICE_ID);
expect(fs.existsSync(sourcePath)).toBe(false);
});
it("reports a generated identity when the invalid row disappears before repair", async () => {
const { env, stateDir } = useStateDir();
seedInvalidCanonical(env);
const detected = detectLegacyDeviceIdentity({
stateDir,
env,
doctorOnlyStateMigrations: true,
});
const db = database(env);
executeSqliteQuerySync(
db,
getNodeSqliteKysely<MigrationDatabase>(db)
.deleteFrom("device_identities")
.where("identity_key", "=", "primary"),
);
const result = await migrateLegacyDeviceIdentity({
detected,
env,
stateDir,
doctorOnlyStateMigrations: true,
});
expect(result.changes).toEqual(["Replaced invalid primary device identity in SQLite."]);
expect(result.notices).toEqual([
"The repaired device has a new identity and must be approved again.",
]);
expect(identityRow(env)?.device_id).toMatch(/^[a-f0-9]{64}$/);
});
it("requires mutation-time Doctor authority after canonical state becomes invalid", async () => {
const { env, stateDir } = useStateDir();
seedCanonical(env, normalizedSwift());
const sourcePath = await writeLegacy({ stateDir, value: nodeIdentity() });
const detected = detectLegacyDeviceIdentity({
stateDir,
env,
doctorOnlyStateMigrations: true,
});
expect(detected).toMatchObject({ hasLegacy: true, hasInvalidCanonical: false });
const db = database(env);
executeSqliteQuerySync(
db,
getNodeSqliteKysely<MigrationDatabase>(db)
.updateTable("device_identities")
.set({ device_id: "0".repeat(64) })
.where("identity_key", "=", "primary"),
);
const result = await migrateLegacyDeviceIdentity({ detected, env, stateDir });
expect(result).toEqual({ changes: [], warnings: [] });
expect(identityRow(env)?.device_id).toBe("0".repeat(64));
expect(fs.existsSync(sourcePath)).toBe(true);
});
it("does not generate an identity from a stale legacy-only detection", async () => {
const { env, stateDir } = useStateDir();
const sourcePath = await writeLegacy({ stateDir, value: nodeIdentity() });
const detected = detectLegacyDeviceIdentity({
stateDir,
env,
doctorOnlyStateMigrations: true,
});
expect(detected).toMatchObject({ hasLegacy: true, hasInvalidCanonical: false });
await fsp.unlink(sourcePath);
const result = await migrateLegacyDeviceIdentity({
detected,
env,
stateDir,
doctorOnlyStateMigrations: true,
});
expect(result).toEqual({ changes: [], warnings: [] });
expect(identityRow(env)).toBeUndefined();
});
it("repairs an invalid canonical update timestamp before retiring JSON", async () => {
const { env, stateDir } = useStateDir();
const expected = normalizedSwift();
seedCanonical(env, expected);
const db = database(env);
executeSqliteQuerySync(
db,
getNodeSqliteKysely<MigrationDatabase>(db)
.updateTable("device_identities")
.set({ updated_at_ms: -1 })
.where("identity_key", "=", "primary"),
);
const sourcePath = await writeLegacy({ stateDir, value: nodeIdentity() });
const result = await migrate(stateDir, env);
expect(result.warnings).toEqual([]);
expect(identityRow(env)).toMatchObject({
device_id: expected.deviceId,
public_key_pem: expected.publicKeyPem,
private_key_pem: expected.privateKeyPem,
created_at_ms: expected.createdAtMs,
});
expect(identityRow(env)?.updated_at_ms).toBeGreaterThanOrEqual(expected.createdAtMs);
expect(fs.existsSync(sourcePath)).toBe(false);
expect(JSON.parse(receipt(env)?.report_json ?? "null")).toMatchObject({
repairedSqliteRecordCount: 1,
});
});
it("blocks a different canonical identity and restores the source", async () => {
const { env, stateDir } = useStateDir();
const winner = anotherIdentity();
seedCanonical(env, winner);
const sourcePath = await writeLegacy({ stateDir });
const before = await fsp.readFile(sourcePath);
const result = await migrate(stateDir, env);
expect(result.warnings.join("\n")).toContain("canonical SQLite device identity differs");
expect(identityRow(env)?.device_id).toBe(winner.deviceId);
await expect(fsp.readFile(sourcePath)).resolves.toEqual(before);
expect(fs.existsSync(`${sourcePath}.doctor-importing`)).toBe(false);
expect(receipt(env)).toBeUndefined();
});
it("restores a source changed before Doctor can claim it", async () => {
const { env, stateDir } = useStateDir();
const sourcePath = await writeLegacy({ stateDir });
const result = await migrate(stateDir, env, {
beforeClaim: (candidate) => fs.appendFileSync(candidate, " "),
});
expect(result.warnings.join("\n")).toContain("changed before Doctor could claim it");
expect(fs.existsSync(sourcePath)).toBe(true);
expect(fs.existsSync(`${sourcePath}.doctor-importing`)).toBe(false);
expect(identityRow(env)).toBeUndefined();
expect(receipt(env)).toBeUndefined();
});
it("imports an interrupted claim", async () => {
const { env, stateDir } = useStateDir();
const sourcePath = await writeLegacy({ stateDir });
const claimPath = `${sourcePath}.doctor-importing`;
await fsp.rename(sourcePath, claimPath);
const result = await migrate(stateDir, env);
expect(result.warnings).toEqual([]);
expect(identityRow(env)?.device_id).toBe(SWIFT_RAW_DEVICE_ID);
expect(fs.existsSync(sourcePath)).toBe(false);
expect(fs.existsSync(claimPath)).toBe(false);
});
it("preserves an interrupted native claim for native startup", async () => {
const { env, stateDir } = useStateDir();
const sourcePath = await writeLegacy({ stateDir });
const nativeClaimPath = `${sourcePath}.native-importing`;
await fsp.rename(sourcePath, nativeClaimPath);
const result = await migrate(stateDir, env);
expect(result.warnings.join("\n")).toContain("Native device identity import is pending");
expect(fs.existsSync(sourcePath)).toBe(false);
expect(fs.existsSync(nativeClaimPath)).toBe(true);
expect(identityRow(env)).toBeUndefined();
expect(receipt(env)).toBeUndefined();
});
it("refuses source and interrupted claim together", async () => {
const { env, stateDir } = useStateDir();
const sourcePath = await writeLegacy({ stateDir });
await fsp.copyFile(sourcePath, `${sourcePath}.doctor-importing`);
const result = await migrate(stateDir, env);
expect(result.warnings.join("\n")).toContain("source and interrupted claim both exist");
expect(identityRow(env)).toBeUndefined();
expect(receipt(env)).toBeUndefined();
});
it("rechecks the canonical row before deleting the claimed source", async () => {
const { env, stateDir } = useStateDir();
const sourcePath = await writeLegacy({ stateDir });
const replacement = anotherIdentity();
const result = await migrate(stateDir, env, {
beforeCleanup: () => {
const db = database(env);
executeSqliteQuerySync(
db,
getNodeSqliteKysely<MigrationDatabase>(db)
.updateTable("device_identities")
.set({
device_id: replacement.deviceId,
public_key_pem: replacement.publicKeyPem,
private_key_pem: replacement.privateKeyPem,
created_at_ms: replacement.createdAtMs,
updated_at_ms: replacement.createdAtMs,
})
.where("identity_key", "=", "primary"),
);
},
});
expect(result.warnings.join("\n")).toContain("legacy cleanup failed");
expect(fs.existsSync(`${sourcePath}.doctor-importing`)).toBe(true);
expect(identityRow(env)?.device_id).toBe(replacement.deviceId);
expect(receipt(env)).toMatchObject({ removed_source: 0 });
});
it("resumes an interrupted claim and cleanup receipt", async () => {
const { env, stateDir } = useStateDir();
const sourcePath = await writeLegacy({ stateDir });
const first = await migrate(stateDir, env, {
removeSource: () => {
throw new Error("simulated unlink failure");
},
});
expect(first.warnings.join("\n")).toContain("legacy cleanup failed");
expect(fs.existsSync(`${sourcePath}.doctor-importing`)).toBe(true);
expect(receipt(env)).toMatchObject({ removed_source: 0 });
closeOpenClawStateDatabaseForTest();
const retry = await migrate(stateDir, env);
expect(retry.warnings).toEqual([]);
expect(retry.changes).toEqual([
"Removed retired device identity JSON covered by its SQLite receipt.",
]);
expect(fs.existsSync(`${sourcePath}.doctor-importing`)).toBe(false);
expect(receipt(env)).toMatchObject({ removed_source: 1 });
});
it("does not discard recreated bytes that differ from the receipt", async () => {
const { env, stateDir } = useStateDir();
const sourcePath = await writeLegacy({ stateDir });
await migrate(stateDir, env, {
removeSource: () => {
throw new Error("simulated unlink failure");
},
});
const replacement = `${JSON.stringify({ ...nodeIdentity(), createdAtMs: CREATED_AT_MS + 1 })}\n`;
await fsp.writeFile(sourcePath, replacement, "utf8");
closeOpenClawStateDatabaseForTest();
const retry = await migrate(stateDir, env);
expect(retry.warnings.join("\n")).toContain("bytes differ from the migration receipt");
await expect(fsp.readFile(sourcePath, "utf8")).resolves.toBe(replacement);
expect(identityRow(env)?.created_at_ms).toBe(CREATED_AT_MS);
});
it("rejects symlinked, hardlinked, oversized, non-UTF-8, and invalid sources", async () => {
const cases: Array<{ env: NodeJS.ProcessEnv; sourcePath: string; stateDir: string }> = [];
const symlink = useStateDir();
const symlinkTarget = path.join(symlink.stateDir, "outside.json");
await fsp.writeFile(symlinkTarget, JSON.stringify(nodeIdentity()), "utf8");
const symlinkPath = path.join(symlink.stateDir, "identity", "device.json");
await fsp.mkdir(path.dirname(symlinkPath), { recursive: true });
await fsp.symlink(symlinkTarget, symlinkPath);
cases.push({ ...symlink, sourcePath: symlinkPath });
const hardlink = useStateDir();
const hardlinkTarget = path.join(hardlink.stateDir, "outside.json");
await fsp.writeFile(hardlinkTarget, JSON.stringify(nodeIdentity()), "utf8");
const hardlinkPath = path.join(hardlink.stateDir, "identity", "device.json");
await fsp.mkdir(path.dirname(hardlinkPath), { recursive: true });
await fsp.link(hardlinkTarget, hardlinkPath);
cases.push({ ...hardlink, sourcePath: hardlinkPath });
const oversized = useStateDir();
const oversizedPath = await writeLegacy({
stateDir: oversized.stateDir,
bytes: Buffer.alloc(128 * 1024 + 1, 0x20),
});
cases.push({ ...oversized, sourcePath: oversizedPath });
const nonUtf8 = useStateDir();
const nonUtf8Path = await writeLegacy({
stateDir: nonUtf8.stateDir,
bytes: Buffer.from([0xff, 0xfe]),
});
cases.push({ ...nonUtf8, sourcePath: nonUtf8Path });
const invalid = useStateDir();
const invalidPath = await writeLegacy({
stateDir: invalid.stateDir,
value: { version: 1, deviceId: "broken" },
});
cases.push({ ...invalid, sourcePath: invalidPath });
for (const testCase of cases) {
closeOpenClawStateDatabaseForTest();
const result = await migrate(testCase.stateDir, testCase.env);
expect(result.warnings.join("\n")).toContain("Failed reading legacy device identity");
expect(fs.existsSync(testCase.sourcePath)).toBe(true);
expect(identityRow(testCase.env)).toBeUndefined();
expect(receipt(testCase.env)).toBeUndefined();
}
});
it("requires exclusive state ownership", async () => {
const { env, stateDir } = useStateDir();
const sourcePath = await writeLegacy({ stateDir });
const gatewayLock = await acquireGatewayLock({
allowInTests: true,
env,
pollIntervalMs: 10,
port: 18_790,
timeoutMs: 100,
});
if (!gatewayLock) {
throw new Error("expected test Gateway lock");
}
let result: Awaited<ReturnType<typeof migrateLegacyDeviceIdentity>>;
try {
result = await migrate(stateDir, env);
} finally {
await gatewayLock.release();
}
expect(result.warnings.join("\n")).toContain("Gateway or another SQLite maintenance command");
expect(fs.existsSync(sourcePath)).toBe(true);
});
it("records the digest of the exact imported bytes", async () => {
const { env, stateDir } = useStateDir();
const bytes = Buffer.from(`${JSON.stringify(nodeIdentity())}\n`, "utf8");
await writeLegacy({ stateDir, bytes });
await migrate(stateDir, env);
expect(receipt(env)).toMatchObject({
source_sha256: createHash("sha256").update(bytes).digest("hex"),
});
});
});
@@ -0,0 +1,697 @@
// Doctor-only import for the retired primary device identity JSON.
import { createHash } from "node:crypto";
import path from "node:path";
import { root, type Root } from "@openclaw/fs-safe";
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
import {
openOpenClawStateDatabase,
runOpenClawStateWriteTransaction,
} from "../state/openclaw-state-db.js";
import { acquireDeviceIdentityCoordinator } from "./device-identity-coordinator.js";
import {
normalizeLegacyDeviceIdentity,
type NormalizedLegacyDeviceIdentity,
} from "./device-identity-legacy.js";
import {
resolveDeviceIdentityStore,
validateStoredDeviceIdentity,
type DeviceIdentity,
} from "./device-identity-store.js";
import { deriveEd25519PrivateKeyRaw, deriveEd25519PublicKeyRaw } from "./ed25519-signature.js";
import { formatErrorMessage } from "./errors.js";
import { acquireGatewayLock, GatewayLockError } from "./gateway-lock.js";
import {
executeSqliteQuerySync,
executeSqliteQueryTakeFirstSync,
getNodeSqliteKysely,
} from "./kysely-sync.js";
import {
hasLegacyDeviceIdentityPath,
repairInvalidCanonicalIdentity,
} from "./state-migrations.device-identity-repair.js";
import type { LegacyDeviceIdentityDetection } from "./state-migrations.device-identity.types.js";
import type { MigrationMessages } from "./state-migrations.types.js";
const IDENTITY_KEY = "primary";
const MIGRATION_KIND = "legacy-device-identity-json";
const MIGRATION_LOCK_TIMEOUT_MS = 250;
const MIGRATION_LOCK_POLL_INTERVAL_MS = 25;
const MAX_LEGACY_IDENTITY_BYTES = 128 * 1024;
const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
function isValidCreatedAtMs(value: unknown): value is number {
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
}
function deviceIdentityKeyMaterialMatches(left: DeviceIdentity, right: DeviceIdentity): boolean {
try {
return (
deriveEd25519PublicKeyRaw(left.publicKeyPem).equals(
deriveEd25519PublicKeyRaw(right.publicKeyPem),
) &&
deriveEd25519PrivateKeyRaw(left.privateKeyPem).equals(
deriveEd25519PrivateKeyRaw(right.privateKeyPem),
)
);
} catch {
return false;
}
}
type DeviceIdentityMigrationDatabase = Pick<
OpenClawStateKyselyDatabase,
"device_identities" | "migration_runs" | "migration_sources"
>;
type LegacySourceSnapshot = {
sourcePath: string;
dev: number;
ino: number;
mtimeMs: number;
sha256: string;
size: number;
identity: NormalizedLegacyDeviceIdentity;
};
type MigrationReceipt = {
sourceKey: string;
sourceSha256: string | null;
removedSource: boolean;
};
export { detectLegacyDeviceIdentity } from "./state-migrations.device-identity-repair.js";
function relativeLegacyPath(stateDir: string, filePath: string): string {
const relativePath = path.relative(path.resolve(stateDir), path.resolve(filePath));
if (
!relativePath ||
relativePath === ".." ||
relativePath.startsWith(`..${path.sep}`) ||
path.isAbsolute(relativePath)
) {
throw new Error("legacy device identity path is outside the state directory");
}
return relativePath;
}
async function readLegacySourceSnapshot(params: {
stateRoot: Root;
stateDir: string;
sourcePath: string;
}): Promise<LegacySourceSnapshot> {
const opened = await params.stateRoot.read(
relativeLegacyPath(params.stateDir, params.sourcePath),
{
hardlinks: "reject",
maxBytes: MAX_LEGACY_IDENTITY_BYTES,
symlinks: "reject",
},
);
if (opened.stat.size !== opened.buffer.byteLength) {
throw new Error("legacy device identity changed while it was being read");
}
const identity = normalizeLegacyDeviceIdentity(JSON.parse(utf8Decoder.decode(opened.buffer)));
if (!identity) {
throw new Error("legacy device identity is invalid or unsupported");
}
return {
sourcePath: params.sourcePath,
dev: opened.stat.dev,
ino: opened.stat.ino,
mtimeMs: opened.stat.mtimeMs,
sha256: createHash("sha256").update(opened.buffer).digest("hex"),
size: opened.stat.size,
identity,
};
}
function snapshotsMatch(left: LegacySourceSnapshot, right: LegacySourceSnapshot): boolean {
return (
left.dev === right.dev &&
left.ino === right.ino &&
left.mtimeMs === right.mtimeMs &&
left.sha256 === right.sha256 &&
left.size === right.size
);
}
function receiptSourceKey(sourcePath: string): string {
return `device-identity-json:${createHash("sha256").update(path.resolve(sourcePath)).digest("hex")}`;
}
function readMigrationReceipt(sourcePath: string, env: NodeJS.ProcessEnv): MigrationReceipt | null {
const sourceKey = receiptSourceKey(sourcePath);
const { db } = openOpenClawStateDatabase({ env });
const row = executeSqliteQueryTakeFirstSync(
db,
getNodeSqliteKysely<DeviceIdentityMigrationDatabase>(db)
.selectFrom("migration_sources")
.select(["removed_source", "source_sha256"])
.where("source_key", "=", sourceKey),
);
return row
? {
sourceKey,
sourceSha256: row.source_sha256,
removedSource: row.removed_source === 1,
}
: null;
}
type CanonicalIdentityRow = {
identity_key: string;
device_id: string;
public_key_pem: string;
private_key_pem: string;
created_at_ms: number;
updated_at_ms: number;
};
function classifyCanonicalRow(
row: CanonicalIdentityRow,
identity: NormalizedLegacyDeviceIdentity,
): "same" | "different" | "invalid" {
if (!isValidCreatedAtMs(row.updated_at_ms)) {
return "invalid";
}
try {
validateStoredDeviceIdentity(
{
deviceId: row.device_id,
publicKeyPem: row.public_key_pem,
privateKeyPem: row.private_key_pem,
createdAtMs: row.created_at_ms,
},
row.identity_key,
);
} catch {
return "invalid";
}
// Valid identities are equal by key fingerprint. PEM text and timestamps are
// serialization metadata, not a reason to rotate an already-canonical key.
return row.identity_key === IDENTITY_KEY &&
row.device_id === identity.deviceId &&
deviceIdentityKeyMaterialMatches(
{
deviceId: row.device_id,
publicKeyPem: row.public_key_pem,
privateKeyPem: row.private_key_pem,
},
identity,
)
? "same"
: "different";
}
function readCanonicalIdentity(
db: ReturnType<typeof openOpenClawStateDatabase>["db"],
): CanonicalIdentityRow | undefined {
return executeSqliteQueryTakeFirstSync(
db,
getNodeSqliteKysely<DeviceIdentityMigrationDatabase>(db)
.selectFrom("device_identities")
.selectAll()
.where("identity_key", "=", IDENTITY_KEY),
);
}
function verifyCanonicalIdentity(
identity: NormalizedLegacyDeviceIdentity,
env: NodeJS.ProcessEnv,
): void {
const { db } = openOpenClawStateDatabase({ env });
const row = readCanonicalIdentity(db);
if (!row || classifyCanonicalRow(row, identity) !== "same") {
throw new Error("canonical SQLite device identity no longer matches the legacy source");
}
}
function importAndRecordReceipt(params: {
env: NodeJS.ProcessEnv;
sourcePath: string;
snapshot: LegacySourceSnapshot;
}): { sourceKey: string; imported: boolean } {
const sourceKey = receiptSourceKey(params.sourcePath);
const runId = `${sourceKey}:${params.snapshot.sha256.slice(0, 16)}`;
const now = Date.now();
return runOpenClawStateWriteTransaction(
({ db }) => {
const stateDb = getNodeSqliteKysely<DeviceIdentityMigrationDatabase>(db);
const existingReceipt = executeSqliteQueryTakeFirstSync(
db,
stateDb
.selectFrom("migration_sources")
.select("source_sha256")
.where("source_key", "=", sourceKey),
);
if (existingReceipt) {
if (existingReceipt.source_sha256 !== params.snapshot.sha256) {
throw new Error("migration receipt belongs to different device identity bytes");
}
const existing = readCanonicalIdentity(db);
if (!existing || classifyCanonicalRow(existing, params.snapshot.identity) !== "same") {
throw new Error("migration receipt does not match the canonical device identity");
}
return { sourceKey, imported: false };
}
const existing = readCanonicalIdentity(db);
const existingState = existing
? classifyCanonicalRow(existing, params.snapshot.identity)
: undefined;
if (existingState === "different") {
throw new Error("canonical SQLite device identity differs from the legacy identity");
}
const imported = !existing || existingState === "invalid";
const repaired = existingState === "invalid";
if (!existing) {
executeSqliteQuerySync(
db,
stateDb.insertInto("device_identities").values({
identity_key: IDENTITY_KEY,
device_id: params.snapshot.identity.deviceId,
public_key_pem: params.snapshot.identity.publicKeyPem,
private_key_pem: params.snapshot.identity.privateKeyPem,
created_at_ms: params.snapshot.identity.createdAtMs,
updated_at_ms: now,
}),
);
} else if (repaired) {
executeSqliteQuerySync(
db,
stateDb
.updateTable("device_identities")
.set({
device_id: params.snapshot.identity.deviceId,
public_key_pem: params.snapshot.identity.publicKeyPem,
private_key_pem: params.snapshot.identity.privateKeyPem,
created_at_ms: params.snapshot.identity.createdAtMs,
updated_at_ms: now,
})
.where("identity_key", "=", IDENTITY_KEY),
);
}
const verified = readCanonicalIdentity(db);
if (!verified || classifyCanonicalRow(verified, params.snapshot.identity) !== "same") {
throw new Error("SQLite verification failed for the primary device identity");
}
const reportJson = JSON.stringify({
source: MIGRATION_KIND,
target: "device_identities",
identityKey: IDENTITY_KEY,
deviceId: params.snapshot.identity.deviceId,
sourceSha256: params.snapshot.sha256,
importedRecordCount: imported ? 1 : 0,
preservedSqliteRecordCount: existing ? 1 : 0,
repairedSqliteRecordCount: repaired ? 1 : 0,
});
executeSqliteQuerySync(
db,
stateDb.insertInto("migration_runs").values({
id: runId,
started_at: now,
finished_at: now,
status: "completed",
report_json: reportJson,
}),
);
executeSqliteQuerySync(
db,
stateDb.insertInto("migration_sources").values({
source_key: sourceKey,
migration_kind: MIGRATION_KIND,
source_path: params.sourcePath,
target_table: "device_identities",
source_sha256: params.snapshot.sha256,
source_size_bytes: params.snapshot.size,
source_record_count: 1,
last_run_id: runId,
status: "completed",
imported_at: now,
removed_source: 0,
report_json: reportJson,
}),
);
return { sourceKey, imported };
},
{ env: params.env },
);
}
function markSourceRemoved(sourceKey: string, env: NodeJS.ProcessEnv): void {
runOpenClawStateWriteTransaction(
({ db }) => {
executeSqliteQuerySync(
db,
getNodeSqliteKysely<DeviceIdentityMigrationDatabase>(db)
.updateTable("migration_sources")
.set({ removed_source: 1 })
.where("source_key", "=", sourceKey),
);
},
{ env },
);
}
async function removePath(params: {
stateRoot: Root;
stateDir: string;
sourcePath: string;
removeSource?: (sourcePath: string) => Promise<void> | void;
}): Promise<void> {
if (params.removeSource) {
await params.removeSource(params.sourcePath);
return;
}
await params.stateRoot.remove(relativeLegacyPath(params.stateDir, params.sourcePath));
}
async function restoreClaim(params: {
stateRoot: Root;
stateDir: string;
sourcePath: string;
claimPath: string;
}): Promise<string | null> {
try {
if (!(await params.stateRoot.exists(relativeLegacyPath(params.stateDir, params.claimPath)))) {
return null;
}
if (await params.stateRoot.exists(relativeLegacyPath(params.stateDir, params.sourcePath))) {
return `source path already exists: ${params.sourcePath}`;
}
await params.stateRoot.move(
relativeLegacyPath(params.stateDir, params.claimPath),
relativeLegacyPath(params.stateDir, params.sourcePath),
);
return null;
} catch (error) {
return String(error);
}
}
async function cleanupReceiptSources(params: {
stateRoot: Root;
stateDir: string;
detected: LegacyDeviceIdentityDetection;
receipt: MigrationReceipt;
env: NodeJS.ProcessEnv;
removeSource?: (sourcePath: string) => Promise<void> | void;
}): Promise<MigrationMessages> {
if (
await params.stateRoot.exists(
relativeLegacyPath(params.stateDir, params.detected.nativeClaimPath),
)
) {
return {
changes: [],
warnings: [
"Native device identity import is pending; restart the native app before running Doctor cleanup.",
],
};
}
const changes: string[] = [];
const warnings: string[] = [];
let removed = 0;
for (const candidate of [params.detected.sourcePath, params.detected.claimPath]) {
if (!(await params.stateRoot.exists(relativeLegacyPath(params.stateDir, candidate)))) {
continue;
}
let snapshot: LegacySourceSnapshot;
try {
snapshot = await readLegacySourceSnapshot({
stateRoot: params.stateRoot,
stateDir: params.stateDir,
sourcePath: candidate,
});
} catch (error) {
warnings.push(`Retired device identity cleanup refused ${candidate}: ${String(error)}`);
continue;
}
if (snapshot.sha256 !== params.receipt.sourceSha256) {
warnings.push(
`Retired device identity cleanup preserved ${candidate}: bytes differ from the migration receipt.`,
);
continue;
}
try {
verifyCanonicalIdentity(snapshot.identity, params.env);
await removePath({ ...params, sourcePath: candidate });
removed += 1;
} catch (error) {
warnings.push(`Retired device identity cleanup failed for ${candidate}: ${String(error)}`);
}
}
if (warnings.length === 0 && (!params.receipt.removedSource || removed > 0)) {
markSourceRemoved(params.receipt.sourceKey, params.env);
}
if (removed > 0) {
changes.push("Removed retired device identity JSON covered by its SQLite receipt.");
}
return { changes, warnings };
}
async function migrateWithExclusiveStateOwnership(params: {
detected: LegacyDeviceIdentityDetection;
stateRoot: Root;
stateDir: string;
env: NodeJS.ProcessEnv;
beforeClaim?: (sourcePath: string) => void;
beforeCleanup?: () => void;
removeSource?: (sourcePath: string) => Promise<void> | void;
}): Promise<MigrationMessages> {
const receipt = readMigrationReceipt(params.detected.sourcePath, params.env);
if (receipt) {
return await cleanupReceiptSources({ ...params, receipt });
}
if (
await params.stateRoot.exists(
relativeLegacyPath(params.stateDir, params.detected.nativeClaimPath),
)
) {
return {
changes: [],
warnings: [
"Native device identity import is pending; restart the native app before running Doctor.",
],
};
}
const hasSource = await params.stateRoot.exists(
relativeLegacyPath(params.stateDir, params.detected.sourcePath),
);
const hasClaim = await params.stateRoot.exists(
relativeLegacyPath(params.stateDir, params.detected.claimPath),
);
if (hasSource && hasClaim) {
return {
changes: [],
warnings: [
"Failed migrating legacy device identity: source and interrupted claim both exist.",
],
};
}
const activePath = hasSource
? params.detected.sourcePath
: hasClaim
? params.detected.claimPath
: null;
if (!activePath) {
return { changes: [], warnings: [] };
}
let snapshot: LegacySourceSnapshot;
try {
snapshot = await readLegacySourceSnapshot({
stateRoot: params.stateRoot,
stateDir: params.stateDir,
sourcePath: activePath,
});
} catch (error) {
return {
changes: [],
warnings: [`Failed reading legacy device identity: ${String(error)}`],
};
}
if (activePath === params.detected.sourcePath) {
try {
params.beforeClaim?.(params.detected.sourcePath);
await params.stateRoot.move(
relativeLegacyPath(params.stateDir, params.detected.sourcePath),
relativeLegacyPath(params.stateDir, params.detected.claimPath),
);
const claimed = await readLegacySourceSnapshot({
stateRoot: params.stateRoot,
stateDir: params.stateDir,
sourcePath: params.detected.claimPath,
});
if (!snapshotsMatch(snapshot, claimed)) {
throw new Error("legacy device identity changed before Doctor could claim it");
}
snapshot = claimed;
} catch (error) {
const restoreError = await restoreClaim({ ...params, ...params.detected });
return {
changes: [],
warnings: [
`Failed migrating legacy device identity: ${String(error)}${restoreError ? `; restore failure: ${restoreError}` : ""}`,
],
};
}
}
let result: ReturnType<typeof importAndRecordReceipt>;
try {
result = importAndRecordReceipt({
env: params.env,
sourcePath: params.detected.sourcePath,
snapshot,
});
} catch (error) {
const restoreError = await restoreClaim({ ...params, ...params.detected });
return {
changes: [],
warnings: [
`Failed migrating legacy device identity: ${String(error)}${restoreError ? `; restore failure: ${restoreError}` : ""}`,
],
};
}
try {
params.beforeCleanup?.();
if (
await params.stateRoot.exists(relativeLegacyPath(params.stateDir, params.detected.sourcePath))
) {
throw new Error("legacy device identity source reappeared during import");
}
const finalSnapshot = await readLegacySourceSnapshot({
stateRoot: params.stateRoot,
stateDir: params.stateDir,
sourcePath: params.detected.claimPath,
});
if (!snapshotsMatch(snapshot, finalSnapshot)) {
throw new Error("legacy device identity claim changed after SQLite import");
}
verifyCanonicalIdentity(finalSnapshot.identity, params.env);
await removePath({ ...params, sourcePath: params.detected.claimPath });
if (
await params.stateRoot.exists(relativeLegacyPath(params.stateDir, params.detected.claimPath))
) {
throw new Error("legacy device identity Doctor claim remains after cleanup");
}
markSourceRemoved(result.sourceKey, params.env);
} catch (error) {
return {
changes: [],
warnings: [`Device identity is in SQLite, but legacy cleanup failed: ${String(error)}`],
};
}
return {
changes: [
result.imported
? "Migrated primary device identity to SQLite."
: "Preserved identical primary device identity already in SQLite.",
],
warnings: [],
notices: ["Removed retired device identity JSON after verified SQLite import."],
};
}
/** Import the retired primary identity while excluding Gateways that can recreate it. */
export async function migrateLegacyDeviceIdentity(params: {
detected: LegacyDeviceIdentityDetection;
stateDir: string;
env?: NodeJS.ProcessEnv;
doctorOnlyStateMigrations?: boolean;
beforeClaim?: (sourcePath: string) => void;
beforeCleanup?: () => void;
removeSource?: (sourcePath: string) => Promise<void> | void;
}): Promise<MigrationMessages> {
if (!params.detected.hasLegacy && !params.detected.hasInvalidCanonical) {
return { changes: [], warnings: [] };
}
if (params.doctorOnlyStateMigrations !== true) {
return { changes: [], warnings: [] };
}
const env = { ...(params.env ?? process.env), OPENCLAW_STATE_DIR: params.stateDir };
let lock: Awaited<ReturnType<typeof acquireGatewayLock>>;
try {
lock = await acquireGatewayLock({
allowInTests: true,
env,
pollIntervalMs: MIGRATION_LOCK_POLL_INTERVAL_MS,
role: "sqlite-maintenance",
timeoutMs: MIGRATION_LOCK_TIMEOUT_MS,
});
} catch (error) {
const detail =
error instanceof GatewayLockError
? "the Gateway or another SQLite maintenance command owns this state directory"
: String(error);
return {
changes: [],
warnings: [
`Failed migrating legacy device identity: ${detail}. Stop the Gateway and run \`openclaw doctor --fix\` again.`,
],
};
}
if (!lock) {
return {
changes: [],
warnings: ["Failed migrating legacy device identity: exclusive state ownership unavailable."],
};
}
let result: MigrationMessages = { changes: [], warnings: [] };
let releaseError: unknown;
let identityCoordinator: ReturnType<typeof acquireDeviceIdentityCoordinator> | undefined;
try {
try {
identityCoordinator = acquireDeviceIdentityCoordinator({
databasePath: resolveDeviceIdentityStore({ env, identityKey: IDENTITY_KEY }).databasePath,
});
} catch (error) {
result.warnings.push(
`Failed migrating legacy device identity: identity state is busy (${formatErrorMessage(error)}).`,
);
}
if (identityCoordinator) {
try {
const hasLegacyNow = hasLegacyDeviceIdentityPath(params.detected);
if (hasLegacyNow) {
const stateRoot = await root(params.stateDir, {
hardlinks: "reject",
maxBytes: MAX_LEGACY_IDENTITY_BYTES,
symlinks: "reject",
});
result = await migrateWithExclusiveStateOwnership({ ...params, env, stateRoot });
} else if (params.detected.hasInvalidCanonical) {
result = repairInvalidCanonicalIdentity(env);
}
} catch (error) {
result.warnings.push(`Failed reading legacy device identity state: ${String(error)}`);
}
}
} finally {
try {
identityCoordinator?.release();
} catch (error) {
releaseError = error;
}
try {
await lock.release();
} catch (error) {
releaseError ??= error;
}
}
if (releaseError) {
result.warnings.push(
`Device identity migration lock release failed: ${formatErrorMessage(releaseError)}`,
);
}
return result;
}
@@ -0,0 +1,8 @@
/** Doctor-only detection result for the retired primary device identity JSON. */
export type LegacyDeviceIdentityDetection = {
sourcePath: string;
claimPath: string;
nativeClaimPath: string;
hasLegacy: boolean;
hasInvalidCanonical: boolean;
};
+50 -2
View File
@@ -51,6 +51,10 @@ import {
detectLegacyDebugProxyCaptureSidecar,
migrateLegacyDebugProxyCaptureSidecar,
} from "./state-migrations.debug-proxy.js";
import {
detectLegacyDeviceIdentity,
migrateLegacyDeviceIdentity,
} from "./state-migrations.device-identity.js";
import {
existsDir,
fileExists,
@@ -442,6 +446,11 @@ export async function detectLegacyStateMigrations(params: {
stateDir,
doctorOnlyStateMigrations: params.doctorOnlyStateMigrations,
});
const deviceIdentity = detectLegacyDeviceIdentity({
stateDir,
env,
doctorOnlyStateMigrations: params.doctorOnlyStateMigrations,
});
const mcpOauth = detectLegacyMcpOAuthStores({
stateDir,
doctorOnlyStateMigrations: params.doctorOnlyStateMigrations,
@@ -630,6 +639,12 @@ export async function detectLegacyStateMigrations(params: {
if (apns.hasLegacy) {
preview.push("- APNs registrations: legacy JSON → shared SQLite state");
}
if (deviceIdentity.hasLegacy) {
preview.push("- Primary device identity: legacy JSON → shared SQLite state");
}
if (deviceIdentity.hasInvalidCanonical && !deviceIdentity.hasLegacy) {
preview.push("- Primary device identity: invalid SQLite row → new device identity");
}
if (mcpOauth.hasLegacy) {
preview.push("- MCP OAuth credentials: legacy JSON → shared SQLite state");
}
@@ -740,6 +755,7 @@ export async function detectLegacyStateMigrations(params: {
acpReplayLedger,
managedOutgoingImages,
apns,
deviceIdentity,
mcpOauth,
restartSentinel,
workspace,
@@ -934,6 +950,7 @@ export async function runLegacyStateMigrations(params: {
env?: NodeJS.ProcessEnv;
now?: () => number;
recoverCorruptTargetStore?: boolean;
doctorOnlyStateMigrations?: boolean;
}): Promise<MigrationMessages> {
const now = params.now ?? (() => Date.now());
const detected = params.detected;
@@ -1003,6 +1020,12 @@ export async function runLegacyStateMigrations(params: {
env,
stateDir: detected.stateDir,
});
const deviceIdentity = await migrateLegacyDeviceIdentity({
detected: detected.deviceIdentity,
env,
stateDir: detected.stateDir,
doctorOnlyStateMigrations: params.doctorOnlyStateMigrations,
});
const mcpOauth = await migrateLegacyMcpOAuthStores({
detected: detected.mcpOauth,
env,
@@ -1072,6 +1095,7 @@ export async function runLegacyStateMigrations(params: {
acpReplayLedger,
managedOutgoingImages,
apns,
deviceIdentity,
mcpOauth,
restartSentinel,
workspace,
@@ -1099,6 +1123,7 @@ export async function runLegacyStateMigrations(params: {
...acpReplayLedger.changes,
...managedOutgoingImages.changes,
...apns.changes,
...deviceIdentity.changes,
...mcpOauth.changes,
...restartSentinel.changes,
...workspace.changes,
@@ -1133,6 +1158,7 @@ export async function runLegacyStateMigrations(params: {
...acpReplayLedger.warnings,
...managedOutgoingImages.warnings,
...apns.warnings,
...deviceIdentity.warnings,
...mcpOauth.warnings,
...restartSentinel.warnings,
...workspace.warnings,
@@ -1171,6 +1197,7 @@ export async function autoMigrateLegacyState(params: {
log?: MigrationLogger;
now?: () => number;
recoverCorruptTargetStore?: boolean;
doctorOnlyStateMigrations?: boolean;
}): Promise<{
migrated: boolean;
skipped: boolean;
@@ -1258,6 +1285,13 @@ export async function autoMigrateLegacyState(params: {
sessionStoreOwnership,
env,
homedir: params.homedir,
doctorOnlyStateMigrations: params.doctorOnlyStateMigrations,
});
const deviceIdentity = await migrateLegacyDeviceIdentity({
detected: detected.deviceIdentity,
env,
stateDir: detected.stateDir,
doctorOnlyStateMigrations: params.doctorOnlyStateMigrations,
});
const hasCustomAgentDir = env.OPENCLAW_AGENT_DIR?.trim() || env.PI_CODING_AGENT_DIR?.trim();
if (hasCustomAgentDir) {
@@ -1329,6 +1363,7 @@ export async function autoMigrateLegacyState(params: {
...configHealth.changes,
...pluginBindingApprovals.changes,
...currentConversationBindings.changes,
...deviceIdentity.changes,
...restartSentinel.changes,
...channelPairing.changes,
...preSessionChannelPlans.changes,
@@ -1350,6 +1385,7 @@ export async function autoMigrateLegacyState(params: {
...configHealth.warnings,
...pluginBindingApprovals.warnings,
...currentConversationBindings.warnings,
...deviceIdentity.warnings,
...restartSentinel.warnings,
...channelPairing.warnings,
...preSessionChannelPlans.warnings,
@@ -1360,6 +1396,7 @@ export async function autoMigrateLegacyState(params: {
detected,
pluginInstallIndex,
updateCheck,
deviceIdentity,
restartSentinel,
pluginPlans,
];
@@ -1381,6 +1418,7 @@ export async function autoMigrateLegacyState(params: {
configHealth.changes.length > 0 ||
pluginBindingApprovals.changes.length > 0 ||
currentConversationBindings.changes.length > 0 ||
deviceIdentity.changes.length > 0 ||
restartSentinel.changes.length > 0 ||
channelPairing.changes.length > 0 ||
preSessionChannelPlans.changes.length > 0 ||
@@ -1416,6 +1454,7 @@ export async function autoMigrateLegacyState(params: {
...stateSchema.changes,
...orphanKeys.changes,
...acpSessionMetadata.changes,
...deviceIdentity.changes,
];
const warnings = [
...stateDirResult.warnings,
@@ -1423,15 +1462,21 @@ export async function autoMigrateLegacyState(params: {
...detected.warnings,
...orphanKeys.warnings,
...acpSessionMetadata.warnings,
...deviceIdentity.warnings,
];
const notices = [
...(stateDirResult.notices ?? []),
...detected.notices,
...(deviceIdentity.notices ?? []),
];
const notices = [...(stateDirResult.notices ?? []), ...detected.notices];
logMigrationResults(changes, warnings, notices);
return {
migrated:
stateDirResult.migrated ||
stateSchema.changes.length > 0 ||
orphanKeys.changes.length > 0 ||
acpSessionMetadata.changes.length > 0,
acpSessionMetadata.changes.length > 0 ||
deviceIdentity.changes.length > 0,
skipped: false,
changes,
warnings,
@@ -1521,6 +1566,7 @@ export async function autoMigrateLegacyState(params: {
...configHealth.changes,
...pluginBindingApprovals.changes,
...currentConversationBindings.changes,
...deviceIdentity.changes,
...restartSentinel.changes,
...channelPairing.changes,
...preSessionChannelPlans.changes,
@@ -1546,6 +1592,7 @@ export async function autoMigrateLegacyState(params: {
...configHealth.warnings,
...pluginBindingApprovals.warnings,
...currentConversationBindings.warnings,
...deviceIdentity.warnings,
...restartSentinel.warnings,
...channelPairing.warnings,
...preSessionChannelPlans.warnings,
@@ -1560,6 +1607,7 @@ export async function autoMigrateLegacyState(params: {
detected,
pluginInstallIndex,
updateCheck,
deviceIdentity,
restartSentinel,
pluginPlans,
];
+2
View File
@@ -3,6 +3,7 @@ import type { SessionScope } from "../config/sessions/types.js";
import type { PluginDoctorStateMigration } from "../plugins/doctor-contract-registry.js";
import type { LegacyAuditLogsDetection } from "./state-migrations.audit-logs.types.js";
import type { LegacyChannelPairingStateDetection } from "./state-migrations.channel-pairing.js";
import type { LegacyDeviceIdentityDetection } from "./state-migrations.device-identity.types.js";
import type { LegacyMcpOAuthDetection } from "./state-migrations.mcp-oauth.types.js";
import type { LegacyRestartSentinelDetection } from "./state-migrations.restart-sentinel.types.js";
import type { LegacyWorkspaceStateDetection } from "./state-migrations.workspace-setup.types.js";
@@ -118,6 +119,7 @@ export type LegacyStateDetection = {
sourcePath: string;
hasLegacy: boolean;
};
deviceIdentity: LegacyDeviceIdentityDetection;
mcpOauth: LegacyMcpOAuthDetection;
restartSentinel?: LegacyRestartSentinelDetection;
workspace: LegacyWorkspaceStateDetection;
+43
View File
@@ -847,6 +847,49 @@ describe("openclaw state database", () => {
).toBeUndefined();
});
it("adopts a canonical device identity seed database without losing the identity", () => {
const stateDir = createTempStateDir();
const databasePath = path.join(stateDir, "state", "openclaw.sqlite");
fs.mkdirSync(path.dirname(databasePath), { recursive: true });
const { DatabaseSync } = requireNodeSqlite();
const seed = new DatabaseSync(databasePath);
seed.exec(`
CREATE TABLE device_identities (
identity_key TEXT NOT NULL PRIMARY KEY,
device_id TEXT NOT NULL,
public_key_pem TEXT NOT NULL,
private_key_pem TEXT NOT NULL,
created_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL
) STRICT;
CREATE INDEX idx_device_identities_device
ON device_identities(device_id, updated_at_ms DESC);
INSERT INTO device_identities VALUES (
'primary', 'device-1', 'public-key', 'private-key', 10, 20
);
`);
seed.close();
const database = openOpenClawStateDatabase({
env: { OPENCLAW_STATE_DIR: stateDir },
});
expect(
database.db.prepare("SELECT * FROM device_identities WHERE identity_key = 'primary'").get(),
).toEqual({
identity_key: "primary",
device_id: "device-1",
public_key_pem: "public-key",
private_key_pem: "private-key",
created_at_ms: 10,
updated_at_ms: 20,
});
expect(readSqliteNumberPragma(database.db, "user_version")).toBe(OPENCLAW_STATE_SCHEMA_VERSION);
expect(collectSqliteSchemaShape(database.db)).toEqual(
createSqliteSchemaShapeFromSql(new URL("./openclaw-state-schema.sql", import.meta.url)),
);
});
it("doctor migrates existing APNs tombstone tables to STRICT without losing rows", () => {
const stateDir = createTempStateDir();
const options = { env: { OPENCLAW_STATE_DIR: stateDir } };
+2 -2
View File
@@ -121,8 +121,8 @@ export async function connectNode(
inst: GatewayInstance,
label: string,
): Promise<{ client: GatewayClient; nodeId: string }> {
const identityPath = path.join(inst.homeDir, `${label}-device.json`);
const deviceIdentity = loadOrCreateDeviceIdentity(identityPath);
const identityPath = path.join(inst.homeDir, `${label}-device.sqlite`);
const deviceIdentity = loadOrCreateDeviceIdentity({ path: identityPath });
const nodeId = deviceIdentity.deviceId;
const client = await connectGatewayClient({
url: `ws://127.0.0.1:${inst.port}`,
@@ -8707,6 +8707,33 @@ describe("check-database-first-legacy-stores", () => {
expect(violations).toEqual([]);
});
it("blocks runtime writes to the retired device identity file", () => {
const violations = collectDatabaseFirstLegacyStoreViolations(
`
import fs from "node:fs";
fs.writeFileSync(path.join(stateDir, "identity/device.json"), "{}\\n");
`,
"src/infra/device-identity.ts",
);
expect(violations).toEqual([{ kind: "legacy store filesystem write", line: 3 }]);
});
it("allows only the device identity migration owner to retire its legacy source", () => {
const violations = collectDatabaseFirstLegacyStoreViolations(
`
import fs from "node:fs";
fs.renameSync(
path.join(stateDir, "identity/device.json"),
path.join(stateDir, "identity/device.json.doctor-importing"),
);
`,
"src/infra/state-migrations.device-identity.ts",
);
expect(violations).toEqual([]);
});
it("allows the workspace Doctor migration owner to claim legacy sidecars", () => {
const violations = collectDatabaseFirstLegacyStoreViolations(
`