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

13 KiB

Tasks: enterprise-macos-keychain

Change ID: enterprise-macos-keychain Package: @suaveplan/macos-keychain Target path: universal/auth/macos-keychain Prerequisite packages on Verdaccio: @suaveplan/error, @suaveplan/types, @suaveplan/crypto


1. Scaffold

  • Create directory universal/auth/macos-keychain/ with src/, tests/, src/__benchmarks__/, and all feature subdirectories per the layout in proposal.md.
  • Write package.json at version 0.1.0 with:
    • "type": "module", "sideEffects": false
    • exports map with top-level "." plus subpaths: "./core/keychain-bridge/keychain-bridge", "./core/error-classifier/error-classifier", "./core/output-parser/output-parser", "./errors/keychain-errors", "./schemas/macos-keychain.schemas", "./types/macos-keychain.types"
    • dependencies: @suaveplan/error workspace:^, @suaveplan/types workspace:^, @suaveplan/crypto workspace:^, zod catalog:forms, @opentelemetry/api catalog:observability, tslib catalog:build
    • devDependencies: vitest catalog:testing, @vitest/coverage-istanbul catalog:testing, fast-check catalog:testing, mitata catalog:testing, @suaveplan/testing workspace:^
  • Write tsconfig.json extending @suaveplan/typescript-config/library.json.
  • Write vitest.config.ts using createTestConfig() from @suaveplan/testing/config (pool: forks).
  • Add lint, typecheck, test, test:coverage, build, docs:check, bench scripts to package.json.

2. Types and errors

2a. Types (src/types/macos-keychain.types.ts + .md)

  • Define all public interfaces: KeychainConfig, KeychainItem, KeychainResult, CommandExecutor.
    • CommandExecutor signature: (command: string, args: string[]) => Promise<{ stdout: string; stderr: string; exitCode: number }>.
    • The default CommandExecutor implementation uses Bun.spawn (Bun runtime) or node:child_process execFile (not exec — shell injection prevention); all arguments are passed as an array, never interpolated into a shell string.
  • Add genesis-only optional fields: clock?: Clock and logger?: LoggerLike on KeychainConfig.
  • Write macos-keychain.types.md (≥200 words, API reference for every type, security note on CommandExecutor using execFile/Bun.spawn not exec).

2b. Error codes (src/error-codes.ts)

  • Define KeychainErrorCode union — eight codes: KEYCHAIN_NOT_FOUND, KEYCHAIN_ITEM_EXISTS, KEYCHAIN_ACCESS_FAILED, KEYCHAIN_LOCKED, KEYCHAIN_UNAVAILABLE, KEYCHAIN_PERMISSION_DENIED, KEYCHAIN_ITEM_INVALID, KEYCHAIN_TIMEOUT (genesis addition).
  • Define KEYCHAIN_ERROR_METADATA with httpStatus, severity, retryable, userMessageKey, category, description per code.
  • Export registerKeychainErrorCodes() as idempotent eager side-effect; call it at file bottom.
  • Export KeychainError extends SuaveplanError<KeychainErrorCode>.

2c. Zod schemas (src/schemas/macos-keychain.schemas.ts + .md)

  • Write Zod schemas: KeychainConfigSchema, KeychainItemSchema, KeychainResultSchema.
  • Write macos-keychain.schemas.md.
  • Write macos-keychain.schemas.test.ts — parse valid/invalid for each schema.

3. Constants (src/constants/index.ts)

  • Export:
    • SECURITY_BINARY = "/usr/bin/security" — path to the macOS security CLI.
    • DEFAULT_TIMEOUT_MS = 10_000.
    • EXIT_ITEM_NOT_FOUND = 44 — errSecItemNotFound.
    • EXIT_DUPLICATE_ITEM = 45 — errSecDuplicateItem.
    • EXIT_AUTH_FAILED = 36 — errAuthFailed.
    • EXIT_LOCKED_PATTERNS: string[] — stderr substrings that indicate a locked keychain.

