Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions crates/custodian-ipc/src/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
//! Everything crosses the wire as raw bytes — fixed-size arrays for keys and
//! digests, `Vec<u8>` 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
Expand Down
1 change: 0 additions & 1 deletion crates/custodian/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
93 changes: 33 additions & 60 deletions crates/custodian/src/custodian.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -54,7 +46,7 @@ impl KeyPurpose {
}

/// Returns the domain separator for this purpose, used in HKDF expansion.
pub fn domain_separator(&self) -> Vec<u8> {
fn domain_separator(&self) -> Vec<u8> {
format!("{PREFIX}-{}", self.label()).into_bytes()
}
}
Expand All @@ -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).
Expand All @@ -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<Key> {
let hk = Hkdf::<Sha256>::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<const N: usize>(
&self,
purpose: KeyPurpose,
epoch: u64,
) -> [u8; N] {
let hk = Hkdf::<Sha256>::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<aes_gcm::Aes256Gcm> {
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()
}
}
2 changes: 1 addition & 1 deletion crates/custodian/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
15 changes: 9 additions & 6 deletions crates/custodian/src/luks_keyfile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)
Expand All @@ -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()))?;
Expand Down
Loading