use std::collections::{BTreeMap, HashMap};
use cid::Cid;
use multihash_codetable::{Code, MultihashDigest};
use serde::{Deserialize, Serialize};
use crate::error::{IdentityError, Result};
use crate::identity::TenzroIdentity;
const DAG_CBOR_CODEC: u64 = 0x71;
const MAX_BLOCK_SIZE: usize = 16 * 1024 * 1024;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IdentityCarBundle {
pub identity: TenzroIdentity,
pub wallet_files: BTreeMap<String, Vec<u8>>,
pub version: u32,
}
impl IdentityCarBundle {
pub const CURRENT_VERSION: u32 = 1;
pub fn new(identity: TenzroIdentity, wallet_files: HashMap<String, Vec<u8>>) -> Self {
Self {
identity,
wallet_files: wallet_files.into_iter().collect(),
version: Self::CURRENT_VERSION,
}
}
pub fn to_car_bytes(&self) -> Result<Vec<u8>> {
encode_car(self)
}
pub fn from_car_bytes(bytes: &[u8]) -> Result<Self> {
decode_car(bytes)
}
}
fn encode_car(bundle: &IdentityCarBundle) -> Result<Vec<u8>> {
let payload = serde_ipld_dagcbor::to_vec(bundle)
.map_err(|e| IdentityError::SerializationError(format!("CAR payload encode: {e}")))?;
let hash = Code::Sha2_256.digest(&payload);
let root_cid = Cid::new_v1(DAG_CBOR_CODEC, hash);
let header = CarHeader {
roots: vec![root_cid],
version: 1,
};
let header_bytes = serde_ipld_dagcbor::to_vec(&header)
.map_err(|e| IdentityError::SerializationError(format!("CAR header encode: {e}")))?;
let cid_bytes = root_cid.to_bytes();
let mut block = Vec::with_capacity(cid_bytes.len() + payload.len());
block.extend_from_slice(&cid_bytes);
block.extend_from_slice(&payload);
let mut out = Vec::with_capacity(16 + header_bytes.len() + block.len());
write_varint(&mut out, header_bytes.len() as u64);
out.extend_from_slice(&header_bytes);
write_varint(&mut out, block.len() as u64);
out.extend_from_slice(&block);
Ok(out)
}
fn decode_car(bytes: &[u8]) -> Result<IdentityCarBundle> {
let mut cursor = bytes;
let header_len = read_varint(&mut cursor)? as usize;
if header_len > MAX_BLOCK_SIZE || header_len > cursor.len() {
return Err(IdentityError::SerializationError(format!(
"CAR header length {} out of range",
header_len
)));
}
let (header_bytes, rest) = cursor.split_at(header_len);
let header: CarHeader = serde_ipld_dagcbor::from_slice(header_bytes)
.map_err(|e| IdentityError::SerializationError(format!("CAR header decode: {e}")))?;
if header.version != 1 {
return Err(IdentityError::SerializationError(format!(
"Unsupported CAR version {}",
header.version
)));
}
if header.roots.len() != 1 {
return Err(IdentityError::SerializationError(format!(
"Expected exactly one root in CAR header, got {}",
header.roots.len()
)));
}
let expected_root = header.roots[0];
cursor = rest;
let block_len = read_varint(&mut cursor)? as usize;
if block_len > MAX_BLOCK_SIZE || block_len > cursor.len() {
return Err(IdentityError::SerializationError(format!(
"CAR block length {} out of range",
block_len
)));
}
let (block_bytes, rest) = cursor.split_at(block_len);
let (cid, payload) = split_cid_and_payload(block_bytes)?;
if cid != expected_root {
return Err(IdentityError::SerializationError(
"CAR block CID does not match the root advertised in the header".into(),
));
}
let derived = Cid::new_v1(DAG_CBOR_CODEC, Code::Sha2_256.digest(payload));
if derived != cid {
return Err(IdentityError::SerializationError(
"CAR block content does not hash to its advertised CID".into(),
));
}
if !rest.is_empty() {
return Err(IdentityError::SerializationError(format!(
"CAR file has {} trailing bytes after the only expected block",
rest.len()
)));
}
let bundle: IdentityCarBundle = serde_ipld_dagcbor::from_slice(payload)
.map_err(|e| IdentityError::SerializationError(format!("CAR payload decode: {e}")))?;
Ok(bundle)
}
#[derive(Serialize, Deserialize)]
struct CarHeader {
roots: Vec<Cid>,
version: u64,
}
fn split_cid_and_payload(block: &[u8]) -> Result<(Cid, &[u8])> {
let mut cursor = block;
let cid = Cid::read_bytes(&mut cursor)
.map_err(|e| IdentityError::SerializationError(format!("CAR block CID: {e}")))?;
let consumed = block.len() - cursor.len();
Ok((cid, &block[consumed..]))
}
fn write_varint(out: &mut Vec<u8>, value: u64) {
let mut buf = unsigned_varint::encode::u64_buffer();
let encoded = unsigned_varint::encode::u64(value, &mut buf);
out.extend_from_slice(encoded);
}
fn read_varint(cursor: &mut &[u8]) -> Result<u64> {
let (value, rest) = unsigned_varint::decode::u64(cursor)
.map_err(|e| IdentityError::SerializationError(format!("CAR varint: {e}")))?;
*cursor = rest;
Ok(value)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::registry::IdentityRegistry;
use tenzro_types::identity::KycTier;
fn sample_identity() -> TenzroIdentity {
let registry = IdentityRegistry::new();
let result = tokio_test_block_on(async {
registry
.register_human_with_fee(
vec![7u8; 32],
"Carol".to_string(),
KycTier::Basic,
)
.await
.unwrap()
});
result.identity
}
fn tokio_test_block_on<F: std::future::Future>(fut: F) -> F::Output {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(fut)
}
#[test]
fn bundle_round_trip_via_car() {
let identity = sample_identity();
let mut wallet_files = HashMap::new();
wallet_files.insert("walletA.json".to_string(), b"sealed-shares-blob".to_vec());
wallet_files.insert("walletA.pq.json".to_string(), b"sealed-mldsa-seed".to_vec());
wallet_files.insert("walletA.bls.json".to_string(), b"sealed-bls-seed".to_vec());
let bundle = IdentityCarBundle::new(identity.clone(), wallet_files.clone());
let car = bundle.to_car_bytes().unwrap();
let decoded = IdentityCarBundle::from_car_bytes(&car).unwrap();
assert_eq!(decoded.version, IdentityCarBundle::CURRENT_VERSION);
assert_eq!(decoded.identity.did_string(), identity.did_string());
assert_eq!(decoded.identity.pq_verifying_key.len(), 1952);
assert_eq!(decoded.identity.bls_verifying_key.len(), 48);
assert_eq!(decoded.wallet_files.len(), 3);
for (k, v) in &wallet_files {
assert_eq!(decoded.wallet_files.get(k), Some(v));
}
}
#[test]
fn corrupted_payload_fails_hash_check() {
let identity = sample_identity();
let bundle = IdentityCarBundle::new(identity, HashMap::new());
let mut car = bundle.to_car_bytes().unwrap();
let len = car.len();
car[len - 1] ^= 0xff;
let err = IdentityCarBundle::from_car_bytes(&car).expect_err("must reject torn bundle");
let msg = format!("{err}");
assert!(
msg.contains("hash") || msg.contains("CID") || msg.contains("decode"),
"expected integrity error, got: {msg}"
);
}
#[test]
fn determinism_same_bundle_same_bytes() {
let identity = sample_identity();
let mut files = HashMap::new();
files.insert("w.json".to_string(), vec![1, 2, 3]);
files.insert("w.pq.json".to_string(), vec![4, 5, 6]);
files.insert("w.bls.json".to_string(), vec![7, 8, 9]);
let a = IdentityCarBundle::new(identity.clone(), files.clone())
.to_car_bytes()
.unwrap();
let b = IdentityCarBundle::new(identity, files)
.to_car_bytes()
.unwrap();
assert_eq!(a, b);
}
}