Skip to content
Open
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
5 changes: 5 additions & 0 deletions .bumpy/keychain-import-require-schema.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
varlock: patch
---

Require an explicit schema before importing plaintext Keychain secrets
5 changes: 5 additions & 0 deletions .bumpy/varlock-keychain-cli.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
varlock: minor
---

Add macOS Keychain management commands.
16 changes: 9 additions & 7 deletions .github/workflows/build-native-rust.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ jobs:

runs-on: ${{ matrix.os }}
name: Build ${{ matrix.native-bin-subdir }}
env:
HAS_OP_CI_TOKEN: ${{ secrets.OP_CI_TOKEN != '' }}

steps:
- uses: actions/checkout@v6
Expand Down Expand Up @@ -91,43 +93,43 @@ jobs:
# macOS Developer ID signing flow. Skipped when OP_CI_TOKEN is absent
# (e.g. fork PRs), leaving the Windows binary unsigned.
- name: Windows signing skipped (no OP_CI_TOKEN)
if: contains(matrix.os, 'windows') && secrets.OP_CI_TOKEN == ''
if: contains(matrix.os, 'windows') && env.HAS_OP_CI_TOKEN != 'true'
shell: bash
run: |
echo "Azure Artifact Signing skipped: OP_CI_TOKEN not available (fork PR?) — Windows binary will be unsigned"

- name: Setup Bun (for varlock secret loading)
if: contains(matrix.os, 'windows') && secrets.OP_CI_TOKEN != ''
if: contains(matrix.os, 'windows') && env.HAS_OP_CI_TOKEN == 'true'
uses: oven-sh/setup-bun@v2

- name: Install node deps (for varlock secret loading)
if: contains(matrix.os, 'windows') && secrets.OP_CI_TOKEN != ''
if: contains(matrix.os, 'windows') && env.HAS_OP_CI_TOKEN == 'true'
run: bun install

# Build varlock JS so we can use it to resolve secrets from 1Password
- name: Build varlock libs
if: contains(matrix.os, 'windows') && secrets.OP_CI_TOKEN != ''
if: contains(matrix.os, 'windows') && env.HAS_OP_CI_TOKEN == 'true'
run: bun run build:libs

# Load Azure signing secrets from 1Password via varlock (scoped to the Rust package)
- name: Load signing secrets
if: contains(matrix.os, 'windows') && secrets.OP_CI_TOKEN != ''
if: contains(matrix.os, 'windows') && env.HAS_OP_CI_TOKEN == 'true'
uses: dmno-dev/varlock-action@v1.0.3
with:
working-directory: packages/encryption-binary-rust
env:
OP_CI_TOKEN: ${{ secrets.OP_CI_TOKEN }}

- name: Azure login (OIDC)
if: contains(matrix.os, 'windows') && secrets.OP_CI_TOKEN != ''
if: contains(matrix.os, 'windows') && env.HAS_OP_CI_TOKEN == 'true'
uses: azure/login@v2
with:
client-id: ${{ env.AZURE_CLIENT_ID }}
tenant-id: ${{ env.AZURE_TENANT_ID }}
subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }}

