use std::fmt;
use affinidi_tdk::affinidi_crypto::KeyType;
use affinidi_tdk::secrets_resolver::secrets::Secret;
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use trust_tasks_proof::affinidi::{SignOptions, sign_trust_task};
use vta_sdk::did_secrets::DidSecretsBundle;
use zeroize::Zeroizing;
use crate::store::Store;
pub const IDENTITY_SECRET: &str = "identity";
pub const MAX_DID_BYTES: usize = 1000;
pub const DIDCOMM_SERVICE_TYPE: &str = "DIDCommMessaging";
pub const TSP_SERVICE_TYPE: &str = "TSPTransport";
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "type")]
enum StoredIdentity {
DidKey { did: String, seed: String },
Bundle { bundle: DidSecretsBundle },
}
#[derive(Clone)]
pub struct BridgeIdentity {
did: String,
signing: Secret,
secrets: Vec<Secret>,
}
impl fmt::Debug for BridgeIdentity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("BridgeIdentity")
.field("did", &self.did)
.field("secrets", &self.secrets.len())
.finish()
}
}
impl BridgeIdentity {
pub fn generate_did_key() -> Result<(Self, Zeroizing<[u8; 32]>)> {
let mut seed = Zeroizing::new([0u8; 32]);
aws_lc_rs::rand::fill(seed.as_mut())
.map_err(|_| anyhow::anyhow!("system RNG unavailable"))?;
let identity = Self::from_seed(&seed)?;
Ok((identity, seed))
}
pub fn from_seed(seed: &[u8; 32]) -> Result<Self> {
let public = ed25519_dalek::SigningKey::from_bytes(seed)
.verifying_key()
.to_bytes();
let did = format!(
"did:key:{}",
vta_sdk::did_key::ed25519_multibase_pubkey(&public)
);
let s = vta_sdk::did_key::secrets_from_did_key(&did, seed)
.map_err(|e| anyhow::anyhow!("did:key secrets: {e}"))?;
Ok(BridgeIdentity {
did,
signing: s.signing.clone(),
secrets: vec![s.signing, s.key_agreement],
})
}
pub fn from_bundle(bundle: &DidSecretsBundle) -> Result<Self> {
let secrets = vta_sdk::did_key::secrets_from_bundle(bundle)
.map_err(|e| anyhow::anyhow!("DID secrets bundle: {e}"))?;
for s in &secrets {
if !s.id.starts_with(&format!("{}#", bundle.did)) {
bail!(
"bundle key `{}` does not belong to `{}`: refusing a bundle for another DID",
s.id,
bundle.did
);
}
}
let signing = secrets
.iter()
.find(|s| s.get_key_type() == KeyType::Ed25519)
.cloned()
.context("the bundle has no Ed25519 key to sign documents with")?;
if !secrets.iter().any(|s| s.get_key_type() == KeyType::X25519) {
bail!("the bundle has no X25519 key-agreement key; DIDComm needs one");
}
Ok(BridgeIdentity {
did: bundle.did.clone(),
signing,
secrets,
})
}
pub fn generate_did_peer(mediator_did: &str) -> Result<(Self, DidSecretsBundle)> {
use affinidi_tdk::dids::{
DID, OneOrMany, PeerKeyRole, PeerService, PeerServiceEndpoint, PeerServiceEndpointLong,
};
if mediator_did.trim().is_empty() || !mediator_did.starts_with("did:") {
bail!("`{mediator_did}` is not a mediator DID");
}
let didcomm = PeerService {
type_: DIDCOMM_SERVICE_TYPE.into(),
endpoint: PeerServiceEndpoint::Long(OneOrMany::One(PeerServiceEndpointLong {
uri: mediator_did.to_string(),
accept: vec!["didcomm/v2".into()],
routing_keys: vec![],
})),
id: None,
};
let tsp = PeerService {
type_: TSP_SERVICE_TYPE.into(),
endpoint: PeerServiceEndpoint::Uri(mediator_did.to_string()),
id: Some("#tsp".into()),
};
let mint = |services: Vec<PeerService>| {
DID::generate_did_peer_with_services(
vec![
(
PeerKeyRole::Verification,
affinidi_tdk::dids::KeyType::Ed25519,
),
(PeerKeyRole::Encryption, affinidi_tdk::dids::KeyType::X25519),
],
Some(services),
)
.map_err(|e| anyhow::anyhow!("minting a did:peer: {e}"))
};
let (mut did, mut secrets) = mint(vec![didcomm.clone(), tsp])?;
let mut with_tsp = true;
if did.len() > MAX_DID_BYTES {
(did, secrets) = mint(vec![didcomm])?;
with_tsp = false;
if did.len() <= MAX_DID_BYTES {
tracing::warn!(
mediator = %mediator_did,
"the mediator's DID is too long to advertise both TSP and DIDComm in a \
did:peer; minted one advertising DIDComm only (the VTC reaches it over \
DIDComm). For TSP, use a mediator with a short DID or a did:webvh bridge DID"
);
}
}
if did.len() > MAX_DID_BYTES {
bail!(
"the did:peer this would mint is {} bytes, past the {MAX_DID_BYTES}-byte limit \
every DID resolver enforces, so the VTC could not resolve it. A did:peer \
carries its mediator inside the identifier: use a mediator with a short DID \
(a did:web or did:webvh), or provision a did:webvh for the bridge instead",
did.len()
);
}
let bundle = bundle_of(&did, &secrets)?;
let identity = Self::from_bundle(&bundle)?;
let mut minted = vec![("DIDComm", advertised_mediator(&did)?)];
if with_tsp {
minted.push(("TSP", advertised_tsp_mediator(&did)?));
}
for (kind, advertised) in minted {
match advertised {
Some(m) if m == mediator_did => {}
other => bail!(
"the minted did:peer advertises {kind} at {other:?}, not `{mediator_did}`: \
refusing it"
),
}
}
Ok((identity, bundle))
}
pub fn to_bundle(&self) -> Result<DidSecretsBundle> {
bundle_of(&self.did, &self.secrets)
}
pub fn stored_bundle(store: &Store) -> Result<Option<DidSecretsBundle>> {
let Some(bytes) = store.get_secret(IDENTITY_SECRET)? else {
return Ok(None);
};
let stored: StoredIdentity =
serde_json::from_slice(&bytes).context("decoding the sealed identity")?;
match stored {
StoredIdentity::Bundle { bundle } => Ok(Some(bundle)),
StoredIdentity::DidKey { .. } => {
Self::load(store)?.map(|id| id.to_bundle()).transpose()
}
}
}
pub fn store_did_key(store: &Store, seed: &[u8; 32]) -> Result<Self> {
let identity = Self::from_seed(seed)?;
let stored = StoredIdentity::DidKey {
did: identity.did.clone(),
seed: hex::encode(seed),
};
let json = Zeroizing::new(serde_json::to_vec(&stored)?);
store.put_secret(IDENTITY_SECRET, &json)?;
Ok(identity)
}
pub fn store_bundle(store: &Store, bundle: DidSecretsBundle) -> Result<Self> {
let identity = Self::from_bundle(&bundle)?;
let json = Zeroizing::new(serde_json::to_vec(&StoredIdentity::Bundle { bundle })?);
store.put_secret(IDENTITY_SECRET, &json)?;
Ok(identity)
}
pub fn load(store: &Store) -> Result<Option<Self>> {
let Some(bytes) = store.get_secret(IDENTITY_SECRET)? else {
return Ok(None);
};
let stored: StoredIdentity =
serde_json::from_slice(&bytes).context("decoding the sealed identity")?;
Ok(Some(match stored {
StoredIdentity::DidKey { did, seed } => {
let raw = Zeroizing::new(hex::decode(seed).context("identity seed")?);
let seed: [u8; 32] = raw
.as_slice()
.try_into()
.map_err(|_| anyhow::anyhow!("identity seed is not 32 bytes"))?;
let id = Self::from_seed(&seed)?;
if id.did != did {
bail!("the sealed seed does not derive the sealed DID `{did}`");
}
id
}
StoredIdentity::Bundle { bundle } => Self::from_bundle(&bundle)?,
}))
}
pub fn did(&self) -> &str {
&self.did
}
pub fn from_secrets(did: &str, signing: Secret, secrets: Vec<Secret>) -> Result<Self> {
for s in &secrets {
if !s.id.starts_with(&format!("{did}#")) {
bail!("key `{}` does not belong to `{did}`", s.id);
}
}
if signing.get_key_type() != KeyType::Ed25519 || !secrets.iter().any(|s| s.id == signing.id)
{
bail!(
"the signing key `{}` is not an Ed25519 key of the set",
signing.id
);
}
Ok(BridgeIdentity {
did: did.to_string(),
signing,
secrets,
})
}
pub fn signing_key_id(&self) -> &str {
&self.signing.id
}
pub(crate) fn secrets(&self) -> &[Secret] {
&self.secrets
}
pub fn same_keys(&self, other: &BridgeIdentity) -> bool {
let keys = |i: &BridgeIdentity| {
let mut v: Vec<(String, Vec<u8>)> = i
.secrets
.iter()
.map(|s| (s.id.clone(), s.get_public_bytes().to_vec()))
.collect();
v.sort();
v
};
self.did == other.did && self.signing.id == other.signing.id && keys(self) == keys(other)
}
pub fn messaging_secrets(&self) -> Vec<Secret> {
self.secrets.clone()
}
pub async fn sign(&self, doc: &Value) -> Result<Value> {
sign_trust_task(
doc,
&self.signing,
SignOptions::new().with_proof_purpose("authentication"),
)
.await
.map_err(|e| anyhow::anyhow!("signing a document: {e}"))
}
}
fn bundle_of(did: &str, secrets: &[Secret]) -> Result<DidSecretsBundle> {
use vta_sdk::did_secrets::SecretEntry;
use vta_sdk::keys::KeyType as BundleKeyType;
let secrets = secrets
.iter()
.map(|s| {
let key_type = match s.get_key_type() {
KeyType::Ed25519 => BundleKeyType::Ed25519,
KeyType::X25519 => BundleKeyType::X25519,
other => bail!("key `{}` is {other:?}, which a bundle does not carry", s.id),
};
Ok(SecretEntry {
key_id: s.id.clone(),
key_type,
private_key_multibase: s
.get_private_keymultibase()
.map_err(|e| anyhow::anyhow!("encoding key `{}`: {e}", s.id))?,
})
})
.collect::<Result<Vec<_>>>()?;
Ok(DidSecretsBundle {
did: did.to_string(),
secrets,
})
}
pub fn advertised_mediator(did: &str) -> Result<Option<String>> {
advertised_endpoint(did, DIDCOMM_SERVICE_TYPE)
}
pub fn advertised_tsp_mediator(did: &str) -> Result<Option<String>> {
advertised_endpoint(did, TSP_SERVICE_TYPE)
}
fn advertised_endpoint(did: &str, service_type: &str) -> Result<Option<String>> {
if !(did.starts_with("did:key:") || did.starts_with("did:peer:")) {
bail!("`{did}` is not resolvable without the network");
}
let parsed: affinidi_tdk::did_common::DID = did
.parse()
.map_err(|e| anyhow::anyhow!("`{did}` is not a DID: {e}"))?;
let doc = parsed
.resolve()
.map_err(|e| anyhow::anyhow!("resolving `{did}`: {e}"))?;
let doc = serde_json::to_value(&doc)?;
let Some(services) = doc.get("service").and_then(Value::as_array) else {
return Ok(None);
};
Ok(services.iter().find_map(|svc| {
let typed = match svc.get("type") {
Some(Value::String(t)) => t == service_type,
Some(Value::Array(ts)) => ts.iter().any(|t| t == service_type),
_ => false,
};
typed
.then(|| svc.get("serviceEndpoint").and_then(endpoint_uri))
.flatten()
}))
}
fn endpoint_uri(endpoint: &Value) -> Option<String> {
match endpoint {
Value::String(s) => Some(s.clone()),
Value::Object(map) => map.get("uri")?.as_str().map(str::to_string),
Value::Array(arr) => arr.iter().find_map(endpoint_uri),
_ => None,
}
}
pub fn check_reachable(did: &str, mediator_did: &str) -> Result<Option<String>> {
if !(did.starts_with("did:key:") || did.starts_with("did:peer:")) {
return Ok(None);
}
let tsp = advertised_tsp_mediator(did)?;
let didcomm = advertised_mediator(did)?;
if let Some(m) = [&tsp, &didcomm]
.into_iter()
.flatten()
.find(|m| m.as_str() != mediator_did)
{
bail!(
"the bridge's DID `{did}` advertises the mediator `{m}`, but the config names \
`{mediator_did}`. A did:peer names its mediator in its identifier, so the VTC \
would send jobs to `{m}`, where this bridge would not be listening.\n\
Fix (recommended, and the only one that keeps bound namespaces working): set \
`mediator_did = \"{m}\"` back in the config.\n\
Only for a bridge that serves no bound namespace: mint a new identity \
(`vgi-bridge identity mint --replace --backup <file>`) and register the new DID \
at the VTC — see `identity mint --help` for what a new DID breaks"
);
}
match didcomm {
Some(_) => Ok(None),
None if tsp.is_some() => Ok(None),
None => Ok(Some(format!(
"the bridge's DID `{did}` advertises no TSP or DIDComm service, so no VTC can send it \
jobs (they fail with `noMatchingProtocol`). Mint a did:peer that names the \
mediator (`vgi-bridge identity mint --replace --backup <file>`) and register \
the new DID at the VTC"
))),
}
}
pub struct GitSigningKey {
pub verification_method: String,
pub key: ed25519_dalek::SigningKey,
}
impl fmt::Debug for GitSigningKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("GitSigningKey")
.field("verification_method", &self.verification_method)
.finish_non_exhaustive()
}
}
impl BridgeIdentity {
pub fn git_signing_key(&self) -> Result<GitSigningKey> {
let private = self.signing.get_private_bytes();
let seed: Zeroizing<[u8; 32]> = Zeroizing::new(
private
.get(..32)
.and_then(|s| <[u8; 32]>::try_from(s).ok())
.context("the signing key is not an Ed25519 seed")?,
);
let key = ed25519_dalek::SigningKey::from_bytes(&seed);
if key.verifying_key().as_bytes().as_slice() != self.signing.get_public_bytes() {
bail!("the signing key's public half does not match the identity");
}
if !self.signing.id.starts_with(&format!("{}#", self.did)) {
bail!(
"the signing key `{}` is not a verification method of `{}`",
self.signing.id,
self.did
);
}
Ok(GitSigningKey {
verification_method: self.signing.id.clone(),
key,
})
}
#[doc(hidden)]
pub async fn sign_with_purpose(&self, doc: &Value, purpose: &str) -> Result<Value> {
sign_trust_task(
doc,
&self.signing,
SignOptions::new().with_proof_purpose(purpose),
)
.await
.map_err(|e| anyhow::anyhow!("signing a document: {e}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::seal::MasterKey;
use trust_tasks_proof::affinidi::Verifier;
#[tokio::test]
async fn a_did_key_signs_what_a_did_key_verifier_accepts() {
let (id, seed) = BridgeIdentity::generate_did_key().unwrap();
assert!(id.did().starts_with("did:key:z6Mk"));
let doc = serde_json::json!({
"id": "urn:uuid:1",
"type": "https://trusttasks.org/spec/git-ns/bridge/result/0.1",
"issuer": id.did(),
"payload": { "jobId": "j", "outcome": "succeeded" },
});
let signed = id.sign(&doc).await.unwrap();
Verifier::for_did_key().verify_raw(&signed).await.unwrap();
let store = Store::in_memory(MasterKey::generate().unwrap()).unwrap();
BridgeIdentity::store_did_key(&store, &seed).unwrap();
let back = BridgeIdentity::load(&store).unwrap().unwrap();
assert_eq!(back.did(), id.did());
assert_eq!(back.messaging_secrets().len(), 2);
assert!(!format!("{back:?}").contains(&hex::encode(*seed)));
}
#[test]
fn the_git_signing_key_is_the_did_key() {
let id = BridgeIdentity::from_seed(&[5u8; 32]).unwrap();
let k = id.git_signing_key().unwrap();
assert_eq!(k.key.to_bytes(), [5u8; 32]);
let mb = id.did().strip_prefix("did:key:").unwrap();
assert_eq!(k.verification_method, format!("{}#{mb}", id.did()));
assert!(
format!("{k:?}").ends_with(", .. }"),
"no key material in Debug"
);
}
const MEDIATOR: &str = "did:web:mediator.acme-vtc.example";
#[tokio::test]
async fn a_did_peer_advertises_tsp_and_didcomm_through_its_mediator() {
let (id, bundle) = BridgeIdentity::generate_did_peer(MEDIATOR).unwrap();
assert!(id.did().starts_with("did:peer:2."), "{}", id.did());
assert!(id.did().len() <= MAX_DID_BYTES);
assert!(
id.did()
.chars()
.all(|c| c.is_ascii_alphanumeric() || ":.-_".contains(c)),
"{}",
id.did()
);
assert_eq!(
advertised_mediator(id.did()).unwrap().as_deref(),
Some(MEDIATOR)
);
assert_eq!(
advertised_tsp_mediator(id.did()).unwrap().as_deref(),
Some(MEDIATOR)
);
assert_eq!(check_reachable(id.did(), MEDIATOR).unwrap(), None);
let doc: affinidi_tdk::did_common::DID = id.did().parse().unwrap();
let doc = serde_json::to_value(doc.resolve().unwrap()).unwrap();
let x25519 = id
.messaging_secrets()
.into_iter()
.find(|s| s.get_key_type() == KeyType::X25519)
.unwrap();
assert_eq!(x25519.id, format!("{}#key-2", id.did()));
assert_eq!(doc["keyAgreement"], serde_json::json!([x25519.id]));
assert_eq!(
doc["assertionMethod"],
serde_json::json!([format!("{}#key-1", id.did())])
);
let err = check_reachable(id.did(), "did:web:elsewhere.example")
.unwrap_err()
.to_string();
let first_fix = err
.find(&format!("`mediator_did = \"{MEDIATOR}\"`"))
.unwrap();
assert!(first_fix < err.find("mint").unwrap(), "{err}");
let doc = serde_json::json!({
"id": "urn:uuid:1",
"type": "https://trusttasks.org/spec/git-ns/bridge/result/0.1",
"issuer": id.did(),
"payload": { "jobId": "j", "outcome": "succeeded" },
});
let signed = id.sign(&doc).await.unwrap();
crate::proof_checker()
.await
.unwrap()
.verify_raw(&signed)
.await
.unwrap();
let k = id.git_signing_key().unwrap();
assert_eq!(k.verification_method, format!("{}#key-1", id.did()));
let store = Store::in_memory(MasterKey::generate().unwrap()).unwrap();
BridgeIdentity::store_bundle(&store, bundle).unwrap();
let back = BridgeIdentity::load(&store).unwrap().unwrap();
assert_eq!(back.did(), id.did());
assert_eq!(back.messaging_secrets().len(), 2);
}
#[tokio::test]
async fn the_vtc_selects_tsp_for_a_did_peer_and_nothing_for_a_did_key() {
use affinidi_tdk::did_resolver::DIDCacheClient;
use affinidi_tdk::did_resolver::config::DIDCacheConfigBuilder;
use vta_sdk::protocol::matching::{Protocol, ServiceCapabilities, select_protocol};
let resolver = DIDCacheClient::new(DIDCacheConfigBuilder::default().build())
.await
.unwrap();
let (peer, _) = BridgeIdentity::generate_did_peer(MEDIATOR).unwrap();
let key = BridgeIdentity::from_seed(&[5u8; 32]).unwrap();
for (id, want) in [(&peer, Some(Protocol::Tsp)), (&key, None)] {
let resolved = resolver.resolve(id.did()).await.unwrap();
let doc = serde_json::to_value(&resolved.doc).unwrap();
let theirs = ServiceCapabilities::from_did_document(&doc);
let ours = ServiceCapabilities {
tsp: Some(id.did().to_string()),
didcomm: Some(id.did().to_string()),
rest: None,
};
let got = select_protocol(&ours, &theirs, id.did())
.ok()
.map(|m| m.protocol);
assert_eq!(got, want, "{}", id.did());
if want.is_some() {
assert_eq!(theirs.didcomm.as_deref(), Some(MEDIATOR));
assert_eq!(theirs.tsp.as_deref(), Some(MEDIATOR));
let didcomm_only = ServiceCapabilities {
tsp: None,
..ours.clone()
};
assert_eq!(
select_protocol(&didcomm_only, &theirs, id.did())
.unwrap()
.protocol,
Protocol::Didcomm
);
let signing = id.git_signing_key().unwrap().key.verifying_key().to_bytes();
assert!(vgi_core::ed25519_signing_keys_from_doc(&doc).contains(&signing));
}
}
}
#[test]
fn a_did_key_is_reachable_by_no_vtc() {
let id = BridgeIdentity::from_seed(&[5u8; 32]).unwrap();
assert_eq!(advertised_mediator(id.did()).unwrap(), None);
let warning = check_reachable(id.did(), MEDIATOR).unwrap().unwrap();
assert!(warning.contains("noMatchingProtocol"), "{warning}");
assert_eq!(
check_reachable("did:webvh:QmScid:bridge.example", MEDIATOR).unwrap(),
None
);
}
#[test]
fn a_mediator_with_a_long_did_is_refused_at_mint() {
let long = format!("did:web:{}.example", "m".repeat(900));
let err = BridgeIdentity::generate_did_peer(&long).unwrap_err();
assert!(err.to_string().contains("1000-byte"), "{err}");
assert!(BridgeIdentity::generate_did_peer("not-a-did").is_err());
}
#[test]
fn a_mediator_too_long_for_both_services_gets_a_didcomm_only_did_peer() {
let short = BridgeIdentity::generate_did_peer(MEDIATOR).unwrap().0;
assert!(advertised_tsp_mediator(short.did()).unwrap().is_some());
let (mediator, id) = (100..900)
.step_by(10)
.map(|n| format!("did:web:{}.example", "m".repeat(n)))
.find_map(|m| {
let (id, _) = BridgeIdentity::generate_did_peer(&m).ok()?;
advertised_tsp_mediator(id.did())
.unwrap()
.is_none()
.then_some((m, id))
})
.expect("a mediator DID too long for both services but not for one");
assert!(id.did().len() <= MAX_DID_BYTES);
assert_eq!(
advertised_mediator(id.did()).unwrap().as_deref(),
Some(mediator.as_str())
);
assert_eq!(check_reachable(id.did(), &mediator).unwrap(), None);
}
#[test]
fn an_exported_identity_imports_as_the_same_did_and_keys() {
for id in [
BridgeIdentity::generate_did_peer(MEDIATOR).unwrap().0,
BridgeIdentity::from_seed(&[7u8; 32]).unwrap(),
] {
let text = serde_json::to_string(&id.to_bundle().unwrap()).unwrap();
let store = Store::in_memory(MasterKey::generate().unwrap()).unwrap();
let back =
BridgeIdentity::store_bundle(&store, serde_json::from_str(&text).unwrap()).unwrap();
assert_eq!(back.did(), id.did());
assert_eq!(
back.git_signing_key().unwrap().key.to_bytes(),
id.git_signing_key().unwrap().key.to_bytes()
);
let reloaded = BridgeIdentity::load(&store).unwrap().unwrap();
assert_eq!(reloaded.did(), id.did());
}
}
#[tokio::test]
async fn signing_for_another_issuer_is_refused() {
let (id, _) = BridgeIdentity::generate_did_key().unwrap();
let doc = serde_json::json!({ "id": "x", "issuer": "did:key:z6MkOther", "payload": {} });
assert!(id.sign(&doc).await.is_err());
}
}