Initial Commit
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
# 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`.
|
||||
Reference in New Issue
Block a user