use crate::secret::{
Cek, ClaimSecret, Kek, MacKey, MetaKey, PayloadKey, SecretA, SecretB, SessionMacKey, SECRET_LEN,
};
use crate::{label, AeadAlg, CryptoError};
use hkdf::Hkdf;
use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;
use subtle::ConstantTimeEq;
use zeroize::{Zeroize, Zeroizing};
const KEK_IKM_LEN: usize = 64;
fn hkdf_sha256<const N: usize>(salt: &[u8], ikm: &[u8], info: &[&[u8]]) -> Zeroizing<[u8; N]> {
let hk = Hkdf::<Sha256>::new(Some(salt), ikm);
let mut okm = Zeroizing::new([0u8; N]);
let _ = hk.expand_multi_info(info, okm.as_mut_slice());
okm
}
pub fn hedged_nonce<const N: usize>(
label: label::Label,
seed: &[u8],
plaintext: &[u8],
aad: &[u8],
) -> Result<[u8; N], CryptoError> {
let pt_len = u32::try_from(plaintext.len()).map_err(|_| CryptoError::BadLength)?;
let aad_len = u32::try_from(aad.len()).map_err(|_| CryptoError::BadLength)?;
let mut extract = hkdf::HkdfExtract::<Sha256>::new(Some(seed));
extract.input_ikm(&pt_len.to_be_bytes());
extract.input_ikm(plaintext);
extract.input_ikm(&aad_len.to_be_bytes());
extract.input_ikm(aad);
let (_, hk) = extract.finalize();
let mut out = [0u8; N];
hk.expand(label.as_bytes(), &mut out).map_err(|_| CryptoError::BadLength)?;
Ok(out)
}
fn hmac_sha256(key: &[u8], message: &[&[u8]]) -> [u8; 32] {
let mut tag = [0u8; 32];
if let Ok(mut mac) = <Hmac<Sha256> as KeyInit>::new_from_slice(key) {
for part in message {
mac.update(part);
}
tag.copy_from_slice(mac.finalize().into_bytes().as_slice());
}
tag
}
pub fn prove_echo(challenge: &[u8], device_fpr: &[u8; 32]) -> [u8; 32] {
hmac_sha256(challenge, &[label::PROVE_ECHO.as_bytes(), device_fpr])
}
#[must_use]
pub fn handshake_transcript(hello_framed: &[u8], challenge_framed: &[u8]) -> [u8; 32] {
use sha2::Digest as _;
let mut t = crate::transcript::Transcript::new(label::ECHO_TRANSCRIPT);
t.field(hello_framed).field(challenge_framed);
Sha256::digest(t.as_bytes()).into()
}
#[must_use]
pub fn echo_transcript(
challenge: &[u8],
device_fpr: &[u8; 32],
handshake: &[u8; 32],
) -> [u8; 32] {
hmac_sha256(challenge, &[label::ECHO_TRANSCRIPT.as_bytes(), device_fpr, handshake])
}
pub fn derive_session_mac_key(challenge: &[u8], device_fpr: &[u8; 32]) -> SessionMacKey {
SessionMacKey::from_bytes(*hkdf_sha256::<SECRET_LEN>(
&[], challenge, &[label::SESSION_MAC.as_bytes(), device_fpr],
))
}
pub fn device_fpr(kem: crate::KemAlg, public: &[u8]) -> Result<[u8; 32], CryptoError> {
use crate::KemAlg;
kem.ensure_supported()?;
let Some(expected) = public_key_len(kem) else {
return Err(CryptoError::UnsupportedAlgorithm);
};
if public.len() != expected {
return Err(CryptoError::BadLength);
}
if kem == KemAlg::X25519HkdfSha256 {
return <[u8; 32]>::try_from(public).map_err(|_| CryptoError::BadLength);
}
use sha2::Digest as _;
let mut h = Sha256::new();
h.update(label::DEVICE_FPR.as_bytes());
h.update([0x00]);
h.update([kem as u8]);
h.update(public);
Ok(h.finalize().into())
}
fn public_key_len(kem: crate::KemAlg) -> Option<usize> {
use crate::KemAlg;
match kem {
KemAlg::X25519HkdfSha256 => Some(32),
KemAlg::P256HkdfSha256 => Some(65),
KemAlg::XWing => Some(crate::xwing::PUBLIC_KEY_LEN),
KemAlg::MlKem768P256 => Some(crate::mlkem_p256::PUBLIC_KEY_LEN),
KemAlg::RsaOaepSha256 => None,
}
}
pub fn request_mac(key: &SessionMacKey, framed: &[u8]) -> [u8; 32] {
hmac_sha256(key.expose(), &[framed])
}
#[must_use]
pub fn operation_id(seed: &[u8; 32], kind: u8, body: &[u8]) -> [u8; 32] {
use sha2::Digest as _;
let mut h = Sha256::new();
h.update(label::OPERATION_ID.as_bytes());
h.update([0x00]);
h.update(seed);
h.update([kind]);
h.update(body);
h.finalize().into()
}
pub const FRESH_ATTEST_NONCE: u8 = 1;
pub const FRESH_PROOF_SECRET: u8 = 2;
pub const FRESH_CREDENTIAL_SECRET: u8 = 3;
pub const FRESH_CREDENTIAL_SEED: u8 = 4;
pub const FRESH_CREDENTIAL_OAEP: u8 = 5;
pub fn server_fresh(
seed: &[u8; 32],
kind: u8,
now: i64,
device_fpr: &[u8; 32],
out: &mut [u8],
) -> Result<(), CryptoError> {
use sha2::Digest as _;
let mut h = Sha256::new();
h.update(label::SERVER_FRESH.as_bytes());
h.update([0x00]);
h.update(seed);
h.update([kind]);
h.update(now.to_be_bytes());
h.update(device_fpr);
let prk = Zeroizing::new(<[u8; 32]>::from(h.finalize()));
let hk = Hkdf::<Sha256>::from_prk(prk.as_slice()).map_err(|_| CryptoError::BadLength)?;
hk.expand(label::SERVER_FRESH.as_bytes(), out).map_err(|_| CryptoError::BadLength)
}
#[must_use]
pub fn attest_qualify(challenge: &[u8; 32], device_fpr: &[u8; 32]) -> [u8; 32] {
use sha2::Digest as _;
let mut h = Sha256::new();
h.update(label::ATTEST_QUALIFY.as_bytes());
h.update([0x00]);
h.update(challenge);
h.update(device_fpr);
h.finalize().into()
}
pub fn derive_kek(file_id: &[u8; 16], org_id: &[u8], a: &SecretA, b: &SecretB) -> Kek {
let mut ikm = [0u8; KEK_IKM_LEN];
let (first, second) = ikm.split_at_mut(SECRET_LEN);
first.copy_from_slice(a.expose());
second.copy_from_slice(b.expose());
let okm = hkdf_sha256::<SECRET_LEN>(file_id, &ikm, &[label::KEK.as_bytes(), org_id, file_id]);
ikm.zeroize();
Kek::from_bytes(*okm)
}
pub fn derive_payload_key(
cek: &Cek,
header_salt: &[u8; 32],
file_id: &[u8; 16],
chunk_size: u32,
aead: AeadAlg,
) -> PayloadKey {
PayloadKey::from_bytes(*hkdf_sha256::<SECRET_LEN>(
header_salt,
cek.expose(),
&[
label::PAYLOAD.as_bytes(),
file_id.as_slice(),
&chunk_size.to_be_bytes(),
&[aead as u8],
],
))
}
pub fn derive_private_meta_key(cek: &Cek, header_salt: &[u8; 32], file_id: &[u8; 16]) -> MetaKey {
MetaKey::from_bytes(*hkdf_sha256::<SECRET_LEN>(
header_salt,
cek.expose(),
&[label::PRIVATE_META.as_bytes(), file_id.as_slice()],
))
}
pub fn derive_content_mac_key(cek: &Cek, header_salt: &[u8; 32], file_id: &[u8; 16]) -> MacKey {
MacKey::from_bytes(*hkdf_sha256::<SECRET_LEN>(
header_salt,
cek.expose(),
&[label::CONTENT_MAC.as_bytes(), file_id.as_slice()],
))
}
pub fn derive_witness_key(device: &crate::secret::X25519Secret) -> MacKey {
MacKey::from_bytes(*hkdf_sha256::<SECRET_LEN>(
&[],
device.expose(),
&[label::CACHED_LEASE.as_bytes()],
))
}
pub fn mark_choice(key: &MacKey, layout: &[u8; 32], token: u64, point: u32, variants: u32) -> Result<u32, CryptoError> {
if variants == 0 {
return Err(CryptoError::BadLength);
}
let mut t = crate::Transcript::new(label::MARK_CHOICE);
t.fixed(layout);
t.u64be(token);
t.u32be(point);
let tag = crate::mac::compute(key, &t)?;
let head: [u8; 8] = tag.get(..8).and_then(|h| h.try_into().ok()).ok_or(CryptoError::BadLength)?;
let value = u64::from_be_bytes(head).checked_rem(u64::from(variants)).ok_or(CryptoError::BadLength)?;
u32::try_from(value).map_err(|_| CryptoError::BadLength)
}
pub fn claim_secret_from_code(canonical: &[u8]) -> ClaimSecret {
let mut hasher = blake3::Hasher::new();
hasher.update(label::CLAIM_CODE.as_bytes());
hasher.update(&[0x00]);
hasher.update(canonical);
ClaimSecret::from_bytes(*hasher.finalize().as_bytes())
}
#[must_use]
pub fn device_secret_from_claim(file_id: &[u8; 16], claim: &ClaimSecret) -> [u8; 32] {
*hkdf_sha256::<SECRET_LEN>(file_id, claim.expose(), &[label::CLAIM_DEVICE.as_bytes()])
}
pub fn secret_b_from_claim(file_id: &[u8; 16], claim: &ClaimSecret) -> (SecretB, [u8; 32]) {
let share = hkdf_sha256::<SECRET_LEN>(file_id, claim.expose(), &[label::SLOT_B_CLAIM.as_bytes()]);
let commitment = hkdf_sha256::<SECRET_LEN>(file_id, claim.expose(), &[label::SLOT_B_COMMIT.as_bytes()]);
(SecretB::from_bytes(*share), *commitment)
}
pub fn slot_commitment(kek: &Kek, core_hash: &[u8; 32]) -> [u8; 32] {
hmac_sha256(kek.expose(), &[label::SLOT_COMMIT.as_bytes(), core_hash.as_slice()])
}
pub fn verify_commitment(expected: &[u8; 32], actual: &[u8; 32]) -> Result<(), CryptoError> {
if bool::from(expected.ct_eq(actual)) {
Ok(())
} else {
Err(CryptoError::Authentication)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
const FILE_ONE: [u8; 16] = [0x11; 16];
const FILE_TWO: [u8; 16] = [0x12; 16];
const SALT_ONE: [u8; 32] = [0x21; 32];
const SALT_TWO: [u8; 32] = [0x22; 32];
const ORG_ONE: &[u8] = b"acme";
const ORG_TWO: &[u8] = b"acme-eu";
fn shares() -> (SecretA, SecretB) {
(SecretA::from_bytes([0xa1; 32]), SecretB::from_bytes([0xb2; 32]))
}
fn cek() -> Cek {
Cek::from_bytes([0xc3; 32])
}
#[test]
fn the_kek_follows_rfc5869_with_the_file_id_as_salt_and_the_shares_as_ikm() {
let (a, b) = shares();
let mut ikm = [0u8; KEK_IKM_LEN];
let (first, second) = ikm.split_at_mut(SECRET_LEN);
first.copy_from_slice(a.expose());
second.copy_from_slice(b.expose());
let prk = hmac_sha256(&FILE_ONE, &[&ikm]);
let expected = hmac_sha256(&prk, &[label::KEK.as_bytes(), ORG_ONE, &FILE_ONE, &[0x01]]);
assert_eq!(derive_kek(&FILE_ONE, ORG_ONE, &a, &b).expose(), &expected);
}
#[test]
fn the_same_shares_in_two_files_never_yield_the_same_kek() {
let (a, b) = shares();
let one = derive_kek(&FILE_ONE, ORG_ONE, &a, &b);
let two = derive_kek(&FILE_TWO, ORG_ONE, &a, &b);
assert_ne!(one.expose(), two.expose());
}
#[test]
fn two_tenants_never_share_a_kek() {
let (a, b) = shares();
let one = derive_kek(&FILE_ONE, ORG_ONE, &a, &b);
let two = derive_kek(&FILE_ONE, ORG_TWO, &a, &b);
assert_ne!(one.expose(), two.expose());
}
#[test]
fn swapping_the_two_shares_yields_a_different_kek() {
let straight = derive_kek(
&FILE_ONE,
ORG_ONE,
&SecretA::from_bytes([0xa1; 32]),
&SecretB::from_bytes([0xb2; 32]),
);
let swapped = derive_kek(
&FILE_ONE,
ORG_ONE,
&SecretA::from_bytes([0xb2; 32]),
&SecretB::from_bytes([0xa1; 32]),
);
assert_ne!(straight.expose(), swapped.expose());
}
#[test]
fn an_org_id_boundary_cannot_be_shifted_into_the_file_id() {
let (a, b) = shares();
let long = derive_kek(&FILE_ONE, b"acme\x11\x11", &a, &b);
let short = derive_kek(&FILE_ONE, b"acme", &a, &b);
assert_ne!(long.expose(), short.expose());
}
#[test]
fn a_repack_with_a_new_header_salt_changes_the_payload_key() {
let key_one =
derive_payload_key(&cek(), &SALT_ONE, &FILE_ONE, 65536, AeadAlg::XChaCha20Poly1305);
let key_two =
derive_payload_key(&cek(), &SALT_TWO, &FILE_ONE, 65536, AeadAlg::XChaCha20Poly1305);
assert_ne!(key_one.expose(), key_two.expose());
}
#[test]
fn the_payload_key_changes_with_the_chunk_size() {
let small =
derive_payload_key(&cek(), &SALT_ONE, &FILE_ONE, 16384, AeadAlg::XChaCha20Poly1305);
let large =
derive_payload_key(&cek(), &SALT_ONE, &FILE_ONE, 65536, AeadAlg::XChaCha20Poly1305);
assert_ne!(small.expose(), large.expose());
}
#[test]
fn the_payload_key_changes_with_the_aead_id() {
let xchacha =
derive_payload_key(&cek(), &SALT_ONE, &FILE_ONE, 65536, AeadAlg::XChaCha20Poly1305);
let gcm = derive_payload_key(&cek(), &SALT_ONE, &FILE_ONE, 65536, AeadAlg::Aes256Gcm);
let siv = derive_payload_key(&cek(), &SALT_ONE, &FILE_ONE, 65536, AeadAlg::Aes256GcmSiv);
assert_ne!(xchacha.expose(), gcm.expose());
assert_ne!(gcm.expose(), siv.expose());
assert_ne!(xchacha.expose(), siv.expose());
}
#[test]
fn the_same_cek_in_two_files_never_yields_the_same_payload_key() {
let one =
derive_payload_key(&cek(), &SALT_ONE, &FILE_ONE, 65536, AeadAlg::XChaCha20Poly1305);
let two =
derive_payload_key(&cek(), &SALT_ONE, &FILE_TWO, 65536, AeadAlg::XChaCha20Poly1305);
assert_ne!(one.expose(), two.expose());
}
#[test]
fn private_metadata_and_content_mac_never_share_a_key_with_the_payload() {
let payload =
derive_payload_key(&cek(), &SALT_ONE, &FILE_ONE, 65536, AeadAlg::XChaCha20Poly1305);
let meta = derive_private_meta_key(&cek(), &SALT_ONE, &FILE_ONE);
let mac = derive_content_mac_key(&cek(), &SALT_ONE, &FILE_ONE);
assert_ne!(payload.expose(), meta.expose());
assert_ne!(meta.expose(), mac.expose());
assert_ne!(payload.expose(), mac.expose());
}
#[test]
fn the_same_claim_code_in_two_files_yields_two_different_shares() {
let claim = ClaimSecret::from_bytes([0x7c; 32]);
let (share_one, commit_one) = secret_b_from_claim(&FILE_ONE, &claim);
let (share_two, commit_two) = secret_b_from_claim(&FILE_TWO, &claim);
assert_ne!(share_one.expose(), share_two.expose());
assert_ne!(commit_one, commit_two);
}
#[test]
fn a_claim_commitment_never_equals_the_share_it_commits_to() {
let claim = ClaimSecret::from_bytes([0x7c; 32]);
let (share, commitment) = secret_b_from_claim(&FILE_ONE, &claim);
assert_ne!(share.expose(), &commitment);
}
#[test]
fn a_single_flipped_bit_in_a_commitment_is_refused() {
let expected = [0x9a; 32];
let mut actual = expected;
if let Some(byte) = actual.get_mut(31) {
*byte ^= 0x01;
}
assert_eq!(verify_commitment(&expected, &expected), Ok(()));
assert_eq!(
verify_commitment(&expected, &actual),
Err(CryptoError::Authentication)
);
}
#[test]
fn every_derivation_of_the_schedule_is_domain_separated() {
let material = [0x55u8; 32];
let file_id = [0x55u8; 16];
let salt = material;
let a = SecretA::from_bytes(material);
let b = SecretB::from_bytes(material);
let cek = Cek::from_bytes(material);
let claim = ClaimSecret::from_bytes(material);
let kek = Kek::from_bytes(material);
let (share, claim_commitment) = secret_b_from_claim(&file_id, &claim);
let derived: Vec<(&str, Vec<u8>)> = vec![
("K1 kek", derive_kek(&file_id, &material, &a, &b).expose().to_vec()),
(
"K3 payload",
derive_payload_key(&cek, &salt, &file_id, 65536, AeadAlg::XChaCha20Poly1305)
.expose()
.to_vec(),
),
(
"K5 private-meta",
derive_private_meta_key(&cek, &salt, &file_id).expose().to_vec(),
),
(
"K6 content-mac",
derive_content_mac_key(&cek, &salt, &file_id).expose().to_vec(),
),
("K7 slot-b-claim", share.expose().to_vec()),
("K8 slot-b-commit", claim_commitment.to_vec()),
("K9 slot-commit", slot_commitment(&kek, &salt).to_vec()),
];
for (index, (left_name, left)) in derived.iter().enumerate() {
for (right_name, right) in derived.iter().skip(index.saturating_add(1)) {
let common = left.len().min(right.len());
let left_prefix: Vec<u8> = left.iter().copied().take(common).collect();
let right_prefix: Vec<u8> = right.iter().copied().take(common).collect();
assert_ne!(
left_prefix, right_prefix,
"{left_name} и {right_name} совпали: разделение доменов не работает"
);
}
}
for (name, value) in &derived {
assert!(value.iter().any(|byte| *byte != 0), "{name} выродился в нули");
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic, clippy::indexing_slicing)]
mod device_fpr_tests {
use super::*;
use crate::KemAlg;
#[test]
fn x25519_fingerprint_is_the_key_itself() {
let key = [0x5a; 32];
assert_eq!(device_fpr(KemAlg::X25519HkdfSha256, &key).unwrap(), key);
}
#[test]
fn the_mechanism_number_is_part_of_the_name() {
let xw = crate::xwing::public_key(&[0x4d; 32]).unwrap();
let a = device_fpr(KemAlg::XWing, &xw).unwrap();
assert!(device_fpr(KemAlg::P256HkdfSha256, &xw[..65]).is_ok());
assert_ne!(a, device_fpr(KemAlg::P256HkdfSha256, &xw[..65]).unwrap());
assert_ne!(&a[..], &xw[..32], "хеш не должен совпадать с началом ключа");
}
#[test]
fn a_key_of_the_wrong_length_is_refused_not_hashed() {
assert_eq!(device_fpr(KemAlg::X25519HkdfSha256, &[0; 31]), Err(CryptoError::BadLength));
assert_eq!(device_fpr(KemAlg::P256HkdfSha256, &[4; 64]), Err(CryptoError::BadLength));
assert_eq!(device_fpr(KemAlg::XWing, &[0; 1215]), Err(CryptoError::BadLength));
assert_eq!(device_fpr(KemAlg::MlKem768P256, &[0; 1250]), Err(CryptoError::BadLength));
assert_eq!(device_fpr(KemAlg::RsaOaepSha256, &[0; 256]), Err(CryptoError::UnsupportedAlgorithm));
}
#[test]
fn the_length_table_has_a_hole_exactly_where_the_build_has_no_mechanism() {
let all: Vec<KemAlg> = (0u8..=255).filter_map(|v| KemAlg::from_u8(v).ok()).collect();
assert_eq!(all.len(), 5, "реестр механизмов изменился — проверьте таблицы");
for kem in all {
assert_eq!(
public_key_len(kem).is_some(),
kem.ensure_supported().is_ok(),
"таблица длин разошлась с исполнимостью на {kem:?}"
);
}
}
#[test]
fn the_label_is_in_the_preimage_not_in_the_output() {
let p = [0x04; 65];
let f = device_fpr(KemAlg::P256HkdfSha256, &p).unwrap();
assert!(!f.starts_with(b"CC/v1"));
}
}