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

186 lines
11 KiB
Markdown

<!-- 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 -->