agentplane 0.7.0

Durable, replayable agent runtime — the journal is the plan of record
Documentation
//! Envelope encryption, and the erasure it makes provable.
//!
//! # Why deleting is not erasing
//!
//! [`BlobStore::expire`](crate::blob::BlobStore::expire) drops a payload's bytes
//! and leaves a tombstone, and the hash chain still verifies because it only
//! ever committed to a digest. That is the right shape, and it is not enough:
//! it erases the bytes *in the live store*. Every backup taken before the
//! request, every replica, every snapshot an operator forgot about still holds
//! them, and an erasure obligation is not discharged by deleting one copy of
//! something that exists in six places.
//!
//! Chasing the copies does not work either. Backups are the point of backups —
//! they are offline, offsite, and frequently immutable by design, because that
//! is what makes them survive the incident they exist for. A retention story
//! that requires rewriting immutable backups has two guarantees in direct
//! conflict, and whichever one loses, loses silently.
//!
//! # What this does instead
//!
//! Payload bytes are sealed under a **data key**, and the data key is wrapped by
//! a key this crate never holds — a KMS, an HSM, whatever the deployment trusts.
//! Erasure destroys the data key. Every copy of the ciphertext becomes
//! unreadable at the same instant, including the ones nobody can reach, because
//! the thing that was destroyed was never in them.
//!
//! This is the standard answer to the immutable-log-versus-erasure problem, and
//! it is the only one that survives contact with a backup regime.
//!
//! Three operations fall out of the same structure, which is the argument for it:
//!
//! * **Erasure** — destroy a data key. Its scope's bytes are gone, everywhere.
//! * **Rotation** — re-wrap data keys under a new wrapping key. Bulk data is
//!   never rewritten, so rotating is cheap enough to actually do on a schedule
//!   rather than a plan.
//! * **Revocation** — destroy a wrapping key. Everything wrapped under it is
//!   unreadable, which is the blast radius a compromised key should have.
//!
//! # What is deliberately absent
//!
//! No key material is generated by, or stored in, this crate beyond the process
//! that is using it. [`KeyRing`] is a seam: a deployment points it at the thing
//! that already holds its keys. There is a `MemoryKeyRing` for tests, and it
//! lives behind the `testkit` feature rather than here: a key ring in the same
//! process as the data it protects protects it from nobody, so the gate is the
//! guarantee instead of a warning in a doc comment.

use std::fmt::Debug;

use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use zeroize::{Zeroize, ZeroizeOnDrop};

use crate::core::{KeyId, Timestamp};

/// A key that seals payload bytes.
///
/// Zeroized on drop. Not `Clone`, not `Debug`-printable, and it does not
/// serialize: a data key that reaches a log line or a journal record is a data
/// key whose destruction no longer erases anything.
#[derive(Zeroize, ZeroizeOnDrop)]
pub struct DataKey([u8; 32]);

impl DataKey {
    /// Take ownership of raw key material.
    #[must_use]
    pub fn new(bytes: [u8; 32]) -> Self {
        Self(bytes)
    }

    /// The raw bytes, for a cipher that needs them.
    #[must_use]
    pub fn expose(&self) -> &[u8; 32] {
        &self.0
    }
}

// Written by hand so the key cannot reach a log through a derived `Debug`.
impl Debug for DataKey {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str("DataKey(<redacted>)")
    }
}

/// A data key as it may safely be stored: sealed by a key this crate never has.
///
/// Safe to journal, back up, and replicate — which is the point, because it
/// travels *with* the payload it sealed. A backup therefore contains everything
/// needed to restore and nothing needed to read: the wrapping key stayed in the
/// service, and destroying it is what makes the backup unreadable.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WrappedKey {
    /// What this key protects: a case, a tenant, whatever the deployment erases
    /// as a unit. Erasure destroys a scope, so the scope *is* the erasure unit.
    pub scope: String,
    /// Which wrapping key sealed it. Rotation changes this and nothing else.
    pub wrapped_by: KeyId,
    /// The sealed data key.
    pub sealed: Vec<u8>,
}

/// Why a key operation did not happen.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum KeyError {
    /// The key is gone, on purpose. The bytes it sealed are unrecoverable.
    ///
    /// Distinct from every other failure, and deliberately so: this is a
    /// completed erasure reporting itself, not an outage. A caller that retries
    /// this forever is waiting for something that will never come back, and a
    /// caller that reports it as corruption sends somebody to look for a fault
    /// that does not exist.
    #[error(
        "the data key for scope '{scope}' was destroyed at {at} ({reason}) — the bytes it \
         sealed are unrecoverable by design, in every copy including backups"
    )]
    Destroyed {
        scope: String,
        at: Timestamp,
        reason: String,
    },

    /// The key ring could not be reached. May succeed later.
    #[error("the key ring is unavailable: {0}")]
    Unavailable(String),

    /// The key ring declined. Will not succeed on retry.
    #[error("the key ring refused: {0}")]
    Refused(String),
}

