Files
2026-05-27 23:29:53 +01:00

232 lines
9.5 KiB
Markdown

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