skilj-core 0.2.0

The engine behind skilj: event storage, DCB consistency checks, projections and the plugin API for event and command types. Most applications use it through the skilj crate.
Documentation
//! Envelope encryption for `SensitiveField`/`EncryptionKey` - see
//! `docs/architecture.md`'s own write-up of this pass and
//! `specs/skilj.allium`'s "the encryption scheme itself (algorithm, key
//! derivation, physical key storage)... is a black box, consistent with
//! how `AccessToken.secret` generation and JWT signature verification are
//! already treated" note (§Excludes). Uses `ring` (already in the
//! dependency tree transitively via `reqwest`'s rustls-tls - promoted to
//! a direct dependency here, not a new crate) rather than hand-rolled
//! crypto.
//!
//! Two-layer (envelope) scheme: each `EncryptionKey` row gets its own
//! random 256-bit AES-256-GCM `DataKey`, generated once and never
//! persisted in the clear - it's wrapped (encrypted) under its bounded
//! context's master key at the key provider - the default key, or the
//! context's own (`provider`, `SkiljBuilder::key_provider`;
//! docs/architecture.md §230; originally one process-wide
//! `EncryptionMasterKey`) - and only the wrapped bytes are stored
//! (`db::get_or_create_encryption_key`). A sensitive field's own
//! plaintext leaf is sealed under the *data* key, not the master key
//! directly - destroying one subject's `EncryptionKey` (nulling its
//! wrapped data key - see `db::destroy_encryption_key`) only ever affects
//! that one subject's own ciphertext, never anyone else's, even though
//! every subject in a process shares the same master key. Consistent with
//! the spec's own stated threat model: protecting *application-level*
//! reads, not a raw-Postgres-access attacker - an operator with direct
//! Postgres access already has full access, per the spec's own text
//! elsewhere on why "what's still plaintext" isn't a query this library
//! offers.
//!
//! `decrypt_leaf` backs the real per-field, per-grant decrypt test both
//! `event_store::render_event`/`render_command` (a declared `SensitiveField`
//! names the exact leaf and subject) and `projections::read_projection`
//! (no declaration at all - `decrypt_ciphertext_leaves` below tries every
//! string leaf in a projection's own stored state against every key
//! resolved for the query's own instance `key`, safe to attempt broadly
//! since `decrypt_leaf`'s own AEAD tag makes a false-positive match
//! astronomically unlikely) both call into.

use ring::aead::{Aad, LessSafeKey, Nonce, UnboundKey, AES_256_GCM, NONCE_LEN};
use ring::rand::{SecureRandom, SystemRandom};

pub mod provider;
pub mod service;
pub mod trail;
pub mod vault;

pub use provider::{
    InMemoryKeyProvider, KeyFileProvider, KeyProvider, KeyVersions, WrappedDataKey,
};
pub use service::{KeyService, StoredWrap};
pub use vault::VaultTransitProvider;

const KEY_LEN: usize = 32; // AES-256

