Skip to content
Closed
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
6 changes: 5 additions & 1 deletion desktop/CHANGELOG.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
{
"unreleased": [],
"unreleased": [
"Hardened sign-in by storing auth tokens in the macOS Keychain instead of UserDefaults",
"Cleared the browser extension token on sign-out so a new account cannot reuse the previous browser session",
"Restricted the local automation bridge to loopback and required a per-launch bearer token"
],
"releases": [
{
"version": "0.11.319",
Expand Down
58 changes: 58 additions & 0 deletions desktop/Desktop/Sources/AuthKeychain.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import Foundation
import Security

/// Keychain-backed storage for sensitive auth tokens (Firebase ID + refresh tokens).
///
/// Items are scoped to the current app bundle, marked
/// `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`, and are NOT included in a
/// keychain access group — so another app running as the same macOS user cannot
/// read them the way it can read the app's UserDefaults plist.
enum AuthKeychain {
private static var service: String {
(Bundle.main.bundleIdentifier ?? "com.omi.desktop") + ".auth"
}

static func set(_ value: String?, forKey key: String) {
let baseQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
]
SecItemDelete(baseQuery as CFDictionary)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

guard let value, let data = value.data(using: .utf8) else { return }
var addQuery = baseQuery
addQuery[kSecValueData as String] = data
addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let status = SecItemAdd(addQuery as CFDictionary, nil)
if status != errSecSuccess {
NSLog("OMI AUTH: Keychain add failed for %@ (status=%d)", key, Int(status))
}
}

static func get(_ key: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var out: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &out)
guard status == errSecSuccess,
let data = out as? Data,
let value = String(data: data, encoding: .utf8)
else {
return nil
}
return value
}

static func clearAll() {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
]
SecItemDelete(query as CFDictionary)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

}
Comment on lines +51 to +57
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 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.

}
38 changes: 32 additions & 6 deletions desktop/Desktop/Sources/AuthService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -780,30 +780,51 @@ class AuthService {
// MARK: - Token Storage

private func saveTokens(idToken: String, refreshToken: String, expiresIn: Int, userId: String) {
UserDefaults.standard.set(idToken, forKey: kAuthIdToken)
UserDefaults.standard.set(refreshToken, forKey: kAuthRefreshToken)
// Store expiry time (current time + expiresIn seconds, minus 5 min buffer)
AuthKeychain.set(idToken, forKey: kAuthIdToken)
AuthKeychain.set(refreshToken, forKey: kAuthRefreshToken)
// Non-sensitive metadata stays in UserDefaults so it remains accessible
// for lightweight state checks without hitting Keychain.
let expiryTime = Date().addingTimeInterval(TimeInterval(expiresIn - 300))
UserDefaults.standard.set(expiryTime.timeIntervalSince1970, forKey: kAuthTokenExpiry)
// Store the user ID that owns these tokens (for validation on retrieval)
UserDefaults.standard.set(userId, forKey: kAuthTokenUserId)
// Defensively wipe any legacy plaintext tokens written by earlier versions.
UserDefaults.standard.removeObject(forKey: kAuthIdToken)
UserDefaults.standard.removeObject(forKey: kAuthRefreshToken)
NSLog("OMI AUTH: Saved tokens for user %@, expires at %@", userId, expiryTime.description)
}

private func clearTokens() {
AuthKeychain.set(nil, forKey: kAuthIdToken)
AuthKeychain.set(nil, forKey: kAuthRefreshToken)
// Purge legacy plaintext copies too.
UserDefaults.standard.removeObject(forKey: kAuthIdToken)
UserDefaults.standard.removeObject(forKey: kAuthRefreshToken)
UserDefaults.standard.removeObject(forKey: kAuthTokenExpiry)
UserDefaults.standard.removeObject(forKey: kAuthTokenUserId)
NSLog("OMI AUTH: Cleared all tokens")
}

/// Read a token from Keychain. On first access after upgrade, transparently
/// migrate any legacy plaintext value out of UserDefaults.
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
Comment on lines +809 to +819
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 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 var storedIdToken: String? {
UserDefaults.standard.string(forKey: kAuthIdToken)
readStoredToken(forKey: kAuthIdToken)
}

private var storedRefreshToken: String? {
UserDefaults.standard.string(forKey: kAuthRefreshToken)
readStoredToken(forKey: kAuthRefreshToken)
}

private var storedTokenUserId: String? {
Expand Down Expand Up @@ -1107,6 +1128,11 @@ class AuthService {
// transcriptionEnabled: removeObject works since nothing writes it back.
UserDefaults.standard.removeObject(forKey: "transcriptionEnabled")

// Browser automation token binds the app to the user's Chrome session.
// Leaving it in place after sign-out would let the next user of this Mac
// drive the previous user's still-logged-in browser.
UserDefaults.standard.removeObject(forKey: "playwrightExtensionToken")

NSLog("OMI AUTH: Signed out and cleared saved state + onboarding")
}

Expand Down
119 changes: 115 additions & 4 deletions desktop/Desktop/Sources/DesktopAutomationBridge.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import AppKit
import Foundation
import Network
import Security

enum DesktopAutomationLaunchOptions {
static let enableFlag = "--automation-bridge"
Expand Down Expand Up @@ -101,16 +102,34 @@ final class DesktopAutomationBridge {

private let queue = DispatchQueue(label: "com.omi.desktop.automation-bridge")
private var listener: NWListener?
private var sessionToken: String?

private init() {}

/// Path where the per-launch bearer token is written so automation clients
/// (agent-swift, test scripts) can read it. File is created with mode 0600
/// and rewritten on every app launch.
static var tokenFileURL: URL {
let fm = FileManager.default
let base = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Library/Application Support")
return base.appendingPathComponent("Omi").appendingPathComponent("automation-bridge.token")
}

func startIfNeeded() {
guard DesktopAutomationLaunchOptions.isEnabled else { return }
guard listener == nil else { return }

do {
let token = try Self.generateSessionToken()
try Self.writeTokenFile(token)
self.sessionToken = token

let parameters = NWParameters.tcp
parameters.allowLocalEndpointReuse = true
// Bind to loopback only. Without this, NWListener binds to all interfaces,
// exposing unauthenticated automation to the local network.
parameters.requiredInterfaceType = .loopback
guard let port = NWEndpoint.Port(rawValue: DesktopAutomationLaunchOptions.port) else {
log("DesktopAutomationBridge: invalid port \(DesktopAutomationLaunchOptions.port)")
return
Expand All @@ -125,18 +144,75 @@ final class DesktopAutomationBridge {
listener.start(queue: queue)
self.listener = listener
log(
"DesktopAutomationBridge: listening on http://127.0.0.1:\(DesktopAutomationLaunchOptions.port)"
"DesktopAutomationBridge: listening on http://127.0.0.1:\(DesktopAutomationLaunchOptions.port) (token at \(Self.tokenFileURL.path))"
)
} catch {
logError("DesktopAutomationBridge: failed to start listener", error: error)
}
}

private func handleConnection(_ connection: NWConnection) {
// Defense in depth: even with requiredInterfaceType = .loopback, verify the
// remote endpoint is a loopback address before processing any data.
if !Self.isLoopbackEndpoint(connection.endpoint) {
log("DesktopAutomationBridge: rejected non-loopback connection from \(connection.endpoint)")
connection.cancel()
return
}
connection.start(queue: queue)
receiveRequest(on: connection, buffer: Data())
}

private static func isLoopbackEndpoint(_ endpoint: NWEndpoint) -> Bool {
switch endpoint {
case .hostPort(let host, _):
switch host {
case .ipv4(let addr):
return addr.isLoopback
case .ipv6(let addr):
return addr.isLoopback
case .name(let name, _):
return name == "localhost" || name == "127.0.0.1" || name == "::1"
@unknown default:
return false
}
default:
return false
}
}

private static func generateSessionToken() throws -> String {
var bytes = [UInt8](repeating: 0, count: 32)
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
guard status == errSecSuccess else {
throw NSError(
domain: "DesktopAutomationBridge", code: Int(status),
userInfo: [NSLocalizedDescriptionKey: "SecRandomCopyBytes failed"])
}
return Data(bytes).base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}

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)
}
Comment on lines +198 to +206
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 security 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) }


private func extractBearerToken(from headers: [String: String]) -> String? {
guard let raw = headers["authorization"] else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespaces)
let prefix = "bearer "
guard trimmed.lowercased().hasPrefix(prefix) else { return nil }
return String(trimmed.dropFirst(prefix.count)).trimmingCharacters(in: .whitespaces)
}

private func receiveRequest(on connection: NWConnection, buffer: Data) {
connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) {
[weak self] data, _, isComplete, error in
Expand Down Expand Up @@ -189,11 +265,15 @@ final class DesktopAutomationBridge {
let path = String(requestParts[1])

var contentLength = 0
var headers: [String: String] = [:]
for line in lines.dropFirst() {
let pieces = line.split(separator: ":", maxSplits: 1)
guard pieces.count == 2 else { continue }
if pieces[0].lowercased() == "content-length" {
contentLength = Int(pieces[1].trimmingCharacters(in: .whitespaces)) ?? 0
let name = pieces[0].lowercased()
let value = pieces[1].trimmingCharacters(in: .whitespaces)
headers[name] = value
if name == "content-length" {
contentLength = Int(value) ?? 0
}
}

Expand All @@ -204,10 +284,38 @@ final class DesktopAutomationBridge {
}

let body = Data(data[bodyStart..<data.index(bodyStart, offsetBy: contentLength)])
return HTTPRequest(method: method, path: path, body: body)
return HTTPRequest(method: method, path: path, headers: headers, body: body)
}

private static func secureCompare(_ a: String, _ b: String) -> Bool {
let ab = Array(a.utf8)
let bb = Array(b.utf8)
if ab.count != bb.count { return false }
var diff: UInt8 = 0
for i in 0..<ab.count { diff |= ab[i] ^ bb[i] }
return diff == 0
}

private func route(request: HTTPRequest) async -> HTTPResponse {
// Every route (including /health) requires the per-launch bearer token.
// Without this any local process — or any LAN host if the listener ever
// falls back to all-interfaces — could trigger /gmail-read on the
// currently signed-in user and walk away with their inbox.
guard let expected = sessionToken else {
return jsonResponse(
DesktopAutomationResponse<DesktopAutomationSnapshot>(
ok: false, result: nil, error: "bridge_not_ready"),
statusCode: 503)
}
guard let provided = extractBearerToken(from: request.headers),
Self.secureCompare(provided, expected)
else {
return jsonResponse(
DesktopAutomationResponse<DesktopAutomationSnapshot>(
ok: false, result: nil, error: "unauthorized"),
statusCode: 401)
}

switch (request.method, request.path) {
case ("GET", "/health"):
let snapshot = await DesktopAutomationStateStore.shared.current()
Expand Down Expand Up @@ -370,7 +478,9 @@ final class DesktopAutomationBridge {
switch response.statusCode {
case 200: statusText = "OK"
case 400: statusText = "Bad Request"
case 401: statusText = "Unauthorized"
case 404: statusText = "Not Found"
case 503: statusText = "Service Unavailable"
default: statusText = "Internal Server Error"
}

Expand Down Expand Up @@ -399,6 +509,7 @@ final class DesktopAutomationBridge {
private struct HTTPRequest {
let method: String
let path: String
let headers: [String: String]
let body: Data
}

Expand Down
Loading