use base64::Engine;
use base64::engine::general_purpose::STANDARD_NO_PAD as B64;
use multibase::Base;
use serde::{Deserialize, Serialize};
use vti_common::error::AppError;
use zeroize::Zeroizing;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct VtcKeyBundle {
pub integration_did: String,
pub ed25519_key_id: String,
pub ed25519_public_multibase: String,
pub ed25519_private_multibase: String,
pub x25519_key_id: String,
pub x25519_public_multibase: String,
pub x25519_private_multibase: String,
}
impl VtcKeyBundle {
pub fn ed25519_private_zeroizing(&self) -> Zeroizing<String> {
Zeroizing::new(self.ed25519_private_multibase.clone())
}
pub fn x25519_private_zeroizing(&self) -> Zeroizing<String> {
Zeroizing::new(self.x25519_private_multibase.clone())
}
pub fn ed25519_private_bytes(&self) -> Result<Zeroizing<[u8; 32]>, AppError> {
decode_private_multibase(&self.ed25519_private_multibase, ED25519_PRIV_CODEC)
}
pub fn x25519_private_bytes(&self) -> Result<Zeroizing<[u8; 32]>, AppError> {
decode_private_multibase(&self.x25519_private_multibase, X25519_PRIV_CODEC)
}
pub fn to_secret_store_bytes(&self) -> Result<Vec<u8>, AppError> {
serde_json::to_vec(self).map_err(|e| AppError::Internal(format!("bundle serialize: {e}")))
}
pub fn from_secret_store_bytes(bytes: &[u8]) -> Result<Self, AppError> {
serde_json::from_slice(bytes).map_err(|e| {
AppError::Internal(format!(
"secret store does not contain a VtcKeyBundle: {e}. Has this VTC been set up \
against a VTA? Run `vtc setup` to provision."
))
})
}
pub fn from_did_key_material(
integration_did: String,
material: &vta_sdk::sealed_transfer::template_bootstrap::DidKeyMaterial,
) -> Self {
Self {
integration_did,
ed25519_key_id: material.signing_key.key_id.clone(),
ed25519_public_multibase: material.signing_key.public_key_multibase.clone(),
ed25519_private_multibase: material.signing_key.private_key_multibase.clone(),
x25519_key_id: material.ka_key.key_id.clone(),
x25519_public_multibase: material.ka_key.public_key_multibase.clone(),
x25519_private_multibase: material.ka_key.private_key_multibase.clone(),
}
}
}
const ED25519_PRIV_CODEC: [u8; 2] = [0x80, 0x26];
const X25519_PRIV_CODEC: [u8; 2] = [0x82, 0x26];
fn decode_private_multibase(
mb: &str,
expected_codec: [u8; 2],
) -> Result<Zeroizing<[u8; 32]>, AppError> {
let (base, decoded) =
multibase::decode(mb).map_err(|e| AppError::Internal(format!("multibase decode: {e}")))?;
if base != Base::Base58Btc {
return Err(AppError::Internal(format!(
"expected base58btc multibase, got {base:?}"
)));
}
if decoded.len() != 2 + 32 {
return Err(AppError::Internal(format!(
"expected 34-byte multicodec-prefixed key, got {}",
decoded.len()
)));
}
if decoded[..2] != expected_codec {
return Err(AppError::Internal(format!(
"wrong multicodec prefix: expected {:02x}{:02x}, got {:02x}{:02x}",
expected_codec[0], expected_codec[1], decoded[0], decoded[1]
)));
}
let mut out = Zeroizing::new([0u8; 32]);
out.copy_from_slice(&decoded[2..]);
Ok(out)
}
#[doc(hidden)]
pub fn encode_private_multibase(bytes: &[u8; 32], codec: [u8; 2]) -> String {
let mut buf = Vec::with_capacity(2 + 32);
buf.extend_from_slice(&codec);
buf.extend_from_slice(bytes);
multibase::encode(Base::Base58Btc, &buf)
}
#[doc(hidden)]
pub fn ed25519_priv_codec() -> [u8; 2] {
ED25519_PRIV_CODEC
}
#[doc(hidden)]
pub fn x25519_priv_codec() -> [u8; 2] {
X25519_PRIV_CODEC
}
#[doc(hidden)]
pub fn bundle_from_raw(
integration_did: &str,
ed25519_priv: &[u8; 32],
x25519_priv: &[u8; 32],
) -> VtcKeyBundle {
use ed25519_dalek::SigningKey;
let signing = SigningKey::from_bytes(ed25519_priv);
let ed25519_public = signing.verifying_key().to_bytes();
let x25519_public_priv = x25519_dalek::StaticSecret::from(*x25519_priv);
let x25519_public = x25519_dalek::PublicKey::from(&x25519_public_priv).to_bytes();
VtcKeyBundle {
integration_did: integration_did.to_string(),
ed25519_key_id: format!("{integration_did}#key-0"),
ed25519_public_multibase: encode_public_multibase(&ed25519_public, [0xed, 0x01]),
ed25519_private_multibase: encode_private_multibase(ed25519_priv, ED25519_PRIV_CODEC),
x25519_key_id: format!("{integration_did}#key-1"),
x25519_public_multibase: encode_public_multibase(&x25519_public, [0xec, 0x01]),
x25519_private_multibase: encode_private_multibase(x25519_priv, X25519_PRIV_CODEC),
}
}
fn encode_public_multibase(bytes: &[u8; 32], codec: [u8; 2]) -> String {
let mut buf = Vec::with_capacity(2 + 32);
buf.extend_from_slice(&codec);
buf.extend_from_slice(bytes);
multibase::encode(Base::Base58Btc, &buf)
}
pub fn inline_secret_for_bundle(bundle: &VtcKeyBundle) -> Result<String, AppError> {
let bytes = bundle.to_secret_store_bytes()?;
Ok(format!("b64:{}", B64.encode(&bytes)))
}
pub fn bundle_from_inline_secret(secret: &str) -> Result<VtcKeyBundle, AppError> {
let body = secret.strip_prefix("b64:").ok_or_else(|| {
AppError::Internal(
"inline_secret is not a VtcKeyBundle wrapper (expected 'b64:' prefix)".into(),
)
})?;
let bytes = B64
.decode(body)
.map_err(|e| AppError::Internal(format!("inline_secret base64 decode: {e}")))?;
VtcKeyBundle::from_secret_store_bytes(&bytes)
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture() -> VtcKeyBundle {
bundle_from_raw("did:webvh:vtc.example.com:abc", &[0x11; 32], &[0x22; 32])
}
#[test]
fn round_trip_secret_store_bytes() {
let b = fixture();
let bytes = b.to_secret_store_bytes().unwrap();
let parsed = VtcKeyBundle::from_secret_store_bytes(&bytes).unwrap();
assert_eq!(b, parsed);
}
#[test]
fn round_trip_inline_secret() {
let b = fixture();
let s = inline_secret_for_bundle(&b).unwrap();
assert!(s.starts_with("b64:"));
let parsed = bundle_from_inline_secret(&s).unwrap();
assert_eq!(b, parsed);
}
#[test]
fn ed25519_private_bytes_decodes() {
let b = fixture();
let raw = b.ed25519_private_bytes().unwrap();
assert_eq!(&*raw, &[0x11; 32]);
}
#[test]
fn x25519_private_bytes_decodes() {
let b = fixture();
let raw = b.x25519_private_bytes().unwrap();
assert_eq!(&*raw, &[0x22; 32]);
}
#[test]
fn from_secret_store_bytes_clear_error_on_garbage() {
let err = VtcKeyBundle::from_secret_store_bytes(b"not a bundle").unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("Run `vtc setup`"),
"expected operator hint in error, got: {msg}"
);
}
#[test]
fn from_secret_store_bytes_rejects_unknown_fields() {
let bogus = br#"{"integration_did":"did:webvh:x","extra":"sneaky","ed25519_key_id":"x#0","ed25519_public_multibase":"z","ed25519_private_multibase":"z","x25519_key_id":"x#1","x25519_public_multibase":"z","x25519_private_multibase":"z"}"#;
assert!(VtcKeyBundle::from_secret_store_bytes(bogus).is_err());
}
#[test]
fn rejects_wrong_multicodec_prefix() {
let mut b = fixture();
let raw = [0x11; 32];
b.ed25519_private_multibase = encode_private_multibase(&raw, X25519_PRIV_CODEC);
let err = b.ed25519_private_bytes().unwrap_err();
assert!(format!("{err}").contains("wrong multicodec prefix"));
}
}