Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 24 additions & 11 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ on:
branches: ["latest"]
paths:
- src/**
- crates/**
- Cargo.toml
- Cargo.lock
- .github/workflows/test.yaml
Expand Down Expand Up @@ -46,8 +47,12 @@ jobs:
~/.cargo/bin
target

- name: consult Clippy
run: cargo clippy --all-targets --features postgres,vsock
- name: Lint workspace (clippy)
run: |
cargo clippy --workspace --all-targets
cargo clippy -p zerolease --all-targets --features vsock,kms
cargo clippy -p zerolease-provider --all-targets --features vault
cargo clippy --manifest-path crates/zerolease-store-postgres/Cargo.toml --all-targets

- name: Install cargo-nextest
shell: bash
Expand All @@ -58,13 +63,16 @@ jobs:
mv cargo-nextest /home/runner/.cargo/bin
fi

- name: Run tests (including vsock)
run: cargo nextest run --features vsock
- name: Run workspace tests
run: cargo nextest run --workspace

- name: Run vsock tests
run: cargo nextest run -p zerolease --features vsock

- name: Run PostgreSQL integration tests
env:
DATABASE_URL: postgres://zerolease:zerolease@localhost/zerolease_test
run: cargo nextest run --features postgres store::postgres::tests -E 'test(store::postgres)' --run-ignored ignored-only --test-threads=1
run: cargo nextest run --manifest-path crates/zerolease-store-postgres/Cargo.toml --run-ignored ignored-only --test-threads=1

- name: Run KMS integration tests
env:
Expand All @@ -73,10 +81,12 @@ jobs:
AWS_REGION: ${{ secrets.AWS_REGION }}
ZEROLEASE_KMS_TEST_KEY_ID: alias/zerolease-test
ZEROLEASE_KMS_TEST_REGION: us-west-2
run: cargo nextest run --features kms keysource::kms -E 'test(keysource::kms)' --run-ignored ignored-only --test-threads=1
run: cargo nextest run -p zerolease --features kms -E 'test(keysource::kms)' --run-ignored ignored-only --test-threads=1

- name: Run doctests
run: cargo test --doc
run: |
cargo test --workspace --doc
cargo test --manifest-path crates/zerolease-store-postgres/Cargo.toml --doc

coverage:
name: coverage
Expand Down Expand Up @@ -108,17 +118,20 @@ jobs:
- name: Install cargo-llvm-cov
uses: taiki-e/install-action@cargo-llvm-cov

- name: Generate coverage (default + vsock tests)
run: cargo llvm-cov --features vsock,postgres --lcov --output-path lcov-default.info
- name: Generate coverage (workspace tests)
run: cargo llvm-cov --workspace --lcov --output-path lcov-workspace.info

- name: Generate coverage (vsock tests)
run: cargo llvm-cov -p zerolease --features vsock --lcov --output-path lcov-vsock.info --no-clean

- name: Generate coverage (PostgreSQL tests)
env:
DATABASE_URL: postgres://zerolease:zerolease@localhost/zerolease_test
run: cargo llvm-cov --features postgres --lcov --output-path lcov-postgres.info -- store::postgres --ignored --test-threads=1
run: cargo llvm-cov --manifest-path crates/zerolease-store-postgres/Cargo.toml --lcov --output-path lcov-postgres.info -- --ignored --test-threads=1

- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
files: lcov-default.info,lcov-postgres.info
files: lcov-workspace.info,lcov-vsock.info,lcov-postgres.info
fail_ci_if_error: false
token: ${{ secrets.CODECOV_TOKEN }}
89 changes: 56 additions & 33 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,64 +1,87 @@
[workspace]
members = [".", "crates/zerolease-provider", "crates/zerolease-store-rusqlite"]
# zerolease-store-postgres excluded from default workspace due to sqlx v0.8
# libsqlite3-sys conflict with rusqlite. Build/test it separately:
# cargo check -p zerolease-store-postgres --manifest-path crates/zerolease-store-postgres/Cargo.toml
exclude = ["crates/zerolease-store-postgres"]
resolver = "3"

[workspace.package]
edition = "2024"
authors = ["C J Silverio <ceejceej@gmail.com>"]
license = "Apache-2.0"
repository = "https://github.com/ceejbot/zerolease"

[workspace.lints.rust]
unsafe_code = { level = "deny", priority = 0 }
future_incompatible = { level = "deny", priority = 1 }
rust_2018_idioms = { level = "warn", priority = 2 }
trivial_casts = { level = "warn", priority = 3 }
trivial_numeric_casts = { level = "warn", priority = 4 }
unused_lifetimes = { level = "warn", priority = 5 }
unused_qualifications = { level = "warn", priority = 6 }

[workspace.lints.clippy]
unwrap_used = "deny"

[workspace.dependencies]
async-trait = "0.1"
chrono = { version = "0.4", features = ["serde"] }
secrecy = { version = "0.10", features = ["serde"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
tokio = { version = "1", features = ["full"] }
uuid = { version = "1", features = ["v7", "serde"] }
zerolease = { path = ".", default-features = false }

[package]
name = "zerolease"
authors = ["C J Silverio <ceejceej@gmail.com>"]
authors.workspace = true
version = "0.1.0"
edition = "2024"
edition.workspace = true
description = "A lightweight, agent-aware credential vault with lease-based access control"
license = "Apache-2.0"
repository = "https://github.com/ceejbot/zerolease"
license.workspace = true
repository.workspace = true
keywords = ["credentials", "vault", "security", "agents", "secrets"]
readme = "README.md"
categories = ["authentication", "cryptography"]

[dependencies]
aes-gcm = "0.10"
async-trait = "0.1"
async-trait.workspace = true
aws-config = { version = "1", optional = true }
aws-sdk-kms = { version = "1", optional = true }
base64 = "0.22"
chacha20poly1305 = "0.10"
chrono = { version = "0.4", features = ["serde"] }
secrecy = { version = "0.10", features = ["serde"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
tokio = { version = "1", features = ["full"] }
chrono.workspace = true
secrecy.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tokio.workspace = true
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
uuid = { version = "1", features = ["v7", "serde"] }
uuid.workspace = true
zeroize = { version = "1.8", features = ["derive"] }

[dependencies.sqlx]
version = "0.8"
features = ["runtime-tokio", "sqlite", "postgres", "chrono", "uuid"]
optional = true

[features]
default = ["sqlite"]
sqlite = ["sqlx"]
postgres = ["sqlx"]
default = []
# Gates integration tests that depend on the in-tree sqlx-based stores
# (not yet extracted to zerolease-store-sqlx). Unused for now.
sqlite = []
vsock = ["tokio-vsock"]
kms = ["aws-sdk-kms", "aws-config"]

[target.'cfg(unix)'.dependencies]
# OS keychain (Linux secret-service, macOS Keychain)
keyring = "3.6"

[target.'cfg(target_os = "linux")'.dependencies]
# vsock for QEMU and Firecracker host-guest communication
tokio-vsock = { version = "0.7", optional = true }

[dev-dependencies]
tempfile = "3"
zerolease-store-rusqlite = { path = "crates/zerolease-store-rusqlite" }

[lints.rust]
unsafe_code = { level = "deny", priority = 0 }
future_incompatible = { level = "deny", priority = 1 }
rust_2018_idioms = { level = "warn", priority = 2 }
trivial_casts = { level = "warn", priority = 3 }
trivial_numeric_casts = { level = "warn", priority = 4 }
unused_lifetimes = { level = "warn", priority = 5 }
unused_qualifications = { level = "warn", priority = 6 }

[lints.clippy]
unwrap_used = "deny"
[lints]
workspace = true
22 changes: 22 additions & 0 deletions crates/zerolease-provider/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
[package]
name = "zerolease-provider"
version = "0.1.0"
description = "CredentialProvider trait for lease-based credential access in AI agent tools"
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true

[dependencies]
async-trait.workspace = true
secrecy.workspace = true
thiserror.workspace = true
tokio = { workspace = true, features = ["sync", "rt"] }
uuid.workspace = true
zerolease = { workspace = true, default-features = false, optional = true }

[features]
vault = ["dep:zerolease"]

[lints]
workspace = true
92 changes: 92 additions & 0 deletions crates/zerolease-provider/src/credential.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
//! Credential guard: a zeroize-on-drop, revoke-on-drop handle to a secret.
//!
//! `CredentialGuard` wraps a `SecretString` obtained through a zerolease
//! lease. When the guard is dropped, the secret is zeroized from memory
//! and the lease revocation is requested via a background channel.
//!
//! The `expose()` closure pattern prevents callers from storing the
//! credential in a variable that outlives the guard.

use secrecy::{ExposeSecret, SecretString};
use tokio::sync::mpsc;
use uuid::Uuid;

/// A handle to an active credential. The secret value is accessible
/// only through [`expose()`](Self::expose) and is zeroized when this
/// guard drops. The underlying lease is revoked on drop.
///
/// NOT Clone, NOT Serialize. Debug redacts the secret value.
pub struct CredentialGuard {
secret: SecretString,
lease_id: Uuid,
target_domain: String,
revoke_tx: Option<mpsc::Sender<Uuid>>,
}

impl CredentialGuard {
/// Create a guard backed by a vault lease with a revocation channel.
#[allow(dead_code)] // we have library users
pub(crate) fn new(
secret: SecretString,
lease_id: Uuid,
target_domain: String,
revoke_tx: mpsc::Sender<Uuid>,
) -> Self {
Self {
secret,
lease_id,
target_domain,
revoke_tx: Some(revoke_tx),
}
}

/// Create a guard with no revocation channel (for static/test providers).
pub(crate) fn new_static(secret: SecretString, target_domain: String) -> Self {
Self {
secret,
lease_id: Uuid::now_v7(),
target_domain,
revoke_tx: None,
}
}

/// Access the secret value. The closure receives a `&str` that
/// must not be stored beyond the closure's scope.
pub fn expose<F, R>(&self, f: F) -> R
where
F: FnOnce(&str) -> R,
{
f(self.secret.expose_secret())
}

/// The domain this credential is scoped to.
pub fn target_domain(&self) -> &str {
&self.target_domain
}

/// The lease ID backing this credential.
pub fn lease_id(&self) -> Uuid {
self.lease_id
}
}

impl std::fmt::Debug for CredentialGuard {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CredentialGuard")
.field("lease_id", &self.lease_id)
.field("target_domain", &self.target_domain)
.field("secret", &"[REDACTED]")
.finish()
}
}

impl Drop for CredentialGuard {
fn drop(&mut self) {
if let Some(tx) = self.revoke_tx.take() {
// Best-effort: if the channel is full or closed, we can't
// block in Drop. The lease will expire on its own via TTL.
let _ = tx.try_send(self.lease_id);
}
// SecretString handles zeroization of the secret value.
}
}
22 changes: 22 additions & 0 deletions crates/zerolease-provider/src/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//! Error types for the credential provider.
//!
//! These errors intentionally do not expose vault internals.
//! A tool sees "credential unavailable" — never lease IDs,
//! policy details, or encryption errors.

#[derive(Debug, thiserror::Error)]
pub enum ProviderError {
/// The requested credential could not be acquired.
/// This covers policy denial, missing secrets, and vault errors —
/// intentionally vague to avoid leaking vault internals.
#[error("credential unavailable: {0}")]
Unavailable(String),

/// The provider could not connect to the vault.
#[error("vault connection failed: {0}")]
ConnectionFailed(String),

/// The credential value was not valid UTF-8.
#[error("credential is not valid UTF-8")]
InvalidUtf8,
}
24 changes: 24 additions & 0 deletions crates/zerolease-provider/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//! zerolease-provider: a `CredentialProvider` trait for AI agent tools.
//!
//! Instead of storing credentials as static `String` fields for the
//! process lifetime, tools call `provider.acquire()` at execution time
//! and receive a `CredentialGuard` that is time-bounded, domain-scoped,
//! and zeroized on drop.
//!
//! The `ZeroleaseProvider` implementation connects to a zerolease vault
//! server over UDS. Other backends (static config, HashiCorp Vault, etc.)
//! can implement the same trait.

pub mod credential;
pub mod error;
pub mod provider;
pub mod static_provider;
#[cfg(feature = "vault")]
pub mod zerolease_provider;

pub use credential::CredentialGuard;
pub use error::ProviderError;
pub use provider::{CredentialProvider, CredentialRequest};
pub use static_provider::StaticProvider;
#[cfg(feature = "vault")]
pub use zerolease_provider::ZeroleaseProvider;
Loading
Loading