openbao 0.2.0

Secure, typed, async Rust SDK for OpenBao
Documentation

OpenBao Rust SDK

openbao is a secure, typed, async Rust SDK for OpenBao, the community-driven open source fork of Vault. It is designed for audited secret workflows: HTTPS by default, no redirect forwarding, strict path validation, secret-aware token types, and a small reviewed dependency surface.

The crate name on crates.io is openbao; Rust imports are lowercase:

use openbao::Client;

This README documents the 0.2.0 release line. 0.2.0 expands the secure 0.1.0 core with token lifecycle helpers, KV v1, fuller KV v2 operations, mount management, response wrapping, ACL policies, capabilities, and a real OpenBao integration gate.

The crate is dual-licensed under MIT or Apache-2.0.

Current Status

Implemented now:

  • Async client with typestate authentication.
  • Direct token authentication with secrecy::SecretString.
  • AppRole login with secret-aware role ID, secret ID, token, and accessor handling.
  • Token create, lookup, accessor lookup/list, renew, revoke, and revoke-self helpers.
  • KV v2 read, write, CAS write, patch, list, latest delete, version read, version delete, undelete, destroy, metadata, and backend config helpers.
  • KV v1 read, write, delete, and list helpers.
  • System health and seal status helpers.
  • Secret and auth mount enable, list, read, tune, and disable helpers.
  • Response wrapping lookup, wrap, unwrap, and rewrap helpers.
  • ACL policy list, read, write, delete, and prefix list helpers.
  • Capability checks for the caller token, an explicit token, or a token accessor.
  • Raw JSON request escape hatch for endpoints that are not typed yet.
  • Local TLS OpenBao Podman stack on 9940 and 9941.
  • Real OpenBao integration test gate using the pinned OpenBao image.

Planned next:

  • 0.3.0: Transit, audit devices, safe lease helpers, and plugins catalog.
  • 0.4.0: PKI, Kubernetes auth, and TLS certificate auth.
  • 0.5.0: database secrets, JWT/OIDC, and userpass.
  • 0.6.0: SSH, TOTP, and explicitly gated init/unseal/rekey/rotate APIs.
  • 0.7.0: cubbyhole, identity, Kubernetes secrets, LDAP secrets, and RabbitMQ.
  • 0.8.0: remaining auth methods and broader system backend automation.

See API Coverage and Release Plan for the road to 1.0.0.

Trust Dashboard

Area Status
License MIT OR Apache-2.0
Rust edition 2024
MSRV Rust 1.95
Async runtime Runtime-agnostic client; examples use Tokio
HTTP transport reqwest with redirects disabled
Default TLS backend Rustls
TLS floor TLS 1.3 by default; TLS 1.2 requires explicit opt-in
Plain HTTP Rejected by default; numeric loopback IPs only when explicitly enabled
Token storage secrecy::SecretString
Unsafe policy unsafe_code = "forbid"
Path validation Rejects traversal, query/fragment injection, empty segments, controls, and trailing periods
Error posture API error strings are bounded and sanitized before formatting
Dependency policy cargo deny plus RustSec audit in the release gate
Release evidence fmt, clippy, tests, docs, deny, audit, SBOM, and real OpenBao integration
Pentest gate Required before tagging a release

Security details live in SECURITY.md. Release evidence and release sequencing live in release-notes and docs/RELEASE_PLAN.md.

Install

[dependencies]
openbao = "0.2"
secrecy = "0.10.3"
serde = { version = "1.0.228", features = ["derive"] }
tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread"] }

Some advanced examples below name transport and JSON helper types directly:

[dependencies]
reqwest = { version = "0.13.4", default-features = false, features = ["rustls"] }
serde_json = "1.0.150"

The crate defaults to the common SDK surface:

[dependencies]
openbao = { version = "0.2", features = ["approle", "token", "kv1", "kv2", "sys", "rustls-tls"] }

For a smaller build, disable defaults and opt into only what the application uses:

[dependencies]
openbao = { version = "0.2", default-features = false, features = ["kv2", "sys", "rustls-tls"] }

Features

Feature Default Purpose
approle yes AppRole authentication helpers.
token yes Token lifecycle helpers.
kv1 yes KV v1 secrets engine helpers.
kv2 yes KV v2 secrets engine helpers.
sys yes System backend helpers.
rustls-tls yes Rustls transport configuration.
native-tls no Legacy native TLS support. Audit before use; it may pull OpenSSL on some targets.

Support Matrix

Client, Transport, And TLS

