Skip to main content

keenv

keenv puts secrets from a KeePass database into the environment of one command and nowhere else. The values never reach a file, an export, or the shell history: they exist between unlocking the database and execing the command, and the process that held them is replaced.

It is the tool the Oberon Systems keyring policy names for local secrets, and the way indech hands the Cloudflare R2 keys to OpenTofu.

Contents

Why

The usual ways of getting a secret into a process all leave it somewhere: export puts it in every child of the shell for the rest of the session, a .env full of plaintext puts it on disk, and eval $(something) puts it in the history file as well.

keenv reads the value out of the database at the moment it is needed, builds the environment for exactly one command, and replaces itself with that command. There is no keenv export and no keenv eval, on purpose.

Installation

pip install keenv

The database itself is read in process through pykeepass, so KeePassXC does not have to be installed.

Configuration

keenv reads two files, both optional. keenv.yaml names the database and maps environment variables onto entries:

vault: ~/Dropbox/oberon.kdbx
keyfile: ~/.keys/oberon.keyx

env:
  AWS_ACCESS_KEY_ID:
    entry: Oberon/R2/indech-state
    field: username
  AWS_SECRET_ACCESS_KEY:
    entry: Oberon/R2/indech-state
    field: password

.env is the same mapping in the shape people already write, where a value may be a keenv:// reference instead of a literal:

AWS_ACCESS_KEY_ID=keenv://Oberon/R2/indech-state/username
AWS_SECRET_ACCESS_KEY=keenv://Oberon/R2/indech-state/password
TF_LOG=INFO

A reference is keenv://<entry path>/<field>. The last segment is the field and everything before it is the path to the entry, so a field whose name contains a slash cannot be addressed. username, password, url, notes and title are matched case-insensitively; any other name is looked up as a custom attribute, with its spelling preserved.

The entry path starts at the top-level groups, one level below the root group KeePass shows at the top of its tree, so Oberon/R2/indech-state and not Root/Oberon/R2/indech-state. A path that does name the root group first is accepted too, under either the group's real name or a plain root, so a path copied straight out of KeePass works as it stands.

Values that are not references pass through literally. A # only starts a comment at the beginning of a line, never in the middle of one, because a secret may contain it.

The layers apply in this order, each one overriding the last:

Layer Set by
keenv.yaml -c, default ./keenv.yaml
.env -e, default ./.env
the database path vault:, then KEENV_VAULT, then --vault
the key file path keyfile:, then KEENV_KEYFILE, then --keyfile

A file that is not there is an empty layer, not an error. Having nothing to resolve after both layers is an error.

ttl: is read from keenv.yaml and nowhere else. It has no flag and no environment variable, so nothing outside the file you can see can start remembering your master password.

keenv.yaml is validated against a pydantic model that rejects keys it does not know, so vualt: is reported as a mistake rather than quietly ignored.

Usage

Run a command with the resolved environment:

keenv run -- tofu -chdir=circuits/live/ramnode/compute plan

Check that every reference still points at something, without printing any value:

keenv check

Expected output:

AWS_ACCESS_KEY_ID            keenv://Oberon/R2/indech-state/UserName               20 chars  keenv.yaml
AWS_SECRET_ACCESS_KEY        keenv://Oberon/R2/indech-state/Password               40 chars  keenv.yaml
TF_LOG                       literal                                                4 chars  .env

The last column is the file the variable came from, which is the quickest way to see that a .env is overriding keenv.yaml.

Point at another database and another mapping:

keenv run --vault ~/other.kdbx -e deploy.env -- ./deploy.sh

Exit codes are 0 on success, 1 for anything keenv can explain, 2 for a usage mistake, and 127 when the command does not exist. Otherwise the exit code is the command's own, because the command replaces keenv.

Remembering the password

By default keenv remembers nothing. Every run asks for the master password, and none of it outlives the process. Stay in that mode unless typing the password is genuinely in the way.

Setting ttl: turns on an agent that remembers it for a while, behind a PIN:

vault: ~/Dropbox/oberon.kdbx
ttl: 5m

The first run after that asks for the password and for a new PIN. Later runs ask only for the PIN:

$ keenv run -- tofu plan
Master password for /home/you/oberon.kdbx:
New PIN (4 to 8 digits):
Repeat the PIN:

$ keenv run -- tofu apply
PIN for /home/you/oberon.kdbx:

keenv lock forgets it at once, without waiting for the TTL. There is no keenv unlock on purpose: the first run that needs the password is the unlock, so there is no separate state to remember to set up.

What is kept, and where

