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`.
|
||||
@@ -0,0 +1,185 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: macos-keychain (enterprise-macos-keychain)
|
||||
|
||||
**Package**: `@suaveplan/macos-keychain`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. (See `openspec/AGENTS.md` Section 13.)
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Generic-password CRUD via the macOS `security` CLI
|
||||
|
||||
The package SHALL expose a `MacOSKeychainBridge` class that performs `add`, `find`, `update`, `delete`, `list`, and `exists` operations against the macOS Keychain by shelling out to `/usr/bin/security` via an injectable `CommandExecutor`. Callers can rely on a strict success/throw contract: success methods resolve with a `KeychainResult` or `KeychainItem`; failure methods reject with a typed `KeychainError` whose `code` field is one of the eight registered codes in `KEYCHAIN.*`.
|
||||
|
||||
#### Scenario: addItem succeeds for a new credential
|
||||
|
||||
- **WHEN** `addItem({ service, account, password })` is called and the executor returns `exitCode === 0`
|
||||
- **THEN** the promise resolves to `{ success: true }` and `keychain.ops_total` records `op="add", success=true`
|
||||
|
||||
#### Scenario: addItem rejects with KEYCHAIN.ITEM_EXISTS on duplicate
|
||||
|
||||
- **WHEN** `addItem` is called and the executor returns the `errSecDuplicateItem` exit code (45)
|
||||
- **THEN** the promise rejects with a `KeychainError` whose `code === "KEYCHAIN.ITEM_EXISTS"`
|
||||
|
||||
#### Scenario: findItem returns the stored password
|
||||
|
||||
- **WHEN** `findItem(service, account)` is called and the executor returns valid `find-generic-password -g` output
|
||||
- **THEN** the promise resolves to a `KeychainItem` whose `password` field equals the stored secret
|
||||
|
||||
**Verified by:** `src/core/keychain-bridge/keychain-bridge.test.ts::"adds a new keychain item successfully"` AND `src/core/keychain-bridge/keychain-bridge.test.ts::"throws KEYCHAIN.ITEM_EXISTS on duplicate add"` AND `src/core/keychain-bridge/keychain-bridge.test.ts::"finds an existing keychain item and returns parsed password"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Stable, registered error-code namespace
|
||||
|
||||
The package SHALL define exactly eight error codes under the `KEYCHAIN.*` namespace, register them eagerly at module load time via `defineCodes` and `registerKeychainErrorCodes`, and surface them through named factory helpers that return `KeychainError` instances carrying both the literal code and a structured context object. Codes are: `KEYCHAIN.NOT_FOUND`, `KEYCHAIN.ITEM_EXISTS`, `KEYCHAIN.ACCESS_FAILED`, `KEYCHAIN.LOCKED`, `KEYCHAIN.UNAVAILABLE`, `KEYCHAIN.PERMISSION_DENIED`, `KEYCHAIN.ITEM_INVALID`, `KEYCHAIN.TIMEOUT`.
|
||||
|
||||
#### Scenario: registerKeychainErrorCodes is idempotent
|
||||
|
||||
- **WHEN** `registerKeychainErrorCodes()` is called twice
|
||||
- **THEN** the metadata table is unchanged on the second call and no error is thrown
|
||||
|
||||
#### Scenario: Each factory yields the matching code
|
||||
|
||||
- **WHEN** `keychainNotFound("svc", "user")` is invoked
|
||||
- **THEN** the returned `KeychainError` has `code === "KEYCHAIN.NOT_FOUND"` and `context.service === "svc"` and `context.account === "user"`
|
||||
|
||||
**Verified by:** `src/errors/keychain-errors.test.ts::"keychainNotFound produces KEYCHAIN.NOT_FOUND with service and account context"` AND `src/errors/keychain-errors.test.ts::"registerKeychainErrorCodes is idempotent across repeat calls"` AND `tests/property.test.ts::"every factory produces the documented code (idempotency)"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Shell-injection-safe CLI invocation
|
||||
|
||||
The package SHALL never interpolate caller-supplied strings into a shell command. The default `CommandExecutor` MUST use `Bun.spawn` (Bun runtime) or `node:child_process.execFile` (Node runtime), passing all arguments as an array to `SECURITY_BINARY = "/usr/bin/security"`. The implementation MUST NOT invoke any shell wrapper.
|
||||
|
||||
#### Scenario: Arguments containing shell metacharacters are passed safely
|
||||
|
||||
- **WHEN** `addItem({ service: "a; rm -rf /", account, password })` is invoked with the default executor
|
||||
- **THEN** the argv passed to the underlying spawn API contains the literal `"a; rm -rf /"` string at the `-s` position and no shell is spawned
|
||||
|
||||
#### Scenario: A custom executor receives a parameterised argv
|
||||
|
||||
- **WHEN** a stub `CommandExecutor` is injected and any CRUD method is called
|
||||
- **THEN** the executor receives `(command, args)` where `command === "/usr/bin/security"` and `args` is a `string[]` whose values are not concatenated into a single string
|
||||
|
||||
**Verified by:** `src/core/keychain-bridge/keychain-bridge.test.ts::"forwards a parameterised argv to the executor (no shell interpolation)"` AND `src/core/keychain-bridge/keychain-bridge.test.ts::"argv preserves shell metacharacters as literal payload"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: AbortSignal cancellation and deadline enforcement
|
||||
|
||||
The package SHALL accept an optional `signal?: AbortSignal` on every public CRUD method and SHALL also derive a deadline from `config.timeoutMs` (default `10_000`) using the injected `Clock`. When either signal fires before the executor returns, the promise MUST reject with a `KeychainError` whose `code === "KEYCHAIN.TIMEOUT"`.
|
||||
|
||||
#### Scenario: Caller-supplied AbortSignal cancels an in-flight call
|
||||
|
||||
- **WHEN** `findItem` is called with an `AbortSignal` that aborts before the executor resolves
|
||||
- **THEN** the promise rejects with `KEYCHAIN.TIMEOUT` and `keychain.ops_total` records `success=false`
|
||||
|
||||
#### Scenario: timeoutMs deadline triggers timeout
|
||||
|
||||
- **WHEN** the executor never resolves and `timeoutMs` elapses on the injected clock
|
||||
- **THEN** the promise rejects with `KEYCHAIN.TIMEOUT`
|
||||
|
||||
**Verified by:** `src/core/keychain-bridge/keychain-bridge.test.ts::"rejects with KEYCHAIN.TIMEOUT when caller signal aborts"` AND `src/core/keychain-bridge/keychain-bridge.test.ts::"rejects with KEYCHAIN.TIMEOUT when timeoutMs deadline elapses"` AND `tests/property.test.ts::"timeout via AbortSignal yields KEYCHAIN.TIMEOUT"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Error classification from CLI exit code and stderr
|
||||
|
||||
The package SHALL provide `classifyError(exitCode, stderr, service, account)` which deterministically maps the documented set of macOS `security` exit codes and stderr substrings to the matching `KeychainError` factory. Stderr substring matching MUST take precedence when the exit code alone is ambiguous. Unrecognised pairs MUST return `null` so the caller can fall back to `keychainAccessFailed`.
|
||||
|
||||
#### Scenario: errSecItemNotFound is classified as KEYCHAIN.NOT_FOUND
|
||||
|
||||
- **WHEN** `classifyError(44, "could not be found", "svc", "user")` is called
|
||||
- **THEN** the result is a `KeychainError` with `code === "KEYCHAIN.NOT_FOUND"`
|
||||
|
||||
#### Scenario: Locked-keychain stderr beats ambiguous exit code
|
||||
|
||||
- **WHEN** `classifyError(1, "User interaction is not allowed; locked", "svc", "user")` is called
|
||||
- **THEN** the result is a `KeychainError` with `code === "KEYCHAIN.LOCKED"`
|
||||
|
||||
#### Scenario: Unknown exit code returns null
|
||||
|
||||
- **WHEN** `classifyError(99999, "unknown failure", "svc", "user")` is called
|
||||
- **THEN** the result is `null`
|
||||
|
||||
**Verified by:** `src/core/error-classifier/error-classifier.test.ts::"classifies errSecItemNotFound as KEYCHAIN.NOT_FOUND"` AND `src/core/error-classifier/error-classifier.test.ts::"prefers locked-stderr over ambiguous exit code"` AND `src/core/error-classifier/error-classifier.test.ts::"returns null for unknown exit code and stderr combinations"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Output parsing for `find-generic-password` and `dump-keychain`
|
||||
|
||||
The package SHALL parse the output of `security find-generic-password -g` into a `KeychainItem` (extracting `password`, optional `label`, `comment`, `createdAt`, `modifiedAt`) and the output of `security dump-keychain` into an array of `{ service, account }` records. Malformed output MUST throw a `KeychainError` with `code === "KEYCHAIN.ITEM_INVALID"`. The parser MUST handle both quoted-string and `0x...` hexadecimal password formats emitted by different `security` CLI versions.
|
||||
|
||||
#### Scenario: Quoted-string password is parsed verbatim
|
||||
|
||||
- **WHEN** `parseKeychainItem` receives stderr containing `password: "hunter2"`
|
||||
- **THEN** the returned `KeychainItem.password === "hunter2"`
|
||||
|
||||
#### Scenario: Hex-encoded password is decoded
|
||||
|
||||
- **WHEN** `parseKeychainItem` receives stderr containing `password: 0x68756e74657232`
|
||||
- **THEN** the returned `KeychainItem.password === "hunter2"`
|
||||
|
||||
#### Scenario: Missing password line throws KEYCHAIN.ITEM_INVALID
|
||||
|
||||
- **WHEN** `parseKeychainItem` receives stderr without a `password:` line
|
||||
- **THEN** it throws a `KeychainError` whose `code === "KEYCHAIN.ITEM_INVALID"`
|
||||
|
||||
**Verified by:** `src/core/output-parser/output-parser.test.ts::"parses a quoted-string password"` AND `src/core/output-parser/output-parser.test.ts::"decodes a hex-encoded password"` AND `src/core/output-parser/output-parser.test.ts::"throws KEYCHAIN.ITEM_INVALID when the password line is missing"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Zeroize sensitive intermediate buffers
|
||||
|
||||
The package SHALL allocate sensitive intermediate buffers (the byte representation of `stdout`/`stderr` containing the raw password) inside a narrow scope and zero them via `zeroize` from `@suaveplan/crypto-utils` after the parsed `KeychainItem.password` has been produced. The `KeychainItem.password` returned to the caller is a JavaScript string and remains the caller's responsibility to dispose of.
|
||||
|
||||
#### Scenario: parseKeychainItem zeroizes its working buffer
|
||||
|
||||
- **WHEN** `parseKeychainItem` is invoked with a buffer-bearing input
|
||||
- **THEN** the implementation calls `zeroize` on the working `Uint8Array` before returning
|
||||
|
||||
**Verified by:** `src/core/output-parser/output-parser.test.ts::"zeroizes the working buffer after extracting the password"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Zod schema validation at every public boundary
|
||||
|
||||
The package SHALL expose Zod schemas — `KeychainConfigSchema`, `KeychainItemSchema`, `KeychainResultSchema` — and the `MacOSKeychainBridge` constructor MUST `parse` the supplied `config` object through `KeychainConfigSchema` so invalid shapes fail fast with a Zod error rather than a downstream undefined-behaviour failure.
|
||||
|
||||
#### Scenario: Negative timeoutMs is rejected
|
||||
|
||||
- **WHEN** `new MacOSKeychainBridge({ timeoutMs: -1 })` is constructed
|
||||
- **THEN** the constructor throws a `ZodError`
|
||||
|
||||
**Verified by:** `src/schemas/macos-keychain.schemas.test.ts::"rejects negative timeoutMs"` AND `src/core/keychain-bridge/keychain-bridge.test.ts::"validates config via KeychainConfigSchema in the constructor"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: OpenTelemetry instrumentation
|
||||
|
||||
The package SHALL emit OpenTelemetry spans, counters, and histograms via the global `@opentelemetry/api` provider for every public CRUD operation. Names: spans `keychain.add` / `keychain.find` / `keychain.update` / `keychain.delete` / `keychain.list`; counter `keychain.ops_total` (labels `op`, `success`); histograms `keychain.cli_duration_ms` (label `op`) and `keychain.items_returned` (recorded only on `listItems`).
|
||||
|
||||
#### Scenario: addItem records on the ops counter and the duration histogram
|
||||
|
||||
- **WHEN** `addItem` is called against a stub executor
|
||||
- **THEN** the OTel meter has been used to increment `keychain.ops_total` and record `keychain.cli_duration_ms` without throwing
|
||||
|
||||
#### Scenario: listItems records on items_returned
|
||||
|
||||
- **WHEN** `listItems()` returns N items
|
||||
- **THEN** `keychain.items_returned` is recorded with value N
|
||||
|
||||
**Verified by:** `tests/otel.test.ts::"addItem records on the ops counter without throwing"` AND `tests/otel.test.ts::"listItems records the items_returned histogram"`
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
> None — this is a greenfield package on genesis.
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
> None.
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
203
openspec/archive/2026-05-03-enterprise-macos-keychain/tasks.md
Normal file
203
openspec/archive/2026-05-03-enterprise-macos-keychain/tasks.md
Normal file
@@ -0,0 +1,203 @@
|
||||
# Tasks: enterprise-macos-keychain
|
||||
|
||||
**Change ID**: enterprise-macos-keychain
|
||||
**Package**: `@suaveplan/macos-keychain`
|
||||
**Target path**: `universal/auth/macos-keychain`
|
||||
**Prerequisite packages on Verdaccio**: `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/crypto`
|
||||
|
||||
---
|
||||
|
||||
## 1. Scaffold
|
||||
|
||||
- [x] Create directory `universal/auth/macos-keychain/` with `src/`, `tests/`, `src/__benchmarks__/`, and all feature subdirectories per the layout in `proposal.md`.
|
||||
- [x] Write `package.json` at version `0.1.0` with:
|
||||
- `"type": "module"`, `"sideEffects": false`
|
||||
- `exports` map with top-level `"."` plus subpaths: `"./core/keychain-bridge/keychain-bridge"`, `"./core/error-classifier/error-classifier"`, `"./core/output-parser/output-parser"`, `"./errors/keychain-errors"`, `"./schemas/macos-keychain.schemas"`, `"./types/macos-keychain.types"`
|
||||
- `dependencies`: `@suaveplan/error workspace:^`, `@suaveplan/types workspace:^`, `@suaveplan/crypto workspace:^`, `zod catalog:forms`, `@opentelemetry/api catalog:observability`, `tslib catalog:build`
|
||||
- `devDependencies`: `vitest catalog:testing`, `@vitest/coverage-istanbul catalog:testing`, `fast-check catalog:testing`, `mitata catalog:testing`, `@suaveplan/testing workspace:^`
|
||||
- [x] Write `tsconfig.json` extending `@suaveplan/typescript-config/library.json`.
|
||||
- [x] Write `vitest.config.ts` using `createTestConfig()` from `@suaveplan/testing/config` (pool: `forks`).
|
||||
- [x] Add `lint`, `typecheck`, `test`, `test:coverage`, `build`, `docs:check`, `bench` scripts to `package.json`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Types and errors
|
||||
|
||||
### 2a. Types (`src/types/macos-keychain.types.ts` + `.md`)
|
||||
|
||||
- [x] Define all public interfaces: `KeychainConfig`, `KeychainItem`, `KeychainResult`, `CommandExecutor`.
|
||||
- `CommandExecutor` signature: `(command: string, args: string[]) => Promise<{ stdout: string; stderr: string; exitCode: number }>`.
|
||||
- The default `CommandExecutor` implementation uses `Bun.spawn` (Bun runtime) or `node:child_process` `execFile` (not `exec` — shell injection prevention); all arguments are passed as an array, never interpolated into a shell string.
|
||||
- [x] Add genesis-only optional fields: `clock?: Clock` and `logger?: LoggerLike` on `KeychainConfig`.
|
||||
- [x] Write `macos-keychain.types.md` (≥200 words, API reference for every type, security note on `CommandExecutor` using `execFile`/`Bun.spawn` not `exec`).
|
||||
|
||||
### 2b. Error codes (`src/error-codes.ts`)
|
||||
|
||||
- [x] Define `KeychainErrorCode` union — eight codes: `KEYCHAIN_NOT_FOUND`, `KEYCHAIN_ITEM_EXISTS`, `KEYCHAIN_ACCESS_FAILED`, `KEYCHAIN_LOCKED`, `KEYCHAIN_UNAVAILABLE`, `KEYCHAIN_PERMISSION_DENIED`, `KEYCHAIN_ITEM_INVALID`, `KEYCHAIN_TIMEOUT` (genesis addition).
|
||||
- [x] Define `KEYCHAIN_ERROR_METADATA` with `httpStatus`, `severity`, `retryable`, `userMessageKey`, `category`, `description` per code.
|
||||
- [x] Export `registerKeychainErrorCodes()` as idempotent eager side-effect; call it at file bottom.
|
||||
- [x] Export `KeychainError extends SuaveplanError<KeychainErrorCode>`.
|
||||
|
||||
### 2c. Zod schemas (`src/schemas/macos-keychain.schemas.ts` + `.md`)
|
||||
|
||||
- [x] Write Zod schemas: `KeychainConfigSchema`, `KeychainItemSchema`, `KeychainResultSchema`.
|
||||
- [x] Write `macos-keychain.schemas.md`.
|
||||
- [x] Write `macos-keychain.schemas.test.ts` — parse valid/invalid for each schema.
|
||||
|
||||
---
|
||||
|
||||
## 3. Constants (`src/constants/index.ts`)
|
||||
|
||||
- [x] Export:
|
||||
- `SECURITY_BINARY = "/usr/bin/security"` — path to the macOS security CLI.
|
||||
- `DEFAULT_TIMEOUT_MS = 10_000`.
|
||||
- `EXIT_ITEM_NOT_FOUND = 44` — `errSecItemNotFound`.
|
||||
- `EXIT_DUPLICATE_ITEM = 45` — `errSecDuplicateItem`.
|
||||
- `EXIT_AUTH_FAILED = 36` — `errAuthFailed`.
|
||||
- `EXIT_LOCKED_PATTERNS: string[]` — stderr substrings that indicate a locked keychain.
|
||||
|
||||
---
|
||||
|
||||
## 4. Error classifier (`src/core/error-classifier/`)
|
||||
|
||||
- [x] Implement `classifyError(exitCode, stderr, service, account): KeychainError | null`:
|
||||
- Map `EXIT_ITEM_NOT_FOUND` + "could not be found" / "no such keychain" stderr patterns → `keychainNotFound`.
|
||||
- Map `EXIT_DUPLICATE_ITEM` + "duplicate" / "already exists" → `keychainItemExists`.
|
||||
- Map `EXIT_AUTH_FAILED` + "auth" patterns → `keychainAccessFailed`.
|
||||
- Map locked-keychain stderr patterns → `keychainLocked`.
|
||||
- Map "permission" / "denied" / "not authorized" → `keychainPermissionDenied`.
|
||||
- Map "security: SecKeychainItemRef" invalid type → `keychainItemInvalid`.
|
||||
- Return `null` for unrecognized combinations (caller wraps in a generic `KEYCHAIN_ACCESS_FAILED`).
|
||||
- [x] Write `error-classifier.test.ts` — each known exit code + stderr combo, unknown combo returns null, stderr precedence over exit code when ambiguous. Each `it` has `{ timeout: 5000 }`.
|
||||
- [x] Write `error-classifier.md`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Error factories (`src/errors/keychain-errors.ts`)
|
||||
|
||||
- [x] Implement named factory helpers: `keychainNotFound`, `keychainItemExists`, `keychainAccessFailed`, `keychainLocked`, `keychainUnavailable`, `keychainPermissionDenied`, `keychainItemInvalid`, `keychainTimeout`.
|
||||
- [x] Each factory returns a `KeychainError` with the corresponding code, a human-readable message, and a structured context object `{ service, account }` (where applicable).
|
||||
- [x] Write `keychain-errors.test.ts` — construction of each factory, correct code, message non-empty, context fields present. Each `it` has `{ timeout: 5000 }`.
|
||||
- [x] Write `keychain-errors.md`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Output parser (`src/core/output-parser/`)
|
||||
|
||||
- [x] Implement `parseKeychainItem(stdout: string, service: string, account: string): KeychainItem`:
|
||||
- Extract `password` from `security find-generic-password -g` output (appears after `password:` line, possibly base-64 encoded for binary passwords).
|
||||
- Extract optional `label`, `comment`, `createdAt`, `modifiedAt` from structured output lines.
|
||||
- Return `KeychainItem` with all available fields.
|
||||
- Throw `KeychainError(KEYCHAIN_ITEM_INVALID)` if the output cannot be parsed.
|
||||
- [x] Implement `parseKeychainList(stdout: string): Array<{ service: string; account: string }>`:
|
||||
- Parse `security dump-keychain` output for service + account pairs.
|
||||
- [x] Write `output-parser.test.ts` — valid stdout for each supported format, missing password line, list parsing, malformed output triggers `KEYCHAIN_ITEM_INVALID`. Each `it` has `{ timeout: 5000 }`.
|
||||
- [x] Write `output-parser.md`.
|
||||
|
||||
---
|
||||
|
||||
## 7. MacOSKeychainBridge (`src/core/keychain-bridge/`)
|
||||
|
||||
- [x] Implement `MacOSKeychainBridge`:
|
||||
- Constructor: `constructor(config?: KeychainConfig, executor?: CommandExecutor)`.
|
||||
- If `executor` is not provided, use a default `CommandExecutor` that invokes `SECURITY_BINARY` via `Bun.spawn` (on Bun runtime) or `node:child_process` `execFile` (not `exec`) — arguments are always passed as an array to prevent shell injection.
|
||||
- Validate `config` with `KeychainConfigSchema`; register error codes eagerly.
|
||||
- `addItem(item)`:
|
||||
- Build args array: `["add-generic-password", "-s", service, "-a", account, "-w", password, ...]` — never interpolate into a shell string.
|
||||
- Execute via `executor`; wrap with `AbortSignal.timeout(timeoutMs)` and translate `TimeoutError` to `keychainTimeout`.
|
||||
- On `exitCode !== 0`, classify via `classifyError`; throw the result (or generic `keychainAccessFailed` for unrecognized codes).
|
||||
- On duplicate, throw `keychainItemExists`.
|
||||
- Instrument with `keychain.add` span and `keychain.ops_total` counter.
|
||||
- `findItem(service, account)`:
|
||||
- Build args array: `["find-generic-password", "-s", service, "-a", account, "-g"]`.
|
||||
- Parse stdout via `parseKeychainItem`.
|
||||
- `zeroize` the internal temporary stdout buffer after extraction.
|
||||
- Instrument with `keychain.find` span and counter.
|
||||
- `updateItem(service, account, newPassword)`:
|
||||
- Delete existing item, then re-add with new password (macOS has no native in-place update for generic passwords).
|
||||
- Instrument with `keychain.update` span and counter.
|
||||
- `deleteItem(service, account)`:
|
||||
- Build args array: `["delete-generic-password", "-s", service, "-a", account]`.
|
||||
- Instrument with `keychain.delete` span and counter.
|
||||
- `listItems(service?)`:
|
||||
- Build args for `find-generic-password` or `dump-keychain`.
|
||||
- Parse via `parseKeychainList`.
|
||||
- Instrument with `keychain.list` span and histogram `keychain.items_returned`.
|
||||
- `itemExists(service, account)`:
|
||||
- Calls `findItem`; returns `true` on success, `false` on `KEYCHAIN_NOT_FOUND`, re-throws other errors.
|
||||
- All methods accept optional `signal?: AbortSignal`; pass to `executor` and reject with `keychainTimeout` on abort.
|
||||
- Log all operations at `debug` level via `config.logger` (if provided).
|
||||
- [x] Write `keychain-bridge.test.ts` (≤500 lines):
|
||||
- Use a stub `CommandExecutor` (plain object literal — no `vi.mock`) that returns preset `{ stdout, stderr, exitCode }` values.
|
||||
- Test each CRUD operation: success path, each error code classification, AbortSignal cancellation, logger calls, OTel counter increments (in-memory exporter).
|
||||
- `{ timeout: 5000 }` on each `it`.
|
||||
- [x] Write `keychain-bridge.md` (≥200 words — architecture overview, security model noting `execFile`/`Bun.spawn` + args-array usage, platform limitations, stub executor guide for tests).
|
||||
|
||||
---
|
||||
|
||||
## 8. OTel instrumentation (cross-cutting review)
|
||||
|
||||
- [x] Confirm all spans, counters, and histograms from `proposal.md §4` are wired.
|
||||
- [x] Add `tests/otel.test.ts` — in-memory exporter; assert `keychain.ops_total` increments for add/find/update/delete, `keychain.cli_duration_ms` records, `keychain.items_returned` histogram records on list.
|
||||
|
||||
---
|
||||
|
||||
## 9. Property-based tests (`tests/property.test.ts`)
|
||||
|
||||
Use `fast-check`. All test timeouts `{ timeout: 30000 }`.
|
||||
|
||||
- [x] **Error classification coverage**: for each documented macOS exit code constant, `classifyError` returns a non-null `KeychainError` with the expected `code`.
|
||||
- [x] **Error factory idempotency**: calling `keychainNotFound(s, a)` twice returns errors with identical `.code` and `.message`.
|
||||
- [x] **Round-trip via stub executor**: `addItem` then `findItem` using a deterministic stub that echoes stored data — result password equals input password.
|
||||
- [x] **Timeout detection**: stub executor that delays beyond `timeoutMs` (AbortSignal + FakeClock) triggers `KEYCHAIN_TIMEOUT` error.
|
||||
|
||||
---
|
||||
|
||||
## 10. Benchmarks (`src/__benchmarks__/macos-keychain.bench.ts`)
|
||||
|
||||
Use `mitata`. All benchmarks use stub `CommandExecutor` (no real keychain access).
|
||||
|
||||
- [x] `findItem_stub`: 100 000 `findItem` calls via a stub executor returning a static valid stdout string — measures parse + error-classification overhead.
|
||||
- [x] `classifyError_10_patterns`: 1 000 000 calls to `classifyError` cycling through 10 exit-code/stderr pairs — pure function overhead.
|
||||
- [x] Document placeholder numbers in `README.md` under `## Benchmarks`.
|
||||
|
||||
---
|
||||
|
||||
## 11. Integration tests (`tests/integration.test.ts`)
|
||||
|
||||
- [x] Tag all tests with `// @integration` header comment.
|
||||
- [x] Guard every test with a runtime skip if not on macOS: `if (process.platform !== 'darwin') { it.skip(...) }`.
|
||||
- [x] Tests: real `addItem`, `findItem`, `updateItem`, `deleteItem`, `listItems`, `itemExists`. Clean up added items in `afterEach`.
|
||||
- [x] Exclude from coverage threshold in `vitest.config.ts`.
|
||||
|
||||
---
|
||||
|
||||
## 12. Docs
|
||||
|
||||
Per-file `.md` (all ≥200 words):
|
||||
|
||||
- [x] `src/types/macos-keychain.types.md`
|
||||
- [x] `src/core/keychain-bridge/keychain-bridge.md`
|
||||
- [x] `src/core/error-classifier/error-classifier.md`
|
||||
- [x] `src/core/output-parser/output-parser.md`
|
||||
- [x] `src/errors/keychain-errors.md`
|
||||
- [x] `src/schemas/macos-keychain.schemas.md`
|
||||
|
||||
Root README:
|
||||
|
||||
- [x] Write `universal/auth/macos-keychain/README.md`: purpose, install, quick-start (add + find + delete), security model (`execFile` array args, password zeroize, access group), stub executor guide for tests, platform requirements (macOS only for real operations), modules table, benchmarks placeholder.
|
||||
|
||||
---
|
||||
|
||||
## 13. Verify and publish
|
||||
|
||||
- [x] Run `bunx turbo lint --filter=@suaveplan/macos-keychain` — zero errors.
|
||||
- [x] Run `bunx turbo typecheck --filter=@suaveplan/macos-keychain` — zero errors.
|
||||
- [x] Run `bunx turbo test --filter=@suaveplan/macos-keychain` — all unit + property tests pass (integration skipped on non-macOS).
|
||||
- [x] Run `bunx turbo test:coverage --filter=@suaveplan/macos-keychain` — 100/100/100/100 on non-integration source files.
|
||||
- [x] Run `bun run docs:check --filter=@suaveplan/macos-keychain` — zero errors.
|
||||
- [x] Confirm no `TODO`, `FIXME`, `@deprecated`, untyped `any`, `ts-ignore` in `src/`. Platform-guarded `.skip` in `tests/integration.test.ts` is permissible.
|
||||
- [x] Confirm `"sideEffects": false` in `package.json`.
|
||||
- [x] Run `bun scripts/publish.ts --package @suaveplan/macos-keychain`.
|
||||
- [x] Update `CLAUDE.md` package listing to include `@suaveplan/macos-keychain`.
|
||||
- [x] Archive: move `openspec/changes/enterprise-macos-keychain/` to `openspec/archive/2026-04-24-enterprise-macos-keychain/` and fold spec delta into `openspec/specs/macos-keychain/spec.md`.
|
||||
Reference in New Issue
Block a user