use std::time::Duration;
use affinidi_messaging_didcomm_service::{
DIDCommService, ListenerConfig, Protocols, RestartPolicy, RetryConfig,
};
use did_hosting_common::server::didcomm_profile::build_tdk_profile_for_identity;
use did_hosting_common::server::identity::{
self, DEFAULT_RELOAD_INTERVAL, DEFAULT_SWEEP_INTERVAL, IdentityGeneration, ReloadOutcome,
mnemonic_from_did,
};
use did_hosting_common::server::identity_drain;
use tokio::sync::watch;
use tokio_util::sync::CancellationToken;
use tracing::{debug, error, info, warn};
use crate::error::AppError;
use crate::messaging;
use crate::secret_store::create_secret_store;
use crate::server::AppState;
const LISTENER_ID: &str = "server";
pub const SWEEP_INTERVAL: Duration = DEFAULT_SWEEP_INTERVAL;
pub const RELOAD_INTERVAL: Duration = DEFAULT_RELOAD_INTERVAL;
pub async fn on_did_published(state: &AppState, mnemonic: &str) {
let Some(server_did) = state.config.server_did.as_deref() else {
return;
};
let Some(ours) = mnemonic_from_did(server_did) else {
return;
};
if ours != mnemonic {
return;
}
info!(
mnemonic,
"our own DID changed — checking for an identity rotation"
);
if let Err(e) = reload_now(state).await {
error!("identity reload failed: {e}");
}
}
pub async fn reload_now(state: &AppState) -> Result<(), AppError> {
let Some(identity) = state.identity.as_ref() else {
return Ok(());
};
let secret_store = create_secret_store(&state.config)?;
let outcome = identity::reload_service_identity(
identity,
&state.store,
secret_store.as_ref(),
state.config.mediator_did.as_deref(),
identity::ProtocolSet {
didcomm: state.config.features.didcomm,
tsp: state.config.features.tsp,
},
state.config.identity.rotation_grace_secs(),
)
.await?;
match outcome {
ReloadOutcome::Unchanged => debug!("service identity unchanged"),
ReloadOutcome::MetadataUpdated { generation } => {
info!(
generation,
"service identity metadata updated (no key rotation) — rebuilding listener"
);
rebuild_listener(state).await?;
}
ReloadOutcome::Established { generation } => {
info!(
generation,
"service identity established — rebuilding listener"
);
rebuild_listener(state).await?;
}
ReloadOutcome::RotatedWithoutOverlap { generation, ka_kid } => {
error!(
generation,
ka_kid = %ka_kid,
"identity rotated WITHOUT a grace period — the new key-agreement key reuses the \
same verification-method id, and a kid identifies exactly one key, so messages \
already encrypted to the previous key CANNOT be decrypted. Peers holding a stale \
DID document cannot reach this service until their cache expires. Rotate onto a \
NEW fragment for a seamless cutover."
);
rebuild_listener(state).await?;
}
ReloadOutcome::Unresolvable => {
warn!("could not resolve our own DID document — identity left as-is");
}
ReloadOutcome::Refused { reason } => {
error!(
"REFUSED to rotate the service identity: {reason}. The published DID document and \
the secret store disagree — the service is still using the previous key. Write \
the new key to the secret store, then publish the DID."
);
}
ReloadOutcome::Rotated {
new_generation,
retired_generation,
expires_at,
} => {
info!(
new_generation,
retired_generation, expires_at, "service identity rotated — rebuilding listener"
);
rebuild_listener(state).await?;
if let Some(retired) = identity
.generations()
.into_iter()
.find(|g| g.id == retired_generation)
{
spawn_mediator_drain(state, retired);
}
}
}
Ok(())
}
async fn rebuild_listener(state: &AppState) -> Result<(), AppError> {
let Some(svc) = state.didcomm_service.get() else {
debug!("no messaging service running — nothing to rebuild");
return Ok(());
};
let Some(identity) = state.identity.as_ref() else {
return Ok(());
};
let Some(mediator_did) = identity.mediator_did() else {
return Ok(());
};
let profile =
build_tdk_profile_for_identity(LISTENER_ID, identity, Some(&mediator_did)).await?;
let transports = identity.protocols();
let protocols = match (transports.didcomm, transports.tsp) {
(true, true) => Protocols::BOTH,
(false, true) => Protocols::TSP_ONLY,
_ => Protocols::DIDCOMM_ONLY,
};
if let Err(e) = svc.remove_listener(LISTENER_ID).await {
warn!("failed to remove the old listener: {e}");
}
svc.add_listener(ListenerConfig {
id: LISTENER_ID.into(),
profile,
restart_policy: RestartPolicy::Always {
backoff: RetryConfig::default(),
},
auto_delete: true,
protocols,
..Default::default()
})
.await
.map_err(|e| AppError::Internal(format!("failed to restart the DIDComm listener: {e}")))?;
info!(
didcomm = transports.didcomm,
tsp = transports.tsp,
"DIDComm listener rebuilt on the new identity"
);
Ok(())
}
pub fn spawn_mediator_drain(state: &AppState, generation: IdentityGeneration) {
let Some(identity) = state.identity.clone() else {
return;
};
if !identity_drain::needs_drain(&identity, &generation) {
return;
}
let Some(listener) = identity_drain::drain_listener_config("server", &identity, &generation)
else {
return;
};
let router = match messaging::build_server_router(state.clone()) {
Ok(r) => r,
Err(e) => {
identity_drain::warn_drain_failed(&generation, &e.to_string());
return;
}
};
let tsp_enabled = generation.protocols.tsp;
let drain_state = state.clone();
let mediator = generation.mediator_did.clone().unwrap_or_default();
let generation_id = generation.id;
tokio::spawn(async move {
info!(
generation = generation_id,
mediator = %mediator,
"connecting to the old mediator to drain messages from peers with a stale DID document"
);
let shutdown = CancellationToken::new();
let config = identity_drain::drain_service_config(listener);
let svc = if tsp_enabled {
DIDCommService::start_with_tsp(
config,
router,
crate::tsp::ServerTspHandler::new(drain_state),
shutdown.clone(),
)
.await
} else {
DIDCommService::start(config, router, shutdown.clone()).await
};
let svc = match svc {
Ok(svc) => svc,
Err(e) => {
identity_drain::warn_drain_failed(&generation, &e.to_string());
return;
}
};
identity_drain::wait_until_generation_retires(identity, generation_id).await;
shutdown.cancel();
svc.shutdown().await;
info!(
generation = generation_id,
mediator = %mediator,
"old-mediator drain stopped"
);
});
}
pub fn resume_mediator_drains(state: &AppState) {
let Some(identity) = state.identity.as_ref() else {
return;
};
for generation in identity_drain::generations_needing_drain(identity) {
spawn_mediator_drain(state, generation);
}
}
pub async fn expire_due(state: &AppState) {
let Some(identity) = state.identity.as_ref() else {
return;
};
let secret_store = match create_secret_store(&state.config) {
Ok(s) => s,
Err(e) => {
warn!("identity sweep: could not open the secret store: {e}");
return;
}
};
let reaped = identity::run_sweep_once(identity, &state.store, secret_store.as_ref()).await;
if reaped > 0 {
if let Err(e) = rebuild_listener(state).await {
error!("failed to rebuild the listener after expiring {reaped} generation(s): {e}");
}
}
}
pub async fn run_identity_sweep_loop(state: AppState, mut shutdown: watch::Receiver<bool>) {
let mut expiry = tokio::time::interval(SWEEP_INTERVAL);
let mut reload = tokio::time::interval(RELOAD_INTERVAL);
expiry.tick().await;
reload.tick().await;
loop {
tokio::select! {
_ = expiry.tick() => expire_due(&state).await,
_ = reload.tick() => {
if let Err(e) = reload_now(&state).await {
debug!("identity backstop reload failed: {e}");
}
}
_ = shutdown.changed() => break,
}
}
}
pub async fn retire_generation_now(state: &AppState, generation_id: u64) -> Result<(), AppError> {
let Some(identity) = state.identity.as_ref() else {
return Err(AppError::Config("no service identity loaded".into()));
};
let secret_store = create_secret_store(&state.config)?;
identity::retire_generation_now(identity, &state.store, secret_store.as_ref(), generation_id)
.await?;
rebuild_listener(state).await?;
warn!(
generation_id,
"identity generation retired immediately — messages still addressed to its \
key-agreement key will no longer decrypt"
);
Ok(())
}