Skip to main content

rite_model/
params.rs

1//! Rules over a step's `with:` values that hold in every build.
2//!
3//! These sit beside [`ActionType::required_with_fields`] and
4//! [`ActionType::reads_contract`]: what an action requires of its block, in
5//! one place both the resolver and the runtime can read. Keeping them here lets
6//! an editor report them, since the language server links the model and the
7//! resolver but no backend.
8//!
9//! Only rules that travel with the document belong here. `RSA-9999` is not a
10//! key algorithm on any machine, so rejecting it is portable. Whether *this*
11//! binary can generate an ML-DSA key is a property of the build doing the
12//! checking, and that question is answered by the action handler instead, at
13//! the point where a backend exists to ask.
14
15use serde::{Deserialize, Serialize};
16
17use crate::display::RevealFormat;
18use crate::transcript::{Format, ValidatorSpec, compile_pattern};
19use crate::types::{ActionType, CertProfile, SharingScheme};
20use rite_sdk::{KeyAlgorithm, KeyUsages, SignAlgorithm, WrapScheme};
21
22/// A `with:` value an action cannot accept.
23///
24/// The message names the field it is about, so a reader with no span still
25/// knows where to look.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct ParamError {
28    /// What is wrong, phrased for the ceremony author.
29    pub message: String,
30}
31
32/// Check the literal part of a step's `with:` block.
33///
34/// `with` is the projection produced by
35/// [`literal_expr_value`](crate::expression::literal_expr_value), so a field
36/// whose value is an expression is *absent* rather than wrong. Nothing here
37/// may report a field as missing: required fields are
38/// [`ActionType::required_with_fields`]'s job, and a required field is
39/// routinely written as `${param.x}`.
40#[must_use]
41pub fn check(action: ActionType, with: &serde_json::Value) -> Vec<ParamError> {
42    match action {
43        ActionType::GenerateKey => {
44            let mut errors = named_value(with, "algorithm", |name| {
45                name.parse::<KeyAlgorithm>()
46                    .map_err(|_| format!("unknown key algorithm '{name}'"))
47            });
48            errors.extend(key_usages(with));
49            errors
50        }
51        ActionType::SignData | ActionType::VerifySignature => {
52            named_value(with, "algorithm", |name| {
53                name.parse::<SignAlgorithm>()
54                    .map_err(|_| format!("unknown signature algorithm '{name}'"))
55            })
56        }
57        ActionType::IssueCertificate => named_value(with, "profile", |name| {
58            name.parse::<CertProfile>()
59                .map_err(|_| format!("unknown certificate profile '{name}'"))
60        }),
61        ActionType::WrapKey => {
62            let mut errors = fingerprint(with, "expect_recipient");
63            errors.extend(scheme(with, |_| Ok(())));
64            errors
65        }
66        // Narrower than `wrap_key`'s vocabulary on purpose: the container is
67        // the only thing `encrypt_data` writes, and a name it would refuse
68        // mid-ceremony belongs in `rite check` rather than in the room.
69        ActionType::EncryptData => {
70            named_value(with, "scheme", |name| match name.parse::<WrapScheme>() {
71                Ok(WrapScheme::CmsAes256Gcm) => Ok(()),
72                Ok(other) => Err(format!(
73                    "encrypt_data writes {}, not {other}",
74                    WrapScheme::CmsAes256Gcm
75                )),
76                Err(_) => Err(format!("unknown wrapping scheme '{name}'")),
77            })
78        }
79        ActionType::SplitSecret => {
80            let mut errors = named_value(with, "scheme", |name| {
81                name.parse::<SharingScheme>()
82                    .map(|_| ())
83                    .map_err(|e| format!("unknown sharing scheme '{name}'; {e}"))
84            });
85            // The limit is the named scheme's, or the default's when the
86            // scheme is absent or not a literal; a scheme that fails to parse
87            // is reported above and the counts are still checked against
88            // something.
89            let scheme = with
90                .get("scheme")
91                .and_then(serde_json::Value::as_str)
92                .and_then(|name| name.parse::<SharingScheme>().ok())
93                .unwrap_or(SharingScheme::RiteSssV1);
94            let max = u64::from(scheme.max_shares());
95            errors.extend(share_count(with, "threshold", max));
96            errors.extend(share_count(with, "shares", max));
97            if let (Some(threshold), Some(shares)) = (
98                with.get("threshold").and_then(serde_json::Value::as_u64),
99                with.get("shares").and_then(serde_json::Value::as_u64),
100            ) && shares < threshold
101            {
102                errors.push(ParamError {
103                    message: format!(
104                        "'shares' is {shares} and 'threshold' is {threshold}; fewer shares than \
105                         the threshold could never reconstruct the secret"
106                    ),
107                });
108            }
109            errors
110        }
111        ActionType::Reveal => reveal(with),
112        ActionType::EnterShare => enter_share(with),
113        ActionType::UnwrapKey | ActionType::ImportKey => {
114            let mut errors = key_identity(with, "expect_key");
115            errors.extend(named_value(with, "algorithm", |name| {
116                name.parse::<KeyAlgorithm>()
117                    .map_err(|_| format!("unknown key algorithm '{name}'"))
118            }));
119            errors
120        }
121        ActionType::EnterSecret => entry_shape(with),
122        ActionType::EnterValue => enter_value(with),
123
124        ActionType::ClockCheck
125        | ActionType::Confirm
126        | ActionType::CheckValue
127        | ActionType::OralReadback
128        | ActionType::MachineInfo
129        | ActionType::ExportPublic
130        | ActionType::Attest
131        | ActionType::GatherEntropy
132        | ActionType::TpmAttest
133        | ActionType::PivReadCertificate
134        | ActionType::PivSign
135        | ActionType::YubikeyAttestSlot
136        | ActionType::DecryptData
137        | ActionType::CombineShares
138        | ActionType::GenerateCsr => Vec::new(),
139    }
140}
141
142/// `enter_value`: the entry shape, and no format whose value is a secret,
143/// since the transcript carries what this step was answered.
144fn enter_value(with: &serde_json::Value) -> Vec<ParamError> {
145    let mut errors = entry_shape(with);
146    let rows = match with.get("format").map(FormatSpec::from_json) {
147        Some(Ok(FormatSpec::Named(format))) => format.rows(),
148        _ => None,
149    };
150    if let Some(rows) = rows {
151        errors.push(ParamError {
152            message: format!(
153                "'{rows}' is for a value written down, which is a secret: use enter_secret"
154            ),
155        });
156    }
157    errors
158}
159
160/// `enter_share`: as `reveal`, and a share is never words and is longer
161/// than its header.
162fn enter_share(with: &serde_json::Value) -> Vec<ParamError> {
163    let mut errors = reveal(with);
164    let format = shown_format(with);
165    if format.is_words() {
166        errors.push(ParamError {
167            message: format!(
168                "a share is written in paper32 or hex; '{format}' is for the secret the shares \
169                 combine into"
170            ),
171        });
172    }
173    // Zero is reported by `reveal`.
174    let header = SharingScheme::RiteSssV1.header_len();
175    if let Some(n) = with.get("length").and_then(serde_json::Value::as_u64)
176        && usize::try_from(n).is_ok_and(|n| (1..=header).contains(&n))
177    {
178        errors.push(ParamError {
179            message: format!(
180                "'length' is {n}; a share is its secret plus {header} header bytes, so it is \
181                 longer than {header}"
182            ),
183        });
184    }
185    errors
186}
187
188/// The format a `reveal` or `enter_share` step names, or the default; one
189/// that does not parse is reported by [`reveal`].
190fn shown_format(with: &serde_json::Value) -> RevealFormat {
191    with.get("format")
192        .and_then(serde_json::Value::as_str)
193        .and_then(|name| name.parse().ok())
194        .unwrap_or(RevealFormat::Paper32)
195}
196
197/// `reveal` and `enter_share`: a format this build shows, and a length in
198/// the format's unit, bytes or the words of a phrase.
199fn reveal(with: &serde_json::Value) -> Vec<ParamError> {
200    let mut errors = named_value(with, "format", |name| {
201        name.parse::<RevealFormat>()
202            .map(|_| ())
203            .map_err(|e| e.to_string())
204    });
205    let format = shown_format(with);
206    let Some(value) = with.get("length") else {
207        return errors;
208    };
209    let message = match value.as_u64().and_then(|n| usize::try_from(n).ok()) {
210        None | Some(0) => Some(format!(
211            "'length' must be a positive integer of {}, found {value}",
212            format.unit()
213        )),
214        Some(n) if format.is_words() && !crate::bip39::is_phrase_len(n) => Some(format!(
215            "'length' counts words for '{format}', and a phrase has {}, found {value}",
216            crate::bip39::PHRASE_LENGTHS
217        )),
218        Some(_) => None,
219    };
220    errors.extend(message.map(|message| ParamError { message }));
221    errors
222}
223
224/// A share count or threshold: an integer from 2 to the scheme's limit.
225///
226/// One is not a split. An absent field is deferred, as everywhere here; a
227/// present one that is not an integer is wrong whatever it would have parsed
228/// to.
229fn share_count(with: &serde_json::Value, field: &'static str, max: u64) -> Vec<ParamError> {
230    let Some(value) = with.get(field) else {
231        return Vec::new();
232    };
233    let message = match value.as_u64() {
234        Some(n) if (2..=max).contains(&n) => return Vec::new(),
235        Some(n) => format!("'{field}' is {n}; it must be from 2 to {max}"),
236        None => format!("'{field}' must be an integer from 2 to {max}, found {value}"),
237    };
238    vec![ParamError { message }]
239}
240
241/// The container a step names, checked against the vocabulary rather than left
242/// to fail at the backend, and then against what the action itself writes.
243///
244/// One message for a name outside the vocabulary, whichever action asked, and
245/// `accept` for the narrowing an action does on top of that.
246fn scheme(
247    with: &serde_json::Value,
248    accept: impl FnOnce(WrapScheme) -> Result<(), String>,
249) -> Vec<ParamError> {
250    named_value(with, "scheme", |name| {
251        let named = name
252            .parse::<WrapScheme>()
253            .map_err(|_| format!("unknown wrapping scheme '{name}'"))?;
254        accept(named)
255    })
256}
257
258/// Apply `parse` to a present string field.
259///
260/// An absent field is deferred, not missing. A field holding something other
261/// than a string is wrong whatever the value would have been.
262fn named_value<T>(
263    with: &serde_json::Value,
264    field: &'static str,
265    parse: impl FnOnce(&str) -> Result<T, String>,
266) -> Vec<ParamError> {
267    let Some(value) = with.get(field) else {
268        return Vec::new();
269    };
270    let Some(name) = value.as_str() else {
271        return vec![ParamError {
272            message: format!("'{field}' must be a string, found {value}"),
273        }];
274    };
275    parse(name)
276        .err()
277        .map(|message| ParamError { message })
278        .into_iter()
279        .collect()
280}
281
282/// The shape an `enter_value` or `enter_secret` step gives the value it asks
283/// for, checked the way the step will build it.
284///
285/// A field still carrying an expression is absent from the projection, so the
286/// rule is built from what is literal, and a conflict between a literal field
287/// and one resolved at run time is found there.
288fn entry_shape(with: &serde_json::Value) -> Vec<ParamError> {
289    let mut errors = Vec::new();
290    let mut length = |field: &'static str| -> Option<usize> {
291        let value = with.get(field)?;
292        match value.as_u64().and_then(|n| usize::try_from(n).ok()) {
293            Some(n) if n > 0 => Some(n),
294            _ => {
295                errors.push(ParamError {
296                    message: format!("'{field}' must be a positive integer, found {value}"),
297                });
298                None
299            }
300        }
301    };
302    let mut shape = EntryShape {
303        length: length("length"),
304        min_length: length("min_length"),
305        max_length: length("max_length"),
306        format: None,
307    };
308    match with.get("format").map(FormatSpec::from_json) {
309        None => {}
310        Some(Ok(format)) => shape.format = Some(format),
311        Some(Err(message)) => errors.push(ParamError { message }),
312    }
313    if let Err(message) = shape.validator() {
314        errors.push(ParamError { message });
315    }
316    errors
317}
318
319/// `format:` as a step writes it: the name of a [`Format`], or a pattern.
320///
321/// A pattern is a format too, one whose argument is the expression, so it is
322/// written under the same key rather than beside it.
323#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
324#[serde(untagged)]
325pub enum FormatSpec {
326    /// `format: hex`
327    Named(Format),
328    /// `format: { pattern: "..." }`
329    Pattern {
330        /// A regular expression the whole value must match.
331        pattern: String,
332    },
333}
334
335impl FormatSpec {
336    /// Read the field, with a message that says what the two forms are.
337    ///
338    /// # Errors
339    ///
340    /// Returns why the value is neither a format name nor a pattern.
341    pub fn from_json(value: &serde_json::Value) -> Result<Self, String> {
342        if let Some(name) = value.as_str() {
343            return name.parse().map(FormatSpec::Named);
344        }
345        match value.get("pattern").and_then(|p| p.as_str()) {
346            Some(pattern) if value.as_object().is_some_and(|m| m.len() == 1) => {
347                Ok(FormatSpec::Pattern {
348                    pattern: pattern.to_string(),
349                })
350            }
351            _ => Err(format!(
352                "'format' must name a format (text, digits, alphanumeric, hex, base64, \
353                 paper32, bip39) or be \
354                 {{ pattern: \"...\" }}, found {value}"
355            )),
356        }
357    }
358}
359
360/// What an `enter_value` or `enter_secret` step says about the value it asks
361/// for, as the `with:` fields spell it.
362///
363/// One place turns these into a [`ValidatorSpec`], so `rite check` and the
364/// running step cannot disagree about which combinations mean something.
365#[derive(Debug, Clone, Default, PartialEq, Eq)]
366pub struct EntryShape {
367    /// `format:`, what kind of value it is.
368    pub format: Option<FormatSpec>,
369    /// `length:`, an exact length.
370    pub length: Option<usize>,
371    /// `min_length:`.
372    pub min_length: Option<usize>,
373    /// `max_length:`.
374    pub max_length: Option<usize>,
375}
376
377impl EntryShape {
378    /// The rule these fields describe.
379    ///
380    /// Nothing given asks only for a non-empty value. A pattern says the
381    /// whole shape, so a length beside it is refused rather than combined
382    /// with it in a way the author would have to guess at. Lengths count the
383    /// format's own units: characters for text, bytes for an encoding.
384    ///
385    /// # Errors
386    ///
387    /// Returns why the fields do not describe one rule, phrased for the
388    /// ceremony author.
389    pub fn validator(&self) -> Result<ValidatorSpec, String> {
390        let bounded =
391            self.length.is_some() || self.min_length.is_some() || self.max_length.is_some();
392        let format = match &self.format {
393            Some(FormatSpec::Pattern { pattern }) => {
394                if bounded {
395                    return Err("a pattern says the whole shape of the value, so 'length', \
396                         'min_length' and 'max_length' cannot be given beside it"
397                        .to_string());
398                }
399                compile_pattern(pattern)?;
400                return Ok(ValidatorSpec::Regex(pattern.clone()));
401            }
402            Some(FormatSpec::Named(format)) => Some(*format),
403            None => None,
404        };
405        let (min_length, max_length) = match (self.length, self.min_length, self.max_length) {
406            (Some(_), Some(_), _) | (Some(_), _, Some(_)) => {
407                return Err(
408                    "'length' is exact, so 'min_length' and 'max_length' cannot be given \
409                     beside it"
410                        .to_string(),
411                );
412            }
413            (Some(length), None, None) => (Some(length), Some(length)),
414            (None, min, max) => (min, max),
415        };
416        if let (Some(min), Some(max)) = (min_length, max_length)
417            && min > max
418        {
419            return Err(format!(
420                "'min_length' is {min} and 'max_length' is {max}, so no value fits"
421            ));
422        }
423        // Bounds that hold no phrase length describe no phrase.
424        if format == Some(Format::Bip39)
425            && !crate::bip39::phrase_lengths().any(|n| {
426                min_length.is_none_or(|min| n >= min) && max_length.is_none_or(|max| n <= max)
427            })
428        {
429            return Err(format!(
430                "a BIP-39 phrase has {} words, and the length given holds none of them",
431                crate::bip39::PHRASE_LENGTHS
432            ));
433        }
434        match format {
435            None if !bounded => Ok(ValidatorSpec::NonEmpty),
436            format => Ok(ValidatorSpec::Format {
437                format: format.unwrap_or(Format::Text),
438                min_length,
439                max_length,
440            }),
441        }
442    }
443}
444
445/// Check the names under `policy: { usages: [...] }`.
446///
447/// These are PKCS#11 usages, what the token permits the key to do. The X.509
448/// `KeyUsage` extension is a different thing and comes from `profile:` on
449/// `issue_certificate`.
450fn key_usages(with: &serde_json::Value) -> Vec<ParamError> {
451    const FIELD: &str = "policy.usages";
452    let Some(usages) = with.get("policy").and_then(|p| p.get("usages")) else {
453        return Vec::new();
454    };
455    let Some(names) = usages.as_array() else {
456        return vec![ParamError {
457            message: format!("'{FIELD}' must be a list, found {usages}"),
458        }];
459    };
460    names
461        .iter()
462        .filter_map(|name| {
463            let known = name
464                .as_str()
465                .is_some_and(|name| KeyUsages::usage_named(name).is_some());
466            (!known).then(|| ParamError {
467                message: format!(
468                    "unknown key usage {name} under '{FIELD}'. Supported: {}",
469                    KeyUsages::all_names().join(", ")
470                ),
471            })
472        })
473        .collect()
474}
475
476/// A fingerprint the ceremony declares in advance, `sha256:<64 hex digits>`.
477///
478/// A value in any other shape can never equal what the runtime computes, so
479/// the step would fail in the room, after the keys exist. The runtime renders
480/// every fingerprint in lowercase, so uppercase hex is one such shape and is
481/// rejected here rather than at the step.
482fn fingerprint(with: &serde_json::Value, field: &'static str) -> Vec<ParamError> {
483    named_value(with, field, |value| {
484        let hex = value.strip_prefix("sha256:").unwrap_or_default();
485        if hex.len() == 64 && hex.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) {
486            Ok(())
487        } else {
488            Err(format!(
489                "'{field}' must be a fingerprint of the form \
490                 'sha256:<64 lowercase hex digits>', found '{value}'"
491            ))
492        }
493    })
494}
495
496/// A key's identity the ceremony declares in advance, in whichever form the
497/// key has one: `sha256:<64 hex digits>` over the public half of a keypair, or
498/// `cmac-aes:<6 hex digits>` for a symmetric key, which has no public half.
499///
500/// Same argument as [`fingerprint`]: a value in neither shape can never equal
501/// what the runtime computes, so the mismatch is worth reporting before the
502/// room rather than during it.
503fn key_identity(with: &serde_json::Value, field: &'static str) -> Vec<ParamError> {
504    named_value(with, field, |value| {
505        let lower_hex = |s: &str, len: usize| {
506            s.len() == len && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
507        };
508        let matches_form = match value.split_once(':') {
509            Some(("sha256", hex)) => lower_hex(hex, 64),
510            Some(("cmac-aes", hex)) => lower_hex(hex, 6),
511            _ => false,
512        };
513        if matches_form {
514            Ok(())
515        } else {
516            Err(format!(
517                "'{field}' must name the key either as 'sha256:<64 lowercase hex digits>', \
518                 the fingerprint of a public key, or as 'cmac-aes:<6 lowercase hex digits>', \
519                 the check value of a symmetric key. Found '{value}'"
520            ))
521        }
522    })
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528    use serde_json::json;
529
530    fn sole(action: ActionType, with: &serde_json::Value) -> String {
531        let errors = check(action, with);
532        let [error] = errors.as_slice() else {
533            panic!("expected exactly one error, got {errors:?}");
534        };
535        error.message.clone()
536    }
537
538    #[test]
539    fn rejects_a_value_outside_the_vocabulary() {
540        assert!(
541            sole(ActionType::GenerateKey, &json!({"algorithm": "RSA-9999"}))
542                .contains("unknown key algorithm")
543        );
544        assert!(
545            sole(ActionType::SignData, &json!({"algorithm": "RSA-PSS-SHA1"}))
546                .contains("unknown signature algorithm")
547        );
548        assert!(
549            sole(
550                ActionType::IssueCertificate,
551                &json!({"profile": "wildcard"})
552            )
553            .contains("unknown certificate profile")
554        );
555        assert!(
556            sole(
557                ActionType::WrapKey,
558                &json!({"expect_recipient": "deadbeef"})
559            )
560            .contains("sha256:<64 lowercase hex digits>")
561        );
562    }
563
564    /// The runtime renders every fingerprint in lowercase, so an uppercase one
565    /// would pass resolution and then never match, failing in the room against
566    /// the very key it names.
567    #[test]
568    fn rejects_a_fingerprint_that_is_not_lowercase() {
569        let upper = format!("sha256:{}", "AB".repeat(32));
570        let message = sole(ActionType::WrapKey, &json!({ "expect_recipient": upper }));
571        assert!(message.contains("lowercase"), "{message}");
572
573        let lower = format!("sha256:{}", "ab".repeat(32));
574        assert!(
575            check(ActionType::WrapKey, &json!({ "expect_recipient": lower })).is_empty(),
576            "the shape the runtime produces is the shape that passes"
577        );
578    }
579
580    /// `encrypt_data` takes a narrower vocabulary than `wrap_key`: a scheme it
581    /// would refuse mid-ceremony is refused at check time instead.
582    #[test]
583    fn rejects_a_scheme_encrypt_data_does_not_write() {
584        let message = sole(ActionType::EncryptData, &json!({"scheme": "AES-256-KW"}));
585        assert!(
586            message.contains("encrypt_data writes CMS-AES-256-GCM"),
587            "{message}"
588        );
589
590        let message = sole(ActionType::EncryptData, &json!({"scheme": "CMS-RSA-CBC"}));
591        assert!(message.contains("unknown wrapping scheme"), "{message}");
592
593        assert!(
594            check(
595                ActionType::EncryptData,
596                &json!({"scheme": "CMS-AES-256-GCM"})
597            )
598            .is_empty()
599        );
600        assert!(
601            check(ActionType::EncryptData, &json!({})).is_empty(),
602            "deferred"
603        );
604
605        // The same name is fine on a wrap, which does write it.
606        assert!(check(ActionType::WrapKey, &json!({"scheme": "AES-256-KW"})).is_empty());
607    }
608
609    /// A scheme name outside the vocabulary would fail in the room, after the
610    /// keys exist, so resolution rejects it.
611    #[test]
612    fn rejects_a_wrapping_scheme_outside_the_vocabulary() {
613        let message = sole(ActionType::WrapKey, &json!({"scheme": "CMS-RSA-CBC"}));
614        assert!(message.contains("unknown wrapping scheme"), "{message}");
615
616        assert!(
617            check(
618                ActionType::WrapKey,
619                &json!({"scheme": "RSA-AES-KEY-WRAP-SHA256"})
620            )
621            .is_empty()
622        );
623        assert!(
624            check(ActionType::WrapKey, &json!({})).is_empty(),
625            "deferred"
626        );
627    }
628
629    #[test]
630    fn accepts_the_vocabulary() {
631        for (action, with) in [
632            (ActionType::GenerateKey, json!({"algorithm": "RSA-4096"})),
633            (ActionType::SignData, json!({"algorithm": "RSA-PSS-SHA256"})),
634            (ActionType::IssueCertificate, json!({"profile": "root_ca"})),
635            (
636                ActionType::UnwrapKey,
637                json!({"expect_key": format!("sha256:{}", "ab".repeat(32))}),
638            ),
639            (
640                ActionType::UnwrapKey,
641                json!({"algorithm": "AES-256", "expect_key": "cmac-aes:763cbc"}),
642            ),
643        ] {
644            assert!(check(action, &with).is_empty(), "{action} rejected {with}");
645        }
646    }
647
648    /// `expect_key` names the recovered key in whichever form it has one, so
649    /// both are accepted and anything else is refused before the room.
650    #[test]
651    fn expect_key_takes_a_fingerprint_or_a_check_value() {
652        let message = sole(
653            ActionType::UnwrapKey,
654            &json!({"expect_key": "cmac-aes:763CBC"}),
655        );
656        assert!(message.contains("lowercase"), "{message}");
657
658        // A check value is three bytes, so a fingerprint's length under the
659        // check value's prefix is not a check value.
660        let message = sole(
661            ActionType::UnwrapKey,
662            &json!({"expect_key": format!("cmac-aes:{}", "ab".repeat(32))}),
663        );
664        assert!(message.contains("6 lowercase hex digits"), "{message}");
665
666        let message = sole(ActionType::UnwrapKey, &json!({"expect_key": "763cbc"}));
667        assert!(
668            message.contains("sha256:") && message.contains("cmac-aes:"),
669            "{message}"
670        );
671    }
672
673    /// Nothing travels with a wrapped key saying what it is, so `algorithm:`
674    /// declares it, and a name outside the vocabulary fails here rather than
675    /// after the blob has been decrypted.
676    #[test]
677    fn rejects_an_unwrap_algorithm_outside_the_vocabulary() {
678        let message = sole(ActionType::UnwrapKey, &json!({"algorithm": "AES-192"}));
679        assert!(message.contains("unknown key algorithm"), "{message}");
680    }
681
682    #[test]
683    fn an_absent_field_is_deferred_not_missing() {
684        // The projection drops `algorithm: ${param.algo}`, and a required
685        // field written as an expression must not be reported here.
686        assert!(check(ActionType::GenerateKey, &json!({})).is_empty());
687        assert!(check(ActionType::IssueCertificate, &json!({})).is_empty());
688    }
689
690    #[test]
691    fn rejects_an_unknown_key_usage() {
692        let message = sole(
693            ActionType::GenerateKey,
694            &json!({"policy": {"usages": ["sign", "key_cert_sign"]}}),
695        );
696        assert!(message.contains("unknown key usage"), "{message}");
697        assert!(
698            message.contains("wrap"),
699            "the message should list what is available: {message}"
700        );
701    }
702
703    #[test]
704    fn accepts_the_pkcs11_usages() {
705        let with = json!({"policy": {"usages": ["sign", "verify", "wrap", "unwrap", "derive"]}});
706        assert!(check(ActionType::GenerateKey, &with).is_empty());
707    }
708
709    #[test]
710    fn a_non_string_value_is_rejected_whatever_it_would_have_meant() {
711        assert!(
712            sole(ActionType::GenerateKey, &json!({"algorithm": 4096})).contains("must be a string")
713        );
714    }
715
716    #[test]
717    fn reveal_checks_its_format_and_length() {
718        assert!(
719            check(
720                ActionType::Reveal,
721                &json!({"message": "Write this down", "format": "paper32", "length": 32})
722            )
723            .is_empty()
724        );
725        assert!(sole(ActionType::Reveal, &json!({"format": "words"})).contains("not a format"));
726        assert!(sole(ActionType::Reveal, &json!({"length": 0})).contains("positive integer"));
727        assert!(
728            check(
729                ActionType::Reveal,
730                &json!({"format": "bip39", "length": 24})
731            )
732            .is_empty()
733        );
734        assert!(
735            sole(
736                ActionType::Reveal,
737                &json!({"format": "bip39", "length": 32})
738            )
739            .contains("counts words")
740        );
741        assert!(
742            sole(ActionType::EnterShare, &json!({"format": "bip39"}))
743                .contains("a share is written")
744        );
745        assert!(check(ActionType::EnterShare, &json!({"length": 35})).is_empty());
746        assert!(sole(ActionType::EnterShare, &json!({"length": 3})).contains("longer than 3"));
747        assert!(sole(ActionType::EnterShare, &json!({"length": 0})).contains("positive integer"));
748    }
749
750    #[test]
751    fn split_secret_counts_are_bounded_by_the_scheme() {
752        assert!(
753            check(
754                ActionType::SplitSecret,
755                &json!({"threshold": 2, "shares": 100})
756            )
757            .is_empty()
758        );
759        assert!(
760            sole(
761                ActionType::SplitSecret,
762                &json!({"threshold": 2, "shares": 101})
763            )
764            .contains("from 2 to 100")
765        );
766        assert!(
767            sole(
768                ActionType::SplitSecret,
769                &json!({"threshold": 1, "shares": 3})
770            )
771            .contains("from 2 to 100")
772        );
773        assert!(
774            sole(
775                ActionType::SplitSecret,
776                &json!({"threshold": 4, "shares": 3})
777            )
778            .contains("fewer shares than the threshold")
779        );
780        assert!(
781            sole(
782                ActionType::SplitSecret,
783                &json!({"threshold": "two", "shares": 3})
784            )
785            .contains("must be an integer")
786        );
787        assert!(
788            sole(
789                ActionType::SplitSecret,
790                &json!({"scheme": "sss/v9", "threshold": 2, "shares": 3})
791            )
792            .contains("unknown sharing scheme")
793        );
794        // A `${param}` threshold is absent from the literal projection and is
795        // deferred, not reported.
796        assert!(check(ActionType::SplitSecret, &json!({"shares": 3})).is_empty());
797    }
798
799    /// One rule from the shape fields, the same one the step will apply.
800    #[test]
801    fn an_entry_shape_becomes_one_rule() {
802        let shape = EntryShape {
803            format: Some(FormatSpec::Named(Format::Digits)),
804            length: Some(6),
805            ..EntryShape::default()
806        };
807        assert!(matches!(
808            shape.validator(),
809            Ok(ValidatorSpec::Format {
810                format: Format::Digits,
811                min_length: Some(6),
812                max_length: Some(6),
813            })
814        ));
815
816        // Bounds alone: the format is text.
817        let shape = EntryShape {
818            max_length: Some(8),
819            ..EntryShape::default()
820        };
821        assert!(matches!(
822            shape.validator(),
823            Ok(ValidatorSpec::Format {
824                format: Format::Text,
825                min_length: None,
826                max_length: Some(8),
827            })
828        ));
829
830        assert!(matches!(
831            EntryShape::default().validator(),
832            Ok(ValidatorSpec::NonEmpty)
833        ));
834        assert!(matches!(
835            EntryShape {
836                format: Some(FormatSpec::Pattern {
837                    pattern: "[a-z]+".to_string()
838                }),
839                ..EntryShape::default()
840            }
841            .validator(),
842            Ok(ValidatorSpec::Regex(_))
843        ));
844    }
845
846    #[test]
847    fn rejects_an_entry_shape_that_is_two_rules() {
848        assert!(
849            sole(
850                ActionType::EnterValue,
851                &json!({"format": {"pattern": "[0-9]+"}, "length": 6})
852            )
853            .contains("pattern")
854        );
855        assert!(
856            sole(
857                ActionType::EnterSecret,
858                &json!({"length": 6, "min_length": 4})
859            )
860            .contains("'length' is exact")
861        );
862        assert!(
863            sole(
864                ActionType::EnterSecret,
865                &json!({"min_length": 8, "max_length": 6})
866            )
867            .contains("no value fits")
868        );
869    }
870
871    #[test]
872    fn rejects_an_entry_shape_outside_the_vocabulary() {
873        assert!(
874            sole(ActionType::EnterSecret, &json!({"format": "emoji"})).contains("unknown format")
875        );
876        assert!(
877            sole(
878                ActionType::EnterSecret,
879                &json!({"format": {"pattern": "[0-9"}})
880            )
881            .contains("invalid pattern")
882        );
883        assert!(
884            sole(
885                ActionType::EnterSecret,
886                &json!({"format": {"regex": "[0-9]+"}})
887            )
888            .contains("'format' must name a format")
889        );
890        assert!(sole(ActionType::EnterSecret, &json!({"length": 0})).contains("positive integer"));
891        assert!(
892            sole(ActionType::EnterValue, &json!({"format": "paper32"})).contains("enter_secret")
893        );
894        assert!(
895            check(
896                ActionType::EnterSecret,
897                &json!({"format": "paper32", "length": 32})
898            )
899            .is_empty()
900        );
901        assert!(
902            sole(ActionType::EnterSecret, &json!({"length": "six"})).contains("positive integer")
903        );
904        assert!(sole(ActionType::EnterValue, &json!({"format": "bip39"})).contains("enter_secret"));
905        assert!(
906            check(
907                ActionType::EnterSecret,
908                &json!({"format": "bip39", "length": 24})
909            )
910            .is_empty()
911        );
912        assert!(
913            check(
914                ActionType::EnterSecret,
915                &json!({"format": "bip39", "min_length": 12, "max_length": 24})
916            )
917            .is_empty()
918        );
919        assert!(
920            sole(
921                ActionType::EnterSecret,
922                &json!({"format": "bip39", "length": 32})
923            )
924            .contains("12, 15, 18, 21 or 24")
925        );
926        assert!(
927            sole(
928                ActionType::EnterSecret,
929                &json!({"format": "bip39", "min_length": 13, "max_length": 14})
930            )
931            .contains("12, 15, 18, 21 or 24")
932        );
933        assert!(check(ActionType::EnterSecret, &json!({"message": "PIN"})).is_empty());
934        assert!(
935            check(
936                ActionType::EnterSecret,
937                &json!({"message": "Key", "format": "hex", "length": 32})
938            )
939            .is_empty()
940        );
941    }
942}