use zeph_common::hash_chain::{ChainKey, ChainKeyRing};
use zeph_vault::VaultProvider;
const KEY_LEN: usize = 32;
pub const HISTORY_KEY_SECRET: &str = "ZEPH_HISTORY_KEY";
pub const HISTORY_KEY_EPOCH_SECRET: &str = "ZEPH_HISTORY_KEY_EPOCH";
pub const HISTORY_KEY_PREVIOUS_SECRET: &str = "ZEPH_HISTORY_KEY_PREVIOUS";
pub const HISTORY_KEY_PREVIOUS_EPOCH_SECRET: &str = "ZEPH_HISTORY_KEY_PREVIOUS_EPOCH";
const HISTORY_CHAIN_KEY_CONTEXT_PREFIX: &str = "zeph-history v1 chain key domain=";
#[derive(Debug, thiserror::Error)]
pub enum HistoryKeyError {
#[error("history chain key must be {KEY_LEN} bytes, got {actual}")]
InvalidKeyLength {
actual: usize,
},
#[error("history chain key is not valid base64")]
MalformedEncoding,
#[error("history key epoch is not a valid non-negative integer")]
MalformedEpoch,
}
pub fn derive_history_chain_key_b64(
b64_key: &str,
domain: &str,
) -> Result<ChainKey, HistoryKeyError> {
use base64::Engine as _;
let bytes = base64::engine::general_purpose::STANDARD
.decode(b64_key.trim())
.map_err(|_| HistoryKeyError::MalformedEncoding)?;
if bytes.len() != KEY_LEN {
return Err(HistoryKeyError::InvalidKeyLength {
actual: bytes.len(),
});
}
let context = format!("{HISTORY_CHAIN_KEY_CONTEXT_PREFIX}{domain}");
Ok(ChainKey::new(blake3::derive_key(&context, &bytes)))
}
#[must_use]
pub fn generate_history_key_b64() -> String {
use base64::Engine as _;
use rand::Rng as _;
let mut bytes = [0u8; KEY_LEN];
rand::rng().fill_bytes(&mut bytes);
base64::engine::general_purpose::STANDARD.encode(bytes)
}
pub async fn resolve_key_ring(
vault: &dyn VaultProvider,
domain: &str,
) -> Result<Option<ChainKeyRing>, HistoryKeyError> {
let get = |key: &'static str| async move { vault.get_secret(key).await.ok().flatten() };
build_key_ring(
get(HISTORY_KEY_SECRET).await,
get(HISTORY_KEY_EPOCH_SECRET).await,
get(HISTORY_KEY_PREVIOUS_SECRET).await,
get(HISTORY_KEY_PREVIOUS_EPOCH_SECRET).await,
domain,
)
}
pub fn resolve_key_ring_sync(
provider: &zeph_vault::AgeVaultProvider,
domain: &str,
) -> Result<Option<ChainKeyRing>, HistoryKeyError> {
build_key_ring(
provider.get(HISTORY_KEY_SECRET).map(str::to_owned),
provider.get(HISTORY_KEY_EPOCH_SECRET).map(str::to_owned),
provider.get(HISTORY_KEY_PREVIOUS_SECRET).map(str::to_owned),
provider
.get(HISTORY_KEY_PREVIOUS_EPOCH_SECRET)
.map(str::to_owned),
domain,
)
}
fn build_key_ring(
current_b64: Option<String>,
current_epoch: Option<String>,
previous_b64: Option<String>,
previous_epoch: Option<String>,
domain: &str,
) -> Result<Option<ChainKeyRing>, HistoryKeyError> {
let Some(current_b64) = current_b64 else {
return Ok(None);
};
let current_epoch = match current_epoch {
Some(s) => s
.trim()
.parse::<u32>()
.map_err(|_| HistoryKeyError::MalformedEpoch)?,
None => 0,
};
let current_key = derive_history_chain_key_b64(¤t_b64, domain)?;
let mut ring = ChainKeyRing::new(current_epoch, current_key);
if let Some(previous_b64) = previous_b64 {
let previous_epoch = match previous_epoch {
Some(s) => s
.trim()
.parse::<u32>()
.map_err(|_| HistoryKeyError::MalformedEpoch)?,
None => current_epoch.saturating_sub(1),
};
let previous_key = derive_history_chain_key_b64(&previous_b64, domain)?;
ring = ring.with_previous(previous_epoch, previous_key);
}
Ok(Some(ring))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn derive_history_chain_key_rejects_malformed_base64() {
assert!(matches!(
derive_history_chain_key_b64("not base64!", "d"),
Err(HistoryKeyError::MalformedEncoding)
));
}
#[test]
fn derive_history_chain_key_rejects_wrong_length() {
use base64::Engine as _;
let short = base64::engine::general_purpose::STANDARD.encode(b"short");
assert!(matches!(
derive_history_chain_key_b64(&short, "d"),
Err(HistoryKeyError::InvalidKeyLength { .. })
));
}
#[test]
fn derive_history_chain_key_is_domain_separated() {
let key = generate_history_key_b64();
let a = derive_history_chain_key_b64(&key, "domain-a").unwrap();
let b = derive_history_chain_key_b64(&key, "domain-b").unwrap();
let probe = zeph_common::hash_chain::genesis(&a, "x", b"y", 0);
let probe_b = zeph_common::hash_chain::genesis(&b, "x", b"y", 0);
assert_ne!(
probe, probe_b,
"chain keys for different domains must differ"
);
}
#[test]
fn derive_history_chain_key_independent_from_durable_key_derivation() {
let key = generate_history_key_b64();
let history_key = derive_history_chain_key_b64(&key, "zeph-session log v1").unwrap();
let durable_hmac_key = crate::durable::derive_control_hmac_key_b64(&key).unwrap();
let probe = zeph_common::hash_chain::genesis(&history_key, "x", b"y", 0);
let probe_durable = zeph_common::hash_chain::genesis(
&zeph_common::hash_chain::ChainKey::new(durable_hmac_key),
"x",
b"y",
0,
);
assert_ne!(probe, probe_durable);
}
#[tokio::test]
async fn resolve_key_ring_returns_none_when_unprovisioned() {
let vault = zeph_vault::MockVaultProvider::new();
let ring = resolve_key_ring(&vault, "zeph-session log v1")
.await
.unwrap();
assert!(ring.is_none());
}
#[tokio::test]
async fn resolve_key_ring_resolves_current_only() {
let key = generate_history_key_b64();
let vault = zeph_vault::MockVaultProvider::new().with_secret(HISTORY_KEY_SECRET, &key);
let ring = resolve_key_ring(&vault, "zeph-session log v1")
.await
.unwrap()
.expect("key ring must resolve once the root secret is provisioned");
assert_eq!(ring.current_epoch(), 0);
}
#[tokio::test]
async fn resolve_key_ring_resolves_rotation_window() {
let current = generate_history_key_b64();
let previous = generate_history_key_b64();
let vault = zeph_vault::MockVaultProvider::new()
.with_secret(HISTORY_KEY_SECRET, ¤t)
.with_secret(HISTORY_KEY_EPOCH_SECRET, "2")
.with_secret(HISTORY_KEY_PREVIOUS_SECRET, &previous)
.with_secret(HISTORY_KEY_PREVIOUS_EPOCH_SECRET, "1");
let ring = resolve_key_ring(&vault, "zeph-subagent transcript v1")
.await
.unwrap()
.expect("must resolve");
assert_eq!(ring.current_epoch(), 2);
let previous_key =
derive_history_chain_key_b64(&previous, "zeph-subagent transcript v1").unwrap();
let base = zeph_common::hash_chain::genesis(
&previous_key,
"zeph-subagent transcript v1",
b"file",
1,
);
let h0 = zeph_common::hash_chain::chain_next(&previous_key, &base, b"entry");
let entries = vec![(b"entry".to_vec(), h0)];
let (_head, resolution) = zeph_common::hash_chain::verify_chained_prefix(
&ring,
"zeph-subagent transcript v1",
b"file",
&entries,
)
.unwrap();
assert_eq!(
resolution,
zeph_common::hash_chain::KeyResolution::Rekeyed(1)
);
}
#[tokio::test]
async fn resolve_key_ring_fails_on_malformed_epoch() {
let key = generate_history_key_b64();
let vault = zeph_vault::MockVaultProvider::new()
.with_secret(HISTORY_KEY_SECRET, &key)
.with_secret(HISTORY_KEY_EPOCH_SECRET, "not-a-number");
let err = resolve_key_ring(&vault, "d").await.unwrap_err();
assert!(matches!(err, HistoryKeyError::MalformedEpoch));
}
}