# macos-keychain **Category**: universal **Path**: `packages/auth/macos-keychain` **Archived from**: `openspec/archive/2026-05-03-enterprise-macos-keychain/` --- ## Purpose `@suaveplan/macos-keychain` is the Suaveplan bridge to the macOS Keychain. It wraps the `/usr/bin/security` CLI behind a strongly-typed `MacOSKeychainBridge` class that performs generic-password CRUD (`addItem`, `findItem`, `updateItem`, `deleteItem`, `listItems`, `itemExists`) with Zod-validated config, AbortSignal cancellation, OpenTelemetry instrumentation, an injectable `Clock`, password-buffer zeroize via `@suaveplan/crypto-utils`, and a pluggable `CommandExecutor` so tests run anywhere without touching the real keychain. ## 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.spawn` (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"`