Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

ReaderWhat to take from this docs
New user / junior engineerThe introduction and Quick start; then Injection modes
Platform / SREKey provisioning and the Threat model
Security engineerThe Threat model and Materialization ABI
Integration authorThe Launcher contract and CLI reference
Product / project managerThis introduction and the postmaster overview

Quick glossary

TermPlain meaning
MaterializationDecrypting secrets and putting them where your app can read them (env vars, files, or credentials)
execvpA Unix system call that replaces the current process with a new binary — postmaster becomes your app
ECIESElliptic Curve Integrated Encryption Scheme — the crypto used to encrypt individual secret values
exec.jsonA JSON config file that tells postmaster what to decrypt, how to inject, and what binary to launch
Key sourceWhere the decryption key comes from (a file, systemd credentials, macOS keychain, or environment)
Injection modeHow decrypted secrets reach the child: env (environment variables), files (one file per secret), or credentials (systemd credentials)
CiphertextEncrypted 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

  1. Decrypts encrypted .env files (or postmaster_bundle JSON containers) using the ecies crate — native Rust, no network calls, no external CLIs, no shell.

  2. 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_FILE pointers)
    • credentials — systemd credentials (strongest hygiene)
  3. Launches the target binary via execvp — postmaster is replaced by your binary. The child process IS MainPID from its first instruction. No parent process holds secrets.

  4. Cleans uppostmaster cleanup removes plaintext secret files after the child exits, wired to ExecStopPost in 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
StageWho owns itWhat happens
1. CustodyVault, AWS SSM, 1Password, TPM2Private keys live in a source/custody system
2. RenderingNix, Terraform, CDK, Ansible, Salt, PuppetEncrypted artifacts + key material + service wiring get onto the host
3. Deliverysystemd, files, keychain, envKey material reaches postmaster at runtime via local sources
4. MaterializationpostmasterPostmaster decrypts and injects — this is the only stage it owns
5. LaunchexecvpProcess 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

  • .env adapter (wire-compatible with dotenvx), postmaster_bundle JSON container, three injection modes, four key sources (systemd credentials, files, keychain, environment), --verify-keys freshness check, setup command, cleanup command.
  • 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

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.