Capability Status Notes
Async client Yes Built on reqwest with a small public API surface.
Typestate auth Yes Separate unauthenticated and authenticated client states.
HTTPS by default Yes Plain HTTP is rejected unless loopback HTTP is explicitly enabled.
Redirect protection Yes Redirect following is disabled to avoid forwarding token headers.
TLS floor Yes TLS 1.3 minimum by default; audited legacy deployments can opt down to TLS 1.2.
Custom CA roots Yes Extra root certificates can be merged with the platform trust store.
Root-only trust stores Yes System roots can be bypassed by using only configured root certificates.
Connection timeout Yes 5-second connection timeout by default; caller overrides are bounded.
User agent fingerprinting Yes Default user agent omits the exact crate version.
Namespace header Yes X-Vault-Namespace support for namespace-aware deployments.
Raw JSON requests Yes Escape hatch for endpoints that are not typed yet.

Authentication

Capability Status Notes
Direct token auth Yes Tokens are accepted as SecretString.
X-Vault-Token Yes Default documented OpenBao-compatible token header.
Bearer auth Yes Optional Authorization: Bearer header mode.
AppRole login Yes Role ID and secret ID are secret-aware; returned token is wrapped.
Token accessor handling Yes Accessors are treated as secret material.
Token lifecycle helpers Yes Lookup, accessor lookup/list, renew, revoke, revoke-self, and create helpers.
Kubernetes auth Planned Planned for 0.4.0.
TLS certificate auth Planned Planned for 0.4.0.
JWT/OIDC and userpass Planned Planned for 0.5.0.

Secret Engines

Capability Status Notes
KV v2 read/write Yes Typed serialization and deserialization.
KV v2 CAS write Yes Optional check-and-set version support.
KV v2 patch Yes JSON merge patch content type.
KV v2 list/delete versions Yes Metadata list, latest delete, soft delete, undelete, and destroy.
KV v2 metadata/config Yes Backend and per-key metadata helpers.
KV v1 Yes Read, write, delete, and list helpers.
Transit Planned Planned for 0.3.0.
PKI Planned Planned for 0.4.0.
Database credentials Planned Planned for 0.5.0.
SSH and TOTP Planned Planned for 0.6.0.
Identity and remaining engines Planned Planned for 0.7.0.

System Backend And Operations

Capability Status Notes
Health Yes Accepts OpenBao active, standby, sealed, and uninitialized health statuses.
Seal status Yes Typed /sys/seal-status helper.
Mount management Yes Secret and auth mount enable/list/read/tune/disable helpers.
Response wrapping Yes Lookup, wrap, unwrap, and rewrap helpers.
Policies and capabilities Yes ACL policy helpers plus self/token/accessor capability checks.
Audit devices Planned Enable/list/disable/hash in 0.3.0.
Lease helpers Planned Safe non-legacy lease endpoints in 0.3.0.
Init, unseal, rekey, rotate Planned Explicit safety documentation in 0.6.0.
Plugins, quotas, metrics, namespaces Planned Planned in the 0.8.0 operations line.

Examples

Create a client from an existing token:

use openbao::{Client, Result};
use secrecy::SecretString;

#[tokio::main]
async fn main() -> Result<()> {
    let token = SecretString::from(std::env::var("BAO_TOKEN").unwrap_or_default());
    let client = Client::new("https://bao.example.com:8200")?.with_token(token);

    let health = client.sys().health().await?;
    println!("openbao version: {}", health.version);
    Ok(())
}

Configure a stricter client with a namespace and root-only trust store:

use openbao::{Client, OpenBaoConfig, Result};
use reqwest::Certificate;
use secrecy::SecretString;

#[tokio::main]
async fn main() -> Result<()> {
    let ca_pem = std::fs::read("openbao-ca.pem")
        .map_err(|error| openbao::Error::InvalidTlsConfig(error.to_string()))?;
    let ca = Certificate::from_pem(&ca_pem)?;

    let config = OpenBaoConfig::new("https://bao.example.com:8200")?
        .namespace("admin/team-a")?
        .only_root_certificates(vec![ca])?;

    let token = SecretString::from(std::env::var("BAO_TOKEN").unwrap_or_default());
    let client = Client::from_config(config)?.with_token(token);

    let seal = client.sys().seal_status().await?;
    println!("sealed: {}", seal.sealed);
    Ok(())
}

Authenticate with AppRole:

use openbao::{Client, Result};
use secrecy::SecretString;

#[tokio::main]
async fn main() -> Result<()> {
    let client = Client::new("https://bao.example.com:8200")?;
    let role_id = SecretString::from(std::env::var("APPROLE_ROLE_ID").unwrap_or_default());
    let secret_id = SecretString::from(std::env::var("APPROLE_SECRET_ID").unwrap_or_default());

    let (client, login) = client.login_approle(role_id, secret_id).await?;
    let health = client.sys().health().await?;

    let _token_accessor = login.accessor;
    println!("openbao version: {}", health.version);
    Ok(())
}

