use std::path::Path;
use affinidi_tdk::secrets_resolver::secrets::Secret;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD as BASE64;
use vta_sdk::credentials::CredentialBundle;
use crate::server::config::TransportSelection;
use crate::server::setup_recipe::hosting_url_for;
pub const SELF_MANAGED_DAEMON_ONLY: &str = "self-managed mode is daemon-only in v1 — re-run setup with did-hosting-daemon. \
See docs/self-managed-mode-spec.md for the full rationale.";
fn write_secret_file_0600(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)?;
}
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut tmp_path = path.to_path_buf();
let suffix: u128 = rand::random::<u128>();
let file_name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "secret".to_string());
tmp_path.set_file_name(format!(".{file_name}.tmp.{suffix:032x}"));
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&tmp_path)?;
f.write_all(bytes)?;
f.sync_all()?;
drop(f);
match std::fs::rename(&tmp_path, path) {
Ok(()) => Ok(()),
Err(e) => {
let _ = std::fs::remove_file(&tmp_path);
Err(e)
}
}
}
#[cfg(not(unix))]
{
std::fs::write(path, bytes)
}
}
pub async fn resolve_vta_mediator(vta_did: &str) -> Option<String> {
eprintln!(" Checking VTA for mediator configuration...");
match tokio::time::timeout(
std::time::Duration::from_secs(10),
vta_sdk::session::resolve_mediator_did(vta_did),
)
.await
{
Ok(Ok(mediator)) => mediator,
Ok(Err(_)) | Err(_) => None,
}
}
pub fn generate_admin_did_key() -> (String, String) {
let secret = Secret::generate_ed25519(None, None);
let pk_multibase = secret
.get_public_keymultibase()
.expect("ed25519 public key multibase");
let sk_multibase = secret
.get_private_keymultibase()
.expect("ed25519 private key multibase");
let did = format!("did:key:{pk_multibase}");
(did, sk_multibase)
}
pub fn write_log_entry_file(log_entry: &str, output_path: &Path) -> Result<(), std::io::Error> {
if let Some(parent) = output_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(output_path, log_entry)
}
pub fn generate_ed25519_multibase() -> String {
let secret = Secret::generate_ed25519(None, None);
secret
.get_private_keymultibase()
.expect("ed25519 multibase encoding")
}
pub struct OnlineProvisionInputs {
pub vta_did: String,
pub context_id: String,
pub ask: vta_sdk::provision_client::ProvisionAsk,
pub messages: std::sync::Arc<dyn vta_sdk::provision_client::OperatorMessages>,
pub setup_key: vta_sdk::provision_client::EphemeralSetupKey,
}
pub struct OnlineProvisionOutcome {
pub integration_did: String,
pub integration_signing_key_mb: String,
pub integration_ka_key_mb: String,
pub did_log_entry: Option<String>,
pub admin_did: String,
pub admin_signing_key_mb: String,
pub vta_did: String,
pub vta_url: Option<String>,
pub vta_credential_b64: String,
}
pub async fn online_provision_setup(
inputs: OnlineProvisionInputs,
) -> Result<OnlineProvisionOutcome, Box<dyn std::error::Error>> {
use vta_sdk::provision_client::{VtaIntent, run_provision};
let OnlineProvisionInputs {
vta_did,
context_id: _context_id,
ask,
messages,
setup_key,
} = inputs;
let setup_did = setup_key.did.clone();
let setup_pk_mb = setup_key.private_key_multibase().to_string();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let drain = tokio::spawn(async move { drain_provision_events_to_stderr(&mut rx).await });
let reply = run_provision(
VtaIntent::FullSetup,
vta_did.clone(),
setup_did,
setup_pk_mb.clone(),
ask,
None,
messages,
tx,
)
.await?;
let _ = drain.await;
reply_to_outcome(reply, vta_did, &setup_pk_mb)
}
pub async fn online_provision_flight(
vta_did: String,
setup_key: vta_sdk::provision_client::EphemeralSetupKey,
ask: vta_sdk::provision_client::ProvisionAsk,
messages: std::sync::Arc<dyn vta_sdk::provision_client::OperatorMessages>,
) -> Result<OnlineProvisionOutcome, Box<dyn std::error::Error>> {
use vta_sdk::provision_client::{
VtaEvent, VtaIntent, run_connection_test, run_provision_flight,
};
let setup_did = setup_key.did.clone();
let setup_pk_mb = setup_key.private_key_multibase().to_string();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
tokio::spawn(run_connection_test(
VtaIntent::FullSetup,
vta_did.clone(),
setup_did.clone(),
setup_pk_mb.clone(),
ask.clone(),
None,
tx,
));
while let Some(event) = rx.recv().await {
match event {
VtaEvent::Connected { reply, .. } => {
return reply_to_outcome(reply, vta_did, &setup_pk_mb);
}
VtaEvent::Failed(reason) => {
eprintln!();
eprintln!(" ✗ {reason}");
return Err(reason.into());
}
VtaEvent::PreflightDone {
rest_url,
mediator_did,
..
} => {
let (ftx, mut frx) = tokio::sync::mpsc::unbounded_channel();
tokio::spawn(run_provision_flight(
vta_did.clone(),
setup_did.clone(),
setup_pk_mb.clone(),
mediator_did,
rest_url,
ask.clone(),
None,
None,
messages.clone(),
ftx,
));
while let Some(fev) = frx.recv().await {
match fev {
VtaEvent::Connected { reply, .. } => {
return reply_to_outcome(reply, vta_did, &setup_pk_mb);
}
VtaEvent::Failed(reason) => {
eprintln!();
eprintln!(" ✗ {reason}");
return Err(reason.into());
}
other => render_event_to_stderr(other),
}
}
return Err("provisioning ended without a terminal event".into());
}
other => render_event_to_stderr(other),
}
}
Err("provisioning ended without a terminal event".into())
}
fn reply_to_outcome(
reply: vta_sdk::provision_client::VtaReply,
vta_did: String,
setup_pk_mb: &str,
) -> Result<OnlineProvisionOutcome, Box<dyn std::error::Error>> {
use vta_sdk::provision_client::VtaReply;
let result = match reply {
VtaReply::Full(boxed) => *boxed,
VtaReply::AdminOnly(_) => {
return Err(
"VTA returned an AdminOnly reply but FullSetup was requested — \
this is a wiring bug; please report"
.into(),
);
}
};
let integration_did = result
.integration_did()
.ok_or("provision reply missing integration DID")?
.to_string();
let integration_keys = result
.integration_key()
.ok_or("provision reply missing integration key material")?;
let integration_signing_key_mb = integration_keys.signing_key.private_key_multibase.clone();
let integration_ka_key_mb = integration_keys.ka_key.private_key_multibase.clone();
let did_log_entry = result.webvh_log().map(str::to_string);
let admin_did = result.admin_did().to_string();
let admin_signing_key_mb = match result.admin_key() {
Some(km) => km.signing_key.private_key_multibase.clone(),
None => setup_pk_mb.to_string(),
};
let vta_url = result.payload.config.vta_url.clone();
let vta_credential_b64 = build_vta_credential_b64(
&admin_did,
&admin_signing_key_mb,
&vta_did,
vta_url.as_deref(),
)?;
Ok(OnlineProvisionOutcome {
integration_did,
integration_signing_key_mb,
integration_ka_key_mb,
did_log_entry,
admin_did,
admin_signing_key_mb,
vta_did,
vta_url,
vta_credential_b64,
})
}
pub fn build_vta_credential_b64(
admin_did: &str,
admin_signing_key_mb: &str,
vta_did: &str,
vta_url: Option<&str>,
) -> Result<String, Box<dyn std::error::Error>> {
let bundle = CredentialBundle {
did: admin_did.to_string(),
private_key_multibase: admin_signing_key_mb.to_string(),
vta_did: vta_did.to_string(),
vta_url: vta_url.map(str::to_string),
};
let json = serde_json::to_vec(&bundle)?;
Ok(BASE64.encode(json))
}
async fn drain_provision_events_to_stderr(
rx: &mut tokio::sync::mpsc::UnboundedReceiver<vta_sdk::provision_client::VtaEvent>,
) {
while let Some(event) = rx.recv().await {
render_event_to_stderr(event);
}
}
fn render_event_to_stderr(event: vta_sdk::provision_client::VtaEvent) {
use vta_sdk::provision_client::{DiagStatus, VtaEvent};
match event {
VtaEvent::CheckStart(check) => {
eprintln!(" [..] {}", check.label());
}
VtaEvent::CheckDone(check, status) => match status {
DiagStatus::Pending | DiagStatus::Running => {}
DiagStatus::Ok(detail) => eprintln!(" [OK] {} {detail}", check.label()),
DiagStatus::Skipped(detail) => eprintln!(" [--] {} {detail}", check.label()),
DiagStatus::Failed(detail) => eprintln!(" [!!] {} {detail}", check.label()),
},
VtaEvent::Resolved(_)
| VtaEvent::AttemptCompleted { .. }
| VtaEvent::PreflightDone { .. } => {
}
VtaEvent::Connected { protocol, .. } => {
eprintln!();
eprintln!(" Connected via {}", protocol.label());
}
VtaEvent::Failed(reason) => {
eprintln!();
eprintln!(" ✗ {reason}");
}
}
}
#[derive(Debug, Clone)]
pub struct OfflineRequestInfo {
pub client_did: String,
pub nonce: String,
pub request_path: std::path::PathBuf,
pub seed: [u8; 32],
}
#[derive(Debug, Clone)]
pub struct OfflineBootstrapResult {
pub did: String,
pub signing_key_multibase: String,
pub key_agreement_multibase: String,
pub did_document: serde_json::Value,
pub log_entry: Option<String>,
pub authorization_vc: serde_json::Value,
pub vta_did: String,
pub vta_url: Option<String>,
pub admin_did: Option<String>,
pub admin_signing_key_multibase: Option<String>,
}
pub enum WebvhDidShape<'a> {
Hosted {
origin: &'a str,
did_path: &'a str,
mediator_did: Option<&'a str>,
transport: TransportSelection,
remote: Option<WebvhRemoteTarget<'a>>,
},
DidcommOnly { mediator_did: &'a str },
}
pub struct WebvhRemoteTarget<'a> {
pub server_id: &'a str,
pub domain: Option<&'a str>,
pub path: Option<&'a str>,
}
pub fn build_webvh_provision_ask(
context_id: &str,
shape: &WebvhDidShape<'_>,
label: Option<&str>,
) -> vta_sdk::provision_client::ProvisionAsk {
use serde_json::Value as JsonValue;
use vta_sdk::provision_client::ProvisionAsk;
let mut ask = match shape {
WebvhDidShape::Hosted {
origin,
did_path,
mediator_did,
transport,
remote,
} => {
let url = match remote {
Some(_) => origin.trim_end_matches('/').to_string(),
None => hosting_url_for(origin, did_path),
};
let mut ask = match (mediator_did, transport) {
(Some(med), TransportSelection::Tsp) => {
let mut a = ProvisionAsk::did_host_tsp(context_id, *med);
a.integration_template_vars
.insert("URL".to_string(), JsonValue::String(url));
a
}
(Some(med), _) => {
let mut a = ProvisionAsk::did_host_didcomm(context_id, *med);
a.integration_template_vars
.insert("URL".to_string(), JsonValue::String(url));
a
}
(None, _) => ProvisionAsk::did_host_http(context_id, url),
};
if let Some(r) = remote {
ask.integration_template_vars.insert(
"WEBVH_SERVER".to_string(),
JsonValue::String(r.server_id.to_string()),
);
if let Some(domain) = r.domain {
ask.integration_template_vars.insert(
"WEBVH_DOMAIN".to_string(),
JsonValue::String(domain.to_string()),
);
}
if let Some(path) = r.path {
ask.integration_template_vars.insert(
"WEBVH_PATH".to_string(),
JsonValue::String(path.to_string()),
);
}
}
ask
}
WebvhDidShape::DidcommOnly { mediator_did } => {
ProvisionAsk::did_host_didcomm(context_id, *mediator_did)
}
};
if let Some(l) = label {
ask = ask.with_label(l.to_string());
}
ask
}
pub async fn write_offline_bootstrap_request(
request_path: &Path,
ask: &vta_sdk::provision_client::ProvisionAsk,
) -> Result<OfflineRequestInfo, Box<dyn std::error::Error>> {
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
use vta_sdk::provision_integration::ProvisionRequestBuilder;
let template = ask.integration_template.as_deref().ok_or(
"ProvisionAsk has no integration template — offline bootstrap requires a minted DID",
)?;
let mut builder = ProvisionRequestBuilder::new(template)
.context_hint(&ask.context)
.validity(chrono::Duration::days(7));
for (k, v) in &ask.integration_template_vars {
builder = builder.var(k.clone(), v.clone());
}
if let Some(l) = &ask.label {
builder = builder.label(l.clone());
}
let signed = builder.sign_ephemeral().await?;
let request_json = serde_json::to_string_pretty(&signed.request)?;
if let Some(parent) = request_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(request_path, request_json)?;
let seed_bytes: [u8; 32] = *signed.seed;
let nonce_b64 = B64.encode(signed.bundle_id);
Ok(OfflineRequestInfo {
client_did: signed.client_did,
nonce: nonce_b64,
request_path: request_path.to_path_buf(),
seed: seed_bytes,
})
}
pub fn open_offline_bootstrap_response(
bundle_armor: &str,
expect_digest: &str,
seed: &[u8; 32],
) -> Result<OfflineBootstrapResult, Box<dyn std::error::Error>> {
use vta_sdk::sealed_transfer::template_bootstrap::TemplateOutput;
use vta_sdk::sealed_transfer::{
SealedPayloadV1, armor, ed25519_seed_to_x25519_secret, open_bundle,
};
let recipient_secret = ed25519_seed_to_x25519_secret(seed);
let bundles = armor::decode(bundle_armor)?;
let bundle = match bundles.as_slice() {
[one] => one,
other => {
return Err(format!(
"expected exactly 1 sealed bundle in armor, got {}",
other.len()
)
.into());
}
};
let opened = open_bundle(&recipient_secret, bundle, Some(expect_digest))?;
let payload = match opened.payload {
SealedPayloadV1::TemplateBootstrap(boxed) => *boxed,
_ => return Err("sealed response was not a TemplateBootstrap payload".into()),
};
let integration_did = payload
.config
.did_document
.get("id")
.and_then(|v| v.as_str())
.ok_or("sealed payload missing config.did_document.id")?
.to_string();
let mut secrets_map = payload.secrets;
let integration_key_material = secrets_map
.remove(&integration_did)
.or_else(|| {
tracing::warn!(
integration_did = %integration_did,
"sealed payload secrets map does not contain integration DID; falling back to first entry",
);
let key = secrets_map.keys().next().cloned()?;
secrets_map.remove(&key)
})
.ok_or("sealed payload has no secrets")?;
let admin_entry = secrets_map.into_iter().next();
let (admin_did, admin_signing_key_multibase) = match admin_entry {
Some((_did, mat)) => (Some(mat.did), Some(mat.signing_key.private_key_multibase)),
None => (None, None),
};
let log_entry = payload.config.outputs.iter().find_map(|o| match o {
TemplateOutput::WebvhLog { log, .. } => Some(log.clone()),
_ => None,
});
Ok(OfflineBootstrapResult {
did: integration_key_material.did,
signing_key_multibase: integration_key_material.signing_key.private_key_multibase,
key_agreement_multibase: integration_key_material.ka_key.private_key_multibase,
did_document: payload.config.did_document,
log_entry,
authorization_vc: payload.authorization,
vta_did: payload.config.vta_trust.vta_did,
vta_url: payload.config.vta_url,
admin_did,
admin_signing_key_multibase,
})
}
#[derive(Debug, Clone, Copy)]
pub enum OfflineOpenNextStep<'a> {
ImportSecrets {
binary: &'a str,
},
Setup {
binary: &'a str,
},
}
pub async fn run_offline_request_cli(
out: &Path,
seed_path: &Path,
binary: &str,
ask: &vta_sdk::provision_client::ProvisionAsk,
) -> Result<(), Box<dyn std::error::Error>> {
let context_id = ask.context.as_str();
let info = write_offline_bootstrap_request(out, ask).await?;
write_secret_file_0600(seed_path, &info.seed)?;
eprintln!();
eprintln!(" Offline bootstrap request ready.");
eprintln!();
eprintln!(" Request file: {}", info.request_path.display());
eprintln!(" Seed (secret): {}", seed_path.display());
eprintln!();
eprintln!(" Consumer DID: {}", info.client_did);
eprintln!(" Nonce: {}", info.nonce);
eprintln!();
eprintln!(" Next steps:");
eprintln!(
" 1. Ferry {} to your VTA admin.",
info.request_path.display()
);
eprintln!(" 2. Ask them to create the VTA context with this DID as admin");
eprintln!(" (skip if the context already exists), via either:");
eprintln!(
" pnm contexts create --context {} \\\n --admin {}",
context_id, info.client_did
);
eprintln!(" or, on the VTA host directly:");
eprintln!(
" vta contexts create --id {} \\\n --admin-did {} --admin-expires 1h",
context_id, info.client_did
);
eprintln!(" 3. Ask them to seal the response:");
eprintln!(
" vta bootstrap provision-integration --request <request-file> \\\n --out <bundle-file>"
);
eprintln!(" 4. They send back an ASCII-armored sealed bundle + SHA-256 digest.");
eprintln!(" 5. Run:");
eprintln!(" {binary} vta-open --bundle <bundle> --expect-digest <hex>");
eprintln!();
eprintln!(" KEEP THE SEED FILE. Losing it means you cannot open the response.");
eprintln!();
Ok(())
}
pub fn run_offline_open_cli(
bundle: &Path,
expect_digest: &str,
seed_path: &Path,
did_doc_out: &Path,
did_log_out: &Path,
secrets_out: &Path,
next: OfflineOpenNextStep<'_>,
) -> Result<(), Box<dyn std::error::Error>> {
let armor =
std::fs::read_to_string(bundle).map_err(|e| format!("read {}: {e}", bundle.display()))?;
let seed_bytes = std::fs::read(seed_path).map_err(|e| {
format!(
"failed to read ephemeral seed at {}: {e}",
seed_path.display()
)
})?;
let seed: [u8; 32] = seed_bytes.as_slice().try_into().map_err(|_| {
format!(
"ephemeral seed at {} has {} bytes (expected 32)",
seed_path.display(),
seed_bytes.len()
)
})?;
let result = open_offline_bootstrap_response(&armor, expect_digest, &seed)?;
let did_doc_json = serde_json::to_string_pretty(&result.did_document)?;
std::fs::write(did_doc_out, &did_doc_json)?;
if let Some(ref log) = result.log_entry {
std::fs::write(did_log_out, log)?;
}
let secrets_payload = serde_json::json!({
"did": result.did,
"signing_key_multibase": result.signing_key_multibase,
"key_agreement_multibase": result.key_agreement_multibase,
"admin_did": result.admin_did,
"admin_signing_key_multibase": result.admin_signing_key_multibase,
"vta_did": result.vta_did,
"vta_url": result.vta_url,
"authorization_vc": result.authorization_vc,
});
write_secret_file_0600(
secrets_out,
serde_json::to_string_pretty(&secrets_payload)?.as_bytes(),
)?;
eprintln!();
eprintln!(" Sealed response opened.");
eprintln!();
eprintln!(" DID: {}", result.did);
eprintln!(" VTA DID: {}", result.vta_did);
if let Some(ref url) = result.vta_url {
eprintln!(" VTA URL: {url}");
}
eprintln!();
eprintln!(" Wrote {}", did_doc_out.display());
if result.log_entry.is_some() {
eprintln!(" Wrote {}", did_log_out.display());
} else {
eprintln!(" No WebvhLog output in the sealed response — did_log_out not written.");
}
eprintln!(" Wrote {} (0600)", secrets_out.display());
eprintln!();
eprintln!(" Next steps:");
eprintln!(" 1. Publish the DID document at <hosting-url>/<path>/did.jsonl using");
eprintln!(
" `did-hosting-server bootstrap-did --did-log {}` on the hosting server.",
did_log_out.display()
);
match next {
OfflineOpenNextStep::ImportSecrets { binary } => {
eprintln!(" 2. Persist the keys via `{binary} import-secrets --signing-key");
eprintln!(" <signing_key_multibase> --ka-key <key_agreement_multibase>`,");
eprintln!(" using the values from {}.", secrets_out.display());
}
OfflineOpenNextStep::Setup { binary } => {
eprintln!(" 2. Run `{binary} setup` and, when the wizard asks for keys,");
eprintln!(" feed in the `signing_key_multibase` and `key_agreement_multibase`");
eprintln!(" values from {}.", secrets_out.display());
eprintln!(" (A dedicated `import-secrets` subcommand for {binary} is");
eprintln!(" planned; for now the setup wizard is the supported entry point.)");
}
}
eprintln!();
Ok(())
}
#[derive(Debug, Clone)]
pub enum ExportAssertionMode {
PinnedOnly,
DidSigned {
signing_key_multibase: String,
verification_method: String,
},
}
#[derive(Debug, Clone)]
pub struct SealedExportInfo {
pub out_path: std::path::PathBuf,
pub digest: String,
pub recipient_did: String,
pub bundle_id_hex: String,
}
pub async fn export_sealed_did_secrets(
request_path: &Path,
out_path: &Path,
producer_did: &str,
did: &str,
signing_key_multibase: String,
ka_key_multibase: String,
assertion: ExportAssertionMode,
) -> Result<SealedExportInfo, Box<dyn std::error::Error>> {
use vta_sdk::did_secrets::{DidSecretsBundle, SecretEntry};
use vta_sdk::keys::KeyType;
use vta_sdk::sealed_transfer::bundle::{AssertionProof, DidSignedAssertion, ProducerAssertion};
use vta_sdk::sealed_transfer::verify::DID_SIGNED_DOMAIN_TAG;
use vta_sdk::sealed_transfer::{
BootstrapRequest, InMemoryNonceStore, SealedPayloadV1, armor, bundle_digest, seal_payload,
};
let req_json = std::fs::read_to_string(request_path)
.map_err(|e| format!("read {}: {e}", request_path.display()))?;
let request: BootstrapRequest = serde_json::from_str(&req_json)
.map_err(|e| format!("parse {}: {e}", request_path.display()))?;
let recipient_x25519 = request.decode_client_x25519_pub()?;
let bundle_id = request.decode_nonce()?;
let secrets = DidSecretsBundle {
did: did.to_string(),
secrets: vec![
SecretEntry {
key_id: format!("{did}#key-0"),
key_type: KeyType::Ed25519,
private_key_multibase: signing_key_multibase,
},
SecretEntry {
key_id: format!("{did}#key-1"),
key_type: KeyType::X25519,
private_key_multibase: ka_key_multibase,
},
],
};
let payload = SealedPayloadV1::DidSecrets(Box::new(secrets));
let proof = match &assertion {
ExportAssertionMode::PinnedOnly => AssertionProof::PinnedOnly,
ExportAssertionMode::DidSigned {
signing_key_multibase,
verification_method,
} => {
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64URL;
use ed25519_dalek::{Signer, SigningKey};
let signer_secret = Secret::from_multibase(signing_key_multibase, None)
.map_err(|e| format!("invalid signing key: {e}"))?;
let seed_bytes: [u8; 32] = signer_secret
.get_private_bytes()
.try_into()
.map_err(|_| "signing key is not a 32-byte Ed25519 seed")?;
let sk = SigningKey::from_bytes(&seed_bytes);
let mut msg = Vec::with_capacity(
DID_SIGNED_DOMAIN_TAG.len() + recipient_x25519.len() + bundle_id.len(),
);
msg.extend_from_slice(DID_SIGNED_DOMAIN_TAG);
msg.extend_from_slice(&recipient_x25519);
msg.extend_from_slice(&bundle_id);
let sig = sk.sign(&msg);
AssertionProof::DidSigned(DidSignedAssertion {
did: producer_did.to_string(),
signature_b64: B64URL.encode(sig.to_bytes()),
verification_method: verification_method.clone(),
})
}
};
let producer = ProducerAssertion {
producer_did: producer_did.to_string(),
proof,
};
let nonce_store = InMemoryNonceStore::new();
let sealed = seal_payload(
&recipient_x25519,
bundle_id,
producer,
&payload,
&nonce_store,
)
.await?;
let digest = bundle_digest(&sealed);
let armored = armor::encode(&sealed);
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(out_path, &armored).map_err(|e| format!("write {}: {e}", out_path.display()))?;
Ok(SealedExportInfo {
out_path: out_path.to_path_buf(),
digest,
recipient_did: request.client_did,
bundle_id_hex: hex::encode(bundle_id),
})
}
#[derive(Debug, Clone)]
pub struct SealedDidSecretsResult {
pub did: String,
pub signing_key_multibase: String,
pub key_agreement_multibase: String,
pub producer_did: String,
pub assertion_verified: bool,
}
pub fn open_sealed_did_secrets(
bundle_armor: &str,
expect_digest: &str,
seed_path: &Path,
expected_producer_pubkey: Option<&[u8; 32]>,
) -> Result<SealedDidSecretsResult, Box<dyn std::error::Error>> {
use vta_sdk::didcomm_light::ed25519_pub_to_x25519_pub;
use vta_sdk::keys::KeyType;
use vta_sdk::sealed_transfer::bundle::AssertionProof;
use vta_sdk::sealed_transfer::verify::verify_did_signed_assertion_with_pubkey;
use vta_sdk::sealed_transfer::{
SealedPayloadV1, armor, ed25519_seed_to_x25519_secret, open_bundle,
};
let seed_bytes = std::fs::read(seed_path).map_err(|e| {
format!(
"failed to read ephemeral seed at {}: {e}",
seed_path.display()
)
})?;
if seed_bytes.len() != 32 {
return Err(format!(
"ephemeral seed at {} has {} bytes (expected 32)",
seed_path.display(),
seed_bytes.len()
)
.into());
}
let seed: [u8; 32] = seed_bytes
.as_slice()
.try_into()
.expect("checked length above");
let recipient_secret = ed25519_seed_to_x25519_secret(&seed);
let bundles = armor::decode(bundle_armor)?;
let bundle = match bundles.as_slice() {
[one] => one,
other => {
return Err(format!(
"expected exactly 1 sealed bundle in armor, got {}",
other.len()
)
.into());
}
};
let opened = open_bundle(&recipient_secret, bundle, Some(expect_digest))?;
let mut assertion_verified = false;
if let Some(expected_pubkey) = expected_producer_pubkey {
match &opened.producer.proof {
AssertionProof::DidSigned(assertion) => {
use ed25519_dalek::SigningKey;
let client_ed_pub = SigningKey::from_bytes(&seed).verifying_key().to_bytes();
let client_x_pub = ed25519_pub_to_x25519_pub(&client_ed_pub)
.map_err(|e| format!("derive client X25519 pubkey: {e}"))?;
verify_did_signed_assertion_with_pubkey(
assertion,
&opened.producer.producer_did,
expected_pubkey,
&client_x_pub,
&opened.bundle_id,
)
.map_err(|e| format!("DidSigned verification failed: {e}"))?;
assertion_verified = true;
}
AssertionProof::PinnedOnly => {
return Err(
"expected DidSigned producer assertion but bundle carries PinnedOnly — \
either drop the expected pubkey to accept PinnedOnly, or ask the \
exporter to sign"
.into(),
);
}
AssertionProof::Attested(_) => {
return Err(
"expected DidSigned producer assertion but bundle carries Attested (Nitro); \
not supported in this flow"
.into(),
);
}
}
}
let did_secrets = match opened.payload {
SealedPayloadV1::DidSecrets(boxed) => *boxed,
_ => return Err("sealed bundle was not a DidSecrets payload".into()),
};
let mut signing = None;
let mut ka = None;
for entry in did_secrets.secrets {
match entry.key_type {
KeyType::Ed25519 if signing.is_none() => signing = Some(entry.private_key_multibase),
KeyType::X25519 if ka.is_none() => ka = Some(entry.private_key_multibase),
_ => {}
}
}
Ok(SealedDidSecretsResult {
did: did_secrets.did,
signing_key_multibase: signing
.ok_or("sealed DidSecrets bundle has no Ed25519 signing key")?,
key_agreement_multibase: ka
.ok_or("sealed DidSecrets bundle has no X25519 key agreement key")?,
producer_did: opened.producer.producer_did,
assertion_verified,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn write_plain_bootstrap_request_for_test(
request_path: &Path,
seed_path: &Path,
label: Option<&str>,
) {
use rand::RngExt;
use vta_sdk::sealed_transfer::{BootstrapRequest, generate_ed25519_keypair};
let (seed, ed_pub) = generate_ed25519_keypair();
let mut nonce = [0u8; 16];
rand::rng().fill(&mut nonce);
let request = BootstrapRequest::new(ed_pub, nonce, label.map(String::from));
let request_json = serde_json::to_string_pretty(&request).expect("serialize request");
std::fs::write(request_path, request_json).expect("write request");
let seed_bytes: [u8; 32] = *seed;
std::fs::write(seed_path, seed_bytes).expect("write seed");
}
#[tokio::test]
async fn offline_request_produces_valid_bootstrap_request_and_in_memory_seed() {
let tmp = tempfile::tempdir().expect("tempdir");
let request_path = tmp.path().join("bootstrap-request.json");
let ask = build_webvh_provision_ask(
"webvh-test-ctx",
&WebvhDidShape::DidcommOnly {
mediator_did: "did:key:z6MkMockMediator",
},
Some("did-hosting-control-test"),
);
let info = write_offline_bootstrap_request(&request_path, &ask)
.await
.expect("write request");
let raw = std::fs::read_to_string(&request_path).expect("read request");
let parsed: vta_sdk::provision_integration::BootstrapRequest =
serde_json::from_str(&raw).expect("parse VP request");
assert!(
parsed.holder.starts_with("did:key:z6Mk"),
"holder must be an Ed25519 did:key, got {}",
parsed.holder
);
assert_eq!(parsed.label.as_deref(), Some("did-hosting-control-test"));
assert_eq!(parsed.nonce.len(), 22, "nonce length");
assert_eq!(info.client_did, parsed.holder);
assert_eq!(info.nonce, parsed.nonce);
assert_eq!(info.request_path, request_path);
assert_ne!(info.seed, [0u8; 32], "seed must not be all zeros");
}
#[tokio::test]
async fn offline_bootstrap_full_webvh_to_vta_roundtrip() {
use std::collections::BTreeMap;
use vta_sdk::provision_integration::payload::{
DidKeyMaterial, KeyPair, TemplateBootstrapConfig, TemplateBootstrapPayload,
TemplateOutput, VtaTrustBundle,
};
use vta_sdk::sealed_transfer::bundle::AssertionProof;
use vta_sdk::sealed_transfer::{
InMemoryNonceStore, ProducerAssertion, SealedPayloadV1, armor, bundle_digest,
seal_payload,
};
let tmp = tempfile::tempdir().expect("tempdir");
let request_path = tmp.path().join("bootstrap-request.json");
let ask = build_webvh_provision_ask(
"webvh-roundtrip-ctx",
&WebvhDidShape::DidcommOnly {
mediator_did: "did:key:z6MkMockMediator",
},
Some("roundtrip-full"),
);
let info = write_offline_bootstrap_request(&request_path, &ask)
.await
.expect("write request");
let raw_vp = std::fs::read_to_string(&request_path).expect("read request");
let request: vta_sdk::provision_integration::BootstrapRequest =
serde_json::from_str(&raw_vp).expect("parse VP");
let verified = request.verify().expect("VP verifies");
let recipient_x25519_pub = verified
.decode_client_x25519_pub()
.expect("derive X25519 pub");
let bundle_id = verified.decode_nonce().expect("decode nonce");
assert_eq!(verified.holder(), info.client_did);
let integration_did = "did:webvh:roundtrip:integration.example.com";
let admin_did = "did:key:z6MkAdminRotated";
let mut secrets = BTreeMap::new();
secrets.insert(
integration_did.to_string(),
DidKeyMaterial {
did: integration_did.into(),
signing_key: KeyPair {
key_id: format!("{integration_did}#key-0"),
public_key_multibase: "z6MkSigningPub".into(),
private_key_multibase: "zSigningPriv".into(),
},
ka_key: KeyPair {
key_id: format!("{integration_did}#key-1"),
public_key_multibase: "z6LSKaPub".into(),
private_key_multibase: "zKaPriv".into(),
},
},
);
secrets.insert(
admin_did.to_string(),
DidKeyMaterial {
did: admin_did.into(),
signing_key: KeyPair {
key_id: format!("{admin_did}#key-0"),
public_key_multibase: "z6MkAdminPub".into(),
private_key_multibase: "zAdminPriv".into(),
},
ka_key: KeyPair {
key_id: format!("{admin_did}#key-1"),
public_key_multibase: "z6LSAdminKa".into(),
private_key_multibase: "zAdminKaPriv".into(),
},
},
);
let payload = TemplateBootstrapPayload {
authorization: serde_json::json!({
"type": ["VerifiableCredential", "VtaAuthorizationCredential"],
"credentialSubject": { "id": admin_did, "vtaContext": "webvh-roundtrip-ctx" },
}),
secrets,
config: TemplateBootstrapConfig {
template_name: "did-hosting-server".into(),
template_kind: "integration".into(),
did_document: serde_json::json!({ "id": integration_did }),
outputs: vec![TemplateOutput::WebvhLog {
did: integration_did.into(),
log: "{\"versionId\":\"1-roundtrip\"}\n".into(),
}],
vta_url: Some("https://vta.example.com".into()),
vta_trust: VtaTrustBundle {
vta_did: "did:webvh:vta.example.com".into(),
vta_did_document: serde_json::json!({ "id": "did:webvh:vta.example.com" }),
vta_did_log: None,
},
},
};
let sealed_payload = SealedPayloadV1::TemplateBootstrap(Box::new(payload));
let producer = ProducerAssertion {
producer_did: "did:key:z6MkProducerSyntheticForTest".into(),
proof: AssertionProof::PinnedOnly,
};
let nonce_store = InMemoryNonceStore::default();
let bundle = seal_payload(
&recipient_x25519_pub,
bundle_id,
producer,
&sealed_payload,
&nonce_store,
)
.await
.expect("seal");
let digest = bundle_digest(&bundle);
let armored = armor::encode(&bundle);
let result = open_offline_bootstrap_response(&armored, &digest, &info.seed)
.expect("open sealed response");
assert_eq!(result.did, integration_did);
assert_eq!(result.signing_key_multibase, "zSigningPriv");
assert_eq!(result.key_agreement_multibase, "zKaPriv");
assert_eq!(result.vta_did, "did:webvh:vta.example.com");
assert_eq!(result.vta_url.as_deref(), Some("https://vta.example.com"));
assert_eq!(
result.log_entry.as_deref(),
Some("{\"versionId\":\"1-roundtrip\"}\n")
);
assert_eq!(result.admin_did.as_deref(), Some(admin_did));
assert_eq!(
result.admin_signing_key_multibase.as_deref(),
Some("zAdminPriv")
);
let mut bad = digest.clone();
bad.replace_range(0..1, if bad.starts_with('0') { "1" } else { "0" });
let err = open_offline_bootstrap_response(&armored, &bad, &info.seed)
.expect_err("bad digest must reject");
let msg = err.to_string();
assert!(
msg.contains("digest") || msg.contains("Digest"),
"expected digest-mismatch error, got: {msg}"
);
}
#[tokio::test]
async fn sealed_did_secrets_export_open_roundtrip_pinned_only() {
let tmp = tempfile::tempdir().expect("tempdir");
let request_path = tmp.path().join("bootstrap-request.json");
let seed_path = tmp.path().join("seed.bin");
let bundle_path = tmp.path().join("sealed.txt");
write_plain_bootstrap_request_for_test(&request_path, &seed_path, Some("roundtrip-test"));
let producer_did = "did:webvh:QmPROD:producer.example.com".to_string();
let exported_did = "did:webvh:QmEXP:example.com:services/export".to_string();
let signing_mb = "z3uFakeEd25519SigningKey".to_string();
let ka_mb = "z3uFakeX25519KaKey".to_string();
let export = export_sealed_did_secrets(
&request_path,
&bundle_path,
&producer_did,
&exported_did,
signing_mb.clone(),
ka_mb.clone(),
ExportAssertionMode::PinnedOnly,
)
.await
.expect("export");
assert_eq!(export.out_path, bundle_path);
assert_eq!(export.digest.len(), 64, "SHA-256 hex");
assert!(export.recipient_did.starts_with("did:key:z6Mk"));
assert_eq!(export.bundle_id_hex.len(), 32, "16 bytes hex");
let armor = std::fs::read_to_string(&bundle_path).expect("read bundle");
let opened =
open_sealed_did_secrets(&armor, &export.digest, &seed_path, None).expect("open");
assert_eq!(opened.did, exported_did);
assert_eq!(opened.signing_key_multibase, signing_mb);
assert_eq!(opened.key_agreement_multibase, ka_mb);
assert_eq!(opened.producer_did, producer_did);
assert!(
!opened.assertion_verified,
"PinnedOnly should not report verified"
);
let mut bad = export.digest.clone();
bad.replace_range(0..1, if bad.starts_with('0') { "1" } else { "0" });
let err = open_sealed_did_secrets(&armor, &bad, &seed_path, None).expect_err("bad digest");
let msg = err.to_string().to_lowercase();
assert!(
msg.contains("digest"),
"expected digest mismatch error, got {msg}"
);
}
#[tokio::test]
async fn sealed_did_secrets_export_open_roundtrip_did_signed() {
use affinidi_tdk::secrets_resolver::secrets::Secret;
let tmp = tempfile::tempdir().expect("tempdir");
let request_path = tmp.path().join("bootstrap-request.json");
let seed_path = tmp.path().join("seed.bin");
let bundle_path = tmp.path().join("sealed.txt");
write_plain_bootstrap_request_for_test(&request_path, &seed_path, Some("ds-test"));
let signer = Secret::generate_ed25519(None, None);
let signer_priv = signer.get_private_keymultibase().expect("priv mb");
let signer_pub_mb = signer.get_public_keymultibase().expect("pub mb");
let (_, pub_raw) = multibase::decode(&signer_pub_mb).expect("decode pub mb");
let signer_pub_bytes: [u8; 32] =
if pub_raw.len() == 34 && pub_raw[0] == 0xed && pub_raw[1] == 0x01 {
pub_raw[2..].try_into().expect("32 bytes")
} else {
pub_raw[..].try_into().expect("32 bytes")
};
let producer_did = "did:webvh:QmPROD:producer.example.com".to_string();
let exported_did = "did:webvh:QmEXP:example.com:services/export".to_string();
let ka_mb = "z3uFakeX25519KaKey".to_string();
let export = export_sealed_did_secrets(
&request_path,
&bundle_path,
&producer_did,
&exported_did,
signer_priv.clone(),
ka_mb.clone(),
ExportAssertionMode::DidSigned {
signing_key_multibase: signer_priv.clone(),
verification_method: format!("{producer_did}#key-0"),
},
)
.await
.expect("export");
let armor = std::fs::read_to_string(&bundle_path).expect("read bundle");
let opened =
open_sealed_did_secrets(&armor, &export.digest, &seed_path, Some(&signer_pub_bytes))
.expect("open");
assert!(opened.assertion_verified, "DidSigned should verify");
assert_eq!(opened.producer_did, producer_did);
let mut wrong = signer_pub_bytes;
wrong[0] ^= 0x01;
let err = open_sealed_did_secrets(&armor, &export.digest, &seed_path, Some(&wrong))
.expect_err("wrong pubkey");
let msg = err.to_string().to_lowercase();
assert!(
msg.contains("didsigned") || msg.contains("signature") || msg.contains("verify"),
"expected signature verification failure, got {msg}"
);
}
#[tokio::test]
async fn offline_request_unique_client_did_per_call() {
let tmp = tempfile::tempdir().expect("tempdir");
let r1 = tmp.path().join("r1.json");
let r2 = tmp.path().join("r2.json");
let ask = || {
build_webvh_provision_ask(
"webvh-test-ctx",
&WebvhDidShape::DidcommOnly {
mediator_did: "did:key:z6MkMockMediator",
},
None,
)
};
let a = write_offline_bootstrap_request(&r1, &ask()).await.unwrap();
let b = write_offline_bootstrap_request(&r2, &ask()).await.unwrap();
assert_ne!(a.client_did, b.client_did, "new keypair per call");
assert_ne!(a.nonce, b.nonce, "new nonce per call");
assert_ne!(a.seed, b.seed, "new seed per call");
}
#[test]
fn build_ask_hosted_serverless_folds_path_no_webvh_vars() {
let ask = build_webvh_provision_ask(
"webvh",
&WebvhDidShape::Hosted {
origin: "https://did.example.com",
did_path: "services/control",
mediator_did: Some("did:key:z6MkMed"),
transport: TransportSelection::Both,
remote: None,
},
Some("lbl"),
);
assert_eq!(
ask.integration_template.as_deref(),
Some("did-host-didcomm")
);
assert_eq!(
ask.integration_template_vars.get("URL"),
Some(&serde_json::Value::String(
"https://did.example.com/services/control".into()
)),
);
assert_eq!(
ask.integration_template_vars.get("MEDIATOR_DID"),
Some(&serde_json::Value::String("did:key:z6MkMed".into())),
);
assert!(!ask.integration_template_vars.contains_key("WEBVH_SERVER"));
assert_eq!(ask.label.as_deref(), Some("lbl"));
}
#[test]
fn build_ask_hosted_tsp_only_uses_tsp_template() {
let ask = build_webvh_provision_ask(
"webvh",
&WebvhDidShape::Hosted {
origin: "https://did.example.com",
did_path: "services/control",
mediator_did: Some("did:key:z6MkMed"),
transport: TransportSelection::Tsp,
remote: None,
},
None,
);
assert_eq!(ask.integration_template.as_deref(), Some("did-host-tsp"));
assert_eq!(
ask.integration_template_vars.get("MEDIATOR_DID"),
Some(&serde_json::Value::String("did:key:z6MkMed".into())),
);
assert_eq!(
ask.integration_template_vars.get("URL"),
Some(&serde_json::Value::String(
"https://did.example.com/services/control".into()
)),
);
}
#[test]
fn build_ask_hosted_didcomm_selection_advertises_tsp_and_didcomm() {
let ask = build_webvh_provision_ask(
"webvh",
&WebvhDidShape::Hosted {
origin: "https://did.example.com",
did_path: "services/control",
mediator_did: Some("did:key:z6MkMed"),
transport: TransportSelection::Didcomm,
remote: None,
},
None,
);
assert_eq!(
ask.integration_template.as_deref(),
Some("did-host-didcomm")
);
}
#[test]
fn build_ask_hosted_no_mediator_uses_daemon_template() {
let ask = build_webvh_provision_ask(
"webvh",
&WebvhDidShape::Hosted {
origin: "https://did.example.com",
did_path: ".well-known",
mediator_did: None,
transport: TransportSelection::Both,
remote: None,
},
None,
);
assert_eq!(ask.integration_template.as_deref(), Some("did-host-http"));
assert_eq!(
ask.integration_template_vars.get("URL"),
Some(&serde_json::Value::String("https://did.example.com".into())),
);
}
#[test]
fn build_ask_hosted_server_managed_injects_webvh_vars_and_keeps_origin() {
let ask = build_webvh_provision_ask(
"webvh",
&WebvhDidShape::Hosted {
origin: "https://daemon.example.com",
did_path: "ignored",
mediator_did: None,
transport: TransportSelection::Both,
remote: Some(WebvhRemoteTarget {
server_id: "srv-1",
domain: Some("hosting.example.com"),
path: Some("acme"),
}),
},
None,
);
assert_eq!(
ask.integration_template_vars.get("URL"),
Some(&serde_json::Value::String(
"https://daemon.example.com".into()
)),
);
assert_eq!(
ask.integration_template_vars.get("WEBVH_SERVER"),
Some(&serde_json::Value::String("srv-1".into())),
);
assert_eq!(
ask.integration_template_vars.get("WEBVH_DOMAIN"),
Some(&serde_json::Value::String("hosting.example.com".into())),
);
assert_eq!(
ask.integration_template_vars.get("WEBVH_PATH"),
Some(&serde_json::Value::String("acme".into())),
);
}
#[test]
fn build_ask_server_managed_omits_domain_when_none() {
let ask = build_webvh_provision_ask(
"webvh",
&WebvhDidShape::Hosted {
origin: "https://daemon.example.com",
did_path: ".well-known",
mediator_did: None,
transport: TransportSelection::Both,
remote: Some(WebvhRemoteTarget {
server_id: "srv-1",
domain: None,
path: Some("acme"),
}),
},
None,
);
assert!(ask.integration_template_vars.contains_key("WEBVH_SERVER"));
assert!(!ask.integration_template_vars.contains_key("WEBVH_DOMAIN"));
assert!(ask.integration_template_vars.contains_key("WEBVH_PATH"));
}
#[test]
fn build_ask_didcomm_only_uses_server_template_no_url() {
let ask = build_webvh_provision_ask(
"webvh",
&WebvhDidShape::DidcommOnly {
mediator_did: "did:key:z6MkMed",
},
None,
);
assert_eq!(
ask.integration_template.as_deref(),
Some("did-host-didcomm")
);
assert!(!ask.integration_template_vars.contains_key("URL"));
assert_eq!(
ask.integration_template_vars.get("MEDIATOR_DID"),
Some(&serde_json::Value::String("did:key:z6MkMed".into())),
);
}
}