ModeBest forSecrets in environ?Hygiene
env (default)12-factor apps, simple servicesYesLowest — same-UID processes can read /proc/PID/environ
filesApps that read KEY_FILE pointers (Docker secrets convention)No — only _FILE pointersMedium — secrets absent from environ, ps, crash dumps
credentialssystemd services wanting strongest isolationNo — reads $CREDENTIALS_DIRECTORYHighest — 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 (default true): if any value fails to decrypt, the entire exec aborts before the child runs. Nothing partial is injected.
  • NUL in env values: rejected in env mode and for passthrough values in files mode. In files mode’s primary write path, NUL is not restricted — the exact bytes reach the file.
  • Empty fetch response: (credentials mode, 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.

ReaderWhat to take
Operator / SREAll sections — this is your playbook
Security engineerThe rotation window and fail-closed behavior
New userSkim 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
  1. Locally: decrypt the env file, delete the old public key and .env.keys entry, re-encrypt with a fresh keypair.
  2. On hosts: set DOTENV_PRIVATE_KEY_PRODUCTION="<new>,<old>" (re-seal the blob in strategy B). Running units are untouched; restarts decrypt either generation.
  3. Deploy the new ciphertext (nixos-rebuild switch).
  4. 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

ReaderWhat 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 engineerThe 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

TermPlain meaning
MaterializationDecrypting secrets and putting them where your app reads them
execvpUnix call that replaces the current process — postmaster becomes your app
ECIESThe encryption scheme used for individual secret values
CiphertextEncrypted data — safe to store in git or the Nix store
MainPIDThe 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”:

  1. Decryption. Postmaster decrypts encrypted inputs using the ecies crate (secp256k1 + HKDF-SHA256 + AES-256-GCM). No network calls. No external CLIs. No shell.

  2. 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).

  3. Launch. Postmaster calls execvp to replace its process image with the target binary. The child IS MainPID from its first instruction.

  4. Cleanup. Postmaster provides a cleanup subcommand that removes plaintext secret files from the secrets directory after the child exits. Wired to ExecStopPost in systemd; the ramdisk destruction handles this on macOS.

  5. Validation. Postmaster provides exec --check for config shape validation (CI) and setup --recheck for runtime environment verification (host). Both run without decrypting secrets.

  6. Setup. Postmaster provides an interactive setup command 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 execvp fires.

  • 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.keys on 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 .env files into the Nix store, generates exec.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_DIRECTORY before postmaster starts. This includes TPM2/host-sealed credentials via LoadCredentialEncrypted= — 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)
  • EnvironmentDOTENV_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 .env files via the ecies crate (native Rust, offline)
  • Decrypts postmaster_bundle files via the same crypto path
  • Injects via env, files, or credentials mode
  • 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_PEERCREDgetpeereid, 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.env file parser
  • serde + serde_json — JSON config deserialization
  • base64 — ciphertext decoding
  • hex — key parsing
  • rustix — syscalls (no libc FFI on Linux)
  • libc — macOS only (for getpeereid and PT_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

ReaderWhat to take
Integration authorThe 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

TermPlain meaning
ABIApplication Binary Interface — the contract between config files, key material, and the launcher
exec.jsonA JSON file that tells postmaster what to decrypt, how to inject, and what to launch
ProjectionWhere decrypted secrets go — env vars, files, or systemd credentials
AdapterAn 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
  • postmaster decrypts 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
  1. Artifact descriptors identify encrypted local inputs.
  2. Key-source descriptors identify local private key material.
  3. Resolution rules turn encrypted entries into named plaintext byte values.
  4. Projection policy decides where those bytes may appear.
  5. 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, and credentials

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:

  • .env maps to DOTENV_PRIVATE_KEY
  • .env.production maps to DOTENV_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:

  • format and integer version are mandatory and fail closed on unknown values.
  • suite is mandatory so cryptographic suites can be explicit rather than inferred from payload length.
  • profile is optional public selection metadata; it is not a secret.
  • recipients[*].id is public routing metadata used to select local private key material.
  • entries keys must use the same identifier rule as .env-derived keys: [A-Za-z_][A-Za-z0-9_]*.
  • encoding is either utf8 or bytes. env projection rejects bytes that cannot become a POSIX environment value; files and credentials preserve 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 postmaster config, 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" reads DOTENV_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 strict fail-closed behavior
  • respect overload precedence
  • 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 --check for 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.json or its successor is rendered
  • how service manager wiring invokes postmaster
  • how postmaster exec --check is 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

ReaderWhat to take
Security engineerThe 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 managerThe residuals table — what postmaster does NOT protect against

Quick glossary

TermPlain meaning
execvpReplaces the current process — no parent holds secrets after launch
Fail-closedIf anything fails, the service does NOT start — no partial secrets
ZeroizeOverwrite secret memory with zeros before it’s freed
mlockallPin memory in RAM so the kernel can’t swap it to disk
TOCTOUTime-of-check to time-of-use — a race between verifying and using a file
ResidualA risk that postmaster cannot fully eliminate
Non-goalSomething postmaster deliberately does not attempt to mitigate

Security posture at a glance

CategoryStatus
Secrets at rest (ciphertext in git/Nix store)Safe — ciphertext is public
Plaintext on persistent diskPrevented — only RAM-backed filesystems
Plaintext in swapMitigated — mlockall + MemorySwapMax=0
Core dumpsMitigated — PR_SET_DUMPABLE=0, RLIMIT_CORE=0
Shell injectionPrevented — no shell anywhere in the path
Symlink attacks on key filesPrevented — O_NOFOLLOW + fstat
Same-UID env var exposure (env mode)Residual — use files or credentials mode
Root on the hostResidual — root is always game over
VM/hypervisor inspectionResidual — outside the trust boundary
CPU cache/register residueNon-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 expects MainPID to send READY=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.

Postmaster rejects symlinks at every point where secret material is read or written:

  • Key files are opened with O_NOFOLLOW — a symlinked .env.keys file is rejected at the kernel level. The file descriptor is then fstat-ed to verify that permissions are 0o077 (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_metadata before loading, rejecting symlinks at the path level. The O_NOFOLLOW open in KeyRing::load provides 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_metadata even on Linux where verify_ramdisk is not used, rejecting a symlinked secrets_dir that could redirect plaintext to persistent disk.
  • Socket permissions are set via fchmod on the file descriptor, not set_permissions on the path, eliminating the TOCTOU window between bind and chmod.

External binary verification

Postmaster verifies the integrity of external binaries before invoking them in the secret path:

  • macOS: /usr/bin/security is verified via codesign -v before 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 env mode (NUL cannot appear in a POSIX environment variable) and in passthrough values in files mode. 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 commits KEY=$(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=0 on the unit, preventing the kernel from swapping the unit’s pages. Postmaster also calls mlockall(MCL_CURRENT | MCL_FUTURE) on Linux (best-effort, may fail under RLIMIT_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.*.randomEncryption for dm-crypt with an ephemeral key, or zramSwap.enable to 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 across execve (kernel guarantee). The child inherits LimitCORE=0 from the systemd unit. Core dumps stay disabled.
  • PR_SET_DUMPABLE: preserved across execve for 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 via preventSwap = true by default.
  • mlockall: does NOT survive execvp — the child’s fresh address space is not pinned in RAM. This gap is covered by the cgroup-level MemorySwapMax=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

FlagDescription
--help, -hPrint top-level help and exit
--version, -VPrint 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]
FlagDescription
--config <path>Configuration file (required)
--bindBind listener sockets even if not socket-activated (macOS)
--allow-nonrootAllow 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…]
FlagRequiredDescription
--config <path>YesPath to exec.json (before --)
--checkNoValidate config shape only, no decryption (for CI)
--verify-keysNoPreflight 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.
  • --check does not accept a command after --.
  • --verify-keys performs a preflight check: 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. This catches the strict: false case where stale keys would silently skip undecryptable values.

Exit codes:

CodeMeaning
0--check passed (or exec succeeded — but exec replaces the process)
1Config error, parse failure, decryption failure
2Missing 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>]
FlagDescription
--config <path>Path to exec.json (required)
--recheckVerification-only — no creation or seeding
--unattendedFail closed without prompting (for CI/automation). Alias: --non-interactive
--forceRe-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>
FlagDescription
--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

ReaderWhat to take
Integration authorThe 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

TermPlain meaning
exec.jsonJSON config that tells postmaster what to decrypt and how to launch
Projection modeHow secrets reach the child: env, files, or credentials
Key sourceWhere the decryption key comes from (file, credential, keychain, env)
StrictIf true (default), any decryption failure aborts the launch
PassthroughIn 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).
  • --check validates 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-keys performs a preflight check: after loading and decrypting, if the resolved values are empty but the env files contain encrypted: values, the keys are stale (from a previous rotation cycle) 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.
  • setup verifies platform prerequisites, creates keys directory with 0700, verifies/creates keys files with 0600, optionally seeds keychain items (macOS), and validates the environment. Idempotent. --recheck is verification-only. --unattended fails closed without prompting. --force re-creates/re-seeds even if correct. --keys-dir overrides the default path (/var/lib/postmaster on Linux, ~/Library/Application Support/postmaster on macOS). --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. Runtime key source accessibility is verified by the launcher itself (fail-closed on missing/unreadable keys); --check cannot 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"]}.

