Skip to main content

rite_model/params/
mod.rs

1//! The params type of each action, and the rules over a step's `with:`
2//! values that hold in every build.
3//!
4//! The types generate the schema of each action's `with:` block (see
5//! [`with_schema`]), which says what keys it takes and what each value may
6//! be. [`cross_field`] holds the few rules a schema cannot say. Both live in
7//! the model so the resolver, the runtime and an editor read the same ones,
8//! since the language server links the model and the resolver but no
9//! backend.
10//!
11//! Only rules that travel with the document belong here. `RSA-9999` is not a
12//! key algorithm on any machine, so rejecting it is portable. Whether *this*
13//! binary can generate an ML-DSA key is a property of the build doing the
14//! checking, and that question is answered by the action handler instead, at
15//! the point where a backend exists to ask.
16
17mod attestation;
18mod crypto;
19mod entropy;
20mod entry;
21mod piv;
22mod pki;
23mod schema;
24mod sharing;
25mod verification;
26
27pub use attestation::AttestParams;
28pub use crypto::{
29    EncryptDataParams, GenerateKeyParams, ImportKeyParams, KeyPolicyParams, KeyUsage,
30    SignDataParams, UnwrapKeyParams, VerifySignatureParams, WrapKeyParams,
31};
32pub use entropy::GatherEntropyParams;
33pub use entry::EntryParams;
34pub use piv::{
35    AttestSlotParams, PIV_SIGN_ALGORITHMS, PIV_SLOT, PivReadCertificateParams, PivSignParams,
36};
37pub use pki::{GenerateCsrParams, IssueCertificateParams};
38pub(crate) use schema::names;
39pub use schema::{KEYWORDS, generator, with_schema};
40pub use sharing::{EnterShareParams, RevealParams, SplitSecretParams};
41pub use verification::{
42    CheckValueParams, ClockCheckParams, ConfirmParams, MachineInfoParams, OralReadbackParams,
43    ReadbackFormat,
44};
45
46use serde::{Deserialize, Serialize};
47
48use crate::transcript::{Format, ValidatorSpec, compile_pattern};
49use crate::types::ActionType;
50
51/// A `with:` value an action cannot accept.
52///
53/// The message names the field it is about, so a reader with no span still
54/// knows where to look.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct ParamError {
57    /// What is wrong, phrased for the ceremony author.
58    pub message: String,
59}
60
61/// The rules over a step's `with:` values that a schema cannot say: one
62/// value compared with another, and a pattern that must compile in the
63/// dialect the runtime uses.
64///
65/// At `rite check`, `with` is the literal part of a block, as
66/// [`literal_expr_value`](crate::expression::literal_expr_value) projects it:
67/// a field whose value is an expression is absent, and a rule is checked over
68/// what is known. When the step runs, it is the evaluated block. A field
69/// that is absent or of the wrong type is skipped, since the schema reports
70/// it. The messages quote no value, since an evaluated one may be a secret
71/// an expression copied in, except a pattern that does not compile, which
72/// the step's prompt would record anyway.
73#[must_use]
74pub fn cross_field(action: ActionType, with: &serde_json::Value) -> Vec<ParamError> {
75    match action {
76        ActionType::SplitSecret => share_counts(with),
77        ActionType::EnterValue | ActionType::EnterSecret => entry_shape(with),
78
79        ActionType::ClockCheck
80        | ActionType::Confirm
81        | ActionType::CheckValue
82        | ActionType::OralReadback
83        | ActionType::Reveal
84        | ActionType::MachineInfo
85        | ActionType::GenerateKey
86        | ActionType::WrapKey
87        | ActionType::UnwrapKey
88        | ActionType::ImportKey
89        | ActionType::EncryptData
90        | ActionType::DecryptData
91        | ActionType::CombineShares
92        | ActionType::EnterShare
93        | ActionType::ExportPublic
94        | ActionType::SignData
95        | ActionType::VerifySignature
96        | ActionType::Attest
97        | ActionType::GatherEntropy
98        | ActionType::TpmAttest
99        | ActionType::PivReadCertificate
100        | ActionType::PivSign
101        | ActionType::YubikeyAttestSlot
102        | ActionType::IssueCertificate
103        | ActionType::GenerateCsr => Vec::new(),
104    }
105}
106
107/// Fewer shares than the threshold could never reconstruct the secret.
108fn share_counts(with: &serde_json::Value) -> Vec<ParamError> {
109    let count = |field| with.get(field).and_then(serde_json::Value::as_u64);
110    match (count("threshold"), count("shares")) {
111        (Some(threshold), Some(shares)) if shares < threshold => vec![ParamError {
112            message: "'shares' is below 'threshold', and fewer shares than the threshold \
113                      could never reconstruct the secret"
114                .to_string(),
115        }],
116        _ => Vec::new(),
117    }
118}
119
120/// The shape an `enter_value` or `enter_secret` step gives the value it asks
121/// for, built the way the step will build it.
122fn entry_shape(with: &serde_json::Value) -> Vec<ParamError> {
123    let length = |field| {
124        with.get(field)
125            .and_then(serde_json::Value::as_u64)
126            .and_then(|n| usize::try_from(n).ok())
127    };
128    let shape = EntryShape {
129        format: with
130            .get("format")
131            .and_then(|format| serde_json::from_value(format.clone()).ok()),
132        length: length("length"),
133        min_length: length("min_length"),
134        max_length: length("max_length"),
135    };
136    shape
137        .validator()
138        .err()
139        .map(|message| ParamError { message })
140        .into_iter()
141        .collect()
142}
143
144/// `format:` as a step writes it: the name of a [`Format`], or a pattern.
145///
146/// A pattern is a format too, one whose argument is the expression, so it is
147/// written under the same key rather than beside it.
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(untagged)]
150pub enum FormatSpec {
151    /// `format: hex`
152    Named(Format),
153    /// `format: { pattern: "..." }`
154    Pattern {
155        /// A regular expression the whole value must match.
156        pattern: String,
157    },
158}
159
160/// A format name, or an object holding only a pattern.
161impl schemars::JsonSchema for FormatSpec {
162    fn schema_name() -> std::borrow::Cow<'static, str> {
163        "FormatSpec".into()
164    }
165
166    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
167        let named = generator.subschema_for::<Format>();
168        schemars::json_schema!({
169            "oneOf": [
170                named,
171                {
172                    "type": "object",
173                    "properties": {
174                        "pattern": {
175                            "type": "string",
176                            "description": "A regular expression the whole value must match.",
177                        }
178                    },
179                    "required": ["pattern"],
180                    "additionalProperties": false,
181                }
182            ],
183            "x-message": format!(
184                "'format' must name a format ({}) or be {{ pattern: \"...\" }}",
185                Format::ALL
186                    .iter()
187                    .map(|format| format.as_str())
188                    .collect::<Vec<_>>()
189                    .join(", ")
190            ),
191        })
192    }
193}
194
195/// What an `enter_value` or `enter_secret` step says about the value it asks
196/// for, as the `with:` fields spell it.
197///
198/// One place turns these into a [`ValidatorSpec`], so `rite check` and the
199/// running step cannot disagree about which combinations mean something.
200#[derive(Debug, Clone, Default, PartialEq, Eq)]
201pub struct EntryShape {
202    /// `format:`, what kind of value it is.
203    pub format: Option<FormatSpec>,
204    /// `length:`, an exact length.
205    pub length: Option<usize>,
206    /// `min_length:`.
207    pub min_length: Option<usize>,
208    /// `max_length:`.
209    pub max_length: Option<usize>,
210}
211
212impl EntryShape {
213    /// The rule these fields describe.
214    ///
215    /// Nothing given asks only for a non-empty value. A pattern says the
216    /// whole shape, so a length beside it is refused rather than combined
217    /// with it in a way the author would have to guess at. Lengths count the
218    /// format's own units: characters for text, bytes for an encoding.
219    ///
220    /// # Errors
221    ///
222    /// Returns why the fields do not describe one rule, phrased for the
223    /// ceremony author.
224    pub fn validator(&self) -> Result<ValidatorSpec, String> {
225        let bounded =
226            self.length.is_some() || self.min_length.is_some() || self.max_length.is_some();
227        let format = match &self.format {
228            Some(FormatSpec::Pattern { pattern }) => {
229                if bounded {
230                    return Err("a pattern says the whole shape of the value, so 'length', \
231                         'min_length' and 'max_length' cannot be given beside it"
232                        .to_string());
233                }
234                compile_pattern(pattern)?;
235                return Ok(ValidatorSpec::Regex(pattern.clone()));
236            }
237            Some(FormatSpec::Named(format)) => Some(*format),
238            None => None,
239        };
240        let (min_length, max_length) = match (self.length, self.min_length, self.max_length) {
241            (Some(_), Some(_), _) | (Some(_), _, Some(_)) => {
242                return Err(
243                    "'length' is exact, so 'min_length' and 'max_length' cannot be given \
244                     beside it"
245                        .to_string(),
246                );
247            }
248            (Some(length), None, None) => (Some(length), Some(length)),
249            (None, min, max) => (min, max),
250        };
251        if let (Some(min), Some(max)) = (min_length, max_length)
252            && min > max
253        {
254            return Err("'min_length' is above 'max_length', so no value fits".to_string());
255        }
256        // Bounds that hold no phrase length describe no phrase.
257        if format == Some(Format::Bip39)
258            && !crate::bip39::phrase_lengths().any(|n| {
259                min_length.is_none_or(|min| n >= min) && max_length.is_none_or(|max| n <= max)
260            })
261        {
262            return Err(format!(
263                "a BIP-39 phrase has {} words, and the length given holds none of them",
264                crate::bip39::PHRASE_LENGTHS
265            ));
266        }
267        match format {
268            None if !bounded => Ok(ValidatorSpec::NonEmpty),
269            format => Ok(ValidatorSpec::Format {
270                format: format.unwrap_or(Format::Text),
271                min_length,
272                max_length,
273            }),
274        }
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use serde_json::json;
282
283    fn sole(action: ActionType, with: &serde_json::Value) -> String {
284        let errors = cross_field(action, with);
285        let [error] = errors.as_slice() else {
286            panic!("expected exactly one error, got {errors:?}");
287        };
288        error.message.clone()
289    }
290
291    #[test]
292    fn a_split_needs_at_least_as_many_shares_as_its_threshold() {
293        assert!(
294            sole(
295                ActionType::SplitSecret,
296                &json!({"threshold": 4, "shares": 3})
297            )
298            .contains("fewer shares than the threshold")
299        );
300        assert!(
301            cross_field(
302                ActionType::SplitSecret,
303                &json!({"threshold": 3, "shares": 3})
304            )
305            .is_empty()
306        );
307        // A `${param}` threshold is absent from the literal projection.
308        assert!(cross_field(ActionType::SplitSecret, &json!({"shares": 3})).is_empty());
309    }
310
311    /// One rule from the shape fields, the same one the step will apply.
312    #[test]
313    fn an_entry_shape_becomes_one_rule() {
314        let shape = EntryShape {
315            format: Some(FormatSpec::Named(Format::Digits)),
316            length: Some(6),
317            ..EntryShape::default()
318        };
319        assert!(matches!(
320            shape.validator(),
321            Ok(ValidatorSpec::Format {
322                format: Format::Digits,
323                min_length: Some(6),
324                max_length: Some(6),
325            })
326        ));
327
328        // Bounds alone: the format is text.
329        let shape = EntryShape {
330            max_length: Some(8),
331            ..EntryShape::default()
332        };
333        assert!(matches!(
334            shape.validator(),
335            Ok(ValidatorSpec::Format {
336                format: Format::Text,
337                min_length: None,
338                max_length: Some(8),
339            })
340        ));
341
342        assert!(matches!(
343            EntryShape::default().validator(),
344            Ok(ValidatorSpec::NonEmpty)
345        ));
346        assert!(matches!(
347            EntryShape {
348                format: Some(FormatSpec::Pattern {
349                    pattern: "[a-z]+".to_string()
350                }),
351                ..EntryShape::default()
352            }
353            .validator(),
354            Ok(ValidatorSpec::Regex(_))
355        ));
356    }
357
358    #[test]
359    fn rejects_an_entry_shape_no_value_fits() {
360        assert!(
361            sole(
362                ActionType::EnterSecret,
363                &json!({"min_length": 8, "max_length": 6})
364            )
365            .contains("no value fits")
366        );
367        assert!(
368            sole(
369                ActionType::EnterSecret,
370                &json!({"format": {"pattern": "[0-9"}})
371            )
372            .contains("invalid pattern")
373        );
374        assert!(
375            sole(
376                ActionType::EnterSecret,
377                &json!({"format": "bip39", "length": 32})
378            )
379            .contains("12, 15, 18, 21 or 24")
380        );
381        assert!(
382            sole(
383                ActionType::EnterSecret,
384                &json!({"format": "bip39", "min_length": 13, "max_length": 14})
385            )
386            .contains("12, 15, 18, 21 or 24")
387        );
388        for with in [
389            json!({"format": "bip39", "length": 24}),
390            json!({"format": "bip39", "min_length": 12, "max_length": 24}),
391            json!({"message": "Key", "format": "hex", "length": 32}),
392            json!({"message": "PIN"}),
393        ] {
394            assert!(
395                cross_field(ActionType::EnterSecret, &with).is_empty(),
396                "{with}"
397            );
398        }
399    }
400}
401
402#[cfg(test)]
403mod schema_tests {
404    use super::*;
405    use crate::ActionType;
406    use serde_json::Value;
407
408    /// Every keyword a generated schema uses is one [`KEYWORDS`] names, so a
409    /// validator for that set alone checks every schema.
410    #[test]
411    fn every_schema_keeps_to_the_keywords() {
412        fn walk(value: &Value, path: &str, found: &mut Vec<String>) {
413            let Value::Object(object) = value else {
414                return;
415            };
416            // The message lookup starts where a keyword is written, which for
417            // a referenced type is its definition, so a message beside a
418            // `$ref` would never be read.
419            if object.contains_key("$ref") && object.contains_key("x-message") {
420                found.push(format!("{path}: x-message beside $ref"));
421            }
422            for (key, child) in object {
423                if !KEYWORDS.contains(&key.as_str()) {
424                    found.push(format!("{path}/{key}"));
425                }
426                let here = format!("{path}/{key}");
427                match (key.as_str(), child) {
428                    // Names map to schemas.
429                    ("properties" | "$defs", Value::Object(map)) => {
430                        for (name, schema) in map {
431                            walk(schema, &format!("{here}/{name}"), found);
432                        }
433                    }
434                    // Lists of schemas.
435                    ("allOf" | "oneOf", Value::Array(items)) => {
436                        for (index, item) in items.iter().enumerate() {
437                            walk(item, &format!("{here}/{index}"), found);
438                        }
439                    }
440                    // One schema.
441                    ("items" | "if" | "then" | "not" | "additionalProperties", _) => {
442                        walk(child, &here, found);
443                    }
444                    // Values and annotations hold data, not schemas.
445                    _ => {}
446                }
447            }
448        }
449        for &action in ActionType::ALL {
450            let mut found = Vec::new();
451            walk(&with_schema(action).to_value(), "", &mut found);
452            assert!(found.is_empty(), "{action} uses {found:?}");
453        }
454    }
455}