Password managers are in the news, and it’s the holidays, so it’s as good a time as ever to describe my password and secret management setup. It’s very much not for everyone, but it’s minimal, simple, and has some interesting security properties: even if my laptop were compromised, it would take an attacker a very long time to extract more than a few low-importance secrets.

I use passage, a fork of password-store that encrypts files with age instead of GnuPG, along with age-plugin-yubikey by Str4d.

Storage and sync

passage, like pass, is effectively a script that makes it convenient to store and retrieve encrypted passwords and secrets from the command line. Each secret is stored in a separate file in $PASSAGE_DIR, which I keep synced with Syncthing and backed up with restic. Changes are automatically versioned with git.

$ passage generate -n example.com 20
[main de2abc4] Add generated password for example.com.
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 example.com.age
The generated password for example.com is:
EOUblkyPqAXHtKT963aP
$ passage -c example.com
Copied example.com to clipboard. Will clear in 45 seconds.

Security

Secrets are encrypted to two keys generated on two YubiKeys with the default settings of age-plugin-yubikey. These keys can’t be extracted or cracked by any attacker, regardless of how compromised my laptop or vault gets. One YubiKey is a USB-C Nano that lives in my laptop, the other one lives on my keychain. If (when!) one breaks, I can use the other to re-encrypt all files to the replacement.

age-plugin-yubikey # run interactive setup
age-plugin-yubikey --identity >> $HOME/.passage/identities
age-plugin-yubikey --list >> $HOME/.passage/store/.age-recipients

Since age-plugin-yubikey configures keys to require a PIN for every session, and PINs are limited to 3 + 3[1] attempts before locking, losing a YubiKey is pretty much a non-event. More importantly, since keys are configured to require touch at every use and each secret is encrypted separately, even a full compromise of my laptop wouldn’t let an attacker dump the whole vault at once: each secret needs a separate physical YubiKey tap to decrypt. I might not notice a decryption failure or two in daily usage, but I will definitely notice something’s off before I tap the YubiKey dozens of times.

Some secrets which I consider especially sensitive and are used rarely are saved in a subdirectory (called "Brooklyn Battery Tunnel", in case anyone catches the reference) and encrypted only to the keychain YubiKey.[2] If an attacker wants even one of those secrets, they will have to compromise my laptop and then wait for weeks until I plug in the higher-privilege YubiKey and tap it. If they hijacked the tap to decrypt a different secret, I would immediately notice that the secret I requested didn’t decrypt successfully.

Note that this is designed to fit my threat model: I trust my sync and storage backend, and I’m mostly interested in hardware-binding the secrets to the YubiKey, to rate-limit exfiltration and give myself a chance to recover from compromise.[3] If you don’t trust your storage, you’ll want to at the very least set $PASSAGE_RECIPIENTS_FILE to a path on the local system, instead of keeping .age-recipients files next to the encrypted files, where the storage provider could change them.

Recovery

The .age-recipients files also include the public key for an offline disaster recovery key. I generated the key with age-keygen, encrypted it with age -p, printed the ciphertext as a QR code, and wrote the random passphrase in pen. This is a bit convoluted, but I don’t trust printers. 🖨️🧐 All this was done in a tmpfs, so nothing reached storage. Only had to do this once, and have been using that key as the anchor for all my disaster recovery data.

I also periodically rsync the passage store to an SD card. That SD card along with a YubiKey is the starting point to reprovision a new laptop. This is kind of a hassle and I might move that role to iCloud Drive now that it’s end-to-end encrypted.

Mobile

Everyone asks what I do for mobile, and the thing is, I don’t really use passwords on my phone all that often. Most apps stay logged in forever with Face ID, as they should. I also like not carrying access to all my secrets on me, even if the iPhone is probably the most secure device I have.

When I need to log into an app, I generate a QR code with passage -q, scan it with the iOS camera app, and copy and paste it. It would be nice if there was an app that used the password management API to automate this, but this happens rarely enough that it’s not worth optimizing for me.

A screenshot of the iOS camera app scanning a QR code generated by passage

