Skip to main content

rite_model/
types.rs

1//! Shared semantic types for the ceremony domain model.
2
3use serde::{Deserialize, Serialize};
4
5/// Ceremony metadata (name and optional description).
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct Metadata {
8    /// Human-readable ceremony name.
9    pub name: String,
10    /// Optional description.
11    #[serde(skip_serializing_if = "Option::is_none")]
12    pub description: Option<String>,
13}
14
15/// Extract the role type from a role ID.
16///
17/// `"witness__1"` → `"witness"`, `"operator"` → `"operator"`.
18pub fn role_type(id: &str) -> &str {
19    id.split_once("__").map_or(id, |(prefix, _)| prefix)
20}
21
22/// Derive a display name from a step or section ID.
23///
24/// Splits on `_` and `-`, title-cases each word, joins with space.
25/// `"verify_time"` → `"Verify Time"`, `"generate_root_ca"` → `"Generate Root Ca"`.
26///
27/// Note: this naive title-casing does not handle acronyms; `"root_ca"` becomes `"Root Ca"`
28/// rather than `"Root CA"`. A lookup table for common ceremony acronyms (CA, CSR, PKI, HSM,
29/// TPM) could improve this. See `derive_role_name` for the same limitation.
30pub fn derive_step_name(id: &str) -> String {
31    id.split(['_', '-'])
32        .map(|word| {
33            let mut chars = word.chars();
34            match chars.next() {
35                None => String::new(),
36                Some(c) => c.to_uppercase().to_string() + chars.as_str(),
37            }
38        })
39        .collect::<Vec<_>>()
40        .join(" ")
41}
42
43/// Derive a display name from a role ID.
44///
45/// Title-cases each word (split on `_`, `-`, and `__`), including the discriminator suffix.
46/// `"witness__1"` → `"Witness 1"`, `"hsm_operator__primary"` → `"Hsm Operator Primary"`.
47pub fn derive_role_name(id: &str) -> String {
48    id.replace("__", " ")
49        .split(['_', '-', ' '])
50        .filter(|s| !s.is_empty())
51        .map(|word| {
52            let mut chars = word.chars();
53            match chars.next() {
54                None => String::new(),
55                Some(c) => c.to_uppercase().to_string() + chars.as_str(),
56            }
57        })
58        .collect::<Vec<_>>()
59        .join(" ")
60}
61
62/// Action types available in ceremony steps.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
64#[serde(rename_all = "snake_case")]
65#[non_exhaustive]
66pub enum ActionType {
67    /// Verify system clock is correct before ceremony proceeds.
68    ///
69    /// Displays current time and requires operator confirmation.
70    /// Should be placed as the first step to ensure all timestamps are valid.
71    ClockCheck,
72    /// Human attests to something with yes/no decision (single person).
73    ///
74    /// Use when the verification requires human judgment about external state.
75    /// Example: "Verify all network cables are disconnected".
76    Confirm,
77    /// Machine compares two known values and records pass/fail (automatic).
78    ///
79    /// Use when both values are known to the system and comparison is deterministic.
80    /// Example: Compare computed `SHA-256` hash against expected value.
81    CheckValue,
82    /// Two-party verbal verification: reader speaks value aloud, confirmer verifies.
83    ///
84    /// Use when a value must be verified against something external (physical label,
85    /// document). Supports NATO phonetic alphabet and hex formatting.
86    OralReadback,
87    /// Capture machine information (hostname, CPU, OS) as evidence.
88    ///
89    /// Records device identity to prove which machine ran the ceremony.
90    /// Should be placed early in ceremony to establish machine context.
91    MachineInfo,
92
93    /// Generate RSA or EC keypair.
94    GenerateKeypair,
95    /// Wrap key using CMS `EnvelopedData`.
96    WrapKey,
97    /// Unwrap key using CMS `EnvelopedData`.
98    UnwrapKey,
99    /// Export public key from keypair.
100    ExportPublic,
101    /// Formal attestation statement.
102    Attest,
103    /// TPM attestation with PCR measurements and cryptographic quotes.
104    ///
105    /// Requires the `rite-tpm` backend.
106    TpmAttest,
107
108    /// Read X.509 certificate from PIV smart card slot.
109    ///
110    /// No PIN required; reading certificates is unauthenticated on PIV cards.
111    /// Requires a PIV backend.
112    PivReadCertificate,
113    /// Sign data using PIV smart card on-device key.
114    ///
115    /// Handles PIN verification internally before signing.
116    /// Requires a PIV backend.
117    PivSign,
118    /// Generate a `YubiKey` attestation certificate for a PIV slot (Yubico extension).
119    ///
120    /// Slot `F9` signs the key's certificate to prove it was generated on-device.
121    /// Requires the `rite-yubikey` backend.
122    YubikeyAttestSlot,
123
124    /// Issue an X.509 certificate from a PKCS#10 CSR.
125    ///
126    /// Takes a CSR and a backend-managed signing key, assembles the `TBSCertificate`,
127    /// signs it via the backend's `SignBackend`, and produces a DER-encoded certificate.
128    /// Works with any backend implementing `SignBackend` (software, PKCS#11, `YubiKey`).
129    IssueCertificate,
130    /// Generate a PKCS#10 CSR signed by a backend-managed key.
131    ///
132    /// Takes a backend-managed signing key and subject parameters, assembles a
133    /// `CertReqInfo`, signs it via the backend's `SignBackend`, and produces a
134    /// `DER`-encoded `CSR`.
135    GenerateCsr,
136}
137
138impl ActionType {
139    /// Returns `true` if this action requires a `backend:` field in the step.
140    ///
141    /// TODO: Replace this with a nested enum split — `ActionType::Backend(BackendAction)` vs
142    /// `ActionType::Local(LocalAction)`. The `requires_backend` check then becomes
143    /// `matches!(self, ActionType::Backend(_))` with no hardcoded list, and each group gains
144    /// its own `impl` block. This is a breaking change to every match on `ActionType` variants.
145    pub fn requires_backend(self) -> bool {
146        matches!(
147            self,
148            ActionType::GenerateKeypair
149                | ActionType::WrapKey
150                | ActionType::UnwrapKey
151                | ActionType::ExportPublic
152                | ActionType::GenerateCsr
153                | ActionType::IssueCertificate
154                | ActionType::PivReadCertificate
155                | ActionType::PivSign
156                | ActionType::YubikeyAttestSlot
157                | ActionType::TpmAttest
158        )
159    }
160
161    /// Returns the `with:` field names that are required for this action.
162    ///
163    /// The resolver reports a diagnostic for each missing field.
164    ///
165    /// TODO: Replace the stringly-typed `with: serde_json::Value` in `schema::StepBody` with a
166    /// typed `WithFields` enum (one variant per action, carrying its required fields as named
167    /// struct members). Serde would then enforce required fields at parse time and this method
168    /// would no longer be needed. This is a breaking change to the schema and lowering layers.
169    pub fn required_with_fields(self) -> &'static [&'static str] {
170        match self {
171            ActionType::CheckValue => &["actual", "expected"],
172            ActionType::GenerateCsr => &["subject"],
173            _ => &[],
174        }
175    }
176
177    /// Short human-readable description of what this action does.
178    ///
179    /// Used as fallback prose in script generation, TUI step display, and LSP
180    /// hover when no explicit `description` or `message` parameter is present.
181    pub fn describe(&self) -> &'static str {
182        match self {
183            ActionType::ClockCheck => "Verify system clock against a reference time.",
184            ActionType::Confirm => "Confirm readiness or completion of a manual step.",
185            ActionType::CheckValue => "Verify a value matches an expected result.",
186            ActionType::OralReadback => "Read back a value aloud for verification.",
187            ActionType::MachineInfo => "Record system and environment information.",
188            ActionType::Attest => "Record a signed attestation from a participant.",
189            ActionType::TpmAttest => "Record TPM platform attestation (PCR values).",
190            ActionType::GenerateKeypair => "Generate an asymmetric keypair.",
191            ActionType::ExportPublic => "Export the public component of a keypair.",
192            ActionType::WrapKey => "Wrap (encrypt) a key for secure transport.",
193            ActionType::UnwrapKey => "Unwrap (decrypt) a transported key.",
194            ActionType::GenerateCsr => "Generate a Certificate Signing Request.",
195            ActionType::IssueCertificate => "Issue an X.509 certificate from a CSR.",
196            ActionType::PivReadCertificate => "Read a certificate from a PIV smart card slot.",
197            ActionType::PivSign => "Sign data using a PIV smart card key.",
198            ActionType::YubikeyAttestSlot => "Attest a YubiKey PIV slot key.",
199        }
200    }
201}
202
203impl std::fmt::Display for ActionType {
204    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
205        match self {
206            ActionType::ClockCheck => write!(f, "clock_check"),
207            ActionType::Confirm => write!(f, "confirm"),
208            ActionType::CheckValue => write!(f, "check_value"),
209            ActionType::OralReadback => write!(f, "oral_readback"),
210            ActionType::MachineInfo => write!(f, "machine_info"),
211            ActionType::GenerateKeypair => write!(f, "generate_keypair"),
212            ActionType::WrapKey => write!(f, "wrap_key"),
213            ActionType::UnwrapKey => write!(f, "unwrap_key"),
214            ActionType::ExportPublic => write!(f, "export_public"),
215            ActionType::Attest => write!(f, "attest"),
216            ActionType::TpmAttest => write!(f, "tpm_attest"),
217            ActionType::PivReadCertificate => write!(f, "piv_read_certificate"),
218            ActionType::PivSign => write!(f, "piv_sign"),
219            ActionType::YubikeyAttestSlot => write!(f, "yubikey_attest_slot"),
220            ActionType::IssueCertificate => write!(f, "issue_certificate"),
221            ActionType::GenerateCsr => write!(f, "generate_csr"),
222        }
223    }
224}
225
226/// Type of output produced by a ceremony.
227#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
228#[serde(rename_all = "snake_case")]
229#[non_exhaustive]
230pub enum OutputType {
231    /// Public key in PEM format.
232    PublicKey,
233    /// Wrapped (encrypted) key in CMS format.
234    WrappedKey,
235    /// X.509 certificate.
236    Certificate,
237    /// DNSSEC signed resource record set.
238    SignedRrset,
239    /// Certificate Transparency Signed Certificate Timestamp.
240    Sct,
241
242    // Documents
243    /// Generic document.
244    Document,
245    /// Ceremony log or transcript.
246    CeremonyLog,
247}
248
249impl std::fmt::Display for OutputType {
250    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
251        match self {
252            OutputType::PublicKey => write!(f, "public_key"),
253            OutputType::WrappedKey => write!(f, "wrapped_key"),
254            OutputType::Certificate => write!(f, "certificate"),
255            OutputType::SignedRrset => write!(f, "signed_rrset"),
256            OutputType::Sct => write!(f, "sct"),
257            OutputType::Document => write!(f, "document"),
258            OutputType::CeremonyLog => write!(f, "ceremony_log"),
259        }
260    }
261}
262
263impl OutputType {
264    /// Returns the default file extension for this output type.
265    pub fn default_extension(&self) -> &'static str {
266        match self {
267            OutputType::PublicKey | OutputType::Certificate => "pem",
268            OutputType::WrappedKey | OutputType::SignedRrset | OutputType::Sct => "bin",
269            OutputType::Document => "txt",
270            OutputType::CeremonyLog => "json",
271        }
272    }
273}
274
275/// Type of a ceremony parameter.
276#[derive(Debug, Clone, Serialize, Deserialize)]
277#[serde(rename_all = "snake_case")]
278#[non_exhaustive]
279pub enum ParameterType {
280    /// Text value.
281    String,
282    /// Date value (`YYYY-MM-DD`).
283    Date,
284    /// Integer value.
285    Integer,
286    /// Boolean value.
287    Boolean,
288}
289
290impl std::fmt::Display for ParameterType {
291    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
292        match self {
293            ParameterType::String => write!(f, "string"),
294            ParameterType::Date => write!(f, "date (YYYY-MM-DD)"),
295            ParameterType::Integer => write!(f, "integer"),
296            ParameterType::Boolean => write!(f, "boolean (true/false)"),
297        }
298    }
299}
300
301/// Typed preset for common post-ceremony duty categories.
302///
303/// Provides built-in prose for scripts when no description is given.
304/// Use `Custom` for duties that don't fit a preset; description is then required.
305///
306/// Note: physical handling steps (sealing hardware, etc.) belong as `physical_action`
307/// steps in ceremony execution, not here.
308#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
309#[serde(rename_all = "snake_case")]
310#[non_exhaustive]
311pub enum DutyType {
312    /// Return materials to secure storage location.
313    ReturnToVault,
314    /// Hand key shares to assigned custodians.
315    DistributeShares,
316    /// Distribute backup media to designated recipients.
317    DistributeMedia,
318    /// Archive ceremony materials at storage locations.
319    ArchiveMaterials,
320    /// Publish ceremony record and witness attestations.
321    PublishRecord,
322    /// Notify stakeholders of ceremony completion.
323    NotifyStakeholders,
324    /// Import generated keys into operational system.
325    ImportKeys,
326    /// Free-form duty; description is required.
327    Custom,
328}
329
330impl DutyType {
331    /// Returns the built-in display name for this duty type.
332    pub fn display_name(&self) -> &'static str {
333        match self {
334            DutyType::ReturnToVault => "Return to Vault",
335            DutyType::DistributeShares => "Distribute Key Shares",
336            DutyType::DistributeMedia => "Distribute Backup Media",
337            DutyType::ArchiveMaterials => "Archive Materials",
338            DutyType::PublishRecord => "Publish Record",
339            DutyType::NotifyStakeholders => "Notify Stakeholders",
340            DutyType::ImportKeys => "Import Keys",
341            DutyType::Custom => "Custom Duty",
342        }
343    }
344
345    /// Returns the built-in prose description for this duty type.
346    ///
347    /// Returns `None` for `Custom` (description must be provided explicitly).
348    pub fn built_in_prose(&self) -> Option<&'static str> {
349        match self {
350            DutyType::ReturnToVault => Some("Return materials to secure storage"),
351            DutyType::DistributeShares => Some("Distribute key shares to assigned custodians"),
352            DutyType::DistributeMedia => Some("Distribute backup media to designated recipients"),
353            DutyType::ArchiveMaterials => {
354                Some("Archive ceremony materials at designated storage locations")
355            }
356            DutyType::PublishRecord => Some("Publish ceremony record and witness attestations"),
357            DutyType::NotifyStakeholders => Some("Notify stakeholders of ceremony completion"),
358            DutyType::ImportKeys => Some("Import generated keys into operational system"),
359            DutyType::Custom => None,
360        }
361    }
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367
368    #[test]
369    fn role_type_extracts_prefix() {
370        assert_eq!(role_type("witness__1"), "witness");
371        assert_eq!(role_type("hsm_operator__primary"), "hsm_operator");
372        assert_eq!(role_type("operator"), "operator");
373        assert_eq!(role_type("witness__"), "witness");
374    }
375
376    #[test]
377    fn derive_step_name_title_cases() {
378        assert_eq!(derive_step_name("verify_time"), "Verify Time");
379        assert_eq!(derive_step_name("generate_root_ca"), "Generate Root Ca");
380        assert_eq!(derive_step_name("wrap_root_ca_key"), "Wrap Root Ca Key");
381        assert_eq!(derive_step_name("witness1_attest"), "Witness1 Attest");
382        assert_eq!(derive_step_name("clock-check"), "Clock Check");
383        assert_eq!(derive_step_name("opening"), "Opening");
384    }
385
386    #[test]
387    fn derive_role_name_title_cases() {
388        assert_eq!(derive_role_name("witness__1"), "Witness 1");
389        assert_eq!(
390            derive_role_name("hsm_operator__primary"),
391            "Hsm Operator Primary"
392        );
393        assert_eq!(derive_role_name("operator"), "Operator");
394        assert_eq!(derive_role_name("ceremony-admin"), "Ceremony Admin");
395    }
396
397    #[test]
398    fn action_type_serde_roundtrip() {
399        // `snake_case` rename means these exact strings appear in ceremony YAML.
400        // A variant rename or serde attr change breaks YAML parsing silently.
401        let cases: &[(ActionType, &str)] = &[
402            (ActionType::ClockCheck, "\"clock_check\""),
403            (ActionType::Confirm, "\"confirm\""),
404            (ActionType::CheckValue, "\"check_value\""),
405            (ActionType::OralReadback, "\"oral_readback\""),
406            (ActionType::MachineInfo, "\"machine_info\""),
407            (ActionType::GenerateKeypair, "\"generate_keypair\""),
408            (ActionType::WrapKey, "\"wrap_key\""),
409            (ActionType::UnwrapKey, "\"unwrap_key\""),
410            (ActionType::ExportPublic, "\"export_public\""),
411            (ActionType::Attest, "\"attest\""),
412            (ActionType::TpmAttest, "\"tpm_attest\""),
413            (ActionType::PivReadCertificate, "\"piv_read_certificate\""),
414            (ActionType::PivSign, "\"piv_sign\""),
415            (ActionType::YubikeyAttestSlot, "\"yubikey_attest_slot\""),
416            (ActionType::IssueCertificate, "\"issue_certificate\""),
417            (ActionType::GenerateCsr, "\"generate_csr\""),
418        ];
419        for &(variant, expected) in cases {
420            let serialized = serde_json::to_string(&variant).unwrap();
421            assert_eq!(serialized, expected, "serialize {variant:?}");
422            let deserialized: ActionType = serde_json::from_str(expected).unwrap();
423            assert_eq!(deserialized, variant, "deserialize {expected}");
424        }
425    }
426
427    #[test]
428    fn action_type_display_matches_serde() {
429        // Display is used in transcript output and error messages; serde in YAML parsing.
430        // They must agree or transcripts reference action names that differ from YAML.
431        let actions = [
432            ActionType::ClockCheck,
433            ActionType::Confirm,
434            ActionType::CheckValue,
435            ActionType::OralReadback,
436            ActionType::MachineInfo,
437            ActionType::GenerateKeypair,
438            ActionType::WrapKey,
439            ActionType::UnwrapKey,
440            ActionType::ExportPublic,
441            ActionType::Attest,
442            ActionType::TpmAttest,
443            ActionType::PivReadCertificate,
444            ActionType::PivSign,
445            ActionType::YubikeyAttestSlot,
446            ActionType::IssueCertificate,
447            ActionType::GenerateCsr,
448        ];
449        for action in actions {
450            let display = action.to_string();
451            let serde_json = serde_json::to_string(&action).unwrap();
452            assert_eq!(
453                display,
454                serde_json.trim_matches('"'),
455                "Display and serde disagree for {action:?}"
456            );
457        }
458    }
459
460    #[test]
461    fn output_type_serde_roundtrip() {
462        // These strings appear in ceremony YAML `output:` blocks and in file extension
463        // mapping. A rename breaks both YAML parsing and output file naming.
464        let cases: &[(OutputType, &str)] = &[
465            (OutputType::PublicKey, "\"public_key\""),
466            (OutputType::WrappedKey, "\"wrapped_key\""),
467            (OutputType::Certificate, "\"certificate\""),
468            (OutputType::Document, "\"document\""),
469            (OutputType::CeremonyLog, "\"ceremony_log\""),
470        ];
471        for &(variant, expected) in cases {
472            let serialized = serde_json::to_string(&variant).unwrap();
473            assert_eq!(serialized, expected, "serialize {variant:?}");
474            let deserialized: OutputType = serde_json::from_str(expected).unwrap();
475            assert_eq!(deserialized, variant, "deserialize {expected}");
476        }
477    }
478
479    #[test]
480    fn parameter_type_serde_roundtrip() {
481        // These strings appear in ceremony YAML `parameters:` blocks.
482        let cases: &[(ParameterType, &str)] = &[
483            (ParameterType::String, "\"string\""),
484            (ParameterType::Date, "\"date\""),
485            (ParameterType::Integer, "\"integer\""),
486            (ParameterType::Boolean, "\"boolean\""),
487        ];
488        for (variant, expected) in cases {
489            let serialized = serde_json::to_string(variant).unwrap();
490            assert_eq!(serialized, *expected, "serialize {variant:?}");
491            let deserialized: ParameterType = serde_json::from_str(expected).unwrap();
492            // ParameterType does not implement PartialEq; check the display instead.
493            assert_eq!(
494                serde_json::to_string(&deserialized).unwrap(),
495                *expected,
496                "deserialize {expected}"
497            );
498        }
499    }
500
501    #[test]
502    fn duty_type_serde_roundtrip() {
503        // These strings appear in ceremony YAML `after:` blocks.
504        let cases: &[(DutyType, &str)] = &[
505            (DutyType::ReturnToVault, "\"return_to_vault\""),
506            (DutyType::DistributeShares, "\"distribute_shares\""),
507            (DutyType::DistributeMedia, "\"distribute_media\""),
508            (DutyType::ArchiveMaterials, "\"archive_materials\""),
509            (DutyType::PublishRecord, "\"publish_record\""),
510            (DutyType::NotifyStakeholders, "\"notify_stakeholders\""),
511            (DutyType::ImportKeys, "\"import_keys\""),
512            (DutyType::Custom, "\"custom\""),
513        ];
514        for (variant, expected) in cases {
515            let serialized = serde_json::to_string(variant).unwrap();
516            assert_eq!(serialized, *expected, "serialize {variant:?}");
517            let deserialized: DutyType = serde_json::from_str(expected).unwrap();
518            assert_eq!(deserialized, *variant, "deserialize {expected}");
519        }
520    }
521
522    #[test]
523    fn duty_type_custom_has_no_built_in_prose() {
524        // The runtime branches on this: if built_in_prose returns None, the description
525        // field is required. A regression here would panic or produce empty script output.
526        assert!(
527            DutyType::Custom.built_in_prose().is_none(),
528            "Custom duty must have no built-in prose"
529        );
530        // All other variants must have prose.
531        for duty in [
532            DutyType::ReturnToVault,
533            DutyType::DistributeShares,
534            DutyType::DistributeMedia,
535            DutyType::ArchiveMaterials,
536            DutyType::PublishRecord,
537            DutyType::NotifyStakeholders,
538            DutyType::ImportKeys,
539        ] {
540            assert!(
541                duty.built_in_prose().is_some(),
542                "{duty:?} must have built-in prose"
543            );
544        }
545    }
546}