Operational guide. Top to bottom this is the path from git clone to a
running encrypted ELF on a Linux target. For why the system is shaped
the way it is, see README.md and DECISIONS.md. For the threat model
(what this defends against, what it does not), see SECURITY.md. For
the wire formats, see SPEC.md. This file does not duplicate any of
that.
Encrypted ELF distribution to Linux hosts where you do not trust root —
rented VMs, cloud containers, generic IaaS. Defends the binary at rest
against disk reads, image extraction, and offline brute force; does
not defend the running process against /proc/<pid>/mem or live
memory forensics by hostile root. Threat model details and the full
list of out-of-scope adversaries are in SECURITY.md.
Distro packages (Ubuntu / Debian).
sudo apt-get install -y build-essential libsodium-dev libkeyutils-devlibssl-dev is needed only for keyring_crypto_test; skip it for
production deploys. macOS can build the cross-platform tools (genkey,
elfenc_pack, kcap_pack, kcap_unpack, keypush_send) with
brew install libsodium; elfdec-run and keypushd are Linux-only.
Kernel floor.
- Linux >= 3.19 is the supported floor for
elfdec-run. The/dev/shmETXTBSY fallback usesexecveat(2)(3.19+) — pre-3.19 kernels fail closed instead of running encrypted ELFs (D-16). MFD_EXECis best-effort. Userspace built on a 6.3+ box still runs on a 5.15-era kernel:memfd_create_compatretries without the bit onEINVAL(D-20).- Validated on v5.15, v6.1, v6.5, v6.8 — see §7 for the matrix.
Optional.
systemd-run --user --scope— only needed for AC-03-09 (cgroup OOM validation intest.sh:609-666). Not needed at runtime.strace— only needed to exercise the D-18 ptrace defense tests.
Mode 4 (file-based key) is the simplest deployment posture and the
right starting point. This is the case validated by test.sh:150-175.
git clone https://github.com/nmicic/ephrun && cd ephrun
make # builds ephrun/ + keypush/
cd ephrun
./genkey # → pub.bin, priv.bin (32 B each)
gcc -O2 hello.c -o hello # any ELF works; hello is the test fixture
./elfenc_pack pub.bin hello hello.elfenc # → ELFENC1 sealed-box
mkdir -p /tmp/elfenc_keys && cp priv.bin /tmp/elfenc_keys/
chmod 600 /tmp/elfenc_keys/priv.bin
ELFDEC_KEYPATH=/tmp/elfenc_keys ./elfdec-run ./hello.elfenc
# → hello from encrypted ELF!pub.bin is not required at runtime — elfdec-run derives it from
priv.bin via crypto_scalarmult_base. Only the priv key reaches the
target.
For a static binary that runs on hosts without matching glibc / libsodium:
make -C ephrun clean && make -C ephrun STATIC=1This is not optional in practice. A dynamic elfdec-run built on a
modern Ubuntu / Debian will fail on an older target with
/lib/x86_64-linux-gnu/libc.so.6: version 'GLIBC_2.38' not found
(or similar) — the build host's glibc baseline gets baked into the
binary at link time. Ship the static build for cross-distro deploys.
The static binary is ~1.2 MB (vs ~32 KB dynamic) but has no runtime
dependencies beyond a 3.19+ kernel.
elfdec-run tries the modes in this priority order; the first success
wins (D-4, elfdec-run.c:498-574):
| # | Trigger env var | Source |
|---|---|---|
| 1 | ELFDEC_CODE |
KCAP3 / KCAP2 / KCAP1 capsule (keyring or $ELFDEC_KEYPATH) |
| 2 | ELFDEC_KEYID |
Raw priv by numeric keyring ID |
| 3 | ELFDEC_LABEL |
Raw priv by keyring search elfdec:<label> |
| 4 | ELFDEC_KEYPATH / ~/.elfenc/ |
priv.bin on disk |
If ELFDEC_LABEL is unset, the canonical realpath of the .elfenc
file is used as the label for tier 3.
Use when you want the priv key never to land on the target as raw
bytes. The capsule is XChaCha20-Poly1305 / Argon2id-wrapped (D-1, D-13)
and is safe to ship next to the .elfenc file. Default Argon2id cost
is T=3, M=64 MiB, P=1; receiver enforces D-15 floor and ceiling before
calling the KDF. Validated by test.sh:209-239 (binary capsule on
disk) and test.sh:311-328 (capsule in keyring).
Build-side:
./kcap_pack --label prod/myapp --code "correct horse battery staple lattice 7" \
--in priv.bin --out capsule.bin
# → 112-byte KCAP3 file (64-byte header + 48-byte ciphertext)Deploy myapp.elfenc and capsule.bin to the target. priv.bin stays
on the builder. Then on the target:
# Option A — capsule on disk
mkdir -p /opt/myapp/keys && cp capsule.bin /opt/myapp/keys/
ELFDEC_CODE="correct horse battery staple lattice 7" \
ELFDEC_KEYPATH=/opt/myapp/keys \
./elfdec-run ./myapp.elfenc
# Option B — capsule in the kernel keyring (no disk file)
keyctl padd user "elfdec_caps:prod/myapp" @s < capsule.bin
ELFDEC_CODE="correct horse battery staple lattice 7" \
ELFDEC_LABEL="prod/myapp" \
./elfdec-run ./myapp.elfencCapsule lookup order (elfdec-run.c:500-509):
- Keyring:
elfdec_caps:<ELFDEC_LABEL>searched in@sthen@u. - File:
$ELFDEC_KEYPATH/capsule.bin, then legacy$ELFDEC_KEYPATH/capsule.json(deprecated, read-only).
Passphrase entropy is the operator's responsibility — Argon2id raises
the per-guess cost to ~100 ms on a modern CPU; it does not save weak
passphrases. See SECURITY.md §4 item 3.
Use when a separate process injected the key and handed you back the
numeric ID (e.g. keypushd ACK, elfdec-ssh-pushkey.sh, or a parent
script). Validated by test.sh:177-194.
# Inject (32-byte raw priv into session keyring, name "elfdec:prod/myapp")
KEYID=$(keyctl padd user "elfdec:prod/myapp" @s < priv.bin)
keyctl setperm "$KEYID" 0x3f030000 # owner: view+read; possessor: all
keyctl timeout "$KEYID" 300 # auto-expire 5 minutes
# Run
ELFDEC_KEYID="$KEYID" ./elfdec-run ./myapp.elfencUse when the key is in the keyring with a known label and the consumer
shouldn't have to track the numeric ID. elfdec-run searches for a
key named elfdec:<ELFDEC_LABEL> in @s then @u. Validated by
test.sh:196-204.
keyctl padd user "elfdec:prod/myapp" @s < priv.bin
ELFDEC_LABEL="prod/myapp" ./elfdec-run ./myapp.elfenc
# If ELFDEC_LABEL is unset, realpath(.elfenc) is used as the label.
./elfdec-run ./myapp.elfenc
# → searches keyring for "elfdec:/abs/path/to/myapp.elfenc"For local injection helpers see ephrun/add_keyring4.sh (root + user
detection, perm fallback) and ephrun/add_keyring8.sh (concise
production version). For SSH push see ephrun/elfdec-ssh-pushkey.sh.
Simplest. Only priv.bin is needed on the target — pub.bin is
derived at runtime. Use for single-host dev or for hosts where you do
not want capsule overhead. Validated by test.sh:150-175.
# Custom dir
sudo mkdir -p /etc/elfenc
sudo cp priv.bin /etc/elfenc/ && sudo chmod 600 /etc/elfenc/priv.bin
ELFDEC_KEYPATH=/etc/elfenc ./elfdec-run ./myapp.elfenc
# Default dir — no env vars
mkdir -p ~/.elfenc && cp priv.bin ~/.elfenc/ && chmod 600 ~/.elfenc/priv.bin
./elfdec-run ./myapp.elfencFilesystem permissions are the only barrier here. On a host where root is hostile, Mode 1 (capsule) is materially better.
Source: ephrun/kcap_pack.c:50-75.
| Flag | Required | Description |
|---|---|---|
--label |
yes | Label string. Recorded for CLI sanity; KCAP3 has no label field. |
--code |
yes | Passphrase. Empty string is rejected ("code required"). |
--in |
no | Path to 32-byte raw priv. Stdin if omitted. |
--out |
no | Output path. Stdout if omitted. Output mode is 0600 when created. |
--ttl N |
no | Deprecated. Accepted for CLI compat, prints warning, ignored. |
--json |
no | Deprecated. Exits 2 with "JSON output deprecated in KCAP3". |
kcap_pack writes only KCAP3 (AC-01-E-03). KCAP1 / KCAP2 are
read-only legacy formats kept readable by elfdec-run and
libexec_key.h for migration; rotate by re-packing the original
priv.bin with kcap_pack and deploying the new capsule. The format
is dispatched by family magic so old capsules keep working until you
replace them.
Source: ephrun/kcap_unpack.c:53-75. Builds on Linux and macOS; no
keyring dependency.
kcap_unpack --in capsule.bin --code "passphrase" > priv.bin
KCAP_CODE="passphrase" kcap_unpack --in capsule.bin > priv.binThis tool is for fixture verification, CI checks, and tooling pipes —
not for production deployment. elfdec-run consumes capsules
in-process; piping the priv key through a CLI defeats the point of the
capsule layer.
Two options exist today. Pick based on whether you have SSH to the target.
Streams the raw priv over SSH stdin into the remote kernel keyring. The key never touches the remote disk.
./elfdec-ssh-pushkey.sh -H user@remote -l prod/myapp -k priv.bin -t 300
# Returns the keyring ID; on the remote:
# ELFDEC_KEYID=<returned_id> /usr/local/bin/elfdec-run ./myapp.elfencUse when SSH is unavailable. keypushd is transitional (D-7) — the
honest replacement going forward is to ship a KCAP3 capsule over any
standard channel (scp, HTTPS, tarball) since the capsule is safe at
rest. Don't add features here.
keypushd CLI flags (keypush/keypushd.c:196-307):
| Flag | Default | Description |
|---|---|---|
--bind IP |
required | UDP bind address. |
--port N |
0 (random) |
UDP port; 0 = OS picks free. |
--label L |
unset | Default label if sender omits one. |
--ttl N |
300 |
Max key TTL in seconds (server-side cap). |
--window N |
60 |
Bootstrap-token expiry in seconds. |
--detach |
off | Fork to background after printing bootstrap JSON. |
--link-user |
off | Also link key into @u (persists across sessions). |
keypush_send CLI flags (keypush/keypush_send.c:50-67):
| Flag | Default | Description |
|---|---|---|
--ip IP |
required | Target IP. |
--port N |
required | Target UDP port. |
--srv-pk-b64 B64 |
required | srv_pk from bootstrap JSON. |
--token T |
required | token from bootstrap JSON. |
--label L |
required | Key label (e.g. prod/myapp). |
--ttl N |
300 |
Requested TTL (capped by server --ttl). |
--wait-ack |
off | Wait up to 3s for ACK/NAK JSON reply. |
Walkthrough:
# On the TARGET
./keypushd --bind 0.0.0.0 --port 9999 --label prod/myapp --ttl 300
# {"ip":"0.0.0.0","port":9999,"srv_pk":"xK7b+...","token":"K3J7QRST...","expires":...}
# Copy srv_pk + token to the control machine out of band, then on the CONTROL host
cat priv.bin | ./keypush_send \
--ip <TARGET_IP> --port 9999 \
--srv-pk-b64 "xK7b+..." --token "K3J7QRST..." \
--label prod/myapp --ttl 300 --wait-ack
# {"ok":true,"keyid":827867509,"ttl":300}
# Back on the TARGET
ELFDEC_LABEL="prod/myapp" ./elfdec-run ./myapp.elfenckeypushd has no rate limiting; the one-shot bootstrap window is the
only mitigation. Run it just long enough to push the key, then stop it.
Leaving it on a public-IP host is misuse (SECURITY.md §4 item 10).
keypushd exits after one successful payload. A kill of its PID
after the push will return non-zero because the process is already
gone — that is the intended one-shot lifecycle, not an error.
The elfdec-run keyring path uses libkeyutils against the kernel
session keyring (@s) and user keyring (@u). A few non-obvious
things bite first-time users:
keyctl show @s # tree view of session keyring
keyctl list @s # one-line-per-key form
keyctl rdescribe <KEYID> # type;UID;GID;perms;description
keyctl read <KEYID> # NOT recommended — prints the key bytes hex
keyctl pkey_query <KEYID> # capabilities (for asymmetric keys)keyctl show @s typically shows _ses → _uid.<UID> — that nested
keyring entry is normal; it is the user keyring linked into the
session keyring by libpam's pam_keyinit.so.
elfdec-run walks the four key sources in priority order: capsule,
ELFDEC_KEYID, ELFDEC_LABEL (with realpath(.elfenc) as default
label), then priv.bin on disk. If you previously seeded
elfdec:<realpath> into @s, the disk path will never be tried even
when the env vars are unset, because tier 3 wins first.
keyctl clear @s # nuke everything in the session keyring
keyctl unlink <KEYID> @s # selective removal
keyctl revoke <KEYID> # mark unusable without removing the linkUse keyctl clear @s between Mode-3 and Mode-4 tests. Symlinks matter
too: tier 3's default label uses realpath(), so the keyring entry
must be named elfdec:<canonicalized-absolute-path>, not the relative
or symlinked path you typed on the command line.
keyctl setperm 0x3f030000 (used in §4 Mode 2) decodes as:
| nibble | bits | meaning |
|---|---|---|
3f |
possessor | view + read + write + search + link + setattr |
03 |
owner | view + read |
00 |
group | none |
00 |
other | none |
Possessor = "any process that has the key linked into a keyring it
holds". For most workloads on the same UID, leaving owner=view+read
is enough; processes do not need write to use a key, only to
modify it.
keyctl timeout <KEYID> <SECONDS> arms a kernel-side expiry. After
the deadline:
keyctl readreturnsKey has expired(errnoEKEYEXPIRED).keyctl_searchreturnsNo such file or directory.- The link itself stays until
keyctl gcruns or youunlinkit.
elfdec-run propagates EKEYEXPIRED up the same path as "key not
found" — the user-visible message is still keyctl_search ... No such file or directory. If a previously working key suddenly stops, check
keyctl rdescribe <KEYID> for an expired flag before assuming
something else broke.
A bare keyctl padd ... @s only lives for the current session
keyring. On a fresh SSH login, _ses is a new keyring and your key
is gone. Two ways to make the key survive across sessions for the
same UID:
# Option A: link an existing key into @u
keyctl link <KEYID> @u
# Option B: write directly into @u (makes the key "owned" by the user keyring)
keyctl padd user "elfdec:prod/myapp" @u < priv.binelfdec-ssh-pushkey.sh does Option A automatically (line 30 of the
script: keyctl link "$KEYID" @u) — that is why ELFDEC_LABEL works
from a separate SSH session after a single push, even though
elfdec:prod/myapp was added to the push-shell's @s.
keypushd --link-user (CLI flag in §6) is the same idea for the UDP
push path.
PAM with pam_keyinit.so (default on login, gdm, sshd) sets up
a fresh session keyring on each login. Headless / non-interactive
contexts that miss it:
| context | symptom | fix |
|---|---|---|
cron, at jobs |
keyctl_search: No such process |
call keyctl new_session first |
systemd services without User= |
keys land in @u for root, not @s |
use @u directly |
docker run (no --privileged) |
add_key: Operation not permitted |
run with --cap-add SYS_ADMIN or use Mode 4 (file-based) |
virtme-ng test guests |
session keyring is anonymous, padd-then-search across processes fails | known matrix-test caveat (see tests/run_kernel_matrix.sh) |
nspawn, chroot w/o new session |
inherits caller's @s (may surprise) |
explicit keyctl new_session |
elfdec-ssh-pushkey.sh calls keyctl new_session on the remote side
(line 26: keyctl new_session >/dev/null 2>&1 || true) for exactly
this reason — without it, a non-interactive ssh ... <command>
invocation with PermitTTY=no may not have a usable @s.
AC-03-09 needs systemd-run --user --scope -p MemoryMax=..., which
requires:
dbus-user-sessioninstalledpam_systemdactive in PAM (default on Ubuntu desktop / sshd)- user cgroup delegation enabled
Quick check:
cat /sys/fs/cgroup/user.slice/user-$UID.slice/cgroup.controllers
# Should include "memory". If the file does not exist, you have no
# user cgroup at all — typical inside containers and on `su -`-style
# shells without a logind session.
systemctl --user status # also a fast indicator: must say "running"
loginctl show-user $USER -p Lingerelfdec-run itself does not need any of this at runtime — only the
AC-03-09 test does.
| distro | core | optional (test only) |
|---|---|---|
| Ubuntu 22.04 / 24.04 | build-essential libsodium-dev libkeyutils-dev |
keyutils libssl-dev systemd-container |
| Debian 12 | same | same |
| RHEL / Fedora / Alma 9 | gcc make libsodium-devel keyutils-libs-devel |
keyutils openssl-devel systemd-container |
| Alpine | build-base libsodium-dev keyutils-dev linux-headers |
keyutils openssl-dev |
| Arch | base-devel libsodium keyutils |
(already in base) |
The runtime libraries are libsodium23 (or libsodium26) and
libkeyutils1. A statically linked elfdec-run (STATIC=1) needs
none of these on the target.
# Decode keypushd bootstrap JSON without jq
SRV_PK=$(sed -E 's/.*"srv_pk":"([^"]+)".*/\1/' bootstrap.json)
TOKEN=$(sed -E 's/.*"token":"([^"]+)".*/\1/' bootstrap.json)
# Find leftover /dev/shm fallback artifacts (D-16 cleanup audit)
find /dev/shm -maxdepth 1 -name 'elfdec-*' -printf '%p (%s bytes)\n'
# What labels are currently in the session keyring (filter ephrun keys)
keyctl list @s | awk '/elfdec:|elfdec_caps:/'
# Force a fresh session keyring for one command (cron / nspawn)
keyctl session - bash -c 'keyctl padd user elfdec:prod/x @s < priv.bin'-
Linux end-to-end (
bash test.sh): the harness reportsTotal: 47, Passed: 47on a real host with logind + user cgroup delegation (Ubuntu / kernel 6.x). On a host wheresystemd-run --user --scopeis unavailable (no DBus user bus, headless container, sshd withpam_systemdoff), AC-03-09 fails and you seeTotal: 47, Passed: 46— that one is environmental, not an ephrun regression. Coverage: all four key-sourcing modes, capsule in keyring, KCAP3 wire format, AC-03-04 / AC-03-05 D-16 fallback, AC-03-07 env scrub (memfd path + ETXTBSY fallback path), AC-03-08 D-19 LD_PRELOAD strip, AC-03-09 cgroup OOM (kernel-conditional per D-20), D-18 ptrace defense, plus negative tests (wrong code, corrupt ELFENC, missing key, wrong keypair). -
Cross-platform (
make -C ephrun check):kcap_kdf_testends withALL TESTS PASSED(≈69 individualok:assertions, range varies as cases are added). Covers: KCAP3 round-trip + fixture decrypt + AAD mutation rejection + D-15 floor/ceiling rejection + KCAP2/KCAP1 fixture decrypt + wrong code + Argon2id timing sanity + non-KCAPmagic rejection. Linux and macOS produce identical results against the same fixture (AC-01-D-02 / AC-01-D-03). -
Kernel matrix (
tests/run_kernel_matrix.sh): v5.15, v6.1, v6.5, v6.8 under virtme-ng. Fromtests/KERNEL_MATRIX_REPORT.md:kernel total pass fail notes v5.4 — — — skipped: kernel image lacks virtio-serial v5.10 — — — skipped: kernel image lacks virtio-serial v5.15 47 43 4 environmental baseline only v6.1 47 43 4 environmental baseline only v6.5 47 43 4 environmental baseline only v6.8 47 43 4 environmental baseline only The 4 failures are uniform across all completed kernels and are virtme-ng environmental limits (no logind for keyring sessions, no systemd-run for AC-03-09), not ephrun bugs. No kernel-version regression detected on the syscall surface ephrun relies on. AC-03-09(c) is kernel-conditional per D-20: pre-6.x kernels SIGKILL before
crypto_pwhashreturns ENOMEM, which the test treats as expected.
The error strings below are taken from elfdec-run.c's xdie calls
(which append : <strerror> for the saved errno). The bracketed
errno hint is the libc translation you will see appended.
capsule decrypt failed (Cannot allocate memory)
Argon2id at M=64 MiB exceeded a cgroup memory ceiling. On kernels
< 6.x this typically arrives as a SIGKILL instead — the cgroup
OOM-killer fires before libsodium's crypto_pwhash can return
ENOMEM. Documented in D-20. Raise MemoryMax (cgroup) or remove
the limit; 96 MiB is the documented headroom that passes
test.sh:617-630.
capsule decrypt failed (Permission denied)
The AEAD verify failed — wrong ELFDEC_CODE or a tampered capsule.
Indistinguishable from "capsule corrupted" at this layer. Re-pack
the capsule from the original priv.bin on the builder.
capsule decrypt failed (Invalid argument)
The capsule failed structural validation before the KDF ran. Causes
include: wrong family magic, unknown version byte, non-zero
flags (reserved-must-be-zero per D-13 / AC-01-A-04), KDF params
outside the D-15 floor/ceiling range, or ct_len not matching the
buffer size. Re-pack with current kcap_pack.
refusing to run under tracer (TracerPid=N)
D-18 fired — /proc/self/status reported a non-zero TracerPid
(e.g. running under strace, gdb -p, or another ptrace
attach). For dev work set ELFDEC_ALLOW_TRACE=1; the variable
itself is in the D-17 scrub list and will not leak to the workload
(test.sh:572-580). Defeated by root via /proc manipulation —
this is bar-raising, not a security guarantee (SECURITY.md §4
item 7).
fexecve failed (Text file busy) then a successful run
Not actually a failure path the user sees — elfdec-run catches
ETXTBSY and falls through to the hardened /dev/shm route
(D-16): copy → open RO → close writer → unlink → execveat AT_EMPTY_PATH. Workload runs from an inode with zero filesystem
references. Any other fexecve errno (ENOEXEC, EACCES, ...) is
fatal — no plaintext is written to /dev/shm. On kernels < 3.19,
execveat is ENOSYS and the fallback fails closed.
open /dev/shm/elfdec-NNN: File exists
A previous elfdec-run crashed before unlink. Safe to remove
manually: rm -f /dev/shm/elfdec-*. The O_EXCL open is a TOCTOU
mitigation, not a leak — there is nothing readable in those files
unless a crash hit before the unlink.
no private key available
None of the four key-sourcing tiers found anything. Diagnostic
checklist:
ELFDEC_CODEset but no capsule?elfdec-runprintswarning: ELFDEC_CODE set but no capsule found (keyring or <path>/{capsule.bin,capsule.json}). Verify$ELFDEC_KEYPATH/capsule.binexists or the keyring entryelfdec_caps:<ELFDEC_LABEL>is present.ELFDEC_KEYIDinvalid printswarning: invalid ELFDEC_KEYID '<value>'orwarning: ELFDEC_KEYID read failed: <reason>.- File mode wrong on
priv.bin?elfdec-runprintsopen <path>: Permission denied. Checkchmod 600.
Workload sees ELFDEC_* or LD_PRELOAD in /proc/self/environ
Diagnostic for "is the env scrub working?". The expected answer is
no — D-17 strips ELFDEC_CODE / KEYID / LABEL / KEYPATH / CAP / ALLOW_TRACE, D-19 strips LD_PRELOAD / LD_LIBRARY_PATH / LD_AUDIT / LD_DEBUG / LD_PROFILE / GCONV_PATH / HOSTALIASES / LOCPATH / NLSPATH / DYLD_INSERT_LIBRARIES / DYLD_LIBRARY_PATH / BASH_ENV / ENV / NODE_OPTIONS / PYTHONSTARTUP / PERL5OPT / PERL5LIB / RUBYOPT / RUBYLIB / _JAVA_OPTIONS / JAVA_TOOL_OPTIONS / CDPATH / GLOBIGNORE from the workload's envp (elfdec-run.c:151-186). If
any of these appear in the workload, you are running an outdated
elfdec-run — rebuild from current source. PATH and other
unrelated variables pass through (test.sh:365-369).
bad magic
The .elfenc file's first 8 bytes are not ELFENC1\0. The file
is corrupt, truncated, or never went through elfenc_pack. Re-encrypt.
fexecve failed (Exec format error)
The plaintext that came out of crypto_box_seal_open is not a
valid ELF for the host architecture. Either the source binary was
built for a different architecture, or the wrong pub.bin was
used to encrypt (the decrypt verified but produced garbage —
unlikely with sealed boxes, possible if you mixed up keys). Verify
with file hello && ./elfenc_pack pub.bin hello hello.elfenc
on the builder using a host-matching binary.
/lib/x86_64-linux-gnu/libc.so.6: version 'GLIBC_2.NN' not found
The dynamic elfdec-run was built against a newer glibc than the
target has. Common when shipping from Ubuntu 24.04 (glibc 2.39) to
22.04 (glibc 2.35) or older. Rebuild with make -C ephrun STATIC=1
and ship the static binary; same fix applies if the host is missing
libsodium23 / libkeyutils1 entirely.
add_key: Operation not permitted (in containers)
A rootless / unprivileged container cannot add keys to its session
keyring. Either grant --cap-add SYS_ADMIN to the container, run
the container with --privileged, or fall back to Mode 4 (file-based)
which does not need the keyring at all.
keyctl_search ... No such process (in cron / at / systemd)
The job has no session keyring because no PAM session was set up.
Wrap the command in keyctl session - <command> (creates a fresh
session keyring scoped to the child) or call keyctl new_session
before the keyctl operations. See §7 "When the keyring has no
session at all".
keyctl padd ... Disk quota exceeded
The kernel's per-user keyring quota (/proc/sys/kernel/keys/maxbytes,
/proc/sys/kernel/keys/maxkeys) is full. Usually the result of a
long-running daemon leaking key links. keyctl list @u to see what
accumulated; keyctl unlink or keyctl revoke the dead ones. The
defaults (200 keys, 20000 bytes per UID) are tight on shared hosts.
Build failures.
fatal error: sodium.h: No such file or directory— installlibsodium-dev.fatal error: keyutils.h: No such file or directory— installlibkeyutils-dev. On RHEL / Fedora the package iskeyutils-libs-devel.cannot find -lkeyutilsat link time — same package missing.makefinishes butkcap_unpackis missing — you are on a top-level Makefile from before the kcap_unpack target was added; eithergit pullor runmake -C ephrun kcap_unpackdirectly.
- Threat model details —
SECURITY.mdis the authoritative source for what the system protects against and what it does not. - Wire formats —
SPEC.md§"Wire formats" for ELFENC1, KCAP3, KCAP2, KCAP1. - Architectural rationale per decision —
DECISIONS.mdD-1 through D-20.