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 {
115                manifest_offset,
116                rejection_phase,
117            } => {
118                write!(
119                    f,
120                    "invalid signature at offset 0x{manifest_offset:X} \
121                     (phase: {rejection_phase})"
122                )
123            }
124            Self::UnknownSigner {
125                manifest_offset, ..
126            } => {
127                write!(f, "unknown signer at offset 0x{manifest_offset:X}")
128            }
129            Self::ContentHashMismatch {
130                pointer_name,
131                seg_offset,
132                ..
133            } => {
134                write!(
135                    f,
136                    "content hash mismatch for {pointer_name} \
137                     at offset 0x{seg_offset:X}"
138                )
139            }
140            Self::EpochDriftExceeded {
141                epoch_drift,
142                max_epoch_drift,
143            } => {
144                write!(
145                    f,
146                    "centroid epoch drift {epoch_drift} exceeds max {max_epoch_drift}"
147                )
148            }
149            Self::Level1InvalidSignature { manifest_offset } => {
150                write!(
151                    f,
152                    "Level 1 manifest invalid signature at offset 0x{manifest_offset:X}"
153                )
154            }
155        }
156    }
157}
158
159/// Content hash fields stored in the Level 0 reserved area (ADR-033 §1).
160///
161/// 96 bytes total: 5 content hashes (16 bytes each) + centroid_epoch (4) +
162/// max_epoch_drift (4) + reserved (8).
163#[derive(Clone, Copy, Debug, PartialEq, Eq)]
164#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
165#[repr(C)]
166pub struct HardeningFields {
167    /// SHAKE-256 truncated to 128 bits of the entrypoint segment payload.
168    pub entrypoint_content_hash: [u8; 16],
169    /// SHAKE-256 truncated to 128 bits of the toplayer segment payload.
170    pub toplayer_content_hash: [u8; 16],
171    /// SHAKE-256 truncated to 128 bits of the centroid segment payload.
172    pub centroid_content_hash: [u8; 16],
173    /// SHAKE-256 truncated to 128 bits of the quantdict segment payload.
174    pub quantdict_content_hash: [u8; 16],
175    /// SHAKE-256 truncated to 128 bits of the hot_cache segment payload.
176    pub hot_cache_content_hash: [u8; 16],
177    /// Monotonic counter incremented on centroid recomputation.
178    pub centroid_epoch: u32,
179    /// Maximum allowed drift before forced recompute.
180    pub max_epoch_drift: u32,
181    /// Reserved for future hardening fields.
182    pub reserved: [u8; 8],
183}
184
185const _: () = assert!(core::mem::size_of::<HardeningFields>() == 96);
186
187impl HardeningFields {
188    /// Offset within the Level 0 reserved area (0xF00 + 109 = 0xF6D).
189    /// Starts after FileIdentity (68 bytes), COW pointers (24 bytes),
190    /// and double-root mechanism (17 bytes).
191    pub const RESERVED_OFFSET: usize = 109;
192
193    /// Create zeroed hardening fields.
194    pub const fn zeroed() -> Self {
195        Self {
196            entrypoint_content_hash: [0u8; 16],
197            toplayer_content_hash: [0u8; 16],
198            centroid_content_hash: [0u8; 16],
199            quantdict_content_hash: [0u8; 16],
200            hot_cache_content_hash: [0u8; 16],
201            centroid_epoch: 0,
202            max_epoch_drift: 64,
203            reserved: [0u8; 8],
204        }
205    }
206
207    /// Serialize to 96 bytes (little-endian).
208    pub fn to_bytes(&self) -> [u8; 96] {
209        let mut buf = [0u8; 96];
210        buf[0..16].copy_from_slice(&self.entrypoint_content_hash);
211        buf[16..32].copy_from_slice(&self.toplayer_content_hash);
212        buf[32..48].copy_from_slice(&self.centroid_content_hash);
213        buf[48..64].copy_from_slice(&self.quantdict_content_hash);
214        buf[64..80].copy_from_slice(&self.hot_cache_content_hash);
215        buf[80..84].copy_from_slice(&self.centroid_epoch.to_le_bytes());
216        buf[84..88].copy_from_slice(&self.max_epoch_drift.to_le_bytes());
217        buf[88..96].copy_from_slice(&self.reserved);
218        buf
219    }
220
221    /// Deserialize from 96 bytes (little-endian).
222    pub fn from_bytes(buf: &[u8; 96]) -> Self {
223        let mut entrypoint_content_hash = [0u8; 16];
224        let mut toplayer_content_hash = [0u8; 16];
225        let mut centroid_content_hash = [0u8; 16];
226        let mut quantdict_content_hash = [0u8; 16];
227        let mut hot_cache_content_hash = [0u8; 16];
228        let mut reserved = [0u8; 8];
229
230        entrypoint_content_hash.copy_from_slice(&buf[0..16]);
231        toplayer_content_hash.copy_from_slice(&buf[16..32]);
232        centroid_content_hash.copy_from_slice(&buf[32..48]);
233        quantdict_content_hash.copy_from_slice(&buf[48..64]);
234        hot_cache_content_hash.copy_from_slice(&buf[64..80]);
235
236        let centroid_epoch = u32::from_le_bytes([buf[80], buf[81], buf[82], buf[83]]);
237        let max_epoch_drift = u32::from_le_bytes([buf[84], buf[85], buf[86], buf[87]]);
238        reserved.copy_from_slice(&buf[88..96]);
239
240        Self {
241            entrypoint_content_hash,
242            toplayer_content_hash,
243            centroid_content_hash,
244            quantdict_content_hash,
245            hot_cache_content_hash,
246            centroid_epoch,
247            max_epoch_drift,
248            reserved,
249        }
250    }
251
252    /// Check if all content hashes are zero (no hardening data stored).
253    pub fn is_empty(&self) -> bool {
254        self.entrypoint_content_hash == [0u8; 16]
255            && self.toplayer_content_hash == [0u8; 16]
256            && self.centroid_content_hash == [0u8; 16]
257            && self.quantdict_content_hash == [0u8; 16]
258            && self.hot_cache_content_hash == [0u8; 16]
259            && self.centroid_epoch == 0
260    }
261
262    /// Get the content hash for a named pointer.
263    pub fn hash_for_pointer(&self, pointer_name: &str) -> Option<&[u8; 16]> {
264        match pointer_name {
265            "entrypoint" => Some(&self.entrypoint_content_hash),
266            "toplayer" => Some(&self.toplayer_content_hash),
267            "centroid" => Some(&self.centroid_content_hash),
268            "quantdict" => Some(&self.quantdict_content_hash),
269            "hot_cache" => Some(&self.hot_cache_content_hash),
270            _ => None,
271        }
272    }
273
274    /// Compute epoch drift relative to the manifest's global epoch.
275    pub fn epoch_drift(&self, manifest_epoch: u32) -> u32 {
276        manifest_epoch.saturating_sub(self.centroid_epoch)
277    }
278
279    /// Check if epoch drift exceeds the maximum allowed.
280    pub fn is_epoch_drift_exceeded(&self, manifest_epoch: u32) -> bool {
281        self.epoch_drift(manifest_epoch) > self.max_epoch_drift
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    #[test]
290    fn security_policy_default_is_strict() {
291        assert_eq!(SecurityPolicy::default(), SecurityPolicy::Strict);
292    }
293
294    #[test]
295    fn security_policy_signature_required() {
296        assert!(!SecurityPolicy::Permissive.requires_signature());
297        assert!(!SecurityPolicy::WarnOnly.requires_signature());
298        assert!(SecurityPolicy::Strict.requires_signature());
299        assert!(SecurityPolicy::Paranoid.requires_signature());
300    }
301
302    #[test]
303    fn security_policy_content_hashes() {
304        assert!(!SecurityPolicy::Permissive.verifies_content_hashes());
305        assert!(SecurityPolicy::WarnOnly.verifies_content_hashes());
306        assert!(SecurityPolicy::Strict.verifies_content_hashes());
307        assert!(SecurityPolicy::Paranoid.verifies_content_hashes());
308    }
309
310    #[test]
311    fn security_policy_level1() {
312        assert!(!SecurityPolicy::Strict.verifies_level1());
313        assert!(SecurityPolicy::Paranoid.verifies_level1());
314    }
315
316    #[test]
317    fn security_policy_repr() {
318        assert_eq!(SecurityPolicy::Permissive as u8, 0x00);
319        assert_eq!(SecurityPolicy::WarnOnly as u8, 0x01);
320        assert_eq!(SecurityPolicy::Strict as u8, 0x02);
321        assert_eq!(SecurityPolicy::Paranoid as u8, 0x03);
322    }
323
324    #[test]
325    fn hardening_fields_size() {
326        assert_eq!(core::mem::size_of::<HardeningFields>(), 96);
327    }
328
329    #[test]
330    fn hardening_fields_round_trip() {
331        let fields = HardeningFields {
332            entrypoint_content_hash: [1u8; 16],
333            toplayer_content_hash: [2u8; 16],
334            centroid_content_hash: [3u8; 16],
335            quantdict_content_hash: [4u8; 16],
336            hot_cache_content_hash: [5u8; 16],
337            centroid_epoch: 42,
338            max_epoch_drift: 64,
339            reserved: [0u8; 8],
340        };
341        let bytes = fields.to_bytes();
342        let decoded = HardeningFields::from_bytes(&bytes);
343        assert_eq!(fields, decoded);
344    }
345
346    #[test]
347    fn hardening_fields_zeroed() {
348        let fields = HardeningFields::zeroed();
349        assert!(fields.is_empty());
350        assert_eq!(fields.max_epoch_drift, 64);
351    }
352
353    #[test]
354    fn hardening_fields_hash_for_pointer() {
355        let mut fields = HardeningFields::zeroed();
356        fields.centroid_content_hash = [0xAB; 16];
357        assert_eq!(fields.hash_for_pointer("centroid"), Some(&[0xAB; 16]));
358        assert_eq!(fields.hash_for_pointer("unknown"), None);
359    }
360
361    #[test]
362    fn hardening_fields_epoch_drift() {
363        let fields = HardeningFields {
364            centroid_epoch: 10,
365            max_epoch_drift: 64,
366            ..HardeningFields::zeroed()
367        };
368        assert_eq!(fields.epoch_drift(50), 40);
369        assert!(!fields.is_epoch_drift_exceeded(50));
370        assert!(fields.is_epoch_drift_exceeded(100));
371    }
372
373    #[test]
374    fn security_error_display() {
375        let err = SecurityError::UnsignedManifest {
376            manifest_offset: 0x1000,
377        };
378        let s = alloc::format!("{err}");
379        assert!(s.contains("unsigned manifest"));
380
381        let err = SecurityError::ContentHashMismatch {
382            pointer_name: "centroid",
383            expected_hash: [0xAA; 16],
384            actual_hash: [0xBB; 16],
385            seg_offset: 0x2000,
386        };
387        let s = alloc::format!("{err}");
388        assert!(s.contains("centroid"));
389        assert!(s.contains("2000"));
390    }
391
392    #[test]
393    fn security_error_unknown_signer() {
394        let err = SecurityError::UnknownSigner {
395            manifest_offset: 0x3000,
396            actual_signer: [0x11; 16],
397            expected_signer: Some([0x22; 16]),
398        };
399        let s = alloc::format!("{err}");
400        assert!(s.contains("unknown signer"));
401    }
402
403    #[test]
404    fn reserved_offset_fits() {
405        // 109 + 96 = 205 <= 252 (reserved area size)
406        assert!(HardeningFields::RESERVED_OFFSET + 96 <= 252);
407    }
408}