use std::collections::BTreeMap;
use affinidi_crypto::did_key as did_key_helpers;
use affinidi_data_integrity::{
DataIntegrityProof, SignOptions, VerifyOptions, crypto_suites::CryptoSuite,
};
use affinidi_secrets_resolver::secrets::Secret;
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use zeroize::Zeroizing;
use crate::sealed_transfer::SealedTransferError;
use super::{BOOTSTRAP_CONTEXT_URL, ProvisionIntegrationError, VC_V2_CONTEXT_URL};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct BootstrapRequest {
#[serde(rename = "@context")]
pub context: Vec<String>,
#[serde(rename = "type")]
pub types: Vec<String>,
pub id: String,
pub holder: String,
pub nonce: String,
#[serde(rename = "validUntil")]
pub valid_until: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
pub ask: BootstrapAsk,
pub proof: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub enum BootstrapAsk {
#[serde(alias = "templateBootstrap")]
TemplateBootstrap(TemplateBootstrapAsk),
#[serde(alias = "adminRotation")]
AdminRotation(AdminRotationAsk),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct TemplateBootstrapAsk {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(rename = "contextHint")]
pub context_hint: Option<String>,
pub template: DidTemplateRef,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(rename = "adminTemplate")]
pub admin_template: Option<DidTemplateRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct AdminRotationAsk {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(rename = "contextHint")]
pub context_hint: Option<String>,
#[serde(rename = "adminTemplate")]
pub admin_template: DidTemplateRef,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct DidTemplateRef {
pub name: String,
#[serde(default)]
pub vars: BTreeMap<String, Value>,
}
impl BootstrapRequest {
pub async fn sign(
ed25519_seed: &[u8; 32],
client_did: &str,
nonce: [u8; 16],
validity: Duration,
label: Option<String>,
ask: BootstrapAsk,
) -> Result<Self, ProvisionIntegrationError> {
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64URL;
let now = Utc::now();
let valid_until = now + validity;
let vm_id = did_key_to_vm(client_did).ok_or_else(|| {
ProvisionIntegrationError::Parse(format!("invalid client_did: {client_did}"))
})?;
let mut signer = Secret::generate_ed25519(Some(&vm_id), Some(ed25519_seed));
signer.id = vm_id.clone();
let unsigned = Self {
context: vec![VC_V2_CONTEXT_URL.into(), BOOTSTRAP_CONTEXT_URL.into()],
types: vec!["VerifiablePresentation".into(), "BootstrapRequest".into()],
id: format!("urn:uuid:{}", uuid::Uuid::new_v4()),
holder: client_did.to_string(),
nonce: B64URL.encode(nonce),
valid_until: rfc3339(valid_until),
label,
ask,
proof: Value::Null, };
let sign_options = SignOptions::new()
.with_proof_purpose("authentication")
.with_created(now);
let mut signing_doc = serde_json::to_value(&unsigned)
.map_err(|e| ProvisionIntegrationError::Parse(format!("serialize VP: {e}")))?;
if let Some(obj) = signing_doc.as_object_mut() {
obj.remove("proof");
}
let proof = DataIntegrityProof::sign(&signing_doc, &signer, sign_options)
.await
.map_err(|e| ProvisionIntegrationError::DataIntegrity(format!("sign VP: {e}")))?;
let mut out = unsigned;
out.proof = serde_json::to_value(&proof)
.map_err(|e| ProvisionIntegrationError::Parse(format!("serialize proof: {e}")))?;
Ok(out)
}
pub fn verify(self) -> Result<VerifiedBootstrapRequest, ProvisionIntegrationError> {
let value = serde_json::to_value(&self)
.map_err(|e| ProvisionIntegrationError::Parse(format!("serialize VP: {e}")))?;
Self::verify_value(value)
}
pub fn verify_value(
value: Value,
) -> Result<VerifiedBootstrapRequest, ProvisionIntegrationError> {
let req: BootstrapRequest = serde_json::from_value(value.clone())
.map_err(|e| ProvisionIntegrationError::Parse(format!("parse VP: {e}")))?;
if !req.types.iter().any(|t| t == "VerifiablePresentation") {
return Err(ProvisionIntegrationError::InvalidClaim(
"type array must include 'VerifiablePresentation'".into(),
));
}
if !req.types.iter().any(|t| t == "BootstrapRequest") {
return Err(ProvisionIntegrationError::InvalidClaim(
"type array must include 'BootstrapRequest'".into(),
));
}
let holder_ed_pub = did_key_helpers::did_key_to_ed25519_pub(&req.holder)
.map_err(|e| ProvisionIntegrationError::HolderMismatch(format!("holder: {e}")))?;
let proof: DataIntegrityProof = serde_json::from_value(req.proof.clone())
.map_err(|e| ProvisionIntegrationError::BadProof(format!("parse proof: {e}")))?;
if !matches!(proof.cryptosuite, CryptoSuite::EddsaJcs2022) {
return Err(ProvisionIntegrationError::BadProof(format!(
"unsupported cryptosuite {:?} (expected eddsa-jcs-2022)",
proof.cryptosuite
)));
}
let vm_did = proof
.verification_method
.split_once('#')
.map(|(d, _)| d)
.ok_or_else(|| {
ProvisionIntegrationError::HolderMismatch("verificationMethod missing '#'".into())
})?;
if vm_did != req.holder {
return Err(ProvisionIntegrationError::HolderMismatch(format!(
"verificationMethod DID '{}' does not match holder '{}'",
vm_did, req.holder
)));
}
let mut signing_doc = value;
if let Some(obj) = signing_doc.as_object_mut() {
obj.remove("proof");
}
proof
.verify_with_public_key(&signing_doc, &holder_ed_pub, VerifyOptions::new())
.map_err(|e| ProvisionIntegrationError::BadProof(format!("verify VP: {e}")))?;
let now = Utc::now();
let skew = Duration::minutes(5);
let vu = req
.valid_until
.parse::<DateTime<Utc>>()
.map_err(|e| ProvisionIntegrationError::Parse(format!("validUntil: {e}")))?;
if vu + skew < now {
return Err(ProvisionIntegrationError::Expired(format!(
"validUntil {vu} has passed"
)));
}
Ok(VerifiedBootstrapRequest { inner: req })
}
}
#[derive(Debug, Clone)]
pub struct VerifiedBootstrapRequest {
inner: BootstrapRequest,
}
impl VerifiedBootstrapRequest {
pub fn holder(&self) -> &str {
&self.inner.holder
}
pub fn decode_nonce(&self) -> Result<[u8; 16], SealedTransferError> {
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64URL;
let raw = B64URL
.decode(&self.inner.nonce)
.map_err(|e| SealedTransferError::Base64(e.to_string()))?;
raw.try_into()
.map_err(|_| SealedTransferError::Wire("nonce must be 16 bytes".into()))
}
pub fn decode_client_ed25519_pub(&self) -> Result<[u8; 32], ProvisionIntegrationError> {
did_key_helpers::did_key_to_ed25519_pub(&self.inner.holder)
.map_err(|e| ProvisionIntegrationError::HolderMismatch(format!("holder did:key: {e}")))
}
pub fn decode_client_x25519_pub(&self) -> Result<[u8; 32], ProvisionIntegrationError> {
let ed = self.decode_client_ed25519_pub()?;
did_key_helpers::ed25519_pub_to_x25519_bytes(&ed).map_err(|e| {
ProvisionIntegrationError::HolderMismatch(format!("holder X25519 derivation: {e}"))
})
}
pub fn ask(&self) -> &BootstrapAsk {
&self.inner.ask
}
pub fn label(&self) -> Option<&str> {
self.inner.label.as_deref()
}
pub fn id(&self) -> &str {
&self.inner.id
}
pub fn valid_until(&self) -> &str {
&self.inner.valid_until
}
pub fn as_wire(&self) -> &BootstrapRequest {
&self.inner
}
pub fn into_wire(self) -> BootstrapRequest {
self.inner
}
}
#[derive(Debug, Clone)]
pub struct ProvisionRequestBuilder {
template: Option<String>,
vars: BTreeMap<String, Value>,
context_hint: Option<String>,
admin_template_name: Option<String>,
admin_template_vars: BTreeMap<String, Value>,
validity: Duration,
label: Option<String>,
note: Option<String>,
}
impl ProvisionRequestBuilder {
pub fn new(template_name: impl Into<String>) -> Self {
Self {
template: Some(template_name.into()),
vars: BTreeMap::new(),
context_hint: None,
admin_template_name: None,
admin_template_vars: BTreeMap::new(),
validity: Duration::hours(1),
label: None,
note: None,
}
}
pub fn for_admin_rotation(admin_template_name: impl Into<String>) -> Self {
Self {
template: None,
vars: BTreeMap::new(),
context_hint: None,
admin_template_name: Some(admin_template_name.into()),
admin_template_vars: BTreeMap::new(),
validity: Duration::hours(1),
label: None,
note: None,
}
}
pub fn var(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
self.vars.insert(key.into(), value.into());
self
}
pub fn vars<I, K, V>(mut self, entries: I) -> Self
where
I: IntoIterator<Item = (K, V)>,
K: Into<String>,
V: Into<Value>,
{
for (k, v) in entries {
self.vars.insert(k.into(), v.into());
}
self
}
pub fn context_hint(mut self, ctx: impl Into<String>) -> Self {
self.context_hint = Some(ctx.into());
self
}
pub fn admin_template(mut self, name: impl Into<String>) -> Self {
self.admin_template_name = Some(name.into());
self
}
pub fn admin_template_var(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
self.admin_template_vars.insert(key.into(), value.into());
self
}
pub fn validity(mut self, d: Duration) -> Self {
self.validity = d;
self
}
pub fn label(mut self, l: impl Into<String>) -> Self {
self.label = Some(l.into());
self
}
pub fn note(mut self, n: impl Into<String>) -> Self {
self.note = Some(n.into());
self
}
fn build_ask(&self) -> BootstrapAsk {
match &self.template {
Some(name) => BootstrapAsk::TemplateBootstrap(TemplateBootstrapAsk {
context_hint: self.context_hint.clone(),
template: DidTemplateRef {
name: name.clone(),
vars: self.vars.clone(),
},
admin_template: self
.admin_template_name
.as_ref()
.map(|name| DidTemplateRef {
name: name.clone(),
vars: self.admin_template_vars.clone(),
}),
note: self.note.clone(),
}),
None => BootstrapAsk::AdminRotation(AdminRotationAsk {
context_hint: self.context_hint.clone(),
admin_template: DidTemplateRef {
name: self
.admin_template_name
.clone()
.expect("admin rotation builder requires an admin template name"),
vars: self.admin_template_vars.clone(),
},
note: self.note.clone(),
}),
}
}
pub async fn sign_with(
self,
ed25519_seed: &[u8; 32],
client_did: &str,
) -> Result<BootstrapRequest, ProvisionIntegrationError> {
let mut nonce = [0u8; 16];
getrandom::fill(&mut nonce)
.map_err(|e| ProvisionIntegrationError::Parse(format!("random nonce: {e}")))?;
let ask = self.build_ask();
BootstrapRequest::sign(
ed25519_seed,
client_did,
nonce,
self.validity,
self.label,
ask,
)
.await
}
pub async fn sign_ephemeral(self) -> Result<SignedEphemeralRequest, ProvisionIntegrationError> {
let (seed, pub_bytes) = crate::sealed_transfer::generate_ed25519_keypair();
let client_did = did_key_helpers::ed25519_pub_to_did_key(&pub_bytes);
let mut bundle_id = [0u8; 16];
getrandom::fill(&mut bundle_id)
.map_err(|e| ProvisionIntegrationError::Parse(format!("random nonce: {e}")))?;
let ask = self.build_ask();
let request = BootstrapRequest::sign(
&seed,
&client_did,
bundle_id,
self.validity,
self.label,
ask,
)
.await?;
Ok(SignedEphemeralRequest {
request,
client_did,
seed,
bundle_id,
})
}
}
#[derive(Debug)]
pub struct SignedEphemeralRequest {
pub request: BootstrapRequest,
pub client_did: String,
pub seed: Zeroizing<[u8; 32]>,
pub bundle_id: [u8; 16],
}
fn did_key_to_vm(did: &str) -> Option<String> {
let rest = did.strip_prefix("did:key:")?;
Some(format!("{did}#{rest}"))
}
fn rfc3339(dt: DateTime<Utc>) -> String {
dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sealed_transfer::generate_ed25519_keypair;
fn sample_ask() -> BootstrapAsk {
BootstrapAsk::TemplateBootstrap(TemplateBootstrapAsk {
context_hint: Some("prod-mediator".into()),
template: DidTemplateRef {
name: "didcomm-mediator".into(),
vars: BTreeMap::from([(
"URL".to_string(),
Value::String("https://mediator.example.com".into()),
)]),
},
admin_template: None,
note: None,
})
}
fn sample_ask_with_admin_rollover() -> BootstrapAsk {
BootstrapAsk::TemplateBootstrap(TemplateBootstrapAsk {
context_hint: Some("prod-mediator".into()),
template: DidTemplateRef {
name: "didcomm-mediator".into(),
vars: BTreeMap::from([(
"URL".to_string(),
Value::String("https://mediator.example.com".into()),
)]),
},
admin_template: Some(DidTemplateRef {
name: "vta-admin".into(),
vars: BTreeMap::new(),
}),
note: None,
})
}
fn sample_client_did(seed_byte: u8) -> ([u8; 32], String) {
let (seed, pub_bytes) = {
let (s, p) = generate_ed25519_keypair();
(*s, p)
};
let _ = seed_byte;
let did = affinidi_crypto::did_key::ed25519_pub_to_did_key(&pub_bytes);
(seed, did)
}
#[tokio::test]
async fn sign_and_verify_round_trip() {
let (seed, client_did) = sample_client_did(1);
let vp = BootstrapRequest::sign(
&seed,
&client_did,
[7u8; 16],
Duration::hours(1),
Some("smoke test".into()),
sample_ask(),
)
.await
.unwrap();
assert_eq!(vp.holder, client_did);
assert!(vp.types.iter().any(|t| t == "VerifiablePresentation"));
assert!(vp.types.iter().any(|t| t == "BootstrapRequest"));
let verified = vp.verify().expect("verify signed VP");
assert_eq!(verified.holder(), client_did);
assert_eq!(verified.decode_nonce().unwrap(), [7u8; 16]);
assert!(matches!(verified.ask(), BootstrapAsk::TemplateBootstrap(_)));
assert_eq!(verified.label(), Some("smoke test"));
}
#[tokio::test]
async fn roundtrip_via_json() {
let (seed, client_did) = sample_client_did(2);
let vp = BootstrapRequest::sign(
&seed,
&client_did,
[9u8; 16],
Duration::hours(1),
None,
sample_ask(),
)
.await
.unwrap();
let json = serde_json::to_string(&vp).unwrap();
let parsed: BootstrapRequest = serde_json::from_str(&json).unwrap();
parsed.verify().expect("verify deserialized VP");
}
#[tokio::test]
async fn tampered_nonce_rejected() {
let (seed, client_did) = sample_client_did(3);
let mut vp = BootstrapRequest::sign(
&seed,
&client_did,
[1u8; 16],
Duration::hours(1),
None,
sample_ask(),
)
.await
.unwrap();
vp.nonce = {
use base64::Engine;
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([2u8; 16])
};
let err = vp.verify().unwrap_err();
assert!(
matches!(err, ProvisionIntegrationError::BadProof(_)),
"expected BadProof, got {err:?}"
);
}
#[tokio::test]
async fn tampered_ask_rejected() {
let (seed, client_did) = sample_client_did(4);
let mut vp = BootstrapRequest::sign(
&seed,
&client_did,
[5u8; 16],
Duration::hours(1),
None,
sample_ask(),
)
.await
.unwrap();
vp.ask = BootstrapAsk::TemplateBootstrap(TemplateBootstrapAsk {
context_hint: Some("prod-mediator".into()),
template: DidTemplateRef {
name: "attacker-template".into(),
vars: BTreeMap::new(),
},
admin_template: None,
note: None,
});
let err = vp.verify().unwrap_err();
assert!(
matches!(err, ProvisionIntegrationError::BadProof(_)),
"expected BadProof, got {err:?}"
);
}
#[tokio::test]
async fn expired_request_rejected() {
let (seed, client_did) = sample_client_did(5);
let vp = BootstrapRequest::sign(
&seed,
&client_did,
[8u8; 16],
Duration::hours(-2),
None,
sample_ask(),
)
.await
.unwrap();
let err = vp.verify().unwrap_err();
assert!(
matches!(err, ProvisionIntegrationError::Expired(_)),
"expected Expired, got {err:?}"
);
}
#[tokio::test]
async fn admin_template_round_trips_through_sign_verify() {
let (seed, client_did) = sample_client_did(11);
let vp = BootstrapRequest::sign(
&seed,
&client_did,
[12u8; 16],
Duration::hours(1),
Some("rollover".into()),
sample_ask_with_admin_rollover(),
)
.await
.unwrap();
let json = serde_json::to_value(&vp).unwrap();
let admin_tpl = &json["ask"]["adminTemplate"];
assert!(!admin_tpl.is_null(), "adminTemplate must serialize");
assert_eq!(admin_tpl["name"], "vta-admin");
let parsed: BootstrapRequest = serde_json::from_value(json).unwrap();
let verified = parsed.verify().expect("verify");
let BootstrapAsk::TemplateBootstrap(ask) = verified.ask() else {
panic!("expected TemplateBootstrap ask")
};
let admin = ask
.admin_template
.as_ref()
.expect("admin_template preserved");
assert_eq!(admin.name, "vta-admin");
}
#[tokio::test]
async fn admin_template_omitted_is_backward_compatible() {
let (seed, client_did) = sample_client_did(13);
let vp = BootstrapRequest::sign(
&seed,
&client_did,
[14u8; 16],
Duration::hours(1),
None,
sample_ask(),
)
.await
.unwrap();
let json = serde_json::to_value(&vp).unwrap();
assert!(
json["ask"].get("adminTemplate").is_none(),
"absent field must be omitted from wire JSON, got: {}",
json["ask"]
);
let parsed: BootstrapRequest = serde_json::from_value(json).unwrap();
let verified = parsed.verify().expect("verify legacy-shape VP");
let BootstrapAsk::TemplateBootstrap(ask) = verified.ask() else {
panic!("expected TemplateBootstrap ask")
};
assert!(ask.admin_template.is_none());
}
#[tokio::test]
async fn tampered_admin_template_rejected() {
let (seed, client_did) = sample_client_did(15);
let mut vp = BootstrapRequest::sign(
&seed,
&client_did,
[16u8; 16],
Duration::hours(1),
None,
sample_ask_with_admin_rollover(),
)
.await
.unwrap();
let BootstrapAsk::TemplateBootstrap(ref mut ask) = vp.ask else {
panic!("expected TemplateBootstrap ask")
};
ask.admin_template = Some(DidTemplateRef {
name: "attacker-admin-template".into(),
vars: BTreeMap::new(),
});
let err = vp.verify().unwrap_err();
assert!(
matches!(err, ProvisionIntegrationError::BadProof(_)),
"expected BadProof, got {err:?}"
);
}
#[tokio::test]
async fn holder_swap_rejected() {
let (seed_a, did_a) = sample_client_did(6);
let (_, did_b) = sample_client_did(7);
let mut vp = BootstrapRequest::sign(
&seed_a,
&did_a,
[4u8; 16],
Duration::hours(1),
None,
sample_ask(),
)
.await
.unwrap();
vp.holder = did_b;
let err = vp.verify().unwrap_err();
assert!(
matches!(err, ProvisionIntegrationError::HolderMismatch(_)),
"expected HolderMismatch, got {err:?}"
);
}
#[test]
fn client_x25519_derivation_matches_sealed_transfer() {
use crate::sealed_transfer::ed25519_seed_to_x25519_secret;
use crate::sealed_transfer::hpke::{open, seal};
let (seed, ed_pub) = {
let (s, p) = generate_ed25519_keypair();
(*s, p)
};
let did = affinidi_crypto::did_key::ed25519_pub_to_did_key(&ed_pub);
let wire = BootstrapRequest {
context: vec![VC_V2_CONTEXT_URL.into(), BOOTSTRAP_CONTEXT_URL.into()],
types: vec!["VerifiablePresentation".into(), "BootstrapRequest".into()],
id: "urn:uuid:test".into(),
holder: did.clone(),
nonce: {
use base64::Engine;
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0u8; 16])
},
valid_until: "2099-01-01T00:00:00Z".into(),
label: None,
ask: sample_ask(),
proof: Value::Null,
};
let verified = VerifiedBootstrapRequest { inner: wire };
let recipient_x_pub = verified.decode_client_x25519_pub().unwrap();
let sealed = seal(&recipient_x_pub, b"hello", b"aad").unwrap();
let x_secret = ed25519_seed_to_x25519_secret(&seed);
let opened = open(&x_secret, &sealed, b"aad").unwrap();
assert_eq!(opened, b"hello");
}
#[tokio::test]
async fn builder_sign_ephemeral_verifies_and_carries_ask() {
let signed = ProvisionRequestBuilder::new("didcomm-mediator")
.var("URL", "https://mediator.example.com")
.context_hint("mediator-prod")
.validity(Duration::hours(2))
.label("builder-smoke-test")
.sign_ephemeral()
.await
.expect("sign_ephemeral");
assert_eq!(signed.request.holder, signed.client_did);
assert!(signed.client_did.starts_with("did:key:z6Mk"));
let verified = signed.request.clone().verify().expect("verify VP");
assert_eq!(verified.decode_nonce().unwrap(), signed.bundle_id);
match verified.ask() {
BootstrapAsk::TemplateBootstrap(ask) => {
assert_eq!(ask.template.name, "didcomm-mediator");
assert_eq!(
ask.template.vars.get("URL").and_then(|v| v.as_str()),
Some("https://mediator.example.com")
);
assert_eq!(ask.context_hint.as_deref(), Some("mediator-prod"));
assert!(ask.admin_template.is_none());
}
other => panic!("expected TemplateBootstrap, got {other:?}"),
}
assert_eq!(verified.label(), Some("builder-smoke-test"));
}
#[tokio::test]
async fn builder_admin_template_attaches_rollover_ref() {
let signed = ProvisionRequestBuilder::new("didcomm-mediator")
.var("URL", "https://mediator.example.com")
.admin_template("vta-admin")
.sign_ephemeral()
.await
.expect("sign_ephemeral");
let verified = signed.request.clone().verify().expect("verify VP");
match verified.ask() {
BootstrapAsk::TemplateBootstrap(ask) => {
let admin = ask.admin_template.as_ref().expect("admin template set");
assert_eq!(admin.name, "vta-admin");
assert!(admin.vars.is_empty());
}
other => panic!("expected TemplateBootstrap, got {other:?}"),
}
}
#[tokio::test]
async fn builder_bulk_vars_preserved() {
let signed = ProvisionRequestBuilder::new("didcomm-mediator")
.vars([
("URL", Value::String("https://m.example.com".into())),
("ROUTING_KEYS", Value::Array(vec![])),
])
.sign_ephemeral()
.await
.expect("sign_ephemeral");
let verified = signed.request.clone().verify().expect("verify VP");
match verified.ask() {
BootstrapAsk::TemplateBootstrap(ask) => {
assert_eq!(
ask.template.vars.get("URL").and_then(|v| v.as_str()),
Some("https://m.example.com")
);
assert!(ask.template.vars.get("ROUTING_KEYS").unwrap().is_array());
}
other => panic!("expected TemplateBootstrap, got {other:?}"),
}
}
#[tokio::test]
async fn builder_sign_with_reuses_caller_identity() {
let (seed, pub_bytes) = {
let (s, p) = crate::sealed_transfer::generate_ed25519_keypair();
(*s, p)
};
let client_did = affinidi_crypto::did_key::ed25519_pub_to_did_key(&pub_bytes);
let vp = ProvisionRequestBuilder::new("didcomm-mediator")
.var("URL", "https://m.example.com")
.sign_with(&seed, &client_did)
.await
.expect("sign_with");
assert_eq!(vp.holder, client_did);
let verified = vp.verify().expect("verify VP");
assert_eq!(verified.holder(), client_did);
}
#[tokio::test]
async fn builder_distinct_bundle_ids_per_call() {
let a = ProvisionRequestBuilder::new("didcomm-mediator")
.var("URL", "https://m.example.com")
.sign_ephemeral()
.await
.unwrap();
let b = ProvisionRequestBuilder::new("didcomm-mediator")
.var("URL", "https://m.example.com")
.sign_ephemeral()
.await
.unwrap();
assert_ne!(a.bundle_id, b.bundle_id);
assert_ne!(a.client_did, b.client_did);
}
async fn build_valid_vp() -> (BootstrapRequest, String) {
let (seed, client_did) = sample_client_did(0xAA);
let vp = BootstrapRequest::sign(
&seed,
&client_did,
[0xBB; 16],
Duration::hours(1),
Some("item-19-fixture".into()),
sample_ask(),
)
.await
.expect("sign fixture VP");
(vp, client_did)
}
#[tokio::test]
async fn verify_fixture_round_trips() {
let (vp, _) = build_valid_vp().await;
vp.verify().expect("fixture VP verifies");
}
async fn build_valid_camelcase_vp() -> (Value, String) {
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64URL;
let (seed, client_did) = sample_client_did(0xD2);
let vm_id = did_key_to_vm(&client_did).expect("vm id");
let mut signer = Secret::generate_ed25519(Some(&vm_id), Some(&seed));
signer.id = vm_id;
let now = Utc::now();
let mut doc = serde_json::json!({
"@context": [VC_V2_CONTEXT_URL, BOOTSTRAP_CONTEXT_URL],
"type": ["VerifiablePresentation", "BootstrapRequest"],
"id": format!("urn:uuid:{}", uuid::Uuid::new_v4()),
"holder": client_did,
"nonce": B64URL.encode([0xD2u8; 16]),
"validUntil": rfc3339(now + Duration::hours(1)),
"label": "v0.2-camelcase-fixture",
"ask": {
"type": "templateBootstrap",
"template": {
"name": "didcomm-mediator",
"vars": { "URL": "https://mediator.example.com" }
}
}
});
let sign_options = SignOptions::new()
.with_proof_purpose("authentication")
.with_created(now);
let proof = DataIntegrityProof::sign(&doc, &signer, sign_options)
.await
.expect("sign camelCase VP");
doc.as_object_mut()
.unwrap()
.insert("proof".into(), serde_json::to_value(&proof).unwrap());
(doc, client_did)
}
#[tokio::test]
async fn verify_value_accepts_v0_2_camelcase_ask() {
let (doc, holder) = build_valid_camelcase_vp().await;
let verified = BootstrapRequest::verify_value(doc).expect("0.2 camelCase VP verifies");
assert_eq!(verified.holder(), holder);
assert!(matches!(verified.ask(), BootstrapAsk::TemplateBootstrap(_)));
}
#[tokio::test]
async fn verify_value_rejects_tampered_v0_2_ask() {
let (mut doc, _) = build_valid_camelcase_vp().await;
doc["ask"]["template"]["name"] = Value::String("swapped-template".into());
let err = BootstrapRequest::verify_value(doc).unwrap_err();
assert!(
matches!(err, ProvisionIntegrationError::BadProof(_)),
"tampered 0.2 ask must yield BadProof, got {err:?}"
);
}
#[tokio::test]
async fn verify_rejects_holder_mutation() {
let (mut vp, _) = build_valid_vp().await;
let (_other_seed, other_did) = sample_client_did(0xCC);
vp.holder = other_did;
let err = vp.verify().unwrap_err();
assert!(
matches!(err, ProvisionIntegrationError::HolderMismatch(_)),
"swapping holder must yield HolderMismatch, got {err:?}"
);
}
#[tokio::test]
async fn verify_rejects_nonce_mutation() {
let (mut vp, _) = build_valid_vp().await;
vp.nonce = "CCCCCCCCCCCCCCCCCCCCCC".to_string();
let err = vp.verify().unwrap_err();
assert!(
matches!(err, ProvisionIntegrationError::BadProof(_)),
"nonce mutation must yield BadProof, got {err:?}"
);
}
#[tokio::test]
async fn verify_rejects_ask_mutation() {
let (mut vp, _) = build_valid_vp().await;
let BootstrapAsk::TemplateBootstrap(ref mut ask) = vp.ask else {
panic!("expected TemplateBootstrap ask")
};
ask.template.name = "swapped-template".into();
let err = vp.verify().unwrap_err();
assert!(
matches!(err, ProvisionIntegrationError::BadProof(_)),
"ask.template.name mutation must yield BadProof, got {err:?}"
);
}
#[tokio::test]
async fn verify_rejects_valid_until_mutation() {
let (mut vp, _) = build_valid_vp().await;
vp.valid_until = "2099-01-01T00:00:00Z".to_string();
let err = vp.verify().unwrap_err();
assert!(
matches!(err, ProvisionIntegrationError::BadProof(_)),
"validUntil mutation must yield BadProof, got {err:?}"
);
}
#[tokio::test]
async fn verify_rejects_id_mutation() {
let (mut vp, _) = build_valid_vp().await;
vp.id = "urn:uuid:11111111-1111-1111-1111-111111111111".to_string();
let err = vp.verify().unwrap_err();
assert!(
matches!(err, ProvisionIntegrationError::BadProof(_)),
"id mutation must yield BadProof, got {err:?}"
);
}
#[tokio::test]
async fn verify_rejects_label_mutation() {
let (mut vp, _) = build_valid_vp().await;
vp.label = Some("attacker-controlled-label".into());
let err = vp.verify().unwrap_err();
assert!(
matches!(err, ProvisionIntegrationError::BadProof(_)),
"label mutation must yield BadProof, got {err:?}"
);
}
#[tokio::test]
async fn verify_rejects_missing_vp_type() {
let (mut vp, _) = build_valid_vp().await;
vp.types.retain(|t| t != "VerifiablePresentation");
let err = vp.verify().unwrap_err();
assert!(
matches!(err, ProvisionIntegrationError::InvalidClaim(_)),
"missing VerifiablePresentation must yield InvalidClaim, got {err:?}"
);
}
#[tokio::test]
async fn verify_rejects_missing_bootstrap_request_type() {
let (mut vp, _) = build_valid_vp().await;
vp.types.retain(|t| t != "BootstrapRequest");
let err = vp.verify().unwrap_err();
assert!(
matches!(err, ProvisionIntegrationError::InvalidClaim(_)),
"missing BootstrapRequest must yield InvalidClaim, got {err:?}"
);
}
#[tokio::test]
async fn verify_rejects_cryptosuite_downgrade() {
let (mut vp, _) = build_valid_vp().await;
let mut proof_obj = vp.proof.as_object().expect("proof is an object").clone();
proof_obj.insert(
"cryptosuite".to_string(),
Value::String("eddsa-rdfc-2022".into()),
);
vp.proof = Value::Object(proof_obj);
let err = vp.verify().unwrap_err();
assert!(
matches!(err, ProvisionIntegrationError::BadProof(_)),
"cryptosuite downgrade must yield BadProof, got {err:?}"
);
}
#[tokio::test]
async fn verify_rejects_verification_method_did_swap() {
let (mut vp, _) = build_valid_vp().await;
let (_other_seed, other_did) = sample_client_did(0xDD);
let mut proof_obj = vp.proof.as_object().expect("proof is an object").clone();
proof_obj.insert(
"verificationMethod".into(),
Value::String(format!("{other_did}#key")),
);
vp.proof = Value::Object(proof_obj);
let err = vp.verify().unwrap_err();
assert!(
matches!(err, ProvisionIntegrationError::HolderMismatch(_)),
"VM DID swap must yield HolderMismatch, got {err:?}"
);
}
#[tokio::test]
async fn verify_rejects_signature_byte_flip() {
let (mut vp, _) = build_valid_vp().await;
let mut proof_obj = vp.proof.as_object().expect("proof is an object").clone();
let original_pv = proof_obj
.get("proofValue")
.and_then(|v| v.as_str())
.expect("proofValue present")
.to_string();
let last = original_pv.chars().next_back().unwrap();
let replacement = if last == '1' { '2' } else { '1' };
let mut mutated: String = original_pv.chars().take(original_pv.len() - 1).collect();
mutated.push(replacement);
proof_obj.insert("proofValue".into(), Value::String(mutated));
vp.proof = Value::Object(proof_obj);
let err = vp.verify().unwrap_err();
assert!(
matches!(err, ProvisionIntegrationError::BadProof(_)),
"signature byte-flip must yield BadProof, got {err:?}"
);
}
#[tokio::test]
async fn verify_rejects_malformed_proof_object() {
let (mut vp, _) = build_valid_vp().await;
vp.proof = Value::String("not-a-proof".into());
let err = vp.verify().unwrap_err();
assert!(
matches!(err, ProvisionIntegrationError::BadProof(_)),
"malformed proof must yield BadProof, got {err:?}"
);
}
#[test]
fn deserialize_rejects_unknown_top_level_field() {
let json = serde_json::json!({
"@context": [VC_V2_CONTEXT_URL, BOOTSTRAP_CONTEXT_URL],
"type": ["VerifiablePresentation", "BootstrapRequest"],
"id": "urn:uuid:00000000-0000-0000-0000-000000000000",
"holder": "did:key:zTest",
"nonce": "AAAAAAAAAAAAAAAAAAAAAA",
"validUntil": "2099-01-01T00:00:00Z",
"ask": {
"type": "TemplateBootstrap",
"template": { "name": "didcomm-mediator" }
},
"proof": {},
"extraSneakyField": "attacker-controlled-value"
});
let err = serde_json::from_value::<BootstrapRequest>(json).unwrap_err();
assert!(
err.to_string().contains("extraSneakyField")
|| err.to_string().contains("unknown field"),
"error must mention the rejected field, got: {err}"
);
}
#[test]
fn deserialize_rejects_unknown_field_in_ask() {
let json = serde_json::json!({
"@context": [VC_V2_CONTEXT_URL, BOOTSTRAP_CONTEXT_URL],
"type": ["VerifiablePresentation", "BootstrapRequest"],
"id": "urn:uuid:00000000-0000-0000-0000-000000000000",
"holder": "did:key:zTest",
"nonce": "AAAAAAAAAAAAAAAAAAAAAA",
"validUntil": "2099-01-01T00:00:00Z",
"ask": {
"type": "TemplateBootstrap",
"template": { "name": "didcomm-mediator" },
"smugglerField": 42
},
"proof": {}
});
let err = serde_json::from_value::<BootstrapRequest>(json).unwrap_err();
assert!(
err.to_string().contains("smugglerField") || err.to_string().contains("unknown field"),
"error must mention the rejected nested field, got: {err}"
);
}
fn sample_admin_rotation_ask() -> BootstrapAsk {
BootstrapAsk::AdminRotation(AdminRotationAsk {
context_hint: Some("prod-mediator".into()),
admin_template: DidTemplateRef {
name: "vta-admin".into(),
vars: BTreeMap::new(),
},
note: None,
})
}
#[tokio::test]
async fn admin_rotation_ask_round_trips_through_sign_verify() {
let (seed, client_did) = sample_client_did(0xE0);
let vp = BootstrapRequest::sign(
&seed,
&client_did,
[0xE1; 16],
Duration::hours(1),
Some("admin-rotation".into()),
sample_admin_rotation_ask(),
)
.await
.unwrap();
let json = serde_json::to_value(&vp).unwrap();
assert_eq!(json["ask"]["type"], "AdminRotation");
assert_eq!(json["ask"]["adminTemplate"]["name"], "vta-admin");
let parsed: BootstrapRequest = serde_json::from_value(json).unwrap();
let verified = parsed.verify().expect("verify");
match verified.ask() {
BootstrapAsk::AdminRotation(ask) => {
assert_eq!(ask.admin_template.name, "vta-admin");
assert_eq!(ask.context_hint.as_deref(), Some("prod-mediator"));
}
other => panic!("expected AdminRotation, got {other:?}"),
}
}
#[tokio::test]
async fn admin_rotation_tampered_template_rejected() {
let (seed, client_did) = sample_client_did(0xE2);
let mut vp = BootstrapRequest::sign(
&seed,
&client_did,
[0xE3; 16],
Duration::hours(1),
None,
sample_admin_rotation_ask(),
)
.await
.unwrap();
let BootstrapAsk::AdminRotation(ref mut ask) = vp.ask else {
panic!("expected AdminRotation ask")
};
ask.admin_template.name = "attacker-admin-template".into();
let err = vp.verify().unwrap_err();
assert!(
matches!(err, ProvisionIntegrationError::BadProof(_)),
"expected BadProof, got {err:?}"
);
}
#[tokio::test]
async fn for_admin_rotation_builder_emits_admin_rotation_variant() {
let signed = ProvisionRequestBuilder::for_admin_rotation("vta-admin")
.context_hint("ctx-1")
.label("openvtc-cli2 first-boot")
.sign_ephemeral()
.await
.expect("sign_ephemeral");
let verified = signed.request.clone().verify().expect("verify VP");
match verified.ask() {
BootstrapAsk::AdminRotation(ask) => {
assert_eq!(ask.admin_template.name, "vta-admin");
assert_eq!(ask.context_hint.as_deref(), Some("ctx-1"));
}
other => panic!("expected AdminRotation, got {other:?}"),
}
assert_eq!(verified.label(), Some("openvtc-cli2 first-boot"));
}
}