From b2701ac9cfcd294e842dc4f60db10b50b5645165 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 29 Jul 2026 02:33:03 -0400 Subject: [PATCH] fix(mac): prevent app auth from blocking node-host startup (#115533) * fix(mac): move device auth tokens to shared SQLite state * fix(mac): fail identity migration when source auth vanishes mid-read Missing-file tolerance now applies only to the first observation; a disappearance after the auth file was observed fails the migration so the claimed identity survives for retry instead of committing without its credentials. * fix(ios): validate auth scope UTF-8 encoding * fix(ios): isolate SQLite auth profiles * chore(ios): refresh native source inventory * fix(mac): keep device auth in the shared token table * test(mac): pin shared-key token cache semantics --- .../Sources/OpenClawKit/DeviceAuthStore.swift | 436 ++++++++++++---- .../DeviceIdentitySQLiteStore.swift | 128 ++--- .../OpenClawNativeStateSQLite.swift | 45 +- .../DeviceAuthStoreTests.swift | 483 ++++++++++++++++++ .../DeviceIdentityStoreTests.swift | 224 +++++--- .../GatewayNodeSessionTests.swift | 4 +- 6 files changed, 1076 insertions(+), 244 deletions(-) create mode 100644 apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceAuthStoreTests.swift diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceAuthStore.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceAuthStore.swift index a1ddd7488999..c97c763dcfe1 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceAuthStore.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceAuthStore.swift @@ -1,4 +1,5 @@ import Foundation +import OpenClawNativeState public struct DeviceAuthEntry: Codable, Sendable, Equatable { public let token: String @@ -23,15 +24,27 @@ struct DeviceAuthStoreFile: Codable, Equatable { } public enum DeviceAuthStore { + private typealias Database = OpenClawNativeStateSQLite + + static let maximumLegacyAuthBytes = 4 * 1024 * 1024 + private static let busyTimeoutMilliseconds: Int32 = 30000 + + private enum LegacyImport { + case missing + case valid(DeviceAuthStoreFile) + case invalid + } + public static func loadToken( deviceId: String, role: String, gatewayID: String? = nil, profile: GatewayDeviceIdentityProfile = .primary) -> DeviceAuthEntry? { - guard let store = readStore(profile: profile), store.deviceId == deviceId else { return nil } guard let key = self.tokenKey(role: role, gatewayID: gatewayID) else { return nil } - return store.tokens[key] + return try? self.withStore(profile: profile) { database in + try self.readEntry(database, deviceId: deviceId, storedRole: key) + } } public static func storeToken( @@ -83,21 +96,17 @@ public enum DeviceAuthStore { let entry = DeviceAuthEntry( token: token, role: normalizedRole, - scopes: normalizeScopes(scopes), + scopes: self.normalizeScopes(scopes), updatedAtMs: Int64(Date().timeIntervalSince1970 * 1000), gatewayID: normalizedGatewayID) guard gatewayID == nil || normalizedGatewayID != nil, let key = self.tokenKey(role: normalizedRole, gatewayID: normalizedGatewayID) else { return (entry, false) } - var next = self.readStore(profile: profile) - if next?.deviceId != deviceId { - next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:]) - } - if next == nil { - next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:]) - } - next?.tokens[key] = entry - let persisted = next.map { self.writeStore($0, profile: profile) } ?? false + let persisted = (try? self.withStore(profile: profile) { database in + // SQLite intentionally keeps tokens for multiple devices instead of replacing the old file owner. + try self.upsertEntry(database, deviceId: deviceId, storedRole: key, entry: entry) + return true + }) ?? false return (entry, persisted) } @@ -107,21 +116,36 @@ public enum DeviceAuthStore { gatewayID: String? = nil, profile: GatewayDeviceIdentityProfile = .primary) { - guard var store = readStore(profile: profile), store.deviceId == deviceId else { return } let normalizedRole = self.normalizeRole(role) if gatewayID == nil { - store.tokens = store.tokens.filter { _, entry in - self.normalizeRole(entry.role) != normalizedRole + try? self.withStore(profile: profile) { database in + for key in try self.storedRoles(database, deviceId: deviceId) + where self.normalizeRole(self.decodeTokenKey(key).role) == normalizedRole + { + try self.deleteEntry(database, deviceId: deviceId, storedRole: key) + } + } + } else if let key = self.tokenKey(role: normalizedRole, gatewayID: gatewayID) { + try? self.withStore(profile: profile) { database in + try self.deleteEntry(database, deviceId: deviceId, storedRole: key) } - } else { - guard let key = self.tokenKey(role: normalizedRole, gatewayID: gatewayID) else { return } - store.tokens.removeValue(forKey: key) } - self.writeStore(store, profile: profile) } public static func clearAll(profile: GatewayDeviceIdentityProfile = .primary) { - try? FileManager.default.removeItem(at: self.fileURL(profile: profile)) + let stateDirectoryURL = DeviceIdentityPaths.stateDirURL() + let databaseURL = stateDirectoryURL + .appendingPathComponent("state", isDirectory: true) + .appendingPathComponent("openclaw.sqlite", isDirectory: false) + guard let identity = try? DeviceIdentitySQLiteStore.loadExisting( + databaseURL: databaseURL, + profile: profile) + else { return } + try? self.withStore(profile: profile) { database in + let statement = try database.prepare("DELETE FROM device_auth_tokens WHERE device_id = ?") + try statement.bindText(identity.deviceId, at: 1) + _ = try statement.step() + } } /// Claims one legacy role token for a caller-proven gateway identity. @@ -133,26 +157,30 @@ public enum DeviceAuthStore { toGatewayID gatewayID: String, profile: GatewayDeviceIdentityProfile = .primary) -> Bool { - guard let gatewayID = self.normalizeGatewayID(gatewayID), - var store = self.readStore(profile: profile), - store.deviceId == deviceId - else { return false } - + guard let gatewayID = self.normalizeGatewayID(gatewayID) else { return false } let normalizedRole = self.normalizeRole(role) guard let legacyKey = self.tokenKey(role: normalizedRole, gatewayID: nil), let scopedKey = self.tokenKey(role: normalizedRole, gatewayID: gatewayID) else { return false } - guard let entry = store.tokens[legacyKey], entry.gatewayID == nil else { return false } - if store.tokens[scopedKey] == nil { - store.tokens[scopedKey] = DeviceAuthEntry( - token: entry.token, - role: normalizedRole, - scopes: entry.scopes, - updatedAtMs: entry.updatedAtMs, - gatewayID: gatewayID) - } - store.tokens.removeValue(forKey: legacyKey) - return self.writeStore(store, profile: profile) + return (try? self.withStore(profile: profile) { database in + guard let entry = try self.readEntry(database, deviceId: deviceId, storedRole: legacyKey), + entry.gatewayID == nil + else { return false } + if try !self.storedRoles(database, deviceId: deviceId).contains(scopedKey) { + try self.upsertEntry( + database, + deviceId: deviceId, + storedRole: scopedKey, + entry: DeviceAuthEntry( + token: entry.token, + role: normalizedRole, + scopes: entry.scopes, + updatedAtMs: entry.updatedAtMs, + gatewayID: gatewayID)) + } + try self.deleteEntry(database, deviceId: deviceId, storedRole: legacyKey) + return true + }) ?? false } /// Removes legacy tokens when the app cannot prove which gateway issued them. @@ -161,13 +189,227 @@ public enum DeviceAuthStore { deviceId: String, profile: GatewayDeviceIdentityProfile = .primary) -> Int { - guard var store = self.readStore(profile: profile), store.deviceId == deviceId else { return 0 } - let legacyKeys = store.tokens.compactMap { key, entry in entry.gatewayID == nil ? key : nil } - guard !legacyKeys.isEmpty else { return 0 } - for key in legacyKeys { - store.tokens.removeValue(forKey: key) + (try? self.withStore(profile: profile) { database in + let keys = try self.storedRoles(database, deviceId: deviceId) + .filter { self.decodeTokenKey($0).gatewayID == nil } + for key in keys { + try self.deleteEntry(database, deviceId: deviceId, storedRole: key) + } + return keys.count + }) ?? 0 + } + + static func importLegacyStore( + _ store: DeviceAuthStoreFile, + stateDirectoryURL: URL, + profile: GatewayDeviceIdentityProfile) throws + { + try self.withStore(stateDirectoryURL: stateDirectoryURL, profile: profile) { database in + try self.importLegacyStore(store, into: database) } - return self.writeStore(store, profile: profile) ? legacyKeys.count : 0 + } + + static func normalizedStore(_ decoded: DeviceAuthStoreFile) -> DeviceAuthStoreFile? { + guard decoded.version == 1 else { return nil } + // Entries carry their owner, so migration compares one canonical role/scope/owner map + // instead of depending on raw JSON key order or obsolete dictionary keys. + var tokens: [String: DeviceAuthEntry] = [:] + for entry in decoded.tokens.values { + let role = self.normalizeRole(entry.role) + let gatewayID = self.normalizeGatewayID(entry.gatewayID) + guard entry.gatewayID == nil || gatewayID != nil, + let key = self.tokenKey(role: role, gatewayID: gatewayID) + else { continue } + let normalized = DeviceAuthEntry( + token: entry.token, + role: role, + scopes: self.normalizeScopes(entry.scopes), + updatedAtMs: entry.updatedAtMs, + gatewayID: gatewayID) + if let existing = tokens[key] { + if existing.updatedAtMs > normalized.updatedAtMs { continue } + if existing.updatedAtMs == normalized.updatedAtMs, existing != normalized { return nil } + } + tokens[key] = normalized + } + return DeviceAuthStoreFile(version: 1, deviceId: decoded.deviceId, tokens: tokens) + } + + private static func withStore( + profile: GatewayDeviceIdentityProfile, + _ body: (Database) throws -> Value) throws -> Value + { + try self.withStore( + stateDirectoryURL: DeviceIdentityPaths.stateDirURL(), + profile: profile, + body) + } + + private static func withStore( + stateDirectoryURL: URL, + profile: GatewayDeviceIdentityProfile, + _ body: (Database) throws -> Value) throws -> Value + { + let legacyURL = self.legacyFileURL(stateDirectoryURL: stateDirectoryURL, profile: profile) + let legacy = try self.readLegacyStore(legacyURL) + if case .invalid = legacy { + try self.quarantineInvalidLegacyFile(legacyURL) + } + let database = try self.openDatabase(stateDirectoryURL: stateDirectoryURL) + if case let .valid(store) = legacy { + try database.withImmediateTransaction { + try database.ensureCanonicalTable(.deviceAuthTokens) + try self.importLegacyStore(store, into: database) + } + try self.removeLegacyFile(legacyURL) + } + // Retire legacy state before a clear can commit; otherwise a crash could reimport a revoked row. + return try database.withImmediateTransaction { + try database.ensureCanonicalTable(.deviceAuthTokens) + return try body(database) + } + } + + private static func openDatabase(stateDirectoryURL: URL) throws -> Database { + try Database( + databaseURL: stateDirectoryURL + .appendingPathComponent("state", isDirectory: true) + .appendingPathComponent("openclaw.sqlite", isDirectory: false), + busyTimeoutMilliseconds: self.busyTimeoutMilliseconds) + } + + private static func importLegacyStore( + _ store: DeviceAuthStoreFile, + into database: Database) throws + { + for (key, entry) in store.tokens.sorted(by: { $0.key < $1.key }) { + if try self.rowIsCanonical(database, deviceId: store.deviceId, storedRole: key) { + continue + } + try self.upsertEntry(database, deviceId: store.deviceId, storedRole: key, entry: entry) + } + } + + private static func readEntry( + _ database: Database, + deviceId: String, + storedRole: String) throws -> DeviceAuthEntry? + { + let statement = try database.prepare(""" + SELECT token, scopes_json, updated_at_ms + FROM device_auth_tokens + WHERE device_id = ? AND role = ? + """) + try statement.bindText(deviceId, at: 1) + try statement.bindText(storedRole, at: 2) + guard try statement.step() == .row else { return nil } + let token = try statement.requiredText(at: 0, field: "device auth token") + let scopesJSON = try statement.requiredText(at: 1, field: "device auth scopes_json") + let updatedAtMs = statement.int64(at: 2) + guard statement.valueType(at: 2) == .integer, + let scopes = self.decodeScopes(scopesJSON) + else { return nil } + let decoded = self.decodeTokenKey(storedRole) + return DeviceAuthEntry( + token: token, + role: decoded.role, + scopes: scopes, + updatedAtMs: updatedAtMs, + gatewayID: decoded.gatewayID) + } + + private static func upsertEntry( + _ database: Database, + deviceId: String, + storedRole: String, + entry: DeviceAuthEntry) throws + { + let scopesData = try JSONEncoder().encode(self.normalizeScopes(entry.scopes)) + guard let scopes = String(bytes: scopesData, encoding: .utf8) else { + throw OpenClawNativeStateError("failed to encode device auth scopes as UTF-8") + } + let statement = try database.prepare(""" + INSERT INTO device_auth_tokens (device_id, role, token, scopes_json, updated_at_ms) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(device_id, role) DO UPDATE SET + token = excluded.token, + scopes_json = excluded.scopes_json, + updated_at_ms = excluded.updated_at_ms + """) + try statement.bindText(deviceId, at: 1) + try statement.bindText(storedRole, at: 2) + try statement.bindText(entry.token, at: 3) + try statement.bindText(scopes, at: 4) + try statement.bindInt64(entry.updatedAtMs, at: 5) + _ = try statement.step() + } + + private static func storedRoles( + _ database: Database, + deviceId: String) throws -> [String] + { + let statement = try database.prepare( + "SELECT role FROM device_auth_tokens WHERE device_id = ? ORDER BY role") + try statement.bindText(deviceId, at: 1) + var keys: [String] = [] + while try statement.step() == .row { + try keys.append(statement.requiredText(at: 0, field: "device auth role")) + } + return keys + } + + private static func rowIsCanonical( + _ database: Database, + deviceId: String, + storedRole: String) throws -> Bool + { + let statement = try database.prepare( + "SELECT scopes_json FROM device_auth_tokens WHERE device_id = ? AND role = ?") + try statement.bindText(deviceId, at: 1) + try statement.bindText(storedRole, at: 2) + guard try statement.step() == .row else { return false } + let scopesJSON = try statement.requiredText(at: 0, field: "device auth scopes_json") + return self.jsonArray(scopesJSON) != nil + } + + private static func deleteEntry( + _ database: Database, + deviceId: String, + storedRole: String) throws + { + let statement = try database.prepare( + "DELETE FROM device_auth_tokens WHERE device_id = ? AND role = ?") + try statement.bindText(deviceId, at: 1) + try statement.bindText(storedRole, at: 2) + _ = try statement.step() + } + + private static func decodeScopes(_ rawJSON: String) -> [String]? { + guard let values = self.jsonArray(rawJSON) else { return nil } + return self.normalizeScopes(values.compactMap { $0 as? String }) + } + + private static func jsonArray(_ rawJSON: String) -> [Any]? { + guard let data = rawJSON.data(using: .utf8) else { return nil } + return try? JSONSerialization.jsonObject(with: data) as? [Any] + } + + private static func decodeTokenKey(_ key: String) -> (role: String, gatewayID: String?) { + let parts = key.split(separator: ".", omittingEmptySubsequences: false) + guard parts.count == 3, parts[0] == "v2", + let gatewayID = self.decodeStorageComponent(String(parts[1])), + let role = self.decodeStorageComponent(String(parts[2])) + else { return (key, nil) } + return (role, gatewayID) + } + + private static func decodeStorageComponent(_ value: String) -> String? { + let base64 = value + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + let padded = base64 + String(repeating: "=", count: (4 - base64.count % 4) % 4) + guard let data = Data(base64Encoded: padded) else { return nil } + return String(data: data, encoding: .utf8) } private static func normalizeRole(_ role: String) -> String { @@ -203,67 +445,65 @@ public enum DeviceAuthStore { return Array(Set(trimmed)).sorted() } - private static func fileURL(profile: GatewayDeviceIdentityProfile) -> URL { - DeviceIdentityPaths.stateDirURL() + private static func legacyFileURL( + stateDirectoryURL: URL, + profile: GatewayDeviceIdentityProfile) -> URL + { + stateDirectoryURL .appendingPathComponent("identity", isDirectory: true) .appendingPathComponent(profile.authFileName, isDirectory: false) } - private static func readStore(profile: GatewayDeviceIdentityProfile) -> DeviceAuthStoreFile? { - let url = self.fileURL(profile: profile) - guard let data = try? Data(contentsOf: url) else { return nil } - 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 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) - let gatewayID = self.normalizeGatewayID(entry.gatewayID) - guard entry.gatewayID == nil || gatewayID != nil, - let key = self.tokenKey(role: role, gatewayID: gatewayID) - else { continue } - let normalized = DeviceAuthEntry( - token: entry.token, - role: role, - scopes: self.normalizeScopes(entry.scopes), - updatedAtMs: entry.updatedAtMs, - gatewayID: gatewayID) - if let existing = tokens[key] { - if existing.updatedAtMs > normalized.updatedAtMs { - continue - } - if existing.updatedAtMs == normalized.updatedAtMs, existing != normalized { - return nil - } - } - tokens[key] = normalized - } - return DeviceAuthStoreFile(version: 1, deviceId: decoded.deviceId, tokens: tokens) - } - - @discardableResult - private static func writeStore( - _ store: DeviceAuthStoreFile, - profile: GatewayDeviceIdentityProfile) -> Bool - { - let url = self.fileURL(profile: profile) + private static func readLegacyStore(_ url: URL) throws -> LegacyImport { + let attributes: [FileAttributeKey: Any] do { - try FileManager.default.createDirectory( - at: url.deletingLastPathComponent(), - withIntermediateDirectories: true) - let data = try JSONEncoder().encode(store) - try data.write(to: url, options: [.atomic]) - try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) - return true - } catch { - return false + attributes = try FileManager.default.attributesOfItem(atPath: url.path) + } catch where self.isMissingFileError(error) { + return .missing } + guard let size = attributes[.size] as? NSNumber, + size.intValue <= self.maximumLegacyAuthBytes, + attributes[.type] as? FileAttributeType == .typeRegular + else { return .invalid } + do { + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + let data = try handle.read(upToCount: self.maximumLegacyAuthBytes + 1) ?? Data() + guard data.count <= self.maximumLegacyAuthBytes, + let decoded = try? JSONDecoder().decode(DeviceAuthStoreFile.self, from: data), + let normalized = self.normalizedStore(decoded) + else { return .invalid } + return .valid(normalized) + } catch where self.isMissingFileError(error) { + return .missing + } + } + + private static func quarantineInvalidLegacyFile(_ url: URL) throws { + let timestamp = Int64(Date().timeIntervalSince1970 * 1000) + let destinationURL = URL( + fileURLWithPath: "\(url.path).invalid-\(timestamp)", + isDirectory: false) + do { + try FileManager.default.moveItem(at: url, to: destinationURL) + } catch where self.isMissingFileError(error) { + return + } + } + + private static func removeLegacyFile(_ url: URL) throws { + do { + try FileManager.default.removeItem(at: url) + } catch where self.isMissingFileError(error) { + return + } + } + + static func isMissingFileError(_ error: Error) -> Bool { + let error = error as NSError + return (error.domain == NSCocoaErrorDomain + && (error.code == NSFileNoSuchFileError || error.code == NSFileReadNoSuchFileError)) + || (error.domain == NSPOSIXErrorDomain + && error.code == Int(POSIXErrorCode.ENOENT.rawValue)) } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceIdentitySQLiteStore.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceIdentitySQLiteStore.swift index fb282e616626..821cf25a3db8 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceIdentitySQLiteStore.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceIdentitySQLiteStore.swift @@ -11,7 +11,6 @@ enum DeviceIdentitySQLiteStore { // production access never waits this long. private static let busyTimeoutMilliseconds: Int32 = 30000 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" @@ -31,7 +30,6 @@ enum DeviceIdentitySQLiteStore { } private struct LegacyAuthCandidate { - let data: Data let store: DeviceAuthStoreFile } @@ -89,6 +87,18 @@ enum DeviceIdentitySQLiteStore { } } + static func loadExisting( + databaseURL: URL, + profile: GatewayDeviceIdentityProfile) throws -> DeviceIdentity? + { + let database = try OpenClawNativeStateSQLite( + databaseURL: databaseURL, + createIfMissing: false) + guard try database.schemaObjectExists(type: "table", name: "device_identities") else { return nil } + try database.ensureCanonicalTable(.deviceIdentities, allowVersionZeroCreation: false) + return try self.readIdentity(database, key: profile.rawValue)?.identity + } + private static func loadOrCreateOwned( databaseURL: URL, destinationStateDirURL: URL, @@ -600,16 +610,11 @@ enum DeviceIdentitySQLiteStore { 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 } + guard source.stateDirURL.standardizedFileURL != destinationStateDirURL.standardizedFileURL else { + return nil + } return try self.readDeviceAuth( source.authURL, beneath: source.stateDirURL, @@ -621,80 +626,53 @@ enum DeviceIdentitySQLiteStore { 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) + // Cross-container auth remains at its source; only canonical SQLite rows move. + try DeviceAuthStore.importLegacyStore( + selectedAuth.store, + stateDirectoryURL: destinationStateDirURL, + profile: profile) } private static func readDeviceAuth( _ url: URL, beneath stateDirURL: URL, - deviceId: String) throws -> LegacyAuthCandidate + 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 { + let before: LegacyFileSnapshot + do { + before = try self.legacyFileSnapshot( + url, + beneath: stateDirURL, + maximumBytes: DeviceAuthStore.maximumLegacyAuthBytes) + } catch where DeviceAuthStore.isMissingFileError(error) { + // Absent at first observation means nothing to migrate. A disappearance after + // this point must fail the migration instead, so the claimed identity survives + // for retry rather than committing without its credentials. + return nil + } + do { + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + let data = try handle.read(upToCount: DeviceAuthStore.maximumLegacyAuthBytes + 1) ?? Data() + let after = try self.legacyFileSnapshot( + url, + beneath: stateDirURL, + maximumBytes: DeviceAuthStore.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(store: normalized) + } catch where DeviceAuthStore.isMissingFileError(error) { 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 { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawNativeState/OpenClawNativeStateSQLite.swift b/apps/shared/OpenClawKit/Sources/OpenClawNativeState/OpenClawNativeStateSQLite.swift index 4de65fcd6b54..2e54070dbe9d 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawNativeState/OpenClawNativeStateSQLite.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawNativeState/OpenClawNativeStateSQLite.swift @@ -14,6 +14,7 @@ public struct OpenClawNativeStateError: Error, LocalizedError, Sendable { } public enum OpenClawNativeStateCanonicalTable: Sendable { + case deviceAuthTokens case deviceIdentities case execApprovalsConfig case macosPortGuardianRecords @@ -102,6 +103,35 @@ public final class OpenClawNativeStateSQLite: @unchecked Sendable { IndexColumn(name: "updated_at_ms", descending: true), ]) + private static let deviceAuthTokens = CanonicalTable( + name: "device_auth_tokens", + indexName: "idx_device_auth_tokens_updated", + createSQL: """ + CREATE TABLE IF NOT EXISTS device_auth_tokens ( + device_id TEXT NOT NULL, + role TEXT NOT NULL, + token TEXT NOT NULL, + scopes_json TEXT NOT NULL, + updated_at_ms INTEGER NOT NULL, + PRIMARY KEY (device_id, role) + ) STRICT; + + CREATE INDEX IF NOT EXISTS idx_device_auth_tokens_updated + ON device_auth_tokens(updated_at_ms DESC, device_id, role); + """, + columns: [ + Column(name: "device_id", type: "TEXT", notNull: true, primaryKeyPosition: 1, hidden: 0), + Column(name: "role", type: "TEXT", notNull: true, primaryKeyPosition: 2, hidden: 0), + Column(name: "token", type: "TEXT", notNull: true, primaryKeyPosition: 0, hidden: 0), + Column(name: "scopes_json", type: "TEXT", notNull: true, primaryKeyPosition: 0, hidden: 0), + Column(name: "updated_at_ms", type: "INTEGER", notNull: true, primaryKeyPosition: 0, hidden: 0), + ], + indexColumns: [ + IndexColumn(name: "updated_at_ms", descending: true), + IndexColumn(name: "device_id", descending: false), + IndexColumn(name: "role", descending: false), + ]) + private static let macosPortGuardianRecords = CanonicalTable( name: "macos_port_guardian_records", indexName: "idx_macos_port_guardian_records_port", @@ -163,6 +193,7 @@ public final class OpenClawNativeStateSQLite: @unchecked Sendable { indexColumns: []) private static let canonicalTables = [ + OpenClawNativeStateSQLite.deviceAuthTokens, OpenClawNativeStateSQLite.deviceIdentities, OpenClawNativeStateSQLite.execApprovalsConfig, OpenClawNativeStateSQLite.macosPortGuardianRecords, @@ -172,11 +203,18 @@ public final class OpenClawNativeStateSQLite: @unchecked Sendable { fileprivate let database: OpaquePointer fileprivate let connectionLock = NSRecursiveLock() - public init(databaseURL: URL, busyTimeoutMilliseconds: Int32 = 5000) throws { + public init( + databaseURL: URL, + busyTimeoutMilliseconds: Int32 = 5000, + createIfMissing: Bool = true) throws + { self.databaseURL = databaseURL - try Self.secureDirectory(databaseURL.deletingLastPathComponent()) + if createIfMissing { + try Self.secureDirectory(databaseURL.deletingLastPathComponent()) + } var database: OpaquePointer? - let flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX + let flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX + | (createIfMissing ? SQLITE_OPEN_CREATE : 0) let result = sqlite3_open_v2(databaseURL.path, &database, flags, nil) guard result == SQLITE_OK, let database else { let detail = database.map { String(cString: sqlite3_errmsg($0)) } ?? "unknown SQLite error" @@ -346,6 +384,7 @@ public final class OpenClawNativeStateSQLite: @unchecked Sendable { private static func descriptor(_ table: OpenClawNativeStateCanonicalTable) -> CanonicalTable { switch table { + case .deviceAuthTokens: self.deviceAuthTokens case .deviceIdentities: self.deviceIdentities case .execApprovalsConfig: self.execApprovalsConfig case .macosPortGuardianRecords: self.macosPortGuardianRecords diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceAuthStoreTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceAuthStoreTests.swift new file mode 100644 index 000000000000..06cb5ada7e5d --- /dev/null +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceAuthStoreTests.swift @@ -0,0 +1,483 @@ +import Foundation +import SQLite3 +import Testing +@testable import OpenClawKit + +@Suite(.serialized) +struct DeviceAuthStoreTests { + @Test(.stateDirectoryIsolated) + func `store and load round trip without legacy files`() throws { + let deviceID = "device-round-trip" + #expect(DeviceAuthStore.storeTokenPersisted( + deviceId: deviceID, + role: " node ", + token: "unscoped-token", + scopes: [" write ", "read", "write", " "])) + #expect(DeviceAuthStore.storeTokenPersisted( + deviceId: deviceID, + role: "operator", + token: "scoped-token", + scopes: ["zeta", "alpha"], + gatewayID: "gateway-a")) + + #expect(try DeviceAuthStore.loadToken(deviceId: deviceID, role: "node") == DeviceAuthEntry( + token: "unscoped-token", + role: "node", + scopes: ["read", "write"], + updatedAtMs: #require(DeviceAuthStore.loadToken(deviceId: deviceID, role: "node")?.updatedAtMs))) + let scoped = try #require(DeviceAuthStore.loadToken( + deviceId: deviceID, + role: "operator", + gatewayID: "gateway-a")) + #expect(scoped.role == "operator") + #expect(scoped.gatewayID == "gateway-a") + #expect(scoped.scopes == ["alpha", "zeta"]) + + for profile in [ + GatewayDeviceIdentityProfile.primary, + .node, + .shareExtension, + ] { + #expect(try !FileManager.default.fileExists(atPath: Self.authURL(profile: profile).path)) + } + } + + @Test(.stateDirectoryIsolated) + func `same device id across profiles shares the token cache`() { + let deviceID = "shared-device-id" + // Matching device IDs imply matching key material and therefore one gateway device, + // so profiles share this cache. The Node runtime reads device_auth_tokens only. + #expect(DeviceAuthStore.storeTokenPersisted( + deviceId: deviceID, + role: "node", + token: "primary-token", + profile: .primary)) + #expect(DeviceAuthStore.loadToken( + deviceId: deviceID, + role: "node", + profile: .node)?.token == "primary-token") + } + + @Test(.stateDirectoryIsolated) + func `distinct device ids remain disjoint across profiles`() { + #expect(DeviceAuthStore.storeTokenPersisted( + deviceId: "primary-device", + role: "node", + token: "primary-token", + profile: .primary)) + #expect(DeviceAuthStore.loadToken( + deviceId: "node-device", + role: "node", + profile: .node) == nil) + #expect(DeviceAuthStore.storeTokenPersisted( + deviceId: "node-device", + role: "node", + token: "node-token", + profile: .node)) + + #expect(DeviceAuthStore.loadToken( + deviceId: "primary-device", + role: "node", + profile: .primary)?.token == "primary-token") + #expect(DeviceAuthStore.loadToken( + deviceId: "node-device", + role: "node", + profile: .node)?.token == "node-token") + } + + @Test(.stateDirectoryIsolated) + func `legacy file imports once and reconstructs scoped metadata`() throws { + let deviceID = "legacy-device" + let gatewayID = "gateway-a" + let scopedKey = "v2.\(Self.storageComponent(gatewayID)).\(Self.storageComponent("operator"))" + try Self.writeLegacy(DeviceAuthStoreFile( + version: 1, + deviceId: deviceID, + tokens: [ + "node": DeviceAuthEntry( + token: "legacy-node", + role: "node", + scopes: [" beta ", "alpha"], + updatedAtMs: 100), + scopedKey: DeviceAuthEntry( + token: "legacy-operator", + role: "operator", + scopes: ["write"], + updatedAtMs: 200, + gatewayID: gatewayID), + ])) + + #expect(DeviceAuthStore.loadToken(deviceId: deviceID, role: "node")?.token == "legacy-node") + #expect(try !FileManager.default.fileExists(atPath: Self.authURL().path)) + let scoped = try #require(DeviceAuthStore.loadToken( + deviceId: deviceID, + role: "operator", + gatewayID: gatewayID)) + #expect(scoped.token == "legacy-operator") + #expect(scoped.role == "operator") + #expect(scoped.gatewayID == gatewayID) + #expect(scoped.scopes == ["write"]) + #expect(DeviceAuthStore.loadToken(deviceId: deviceID, role: "node")?.scopes == ["alpha", "beta"]) + } + + @Test(.stateDirectoryIsolated) + func `legacy import preserves a canonical SQLite row`() throws { + let deviceID = "preserve-device" + #expect(DeviceAuthStore.storeTokenPersisted( + deviceId: deviceID, + role: "node", + token: "sqlite-token", + scopes: ["sqlite-scope"])) + try Self.writeLegacy(DeviceAuthStoreFile( + version: 1, + deviceId: deviceID, + tokens: [ + "node": DeviceAuthEntry( + token: "legacy-token", + role: "node", + scopes: ["legacy-scope"], + updatedAtMs: 1), + ])) + + let loaded = try #require(DeviceAuthStore.loadToken(deviceId: deviceID, role: "node")) + #expect(loaded.token == "sqlite-token") + #expect(loaded.scopes == ["sqlite-scope"]) + #expect(try !FileManager.default.fileExists(atPath: Self.authURL().path)) + } + + @Test(.stateDirectoryIsolated) + func `failed legacy removal cannot commit a clear`() throws { + let deviceID = "removal-failure-device" + try Self.writeLegacy(DeviceAuthStoreFile( + version: 1, + deviceId: deviceID, + tokens: [ + "node": DeviceAuthEntry( + token: "legacy-token", + role: "node", + scopes: [], + updatedAtMs: 100), + ])) + let authURL = try Self.authURL() + let identityDirectory = authURL.deletingLastPathComponent() + try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: identityDirectory.path) + defer { + try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: identityDirectory.path) + } + + DeviceAuthStore.clearToken(deviceId: deviceID, role: "node") + + #expect(FileManager.default.fileExists(atPath: authURL.path)) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: identityDirectory.path) + #expect(DeviceAuthStore.loadToken(deviceId: deviceID, role: "node")?.token == "legacy-token") + #expect(!FileManager.default.fileExists(atPath: authURL.path)) + } + + @Test(.stateDirectoryIsolated) + func `temporary legacy access failure remains retryable`() throws { + let deviceID = "access-failure-device" + try Self.writeLegacy(DeviceAuthStoreFile( + version: 1, + deviceId: deviceID, + tokens: [ + "node": DeviceAuthEntry( + token: "legacy-token", + role: "node", + scopes: [], + updatedAtMs: 100), + ])) + let authURL = try Self.authURL() + let identityDirectory = authURL.deletingLastPathComponent() + try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: identityDirectory.path) + defer { + try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: identityDirectory.path) + } + + #expect(!DeviceAuthStore.storeTokenPersisted( + deviceId: deviceID, + role: "operator", + token: "must-not-persist")) + + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: identityDirectory.path) + #expect(FileManager.default.fileExists(atPath: authURL.path)) + #expect(DeviceAuthStore.loadToken(deviceId: deviceID, role: "node")?.token == "legacy-token") + #expect(!FileManager.default.fileExists(atPath: authURL.path)) + } + + @Test(.stateDirectoryIsolated) + func `failed invalid-file quarantine aborts the requested write`() throws { + let authURL = try Self.authURL() + let identityDirectory = authURL.deletingLastPathComponent() + try FileManager.default.createDirectory(at: identityDirectory, withIntermediateDirectories: true) + try Data([0xFF]).write(to: authURL) + try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: identityDirectory.path) + defer { + try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: identityDirectory.path) + } + + #expect(!DeviceAuthStore.storeTokenPersisted( + deviceId: "quarantine-device", + role: "node", + token: "must-not-persist")) + #expect(FileManager.default.fileExists(atPath: authURL.path)) + + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: identityDirectory.path) + #expect(DeviceAuthStore.storeTokenPersisted( + deviceId: "quarantine-device", + role: "node", + token: "sqlite-token")) + #expect(DeviceAuthStore.loadToken( + deviceId: "quarantine-device", + role: "node")?.token == "sqlite-token") + } + + @Test(.stateDirectoryIsolated) + func `corrupt legacy file is quarantined and SQLite remains writable`() throws { + let authURL = try Self.authURL() + try FileManager.default.createDirectory( + at: authURL.deletingLastPathComponent(), + withIntermediateDirectories: true) + try Data([0xFF, 0x00, 0xAA]).write(to: authURL) + + #expect(DeviceAuthStore.storeTokenPersisted( + deviceId: "corrupt-device", + role: "node", + token: "sqlite-token")) + #expect(DeviceAuthStore.loadToken( + deviceId: "corrupt-device", + role: "node")?.token == "sqlite-token") + #expect(!FileManager.default.fileExists(atPath: authURL.path)) + let quarantined = try FileManager.default.contentsOfDirectory( + atPath: authURL.deletingLastPathComponent().path) + .filter { $0.hasPrefix("device-auth.json.invalid-") } + #expect(quarantined.count == 1) + } + + @Test(.stateDirectoryIsolated) + func `clear token distinguishes one scope from every scope`() { + let deviceID = "clear-device" + _ = DeviceAuthStore.storeToken(deviceId: deviceID, role: "node", token: "unscoped") + _ = DeviceAuthStore.storeToken( + deviceId: deviceID, + role: "node", + token: "gateway-a", + gatewayID: "gateway-a") + _ = DeviceAuthStore.storeToken( + deviceId: deviceID, + role: "node", + token: "gateway-b", + gatewayID: "gateway-b") + _ = DeviceAuthStore.storeToken( + deviceId: deviceID, + role: "operator", + token: "operator", + gatewayID: "gateway-a") + + DeviceAuthStore.clearToken(deviceId: deviceID, role: "node", gatewayID: "gateway-a") + #expect(DeviceAuthStore.loadToken(deviceId: deviceID, role: "node")?.token == "unscoped") + #expect(DeviceAuthStore.loadToken( + deviceId: deviceID, + role: "node", + gatewayID: "gateway-a") == nil) + #expect(DeviceAuthStore.loadToken( + deviceId: deviceID, + role: "node", + gatewayID: "gateway-b")?.token == "gateway-b") + + DeviceAuthStore.clearToken(deviceId: deviceID, role: "node") + #expect(DeviceAuthStore.loadToken(deviceId: deviceID, role: "node") == nil) + #expect(DeviceAuthStore.loadToken( + deviceId: deviceID, + role: "node", + gatewayID: "gateway-b") == nil) + #expect(DeviceAuthStore.loadToken( + deviceId: deviceID, + role: "operator", + gatewayID: "gateway-a")?.token == "operator") + } + + @Test(.stateDirectoryIsolated) + func `clear all removes only the selected profile identity rows`() { + let primary = DeviceIdentityStore.loadOrCreate(profile: .primary) + let node = DeviceIdentityStore.loadOrCreate(profile: .node) + let share = DeviceIdentityStore.loadOrCreate(profile: .shareExtension) + _ = DeviceAuthStore.storeToken(deviceId: primary.deviceId, role: "node", token: "primary") + _ = DeviceAuthStore.storeToken( + deviceId: node.deviceId, + role: "node", + token: "node", + profile: .node) + _ = DeviceAuthStore.storeToken( + deviceId: share.deviceId, + role: "node", + token: "share", + profile: .shareExtension) + + DeviceAuthStore.clearAll(profile: .shareExtension) + + #expect(DeviceAuthStore.loadToken(deviceId: primary.deviceId, role: "node")?.token == "primary") + #expect(DeviceAuthStore.loadToken( + deviceId: node.deviceId, + role: "node", + profile: .node)?.token == "node") + #expect(DeviceAuthStore.loadToken( + deviceId: share.deviceId, + role: "node", + profile: .shareExtension) == nil) + } + + @Test(.stateDirectoryIsolated) + func `clear all without an identity leaves legacy state untouched`() throws { + let legacy = DeviceAuthStoreFile( + version: 1, + deviceId: "orphaned-device", + tokens: [ + "node": DeviceAuthEntry( + token: "legacy-token", + role: "node", + scopes: [], + updatedAtMs: 100), + ]) + try Self.writeLegacy(legacy) + let authURL = try Self.authURL() + let original = try Data(contentsOf: authURL) + + DeviceAuthStore.clearAll() + + #expect(try Data(contentsOf: authURL) == original) + #expect(try !FileManager.default.fileExists(atPath: Self.databaseURL().path)) + } + + @Test(.stateDirectoryIsolated) + func `version zero bootstrap creates the exact composite key table`() throws { + #expect(DeviceAuthStore.storeTokenPersisted( + deviceId: "bootstrap-device", + role: "node", + token: "bootstrap-token")) + let databaseURL = try Self.databaseURL() + + #expect(try Self.scalarInt(databaseURL, "PRAGMA user_version") == 0) + #expect(try Self.scalarText( + databaseURL, + """ + SELECT group_concat(name || ':' || pk, ',') + FROM (SELECT name, pk FROM pragma_table_xinfo('device_auth_tokens') WHERE pk > 0 ORDER BY cid) + """) == "device_id:1,role:2") + #expect(try Self.scalarText( + databaseURL, + """ + SELECT group_concat(name || ':' || "desc", ',') + FROM ( + SELECT name, "desc" FROM pragma_index_xinfo('idx_device_auth_tokens_updated') + WHERE key = 1 ORDER BY seqno + ) + """) == "updated_at_ms:1,device_id:0,role:0") + } + + @Test(.stateDirectoryIsolated) + func `versioned database never synthesizes a missing auth table`() throws { + let databaseURL = try Self.databaseURL() + try Self.execute(databaseURL, """ + CREATE TABLE schema_meta ( + meta_key TEXT NOT NULL PRIMARY KEY, + role TEXT NOT NULL, + schema_version INTEGER NOT NULL + ) STRICT; + INSERT INTO schema_meta (meta_key, role, schema_version) VALUES ('primary', 'global', 6); + PRAGMA user_version = 6; + """) + + #expect(!DeviceAuthStore.storeTokenPersisted( + deviceId: "versioned-device", + role: "node", + token: "must-not-persist")) + #expect(try Self.scalarInt( + databaseURL, + "SELECT COUNT(*) FROM sqlite_schema WHERE type = 'table' AND name = 'device_auth_tokens'") == 0) + } + +} + +extension DeviceAuthStoreTests { + private static func stateDirectoryURL() throws -> URL { + let path = try #require(getenv("OPENCLAW_STATE_DIR").map { String(cString: $0) }) + return URL(fileURLWithPath: path, isDirectory: true) + } + + private static func databaseURL() throws -> URL { + try self.stateDirectoryURL() + .appendingPathComponent("state", isDirectory: true) + .appendingPathComponent("openclaw.sqlite", isDirectory: false) + } + + private static func authURL( + profile: GatewayDeviceIdentityProfile = .primary) throws -> URL + { + try self.stateDirectoryURL() + .appendingPathComponent("identity", isDirectory: true) + .appendingPathComponent(profile.authFileName, isDirectory: false) + } + + private static func writeLegacy( + _ store: DeviceAuthStoreFile, + profile: GatewayDeviceIdentityProfile = .primary) throws + { + let url = try self.authURL(profile: profile) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) + try JSONEncoder().encode(store).write(to: url, options: [.atomic]) + } + + private static func storageComponent(_ value: String) -> String { + Data(value.utf8).base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + private static func execute(_ databaseURL: URL, _ sql: String) throws { + try FileManager.default.createDirectory( + at: databaseURL.deletingLastPathComponent(), + withIntermediateDirectories: true) + var database: OpaquePointer? + guard sqlite3_open(databaseURL.path, &database) == SQLITE_OK, let database else { + throw DeviceIdentityStore.storageError("Could not open test database") + } + defer { sqlite3_close(database) } + guard sqlite3_exec(database, sql, nil, nil, nil) == SQLITE_OK else { + throw DeviceIdentityStore.storageError(String(cString: sqlite3_errmsg(database))) + } + } + + private static func scalarInt(_ databaseURL: URL, _ sql: String) throws -> Int64 { + try self.scalar(databaseURL, sql) { sqlite3_column_int64($0, 0) } + } + + private static func scalarText(_ databaseURL: URL, _ sql: String) throws -> String? { + try self.scalar(databaseURL, sql) { statement in + sqlite3_column_text(statement, 0).map { String(cString: $0) } + } + } + + private static func scalar( + _ databaseURL: URL, + _ sql: String, + transform: (OpaquePointer) -> T) throws -> T + { + var database: OpaquePointer? + guard sqlite3_open(databaseURL.path, &database) == SQLITE_OK, let database else { + throw DeviceIdentityStore.storageError("Could not open test database") + } + defer { sqlite3_close(database) } + var statement: OpaquePointer? + guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK, let statement else { + throw DeviceIdentityStore.storageError(String(cString: sqlite3_errmsg(database))) + } + defer { sqlite3_finalize(statement) } + guard sqlite3_step(statement) == SQLITE_ROW else { + throw DeviceIdentityStore.storageError(String(cString: sqlite3_errmsg(database))) + } + return transform(statement) + } +} diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceIdentityStoreTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceIdentityStoreTests.swift index 966f35ec184e..dd31d1c69f82 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceIdentityStoreTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceIdentityStoreTests.swift @@ -189,12 +189,12 @@ struct DeviceIdentityStoreTests { gatewayID: nextLineOwner)?.token == "next-line-token") let stateDirPath = try #require(getenv("OPENCLAW_STATE_DIR").map { String(cString: $0) }) - let authURL = URL(fileURLWithPath: stateDirPath, isDirectory: true) - .appendingPathComponent("identity", isDirectory: true) - .appendingPathComponent("device-auth.json", isDirectory: false) - let raw = try #require(JSONSerialization.jsonObject(with: Data(contentsOf: authURL)) as? [String: Any]) - let tokens = try #require(raw["tokens"] as? [String: Any]) - #expect(tokens.count == 3) + let stateDirURL = URL(fileURLWithPath: stateDirPath, isDirectory: true) + #expect(try Self.scalarInt( + stateDirURL.appendingPathComponent("state/openclaw.sqlite"), + "SELECT COUNT(*) FROM device_auth_tokens WHERE device_id = '\(deviceID)'") == 3) + #expect(!FileManager.default.fileExists( + atPath: stateDirURL.appendingPathComponent("identity/device-auth.json").path)) DeviceAuthStore.clearToken(deviceId: deviceID, role: "node", gatewayID: decomposedOwner) #expect(DeviceAuthStore.loadToken( @@ -373,7 +373,7 @@ struct DeviceIdentityStoreTests { } @Test(.stateDirectoryIsolated) - func `secondary profiles use separate identity rows and auth files`() throws { + func `secondary profiles use separate identity and auth rows`() throws { let primaryIdentity = DeviceIdentityStore.loadOrCreate() let nodeIdentity = DeviceIdentityStore.loadOrCreate(profile: .node) let shareIdentity = DeviceIdentityStore.loadOrCreate(profile: .shareExtension) @@ -402,10 +402,13 @@ struct DeviceIdentityStoreTests { #expect(try Self.scalarInt( stateDir.appendingPathComponent("state/openclaw.sqlite"), "SELECT COUNT(*) FROM device_identities") == 3) - #expect(FileManager.default.fileExists(atPath: identityDir.appendingPathComponent("device-auth.json").path)) - #expect(FileManager.default + #expect(try Self.scalarInt( + stateDir.appendingPathComponent("state/openclaw.sqlite"), + "SELECT COUNT(*) FROM device_auth_tokens") == 3) + #expect(!FileManager.default.fileExists(atPath: identityDir.appendingPathComponent("device-auth.json").path)) + #expect(!FileManager.default .fileExists(atPath: identityDir.appendingPathComponent("node-device-auth.json").path)) - #expect(FileManager.default + #expect(!FileManager.default .fileExists(atPath: identityDir.appendingPathComponent("share-device-auth.json").path)) #expect(DeviceAuthStore.loadToken(deviceId: primaryIdentity.deviceId, role: "node")?.token == "primary-token") #expect(DeviceAuthStore.loadToken( @@ -955,7 +958,7 @@ struct DeviceIdentityStoreTests { } @Test - func `migration copies auth without clobbering or removing its source`() throws { + func `migration imports auth rows without removing its source`() throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: tempDir) } @@ -964,58 +967,32 @@ struct DeviceIdentityStoreTests { stateDirURL: sourceRoot, profile: .primary, contents: Self.nodePEMIdentityJSON()) - let auth = "{\"version\":1,\"deviceId\":\"\(Self.fixtureDeviceID)\",\"tokens\":{}}" + let auth = """ + {"version":1,"deviceId":"\(Self + .fixtureDeviceID)","tokens":{"node":{"token":"source-token","role":"node","scopes":["write"],"updatedAtMs":100}}} + """ try auth.write(to: source.authURL, atomically: true, encoding: .utf8) let destination = tempDir.appendingPathComponent("legacy", isDirectory: true) + let databaseURL = destination.appendingPathComponent("state/openclaw.sqlite") let destinationAuthURL = destination.appendingPathComponent("identity/device-auth.json") _ = try DeviceIdentitySQLiteStore.loadOrCreate( - databaseURL: destination.appendingPathComponent("openclaw.sqlite"), + databaseURL: databaseURL, destinationStateDirURL: destination, profile: .primary, legacySources: [source]) #expect(!FileManager.default.fileExists(atPath: source.identityURL.path)) #expect(try String(contentsOf: source.authURL, encoding: .utf8) == auth) - #expect(try String(contentsOf: destinationAuthURL, encoding: .utf8) == auth) + #expect(!FileManager.default.fileExists(atPath: destinationAuthURL.path)) + #expect(try Self.scalarText( + databaseURL, + "SELECT token FROM device_auth_tokens WHERE device_id = '\(Self.fixtureDeviceID)' AND role = 'node'") == + "source-token") } @Test - func `migration rejects destination auth owned by another device`() throws { - let tempDir = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - defer { try? FileManager.default.removeItem(at: tempDir) } - let sourceRoot = tempDir.appendingPathComponent("shared", isDirectory: true) - let source = try Self.writeLegacyIdentity( - stateDirURL: sourceRoot, - profile: .primary, - contents: Self.nodePEMIdentityJSON()) - let sourceAuth = "{\"version\":1,\"deviceId\":\"\(Self.fixtureDeviceID)\",\"tokens\":{}}" - try sourceAuth.write(to: source.authURL, atomically: true, encoding: .utf8) - let destination = tempDir.appendingPathComponent("legacy", isDirectory: true) - let destinationAuthURL = destination.appendingPathComponent("identity/device-auth.json") - try FileManager.default.createDirectory( - at: destinationAuthURL.deletingLastPathComponent(), - withIntermediateDirectories: true) - let destinationAuth = #"{"version":1,"deviceId":"another-device","tokens":{}}"# - try destinationAuth.write(to: destinationAuthURL, atomically: true, encoding: .utf8) - - #expect(throws: NSError.self) { - try DeviceIdentitySQLiteStore.loadOrCreate( - databaseURL: destination.appendingPathComponent("openclaw.sqlite"), - destinationStateDirURL: destination, - profile: .primary, - legacySources: [source]) - } - - #expect(FileManager.default.fileExists(atPath: source.identityURL.path)) - #expect(try String(contentsOf: source.authURL, encoding: .utf8) == sourceAuth) - #expect(try String(contentsOf: destinationAuthURL, encoding: .utf8) == destinationAuth) - } - - // swiftlint:disable line_length - @Test - func `migration rejects stale destination auth for the same device`() throws { + func `migration preserves canonical destination auth rows`() throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: tempDir) } @@ -1030,28 +1007,147 @@ struct DeviceIdentityStoreTests { """ try sourceAuth.write(to: source.authURL, atomically: true, encoding: .utf8) let destination = tempDir.appendingPathComponent("legacy", isDirectory: true) + let databaseURL = destination.appendingPathComponent("state/openclaw.sqlite") + try FileManager.default.createDirectory( + at: databaseURL.deletingLastPathComponent(), + withIntermediateDirectories: true) + try Self.execute(databaseURL, """ + CREATE TABLE device_auth_tokens ( + device_id TEXT NOT NULL, + role TEXT NOT NULL, + token TEXT NOT NULL, + scopes_json TEXT NOT NULL, + updated_at_ms INTEGER NOT NULL, + PRIMARY KEY (device_id, role) + ) STRICT; + CREATE INDEX idx_device_auth_tokens_updated + ON device_auth_tokens(updated_at_ms DESC, device_id, role); + INSERT INTO device_auth_tokens (device_id, role, token, scopes_json, updated_at_ms) + VALUES ('\(Self.fixtureDeviceID)', 'node', 'destination-token', '[]', 200); + """) + + _ = try DeviceIdentitySQLiteStore.loadOrCreate( + databaseURL: databaseURL, + destinationStateDirURL: destination, + profile: .primary, + legacySources: [source]) + + #expect(!FileManager.default.fileExists(atPath: source.identityURL.path)) + #expect(try String(contentsOf: source.authURL, encoding: .utf8) == sourceAuth) + #expect(try Self.scalarText( + databaseURL, + "SELECT token FROM device_auth_tokens WHERE device_id = '\(Self.fixtureDeviceID)' AND role = 'node'") == + "destination-token") + } + + @Test + func `migration imports destination legacy auth before source auth`() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + let source = try Self.writeLegacyIdentity( + stateDirURL: tempDir.appendingPathComponent("source", isDirectory: true), + profile: .primary, + contents: Self.nodePEMIdentityJSON()) + let sourceAuth = """ + {"version":1,"deviceId":"\(Self + .fixtureDeviceID)","tokens":{"node":{"token":"source-token","role":"node","scopes":[],"updatedAtMs":100}}} + """ + try sourceAuth.write(to: source.authURL, atomically: true, encoding: .utf8) + let destination = tempDir.appendingPathComponent("destination", isDirectory: true) let destinationAuthURL = destination.appendingPathComponent("identity/device-auth.json") try FileManager.default.createDirectory( at: destinationAuthURL.deletingLastPathComponent(), withIntermediateDirectories: true) - let destinationAuth = "{\"version\":1,\"deviceId\":\"\(Self.fixtureDeviceID)\",\"tokens\":{}}" + let destinationAuth = sourceAuth.replacingOccurrences(of: "source-token", with: "destination-token") try destinationAuth.write(to: destinationAuthURL, atomically: true, encoding: .utf8) + let databaseURL = destination.appendingPathComponent("state/openclaw.sqlite") + + _ = try DeviceIdentitySQLiteStore.loadOrCreate( + databaseURL: databaseURL, + destinationStateDirURL: destination, + profile: .primary, + legacySources: [source]) + + #expect(!FileManager.default.fileExists(atPath: source.identityURL.path)) + #expect(try String(contentsOf: source.authURL, encoding: .utf8) == sourceAuth) + #expect(!FileManager.default.fileExists(atPath: destinationAuthURL.path)) + #expect(try Self.scalarText( + databaseURL, + "SELECT token FROM device_auth_tokens WHERE device_id = '\(Self.fixtureDeviceID)' AND role = 'node'") == + "destination-token") + } + + @Test + func `source auth access failure preserves the identity claim for retry`() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + let source = try Self.writeLegacyIdentity( + stateDirURL: tempDir.appendingPathComponent("source", isDirectory: true), + profile: .primary, + contents: Self.nodePEMIdentityJSON()) + let sourceAuth = """ + {"version":1,"deviceId":"\(Self.fixtureDeviceID)","tokens":{}} + """ + try sourceAuth.write(to: source.authURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: source.authURL.path) + defer { + try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: source.authURL.path) + } + let destination = tempDir.appendingPathComponent("destination", isDirectory: true) #expect(throws: NSError.self) { try DeviceIdentitySQLiteStore.loadOrCreate( - databaseURL: destination.appendingPathComponent("openclaw.sqlite"), + databaseURL: destination.appendingPathComponent("state/openclaw.sqlite"), destinationStateDirURL: destination, profile: .primary, legacySources: [source]) } #expect(FileManager.default.fileExists(atPath: source.identityURL.path)) - #expect(try String(contentsOf: source.authURL, encoding: .utf8) == sourceAuth) - #expect(try String(contentsOf: destinationAuthURL, encoding: .utf8) == destinationAuth) + #expect(FileManager.default.fileExists(atPath: source.authURL.path)) } @Test - func `migration accepts equivalent auth with reordered scopes`() throws { + func `migration rejects conflicting source auth stores`() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + let identityJSON = try Self.nodePEMIdentityJSON() + let first = try Self.writeLegacyIdentity( + stateDirURL: tempDir.appendingPathComponent("first", isDirectory: true), + profile: .primary, + contents: identityJSON) + let second = try Self.writeLegacyIdentity( + stateDirURL: tempDir.appendingPathComponent("second", isDirectory: true), + profile: .primary, + contents: identityJSON) + let firstAuth = """ + {"version":1,"deviceId":"\(Self + .fixtureDeviceID)","tokens":{"node":{"token":"first-token","role":"node","scopes":[],"updatedAtMs":100}}} + """ + let secondAuth = firstAuth.replacingOccurrences(of: "first-token", with: "second-token") + try firstAuth.write(to: first.authURL, atomically: true, encoding: .utf8) + try secondAuth.write(to: second.authURL, atomically: true, encoding: .utf8) + let destination = tempDir.appendingPathComponent("destination", isDirectory: true) + + #expect(throws: NSError.self) { + try DeviceIdentitySQLiteStore.loadOrCreate( + databaseURL: destination.appendingPathComponent("state/openclaw.sqlite"), + destinationStateDirURL: destination, + profile: .primary, + legacySources: [first, second]) + } + + #expect(FileManager.default.fileExists(atPath: first.identityURL.path)) + #expect(FileManager.default.fileExists(atPath: second.identityURL.path)) + #expect(try String(contentsOf: first.authURL, encoding: .utf8) == firstAuth) + #expect(try String(contentsOf: second.authURL, encoding: .utf8) == secondAuth) + } + + @Test + func `migration normalizes imported auth scopes`() throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: tempDir) } @@ -1066,28 +1162,22 @@ struct DeviceIdentityStoreTests { """ try sourceAuth.write(to: source.authURL, atomically: true, encoding: .utf8) let destination = tempDir.appendingPathComponent("legacy", isDirectory: true) - let destinationAuthURL = destination.appendingPathComponent("identity/device-auth.json") - try FileManager.default.createDirectory( - at: destinationAuthURL.deletingLastPathComponent(), - withIntermediateDirectories: true) - let destinationAuth = """ - {"version":1,"deviceId":"\(Self - .fixtureDeviceID)","tokens":{"node":{"token":"source-token","role":"node","scopes":["read","write"],"updatedAtMs":100}}} - """ - try destinationAuth.write(to: destinationAuthURL, atomically: true, encoding: .utf8) + let databaseURL = destination.appendingPathComponent("state/openclaw.sqlite") _ = try DeviceIdentitySQLiteStore.loadOrCreate( - databaseURL: destination.appendingPathComponent("openclaw.sqlite"), + databaseURL: databaseURL, destinationStateDirURL: destination, profile: .primary, legacySources: [source]) #expect(!FileManager.default.fileExists(atPath: source.identityURL.path)) - #expect(try String(contentsOf: destinationAuthURL, encoding: .utf8) == destinationAuth) + #expect(try String(contentsOf: source.authURL, encoding: .utf8) == sourceAuth) + #expect(try Self.scalarText( + databaseURL, + "SELECT scopes_json FROM device_auth_tokens WHERE device_id = '\(Self.fixtureDeviceID)' AND role = 'node'") == + "[\"read\",\"write\"]") } - // swiftlint:enable line_length - @Test func `preserves WAL journal mode`() throws { let tempDir = FileManager.default.temporaryDirectory diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift index 888941b56198..7a8d58847e47 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift @@ -3098,7 +3098,9 @@ struct GatewayNodeSessionTests { #expect(shareDeviceId != primaryIdentity.deviceId) #expect(DeviceAuthStore.loadToken(deviceId: primaryIdentity.deviceId, role: "node")? .token == "primary-node-token") - #expect(DeviceAuthStore.loadToken(deviceId: shareDeviceId, role: "node") == nil) + // Profile selects identity resolution, not a token namespace; (device_id, role) is the canonical key. + // Per-profile identities keep caches disjoint in practice, and Node reads the same table by that key. + #expect(DeviceAuthStore.loadToken(deviceId: shareDeviceId, role: "node")?.token == "share-node-token") #expect( DeviceAuthStore .loadToken(deviceId: shareDeviceId, role: "node", profile: .shareExtension)?.token ==