9.5 KiB
OpenSpec Change: enterprise-macos-keychain
- Change ID: enterprise-macos-keychain
- Package:
@suaveplan/macos-keychain - Category: universal
- Target path:
universal/auth/macos-keychain - Status: PENDING
- Date: 2026-04-24
- Reference spec:
/mnt/s/web/suaveplan/enterprise/macos-keychain.md
Summary
Deliver @suaveplan/macos-keychain on the genesis branch from scratch at enterprise quality. The package provides a macOS Security framework bridge via the security CLI: MacOSKeychainBridge for credential CRUD (add, find, update, delete, list), a pluggable CommandExecutor interface for test-double injection, a rich typed error hierarchy keyed on macOS exit codes, and a structured error code registry. The genesis rewrite adds Zod option schemas, injectable Clock for timeout calculations, OTel instrumentation, AbortSignal cancellation on CLI invocations, property-based tests, and mitata benchmarks covering the executor path. Since the package wraps a macOS-only CLI tool, tests that call the real /usr/bin/security binary are tagged @integration and skipped in CI; all unit tests use a real injected CommandExecutor stub (no vi.mock).
Current-state audit (genesis)
The package does not exist on genesis yet — this is a fresh build.
Available workspace deps on genesis
| Package | Role |
|---|---|
@suaveplan/error (workspace:^) |
Base class for KeychainError; defineCodes for KEYCHAIN.* codes |
@suaveplan/types (workspace:^) |
Clock (injectable for timeout calculation), LoggerLike |
@suaveplan/crypto (workspace:^) |
zeroize — used to clear password strings from memory after use |
zod (catalog:forms) |
Zod schemas for all public option types |
@opentelemetry/api (catalog:observability) |
Spans, counters, histograms |
tslib (catalog:build) |
TypeScript runtime helpers |
Dev deps: vitest, @vitest/coverage-istanbul, fast-check, mitata (all catalog:testing), @suaveplan/testing (workspace:^).
Preserved public API surface (from main branch)
Types
interface KeychainConfig {
serviceName?: string // default service label for operations
accessGroup?: string // keychain access group for shared access
timeoutMs?: number // CLI command timeout; default 10 000
clock?: Clock
logger?: LoggerLike
}
interface KeychainItem {
service: string
account: string
password: string // plain text — caller responsible for zeroize after use
label?: string
comment?: string
createdAt?: Date
modifiedAt?: Date
}
interface KeychainResult {
success: boolean
error?: string
}
type CommandExecutor = (
command: string,
args: string[]
) => Promise<{ stdout: string; stderr: string; exitCode: number }>
MacOSKeychainBridge class
class MacOSKeychainBridge {
constructor(config?: KeychainConfig, executor?: CommandExecutor)
// Add a new password item; throws KeychainError(KEYCHAIN_ITEM_EXISTS) if duplicate
addItem(item: Omit<KeychainItem, 'createdAt' | 'modifiedAt'>): Promise<KeychainResult>
// Find and return the password for a service/account pair; throws KeychainError(KEYCHAIN_NOT_FOUND) if absent
findItem(service: string, account: string): Promise<KeychainItem>
// Update an existing item's password
updateItem(service: string, account: string, newPassword: string): Promise<KeychainResult>
// Delete an item; throws KeychainError(KEYCHAIN_NOT_FOUND) if absent
deleteItem(service: string, account: string): Promise<KeychainResult>
// List all items for a service (or all items if service is omitted)
listItems(service?: string): Promise<KeychainItem[]>
// Check whether an item exists without fetching the password
itemExists(service: string, account: string): Promise<boolean>
}
Error factories and error codes
type KeychainErrorCode =
| "KEYCHAIN_NOT_FOUND"
| "KEYCHAIN_ITEM_EXISTS"
| "KEYCHAIN_ACCESS_FAILED"
| "KEYCHAIN_LOCKED"
| "KEYCHAIN_UNAVAILABLE"
| "KEYCHAIN_PERMISSION_DENIED"
| "KEYCHAIN_ITEM_INVALID"
| "KEYCHAIN_TIMEOUT"
const KEYCHAIN_ERROR_METADATA: Readonly<Record<KeychainErrorCode, CodeMetadata>>
function registerKeychainErrorCodes(): void
// Named factory helpers (each returns a typed KeychainError instance)
function keychainNotFound(service: string, account: string): KeychainError
function keychainItemExists(service: string, account: string): KeychainError
function keychainAccessFailed(service: string, account: string): KeychainError
function keychainLocked(service: string, account: string): KeychainError
function keychainUnavailable(): KeychainError
function keychainPermissionDenied(service: string, account: string): KeychainError
function keychainItemInvalid(service: string, account: string, reason: string): KeychainError
function keychainTimeout(service: string, account: string, timeoutMs: number): KeychainError
New additions for genesis
1. KEYCHAIN_TIMEOUT error code
The main branch exposes seven codes; genesis adds KEYCHAIN_TIMEOUT for when the CLI invocation exceeds config.timeoutMs. The error factory keychainTimeout(service, account, timeoutMs) is a new export.
2. AbortSignal on CLI invocations
MacOSKeychainBridge methods accept an optional signal?: AbortSignal. When present, if the signal fires before the CLI returns, the promise rejects with KeychainError(KEYCHAIN_TIMEOUT).
3. Clock injection
timeoutMs deadline is computed via clock.now() + timeoutMs — no bare Date.now() calls. KeychainItem.createdAt / modifiedAt are parsed from the security show-keychain-info output but clock is also used as the fallback timestamp when the CLI omits dates.
4. OTel instrumentation
keychain.addspan +keychain.ops_totalcounter (labelop: "add",success: bool)keychain.findspan + counter (labelop: "find",success: bool,cached: bool)keychain.updatespan + counter (labelop: "update")keychain.deletespan + counter (labelop: "delete")keychain.listspan + histogramkeychain.items_returnedkeychain.cli_duration_mshistogram (labelop)
5. Zod schemas
KeychainConfigSchema, KeychainItemSchema, KeychainResultSchema.
6. zeroize on sensitive values
After findItem returns to the caller, the bridge does not hold a reference to the plain-text password. Internal temporary buffers holding the stdout string from the CLI are zeroed via @suaveplan/crypto's zeroize after the password is extracted.
7. Property-based tests (fast-check)
- Error classification: for every macOS exit code in the documented set,
classifyErrorreturns aKeychainErrorwith the expected code — nevernullfor known codes, always non-null for unknown codes. - Error factory idempotency: calling
keychainNotFound(s, a)twice with the same arguments returns errors with identicalcodeand equivalentmessagestrings. - Round-trip (stub executor):
addItem→findItemvia a deterministicCommandExecutorstub that echoes the stored data; result password matches input.
8. Benchmark suite
MacOSKeychainBridge.findItemvia a stubCommandExecutor(no real keychain) — 100 000 iterations measuring parse and error-classification overhead.classifyErrorfor 10 different exit-code/stderr combinations — 1 000 000 iterations (pure-function benchmark).
File layout
universal/auth/macos-keychain/
package.json
tsconfig.json
vitest.config.ts
src/
index.ts
error-codes.ts # KEYCHAIN.* codes + registerKeychainErrorCodes
constants/
index.ts # EXIT_CODES, SECURITY_BINARY, DEFAULT_TIMEOUT_MS
types/
macos-keychain.types.ts
macos-keychain.types.md
core/
keychain-bridge/
keychain-bridge.ts # MacOSKeychainBridge class
keychain-bridge.md
keychain-bridge.types.ts
keychain-bridge.test.ts
index.ts
error-classifier/
error-classifier.ts # classifyError(exitCode, stderr, service, account)
error-classifier.md
error-classifier.test.ts
index.ts
output-parser/
output-parser.ts # parseKeychainItem from security CLI stdout
output-parser.md
output-parser.test.ts
index.ts
errors/
keychain-errors.ts # named factory helpers
keychain-errors.md
keychain-errors.test.ts
schemas/
macos-keychain.schemas.ts
macos-keychain.schemas.md
macos-keychain.schemas.test.ts
__benchmarks__/
macos-keychain.bench.ts
tests/
integration.test.ts # @integration — requires real macOS + security binary
property.test.ts
Impact
Downstream packages: CLI tools and desktop apps on macOS that need credential storage (e.g., @suaveplan/crypto-vault could delegate to this for native key storage on macOS).
Breaking changes from main-branch API: KEYCHAIN_TIMEOUT code and keychainTimeout factory are net-new additions. All existing exports are preserved. The CommandExecutor interface is unchanged.
Workspace dep prerequisites: @suaveplan/error, @suaveplan/types, @suaveplan/crypto must be published to Verdaccio before this package can be built or tested.
Platform note: The integration tests (tests/integration.test.ts) require a real macOS environment with /usr/bin/security. They are tagged @integration and excluded from CI by default. All unit tests run on Linux/macOS via the stub CommandExecutor.