use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use clawft_types::project::canon::{canonical_json, hex_decode, hex_encode};
use clawft_types::project::cert::{key_id, ts};
use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
pub const ROTATION_DOMAIN: &str = "weftos-user-key-rotation-v1\n";
pub const ROTATION_VERSION: u32 = 1;
pub const KIND_ROTATED: &str = "user.key.rotated";
pub const ROTATION_FILE: &str = "user-key-rotations.jsonl";
#[derive(Debug, thiserror::Error)]
pub enum RotationError {
#[error("rotation record {seq}: {why}")]
Malformed {
seq: u64,
why: String,
},
#[error("rotation record {seq}: the {which} key's signature does not verify")]
BadSignature {
seq: u64,
which: &'static str,
},
#[error("rotation record {seq}: {why}")]
Broken {
seq: u64,
why: String,
},
#[error("the rotation history ends at key {history} but the user key in use is {current}; refusing to trust the history")]
NotCurrent {
history: String,
current: String,
},
#[error("rotation log {path}: {why}; refusing to verify until it is repaired")]
Log {
path: String,
why: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RotationRecord {
pub v: u32,
pub seq: u64,
pub old_pubkey: String,
pub old_key_id: String,
pub new_pubkey: String,
pub new_key_id: String,
pub rotated_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prev: Option<String>,
pub sig_old: String,
pub sig_new: String,
}
impl RotationRecord {
fn body(&self) -> Value {
json!({
"v": self.v,
"seq": self.seq,
"old_pubkey": self.old_pubkey,
"old_key_id": self.old_key_id,
"new_pubkey": self.new_pubkey,
"new_key_id": self.new_key_id,
"rotated_at": self.rotated_at,
"prev": self.prev,
})
}
fn signed_bytes(&self) -> Vec<u8> {
let mut out = ROTATION_DOMAIN.as_bytes().to_vec();
out.extend_from_slice(canonical_json(&self.body()).as_bytes());
out
}
pub fn sign(
old: &SigningKey,
new: &SigningKey,
prev: Option<&RotationRecord>,
rotated_at: DateTime<Utc>,
) -> Self {
let (op, np) = (old.verifying_key().to_bytes(), new.verifying_key().to_bytes());
let mut r = Self {
v: ROTATION_VERSION,
seq: prev.map_or(1, |p| p.seq + 1),
old_pubkey: hex_encode(&op),
old_key_id: key_id(&op),
new_pubkey: hex_encode(&np),
new_key_id: key_id(&np),
rotated_at: ts(rotated_at),
prev: prev.map(Self::hash),
sig_old: String::new(),
sig_new: String::new(),
};
let bytes = r.signed_bytes();
r.sig_old = hex_encode(&old.sign(&bytes).to_bytes());
r.sig_new = hex_encode(&new.sign(&bytes).to_bytes());
r
}
pub fn hash(&self) -> String {
let mut v = self.body();
v["sig_old"] = json!(self.sig_old);
v["sig_new"] = json!(self.sig_new);
hex_encode(&Sha256::digest(canonical_json(&v).as_bytes()))
}
pub fn rotated(&self) -> Option<DateTime<Utc>> {
DateTime::parse_from_rfc3339(&self.rotated_at)
.ok()
.map(|t| t.with_timezone(&Utc))
.filter(|t| ts(*t) == self.rotated_at)
}
pub fn verify(&self) -> Result<(), RotationError> {
let bad = |why: &str| RotationError::Malformed { seq: self.seq, why: why.to_owned() };
if self.v != ROTATION_VERSION {
return Err(bad("unsupported version"));
}
let op: [u8; 32] = hex_decode(&self.old_pubkey).ok_or_else(|| bad("old_pubkey is not 64 lowercase hex"))?;
let np: [u8; 32] = hex_decode(&self.new_pubkey).ok_or_else(|| bad("new_pubkey is not 64 lowercase hex"))?;
if self.old_key_id != key_id(&op) || self.new_key_id != key_id(&np) {
return Err(bad("a key id does not match its public key"));
}
if op == np {
return Err(bad("old and new keys are the same"));
}
if self.rotated().is_none() {
return Err(bad("rotated_at is not canonical RFC 3339 (YYYY-MM-DDTHH:MM:SSZ)"));
}
let bytes = self.signed_bytes();
for (which, pk, sig) in [("old", op, &self.sig_old), ("new", np, &self.sig_new)] {
let sig: [u8; 64] = hex_decode(sig).ok_or_else(|| bad("a signature is not 128 lowercase hex"))?;
VerifyingKey::from_bytes(&pk)
.map_err(|_| bad("a public key is not a valid point"))?
.verify_strict(&bytes, &Signature::from_bytes(&sig))
.map_err(|_| RotationError::BadSignature { seq: self.seq, which })?;
}
Ok(())
}
}
#[derive(Debug, Clone)]
struct Retired {
pubkey: [u8; 32],
rotated_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct UserKeyHistory {
current: [u8; 32],
retired: Vec<Retired>,
}
impl UserKeyHistory {
pub fn single(current: &[u8; 32]) -> Self {
Self { current: *current, retired: Vec::new() }
}
pub fn from_records(current: &[u8; 32], records: &[RotationRecord]) -> Result<Self, RotationError> {
let mut retired: Vec<Retired> = Vec::new();
let mut prev: Option<&RotationRecord> = None;
for r in records {
r.verify()?;
let broken = |why: &str| RotationError::Broken { seq: r.seq, why: why.to_owned() };
match prev {
None if r.seq != 1 || r.prev.is_some() => return Err(broken("the first record must be seq 1 with no prev")),
Some(p) => {
if r.seq != p.seq + 1 {
return Err(broken("sequence is not previous + 1"));
}
if r.prev.as_deref() != Some(p.hash().as_str()) {
return Err(broken("prev does not match the previous record"));
}
if r.old_pubkey != p.new_pubkey {
return Err(broken("old key is not the previous record's new key"));
}
if r.rotated() < p.rotated() {
return Err(broken("rotated_at goes backwards"));
}
}
None => {}
}
let op = hex_decode::<32>(&r.old_pubkey).ok_or_else(|| broken("old_pubkey"))?;
let when = r.rotated().ok_or_else(|| broken("rotated_at"))?;
retired.push(Retired { pubkey: op, rotated_at: when });
prev = Some(r);
}
let end = match prev {
Some(l) => hex_decode::<32>(&l.new_pubkey).unwrap_or([0; 32]),
None => *current,
};
if &end != current {
return Err(RotationError::NotCurrent { history: key_id(&end), current: key_id(current) });
}
Ok(Self { current: *current, retired })
}
pub fn current(&self) -> &[u8; 32] {
&self.current
}
pub fn rotations(&self) -> usize {
self.retired.len()
}
pub fn key_for(&self, user_key_id: &str) -> Option<[u8; 32]> {
if key_id(&self.current) == user_key_id {
return Some(self.current);
}
self.retired.iter().find(|r| key_id(&r.pubkey) == user_key_id).map(|r| r.pubkey)
}
pub fn accepts(&self, pubkey: &[u8; 32], at: DateTime<Utc>) -> bool {
if pubkey == &self.current {
return true;
}
self.retired.iter().any(|r| &r.pubkey == pubkey && at <= r.rotated_at)
}
pub fn retired_keys(&self) -> impl Iterator<Item = [u8; 32]> + '_ {
self.retired.iter().map(|r| r.pubkey)
}
pub fn rotated_at(&self, pubkey: &[u8; 32]) -> Option<DateTime<Utc>> {
self.retired.iter().find(|r| &r.pubkey == pubkey).map(|r| r.rotated_at)
}
}
#[derive(Debug, Clone)]
pub struct RotationLog {
path: PathBuf,
}
impl RotationLog {
pub fn new(dir: &Path) -> Self {
Self { path: dir.join(ROTATION_FILE) }
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn read(&self) -> Result<Vec<RotationRecord>, RotationError> {
let log = |why: String| RotationError::Log { path: self.path.display().to_string(), why };
let text = match std::fs::read_to_string(&self.path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(log(e.to_string())),
};
text.lines()
.filter(|l| !l.trim().is_empty())
.enumerate()
.map(|(i, l)| serde_json::from_str(l).map_err(|e| log(format!("line {}: {e}", i + 1))))
.collect()
}
pub fn history(&self, current: &[u8; 32]) -> Result<UserKeyHistory, RotationError> {
UserKeyHistory::from_records(current, &self.read()?)
}
pub fn replace(&self, records: &[RotationRecord]) -> Result<(), RotationError> {
let end = records.last().and_then(|r| hex_decode::<32>(&r.new_pubkey)).ok_or_else(|| RotationError::Log {
path: self.path.display().to_string(),
why: "nothing to write".into(),
})?;
UserKeyHistory::from_records(&end, records)?;
let mut body = String::new();
for r in records {
body.push_str(&serde_json::to_string(r).unwrap_or_default());
body.push('\n');
}
crate::project_identity::write_private_atomic(&self.path, body.as_bytes(), false)
.map_err(|e| RotationError::Log { path: self.path.display().to_string(), why: e.to_string() })
}
pub fn append(&self, record: &RotationRecord) -> Result<(), RotationError> {
let mut all = self.read()?;
all.push(record.clone());
let end = hex_decode::<32>(&record.new_pubkey).unwrap_or([0; 32]);
UserKeyHistory::from_records(&end, &all)?;
let mut body = String::new();
for r in &all {
body.push_str(&serde_json::to_string(r).unwrap_or_default());
body.push('\n');
}
crate::project_identity::write_private_atomic(&self.path, body.as_bytes(), false)
.map_err(|e| RotationError::Log { path: self.path.display().to_string(), why: e.to_string() })
}
}
#[cfg(test)]
#[path = "user_key_rotation_tests.rs"]
mod tests;