dig-keystore 0.5.0

Encrypted secret-key storage for DIG Network binaries (BLS signing + L1 wallet keys). AES-256-GCM + Argon2id, typed per-scheme magic files, zeroizing memory hygiene.
Documentation
//! [`FakeDevice`] — a configurable [`HardwareProvider`] double.
//!
//! # Why a double, and what it does and does not prove
//!
//! Real TPM / Secure Enclave hardware is absent from CI (this repo's workflows
//! run `ubuntu-latest` only) and cannot be made to lie on demand. `FakeDevice`
//! exists to exercise the parts that *are* platform-independent: the tier
//! decision, the fail-closed rules, the envelope codec, and the cross-machine
//! binding property.
//!
//! It models non-exportability **structurally**: the device key lives only
//! inside the `FakeDevice` and is never written into an envelope, so a second
//! device with a different key cannot open the first device's blobs — exactly
//! the situation of a sealed blob copied to another machine. What the double
//! cannot prove is that a *real* platform key is non-exportable; that assertion
//! belongs against the platform itself (it is made by attempting an export and
//! requiring the platform to refuse).
//!
//! The double is deliberately **wide**. A double that can vary only one field
//! cannot express a multi-field lie, and the interesting failures here are
//! precisely providers that are inconsistent with themselves: one that probes
//! `Available` but cannot wrap, one that claims a hardware kind it does not
//! bind, one that reports `NonExportable` custody over a key held in process
//! memory.

use zeroize::Zeroizing;

use std::sync::Arc;

use parking_lot::Mutex;

use super::provider::{ContentKey, HardwareProvider, KeyCustody, CONTENT_KEY_LEN};
use super::tier::{HardwareKind, HardwareProbe};
use crate::cipher;
use crate::error::{KeystoreError, Result};

/// How a [`FakeDevice`] behaves when asked to wrap or unwrap.
///
/// Each variant is an adversary the tier self-test must refute. The vocabulary is
/// deliberately wide: **a fixture set that cannot express a given lie reports the
/// guard against it as safe**, so every clause of the self-test contract needs a
/// variant that violates exactly that clause.
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum WrapBehaviour {
    /// Correct: AES-256-GCM under the device key, round-trips.
    #[default]
    Honest,
    /// `wrap_key` errors — models hardware that probes present but cannot be used.
    FailWrap,
    /// `wrap_key` succeeds, `unwrap_key` errors.
    FailUnwrap,
    /// `wrap_key` returns the content key verbatim — models a "wrapping" that
    /// wraps nothing, leaving the key exportable with the blob.
    Passthrough,
    /// `unwrap_key` returns a *different* key rather than failing — models a
    /// provider whose round-trip silently does not reproduce the key.
    WrongKeyOnUnwrap,

    /// `wrap_key` returns an **empty** wrapped key while `unwrap_key` still
    /// reproduces the right one from an internal slot.
    ///
    /// Models a provider that emits nothing into the envelope but remembers the
    /// key out of band. It is the only way to violate *just* the
    /// "a wrap that returns nothing" clause of the self-test contract: a device
    /// that returned empty and then failed to unwrap would be refuted by the
    /// round-trip clause instead, leaving the empty-wrap guard untested. Such a
    /// provider would write envelopes carrying no wrapped key at all.
    EmptyWrapWithRecall,
}

/// A configurable stand-in for a hardware trusted component.
#[derive(Debug, Clone)]
pub struct FakeDevice {
    kind: HardwareKind,
    probe: HardwareProbe,
    custody: KeyCustody,
    behaviour: WrapBehaviour,
    /// Per-device key. Never leaves this struct and is never written into an
    /// envelope — this is what makes one device's blobs unopenable by another.
    device_key: [u8; 32],
    /// The last content key this device was asked to wrap.
    ///
    /// Serves two purposes: it lets a test assert that the plaintext content key
    /// does not appear in the stored envelope, and it lets
    /// [`WrapBehaviour::EmptyWrapWithRecall`] reproduce a key it never wrote out.
    /// Shared behind an `Arc` so a clone of the device observes the same slot.
    last_wrapped: Arc<Mutex<Option<[u8; CONTENT_KEY_LEN]>>>,
}

impl FakeDevice {
    /// An honest, working device of `kind`, keyed by `device_id`.
    ///
    /// Two devices with different `device_id`s stand for two different machines.
    pub fn working(kind: HardwareKind, device_id: u8) -> Self {
        Self {
            kind,
            probe: HardwareProbe::Available(kind),
            custody: KeyCustody::NonExportable,
            behaviour: WrapBehaviour::Honest,
            device_key: [device_id; 32],
            last_wrapped: Arc::default(),
        }
    }

    /// The last content key this device was asked to wrap, if any.
    ///
    /// Test-only observability: it is what makes "the content key never appears
    /// in the stored bytes" an assertion about the actual key rather than about a
    /// value the test invented.
    pub fn last_wrapped_content_key(&self) -> Option<[u8; CONTENT_KEY_LEN]> {
        *self.last_wrapped.lock()
    }

    /// A host with definitively no hardware.
    pub fn absent(kind: HardwareKind) -> Self {
        Self {
            probe: HardwareProbe::Absent,
            ..Self::working(kind, 0)
        }
    }

    /// A host whose hardware could not be inspected.
    pub fn indeterminate(kind: HardwareKind, detail: &str) -> Self {
        Self {
            probe: HardwareProbe::indeterminate(detail),
            ..Self::working(kind, 0)
        }
    }

