Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 76 additions & 26 deletions Sources/CodexerCore/ClaudeUsageClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProfileRateLimits, Never>]] = [:]

Expand All @@ -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)
}
Expand All @@ -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)
}

Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -683,25 +698,43 @@ 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 }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Re-read a rotated Desktop key

If Claude recreates or rotates its Safe Storage item while AgentDock remains open, this unconditional return keeps using the obsolete derived key even during an interactive manual refresh. The newly encrypted token caches then fail to decrypt, and allowKeychainInteraction: true never causes another Keychain read, so live usage remains unavailable until AgentDock is restarted. Retry the Keychain lookup when the retained key cannot decrypt the current profile state.

Useful? React with 👍 / 👎.

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,
kSecMatchLimit as String: kSecMatchLimitOne,
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 {
Expand Down Expand Up @@ -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<T>(_ 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()
}
}
80 changes: 80 additions & 0 deletions Tests/CodexerCoreTests/ClaudeCredentialReaderTests.swift
Original file line number Diff line number Diff line change
@@ -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
))
}
}
23 changes: 23 additions & 0 deletions Tests/CodexerCoreTests/ClaudeUsageClientTests.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Security
import XCTest
@testable import CodexerCore

Expand Down Expand Up @@ -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.")
Expand Down
8 changes: 6 additions & 2 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading