Skip to main content

oc_crypto/
kdf.rs

1//! Key schedule: derivations K1…K9 from `docs/format.md`, section 3.5.
2//!
3//! Each function corresponds to exactly one table row. No label is
4//! used twice, and each derivation includes `file_id` to prevent keys from
5//! matching across files even when the initial secrets match.
6
7use crate::secret::{
8    Cek, ClaimSecret, Kek, MacKey, MetaKey, PayloadKey, SecretA, SecretB, SessionMacKey, SECRET_LEN,
9};
10use crate::{label, AeadAlg, CryptoError};
11
12use hkdf::Hkdf;
13use hmac::{Hmac, KeyInit, Mac};
14use sha2::Sha256;
15use subtle::ConstantTimeEq;
16use zeroize::{Zeroize, Zeroizing};
17
18/// K1 ikm length: exactly two 32-byte shares.
19///
20/// Hardcoded as a constant rather than derived from argument lengths because
21/// fixed length is a condition of the combiner's correctness, not an
22/// implementation detail.
23const KEK_IKM_LEN: usize = 64;
24
25/// Expand HKDF-SHA256 into a buffer whose length is specified by its type.
26///
27/// `expand` fails in only one case: requesting more than 255×32
28/// bytes; here the array type specifies output length, at most 32 bytes,
29/// so the error branch is unreachable. K1…K8 therefore return a key rather than `Result`:
30/// threading an impossible variant through every key-schedule call would
31/// train callers to use `?` where nothing can fail.
32/// Returns a wiping wrapper rather than a bare array.
33///
34/// This function outputs key material. A bare `[u8; N]` remains on the stack until
35/// the calling function ends and can enter the pagefile with it, without an owner
36/// to wipe it on destruction. The wrapper makes wiping
37/// a property of the type: previously every caller had to remember it individually,
38/// and not all did.
39fn hkdf_sha256<const N: usize>(salt: &[u8], ikm: &[u8], info: &[&[u8]]) -> Zeroizing<[u8; N]> {
40    let hk = Hkdf::<Sha256>::new(Some(salt), ikm);
41    let mut okm = Zeroizing::new([0u8; N]);
42    // Результат игнорируется осознанно: альтернатива — паника, запрещённая в
43    // этом крейте. Вырождение буфера в нули поймал бы тест
44    // `every_derivation_of_the_schedule_is_domain_separated`, который сравнивает
45    // все производные схемы между собой.
46    let _ = hk.expand_multi_info(info, okm.as_mut_slice());
47    okm
48}
49
50/// A nonce not wholly dependent on RNG state (hedged nonce).
51///
52/// **Why.** A random nonce is safe only while the RNG does not
53/// repeat, and nothing guarantees that: virtual-machine snapshot rollback,
54/// disk-image cloning, and backup restoration return
55/// the RNG to a previous state. A separate random value does not
56/// help: it comes from the same RNG and repeats with it,
57/// repeating the entire keystream. For slot sealing the cost is
58/// maximal: plaintexts are shares of a 2-of-2 secret-sharing scheme, and repetition gives
59/// `ct₁ ⊕ ct₂ = pt₁ ⊕ pt₂`, hence both shares, the KEK, and the content key without a single
60/// private key.
61///
62/// **How.** Since version 3, `HKDF-Extract(salt = RNG seed,
63/// ikm = u32be(len(pt)) ‖ pt ‖ u32be(len(aad)) ‖ aad)`, then `Expand(info = label)`.
64/// AAD is exactly what AEAD receives: with a repeated RNG, identical plaintext
65/// in a different context gets a different nonce. This is hedging, not SIV:
66/// the key is not part of the seed; fully repeated inputs still repeat the nonce.
67/// Both lengths are mandatory because the parts have variable widths (the K1 argument).
68/// Lengths unrepresentable as u32 and excessive output lengths return BadLength.
69///
70/// **This does not contradict "nonces are stored, not derived".** The rule
71/// exists to prevent a nonce being a function of values available to the reader:
72/// derived from the shared secret, it would match whenever that secret matched.
73/// Here, conversely, the nonce derives from what the reader lacks (seed,
74/// plaintext) and **is stored in the file**; the reader still takes it
75/// ready-made and never computes it.
76///
77/// The seed is never stored anywhere: it is needed only during computation.
78///
79/// The label has type [`label::Label`]: nonce hedging is also a domain, and
80/// its four labels (K17–K20) are registered alongside the others. Passing a
81/// fifth label invented on the spot would derive a nonce in a domain
82/// unknown to §3.6.
83pub fn hedged_nonce<const N: usize>(
84    label: label::Label,
85    seed: &[u8],
86    plaintext: &[u8],
87    aad: &[u8],
88) -> Result<[u8; N], CryptoError> {
89    let pt_len = u32::try_from(plaintext.len()).map_err(|_| CryptoError::BadLength)?;
90    let aad_len = u32::try_from(aad.len()).map_err(|_| CryptoError::BadLength)?;
91    // Потоковый Extract не создаёт вторую копию открытого текста в куче (И-11).
92    let mut extract = hkdf::HkdfExtract::<Sha256>::new(Some(seed));
93    extract.input_ikm(&pt_len.to_be_bytes());
94    extract.input_ikm(plaintext);
95    extract.input_ikm(&aad_len.to_be_bytes());
96    extract.input_ikm(aad);
97    let (_, hk) = extract.finalize();
98    let mut out = [0u8; N];
99    hk.expand(label.as_bytes(), &mut out).map_err(|_| CryptoError::BadLength)?;
100    Ok(out)
101}
102/// `HMAC-SHA256` over a sequence of message pieces.
103///
104/// Pieces are fed sequentially rather than concatenated into a buffer: concatenation would
105/// allocate memory for a message needed nowhere else.
106fn hmac_sha256(key: &[u8], message: &[&[u8]]) -> [u8; 32] {
107    let mut tag = [0u8; 32];
108    // `new_from_slice` у HMAC принимает ключ любой длины (длинный хешируется,
109    // короткий дополняется нулями), поэтому ошибка недостижима.
110    if let Ok(mut mac) = <Hmac<Sha256> as KeyInit>::new_from_slice(key) {
111        for part in message {
112            mac.update(part);
113        }
114        // Выход HMAC-SHA256 всегда 32 байта, длины совпадают по построению.
115        tag.copy_from_slice(mac.finalize().into_bytes().as_slice());
116    }
117    tag
118}
119
120/// K23: proof of opening the challenge without revealing the secret itself.
121/// The challenge remains secret between two parties: the public echo cannot derive K24.
122/// All 64 bytes of the hardware challenge enter the HMAC key; output is always 32 bytes.
123///
124/// **NOT USED by the protocol since 2026-09-21.** [`echo_transcript`] computes the echo
125/// (K31): the old form bound nothing but the
126/// secret to the conversation, so a recorded echo worked in any conversation repeating that secret.
127/// For the decision and its limits, see `docs/format.md`, section "ECHO BOUND TO THE CONVERSATION
128/// 2026-09-21".
129///
130/// Retained for the frozen `k23_prove_echo` vector
131/// (`tests/kat/derivations_wire.kat`): I-14 forbids changing frozen artifacts, and
132/// removing the derivation would leave the vector without the operation it checks.
133/// It must acquire no new callers; the guard
134/// `the_old_unbound_echo_has_no_callers_outside_its_vector` enforces that.
135pub fn prove_echo(challenge: &[u8], device_fpr: &[u8; 32]) -> [u8; 32] {
136    hmac_sha256(challenge, &[label::PROVE_ECHO.as_bytes(), device_fpr])
137}
138
139/// K31, step 1: handshake transcript hash (`docs/protocol.md` §9.4).
140///
141/// `SHA-256("CC/v1/echo-transcript" ‖ 0x00 ‖ u32le(len(hello)) ‖ hello ‖
142/// u32le(len(challenge)) ‖ challenge)`.
143///
144/// # What is included and why
145///
146/// Exactly two frames, both available in full to BOTH parties by echo time:
147/// the device hello (`kind ‖ TLV`: presented keys, `device_fpr`,
148/// mechanism number) and server challenge (`kind ‖ TLV`: sealed halves with
149/// their `enc`, nonces, and ciphertexts). Neither connection numbers nor clock
150/// readings are included deliberately: only the server knows the number, the parties have different clocks,
151/// and a value known to only one party cannot be a transcript.
152///
153/// # Why RAW bytes rather than reconstructed values
154///
155/// The same argument as I-5: reconstructing parsed data would yield one byte sequence for
156/// a canonical document and another for one whose canonicality is checked
157/// by nothing except our own encoder. An intermediary modifying a frame in transit
158/// must diverge from the honest party, which happens only when the hash
159/// covers what actually traveled over the wire.
160///
161/// # Why lengths are included
162///
163/// [`Transcript::field`](crate::transcript::Transcript::field) prefixes
164/// each piece with its length: otherwise ("ab", "c") and ("a", "bc") would give
165/// one hash, allowing an attacker to move bytes between hello and challenge
166/// without changing the echo.
167#[must_use]
168pub fn handshake_transcript(hello_framed: &[u8], challenge_framed: &[u8]) -> [u8; 32] {
169    use sha2::Digest as _;
170    let mut t = crate::transcript::Transcript::new(label::ECHO_TRANSCRIPT);
171    t.field(hello_framed).field(challenge_framed);
172    Sha256::digest(t.as_bytes()).into()
173}
174
175/// K31, step 2: proof-of-possession echo bound to the conversation.
176///
177/// `HMAC-SHA256(key = challenge secret, "CC/v1/echo-transcript" ‖ device_fpr ‖
178/// handshake)`, where `handshake` is the output of [`handshake_transcript`].
179///
180/// # What this fixes
181///
182/// K23 included only the fingerprint in the message. An echo recorded from the wire worked in
183/// ANY conversation with the same device whenever the secret repeated, and the server
184/// secret repeats after snapshot rollback (I-1, argument C-13; K30 addressed repetition,
185/// but not the construction itself). The safety margin depended on the server currently issuing
186/// nothing on `Proven` without a session MAC, making it just one edit thick.
187/// The echo is now a function of the conversation: the server seals anew
188/// in each conversation, with its own ephemeral `Seal` pair, so challenge bytes differ.
189///
190/// # What this does NOT provide
191///
192/// It does not fix full snapshot rollback TOGETHER with the clock: then both
193/// the secret (time is in K30's preimage) and the ephemeral sealing pair repeat,
194/// repeating the entire transcript. It does not address an attacker possessing
195/// the device private key: that attacker legitimately proves possession.
196#[must_use]
197pub fn echo_transcript(
198    challenge: &[u8],
199    device_fpr: &[u8; 32],
200    handshake: &[u8; 32],
201) -> [u8; 32] {
202    hmac_sha256(challenge, &[label::ECHO_TRANSCRIPT.as_bytes(), device_fpr, handshake])
203}
204
205/// K24: a separate key authenticating requests in this conversation.
206pub fn derive_session_mac_key(challenge: &[u8], device_fpr: &[u8; 32]) -> SessionMacKey {
207    SessionMacKey::from_bytes(*hkdf_sha256::<SECRET_LEN>(
208        &[], challenge, &[label::SESSION_MAC.as_bytes(), device_fpr],
209    ))
210}
211
212/// K27: device fingerprint for ANY agreement mechanism.
213///
214/// The fingerprint is the single 32-byte value a person checks
215/// through a second channel and to which the author later seals a share. For X25519 these two
216/// roles coincide in the key itself: it is 32 bytes, and its fingerprint equals the key.
217/// This is exactly what the bequest, quorum, and approval doors relied on, and precisely
218/// why they were closed to anything longer: for P-256 (65 bytes) and
219/// hybrids (1216, 1249), the fingerprint was UNDEFINED, and an intermediary presenting
220/// someone else's fingerprint with its own key could receive the share under its key
221/// in someone else's name (review 2026-09-06, N-1 and N-4).
222///
223/// This definition closes the gap for every mechanism at once: the fingerprint becomes
224/// a COMMITMENT to the key. Whoever receives `(fpr, kem, public)` must
225/// check `fpr == device_fpr(kem, public)` BEFORE using any of
226/// the three; for X25519 this is the same comparison as before.
227///
228/// **The form is deliberately asymmetric.** For `kem_id = 1`, it returns the key itself,
229/// not its hash: K11, K21, K23, K24 vectors and the `Hello` layout freeze that behavior,
230/// and unifying the fingerprints would require reissuing them without a single
231/// new security fact. For the other mechanisms:
232/// `SHA-256("CC/v1/device-fpr" ‖ 0x00 ‖ u8(kem_id) ‖ public)`. The mechanism number
233/// is in the preimage: the same key bytes under two numbers produce different
234/// fingerprints, so relabeling a mechanism changes the device name.
235///
236/// Key length is checked against the mechanism, not taken "as received" (I-8): a fingerprint
237/// of an incorrectly sized key would do no harm, but one rule applies everywhere,
238/// and making an exception here would cost more than the check.
239///
240/// # Errors
241/// `public` length does not match the mechanism, or the mechanism defines no length
242/// (RSA-OAEP: variable-length keys, with no defined fingerprint).
243pub fn device_fpr(kem: crate::KemAlg, public: &[u8]) -> Result<[u8; 32], CryptoError> {
244    use crate::KemAlg;
245    // Исполнимость спрашивается ОДНИМ именем, а не вторым `match` рядом с
246    // таблицей длин. Раньше ответ «RSA-OAEP не умеем» стоял прямо в этой
247    // таблице, и таких ответов по репозиторию было четыре: разойтись они могли
248    // только в сторону «механизм, которого сборка не исполняет, где-то сочли
249    // исполнимым».
250    kem.ensure_supported()?;
251    let Some(expected) = public_key_len(kem) else {
252        // Недостижимо: пробел таблицы длин совпадает с неисполнимым механизмом,
253        // и совпадение это не на памяти — его держит проба
254        // `the_length_table_has_a_hole_exactly_where_the_build_has_no_mechanism`.
255        return Err(CryptoError::UnsupportedAlgorithm);
256    };
257    if public.len() != expected {
258        return Err(CryptoError::BadLength);
259    }
260    if kem == KemAlg::X25519HkdfSha256 {
261        // Отпечаток И ЕСТЬ ключ — заморожено, см. выше.
262        return <[u8; 32]>::try_from(public).map_err(|_| CryptoError::BadLength);
263    }
264    use sha2::Digest as _;
265    let mut h = Sha256::new();
266    h.update(label::DEVICE_FPR.as_bytes());
267    h.update([0x00]);
268    h.update([kem as u8]);
269    h.update(public);
270    Ok(h.finalize().into())
271}
272
273/// A mechanism's public-key length is a DIFFERENT question from executability.
274///
275/// `None` means "this mechanism defines no key shape", currently exactly
276/// unimplemented RSA-OAEP: its keys have variable length and no defined
277/// fingerprint. Agreement between the two tables, a gap here and `false` in
278/// [`crate::seal::supports_kem`], is tested rather than assumed:
279/// length is a property of shape, executability of a build; tomorrow a
280/// mechanism may have a known shape that the build cannot yet execute.
281///
282/// `match` without `_`: a new [`crate::KemAlg`] member must break the build here.
283fn public_key_len(kem: crate::KemAlg) -> Option<usize> {
284    use crate::KemAlg;
285    match kem {
286        KemAlg::X25519HkdfSha256 => Some(32),
287        KemAlg::P256HkdfSha256 => Some(65),
288        KemAlg::XWing => Some(crate::xwing::PUBLIC_KEY_LEN),
289        KemAlg::MlKem768P256 => Some(crate::mlkem_p256::PUBLIC_KEY_LEN),
290        KemAlg::RsaOaepSha256 => None,
291    }
292}
293
294/// K25: MAC of raw frame bytes including kind, but excluding the trailing MAC.
295/// No label: K24 is dedicated to K25, and the kind inside the message separates requests.
296pub fn request_mac(key: &SessionMacKey, framed: &[u8]) -> [u8; 32] {
297    hmac_sha256(key.expose(), &[framed])
298}
299
300/// K28: wire operation identity (`docs/protocol.md` §9.10).
301///
302/// `SHA-256("CC/v1/operation-id" ‖ 0x00 ‖ seed(32) ‖ u8(kind) ‖ body)`, where body is
303/// the encoded request WITHOUT the identity field itself (for an activation request, without
304/// tag 9: a value cannot include itself).
305///
306/// # Why not bare random bytes
307///
308/// By the C-13 argument (I-1): RNGs repeat after VM snapshot rollback, image
309/// cloning, and backup restoration. A bare random identifier could match for
310/// two DIFFERENT requests, giving the second request the stored outcome of the first:
311/// "issued" for someone else's request or "already used" rejection for its own. Including the body in the seed
312/// separates different requests even when the RNG repeats; only identical
313/// requests remain identical, and sharing an outcome is correct for them.
314///
315/// No secret is present or needed: the identifier travels openly on the wire,
316/// and requires uniqueness rather than unpredictability. An intermediary observing
317/// the wire already learns it; there is nothing to forge: replay with a different body
318/// is rejected by the server's body hash, and activation bodies carry a session MAC.
319///
320/// The kind enters the seed because activation and renewal have the same body:
321/// without it, one seed would give two different operations one identity.
322#[must_use]
323pub fn operation_id(seed: &[u8; 32], kind: u8, body: &[u8]) -> [u8; 32] {
324    use sha2::Digest as _;
325    let mut h = Sha256::new();
326    h.update(label::OPERATION_ID.as_bytes());
327    h.update([0x00]);
328    h.update(seed);
329    h.update([kind]);
330    h.update(body);
331    h.finalize().into()
332}
333
334/// Purpose of a fresh server value: attestation challenge (`docs/protocol.md`
335/// §9.11.1, step 1).
336pub const FRESH_ATTEST_NONCE: u8 = 1;
337
338/// Purpose of a fresh server value: proof-of-possession secret
339/// (`docs/protocol.md` §9.4).
340pub const FRESH_PROOF_SECRET: u8 = 2;
341
342/// Purpose of a fresh server value: TPM credential secret
343/// (`TPM2_MakeCredential`, `docs/protocol.md` §9.11.1, step 2).
344pub const FRESH_CREDENTIAL_SECRET: u8 = 3;
345
346/// Purpose of a fresh server value: TPM credential protection seed.
347pub const FRESH_CREDENTIAL_SEED: u8 = 4;
348
349/// Purpose of a fresh server value: OAEP seed for credential protection
350/// with an RSA EK.
351pub const FRESH_CREDENTIAL_OAEP: u8 = 5;
352
353/// K30: a fresh value generated by the SERVER (`docs/format.md`, section
354/// "SERVER FRESHNESS IS DERIVED 2026-09-20").
355///
356/// `prk = SHA-256("CC/v1/server-fresh" ‖ 0x00 ‖ seed(32) ‖ u8(kind) ‖
357/// i64be(now) ‖ device_fpr(32))`, then `HKDF-Expand(prk, info = label)` into a buffer
358/// of the required length.
359///
360/// # Why not bare random bytes
361///
362/// By the C-13 argument (I-1): RNGs repeat after VM snapshot rollback, image
363/// cloning, and backup restoration. This is worse on the server than on the client: its state
364/// rolls back together with the RNG, so an "already issued" value
365/// becomes unissued again, and nobody notices the repetition.
366///
367/// # Why TIME separates values rather than request data
368///
369/// NONE of the consumers has plaintext capable of distinguishing repeats:
370/// an attestation challenge is pure freshness, as is a proof secret, and so are the three
371/// TPM credential values (`kind` 3–5, added 2026-09-21).
372/// A seed based only on
373/// request data (the device fingerprint) would separate DIFFERENT devices but still
374/// repeat for the same device, which is precisely the danger: two conversations with one
375/// device would receive one secret, hence one echo and one session MAC key.
376/// The server has a clock, and `now` already enters the handler as a parameter.
377///
378/// The fingerprint is a second separator rather than a replacement for time: within one
379/// second, two devices must receive different values.
380///
381/// # What this does NOT provide
382///
383/// Snapshot rollback TOGETHER with the clock cannot be distinguished: `now` returns to its old value,
384/// and the value repeats. This protects against RNG repetition, not an adversary
385/// controlling the server clock.
386///
387/// # Why Expand rather than a second hash with a counter
388///
389/// The proof-of-possession secret consists of several 32-byte halves, one per
390/// presented key, and its length is known only at runtime. Appending
391/// counter-based hashes would introduce a homemade KDF beside an existing one:
392/// `HKDF-Expand` uses the same counter, but standardized and already verified.
393/// Thus `N = 32` is no exception: even the attestation challenge gets its 32 bytes
394/// through Expand, giving one form for both applications.
395///
396/// # Errors
397/// [`CryptoError::BadLength`]: more than 255×32 bytes requested.
398pub fn server_fresh(
399    seed: &[u8; 32],
400    kind: u8,
401    now: i64,
402    device_fpr: &[u8; 32],
403    out: &mut [u8],
404) -> Result<(), CryptoError> {
405    use sha2::Digest as _;
406    let mut h = Sha256::new();
407    h.update(label::SERVER_FRESH.as_bytes());
408    h.update([0x00]);
409    h.update(seed);
410    h.update([kind]);
411    h.update(now.to_be_bytes());
412    h.update(device_fpr);
413    // Секрет доказательства владения — ключевой материал, и prk его порождает.
414    // Голый массив на стеке уехал бы в файл подкачки без владельца, который его
415    // затрёт (И-11).
416    let prk = Zeroizing::new(<[u8; 32]>::from(h.finalize()));
417    let hk = Hkdf::<Sha256>::from_prk(prk.as_slice()).map_err(|_| CryptoError::BadLength)?;
418    hk.expand(label::SERVER_FRESH.as_bytes(), out).map_err(|_| CryptoError::BadLength)
419}
420
421/// K29: qualifying data for the TPM statement about a device key
422/// (`docs/protocol.md` §9.11, link 4): `extraData` in `TPMS_ATTEST`.
423///
424/// The challenge binds the statement to the conversation and the fingerprint to the device:
425/// a statement taken for another challenge or about another key will not match.
426/// No secret: the challenge travels openly on the wire; uniqueness is required.
427#[must_use]
428pub fn attest_qualify(challenge: &[u8; 32], device_fpr: &[u8; 32]) -> [u8; 32] {
429    use sha2::Digest as _;
430    let mut h = Sha256::new();
431    h.update(label::ATTEST_QUALIFY.as_bytes());
432    h.update([0x00]);
433    h.update(challenge);
434    h.update(device_fpr);
435    h.finalize().into()
436}
437
438/// K1: file wrapping key.
439///
440/// `HKDF-SHA256(salt=file_id, ikm=secret_A‖secret_B, info="CC/v1/kek"‖org_id‖file_id)`.
441///
442/// `HKDF-Extract` over concatenation is a valid combiner **only** when
443/// both shares have fixed lengths; types guarantee that here.
444pub fn derive_kek(file_id: &[u8; 16], org_id: &[u8], a: &SecretA, b: &SecretB) -> Kek {
445    // Ровно 64 байта, по 32 на долю. При переменной длине пара (A‖B) стала бы
446    // неоднозначной: другая пара с тем же склеенным представлением дала бы тот
447    // же KEK, и схема «2 из 2» перестала бы быть схемой «2 из 2».
448    let mut ikm = [0u8; KEK_IKM_LEN];
449    let (first, second) = ikm.split_at_mut(SECRET_LEN);
450    first.copy_from_slice(a.expose());
451    second.copy_from_slice(b.expose());
452
453    // `org_id` входит в `info` в каждой строке схемы: без него два арендатора с
454    // совпавшими долями получили бы один ключ, а разделение арендаторов должно
455    // держаться на криптографии, а не на проверке поля при чтении.
456    let okm = hkdf_sha256::<SECRET_LEN>(file_id, &ikm, &[label::KEK.as_bytes(), org_id, file_id]);
457
458    // Обе доли лежали на стеке в открытом виде. Затираем немедленно, а не
459    // полагаемся на выход из функции: у копии нет владельца, который затрёт её
460    // при уничтожении.
461    ikm.zeroize();
462    Kek::from_bytes(*okm)
463}
464
465/// K3: payload key.
466///
467/// `header_salt` is random for each packing operation, so repacking
468/// the same content with the same CEK yields a different stream key and does not
469/// produce matching ciphertexts.
470pub fn derive_payload_key(
471    cek: &Cek,
472    header_salt: &[u8; 32],
473    file_id: &[u8; 16],
474    chunk_size: u32,
475    aead: AeadAlg,
476) -> PayloadKey {
477    // `chunk_size` и `aead_id` входят в `info` потому, что оба задают способ
478    // кадрирования потока. Тот же ключ при другом размере чанка означал бы, что
479    // один и тот же поток ключей применён к другой разбивке открытого текста, а
480    // при другом AEAD — к другой схеме nonce; и то, и другое ведёт к повторному
481    // использованию ключевого потока на разных данных.
482    PayloadKey::from_bytes(*hkdf_sha256::<SECRET_LEN>(
483        header_salt,
484        cek.expose(),
485        &[
486            label::PAYLOAD.as_bytes(),
487            file_id.as_slice(),
488            &chunk_size.to_be_bytes(),
489            &[aead as u8],
490        ],
491    ))
492}
493
494/// K5: private-metadata key (the real filename and informational size).
495///
496/// A separate key lets metadata grow without affecting the payload.
497pub fn derive_private_meta_key(cek: &Cek, header_salt: &[u8; 32], file_id: &[u8; 16]) -> MetaKey {
498    MetaKey::from_bytes(*hkdf_sha256::<SECRET_LEN>(
499        header_salt,
500        cek.expose(),
501        &[label::PRIVATE_META.as_bytes(), file_id.as_slice()],
502    ))
503}
504
505/// K6: mutable-region MAC key.
506pub fn derive_content_mac_key(cek: &Cek, header_salt: &[u8; 32], file_id: &[u8; 16]) -> MacKey {
507    // Изменяемая область заверяется MAC на ключе от CEK, а не подписью автора:
508    // правка происходит без автора, подписать он может только то, что видел.
509    MacKey::from_bytes(*hkdf_sha256::<SECRET_LEN>(
510        header_salt,
511        cek.expose(),
512        &[label::CONTENT_MAC.as_bytes(), file_id.as_slice()],
513    ))
514}
515
516/// K12: access-state witness key.
517///
518/// Authenticates the lease-cache head stored in two places: inside and outside the profile. The label
519/// `"CC/v1/cached-lease"` was reserved in the §3.6 registry in advance and until now
520/// existed only as a constant; here it gains a consumer.
521///
522/// Derived from the DEVICE secret rather than a file key: there is one witness per
523/// machine, unrelated to any container. Otherwise a head would be needed
524/// for every file, defeating the meaning of "one number outside the profile".
525///
526/// The salt is deliberately empty. HKDF salt is optional, and none is available here:
527/// there is no file identifier or header salt, only a device. The label
528/// in `info` sets the domain, which suffices: separation comes from it, not
529/// from salt.
530///
531/// What this key does NOT provide: protection against the machine's owner. The device secret is in
532/// their profile, so they can derive the key too. Authentication here addresses a fellow machine user
533/// with write access to a shared directory, and accidental corruption, not
534/// whoever owns the profile.
535pub fn derive_witness_key(device: &crate::secret::X25519Secret) -> MacKey {
536    MacKey::from_bytes(*hkdf_sha256::<SECRET_LEN>(
537        &[],
538        device.expose(),
539        &[label::CACHED_LEASE.as_bytes()],
540    ))
541}
542
543/// Semantic-mark variant selection (D5): `HMAC(organization key,
544/// "CC/v1/mark-choice" ‖ layout ‖ u64be(copy mark) ‖ u32be(point))`,
545/// the first eight bytes interpreted as `u64be`, modulo the variant count.
546///
547/// The key is a SEPARATE organization marking key, not a device or
548/// author key: selection must be unpredictable for the recipient and reproducible for
549/// whoever investigates a leak; this key serves no other purpose. The layout is
550/// MAC-covered: one copy mark in two documents yields independent choices.
551/// For up to sixteen variants, modulo bias is on the order of 2^-60 and
552/// requires no correction.
553///
554/// # Errors
555/// [`CryptoError::BadLength`]: zero variants.
556pub fn mark_choice(key: &MacKey, layout: &[u8; 32], token: u64, point: u32, variants: u32) -> Result<u32, CryptoError> {
557    if variants == 0 {
558        return Err(CryptoError::BadLength);
559    }
560    let mut t = crate::Transcript::new(label::MARK_CHOICE);
561    t.fixed(layout);
562    t.u64be(token);
563    t.u32be(point);
564    let tag = crate::mac::compute(key, &t)?;
565    let head: [u8; 8] = tag.get(..8).and_then(|h| h.try_into().ok()).ok_or(CryptoError::BadLength)?;
566    let value = u64::from_be_bytes(head).checked_rem(u64::from(variants)).ok_or(CryptoError::BadLength)?;
567    u32::try_from(value).map_err(|_| CryptoError::BadLength)
568}
569
570/// K14: claim-code secret derived from its canonical text.
571///
572/// Input is the code's **canonical characters**: no separators, uppercase
573/// (§3.4). Canonicalization belongs to the interface rather than the key schedule:
574/// alphabet, grouping, and whitespace tolerance determine how easily a code can
575/// be dictated, without affecting any key byte. Transforming text into a
576/// secret, however, affects everything and therefore lives here with the rest of the schedule and its
577/// domain label.
578///
579/// Hashes the text rather than bits assembled from the characters. The distinction matters:
580/// assembling bits is a second encoding of the same thing, and even a bit-order discrepancy
581/// would make the code printed by the author differ from the code
582/// entered by the recipient. Text is unambiguous by construction.
583///
584/// BLAKE3 rather than HKDF: there is neither salt nor a division into extract and
585/// expand; there is exactly one compression of variable-length input into 32 bytes. The separator
586/// `0x00` follows the label because text length is not predetermined;
587/// without it, `label‖code` would parse ambiguously.
588///
589/// Input entropy is **not checked here and cannot be**: the code is already
590/// text by this point, and its original randomness is not visible. The
591/// [`crate::MIN_CLAIM_BITS`] boundary is checked where the code is generated.
592pub fn claim_secret_from_code(canonical: &[u8]) -> ClaimSecret {
593    let mut hasher = blake3::Hasher::new();
594    hasher.update(label::CLAIM_CODE.as_bytes());
595    hasher.update(&[0x00]);
596    hasher.update(canonical);
597    ClaimSecret::from_bytes(*hasher.finalize().as_bytes())
598}
599
600/// K7 and K8: recipient share derived from the claim code, and the code's commitment.
601///
602/// Returns `(secret_B, commitment)`. Only the commitment enters the
603/// container; the code itself is delivered through a second channel.
604/// Heir-device X25519 private key derived from the code.
605///
606/// # Why a keypair rather than a share
607///
608/// The heir's share is FIXED: this file's share B, which cannot be derived from
609/// an arbitrary code. The code therefore derives a pair, and the bequest
610/// is sealed to its public key with ordinary `seal`: to the server and wire,
611/// such an heir is indistinguishable from a device, and neither side
612/// knows anything about the code.
613///
614/// `salt = file_id`, just as for the code-derived share, for the same reason: the same
615/// code issued twice produces different pairs for different files; shared tables cannot
616/// be constructed.
617///
618/// X25519 scalar multiplication "accepts" any 32 bytes: `from_bytes` performs
619/// clamping itself, so no special validation of KDF output is required.
620#[must_use]
621pub fn device_secret_from_claim(file_id: &[u8; 16], claim: &ClaimSecret) -> [u8; 32] {
622    *hkdf_sha256::<SECRET_LEN>(file_id, claim.expose(), &[label::CLAIM_DEVICE.as_bytes()])
623}
624
625pub fn secret_b_from_claim(file_id: &[u8; 16], claim: &ClaimSecret) -> (SecretB, [u8; 32]) {
626    // Две разные метки над одним ikm дают независимые выходы, поэтому
627    // обязательство не выдаёт ничего о доле. Обязательство вида `H(secret_B)`
628    // связало бы их: кто угадал одно, получил бы проверку и для другого.
629    let share = hkdf_sha256::<SECRET_LEN>(file_id, claim.expose(), &[label::SLOT_B_CLAIM.as_bytes()]);
630    // `salt = file_id` не даёт строить общие таблицы: один и тот же код,
631    // выданный дважды, в разных файлах превращается в разные доли.
632    let commitment = hkdf_sha256::<SECRET_LEN>(file_id, claim.expose(), &[label::SLOT_B_COMMIT.as_bytes()]);
633    // Обязательство секретом не является — оно и так уходит в контейнер, — а
634    // доля является, и её транзитная копия исчезает вместе с обёрткой.
635    (SecretB::from_bytes(*share), *commitment)
636}
637
638/// K9: slot commitment, `HMAC-SHA256(KEK, "CC/v1/slot-commit"‖core_hash)`.
639///
640/// Checked in constant time **before** opening AEAD. Without it, the claim code
641/// becomes a partitioning oracle: XChaCha20-Poly1305 is not
642/// key-committing, and an attacker constructs a wrapper that opens under many
643/// candidate KEKs.
644///
645/// Bound to `core_hash`, not `file_id`, for two reasons. First:
646/// the binding is strictly stronger: the header-core hash already includes `file_id` and changes
647/// when any other author-signed field is substituted. Second: `core_hash`
648/// is available where the CEK wrapper is computed, whereas `file_id` is not
649/// passed in its signature.
650///
651/// No circular dependency: `core_hash` covers the header **without** the key-slot
652/// record, precisely why that record is excluded.
653///
654/// There is exactly one function. Previously there were two, bound to `file_id` and
655/// `core_hash`, violating "no domain label is used
656/// twice": one label served two distinct bindings, meaning a tag from one
657/// context could be presented in the other.
658pub fn slot_commitment(kek: &Kek, core_hash: &[u8; 32]) -> [u8; 32] {
659    hmac_sha256(kek.expose(), &[label::SLOT_COMMIT.as_bytes(), core_hash.as_slice()])
660}
661
662/// Constant-time commitment comparison.
663pub fn verify_commitment(expected: &[u8; 32], actual: &[u8; 32]) -> Result<(), CryptoError> {
664    // Только `ct_eq`. Сравнение `==` выходит на первом различающемся байте, и по
665    // времени ответа обязательство подбирается побайтово — за 32×256 попыток
666    // вместо 2¹²⁸.
667    if bool::from(expected.ct_eq(actual)) {
668        Ok(())
669    } else {
670        // Тот же вариант ошибки, что и у неудачного тега AEAD: различить, что
671        // именно не сошлось, вызывающий не должен.
672        Err(CryptoError::Authentication)
673    }
674}
675
676#[cfg(test)]
677#[allow(clippy::unwrap_used, clippy::panic)]
678mod tests {
679    use super::*;
680
681    const FILE_ONE: [u8; 16] = [0x11; 16];
682    const FILE_TWO: [u8; 16] = [0x12; 16];
683    const SALT_ONE: [u8; 32] = [0x21; 32];
684    const SALT_TWO: [u8; 32] = [0x22; 32];
685    const ORG_ONE: &[u8] = b"acme";
686    const ORG_TWO: &[u8] = b"acme-eu";
687
688    fn shares() -> (SecretA, SecretB) {
689        (SecretA::from_bytes([0xa1; 32]), SecretB::from_bytes([0xb2; 32]))
690    }
691
692    fn cek() -> Cek {
693        Cek::from_bytes([0xc3; 32])
694    }
695
696    #[test]
697    fn the_kek_follows_rfc5869_with_the_file_id_as_salt_and_the_shares_as_ikm() {
698        // Перепутать местами `salt` и `ikm` — классическая ошибка применения
699        // HKDF: она ничем не проявляется, кроме несовместимости с чужой
700        // реализацией того же формата, а обнаружится уже после выпуска файлов.
701        // Поэтому K1 пересчитывается вручную по RFC 5869: Extract с солью в роли
702        // ключа HMAC, затем один блок Expand с суффиксом 0x01.
703        let (a, b) = shares();
704        let mut ikm = [0u8; KEK_IKM_LEN];
705        let (first, second) = ikm.split_at_mut(SECRET_LEN);
706        first.copy_from_slice(a.expose());
707        second.copy_from_slice(b.expose());
708
709        let prk = hmac_sha256(&FILE_ONE, &[&ikm]);
710        let expected = hmac_sha256(&prk, &[label::KEK.as_bytes(), ORG_ONE, &FILE_ONE, &[0x01]]);
711
712        assert_eq!(derive_kek(&FILE_ONE, ORG_ONE, &a, &b).expose(), &expected);
713    }
714
715
716    #[test]
717    fn the_same_shares_in_two_files_never_yield_the_same_kek() {
718        // Иначе одна вскрытая пара долей открывала бы все файлы арендатора, а не
719        // один: `file_id` — единственное, что делает ключи файла его личными.
720        let (a, b) = shares();
721        let one = derive_kek(&FILE_ONE, ORG_ONE, &a, &b);
722        let two = derive_kek(&FILE_TWO, ORG_ONE, &a, &b);
723        assert_ne!(one.expose(), two.expose());
724    }
725
726    #[test]
727    fn two_tenants_never_share_a_kek() {
728        // Разделение арендаторов обязано держаться на криптографии: проверка
729        // поля `org_id` при чтении — это решение, принимаемое клиентом, которого
730        // противник контролирует.
731        let (a, b) = shares();
732        let one = derive_kek(&FILE_ONE, ORG_ONE, &a, &b);
733        let two = derive_kek(&FILE_ONE, ORG_TWO, &a, &b);
734        assert_ne!(one.expose(), two.expose());
735    }
736
737    #[test]
738    fn swapping_the_two_shares_yields_a_different_kek() {
739        // Проверяет порядок конкатенации: A‖B и B‖A — разные ikm. Если бы
740        // порядок «поплыл» при рефакторинге, все ранее выпущенные файлы
741        // перестали бы открываться, и заметить это надо на сборке, а не в поле.
742        let straight = derive_kek(
743            &FILE_ONE,
744            ORG_ONE,
745            &SecretA::from_bytes([0xa1; 32]),
746            &SecretB::from_bytes([0xb2; 32]),
747        );
748        let swapped = derive_kek(
749            &FILE_ONE,
750            ORG_ONE,
751            &SecretA::from_bytes([0xb2; 32]),
752            &SecretB::from_bytes([0xa1; 32]),
753        );
754        assert_ne!(straight.expose(), swapped.expose());
755    }
756
757    #[test]
758    fn an_org_id_boundary_cannot_be_shifted_into_the_file_id() {
759        // `info` — конкатенация без разделителей, поэтому пара (org_id, file_id)
760        // обязана оставаться однозначной. `file_id` фиксирован типом в 16 байт,
761        // и сдвинуть границу можно только вместе с изменением `org_id`, что и
762        // проверяется: два разных арендатора не сходятся к одному ключу.
763        let (a, b) = shares();
764        let long = derive_kek(&FILE_ONE, b"acme\x11\x11", &a, &b);
765        let short = derive_kek(&FILE_ONE, b"acme", &a, &b);
766        assert_ne!(long.expose(), short.expose());
767    }
768
769    #[test]
770    fn a_repack_with_a_new_header_salt_changes_the_payload_key() {
771        // Повторная упаковка того же содержимого тем же CEK не должна давать
772        // совпадающих шифротекстов: иначе видно, что два контейнера содержат
773        // одно и то же, без единого ключа.
774        let key_one =
775            derive_payload_key(&cek(), &SALT_ONE, &FILE_ONE, 65536, AeadAlg::XChaCha20Poly1305);
776        let key_two =
777            derive_payload_key(&cek(), &SALT_TWO, &FILE_ONE, 65536, AeadAlg::XChaCha20Poly1305);
778        assert_ne!(key_one.expose(), key_two.expose());
779    }
780
781    #[test]
782    fn the_payload_key_changes_with_the_chunk_size() {
783        // Размер чанка задаёт разбивку открытого текста. Тот же ключ при другой
784        // разбивке — это тот же ключевой поток на других данных.
785        let small =
786            derive_payload_key(&cek(), &SALT_ONE, &FILE_ONE, 16384, AeadAlg::XChaCha20Poly1305);
787        let large =
788            derive_payload_key(&cek(), &SALT_ONE, &FILE_ONE, 65536, AeadAlg::XChaCha20Poly1305);
789        assert_ne!(small.expose(), large.expose());
790    }
791
792    #[test]
793    fn the_payload_key_changes_with_the_aead_id() {
794        // Смена профиля меняет схему nonce: у XChaCha он случайный и хранится, у
795        // AES-GCM — счётчиковый. Общий ключ между профилями означал бы
796        // повторное использование пары (ключ, nonce) в двух разных схемах.
797        let xchacha =
798            derive_payload_key(&cek(), &SALT_ONE, &FILE_ONE, 65536, AeadAlg::XChaCha20Poly1305);
799        let gcm = derive_payload_key(&cek(), &SALT_ONE, &FILE_ONE, 65536, AeadAlg::Aes256Gcm);
800        let siv = derive_payload_key(&cek(), &SALT_ONE, &FILE_ONE, 65536, AeadAlg::Aes256GcmSiv);
801        assert_ne!(xchacha.expose(), gcm.expose());
802        assert_ne!(gcm.expose(), siv.expose());
803        assert_ne!(xchacha.expose(), siv.expose());
804    }
805
806    #[test]
807    fn the_same_cek_in_two_files_never_yields_the_same_payload_key() {
808        let one =
809            derive_payload_key(&cek(), &SALT_ONE, &FILE_ONE, 65536, AeadAlg::XChaCha20Poly1305);
810        let two =
811            derive_payload_key(&cek(), &SALT_ONE, &FILE_TWO, 65536, AeadAlg::XChaCha20Poly1305);
812        assert_ne!(one.expose(), two.expose());
813    }
814
815    #[test]
816    fn private_metadata_and_content_mac_never_share_a_key_with_the_payload() {
817        // Три ключа от одного CEK и одной соли. Совпадение любых двух означало
818        // бы, что тег изменяемой области можно подделать ключом полезной
819        // нагрузки или наоборот.
820        let payload =
821            derive_payload_key(&cek(), &SALT_ONE, &FILE_ONE, 65536, AeadAlg::XChaCha20Poly1305);
822        let meta = derive_private_meta_key(&cek(), &SALT_ONE, &FILE_ONE);
823        let mac = derive_content_mac_key(&cek(), &SALT_ONE, &FILE_ONE);
824        assert_ne!(payload.expose(), meta.expose());
825        assert_ne!(meta.expose(), mac.expose());
826        assert_ne!(payload.expose(), mac.expose());
827    }
828
829    #[test]
830    fn the_same_claim_code_in_two_files_yields_two_different_shares() {
831        // Код-претензия может быть выдан повторно (тот же генератор, та же
832        // длина). Соль `file_id` не даёт одному коду открыть два файла.
833        let claim = ClaimSecret::from_bytes([0x7c; 32]);
834        let (share_one, commit_one) = secret_b_from_claim(&FILE_ONE, &claim);
835        let (share_two, commit_two) = secret_b_from_claim(&FILE_TWO, &claim);
836        assert_ne!(share_one.expose(), share_two.expose());
837        assert_ne!(commit_one, commit_two);
838    }
839
840    #[test]
841    fn a_claim_commitment_never_equals_the_share_it_commits_to() {
842        // Обязательство лежит в контейнере открытым текстом. Совпади оно с
843        // долей — контейнер раздавал бы secret_B каждому, кто его прочитал.
844        let claim = ClaimSecret::from_bytes([0x7c; 32]);
845        let (share, commitment) = secret_b_from_claim(&FILE_ONE, &claim);
846        assert_ne!(share.expose(), &commitment);
847    }
848
849
850    #[test]
851    fn a_single_flipped_bit_in_a_commitment_is_refused() {
852        let expected = [0x9a; 32];
853        let mut actual = expected;
854        if let Some(byte) = actual.get_mut(31) {
855            *byte ^= 0x01;
856        }
857        assert_eq!(verify_commitment(&expected, &expected), Ok(()));
858        assert_eq!(
859            verify_commitment(&expected, &actual),
860            Err(CryptoError::Authentication)
861        );
862    }
863
864    #[test]
865    fn every_derivation_of_the_schedule_is_domain_separated() {
866        // Все производные получают максимально одинаковый вход: одни и те же 32
867        // байта в роли долей, CEK, кода-претензии, KEK и соли. При таком входе
868        // единственное, что разводит выходы, — метки домена. Совпадение любых
869        // двух означало бы, что ключ одного назначения принимается вместо
870        // другого: тег изменяемой области подделывается ключом метаданных,
871        // обязательство слота подставляется как обязательство кода.
872        let material = [0x55u8; 32];
873        let file_id = [0x55u8; 16];
874        let salt = material;
875
876        let a = SecretA::from_bytes(material);
877        let b = SecretB::from_bytes(material);
878        let cek = Cek::from_bytes(material);
879        let claim = ClaimSecret::from_bytes(material);
880        let kek = Kek::from_bytes(material);
881
882        let (share, claim_commitment) = secret_b_from_claim(&file_id, &claim);
883        let derived: Vec<(&str, Vec<u8>)> = vec![
884            ("K1 kek", derive_kek(&file_id, &material, &a, &b).expose().to_vec()),
885            (
886                "K3 payload",
887                derive_payload_key(&cek, &salt, &file_id, 65536, AeadAlg::XChaCha20Poly1305)
888                    .expose()
889                    .to_vec(),
890            ),
891            (
892                "K5 private-meta",
893                derive_private_meta_key(&cek, &salt, &file_id).expose().to_vec(),
894            ),
895            (
896                "K6 content-mac",
897                derive_content_mac_key(&cek, &salt, &file_id).expose().to_vec(),
898            ),
899            ("K7 slot-b-claim", share.expose().to_vec()),
900            ("K8 slot-b-commit", claim_commitment.to_vec()),
901            ("K9 slot-commit", slot_commitment(&kek, &salt).to_vec()),
902        ];
903
904        for (index, (left_name, left)) in derived.iter().enumerate() {
905            for (right_name, right) in derived.iter().skip(index.saturating_add(1)) {
906                // Сравниваются общие префиксы, иначе K2 длиной 24 байта прошёл бы
907                // тест «бесплатно», просто из-за другой длины.
908                let common = left.len().min(right.len());
909                let left_prefix: Vec<u8> = left.iter().copied().take(common).collect();
910                let right_prefix: Vec<u8> = right.iter().copied().take(common).collect();
911                assert_ne!(
912                    left_prefix, right_prefix,
913                    "{left_name} и {right_name} совпали: разделение доменов не работает"
914                );
915            }
916        }
917
918        // Заодно ловит вырождение буфера в нули: ни одна производная не должна
919        // быть пустым ключом, даже если бы `expand` когда-нибудь отказал.
920        for (name, value) in &derived {
921            assert!(value.iter().any(|byte| *byte != 0), "{name} выродился в нули");
922        }
923    }
924}
925
926#[cfg(test)]
927#[allow(clippy::unwrap_used, clippy::panic, clippy::indexing_slicing)]
928mod device_fpr_tests {
929    use super::*;
930    use crate::KemAlg;
931
932    /// For X25519, the fingerprint IS the key: frozen by wire vectors.
933    #[test]
934    fn x25519_fingerprint_is_the_key_itself() {
935        let key = [0x5a; 32];
936        assert_eq!(device_fpr(KemAlg::X25519HkdfSha256, &key).unwrap(), key);
937    }
938
939    /// The mechanism number enters the preimage: identical bytes under another number mean
940    /// a different device name. Otherwise relabeling the mechanism would preserve the name.
941    #[test]
942    fn the_mechanism_number_is_part_of_the_name() {
943        let xw = crate::xwing::public_key(&[0x4d; 32]).unwrap();
944        let a = device_fpr(KemAlg::XWing, &xw).unwrap();
945        // Тот же префикс байтов, но заявленный как P-256 — длина не сходится.
946        assert!(device_fpr(KemAlg::P256HkdfSha256, &xw[..65]).is_ok());
947        assert_ne!(a, device_fpr(KemAlg::P256HkdfSha256, &xw[..65]).unwrap());
948        assert_ne!(&a[..], &xw[..32], "хеш не должен совпадать с началом ключа");
949    }
950
951    /// Length is checked against the mechanism (I-8), not taken "as received".
952    #[test]
953    fn a_key_of_the_wrong_length_is_refused_not_hashed() {
954        assert_eq!(device_fpr(KemAlg::X25519HkdfSha256, &[0; 31]), Err(CryptoError::BadLength));
955        assert_eq!(device_fpr(KemAlg::P256HkdfSha256, &[4; 64]), Err(CryptoError::BadLength));
956        assert_eq!(device_fpr(KemAlg::XWing, &[0; 1215]), Err(CryptoError::BadLength));
957        assert_eq!(device_fpr(KemAlg::MlKem768P256, &[0; 1250]), Err(CryptoError::BadLength));
958        assert_eq!(device_fpr(KemAlg::RsaOaepSha256, &[0; 256]), Err(CryptoError::UnsupportedAlgorithm));
959    }
960
961    /// The length table has a gap exactly where the build cannot execute a mechanism.
962    ///
963    /// The two tables answer DIFFERENT questions, "what shape is the key" and "can
964    /// we execute this mechanism"; nothing guarantees their agreement except
965    /// today's coincidence. Since `device_fpr` needs that agreement (otherwise the
966    /// "no length" branch would have reachable meaning), it must be tested rather than
967    /// assumed. Enumerate ALL members, not a list recalled from memory: a new
968    /// mechanism will be included automatically.
969    #[test]
970    fn the_length_table_has_a_hole_exactly_where_the_build_has_no_mechanism() {
971        // Члены берутся ИЗ РАЗБОРА, а не списком: список по памяти устареет в
972        // день, когда реестр пополнят, и проба промолчит именно о новом номере.
973        let all: Vec<KemAlg> = (0u8..=255).filter_map(|v| KemAlg::from_u8(v).ok()).collect();
974        assert_eq!(all.len(), 5, "реестр механизмов изменился — проверьте таблицы");
975        for kem in all {
976            assert_eq!(
977                public_key_len(kem).is_some(),
978                kem.ensure_supported().is_ok(),
979                "таблица длин разошлась с исполнимостью на {kem:?}"
980            );
981        }
982    }
983
984    /// A hashed fingerprint does not start with the label: the label is in the preimage, not the
985    /// output. This guards against "fingerprint = label ‖ key" during refactoring.
986    #[test]
987    fn the_label_is_in_the_preimage_not_in_the_output() {
988        let p = [0x04; 65];
989        let f = device_fpr(KemAlg::P256HkdfSha256, &p).unwrap();
990        assert!(!f.starts_with(b"CC/v1"));
991    }
992}