230 lines
12 KiB
Markdown
230 lines
12 KiB
Markdown
# 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"`
|