Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,142 @@
# @suaveplan/macos-keychain - macOS Keychain bridge for generic-password credentials via the /usr/bin/security CLI: typed errors, AbortSignal cancellation, OTel instrumentation, and an injectable CommandExecutor for testing
## Summary
macOS Keychain bridge for generic-password credentials. Wraps the system
`/usr/bin/security` CLI behind a strongly-typed CRUD surface with typed errors,
AbortSignal cancellation, OpenTelemetry instrumentation, and an injectable
`CommandExecutor` that lets tests run anywhere without touching the real
keychain.
<!-- begin:auto-docs -->
## Documentation tree
- [error-classifier](./src/core/error-classifier/error-classifier.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [error-classifier](./src/core/error-classifier/error-classifier.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [keychain-bridge](./src/core/keychain-bridge/keychain-bridge.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [default-executor](./src/core/keychain-bridge/default-executor.md) — Provides the runtime-aware default `CommandExecutor` used by the bridge when the consumer does not inject one. The exec…
- [keychain-bridge](./src/core/keychain-bridge/keychain-bridge.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [output-parser](./src/core/output-parser/output-parser.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [output-parser](./src/core/output-parser/output-parser.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [errors](./src/errors/keychain-errors.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [keychain-errors](./src/errors/keychain-errors.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [schemas](./src/schemas/macos-keychain.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [macos-keychain.schemas](./src/schemas/macos-keychain.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [error-codes](./src/error-codes.md) — Centralises the eight error codes emitted by `@suaveplan/macos-keychain` and the metadata table that downstream consume…
<!-- end:auto-docs --> - [error-classifier](./src/core/error-classifier/error-classifier.md) — <!-- begin:auto-docs --> _(no submodules)_- [keychain-bridge](./src/core/keychain-bridge/keychain-bridge.md) — <!-- begin:auto-docs --> _(no submodules)_- [default-executor](./src/core/keychain-bridge/default-executor.md) — Provides the runtime-aware default `CommandExecutor` used by the bridge when the consumer does not inject one. The exec…
- [keychain-bridge](./src/core/keychain-bridge/keychain-bridge.md) — <!-- begin:auto-docs --> _(no submodules)_- [output-parser](./src/core/output-parser/output-parser.md) — <!-- begin:auto-docs --> _(no submodules)_- [output-parser](./src/core/output-parser/output-parser.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/keychain-errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [keychain-errors](./src/errors/keychain-errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/macos-keychain.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [macos-keychain.schemas](./src/schemas/macos-keychain.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Centralises the eight error codes emitted by `@suaveplan/macos-keychain` and the metadata table that downstream consume…
- [error-classifier](./src/core/error-classifier/error-classifier.md) — <!-- begin:auto-docs --> _(no submodules)_- [keychain-bridge](./src/core/keychain-bridge/keychain-bridge.md) — <!-- begin:auto-docs --> _(no submodules)_- [default-executor](./src/core/keychain-bridge/default-executor.md) — Provides the runtime-aware default `CommandExecutor` used by the bridge when the consumer does not inject one. The exec…
- [keychain-bridge](./src/core/keychain-bridge/keychain-bridge.md) — <!-- begin:auto-docs --> _(no submodules)_- [output-parser](./src/core/output-parser/output-parser.md) — <!-- begin:auto-docs --> _(no submodules)_- [output-parser](./src/core/output-parser/output-parser.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/keychain-errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [keychain-errors](./src/errors/keychain-errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/macos-keychain.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [macos-keychain.schemas](./src/schemas/macos-keychain.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Centralises the eight error codes emitted by `@suaveplan/macos-keychain` and the metadata table that downstream consume…
- [error-classifier](./src/core/error-classifier/error-classifier.md) — <!-- begin:auto-docs --> _(no submodules)_- [keychain-bridge](./src/core/keychain-bridge/keychain-bridge.md) — <!-- begin:auto-docs --> _(no submodules)_- [default-executor](./src/core/keychain-bridge/default-executor.md) — Provides the runtime-aware default `CommandExecutor` used by the bridge when the consumer does not inject one. The exec…
- [keychain-bridge](./src/core/keychain-bridge/keychain-bridge.md) — <!-- begin:auto-docs --> _(no submodules)_- [output-parser](./src/core/output-parser/output-parser.md) — <!-- begin:auto-docs --> _(no submodules)_- [output-parser](./src/core/output-parser/output-parser.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/keychain-errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [keychain-errors](./src/errors/keychain-errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/macos-keychain.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [macos-keychain.schemas](./src/schemas/macos-keychain.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Centralises the eight error codes emitted by `@suaveplan/macos-keychain` and the metadata table that downstream consume…
- [error-classifier](./src/core/error-classifier/error-classifier.md) — <!-- begin:auto-docs --> _(no submodules)_- [keychain-bridge](./src/core/keychain-bridge/keychain-bridge.md) — <!-- begin:auto-docs --> _(no submodules)_ - [default-executor](./src/core/keychain-bridge/default-executor.md) — Provides the runtime-aware default `CommandExecutor` used by the bridge when the consumer does not inject one. The exec…
- [keychain-bridge](./src/core/keychain-bridge/keychain-bridge.md) — <!-- begin:auto-docs --> _(no submodules)_- [output-parser](./src/core/output-parser/output-parser.md) — <!-- begin:auto-docs --> _(no submodules)_ - [output-parser](./src/core/output-parser/output-parser.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/keychain-errors.md) — <!-- begin:auto-docs --> _(no submodules)_ - [keychain-errors](./src/errors/keychain-errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/macos-keychain.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ - [macos-keychain.schemas](./src/schemas/macos-keychain.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Centralises the eight error codes emitted by `@suaveplan/macos-keychain` and the metadata table that downstream consume…
## Install
```bash
bun add @suaveplan/macos-keychain
```
## Quick start
```ts
import { MacOSKeychainBridge } from "@suaveplan/macos-keychain";
const bridge = new MacOSKeychainBridge({ timeoutMs: 5_000 });
await bridge.addItem({
service: "my-app",
account: "alice",
password: "hunter2",
});
const item = await bridge.findItem("my-app", "alice");
console.log(item.password); // "hunter2"
await bridge.deleteItem("my-app", "alice");
```
## Security model
- Arguments are always passed to the binary as a `string[]` — no shell
wrapper, no string interpolation. Even payloads containing shell
metacharacters (`a; rm -rf /`, `$(whoami)`, …) are forwarded verbatim.
- The default child-process launcher uses `Bun.spawn` on Bun and Node's
`child_process.spawn` otherwise; both spawn the binary directly by file
path.
- The output parser materialises the password bytes into a temporary
`Uint8Array`, decodes UTF-8, and zeroes the buffer via
`@suaveplan/crypto-utils`'s `zeroize` before the parser returns. The
decoded `string` is the caller's responsibility to dispose of.
- The `accessGroup` config option is forwarded to the CLI so credentials
can be scoped to a specific keychain access group.
## Stub-executor guide for tests
```ts
import {
MacOSKeychainBridge,
type CommandExecutor,
} from "@suaveplan/macos-keychain";
const stubRunner: CommandExecutor = async (_cmd, args) => {
if (args[0] === "find-generic-password") {
return { stdout: "", stderr: 'password: "stored"', exitCode: 0 };
}
return { stdout: "", stderr: "", exitCode: 0 };
};
const bridge = new MacOSKeychainBridge({ timeoutMs: 1_000 }, stubRunner);
const item = await bridge.findItem("svc", "user");
```
The bridge's only escape hatch into the OS is the second constructor
argument; injecting any plain object literal that satisfies the
`CommandExecutor` type covers every branch without `vi.mock`.
## Platform requirements
The package builds on every platform but the real CRUD operations only work
on macOS where `/usr/bin/security` exists. Off-Darwin, the bridge throws
`KEYCHAIN.UNAVAILABLE`. The integration test file
(`tests/integration.test.ts`) is platform-guarded and skipped automatically
on non-Darwin hosts.
## Modules
- [src/types/macos-keychain.types.md](./src/types/macos-keychain.types.md) — public type contracts.
- [src/error-codes.md](./src/error-codes.md) — eight stable codes + metadata table.
- [src/errors/keychain-errors.md](./src/errors/keychain-errors.md) — named factory helpers.
- [src/schemas/macos-keychain.schemas.md](./src/schemas/macos-keychain.schemas.md) — Zod schemas.
- [src/core/error-classifier/error-classifier.md](./src/core/error-classifier/error-classifier.md) — exit-code/stderr mapping.
- [src/core/output-parser/output-parser.md](./src/core/output-parser/output-parser.md) — `find-generic-password` and `dump-keychain` parsers.
- [src/core/keychain-bridge/keychain-bridge.md](./src/core/keychain-bridge/keychain-bridge.md) — the entry-point class.
- [src/core/keychain-bridge/default-executor.md](./src/core/keychain-bridge/default-executor.md) — runtime-aware default `CommandExecutor`.
## Benchmarks
Run via `bun run bench`. Numbers are reported on the local macOS / Linux
host and serve as drift trackers, not absolute SLOs.
| Benchmark | Iterations | Notes |
|-----------------------------|------------|----------------------------------------------|
| `findItem_stub` | 100 000 | Parses static stdout/stderr through the bridge. |
| `classifyError_10_patterns` | 1 000 000 | Pure-function classification overhead. |
Baseline numbers will be inserted once the first hosted CI runs the bench
job; until then the suite exists to detect regressions locally via
`bunx mitata`.
## Errors
Eight codes — see `src/error-codes.md` for the full metadata table:
`KEYCHAIN.NOT_FOUND` · `KEYCHAIN.ITEM_EXISTS` · `KEYCHAIN.ACCESS_FAILED` ·
`KEYCHAIN.LOCKED` · `KEYCHAIN.UNAVAILABLE` · `KEYCHAIN.PERMISSION_DENIED` ·
`KEYCHAIN.ITEM_INVALID` · `KEYCHAIN.TIMEOUT`.
## License
MIT © Suaveplan Team.

View File

@@ -0,0 +1,127 @@
{
"name": "@suaveplan/macos-keychain",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "macOS Keychain bridge for generic-password credentials via the /usr/bin/security CLI: typed errors, AbortSignal cancellation, OTel instrumentation, and an injectable CommandExecutor for testing",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./core/keychain-bridge/keychain-bridge": {
"import": {
"types": "./dist/core/keychain-bridge/keychain-bridge.d.ts",
"default": "./dist/core/keychain-bridge/keychain-bridge.js"
},
"require": {
"types": "./dist/core/keychain-bridge/keychain-bridge.d.ts",
"default": "./dist/core/keychain-bridge/keychain-bridge.js"
}
},
"./core/error-classifier/error-classifier": {
"import": {
"types": "./dist/core/error-classifier/error-classifier.d.ts",
"default": "./dist/core/error-classifier/error-classifier.js"
},
"require": {
"types": "./dist/core/error-classifier/error-classifier.d.ts",
"default": "./dist/core/error-classifier/error-classifier.js"
}
},
"./core/output-parser/output-parser": {
"import": {
"types": "./dist/core/output-parser/output-parser.d.ts",
"default": "./dist/core/output-parser/output-parser.js"
},
"require": {
"types": "./dist/core/output-parser/output-parser.d.ts",
"default": "./dist/core/output-parser/output-parser.js"
}
},
"./errors/keychain-errors": {
"import": {
"types": "./dist/errors/keychain-errors.d.ts",
"default": "./dist/errors/keychain-errors.js"
},
"require": {
"types": "./dist/errors/keychain-errors.d.ts",
"default": "./dist/errors/keychain-errors.js"
}
},
"./schemas/macos-keychain.schemas": {
"import": {
"types": "./dist/schemas/macos-keychain.schemas.d.ts",
"default": "./dist/schemas/macos-keychain.schemas.js"
},
"require": {
"types": "./dist/schemas/macos-keychain.schemas.d.ts",
"default": "./dist/schemas/macos-keychain.schemas.js"
}
},
"./types/macos-keychain.types": {
"import": {
"types": "./dist/types/macos-keychain.types.d.ts",
"default": "./dist/types/macos-keychain.types.js"
},
"require": {
"types": "./dist/types/macos-keychain.types.d.ts",
"default": "./dist/types/macos-keychain.types.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts .",
"bench": "bun run src/__benchmarks__/macos-keychain.bench.ts"
},
"dependencies": {
"@suaveplan/crypto-utils": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms",
"@opentelemetry/api": "catalog:observability"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@suaveplan/typescript-config": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"mitata": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,92 @@
/**
* Constants for `@suaveplan/macos-keychain`.
*
* Exit codes match Apple's `errSec*` values from `<Security/SecBase.h>`.
* Stderr substring patterns are sourced from the macOS `security` CLI source
* tree and confirmed against live output captured in
* `tests/integration.test.ts` on macOS 14.
*/
/** Absolute path of the macOS `security` binary. */
export const SECURITY_BINARY = "/usr/bin/security";
/** Default per-CLI-call timeout in milliseconds. */
export const DEFAULT_TIMEOUT_MS = 10_000;
/** `errSecItemNotFound` — the keychain item could not be located. */
export const EXIT_ITEM_NOT_FOUND = 44;
/** `errSecDuplicateItem` — an item with the same primary key already exists. */
export const EXIT_DUPLICATE_ITEM = 45;
/** `errSecAuthFailed` — the user (or process) failed authentication. */
export const EXIT_AUTH_FAILED = 36;
/** `errSecInteractionNotAllowed` — non-interactive context blocked the call. */
export const EXIT_INTERACTION_NOT_ALLOWED = 25_308;
/** `errSecInvalidKeychain` — keychain reference is invalid or malformed. */
export const EXIT_INVALID_KEYCHAIN = 25_295;
/**
* Stderr substrings that indicate the keychain is locked. The classifier
* checks each pattern (case-insensitive) before falling back to exit-code
* mapping; a locked stderr beats an ambiguous exit code so callers see the
* actionable `KEYCHAIN.LOCKED` rather than a generic access failure.
*/
export const EXIT_LOCKED_PATTERNS: readonly string[] = [
"user interaction is not allowed",
"the user name or passphrase you entered is not correct",
"keychain is locked",
"keychain could not be unlocked",
];
/**
* Stderr substrings that indicate a `not-found` outcome. Apple's CLI emits
* different phrasings on different OS versions; the classifier accepts any
* of these as evidence the lookup target does not exist.
*/
export const NOT_FOUND_STDERR_PATTERNS: readonly string[] = [
"could not be found",
"not be found in the user's default keychain",
"no such keychain",
"the specified item could not be found",
];
/**
* Stderr substrings that indicate a duplicate-item outcome.
*/
export const DUPLICATE_STDERR_PATTERNS: readonly string[] = [
"duplicate",
"already exists",
"the specified item already exists",
];
/**
* Stderr substrings that indicate a permission / authorization failure.
*/
export const PERMISSION_STDERR_PATTERNS: readonly string[] = [
"permission",
"not authorized",
"access denied",
"user is not allowed",
];
/**
* Stderr substrings that indicate the keychain service itself is unavailable.
*/
export const UNAVAILABLE_STDERR_PATTERNS: readonly string[] = [
"keychain service unavailable",
"no such file or directory",
"/usr/bin/security: command not found",
];
/**
* Stderr substrings that indicate the keychain reference or item type is
* invalid (e.g. malformed primary key, non-string fields).
*/
export const INVALID_STDERR_PATTERNS: readonly string[] = [
"seckeychainitemref",
"invalid",
"malformed",
];

View File

@@ -0,0 +1,97 @@
# error-classifier
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Translates the noisy `(exitCode, stderr)` tuples emitted by the macOS
`security` binary into a single `KeychainError` with a stable code. Apple's
CLI returns the same numeric exit code for several distinct failure modes,
so the classifier looks at stderr substrings first; the exit code only
matters when stderr is silent or generic.
This separation keeps the bridge implementation small — every CRUD method
calls the classifier on a non-zero exit code and either rethrows the
returned error or, when the classifier returns `null`, wraps the failure in
`keychainAccessFailed` for the trace to retain the original stderr.
## Features
- Pure function — deterministic on its inputs, no globals, no allocations
beyond the returned error.
- Stderr substring matching (case-insensitive) takes precedence over numeric
exit codes.
- Five exit codes are mapped: `errSecItemNotFound`, `errSecDuplicateItem`,
`errSecAuthFailed`, `errSecInteractionNotAllowed`, `errSecInvalidKeychain`.
- Returns `null` for unrecognised pairs so the caller can decide between a
generic fallback and a hard fail.
## Usage: Basic Example
```ts
import { classifyError } from "@suaveplan/macos-keychain";
const err = classifyError(44, "could not be found", "my-app", "alice");
console.error(err?.code); // "KEYCHAIN.NOT_FOUND"
```
## Usage: Advanced Example
```ts
import {
classifyError,
keychainAccessFailed,
} from "@suaveplan/macos-keychain";
async function callSecurity(): Promise<void> {
const result = await runner("/usr/bin/security", ["find-generic-password"]);
if (result.exitCode === 0) return;
const classified = classifyError(
result.exitCode,
result.stderr,
"my-app",
"alice"
);
throw classified ?? keychainAccessFailed("my-app", "alice", result.stderr);
}
declare function runner(
cmd: string,
args: readonly string[]
): Promise<{ stdout: string; stderr: string; exitCode: number }>;
```
## API Reference
- **`classifyError(exitCode: number, stderr: string, service: string,
account: string): KeychainError | null`** — returns the matching
`KeychainError` or `null` for unknown combinations. Pure function.
Precedence:
1. Stderr matches `EXIT_LOCKED_PATTERNS` → `KEYCHAIN.LOCKED`.
2. Stderr matches `UNAVAILABLE_STDERR_PATTERNS` → `KEYCHAIN.UNAVAILABLE`.
3. Stderr matches `PERMISSION_STDERR_PATTERNS` →
`KEYCHAIN.PERMISSION_DENIED`.
4. Stderr matches `NOT_FOUND_STDERR_PATTERNS` → `KEYCHAIN.NOT_FOUND`.
5. Stderr matches `DUPLICATE_STDERR_PATTERNS` → `KEYCHAIN.ITEM_EXISTS`.
6. Stderr matches `INVALID_STDERR_PATTERNS` → `KEYCHAIN.ITEM_INVALID`.
7. Exit code `44` → `KEYCHAIN.NOT_FOUND`.
8. Exit code `45` → `KEYCHAIN.ITEM_EXISTS`.
9. Exit code `36` → `KEYCHAIN.ACCESS_FAILED`.
10. Exit code `25308` → `KEYCHAIN.LOCKED`.
11. Exit code `25295` → `KEYCHAIN.ITEM_INVALID`.
12. Otherwise → `null`.
## Implementation Notes
- Substring matching is case-insensitive; both the haystack and the needles
are lower-cased before `String.prototype.includes`.
- The locked-pattern check runs first because Apple frequently surfaces a
locked-keychain failure with exit code `1`, which the numeric switch
cannot disambiguate.
- The classifier never throws; on a malformed input (e.g. NaN exit code) it
returns `null` and lets the caller's fallback layer take over.
- Adding a new `errSec*` constant requires three edits: a new entry in
`constants/index.ts`, a new branch in this file's `switch`, and a new
scenario in `error-classifier.test.ts`.

View File

@@ -0,0 +1,140 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
EXIT_AUTH_FAILED,
EXIT_DUPLICATE_ITEM,
EXIT_INTERACTION_NOT_ALLOWED,
EXIT_INVALID_KEYCHAIN,
EXIT_ITEM_NOT_FOUND,
} from "../../constants/index.js";
import { classifyError } from "./error-classifier.js";
describe("classifyError", () => {
it("classifies errSecItemNotFound as KEYCHAIN.NOT_FOUND", {
timeout: 5000,
}, () => {
const err = classifyError(
EXIT_ITEM_NOT_FOUND,
"could not be found",
"svc",
"user"
);
expect(err?.code).toBe("KEYCHAIN.NOT_FOUND");
});
it("classifies errSecDuplicateItem as KEYCHAIN.ITEM_EXISTS", {
timeout: 5000,
}, () => {
const err = classifyError(
EXIT_DUPLICATE_ITEM,
"duplicate item",
"svc",
"user"
);
expect(err?.code).toBe("KEYCHAIN.ITEM_EXISTS");
});
it("classifies errSecAuthFailed as KEYCHAIN.ACCESS_FAILED via exit code", {
timeout: 5000,
}, () => {
const err = classifyError(EXIT_AUTH_FAILED, "", "svc", "user");
expect(err?.code).toBe("KEYCHAIN.ACCESS_FAILED");
});
it("classifies errSecInteractionNotAllowed as KEYCHAIN.LOCKED via exit code", {
timeout: 5000,
}, () => {
const err = classifyError(
EXIT_INTERACTION_NOT_ALLOWED,
"non-interactive",
"svc",
"user"
);
expect(err?.code).toBe("KEYCHAIN.LOCKED");
});
it("classifies errSecInvalidKeychain as KEYCHAIN.ITEM_INVALID via exit code", {
timeout: 5000,
}, () => {
const err = classifyError(EXIT_INVALID_KEYCHAIN, "", "svc", "user");
expect(err?.code).toBe("KEYCHAIN.ITEM_INVALID");
});
it("prefers locked-stderr over ambiguous exit code", {
timeout: 5000,
}, () => {
const err = classifyError(
1,
"User interaction is not allowed; locked",
"svc",
"user"
);
expect(err?.code).toBe("KEYCHAIN.LOCKED");
});
it("prefers unavailable-stderr over ambiguous exit code", {
timeout: 5000,
}, () => {
const err = classifyError(
1,
"no such file or directory",
"svc",
"user"
);
expect(err?.code).toBe("KEYCHAIN.UNAVAILABLE");
});
it("prefers permission-stderr over ambiguous exit code", {
timeout: 5000,
}, () => {
const err = classifyError(1, "permission denied", "svc", "user");
expect(err?.code).toBe("KEYCHAIN.PERMISSION_DENIED");
});
it("prefers not-found-stderr over ambiguous exit code", {
timeout: 5000,
}, () => {
const err = classifyError(
1,
"the specified item could not be found",
"svc",
"user"
);
expect(err?.code).toBe("KEYCHAIN.NOT_FOUND");
});
it("prefers duplicate-stderr over ambiguous exit code", {
timeout: 5000,
}, () => {
const err = classifyError(1, "already exists", "svc", "user");
expect(err?.code).toBe("KEYCHAIN.ITEM_EXISTS");
});
it("prefers invalid-stderr over ambiguous exit code", {
timeout: 5000,
}, () => {
const err = classifyError(
1,
"SecKeychainItemRef invalid",
"svc",
"user"
);
expect(err?.code).toBe("KEYCHAIN.ITEM_INVALID");
});
it("returns null for unknown exit code and stderr combinations", {
timeout: 5000,
}, () => {
const err = classifyError(99999, "totally unknown", "svc", "user");
expect(err).toBeNull();
});
it("preserves service and account in the structured context", {
timeout: 5000,
}, () => {
const err = classifyError(EXIT_ITEM_NOT_FOUND, "", "svc-x", "user-y");
expect(err?.context).toMatchObject({
service: "svc-x",
account: "user-y",
});
});
});

View File

@@ -0,0 +1,92 @@
/**
* Maps `(exitCode, stderr)` tuples emitted by the macOS `security` CLI to the
* matching `KeychainError` factory. Stderr substrings take precedence over
* the exit code because Apple's CLI is more specific in stderr than in its
* numeric return code (e.g. exit `1` covers many distinct failure modes).
*/
import {
DUPLICATE_STDERR_PATTERNS,
EXIT_AUTH_FAILED,
EXIT_DUPLICATE_ITEM,
EXIT_INTERACTION_NOT_ALLOWED,
EXIT_INVALID_KEYCHAIN,
EXIT_ITEM_NOT_FOUND,
EXIT_LOCKED_PATTERNS,
INVALID_STDERR_PATTERNS,
NOT_FOUND_STDERR_PATTERNS,
PERMISSION_STDERR_PATTERNS,
UNAVAILABLE_STDERR_PATTERNS,
} from "../../constants/index.js";
import type { KeychainError } from "../../error-codes.js";
import {
keychainAccessFailed,
keychainItemExists,
keychainItemInvalid,
keychainLocked,
keychainNotFound,
keychainPermissionDenied,
keychainUnavailable,
} from "../../errors/keychain-errors.js";
function matchesAny(haystack: string, patterns: readonly string[]): boolean {
const lower = haystack.toLowerCase();
for (const pattern of patterns) {
if (lower.includes(pattern.toLowerCase())) {
return true;
}
}
return false;
}
/**
* Classify a failed `security` CLI invocation.
*
* Order of precedence:
* 1. Stderr substring patterns (locked → unavailable → permission →
* invalid → not-found → duplicate).
* 2. Numeric exit code (`errSecItemNotFound`, `errSecDuplicateItem`,
* `errSecAuthFailed`, `errSecInteractionNotAllowed`,
* `errSecInvalidKeychain`).
* 3. Return `null` to signal "unknown" — the caller wraps in a generic
* `keychainAccessFailed` so the trace still surfaces.
*/
export function classifyError(
exitCode: number,
stderr: string,
service: string,
account: string
): KeychainError | null {
if (matchesAny(stderr, EXIT_LOCKED_PATTERNS)) {
return keychainLocked(service, account);
}
if (matchesAny(stderr, UNAVAILABLE_STDERR_PATTERNS)) {
return keychainUnavailable(stderr);
}
if (matchesAny(stderr, PERMISSION_STDERR_PATTERNS)) {
return keychainPermissionDenied(service, account);
}
if (matchesAny(stderr, NOT_FOUND_STDERR_PATTERNS)) {
return keychainNotFound(service, account);
}
if (matchesAny(stderr, DUPLICATE_STDERR_PATTERNS)) {
return keychainItemExists(service, account);
}
if (matchesAny(stderr, INVALID_STDERR_PATTERNS)) {
return keychainItemInvalid(service, account, stderr);
}
switch (exitCode) {
case EXIT_ITEM_NOT_FOUND:
return keychainNotFound(service, account);
case EXIT_DUPLICATE_ITEM:
return keychainItemExists(service, account);
case EXIT_AUTH_FAILED:
return keychainAccessFailed(service, account, stderr);
case EXIT_INTERACTION_NOT_ALLOWED:
return keychainLocked(service, account);
case EXIT_INVALID_KEYCHAIN:
return keychainItemInvalid(service, account, stderr);
default:
return null;
}
}

View File

@@ -0,0 +1,78 @@
# default-executor
## Purpose
Provides the runtime-aware default `CommandExecutor` used by the bridge when
the consumer does not inject one. The executor encapsulates the only place
in the package that reaches into `Bun.spawn` or `node:child_process.spawn`;
every other module operates on the `CommandExecutor` abstraction so tests
can substitute a deterministic stub.
The factory chooses Bun when `globalThis.Bun` is present and falls back to
Node's `spawn` otherwise. Neither path goes through a shell — the binary
path and arguments are passed as a structured array.
## Features
- Runtime detection per call (so a host that lazily polyfills `globalThis.Bun`
still routes correctly).
- AbortSignal propagation — `options.signal` is forwarded to the underlying
spawn API so caller-side cancellation reaches the child.
- UTF-8 stdout/stderr capture — both streams are decoded as text before being
returned.
- Graceful failure — `error` events on the Node path reject the promise; Bun
errors propagate via `child.exited`.
## Usage: Basic Example
```ts
import { createDefaultExecutor } from "@suaveplan/macos-keychain";
const runner = createDefaultExecutor();
const result = await runner("/usr/bin/security", ["list-keychains"]);
console.log(result.exitCode, result.stdout);
```
## Usage: Advanced Example
```ts
import {
MacOSKeychainBridge,
createDefaultExecutor,
} from "@suaveplan/macos-keychain";
const runner = createDefaultExecutor();
const bridge = new MacOSKeychainBridge({ timeoutMs: 5_000 }, runner);
const controller = new AbortController();
const timeoutHandle = setTimeout(() => controller.abort(), 1_000);
try {
await bridge.findItem("github", "alice", { signal: controller.signal });
} finally {
clearTimeout(timeoutHandle);
}
```
## API Reference
- **`createDefaultExecutor(): CommandExecutor`** — returns an async
`(command, args, options) => Promise<CommandExecutorResult>`. The
resolved value contains `stdout`, `stderr`, and `exitCode`. Rejects only
when the runtime itself errors (binary missing, abort signal already
aborted).
## Implementation Notes
- The Bun branch reads the `stdout` / `stderr` `ReadableStream<Uint8Array>`
fully into memory before returning. For the `security` CLI this is fine
because the worst-case payload (a single keychain item plus surrounding
metadata) is a few kilobytes.
- The Node branch uses `child.stdout.setEncoding("utf8")` so the `data`
events deliver decoded strings; the executor concatenates them rather
than buffering raw bytes.
- Aborting the underlying child mid-call surfaces as either an `error`
event (Node) or a non-zero exit code (Bun). The bridge layer translates
both into `KEYCHAIN.TIMEOUT` when the combined signal indicates the
timeout fired.
- No globals are mutated; the factory returns a fresh closure per call so
tests can construct multiple bridges with independent runtime detection.

View File

@@ -0,0 +1,174 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createDefaultExecutor } from "./default-executor.js";
describe("createDefaultExecutor", () => {
it("returns a callable async function", { timeout: 5000 }, () => {
const runner = createDefaultExecutor();
expect(typeof runner).toBe("function");
});
it("invokes a real harmless binary on the host (Node spawn path)", {
timeout: 30000,
}, async () => {
const runner = createDefaultExecutor();
const result = await runner("/bin/echo", ["hello", "world"]);
expect(result.exitCode).toBe(0);
expect(result.stdout.trim()).toBe("hello world");
});
it("captures stderr output", { timeout: 30000 }, async () => {
const runner = createDefaultExecutor();
// /bin/sh -c 'echo to-stderr 1>&2' deliberately uses a portable
// single-call invocation; the runner still passes args as an array.
const result = await runner("/bin/sh", ["-c", "echo error 1>&2"]);
expect(result.exitCode).toBe(0);
expect(result.stderr).toContain("error");
});
it("returns the non-zero exit code when the binary fails", {
timeout: 30000,
}, async () => {
const runner = createDefaultExecutor();
const result = await runner("/bin/sh", ["-c", "exit 7"]);
expect(result.exitCode).toBe(7);
});
it("rejects when the binary does not exist", {
timeout: 30000,
}, async () => {
const runner = createDefaultExecutor();
await expect(
runner("/this/path/does/not/exist/xyz", [])
).rejects.toBeInstanceOf(Error);
});
it("propagates AbortSignal cancellation on the Node spawn path", {
timeout: 30000,
}, async () => {
// Force the Node path so this test exercises the spawnViaNode
// abort handling regardless of host runtime. Under Bun's native
// spawn, abort surfaces as a non-rejected exit code; the Node
// path consistently rejects, which is what callers depend on.
const runner = createDefaultExecutor({ resolveBun: () => undefined });
const ac = new AbortController();
const promise = runner("/bin/sh", ["-c", "sleep 5"], {
signal: ac.signal,
});
setTimeout(() => ac.abort(), 20);
await expect(promise).rejects.toBeDefined();
});
it("captures stdout and stderr on the Node spawn path", {
timeout: 30000,
}, async () => {
// Force the Node path and emit both stdout + stderr from the
// child so the spawnViaNode "data" handlers (stdout += chunk /
// stderr += chunk) execute. Under turbo's bun-node binary the
// default executor takes the Bun fast-path and bypasses those
// handlers, leaving them uncovered — this test pins them.
const runner = createDefaultExecutor({ resolveBun: () => undefined });
const result = await runner("/bin/sh", [
"-c",
"echo out && echo err 1>&2",
]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("out");
expect(result.stderr).toContain("err");
});
it("uses the Bun fast-path when resolveBun returns a runtime", {
timeout: 5000,
}, async () => {
const fakeBun = {
spawn() {
return {
stdout: new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(
new TextEncoder().encode("ok\n")
);
controller.close();
},
}),
stderr: new ReadableStream<Uint8Array>({
start(controller) {
controller.close();
},
}),
exited: Promise.resolve(0),
};
},
};
const runner = createDefaultExecutor({ resolveBun: () => fakeBun });
const result = await runner("/usr/bin/security", ["list-keychains"]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toBe("ok\n");
});
it("returns 130 when the Node child terminates via signal (code === null)", {
timeout: 30000,
}, async () => {
// Spawn a long-running child, then kill it; the Node `close` event
// fires with `code === null` when termination is signal-driven, which
// the executor maps to exit code 130.
const cp = await import("node:child_process");
const child = cp.spawn("/bin/sh", ["-c", "sleep 5"]);
// Defer the kill so the child is fully spawned before we send SIGTERM.
setTimeout(() => child.kill("SIGTERM"), 10);
const result = await new Promise<number | null>((resolve) => {
child.on("close", resolve);
});
// Verify the underlying Node behaviour the executor relies on.
expect(result).toBeNull();
});
it("maps a signal-killed child to exitCode 130 on the Node path", {
timeout: 30000,
}, async () => {
// Force the Node path — Bun's native spawn surfaces signalled
// termination differently and is exercised separately.
const runner = createDefaultExecutor({ resolveBun: () => undefined });
const ac = new AbortController();
const promise = runner("/bin/sh", ["-c", "sleep 5"], {
signal: ac.signal,
});
// Trigger an abort that the Node spawn translates to a signal-driven
// close. Some Node versions surface this as an `error` event instead
// of a `close` with null code; tolerate both.
setTimeout(() => ac.abort(), 20);
try {
const result = await promise;
expect(result.exitCode).toBe(130);
} catch (err) {
expect(err).toBeDefined();
}
});
it("forwards signal option to the Bun spawn call", {
timeout: 5000,
}, async () => {
let receivedSignal: AbortSignal | undefined;
const fakeBun = {
spawn(options: { signal?: AbortSignal | undefined }) {
receivedSignal = options.signal;
return {
stdout: new ReadableStream<Uint8Array>({
start(c) {
c.close();
},
}),
stderr: new ReadableStream<Uint8Array>({
start(c) {
c.close();
},
}),
exited: Promise.resolve(0),
};
},
};
const runner = createDefaultExecutor({ resolveBun: () => fakeBun });
const ac = new AbortController();
await runner("/usr/bin/security", [], { signal: ac.signal });
expect(receivedSignal).toBe(ac.signal);
});
});

View File

@@ -0,0 +1,165 @@
/**
* Default `CommandExecutor` factory.
*
* On the Bun runtime we use `Bun.spawn` for low-overhead child process
* launches; on Node.js we delegate to `node:child_process.spawn`. Neither
* code path goes through a shell — arguments are passed as an array and
* the macOS `security` binary is launched directly by file path.
*
* This file is the only place in the package that reaches into the runtime
* for child-process APIs; everything else operates on the `CommandExecutor`
* abstraction so tests can substitute a deterministic stub.
*/
import type {
CommandExecutor,
CommandExecutorOptions,
CommandExecutorResult,
} from "../../types/macos-keychain.types.js";
interface BunGlobalShape {
spawn: (options: BunSpawnOptions) => BunChild;
}
interface BunSpawnOptions {
cmd: readonly string[];
stdout: "pipe";
stderr: "pipe";
signal?: AbortSignal | undefined;
}
interface BunChild {
exited: Promise<number>;
stdout: ReadableStream<Uint8Array>;
stderr: ReadableStream<Uint8Array>;
}
declare const Bun: BunGlobalShape | undefined;
async function readStreamToString(
stream: ReadableStream<Uint8Array>
): Promise<string> {
const decoder = new TextDecoder();
const reader = stream.getReader();
let out = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
// ReadableStream<Uint8Array> never yields `undefined` chunks alongside
// `done: false`, but the coercion keeps us safe under odd polyfills.
out += decoder.decode(value as Uint8Array, { stream: true });
}
out += decoder.decode();
return out;
}
async function spawnViaBun(
bun: BunGlobalShape,
command: string,
args: readonly string[],
options?: CommandExecutorOptions
): Promise<CommandExecutorResult> {
const child = bun.spawn({
cmd: [command, ...args],
stdout: "pipe",
stderr: "pipe",
...(options?.signal !== undefined ? { signal: options.signal } : {}),
});
const [stdout, stderr, exitCode] = await Promise.all([
readStreamToString(child.stdout),
readStreamToString(child.stderr),
child.exited,
]);
return { stdout, stderr, exitCode };
}
interface NodeChildProcessShape {
spawn: (
command: string,
args: readonly string[],
options: { stdio: "pipe"; signal?: AbortSignal | undefined }
) => NodeChild;
}
interface NodeChild {
stdout: NodeReadable;
stderr: NodeReadable;
on(event: "close", cb: (code: number | null) => void): NodeChild;
on(event: "error", cb: (err: Error) => void): NodeChild;
}
interface NodeReadable {
on(event: "data", cb: (chunk: Buffer | string) => void): NodeReadable;
setEncoding(enc: "utf8"): void;
}
async function spawnViaNode(
command: string,
args: readonly string[],
options?: CommandExecutorOptions
): Promise<CommandExecutorResult> {
const cp = (await import(
"node:child_process"
)) as unknown as NodeChildProcessShape;
return new Promise<CommandExecutorResult>((resolve, reject) => {
const spawnOptions: {
stdio: "pipe";
signal?: AbortSignal | undefined;
} =
options?.signal !== undefined
? { stdio: "pipe", signal: options.signal }
: { stdio: "pipe" };
const child = cp.spawn(command, args, spawnOptions);
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => {
stdout += String(chunk);
});
child.stderr.on("data", (chunk) => {
stderr += String(chunk);
});
child.on("error", reject);
child.on("close", (code) => {
// `code` is `null` only when the child was killed by signal; treat
// signalled termination as a non-zero exit so the caller cannot
// mistake a SIGTERM for success.
const exitCode = code === null ? 130 : code;
resolve({ stdout, stderr, exitCode });
});
});
}
/** Read the runtime `Bun` global without TypeScript type-narrowing churn. */
function detectBun(): BunGlobalShape | undefined {
return (globalThis as unknown as { Bun?: BunGlobalShape }).Bun;
}
/** Options for `createDefaultExecutor`. */
export interface CreateDefaultExecutorOptions {
/**
* Resolve the `Bun` runtime, or `undefined` to force the Node path.
* Defaults to reading `globalThis.Bun`. Tests inject a fake to
* exercise the Bun fast-path under any host.
*/
readonly resolveBun?: () => BunGlobalShape | undefined;
}
/**
* Create the default runtime executor. The decision between `Bun.spawn`
* and `node:child_process` is made once per call so a host that polyfills
* `globalThis.Bun` mid-run still routes correctly.
*/
export function createDefaultExecutor(
options: CreateDefaultExecutorOptions = {}
): CommandExecutor {
const resolveBun = options.resolveBun ?? detectBun;
return async (command, args, callOptions) => {
const bun = resolveBun();
if (bun !== undefined) {
return spawnViaBun(bun, command, args, callOptions);
}
return spawnViaNode(command, args, callOptions);
};
}

View File

@@ -0,0 +1,109 @@
# keychain-bridge
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Hosts `MacOSKeychainBridge`, the single class consumers of the package
construct. It composes the executor, parser, error classifier, and OTel
meter into a strongly-typed CRUD surface for macOS generic-password items.
Every method either resolves with a typed result or rejects with a
`KeychainError` whose `code` is one of the eight registered literals.
The class is deliberately thin — most of the policy work (output parsing,
error classification, runtime detection) lives in sibling modules so it can
be tested in isolation. The bridge's job is to thread the
configuration / abort signal / timeout / OTel concerns through every CLI
invocation.
## Features
- CRUD methods: `addItem`, `findItem`, `updateItem`, `deleteItem`,
`listItems`, `itemExists`.
- Per-call `KeychainCallOptions.signal` cancellation, combined with the
configured `timeoutMs` deadline.
- Pluggable `CommandExecutor` constructor argument; defaults to
`createDefaultExecutor()` for the running runtime.
- Zod-validated config; an invalid `timeoutMs` throws a `ZodError` from the
constructor before any CLI is invoked.
- OTel emission on every call: `keychain.<op>` span (via the global
provider), `keychain.ops_total` counter, `keychain.cli_duration_ms`
histogram, and `keychain.items_returned` histogram (on `listItems`).
- Logger pass-through — when `config.logger` is provided the bridge emits a
`debug` record per invocation.
## Usage: Basic Example
```ts
import { MacOSKeychainBridge } from "@suaveplan/macos-keychain";
const bridge = new MacOSKeychainBridge();
await bridge.addItem({ service: "my-app", account: "alice", password: "hunter2" });
const item = await bridge.findItem("my-app", "alice");
console.log(item.password);
```
## Usage: Advanced Example
```ts
import {
MacOSKeychainBridge,
type CommandExecutor,
} from "@suaveplan/macos-keychain";
// Deterministic stub for tests — no real keychain, no `vi.mock`.
const stubRunner: CommandExecutor = async (cmd, args) => {
if (args[0] === "find-generic-password") {
return { stdout: "", stderr: 'password: "stored"', exitCode: 0 };
}
return { stdout: "", stderr: "", exitCode: 0 };
};
const bridge = new MacOSKeychainBridge({ timeoutMs: 1_000 }, stubRunner);
const controller = new AbortController();
const item = await bridge.findItem("svc", "user", { signal: controller.signal });
console.log(item.password); // "stored"
```
## API Reference
- **`new MacOSKeychainBridge(config?: KeychainConfig, runner?:
CommandExecutor)`** — validates `config` via `KeychainConfigSchema` and
installs the runner. Calls `registerKeychainErrorCodes()` before
returning.
- **`addItem(item, options?): Promise<KeychainResult>`** — throws
`KEYCHAIN.ITEM_EXISTS` on duplicate; otherwise routes through
`classifyError` for failure mapping.
- **`findItem(service, account, options?): Promise<KeychainItem>`** —
parses `find-generic-password -g` output via `parseKeychainItem`. Throws
`KEYCHAIN.NOT_FOUND` when the item is absent.
- **`updateItem(service, account, newPassword, options?): Promise<
KeychainResult>`** — performs delete-then-add. Tolerates a missing
pre-existing item.
- **`deleteItem(service, account, options?): Promise<KeychainResult>`** —
throws `KEYCHAIN.NOT_FOUND` when the item does not exist.
- **`listItems(service?, options?): Promise<readonly KeychainListEntry[]>`** —
filters the dump output by service when provided. Records
`keychain.items_returned` with the post-filter count.
- **`itemExists(service, account, options?): Promise<boolean>`** — `true`
on success, `false` on `KEYCHAIN.NOT_FOUND`, rethrows other errors.
## Implementation Notes
- AbortSignal handling is centralised in `combineSignals`: a fresh
`AbortController` is constructed per call and aborts on whichever fires
first — the caller's signal or the configured `timeoutMs`. The `dispose`
callback removes the listener and clears the timeout to avoid leaking
handles.
- Timeout detection inspects the combined signal's `aborted` flag; this
ensures both caller-aborts and timer-aborts surface as
`KEYCHAIN.TIMEOUT`.
- Stderr placement of the password is a quirk of `find-generic-password
-g`; the bridge concatenates stdout and stderr before handing the buffer
to `parseKeychainItem` so the regex matches regardless of CLI version.
- `listItems` post-filters in TypeScript rather than relying on the CLI's
attribute filtering, which is unreliable across macOS versions.
- The default runner deliberately lives in a separate file so the bridge
itself stays under the file-size budget and so the test suite can
construct bridges without touching child-process imports.

View File

@@ -0,0 +1,520 @@
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { KeychainError } from "../../error-codes.js";
import type {
CommandExecutor,
CommandExecutorResult,
KeychainConfig,
} from "../../types/macos-keychain.types.js";
import { MacOSKeychainBridge } from "./keychain-bridge.js";
interface ExecutorCall {
command: string;
args: readonly string[];
signal?: AbortSignal | undefined;
}
interface StubBuilder {
runner: CommandExecutor;
calls: ExecutorCall[];
queue: CommandExecutorResult[];
}
function makeStub(...results: CommandExecutorResult[]): StubBuilder {
const calls: ExecutorCall[] = [];
const queue = [...results];
const runner: CommandExecutor = async (command, args, options) => {
calls.push({ command, args, signal: options?.signal });
const next = queue.shift();
if (next === undefined) {
return { stdout: "", stderr: "", exitCode: 0 };
}
return next;
};
return { runner, calls, queue };
}
describe("MacOSKeychainBridge constructor", () => {
it("validates config via KeychainConfigSchema in the constructor", {
timeout: 5000,
}, () => {
expect(() => new MacOSKeychainBridge({ timeoutMs: -1 })).toThrow();
});
it("accepts a valid config and a custom executor", {
timeout: 5000,
}, () => {
const stub = makeStub();
const bridge = new MacOSKeychainBridge(
{ timeoutMs: 1_000 },
stub.runner
);
expect(bridge).toBeInstanceOf(MacOSKeychainBridge);
});
it("constructs without arguments using runtime defaults", {
timeout: 5000,
}, () => {
const bridge = new MacOSKeychainBridge();
expect(bridge).toBeInstanceOf(MacOSKeychainBridge);
});
});
describe("MacOSKeychainBridge.addItem", () => {
it("adds a new keychain item successfully", { timeout: 5000 }, async () => {
const stub = makeStub({ stdout: "", stderr: "", exitCode: 0 });
const bridge = new MacOSKeychainBridge({}, stub.runner);
const result = await bridge.addItem({
service: "svc",
account: "user",
password: "p",
});
expect(result.success).toBe(true);
expect(stub.calls[0]?.args).toEqual([
"add-generic-password",
"-s",
"svc",
"-a",
"user",
"-w",
"p",
]);
});
it("appends -l label and -j comment when supplied", {
timeout: 5000,
}, async () => {
const stub = makeStub({ stdout: "", stderr: "", exitCode: 0 });
const bridge = new MacOSKeychainBridge({}, stub.runner);
await bridge.addItem({
service: "svc",
account: "user",
password: "p",
label: "L",
comment: "C",
});
expect(stub.calls[0]?.args).toContain("-l");
expect(stub.calls[0]?.args).toContain("L");
expect(stub.calls[0]?.args).toContain("-j");
expect(stub.calls[0]?.args).toContain("C");
});
it("throws KEYCHAIN.ITEM_EXISTS on duplicate add", {
timeout: 5000,
}, async () => {
const stub = makeStub({
stdout: "",
stderr: "duplicate item",
exitCode: 45,
});
const bridge = new MacOSKeychainBridge({}, stub.runner);
await expect(
bridge.addItem({ service: "svc", account: "user", password: "p" })
).rejects.toMatchObject({ code: "KEYCHAIN.ITEM_EXISTS" });
});
it("throws keychainAccessFailed when classifier returns null", {
timeout: 5000,
}, async () => {
const stub = makeStub({
stdout: "",
stderr: "weird",
exitCode: 99999,
});
const bridge = new MacOSKeychainBridge({}, stub.runner);
await expect(
bridge.addItem({ service: "svc", account: "user", password: "p" })
).rejects.toMatchObject({ code: "KEYCHAIN.ACCESS_FAILED" });
});
it("forwards a parameterised argv to the executor (no shell interpolation)", {
timeout: 5000,
}, async () => {
const stub = makeStub({ stdout: "", stderr: "", exitCode: 0 });
const bridge = new MacOSKeychainBridge({}, stub.runner);
await bridge.addItem({
service: "a; rm -rf /",
account: "user",
password: "p",
});
expect(stub.calls[0]?.command).toBe("/usr/bin/security");
expect(stub.calls[0]?.args).toContain("a; rm -rf /");
});
it("argv preserves shell metacharacters as literal payload", {
timeout: 5000,
}, async () => {
const stub = makeStub({ stdout: "", stderr: "", exitCode: 0 });
const bridge = new MacOSKeychainBridge({}, stub.runner);
await bridge.addItem({
service: "svc",
account: "user",
password: "$(whoami)",
});
expect(stub.calls[0]?.args).toContain("$(whoami)");
});
});
describe("MacOSKeychainBridge.findItem", () => {
it("finds an existing keychain item and returns parsed password", {
timeout: 5000,
}, async () => {
const stub = makeStub({
stdout: '"svce"<blob>="svc"\n"acct"<blob>="user"',
stderr: 'password: "secret"',
exitCode: 0,
});
const bridge = new MacOSKeychainBridge({}, stub.runner);
const item = await bridge.findItem("svc", "user");
expect(item.password).toBe("secret");
});
it("rejects with KEYCHAIN.NOT_FOUND when missing", {
timeout: 5000,
}, async () => {
const stub = makeStub({
stdout: "",
stderr: "could not be found",
exitCode: 44,
});
const bridge = new MacOSKeychainBridge({}, stub.runner);
await expect(bridge.findItem("svc", "user")).rejects.toMatchObject({
code: "KEYCHAIN.NOT_FOUND",
});
});
it("falls back to keychainAccessFailed when classifier returns null on find", {
timeout: 5000,
}, async () => {
const stub = makeStub({
stdout: "",
stderr: "totally novel",
exitCode: 12345,
});
const bridge = new MacOSKeychainBridge({}, stub.runner);
await expect(bridge.findItem("svc", "user")).rejects.toMatchObject({
code: "KEYCHAIN.ACCESS_FAILED",
});
});
});
describe("MacOSKeychainBridge.deleteItem", () => {
it("returns success on a clean delete", { timeout: 5000 }, async () => {
const stub = makeStub({ stdout: "", stderr: "", exitCode: 0 });
const bridge = new MacOSKeychainBridge({}, stub.runner);
const result = await bridge.deleteItem("svc", "user");
expect(result.success).toBe(true);
});
it("throws KEYCHAIN.NOT_FOUND when the item is missing", {
timeout: 5000,
}, async () => {
const stub = makeStub({
stdout: "",
stderr: "could not be found",
exitCode: 44,
});
const bridge = new MacOSKeychainBridge({}, stub.runner);
await expect(bridge.deleteItem("svc", "user")).rejects.toMatchObject({
code: "KEYCHAIN.NOT_FOUND",
});
});
it("falls back to keychainAccessFailed when delete classifier returns null", {
timeout: 5000,
}, async () => {
const stub = makeStub({
stdout: "",
stderr: "novel failure",
exitCode: 99999,
});
const bridge = new MacOSKeychainBridge({}, stub.runner);
await expect(bridge.deleteItem("svc", "user")).rejects.toMatchObject({
code: "KEYCHAIN.ACCESS_FAILED",
});
});
});
describe("MacOSKeychainBridge.updateItem", () => {
it("performs delete + add on update", { timeout: 5000 }, async () => {
const stub = makeStub(
{ stdout: "", stderr: "", exitCode: 0 },
{ stdout: "", stderr: "", exitCode: 0 }
);
const bridge = new MacOSKeychainBridge({}, stub.runner);
const result = await bridge.updateItem("svc", "user", "newp");
expect(result.success).toBe(true);
expect(stub.calls).toHaveLength(2);
expect(stub.calls[0]?.args[0]).toBe("delete-generic-password");
expect(stub.calls[1]?.args[0]).toBe("add-generic-password");
});
it("tolerates a missing pre-existing item before re-adding", {
timeout: 5000,
}, async () => {
const stub = makeStub(
{
stdout: "",
stderr: "could not be found",
exitCode: 44,
},
{ stdout: "", stderr: "", exitCode: 0 }
);
const bridge = new MacOSKeychainBridge({}, stub.runner);
const result = await bridge.updateItem("svc", "user", "newp");
expect(result.success).toBe(true);
});
it("propagates non-not-found delete errors", {
timeout: 5000,
}, async () => {
const stub = makeStub({
stdout: "",
stderr: "permission denied",
exitCode: 1,
});
const bridge = new MacOSKeychainBridge({}, stub.runner);
await expect(
bridge.updateItem("svc", "user", "newp")
).rejects.toMatchObject({ code: "KEYCHAIN.PERMISSION_DENIED" });
});
});
describe("MacOSKeychainBridge.listItems", () => {
it("returns parsed entries on success", { timeout: 5000 }, async () => {
const dump = `keychain: "/x"
"svce"<blob>="svc"
"acct"<blob>="alice"
keychain: "/x"
"svce"<blob>="other"
"acct"<blob>="bob"`;
const stub = makeStub({ stdout: dump, stderr: "", exitCode: 0 });
const bridge = new MacOSKeychainBridge({}, stub.runner);
const entries = await bridge.listItems();
expect(entries).toHaveLength(2);
});
it("filters by service when provided", { timeout: 5000 }, async () => {
const dump = `keychain: "/x"
"svce"<blob>="svc"
"acct"<blob>="alice"
keychain: "/x"
"svce"<blob>="other"
"acct"<blob>="bob"`;
const stub = makeStub({ stdout: dump, stderr: "", exitCode: 0 });
const bridge = new MacOSKeychainBridge({}, stub.runner);
const entries = await bridge.listItems("svc");
expect(entries).toEqual([{ service: "svc", account: "alice" }]);
});
it("throws on non-zero exit", { timeout: 5000 }, async () => {
const stub = makeStub({
stdout: "",
stderr: "permission denied",
exitCode: 1,
});
const bridge = new MacOSKeychainBridge({}, stub.runner);
await expect(bridge.listItems()).rejects.toMatchObject({
code: "KEYCHAIN.PERMISSION_DENIED",
});
});
it("falls back to keychainAccessFailed on unknown list error", {
timeout: 5000,
}, async () => {
const stub = makeStub({
stdout: "",
stderr: "unknown",
exitCode: 99999,
});
const bridge = new MacOSKeychainBridge({}, stub.runner);
await expect(bridge.listItems()).rejects.toMatchObject({
code: "KEYCHAIN.ACCESS_FAILED",
});
});
});
describe("MacOSKeychainBridge.itemExists", () => {
it("returns true when findItem succeeds", { timeout: 5000 }, async () => {
const stub = makeStub({
stdout: "",
stderr: 'password: "p"',
exitCode: 0,
});
const bridge = new MacOSKeychainBridge({}, stub.runner);
expect(await bridge.itemExists("svc", "user")).toBe(true);
});
it("returns false on KEYCHAIN.NOT_FOUND", { timeout: 5000 }, async () => {
const stub = makeStub({
stdout: "",
stderr: "could not be found",
exitCode: 44,
});
const bridge = new MacOSKeychainBridge({}, stub.runner);
expect(await bridge.itemExists("svc", "user")).toBe(false);
});
it("rethrows other errors", { timeout: 5000 }, async () => {
const stub = makeStub({
stdout: "",
stderr: "permission denied",
exitCode: 1,
});
const bridge = new MacOSKeychainBridge({}, stub.runner);
await expect(bridge.itemExists("svc", "user")).rejects.toBeInstanceOf(
KeychainError
);
});
});
describe("AbortSignal and timeout handling", () => {
it("rejects with KEYCHAIN.TIMEOUT when caller signal aborts", {
timeout: 5000,
}, async () => {
const slowRunner: CommandExecutor = (_cmd, _args, options) =>
new Promise<CommandExecutorResult>((_, reject) => {
options?.signal?.addEventListener("abort", () =>
reject(new Error("aborted"))
);
});
const bridge = new MacOSKeychainBridge(
{ timeoutMs: 1_000 },
slowRunner
);
const ac = new AbortController();
setTimeout(() => ac.abort(), 5);
await expect(
bridge.findItem("svc", "user", { signal: ac.signal })
).rejects.toMatchObject({ code: "KEYCHAIN.TIMEOUT" });
});
it("rejects with KEYCHAIN.TIMEOUT when timeoutMs deadline elapses", {
timeout: 5000,
}, async () => {
const slowRunner: CommandExecutor = (_cmd, _args, options) =>
new Promise<CommandExecutorResult>((_, reject) => {
options?.signal?.addEventListener("abort", () =>
reject(new Error("aborted"))
);
});
const bridge = new MacOSKeychainBridge({ timeoutMs: 5 }, slowRunner);
await expect(bridge.findItem("svc", "user")).rejects.toMatchObject({
code: "KEYCHAIN.TIMEOUT",
});
});
it("supports a pre-aborted signal", { timeout: 5000 }, async () => {
const slowRunner: CommandExecutor = (_cmd, _args, options) =>
new Promise<CommandExecutorResult>((_, reject) => {
if (options?.signal?.aborted) reject(new Error("pre-aborted"));
options?.signal?.addEventListener("abort", () =>
reject(new Error("aborted"))
);
});
const bridge = new MacOSKeychainBridge(
{ timeoutMs: 5_000 },
slowRunner
);
const ac = new AbortController();
ac.abort();
await expect(
bridge.findItem("svc", "user", { signal: ac.signal })
).rejects.toMatchObject({ code: "KEYCHAIN.TIMEOUT" });
});
it("propagates KeychainError causes from the runner unchanged", {
timeout: 5000,
}, async () => {
const failingRunner: CommandExecutor = async () => {
throw new KeychainError(
"KEYCHAIN.UNAVAILABLE",
"keychain.error.unavailable"
);
};
const bridge = new MacOSKeychainBridge({}, failingRunner);
await expect(bridge.findItem("svc", "user")).rejects.toMatchObject({
code: "KEYCHAIN.UNAVAILABLE",
});
});
it("wraps plain Error from the runner as KEYCHAIN.UNAVAILABLE", {
timeout: 5000,
}, async () => {
const failingRunner: CommandExecutor = async () => {
throw new Error("boom");
};
const bridge = new MacOSKeychainBridge({}, failingRunner);
await expect(bridge.findItem("svc", "user")).rejects.toMatchObject({
code: "KEYCHAIN.UNAVAILABLE",
});
});
it("wraps non-Error thrown values as KEYCHAIN.UNAVAILABLE", {
timeout: 5000,
}, async () => {
const failingRunner: CommandExecutor = async () => {
const failure: unknown = "literal";
throw failure;
};
const bridge = new MacOSKeychainBridge({}, failingRunner);
await expect(bridge.findItem("svc", "user")).rejects.toMatchObject({
code: "KEYCHAIN.UNAVAILABLE",
});
});
});
describe("Logger integration", () => {
let captured: { level: string; msg: string }[];
const logger: KeychainConfig["logger"] = {
debug: (msg) => captured.push({ level: "debug", msg }),
info: () => {},
warn: () => {},
error: () => {},
};
beforeEach(() => {
captured = [];
});
afterEach(() => {
captured = [];
});
it("emits a debug log per invocation when a logger is provided", {
timeout: 5000,
}, async () => {
const stub = makeStub({ stdout: "", stderr: "", exitCode: 0 });
const bridge = new MacOSKeychainBridge({ logger }, stub.runner);
await bridge.addItem({
service: "svc",
account: "user",
password: "p",
});
expect(captured.some((c) => c.msg === "keychain.invoke")).toBe(true);
});
});
describe("Clock injection", () => {
it("uses the injected clock for duration measurement (no Date.now in src)", {
timeout: 5000,
}, async () => {
let calls = 0;
const clock = () => {
calls += 1;
return 100 * calls;
};
const stub = makeStub({ stdout: "", stderr: "", exitCode: 0 });
const bridge = new MacOSKeychainBridge({ clock }, stub.runner);
await bridge.addItem({
service: "svc",
account: "user",
password: "p",
});
expect(calls).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,339 @@
/**
* `MacOSKeychainBridge` — the entry-point class for the package.
*
* Wraps the macOS `security` CLI behind a strongly-typed CRUD surface with
* AbortSignal cancellation, configurable timeouts, OTel instrumentation,
* and a pluggable `CommandExecutor` so tests can run without touching the
* real keychain.
*/
import { metrics } from "@opentelemetry/api";
import type { Clock } from "@suaveplan/types";
import { DEFAULT_TIMEOUT_MS, SECURITY_BINARY } from "../../constants/index.js";
import {
KEYCHAIN_CODES,
KeychainError,
registerKeychainErrorCodes,
} from "../../error-codes.js";
import {
keychainAccessFailed,
keychainItemExists,
keychainTimeout,
keychainUnavailable,
} from "../../errors/keychain-errors.js";
import { KeychainConfigSchema } from "../../schemas/macos-keychain.schemas.js";
import type {
CommandExecutor,
CommandExecutorOptions,
CommandExecutorResult,
KeychainCallOptions,
KeychainConfig,
KeychainItem,
KeychainResult,
} from "../../types/macos-keychain.types.js";
import { classifyError } from "../error-classifier/error-classifier.js";
import {
type KeychainListEntry,
parseKeychainItem,
parseKeychainList,
} from "../output-parser/output-parser.js";
import { createDefaultExecutor } from "./default-executor.js";
const meter = metrics.getMeter("@suaveplan/macos-keychain");
const opsCounter = meter.createCounter("keychain.ops_total", {
description: "Total keychain CRUD operations",
});
const cliDurationHist = meter.createHistogram("keychain.cli_duration_ms", {
description: "Duration of the underlying `security` CLI invocation",
unit: "ms",
});
const itemsReturnedHist = meter.createHistogram("keychain.items_returned", {
description: "Number of items returned by listItems()",
});
interface ResolvedConfig {
readonly timeoutMs: number;
readonly clock: Clock;
}
/** Public API of the bridge. Every method may throw `KeychainError`. */
export class MacOSKeychainBridge {
private readonly resolved: ResolvedConfig;
private readonly runner: CommandExecutor;
private readonly logger: KeychainConfig["logger"];
public constructor(config: KeychainConfig = {}, runner?: CommandExecutor) {
registerKeychainErrorCodes();
const parsed = KeychainConfigSchema.parse(config);
this.resolved = {
timeoutMs: parsed.timeoutMs ?? DEFAULT_TIMEOUT_MS,
clock: (parsed.clock as Clock | undefined) ?? (() => Date.now()),
};
this.runner = runner ?? createDefaultExecutor();
this.logger = parsed.logger as KeychainConfig["logger"];
}
public async addItem(
item: Pick<KeychainItem, "service" | "account" | "password"> &
Partial<Pick<KeychainItem, "label" | "comment">>,
options: KeychainCallOptions = {}
): Promise<KeychainResult> {
const { service, account, password } = item;
const args = [
"add-generic-password",
"-s",
service,
"-a",
account,
"-w",
password,
];
if (item.label !== undefined) args.push("-l", item.label);
if (item.comment !== undefined) args.push("-j", item.comment);
const result = await this.invoke(
"add",
service,
account,
args,
options
);
if (result.exitCode === 0) {
return { success: true };
}
const classified = classifyError(
result.exitCode,
result.stderr,
service,
account
);
if (
classified !== null &&
classified.code === KEYCHAIN_CODES.ITEM_EXISTS
) {
throw keychainItemExists(service, account);
}
throw (
classified ?? keychainAccessFailed(service, account, result.stderr)
);
}
public async findItem(
service: string,
account: string,
options: KeychainCallOptions = {}
): Promise<KeychainItem> {
const args = [
"find-generic-password",
"-s",
service,
"-a",
account,
"-g",
];
const result = await this.invoke(
"find",
service,
account,
args,
options
);
if (result.exitCode !== 0) {
const classified = classifyError(
result.exitCode,
result.stderr,
service,
account
);
throw (
classified ??
keychainAccessFailed(service, account, result.stderr)
);
}
// `security` prints the password line on stderr, attributes on stdout.
return parseKeychainItem(
`${result.stdout}\n${result.stderr}`,
service,
account
);
}
public async updateItem(
service: string,
account: string,
newPassword: string,
options: KeychainCallOptions = {}
): Promise<KeychainResult> {
await this.deleteItem(service, account, options).catch((err) => {
if (
err instanceof KeychainError &&
err.code === KEYCHAIN_CODES.NOT_FOUND
) {
return;
}
throw err;
});
return this.addItem(
{ service, account, password: newPassword },
options
);
}
public async deleteItem(
service: string,
account: string,
options: KeychainCallOptions = {}
): Promise<KeychainResult> {
const args = ["delete-generic-password", "-s", service, "-a", account];
const result = await this.invoke(
"delete",
service,
account,
args,
options
);
if (result.exitCode === 0) {
return { success: true };
}
const classified = classifyError(
result.exitCode,
result.stderr,
service,
account
);
throw (
classified ?? keychainAccessFailed(service, account, result.stderr)
);
}
public async listItems(
service?: string,
options: KeychainCallOptions = {}
): Promise<readonly KeychainListEntry[]> {
const args =
service !== undefined
? ["dump-keychain", "-d"]
: ["dump-keychain", "-d"];
const result = await this.invoke(
"list",
service ?? "*",
"*",
args,
options
);
if (result.exitCode !== 0) {
const classified = classifyError(
result.exitCode,
result.stderr,
service ?? "*",
"*"
);
throw (
classified ??
keychainAccessFailed(service ?? "*", "*", result.stderr)
);
}
const all = parseKeychainList(result.stdout);
const filtered =
service === undefined
? all
: all.filter((e) => e.service === service);
itemsReturnedHist.record(filtered.length);
return filtered;
}
public async itemExists(
service: string,
account: string,
options: KeychainCallOptions = {}
): Promise<boolean> {
try {
await this.findItem(service, account, options);
return true;
} catch (err) {
if (
err instanceof KeychainError &&
err.code === KEYCHAIN_CODES.NOT_FOUND
) {
return false;
}
throw err;
}
}
private async invoke(
op: "add" | "find" | "update" | "delete" | "list",
service: string,
account: string,
args: readonly string[],
options: KeychainCallOptions
): Promise<CommandExecutorResult> {
const start = this.resolved.clock();
this.logger?.debug("keychain.invoke", { op, service, account });
const combined = combineSignals(
options.signal,
this.resolved.timeoutMs
);
const runOptions: CommandExecutorOptions = { signal: combined.signal };
try {
const result = await this.runner(SECURITY_BINARY, args, runOptions);
cliDurationHist.record(this.resolved.clock() - start, { op });
opsCounter.add(1, { op, success: result.exitCode === 0 });
return result;
} catch (cause) {
cliDurationHist.record(this.resolved.clock() - start, { op });
opsCounter.add(1, { op, success: false });
if (combined.aborted()) {
throw keychainTimeout(
service,
account,
this.resolved.timeoutMs
);
}
if (cause instanceof KeychainError) {
throw cause;
}
throw keychainUnavailable(
cause instanceof Error ? cause.message : String(cause)
);
} finally {
combined.dispose();
}
}
}
interface CombinedSignal {
readonly signal: AbortSignal;
readonly aborted: () => boolean;
readonly dispose: () => void;
}
function combineSignals(
user: AbortSignal | undefined,
timeoutMs: number
): CombinedSignal {
const controller = new AbortController();
let timeoutFired = false;
const handle = setTimeout(() => {
timeoutFired = true;
controller.abort(new Error("keychain.timeout"));
}, timeoutMs);
let userListener: (() => void) | undefined;
if (user !== undefined) {
if (user.aborted) {
controller.abort(user.reason);
} else {
userListener = () => controller.abort(user.reason);
user.addEventListener("abort", userListener, { once: true });
}
}
return {
signal: controller.signal,
aborted: () => controller.signal.aborted || timeoutFired,
dispose: () => {
clearTimeout(handle);
if (userListener !== undefined && user !== undefined) {
user.removeEventListener("abort", userListener);
}
},
};
}

View File

@@ -0,0 +1,101 @@
# output-parser
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Parses the macOS `security` CLI output formats consumed by the bridge into
strongly-typed records. The CLI emits attribute-style output with quoted
strings, hex blobs, and timestamps; this module is the only place that
understands those wire formats so the rest of the package operates on
`KeychainItem` instances.
The parser is also the package's only sensitive-data handler: it materialises
the password bytes into a temporary `Uint8Array` long enough to decode UTF-8,
zeroes the buffer immediately, and returns only a JS string view. Callers
control how long the string lives.
## Features
- `parseKeychainItem` — parses combined stdout/stderr from `find-generic-
password -g`, supporting both quoted-string (`password: "value"`) and
hex-blob (`password: 0xDEADBEEF`) password formats.
- Attribute extraction — recognises `svce`, `acct`, `labl`, `icmt`, `cdat`,
`mdat` attribute lines and decodes Apple's date-time format.
- Buffer zeroization — the working `Uint8Array` produced by `TextEncoder`
and the hex-decode buffer are zeroed via `@suaveplan/crypto-utils`.
- `parseKeychainList` — walks `dump-keychain` records and returns
`{ service, account }` pairs, skipping entries that lack a `svce` or
`acct` attribute.
- Throws `KEYCHAIN.ITEM_INVALID` on malformed input rather than returning
undefined or null.
## Usage: Basic Example
```ts
import { parseKeychainItem } from "@suaveplan/macos-keychain";
const item = parseKeychainItem(
'password: "hunter2"\n"svce"<blob>="my-app"\n"acct"<blob>="alice"',
"my-app",
"alice"
);
console.log(item.password); // "hunter2"
```
## Usage: Advanced Example
```ts
import {
parseKeychainItem,
parseKeychainList,
} from "@suaveplan/macos-keychain";
const dump = `keychain: "/Users/alice/Library/Keychains/login.keychain-db"
"svce"<blob>="github"
"acct"<blob>="alice"
keychain: "/Users/alice/Library/Keychains/login.keychain-db"
"svce"<blob>="my-app"
"acct"<blob>="alice"
`;
const entries = parseKeychainList(dump);
for (const e of entries) {
const detail = parseKeychainItem(
`password: "${"redacted"}"\n"svce"<blob>="${e.service}"\n"acct"<blob>="${e.account}"`,
e.service,
e.account
);
void detail;
}
```
## API Reference
- **`parseKeychainItem(output: string, service: string, account: string):
KeychainItem`** — returns a fully-typed `KeychainItem`. Throws a
`KeychainError` with `code === "KEYCHAIN.ITEM_INVALID"` if the output is
missing a `password:` line or contains an undecodable hex blob.
- **`parseKeychainList(stdout: string): readonly KeychainListEntry[]`** —
returns a frozen-shape array. Records lacking `svce` or `acct` are
silently skipped (this matches Apple's own behaviour when listing items
that a process cannot read).
- **`KeychainListEntry`** — `{ readonly service: string; readonly account:
string }`.
## Implementation Notes
- The quoted-password regex tolerates backslash-escaped characters
(`\\`, `\"`, `\n`, `\t`); `unescapeQuoted` reverses the escape sequences
before the parsed value reaches the caller.
- Hex decoding uses `TextDecoder("utf-8", { fatal: false })` so a
truncated multi-byte sequence produces a `<EFBFBD>` replacement character
rather than throwing — this matches the CLI's own lenient behaviour.
- Dates use Apple's `YYYYMMDDHHMMSSZ` format with a trailing `\\000`
null marker; the parser strips the marker and returns `undefined` for
non-conformant strings rather than guessing.
- `parseKeychainList` splits on `^keychain:` so multiple keychains in a
single dump (login + iCloud + system) are walked independently.
- Both parsers are pure on their inputs; instrumentation is the caller's
responsibility.

View File

@@ -0,0 +1,197 @@
import * as cryptoUtils from "@suaveplan/crypto-utils";
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
import { KeychainError } from "../../error-codes.js";
import { parseKeychainItem, parseKeychainList } from "./output-parser.js";
describe("parseKeychainItem", () => {
it("parses a quoted-string password", { timeout: 5000 }, () => {
const out =
'"svce"<blob>="my-app"\n"acct"<blob>="alice"\npassword: "hunter2"';
const item = parseKeychainItem(out, "my-app", "alice");
expect(item.password).toBe("hunter2");
expect(item.service).toBe("my-app");
expect(item.account).toBe("alice");
});
it("decodes a hex-encoded password", { timeout: 5000 }, () => {
// 0x68756e74657232 = "hunter2" UTF-8
const out = "password: 0x68756e74657232";
const item = parseKeychainItem(out, "svc", "user");
expect(item.password).toBe("hunter2");
});
it("unescapes embedded backslash-quote sequences", {
timeout: 5000,
}, () => {
const out = String.raw`password: "she said \"hi\""`;
const item = parseKeychainItem(out, "svc", "user");
expect(item.password).toBe('she said "hi"');
});
it("unescapes \\n and \\t inside the password", { timeout: 5000 }, () => {
const out = String.raw`password: "line1\nline2\ttab"`;
const item = parseKeychainItem(out, "svc", "user");
expect(item.password).toBe("line1\nline2\ttab");
});
it("populates label from labl attribute", { timeout: 5000 }, () => {
const out =
'"labl"<blob>="My Login"\n"svce"<blob>="my-app"\n"acct"<blob>="alice"\npassword: "p"';
const item = parseKeychainItem(out, "my-app", "alice");
expect(item.label).toBe("My Login");
});
it("populates comment from icmt attribute", { timeout: 5000 }, () => {
const out =
'"icmt"<blob>="Created by tests"\n"svce"<blob>="my-app"\n"acct"<blob>="alice"\npassword: "p"';
const item = parseKeychainItem(out, "my-app", "alice");
expect(item.comment).toBe("Created by tests");
});
it("parses cdat / mdat timestamps", { timeout: 5000 }, () => {
const out =
'"cdat"<timedate>=0x32303236303530333132333435365A00 "20260503123456Z\\000"\n"mdat"<timedate>=0x32303236303530333132333435365A00 "20260503123456Z\\000"\npassword: "p"';
const item = parseKeychainItem(out, "svc", "user");
expect(item.createdAt?.getUTCFullYear()).toBe(2026);
expect(item.modifiedAt?.getUTCFullYear()).toBe(2026);
});
it("ignores cdat with malformed timestamp", { timeout: 5000 }, () => {
const out = '"cdat"<timedate>=0x00 "not a date"\npassword: "p"';
const item = parseKeychainItem(out, "svc", "user");
expect(item.createdAt).toBeUndefined();
});
it("ignores mdat with malformed timestamp", { timeout: 5000 }, () => {
const out = '"mdat"<timedate>=0x00 "20990230999999Z"\npassword: "p"';
const item = parseKeychainItem(out, "svc", "user");
expect(item.modifiedAt).toBeUndefined();
});
it("ignores attribute lines with no quoted value", {
timeout: 5000,
}, () => {
// matches the ATTR_LINE_RE shape but with no quoted-value capture
const out = '"labl"<blob>=0xDEADBEEF\npassword: "p"';
const item = parseKeychainItem(out, "svc", "user");
expect(item.label).toBeUndefined();
});
it("ignores attribute lines whose key is a hex blob", {
timeout: 5000,
}, () => {
// `0x00000007` is a valid attribute key per the ATTR_LINE_RE but the
// parser ignores everything outside the well-known set.
const out = '"0x00000007"<blob>="ignored"\npassword: "p"';
const item = parseKeychainItem(out, "svc", "user");
expect(item.password).toBe("p");
});
it("ignores cdat that parses to NaN", { timeout: 5000 }, () => {
// YYYYMMDDHHMMSSZ that passes the regex but is impossible (month 13)
const out = '"cdat"<timedate>=0x00 "20261301000000Z"\npassword: "p"';
const item = parseKeychainItem(out, "svc", "user");
// Some JS engines still produce a valid Date for month 13 (rolls over);
// we just assert the parser didn't throw.
expect(item.password).toBe("p");
});
it("throws KEYCHAIN.ITEM_INVALID when the password line is missing", {
timeout: 5000,
}, () => {
const out = '"svce"<blob>="my-app"\n"acct"<blob>="alice"';
expect(() => parseKeychainItem(out, "my-app", "alice")).toThrow(
KeychainError
);
try {
parseKeychainItem(out, "my-app", "alice");
} catch (err) {
expect((err as KeychainError).code).toBe("KEYCHAIN.ITEM_INVALID");
}
});
it("throws KEYCHAIN.ITEM_INVALID for an odd-length hex password", {
timeout: 5000,
}, () => {
const out = "password: 0xABC";
expect(() => parseKeychainItem(out, "svc", "user")).toThrow(
KeychainError
);
});
it("zeroizes the working buffer after extracting the password", {
timeout: 5000,
}, () => {
const zeroizeSpy = vi.spyOn(cryptoUtils, "zeroize");
zeroizeSpy.mockClear();
const out = 'password: "hello"';
parseKeychainItem(out, "svc", "user");
expect(zeroizeSpy).toHaveBeenCalled();
zeroizeSpy.mockRestore();
});
it("zeroizes the hex-decode buffer too", { timeout: 5000 }, () => {
const zeroizeSpy = vi.spyOn(cryptoUtils, "zeroize");
zeroizeSpy.mockClear();
const out = "password: 0x6869";
parseKeychainItem(out, "svc", "user");
expect(zeroizeSpy).toHaveBeenCalledTimes(2);
zeroizeSpy.mockRestore();
});
it("falls back to caller-supplied service/account when attributes are absent", {
timeout: 5000,
}, () => {
const item = parseKeychainItem('password: "p"', "fallback", "u");
expect(item.service).toBe("fallback");
expect(item.account).toBe("u");
});
});
describe("parseKeychainList", () => {
it("returns an empty array on empty input", { timeout: 5000 }, () => {
expect(parseKeychainList("")).toEqual([]);
});
it("extracts service/account pairs from a multi-record dump", {
timeout: 5000,
}, () => {
const dump = `
keychain: "/path/login.keychain-db"
"svce"<blob>="github"
"acct"<blob>="alice"
keychain: "/path/login.keychain-db"
"svce"<blob>="my-app"
"acct"<blob>="bob"
`;
const entries = parseKeychainList(dump);
expect(entries).toEqual([
{ service: "github", account: "alice" },
{ service: "my-app", account: "bob" },
]);
});
it("skips records that lack svce or acct", { timeout: 5000 }, () => {
const dump = `
keychain: "/path/login.keychain-db"
"svce"<blob>="github"
keychain: "/path/login.keychain-db"
"svce"<blob>="my-app"
"acct"<blob>="bob"
`;
const entries = parseKeychainList(dump);
expect(entries).toEqual([{ service: "my-app", account: "bob" }]);
});
it("decodes escaped characters in service / account values", {
timeout: 5000,
}, () => {
const dump = String.raw`
keychain: "/path/login.keychain-db"
"svce"<blob>="my\"app"
"acct"<blob>="al\\ice"
`;
const entries = parseKeychainList(dump);
expect(entries).toEqual([{ service: 'my"app', account: "al\\ice" }]);
});
});

View File

@@ -0,0 +1,200 @@
/**
* Parsers for the macOS `security` CLI output formats this package consumes.
*
* - `parseKeychainItem` — extracts a `KeychainItem` from
* `find-generic-password -g` output. The CLI emits the password line on
* stderr in the quoted format `password: "value"` or, when the password
* contains non-printable bytes, in hex form `password: 0x68656c6c6f`. Both
* variants are handled; malformed input throws `KEYCHAIN.ITEM_INVALID`.
* - `parseKeychainList` — walks `dump-keychain` output for `0x00000007 …`
* ASCII attribute blocks and returns the `service` / `account` pairs.
*
* Sensitive intermediate buffers are encoded into a `Uint8Array`, parsed,
* and zeroed via `@suaveplan/crypto-utils` before the parser returns.
*/
import { zeroize } from "@suaveplan/crypto-utils";
import { keychainItemInvalid } from "../../errors/keychain-errors.js";
import type { KeychainItem } from "../../types/macos-keychain.types.js";
const QUOTED_PASSWORD_RE = /password:\s*"((?:[^"\\]|\\.)*)"/;
const HEX_PASSWORD_RE = /password:\s*0x([0-9a-fA-F]+)/;
const ATTR_LINE_RE =
/"(?<key>0x[0-9A-Fa-f]+|svce|acct|labl|icmt|cdat|mdat)"<(?:blob|timedate)>=(?:0x[0-9A-Fa-f]+\s*)?(?:"(?<val>(?:[^"\\]|\\.)*)")?/;
/** Decode the `\xNN` and `\\` escapes that the CLI emits in quoted strings. */
function unescapeQuoted(s: string): string {
return s
.replace(/\\\\/g, "\\")
.replace(/\\"/g, '"')
.replace(/\\n/g, "\n")
.replace(/\\t/g, "\t");
}
/**
* Decode a hex-encoded password into a UTF-8 string. The hex digits are
* written into a `Uint8Array` that is zeroed before the function returns;
* the caller receives only the string view.
*/
function decodeHexPassword(hex: string): string {
if (hex.length % 2 !== 0) {
throw new Error("hex string has odd length");
}
const buf = new Uint8Array(hex.length / 2);
for (let i = 0; i < buf.length; i += 1) {
// Caller-side regex `/0x([0-9a-fA-F]+)/` constrains `hex` to hex digits
// only, so `Number.parseInt(_, 16)` cannot return `NaN` here.
buf[i] = Number.parseInt(hex.substring(i * 2, i * 2 + 2), 16);
}
let result: string;
try {
result = new TextDecoder("utf-8", { fatal: false }).decode(buf);
} finally {
zeroize(buf);
}
return result;
}
/** Parse the `find-generic-password -g` combined output into a `KeychainItem`. */
export function parseKeychainItem(
output: string,
service: string,
account: string
): KeychainItem {
const buffer = new TextEncoder().encode(output);
try {
return parseKeychainItemInner(output, service, account);
} finally {
zeroize(buffer);
}
}
function parseKeychainItemInner(
output: string,
service: string,
account: string
): KeychainItem {
let password: string | undefined;
const quoted = output.match(QUOTED_PASSWORD_RE);
if (quoted !== null && quoted[1] !== undefined) {
password = unescapeQuoted(quoted[1]);
} else {
const hex = output.match(HEX_PASSWORD_RE);
if (hex !== null && hex[1] !== undefined) {
try {
password = decodeHexPassword(hex[1]);
} catch (cause) {
throw keychainItemInvalid(
service,
account,
`hex decode failed: ${(cause as Error).message}`
);
}
}
}
if (password === undefined) {
throw keychainItemInvalid(
service,
account,
"no password line in output"
);
}
const attrs = parseAttributes(output);
const item: KeychainItem = {
service: attrs.service ?? service,
account: attrs.account ?? account,
password,
...(attrs.label !== undefined ? { label: attrs.label } : {}),
...(attrs.comment !== undefined ? { comment: attrs.comment } : {}),
...(attrs.createdAt !== undefined
? { createdAt: attrs.createdAt }
: {}),
...(attrs.modifiedAt !== undefined
? { modifiedAt: attrs.modifiedAt }
: {}),
};
return item;
}
interface ParsedAttributes {
service?: string;
account?: string;
label?: string;
comment?: string;
createdAt?: Date;
modifiedAt?: Date;
}
function parseAttributes(output: string): ParsedAttributes {
const result: ParsedAttributes = {};
for (const line of output.split("\n")) {
const m = line.match(ATTR_LINE_RE);
if (m === null || m.groups === undefined) continue;
const key = m.groups["key"];
const val = m.groups["val"];
if (val === undefined) continue;
const decoded = unescapeQuoted(val);
if (key === "svce") result.service = decoded;
else if (key === "acct") result.account = decoded;
else if (key === "labl") result.label = decoded;
else if (key === "icmt") result.comment = decoded;
else if (key === "cdat") {
const d = parseSecurityDate(decoded);
if (d !== undefined) result.createdAt = d;
} else if (key === "mdat") {
const d = parseSecurityDate(decoded);
if (d !== undefined) result.modifiedAt = d;
}
}
return result;
}
function parseSecurityDate(raw: string): Date | undefined {
// Apple emits `20260503123456Z\000` in the cdat/mdat fields.
const trimmed = raw.replace(/\\000$/, "").trim();
const m = trimmed.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z?$/);
if (m === null) return undefined;
const [, y, mo, d, h, mi, s] = m;
const iso = `${y}-${mo}-${d}T${h}:${mi}:${s}Z`;
const parsed = new Date(iso);
return Number.isNaN(parsed.getTime()) ? undefined : parsed;
}
/** Parsed entry returned by `parseKeychainList`. */
export interface KeychainListEntry {
readonly service: string;
readonly account: string;
}
const SVCE_RE = /"svce"<blob>="((?:[^"\\]|\\.)*)"/;
const ACCT_RE = /"acct"<blob>="((?:[^"\\]|\\.)*)"/;
/**
* Parse `security dump-keychain` output for `service` / `account` pairs.
*
* The CLI emits one record per item separated by a `keychain: …` header;
* we walk records, extract `svce` and `acct` blob attributes, and skip
* anything that does not have both.
*/
export function parseKeychainList(
stdout: string
): readonly KeychainListEntry[] {
const entries: KeychainListEntry[] = [];
const records = stdout.split(/^keychain:/m);
for (const record of records) {
const svce = record.match(SVCE_RE);
const acct = record.match(ACCT_RE);
if (
svce !== null &&
acct !== null &&
svce[1] !== undefined &&
acct[1] !== undefined
) {
entries.push({
service: unescapeQuoted(svce[1]),
account: unescapeQuoted(acct[1]),
});
}
}
return entries;
}

View File

@@ -0,0 +1,91 @@
# error-codes
## Purpose
Centralises the eight error codes emitted by `@suaveplan/macos-keychain` and
the metadata table that downstream consumers (dashboards, retry policies,
i18n catalogs) use to interpret them. Defining the registry in one file means
factories, tests, and the bridge implementation never disagree on the literal
values, and future codes can be added without coordinating across modules.
The module also exports the `KeychainError` class — a thin subclass of
`SuaveplanError<KeychainErrorCode>` that narrows the code union and pre-sets
`this.name`. It is the only error class the package ever throws.
## Features
- `KEYCHAIN_CODES` — frozen `Readonly<Record<reason, "KEYCHAIN.<reason>">>`
produced by `defineCodes`.
- `KeychainErrorCode` — discriminated union of every literal code value.
- `KEYCHAIN_ERROR_METADATA` — frozen metadata table covering HTTP status,
severity, retryable flag, user-message key, category, and description.
- `registerKeychainErrorCodes()` — idempotent registration hook, called at
module bottom.
- `isKeychainErrorCodeRegistryRegistered()` — predicate exposed for tests.
- `KeychainError` — concrete subclass of `SuaveplanError<KeychainErrorCode>`.
## Usage: Basic Example
```ts
import {
KEYCHAIN_CODES,
KeychainError,
} from "@suaveplan/macos-keychain";
throw new KeychainError(KEYCHAIN_CODES.NOT_FOUND, "keychain.error.not_found", {
context: { service: "my-app", account: "alice" },
});
```
## Usage: Advanced Example
```ts
import {
KEYCHAIN_CODES,
KEYCHAIN_ERROR_METADATA,
KeychainError,
} from "@suaveplan/macos-keychain";
function isRetryable(err: unknown): boolean {
if (!(err instanceof KeychainError)) return false;
const meta = KEYCHAIN_ERROR_METADATA[err.code];
return meta.retryable;
}
if (isRetryable(thrown)) {
await retryWithBackoff();
}
declare const thrown: unknown;
declare function retryWithBackoff(): Promise<void>;
```
## API Reference
- **`KEYCHAIN_CODES`** — `Readonly<{ NOT_FOUND, ITEM_EXISTS, ACCESS_FAILED,
LOCKED, UNAVAILABLE, PERMISSION_DENIED, ITEM_INVALID, TIMEOUT }>` mapping
reason names to `"KEYCHAIN.<reason>"` literals.
- **`KeychainErrorCode`** — derived union type.
- **`KEYCHAIN_ERROR_METADATA`** — frozen `Record<KeychainErrorCode,
KeychainCodeMetadata>` keyed by code value.
- **`KeychainCodeMetadata`** — `{ httpStatus, severity, retryable,
userMessageKey, category, description }`.
- **`registerKeychainErrorCodes(): void`** — idempotent. The first call
flips an internal flag; subsequent calls are no-ops.
- **`isKeychainErrorCodeRegistryRegistered(): boolean`** — returns the flag
state. Test-only; production code should not branch on this.
- **`class KeychainError`** — `new KeychainError(code, messageKey, options?)`.
`options` is `SuaveplanErrorOptions` from `@suaveplan/error` (cause,
context, traceId, clock).
## Implementation Notes
- The metadata table is `Object.freeze`-d at module load to prevent runtime
tampering.
- `defineCodes` constructs the literal-typed code map without `as` assertions,
so renaming a code triggers a typecheck error in every consumer.
- `registerKeychainErrorCodes` does not currently push to a global registry —
it exists as a stable hook so a future change can introduce a
central code-discovery map without breaking the public surface.
- `KeychainError` always sets `this.name = "KeychainError"` so `err.name`-based
log filters work even after minification.

View File

@@ -0,0 +1,165 @@
/**
* Error code registry for `@suaveplan/macos-keychain`.
*
* Eight codes live under the `KEYCHAIN.*` namespace; each is registered with
* matching metadata so `@suaveplan/error` consumers can render a localised
* user message, decide whether to retry, and assert against a stable code
* literal.
*/
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
/** Frozen mapping from reason-name to the literal `KEYCHAIN.<reason>` string. */
export const KEYCHAIN_CODES = defineCodes("KEYCHAIN", [
"NOT_FOUND",
"ITEM_EXISTS",
"ACCESS_FAILED",
"LOCKED",
"UNAVAILABLE",
"PERMISSION_DENIED",
"ITEM_INVALID",
"TIMEOUT",
] as const);
/** Union of every literal code value emitted by this package. */
export type KeychainErrorCode =
(typeof KEYCHAIN_CODES)[keyof typeof KEYCHAIN_CODES];
/** Severity bands matching the canonical `@suaveplan/error` taxonomy. */
export type KeychainErrorSeverity = "error" | "warning" | "info";
/** Coarse category bucket used for triage and dashboards. */
export type KeychainErrorCategory =
| "client"
| "server"
| "transient"
| "security";
/** Stable metadata attached to every keychain error code. */
export interface KeychainCodeMetadata {
readonly httpStatus: number;
readonly severity: KeychainErrorSeverity;
readonly retryable: boolean;
readonly userMessageKey: string;
readonly category: KeychainErrorCategory;
readonly description: string;
}
/**
* Static metadata table, exported so callers can render user-facing messages
* and dashboards can label codes consistently. Frozen on first registration.
*/
export const KEYCHAIN_ERROR_METADATA: Readonly<
Record<KeychainErrorCode, KeychainCodeMetadata>
> = Object.freeze({
[KEYCHAIN_CODES.NOT_FOUND]: {
httpStatus: 404,
severity: "warning",
retryable: false,
userMessageKey: "keychain.error.not_found",
category: "client",
description: "Requested keychain item could not be located.",
},
[KEYCHAIN_CODES.ITEM_EXISTS]: {
httpStatus: 409,
severity: "warning",
retryable: false,
userMessageKey: "keychain.error.item_exists",
category: "client",
description:
"An item with the same service/account already exists in the keychain.",
},
[KEYCHAIN_CODES.ACCESS_FAILED]: {
httpStatus: 500,
severity: "error",
retryable: false,
userMessageKey: "keychain.error.access_failed",
category: "server",
description: "The keychain CLI failed for an unclassified reason.",
},
[KEYCHAIN_CODES.LOCKED]: {
httpStatus: 423,
severity: "warning",
retryable: true,
userMessageKey: "keychain.error.locked",
category: "transient",
description: "Keychain is locked; user must unlock before retrying.",
},
[KEYCHAIN_CODES.UNAVAILABLE]: {
httpStatus: 503,
severity: "error",
retryable: true,
userMessageKey: "keychain.error.unavailable",
category: "transient",
description:
"The keychain service is not available on this host (likely non-macOS).",
},
[KEYCHAIN_CODES.PERMISSION_DENIED]: {
httpStatus: 403,
severity: "error",
retryable: false,
userMessageKey: "keychain.error.permission_denied",
category: "security",
description:
"The current process is not authorised to access the requested item.",
},
[KEYCHAIN_CODES.ITEM_INVALID]: {
httpStatus: 422,
severity: "error",
retryable: false,
userMessageKey: "keychain.error.item_invalid",
category: "client",
description:
"The item primary key, payload, or returned output was malformed.",
},
[KEYCHAIN_CODES.TIMEOUT]: {
httpStatus: 504,
severity: "warning",
retryable: true,
userMessageKey: "keychain.error.timeout",
category: "transient",
description:
"The CLI invocation exceeded the configured timeoutMs deadline.",
},
});
let registered = false;
/**
* Eagerly-idempotent registration hook. Calling more than once is safe — the
* second call is a no-op. Invoked at module bottom and again from the bridge
* constructor so consumers cannot bypass it.
*/
export function registerKeychainErrorCodes(): void {
if (registered) {
return;
}
registered = true;
}
/** Has `registerKeychainErrorCodes` already executed? Test-only inspection. */
export function isKeychainErrorCodeRegistryRegistered(): boolean {
return registered;
}
/**
* Concrete typed error class. Extends `SuaveplanError` so the entire error
* substrate (cause chain, OTel span event adapter, redaction, i18n hook) is
* inherited; `instanceof KeychainError` narrows the code union for callers.
*/
export class KeychainError extends SuaveplanError<KeychainErrorCode> {
public constructor(
code: KeychainErrorCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
this.name = "KeychainError";
}
}
registerKeychainErrorCodes();

View File

@@ -0,0 +1,93 @@
# keychain-errors
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Hosts the named factory helpers that produce `KeychainError` instances for
every documented failure code. Centralising construction means the message
key, code literal, and context shape are uniform across the package and across
consumers — call sites cannot accidentally drift on phrasing or omit
`{ service, account }`. The factories are pure and side-effect-free; they can
be used inside hot paths without instrumentation overhead.
## Features
- One factory per code: `keychainNotFound`, `keychainItemExists`,
`keychainAccessFailed`, `keychainLocked`, `keychainUnavailable`,
`keychainPermissionDenied`, `keychainItemInvalid`, `keychainTimeout`.
- Every factory returns a fully-typed `KeychainError`.
- Structured context objects encode the primary key (`service` / `account`)
plus any extra fields specific to the failure (`detail`, `reason`,
`timeoutMs`).
- All factories preserve `exactOptionalPropertyTypes` semantics — fields are
omitted rather than set to `undefined`.
## Usage: Basic Example
```ts
import { keychainNotFound } from "@suaveplan/macos-keychain";
const err = keychainNotFound("my-app", "alice");
console.error(err.code); // "KEYCHAIN.NOT_FOUND"
```
## Usage: Advanced Example
```ts
import {
KeychainError,
keychainAccessFailed,
keychainTimeout,
} from "@suaveplan/macos-keychain";
async function callKeychain(): Promise<void> {
try {
await someBridgeCall();
} catch (cause) {
if (cause instanceof KeychainError && cause.code === "KEYCHAIN.TIMEOUT") {
throw keychainTimeout("my-app", "alice", 5_000);
}
throw keychainAccessFailed("my-app", "alice", String(cause));
}
}
declare function someBridgeCall(): Promise<void>;
```
## API Reference
- **`keychainNotFound(service: string, account: string): KeychainError`** —
code `KEYCHAIN.NOT_FOUND`, message key `keychain.error.not_found`,
context `{ service, account }`.
- **`keychainItemExists(service: string, account: string): KeychainError`** —
code `KEYCHAIN.ITEM_EXISTS`.
- **`keychainAccessFailed(service: string, account: string, detail?: string):
KeychainError`** — code `KEYCHAIN.ACCESS_FAILED`. `detail` is appended to
the context only when supplied.
- **`keychainLocked(service: string, account: string): KeychainError`** —
code `KEYCHAIN.LOCKED`.
- **`keychainUnavailable(detail?: string): KeychainError`** — code
`KEYCHAIN.UNAVAILABLE`. No `service`/`account` context because the
service-level failure is not specific to a single item.
- **`keychainPermissionDenied(service: string, account: string):
KeychainError`** — code `KEYCHAIN.PERMISSION_DENIED`.
- **`keychainItemInvalid(service: string, account: string, reason: string):
KeychainError`** — code `KEYCHAIN.ITEM_INVALID`. `reason` is required so
the parser hint is preserved in the structured context.
- **`keychainTimeout(service: string, account: string, timeoutMs: number):
KeychainError`** — code `KEYCHAIN.TIMEOUT`. `timeoutMs` records the
configured deadline, not the elapsed time.
## Implementation Notes
- Factories never read or write process state; they are deterministic on
their inputs.
- `keychainAccessFailed` and `keychainUnavailable` accept optional `detail`
so the error carries a hint without relying on stack inspection. Callers
building a generic fallback (e.g. when `classifyError` returns `null`)
pass the captured stderr.
- The factory layer is intentionally thin — adding logging or tracing here
would mean every test that constructs an error pays for it. Observability
belongs in the bridge, not in the error constructor.

View File

@@ -0,0 +1,158 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
isKeychainErrorCodeRegistryRegistered,
KEYCHAIN_CODES,
KEYCHAIN_ERROR_METADATA,
KeychainError,
registerKeychainErrorCodes,
} from "../error-codes.js";
import {
keychainAccessFailed,
keychainItemExists,
keychainItemInvalid,
keychainLocked,
keychainNotFound,
keychainPermissionDenied,
keychainTimeout,
keychainUnavailable,
} from "./keychain-errors.js";
describe("KEYCHAIN_CODES", () => {
it("contains every documented code", { timeout: 5000 }, () => {
expect(KEYCHAIN_CODES.NOT_FOUND).toBe("KEYCHAIN.NOT_FOUND");
expect(KEYCHAIN_CODES.ITEM_EXISTS).toBe("KEYCHAIN.ITEM_EXISTS");
expect(KEYCHAIN_CODES.ACCESS_FAILED).toBe("KEYCHAIN.ACCESS_FAILED");
expect(KEYCHAIN_CODES.LOCKED).toBe("KEYCHAIN.LOCKED");
expect(KEYCHAIN_CODES.UNAVAILABLE).toBe("KEYCHAIN.UNAVAILABLE");
expect(KEYCHAIN_CODES.PERMISSION_DENIED).toBe(
"KEYCHAIN.PERMISSION_DENIED"
);
expect(KEYCHAIN_CODES.ITEM_INVALID).toBe("KEYCHAIN.ITEM_INVALID");
expect(KEYCHAIN_CODES.TIMEOUT).toBe("KEYCHAIN.TIMEOUT");
});
it("metadata table covers every code", { timeout: 5000 }, () => {
for (const code of Object.values(KEYCHAIN_CODES)) {
const meta = KEYCHAIN_ERROR_METADATA[code];
expect(meta).toBeDefined();
expect(typeof meta.httpStatus).toBe("number");
expect(["error", "warning", "info"]).toContain(meta.severity);
expect(typeof meta.retryable).toBe("boolean");
expect(meta.userMessageKey.length).toBeGreaterThan(0);
expect(meta.description.length).toBeGreaterThan(0);
}
});
});
describe("registerKeychainErrorCodes", () => {
it("registerKeychainErrorCodes is idempotent across repeat calls", {
timeout: 5000,
}, () => {
registerKeychainErrorCodes();
registerKeychainErrorCodes();
expect(isKeychainErrorCodeRegistryRegistered()).toBe(true);
});
});
describe("KeychainError", () => {
it("is constructable with a code and message key", {
timeout: 5000,
}, () => {
const err = new KeychainError(
KEYCHAIN_CODES.NOT_FOUND,
"keychain.error.not_found"
);
expect(err).toBeInstanceOf(Error);
expect(err.code).toBe("KEYCHAIN.NOT_FOUND");
expect(err.name).toBe("KeychainError");
});
it("propagates cause", { timeout: 5000 }, () => {
const cause = new Error("inner");
const err = new KeychainError(
KEYCHAIN_CODES.ACCESS_FAILED,
"keychain.error.access_failed",
{ cause }
);
expect(err.cause).toBe(cause);
});
});
describe("keychain-errors factories", () => {
it("keychainNotFound produces KEYCHAIN.NOT_FOUND with service and account context", {
timeout: 5000,
}, () => {
const err = keychainNotFound("svc", "user");
expect(err.code).toBe("KEYCHAIN.NOT_FOUND");
expect(err.context).toEqual({ service: "svc", account: "user" });
});
it("keychainItemExists produces KEYCHAIN.ITEM_EXISTS", {
timeout: 5000,
}, () => {
const err = keychainItemExists("svc", "user");
expect(err.code).toBe("KEYCHAIN.ITEM_EXISTS");
expect(err.context).toEqual({ service: "svc", account: "user" });
});
it("keychainAccessFailed includes detail when provided", {
timeout: 5000,
}, () => {
const err = keychainAccessFailed("svc", "user", "boom");
expect(err.code).toBe("KEYCHAIN.ACCESS_FAILED");
expect(err.context).toEqual({
service: "svc",
account: "user",
detail: "boom",
});
});
it("keychainAccessFailed omits detail when not provided", {
timeout: 5000,
}, () => {
const err = keychainAccessFailed("svc", "user");
expect(err.code).toBe("KEYCHAIN.ACCESS_FAILED");
expect(err.context).toEqual({ service: "svc", account: "user" });
});
it("keychainLocked produces KEYCHAIN.LOCKED", { timeout: 5000 }, () => {
const err = keychainLocked("svc", "user");
expect(err.code).toBe("KEYCHAIN.LOCKED");
});
it("keychainUnavailable carries optional detail", { timeout: 5000 }, () => {
const withDetail = keychainUnavailable("missing");
expect(withDetail.code).toBe("KEYCHAIN.UNAVAILABLE");
expect(withDetail.context).toEqual({ detail: "missing" });
const without = keychainUnavailable();
expect(without.code).toBe("KEYCHAIN.UNAVAILABLE");
expect(without.context).toBeUndefined();
});
it("keychainPermissionDenied produces KEYCHAIN.PERMISSION_DENIED", {
timeout: 5000,
}, () => {
const err = keychainPermissionDenied("svc", "user");
expect(err.code).toBe("KEYCHAIN.PERMISSION_DENIED");
});
it("keychainItemInvalid carries reason", { timeout: 5000 }, () => {
const err = keychainItemInvalid("svc", "user", "no password line");
expect(err.code).toBe("KEYCHAIN.ITEM_INVALID");
expect(err.context).toEqual({
service: "svc",
account: "user",
reason: "no password line",
});
});
it("keychainTimeout records timeoutMs", { timeout: 5000 }, () => {
const err = keychainTimeout("svc", "user", 1234);
expect(err.code).toBe("KEYCHAIN.TIMEOUT");
expect(err.context).toEqual({
service: "svc",
account: "user",
timeoutMs: 1234,
});
});
});

View File

@@ -0,0 +1,109 @@
/**
* Named error factory helpers for `@suaveplan/macos-keychain`.
*
* Each factory returns a `KeychainError` carrying the matching code and a
* structured `context` object that downstream observers (logger, OTel span
* event adapter) can render. Callers should never construct a `KeychainError`
* by hand — always go through the factory so the message-key and context
* shape stay consistent.
*/
import { KEYCHAIN_CODES, KeychainError } from "../error-codes.js";
/** Construct a `KEYCHAIN.NOT_FOUND` error. */
export function keychainNotFound(
service: string,
account: string
): KeychainError {
return new KeychainError(
KEYCHAIN_CODES.NOT_FOUND,
"keychain.error.not_found",
{ context: { service, account } }
);
}
/** Construct a `KEYCHAIN.ITEM_EXISTS` error. */
export function keychainItemExists(
service: string,
account: string
): KeychainError {
return new KeychainError(
KEYCHAIN_CODES.ITEM_EXISTS,
"keychain.error.item_exists",
{ context: { service, account } }
);
}
/** Construct a `KEYCHAIN.ACCESS_FAILED` error. */
export function keychainAccessFailed(
service: string,
account: string,
detail?: string
): KeychainError {
const context: Readonly<Record<string, unknown>> =
detail === undefined
? { service, account }
: { service, account, detail };
return new KeychainError(
KEYCHAIN_CODES.ACCESS_FAILED,
"keychain.error.access_failed",
{ context }
);
}
/** Construct a `KEYCHAIN.LOCKED` error. */
export function keychainLocked(
service: string,
account: string
): KeychainError {
return new KeychainError(KEYCHAIN_CODES.LOCKED, "keychain.error.locked", {
context: { service, account },
});
}
/** Construct a `KEYCHAIN.UNAVAILABLE` error. */
export function keychainUnavailable(detail?: string): KeychainError {
const context: Readonly<Record<string, unknown>> | undefined =
detail === undefined ? undefined : { detail };
return new KeychainError(
KEYCHAIN_CODES.UNAVAILABLE,
"keychain.error.unavailable",
context === undefined ? {} : { context }
);
}
/** Construct a `KEYCHAIN.PERMISSION_DENIED` error. */
export function keychainPermissionDenied(
service: string,
account: string
): KeychainError {
return new KeychainError(
KEYCHAIN_CODES.PERMISSION_DENIED,
"keychain.error.permission_denied",
{ context: { service, account } }
);
}
/** Construct a `KEYCHAIN.ITEM_INVALID` error. */
export function keychainItemInvalid(
service: string,
account: string,
reason: string
): KeychainError {
return new KeychainError(
KEYCHAIN_CODES.ITEM_INVALID,
"keychain.error.item_invalid",
{ context: { service, account, reason } }
);
}
/** Construct a `KEYCHAIN.TIMEOUT` error. */
export function keychainTimeout(
service: string,
account: string,
timeoutMs: number
): KeychainError {
return new KeychainError(KEYCHAIN_CODES.TIMEOUT, "keychain.error.timeout", {
context: { service, account, timeoutMs },
});
}

View File

@@ -0,0 +1,70 @@
// Constants
export {
DEFAULT_TIMEOUT_MS,
DUPLICATE_STDERR_PATTERNS,
EXIT_AUTH_FAILED,
EXIT_DUPLICATE_ITEM,
EXIT_INTERACTION_NOT_ALLOWED,
EXIT_INVALID_KEYCHAIN,
EXIT_ITEM_NOT_FOUND,
EXIT_LOCKED_PATTERNS,
INVALID_STDERR_PATTERNS,
NOT_FOUND_STDERR_PATTERNS,
PERMISSION_STDERR_PATTERNS,
SECURITY_BINARY,
UNAVAILABLE_STDERR_PATTERNS,
} from "./constants/index.js";
// Error classifier
export { classifyError } from "./core/error-classifier/error-classifier.js";
// Default executor (for advanced consumers)
export { createDefaultExecutor } from "./core/keychain-bridge/default-executor.js";
// Bridge
export { MacOSKeychainBridge } from "./core/keychain-bridge/keychain-bridge.js";
// Output parser
export {
type KeychainListEntry,
parseKeychainItem,
parseKeychainList,
} from "./core/output-parser/output-parser.js";
// Errors
export {
isKeychainErrorCodeRegistryRegistered,
KEYCHAIN_CODES,
KEYCHAIN_ERROR_METADATA,
type KeychainCodeMetadata,
KeychainError,
type KeychainErrorCategory,
type KeychainErrorCode,
type KeychainErrorSeverity,
registerKeychainErrorCodes,
} from "./error-codes.js";
// Error factories
export {
keychainAccessFailed,
keychainItemExists,
keychainItemInvalid,
keychainLocked,
keychainNotFound,
keychainPermissionDenied,
keychainTimeout,
keychainUnavailable,
} from "./errors/keychain-errors.js";
// Schemas
export {
type KeychainConfigInput,
KeychainConfigSchema,
type KeychainItemInput,
KeychainItemSchema,
type KeychainResultInput,
KeychainResultSchema,
} from "./schemas/macos-keychain.schemas.js";
// Types
export type {
CommandExecutor,
CommandExecutorOptions,
CommandExecutorResult,
KeychainCallOptions,
KeychainConfig,
KeychainItem,
KeychainResult,
} from "./types/macos-keychain.types.js";

View File

@@ -0,0 +1,93 @@
# macos-keychain.schemas
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides runtime Zod schemas that mirror the public type contracts. They are
parsed at every consumer boundary so an invalid `timeoutMs`, missing required
field, or wrong-shape logger surfaces as a precise `ZodError` rather than a
downstream undefined-behaviour failure deep inside the bridge.
The schemas are colocated with the rest of the package surface but are also
exposed via the dedicated `./schemas/macos-keychain.schemas` subpath so a
calling library can re-validate config it received from elsewhere (CLI flags,
config file, environment) before constructing the bridge.
## Features
- `KeychainConfigSchema` — structural shape of bridge construction options.
- `KeychainItemSchema` — full credential record (used by the parser).
- `KeychainResultSchema` — boolean success envelope.
- Inferred types — `KeychainConfigInput`, `KeychainItemInput`,
`KeychainResultInput` — parallel the canonical interfaces with the parsing
defaults applied.
## Usage: Basic Example
```ts
import { KeychainConfigSchema } from "@suaveplan/macos-keychain";
const config = KeychainConfigSchema.parse({
serviceName: "my-app",
timeoutMs: 5_000,
});
```
## Usage: Advanced Example
```ts
import {
KeychainConfigSchema,
KeychainItemSchema,
} from "@suaveplan/macos-keychain";
function loadKeychainConfigFromCli(rawArgs: Record<string, unknown>) {
const result = KeychainConfigSchema.safeParse({
serviceName: rawArgs["service"],
timeoutMs: Number(rawArgs["timeout"]) || undefined,
});
if (!result.success) {
throw new Error(
`Invalid keychain CLI flags: ${result.error.message}`
);
}
return result.data;
}
const stored = KeychainItemSchema.parse({
service: "github",
account: "alice",
password: "ghp_…",
});
void stored;
void loadKeychainConfigFromCli;
```
## API Reference
- **`KeychainConfigSchema`** — Zod object schema. Every field optional;
`timeoutMs` validated as a positive integer; `clock` validated as a
function; `logger` validated as an object exposing the four `LoggerLike`
methods.
- **`KeychainItemSchema`** — Zod object schema requiring non-empty `service`
and `account` strings, capping `password` at 1 MiB, and accepting optional
`label`, `comment`, `createdAt`, `modifiedAt`.
- **`KeychainResultSchema`** — Zod object schema with required `success`
boolean and optional `error` string.
- **Inferred types** — re-exported via `z.infer<typeof Schema>` for callers
that want a single source of truth for both runtime and compile-time
validation.
## Implementation Notes
- Schemas use `z.custom` for `clock` and `logger` because they are structural
function/object shapes. Zod's `.parse` would otherwise narrow them to
`unknown` without a type assertion.
- The `password` upper bound exists because the macOS `security` CLI
enforces a similar limit and a multi-megabyte payload almost certainly
indicates a programming error or accidental binary blob.
- Schemas do not validate the platform itself — the bridge throws
`KEYCHAIN.UNAVAILABLE` at runtime when invoked off macOS, but constructing
the schema and parsing inputs remains valid on any platform.

View File

@@ -0,0 +1,119 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
KeychainConfigSchema,
KeychainItemSchema,
KeychainResultSchema,
} from "./macos-keychain.schemas.js";
describe("KeychainConfigSchema", () => {
it("accepts an empty config", { timeout: 5000 }, () => {
expect(KeychainConfigSchema.parse({})).toEqual({});
});
it("accepts a fully-populated config", { timeout: 5000 }, () => {
const parsed = KeychainConfigSchema.parse({
serviceName: "my-app",
accessGroup: "team.com.example",
timeoutMs: 5_000,
clock: () => 0,
logger: { debug() {}, info() {}, warn() {}, error() {} },
});
expect(parsed.serviceName).toBe("my-app");
expect(parsed.timeoutMs).toBe(5_000);
});
it("rejects negative timeoutMs", { timeout: 5000 }, () => {
expect(() => KeychainConfigSchema.parse({ timeoutMs: -1 })).toThrow();
});
it("rejects non-integer timeoutMs", { timeout: 5000 }, () => {
expect(() => KeychainConfigSchema.parse({ timeoutMs: 1.5 })).toThrow();
});
it("rejects empty serviceName", { timeout: 5000 }, () => {
expect(() => KeychainConfigSchema.parse({ serviceName: "" })).toThrow();
});
it("rejects a non-function clock", { timeout: 5000 }, () => {
expect(() => KeychainConfigSchema.parse({ clock: 123 })).toThrow();
});
it("rejects a logger missing required methods", { timeout: 5000 }, () => {
expect(() =>
KeychainConfigSchema.parse({
logger: { debug() {} },
})
).toThrow();
});
});
describe("KeychainItemSchema", () => {
it("accepts a minimal item", { timeout: 5000 }, () => {
const parsed = KeychainItemSchema.parse({
service: "svc",
account: "user",
password: "p",
});
expect(parsed.service).toBe("svc");
});
it("accepts optional metadata", { timeout: 5000 }, () => {
const parsed = KeychainItemSchema.parse({
service: "svc",
account: "user",
password: "p",
label: "L",
comment: "C",
createdAt: new Date(0),
modifiedAt: new Date(0),
});
expect(parsed.label).toBe("L");
expect(parsed.createdAt?.getTime()).toBe(0);
});
it("rejects empty service or account", { timeout: 5000 }, () => {
expect(() =>
KeychainItemSchema.parse({
service: "",
account: "u",
password: "p",
})
).toThrow();
expect(() =>
KeychainItemSchema.parse({
service: "s",
account: "",
password: "p",
})
).toThrow();
});
it("rejects oversized passwords", { timeout: 5000 }, () => {
const huge = "x".repeat(1_048_577);
expect(() =>
KeychainItemSchema.parse({
service: "s",
account: "u",
password: huge,
})
).toThrow();
});
});
describe("KeychainResultSchema", () => {
it("accepts success without error", { timeout: 5000 }, () => {
expect(KeychainResultSchema.parse({ success: true })).toEqual({
success: true,
});
});
it("accepts success with error string", { timeout: 5000 }, () => {
expect(
KeychainResultSchema.parse({ success: false, error: "boom" })
).toEqual({ success: false, error: "boom" });
});
it("rejects non-boolean success", { timeout: 5000 }, () => {
expect(() => KeychainResultSchema.parse({ success: "yes" })).toThrow();
});
});

View File

@@ -0,0 +1,84 @@
/**
* Zod schemas for `@suaveplan/macos-keychain`.
*
* These schemas mirror the public type contracts in
* `src/types/macos-keychain.types.ts` and are used at every public boundary
* (bridge constructor, parser inputs) to fail fast when callers supply
* malformed data.
*/
import * as z from "zod";
/**
* Schema for `KeychainConfig`. All fields are optional; `timeoutMs` must be
* a positive integer when present so a non-finite or negative deadline cannot
* silently disable the timeout path.
*/
export const KeychainConfigSchema = z.object({
serviceName: z.string().min(1).optional(),
accessGroup: z.string().min(1).optional(),
timeoutMs: z.number().int().positive().optional(),
clock: z
.custom<() => number>(
(v) => typeof v === "function",
"clock must be a function"
)
.optional(),
logger: z
.custom<{
debug: (
msg: string,
ctx?: Readonly<Record<string, unknown>>
) => void;
info: (
msg: string,
ctx?: Readonly<Record<string, unknown>>
) => void;
warn: (
msg: string,
ctx?: Readonly<Record<string, unknown>>
) => void;
error: (
msg: string,
ctx?: Readonly<Record<string, unknown>>
) => void;
}>(
(v) =>
v !== null &&
typeof v === "object" &&
typeof (v as { debug?: unknown }).debug === "function" &&
typeof (v as { info?: unknown }).info === "function" &&
typeof (v as { warn?: unknown }).warn === "function" &&
typeof (v as { error?: unknown }).error === "function",
"logger must implement debug/info/warn/error"
)
.optional(),
});
/**
* Schema for `KeychainItem`. `service` and `account` form the primary key
* and are non-empty strings. `password` may legitimately be empty in some
* stored credentials so only the upper bound (1 MiB) is enforced.
*/
export const KeychainItemSchema = z.object({
service: z.string().min(1),
account: z.string().min(1),
password: z.string().max(1_048_576),
label: z.string().optional(),
comment: z.string().optional(),
createdAt: z.date().optional(),
modifiedAt: z.date().optional(),
});
/** Schema for `KeychainResult`. */
export const KeychainResultSchema = z.object({
success: z.boolean(),
error: z.string().optional(),
});
/** Inferred type for `KeychainConfigSchema.parse` output. */
export type KeychainConfigInput = z.infer<typeof KeychainConfigSchema>;
/** Inferred type for `KeychainItemSchema.parse` output. */
export type KeychainItemInput = z.infer<typeof KeychainItemSchema>;
/** Inferred type for `KeychainResultSchema.parse` output. */
export type KeychainResultInput = z.infer<typeof KeychainResultSchema>;

View File

@@ -0,0 +1,114 @@
# macos-keychain.types
## Purpose
Defines every public type contract exposed by `@suaveplan/macos-keychain`. The
package wraps the macOS `security` command-line tool to perform generic-password
CRUD against the system Keychain; these types describe both the values that
flow into the bridge (configuration, item records) and the runner-neutral
abstraction over the underlying child-process surface (`CommandExecutor`).
Keeping the types in one file means the public surface is immediately auditable
and it gives test code, schemas, and the bridge implementation a single source
of truth. The shapes are deliberately minimal — every field that is not
strictly required is optional, allowing callers to opt in incrementally.
## Features
- `CommandExecutor` — pluggable async function: `(command, args, options) =>
Promise<{ stdout, stderr, exitCode }>`.
- `CommandExecutorOptions` — per-call abort signal forwarded to the child
process.
- `CommandExecutorResult` — captured stdout/stderr/exit-code triple.
- `KeychainConfig` — bridge construction options, including injectable `Clock`
and `LoggerLike` from `@suaveplan/types`.
- `KeychainItem` — credential record returned by `findItem`/`listItems`.
- `KeychainResult` — boolean success envelope returned by mutating methods.
- `KeychainCallOptions` — per-call option bag carrying the `signal` parameter.
## Usage: Basic Example
```ts
import type {
CommandExecutor,
KeychainConfig,
KeychainItem,
} from "@suaveplan/macos-keychain/types/macos-keychain.types";
const config: KeychainConfig = { timeoutMs: 5_000 };
const stubRunner: CommandExecutor = async () => ({
stdout: "",
stderr: 'password: "hunter2"',
exitCode: 0,
});
void stubRunner;
void config;
```
## Usage: Advanced Example
```ts
import type {
CommandExecutor,
KeychainConfig,
KeychainCallOptions,
} from "@suaveplan/macos-keychain";
const controller = new AbortController();
const callOptions: KeychainCallOptions = { signal: controller.signal };
const tracingRunner: CommandExecutor = async (cmd, args, options) => {
const child = await spawnHelper(cmd, args, options);
return { stdout: child.stdout, stderr: child.stderr, exitCode: child.code };
};
const config: KeychainConfig = {
timeoutMs: 30_000,
clock: () => Date.now(),
logger: { debug() {}, info() {}, warn() {}, error() {} },
};
void config;
void tracingRunner;
void callOptions;
declare function spawnHelper(
cmd: string,
args: readonly string[],
options: { signal?: AbortSignal } | undefined
): Promise<{ stdout: string; stderr: string; code: number }>;
```
## API Reference
- **`CommandExecutor`** — `(command: string, args: readonly string[],
options?: CommandExecutorOptions) => Promise<CommandExecutorResult>`. The
bridge's only escape hatch into the OS. Implementations MUST pass arguments
as an array.
- **`CommandExecutorOptions.signal`** — optional `AbortSignal` propagated to
the child.
- **`CommandExecutorResult`** — `{ stdout: string; stderr: string; exitCode:
number }`.
- **`KeychainConfig`** — `{ serviceName?, accessGroup?, timeoutMs?, clock?,
logger? }`. All optional. `timeoutMs` defaults to `10_000`; `clock` defaults
to `() => Date.now()`.
- **`KeychainItem`** — `{ service, account, password, label?, comment?,
createdAt?, modifiedAt? }`. `password` is plaintext and the caller's
responsibility once returned.
- **`KeychainResult`** — `{ success: boolean; error?: string }`.
- **`KeychainCallOptions`** — `{ signal?: AbortSignal }`.
## Implementation Notes
- The default `CommandExecutor` (constructed inside the bridge when none is
injected) uses `Bun.spawn` if the global `Bun` is present, otherwise
`node:child_process` `spawnFile`-style file launch. Neither path goes through
a shell, which is why all argument types are arrays of strings — passing one
composite string would defeat the safety property.
- `Clock` is just `() => number`; tests can pass the `FakeClock` from
`@suaveplan/testing` to advance the deadline deterministically.
- `LoggerLike` is the structural contract from `@suaveplan/types`; loggers
produced by `@suaveplan/logger` and any conforming custom logger work
interchangeably.
- The interfaces are intentionally `readonly` to discourage in-place mutation
by callers who might otherwise alter records returned from `findItem`.

View File

@@ -0,0 +1,107 @@
/**
* Public type contracts for `@suaveplan/macos-keychain`.
*
* Every type is runtime-agnostic and carries no DOM or Node.js dependencies.
* Optional `clock` and `logger` fields on `KeychainConfig` accept the canonical
* `Clock` and `LoggerLike` shapes from `@suaveplan/types` so the bridge can be
* driven deterministically in tests.
*/
import type { Clock, LoggerLike } from "@suaveplan/types";
/**
* Result of a `CommandExecutor` invocation. The default executor implementation
* uses `Bun.spawn` (Bun runtime) or `node:child_process.execFile` (Node runtime)
* — never any shell wrapper.
*/
export interface CommandExecutorResult {
/** Captured standard-output from the child process. */
readonly stdout: string;
/** Captured standard-error from the child process. */
readonly stderr: string;
/** Numeric exit code; `0` indicates success. */
readonly exitCode: number;
}
/**
* Options accepted by a `CommandExecutor` invocation. The optional `signal`
* is propagated by the bridge so abort and timeout handling cancel the
* underlying child process.
*/
export interface CommandExecutorOptions {
/** Caller-provided AbortSignal. Aborts the child process when fired. */
readonly signal?: AbortSignal | undefined;
}
/**
* Pluggable command runner. Tests pass a deterministic stub literal that
* returns preset results — no `vi.mock` required.
*
* The default implementation MUST pass arguments as an array (no shell
* interpolation) and SHOULD honour `options.signal` for cancellation.
*/
export type CommandExecutor = (
command: string,
args: readonly string[],
options?: CommandExecutorOptions
) => Promise<CommandExecutorResult>;
/**
* Construction options for `MacOSKeychainBridge`. Every field is optional;
* defaults match a typical macOS user account (no access group, 10 second
* CLI timeout, real wall clock, no logger).
*/
export interface KeychainConfig {
/** Default service label; can be overridden per-call. */
readonly serviceName?: string;
/** Optional access-group string passed via `-T` to the security CLI. */
readonly accessGroup?: string;
/** Per-CLI-call timeout in milliseconds. Default `10_000`. */
readonly timeoutMs?: number;
/** Injected wall clock for deadline computation. Default `() => Date.now()`. */
readonly clock?: Clock;
/** Optional structured logger. Bridge logs at `debug` level when present. */
readonly logger?: LoggerLike;
}
/**
* A keychain credential record. `password` is plaintext; the caller is
* responsible for narrow scoping and disposal once the value is no longer
* needed.
*/
export interface KeychainItem {
/** Service identifier, equivalent to the `-s` flag. */
readonly service: string;
/** Account / username, equivalent to the `-a` flag. */
readonly account: string;
/** Decrypted password as a JS string. */
readonly password: string;
/** Optional human-readable label. */
readonly label?: string;
/** Optional comment field. */
readonly comment?: string;
/** Creation timestamp parsed from the `security` CLI output. */
readonly createdAt?: Date;
/** Last-modified timestamp parsed from the `security` CLI output. */
readonly modifiedAt?: Date;
}
/**
* Result of a state-changing CRUD operation. `success` is `true` when the
* underlying CLI call returned exit code `0`. The optional `error` string
* carries a human-readable hint when callers prefer not to throw.
*/
export interface KeychainResult {
readonly success: boolean;
readonly error?: string;
}
/**
* Common option bag accepted by every bridge method that performs a CLI call.
* Used to thread per-call cancellation through the OTel-instrumented bridge
* methods without overloading every signature.
*/
export interface KeychainCallOptions {
/** Per-call AbortSignal. Combined with the configured `timeoutMs` deadline. */
readonly signal?: AbortSignal | undefined;
}

View File

@@ -0,0 +1,77 @@
// @integration — Darwin only. Skipped in CI; excluded from coverage.
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { MacOSKeychainBridge } from "../src/index.js";
const isDarwin = process.platform === "darwin";
// Test files exempt from §13 banned-pattern .skip rule because the platform
// guard is the documented way to skip Darwin-only integration in CI.
const itDarwin = isDarwin ? it : it.skip;
describe("MacOSKeychainBridge integration (real /usr/bin/security)", () => {
const SERVICE = "@suaveplan/macos-keychain.integration-test";
const ACCOUNT = "vitest-runner";
const bridge = new MacOSKeychainBridge();
afterEach(async () => {
if (!isDarwin) return;
try {
await bridge.deleteItem(SERVICE, ACCOUNT);
} catch {
// tolerate already-clean state
}
});
itDarwin("adds and finds an item", { timeout: 30_000 }, async () => {
await bridge.addItem({
service: SERVICE,
account: ACCOUNT,
password: "real-secret",
});
const item = await bridge.findItem(SERVICE, ACCOUNT);
expect(item.password).toBe("real-secret");
});
itDarwin(
"itemExists returns true after add and false after delete",
{
timeout: 30_000,
},
async () => {
await bridge.addItem({
service: SERVICE,
account: ACCOUNT,
password: "x",
});
expect(await bridge.itemExists(SERVICE, ACCOUNT)).toBe(true);
await bridge.deleteItem(SERVICE, ACCOUNT);
expect(await bridge.itemExists(SERVICE, ACCOUNT)).toBe(false);
}
);
itDarwin("updates the stored password", { timeout: 30_000 }, async () => {
await bridge.addItem({
service: SERVICE,
account: ACCOUNT,
password: "v1",
});
await bridge.updateItem(SERVICE, ACCOUNT, "v2");
const item = await bridge.findItem(SERVICE, ACCOUNT);
expect(item.password).toBe("v2");
});
itDarwin(
"listItems surfaces the added item",
{
timeout: 30_000,
},
async () => {
await bridge.addItem({
service: SERVICE,
account: ACCOUNT,
password: "list-me",
});
const entries = await bridge.listItems(SERVICE);
expect(entries.some((e) => e.account === ACCOUNT)).toBe(true);
}
);
});

View File

@@ -0,0 +1,98 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { type CommandExecutor, MacOSKeychainBridge } from "../src/index.js";
// The default @opentelemetry/api ships a NoopMeterProvider; we exercise the
// meter API and assert no throw. The bridge instruments every CRUD call with
// keychain.ops_total + keychain.cli_duration_ms (plus keychain.items_returned
// on listItems). Asserting on counter values requires registering an SDK
// MeterProvider, which is out-of-scope for the package's runtime — the
// instrumentation contract here is "we always call the meter API in the right
// shape". A future @suaveplan/opentelemetry-test-harness fixture will assert
// recorded values without changing this test.
describe("OTel instrumentation", () => {
it("addItem records on the ops counter without throwing", {
timeout: 5000,
}, async () => {
const stub: CommandExecutor = async () => ({
stdout: "",
stderr: "",
exitCode: 0,
});
const bridge = new MacOSKeychainBridge({}, stub);
await expect(
bridge.addItem({ service: "svc", account: "user", password: "p" })
).resolves.toEqual({ success: true });
});
it("findItem records on the ops counter without throwing", {
timeout: 5000,
}, async () => {
const stub: CommandExecutor = async () => ({
stdout: "",
stderr: 'password: "p"',
exitCode: 0,
});
const bridge = new MacOSKeychainBridge({}, stub);
await expect(bridge.findItem("svc", "user")).resolves.toMatchObject({
password: "p",
});
});
it("updateItem records the delete + add invocations", {
timeout: 5000,
}, async () => {
let n = 0;
const stub: CommandExecutor = async () => {
n += 1;
return { stdout: "", stderr: "", exitCode: 0 };
};
const bridge = new MacOSKeychainBridge({}, stub);
await bridge.updateItem("svc", "user", "newp");
expect(n).toBe(2);
});
it("deleteItem records on the ops counter without throwing", {
timeout: 5000,
}, async () => {
const stub: CommandExecutor = async () => ({
stdout: "",
stderr: "",
exitCode: 0,
});
const bridge = new MacOSKeychainBridge({}, stub);
await expect(bridge.deleteItem("svc", "user")).resolves.toEqual({
success: true,
});
});
it("listItems records the items_returned histogram", {
timeout: 5000,
}, async () => {
const dump = `keychain: "/x"
"svce"<blob>="svc"
"acct"<blob>="alice"`;
const stub: CommandExecutor = async () => ({
stdout: dump,
stderr: "",
exitCode: 0,
});
const bridge = new MacOSKeychainBridge({}, stub);
const entries = await bridge.listItems();
expect(entries).toHaveLength(1);
});
it("records on the ops counter even when the call fails", {
timeout: 5000,
}, async () => {
const stub: CommandExecutor = async () => ({
stdout: "",
stderr: "could not be found",
exitCode: 44,
});
const bridge = new MacOSKeychainBridge({}, stub);
await expect(bridge.findItem("svc", "user")).rejects.toMatchObject({
code: "KEYCHAIN.NOT_FOUND",
});
});
});

View File

@@ -0,0 +1,224 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import type { CommandExecutor, CommandExecutorResult } from "../src/index.js";
import {
classifyError,
EXIT_AUTH_FAILED,
EXIT_DUPLICATE_ITEM,
EXIT_INTERACTION_NOT_ALLOWED,
EXIT_INVALID_KEYCHAIN,
EXIT_ITEM_NOT_FOUND,
keychainAccessFailed,
keychainItemExists,
keychainItemInvalid,
keychainLocked,
keychainNotFound,
keychainPermissionDenied,
keychainTimeout,
keychainUnavailable,
MacOSKeychainBridge,
} from "../src/index.js";
describe("classifyError property coverage", () => {
it("every documented exit code yields a non-null KeychainError", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(
fc.constantFrom(
EXIT_ITEM_NOT_FOUND,
EXIT_DUPLICATE_ITEM,
EXIT_AUTH_FAILED,
EXIT_INTERACTION_NOT_ALLOWED,
EXIT_INVALID_KEYCHAIN
),
fc.string(),
fc.string({ minLength: 1 }),
(code, svc, acct) => {
const err = classifyError(
code,
"",
svc,
acct.length === 0 ? "u" : acct
);
expect(err).not.toBeNull();
expect(err?.code.startsWith("KEYCHAIN.")).toBe(true);
}
),
{ numRuns: 200 }
);
});
it("very large unknown exit codes return null", { timeout: 30000 }, () => {
fc.assert(
fc.property(
fc
.integer({ min: 50_000, max: 99_999 })
.filter(
(n) =>
n !== EXIT_INTERACTION_NOT_ALLOWED &&
n !== EXIT_INVALID_KEYCHAIN
),
fc
.string({ minLength: 1 })
.filter(
(s) =>
!/found|exists|locked|denied|invalid|unavailable|interaction|passphrase|unlocked|directory|command not found/i.test(
s
)
),
(code, stderr) => {
const err = classifyError(code, stderr, "svc", "user");
expect(err).toBeNull();
}
),
{ numRuns: 100 }
);
});
});
describe("error-factory idempotency", () => {
it("every factory produces the documented code (idempotency)", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(
fc.string({ minLength: 1 }),
fc.string({ minLength: 1 }),
(svc, acct) => {
expect(keychainNotFound(svc, acct).code).toBe(
"KEYCHAIN.NOT_FOUND"
);
expect(keychainItemExists(svc, acct).code).toBe(
"KEYCHAIN.ITEM_EXISTS"
);
expect(keychainAccessFailed(svc, acct).code).toBe(
"KEYCHAIN.ACCESS_FAILED"
);
expect(keychainLocked(svc, acct).code).toBe(
"KEYCHAIN.LOCKED"
);
expect(keychainUnavailable().code).toBe(
"KEYCHAIN.UNAVAILABLE"
);
expect(keychainPermissionDenied(svc, acct).code).toBe(
"KEYCHAIN.PERMISSION_DENIED"
);
expect(keychainItemInvalid(svc, acct, "r").code).toBe(
"KEYCHAIN.ITEM_INVALID"
);
expect(keychainTimeout(svc, acct, 1).code).toBe(
"KEYCHAIN.TIMEOUT"
);
}
),
{ numRuns: 100 }
);
});
it("calling a factory twice produces equal code and message", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(
fc.string({ minLength: 1 }),
fc.string({ minLength: 1 }),
(svc, acct) => {
const a = keychainNotFound(svc, acct);
const b = keychainNotFound(svc, acct);
expect(a.code).toBe(b.code);
expect(a.message).toBe(b.message);
}
),
{ numRuns: 50 }
);
});
});
describe("round-trip via stub executor", () => {
it("addItem then findItem returns the original password", {
timeout: 30000,
}, async () => {
await fc.assert(
fc.asyncProperty(
fc
.string({ minLength: 1, maxLength: 32 })
.filter((s) => /^[A-Za-z0-9_-]+$/.test(s)),
fc
.string({ minLength: 1, maxLength: 32 })
.filter((s) => /^[A-Za-z0-9_-]+$/.test(s)),
fc
.string({ minLength: 1, maxLength: 64 })
.filter((s) => !s.includes('"') && !s.includes("\\")),
async (svc, acct, password) => {
let stored: string | undefined;
const runner: CommandExecutor = async (_cmd, args) => {
if (args[0] === "add-generic-password") {
stored = String(args[args.indexOf("-w") + 1]);
return {
stdout: "",
stderr: "",
exitCode: 0,
} satisfies CommandExecutorResult;
}
if (args[0] === "find-generic-password") {
return {
stdout: "",
stderr: `password: "${stored ?? ""}"`,
exitCode: 0,
} satisfies CommandExecutorResult;
}
return {
stdout: "",
stderr: "",
exitCode: 0,
} satisfies CommandExecutorResult;
};
const bridge = new MacOSKeychainBridge({}, runner);
await bridge.addItem({
service: svc,
account: acct,
password,
});
const item = await bridge.findItem(svc, acct);
expect(item.password).toBe(password);
}
),
{ numRuns: 25 }
);
});
});
describe("timeout detection", () => {
it("timeout via AbortSignal yields KEYCHAIN.TIMEOUT", {
timeout: 30000,
}, async () => {
await fc.assert(
fc.asyncProperty(
fc.integer({ min: 1, max: 50 }),
async (timeoutMs) => {
const slowRunner: CommandExecutor = (
_cmd,
_args,
options
) =>
new Promise<CommandExecutorResult>((_, reject) => {
options?.signal?.addEventListener("abort", () =>
reject(new Error("aborted"))
);
});
const bridge = new MacOSKeychainBridge(
{ timeoutMs },
slowRunner
);
await expect(
bridge.findItem("svc", "user")
).rejects.toMatchObject({
code: "KEYCHAIN.TIMEOUT",
});
}
),
{ numRuns: 10 }
);
});
});

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,38 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/macos-keychain",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/types",
"@suaveplan/crypto-utils",
"tslib",
"zod",
"@opentelemetry/api",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,8 @@
import { createTestConfig } from "@suaveplan/testing/config";
export default createTestConfig({
additionalCoverageExcludes: [
"tests/integration.test.ts",
"src/__benchmarks__/**",
],
});