/// Library-level errors this module's own operations reject for.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
    #[error(
        "sensitive_fields declares a field to encrypt, but no key provider was configured \
         (SkiljBuilder::key_provider)"
    )]
    /// A type declares sensitive fields, but no key provider is
    /// configured.
    MasterKeyNotConfigured,

    /// Covers both an `EncryptionKey`'s own wrapped `DataKey` failing to
    /// unwrap (corrupted/tampered stored bytes, or the wrong master key)
    /// and a leaf's own ciphertext failing to decrypt - `ring`'s own AEAD
    /// failure (`ring::error::Unspecified`) carries no further detail by
    /// design, so neither case can say more than "this didn't verify."
    #[error("AEAD decryption failed - corrupted or tampered ciphertext, or the wrong key")]
    DecryptFailed,

    /// The key provider could not be reached, or failed
    /// (docs/architecture.md §230). Worth retrying.
    #[error("key provider {provider} is unavailable: {message}")]
    ProviderUnavailable {
        /// The provider.
        provider: String,
        /// What went wrong.
        message: String,
    },

    /// The key provider refused the request: a permission it lacks, a key
    /// that doesn't exist, a ciphertext it doesn't accept.
    #[error("key provider {provider} refused: {message}")]
    ProviderRefused {
        /// The provider.
        provider: String,
        /// Its answer.
        message: String,
    },

    /// A data key is wrapped under a master key version that was retired
    /// (docs/architecture.md §230): it can no longer be unwrapped.
    #[error("the master key version {version} this data key is wrapped under is retired")]
    KeyVersionRetired {
        /// The retired version.
        version: u32,
    },

    /// Data keys were wrapped by another key provider, or another key,
    /// than the configured one (docs/architecture.md §230).
    #[error(
        "data keys are wrapped by key provider {stored}, but {configured} is configured: \
         configure the provider, and the key, that wrapped them"
    )]
    ProviderChanged {
        /// The provider the data keys name.
        stored: String,
        /// The configured provider.
        configured: String,
    },

    /// A key answers to the name the data keys record, but unwraps none of
    /// the newest of a bounded context's (docs/architecture.md §230): other
    /// key material under the same name - a key file recreated at its path,
    /// another Vault cluster with the same key path.
    #[error(
        "key {provider} unwraps none of {bounded_context}'s newest data keys: it is not the key \
         that wrapped them (a key file recreated, another Vault cluster?) - restore that key"
    )]
    KeyMaterialChanged {
        /// The key's name.
        provider: String,
        /// The bounded context whose data keys it doesn't unwrap.
        bounded_context: String,
    },

    /// Data keys from before key providers are stored, and no legacy master
    /// key is configured to read them (docs/architecture.md §230).
    #[error(
        "data keys wrapped before key providers are stored: configure the master key they were \
         wrapped under with SkiljBuilder::legacy_encryption_master_key until they are rewrapped"
    )]
    LegacyMasterKeyRequired,

    /// The configured legacy master key unwraps none of the newest data
    /// keys a bounded context stored before key providers: not the key they
    /// were wrapped under.
    #[error(
        "the legacy encryption master key unwraps none of {bounded_context}'s newest data keys \
         from before key providers: configure the master key they were wrapped under"
    )]
    LegacyMasterKeyChanged {
        /// The bounded context whose data keys it doesn't unwrap.
        bounded_context: String,
    },
}

impl crate::error::SkiljRejection for Error {
    fn code(&self) -> &str {
        match self {
            Error::MasterKeyNotConfigured => "encryption_master_key_not_configured",
            Error::DecryptFailed => "encryption_decrypt_failed",
            Error::ProviderUnavailable { .. } => "encryption_key_provider_unavailable",
            Error::ProviderRefused { .. } => "encryption_key_provider_refused",
            Error::KeyVersionRetired { .. } => "encryption_key_version_retired",
            Error::ProviderChanged { .. } => "encryption_key_provider_changed",
            Error::KeyMaterialChanged { .. } => "encryption_key_material_changed",
            Error::LegacyMasterKeyRequired => "encryption_legacy_master_key_required",
            Error::LegacyMasterKeyChanged { .. } => "encryption_legacy_master_key_changed",
        }
    }

    fn message(&self) -> String {
        self.to_string()
    }
}

/// The raw master key skilj used before key providers
/// (docs/architecture.md §230): now only read, through
/// `SkiljBuilder::legacy_encryption_master_key`, to unwrap the data keys it
/// wrapped until they are rewrapped by the provider. Originally: a
/// process-wide symmetric key wrapping every per-subject `DataKey` this
/// process ever provisions - supplied once, at `SkiljBuilder::
/// encryption_master_key(...)` time. Never itself persisted; a consuming
/// application owns keeping this value stable across restarts - losing it
/// makes every already-wrapped `DataKey` permanently unrecoverable, the
/// same "physical key storage" concern the spec leaves entirely to this
/// module's own discretion.
#[derive(Clone)]
pub struct EncryptionMasterKey([u8; KEY_LEN]);

