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).
- arm-kdbx, make-master.sh.
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 password iff a fresh (< KDBX_TTL, default 1800 s) arm exists;
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:
arm-kdbx # try key A then B (B alone costs an extra touch — see note)
arm-kdbx a | b # use only that key (1 touch; use `b` when B is plugged)
arm-kdbx off # wipe early (or it auto-expires after the TTL)
# agent side, on ctrl (only works while armed):
kdbx-cli db-info
kdbx-cli ls
kdbx-cli show "group/entry"
arm-kdbx with no selector tries identity-A first, so using B requires one extra
(wasted) touch on the A attempt — pass b to skip it. This is inherent to non-discoverable
credentials (the plugin must touch to test each identity).
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 both keys:
scripts/kdbx/make-master.sh(prompts twice, reads recipients from the identity files, writes~/.config/kdbx/master.age). - Install the scripts (all version-controlled in
scripts/kdbx/): atomic getsarm-kdbx(+make-master.sh) in~/.local/bin; 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.