Skip to main content

oc_crypto/
kdf.rs

1// SPDX-License-Identifier: MPL-2.0
2//! Key schedule: derivations K1…K9 from `docs/format.md`, section 3.5.
3//!
4//! Each function corresponds to exactly one table row. No label is
5//! used twice, and each derivation includes `file_id` to prevent keys from
6//! matching across files even when the initial secrets match.
7
8use crate::secret::{
9    Cek, ClaimSecret, Kek, MacKey, MetaKey, PayloadKey, SecretA, SecretB, SessionMacKey, SECRET_LEN,
10};
11use crate::{label, AeadAlg, CryptoError};
12
13use hkdf::Hkdf;
14use hmac::{Hmac, KeyInit, Mac};
15use sha2::Sha256;
16use subtle::ConstantTimeEq;
17use zeroize::{Zeroize, Zeroizing};
18
19/// K1 ikm length: exactly two 32-byte shares.
20///
21/// Hardcoded as a constant rather than derived from argument lengths because
22/// fixed length is a condition of the combiner's correctness, not an
23/// implementation detail.
24const KEK_IKM_LEN: usize = 64;
25
26/// Expand HKDF-SHA256 into a buffer whose length is specified by its type.
27///
28/// `expand` fails in only one case: requesting more than 255×32
29/// bytes; here the array type specifies output length, at most 32 bytes,
30/// so the error branch is unreachable. K1…K8 therefore return a key rather than `Result`:
31/// threading an impossible variant through every key-schedule call would
32/// train callers to use `?` where nothing can fail.
33/// Returns a wiping wrapper rather than a bare array.
34///
35/// This function outputs key material. A bare `[u8; N]` remains on the stack until
36/// the calling function ends and can enter the pagefile with it, without an owner
37/// to wipe it on destruction. The wrapper makes wiping
38/// a property of the type: previously every caller had to remember it individually,
39/// and not all did.
40fn hkdf_sha256<const N: usize>(salt: &[u8], ikm: &[u8], info: &[&[u8]]) -> Zeroizing<[u8; N]> {
41    let hk = Hkdf::<Sha256>::new(Some(salt), ikm);
42    let mut okm = Zeroizing::new([0u8; N]);
43    // Результат игнорируется осознанно: альтернатива — паника, запрещённая в
44    // этом крейте. Вырождение буфера в нули поймал бы тест
45    // `every_derivation_of_the_schedule_is_domain_separated`, который сравнивает
46    // все производные схемы между собой.
47    let _ = hk.expand_multi_info(info, okm.as_mut_slice());
48    okm
49}
50
51/// Hedge a nonce against repeated RNG state (`docs/format.md` §3.1, §6.1).
52///
53/// Since version 3, `HKDF-Extract(salt = RNG seed,
54/// ikm = u32be(len(pt)) ‖ pt ‖ u32be(len(aad)) ‖ aad)`, followed by
55/// `Expand(info = label)`. Lengths separate variable-width inputs; AAD is exactly
56/// what AEAD authenticates. Unrepresentable lengths return `BadLength`.
57///
58/// Changed plaintext or AAD changes the nonce even if the random seed repeats.
59/// This is not SIV: the key is not an input, and fully repeated inputs still
60/// repeat the nonce. The sender stores the nonce; the reader never derives it.
61/// The seed is transient, and [`label::Label`] restricts the derivation domain.
62pub fn hedged_nonce<const N: usize>(
63    label: label::Label,
64    seed: &[u8],
65    plaintext: &[u8],
66    aad: &[u8],
67) -> Result<[u8; N], CryptoError> {
68    let pt_len = u32::try_from(plaintext.len()).map_err(|_| CryptoError::BadLength)?;
69    let aad_len = u32::try_from(aad.len()).map_err(|_| CryptoError::BadLength)?;
70    // Потоковый Extract не создаёт вторую копию открытого текста в куче (И-11).
71    let mut extract = hkdf::HkdfExtract::<Sha256>::new(Some(seed));
72    extract.input_ikm(&pt_len.to_be_bytes());
73    extract.input_ikm(plaintext);
74    extract.input_ikm(&aad_len.to_be_bytes());
75    extract.input_ikm(aad);
76    let (_, hk) = extract.finalize();
77    let mut out = [0u8; N];
78    hk.expand(label.as_bytes(), &mut out).map_err(|_| CryptoError::BadLength)?;
79    Ok(out)
80}
81/// `HMAC-SHA256` over a sequence of message pieces.
82///
83/// Pieces are fed sequentially rather than concatenated into a buffer: concatenation would
84/// allocate memory for a message needed nowhere else.
85fn hmac_sha256(key: &[u8], message: &[&[u8]]) -> [u8; 32] {
86    let mut tag = [0u8; 32];
87    // `new_from_slice` у HMAC принимает ключ любой длины (длинный хешируется,
88    // короткий дополняется нулями), поэтому ошибка недостижима.
89    if let Ok(mut mac) = <Hmac<Sha256> as KeyInit>::new_from_slice(key) {
90        for part in message {
91            mac.update(part);
92        }
93        // Выход HMAC-SHA256 всегда 32 байта, длины совпадают по построению.
94        tag.copy_from_slice(mac.finalize().into_bytes().as_slice());
95    }
96    tag
97}
98
99/// Legacy K23 challenge echo, retained only for its frozen vector.
100///
101/// All challenge bytes enter the HMAC key; output is 32 bytes. This form does not
102/// bind the conversation. The protocol uses [`echo_transcript`] (K31) instead,
103/// and `the_old_unbound_echo_has_no_callers_outside_its_vector` guards that boundary.
104pub fn prove_echo(challenge: &[u8], device_fpr: &[u8; 32]) -> [u8; 32] {
105    hmac_sha256(challenge, &[label::PROVE_ECHO.as_bytes(), device_fpr])
106}
107
108/// K31 handshake transcript hash (`docs/protocol.md` §9.4).
109///
110/// `SHA-256("CC/v1/echo-transcript" ‖ 0x00 ‖ u32le(len(hello)) ‖ hello ‖
111/// u32le(len(challenge)) ‖ challenge)`.
112///
113/// Both peers have these complete raw frames. Length prefixes prevent moving
114/// bytes between them without changing the hash. Re-encoding parsed values would
115/// lose changes to their original representation.
116#[must_use]
117pub fn handshake_transcript(hello_framed: &[u8], challenge_framed: &[u8]) -> [u8; 32] {
118    use sha2::Digest as _;
119    let mut t = crate::transcript::Transcript::new(label::ECHO_TRANSCRIPT);
120    t.field(hello_framed).field(challenge_framed);
121    Sha256::digest(t.as_bytes()).into()
122}
123
124/// K31 proof-of-possession echo bound to the conversation.
125///
126/// `HMAC-SHA256(key = challenge secret, "CC/v1/echo-transcript" ‖ device_fpr ‖
127/// handshake)`, where `handshake` comes from [`handshake_transcript`].
128///
129/// Including the transcript prevents an echo from moving to a different handshake
130/// when the challenge secret repeats. It cannot distinguish a fully repeated
131/// transcript after snapshot and clock rollback, or protect against a stolen key.
132#[must_use]
133pub fn echo_transcript(
134    challenge: &[u8],
135    device_fpr: &[u8; 32],
136    handshake: &[u8; 32],
137) -> [u8; 32] {
138    hmac_sha256(challenge, &[label::ECHO_TRANSCRIPT.as_bytes(), device_fpr, handshake])
139}
140
141/// K24: a separate key authenticating requests in this conversation.
142pub fn derive_session_mac_key(challenge: &[u8], device_fpr: &[u8; 32]) -> SessionMacKey {
143    SessionMacKey::from_bytes(*hkdf_sha256::<SECRET_LEN>(
144        &[], challenge, &[label::SESSION_MAC.as_bytes(), device_fpr],
145    ))
146}
147
148/// K27 device fingerprint bound to its agreement mechanism and public key.
149///
150/// X25519 returns the public key itself for compatibility with frozen vectors.
151/// Other supported mechanisms use
152/// `SHA-256("CC/v1/device-fpr" ‖ 0x00 ‖ u8(kem_id) ‖ public)`.
153/// Callers must compare this result with the presented fingerprint before using
154/// the key; the mechanism byte prevents relabeling the same bytes.
155///
156/// # Errors
157/// `public` has the wrong length, or the mechanism has no defined fingerprint.
158pub fn device_fpr(kem: crate::KemAlg, public: &[u8]) -> Result<[u8; 32], CryptoError> {
159    use crate::KemAlg;
160    // Исполнимость спрашивается ОДНИМ именем, а не вторым `match` рядом с
161    // таблицей длин. Раньше ответ «RSA-OAEP не умеем» стоял прямо в этой
162    // таблице, и таких ответов по репозиторию было четыре: разойтись они могли
163    // только в сторону «механизм, которого сборка не исполняет, где-то сочли
164    // исполнимым».
165    kem.ensure_supported()?;
166    let Some(expected) = public_key_len(kem) else {
167        // Недостижимо: пробел таблицы длин совпадает с неисполнимым механизмом,
168        // и совпадение это не на памяти — его держит проба
169        // `the_length_table_has_a_hole_exactly_where_the_build_has_no_mechanism`.
170        return Err(CryptoError::UnsupportedAlgorithm);
171    };
172    if public.len() != expected {
173        return Err(CryptoError::BadLength);
174    }
175    if kem == KemAlg::X25519HkdfSha256 {
176        // Отпечаток И ЕСТЬ ключ — заморожено, см. выше.
177        return <[u8; 32]>::try_from(public).map_err(|_| CryptoError::BadLength);
178    }
179    use sha2::Digest as _;
180    let mut h = Sha256::new();
181    h.update(label::DEVICE_FPR.as_bytes());
182    h.update([0x00]);
183    h.update([kem as u8]);
184    h.update(public);
185    Ok(h.finalize().into())
186}
187
188/// A mechanism's public-key length is a DIFFERENT question from executability.
189///
190/// `None` means "this mechanism defines no key shape", currently exactly
191/// unimplemented RSA-OAEP: its keys have variable length and no defined
192/// fingerprint. Agreement between the two tables, a gap here and `false` in
193/// [`crate::seal::supports_kem`], is tested rather than assumed:
194/// length is a property of shape, executability of a build; tomorrow a
195/// mechanism may have a known shape that the build cannot yet execute.
196///
197/// `match` without `_`: a new [`crate::KemAlg`] member must break the build here.
198fn public_key_len(kem: crate::KemAlg) -> Option<usize> {
199    use crate::KemAlg;
200    match kem {
201        KemAlg::X25519HkdfSha256 => Some(32),
202        KemAlg::P256HkdfSha256 => Some(65),
203        KemAlg::XWing => Some(crate::xwing::PUBLIC_KEY_LEN),
204        KemAlg::MlKem768P256 => Some(crate::mlkem_p256::PUBLIC_KEY_LEN),
205        KemAlg::RsaOaepSha256 => None,
206    }
207}
208
209/// K25: MAC of raw frame bytes including kind, but excluding the trailing MAC.
210/// No label: K24 is dedicated to K25, and the kind inside the message separates requests.
211pub fn request_mac(key: &SessionMacKey, framed: &[u8]) -> [u8; 32] {
212    hmac_sha256(key.expose(), &[framed])
213}
214
215/// K28 wire operation identity (`docs/protocol.md` §9.10).
216///
217/// `SHA-256("CC/v1/operation-id" ‖ 0x00 ‖ seed(32) ‖ u8(kind) ‖ body)`.
218/// `body` is the encoded request without the identity field itself.
219///
220/// Binding kind and body separates different operations when the RNG repeats;
221/// identical requests retain the same identity. This public value provides
222/// deduplication, not authentication.
223#[must_use]
224pub fn operation_id(seed: &[u8; 32], kind: u8, body: &[u8]) -> [u8; 32] {
225    use sha2::Digest as _;
226    let mut h = Sha256::new();
227    h.update(label::OPERATION_ID.as_bytes());
228    h.update([0x00]);
229    h.update(seed);
230    h.update([kind]);
231    h.update(body);
232    h.finalize().into()
233}
234
235/// Purpose of a fresh server value: attestation challenge (`docs/protocol.md`
236/// §9.11.1, step 1).
237pub const FRESH_ATTEST_NONCE: u8 = 1;
238
239/// Purpose of a fresh server value: proof-of-possession secret
240/// (`docs/protocol.md` §9.4).
241pub const FRESH_PROOF_SECRET: u8 = 2;
242
243/// Purpose of a fresh server value: TPM credential secret
244/// (`TPM2_MakeCredential`, `docs/protocol.md` §9.11.1, step 2).
245pub const FRESH_CREDENTIAL_SECRET: u8 = 3;
246
247/// Purpose of a fresh server value: TPM credential protection seed.
248pub const FRESH_CREDENTIAL_SEED: u8 = 4;
249
250/// Purpose of a fresh server value: OAEP seed for credential protection
251/// with an RSA EK.
252pub const FRESH_CREDENTIAL_OAEP: u8 = 5;
253
254/// K30 server freshness (`docs/format.md`, "SERVER FRESHNESS IS DERIVED").
255///
256/// `prk = SHA-256("CC/v1/server-fresh" ‖ 0x00 ‖ seed(32) ‖ u8(kind) ‖
257/// i64be(now) ‖ device_fpr(32))`, then `HKDF-Expand(prk, info = label)`.
258///
259/// Time separates calls for one device when random state repeats; the fingerprint
260/// separates devices within one second. Fully repeated inputs, including clock
261/// rollback, still produce the same output. The caller supplies the clock and seed.
262///
263/// # Errors
264/// [`CryptoError::BadLength`] if more than 255×32 bytes are requested.
265pub fn server_fresh(
266    seed: &[u8; 32],
267    kind: u8,
268    now: i64,
269    device_fpr: &[u8; 32],
270    out: &mut [u8],
271) -> Result<(), CryptoError> {
272    use sha2::Digest as _;
273    let mut h = Sha256::new();
274    h.update(label::SERVER_FRESH.as_bytes());
275    h.update([0x00]);
276    h.update(seed);
277    h.update([kind]);
278    h.update(now.to_be_bytes());
279    h.update(device_fpr);
280    // Секрет доказательства владения — ключевой материал, и prk его порождает.
281    // Голый массив на стеке уехал бы в файл подкачки без владельца, который его
282    // затрёт (И-11).
283    let prk = Zeroizing::new(<[u8; 32]>::from(h.finalize()));
284    let hk = Hkdf::<Sha256>::from_prk(prk.as_slice()).map_err(|_| CryptoError::BadLength)?;
285    hk.expand(label::SERVER_FRESH.as_bytes(), out).map_err(|_| CryptoError::BadLength)
286}
287
288/// K29: qualifying data for the TPM statement about a device key
289/// (`docs/protocol.md` §9.11, link 4): `extraData` in `TPMS_ATTEST`.
290///
291/// The challenge binds the statement to the conversation and the fingerprint to the device:
292/// a statement taken for another challenge or about another key will not match.
293/// No secret: the challenge travels openly on the wire; uniqueness is required.
294#[must_use]
295pub fn attest_qualify(challenge: &[u8; 32], device_fpr: &[u8; 32]) -> [u8; 32] {
296    use sha2::Digest as _;
297    let mut h = Sha256::new();
298    h.update(label::ATTEST_QUALIFY.as_bytes());
299    h.update([0x00]);
300    h.update(challenge);
301    h.update(device_fpr);
302    h.finalize().into()
303}
304
305/// K1: file wrapping key.
306///
307/// `HKDF-SHA256(salt=file_id, ikm=secret_A‖secret_B, info="CC/v1/kek"‖org_id‖file_id)`.
308///
309/// `HKDF-Extract` over concatenation is a valid combiner **only** when
310/// both shares have fixed lengths; types guarantee that here.
311pub fn derive_kek(file_id: &[u8; 16], org_id: &[u8], a: &SecretA, b: &SecretB) -> Kek {
312    // Ровно 64 байта, по 32 на долю. При переменной длине пара (A‖B) стала бы
313    // неоднозначной: другая пара с тем же склеенным представлением дала бы тот
314    // же KEK, и схема «2 из 2» перестала бы быть схемой «2 из 2».
315    let mut ikm = [0u8; KEK_IKM_LEN];
316    let (first, second) = ikm.split_at_mut(SECRET_LEN);
317    first.copy_from_slice(a.expose());
318    second.copy_from_slice(b.expose());
319
320    // `org_id` входит в `info` в каждой строке схемы: без него два арендатора с
321    // совпавшими долями получили бы один ключ, а разделение арендаторов должно
322    // держаться на криптографии, а не на проверке поля при чтении.
323    let okm = hkdf_sha256::<SECRET_LEN>(file_id, &ikm, &[label::KEK.as_bytes(), org_id, file_id]);
324
325    // Обе доли лежали на стеке в открытом виде. Затираем немедленно, а не
326    // полагаемся на выход из функции: у копии нет владельца, который затрёт её
327    // при уничтожении.
328    ikm.zeroize();
329    Kek::from_bytes(*okm)
330}
331
332/// K3: payload key.
333///
334/// `header_salt` is random for each packing operation, so repacking
335/// the same content with the same CEK yields a different stream key and does not
336/// produce matching ciphertexts.
337pub fn derive_payload_key(
338    cek: &Cek,
339    header_salt: &[u8; 32],
340    file_id: &[u8; 16],
341    chunk_size: u32,
342    aead: AeadAlg,
343) -> PayloadKey {
344    // `chunk_size` и `aead_id` входят в `info` потому, что оба задают способ
345    // кадрирования потока. Тот же ключ при другом размере чанка означал бы, что
346    // один и тот же поток ключей применён к другой разбивке открытого текста, а
347    // при другом AEAD — к другой схеме nonce; и то, и другое ведёт к повторному
348    // использованию ключевого потока на разных данных.
349    PayloadKey::from_bytes(*hkdf_sha256::<SECRET_LEN>(
350        header_salt,
351        cek.expose(),
352        &[
353            label::PAYLOAD.as_bytes(),
354            file_id.as_slice(),
355            &chunk_size.to_be_bytes(),
356            &[aead as u8],
357        ],
358    ))
359}
360
361/// K5: private-metadata key (the real filename and informational size).
362///
363/// A separate key lets metadata grow without affecting the payload.
364pub fn derive_private_meta_key(cek: &Cek, header_salt: &[u8; 32], file_id: &[u8; 16]) -> MetaKey {
365    MetaKey::from_bytes(*hkdf_sha256::<SECRET_LEN>(
366        header_salt,
367        cek.expose(),
368        &[label::PRIVATE_META.as_bytes(), file_id.as_slice()],
369    ))
370}
371
372/// K6: mutable-region MAC key.
373pub fn derive_content_mac_key(cek: &Cek, header_salt: &[u8; 32], file_id: &[u8; 16]) -> MacKey {
374    // Изменяемая область заверяется MAC на ключе от CEK, а не подписью автора:
375    // правка происходит без автора, подписать он может только то, что видел.
376    MacKey::from_bytes(*hkdf_sha256::<SECRET_LEN>(
377        header_salt,
378        cek.expose(),
379        &[label::CONTENT_MAC.as_bytes(), file_id.as_slice()],
380    ))
381}
382
383/// K12 key for authenticating a device's lease-cache head.
384///
385/// Derived from the device secret with empty salt and the registered cached-lease
386/// label. It is independent of any file. It protects against corruption and other
387/// users with write access to shared storage, not the owner of the device secret.
388pub fn derive_witness_key(device: &crate::secret::X25519Secret) -> MacKey {
389    MacKey::from_bytes(*hkdf_sha256::<SECRET_LEN>(
390        &[],
391        device.expose(),
392        &[label::CACHED_LEASE.as_bytes()],
393    ))
394}
395
396/// Semantic-mark variant selection (D5): `HMAC(organization key,
397/// "CC/v1/mark-choice" ‖ layout ‖ u64be(copy mark) ‖ u32be(point))`,
398/// the first eight bytes interpreted as `u64be`, modulo the variant count.
399///
400/// The key is a SEPARATE organization marking key, not a device or
401/// author key: selection must be unpredictable for the recipient and reproducible for
402/// whoever investigates a leak; this key serves no other purpose. The layout is
403/// MAC-covered: one copy mark in two documents yields independent choices.
404/// For up to sixteen variants, modulo bias is on the order of 2^-60 and
405/// requires no correction.
406///
407/// # Errors
408/// [`CryptoError::BadLength`]: zero variants.
409pub fn mark_choice(key: &MacKey, layout: &[u8; 32], token: u64, point: u32, variants: u32) -> Result<u32, CryptoError> {
410    if variants == 0 {
411        return Err(CryptoError::BadLength);
412    }
413    let mut t = crate::Transcript::new(label::MARK_CHOICE);
414    t.fixed(layout);
415    t.u64be(token);
416    t.u32be(point);
417    let tag = crate::mac::compute(key, &t)?;
418    let head: [u8; 8] = tag.get(..8).and_then(|h| h.try_into().ok()).ok_or(CryptoError::BadLength)?;
419    let value = u64::from_be_bytes(head).checked_rem(u64::from(variants)).ok_or(CryptoError::BadLength)?;
420    u32::try_from(value).map_err(|_| CryptoError::BadLength)
421}
422
423/// K14 claim secret from canonical code text: uppercase, without separators.
424///
425/// Hashing the text avoids a second bit-level encoding. The interface performs
426/// canonicalization and checks generated entropy against [`crate::MIN_CLAIM_BITS`];
427/// this function cannot infer entropy from an already chosen string.
428pub fn claim_secret_from_code(canonical: &[u8]) -> ClaimSecret {
429    let mut hasher = blake3::Hasher::new();
430    hasher.update(label::CLAIM_CODE.as_bytes());
431    hasher.update(&[0x00]);
432    hasher.update(canonical);
433    ClaimSecret::from_bytes(*hasher.finalize().as_bytes())
434}
435
436/// Heir-device X25519 secret derived from a claim code.
437///
438/// The bequest seals the existing share B to this derived device key; it does not
439/// derive a replacement share. `file_id` is the salt, separating reuse of the same
440/// code across files. X25519 performs scalar clamping when the key is used.
441#[must_use]
442pub fn device_secret_from_claim(file_id: &[u8; 16], claim: &ClaimSecret) -> [u8; 32] {
443    *hkdf_sha256::<SECRET_LEN>(file_id, claim.expose(), &[label::CLAIM_DEVICE.as_bytes()])
444}
445
446pub fn secret_b_from_claim(file_id: &[u8; 16], claim: &ClaimSecret) -> (SecretB, [u8; 32]) {
447    // Две разные метки над одним ikm дают независимые выходы, поэтому
448    // обязательство не выдаёт ничего о доле. Обязательство вида `H(secret_B)`
449    // связало бы их: кто угадал одно, получил бы проверку и для другого.
450    let share = hkdf_sha256::<SECRET_LEN>(file_id, claim.expose(), &[label::SLOT_B_CLAIM.as_bytes()]);
451    // `salt = file_id` не даёт строить общие таблицы: один и тот же код,
452    // выданный дважды, в разных файлах превращается в разные доли.
453    let commitment = hkdf_sha256::<SECRET_LEN>(file_id, claim.expose(), &[label::SLOT_B_COMMIT.as_bytes()]);
454    // Обязательство секретом не является — оно и так уходит в контейнер, — а
455    // доля является, и её транзитная копия исчезает вместе с обёрткой.
456    (SecretB::from_bytes(*share), *commitment)
457}
458
459/// K9 slot commitment: `HMAC-SHA256(KEK, "CC/v1/slot-commit"‖core_hash)`.
460///
461/// Checked in constant time before AEAD opening: XChaCha20-Poly1305 alone is not
462/// key-committing and could expose a claim-code partitioning oracle. `core_hash`
463/// binds the header while excluding the slot and wrapped-key records, avoiding
464/// a circular dependency.
465pub fn slot_commitment(kek: &Kek, core_hash: &[u8; 32]) -> [u8; 32] {
466    hmac_sha256(kek.expose(), &[label::SLOT_COMMIT.as_bytes(), core_hash.as_slice()])
467}
468
469/// Constant-time commitment comparison.
470pub fn verify_commitment(expected: &[u8; 32], actual: &[u8; 32]) -> Result<(), CryptoError> {
471    // Только `ct_eq`. Сравнение `==` выходит на первом различающемся байте, и по
472    // времени ответа обязательство подбирается побайтово — за 32×256 попыток
473    // вместо 2¹²⁸.
474    if bool::from(expected.ct_eq(actual)) {
475        Ok(())
476    } else {
477        // Тот же вариант ошибки, что и у неудачного тега AEAD: различить, что
478        // именно не сошлось, вызывающий не должен.
479        Err(CryptoError::Authentication)
480    }
481}
482
483#[cfg(test)]
484#[allow(clippy::unwrap_used, clippy::panic)]
485mod tests {
486    use super::*;
487
488    const FILE_ONE: [u8; 16] = [0x11; 16];
489    const FILE_TWO: [u8; 16] = [0x12; 16];
490    const SALT_ONE: [u8; 32] = [0x21; 32];
491    const SALT_TWO: [u8; 32] = [0x22; 32];
492    const ORG_ONE: &[u8] = b"acme";
493    const ORG_TWO: &[u8] = b"acme-eu";
494
495    fn shares() -> (SecretA, SecretB) {
496        (SecretA::from_bytes([0xa1; 32]), SecretB::from_bytes([0xb2; 32]))
497    }
498
499    fn cek() -> Cek {
500        Cek::from_bytes([0xc3; 32])
501    }
502
503    #[test]
504    fn the_kek_follows_rfc5869_with_the_file_id_as_salt_and_the_shares_as_ikm() {
505        // Перепутать местами `salt` и `ikm` — классическая ошибка применения
506        // HKDF: она ничем не проявляется, кроме несовместимости с чужой
507        // реализацией того же формата, а обнаружится уже после выпуска файлов.
508        // Поэтому K1 пересчитывается вручную по RFC 5869: Extract с солью в роли
509        // ключа HMAC, затем один блок Expand с суффиксом 0x01.
510        let (a, b) = shares();
511        let mut ikm = [0u8; KEK_IKM_LEN];
512        let (first, second) = ikm.split_at_mut(SECRET_LEN);
513        first.copy_from_slice(a.expose());
514        second.copy_from_slice(b.expose());
515
516        let prk = hmac_sha256(&FILE_ONE, &[&ikm]);
517        let expected = hmac_sha256(&prk, &[label::KEK.as_bytes(), ORG_ONE, &FILE_ONE, &[0x01]]);
518
519        assert_eq!(derive_kek(&FILE_ONE, ORG_ONE, &a, &b).expose(), &expected);
520    }
521
522
523    #[test]
524    fn the_same_shares_in_two_files_never_yield_the_same_kek() {
525        // Иначе одна вскрытая пара долей открывала бы все файлы арендатора, а не
526        // один: `file_id` — единственное, что делает ключи файла его личными.
527        let (a, b) = shares();
528        let one = derive_kek(&FILE_ONE, ORG_ONE, &a, &b);
529        let two = derive_kek(&FILE_TWO, ORG_ONE, &a, &b);
530        assert_ne!(one.expose(), two.expose());
531    }
532
533    #[test]
534    fn two_tenants_never_share_a_kek() {
535        // Разделение арендаторов обязано держаться на криптографии: проверка
536        // поля `org_id` при чтении — это решение, принимаемое клиентом, которого
537        // противник контролирует.
538        let (a, b) = shares();
539        let one = derive_kek(&FILE_ONE, ORG_ONE, &a, &b);
540        let two = derive_kek(&FILE_ONE, ORG_TWO, &a, &b);
541        assert_ne!(one.expose(), two.expose());
542    }
543
544    #[test]
545    fn swapping_the_two_shares_yields_a_different_kek() {
546        // Проверяет порядок конкатенации: A‖B и B‖A — разные ikm. Если бы
547        // порядок «поплыл» при рефакторинге, все ранее выпущенные файлы
548        // перестали бы открываться, и заметить это надо на сборке, а не в поле.
549        let straight = derive_kek(
550            &FILE_ONE,
551            ORG_ONE,
552            &SecretA::from_bytes([0xa1; 32]),
553            &SecretB::from_bytes([0xb2; 32]),
554        );
555        let swapped = derive_kek(
556            &FILE_ONE,
557            ORG_ONE,
558            &SecretA::from_bytes([0xb2; 32]),
559            &SecretB::from_bytes([0xa1; 32]),
560        );
561        assert_ne!(straight.expose(), swapped.expose());
562    }
563
564    #[test]
565    fn an_org_id_boundary_cannot_be_shifted_into_the_file_id() {
566        // `info` — конкатенация без разделителей, поэтому пара (org_id, file_id)
567        // обязана оставаться однозначной. `file_id` фиксирован типом в 16 байт,
568        // и сдвинуть границу можно только вместе с изменением `org_id`, что и
569        // проверяется: два разных арендатора не сходятся к одному ключу.
570        let (a, b) = shares();
571        let long = derive_kek(&FILE_ONE, b"acme\x11\x11", &a, &b);
572        let short = derive_kek(&FILE_ONE, b"acme", &a, &b);
573        assert_ne!(long.expose(), short.expose());
574    }
575
576    #[test]
577    fn a_repack_with_a_new_header_salt_changes_the_payload_key() {
578        // Повторная упаковка того же содержимого тем же CEK не должна давать
579        // совпадающих шифротекстов: иначе видно, что два контейнера содержат
580        // одно и то же, без единого ключа.
581        let key_one =
582            derive_payload_key(&cek(), &SALT_ONE, &FILE_ONE, 65536, AeadAlg::XChaCha20Poly1305);
583        let key_two =
584            derive_payload_key(&cek(), &SALT_TWO, &FILE_ONE, 65536, AeadAlg::XChaCha20Poly1305);
585        assert_ne!(key_one.expose(), key_two.expose());
586    }
587
588    #[test]
589    fn the_payload_key_changes_with_the_chunk_size() {
590        // Размер чанка задаёт разбивку открытого текста. Тот же ключ при другой
591        // разбивке — это тот же ключевой поток на других данных.
592        let small =
593            derive_payload_key(&cek(), &SALT_ONE, &FILE_ONE, 16384, AeadAlg::XChaCha20Poly1305);
594        let large =
595            derive_payload_key(&cek(), &SALT_ONE, &FILE_ONE, 65536, AeadAlg::XChaCha20Poly1305);
596        assert_ne!(small.expose(), large.expose());
597    }
598
599    #[test]
600    fn the_payload_key_changes_with_the_aead_id() {
601        // Смена профиля меняет схему nonce: у XChaCha он случайный и хранится, у
602        // AES-GCM — счётчиковый. Общий ключ между профилями означал бы
603        // повторное использование пары (ключ, nonce) в двух разных схемах.
604        let xchacha =
605            derive_payload_key(&cek(), &SALT_ONE, &FILE_ONE, 65536, AeadAlg::XChaCha20Poly1305);
606        let gcm = derive_payload_key(&cek(), &SALT_ONE, &FILE_ONE, 65536, AeadAlg::Aes256Gcm);
607        let siv = derive_payload_key(&cek(), &SALT_ONE, &FILE_ONE, 65536, AeadAlg::Aes256GcmSiv);
608        assert_ne!(xchacha.expose(), gcm.expose());
609        assert_ne!(gcm.expose(), siv.expose());
610        assert_ne!(xchacha.expose(), siv.expose());
611    }
612
613    #[test]
614    fn the_same_cek_in_two_files_never_yields_the_same_payload_key() {
615        let one =
616            derive_payload_key(&cek(), &SALT_ONE, &FILE_ONE, 65536, AeadAlg::XChaCha20Poly1305);
617        let two =
618            derive_payload_key(&cek(), &SALT_ONE, &FILE_TWO, 65536, AeadAlg::XChaCha20Poly1305);
619        assert_ne!(one.expose(), two.expose());
620    }
621
622    #[test]
623    fn private_metadata_and_content_mac_never_share_a_key_with_the_payload() {
624        // Три ключа от одного CEK и одной соли. Совпадение любых двух означало
625        // бы, что тег изменяемой области можно подделать ключом полезной
626        // нагрузки или наоборот.
627        let payload =
628            derive_payload_key(&cek(), &SALT_ONE, &FILE_ONE, 65536, AeadAlg::XChaCha20Poly1305);
629        let meta = derive_private_meta_key(&cek(), &SALT_ONE, &FILE_ONE);
630        let mac = derive_content_mac_key(&cek(), &SALT_ONE, &FILE_ONE);
631        assert_ne!(payload.expose(), meta.expose());
632        assert_ne!(meta.expose(), mac.expose());
633        assert_ne!(payload.expose(), mac.expose());
634    }
635
636    #[test]
637    fn the_same_claim_code_in_two_files_yields_two_different_shares() {
638        // Код-претензия может быть выдан повторно (тот же генератор, та же
639        // длина). Соль `file_id` не даёт одному коду открыть два файла.
640        let claim = ClaimSecret::from_bytes([0x7c; 32]);
641        let (share_one, commit_one) = secret_b_from_claim(&FILE_ONE, &claim);
642        let (share_two, commit_two) = secret_b_from_claim(&FILE_TWO, &claim);
643        assert_ne!(share_one.expose(), share_two.expose());
644        assert_ne!(commit_one, commit_two);
645    }
646
647    #[test]
648    fn a_claim_commitment_never_equals_the_share_it_commits_to() {
649        // Обязательство лежит в контейнере открытым текстом. Совпади оно с
650        // долей — контейнер раздавал бы secret_B каждому, кто его прочитал.
651        let claim = ClaimSecret::from_bytes([0x7c; 32]);
652        let (share, commitment) = secret_b_from_claim(&FILE_ONE, &claim);
653        assert_ne!(share.expose(), &commitment);
654    }
655
656
657    #[test]
658    fn a_single_flipped_bit_in_a_commitment_is_refused() {
659        let expected = [0x9a; 32];
660        let mut actual = expected;
661        if let Some(byte) = actual.get_mut(31) {
662            *byte ^= 0x01;
663        }
664        assert_eq!(verify_commitment(&expected, &expected), Ok(()));
665        assert_eq!(
666            verify_commitment(&expected, &actual),
667            Err(CryptoError::Authentication)
668        );
669    }
670
671    #[test]
672    fn every_derivation_of_the_schedule_is_domain_separated() {
673        // Все производные получают максимально одинаковый вход: одни и те же 32
674        // байта в роли долей, CEK, кода-претензии, KEK и соли. При таком входе
675        // единственное, что разводит выходы, — метки домена. Совпадение любых
676        // двух означало бы, что ключ одного назначения принимается вместо
677        // другого: тег изменяемой области подделывается ключом метаданных,
678        // обязательство слота подставляется как обязательство кода.
679        let material = [0x55u8; 32];
680        let file_id = [0x55u8; 16];
681        let salt = material;
682
683        let a = SecretA::from_bytes(material);
684        let b = SecretB::from_bytes(material);
685        let cek = Cek::from_bytes(material);
686        let claim = ClaimSecret::from_bytes(material);
687        let kek = Kek::from_bytes(material);
688
689        let (share, claim_commitment) = secret_b_from_claim(&file_id, &claim);
690        let derived: Vec<(&str, Vec<u8>)> = vec![
691            ("K1 kek", derive_kek(&file_id, &material, &a, &b).expose().to_vec()),
692            (
693                "K3 payload",
694                derive_payload_key(&cek, &salt, &file_id, 65536, AeadAlg::XChaCha20Poly1305)
695                    .expose()
696                    .to_vec(),
697            ),
698            (
699                "K5 private-meta",
700                derive_private_meta_key(&cek, &salt, &file_id).expose().to_vec(),
701            ),
702            (
703                "K6 content-mac",
704                derive_content_mac_key(&cek, &salt, &file_id).expose().to_vec(),
705            ),
706            ("K7 slot-b-claim", share.expose().to_vec()),
707            ("K8 slot-b-commit", claim_commitment.to_vec()),
708            ("K9 slot-commit", slot_commitment(&kek, &salt).to_vec()),
709        ];
710
711        for (index, (left_name, left)) in derived.iter().enumerate() {
712            for (right_name, right) in derived.iter().skip(index.saturating_add(1)) {
713                // Сравниваются общие префиксы, иначе K2 длиной 24 байта прошёл бы
714                // тест «бесплатно», просто из-за другой длины.
715                let common = left.len().min(right.len());
716                let left_prefix: Vec<u8> = left.iter().copied().take(common).collect();
717                let right_prefix: Vec<u8> = right.iter().copied().take(common).collect();
718                assert_ne!(
719                    left_prefix, right_prefix,
720                    "{left_name} и {right_name} совпали: разделение доменов не работает"
721                );
722            }
723        }
724
725        // Заодно ловит вырождение буфера в нули: ни одна производная не должна
726        // быть пустым ключом, даже если бы `expand` когда-нибудь отказал.
727        for (name, value) in &derived {
728            assert!(value.iter().any(|byte| *byte != 0), "{name} выродился в нули");
729        }
730    }
731}
732
733#[cfg(test)]
734#[allow(clippy::unwrap_used, clippy::panic, clippy::indexing_slicing)]
735mod device_fpr_tests {
736    use super::*;
737    use crate::KemAlg;
738
739    /// For X25519, the fingerprint IS the key: frozen by wire vectors.
740    #[test]
741    fn x25519_fingerprint_is_the_key_itself() {
742        let key = [0x5a; 32];
743        assert_eq!(device_fpr(KemAlg::X25519HkdfSha256, &key).unwrap(), key);
744    }
745
746    /// The mechanism number enters the preimage: identical bytes under another number mean
747    /// a different device name. Otherwise relabeling the mechanism would preserve the name.
748    #[test]
749    fn the_mechanism_number_is_part_of_the_name() {
750        let xw = crate::xwing::public_key(&[0x4d; 32]).unwrap();
751        let a = device_fpr(KemAlg::XWing, &xw).unwrap();
752        // Тот же префикс байтов, но заявленный как P-256 — длина не сходится.
753        assert!(device_fpr(KemAlg::P256HkdfSha256, &xw[..65]).is_ok());
754        assert_ne!(a, device_fpr(KemAlg::P256HkdfSha256, &xw[..65]).unwrap());
755        assert_ne!(&a[..], &xw[..32], "хеш не должен совпадать с началом ключа");
756    }
757
758    /// Length is checked against the mechanism (I-8), not taken "as received".
759    #[test]
760    fn a_key_of_the_wrong_length_is_refused_not_hashed() {
761        assert_eq!(device_fpr(KemAlg::X25519HkdfSha256, &[0; 31]), Err(CryptoError::BadLength));
762        assert_eq!(device_fpr(KemAlg::P256HkdfSha256, &[4; 64]), Err(CryptoError::BadLength));
763        assert_eq!(device_fpr(KemAlg::XWing, &[0; 1215]), Err(CryptoError::BadLength));
764        assert_eq!(device_fpr(KemAlg::MlKem768P256, &[0; 1250]), Err(CryptoError::BadLength));
765        assert_eq!(device_fpr(KemAlg::RsaOaepSha256, &[0; 256]), Err(CryptoError::UnsupportedAlgorithm));
766    }
767
768    /// The length table has a gap exactly where the build cannot execute a mechanism.
769    ///
770    /// The two tables answer DIFFERENT questions, "what shape is the key" and "can
771    /// we execute this mechanism"; nothing guarantees their agreement except
772    /// today's coincidence. Since `device_fpr` needs that agreement (otherwise the
773    /// "no length" branch would have reachable meaning), it must be tested rather than
774    /// assumed. Enumerate ALL members, not a list recalled from memory: a new
775    /// mechanism will be included automatically.
776    #[test]
777    fn the_length_table_has_a_hole_exactly_where_the_build_has_no_mechanism() {
778        // Члены берутся ИЗ РАЗБОРА, а не списком: список по памяти устареет в
779        // день, когда реестр пополнят, и проба промолчит именно о новом номере.
780        let all: Vec<KemAlg> = (0u8..=255).filter_map(|v| KemAlg::from_u8(v).ok()).collect();
781        assert_eq!(all.len(), 5, "реестр механизмов изменился — проверьте таблицы");
782        for kem in all {
783            assert_eq!(
784                public_key_len(kem).is_some(),
785                kem.ensure_supported().is_ok(),
786                "таблица длин разошлась с исполнимостью на {kem:?}"
787            );
788        }
789    }
790
791    /// A hashed fingerprint does not start with the label: the label is in the preimage, not the
792    /// output. This guards against "fingerprint = label ‖ key" during refactoring.
793    #[test]
794    fn the_label_is_in_the_preimage_not_in_the_output() {
795        let p = [0x04; 65];
796        let f = device_fpr(KemAlg::P256HkdfSha256, &p).unwrap();
797        assert!(!f.starts_with(b"CC/v1"));
798    }
799}