p / fzf

A big usability upgrade is a five-lines bash script that uses fzf to provide fuzzy search.

#! /usr/bin/env bash
set -eou pipefail
PREFIX="${PASSAGE_DIR:-$HOME/.passage/store}"
FZF_DEFAULT_OPTS=""
name="$(find "$PREFIX" -type f -name '*.age' | \
  sed -e "s|$PREFIX/||" -e 's|\.age$||' | \
  fzf --height 40% --reverse --no-multi)"
passage "${@}" "$name"

I have it on $PATH as p, and ⌘-0 brings up iTerm2, so I can do

⌘-0, “p -c”, ↩, “ex”, ↩, tap YubiKey, ⌘-0, ⌘-V

to copy and paste the password for example.com. It fits nicely in muscle memory.

Future work: yubikey-agent integration

I’m pretty happy with the setup as it is, but there’s one thing still on the roadmap: yubikey-agent integration. yubikey-agent is an SSH agent that uses the YubiKey PIV applet like age-plugin-yubikey. It has a native graphical PIN prompt on macOS, is compatible with all servers, and unlike gpg-agent doesn’t need restarting all the time. It also keeps a persistent connection to the PIV applet, which allows it to use the YubiKey’s PIN cache to require the PIN only once per session. Unfortunately, this connection keeps an exclusive lock on the applet which conflicts with age-plugin-yubikey.

For now, I solved it by adding killall -SIGHUP yubikey-agent to the p script, and everything works smoothly, despite having to type the PIN when I switch from SSH to passage.

Soon, I plan to make an age-plugin-yubikey-agent which talks to a running instance of yubikey-agent and uses the keys generated by age-plugin-yubikey. This way, I’ll basically never have to type the PIN.

The main blocker is figuring out the protocol to make age-plugin-yubikey-agent talk to yubikey-agent. One compelling option is to use an SSH agent protocol extension, but I need to make sure it doesn’t get forwarded by ssh -A by default. It would be pretty surprising if forwarding SSH authentications also forwarded age decryptions. OpenSSH recently added some support for detecting forwarded requests, so that might allow me to deny them for the age protocol, and maybe also add a confirmation prompt for forwarded SSH authentication that tells the user what host the remote is trying to authenticate to.

Bonus: using passage in scripts

A final tip: age-plugin-yubikey delegates the PIN prompt to age via the plugin protocol, and age has a pretty good terminal UI, so it can ask for the PIN even if used from a script and even if stdin and stderr are redirected.

This means I can use it from Python scripts such as offlineimap’s remotepasseval function

#! /usr/bin/env python2
from subprocess import check_output
def get_pass():
    return check_output(["passage", "credentials/offlineimap"]).splitlines()[0]

or with Ansible’s passwordstore plugin (after adding a pass alias for passage in $PATH)

- name: cache passwordstore access
  set_fact:
    secrets: "{{ lookup('passwordstore', 'credentials/fleet.yaml returnall=true') | from_yaml }}"

If this looks interesting, there’s a script for migrating from password-store in the passage README, and I always welcome UX reports (positive or negative) as GitHub Discussions.

To get updates on all these projects, you can subscribe to this newsletter below or follow me at @filippo@abyssdomain.expert!

The picture

This is a "succo d’erba" from the early 1900's, painted with natural pigments on tapestry cloth. My 95 years old grandfather shipped this to me. It used to hang in his father's office, then in his own, and it now hangs in mine.

The side of a room, at the bottom there's a sofa, above it a large painting. It shows two tigers in the grass, with a painted frame.


  1. Three for the PIN, three for the PUK, which are set to the same value. ↩︎

  2. To do that, it’s sufficient to put an .age-receipients file in the subdirectory, and it overrides the root one. ↩︎

  3. One of my unpopular opinions is that if you are not aiming for hardware-binding and trust your storage, storing passwords unencrypted is fine. An attacker that can extract arbitrary files from my laptop must have already compromised me, and an attacker that has compromised me can just keylog whatever vault password. Anyway. ↩︎