use std::sync::{Arc, RwLock};
use affinidi_did_resolver_cache_sdk::{DIDCacheClient, config::DIDCacheConfigBuilder};
use affinidi_tdk::secrets_resolver::secrets::Secret;
use affinidi_tdk::secrets_resolver::{SecretsResolver, ThreadedSecretsResolver};
use didwebvh_rs::log_entry::LogEntryMethods;
use serde::{Deserialize, Serialize};
use tracing::{debug, error, info, warn};
use super::auth::session::now_epoch;
use super::error::AppError;
use super::secret_store::{RetiredKeys, SecretStore, ServerSecrets};
use super::store::{KS_IDENTITY, KeyspaceHandle, Store};
pub const DID_CACHE_TTL_SECS: u32 = 120;
const KEY_CURRENT: &str = "identity:current";
const GEN_PREFIX: &str = "identity:gen:";
fn gen_key(id: u64) -> String {
format!("{GEN_PREFIX}{id:020}")
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProtocolSet {
pub didcomm: bool,
pub tsp: bool,
}
impl ProtocolSet {
pub fn union(self, other: Self) -> Self {
Self {
didcomm: self.didcomm || other.didcomm,
tsp: self.tsp || other.tsp,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct IdentityGeneration {
pub id: u64,
pub did: String,
pub signing_kid: String,
pub ka_kid: String,
#[serde(default)]
pub ka_public_multibase: Option<String>,
pub mediator_did: Option<String>,
pub protocols: ProtocolSet,
pub created_at: u64,
#[serde(default)]
pub retired_at: Option<u64>,
#[serde(default)]
pub expires_at: Option<u64>,
}
impl IdentityGeneration {
pub fn is_live(&self, now: u64) -> bool {
self.expires_at.is_none_or(|expires| expires > now)
}
pub fn differs_from(&self, other: &Self) -> bool {
self.did != other.did
|| self.signing_kid != other.signing_kid
|| self.ka_kid != other.ka_kid
|| self.mediator_did != other.mediator_did
|| self.protocols != other.protocols
|| self.key_material_differs_from(other)
}
pub fn key_material_differs_from(&self, other: &Self) -> bool {
match (&self.ka_public_multibase, &other.ka_public_multibase) {
(Some(a), Some(b)) => a != b,
_ => false,
}
}
pub fn is_same_kid_rotation(&self, other: &Self) -> bool {
self.ka_kid == other.ka_kid && self.key_material_differs_from(other)
}
}
struct LiveSet {
generations: Vec<IdentityGeneration>,
secrets: Vec<Secret>,
}
pub struct ServiceIdentity {
pub did: String,
pub did_resolver: DIDCacheClient,
pub secrets_resolver: Arc<ThreadedSecretsResolver>,
live: RwLock<LiveSet>,
rotation: tokio::sync::Mutex<()>,
}
impl ServiceIdentity {
pub fn current(&self) -> IdentityGeneration {
self.live.read().expect("identity lock").generations[0].clone()
}
pub fn generations(&self) -> Vec<IdentityGeneration> {
self.live.read().expect("identity lock").generations.clone()
}
pub fn secrets(&self) -> Vec<Secret> {
self.live.read().expect("identity lock").secrets.clone()
}
pub fn protocols(&self) -> ProtocolSet {
self.live
.read()
.expect("identity lock")
.generations
.iter()
.fold(ProtocolSet::default(), |acc, g| acc.union(g.protocols))
}
pub fn mediator_did(&self) -> Option<String> {
self.current().mediator_did
}
pub async fn generated_for(did: &str) -> Result<Arc<Self>, AppError> {
let mut secret = Secret::generate_ed25519(None, None);
secret.id = format!("{did}#key-1");
Self::from_signing_secret(did, secret).await
}
pub async fn from_signing_secret(did: &str, secret: Secret) -> Result<Arc<Self>, AppError> {
let did_resolver = DIDCacheClient::new(
DIDCacheConfigBuilder::default()
.with_cache_ttl(DID_CACHE_TTL_SECS)
.build(),
)
.await
.map_err(|e| AppError::Internal(format!("DID cache: {e}")))?;
let (secrets_resolver, _handle) = ThreadedSecretsResolver::new(None).await;
let generation = IdentityGeneration {
id: 0,
did: did.to_string(),
signing_kid: secret.id.clone(),
ka_kid: String::new(),
ka_public_multibase: None,
mediator_did: None,
protocols: ProtocolSet::default(),
created_at: now_epoch(),
retired_at: None,
expires_at: None,
};
Ok(Arc::new(Self {
did: did.to_string(),
did_resolver,
secrets_resolver: Arc::new(secrets_resolver),
live: RwLock::new(LiveSet {
generations: vec![generation],
secrets: vec![secret],
}),
rotation: tokio::sync::Mutex::new(()),
}))
}
#[cfg(any(test, feature = "test-support"))]
pub async fn for_didcomm_test(
did: &str,
signing_secret: Secret,
ka_secret: Secret,
mediator_did: &str,
protocols: ProtocolSet,
) -> Result<Arc<Self>, AppError> {
let did_resolver = DIDCacheClient::new(
DIDCacheConfigBuilder::default()
.with_cache_ttl(DID_CACHE_TTL_SECS)
.build(),
)
.await
.map_err(|e| AppError::Internal(format!("DID cache: {e}")))?;
let (secrets_resolver, _handle) = ThreadedSecretsResolver::new(None).await;
secrets_resolver.insert(signing_secret.clone()).await;
secrets_resolver.insert(ka_secret.clone()).await;
let generation = IdentityGeneration {
id: 0,
did: did.to_string(),
signing_kid: signing_secret.id.clone(),
ka_kid: ka_secret.id.clone(),
ka_public_multibase: None,
mediator_did: Some(mediator_did.to_string()),
protocols,
created_at: now_epoch(),
retired_at: None,
expires_at: None,
};
Ok(Arc::new(Self {
did: did.to_string(),
did_resolver,
secrets_resolver: Arc::new(secrets_resolver),
live: RwLock::new(LiveSet {
generations: vec![generation],
secrets: vec![signing_secret, ka_secret],
}),
rotation: tokio::sync::Mutex::new(()),
}))
}
#[cfg(test)]
pub(crate) async fn for_test(did: &str, generations: Vec<IdentityGeneration>) -> Arc<Self> {
let did_resolver = DIDCacheClient::new(DIDCacheConfigBuilder::default().build())
.await
.expect("local DID cache");
let (secrets_resolver, _handle) = ThreadedSecretsResolver::new(None).await;
Arc::new(Self {
did: did.to_string(),
did_resolver,
secrets_resolver: Arc::new(secrets_resolver),
live: RwLock::new(LiveSet {
generations,
secrets: Vec::new(),
}),
rotation: tokio::sync::Mutex::new(()),
})
}
}
pub fn mnemonic_from_did(did: &str) -> Option<String> {
let rest = did.strip_prefix("did:webvh:")?;
let after_scid = rest.split_once(':')?.1;
if after_scid.is_empty() {
return None;
}
match after_scid.split_once(':') {
Some((_host, path)) if !path.is_empty() => Some(path.replace(':', "/")),
_ => Some(".well-known".to_string()),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedIdentityDoc {
pub signing_kid: String,
pub ka_kid: String,
pub ka_public_multibase: Option<String>,
}
pub async fn resolve_identity_doc(
did: &str,
did_resolver: &DIDCacheClient,
) -> Option<ResolvedIdentityDoc> {
let doc = match did_resolver.resolve(did).await {
Ok(response) => response.doc,
Err(e) => {
warn!(did = did, "failed to resolve DID document: {e}");
return None;
}
};
if doc.authentication.is_empty() && doc.key_agreement.is_empty() {
warn!(
did = did,
"DID document has no authentication or keyAgreement"
);
return None;
}
let signing_kid = doc
.authentication
.first()
.map_or_else(|| format!("{did}#key-0"), |vr| vr.get_id().to_string());
let ka_kid = doc
.key_agreement
.first()
.map_or_else(|| format!("{did}#key-1"), |vr| vr.get_id().to_string());
let ka_public_multibase = doc
.verification_method
.iter()
.find(|vm| vm.id.as_str() == ka_kid)
.and_then(|vm| vm.property_set.get("publicKeyMultibase"))
.and_then(|v| v.as_str())
.map(str::to_string);
debug!(
did = did,
signing = %signing_kid,
key_agreement = %ka_kid,
"resolved own DID document"
);
Some(ResolvedIdentityDoc {
signing_kid,
ka_kid,
ka_public_multibase,
})
}
pub fn resolve_identity_doc_from_log(did: &str, log_content: &str) -> Option<ResolvedIdentityDoc> {
let state = super::identity_rotate::load_validated_state(log_content).ok()?;
let current = state.log_entries().last()?.log_entry.get_state();
let resolved = kids_from_document(did, current)?;
debug!(
did = did,
signing = %resolved.signing_kid,
key_agreement = %resolved.ka_kid,
"resolved own DID document from the local log (no network)"
);
Some(resolved)
}
fn kids_from_document(did: &str, current: &serde_json::Value) -> Option<ResolvedIdentityDoc> {
if current.get("authentication").is_none() && current.get("keyAgreement").is_none() {
return None;
}
let kid_of = |entry: &serde_json::Value| -> Option<String> {
entry
.as_str()
.map(str::to_string)
.or_else(|| entry.get("id").and_then(|v| v.as_str()).map(str::to_string))
};
let signing_kid = current
.get("authentication")
.and_then(|v| v.as_array())
.and_then(|a| a.first())
.and_then(kid_of)
.unwrap_or_else(|| format!("{did}#key-0"));
let ka_kid = current
.get("keyAgreement")
.and_then(|v| v.as_array())
.and_then(|a| a.first())
.and_then(kid_of)
.unwrap_or_else(|| format!("{did}#key-1"));
let ka_public_multibase = current
.get("verificationMethod")
.and_then(|v| v.as_array())
.and_then(|vms| {
vms.iter()
.find(|vm| vm.get("id").and_then(|i| i.as_str()) == Some(ka_kid.as_str()))
})
.and_then(|vm| vm.get("publicKeyMultibase"))
.and_then(|v| v.as_str())
.map(str::to_string);
Some(ResolvedIdentityDoc {
signing_kid,
ka_kid,
ka_public_multibase,
})
}
fn warn_if_well_known_in_identifier(did: &str) {
if !did.ends_with(":.well-known") {
return;
}
warn!(
did = did,
"this DID carries `.well-known` inside its identifier, which no conforming \
did:webvh resolver can round-trip — `.well-known` is where the document is \
*stored*, not part of the DID. A root DID should be `did:webvh:<scid>:<host>`. \
This was minted by a build predating the fix and cannot be corrected in place \
(the SCID is bound to the document): the DID must be recreated. Identity \
rotation cannot function until then."
);
}
fn secret_matches_document(secret: &Secret, advertised: Option<&str>) -> bool {
let Some(advertised) = advertised else {
return true;
};
match secret.get_public_keymultibase() {
Ok(derived) => derived == advertised,
Err(e) => {
warn!("could not derive public key from secret: {e}");
false
}
}
}
pub async fn load_generations(
identity_ks: &KeyspaceHandle,
now: u64,
) -> Result<Vec<IdentityGeneration>, AppError> {
let current_id: Option<u64> = identity_ks.get(KEY_CURRENT.as_bytes().to_vec()).await?;
let mut generations: Vec<IdentityGeneration> = Vec::new();
for (_key, value) in identity_ks.prefix_iter_raw(GEN_PREFIX.as_bytes()).await? {
match serde_json::from_slice::<IdentityGeneration>(&value) {
Ok(record) if record.is_live(now) => generations.push(record),
Ok(record) => debug!(id = record.id, "identity generation expired — skipping"),
Err(e) => warn!("skipping unreadable identity generation record: {e}"),
}
}
generations.sort_by_key(|g| std::cmp::Reverse(g.id));
if let Some(id) = current_id
&& let Some(pos) = generations.iter().position(|g| g.id == id)
{
generations.swap(0, pos);
}
Ok(generations)
}
pub async fn save_current_generation(
store: &Store,
identity_ks: &KeyspaceHandle,
generation: &IdentityGeneration,
) -> Result<(), AppError> {
let mut batch = store.batch();
batch.insert(identity_ks, gen_key(generation.id), generation)?;
batch.insert(identity_ks, KEY_CURRENT.as_bytes().to_vec(), &generation.id)?;
batch.commit().await
}
fn secrets_for(generation: &IdentityGeneration, secrets: &ServerSecrets) -> Vec<Secret> {
let (signing_key, ka_key) = if generation.retired_at.is_some() {
match secrets
.retired
.iter()
.find(|r| r.ka_kid == generation.ka_kid)
{
Some(retired) => (&retired.signing_key, &retired.key_agreement_key),
None => {
warn!(
id = generation.id,
ka_kid = %generation.ka_kid,
"retired generation has no key material in the secret store — \
messages still addressed to it cannot be decrypted"
);
return Vec::new();
}
}
} else {
(&secrets.signing_key, &secrets.key_agreement_key)
};
let mut out = Vec::new();
match Secret::from_multibase(signing_key, Some(&generation.signing_kid)) {
Ok(secret) => out.push(secret),
Err(e) => warn!("failed to decode signing_key: {e}"),
}
match Secret::from_multibase(ka_key, Some(&generation.ka_kid)) {
Ok(secret) => out.push(secret),
Err(e) => warn!("failed to decode key_agreement_key: {e}"),
}
out
}
pub async fn load_identity(
server_did: Option<&str>,
mediator_did: Option<&str>,
protocols: ProtocolSet,
secrets: &ServerSecrets,
store: &Store,
) -> Option<Arc<ServiceIdentity>> {
let did = match server_did {
Some(did) => did,
None => {
warn!("server_did not configured — DIDComm auth endpoints will not work");
return None;
}
};
let did_resolver = match DIDCacheClient::new(
DIDCacheConfigBuilder::default()
.with_cache_ttl(DID_CACHE_TTL_SECS)
.build(),
)
.await
{
Ok(resolver) => resolver,
Err(e) => {
warn!("failed to create DID resolver: {e} — DIDComm auth endpoints will not work");
return None;
}
};
let identity_ks = match store.keyspace(KS_IDENTITY) {
Ok(ks) => ks,
Err(e) => {
warn!("failed to open identity keyspace: {e} — DIDComm auth endpoints will not work");
return None;
}
};
let now = now_epoch();
let stored = load_generations(&identity_ks, now)
.await
.unwrap_or_else(|e| {
warn!("failed to read identity generations: {e} — treating as empty");
Vec::new()
});
let local_log: Option<String> = match (
mnemonic_from_did(did),
store.keyspace(super::store::KS_DIDS),
) {
(Some(mnemonic), Ok(dids_ks)) => dids_ks
.get_raw(crate::did_ops::content_log_key(&mnemonic))
.await
.ok()
.flatten()
.and_then(|bytes| String::from_utf8(bytes).ok()),
_ => None,
};
let current = resolve_current_generation(
did,
mediator_did,
protocols,
&did_resolver,
local_log.as_deref(),
stored.first(),
now,
)
.await;
let (current, persist) = match current {
Some(generation) => {
let persist = stored.first() != Some(&generation);
(generation, persist)
}
None => {
warn!(
did = did,
"DID document not resolvable and no stored generation — \
falling back to #key-0/#key-1 and not persisting"
);
warn_if_well_known_in_identifier(did);
(
IdentityGeneration {
id: 0,
did: did.to_string(),
signing_kid: format!("{did}#key-0"),
ka_kid: format!("{did}#key-1"),
ka_public_multibase: None,
mediator_did: mediator_did.map(str::to_string),
protocols,
created_at: now,
retired_at: None,
expires_at: None,
},
false,
)
}
};
if persist && let Err(e) = save_current_generation(store, &identity_ks, ¤t).await {
warn!("failed to persist identity generation: {e}");
}
match Secret::from_multibase(&secrets.key_agreement_key, Some(¤t.ka_kid)) {
Ok(ka) if !secret_matches_document(&ka, current.ka_public_multibase.as_deref()) => {
error!(
did = did,
ka_kid = %current.ka_kid,
"the DID document advertises a key-agreement key whose private half is NOT in \
the secret store — this identity cannot decrypt or authenticate, and will fail \
once any grace period elapses. Write the matching key to the secret store \
(keys, then publish the DID), or re-resolve."
);
}
Err(e) => warn!(
did = did,
ka_kid = %current.ka_kid,
"could not decode the stored key-agreement key to validate it against the \
document: {e}"
),
_ => {}
}
let current_id = current.id;
let mut generations = vec![current];
generations.extend(
stored
.into_iter()
.filter(|g| g.retired_at.is_some() && g.id != current_id),
);
let (secrets_resolver, _handle) = ThreadedSecretsResolver::new(None).await;
let mut all_secrets = Vec::new();
for generation in &generations {
for secret in secrets_for(generation, secrets) {
secrets_resolver.insert(secret.clone()).await;
all_secrets.push(secret);
}
}
info!(
did = did,
generation = generations[0].id,
signing_kid = %generations[0].signing_kid,
ka_kid = %generations[0].ka_kid,
live_generations = generations.len(),
"service identity loaded"
);
Some(Arc::new(ServiceIdentity {
did: did.to_string(),
did_resolver,
secrets_resolver: Arc::new(secrets_resolver),
live: RwLock::new(LiveSet {
generations,
secrets: all_secrets,
}),
rotation: tokio::sync::Mutex::new(()),
}))
}
async fn resolve_current_generation(
did: &str,
mediator_did: Option<&str>,
protocols: ProtocolSet,
did_resolver: &DIDCacheClient,
local_log: Option<&str>,
stored: Option<&IdentityGeneration>,
now: u64,
) -> Option<IdentityGeneration> {
let resolved = match local_log.and_then(|log| resolve_identity_doc_from_log(did, log)) {
Some(from_log) => Some(from_log),
None => resolve_identity_doc(did, did_resolver).await,
};
let Some(doc) = resolved else {
if let Some(stored) = stored {
warn!(
did = did,
generation = stored.id,
"DID document not resolvable — keeping the stored generation's key IDs"
);
}
return stored.cloned();
};
let id = stored.map_or(0, |g| g.id);
let created_at = stored.map_or(now, |g| g.created_at);
Some(IdentityGeneration {
id,
did: did.to_string(),
signing_kid: doc.signing_kid,
ka_kid: doc.ka_kid,
ka_public_multibase: doc.ka_public_multibase,
mediator_did: mediator_did.map(str::to_string),
protocols,
created_at,
retired_at: None,
expires_at: None,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReloadOutcome {
Unchanged,
Established { generation: u64 },
RotatedWithoutOverlap { generation: u64, ka_kid: String },
Unresolvable,
Refused { reason: String },
Rotated {
new_generation: u64,
retired_generation: u64,
expires_at: u64,
},
MetadataUpdated { generation: u64 },
}
pub async fn reload_service_identity(
identity: &ServiceIdentity,
store: &Store,
secret_store: &dyn SecretStore,
mediator_did: Option<&str>,
protocols: ProtocolSet,
grace_secs: u64,
) -> Result<ReloadOutcome, AppError> {
let _guard = identity.rotation.lock().await;
identity.did_resolver.remove(&identity.did).await;
let Some(doc) = resolve_identity_doc(&identity.did, &identity.did_resolver).await else {
return Ok(ReloadOutcome::Unresolvable);
};
let current = identity.current();
let now = now_epoch();
let identity_ks = store.keyspace(KS_IDENTITY)?;
let persisted: Option<u64> = identity_ks.get(KEY_CURRENT.as_bytes().to_vec()).await?;
let candidate = IdentityGeneration {
id: if persisted.is_some() {
current.id + 1
} else {
current.id
},
did: identity.did.clone(),
signing_kid: doc.signing_kid.clone(),
ka_kid: doc.ka_kid.clone(),
ka_public_multibase: doc.ka_public_multibase.clone(),
mediator_did: mediator_did.map(str::to_string),
protocols,
created_at: now,
retired_at: None,
expires_at: None,
};
if persisted.is_none() {
establish_generation(identity, store, &identity_ks, &candidate, secret_store).await?;
info!(
generation = candidate.id,
signing_kid = %candidate.signing_kid,
ka_kid = %candidate.ka_kid,
"service identity established from the DID document"
);
return Ok(ReloadOutcome::Established {
generation: candidate.id,
});
}
if !current.differs_from(&candidate) {
debug!("identity unchanged");
return Ok(ReloadOutcome::Unchanged);
}
let key_changed = current.signing_kid != candidate.signing_kid
|| current.ka_kid != candidate.ka_kid
|| current.key_material_differs_from(&candidate);
if !key_changed {
let updated = IdentityGeneration {
id: current.id,
..candidate.clone()
};
update_current_generation_in_place(identity, store, &identity_ks, &updated).await?;
info!(
generation = updated.id,
didcomm = updated.protocols.didcomm,
tsp = updated.protocols.tsp,
"identity metadata changed (protocols/mediator) but keys did not — \
updated in place, no rotation"
);
return Ok(ReloadOutcome::MetadataUpdated {
generation: updated.id,
});
}
let Some(mut secrets) = secret_store.get().await? else {
return Ok(ReloadOutcome::Refused {
reason: "secret store holds no server secrets".into(),
});
};
let new_ka = Secret::from_multibase(&secrets.key_agreement_key, Some(&candidate.ka_kid))
.map_err(|e| AppError::Config(format!("failed to decode key_agreement_key: {e}")))?;
if !secret_matches_document(&new_ka, doc.ka_public_multibase.as_deref()) {
return Ok(ReloadOutcome::Refused {
reason: format!(
"the DID document advertises key-agreement key {} but the secret store holds a \
different private key — write the new key to the secret store before publishing \
the DID (keys, then rotate)",
candidate.ka_kid
),
});
}
if current.is_same_kid_rotation(&candidate) {
establish_generation(identity, store, &identity_ks, &candidate, secret_store).await?;
warn!(
ka_kid = %candidate.ka_kid,
"the DID document rotated its key-agreement key but kept the same \
verification-method id ({}) — so NO grace period is possible: a kid identifies \
exactly one key, and messages already encrypted to the previous one cannot be \
decrypted. Adopted the new key; peers holding a stale document cannot reach this \
service until their cache expires. Rotate onto a NEW fragment to get an overlap.",
candidate.ka_kid
);
return Ok(ReloadOutcome::RotatedWithoutOverlap {
generation: candidate.id,
ka_kid: candidate.ka_kid,
});
}
let expires_at = now.saturating_add(grace_secs);
let mut retiring = current.clone();
retiring.retired_at = Some(now);
retiring.expires_at = Some(expires_at);
if grace_secs > 0 {
match retired_keys_for(identity, &retiring) {
Some(keys) => {
secrets.retired.retain(|r| r.ka_kid != keys.ka_kid);
secrets.retired.push(keys);
}
None => warn!(
id = retiring.id,
"outgoing generation's key material is not in memory — \
it cannot be honoured after a restart"
),
}
}
secret_store.set(&secrets).await?;
let identity_ks = store.keyspace(KS_IDENTITY)?;
let mut batch = store.batch();
batch.insert(&identity_ks, gen_key(retiring.id), &retiring)?;
batch.insert(&identity_ks, gen_key(candidate.id), &candidate)?;
batch.insert(&identity_ks, KEY_CURRENT.as_bytes().to_vec(), &candidate.id)?;
batch.commit().await?;
let new_secrets = secrets_for(&candidate, &secrets);
for secret in &new_secrets {
identity.secrets_resolver.insert(secret.clone()).await;
}
{
let mut live = identity.live.write().expect("identity lock");
let mut generations = vec![candidate.clone()];
if grace_secs > 0 {
generations.push(retiring.clone());
generations.extend(live.generations.iter().skip(1).cloned());
}
let mut all = new_secrets;
if grace_secs > 0 {
all.extend(live.secrets.iter().cloned());
}
live.generations = generations;
live.secrets = all;
}
if grace_secs == 0 {
drop_generation_secrets(identity, &retiring).await;
}
info!(
new_generation = candidate.id,
retired_generation = retiring.id,
ka_kid = %candidate.ka_kid,
expires_at,
"service identity rotated"
);
Ok(ReloadOutcome::Rotated {
new_generation: candidate.id,
retired_generation: retiring.id,
expires_at,
})
}
async fn update_current_generation_in_place(
identity: &ServiceIdentity,
store: &Store,
identity_ks: &KeyspaceHandle,
updated: &IdentityGeneration,
) -> Result<(), AppError> {
save_current_generation(store, identity_ks, updated).await?;
let mut live = identity.live.write().expect("identity lock");
if let Some(first) = live.generations.first_mut() {
*first = updated.clone();
} else {
live.generations = vec![updated.clone()];
}
Ok(())
}
async fn establish_generation(
identity: &ServiceIdentity,
store: &Store,
identity_ks: &KeyspaceHandle,
generation: &IdentityGeneration,
secret_store: &dyn SecretStore,
) -> Result<(), AppError> {
let Some(secrets) = secret_store.get().await? else {
return Err(AppError::Config(
"secret store holds no server secrets".into(),
));
};
save_current_generation(store, identity_ks, generation).await?;
let placeholder = identity.current();
identity
.secrets_resolver
.remove_secret(&placeholder.ka_kid)
.await;
identity
.secrets_resolver
.remove_secret(&placeholder.signing_kid)
.await;
let new_secrets = secrets_for(generation, &secrets);
for secret in &new_secrets {
identity.secrets_resolver.insert(secret.clone()).await;
}
let mut live = identity.live.write().expect("identity lock");
live.generations = vec![generation.clone()];
live.secrets = new_secrets;
Ok(())
}
fn retired_keys_for(
identity: &ServiceIdentity,
generation: &IdentityGeneration,
) -> Option<RetiredKeys> {
let live = identity.live.read().expect("identity lock");
let find = |kid: &str| {
live.secrets
.iter()
.find(|s| s.id == kid)
.and_then(|s| s.get_private_keymultibase().ok())
};
Some(RetiredKeys {
ka_kid: generation.ka_kid.clone(),
key_agreement_key: find(&generation.ka_kid)?,
signing_kid: generation.signing_kid.clone(),
signing_key: find(&generation.signing_kid)?,
})
}
async fn drop_generation_secrets(identity: &ServiceIdentity, generation: &IdentityGeneration) {
identity
.secrets_resolver
.remove_secret(&generation.ka_kid)
.await;
identity
.secrets_resolver
.remove_secret(&generation.signing_kid)
.await;
let mut live = identity.live.write().expect("identity lock");
live.generations.retain(|g| g.id != generation.id);
live.secrets
.retain(|s| s.id != generation.ka_kid && s.id != generation.signing_kid);
}
pub const DEFAULT_SWEEP_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60);
pub const DEFAULT_RELOAD_INTERVAL: std::time::Duration = std::time::Duration::from_secs(300);
pub async fn run_sweep_once(
identity: &ServiceIdentity,
store: &Store,
secret_store: &dyn SecretStore,
) -> u64 {
let now = now_epoch();
let live = identity.generations();
let persisted: Vec<u64> = match store.keyspace(KS_IDENTITY) {
Ok(ks) => match load_generations(&ks, now).await {
Ok(records) => records.iter().map(|g| g.id).collect(),
Err(e) => {
warn!("identity sweep: could not read generations: {e}");
return 0;
}
},
Err(e) => {
warn!("identity sweep: could not open the identity keyspace: {e}");
return 0;
}
};
let current_id = live.first().map(|g| g.id);
let doomed_ids: Vec<u64> = live
.iter()
.filter(|g| {
if Some(g.id) == current_id {
return false;
}
!g.is_live(now) || !persisted.contains(&g.id)
})
.map(|g| g.id)
.collect();
if doomed_ids.is_empty() {
return 0;
}
let surviving_ka_kids: std::collections::HashSet<&str> = live
.iter()
.filter(|g| !doomed_ids.contains(&g.id))
.map(|g| g.ka_kid.as_str())
.collect();
let mut to_expire: Vec<IdentityGeneration> = Vec::new();
let mut rescued: Vec<u64> = Vec::new();
for g in live.iter().filter(|g| doomed_ids.contains(&g.id)) {
if surviving_ka_kids.contains(g.ka_kid.as_str()) {
rescued.push(g.id);
} else {
to_expire.push(g.clone());
}
}
if !rescued.is_empty() {
error!(
?rescued,
"identity sweep: NOT expiring generation(s) whose key-agreement key is still used by \
a surviving generation — expiring them would strip the key in active use and leave \
no usable key-agreement key. This indicates a same-`ka_kid` rotation that should no \
longer happen (see reload_service_identity's metadata-vs-key split); the shared key \
material stays loaded until the drift is resolved."
);
}
let mut reaped = 0;
for generation in &to_expire {
if let Err(e) = expire_generation(identity, store, secret_store, generation).await {
warn!(id = generation.id, "failed to expire generation: {e}");
continue;
}
info!(
id = generation.id,
ka_kid = %generation.ka_kid,
"identity generation expired — its key material is no longer honoured"
);
reaped += 1;
}
reaped
}
pub async fn retire_generation_now(
identity: &ServiceIdentity,
store: &Store,
secret_store: &dyn SecretStore,
generation_id: u64,
) -> Result<(), AppError> {
let _guard = identity.rotation.lock().await;
let generations = identity.generations();
let Some(generation) = generations.iter().find(|g| g.id == generation_id) else {
return Err(AppError::validation(
crate::server::error::ValidationKind::Other,
format!("no live identity generation with id {generation_id}"),
));
};
if generation.retired_at.is_none() {
return Err(AppError::Config(
"refusing to expire the current generation — rotate to a new one first".into(),
));
}
expire_generation(identity, store, secret_store, generation).await
}
async fn expire_generation(
identity: &ServiceIdentity,
store: &Store,
secret_store: &dyn SecretStore,
generation: &IdentityGeneration,
) -> Result<(), AppError> {
drop_generation_secrets(identity, generation).await;
if let Some(mut secrets) = secret_store.get().await? {
let before = secrets.retired.len();
secrets.retired.retain(|r| r.ka_kid != generation.ka_kid);
if secrets.retired.len() != before {
secret_store.set(&secrets).await?;
}
}
let identity_ks = store.keyspace(KS_IDENTITY)?;
identity_ks.remove(gen_key(generation.id)).await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::server::config::StoreConfig;
use std::sync::Mutex as StdMutex;
const DID: &str = "did:webvh:example:alpha";
async fn fjall_store() -> Store {
let dir = tempfile::tempdir().expect("tempdir");
let cfg = StoreConfig {
data_dir: dir.path().to_path_buf(),
..StoreConfig::default()
};
std::mem::forget(dir);
Store::open(&cfg).await.expect("open fjall")
}
struct MockSecretStore(StdMutex<Option<ServerSecrets>>);
impl MockSecretStore {
fn new(secrets: ServerSecrets) -> Self {
Self(StdMutex::new(Some(secrets)))
}
fn snapshot(&self) -> ServerSecrets {
self.0.lock().unwrap().clone().expect("secrets present")
}
}
impl SecretStore for MockSecretStore {
fn get(
&self,
) -> super::super::secret_store::BoxFuture<'_, Result<Option<ServerSecrets>, AppError>>
{
let v = self.0.lock().unwrap().clone();
Box::pin(async move { Ok(v) })
}
fn set(
&self,
secrets: &ServerSecrets,
) -> super::super::secret_store::BoxFuture<'_, Result<(), AppError>> {
*self.0.lock().unwrap() = Some(secrets.clone());
Box::pin(async move { Ok(()) })
}
fn get_bootstrap_seed(
&self,
) -> super::super::secret_store::BoxFuture<'_, Result<Option<[u8; 32]>, AppError>> {
Box::pin(async move { Ok(None) })
}
fn set_bootstrap_seed(
&self,
_seed: &[u8; 32],
) -> super::super::secret_store::BoxFuture<'_, Result<(), AppError>> {
Box::pin(async move { Ok(()) })
}
fn clear_bootstrap_seed(
&self,
) -> super::super::secret_store::BoxFuture<'_, Result<(), AppError>> {
Box::pin(async move { Ok(()) })
}
}
fn keyed_generation(id: u64, tag: &str) -> (IdentityGeneration, Secret, Secret) {
let signing_kid = format!("{DID}#z6Mk{tag}");
let ka_kid = format!("{DID}#z6LS{tag}");
let signing = Secret::generate_ed25519(Some(&signing_kid), None);
let ka = Secret::generate_x25519(Some(&ka_kid), None).expect("x25519");
let mut g = generation(id, &ka_kid);
g.signing_kid = signing_kid;
(g, signing, ka)
}
async fn identity_with(
generations: Vec<IdentityGeneration>,
secrets: Vec<Secret>,
) -> Arc<ServiceIdentity> {
let did_resolver = DIDCacheClient::new(DIDCacheConfigBuilder::default().build())
.await
.expect("local DID cache");
let (secrets_resolver, _handle) = ThreadedSecretsResolver::new(None).await;
for s in &secrets {
secrets_resolver.insert(s.clone()).await;
}
Arc::new(ServiceIdentity {
did: DID.to_string(),
did_resolver,
secrets_resolver: Arc::new(secrets_resolver),
live: RwLock::new(LiveSet {
generations,
secrets,
}),
rotation: tokio::sync::Mutex::new(()),
})
}
fn server_secrets(signing: &Secret, ka: &Secret, retired: Vec<RetiredKeys>) -> ServerSecrets {
ServerSecrets {
signing_key: signing.get_private_keymultibase().unwrap(),
key_agreement_key: ka.get_private_keymultibase().unwrap(),
jwt_signing_key: Secret::generate_ed25519(None, None)
.get_private_keymultibase()
.unwrap(),
vta_credential: None,
retired,
}
}
fn generation(id: u64, ka_kid: &str) -> IdentityGeneration {
IdentityGeneration {
id,
did: "did:webvh:example:alpha".into(),
signing_kid: "did:webvh:example:alpha#key-0".into(),
ka_kid: ka_kid.into(),
ka_public_multibase: None,
mediator_did: Some("did:web:mediator.example".into()),
protocols: ProtocolSet {
didcomm: true,
tsp: false,
},
created_at: 100,
retired_at: None,
expires_at: None,
}
}
#[test]
fn protocol_union_carries_a_retiring_transport() {
let current = ProtocolSet {
didcomm: false,
tsp: true,
};
let retiring = ProtocolSet {
didcomm: true,
tsp: false,
};
let union = current.union(retiring);
assert!(
union.didcomm,
"retiring generation's DIDComm must be carried"
);
assert!(union.tsp, "current generation's TSP must be carried");
}
#[test]
fn identical_facts_do_not_read_as_a_rotation() {
let a = generation(0, "did:webvh:example:alpha#z6LSold");
let mut b = generation(7, "did:webvh:example:alpha#z6LSold");
b.created_at = 999;
b.retired_at = Some(1000);
assert!(!a.differs_from(&b), "id and timestamps are not identity");
}
#[test]
fn new_key_material_under_the_same_kid_is_not_reported_as_unchanged() {
let mut old = generation(0, "did:webvh:example:alpha#key-1");
old.ka_public_multibase = Some("z6LSoldkey".into());
let mut new = old.clone();
new.ka_public_multibase = Some("z6LSnewkey".into());
assert!(
old.differs_from(&new),
"new key material must register as a change even when the kid is identical"
);
assert!(old.is_same_kid_rotation(&new));
}
#[test]
fn a_same_kid_rotation_cannot_overlap_but_a_fresh_fragment_can() {
let mut old = generation(0, "did:webvh:example:alpha#key-1");
old.ka_public_multibase = Some("z6LSoldkey".into());
let mut same_kid = old.clone();
same_kid.ka_public_multibase = Some("z6LSnewkey".into());
assert!(
same_kid.is_same_kid_rotation(&old),
"same fragment + new key = no overlap possible"
);
let mut fresh_kid = generation(1, "did:webvh:example:alpha#z6LSnewkey");
fresh_kid.ka_public_multibase = Some("z6LSnewkey".into());
assert!(old.differs_from(&fresh_kid));
assert!(
!old.is_same_kid_rotation(&fresh_kid),
"a fresh fragment can hold both keys at once — this is the overlapping path"
);
}
#[test]
fn unknown_key_material_never_trips_the_diff() {
let old = generation(0, "did:webvh:example:alpha#key-1"); let mut new = old.clone();
new.ka_public_multibase = Some("z6LSsomething".into());
assert!(!old.key_material_differs_from(&new));
assert!(
!old.differs_from(&new),
"unknown must not look like a rotation"
);
}
#[test]
fn a_changed_key_agreement_kid_reads_as_a_rotation() {
let a = generation(0, "did:webvh:example:alpha#z6LSold");
let b = generation(0, "did:webvh:example:alpha#z6LSnew");
assert!(a.differs_from(&b));
}
#[test]
fn a_changed_mediator_reads_as_a_rotation() {
let a = generation(0, "did:webvh:example:alpha#z6LSold");
let mut b = a.clone();
b.mediator_did = Some("did:web:mediator2.example".into());
assert!(a.differs_from(&b));
}
#[test]
fn liveness_follows_the_expiry() {
let mut record = generation(0, "did:webvh:example:alpha#z6LSold");
assert!(record.is_live(u64::MAX), "current generation never expires");
record.expires_at = Some(500);
assert!(record.is_live(499));
assert!(!record.is_live(500), "expiry is exclusive");
assert!(!record.is_live(501));
}
#[test]
fn the_mnemonic_gate_identifies_our_own_did() {
assert_eq!(
mnemonic_from_did("did:webvh:QmSCID:example.com:alice").as_deref(),
Some("alice")
);
assert_eq!(
mnemonic_from_did("did:webvh:QmSCID:example.com:team:alice").as_deref(),
Some("team/alice")
);
assert_eq!(
mnemonic_from_did("did:webvh:QmSCID:localhost%3A8080:alice").as_deref(),
Some("alice")
);
assert_eq!(
mnemonic_from_did("did:webvh:QmSCID:did.example.com").as_deref(),
Some(".well-known")
);
assert_eq!(
mnemonic_from_did("did:webvh:QmSCID:localhost%3A8080").as_deref(),
Some(".well-known"),
"a root DID with a percent-encoded port is still a root DID"
);
assert_eq!(mnemonic_from_did("did:web:example.com:alice"), None);
assert_eq!(mnemonic_from_did("did:key:z6Mk"), None);
assert_eq!(mnemonic_from_did("did:webvh:QmSCID"), None, "no host");
}
#[test]
fn the_root_did_round_trips_between_identifier_and_storage_slot() {
use crate::did::{DidDocumentOptions, build_did_document};
let doc = build_did_document(
"did.example.com",
".well-known",
"z6MkPubKey",
&DidDocumentOptions::default(),
);
let id = doc["id"].as_str().expect("id");
assert_eq!(
id, "did:webvh:{SCID}:did.example.com",
"`.well-known` is a storage slot, not part of the identifier"
);
let concrete = id.replace("{SCID}", "QmSCID");
assert_eq!(mnemonic_from_did(&concrete).as_deref(), Some(".well-known"));
}
#[test]
fn generation_keys_sort_in_id_order() {
let mut keys = [gen_key(10), gen_key(9), gen_key(100)];
keys.sort();
assert_eq!(keys, [gen_key(9), gen_key(10), gen_key(100)]);
}
#[tokio::test]
async fn a_saved_generation_survives_a_restart() {
let store = fjall_store().await;
let ks = store.keyspace(KS_IDENTITY).expect("identity keyspace");
let original = generation(0, "did:webvh:example:alpha#z6LSresolved");
save_current_generation(&store, &ks, &original)
.await
.expect("save");
let ks = store.keyspace(KS_IDENTITY).expect("identity keyspace");
let loaded = load_generations(&ks, 1_000).await.expect("load");
assert_eq!(loaded, vec![original]);
}
#[tokio::test]
async fn a_retired_generation_stays_live_until_it_expires() {
let store = fjall_store().await;
let ks = store.keyspace(KS_IDENTITY).expect("identity keyspace");
let current = generation(1, "did:webvh:example:alpha#z6LSnew");
let mut retired = generation(0, "did:webvh:example:alpha#z6LSold");
retired.retired_at = Some(500);
retired.expires_at = Some(1_000);
save_current_generation(&store, &ks, ¤t)
.await
.expect("save current");
ks.insert(gen_key(retired.id), &retired)
.await
.expect("save retired");
let live = load_generations(&ks, 999).await.expect("load");
assert_eq!(live.len(), 2, "retired generation must still be honoured");
assert_eq!(live[0].id, 1, "current generation sorts first");
assert_eq!(live[1].id, 0);
let live = load_generations(&ks, 1_000).await.expect("load");
assert_eq!(live, vec![current], "expired generation must be dropped");
}
#[tokio::test]
async fn a_retired_generation_reads_its_key_material_from_the_retired_set() {
let (new_gen, new_signing, new_ka) = keyed_generation(1, "new");
let (mut old_gen, old_signing, old_ka) = keyed_generation(0, "old");
old_gen.retired_at = Some(500);
old_gen.expires_at = Some(4_100);
let secrets = server_secrets(
&new_signing,
&new_ka,
vec![RetiredKeys {
ka_kid: old_gen.ka_kid.clone(),
key_agreement_key: old_ka.get_private_keymultibase().unwrap(),
signing_kid: old_gen.signing_kid.clone(),
signing_key: old_signing.get_private_keymultibase().unwrap(),
}],
);
let current = secrets_for(&new_gen, &secrets);
assert_eq!(current.len(), 2);
assert!(current.iter().any(|s| s.id == new_gen.ka_kid));
let retired = secrets_for(&old_gen, &secrets);
assert_eq!(retired.len(), 2, "retired generation's keys must come back");
let ka = retired
.iter()
.find(|s| s.id == old_gen.ka_kid)
.expect("old ka secret");
assert_eq!(
ka.get_private_keymultibase().unwrap(),
old_ka.get_private_keymultibase().unwrap(),
"must be the *old* private key, not the new one"
);
}
#[tokio::test]
async fn a_retired_generation_with_no_stored_key_yields_nothing() {
let (_new_gen, new_signing, new_ka) = keyed_generation(1, "new");
let (mut old_gen, _, _) = keyed_generation(0, "old");
old_gen.retired_at = Some(500);
old_gen.expires_at = Some(4_100);
let secrets = server_secrets(&new_signing, &new_ka, Vec::new());
assert!(secrets_for(&old_gen, &secrets).is_empty());
}
#[tokio::test]
async fn the_sweep_reaps_an_expired_generation_and_stops_honouring_its_key() {
let store = fjall_store().await;
let ks = store.keyspace(KS_IDENTITY).expect("identity keyspace");
let (new_gen, new_signing, new_ka) = keyed_generation(1, "new");
let (mut old_gen, old_signing, old_ka) = keyed_generation(0, "old");
old_gen.retired_at = Some(1);
old_gen.expires_at = Some(2);
let retired_entry = RetiredKeys {
ka_kid: old_gen.ka_kid.clone(),
key_agreement_key: old_ka.get_private_keymultibase().unwrap(),
signing_kid: old_gen.signing_kid.clone(),
signing_key: old_signing.get_private_keymultibase().unwrap(),
};
let secret_store =
MockSecretStore::new(server_secrets(&new_signing, &new_ka, vec![retired_entry]));
save_current_generation(&store, &ks, &new_gen)
.await
.expect("save current");
ks.insert(gen_key(old_gen.id), &old_gen)
.await
.expect("save retired");
let identity = identity_with(
vec![new_gen.clone(), old_gen.clone()],
vec![
new_signing.clone(),
new_ka.clone(),
old_signing.clone(),
old_ka.clone(),
],
)
.await;
assert!(
identity
.secrets_resolver
.get_secret(&old_gen.ka_kid)
.await
.is_some()
);
let reaped = run_sweep_once(&identity, &store, &secret_store).await;
assert_eq!(reaped, 1);
assert!(
identity
.secrets_resolver
.get_secret(&old_gen.ka_kid)
.await
.is_none(),
"expired key must no longer be honoured"
);
assert!(
identity
.secrets_resolver
.get_secret(&new_gen.ka_kid)
.await
.is_some()
);
assert_eq!(identity.generations(), vec![new_gen]);
assert!(secret_store.snapshot().retired.is_empty());
assert!(
load_generations(&ks, now_epoch())
.await
.unwrap()
.iter()
.all(|g| g.id != old_gen.id)
);
}
#[tokio::test]
async fn establishing_generation_zero_re_keys_the_placeholder() {
let store = fjall_store().await;
let ks = store.keyspace(KS_IDENTITY).expect("identity keyspace");
let (real, signing, ka) = keyed_generation(0, "real");
let secret_store = MockSecretStore::new(server_secrets(&signing, &ka, Vec::new()));
let placeholder = IdentityGeneration {
signing_kid: format!("{DID}#key-0"),
ka_kid: format!("{DID}#key-1"),
..real.clone()
};
let placeholder_secrets = vec![
Secret::from_multibase(
&signing.get_private_keymultibase().unwrap(),
Some(&placeholder.signing_kid),
)
.unwrap(),
Secret::from_multibase(
&ka.get_private_keymultibase().unwrap(),
Some(&placeholder.ka_kid),
)
.unwrap(),
];
let identity = identity_with(vec![placeholder.clone()], placeholder_secrets).await;
establish_generation(&identity, &store, &ks, &real, &secret_store)
.await
.expect("establish");
assert!(
identity
.secrets_resolver
.get_secret(&real.ka_kid)
.await
.is_some(),
"the document's key-agreement kid must resolve after establishing"
);
assert!(
identity
.secrets_resolver
.get_secret(&placeholder.ka_kid)
.await
.is_none(),
"the guessed placeholder kid must be dropped, not left alongside"
);
let live = identity.generations();
assert_eq!(live.len(), 1, "establishing must not retire anything");
assert_eq!(live[0].id, 0);
assert!(live[0].retired_at.is_none());
assert_eq!(live[0].ka_kid, real.ka_kid);
assert_eq!(
load_generations(&ks, now_epoch()).await.unwrap(),
vec![real]
);
}
#[tokio::test]
async fn the_sweep_drops_a_generation_deleted_out_of_band() {
let store = fjall_store().await;
let ks = store.keyspace(KS_IDENTITY).expect("identity keyspace");
let (new_gen, new_signing, new_ka) = keyed_generation(1, "new");
let (mut old_gen, old_signing, old_ka) = keyed_generation(0, "old");
old_gen.retired_at = Some(now_epoch());
old_gen.expires_at = Some(now_epoch() + 3_600);
let secret_store = MockSecretStore::new(server_secrets(&new_signing, &new_ka, Vec::new()));
save_current_generation(&store, &ks, &new_gen)
.await
.expect("save current");
let identity = identity_with(
vec![new_gen.clone(), old_gen.clone()],
vec![new_signing, new_ka, old_signing, old_ka],
)
.await;
assert_eq!(run_sweep_once(&identity, &store, &secret_store).await, 1);
assert!(
identity
.secrets_resolver
.get_secret(&old_gen.ka_kid)
.await
.is_none(),
"a generation the store no longer vouches for must stop decrypting"
);
assert_eq!(identity.generations(), vec![new_gen]);
}
#[tokio::test]
async fn the_sweep_never_drops_the_current_generation() {
let store = fjall_store().await;
let (current, signing, ka) = keyed_generation(1, "cur");
let secret_store = MockSecretStore::new(server_secrets(&signing, &ka, Vec::new()));
let identity = identity_with(vec![current.clone()], vec![signing, ka]).await;
assert_eq!(run_sweep_once(&identity, &store, &secret_store).await, 0);
assert!(
identity
.secrets_resolver
.get_secret(¤t.ka_kid)
.await
.is_some(),
"the current generation must survive an empty store"
);
}
#[tokio::test]
async fn the_sweep_leaves_a_generation_inside_its_window() {
let store = fjall_store().await;
let ks = store.keyspace(KS_IDENTITY).expect("identity keyspace");
let (new_gen, new_signing, new_ka) = keyed_generation(1, "new");
let (mut old_gen, old_signing, old_ka) = keyed_generation(0, "old");
old_gen.retired_at = Some(now_epoch());
old_gen.expires_at = Some(now_epoch() + 3_600);
let secret_store = MockSecretStore::new(server_secrets(&new_signing, &new_ka, Vec::new()));
save_current_generation(&store, &ks, &new_gen)
.await
.expect("save current");
ks.insert(gen_key(old_gen.id), &old_gen)
.await
.expect("save retired");
let identity = identity_with(
vec![new_gen, old_gen.clone()],
vec![new_signing, new_ka, old_signing, old_ka],
)
.await;
assert_eq!(run_sweep_once(&identity, &store, &secret_store).await, 0);
assert!(
identity
.secrets_resolver
.get_secret(&old_gen.ka_kid)
.await
.is_some(),
"a generation inside its window must keep decrypting"
);
}
#[tokio::test]
async fn retire_now_drops_the_key_immediately() {
let store = fjall_store().await;
let ks = store.keyspace(KS_IDENTITY).expect("identity keyspace");
let (new_gen, new_signing, new_ka) = keyed_generation(1, "new");
let (mut old_gen, old_signing, old_ka) = keyed_generation(0, "old");
old_gen.retired_at = Some(now_epoch());
old_gen.expires_at = Some(now_epoch() + 3_600);
let secret_store = MockSecretStore::new(server_secrets(
&new_signing,
&new_ka,
vec![RetiredKeys {
ka_kid: old_gen.ka_kid.clone(),
key_agreement_key: old_ka.get_private_keymultibase().unwrap(),
signing_kid: old_gen.signing_kid.clone(),
signing_key: old_signing.get_private_keymultibase().unwrap(),
}],
));
ks.insert(gen_key(old_gen.id), &old_gen).await.unwrap();
let identity = identity_with(
vec![new_gen, old_gen.clone()],
vec![new_signing, new_ka, old_signing, old_ka],
)
.await;
retire_generation_now(&identity, &store, &secret_store, old_gen.id)
.await
.expect("retire now");
assert!(
identity
.secrets_resolver
.get_secret(&old_gen.ka_kid)
.await
.is_none(),
"the compromised key must stop decrypting immediately"
);
assert!(secret_store.snapshot().retired.is_empty());
}
#[tokio::test]
async fn retire_now_refuses_to_expire_the_current_generation() {
let store = fjall_store().await;
let (current, signing, ka) = keyed_generation(1, "cur");
let secret_store = MockSecretStore::new(server_secrets(&signing, &ka, Vec::new()));
let identity = identity_with(vec![current.clone()], vec![signing, ka.clone()]).await;
let err = retire_generation_now(&identity, &store, &secret_store, current.id)
.await
.expect_err("must refuse");
assert!(
format!("{err}").contains("current generation"),
"unexpected error: {err}"
);
assert!(
identity
.secrets_resolver
.get_secret(¤t.ka_kid)
.await
.is_some(),
"the current key must still be honoured after a refused retire"
);
}
#[tokio::test]
async fn the_half_rotation_guard_rejects_a_key_the_document_does_not_advertise() {
let advertised = Secret::generate_x25519(Some("did:x#ka"), None).expect("x25519");
let unrelated = Secret::generate_x25519(Some("did:x#ka"), None).expect("x25519");
let advertised_pk = advertised.get_public_keymultibase().unwrap();
assert!(secret_matches_document(&advertised, Some(&advertised_pk)));
assert!(
!secret_matches_document(&unrelated, Some(&advertised_pk)),
"a key that is not the private half of the advertised one must be rejected"
);
assert!(secret_matches_document(&unrelated, None));
}
#[test]
fn the_grace_period_parses_and_a_typo_fails_safe() {
use crate::server::config::IdentityConfig;
let cfg = IdentityConfig::default();
assert_eq!(cfg.rotation_grace_secs(), 3600, "default is 1h");
let cfg = IdentityConfig {
rotation_grace_period: "30m".into(),
..Default::default()
};
assert_eq!(cfg.rotation_grace_secs(), 1800);
let cfg = IdentityConfig {
rotation_grace_period: "0".into(),
..Default::default()
};
assert_eq!(cfg.rotation_grace_secs(), 0, "0 retires immediately");
let cfg = IdentityConfig {
rotation_grace_period: "1 fortnight".into(),
..Default::default()
};
assert_eq!(cfg.rotation_grace_secs(), 3600);
}
#[tokio::test]
async fn the_current_generation_sorts_first_even_when_it_is_not_the_newest() {
let store = fjall_store().await;
let ks = store.keyspace(KS_IDENTITY).expect("identity keyspace");
let older = generation(3, "did:webvh:example:alpha#z6LSthree");
let newer = generation(9, "did:webvh:example:alpha#z6LSnine");
ks.insert(gen_key(newer.id), &newer).await.expect("save");
save_current_generation(&store, &ks, &older)
.await
.expect("save current");
let live = load_generations(&ks, 1_000).await.expect("load");
assert_eq!(
live[0].id, 3,
"the *current* generation leads, not the newest"
);
}
#[test]
fn kids_from_document_extracts_the_did_document_shape() {
let did = "did:webvh:QmScid:host.example:node";
let doc = serde_json::json!({
"id": did,
"verificationMethod": [
{ "id": format!("{did}#key-0"), "type": "Multikey", "publicKeyMultibase": "z6MkSign" },
{ "id": format!("{did}#key-1"), "type": "Multikey", "publicKeyMultibase": "z6LSAgree" },
],
"authentication": [format!("{did}#key-0")],
"assertionMethod": [format!("{did}#key-0")],
"keyAgreement": [format!("{did}#key-1")],
});
let got = kids_from_document(did, &doc).expect("has relationships");
assert_eq!(got.signing_kid, format!("{did}#key-0"));
assert_eq!(got.ka_kid, format!("{did}#key-1"));
assert_eq!(got.ka_public_multibase.as_deref(), Some("z6LSAgree"));
}
#[test]
fn kids_from_document_none_without_relationships() {
let doc = serde_json::json!({ "id": "did:webvh:Q:h:n", "service": [] });
assert!(kids_from_document("did:webvh:Q:h:n", &doc).is_none());
}
#[test]
fn a_protocols_change_is_metadata_not_a_key_change() {
let (mut a, _s, _k) = keyed_generation(1, "x");
a.protocols = ProtocolSet {
didcomm: true,
tsp: false,
};
let mut b = a.clone();
b.protocols = ProtocolSet {
didcomm: true,
tsp: true,
};
assert!(
a.differs_from(&b),
"a protocols change is a real difference"
);
assert_eq!(a.signing_kid, b.signing_kid);
assert_eq!(a.ka_kid, b.ka_kid);
assert!(
!a.key_material_differs_from(&b),
"no key material changed — so it must not retire/rotate"
);
}
#[tokio::test]
async fn metadata_update_keeps_retired_generation_and_its_key() {
let store = fjall_store().await;
let ks = store.keyspace(KS_IDENTITY).expect("identity keyspace");
let (current, cur_signing, cur_ka) = keyed_generation(1, "cur");
let (mut retired, ret_signing, ret_ka) = keyed_generation(0, "ret");
retired.retired_at = Some(now_epoch());
retired.expires_at = Some(now_epoch() + 3_600);
let identity = identity_with(
vec![current.clone(), retired.clone()],
vec![cur_signing, cur_ka, ret_signing, ret_ka],
)
.await;
save_current_generation(&store, &ks, ¤t)
.await
.expect("save current");
let updated = IdentityGeneration {
protocols: ProtocolSet {
didcomm: true,
tsp: true,
},
..current.clone()
};
update_current_generation_in_place(&identity, &store, &ks, &updated)
.await
.expect("in-place update");
let live = identity.generations();
assert!(
live.iter()
.any(|g| g.id == retired.id && g.retired_at.is_some()),
"the retired generation must survive — its premature retirement is the bug"
);
assert!(
identity
.secrets_resolver
.get_secret(&retired.ka_kid)
.await
.is_some(),
"the retired key must still decrypt"
);
let cur = live
.iter()
.find(|g| g.id == current.id)
.expect("current present");
assert!(cur.protocols.tsp, "protocols were updated");
assert_eq!(
cur.ka_kid, current.ka_kid,
"the key-agreement kid is unchanged"
);
assert!(
identity
.secrets_resolver
.get_secret(¤t.ka_kid)
.await
.is_some(),
"the current key is still usable"
);
}
#[tokio::test]
async fn the_sweep_never_strips_a_key_a_survivor_still_uses() {
let store = fjall_store().await;
let ks = store.keyspace(KS_IDENTITY).expect("identity keyspace");
let (current, signing, ka) = keyed_generation(1, "shared");
let mut retired = current.clone();
retired.id = 0;
retired.retired_at = Some(1);
retired.expires_at = Some(2);
let secret_store = MockSecretStore::new(server_secrets(&signing, &ka, Vec::new()));
save_current_generation(&store, &ks, ¤t)
.await
.expect("save current");
ks.insert(gen_key(retired.id), &retired)
.await
.expect("save retired");
let identity =
identity_with(vec![current.clone(), retired.clone()], vec![signing, ka]).await;
assert_eq!(
run_sweep_once(&identity, &store, &secret_store).await,
0,
"must not expire a retired generation that shares the current key-agreement kid"
);
assert!(
identity
.secrets_resolver
.get_secret(¤t.ka_kid)
.await
.is_some(),
"the key the current generation is using must survive the sweep"
);
}
}