Federation worker holds no secret files; secrets arrive over inherited fds¶
- Status: accepted
- Deciders: James Chapman
- Date: 2026-09-21
Technical Story: 0.12.13 security audit, Critical/High findings — finding N1 (the out-of-process federation worker can recover the operator master key, and from it the server signing secret and access-token HMAC keys, even though it never uses them today).
Context and Problem Statement¶
The federation worker (merovingian-fed-worker) is a separate, more exposed
process specifically so that a compromise of it does not hand an attacker the
Matrix signing secret (ADR-0015). Before this change it still opened the
operator master-key file itself, at startup (WorkerEventLoop::run()
called crypto::load_master_key_material then crypto::derive_ipc_auth_key)
so it could authenticate the IPC handshake with main. That file is the root
secret every other protected key is derived from: the secret-box key that
encrypts server_signing_keys.secret_key at rest
(crypto::signing_secret_box_key) and the v3/v4 access-token HMAC keys
(crypto::derive_token_hmac_key[_v3]). No worker-reachable code path actually
calls those derivations today (GET /_matrix/key/v2/server and
GET /_matrix/federation/v1/openid/userinfo are always served on main by
FederationProxy::handle, and the worker always installs a non-null
crypto_provider signing_override, so ensure_runtime_server_signing_key
is never reached inside it) — but the worker had the file open on disk with
no code-level reason to, which is worse than needed for a process whose whole
purpose is to hold as little trust as possible, and one bug in that reasoning
(a future worker code path that calls one of those derivations, or an
operator-visible route accidentally proxied to the worker) would silently
turn "the worker can't reach it" into "the worker already had it".
It also started a full HomeserverRuntime with the same database
credentials as main and no filesystem sandbox beyond seccomp (which does not
restrict file paths, only syscalls), so removing the master-key file access
alone does not make the worker fully untrusted — it closes one of three
related gaps.
Decision Drivers¶
- A process whose entire purpose is holding minimal trust should not hold a root secret it has no code-level need for, even if no current code path uses it.
- Whatever replaces file access must not weaken the existing mutual authentication the IPC key already provides (ADR unnumbered, #318) — the worker must still prove it holds something main issued it, not accept an unauthenticated channel.
- The fix must not require the worker to re-derive anything from a hash of a file it should not be reading; it must receive the already-derived key material, verifiably, exactly once.
- Future parts (separate DB credential; Landlock) depend on the same delivery mechanism (inherited fd at spawn) established here, so the mechanism should generalise rather than being a one-off pipe hack.
Considered Options — overall 3-part design¶
This finding is fixed in three parts, landing as separate commits/PRs. This ADR records the full design; parts 1 and 2 have shipped as of this record's last update (part 2: 0.12.13, same branch).
- (This ADR, shipped 0.12.13) Secrets arrive over inherited fds, not files. Main derives the IPC auth key once and hands the worker only those 32 bytes over a second inherited pipe fd at spawn time. The worker never opens the master-key file.
- (Shipped 0.12.13, this update) A separate, least-privilege worker
database login. A second PostgreSQL role
(
federation.worker.database_uri_file), read by main and handed to the worker the same way (an inherited fd, not a file the worker opens), whose grants excludeSELECTonserver_signing_keysand the other credential-bearing tablesdatabase::table_load_profile_includesexcludes. - (Shipped 0.12.13, this update) Linux Landlock filesystem restriction.
The worker calls
landlock_restrict_selfto deny filesystem access outside what it genuinely needs (its own SQLite database directory, if any, plus a fixed set of best-effort OS-integration paths; nothing else — never the master-key file, either database URI file, or TLS private keys). If the running kernel lacks Landlock support, the worker refuses to start unless the operator setsfederation.worker.allow_without_landlock=true, which logs CRITICAL on every start — mirroring the fail-closed seccomp policy in ADR-0041. Before this landed, a worker compromised through a memory-safety bug (not merely one abusing an intentional file-open) could still open the master-key file directly off disk despite no code path asking it to.
Options considered and rejected, for part 1¶
- Status quo: worker reads the master key file itself. Rejected — this is the finding; see Context above.
- A separate on-disk IPC key file, written by main and read by the worker
by path (
federation.worker.ipc_key_fileor similar). Rejected: this still leaves a secret sitting on disk with a path the worker's config carries, reintroducing a smaller version of the same class of problem (a file the worker can be tricked into reading again, e.g. after a config-reload bug, or that persists across a crash for another process to read). An inherited fd has no path, cannot be reopened after the handshake, and is closed automatically if the worker never starts (nothing to clean up). - Pass the key as a command-line argument or environment variable. Rejected
outright: both are visible to any other process on the host via
/proc/<pid>/cmdlineor/proc/<pid>/environ(root, or same-UID with appropriate permissions), which is a strictly worse exposure than an fd only the two processes involved ever hold. ADR-0042 already rejects environment variables for this exact reason for the worker's environment generally. - SET ROLE-based database separation instead of a second login (bears on
part 2, recorded here since it was evaluated alongside this finding).
Rejected: a session that can
SET ROLEto a restricted role can alsoRESET ROLEback to the login role that granted it, so a compromised worker holding the original login credential gains nothing from a restricted role it can trivially undo. Only a distinct login credential the worker process never has in the first place is a real boundary. - Best-effort Landlock (log a warning and continue if unavailable), instead
of fail-closed (bears on part 3, recorded here for the same reason).
Rejected for the same reason ADR-0041 rejected best-effort seccomp: an
operator who cannot tell "sandboxed" from "silently unsandboxed" from the
logs will not notice until it matters. The explicit
allow_without_landlock=trueescape hatch keeps old kernels usable without making the gap silent.
Decision Outcome¶
Chosen option for part 1: secrets arrive over inherited fds, not files.
homeserver::WorkerPool's constructor derives the IPC auth key once, from
the operator master-key file, for the whole pool (not once per shard/restart).
homeserver::WorkerSupervisor::spawn_and_connect — reused for every spawn and
every automatic restart — creates a pipe2(O_CLOEXEC) pipe, writes exactly
the derived key bytes into it and closes its own write end
(homeserver::make_worker_key_pipe). The read end stays FD_CLOEXEC in
main: main is multithreaded, and clearing the flag in the parent would let
any concurrent spawn (another shard's restart, the thumbnail decoder) inherit
the pipe carrying the key. Instead a posix_spawn_file_actions_adddup2 places
it at the fixed kWorkerIpcKeyFd (4) in the child, which clears FD_CLOEXEC
on the child's copy only, and --ipc-key-fd 4 is passed alongside the
existing --ipc-fd 3. The helper keeps the source fd off both fixed numbers,
so the dup2 can neither be clobbered by the ipc socket's dup2 nor degenerate
into a same-fd dup2 (which some libcs treat as a no-op that leaves the flag set).
The worker (federation_worker::read_ipc_auth_key, src/federation_worker/ipc_key_fd.cpp)
reads exactly that many bytes, requires EOF immediately afterward (rejecting
a short or long write rather than truncating or padding it), and closes the
fd. WorkerEventLoop::run() then calls federation_worker::clear_master_key_file
on its own in-memory config copy — emptying security.secrets.master_key_file
— before calling homeserver::start_runtime(), so no runtime code path
inside the worker (today's or a future one) has a path string to pass to
crypto::load_master_key_material even if it tried.
Key bytes live in core::SecretBuffer on the main side (mlocked, zeroised);
the intermediate crypto::IpcAuthKey value used to build the pipe write and
the per-process IpcChannel is itself self-zeroising (its destructor calls
sodium_memzero), consistent with core/AGENTS.md.
Positive Consequences¶
- The worker process, at the OS level, no longer has the master-key file open at any point in its lifetime — a compromise that reads worker process memory or inspects its open file descriptors finds only a derived, single-purpose key, never the root secret.
- The change generalises: parts 2 and 3 reuse "main reads/derives the sensitive thing once and hands the worker only what it needs over an inherited fd" as the same pattern, rather than inventing a new mechanism per finding.
WorkerPoolderiving the key once instead ofWorkerSupervisorderiving it on every spawn/restart also removes N-1 redundant master-key-file reads andsodium_mlock/munlockpairs for an N-shard, M-restart deployment.
Negative Consequences¶
- Two inherited fds to manage per worker spawn instead of one, including a
fd-numbering hazard (the pipe's read end could coincidentally land on the
fixed
kWorkerIpcFdnumber) thatspawn_and_connectnow defends against explicitly, mirroring the pre-existing defence forserver_fd. - This part alone does not make the worker fully untrusted with respect to the master key's effects: it still shares main's database credentials (part 2 closes this) and has no filesystem sandbox beyond seccomp's syscall restrictions (part 3 closes this). See docs/threat-model.md, "Operator master key reachable from the federation worker", for the running record of which gap is closed as of a given version.
Decision Outcome, part 2¶
Chosen option for part 2: a separate, least-privilege PostgreSQL login, delivered the same inherited-fd way as part 1, plus a load profile so the worker never hydrates the excluded material into memory regardless of which credential it holds.
homeserver::worker_supervisor.cpp's make_worker_key_pipe is generalised
into make_worker_secret_pipe(secret, reserved_fds) — the same construction
(pipe2(O_CLOEXEC), stays FD_CLOEXEC in the multithreaded parent, relocated
past every reserved fd via F_DUPFD_CLOEXEC so it can never land on one) now
parameterised over an arbitrary set of reserved child fd numbers instead of
the two part 1 hardcoded. make_worker_db_uri_pipe is the new caller,
reserving kWorkerIpcFd (3), kWorkerIpcKeyFd (4), and the new
kWorkerDbUriFd (5); make_worker_key_pipe itself is unchanged in name,
signature, and behaviour. WorkerPool::WorkerPool reads
federation.worker.database_uri_file once (mirroring the master-key-material
derivation immediately above it) and hands each WorkerSupervisor its own
core::SecretBuffer copy; spawn_and_connect passes --db-uri-fd <n> on
argv and adds a third adddup2 file action, but only when a separate URI
actually applies — a SQLite backend or the
allow_shared_database_credentials=true opt-out mean neither the flag nor
the pipe exist at all, so the degraded mode is explicit in the child's argv,
not an accidental silent fallback. The worker
(federation_worker::read_worker_database_uri) reads until EOF — the URI has
no fixed length, unlike the 32-byte auth key — bounded to 4096 bytes,
rejecting empty or oversized results, and
federation_worker::apply_worker_database_uri sets the worker's own config
copy's database.worker_conninfo_override and clears
uri_file/runtime_role/migration_role, so the restricted login connects
directly with its own grants and never attempts a SET ROLE onto roles it
was never made a member of.
Independently, database::TableLoadProfile::federation_worker
(RuntimeStartOptions::database_load_profile, set unconditionally by
WorkerEventLoop::run()) makes open_postgresql_persistent_store's row
loader an allowlist: database::federation_worker_table_allowlist
(include/merovingian/database/persistent_store.hpp) names exactly nine
tables — rooms, membership, current_state, events, event_edges,
event_auth, event_signatures, room_aliases, and (column-restricted)
server_signing_keys — derived by tracing every non-relayed
FederationRuntimeState callback to the PersistentStore field it actually
reads; every table absent from the array is excluded, so a future
migration's new table is unreadable by the worker until someone adds it
deliberately. See docs/database-persistence.md, "Federation worker
least-privilege role", for the full per-table citation and the complete
classification of all 54 tables migrations/*.sql creates. This is
deliberately a second, independent mechanism from the separate login: it
means the worker never pulls unlisted material into its own process memory
even in the allow_shared_database_credentials=true degraded mode, and it
is what makes the separate role's restricted grants actually usable —
without it, the row loader's unconditional SELECT statements for every
other table would make the worker fail to start under a role that cannot
read them.
server_signing_keys needed a column-level answer, not a table-level one:
the worker's remote-key cache genuinely reads and writes other servers'
rows in this table (federation::remote_key_cache_probe/
remote_key_resolver), so excluding the table entirely would have broken
that feature, but the table also holds this server's own secret_key
column — the exact thing this part exists to protect. load_persistent_rows
uses a worker-specific 4-column query that never names secret_key, and
provision-federation-worker-role.sql grants
SELECT (server_name, key_id, public_key, valid_until_ts) — PostgreSQL
column-level privileges — so the database itself, not just the C++ query,
refuses secret_key to this role.
Options considered and rejected, for part 2¶
- SET ROLE-based database separation instead of a second login. Already
rejected above (recorded alongside part 1's own rejected options, since it
was evaluated at the same time): a session that can
SET ROLEto a restricted role can alsoRESET ROLEback to the login role that granted it, so a compromised worker holding the original login credential gains nothing from a restricted role it can trivially undo. - A denylist excluding only the obviously secret-bearing tables, with the
SQL script granting
SELECTonALL TABLES(plusALTER DEFAULT PRIVILEGESfor future tables) and thenREVOKEing those few. This was the part-2 design as first implemented, and was rejected on review: both halves are fail-open. A future migration's new table is automatically readable by the worker role (default privileges) and automatically hydrated into worker memory (the C++ predicate returnstruefor anything not explicitly denied) unless someone remembers to add it to the deny side — exactly backwards from a least-privilege boundary, where an unrecognised table should be inaccessible until someone deliberately grants it. Replaced with the allowlist described above:GRANT SELECTis per table, noALTER DEFAULT PRIVILEGESand noGRANT ... ON ALL TABLESappear in the script at all, and the C++ predicate returnsfalsefor anything not infederation_worker_table_allowlist. - Grant the worker role broad SELECT and rely only on the load profile to
protect
server_signing_keys. Rejected: the load profile is enforced entirely in this project's own C++ code, not by PostgreSQL. A future bug that constructsRuntimeStartOptionswithout settingdatabase_load_profile(a new worker code path, a test harness, an embedder) would silently fall back toTableLoadProfile::fulland load every table anyway — the database-level column grant onserver_signing_keys, and the plain absence of aGRANTfor every other table, is what makes that failure mode impossible rather than merely unlikely. The two mechanisms are deliberately redundant, not substitutes for each other, andtests/unit/test_worker_db_uri.cppasserts they name the same table set so they cannot drift apart silently. - Grant
INSERT/UPDATE/DELETEon the allowlisted tables, matching:runtime_role's own DML grants. Rejected: the worker's ownPersistentStoreis a read-only snapshot for room-scoped federation reads, never the system of record (src/federation_worker/AGENTS.mdrule 3) — every write the worker's federation routes need is relayed to main over IPC.provision-federation-worker-role.sqlgrantsSELECTonly, strictly less than:runtime_role. One dormant exception was found on review:state_conflict_resolver(wired, never overridden by the worker) writescurrent_stateif a relayedpdu_sinkresponse ever carried a populatedstate_conflict— which the IPC deserializer never populates today, so the path is unreachable in practice. It was deliberately left as is rather than "fixed":current_stateis already allowlisted for reads, and a SELECT-only grant makes any future write attempt through this path fail closed (a permission error) instead of silently succeeding, so the invariant holds even if the unreachability is ever broken by accident. Seedocs/database-persistence.md, "Federation worker least-privilege role".
Positive Consequences, part 2¶
- A PostgreSQL-backed worker compromised through anything short of arbitrary
filesystem access (part 3's remaining gap) cannot read
server_signing_keys.secret_key,access_tokens.token_hash, or any of the 44 other non-allowlisted tables via SQL at all — the database itself refuses the query, independent of what this project's own code does or fails to do. - The allowlist shape means the security posture of a future migration's
new table is "worker cannot read it" by default, requiring a deliberate
two-file change (
federation_worker_table_allowlistand aGRANT SELECTline) to widen access — the opposite failure mode from the denylist this replaced, where a new table was readable by default. - The load profile is backend-agnostic and applies even in the
allow_shared_database_credentials=truedegraded mode: a worker sharing main's login still never pulls unlisted tables into its own process memory, narrowing what a memory-disclosure bug in the worker can expose even without a separate role provisioned. - The generalised
make_worker_secret_pipemeans part 3, if it ever needs to hand the worker a third kind of secret material over an inherited fd, reuses the same reserved-fd-set parameterisation rather than a fourth bespoke pipe helper.
Negative Consequences, part 2¶
- A third inherited fd and a third
posix_spawn_file_actions_adddup2to manage per worker spawn, conditionally present depending on config — slightly more branching inspawn_and_connectthan a fd that is always passed. load_persistent_rowsnow wraps every one of its ~40 per-tableSELECTblocks in atable_load_profile_includescheck, including the ones that are always true forTableLoadProfile::full— more boilerplate per block than the denylist's seven guarded exceptions, in exchange for the fail-closed property above.federation_worker_table_allowlist(C++) andprovision-federation-worker-role.sql's per-tableGRANT SELECTlist are two separate files that must name the same table set; a unit test (tests/unit/test_worker_db_uri.cpp, tag[worker_db_uri]) parses both from the source tree and fails the build if they diverge, so this is enforced by CI rather than resting on the cross-references each leaves in its own comments.- SQLite deployments get no role-level improvement from this part — a single shared file offers no boundary to separate — and the load profile there reduces (but does not eliminate: the file itself remains fully readable by the worker process) how much credential material sits resident in worker memory. See docs/threat-model.md.
Decision Outcome, part 3¶
Chosen option for part 3: Linux Landlock, applied before the worker seccomp filter, with a fail-closed unavailable-kernel gate matching ADR-0041.
platform::apply_worker_landlock()
(include/merovingian/platform/landlock_hardening.hpp,
src/platform/landlock_hardening.cpp) is called from
federation_worker::main() immediately after the config file is parsed and
both inherited fds are validated as open, and immediately before the
existing apply_worker_hardening() call that installs the worker seccomp
filter (src/federation_worker/main.cpp). It:
- Queries the running kernel's Landlock ABI version via
landlock_create_ruleset(nullptr, 0, LANDLOCK_CREATE_RULESET_VERSION)— the methodlandlock(7)documents for this purpose. A return< 1means Landlock is unavailable (ENOSYSon kernels older than 5.13,EOPNOTSUPPwhen Landlock is disabled at boot, e.g. via thelsm=boot parameter) and is handled by the fail-closed/opt-out gate below. - Builds a
handled_access_fsmask covering every filesystem right this project ever restricts (read, write, execute, create, remove, rename-refer, truncate), downgraded to whatever bits the reported ABI version actually defines (platform::landlock_handled_access_fs) — dropping unsupported rights bits for an older-but-supported kernel is correct Landlock practice; refusing to run there is not (see "Options considered and rejected" below). - Calls
landlock_create_rulesetwith that mask, then onelandlock_add_ruleperplatform::LandlockPathRulein the listplatform::build_worker_landlock_rules(config)builds from the worker's own config copy (never a second, drifting hard-coded list) — see "Allowlist derivation" below — thenlandlock_restrict_self. - Every step past the ABI-availability query is unconditionally fatal on
failure, independent of
federation.worker.allow_without_landlock: that opt-out means "this kernel has no Landlock", never "Landlock is present but broken". A required rule (the SQLite database directory) failing to add — including the directory not existing — is fatal; a best-effort rule (every OS-integration path) failing to add is logged and skipped, since those vary by distribution and their absence degrades a specific outbound capability rather than indicating a broken sandbox.
The real landlock_create_ruleset/landlock_add_rule/landlock_restrict_self
calls sit behind an injectable platform::LandlockHardeningOps function
table, issued via raw ::syscall() rather than a libc wrapper (glibc did not
gain wrapper functions for these until 2.38, and the kernel/glibc pair this
project ships against must not be assumed to have them) — the same pattern
media::DecoderHardeningOps established, so every fail-closed path is unit
tested (tests/unit/test_worker_landlock.cpp, tag [worker_landlock])
without actually restricting the test process. A forked scenario in the same
file applies a real ruleset in a child process (mirroring
tests/integration/test_seccomp_sqlite_flow.cpp's fork pattern, needed
because landlock_restrict_self is irreversible for the calling process'
lifetime) and asserts a path outside the allowlist is refused with EACCES
while a path inside it still opens — or skips cleanly when the running
kernel has no Landlock support.
Seccomp ordering¶
Landlock is applied before the worker seccomp filter
(apply_worker_hardening(), which still runs immediately afterward in
main()), not after. landlock_create_ruleset, landlock_add_rule, and
landlock_restrict_self are Linux syscalls the worker seccomp allowlist
(k_worker_allowed_syscalls, src/platform/seccomp_hardening.cpp) does not
otherwise need — they run exactly once, at startup, before Landlock
irreversibly denies them anyway (Landlock does not restrict its own syscalls
from being called again, but there is never a reason to call them twice).
Two orderings were available:
- Apply Landlock first (chosen): the worker seccomp allowlist never has to carry three syscalls that are only ever used once, at startup, and never again — keeping that allowlist's contents an accurate description of what the worker's steady-state runtime needs, which is what a future reader auditing it for "why is this syscall allowed" should find.
- Apply Landlock after seccomp, with the three Landlock syscalls added to
k_worker_allowed_syscalls. Rejected: this permanently widens the worker's post-restriction syscall surface for a capability it only ever legitimately uses in the single-digit-millisecond window between exec andlandlock_restrict_self. It also couples the two controls' allowlists together for no benefit — Landlock's own fail-closed gate above already guarantees the ruleset either applies successfully or the worker never reaches the code that would need those syscalls again.
The worker also inherits the main server's filter. A seccomp filter
survives execve, so when the main server runs with its own seccomp filter
(the production configuration), the worker starts already bound by main's
allowlist, before it installs its own. The three Landlock syscalls are
therefore on main's allowlist too (k_seccomp_filter,
src/platform/seccomp_hardening.cpp); without them the worker's first
landlock_create_ruleset killed it under SECCOMP_RET_KILL_PROCESS, which
only the full-hardening startup test (test_server_startup_hardening_flow.cpp)
caught — the federation-worker integration tests spawn the worker from an
unfiltered test process. Allowing them in main grants nothing: Landlock can
only ever narrow the caller's filesystem access.
Kernel contract¶
Two kernel rules the injected-ops unit tests cannot model, each of which stopped the worker starting until fixed (0.12.13):
- A rule on a non-directory may carry only file-level rights
(
EXECUTE,WRITE_FILE,READ_FILE,TRUNCATE); requestingREAD_DIRon/etc/resolv.confisEINVAL.real_add_rulefstats the sameO_PATHfd it passes to the kernel and trims the rights withplatform::landlock_access_for_inode, so there is no gap between checking the inode type and adding the rule. landlock_restrict_selfrequiresno_new_privs(orCAP_SYS_ADMIN), otherwiseEPERM. The worker's seccomp step sets it, but later and only whenapply_hardeningis on, soapply_worker_landlocksetsPR_SET_NO_NEW_PRIVSimmediately beforelandlock_restrict_self; a failure there is fatal regardless of the opt-out. The worker never execs, so this costs nothing.
The forked real-kernel test skips only when the kernel reports no Landlock
ABI; any other refusal is a failure (it previously skipped on every refusal,
which hid the EPERM).
Allowlist derivation¶
platform::build_worker_landlock_rules() builds two kinds of rules from the
worker's own config copy — see platform::LandlockPathRule::required:
- Config-derived, required. When
database.backend=sqlite, the directory containingdatabase.sqlite_path(not just the file) is grantedplatform::LandlockAccess::read_write. WAL mode creates-wal/-shmsiblings and a rollback journal is created and removed around each write transaction — none of which exist on a first boot — so the rule must cover directory-levelMAKE_REG/REMOVE_FILE, not just the main file. A PostgreSQL-backed worker gets no filesystem rule for the database at all: it is reached over the network socket ADR-0062 part 2's separate URI authenticates, which Landlock does not restrict (Landlock governs filesystem actions only). - Fixed, best-effort.
http::detect_system_ca_trust()(src/http/outbound_client.cpp), which the worker's relay pool calls on its first outbound federation request, probes eight candidate CA-bundle file paths and four candidate CA-bundle directory paths across the distributions this project ships on — reading the source directly (rather than re-deriving the list independently) is what the rule set's certificate-store entries (/etc/ssl/certs,/etc/ssl/cert.pem,/etc/pki/tls/certs,/etc/pki/ca-trust,/usr/share/ca-certificates, and related entries, plus the OpenSSL config files) are taken from, plus the directories Debian/Ubuntu's per-certificate symlinks under/etc/ssl/certscommonly resolve into. Not the whole of/etc/sslor/etc/pki: those trees hold the conventional TLS private-key directories (/etc/ssl/private,/etc/pki/tls/private), and a read grant on the parent would have exposed any key an operator kept there. Name resolution (getaddrinfo, used to reach federation peers) reads glibc NSS configuration —/etc/resolv.conf,/etc/hosts,/etc/nsswitch.conf,/etc/gai.conf,/etc/host.conf— and lazilydlopen()slibnss_dns.so/libnss_files.sofrom the platform's shared-library directories, which are grantedplatform::LandlockAccess::read_executesommap(PROT_EXEC)of the library file succeeds. Timezone data (/usr/share/zoneinfo,/etc/localtime) rounds out the list. These arerequired = false: a missing candidate on a given distribution is a normal, expected outcome (this project ships on Debian, Fedora, the BSDs, and others, each with a different subset present), not a broken sandbox, so it is skipped rather than refusing to start.
Static analysis of src/http/outbound_client.cpp and glibc's documented
NSS/getaddrinfo resolution path was the primary source for this list.
/proc/self/* and /dev/urandom were considered and excluded:
crypto:: and libsodium's own randomness sourcing use getrandom(2)
directly on Linux (no /dev/urandom fd, unlike the NetBSD path recorded
in docs/adr/ for the fd-sweep fix), and no worker code path reads
/proc/self/*.
Options considered and rejected, for part 3¶
- Best-effort Landlock (log a warning and continue if unavailable), instead of fail-closed. Already rejected above (recorded alongside parts 1 and 2's own rejected options, since it was evaluated at the same time as this finding): mirrors ADR-0041's rejection of best-effort seccomp — an operator who cannot tell "sandboxed" from "silently unsandboxed" from the logs will not notice until it matters.
- Refuse to run on any kernel reporting an ABI version below this
build's
k_landlock_max_known_abi, instead of downgrading the requested rights mask to what the reported (possibly lower) ABI supports. Rejected: Landlock is explicitly designed for incremental adoption —landlock(7)'s own guidance is to request the rights the running kernel supports, not to require a specific version. A kernel reporting ABI 1 (Linux 5.13, noREFERorTRUNCATErights) still meaningfully restricts the worker to its allowlisted paths for every right ABI 1 defines; refusing to start there over two rights this project's rule set does not depend on losing would make the fail-closed gate needlessly aggressive, the same category of mistake the "any other Landlock failure is always fatal" rule above avoids in the other direction. - A single flat allowlist with no
required/best-effort distinction — every path either fatal-if-missing or silently-ignored-if-missing. Rejected: collapsing the two loses the actual security property each is for. The SQLite database directory not existing is a genuine misconfiguration the worker should refuse to start under (silently ignoring it would mean the worker starts, then fails unrelatedly and confusingly the first time it opens the database); a CA-bundle candidate from a distribution this worker is not running on is expected and must not block startup.platform::LandlockPathRule::requirednames the distinction explicitly per rule rather than encoding it as "which list this path happens to be in." - Grant the worker's own binary and its shared-library dependencies
read_executeindividually (resolved via/proc/self/exeandldd-style introspection at startup), instead of the fixed library-directory list. Rejected: the worker's own executable and its link-time shared libraries are already mapped into the process image before Landlock is applied (ELF loading happens atexecve(), beforemain()runs), so Landlock never needs to grant access to them — only libraries loaded after the restriction point viadlopen()(NSS modules, TLS engines) need a rule, and those come from a small, well-known set of system library directories rather than needing runtime introspection.
Positive Consequences, part 3¶
- A federation worker compromised through a memory-safety bug — not merely one abusing an intentionally-opened file — can no longer open the operator master-key file, either database URI file, or TLS private keys directly off disk, closing the residual gap parts 1 and 2 recorded. Combined with part 2, a PostgreSQL-backed worker compromised this way has no filesystem path to any of this server's cryptographic secrets and no database credential beyond its own least-privilege role.
- The
required/best-effort split means a distribution this project has not been explicitly tested on (a CA-bundle path in a different location, a non-multiarch library layout) degrades a specific outbound capability (TLS verification, name resolution) rather than refusing to start entirely — the same fail-open-on-the-unimportant-bits, fail-closed-on-the-security-boundary split ADR-0041 already established for the seccomp/Landlock unavailability gate itself.
Negative Consequences, part 3¶
- The fixed OS-integration path list (
build_worker_landlock_rules) is a static, best-effort approximation of whatgetaddrinfo/libcurl/OpenSSL actually open on any given distribution, not a runtime-derived one; a distribution whose CA bundle or NSS library lives somewhere entirely outside the listed candidates degrades outbound federation (TLS verification or name resolution failing) rather than the worker refusing to start, which can be a harder failure mode to diagnose than an explicit startup refusal. The strace-based derivation above is intended to keep this list accurate for the primary distributions this project packages for (packaging/), not to be exhaustive for every possible host. - Two independent fail-closed gates (seccomp's
apply_hardeningand Landlock'sallow_without_landlock) now exist for the worker, with different scopes and different opt-outs — an operator readingdocs/hardening.mdneeds to understand both are independent, since disabling one does not disable the other (deliberately; see "Seccomp ordering" above). federation.worker.allow_without_landlock=trueon a kernel that genuinely lacks Landlock leaves the worker exactly as exposed as it was before this part shipped — an operator who sets it without reading why loses the improvement this part provides, mitigated only by theCRITICALlog line on every start.
Links¶
- ADR-0015 — the signing-secret analogue of this decision; not superseded by this ADR, since it addresses a different secret (the signing key, delegated via IPC sign-request/sign-response, never handed to the worker in any form) via a different mechanism (a signing oracle, not a one-shot key handoff).
- ADR-0041 — the fail-closed precedent part 3's Landlock gate follows.
- ADR-0042 — established that secrets must not reach the worker via environment variables; this ADR extends the same reasoning to command-line arguments and to files the worker would otherwise open by path.
docs/threat-model.md, "Operator master key reachable from the federation worker"docs/hardening.md, "Out-of-process federation worker IPC security"docs/crypto-boundary.mddocs/database-persistence.md, "Federation worker least-privilege role" (part 2)packaging/postgresql/provision-federation-worker-role.sql(part 2)tests/unit/test_worker_landlock.cpp, tag[worker_landlock](part 3)CHANGELOG.md, 0.12.13 (part 3)
Secret-exposure guard¶
A rule on a directory grants everything beneath it, so "no secret path is
in the rule set" is not enough: the required read-write grant on the SQLite
database's directory would silently expose a master key an operator placed
beside the database. At startup, before applying the ruleset, the worker
checks every rule against platform::worker_landlock_secret_paths() (the
master key, both TLS private keys, both database URI files, and the
registration token file) with platform::find_landlock_rule_covering_secret,
and refuses to start if any rule equals or is an ancestor of one. Paths are
compared by component after std::filesystem::weakly_canonical, matching
how the kernel resolves the rule's path, so a symlink cannot hide an overlap
and /etc/merov never covers /etc/merovingian. The refusal applies even
with allow_without_landlock: it is a secret-placement error, not a kernel
capability question. The shipped layout (secrets in /etc/merovingian, the
database in /var/lib/merovingian) passes it.