impl EncryptionMasterKey {
    /// The key from its 32 bytes, which should come from a secure random
    /// source and be kept secret.
    pub fn from_bytes(bytes: [u8; KEY_LEN]) -> Self {
        Self(bytes)
    }
}

impl std::fmt::Debug for EncryptionMasterKey {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str("EncryptionMasterKey(REDACTED)")
    }
}

/// One subject's own per-`EncryptionKey` symmetric key - generated once,
/// at provisioning time (`db::get_or_create_encryption_key`), and never
/// itself persisted in the clear; only its *wrapped* form
/// (`generate_and_wrap_data_key`'s own output) is stored. Deliberately no
/// `Debug` impl - nothing should ever have a reason to print one.
#[derive(Clone)]
pub struct DataKey([u8; KEY_LEN]);

impl DataKey {
    /// The key's bytes, for wrapping by a key provider.
    pub(crate) fn bytes(&self) -> &[u8; KEY_LEN] {
        &self.0
    }
}

fn random_bytes<const N: usize>() -> [u8; N] {
    let rng = SystemRandom::new();
    let mut bytes = [0u8; N];
    rng.fill(&mut bytes).expect(
        "SystemRandom::fill only fails on catastrophic OS RNG failure - see ring's own docs",
    );
    bytes
}

fn seal(key_bytes: &[u8; KEY_LEN], nonce_bytes: [u8; NONCE_LEN], mut in_out: Vec<u8>) -> Vec<u8> {
    let unbound = UnboundKey::new(&AES_256_GCM, key_bytes)
        .expect("a KEY_LEN=32-byte key is always valid for AES_256_GCM");
    let key = LessSafeKey::new(unbound);
    let nonce = Nonce::assume_unique_for_key(nonce_bytes);
    key.seal_in_place_append_tag(nonce, Aad::empty(), &mut in_out)
        .expect("sealing a plain byte buffer under a fresh nonce never fails");
    in_out
}

fn open(
    key_bytes: &[u8; KEY_LEN],
    nonce_bytes: [u8; NONCE_LEN],
    mut in_out: Vec<u8>,
) -> Result<Vec<u8>, Error> {
    let unbound = UnboundKey::new(&AES_256_GCM, key_bytes)
        .expect("a KEY_LEN=32-byte key is always valid for AES_256_GCM");
    let key = LessSafeKey::new(unbound);
    let nonce = Nonce::assume_unique_for_key(nonce_bytes);
    let len = key
        .open_in_place(nonce, Aad::empty(), &mut in_out)
        .map_err(|_| Error::DecryptFailed)?
        .len();
    in_out.truncate(len);
    Ok(in_out)
}

/// Generates a fresh `DataKey` for a newly-provisioned `EncryptionKey`
/// row, wrapped under `master_key` - `db::get_or_create_encryption_key`'s
/// own "provision" branch. Returns both the plaintext `DataKey` (for the
/// caller to encrypt with immediately) and its wrapped bytes + nonce (for
/// the caller to persist - never the plaintext key itself).
pub fn generate_and_wrap_data_key(master_key: &EncryptionMasterKey) -> (DataKey, Vec<u8>, Vec<u8>) {
    let data_key_bytes = random_bytes::<KEY_LEN>();
    let nonce_bytes = random_bytes::<NONCE_LEN>();
    let wrapped = seal(&master_key.0, nonce_bytes, data_key_bytes.to_vec());
    (DataKey(data_key_bytes), wrapped, nonce_bytes.to_vec())
}

/// Recovers a previously-wrapped `DataKey` - `db::get_or_create_encryption_key`'s
/// own "reuse an already-active key" branch.
pub fn unwrap_data_key(
    master_key: &EncryptionMasterKey,
    wrapped: &[u8],
    nonce: &[u8],
) -> Result<DataKey, Error> {
    let nonce_bytes: [u8; NONCE_LEN] = nonce.try_into().map_err(|_| Error::DecryptFailed)?;
    let opened = open(&master_key.0, nonce_bytes, wrapped.to_vec())?;
    let key_bytes: [u8; KEY_LEN] = opened.try_into().map_err(|_| Error::DecryptFailed)?;
    Ok(DataKey(key_bytes))
}

