diff --git a/changelog.d/0024-secrets-keychain.md b/changelog.d/0024-secrets-keychain.md new file mode 100644 index 0000000000..ccb2aa0cf0 --- /dev/null +++ b/changelog.d/0024-secrets-keychain.md @@ -0,0 +1,7 @@ +[Features] +- `scripts/secrets.sh` can read the openssl passphrase from the macOS + Keychain instead of prompting for it. Store it once as service + `stratos-e2e-secrets`, account `passphrase`, then pass + `--keychain stratos-e2e-secrets` or set `STRATOS_SECRETS_KEYCHAIN`. + Files stay compatible with ones encrypted at the prompt, and a + missing item is an error rather than a fallback to the prompt. diff --git a/docs/secrets-management.md b/docs/secrets-management.md index e98ef468db..4ba1ce8d12 100644 --- a/docs/secrets-management.md +++ b/docs/secrets-management.md @@ -118,6 +118,39 @@ scripts/secrets.sh decrypt scripts/secrets.sh run-e2e ``` +#### Passphrase from the macOS Keychain + +On macOS the openssl backend can read the passphrase from the Keychain +instead of prompting for it. Store it once as a generic password with +service `stratos-e2e-secrets` and account `passphrase`; `security` +prompts for the value, so it stays out of your shell history: + +```bash +security add-generic-password -s stratos-e2e-secrets -a passphrase -w +``` + +Then name the service with `--keychain`, or set it once with +`STRATOS_SECRETS_KEYCHAIN`: + +```bash +scripts/secrets.sh run-e2e --keychain stratos-e2e-secrets + +export STRATOS_SECRETS_KEYCHAIN=stratos-e2e-secrets +scripts/secrets.sh decrypt --env local +``` + +The passphrase goes to openssl on a file descriptor, never in a +command's arguments or the environment. `encrypt`, `decrypt`, `run-e2e` +and `check` all use it. Every `--env` file uses the same item; to give +an environment its own passphrase, store it under another service and +pass that one (`--env staging --keychain stratos-e2e-secrets-staging`). + +Files are the same whichever way the passphrase is entered, so a file +encrypted at the prompt decrypts from the Keychain and the reverse. +With neither the flag nor the variable set, openssl prompts as before. +If the item is missing, or there is no `security` command (Linux), the +script stops with an error rather than falling back to the prompt. + ### 3. SOPS + age (recommended for teams) Uses public-key encryption. No shared passphrase needed — each team @@ -199,6 +232,7 @@ E2E_ENV Environment name for file selection (none) E2E_PROFILE Profile name within secrets file (none) E2E_BASE_URL Override Stratos URL https://localhost:5440 STRATOS_SECRETS_BACKEND Default encryption backend openssl +STRATOS_SECRETS_KEYCHAIN Keychain service for the passphrase (none: openssl prompts) SOPS_AGE_KEY_FILE Path to age private key ~/.config/sops/age/keys.txt ``` @@ -410,6 +444,8 @@ Problem Cause Fix Secrets file not found No secrets.yaml, no env var Run secrets.sh decrypt or copy template Profile 'X' not found E2E_PROFILE not in file Check profiles: section in secrets file bad decrypt (openssl) Wrong passphrase Re-enter passphrase +no Keychain item No item under that service security add-generic-password -s SVC -a passphrase -w +'security' not found Keychain source used off macOS Unset STRATOS_SECRETS_KEYCHAIN / drop --keychain no identity matched (sops) Age key not in .sops.yaml Add your public key to .sops.yaml age: no identity Missing age key file age-keygen -o ~/.config/sops/age/keys.txt Tests fail with auth Wrong credentials Verify creds match target environment diff --git a/scripts/secrets.sh b/scripts/secrets.sh index 00596dea20..631ed2eeff 100755 --- a/scripts/secrets.sh +++ b/scripts/secrets.sh @@ -6,10 +6,11 @@ # Supports per-environment files and both OpenSSL and SOPS+age backends. # # Usage: -# secrets.sh encrypt [--env NAME] [--backend sops|openssl] -# secrets.sh decrypt [--env NAME] [--backend sops|openssl] -# secrets.sh run-e2e [--env NAME] [--backend sops|openssl] [-- ARGS...] -# secrets.sh check +# secrets.sh encrypt [--env NAME] [--backend sops|openssl] [--keychain SERVICE] +# secrets.sh decrypt [--env NAME] [--backend sops|openssl] [--keychain SERVICE] +# secrets.sh run-e2e [--env NAME] [--backend sops|openssl] [--keychain SERVICE] +# [-- ARGS...] +# secrets.sh check [--backend sops|openssl] [--keychain SERVICE] # # Environment files: # Default: secrets.yaml / secrets.yaml.enc @@ -19,9 +20,22 @@ # openssl — AES-256-CBC with PBKDF2 (default, no extra tools needed) # sops — SOPS + age (requires sops and age installed) # +# Keychain passphrase (openssl backend, macOS): +# By default openssl prompts for the passphrase. With --keychain SERVICE +# (or STRATOS_SECRETS_KEYCHAIN=SERVICE) it is read instead from the +# generic password item with that service and account "passphrase", +# and handed to openssl on a file descriptor, never in argv or the +# environment. The convention is service "stratos-e2e-secrets"; every +# --env file uses that item unless a per-env service is named. +# Add the item once (security prompts for the value): +# security add-generic-password -s stratos-e2e-secrets -a passphrase -w +# A missing item, or no `security` command (Linux), is an error; it +# never falls back to the prompt. Files are the same either way. +# # Environment variables: # E2E_ENV — Default --env value # STRATOS_SECRETS_BACKEND — Default --backend value (openssl|sops) +# STRATOS_SECRETS_KEYCHAIN — Default --keychain value (Keychain service) # SOPS_AGE_KEY_FILE — Path to age key file (for sops backend) # # Portability: @@ -37,6 +51,8 @@ ROOT_DIR="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" # ── Defaults ────────────────────────────────────────────────── ENV_NAME="${E2E_ENV:-}" BACKEND="${STRATOS_SECRETS_BACKEND:-openssl}" +KEYCHAIN_SERVICE="${STRATOS_SECRETS_KEYCHAIN:-}" +KEYCHAIN_ACCOUNT="passphrase" COMMAND="" E2E_ARGS=() @@ -55,6 +71,9 @@ Options: --env NAME Environment name (e.g., local, staging, prod) Uses secrets..yaml / secrets..yaml.enc --backend TYPE Encryption backend: openssl (default) or sops + --keychain SVC openssl backend: read the passphrase from the macOS + Keychain item with service SVC, account "passphrase", + instead of prompting (e.g. stratos-e2e-secrets) --help Show this help Examples: @@ -62,6 +81,7 @@ Examples: secrets.sh decrypt --env local # Decrypt local env secrets secrets.sh run-e2e --env staging -- --headed # Run tests against staging secrets.sh check --backend sops # Check sops prerequisites + secrets.sh run-e2e --keychain stratos-e2e-secrets # No passphrase prompt USAGE exit "${1:-0}" } @@ -76,6 +96,8 @@ parse_args() { ENV_NAME="${2:?--env requires a value}"; shift 2 ;; --backend) BACKEND="${2:?--backend requires a value}"; shift 2 ;; + --keychain) + KEYCHAIN_SERVICE="${2:?--keychain requires a value}"; shift 2 ;; --help|-h) usage 0 ;; --) @@ -108,20 +130,50 @@ encrypted_path() { fi } +# ── Keychain passphrase ─────────────────────────────────────── +# Fails unless the Keychain item exists. The lookup leaves out -w, so it +# only confirms the item; the passphrase is read later, straight into +# openssl's file descriptor. +keychain_require() { + if ! command -v security &>/dev/null; then + echo "Error: a Keychain service is set, but 'security' command not found (macOS only)" >&2 + exit 1 + fi + if ! security find-generic-password -s "${KEYCHAIN_SERVICE}" -a "${KEYCHAIN_ACCOUNT}" &>/dev/null; then + echo "Error: no Keychain item for service '${KEYCHAIN_SERVICE}', account '${KEYCHAIN_ACCOUNT}'" >&2 + echo " Add it with: security add-generic-password -s ${KEYCHAIN_SERVICE} -a ${KEYCHAIN_ACCOUNT} -w" >&2 + exit 1 + fi +} + # ── OpenSSL backend ─────────────────────────────────────────── +# Runs openssl enc with the passphrase from the Keychain on fd 3 when a +# service is set; otherwise openssl prompts for it as before. openssl +# takes the first line of fd 3 without its newline, the same passphrase +# the prompt would have read, so files stay interchangeable. +openssl_enc() { + if [[ -z "${KEYCHAIN_SERVICE}" ]]; then + openssl enc "$@" + return + fi + keychain_require + openssl enc "$@" -pass fd:3 \ + 3< <(security find-generic-password -s "${KEYCHAIN_SERVICE}" -a "${KEYCHAIN_ACCOUNT}" -w) +} + openssl_encrypt() { local plain="$1" encrypted="$2" - openssl enc -aes-256-cbc -salt -pbkdf2 -in "${plain}" -out "${encrypted}" + openssl_enc -aes-256-cbc -salt -pbkdf2 -in "${plain}" -out "${encrypted}" } openssl_decrypt() { local encrypted="$1" plain="$2" - openssl enc -aes-256-cbc -d -pbkdf2 -in "${encrypted}" -out "${plain}" + openssl_enc -aes-256-cbc -d -pbkdf2 -in "${encrypted}" -out "${plain}" } openssl_decrypt_stdout() { local encrypted="$1" - openssl enc -aes-256-cbc -d -pbkdf2 -in "${encrypted}" + openssl_enc -aes-256-cbc -d -pbkdf2 -in "${encrypted}" } openssl_check() { @@ -130,6 +182,10 @@ openssl_check() { return 1 fi echo "OK: openssl $(openssl version 2>&1 | head -1)" + if [[ -n "${KEYCHAIN_SERVICE}" ]]; then + keychain_require + echo "OK: Keychain item '${KEYCHAIN_SERVICE}' (account '${KEYCHAIN_ACCOUNT}') found" + fi } # ── SOPS+age backend ───────────────────────────────────────── diff --git a/scripts/tests/secrets-keychain.test.mjs b/scripts/tests/secrets-keychain.test.mjs new file mode 100644 index 0000000000..7161cd79b8 --- /dev/null +++ b/scripts/tests/secrets-keychain.test.mjs @@ -0,0 +1,150 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { spawnSync, execFileSync } from 'node:child_process' +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + +// The openssl backend of secrets.sh reading its passphrase from the macOS +// Keychain. A fake `security` first on PATH stands in for the Keychain, so +// the real one is never called. + +const script = fileURLToPath(new URL('../secrets.sh', import.meta.url)) +const service = 'stratos-e2e-secrets-test' +const passphrase = 'test-only-passphrase' +const plaintext = 'consoleUsers:\n admin:\n username: test-admin\n' +const openssl = execFileSync('/bin/sh', ['-c', 'command -v openssl'], { encoding: 'utf8' }).trim() + +// Prints the passphrase, newline-terminated like the real command, for the +// one known service and account; any other item is "not found". +const fakeSecurity = `#!/bin/sh +service= account= reveal= +while [ $# -gt 0 ]; do + case "$1" in + -s) service="$2"; shift 2 ;; + -a) account="$2"; shift 2 ;; + -w) reveal=1; shift ;; + *) shift ;; + esac +done +if [ "$service" != "${service}" ] || [ "$account" != "passphrase" ]; then + echo "security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain." >&2 + exit 44 +fi +if [ -n "$reveal" ]; then echo "${passphrase}"; else echo "keychain: \\"test\\""; fi +` + +function sandbox() { + const dir = mkdtempSync(join(tmpdir(), 'secrets-keychain-')) + const bin = join(dir, 'bin') + mkdirSync(bin) + writeFileSync(join(bin, 'security'), fakeSecurity) + chmodSync(join(bin, 'security'), 0o755) + return { dir, bin } +} + +function run(dir, args, env) { + return spawnSync('/bin/bash', [script, ...args], { + cwd: dir, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 20000, + env: { HOME: dir, ...env }, + }) +} + +const withFake = (bin, extra = {}) => ({ PATH: `${bin}:${process.env.PATH}`, ...extra }) + +test('encrypt then decrypt round-trips through the Keychain passphrase', () => { + const { dir, bin } = sandbox() + try { + writeFileSync(join(dir, 'secrets.yaml'), plaintext) + const env = withFake(bin, { STRATOS_SECRETS_KEYCHAIN: service }) + const enc = run(dir, ['encrypt'], env) + assert.equal(enc.status, 0, enc.stderr) + rmSync(join(dir, 'secrets.yaml')) + const dec = run(dir, ['decrypt'], env) + assert.equal(dec.status, 0, dec.stderr) + assert.equal(readFileSync(join(dir, 'secrets.yaml'), 'utf8'), plaintext) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +test('--keychain names the service on the command line', () => { + const { dir, bin } = sandbox() + try { + writeFileSync(join(dir, 'secrets.yaml'), plaintext) + const enc = run(dir, ['encrypt', '--keychain', service], withFake(bin)) + assert.equal(enc.status, 0, enc.stderr) + const dec = spawnSync(openssl, ['enc', '-aes-256-cbc', '-d', '-pbkdf2', '-pass', `pass:${passphrase}`, + '-in', join(dir, 'secrets.yaml.enc')], { encoding: 'utf8' }) + assert.equal(dec.status, 0, dec.stderr) + assert.equal(dec.stdout, plaintext) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +// A file made the old way, with the passphrase typed at openssl's prompt, +// is the same as one made with -pass pass:. +test('a file encrypted by plain openssl decrypts through the Keychain', () => { + const { dir, bin } = sandbox() + try { + writeFileSync(join(dir, 'plain.yaml'), plaintext) + execFileSync(openssl, ['enc', '-aes-256-cbc', '-salt', '-pbkdf2', '-pass', `pass:${passphrase}`, + '-in', join(dir, 'plain.yaml'), '-out', join(dir, 'secrets.local.yaml.enc')]) + const dec = run(dir, ['decrypt', '--env', 'local'], withFake(bin, { STRATOS_SECRETS_KEYCHAIN: service })) + assert.equal(dec.status, 0, dec.stderr) + assert.equal(readFileSync(join(dir, 'secrets.local.yaml'), 'utf8'), plaintext) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +// run-e2e decrypts to memory and execs bunx; a fake bunx prints what it got. +test('run-e2e hands the Keychain-decrypted secrets to the test run', () => { + const { dir, bin } = sandbox() + try { + writeFileSync(join(bin, 'bunx'), '#!/bin/sh\nprintf "%s" "$STRATOS_SECRETS"\n') + chmodSync(join(bin, 'bunx'), 0o755) + writeFileSync(join(dir, 'plain.yaml'), plaintext) + execFileSync(openssl, ['enc', '-aes-256-cbc', '-salt', '-pbkdf2', '-pass', `pass:${passphrase}`, + '-in', join(dir, 'plain.yaml'), '-out', join(dir, 'secrets.yaml.enc')]) + const e2e = run(dir, ['run-e2e', '--keychain', service], withFake(bin)) + assert.equal(e2e.status, 0, e2e.stderr) + assert.ok(e2e.stdout.endsWith(plaintext.trimEnd()), e2e.stdout) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +test('a missing Keychain item fails loudly and writes nothing', () => { + const { dir, bin } = sandbox() + try { + writeFileSync(join(dir, 'secrets.yaml'), plaintext) + const enc = run(dir, ['encrypt'], withFake(bin, { STRATOS_SECRETS_KEYCHAIN: 'no-such-service' })) + assert.notEqual(enc.status, 0) + assert.match(enc.stderr, /no Keychain item for service 'no-such-service'/) + assert.equal(existsSync(join(dir, 'secrets.yaml.enc')), false) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +test('without a security command the Keychain source fails loudly', () => { + const { dir } = sandbox() + const bin = join(dir, 'openssl-only') + try { + mkdirSync(bin) + symlinkSync(openssl, join(bin, 'openssl')) + writeFileSync(join(dir, 'secrets.yaml'), plaintext) + const enc = run(dir, ['encrypt'], { PATH: bin, STRATOS_SECRETS_KEYCHAIN: service }) + assert.notEqual(enc.status, 0) + assert.match(enc.stderr, /'security' command not found/) + assert.equal(existsSync(join(dir, 'secrets.yaml.enc')), false) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +})