use crate::{CryptoError, KemAlg};
use crate::label::Label;
use crate::secret::X25519Secret;
use chacha20poly1305::aead::{Aead, Payload};
use chacha20poly1305::{KeyInit, XChaCha20Poly1305};
use hkdf::Hkdf;
use rand_core::CryptoRng;
use sha2::Sha256;
use subtle::ConstantTimeEq;
use x25519_dalek::{PublicKey, StaticSecret};
use zeroize::Zeroizing;
pub const PUBLIC_KEY_LEN: usize = 32;
pub const DEFAULT_SEALING_KEM: KemAlg = KemAlg::X25519HkdfSha256;
pub fn supports_kem(kem: KemAlg) -> bool {
match kem {
KemAlg::X25519HkdfSha256 => true,
KemAlg::P256HkdfSha256 => true,
KemAlg::RsaOaepSha256 => false,
KemAlg::XWing => true,
KemAlg::MlKem768P256 => true,
}
}
pub fn slot_info(purpose: Label, kem: KemAlg, file_id: &[u8; 16]) -> Vec<u8> {
let purpose = purpose.as_bytes();
let mut info = Vec::with_capacity(purpose.len().saturating_add(17));
info.extend_from_slice(purpose);
info.push(kem as u8);
info.extend_from_slice(file_id);
info
}
#[must_use]
pub fn a_to_device_info(
kem: KemAlg,
file_id: &[u8; 16],
device_fpr: &[u8; 32],
seq: u64,
) -> Vec<u8> {
let mut info = Vec::with_capacity(64);
info.extend_from_slice(crate::label::A_TO_DEVICE.as_bytes());
info.push(kem as u8);
info.extend_from_slice(file_id);
info.extend_from_slice(device_fpr);
info.extend_from_slice(&seq.to_be_bytes());
info
}
#[must_use]
pub fn b_to_device_info(kem: KemAlg, file_id: &[u8; 16], device_fpr: &[u8; 32]) -> Vec<u8> {
let mut info =
Vec::with_capacity(crate::label::B_TO_DEVICE.len().saturating_add(49));
info.extend_from_slice(crate::label::B_TO_DEVICE.as_bytes());
info.push(kem as u8);
info.extend_from_slice(file_id);
info.extend_from_slice(device_fpr);
info
}
#[must_use]
pub fn challenge_info(device_fpr: &[u8; 32]) -> Vec<u8> {
let mut info = Vec::with_capacity(crate::label::ATTEST_NONCE.len().saturating_add(32));
info.extend_from_slice(crate::label::ATTEST_NONCE.as_bytes());
info.extend_from_slice(device_fpr);
info
}
const NONCE_LEN: usize = 24;
const TAG_LEN: usize = 16;
const SHARED_LEN: usize = 32;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SealedBlob {
pub enc: Vec<u8>,
pub nonce: [u8; NONCE_LEN],
pub ct: Vec<u8>,
}
pub fn x25519_public(secret: &X25519Secret) -> [u8; PUBLIC_KEY_LEN] {
let sk = StaticSecret::from(*secret.expose());
PublicKey::from(&sk).to_bytes()
}
pub fn seal<R: CryptoRng + ?Sized>(
recipient_public: &[u8; PUBLIC_KEY_LEN],
info: &[u8],
aad: &[u8],
plaintext: &[u8],
rng: &mut R,
) -> Result<SealedBlob, CryptoError> {
if !canonical_x25519_public(recipient_public) {
return Err(CryptoError::BadKey);
}
let mut eph_bytes = Zeroizing::new([0u8; SHARED_LEN]);
rng.fill_bytes(eph_bytes.as_mut_slice());
let eph_sk = StaticSecret::from(*eph_bytes);
let eph_pk = PublicKey::from(&eph_sk).to_bytes();
let mut nonce_seed = Zeroizing::new([0u8; NONCE_LEN]);
rng.fill_bytes(nonce_seed.as_mut_slice());
let nonce = crate::kdf::hedged_nonce::<NONCE_LEN>(
crate::label::SEAL_NONCE,
nonce_seed.as_slice(),
plaintext,
aad,
)?;
let shared = eph_sk.diffie_hellman(&PublicKey::from(*recipient_public));
let shared = crate::agreement::SharedSecret::from_be_bytes(*shared.as_bytes());
seal_core(&shared, &eph_pk, recipient_public, info, aad, plaintext, nonce)
}
pub fn seal_p256<R: CryptoRng + ?Sized>(
recipient_public: &[u8],
info: &[u8],
aad: &[u8],
plaintext: &[u8],
rng: &mut R,
) -> Result<SealedBlob, CryptoError> {
validate_dh_public(recipient_public, 65)?;
use crate::agreement::KeyAgreement as _;
let eph = crate::agreement::P256Agreement::generate(rng);
let eph_pk = eph.public_key();
let mut nonce_seed = Zeroizing::new([0u8; NONCE_LEN]);
rng.fill_bytes(nonce_seed.as_mut_slice());
let nonce = crate::kdf::hedged_nonce::<NONCE_LEN>(
crate::label::SEAL_NONCE,
nonce_seed.as_slice(),
plaintext,
aad,
)?;
let shared = eph.agree(recipient_public)?;
seal_core(&shared, &eph_pk, recipient_public, info, aad, plaintext, nonce)
}
fn seal_core(
shared: &crate::agreement::SharedSecret,
eph_public: &[u8],
recipient_public: &[u8],
info: &[u8],
aad: &[u8],
plaintext: &[u8],
nonce: [u8; NONCE_LEN],
) -> Result<SealedBlob, CryptoError> {
let key = derive_key(shared, eph_public, recipient_public, info)?;
let cipher = XChaCha20Poly1305::new((&*key).into());
let ct = cipher
.encrypt((&nonce).into(), Payload { msg: plaintext, aad })
.map_err(|_| CryptoError::BadLength)?;
Ok(SealedBlob { enc: eph_public.to_vec(), nonce, ct })
}
fn canonical_x25519_public(public: &[u8; PUBLIC_KEY_LEN]) -> bool {
let mut modulus = [0xff; PUBLIC_KEY_LEN];
if let Some(first) = modulus.first_mut() {
*first = 0xed;
}
if let Some(last) = modulus.last_mut() {
*last = 0x7f;
}
public.iter().rev().cmp(modulus.iter().rev()).is_lt()
}
fn validate_dh_public(public: &[u8], expected_len: usize) -> Result<(), CryptoError> {
match expected_len {
PUBLIC_KEY_LEN => {
let public = public.try_into().map_err(|_| CryptoError::BadLength)?;
if !canonical_x25519_public(public) {
return Err(CryptoError::BadKey);
}
}
65 => {
if public.len() != 65 || public.first() != Some(&0x04) {
return Err(CryptoError::BadLength);
}
p256::PublicKey::from_sec1_bytes(public).map_err(|_| CryptoError::BadKey)?;
}
_ => return Err(CryptoError::BadLength),
}
Ok(())
}
pub fn seal_xwing<R: CryptoRng + ?Sized>(
recipient_public: &[u8],
info: &[u8],
aad: &[u8],
plaintext: &[u8],
rng: &mut R,
) -> Result<SealedBlob, CryptoError> {
if recipient_public.len() != crate::xwing::PUBLIC_KEY_LEN {
return Err(CryptoError::BadLength);
}
let (_, classical) = recipient_public
.split_last_chunk::<PUBLIC_KEY_LEN>()
.ok_or(CryptoError::BadLength)?;
validate_dh_public(classical, PUBLIC_KEY_LEN)?;
let (shared, ciphertext) = crate::xwing::encapsulate(recipient_public, rng)?;
let mut nonce_seed = Zeroizing::new([0u8; NONCE_LEN]);
rng.fill_bytes(nonce_seed.as_mut_slice());
let nonce = crate::kdf::hedged_nonce::<NONCE_LEN>(
crate::label::SEAL_NONCE,
nonce_seed.as_slice(),
plaintext,
aad,
)?;
let shared = crate::agreement::SharedSecret::from_be_bytes(*shared);
seal_core(&shared, &ciphertext, recipient_public, info, aad, plaintext, nonce)
}
pub fn open_xwing(
recipient_secret: &[u8; crate::xwing::SECRET_LEN],
blob: &SealedBlob,
info: &[u8],
aad: &[u8],
) -> Result<Zeroizing<Vec<u8>>, CryptoError> {
if blob.ct.len() < TAG_LEN {
return Err(CryptoError::BadLength);
}
let shared = crate::xwing::decapsulate(recipient_secret, &blob.enc)?;
let own_public = crate::xwing::public_key(recipient_secret)?;
let shared = crate::agreement::SharedSecret::from_be_bytes(*shared);
let key = derive_key(&shared, &blob.enc, &own_public, info)?;
open_core(&key, blob, aad)
}
pub fn seal_mlkem_p256<R: CryptoRng + ?Sized>(
recipient_public: &[u8],
info: &[u8],
aad: &[u8],
plaintext: &[u8],
rng: &mut R,
) -> Result<SealedBlob, CryptoError> {
let (shared, ciphertext) = crate::mlkem_p256::encapsulate(recipient_public, rng)?;
let mut nonce_seed = Zeroizing::new([0u8; NONCE_LEN]);
rng.fill_bytes(nonce_seed.as_mut_slice());
let nonce = crate::kdf::hedged_nonce::<NONCE_LEN>(
crate::label::SEAL_NONCE,
nonce_seed.as_slice(),
plaintext,
aad,
)?;
let shared = crate::agreement::SharedSecret::from_be_bytes(*shared);
seal_core(&shared, &ciphertext, recipient_public, info, aad, plaintext, nonce)
}
pub fn open_mlkem_p256(
ml_kem_seed: &[u8; crate::mlkem_p256::ML_KEM_SEED_LEN],
classical: &dyn crate::agreement::KeyAgreement,
blob: &SealedBlob,
info: &[u8],
aad: &[u8],
) -> Result<Zeroizing<Vec<u8>>, CryptoError> {
if blob.ct.len() < TAG_LEN {
return Err(CryptoError::BadLength);
}
let shared = crate::mlkem_p256::decapsulate_with(ml_kem_seed, classical, &blob.enc)?;
let own_public =
crate::mlkem_p256::public_key_from_parts(ml_kem_seed, &classical.public_key())?;
let shared = crate::agreement::SharedSecret::from_be_bytes(*shared);
let key = derive_key(&shared, &blob.enc, &own_public, info)?;
open_core(&key, blob, aad)
}
pub fn open(
recipient_secret: &X25519Secret,
blob: &SealedBlob,
info: &[u8],
aad: &[u8],
) -> Result<Zeroizing<Vec<u8>>, CryptoError> {
if blob.ct.len() < TAG_LEN {
return Err(CryptoError::BadLength);
}
let enc: [u8; PUBLIC_KEY_LEN] =
blob.enc.as_slice().try_into().map_err(|_| CryptoError::BadLength)?;
validate_dh_public(&enc, PUBLIC_KEY_LEN)?;
let sk = StaticSecret::from(*recipient_secret.expose());
let own_pk = PublicKey::from(&sk).to_bytes();
let shared = sk.diffie_hellman(&PublicKey::from(enc));
let shared = crate::agreement::SharedSecret::from_be_bytes(*shared.as_bytes());
let key = derive_key(&shared, &enc, &own_pk, info)?;
open_core(&key, blob, aad)
}
pub fn open_with(
agreement: &dyn crate::agreement::KeyAgreement,
blob: &SealedBlob,
info: &[u8],
aad: &[u8],
) -> Result<Zeroizing<Vec<u8>>, CryptoError> {
if blob.ct.len() < TAG_LEN {
return Err(CryptoError::BadLength);
}
let own_pk = agreement.public_key();
validate_dh_public(&own_pk, own_pk.len())?;
validate_dh_public(&blob.enc, own_pk.len())?;
let shared = agreement.agree(&blob.enc)?;
let key = derive_key(&shared, &blob.enc, &own_pk, info)?;
open_core(&key, blob, aad)
}
fn open_core(
key: &[u8; SHARED_LEN],
blob: &SealedBlob,
aad: &[u8],
) -> Result<Zeroizing<Vec<u8>>, CryptoError> {
let cipher = XChaCha20Poly1305::new(key.into());
let plaintext = cipher
.decrypt((&blob.nonce).into(), Payload { msg: &blob.ct, aad })
.map_err(|_| CryptoError::Authentication)?;
Ok(Zeroizing::new(plaintext))
}
fn derive_key(
shared: &crate::agreement::SharedSecret,
eph_public: &[u8],
recipient_public: &[u8],
info: &[u8],
) -> Result<Zeroizing<[u8; SHARED_LEN]>, CryptoError> {
let shared = shared.expose();
let zero = [0u8; SHARED_LEN];
if bool::from(shared.as_slice().ct_eq(zero.as_slice())) {
return Err(CryptoError::BadKey);
}
let mut ikm = Zeroizing::new(Vec::with_capacity(
shared.len().saturating_add(eph_public.len()).saturating_add(recipient_public.len()),
));
ikm.extend_from_slice(shared);
ikm.extend_from_slice(eph_public);
ikm.extend_from_slice(recipient_public);
let hk = Hkdf::<Sha256>::new(None, &ikm);
let mut key = Zeroizing::new([0u8; SHARED_LEN]);
hk.expand(&expand_info(crate::label::SEAL_KEY.as_bytes(), info), key.as_mut_slice())
.map_err(|_| CryptoError::BadLength)?;
Ok(key)
}
fn expand_info(label: &[u8], info: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(label.len().saturating_add(info.len()));
out.extend_from_slice(label);
out.extend_from_slice(info);
out
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic, clippy::arithmetic_side_effects)]
mod moved_info_tests {
use super::*;
const FILE_ID: [u8; 16] = [
0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e,
0x1f,
];
const DEVICE_FPR: [u8; 32] = [
0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e,
0x2f, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, 0x3c, 0x3d,
0x3e, 0x3f,
];
const SEQ: u64 = 0x0102_0304_0506_0708;
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
#[test]
fn the_moved_derivation_inputs_are_byte_for_byte_what_they_were() {
assert_eq!(
hex(&a_to_device_info(KemAlg::P256HkdfSha256, &FILE_ID, &DEVICE_FPR, SEQ)),
"43432f76312f612d746f2d64657669636502101112131415161718191a1b1c1d1e1f\
202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f\
0102030405060708",
);
assert_eq!(
hex(&b_to_device_info(KemAlg::XWing, &FILE_ID, &DEVICE_FPR)),
"43432f76312f622d746f2d64657669636504101112131415161718191a1b1c1d1e1f\
202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f",
);
assert_eq!(
hex(&challenge_info(&DEVICE_FPR)),
"43432f76312f6174746573742d6e6f6e6365\
202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f",
);
}
#[test]
fn the_mechanism_binds_both_shares_and_deliberately_not_the_challenge() {
assert_ne!(
a_to_device_info(KemAlg::X25519HkdfSha256, &FILE_ID, &DEVICE_FPR, SEQ),
a_to_device_info(KemAlg::P256HkdfSha256, &FILE_ID, &DEVICE_FPR, SEQ),
);
assert_ne!(
b_to_device_info(KemAlg::X25519HkdfSha256, &FILE_ID, &DEVICE_FPR),
b_to_device_info(KemAlg::XWing, &FILE_ID, &DEVICE_FPR),
);
assert_eq!(
challenge_info(&DEVICE_FPR).len(),
crate::label::ATTEST_NONCE.len() + 32,
"в вызов добавился байт — это смена байтов на проводе",
);
}
#[test]
fn only_the_a_share_is_tied_to_a_lease_number() {
let b = b_to_device_info(KemAlg::X25519HkdfSha256, &FILE_ID, &DEVICE_FPR);
assert_eq!(b.len(), crate::label::B_TO_DEVICE.len() + 1 + 16 + 32);
let a = a_to_device_info(KemAlg::X25519HkdfSha256, &FILE_ID, &DEVICE_FPR, SEQ);
assert_eq!(a.len(), crate::label::A_TO_DEVICE.len() + 1 + 16 + 32 + 8);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic)]
mod kem_binding_tests {
use super::*;
const FILE_ID: [u8; 16] = [0x5a; 16];
#[test]
fn relabelling_a_slot_with_another_kem_changes_the_key_it_derives() {
let as_x25519 = slot_info(crate::label::SLOT_SERVER, KemAlg::X25519HkdfSha256, &FILE_ID);
let as_p256 = slot_info(crate::label::SLOT_SERVER, KemAlg::P256HkdfSha256, &FILE_ID);
assert_ne!(as_x25519, as_p256, "переразметка KEM не меняет info");
}
#[test]
fn a_blob_sealed_under_one_kem_label_does_not_open_under_another() {
struct Fixed(u8);
impl rand_core::TryRng for Fixed {
type Error = core::convert::Infallible;
fn try_next_u32(&mut self) -> Result<u32, Self::Error> {
Ok(u32::from(self.0))
}
fn try_next_u64(&mut self) -> Result<u64, Self::Error> {
Ok(u64::from(self.0))
}
fn try_fill_bytes(&mut self, dst: &mut [u8]) -> Result<(), Self::Error> {
for b in dst.iter_mut() {
self.0 = self.0.wrapping_add(1);
*b = self.0;
}
Ok(())
}
}
impl rand_core::TryCryptoRng for Fixed {}
let recipient = X25519Secret::from_bytes([0x11; 32]);
let public = x25519_public(&recipient);
let honest = slot_info(crate::label::SLOT_SERVER, KemAlg::X25519HkdfSha256, &FILE_ID);
let forged = slot_info(crate::label::SLOT_SERVER, KemAlg::P256HkdfSha256, &FILE_ID);
let blob = seal(&public, &honest, b"aad", b"share", &mut Fixed(1)).unwrap();
assert!(open(&recipient, &blob, &honest, b"aad").is_ok());
assert_eq!(
open(&recipient, &blob, &forged, b"aad").err(),
Some(CryptoError::Authentication),
"слот открылся под чужой меткой механизма"
);
}
#[test]
fn the_build_executes_exactly_the_mechanisms_it_claims() {
assert!(supports_kem(DEFAULT_SEALING_KEM));
assert!(supports_kem(KemAlg::X25519HkdfSha256));
assert!(supports_kem(KemAlg::P256HkdfSha256), "P-256 объявлен версией 2 и обязан исполняться");
assert!(!supports_kem(KemAlg::RsaOaepSha256), "неисполнимый механизм обязан остаться");
}
#[test]
fn a_p256_slot_seals_and_opens_through_the_agreement_trait() {
use crate::agreement::{KeyAgreement as _, P256Agreement};
let recipient = P256Agreement::from_be_bytes(&[0x5e; 32]).unwrap();
let recipient_public = recipient.public_key();
assert_eq!(recipient_public.len(), 65);
struct Counter(u8);
impl rand_core::TryRng for Counter {
type Error = core::convert::Infallible;
fn try_next_u32(&mut self) -> Result<u32, Self::Error> {
Ok(u32::from(self.0))
}
fn try_next_u64(&mut self) -> Result<u64, Self::Error> {
Ok(u64::from(self.0))
}
fn try_fill_bytes(&mut self, dst: &mut [u8]) -> Result<(), Self::Error> {
for b in dst.iter_mut() {
self.0 = self.0.wrapping_add(1);
*b = self.0;
}
Ok(())
}
}
impl rand_core::TryCryptoRng for Counter {}
let info = slot_info(crate::label::SLOT_AUTHOR_DEVICE, KemAlg::P256HkdfSha256, &[0x11; 16]);
let blob = seal_p256(&recipient_public, &info, b"aad", b"share", &mut Counter(7)).unwrap();
assert_eq!(blob.enc.len(), 65, "эфемерный ключ P-256 на проводе — 65 байт");
let opened = open_with(&recipient, &blob, &info, b"aad").unwrap();
assert_eq!(opened.as_slice(), b"share");
let stranger = P256Agreement::from_be_bytes(&[0x77; 32]).unwrap();
assert_eq!(
open_with(&stranger, &blob, &info, b"aad").err(),
Some(CryptoError::Authentication),
"слот открылся чужим ключом"
);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic)]
mod tests {
#[test]
fn canonical_x25519_coordinates_are_strictly_below_the_modulus() {
let mut modulus = [0xff; PUBLIC_KEY_LEN];
*modulus.first_mut().unwrap() = 0xed;
*modulus.last_mut().unwrap() = 0x7f;
let mut below = modulus;
*below.first_mut().unwrap() = 0xec;
assert!(canonical_x25519_public(&below));
assert!(canonical_x25519_public(&[0; PUBLIC_KEY_LEN]));
assert!(!canonical_x25519_public(&modulus));
assert!(!canonical_x25519_public(&[0xff; PUBLIC_KEY_LEN]));
let mut high_bit = [0; PUBLIC_KEY_LEN];
*high_bit.last_mut().unwrap() = 0x80;
assert!(!canonical_x25519_public(&high_bit));
}
#[test]
fn a_hardware_hybrid_slot_opens_with_both_halves() {
let pair = crate::mlkem_p256::keypair_from_seed(&[0x2f; 32]).unwrap();
let info = slot_info(crate::label::SLOT_RECIPIENT, crate::KemAlg::MlKem768P256, &[7u8; 16]);
let aad = [0x9d_u8; 32];
let mut rng = TestRng::seeded(0x51);
let blob = seal_mlkem_p256(&pair.public_key, &info, &aad, b"secret share", &mut rng).unwrap();
assert_eq!(blob.enc.len(), 1153, "enc аппаратного гибрида не 1153 байта");
let opened =
open_mlkem_p256(&pair.ml_kem_seed, &pair.classical, &blob, &info, &aad).unwrap();
assert_eq!(opened.as_slice(), b"secret share");
}
#[test]
fn neither_half_alone_opens_a_hardware_hybrid_slot() {
let pair = crate::mlkem_p256::keypair_from_seed(&[0x2f; 32]).unwrap();
let other = crate::mlkem_p256::keypair_from_seed(&[0x88; 32]).unwrap();
let info = slot_info(crate::label::SLOT_RECIPIENT, crate::KemAlg::MlKem768P256, &[7u8; 16]);
let mut rng = TestRng::seeded(0x51);
let blob = seal_mlkem_p256(&pair.public_key, &info, &[0u8; 32], b"share", &mut rng).unwrap();
assert!(
open_mlkem_p256(&other.ml_kem_seed, &pair.classical, &blob, &info, &[0u8; 32]).is_err(),
"слот открылся без своей постквантовой половины"
);
assert!(
open_mlkem_p256(&pair.ml_kem_seed, &other.classical, &blob, &info, &[0u8; 32]).is_err(),
"слот открылся без своей классической половины"
);
}
#[test]
fn a_hybrid_slot_seals_and_opens_with_the_same_seed() {
let secret = [0x2f_u8; crate::xwing::SECRET_LEN];
let public = crate::xwing::public_key(&secret).unwrap();
let info = slot_info(crate::label::SLOT_RECIPIENT, crate::KemAlg::XWing, &[7u8; 16]);
let aad = [0x9d_u8; 32];
let mut rng = TestRng::seeded(0x51);
let blob = seal_xwing(&public, &info, &aad, b"secret share", &mut rng).unwrap();
assert_eq!(blob.enc.len(), crate::xwing::CIPHERTEXT_LEN, "enc гибрида не 1120 байт");
let opened = open_xwing(&secret, &blob, &info, &aad).unwrap();
assert_eq!(opened.as_slice(), b"secret share");
}
#[test]
fn a_hybrid_slot_relabelled_with_another_mechanism_does_not_open() {
let secret = [0x2f_u8; crate::xwing::SECRET_LEN];
let public = crate::xwing::public_key(&secret).unwrap();
let honest = slot_info(crate::label::SLOT_RECIPIENT, crate::KemAlg::XWing, &[7u8; 16]);
let forged = slot_info(crate::label::SLOT_RECIPIENT, crate::KemAlg::X25519HkdfSha256, &[7u8; 16]);
let mut rng = TestRng::seeded(0x51);
let blob = seal_xwing(&public, &honest, &[0u8; 32], b"share", &mut rng).unwrap();
assert!(open_xwing(&secret, &blob, &forged, &[0u8; 32]).is_err());
}
#[test]
fn a_corrupted_hybrid_ciphertext_is_caught_by_the_aead_tag() {
let secret = [0x2f_u8; crate::xwing::SECRET_LEN];
let public = crate::xwing::public_key(&secret).unwrap();
let info = slot_info(crate::label::SLOT_RECIPIENT, crate::KemAlg::XWing, &[7u8; 16]);
let mut rng = TestRng::seeded(0x51);
let mut blob = seal_xwing(&public, &info, &[0u8; 32], b"share", &mut rng).unwrap();
if let Some(byte) = blob.enc.get_mut(0) {
*byte ^= 1;
}
assert!(matches!(
open_xwing(&secret, &blob, &info, &[0u8; 32]),
Err(CryptoError::Authentication)
));
}
use super::*;
struct TestRng([u8; 32]);
impl TestRng {
fn seeded(seed: u8) -> Self {
Self([seed; 32])
}
}
impl rand_core::TryRng for TestRng {
type Error = core::convert::Infallible;
fn try_next_u32(&mut self) -> Result<u32, Self::Error> {
let mut b = [0u8; 4];
self.try_fill_bytes(&mut b)?;
Ok(u32::from_le_bytes(b))
}
fn try_next_u64(&mut self) -> Result<u64, Self::Error> {
let mut b = [0u8; 8];
self.try_fill_bytes(&mut b)?;
Ok(u64::from_le_bytes(b))
}
fn try_fill_bytes(&mut self, dst: &mut [u8]) -> Result<(), Self::Error> {
for out in dst.chunks_mut(32) {
self.0 = blake3::hash(&self.0).into();
for (o, s) in out.iter_mut().zip(self.0.iter()) {
*o = *s;
}
}
Ok(())
}
}
impl rand_core::TryCryptoRng for TestRng {}
const INFO: &[u8] = b"CC/v1/slot-A\x01\x02\x03";
const AAD: &[u8] = b"policy-hash";
const PT: &[u8] = b"secret_A: 32 bytes worth of key ";
fn recipient(seed: u8) -> X25519Secret {
X25519Secret::from_bytes([seed; 32])
}
fn open_err(r: Result<Zeroizing<Vec<u8>>, CryptoError>) -> CryptoError {
match r {
Ok(_) => panic!("открытие должно было провалиться, но вернуло открытый текст"),
Err(e) => e,
}
}
#[test]
fn a_sealed_secret_opens_only_under_the_matching_private_key() {
let mut rng = TestRng::seeded(1);
let sk = recipient(7);
let pk = x25519_public(&sk);
let blob = seal(&pk, INFO, AAD, PT, &mut rng).unwrap();
assert_eq!(blob.ct.len(), PT.len().saturating_add(TAG_LEN), "тег обязан быть приписан");
let opened = open(&sk, &blob, INFO, AAD).unwrap();
assert_eq!(opened.as_slice(), PT);
}
#[test]
fn a_foreign_private_key_never_opens_the_blob() {
let mut rng = TestRng::seeded(2);
let sk = recipient(7);
let blob = seal(&x25519_public(&sk), INFO, AAD, PT, &mut rng).unwrap();
let stranger = recipient(9);
assert_eq!(open_err(open(&stranger, &blob, INFO, AAD)), CryptoError::Authentication);
}
#[test]
fn a_blob_sealed_for_one_slot_does_not_open_under_another_slots_info() {
let mut rng = TestRng::seeded(3);
let sk = recipient(7);
let blob = seal(&x25519_public(&sk), INFO, AAD, PT, &mut rng).unwrap();
let other_info = b"CC/v1/slot-B\x01\x02\x03";
assert_eq!(open_err(open(&sk, &blob, other_info, AAD)), CryptoError::Authentication);
}
#[test]
fn a_blob_does_not_open_under_a_different_aad() {
let mut rng = TestRng::seeded(4);
let sk = recipient(7);
let blob = seal(&x25519_public(&sk), INFO, AAD, PT, &mut rng).unwrap();
assert_eq!(open_err(open(&sk, &blob, INFO, b"other-policy")), CryptoError::Authentication);
}
#[test]
fn substituting_the_encapsulated_public_key_breaks_the_blob() {
let mut rng = TestRng::seeded(5);
let sk = recipient(7);
let mut blob = seal(&x25519_public(&sk), INFO, AAD, PT, &mut rng).unwrap();
let intruder = recipient(11);
blob.enc = x25519_public(&intruder).to_vec();
assert_eq!(open_err(open(&sk, &blob, INFO, AAD)), CryptoError::Authentication);
}
#[test]
fn flipping_a_single_ciphertext_bit_is_detected() {
let mut rng = TestRng::seeded(6);
let sk = recipient(7);
let mut blob = seal(&x25519_public(&sk), INFO, AAD, PT, &mut rng).unwrap();
let flipped = blob.ct.first_mut().map(|b| {
*b ^= 0x01;
});
assert!(flipped.is_some(), "шифротекст не может быть пустым");
assert_eq!(open_err(open(&sk, &blob, INFO, AAD)), CryptoError::Authentication);
}
#[test]
fn two_seals_of_the_same_input_never_repeat_enc_or_ciphertext() {
let mut rng = TestRng::seeded(8);
let sk = recipient(7);
let pk = x25519_public(&sk);
let first = seal(&pk, INFO, AAD, PT, &mut rng).unwrap();
let second = seal(&pk, INFO, AAD, PT, &mut rng).unwrap();
assert_ne!(first.enc, second.enc, "эфемерный ключ повторился");
assert_ne!(first.ct, second.ct, "шифротекст повторился при том же входе");
assert_eq!(open(&sk, &first, INFO, AAD).unwrap().as_slice(), PT);
assert_eq!(open(&sk, &second, INFO, AAD).unwrap().as_slice(), PT);
}
#[test]
fn a_low_order_public_key_is_refused_before_any_aead_work() {
let mut rng = TestRng::seeded(10);
let zero_pk = [0u8; PUBLIC_KEY_LEN];
let sealed = seal(&zero_pk, INFO, AAD, PT, &mut rng);
assert_eq!(sealed.err(), Some(CryptoError::BadKey));
let sk = recipient(7);
let blob = SealedBlob { enc: zero_pk.to_vec(), nonce: [0x01; 24], ct: vec![0u8; 48] };
assert_eq!(open_err(open(&sk, &blob, INFO, AAD)), CryptoError::BadKey);
}
#[test]
fn a_ciphertext_shorter_than_the_tag_is_a_length_error() {
let sk = recipient(7);
let blob = SealedBlob { enc: x25519_public(&recipient(3)).to_vec(), nonce: [0x01; 24], ct: vec![0u8; TAG_LEN - 1] };
assert_eq!(open_err(open(&sk, &blob, INFO, AAD)), CryptoError::BadLength);
}
#[test]
fn an_empty_plaintext_still_produces_an_authenticated_blob() {
let mut rng = TestRng::seeded(12);
let sk = recipient(7);
let blob = seal(&x25519_public(&sk), INFO, AAD, b"", &mut rng).unwrap();
assert_eq!(blob.ct.len(), TAG_LEN);
assert!(open(&sk, &blob, INFO, AAD).unwrap().is_empty());
}
}