pub mod armor;
pub mod bundle;
pub mod chunk;
pub mod error;
pub mod external;
pub mod hpke;
pub mod nonce;
pub mod request;
pub mod template_bootstrap;
pub mod verify;
pub use bundle::{
ArmoredChunk, AssertionProof, AttestationQuoteAssertion, DidSignedAssertion,
IssuedCredentialBundle, LabeledKey, MessagingBridgeCredentialsBundle, ProducerAssertion,
RawPrivateKey, SealedBundle, SealedPayloadV1, SeedMnemonicBundle,
};
pub use chunk::{ChunkPlaintext, MAX_PAYLOAD_FRAGMENT, VERSION};
pub use error::SealedTransferError;
pub use external::{ExternalCredentialPayload, ExternalSecretPayload, PresignedEntry};
pub use hpke::{HpkeSealed, generate_keypair, open as hpke_open, seal as hpke_seal};
pub use nonce::{InMemoryNonceStore, NonceStore};
pub use request::BootstrapRequest;
pub use template_bootstrap::{
AdminRotationPayload, DidKeyMaterial, DidKeyMaterialV2, KeyPair, SlotKeyPair,
TemplateBootstrapConfig, TemplateBootstrapPayload, TemplateBootstrapPayloadV2, TemplateOutput,
VtaTrustBundle,
};
use sha2::{Digest, Sha256};
use zeroize::Zeroizing;
pub fn generate_ed25519_keypair() -> (Zeroizing<[u8; 32]>, [u8; 32]) {
let mut seed = [0u8; 32];
getrandom::fill(&mut seed)
.expect("OS CSPRNG failed — see the randomness note in sealed_transfer::hpke");
let signing = ed25519_dalek::SigningKey::from_bytes(&seed);
let pubkey = signing.verifying_key().to_bytes();
(Zeroizing::new(seed), pubkey)
}
pub fn ed25519_seed_to_x25519_secret(seed: &[u8; 32]) -> Zeroizing<[u8; 32]> {
Zeroizing::new(affinidi_crypto::ed25519::ed25519_private_to_x25519(seed))
}
pub const DEFAULT_DIGEST_ALGO: &str = "sha256";
pub fn bundle_digest(bundle: &SealedBundle) -> String {
let mut chunks: Vec<&ArmoredChunk> = bundle.chunks.iter().collect();
chunks.sort_by_key(|c| c.chunk_index);
let mut hasher = Sha256::new();
for c in chunks {
hasher.update(&c.sealed_bytes);
}
crate::hex::lower(hasher.finalize().as_slice())
}
pub fn bundle_digest_multibase(bundle: &SealedBundle) -> String {
let mut chunks: Vec<&ArmoredChunk> = bundle.chunks.iter().collect();
chunks.sort_by_key(|c| c.chunk_index);
let mut hasher = Sha256::new();
for c in chunks {
hasher.update(&c.sealed_bytes);
}
let mut mh = vec![0x12, 0x20];
mh.extend_from_slice(hasher.finalize().as_slice());
multibase::encode(multibase::Base::Base58Btc, mh)
}
pub fn multibase_digest_to_hex(value: &str) -> Result<String, SealedTransferError> {
let (_base, bytes) = multibase::decode(value).map_err(|e| {
SealedTransferError::Armor(format!("digestMultibase is not multibase: {e}"))
})?;
match bytes.split_at_checked(2) {
Some(([0x12, 0x20], digest)) if digest.len() == 32 => Ok(crate::hex::lower(digest)),
_ => Err(SealedTransferError::Armor(
"digestMultibase is not a sha2-256 multihash — this build cannot verify it".into(),
)),
}
}
pub async fn seal_payload(
recipient_pubkey: &[u8; 32],
bundle_id: [u8; 16],
producer: ProducerAssertion,
payload: &SealedPayloadV1,
nonce_store: &dyn NonceStore,
) -> Result<SealedBundle, SealedTransferError> {
nonce_store.check_and_record(&bundle_id).await?;
let mut chunks = Vec::new();
for plaintext in chunk_plaintexts(payload, bundle_id, &producer)? {
let aad = plaintext.aad(DEFAULT_DIGEST_ALGO);
let mut pt_cbor = Vec::new();
ciborium::ser::into_writer(&plaintext, &mut pt_cbor)
.map_err(|e| SealedTransferError::CborEncode(e.to_string()))?;
let sealed = hpke_seal(recipient_pubkey, &pt_cbor, &aad)?;
let mut sealed_cbor = Vec::new();
ciborium::ser::into_writer(&sealed, &mut sealed_cbor)
.map_err(|e| SealedTransferError::CborEncode(e.to_string()))?;
chunks.push(ArmoredChunk {
chunk_index: plaintext.chunk_index,
total_chunks: plaintext.total_chunks,
sealed_bytes: sealed_cbor,
});
}
Ok(SealedBundle {
bundle_id,
digest_algo: DEFAULT_DIGEST_ALGO.to_string(),
chunks,
})
}
fn chunk_plaintexts(
payload: &SealedPayloadV1,
bundle_id: [u8; 16],
producer: &ProducerAssertion,
) -> Result<Vec<ChunkPlaintext>, SealedTransferError> {
let mut payload_bytes = Vec::new();
ciborium::ser::into_writer(payload, &mut payload_bytes)
.map_err(|e| SealedTransferError::CborEncode(e.to_string()))?;
let total_chunks_usize = payload_bytes.len().div_ceil(MAX_PAYLOAD_FRAGMENT).max(1);
let total_chunks: u16 = total_chunks_usize
.try_into()
.map_err(|_| SealedTransferError::Wire("payload too large for u16 chunk count".into()))?;
Ok((0..total_chunks_usize)
.map(|i| {
let start = i * MAX_PAYLOAD_FRAGMENT;
let end = (start + MAX_PAYLOAD_FRAGMENT).min(payload_bytes.len());
ChunkPlaintext {
version: VERSION,
bundle_id,
chunk_index: i as u16,
total_chunks,
producer_did: (i == 0).then(|| producer.producer_did.clone()),
producer_assertion: (i == 0).then(|| producer.clone()),
payload_fragment: payload_bytes[start..end].to_vec(),
}
})
.collect())
}
pub fn sealed_armor_len_bound(
payload: &SealedPayloadV1,
producer: &ProducerAssertion,
) -> Result<usize, SealedTransferError> {
const AEAD_TAG_LEN: usize = 16;
let widest_id = [0xff; 16];
let mut chunks = Vec::new();
for plaintext in chunk_plaintexts(payload, widest_id, producer)? {
let mut pt_cbor = Vec::new();
ciborium::ser::into_writer(&plaintext, &mut pt_cbor)
.map_err(|e| SealedTransferError::CborEncode(e.to_string()))?;
let widest = HpkeSealed {
kem_encap: [0xff; 32],
aead_ciphertext: vec![0xff; pt_cbor.len() + AEAD_TAG_LEN],
};
let mut sealed_cbor = Vec::new();
ciborium::ser::into_writer(&widest, &mut sealed_cbor)
.map_err(|e| SealedTransferError::CborEncode(e.to_string()))?;
chunks.push(ArmoredChunk {
chunk_index: plaintext.chunk_index,
total_chunks: plaintext.total_chunks,
sealed_bytes: sealed_cbor,
});
}
Ok(armor::encode(&SealedBundle {
bundle_id: widest_id,
digest_algo: DEFAULT_DIGEST_ALGO.to_string(),
chunks,
})
.len())
}
#[derive(Debug)]
pub struct OpenedBundle {
pub payload: SealedPayloadV1,
pub producer: ProducerAssertion,
pub bundle_id: [u8; 16],
}
#[derive(Debug, Clone, Copy)]
pub enum PinnedOnlyPolicy {
RequireOobDigest,
CallerHasIndependentTrustAnchor,
}
pub fn open_bundle(
recipient_secret: &[u8; 32],
bundle: &SealedBundle,
expect_digest: Option<&str>,
) -> Result<OpenedBundle, SealedTransferError> {
open_bundle_with_policy(
recipient_secret,
bundle,
expect_digest,
PinnedOnlyPolicy::RequireOobDigest,
)
}
pub fn open_bundle_with_policy(
recipient_secret: &[u8; 32],
bundle: &SealedBundle,
expect_digest: Option<&str>,
pinned_policy: PinnedOnlyPolicy,
) -> Result<OpenedBundle, SealedTransferError> {
if let Some(expected) = expect_digest {
let got = bundle_digest(bundle);
if !constant_time_eq(expected.as_bytes(), got.as_bytes()) {
return Err(SealedTransferError::DigestMismatch {
expected: expected.to_string(),
got,
});
}
}
let mut plaintexts: Vec<ChunkPlaintext> = Vec::with_capacity(bundle.chunks.len());
for chunk in &bundle.chunks {
let sealed: HpkeSealed = ciborium::de::from_reader(&chunk.sealed_bytes[..])
.map_err(|e| SealedTransferError::CborDecode(e.to_string()))?;
let header_for_aad = ChunkPlaintext {
version: VERSION,
bundle_id: bundle.bundle_id,
chunk_index: chunk.chunk_index,
total_chunks: chunk.total_chunks,
producer_did: None,
producer_assertion: None,
payload_fragment: Vec::new(),
};
let aad = header_for_aad.aad(&bundle.digest_algo);
let pt_bytes = hpke_open(recipient_secret, &sealed, &aad)?;
let pt: ChunkPlaintext = ciborium::de::from_reader(&pt_bytes[..])
.map_err(|e| SealedTransferError::CborDecode(e.to_string()))?;
if pt.bundle_id != bundle.bundle_id
|| pt.chunk_index != chunk.chunk_index
|| pt.total_chunks != chunk.total_chunks
{
return Err(SealedTransferError::ChunkMismatch(
"inner header != armor header".into(),
));
}
plaintexts.push(pt);
}
let chunk0 = plaintexts
.iter()
.find(|p| p.chunk_index == 0)
.ok_or(SealedTransferError::MissingAssertion)?;
let producer = chunk0
.producer_assertion
.clone()
.ok_or(SealedTransferError::MissingAssertion)?;
let declared_did = chunk0
.producer_did
.clone()
.ok_or(SealedTransferError::MissingAssertion)?;
if declared_did != producer.producer_did {
return Err(SealedTransferError::ProducerMismatch {
declared: declared_did,
expected: producer.producer_did.clone(),
});
}
let payload_bytes = chunk::reassemble(plaintexts)?;
let payload: SealedPayloadV1 = ciborium::de::from_reader(&payload_bytes[..])
.map_err(|e| SealedTransferError::CborDecode(e.to_string()))?;
if matches!(producer.proof, AssertionProof::PinnedOnly)
&& expect_digest.is_none()
&& matches!(pinned_policy, PinnedOnlyPolicy::RequireOobDigest)
{
return Err(SealedTransferError::PinnedOnlyRequiresDigest);
}
Ok(OpenedBundle {
payload,
producer,
bundle_id: bundle.bundle_id,
})
}
pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut acc = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
acc |= x ^ y;
}
acc == 0
}
#[cfg(test)]
mod tests {
use super::*;
use crate::credentials::CredentialBundle;
fn sample_payload() -> SealedPayloadV1 {
SealedPayloadV1::AdminCredential(Box::new(CredentialBundle::new(
"did:key:z6Mk123",
"z1234567890",
"did:key:z6MkVTA",
)))
}
fn sample_assertion(producer_did: String) -> ProducerAssertion {
ProducerAssertion {
producer_did,
proof: AssertionProof::PinnedOnly,
}
}
#[tokio::test]
async fn no_seal_is_longer_than_the_armor_bound() {
use super::external::{ExternalCredentialPayload, PresignedEntry};
let (_prod_sk, prod_pk) = generate_ed25519_keypair();
let producer = sample_assertion(affinidi_crypto::did_key::ed25519_pub_to_did_key(&prod_pk));
let store = InMemoryNonceStore::new();
for n in [1usize, 8, 64] {
let entry = |i: usize| PresignedEntry {
index: i as u32,
operation: "upload-part".into(),
method: "PUT".into(),
url: format!("https://s3.example/b/{}?partNumber={i}", "k".repeat(1024)),
headers: [("Content-Length".to_string(), "5242880".to_string())].into(),
expires_at: "2026-10-10T10:05:00Z".into(),
};
let payload = SealedPayloadV1::ExternalCredential(Box::new(
ExternalCredentialPayload::PresignedRequests {
requests: (0..n).map(entry).collect(),
expires_at: "2026-10-10T10:05:00Z".into(),
},
));
let bound = sealed_armor_len_bound(&payload, &producer).unwrap();
for _ in 0..16 {
let (_sk, pk) = generate_keypair();
let mut id = [0u8; 16];
getrandom::fill(&mut id).unwrap();
let bundle = seal_payload(&pk, id, producer.clone(), &payload, &store)
.await
.unwrap();
let len = armor::encode(&bundle).len();
assert!(len <= bound, "{n} entries: sealed {len} > bound {bound}");
assert!(
bound - len < bound / 10,
"{n} entries: bound {bound} is far above sealed {len}"
);
}
}
}
#[tokio::test]
async fn round_trip_single_chunk() {
let (recip_sk, recip_pk) = generate_keypair();
let (_prod_sk, prod_pk) = generate_ed25519_keypair();
let assertion =
sample_assertion(affinidi_crypto::did_key::ed25519_pub_to_did_key(&prod_pk));
let store = InMemoryNonceStore::new();
let bundle_id = [7u8; 16];
let bundle = seal_payload(&recip_pk, bundle_id, assertion, &sample_payload(), &store)
.await
.unwrap();
assert_eq!(bundle.chunks.len(), 1);
let digest = bundle_digest(&bundle);
let opened = open_bundle(&recip_sk, &bundle, Some(&digest)).unwrap();
assert_eq!(opened.bundle_id, bundle_id);
match opened.payload {
SealedPayloadV1::AdminCredential(c) => assert_eq!(c.did, "did:key:z6Mk123"),
_ => panic!("wrong payload variant"),
}
}
#[tokio::test]
async fn round_trip_multi_chunk() {
let (recip_sk, recip_pk) = generate_keypair();
let (_prod_sk, prod_pk) = generate_ed25519_keypair();
let assertion =
sample_assertion(affinidi_crypto::did_key::ed25519_pub_to_did_key(&prod_pk));
let store = InMemoryNonceStore::new();
let bundle_id = [9u8; 16];
let big_keys: Vec<LabeledKey> = (0..2048)
.map(|i| LabeledKey {
label: format!("k-{i}"),
key_b64: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_string(),
key_type: Some("ed25519".to_string()),
})
.collect();
let payload = SealedPayloadV1::AdminKeySet(big_keys);
let bundle = seal_payload(&recip_pk, bundle_id, assertion, &payload, &store)
.await
.unwrap();
assert!(bundle.chunks.len() > 1, "expected multi-chunk bundle");
let digest = bundle_digest(&bundle);
let opened = open_bundle(&recip_sk, &bundle, Some(&digest)).unwrap();
match opened.payload {
SealedPayloadV1::AdminKeySet(keys) => assert_eq!(keys.len(), 2048),
_ => panic!("wrong variant"),
}
}
#[tokio::test]
async fn round_trip_messaging_bridge_credentials() {
let (recip_sk, recip_pk) = generate_keypair();
let (_prod_sk, prod_pk) = generate_ed25519_keypair();
let assertion =
sample_assertion(affinidi_crypto::did_key::ed25519_pub_to_did_key(&prod_pk));
let store = InMemoryNonceStore::new();
let bundle_id = [12u8; 16];
let mut fields = serde_json::Map::new();
fields.insert("botToken".into(), serde_json::json!("123:abc"));
fields.insert(
"registration".into(),
serde_json::json!({ "number": "+15551234", "captcha": "tok" }),
);
let payload = SealedPayloadV1::MessagingBridgeCredentials(Box::new(
MessagingBridgeCredentialsBundle {
platform: "telegram".to_string(),
fields: fields.clone(),
},
));
let bundle = seal_payload(&recip_pk, bundle_id, assertion, &payload, &store)
.await
.unwrap();
let digest = bundle_digest(&bundle);
let opened = open_bundle(&recip_sk, &bundle, Some(&digest)).unwrap();
match opened.payload {
SealedPayloadV1::MessagingBridgeCredentials(b) => {
assert_eq!(b.platform, "telegram");
assert_eq!(b.fields, fields);
}
other => panic!("wrong variant: {other:?}"),
}
}
#[tokio::test]
async fn seed_mnemonic_roundtrips_and_redacts() {
let (recip_sk, recip_pk) = generate_keypair();
let (_prod_sk, prod_pk) = generate_ed25519_keypair();
let assertion =
sample_assertion(affinidi_crypto::did_key::ed25519_pub_to_did_key(&prod_pk));
let store = InMemoryNonceStore::new();
let words = "abandon ability able about above absent absorb abstract";
let payload = SealedPayloadV1::SeedMnemonic(Box::new(SeedMnemonicBundle {
mnemonic: words.to_string(),
vta_did: Some("did:webvh:scid:vta.example".into()),
}));
assert!(!format!("{payload:?}").contains("abandon"));
let bundle = seal_payload(&recip_pk, [13u8; 16], assertion, &payload, &store)
.await
.unwrap();
let digest = bundle_digest(&bundle);
match open_bundle(&recip_sk, &bundle, Some(&digest))
.unwrap()
.payload
{
SealedPayloadV1::SeedMnemonic(m) => assert_eq!(m.mnemonic, words),
other => panic!("wrong variant: {other:?}"),
}
}
#[tokio::test]
async fn replay_rejected_by_nonce_store() {
let (_recip_sk, recip_pk) = generate_keypair();
let (_prod_sk, prod_pk) = generate_ed25519_keypair();
let assertion =
sample_assertion(affinidi_crypto::did_key::ed25519_pub_to_did_key(&prod_pk));
let store = InMemoryNonceStore::new();
let bundle_id = [1u8; 16];
seal_payload(
&recip_pk,
bundle_id,
assertion.clone(),
&sample_payload(),
&store,
)
.await
.unwrap();
let err = seal_payload(&recip_pk, bundle_id, assertion, &sample_payload(), &store)
.await
.unwrap_err();
assert!(matches!(err, SealedTransferError::NonceReplay));
}
#[tokio::test]
async fn digest_mismatch_rejected() {
let (recip_sk, recip_pk) = generate_keypair();
let (_prod_sk, prod_pk) = generate_ed25519_keypair();
let assertion =
sample_assertion(affinidi_crypto::did_key::ed25519_pub_to_did_key(&prod_pk));
let store = InMemoryNonceStore::new();
let bundle = seal_payload(&recip_pk, [2u8; 16], assertion, &sample_payload(), &store)
.await
.unwrap();
let err = open_bundle(&recip_sk, &bundle, Some("deadbeef")).unwrap_err();
assert!(matches!(err, SealedTransferError::DigestMismatch { .. }));
}
#[tokio::test]
async fn digest_match_accepted() {
let (recip_sk, recip_pk) = generate_keypair();
let (_prod_sk, prod_pk) = generate_ed25519_keypair();
let assertion =
sample_assertion(affinidi_crypto::did_key::ed25519_pub_to_did_key(&prod_pk));
let store = InMemoryNonceStore::new();
let bundle = seal_payload(&recip_pk, [3u8; 16], assertion, &sample_payload(), &store)
.await
.unwrap();
let digest = bundle_digest(&bundle);
open_bundle(&recip_sk, &bundle, Some(&digest)).unwrap();
}
#[tokio::test]
async fn armor_round_trip() {
let (recip_sk, recip_pk) = generate_keypair();
let (_prod_sk, prod_pk) = generate_ed25519_keypair();
let assertion =
sample_assertion(affinidi_crypto::did_key::ed25519_pub_to_did_key(&prod_pk));
let store = InMemoryNonceStore::new();
let bundle = seal_payload(&recip_pk, [4u8; 16], assertion, &sample_payload(), &store)
.await
.unwrap();
let armored = armor::encode(&bundle);
assert!(armored.contains("BEGIN VTA SEALED BUNDLE"));
let parsed = armor::decode(&armored).unwrap();
assert_eq!(parsed.len(), 1);
let digest = bundle_digest(&parsed[0]);
let opened = open_bundle(&recip_sk, &parsed[0], Some(&digest)).unwrap();
match opened.payload {
SealedPayloadV1::AdminCredential(c) => assert_eq!(c.did, "did:key:z6Mk123"),
_ => panic!("wrong variant"),
}
}
#[tokio::test]
async fn pinned_only_without_digest_rejected() {
let (recip_sk, recip_pk) = generate_keypair();
let (_prod_sk, prod_pk) = generate_ed25519_keypair();
let assertion =
sample_assertion(affinidi_crypto::did_key::ed25519_pub_to_did_key(&prod_pk));
let store = InMemoryNonceStore::new();
let bundle = seal_payload(&recip_pk, [11u8; 16], assertion, &sample_payload(), &store)
.await
.unwrap();
let err = open_bundle(&recip_sk, &bundle, None)
.expect_err("PinnedOnly without OOB digest must be rejected");
assert!(
matches!(err, SealedTransferError::PinnedOnlyRequiresDigest),
"expected PinnedOnlyRequiresDigest, got {err:?}"
);
}
#[tokio::test]
async fn armor_corruption_caught_by_crc24() {
let (_recip_sk, recip_pk) = generate_keypair();
let (_prod_sk, prod_pk) = generate_ed25519_keypair();
let assertion =
sample_assertion(affinidi_crypto::did_key::ed25519_pub_to_did_key(&prod_pk));
let store = InMemoryNonceStore::new();
let bundle = seal_payload(&recip_pk, [5u8; 16], assertion, &sample_payload(), &store)
.await
.unwrap();
let mut armored = armor::encode(&bundle);
let body_offset = armored.find("\n\n").unwrap() + 2;
let bytes = unsafe { armored.as_bytes_mut() };
bytes[body_offset] = if bytes[body_offset] == b'A' {
b'B'
} else {
b'A'
};
let err = armor::decode(&armored).unwrap_err();
assert!(
matches!(err, SealedTransferError::Crc24Mismatch { .. }),
"expected Crc24Mismatch, got {err:?}"
);
}
#[tokio::test]
async fn aad_tamper_caught_by_aead() {
let (recip_sk, recip_pk) = generate_keypair();
let (_prod_sk, prod_pk) = generate_ed25519_keypair();
let assertion =
sample_assertion(affinidi_crypto::did_key::ed25519_pub_to_did_key(&prod_pk));
let store = InMemoryNonceStore::new();
let bundle = seal_payload(&recip_pk, [6u8; 16], assertion, &sample_payload(), &store)
.await
.unwrap();
let mut tampered = bundle.clone();
tampered.bundle_id = [0xff; 16];
let err = open_bundle(&recip_sk, &tampered, None).unwrap_err();
assert!(matches!(err, SealedTransferError::Hpke(_)));
}
async fn roundtrip_with_armor_mutation(
mutate: impl FnOnce(&mut String),
) -> SealedTransferError {
let (recip_sk, recip_pk) = generate_keypair();
let (_prod_sk, prod_pk) = generate_ed25519_keypair();
let assertion =
sample_assertion(affinidi_crypto::did_key::ed25519_pub_to_did_key(&prod_pk));
let store = InMemoryNonceStore::new();
let bundle = seal_payload(&recip_pk, [7u8; 16], assertion, &sample_payload(), &store)
.await
.expect("seal");
let mut armored = armor::encode(&bundle);
mutate(&mut armored);
match armor::decode(&armored) {
Ok(bundles) => {
let merged = bundles.into_iter().next().expect("at least one bundle");
open_bundle(&recip_sk, &merged, None).expect_err("open must fail")
}
Err(e) => e,
}
}
#[tokio::test]
async fn armor_bundle_id_tamper_caught() {
let err = roundtrip_with_armor_mutation(|s| {
let line = s.find("Bundle-Id: ").expect("Bundle-Id present");
let hex_start = line + "Bundle-Id: ".len();
let bytes = unsafe { s.as_bytes_mut() };
bytes[hex_start] = if bytes[hex_start] == b'0' { b'1' } else { b'0' };
})
.await;
assert!(matches!(
err,
SealedTransferError::Hpke(_) | SealedTransferError::Armor(_)
));
}
#[tokio::test]
async fn armor_chunk_count_tamper_caught() {
let err = roundtrip_with_armor_mutation(|s| {
let line = s.find("Chunk: 1/1").expect("Chunk: 1/1 present");
let bytes = unsafe { s.as_bytes_mut() };
bytes[line + "Chunk: 1/".len()] = b'9';
})
.await;
assert!(
matches!(
err,
SealedTransferError::Hpke(_)
| SealedTransferError::ChunkMismatch(_)
| SealedTransferError::MissingChunks { .. }
| SealedTransferError::Armor(_)
),
"expected Hpke / ChunkMismatch / MissingChunks / Armor, got {err:?}"
);
}
#[tokio::test]
async fn armor_digest_algo_tamper_caught() {
let err = roundtrip_with_armor_mutation(|s| {
*s = s.replace("Digest-Algo: sha256", "Digest-Algo: sha512");
})
.await;
assert!(matches!(err, SealedTransferError::Hpke(_)), "got {err:?}");
}
#[tokio::test]
async fn armor_unknown_header_rejected() {
let err = roundtrip_with_armor_mutation(|s| {
*s = s.replace("Version: 1\n", "Version: 1\nX-Trust-Me: yes\n");
})
.await;
match err {
SealedTransferError::Armor(msg) => {
assert!(
msg.contains("X-Trust-Me") || msg.contains("unknown header"),
"got {msg}"
);
}
other => panic!("expected Armor(unknown header), got {other:?}"),
}
}
#[tokio::test]
async fn missing_chunk_rejected() {
let (recip_sk, recip_pk) = generate_keypair();
let (_prod_sk, prod_pk) = generate_ed25519_keypair();
let assertion =
sample_assertion(affinidi_crypto::did_key::ed25519_pub_to_did_key(&prod_pk));
let store = InMemoryNonceStore::new();
let big_keys: Vec<LabeledKey> = (0..2048)
.map(|i| LabeledKey {
label: format!("k-{i}"),
key_b64: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_string(),
key_type: None,
})
.collect();
let payload = SealedPayloadV1::AdminKeySet(big_keys);
let mut bundle = seal_payload(&recip_pk, [10u8; 16], assertion, &payload, &store)
.await
.unwrap();
assert!(bundle.chunks.len() > 1);
bundle.chunks.pop();
let err = open_bundle(&recip_sk, &bundle, None).unwrap_err();
assert!(
matches!(err, SealedTransferError::MissingChunks { .. }),
"got {err:?}"
);
}
#[test]
fn bootstrap_request_round_trip() {
let (_seed, ed_pub) = generate_ed25519_keypair();
let req = BootstrapRequest::new(ed_pub, [42u8; 16], Some("test".into()));
assert!(req.client_did.starts_with("did:key:z6Mk"));
let json = serde_json::to_string(&req).unwrap();
let back: BootstrapRequest = serde_json::from_str(&json).unwrap();
assert_eq!(back.decode_client_ed25519_pub().unwrap(), ed_pub);
assert_eq!(back.decode_nonce().unwrap(), [42u8; 16]);
}
#[test]
fn signed_claim_verifies_and_round_trips() {
let (seed, ed_pub) = generate_ed25519_keypair();
let req = BootstrapRequest::new_signed_claim(&seed, [7u8; 16], None);
assert_eq!(req.decode_client_ed25519_pub().unwrap(), ed_pub);
let back: BootstrapRequest =
serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap();
back.verify_claim_signature().unwrap();
}
#[test]
fn claim_signature_rejects_missing_tampered_or_foreign() {
let (seed, _) = generate_ed25519_keypair();
let signed = BootstrapRequest::new_signed_claim(&seed, [7u8; 16], None);
let (_, other_pub) = generate_ed25519_keypair();
assert!(
BootstrapRequest::new(other_pub, [7u8; 16], None)
.verify_claim_signature()
.is_err()
);
let mut renonced = signed.clone();
renonced.nonce = BootstrapRequest::new(other_pub, [8u8; 16], None).nonce;
assert!(renonced.verify_claim_signature().is_err());
let mut swapped = signed.clone();
swapped.client_did = affinidi_crypto::did_key::ed25519_pub_to_did_key(&other_pub);
assert!(swapped.verify_claim_signature().is_err());
let mut garbled = signed;
garbled.claim_signature = Some("AAAA".into());
assert!(garbled.verify_claim_signature().is_err());
}
#[test]
fn bootstrap_request_derives_x25519_pub_from_did_key() {
let (seed, ed_pub) = generate_ed25519_keypair();
let req = BootstrapRequest::new(ed_pub, [1u8; 16], None);
let producer_x25519 = req.decode_client_x25519_pub().unwrap();
let consumer_x25519 = ed25519_seed_to_x25519_secret(&seed);
let sealed = hpke_seal(&producer_x25519, b"payload", b"aad").unwrap();
let opened = hpke_open(&consumer_x25519, &sealed, b"aad").unwrap();
assert_eq!(&opened, b"payload");
}
#[test]
fn bootstrap_request_rejects_unknown_fields() {
let json = r#"{
"version": 1,
"client_did": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK",
"nonce": "AA",
"requested_role": "Admin"
}"#;
assert!(serde_json::from_str::<BootstrapRequest>(json).is_err());
}
}