use std::path::PathBuf;
use affinidi_tdk::dids::{OneOrMany, PeerService, PeerServiceEndpoint, PeerServiceEndpointLong};
use affinidi_tdk::secrets_resolver::secrets::Secret;
use vta_sdk::did_secrets::DidSecretsBundle;
use crate::acl::{AclEntry, Role, store_acl_entry};
use crate::config::AppConfig;
use crate::operations::did_peer::{
mediator_did_didcomm_service, mint_did_peer_with_services, peer_secrets_to_entries,
};
use crate::store::Store;
pub struct CreateDidPeerArgs {
pub config_path: Option<PathBuf>,
pub context: String,
pub label: Option<String>,
pub mediator_url: Option<String>,
pub mediator_did: Option<String>,
pub export_secrets: bool,
pub admin: bool,
}
pub async fn run_create_did_peer(
args: CreateDidPeerArgs,
) -> Result<(), Box<dyn std::error::Error>> {
let config = AppConfig::load(args.config_path)?;
let store = Store::open(&config.store)?;
let contexts_ks = store.keyspace(crate::keyspaces::CONTEXTS)?;
if crate::contexts::get_context(&contexts_ks, &args.context)
.await?
.is_none()
{
return Err(format!(
"context '{}' does not exist (create it first with `vta contexts ...`)",
args.context
)
.into());
}
let label = args.label.as_deref().unwrap_or(&args.context);
let services = select_services(args.mediator_did.as_deref(), args.mediator_url.as_deref())?;
let (did, secrets): (String, Vec<Secret>) = mint_did_peer_with_services(services)?;
eprintln!("\x1b[1;32mCreated DID:\x1b[0m {did}");
if args.admin {
let acl_ks = store.keyspace(crate::keyspaces::ACL)?;
let entry = AclEntry::new(did.clone(), Role::Admin, "cli:create-did-peer")
.with_label(args.label.clone())
.with_contexts(vec![args.context.clone()]);
store_acl_entry(&acl_ks, &entry).await?;
eprintln!(
"ACL entry created: {did} (admin, context: {})",
args.context
);
}
store.persist().await?;
eprintln!(
" \x1b[2mdid:peer is self-contained: keys + services are encoded in the DID.\x1b[0m"
);
let _ = label;
if args.export_secrets {
let entries = peer_secrets_to_entries(&secrets)?;
let bundle = DidSecretsBundle {
did: did.clone(),
secrets: entries,
};
let json = serde_json::to_string_pretty(&bundle)?;
eprintln!();
eprintln!("\x1b[1;33m╔══════════════════════════════════════════════════════════╗");
eprintln!("║ WARNING: The secrets bundle contains private keys. ║");
eprintln!("║ Redirect to a file with restrictive permissions. ║");
eprintln!("╚══════════════════════════════════════════════════════════╝\x1b[0m");
eprintln!();
println!("{json}");
eprintln!();
}
Ok(())
}
fn select_services(
mediator_did: Option<&str>,
mediator_url: Option<&str>,
) -> Result<Vec<PeerService>, Box<dyn std::error::Error>> {
match (mediator_did, mediator_url) {
(Some(_), Some(_)) => {
Err("--mediator-did and --mediator-url are mutually exclusive; pass exactly one".into())
}
(Some(did), None) => Ok(mediator_did_didcomm_service(
did,
vec!["didcomm/v2".into()],
vec![],
)),
(None, Some(url)) => mediator_services(url),
(None, None) => Err("one of --mediator-did or --mediator-url is required".into()),
}
}
fn mediator_services(service_uri: &str) -> Result<Vec<PeerService>, Box<dyn std::error::Error>> {
let service_uri = service_uri.trim_end_matches('/').to_string();
let ws_uri = websocket_service_uri(&service_uri)?;
let auth_uri = format!("{service_uri}/authenticate");
Ok(vec![
PeerService {
type_: "dm".into(),
endpoint: PeerServiceEndpoint::Long(OneOrMany::Many(vec![
PeerServiceEndpointLong {
uri: service_uri,
accept: vec!["didcomm/v2".into()],
routing_keys: vec![],
},
PeerServiceEndpointLong {
uri: ws_uri,
accept: vec!["didcomm/v2".into()],
routing_keys: vec![],
},
])),
id: None,
},
PeerService {
type_: "Authentication".into(),
endpoint: PeerServiceEndpoint::Uri(auth_uri),
id: Some("#auth".into()),
},
])
}
fn websocket_service_uri(service_uri: &str) -> Result<String, Box<dyn std::error::Error>> {
let mut url = url::Url::parse(service_uri)
.map_err(|e| format!("invalid mediator URL `{service_uri}`: {e}"))?;
match url.scheme() {
"http" => url
.set_scheme("ws")
.map_err(|_| format!("failed to convert `{service_uri}` to ws://"))?,
"https" => url
.set_scheme("wss")
.map_err(|_| format!("failed to convert `{service_uri}` to wss://"))?,
other => {
return Err(
format!("mediator URL must use http:// or https:// (got {other}://)").into(),
);
}
}
let path = url.path().trim_end_matches('/');
url.set_path(&format!("{path}/ws"));
Ok(url.to_string().trim_end_matches('/').to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "config-seed")]
use crate::acl::get_acl_entry;
const MEDIATOR_DID: &str = "did:webvh:QmExample:mediator.example.com";
#[test]
fn mediator_did_selects_the_did_style_didcomm_service() {
let services = select_services(Some(MEDIATOR_DID), None).expect("did-style services");
assert_eq!(services.len(), 1, "expected exactly one service");
assert_eq!(services[0].type_, "DIDCommMessaging");
match &services[0].endpoint {
PeerServiceEndpoint::Long(OneOrMany::One(ep)) => {
assert_eq!(
ep.uri, MEDIATOR_DID,
"endpoint uri must be the mediator DID"
);
assert_eq!(ep.accept, vec!["didcomm/v2".to_string()]);
assert!(ep.routing_keys.is_empty());
}
other => panic!("expected a single long-form endpoint, got {other:?}"),
}
}
#[test]
fn mediator_url_still_selects_the_url_style_services() {
let services = select_services(None, Some("http://127.0.0.1:61881/mediator/v1"))
.expect("url-style services");
assert_eq!(services.len(), 2);
assert_eq!(services[0].type_, "dm");
assert_eq!(services[1].type_, "Authentication");
assert_eq!(services[1].id.as_deref(), Some("#auth"));
}
#[test]
fn mediator_flags_require_exactly_one() {
let both = select_services(Some(MEDIATOR_DID), Some("http://127.0.0.1:61881"))
.expect_err("both flags must be rejected");
assert!(
both.to_string().contains("mutually exclusive"),
"unexpected message: {both}"
);
let neither = select_services(None, None).expect_err("neither flag must be rejected");
assert!(
neither.to_string().contains("required"),
"unexpected message: {neither}"
);
}
#[cfg(feature = "config-seed")]
#[tokio::test]
async fn create_did_peer_admin_export_is_noninteractive_and_grants_admin() {
let dir = tempfile::TempDir::new().expect("tempdir");
let data_dir = dir.path().join("data");
std::fs::create_dir_all(&data_dir).unwrap();
let config_path = dir.path().join("config.toml");
let seed_hex = hex::encode([9u8; 64]);
std::fs::write(
&config_path,
format!(
"[store]\ndata_dir = \"{}\"\n\n[secrets]\nseed = \"{seed_hex}\"\n",
data_dir.display()
),
)
.unwrap();
let config = AppConfig::load(Some(config_path.clone())).expect("load config");
let store = Store::open(&config.store).expect("open store");
let contexts_ks = store.keyspace(crate::keyspaces::CONTEXTS).unwrap();
crate::contexts::create_context(&contexts_ks, "agents", "Agents")
.await
.unwrap();
store.persist().await.unwrap();
drop(contexts_ks);
drop(store);
let args = CreateDidPeerArgs {
config_path: Some(config_path.clone()),
context: "agents".to_string(),
label: Some("agent-1".to_string()),
mediator_url: Some("http://127.0.0.1:61881/mediator/v1".to_string()),
mediator_did: None,
export_secrets: true,
admin: true,
};
run_create_did_peer(args).await.expect("create-did-peer");
let store = Store::open(&config.store).expect("reopen store");
let acl_ks = store.keyspace(crate::keyspaces::ACL).unwrap();
let entries = crate::acl::list_acl_entries(&acl_ks).await.unwrap();
assert_eq!(entries.len(), 1, "one ACL entry created");
let did = &entries[0].did;
assert!(did.starts_with("did:peer:2"), "got {did}");
let entry = get_acl_entry(&acl_ks, did)
.await
.unwrap()
.expect("ACL entry created for the did:peer");
assert_eq!(entry.role, Role::Admin);
assert_eq!(entry.allowed_contexts, vec!["agents".to_string()]);
}
#[test]
fn bundle_has_two_entries_ed25519_then_x25519() {
let services = mediator_services("http://127.0.0.1:61881/mediator/v1").unwrap();
let (did, secrets) = mint_did_peer_with_services(services).expect("generate did:peer");
assert!(did.starts_with("did:peer:2"), "got {did}");
assert_eq!(secrets.len(), 2);
let entries = peer_secrets_to_entries(&secrets).expect("map secrets to entries");
assert_eq!(entries.len(), 2);
assert!(entries[0].key_id.contains("#key-1"));
assert_eq!(entries[0].key_type, vta_sdk::keys::KeyType::Ed25519);
assert!(!entries[0].private_key_multibase.is_empty());
assert!(entries[1].key_id.contains("#key-2"));
assert_eq!(entries[1].key_type, vta_sdk::keys::KeyType::X25519);
assert!(!entries[1].private_key_multibase.is_empty());
}
}