use affinidi_data_integrity::DataIntegrityProof;
use affinidi_vc::VerifiableCredential;
use chrono::Duration;
use serde_json::Value;
use crate::sealed_transfer::template_bootstrap::{
TemplateBootstrapConfig, TemplateBootstrapPayload, VtaTrustBundle,
};
use super::ProvisionIntegrationError;
use super::credential::{VtaAuthorizationClaim, verify_vta_authorization_credential};
pub struct VerifiedTemplateBootstrap {
inner: TemplateBootstrapPayload,
parsed_claim: VtaAuthorizationClaim,
parsed_vc: VerifiableCredential,
}
impl VerifiedTemplateBootstrap {
pub fn admin_claim(&self) -> &VtaAuthorizationClaim {
&self.parsed_claim
}
pub fn credential(&self) -> &VerifiableCredential {
&self.parsed_vc
}
pub fn config(&self) -> &TemplateBootstrapConfig {
&self.inner.config
}
pub fn secrets(
&self,
) -> &std::collections::BTreeMap<
String,
crate::sealed_transfer::template_bootstrap::DidKeyMaterial,
> {
&self.inner.secrets
}
pub fn vta_trust(&self) -> &VtaTrustBundle {
&self.inner.config.vta_trust
}
pub fn into_inner(self) -> TemplateBootstrapPayload {
self.inner
}
}
pub fn verify_template_bootstrap(
payload: TemplateBootstrapPayload,
expected_vta_did: &str,
clock_skew: Duration,
) -> Result<VerifiedTemplateBootstrap, ProvisionIntegrationError> {
if payload.config.vta_trust.vta_did != expected_vta_did {
return Err(ProvisionIntegrationError::InvalidClaim(format!(
"bundle vta_trust.vta_did '{}' does not match pinned expected_vta_did '{}'",
payload.config.vta_trust.vta_did, expected_vta_did,
)));
}
let vc: VerifiableCredential = serde_json::from_value(payload.authorization.clone())
.map_err(|e| ProvisionIntegrationError::Parse(format!("parse authorization VC: {e}")))?;
let proof_value = vc
.proof
.as_ref()
.ok_or_else(|| ProvisionIntegrationError::BadProof("VC has no proof".into()))?;
let proof: DataIntegrityProof = serde_json::from_value(proof_value.clone())
.map_err(|e| ProvisionIntegrationError::BadProof(format!("parse VC proof: {e}")))?;
let issuer_pubkey = extract_ed25519_pubkey_from_did_doc(
&payload.config.vta_trust.vta_did_document,
&proof.verification_method,
)?;
let verified_vc = verify_vta_authorization_credential(&vc, &issuer_pubkey, clock_skew)?;
let parsed_claim = verified_vc.claim().clone();
if parsed_claim.admin_of.vta != expected_vta_did {
return Err(ProvisionIntegrationError::InvalidClaim(format!(
"VC claim.admin_of.vta '{}' does not match pinned expected_vta_did '{}'",
parsed_claim.admin_of.vta, expected_vta_did,
)));
}
Ok(VerifiedTemplateBootstrap {
inner: payload,
parsed_claim,
parsed_vc: vc,
})
}
fn extract_ed25519_pubkey_from_did_doc(
doc: &Value,
target_vm_id: &str,
) -> Result<[u8; 32], ProvisionIntegrationError> {
let vms = doc
.get("verificationMethod")
.and_then(|v| v.as_array())
.ok_or_else(|| {
ProvisionIntegrationError::InvalidClaim(
"vta_did_document has no verificationMethod array".into(),
)
})?;
let target_fragment = target_vm_id.split_once('#').map(|(_, f)| f);
for vm in vms {
let id = vm.get("id").and_then(|v| v.as_str()).unwrap_or("");
let id_fragment = id.split_once('#').map(|(_, f)| f);
let matches =
id == target_vm_id || (target_fragment.is_some() && target_fragment == id_fragment);
if !matches {
continue;
}
let mb = vm
.get("publicKeyMultibase")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ProvisionIntegrationError::InvalidClaim(format!(
"verificationMethod '{id}' has no publicKeyMultibase"
))
})?;
return crate::did_key::decode_ed25519_public_key_multibase(mb).map_err(|e| {
ProvisionIntegrationError::InvalidClaim(format!(
"decode publicKeyMultibase for '{id}': {e}"
))
});
}
Err(ProvisionIntegrationError::InvalidClaim(format!(
"verificationMethod '{target_vm_id}' not found in vta_did_document"
)))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::credentials::CredentialBundle as _Dummy;
use crate::did_key::ed25519_multibase_pubkey;
use crate::provision_integration::credential::{
AdminOfClaim, OperatorOfClaim, VtaAuthorizationParams,
};
use crate::sealed_transfer::generate_ed25519_keypair;
use crate::sealed_transfer::template_bootstrap::{
TemplateBootstrapConfig, TemplateBootstrapPayload, VtaTrustBundle,
};
use affinidi_secrets_resolver::secrets::Secret;
use serde_json::json;
use std::collections::BTreeMap;
#[allow(dead_code)]
fn _touch_dummy() -> Option<_Dummy> {
None
}
async fn make_fixture(
vta_did: &str,
subject_did: &str,
claim_vta: &str,
) -> (TemplateBootstrapPayload, [u8; 32]) {
let (seed, pub_bytes) = generate_ed25519_keypair();
let multibase = ed25519_multibase_pubkey(&pub_bytes);
let vm_id = format!("{vta_did}#key-0");
let doc = json!({
"id": vta_did,
"verificationMethod": [{
"id": vm_id,
"type": "Multikey",
"controller": vta_did,
"publicKeyMultibase": multibase,
}],
"assertionMethod": [vm_id.clone()],
});
let mut issuer_secret = Secret::generate_ed25519(Some(&vm_id), Some(&seed));
issuer_secret.id = vm_id.clone();
let params = VtaAuthorizationParams::new(VtaAuthorizationClaim {
id: subject_did.to_string(),
admin_of: AdminOfClaim {
vta: claim_vta.to_string(),
context: "prod-mediator".into(),
role: "admin".into(),
},
operator_of: Some(OperatorOfClaim {
did: "did:webvh:integration.example".into(),
template: "didcomm-mediator".into(),
}),
});
let vc =
super::super::credential::issue_vta_authorization_credential(&issuer_secret, params)
.await
.expect("issue VC");
let payload = TemplateBootstrapPayload {
authorization: serde_json::to_value(&vc).unwrap(),
secrets: BTreeMap::new(),
config: TemplateBootstrapConfig {
template_name: "didcomm-mediator".into(),
template_kind: "mediator".into(),
did_document: json!({"id": "did:webvh:integration.example"}),
outputs: vec![],
vta_url: Some("https://vta.test".into()),
vta_trust: VtaTrustBundle {
vta_did: vta_did.to_string(),
vta_did_document: doc,
vta_did_log: None,
},
},
};
(payload, pub_bytes)
}
#[tokio::test]
async fn verify_passes_for_matched_pinned_did() {
let vta_did = "did:key:zVerifierVtaOne";
let (payload, _) = make_fixture(vta_did, "did:key:zSubject", vta_did).await;
match verify_template_bootstrap(payload, vta_did, Duration::minutes(5)) {
Ok(verified) => {
assert_eq!(verified.admin_claim().admin_of.vta, vta_did);
assert_eq!(verified.admin_claim().admin_of.role, "admin");
}
Err(e) => panic!("verify must pass when pinned DID matches: {e}"),
}
}
#[tokio::test]
async fn verify_rejects_mismatched_pinned_did() {
let (payload, _) = make_fixture(
"did:key:zBundleVta",
"did:key:zSubject",
"did:key:zBundleVta",
)
.await;
let result = verify_template_bootstrap(
payload,
"did:key:zOperatorPinnedSomeoneElse",
Duration::minutes(5),
);
match result {
Err(ProvisionIntegrationError::InvalidClaim(msg)) => {
assert!(msg.contains("does not match pinned"), "got: {msg}")
}
Err(other) => panic!("expected InvalidClaim, got {other:?}"),
Ok(_) => panic!("verify must reject mismatched pinned DID"),
}
}
#[tokio::test]
async fn verify_rejects_claim_vta_drift() {
let signing_vta = "did:key:zBundleVta";
let (payload, _) = make_fixture(
signing_vta,
"did:key:zSubject",
"did:key:zClaimedDifferentVta",
)
.await;
let result = verify_template_bootstrap(payload, signing_vta, Duration::minutes(5));
match result {
Err(ProvisionIntegrationError::InvalidClaim(msg)) => {
assert!(msg.contains("claim.admin_of.vta"), "got: {msg}")
}
Err(other) => panic!("expected InvalidClaim (claim-drift), got {other:?}"),
Ok(_) => panic!("verify must reject claim-drift"),
}
}
#[tokio::test]
async fn verify_rejects_unknown_verification_method() {
let vta_did = "did:key:zVtaMissingVm";
let (mut payload, _) = make_fixture(vta_did, "did:key:zSubject", vta_did).await;
payload.config.vta_trust.vta_did_document = json!({
"id": vta_did,
"verificationMethod": [],
});
let result = verify_template_bootstrap(payload, vta_did, Duration::minutes(5));
match result {
Err(ProvisionIntegrationError::InvalidClaim(msg)) => {
assert!(msg.contains("not found"), "got: {msg}")
}
Err(other) => panic!("expected InvalidClaim (VM lookup), got {other:?}"),
Ok(_) => panic!("verify must reject missing VM"),
}
}
}