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

12 KiB

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"