Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Keymaster – Development Guide

## Project Overview
Keymaster is a macOS CLI and HTTP daemon for secure Keychain access, protected by Touch ID / login password. Written in Swift with no external dependencies.

## Building

```shell
# CLI tool
swiftc keymaster.swift -o keymaster

# HTTP daemon
swiftc keymasterd.swift -o keymasterd

# inetd-style daemon
swiftc keymasterd-inetd.swift -o keymasterd-inetd
```

Use `-O` for release optimisation.

## Architecture

| Component | File | Purpose |
|---|---|---|
| CLI | `keymaster.swift` | Interactive `set`/`get`/`delete` via terminal |
| Daemon | `keymasterd.swift` | Persistent HTTP server on localhost |
| inetd Daemon | `keymasterd-inetd.swift` | On-demand, spawned by launchd per request |

All three share the same Keychain access pattern (`SecItemAdd`/`SecItemCopyMatching`/`SecItemDelete`) and authentication flow (`LAContext.evaluatePolicy`).

## Key Design Decisions

- **Single authentication per request**: The `get` command and `/keys/` endpoint authenticate once, then fetch all requested keys. This avoids multiple Touch ID prompts.
- **JSON by default**: The `get` command returns JSON (`{"key": ..., "value": ..., "error": ...}`). Use `--plain` for legacy raw-text output (single key only).
- **Comma-separated keys**: Both CLI (`keymaster get a,b,c`) and HTTP (`/keys/a,b,c`) use comma separation.
- **Partial success**: When fetching multiple keys, missing keys return `"error": "not found"` with `"value": null` instead of failing the whole request.
- **Backward compatibility**: `/key/<name>` endpoint still returns plain text. `--plain` flag preserves old CLI behaviour.

## Output Format

### CLI (`keymaster get`)
- Default: JSON to stdout, status to stderr
- `--plain`: raw secret value to stdout (single key only)

### HTTP daemon
- `GET /key/<name>` — plain text (backward compatible)
- `GET /keys/<name1>,<name2>` — JSON (`application/json`)

## Testing

No automated tests. Manual testing:

```shell
# Store test keys
./keymaster set test_key1 "value1"
./keymaster set test_key2 "value2"

# Single key JSON
./keymaster get test_key1

# Multiple keys
./keymaster get test_key1,test_key2

# Plain mode
./keymaster get test_key1 --plain

# Daemon
KEYMASTERD_PASSWORD=test keymasterd -u admin &
curl -u admin:test http://localhost:8787/keys/test_key1,test_key2
curl -u admin:test http://localhost:8787/key/test_key1
```
99 changes: 87 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,26 +46,37 @@ Tip: Compile with -O for release optimisation.
## 🚀 Usage

```shell
keymaster set <key> <secret> Store or update <secret> for <key>
keymaster get <key> [options] Print secret to stdout
keymaster delete <key> Remove secret from Keychain
keymaster set <key> <secret> Store or update <secret> for <key>
keymaster get <key>[,<key2>,...] [options] Print secret(s) to stdout (JSON)
keymaster delete <key> Remove secret from Keychain

Options:
-h, --help Show detailed help and exit
-d, --description <text> Custom description for biometric prompt (get only)
--plain Output raw value instead of JSON (single key only)
```

### Examples

#### Save a GitHub token
```keymaster set github_token "ghp_abc123"```

#### Read it back
```GITHUB_TOKEN=$(keymaster get github_token)```
#### Read it back (JSON output, default)
```shell
keymaster get github_token
# {"key": "github_token", "value": "ghp_abc123", "error": null}
```

When running `get`, keymaster will show which key is being read:
#### Read multiple keys with a single authentication
```shell
keymaster get github_token,gitlab_token,slack_token
# [{"key": "github_token", "value": "ghp_abc123", "error": null}, {"key": "gitlab_token", "value": "glpat_xyz", "error": null}, {"key": "slack_token", "value": null, "error": "not found"}]
```
Reading key "github_token" from Keychain...
Only one Touch ID / password prompt is shown for the entire batch.

#### Plain text output (legacy behaviour)
```shell
GITHUB_TOKEN=$(keymaster get github_token --plain)
```

