Skip to main content

rite_model/
types.rs

1//! Shared semantic types for the ceremony domain model.
2
3use serde::{Deserialize, Serialize};
4use std::fmt;
5
6/// Ceremony metadata (name and optional description).
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct Metadata {
9    /// Human-readable ceremony name.
10    pub name: String,
11    /// Optional description.
12    #[serde(skip_serializing_if = "Option::is_none")]
13    pub description: Option<String>,
14}
15
16/// Extract the role type from a role ID.
17///
18/// `"witness__1"` → `"witness"`, `"operator"` → `"operator"`.
19pub fn role_type(id: &str) -> &str {
20    id.split_once("__").map_or(id, |(prefix, _)| prefix)
21}
22
23/// Derive a display name from a step or section ID.
24///
25/// Splits on `_` and `-`, title-cases each word, joins with space.
26/// `"verify_time"` → `"Verify Time"`, `"generate_root_ca"` → `"Generate Root Ca"`.
27///
28/// Note: this naive title-casing does not handle acronyms; `"root_ca"` becomes `"Root Ca"`
29/// rather than `"Root CA"`. A lookup table for common ceremony acronyms (CA, CSR, PKI, HSM,
30/// TPM) could improve this. See `derive_role_name` for the same limitation.
31pub fn derive_step_name(id: &str) -> String {
32    id.split(['_', '-'])
33        .map(|word| {
34            let mut chars = word.chars();
35            match chars.next() {
36                None => String::new(),
37                Some(c) => c.to_uppercase().to_string() + chars.as_str(),
38            }
39        })
40        .collect::<Vec<_>>()
41        .join(" ")
42}
43
44/// Derive a display name from a role ID.
45///
46/// Title-cases each word (split on `_`, `-`, and `__`), including the discriminator suffix.
47/// `"witness__1"` → `"Witness 1"`, `"hsm_operator__primary"` → `"Hsm Operator Primary"`.
48pub fn derive_role_name(id: &str) -> String {
49    id.replace("__", " ")
50        .split(['_', '-', ' '])
51        .filter(|s| !s.is_empty())
52        .map(|word| {
53            let mut chars = word.chars();
54            match chars.next() {
55                None => String::new(),
56                Some(c) => c.to_uppercase().to_string() + chars.as_str(),
57            }
58        })
59        .collect::<Vec<_>>()
60        .join(" ")
61}
62
63/// Action types available in ceremony steps.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
65#[serde(rename_all = "snake_case")]
66#[non_exhaustive]
67pub enum ActionType {
68    /// Verify system clock is correct before ceremony proceeds.
69    ///
70    /// Displays current time and requires operator confirmation.
71    /// Should be placed as the first step to ensure all timestamps are valid.
72    ClockCheck,
73    /// Human attests to something with yes/no decision (single person).
74    ///
75    /// Use when the verification requires human judgment about external state.
76    /// Example: "Verify all network cables are disconnected".
77    Confirm,
78    /// Machine compares two known values and records pass/fail (automatic).
79    ///
80    /// Use when both values are known to the system and comparison is deterministic.
81    /// Example: Compare computed `SHA-256` hash against expected value.
82    CheckValue,
83    /// Two-party verbal verification: reader speaks value aloud, confirmer verifies.
84    ///
85    /// Use when a value must be verified against something external (physical label,
86    /// document). Supports NATO phonetic alphabet and hex formatting.
87    OralReadback,
88    /// Show a value on screen for a person to write down, then withdraw it.
89    ///
90    /// Reads `value:`, a share or any byte artifact, and shows it in a
91    /// chosen encoding while a prompt is up; on acknowledgement it is gone.
92    /// The transcript records that the value was shown and nothing of it.
93    /// `rite script` prints a page for the step, one box per character
94    /// in rows, the parity cells set apart.
95    Reveal,
96    /// Capture machine information (hostname, CPU, OS) as evidence.
97    ///
98    /// Records device identity to prove which machine ran the ceremony.
99    /// Should be placed early in ceremony to establish machine context.
100    MachineInfo,
101    /// A person types a value the ceremony needs and can record: a serial
102    /// number read off a device, an address shown on a screen.
103    ///
104    /// The value becomes a text artifact, so a later step compares it or
105    /// prints it, and the transcript carries it. `format:` and the length
106    /// fields say what shape the value has, so a slip is refused at the
107    /// keyboard rather than found at the end.
108    EnterValue,
109    /// A person types a secret the ceremony needs and must not record: a
110    /// passphrase, a PIN.
111    ///
112    /// `enter_value` for a value the transcript may not carry. Echo is off,
113    /// the artifact is held wiped in memory, and the transcript records that
114    /// a secret was entered at this step and nothing derived from it. A step
115    /// names it in `reads:`, which borrows the value. An expression in
116    /// `with:` copies it into the step's parameters, as it would opened
117    /// content.
118    EnterSecret,
119
120    /// Generate a key: a keypair for an asymmetric algorithm, one secret for
121    /// a symmetric one.
122    GenerateKey,
123    /// Encrypt a private key under another key so it can leave the machine.
124    ///
125    /// The OpenSSL backend produces a CMS `AuthEnvelopedData` under
126    /// AES-256-GCM. Which encapsulation that takes follows the recipient key,
127    /// and the transcript records what the wrap actually did.
128    ///
129    /// Reads `wrapping_key:` to wrap under a key the backend holds, or
130    /// `recipient:` to wrap to a public key held outside the ceremony.
131    WrapKey,
132    /// Decrypt a wrapped key and import it into a backend under a new label.
133    UnwrapKey,
134    /// Encrypt content for a recipient, producing an encrypted-data artifact.
135    ///
136    /// `wrap_key` for bytes that are not a key. The container is the same one,
137    /// and the claim is not: a wrap says a key left a backend under protection,
138    /// while encrypted content makes no custody claim at all.
139    ///
140    /// Reads `encryption_key:`, a key-encryption key the backend holds. The key
141    /// protects a fresh content-encryption key, and the content is encrypted
142    /// under that.
143    EncryptData,
144    /// Decrypt an encrypted-data artifact back to bytes.
145    ///
146    /// The bytes become an ordinary artifact, so a later step reads them as a
147    /// material: `import_key` lifts them into a key, `check_value` compares
148    /// them, `sign_data` signs them.
149    DecryptData,
150    /// Split a secret into shares of which a threshold reconstruct it.
151    ///
152    /// Shamir over GF(2^8), the `rite-sss/v1` format, with the polynomial
153    /// coefficients drawn from the step's backend. Reads `secret:` and
154    /// creates one artifact holding every share, reached as
155    /// `${artifact.<name>.share_N}` under a later step's `reads:`. At most
156    /// 100 shares. Every subset of `threshold` shares is combined and
157    /// checked before the step completes, so a share that leaves the room
158    /// has been shown to work; a split with more than 100,000 such subsets
159    /// is refused at run time.
160    SplitSecret,
161    /// Reconstruct a secret from shares `split_secret` made.
162    ///
163    /// Reads `shares:`, a list of at least two, each a share of a set or a
164    /// share a custodian typed back. Each share records how many are
165    /// needed, so too few shares is an error before anything is computed.
166    /// The result stays in memory and is erased when the run ends, like the
167    /// content `decrypt_data` opens.
168    CombineShares,
169    /// Type a share back from its sheet, row by row.
170    ///
171    /// Each row is checked as it is typed: a wrong character is corrected
172    /// and named, two unreadable ones are recovered, and a row that needs
173    /// more is typed again. Rows that are not a share are refused before
174    /// the step ends. Creates a share `combine_shares` reads, named without
175    /// a `share_N` property. The transcript records the share's index and
176    /// which rows were repaired, never the rows.
177    EnterShare,
178    /// Install key material the ceremony holds as a key of a named algorithm.
179    ///
180    /// `unwrap_key` without the decrypt. The bytes can be a material carried
181    /// into the room or an artifact an earlier step produced, and `algorithm:`
182    /// says how to read them, because raw material says nothing about itself.
183    ImportKey,
184    /// Export public key from keypair.
185    ExportPublic,
186    /// Sign arbitrary data with a backend-managed key.
187    ///
188    /// The signature algorithm follows from the key unless `algorithm:` names
189    /// another one the key accepts. Requires a backend implementing `SignBackend`.
190    SignData,
191    /// Verify a signature over data, given the signer's public key.
192    ///
193    /// Needs no backend: verification takes only a public key, so it works on
194    /// evidence the ceremony did not produce. The key may be a bare public key
195    /// or a certificate carrying one. Naming a `backend:` delegates the check
196    /// to that backend instead.
197    VerifySignature,
198    /// Formal attestation statement.
199    Attest,
200    /// Fold human-supplied entropy into the ceremony seed.
201    ///
202    /// A participant supplies a free-form random value (for example, the
203    /// result of rolling physical dice), which is mixed into the entropy
204    /// source's ratchet. Any later drawn value reflects the contribution and
205    /// stays re-derivable by `rite verify`.
206    GatherEntropy,
207    /// TPM attestation with PCR measurements and cryptographic quotes.
208    ///
209    /// Requires the `rite-tpm` backend.
210    TpmAttest,
211
212    /// Read X.509 certificate from PIV smart card slot.
213    ///
214    /// No PIN required; reading certificates is unauthenticated on PIV cards.
215    /// Requires a PIV backend.
216    PivReadCertificate,
217    /// Sign data using PIV smart card on-device key.
218    ///
219    /// Handles PIN verification internally before signing.
220    /// Requires a PIV backend.
221    PivSign,
222    /// Generate a `YubiKey` attestation certificate for a PIV slot (Yubico extension).
223    ///
224    /// Slot `F9` signs the key's certificate to prove it was generated on-device.
225    /// Requires the `rite-yubikey` backend.
226    YubikeyAttestSlot,
227
228    /// Issue an X.509 certificate from a PKCS#10 CSR.
229    ///
230    /// Takes a CSR and a backend-managed signing key, assembles the `TBSCertificate`,
231    /// signs it via the backend's `SignBackend`, and produces a DER-encoded certificate.
232    /// Works with any backend implementing `SignBackend` (software, PKCS#11, `YubiKey`).
233    IssueCertificate,
234    /// Generate a PKCS#10 CSR signed by a backend-managed key.
235    ///
236    /// Takes a backend-managed signing key and subject parameters, assembles a
237    /// `CertReqInfo`, signs it via the backend's `SignBackend`, and produces a
238    /// `DER`-encoded `CSR`.
239    GenerateCsr,
240}
241
242/// Whether an action needs the `backend:` field on its step.
243///
244/// Three states, not two: an action can also run without a backend and accept
245/// one anyway. Verification is the case that forces the distinction, since a
246/// signature check needs only a public key but a validated deployment may
247/// require it inside the boundary.
248#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
249#[serde(rename_all = "snake_case")]
250#[non_exhaustive]
251pub enum BackendUsage {
252    /// The step must name a backend; omitting it is an error.
253    Required,
254    /// The step runs in software unless it names a backend, which then does the
255    /// work instead. Naming one changes who performs the operation, not what
256    /// the step accepts or produces.
257    SoftwareUnlessNamed,
258    /// The action never uses a backend; naming one is a mistake worth warning about.
259    Unused,
260}
261
262/// The certificate shape an `issue_certificate` step asks for.
263///
264/// DSL vocabulary rather than a backend concern: the author writes the name,
265/// and the handler turns it into X.509 extensions. `path_len` is a separate
266/// parameter, not part of a profile's identity, so this enum carries no
267/// run-specific values.
268#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
269#[serde(rename_all = "snake_case")]
270#[non_exhaustive]
271pub enum CertProfile {
272    /// Self-signed CA at the top of a chain.
273    RootCa,
274    /// CA below another, constrained by `path_len`.
275    SubCa,
276    /// TLS server certificate.
277    TlsServer,
278    /// Code-signing certificate.
279    CodeSigning,
280    /// A leaf with no CA rights and no extended key usage.
281    EndEntity,
282}
283
284impl fmt::Display for CertProfile {
285    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
286        match self {
287            CertProfile::RootCa => write!(f, "root_ca"),
288            CertProfile::SubCa => write!(f, "sub_ca"),
289            CertProfile::TlsServer => write!(f, "tls_server"),
290            CertProfile::CodeSigning => write!(f, "code_signing"),
291            CertProfile::EndEntity => write!(f, "end_entity"),
292        }
293    }
294}
295
296impl std::str::FromStr for CertProfile {
297    type Err = UnknownCertProfile;
298
299    fn from_str(s: &str) -> Result<Self, Self::Err> {
300        match s {
301            "root_ca" => Ok(Self::RootCa),
302            // `intermediate_ca` is the same shape under the name the PKI
303            // world more often uses for it.
304            "sub_ca" | "intermediate_ca" => Ok(Self::SubCa),
305            "tls_server" => Ok(Self::TlsServer),
306            "code_signing" => Ok(Self::CodeSigning),
307            "end_entity" => Ok(Self::EndEntity),
308            _ => Err(UnknownCertProfile),
309        }
310    }
311}
312
313/// A `profile:` value naming no certificate shape.
314#[derive(Debug, Clone, Copy, PartialEq, Eq)]
315pub struct UnknownCertProfile;
316
317impl fmt::Display for UnknownCertProfile {
318    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
319        write!(
320            f,
321            "supported profiles: root_ca, sub_ca, tls_server, code_signing, end_entity"
322        )
323    }
324}
325
326/// The secret sharing scheme a `split_secret` step names.
327///
328/// DSL vocabulary, like [`CertProfile`]: the author writes the name and the
329/// transcript records it. One scheme today. The enum exists so a second one
330/// (a prime field, a verifiable variant, codex32) is a variant rather than a
331/// flag, and so a name this build does not implement is refused at `rite
332/// check`.
333#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
334#[serde(into = "String", try_from = "String")]
335#[non_exhaustive]
336pub enum SharingScheme {
337    /// Shamir over GF(2^8), one polynomial per byte, the `rite-sss/v1`
338    /// share format.
339    RiteSssV1,
340}
341
342impl SharingScheme {
343    /// The name a ceremony writes and the transcript records.
344    pub fn as_str(self) -> &'static str {
345        match self {
346            SharingScheme::RiteSssV1 => "rite-sss/v1",
347        }
348    }
349
350    /// The most shares a split under this scheme may make.
351    ///
352    /// A policy limit rather than what the arithmetic allows: a share index
353    /// is a byte, so `rite-sss/v1` could evaluate 255 points, and 100 is a
354    /// round number well past any split a room of custodians receives. Kept
355    /// low on purpose, since raising it later costs nothing and lowering it
356    /// would break a ceremony that ran. It also leaves the indexes SLIP-0039
357    /// reserves (254 for a digest, 255 for the secret) free for a scheme that
358    /// wants them.
359    pub fn max_shares(self) -> u8 {
360        match self {
361            SharingScheme::RiteSssV1 => 100,
362        }
363    }
364
365    /// The bytes in front of a share's values: for `rite-sss/v1`, a
366    /// version, a threshold and an index.
367    #[must_use]
368    pub fn header_len(self) -> usize {
369        match self {
370            SharingScheme::RiteSssV1 => 3,
371        }
372    }
373
374    /// The length in bytes of one share of a secret of `secret_len` bytes,
375    /// as a sheet or a transport holds it: the header, then one byte per
376    /// byte of the secret.
377    #[must_use]
378    pub fn share_len(self, secret_len: usize) -> usize {
379        match self {
380            SharingScheme::RiteSssV1 => secret_len.saturating_add(self.header_len()),
381        }
382    }
383}
384
385impl fmt::Display for SharingScheme {
386    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
387        f.write_str(self.as_str())
388    }
389}
390
391impl From<SharingScheme> for String {
392    fn from(scheme: SharingScheme) -> Self {
393        scheme.as_str().to_string()
394    }
395}
396
397impl std::str::FromStr for SharingScheme {
398    type Err = UnknownSharingScheme;
399
400    fn from_str(s: &str) -> Result<Self, Self::Err> {
401        match s {
402            "rite-sss/v1" => Ok(Self::RiteSssV1),
403            _ => Err(UnknownSharingScheme),
404        }
405    }
406}
407
408impl TryFrom<String> for SharingScheme {
409    type Error = UnknownSharingScheme;
410
411    fn try_from(s: String) -> Result<Self, Self::Error> {
412        s.parse()
413    }
414}
415
416/// A `scheme:` value naming no sharing scheme this build implements.
417#[derive(Debug, Clone, Copy, PartialEq, Eq)]
418pub struct UnknownSharingScheme;
419
420impl fmt::Display for UnknownSharingScheme {
421    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
422        write!(f, "supported sharing schemes: rite-sss/v1")
423    }
424}
425
426impl std::error::Error for UnknownSharingScheme {}
427
428/// What an action requires of its step's `reads:` map.
429///
430/// See [`ActionType::reads_contract`].
431#[derive(Debug, Clone, Copy, PartialEq, Eq)]
432#[non_exhaustive]
433pub struct ReadsContract {
434    /// Inputs every step of this action must name.
435    pub required: &'static [&'static str],
436    /// Groups from which exactly one input must be named. Each group holds the
437    /// alternatives in the order they should be listed to the author.
438    pub exactly_one_of: &'static [&'static [&'static str]],
439    /// Inputs a step may name and the action reads when it does.
440    ///
441    /// Listed so that a key the action would never look at is refused rather
442    /// than dropped: a `reads:` entry that nothing reads is a step running
443    /// without what its author gave it.
444    pub optional: &'static [&'static str],
445    /// `with:` fields that mean nothing unless the step also names a given
446    /// input, as `(field, input)` pairs.
447    ///
448    /// Where an action offers two paths over one operation, a parameter can
449    /// belong to only one of them. On the other path it would be parsed and
450    /// never read, so the step would run without whatever the parameter asked
451    /// for. This says which parameter needs which input.
452    pub with_field_requires: &'static [(&'static str, &'static str)],
453    /// Inputs that hold a list of references rather than one, each with the
454    /// fewest entries a step may give.
455    ///
456    /// For an action that takes a set whose size the ceremony chooses, such
457    /// as the shares of a split. Every other input named in this contract
458    /// holds one reference, and the resolver reports a list where one is
459    /// expected as it reports one where a list is.
460    pub lists: &'static [ListInput],
461}
462
463/// A `reads:` input that holds a list of references.
464#[derive(Debug, Clone, Copy, PartialEq, Eq)]
465pub struct ListInput {
466    /// The key under `reads:`.
467    pub name: &'static str,
468    /// Fewest references the list may hold.
469    pub at_least: usize,
470}
471
472impl ReadsContract {
473    /// The contract of an action that constrains its inputs in neither way.
474    pub const NONE: Self = Self::required(&[]);
475
476    /// The contract of an action that requires these inputs and offers no
477    /// alternatives between them.
478    #[must_use]
479    pub const fn required(fields: &'static [&'static str]) -> Self {
480        Self {
481            required: fields,
482            exactly_one_of: &[],
483            optional: &[],
484            with_field_requires: &[],
485            lists: &[],
486        }
487    }
488
489    /// Whether the input named `key` holds a list under this contract.
490    pub fn is_list(&self, key: &str) -> bool {
491        self.lists.iter().any(|list| list.name == key)
492    }
493
494    /// Whether this contract constrains the `reads:` map at all.
495    ///
496    /// `with_field_requires` is a rule over `with:`, so it is not counted here.
497    pub fn is_empty(&self) -> bool {
498        self.required.is_empty()
499            && self.exactly_one_of.is_empty()
500            && self.optional.is_empty()
501            && self.lists.is_empty()
502    }
503
504    /// Whether a step of this action may name this input.
505    pub fn accepts(&self, key: &str) -> bool {
506        self.required.contains(&key)
507            || self.optional.contains(&key)
508            || self.exactly_one_of.iter().any(|group| group.contains(&key))
509            || self.lists.iter().any(|list| list.name == key)
510    }
511}
512
513impl ActionType {
514    /// Every action type, for callers that need to enumerate them.
515    ///
516    /// Maintained by hand: `#[non_exhaustive]` means no downstream crate can
517    /// derive this list, and adding a variant produces no error outside this
518    /// crate. Adding one here is what keeps editor completion and any other
519    /// catalogue from silently missing it. The test below catches omissions.
520    pub const ALL: &'static [ActionType] = &[
521        ActionType::ClockCheck,
522        ActionType::Confirm,
523        ActionType::CheckValue,
524        ActionType::OralReadback,
525        ActionType::Reveal,
526        ActionType::MachineInfo,
527        ActionType::EnterValue,
528        ActionType::EnterSecret,
529        ActionType::GenerateKey,
530        ActionType::WrapKey,
531        ActionType::UnwrapKey,
532        ActionType::ImportKey,
533        ActionType::EncryptData,
534        ActionType::DecryptData,
535        ActionType::SplitSecret,
536        ActionType::CombineShares,
537        ActionType::EnterShare,
538        ActionType::ExportPublic,
539        ActionType::SignData,
540        ActionType::VerifySignature,
541        ActionType::Attest,
542        ActionType::GatherEntropy,
543        ActionType::TpmAttest,
544        ActionType::PivReadCertificate,
545        ActionType::PivSign,
546        ActionType::YubikeyAttestSlot,
547        ActionType::IssueCertificate,
548        ActionType::GenerateCsr,
549    ];
550
551    /// How this action relates to the `backend:` field on its step.
552    ///
553    /// TODO: Replace this with a nested enum split — `ActionType::Backend(BackendAction)` vs
554    /// `ActionType::Local(LocalAction)`, once a home is found for the actions that are
555    /// neither. This is a breaking change to every match on `ActionType` variants.
556    pub fn backend_usage(self) -> BackendUsage {
557        match self {
558            ActionType::GenerateKey
559            | ActionType::SignData
560            | ActionType::WrapKey
561            | ActionType::UnwrapKey
562            | ActionType::ExportPublic
563            | ActionType::GenerateCsr
564            | ActionType::IssueCertificate
565            | ActionType::PivReadCertificate
566            | ActionType::PivSign
567            | ActionType::YubikeyAttestSlot
568            | ActionType::ImportKey
569            | ActionType::EncryptData
570            | ActionType::DecryptData
571            | ActionType::SplitSecret
572            | ActionType::TpmAttest => BackendUsage::Required,
573
574            ActionType::VerifySignature => BackendUsage::SoftwareUnlessNamed,
575
576            ActionType::ClockCheck
577            | ActionType::Confirm
578            | ActionType::CheckValue
579            | ActionType::OralReadback
580            | ActionType::Reveal
581            | ActionType::MachineInfo
582            | ActionType::EnterValue
583            | ActionType::EnterSecret
584            | ActionType::Attest
585            | ActionType::CombineShares
586            | ActionType::EnterShare
587            | ActionType::GatherEntropy => BackendUsage::Unused,
588        }
589    }
590
591    /// Whether the artifact this action creates is shares of a secret, which
592    /// `rite check` tells from the step that made it, since the definition
593    /// does not say what kind an artifact is.
594    pub fn creates_shares(self) -> bool {
595        match self {
596            ActionType::SplitSecret | ActionType::EnterShare => true,
597            ActionType::ClockCheck
598            | ActionType::Confirm
599            | ActionType::CheckValue
600            | ActionType::OralReadback
601            | ActionType::Reveal
602            | ActionType::MachineInfo
603            | ActionType::EnterValue
604            | ActionType::EnterSecret
605            | ActionType::GenerateKey
606            | ActionType::WrapKey
607            | ActionType::UnwrapKey
608            | ActionType::ImportKey
609            | ActionType::EncryptData
610            | ActionType::DecryptData
611            | ActionType::CombineShares
612            | ActionType::ExportPublic
613            | ActionType::SignData
614            | ActionType::VerifySignature
615            | ActionType::GenerateCsr
616            | ActionType::IssueCertificate
617            | ActionType::PivReadCertificate
618            | ActionType::PivSign
619            | ActionType::YubikeyAttestSlot
620            | ActionType::Attest
621            | ActionType::GatherEntropy
622            | ActionType::TpmAttest => false,
623        }
624    }
625
626    /// Returns the `with:` field names that are required for this action.
627    ///
628    /// The resolver reports a diagnostic for each missing field.
629    ///
630    /// TODO: Replace the stringly-typed `with: serde_json::Value` in `schema::StepBody` with a
631    /// typed `WithFields` enum (one variant per action, carrying its required fields as named
632    /// struct members). Serde would then enforce required fields at parse time and this method
633    /// would no longer be needed. This is a breaking change to the schema and lowering layers.
634    pub fn required_with_fields(self) -> &'static [&'static str] {
635        match self {
636            ActionType::CheckValue => &["actual", "expected"],
637            ActionType::GenerateCsr => &["subject"],
638            // Raw material carries no description, so the ceremony declares
639            // what it is lifting rather than the step guessing.
640            ActionType::ImportKey => &["algorithm"],
641            ActionType::SplitSecret => &["threshold", "shares"],
642            // The label is what the person sees at the keyboard or on the
643            // sheet, and there is no default that names what it is.
644            ActionType::EnterValue
645            | ActionType::EnterSecret
646            | ActionType::Reveal
647            | ActionType::EnterShare => &["message"],
648
649            ActionType::ClockCheck
650            | ActionType::Confirm
651            | ActionType::OralReadback
652            | ActionType::MachineInfo
653            | ActionType::GenerateKey
654            | ActionType::WrapKey
655            | ActionType::UnwrapKey
656            | ActionType::ExportPublic
657            | ActionType::SignData
658            | ActionType::VerifySignature
659            | ActionType::Attest
660            | ActionType::GatherEntropy
661            | ActionType::TpmAttest
662            | ActionType::PivReadCertificate
663            | ActionType::PivSign
664            | ActionType::YubikeyAttestSlot
665            | ActionType::EncryptData
666            | ActionType::DecryptData
667            | ActionType::CombineShares
668            | ActionType::IssueCertificate => &[],
669        }
670    }
671
672    /// Every `with:` key this action accepts.
673    ///
674    /// Nothing else may appear under `with:`. Serde ignores a key it does not
675    /// know, so without this a misspelled field, or one that used to exist, is
676    /// dropped in silence and the ceremony quietly stops doing what its author
677    /// wrote. That is the failure this list exists to prevent, and it covers
678    /// typos and removed fields alike rather than naming them one at a time.
679    ///
680    /// Required keys are the subset in
681    /// [`required_with_fields`](Self::required_with_fields); the rest are
682    /// optional. Nested keys are not listed: only the top level of the block
683    /// is checked, because a nested shape belongs to the handler's own params
684    /// type.
685    pub fn known_with_fields(self) -> &'static [&'static str] {
686        match self {
687            ActionType::ClockCheck | ActionType::Confirm => &["message"],
688            ActionType::CheckValue => &["actual", "expected", "message", "sensitive"],
689            ActionType::OralReadback => &["value", "format", "characters", "message"],
690            ActionType::Reveal | ActionType::EnterShare => &["message", "format", "note", "length"],
691            ActionType::MachineInfo => &[
692                "include_machine_id",
693                "include_cpu",
694                "include_os",
695                "include_security_features",
696                "message",
697            ],
698            ActionType::EnterValue | ActionType::EnterSecret => {
699                &["message", "format", "length", "min_length", "max_length"]
700            }
701            ActionType::GenerateKey => &["algorithm", "policy", "slot"],
702            ActionType::WrapKey => &["scheme", "expect_recipient"],
703            ActionType::UnwrapKey | ActionType::ImportKey => {
704                &["algorithm", "expect_key", "label", "policy"]
705            }
706            ActionType::EncryptData => &["scheme"],
707            ActionType::SplitSecret => &["scheme", "threshold", "shares"],
708            ActionType::SignData | ActionType::VerifySignature => &["algorithm", "message"],
709            ActionType::Attest => &["statement"],
710            ActionType::GatherEntropy => &["instruction"],
711            ActionType::IssueCertificate => &["profile", "validity_days", "issuer_cn", "path_len"],
712            ActionType::GenerateCsr => &["subject", "san"],
713            ActionType::PivReadCertificate | ActionType::YubikeyAttestSlot => &["slot", "message"],
714            ActionType::PivSign => &["slot", "algorithm", "message"],
715            // `export_public` and `decrypt_data` take their inputs through
716            // `reads:` alone, and `tpm_attest` has no handler in any build yet,
717            // so none of the three has a `with:` shape to accept. For the last,
718            // `unsupported_actions` is what reports the step itself.
719            ActionType::ExportPublic
720            | ActionType::DecryptData
721            | ActionType::CombineShares
722            | ActionType::TpmAttest => &[],
723        }
724    }
725
726    /// Returns the `reads:` inputs this action requires, and the groups from
727    /// which exactly one input must be named.
728    ///
729    /// A group is how an action offers two paths over the same operation. The
730    /// input the author names selects the path, so naming both is ambiguous and
731    /// naming neither leaves the path undetermined. The resolver reports either
732    /// on the step.
733    pub fn reads_contract(self) -> ReadsContract {
734        match self {
735            ActionType::WrapKey => ReadsContract {
736                required: &["key_to_wrap"],
737                exactly_one_of: &[&["wrapping_key", "recipient"]],
738                optional: &[],
739                // `expect_recipient` is compared against the recipient a wrap
740                // is given, and only the external path has one.
741                with_field_requires: &[("expect_recipient", "recipient")],
742                lists: &[],
743            },
744            ActionType::UnwrapKey => ReadsContract::required(&["unwrapping_key", "wrapped_data"]),
745            ActionType::ImportKey => ReadsContract {
746                required: &["key_material"],
747                exactly_one_of: &[],
748                // What opens an encrypted private key. Read from the store
749                // rather than given in `with:`, so the value is borrowed and
750                // never copied into the step's parameters.
751                optional: &["passphrase"],
752                with_field_requires: &[],
753                lists: &[],
754            },
755            ActionType::EncryptData => ReadsContract::required(&["data", "encryption_key"]),
756            ActionType::DecryptData => {
757                ReadsContract::required(&["encrypted_data", "decryption_key"])
758            }
759            ActionType::SplitSecret => ReadsContract::required(&["secret"]),
760            // Two is the smallest threshold; the shares themselves say
761            // whether two is enough, at run time.
762            ActionType::CombineShares => ReadsContract {
763                required: &[],
764                exactly_one_of: &[],
765                optional: &[],
766                with_field_requires: &[],
767                lists: &[ListInput {
768                    name: "shares",
769                    at_least: 2,
770                }],
771            },
772            ActionType::SignData => ReadsContract::required(&["key", "data"]),
773            ActionType::VerifySignature => ReadsContract::required(&["key", "data", "signature"]),
774            // Without `issuer_cert` the certificate is self-issued under the
775            // CSR's subject; with it, the issuer name and key identifier come
776            // from the CA certificate.
777            ActionType::IssueCertificate => ReadsContract {
778                required: &["signing_key", "csr"],
779                exactly_one_of: &[],
780                optional: &["issuer_cert"],
781                with_field_requires: &[],
782                lists: &[],
783            },
784            ActionType::GenerateCsr => ReadsContract::required(&["signing_key"]),
785            ActionType::Reveal => ReadsContract::required(&["value"]),
786
787            ActionType::ClockCheck
788            | ActionType::Confirm
789            | ActionType::CheckValue
790            | ActionType::OralReadback
791            | ActionType::MachineInfo
792            | ActionType::EnterValue
793            | ActionType::EnterSecret
794            | ActionType::GenerateKey
795            | ActionType::ExportPublic
796            | ActionType::Attest
797            | ActionType::GatherEntropy
798            | ActionType::TpmAttest
799            | ActionType::PivReadCertificate
800            | ActionType::PivSign
801            | ActionType::YubikeyAttestSlot
802            | ActionType::EnterShare => ReadsContract::NONE,
803        }
804    }
805
806    /// Short human-readable description of what this action does.
807    ///
808    /// Used as fallback prose in script generation, TUI step display, and LSP
809    /// hover when no explicit `description` or `message` parameter is present.
810    pub fn describe(&self) -> &'static str {
811        match self {
812            ActionType::ClockCheck => "Verify system clock against a reference time.",
813            ActionType::Confirm => "Confirm readiness or completion of a manual step.",
814            ActionType::CheckValue => "Verify a value matches an expected result.",
815            ActionType::OralReadback => "Read back a value aloud for verification.",
816            ActionType::Reveal => "Show a value for a person to write down.",
817            ActionType::MachineInfo => "Record system and environment information.",
818            ActionType::EnterValue => "Type a value the ceremony records.",
819            ActionType::EnterSecret => "Type a secret the ceremony holds and never records.",
820            ActionType::Attest => "Record a signed attestation from a participant.",
821            ActionType::GatherEntropy => "Fold human-supplied entropy into the ceremony seed.",
822            ActionType::TpmAttest => "Record TPM platform attestation (PCR values).",
823            ActionType::GenerateKey => "Generate a cryptographic key.",
824            ActionType::ExportPublic => "Export the public component of a keypair.",
825            ActionType::SignData => "Sign data with a ceremony key.",
826            ActionType::VerifySignature => "Verify a signature against a public key.",
827            ActionType::WrapKey => "Encrypt a key under another key for transport.",
828            ActionType::UnwrapKey => "Decrypt a wrapped key and import it into a backend.",
829            ActionType::GenerateCsr => "Generate a Certificate Signing Request.",
830            ActionType::IssueCertificate => "Issue an X.509 certificate from a CSR.",
831            ActionType::PivReadCertificate => "Read a certificate from a PIV smart card slot.",
832            ActionType::PivSign => "Sign data using a PIV smart card key.",
833            ActionType::YubikeyAttestSlot => "Attest a YubiKey PIV slot key.",
834            ActionType::ImportKey => "Import a key from material the ceremony holds.",
835            ActionType::EncryptData => "Encrypt content under a key held by a backend.",
836            ActionType::DecryptData => "Decrypt an encrypted-data artifact back to bytes.",
837            ActionType::SplitSecret => {
838                "Split a secret into shares a threshold of which reconstruct it."
839            }
840            ActionType::CombineShares => "Reconstruct a secret from its shares.",
841            ActionType::EnterShare => "Type a share back from its sheet.",
842        }
843    }
844}
845
846impl std::fmt::Display for ActionType {
847    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
848        match self {
849            ActionType::ClockCheck => write!(f, "clock_check"),
850            ActionType::Confirm => write!(f, "confirm"),
851            ActionType::CheckValue => write!(f, "check_value"),
852            ActionType::OralReadback => write!(f, "oral_readback"),
853            ActionType::Reveal => write!(f, "reveal"),
854            ActionType::MachineInfo => write!(f, "machine_info"),
855            ActionType::EnterValue => write!(f, "enter_value"),
856            ActionType::EnterSecret => write!(f, "enter_secret"),
857            ActionType::GenerateKey => write!(f, "generate_key"),
858            ActionType::WrapKey => write!(f, "wrap_key"),
859            ActionType::UnwrapKey => write!(f, "unwrap_key"),
860            ActionType::ImportKey => write!(f, "import_key"),
861            ActionType::EncryptData => write!(f, "encrypt_data"),
862            ActionType::DecryptData => write!(f, "decrypt_data"),
863            ActionType::SplitSecret => write!(f, "split_secret"),
864            ActionType::CombineShares => write!(f, "combine_shares"),
865            ActionType::EnterShare => write!(f, "enter_share"),
866            ActionType::ExportPublic => write!(f, "export_public"),
867            ActionType::SignData => write!(f, "sign_data"),
868            ActionType::VerifySignature => write!(f, "verify_signature"),
869            ActionType::Attest => write!(f, "attest"),
870            ActionType::GatherEntropy => write!(f, "gather_entropy"),
871            ActionType::TpmAttest => write!(f, "tpm_attest"),
872            ActionType::PivReadCertificate => write!(f, "piv_read_certificate"),
873            ActionType::PivSign => write!(f, "piv_sign"),
874            ActionType::YubikeyAttestSlot => write!(f, "yubikey_attest_slot"),
875            ActionType::IssueCertificate => write!(f, "issue_certificate"),
876            ActionType::GenerateCsr => write!(f, "generate_csr"),
877        }
878    }
879}
880
881/// Type of output produced by a ceremony.
882#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
883#[serde(rename_all = "snake_case")]
884#[non_exhaustive]
885pub enum OutputType {
886    /// Public key in PEM format.
887    PublicKey,
888    /// A key wrapped for transport, in the container its scheme names.
889    WrappedKey,
890    /// Content encrypted for a recipient, in the container its scheme names.
891    EncryptedData,
892    /// X.509 certificate.
893    Certificate,
894    /// DNSSEC signed resource record set.
895    SignedRrset,
896    /// Certificate Transparency Signed Certificate Timestamp.
897    Sct,
898
899    // Documents
900    /// Generic document.
901    Document,
902    /// Ceremony log or transcript.
903    CeremonyLog,
904}
905
906impl std::fmt::Display for OutputType {
907    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
908        match self {
909            OutputType::PublicKey => write!(f, "public_key"),
910            OutputType::WrappedKey => write!(f, "wrapped_key"),
911            OutputType::EncryptedData => write!(f, "encrypted_data"),
912            OutputType::Certificate => write!(f, "certificate"),
913            OutputType::SignedRrset => write!(f, "signed_rrset"),
914            OutputType::Sct => write!(f, "sct"),
915            OutputType::Document => write!(f, "document"),
916            OutputType::CeremonyLog => write!(f, "ceremony_log"),
917        }
918    }
919}
920
921impl OutputType {
922    /// Returns the default file extension for this output type.
923    pub fn default_extension(&self) -> &'static str {
924        match self {
925            OutputType::PublicKey | OutputType::Certificate => "pem",
926            OutputType::WrappedKey
927            | OutputType::EncryptedData
928            | OutputType::SignedRrset
929            | OutputType::Sct => "bin",
930            OutputType::Document => "txt",
931            OutputType::CeremonyLog => "json",
932        }
933    }
934}
935
936/// Type of a ceremony parameter.
937#[derive(Debug, Clone, Serialize, Deserialize)]
938#[serde(rename_all = "snake_case")]
939#[non_exhaustive]
940pub enum ParameterType {
941    /// Text value.
942    String,
943    /// Date value (`YYYY-MM-DD`).
944    Date,
945    /// Integer value.
946    Integer,
947    /// Boolean value.
948    Boolean,
949}
950
951impl std::fmt::Display for ParameterType {
952    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
953        match self {
954            ParameterType::String => write!(f, "string"),
955            ParameterType::Date => write!(f, "date (YYYY-MM-DD)"),
956            ParameterType::Integer => write!(f, "integer"),
957            ParameterType::Boolean => write!(f, "boolean (true/false)"),
958        }
959    }
960}
961
962/// Typed preset for common post-ceremony duty categories.
963///
964/// Provides built-in prose for scripts when no description is given.
965/// Use `Custom` for duties that don't fit a preset; description is then required.
966///
967/// Note: physical handling steps (sealing hardware, etc.) belong as `physical_action`
968/// steps in ceremony execution, not here.
969#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
970#[serde(rename_all = "snake_case")]
971#[non_exhaustive]
972pub enum DutyType {
973    /// Return materials to secure storage location.
974    ReturnToVault,
975    /// Hand key shares to assigned custodians.
976    DistributeShares,
977    /// Distribute backup media to designated recipients.
978    DistributeMedia,
979    /// Archive ceremony materials at storage locations.
980    ArchiveMaterials,
981    /// Publish ceremony record and witness attestations.
982    PublishRecord,
983    /// Notify stakeholders of ceremony completion.
984    NotifyStakeholders,
985    /// Import generated keys into operational system.
986    ImportKeys,
987    /// Free-form duty; description is required.
988    Custom,
989}
990
991impl DutyType {
992    /// Returns the built-in display name for this duty type.
993    pub fn display_name(&self) -> &'static str {
994        match self {
995            DutyType::ReturnToVault => "Return to Vault",
996            DutyType::DistributeShares => "Distribute Key Shares",
997            DutyType::DistributeMedia => "Distribute Backup Media",
998            DutyType::ArchiveMaterials => "Archive Materials",
999            DutyType::PublishRecord => "Publish Record",
1000            DutyType::NotifyStakeholders => "Notify Stakeholders",
1001            DutyType::ImportKeys => "Import Keys",
1002            DutyType::Custom => "Custom Duty",
1003        }
1004    }
1005
1006    /// Returns the built-in prose description for this duty type.
1007    ///
1008    /// Returns `None` for `Custom` (description must be provided explicitly).
1009    pub fn built_in_prose(&self) -> Option<&'static str> {
1010        match self {
1011            DutyType::ReturnToVault => Some("Return materials to secure storage"),
1012            DutyType::DistributeShares => Some("Distribute key shares to assigned custodians"),
1013            DutyType::DistributeMedia => Some("Distribute backup media to designated recipients"),
1014            DutyType::ArchiveMaterials => {
1015                Some("Archive ceremony materials at designated storage locations")
1016            }
1017            DutyType::PublishRecord => Some("Publish ceremony record and witness attestations"),
1018            DutyType::NotifyStakeholders => Some("Notify stakeholders of ceremony completion"),
1019            DutyType::ImportKeys => Some("Import generated keys into operational system"),
1020            DutyType::Custom => None,
1021        }
1022    }
1023}
1024
1025#[cfg(test)]
1026mod tests {
1027    use super::*;
1028
1029    #[test]
1030    fn role_type_extracts_prefix() {
1031        assert_eq!(role_type("witness__1"), "witness");
1032        assert_eq!(role_type("hsm_operator__primary"), "hsm_operator");
1033        assert_eq!(role_type("operator"), "operator");
1034        assert_eq!(role_type("witness__"), "witness");
1035    }
1036
1037    #[test]
1038    fn derive_step_name_title_cases() {
1039        assert_eq!(derive_step_name("verify_time"), "Verify Time");
1040        assert_eq!(derive_step_name("generate_root_ca"), "Generate Root Ca");
1041        assert_eq!(derive_step_name("wrap_root_ca_key"), "Wrap Root Ca Key");
1042        assert_eq!(derive_step_name("witness1_attest"), "Witness1 Attest");
1043        assert_eq!(derive_step_name("clock-check"), "Clock Check");
1044        assert_eq!(derive_step_name("opening"), "Opening");
1045    }
1046
1047    #[test]
1048    fn derive_role_name_title_cases() {
1049        assert_eq!(derive_role_name("witness__1"), "Witness 1");
1050        assert_eq!(
1051            derive_role_name("hsm_operator__primary"),
1052            "Hsm Operator Primary"
1053        );
1054        assert_eq!(derive_role_name("operator"), "Operator");
1055        assert_eq!(derive_role_name("ceremony-admin"), "Ceremony Admin");
1056    }
1057
1058    #[test]
1059    fn action_type_serde_roundtrip() {
1060        // `snake_case` rename means these exact strings appear in ceremony YAML.
1061        // A variant rename or serde attr change breaks YAML parsing silently.
1062        let cases: &[(ActionType, &str)] = &[
1063            (ActionType::ClockCheck, "\"clock_check\""),
1064            (ActionType::Confirm, "\"confirm\""),
1065            (ActionType::CheckValue, "\"check_value\""),
1066            (ActionType::OralReadback, "\"oral_readback\""),
1067            (ActionType::Reveal, "\"reveal\""),
1068            (ActionType::MachineInfo, "\"machine_info\""),
1069            (ActionType::GenerateKey, "\"generate_key\""),
1070            (ActionType::WrapKey, "\"wrap_key\""),
1071            (ActionType::UnwrapKey, "\"unwrap_key\""),
1072            (ActionType::ImportKey, "\"import_key\""),
1073            (ActionType::EncryptData, "\"encrypt_data\""),
1074            (ActionType::DecryptData, "\"decrypt_data\""),
1075            (ActionType::SplitSecret, "\"split_secret\""),
1076            (ActionType::CombineShares, "\"combine_shares\""),
1077            (ActionType::EnterShare, "\"enter_share\""),
1078            (ActionType::ExportPublic, "\"export_public\""),
1079            (ActionType::Attest, "\"attest\""),
1080            (ActionType::TpmAttest, "\"tpm_attest\""),
1081            (ActionType::PivReadCertificate, "\"piv_read_certificate\""),
1082            (ActionType::PivSign, "\"piv_sign\""),
1083            (ActionType::YubikeyAttestSlot, "\"yubikey_attest_slot\""),
1084            (ActionType::IssueCertificate, "\"issue_certificate\""),
1085            (ActionType::GenerateCsr, "\"generate_csr\""),
1086        ];
1087        for &(variant, expected) in cases {
1088            let serialized = serde_json::to_string(&variant).unwrap();
1089            assert_eq!(serialized, expected, "serialize {variant:?}");
1090            let deserialized: ActionType = serde_json::from_str(expected).unwrap();
1091            assert_eq!(deserialized, variant, "deserialize {expected}");
1092        }
1093    }
1094
1095    #[test]
1096    fn action_type_display_matches_serde() {
1097        // Display is used in transcript output and error messages; serde in YAML parsing.
1098        // They must agree or transcripts reference action names that differ from YAML.
1099        let actions = [
1100            ActionType::ClockCheck,
1101            ActionType::Confirm,
1102            ActionType::CheckValue,
1103            ActionType::OralReadback,
1104            ActionType::Reveal,
1105            ActionType::MachineInfo,
1106            ActionType::GenerateKey,
1107            ActionType::WrapKey,
1108            ActionType::UnwrapKey,
1109            ActionType::ImportKey,
1110            ActionType::ExportPublic,
1111            ActionType::Attest,
1112            ActionType::TpmAttest,
1113            ActionType::PivReadCertificate,
1114            ActionType::PivSign,
1115            ActionType::YubikeyAttestSlot,
1116            ActionType::IssueCertificate,
1117            ActionType::GenerateCsr,
1118        ];
1119        for action in actions {
1120            let display = action.to_string();
1121            let serde_json = serde_json::to_string(&action).unwrap();
1122            assert_eq!(
1123                display,
1124                serde_json.trim_matches('"'),
1125                "Display and serde disagree for {action:?}"
1126            );
1127        }
1128    }
1129
1130    #[test]
1131    fn output_type_serde_roundtrip() {
1132        // These strings appear in ceremony YAML `output:` blocks and in file extension
1133        // mapping. A rename breaks both YAML parsing and output file naming.
1134        let cases: &[(OutputType, &str)] = &[
1135            (OutputType::PublicKey, "\"public_key\""),
1136            (OutputType::WrappedKey, "\"wrapped_key\""),
1137            (OutputType::EncryptedData, "\"encrypted_data\""),
1138            (OutputType::Certificate, "\"certificate\""),
1139            (OutputType::Document, "\"document\""),
1140            (OutputType::CeremonyLog, "\"ceremony_log\""),
1141        ];
1142        for &(variant, expected) in cases {
1143            let serialized = serde_json::to_string(&variant).unwrap();
1144            assert_eq!(serialized, expected, "serialize {variant:?}");
1145            let deserialized: OutputType = serde_json::from_str(expected).unwrap();
1146            assert_eq!(deserialized, variant, "deserialize {expected}");
1147        }
1148    }
1149
1150    #[test]
1151    fn parameter_type_serde_roundtrip() {
1152        // These strings appear in ceremony YAML `parameters:` blocks.
1153        let cases: &[(ParameterType, &str)] = &[
1154            (ParameterType::String, "\"string\""),
1155            (ParameterType::Date, "\"date\""),
1156            (ParameterType::Integer, "\"integer\""),
1157            (ParameterType::Boolean, "\"boolean\""),
1158        ];
1159        for (variant, expected) in cases {
1160            let serialized = serde_json::to_string(variant).unwrap();
1161            assert_eq!(serialized, *expected, "serialize {variant:?}");
1162            let deserialized: ParameterType = serde_json::from_str(expected).unwrap();
1163            // ParameterType does not implement PartialEq; check the display instead.
1164            assert_eq!(
1165                serde_json::to_string(&deserialized).unwrap(),
1166                *expected,
1167                "deserialize {expected}"
1168            );
1169        }
1170    }
1171
1172    #[test]
1173    fn duty_type_serde_roundtrip() {
1174        // These strings appear in ceremony YAML `after:` blocks.
1175        let cases: &[(DutyType, &str)] = &[
1176            (DutyType::ReturnToVault, "\"return_to_vault\""),
1177            (DutyType::DistributeShares, "\"distribute_shares\""),
1178            (DutyType::DistributeMedia, "\"distribute_media\""),
1179            (DutyType::ArchiveMaterials, "\"archive_materials\""),
1180            (DutyType::PublishRecord, "\"publish_record\""),
1181            (DutyType::NotifyStakeholders, "\"notify_stakeholders\""),
1182            (DutyType::ImportKeys, "\"import_keys\""),
1183            (DutyType::Custom, "\"custom\""),
1184        ];
1185        for (variant, expected) in cases {
1186            let serialized = serde_json::to_string(variant).unwrap();
1187            assert_eq!(serialized, *expected, "serialize {variant:?}");
1188            let deserialized: DutyType = serde_json::from_str(expected).unwrap();
1189            assert_eq!(deserialized, *variant, "deserialize {expected}");
1190        }
1191    }
1192
1193    #[test]
1194    fn duty_type_custom_has_no_built_in_prose() {
1195        // The runtime branches on this: if built_in_prose returns None, the description
1196        // field is required. A regression here would panic or produce empty script output.
1197        assert!(
1198            DutyType::Custom.built_in_prose().is_none(),
1199            "Custom duty must have no built-in prose"
1200        );
1201        // All other variants must have prose.
1202        for duty in [
1203            DutyType::ReturnToVault,
1204            DutyType::DistributeShares,
1205            DutyType::DistributeMedia,
1206            DutyType::ArchiveMaterials,
1207            DutyType::PublishRecord,
1208            DutyType::NotifyStakeholders,
1209            DutyType::ImportKeys,
1210        ] {
1211            assert!(
1212                duty.built_in_prose().is_some(),
1213                "{duty:?} must have built-in prose"
1214            );
1215        }
1216    }
1217
1218    /// `ActionType::ALL` is written by hand, so it can fall behind the enum.
1219    ///
1220    /// The match below is exhaustive, so adding a variant fails to compile
1221    /// here. That is the whole mechanism: it puts a compiler error next to the
1222    /// list an author has to extend. The name check then catches a variant
1223    /// listed twice, and `rite-ls` compares its own catalogue against `ALL`,
1224    /// which is what catches one left out.
1225    /// A required field the action does not accept would be reported as
1226    /// unknown, which is the opposite of the message intended, and nothing
1227    /// makes the two lists agree on their own.
1228    #[test]
1229    fn every_required_field_is_also_a_known_field() {
1230        for action in ActionType::ALL {
1231            let known = action.known_with_fields();
1232            for field in action.required_with_fields() {
1233                assert!(
1234                    known.contains(field),
1235                    "{action} requires '{field}' and does not accept it"
1236                );
1237            }
1238        }
1239    }
1240
1241    #[test]
1242    fn all_lists_every_action_type() {
1243        for action in ActionType::ALL {
1244            match action {
1245                ActionType::ClockCheck
1246                | ActionType::Confirm
1247                | ActionType::CheckValue
1248                | ActionType::OralReadback
1249                | ActionType::Reveal
1250                | ActionType::MachineInfo
1251                | ActionType::EnterValue
1252                | ActionType::EnterSecret
1253                | ActionType::GenerateKey
1254                | ActionType::WrapKey
1255                | ActionType::UnwrapKey
1256                | ActionType::ImportKey
1257                | ActionType::EncryptData
1258                | ActionType::DecryptData
1259                | ActionType::SplitSecret
1260                | ActionType::CombineShares
1261                | ActionType::EnterShare
1262                | ActionType::ExportPublic
1263                | ActionType::SignData
1264                | ActionType::VerifySignature
1265                | ActionType::Attest
1266                | ActionType::GatherEntropy
1267                | ActionType::TpmAttest
1268                | ActionType::PivReadCertificate
1269                | ActionType::PivSign
1270                | ActionType::YubikeyAttestSlot
1271                | ActionType::IssueCertificate
1272                | ActionType::GenerateCsr => {}
1273            }
1274        }
1275
1276        let names: std::collections::BTreeSet<String> =
1277            ActionType::ALL.iter().map(ToString::to_string).collect();
1278        assert_eq!(
1279            names.len(),
1280            ActionType::ALL.len(),
1281            "two actions share a DSL name"
1282        );
1283    }
1284}