SOPS¶
Mozilla SOPS encrypts individual values inside structured files (YAML, JSON, ENV, INI) and supports age as one of its backends. cryptkey can produce the age identity SOPS uses — derived deterministically from your profile — so unlocking SOPS-encrypted secrets requires authenticating with your enrolled providers.
How it works¶
cryptkey's --age output is a fully valid age X25519 identity, derived from the profile's master key via HKDF. You don't generate a separate age keypair; cryptkey is the key generator:
- Private half (
AGE-SECRET-KEY-1…): re-derived on demand viacryptkey derive --age. Lives in process memory for the life of one SOPS invocation; never on disk. - Public half (
age1…): the recipient string you put in.sops.yaml. Derivable withcryptkey derive --age-recipientwithout a full unlock when you just need to know where to encrypt to.
Because rekey preserves the profile's master key and output_salt, the age identity stays stable across provider rotations — SOPS files encrypted today keep decrypting after you swap out providers.
Setup¶
1. Create a cryptkey profile (if you don't have one)¶
cryptkey init
# Enroll at least 2 providers with threshold 2 — see Getting Started.
2. Put the recipient in .sops.yaml¶
The recipient is a public value; commit it with the repo. Generate it once:
RECIPIENT=$(cryptkey derive --age-recipient)
cat > .sops.yaml <<EOF
creation_rules:
- age: "$RECIPIENT"
EOF
Or with a specific --use label so SOPS uses a separate domain-separated key from the rest of the profile:
RECIPIENT=$(cryptkey derive --age-recipient --use sops)
# … same .sops.yaml write as above.
That's the whole setup. No age-keygen, no encrypted keyfile on disk, no plugin to install.
Encrypting¶
Encryption only needs the public recipient, which SOPS reads from .sops.yaml — no cryptkey involved:
sops -e secrets.plaintext.yaml > secrets.yaml
Decrypting¶
SOPS accepts an age identity via the SOPS_AGE_KEY_FILE environment variable. Point it at /dev/stdin and stream the identity in through cryptkey's -- exec:
# Decrypt to stdout
cryptkey derive --age -- sh -c 'SOPS_AGE_KEY_FILE=/dev/stdin sops -d secrets.yaml'
# Extract a single value
cryptkey derive --age -- sh -c 'SOPS_AGE_KEY_FILE=/dev/stdin sops -d --extract "[\"database\"][\"password\"]" secrets.yaml'
The identity flows from cryptkey's --age output into the subshell's stdin, which SOPS_AGE_KEY_FILE=/dev/stdin tells SOPS to read as its keyfile. Nothing touches disk; cryptkey zeroes its []byte copy of the identity as soon as SOPS exits.
If you're on a label other than the default:
cryptkey derive --age --use sops -- sh -c 'SOPS_AGE_KEY_FILE=/dev/stdin sops -d secrets.yaml'
Editing¶
SOPS edits in place by opening $EDITOR on the decrypted content and re-encrypting on save. Same plumbing:
cryptkey derive --age -- sh -c 'SOPS_AGE_KEY_FILE=/dev/stdin sops secrets.yaml'
Wrapper function¶
Drop this in your shell rc if you SOPS often:
sops-cryptkey() {
cryptkey derive --age -- sh -c \
'SOPS_AGE_KEY_FILE=/dev/stdin exec sops "$@"' _ "$@"
}
# Usage:
sops-cryptkey -d secrets.yaml
sops-cryptkey secrets.yaml # edit
sops-cryptkey -d --extract '["database"]["password"]' secrets.yaml
Why not SOPS_AGE_KEY (env var)?¶
SOPS also accepts the identity directly via SOPS_AGE_KEY=<identity> rather than a keyfile path. You'd write:
SOPS_AGE_KEY=$(cryptkey derive --age) sops -d secrets.yaml
This works but is the weaker transport — the identity lives as a shell-variable string in the process environment (readable via /proc/<pid>/environ by any same-UID process for the whole lifetime of the sops child) and cryptkey can't zero its side of the copy because env vars are Go strings. Use the SOPS_AGE_KEY_FILE=/dev/stdin form above unless something's blocking the stdin path. Full rationale in derive: Secret delivery.
CI / unattended decryption¶
cryptkey is designed for interactive multi-factor unlock; every provider except TPM wants either a prompt, a hardware touch, or a browser interaction. That doesn't fit the "fully unattended build runner" pattern. Two honest options:
- Dedicated CI identity outside cryptkey. Generate a plain
age-keygenidentity, store it in your CI provider's secrets manager, inject asSOPS_AGE_KEYin the CI job, use it for that environment only. Your local developer workflow still uses cryptkey; CI uses its own identity. Add both recipients to.sops.yaml'sage:list so files encrypt for both. - TPM-only profile on a long-lived build host. If the CI runner is a dedicated box with a TPM, a cryptkey profile with just a
tpmprovider unlocks non-interactively (TPM does its own HMAC without user input). Works but binds decryption to that specific chip — not portable across runners.
Don't try to bolt echo "$CI_SECRET" | cryptkey derive … patterns onto the passphrase provider; the interactive prompt path doesn't read from piped stdin in a way that'll survive future changes, and it conflates cryptkey's threat model ("human authenticates with multiple factors") with a fundamentally different one ("build agent holds a single secret in its environment").
Notes¶
- Encryption (
sops -e) never needs cryptkey — only the public recipient in.sops.yaml. cryptkey derive --ageis deterministic for a given profile +--uselabel, so a freshly-cloned repo on a new machine with the same profile produces the same identity and decrypts existing SOPS files.- The same machine note applies as elsewhere: "same profile" means the profile TOML and the ability to unlock enough providers. TPM-bound providers don't migrate between machines; re-derive a replacement via
cryptkey rekeybefore moving. - Pair
--use sopswith other--uselabels (--use disk,--use backups) to keep SOPS's identity domain-separated from your other cryptkey-derived keys.