- name: Sign Windows binary (Azure Artifact Signing)
if: contains(matrix.os, 'windows') && secrets.OP_CI_TOKEN != ''
if: contains(matrix.os, 'windows') && env.HAS_OP_CI_TOKEN == 'true'
uses: azure/artifact-signing-action@v2
with:
endpoint: ${{ env.AZURE_ARTIFACT_SIGNING_ENDPOINT }}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,25 @@ enum KeychainError: LocalizedError {
case keychainNotFound(String)
case ambiguousMatch(service: String, accounts: [String])

var code: String {
switch self {
case .itemNotFound:
return "itemNotFound"
case .accessDenied:
return "accessDenied"
case .duplicateItem:
return "duplicateItem"
case .unexpectedData:
return "unexpectedData"
case .unhandledError:
return "unhandledError"
case .keychainNotFound:
return "keychainNotFound"
case .ambiguousMatch:
return "ambiguousMatch"
}
}

var errorDescription: String? {
switch self {
case .itemNotFound:
Expand Down Expand Up @@ -301,6 +320,50 @@ final class KeychainManager {

// 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 {
guard let valueData = value.data(using: .utf8) else {
throw KeychainError.unexpectedData
}

let lookup: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: account,
]

if update {
let attrs: [CFString: Any] = [
kSecValueData: valueData,
kSecAttrLabel: account.isEmpty ? service : account,
]
let status = SecItemUpdate(lookup as CFDictionary, attrs as CFDictionary)
switch status {
case errSecSuccess:
return true
case errSecItemNotFound:
break
default:
throw KeychainError.unhandledError(status)
}
}

var addQuery = lookup
addQuery[kSecAttrLabel] = account.isEmpty ? service : account
addQuery[kSecValueData] = valueData

let status = SecItemAdd(addQuery as CFDictionary, nil)
switch status {
case errSecSuccess:
return false
case errSecDuplicateItem:
throw KeychainError.duplicateItem
default:
throw KeychainError.unhandledError(status)
}
}

/// Create a new keychain item as a secure note.
/// The label is the only user-visible identifier in Keychain Access.
/// The value is stored as RTF for Keychain Access compatibility.
Expand Down Expand Up @@ -420,25 +483,55 @@ final class KeychainManager {
/// Get a SecKeychainItem reference for ACL operations.
/// Searches generic passwords first, then internet passwords.
private static func getItemRef(service: String, account: String?, keychainName: String?) throws -> SecKeychainItem {
let searchList: [SecKeychain]?
if let keychainName = keychainName {
guard let keychainRef = resolveKeychain(named: keychainName) else {
throw KeychainError.keychainNotFound(keychainName)
}
searchList = [keychainRef]
} else {
searchList = nil
}

for itemClass in [kSecClassGenericPassword, kSecClassInternetPassword] {
let serviceAttribute = itemClass == kSecClassGenericPassword ? kSecAttrService : kSecAttrServer

if account == nil {
var countQuery: [CFString: Any] = [
kSecClass: itemClass,
kSecReturnAttributes: true,
kSecMatchLimit: kSecMatchLimitAll,
serviceAttribute: service,
]

if let searchList = searchList {
countQuery[kSecMatchSearchList] = searchList
}

var countResult: AnyObject?
let countStatus = SecItemCopyMatching(countQuery as CFDictionary, &countResult)
if countStatus == errSecSuccess, let items = countResult as? [[String: Any]], items.count > 1 {
let accounts = items.compactMap { $0[kSecAttrAccount as String] as? String }
throw KeychainError.ambiguousMatch(
service: service,
accounts: accounts
)
}
}

var query: [CFString: Any] = [
kSecClass: itemClass,
kSecReturnRef: true,
kSecMatchLimit: kSecMatchLimitOne,
serviceAttribute: service,
]

if itemClass == kSecClassGenericPassword {
query[kSecAttrService] = service
} else {
query[kSecAttrServer] = service
}

if let account = account {
query[kSecAttrAccount] = account
}

if let keychainName = keychainName, let keychainRef = resolveKeychain(named: keychainName) {
query[kSecMatchSearchList] = [keychainRef]
if let searchList = searchList {
query[kSecMatchSearchList] = searchList
}

var result: AnyObject?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ func jsonSuccess(_ result: [String: Any]) -> Never {
_exit(0)
}

func keychainErrorResponse(_ error: Error) -> [String: Any] {
if let keychainError = error as? KeychainError {
return [
"error": keychainError.localizedDescription,
"errorCode": keychainError.code,
]
}
return ["error": error.localizedDescription]
}

// MARK: - CLI Parsing

let args = CommandLine.arguments
Expand Down Expand Up @@ -324,7 +334,7 @@ case "daemon":
)
return ["result": value]
} catch {
return ["error": error.localizedDescription]
return keychainErrorResponse(error)
}
}

Expand All @@ -344,7 +354,7 @@ case "daemon":
statusBarMenu?.refresh()
return ["result": value]
} catch {
return ["error": error.localizedDescription]
return keychainErrorResponse(error)
}

case "keychain-search":
Expand All @@ -364,6 +374,54 @@ case "daemon":
}
return ["result": selected]

case "keychain-fix-access":
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]

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

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"]
}
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 updated = try KeychainManager.setGenericPassword(
service: service,
account: account,
value: value,
update: update
)
return ["result": ["updated": updated]]
} catch {
return keychainErrorResponse(error)
}

default:
return ["error": "Unknown action: \(action)"]
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,58 @@ Rather than wiring up items individually, the easiest way to get started is by u
ITEM=keychain(prompt) # Opens a native picker dialog
```

## Import plaintext env files

If you already have sensitive plaintext values in a local `.env` file, import them into macOS Keychain and write stable `keychain(...)` references to a profile env file:

```sh
varlock keychain import --from .env --profile jb --write .env.jb
```

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 includes variables marked `@sensitive` in that schema and never prints secret values. By default, Varlock refuses to overwrite existing Keychain items or existing refs in the target env file; pass `--force` to overwrite both.

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 --from .env --profile jb --project my-app --write .env.jb
```

## 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 .env.jb
```

This stores the item under `service="varlock"` with account `<project>:<profile>:API_KEY`, then writes the matching `keychain(...)` ref when `--write` 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 .env.jb
```

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

## Access management

If VarlockEnclave cannot read an existing Keychain item, grant access without using `/usr/bin/security` directly:

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

`--service` defaults to `varlock`, but can be overridden for legacy or manually-created Keychain items:

```sh
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
```

## Reference

<div class="reference-docs">
Expand Down
2 changes: 2 additions & 0 deletions packages/varlock/src/cli/cli-executable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { commandSpec as installPluginCommandSpec } from './commands/install-plug
import { commandSpec as auditCommandSpec } from './commands/audit.command';
import { commandSpec as generateKeyCommandSpec } from './commands/generate-key.command';
import { commandSpec as cacheCommandSpec } from './commands/cache.command';
import { commandSpec as keychainCommandSpec } from './commands/keychain.command';
// import { commandSpec as loginCommandSpec } from './commands/login.command';
// import { commandSpec as pluginCommandSpec } from './commands/plugin.command';

Expand Down Expand Up @@ -71,6 +72,7 @@ subCommands.set('typegen', buildLazyCommand(typegenCommandSpec, async () => awai
subCommands.set('install-plugin', buildLazyCommand(installPluginCommandSpec, async () => await import('./commands/install-plugin.command')));
subCommands.set('generate-key', buildLazyCommand(generateKeyCommandSpec, async () => await import('./commands/generate-key.command')));
subCommands.set('cache', buildLazyCommand(cacheCommandSpec, async () => await import('./commands/cache.command')));
subCommands.set('keychain', buildLazyCommand(keychainCommandSpec, async () => await import('./commands/keychain.command')));
// subCommands.set('login', buildLazyCommand(loginCommandSpec, async () => await import('./commands/login.command')));
// subCommands.set('plugin', buildLazyCommand(pluginCommandSpec, async () => await import('./commands/plugin.command')));

Expand Down
Loading