Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
411c0b6
fix: allow deno varlock ipc clients
bjesuiter Jun 26, 2026
514b839
test: add deno compatibility smoke tests
bjesuiter Jun 26, 2026
c01358f
test: add gated deno smoke tests
bjesuiter Jun 26, 2026
bca2b33
test: split deno keychain smoke script
bjesuiter Jun 26, 2026
2db3a1f
test: add gated keychain smoke test
bjesuiter Jun 26, 2026
6bbe6aa
test: add keychain set smoke test
bjesuiter Jun 26, 2026
08d5915
test: add keychain import smoke test
bjesuiter Jun 26, 2026
295e455
test: add keychain fix-access smoke test
bjesuiter Jun 26, 2026
2cd4365
fix: batch keychain access fixes
bjesuiter Jun 26, 2026
69fb0f9
fix: read keychain secrets via security cli
bjesuiter Jun 26, 2026
0555ca6
fix: make keychain fix-access take ownership
bjesuiter Jun 27, 2026
6dbf37a
docs: use generic keychain profile examples
bjesuiter Jun 26, 2026
7bb7d45
fix: preserve keychain fix-access targets
bjesuiter Jun 27, 2026
b052bd1
test: run smoke suite through deno
bjesuiter Jun 27, 2026
00fb7e0
fix(keychain): fail on unresolved ownership keychains
bjesuiter Jun 27, 2026
b54971a
fix(keychain): restore secret on ownership failure
bjesuiter Jun 27, 2026
42a0f24
fix(keychain): stage ownership transfers safely
bjesuiter Jun 27, 2026
abda803
chore: update bun lockfile
bjesuiter Jun 27, 2026
905597b
fix: keep keychain fix-access non-destructive
bjesuiter Jun 28, 2026
5199bfa
test: smoke keychain take-ownership
bjesuiter Jun 28, 2026
8b1c78d
test: assert keychain fix precondition
bjesuiter Jun 28, 2026
e172a26
Revert "test: assert keychain fix precondition"
bjesuiter Jun 28, 2026
b27043f
feat: allow disabling keychain read fallback
bjesuiter Jun 28, 2026
072218d
test: reset daemon for keychain smoke tests
bjesuiter Jun 28, 2026
cae1829
test: use one keychain daemon reset helper
bjesuiter Jun 28, 2026
bdd1a0a
fix: simplify keychain access migration
bjesuiter Jun 29, 2026
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
5 changes: 5 additions & 0 deletions .bumpy/keychain-fix-access-batch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
varlock: patch
---

Remove the unsupported macOS Keychain ACL mutation path from fix-access.
5 changes: 5 additions & 0 deletions .bumpy/keychain-fix-access-take-ownership.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
varlock: patch
---

Change macOS Keychain fix-access to use the native read prompt with Always Allow guidance, and replace take-ownership with a non-destructive cloneToOwned command.
5 changes: 5 additions & 0 deletions .bumpy/keychain-security-cli-reads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
varlock: patch
---

Reduce macOS Keychain password prompts by reading keychain secrets through the security CLI.
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,20 @@ public enum LegacyKeychain {
let status = SecKeychainOpen(path, &keychain)
return (status, keychain)
}

public static func keychainCopyDefault() -> (OSStatus, SecKeychain?) {
var keychain: SecKeychain?
let status = SecKeychainCopyDefault(&keychain)
return (status, keychain)
}

public static func keychainGetStatus(_ keychain: SecKeychain) -> (OSStatus, SecKeychainStatus) {
var keychainStatus = SecKeychainStatus()
let status = SecKeychainGetStatus(keychain, &keychainStatus)
return (status, keychainStatus)
}

