Introduction
postmaster is a standalone, format-neutral local secret materialization
engine. It decrypts encrypted secret inputs at process launch time and
injects them into a child process via environment variables, files, or
systemd credentials — then execvps the target binary directly. No Node,
no shell, no separate CLI process in the runtime path.
Audience Quickstart (Plain Language)
What this is: a tool that takes encrypted secrets (like database passwords and API tokens) and safely puts them into your service’s environment at startup — without writing plaintext to disk, without running a shell, and without keeping a long-lived parent process that holds secrets in memory.
Who this is for
| Reader | What to take from this docs |
|---|---|
| New user / junior engineer | The introduction and Quick start; then Injection modes |
| Platform / SRE | Key provisioning and the Threat model |
| Security engineer | The Threat model and Materialization ABI |
| Integration author | The Launcher contract and CLI reference |
| Product / project manager | This introduction and the postmaster overview |
Quick glossary
| Term | Plain meaning |
|---|---|
| Materialization | Decrypting secrets and putting them where your app can read them (env vars, files, or credentials) |
| execvp | A Unix system call that replaces the current process with a new binary — postmaster becomes your app |
| ECIES | Elliptic Curve Integrated Encryption Scheme — the crypto used to encrypt individual secret values |
| exec.json | A JSON config file that tells postmaster what to decrypt, how to inject, and what binary to launch |
| Key source | Where the decryption key comes from (a file, systemd credentials, macOS keychain, or environment) |
| Injection mode | How decrypted secrets reach the child: env (environment variables), files (one file per secret), or credentials (systemd credentials) |
| Ciphertext | Encrypted data — safe to store in git, the Nix store, or any world-readable location |
The Core Problem
Deploying secrets to production services is hard. The secrets need to be present at runtime, but they must not be stored in plaintext on disk, in the container image, or in the deployment manifest. Common approaches each have tradeoffs:
- Shell wrappers run a script that
evals secrets — vulnerable to command injection if a secret contains$(...)or backticks. - External CLIs (Node, Python) stay alive as a parent process — secrets persist in the parent’s heap for the service’s entire lifetime.
- Sidecar containers inject secrets over the network — adds a network dependency to the startup path.
- Manual env vars put plaintext secrets in the systemd unit file or
the container spec — visible in
ps,systemctl cat, and crash dumps.
Postmaster takes a different approach: decrypt locally, inject directly, replace the process image. The secret material exists in process memory for a brief window, never touches persistent disk, and the process that held the secrets is gone the moment your binary starts.
What postmaster Does
-
Decrypts encrypted
.envfiles (orpostmaster_bundleJSON containers) using theeciescrate — native Rust, no network calls, no external CLIs, no shell. -
Injects decrypted values into the child via one of three modes:
env— environment variables (12-factor apps)files— one file per secret on a RAM-backed filesystem (Docker secrets convention,KEY_FILEpointers)credentials— systemd credentials (strongest hygiene)
-
Launches the target binary via
execvp— postmaster is replaced by your binary. The child process ISMainPIDfrom its first instruction. No parent process holds secrets. -
Cleans up —
postmaster cleanupremoves plaintext secret files after the child exits, wired toExecStopPostin systemd.
How postmaster Works
flowchart LR
store["Encrypted .env file\n(nix store / git / artifact)"]
keys["Private key\n(systemd cred / file / keychain)"]
pm["postmaster exec"]
child["Your binary\n(MainPID)"]
store --> pm
keys --> pm
pm -->|decrypt + inject| child
pm -.->|execvp replaces\npostmaster| child
The ciphertext is designed to be public. Every value is ECIES/secp256k1-encrypted individually — the public key embedded in the file only permits encrypting new values, never decrypting existing ones. So the world-readable Nix store, git repo, or artifact registry stops being an adversary. The entire secret surface collapses to one 64-hex private key per environment.
The Secret Pipeline
Postmaster owns exactly one stage in a 5-stage pipeline. Understanding which stage each system owns is the key to understanding what postmaster is (and is not).
flowchart LR
subgraph s1["1. Custody"]
vault["Vault / SSM /\n1Password / TPM2"]
end
subgraph s2["2. Rendering"]
nix["Nix / Terraform /\nCDK / Ansible"]
end
subgraph s3["3. Delivery"]
cred["systemd cred /\nfile / keychain"]
end
subgraph s4["4. Materialization"]
pm["postmaster\ndecrypt + inject"]
end
subgraph s5["5. Launch"]
exec["execvp\nchild = MainPID"]
end
vault --> nix
nix --> cred
cred --> pm
pm --> exec
| Stage | Who owns it | What happens |
|---|---|---|
| 1. Custody | Vault, AWS SSM, 1Password, TPM2 | Private keys live in a source/custody system |
| 2. Rendering | Nix, Terraform, CDK, Ansible, Salt, Puppet | Encrypted artifacts + key material + service wiring get onto the host |
| 3. Delivery | systemd, files, keychain, env | Key material reaches postmaster at runtime via local sources |
| 4. Materialization | postmaster | Postmaster decrypts and injects — this is the only stage it owns |
| 5. Launch | execvp | Process image replaced; child binary is MainPID |
Postmaster does not participate in stages 1-3. It does not fetch from remote systems, does not deploy infrastructure, and does not supervise the child process. It decrypts, injects, and gets out of the way.
Capability Horizon
.envadapter (wire-compatible with dotenvx),postmaster_bundleJSON container, three injection modes, four key sources (systemd credentials, files, keychain, environment),--verify-keysfreshness check,setupcommand,cleanupcommand.- Non-goals: postmaster will never become a network client for Vault, SSM, or any remote secret store. It will not add a plugin system, an async runtime, or a CLI parsing library.
First input adapter
The first input adapter is wire-compatible with
dotenvx-encrypted .env files
(same ECIES/secp256k1 + HKDF-SHA256 + AES-256-GCM crypto path), so
existing .env files encrypted with the dotenvx CLI decrypt byte-exact
through postmaster’s native Rust implementation.
dotenvx is one adapter, not the ceiling — the architecture supports
file-based (.env), HashiCorp Vault, AWS SSM, and other secret providers
as source/custody backends. But those systems deliver key material to
local terminal sources; postmaster reads from those local sources and
never reaches across the network.
Quick start
This page shows how to get postmaster running with an encrypted .env
file in under 10 minutes.
Audience Quickstart
What this is: a hands-on walkthrough of the simplest path — encrypting secrets, provisioning keys, and launching a service with postmaster.
Prerequisites
- A Rust toolchain (
rustup, stable) - dotenvx CLI (or any tool producing the same ECIES format) for encrypting secrets
- postmaster built from source (or from the Nix flake)
Step 1: Author secrets
Create a .env file and encrypt it:
$ dotenvx set DATABASE_URL 'postgres://user:pass@localhost:5432/myapp' \
-f secrets/.env.production
$ dotenvx set API_TOKEN 'secret-token-here' \
-f secrets/.env.production
The .env.production file now contains encrypted values. It is safe to
commit to git — the ciphertext is public, the private key is the real
secret.
DOTENV_PUBLIC_KEY="0x04a1b2c3..."
DATABASE_URL="encrypted:BGAAAAAADWzz..."
API_TOKEN="encrypted:BGAAAAAADWyy..."
Step 2: Provision keys
Postmaster needs the private key to decrypt the values. The key lives in
a .env.keys file:
$ dotenvx keys -f secrets/.env.production
DOTENV_PRIVATE_KEY_PRODUCTION="a1b2c3d4e5f6..."
Place the keys file on the host (Linux):
$ sudo install -d -m 0700 /var/lib/postmaster
$ sudo install -m 0400 /dev/stdin /var/lib/postmaster/.env.keys <<'EOF'
DOTENV_PRIVATE_KEY_PRODUCTION="a1b2c3d4e5f6..."
EOF
On macOS, you can also use the keychain:
$ security add-generic-password -a DOTENV_PRIVATE_KEY_PRODUCTION \
-s postmaster -w "a1b2c3d4e5f6..."
Step 3: Create exec.json
Postmaster reads an exec.json config that specifies what to decrypt,
how to inject, and what binary to launch:
{
"mode": "env",
"env_files": ["/path/to/.env.production"],
"keys": [
{"file": "/var/lib/postmaster/.env.keys"}
]
}
See the Launcher contract for all config fields and modes.
Step 4: Validate config
$ postmaster exec --config exec.json --check
This validates the config shape without decrypting, reading key material, or launching anything. Use it in CI.
To verify key sources are accessible on the host without decrypting:
$ postmaster setup --config exec.json --recheck
Step 5: Launch
$ postmaster exec --config exec.json -- /usr/bin/env
DATABASE_URL=postgres://user:pass@localhost:5432/myapp
API_TOKEN=secret-token-here
Postmaster decrypted the values, injected them as environment variables,
and execvp’d /usr/bin/env — which printed them. In a real deployment,
the service manager (systemd or launchd) runs postmaster, which
replaces itself with your binary.
flowchart TB
cfg["exec.json"]
env["encrypted .env"]
keys[".env.keys"]
pm["postmaster exec"]
child["your binary"]
cfg --> pm
env --> pm
keys --> pm
pm -->|decrypt and inject| child
pm -.->|execvp| child
Next steps
- CLI reference — all commands, flags, and exit codes
- Launcher contract — the full
exec.jsonspecification - Threat model — what postmaster protects and what it cannot prevent
Injection modes
Postmaster supports three ways to deliver decrypted secrets to the child process. Each mode makes a different tradeoff between convenience and security.
Audience Quickstart
What this is: choosing how secrets reach your application — as environment variables, as files, or as systemd credentials.
| Mode | Best for | Secrets in environ? | Hygiene |
|---|---|---|---|
env (default) | 12-factor apps, simple services | Yes | Lowest — same-UID processes can read /proc/PID/environ |
files | Apps that read KEY_FILE pointers (Docker secrets convention) | No — only _FILE pointers | Medium — secrets absent from environ, ps, crash dumps |
credentials | systemd services wanting strongest isolation | No — reads $CREDENTIALS_DIRECTORY | Highest — unit-private unswappable ramfs |
Mode comparison
flowchart TB
subgraph env["mode: env"]
pm_env["postmaster"] -->|KEY=value\nin environ| child_env["your binary"]
end
subgraph files["mode: files"]
pm_files["postmaster"] -->|write 0600 file| tmpfs["tmpfs / ramdisk"]
tmpfs -->|KEY_FILE=/run/.../KEY| child_files["your binary"]
end
subgraph cred["mode: credentials"]
systemd["systemd"] -->|LoadCredential=| ramfs["ramfs\n$CREDENTIALS_DIR"]
pm_cred["postmaster"] -->|decrypt + write| ramfs
ramfs -->|KEY_FILE=$CREDENTIALS_DIR/KEY| child_cred["your binary"]
end
mode = "env" (default)
Decrypted values are exported into the process environment and the
wrapper execs your binary. Correct for 12-factor apps.
Exposure: environment variables are readable via
/proc/<pid>/environ by root and the same UID, inherited by every child,
and prone to appearing in crash dumps and debug output.
Mitigations: DynamicUser=true (a fresh UID per service — “same UID”
means “this service only”), ProtectProc hardening (hides other
processes’ /proc entries), and ultimately switching to files mode.
No shell expansion: postmaster performs no ${VAR}/$(…) expansion
anywhere in the decrypt-and-inject path, in any mode. A repo contributor
who commits KEY=$(cmd) gets the literal string, never a command
execution at service start.
mode = "files"
Values are written one-file-per-key onto a namespace-private tmpfs
(TemporaryFileSystem=/run/postmaster; on macOS, a per-service directory
on an HFS RAM disk) and only <KEY>_FILE pointer variables are exported
— the Docker-secrets convention
(DATABASE_URL_FILE=/run/postmaster/DATABASE_URL) that many daemons
support natively.
Secrets are absent from environ, ps e, crash-dump environment sections,
and ambient child inheritance. The mount is invisible outside the unit’s
mount namespace on Linux (root needs nsenter) and is destroyed with
the unit. On macOS, the RAM disk is protected by ownership (0700), not
invisibility — macOS has no mount namespaces.
Each secret file is written with O_NOFOLLOW | O_CREAT | O_TRUNC and
fchmod’d to 0600 on the file descriptor (no TOCTOU window between
open and chmod).
For a mostly-file-capable app with a straggler that needs an env var:
{
"mode": "files",
"env_files": ["secrets/.env.production"],
"secrets_dir": "/run/postmaster",
"passthrough": ["RUST_LOG"]
}
Keys in passthrough are also exported as real env vars (subject to NUL
rejection, since they become environment variables).
mode = "credentials"
Real systemd credentials, served on demand by the postmaster daemon.
The consumer’s unit gets
LoadCredential=<KEY>:/run/postmaster-credd/<svc>/<KEY>.sock per key and
the app reads $CREDENTIALS_DIRECTORY/<KEY> from unit-private,
unswappable ramfs.
No Node, no jq, no shell anywhere in the consumer’s start path. The
optional wrapper only exports non-secret <KEY>_FILE pointers.
Strongest hygiene of the three.
On macOS, postmaster runs --bind (owns its sockets), the wrapper
postmaster fetches each key onto the RAM disk, and exports
$CREDENTIALS_DIRECTORY set to secrets_dir — same path convention as
systemd credentials, so app code is unchanged.
Fail-closed rules
strict(defaulttrue): if any value fails to decrypt, the entire exec aborts before the child runs. Nothing partial is injected.- NUL in env values: rejected in
envmode and forpassthroughvalues infilesmode. Infilesmode’s primary write path, NUL is not restricted — the exact bytes reach the file. - Empty fetch response: (
credentialsmode, macOS socket path): treated as a refusal, fails closed. - Missing
$CREDENTIALS_DIRECTORY: fails closed in credentials mode (systemd path).
See the Launcher contract for the complete specification of each mode’s fields, validation rules, and per-mode injected variables.
Key provisioning & rotation
This page covers how to get private keys onto hosts and how to rotate them without downtime.
Audience Quickstart
What this is: the operational side of postmaster — how the decryption key gets to the host, and how to rotate it safely.
| Reader | What to take |
|---|---|
| Operator / SRE | All sections — this is your playbook |
| Security engineer | The rotation window and fail-closed behavior |
| New user | Skim strategies A and B, come back when you deploy |
Key source overview
Postmaster reads private keys from local terminal sources only. It never fetches from remote systems.
flowchart TB
subgraph sources["Key sources (local only)"]
cred["systemd credentials\n(LoadCredential=)"]
file["file\n(.env.keys on disk)"]
kc["keychain\n(macOS only)"]
env["environment\n(DOTENV_PRIVATE_KEY*)"]
end
pm["postmaster"]
sources --> pm
Strategy A: Manual keyfile (default)
One-time per host:
$ sudo install -d -m 0700 /var/lib/postmaster
$ sudo install -m 0400 /dev/stdin /var/lib/postmaster/.env.keys <<'EOF'
DOTENV_PRIVATE_KEY_PRODUCTION="<64-hex from your local .env.keys>"
EOF
systemd treats an absolute-path credential as mandatory: missing file means the unit fails to start. Fail-closed.
Strategy B: TPM2 / host-sealed blob
Fully declarative — the key is sealed to the host’s TPM2 and can only be decrypted on that specific machine:
# on the target host, once (--name MUST match postmaster-<service>)
$ sudo systemd-creds encrypt --with-key=host+tpm2 \
--name=postmaster-myapp - myapp-myhost.cred <<'EOF'
DOTENV_PRIVATE_KEY_PRODUCTION="<64-hex>"
EOF
# copy myapp-myhost.cred back into the repo
Reference the sealed blob in your config:
{
"keys": [
{"file": "/nix/store/...-myapp.cred"}
]
}
Blobs are per-host. Without a TPM, --with-key=host binds to
/var/lib/systemd/credential.secret.
Strategy C: Deployment-tool key push
Using colmena or similar:
deployment.keys."myapp.env.keys" = {
keyFile = ./deployer-only/myapp.env.keys;
destDir = "/var/lib/postmaster";
user = "root"; group = "root"; permissions = "0400";
};
Strategy D: macOS Keychain
$ security add-generic-password -a DOTENV_PRIVATE_KEY_PRODUCTION \
-s postmaster -w "<64-hex>"
No file on disk — keys live as generic-password items in the System
keychain. Postmaster reads them natively via security(1).
Rotation
No rotate subcommand exists. The flow is manual, but
DOTENV_PRIVATE_KEY* accepts comma-separated keys for a
zero-downtime window:
flowchart TD
step1["Step 1: Author new keypair locally\nDecrypt env, delete old pub key, re-encrypt with fresh keypair"]
step2["Step 2: Set DOTENV_PRIVATE_KEY=new,old\nBoth keys accepted; restarts decrypt either generation"]
step3["Step 3: Deploy new ciphertext\nnixos-rebuild switch — new units decrypt with new key\nOld units still running on old key"]
step4["Step 4: Drop old key (after rollback horizon)\nSet DOTENV_PRIVATE_KEY=new only\nOld generations + new-only key = broken restarts"]
step1 --> step2
step2 --> step3
step3 --> step4
- Locally: decrypt the env file, delete the old public key and
.env.keysentry, re-encrypt with a fresh keypair. - On hosts: set
DOTENV_PRIVATE_KEY_PRODUCTION="<new>,<old>"(re-seal the blob in strategy B). Running units are untouched; restarts decrypt either generation. - Deploy the new ciphertext (
nixos-rebuild switch). - Drop
<old>once past your rollback horizon — an old generation plus a new-only key means broken restarts.
Use --verify-keys to catch stale keys before they cause a silent
failure:
$ postmaster exec --config exec.json --verify-keys -- /bin/true
postmaster: the secret materialization engine
Postmaster is a standalone Rust binary that does exactly one thing:
decrypt encrypted secret inputs at process launch time and inject
them into a child process, then replace its own process image with
the target binary via execvp.
It is not a library. It is not a plugin host. It is not a daemon framework. It is a single binary with a fixed, local, offline contract.
Audience Quickstart (Plain Language)
What this is: the architecture of postmaster — what it does, what it owns, and where it fits in the secret delivery pipeline.
Who this is for
| Reader | What to take |
|---|---|
| New user / junior engineer | “What postmaster owns” and “The secret pipeline” — understand the scope |
| Platform / SRE | “Implementation” and “Dependency policy” — what you’re deploying |
| Security engineer | The entire page — this defines the security boundary |
| Integration author | “What postmaster does NOT own” and the Launcher contract |
| Product / project manager | “What postmaster owns” and the pipeline diagram — the 30-second summary |
Quick glossary
| Term | Plain meaning |
|---|---|
| Materialization | Decrypting secrets and putting them where your app reads them |
| execvp | Unix call that replaces the current process — postmaster becomes your app |
| ECIES | The encryption scheme used for individual secret values |
| Ciphertext | Encrypted data — safe to store in git or the Nix store |
| MainPID | The process systemd considers “the service” — with execvp, it’s your binary from instruction one |
What postmaster owns
Postmaster owns the runtime secret path — everything that happens between “encrypted artifact exists on disk” and “child process has plaintext in its environment or files”:
-
Decryption. Postmaster decrypts encrypted inputs using the
eciescrate (secp256k1 + HKDF-SHA256 + AES-256-GCM). No network calls. No external CLIs. No shell. -
Injection. Postmaster injects decrypted values into the child via one of three modes: environment variables (
env), files on a RAM-backed filesystem (files), or systemd credentials (credentials). -
Launch. Postmaster calls
execvpto replace its process image with the target binary. The child ISMainPIDfrom its first instruction. -
Cleanup. Postmaster provides a
cleanupsubcommand that removes plaintext secret files from the secrets directory after the child exits. Wired toExecStopPostin systemd; the ramdisk destruction handles this on macOS. -
Validation. Postmaster provides
exec --checkfor config shape validation (CI) andsetup --recheckfor runtime environment verification (host). Both run without decrypting secrets. -
Setup. Postmaster provides an interactive
setupcommand that creates keys directories, verifies permissions, optionally seeds keychain items (macOS), and validates the environment — idempotently.
What postmaster does NOT own
Postmaster does NOT own anything that happens before the encrypted artifact exists or after the child process starts:
-
Secret authoring. Encrypting secrets is done by workstation tooling (dotenvx CLI, or any tool producing the same ECIES format). Postmaster does not encrypt.
-
Secret custody. Where private keys live before they reach the host is a source/custody system concern. Vault, AWS SSM, 1Password — these systems hold the private key material and deliver it to a local terminal source. Postmaster reads it locally; it does not fetch it remotely.
-
Infrastructure rendering. How the encrypted artifacts, key material,
exec.json, and service wiring get onto the host is an automation renderer concern. Nix, Terraform, CDK, Ansible, SaltStack, Puppet — these tools render the deployment artifacts and wire the service manager. Postmaster does not deploy. -
Service supervision. Postmaster does not supervise the child process. The service manager (systemd on Linux, launchd on macOS) starts, monitors, and stops the service. Postmaster’s job is done the moment
execvpfires. -
Child behavior. What the child process does with its secrets — reading from environment variables, files, or credentials — is the application’s concern. Postmaster guarantees the secrets are delivered; it does not guarantee the application handles them correctly.
The secret pipeline
The full secret lifecycle is a pipeline of distinct stages, each owned by a different system. Postmaster owns exactly one stage.
flowchart LR
subgraph s1["1. Custody"]
vault["Vault / SSM /\n1Password / TPM2"]
end
subgraph s2["2. Rendering"]
nix["Nix / Terraform /\nCDK / Ansible"]
end
subgraph s3["3. Delivery"]
cred["systemd cred /\nfile / keychain"]
end
subgraph s4["4. Materialization"]
pm["postmaster\ndecrypt + inject"]
end
subgraph s5["5. Launch"]
exec["execvp\nchild = MainPID"]
end
vault --> nix
nix --> cred
cred --> pm
pm --> exec
Stage 1: Custody (source systems)
Where private key material lives before it reaches the host.
- HashiCorp Vault — stores private keys; a Vault Agent template
renders a
.env.keys-format file locally - AWS SSM Parameter Store — stores private keys; cloud-init or a oneshot unit materializes them locally
- 1Password — stores private keys; a bootstrap process delivers them to a local terminal source
- TPM2 / host-sealed blobs — private keys sealed to the host’s
TPM2 (or host key) via
systemd-creds encrypt --with-key=host+tpm2, producing a sealed credential file that can only be decrypted on that specific host - Manual — operator places
.env.keyson the host directly
Postmaster does not participate in this stage. These systems deliver key material to local terminal sources (files, credentials, keychain). Postmaster reads from those local sources.
Stage 2: Rendering (automation tools)
How encrypted artifacts, key material, exec.json, and service
wiring get onto the host.
- Nix — builds encrypted
.envfiles into the Nix store, generatesexec.json, wires systemd/launchd units - Terraform / OpenTofu — renders infrastructure, deploys artifacts, wires service units
- AWS CDK — renders CloudFormation, deploys artifacts, wires service units
- Ansible — renders config, deploys artifacts, wires service units
- SaltStack — renders config, deploys artifacts, wires service units
- Puppet — renders config, deploys artifacts, wires service units
Postmaster does not participate in this stage. These tools produce the local artifacts and wiring that postmaster consumes.
Stage 3: Delivery (local key sources)
How key material reaches postmaster at runtime.
- systemd credentials (
LoadCredential=) — PID 1 places key material in$CREDENTIALS_DIRECTORYbefore postmaster starts. This includes TPM2/host-sealed credentials viaLoadCredentialEncrypted=— systemd decrypts the sealed blob with the host’s TPM2 before placing it in the credentials directory - Files — a
.env.keys-format file on local disk (not in the Nix store) - macOS Keychain — key material stored as generic-password items,
read natively by postmaster via
security(1) - Environment —
DOTENV_PRIVATE_KEY*variables in the process environment (set by the service manager)
Postmaster reads from these local sources. It does not fetch from remote systems.
Stage 4: Materialization (postmaster)
Postmaster decrypts encrypted inputs and injects them into the child context. This is the only stage postmaster owns.
- Decrypts
.envfiles via theeciescrate (native Rust, offline) - Decrypts
postmaster_bundlefiles via the same crypto path - Injects via
env,files, orcredentialsmode - Zeroizes decrypted values and private keys before
execvp - Fails closed if any value cannot decrypt
Stage 5: Launch (execvp)
Postmaster calls execvp. The process image is replaced. The child
binary IS MainPID. Postmaster is gone.
The service manager (systemd/launchd) supervises the child from this
point. ExecStopPost runs postmaster cleanup to remove plaintext
files after the child exits (Linux). The ramdisk is destroyed when
the launchd job stops (macOS).
Implementation
postmaster/ contains a dependency-lean Rust binary (edition
2024; pure-Rust crypto, no openssl; no async runtime; syscalls via
rustix’s linux_raw backend rather than libc FFI on Linux, leaving
one unsafe block for the fd-ownership assertion at the systemd
socket-activation boundary; on macOS, libc is used for getpeereid
and PT_DENY_ATTACH hardening — each unsafe site carries a SAFETY
comment) that materializes decrypted secret values as systemd
LoadCredential= AF_UNIX sources or directly injects them via
execvp. systemd’s documented contract for socket credential sources
is minimal — the manager connects once at process invocation and reads
until EOF, with no request metadata in-band — so the request is encoded
in the path: one socket per (service, key). The module generates a
single postmaster-credd.socket unit carrying N ListenStream=
entries; postmaster maps each inherited fd back to its path via
getsockname(2).
On Linux, postmaster is socket-activated: the postmaster-credd.socket
unit owns the sockets and the daemon inherits them via sd_listen_fds.
On macOS it runs --bind (launchd has no socket-credential contract),
owning the same per-(service,key) socket paths itself; the syscall layer
is cfg-gated (SO_PEERCRED→getpeereid, prctl/mlockall→core-limit +
PT_DENY_ATTACH).
The binary has five entry points: the default daemon (--bind/
socket-activated serve), fetch (the darwin fetch client), exec
(decrypt + inject + execvp), setup (idempotent environment
verification and key provisioning), and cleanup (plaintext file
removal for ExecStopPost). Its config schema, key-source precedence,
and per-mode injection rules are the cross-binding contract at
Launcher contract.
Dependency policy
Postmaster has 8 dependencies:
ecies— crypto (secp256k1 + AES-256-GCM)dotenvy—.envfile parserserde+serde_json— JSON config deserializationbase64— ciphertext decodinghex— key parsingrustix— syscalls (no libc FFI on Linux)libc— macOS only (forgetpeereidandPT_DENY_ATTACH)zeroize— secret memory wiping
No CLI parsing library (clap, etc.). No async runtime. No OpenSSL. No network client. No plugin system. The dependency set is intentionally minimal: smaller binary, smaller attack surface, faster startup, fewer supply-chain risks.
New dependencies are rejected unless they are strictly necessary for the core decrypt-inject-execvp path and cannot be implemented in a few lines of stdlib + rustix.
Materialization ABI
postmaster is the local secret materialization engine. Its ABI is the
contract between public deployment artifacts, local private key material,
and the process that receives plaintext at runtime.
Audience Quickstart (Plain Language)
What this is: the contract that every integration (Nix, Terraform, CDK, Ansible, etc.) must follow when wiring postmaster. If you’re writing a binding or evaluating whether a generated config is correct, this is your page.
Who this is for
| Reader | What to take |
|---|---|
| Integration author | The entire page — this is your contract |
| Security engineer | “Non-goals” and “Resolution” — what postmaster will never do |
| New user | “Current ABI instance” and the exec.json example — the 60-second tour |
| Platform / SRE | “Key sources” and “Validation” — what the host needs |
Quick glossary
| Term | Plain meaning |
|---|---|
| ABI | Application Binary Interface — the contract between config files, key material, and the launcher |
| exec.json | A JSON file that tells postmaster what to decrypt, how to inject, and what to launch |
| Projection | Where decrypted secrets go — env vars, files, or systemd credentials |
| Adapter | An input format (.env, postmaster_bundle) — postmaster supports multiple, not just one |
The ABI is deliberately local:
- encrypted secret artifacts may be public and declarative
- private key material arrives through local terminal sources
postmasterdecrypts and projects plaintext on the host- plaintext is handed to the target process through one selected projection
- failure happens before
exec, not after a partially configured launch
This page describes the ABI and how exec.json fits inside it. It is a
contract for bindings and documentation recipes, not an invitation to put
provider clients into postmaster.
Non-goals
postmaster must not become:
- an AWS SSM client
- a Vault client
- a Terraform/OpenTofu/CDK/Ansible/Salt/Puppet plugin host
- a general remote secret-store SDK wrapper
- a process that fetches secrets over the network in the secret path
Vault, AWS SSM, 1Password, and other centralized stores are source or
custody systems. Automation tools (Nix, CDK, Ansible, SaltStack, Puppet,
Terraform, OpenTofu) are renderers and installers. Their job is to deliver
local artifacts, local key material, config, and service wiring around the
ABI. Runtime decrypt and injection remain inside postmaster.
Contract layers
The ABI has five layers.
flowchart TD
art["1. Artifact descriptors\nidentify encrypted local inputs"]
key["2. Key-source descriptors\nidentify local private key material"]
res["3. Resolution rules\nturn encrypted entries into plaintext"]
proj["4. Projection policy\ndecides where bytes appear"]
launch["5. Launch semantics\nexecvp replaces postmaster with target"]
art --> res
key --> res
res --> proj
proj --> launch
- Artifact descriptors identify encrypted local inputs.
- Key-source descriptors identify local private key material.
- Resolution rules turn encrypted entries into named plaintext byte values.
- Projection policy decides where those bytes may appear.
- Launch semantics replace the wrapper process with the target command only after validation and materialization succeed.
All ABI data intended for generated config must be safe to store in the Nix store or an equivalent world-readable deployment artifact. Secret bytes and private keys are never config fields.
Current ABI instance
exec.json is the first ABI instance. It is implicitly:
- ABI version:
0 - input adapter:
.env(wire-compatible with dotenvx-encrypted files) - artifact shape:
env_files - key material shape:
.env.keys/DOTENV_PRIVATE_KEY* - projection modes:
env,files, andcredentials
The current parser does not accept explicit version or adapter fields.
Bindings must not emit those fields until the Rust parser and schema define
them.
The current shape is:
{
"mode": "files",
"keys": [{ "credential": "postmaster-myapp" }, "environment"],
"env_files": ["/nix/store/.../.env.production"],
"strict": true,
"overload": false,
"secrets_dir": "/run/postmaster",
"passthrough": ["RUST_LOG"]
}
env_files are encrypted .env files, wire-compatible with dotenvx. They
must follow the .env[.<environment>] basename convention because the
filename selects the private key variable:
.envmaps toDOTENV_PRIVATE_KEY.env.productionmaps toDOTENV_PRIVATE_KEY_PRODUCTION- non-alphanumeric environment suffix bytes normalize to
_
The .env adapter is the first adapter, not the ceiling of the system.
Neutral bundle adapter
The postmaster_bundle adapter is a second input format, not a replacement
for .env. Its job is to let non-.env ecosystems produce a public
encrypted artifact without pretending their native model is an .env file.
The on-disk format is a JSON container. JSON keeps generated artifacts easy for Terraform, OpenTofu, CDK, Ansible, Salt, Puppet, and Nix to render and diff, while encrypted values remain opaque base64 strings.
{
"format": "postmaster.bundle",
"version": 1,
"profile": "production",
"suite": "ecies-secp256k1-hkdf-sha256-aes-256-gcm",
"recipients": [
{
"id": "host:api-01",
"public_key": "02..."
}
],
"entries": {
"DATABASE_URL": {
"encoding": "utf8",
"ciphertext": "base64..."
},
"TLS_KEY": {
"encoding": "bytes",
"ciphertext": "base64..."
}
}
}
All fields above are public metadata except ciphertext, which is encrypted
and authenticated. The metadata that affects interpretation must be covered
by authentication so a store or repo adversary cannot rename a key, change a
profile, change a recipient id, switch an encoding, or replay a value between
profiles without detection.
The design constraints are:
formatand integerversionare mandatory and fail closed on unknown values.suiteis mandatory so cryptographic suites can be explicit rather than inferred from payload length.profileis optional public selection metadata; it is not a secret.recipients[*].idis public routing metadata used to select local private key material.entrieskeys must use the same identifier rule as.env-derived keys:[A-Za-z_][A-Za-z0-9_]*.encodingis eitherutf8orbytes.envprojection rejects bytes that cannot become a POSIX environment value;filesandcredentialspreserve byte-exact values.- The canonical serialized bytes for authenticated metadata must be specified before implementation. Generated artifacts need deterministic bytes for reproducible builds and stable test vectors.
- A bundle can contain many entries for one projection, but it does not
encode service-manager policy. Projection remains in the
postmasterconfig, not inside the encrypted artifact.
The bundle preserves the same runtime invariants: local-only resolution,
no network egress, no plaintext config fields, deterministic failure before
launch, and the same output projection semantics as .env values.
The implementation ships with conformance vectors for:
- minimal valid bundle
- multiple recipients
- binary value projected to files
- invalid key name
- unknown version
- tampered authenticated metadata
- wrong local private key
- replayed ciphertext under a different entry name or profile
Key sources
Implemented launcher key sources are local and terminal:
{"credential": "<name>"}reads from$CREDENTIALS_DIRECTORY/<name>and is skippable when absent, so generated configs can fall through to another source. The name must be a single relative path component.{"file": "/path/to/.env.keys"}reads a local file and fails closed if the file is unavailable or under/nix/store.{"keychain": "<service>"}reads from macOS Keychain and is terminal."environment"readsDOTENV_PRIVATE_KEY*from the process environment and is terminal.
Provider names such as aws_ssm and vault are reserved contract language,
but they are not implemented key sources. A Vault Agent, cloud-init step,
systemd oneshot, systemd-creds workflow, or platform bootstrap process can
fetch or seal material before launch and hand it to one of the implemented
local sources.
Resolution
Resolution must:
- parse every configured encrypted artifact before launching the target
- build the wanted private-key set from artifact metadata
- load key material from the ordered local sources
- decrypt with the selected adapter
- reject malformed key names
- respect
strictfail-closed behavior - respect
overloadprecedence - keep plaintext out of stdout, generated config, and persistent public artifacts
For the .env adapter, this is implemented by resolve_all and the same
native ECIES path used by the credential daemon.
Diagnostics and redaction
Failures identify the field, artifact, key name, or projection that failed
without printing decrypted values, private keys, or secret-bearing command
output. Diagnostics may include public paths, key names, mode names, source
kinds, and parser errors. They must not include plaintext secret bytes,
.env.keys contents, ECIES private keys, or values fetched from postmaster
sockets.
Bindings follow the same rule. Generated validation logs and CI output can show which config failed and why, but not the material that would allow decryption or disclose the decrypted result.
Projection modes
Projection modes are shared ABI behavior.
env
All resolved values are exported as real environment variables, then the target command is executed directly. Values containing NUL bytes are rejected because POSIX environment variables cannot carry them safely.
files
Each resolved value is written as a 0600 file under secrets_dir.
postmaster exports <KEY>_FILE pointers for each secret.
The launcher refuses to follow symlinks when writing secret files. On macOS,
when verify_ramdisk is set, it verifies the configured mount before writing
plaintext.
passthrough may additionally export selected keys as real environment
variables. Passthrough values containing NUL bytes are rejected.
credentials
On systemd, PID 1 has already materialized credentials and the launcher only
exports <KEY>_FILE pointers into $CREDENTIALS_DIRECTORY.
On launchd, the launcher can fetch configured keys from local postmaster sockets, write them into the verified RAM disk directory, and export credential-style pointers.
Every credential_keys entry must be a valid identifier before any fetch or
write occurs.
Validation
The ABI needs two validation surfaces:
- JSON Schema for generated config and documentation fixtures
postmaster exec --checkfor parser-authoritative structural validation
The current exec.json schema lives at docs/schema/exec.schema.json.
Fixture examples live under docs/examples/ and are exercised by the Rust
test suite, so documentation examples cannot silently drift from the parser.
--check reads only the config file. It rejects malformed JSON, unknown
fields, unknown modes or key-source kinds, missing mode-required fields,
invalid env-file basenames, invalid identifier fields, forbidden
private-key paths under /nix/store, and incomplete credential socket maps.
It does not resolve keys, decrypt values, read local key files, inspect
secrets_dir, probe sockets, mount-check ramdisks, contact remote systems, or
depend on host-specific secret state. That makes it suitable for CI and for
automation tools that want to validate generated config before deployment.
Limitation: --check validates config shape only, not key source
accessibility. A config can pass --check in CI but fail at runtime because
the key file is missing, the keychain item is absent, or the credential
source is not provisioned. The launcher’s fail-closed behavior is the
runtime guarantee; --check cannot substitute for it.
postmaster setup --config <exec.json> --recheck provides runtime
environment verification — it probes the filesystem and keychain to
confirm that key material is actually accessible at the configured paths,
without decrypting any secrets. It complements --check: run --check
in CI for config correctness, run setup --recheck on the host for
environment readiness.
The full postmaster setup command goes beyond verification: it
creates the keys directory with 0700 permissions, verifies or creates
keys files with 0600, optionally seeds keychain items (macOS, interactive),
and validates platform prerequisites. It is idempotent — each step checks
whether the target state is already present and correct, and skips if it
is. Flags: --recheck (verification only), --unattended (non-interactive,
fail closed), --force (re-create/re-seed), --keys-dir <path> (override
default path).
Integration responsibilities
Bindings and recipes are correct when they produce local ABI inputs and prove them before launch.
Source or custody systems should document:
- where private key material lives
- how it is delivered to a local terminal source
- rotation behavior
- audit behavior
- failure behavior
Automation systems should document:
- how encrypted artifacts are installed
- how
exec.jsonor its successor is rendered - how service manager wiring invokes
postmaster - how
postmaster exec --checkis run - how plaintext persistence is avoided
No integration should reimplement decrypt/inject semantics. If an external tool needs to participate, it should chain through the ABI.
Conformance
Each supported adapter has conformance fixtures:
- valid minimal config
- valid full config per projection mode
- invalid unknown source kind
- invalid unknown mode
- invalid identifier
- missing required mode fields
- adapter-specific malformed artifact
- strict undecryptable value behavior
- precedence behavior for
overload
Provider or automation repositories claim compatibility by showing that
their generated artifacts pass the schema, --check, and the shared fixture
suite without adding provider logic to postmaster.
Threat model
Audience Quickstart (Plain Language)
What this is: what postmaster protects, what it cannot prevent, and why it’s built this way. This is the security reference.
Who this is for
| Reader | What to take |
|---|---|
| Security engineer | The entire page — every claim is about the security boundary |
| Platform / SRE | “Swap and core dumps” and “Hardening inheritance” — what to configure |
| Integration author | “Why a native launcher” — understand the design constraints |
| New user | “Secrets at rest” and “Fail-closed” — the 30-second security summary |
| Product / project manager | The residuals table — what postmaster does NOT protect against |
Quick glossary
| Term | Plain meaning |
|---|---|
| execvp | Replaces the current process — no parent holds secrets after launch |
| Fail-closed | If anything fails, the service does NOT start — no partial secrets |
| Zeroize | Overwrite secret memory with zeros before it’s freed |
| mlockall | Pin memory in RAM so the kernel can’t swap it to disk |
| TOCTOU | Time-of-check to time-of-use — a race between verifying and using a file |
| Residual | A risk that postmaster cannot fully eliminate |
| Non-goal | Something postmaster deliberately does not attempt to mitigate |
Security posture at a glance
| Category | Status |
|---|---|
| Secrets at rest (ciphertext in git/Nix store) | Safe — ciphertext is public |
| Plaintext on persistent disk | Prevented — only RAM-backed filesystems |
| Plaintext in swap | Mitigated — mlockall + MemorySwapMax=0 |
| Core dumps | Mitigated — PR_SET_DUMPABLE=0, RLIMIT_CORE=0 |
| Shell injection | Prevented — no shell anywhere in the path |
| Symlink attacks on key files | Prevented — O_NOFOLLOW + fstat |
| Same-UID env var exposure (env mode) | Residual — use files or credentials mode |
| Root on the host | Residual — root is always game over |
| VM/hypervisor inspection | Residual — outside the trust boundary |
| CPU cache/register residue | Non-goal — no mitigation available |
Why a native launcher, not a shell wrapper
Postmaster is a native Rust binary, not a shell wrapper around an external CLI. This decision is driven by three security properties that a shell-based approach cannot provide.
Process identity: execvp, not spawn
When postmaster launches your service, it uses execvp — the system
call that replaces the current process image with the target binary.
The process ID (PID) does not change. The service binary is MainPID
from its first instruction.
A shell wrapper or Node.js parent process would spawn a child
instead. The parent stays alive as MainPID, which breaks:
- systemd readiness notifications (
Type=notify,sd_notify): systemd expectsMainPIDto sendREADY=1. A parent process that spawns the real service means the wrong process is notifying. - Cgroup memory accounting: the parent’s memory (including private key material) is counted against the service unit’s cgroup.
- Secret lifetime: the parent process holds private keys and
decrypted values in its heap for the entire lifetime of the service.
With
execvp, postmaster’s process image is replaced — all heap memory is released and the kernel zeroes the pages before reuse.
No shell interpolation
A shell-based launcher would use eval or source-based injection to
set environment variables. That leaves $(…) and backticks live
inside double quotes. Combined with a public key embedded in the
repository (which lets any repo writer add or replace encrypted
values), this would be remote code execution at service start for
anyone who can commit to the repo.
Postmaster never shells out or evals. Decrypted values move directly into the child’s environment array or a file write. No shell sits between decryption and injection to reinterpret the values.
Offline decryption
Postmaster’s decryption uses the ecies library — a native Rust
implementation of the same crypto path as dotenvx. It makes no network
calls of any kind. There is no update check, no telemetry, no CLI
settings directory to manage.
This is an invariant of the secret path: postmaster must not become an AWS SSM client, a Vault client, or a general secret-store plugin host. Centralized secret stores (Vault, AWS SSM, 1Password, etc.) are source/custody systems that deliver key material to postmaster through local terminal sources such as systemd credentials, out-of-store files, macOS keychain items, or a deliberately managed process environment. Postmaster materializes secrets locally; it does not fetch them remotely.
What postmaster protects
Trust boundary overview
flowchart TB
subgraph trusted["Trusted zone"]
keys["Private key material\n(systemd cred / file / keychain)"]
pm["postmaster\n(decrypt + inject)"]
child["Child process\n(your binary)"]
end
subgraph untrusted["Public zone"]
ciphertext["Encrypted .env\n(git / Nix store / artifact)"]
end
subgraph outside["Outside trust boundary"]
root["Root on host"]
vm["VM / hypervisor"]
cpu["CPU cache / registers"]
end
ciphertext --> pm
keys --> pm
pm -->|execvp| child
pm -.->|cannot stop| root
pm -.->|cannot stop| vm
pm -.->|cannot stop| cpu
Secrets at rest
Encrypted .env files and postmaster_bundle files are safe to store
in git, the Nix store, binary caches, and deploy artifacts. The
ciphertext is public — the matching private key is the real secret.
Only someone with the private key can decrypt the values.
A teammate who has repository access (and thus the public key) can add or replace encrypted secrets, but cannot read existing ones.
Plaintext never on persistent disk
Decrypted values exist only in:
- Process memory (anonymous heap, zeroized after use)
- RAM-backed filesystems (Linux tmpfs, macOS ramdisk — destroyed when the service unit stops)
They are never written to a file on persistent disk. The tmpfs is
namespace-private (invisible outside the unit’s mount namespace on
Linux) and unswappable (via mlockall on Linux and the service
manager’s cgroup-level MemorySwapMax=0 setting).
Fail-closed by default
If any value fails to decrypt (wrong key, corrupted ciphertext, stale
key file), the entire launch aborts before the child process starts.
Nothing partial is injected. This is controlled by the strict
setting, which defaults to true.
Symlink attacks on key files and secret paths
Postmaster rejects symlinks at every point where secret material is read or written:
- Key files are opened with
O_NOFOLLOW— a symlinked.env.keysfile is rejected at the kernel level. The file descriptor is thenfstat-ed to verify that permissions are0o077(no group or world access) and that the owner matches the current effective user ID. This fd-based approach eliminates the TOCTOU window between open and permission check. - Credential source paths are checked with
symlink_metadatabefore loading, rejecting symlinks at the path level. TheO_NOFOLLOWopen inKeyRing::loadprovides defense-in-depth at the fd level. - Secret files are written with
O_NOFOLLOW | O_CREAT | O_TRUNC— a symlink planted at the target path is rejected, not followed. - Secrets directory is checked with
symlink_metadataeven on Linux whereverify_ramdiskis not used, rejecting a symlinkedsecrets_dirthat could redirect plaintext to persistent disk. - Socket permissions are set via
fchmodon the file descriptor, notset_permissionson the path, eliminating the TOCTOU window betweenbindandchmod.
External binary verification
Postmaster verifies the integrity of external binaries before invoking them in the secret path:
- macOS:
/usr/bin/securityis verified viacodesign -vbefore being invoked for keychain access. This ensures the binary has a valid Apple code signature and has not been tampered with or replaced. - Linux: External binaries are verified as regular files owned by root (uid 0) and not world-writable.
Input validation
Postmaster validates all externally-authored input before it enters the secret path:
- NUL byte rejection: Environment variable values containing NUL
bytes are rejected in
envmode (NUL cannot appear in a POSIX environment variable) and inpassthroughvalues infilesmode. Credential source names are checked for NUL bytes before any path operation. - Path traversal rejection: Credential source names must be a
single relative path component —
.,.., absolute paths, and slash-containing paths are rejected. - Identifier validation: All key names must match
[A-Za-z_][A-Za-z0-9_]*— this is enforced before any value from that file is used, since a key name becomes a shell-visible env-var name or a filename component. - No shell expansion: Postmaster performs no
${VAR}or$(…)expansion anywhere in the decrypt-and-inject path. Values are opaque bytes end to end. A repo contributor who commitsKEY=$(cmd)gets the literal string, never a command execution at service start.
Key freshness verification
Postmaster provides --verify-keys on exec to detect stale keys
from a previous rotation cycle. After loading and decrypting, if the
resolved values are empty but the env files contain encrypted:
values, the keys are stale and the exec aborts with a clear error.
This catches the strict: false case where stale keys would silently
skip undecryptable values and launch the child with no secrets.
Residuals: what postmaster cannot fully prevent
Environment variables are visible to same-UID processes (env mode)
In env mode, decrypted values are injected as environment variables.
Any process running as the same user can read them via /proc/PID/environ,
ps e, or environment dump tools. This is a fundamental Unix property —
environment variables are not secret from same-UID processes.
Mitigations: DynamicUser (a fresh UID per service), ProtectProc
hardening (hides other processes’ /proc entries), and ultimately
switching to files mode (where values are written to files, not
environment variables).
On the BEAM (Erlang/Elixir), os:getenv/1 is reachable from any code
in the node, including a remote shell. Files mode with read-once-at-boot
into guarded state (and process_flag(sensitive, true) on the holder)
is strictly better than leaving secrets in the environment for the
node’s lifetime.
Root on the host
A process running as root can read any other process’s memory via
/proc/PID/mem or ptrace. This is the Unix trust boundary — root is
always game over. Postmaster’s PR_SET_DUMPABLE=0 and PT_DENY_ATTACH
prevent non-privileged peers from attaching, but cannot stop root.
Swap and core dumps
Swap and core dumps are the two ways plaintext can leak from memory to persistent disk.
Core dumps are disabled at both the process level
(PR_SET_DUMPABLE=0 on Linux, RLIMIT_CORE=0 on macOS) and the unit
level (LimitCORE=0, CoredumpFilter=0 in the Nix module). For
host-wide coverage, set
systemd.coredump.extraConfig = "Storage=none".
Swap is addressed on two layers:
- Per-unit: the service manager (systemd) can set
MemorySwapMax=0on the unit, preventing the kernel from swapping the unit’s pages. Postmaster also callsmlockall(MCL_CURRENT | MCL_FUTURE)on Linux (best-effort, may fail underRLIMIT_MEMLOCK). On macOS,mlockall(2)does not exist, but macOS encrypts swap by default, so swapped pages are ciphertext at rest. - Host-wide: encrypted swap or zram keeps all swap off persistent disk.
On NixOS, this is a one-liner:
swapDevices.*.randomEncryptionfor dm-crypt with an ephemeral key, orzramSwap.enableto keep swap in compressed RAM (background: Arch wiki, dm-crypt swap encryption).
Hardening inheritance across execvp
Postmaster calls harden_process() at startup, which sets process-level
hardening (PR_SET_DUMPABLE=0, mlockall, RLIMIT_CORE=0). After
execvp, the child process gets a fresh address space. Some settings
survive execvp, some do not:
RLIMIT_CORE: preserved acrossexecve(kernel guarantee). The child inheritsLimitCORE=0from the systemd unit. Core dumps stay disabled.PR_SET_DUMPABLE: preserved acrossexecvefor non-setuid binaries. The child inherits non-dumpable status unless it is a setuid/setgid/capability-bearing binary (which would be unusual for a service).MemorySwapMax=0: cgroup-level, not process-level. The child stays in the same cgroup, so the swap limit applies to the child’s pages too. This is the primary swap protection for the child. The Nix module sets this viapreventSwap = trueby default.mlockall: does NOT surviveexecvp— the child’s fresh address space is not pinned in RAM. This gap is covered by the cgroup-levelMemorySwapMax=0.
Memory residency
Plaintext secrets exist in process memory between decryption and consumption. This section documents where plaintext lives, what postmaster mitigates, what remains as a residual, and what is explicitly a non-goal.
Anonymous memory (heap, stack) — Mitigated
Postmaster’s decrypted values, key material, and resolved environment
pairs all live in anonymous memory — heap allocations wrapped in
Zeroizing<Vec<u8>> that zero the buffer on drop. The child process
receives plaintext via its environment array (env mode) or reads it
from files on a tmpfs/ramdisk (files/credentials mode).
Paging/swap: Anonymous pages can be swapped out by the kernel.
Postmaster mitigates this on Linux with mlockall(MCL_CURRENT | MCL_FUTURE) (best-effort, may fail under RLIMIT_MEMLOCK) and the
service manager’s cgroup knob MemorySwapMax=0 (wired by preventSwap
in the Nix module). On macOS, mlockall(2) does not exist, but macOS
encrypts swap by default, so swapped pages are ciphertext at rest.
Host-wide encrypted swap or zram on Linux provides the same guarantee
without per-unit configuration.
Core dumps: Disabled at the process level (PR_SET_DUMPABLE=0 on
Linux, RLIMIT_CORE=0 on macOS) and at the unit level
(LimitCORE=0, CoredumpFilter=0 in the Nix module). Host-wide
systemd.coredump.extraConfig = "Storage=none" is a belt for all
units.
Pre-execvp zeroization: Private keys (the KeyRing) are zeroized
when decrypt() returns and the ring local goes out of scope —
each Zeroizing<[u8; 32]> wipes its 32 bytes on drop. Decrypted
values (Resolved, a Vec<(String, Zeroizing<Vec<u8>>)>) are
explicitly dropped immediately after they are consumed, before
execvp is called. In env mode, resolved is dropped after
env_pairs extracts the OsString pairs the kernel needs. In
files mode, resolved is dropped after the secret files are written.
Only the extra vector (env array for env mode, file paths for files
mode) survives to execvp — and in files mode, extra contains only
paths, not plaintext.
Post-execvp heap lifetime — Residual (mitigated as far as possible)
After execvp, the process image is replaced. The Zeroizing drop
handlers for the resolved values have already fired (via explicit
drop), but the extra vector (env mode) holds plaintext as
OsString pairs that the kernel needs to build the child’s environ.
The kernel copies these into the new process’s environment and the
old process’s heap pages are released. The kernel zeroes anonymous
pages before reusing them, so the plaintext exists only in freed
pages until the kernel reclaims them — a transient window, not a
persistent risk.
No further mitigation is possible: the kernel needs the env array
to be intact at the moment of execvp, and execvp replaces the
process image without running destructors. Fork+wait would keep
secrets in a long-lived parent heap (strictly worse) and was rejected.
File-backed memory (page cache) — Mitigated by design
Postmaster does not mmap secret files. Secret files on Linux tmpfs
and macOS ramdisk are kernel-resident RAM — they are never on
persistent disk and never enter the page cache for a backing file.
The .env and .env.keys source files are read via dotenvy’s
from_read_iter which uses read(2), not mmap(2) — their contents
enter anonymous memory, not file-backed pages.
The distinction matters: file-backed pages (e.g., a secret stored in a
regular file on an ext4 volume) persist in the kernel page cache after
the file is closed and the process exits. Postmaster avoids this by
writing secrets only to tmpfs/ramdisk (RAM-backed, no page cache for a
backing file) and by using read(2) for source files (anonymous
memory, not mapped).
Container swap enforcement — Mitigated
MemorySwapMax=0 can be wired by the service manager (systemd) or the
Nix module via preventSwap. This enforces the cgroup’s swap limit —
the kernel will not swap the unit’s pages even under host memory
pressure. The residual is only if the host kernel itself is
compromised, which falls under “root on the host is always game over.”
Host-wide encrypted swap is recommended as defense-in-depth for the
entire host, not just postmaster’s cgroup.
VM and hypervisor inspection — Residual (no mitigation available)
A hypervisor can inspect guest memory, take snapshots, or
live-migrate VMs to other hosts. Postmaster’s mlockall and
MemorySwapMax=0 operate inside the guest kernel; the hypervisor is
outside the trust boundary. The only defense is confidential computing
hardware (AMD SEV-SNP, Intel TDX, AWS Nitro Enclaves), which is
outside postmaster’s scope and dependency set.
CPU registers and cache — Non-goal (no mitigation available)
Plaintext secrets pass through CPU registers and L1/L2/L3 cache
during decryption and injection. Postmaster does not attempt to flush
cache lines or zero registers after use. This is a non-goal:
mitigating cache-line side channels and register residue requires
constant-time crypto implementations and hardware support (e.g., TSX
memory scrubbing, wbinvd instruction) that are outside postmaster’s
scope. The crypto library (ecies) uses secp256k1 and AES-256-GCM
primitives that are not guaranteed constant-time on all targets; this
is a property of the crypto library, not postmaster.
Privileged ptrace — Residual (no mitigation available)
PR_SET_DUMPABLE=0 (Linux) and PT_DENY_ATTACH (macOS release builds)
prevent non-privileged peers from attaching. A privileged process
(root, or a process with CAP_SYS_PTRACE) can still read process
memory — this is unavoidable and the same trust boundary as “root on
the host is always game over.” No software mitigation exists for
privileged memory inspection within the same kernel.
Plaintext file persistence window (files mode) — Mitigated
Secret files written to the tmpfs/ramdisk are removed by postmaster cleanup --config <exec.json>, wired to ExecStopPost in the systemd
Nix module — it runs immediately after the child exits. On macOS, the
ramdisk is destroyed when the postmaster-ramdisk launchd job stops.
The prune_stale function also removes stale files from prior
launches before writing new ones.
Ramdisk mount verification (macOS) — Mitigated
verify_ramdisk verifies the mount is RAM-backed by parsing
hdiutil info for the device’s image-path starting with ram://.
Fails closed if the device isn’t found or isn’t RAM-backed, preventing
plaintext from being written to a file-backed HFS image masquerading
as the ramdisk.
fd closure before execvp — Mitigated
OFlags::CLOEXEC is set on all rustix::fs::open calls (5 sites) —
the kernel automatically closes these on execvp, preventing the child
from inheriting postmaster’s open file descriptors. Third-party code
(e.g., dotenvy) may open fds without CLOEXEC; a pre_exec hook to
close those was considered but not implemented because it requires
unsafe code or a new cross-platform dependency. Postmaster’s own fds
are fully covered.
Secrets directory path exposure — Mitigated
The child receives secrets via $KEY_FILE pointers (Docker secrets
convention, systemd credentials convention, 12-factor _FILE
pattern). The secrets directory path is not separately exported as
an environment variable. CREDENTIALS_DIRECTORY is exported in
credentials mode because it is a systemd standard that the child
uses to locate credential files.
CLI reference
All postmaster commands, flags, and exit codes. The CLI is hand-rolled
with no external parsing library — every flag below is the complete
set, directly from the argument parser in config.rs.
Synopsis
$ postmaster --config <file.json> [--bind] [--allow-nonroot]
$ postmaster exec --config <exec.json> [--check] [--verify-keys] -- <command> [args…]
$ postmaster setup --config <exec.json> [--recheck] [--unattended] [--force] [--keys-dir <path>]
$ postmaster cleanup --config <exec.json>
$ postmaster fetch <socket-path>
$ postmaster help [subcommand]
$ postmaster --version
$ postmaster --help
Global flags
| Flag | Description |
|---|---|
--help, -h | Print top-level help and exit |
--version, -V | Print version and exit |
Default mode (serve)
When no subcommand is given, postmaster enters daemon (serve) mode. This is the credential server used in credentials injection mode — socket-activated on Linux, self-binding on macOS.
$ postmaster --config <file.json> [--bind] [--allow-nonroot]
| Flag | Description |
|---|---|
--config <path> | Configuration file (required) |
--bind | Bind listener sockets even if not socket-activated (macOS) |
--allow-nonroot | Allow non-root connections (dangerous) |
On Linux, postmaster is socket-activated: the postmaster-credd.socket
unit owns the sockets and the daemon inherits them via
sd_listen_fds. On macOS, --bind causes postmaster to create its own
listener sockets.
exec
Decrypt and inject secrets, then execvp the target binary.
$ postmaster exec --config <exec.json> [--check] [--verify-keys] -- <command> [args…]
| Flag | Required | Description |
|---|---|---|
--config <path> | Yes | Path to exec.json (before --) |
--check | No | Validate config shape only, no decryption (for CI) |
--verify-keys | No | Preflight decryption to catch stale keys |
-- | Yes (unless --check) | Separator; everything after is the child argv |
- On success the process image is replaced (
execvp); postmaster never returns. - On failure the error is logged and the process exits non-zero.
--checkdoes not accept a command after--.--verify-keysperforms a preflight check: after loading and decrypting, if the resolved values are empty but the env files containencrypted:values, the keys are stale and the exec aborts. This catches thestrict: falsecase where stale keys would silently skip undecryptable values.
Exit codes:
| Code | Meaning |
|---|---|
| 0 | --check passed (or exec succeeded — but exec replaces the process) |
| 1 | Config error, parse failure, decryption failure |
| 2 | Missing arguments, bad usage |
setup
Create keys directory, verify permissions, optionally seed keychain (macOS), validate environment. Idempotent — skips steps that are already correct.
$ postmaster setup --config <exec.json> [--recheck] [--unattended] [--force] [--keys-dir <path>]
| Flag | Description |
|---|---|
--config <path> | Path to exec.json (required) |
--recheck | Verification-only — no creation or seeding |
--unattended | Fail closed without prompting (for CI/automation). Alias: --non-interactive |
--force | Re-create/re-seed even if already correct |
--keys-dir <path> | Override default keys directory |
Default keys directory:
- Linux:
/var/lib/postmaster - macOS:
~/Library/Application Support/postmaster
cleanup
Remove plaintext secret files from the secrets directory after the child exits.
$ postmaster cleanup --config <exec.json>
| Flag | Description |
|---|---|
--config <path> | Path to exec.json (reads secrets_dir from config) |
Wired to ExecStopPost in systemd by the Nix module. On macOS, the
ramdisk destruction handles cleanup automatically when the launchd job
stops.
fetch
macOS credential fetch client — connects to a postmaster socket and reads the decrypted value to EOF.
$ postmaster fetch /run/postmaster-credd/myapp/DATABASE_URL.sock
Used internally by the wrapper in credentials mode on macOS. Not typically called directly.
help
Print help text for a subcommand or the top-level help.
$ postmaster help # top-level help
$ postmaster help exec # exec subcommand help
$ postmaster help setup # setup subcommand help
$ postmaster help cleanup # cleanup subcommand help
$ postmaster help fetch # fetch subcommand help
version
$ postmaster --version
$ postmaster -V
Launcher contract (exec.json)
Audience Quickstart (Plain Language)
What this is: the complete specification of exec.json — every
field, every mode, every validation rule. This is the contract a
binding (Nix, Terraform, CDK, Ansible) must produce.
Who this is for
| Reader | What to take |
|---|---|
| Integration author | The entire page — your binding must produce this exact format |
| Security engineer | “Fail-closed rules” and per-mode validation |
| New user | “Invocation” and the config example — enough to get started |
| Platform / SRE | “Invocation” and --check / --recheck validation commands |
Quick glossary
| Term | Plain meaning |
|---|---|
| exec.json | JSON config that tells postmaster what to decrypt and how to launch |
| Projection mode | How secrets reach the child: env, files, or credentials |
| Key source | Where the decryption key comes from (file, credential, keychain, env) |
| Strict | If true (default), any decryption failure aborts the launch |
| Passthrough | In files mode, specific keys also exported as env vars |
postmaster exec is the concrete instance of the
materialization ABI.
The current config shape is
the .env adapter: encrypted inputs are env_files, private key material uses
the .env.keys / DOTENV_PRIVATE_KEY* convention, and output projection is
selected with mode. This page documents the committed parser and launcher
behavior; the ABI page records how this contract fits into the broader
format-neutral architecture.
postmaster exec is a second entry point alongside the credential daemon:
instead of serving decrypted values over a socket, it decrypts once, injects
them into a child process by one of three modes, and execvps a target
command. It exists so that bindings that cannot rely on systemd’s
LoadCredential= socket contract — Ansible, Puppet, Terraform/CDK,
hand-rolled process supervisors, or nix-darwin’s launchd — get the
same decrypt-and-inject semantics as the Linux daemon path, without
templating shell or re-implementing the crypto.
This page is the cross-binding contract. The Nix module is the first
binding to emit exec.json; it is not privileged over any other binding.
A binding is correct if and only if the exec.json it emits is
described by this document and passes the conformance suite.
Nothing here documents intent or a roadmap; every claim below is true
of the committed parser and launcher (src/config.rs,
src/launcher.rs, src/keys.rs) as of this writing.
Config to launch flow
flowchart TD
cfg["exec.json\n(mode, keys, env_files)"]
check["--check\nconfig shape only"]
recheck["setup --recheck\nkey source accessible?"]
resolve["resolve_all\ndecrypt + resolve"]
verify["--verify-keys\nstale key check"]
inject["inject\n(env / files / credentials)"]
execvp["execvp\nchild = MainPID"]
cfg --> check
cfg --> recheck
cfg --> resolve
resolve --> verify
resolve --> inject
inject --> execvp
Reference implementation
The .env semantics this contract mirrors — encrypted-value format,
.env.keys rotation via comma-separated candidates, DOTENV_PRIVATE_KEY*
naming, --strict/--overload precedence — are wire-compatible with
dotenvx-encrypted .env files.
postmaster exec does not shell out to any external CLI at all — it
implements the decrypt path natively in Rust (same ecies crate the
daemon uses) — but wherever this document describes a precedence or
filtering rule, it describes the wire-compatible behavior, not a
postmaster-only invention.
Invocation
$ postmaster exec --config <exec.json> -- <command> [args…]
$ postmaster exec --config <exec.json> --check
$ postmaster exec --config <exec.json> [--verify-keys] -- <command> [args…]
$ postmaster setup --config <exec.json> [--recheck] [--unattended] [--force] [--keys-dir <path>]
$ postmaster cleanup --config <exec.json>
--config <path>is required and must come before--.- Everything after
--is the argv of the child process; it must be non-empty (exec: missing command after`--`otherwise). --checkvalidates the public config shape and exits without decrypting, reading key material, checking the filesystem, fetching sockets, or launching the child command. It does not accept a command after--.--verify-keysperforms a preflight check: after loading and decrypting, if the resolved values are empty but the env files containencrypted:values, the keys are stale (from a previous rotation cycle) and the exec aborts with a clear error. This catches thestrict: falsecase where stale keys would silently skip undecryptable values and launch the child with no secrets.setupverifies platform prerequisites, creates keys directory with 0700, verifies/creates keys files with 0600, optionally seeds keychain items (macOS), and validates the environment. Idempotent.--recheckis verification-only.--unattendedfails closed without prompting.--forcere-creates/re-seeds even if correct.--keys-diroverrides the default path (/var/lib/postmasteron Linux,~/Library/Application Support/postmasteron macOS).--checkvalidates config shape only, not key source accessibility: a config can pass--checkin CI but fail at runtime because the key file is missing, the keychain item is absent, or the credential source is not provisioned. Runtime key source accessibility is verified by the launcher itself (fail-closed on missing/unreadable keys);--checkcannot guarantee the handoff will work, only that the config is well-formed.- On success the process image is replaced (
execvp); postmaster never returns. On failure to exec the target binary itself, the error is logged and returned as a non-zero exit — nothing beyond this point is fail-open.
ExecConfig fields
exec.json deserializes into this struct (src/config.rs).
Unknown root fields are rejected. ABI fields such as version or
adapter must not be emitted until the parser and schema explicitly define
them. A machine-readable JSON Schema for this current shape is kept at
docs/schema/exec.schema.json, with parser-checked examples under
docs/examples/.
Every field except mode has a default, so a minimal config is just
{"mode": "credentials", "credential_keys": ["DB_URL"]}.
| Field | Type | Default | Meaning |
|---|---|---|---|
mode | "env" | "files" | "credentials" | — (required) | Injection mode; see below. |
keys | array of key-source specs | [] | Ordered list of where to obtain DOTENV_PRIVATE_KEY* material. Unused in credentials mode. |
env_files | array of paths | [] | Encrypted .env[.<environment>] files, in precedence order (first file wins unless overload). Required (non-empty) for env/files modes. |
bundle_files | array of paths | [] | Encrypted postmaster_bundle JSON containers, in precedence order. Entries are merged with env_files; bundle keys use the same identifier rule and injection modes. |
strict | bool | true | Any value that fails to decrypt aborts the whole exec before the child runs. |
overload | bool | false | File-derived values override the process environment; later files override earlier ones. |
secrets_dir | path or null | null | files mode, and credentials mode on the launchd/fetch path: directory plaintext files are written into. |
passthrough | array of strings | [] | files mode only: these keys are also exported as real env vars, in addition to their _FILE pointer. |
credential_keys | array of strings | [] | credentials mode: keys to expose as $CREDENTIALS_DIRECTORY/<KEY>. Required (non-empty) for that mode. |
sockets | map of key → socket path | {} | credentials mode: postmaster socket to fetch <KEY> from. Non-empty selects the darwin/launchd fetch path; empty means systemd already materialized the credentials via LoadCredential=. |
verify_ramdisk | path or null | null | darwin only: refuse to write plaintext unless this mount point is the HFS ramdisk and secrets_dir is a directory owned by the current euid. |
Note the field name is verify_ramdisk, not ramdisk or ramdisk_mount —
match it exactly.
Mode-required fields are enforced by postmaster exec --check and again
before a real launch:
envrequires non-emptykeysand non-emptyenv_files.filesrequires non-emptykeys, non-emptyenv_files, andsecrets_dir.credentialsrequires non-emptycredential_keys. Ifsocketsis non-empty, it also requiressecrets_dirand a socket mapping for every credential key.
--check also validates env-file basenames, rejects private-key
file sources under /nix/store, rejects path-like credential source
names, and applies identifier validation to passthrough and
credential_keys.
Mode-irrelevant fields are rejected. --check and the pre-launch
validation fail fast if a config includes fields that do not belong to the
selected mode — before any secret material transits. This catches binding
bugs where a config generator accidentally emits, for example, env_files
in a credentials-mode config or credential_keys in an env-mode config.
Default/empty values (empty arrays, null, false) are accepted, since
serde fills those in even when the generator omits the field.
| Mode | Rejected if present (non-default) |
|---|---|
env | secrets_dir, credential_keys, sockets, verify_ramdisk, passthrough |
files | credential_keys, sockets |
credentials | keys, env_files, overload, passthrough |
Key-source list (keys)
Each entry in keys is one externally-tagged JSON shape:
| JSON | Meaning |
|---|---|
{"credential": "<name>"} | $CREDENTIALS_DIRECTORY/<name> (the launcher’s own LoadCredential, i.e. .env.keys handed to it the same way postmaster’s daemon receives its own). <name> must be a single relative path component, not ., .., an absolute path, or a slash-containing path. |
{"file": "<path>"} | An out-of-store .env.keys-format file read directly. Rejected outright if the path starts with /nix/store — private keys must never live in the world-readable store. |
{"keychain": "<service>"} | macOS System keychain; one generic-password item per DOTENV_PRIVATE_KEY[_<ENV>] account, service name as given. Only the variables actually needed (derived from env_files) are fetched. Any other target: error, “only available on macOS”. |
"environment" | The unit-variant source — note it is a bare JSON string, not an object, because it carries no data. Reads DOTENV_PRIVATE_KEY* straight from postmaster’s own process environment. |
Selection contract, evaluated in list order:
credentialis skippable: if$CREDENTIALS_DIRECTORYis unset, or the named file doesn’t exist under it, the launcher falls through to the next entry inkeys. If the file does exist, whateverKeyRing::loadreturns (success or parse error) is final — a bad keys file under an existing credential does not fall through.file,keychain, andenvironmentare terminal: the first time one of these is reached, its result — success or failure — is the result of key-ring selection. There is no fallback past a terminal source.- If every entry in
keysis exhausted without returning (i.e. every entry was a skippable, absentcredential), orkeysis empty:"no usable key source (configure keys=[…] or set DOTENV_PRIVATE_KEY*)".
credentials mode never touches keys or env_files at all — no
decryption happens in that mode; see below.
Precedence contract
This is the contract resolve_all (src/keys.rs) implements,
verbatim:
- Within one file, the last assignment wins — ordinary dotenv
semantics.
A=1followed later in the same file byA=2yields2. - Across files, the first file that defines a key wins, in the order
env_fileslists them — unlessoverloadistrue, in which case the last file defining the key wins. Note: which file wins does not change the position a key is emitted in; a key’s slot is fixed by the first file that introduces it,overloadonly changes which file’s value fills that slot. - Process-environment presence suppresses the file-derived value,
but only when
overloadisfalse: if the variable is already set in postmaster’s process environment, it is dropped from the resolved set entirely (the child inherits the parent’s existing value unmodified, sinceexec()only adds the resolved pairs on top of an inherited environment). Whenoverloadistrue, process environment is not consulted at all — file values always win.
DOTENV_ filtering and identifier validation
- Any raw key beginning with
DOTENV_(not just the private/public key vars) is dropped during parsing — metadata, never a value to inject. - Every remaining key must match
[A-Za-z_][A-Za-z0-9_]*(crate::keys::is_ident) — ASCII letters/digits/underscore, first character not a digit. A key that fails this is a hard parse error ("<file>: refusing non-identifier key <k>"), not a skip: a key from an encrypted file will end up as a shell-visible env-var name or a filename component, so this is enforced before any value from that file is used at all. credential_keys(used only incredentialsmode, where there is noresolve_allpass) is validated against the sameis_identrule up front, before any file is written or any socket is fetched (validate_credential_keys) — a bad key later in the list (e.g."../evil", an absolute path, or a name containing/) can’t leave a partial plaintext write behind, and can’t be used to escapesecrets_diror forge an arbitrary env-var name.
Encrypted-value format
A raw value that does not start with encrypted: is passed through
verbatim as opaque bytes (this is how plaintext values reach the child —
no decryption attempted). A value that does start with encrypted: has the
following payload base64-decoded and decrypted with ecies against each
candidate key under the matching DOTENV_PRIVATE_KEY* name
(comma-separated rotation candidates in .env.keys are tried in order;
first one that decrypts wins).
Lenient base64 decoding: the payload after encrypted: may contain
ASCII whitespace (spaces, tabs, newlines, carriage returns). If any is
present, it is stripped before decoding, so a value that has been
line-wrapped decodes identically to the same value on one line. This
matches the wire-compatible behavior for line-wrapped encrypted values and
is exercised directly in tests/exec_conformance.rs
(wrapped_base64_ciphertext_decrypts).
key_var_for maps an env-file name to the private-key variable it needs:
.env → DOTENV_PRIVATE_KEY; .env.production →
DOTENV_PRIVATE_KEY_PRODUCTION; non-alphanumeric characters in the
environment suffix become _ and the whole suffix is upper-cased. A file
name that doesn’t start with .env is a hard parse error.
Per-mode injected variables
mode: "env". Decrypts env_files through the key ring in keys,
then exports every resolved KEY=value pair directly into the child’s
environment. No pointer files, no secrets_dir. A resolved value
containing a NUL byte is a hard error
("<key>: value contains a NUL byte; cannot be an environment variable (use files mode)") — environment variables cannot hold NUL, so this fails
closed rather than silently truncating.
mode: "files". Requires secrets_dir. If verify_ramdisk is set,
the ramdisk guard (below) runs first; otherwise secrets_dir merely has
to exist and be a directory. Each resolved value is written to
<secrets_dir>/<KEY> with permissions forced to 0600 (via fchmod on
the file descriptor after O_NOFOLLOW | O_CREAT | O_TRUNC open, so the
permission is set atomically without a TOCTOU window) — unlike env mode,
values here may contain NUL; the file is written byte-exact. For each key
the launcher exports <KEY>_FILE pointing at that path; if the key is
also listed in passthrough, it is additionally exported as a real
<KEY>=value env var (and that copy is subject to the same NUL-rejection
rule as env mode, since it becomes a real env var).
mode: "credentials". Requires non-empty credential_keys
(validated as identifiers up front, see above). No decryption occurs in
this mode — it dispatches on whether sockets is empty:
socketsempty (systemd path): pid 1 has already materialized the credentials viaLoadCredential=before the unit’sExecStartruns. Requires$CREDENTIALS_DIRECTORYto be set in the process environment ("postmaster credentials expected but CREDENTIALS_DIRECTORY is unset"otherwise — a fail-closed guard against running this mode outside systemd). For each key, exports<KEY>_FILE=$CREDENTIALS_DIRECTORY/<KEY>— no reading, no permission changes; systemd already owns that file.socketsnon-empty (darwin/launchd fetch path): requiressecrets_dir; runs the ramdisk guard ifverify_ramdiskis set. For each key, looks up its socket insockets(missing entry is an error), connects and reads to EOF via the same client pathpostmaster fetchuses, writes the bytes to<secrets_dir>/<KEY>at0600, and exports<KEY>_FILE. After all keys, exports bothCREDENTIALS_DIRECTORYset tosecrets_dir— unlike the systemd branch, there is no realLoadCredentialmechanism on darwin, so the launcher has to tell the consumer thatsecrets_diris its credentials directory.
Fail-closed rules
strict(defaulttrue). Inenv/filesmodes, if any value fails to decrypt (bad key, wrong key, malformed ciphertext), the whole exec aborts before the child runs — nothing partial is injected. Withstrict: false, an undecryptable value is logged and simply omitted from the resolved set (never served as an error string or empty value).- NUL in an environment value is refused in
envmode and forpassthroughvalues infilesmode (see above); infilesmode’s primary write path (the pointer-file contents), NUL is not restricted — the exact bytes reach the file. - Empty fetch response (
credentialsmode, darwin socket path):fetch_bytestreats a zero-byte read as the server having refused to serve, and fails closed with"<path>: server sent zero bytes (refused to serve); failing closed"rather than writing an empty credential file. - Missing
$CREDENTIALS_DIRECTORYincredentialsmode’s systemd branch fails closed rather than silently exporting nothing. - Private keys never come from the Nix store. A
{"file": …}key source under/nix/storeis refused outright, even before attempting to read it — the store is world-readable, so a private key living there is not private. - Keys file permissions and ownership.
KeyRing::loadopens the keys file withO_NOFOLLOWandfstats the fd to verify: no group/world access (mode & 0o077 == 0), and owned by the current euid. A world-readable or symlinked keys file fails closed. - External binary verification. On macOS,
from_keychainverifies the code signature of/usr/bin/securityviacodesign -vbefore invoking it. On Linux, external binaries are verified as regular files owned by root (uid 0) and not world-writable. verify_ramdisk(darwin ramdisk guard). When set, before any plaintext is written the launcher runs two checks: it shells out to/sbin/mountand requires the configured mount point to appear as anhfsfilesystem, then runshdiutil infoand requires the device’simage-pathto start withram://(confirming RAM backing, not a file-backed image). It thenstatssecrets_dirand requires it to exist, be a directory, and be owned by the launcher’s own effective uid. Either check failing refuses to write plaintext to what might be persistent disk, with an error pointing atpostmaster-ramdiskhealth.
Extensibility boundary
KeySourceSpec is the boundary between postmaster and the systems around
it. The implemented source kinds are deliberately local and terminal:
credential, file, keychain, and environment. postmaster rejects any
unknown key-source kind at JSON-parse time; an unknown tag is a
deserialize error, not a silently-ignored source.
Provider names such as vault and aws_ssm are reserved for the contract,
but native network key sources are out of scope for the current threat
model. postmaster must not become an AWS SSM client, a Vault client, or a
general secret-store plugin host. Those systems are supported as upstream
custody/source platforms: they deliver .env.keys-format private-key
material into one of the implemented local sources, and postmaster remains
the offline runtime decrypt/inject boundary.
The obligations run in different directions for postmaster and for bindings:
- postmaster validates the
exec.jsonshape, enforces the implemented source semantics, decrypts locally, injects locally, and fails closed. - A binding must only emit source kinds it actually knows the semantics of. A binding that generates configs should treat an unrecognized source kind in its own templates as a bug in itself, not rely on postmaster to validate binding-author intent.
Composition recipes
None of these need new code in postmaster, and none of them put a network
client in postmaster’s secret path. They compose the local key sources
with external secret-management tooling that materializes a
DOTENV_PRIVATE_KEY* file, credential, or keychain item before
postmaster exec runs:
- Vault Agent →
{"file": …}. Point a Vault Agent template at rendering a.env.keys-format file (owned by the service user, off any world-readable store path) and reference it with{"file": "/run/vault/myapp.env.keys"}. Vault Agent owns Vault auth, network I/O, renewal, and rotation; postmaster only ever reads the rendered local file. - AWS SSM Parameter Store →
{"credential": …}. Materialize the parameter via a oneshot unit, cloud-init, an instance bootstrap step, orsystemd-credsfor TPM2 sealing on NixOS into the launcher’s ownLoadCredential=directory, then reference it as{"credential": "postmaster-myapp"}. AWS tooling owns IAM, network I/O, and retrieval; postmaster inherits systemd’s local credential access control. - macOS Keychain →
{"keychain": "<service>"}. No external tooling needed beyond what’s already documented forkey.keychainService(Operational notes) — this is the existing source, not a new composition, but the one to reach for on darwin when a file orLoadCredentialisn’t a fit.
Conformance
tests/exec_conformance.rs is the executable form of this document:
metacharacter-laden values stay inert across all three modes,
process-env-wins-without-overload and overload-reverses-it, rotation with
a bogus first key, strict failure refuses to exec, 0600 pointer files
and passthrough, both credentials branches (systemd pointer-only and
the darwin fetch loop), first-file/last-file precedence, same-file
last-assignment-wins, NUL-in-value handling in both env and files
modes, stale-key detection with --verify-keys, and non-identifier key
rejection at the exec boundary. Any binding — Ansible, Puppet, Salt, CDK,
or anything else that can render JSON — is correct precisely to the extent
that the exec.json it emits matches this document and that emitted
config passes this suite.
Changelog
All notable changes to this project are documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]
Added
- Standalone postmaster binary — a format-neutral local secret
materialization engine that decrypts encrypted inputs at process
launch time, injects them into a child process, and
execvps the target binary. .envinput adapter — wire-compatible with dotenvx-encrypted.envfiles (ECIES/secp256k1 + HKDF-SHA256 + AES-256-GCM).postmaster_bundleJSON container format as a second input adapter.KeyNamingenum (Env, Bundle) for compile-time exhaustive adapter dispatch — zero vtable overhead, controlled adapter set.- Three injection modes:
env(environment variables),files(one-file-per-secret on RAM-backed filesystem),credentials(systemd credentials via postmaster daemon). - Four key sources: systemd credentials (
LoadCredential=), files (.env.keyson disk), macOS keychain, environment variables. postmaster exec --checkfor config shape validation (CI).postmaster setup --recheckfor runtime key source verification (host).postmaster setupinteractive command — idempotent, creates keys directory, verifies permissions, optionally seeds keychain (macOS).postmaster cleanup --config <exec.json>subcommand for plaintext secret file removal. Wired toExecStopPostin systemd.--verify-keysflag onexecfor stale key detection after rotation.OFlags::CLOEXECon allrustix::fs::opencall sites.- RAM backing verification via
hdiutil infoon macOS. - Pre-execvp zeroization of
KeyRingandResolvedvalues. - Symlink rejection at every secret path:
O_NOFOLLOW,fstat,symlink_metadata,fchmodon socket fd. - External binary verification:
codesign -von macOS, root-owned check on Linux. - Input validation: NUL byte rejection, path traversal rejection, identifier validation, no shell expansion invariant.
- Threat model documentation covering memory residency, hardening inheritance across execvp, and the 5-stage secret pipeline.
verify_key_freshnessusesKeyNaming::Env.metadata_prefix().- Two security audits conducted (provider handoff + destination handoff), all findings fixed.
- Memory hardening audit conducted, all findings fixed.