47 — KeePass presence-unlock (YubiKey-gated lab.kdbx, 2026-07-18)¶
Let the agent open lab.kdbx on ctrl only while the operator is present, gated by
the operator's YubiKey, with no password typing — replacing the manual
kdbx-unlock master-password drop. The operator "arms" once per session (FIDO2 PIN +
touch); for a short TTL after that the agent can read lab.kdbx; then it expires or is
disarmed. The master password is encrypted at rest under the YubiKey and only ever exists
in RAM on ctrl during the armed window.
Why this shape.
lab.kdbxlives on ctrl but the YubiKey lives on the operator's workstation (atomic). A token op must therefore produce the password on ctrl. Rather than forward the token to ctrl (the ssh-agent + PIV/PKCS#11 path — tried and rejected as too finicky:ykcs11hides PIV retired slots,openscrefused to load into ssh-agent), the operator decrypts locally on atomic and ships the password over the existingssh sysadmintrust. The crypto is a purpose-built age plugin (age-plugin-fido2-hmac) — no ssh-agent, no PKCS#11, one binary + one file. It stays entirely out of the PIV applet, so the login key (9A) and the offline user/host CAs (9C/9D) are untouched. Break-glass — the manual master-password entry — still works unchanged; this is a convenience layer, not a new dependency.
Components¶
On atomic (the operator's workstation; scripts in scripts/kdbx/, installed to
~/.local/bin and ~/.config/kdbx/):
- age (Fedora-packaged, pure-Go) + age-plugin-fido2-hmac (release binary, ~/.local/bin).
- ~/.config/kdbx/identity-{A,B}.txt — one FIDO2 identity per YubiKey (require-PIN +
separate-identity; non-discoverable credentials, so the identity files are required
to decrypt — back them up).
- ~/.config/kdbx/master.age — the lab.kdbx master password encrypted to both keys'
recipients (age multi-recipient → either key decrypts).
- scripts/yubikey-tool.sh arm-kdbx [LABEL|off] (menu K, or 3 on the intent screen) —
the arm/disarm front, integrated into the YubiKey tool 2026-09-14 so every token operation
lives in one place (it is the one action there that is not a signature: FIDO2 applet only,
9a/9c/9d untouched). scripts/kdbx/arm-kdbx is a shim onto it that keeps the bare
arm-kdbx [a|b|off] command working; make-master.sh stays standalone (one-time setup).
The tool's --deps reports age, the plugin, and whether master.age + identities are here.
On ctrl (run as claude-agent; ~/kdbx/):
- kdbx-arm-receive — reads the password on stdin (over ssh sysadmin) → writes it to a
tmpfs credential /dev/shm/kdbx-master.cred (mode 600, dies on reboot).
- kdbx-get — emits the MASTER password iff a fresh (< KDBX_TTL, default 1800 s) arm exists;
⚠ it is NOT a field reader — its arguments are ignored, and it returns the same value
for any input (mistaken for an entry-reader on 2026-08-15, it transmitted the master
password as an API bearer token; the tell for the class: a credential reader returning
identical output for a bogus entry name is not reading what you asked). It also emits
no trailing newline — read(1) returns nonzero at EOF while still filling the
variable, so consume as IFS= read -rs PW || [ -n "$PW" ] under set -e.
Reading an ENTRY field = kdbx-cli show -s -a Password "<entry title>" — the -s is
load-bearing: without it keepassxc-cli prints the literal 9-char placeholder PROTECTED,
which is not the value;
else wipes the stale copy and fails closed.
- kdbx-disarm — wipe now.
- kdbx-cli — wrapper: kdbx-get | ssh almalinux@ctrl 'keepassxc-cli <cmd> <lab.kdbx>'
(the DB is almalinux-owned; needs a loaded admin-ctrl task cert).
Daily use¶
# on atomic, when you want to let the agent into lab.kdbx:
scripts/yubikey-tool.sh arm-kdbx # try every token identity in label order (A, then B…)
scripts/yubikey-tool.sh arm-kdbx b # only that token (1 touch; pass the label you are holding)
scripts/yubikey-tool.sh arm-kdbx off # wipe early (or it auto-expires after the TTL)
# the same three as `arm-kdbx` / `arm-kdbx b` / `arm-kdbx off` via the shim, or menu K / 3
# agent side, on ctrl (only works while armed):
kdbx-cli db-info
kdbx-cli ls
kdbx-cli show "group/entry"
arm-kdbx with no label tries every identity-*.txt in label order (A first), so using
B costs one extra (wasted) touch on the A attempt — pass the label to skip it. This is
inherent to non-discoverable credentials (the plugin must touch to test each identity). Target
and paths are overridable (KDBX_DIR, KDBX_HOST — default the agent account on ctrl, the
same AGENT_HOST the tool deploys certs to); YK_DRY_RUN=1 prints the pipeline and runs nothing.
Build (reproduce from scratch)¶
- Install tools on atomic →
~/.local/bin:age(copy the Fedora-signed binary out of a toolbox, ordnf/download+verify) andage-plugin-fido2-hmac(release binary; pin+record its SHA256 — upstream publishes no checksum/attestation, so this is hash-pinned, not cryptographically verified). - Set a FIDO2 PIN on each YubiKey (required — hmac-secret credential creation is denied
without one):
ykman fido access change-pin. - Generate a credential per key (swap tokens between):
age-plugin-fido2-hmac -g > ~/.config/kdbx/identity-<A|B>.txt. Answer yes to "require a PIN for decryption" (two-factor) and yes to "separate identity (better privacy)". Recipient is the# public key:line in the file. - Encrypt the master password to every key:
scripts/kdbx/make-master.sh(prompts twice, reads the recipient from eachidentity-*.txt— it refuses with fewer than two, since either-token redundancy is the point — writes~/.config/kdbx/master.age). - Install the scripts (all version-controlled in
scripts/kdbx/): atomic needs nothing beyond the repo checkout — arming isscripts/yubikey-tool.sh arm-kdbx; for the barearm-kdbxcommand, symlinkscripts/kdbx/arm-kdbxinto~/.local/bin(a copy cannot locate the tool;MYOS_REPO=<checkout>is the fallback). ctrl getskdbx-arm-receive/kdbx-get/kdbx-disarm/kdbx-cliin~/kdbx/(currently placed by hand — TODO: deploy the ctrl scripts via thecontrolrole so a rebuilt ctrl gets them automatically). - Back up
master.age+identity-{A,B}.txtoff atomic (they are safe to escrow — useless without the physical YubiKey; but the identity files are required to decrypt, so losing them breaks arming). Current escrow:claude-agent@ctrl:~/kdbx/atomic-backup/(covered by ctrl's daily vzdump → rpool → off-box). Do not escrow the identity files only intolab.kdbx— that's a circular trap (you'd need them to open the DB that holds them).
Validate¶
arm-kdbx(A) → one PIN + one touch →kdbx armed …from ctrl;kdbx-cli db-infoprints the DB metadata.arm-kdbx b(B plugged) → same, proving redundancy.arm-kdbx off→kdbx-getthen fails "not armed"; the tmpfs cred is gone.- After
KDBX_TTL,kdbx-getrefuses and wipes the stale cred.
Security notes¶
- Presence = a short TTL, not "while the SSH session is live" — looser but far simpler. Tightening to connection-scoped wipe is a possible later refinement.
- No TPM for the password at rest — deliberately. TPM is presence-blind (unsealable
anytime on ctrl), which would defeat the operator-presence gate. The at-rest secret is
master.age, decryptable only with the physical YubiKey. - Password is RAM-only on ctrl (
/dev/shm), 600,claude-agent-owned, gone on reboot; never written to disk on atomic either. - FIDO2 device access works over SSH because the operator has a local graphical session
on atomic (systemd
uaccessACL on/dev/hidraw*), and the agent's commands run as the same user. - Hardening owed (surfaced during setup, operator's to do): YubiKey A still has the factory PUK; B still has the default PIV management key.