Skip to main content

oc_crypto/
lib.rs

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