Desktop: harden auth-token storage and local automation bridge#6715
Desktop: harden auth-token storage and local automation bridge#6715
Conversation
Backs the Firebase ID and refresh token with macOS Keychain (kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly) so another app or user account on the same Mac cannot read them out of the app's UserDefaults plist. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…sign-out Migrates id_token and refresh_token from UserDefaults (readable by any other process running as the same macOS user) to the Keychain. Adds a lazy migration on first read after upgrade and purges the legacy plaintext copy. Also clears playwrightExtensionToken during signOut so a subsequent sign-in on the same Mac does not inherit the previous account's Chrome session. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… auth Before: when enabled via --automation-bridge or OMI_ENABLE_LOCAL_AUTOMATION=1, the NWListener bound to all interfaces (no host restriction) and every route ran without authentication, so any process on the same machine or on the same LAN could POST /gmail-read and extract the signed-in user's recent emails. Now: - parameters.requiredInterfaceType = .loopback prevents binding to anything other than lo0. - Defense-in-depth check rejects any non-loopback peer endpoint before reading a byte from it. - A 256-bit bearer token is generated via SecRandomCopyBytes on each launch, written to ~/Library/Application Support/Omi/automation-bridge.token with mode 0600, and required in the Authorization: Bearer header on every route (including /health). The compare is constant-time. Automation clients (agent-swift etc.) must now read the token file and send it on every request. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Greptile SummaryThis PR hardens three security gaps in the macOS desktop app: Firebase auth tokens moved from
Confidence Score: 4/5Hold for the migration data-loss fix before shipping to users upgrading from UserDefaults storage. One P1 defect exists on the migration path: UserDefaults is cleared unconditionally even when the Keychain write fails, which will silently sign out any upgrading user whose Keychain write errors. This affects the entire upgrading population and is the primary user-visible risk of this PR. All other findings are P2. The automation bridge hardening and playwrightExtensionToken clearance are correct. desktop/Desktop/Sources/AuthService.swift — readStoredToken() migration logic (lines 809–819)
|
| Filename | Overview |
|---|---|
| desktop/Desktop/Sources/AuthService.swift | Token migration in readStoredToken() unconditionally deletes the UserDefaults copy before confirming the Keychain write succeeded, risking silent sign-out on upgrade for any user where SecItemAdd fails; remaining changes (clearTokens, playwrightExtensionToken clearance) are correct. |
| desktop/Desktop/Sources/DesktopAutomationBridge.swift | Loopback binding, defense-in-depth endpoint check, and bearer token auth are correctly implemented; token file has a brief window with loose permissions between atomic write and setAttributes() — low-severity given the 0700 parent directory. |
| desktop/Desktop/Sources/AuthKeychain.swift | New Keychain helper for Firebase token storage; correct use of kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, but clearAll() is dead code that may cause future divergence with clearTokens(). |
| desktop/CHANGELOG.json | Three user-facing changelog entries correctly summarizing the security hardening changes. |
Sequence Diagram
sequenceDiagram
participant App as macOS App
participant KCH as AuthKeychain
participant UD as UserDefaults
participant DAB as DesktopAutomationBridge
participant TF as token file (0600)
participant Client as Automation Client
Note over App,UD: First launch after upgrade (migration)
App->>KCH: get(key) returns nil
App->>UD: string(forKey: key) returns legacy value
App->>KCH: set(legacy, forKey:) via SecItemAdd
Note over KCH: If SecItemAdd fails, deletion still proceeds
App->>UD: removeObject(forKey:) unconditional
App-->>App: returns legacy (OK this session)
Note over App,UD: Next launch: token gone from both stores
Note over DAB,Client: Bridge startup (hardened)
App->>DAB: startIfNeeded()
DAB->>DAB: generateSessionToken() via SecRandomCopyBytes
DAB->>TF: writeTokenFile() dir=0700 file 0644 then 0600
DAB->>DAB: NWListener with requiredInterfaceType loopback
Client->>TF: read token from file
Client->>DAB: GET /health with Authorization header
DAB->>DAB: isLoopbackEndpoint() + secureCompare()
DAB-->>Client: 200 OK or 401 Unauthorized
Reviews (1): Last reviewed commit: "Add changelog entries for desktop securi..." | Re-trigger Greptile
| private func readStoredToken(forKey key: String) -> String? { | ||
| if let value = AuthKeychain.get(key), !value.isEmpty { | ||
| return value | ||
| } | ||
| if let legacy = UserDefaults.standard.string(forKey: key), !legacy.isEmpty { | ||
| AuthKeychain.set(legacy, forKey: key) | ||
| UserDefaults.standard.removeObject(forKey: key) | ||
| NSLog("OMI AUTH: Migrated %@ from UserDefaults to Keychain", key) | ||
| return legacy | ||
| } | ||
| return nil |
There was a problem hiding this comment.
Migration deletes UserDefaults token before confirming Keychain write
UserDefaults.removeObject(forKey: key) is called unconditionally, even when AuthKeychain.set() silently fails. If SecItemAdd fails (Keychain locked at first launch, ad-hoc signing on dev builds, Keychain corruption), the token is permanently gone from UserDefaults while absent from Keychain — causing a silent, unexplained sign-out the next time the app launches, for every user who upgrades.
The fix is to call AuthKeychain.get(key) after the write and only purge the UserDefaults copy when the read-back succeeds:
if let legacy = UserDefaults.standard.string(forKey: key), !legacy.isEmpty {
AuthKeychain.set(legacy, forKey: key)
if AuthKeychain.get(key) != nil {
UserDefaults.standard.removeObject(forKey: key)
} else {
// Keychain write failed — keep the UserDefaults copy so the next
// launch can retry migration instead of losing the session.
}
return legacy
}| private static func writeTokenFile(_ token: String) throws { | ||
| let url = tokenFileURL | ||
| let fm = FileManager.default | ||
| try fm.createDirectory( | ||
| at: url.deletingLastPathComponent(), withIntermediateDirectories: true, | ||
| attributes: [.posixPermissions: 0o700]) | ||
| try token.write(to: url, atomically: true, encoding: .utf8) | ||
| try fm.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) | ||
| } |
There was a problem hiding this comment.
Brief world-readable window between atomic write and permission tightening
String.write(to:atomically:encoding:) creates a temp file with umask-derived permissions (typically 0644 with the default macOS umask of 022), renames it to the final path, and only then does setAttributes([.posixPermissions: 0o600]) narrow it to 0600. Any process running as the same macOS user can read the token file during this window.
The parent directory is correctly set to 0700, so processes owned by other users are already blocked. The risk is limited to other processes running as the same user watching this specific file. A more robust approach is to use a POSIX open() call with permissions set at creation time:
let fileMode: UInt16 = 0o600
let fd = open(url.path, O_CREAT | O_WRONLY | O_TRUNC, fileMode)
guard fd >= 0 else { throw CocoaError(.fileNoSuchFile) }
defer { close(fd) }
let tokenData = Data(token.utf8)
_ = tokenData.withUnsafeBytes { write(fd, $0.baseAddress!, $0.count) }| static func clearAll() { | ||
| let query: [String: Any] = [ | ||
| kSecClass as String: kSecClassGenericPassword, | ||
| kSecAttrService as String: service, | ||
| ] | ||
| SecItemDelete(query as CFDictionary) | ||
| } |
There was a problem hiding this comment.
clearAll() is unused; clearTokens() deletes keys individually
AuthKeychain.clearAll() deletes every Keychain item for the service in one call, but AuthService.clearTokens() calls AuthKeychain.set(nil, forKey:) twice (once per key). Both approaches work for the current two-key schema, but if a future commit adds a third Keychain key and only updates clearAll() (not clearTokens()), that key won't be purged on sign-out.
Consider either (a) calling AuthKeychain.clearAll() from clearTokens() and removing the individual set(nil, …) calls, or (b) removing clearAll() to eliminate the confusion. As-is, clearAll() is dead code.
| kSecAttrService as String: service, | ||
| kSecAttrAccount as String: key, | ||
| ] | ||
| SecItemDelete(baseQuery as CFDictionary) |
There was a problem hiding this comment.
set() function: SecItemDelete removes the existing keychain item before the guard check. If value is nil or encoding fails after the delete, the old token is permanently lost — the update is not atomic.
Fix: Move SecItemDelete inside the guard block, or use SecItemUpdate for atomic upsert.
| kSecClass as String: kSecClassGenericPassword, | ||
| kSecAttrService as String: service, | ||
| ] | ||
| SecItemDelete(query as CFDictionary) |
There was a problem hiding this comment.
clearAll() calls SecItemDelete but ignores the returned OSStatus. If deletion fails due to Keychain access restrictions, the function silently succeeds — the caller has no way to know tokens were not cleared, leaving stale credentials in place.
Fix: Check and log the status, or propagate an error to the caller.
Summary
Security fixes for the macOS desktop app based on a horizontal-authz audit — each issue lets one user access another's data on a shared Mac or network. Three changes:
UserDefaultsto Keychain. The id/refresh tokens were written to~/Library/Preferences/<bundle>.plist, readable by any non-sandboxed process running as the same macOS user. They now live in the Keychain under the app's bundle-scoped service, markedkSecAttrAccessibleAfterFirstUnlockThisDeviceOnly. On first launch after upgrade, stored tokens are migrated out ofUserDefaultslazily and the plaintext copy is deleted.DesktopAutomationBridgelocked down. When enabled (via--automation-bridge/OMI_ENABLE_LOCAL_AUTOMATION=1), theNWListenerbound to all interfaces and every route ran unauthenticated — soPOST /gmail-readcould exfiltrate the signed-in user's inbox from any process or LAN host. Now: (a)requiredInterfaceType = .loopback, (b) non-loopback endpoints are rejected before reading a byte, (c) a 256-bit per-launch bearer token is generated viaSecRandomCopyBytes, written to~/Library/Application Support/Omi/automation-bridge.token(mode 0600), and required on every route with constant-time comparison.playwrightExtensionTokencleared on sign-out. The token persisted acrosssignOut(); next sign-in on the same Mac inherited the previous user's Chrome session, so "summarize my emails" could return the previous user's Gmail. Now cleared alongside the other onboarding keys inAuthService.signOut.Test plan
UserDefaults; confirm tokens migrate on first launch and app stays signed in.auth_idToken/auth_refreshTokenare removed andplaywrightExtensionTokenis gone fromUserDefaults.OMI_ENABLE_LOCAL_AUTOMATION=1; without the bearer token,curl http://127.0.0.1:47777/healthreturns 401; withAuthorization: Bearer <token-from-file>, returns 200.🤖 Generated with Claude Code