use affinidi_tdk::secrets_resolver::secrets::Secret;
use did_hosting_common::did::{
DidDocumentOptions, build_did_document, create_log_entry, encode_host,
};
use tracing::info;
use crate::did_ops::{
DidRecord, content_log_key, content_witness_key, did_key, extract_did_id,
extract_service_types, owner_key, validate_did_jsonl,
};
use crate::error::AppError;
use crate::store::{KeyspaceHandle, Store};
use did_hosting_common::server::auth::session::now_epoch;
#[derive(Debug)]
pub struct BootstrapResult {
pub scid: String,
pub did_id: String,
pub jsonl: String,
pub mnemonic: String,
}
pub async fn request_witness_proof(
witness_url: &str,
witness_did: &str,
witness_id: &str,
result: &BootstrapResult,
signing_secret: &Secret,
) -> Result<String, AppError> {
let version_id = result
.jsonl
.lines()
.rfind(|l| !l.trim().is_empty())
.and_then(|line| serde_json::from_str::<serde_json::Value>(line).ok())
.and_then(|v| v.get("versionId")?.as_str().map(String::from))
.ok_or_else(|| AppError::Internal("the DID log has no versionId to witness".into()))?;
let mut signer = signing_secret.clone();
signer.id = format!("{}#key-0", result.did_id);
let resolver = affinidi_did_resolver_cache_sdk::DIDCacheClient::new(
affinidi_did_resolver_cache_sdk::config::DIDCacheConfigBuilder::default().build(),
)
.await
.map_err(|e| AppError::Internal(format!("DID resolver: {e}")))?;
let client = did_hosting_common::WitnessClient::new(
witness_url,
witness_did,
&result.did_id,
signer,
resolver,
);
let signed = client
.sign(witness_id, &version_id, &result.jsonl)
.await
.map_err(|e| AppError::Internal(format!("witness/sign: {e}")))?;
let witness_file = serde_json::json!([{
"versionId": signed.version_id.as_str(),
"proof": [signed.proof],
}]);
Ok(witness_file.to_string())
}
pub async fn root_did_exists(dids_ks: &KeyspaceHandle) -> Result<bool, AppError> {
dids_ks.contains_key(did_key(".well-known")).await
}
pub async fn bootstrap_root_did(
store: &Store,
dids_ks: &KeyspaceHandle,
signing_secret: &Secret,
ka_secret: Option<&Secret>,
mediator_did: Option<&str>,
public_url: &str,
) -> Result<BootstrapResult, AppError> {
bootstrap_did(
store,
dids_ks,
signing_secret,
ka_secret,
mediator_did,
true,
true,
public_url,
".well-known",
)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn bootstrap_did(
store: &Store,
dids_ks: &KeyspaceHandle,
signing_secret: &Secret,
ka_secret: Option<&Secret>,
mediator_did: Option<&str>,
advertise_didcomm: bool,
advertise_tsp: bool,
public_url: &str,
mnemonic: &str,
) -> Result<BootstrapResult, AppError> {
if dids_ks.contains_key(did_key(mnemonic)).await? {
return Err(AppError::Conflict(format!(
"DID at path '{mnemonic}' already exists"
)));
}
let host = encode_host(public_url)
.map_err(|e| AppError::Config(format!("failed to encode host from public_url: {e}")))?;
let public_key = signing_secret
.get_public_keymultibase()
.map_err(|e| AppError::Internal(format!("failed to get public key multibase: {e}")))?;
let ka_public_key = ka_secret
.map(|s| s.get_public_keymultibase())
.transpose()
.map_err(|e| AppError::Internal(format!("failed to get KA public key multibase: {e}")))?;
let doc = build_did_document(
&host,
mnemonic,
&public_key,
&DidDocumentOptions {
key_agreement_multibase: ka_public_key.as_deref(),
mediator_endpoint: if advertise_didcomm {
mediator_did
} else {
None
},
tsp_endpoint: if advertise_tsp { mediator_did } else { None },
},
);
let (scid, jsonl) = create_log_entry(&doc, signing_secret)
.await
.map_err(|e| AppError::Internal(format!("failed to create log entry: {e}")))?;
let did_id = extract_did_id(&jsonl)
.ok_or_else(|| AppError::Internal("failed to extract DID id from log entry".into()))?;
let mnemonic = mnemonic.to_string();
let now = now_epoch();
let record = DidRecord {
owner: "system".to_string(),
mnemonic: mnemonic.clone(),
created_at: now,
updated_at: now,
version_count: 1,
did_id: Some(did_id.clone()),
content_size: jsonl.len() as u64,
disabled: false,
deleted_at: None,
method: "webvh".to_string(),
domain: String::new(),
services: extract_service_types(&jsonl),
agent_names: Vec::new(),
};
let mut batch = store.batch();
batch.insert(dids_ks, did_key(&mnemonic), &record)?;
batch.insert_raw(
dids_ks,
content_log_key(&mnemonic),
jsonl.as_bytes().to_vec(),
);
batch.insert_raw(
dids_ks,
owner_key("system", &mnemonic),
mnemonic.as_bytes().to_vec(),
);
batch.commit().await?;
info!(did = %did_id, scid = %scid, path = %mnemonic, "DID bootstrapped");
Ok(BootstrapResult {
scid,
did_id,
jsonl,
mnemonic,
})
}
pub async fn import_root_did(
store: &Store,
dids_ks: &KeyspaceHandle,
jsonl: &str,
witness_content: Option<&str>,
) -> Result<BootstrapResult, AppError> {
if root_did_exists(dids_ks).await? {
return Err(AppError::Conflict(
"root DID (.well-known) already exists".into(),
));
}
import_did_at_path(store, dids_ks, ".well-known", jsonl, witness_content).await
}
#[derive(Debug)]
pub enum OwnDidImport {
Imported(BootstrapResult),
AlreadyPresent { did_id: String },
HeldByAnother {
existing: Option<String>,
minted: String,
},
}
pub async fn import_own_did(
store: &Store,
dids_ks: &KeyspaceHandle,
mnemonic: &str,
jsonl: &str,
) -> Result<OwnDidImport, AppError> {
let minted = extract_did_id(jsonl)
.ok_or_else(|| AppError::Validation("could not extract DID id from did.jsonl".into()))?;
if let Some(existing) = dids_ks.get::<DidRecord>(did_key(mnemonic)).await? {
return Ok(if existing.did_id.as_deref() == Some(minted.as_str()) {
OwnDidImport::AlreadyPresent { did_id: minted }
} else {
OwnDidImport::HeldByAnother {
existing: existing.did_id,
minted,
}
});
}
import_did_at_path(store, dids_ks, mnemonic, jsonl, None)
.await
.map(OwnDidImport::Imported)
}
pub async fn replace_own_did(
store: &Store,
dids_ks: &KeyspaceHandle,
mnemonic: &str,
jsonl: &str,
) -> Result<BootstrapResult, AppError> {
if let Some(existing) = dids_ks.get::<DidRecord>(did_key(mnemonic)).await? {
let mut batch = store.batch();
batch.remove(dids_ks, did_key(mnemonic));
batch.remove(dids_ks, content_log_key(mnemonic));
batch.remove(dids_ks, content_witness_key(mnemonic));
batch.remove(dids_ks, owner_key(&existing.owner, mnemonic));
batch.commit().await?;
}
import_did_at_path(store, dids_ks, mnemonic, jsonl, None).await
}
pub fn stale_own_did_message(path: &str, existing: Option<&str>, minted: &str) -> String {
format!(
"the store already holds a different DID at '{path}' ({existing}), not the one this \
setup minted ({minted}). This usually means `public_url` changed: the old DID is \
still bound to the previous hostname, and serving it from the new one returns 404.\n\
Remove just that DID and re-run setup:\n \
did-hosting-server remove-did --path {path}\n\
(Deleting the data directory also clears it, but removes every DID this daemon \
hosts, not only its own.)",
existing = existing.unwrap_or("an unidentified DID"),
)
}
pub async fn import_did_at_path(
store: &Store,
dids_ks: &KeyspaceHandle,
mnemonic: &str,
jsonl: &str,
witness_content: Option<&str>,
) -> Result<BootstrapResult, AppError> {
if dids_ks.contains_key(did_key(mnemonic)).await? {
return Err(AppError::Conflict(format!(
"DID at path '{mnemonic}' already exists"
)));
}
validate_did_jsonl(jsonl)?;
let did_id = extract_did_id(jsonl)
.ok_or_else(|| AppError::Validation("could not extract DID id from did.jsonl".into()))?;
let scid = did_id
.strip_prefix("did:webvh:")
.and_then(|rest| rest.split(':').next())
.ok_or_else(|| AppError::Validation("could not extract SCID from DID id".into()))?
.to_string();
if let Some(witness) = witness_content {
serde_json::from_str::<serde_json::Value>(witness).map_err(|e| {
AppError::Validation(format!("did-witness.json must be valid JSON: {e}"))
})?;
}
let mnemonic_str = mnemonic.to_string();
let now = now_epoch();
let version_count = jsonl.lines().filter(|l| !l.trim().is_empty()).count() as u64;
let record = DidRecord {
owner: "system".to_string(),
mnemonic: mnemonic_str.clone(),
created_at: now,
updated_at: now,
version_count,
did_id: Some(did_id.clone()),
content_size: jsonl.len() as u64,
disabled: false,
deleted_at: None,
method: "webvh".to_string(),
domain: String::new(),
services: extract_service_types(jsonl),
agent_names: Vec::new(),
};
let mut batch = store.batch();
batch.insert(dids_ks, did_key(&mnemonic_str), &record)?;
batch.insert_raw(
dids_ks,
content_log_key(&mnemonic_str),
jsonl.as_bytes().to_vec(),
);
batch.insert_raw(
dids_ks,
owner_key("system", &mnemonic_str),
mnemonic_str.as_bytes().to_vec(),
);
if let Some(witness) = witness_content {
batch.insert_raw(
dids_ks,
content_witness_key(&mnemonic_str),
witness.as_bytes().to_vec(),
);
}
batch.commit().await?;
info!(did = %did_id, scid = %scid, path = %mnemonic_str, "DID imported from files");
Ok(BootstrapResult {
scid,
did_id,
jsonl: jsonl.to_string(),
mnemonic: mnemonic_str,
})
}
#[cfg(test)]
mod own_did_import_tests {
use super::*;
use did_hosting_common::server::config::StoreConfig;
use did_hosting_common::server::store::KS_DIDS;
async fn store() -> (tempfile::TempDir, Store, KeyspaceHandle) {
let dir = tempfile::tempdir().expect("tempdir");
let store = Store::open(&StoreConfig {
data_dir: dir.path().to_path_buf(),
..StoreConfig::default()
})
.await
.expect("open store");
let ks = store.keyspace(KS_DIDS).expect("dids keyspace");
(dir, store, ks)
}
async fn minted_for(public_url: &str) -> String {
use did_hosting_common::did::{build_did_document, create_log_entry, encode_host};
let secret = affinidi_tdk::secrets_resolver::secrets::Secret::generate_ed25519(None, None);
let pk = secret.get_public_keymultibase().unwrap();
let host = encode_host(public_url).unwrap();
let doc = build_did_document(&host, ".well-known", &pk, &Default::default());
create_log_entry(&doc, &secret).await.unwrap().1
}
#[tokio::test]
async fn a_free_path_imports() {
let (_dir, store, ks) = store().await;
let jsonl = minted_for("http://localhost:3000").await;
assert!(matches!(
import_own_did(&store, &ks, ".well-known", &jsonl)
.await
.unwrap(),
OwnDidImport::Imported(_)
));
}
#[tokio::test]
async fn the_same_did_again_is_a_re_run_not_a_conflict() {
let (_dir, store, ks) = store().await;
let jsonl = minted_for("http://localhost:3000").await;
import_own_did(&store, &ks, ".well-known", &jsonl)
.await
.unwrap();
match import_own_did(&store, &ks, ".well-known", &jsonl)
.await
.unwrap()
{
OwnDidImport::AlreadyPresent { did_id } => {
assert_eq!(Some(did_id), extract_did_id(&jsonl));
}
other => panic!("expected AlreadyPresent, got {other:?}"),
}
}
#[tokio::test]
async fn a_different_did_at_the_path_is_refused_and_left_untouched() {
let (_dir, store, ks) = store().await;
let old = minted_for("http://old-host.example").await;
let new = minted_for("http://new-host.example").await;
import_own_did(&store, &ks, ".well-known", &old)
.await
.unwrap();
match import_own_did(&store, &ks, ".well-known", &new)
.await
.unwrap()
{
OwnDidImport::HeldByAnother { existing, minted } => {
assert_eq!(existing, extract_did_id(&old));
assert_eq!(Some(minted), extract_did_id(&new));
}
other => panic!("expected HeldByAnother, got {other:?}"),
}
let kept = ks
.get::<DidRecord>(did_key(".well-known"))
.await
.unwrap()
.expect("record still there");
assert_eq!(
kept.did_id,
extract_did_id(&old),
"the old DID was not overwritten"
);
}
#[tokio::test]
async fn replace_own_did_swaps_the_stale_did_for_the_new_one() {
let (_dir, store, ks) = store().await;
let old = minted_for("http://old-host.example").await;
let new = minted_for("http://new-host.example").await;
import_own_did(&store, &ks, ".well-known", &old)
.await
.unwrap();
let result = replace_own_did(&store, &ks, ".well-known", &new)
.await
.expect("replace succeeds");
assert_eq!(Some(result.did_id.clone()), extract_did_id(&new));
let kept = ks
.get::<DidRecord>(did_key(".well-known"))
.await
.unwrap()
.expect("record still there");
assert_eq!(
kept.did_id,
extract_did_id(&new),
"the new DID replaced the stale one"
);
match import_own_did(&store, &ks, ".well-known", &new)
.await
.unwrap()
{
OwnDidImport::AlreadyPresent { did_id } => {
assert_eq!(Some(did_id), extract_did_id(&new));
}
other => panic!("expected AlreadyPresent, got {other:?}"),
}
}
#[tokio::test]
async fn replace_own_did_on_a_free_path_just_imports() {
let (_dir, store, ks) = store().await;
let jsonl = minted_for("http://host.example").await;
let result = replace_own_did(&store, &ks, ".well-known", &jsonl)
.await
.expect("replace on a free path succeeds");
assert_eq!(Some(result.did_id), extract_did_id(&jsonl));
}
#[test]
fn the_refusal_names_the_targeted_fix() {
let msg = stale_own_did_message(".well-known", Some("did:webvh:old"), "did:webvh:new");
assert!(msg.contains("remove-did --path .well-known"), "{msg}");
assert!(
msg.contains("did:webvh:old") && msg.contains("did:webvh:new"),
"{msg}"
);
assert!(msg.contains("every DID"), "{msg}");
assert!(
!msg.contains("bootstrap-did"),
"the old advice hit the same guard: {msg}"
);
}
}