# 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 ```ts 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 ```ts class MacOSKeychainBridge { constructor(config?: KeychainConfig, executor?: CommandExecutor) // Add a new password item; throws KeychainError(KEYCHAIN_ITEM_EXISTS) if duplicate addItem(item: Omit): Promise // Find and return the password for a service/account pair; throws KeychainError(KEYCHAIN_NOT_FOUND) if absent findItem(service: string, account: string): Promise // Update an existing item's password updateItem(service: string, account: string, newPassword: string): Promise // Delete an item; throws KeychainError(KEYCHAIN_NOT_FOUND) if absent deleteItem(service: string, account: string): Promise // List all items for a service (or all items if service is omitted) listItems(service?: string): Promise // Check whether an item exists without fetching the password itemExists(service: string, account: string): Promise } ``` ### Error factories and error codes ```ts 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> 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.add` span + `keychain.ops_total` counter (label `op: "add"`, `success: bool`) - `keychain.find` span + counter (label `op: "find"`, `success: bool`, `cached: bool`) - `keychain.update` span + counter (label `op: "update"`) - `keychain.delete` span + counter (label `op: "delete"`) - `keychain.list` span + histogram `keychain.items_returned` - `keychain.cli_duration_ms` histogram (label `op`) ### 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, `classifyError` returns a `KeychainError` with the expected code — never `null` for known codes, always non-null for unknown codes. - Error factory idempotency: calling `keychainNotFound(s, a)` twice with the same arguments returns errors with identical `code` and equivalent `message` strings. - Round-trip (stub executor): `addItem` → `findItem` via a deterministic `CommandExecutor` stub that echoes the stored data; result password matches input. ### 8. Benchmark suite - `MacOSKeychainBridge.findItem` via a stub `CommandExecutor` (no real keychain) — 100 000 iterations measuring parse and error-classification overhead. - `classifyError` for 10 different exit-code/stderr combinations — 1 000 000 iterations (pure-function benchmark). --- ## File layout ```text 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`.