galata-vault 0.4.0

End-to-end-encrypted secrets and config documents with no accounts: the galata-vault Rust SDK. A token client and the owner API over a pluggable transport, with an optional in-process backend.
Documentation

galata-vault

End-to-end-encrypted secrets and config documents with no accounts: the galata-vault Rust SDK. Values are encrypted, decrypted and verified on this side; the server only ever stores ciphertext and signatures. gv, gv ui and the Python package are built on this crate; there is no second client implementation.

galata-vault has not been independently audited. Its formats may change before 1.0. Use it at your own risk.

Two ways in:

  • Vault, the token client. Open a vault with an access token, then read, list, write and delete secrets and configs as the token's scope allows, verify the audit chain, and refresh after a rotation.
  • owner, the owner API. Everything gv does as a project's owner: create projects and environments, rediscover a subtree from a key, render and parse recovery and delegation kits, mint and revoke tokens, rotate, rekey (with resume and abort), and verify audit chains against a persisted head.

Install

[dependencies]
galata-vault = "0.1"

Stability. The API is 0.x: a 0.y bump may break it, and the changelog says how. The wire and file formats are specified separately, in the repository's docs/spec/, and carry their own version markers: data written under a marker stays readable by every later release that reads it, and a format that changes gets a new marker, never a silent change. The minimum supported Rust version is 1.98; raising it is a minor release. Security fixes cover the latest 0.y release (SECURITY.md).

Opening a vault with a token

A vault is opened with an access token minted by the environment's owner (gv token mint --scope …, or Environment::mint), and the server it belongs to.

use galata_vault::Vault;

// GV_SERVER, and exactly one of GV_TOKEN or GV_TOKEN_FILE.
let vault = Vault::from_env()?;
// Or explicitly. On Unix the file must be mode 0600 or 0400 and hold one token.
let vault = Vault::from_token_file("/run/secrets/gv-token", "http://127.0.0.1:8443")?;
# Ok::<(), galata_vault::Error>(())

The server must be https://, or http:// on loopback. A token's checksum is checked before any request, and neither the token nor anything decrypted ever appears in an error.

Opening verifies the vault from the vault id inside the gvt1_ token: the owner signing key must hash to it, the generation descriptor must be signed by that key, and the token's bundle must be owner-signed for this token and hold exactly the keys the descriptor names. Every request is signed with a key derived from the token; the token string itself is never sent.

Token files on Windows. This crate cannot verify a Windows ACL without unsafe code or another dependency, so from_token_file and GV_TOKEN_FILE refuse the file there (invalid_token_file). A caller that has checked the file's access control itself opens it with Vault::from_token_file_with(path, server, TokenFileCheck::CallerVerified). Vault::new with a token from a secure source works everywhere.

Reading and writing

use galata_vault::{Expect, NewConfig, Vault};
# let new_body = String::new();

let vault = Vault::from_env()?;

let db = vault.secret("DATABASE_URL")?;          // SecretValue
let bytes: &[u8] = db.expose();                  // exactly what was written
let set = vault.secrets(&["A", "B"])?;           // all, or an error; never some
let all = vault.readable(None)?;                 // every secret this token may read

let app = vault.config("app")?;                  // ConfigDocument
let settings: toml::Table = app.deserialize()?;  // toml and json only
let version = app.version();

// An edit that fails if someone else wrote in between. Never retried.
vault.set_config("app", NewConfig::toml(new_body).expect_version(version))?;
vault.set_secret_expecting("API_KEY", b"k2", Expect::Version(1))?;
vault.delete_secret("OLD_KEY", None)?;           // a signed tombstone; history is kept
let versions = vault.history("API_KEY")?;
# Ok::<(), galata_vault::Error>(())

Every record read carries a signature by the generation's writer key for its kind, over its vault, generation, name index, version, write time and ciphertext. A record that does not verify is an Integrity error; so is a value whose encrypted envelope names a different vault, generation, version or name.

SecretValue and ConfigDocument zeroize on drop, and cannot be printed: no Display, no Serialize, and a Debug that shows names, versions, formats and sizes only.

A long-lived handle survives a rotation: a write that meets the new generation fails with Error::StaleGeneration; vault.refresh() fetches the new bundle and descriptor, and the retry succeeds.

The owner API

use std::sync::Arc;
use galata_vault::owner::{Owner, RetiresAllTokens};
use galata_vault::{FileStateStore, KeyStore, Scope};
# fn my_key_store() -> Arc<dyn KeyStore> { unimplemented!() }

let mut owner = Owner::open(my_key_store(), Arc::new(FileStateStore::new("/var/lib/acme")))?;

// Nothing reaches the server until the kit is acknowledged.
let init = owner.begin_init("acme", "https://vault.example")?;
store_somewhere_safe(&init.recovery_kit().render()?);
init.confirm_kit_stored()?;

owner.env_add(&"acme/prod".parse()?)?;
let env = owner.environment(&"acme/prod".parse()?)?; // derefs to Vault
env.set_secret("DATABASE_URL", b"postgres://db.internal/app")?;
let token = env.mint(Scope::Read, 0, &[])?;           // token.expose(), once
owner.close(env)?;                                    // remembers pins, saves state
# fn store_somewhere_safe(_: &str) {}
# let _ = (token, RetiresAllTokens);
# Ok::<(), Box<dyn std::error::Error>>(())

