Unlocking SSH Keys With Bitwarden

Typing long SSH key passphrases is a hassle. bw-ssh solves that by keeping the passphrase in your Bitwarden (or in my case a self-hosted Vaultwarden) vault using the command-line client rbw and handing it to OpenSSH through the ssh-askpass interface. The private key stays were it is, only the passphrase gets stored.

This guide was tested on the following setup:

Some patches for rbw and bw-ssh were needed to get this setup working.

How it actually works

ssh or ssh-add need a passphrase. OpenSSH runs the tool configured at $SSH_ASKPASS which is bw-askpass from bw-ssh. This tool executes rbw get folder ssh <key filaname to get the passphrase from your Bitwarden vault.
bw-askpass derives the vault entry name from the key path in the prompt, so the entry must be named exactly like the key file (id_ed25519, not “GitHub key”).

Why not just use an agent?

An ssh-agent (or a desktop keyring agent) caches a decrypted key for the current session. After a reboot, a logout, or on another machine, you are back to typing the passphrase. bw-ssh changes where the secret it stored: it stays encrypted in the same vault as your passwords, is unlocked once per session, and is available to every machine that can reach that vault.

How to set it up

Install and configure rbw

paru -S rbw python-sh

rbw config set email you@example.com # configure rbw for your Bitwarden account
rbw config set base_url https://vault.example.com  # for self-hosted vaults

rbw register    # registers this device
rbw login       # stores an access token
rbw unlock      # unlocks the local database via PIN entry
rbw list        # check if everything works

rbw keeps the decrypted key in its agent for lock_timeout seconds (36000 = 10 hours by default).

Install the bw-ssh scripts

git clone https://framagit.org/Glandos/bw-ssh.git
install -Dm755 bw-ssh/bw-askpass ~/.local/bin/bw-askpass
ln -sf bw-askpass ~/.local/bin/bw-keygen

Make sure ~/.local/bin is in your PATH. Do not rename the symlink, bw-keygen only works because its name contains keygen.

Fix bw-ssh for python-sh 2.x

bw-keygen was written against python-sh 1.x. In version 2.0, a command call returns a plain str object; the RunningCommand object (with .exit_code and .stderr) is only returned with return_cmd=True. Without the fix, bw-keygen dies immediately with AttributeError: 'str' object has no attribute 'exit_code'.

You can find the fixed version for bw-askpass on my GitHub.

Fix rbw for Vaultwarden 1.37 (HTTP 422)

Vaultwarden 1.37 requires an encryptedFor field on cipher create and update; rbw 1.15.0 does not send it, so rbw generate, add, and edit fail with rbw generate: api request returned error: 422

Reads (list, get, sync, unlock) are unaffected, and deletes still work. There is already a PR for this open (doy/rbw#385), which unfortunately is not merged yet.

Get the patched rbw from my GitHub as well.

As a shortcut, the AUR package rbw-enhanced carries this fix plus a larger set of fork changes; it replaces the rbw package.

Create a key, or protect an existing one

For a new key, let bw-keygen generate the passphrase and store it:

bw-keygen -t ed25519 -f ~/.ssh/id_ed25519_work

This writes a 20-character passphrase to the vault entry id_ed25519_work in folder ssh (the folder is created on first write), then calls ssh-keygen -N <passphrase>. Use the exact ssh-keygen arguments you normally would; everything except -N is forwarded.

For an existing unencrypted key, store a passphrase and apply it:

cp -a ~/.ssh/id_ed25519 /tmp/id_ed25519.plain   # temporary backup
NEW=$(rbw generate --folder ssh 20 id_ed25519)
ssh-keygen -p -f ~/.ssh/id_ed25519 -P '' -N "$NEW"
ssh-keygen -y -f ~/.ssh/id_ed25519 -P "$NEW" >/dev/null && echo ok
rm /tmp/id_ed25519.plain

The key and the fingerprint do not change, so existing authorizations keep working. If the key already has a passphrase, store it instead of generating a new one (rbw add --folder ssh id_ed25519, or create the login item in the vault by hand).

Wire askpass into your shell

OpenSSH only uses SSH_ASKPASS when there is no TTY, unless SSH_ASKPASS_REQUIRE is set. Use prefer so it is used in terminals too.

Fish (~/.config/fish/config.fish):

set -gx SSH_ASKPASS bw-askpass
set -gx SSH_ASKPASS_REQUIRE prefer

Bash/Zsh (~/.bashrc / ~/.zshrc):

export SSH_ASKPASS=bw-askpass
export SSH_ASKPASS_REQUIRE=prefer

Run an SSH agent

ssh-add needs an agent. On systemd systems, enable the user socket:

systemctl --user enable --now ssh-agent.socket

Then point SSH_AUTH_SOCK at it.

Fish (~/.config/fish/config.fish):

if set -q XDG_RUNTIME_DIR
    set -gx SSH_AUTH_SOCK $XDG_RUNTIME_DIR/ssh-agent.socket
end

Bash/Zsh (~/.bashrc / ~/.zshrc):

export SSH_AUTH_SOCK="${XDG_RUNTIME_DIR}/ssh-agent.socket"

Open a new terminal afterwards.

Verify everything works

rbw unlocked
rbw get --folder ssh id_ed25519
ssh-add ~/.ssh/id_ed25519      # should read the passphrase from Bitwarden

ssh-add prints “Reading passphrase from Bitwarden vault for …”. That output is normal and a good indicator everything worked as expected.