    /// Override the probe outcome.
    pub fn with_probe(mut self, probe: HardwareProbe) -> Self {
        self.probe = probe;
        self
    }

    /// Override the reported custody.
    pub fn with_custody(mut self, custody: KeyCustody) -> Self {
        self.custody = custody;
        self
    }

    /// Override the wrap/unwrap behaviour.
    pub fn with_behaviour(mut self, behaviour: WrapBehaviour) -> Self {
        self.behaviour = behaviour;
        self
    }

    /// Override the advertised kind independently of the probed kind, so the
    /// double can contradict itself.
    pub fn with_kind(mut self, kind: HardwareKind) -> Self {
        self.kind = kind;
        self
    }

    /// Length of the per-wrap nonce prefixed to a wrapped blob.
    const NONCE_LEN: usize = 12;

    /// Draw a fresh nonce for one wrap.
    ///
    /// A fixed nonce would be wrong even in a double: every wrap encrypts a
    /// *different* freshly-generated content key under the same device key, so a
    /// constant nonce is AES-GCM nonce reuse across distinct plaintexts. The
    /// nonce is prefixed to the wrapped blob so `unwrap_key` can recover it.
    fn fresh_nonce() -> [u8; Self::NONCE_LEN] {
        let mut nonce = <[u8; Self::NONCE_LEN]>::default();
        rand_core::RngCore::fill_bytes(&mut rand_core::OsRng, &mut nonce);
        nonce
    }
}

impl HardwareProvider for FakeDevice {
    fn kind(&self) -> HardwareKind {
        self.kind
    }

    fn probe(&self) -> HardwareProbe {
        self.probe.clone()
    }

    fn custody(&self) -> KeyCustody {
        self.custody
    }

    fn wrap_key(&self, content_key: &ContentKey) -> Result<Vec<u8>> {
        *self.last_wrapped.lock() = Some(**content_key);
        match self.behaviour {
            WrapBehaviour::FailWrap => Err(KeystoreError::HardwareWrapFailed {
                detail: "fake device refuses to wrap".to_owned(),
            }),
            WrapBehaviour::Passthrough => Ok(content_key.to_vec()),
            // Emits nothing, but see `unwrap_key` — the key is remembered.
            WrapBehaviour::EmptyWrapWithRecall => Ok(Vec::new()),
            _ => {
                // nonce || AES-256-GCM(content key) under the device key.
                let nonce = Self::fresh_nonce();
                let sealed =
                    cipher::encrypt(&self.device_key, &nonce, content_key.as_slice(), b"")?;
                let mut out = Vec::with_capacity(nonce.len() + sealed.len());
                out.extend_from_slice(&nonce);
                out.extend_from_slice(&sealed);
                Ok(out)
            }
        }
    }

    fn unwrap_key(&self, wrapped: &[u8]) -> Result<ContentKey> {
        match self.behaviour {
            WrapBehaviour::FailUnwrap => {
                return Err(KeystoreError::HardwareUnwrapFailed {
                    detail: "fake device refuses to unwrap".to_owned(),
                })
            }
            WrapBehaviour::WrongKeyOnUnwrap => {
                // Any key OTHER than the one wrapped; derived, not a literal.
                let mut wrong = <[u8; CONTENT_KEY_LEN]>::default();
                rand_core::RngCore::fill_bytes(&mut rand_core::OsRng, &mut wrong);
                return Ok(Zeroizing::new(wrong));
            }
            WrapBehaviour::EmptyWrapWithRecall => {
                return self.last_wrapped.lock().map(Zeroizing::new).ok_or_else(|| {
                    KeystoreError::HardwareUnwrapFailed {
                        detail: "recall device has wrapped nothing yet".to_owned(),
                    }
                })
            }
            WrapBehaviour::Passthrough => {
                let bytes: [u8; CONTENT_KEY_LEN] =
                    wrapped
                        .try_into()
                        .map_err(|_| KeystoreError::HardwareUnwrapFailed {
                            detail: "passthrough device got a non-key blob".to_owned(),
                        })?;
                return Ok(Zeroizing::new(bytes));
            }
            _ => {}
        }

        // Split the prefixed nonce back off. A blob too short to carry one was
        // not produced by this device.
        if wrapped.len() <= Self::NONCE_LEN {
            return Err(KeystoreError::HardwareUnwrapFailed {
                detail: "wrapped blob is too short to carry a nonce".to_owned(),
            });
        }
        let (nonce, sealed) = wrapped.split_at(Self::NONCE_LEN);
        let nonce: [u8; Self::NONCE_LEN] = nonce.try_into().expect("checked length");

        // A blob sealed by a *different* device key fails here — the
        // cross-machine binding guarantee.
        let plain = cipher::decrypt(&self.device_key, &nonce, sealed, b"").map_err(|_| {
            KeystoreError::HardwareUnwrapFailed {
                detail: "wrapped key was not sealed by this device".to_owned(),
            }
        })?;
        let bytes: [u8; CONTENT_KEY_LEN] =
            plain
                .as_slice()
                .try_into()
                .map_err(|_| KeystoreError::HardwareUnwrapFailed {
                    detail: "unwrapped key has the wrong length".to_owned(),
                })?;
        Ok(Zeroizing::new(bytes))
    }
}