use multibase::Base;
use serde::{Deserialize, Serialize};
use vti_common::error::AppError;
use zeroize::Zeroizing;
#[derive(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 std::fmt::Debug for VtcKeyBundle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("VtcKeyBundle")
.field("integration_did", &self.integration_did)
.field("ed25519_key_id", &self.ed25519_key_id)
.field("ed25519_public_multibase", &self.ed25519_public_multibase)
.field("ed25519_private_multibase", &"<redacted>")
.field("x25519_key_id", &self.x25519_key_id)
.field("x25519_public_multibase", &self.x25519_public_multibase)
.field("x25519_private_multibase", &"<redacted>")
.finish()
}
}
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 decode_secret_store_value(
vtc_did: &str,
stored: &[u8],
) -> Result<(Zeroizing<[u8; 32]>, Zeroizing<[u8; 32]>), String> {
if stored.len() == 64 {
let mut ed = Zeroizing::new([0u8; 32]);
let mut x = Zeroizing::new([0u8; 32]);
ed.copy_from_slice(&stored[..32]);
x.copy_from_slice(&stored[32..]);
return Ok((ed, x));
}
let bundle = VtcKeyBundle::from_secret_store_bytes(stored)
.map_err(|e| format!("secret store payload not a VtcKeyBundle: {e}"))?;
if bundle.integration_did != vtc_did {
return Err(format!(
"VtcKeyBundle DID '{}' does not match config.vtc_did '{}' — refusing to init auth",
bundle.integration_did, vtc_did
));
}
let ed = bundle
.ed25519_private_bytes()
.map_err(|e| format!("bundle Ed25519 decode: {e}"))?;
let x = bundle
.x25519_private_bytes()
.map_err(|e| format!("bundle X25519 decode: {e}"))?;
Ok((ed, x))
}
#[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 debug_redacts_private_keys() {
let b = fixture();
let dbg = format!("{b:?}");
assert!(dbg.contains("<redacted>"), "got {dbg}");
assert!(
!dbg.contains(&b.ed25519_private_multibase),
"ed25519 private leaked: {dbg}"
);
assert!(
!dbg.contains(&b.x25519_private_multibase),
"x25519 private leaked: {dbg}"
);
assert!(dbg.contains(&b.integration_did));
}
#[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"));
}
#[test]
fn decode_secret_store_value_accepts_json_bundle() {
let b = fixture(); let bytes = b.to_secret_store_bytes().unwrap();
let (ed, x) =
decode_secret_store_value("did:webvh:vtc.example.com:abc", &bytes).expect("decodes");
assert_eq!(&*ed, &[0x11; 32]);
assert_eq!(&*x, &[0x22; 32]);
}
#[test]
fn decode_secret_store_value_accepts_legacy_64_bytes() {
let mut raw = Vec::with_capacity(64);
raw.extend_from_slice(&[0xAA; 32]);
raw.extend_from_slice(&[0xBB; 32]);
let (ed, x) = decode_secret_store_value("did:any", &raw).expect("64-byte split");
assert_eq!(&*ed, &[0xAA; 32]);
assert_eq!(&*x, &[0xBB; 32]);
}
#[test]
fn decode_secret_store_value_rejects_did_mismatch() {
let b = fixture();
let bytes = b.to_secret_store_bytes().unwrap();
let err = decode_secret_store_value("did:webvh:someone.else", &bytes).unwrap_err();
assert!(
err.contains("does not match"),
"expected DID-mismatch error, got: {err}"
);
}
}