/// Where data keys are made, wrapped, and destroyed.
///
/// The wrapping key never leaves the implementation. That is the whole seam: a
/// deployment can put it in a KMS and this crate is still only ever holding a
/// data key it was handed, for as long as it takes to seal or open one payload.
#[async_trait]
pub trait KeyRing: Send + Sync + Debug {
    /// Mint a **fresh** data key, wrapped under the scope's key.
    ///
    /// Every call returns a new key. That is not a simplification — it is what
    /// a key-management service does: Vault's `transit/datakey` and AWS KMS's
    /// `GenerateDataKey` both mint one per call and wrap it under a *named*
    /// key. A ring that returned a stable per-scope key could not be
    /// implemented against either.
    ///
    /// It is also the better shape. The erasure unit is the **wrapping key**,
    /// not the data key: destroying a scope's wrapping key makes every data key
    /// ever wrapped under it unopenable at once, however many payloads there
    /// were and wherever their copies ended up.
    ///
    /// # Errors
    ///
    /// [`KeyError::Destroyed`] if the scope was already erased — a scope does
    /// not come back, because a scope that can be re-created is one where a late
    /// write silently lands in an erased unit.
    async fn data_key(&self, scope: &str) -> Result<(DataKey, WrappedKey), KeyError>;

    /// Open a wrapped data key.
    ///
    /// The wrapped form travels **with the payload it sealed**, because that is
    /// the only thing that makes a restore work: a backup holds ciphertext and
    /// its wrapped key, and neither is readable without the wrapping key the
    /// service still holds.
    ///
    /// Erasure is enforced by the service, not by this crate refusing to look:
    /// once the scope's wrapping key is destroyed, whoever kept a copy of the
    /// envelope holds bytes nobody can open — including the operator.
    ///
    /// # Errors
    ///
    /// [`KeyError::Destroyed`] once the scope has been erased.
    async fn open(&self, wrapped: &WrappedKey) -> Result<DataKey, KeyError>;

    /// Destroy a scope's data key. This is the erasure.
    ///
    /// Idempotent: the first destruction stands, so a retry cannot rewrite when
    /// or why the data went. The reason is kept because "erased" without one is
    /// not an answer anybody can give a regulator.
    ///
    /// # Errors
    ///
    /// If the key ring cannot be reached.
    async fn destroy(&self, scope: &str, at: Timestamp, reason: &str) -> Result<(), KeyError>;

    /// Re-wrap a data key under the current wrapping key.
    ///
    /// Rotation touches only the wrapped form — bulk data is never re-encrypted,
    /// which is what makes rotating cheap enough to do on a schedule instead of
    /// in a plan nobody executes.
    ///
    /// # Errors
    ///
    /// [`KeyError::Destroyed`] if the scope has been erased.
    async fn rewrap(&self, wrapped: &WrappedKey) -> Result<WrappedKey, KeyError>;
}

/// The erasure scope for a unit within a tenant.
///
/// Derived here and nowhere else. The write path and the erasure path must
/// agree byte-for-byte about which key seals a payload, and the first version of
/// this had them building the string separately: writes sealed under
/// `tenant/case` while erasure destroyed `case`, so every erasure reported
/// success and destroyed nothing. Two places deriving one fact is how that
/// happens, so there is one place.
///
/// [`TenantId`](crate::core::TenantId) refuses `/`, which is what stops a tenant
/// named `acme/prod` from colliding with tenant `acme`, unit `prod`.
#[must_use]
pub fn scope(tenant: &crate::core::TenantId, unit: &str) -> String {
    format!("{tenant}/{unit}")
}

mod envelope;

mod cases;
pub use cases::SealedCases;

mod events;
pub use events::SealedEvents;

mod tasks;
pub use tasks::SealedTasks;

mod journal;
pub use journal::SealedJournal;

mod sealed;
pub use sealed::EncryptedBlobs;

mod memory;
pub use memory::EncryptedMemoryStore;

#[cfg(feature = "keyring-vault")]
mod vault;
#[cfg(feature = "keyring-vault")]
pub use vault::VaultTransit;