/// Encrypts one sensitive field's own plaintext leaf value under its
/// subject's `DataKey` - `event_store::protect_sensitive_fields`'s own
/// per-field operation. A fresh random nonce every call, prepended to the
/// ciphertext+tag and base64-encoded as a single opaque string - the
/// value that replaces the leaf's plaintext in the stored payload.
pub fn encrypt_leaf(data_key: &DataKey, plaintext: &str) -> String {
    let nonce_bytes = random_bytes::<NONCE_LEN>();
    let sealed = seal(&data_key.0, nonce_bytes, plaintext.as_bytes().to_vec());
    let mut combined = Vec::with_capacity(NONCE_LEN + sealed.len());
    combined.extend_from_slice(&nonce_bytes);
    combined.extend_from_slice(&sealed);
    crate::base64::encode(&combined)
}

/// Inverts `encrypt_leaf`.
pub fn decrypt_leaf(data_key: &DataKey, ciphertext_b64: &str) -> Result<String, Error> {
    let combined = crate::base64::decode(ciphertext_b64).ok_or(Error::DecryptFailed)?;
    if combined.len() < NONCE_LEN {
        return Err(Error::DecryptFailed);
    }
    let (nonce_bytes, ciphertext) = combined.split_at(NONCE_LEN);
    let nonce_bytes: [u8; NONCE_LEN] = nonce_bytes.try_into().map_err(|_| Error::DecryptFailed)?;
    let opened = open(&data_key.0, nonce_bytes, ciphertext.to_vec())?;
    String::from_utf8(opened).map_err(|_| Error::DecryptFailed)
}

/// `projections::read_projection`'s own real branch - real decrypt-on-read
/// for `Projection` state, with no field declared sensitive anywhere on
/// `Projection` itself (see `docs/architecture.md`'s own write-up of this
/// pass, and `specs/skilj.allium`'s `SensitiveFieldsStayProtected`
/// guarantee on `ProjectionQuery`: "sensitivity is declared once, on the
/// EventType, and inherited rather than re-declared per projection" -
/// this is what actually delivers that, rather than asking a projection
/// author to redeclare it, a design a declared-`sensitive_fields` draft
/// was rejected for: an author who forgets to declare a field that
/// really does hold sensitive content would otherwise leak it to every
/// caller regardless of grant, silently).
///
/// Recurses into every object/array, trying `decrypt_leaf` against each
/// of `data_keys` for every string leaf it finds, at any depth - no
/// "one level of nesting" cap the way a *declared* `SensitiveField.field`
/// path needs one for: there's no static path to validate at
/// registration time here, so there's nothing stopping a full, generic
/// walk, which is also strictly more thorough. A leaf that decrypts under
/// none of `data_keys` is left completely untouched - genuinely plain
/// data, or ciphertext for some other subject either way; a leaf that
/// decrypts under one is real ciphertext for the subject those keys were
/// resolved for, substituted with its plaintext. Every candidate key is
/// tried in turn and the first successful one wins - `data_keys` is
/// expected to be small (the number of distinct subjects the query's own
/// instance `key` matches across every `subject_key` namespace, normally
/// one), so this stays cheap even though it isn't declaratively scoped to
/// one exact field.
pub fn decrypt_ciphertext_leaves(value: &mut serde_json::Value, data_keys: &[DataKey]) {
    match value {
        serde_json::Value::String(s) => {
            for data_key in data_keys {
                if let Ok(plaintext) = decrypt_leaf(data_key, s) {
                    *s = plaintext;
                    return;
                }
            }
        }
        serde_json::Value::Array(items) => {
            for item in items {
                decrypt_ciphertext_leaves(item, data_keys);
            }
        }
        serde_json::Value::Object(fields) => {
            for field in fields.values_mut() {
                decrypt_ciphertext_leaves(field, data_keys);
            }
        }
        _ => {}
    }
}