use affinidi_tdk::secrets_resolver::secrets::Secret;
use didwebvh_rs::{
DIDWebVHState,
log_entry::{LogEntry, LogEntryMethods},
log_entry_state::{LogEntryState, LogEntryValidationStatus},
parameters::Parameters,
};
use serde_json::{Value, json};
use crate::did_ops::content_log_key;
use crate::server::auth::session::now_epoch;
use crate::server::error::AppError;
use crate::server::identity::{IdentityGeneration, load_generations, mnemonic_from_did};
use crate::server::secret_store::{RetiredKeys, SecretStore};
use crate::server::store::{KS_DIDS, KS_IDENTITY, Store};
pub struct RotationReport {
pub did: String,
pub which: RotateKeys,
pub new_ka_kid: String,
pub retired_ka_kid: String,
pub new_signing_kid: String,
pub retired_signing_kid: String,
pub new_generation: u64,
pub retired_generation: u64,
pub expires_at: u64,
pub version_count: usize,
}
pub(crate) fn load_validated_state(content: &str) -> Result<DIDWebVHState, AppError> {
let mut state = DIDWebVHState::default();
let mut version = None;
for (idx, line) in content.lines().enumerate() {
let line = line.trim();
if line.is_empty() {
continue;
}
let entry = LogEntry::deserialize_string(line, version)
.map_err(|e| AppError::Config(format!("invalid log entry at line {}: {e}", idx + 1)))?;
version = Some(entry.get_webvh_version());
let version_number = entry
.get_version_id_fields()
.map_err(|e| AppError::Config(format!("invalid versionId at line {}: {e}", idx + 1)))?
.0;
state.log_entries_mut().push(LogEntryState {
log_entry: entry,
version_number,
validation_status: LogEntryValidationStatus::NotValidated,
validated_parameters: Parameters::default(),
});
}
if state.log_entries().is_empty() {
return Err(AppError::Config("the DID log is empty".into()));
}
let report = state
.validate()
.map_err(|e| AppError::Config(format!("the existing DID log does not verify: {e}")))?;
report.assert_complete().map_err(|e| {
AppError::Config(format!(
"the existing DID log is incomplete: {e} — refusing to append"
))
})?;
Ok(state)
}
fn rotate_key_agreement(doc: &Value, did_id: &str, new_ka_multibase: &str) -> (Value, String) {
let mut doc = doc.clone();
let new_kid = format!("{did_id}#{new_ka_multibase}");
let old_kids: Vec<String> = doc
.get("keyAgreement")
.and_then(Value::as_array)
.map(|refs| {
refs.iter()
.filter_map(|r| r.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
if let Some(vms) = doc
.get_mut("verificationMethod")
.and_then(Value::as_array_mut)
{
vms.retain(|vm| {
vm.get("id")
.and_then(Value::as_str)
.is_none_or(|id| !old_kids.iter().any(|k| k == id))
});
vms.push(json!({
"id": new_kid,
"type": "Multikey",
"controller": did_id,
"publicKeyMultibase": new_ka_multibase,
}));
}
doc["keyAgreement"] = json!([new_kid]);
(doc, new_kid)
}
fn rotate_signing(doc: &Value, did_id: &str, new_signing_multibase: &str) -> (Value, String) {
let mut doc = doc.clone();
let new_kid = format!("{did_id}#{new_signing_multibase}");
let mut old_kids: Vec<String> = Vec::new();
for rel in ["authentication", "assertionMethod"] {
if let Some(refs) = doc.get(rel).and_then(Value::as_array) {
for r in refs.iter().filter_map(Value::as_str) {
if !old_kids.iter().any(|k| k == r) {
old_kids.push(r.to_string());
}
}
}
}
let ka_kids: Vec<String> = doc
.get("keyAgreement")
.and_then(Value::as_array)
.map(|refs| {
refs.iter()
.filter_map(|r| r.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
if let Some(vms) = doc
.get_mut("verificationMethod")
.and_then(Value::as_array_mut)
{
vms.retain(|vm| {
let Some(id) = vm.get("id").and_then(Value::as_str) else {
return true;
};
let is_old_signing = old_kids.iter().any(|k| k == id);
let still_used_for_ka = ka_kids.iter().any(|k| k == id);
!is_old_signing || still_used_for_ka
});
vms.push(json!({
"id": new_kid,
"type": "Multikey",
"controller": did_id,
"publicKeyMultibase": new_signing_multibase,
}));
}
doc["authentication"] = json!([new_kid]);
if doc.get("assertionMethod").is_some() {
doc["assertionMethod"] = json!([new_kid]);
}
(doc, new_kid)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RotateKeys {
KeyAgreement,
Signing,
Both,
}
impl RotateKeys {
pub fn parse(s: &str) -> Result<Self, AppError> {
match s.trim().to_ascii_lowercase().as_str() {
"ka" | "key-agreement" | "keyagreement" => Ok(Self::KeyAgreement),
"signing" | "update" => Ok(Self::Signing),
"both" | "all" => Ok(Self::Both),
other => Err(AppError::Config(format!(
"invalid --keys '{other}' (expected 'ka', 'signing', or 'both')"
))),
}
}
fn rotates_ka(self) -> bool {
matches!(self, Self::KeyAgreement | Self::Both)
}
fn rotates_signing(self) -> bool {
matches!(self, Self::Signing | Self::Both)
}
}
pub async fn rotate_keys(
store: &Store,
secret_store: &dyn SecretStore,
server_did: &str,
which: RotateKeys,
new_ka_key: Option<&str>,
new_signing_key: Option<&str>,
grace_secs: u64,
) -> Result<RotationReport, AppError> {
let Some(mnemonic) = mnemonic_from_did(server_did) else {
return Err(AppError::Config(format!(
"`{server_did}` is not a did:webvh identifier this service hosts"
)));
};
let Some(mut secrets) = secret_store.get().await? else {
return Err(AppError::Config(
"secret store holds no server secrets".into(),
));
};
let dids_ks = store.keyspace(KS_DIDS)?;
let Some(raw) = dids_ks.get_raw(content_log_key(&mnemonic)).await? else {
return Err(AppError::Config(format!(
"no DID log stored at `{mnemonic}` — is `server_did` right?"
)));
};
let content = String::from_utf8(raw)
.map_err(|e| AppError::Config(format!("DID log is not valid UTF-8: {e}")))?;
let mut state = load_validated_state(&content)?;
let last = state
.log_entries()
.last()
.ok_or_else(|| AppError::Config("the DID log is empty".into()))?;
let current_doc = last.log_entry.get_state().clone();
let params = last.validated_parameters.clone();
let did_id = current_doc
.get("id")
.and_then(Value::as_str)
.ok_or_else(|| AppError::Config("the DID document has no `id`".into()))?
.to_string();
let retired_ka_kid = current_doc
.get("keyAgreement")
.and_then(Value::as_array)
.and_then(|a| a.first())
.and_then(Value::as_str)
.ok_or_else(|| {
AppError::Config("the DID document advertises no keyAgreement key to rotate".into())
})?
.to_string();
let retired_signing_kid = current_doc
.get("authentication")
.and_then(Value::as_array)
.and_then(|a| a.first())
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let mut new_doc = current_doc.clone();
let mut new_params = params.clone();
let (new_ka_kid, new_ka_multibase, new_ka_private) = if which.rotates_ka() {
let new_ka = match new_ka_key {
Some(multibase) => Secret::from_multibase(multibase, None)
.map_err(|e| AppError::Config(format!("invalid --ka-key: {e}")))?,
None => Secret::generate_x25519(None, None)
.map_err(|e| AppError::Config(format!("failed to generate an X25519 key: {e}")))?,
};
let multibase = new_ka
.get_public_keymultibase()
.map_err(|e| AppError::Config(format!("failed to derive the new public key: {e}")))?;
let (doc, kid) = rotate_key_agreement(&new_doc, &did_id, &multibase);
if kid == retired_ka_kid {
return Err(AppError::Config(
"the new key-agreement key is identical to the current one — nothing to rotate"
.into(),
));
}
new_doc = doc;
let private = new_ka
.get_private_keymultibase()
.map_err(|e| AppError::Config(format!("failed to encode the new private key: {e}")))?;
(kid, Some(multibase), Some(private))
} else {
(retired_ka_kid.clone(), None, None)
};
let signing = Secret::from_multibase(&secrets.signing_key, None)
.map_err(|e| AppError::Config(format!("failed to decode signing_key: {e}")))?;
let signing_multibase = signing
.get_public_keymultibase()
.map_err(|e| AppError::Config(format!("failed to derive the signing public key: {e}")))?;
let (new_signing_kid, new_signing_private) = if which.rotates_signing() {
let new_signing = match new_signing_key {
Some(multibase) => Secret::from_multibase(multibase, None)
.map_err(|e| AppError::Config(format!("invalid --signing-key: {e}")))?,
None => Secret::generate_ed25519(None, None),
};
let multibase = new_signing.get_public_keymultibase().map_err(|e| {
AppError::Config(format!("failed to derive the new signing public key: {e}"))
})?;
if multibase == signing_multibase {
return Err(AppError::Config(
"the new signing key is identical to the current one — nothing to rotate".into(),
));
}
let (doc, kid) = rotate_signing(&new_doc, &did_id, &multibase);
new_doc = doc;
new_params.update_keys = Some(std::sync::Arc::new(vec![multibase.into()]));
let private = new_signing.get_private_keymultibase().map_err(|e| {
AppError::Config(format!("failed to encode the new signing private key: {e}"))
})?;
(kid, Some(private))
} else {
(retired_signing_kid.clone(), None)
};
let mut signer = signing.clone();
if !signer.id.contains('#') {
signer.id = format!("did:key:{signing_multibase}#{signing_multibase}");
}
state
.create_log_entry(None, &new_doc, &new_params, &signer)
.await
.map_err(|e| AppError::Config(format!("failed to sign the new log entry: {e}")))?;
let new_line = serde_json::to_string(
&state
.log_entries()
.last()
.expect("just appended an entry")
.log_entry,
)?;
let version_count = state.log_entries().len();
let mut new_log = content.trim_end().to_string();
new_log.push('\n');
new_log.push_str(&new_line);
new_log.push('\n');
crate::did_ops::verify_did_log_proofs(&new_log)
.map_err(|e| AppError::Config(format!("the rotated DID log does not verify: {e}")))?;
let now = now_epoch();
let expires_at = now.saturating_add(grace_secs);
if grace_secs > 0 {
secrets.retired.retain(|r| r.ka_kid != retired_ka_kid);
secrets.retired.push(RetiredKeys {
ka_kid: retired_ka_kid.clone(),
key_agreement_key: secrets.key_agreement_key.clone(),
signing_kid: retired_signing_kid.clone(),
signing_key: secrets.signing_key.clone(),
});
}
if let Some(private) = new_ka_private {
secrets.key_agreement_key = private;
}
if let Some(private) = new_signing_private {
secrets.signing_key = private;
}
secret_store.set(&secrets).await?;
let identity_ks = store.keyspace(KS_IDENTITY)?;
let live = load_generations(&identity_ks, now).await?;
let current = live.first().cloned().ok_or_else(|| {
AppError::Config(
"no identity generation recorded — start the service once so it can resolve its own \
DID, then rotate"
.into(),
)
})?;
let mut retiring = current.clone();
retiring.retired_at = Some(now);
retiring.expires_at = Some(expires_at);
let new_generation = IdentityGeneration {
id: current.id + 1,
did: did_id.clone(),
signing_kid: new_signing_kid.clone(),
ka_kid: new_ka_kid.clone(),
ka_public_multibase: new_ka_multibase
.clone()
.or(current.ka_public_multibase.clone()),
mediator_did: current.mediator_did.clone(),
protocols: current.protocols,
created_at: now,
retired_at: None,
expires_at: None,
};
let mut batch = store.batch();
if grace_secs > 0 {
batch.insert(
&identity_ks,
format!("identity:gen:{:020}", retiring.id),
&retiring,
)?;
}
batch.insert(
&identity_ks,
format!("identity:gen:{:020}", new_generation.id),
&new_generation,
)?;
batch.insert(
&identity_ks,
b"identity:current".to_vec(),
&new_generation.id,
)?;
batch.commit().await?;
dids_ks
.insert_raw(content_log_key(&mnemonic), new_log.into_bytes())
.await?;
store.persist().await?;
Ok(RotationReport {
did: did_id,
which,
new_ka_kid,
retired_ka_kid,
new_signing_kid,
retired_signing_kid,
new_generation: new_generation.id,
retired_generation: retiring.id,
expires_at,
version_count,
})
}
#[cfg(test)]
mod tests {
use super::*;
const DID: &str = "did:webvh:QmSCID:example.com";
fn doc_with_ka(ka_kid: &str, ka_multibase: &str) -> Value {
json!({
"id": DID,
"authentication": [format!("{DID}#key-0")],
"keyAgreement": [ka_kid],
"verificationMethod": [
{ "id": format!("{DID}#key-0"), "type": "Multikey",
"controller": DID, "publicKeyMultibase": "z6MkSigning" },
{ "id": ka_kid, "type": "Multikey",
"controller": DID, "publicKeyMultibase": ka_multibase },
],
"service": [
{ "id": format!("{DID}#vta-didcomm"), "type": "DIDCommMessaging",
"serviceEndpoint": { "uri": "did:web:mediator.example" } }
],
})
}
#[test]
fn the_new_key_lands_on_a_fresh_fragment() {
let doc = doc_with_ka(&format!("{DID}#key-1"), "z6LSold");
let (rotated, new_kid) = rotate_key_agreement(&doc, DID, "z6LSnew");
assert_eq!(new_kid, format!("{DID}#z6LSnew"));
assert_ne!(new_kid, format!("{DID}#key-1"));
assert_eq!(rotated["keyAgreement"], json!([new_kid]));
}
#[test]
fn the_outgoing_key_is_removed_from_the_document() {
let old_kid = format!("{DID}#key-1");
let doc = doc_with_ka(&old_kid, "z6LSold");
let (rotated, _) = rotate_key_agreement(&doc, DID, "z6LSnew");
let vms = rotated["verificationMethod"].as_array().unwrap();
assert!(
!vms.iter().any(|vm| vm["id"] == json!(old_kid)),
"the retired key-agreement method must not stay published"
);
assert!(
vms.iter()
.any(|vm| vm["publicKeyMultibase"] == json!("z6LSnew")),
"the new key must be published"
);
}
#[test]
fn a_signing_rotation_moves_the_authentication_key_and_leaves_encryption_alone() {
let ka_kid = format!("{DID}#z6LSka");
let doc = doc_with_ka(&ka_kid, "z6LSka");
let (rotated, new_kid) = rotate_signing(&doc, DID, "z6MkNewSigning");
assert_eq!(new_kid, format!("{DID}#z6MkNewSigning"));
assert_eq!(rotated["authentication"], json!([new_kid]));
let vms = rotated["verificationMethod"].as_array().unwrap();
assert!(
!vms.iter()
.any(|vm| vm["id"] == json!(format!("{DID}#key-0"))),
"the superseded signing key must not stay published"
);
assert_eq!(rotated["keyAgreement"], doc["keyAgreement"]);
assert!(
vms.iter().any(|vm| vm["id"] == json!(ka_kid)),
"the key-agreement verification method must survive a signing rotation"
);
assert_eq!(rotated["service"], doc["service"], "services must survive");
}
#[test]
fn a_key_doing_double_duty_survives_a_signing_rotation() {
let shared = format!("{DID}#z6MkShared");
let doc = json!({
"id": DID,
"authentication": [shared],
"keyAgreement": [shared],
"verificationMethod": [
{ "id": shared, "type": "Multikey",
"controller": DID, "publicKeyMultibase": "z6MkShared" },
],
});
let (rotated, _) = rotate_signing(&doc, DID, "z6MkNewSigning");
let vms = rotated["verificationMethod"].as_array().unwrap();
assert!(
vms.iter().any(|vm| vm["id"] == json!(shared)),
"a key still referenced by keyAgreement must not be removed as a stale signing key"
);
assert_eq!(rotated["keyAgreement"], json!([shared]));
}
#[test]
fn rotate_keys_parses_the_operator_facing_names() {
assert_eq!(RotateKeys::parse("ka").unwrap(), RotateKeys::KeyAgreement);
assert_eq!(RotateKeys::parse("signing").unwrap(), RotateKeys::Signing);
assert_eq!(RotateKeys::parse("both").unwrap(), RotateKeys::Both);
assert!(RotateKeys::parse("everything").is_err());
}
#[test]
fn a_key_rotation_does_not_quietly_become_a_service_change() {
let doc = doc_with_ka(&format!("{DID}#key-1"), "z6LSold");
let (rotated, _) = rotate_key_agreement(&doc, DID, "z6LSnew");
assert_eq!(rotated["service"], doc["service"], "services must survive");
assert_eq!(
rotated["authentication"], doc["authentication"],
"the signing key is not what a key-agreement rotation touches"
);
let vms = rotated["verificationMethod"].as_array().unwrap();
assert!(
vms.iter()
.any(|vm| vm["id"] == json!(format!("{DID}#key-0"))),
"the authentication verification method must survive"
);
}
}