Skip to main content

rvf_types/
security.rs

1//! Security policy and error types for ADR-033 mandatory manifest signatures.
2//!
3//! Defines the `SecurityPolicy` mount policy (default: Strict) and
4//! structured `SecurityError` diagnostics for deterministic failure reasons.
5
6/// Manifest signature verification policy.
7///
8/// Controls how the runtime handles unsigned or invalid signatures
9/// when opening an RVF file. Default is `Strict` — no signature means
10/// no mount in production.
11#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
12#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
13#[repr(u8)]
14pub enum SecurityPolicy {
15    /// No signature verification. For development and testing only.
16    Permissive = 0x00,
17    /// Warn on missing or invalid signatures, but allow open.
18    WarnOnly = 0x01,
19    /// Require valid signature on Level 0 manifest.
20    /// DEFAULT for production.
21    Strict = 0x02,
22    /// Require valid signatures on Level 0, Level 1, and all
23    /// hotset-referenced segments. Full chain verification.
24    Paranoid = 0x03,
25}
26
27impl Default for SecurityPolicy {
28    fn default() -> Self {
29        Self::Strict
30    }
31}
32
33impl SecurityPolicy {
34    /// Returns true if signature verification is required at mount time.
35    pub const fn requires_signature(&self) -> bool {
36        matches!(*self, Self::Strict | Self::Paranoid)
37    }
38
39    /// Returns true if content hash verification is performed on hotset access.
40    pub const fn verifies_content_hashes(&self) -> bool {
41        matches!(*self, Self::WarnOnly | Self::Strict | Self::Paranoid)
42    }
43
44    /// Returns true if Level 1 manifest is also signature-verified.
45    pub const fn verifies_level1(&self) -> bool {
46        matches!(*self, Self::Paranoid)
47    }
48}
49
50/// Structured security error with deterministic, stable error codes.
51///
52/// Every variant includes enough context for logging and diagnostics
53/// without exposing internal state that could aid an attacker.
54#[derive(Clone, Debug, PartialEq, Eq)]
55#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
56pub enum SecurityError {
57    /// Level 0 manifest has no signature (sig_algo = 0).
58    UnsignedManifest {
59        /// Byte offset of the rejected manifest.
60        manifest_offset: u64,
61    },
62
63    /// Signature is present but cryptographically invalid.
64    InvalidSignature {
65        /// Byte offset of the rejected manifest.
66        manifest_offset: u64,
67        /// Phase where rejection occurred.
68        rejection_phase: &'static str,
69    },
70
71    /// Signature is valid but from an unknown/untrusted signer.
72    UnknownSigner {
73        /// Byte offset of the rejected manifest.
74        manifest_offset: u64,
75        /// Fingerprint of the actual signer (first 16 bytes of public key hash).
76        actual_signer: [u8; 16],
77        /// Fingerprint of the expected signer from trust store (if known).
78        expected_signer: Option<[u8; 16]>,
79    },
80
81    /// Content hash of a hotset-referenced segment does not match.
82    ContentHashMismatch {
83        /// Name of the pointer that failed (e.g., "centroid_seg_offset").
84        pointer_name: &'static str,
85        /// Content hash stored in Level 0.
86        expected_hash: [u8; 16],
87        /// Actual hash of the segment at the pointed offset.
88        actual_hash: [u8; 16],
89        /// Byte offset that was followed.
90        seg_offset: u64,
91    },
92
93    /// Centroid epoch drift exceeds maximum allowed.
94    EpochDriftExceeded {
95        /// Current epoch drift value.
96        epoch_drift: u32,
97        /// Maximum allowed drift.
98        max_epoch_drift: u32,
99    },
100
101    /// Level 1 manifest signature invalid (Paranoid mode only).
102    Level1InvalidSignature {
103        /// Byte offset of the Level 1 manifest.
104        manifest_offset: u64,
105    },
106}
107
108impl core::fmt::Display for SecurityError {
109    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
110        match self {
111            Self::UnsignedManifest { manifest_offset } => {
112                write!(f, "unsigned manifest at offset 0x{manifest_offset:X}")
113            }
114            Self::InvalidSignature { manifest_offset, rejection_phase } => {
115                write!(
116                    f,
117                    "invalid signature at offset 0x{manifest_offset:X} \
118                     (phase: {rejection_phase})"
119                )
120            }
121            Self::UnknownSigner { manifest_offset, .. } => {
122                write!(f, "unknown signer at offset 0x{manifest_offset:X}")
123            }
124            Self::ContentHashMismatch { pointer_name, seg_offset, .. } => {
125                write!(
126                    f,
127                    "content hash mismatch for {pointer_name} \
128                     at offset 0x{seg_offset:X}"
129                )
130            }
131            Self::EpochDriftExceeded { epoch_drift, max_epoch_drift } => {
132                write!(
133                    f,
134                    "centroid epoch drift {epoch_drift} exceeds max {max_epoch_drift}"
135                )
136            }
137            Self::Level1InvalidSignature { manifest_offset } => {
138                write!(
139                    f,
140                    "Level 1 manifest invalid signature at offset 0x{manifest_offset:X}"
141                )
142            }
143        }
144    }
145}
146
147/// Content hash fields stored in the Level 0 reserved area (ADR-033 §1).
148///
149/// 96 bytes total: 5 content hashes (16 bytes each) + centroid_epoch (4) +
150/// max_epoch_drift (4) + reserved (8).
151#[derive(Clone, Copy, Debug, PartialEq, Eq)]
152#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
153#[repr(C)]
154pub struct HardeningFields {
155    /// SHAKE-256 truncated to 128 bits of the entrypoint segment payload.
156    pub entrypoint_content_hash: [u8; 16],
157    /// SHAKE-256 truncated to 128 bits of the toplayer segment payload.
158    pub toplayer_content_hash: [u8; 16],
159    /// SHAKE-256 truncated to 128 bits of the centroid segment payload.
160    pub centroid_content_hash: [u8; 16],
161    /// SHAKE-256 truncated to 128 bits of the quantdict segment payload.
162    pub quantdict_content_hash: [u8; 16],
163    /// SHAKE-256 truncated to 128 bits of the hot_cache segment payload.
164    pub hot_cache_content_hash: [u8; 16],
165    /// Monotonic counter incremented on centroid recomputation.
166    pub centroid_epoch: u32,
167    /// Maximum allowed drift before forced recompute.
168    pub max_epoch_drift: u32,
169    /// Reserved for future hardening fields.
170    pub reserved: [u8; 8],
171}
172
173const _: () = assert!(core::mem::size_of::<HardeningFields>() == 96);
174
175impl HardeningFields {
176    /// Offset within the Level 0 reserved area (0xF00 + 109 = 0xF6D).
177    /// Starts after FileIdentity (68 bytes), COW pointers (24 bytes),
178    /// and double-root mechanism (17 bytes).
179    pub const RESERVED_OFFSET: usize = 109;
180
181    /// Create zeroed hardening fields.
182    pub const fn zeroed() -> Self {
183        Self {
184            entrypoint_content_hash: [0u8; 16],
185            toplayer_content_hash: [0u8; 16],
186            centroid_content_hash: [0u8; 16],
187            quantdict_content_hash: [0u8; 16],
188            hot_cache_content_hash: [0u8; 16],
189            centroid_epoch: 0,
190            max_epoch_drift: 64,
191            reserved: [0u8; 8],
192        }
193    }
194
195    /// Serialize to 96 bytes (little-endian).
196    pub fn to_bytes(&self) -> [u8; 96] {
197        let mut buf = [0u8; 96];
198        buf[0..16].copy_from_slice(&self.entrypoint_content_hash);
199        buf[16..32].copy_from_slice(&self.toplayer_content_hash);
200        buf[32..48].copy_from_slice(&self.centroid_content_hash);
201        buf[48..64].copy_from_slice(&self.quantdict_content_hash);
202        buf[64..80].copy_from_slice(&self.hot_cache_content_hash);
203        buf[80..84].copy_from_slice(&self.centroid_epoch.to_le_bytes());
204        buf[84..88].copy_from_slice(&self.max_epoch_drift.to_le_bytes());
205        buf[88..96].copy_from_slice(&self.reserved);
206        buf
207    }
208
209    /// Deserialize from 96 bytes (little-endian).
210    pub fn from_bytes(buf: &[u8; 96]) -> Self {
211        let mut entrypoint_content_hash = [0u8; 16];
212        let mut toplayer_content_hash = [0u8; 16];
213        let mut centroid_content_hash = [0u8; 16];
214        let mut quantdict_content_hash = [0u8; 16];
215        let mut hot_cache_content_hash = [0u8; 16];
216        let mut reserved = [0u8; 8];
217
218        entrypoint_content_hash.copy_from_slice(&buf[0..16]);
219        toplayer_content_hash.copy_from_slice(&buf[16..32]);
220        centroid_content_hash.copy_from_slice(&buf[32..48]);
221        quantdict_content_hash.copy_from_slice(&buf[48..64]);
222        hot_cache_content_hash.copy_from_slice(&buf[64..80]);
223
224        let centroid_epoch = u32::from_le_bytes([buf[80], buf[81], buf[82], buf[83]]);
225        let max_epoch_drift = u32::from_le_bytes([buf[84], buf[85], buf[86], buf[87]]);
226        reserved.copy_from_slice(&buf[88..96]);
227
228        Self {
229            entrypoint_content_hash,
230            toplayer_content_hash,
231            centroid_content_hash,
232            quantdict_content_hash,
233            hot_cache_content_hash,
234            centroid_epoch,
235            max_epoch_drift,
236            reserved,
237        }
238    }
239
240    /// Check if all content hashes are zero (no hardening data stored).
241    pub fn is_empty(&self) -> bool {
242        self.entrypoint_content_hash == [0u8; 16]
243            && self.toplayer_content_hash == [0u8; 16]
244            && self.centroid_content_hash == [0u8; 16]
245            && self.quantdict_content_hash == [0u8; 16]
246            && self.hot_cache_content_hash == [0u8; 16]
247            && self.centroid_epoch == 0
248    }
249
250    /// Get the content hash for a named pointer.
251    pub fn hash_for_pointer(&self, pointer_name: &str) -> Option<&[u8; 16]> {
252        match pointer_name {
253            "entrypoint" => Some(&self.entrypoint_content_hash),
254            "toplayer" => Some(&self.toplayer_content_hash),
255            "centroid" => Some(&self.centroid_content_hash),
256            "quantdict" => Some(&self.quantdict_content_hash),
257            "hot_cache" => Some(&self.hot_cache_content_hash),
258            _ => None,
259        }
260    }
261
262    /// Compute epoch drift relative to the manifest's global epoch.
263    pub fn epoch_drift(&self, manifest_epoch: u32) -> u32 {
264        manifest_epoch.saturating_sub(self.centroid_epoch)
265    }
266
267    /// Check if epoch drift exceeds the maximum allowed.
268    pub fn is_epoch_drift_exceeded(&self, manifest_epoch: u32) -> bool {
269        self.epoch_drift(manifest_epoch) > self.max_epoch_drift
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn security_policy_default_is_strict() {
279        assert_eq!(SecurityPolicy::default(), SecurityPolicy::Strict);
280    }
281
282    #[test]
283    fn security_policy_signature_required() {
284        assert!(!SecurityPolicy::Permissive.requires_signature());
285        assert!(!SecurityPolicy::WarnOnly.requires_signature());
286        assert!(SecurityPolicy::Strict.requires_signature());
287        assert!(SecurityPolicy::Paranoid.requires_signature());
288    }
289
290    #[test]
291    fn security_policy_content_hashes() {
292        assert!(!SecurityPolicy::Permissive.verifies_content_hashes());
293        assert!(SecurityPolicy::WarnOnly.verifies_content_hashes());
294        assert!(SecurityPolicy::Strict.verifies_content_hashes());
295        assert!(SecurityPolicy::Paranoid.verifies_content_hashes());
296    }
297
298    #[test]
299    fn security_policy_level1() {
300        assert!(!SecurityPolicy::Strict.verifies_level1());
301        assert!(SecurityPolicy::Paranoid.verifies_level1());
302    }
303
304    #[test]
305    fn security_policy_repr() {
306        assert_eq!(SecurityPolicy::Permissive as u8, 0x00);
307        assert_eq!(SecurityPolicy::WarnOnly as u8, 0x01);
308        assert_eq!(SecurityPolicy::Strict as u8, 0x02);
309        assert_eq!(SecurityPolicy::Paranoid as u8, 0x03);
310    }
311
312    #[test]
313    fn hardening_fields_size() {
314        assert_eq!(core::mem::size_of::<HardeningFields>(), 96);
315    }
316
317    #[test]
318    fn hardening_fields_round_trip() {
319        let fields = HardeningFields {
320            entrypoint_content_hash: [1u8; 16],
321            toplayer_content_hash: [2u8; 16],
322            centroid_content_hash: [3u8; 16],
323            quantdict_content_hash: [4u8; 16],
324            hot_cache_content_hash: [5u8; 16],
325            centroid_epoch: 42,
326            max_epoch_drift: 64,
327            reserved: [0u8; 8],
328        };
329        let bytes = fields.to_bytes();
330        let decoded = HardeningFields::from_bytes(&bytes);
331        assert_eq!(fields, decoded);
332    }
333
334    #[test]
335    fn hardening_fields_zeroed() {
336        let fields = HardeningFields::zeroed();
337        assert!(fields.is_empty());
338        assert_eq!(fields.max_epoch_drift, 64);
339    }
340
341    #[test]
342    fn hardening_fields_hash_for_pointer() {
343        let mut fields = HardeningFields::zeroed();
344        fields.centroid_content_hash = [0xAB; 16];
345        assert_eq!(fields.hash_for_pointer("centroid"), Some(&[0xAB; 16]));
346        assert_eq!(fields.hash_for_pointer("unknown"), None);
347    }
348
349    #[test]
350    fn hardening_fields_epoch_drift() {
351        let fields = HardeningFields {
352            centroid_epoch: 10,
353            max_epoch_drift: 64,
354            ..HardeningFields::zeroed()
355        };
356        assert_eq!(fields.epoch_drift(50), 40);
357        assert!(!fields.is_epoch_drift_exceeded(50));
358        assert!(fields.is_epoch_drift_exceeded(100));
359    }
360
361    #[test]
362    fn security_error_display() {
363        let err = SecurityError::UnsignedManifest { manifest_offset: 0x1000 };
364        let s = alloc::format!("{err}");
365        assert!(s.contains("unsigned manifest"));
366
367        let err = SecurityError::ContentHashMismatch {
368            pointer_name: "centroid",
369            expected_hash: [0xAA; 16],
370            actual_hash: [0xBB; 16],
371            seg_offset: 0x2000,
372        };
373        let s = alloc::format!("{err}");
374        assert!(s.contains("centroid"));
375        assert!(s.contains("2000"));
376    }
377
378    #[test]
379    fn security_error_unknown_signer() {
380        let err = SecurityError::UnknownSigner {
381            manifest_offset: 0x3000,
382            actual_signer: [0x11; 16],
383            expected_signer: Some([0x22; 16]),
384        };
385        let s = alloc::format!("{err}");
386        assert!(s.contains("unknown signer"));
387    }
388
389    #[test]
390    fn reserved_offset_fits() {
391        // 109 + 96 = 205 <= 252 (reserved area size)
392        assert!(HardeningFields::RESERVED_OFFSET + 96 <= 252);
393    }
394}