use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AdminRotationPayload {
pub authorization: serde_json::Value,
pub admin: DidKeyMaterial,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vta_url: Option<String>,
pub vta_trust: VtaTrustBundle,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TemplateBootstrapPayload {
pub authorization: serde_json::Value,
pub secrets: BTreeMap<String, DidKeyMaterial>,
pub config: TemplateBootstrapConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DidKeyMaterial {
pub did: String,
pub signing_key: KeyPair,
pub ka_key: KeyPair,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct KeyPair {
pub key_id: String,
pub public_key_multibase: String,
pub private_key_multibase: String,
}
impl KeyPair {
pub fn private_zeroizing(&self) -> Zeroizing<String> {
Zeroizing::new(self.private_key_multibase.clone())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TemplateBootstrapPayloadV2 {
pub authorization: serde_json::Value,
pub secrets: BTreeMap<String, DidKeyMaterialV2>,
pub config: TemplateBootstrapConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DidKeyMaterialV2 {
pub did: String,
pub signing_key: SlotKeyPair,
pub ka_key: SlotKeyPair,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub additional_signing_keys: Vec<SlotKeyPair>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SlotKeyPair {
pub slot: String,
pub key_type: crate::keys::KeyType,
pub key_id: String,
pub public_key_multibase: String,
pub private_key_multibase: String,
}
impl DidKeyMaterialV2 {
pub fn from_v1(v1: &DidKeyMaterial) -> Self {
use crate::did_templates::{SLOT_KA, SLOT_SIGNING};
use crate::keys::KeyType;
Self {
did: v1.did.clone(),
signing_key: SlotKeyPair::from_v1_pair(SLOT_SIGNING, &v1.signing_key, KeyType::Ed25519),
ka_key: SlotKeyPair::from_v1_pair(SLOT_KA, &v1.ka_key, KeyType::X25519),
additional_signing_keys: Vec::new(),
}
}
}
impl SlotKeyPair {
fn from_v1_pair(slot: &str, pair: &KeyPair, contract: crate::keys::KeyType) -> Self {
Self {
slot: slot.to_string(),
key_type: crate::keys::KeyType::from_public_multibase(&pair.public_key_multibase)
.unwrap_or(contract),
key_id: pair.key_id.clone(),
public_key_multibase: pair.public_key_multibase.clone(),
private_key_multibase: pair.private_key_multibase.clone(),
}
}
pub fn private_zeroizing(&self) -> Zeroizing<String> {
Zeroizing::new(self.private_key_multibase.clone())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TemplateBootstrapConfig {
pub template_name: String,
pub template_kind: String,
pub did_document: serde_json::Value,
pub outputs: Vec<TemplateOutput>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vta_url: Option<String>,
pub vta_trust: VtaTrustBundle,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum TemplateOutput {
WebvhLog {
did: String,
log: String,
},
DidCommService {
did: String,
url: String,
accept: Vec<String>,
routing_keys: Vec<String>,
},
Generic {
kind: String,
payload: serde_json::Value,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct VtaTrustBundle {
pub vta_did: String,
pub vta_did_document: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vta_did_log: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sealed_transfer::SealedPayloadV1;
use serde_json::json;
fn sample_payload() -> TemplateBootstrapPayload {
TemplateBootstrapPayload {
authorization: json!({ "type": ["VerifiableCredential", "VtaAuthorizationCredential"] }),
secrets: BTreeMap::from([(
"did:webvh:mediator.example.com".to_string(),
DidKeyMaterial {
did: "did:webvh:mediator.example.com".into(),
signing_key: KeyPair {
key_id: "did:webvh:mediator.example.com#key-0".into(),
public_key_multibase: "z6Mk...".into(),
private_key_multibase: "z...".into(),
},
ka_key: KeyPair {
key_id: "did:webvh:mediator.example.com#key-1".into(),
public_key_multibase: "z6LS...".into(),
private_key_multibase: "z...".into(),
},
},
)]),
config: TemplateBootstrapConfig {
template_name: "didcomm-mediator".into(),
template_kind: "mediator".into(),
did_document: json!({ "id": "did:webvh:mediator.example.com" }),
outputs: vec![TemplateOutput::WebvhLog {
did: "did:webvh:mediator.example.com".into(),
log: "{...}".into(),
}],
vta_url: Some("https://vta.example.com".into()),
vta_trust: VtaTrustBundle {
vta_did: "did:webvh:vta.example.com".into(),
vta_did_document: json!({ "id": "did:webvh:vta.example.com" }),
vta_did_log: Some("{...}".into()),
},
},
}
}
#[test]
fn template_bootstrap_payload_json_round_trip() {
let payload = sample_payload();
let json = serde_json::to_string(&payload).unwrap();
let parsed: TemplateBootstrapPayload = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.config.template_name, "didcomm-mediator");
assert_eq!(parsed.secrets.len(), 1);
}
#[test]
fn sealed_payload_variant_round_trip() {
let payload = SealedPayloadV1::TemplateBootstrap(Box::new(sample_payload()));
let json = serde_json::to_string(&payload).unwrap();
let parsed: SealedPayloadV1 = serde_json::from_str(&json).unwrap();
match parsed {
SealedPayloadV1::TemplateBootstrap(p) => {
assert_eq!(p.config.template_kind, "mediator");
assert_eq!(p.config.outputs.len(), 1);
}
other => panic!("expected TemplateBootstrap, got {other:?}"),
}
}
#[test]
fn template_output_webvh_log_tag_on_wire() {
let out = TemplateOutput::WebvhLog {
did: "did:webvh:x".into(),
log: "line".into(),
};
let v = serde_json::to_value(&out).unwrap();
assert_eq!(v["type"], "webvh_log");
}
#[test]
fn template_output_generic_round_trips_arbitrary_payload() {
let original = TemplateOutput::Generic {
kind: "status-list".into(),
payload: json!({
"statusListUrl": "https://issuer.example.com/status",
"expires": "2026-12-31T00:00:00Z",
}),
};
let wire = serde_json::to_value(&original).unwrap();
assert_eq!(wire["type"], "generic");
assert_eq!(wire["kind"], "status-list");
assert_eq!(
wire["payload"]["statusListUrl"],
"https://issuer.example.com/status"
);
let parsed: TemplateOutput = serde_json::from_value(wire).unwrap();
match parsed {
TemplateOutput::Generic { kind, payload } => {
assert_eq!(kind, "status-list");
assert_eq!(payload["expires"], "2026-12-31T00:00:00Z");
}
other => panic!("expected Generic, got {other:?}"),
}
}
fn sample_admin_rotation_payload() -> AdminRotationPayload {
AdminRotationPayload {
authorization: json!({ "type": ["VerifiableCredential", "VtaAuthorizationCredential"] }),
admin: DidKeyMaterial {
did: "did:key:z6MkAdmin".into(),
signing_key: KeyPair {
key_id: "did:key:z6MkAdmin#z6MkAdmin".into(),
public_key_multibase: "z6MkAdminSigning".into(),
private_key_multibase: "zAdminSigningPriv".into(),
},
ka_key: KeyPair {
key_id: "did:key:z6MkAdmin#z6LSAdmin".into(),
public_key_multibase: "z6LSAdminKa".into(),
private_key_multibase: "zAdminKaPriv".into(),
},
},
vta_url: Some("https://vta.example.com".into()),
vta_trust: VtaTrustBundle {
vta_did: "did:webvh:vta.example.com".into(),
vta_did_document: json!({ "id": "did:webvh:vta.example.com" }),
vta_did_log: None,
},
}
}
#[test]
fn admin_rotation_payload_json_round_trip() {
let payload = sample_admin_rotation_payload();
let json = serde_json::to_string(&payload).unwrap();
let parsed: AdminRotationPayload = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.admin.did, "did:key:z6MkAdmin");
assert_eq!(parsed.vta_url.as_deref(), Some("https://vta.example.com"));
}
#[test]
fn admin_rotation_sealed_payload_variant_round_trip() {
let payload = SealedPayloadV1::AdminRotation(Box::new(sample_admin_rotation_payload()));
let json = serde_json::to_string(&payload).unwrap();
assert!(
json.contains("\"admin_rotation\""),
"wire tag must be snake_case `admin_rotation`, got: {json}"
);
let parsed: SealedPayloadV1 = serde_json::from_str(&json).unwrap();
match parsed {
SealedPayloadV1::AdminRotation(p) => {
assert_eq!(p.admin.did, "did:key:z6MkAdmin");
}
other => panic!("expected AdminRotation, got {other:?}"),
}
}
#[test]
fn template_output_generic_coexists_with_typed_variants() {
let outputs = vec![
TemplateOutput::WebvhLog {
did: "did:webvh:mediator.example".into(),
log: "one\ntwo\n".into(),
},
TemplateOutput::Generic {
kind: "oob-invitation".into(),
payload: json!({"url": "https://mediator.example/oob?c=abc"}),
},
];
let wire = serde_json::to_string(&outputs).unwrap();
let parsed: Vec<TemplateOutput> = serde_json::from_str(&wire).unwrap();
assert_eq!(parsed.len(), 2);
assert!(matches!(parsed[0], TemplateOutput::WebvhLog { .. }));
match &parsed[1] {
TemplateOutput::Generic { kind, .. } => assert_eq!(kind, "oob-invitation"),
other => panic!("expected Generic at index 1, got {other:?}"),
}
}
}
#[cfg(test)]
mod v2_tests {
use super::*;
use crate::keys::KeyType;
use crate::sealed_transfer::SealedPayloadV1;
use serde_json::json;
fn config() -> TemplateBootstrapConfig {
TemplateBootstrapConfig {
template_name: "vtc-host".into(),
template_kind: "vtc-host".into(),
did_document: json!({ "id": "did:webvh:vtc.example" }),
outputs: vec![],
vta_url: None,
vta_trust: VtaTrustBundle {
vta_did: "did:webvh:vta.example".into(),
vta_did_document: json!({ "id": "did:webvh:vta.example" }),
vta_did_log: None,
},
}
}
fn hybrid_material() -> DidKeyMaterialV2 {
DidKeyMaterialV2 {
did: "did:webvh:vtc.example".into(),
signing_key: SlotKeyPair {
slot: "signing".into(),
key_type: KeyType::Ed25519,
key_id: "did:webvh:vtc.example#key-0".into(),
public_key_multibase: "z6MkSigning".into(),
private_key_multibase: "zPrivSigning".into(),
},
ka_key: SlotKeyPair {
slot: "ka".into(),
key_type: KeyType::X25519,
key_id: "did:webvh:vtc.example#key-1".into(),
public_key_multibase: "z6LSka".into(),
private_key_multibase: "zPrivKa".into(),
},
additional_signing_keys: vec![SlotKeyPair {
slot: "pq-signing".into(),
key_type: KeyType::MlDsa44,
key_id: "did:webvh:vtc.example#key-2".into(),
public_key_multibase: "zPqPublic".into(),
private_key_multibase: "zPqPrivate".into(),
}],
}
}
#[test]
fn a_field_addition_would_have_been_rejected_as_corruption() {
let with_extra_field = json!({
"did": "did:webvh:x",
"signing_key": {"key_id":"did:webvh:x#key-0","public_key_multibase":"z1","private_key_multibase":"z2"},
"ka_key": {"key_id":"did:webvh:x#key-1","public_key_multibase":"z3","private_key_multibase":"z4"},
"pq_signing_key": {"key_id":"did:webvh:x#key-2","public_key_multibase":"z5","private_key_multibase":"z6"},
});
let err = serde_json::from_value::<DidKeyMaterial>(with_extra_field)
.expect_err("deny_unknown_fields refuses a third key field");
assert!(
err.to_string().contains("unknown field `pq_signing_key`"),
"got: {err}"
);
let future_variant = json!({ "template_bootstrap_v9": { "anything": 1 } });
let err = serde_json::from_value::<SealedPayloadV1>(future_variant)
.expect_err("an opener cannot read a variant it does not have");
assert!(
err.to_string()
.contains("unknown variant `template_bootstrap_v9`"),
"an unrecognised payload must fail by naming the variant: {err}"
);
}
#[test]
fn the_v2_payload_round_trips_through_the_sealed_envelope() {
let payload = SealedPayloadV1::TemplateBootstrapV2(Box::new(TemplateBootstrapPayloadV2 {
authorization: json!({ "type": ["VerifiableCredential"] }),
secrets: BTreeMap::from([("did:webvh:vtc.example".to_string(), hybrid_material())]),
config: config(),
}));
let json = serde_json::to_string(&payload).unwrap();
assert!(
json.contains("\"template_bootstrap_v2\""),
"the wire tag must be snake_case, matching every other variant: {json}"
);
let parsed: SealedPayloadV1 = serde_json::from_str(&json).unwrap();
let SealedPayloadV1::TemplateBootstrapV2(p) = parsed else {
panic!("expected TemplateBootstrapV2");
};
let material = &p.secrets["did:webvh:vtc.example"];
assert_eq!(material.additional_signing_keys.len(), 1);
assert_eq!(material.additional_signing_keys[0].slot, "pq-signing");
assert_eq!(
material.additional_signing_keys[0].key_type,
KeyType::MlDsa44,
"the algorithm must survive the wire as a value, not be re-inferred"
);
}
#[test]
fn an_empty_additional_list_is_absent_from_the_wire() {
let mut material = hybrid_material();
material.additional_signing_keys.clear();
let json = serde_json::to_string(&material).unwrap();
assert!(
!json.contains("additional_signing_keys"),
"an empty list must not appear on the wire: {json}"
);
let parsed: DidKeyMaterialV2 = serde_json::from_str(&json).unwrap();
assert!(parsed.additional_signing_keys.is_empty());
}
#[test]
fn a_v1_payload_lifts_with_its_algorithms_read_from_the_keys() {
let ed = multibase::encode(
multibase::Base::Base58Btc,
[&[0xed, 0x01][..], &[7u8; 32][..]].concat(),
);
let x = multibase::encode(
multibase::Base::Base58Btc,
[&[0xec, 0x01][..], &[9u8; 32][..]].concat(),
);
let v1 = DidKeyMaterial {
did: "did:webvh:x".into(),
signing_key: KeyPair {
key_id: "did:webvh:x#key-0".into(),
public_key_multibase: ed,
private_key_multibase: "zPriv0".into(),
},
ka_key: KeyPair {
key_id: "did:webvh:x#key-1".into(),
public_key_multibase: x,
private_key_multibase: "zPriv1".into(),
},
};
let lifted = DidKeyMaterialV2::from_v1(&v1);
assert_eq!(lifted.signing_key.slot, "signing");
assert_eq!(lifted.signing_key.key_type, KeyType::Ed25519);
assert_eq!(lifted.ka_key.slot, "ka");
assert_eq!(lifted.ka_key.key_type, KeyType::X25519);
assert!(lifted.additional_signing_keys.is_empty());
}
#[test]
fn a_v1_key_the_multicodec_cannot_classify_takes_the_formats_word() {
let junk = multibase::encode(
multibase::Base::Base58Btc,
[&[0xff, 0xff][..], &[1u8; 32][..]].concat(),
);
let v1 = DidKeyMaterial {
did: "did:webvh:x".into(),
signing_key: KeyPair {
key_id: "did:webvh:x#key-0".into(),
public_key_multibase: junk.clone(),
private_key_multibase: "zPriv0".into(),
},
ka_key: KeyPair {
key_id: "did:webvh:x#key-1".into(),
public_key_multibase: "z6LSNotARealKey".into(),
private_key_multibase: "zPriv1".into(),
},
};
let lifted = DidKeyMaterialV2::from_v1(&v1);
assert_eq!(
lifted.signing_key.key_type,
KeyType::Ed25519,
"V1's signing slot is Ed25519 by definition of the format"
);
assert_eq!(
lifted.ka_key.key_type,
KeyType::X25519,
"V1's key-agreement slot is X25519 by definition of the format"
);
}
#[test]
fn a_readable_multicodec_beats_the_format_contract() {
let pq = multibase::encode(
multibase::Base::Base58Btc,
[KeyType::MlDsa44.multicodec_public(), &[7u8; 32][..]].concat(),
);
let v1 = DidKeyMaterial {
did: "did:webvh:x".into(),
signing_key: KeyPair {
key_id: "did:webvh:x#key-0".into(),
public_key_multibase: pq,
private_key_multibase: "zPriv0".into(),
},
ka_key: KeyPair {
key_id: "did:webvh:x#key-1".into(),
public_key_multibase: "zJunk".into(),
private_key_multibase: "zPriv1".into(),
},
};
assert_eq!(
DidKeyMaterialV2::from_v1(&v1).signing_key.key_type,
KeyType::MlDsa44,
"a readable prefix must not be overridden by the contract"
);
}
#[test]
fn the_v1_variant_is_byte_identical_to_what_it_always_was() {
let payload = SealedPayloadV1::TemplateBootstrap(Box::new(TemplateBootstrapPayload {
authorization: json!({}),
secrets: BTreeMap::new(),
config: config(),
}));
let json = serde_json::to_string(&payload).unwrap();
assert!(json.contains("\"template_bootstrap\""));
assert!(
!json.contains("template_bootstrap_v2"),
"a V1 payload must not acquire the V2 tag: {json}"
);
}
}