diff --git a/Sources/CodexerCore/ClaudeUsageClient.swift b/Sources/CodexerCore/ClaudeUsageClient.swift index f4e7dd9..e634cd6 100644 --- a/Sources/CodexerCore/ClaudeUsageClient.swift +++ b/Sources/CodexerCore/ClaudeUsageClient.swift @@ -43,6 +43,7 @@ public actor ClaudeUsageClient: ClaudeUsageFetching { } private let session: URLSession + private var credentialReader = ClaudeCredentialReader() private var cache: [String: CachedUsage] = [:] private var refreshWaiters: [String: [CheckedContinuation]] = [:] @@ -64,15 +65,20 @@ public actor ClaudeUsageClient: ClaudeUsageFetching { allowKeychainInteraction: Bool, forceRefresh: Bool = false ) async -> ProfileRateLimits { - let reader = ClaudeCredentialReader(allowKeychainInteraction: allowKeychainInteraction) - let codeCredential = reader.readCodeCredential(homeURL: claudeCodeHomeURL) + let codeCredential = credentialReader.readCodeCredential( + homeURL: claudeCodeHomeURL, + allowKeychainInteraction: allowKeychainInteraction + ) let credential = if let codeCredential, codeCredential.scopes.isEmpty || codeCredential.scopes.contains("user:profile") { codeCredential } else { - reader.readDesktopCredential(userDataURL: claudeUserDataURL) ?? codeCredential + credentialReader.readDesktopCredential( + userDataURL: claudeUserDataURL, + allowKeychainInteraction: allowKeychainInteraction + ) ?? codeCredential } return await fetch(credential, forceRefresh: forceRefresh) } @@ -82,9 +88,10 @@ public actor ClaudeUsageClient: ClaudeUsageFetching { allowKeychainInteraction: Bool, forceRefresh: Bool = false ) async -> ProfileRateLimits { - let credential = ClaudeCredentialReader( + let credential = credentialReader.readDesktopCredential( + userDataURL: claudeUserDataURL, allowKeychainInteraction: allowKeychainInteraction - ).readDesktopCredential(userDataURL: claudeUserDataURL) + ) return await fetch(credential, forceRefresh: forceRefresh) } @@ -94,7 +101,7 @@ public actor ClaudeUsageClient: ClaudeUsageFetching { ) async -> ProfileRateLimits { guard let credential else { return ProfileRateLimits( - errorMessage: "Live usage is unavailable. Open Claude and sign in, then refresh." + errorMessage: "Live usage is unavailable. Open Claude and sign in, then refresh. If macOS asks for Keychain access, choose Always Allow to retain access." ) } guard credential.scopes.isEmpty || credential.scopes.contains("user:profile") else { @@ -466,23 +473,31 @@ enum ClaudeUsageResponseParser { } } -private struct ClaudeCredentialReader { +struct ClaudeCredentialReader { private static let codeService = "Claude Code-credentials" private static let safeStorageService = "Claude Safe Storage" private static let safeStorageAccount = "Claude Key" private static let cacheKeys = ["oauth:tokenCacheV2", "oauth:tokenCache"] - private let allowKeychainInteraction: Bool + // One reader lives on ClaudeUsageClient's actor and shares this Desktop key + // across profiles. Tokens and account identity are still reread for each root. + private var desktopKey: Data? + private let keychain: SecKeychain? - init(allowKeychainInteraction: Bool) { - self.allowKeychainInteraction = allowKeychainInteraction + init(keychain: SecKeychain? = nil) { + self.keychain = keychain } - func readCodeCredential(homeURL: URL) -> ClaudeUsageCredential? { + func readCodeCredential(homeURL: URL, allowKeychainInteraction: Bool) -> ClaudeUsageCredential? { let identity = codeIdentity(homeURL: homeURL) if let text = readKeychainPassword( service: Self.codeService, - account: NSUserName() - ) ?? readKeychainPassword(service: Self.codeService, account: nil), + account: NSUserName(), + allowKeychainInteraction: allowKeychainInteraction + ) ?? readKeychainPassword( + service: Self.codeService, + account: nil, + allowKeychainInteraction: allowKeychainInteraction + ), let credential = parseCredential(text, identity: identity) { return credential @@ -495,18 +510,18 @@ private struct ClaudeCredentialReader { return parseCredential(text, identity: identity) } - func readDesktopCredential(userDataURL: URL) -> ClaudeUsageCredential? { + mutating func readDesktopCredential( + userDataURL: URL, + allowKeychainInteraction: Bool + ) -> ClaudeUsageCredential? { let configURL = userDataURL.appendingPathComponent("config.json") guard let data = try? BoundedFileReader.data( at: configURL, maximumBytes: LocalControlFileLimit.providerCredentialState ), let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let password = readKeychainPassword( - service: Self.safeStorageService, - account: Self.safeStorageAccount - ), - let key = try? deriveKey(password: password) + Self.cacheKeys.contains(where: { root[$0] is String }), + let key = safeStorageKey(allowKeychainInteraction: allowKeychainInteraction) else { return nil } let caches = Self.cacheKeys.compactMap { cacheKey -> [String: Any]? in @@ -683,7 +698,22 @@ private struct ClaudeCredentialReader { return nil } - private func readKeychainPassword(service: String, account: String?) -> String? { + private mutating func safeStorageKey(allowKeychainInteraction: Bool) -> Data? { + if let desktopKey { return desktopKey } + guard let password = readKeychainPassword( + service: Self.safeStorageService, + account: Self.safeStorageAccount, + allowKeychainInteraction: allowKeychainInteraction + ), let key = try? deriveKey(password: password) else { return nil } + desktopKey = key + return key + } + + private func readKeychainPassword( + service: String, + account: String?, + allowKeychainInteraction: Bool + ) -> String? { var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, @@ -691,17 +721,20 @@ private struct ClaudeCredentialReader { kSecReturnData as String: true ] if let account { query[kSecAttrAccount as String] = account } + if let keychain { query[kSecMatchSearchList as String] = [keychain] } if !allowKeychainInteraction { let context = LAContext() context.interactionNotAllowed = true query[kSecUseAuthenticationContext as String] = context } - var result: CFTypeRef? - guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, - let data = result as? Data, - let value = String(data: data, encoding: .utf8) - else { return nil } - return nonempty(value) + return ClaudeKeychainAccess.withInteractionAllowed(allowKeychainInteraction) { + var result: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, + let data = result as? Data, + let value = String(data: data, encoding: .utf8) + else { return nil } + return nonempty(value) + } } private func deriveKey(password: String) throws -> Data { @@ -813,3 +846,20 @@ private extension Data { self.init(bytes) } } + +// Claude uses the file-based login Keychain. LAContext only suppresses UI for +// Data Protection items; legacy Keychain access needs the process-level flag. +// Serialize the synchronous read and restore the prior flag before returning. +enum ClaudeKeychainAccess { + private static let lock = NSLock() + + static func withInteractionAllowed(_ allowed: Bool, operation: () -> T?) -> T? { + lock.lock() + defer { lock.unlock() } + var previous = DarwinBoolean(false) + guard SecKeychainGetUserInteractionAllowed(&previous) == errSecSuccess else { return nil } + guard SecKeychainSetUserInteractionAllowed(allowed) == errSecSuccess else { return nil } + defer { _ = SecKeychainSetUserInteractionAllowed(previous.boolValue) } + return operation() + } +} diff --git a/Tests/CodexerCoreTests/ClaudeCredentialReaderTests.swift b/Tests/CodexerCoreTests/ClaudeCredentialReaderTests.swift new file mode 100644 index 0000000..a226981 --- /dev/null +++ b/Tests/CodexerCoreTests/ClaudeCredentialReaderTests.swift @@ -0,0 +1,80 @@ +import Foundation +import Security +import XCTest +@testable import CodexerCore + +final class ClaudeCredentialReaderTests: XCTestCase { + func testDesktopKeyIsReusedWhileProfileCredentialsAreReadFresh() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + // A real, isolated Keychain with synthetic data; never query the login Keychain. + let password = "agentdock-test-password" + var created: SecKeychain? + let status = password.withCString { bytes in + SecKeychainCreate( + directory.appendingPathComponent("fixture.keychain").path, + UInt32(password.utf8.count), bytes, false, nil, &created + ) + } + XCTAssertEqual(status, errSecSuccess) + let keychain = try XCTUnwrap(created) + defer { _ = SecKeychainDelete(keychain) } + let attributes: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "Claude Safe Storage", + kSecAttrAccount as String: "Claude Key", + kSecUseKeychain as String: keychain, + kSecValueData as String: Data(password.utf8) + ] + XCTAssertEqual(SecItemAdd(attributes as CFDictionary, nil), errSecSuccess) + + let official = directory.appendingPathComponent("official", isDirectory: true) + let managed = directory.appendingPathComponent("managed", isDirectory: true) + try FileManager.default.createDirectory(at: official, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: managed, withIntermediateDirectories: true) + // Electron v10 AES-CBC fixtures, PBKDF2-SHA1(password, "saltysalt", 1003), space IV. + let first = Data(#"{"lastKnownAccountUuid":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","oauth:tokenCacheV2":"djEwE9y4lgzKGR3SkB2bnuiCGyRu9Thl9kfHsh0glwfcgQ7JEx5myhGSAv+phFvoC2MCpK3VgEvAo7iiuBImTOCnJlWWVmFucBH3ZD5Eh54R/FfVs5Q2SWCckEmFJNct5iZHIBFyAscCYa2D1RO4M25aMGiLjpeJiDJbMqCTX5rGF/7DKydfhLrE1L/+mu8ZbSlQ9d0ZUv9lZmmbV3yKSdljpA=="}"#.utf8) + let second = Data(#"{"lastKnownAccountUuid":"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb","oauth:tokenCacheV2":"djEwdg7PGZpJc77QOSdvoS8YSvNkgCSUGYt8jtehMMnLLTWBFO/nW0vRiOmRux5zYnPlMxnx/qWboh+lLeCr44jMIUpJ1p+uh9zLU9+AkB85XR2Ob8uWlNCSZoZDAq8LahFF29uz2K1j6Nbpymx+3jymLP1ZLsHLslNsusecFt9wszx6U/5Z/FYa2swcti8N6tUVfJ3edu1rQVr1kS/l9W6kiA=="}"#.utf8) + try first.write(to: official.appendingPathComponent("config.json")) + try second.write(to: managed.appendingPathComponent("config.json")) + + var reader = ClaudeCredentialReader(keychain: keychain) + XCTAssertEqual(reader.readDesktopCredential( + userDataURL: official, allowKeychainInteraction: false + )?.accessToken, "synthetic-token-one") + + // Remove only our synthetic item. Subsequent success requires the retained key. + let deletion: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "Claude Safe Storage", + kSecAttrAccount as String: "Claude Key", + kSecMatchSearchList as String: [keychain] + ] + XCTAssertEqual(SecItemDelete(deletion as CFDictionary), errSecSuccess) + let other = try XCTUnwrap(reader.readDesktopCredential( + userDataURL: managed, allowKeychainInteraction: false + )) + XCTAssertEqual(other.accessToken, "synthetic-token-two") + XCTAssertEqual(other.identity?.accountUUID, "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb") + XCTAssertEqual(other.identity?.organizationUUID, "22222222-2222-2222-2222-222222222222") + + // A replaced token/account on the same root must not reuse the old credential. + try second.write(to: official.appendingPathComponent("config.json")) + XCTAssertEqual(reader.readDesktopCredential( + userDataURL: official, allowKeychainInteraction: false + )?.accessToken, "synthetic-token-two") + try FileManager.default.removeItem(at: official.appendingPathComponent("config.json")) + XCTAssertNil(reader.readDesktopCredential( + userDataURL: official, allowKeychainInteraction: false + )) + + // A new reader has no persisted key and cannot read the now-missing item. + var freshReader = ClaudeCredentialReader(keychain: keychain) + XCTAssertNil(freshReader.readDesktopCredential( + userDataURL: managed, allowKeychainInteraction: false + )) + } +} diff --git a/Tests/CodexerCoreTests/ClaudeUsageClientTests.swift b/Tests/CodexerCoreTests/ClaudeUsageClientTests.swift index 4f582cb..686eb0a 100644 --- a/Tests/CodexerCoreTests/ClaudeUsageClientTests.swift +++ b/Tests/CodexerCoreTests/ClaudeUsageClientTests.swift @@ -1,3 +1,4 @@ +import Security import XCTest @testable import CodexerCore @@ -36,6 +37,28 @@ final class ClaudeUsageClientTests: XCTestCase { XCTAssertFalse(original.cacheKey.contains(original.accessToken)) } + func testNoninteractiveKeychainReadSuppressesLegacyUIAndRestoresState() throws { + var original = DarwinBoolean(false) + XCTAssertEqual(SecKeychainGetUserInteractionAllowed(&original), errSecSuccess) + defer { _ = SecKeychainSetUserInteractionAllowed(original.boolValue) } + XCTAssertEqual(SecKeychainSetUserInteractionAllowed(true), errSecSuccess) + + let result: Bool? = ClaudeKeychainAccess.withInteractionAllowed(false) { + var allowed = DarwinBoolean(true) + XCTAssertEqual(SecKeychainGetUserInteractionAllowed(&allowed), errSecSuccess) + return allowed.boolValue + } + XCTAssertEqual(result, false) + var restored = DarwinBoolean(false) + XCTAssertEqual(SecKeychainGetUserInteractionAllowed(&restored), errSecSuccess) + XCTAssertTrue(restored.boolValue) + + let missing: String? = ClaudeKeychainAccess.withInteractionAllowed(false) { nil } + XCTAssertNil(missing) + XCTAssertEqual(SecKeychainGetUserInteractionAllowed(&restored), errSecSuccess) + XCTAssertTrue(restored.boolValue) + } + func testInstalledOfficialUsageWhenEnabled() async throws { guard ProcessInfo.processInfo.environment["AGENTDOCK_LIVE_CLAUDE_USAGE_TEST"] == "1" else { throw XCTSkip("Set AGENTDOCK_LIVE_CLAUDE_USAGE_TEST=1 to validate the signed-in official account.") diff --git a/docs/security.md b/docs/security.md index c02e1d7..4cf67f4 100644 --- a/docs/security.md +++ b/docs/security.md @@ -120,8 +120,12 @@ Claude Desktop OAuth access tokens and the active Desktop organization from provider-owned Keychain and local state, then calls Anthropic's OAuth profile and usage endpoints. Tokens are held only for the request; AgentDock never copies them into its own storage, refreshes or rotates them, includes them in -analytics, or logs them. Background reads forbid Keychain interaction; a manual -refresh may show the system access prompt. Managed profiles resolve credentials +analytics, or logs them. The Desktop decryption key is retained only in memory +for the usage client's lifetime and reused across official and managed profiles. +Each profile's tokens and account identity are still read afresh from its files. +Background reads forbid Keychain interaction, including legacy login-Keychain +prompts. A manual refresh can request access; choosing **Always Allow** lets +macOS retain that grant for subsequent launches of the signed app. Managed profiles resolve credentials only from their own Desktop user-data roots. Desktop usage requires a valid account and organization identity, and responses are verified against that identity before display. An explicit identity mismatch clears the corresponding