FieldTypeDefaultMeaning
mode"env" | "files" | "credentials"— (required)Injection mode; see below.
keysarray of key-source specs[]Ordered list of where to obtain DOTENV_PRIVATE_KEY* material. Unused in credentials mode.
env_filesarray of paths[]Encrypted .env[.<environment>] files, in precedence order (first file wins unless overload). Required (non-empty) for env/files modes.
bundle_filesarray 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.
strictbooltrueAny value that fails to decrypt aborts the whole exec before the child runs.
overloadboolfalseFile-derived values override the process environment; later files override earlier ones.
secrets_dirpath or nullnullfiles mode, and credentials mode on the launchd/fetch path: directory plaintext files are written into.
passthrougharray of strings[]files mode only: these keys are also exported as real env vars, in addition to their _FILE pointer.
credential_keysarray of strings[]credentials mode: keys to expose as $CREDENTIALS_DIRECTORY/<KEY>. Required (non-empty) for that mode.
socketsmap 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_ramdiskpath or nullnulldarwin 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:

  • env requires non-empty keys and non-empty env_files.
  • files requires non-empty keys, non-empty env_files, and secrets_dir.
  • credentials requires non-empty credential_keys. If sockets is non-empty, it also requires secrets_dir and 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.

ModeRejected if present (non-default)
envsecrets_dir, credential_keys, sockets, verify_ramdisk, passthrough
filescredential_keys, sockets
credentialskeys, env_files, overload, passthrough

