const REPLACE_DID_LOCAL_WRITES: &[&str] = &[
"index.json",
"identity.json",
"auth.json",
"did_document.json",
"key-1-private.pem",
"key-1-public.pem",
"e2ee-signing-private.pem",
"e2ee-agreement-private.pem",
".legacy-backup/replace-did",
"sqlite.identity_did_history",
"sqlite.owner_did_snapshot_refresh",
];
#[cfg(feature = "sqlite")]
const REPLACE_DID_REBIND_TABLES: &[&str] = &[
"messages",
"contacts",
"contact_handle_bindings",
"relationship_events",
"groups",
"group_members",
];
#[cfg(feature = "sqlite")]
const REPLACE_DID_E2EE_TABLES: &[&str] = &["e2ee_outbox", "e2ee_sessions"];
pub(crate) fn plan_replace_did_for_client(
client: &crate::core::ImClient,
mut request: crate::identity::ReplaceDidPlanRequest,
) -> crate::ImResult<crate::identity::ReplaceDidPlan> {
validate_plan_request(&request)?;
request.affected_local_state = plan_replace_did_local_state_rebind(
&client.core_inner().sdk_paths().local_state.sqlite_path,
request.identity.did.as_str(),
request.planned_new_did.as_str(),
)?;
plan_replace_did(request)
}
pub(crate) fn plan_replace_did(
request: crate::identity::ReplaceDidPlanRequest,
) -> crate::ImResult<crate::identity::ReplaceDidPlan> {
validate_plan_request(&request)?;
let call = crate::internal::identity_wire::replace_did::build_replace_did_rpc_call(
crate::internal::identity_wire::ReplaceDidRpcParams {
new_did_document: serde_json::json!("generated_e1_document"),
is_public: request.is_public,
is_agent: request.is_agent,
role: request.role,
endpoint_url: request.endpoint_url,
},
);
Ok(crate::identity::ReplaceDidPlan {
action: "replace_did".to_string(),
identity: request.identity.clone(),
dangerous: true,
risk_summary: vec![
"Replaces the selected handle identity DID and local key material.".to_string(),
"Requires a local backup before any execution path may continue.".to_string(),
"Local SQLite DID history and owner_did snapshots are refreshed under the same owner identity."
.to_string(),
],
backup_plan: crate::identity::ReplaceDidBackupPlan {
required: true,
backup_path_preview: request.backup_path_preview.clone(),
manifest_preview: crate::identity::ReplaceDidBackupManifestPreview {
reason: "replace_did".to_string(),
identity_name: identity_name(&request.identity),
linked_identity_names: request.linked_identity_names.clone(),
old_did: request.identity.did.clone(),
old_dir_name: request.old_dir_name,
planned_new_did: request.planned_new_did.clone(),
},
},
local_rebind_plan: crate::identity::ReplaceDidLocalRebindPlan {
required: request.identity.did != request.planned_new_did,
old_owner_did: request.identity.did.clone(),
new_owner_did: request.planned_new_did.clone(),
destructive: false,
dry_run_only: true,
},
affected_local_state: request.affected_local_state,
remote_replace_did_call_preview: crate::identity::ReplaceDidRemoteCallPreview {
endpoint: call.endpoint.to_string(),
method: call.method.to_string(),
params: call.params,
},
rollback_notes: vec![
"Do not execute remote replace_did unless the backup manifest has been written."
.to_string(),
"If later execution fails after the remote call, restore the identity backup and inspect local DID history/snapshot counts."
.to_string(),
],
local_writes: REPLACE_DID_LOCAL_WRITES
.iter()
.map(|item| (*item).to_string())
.collect(),
warnings: Vec::new(),
})
}
#[cfg(feature = "sqlite")]
pub(crate) fn plan_replace_did_local_state_rebind(
sqlite_path: &std::path::Path,
old_owner_did: &str,
new_owner_did: &str,
) -> crate::ImResult<crate::identity::ReplaceDidAffectedLocalState> {
let _ = (sqlite_path, old_owner_did, new_owner_did);
Ok(crate::identity::ReplaceDidAffectedLocalState {
store_rebind_counts: zero_counts(REPLACE_DID_REBIND_TABLES),
e2ee_cleanup_counts: zero_counts(REPLACE_DID_E2EE_TABLES),
})
}
#[cfg(not(feature = "sqlite"))]
pub(crate) fn plan_replace_did_local_state_rebind(
_sqlite_path: &std::path::Path,
_old_owner_did: &str,
_new_owner_did: &str,
) -> crate::ImResult<crate::identity::ReplaceDidAffectedLocalState> {
Ok(crate::identity::ReplaceDidAffectedLocalState::default())
}
#[cfg(feature = "sqlite")]
fn zero_counts(tables: &[&str]) -> std::collections::BTreeMap<String, i64> {
tables
.iter()
.map(|table| ((*table).to_string(), 0))
.collect()
}
pub(crate) fn validate_plan_request(
request: &crate::identity::ReplaceDidPlanRequest,
) -> crate::ImResult<()> {
if request.identity.did.as_str().trim().is_empty() {
return Err(crate::ImError::invalid_input(
Some("identity.did".to_string()),
"identity DID is required",
));
}
if request.planned_new_did.as_str().trim().is_empty() {
return Err(crate::ImError::invalid_input(
Some("planned_new_did".to_string()),
"planned new DID is required",
));
}
if request.backup_path_preview.trim().is_empty() {
return Err(crate::ImError::invalid_input(
Some("backup_path_preview".to_string()),
"backup path preview is required",
));
}
Ok(())
}
pub(crate) fn validate_request_shape(
plan: &crate::identity::ReplaceDidPlan,
) -> crate::ImResult<()> {
if plan.action != "replace_did" {
return Err(crate::ImError::invalid_input(
Some("plan.action".to_string()),
"replace DID execution requires a replace_did plan",
));
}
if !plan.dangerous {
return Err(crate::ImError::invalid_input(
Some("plan.dangerous".to_string()),
"replace DID execution requires a dangerous plan",
));
}
if plan.backup_plan.backup_path_preview.trim().is_empty() {
return Err(crate::ImError::invalid_input(
Some("plan.backup_plan.backup_path_preview".to_string()),
"backup path preview is required",
));
}
if plan.identity.did == plan.local_rebind_plan.new_owner_did {
return Err(crate::ImError::invalid_input(
Some("plan.local_rebind_plan.new_owner_did".to_string()),
"replacement DID must differ from the current DID",
));
}
Ok(())
}
fn identity_name(identity: &crate::identity::IdentitySummary) -> String {
identity
.local_alias
.clone()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| identity.id.as_str().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
#[test]
fn replace_did_plan_contains_backup_remote_rebind_and_counts() {
let mut store_counts = BTreeMap::new();
store_counts.insert("messages".to_string(), 2);
let mut e2ee_counts = BTreeMap::new();
e2ee_counts.insert("e2ee_sessions".to_string(), 1);
let plan = plan_replace_did(crate::identity::ReplaceDidPlanRequest {
identity: identity_summary(),
linked_identity_names: vec!["alice".to_string(), "alias".to_string()],
planned_new_did: crate::ids::Did::parse("did:wba:awiki.test:alice:e1_new").unwrap(),
backup_path_preview: "/tmp/.legacy-backup/replace-did/<timestamp>-alice".to_string(),
old_dir_name: "e1_old".to_string(),
is_public: Some(false),
is_agent: None,
role: Some(String::new()),
endpoint_url: Some("https://example.test/agent".to_string()),
affected_local_state: crate::identity::ReplaceDidAffectedLocalState {
store_rebind_counts: store_counts,
e2ee_cleanup_counts: e2ee_counts,
},
})
.unwrap();
assert_eq!(plan.action, "replace_did");
assert!(plan.dangerous);
assert!(plan.backup_plan.required);
assert_eq!(plan.backup_plan.manifest_preview.reason, "replace_did");
assert_eq!(plan.remote_replace_did_call_preview.method, "replace_did");
assert_eq!(
plan.remote_replace_did_call_preview.params["is_public"],
false
);
assert_eq!(
plan.remote_replace_did_call_preview.params["role"],
serde_json::Value::Null
);
assert_eq!(
plan.remote_replace_did_call_preview.params["endpoint_url"],
"https://example.test/agent"
);
assert!(plan.local_rebind_plan.dry_run_only);
assert_eq!(plan.affected_local_state.store_rebind_counts["messages"], 2);
assert_eq!(
plan.affected_local_state.e2ee_cleanup_counts["e2ee_sessions"],
1
);
assert!(plan
.local_writes
.contains(&".legacy-backup/replace-did".to_string()));
assert!(plan
.rollback_notes
.iter()
.any(|note| note.contains("backup manifest")));
}
#[cfg(feature = "sqlite")]
#[test]
fn replace_did_service_does_not_count_business_rows_by_owner_did() {
let root = tempfile::TempDir::new().unwrap();
let core = crate::core::ImCore::new(
crate::config::ImCoreConfig {
service_base_url: crate::config::ServiceEndpoint::parse("https://example.test")
.unwrap(),
did_domain: "awiki.test".to_string(),
user_service_endpoint: None,
message_service_endpoint: None,
mail_service_endpoint: None,
anp_service_endpoint: None,
anp_service_did: None,
ca_bundle: None,
transport_policy: crate::config::MessageTransportPolicy::HttpOnly,
},
crate::paths::ImCorePaths {
identities: crate::paths::IdentityRegistryPaths {
identity_root_dir: root.path().join("identities"),
registry_path: root.path().join("identities").join("registry.json"),
default_identity_path: Some(root.path().join("identities").join("default")),
},
local_state: crate::paths::LocalStatePaths {
sqlite_path: root.path().join("local").join("im.sqlite"),
},
runtime: crate::paths::RuntimePaths {
cache_dir: root.path().join("cache"),
temp_dir: root.path().join("tmp"),
},
},
)
.unwrap();
let client = core
.client(crate::identity::IdentitySelector::Did(
crate::ids::Did::parse("did:wba:awiki.test:alice:e1_old").unwrap(),
))
.unwrap();
let sqlite_path = &client.core_inner().sdk_paths().local_state.sqlite_path;
let connection = crate::internal::local_state::open_writable(sqlite_path).unwrap();
connection
.execute(
"INSERT INTO messages (msg_id, owner_identity_id, owner_did, conversation_id, thread_id, stored_at)
VALUES (?1, ?2, ?3, ?4, ?4, ?5)",
rusqlite::params![
"replace-did-msg-1",
"alice-id",
"did:wba:awiki.test:alice:e1_old",
"dm:did:example:bob",
"2026-05-25T00:00:00Z",
],
)
.unwrap();
drop(connection);
let mut request = crate::identity::ReplaceDidPlanRequest {
identity: identity_summary(),
linked_identity_names: vec!["alice".to_string()],
planned_new_did: crate::ids::Did::parse("did:wba:awiki.test:alice:e1_new").unwrap(),
backup_path_preview: "/tmp/.legacy-backup/replace-did/<timestamp>-alice".to_string(),
old_dir_name: "e1_old".to_string(),
is_public: Some(false),
is_agent: None,
role: None,
endpoint_url: Some("https://example.test/agent".to_string()),
affected_local_state: crate::identity::ReplaceDidAffectedLocalState::default(),
};
let plan = client.identity().replace_did_plan(request.clone()).unwrap();
assert_eq!(plan.affected_local_state.store_rebind_counts["messages"], 0);
assert_eq!(
plan.affected_local_state.e2ee_cleanup_counts["e2ee_sessions"],
0
);
request.planned_new_did = request.identity.did.clone();
let same_did_plan = client.identity().replace_did_plan(request).unwrap();
assert_eq!(
same_did_plan.affected_local_state.store_rebind_counts["messages"],
0
);
assert_eq!(
same_did_plan.affected_local_state.e2ee_cleanup_counts["e2ee_sessions"],
0
);
}
fn identity_summary() -> crate::identity::IdentitySummary {
crate::identity::IdentitySummary {
id: crate::ids::IdentityId::parse("alice-id").unwrap(),
did: crate::ids::Did::parse("did:wba:awiki.test:alice:e1_old").unwrap(),
handle: Some(crate::ids::Handle::parse("alice.awiki.test", "").unwrap()),
display_name: Some("Alice".to_string()),
local_alias: Some("alice".to_string()),
device_id: None,
is_default: true,
readiness: crate::identity::IdentityReadiness {
ready_for_auth: true,
ready_for_messaging: true,
missing: Vec::new(),
},
}
}
}