..

Bitwarden command-line wrapper for rbw and ansible

I have a clunky solution to a very niche problem, so I might as well make a blog post out of it.

Picture this situation : you are using Bitwarden as your password manager, whether the official one, or a self hosted version using Vaultwarden. You deploy things with Ansible, and your ansible code lives in a git repository. But now you either need to encrypt your secrets using ansible vault, or enter each of your secrets with prompts.

Finally you end up on the ansible lookup plugin community.general.bitwarden which allows you to fetch secrets directly into your bitwarden vault. You install the command line client and you’re off to the races.

Life is good, your secrets can be deployed without being hardcoded, or without requiring a human to enter them at each deployment. But if you pay close attention, you might feel something happening. Something almost imperceptible at first, but over time, as the amount of secrets in your roles and playbooks increase, it can’t be ignored anymore.

The bw CLI is slow as heck.

I’m not downplaying this : each execution of the CLI is about one second, and the lookup plugin actually executes the CLI twice per secret retrieved. If you are a good sysadmin and you execute your playbooks in check-mode first (ansible’s dry-run), then those seconds start to add up pretty fast. In the worst case scenario, if you have more than 10 secrets to retrieve for one file, you might lose your sanity.

Gotta go fast

There is one unofficial bitwarden command-line client that is written in rust named rbw. This tool has a lot of upsides compared to bw : it’s a lot faster, the unlocking mechanism for your vault uses a service instead of an environment variable, so you can switch terminals and still have access to your unlocked vault. All good.

Here is a hyperfine benchmark just to see the difference.

michel@laptop:~$ hyperfine "bw get item 38bdfc89-277e-4c79-be8d-76481879415b"
Benchmark 1: bw get item 38bdfc89-277e-4c79-be8d-76481879415b
  Time (mean ± σ):      2.884 s ±  0.116 s    [User: 1.625 s, System: 0.301 s]
  Range (min … max):    2.774 s …  3.128 s    10 runs

michel@laptop:~$ hyperfine "rbw get --raw 38bdfc89-277e-4c79-be8d-76481879415b"
Benchmark 1: rbw get --raw 38bdfc89-277e-4c79-be8d-76481879415b
  Time (mean ± σ):      23.9 ms ±   1.4 ms    [User: 6.2 ms, System: 7.6 ms]
  Range (min … max):    21.0 ms …  27.6 ms    129 runs

The maintainer made a choice that bites though : the interface is not compatible with bw at all, most command arguments are different, and their outputs too. So you cannot make it a drop-in replacement of bw.

# Most of the fields have been removed, this is a lot more verbose
michel@laptop:~$ rbw get --raw my_secret
{
  "id": "38bdfc89-277e-4c79-be8d-76481879415b",
  "name": "my_secret",
  "data": {
    "username": "root",
    "password": "hunter2",
    "uris": []
  },
}
michel@laptop:~$ bw get item my_secret | jq
{
  "object": "item",
  "id": "38bdfc89-277e-4c79-be8d-76481879415b",
  "name": "my_secret",
  "login": {
    "uris": [],
    "username": "root",
    "password": "hunter2",
  },
}

Let’s wrap this up

My solution to this was to create a wrapper in bash with a very simple logic. We read the parameters passed to the wrapper, if this looks like a lookup command, we pass this out to rbw and make a few changes in the output so that it looks like the output of bw. The other command that we wrap is the bw status command where we check the unlocked status of rbw instead of bw. For any other command, we pass it to the official bw.

And then to make this work we rename the official bw, and make sure our wrapper is in our $PATH and named bw (here I put everything in ~/.local/bin) :

michel@laptop:~$ ls ~/.local/bin
bw          # our wrapper
bw_official # the official bw cli, but renamed
rbw         # the rbw cli, unchanged

And here is an implementation of the wrapper :

#!/usr/bin/env bash

# If the command is "status", get the status of rbw and exit
if [ "$1" == "status" ]; then
  rbw unlocked >/dev/null 2>&1
  if [[ $? -eq 0 ]]; then
    echo '{"status": "unlocked"}'
    exit 0
  else
    echo '{"status": "locked"}'
    exit 0
  fi
fi

# If the command is "list get item xxx", use rbw and exit.
if [[ "$#" -eq 3 ]] && [[ "$1" == "get" ]] &&
  [[ "$2" == "item" ]]; then

  readonly item_name="${3}"
  output=$(rbw get --raw "${item_name}" 2>/dev/null)

  if [[ $? -eq 0 ]]; then
    echo "${output}" | jq '.["login"] = .data | del(.data)'
    exit 0
  fi
fi

# For everything else, run bw_official
bw_official "$@"
readonly bw_official_exit_code="$?"

# Synchronize rbw after any possible changes.
rbw sync

exit "${bw_official_exit_code}"

The wrapper requires only pure bash functions, as well as jq, which is a (really nice) cli tool to parse and modify json data. It should be available in your favorite package manager.

A tangent on deployment and onboarding

I’m not a sole sysadmin in my team, and once I got this thing to work, I got a different kind of problem to solve : if this became the new de-facto way of deploy things with ansible and secrets in my team, how could I make sure everyone could also set this up without too much hassle ?

This setup is kind of close to the linux philosophy : specialised tools used for their strengths, and composed together as scripts or pipelines. But this philosophy clashes strongly with the need to onboard less experienced engineers in the team. Letting them set that up by hand and help them troubleshoot any issue that they might find will definitely eat the time of everyone which in turn creates friction and lower morale.

My current solution is to use a set of ansible playbooks to deploy things locally on their laptops, and in a ~/.$COMPANY_NAME dir, so that it does not clash with any of the other tools that they might use personally.

Maybe there would be an argument to package this differently. Maybe in a docker container, maybe in an in-house command-line tool that encompasses a lot of internal uses like this one. We could also definitely code our own ansible lookup plugin that discusses with our bitwarden server.