The agent never holds the master password in the clear. The client seals it before the agent ever sees it:

keystream = argon2id(PIN, random salt, 128 bytes)
blob      = password padded to 128 bytes XOR keystream

The agent holds that salt and that blob, and nothing else. The PIN is not stored, not even as a hash: it is checked by unsealing the blob and offering the result to the database, so a wrong PIN yields rubbish and the database is what turns it down. The padding is what keeps the blob from betraying how long the password is.

The agent is forked before the password is read, so the plaintext is never in its address space, not even inherited across the fork. It runs with core dumps disabled, with PR_SET_DUMPABLE cleared so no other process of yours can attach to it or read its memory, and with its pages kept out of swap where the limits allow.

Its socket and lock file sit in $XDG_RUNTIME_DIR/keenv/, named after a hash of the database path. Each database therefore gets one agent, shared by every project pointing at it. That directory is a tmpfs owned by you, so nothing survives a reboot, and nothing cryptographic is written there in any case: the lock file holds a pid, a socket path and a version.

The limits, plainly

ttl takes 30s, 5m or a bare count of seconds, and must be more than zero and no more than fifteen minutes. Anything longer is a configuration error rather than a value quietly cut down to fit. The clock is idle-based: every successful run puts it back.

Five failures inside five minutes and the agent wipes itself and exits. That catches a typo and a stuck script. It is not a defence against a hostile program: the agent never sees the PIN, so a program bent on guessing simply would not report its failures. What actually prices an attack is the cost of Argon2 and the length of the PIN.

PIN Guesses Roughly
4 digits 10 thousand hours
6 digits 1 million weeks
8 digits 100 million years

Four digits are accepted, but keenv says what they cost and asks you to confirm.

Two things this does not protect against, said outright:

  • Anyone who can both dump the agent's memory and obtain a copy of the .kdbx can search for the PIN offline, at the price above. A database kept in a synced folder is well within reach of that.
  • The client necessarily holds the password in the clear for as long as it takes to open the database. execvpe then replaces the process, which is the same guarantee the default mode gives.

A key file is a different trade and needs none of this. It already opens the database without a prompt, so ttl does nothing alongside one, and keenv says so rather than starting an agent that would hold nothing.

How it works

  1. Both layers are read and merged into one list of variables. A name defined in both takes its value from .env, and keenv check names the file each variable came from.
  2. If any of them is a keenv:// reference, the database is opened once. The master password is asked for on /dev/tty, never on stdin, so a password prompt can never swallow the first line of a pipe. A key file replaces the prompt, and ttl: replaces it with a PIN for as long as the agent lives.
  3. Every reference is resolved from that one open database.
  4. The resolved variables are laid over a copy of the current environment and handed to os.execvpe.

Step 4 is what keeps the secrets contained: execvpe replaces the process image, so nothing that held the values is still running once the command starts, and the shell that invoked keenv never saw them.

Development

make init
make test
make lint

make init creates .venv, installs the package in editable mode and wires up the pre-commit hooks. The test suite builds its own throwaway database in a temporary directory; no test opens a real vault.

Commits go through commitizen:

.venv/bin/cz commit

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

keenv-0.2.0.tar.gz (15.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

keenv-0.2.0-py3-none-any.whl (18.4 kB view details)

Uploaded Python 3

File details

Details for the file keenv-0.2.0.tar.gz.

File metadata

  • Download URL: keenv-0.2.0.tar.gz
  • Upload date:
  • Size: 15.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.8.15

File hashes

Hashes for keenv-0.2.0.tar.gz
Algorithm Hash digest
SHA256 06ecd107768d401cd53e99bba03bb6361a7b0011cd86edd7e6ab8cceff28f2e2
MD5 1f9d123551a37ac24fb0e717f352d703
BLAKE2b-256 160514ad2d9aa4ea2fa93fab86925d06850e092d87ac15aec48de04dd7ca114c

See more details on using hashes here.

File details

Details for the file keenv-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: keenv-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 18.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.8.15

File hashes

Hashes for keenv-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 e1b4b2d7a57843657ccd6751446b6423f19ac3549e14e09e7a89ae8867b3c5b4
MD5 54b9460cdb72f88f86805be4c6657a9b
BLAKE2b-256 6a43c179ac14b2978da02b5a7acc65d49791715e2ca5fe92410c277e6f792824

See more details on using hashes here.

Release history Release notifications | RSS feed

0.3.1

2 files

0.2.1

2 files

This release

0.2.0 This release

2 files

0.1.2

2 files

0.1.1

2 files

0.1.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page