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

9.5 KiB

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

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

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

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

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.