1use zeph_common::hash_chain::{ChainKey, ChainKeyRing};
25use zeph_vault::VaultProvider;
26
27const KEY_LEN: usize = 32;
29
30pub const HISTORY_KEY_SECRET: &str = "ZEPH_HISTORY_KEY";
32pub const HISTORY_KEY_EPOCH_SECRET: &str = "ZEPH_HISTORY_KEY_EPOCH";
35pub const HISTORY_KEY_PREVIOUS_SECRET: &str = "ZEPH_HISTORY_KEY_PREVIOUS";
38pub const HISTORY_KEY_PREVIOUS_EPOCH_SECRET: &str = "ZEPH_HISTORY_KEY_PREVIOUS_EPOCH";
40
41const HISTORY_CHAIN_KEY_CONTEXT_PREFIX: &str = "zeph-history v1 chain key domain=";
46
47#[derive(Debug, thiserror::Error)]
49pub enum HistoryKeyError {
50 #[error("history chain key must be {KEY_LEN} bytes, got {actual}")]
52 InvalidKeyLength {
53 actual: usize,
55 },
56 #[error("history chain key is not valid base64")]
58 MalformedEncoding,
59 #[error("history key epoch is not a valid non-negative integer")]
61 MalformedEpoch,
62}
63
64pub fn derive_history_chain_key_b64(
76 b64_key: &str,
77 domain: &str,
78) -> Result<ChainKey, HistoryKeyError> {
79 use base64::Engine as _;
80 let bytes = base64::engine::general_purpose::STANDARD
81 .decode(b64_key.trim())
82 .map_err(|_| HistoryKeyError::MalformedEncoding)?;
83 if bytes.len() != KEY_LEN {
84 return Err(HistoryKeyError::InvalidKeyLength {
85 actual: bytes.len(),
86 });
87 }
88 let context = format!("{HISTORY_CHAIN_KEY_CONTEXT_PREFIX}{domain}");
89 Ok(ChainKey::new(blake3::derive_key(&context, &bytes)))
90}
91
92#[must_use]
104pub fn generate_history_key_b64() -> String {
105 use base64::Engine as _;
106 use rand::Rng as _;
107 let mut bytes = [0u8; KEY_LEN];
108 rand::rng().fill_bytes(&mut bytes);
109 base64::engine::general_purpose::STANDARD.encode(bytes)
110}
111
112pub async fn resolve_key_ring(
128 vault: &dyn VaultProvider,
129 domain: &str,
130) -> Result<Option<ChainKeyRing>, HistoryKeyError> {
131 let get = |key: &'static str| async move { vault.get_secret(key).await.ok().flatten() };
132 build_key_ring(
133 get(HISTORY_KEY_SECRET).await,
134 get(HISTORY_KEY_EPOCH_SECRET).await,
135 get(HISTORY_KEY_PREVIOUS_SECRET).await,
136 get(HISTORY_KEY_PREVIOUS_EPOCH_SECRET).await,
137 domain,
138 )
139}
140
141pub fn resolve_key_ring_sync(
151 provider: &zeph_vault::AgeVaultProvider,
152 domain: &str,
153) -> Result<Option<ChainKeyRing>, HistoryKeyError> {
154 build_key_ring(
155 provider.get(HISTORY_KEY_SECRET).map(str::to_owned),
156 provider.get(HISTORY_KEY_EPOCH_SECRET).map(str::to_owned),
157 provider.get(HISTORY_KEY_PREVIOUS_SECRET).map(str::to_owned),
158 provider
159 .get(HISTORY_KEY_PREVIOUS_EPOCH_SECRET)
160 .map(str::to_owned),
161 domain,
162 )
163}
164
165fn build_key_ring(
168 current_b64: Option<String>,
169 current_epoch: Option<String>,
170 previous_b64: Option<String>,
171 previous_epoch: Option<String>,
172 domain: &str,
173) -> Result<Option<ChainKeyRing>, HistoryKeyError> {
174 let Some(current_b64) = current_b64 else {
175 return Ok(None);
176 };
177 let current_epoch = match current_epoch {
178 Some(s) => s
179 .trim()
180 .parse::<u32>()
181 .map_err(|_| HistoryKeyError::MalformedEpoch)?,
182 None => 0,
183 };
184 let current_key = derive_history_chain_key_b64(¤t_b64, domain)?;
185 let mut ring = ChainKeyRing::new(current_epoch, current_key);
186
187 if let Some(previous_b64) = previous_b64 {
188 let previous_epoch = match previous_epoch {
189 Some(s) => s
190 .trim()
191 .parse::<u32>()
192 .map_err(|_| HistoryKeyError::MalformedEpoch)?,
193 None => current_epoch.saturating_sub(1),
194 };
195 let previous_key = derive_history_chain_key_b64(&previous_b64, domain)?;
196 ring = ring.with_previous(previous_epoch, previous_key);
197 }
198
199 Ok(Some(ring))
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205
206 #[test]
207 fn derive_history_chain_key_rejects_malformed_base64() {
208 assert!(matches!(
209 derive_history_chain_key_b64("not base64!", "d"),
210 Err(HistoryKeyError::MalformedEncoding)
211 ));
212 }
213
214 #[test]
215 fn derive_history_chain_key_rejects_wrong_length() {
216 use base64::Engine as _;
217 let short = base64::engine::general_purpose::STANDARD.encode(b"short");
218 assert!(matches!(
219 derive_history_chain_key_b64(&short, "d"),
220 Err(HistoryKeyError::InvalidKeyLength { .. })
221 ));
222 }
223
224 #[test]
225 fn derive_history_chain_key_is_domain_separated() {
226 let key = generate_history_key_b64();
227 let a = derive_history_chain_key_b64(&key, "domain-a").unwrap();
228 let b = derive_history_chain_key_b64(&key, "domain-b").unwrap();
229 let probe = zeph_common::hash_chain::genesis(&a, "x", b"y", 0);
233 let probe_b = zeph_common::hash_chain::genesis(&b, "x", b"y", 0);
234 assert_ne!(
235 probe, probe_b,
236 "chain keys for different domains must differ"
237 );
238 }
239
240 #[test]
241 fn derive_history_chain_key_independent_from_durable_key_derivation() {
242 let key = generate_history_key_b64();
246 let history_key = derive_history_chain_key_b64(&key, "zeph-session log v1").unwrap();
247 let durable_hmac_key = crate::durable::derive_control_hmac_key_b64(&key).unwrap();
248 let probe = zeph_common::hash_chain::genesis(&history_key, "x", b"y", 0);
249 let probe_durable = zeph_common::hash_chain::genesis(
250 &zeph_common::hash_chain::ChainKey::new(durable_hmac_key),
251 "x",
252 b"y",
253 0,
254 );
255 assert_ne!(probe, probe_durable);
256 }
257
258 #[tokio::test]
259 async fn resolve_key_ring_returns_none_when_unprovisioned() {
260 let vault = zeph_vault::MockVaultProvider::new();
261 let ring = resolve_key_ring(&vault, "zeph-session log v1")
262 .await
263 .unwrap();
264 assert!(ring.is_none());
265 }
266
267 #[tokio::test]
268 async fn resolve_key_ring_resolves_current_only() {
269 let key = generate_history_key_b64();
270 let vault = zeph_vault::MockVaultProvider::new().with_secret(HISTORY_KEY_SECRET, &key);
271 let ring = resolve_key_ring(&vault, "zeph-session log v1")
272 .await
273 .unwrap()
274 .expect("key ring must resolve once the root secret is provisioned");
275 assert_eq!(ring.current_epoch(), 0);
276 }
277
278 #[tokio::test]
279 async fn resolve_key_ring_resolves_rotation_window() {
280 let current = generate_history_key_b64();
281 let previous = generate_history_key_b64();
282 let vault = zeph_vault::MockVaultProvider::new()
283 .with_secret(HISTORY_KEY_SECRET, ¤t)
284 .with_secret(HISTORY_KEY_EPOCH_SECRET, "2")
285 .with_secret(HISTORY_KEY_PREVIOUS_SECRET, &previous)
286 .with_secret(HISTORY_KEY_PREVIOUS_EPOCH_SECRET, "1");
287
288 let ring = resolve_key_ring(&vault, "zeph-subagent transcript v1")
289 .await
290 .unwrap()
291 .expect("must resolve");
292 assert_eq!(ring.current_epoch(), 2);
293
294 let previous_key =
298 derive_history_chain_key_b64(&previous, "zeph-subagent transcript v1").unwrap();
299 let base = zeph_common::hash_chain::genesis(
300 &previous_key,
301 "zeph-subagent transcript v1",
302 b"file",
303 1,
304 );
305 let h0 = zeph_common::hash_chain::chain_next(&previous_key, &base, b"entry");
306 let entries = vec![(b"entry".to_vec(), h0)];
307 let (_head, resolution) = zeph_common::hash_chain::verify_chained_prefix(
308 &ring,
309 "zeph-subagent transcript v1",
310 b"file",
311 &entries,
312 )
313 .unwrap();
314 assert_eq!(
315 resolution,
316 zeph_common::hash_chain::KeyResolution::Rekeyed(1)
317 );
318 }
319
320 #[tokio::test]
321 async fn resolve_key_ring_fails_on_malformed_epoch() {
322 let key = generate_history_key_b64();
323 let vault = zeph_vault::MockVaultProvider::new()
324 .with_secret(HISTORY_KEY_SECRET, &key)
325 .with_secret(HISTORY_KEY_EPOCH_SECRET, "not-a-number");
326 let err = resolve_key_ring(&vault, "d").await.unwrap_err();
327 assert!(matches!(err, HistoryKeyError::MalformedEpoch));
328 }
329}