4. Error classifier (src/core/error-classifier/)

  • Implement classifyError(exitCode, stderr, service, account): KeychainError | null:
    • Map EXIT_ITEM_NOT_FOUND + "could not be found" / "no such keychain" stderr patterns → keychainNotFound.
    • Map EXIT_DUPLICATE_ITEM + "duplicate" / "already exists" → keychainItemExists.
    • Map EXIT_AUTH_FAILED + "auth" patterns → keychainAccessFailed.
    • Map locked-keychain stderr patterns → keychainLocked.
    • Map "permission" / "denied" / "not authorized" → keychainPermissionDenied.
    • Map "security: SecKeychainItemRef" invalid type → keychainItemInvalid.
    • Return null for unrecognized combinations (caller wraps in a generic KEYCHAIN_ACCESS_FAILED).
  • Write error-classifier.test.ts — each known exit code + stderr combo, unknown combo returns null, stderr precedence over exit code when ambiguous. Each it has { timeout: 5000 }.
  • Write error-classifier.md.

5. Error factories (src/errors/keychain-errors.ts)

  • Implement named factory helpers: keychainNotFound, keychainItemExists, keychainAccessFailed, keychainLocked, keychainUnavailable, keychainPermissionDenied, keychainItemInvalid, keychainTimeout.
  • Each factory returns a KeychainError with the corresponding code, a human-readable message, and a structured context object { service, account } (where applicable).
  • Write keychain-errors.test.ts — construction of each factory, correct code, message non-empty, context fields present. Each it has { timeout: 5000 }.
  • Write keychain-errors.md.

6. Output parser (src/core/output-parser/)

  • Implement parseKeychainItem(stdout: string, service: string, account: string): KeychainItem:
    • Extract password from security find-generic-password -g output (appears after password: line, possibly base-64 encoded for binary passwords).
    • Extract optional label, comment, createdAt, modifiedAt from structured output lines.
    • Return KeychainItem with all available fields.
    • Throw KeychainError(KEYCHAIN_ITEM_INVALID) if the output cannot be parsed.
  • Implement parseKeychainList(stdout: string): Array<{ service: string; account: string }>:
    • Parse security dump-keychain output for service + account pairs.
  • Write output-parser.test.ts — valid stdout for each supported format, missing password line, list parsing, malformed output triggers KEYCHAIN_ITEM_INVALID. Each it has { timeout: 5000 }.
  • Write output-parser.md.

7. MacOSKeychainBridge (src/core/keychain-bridge/)

  • Implement MacOSKeychainBridge:
    • Constructor: constructor(config?: KeychainConfig, executor?: CommandExecutor).
      • If executor is not provided, use a default CommandExecutor that invokes SECURITY_BINARY via Bun.spawn (on Bun runtime) or node:child_process execFile (not exec) — arguments are always passed as an array to prevent shell injection.
      • Validate config with KeychainConfigSchema; register error codes eagerly.
    • addItem(item):
      • Build args array: ["add-generic-password", "-s", service, "-a", account, "-w", password, ...] — never interpolate into a shell string.
      • Execute via executor; wrap with AbortSignal.timeout(timeoutMs) and translate TimeoutError to keychainTimeout.
      • On exitCode !== 0, classify via classifyError; throw the result (or generic keychainAccessFailed for unrecognized codes).
      • On duplicate, throw keychainItemExists.
      • Instrument with keychain.add span and keychain.ops_total counter.
    • findItem(service, account):
      • Build args array: ["find-generic-password", "-s", service, "-a", account, "-g"].
      • Parse stdout via parseKeychainItem.
      • zeroize the internal temporary stdout buffer after extraction.
      • Instrument with keychain.find span and counter.
    • updateItem(service, account, newPassword):
      • Delete existing item, then re-add with new password (macOS has no native in-place update for generic passwords).
      • Instrument with keychain.update span and counter.
    • deleteItem(service, account):
      • Build args array: ["delete-generic-password", "-s", service, "-a", account].
      • Instrument with keychain.delete span and counter.
    • listItems(service?):
      • Build args for find-generic-password or dump-keychain.
      • Parse via parseKeychainList.
      • Instrument with keychain.list span and histogram keychain.items_returned.
    • itemExists(service, account):
      • Calls findItem; returns true on success, false on KEYCHAIN_NOT_FOUND, re-throws other errors.
    • All methods accept optional signal?: AbortSignal; pass to executor and reject with keychainTimeout on abort.
    • Log all operations at debug level via config.logger (if provided).
  • Write keychain-bridge.test.ts (≤500 lines):
    • Use a stub CommandExecutor (plain object literal — no vi.mock) that returns preset { stdout, stderr, exitCode } values.
    • Test each CRUD operation: success path, each error code classification, AbortSignal cancellation, logger calls, OTel counter increments (in-memory exporter).
    • { timeout: 5000 } on each it.
  • Write keychain-bridge.md (≥200 words — architecture overview, security model noting execFile/Bun.spawn + args-array usage, platform limitations, stub executor guide for tests).

