use std::time::{SystemTime, UNIX_EPOCH};
use affinidi_status_list::StatusPurpose;
use axum::Json;
use axum::extract::State;
use axum::http::StatusCode;
use chrono::{DateTime, Utc};
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use tracing::{info, warn};
use uuid::Uuid;
use vti_common::audit::{AuditEvent, DidRotatedData};
use vti_common::auth::session::{delete_session, list_sessions};
use vti_common::error::AppError;
use crate::acl::get_acl_entry;
use crate::auth::AuthClaims;
use crate::credentials::{
CredentialStatusRef, RoleVecParams, VmcParams, build_role_vec, build_vmc,
};
use crate::members::{get_member, store_member};
use crate::server::AppState;
use crate::status_list;
pub const ROTATION_DOMAIN_TAG: &[u8] = b"vtc-did-rotation/v1\0";
const CHALLENGE_TTL_SECS: i64 = 10 * 60;
const ROTATION_PREFIX: &[u8] = b"rotation_chal:";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RotationChallenge {
id: Uuid,
did: String,
expires_at: DateTime<Utc>,
}
fn challenge_key(id: Uuid) -> Vec<u8> {
let mut k = ROTATION_PREFIX.to_vec();
k.extend_from_slice(id.to_string().as_bytes());
k
}
async fn store_challenge(state: &AppState, challenge: &RotationChallenge) -> Result<(), AppError> {
let key = String::from_utf8(challenge_key(challenge.id))
.map_err(|e| AppError::Internal(format!("rotation key encoding broke: {e}")))?;
state.passkey_ks.insert(key, challenge).await
}
async fn take_challenge(state: &AppState, id: Uuid) -> Result<Option<RotationChallenge>, AppError> {
let key = challenge_key(id);
let raw = state.passkey_ks.get_raw(key.clone()).await?;
let Some(bytes) = raw else { return Ok(None) };
let challenge: RotationChallenge = serde_json::from_slice(&bytes)
.map_err(|e| AppError::Internal(format!("RotationChallenge decode: {e}")))?;
state.passkey_ks.remove(key).await?;
Ok(Some(challenge))
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ChallengeResponse {
pub rotation_id: Uuid,
pub expires_at: DateTime<Utc>,
pub signing_payload_hex: String,
pub canonical_template: JsonValue,
}
pub async fn challenge(
auth: AuthClaims,
State(state): State<AppState>,
) -> Result<(StatusCode, Json<ChallengeResponse>), AppError> {
let _acl = get_acl_entry(&state.acl_ks, &auth.did)
.await?
.ok_or_else(|| AppError::NotFound(format!("no ACL row for {} — not a member", auth.did)))?;
let id = Uuid::new_v4();
let now = Utc::now();
let expires_at = now + chrono::Duration::seconds(CHALLENGE_TTL_SECS);
let challenge = RotationChallenge {
id,
did: auth.did.clone(),
expires_at,
};
store_challenge(&state, &challenge).await?;
let template = serde_json::json!({
"rotationId": id.to_string(),
"oldDid": auth.did,
"newDid": "<fill in>",
"expiresAt": expires_at.timestamp(),
});
info!(
rotation_id = %id,
did = %auth.did,
"DID rotation challenge issued"
);
Ok((
StatusCode::OK,
Json(ChallengeResponse {
rotation_id: id,
expires_at,
signing_payload_hex: hex::encode(ROTATION_DOMAIN_TAG),
canonical_template: template,
}),
))
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FinishBody {
pub rotation_id: Uuid,
pub old_did: String,
pub new_did: String,
pub old_signature: String,
pub new_signature: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FinishResponse {
pub new_did: String,
pub method: String,
pub vmc: JsonValue,
pub role_vec: JsonValue,
}
pub async fn rotate(
auth: AuthClaims,
State(state): State<AppState>,
Json(body): Json<FinishBody>,
) -> Result<(StatusCode, Json<FinishResponse>), AppError> {
let audit_writer = state
.audit_writer
.as_ref()
.ok_or_else(|| AppError::Internal("audit_writer not initialised".into()))?;
if auth.did != body.old_did {
return Err(AppError::Forbidden(format!(
"session DID ({}) does not match oldDid ({})",
auth.did, body.old_did
)));
}
let method = method_of(&body.new_did)?;
let challenge = take_challenge(&state, body.rotation_id)
.await?
.ok_or_else(|| {
AppError::Validation(format!(
"rotation challenge {} not found or already consumed",
body.rotation_id
))
})?;
if challenge.did != body.old_did {
return Err(AppError::Forbidden(format!(
"rotation challenge was issued for {}, not {}",
challenge.did, body.old_did
)));
}
if Utc::now() > challenge.expires_at {
return Err(AppError::Validation(format!(
"rotation challenge {} expired at {}",
body.rotation_id, challenge.expires_at
)));
}
if body.old_did == body.new_did {
return Err(AppError::Validation(
"oldDid and newDid must differ — same-DID rotation is a no-op".into(),
));
}
let payload = canonical_signing_bytes(
body.rotation_id,
&body.old_did,
&body.new_did,
challenge.expires_at.timestamp(),
)?;
verify_did_key_signature(&body.old_did, &payload, &body.old_signature)
.map_err(|e| AppError::Validation(format!("oldSignature failed: {e}")))?;
match method {
"did:key" => verify_did_key_signature(&body.new_did, &payload, &body.new_signature)
.map_err(|e| AppError::Validation(format!("newSignature failed: {e}")))?,
"did:webvh" => {
let resolver = state.did_resolver.as_ref().ok_or_else(|| {
AppError::Internal(
"DID resolver not configured — did:webvh rotation requires it".into(),
)
})?;
verify_did_webvh_signature(&body.new_did, &payload, &body.new_signature, resolver)
.await
.map_err(|e| AppError::Validation(format!("newSignature failed: {e}")))?;
}
other => {
return Err(AppError::Validation(format!(
"DID method '{other}' is not supported for rotation"
)));
}
}
if get_acl_entry(&state.acl_ks, &body.new_did).await?.is_some() {
return Err(AppError::Conflict(format!(
"newDid {} already has an ACL row — refusing to clobber",
body.new_did
)));
}
let mut acl = get_acl_entry(&state.acl_ks, &body.old_did)
.await?
.ok_or_else(|| {
AppError::Internal(format!(
"ACL row for {} disappeared mid-rotation",
body.old_did
))
})?;
acl.did = body.new_did.clone();
let acl_moved = state
.acl_ks
.swap(
format!("acl:{}", body.old_did).into_bytes(),
format!("acl:{}", body.new_did).into_bytes(),
&acl,
)
.await?;
if !acl_moved {
return Err(AppError::Conflict(format!(
"ACL row for newDid {} was created mid-rotation",
body.new_did
)));
}
if let Some(mut m) = get_member(&state.members_ks, &body.old_did).await? {
m.did = body.new_did.clone();
let member_moved = state
.members_ks
.swap(
format!("members:{}", body.old_did).into_bytes(),
format!("members:{}", body.new_did).into_bytes(),
&m,
)
.await?;
if !member_moved {
return Err(AppError::Conflict(format!(
"member row for newDid {} was created mid-rotation",
body.new_did
)));
}
}
let sessions = list_sessions(&state.sessions_ks).await?;
for s in sessions.iter().filter(|s| s.did == body.old_did) {
let _ = delete_session(&state.sessions_ks, &s.session_id).await;
}
let (vmc_value, vec_value, vmc_id, vec_id) =
match reissue_credentials(&state, &body.new_did, &acl).await {
Ok(out) => out,
Err(e) => {
warn!(error = %e, "rotation succeeded but credential re-issuance failed");
(JsonValue::Null, JsonValue::Null, None, None)
}
};
audit_writer
.write(
&body.new_did,
Some(&body.old_did),
AuditEvent::DidRotated(DidRotatedData {
old_did: body.old_did.clone(),
new_did: body.new_did.clone(),
method: method.to_string(),
vmc_id,
role_vec_id: vec_id,
}),
)
.await?;
info!(
old_did = %body.old_did,
new_did = %body.new_did,
method,
"DID rotated"
);
Ok((
StatusCode::OK,
Json(FinishResponse {
new_did: body.new_did,
method: method.to_string(),
vmc: vmc_value,
role_vec: vec_value,
}),
))
}
fn method_of(did: &str) -> Result<&'static str, AppError> {
if did.starts_with("did:key:") {
Ok("did:key")
} else if did.starts_with("did:webvh:") {
Ok("did:webvh")
} else {
Err(AppError::Validation(format!(
"DID '{did}' is not did:key or did:webvh"
)))
}
}
fn canonical_signing_bytes(
rotation_id: Uuid,
old_did: &str,
new_did: &str,
expires_at: i64,
) -> Result<Vec<u8>, AppError> {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Payload<'a> {
rotation_id: String,
old_did: &'a str,
new_did: &'a str,
expires_at: i64,
}
let json = serde_json::to_vec(&Payload {
rotation_id: rotation_id.to_string(),
old_did,
new_did,
expires_at,
})
.map_err(|e| AppError::Internal(format!("canonical payload: {e}")))?;
let mut buf = Vec::with_capacity(ROTATION_DOMAIN_TAG.len() + json.len());
buf.extend_from_slice(ROTATION_DOMAIN_TAG);
buf.extend_from_slice(&json);
Ok(buf)
}
fn verify_did_key_signature(did: &str, payload: &[u8], hex_sig: &str) -> Result<(), String> {
let pub_bytes = affinidi_crypto::did_key::did_key_to_ed25519_pub(did)
.map_err(|e| format!("did:key parse: {e}"))?;
let vk =
VerifyingKey::from_bytes(&pub_bytes).map_err(|e| format!("invalid Ed25519 pubkey: {e}"))?;
let raw = hex::decode(hex_sig).map_err(|e| format!("signature is not hex: {e}"))?;
let sig = Signature::from_slice(&raw).map_err(|e| format!("signature is not 64 bytes: {e}"))?;
vk.verify(payload, &sig)
.map_err(|e| format!("signature verification: {e}"))
}
async fn verify_did_webvh_signature(
did: &str,
payload: &[u8],
hex_sig: &str,
resolver: &affinidi_did_resolver_cache_sdk::DIDCacheClient,
) -> Result<(), String> {
let resolved = resolver
.resolve(did)
.await
.map_err(|e| format!("did:webvh resolve: {e}"))?;
let target_vm_id = format!("{did}#key-0");
let vm = resolved
.doc
.verification_method
.iter()
.find(|m| m.id.as_str() == target_vm_id)
.ok_or_else(|| format!("verification method {target_vm_id} not present on {did}"))?;
let pub_bytes = vm
.get_public_key_bytes()
.map_err(|e| format!("extract pubkey: {e}"))?;
if pub_bytes.len() != 32 {
return Err(format!(
"{target_vm_id} pubkey is {} bytes, expected 32 (Ed25519)",
pub_bytes.len()
));
}
let mut buf = [0u8; 32];
buf.copy_from_slice(&pub_bytes);
let vk = VerifyingKey::from_bytes(&buf).map_err(|e| format!("invalid Ed25519 pubkey: {e}"))?;
let raw = hex::decode(hex_sig).map_err(|e| format!("signature is not hex: {e}"))?;
let sig = Signature::from_slice(&raw).map_err(|e| format!("signature is not 64 bytes: {e}"))?;
vk.verify(payload, &sig)
.map_err(|e| format!("signature verification: {e}"))
}
async fn reissue_credentials(
state: &AppState,
new_did: &str,
acl: &crate::acl::VtcAclEntry,
) -> Result<(JsonValue, JsonValue, Option<String>, Option<String>), AppError> {
let signer = state
.credential_signer
.as_ref()
.ok_or_else(|| AppError::Internal("credential signer not initialised".into()))?;
let member = get_member(&state.members_ks, new_did)
.await?
.ok_or_else(|| {
AppError::Internal(format!(
"Member row for {new_did} missing after rotation move"
))
})?;
let row = status_list::get_state(&state.status_lists_ks, StatusPurpose::Revocation)
.await?
.ok_or_else(|| AppError::Internal("revocation status list not provisioned".into()))?;
let slot = member.status_list_index.ok_or_else(|| {
AppError::Internal(format!(
"Member {new_did} has no status_list_index — rotation cannot reissue"
))
})?;
let status_ref = CredentialStatusRef::revocation(row.list_credential_id.clone(), slot);
let vmc_id = format!("urn:uuid:{}", Uuid::new_v4());
let vmc = build_vmc(
signer,
VmcParams::new(new_did)
.with_id(vmc_id.clone())
.with_status_ref(status_ref)
.with_personhood(false),
)
.await?;
let vec_id = format!("urn:uuid:{}", Uuid::new_v4());
let role_vec = build_role_vec(
signer,
RoleVecParams::new(new_did, acl.role.clone()).with_id(vec_id.clone()),
)
.await?;
let mut member_mut = member;
member_mut.current_vmc_id = Some(vmc_id.clone());
member_mut.current_role_vec_id = Some(vec_id.clone());
store_member(&state.members_ks, &member_mut).await?;
let vmc_value = serde_json::to_value(&vmc)
.map_err(|e| AppError::Internal(format!("serialise VMC: {e}")))?;
let vec_value = serde_json::to_value(&role_vec)
.map_err(|e| AppError::Internal(format!("serialise VEC: {e}")))?;
Ok((vmc_value, vec_value, Some(vmc_id), Some(vec_id)))
}
#[allow(dead_code)]
fn epoch_now() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn method_of_recognises_did_key() {
assert_eq!(method_of("did:key:z6MkAbc").unwrap(), "did:key");
}
#[test]
fn method_of_recognises_did_webvh() {
assert_eq!(method_of("did:webvh:example.com:abc").unwrap(), "did:webvh");
}
#[test]
fn method_of_rejects_unknown_methods() {
let err = method_of("did:example:abc").unwrap_err();
assert!(matches!(err, AppError::Validation(_)));
}
#[test]
fn method_of_rejects_non_did_strings() {
assert!(method_of("https://example.com").is_err());
assert!(method_of("").is_err());
}
#[test]
fn verify_did_webvh_signature_rejects_non_hex_signature() {
let raw = hex::decode("not-hex");
assert!(raw.is_err());
}
}