systemd_creds
systemd_creds
¶
Wrap systemd-creds(1) for the machine-bound passphrase tier.
systemd-creds
seals a credential against the local machine's TPM2 and/or the host
key under /var/lib/systemd/credential.secret. Decryption only
works on the same machine — moving the encrypted blob to another host
yields Failed to decrypt: Operation not supported.
The sandbox uses systemd-creds as the tier just below session-unlock in the SQLCipher passphrase resolution chain: machine-bound, no OS keyring required, survives reboots, no plaintext-on-disk.
Why this works for a non-root user. Both encrypt and
decrypt are always called with --user. In that mode a
regular shell invocation that detects geteuid() != 0 transparently
delegates the privileged half (reading credential.secret, talking
to the TPM) to PID 1 over the io.systemd.Credentials Varlink
interface; PID 1 returns the plaintext to the caller. No setuid,
no tss group, no sudo.
This requires systemd ≥ 257 (PR systemd/systemd#35536, merged
2024-12-20). Earlier releases lack the Varlink endpoint and the
--user decrypt path fails for non-root with Failed to determine
local credential key: Permission denied. is_available
gates on the version so the tier reports as unavailable rather than
failing at runtime.
Design choices that follow systemd-creds' intent.
--name=terok-sandbox.vault-passphraseis always set. systemd embeds the name in the sealed blob to prevent cross-purpose reuse ("a credential sealed for X must not decrypt as if it were Y"); an explicit namespaced name is the documented production pattern.- The default key mode (
KeyMode.AUTO→--with-key=auto) delegates the host-vs-TPM choice to systemd, which yieldshost+tpm2on TPM-equipped systems (defense in depth) and falls back to host alone on TPM-less hosts. We don't second-guess that decision — duplicating systemd's auto-detection in Python would drift over time and weaken the dual-factor default. - No PCR policy. Application-level credentials that need to survive kernel / UKI updates without operator intervention shouldn't bind to PCR values — Lennart's writing on PCR sealing targets disk encryption, not application secrets, where PCR brittleness costs too much (every kernel update would require re-sealing). The attacker that can boot another kernel can also read the encrypted DB plaintext when the legitimate operator unlocks the vault, so the PCR policy doesn't move the needle for our threat model.
The module is a thin subprocess shim — no Python binding exists for
systemd-creds and the CLI is the supported entry point. Tests stub
subprocess.run directly; production code stays trivial.
KeyMode = Literal['auto', 'host', 'tpm2', 'host+tpm2']
module-attribute
¶
__all__ = ['KeyMode', 'has_tpm2', 'is_available', 'seal', 'unseal']
module-attribute
¶
seal(passphrase, credential_path, *, key_mode='auto')
¶
Encrypt passphrase into credential_path under the namespaced
credential name terok-sandbox.vault-passphrase.
key_mode maps 1:1 onto systemd-creds --with-key=…:
"auto"(default) — systemd chooseshost+tpm2on TPM-equipped hosts andhostotherwise. Defense in depth on hardware that supports it, graceful fallback on hardware that doesn't."host+tpm2"— pin the dual-factor combination explicitly."tpm2"— TPM-only. Refuses to seal on a host without a TPM."host"— host-key only, no TPM dependency.
All seals go through --user so the credential is bound to the
calling UID + username + machine-id; PID 1's Varlink interface
handles the privileged half.
The encrypted blob is captured from systemd-creds' stdout and
written atomically via tempfile.mkstemp + os.replace — the
leaf is materialised at 0o600 from creation (no umask window)
and the rename never follows a symlink at the destination.
Symlinks at the parent or the leaf are refused outright.
Empty passphrase is rejected — sealing nothing produces a credential that decrypts to nothing, which the chain would treat as "tier empty" and silently skip.
Raises:
| Type | Description |
|---|---|
ValueError
|
passphrase is empty. |
RuntimeError
|
the binary is missing, too old, times out, or its subprocess fails for any other reason; the caller surfaces actionable hints. |
Source code in src/terok_sandbox/vault/store/systemd_creds.py
117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 | |
unseal(credential_path)
¶
Return the decrypted passphrase, or None if the credential isn't usable here.
Passes --user and the same --name= the credential was
sealed with, so a sealed blob can only be unsealed for its
intended purpose — systemd refuses cross-purpose decrypts even on
the same host.
Returns None rather than raising so the resolver can fall
through to the next tier on every failure mode: file missing,
systemd-creds absent or too old, host can't decrypt (e.g.
credential moved from another machine, TPM state changed), Varlink
socket unreachable, timeout, name mismatch.
Empty decrypt output is also collapsed to None — SQLCipher's
no-encryption sentinel must never reach the connection.
When running as in-namespace root (the rootless-Podman user
namespace the per-container supervisor lives in), the CLI can't
reach the host key / TPM, so the unseal is routed through PID 1's
io.systemd.Credentials Varlink interface instead — see
_outer_uid_if_userns_root.
Source code in src/terok_sandbox/vault/store/systemd_creds.py
is_available()
¶
Return True when systemd-creds is usable from a non-root caller.
Requires three things, in order from cheapest to most decisive:
- The binary on
PATH. - A host systemd ≥
_MIN_SYSTEMD_VERSIONso the non-root Varlink delegation path exists in the binary at all. - A live
io.systemd.CredentialsVarlink socket — present only when PID 1 is a recent enough systemd actually serving the interface. Containers and minimal-init systems pass (1) + (2) but fail (3); without this checkseal()would surface the opaqueFailed to connect to io.systemd.Credentialserror.
Source code in src/terok_sandbox/vault/store/systemd_creds.py
has_tpm2()
¶
Return True when the host has a TPM2 device usable by systemd-creds.
Mirrors systemd-creds has-tpm2's exit code. A preference
probe, not a precondition: a missing TPM doesn't break the tier —
host-key sealing still works in --user mode — so callers use
this to choose between TPM2 and host-key, not to gate availability.