oc_crypto/lib.rs
1// SPDX-License-Identifier: MPL-2.0
2//! Cryptographic core of the `.cc` container.
3//!
4//! A crate-wide, lint-enforced rule: **no I/O,
5//! clocks, or randomness from thin air**. Nonces and RNGs are passed
6//! as arguments. This makes every test deterministic, while Wycheproof vectors
7//! exercise our own call sites, not merely the underlying crates.
8//!
9//! Parsing ensures that plaintext never leaves the function before the authentication
10//! tag is checked: on failure the buffer is wiped, and the caller must treat it
11//! as unusable.
12
13pub mod aead;
14pub mod agreement;
15pub mod kdf;
16pub mod mac;
17pub mod merkle;
18pub mod mlkem_p256;
19pub mod rsa;
20/// Software RSA-PSS signer: only for probes and the testbed (`docs/format.md`,
21/// "EDITING IS EXECUTABLE"). Excluded from production: the `test-signer` feature.
22#[cfg(any(test, feature = "test-signer"))]
23pub mod rsa_test_signer;
24pub mod seal;
25pub mod secret;
26pub mod sign;
27/// Payload chunking discipline: one loop for every host.
28pub mod stream;
29pub mod tpm;
30pub mod transcript;
31pub mod wrap;
32pub mod xwing;
33
34pub use label::Label;
35pub use secret::{
36 Cek, ClaimSecret, Kek, MacKey, MetaKey, PayloadKey, SecretA, SecretB, SecretBuf, X25519Secret,
37};
38pub use transcript::Transcript;
39
40/// Cryptographic operation errors.
41///
42/// Variants deliberately reveal few details: an error message must not give
43/// an adversary another bit of information about precisely which check
44/// failed or at which byte.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum CryptoError {
47 /// Authentication failed: AEAD tag, MAC, or slot commitment.
48 /// One variant for all three cases, deliberately.
49 Authentication,
50 /// The signature is invalid or noncanonical.
51 BadSignature,
52 /// Input or output length violates the contract.
53 BadLength,
54 /// Data does not match the tree root.
55 TreeMismatch,
56 /// Access outside the tree.
57 IndexOutOfRange,
58 /// The key is not a valid curve point or is forbidden (small order).
59 BadKey,
60 /// This client build does not support the algorithm identifier.
61 UnsupportedAlgorithm,
62}
63
64impl core::fmt::Display for CryptoError {
65 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
66 let text = match self {
67 Self::Authentication => "проверка подлинности не прошла",
68 Self::BadSignature => "подпись неверна",
69 Self::BadLength => "некорректная длина данных",
70 Self::TreeMismatch => "данные не соответствуют дереву целостности",
71 Self::IndexOutOfRange => "индекс за пределами дерева",
72 Self::BadKey => "некорректный ключ",
73 Self::UnsupportedAlgorithm => "алгоритм не поддерживается",
74 };
75 f.write_str(text)
76 }
77}
78
79impl core::error::Error for CryptoError {}
80
81/// Algorithm identifiers covered by the header signature.
82///
83/// Agility uses explicit numbers rather than "the current best choice" because
84/// the KEM will change during the product's lifetime: X25519 will give way to an ML-KEM hybrid,
85/// and slots using different KEMs must coexist in one file.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87#[repr(u8)]
88pub enum AeadAlg {
89 /// Primary profile. A 192-bit nonce allows storing a random nonce.
90 XChaCha20Poly1305 = 1,
91 /// Profile for FIPS requirements. A 96-bit nonce requires a counter and
92 /// makes the container effectively write-once.
93 Aes256Gcm = 2,
94 /// Nonce-reuse-resistant variant.
95 ///
96 /// This used to say "for editable files", which was a promise, not a
97 /// description: editable files use `aead_id = 1`, like all
98 /// others. The property SIV was meant to provide comes from plaintext-hedged
99 /// nonces ([`aead::seal_chunk_hedged`]), introduced after
100 /// the promise itself and making the second profile unnecessary. See
101 /// `docs/format.md` §6.1.
102 ///
103 /// The member remains to prevent assigning number 3 to another cipher.
104 Aes256GcmSiv = 3,
105}
106
107impl AeadAlg {
108 /// Parse an identifier from a file. Unknown values cause rejection, not
109 /// substitution of a default.
110 ///
111 /// Parsing immediately passes the second boundary, [`aead::ensure_supported`],
112 /// just as [`TreeHashAlg::from_u8`] already does. The asymmetry was
113 /// substantive: a header with `aead_id = 2` passed signature verification, slot parsing,
114 /// key agreement, and CEK unwrapping before failing on the first chunk. All
115 /// that work was done on a file already known, at parsing time,
116 /// to be impossible to open.
117 pub fn from_u8(v: u8) -> Result<Self, CryptoError> {
118 let alg = match v {
119 1 => Self::XChaCha20Poly1305,
120 2 => Self::Aes256Gcm,
121 3 => Self::Aes256GcmSiv,
122 _ => return Err(CryptoError::UnsupportedAlgorithm),
123 };
124 aead::ensure_supported(alg)?;
125 Ok(alg)
126 }
127}
128
129/// Signature algorithm.
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131#[repr(u8)]
132pub enum SigAlg {
133 /// Author signature. The only one this build executes.
134 Ed25519 = 1,
135 /// Editing-device signature, format version 3: RSA-PSS-SHA256,
136 /// MGF1-SHA256, 32-byte salt, exponent 65537.
137 ///
138 /// Chosen for its failure mode, not taste: ECDSA consumes an ephemeral `k` for
139 /// every signature, and signing two different messages with the same `k` reveals
140 /// the private key by arithmetic, exactly what virtual-machine snapshot
141 /// rollback causes. With PSS, repeated salt yields one extra valid signature and
142 /// nothing more. See `docs/format.md`, "VERSION 3 OPENED", item 7.
143 ///
144 /// Verifier: [`rsa::verify_pss_sha256`], in pure Rust: `oc-format`
145 /// verifies the signature and must build for
146 /// `wasm32-unknown-unknown`.
147 ///
148 /// Used **only by mutable-region tag 6**. This number is invalid in `suite.sig_alg`:
149 /// that field specifies the AUTHOR signature, frozen in version 1 as
150 /// Ed25519. Header parsing checks placement: `ensure_supported`
151 /// answers "can we execute it", not "does it belong here".
152 RsaPssSha256 = 2,
153}
154
155impl SigAlg {
156 /// Whether the build can execute the declared algorithm.
157 ///
158 /// A second boundary, like [`aead::ensure_supported`] and
159 /// [`merkle::ensure_supported`]. Without it, an identifier in a signed
160 /// header controls nothing: a file declaring RSA-PSS would still
161 /// be verified as Ed25519 and accepted. This is the same defect class
162 /// that produced `alg: none` in JWS.
163 ///
164 /// The `match` deliberately has no `_`: adding a member must break the build here,
165 /// beside verification, rather than pass silently.
166 pub fn ensure_supported(self) -> Result<(), CryptoError> {
167 match self {
168 Self::Ed25519 => Ok(()),
169 // Исполняется с появлением `rsa::verify_pss_sha256`. До него здесь
170 // стоял отказ, и это было верно: номер, который сборка не умеет
171 // исполнить, обязан отвергаться на разборе.
172 Self::RsaPssSha256 => Ok(()),
173 }
174 }
175
176 /// Parse an identifier from a file.
177 ///
178 /// As with [`AeadAlg::from_u8`], parsing immediately passes through
179 /// [`Self::ensure_supported`]: a header using an unimplemented algorithm must
180 /// be rejected AT PARSING, not after key agreement and CEK unwrapping.
181 pub fn from_u8(v: u8) -> Result<Self, CryptoError> {
182 let alg = match v {
183 1 => Self::Ed25519,
184 2 => Self::RsaPssSha256,
185 _ => return Err(CryptoError::UnsupportedAlgorithm),
186 };
187 alg.ensure_supported()?;
188 Ok(alg)
189 }
190}
191
192/// Key encapsulation mechanism. Specified **per slot**, not per file.
193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
194#[repr(u8)]
195pub enum KemAlg {
196 /// Author → server and author → recipient.
197 X25519HkdfSha256 = 1,
198 /// Server → device. P-256 specifically, not X25519: Microsoft Platform Crypto
199 /// Provider does not provide X25519; TPM 2.0 offers ECDH P-256 and RSA.
200 P256HkdfSha256 = 2,
201 /// Fallback for TPMs without ECDH support.
202 RsaOaepSha256 = 3,
203 /// X25519 and ML-KEM-768 hybrid using X-Wing: a post-quantum half
204 /// alongside a classical half. Mechanism: [`crate::xwing`]; normative form:
205 /// `docs/format.md`, "VERSION 3 OPENED", item 1 (the item itself is in version 4).
206 ///
207 /// Targets a SOFTWARE key: X-Wing is defined over X25519, while a TPM key
208 /// is P-256. Post-quantum protection and hardware binding of the recipient are currently
209 /// mutually exclusive; see `docs/threat-model.md` §2.
210 XWing = 4,
211 /// ECDH P-256 and ML-KEM-768 hybrid: MLKEM768-P256. Mechanism:
212 /// [`crate::mlkem_p256`]; normative form: `docs/format.md`, version 5.
213 ///
214 /// The difference from [`Self::XWing`] is not strength but WHERE the classical
215 /// half resides: Platform Crypto Provider supports P-256 but not X25519. Thus
216 /// the fifth mechanism is the only one combining post-quantum protection with
217 /// TPM key non-exportability rather than making them mutually exclusive.
218 MlKem768P256 = 5,
219}
220
221impl KemAlg {
222 /// Whether the build can execute the declared mechanism: ONE name for this question.
223 ///
224 /// The name was not introduced for neatness. "Is this mechanism executable?"
225 /// was answered separately by the fingerprint table ([`kdf::device_fpr`]), header length
226 /// tables, and the client's share-B issuance branch; the answers agreed
227 /// only through the editor's memory. Such a set can diverge exactly once:
228 /// in the direction of "somewhere a mechanism this build cannot execute was considered
229 /// executable".
230 ///
231 /// The answer comes from [`seal::supports_kem`] rather than being duplicated here: the `match`
232 /// without `_` must sit BESIDE THE IMPLEMENTATION, so adding a [`KemAlg`]
233 /// member breaks the build at the code responsible for executing it. This is
234 /// the `Result` form of the same answer, for callers needing rejection rather than `bool`,
235 /// following [`SigAlg::ensure_supported`].
236 ///
237 /// # Errors
238 /// [`CryptoError::UnsupportedAlgorithm`]: the registry has the number but the build
239 /// lacks the mechanism.
240 pub fn ensure_supported(self) -> Result<(), CryptoError> {
241 if seal::supports_kem(self) { Ok(()) } else { Err(CryptoError::UnsupportedAlgorithm) }
242 }
243
244 /// Parse an identifier from a file.
245 ///
246 /// # Why this has NO second boundary, unlike its neighbors
247 ///
248 /// [`AeadAlg::from_u8`], [`SigAlg::from_u8`], and [`TreeHashAlg::from_u8`]
249 /// call `ensure_supported` during parsing: an unimplemented header algorithm
250 /// must reject the FILE, the earlier the better. For `kem_id`, the consequence
251 /// differs normatively: `docs/format.md` §3.3 and §3.5 require
252 /// SKIPPING a slot with an unimplemented or unknown mechanism rather than rejecting
253 /// the file; a usable slot for this recipient may be adjacent.
254 /// Rejection built in here would move "skip or reject" from
255 /// the caller's level into number parsing, which knows nothing
256 /// about slots.
257 ///
258 /// The boundary therefore remains a separate [`Self::ensure_supported`] call,
259 /// while parsing answers only "is this number in the registry?".
260 pub fn from_u8(v: u8) -> Result<Self, CryptoError> {
261 match v {
262 1 => Ok(Self::X25519HkdfSha256),
263 2 => Ok(Self::P256HkdfSha256),
264 3 => Ok(Self::RsaOaepSha256),
265 4 => Ok(Self::XWing),
266 5 => Ok(Self::MlKem768P256),
267 _ => Err(CryptoError::UnsupportedAlgorithm),
268 }
269 }
270}
271
272/// Payload-tree hash.
273#[derive(Debug, Clone, Copy, PartialEq, Eq)]
274#[repr(u8)]
275pub enum TreeHashAlg {
276 /// Naturally tree-based and substantially faster than SHA-256.
277 Blake3 = 1,
278 /// Listed in the format registry but not used to compute trees in this build: see
279 /// [`merkle::ensure_supported`]. The member remains to prevent reusing number 2
280 /// for another hash; otherwise old files would be read with the wrong
281 /// algorithm rather than rejected.
282 Sha256 = 2,
283}
284
285impl TreeHashAlg {
286 /// Parse an identifier from a file.
287 ///
288 /// Parsing a number and being able to execute it are distinct; previously this build
289 /// only did the first: it accepted `tree_hash_id = 2`, yet still computed the tree
290 /// using BLAKE3. Parsing therefore immediately passes a second boundary,
291 /// [`merkle::ensure_supported`], the sole declaration of what this
292 /// build supports. A single place prevents the supported-algorithm list from
293 /// diverging from the hasher's behavior.
294 pub fn from_u8(v: u8) -> Result<Self, CryptoError> {
295 let alg = match v {
296 1 => Self::Blake3,
297 2 => Self::Sha256,
298 _ => return Err(CryptoError::UnsupportedAlgorithm),
299 };
300 merkle::ensure_supported(alg)?;
301 Ok(alg)
302 }
303}
304
305/// Domain-separation labels.
306///
307/// None is used twice across the system. The only route into
308/// a signature is through [`Transcript`], whose constructor requires a label.
309pub mod label {
310 /// A domain label: a value that CANNOT be invented.
311 ///
312 /// # Why a type where `&'static [u8]` used to suffice
313 ///
314 /// I-12 requires unique, prefix-free labels, guarded by
315 /// four probes: uniqueness, prefix-freeness, versioning, and agreement with
316 /// specification §3.6. All four inspect [`ALL`], the REGISTRY. They never saw
317 /// call sites: `Transcript::new` and `seal::slot_info`
318 /// accepted arbitrary bytes, and a caller could pass
319 /// `b"CC/v1/lease-cache"`, a string absent from the registry and extending
320 /// [`LEASE`]. No probe would detect that, because it would
321 /// check the list rather than the call.
322 ///
323 /// The new type closes exactly this gap: `Label` values come ONLY
324 /// from this module's constants because the constructor is private and the field
325 /// is not public. The registry's guarantee becomes a guarantee of every call,
326 /// checked by the compiler rather than a probe.
327 ///
328 /// # Why `Debug` prints the string itself
329 ///
330 /// A label is not secret: it is plaintext in every file and in the specification.
331 /// I-11 forbids secrets in `Debug`, not domain names; hiding
332 /// the label would blind signature-failure debugging without the slightest
333 /// benefit.
334 #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
335 pub struct Label(&'static [u8]);
336
337 impl Label {
338 /// Create a label. Deliberately private: see the type documentation.
339 ///
340 /// `const fn` because all labels are constants; a constant
341 /// constructed at runtime would require `OnceLock` where all that is needed
342 /// is a byte array.
343 const fn new(bytes: &'static [u8]) -> Self {
344 Self(bytes)
345 }
346
347 /// Label bytes: what enters `info`, the transcript, and the preimage.
348 #[must_use]
349 pub const fn as_bytes(self) -> &'static [u8] {
350 self.0
351 }
352
353 /// Byte length. Needed in `const` context: the private-metadata AAD layout
354 /// is computed at build time (`aead::META_AAD_LEN`).
355 #[must_use]
356 pub const fn len(self) -> usize {
357 self.0.len()
358 }
359
360 /// Whether the label is empty. Always `false`: a label with no bytes cannot separate
361 /// domains, but without this method `clippy::len_without_is_empty` is right.
362 #[must_use]
363 pub const fn is_empty(self) -> bool {
364 self.0.is_empty()
365 }
366
367 /// A label OUTSIDE THE REGISTRY: for prototypes and probes only.
368 ///
369 /// Needed by two out-of-tree prototypes (`experiments/attested-release`,
370 /// `spikes/disclosure-capsules`): each signs ITS OWN statement in
371 /// its own domain (`"F29/proto/evidence"`, `"SS/spike/..."`); adding those
372 /// strings to the registry is forbidden, since the registry is normative and the prototype may die tomorrow.
373 ///
374 /// The `ad-hoc-label` feature is disabled by default and listed as
375 /// non-shipping (`cc-cli/tests/repository_hygiene.rs`) for the same
376 /// reason as `explicit-nonce`: enabling it in a release restores the
377 /// production escape hatch that this type was introduced to close.
378 #[cfg(any(test, feature = "ad-hoc-label"))]
379 #[must_use]
380 pub const fn ad_hoc(bytes: &'static [u8]) -> Self {
381 Self::new(bytes)
382 }
383 }
384
385 impl core::fmt::Debug for Label {
386 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
387 match core::str::from_utf8(self.0) {
388 Ok(text) => write!(f, "Label({text})"),
389 // Метки реестра — ASCII по построению, и эта ветка недостижима
390 // для них. Она существует ради `ad_hoc`, которому байты передают
391 // прототипы.
392 Err(_) => write!(f, "Label({:?})", self.0),
393 }
394 }
395 }
396
397 pub const HEADER_SIG: Label = Label::new(b"CC/v1/header-sig");
398 pub const REVOCATION: Label = Label::new(b"CC/v1/revocation");
399 pub const GRANT: Label = Label::new(b"CC/v1/grant");
400 /// Author signature on an AGENT GRANT: door, expiry, depth, and shares B
401 /// for the subtree (`oc_protocol::agent::AgentGrant`, Agent Protocol, stage 1).
402 ///
403 /// Its own label, not [`GRANT`], for good reason: `"CC/v1/grant"` marks
404 /// author approval of ONE request for one file
405 /// (`oc_protocol::access::decision_transcript`), whereas an agent grant distributes shares
406 /// for an entire tree and names the key with which the door signs delegations.
407 /// If the domains coincided, approval of one file would also serve as a signature
408 /// for distributing the entire subtree.
409 ///
410 /// Prefix-free: `"CC/v1/grant"` is not its prefix (seventh byte `a`
411 /// versus `g`); its nearest `a` neighbors, `"CC/v1/activate-req"`,
412 /// `"CC/v1/audit-entry"`, `"CC/v1/audit-head"`, `"CC/v1/attest-nonce"`,
413 /// `"CC/v1/attest-qualify"`, `"CC/v1/author-order"`,
414 /// `"CC/v1/authority-binding"`, and `"CC/v1/authority-transfer"`, differ
415 /// at byte eight (`g` versus `c`, `u`, `t`).
416 ///
417 /// Does not affect the container: it changes no format version and enters no
418 /// header. It is registered because prefix-freeness can be proved
419 /// only here (I-12).
420 pub const AGENT_GRANT: Label = Label::new(b"CC/v1/agent-grant");
421 /// Parent-door signature on a DELEGATION to a child
422 /// (`oc_protocol::agent::Delegation`, same source).
423 ///
424 /// Distinct from [`AGENT_GRANT`]: the author signs a grant with the container
425 /// header's key, while the door signs delegations with its own ephemeral Ed25519 key. A signature
426 /// on one must not work for the other, or a door receiving
427 /// a grant could issue itself another grant, with new depth and expiry.
428 ///
429 /// Prefix-free: the nearest `d` labels are `"CC/v1/device-fpr"`,
430 /// `"CC/v1/directory-entry"`, and `"CC/v1/directory-head"`, all differing
431 /// at byte eight (`e` versus `e`/`i`; for `device-fpr`, at byte nine,
432 /// `l` versus `v`).
433 pub const DELEGATION: Label = Label::new(b"CC/v1/delegation");
434 /// Author signature on an ACTION GRANT: which actions, subject to which
435 /// constraints, the door may request from the server
436 /// (`oc_protocol::action::ActionGrant`, Agent Protocol, stage 2,
437 /// `docs/agent-protocol/stage-2-actions.md` §4.1).
438 ///
439 /// Distinct from [`AGENT_GRANT`], for the same reason separating an agent grant from
440 /// [`GRANT`]: an agent grant distributes shares B for READING a subtree; an action
441 /// grant distributes permission to ACT outside the sandbox: push a branch,
442 /// delete a file, contact the outside. If domains matched, a signature granting
443 /// read access would grant actions too: an author opening a directory
444 /// to an agent would silently grant it `git push` as well.
445 ///
446 /// Prefix-free: among `a` labels, the closest prefix is `"CC/v1/activate-req"`,
447 /// which differs at the fifth byte of the name (`o` versus `v`:
448 /// `action` versus `activate`); its neighbor [`ACTION_LEASE`] shares only
449 /// `"CC/v1/action-"`, followed by `g` versus `l`. Neither extends the other.
450 ///
451 /// Does not affect the container: it changes no format version, enters no header,
452 /// and none of its bytes occur in `.cc`. It is registered
453 /// because prefix-freeness can be proved only here (I-12).
454 pub const ACTION_GRANT: Label = Label::new(b"CC/v1/action-grant");
455 /// SERVER signature on a single-use action lease
456 /// (`oc_protocol::action::ActionLease`, same source, §4.3).
457 ///
458 /// The key is the same one the server uses to sign file leases
459 /// (`authority.lease_verify_key` from the header), but the label is distinct for a
460 /// reason: a file lease permits OPENING a file; an action lease permits
461 /// EXECUTING an action with specified arguments. If domains matched, one
462 /// signed document could replace the other under the same key, reducing
463 /// the distinction between "read" and "push a branch" to how the recipient
464 /// interprets the bytes.
465 ///
466 /// Prefix-free with [`LEASE`] (`"CC/v1/lease"` is not its prefix) and
467 /// [`ACTION_GRANT`] (see there).
468 pub const ACTION_LEASE: Label = Label::new(b"CC/v1/action-lease");
469 /// AUTHOR signature on a decision concerning an action-execution request
470 /// (`oc_protocol::action::ActionDecision`, stage 2, §5 step 3).
471 ///
472 /// Distinct from [`GRANT`], which signs decisions about ACCESS requests
473 /// (`oc_protocol::access::decision_transcript`). Both use the same key,
474 /// from the header and recorded by the server at file registration;
475 /// only the label separates the domains. If they coincided, two different author
476 /// statements would share one signature: "issue this device the file's share B"
477 /// and "let this door execute `git push` to this branch". Differing tag
478 /// numbers cannot be relied on to separate them: both bodies use
479 /// TLV, and matching numbers are a matter of time, not construction.
480 ///
481 /// Prefix-free: with [`ACTION_GRANT`] and [`ACTION_LEASE`] it shares only
482 /// `"CC/v1/action-"`, then `d` versus `g` and `l`; no registry label
483 /// starts with `"CC/v1/action-d"`. Like both neighbors, it does not affect
484 /// the container: it changes no format version and enters no header.
485 pub const ACTION_DECISION: Label = Label::new(b"CC/v1/action-decision");
486 pub const LEASE: Label = Label::new(b"CC/v1/lease");
487 pub const ACTIVATE_REQ: Label = Label::new(b"CC/v1/activate-req");
488 pub const AUDIT_ENTRY: Label = Label::new(b"CC/v1/audit-entry");
489 /// Signed log head: size and tree root over the entries.
490 ///
491 /// Its own label rather than a shared entry label, for good reason: heads and entries are
492 /// DIFFERENT statements. A signature on one must not work for the other,
493 /// or a signed entry could be presented as a signed head.
494 ///
495 /// Prefix-free: `"CC/v1/audit-entry"` is not its prefix, nor vice versa
496 /// (I-12), tested across the entire list.
497 pub const AUDIT_HEAD: Label = Label::new(b"CC/v1/audit-head");
498 pub const ATTEST_NONCE: Label = Label::new(b"CC/v1/attest-nonce");
499 pub const CONTENT_MAC: Label = Label::new(b"CC/v1/content-mac");
500 /// Editor signature over the mutable region (`docs/format.md`, "EDITING IS
501 /// EXECUTABLE", item C).
502 pub const EDITOR_SIG: Label = Label::new(b"CC/v1/editor-sig");
503 /// Editing-key certificate: the author or a coauthor certifies "device X has
504 /// editing key S for this file" (same source, item B).
505 ///
506 /// Prefix-free with [`EDITOR_SIG`]: they share only `"CC/v1/editor-"`, and
507 /// neither extends the other.
508 pub const EDITOR_CERT: Label = Label::new(b"CC/v1/editor-cert");
509 /// Editing-session head: a hash chain of saves (same source, item D).
510 pub const EDIT_SESSION: Label = Label::new(b"CC/v1/edit-session");
511 /// Revision submission to the server (`docs/protocol.md` §9.12): a separate label so
512 /// an editing-key signature on a submission cannot serve as a region signature,
513 /// or vice versa (I-12).
514 pub const EDITION_CLAIM: Label = Label::new(b"CC/v1/edition-claim");
515 /// File imprint for an RFC 3161 timestamp (`docs/format.md`, "FOOTER AND
516 /// TIMESTAMP", item B).
517 pub const FOOTER_IMPRINT: Label = Label::new(b"CC/v1/footer-imprint");
518 /// Witness signature over the server's log head (`docs/protocol.md`
519 /// §9.13, D3). Separate from `audit-head`: the server signs the head,
520 /// while another party signs the witness statement; one's signature must not serve
521 /// as the other's.
522 pub const WITNESS_COSIGN: Label = Label::new(b"CC/v1/witness-cosign");
523 /// Key-directory log leaf: directory-entry hash (`docs/protocol.md`
524 /// §9.14, D4). Unlike the event log, the leaf hashes the entry ITSELF, not
525 /// its MAC: verifiers must see exactly what is proved.
526 pub const DIRECTORY_ENTRY: Label = Label::new(b"CC/v1/directory-entry");
527 /// Directory-log head signature. Separate from `audit-head`: a directory
528 /// head and an event-log head are different statements.
529 pub const DIRECTORY_HEAD: Label = Label::new(b"CC/v1/directory-head");
530 /// Semantic-mark layout fingerprint (D5): which points and which
531 /// equivalent variants they contain.
532 pub const MARK_LAYOUT: Label = Label::new(b"CC/v1/mark-layout");
533 /// Semantic-mark variant selection using the organization key (D5). Prefix-free with
534 /// `mark-layout`: they share only `"CC/v1/mark-"`.
535 pub const MARK_CHOICE: Label = Label::new(b"CC/v1/mark-choice");
536 /// Server recovery-package manifest signature using the lease-signing key
537 /// (E2, B5): which keys, state, and log head the package contains.
538 /// Does not affect the container.
539 pub const RECOVERY_MANIFEST: Label = Label::new(b"CC/v1/recovery-manifest");
540 /// Server-binding signature using the lease-signing key (E2, B2,
541 /// `oc_protocol::control::Binding`).
542 pub const AUTHORITY_BINDING: Label = Label::new(b"CC/v1/authority-binding");
543 /// Controller signature on an intent (E2, B2,
544 /// `oc_protocol::control::ControlRequest`).
545 pub const CONTROL_REQUEST: Label = Label::new(b"CC/v1/control-request");
546 /// Server signature on an operation receipt (E2, B2,
547 /// `oc_protocol::control::Receipt`). Prefix-free with `operation-id`: they share
548 /// only `"CC/v1/operation-"`.
549 pub const OPERATION_RECEIPT: Label = Label::new(b"CC/v1/operation-receipt");
550 /// Signature on a state snapshot sent to a replica (E2, B4,
551 /// `oc_protocol::replica::Push`).
552 pub const REPLICA_PUSH: Label = Label::new(b"CC/v1/replica-push");
553 /// Replica signature on an accepted snapshot (E2, B4,
554 /// `oc_protocol::replica::Ack`). Separate from `replica-push`: different
555 /// parties sign, and one's signature must not serve as the other's.
556 pub const REPLICA_ACK: Label = Label::new(b"CC/v1/replica-ack");
557 /// Controller signature transferring authority to a successor (E2, B7,
558 /// `oc_protocol::control::Transfer`). Separate from `control-request`:
559 /// an intent changes binding within an epoch; a transfer changes the epoch,
560 /// and a signature on one must not work for the other.
561 pub const AUTHORITY_TRANSFER: Label = Label::new(b"CC/v1/authority-transfer");
562 pub const CHUNK: Label = Label::new(b"CC/v1/chunk");
563 pub const LEAF: Label = Label::new(b"CC/v1/leaf");
564 pub const NODE: Label = Label::new(b"CC/v1/node");
565
566 pub const KEK: Label = Label::new(b"CC/v1/kek");
567 pub const PAYLOAD: Label = Label::new(b"CC/v1/payload");
568 pub const NONCE_BASE: Label = Label::new(b"CC/v1/nonce-base");
569 pub const PRIVATE_META: Label = Label::new(b"CC/v1/private-meta");
570 /// DEVICE keypair derived from a claim code.
571 ///
572 /// # Why a third code-related label was needed when two already existed
573 ///
574 /// `SLOT_B_CLAIM` derives share B directly from the code: that is how a
575 /// `RecipientClaim` slot works, correctly in that case: the recipient's share can be
576 /// anything, provided both parties derive the same value.
577 ///
578 /// For a code-based heir the share is FIXED: this file's share B, stored
579 /// in the author slot. It cannot be derived from an arbitrary code: derivation produces
580 /// what it produces. The code therefore derives a KEYPAIR, not a share, and the bequest
581 /// is sealed to its public key with ordinary `seal`, just as for any
582 /// device. No new primitives: the same HKDF, the same X25519, the
583 /// same `seal`.
584 ///
585 /// The label is separate and must remain so: using `SLOT_B_CLAIM` with the same
586 /// `ikm` would yield a private key equal to the slot share, so a code
587 /// opening one file would reveal the key used to sign another.
588 pub const CLAIM_DEVICE: Label = Label::new(b"CC/v1/claim-device");
589 pub const SLOT_B_CLAIM: Label = Label::new(b"CC/v1/slot-b-claim");
590 pub const SLOT_B_COMMIT: Label = Label::new(b"CC/v1/slot-b-commit");
591 pub const SLOT_COMMIT: Label = Label::new(b"CC/v1/slot-commit");
592 pub const A_TO_DEVICE: Label = Label::new(b"CC/v1/a-to-device");
593 /// Share B sent FROM THE AUTHOR to the recipient's device.
594 ///
595 /// A distinct domain from [`A_TO_DEVICE`], not symmetry for its own sake:
596 /// DIFFERENT parties issue shares under different decisions. If domains matched, a block
597 /// issued by the server could stand in for an author block, or vice versa.
598 ///
599 /// Prefix-free relative to `a-to-device` and all others (I-12): their initial
600 /// bytes differ.
601 pub const B_TO_DEVICE: Label = Label::new(b"CC/v1/b-to-device");
602 /// Deliberately not `"CC/v1/lease-cache"`: that string would extend
603 /// [`LEASE`], while labels also prefix HKDF `info`, with no
604 /// separating zero byte. The label set must be prefix-free.
605 pub const CACHED_LEASE: Label = Label::new(b"CC/v1/cached-lease");
606 pub const SEAL_KEY: Label = Label::new(b"CC/v1/seal-key");
607 /// Slot-sealing nonce hedging (§3.3).
608 ///
609 /// A **nonce** derivation label: its introduction clarifies rather than abandons
610 /// "nonces are stored, not derived". The reader still takes the nonce
611 /// **without computing it**, from the slot record. The sender derives it,
612 /// solely to stop the value being a pure function of
613 /// RNG state. See [`crate::kdf::hedged_nonce`].
614 pub const SEAL_NONCE: Label = Label::new(b"CC/v1/seal-nonce");
615 /// CEK-wrapper nonce hedging (§3.1). Same purpose as [`SEAL_NONCE`].
616 pub const WRAP_NONCE: Label = Label::new(b"CC/v1/wrap-nonce");
617 /// Payload-frame nonce hedging (§6.1).
618 ///
619 /// The fourth label serving this purpose; its later appearance was not about
620 /// completeness: decision C-13 was applied to slot sealing and CEK wrapping,
621 /// while two nonces, frame and private metadata, still took bytes directly
622 /// from the RNG. The same hole, simply in less conspicuous places.
623 ///
624 /// Deliberately **not** `"CC/v1/chunk-nonce"`: that string would extend the
625 /// [`CHUNK`] label, and labels also prefix HKDF `info`, where no
626 /// zero byte separates them: `"CC/v1/chunk"‖"-nonce"‖X` would equal
627 /// `"CC/v1/chunk-nonce"‖X`. Exactly the same case as [`CACHED_LEASE`], caught
628 /// by the same prefix-freeness test. Hence "frame" rather than "chunk":
629 /// the nonce belongs to the on-disk frame, not the logical chunk.
630 pub const FRAME_NONCE: Label = Label::new(b"CC/v1/frame-nonce");
631 /// Private-metadata nonce hedging (§2.0).
632 ///
633 /// Repeated RNG state cost more here than for a chunk: `CEK` and
634 /// `header_salt` come from the same RNG, so snapshot rollback
635 /// repeated both the K5 key and nonce while plaintexts (filename, size)
636 /// differed. This reuses the keystream and repeats the one-time
637 /// Poly1305 key inside the author-signed header.
638 pub const META_NONCE: Label = Label::new(b"CC/v1/meta-nonce");
639 /// Header-core hash: everything except key material.
640 pub const CORE_HASH: Label = Label::new(b"CC/v1/core-hash");
641 /// Policy hash over its byte range.
642 pub const POLICY_HASH: Label = Label::new(b"CC/v1/policy-hash");
643
644 /// Slot purpose: license-server share.
645 ///
646 /// Each slot kind has its own label in the sealing `info`.
647 /// Consequently, ciphertext addressed to the server does not open as ciphertext
648 /// addressed to the author's device, even if both are sealed to the same key.
649 pub const SLOT_SERVER: Label = Label::new(b"CC/v1/slot-server");
650 /// Slot purpose: recipient share.
651 pub const SLOT_RECIPIENT: Label = Label::new(b"CC/v1/slot-recipient");
652 /// Slot purpose: both shares for the author's device.
653 pub const SLOT_AUTHOR_DEVICE: Label = Label::new(b"CC/v1/slot-author-device");
654
655 /// Claim-code text → `claim_secret` (§3.4).
656 ///
657 /// Lives here although used in the client: the system has one label registry,
658 /// and declaring a label outside it breaks the only available prefix-freeness
659 /// guarantee, the test over [`ALL`]. There would be nothing to check it with,
660 /// precisely because the list is complete.
661 ///
662 /// The difference from [`SLOT_B_CLAIM`] matters: that label derives a **share** from
663 /// an existing 32-byte secret, while this one transforms
664 /// **printed text** into that secret. Different inputs, different domains.
665 pub const CLAIM_CODE: Label = Label::new(b"CC/v1/claim-code");
666 /// An author's wire instruction to the server to register or revoke a file.
667 /// Signed by the author key, the same one that signed the header.
668 pub const AUTHOR_ORDER: Label = Label::new(b"CC/v1/author-order");
669
670 /// Proof of opening a secret challenge (K23).
671 ///
672 /// **Unused by the protocol since 2026-09-21** (`docs/format.md`, section
673 /// "ECHO BOUND TO THE CONVERSATION 2026-09-21"): K31 derives the echo from
674 /// the handshake transcript. The label and derivation remain for the frozen
675 /// `k23_prove_echo` vector in `derivations_wire.kat`: I-14 forbids changing
676 /// frozen artifacts, and removing the label from the registry would remove
677 /// the tested domain beneath the vector.
678 pub const PROVE_ECHO: Label = Label::new(b"CC/v1/prove-echo");
679 /// Request MAC key after proof (K24).
680 pub const SESSION_MAC: Label = Label::new(b"CC/v1/session-mac");
681 /// Conversation-bound proof-of-possession echo (K31,
682 /// `docs/protocol.md` §9.4, decision 2026-09-21).
683 ///
684 /// One label for both steps of ONE derivation: it labels the handshake
685 /// transcript and also separates the HMAC domain whose key is the challenge
686 /// secret. There is no second domain, only "echo over transcript";
687 /// the label in the HMAC message prevents an echo colliding with K23 under
688 /// the same key.
689 ///
690 /// **The name deliberately does not extend `"CC/v1/prove-echo"`**: this set is
691 /// prefix-free, and `"CC/v1/prove-echo-bound"` would extend an already
692 /// occupied label, exactly what I-12 forbids. The nearest `e` neighbors,
693 /// `"CC/v1/editor-sig"`, `"CC/v1/editor-cert"`, `"CC/v1/edit-session"`,
694 /// and `"CC/v1/edition-claim"`, differ at byte eight (`c` versus
695 /// `d`).
696 pub const ECHO_TRANSCRIPT: Label = Label::new(b"CC/v1/echo-transcript");
697
698 /// Device fingerprint for mechanisms whose public keys exceed 32 bytes
699 /// (K27). For X25519 the fingerprint IS the key, and this label is unused:
700 /// that form is frozen by K11, K21, K23, and K24 vectors.
701 pub const DEVICE_FPR: Label = Label::new(b"CC/v1/device-fpr");
702
703 /// Wire operation identity (K28, `docs/protocol.md` §9.10).
704 ///
705 /// The identifier derives from a seed and the request body rather than coming directly
706 /// from the RNG, for the same reason as nonce hedging (C-13): RNGs
707 /// repeat after snapshot rollback and image cloning, and a server would treat two DIFFERENT
708 /// requests with one identifier as one request, giving the second
709 /// someone else's "issued" outcome.
710 pub const OPERATION_ID: Label = Label::new(b"CC/v1/operation-id");
711 /// Fresh value generated by the SERVER (K30, `docs/format.md`, section
712 /// "SERVER FRESHNESS IS DERIVED 2026-09-20").
713 ///
714 /// One label serves two purposes, attestation challenge (§9.11.1) and
715 /// proof-of-possession secret (§9.4), separated by `kind` inside the preimage,
716 /// as with [`OPERATION_ID`]. A second label would create a second domain where
717 /// there is only one: a server freshness value.
718 ///
719 /// Prefix-free: `"CC/v1/seal-key"`, `"CC/v1/seal-nonce"`, and
720 /// `"CC/v1/session-mac"` differ at byte eight, and no registry label
721 /// is its prefix.
722 pub const SERVER_FRESH: Label = Label::new(b"CC/v1/server-fresh");
723
724 /// Publisher-key signature on the DISTRIBUTION PACKAGE manifest (`manifest.txt` in a
725 /// Close Crate package, verified by `cc install`).
726 ///
727 /// # Why a label rather than "sign the manifest bytes"
728 ///
729 /// Because the publisher key is ordinary Ed25519, and without a domain its signature over
730 /// arbitrary bytes would work anywhere the same key verifies
731 /// something else. A distribution manifest has its own domain: it is neither a container,
732 /// protocol document, nor operator file, but an inventory of programs in an archive.
733 ///
734 /// The label does not affect the container at all: it changes no format version and enters no
735 /// header. It belongs in the registry not because the format needs it,
736 /// but because the registry is the ONLY place prefix-freeness is proved
737 /// (I-12): a label declared elsewhere is checked by nothing.
738 ///
739 /// Prefix-free: the nearest `p` labels are `"CC/v1/payload"`,
740 /// `"CC/v1/private-meta"`, `"CC/v1/policy-hash"`, and `"CC/v1/prove-echo"`;
741 /// all differ at byte eight; `"CC/v1/recovery-manifest"`
742 /// shares only a suffix, not a prefix.
743 pub const PACKAGE_MANIFEST: Label = Label::new(b"CC/v1/package-manifest");
744
745 /// K29: qualifying data for a TPM statement about the device key (B6a,
746 /// `docs/protocol.md` §9.11): `extraData` in `TPMS_ATTEST`.
747 ///
748 /// Separate from [`ATTEST_NONCE`]: that already serves as `info` when sealing a proof-of-possession
749 /// challenge (§9.4), and one label for two applications leaves
750 /// an unseparated domain (I-12). Prefix-free: `"CC/v1/attest-nonce"` is not
751 /// a prefix of this string, nor vice versa.
752 pub const ATTEST_QUALIFY: Label = Label::new(b"CC/v1/attest-qualify");
753
754 /// All labels in one list for the uniqueness test.
755 ///
756 /// The list remains the SOLE source for the four I-12 probes even after
757 /// [`Label`] was introduced: the type guards call sites, the list guards
758 /// the registry's contents. Neither check replaces the other.
759 pub const ALL: &[Label] = &[
760 HEADER_SIG, REVOCATION, GRANT, LEASE, ACTIVATE_REQ, AUDIT_ENTRY, AUDIT_HEAD,
761 ATTEST_NONCE,
762 CONTENT_MAC, EDITOR_SIG, CHUNK, LEAF, NODE, KEK, PAYLOAD, NONCE_BASE,
763 PRIVATE_META, CLAIM_DEVICE, SLOT_B_CLAIM, SLOT_B_COMMIT, SLOT_COMMIT, A_TO_DEVICE,
764 B_TO_DEVICE,
765 CACHED_LEASE,
766 SEAL_KEY, SEAL_NONCE, WRAP_NONCE, FRAME_NONCE, META_NONCE, CORE_HASH, POLICY_HASH,
767 SLOT_SERVER, SLOT_RECIPIENT, SLOT_AUTHOR_DEVICE, CLAIM_CODE, AUTHOR_ORDER, PROVE_ECHO, SESSION_MAC,
768 ECHO_TRANSCRIPT,
769 DEVICE_FPR, OPERATION_ID, ATTEST_QUALIFY, EDITOR_CERT, EDIT_SESSION, EDITION_CLAIM, FOOTER_IMPRINT,
770 WITNESS_COSIGN, DIRECTORY_ENTRY, DIRECTORY_HEAD, MARK_LAYOUT, MARK_CHOICE,
771 RECOVERY_MANIFEST, AUTHORITY_BINDING, CONTROL_REQUEST, OPERATION_RECEIPT,
772 REPLICA_PUSH, REPLICA_ACK, AUTHORITY_TRANSFER,
773 SERVER_FRESH, PACKAGE_MANIFEST,
774 AGENT_GRANT, DELEGATION,
775 ACTION_GRANT, ACTION_LEASE, ACTION_DECISION,
776 ];
777}
778
779/// Minimum claim-code entropy.
780///
781/// Not a cosmetic number. XChaCha20-Poly1305 is not key-committing, and without
782/// slot-commitment verification a low-entropy code can be recovered through
783/// a partitioning oracle substantially faster than exhaustive search. A six-digit code
784/// is unacceptable.
785///
786/// The boundary is checked **at build time**, not runtime, and could not be otherwise.
787/// `ClaimSecret::from_bytes` accepts any 32 bytes and must: by then
788/// the code has been hash-compressed and the result does not reveal entropy. It must be measured
789/// at generation, where it is exactly measurable: code length and alphabet size
790/// are known constants. The check lives in `cc_cli::claim` (`const _: () =
791/// assert!(...)`), so a short code will not "fail a test"; it will not compile.
792///
793/// Human-invented codes are absent from the product for the same reason: the commitment
794/// is plaintext in the container, guessing is offline, and attempt counts
795/// cannot be limited, since the guesses are not made against us.
796pub const MIN_CLAIM_BITS: u32 = 128;
797
798/// SHA-256 of a byte slice.
799///
800/// # Why a shared function when format hashes are computed in `oc-format`
801///
802/// Because not everything that needs hashing is format data. A distribution manifest
803/// lists programs and checksums; it is not a container, has no format versions,
804/// and creating a tag-registry entry for it would be a mistake.
805///
806/// It lives here rather than in `cc-cli` under the crate rules: `sha2` is already a dependency
807/// of this crate, and a second edge to `cc-cli` would introduce a direct
808/// dependency where none is needed. This crate remains pure:
809/// no I/O, clocks, or RNGs here.
810///
811/// Compare results only through [`digest_eq`].
812#[must_use]
813pub fn sha256(bytes: &[u8]) -> [u8; 32] {
814 use sha2::Digest as _;
815 sha2::Sha256::digest(bytes).into()
816}
817
818/// Compare two 32-byte digests in constant time.
819///
820/// The only way to compare hashes, roots, fingerprints, and commitments throughout the
821/// repository, a rule rather than an optimization. Some values are public
822/// (the tree root is plaintext in the file), some are not (the slot commitment), and
823/// the boundary moves over time: `original_root` is public today, but
824/// becomes an address in detached mode. Permitting "ordinary `==` here
825/// because the value is public" would oblige us to prove that again with
826/// every change, and eventually prove it incorrectly.
827///
828/// Comparison accepts fixed-length arrays rather than slices: a length that can
829/// be confused is a second way to err, which is unnecessary here.
830#[must_use]
831pub fn digest_eq(a: &[u8; 32], b: &[u8; 32]) -> bool {
832 use subtle::ConstantTimeEq;
833 bool::from(a.ct_eq(b))
834}
835
836/// Compare two PUBLIC keys of possibly different lengths in constant time.
837///
838/// A separate function, not [`digest_eq`] weakened to slices; this distinction is essential.
839/// The rule "comparison accepts fixed-length arrays" prevents
840/// length from becoming a second source of errors; extending the exception to every
841/// comparison in the repository would abolish that rule. Exactly one circumstance
842/// justifies this exception: key length is a function of the mechanism (`kem_id`), differing for
843/// X25519 and P-256, and both comparison operands come from the **author-signed**
844/// header, where length is public by construction.
845///
846/// Length mismatch returns `false` immediately, before comparing contents, without leaking
847/// anything: the length is a public number in the file, already known to an attacker. This means
848/// "the slot is not for our mechanism", hence "not our slot", exactly the same as
849/// mismatching key bytes.
850///
851/// Contents are compared using `subtle` (I-13): the key is public, but response timing
852/// must not reveal how many bytes matched, or a byte-by-byte oracle for guessing
853/// the slot's recipient would emerge.
854#[must_use]
855pub fn public_key_eq(a: &[u8], b: &[u8]) -> bool {
856 use subtle::ConstantTimeEq;
857 if a.len() != b.len() {
858 return false;
859 }
860 bool::from(a.ct_eq(b))
861}
862
863#[cfg(test)]
864#[allow(clippy::unwrap_used, clippy::panic)]
865mod tests {
866 use super::*;
867 use std::collections::BTreeSet;
868
869 #[test]
870 fn digest_comparison_agrees_with_equality_on_every_byte_position() {
871 // Проверяется не «работает ли ct_eq» — за это отвечает subtle, — а то, что
872 // единый способ сравнения не разошёлся с обычным равенством ни в одном
873 // разряде. Ошибка вида «сравнили первые 16 байт» прошла бы мимо теста на
874 // паре случайных значений, но не мимо перебора позиций.
875 let base = [0xA5u8; 32];
876 assert!(digest_eq(&base, &base.clone()));
877 for position in 0..32usize {
878 let mut other = base;
879 if let Some(byte) = other.get_mut(position) {
880 *byte ^= 0x80;
881 }
882 assert!(!digest_eq(&base, &other), "различие в байте {position} не замечено");
883 }
884 }
885
886 #[test]
887 fn every_domain_label_is_unique() {
888 // Повторно использованная метка — тихая уязвимость: подпись из одного
889 // контекста начинает приниматься в другом. Дешевле поймать тестом.
890 // Сравниваются БАЙТЫ, а не значения `Label`: две константы с одной и той
891 // же строкой — это и есть повтор домена, и `Label` их не различает лишь
892 // потому, что различать там нечего. Сверка по байтам оставляет проверку
893 // той же, какой она была до появления типа.
894 let unique: BTreeSet<&[u8]> = label::ALL.iter().map(|l| l.as_bytes()).collect();
895 assert_eq!(unique.len(), label::ALL.len(), "метки домена повторяются");
896 }
897
898 #[test]
899 fn no_label_is_a_prefix_of_another() {
900 // Префиксная метка позволяет столкнуть кодировки: "CC/v1/lease" и
901 // "CC/v1/lease-cache" различаются только тем, что идёт дальше.
902 // Разделитель 0x00 в транскрипте закрывает это, тест фиксирует намерение.
903 for a in label::ALL.iter().map(|l| l.as_bytes()) {
904 for b in label::ALL.iter().map(|l| l.as_bytes()) {
905 if a != b {
906 assert!(!b.starts_with(a) || b.len() == a.len(), "метка {a:?} — префикс {b:?}");
907 }
908 }
909 }
910 }
911
912 #[test]
913 fn every_domain_label_is_versioned() {
914 for l in label::ALL.iter().map(|l| l.as_bytes()) {
915 assert!(
916 l.starts_with(b"CC/v1/"),
917 "метка {:?} без версии: при переходе на v2 её нельзя будет отличить",
918 core::str::from_utf8(l).unwrap_or("<не utf8>")
919 );
920 }
921 }
922
923 #[test]
924 fn algorithm_ids_are_stable_numbers() {
925 // Значения входят в подписанный транскрипт, поэтому их нельзя менять
926 // местами при рефакторинге: старые файлы перестанут проверяться.
927 assert_eq!(AeadAlg::XChaCha20Poly1305 as u8, 1);
928 assert_eq!(SigAlg::Ed25519 as u8, 1);
929 assert_eq!(KemAlg::X25519HkdfSha256 as u8, 1);
930 assert_eq!(KemAlg::P256HkdfSha256 as u8, 2);
931 assert_eq!(KemAlg::XWing as u8, 4);
932 assert_eq!(KemAlg::MlKem768P256 as u8, 5);
933 assert_eq!(TreeHashAlg::Blake3 as u8, 1);
934 }
935
936 #[test]
937 fn unknown_algorithm_ids_are_refused_not_defaulted() {
938 for v in [0u8, 6, 99, 255] {
939 assert_eq!(AeadAlg::from_u8(v), Err(CryptoError::UnsupportedAlgorithm));
940 assert!(KemAlg::from_u8(v).is_err(), "неизвестный kem_id {v} принят");
941 }
942 // ЧЕТВЁРКА ОТСЮДА УБРАНА, и это тот же случай, что с `SigAlg::from_u8(2)`
943 // ниже. Она перестала быть неизвестным номером: её занял гибрид X-Wing
944 // решением версии 4. Оставь её здесь — и проба утверждала бы, что формат
945 // четвёртого механизма не знает, ровно тогда, когда он заработал.
946 assert!(KemAlg::from_u8(4).is_ok(), "четвёрка занята гибридом X-Wing");
947 // Пятёрка ушла отсюда по той же причине, что и четвёрка до неё: её занял
948 // аппаратный гибрид MLKEM768-P256 решением версии 5. Список неизвестных
949 // номеров тает с каждым занятым, и это нормально — он про НЕЗАНЯТЫЕ.
950 assert!(KemAlg::from_u8(5).is_ok(), "пятёрка занята аппаратным гибридом");
951 // `SigAlg::from_u8(2)` ЗДЕСЬ БОЛЬШЕ НЕ ПРОВЕРЯЕТСЯ, и это не упущение.
952 // Двойка перестала быть неизвестным номером: она занята RSA-PSS решением
953 // версии 3. Отказ остался тем же, но означает другое — «занято, не
954 // исполняется», — и проба под именем «неизвестные номера» утверждала бы
955 // неправду. Перенесено ниже, к своим соседям.
956 assert_eq!(SigAlg::from_u8(3), Err(CryptoError::UnsupportedAlgorithm));
957 }
958
959 #[test]
960 fn both_signature_algorithms_are_executable_and_numbered_stably() {
961 // Обе схемы сборка теперь исполняет: Ed25519 подписывает автор, RSA-PSS
962 // — редактировавшее устройство. Номера входят в подписанный транскрипт
963 // (`verify::suite_id`), поэтому меняться местами не вправе.
964 //
965 // «Умеем исполнить» — не то же, что «годится здесь»: `suite.sig_alg`
966 // допускает только Ed25519, и эту проверку ставит разбор заголовка. Она
967 // проверяется там же, у своего места, а не здесь.
968 assert_eq!(SigAlg::Ed25519 as u8, 1);
969 assert_eq!(SigAlg::RsaPssSha256 as u8, 2);
970 assert_eq!(SigAlg::from_u8(1), Ok(SigAlg::Ed25519));
971 assert_eq!(SigAlg::from_u8(2), Ok(SigAlg::RsaPssSha256));
972 assert_eq!(SigAlg::Ed25519.ensure_supported(), Ok(()));
973 assert_eq!(SigAlg::RsaPssSha256.ensure_supported(), Ok(()));
974 }
975
976 #[test]
977 fn an_aead_id_this_build_cannot_execute_is_refused_at_parse_time() {
978 // Симметрично TreeHashAlg. Номера 2 и 3 в реестре формата существуют
979 // (docs/format.md §6.1), но профилей AES в сборке нет, поэтому отказ
980 // обязан приходить на разборе, а не на первом чанке — иначе подпись,
981 // слот, согласование ключей и разворот CEK делаются впустую над файлом,
982 // про который уже всё известно.
983 for v in [2u8, 3] {
984 assert_eq!(
985 AeadAlg::from_u8(v),
986 Err(CryptoError::UnsupportedAlgorithm),
987 "aead_id {v} принят разбором, хотя исполнять его нечем"
988 );
989 }
990 assert_eq!(AeadAlg::from_u8(1), Ok(AeadAlg::XChaCha20Poly1305));
991 }
992}