diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5859f13 --- /dev/null +++ b/CLAUDE.md @@ -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/` 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/` — plain text (backward compatible) +- `GET /keys/,` — 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 +``` diff --git a/README.md b/README.md index d3b2b41..3213783 100644 --- a/README.md +++ b/README.md @@ -46,13 +46,14 @@ Tip: Compile with -O for release optimisation. ## 🚀 Usage ```shell -keymaster set Store or update for -keymaster get [options] Print secret to stdout -keymaster delete Remove secret from Keychain +keymaster set Store or update for +keymaster get [,,...] [options] Print secret(s) to stdout (JSON) +keymaster delete Remove secret from Keychain Options: -h, --help Show detailed help and exit -d, --description Custom description for biometric prompt (get only) +--plain Output raw value instead of JSON (single key only) ``` ### Examples @@ -60,12 +61,22 @@ Options: #### 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 @@ -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/... ``` @@ -171,8 +191,11 @@ Command-line arguments override environment variables. ## API Endpoints -### GET /key/ -Retrieve a secret from the Keychain. Triggers biometric/password authentication. +### GET /key/\ +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 @@ -180,6 +203,31 @@ Retrieve a secret from the Keychain. Triggers biometric/password authentication. - `403 Forbidden` - Biometric/password authentication failed - `404 Not Found` - Key not found in Keychain +### GET /keys/\,\,... +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. @@ -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') ``` --- diff --git a/keymaster.swift b/keymaster.swift index 61b5dc3..c19f482 100644 --- a/keymaster.swift +++ b/keymaster.swift @@ -79,6 +79,47 @@ func authenticate( reply: reply) } +// MARK: - JSON helpers + +func jsonEscape(_ s: String) -> String { + var out = "" + for ch in s { + switch ch { + case "\"": out += "\\\"" + case "\\": out += "\\\\" + case "\n": out += "\\n" + case "\r": out += "\\r" + case "\t": out += "\\t" + default: + if ch.asciiValue != nil && ch.asciiValue! < 0x20 { + out += String(format: "\\u%04x", ch.asciiValue!) + } else { + out.append(ch) + } + } + } + return out +} + +func jsonObject(_ pairs: [(String, Any)]) -> String { + var entries: [String] = [] + for (key, value) in pairs { + let k = "\"\(jsonEscape(key))\"" + let v: String + if let s = value as? String { + v = "\"\(jsonEscape(s))\"" + } else if let b = value as? Bool { + v = b ? "true" : "false" + } else if value is NSNull { + v = "null" + } else { + v = "\"\(jsonEscape(String(describing: value)))\"" + } + entries.append("\(k): \(v)") + } + return "{\(entries.joined(separator: ", "))}" +} + // MARK: - CLI func printHelp() { @@ -89,16 +130,26 @@ func printHelp() { USAGE: keymaster set Store or update for - keymaster get [options] Print secret to stdout + keymaster get [,,...] [options] Print secret(s) to stdout keymaster delete Remove secret from Keychain OPTIONS: -h, --help Display this help message and exit -d, --description Custom description for biometric prompt (get only) + --plain Output secret values only (no JSON) + Only valid with a single key. + + OUTPUT FORMAT: + By default, `get` returns JSON: + Single key: {"key": "", "value": "", "error": null} + Multiple keys: [{"key": "", "value": "", "error": null}, ...] + With --plain, a single key's value is printed as raw text (legacy behaviour). EXAMPLES: keymaster set github_token "abc123" keymaster get github_token + keymaster get github_token,gitlab_token,slack_token + keymaster get github_token --plain keymaster get vpn_password --description "VPN wants to authenticate" keymaster delete github_token """) @@ -119,39 +170,62 @@ func main() { } let action = args.removeFirst() - let key = args.removeFirst() + let keyArg = args.removeFirst() - // Parse optional description for "get" command + // Parse optional flags for "get" command var customDescription: String? + var plainOutput = false var secret = "" if action == "get" { - // Check for --description or -d flag while !args.isEmpty { let arg = args.removeFirst() if arg == "--description" || arg == "-d" { if !args.isEmpty { customDescription = args.removeFirst() } + } else if arg == "--plain" { + plainOutput = true } } } else if action == "set" { secret = args.first ?? "" } + // For "get", split comma-separated keys + let keys: [String] + if action == "get" { + keys = keyArg.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty } + } else { + keys = [keyArg] + } + + if keys.isEmpty { + fputs("Error: no key names provided\n", stderr) + exit(EXIT_FAILURE) + } + + if plainOutput && keys.count > 1 { + fputs("Error: --plain can only be used with a single key\n", stderr) + exit(EXIT_FAILURE) + } + // Build authentication reason let authReason: String if let description = customDescription { authReason = description + } else if keys.count == 1 { + authReason = "\(action) the secret for \"\(keys[0])\"" } else { - authReason = "\(action) the secret for \"\(key)\"" + authReason = "\(action) \(keys.count) secrets from the Keychain" } switch action { case "set", "get", "delete": - // Show which key is being accessed + // Show which key(s) are being accessed if action == "get" { - fputs("Reading key \"\(key)\" from Keychain...\n", stderr) + let keyList = keys.map { "\"\($0)\"" }.joined(separator: ", ") + fputs("Reading key(s) \(keyList) from Keychain...\n", stderr) } authenticate(reason: authReason) { success, error in @@ -166,25 +240,44 @@ func main() { fputs("Error: missing for set action\n", stderr) exit(EXIT_FAILURE) } - guard setPassword(key: key, password: secret) else { + guard setPassword(key: keys[0], password: secret) else { fputs("Error writing to Keychain\n", stderr) exit(EXIT_FAILURE) } - print("✔ Key \"\(key)\" stored successfully") + print("✔ Key \"\(keys[0])\" stored successfully") case "get": - guard let pwd = getPassword(key: key) else { - fputs("No item found for \"\(key)\"\n", stderr) - exit(EXIT_FAILURE) + if plainOutput { + // Legacy single-key plain text output + guard let pwd = getPassword(key: keys[0]) else { + fputs("No item found for \"\(keys[0])\"\n", stderr) + exit(EXIT_FAILURE) + } + print(pwd) + } else { + // JSON output + var results: [String] = [] + for k in keys { + let pwd = getPassword(key: k) + if let pwd = pwd { + results.append(jsonObject([("key", k), ("value", pwd), ("error", NSNull())])) + } else { + results.append(jsonObject([("key", k), ("value", NSNull()), ("error", "not found")])) + } + } + if keys.count == 1 { + print(results[0]) + } else { + print("[\(results.joined(separator: ", "))]") + } } - print(pwd) case "delete": - guard deletePassword(key: key) else { - fputs("Error deleting item for \"\(key)\"\n", stderr) + guard deletePassword(key: keys[0]) else { + fputs("Error deleting item for \"\(keys[0])\"\n", stderr) exit(EXIT_FAILURE) } - print("✔ Key \"\(key)\" deleted successfully") + print("✔ Key \"\(keys[0])\" deleted successfully") default: break // Unreached } exit(EXIT_SUCCESS) diff --git a/keymasterd-inetd.swift b/keymasterd-inetd.swift index a1034a0..da8d540 100644 --- a/keymasterd-inetd.swift +++ b/keymasterd-inetd.swift @@ -68,9 +68,50 @@ func authenticate(reason: String) -> (success: Bool, error: Error?) { return (authSuccess, authError) } +// MARK: - JSON helpers + +func jsonEscape(_ s: String) -> String { + var out = "" + for ch in s { + switch ch { + case "\"": out += "\\\"" + case "\\": out += "\\\\" + case "\n": out += "\\n" + case "\r": out += "\\r" + case "\t": out += "\\t" + default: + if ch.asciiValue != nil && ch.asciiValue! < 0x20 { + out += String(format: "\\u%04x", ch.asciiValue!) + } else { + out.append(ch) + } + } + } + return out +} + +func jsonObject(_ pairs: [(String, Any)]) -> String { + var entries: [String] = [] + for (key, value) in pairs { + let k = "\"\(jsonEscape(key))\"" + let v: String + if let s = value as? String { + v = "\"\(jsonEscape(s))\"" + } else if let b = value as? Bool { + v = b ? "true" : "false" + } else if value is NSNull { + v = "null" + } else { + v = "\"\(jsonEscape(String(describing: value)))\"" + } + entries.append("\(k): \(v)") + } + return "{\(entries.joined(separator: ", "))}" +} + // MARK: - HTTP Processing -func httpResponse(status: Int, body: String, headers: [String: String] = [:]) -> String { +func httpResponse(status: Int, body: String, contentType: String = "text/plain", headers: [String: String] = [:]) -> String { let statusText: String switch status { case 200: statusText = "OK" @@ -83,7 +124,7 @@ func httpResponse(status: Int, body: String, headers: [String: String] = [:]) -> } var response = "HTTP/1.1 \(status) \(statusText)\r\n" - response += "Content-Type: text/plain\r\n" + response += "Content-Type: \(contentType)\r\n" response += "Content-Length: \(body.utf8.count)\r\n" response += "Connection: close\r\n" @@ -109,7 +150,21 @@ func processRequest(_ request: String) -> String { } let method = parts[0] - let path = parts[1] + let fullPath = parts[1] + + // Split path and query string + let pathComponents = fullPath.components(separatedBy: "?") + let path = pathComponents[0] + var queryParams: [String: String] = [:] + if pathComponents.count > 1 { + let queryString = pathComponents[1] + for param in queryString.components(separatedBy: "&") { + let kv = param.components(separatedBy: "=") + if kv.count == 2 { + queryParams[kv[0].removingPercentEncoding ?? kv[0]] = kv[1].removingPercentEncoding ?? kv[1] + } + } + } // Check HTTP Basic Auth if configured if config.requireAuth { @@ -143,7 +198,58 @@ func processRequest(_ request: String) -> String { return httpResponse(status: 200, body: "OK") } - // Key retrieval: /key/ + // Multi-key retrieval: /keys/,,... + if path.hasPrefix("/keys/") { + let keysParam = String(path.dropFirst("/keys/".count)) + guard !keysParam.isEmpty else { + return httpResponse(status: 400, body: "Key name(s) required", contentType: "application/json") + } + let keyNames = keysParam.components(separatedBy: ",") + .map { ($0.removingPercentEncoding ?? $0).trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + guard !keyNames.isEmpty else { + return httpResponse(status: 400, body: "Key name(s) required", contentType: "application/json") + } + + let reason: String + if let desc = queryParams["description"] { + reason = desc + } else { + let keyList = keyNames.map { "\"\($0)\"" }.joined(separator: ", ") + if keyNames.count == 1 { + reason = "\(config.authDescription): \(keyList)" + } else { + reason = "\(config.authDescription): \(keyNames.count) keys (\(keyList))" + } + } + + let (success, error) = authenticate(reason: reason) + guard success else { + let errorMsg = error?.localizedDescription ?? "Authentication failed" + return httpResponse(status: 403, body: jsonObject([("error", "Authentication failed: \(errorMsg)")]), contentType: "application/json") + } + + var results: [String] = [] + for k in keyNames { + let pwd = getPassword(key: k) + if let pwd = pwd { + results.append(jsonObject([("key", k), ("value", pwd), ("error", NSNull())])) + } else { + results.append(jsonObject([("key", k), ("value", NSNull()), ("error", "not found")])) + } + } + + let body: String + if keyNames.count == 1 { + body = results[0] + } else { + body = "[\(results.joined(separator: ", "))]" + } + + return httpResponse(status: 200, body: body, contentType: "application/json") + } + + // Single key retrieval: /key/ if path.hasPrefix("/key/") { let keyName = String(path.dropFirst("/key/".count)) guard !keyName.isEmpty else { @@ -154,7 +260,12 @@ func processRequest(_ request: String) -> String { let decodedKeyName = keyName.removingPercentEncoding ?? keyName // Authenticate - let reason = "\(config.authDescription): \"\(decodedKeyName)\"" + let reason: String + if let desc = queryParams["description"] { + reason = desc + } else { + reason = "\(config.authDescription): \"\(decodedKeyName)\"" + } let (success, error) = authenticate(reason: reason) guard success else { diff --git a/keymasterd.swift b/keymasterd.swift index 21352f5..7912ccb 100644 --- a/keymasterd.swift +++ b/keymasterd.swift @@ -62,6 +62,47 @@ func authenticate( reply: reply) } +// MARK: - JSON helpers + +func jsonEscape(_ s: String) -> String { + var out = "" + for ch in s { + switch ch { + case "\"": out += "\\\"" + case "\\": out += "\\\\" + case "\n": out += "\\n" + case "\r": out += "\\r" + case "\t": out += "\\t" + default: + if ch.asciiValue != nil && ch.asciiValue! < 0x20 { + out += String(format: "\\u%04x", ch.asciiValue!) + } else { + out.append(ch) + } + } + } + return out +} + +func jsonObject(_ pairs: [(String, Any)]) -> String { + var entries: [String] = [] + for (key, value) in pairs { + let k = "\"\(jsonEscape(key))\"" + let v: String + if let s = value as? String { + v = "\"\(jsonEscape(s))\"" + } else if let b = value as? Bool { + v = b ? "true" : "false" + } else if value is NSNull { + v = "null" + } else { + v = "\"\(jsonEscape(String(describing: value)))\"" + } + entries.append("\(k): \(v)") + } + return "{\(entries.joined(separator: ", "))}" +} + // MARK: - HTTP Server class HTTPServer { @@ -166,7 +207,21 @@ class HTTPServer { } let method = parts[0] - let path = parts[1] + let fullPath = parts[1] + + // Split path and query string + let pathComponents = fullPath.components(separatedBy: "?") + let path = pathComponents[0] + var queryParams: [String: String] = [:] + if pathComponents.count > 1 { + let queryString = pathComponents[1] + for param in queryString.components(separatedBy: "&") { + let kv = param.components(separatedBy: "=") + if kv.count == 2 { + queryParams[kv[0].removingPercentEncoding ?? kv[0]] = kv[1].removingPercentEncoding ?? kv[1] + } + } + } // Check HTTP Basic Auth if configured if config.requireAuth { @@ -200,7 +255,22 @@ class HTTPServer { return httpResponse(status: 200, body: "OK") } - // Key retrieval: /key/ + // Multi-key retrieval: /keys/,,... + if path.hasPrefix("/keys/") { + let keysParam = String(path.dropFirst("/keys/".count)) + guard !keysParam.isEmpty else { + return httpResponse(status: 400, body: "Key name(s) required", contentType: "application/json") + } + let keyNames = keysParam.components(separatedBy: ",") + .map { ($0.removingPercentEncoding ?? $0).trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + guard !keyNames.isEmpty else { + return httpResponse(status: 400, body: "Key name(s) required", contentType: "application/json") + } + return handleGetKeys(keyNames: keyNames, customDescription: queryParams["description"]) + } + + // Single key retrieval: /key/ if path.hasPrefix("/key/") { let keyName = String(path.dropFirst("/key/".count)) guard !keyName.isEmpty else { @@ -210,22 +280,30 @@ class HTTPServer { // URL decode the key name let decodedKeyName = keyName.removingPercentEncoding ?? keyName - return handleGetKey(keyName: decodedKeyName) + return handleGetKey(keyName: decodedKeyName, customDescription: queryParams["description"]) } return httpResponse(status: 404, body: "Not Found") } - private func handleGetKey(keyName: String) -> String { - log("Request for key: \(keyName)") - + private func authenticateOnce(keyNames: [String], customDescription: String? = nil) -> (Bool, Error?) { let semaphore = DispatchSemaphore(value: 0) var authSuccess = false var authError: Error? - // Run authentication on main thread for UI prompt + let reason: String + if let desc = customDescription { + reason = desc + } else { + let keyList = keyNames.map { "\"\($0)\"" }.joined(separator: ", ") + if keyNames.count == 1 { + reason = "\(config.authDescription): \(keyList)" + } else { + reason = "\(config.authDescription): \(keyNames.count) keys (\(keyList))" + } + } + DispatchQueue.main.async { - let reason = "\(config.authDescription): \"\(keyName)\"" authenticate(reason: reason) { success, error in authSuccess = success authError = error @@ -234,6 +312,13 @@ class HTTPServer { } semaphore.wait() + return (authSuccess, authError) + } + + private func handleGetKey(keyName: String, customDescription: String? = nil) -> String { + log("Request for key: \(keyName)") + + let (authSuccess, authError) = authenticateOnce(keyNames: [keyName], customDescription: customDescription) guard authSuccess else { let errorMsg = authError?.localizedDescription ?? "Authentication failed" @@ -250,6 +335,39 @@ class HTTPServer { return httpResponse(status: 200, body: password, contentType: "text/plain") } + private func handleGetKeys(keyNames: [String], customDescription: String? = nil) -> String { + log("Request for keys: \(keyNames.joined(separator: ", "))") + + let (authSuccess, authError) = authenticateOnce(keyNames: keyNames, customDescription: customDescription) + + guard authSuccess else { + let errorMsg = authError?.localizedDescription ?? "Authentication failed" + log("Authentication failed for keys: \(errorMsg)") + return httpResponse(status: 403, body: jsonObject([("error", "Authentication failed: \(errorMsg)")]), contentType: "application/json") + } + + var results: [String] = [] + for k in keyNames { + let pwd = getPassword(key: k) + if let pwd = pwd { + log("Successfully retrieved key: \(k)") + results.append(jsonObject([("key", k), ("value", pwd), ("error", NSNull())])) + } else { + log("Key not found: \(k)") + results.append(jsonObject([("key", k), ("value", NSNull()), ("error", "not found")])) + } + } + + let body: String + if keyNames.count == 1 { + body = results[0] + } else { + body = "[\(results.joined(separator: ", "))]" + } + + return httpResponse(status: 200, body: body, contentType: "application/json") + } + private func httpResponse(status: Int, body: String, contentType: String = "text/plain", headers: [String: String] = [:]) -> String { let statusText: String switch status { @@ -320,8 +438,13 @@ func printHelp() { KEYMASTERD_BIND Host/IP to bind to (alternative to -b) ENDPOINTS: - GET /key/ Retrieve a secret from the Keychain - GET /health Health check endpoint + GET /key/ Retrieve a single secret (plain text) + GET /keys/,,... Retrieve multiple secrets (JSON) + GET /health Health check endpoint + + QUERY PARAMETERS: + ?description= Custom description for the biometric prompt + (applies to /key/ and /keys/ endpoints) EXAMPLES: # Start with defaults (localhost:8787, no auth) @@ -337,11 +460,20 @@ func printHelp() { KEYMASTERD_PASSWORD=mypass keymasterd --bind 0.0.0.0 --port 8787 -u myuser CURL USAGE: - # Without auth + # Single key (plain text, legacy) curl http://localhost:8787/key/my_secret_key + # Single key (JSON) + curl http://localhost:8787/keys/my_secret_key + + # Multiple keys in one authenticated request (JSON) + curl http://localhost:8787/keys/github_token,gitlab_token,slack_token + # With HTTP Basic Auth - curl -u admin:secret123 http://localhost:8787/key/my_secret_key + curl -u admin:secret123 http://localhost:8787/keys/github_token,gitlab_token + + # Custom biometric prompt description + curl 'http://localhost:8787/keys/github_token?description=CI+pipeline+needs+GitHub+token' # Health check curl http://localhost:8787/health