public static func keychainUnlock(_ keychain: SecKeychain) -> OSStatus {
return SecKeychainUnlock(keychain, 0, nil, false)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ enum KeychainError: LocalizedError {
case unhandledError(OSStatus)
case keychainNotFound(String)
case ambiguousMatch(service: String, accounts: [String])
case ownershipTransferFailed(recreateError: Error, restoreError: Error?)

var code: String {
switch self {
Expand All @@ -28,6 +29,8 @@ enum KeychainError: LocalizedError {
return "keychainNotFound"
case .ambiguousMatch:
return "ambiguousMatch"
case .ownershipTransferFailed:
return "ownershipTransferFailed"
}
}

Expand All @@ -51,10 +54,23 @@ enum KeychainError: LocalizedError {
case .ambiguousMatch(let service, let accounts):
let accountList = accounts.map { "\"\($0)\"" }.joined(separator: ", ")
return "Multiple keychain items found for service \"\(service)\" with accounts: \(accountList). Specify account to disambiguate."
case .ownershipTransferFailed(let recreateError, let restoreError):
let recreateMessage = keychainErrorDetail(recreateError)
if let restoreError = restoreError {
return "Ownership transfer failed while recreating the keychain item (\(recreateMessage)); restoring the secret value also failed (\(keychainErrorDetail(restoreError)))."
}
return "Ownership transfer failed while recreating the keychain item (\(recreateMessage)); the secret value was preserved or restored."
}
}
}

private func keychainErrorDetail(_ error: Error) -> String {
if let keychainError = error as? KeychainError {
return keychainError.localizedDescription
}
return error.localizedDescription
}

/// Metadata about a keychain item (no secret values)
struct KeychainItemMeta {
let service: String
Expand Down Expand Up @@ -196,12 +212,12 @@ final class KeychainManager {
/// Fetch the password value for a keychain item.
/// At least one of service or account must be provided.
/// Throws if not found, access denied, or ambiguous match.
static func getItem(service: String? = nil, account: String? = nil, keychainName: String? = nil) throws -> String {
static func getItem(service: String? = nil, account: String? = nil, keychainName: String? = nil, useFallback: Bool = true) throws -> String {
// Try generic password first, then internet password
if let value = try? getItemOfClass(kSecClassGenericPassword, service: service, account: account, keychainName: keychainName) {
if let value = try? getItemOfClass(kSecClassGenericPassword, service: service, account: account, keychainName: keychainName, useFallback: useFallback) {
return value
}
return try getItemOfClass(kSecClassInternetPassword, service: service, account: account, keychainName: keychainName)
return try getItemOfClass(kSecClassInternetPassword, service: service, account: account, keychainName: keychainName, useFallback: useFallback)
}

/// Fetch a metadata field (account, label, etc.) from a keychain item instead of the password.
Expand Down Expand Up @@ -249,7 +265,7 @@ final class KeychainManager {
throw KeychainError.itemNotFound
}

private static func getItemOfClass(_ itemClass: CFString, service: String?, account: String?, keychainName: String?) throws -> String {
private static func getItemOfClass(_ itemClass: CFString, service: String?, account: String?, keychainName: String?, useFallback: Bool = true) throws -> String {
// When account is nil and service is set, check for ambiguity
if account == nil, let service = service {
var countQuery: [CFString: Any] = [
Expand Down Expand Up @@ -312,26 +328,89 @@ final class KeychainManager {
case errSecItemNotFound:
throw KeychainError.itemNotFound
case errSecAuthFailed, errSecInteractionNotAllowed:
if useFallback,
itemClass == kSecClassGenericPassword,
let service = service,
let value = try? getGenericPasswordViaSecurityCLI(service: service, account: account, keychainName: keychainName) {
return value
}
throw KeychainError.accessDenied("Authentication failed or interaction not allowed")
default:
if useFallback,
itemClass == kSecClassGenericPassword,
let service = service,
let value = try? getGenericPasswordViaSecurityCLI(service: service, account: account, keychainName: keychainName) {
return value
}
throw KeychainError.unhandledError(status)
}
}

private static func getGenericPasswordViaSecurityCLI(service: String, account: String?, keychainName: String?) throws -> String {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/security")

var arguments = ["find-generic-password", "-s", service, "-w"]
if let account = account {
arguments.append(contentsOf: ["-a", account])
}
if let keychainName = keychainName, let keychainPath = resolveKeychainPath(named: keychainName) {
arguments.append(keychainPath)
}
process.arguments = arguments

let stdout = Pipe()
let stderr = Pipe()
process.standardOutput = stdout
process.standardError = stderr

try process.run()
process.waitUntilExit()

guard process.terminationStatus == 0 else {
let errorData = stderr.fileHandleForReading.readDataToEndOfFile()
let errorOutput = String(data: errorData, encoding: .utf8) ?? ""
if errorOutput.contains("could not be found") || errorOutput.contains("specified item could not be found") {
throw KeychainError.itemNotFound
}
throw KeychainError.unhandledError(OSStatus(process.terminationStatus))
}

let data = stdout.fileHandleForReading.readDataToEndOfFile()
guard var value = String(data: data, encoding: .utf8) else {
throw KeychainError.unexpectedData
}
value = value.replacingOccurrences(of: "\r?\n$", with: "", options: .regularExpression)
return value
}

// MARK: - Add Item

/// Create or update a generic password item.
/// Returns true when an existing item was updated, false when a new item was created.
static func setGenericPassword(service: String, account: String, value: String, update: Bool = false) throws -> Bool {
static func setGenericPassword(service: String, account: String, value: String, update: Bool = false, keychainName: String? = nil) throws -> Bool {
guard let valueData = value.data(using: .utf8) else {
throw KeychainError.unexpectedData
}

let lookup: [CFString: Any] = [
let keychainRef: SecKeychain?
if let keychainName = keychainName {
guard let resolvedKeychain = resolveKeychain(named: keychainName) else {
throw KeychainError.keychainNotFound(keychainName)
}
keychainRef = resolvedKeychain
} else {
keychainRef = nil
}

var lookup: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: account,
]
if let keychainRef = keychainRef {
lookup[kSecMatchSearchList] = [keychainRef]
}

if update {
let attrs: [CFString: Any] = [
Expand All @@ -352,6 +431,10 @@ final class KeychainManager {
var addQuery = lookup
addQuery[kSecAttrLabel] = account.isEmpty ? service : account
addQuery[kSecValueData] = valueData
if let keychainRef = keychainRef {
addQuery[kSecUseKeychain] = keychainRef
addQuery.removeValue(forKey: kSecMatchSearchList)
}

let status = SecItemAdd(addQuery as CFDictionary, nil)
switch status {
Expand Down Expand Up @@ -403,17 +486,9 @@ final class KeychainManager {
}
}

// MARK: - ACL Management

/// Attempt to add the given application path to the ACL of a keychain item.
/// This uses the legacy Keychain API which supports per-application access control.
/// Returns true if the ACL was modified, false if no change was needed.
/// macOS will prompt the user for authentication to authorize the change.
static func addToACL(service: String, account: String? = nil, keychainName: String? = nil, appPath: String) throws -> Bool {
// We need the item reference for ACL manipulation
let itemRef = try getItemRef(service: service, account: account, keychainName: keychainName)

// Get current access object (uses legacy API wrappers from KeychainLegacyACL.swift)
let (accessStatus, access) = LegacyKeychain.itemCopyAccess(itemRef)
guard accessStatus == errSecSuccess, let currentAccess = access else {
if accessStatus == errSecNoAccessForItem {
Expand All @@ -422,29 +497,22 @@ final class KeychainManager {
throw KeychainError.unhandledError(accessStatus)
}

// Get all ACL entries
let (aclListStatus, aclListRef) = LegacyKeychain.accessCopyACLList(currentAccess)
guard aclListStatus == errSecSuccess, let aclList = aclListRef as? [SecACL] else {
throw KeychainError.accessDenied("Cannot read ACL list")
}

// Create trusted application for our binary
let (trustStatus, trustedApp) = LegacyKeychain.trustedApplicationCreate(path: appPath)
guard trustStatus == errSecSuccess, let newTrustedApp = trustedApp else {
throw KeychainError.unhandledError(trustStatus)
}

var modified = false

// Find ACL entries that control decryption/reading and add our app
for acl in aclList {
let (contentsStatus, appList, description, promptSelector) = LegacyKeychain.aclCopyContents(acl)
guard contentsStatus == errSecSuccess else { continue }

// nil appList means "allow all apps" — no change needed
guard let currentApps = appList as? [SecTrustedApplication] else { continue }

// Check if our app is already in the list
var alreadyPresent = false
for app in currentApps {
let (dataStatus, appData) = LegacyKeychain.trustedApplicationCopyData(app)
Expand All @@ -468,7 +536,6 @@ final class KeychainManager {
}

if modified {
// Apply the modified access object back to the item
let setStatus = LegacyKeychain.itemSetAccess(itemRef, currentAccess)
if setStatus != errSecSuccess {
throw KeychainError.unhandledError(setStatus)
Expand All @@ -478,6 +545,31 @@ final class KeychainManager {
return modified
}

static func deleteGenericPassword(service: String, account: String, keychainName: String? = nil) throws {
var query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: account,
]

if let keychainName = keychainName {
guard let keychainRef = resolveKeychain(named: keychainName) else {
throw KeychainError.keychainNotFound(keychainName)
}
query[kSecMatchSearchList] = [keychainRef]
}

let status = SecItemDelete(query as CFDictionary)
switch status {
case errSecSuccess:
return
case errSecItemNotFound:
throw KeychainError.itemNotFound
default:
throw KeychainError.unhandledError(status)
}
}

// MARK: - Private Helpers

/// Get a SecKeychainItem reference for ACL operations.
Expand Down Expand Up @@ -548,6 +640,19 @@ final class KeychainManager {
/// Resolve a human-friendly keychain name to a SecKeychain reference.
/// Supports: "Login", "System", or a full/partial path.
private static func resolveKeychain(named name: String) -> SecKeychain? {
guard let path = resolveKeychainPath(named: name) else {
return nil
}

let (status, keychain) = LegacyKeychain.keychainOpen(path: path)
guard status == errSecSuccess, let kc = keychain else {
return nil
}

return kc
}

private static func resolveKeychainPath(named name: String) -> String? {
let lowered = name.lowercased()

// Well-known keychains
Expand All @@ -568,12 +673,7 @@ final class KeychainManager {
return nil
}

let (status, keychain) = LegacyKeychain.keychainOpen(path: path)
guard status == errSecSuccess, let kc = keychain else {
return nil
}

return kc
return path
}

/// Extract keychain file path from item attributes (if available).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ private let allowedBinaryNames: Set<String> = [
"varlock", // SEA CLI binary
"node", // Node.js (varlock TS client)
"bun", // Bun runtime (varlock TS client)
"deno", // Deno runtime (varlock TS client)
]

/// Verify that a peer process is an allowed Varlock client.
Expand Down
Loading
Loading