use serde_json::Value;
use crate::provision_integration::http::{ProvisionIntegrationResponse, ProvisionSummary};
use crate::provision_integration::payload::{
DidKeyMaterial, TemplateBootstrapPayload, TemplateOutput,
};
use crate::sealed_transfer::{SealedPayloadV1, armor, open_bundle};
use super::error::ProvisionError;
#[derive(Debug, Clone)]
pub struct ProvisionResult {
pub bundle_id_hex: String,
pub digest: String,
pub summary: ProvisionSummary,
pub payload: TemplateBootstrapPayload,
}
impl ProvisionResult {
pub fn admin_did(&self) -> &str {
&self.summary.admin_did
}
pub fn integration_did(&self) -> Option<&str> {
self.summary.integration_did.as_deref()
}
pub fn admin_key(&self) -> Option<&DidKeyMaterial> {
self.payload.secrets.get(self.admin_did())
}
pub fn integration_key(&self) -> Option<&DidKeyMaterial> {
self.payload.secrets.get(self.integration_did()?)
}
pub fn webvh_log(&self) -> Option<&str> {
let integration_did = self.integration_did()?;
self.payload
.config
.outputs
.iter()
.find_map(|out| match out {
TemplateOutput::WebvhLog { did, log } if did == integration_did => {
Some(log.as_str())
}
_ => None,
})
}
pub fn authorization_vc(&self) -> &Value {
&self.payload.authorization
}
#[cfg(test)]
pub fn vta_url(&self) -> Option<&str> {
self.payload.config.vta_url.as_deref()
}
}
pub fn response_to_result(
seed: &[u8; 32],
vp_nonce: [u8; 16],
response: ProvisionIntegrationResponse,
) -> Result<ProvisionResult, ProvisionError> {
let bundles =
armor::decode(&response.bundle).map_err(|e| ProvisionError::Armor(e.to_string()))?;
if bundles.len() != 1 {
return Err(ProvisionError::Armor(format!(
"expected exactly one armored bundle, found {}",
bundles.len()
)));
}
let bundle = &bundles[0];
if bundle.bundle_id != vp_nonce {
return Err(ProvisionError::Armor(
"returned bundle_id does not match the VP nonce".into(),
));
}
let x_secret = crate::sealed_transfer::ed25519_seed_to_x25519_secret(seed);
let opened = open_bundle(&x_secret, bundle, Some(&response.digest))?;
let payload = match opened.payload {
SealedPayloadV1::TemplateBootstrap(boxed) => *boxed,
_ => return Err(ProvisionError::WrongPayload),
};
Ok(ProvisionResult {
bundle_id_hex: hex_lower(&opened.bundle_id),
digest: response.digest,
summary: response.summary,
payload,
})
}
pub fn admin_rotation_response_to_reply(
seed: &[u8; 32],
vp_nonce: [u8; 16],
response: ProvisionIntegrationResponse,
) -> Result<super::intent::AdminCredentialReply, ProvisionError> {
let bundles =
armor::decode(&response.bundle).map_err(|e| ProvisionError::Armor(e.to_string()))?;
if bundles.len() != 1 {
return Err(ProvisionError::Armor(format!(
"expected exactly one armored bundle, found {}",
bundles.len()
)));
}
let bundle = &bundles[0];
if bundle.bundle_id != vp_nonce {
return Err(ProvisionError::Armor(
"returned bundle_id does not match the VP nonce".into(),
));
}
let x_secret = crate::sealed_transfer::ed25519_seed_to_x25519_secret(seed);
let opened = open_bundle(&x_secret, bundle, Some(&response.digest))?;
let payload = match opened.payload {
SealedPayloadV1::AdminRotation(boxed) => *boxed,
_ => return Err(ProvisionError::WrongPayload),
};
Ok(super::intent::AdminCredentialReply {
admin_did: payload.admin.did.clone(),
admin_private_key_mb: payload.admin.signing_key.private_key_multibase.clone(),
})
}
#[allow(dead_code)] pub(crate) fn decode_nonce_b64url(s: &str) -> Result<[u8; 16], String> {
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64URL;
let raw = B64URL
.decode(s)
.map_err(|e| format!("VP nonce base64url: {e}"))?;
raw.try_into()
.map_err(|_| "VP nonce must be 16 bytes".to_string())
}
pub(crate) fn hex_lower(bytes: &[u8]) -> String {
const T: &[u8; 16] = b"0123456789abcdef";
let mut s = String::with_capacity(bytes.len() * 2);
for &b in bytes {
s.push(T[(b >> 4) as usize] as char);
s.push(T[(b & 0xf) as usize] as char);
}
s
}
#[cfg(test)]
mod tests {
use super::*;
use crate::provision_client::test_helpers::sample_provision_result;
#[test]
fn result_accessors_with_admin_rollover() {
let r = sample_provision_result(true);
assert_eq!(r.admin_did(), "did:key:z6MkAdmin");
assert_eq!(
r.integration_did(),
Some("did:webvh:integration.example.com")
);
assert!(r.admin_key().is_some());
assert!(r.integration_key().is_some());
assert!(r.webvh_log().is_some());
assert_eq!(r.vta_url(), Some("https://vta.example.com"));
}
#[test]
fn result_accessors_without_admin_rollover_fall_back_to_client_did() {
let r = sample_provision_result(false);
assert_eq!(r.admin_did(), "did:key:z6MkSetup");
assert!(r.admin_key().is_none());
assert!(r.integration_key().is_some());
}
#[test]
fn webvh_log_matches_integration_did_only() {
let mut r = sample_provision_result(true);
r.payload.config.outputs = vec![TemplateOutput::WebvhLog {
did: "did:webvh:unrelated".into(),
log: "noise".into(),
}];
assert!(r.webvh_log().is_none());
}
#[test]
fn decode_nonce_b64url_round_trip() {
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64URL;
let bytes = [0xab; 16];
let s = B64URL.encode(bytes);
assert_eq!(decode_nonce_b64url(&s).unwrap(), bytes);
}
#[test]
fn decode_nonce_b64url_rejects_wrong_length() {
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64URL;
let s = B64URL.encode([0u8; 8]);
assert!(decode_nonce_b64url(&s).is_err());
}
#[test]
fn hex_lower_matches_known_vector() {
assert_eq!(hex_lower(&[0x00, 0xff, 0xab, 0x10]), "00ffab10");
}
}