Skip to main content

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}