1use std::path::{Path, PathBuf};
28
29use chrono::{DateTime, Utc};
30use clawft_types::project::canon::{canonical_json, hex_decode, hex_encode};
31use clawft_types::project::cert::{key_id, ts};
32use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
33use serde::{Deserialize, Serialize};
34use serde_json::{Value, json};
35use sha2::{Digest, Sha256};
36
37pub const ROTATION_DOMAIN: &str = "weftos-user-key-rotation-v1\n";
39pub const ROTATION_VERSION: u32 = 1;
41pub const KIND_ROTATED: &str = "user.key.rotated";
43pub const ROTATION_FILE: &str = "user-key-rotations.jsonl";
45
46#[derive(Debug, thiserror::Error)]
48pub enum RotationError {
49 #[error("rotation record {seq}: {why}")]
51 Malformed {
52 seq: u64,
54 why: String,
56 },
57 #[error("rotation record {seq}: the {which} key's signature does not verify")]
59 BadSignature {
60 seq: u64,
62 which: &'static str,
64 },
65 #[error("rotation record {seq}: {why}")]
67 Broken {
68 seq: u64,
70 why: String,
72 },
73 #[error("the rotation history ends at key {history} but the user key in use is {current}; refusing to trust the history")]
75 NotCurrent {
76 history: String,
78 current: String,
80 },
81 #[error("rotation log {path}: {why}; refusing to verify until it is repaired")]
83 Log {
84 path: String,
86 why: String,
88 },
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(deny_unknown_fields)]
94pub struct RotationRecord {
95 pub v: u32,
97 pub seq: u64,
99 pub old_pubkey: String,
101 pub old_key_id: String,
103 pub new_pubkey: String,
105 pub new_key_id: String,
107 pub rotated_at: String,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub prev: Option<String>,
112 pub sig_old: String,
114 pub sig_new: String,
116}
117
118impl RotationRecord {
119 fn body(&self) -> Value {
120 json!({
121 "v": self.v,
122 "seq": self.seq,
123 "old_pubkey": self.old_pubkey,
124 "old_key_id": self.old_key_id,
125 "new_pubkey": self.new_pubkey,
126 "new_key_id": self.new_key_id,
127 "rotated_at": self.rotated_at,
128 "prev": self.prev,
129 })
130 }
131
132 fn signed_bytes(&self) -> Vec<u8> {
133 let mut out = ROTATION_DOMAIN.as_bytes().to_vec();
134 out.extend_from_slice(canonical_json(&self.body()).as_bytes());
135 out
136 }
137
138 pub fn sign(
140 old: &SigningKey,
141 new: &SigningKey,
142 prev: Option<&RotationRecord>,
143 rotated_at: DateTime<Utc>,
144 ) -> Self {
145 let (op, np) = (old.verifying_key().to_bytes(), new.verifying_key().to_bytes());
146 let mut r = Self {
147 v: ROTATION_VERSION,
148 seq: prev.map_or(1, |p| p.seq + 1),
149 old_pubkey: hex_encode(&op),
150 old_key_id: key_id(&op),
151 new_pubkey: hex_encode(&np),
152 new_key_id: key_id(&np),
153 rotated_at: ts(rotated_at),
154 prev: prev.map(Self::hash),
155 sig_old: String::new(),
156 sig_new: String::new(),
157 };
158 let bytes = r.signed_bytes();
159 r.sig_old = hex_encode(&old.sign(&bytes).to_bytes());
160 r.sig_new = hex_encode(&new.sign(&bytes).to_bytes());
161 r
162 }
163
164 pub fn hash(&self) -> String {
166 let mut v = self.body();
167 v["sig_old"] = json!(self.sig_old);
168 v["sig_new"] = json!(self.sig_new);
169 hex_encode(&Sha256::digest(canonical_json(&v).as_bytes()))
170 }
171
172 pub fn rotated(&self) -> Option<DateTime<Utc>> {
174 DateTime::parse_from_rfc3339(&self.rotated_at)
175 .ok()
176 .map(|t| t.with_timezone(&Utc))
177 .filter(|t| ts(*t) == self.rotated_at)
178 }
179
180 pub fn verify(&self) -> Result<(), RotationError> {
182 let bad = |why: &str| RotationError::Malformed { seq: self.seq, why: why.to_owned() };
183 if self.v != ROTATION_VERSION {
184 return Err(bad("unsupported version"));
185 }
186 let op: [u8; 32] = hex_decode(&self.old_pubkey).ok_or_else(|| bad("old_pubkey is not 64 lowercase hex"))?;
187 let np: [u8; 32] = hex_decode(&self.new_pubkey).ok_or_else(|| bad("new_pubkey is not 64 lowercase hex"))?;
188 if self.old_key_id != key_id(&op) || self.new_key_id != key_id(&np) {
189 return Err(bad("a key id does not match its public key"));
190 }
191 if op == np {
192 return Err(bad("old and new keys are the same"));
193 }
194 if self.rotated().is_none() {
195 return Err(bad("rotated_at is not canonical RFC 3339 (YYYY-MM-DDTHH:MM:SSZ)"));
196 }
197 let bytes = self.signed_bytes();
198 for (which, pk, sig) in [("old", op, &self.sig_old), ("new", np, &self.sig_new)] {
199 let sig: [u8; 64] = hex_decode(sig).ok_or_else(|| bad("a signature is not 128 lowercase hex"))?;
200 VerifyingKey::from_bytes(&pk)
201 .map_err(|_| bad("a public key is not a valid point"))?
202 .verify_strict(&bytes, &Signature::from_bytes(&sig))
203 .map_err(|_| RotationError::BadSignature { seq: self.seq, which })?;
204 }
205 Ok(())
206 }
207}
208
209#[derive(Debug, Clone)]
210struct Retired {
211 pubkey: [u8; 32],
212 rotated_at: DateTime<Utc>,
213}
214
215#[derive(Debug, Clone)]
217pub struct UserKeyHistory {
218 current: [u8; 32],
219 retired: Vec<Retired>,
220}
221
222impl UserKeyHistory {
223 pub fn single(current: &[u8; 32]) -> Self {
225 Self { current: *current, retired: Vec::new() }
226 }
227
228 pub fn from_records(current: &[u8; 32], records: &[RotationRecord]) -> Result<Self, RotationError> {
231 let mut retired: Vec<Retired> = Vec::new();
232 let mut prev: Option<&RotationRecord> = None;
233 for r in records {
234 r.verify()?;
235 let broken = |why: &str| RotationError::Broken { seq: r.seq, why: why.to_owned() };
236 match prev {
237 None if r.seq != 1 || r.prev.is_some() => return Err(broken("the first record must be seq 1 with no prev")),
238 Some(p) => {
239 if r.seq != p.seq + 1 {
240 return Err(broken("sequence is not previous + 1"));
241 }
242 if r.prev.as_deref() != Some(p.hash().as_str()) {
243 return Err(broken("prev does not match the previous record"));
244 }
245 if r.old_pubkey != p.new_pubkey {
246 return Err(broken("old key is not the previous record's new key"));
247 }
248 if r.rotated() < p.rotated() {
249 return Err(broken("rotated_at goes backwards"));
250 }
251 }
252 None => {}
253 }
254 let op = hex_decode::<32>(&r.old_pubkey).ok_or_else(|| broken("old_pubkey"))?;
255 let when = r.rotated().ok_or_else(|| broken("rotated_at"))?;
256 retired.push(Retired { pubkey: op, rotated_at: when });
257 prev = Some(r);
258 }
259 let end = match prev {
260 Some(l) => hex_decode::<32>(&l.new_pubkey).unwrap_or([0; 32]),
261 None => *current,
262 };
263 if &end != current {
264 return Err(RotationError::NotCurrent { history: key_id(&end), current: key_id(current) });
265 }
266 Ok(Self { current: *current, retired })
267 }
268
269 pub fn current(&self) -> &[u8; 32] {
271 &self.current
272 }
273
274 pub fn rotations(&self) -> usize {
276 self.retired.len()
277 }
278
279 pub fn key_for(&self, user_key_id: &str) -> Option<[u8; 32]> {
281 if key_id(&self.current) == user_key_id {
282 return Some(self.current);
283 }
284 self.retired.iter().find(|r| key_id(&r.pubkey) == user_key_id).map(|r| r.pubkey)
285 }
286
287 pub fn accepts(&self, pubkey: &[u8; 32], at: DateTime<Utc>) -> bool {
290 if pubkey == &self.current {
291 return true;
292 }
293 self.retired.iter().any(|r| &r.pubkey == pubkey && at <= r.rotated_at)
294 }
295
296 pub fn retired_keys(&self) -> impl Iterator<Item = [u8; 32]> + '_ {
298 self.retired.iter().map(|r| r.pubkey)
299 }
300
301 pub fn rotated_at(&self, pubkey: &[u8; 32]) -> Option<DateTime<Utc>> {
303 self.retired.iter().find(|r| &r.pubkey == pubkey).map(|r| r.rotated_at)
304 }
305}
306
307#[derive(Debug, Clone)]
309pub struct RotationLog {
310 path: PathBuf,
311}
312
313impl RotationLog {
314 pub fn new(dir: &Path) -> Self {
316 Self { path: dir.join(ROTATION_FILE) }
317 }
318
319 pub fn path(&self) -> &Path {
321 &self.path
322 }
323
324 pub fn read(&self) -> Result<Vec<RotationRecord>, RotationError> {
327 let log = |why: String| RotationError::Log { path: self.path.display().to_string(), why };
328 let text = match std::fs::read_to_string(&self.path) {
329 Ok(t) => t,
330 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
331 Err(e) => return Err(log(e.to_string())),
332 };
333 text.lines()
334 .filter(|l| !l.trim().is_empty())
335 .enumerate()
336 .map(|(i, l)| serde_json::from_str(l).map_err(|e| log(format!("line {}: {e}", i + 1))))
337 .collect()
338 }
339
340 pub fn history(&self, current: &[u8; 32]) -> Result<UserKeyHistory, RotationError> {
342 UserKeyHistory::from_records(current, &self.read()?)
343 }
344
345 pub fn replace(&self, records: &[RotationRecord]) -> Result<(), RotationError> {
348 let end = records.last().and_then(|r| hex_decode::<32>(&r.new_pubkey)).ok_or_else(|| RotationError::Log {
349 path: self.path.display().to_string(),
350 why: "nothing to write".into(),
351 })?;
352 UserKeyHistory::from_records(&end, records)?;
353 let mut body = String::new();
354 for r in records {
355 body.push_str(&serde_json::to_string(r).unwrap_or_default());
356 body.push('\n');
357 }
358 crate::project_identity::write_private_atomic(&self.path, body.as_bytes(), false)
359 .map_err(|e| RotationError::Log { path: self.path.display().to_string(), why: e.to_string() })
360 }
361
362 pub fn append(&self, record: &RotationRecord) -> Result<(), RotationError> {
365 let mut all = self.read()?;
366 all.push(record.clone());
367 let end = hex_decode::<32>(&record.new_pubkey).unwrap_or([0; 32]);
368 UserKeyHistory::from_records(&end, &all)?;
369 let mut body = String::new();
370 for r in &all {
371 body.push_str(&serde_json::to_string(r).unwrap_or_default());
372 body.push('\n');
373 }
374 crate::project_identity::write_private_atomic(&self.path, body.as_bytes(), false)
375 .map_err(|e| RotationError::Log { path: self.path.display().to_string(), why: e.to_string() })
376 }
377}
378
379#[cfg(test)]
380#[path = "user_key_rotation_tests.rs"]
381mod tests;