Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 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
2f265f3
chore(keychain): remove unused legacy helpers
bjesuiter Jul 29, 2026
9d02b2c
fix(keychain): use Security.framework for secret reads
bjesuiter Jul 31, 2026
db302de
Merge branch 'main' into deno-compatibility
bjesuiter Jul 31, 2026
97bbda2
chore: fix keychain smoke helper formatting
bjesuiter Jul 31, 2026
638717f
chore(keychain): remove ownership transfer remnants
bjesuiter Jul 31, 2026
a4b1c6f
test(keychain): require signed helper for smoke tests
bjesuiter Jul 31, 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.
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 @@ -197,7 +197,8 @@ final class KeychainManager {
/// 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 {
// Try generic password first, then internet password
// Try generic password first, then internet password. Secret reads intentionally use
// Security.framework directly so Keychain evaluates access for VarlockEnclave.
if let value = try? getItemOfClass(kSecClassGenericPassword, service: service, account: account, keychainName: keychainName) {
return value
}
Expand Down Expand Up @@ -322,16 +323,29 @@ final class KeychainManager {

/// 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 +366,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 +421,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 +432,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 +471,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 +480,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 +575,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 +608,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
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ func jsonSuccess(_ result: [String: Any]) -> Never {
_exit(0)
}

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

func keychainErrorResponse(_ error: Error) -> [String: Any] {
if let keychainError = error as? KeychainError {
return [
Expand Down Expand Up @@ -374,50 +381,50 @@ case "daemon":
}
return ["result": selected]

case "keychain-fix-access":
case "keychain-set":
guard let payload = message["payload"] as? [String: Any] else {
return ["error": "Missing payload"]
}
guard let service = payload["service"] as? String else {
return ["error": "Missing service"]
}
let account = payload["account"] as? String
let keychainName = payload["keychain"] as? String
let appPath = Bundle.main.executablePath ?? ProcessInfo.processInfo.arguments[0]
guard let value = payload["value"] as? String else {
return ["error": "Missing value"]
}
let account = payload["account"] as? String ?? ""
let update = payload["update"] as? Bool ?? false

do {
let modified = try KeychainManager.addToACL(
let updated = try KeychainManager.setGenericPassword(
service: service,
account: account,
keychainName: keychainName,
appPath: appPath
value: value,
update: update
)
return ["result": ["modified": modified]]
return ["result": ["updated": updated]]
} catch {
return keychainErrorResponse(error)
}

case "keychain-set":
case "keychain-delete":
guard let payload = message["payload"] as? [String: Any] else {
return ["error": "Missing payload"]
}
guard let service = payload["service"] as? String else {
return ["error": "Missing service"]
}
guard let value = payload["value"] as? String else {
return ["error": "Missing value"]
guard let account = payload["account"] as? String else {
return ["error": "Missing account"]
}
let account = payload["account"] as? String ?? ""
let update = payload["update"] as? Bool ?? false
let keychainName = payload["keychain"] as? String

do {
let updated = try KeychainManager.setGenericPassword(
try KeychainManager.deleteGenericPassword(
service: service,
account: account,
value: value,
update: update
keychainName: keychainName
)
return ["result": ["updated": updated]]
return ["result": ["deleted": true]]
} catch {
return keychainErrorResponse(error)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,13 @@ ITEM=keychain(prompt) # Opens a native picker dialog
If you already have sensitive plaintext values in a local `.env` file, import them into macOS Keychain and replace the plaintext with stable `keychain(...)` references. The file to import is the first argument:

```sh
varlock keychain import .env --profile jb
varlock keychain import .env --profile myenv
```

By default this **edits the file in place**: each sensitive plaintext value is replaced by its `keychain(...)` ref, so the secret no longer lives on disk. Comments and non-sensitive values are left untouched. To write the refs to a *different* file instead and leave the source as-is, pass `--write-to`:

```sh
varlock keychain import .env --profile jb --write-to .env.jb
varlock keychain import .env --profile myenv --write-to .env.myenv
```

Import requires an existing `.env.schema` file for the input env file so Varlock knows which input variables are secrets and which are not. It only imports variables marked `@sensitive` in that schema and never prints secret values. Re-running is safe: values already converted to `keychain(...)` refs are skipped. By default, Varlock refuses to overwrite an existing Keychain item (or, with `--write-to`, an existing ref in the target file); pass `--force` to overwrite.
Expand All @@ -60,31 +60,39 @@ The file you name must be one Varlock loads as part of your env setup. It resolv
Generated refs use `service="varlock"` and account names like `<project>:<profile>:<ENV_VAR>`. The project defaults to the current directory name and can be overridden:

```sh
varlock keychain import .env --profile jb --project my-app
varlock keychain import .env --profile myenv --project my-app
```

## Set one secret manually

To store one secret without putting the value in shell history, run `set` and enter the value at the masked prompt:

```sh
varlock keychain set API_KEY --profile jb --write-to .env.jb
varlock keychain set API_KEY --profile myenv --write-to .env.myenv
```

This stores the item under `service="varlock"` with account `<project>:<profile>:API_KEY`, then writes the matching `keychain(...)` ref when `--write-to` is provided. If you need to paste a multi-line secret, pipe it through stdin instead of passing it as a command-line argument:

```sh
cat secret.txt | varlock keychain set PRIVATE_KEY --profile jb --write-to .env.jb
cat secret.txt | varlock keychain set PRIVATE_KEY --profile myenv --write-to .env.myenv
```

By default, `set` refuses to overwrite an existing Keychain item or env ref. Pass `--force` to replace both.

To delete a generic-password item through Varlock's macOS helper:

```sh
varlock keychain delete --account "my-app:myenv:API_KEY"
```

Pass `--service` if the item does not use the default `varlock` service.

## Access management

If VarlockEnclave cannot read an existing Keychain item, grant access without using `/usr/bin/security` directly:
If VarlockEnclave cannot read an existing Keychain item, ask the helper to read it once and approve the macOS prompt:

```sh
varlock keychain fix-access --account "my-app:jb:API_KEY"
varlock keychain fix-access --account "my-app:myenv:API_KEY"
```

`--service` defaults to `varlock`, but can be overridden for legacy or manually-created Keychain items:
Expand All @@ -96,9 +104,37 @@ varlock keychain fix-access --service "com.company.api" --account "admin"
You can also fix every explicit `keychain(...)` ref in an env file:

```sh
varlock keychain fix-access --path .env.jb
varlock keychain fix-access --path .env.myenv
```

Varlock reads secrets directly through Apple's Security framework, so macOS evaluates access for VarlockEnclave itself.

macOS may show one Keychain prompt per secret. Choose **Always Allow** for each prompt. This adds VarlockEnclave to the item's access control list, so repeated reads no longer prompt. Choosing **Allow Once** lets that single read continue, but future reads will still prompt.

If you want a new Varlock-owned copy instead of relying on the original item's access rules, clone one existing secret into a new item:

```sh
varlock keychain cloneToOwned \
--service "com.company.api" \
--account "admin" \
--target-account "my-app:myenv:API_KEY"
```

`cloneToOwned` reads the source secret and creates a new destination item through Varlock. It does not delete or modify the original item.

To also write the new ref to an env file, provide the env key explicitly:

```sh
varlock keychain cloneToOwned \
--service "com.company.api" \
--account "admin" \
--target-account "my-app:myenv:API_KEY" \
--write-to .env.myenv \
--key API_KEY
```

If the env key already exists, pass `--force` to replace it.

## List Keychain items

To see which Keychain items are available, list them by service name. This shows metadata only (service, account, and keychain) and never reads secret values:
Expand Down
1 change: 1 addition & 0 deletions packages/varlock/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@
"@clack/prompts": "^1.0.0",
"@env-spec/parser": "workspace:*",
"@env-spec/utils": "workspace:*",
"@bomb.sh/tab": "^0.0.15",
"@gunshi/plugin-completion": "^0.35.1",
"@gunshi/plugin-i18n": "^0.35.1",
"@peculiar/asn1-ecc": "^2.8.0",
Expand Down
Loading
Loading