Simple CLI for an age-backed secrets repo
README.md

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.txt by default (SECRETS_IDENTITY overrides it).
  • Encryption targets every recipient in $SECRETS_DIR/.recipients when 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 .age is 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 #

  • age and age-keygen on PATH
  • an existing $SECRETS_DIR
  • an age identity at ~/.config/age/identity.txt (or $SECRETS_IDENTITY), e.g.
    mkdir -p ~/.config/age
    age-keygen -o ~/.config/age/identity.txt
    
    A plugin identity works too, such as one from age-plugin-yubikey --generate, with the plugin on PATH. 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

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
  • encrypt refuses if .recipients doesn't list your own identity, so you can't lock yourself out of a secret you just wrote.
  • rekey decrypts each *.age file straight into age --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
  • exec removes the identity when the command exits, fails, or is interrupted (^C, kill). Inside a session, exec reuses it; with a plain identity file (no prompts, e.g. CI) it just runs the command.
  • The backstop: each session starts a systemd --user timer that removes it after $SECRETS_SESSION_TIMEOUT seconds (default 3600) whatever happens, even kill -9. lock stops the timer. Without systemd you get a warning.
  • decrypt outside 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