Write and read KV v2 data:

use openbao::{Client, Result};
use secrecy::SecretString;
use serde::{Deserialize, Serialize};

#[derive(Deserialize, Serialize)]
struct DatabaseCredentials {
    username: String,
    password: SecretString,
}

#[tokio::main]
async fn main() -> Result<()> {
    let token = SecretString::from(std::env::var("BAO_TOKEN").unwrap_or_default());
    let client = Client::new("https://bao.example.com:8200")?.with_token(token);
    let kv = client.kv2("secret")?;

    kv.write(
        "production/database",
        DatabaseCredentials {
            username: "app".to_owned(),
            password: SecretString::from("change-me"),
        },
    )
    .await?;

    let secret = kv
        .read::<DatabaseCredentials>("production/database")
        .await?;

    println!("username: {}", secret.data.username);
    let _password_is_not_logged = secret.data.password;
    Ok(())
}

Use KV v2 check-and-set, patch, and version operations:

use openbao::secrets::kv2::{Kv2MetadataOptions, Kv2WriteOptions};
use openbao::{Client, Result};
use secrecy::SecretString;
use serde::Serialize;

#[derive(Serialize)]
struct Patch {
    password: SecretString,
}

#[tokio::main]
async fn main() -> Result<()> {
    let token = SecretString::from(std::env::var("BAO_TOKEN").unwrap_or_default());
    let client = Client::new("https://bao.example.com:8200")?.with_token(token);
    let kv = client.kv2("secret")?;

    kv.write_with_options(
        "app/config",
        serde_json::json!({ "username": "app", "password": "first" }),
        Some(Kv2WriteOptions { cas: Some(0) }),
    )
    .await?;

    let second = kv
        .patch("app/config", Patch {
            password: SecretString::from("rotated"),
        })
        .await?;

    let _previous = kv
        .read_version::<serde_json::Value>("app/config", second.version - 1)
        .await?;
    kv.delete_versions("app/config", &[second.version - 1]).await?;
    kv.undelete_versions("app/config", &[second.version - 1]).await?;
    kv.destroy_versions("app/config", &[second.version - 1]).await?;

    kv.patch_metadata(
        "app/config",
        &Kv2MetadataOptions {
            max_versions: Some(10),
            cas_required: Some(true),
            delete_version_after: Some("24h".to_owned()),
            custom_metadata: None,
        },
    )
    .await?;

    Ok(())
}

Use a KV v1 mount:

use openbao::{Client, Result};
use secrecy::SecretString;
use serde::{Deserialize, Serialize};

#[derive(Deserialize, Serialize)]
struct Config {
    endpoint: String,
}

#[tokio::main]
async fn main() -> Result<()> {
    let token = SecretString::from(std::env::var("BAO_TOKEN").unwrap_or_default());
    let client = Client::new("https://bao.example.com:8200")?.with_token(token);
    let kv = client.kv1("legacy-secret")?;

    kv.write("app/config", Config {
        endpoint: "https://api.example.com".to_owned(),
    })
    .await?;

    let config = kv.read::<Config>("app/config").await?;
    let keys = kv.list("app").await?;

    println!("endpoint: {}", config.endpoint);
    println!("keys: {}", keys.keys.len());
    Ok(())
}

Create, inspect, renew, and revoke a child token:

use openbao::auth::token::TokenCreateRequest;
use openbao::{Client, Result};
use secrecy::SecretString;
use std::collections::BTreeMap;

#[tokio::main]
async fn main() -> Result<()> {
    let root_or_parent = SecretString::from(std::env::var("BAO_TOKEN").unwrap_or_default());
    let client = Client::new("https://bao.example.com:8200")?.with_token(root_or_parent);

    let child = client
        .token()
        .create(&TokenCreateRequest {
            policies: vec!["default".to_owned()],
            meta: BTreeMap::from([("owner".to_owned(), "example".to_owned())]),
            display_name: Some("example-child".to_owned()),
            ttl: Some("30m".to_owned()),
            explicit_max_ttl: Some("1h".to_owned()),
            renewable: Some(true),
            ..TokenCreateRequest::default()
        })
        .await?;

    let info = client.token().lookup(&child.client_token).await?;
    println!("renewable: {}", info.renewable);

    let _renewed = client.token().renew(&child.client_token, Some("15m")).await?;
    client.token().revoke_accessor(&child.accessor).await?;
    Ok(())
}

Enable and tune a KV v2 mount:

use openbao::sys::{LeaseDuration, MountConfig, MountEnableRequest};
use openbao::{Client, Result};
use secrecy::SecretString;
use std::collections::BTreeMap;