The library asks nothing and prints nothing. Every confirmation gv asks for is a typestate or an argument: PendingInit::confirm_kit_stored, export_kit(path, GrantsSubtreeOwnership), begin_rekey(path, RetiresAllTokens). Every warning goes to the Events observer or into an outcome (Removal, Repair, RekeyOutcome). No kit file is written: the caller stores what it is handed. A path that is not in the known tree is refused before any request, with the closest known path as data (Error::UnknownPath { suggestion, .. }).

Seams

Nothing reaches the host unless it is handed the means:

seam what it is provided
Transport one method: send the canonical request the protocol signs, get status, headers and body HttpTransport (feature http); Embedded (feature embedded); RecordingTransport (feature test-util)
Api the typed operations over any Transport: builds, signs and interprets every request always
ClientBuilder timeout (120 s), proxy (none unless given; never from the environment), root certificates or the platform verifier, user agent, observer; redirects are never followed feature http
KeyStore where held node keys live none (bring your own; gv uses the OS keychain)
StateStore the known tree, keep-alive times, audit heads, pins, a rekey in progress FileStateStore (gv's config.toml and state.toml, mode 0600 on Unix)
Events expiry, progress and warnings, for the handle that made the request NoEvents (the default)

Nothing is process-global: two handles with two observers each hear only their own notices.

Features

  • http (default): the HTTP transport. Without it no HTTP client (ureq, rustls, ring) is linked; callers bring a Transport and use Vault::with_api and Owner::with_connector.
  • platform-verifier: verify TLS with the operating system's trust store.
  • embedded: the in-process backend, galata_vault::embedded::open(dir): a Transport that runs galata_vault::server_core over the SQLite store in a data directory, the layout gv-server local uses, with no socket, HTTP or async runtime. Every request is signed and checked as over HTTP. It links galata_vault::server_core, galata_vault::backend and rusqlite, and no HTTP server, runtime or S3 client (scripts/check-sdk-linkage.sh). One process opens a directory at a time (data_dir_in_use otherwise); several processes share vaults through gv-server local. The module documentation states what the embedding process can bypass, and which cryptographic checks still bind it.
  • test-util: in-memory key and state stores, RecordingTransport, and galata_vault::testing for adversarial test harnesses.

Detecting rollback

# let vault = galata_vault::Vault::from_env()?;
# let kept: Option<galata_vault::ChainHead> = None;
let report = vault.verify_audit(kept)?;   // every scope may do this
let keep = report.head;                   // store it; pass it next time
# let store = galata_vault::FileStateStore::new("/var/lib/acme");
let report = vault.audit(&store)?;        // or keep it in a StateStore

let pins = vault.pins();                  // descriptor and record versions seen
vault.set_pins(pins)?;                    // on a later handle: refuses regressions
# Ok::<(), galata_vault::Error>(())

A caller that keeps the audit head and the pins across runs detects a server that shows an older record version (version_rollback), an older or different generation (generation_rollback), or a history that does not continue from the kept head (audit_mismatch). The recorded head moves only when the whole fetched range verifies. A client that keeps nothing cannot tell a rolled-back or forked server from an honest one.

What a token can do

scope list read secrets write secrets read configs write configs
meta ✓
append ✓ ✓ ✓
read ✓ ✓ ✓
admin ✓ ✓ ✓ ✓ ✓
config ✓ ✓
config-write ✓ ✓ ✓

The limits are cryptographic: a scope's bundle holds only the keys its row needs. A config or config-write token never holds the vault key, and a config-write token holds the config writer key but not the secret writer key, so it cannot read or write a secret whatever it is given. The server's scope checks decide what succeeds; the SDK refuses nothing locally that the scope permits. Minting and rotation need the owner key.

Configs are checked before they are written

  • The body must parse in its declared format (toml, json); yaml and text must be UTF-8.
  • A body holding a credential literal is refused: a gvt1_ token or gvk1_ key with a valid checksum, a PEM private key, or 0x and exactly 64 hex digits. Config tokens are weaker than secret readers, so a key in a config is a key for every config reader. NewConfig::allow_literals() overrides it for one write.

Errors name the line and the kind of problem, never the text.

Errors

One type, galata_vault::Error: a #[non_exhaustive] enum whose variants carry structured detail (Conflict { expected, current, .. }, UnknownPath { suggestion, .. }, Integrity { failure, .. }, …) and keep their source where there is one (a transport failure's source() is the I/O or TLS error). It is Send, Sync and Clone.

code() is a stable string, the same the Python package reports and the same this crate reported before the enum existed; kind() is one of Auth, Forbidden, NotFound, Conflict, Transport, Invalid, Integrity or Other. The Integrity codes are bad_signature, key_mismatch, binding_mismatch, version_rollback, generation_rollback and audit_mismatch. No error ever holds a token, key material, a value or a config body.

The host process

The library is blocking; from async code, call it through spawn_blocking. A Vault is Send + Sync, so one handle serves every thread. It touches no keychain and no process limits, writes nothing to stdout or stderr, prompts for nothing, installs no signal handler, starts no runtime and keeps no process-global state. scripts/check-sdk-linkage.sh keeps keyring, rlimit, rpassword, clap and pyo3 out of its dependency graph, and scripts/check-client-linkage.sh keeps value decryption out of galata_vault::client, the transport crate beneath it.

Examples

cargo run -p galata-vault --example <name> (and --features embedded for embedded):

  • token_read: read one secret with a token from the environment.
  • owner_workflow: create a project with an in-memory key store, add an environment, mint, read with the token, rotate.
  • custom_transport: a Transport that logs each request's method and path.
  • embedded: open a data directory, create a project and an environment, set a secret and read it back through a read token, with no server.