- Shell 89%
- Nix 11%
secrets #
A tiny wrapper around age for managing a
directory of encrypted secrets. Secrets are stored as individual *.age files
under a single directory ($SECRETS_DIR, default ~/projects/data), and
paths are preserved so you can organize them in a hierarchy and expose one
secret under multiple tags using symlinks.
How it works #
- Decryption uses your age identity file,
~/.config/age/identity.txtby default (SECRETS_IDENTITYoverrides it). - Encryption targets every recipient in
$SECRETS_DIR/.recipientswhen that file exists, or only your own identity's public key when it doesn't. See Multiple recipients. - Secret paths are relative to
$SECRETS_DIR. A leading/is optional, a trailing.ageis optional and the suffix is always re-added on disk. - Parent directories are created automatically on encrypt.
..is rejected, so a secret path can never escape$SECRETS_DIR.
Requirements #
ageandage-keygenonPATH- an existing
$SECRETS_DIR - an age identity at
~/.config/age/identity.txt(or$SECRETS_IDENTITY), e.g.
A plugin identity works too, such as one frommkdir -p ~/.config/age age-keygen -o ~/.config/age/identity.txtage-plugin-yubikey --generate, with the plugin onPATH. Its recipient is read from the file's# Recipient:comment.
Install #
With Nix (from the flake):
nix build git+https://knot.rtw.run/rtw.run/secrets
nix run git+https://knot.rtw.run/rtw.run/secrets -- list
Or as a flake input in a dev environment:
inputs.secrets.url = "git+https://knot.rtw.run/rtw.run/secrets";
# ...
buildInputs = [ secrets.packages.${system}.default ];
Without Nix, just put the secrets script on your PATH (e.g. symlink it
into ~/bin). The script only needs age, age-keygen, and coreutils.
Usage #
secrets list # list secrets, relative paths, symlinks annotated
secrets encrypt <file|name> # encrypt a file, or stdin, to $SECRETS_DIR/<path>.age
secrets decrypt <secret> # decrypt $SECRETS_DIR/<secret>[.age] to stdout
secrets recipients # recipients new secrets are encrypted to
secrets rekey [--dry-run] # re-encrypt every secret to the current recipients
Encrypt #
From stdin, into a nested path (openai/api-key.age is created, along with
the openai/ directory):
echo "sk-abc123" | secrets encrypt openai/api-key
From an existing file. The file's path is preserved under $SECRETS_DIR:
secrets encrypt config/rclone.conf
# -> $SECRETS_DIR/config/rclone.conf.age
A leading slash is allowed and just means "root of $SECRETS_DIR":
echo "hunter2" | secrets encrypt /my/fancy/secret.age
# -> $SECRETS_DIR/my/fancy/secret.age
Encrypt refuses to overwrite an existing secret.
Decrypt #
Decryption writes to stdout, so redirect it or pipe it where you need:
secrets decrypt openai/api-key # -> sk-abc123
secrets decrypt openai/api-key.age # .age suffix is optional
secrets decrypt /my/fancy/secret.age # leading slash is optional too
secrets decrypt config/rclone.conf > ~/.config/rclone/rclone.conf
List #
$ secrets list
config/rclone.conf.age
my/fancy/secret.age
openai/api-key.age
work/openai -> ../openai/api-key.age
Tagging with symlinks #
Store a secret once and link to it from as many tag directories as you like.
Symlink names may omit the .age suffix; decrypt falls back to the exact
path when <name>.age doesn't exist.
ln -s ../openai/api-key.age "$SECRETS_DIR/work/openai"
ln -s ../openai/api-key.age "$SECRETS_DIR/personal/openai"
secrets decrypt work/openai # -> sk-abc123
secrets list # shows "work/openai -> ../openai/api-key.age"
Multiple recipients #
To let more than one identity decrypt the vault (say, your laptop and a CI
runner, each with its own key), commit a .recipients file at the root of
$SECRETS_DIR: one age (or SSH) recipient per line, # comments and blank
lines allowed.
cat > "$SECRETS_DIR/.recipients" <<EOF
age1...laptop # me
age1...runner # ci runner (infra.rtw.run)
EOF
secrets recipients # what new secrets are encrypted to; marks "(you)"
secrets rekey --dry-run # list what would be re-encrypted
secrets rekey # re-encrypt every secret to the current recipients
encryptrefuses if.recipientsdoesn't list your own identity, so you can't lock yourself out of a secret you just wrote.rekeydecrypts each*.agefile straight intoage --encrypt(plaintext never touches disk), writes a temp file next to it and moves it into place, keeping the file mode. Tag symlinks and.git/are skipped. It stops at the first secret your identity can't decrypt; files already rekeyed stay rekeyed, and running it again is safe.- Adding a recipient: add the line,
secrets rekey, commit and push. Removing one: delete the line,secrets rekey, and rotate whatever that identity could read (it may have kept copies of the old ciphertexts).
Sessions: one prompt for many decrypts #
A hardware identity (age-plugin-yubikey) asks for its PIN and a touch on
every decrypt, and a deploy can decrypt dozens of secrets. A session asks
once: it decrypts $SECRETS_SESSION_IDENTITY, a secret holding a plain age
identity that can read the vault (e.g. a CI runner's, already a recipient),
into a fresh file in $XDG_RUNTIME_DIR/secrets (tmpfs, mode 0600) and points
SECRETS_IDENTITY at it.
export SECRETS_SESSION_IDENTITY=servers/hosts/infra.rtw.run/ci-age-identity
secrets exec -- colmena apply # PIN + touch once; every decrypt under it is silent
eval "$(secrets unlock)" # by hand: until `secrets lock`
secrets lock # this session; `lock --all` for every one
execremoves the identity when the command exits, fails, or is interrupted (^C,kill). Inside a session,execreuses it; with a plain identity file (no prompts, e.g. CI) it just runs the command.- The backstop: each session starts a
systemd --usertimer that removes it after$SECRETS_SESSION_TIMEOUTseconds (default 3600) whatever happens, evenkill -9.lockstops the timer. Without systemd you get a warning. decryptoutside a session works as before: one prompt per secret.- While a session is open, anything running as you can read its identity, and so the vault. Keep sessions as short as the work.
Version #
secrets version # "secrets 1.3.0 (abc1234)"; a checkout says it's unbuilt
Environment #
| Variable | Default | Description |
|---|---|---|
SECRETS_DIR |
~/projects/data |
Directory that holds the *.age files |
SECRETS_IDENTITY |
~/.config/age/identity.txt |
age identity used to decrypt (and whose recipient must be listed in .recipients) |
SECRETS_SESSION_IDENTITY |
(none) | Secret a session unlocks: a plain age identity that can read the vault |
SECRETS_SESSION_TIMEOUT |
3600 |
Seconds before the timer removes a session; 0: no timer |
SECRETS_UNLOCK |
auto |
auto: sessions only for hardware (plugin) identities; always: for any identity |