diff --git a/Cargo.lock b/Cargo.lock index fd7e3ddf..5d6cd3a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5535,12 +5535,10 @@ dependencies = [ "aead", "arrayref", "arrayvec", - "cfg-if", "curve25519-dalek", "getrandom_or_panic", "merlin", "rand_core 0.6.4", - "serde", "serde_bytes", "sha2 0.10.9", "subtle", @@ -5702,7 +5700,6 @@ dependencies = [ "anyhow", "hkdf", "rand 0.9.2", - "schnorrkel", "secp256k1", "seismic-crypto", "sha2 0.10.9", diff --git a/Cargo.toml b/Cargo.toml index a78ac9d1..07201749 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,7 +71,7 @@ rand = "0.9.2" # Provider choice for the process-level rustls default that attested-tls's # collateral fetching requires the application to install. rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs"] } -schnorrkel = { version = "0.11.2", features = ["serde"] } +schnorrkel = "0.11.2" secp256k1 = { version = "0.30", features = ["rand", "recovery", "std", "serde"] } serde = { version = "1.0", features = ["derive"] } serde_bytes = "0.11" diff --git a/crates/custodian-ipc/src/messages.rs b/crates/custodian-ipc/src/messages.rs index e3a51e08..61717237 100644 --- a/crates/custodian-ipc/src/messages.rs +++ b/crates/custodian-ipc/src/messages.rs @@ -3,8 +3,8 @@ //! Everything crosses the wire as raw bytes — fixed-size arrays for keys and //! digests, `Vec` for variable-length blobs — tagged `serde_bytes` so CBOR //! encodes them as native byte strings. Callers convert to typed keys -//! (`secp256k1`, `schnorrkel`, `aes-gcm`) at their own boundary; see the -//! crate-level boundary rule. +//! (`secp256k1`, `aes-gcm`) at their own boundary; see the crate-level +//! boundary rule. //! //! Key-fetch methods are deliberately one-per-purpose, never bundled: the //! method is the unit an ACL grant covers, so each caller can be given diff --git a/crates/custodian/Cargo.toml b/crates/custodian/Cargo.toml index 3564ffc1..c6218a36 100644 --- a/crates/custodian/Cargo.toml +++ b/crates/custodian/Cargo.toml @@ -16,7 +16,6 @@ aes-gcm.workspace = true anyhow.workspace = true hkdf.workspace = true rand.workspace = true -schnorrkel.workspace = true secp256k1.workspace = true sha2.workspace = true zeroize.workspace = true diff --git a/crates/custodian/src/custodian.rs b/crates/custodian/src/custodian.rs index 9e62211e..64551928 100644 --- a/crates/custodian/src/custodian.rs +++ b/crates/custodian/src/custodian.rs @@ -2,32 +2,24 @@ use anyhow::Result; use hkdf::Hkdf; use rand::{TryRngCore as _, rngs::OsRng}; use sha2::Sha256; -use zeroize::{Zeroize, ZeroizeOnDrop}; +use zeroize::ZeroizeOnDrop; /// Salt used during HKDF key derivation for purpose-specific keys. const PURPOSE_DERIVE_SALT: &[u8] = b"seismic-purpose-derive-salt"; /// Prefix used in domain separation when deriving purpose-specific keys. const PREFIX: &str = "seismic-purpose"; -#[derive(Zeroize, ZeroizeOnDrop, Clone)] -pub struct Key([u8; 32]); - -impl AsRef<[u8]> for Key { - fn as_ref(&self) -> &[u8] { - &self.0 - } -} - /// Holder of the network root key; every other secret is derived from it on -/// demand. Deliberately not `Clone`: exactly one copy per process, dropped -/// (and zeroized) with the custodian itself. +/// demand. Deliberately not `Clone`: exactly one copy per process, zeroized +/// when the custodian drops. +#[derive(ZeroizeOnDrop)] pub struct Custodian { - pub(crate) root_key: Key, + pub(crate) root_key: [u8; 32], } /// Enum representing the intended usage ("purpose") of a derived key. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum KeyPurpose { +pub(crate) enum KeyPurpose { Snapshot, RngPrecompile, TxIo, @@ -54,7 +46,7 @@ impl KeyPurpose { } /// Returns the domain separator for this purpose, used in HKDF expansion. - pub fn domain_separator(&self) -> Vec { + fn domain_separator(&self) -> Vec { format!("{PREFIX}-{}", self.label()).into_bytes() } } @@ -63,9 +55,7 @@ impl Custodian { /// Install an already-obtained root key (the joining-node path: the caller /// ran the attested bootstrap handshake and unwrapped the peer's response). pub fn new(root_key: [u8; 32]) -> Self { - Self { - root_key: Key(root_key), - } + Self { root_key } } /// Generate a fresh root key from the OS CSPRNG (the genesis-node path). @@ -78,69 +68,52 @@ impl Custodian { Ok(km) } - /// Derives a key for a specific `KeyPurpose` - /// - /// # Errors + /// HKDF-expands the root key into `N` bytes for one purpose and epoch. + /// Every purpose takes 32 bytes except `RngPrecompile`, whose precompile + /// wants 64 bytes of IKM. /// - /// Returns an error if HKDF expansion fails (though this is unlikely with correct parameters). - pub fn derive_purpose_key(&self, purpose: KeyPurpose, epoch: u64) -> Result { - let hk = Hkdf::::new(Some(PURPOSE_DERIVE_SALT), self.root_key.0.as_ref()); + /// Crate-internal, so every purpose has exactly one way out: `get_*` for + /// the keys a caller holds in memory, and `write_luks_keyfile` for the + /// LUKS pair, which leaves only as a file. + pub(crate) fn expand_purpose( + &self, + purpose: KeyPurpose, + epoch: u64, + ) -> [u8; N] { + let hk = Hkdf::::new(Some(PURPOSE_DERIVE_SALT), &self.root_key); let mut info = purpose.domain_separator(); info.extend_from_slice(&epoch.to_be_bytes()); - let mut derived_key = vec![0u8; 32]; - hk.expand(&info, &mut derived_key) - .expect("32 is a valid length for Sha256 to output"); - let key = Key(derived_key.try_into().expect("unfallible")); - - Ok(key) + let mut derived = [0u8; N]; + hk.expand(&info, &mut derived) + .expect("N is far below HKDF-SHA256's 255 * 32 byte output limit"); + derived } pub fn get_tx_io_sk(&self, epoch: u64) -> secp256k1::SecretKey { - let key = self - .derive_purpose_key(KeyPurpose::TxIo, epoch) - .expect("purpose key derivation must succeed"); - secp256k1::SecretKey::from_slice(key.as_ref()) + let key: [u8; 32] = self.expand_purpose(KeyPurpose::TxIo, epoch); + secp256k1::SecretKey::from_slice(&key) .expect("retrieved secp256k1 secret key should be valid") } /// Retrieves the secp256k1 public key corresponding to the TxIo secret key. pub fn get_tx_io_pk(&self, epoch: u64) -> secp256k1::PublicKey { - let key = self - .derive_purpose_key(KeyPurpose::TxIo, epoch) - .expect("purpose key derivation must succeed"); - let sk = secp256k1::SecretKey::from_slice(key.as_ref()) + let key: [u8; 32] = self.expand_purpose(KeyPurpose::TxIo, epoch); + let sk = secp256k1::SecretKey::from_slice(&key) .expect("retrieved secp256k1 secret key should be valid"); secp256k1::PublicKey::from_secret_key(&secp256k1::Secp256k1::new(), &sk) } /// Derives the 64 bytes of HKDF input key material that seed the RNG - /// precompile: the secret half of a schnorrkel keypair expanded from the - /// purpose-derived mini secret. - // TODO: the schnorrkel mini-secret expansion is kept only for backward - // compatibility with the running testnet — the expansion determines the - // derived bytes, and the RNG precompile's outputs are consensus. On the - // next network reset, drop it and take the ikm straight from the purpose - // derivation: have `derive_purpose_key` expand 64 bytes here instead of - // 32, removing schnorrkel from the custodian. + /// precompile (0x64). The precompile re-derives from them on every call, + /// so these bytes are consensus for the network's lifetime. pub fn get_rng_ikm(&self, epoch: u64) -> [u8; 64] { - let mini_key = self - .derive_purpose_key(KeyPurpose::RngPrecompile, epoch) - .expect("purpose key derivation must succeed"); - let mini_key_bytes = mini_key.as_ref(); - let mini_secret_key = schnorrkel::MiniSecretKey::from_bytes(mini_key_bytes) - .expect("mini_secret_key should be valid"); - mini_secret_key - .expand(schnorrkel::ExpansionMode::Uniform) - .to_bytes() + self.expand_purpose(KeyPurpose::RngPrecompile, epoch) } /// Retrieves the AES-256-GCM encryption key used for snapshot operations. pub fn get_snapshot_key(&self, epoch: u64) -> aes_gcm::Key { - let key = self - .derive_purpose_key(KeyPurpose::Snapshot, epoch) - .expect("purpose key derivation must succeed"); - let bytes: [u8; 32] = key.as_ref().try_into().expect("Key should be 32 bytes"); - bytes.into() + let key: [u8; 32] = self.expand_purpose(KeyPurpose::Snapshot, epoch); + key.into() } } diff --git a/crates/custodian/src/lib.rs b/crates/custodian/src/lib.rs index 5679882c..1d5ed440 100644 --- a/crates/custodian/src/lib.rs +++ b/crates/custodian/src/lib.rs @@ -20,7 +20,7 @@ mod custodian; // it doesn't — RAM-only derivation of root_key + purpose keys mod luks_keyfile; // ephemeral tmpfs keyfile, handed off to setup-persistent-luks mod root_key_wrap; // over the network, AEAD-wrapped to an attested peer -pub use custodian::{Custodian, Key, KeyPurpose}; +pub use custodian::Custodian; pub use root_key_wrap::{ EphemeralKeypair, VerifiedPeerAuthorization, WrappedRootKey, unwrap_root_key, }; diff --git a/crates/custodian/src/luks_keyfile.rs b/crates/custodian/src/luks_keyfile.rs index a660ba62..ec87e3c5 100644 --- a/crates/custodian/src/luks_keyfile.rs +++ b/crates/custodian/src/luks_keyfile.rs @@ -3,6 +3,7 @@ use crate::custodian::{Custodian, KeyPurpose}; use anyhow::{Context as _, Result}; use std::{fs, fs::OpenOptions, io::Write as _, os::unix::fs::OpenOptionsExt as _, path::Path}; +use zeroize::Zeroizing; /// Storage and header-MAC keys are pinned at epoch 0; they are never rotated. const KEYS_EPOCH_0: u64 = 0; @@ -16,12 +17,14 @@ impl Custodian { /// file. Where the file goes, and who reads and shreds it, is the /// caller's deployment contract. pub fn write_luks_keyfile(&self, path: &Path) -> Result<()> { - let storage_key = self.derive_purpose_key(KeyPurpose::Storage, KEYS_EPOCH_0)?; - let header_mac_key = self.derive_purpose_key(KeyPurpose::LuksHeaderMac, KEYS_EPOCH_0)?; + let storage_key: [u8; 32] = self.expand_purpose(KeyPurpose::Storage, KEYS_EPOCH_0); + let header_mac_key: [u8; 32] = self.expand_purpose(KeyPurpose::LuksHeaderMac, KEYS_EPOCH_0); - let mut buf = [0u8; 64]; - buf[..32].copy_from_slice(storage_key.as_ref()); - buf[32..].copy_from_slice(header_mac_key.as_ref()); + // The assembled image is the longest-lived copy this process keeps, + // and it leaves as a file; scrub it when the write is done. + let mut buf = Zeroizing::new([0u8; 64]); + buf[..32].copy_from_slice(&storage_key); + buf[32..].copy_from_slice(&header_mac_key); let tmp = path.with_extension("tmp"); // delete in case a previous run left a tmp file (crashed before deleting it) @@ -32,7 +35,7 @@ impl Custodian { .mode(0o400) .open(&tmp) .with_context(|| format!("creating {}", tmp.display()))?; - f.write_all(&buf)?; + f.write_all(buf.as_slice())?; f.sync_all()?; drop(f); fs::rename(&tmp, path).with_context(|| format!("renaming {} into place", tmp.display()))?;