Skip to content
Merged
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
7 changes: 7 additions & 0 deletions changelog.d/0024-secrets-keychain.md
Original file line number Diff line number Diff line change
@@ -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.
36 changes: 36 additions & 0 deletions docs/secrets-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
```

Expand Down Expand Up @@ -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
Expand Down
70 changes: 63 additions & 7 deletions scripts/secrets.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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=()

Expand All @@ -55,13 +71,17 @@ Options:
--env NAME Environment name (e.g., local, staging, prod)
Uses secrets.<env>.yaml / secrets.<env>.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:
secrets.sh encrypt # Encrypt default secrets
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}"
}
Expand All @@ -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 ;;
--)
Expand Down Expand Up @@ -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() {
Expand All @@ -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 ─────────────────────────────────────────
Expand Down
150 changes: 150 additions & 0 deletions scripts/tests/secrets-keychain.test.mjs
Original file line number Diff line number Diff line change
@@ -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 })
}
})
Loading