Packages

An OpenBao client for Elixir: Transit, AppRole and a supervised token that renews itself before it expires.

Current section

Files

Jump to
ex_bao README.md
Raw

README.md

# ExBao

An [OpenBao](https://openbao.org) client for Elixir: Transit, AppRole, and a
supervised token that renews itself before it expires.

[![Hex.pm](https://img.shields.io/hexpm/v/ex_bao.svg)](https://hex.pm/packages/ex_bao)
[![Hex.pm](https://img.shields.io/hexpm/dt/ex_bao.svg)](https://hex.pm/packages/ex_bao)
[![Docs](https://img.shields.io/badge/hex-docs-blue.svg)](https://hexdocs.pm/ex_bao)

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](#compatibility).

## Installation

1. Add `ex_bao` to your dependencies in `mix.exs`:

```elixir
def deps do
  [
    {:ex_bao, "~> 0.1"}
  ]
end
```

2. 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:

```elixir
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:8200`
* `BAO_TOKEN` — a token, when you already have one
* `BAO_ROLE_ID` and `BAO_SECRET_ID` — AppRole credentials, when you do not
* `BAO_CACERT` — path to a PEM bundle that signs the server's certificate
* `BAO_NAMESPACE` — namespace to send with every request, if you use them

Application variables, for whatever the environment does not set:

```elixir
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:

```elixir
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:

```elixir
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:

```elixir
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:

```elixir
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:

```elixir
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:

```elixir
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:

```elixir
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**:

```elixir
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**:

```elixir
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:

```elixir
iex> ExBao.Transit.set_min_decryption_version(server, "payout", 2)
:ok
```

### Managing keys

```elixir
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:

```elixir
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.

```elixir

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

```console
$ 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][tc]: 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][tcd] 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

```console
$ 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:

```console
$ BAO_TEST_ADDR=http://127.0.0.1:8200 BAO_TEST_TOKEN=root mix test.all
```

[tc]: https://github.com/testcontainers/testcontainers-elixir
[tcd]: https://testcontainers.com/desktop/

### 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

```console
$ mix hex.publish
```