ExBao
An OpenBao client for Elixir: Transit, AppRole, and a supervised token that renews itself before it expires.
OpenBao is the Linux Foundation fork of HashiCorp Vault. This client targets OpenBao's own API and is tested against every supported release — see Compatibility.
Installation
- Add
ex_baoto your dependencies inmix.exs:
def deps do
[
{:ex_bao, "~> 0.1"}
]
end
- Add the token server to your supervision tree. It authenticates on start and keeps the token alive, so nothing else in your application has to know a token exists:
def start(_type, _args) do
children = [
{ExBao.TokenServer, name: MyApp.Bao}
]
Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor)
end
Configuration
Configuration is read from the environment first, then from application config. The environment wins, so a release can be pointed at a different server without rebuilding it.
Environment variables:
BAO_ADDR— the server, e.g.https://bao.internal:8200BAO_TOKEN— a token, when you already have oneBAO_ROLE_IDandBAO_SECRET_ID— AppRole credentials, when you do notBAO_CACERT— path to a PEM bundle that signs the server's certificateBAO_NAMESPACE— namespace to send with every request, if you use them
Application variables, for whatever the environment does not set:
config :ex_bao,
addr: "https://bao.internal:8200",
auth: {:approle, role_id: "...", secret_id: "..."},
# Renew once this much of the lease is gone. 0.7 renews a 60s lease at 42s,
# which leaves room for a failed attempt and a retry before it expires.
renew_after: 0.7,
# Passed straight to Req, which passes them to Finch.
receive_timeout: 5_000,
retry: :safe_transient
TLS options go under :connect_options, which reaches Finch unchanged:
config :ex_bao,
connect_options: [
transport_opts: [
cacertfile: "/etc/ssl/certs/bao-ca.pem",
verify: :verify_peer
]
]
Verification is on by default. Turning it off is a per-client option and never a global one, so a test that needs a self-signed certificate cannot quietly disable it for production too:
client = ExBao.Client.new(addr: "https://localhost:8200", verify: false)
Usage
Authenticating
The token server does this on start and again whenever the lease is running out. You only call it directly when you are managing the token yourself:
iex> ExBao.Auth.AppRole.login(client, role_id: "db02de0...", secret_id: "6a17...")
{:ok, %ExBao.Auth{token: "s.wOrM...", lease_duration: 2_764_800, renewable: true}}
Errors are tagged, never bare strings, so a caller can match on the one it knows how to handle:
iex> ExBao.Auth.AppRole.login(client, role_id: "nope", secret_id: "nope")
{:error, %ExBao.Error{status: 400, kind: :invalid_credentials, messages: ["invalid role or secret ID"]}}
Sealing and opening
Transit encrypts without ever handing you the key. What comes back carries
the key version on the outside — vault:v1: — so a rotated key can open what
an older one sealed:
iex> ExBao.Transit.encrypt(server, "payout", "00912345620")
{:ok, "vault:v1:Xo0En3wRbT5urbzKrsXFAhXUmjkqumptoErLc3NIZ9bskIycK3l6"}
iex> ExBao.Transit.decrypt(server, "payout", "vault:v1:Xo0En3wRbT5urbzKrsXFAhXUmjkqumptoErLc3NIZ9bskIycK3l6")
{:ok, "00912345620"}
Base64 is handled for you in both directions: you pass and receive the plain value, not an encoding of it.
Doing it in batches
One round trip instead of one per item, which matters when a page renders a list. Every element comes back wrapped, whether anything failed or not:
iex> ExBao.Transit.encrypt_batch(server, "payout", ["00912345620", "3001234417"])
{:ok, [ok: "vault:v1:Xo0En3w...", ok: "vault:v1:9dK2lsP..."]}
iex> ExBao.Transit.decrypt_batch(server, "payout", ["vault:v1:Xo0En3w...", "vault:v1:corrupted"])
{:ok, [ok: "00912345620", error: %ExBao.Error{kind: :invalid_ciphertext}]}
The outer :ok does not mean the work succeeded — it means the request was
made and here are the results. What failed is inside, where whoever iterates
has to see it. One corrupt row does not blank the page.
split/1 separates the two sides:
iex> {sealed, failed} = ExBao.Transit.encrypt_batch(server, "payout", values) |> ExBao.Transit.split()
Results are positional, so the fifth answer belongs to the fifth value. That
holds — but it is fragile to depend on, since anything that filters or
reorders on the way breaks it silently. :references removes the dependency
by having the server echo a label back with each result, errors included:
iex> ExBao.Transit.decrypt_batch(server, "payout", ciphertexts, references: ids)
{:ok, [{"dest-42", {:ok, "00912345620"}}, {"dest-77", {:error, %ExBao.Error{}}}]}
iex> ... |> ExBao.Transit.split()
{[{"dest-42", "00912345620"}], [{"dest-77", %ExBao.Error{kind: :invalid_ciphertext}}]}
Rotating a key
Rotation adds a version; it does not invalidate the old one. rewrap re-seals
an existing ciphertext under the newest version without the plaintext ever
leaving the server:
iex> ExBao.Transit.rotate(server, "payout")
:ok
iex> ExBao.Transit.rewrap(server, "payout", "vault:v1:Xo0En3w...")
{:ok, "vault:v2:Qm5tRa0..."}
Set the minimum version allowed for decryption once every row has moved:
iex> ExBao.Transit.set_min_decryption_version(server, "payout", 2)
:ok
Managing keys
iex> ExBao.Transit.create_key(server, "payout", type: :aes256_gcm96)
:ok
Sealing under a key that does not exist creates it — that is OpenBao's
behaviour, so a typo in a key name does not fail, it quietly makes a second
key. avoid_create_on_missing: true reads the key first and fails with
:not_found instead, at the cost of one round trip:
iex> ExBao.Transit.encrypt(server, "payoutt", "00912345620", avoid_create_on_missing: true)
{:error, %ExBao.Error{kind: :not_found}}
The other guard is a policy that does not grant create on transit/keys/*
to the application. They are not alternatives: the option catches it where
it happened, the policy catches it in code that forgot to ask.
iex> ExBao.Transit.read_key(server, "payout")
{:ok, %{type: "aes256-gcm96", latest_version: 2, min_decryption_version: 1, keys: %{1 => ..., 2 => ...}}}
iex> ExBao.Transit.list_keys(server)
{:ok, ["payout", "totp"]}
Compatibility
Every release is tested against each supported OpenBao series by running the integration suite inside a container that holds that exact server. A cell is green because the tests ran and passed there, not because the API looked similar.
| OpenBao | Transit | AppRole | Batch | Rewrap |
|---|---|---|---|---|
| 2.6.2 | ✅ | ✅ | ✅ | ✅ |
| 2.5.x | — | — | — | — |
| 2.4.x | — | — | — | — |
A dash means not measured yet, not "does not work". The matrix is written and has only ever run against 2.6.2; the other rows fill in when it runs. A table that claimed green on a version nobody tested would be the same kind of guess this project exists to avoid.
Changes to OpenBao's published OpenAPI specification are tracked in
priv/openapi/, one file per version. A diff there says what to look at
when a new release lands; the matrix above says what actually broke. They
are not the same question, and only the second one is an answer.
Running the tests
$ mix test # unit only. No Docker, no network, runs anywhere.
$ mix test.all # adds the integration suite, which starts its own OpenBao.
The integration suite uses Testcontainers: it boots the server, waits until it answers, and tears it down afterwards. Nothing is left running when a test crashes.
On Linux and macOS the Docker socket is where the library looks for it.
On Windows it is not — Docker Desktop speaks over a named pipe — so
install Testcontainers Desktop once. It writes the tc.host the
library reads and exposes a local endpoint, after which mix test.all runs
from the host like anywhere else.
In a container instead
$ docker compose -f docker-compose.test.yml run --rm test
Elixir in a Linux container with the host's Docker socket mounted in, so the servers the suite starts are siblings of it rather than children. Useful for pinning the Elixir version, and for a machine where installing anything else is not an option.
Against a server you already have, skipping containers entirely:
$ BAO_TEST_ADDR=http://127.0.0.1:8200 BAO_TEST_TOKEN=root mix test.all
A note on hackney
mix deps.get reports CVEs in hackney, which arrives through
testcontainers → tesla. It is a test-only dependency: it is not in the
published package, and nothing in this library uses it — the client runs on
Req/Finch. Nobody who installs ex_bao pulls it in.
Notes
On the token server
ExBao.TokenServer is the reason this library exists as more than a wrapper
around HTTP calls. It authenticates on start, renews at renew_after of the
lease rather than on expiry, and re-authenticates from scratch if a renewal is
refused — a token can be revoked, and a renewal loop that only knows how to
renew will spin forever against a token that will never come back.
It does not cache secrets. A client that caches has to decide when to stop trusting the cache, and that decision belongs to the caller who knows what the value is for.
On what is not here yet
This is an alpha. Transit and AppRole are implemented by hand because they are the ones worth designing. The rest of OpenBao's API — 292 paths and 444 operations as of 2.6.2 — will be generated from the server's own OpenAPI specification rather than transcribed from another client, so that it stays honest across versions.
Releasing
$ mix hex.publish