8. OTel instrumentation (cross-cutting review)

  • Confirm all spans, counters, and histograms from proposal.md §4 are wired.
  • Add tests/otel.test.ts — in-memory exporter; assert keychain.ops_total increments for add/find/update/delete, keychain.cli_duration_ms records, keychain.items_returned histogram records on list.

9. Property-based tests (tests/property.test.ts)

Use fast-check. All test timeouts { timeout: 30000 }.

  • Error classification coverage: for each documented macOS exit code constant, classifyError returns a non-null KeychainError with the expected code.
  • Error factory idempotency: calling keychainNotFound(s, a) twice returns errors with identical .code and .message.
  • Round-trip via stub executor: addItem then findItem using a deterministic stub that echoes stored data — result password equals input password.
  • Timeout detection: stub executor that delays beyond timeoutMs (AbortSignal + FakeClock) triggers KEYCHAIN_TIMEOUT error.

10. Benchmarks (src/__benchmarks__/macos-keychain.bench.ts)

Use mitata. All benchmarks use stub CommandExecutor (no real keychain access).

  • findItem_stub: 100 000 findItem calls via a stub executor returning a static valid stdout string — measures parse + error-classification overhead.
  • classifyError_10_patterns: 1 000 000 calls to classifyError cycling through 10 exit-code/stderr pairs — pure function overhead.
  • Document placeholder numbers in README.md under ## Benchmarks.

11. Integration tests (tests/integration.test.ts)

  • Tag all tests with // @integration header comment.
  • Guard every test with a runtime skip if not on macOS: if (process.platform !== 'darwin') { it.skip(...) }.
  • Tests: real addItem, findItem, updateItem, deleteItem, listItems, itemExists. Clean up added items in afterEach.
  • Exclude from coverage threshold in vitest.config.ts.

12. Docs

Per-file .md (all ≥200 words):

  • src/types/macos-keychain.types.md
  • src/core/keychain-bridge/keychain-bridge.md
  • src/core/error-classifier/error-classifier.md
  • src/core/output-parser/output-parser.md
  • src/errors/keychain-errors.md
  • src/schemas/macos-keychain.schemas.md

Root README:

  • Write universal/auth/macos-keychain/README.md: purpose, install, quick-start (add + find + delete), security model (execFile array args, password zeroize, access group), stub executor guide for tests, platform requirements (macOS only for real operations), modules table, benchmarks placeholder.

13. Verify and publish

  • Run bunx turbo lint --filter=@suaveplan/macos-keychain — zero errors.
  • Run bunx turbo typecheck --filter=@suaveplan/macos-keychain — zero errors.
  • Run bunx turbo test --filter=@suaveplan/macos-keychain — all unit + property tests pass (integration skipped on non-macOS).
  • Run bunx turbo test:coverage --filter=@suaveplan/macos-keychain — 100/100/100/100 on non-integration source files.
  • Run bun run docs:check --filter=@suaveplan/macos-keychain — zero errors.
  • Confirm no TODO, FIXME, @deprecated, untyped any, ts-ignore in src/. Platform-guarded .skip in tests/integration.test.ts is permissible.
  • Confirm "sideEffects": false in package.json.
  • Run bun scripts/publish.ts --package @suaveplan/macos-keychain.
  • Update CLAUDE.md package listing to include @suaveplan/macos-keychain.
  • Archive: move openspec/changes/enterprise-macos-keychain/ to openspec/archive/2026-04-24-enterprise-macos-keychain/ and fold spec delta into openspec/specs/macos-keychain/spec.md.