From 2ff329fc12f7230077cf2b911f908b7a54cdf6b0 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Thu, 22 Oct 2026 02:55:18 -0700 Subject: [PATCH] feat(secrets): read the openssl passphrase from Keychain The openssl backend of scripts/secrets.sh always let openssl prompt for the passphrase. With --keychain SERVICE, or STRATOS_SECRETS_KEYCHAIN=SERVICE, it now reads it from the macOS Keychain item with that service and account "passphrase", and hands it to openssl on fd 3, never in argv or the environment. encrypt, decrypt, run-e2e and check all use it; the sops backend is unchanged. The cipher and options are unchanged and openssl takes the first line of the fd as the prompt would, so files stay interchangeable with ones encrypted at the prompt. With neither set, the behaviour is as before. A missing item, or no security command, stops the script with an error instead of falling back to the prompt. docs/secrets-management.md and the script header document the convention: service stratos-e2e-secrets, account passphrase, shared by every --env file unless another service is named. scripts/tests/secrets-keychain.test.mjs drives the script with a fake security command on PATH. --- changelog.d/0024-secrets-keychain.md | 7 ++ docs/secrets-management.md | 36 ++++++ scripts/secrets.sh | 70 +++++++++-- scripts/tests/secrets-keychain.test.mjs | 150 ++++++++++++++++++++++++ 4 files changed, 256 insertions(+), 7 deletions(-) create mode 100644 changelog.d/0024-secrets-keychain.md create mode 100644 scripts/tests/secrets-keychain.test.mjs 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 }) + } +})