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}