Key-source list (keys)

Each entry in keys is one externally-tagged JSON shape:

JSONMeaning
{"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:

  • credential is skippable: if $CREDENTIALS_DIRECTORY is unset, or the named file doesn’t exist under it, the launcher falls through to the next entry in keys. If the file does exist, whatever KeyRing::load returns (success or parse error) is final — a bad keys file under an existing credential does not fall through.
  • file, keychain, and environment are 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 keys is exhausted without returning (i.e. every entry was a skippable, absent credential), or keys is 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=1 followed later in the same file by A=2 yields 2.
  • Across files, the first file that defines a key wins, in the order env_files lists them — unless overload is true, 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, overload only changes which file’s value fills that slot.
  • Process-environment presence suppresses the file-derived value, but only when overload is false: 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, since exec() only adds the resolved pairs on top of an inherited environment). When overload is true, 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 in credentials mode, where there is no resolve_all pass) is validated against the same is_ident rule 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 escape secrets_dir or 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: .envDOTENV_PRIVATE_KEY; .env.productionDOTENV_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:

  • sockets empty (systemd path): pid 1 has already materialized the credentials via LoadCredential= before the unit’s ExecStart runs. Requires $CREDENTIALS_DIRECTORY to 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.
  • sockets non-empty (darwin/launchd fetch path): requires secrets_dir; runs the ramdisk guard if verify_ramdisk is set. For each key, looks up its socket in sockets (missing entry is an error), connects and reads to EOF via the same client path postmaster fetch uses, writes the bytes to <secrets_dir>/<KEY> at 0600, and exports <KEY>_FILE. After all keys, exports both CREDENTIALS_DIRECTORY set to secrets_dir — unlike the systemd branch, there is no real LoadCredential mechanism on darwin, so the launcher has to tell the consumer that secrets_dir is its credentials directory.

Fail-closed rules

  • strict (default true). In env/files modes, if any value fails to decrypt (bad key, wrong key, malformed ciphertext), the whole exec aborts before the child runs — nothing partial is injected. With strict: 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 env mode and for passthrough values in files mode (see above); in files mode’s primary write path (the pointer-file contents), NUL is not restricted — the exact bytes reach the file.
  • Empty fetch response (credentials mode, darwin socket path): fetch_bytes treats 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_DIRECTORY in credentials mode’s systemd branch fails closed rather than silently exporting nothing.
  • Private keys never come from the Nix store. A {"file": …} key source under /nix/store is 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::load opens the keys file with O_NOFOLLOW and fstats 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_keychain verifies the code signature of /usr/bin/security via codesign -v before 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/mount and requires the configured mount point to appear as an hfs filesystem, then runs hdiutil info and requires the device’s image-path to start with ram:// (confirming RAM backing, not a file-backed image). It then stats secrets_dir and 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 at postmaster-ramdisk health.

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.json shape, 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, or systemd-creds for TPM2 sealing on NixOS into the launcher’s own LoadCredential= 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 for key.keychainService (Operational notes) — this is the existing source, not a new composition, but the one to reach for on darwin when a file or LoadCredential isn’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.
  • .env input adapter — wire-compatible with dotenvx-encrypted .env files (ECIES/secp256k1 + HKDF-SHA256 + AES-256-GCM).
  • postmaster_bundle JSON container format as a second input adapter.
  • KeyNaming enum (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.keys on disk), macOS keychain, environment variables.
  • postmaster exec --check for config shape validation (CI).
  • postmaster setup --recheck for runtime key source verification (host).
  • postmaster setup interactive command — idempotent, creates keys directory, verifies permissions, optionally seeds keychain (macOS).
  • postmaster cleanup --config <exec.json> subcommand for plaintext secret file removal. Wired to ExecStopPost in systemd.
  • --verify-keys flag on exec for stale key detection after rotation.
  • OFlags::CLOEXEC on all rustix::fs::open call sites.
  • RAM backing verification via hdiutil info on macOS.
  • Pre-execvp zeroization of KeyRing and Resolved values.
  • Symlink rejection at every secret path: O_NOFOLLOW, fstat, symlink_metadata, fchmod on socket fd.
  • External binary verification: codesign -v on 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_freshness uses KeyNaming::Env.metadata_prefix().
  • Two security audits conducted (provider handoff + destination handoff), all findings fixed.
  • Memory hardening audit conducted, all findings fixed.