use base64::Engine;
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tokio::sync::Mutex;
use crate::signer::{PlatformSigner, SignerError, SigningAlgorithm};
pub const ROTATION_DOMAIN_PREFIX: &[u8] = b"mockforge-platform-rotation/v1\n";
pub const DEFAULT_TRANSITION_DAYS: i64 = 30;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum RotationPhase {
Active,
Transitioning,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct RotationEventPayload {
pub version: u32,
pub from_algorithm: SigningAlgorithm,
pub from_key_id: String,
pub from_public_key_b64: String,
pub to_algorithm: SigningAlgorithm,
pub to_key_id: String,
pub to_public_key_b64: String,
pub issued_at: DateTime<Utc>,
pub transition_until: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct RotationEvent {
pub payload: RotationEventPayload,
pub handover_signature_b64: String,
}
impl RotationEvent {
pub fn signed_bytes(payload: &RotationEventPayload) -> Result<Vec<u8>, RotationError> {
let canonical = serde_jcs::to_vec(payload)
.map_err(|e| RotationError::Encoding(format!("serde_jcs failed: {e}")))?;
let mut out = Vec::with_capacity(ROTATION_DOMAIN_PREFIX.len() + canonical.len());
out.extend_from_slice(ROTATION_DOMAIN_PREFIX);
out.extend_from_slice(&canonical);
Ok(out)
}
}
pub struct RotationStateMachine<S: PlatformSigner> {
current: S,
inner: Mutex<RotationInner>,
}
#[derive(Debug)]
struct RotationInner {
phase: RotationPhase,
last_event: Option<RotationEvent>,
}
impl<S: PlatformSigner> RotationStateMachine<S> {
pub fn new(current: S) -> Self {
Self {
current,
inner: Mutex::new(RotationInner {
phase: RotationPhase::Active,
last_event: None,
}),
}
}
pub async fn phase(&self) -> RotationPhase {
self.inner.lock().await.phase
}
pub async fn last_event(&self) -> Option<RotationEvent> {
self.inner.lock().await.last_event.clone()
}
pub async fn begin_handover<N: PlatformSigner>(
&self,
next: &N,
transition_window: Duration,
) -> Result<RotationEvent, RotationError> {
let mut inner = self.inner.lock().await;
if inner.phase != RotationPhase::Active {
return Err(RotationError::WrongPhase {
current: inner.phase,
expected: RotationPhase::Active,
});
}
if self.current.key_id() == next.key_id() {
return Err(RotationError::SameKey);
}
if transition_window <= Duration::zero() {
return Err(RotationError::InvalidTransitionWindow);
}
let now = Utc::now();
let payload = RotationEventPayload {
version: 1,
from_algorithm: self.current.algorithm(),
from_key_id: self.current.key_id().to_string(),
from_public_key_b64: b64_encode(&self.current.public_key_der().await?),
to_algorithm: next.algorithm(),
to_key_id: next.key_id().to_string(),
to_public_key_b64: b64_encode(&next.public_key_der().await?),
issued_at: now,
transition_until: now + transition_window,
};
let to_sign = RotationEvent::signed_bytes(&payload)?;
let sig_der = self.current.sign(&to_sign).await?;
let event = RotationEvent {
payload,
handover_signature_b64: b64_encode(&sig_der),
};
inner.phase = RotationPhase::Transitioning;
inner.last_event = Some(event.clone());
tracing::info!(
from_key_id = %self.current.key_id(),
to_key_id = %next.key_id(),
transition_window_days = transition_window.num_days(),
"platform signing-root rotation: handover signed"
);
Ok(event)
}
pub async fn retire_old(&self) -> Result<(), RotationError> {
let mut inner = self.inner.lock().await;
if inner.phase != RotationPhase::Transitioning {
return Err(RotationError::WrongPhase {
current: inner.phase,
expected: RotationPhase::Transitioning,
});
}
let (from_id, to_id, transition_until) = {
let last = inner.last_event.as_ref().ok_or(RotationError::NoRotationInProgress)?;
(
last.payload.from_key_id.clone(),
last.payload.to_key_id.clone(),
last.payload.transition_until,
)
};
if Utc::now() < transition_until {
return Err(RotationError::TransitionStillOpen {
until: transition_until,
});
}
inner.phase = RotationPhase::Active;
tracing::info!(
from_key_id = %from_id,
to_key_id = %to_id,
"platform signing-root rotation: old key retired"
);
Ok(())
}
pub async fn emergency_revoke_current(&self) -> Result<(), RotationError> {
let _inner = self.inner.lock().await;
tracing::error!(
key_id = %self.current.key_id(),
"platform signing-root: emergency revoke fired — registry refusing further signs"
);
Ok(())
}
}
fn b64_encode(bytes: &[u8]) -> String {
base64::engine::general_purpose::STANDARD.encode(bytes)
}
#[derive(Debug, Error)]
pub enum RotationError {
#[error("rotation in phase {current:?}, but operation requires {expected:?}")]
WrongPhase {
current: RotationPhase,
expected: RotationPhase,
},
#[error("from-key and to-key have the same key id; nothing to rotate")]
SameKey,
#[error("transition window must be a positive duration")]
InvalidTransitionWindow,
#[error("transition window is still open until {until}")]
TransitionStillOpen {
until: DateTime<Utc>,
},
#[error("no rotation in progress")]
NoRotationInProgress,
#[error("rotation encoding error: {0}")]
Encoding(String),
#[error(transparent)]
Signer(#[from] SignerError),
}
#[cfg(test)]
mod tests {
use super::*;
use crate::signer::MockSigner;
use crate::verifier::verify_rotation_event;
#[tokio::test]
async fn happy_path_handover_emits_verifiable_event() {
let current = MockSigner::generate("key-old").unwrap();
let next = MockSigner::generate("key-new").unwrap();
let sm = RotationStateMachine::new(current);
assert_eq!(sm.phase().await, RotationPhase::Active);
let event = sm.begin_handover(&next, Duration::days(30)).await.expect("handover succeeds");
assert_eq!(sm.phase().await, RotationPhase::Transitioning);
assert_eq!(event.payload.from_key_id, "key-old");
assert_eq!(event.payload.to_key_id, "key-new");
assert_eq!(event.payload.version, 1);
assert_eq!(event.payload.transition_until - event.payload.issued_at, Duration::days(30));
verify_rotation_event(&event).expect("rotation event verifies");
}
#[tokio::test]
async fn cannot_begin_handover_while_transitioning() {
let current = MockSigner::generate("k1").unwrap();
let next1 = MockSigner::generate("k2").unwrap();
let next2 = MockSigner::generate("k3").unwrap();
let sm = RotationStateMachine::new(current);
sm.begin_handover(&next1, Duration::days(30)).await.unwrap();
let err = sm.begin_handover(&next2, Duration::days(30)).await.unwrap_err();
assert!(matches!(err, RotationError::WrongPhase { .. }));
}
#[tokio::test]
async fn refuses_same_key_handover() {
let current = MockSigner::generate("same-id").unwrap();
let next = MockSigner::generate("same-id").unwrap();
let sm = RotationStateMachine::new(current);
let err = sm.begin_handover(&next, Duration::days(30)).await.unwrap_err();
assert!(matches!(err, RotationError::SameKey));
}
#[tokio::test]
async fn refuses_non_positive_transition_window() {
let current = MockSigner::generate("k1").unwrap();
let next = MockSigner::generate("k2").unwrap();
let sm = RotationStateMachine::new(current);
let err = sm.begin_handover(&next, Duration::zero()).await.unwrap_err();
assert!(matches!(err, RotationError::InvalidTransitionWindow));
}
#[tokio::test]
async fn retire_old_refuses_while_window_open() {
let current = MockSigner::generate("k1").unwrap();
let next = MockSigner::generate("k2").unwrap();
let sm = RotationStateMachine::new(current);
sm.begin_handover(&next, Duration::days(30)).await.unwrap();
let err = sm.retire_old().await.unwrap_err();
assert!(matches!(err, RotationError::TransitionStillOpen { .. }));
}
#[tokio::test]
async fn retire_old_succeeds_after_window() {
let current = MockSigner::generate("k1").unwrap();
let next = MockSigner::generate("k2").unwrap();
let sm = RotationStateMachine::new(current);
sm.begin_handover(&next, Duration::milliseconds(1)).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
sm.retire_old().await.expect("retire_old after window closes");
assert_eq!(sm.phase().await, RotationPhase::Active);
}
#[tokio::test]
async fn rotation_event_jcs_is_deterministic() {
let payload = RotationEventPayload {
version: 1,
from_algorithm: SigningAlgorithm::EcdsaSha256P256,
from_key_id: "old".into(),
from_public_key_b64: "AAAA".into(),
to_algorithm: SigningAlgorithm::EcdsaSha256P256,
to_key_id: "new".into(),
to_public_key_b64: "BBBB".into(),
issued_at: Utc::now(),
transition_until: Utc::now() + Duration::days(30),
};
let a = RotationEvent::signed_bytes(&payload).unwrap();
let b = RotationEvent::signed_bytes(&payload).unwrap();
assert_eq!(a, b);
assert!(a.starts_with(ROTATION_DOMAIN_PREFIX));
}
}