diff --git a/README.md b/README.md index 5ce2d7c..45c507c 100644 --- a/README.md +++ b/README.md @@ -121,9 +121,16 @@ JSON over length-prefixed frames (4-byte big-endian length + payload). Each conn - Auth tokens stored as SHA-256 hashes, never in plaintext - TCP listener binds localhost only +## Documentation + +- **[Design](docs/design.md)** — Threat model, core abstractions, design decisions, what this is not. +- **[Embedded guide](docs/guide-embedded.md)** — Integrating zerolease into a Rust application (e.g., zeroclaw). In-process vault, no server. +- **[Cloud service guide](docs/guide-cloud-service.md)** — Running the vault as a server with PostgreSQL or AWS Secrets Manager. Multiple clients over UDS/TCP. +- **[VM deployment guide](docs/guide-vm-deployment.md)** — Full production deployment with QEMU VMs, credential provisioner, lease-aware proxy, and iptables network jail. + ## Status -Early development. Trait definitions and core types are stable. Concrete backend implementations are functional. Integration-level documentation and a standalone server binary are planned. +Early development. Core traits and backend implementations are functional. The VM agent (provisioner + proxy + credential helper) is implemented and security-audited. A standalone server binary is planned. ## License diff --git a/docs/deployment-architecture.md b/docs/deployment-architecture.md deleted file mode 100644 index c4b4cd1..0000000 --- a/docs/deployment-architecture.md +++ /dev/null @@ -1,161 +0,0 @@ -# Deployment Architecture: Cloud Agent Stacks - -This document describes how zerolease fits into a production deployment where AI coding agents run inside isolated QEMU virtual machines, managed by an orchestrator (the Claw). - -## The Problem - -You want to run Claude Code agents that need real credentials — GitHub tokens, API keys, database passwords — inside disposable cloud VMs. The agents shouldn't hold raw credentials. They shouldn't be able to exfiltrate them. And when the work is done, every credential should be revocable in one shot. - -## Overview - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ AWS Instance │ -│ │ -│ ┌──────────────────────────────────────┐ │ -│ │ The Claw (orchestrator) │ │ -│ │ │ │ -│ │ ┌────────────┐ ┌────────────────┐ │ │ -│ │ │ zerolease │ │ Token Manager │ │ │ -│ │ │ vault │ │ (per prompt │ │ │ -│ │ │ │ │ run) │ │ │ -│ │ └─────┬──────┘ └───────┬────────┘ │ │ -│ │ │ TCP :9100 │ │ │ -│ │ │ (localhost) │ │ │ -│ └────────┼─────────────────┼───────────┘ │ -│ │ │ │ -│ ┌─────┴─────────────────┴──────────────────┐ │ -│ │ QEMU host-forwarded ports │ │ -│ └─────┬──────────────┬──────────────┬──────┘ │ -│ │ │ │ │ -│ ┌────────┴───────┐ ┌───┴────────┐ ┌───┴────────┐ │ -│ │ QEMU VM │ │ QEMU VM │ │ QEMU VM │ │ -│ │ Stack A │ │ Stack B │ │ Stack C │ │ -│ │ │ │ │ │ │ │ -│ │ prompt-run- │ │ prompt- │ │ prompt- │ │ -│ │ 001 │ │ run-002 │ │ run-003 │ │ -│ │ │ │ │ │ │ │ -│ │ ┌───────────┐ │ │ ┌──────┐ │ │ ┌──────┐ │ │ -│ │ │ CLI │ │ │ │ CLI │ │ │ │ CLI │ │ │ -│ │ │ wrapper │ │ │ │ wrap │ │ │ │ wrap │ │ │ -│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ -│ │ │ Claude │ │ │ │ CC │ │ │ │ CC │ │ │ -│ │ │ Code │ │ │ │ │ │ │ │ │ │ │ -│ │ └───────────┘ │ │ └──────┘ │ │ └──────┘ │ │ -│ │ postgres, etc │ │ dev env │ │ dev env │ │ -│ └────────────────┘ └────────────┘ └────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────┘ -``` - -## How It Works - -### 1. A developer requests a prompt run - -Someone asks the Claw to run a Claude Code agent against a repository. The Claw knows who is asking and what credentials they're authorized to use. - -### 2. The Claw provisions a token - -Before spinning up VMs, the Claw registers a **prompt-run token** with the zerolease vault. This token is scoped to a specific set of credentials and has a TTL matching the expected run duration. - -``` -Claw → zerolease vault: register token "prompt-run-001" - → grants access to: github-pat, jira-token - → domains: github.com, *.atlassian.net - → TTL: 2 hours - → role: Agent (bound to agent ID "prompt-run-001") -``` - -### 3. The Claw boots the VM stack - -A QEMU VM is started with: - -- A pre-baked development environment (language runtimes, database, etc.) -- The prompt-run token injected at boot (via cloud-init or QEMU `-fw_cfg`) -- Network configured so the VM can reach the host vault via a forwarded port - -The VM doesn't receive any real credentials at boot. It only has the token. - -### 4. The CLI wrapper exchanges leases for credentials - -Inside the VM, Claude Code is wrapped by a thin CLI that intercepts credential requests. When a tool needs a GitHub token: - -``` -Claude Code → CLI wrapper: "I need the GitHub token" -CLI wrapper → zerolease vault (TCP, with prompt-run token): - 1. ClientHello with token → authenticated as prompt-run-001 - 2. request_lease(secret: "github-pat", domain: "github.com") - 3. access_secret(lease_id) → receives actual token -CLI wrapper → Claude Code: injects credential into tool environment -``` - -The real credential exists in the VM's memory only for the duration of the tool call. The `LeaseGuard` zeroizes it on drop. - -### 5. When the prompt run ends - -The Claw revokes the prompt-run token. All leases issued under that token expire immediately. The VM is destroyed. No credentials persist anywhere. - -## Credential Flow - -```mermaid -sequenceDiagram - participant Dev as Developer - participant Claw as The Claw - participant Vault as zerolease vault - participant VM as QEMU VM - participant CLI as CLI wrapper - participant CC as Claude Code - participant Tool as Tool (git, API) - - Dev->>Claw: "Run prompt against repo X" - Claw->>Vault: Register token for prompt-run-001 - Claw->>VM: Boot with token (no credentials) - - CC->>Tool: git push (needs auth) - Tool->>CLI: credential request - CLI->>Vault: ClientHello + token - Vault-->>CLI: ServerHello (authenticated) - CLI->>Vault: request_lease("github-pat", "github.com") - Vault-->>CLI: LeaseGrant (lease ID, TTL) - CLI->>Vault: access_secret(lease_id) - Vault-->>CLI: credential value - CLI->>Tool: inject credential - Tool->>Tool: git push succeeds - CLI->>CLI: zeroize credential from memory - - Note over Dev,Tool: Later... - - Dev->>Claw: "Done" (or timeout) - Claw->>Vault: Revoke token for prompt-run-001 - Vault->>Vault: Expire all leases for prompt-run-001 - Claw->>VM: Destroy -``` - -## Security Properties - -**Credentials never leave the host.** The vault runs on the same AWS instance as the QEMU VMs. TCP traffic stays on localhost. No credential material crosses a network boundary. - -**Agents can't exfiltrate.** A leaked lease ID is useless — it's bound to a specific domain. A GitHub lease can't be used against `evil.com`. The vault checks the target domain on every `access_secret` call. - -**Time-bounded by default.** Leases expire. If the Claw crashes, credentials become inaccessible after the TTL. No dangling access. - -**One revocation kills everything.** Revoking the prompt-run token invalidates all leases issued under it. The Claw doesn't need to track individual credentials — it tracks prompt runs. - -**Auditable.** Every lease grant, access, and revocation is logged with the prompt-run ID, agent identity, target domain, and timestamp. The Claw can answer "what credentials did prompt-run-001 use?" from the audit log. - -## What Lives Where - -| Component | Runs on | Provided by | -| ---------------- | ------------------- | ---------------------------------------- | -| zerolease vault | Host (AWS instance) | zerolease core + store crate | -| Token management | Host | The Claw (implements `Authenticator`) | -| TCP listener | Host, port 9100 | zerolease `TcpListener` | -| CLI wrapper | Guest (QEMU VM) | Custom binary using `zerolease-provider` | -| Claude Code | Guest (QEMU VM) | Anthropic | -| Dev environment | Guest (QEMU VM) | Pre-baked VM image | - -## Open Questions - -- **Token delivery mechanism.** Cloud-init userdata vs. QEMU `-fw_cfg` vs. virtio-serial. Cloud-init is simplest but the token is visible to anything in the VM that can read the metadata service. -- **Connection pooling.** Currently each credential request opens a new TCP connection. For high-frequency tool use, a persistent connection or connection pool in the CLI wrapper would reduce latency. -- **Multi-instance coordination.** When prompt-run stacks span multiple AWS instances (not planned initially), the vault would need to be network-accessible or replicated. TLS becomes mandatory at that point. diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..14eb43a --- /dev/null +++ b/docs/design.md @@ -0,0 +1,120 @@ +# Design + +zerolease is a credential vault for environments where AI coding agents need access to secrets they shouldn't be trusted to hold. This document explains the assumptions, threat model, and design decisions. + +## The Problem + +An AI coding agent that can run `git push`, call APIs, or deploy infrastructure needs credentials. The conventional approach — put `GITHUB_TOKEN` in the environment and let the agent use it — is dangerous: + +- The agent (and every tool it invokes) has the raw credential for the entire session. +- The credential works against any domain, not just the one the agent needs. +- There's no way to revoke access without killing the process. +- There's no audit trail of what the credential was used for. +- A compromised or misbehaving tool can exfiltrate the credential. + +zerolease replaces this with lease-based access: agents receive time-bounded, domain-scoped handles to credentials. The credential material is managed by the vault, not the agent. + +## Threat Model + +**What we defend against:** + +- An agent tool that tries to use a credential against an unauthorized domain (e.g., sending a GitHub token to `evil.com`). +- A tool that holds a credential in memory after the lease expires and tries to keep using it. +- A tool that reads credentials from the environment or filesystem and attempts to exfiltrate them via the network. +- Credential persistence after the agent's work is done. + +**What we don't defend against:** + +- A tool that exfiltrates data through an allowed domain (e.g., creating a GitHub gist with stolen data). Domain-level access control can't distinguish legitimate from malicious use of an allowed API. +- Side-channel attacks (timing, power analysis). This is a software vault, not an HSM. +- A compromised VM image. If the base image is tampered with, all bets are off. +- Denial of service by a tool that exhausts the proxy's resources. The proxy is hardened against common DoS vectors but is not a production-grade DDoS target. + +**Trust boundaries:** + +| Component | Trust level | +|-----------|------------| +| The vault (host) | Fully trusted. Holds credentials, enforces policy. | +| The orchestrator (Claw) | Fully trusted. Provisions VMs, manages tokens. | +| The proxy (VM) | Trusted infrastructure. Runs as a separate user, enforces leases at the network layer. | +| The provisioner (VM) | Trusted infrastructure. Runs once, handles the vault token, exits. | +| Claude Code (VM) | Untrusted. Receives credentials via env vars and config files. | +| Tools invoked by Claude Code (VM) | Untrusted. May attempt to exfiltrate credentials. | + +## Core Abstractions + +### The Vault (`Vault`) + +The vault is generic over three pluggable backends: + +- **`KeySource`** (`K`): Manages the data encryption key (DEK). Implementations: OS keychain, AWS KMS, environment variable. +- **`SecretStore`** (`S`): Persists encrypted secret blobs. Implementations: rusqlite, PostgreSQL, AWS Secrets Manager. +- **`AuditLog`** (`A`): Records every credential operation. Implementations: `TracingAuditLog` (emit to stdout/log aggregator), rusqlite, PostgreSQL. + +These are chosen at compile time. A developer laptop uses `KeychainSource + RusqliteStore + RusqliteAuditLog`. A cloud deployment uses `KmsSource + AwsSecretsManagerStore + TracingAuditLog`. + +### Leases + +A lease is a time-bounded, domain-scoped handle to a credential. When an agent needs a GitHub token: + +1. It requests a lease for `github-pat` scoped to `github.com`. +2. The vault checks the policy engine (deny-by-default, first-match grant list). +3. If allowed, the vault creates a `Lease` with a TTL, optional use count, and domain restrictions. +4. The agent receives a `LeaseGrant` (metadata: lease ID, expiry, domains) — not the credential itself. +5. To get the actual credential, the agent calls `access_secret(lease_id, target_domain)`. +6. The vault verifies the target domain is in the lease's allowed list, decrypts the secret, and returns it in a `LeaseGuard` that zeroizes on drop. + +Leases expire automatically. They can be revoked at any time by the vault administrator or the orchestrator. + +### Transports + +The vault speaks a JSON-over-length-prefixed-frames protocol. Three transports: + +- **Unix domain socket**: For local processes. Identity from `SO_PEERCRED` (UID/PID). +- **TCP + token**: For QEMU VMs. Identity from a bearer token in the `ClientHello` handshake. Listener binds localhost only. +- **vsock**: For Firecracker VMs. Identity from the guest CID. + +The transport provides a `PeerIdentity` (what the OS/network tells us about the peer). The `Authenticator` trait maps this to a `ConnectionIdentity` (role + agent binding). The vault dispatch logic enforces role-based access control. + +### The Proxy + +In VM deployments, credentials are injected into the environment where any process can read them. Lease revocation is meaningless if the tool already has the raw token. The lease-aware proxy closes this gap: + +- It runs as a long-lived process inside the VM, separate from the agent. +- All outgoing HTTPS traffic must pass through it (via `HTTPS_PROXY` env var + iptables fallback). +- On each connection attempt, it checks: does this domain have an active lease? +- If yes: TCP tunnel (no TLS termination, the proxy never sees credential material). +- If no: connection blocked. + +When the orchestrator revokes the prompt-run token, the proxy starts blocking. The tool may have the credential in memory, but it can't reach any server with it. + +## Design Decisions + +**Deny-by-default policy.** The policy engine uses a flat grant list, not a policy language like OPA or Cedar. First match wins. If no rule matches, access is denied. This is intentionally simple — easy to audit, hard to misconfigure. + +**Newtype IDs everywhere.** `SecretId`, `AgentId`, `LeaseId`, `SecretName`, `DomainScope` are all newtypes. You can't accidentally pass an `AgentId` where a `SecretId` is expected. The compiler catches it. + +**Zeroize on drop.** Secret values use `SecretString` and `Zeroize`. The `LeaseGuard` is not `Clone`, not `Serialize`, and redacts in `Debug`. When the guard drops, the secret is overwritten in memory. + +**Storage and audit are decoupled.** A `SecretStore` crate doesn't need to also provide an `AuditLog`. The AWS Secrets Manager backend provides only `SecretStore`; you pair it with `TracingAuditLog` for audit. This lets you choose the right tool for each job. + +**The proxy doesn't terminate TLS.** It only needs the destination domain, which it gets from the HTTP CONNECT request line (explicit proxy) or TLS SNI (transparent proxy). It never sees credential material inside the encrypted tunnel. No custom CA cert, no per-API auth knowledge. + +**The vault token dies with the provisioner.** In VM deployments, the prompt-run token is used by the provisioner and never written to the agent's environment. The provisioner exits, taking the token with it. If `credential-fill` (git credential helper) needs vault access, it gets a separate, more restricted token. + +**Default-deny outbound networking.** In VM deployments, `iptables -P OUTPUT DROP` is applied at boot before any process starts. Only the proxy user can reach port 443. All other outbound traffic (UDP, ICMP, SSH, HTTP) is blocked. The VM is a network jail with one exit. + +## Encryption + +Secrets are encrypted at rest using AEAD ciphers: + +- **AES-256-GCM**: Hardware-accelerated on x86_64 via AES-NI. Default. +- **XChaCha20-Poly1305**: Constant-time, good for non-x86 targets or when you want a larger nonce. + +The data encryption key (DEK) is managed by the `KeySource`. In KMS deployments, the DEK is itself encrypted by KMS (envelope encryption) — the vault never makes a KMS call per secret operation, only on DEK load/rotate. + +## What This Is Not + +- **Not a secrets manager.** It doesn't generate, rotate, or sync credentials with upstream services. It stores credentials that an administrator puts in and controls how agents access them. +- **Not a network proxy for general use.** The proxy enforces lease state, not general access control. It's purpose-built for the VM deployment model. +- **Not an HSM.** Secrets exist as plaintext in the vault process's memory while being accessed. The vault is a software component, not a hardware security boundary. diff --git a/docs/guide-cloud-service.md b/docs/guide-cloud-service.md new file mode 100644 index 0000000..9e602e2 --- /dev/null +++ b/docs/guide-cloud-service.md @@ -0,0 +1,182 @@ +# Guide: Cloud Service Deployment + +This guide describes deploying zerolease as a server process that manages credentials for a shared infrastructure — multiple services, CI/CD pipelines, or orchestrators accessing a central credential store. + +## When to Use This + +You have a persistent infrastructure (not disposable VMs) where multiple processes need credential access. You want centralized policy, audit logging, and the ability to rotate credentials without redeploying consumers. The vault runs as a server process; clients connect over Unix domain sockets or TCP. + +## Architecture + +``` +┌─────────────────────────────────────────┐ +│ Host / Server │ +│ │ +│ zerolease vault server │ +│ ├── KeySource: KmsSource (AWS KMS) │ +│ ├── SecretStore: PostgresStore │ +│ ├── AuditLog: TracingAuditLog → ELK │ +│ └── Authenticator: (your impl) │ +│ │ │ +│ ├── UDS → local orchestrator │ +│ └── TCP → CI runners, services │ +│ │ +│ Service A ──UDS──→ vault │ +│ Service B ──UDS──→ vault │ +│ CI runner ──TCP──→ vault │ +└─────────────────────────────────────────┘ +``` + +## Choosing Backends + +| Backend | Crate | Why | +|---------|-------|-----| +| **KeySource** | `KmsSource` (feature `kms`) | DEK encrypted by AWS KMS. No key material on disk. | +| **SecretStore** | `zerolease-store-postgres` | Shared PostgreSQL. Backups, replication, familiar ops. | +| **AuditLog** | `TracingAuditLog` (core crate) | Structured events → stdout → fluentd/vector → your log aggregator. | + +For smaller deployments, `RusqliteStore + RusqliteAuditLog` on a single host works fine. + +For AWS-native deployments where you want AWS to manage the encrypted blobs: `AwsSecretsManagerStore + TracingAuditLog`. + +## Running the Vault Server + +The vault server is not yet a standalone binary (it's a library). You write a small Rust program that configures the backends and starts the server: + +```rust +use std::sync::Arc; +use zerolease::keysource::kms::KmsSource; +use zerolease::policy::PolicyEngine; +use zerolease::audit::tracing_log::TracingAuditLog; +use zerolease::server::VaultServer; +use zerolease::transport::uds::UdsListener; +use zerolease_store_postgres::PostgresStore; + +#[tokio::main] +async fn main() -> Result<(), Box> { + tracing_subscriber::fmt::init(); + + let key_source = KmsSource::new( + "alias/zerolease-prod", + "us-west-2", + "/var/lib/zerolease/dek.enc", + ).await?; + + let store = PostgresStore::new( + "postgres://zerolease:password@db.internal/zerolease" + ).await?; + + let audit = TracingAuditLog::new(); + let policy = PolicyEngine::new(load_grants_from_config()?); + let vault = Arc::new(Vault::new(key_source, store, audit, policy)); + + let listener = UdsListener::bind("/var/run/zerolease/vault.sock")?; + let authenticator = Arc::new(your_authenticator()); + + let server = VaultServer::new(vault, listener, authenticator); + server.serve().await?; + + Ok(()) +} +``` + +## Authentication + +You implement the `Authenticator` trait to map peer identity to roles: + +```rust +use zerolease::auth::{Authenticator, ConnectionIdentity, Role}; +use zerolease::transport::PeerIdentity; +use zerolease::types::AgentId; + +struct MyAuthenticator { /* ... */ } + +#[async_trait::async_trait] +impl Authenticator for MyAuthenticator { + async fn authenticate( + &self, + peer: &PeerIdentity, + token: Option<&str>, + ) -> Option { + match peer { + // Local orchestrator process (known UID). + PeerIdentity::Unix { uid: 1000, .. } => Some(ConnectionIdentity { + role: Role::Admin, + agent_id: None, + label: "orchestrator".to_string(), + }), + // CI runner with a token. + PeerIdentity::Tcp { .. } => { + let token = token?; + self.validate_ci_token(token).await + } + _ => None, + } + } +} +``` + +Three roles are available: +- **Admin**: Can store, delete, list secrets, and perform all lease operations. +- **Agent**: Bound to a single agent identity. Can only request/access/revoke leases. The agent field in requests is ignored — the server substitutes the bound identity. +- **Orchestrator**: Trusted to assert any agent identity per request. For systems acting on behalf of multiple agents. + +## Client Usage + +Consumers connect via `VaultClient`: + +```rust +use zerolease::client::VaultClient; +use zerolease::transport::uds::UdsConnector; + +let connector = UdsConnector::new("/var/run/zerolease/vault.sock"); +let mut client = VaultClient::connect(&connector).await?; + +// Request a lease. +let grant = client.request_lease("my-service", "db-password", "db.internal").await?; + +// Access the secret. +let secret_bytes = client.access_secret(*grant.lease_id.as_uuid(), "db.internal").await?; +let password = String::from_utf8(secret_bytes)?; + +// Use the password, then let it go. +// (In practice, wrap this in a more structured pattern.) +``` + +For TCP clients with token auth: + +```rust +use zerolease::client::VaultClient; +use zerolease::transport::tcp::TcpConnector; + +let connector = TcpConnector::new("127.0.0.1:9100".parse()?, "my-bearer-token"); +let mut client = VaultClient::connect_with_token(&connector, connector.token()).await?; +``` + +## AWS Secrets Manager Backend + +If you prefer AWS to manage the encrypted secret storage (leveraging AWS's encryption, replication, and IAM): + +```rust +use zerolease_store_aws_sm::AwsSecretsManagerStore; + +let store = AwsSecretsManagerStore::from_env("zerolease").await?; +``` + +Secrets are stored as individual AWS Secrets Manager secrets under the prefix `zerolease/`. Metadata is stored in AWS tags for efficient listing. See the [AWS SM crate docs](../crates/zerolease-store-aws-sm/) for IAM permissions and configuration. + +Note: the AWS SM backend provides `SecretStore` only, not `AuditLog`. Pair it with `TracingAuditLog` — AWS CloudTrail already captures Secrets Manager API calls, giving you a second audit trail. + +## Monitoring and Operations + +**Audit events** are emitted as structured `tracing` events at the `info` level with `target: "zerolease::audit"`. Configure your tracing subscriber to route these to your log aggregator: + +``` +{"timestamp":"2026-03-28T19:00:00Z","level":"INFO","target":"zerolease::audit", + "event_id":"...", "agent":"tool-git", "event":"LeaseGranted", + "secret_name":"github-pat", "outcome":"Success"} +``` + +**DEK rotation** re-encrypts all secrets under a new key. This is a `batch_update` operation — transactional in SQL backends, best-effort (but retryable) in the AWS SM backend. + +**Policy reloads** are logged as `PolicyReloaded { grant_count }` audit events. diff --git a/docs/guide-embedded.md b/docs/guide-embedded.md new file mode 100644 index 0000000..8bdbdb1 --- /dev/null +++ b/docs/guide-embedded.md @@ -0,0 +1,148 @@ +# Guide: Embedded in an Application (zeroclaw) + +This guide describes how to integrate zerolease directly into a Rust application that manages AI agents — the way [zeroclaw](https://github.com/ceejbot/zeroclaw) uses it. + +## When to Use This + +You're building an application that orchestrates AI agents and their tools. The application already manages tool execution and wants fine-grained control over which tools get which credentials, for how long, and for which domains. You want the vault in-process, not as a separate service. + +## Architecture + +``` +Your Application +├── Vault +├── PolicyEngine (configured with grant rules) +├── Tool A: request_lease → access_secret → do work → drop guard +├── Tool B: request_lease → access_secret → do work → drop guard +└── Audit log (queryable: "what did Tool A access?") +``` + +The vault runs in-process. No server, no transport, no proxy. Tools call the vault API directly through Rust function calls. + +## Choosing Backends + +For an embedded application on a single machine: + +| Backend | Crate | Why | +|---------|-------|-----| +| **KeySource** | `KeychainSource` | OS keychain (macOS Keychain, Linux secret-service). DEK never touches disk. | +| **SecretStore** | `zerolease-store-rusqlite` | Single SQLite file. Zero config. Same process. | +| **AuditLog** | `RusqliteAuditLog` | Queryable audit in the same SQLite DB (or a separate one). | + +Add to your `Cargo.toml`: + +```toml +[dependencies] +zerolease = { version = "0.1" } +zerolease-store-rusqlite = { version = "0.1" } +``` + +## Integration Pattern + +### 1. Initialize the vault at startup + +```rust +use zerolease::keysource::keychain::KeychainSource; +use zerolease::policy::PolicyEngine; +use zerolease::vault::Vault; +use zerolease_store_rusqlite::{RusqliteStore, RusqliteAuditLog}; + +// Load the DEK from the OS keychain. +let key_source = KeychainSource::new("myapp", "vault-dek").await?; + +// Open (or create) the SQLite databases. +let store = RusqliteStore::new("secrets.db").await?; +let audit = RusqliteAuditLog::new("audit.db").await?; + +// Configure the policy engine. +let policy = PolicyEngine::new(grants); + +// Create the vault. +let vault = Vault::new(key_source, store, audit, policy); +``` + +### 2. Store credentials (admin operation) + +```rust +use zerolease::store::SecretKind; +use zerolease::transport::PeerIdentity; +use zerolease::types::SecretName; + +vault.store_secret( + &SecretName::new("github-pat"), + b"ghp_abc123...", + SecretKind::Pat, + Some("GitHub PAT for repo access".into()), + &PeerIdentity::Anonymous, // admin, no transport peer +).await?; +``` + +### 3. Grant access via policy + +```rust +use zerolease::policy::{PolicyGrant, GrantScope}; +use zerolease::types::{AgentId, DomainScope, SecretName}; + +let grants = vec![ + PolicyGrant { + agent: AgentId::new("tool-git"), + secret: SecretName::new("github-pat"), + domains: vec![DomainScope::new("github.com")], + max_ttl_seconds: 900, // 15 minutes + max_uses: Some(10), + renewable: false, + }, +]; +``` + +### 4. Lease credentials per-tool + +```rust +let peer = PeerIdentity::Anonymous; // in-process, no transport + +// Tool requests a lease. +let grant = vault.request_lease( + &AgentId::new("tool-git"), + &SecretName::new("github-pat"), + &DomainScope::new("github.com"), + &peer, +).await?; + +// Tool accesses the secret through the lease. +let guard = vault.access_secret( + &grant.lease_id, + &DomainScope::new("github.com"), + &peer, +).await?; + +// Use the credential. It's zeroized when `guard` drops. +guard.expose(|token| { + // Use token for git operation. +}); +// guard dropped here → secret zeroized from memory +``` + +### 5. Query the audit log + +```rust +let events = audit.query_by_agent( + &AgentId::new("tool-git"), + 100 +).await?; + +for event in events { + println!("{}: {:?}", event.timestamp, event.event); +} +``` + +## Adapting zeroclaw + +[zeroclaw](https://github.com/ceejbot/zeroclaw) has a built-in credential store. To replace it with zerolease: + +1. **Add the zerolease dependencies** to zeroclaw's `Cargo.toml`. +2. **Replace the built-in vault** with `Vault`. zeroclaw already uses rusqlite, so the `zerolease-store-rusqlite` crate avoids `libsqlite3-sys` link conflicts (it uses rusqlite, not sqlx). +3. **Adapt tool credential injection.** Where zeroclaw currently hands tools a raw credential string, replace with the lease-and-access pattern above. Each tool gets a `LeaseGuard` scoped to the domain it needs. +4. **Configure the policy engine** from zeroclaw's existing permission model. The flat grant list maps naturally to zeroclaw's per-tool permission declarations. +5. **Wire the audit log** to zeroclaw's observability. The `RusqliteAuditLog` is queryable; the `TracingAuditLog` emits structured events to the tracing subscriber. + +The key change is moving from "tool holds a credential for its lifetime" to "tool leases a credential for each operation." The vault's domain scoping and TTLs prevent lateral movement even if a tool is compromised. diff --git a/docs/guide-vm-deployment.md b/docs/guide-vm-deployment.md new file mode 100644 index 0000000..443f6ac --- /dev/null +++ b/docs/guide-vm-deployment.md @@ -0,0 +1,269 @@ +# Guide: VM Deployment with the Lease-Aware Proxy + +This guide describes the full production deployment model: AI coding agents running inside disposable QEMU virtual machines, managed by an orchestrator (the Claw), with the lease-aware proxy enforcing credential lifecycle at the network layer. + +This is the most secure deployment model zerolease supports. It combines defense-in-depth across four layers: vault policy, lease TTLs, network enforcement, and VM disposal. + +## Architecture + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ AWS Instance │ +│ │ +│ ┌─────────────────────────────────────────┐ │ +│ │ The Claw (orchestrator) │ │ +│ │ │ │ +│ │ ┌──────────────┐ ┌─────────────────┐ │ │ +│ │ │ zerolease │ │ Token Manager │ │ │ +│ │ │ vault │ │ │ │ │ +│ │ │ TCP :9100 │ │ Creates tokens │ │ │ +│ │ │ (localhost) │ │ per prompt run │ │ │ +│ │ └──────┬───────┘ └────────┬────────┘ │ │ +│ └─────────┼───────────────────┼───────────┘ │ +│ │ │ │ +│ ┌──────┴───────────────────┴──────────────────────┐ │ +│ │ QEMU host-forwarded port 9100 │ │ +│ └──────┬────────────────┬────────────────┬────────┘ │ +│ │ │ │ │ +│ ┌─────────┴──────┐ ┌──────┴───────┐ ┌──────┴───────┐ │ +│ │ QEMU VM │ │ QEMU VM │ │ QEMU VM │ │ +│ │ prompt-run-001│ │ prompt-run- │ │ prompt-run- │ │ +│ │ │ │ 002 │ │ 003 │ │ +│ │ ┌───────────┐ │ │ │ │ │ │ +│ │ │ proxy │ │ │ ... │ │ ... │ │ +│ │ │ :8080 │ │ │ │ │ │ │ +│ │ │ :8443 │ │ │ │ │ │ │ +│ │ ├───────────┤ │ │ │ │ │ │ +│ │ │ Claude │ │ │ │ │ │ │ +│ │ │ Code │ │ │ │ │ │ │ +│ │ │ + tools │ │ │ │ │ │ │ +│ │ └───────────┘ │ │ │ │ │ │ +│ │ postgres, etc │ │ │ │ │ │ +│ └────────────────┘ └──────────────┘ └──────────────┘ │ +│ │ +└──────────────────────────────────────────────────────────────────┘ +``` + +## The VM Image + +The VM image is pre-baked with the development environment and the zerolease agent binary. It ships with **no credentials of any kind**: + +- No `~/.ssh/id_*` +- No `~/.netrc` +- No `~/.npmrc` with tokens +- No `~/.config/gh/hosts.yml` +- No environment variables with secrets + +This is not incidental — it's a security requirement. zerolease-agent is the **sole credential source** inside the VM. Tools cannot fall back to a cached credential because there are none. + +### What the image includes + +- `zerolease-agent` binary (the `provision`, `proxy`, and `credential-fill` subcommands) +- Git configured to use the credential helper: `git config --system credential.helper '/usr/bin/zerolease-agent credential-fill'` +- Git configured to prefer HTTPS over SSH: `git config --system url."https://github.com/".insteadOf "git@github.com:"` +- iptables rules applied at init (see below) +- IPv6 disabled: `sysctl net.ipv6.conf.all.disable_ipv6=1` +- Language runtimes, databases, and other tools the agents need + +### iptables rules (applied at init, before any network-capable process) + +```bash +# Default deny all outbound. +iptables -P OUTPUT DROP + +# Allow loopback (proxy ↔ agent communication). +iptables -A OUTPUT -o lo -j ACCEPT + +# Allow the proxy user to reach external port 443. +iptables -A OUTPUT -m owner --uid-owner proxy-user -p tcp --dport 443 -j ACCEPT + +# Allow DNS to local resolver only. +iptables -A OUTPUT -d 127.0.0.1 -p udp --dport 53 -j ACCEPT +iptables -A OUTPUT -d 127.0.0.1 -p tcp --dport 53 -j ACCEPT + +# Allow the provisioner to reach the host vault. +iptables -A OUTPUT -d 10.0.2.2 -p tcp --dport 9100 \ + -m owner --uid-owner provision-user -j ACCEPT + +# Transparent proxy fallback: redirect any port-443 traffic that +# bypasses HTTPS_PROXY to the transparent proxy. +iptables -t nat -A OUTPUT -p tcp --dport 443 \ + -m owner ! --uid-owner proxy-user \ + -j REDIRECT --to-port 8443 +``` + +This creates a network jail. The only outbound path is through the proxy, which checks lease state on every connection. DNS is local-only. UDP, ICMP, SSH, and HTTP (port 80) are blocked. + +## VM Lifecycle + +### Phase 1: Boot + +The Claw boots the QEMU VM. The VM starts with iptables rules in place but no credentials, no tokens, and no network access (everything is blocked by default-deny). + +### Phase 2: Proxy startup + +```bash +zerolease-agent proxy \ + --port 8080 \ + --transparent-port 8443 \ + --lease-file /var/run/zerolease/leases.json & +``` + +The proxy starts in deny-all mode (no leases loaded yet). Any outgoing connection is blocked. This is intentional — the proxy must be running before any credentials exist in the VM. + +### Phase 3: Provisioning + +The Claw sends the prompt-run token to the VM and triggers provisioning: + +```bash +zerolease-agent provision \ + --vault-addr 10.0.2.2:9100 \ + --token "$PROMPT_RUN_TOKEN" \ + --manifest /etc/zerolease/credentials.json \ + --env-file /etc/zerolease/env \ + --lease-file /var/run/zerolease/leases.json \ + --credential-token "$CREDENTIAL_HELPER_TOKEN" +``` + +The provisioner: +1. Connects to the host vault with the prompt-run token. +2. Acquires a lease for each credential in the manifest. +3. Writes credential values to `/etc/zerolease/env` (sourceable shell file, mode 0600). +4. Writes config files (`.npmrc`, `pip.conf`, etc.) per the manifest. +5. Writes the lease state file (`/var/run/zerolease/leases.json`) — the proxy picks this up and starts allowing connections to leased domains. +6. Writes `ZEROLEASE_CREDENTIAL_TOKEN` (a separate, restricted token for the git credential helper) to the env file. +7. Exits. The prompt-run token dies with this process. + +After provisioning: +- The env file contains credential values and configuration (`HTTPS_PROXY`, `GIT_CONFIG_*`, `ZEROLEASE_VAULT_ADDR`, `ZEROLEASE_CREDENTIAL_TOKEN`). +- The proxy has lease state and is now allowing connections to leased domains. +- The prompt-run token no longer exists in any process or file. + +### Phase 4: Agent execution + +```bash +source /etc/zerolease/env +claude code +``` + +Claude Code starts with credentials in its environment. When it runs `git push`: + +1. Git calls `zerolease-agent credential-fill get` with the target host. +2. The credential helper connects to the vault with the restricted `ZEROLEASE_CREDENTIAL_TOKEN`, acquires a lease scoped to that exact domain, and returns the credential. +3. Git makes the HTTPS request through the proxy (`HTTPS_PROXY=http://127.0.0.1:8080`). +4. The proxy checks: does `github.com` have an active lease? Yes → tunnel established. +5. The git operation completes. + +When Claude Code runs `curl https://api.fastly.com/...` with `FASTLY_API_KEY` in the env: +1. curl connects through the proxy. +2. The proxy checks: does `api.fastly.com` have an active lease? Yes → tunnel established. +3. The request completes. + +When a tool tries to reach `https://evil.com`: +1. The proxy checks: does `evil.com` have an active lease? No → connection blocked (403). + +### Phase 5: Completion or revocation + +The prompt run completes (or the Claw decides to stop it): + +1. The Claw revokes the prompt-run token in the vault. +2. All leases issued under that token expire immediately. +3. The proxy's background refresh detects the expired leases and starts blocking. +4. The Claw destroys the VM. + +If the Claw crashes before revoking, the leases still expire on their own (TTL-based). The VM continues to function until the leases expire, then all network access is blocked. The VM can be cleaned up later. + +## The Credential Manifest + +The manifest describes what the agent needs. It's injected into the VM alongside the provisioner configuration (via cloud-init, QEMU `-fw_cfg`, or a shared filesystem). + +```json +{ + "credentials": [ + { + "secret_name": "github-pat", + "target_domain": "github.com", + "inject": [ + { "type": "env", "var": "GH_TOKEN" }, + { "type": "git_credential", "host": "github.com" } + ] + }, + { + "secret_name": "npm-token", + "target_domain": "registry.npmjs.org", + "inject": [ + { "type": "env", "var": "NPM_TOKEN" }, + { + "type": "file", + "path": "~/.npmrc", + "template": "//registry.npmjs.org/:_authToken=${SECRET}" + } + ] + }, + { + "secret_name": "fastly-key", + "target_domain": "api.fastly.com", + "inject": [ + { "type": "env", "var": "FASTLY_API_KEY" } + ] + } + ] +} +``` + +Each credential can use multiple injection mechanisms: + +| Mechanism | How it works | Security properties | +|-----------|-------------|---------------------| +| `env` | Sets an environment variable | Visible to all child processes. Proxy enforces at the network level. | +| `file` | Writes a config file from a template (`${SECRET}` placeholder, mode 0600) | Only readable by the owning user. Proxy enforces at the network level. | +| `git_credential` | Registers a git credential helper mapping for this host | Per-request domain validation via the vault. The strongest injection mechanism. | + +## Security Properties + +**Four layers of defense:** + +1. **Vault policy**: The policy engine decides which agents can access which secrets for which domains. Deny-by-default. +2. **Lease TTLs**: Every credential access is time-bounded. Leases expire automatically. +3. **Network enforcement**: The proxy blocks connections to domains without active leases. Lease revocation means network cutoff. +4. **VM disposal**: The VM is destroyed when the prompt run ends. No credential material persists. + +**Assumptions that must hold:** + +- The VM image is trusted and not tampered with. (Consider dm-verity or signed images.) +- The proxy runs as a separate user that the agent cannot kill, signal, or impersonate. +- iptables rules are applied before any network-capable process starts. +- IPv6 is disabled (or duplicated in ip6tables). +- The prompt-run token is not baked into the VM image — it's delivered at provisioning time and dies with the provisioner process. + +**Known residual risks:** + +- A tool with a lease for `github.com` can exfiltrate data through legitimate GitHub API calls. Domain-level access control cannot prevent this. +- Environment variables are readable by sibling processes (`/proc//environ`). Mitigated by VM disposal and lease TTLs. +- The proxy's transparent mode (SNI extraction) is weaker than the explicit mode (CONNECT). SNI can be spoofed. The transparent mode is defense-in-depth, not primary enforcement. + +## Proxy Hardening + +The proxy validates more than just lease state: + +- **Port restriction**: Only ports 443 and 8443 are allowed. A `CONNECT github.com:22` is blocked even with a valid domain lease. +- **Private IP blocking**: DNS resolution results are checked against private ranges (10.x, 172.16.x, 192.168.x, 169.254.x). Prevents SSRF to cloud metadata services or internal networks. +- **Hostname validation**: Only valid DNS characters allowed. Path traversal and injection attempts are rejected. +- **Request size limits**: CONNECT request lines capped at 8 KiB, max 64 headers. Prevents OOM via unbounded reads. +- **Tunnel timeout**: Tunnels are forcibly closed when the lease expires (or after 1 hour, whichever comes first). +- **Case normalization**: Domain matching is case-insensitive. +- **Absent SNI = deny**: The transparent proxy drops connections without a parseable SNI hostname. + +## What the Claw Provides + +zerolease provides the vault, transports, and agent binary. The orchestrator (the Claw) is responsible for: + +| Responsibility | What it means | +|---------------|---------------| +| **Token lifecycle** | Create a prompt-run token before booting the VM. Revoke it when done. | +| **Manifest generation** | Decide which credentials this prompt run needs, based on the repos and tools involved. | +| **VM provisioning** | Boot QEMU with the right image, host-forward port 9100, inject the manifest and token. | +| **VM destruction** | Kill the VM when the prompt run ends (or on timeout). | +| **Policy management** | Configure the vault's grant list: which agents get which secrets for which domains. | +| **Authenticator implementation** | Map prompt-run tokens to `ConnectionIdentity` (role, agent binding). The `TokenAuthenticator` in zerolease is a reference implementation; production deployments may need richer logic. | +| **Monitoring** | Watch audit events, set alerts for anomalies (unexpected domains, high lease counts, revocation failures). | diff --git a/docs/implementation/specs/2026-03-25-aead-encryption-layer-design.md b/docs/implementation/specs/2026-03-25-aead-encryption-layer-design.md deleted file mode 100644 index 04d00a8..0000000 --- a/docs/implementation/specs/2026-03-25-aead-encryption-layer-design.md +++ /dev/null @@ -1,178 +0,0 @@ -# AEAD Encryption Layer Design - -**Date:** 2026-03-25 -**Status:** Draft -**Scope:** New `src/crypto.rs` module + vault integration - -## Problem - -The vault stores secrets encrypted at rest, but the actual AEAD encryption and decryption are not implemented. `Vault::decrypt` is a placeholder that always returns `Error::DecryptionFailed`, and no `encrypt` path exists. Without this layer, nothing downstream (secret storage, key rotation, integration tests) can work with real encrypted data. - -## Design - -### New module: `src/crypto.rs` - -A standalone module that owns all AEAD operations. The vault never touches `aes_gcm` or `chacha20poly1305` directly. - -### Types - -```rust -/// Output of an AEAD encryption operation. -/// Maps 1:1 to the ciphertext/nonce/algorithm fields on StoredSecret. -/// -/// Not Clone — this is the transient in-memory crypto output, distinct from -/// StoredSecret (which is Clone because the store backend needs to copy it -/// for database round-trips). Sealed lives only between encrypt/decrypt calls. -/// -/// Not Zeroize — ciphertext is not secret (the key is). Zeroizing ciphertext -/// would add overhead with no security benefit. -pub struct Sealed { - pub ciphertext: Vec, - pub nonce: Vec, - pub algorithm: CipherAlgorithm, -} - -/// AEAD cipher dispatcher. Holds a default algorithm for encryption; -/// decryption reads the algorithm from the Sealed value. -pub struct Cipher { - default_algorithm: CipherAlgorithm, -} -``` - -### API - -```rust -impl Cipher { - pub fn new(default_algorithm: CipherAlgorithm) -> Self; - - /// Encrypt plaintext using the default algorithm. - /// Generates a fresh random nonce via OsRng. - pub fn encrypt(&self, plaintext: &[u8], key: &DataEncryptionKey) -> Result; - - /// Decrypt a Sealed value. Dispatches on sealed.algorithm, - /// NOT on self.default_algorithm. - pub fn decrypt(&self, sealed: &Sealed, key: &DataEncryptionKey) -> Result>; -} -``` - -### Internal dispatch - -Private helper functions per algorithm. The `Cipher` public methods call `key.as_bytes()` on the `DataEncryptionKey` to obtain `&[u8; 32]` before passing to these helpers: - -```rust -fn encrypt_aes_gcm(plaintext: &[u8], key: &[u8; 32]) -> Result<(Vec, Vec)>; -fn decrypt_aes_gcm(ciphertext: &[u8], nonce: &[u8], key: &[u8; 32]) -> Result>; -fn encrypt_xchacha(plaintext: &[u8], key: &[u8; 32]) -> Result<(Vec, Vec)>; -fn decrypt_xchacha(ciphertext: &[u8], nonce: &[u8], key: &[u8; 32]) -> Result>; -``` - -### Nonce generation - -- AES-256-GCM: 12-byte nonce via `Aes256Gcm::generate_nonce(&mut OsRng)` -- XChaCha20-Poly1305: 24-byte nonce via `XChaCha20Poly1305::generate_nonce(&mut OsRng)` - -Both use the `aead` crate's `AeadCore` trait, which delegates to `OsRng`. No manual nonce construction. - -### Error mapping - -Both crates return `aead::Error` (an opaque unit struct). Mapped to: - -- Encryption failure → `Error::EncryptionFailed` -- Decryption failure → `Error::DecryptionFailed` - -No wrapping of the inner error since it carries no information. - -### Return type - -`decrypt` returns `Vec`, not `String`. The crypto layer is byte-oriented so it works for binary secrets (SSH keys, client certs). UTF-8 conversion happens at the vault layer. - -## Vault integration - -### New field - -`Vault` gains `cipher: Cipher`. The `Vault::new` signature changes from: - -```rust -pub fn new(key_source: K, store: S, audit: A, policy: PolicyEngine) -> Self -``` - -to: - -```rust -pub fn new(key_source: K, store: S, audit: A, policy: PolicyEngine, default_algorithm: CipherAlgorithm) -> Self -``` - -The `Cipher` is constructed internally: `cipher: Cipher::new(default_algorithm)`. - -### Updated `decrypt` method - -```rust -async fn decrypt(&self, ciphertext: &[u8], nonce: &[u8], algorithm: CipherAlgorithm) -> Result { - let dek = self.dek.read().await; - let dek = dek.as_ref().ok_or(Error::KeySourceUnavailable("vault not initialized (no DEK)".into()))?; - let sealed = Sealed { ciphertext: ciphertext.to_vec(), nonce: nonce.to_vec(), algorithm }; - let bytes = self.cipher.decrypt(&sealed, dek)?; - String::from_utf8(bytes).map_err(|_| Error::DecryptionFailed) -} -``` - -The signature gains `algorithm`. The existing call site in `access_secret` (vault.rs line 214) must be updated from: - -```rust -let plaintext = self.decrypt(&stored.ciphertext, &stored.nonce).await?; -``` - -to: - -```rust -let plaintext = self.decrypt(&stored.ciphertext, &stored.nonce, stored.algorithm).await?; -``` - -### UTF-8 assumption - -The vault-level `decrypt` returns `String` because `LeaseGuard` wraps `SecretString`, and all current `SecretKind` variants are text-based (PATs, API keys, OAuth tokens, JSON-serialized basic auth). Binary secrets (SSH keys, client certs) are a future concern — when supported, the vault will need a `decrypt_bytes` variant or `LeaseGuard` will need to support `SecretVec`. For now, UTF-8 failure maps to `Error::DecryptionFailed`, which is acceptable since non-UTF-8 data reaching this path would indicate corruption or a bug, not a legitimate binary secret. - -### New `encrypt` helper - -```rust -async fn encrypt(&self, plaintext: &[u8]) -> Result { - let dek = self.dek.read().await; - let dek = dek.as_ref().ok_or(Error::KeySourceUnavailable("vault not initialized (no DEK)".into()))?; - self.cipher.encrypt(plaintext, dek) -} -``` - -Used by a future `store_secret` method (out of scope for this spec). - -### No new public vault API - -This spec wires up internal helpers. No new public `store_secret` method — that depends on a concrete `SecretStore` implementation and is separate work. - -## Algorithm selection strategy - -- **Encrypt** always uses `self.default_algorithm` (configured at vault construction). -- **Decrypt** always dispatches on `sealed.algorithm` (stored with the ciphertext). -- This enables algorithm migration: change the default, and new secrets use the new algorithm while old secrets still decrypt correctly. -- Default recommendation: `Aes256Gcm` (hardware-accelerated on x86_64 via AES-NI). - -## Testing - -All tests are unit tests in `src/crypto.rs`. No async, no vault, pure crypto. - -1. **Round-trip per algorithm:** Encrypt then decrypt with same key, verify plaintext matches. One test for AES-256-GCM, one for XChaCha20-Poly1305. -2. **Wrong key:** Encrypt with key A, decrypt with key B → `Error::DecryptionFailed`. -3. **Tampered ciphertext:** Flip a byte in ciphertext → `Error::DecryptionFailed`. -4. **Tampered nonce:** Alter nonce → `Error::DecryptionFailed`. -5. **Cross-algorithm mismatch:** Encrypt with AES-GCM, then manually construct a `Sealed` with the same ciphertext/nonce but `algorithm: XChaCha20Poly1305`. Decrypt should fail → `Error::DecryptionFailed`. (Must manually construct because `encrypt` always sets the correct algorithm.) -6. **Empty plaintext:** Verify AEAD handles zero-length input correctly. -7. **Algorithm stored correctly:** Encrypt with AES-GCM default, verify `sealed.algorithm == Aes256Gcm`. Same for XChaCha20. - -No vault integration tests in this scope — the vault wiring is thin enough that crypto unit tests plus existing vault tests cover the risk. - -## Out of scope - -- Concrete `KeySource` implementations (keychain, KMS, env var) -- Concrete `SecretStore` implementations (SQLite, PostgreSQL) -- Public `store_secret` method on `Vault` -- `async_trait` migration (separate cleanup) -- Wire protocol diff --git a/docs/implementation/specs/2026-03-25-keysource-implementations-design.md b/docs/implementation/specs/2026-03-25-keysource-implementations-design.md deleted file mode 100644 index e51e160..0000000 --- a/docs/implementation/specs/2026-03-25-keysource-implementations-design.md +++ /dev/null @@ -1,123 +0,0 @@ -# KeySource Implementations Design - -**Date:** 2026-03-25 -**Status:** Draft -**Scope:** New `src/keysource/env.rs` and `src/keysource/keychain.rs` - -## Problem - -The `KeySource` trait is defined but has no concrete implementations. Without one, the vault cannot initialize its DEK, so nothing downstream (storing secrets, issuing leases, integration tests) can work. Two implementations are needed for the two primary deployment targets: developer laptops (OS keychain) and CI/testing (environment variable). - -## Design - -### File structure - -``` -src/keysource/ - mod.rs — existing trait + types (add module declarations) - env.rs — EnvVarSource - keychain.rs — KeychainSource (cfg(unix) only) -``` - -`mod.rs` gains: - -```rust -pub mod env; -#[cfg(unix)] -pub mod keychain; -``` - -`keychain` is `cfg(unix)` because the `keyring` dependency is gated with `[target.'cfg(unix)'.dependencies]` in `Cargo.toml`. - -### `EnvVarSource` - -```rust -pub struct EnvVarSource { - var_name: String, -} -``` - -**Constructor:** `EnvVarSource::new(var_name: impl Into)` — stores the variable name. Does not read the environment at construction time. - -**`load_or_create_dek`:** Reads the env var, hex-decodes it (64 hex chars → 32 bytes), returns `DataEncryptionKey::from_bytes`. There is no "create" path — an env var source is read-only. If the variable is not set, that's an error, not a prompt to generate a key. - -Hex decoding uses a manual implementation (no `hex` crate). For a fixed 64-char input, this is a few lines of code. - -Errors: -- Variable not set → `Error::KeySourceUnavailable("env var not set")` -- Not valid hex → `Error::InvalidConfig("env var is not valid hex")` -- Wrong length (not 64 hex chars / 32 bytes) → `Error::InvalidConfig("env var must be 64 hex characters (32 bytes)")` - -**`rotate_dek`:** Accepts the `new_dek: &DataEncryptionKey` parameter (as required by the trait) but ignores it. Returns `Error::KeySourceUnavailable("cannot rotate an env-var key source; set a new value and restart")`. - -**`description`:** Returns `"env:"`. Never includes the key value. - -### `KeychainSource` - -```rust -pub struct KeychainSource { - service: String, - account: String, -} -``` - -**Constructor:** `KeychainSource::new(service: impl Into, account: impl Into)` — stores the keychain entry coordinates. - -**`load_or_create_dek`:** The implementation clones `self.service` and `self.account` into the closure, then wraps all keyring calls in `tokio::task::spawn_blocking` to avoid blocking the async runtime. A `JoinError` (panic in the blocking task) maps to `Error::KeySourceUnavailable`. - -Inside the blocking closure: - -1. Call `keyring::Entry::new(&service, &account)`. This is fallible in keyring 3.6 — map errors to `Error::KeySourceUnavailable`. -2. Call `entry.get_secret()`: - - **Ok, 32 bytes:** return `DataEncryptionKey::from_bytes`. - - **Ok, wrong length:** return `Error::InvalidConfig("keychain entry has wrong key length (expected 32 bytes)")`. - - **Err, `keyring::Error::NoEntry`:** generate 32 random bytes via `OsRng` (re-exported from `aes_gcm::aead::OsRng`, already a transitive dependency), call `entry.set_secret(&bytes)`. If `set_secret` fails (keychain locked, permission denied), return `Error::KeySourceUnavailable` with the keyring error message. Otherwise return the new DEK. - - **Err, other:** return `Error::KeySourceUnavailable` with the keyring error message. - -**`rotate_dek`:** Accepts the `new_dek: &DataEncryptionKey` parameter but ignores it. Returns `Error::KeySourceUnavailable("DEK rotation not yet supported for keychain source")`. - -**`description`:** Returns `"keychain:/"`. - -### Why `KeySourceUnavailable` for rotation errors - -Both implementations use `Error::KeySourceUnavailable` (not `InvalidConfig`) for rotation failures. The source is correctly configured — it simply doesn't support this operation. `InvalidConfig` is reserved for actual configuration problems (wrong hex, missing var, bad key length). - -### `EncryptedDek` return from `rotate_dek` - -Both implementations return an error from `rotate_dek`, so the `EncryptedDek` return type is never constructed. Rotation is a heavyweight operation requiring re-encryption of all secrets via the `SecretStore`, which has no concrete implementation yet. This will be revisited when the SQLite store lands. - -## Testing - -### `EnvVarSource` tests (unit tests in `env.rs`) - -Tests use unique variable names per test (e.g., `ZEROLEASE_TEST_ENV_1`, `_2`, etc.) to avoid collisions. Since `std::env::set_var` is process-global and not thread-safe, env tests must run sequentially. Run with `cargo test keysource::env -- --test-threads=1`. - -Note: as of Rust edition 2024, `std::env::set_var` is `unsafe` because it is not thread-safe. Tests that call it must use an `unsafe` block. - -1. **Valid hex key loads:** Set env var to a known 64-char hex string, verify `load_or_create_dek` returns the expected bytes. -2. **Missing env var errors:** Use an unset var name → `KeySourceUnavailable`. -3. **Bad hex errors:** Set var to `"not-hex-at-all"` → `InvalidConfig`. -4. **Wrong length errors:** Set var to valid hex but only 20 chars → `InvalidConfig`. -5. **Rotate returns error:** Verify `rotate_dek` returns `KeySourceUnavailable`. -6. **Description format:** Verify `description()` contains the var name, not the key value. - -### `KeychainSource` tests (integration tests in `keychain.rs`, `#[ignore]`) - -These require a real OS keychain, which is not available in CI or headless environments. All keychain tests are marked `#[ignore]` so they don't run in the default test suite. Run manually with `cargo test keysource::keychain -- --ignored`. - -1. **Create then load round-trip:** Create a `KeychainSource` with a unique test service/account (e.g., `"zerolease-test"` / `"test-dek-roundtrip"`), call `load_or_create_dek` (creates new), call again (loads existing), verify both return the same key bytes. Clean up the keychain entry after the test using `keyring::Entry::new(...).unwrap().delete_credential()`. -2. **Description format:** Verify `description()` returns `"keychain:/"`. - -## Dependencies - -No new crate dependencies: -- `keyring 3.6` is already in `[target.'cfg(unix)'.dependencies]` -- `OsRng` is re-exported through `aes_gcm::aead::OsRng` (transitive, already used in `src/crypto.rs`) -- Hex decoding is manual (no `hex` crate) -- `tokio::task::spawn_blocking` is available from `tokio` with `features = ["full"]` - -## Out of scope - -- `KmsSource` (AWS KMS envelope encryption) -- DEK rotation implementation (deferred until SecretStore exists) -- `KeySourceConfig` → concrete source construction (runtime config dispatch) diff --git a/docs/implementation/specs/2026-03-25-store-secret-sqlite-design.md b/docs/implementation/specs/2026-03-25-store-secret-sqlite-design.md deleted file mode 100644 index 0c2e227..0000000 --- a/docs/implementation/specs/2026-03-25-store-secret-sqlite-design.md +++ /dev/null @@ -1,165 +0,0 @@ -# store_secret + SQLite SecretStore Design - -**Date:** 2026-03-25 -**Status:** Draft -**Scope:** New `src/store/sqlite.rs`, new `Vault::store_secret` method, vault integration test - -## Problem - -The vault has working encryption (`Cipher`), key management (`EnvVarSource`, `KeychainSource`), leasing, and policy, but no way to actually store or retrieve secrets. The `SecretStore` trait is defined but has no implementation, and the vault has no `store_secret` method. Without these, the vault cannot function end-to-end. - -## Design - -### File structure - -| Action | Path | Responsibility | -|--------|------|---------------| -| Create | `src/store/sqlite.rs` | `SqliteStore` struct, `SecretStore` impl, schema migration, unit tests | -| Modify | `src/store/mod.rs` | Add `#[cfg(feature = "sqlite")] pub mod sqlite;` and `From<&StoredSecret> for SecretMetadata` | -| Modify | `src/vault.rs` | Add `store_secret` public method, vault integration test with `NoopAuditLog` | - -### SQLite schema - -```sql -CREATE TABLE IF NOT EXISTS secrets ( - id TEXT PRIMARY KEY, - name TEXT UNIQUE NOT NULL, - ciphertext BLOB NOT NULL, - nonce BLOB NOT NULL, - algorithm TEXT NOT NULL, - kind TEXT NOT NULL, - description TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - version INTEGER NOT NULL DEFAULT 1 -); -``` - -- `id`: `SecretId` UUID as text. -- `algorithm`: serde-serialized enum variant name (`"Aes256Gcm"` or `"XChaCha20Poly1305"`). -- `kind`: full JSON blob from `serde_json::to_string(&kind)` (e.g., `"\"Pat\""` or `{"OAuth2":{"refresh_ciphertext":...}}`). -- Timestamps: ISO 8601 text (SQLite has no native datetime; text is most portable). - -### `SqliteStore` - -```rust -pub struct SqliteStore { - pool: SqlitePool, -} -``` - -**Constructor:** `SqliteStore::new(path: impl AsRef) -> Result` - -Creates the SQLite file if needed using `SqliteConnectOptions::new().filename(path).create_if_missing(true)`, opens a connection pool via `SqlitePoolOptions`, and runs `CREATE TABLE IF NOT EXISTS`. The constructor is async (it performs I/O). - -**Trait method implementations:** - -- **`put`:** Generates `SecretId::new()`, `Utc::now()` for timestamps inside the method (the caller only provides `StoreSecretParams` which has no id/timestamps). Runs `INSERT INTO secrets (id, name, ciphertext, nonce, algorithm, kind, description, created_at, updated_at, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`. On UNIQUE constraint violation (`sqlx::Error::Database` with SQLite error code), return `Error::SecretAlreadyExists(name)`. On success, return the `StoredSecret`. - -- **`get`:** `SELECT * FROM secrets WHERE name = ?`. If no row returned, return `Error::SecretNotFound(name)`. Deserialize `algorithm` and `kind` from their text representations using `serde_json::from_str`. Map deserialization errors to `Error::Storage`. - -- **`update`:** `UPDATE secrets SET ciphertext = ?, nonce = ?, algorithm = ?, updated_at = ?, version = version + 1 WHERE name = ?`. If rows affected is 0, return `Error::SecretNotFound(name)`. Then `SELECT` the updated row to return the full `StoredSecret`. Note: `update` only rotates the ciphertext/nonce/algorithm — it does not change `description` or `kind`. This is by design: `update` is for key rotation, not metadata editing. - -- **`delete`:** `DELETE FROM secrets WHERE name = ?`. If rows affected is 0, return `Error::SecretNotFound(name)`. - -- **`list`:** `SELECT id, name, kind, description, created_at, updated_at, version FROM secrets`. Returns `Vec`. Never selects ciphertext or nonce. - -**Serialization/deserialization:** `algorithm` and `kind` are stored as their `serde_json::to_string` output (e.g., `"\"Aes256Gcm\""` becomes the TEXT value `"Aes256Gcm"` in SQLite, `serde_json` on `SecretKind::Pat` produces `"\"Pat\""` → stored as `"Pat"`). Deserialized back with `serde_json::from_str`. Both types derive `Serialize`/`Deserialize`. Deserialization errors map to `Error::Storage`. - -### `From<&StoredSecret> for SecretMetadata` - -Added to `src/store/mod.rs`: - -```rust -impl From<&StoredSecret> for SecretMetadata { - fn from(s: &StoredSecret) -> Self { - Self { - id: s.id, // SecretId is Copy - name: s.name.clone(), - kind: s.kind.clone(), - description: s.description.clone(), - created_at: s.created_at, // DateTime is Copy - updated_at: s.updated_at, - version: s.version, - } - } -} -``` - -`SecretId` derives `Copy` (in `types.rs`). `DateTime` is `Copy` (from chrono). So `id`, `created_at`, `updated_at`, and `version` are all copied by value. - -### `Vault::store_secret` - -```rust -pub async fn store_secret( - &self, - name: &SecretName, - plaintext: &[u8], - kind: SecretKind, - description: Option, - peer: &PeerIdentity, -) -> Result -``` - -Flow: -1. `let sealed = self.encrypt(plaintext).await?` — fails with `Error::KeySourceUnavailable` if vault is not initialized (DEK is `None`) -2. Build `StoreSecretParams { name: name.clone(), ciphertext: sealed.ciphertext, nonce: sealed.nonce, algorithm: sealed.algorithm, kind, description }` -3. `let stored = self.store.put(params).await?` — fails with `Error::SecretAlreadyExists` if duplicate name -4. Audit: `self.audit.record(AuditEntry::new(AuditEvent::SecretStored { secret_name: name.clone() }, AgentId::new("admin"), peer, AuditOutcome::Success)).await?` -5. Return `SecretMetadata::from(&stored)` - -`AgentId::new(impl Into)` exists in `types.rs`. Uses `"admin"` as a placeholder since secret storage is an admin operation, not agent-initiated. The `self.encrypt` helper (added in the AEAD spec) is used here for the first time. - -### Runtime SQL queries (no compile-time checking) - -All queries use `sqlx::query` / `sqlx::query_as` with runtime string SQL (not the `sqlx::query!` macro). No `DATABASE_URL` or offline query cache needed. SQL correctness is verified by the test suite. - -## Testing - -### `SqliteStore` tests (unit tests in `sqlite.rs`) - -Each test uses `tempfile::NamedTempFile` for an isolated database. New dev-dependency: `tempfile`. - -1. **Put and get round-trip:** Store a secret, retrieve by name, verify all fields match. -2. **Put duplicate name errors:** Store, store same name → `SecretAlreadyExists`. -3. **Get missing errors:** Get nonexistent name → `SecretNotFound`. -4. **Update increments version:** Store (version 1), update, verify version 2 and new ciphertext. -5. **Update missing errors:** Update nonexistent → `SecretNotFound`. -6. **Delete removes secret:** Store, delete, get → `SecretNotFound`. -7. **Delete missing errors:** Delete nonexistent → `SecretNotFound`. -8. **List returns metadata:** Store two secrets, list, verify both returned with correct metadata. - -### Vault integration test (in `src/vault.rs` test module) - -A `NoopAuditLog` test helper that implements all 4 `AuditLog` trait methods: -- `record` → `Ok(())` -- `query_by_agent` → `Ok(vec![])` -- `query_by_secret` → `Ok(vec![])` -- `query_by_lease` → `Ok(vec![])` - -Combined with `EnvVarSource` and `SqliteStore`, this enables the first end-to-end vault test: - -1. Set env var with a hex-encoded test key -2. Create vault with `EnvVarSource` + `SqliteStore` (tempfile) + `NoopAuditLog` + a `PolicyEngine` configured with a grant for `AgentId::new("test-agent")` allowing access to the test secret on `DomainScope::new("api.example.com")` -3. Initialize vault (`vault.initialize().await`) -4. `store_secret` — store a plaintext secret (e.g., `b"my-api-token"`) -5. `request_lease` — request a lease as `"test-agent"` for the stored secret name and domain -6. `access_secret` — use the lease to decrypt, verify the plaintext matches the original `b"my-api-token"` - -This test validates the complete flow: encrypt → store → lease → decrypt. - -## Dependencies - -- **New dev-dependency:** `tempfile` — add to `Cargo.toml`: - ```toml - [dev-dependencies] - tempfile = "3" - ``` -- **Existing:** `sqlx` with `sqlite` feature (already in Cargo.toml, gated on `sqlite` feature flag) - -## Out of scope - -- PostgreSQL `SecretStore` implementation -- Secret rotation via `update` (the method exists but no vault-level orchestration) -- Concrete `AuditLog` implementations (the `NoopAuditLog` is test-only) -- Agent-based authorization for secret management diff --git a/docs/implementation/specs/2026-03-26-async-trait-migration-design.md b/docs/implementation/specs/2026-03-26-async-trait-migration-design.md deleted file mode 100644 index fff89ff..0000000 --- a/docs/implementation/specs/2026-03-26-async-trait-migration-design.md +++ /dev/null @@ -1,101 +0,0 @@ -# async_trait Migration Design - -**Date:** 2026-03-26 -**Status:** Draft -**Scope:** Replace all `Pin>` trait methods with `#[async_trait]` across 5 traits and 11 implementations - -## Problem - -All backend traits (`KeySource`, `SecretStore`, `AuditLog`, `VaultListener`, `VaultConnector`) use hand-written `Pin + Send + '_>>` return types. This is verbose and produces the same code that `async_trait` generates. The `async_trait` macro provides the same behavior with cleaner syntax. - -## Design - -Add `async-trait = "0.1"` as a dependency. Apply `#[async_trait]` to all 5 trait definitions and all 11 implementations. Remove the manual `Pin>` return types and replace with `async fn`. - -### Traits to migrate - -| Trait | File | Methods | -|-------|------|---------| -| `KeySource` | `src/keysource/mod.rs` | `load_or_create_dek`, `rotate_dek` | -| `SecretStore` | `src/store/mod.rs` | `put`, `get`, `update`, `delete`, `list` | -| `AuditLog` | `src/audit/mod.rs` | `record`, `query_by_agent`, `query_by_secret`, `query_by_lease` | -| `VaultListener` | `src/transport/mod.rs` | `accept` | -| `VaultConnector` | `src/transport/mod.rs` | `connect` | - -### Implementations to migrate - -| Impl | File | -|------|------| -| `EnvVarSource` | `src/keysource/env.rs` | -| `KeychainSource` | `src/keysource/keychain.rs` | -| `SqliteStore` | `src/store/sqlite.rs` | -| `SqliteAuditLog` | `src/audit/sqlite.rs` | -| `NoopAuditLog` (vault tests) | `src/vault.rs` | -| `NoopAuditLog` (server tests) | `src/server.rs` | -| `NoopAuditLog` (client tests) | `src/client.rs` | -| `UdsListener` | `src/transport/uds.rs` | -| `UdsConnector` | `src/transport/uds.rs` | -| `VsockListener` | `src/transport/vsock.rs` | -| `VsockConnector` | `src/transport/vsock.rs` | - -### Transformation pattern - -**Before (trait definition):** -```rust -pub trait SecretStore: Send + Sync + 'static { - fn get(&self, name: &SecretName) -> Pin> + Send + '_>>; -} -``` - -**After:** -```rust -#[async_trait::async_trait] -pub trait SecretStore: Send + Sync + 'static { - async fn get(&self, name: &SecretName) -> Result; -} -``` - -**Before (implementation):** -```rust -impl SecretStore for SqliteStore { - fn get(&self, name: &SecretName) -> Pin> + Send + '_>> { - let name = name.clone(); - Box::pin(async move { - // ... - }) - } -} -``` - -**After:** -```rust -#[async_trait::async_trait] -impl SecretStore for SqliteStore { - async fn get(&self, name: &SecretName) -> Result { - // ... (body of the async block, no Box::pin, no clone-before-pin) - } -} -``` - -### What changes - -- `Pin + Send + '_>>` return types → `async fn` -- `Box::pin(async move { ... })` wrappers → bare function body -- Pre-`Box::pin` parameter cloning (e.g., `let name = name.clone();` before `Box::pin`) → removed (async_trait handles lifetimes) -- `use std::future::Future; use std::pin::Pin;` → removed from files that only used them for traits -- `#[allow(clippy::type_complexity)]` on `VaultListener::accept` → removed - -### What doesn't change - -- Trait bounds (`Send + Sync + 'static`) — kept -- Method signatures (parameter types, return types inside the Future) — kept -- Method bodies (the actual logic) — kept -- All existing tests — no behavior change - -## Testing - -No new tests. All 92 existing tests must continue to pass. This is a purely mechanical refactor with no behavior change. - -## Dependencies - -- Add `async-trait = "0.1"` to `[dependencies]` in `Cargo.toml` diff --git a/docs/implementation/specs/2026-03-26-client-library-design.md b/docs/implementation/specs/2026-03-26-client-library-design.md deleted file mode 100644 index f5e988c..0000000 --- a/docs/implementation/specs/2026-03-26-client-library-design.md +++ /dev/null @@ -1,159 +0,0 @@ -# Client Library Design - -**Date:** 2026-03-26 -**Status:** Draft -**Scope:** `src/client.rs` (VaultClient), `src/transport/uds.rs` (UdsConnector), new `Error::Remote` variant - -## Problem - -The vault server accepts connections and dispatches requests, but there is no typed Rust client to talk to it. Consumers would have to manually construct JSON, handle framing, base64 encode/decode, and parse responses. A typed client hides all wire protocol details behind ergonomic Rust methods. - -## Design - -### File structure - -| Action | Path | Responsibility | -|--------|------|---------------| -| Create | `src/client.rs` | `VaultClient`, typed methods, handshake, internal request helper | -| Modify | `src/transport/uds.rs` | Add `UdsConnector` implementing `VaultConnector` | -| Modify | `src/error.rs` | Add `Error::Remote { code, message }` variant | -| Modify | `src/lib.rs` | Add `pub mod client;` | - -### `VaultClient` - -```rust -pub struct VaultClient { - reader: ReadHalf, - writer: WriteHalf, -} -``` - -Uses `tokio::io::split` to hold the two halves of the stream. These are `tokio::io::ReadHalf` and `tokio::io::WriteHalf` from `tokio::io::split` (not the borrowing `ReadHalf`/`WriteHalf` from `UnixStream::split`). Both implement `Unpin` regardless of the inner stream type, so they work with `read_frame`/`write_frame`. - -All methods take `&mut self` — the client is not shareable across tasks without external synchronization. - -**Constructor:** `VaultClient::connect(connector: &C) -> Result` - -1. `connector.connect().await?` — get the stream -2. `tokio::io::split(stream)` — split into reader/writer -3. Serialize `ClientHello::new()` → `write_frame` -4. `read_frame` → deserialize `ServerHello` — if `ok: false`, return `Error::Transport` with the rejection message -5. Return the client - -After `connect` succeeds, the handshake is done and the client is ready for requests. - -**Internal request helper:** - -```rust -async fn request(&mut self, method: &str, params: serde_json::Value) -> Result -``` - -1. Generate `Uuid::now_v7()` as the request ID -2. Build `Request { id, method, params }` -3. `serde_json::to_vec(&request)` → `write_frame(&mut self.writer, &bytes)` -4. `read_frame(&mut self.reader)` → `serde_json::from_slice::(&bytes)` -5. If `response.ok`: return `response.result.unwrap_or(serde_json::json!({}))` -6. If `!response.ok`: convert `response.error` to `Error::Remote` and return `Err` - -### Typed methods - -All 8 protocol methods, each calling `self.request()` internally: - -| Method | Signature | Notes | -|--------|-----------|-------| -| `store_secret` | `(&mut self, name: &str, plaintext: &[u8], kind: SecretKind, description: Option) -> Result` | Base64-encodes plaintext, serializes kind | -| `request_lease` | `(&mut self, agent: &str, secret_name: &str, domain: &str) -> Result` | | -| `access_secret` | `(&mut self, lease_id: Uuid, target_domain: &str) -> Result>` | Deserializes result as `AccessSecretResponse`, base64-decodes `.secret` field | -| `revoke_lease` | `(&mut self, lease_id: Uuid, reason: RevocationReason) -> Result<()>` | Serializes reason | -| `revoke_all_for_agent` | `(&mut self, agent: &str) -> Result` | Returns revoked_count | -| `list_secrets` | `(&mut self) -> Result>` | Deserializes result as `ListSecretsResponse`, then deserializes each element in `.secrets` as `SecretMetadata` | -| `renew_lease` | `(&mut self, lease_id: Uuid, extension_secs: i64) -> Result` | | -| `delete_secret` | `(&mut self, name: &str) -> Result<()>` | | - -The caller works with Rust types throughout. No JSON, no base64, no request IDs. - -### `UdsConnector` - -```rust -pub struct UdsConnector { - path: PathBuf, -} -``` - -**Constructor:** `UdsConnector::new(path: impl Into)` — stores the path. - -**`VaultConnector` impl:** `connect()` calls `tokio::net::UnixStream::connect(&self.path)`, maps `io::Error` to `Error::Transport`. - -### `Error::Remote` variant - -Add to `src/error.rs`: - -```rust - /// An error returned by the vault server over the wire. - /// The code is the machine-readable error code (e.g., "access_denied"). - #[error("remote error ({code}): {message}")] - Remote { - code: String, - message: String, - }, -``` - -The client maps `ErrorPayload { code, message }` from the wire to this variant. Callers can match on the `code` field for programmatic error handling: - -```rust -match client.request_lease("agent", "secret", "domain").await { - Err(Error::Remote { code, .. }) if code == "access_denied" => { /* handle */ } - // ... -} -``` - -**Required:** This new variant must be added to the `error_to_code` match in `src/protocol/mod.rs` — map it to `"remote"`. Without this arm, the code will not compile (the match is exhaustive). The server should never produce this error; it is client-side only. - -### Base64 encoding/decoding - -The client uses `base64::engine::general_purpose::STANDARD` (same as the server dispatch) for: -- Encoding `&[u8]` plaintext → String in `store_secret` -- Decoding String → `Vec` in `access_secret` - -Base64 decode errors map to `Error::Transport("invalid base64 in server response")`. - -## Testing - -### `UdsConnector` test (in `src/transport/uds.rs`) - -1. **Connect to a listener** — bind `UdsListener`, create `UdsConnector` with same path, connect, verify stream is usable. - -### `VaultClient` integration tests (in `src/client.rs`) - -Each test spins up a full vault + server in the background: -- Set a unique env var (e.g., `ZEROLEASE_TEST_CLIENT_1`) with `unsafe { std::env::set_var(...) }` — unique names per test avoid collisions in parallel execution -- Create `EnvVarSource` + `SqliteStore` + `NoopAuditLog` + `PolicyEngine` → `Vault` -- Initialize the vault (`vault.initialize().await`) -- Bind `UdsListener` on a temp dir path -- Wrap vault in `Arc`, create `VaultServer` -- Move the `VaultServer` into a `tokio::spawn` task that calls `serve()`. Save the `JoinHandle`. -- Create `UdsConnector` with the socket path, `VaultClient::connect(&connector)` -- Exercise the client methods -- After the test: `handle.abort()` to stop the server task, then drop temp dir - -2. **Connect and handshake** — verify `VaultClient::connect` succeeds. -3. **Store and list** — store a secret, list, verify it appears. -4. **Full lease cycle** — store → request_lease → access_secret → verify bytes match original plaintext. -5. **Revoke lease** — store → lease → revoke → access returns error. -6. **Revoke all for agent** — store → lease → revoke_all_for_agent → verify count >= 1. -7. **Delete secret** — store → delete → list returns empty. -8. **Renew lease** — store → lease (with `LeaseTerms::workflow()` policy grant for renewable=true) → renew → verify ok. -9. **Error mapping** — request lease for nonexistent secret → `Error::Remote { code: "secret_not_found", .. }`. - -All tests gated with `#[cfg(feature = "sqlite")]`. - -## Dependencies - -No new dependencies. All used crates (`base64`, `serde_json`, `uuid`, `tokio`) are already present. - -## Out of scope - -- Connection pooling / reconnection -- Concurrent request pipelining (client is `&mut self`) -- vsock connector (trivial to add later, same pattern as UDS) -- Client-side caching of leases diff --git a/docs/implementation/specs/2026-03-26-kms-source-design.md b/docs/implementation/specs/2026-03-26-kms-source-design.md deleted file mode 100644 index e996fe2..0000000 --- a/docs/implementation/specs/2026-03-26-kms-source-design.md +++ /dev/null @@ -1,95 +0,0 @@ -# AWS KMS Key Source Design - -**Date:** 2026-03-26 -**Status:** Draft -**Scope:** New `src/keysource/kms.rs`, update `kms` feature flag - -## Problem - -The vault has key sources for developer machines (OS keychain) and CI (env var), but no production-grade key management backed by hardware security. AWS KMS provides hardware-backed encryption keys where the master key never leaves the HSM, and we use envelope encryption to avoid KMS round-trips per secret operation. - -## Design - -### File structure - -| Action | Path | Responsibility | -|--------|------|---------------| -| Create | `src/keysource/kms.rs` | `KmsSource` implementing `KeySource` | -| Modify | `src/keysource/mod.rs` | Add `#[cfg(feature = "kms")] pub mod kms;` | -| Modify | `Cargo.toml` | Update `kms` feature flag, add optional deps | - -### Dependencies - -```toml -[dependencies] -aws-sdk-kms = { version = "1", optional = true } -aws-config = { version = "1", optional = true } - -[features] -kms = ["aws-sdk-kms", "aws-config"] -``` - -Both dependencies are optional. The `aws-config` crate provides `aws_config::load_defaults()` for credential/region resolution. The KMS module is gated with `#[cfg(feature = "kms")]`. - -### `KmsSource` - -```rust -pub struct KmsSource { - client: aws_sdk_kms::Client, - key_id: String, - encrypted_dek_path: PathBuf, - description: String, -} -``` - -**Constructor:** `KmsSource::new(key_id: impl Into, region: impl Into, encrypted_dek_path: impl Into) -> Result` - -1. Build AWS config with the specified region: `aws_config::defaults(BehaviorVersion::latest()).region(Region::new(region)).load().await` -2. Create `aws_sdk_kms::Client::new(&config)` -3. Store `key_id`, `encrypted_dek_path`, build `description` - -The constructor is async because loading AWS config resolves credentials. - -### `load_or_create_dek` - -1. Check if `self.encrypted_dek_path` exists -2. **If exists:** Read the file contents, call KMS `Decrypt` with the blob, extract the 32-byte plaintext, return `DataEncryptionKey::from_bytes` -3. **If not exists:** Generate 32 random bytes via `OsRng`, call KMS `Encrypt` with the plaintext, save the ciphertext blob to `encrypted_dek_path`, return the DEK - -KMS API calls: -- `Decrypt`: `client.decrypt().ciphertext_blob(Blob::new(bytes)).key_id(&self.key_id).send().await` -- `Encrypt`: `client.encrypt().plaintext(Blob::new(dek_bytes)).key_id(&self.key_id).send().await` - -Error mapping: KMS SDK errors → `Error::KeySourceUnavailable(msg)`. File I/O errors → `Error::KeySourceUnavailable(msg)`. - -### `rotate_dek` - -1. Call KMS `Encrypt` on `new_dek.as_bytes()` -2. Save the new encrypted blob to `encrypted_dek_path` (overwrite) -3. Return `EncryptedDek { ciphertext, key_id }` - -Unlike EnvVar and Keychain, rotation IS supported for KMS — it just re-encrypts the DEK. - -### `description` - -Returns `"kms:"` (pre-formatted string stored in struct, same pattern as other sources). - -### File format for encrypted DEK - -The encrypted DEK blob file is a raw binary file containing the KMS ciphertext blob. No JSON wrapper, no encoding — just the raw bytes as returned by KMS `Encrypt`. This keeps the format simple and compatible with the AWS CLI (`aws kms decrypt --ciphertext-blob fileb://dek.enc`). - -## Testing - -KMS tests require AWS credentials and a KMS key. All tests are `#[ignore]` by default. - -1. **Encrypt and decrypt round-trip** (`#[ignore]`) — create a KmsSource, generate a DEK, verify it can be loaded back. -2. **Constructor compiles** (not `#[ignore]`) — verify the types work without calling AWS. - -For CI: the module compiles when the `kms` feature is enabled but tests don't run without `--ignored` and valid AWS credentials. - -## Out of scope - -- KMS key creation / management -- KMS key rotation (rotating the KMS key itself, vs rotating our DEK) -- Multi-region KMS -- KMS grants / key policies diff --git a/docs/implementation/specs/2026-03-26-postgres-store-design.md b/docs/implementation/specs/2026-03-26-postgres-store-design.md deleted file mode 100644 index e087ff2..0000000 --- a/docs/implementation/specs/2026-03-26-postgres-store-design.md +++ /dev/null @@ -1,76 +0,0 @@ -# PostgreSQL SecretStore Design - -**Date:** 2026-03-26 -**Status:** Draft -**Scope:** New `src/store/postgres.rs`, gated on `postgres` feature - -## Problem - -The vault has a SQLite store for single-host deployments but no shared storage for multi-instance deployments. PostgreSQL enables multiple vault instances to share a common secret store, and integrates with existing database infrastructure, backup tooling, and monitoring. - -## Design - -### File structure - -| Action | Path | Responsibility | -|--------|------|---------------| -| Create | `src/store/postgres.rs` | `PostgresStore` implementing `SecretStore` | -| Modify | `src/store/mod.rs` | Add `#[cfg(feature = "postgres")] pub mod postgres;` | - -No Cargo.toml changes needed — `postgres = ["sqlx"]` already exists, and sqlx already has the `postgres` feature in its feature list. - -### Schema - -```sql -CREATE TABLE IF NOT EXISTS secrets ( - id TEXT PRIMARY KEY, - name TEXT UNIQUE NOT NULL, - ciphertext BYTEA NOT NULL, - nonce BYTEA NOT NULL, - algorithm TEXT NOT NULL, - kind TEXT NOT NULL, - description TEXT, - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL, - version INTEGER NOT NULL DEFAULT 1 -); -``` - -Differences from SQLite: -- `BYTEA` instead of `BLOB` for binary data -- `TIMESTAMPTZ` instead of `TEXT` for timestamps — sqlx can bind/extract `DateTime` directly with PostgreSQL, no RFC 3339 string conversion needed -- Same column names and semantics - -### `PostgresStore` - -```rust -pub struct PostgresStore { - pool: PgPool, -} -``` - -**Constructor:** `PostgresStore::new(url: &str) -> Result` — connects to the given PostgreSQL URL (e.g., `postgres://user:pass@host/dbname`), runs `CREATE TABLE IF NOT EXISTS`. Uses `PgPoolOptions`. - -**Trait methods:** Same logic as `SqliteStore` with PostgreSQL query syntax: -- `$1, $2, $3` parameter placeholders instead of `?` -- `BYTEA` columns bind/extract `Vec` directly -- `TIMESTAMPTZ` columns bind/extract `DateTime` directly (no string conversion) -- UNIQUE constraint violation detection uses PostgreSQL error code `23505` -- `rows_affected()` works the same way - -### Row deserialization - -A `row_to_stored_secret` helper, same pattern as SQLite but simpler — timestamps extract directly as `DateTime`, no RFC 3339 parsing. - -## Testing - -PostgreSQL tests require a running PostgreSQL instance. All tests are `#[ignore]` by default. - -1. **Put and get round-trip** (`#[ignore]`) -2. **Constructor compiles** (not `#[ignore]`) — verify types work - -Tests use the `DATABASE_URL` environment variable for the connection string. - -## Dependencies - -No new dependencies. `sqlx` with `postgres` feature is already configured. diff --git a/docs/implementation/specs/2026-03-26-server-dispatch-uds-design.md b/docs/implementation/specs/2026-03-26-server-dispatch-uds-design.md deleted file mode 100644 index 302a442..0000000 --- a/docs/implementation/specs/2026-03-26-server-dispatch-uds-design.md +++ /dev/null @@ -1,193 +0,0 @@ -# Server Dispatch + UDS Transport Design - -**Date:** 2026-03-26 -**Status:** Draft -**Scope:** `src/server.rs` (server loop + dispatch), `src/transport/uds.rs` (UDS listener), three new vault methods, new audit event - -## Problem - -The wire protocol (framing + types) is implemented, but nothing connects it to the vault. There is no server that accepts connections, runs the handshake, dispatches requests to vault methods, and sends responses. There is no concrete transport listener. The vault is also missing three methods the protocol requires: `list_secrets`, `renew_lease`, `delete_secret`. - -## Design - -### File structure - -| Action | Path | Responsibility | -|--------|------|---------------| -| Create | `src/server.rs` | `VaultServer` struct, `serve()` loop, `handle_connection`, `dispatch` | -| Create | `src/transport/uds.rs` | `UdsListener` implementing `VaultListener` | -| Modify | `src/transport/mod.rs` | Add `pub mod uds;` | -| Modify | `src/vault.rs` | Add `list_secrets`, `renew_lease`, `delete_secret` methods | -| Modify | `src/audit/mod.rs` | Add `LeaseRenewed` variant to `AuditEvent` | -| Modify | `src/lib.rs` | Add `pub mod server;` | - -### `VaultServer` - -```rust -pub struct VaultServer -where - K: KeySource, - S: SecretStore, - A: AuditLog, - L: VaultListener, -{ - vault: Arc>, - listener: L, -} -``` - -**Constructor:** `VaultServer::new(vault: Arc>, listener: L) -> Self` - -**`serve(&self)`** — the main accept loop: -1. Loop on `self.listener.accept()` -2. For each `(stream, peer)`, clone the `Arc` and `tokio::spawn` a task calling `handle_connection` -3. Returns on listener error. Caller decides shutdown policy. Note: already-spawned connection tasks continue running after `serve` returns — they hold their own `Arc` clone. - -**`handle_connection(vault, stream, peer)`** — per-connection async function: -1. Split stream into reader/writer halves via `tokio::io::split`. Both halves are passed by `&mut` reference to the framing functions throughout the handshake and request loop. -2. Read first frame → deserialize as `ClientHello` -3. Validate: `protocol == PROTOCOL_NAME` and `version <= CURRENT_VERSION` -4. If handshake fails: write `ServerHello::reject(...)` frame, return (close connection) -5. If handshake succeeds: write `ServerHello::accept(version)` frame -6. Request loop: - - `read_frame` → on EOF: return cleanly (client disconnected) - - On framing error: write `Response::protocol_error(Uuid::nil(), CODE_PROTOCOL_ERROR, msg)`, return (close connection) - - Deserialize bytes as `Request` → on failure: write `Response::protocol_error(Uuid::nil(), CODE_INVALID_REQUEST, msg)`, continue loop - - Call `dispatch(vault, &request, &peer).await` - - Serialize `Response` → `write_frame` - -**Error behavior:** -- Handshake failure → close connection -- Framing error (`protocol_error`) → close connection (stream is likely desynchronized) -- Bad JSON / unknown method / malformed params (`invalid_request`) → send error response, keep connection open - -### `dispatch` function - -```rust -async fn dispatch( - vault: &Vault, - request: &Request, - peer: &PeerIdentity, -) -> Response -where - K: KeySource, - S: SecretStore, - A: AuditLog, -``` - -Matches on `request.method`: -- `"store_secret"` → deserialize `StoreSecretRequest` from params, base64-decode plaintext, call `vault.store_secret(...)`, serialize `SecretMetadata` as result -- `"request_lease"` → deserialize `RequestLeaseRequest`, call `vault.request_lease(...)`, serialize `LeaseGrant` -- `"access_secret"` → deserialize `AccessSecretRequest`, call `vault.access_secret(...)`, `guard.expose()` → base64-encode, serialize `AccessSecretResponse` -- `"revoke_lease"` → deserialize `RevokeLeaseRequest`, convert `reason` field from `serde_json::Value` to `RevocationReason` via `serde_json::from_value(request.reason)` (map failure to `invalid_request`), call `vault.revoke_lease(&lease_id, reason, peer)`, return empty result `{}` -- `"revoke_all_for_agent"` → deserialize `RevokeAllForAgentRequest`, call `vault.revoke_all_for_agent(...)`, serialize `RevokeAllForAgentResponse` -- `"list_secrets"` → call `vault.list_secrets()`, convert each `SecretMetadata` to `serde_json::Value` via `serde_json::to_value`, wrap in `ListSecretsResponse`, serialize -- `"renew_lease"` → deserialize `RenewLeaseRequest`, call `vault.renew_lease(...)`, serialize `LeaseGrant` -- `"delete_secret"` → deserialize `DeleteSecretRequest`, call `vault.delete_secret(...)`, return empty result `{}` -- Unknown method → `Response::protocol_error(request.id, CODE_INVALID_REQUEST, "unknown method: ...")` - -If params deserialization fails → `Response::protocol_error(request.id, CODE_INVALID_REQUEST, "invalid params: ...")`. - -If the vault method returns an `Err(e)` → `Response::from_error(request.id, &e)`. - -All vault method calls pass `peer` from the connection context (not from the request). - -### `UdsListener` - -```rust -pub struct UdsListener { - inner: tokio::net::UnixListener, -} -``` - -**Constructor:** `UdsListener::bind(path: impl AsRef) -> Result` — calls `tokio::net::UnixListener::bind(path)`, maps `io::Error` to `Error::Transport`. - -**`VaultListener` impl:** -- `type Stream = tokio::net::UnixStream` -- `accept()` — calls `self.inner.accept()`, extracts peer credentials via `stream.peer_cred()`: - - `tokio::net::UnixStream::peer_cred()` returns `io::Result`. Tokio abstracts over `SO_PEERCRED` (Linux) and `LOCAL_PEERCRED` (macOS) behind this single API — it is available on both platforms. - - UID: `cred.uid()` (always available) - - PID: `cred.pid().unwrap_or(0) as u32` (may be `None` on some platforms) - - Builds `PeerIdentity::Unix { uid, pid }` - - If `peer_cred()` fails at runtime: use `PeerIdentity::Anonymous` (don't fail the connection) - -**No socket file management.** The caller creates and removes the socket file. The listener just binds and accepts. Before binding, the caller should remove any stale socket file from a previous run. - -### Three new vault methods - -**`list_secrets(&self) -> Result>`** - -Delegates to `self.store.list()`. No policy check, no audit — listing metadata is an admin operation. - -**`renew_lease(&self, lease_id: &LeaseId, extension_secs: i64, peer: &PeerIdentity) -> Result`** - -1. Lock `self.leases` for write -2. Find lease by ID → `Error::LeaseNotFound` if missing -3. Check if lease is expired (`Utc::now() > lease.expires_at`) → `Error::LeaseExpired` if so (prevents renewing already-expired leases that haven't been GC'd yet) -4. Call `lease.renew(TimeDelta::seconds(extension_secs))` → returns error if not renewable or revoked -5. Build `LeaseGrant::from(&lease)`, extract `lease.agent.clone()` for audit -6. Release lock -7. Audit log `AuditEntry::new(AuditEvent::LeaseRenewed { lease_id, extension_secs }, agent, peer, AuditOutcome::Success)` -8. Return the updated `LeaseGrant` - -**`delete_secret(&self, name: &SecretName, peer: &PeerIdentity) -> Result<()>`** - -1. Lock `self.leases` for write, iterate and revoke any whose `secret_name == name` and not already revoked (best-effort audit for each, same pattern as `revoke_all_for_agent`) -2. Release the lease lock before calling the store (avoid holding the lock across async I/O) -3. Call `self.store.delete(name)` → `Error::SecretNotFound` if missing -4. Audit log `AuditEntry::new(AuditEvent::SecretDeleted { secret_name }, AgentId::new("admin"), peer, AuditOutcome::Success)` -5. Return `()` - -### New audit event - -Add to the `AuditEvent` enum in `src/audit/mod.rs`: - -```rust - /// A lease was renewed. - LeaseRenewed { - lease_id: LeaseId, - extension_secs: i64, - }, -``` - -## Testing - -### Dispatch tests (unit tests in `src/server.rs`) - -Using `EnvVarSource` + `SqliteStore` + `NoopAuditLog` (same pattern as the existing vault integration test): - -1. **store_secret dispatches** — send store request, verify success response with metadata -2. **request_lease dispatches** — store a secret first, then request a lease, verify `LeaseGrant` -3. **access_secret dispatches** — full flow: store → lease → access, verify base64-encoded secret -4. **list_secrets dispatches** — store two secrets, list, verify count -5. **renew_lease dispatches** — request a renewable lease, renew, verify updated grant -6. **delete_secret dispatches** — store, delete, verify get fails -7. **revoke_lease dispatches** — request lease, revoke, verify -8. **revoke_all_for_agent dispatches** — request lease, revoke all, verify count -9. **Unknown method returns invalid_request** -10. **Malformed params returns invalid_request** - -### Connection handler tests (integration tests in `src/server.rs`, using `tokio::io::duplex`) - -11. **Full handshake + request/response** — duplex simulates client. Send ClientHello, receive ServerHello. Send store_secret, receive success. -12. **Bad handshake closes connection** — wrong protocol name → rejection + stream closes. -13. **Framing error closes connection** — write raw garbage bytes (not valid length-prefix), verify connection closes after error response. -14. **Invalid request keeps connection open** — send request with unknown method → error response, then send a valid request → still works. - -### UDS listener tests (in `src/transport/uds.rs`, using temp directories) - -15. **Bind and accept** — bind UDS in temp dir, connect with `tokio::net::UnixStream::connect`, verify listener yields a stream. -16. **Peer cred populated** — verify `PeerIdentity::Unix` with non-zero UID. - -### Dependencies - -- `tempfile` (existing dev-dep) for temp directories in UDS tests -- No new dependencies - -## Out of scope - -- vsock listener -- Client library -- TLS / authentication -- Graceful shutdown (signal handling) -- Connection limits / rate limiting diff --git a/docs/implementation/specs/2026-03-26-sqlite-audit-log-design.md b/docs/implementation/specs/2026-03-26-sqlite-audit-log-design.md deleted file mode 100644 index 910629e..0000000 --- a/docs/implementation/specs/2026-03-26-sqlite-audit-log-design.md +++ /dev/null @@ -1,142 +0,0 @@ -# SQLite Audit Log Design - -**Date:** 2026-03-26 -**Status:** Draft -**Scope:** New `src/audit/sqlite.rs` implementing `AuditLog` trait with SQLite persistence - -## Problem - -Every audit event flows through `tracing` (as of the recent vault change), but there is no persistent, queryable audit store. The `AuditLog` trait's `query_by_agent`, `query_by_secret`, and `query_by_lease` methods are unimplemented — only `NoopAuditLog` exists. For incident investigation and compliance, audit events need to be stored and queryable. - -## Design - -### File structure - -| Action | Path | Responsibility | -|--------|------|---------------| -| Create | `src/audit/sqlite.rs` | `SqliteAuditLog` struct, `AuditLog` impl, schema migration, unit tests | -| Modify | `src/audit/mod.rs` | Add `#[cfg(feature = "sqlite")] pub mod sqlite;` | - -### Database file - -The audit log uses a **separate SQLite file** from the secrets database. Audit events grow much faster than secrets (every access is a row), and separating them allows independent rotation, archival, and backup. - -### Schema - -```sql -CREATE TABLE IF NOT EXISTS audit_events ( - event_id TEXT PRIMARY KEY, - timestamp TEXT NOT NULL, - event TEXT NOT NULL, - agent TEXT NOT NULL, - peer_identity TEXT NOT NULL, - outcome TEXT NOT NULL, - secret_name TEXT, - lease_id TEXT -); - -CREATE INDEX IF NOT EXISTS idx_audit_agent ON audit_events(agent); -CREATE INDEX IF NOT EXISTS idx_audit_secret ON audit_events(secret_name); -CREATE INDEX IF NOT EXISTS idx_audit_lease ON audit_events(lease_id); -CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_events(timestamp); -``` - -- `event_id`: UUID v7 as text (`entry.event_id.to_string()`) -- `timestamp`: RFC 3339 text via `entry.timestamp.to_rfc3339()`. `chrono::Utc` always produces a consistent fixed-width UTC format, so lexicographic ordering matches chronological ordering. -- `event`: JSON-serialized `AuditEvent` via `serde_json::to_string` -- `agent`: the inner string from `entry.agent.as_str()` (NOT the `Display` format which prefixes `"agent:"`). Reconstructed with `AgentId::new(s)`. Query binds use `agent.as_str()`. -- `peer_identity`: the `Display` string from `entry.peer_identity` (which is already a `String` on `AuditEntry`) -- `outcome`: JSON-serialized `AuditOutcome` via `serde_json::to_string` -- `secret_name`: denormalized, extracted from the `AuditEvent` variant at insert time using `SecretName::as_str()` for the value. NULL for events without a secret (e.g., `DekRotated`, `PolicyReloaded`, `LeaseRevoked`). -- `lease_id`: denormalized, extracted from the `AuditEvent` variant at insert time using `lease_id.as_uuid().to_string()` for the value. Reconstructed with `LeaseId::from_uuid(Uuid::parse_str(s))`. NULL for events without a lease (e.g., `SecretStored`, `SecretDeleted`). - -**Known schema limitation:** `LeaseRevoked` events do not carry a `secret_name`, so they cannot be found by `query_by_secret` even if the revocation was triggered by a secret deletion. The `SecretDeleted` event itself is recorded separately and will appear in `query_by_secret` results. - -The denormalized columns avoid `json_extract` queries — the three query methods become simple `WHERE` clauses on indexed columns. - -### `SqliteAuditLog` - -```rust -pub struct SqliteAuditLog { - pool: SqlitePool, -} -``` - -**Constructor:** `SqliteAuditLog::new(path: impl AsRef) -> Result` - -Same pattern as `SqliteStore`: creates file with `SqliteConnectOptions::new().filename(path).create_if_missing(true).journal_mode(SqliteJournalMode::Wal)`, opens pool via `SqlitePoolOptions`, runs `CREATE TABLE IF NOT EXISTS` + `CREATE INDEX IF NOT EXISTS`. WAL mode is enabled for better concurrent read/write performance (SQLite allows concurrent reads while a single write is in progress). - -### Trait method implementations - -**`record`:** -1. Extract `secret_name` and `lease_id` from the `AuditEvent` variant via a helper function `extract_event_fields(event: &AuditEvent) -> (Option, Option)`. This matches on the event variant and extracts the relevant fields: - - `LeaseGranted { secret_name, lease_id, .. }` → `(Some(name), Some(lease_id))` - - `SecretAccessed { secret_name, lease_id, .. }` → `(Some(name), Some(lease_id))` - - `LeaseRevoked { lease_id, .. }` → `(None, Some(lease_id))` - - `LeaseRenewed { lease_id, .. }` → `(None, Some(lease_id))` - - `AccessDenied { secret_name, .. }` → `(Some(name), None)` - - `SecretStored { secret_name }` → `(Some(name), None)` - - `SecretRotated { secret_name, .. }` → `(Some(name), None)` - - `SecretDeleted { secret_name }` → `(Some(name), None)` - - `DekRotated` → `(None, None)` - - `PolicyReloaded { .. }` → `(None, None)` -2. Serialize `event` and `outcome` to JSON via `serde_json::to_string`. -3. INSERT into `audit_events`. - -**`query_by_agent`:** -```sql -SELECT * FROM audit_events WHERE agent = ? ORDER BY timestamp DESC LIMIT ? -``` -Deserialize each row back into `AuditEntry` via `row_to_audit_entry` helper. - -**`query_by_secret`:** -```sql -SELECT * FROM audit_events WHERE secret_name = ? ORDER BY timestamp DESC LIMIT ? -``` - -**`query_by_lease`:** -```sql -SELECT * FROM audit_events WHERE lease_id = ? ORDER BY timestamp DESC -``` -No limit parameter — the trait signature takes only `&LeaseId`. - -All queries order by `timestamp DESC` (most recent first). - -### Row deserialization - -A `row_to_audit_entry` helper function (same pattern as `row_to_stored_secret` in `SqliteStore`). All parsing errors map to `Error::Storage(...)`: -- `event_id`: `Uuid::parse_str(&s).map_err(|e| Error::Storage(...))` -- `timestamp`: `DateTime::parse_from_rfc3339(&s)...with_timezone(&Utc)` mapped to `Error::Storage` -- `event`: `serde_json::from_str::(&s)` mapped to `Error::Storage` -- `agent`: `AgentId::new(agent_string)` (infallible — stores the inner string, not the Display prefix) -- `peer_identity`: stored and retrieved as a plain `String` (the `AuditEntry.peer_identity` field is `String`) -- `outcome`: `serde_json::from_str::(&s)` mapped to `Error::Storage` - -### Retention - -No built-in retention policy. The audit log appends forever. Retention is an operational concern — the caller can prune old rows externally (`DELETE FROM audit_events WHERE timestamp < ?`) or rotate the database file. A built-in retention mechanism can be added later if needed. - -## Testing - -### Unit tests in `src/audit/sqlite.rs` - -Each test uses `tempfile::NamedTempFile` for an isolated database. - -1. **Record and query by agent** — record 3 events for agent "alice" and 1 for "bob", query by "alice" with limit 10, verify 3 returned, most recent first. -2. **Query by secret** — record events referencing different secrets, query by one secret name, verify only matching events returned. -3. **Query by lease** — record events with different lease IDs, query by one, verify only matching events returned. -4. **Limit respected** — record 5 events for one agent, query with limit 2, verify only 2 returned. -5. **Events without secret/lease still queryable** — record a `DekRotated` event (no secret_name, no lease_id), verify it's returned by `query_by_agent` but not by `query_by_secret`. -6. **Round-trip fidelity** — record an event, query it back, verify all fields match (event, agent, peer_identity, outcome, timestamp within tolerance). -7. **Empty result** — query by an agent/secret/lease that has no matching rows, verify empty `Vec` returned (not an error). - -## Dependencies - -No new dependencies. `sqlx` with `sqlite` feature and `tempfile` are already present. - -## Out of scope - -- Retention policy / pruning -- Audit log rotation -- External audit sink (CloudWatch, Splunk, etc.) -- Vault integration test with `SqliteAuditLog` (existing tests use `NoopAuditLog` + tracing) diff --git a/docs/implementation/specs/2026-03-26-vsock-transport-design.md b/docs/implementation/specs/2026-03-26-vsock-transport-design.md deleted file mode 100644 index 5d8dcef..0000000 --- a/docs/implementation/specs/2026-03-26-vsock-transport-design.md +++ /dev/null @@ -1,79 +0,0 @@ -# vsock Transport Design - -**Date:** 2026-03-26 -**Status:** Draft -**Scope:** New `src/transport/vsock.rs`, update `vsock` feature flag - -## Problem - -The vault supports UDS for local developer machines but has no transport for VM-isolated deployments. vsock (virtio-vsock) enables communication between a Firecracker/QEMU host and guest VMs without any network stack — the vault server runs on the host and agents inside VMs connect via CID + port. - -## Design - -### File structure - -| Action | Path | Responsibility | -|--------|------|---------------| -| Create | `src/transport/vsock.rs` | `VsockListener`, `VsockConnector` | -| Modify | `src/transport/mod.rs` | Add `#[cfg(feature = "vsock")] pub mod vsock;` | -| Modify | `Cargo.toml` | Update `vsock = []` to `vsock = ["tokio-vsock"]` | - -### `VsockListener` - -```rust -pub struct VsockListener { - inner: tokio_vsock::VsockListener, -} -``` - -**Constructor:** `VsockListener::bind(port: u32) -> Result` — binds to `VMADDR_CID_ANY` (accepts connections from any CID) on the given port. Maps errors to `Error::Transport`. - -**`VaultListener` impl:** -- `type Stream = tokio_vsock::VsockStream` -- `accept()` — calls `self.inner.accept()`, extracts the peer CID from the remote address to build `PeerIdentity::Vsock { cid }`. `tokio_vsock::VsockStream` implements `AsyncRead + AsyncWrite + Send + Unpin`, satisfying `VaultStream` via the blanket impl. - -### `VsockConnector` - -```rust -pub struct VsockConnector { - cid: u32, - port: u32, -} -``` - -**Constructor:** `VsockConnector::new(cid: u32, port: u32)` — stores the target CID and port. CID 2 is conventionally the host. - -**`VaultConnector` impl:** `connect()` calls `tokio_vsock::VsockStream::connect(self.cid, self.port)`, maps errors to `Error::Transport`. - -### Feature flag - -Update `Cargo.toml`: -```toml -vsock = ["tokio-vsock"] -``` - -This ties the `vsock` feature to the `tokio-vsock` dependency, which is already declared as optional and Linux-only. The module is gated with `#[cfg(feature = "vsock")]`. - -### Platform gating - -The `tokio-vsock` crate only compiles on Linux. The module declaration uses `#[cfg(feature = "vsock")]` — since the dependency is only available on Linux (`[target.'cfg(target_os = "linux")'.dependencies]`), enabling the feature on non-Linux platforms will fail at compile time. This is the correct behavior. - -## Testing - -vsock requires a hypervisor environment (Firecracker or QEMU with vsock configured). Tests are `#[ignore]` by default. - -1. **Bind and accept** — `#[ignore]` — bind a VsockListener on a test port, connect with VsockConnector, verify stream and `PeerIdentity::Vsock` peer. - -For development purposes, a basic smoke test that just verifies the types compile and the constructors work (without actually connecting) can run on Linux CI: - -2. **Constructors compile** — (not `#[ignore]`) — just verify `VsockListener::bind` and `VsockConnector::new` are callable. This only runs when the `vsock` feature is enabled. - -## Dependencies - -No new dependencies — `tokio-vsock 0.7` is already in Cargo.toml as an optional dependency. The feature flag change just wires it up. - -## Out of scope - -- vsock device configuration on the host/guest -- CID management / discovery -- vsock proxy patterns diff --git a/docs/implementation/specs/2026-03-26-wire-protocol-design.md b/docs/implementation/specs/2026-03-26-wire-protocol-design.md deleted file mode 100644 index 62f5b85..0000000 --- a/docs/implementation/specs/2026-03-26-wire-protocol-design.md +++ /dev/null @@ -1,379 +0,0 @@ -# Wire Protocol Design - -**Date:** 2026-03-26 -**Status:** Draft -**Scope:** New `src/protocol/` module — framing layer, protocol types, serde, no transport integration - -## Problem - -The vault has working encryption, key management, storage, leasing, and policy, but no way for clients to communicate with it over the wire. The transport traits (`VaultListener`, `VaultConnector`) define bidirectional byte streams, but no request/response format exists. Without a wire protocol, agents can't connect to the vault. - -## Decisions - -- **JSON + length-prefixed frames** over gRPC/tonic. Maps directly to the existing `VaultStream` (`AsyncRead + AsyncWrite`) abstraction, works over UDS and vsock without friction, debuggable, no new dependencies. -- **Version negotiation on connection** (not per-message). Client declares version in handshake, server adapts. Server leads in version support. -- **UUID v7 request IDs** for correlation. Globally unique (useful for audit log tracing across connections), time-sortable, already in the dependency tree. -- **Error codes as strings** mapping directly to `Error` enum variants. No numeric status codes. - -## Framing - -Every message on the wire is a 4-byte big-endian length prefix followed by that many bytes of UTF-8 JSON: - -``` -[u32 length (big-endian)][JSON payload ... length bytes ...] -``` - -**Maximum message size:** 1 MiB (1,048,576 bytes). The limit is enforced in both directions: `write_frame` refuses to send a payload exceeding the limit, and `read_frame` refuses to read one. Credential payloads are small, so anything near 1 MiB is a bug or an attack. The framing layer returns an error; the caller decides whether to close the connection. - -Both directions (client→server, server→client) use the same framing. The framing layer is a standalone module operating on any `AsyncRead + AsyncWrite` stream. - -### Framing API - -```rust -/// Read a length-prefixed frame from a stream. Returns the raw bytes. -pub async fn read_frame(reader: &mut (impl AsyncRead + Unpin)) -> Result>; - -/// Write a length-prefixed frame to a stream. -pub async fn write_frame(writer: &mut (impl AsyncWrite + Unpin), data: &[u8]) -> Result<()>; -``` - -Errors: -- Payload exceeds 1 MiB → `Error::Transport("frame too large: N bytes (max 1048576)")` -- Unexpected EOF → `Error::Transport("unexpected EOF reading frame")` -- I/O error → `Error::Transport` wrapping the io::Error message - -## Handshake - -The first message pair on every connection is version negotiation. Both use the same length-prefixed framing as all other messages. - -**Client sends:** -```json -{"protocol": "zerolease", "version": 1} -``` - -**Server responds (success):** -```json -{"protocol": "zerolease", "version": 1, "ok": true} -``` - -**Server responds (unsupported version):** -```json -{"protocol": "zerolease", "version": 1, "ok": false, "error": "unsupported client version 99; server supports 1"} -``` - -The `ok` field signals success or rejection (consistent with the request/response envelope pattern). The `version` field in a rejection response is the server's own version (for diagnostics). The server closes the connection after sending a rejection. - -The `"protocol": "zerolease"` field is a magic string — if a non-zerolease client connects, the server detects it and closes rather than parsing garbage. - -After a successful handshake, all subsequent messages are requests and responses. - -### Handshake types - -Client hello (no `ok` field): - -```rust -#[derive(Debug, Serialize, Deserialize)] -pub struct ClientHello { - pub protocol: String, - pub version: u32, -} -``` - -Server hello response: - -```rust -#[derive(Debug, Serialize, Deserialize)] -pub struct ServerHello { - pub protocol: String, - pub version: u32, - pub ok: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} -``` - -`PROTOCOL_NAME = "zerolease"` and `CURRENT_VERSION = 1` as constants. - -## Request/Response Envelope - -**Request (client → server):** -```json -{ - "id": "01968a3f-7b2c-7def-8abc-123456789abc", - "method": "request_lease", - "params": { ... } -} -``` - -**Response — success (server → client):** -```json -{ - "id": "01968a3f-7b2c-7def-8abc-123456789abc", - "ok": true, - "result": { ... } -} -``` - -**Response — error (server → client):** -```json -{ - "id": "01968a3f-7b2c-7def-8abc-123456789abc", - "ok": false, - "error": { - "code": "access_denied", - "message": "agent ci-agent-1 is not authorized to access secret github-pat for domain evil.example.com" - } -} -``` - -`id` is a UUID v7 generated by the client and echoed verbatim by the server. `method` is a snake_case string. `params` and `result` shapes are method-specific. - -### Envelope types - -```rust -#[derive(Debug, Serialize, Deserialize)] -pub struct Request { - pub id: Uuid, - pub method: String, - pub params: serde_json::Value, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct Response { - pub id: Uuid, - pub ok: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub result: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct ErrorPayload { - pub code: String, - pub message: String, -} -``` - -`params` and `result` use `serde_json::Value` at the envelope level. The server deserializes `params` into method-specific structs after dispatching on `method`. This keeps the envelope generic. - -## Error Codes - -Error codes are snake_case strings mapping directly to the `Error` enum variants: - -| Code | Error variant | -|------|--------------| -| `lease_expired` | `LeaseExpired` | -| `lease_revoked` | `LeaseRevoked` | -| `lease_not_found` | `LeaseNotFound` | -| `access_denied` | `AccessDenied` | -| `no_policy_for_agent` | `NoPolicyForAgent` | -| `secret_not_found` | `SecretNotFound` | -| `secret_already_exists` | `SecretAlreadyExists` | -| `encryption_failed` | `EncryptionFailed` | -| `decryption_failed` | `DecryptionFailed` | -| `key_source_unavailable` | `KeySourceUnavailable` | -| `storage` | `Storage` | -| `transport` | `Transport` | -| `invalid_config` | `InvalidConfig` | -| `invalid_request` | (protocol-level) | -| `protocol_error` | (protocol-level) | - -`invalid_request` is returned when the server receives valid JSON but cannot parse it as a `Request` (missing `id`, unknown `method`, malformed `params`). `protocol_error` is returned for non-JSON frames, invalid UTF-8, or other framing-level issues. These are not `Error` enum variants — they are protocol-level error codes generated by the server dispatcher. - -For `invalid_request`, the response `id` uses the request's `id` if parseable, otherwise a nil UUID (`00000000-0000-0000-0000-000000000000`). - -A function `error_to_code(err: &Error) -> &'static str` provides the mapping for vault errors. A function `error_to_payload(err: &Error) -> ErrorPayload` builds the full payload using the error's `Display` impl for the message. - -## Method Catalog - -### `store_secret` - -**Params:** -```json -{ - "name": "github-pat", - "plaintext": "c3VwZXItc2VjcmV0LWFwaS1rZXk=", - "kind": "Pat", - "description": "GitHub personal access token" -} -``` - -`plaintext` is standard base64-encoded (RFC 4648 §4 with padding). The server base64-decodes this to `&[u8]` before passing to `vault.store_secret`. `kind` is the `SecretKind` enum serialized by serde (e.g., `"Pat"`, `"ApiKey"`, `{"OAuth2": {...}}`). `description` is optional. - -**Result:** `SecretMetadata` (serialized by serde). - -**Server-side:** The dispatcher calls `vault.store_secret(name, &decoded_plaintext, kind, description, peer)` where `peer` is the connection's `PeerIdentity` (derived from the transport, not from the request). - -### `request_lease` - -**Params:** -```json -{ - "agent": "ci-agent-1", - "secret_name": "github-pat", - "domain": "api.github.com" -} -``` - -**Result:** `LeaseGrant` (serialized by serde — already derives `Serialize, Deserialize`). - -**Server-side:** Calls `vault.request_lease(&AgentId::new(agent), &SecretName::new(secret_name), &DomainScope::new(domain), peer)` where `peer` is the connection's `PeerIdentity`. - -### `access_secret` - -**Params:** -```json -{ - "lease_id": "01968a3f-...", - "target_domain": "api.github.com" -} -``` - -**Result:** -```json -{ - "secret": "c3VwZXItc2VjcmV0LWFwaS1rZXk=" -} -``` - -`secret` is the decrypted value, base64-encoded. The server calls `vault.access_secret(&lease_id, target_domain, peer)`, then `guard.expose()` to get the plaintext, base64-encodes it, and returns it. The `LeaseGuard` is dropped immediately after encoding (zeroizing the in-memory plaintext). `peer` is the connection's `PeerIdentity`. - -### `revoke_lease` - -**Params:** -```json -{ - "lease_id": "01968a3f-...", - "reason": "AdminRevoked" -} -``` - -`reason` is a `RevocationReason` enum variant serialized by serde. - -**Result:** `{}` (empty object). - -**Server-side:** Calls `vault.revoke_lease(&lease_id, reason, peer)`. `peer` is the connection's `PeerIdentity`. - -### `revoke_all_for_agent` - -**Params:** -```json -{ - "agent": "ci-agent-1" -} -``` - -**Result:** -```json -{ - "revoked_count": 3 -} -``` - -`revoked_count` is a JSON integer (serialized from Rust `usize`). - -**Server-side:** Calls `vault.revoke_all_for_agent(&AgentId::new(agent), peer)`. `peer` is the connection's `PeerIdentity`. - -### `list_secrets` - -**Params:** `{}` (empty object). - -**Result:** -```json -{ - "secrets": [ SecretMetadata, ... ] -} -``` - -**Server-side:** Requires a new `Vault::list_secrets` public method (does not exist yet). This method delegates to `self.store.list()`. No encryption or policy involved — it returns metadata only. - -### `renew_lease` - -**Params:** -```json -{ - "lease_id": "01968a3f-...", - "extension_secs": 900 -} -``` - -**Result:** `LeaseGrant` (updated with new expiration). - -**Server-side:** Requires a new `Vault::renew_lease` public method (does not exist yet). This method looks up the lease, calls `lease.renew(TimeDelta::seconds(extension_secs))`, audit-logs the renewal, and returns an updated `LeaseGrant`. - -### `delete_secret` - -**Params:** -```json -{ - "name": "github-pat" -} -``` - -**Result:** `{}` (empty object). - -**Server-side:** Requires a new `Vault::delete_secret` public method (does not exist yet). This method delegates to `self.store.delete(name)`, audit-logs the deletion (`AuditEvent::SecretDeleted`), and should revoke all active leases for the deleted secret. - -### New vault methods required - -Three methods in this catalog (`list_secrets`, `renew_lease`, `delete_secret`) require new public methods on `Vault`. These are thin wrappers around existing subsystem calls. They will be added as part of the protocol implementation plan — the protocol types themselves can be defined and tested without them. - -### Binary data encoding - -`plaintext` in `store_secret` and `secret` in `access_secret` are standard base64 with padding (RFC 4648 §4). No URL-safe variant. Uses the `base64` crate version `0.22` with `engine::general_purpose::STANDARD` for both encoding and decoding. - -### PeerIdentity - -Not included in any request params. The server derives `PeerIdentity` from the transport layer (SO_PEERCRED for UDS, CID for vsock) once per connection and passes it to every vault method call. Clients do not self-report their identity — that would be trivially spoofable. - -## File Structure - -``` -src/protocol/ - mod.rs — protocol types (Handshake, Request, Response, ErrorPayload, method params/results), - error code mapping, constants - frame.rs — length-prefixed framing (read_frame, write_frame) -``` - -`src/lib.rs` gains `pub mod protocol;`. - -The framing layer (`frame.rs`) is pure byte-level I/O — reads/writes length-prefixed buffers, knows nothing about JSON or vault methods. The protocol types (`mod.rs`) define the JSON structures and serde logic. Clean separation: framing tested with raw bytes, protocol types tested with serde round-trips. - -## Testing - -### `frame.rs` tests - -1. **Round-trip:** Write a frame, read it back, verify bytes match. -2. **Multiple frames:** Write 3 frames, read 3 back in order (using `tokio::io::duplex`). -3. **Oversized frame rejected:** Attempt to write a frame exceeding 1 MiB → error. -4. **Empty frame:** Zero-length payload round-trips correctly. -5. **Oversized read rejected:** Receive a frame header claiming > 1 MiB → error on read. - -### `mod.rs` tests (protocol types) - -1. **Handshake serde round-trip** (success and rejection variants). -2. **Request serde round-trip** for representative methods. -3. **Success response serde round-trip.** -4. **Error response serde round-trip.** -5. **Error code mapping:** `error_to_code` for each `Error` variant produces the expected string. -6. **Wrong protocol name rejected:** Handshake with `"protocol": "not-zerolease"` is detectable. -7. **Unsupported version rejected:** `ClientHello` with `version: 99` produces `ServerHello` with `ok: false`, the server's version, and a non-empty error string. -8. **Unknown method produces `invalid_request`:** Request with `"method": "nonexistent"` maps to the correct error code. -9. **Malformed params produce `invalid_request`:** Request with wrong param types. - -No vault integration tests in this scope. Connecting the protocol to the actual vault server is a separate piece of work (transport implementation). - -## Dependencies - -- `base64 = "0.22"` (new dependency) — for encoding/decoding binary data in JSON. Uses `engine::general_purpose::STANDARD`. -- `tokio::io::{AsyncReadExt, AsyncWriteExt}` — for frame reading/writing (already available via `tokio` with `full` features). -- All other dependencies (`serde`, `serde_json`, `uuid`) are already present. - -## Out of scope - -- Concrete transport implementations (UDS listener, vsock listener) -- Server-side method dispatch (routing requests to vault methods) -- Client library -- Authentication/authorization at the transport level -- TLS