use affinidi_messaging_mediator_admin::{
AddressBook, ConsoleError, Identity, IdentityChoice, IdentitySource, MediatorConsole,
};
use affinidi_messaging_mediator_tui::{App, default_address_book_path};
use affinidi_secrets_resolver::secrets::Secret;
use async_trait::async_trait;
use vta_sdk::client::VtaClient;
use vta_sdk::did_secrets::select_secret_kid;
use affinidi_messaging_mediator_admin::account_hash;
use affinidi_messaging_mediator_admin::specs::account::update::v0_1::AccountType;
use crate::cli::{GrantRole, MessagingCommands};
const SESSION_CHOICE: &str = "pnm-session";
const KEY_PAGE: u64 = 100;
pub(crate) async fn run(
client: &VtaClient,
keyring_key: &str,
mediator_hint: Option<&str>,
command: MessagingCommands,
) -> Result<(), Box<dyn std::error::Error>> {
match command {
MessagingCommands::Grant {
target,
role,
context,
did,
mediator,
as_session,
} => {
if !target.starts_with("did:") {
return Err(format!("{target} is not a DID").into());
}
let source = VtaIdentitySource {
client,
keyring_key: keyring_key.to_string(),
};
let choices = source.list().await?;
let choice = choose(&choices, context.as_deref(), did.as_deref(), as_session)?;
let console = connect(&source, &choice, mediator.as_deref(), mediator_hint).await?;
if !console.capabilities().mediator_wide {
return Err(format!(
"{} is a {:?} account at {}, and only an administrator can give an \
account a role. Act as the mediator's administrator (its admin_did).",
console.did(),
console.mode(),
console.mediator_did()
)
.into());
}
let hash = account_hash(&target);
let updated = console
.update_account(Some(hash.clone()), Some(account_type(role)), None, None)
.await?;
let recorded = serde_json::to_value(updated.account_type)
.ok()
.and_then(|v| v.as_str().map(str::to_string))
.unwrap_or_else(|| "unknown".into());
println!("{target}");
println!(" account {hash}");
println!(" at {}", console.mediator_did());
println!(" is now {recorded}");
if recorded != grant_role_wire(role) {
return Err(format!(
"the mediator recorded {recorded}, not {} — nothing else was changed",
grant_role_wire(role)
)
.into());
}
Ok(())
}
MessagingCommands::Console {
context,
did,
mediator,
as_session,
} => {
let source = VtaIdentitySource {
client,
keyring_key: keyring_key.to_string(),
};
let choices = source.list().await?;
let choice = choose(&choices, context.as_deref(), did.as_deref(), as_session)?;
let console = connect(&source, &choice, mediator.as_deref(), mediator_hint).await?;
let book_path = default_address_book_path();
let mut book = match &book_path {
Some(path) => AddressBook::load(path)
.map_err(|e| format!("address book {}: {e}", path.display()))?,
None => AddressBook::new(),
};
seed_address_book(client, &choices, &mut book).await;
let mut terminal = ratatui::init();
let _ = ratatui::crossterm::execute!(
std::io::stdout(),
ratatui::crossterm::event::EnableBracketedPaste
);
let result = App::new(console)
.with_address_book(book, book_path)
.run(&mut terminal)
.await;
let _ = ratatui::crossterm::execute!(
std::io::stdout(),
ratatui::crossterm::event::DisableBracketedPaste
);
ratatui::restore();
Ok(result?)
}
}
}
async fn connect(
source: &VtaIdentitySource<'_>,
choice: &IdentityChoice,
mediator: Option<&str>,
mediator_hint: Option<&str>,
) -> Result<MediatorConsole, Box<dyn std::error::Error>> {
let mut identity = source.load(choice).await?;
identity.mediator_did = mediator.map(str::to_string);
eprintln!("connecting to the mediator as {} …", identity.alias);
match MediatorConsole::connect(identity.clone()).await {
Err(ConsoleError::NoMediator(_)) if mediator.is_none() && mediator_hint.is_some() => {
identity.mediator_did = mediator_hint.map(str::to_string);
Ok(MediatorConsole::connect(identity).await?)
}
other => Ok(other?),
}
}
fn account_type(role: GrantRole) -> AccountType {
match role {
GrantRole::Admin => AccountType::Admin,
GrantRole::Standard => AccountType::Standard,
}
}
fn grant_role_wire(role: GrantRole) -> &'static str {
match role {
GrantRole::Admin => "admin",
GrantRole::Standard => "standard",
}
}
fn choose(
choices: &[IdentityChoice],
context: Option<&str>,
did: Option<&str>,
as_session: bool,
) -> Result<IdentityChoice, Box<dyn std::error::Error>> {
let choices = choices.to_vec();
if as_session {
return first(
narrow(&choices, Some(SESSION_CHOICE), None),
SESSION_CHOICE,
None,
);
}
let candidates = narrow(&choices, context, did);
match candidates.len() {
0 => first(candidates, context.unwrap_or("any context"), did),
1 => Ok(candidates[0].clone()),
_ if context.is_none() && did.is_none() => ask(&choices),
_ => ask(&candidates.into_iter().cloned().collect::<Vec<_>>()),
}
}
fn narrow<'a>(
choices: &'a [IdentityChoice],
context: Option<&str>,
did: Option<&str>,
) -> Vec<&'a IdentityChoice> {
choices
.iter()
.filter(|c| match context {
Some(context) => c.id == context,
None => c.id != SESSION_CHOICE,
})
.filter(|c| did.is_none() || c.did.as_deref() == did)
.collect()
}
fn first(
candidates: Vec<&IdentityChoice>,
context: &str,
did: Option<&str>,
) -> Result<IdentityChoice, Box<dyn std::error::Error>> {
candidates.first().map(|c| (*c).clone()).ok_or_else(|| {
let what = match did {
Some(did) => format!("{did} in {context}"),
None => format!("'{context}'"),
};
format!(
"no usable identity {what}: it must be a DID whose keys are in a context you may \
act in, or --as-session"
)
.into()
})
}
fn ask(choices: &[IdentityChoice]) -> Result<IdentityChoice, Box<dyn std::error::Error>> {
let labels: Vec<String> = choices
.iter()
.map(|c| match &c.detail {
Some(d) => format!("{} ({d})", c.label),
None => c.label.clone(),
})
.collect();
let i = dialoguer::Select::new()
.with_prompt("Open the mediator console as")
.items(&labels)
.default(0)
.interact()?;
Ok(choices[i].clone())
}
async fn seed_address_book(client: &VtaClient, choices: &[IdentityChoice], book: &mut AddressBook) {
if let Ok(acl) = client.list_acl(None).await {
for entry in acl.entries {
let name = entry
.label
.filter(|l| !l.trim().is_empty())
.unwrap_or_else(|| format!("{} (VTA access)", entry.role));
book.know(&entry.did, &name);
}
}
let context_names: std::collections::HashMap<String, String> = client
.list_contexts()
.await
.map(|r| r.contexts.into_iter().map(|c| (c.id, c.name)).collect())
.unwrap_or_default();
if let Ok(webvh) = client.list_dids_webvh(None, None).await {
for record in webvh.dids {
let context = context_names
.get(&record.context_id)
.cloned()
.unwrap_or_else(|| record.context_id.clone());
let name = if record.mnemonic.trim().is_empty() {
context
} else {
format!("{context} · {}", record.mnemonic)
};
book.know(&record.did, &name);
}
}
for (did, name) in choice_names(choices) {
book.know(&did, &name);
}
if let Some(vta) = client.vta_did() {
book.know(vta, "VTA");
}
}
fn choice_names(choices: &[IdentityChoice]) -> Vec<(String, String)> {
let mut per_label: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
for c in choices {
*per_label.entry(c.label.as_str()).or_default() += 1;
}
choices
.iter()
.filter_map(|c| {
let did = c.did.clone()?;
let name = if c.id == SESSION_CHOICE {
"pnm session".to_string()
} else if per_label.get(c.label.as_str()).copied().unwrap_or(0) > 1 {
let tail = did.rsplit(':').next().unwrap_or(&did);
let tail: String = tail.chars().take(16).collect();
format!("{} · {tail}", c.label)
} else {
c.label.clone()
};
Some((did, name))
})
.collect()
}
struct VtaIdentitySource<'a> {
client: &'a VtaClient,
keyring_key: String,
}
#[async_trait]
impl IdentitySource for VtaIdentitySource<'_> {
async fn list(&self) -> affinidi_messaging_mediator_admin::Result<Vec<IdentityChoice>> {
let contexts = self
.client
.list_contexts()
.await
.map_err(|e| ConsoleError::Identity(format!("listing contexts: {e}")))?;
let mut choices = Vec::new();
for c in contexts.contexts {
let dids = self.context_dids(&c.id, c.did.as_deref()).await?;
let several = dids.len() > 1;
for did in dids {
choices.push(IdentityChoice {
id: c.id.clone(),
label: c.name.clone(),
detail: Some(if several {
format!("{did} — context {}", c.id)
} else {
format!("context {}", c.id)
}),
did: Some(did),
});
}
}
if let Some(session) = crate::auth::loaded_session(&self.keyring_key) {
choices.push(IdentityChoice {
id: SESSION_CHOICE.into(),
label: "this pnm session".into(),
detail: Some(session.client_did.clone()),
did: Some(session.client_did),
});
}
Ok(choices)
}
async fn load(
&self,
choice: &IdentityChoice,
) -> affinidi_messaging_mediator_admin::Result<Identity> {
if choice.id == SESSION_CHOICE {
return self.session_identity();
}
let did = choice
.did
.clone()
.ok_or_else(|| ConsoleError::Identity(format!("context {} has no DID", choice.id)))?;
let secrets = self.export_did_keys(&choice.id, &did).await?;
Ok(Identity {
alias: choice.label.clone(),
did,
secrets,
mediator_did: None,
})
}
}
impl VtaIdentitySource<'_> {
async fn context_dids(
&self,
context_id: &str,
primary: Option<&str>,
) -> affinidi_messaging_mediator_admin::Result<Vec<String>> {
let mut dids = std::collections::BTreeSet::new();
let mut offset = 0;
loop {
let page = self
.client
.list_keys(offset, KEY_PAGE, Some("active"), Some(context_id))
.await
.map_err(|e| {
ConsoleError::Identity(format!("listing keys of {context_id}: {e}"))
})?;
if page.keys.is_empty() {
break;
}
for key in &page.keys {
if let Some(did) = key_did(&key.key_id, key.label.as_deref()) {
dids.insert(did);
}
}
offset += page.keys.len() as u64;
if offset >= page.total {
break;
}
}
Ok(order_dids(dids, primary))
}
async fn export_did_keys(
&self,
context_id: &str,
did: &str,
) -> affinidi_messaging_mediator_admin::Result<Vec<Secret>> {
let mut secrets = Vec::new();
let mut offset = 0;
loop {
let page = self
.client
.list_keys(offset, KEY_PAGE, Some("active"), Some(context_id))
.await
.map_err(|e| {
ConsoleError::Identity(format!("listing keys of {context_id}: {e}"))
})?;
if page.keys.is_empty() {
break;
}
for key in &page.keys {
let Some(kid) = select_secret_kid(did, &key.key_id, key.label.as_deref()) else {
continue;
};
if key.exportable == Some(false) {
return Err(ConsoleError::Identity(format!(
"{kid} is marked non-exportable; the console needs this DID's keys to \
sign and decrypt, and the VTA has no remote key agreement"
)));
}
let exported = self.client.get_key_secret(&key.key_id).await.map_err(|e| {
ConsoleError::Identity(format!(
"exporting {kid} (needs the KeyExport capability): {e}"
))
})?;
let secret = Secret::from_multibase(&exported.private_key_multibase, Some(&kid))
.map_err(|e| ConsoleError::Identity(format!("{kid}: {e}")))?;
secrets.push(secret);
}
offset += page.keys.len() as u64;
if offset >= page.total {
break;
}
}
if secrets.is_empty() {
return Err(ConsoleError::Identity(format!(
"no keys of {did} were found in context {context_id}"
)));
}
Ok(secrets)
}
fn session_identity(&self) -> affinidi_messaging_mediator_admin::Result<Identity> {
let session = crate::auth::loaded_session(&self.keyring_key)
.ok_or_else(|| ConsoleError::Identity("no pnm session — run `pnm setup`".into()))?;
let seed = crate::auth::session_seed(&session)
.map_err(|e| ConsoleError::Identity(e.to_string()))?;
let keys = vta_sdk::did_key::secrets_from_did_key(&session.client_did, &seed)
.map_err(|e| ConsoleError::Identity(e.to_string()))?;
Ok(Identity {
alias: "this pnm session".into(),
did: session.client_did,
secrets: vec![keys.signing, keys.key_agreement],
mediator_did: None,
})
}
}
fn key_did(key_id: &str, label: Option<&str>) -> Option<String> {
let vm_id =
|s: &str| s.starts_with("did:") && s.contains('#') && !s.chars().any(char::is_whitespace);
let id = if vm_id(key_id) {
key_id
} else {
label.filter(|l| vm_id(l))?
};
let did = id.split('#').next()?;
select_secret_kid(did, key_id, label).map(|_| did.to_string())
}
fn order_dids(mut dids: std::collections::BTreeSet<String>, primary: Option<&str>) -> Vec<String> {
let mut ordered = Vec::with_capacity(dids.len());
if let Some(primary) = primary
&& dids.remove(primary)
{
ordered.push(primary.to_string());
}
ordered.extend(dids);
ordered
}
#[cfg(test)]
mod tests {
use super::*;
fn choice(id: &str) -> IdentityChoice {
IdentityChoice {
id: id.into(),
label: id.into(),
did: Some(format!("did:example:{id}")),
detail: None,
}
}
fn in_context(id: &str, did: &str) -> IdentityChoice {
IdentityChoice {
did: Some(did.into()),
..choice(id)
}
}
#[test]
fn a_grant_never_names_root_admin() {
assert_eq!(account_type(GrantRole::Admin), AccountType::Admin);
assert_eq!(account_type(GrantRole::Standard), AccountType::Standard);
for role in [GrantRole::Admin, GrantRole::Standard] {
assert_eq!(
serde_json::to_value(account_type(role)).unwrap(),
serde_json::Value::String(grant_role_wire(role).into())
);
}
}
#[test]
fn a_named_context_or_the_session_is_picked_by_id() {
let choices = vec![choice("ctx-a"), choice(SESSION_CHOICE)];
assert_eq!(narrow(&choices, Some("ctx-a"), None)[0].id, "ctx-a");
assert_eq!(
narrow(&choices, Some(SESSION_CHOICE), None)[0].id,
SESSION_CHOICE
);
assert_eq!(narrow(&choices, None, None).len(), 1);
}
#[test]
fn identity_choices_are_named_by_context_and_told_apart() {
let choices = vec![
IdentityChoice {
label: "billing".into(),
..in_context("ctx-a", "did:webvh:Qm:ex.com:billing")
},
IdentityChoice {
label: "shared".into(),
..in_context("ctx-b", "did:webvh:Qm:ex.com:one")
},
IdentityChoice {
label: "shared".into(),
..in_context("ctx-b", "did:webvh:Qm:ex.com:two")
},
choice(SESSION_CHOICE),
];
let names: std::collections::HashMap<String, String> =
choice_names(&choices).into_iter().collect();
assert_eq!(names["did:webvh:Qm:ex.com:billing"], "billing");
assert_eq!(names["did:webvh:Qm:ex.com:one"], "shared · one");
assert_eq!(names["did:webvh:Qm:ex.com:two"], "shared · two");
assert_eq!(
names[&format!("did:example:{SESSION_CHOICE}")],
"pnm session"
);
}
#[test]
fn a_did_picks_one_of_several_in_a_context() {
let choices = vec![
in_context("ctx-a", "did:example:one"),
in_context("ctx-a", "did:example:two"),
in_context("ctx-b", "did:example:three"),
];
assert_eq!(narrow(&choices, Some("ctx-a"), None).len(), 2);
let two = narrow(&choices, Some("ctx-a"), Some("did:example:two"));
assert_eq!(two.len(), 1);
assert_eq!(two[0].did.as_deref(), Some("did:example:two"));
assert_eq!(
narrow(&choices, None, Some("did:example:three"))[0].id,
"ctx-b"
);
assert!(narrow(&choices, Some("ctx-b"), Some("did:example:one")).is_empty());
}
#[test]
fn an_unusable_context_says_why() {
let err = first(vec![], "ctx-b", None).unwrap_err().to_string();
assert!(
err.contains("ctx-b") && err.contains("--as-session"),
"{err}"
);
let err = first(vec![], "ctx-b", Some("did:example:x"))
.unwrap_err()
.to_string();
assert!(err.contains("did:example:x in ctx-b"), "{err}");
}
#[test]
fn a_key_belongs_to_the_did_its_id_or_label_names() {
assert_eq!(
key_did("did:webvh:Qm:ex.com#key-0", None).as_deref(),
Some("did:webvh:Qm:ex.com")
);
assert_eq!(
key_did("z6Mkabc", Some("did:peer:2.Vz#key-1")).as_deref(),
Some("did:peer:2.Vz")
);
assert_eq!(key_did("z6Mkabc", Some("did:key:z6Mk signing key")), None);
assert_eq!(key_did("z6Mkabc", None), None);
}
#[test]
fn the_context_did_comes_first() {
let dids: std::collections::BTreeSet<String> =
["did:a", "did:b", "did:c"].map(String::from).into();
assert_eq!(
order_dids(dids.clone(), Some("did:c")),
["did:c", "did:a", "did:b"]
);
assert_eq!(order_dids(dids.clone(), None), ["did:a", "did:b", "did:c"]);
assert_eq!(order_dids(dids, Some("did:z")), ["did:a", "did:b", "did:c"]);
}
}