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.