Skip to main content

metamorphic_log/
policy.rs

1//! Layer-0: the per-namespace **`NamespacePolicy`** — a signed, in-log,
2//! versioned record that declares a namespace's cryptographic posture, plus the
3//! **declared == observed** enforcement that rejects any artifact whose
4//! *observed* posture disagrees with the *declared* one (no silent downgrade).
5//!
6//! ## What this layer is (and is not)
7//!
8//! Per the project's invariant wall (#290 / #299 / #324), the Layer-1 substrate
9//! — the SHA-256 tree-node hash, the canonical leaf byte layout, and the
10//! RFC 6962 / RFC 9162 proof protocol — is **fixed and audited** so independent
11//! witnesses can recompute every root *without knowing anything about a
12//! namespace's suite*. A `NamespacePolicy` never touches those bytes. It lives
13//! at the metadata layer the scoping doc (#324) defines as the **only** legal
14//! flexibility point: a namespace's selectable post-quantum posture
15//! (checkpoint-signature suite/level, commitment-hash strength, VRF privacy
16//! mode). A suite-unaware verifier still recomputes every root; suite-awareness
17//! only *adds* enforcement of the PQ/privacy artifacts layered around the
18//! unchanged canonical bytes.
19//!
20//! ## The record
21//!
22//! [`NamespacePolicy`] is itself a canonical, byte-disciplined Layer-0 leaf
23//! (mirroring [`crate::leaf`]'s grammar: `u32`-be length prefixes, `u64`-be
24//! integers, big-endian, never reordered). Its fields (#324 Q1):
25//!
26//! - `namespace` — the per-tenant [`crate::coniks::Namespace`] this policy
27//!   governs (immutable identity of the directory).
28//! - `policy_schema_version` (`u32`) — the version of this record, **also** the
29//!   migration sequence number (each migration is `+1`; see [`PolicyChain`]).
30//! - `security_level` ([`SecurityLevel`]) — `Cat3` / `Cat5`; a forced explicit
31//!   choice (the SDK suggests `Cat5`).
32//! - `checkpoint_suite` ([`CheckpointSuite`]) — `Hybrid` (default) /
33//!   `HybridMatched` / `PureCnsa2`; the orthogonal CNSA-posture knob (#312).
34//! - `commitment_hash` ([`CommitmentHash`]) — `Sha3_256` (Cat-3) / `Sha3_512`
35//!   (Cat-5), **derived** from the level under the bundle but stored explicitly
36//!   so a future expert/decoupled mode is a non-breaking read.
37//! - `vrf_mode` ([`VrfMode`]) — `Classical` (default; the **only** legal value
38//!   in v0.1, per #304), with `HybridOutput` / `PurePqExperimental` scoped but
39//!   not yet built.
40//! - `effective_from` (`u64`) — the tree size / checkpoint index at which this
41//!   version takes force (the epoch boundary).
42//! - `created_at` (`u64`) — informational Unix-ms timestamp; ordering authority
43//!   is `effective_from` + log position, never wall-clock.
44//! - `prev_policy_hash` — the 64-byte SHA3-512 [`NamespacePolicy::policy_hash`]
45//!   of the prior version, or `None` for the genesis version (the chain link).
46//!
47//! ## Signed, in-log, versioned
48//!
49//! A policy is published as a [`SignedPolicy`]: the canonical record bytes
50//! signed by the namespace **root signing key** via the same single-source-of-
51//! truth composite primitive ([`metamorphic_crypto::sign`] /
52//! [`metamorphic_crypto::verify`]) that backs the Slice-3 hybrid checkpoint note
53//! line, under the versioned context label `<namespace>/namespace-policy/v1`.
54//! The root key is pinned TOFU on first contact (same trust-bootstrap as the
55//! #291/#315 signed key-history; the log provides *continuity*, not first-
56//! contact trust). Because ML-DSA signing is hedged, the signature **bytes are
57//! not reproducible**, but **verification is deterministic** — so the KATs lock
58//! the deterministic verifying key and canonical bytes, not signature bytes.
59//!
60//! ## Immutability + versioned migration
61//!
62//! A policy is immutable within its version. A change is a **new** version that
63//! chains to the prior one via `prev_policy_hash`, with a strictly greater
64//! `effective_from` and `policy_schema_version + 1`. [`PolicyChain`] holds the
65//! ordered list, validates the chain, and enforces that migrations may only
66//! **strengthen** posture (a weakening is [`Error::PolicyMigrationRejected`]).
67//! Each version owns the half-open range `[effective_from_n, effective_from_{n+1})`,
68//! and [`PolicyChain::active_at`] resolves the policy in force at a tree
69//! position (the authority for what a namespace *required* there).
70//!
71//! ## Declared == observed (the headline)
72//!
73//! [`NamespacePolicy::enforce_checkpoint_signing_key`] /
74//! [`NamespacePolicy::enforce_checkpoint_signature`] map an observed checkpoint
75//! hybrid key/signature to its `(Suite, SignatureLevel)` via the v0.8.1
76//! [`metamorphic_crypto::signature_posture`] /
77//! [`metamorphic_crypto::signature_posture_from_signature`] accessors and
78//! compare it to the declared posture; [`NamespacePolicy::enforce_vrf_suite_id`]
79//! checks the Slice-4 CONIKS [`crate::vrf::Vrf::suite_id`] (#332); and
80//! [`NamespacePolicy::enforce_commitment_hash`] checks the commitment parameter.
81//! Any mismatch is [`Error::PostureMismatch`] — a hard rejection. This crate
82//! re-derives **no** private crypto wire tags; it only *consumes* the typed,
83//! opaque metamorphic-crypto accessors.
84//!
85//! ## Honest framing
86//!
87//! This makes a namespace's posture **verifiable**, not stronger. It is a safe
88//! menu with safe defaults (classical-default hybrid); customers cannot select
89//! free-form or silently-weaker posture, and any downgrade is logged and
90//! rejected. The primitives are not FIPS-validated and this project makes no
91//! FIPS-validation claim.
92
93use metamorphic_crypto::{SignatureLevel, Suite};
94
95use crate::coniks::Namespace;
96use crate::error::{Error, Result};
97use crate::leaf::{ContextLabel, content_hash};
98use crate::merkle::{Hash, hash_leaf};
99
100/// The fixed canonical byte-layout version of the [`NamespacePolicy`] record
101/// (the discipline version, distinct from the per-record
102/// [`NamespacePolicy::policy_schema_version`]). A layout change is a new value
103/// here, never a silent reinterpretation.
104pub const POLICY_FORMAT_VERSION: u32 = 1;
105
106/// The fixed canonical byte-layout version of the [`SignedPolicy`] envelope.
107pub const SIGNED_POLICY_FORMAT_VERSION: u32 = 1;
108
109/// Length of a [`NamespacePolicy::policy_hash`] (a SHA3-512 digest), in bytes.
110pub const POLICY_HASH_LEN: usize = 64;
111
112/// The bundled NIST security level for a namespace's posture (#324 Q3).
113///
114/// `Cat3` and `Cat5` are the v0.1 menu; `security_level` is a forced explicit
115/// choice at namespace creation (no default). The level selects the ML-DSA
116/// parameter set for checkpoint signatures and, under the bundle, the
117/// [`CommitmentHash`] strength.
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
119pub enum SecurityLevel {
120    /// NIST Category 3 (ML-DSA-65; ~AES-192). Bundles [`CommitmentHash::Sha3_256`].
121    Cat3,
122    /// NIST Category 5 (ML-DSA-87; ~AES-256). Bundles [`CommitmentHash::Sha3_512`].
123    Cat5,
124}
125
126impl SecurityLevel {
127    const TAG_CAT3: u8 = 0x03;
128    const TAG_CAT5: u8 = 0x05;
129
130    fn tag(self) -> u8 {
131        match self {
132            SecurityLevel::Cat3 => Self::TAG_CAT3,
133            SecurityLevel::Cat5 => Self::TAG_CAT5,
134        }
135    }
136
137    fn from_tag(tag: u8) -> Result<Self> {
138        match tag {
139            Self::TAG_CAT3 => Ok(SecurityLevel::Cat3),
140            Self::TAG_CAT5 => Ok(SecurityLevel::Cat5),
141            other => Err(Error::MalformedPolicy(format!(
142                "unknown security_level tag 0x{other:02x}"
143            ))),
144        }
145    }
146
147    /// Monotone posture rank (higher is stronger), used by migration checks.
148    fn rank(self) -> u8 {
149        match self {
150            SecurityLevel::Cat3 => 0,
151            SecurityLevel::Cat5 => 1,
152        }
153    }
154
155    /// The metamorphic-crypto [`SignatureLevel`] this level maps to for
156    /// declared == observed checkpoint-posture enforcement.
157    #[must_use]
158    pub fn signature_level(self) -> SignatureLevel {
159        match self {
160            SecurityLevel::Cat3 => SignatureLevel::Cat3,
161            SecurityLevel::Cat5 => SignatureLevel::Cat5,
162        }
163    }
164
165    /// The [`CommitmentHash`] derived from this level under the v0.1 bundle.
166    #[must_use]
167    pub fn derived_commitment_hash(self) -> CommitmentHash {
168        match self {
169            SecurityLevel::Cat3 => CommitmentHash::Sha3_256,
170            SecurityLevel::Cat5 => CommitmentHash::Sha3_512,
171        }
172    }
173}
174
175/// The additive PQ **checkpoint-signature suite** a namespace declares (#312 /
176/// #324 Q2). Orthogonal to [`SecurityLevel`]: `Hybrid` is the default and
177/// strict-AND backstop; `PureCnsa2` is the pure-PQ CNSA-2.0 box (Cat-5 only).
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
179pub enum CheckpointSuite {
180    /// Default: classical + PQ strict-AND composite (the #312 default).
181    Hybrid,
182    /// Classical partner matched to the PQ category (Ed448 at Cat-3, P-521 at
183    /// Cat-5).
184    HybridMatched,
185    /// Pure post-quantum, no classical half (CNSA 2.0). Legal only at Cat-5.
186    PureCnsa2,
187}
188
189impl CheckpointSuite {
190    const TAG_HYBRID: u8 = 0x01;
191    const TAG_HYBRID_MATCHED: u8 = 0x02;
192    const TAG_PURE_CNSA2: u8 = 0x03;
193
194    fn tag(self) -> u8 {
195        match self {
196            CheckpointSuite::Hybrid => Self::TAG_HYBRID,
197            CheckpointSuite::HybridMatched => Self::TAG_HYBRID_MATCHED,
198            CheckpointSuite::PureCnsa2 => Self::TAG_PURE_CNSA2,
199        }
200    }
201
202    fn from_tag(tag: u8) -> Result<Self> {
203        match tag {
204            Self::TAG_HYBRID => Ok(CheckpointSuite::Hybrid),
205            Self::TAG_HYBRID_MATCHED => Ok(CheckpointSuite::HybridMatched),
206            Self::TAG_PURE_CNSA2 => Ok(CheckpointSuite::PureCnsa2),
207            other => Err(Error::MalformedPolicy(format!(
208                "unknown checkpoint_suite tag 0x{other:02x}"
209            ))),
210        }
211    }
212
213    /// The metamorphic-crypto [`Suite`] this maps to for declared == observed
214    /// checkpoint-posture enforcement.
215    #[must_use]
216    pub fn crypto_suite(self) -> Suite {
217        match self {
218            CheckpointSuite::Hybrid => Suite::Hybrid,
219            CheckpointSuite::HybridMatched => Suite::HybridMatched,
220            CheckpointSuite::PureCnsa2 => Suite::PureCnsa2,
221        }
222    }
223}
224
225/// The Layer-3 **commitment-hash strength** (#324 Q3), derived from
226/// [`SecurityLevel`] under the v0.1 bundle but stored explicitly.
227#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
228pub enum CommitmentHash {
229    /// SHA3-256 (Cat-3 bundle).
230    Sha3_256,
231    /// SHA3-512 (Cat-5 bundle).
232    Sha3_512,
233}
234
235impl CommitmentHash {
236    const TAG_SHA3_256: u8 = 0x01;
237    const TAG_SHA3_512: u8 = 0x02;
238
239    fn tag(self) -> u8 {
240        match self {
241            CommitmentHash::Sha3_256 => Self::TAG_SHA3_256,
242            CommitmentHash::Sha3_512 => Self::TAG_SHA3_512,
243        }
244    }
245
246    fn from_tag(tag: u8) -> Result<Self> {
247        match tag {
248            Self::TAG_SHA3_256 => Ok(CommitmentHash::Sha3_256),
249            Self::TAG_SHA3_512 => Ok(CommitmentHash::Sha3_512),
250            other => Err(Error::MalformedPolicy(format!(
251                "unknown commitment_hash tag 0x{other:02x}"
252            ))),
253        }
254    }
255
256    fn rank(self) -> u8 {
257        match self {
258            CommitmentHash::Sha3_256 => 0,
259            CommitmentHash::Sha3_512 => 1,
260        }
261    }
262}
263
264/// The Layer-3 **VRF privacy mode** (#324 Q3 / #304). In v0.1 only `Classical`
265/// is legal; `HybridOutput` and `PurePqExperimental` are scoped for the future
266/// hybrid path but rejected as malformed until that path is built.
267#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
268pub enum VrfMode {
269    /// Classical ECVRF-edwards25519 (RFC 9381 ciphersuite `0x03`); the v0.1
270    /// default and only legal value.
271    Classical,
272    /// Designed-in hybrid output combiner (classical || PQ via SHA3-512). Scoped
273    /// but **not** legal in v0.1 (no audited lattice VRF — #304).
274    HybridOutput,
275    /// Experimental pure-PQ VRF. Scoped but **not** legal in v0.1.
276    PurePqExperimental,
277}
278
279impl VrfMode {
280    const TAG_CLASSICAL: u8 = 0x01;
281    const TAG_HYBRID_OUTPUT: u8 = 0x02;
282    const TAG_PURE_PQ: u8 = 0x03;
283
284    fn tag(self) -> u8 {
285        match self {
286            VrfMode::Classical => Self::TAG_CLASSICAL,
287            VrfMode::HybridOutput => Self::TAG_HYBRID_OUTPUT,
288            VrfMode::PurePqExperimental => Self::TAG_PURE_PQ,
289        }
290    }
291
292    fn from_tag(tag: u8) -> Result<Self> {
293        match tag {
294            Self::TAG_CLASSICAL => Ok(VrfMode::Classical),
295            Self::TAG_HYBRID_OUTPUT => Ok(VrfMode::HybridOutput),
296            Self::TAG_PURE_PQ => Ok(VrfMode::PurePqExperimental),
297            other => Err(Error::MalformedPolicy(format!(
298                "unknown vrf_mode tag 0x{other:02x}"
299            ))),
300        }
301    }
302
303    fn rank(self) -> u8 {
304        match self {
305            VrfMode::Classical => 0,
306            VrfMode::HybridOutput => 1,
307            VrfMode::PurePqExperimental => 2,
308        }
309    }
310
311    /// The CONIKS [`crate::vrf::Vrf::suite_id`] this mode requires, for
312    /// declared == observed VRF enforcement. Returns `None` for modes that have
313    /// no built construction in v0.1.
314    #[must_use]
315    pub fn expected_vrf_suite_id(self) -> Option<u8> {
316        match self {
317            VrfMode::Classical => Some(metamorphic_crypto::ECVRF_EDWARDS25519_SHA512_TAI_SUITE),
318            VrfMode::HybridOutput | VrfMode::PurePqExperimental => None,
319        }
320    }
321}
322
323/// The versioned, canonical, signed-in-log per-namespace policy record.
324///
325/// Construct via [`NamespacePolicy::new`] (which validates well-formedness),
326/// serialize via [`NamespacePolicy::canonical_bytes`], and parse via
327/// [`NamespacePolicy::parse`]. See the module docs for the field set and the
328/// invariant wall.
329#[derive(Debug, Clone, PartialEq, Eq)]
330pub struct NamespacePolicy {
331    namespace: Namespace,
332    policy_schema_version: u32,
333    security_level: SecurityLevel,
334    checkpoint_suite: CheckpointSuite,
335    commitment_hash: CommitmentHash,
336    vrf_mode: VrfMode,
337    effective_from: u64,
338    created_at: u64,
339    prev_policy_hash: Option<[u8; POLICY_HASH_LEN]>,
340}
341
342impl NamespacePolicy {
343    /// The canonical context-label record type for a namespace policy.
344    pub const RECORD_TYPE: &'static str = "namespace-policy";
345
346    /// Build and validate a namespace policy.
347    ///
348    /// Enforces the v0.1 well-formedness rules: `commitment_hash` must equal the
349    /// one derived from `security_level` (the bundle), `vrf_mode` must be
350    /// `Classical`, `PureCnsa2` requires Cat-5, and `prev_policy_hash` (if
351    /// present) must be exactly 64 bytes. `policy_schema_version` must be `>= 1`.
352    ///
353    /// # Errors
354    /// Returns [`Error::MalformedPolicy`] for any violation.
355    #[allow(clippy::too_many_arguments)]
356    pub fn new(
357        namespace: Namespace,
358        policy_schema_version: u32,
359        security_level: SecurityLevel,
360        checkpoint_suite: CheckpointSuite,
361        commitment_hash: CommitmentHash,
362        vrf_mode: VrfMode,
363        effective_from: u64,
364        created_at: u64,
365        prev_policy_hash: Option<[u8; POLICY_HASH_LEN]>,
366    ) -> Result<Self> {
367        let policy = Self {
368            namespace,
369            policy_schema_version,
370            security_level,
371            checkpoint_suite,
372            commitment_hash,
373            vrf_mode,
374            effective_from,
375            created_at,
376            prev_policy_hash,
377        };
378        policy.validate()?;
379        Ok(policy)
380    }
381
382    /// Convenience constructor for the bundled DX surface (#324 Q3): the
383    /// `commitment_hash` is derived from `security_level`, `vrf_mode` is
384    /// `Classical`, and this is the genesis version (`policy_schema_version = 1`,
385    /// `prev_policy_hash = None`).
386    ///
387    /// # Errors
388    /// Returns [`Error::MalformedPolicy`] (e.g. `PureCnsa2` below Cat-5).
389    pub fn genesis(
390        namespace: Namespace,
391        security_level: SecurityLevel,
392        checkpoint_suite: CheckpointSuite,
393        effective_from: u64,
394        created_at: u64,
395    ) -> Result<Self> {
396        Self::new(
397            namespace,
398            1,
399            security_level,
400            checkpoint_suite,
401            security_level.derived_commitment_hash(),
402            VrfMode::Classical,
403            effective_from,
404            created_at,
405            None,
406        )
407    }
408
409    fn validate(&self) -> Result<()> {
410        if self.policy_schema_version == 0 {
411            return Err(Error::MalformedPolicy(
412                "policy_schema_version must be >= 1".into(),
413            ));
414        }
415        // v0.1 bundle: commitment_hash is derived from security_level.
416        if self.commitment_hash != self.security_level.derived_commitment_hash() {
417            return Err(Error::MalformedPolicy(format!(
418                "commitment_hash {:?} does not match the one derived from security_level {:?}",
419                self.commitment_hash, self.security_level
420            )));
421        }
422        // v0.1: only Classical VRF is legal (no audited lattice VRF — #304).
423        if self.vrf_mode != VrfMode::Classical {
424            return Err(Error::MalformedPolicy(format!(
425                "vrf_mode {:?} is not legal in v0.1 (only Classical)",
426                self.vrf_mode
427            )));
428        }
429        // PureCnsa2 is a Cat-5-only box (mirrors metamorphic-crypto).
430        if self.checkpoint_suite == CheckpointSuite::PureCnsa2
431            && self.security_level != SecurityLevel::Cat5
432        {
433            return Err(Error::MalformedPolicy(
434                "PureCnsa2 checkpoint_suite requires security_level Cat5".into(),
435            ));
436        }
437        if matches!(self.prev_policy_hash.as_ref(), Some(h) if h.len() != POLICY_HASH_LEN) {
438            return Err(Error::MalformedPolicy(
439                "prev_policy_hash must be 64 bytes".into(),
440            ));
441        }
442        Ok(())
443    }
444
445    /// The governed namespace.
446    #[must_use]
447    pub fn namespace(&self) -> &Namespace {
448        &self.namespace
449    }
450
451    /// The record / migration-sequence version.
452    #[must_use]
453    pub fn policy_schema_version(&self) -> u32 {
454        self.policy_schema_version
455    }
456
457    /// The declared security level.
458    #[must_use]
459    pub fn security_level(&self) -> SecurityLevel {
460        self.security_level
461    }
462
463    /// The declared checkpoint-signature suite.
464    #[must_use]
465    pub fn checkpoint_suite(&self) -> CheckpointSuite {
466        self.checkpoint_suite
467    }
468
469    /// The declared commitment-hash strength.
470    #[must_use]
471    pub fn commitment_hash(&self) -> CommitmentHash {
472        self.commitment_hash
473    }
474
475    /// The declared VRF privacy mode.
476    #[must_use]
477    pub fn vrf_mode(&self) -> VrfMode {
478        self.vrf_mode
479    }
480
481    /// The tree size / checkpoint index at which this version takes force.
482    #[must_use]
483    pub fn effective_from(&self) -> u64 {
484        self.effective_from
485    }
486
487    /// The informational creation timestamp (Unix milliseconds).
488    #[must_use]
489    pub fn created_at(&self) -> u64 {
490        self.created_at
491    }
492
493    /// The 64-byte previous-version hash, or `None` for the genesis version.
494    #[must_use]
495    pub fn prev_policy_hash(&self) -> Option<&[u8; POLICY_HASH_LEN]> {
496        self.prev_policy_hash.as_ref()
497    }
498
499    /// The declared `(Suite, SignatureLevel)` checkpoint posture — what an
500    /// observed checkpoint signature must match.
501    #[must_use]
502    pub fn declared_checkpoint_posture(&self) -> (Suite, SignatureLevel) {
503        (
504            self.checkpoint_suite.crypto_suite(),
505            self.security_level.signature_level(),
506        )
507    }
508
509    /// The canonical context label for this policy, `<namespace>/namespace-policy/v1`.
510    ///
511    /// # Errors
512    /// Propagates [`ContextLabel::parse`] errors (cannot occur for a valid
513    /// namespace).
514    pub fn context_label(&self) -> Result<ContextLabel> {
515        ContextLabel::parse(&format!(
516            "{}/{}/v{}",
517            self.namespace.as_str(),
518            Self::RECORD_TYPE,
519            POLICY_FORMAT_VERSION
520        ))
521    }
522
523    /// Build the canonical, byte-reproducible serialization of this policy.
524    ///
525    /// ```text
526    /// canonical(policy) =
527    ///     u32_be(POLICY_FORMAT_VERSION = 1)
528    ///  || lp(namespace)
529    ///  || u32_be(policy_schema_version)
530    ///  || u8(security_level tag)
531    ///  || u8(checkpoint_suite tag)
532    ///  || u8(commitment_hash tag)
533    ///  || u8(vrf_mode tag)
534    ///  || u64_be(effective_from)
535    ///  || u64_be(created_at)
536    ///  || lp(prev_policy_hash)   // 0-length for genesis
537    /// ```
538    #[must_use]
539    pub fn canonical_bytes(&self) -> Vec<u8> {
540        let ns = self.namespace.as_str().as_bytes();
541        let prev: &[u8] = self.prev_policy_hash.as_ref().map_or(&[], |h| h.as_slice());
542        let mut out = Vec::with_capacity(4 + 4 + ns.len() + 4 + 4 + 8 + 8 + 4 + prev.len());
543        out.extend_from_slice(&POLICY_FORMAT_VERSION.to_be_bytes());
544        push_lp(&mut out, ns);
545        out.extend_from_slice(&self.policy_schema_version.to_be_bytes());
546        out.push(self.security_level.tag());
547        out.push(self.checkpoint_suite.tag());
548        out.push(self.commitment_hash.tag());
549        out.push(self.vrf_mode.tag());
550        out.extend_from_slice(&self.effective_from.to_be_bytes());
551        out.extend_from_slice(&self.created_at.to_be_bytes());
552        push_lp(&mut out, prev);
553        out
554    }
555
556    /// Parse a policy from its canonical bytes, validating the layout, the enum
557    /// tags, and the v0.1 well-formedness rules.
558    ///
559    /// # Errors
560    /// Returns [`Error::MalformedPolicy`] for an unknown format version, an
561    /// unknown enum tag, a length-prefix overrun, trailing bytes, a
562    /// `prev_policy_hash` that is present but not 64 bytes, or any rule violation.
563    pub fn parse(bytes: &[u8]) -> Result<Self> {
564        let mut cur = Cursor::new(bytes);
565        let format_version = cur.u32()?;
566        if format_version != POLICY_FORMAT_VERSION {
567            return Err(Error::MalformedPolicy(format!(
568                "unknown policy format version {format_version}"
569            )));
570        }
571        let ns_bytes = cur.lp()?;
572        let namespace = core::str::from_utf8(ns_bytes)
573            .map_err(|_| Error::MalformedPolicy("namespace is not valid UTF-8".into()))
574            .and_then(Namespace::parse)?;
575        let policy_schema_version = cur.u32()?;
576        let security_level = SecurityLevel::from_tag(cur.u8()?)?;
577        let checkpoint_suite = CheckpointSuite::from_tag(cur.u8()?)?;
578        let commitment_hash = CommitmentHash::from_tag(cur.u8()?)?;
579        let vrf_mode = VrfMode::from_tag(cur.u8()?)?;
580        let effective_from = cur.u64()?;
581        let created_at = cur.u64()?;
582        let prev = cur.lp()?;
583        let prev_policy_hash = match prev.len() {
584            0 => None,
585            POLICY_HASH_LEN => {
586                let mut h = [0u8; POLICY_HASH_LEN];
587                h.copy_from_slice(prev);
588                Some(h)
589            }
590            other => {
591                return Err(Error::MalformedPolicy(format!(
592                    "prev_policy_hash is {other} bytes, want 0 (genesis) or {POLICY_HASH_LEN}"
593                )));
594            }
595        };
596        if !cur.is_empty() {
597            return Err(Error::MalformedPolicy(
598                "trailing bytes after policy record".into(),
599            ));
600        }
601
602        Self::new(
603            namespace,
604            policy_schema_version,
605            security_level,
606            checkpoint_suite,
607            commitment_hash,
608            vrf_mode,
609            effective_from,
610            created_at,
611            prev_policy_hash,
612        )
613    }
614
615    /// The intra-chain `policy_hash`: the 64-byte SHA3-512 content hash over the
616    /// canonical bytes under the `<namespace>/namespace-policy/v1` label.
617    ///
618    /// The next version chains to this digest via `prev_policy_hash`. Note this
619    /// is computed over the **policy** bytes, not the [`SignedPolicy`] envelope,
620    /// so the (hedged, non-reproducible) signature never affects the chain.
621    ///
622    /// # Errors
623    /// Propagates [`NamespacePolicy::context_label`] errors.
624    pub fn policy_hash(&self) -> Result<[u8; POLICY_HASH_LEN]> {
625        let label = self.context_label()?;
626        Ok(content_hash(&label, &self.canonical_bytes()))
627    }
628
629    /// The RFC 6962 Merkle leaf hash `SHA-256(0x00 || canonical)` over the raw
630    /// canonical policy bytes (the Layer-0 leaf hash; independent of
631    /// [`NamespacePolicy::policy_hash`]).
632    #[must_use]
633    pub fn rfc6962_leaf_hash(&self) -> Hash {
634        hash_leaf(&self.canonical_bytes())
635    }
636
637    // === Declared == observed enforcement ===
638
639    /// Enforce that an **observed** checkpoint hybrid signing **public key**
640    /// matches this policy's declared checkpoint posture.
641    ///
642    /// The observed posture is read from the key's self-describing tag via the
643    /// typed, opaque [`metamorphic_crypto::signature_posture`] accessor (no wire
644    /// tags re-derived here); a structurally malformed key surfaces as a
645    /// mismatch rather than a panic.
646    ///
647    /// # Errors
648    /// Returns [`Error::PostureMismatch`] if the observed `(Suite,
649    /// SignatureLevel)` differs from [`NamespacePolicy::declared_checkpoint_posture`].
650    pub fn enforce_checkpoint_signing_key(&self, public_key_b64: &str) -> Result<()> {
651        let observed = metamorphic_crypto::signature_posture(public_key_b64).map_err(|e| {
652            Error::PostureMismatch {
653                declared: posture_str(self.declared_checkpoint_posture()),
654                observed: format!("undecodable checkpoint key ({e})"),
655            }
656        })?;
657        self.check_checkpoint_posture(observed)
658    }
659
660    /// Enforce that an **observed** checkpoint composite **signature** matches
661    /// this policy's declared checkpoint posture (the signature counterpart to
662    /// [`NamespacePolicy::enforce_checkpoint_signing_key`], via
663    /// [`metamorphic_crypto::signature_posture_from_signature`]).
664    ///
665    /// # Errors
666    /// Returns [`Error::PostureMismatch`] on any disagreement.
667    pub fn enforce_checkpoint_signature(&self, signature_b64: &str) -> Result<()> {
668        let observed = metamorphic_crypto::signature_posture_from_signature(signature_b64)
669            .map_err(|e| Error::PostureMismatch {
670                declared: posture_str(self.declared_checkpoint_posture()),
671                observed: format!("undecodable checkpoint signature ({e})"),
672            })?;
673        self.check_checkpoint_posture(observed)
674    }
675
676    fn check_checkpoint_posture(&self, observed: (Suite, SignatureLevel)) -> Result<()> {
677        let declared = self.declared_checkpoint_posture();
678        if observed == declared {
679            Ok(())
680        } else {
681            Err(Error::PostureMismatch {
682                declared: posture_str(declared),
683                observed: posture_str(observed),
684            })
685        }
686    }
687
688    /// Enforce that an **observed** CONIKS VRF suite id (the Slice-4
689    /// [`crate::vrf::Vrf::suite_id`], #332) matches this policy's declared
690    /// [`VrfMode`].
691    ///
692    /// # Errors
693    /// Returns [`Error::PostureMismatch`] if the observed suite id differs from
694    /// the one the declared mode requires (or if the declared mode has no built
695    /// construction in v0.1).
696    pub fn enforce_vrf_suite_id(&self, observed_suite_id: u8) -> Result<()> {
697        match self.vrf_mode.expected_vrf_suite_id() {
698            Some(expected) if expected == observed_suite_id => Ok(()),
699            expected => Err(Error::PostureMismatch {
700                declared: expected.map_or_else(
701                    || format!("vrf_mode {:?} (no built suite)", self.vrf_mode),
702                    |e| format!("vrf_mode {:?} (suite_id 0x{e:02x})", self.vrf_mode),
703                ),
704                observed: format!("vrf suite_id 0x{observed_suite_id:02x}"),
705            }),
706        }
707    }
708
709    /// Enforce that an **observed** commitment-hash parameter matches this
710    /// policy's declared [`CommitmentHash`].
711    ///
712    /// # Errors
713    /// Returns [`Error::PostureMismatch`] on disagreement.
714    pub fn enforce_commitment_hash(&self, observed: CommitmentHash) -> Result<()> {
715        if observed == self.commitment_hash {
716            Ok(())
717        } else {
718            Err(Error::PostureMismatch {
719                declared: format!("commitment_hash {:?}", self.commitment_hash),
720                observed: format!("commitment_hash {observed:?}"),
721            })
722        }
723    }
724}
725
726/// A snapshot of an artifact's **observed** crypto posture, for a single
727/// declared == observed check against the active [`NamespacePolicy`].
728#[derive(Debug, Clone, PartialEq, Eq)]
729pub struct ObservedPosture {
730    /// The observed checkpoint `(Suite, SignatureLevel)` (decoded from the
731    /// checkpoint key/signature via the metamorphic-crypto accessors).
732    pub checkpoint: (Suite, SignatureLevel),
733    /// The observed CONIKS [`crate::vrf::Vrf::suite_id`].
734    pub vrf_suite_id: u8,
735    /// The observed commitment-hash parameter.
736    pub commitment_hash: CommitmentHash,
737}
738
739impl NamespacePolicy {
740    /// Enforce declared == observed across all three posture axes at once
741    /// (checkpoint signature, CONIKS VRF suite, commitment hash). Any single
742    /// mismatch is a hard rejection.
743    ///
744    /// # Errors
745    /// Returns the first [`Error::PostureMismatch`] encountered.
746    pub fn enforce_observed(&self, observed: &ObservedPosture) -> Result<()> {
747        self.check_checkpoint_posture(observed.checkpoint)?;
748        self.enforce_vrf_suite_id(observed.vrf_suite_id)?;
749        self.enforce_commitment_hash(observed.commitment_hash)?;
750        Ok(())
751    }
752}
753
754fn posture_str(p: (Suite, SignatureLevel)) -> String {
755    format!("{:?}/{:?}", p.0, p.1)
756}
757
758/// A [`NamespacePolicy`] together with the namespace root key's composite
759/// signature over its canonical bytes (the signed, in-log artifact).
760///
761/// The signature is produced by the same single-source-of-truth composite
762/// primitive that backs the Slice-3 hybrid checkpoint line, under the versioned
763/// context label `<namespace>/namespace-policy/v1`. ML-DSA signing is hedged, so
764/// the signature bytes are not reproducible, but verification is deterministic.
765#[derive(Debug, Clone, PartialEq, Eq)]
766pub struct SignedPolicy {
767    policy: NamespacePolicy,
768    signing_public_key: Vec<u8>,
769    signature: Vec<u8>,
770}
771
772impl SignedPolicy {
773    /// Sign `policy` with a metamorphic-crypto hybrid composite secret key
774    /// (base64), binding the signature to the `<namespace>/namespace-policy/v1`
775    /// context.
776    ///
777    /// # Errors
778    /// Returns [`Error::HybridSignature`] if the secret key cannot be
779    /// decoded/derived or the composite signature cannot be produced, and
780    /// propagates [`NamespacePolicy::context_label`] errors.
781    pub fn sign(policy: NamespacePolicy, secret_key_b64: &str) -> Result<Self> {
782        let ctx = policy.context_label()?;
783        let canonical = policy.canonical_bytes();
784        let public_key_b64 = metamorphic_crypto::derive_public_key(secret_key_b64)
785            .map_err(|e| Error::HybridSignature(format!("invalid policy signing key: {e}")))?;
786        let signing_public_key = metamorphic_crypto::b64::decode(&public_key_b64)
787            .map_err(|e| Error::HybridSignature(format!("undecodable policy public key: {e}")))?;
788        let sig_b64 = metamorphic_crypto::sign(&canonical, ctx.as_str(), secret_key_b64)
789            .map_err(|e| Error::HybridSignature(format!("policy signing failed: {e}")))?;
790        let signature = metamorphic_crypto::b64::decode(&sig_b64)
791            .map_err(|e| Error::HybridSignature(format!("undecodable policy signature: {e}")))?;
792        Ok(Self {
793            policy,
794            signing_public_key,
795            signature,
796        })
797    }
798
799    /// Build a signed policy from already-produced parts (e.g. parsed from the
800    /// log). Does **not** verify the signature; call [`SignedPolicy::verify`].
801    #[must_use]
802    pub fn from_parts(
803        policy: NamespacePolicy,
804        signing_public_key: Vec<u8>,
805        signature: Vec<u8>,
806    ) -> Self {
807        Self {
808            policy,
809            signing_public_key,
810            signature,
811        }
812    }
813
814    /// The wrapped policy.
815    #[must_use]
816    pub fn policy(&self) -> &NamespacePolicy {
817        &self.policy
818    }
819
820    /// The namespace root signing public key (metamorphic-crypto composite
821    /// `tag || classical_pk || ml_dsa_pk`).
822    #[must_use]
823    pub fn signing_public_key(&self) -> &[u8] {
824        &self.signing_public_key
825    }
826
827    /// The composite signature bytes over the canonical policy.
828    #[must_use]
829    pub fn signature(&self) -> &[u8] {
830        &self.signature
831    }
832
833    /// Verify the policy's own composite signature under the namespace's
834    /// `<namespace>/namespace-policy/v1` context. On success returns the verified
835    /// [`NamespacePolicy`].
836    ///
837    /// A relying party should additionally check that `signing_public_key`
838    /// matches the TOFU-pinned namespace root key (this is the application's
839    /// first-contact trust decision, not this library's).
840    ///
841    /// # Errors
842    /// Returns [`Error::InvalidSignature`] if the signature does not verify, and
843    /// propagates [`NamespacePolicy::context_label`] errors. A structurally
844    /// malformed key/signature is treated as a verification failure, never a
845    /// panic.
846    pub fn verify(&self) -> Result<&NamespacePolicy> {
847        let ctx = self.policy.context_label()?;
848        let canonical = self.policy.canonical_bytes();
849        let sig_b64 = metamorphic_crypto::b64::encode(&self.signature);
850        let pk_b64 = metamorphic_crypto::b64::encode(&self.signing_public_key);
851        let ok = metamorphic_crypto::verify(&canonical, ctx.as_str(), &sig_b64, &pk_b64)
852            .unwrap_or(false);
853        if ok {
854            Ok(&self.policy)
855        } else {
856            Err(Error::InvalidSignature {
857                name: format!("{}/namespace-policy", self.policy.namespace.as_str()),
858                key_id: 0,
859            })
860        }
861    }
862
863    /// Build the canonical serialization of the signed envelope:
864    ///
865    /// ```text
866    /// signed_canonical =
867    ///     u32_be(SIGNED_POLICY_FORMAT_VERSION = 1)
868    ///  || lp(policy_canonical_bytes)
869    ///  || lp(signing_public_key)
870    ///  || lp(signature)
871    /// ```
872    ///
873    /// This is the Layer-0 leaf placed in the log.
874    #[must_use]
875    pub fn canonical_bytes(&self) -> Vec<u8> {
876        let policy = self.policy.canonical_bytes();
877        let mut out = Vec::with_capacity(
878            4 + 12 + policy.len() + self.signing_public_key.len() + self.signature.len(),
879        );
880        out.extend_from_slice(&SIGNED_POLICY_FORMAT_VERSION.to_be_bytes());
881        push_lp(&mut out, &policy);
882        push_lp(&mut out, &self.signing_public_key);
883        push_lp(&mut out, &self.signature);
884        out
885    }
886
887    /// Parse a signed envelope from its canonical bytes (does **not** verify the
888    /// signature; call [`SignedPolicy::verify`]).
889    ///
890    /// # Errors
891    /// Returns [`Error::MalformedPolicy`] for an unknown format version, a
892    /// length-prefix overrun, an empty key/signature, or trailing bytes; and
893    /// propagates [`NamespacePolicy::parse`] errors.
894    pub fn parse(bytes: &[u8]) -> Result<Self> {
895        let mut cur = Cursor::new(bytes);
896        let format_version = cur.u32()?;
897        if format_version != SIGNED_POLICY_FORMAT_VERSION {
898            return Err(Error::MalformedPolicy(format!(
899                "unknown signed-policy format version {format_version}"
900            )));
901        }
902        let policy = NamespacePolicy::parse(cur.lp()?)?;
903        let signing_public_key = cur.lp()?.to_vec();
904        let signature = cur.lp()?.to_vec();
905        if signing_public_key.is_empty() || signature.is_empty() {
906            return Err(Error::MalformedPolicy(
907                "signed policy must carry a non-empty key and signature".into(),
908            ));
909        }
910        if !cur.is_empty() {
911            return Err(Error::MalformedPolicy(
912                "trailing bytes after signed policy envelope".into(),
913            ));
914        }
915        Ok(Self {
916            policy,
917            signing_public_key,
918            signature,
919        })
920    }
921}
922
923/// An ordered, validated list of [`NamespacePolicy`] versions for one namespace.
924///
925/// The chain enforces immutability-by-versioning and only-legal-strengthening
926/// migration (see [`PolicyChain::push`]). Each version `n` owns the half-open
927/// validity range `[effective_from_n, effective_from_{n+1})`;
928/// [`PolicyChain::active_at`] resolves the policy in force at a tree position.
929#[derive(Debug, Clone, PartialEq, Eq)]
930pub struct PolicyChain {
931    versions: Vec<NamespacePolicy>,
932}
933
934impl PolicyChain {
935    /// Start a chain from a genesis policy.
936    ///
937    /// # Errors
938    /// Returns [`Error::PolicyMigrationRejected`] if the policy is not a valid
939    /// genesis (it must have `prev_policy_hash == None`).
940    pub fn genesis(policy: NamespacePolicy) -> Result<Self> {
941        if policy.prev_policy_hash.is_some() {
942            return Err(Error::PolicyMigrationRejected(
943                "genesis policy must not carry a prev_policy_hash".into(),
944            ));
945        }
946        Ok(Self {
947            versions: vec![policy],
948        })
949    }
950
951    /// The ordered policy versions.
952    #[must_use]
953    pub fn versions(&self) -> &[NamespacePolicy] {
954        &self.versions
955    }
956
957    /// The most recent (currently active) policy version.
958    #[must_use]
959    pub fn latest(&self) -> &NamespacePolicy {
960        self.versions
961            .last()
962            .expect("a PolicyChain always has at least the genesis version")
963    }
964
965    /// Append a migration to the chain, enforcing the #324 rules:
966    ///
967    /// - same `namespace`;
968    /// - `policy_schema_version` is exactly `prev + 1`;
969    /// - `effective_from` is strictly greater than the prior version's;
970    /// - `prev_policy_hash` equals the prior version's [`NamespacePolicy::policy_hash`];
971    /// - the migration does not **weaken** posture (security level, commitment
972    ///   hash, or VRF mode may only stay the same or strengthen).
973    ///
974    /// # Errors
975    /// Returns [`Error::PolicyMigrationRejected`] for any rule violation.
976    pub fn push(&mut self, next: NamespacePolicy) -> Result<()> {
977        let prev = self.latest();
978
979        if next.namespace != prev.namespace {
980            return Err(Error::PolicyMigrationRejected(format!(
981                "namespace changed from {:?} to {:?}",
982                prev.namespace.as_str(),
983                next.namespace.as_str()
984            )));
985        }
986        if next.policy_schema_version != prev.policy_schema_version + 1 {
987            return Err(Error::PolicyMigrationRejected(format!(
988                "policy_schema_version must increment by 1 ({} -> {}), got {}",
989                prev.policy_schema_version,
990                prev.policy_schema_version + 1,
991                next.policy_schema_version
992            )));
993        }
994        if next.effective_from <= prev.effective_from {
995            return Err(Error::PolicyMigrationRejected(format!(
996                "effective_from must strictly increase ({} -> {})",
997                prev.effective_from, next.effective_from
998            )));
999        }
1000        let expected_prev = prev.policy_hash()?;
1001        match next.prev_policy_hash {
1002            Some(h) if h == expected_prev => {}
1003            Some(_) => {
1004                return Err(Error::PolicyMigrationRejected(
1005                    "prev_policy_hash does not chain to the prior version".into(),
1006                ));
1007            }
1008            None => {
1009                return Err(Error::PolicyMigrationRejected(
1010                    "migration must carry a prev_policy_hash".into(),
1011                ));
1012            }
1013        }
1014        if next.security_level.rank() < prev.security_level.rank()
1015            || next.commitment_hash.rank() < prev.commitment_hash.rank()
1016            || next.vrf_mode.rank() < prev.vrf_mode.rank()
1017        {
1018            return Err(Error::PolicyMigrationRejected(format!(
1019                "migration would weaken posture (prev {:?}/{:?}/{:?} -> next {:?}/{:?}/{:?})",
1020                prev.security_level,
1021                prev.commitment_hash,
1022                prev.vrf_mode,
1023                next.security_level,
1024                next.commitment_hash,
1025                next.vrf_mode
1026            )));
1027        }
1028
1029        self.versions.push(next);
1030        Ok(())
1031    }
1032
1033    /// Resolve the policy version in force at tree `position`: the version whose
1034    /// half-open range `[effective_from_n, effective_from_{n+1})` contains it.
1035    ///
1036    /// # Errors
1037    /// Returns [`Error::UnknownNamespacePolicy`] if `position` precedes the
1038    /// genesis `effective_from` (no version was yet in force).
1039    pub fn active_at(&self, position: u64) -> Result<&NamespacePolicy> {
1040        if position < self.versions[0].effective_from {
1041            return Err(Error::UnknownNamespacePolicy(format!(
1042                "tree position {position} precedes the genesis effective_from {}",
1043                self.versions[0].effective_from
1044            )));
1045        }
1046        // Versions are stored in strictly increasing effective_from order, so
1047        // the last one whose effective_from <= position is in force.
1048        let active = self
1049            .versions
1050            .iter()
1051            .rev()
1052            .find(|p| p.effective_from <= position)
1053            .expect("position >= genesis effective_from guarantees a match");
1054        Ok(active)
1055    }
1056}
1057
1058// === Length-prefix discipline (mirrors `crate::leaf`) ===
1059
1060/// Append `lp(bytes) = u32_be(len(bytes)) || bytes` to `out`.
1061fn push_lp(out: &mut Vec<u8>, bytes: &[u8]) {
1062    out.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
1063    out.extend_from_slice(bytes);
1064}
1065
1066/// A minimal big-endian, length-prefix-aware reader over a canonical byte buffer.
1067struct Cursor<'a> {
1068    buf: &'a [u8],
1069    pos: usize,
1070}
1071
1072impl<'a> Cursor<'a> {
1073    fn new(buf: &'a [u8]) -> Self {
1074        Self { buf, pos: 0 }
1075    }
1076
1077    fn is_empty(&self) -> bool {
1078        self.pos >= self.buf.len()
1079    }
1080
1081    fn take(&mut self, n: usize) -> Result<&'a [u8]> {
1082        let end = self
1083            .pos
1084            .checked_add(n)
1085            .filter(|&e| e <= self.buf.len())
1086            .ok_or_else(|| {
1087                Error::MalformedPolicy(format!(
1088                    "field of {n} bytes overruns the {}-byte buffer at offset {}",
1089                    self.buf.len(),
1090                    self.pos
1091                ))
1092            })?;
1093        let out = &self.buf[self.pos..end];
1094        self.pos = end;
1095        Ok(out)
1096    }
1097
1098    fn u8(&mut self) -> Result<u8> {
1099        Ok(self.take(1)?[0])
1100    }
1101
1102    fn u32(&mut self) -> Result<u32> {
1103        let b = self.take(4)?;
1104        Ok(u32::from_be_bytes([b[0], b[1], b[2], b[3]]))
1105    }
1106
1107    fn u64(&mut self) -> Result<u64> {
1108        let b = self.take(8)?;
1109        Ok(u64::from_be_bytes([
1110            b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
1111        ]))
1112    }
1113
1114    fn lp(&mut self) -> Result<&'a [u8]> {
1115        let len = self.u32()? as usize;
1116        self.take(len)
1117    }
1118}
1119
1120#[cfg(all(test, not(target_arch = "wasm32")))]
1121mod tests {
1122    use super::*;
1123
1124    fn ns() -> Namespace {
1125        Namespace::parse("acme").unwrap()
1126    }
1127
1128    fn cat5_pure() -> NamespacePolicy {
1129        NamespacePolicy::genesis(
1130            ns(),
1131            SecurityLevel::Cat5,
1132            CheckpointSuite::PureCnsa2,
1133            0,
1134            1_700_000,
1135        )
1136        .unwrap()
1137    }
1138
1139    #[test]
1140    fn genesis_derives_commitment_hash_and_classical_vrf() {
1141        let p = NamespacePolicy::genesis(ns(), SecurityLevel::Cat3, CheckpointSuite::Hybrid, 0, 0)
1142            .unwrap();
1143        assert_eq!(p.commitment_hash(), CommitmentHash::Sha3_256);
1144        assert_eq!(p.vrf_mode(), VrfMode::Classical);
1145        assert_eq!(p.policy_schema_version(), 1);
1146        assert!(p.prev_policy_hash().is_none());
1147
1148        let p5 = cat5_pure();
1149        assert_eq!(p5.commitment_hash(), CommitmentHash::Sha3_512);
1150    }
1151
1152    #[test]
1153    fn canonical_round_trips_byte_for_byte() {
1154        let p = cat5_pure();
1155        let bytes = p.canonical_bytes();
1156        let parsed = NamespacePolicy::parse(&bytes).unwrap();
1157        assert_eq!(parsed, p);
1158        assert_eq!(parsed.canonical_bytes(), bytes);
1159    }
1160
1161    #[test]
1162    fn parse_rejects_malformed() {
1163        // Truncated.
1164        assert!(matches!(
1165            NamespacePolicy::parse(&[0, 0, 0, 1]),
1166            Err(Error::MalformedPolicy(_))
1167        ));
1168        // Trailing bytes.
1169        let mut b = cat5_pure().canonical_bytes();
1170        b.push(0xff);
1171        assert!(matches!(
1172            NamespacePolicy::parse(&b),
1173            Err(Error::MalformedPolicy(_))
1174        ));
1175    }
1176
1177    #[test]
1178    fn rejects_commitment_hash_not_matching_level() {
1179        let r = NamespacePolicy::new(
1180            ns(),
1181            1,
1182            SecurityLevel::Cat5,
1183            CheckpointSuite::Hybrid,
1184            CommitmentHash::Sha3_256, // wrong: Cat5 derives Sha3_512
1185            VrfMode::Classical,
1186            0,
1187            0,
1188            None,
1189        );
1190        assert!(matches!(r, Err(Error::MalformedPolicy(_))));
1191    }
1192
1193    #[test]
1194    fn rejects_non_classical_vrf_and_purecnsa2_below_cat5() {
1195        assert!(matches!(
1196            NamespacePolicy::new(
1197                ns(),
1198                1,
1199                SecurityLevel::Cat5,
1200                CheckpointSuite::Hybrid,
1201                CommitmentHash::Sha3_512,
1202                VrfMode::HybridOutput,
1203                0,
1204                0,
1205                None,
1206            ),
1207            Err(Error::MalformedPolicy(_))
1208        ));
1209        assert!(matches!(
1210            NamespacePolicy::genesis(ns(), SecurityLevel::Cat3, CheckpointSuite::PureCnsa2, 0, 0),
1211            Err(Error::MalformedPolicy(_))
1212        ));
1213    }
1214
1215    #[test]
1216    fn policy_hash_is_stable_and_context_bound() {
1217        let p = cat5_pure();
1218        assert_eq!(p.policy_hash().unwrap(), p.policy_hash().unwrap());
1219        // Different namespace => different hash (context separation).
1220        let other = NamespacePolicy::genesis(
1221            Namespace::parse("other").unwrap(),
1222            SecurityLevel::Cat5,
1223            CheckpointSuite::PureCnsa2,
1224            0,
1225            1_700_000,
1226        )
1227        .unwrap();
1228        assert_ne!(p.policy_hash().unwrap(), other.policy_hash().unwrap());
1229    }
1230
1231    #[test]
1232    fn enforce_vrf_suite_id_classical() {
1233        let p = cat5_pure();
1234        assert!(p.enforce_vrf_suite_id(0x03).is_ok());
1235        assert!(matches!(
1236            p.enforce_vrf_suite_id(0x04),
1237            Err(Error::PostureMismatch { .. })
1238        ));
1239    }
1240
1241    #[test]
1242    fn enforce_commitment_hash() {
1243        let p = cat5_pure();
1244        assert!(p.enforce_commitment_hash(CommitmentHash::Sha3_512).is_ok());
1245        assert!(matches!(
1246            p.enforce_commitment_hash(CommitmentHash::Sha3_256),
1247            Err(Error::PostureMismatch { .. })
1248        ));
1249    }
1250
1251    #[test]
1252    fn migration_strengthen_ok_weaken_rejected() {
1253        let g = NamespacePolicy::genesis(ns(), SecurityLevel::Cat3, CheckpointSuite::Hybrid, 0, 0)
1254            .unwrap();
1255        let mut chain = PolicyChain::genesis(g.clone()).unwrap();
1256
1257        // Strengthen Cat3 -> Cat5 (commitment hash bundles up too).
1258        let v2 = NamespacePolicy::new(
1259            ns(),
1260            2,
1261            SecurityLevel::Cat5,
1262            CheckpointSuite::Hybrid,
1263            CommitmentHash::Sha3_512,
1264            VrfMode::Classical,
1265            100,
1266            1,
1267            Some(g.policy_hash().unwrap()),
1268        )
1269        .unwrap();
1270        chain.push(v2.clone()).unwrap();
1271        assert_eq!(chain.versions().len(), 2);
1272
1273        // Weaken Cat5 -> Cat3 is rejected.
1274        let weak = NamespacePolicy::new(
1275            ns(),
1276            3,
1277            SecurityLevel::Cat3,
1278            CheckpointSuite::Hybrid,
1279            CommitmentHash::Sha3_256,
1280            VrfMode::Classical,
1281            200,
1282            2,
1283            Some(v2.policy_hash().unwrap()),
1284        )
1285        .unwrap();
1286        assert!(matches!(
1287            chain.push(weak),
1288            Err(Error::PolicyMigrationRejected(_))
1289        ));
1290    }
1291
1292    #[test]
1293    fn migration_rejects_bad_chain_links() {
1294        let g = NamespacePolicy::genesis(ns(), SecurityLevel::Cat3, CheckpointSuite::Hybrid, 0, 0)
1295            .unwrap();
1296        let mut chain = PolicyChain::genesis(g.clone()).unwrap();
1297
1298        // Wrong prev hash.
1299        let bad_prev = NamespacePolicy::new(
1300            ns(),
1301            2,
1302            SecurityLevel::Cat3,
1303            CheckpointSuite::Hybrid,
1304            CommitmentHash::Sha3_256,
1305            VrfMode::Classical,
1306            10,
1307            1,
1308            Some([0u8; POLICY_HASH_LEN]),
1309        )
1310        .unwrap();
1311        assert!(matches!(
1312            chain.push(bad_prev),
1313            Err(Error::PolicyMigrationRejected(_))
1314        ));
1315
1316        // Non-incrementing version.
1317        let bad_ver = NamespacePolicy::new(
1318            ns(),
1319            3,
1320            SecurityLevel::Cat3,
1321            CheckpointSuite::Hybrid,
1322            CommitmentHash::Sha3_256,
1323            VrfMode::Classical,
1324            10,
1325            1,
1326            Some(g.policy_hash().unwrap()),
1327        )
1328        .unwrap();
1329        assert!(matches!(
1330            chain.push(bad_ver),
1331            Err(Error::PolicyMigrationRejected(_))
1332        ));
1333    }
1334
1335    #[test]
1336    fn active_at_resolves_half_open_ranges() {
1337        let g = NamespacePolicy::genesis(ns(), SecurityLevel::Cat3, CheckpointSuite::Hybrid, 5, 0)
1338            .unwrap();
1339        let mut chain = PolicyChain::genesis(g.clone()).unwrap();
1340        let v2 = NamespacePolicy::new(
1341            ns(),
1342            2,
1343            SecurityLevel::Cat5,
1344            CheckpointSuite::Hybrid,
1345            CommitmentHash::Sha3_512,
1346            VrfMode::Classical,
1347            10,
1348            1,
1349            Some(g.policy_hash().unwrap()),
1350        )
1351        .unwrap();
1352        chain.push(v2).unwrap();
1353
1354        assert!(matches!(
1355            chain.active_at(4),
1356            Err(Error::UnknownNamespacePolicy(_))
1357        ));
1358        assert_eq!(chain.active_at(5).unwrap().policy_schema_version(), 1);
1359        assert_eq!(chain.active_at(9).unwrap().policy_schema_version(), 1);
1360        assert_eq!(chain.active_at(10).unwrap().policy_schema_version(), 2);
1361        assert_eq!(chain.active_at(1000).unwrap().policy_schema_version(), 2);
1362    }
1363}