#[tokio::main]
async fn main() -> Result<()> {
    let token = SecretString::from(std::env::var("BAO_TOKEN").unwrap_or_default());
    let client = Client::new("https://bao.example.com:8200")?.with_token(token);

    client
        .sys()
        .enable_mount(
            "apps",
            &MountEnableRequest {
                backend_type: "kv".to_owned(),
                description: Some("application secrets".to_owned()),
                config: Some(MountConfig {
                    default_lease_ttl: Some(LeaseDuration::Duration("30m".to_owned())),
                    max_lease_ttl: Some(LeaseDuration::Duration("2h".to_owned())),
                    ..MountConfig::default()
                }),
                options: BTreeMap::from([("version".to_owned(), "2".to_owned())]),
                local: None,
                seal_wrap: Some(true),
                external_entropy_access: None,
            },
        )
        .await?;

    let mounts = client.sys().list_mounts().await?;
    println!("mount count: {}", mounts.len());
    Ok(())
}

Wrap and unwrap JSON data:

use openbao::{Client, Result};
use secrecy::SecretString;
use serde::{Deserialize, Serialize};

#[derive(Deserialize, Serialize)]
struct WrappedPayload {
    nonce: String,
}

#[tokio::main]
async fn main() -> Result<()> {
    let token = SecretString::from(std::env::var("BAO_TOKEN").unwrap_or_default());
    let client = Client::new("https://bao.example.com:8200")?.with_token(token);

    let wrap = client
        .sys()
        .wrapping_wrap("5m", &WrappedPayload {
            nonce: "one-time".to_owned(),
        })
        .await?;

    let payload = client
        .sys()
        .wrapping_unwrap::<WrappedPayload>(Some(&wrap.token))
        .await?;

    println!("payload nonce length: {}", payload.nonce.len());
    Ok(())
}

Write an ACL policy and check capabilities:

use openbao::sys::PolicyWriteRequest;
use openbao::{Client, Result};
use secrecy::SecretString;

#[tokio::main]
async fn main() -> Result<()> {
    let token = SecretString::from(std::env::var("BAO_TOKEN").unwrap_or_default());
    let client = Client::new("https://bao.example.com:8200")?.with_token(token);

    client
        .sys()
        .write_policy(
            "app-read",
            &PolicyWriteRequest {
                policy: r#"path "secret/data/app" { capabilities = ["read"] }"#.to_owned(),
                expiration: None,
                ttl: None,
                cas: None,
                cas_required: None,
            },
        )
        .await?;

    let capabilities = client.sys().capabilities_self(["secret/data/app"]).await?;
    let _for_path = capabilities.by_path.get("secret/data/app");
    Ok(())
}

Call an endpoint that is not typed yet:

use openbao::{Client, Result};
use reqwest::Method;
use secrecy::SecretString;
use serde_json::Value;

#[tokio::main]
async fn main() -> Result<()> {
    let token = SecretString::from(std::env::var("BAO_TOKEN").unwrap_or_default());
    let client = Client::new("https://bao.example.com:8200")?.with_token(token);

    let response: Value = client
        .request_json(Method::GET, "sys/internal/specs/openapi", Option::<&Value>::None)
        .await?;

    println!("openapi keys: {}", response.as_object().map_or(0, |object| object.len()));
    Ok(())
}

The raw request layer is intentionally low level. Prefer typed helpers when the crate supports an endpoint; use raw JSON to bridge missing OpenBao APIs while coverage grows.

Local OpenBao Dev Instance

The local dev stack uses Podman, TLS, a private CA, and loopback-only ports in the requested 994x range.

Prepare the rootless Podman volume and TLS assets without starting OpenBao:

scripts/openbao_dev.sh prepare

This project does not require a /srv directory tree for local development: raft data lives in a Podman-managed volume, and TLS material lives in the ignored deploy/podman/dev-state/ directory.

scripts/openbao_dev.sh up

Endpoints:

  • API: https://127.0.0.1:9940
  • Cluster: https://127.0.0.1:9941
  • CA certificate: deploy/podman/dev-state/tls/dev-ca.crt

Initialize and unseal OpenBao using bao operator init and bao operator unseal, then export BAO_ADDR=https://127.0.0.1:9940 and BAO_CACERT=deploy/podman/dev-state/tls/dev-ca.crt.

Run the real OpenBao integration flow:

scripts/openbao_integration.sh

The integration script creates a fresh TLS dev instance, initializes and unseals it, stores the root token in a temporary 0600 file for the test process, and removes that file when the run exits.

Release Discipline

Run the normal local checks:

scripts/checks.sh

Run the current release gate:

scripts/release_0_2_gate.sh

Set OPENBAO_SKIP_INTEGRATION=1 only when Podman is unavailable; release candidate validation should run the integration gate.

No release tag should be cut unless the matching pentest report status is reviewed and recorded in the release notes.