#### Use a custom biometric prompt
Expand All @@ -76,12 +87,21 @@ This will show "VPN wants to authenticate" in the Touch ID/password prompt inste
#### Remove when no longer needed
```keymaster delete github_token```

Inside a Bash script:
Inside a Bash script (using JSON with jq):
```shell
#!/usr/bin/env bash
set -euo pipefail

API_KEY=$(keymaster get my_service_api_key)
API_KEY=$(keymaster get my_service_api_key | jq -r '.value')
curl -H "Authorization: Bearer $API_KEY" https://api.example.com/v1/...
```

Or using plain mode:
```shell
#!/usr/bin/env bash
set -euo pipefail

API_KEY=$(keymaster get my_service_api_key --plain)
curl -H "Authorization: Bearer $API_KEY" https://api.example.com/v1/...
```

Expand Down Expand Up @@ -171,15 +191,43 @@ Command-line arguments override environment variables.

## API Endpoints

### GET /key/<keyname>
Retrieve a secret from the Keychain. Triggers biometric/password authentication.
### GET /key/\<keyname\>
Retrieve a single secret from the Keychain (plain text response). Triggers biometric/password authentication.

**Query parameters:**
- `description` (optional) — Custom text for the Touch ID / password prompt shown to the user.

**Response:**
- `200 OK` - Secret value in plain text
- `401 Unauthorized` - Missing or invalid HTTP Basic Auth
- `403 Forbidden` - Biometric/password authentication failed
- `404 Not Found` - Key not found in Keychain

### GET /keys/\<key1\>,\<key2\>,...
Retrieve one or more secrets in a single authenticated request. Returns JSON.

**Query parameters:**
- `description` (optional) — Custom text for the Touch ID / password prompt shown to the user.

**Response (single key):**
```json
{"key": "github_token", "value": "ghp_abc123", "error": null}
```

**Response (multiple keys):**
```json
[
{"key": "github_token", "value": "ghp_abc123", "error": null},
{"key": "gitlab_token", "value": null, "error": "not found"}
]
```

- `200 OK` - JSON with results for each requested key
- `401 Unauthorized` - Missing or invalid HTTP Basic Auth
- `403 Forbidden` - Biometric/password authentication failed

Keys that don't exist are returned with `"value": null` and an `"error"` message rather than failing the entire request.

### GET /health
Health check endpoint. Returns `OK` if server is running.

Expand All @@ -192,16 +240,43 @@ Health check endpoint. Returns `OK` if server is running.
KEYMASTERD_PASSWORD=secret123 keymasterd --port 9000 --username admin
```

### Retrieve a secret with curl
### Retrieve a single secret with curl (plain text)
```shell
curl -u admin:secret123 http://localhost:9000/key/github_token
```

### Retrieve a single secret as JSON
```shell
curl -u admin:secret123 http://localhost:9000/keys/github_token
# {"key": "github_token", "value": "ghp_abc123", "error": null}
```

### Retrieve multiple secrets with one authentication
```shell
curl -u admin:secret123 http://localhost:9000/keys/github_token,gitlab_token,slack_token
# [{"key": "github_token", "value": "...", "error": null}, {"key": "gitlab_token", "value": "...", "error": null}, ...]
```

### Custom biometric prompt description
```shell
# Tell the user why the secret is being requested
curl -u admin:secret123 'http://localhost:9000/keys/deploy_key?description=CI+pipeline+deploying+to+production'

# Works on /key/ too
curl -u admin:secret123 'http://localhost:9000/key/github_token?description=Release+script+needs+GitHub+token'
```

### Use in a script
```shell
#!/usr/bin/env bash
# Single key (plain text, legacy)
API_KEY=$(curl -s -u admin:secret123 http://localhost:8787/key/my_api_key)
curl -H "Authorization: Bearer $API_KEY" https://api.example.com/v1/...

# Multiple keys (JSON, parse with jq)
SECRETS=$(curl -s -u admin:secret123 http://localhost:8787/keys/github_token,deploy_key)
GH_TOKEN=$(echo "$SECRETS" | jq -r '.[0].value')
DEPLOY_KEY=$(echo "$SECRETS" | jq -r '.[1].value')
```

---
Expand Down
Loading