use serde::{Deserialize, Serialize};
use crate::display::RevealFormat;
use crate::transcript::{Format, ValidatorSpec, compile_pattern};
use crate::types::{ActionType, CertProfile, SharingScheme};
use rite_sdk::{KeyAlgorithm, KeyUsages, SignAlgorithm, WrapScheme};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParamError {
pub message: String,
}
#[must_use]
pub fn check(action: ActionType, with: &serde_json::Value) -> Vec<ParamError> {
match action {
ActionType::GenerateKey => {
let mut errors = named_value(with, "algorithm", |name| {
name.parse::<KeyAlgorithm>()
.map_err(|_| format!("unknown key algorithm '{name}'"))
});
errors.extend(key_usages(with));
errors
}
ActionType::SignData | ActionType::VerifySignature => {
named_value(with, "algorithm", |name| {
name.parse::<SignAlgorithm>()
.map_err(|_| format!("unknown signature algorithm '{name}'"))
})
}
ActionType::IssueCertificate => named_value(with, "profile", |name| {
name.parse::<CertProfile>()
.map_err(|_| format!("unknown certificate profile '{name}'"))
}),
ActionType::WrapKey => {
let mut errors = fingerprint(with, "expect_recipient");
errors.extend(scheme(with, |_| Ok(())));
errors
}
ActionType::EncryptData => {
named_value(with, "scheme", |name| match name.parse::<WrapScheme>() {
Ok(WrapScheme::CmsAes256Gcm) => Ok(()),
Ok(other) => Err(format!(
"encrypt_data writes {}, not {other}",
WrapScheme::CmsAes256Gcm
)),
Err(_) => Err(format!("unknown wrapping scheme '{name}'")),
})
}
ActionType::SplitSecret => {
let mut errors = named_value(with, "scheme", |name| {
name.parse::<SharingScheme>()
.map(|_| ())
.map_err(|e| format!("unknown sharing scheme '{name}'; {e}"))
});
let scheme = with
.get("scheme")
.and_then(serde_json::Value::as_str)
.and_then(|name| name.parse::<SharingScheme>().ok())
.unwrap_or(SharingScheme::RiteSssV1);
let max = u64::from(scheme.max_shares());
errors.extend(share_count(with, "threshold", max));
errors.extend(share_count(with, "shares", max));
if let (Some(threshold), Some(shares)) = (
with.get("threshold").and_then(serde_json::Value::as_u64),
with.get("shares").and_then(serde_json::Value::as_u64),
) && shares < threshold
{
errors.push(ParamError {
message: format!(
"'shares' is {shares} and 'threshold' is {threshold}; fewer shares than \
the threshold could never reconstruct the secret"
),
});
}
errors
}
ActionType::Reveal => reveal(with),
ActionType::EnterShare => enter_share(with),
ActionType::UnwrapKey | ActionType::ImportKey => {
let mut errors = key_identity(with, "expect_key");
errors.extend(named_value(with, "algorithm", |name| {
name.parse::<KeyAlgorithm>()
.map_err(|_| format!("unknown key algorithm '{name}'"))
}));
errors
}
ActionType::EnterSecret => entry_shape(with),
ActionType::EnterValue => enter_value(with),
ActionType::ClockCheck
| ActionType::Confirm
| ActionType::CheckValue
| ActionType::OralReadback
| ActionType::MachineInfo
| ActionType::ExportPublic
| ActionType::Attest
| ActionType::GatherEntropy
| ActionType::TpmAttest
| ActionType::PivReadCertificate
| ActionType::PivSign
| ActionType::YubikeyAttestSlot
| ActionType::DecryptData
| ActionType::CombineShares
| ActionType::GenerateCsr => Vec::new(),
}
}
fn enter_value(with: &serde_json::Value) -> Vec<ParamError> {
let mut errors = entry_shape(with);
let rows = match with.get("format").map(FormatSpec::from_json) {
Some(Ok(FormatSpec::Named(format))) => format.rows(),
_ => None,
};
if let Some(rows) = rows {
errors.push(ParamError {
message: format!(
"'{rows}' is for a value written down, which is a secret: use enter_secret"
),
});
}
errors
}
fn enter_share(with: &serde_json::Value) -> Vec<ParamError> {
let mut errors = reveal(with);
let format = shown_format(with);
if format.is_words() {
errors.push(ParamError {
message: format!(
"a share is written in paper32 or hex; '{format}' is for the secret the shares \
combine into"
),
});
}
let header = SharingScheme::RiteSssV1.header_len();
if let Some(n) = with.get("length").and_then(serde_json::Value::as_u64)
&& usize::try_from(n).is_ok_and(|n| (1..=header).contains(&n))
{
errors.push(ParamError {
message: format!(
"'length' is {n}; a share is its secret plus {header} header bytes, so it is \
longer than {header}"
),
});
}
errors
}
fn shown_format(with: &serde_json::Value) -> RevealFormat {
with.get("format")
.and_then(serde_json::Value::as_str)
.and_then(|name| name.parse().ok())
.unwrap_or(RevealFormat::Paper32)
}
fn reveal(with: &serde_json::Value) -> Vec<ParamError> {
let mut errors = named_value(with, "format", |name| {
name.parse::<RevealFormat>()
.map(|_| ())
.map_err(|e| e.to_string())
});
let format = shown_format(with);
let Some(value) = with.get("length") else {
return errors;
};
let message = match value.as_u64().and_then(|n| usize::try_from(n).ok()) {
None | Some(0) => Some(format!(
"'length' must be a positive integer of {}, found {value}",
format.unit()
)),
Some(n) if format.is_words() && !crate::bip39::is_phrase_len(n) => Some(format!(
"'length' counts words for '{format}', and a phrase has {}, found {value}",
crate::bip39::PHRASE_LENGTHS
)),
Some(_) => None,
};
errors.extend(message.map(|message| ParamError { message }));
errors
}
fn share_count(with: &serde_json::Value, field: &'static str, max: u64) -> Vec<ParamError> {
let Some(value) = with.get(field) else {
return Vec::new();
};
let message = match value.as_u64() {
Some(n) if (2..=max).contains(&n) => return Vec::new(),
Some(n) => format!("'{field}' is {n}; it must be from 2 to {max}"),
None => format!("'{field}' must be an integer from 2 to {max}, found {value}"),
};
vec![ParamError { message }]
}
fn scheme(
with: &serde_json::Value,
accept: impl FnOnce(WrapScheme) -> Result<(), String>,
) -> Vec<ParamError> {
named_value(with, "scheme", |name| {
let named = name
.parse::<WrapScheme>()
.map_err(|_| format!("unknown wrapping scheme '{name}'"))?;
accept(named)
})
}
fn named_value<T>(
with: &serde_json::Value,
field: &'static str,
parse: impl FnOnce(&str) -> Result<T, String>,
) -> Vec<ParamError> {
let Some(value) = with.get(field) else {
return Vec::new();
};
let Some(name) = value.as_str() else {
return vec![ParamError {
message: format!("'{field}' must be a string, found {value}"),
}];
};
parse(name)
.err()
.map(|message| ParamError { message })
.into_iter()
.collect()
}
fn entry_shape(with: &serde_json::Value) -> Vec<ParamError> {
let mut errors = Vec::new();
let mut length = |field: &'static str| -> Option<usize> {
let value = with.get(field)?;
match value.as_u64().and_then(|n| usize::try_from(n).ok()) {
Some(n) if n > 0 => Some(n),
_ => {
errors.push(ParamError {
message: format!("'{field}' must be a positive integer, found {value}"),
});
None
}
}
};
let mut shape = EntryShape {
length: length("length"),
min_length: length("min_length"),
max_length: length("max_length"),
format: None,
};
match with.get("format").map(FormatSpec::from_json) {
None => {}
Some(Ok(format)) => shape.format = Some(format),
Some(Err(message)) => errors.push(ParamError { message }),
}
if let Err(message) = shape.validator() {
errors.push(ParamError { message });
}
errors
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum FormatSpec {
Named(Format),
Pattern {
pattern: String,
},
}
impl FormatSpec {
pub fn from_json(value: &serde_json::Value) -> Result<Self, String> {
if let Some(name) = value.as_str() {
return name.parse().map(FormatSpec::Named);
}
match value.get("pattern").and_then(|p| p.as_str()) {
Some(pattern) if value.as_object().is_some_and(|m| m.len() == 1) => {
Ok(FormatSpec::Pattern {
pattern: pattern.to_string(),
})
}
_ => Err(format!(
"'format' must name a format (text, digits, alphanumeric, hex, base64, \
paper32, bip39) or be \
{{ pattern: \"...\" }}, found {value}"
)),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct EntryShape {
pub format: Option<FormatSpec>,
pub length: Option<usize>,
pub min_length: Option<usize>,
pub max_length: Option<usize>,
}
impl EntryShape {
pub fn validator(&self) -> Result<ValidatorSpec, String> {
let bounded =
self.length.is_some() || self.min_length.is_some() || self.max_length.is_some();
let format = match &self.format {
Some(FormatSpec::Pattern { pattern }) => {
if bounded {
return Err("a pattern says the whole shape of the value, so 'length', \
'min_length' and 'max_length' cannot be given beside it"
.to_string());
}
compile_pattern(pattern)?;
return Ok(ValidatorSpec::Regex(pattern.clone()));
}
Some(FormatSpec::Named(format)) => Some(*format),
None => None,
};
let (min_length, max_length) = match (self.length, self.min_length, self.max_length) {
(Some(_), Some(_), _) | (Some(_), _, Some(_)) => {
return Err(
"'length' is exact, so 'min_length' and 'max_length' cannot be given \
beside it"
.to_string(),
);
}
(Some(length), None, None) => (Some(length), Some(length)),
(None, min, max) => (min, max),
};
if let (Some(min), Some(max)) = (min_length, max_length)
&& min > max
{
return Err(format!(
"'min_length' is {min} and 'max_length' is {max}, so no value fits"
));
}
if format == Some(Format::Bip39)
&& !crate::bip39::phrase_lengths().any(|n| {
min_length.is_none_or(|min| n >= min) && max_length.is_none_or(|max| n <= max)
})
{
return Err(format!(
"a BIP-39 phrase has {} words, and the length given holds none of them",
crate::bip39::PHRASE_LENGTHS
));
}
match format {
None if !bounded => Ok(ValidatorSpec::NonEmpty),
format => Ok(ValidatorSpec::Format {
format: format.unwrap_or(Format::Text),
min_length,
max_length,
}),
}
}
}
fn key_usages(with: &serde_json::Value) -> Vec<ParamError> {
const FIELD: &str = "policy.usages";
let Some(usages) = with.get("policy").and_then(|p| p.get("usages")) else {
return Vec::new();
};
let Some(names) = usages.as_array() else {
return vec![ParamError {
message: format!("'{FIELD}' must be a list, found {usages}"),
}];
};
names
.iter()
.filter_map(|name| {
let known = name
.as_str()
.is_some_and(|name| KeyUsages::usage_named(name).is_some());
(!known).then(|| ParamError {
message: format!(
"unknown key usage {name} under '{FIELD}'. Supported: {}",
KeyUsages::all_names().join(", ")
),
})
})
.collect()
}
fn fingerprint(with: &serde_json::Value, field: &'static str) -> Vec<ParamError> {
named_value(with, field, |value| {
let hex = value.strip_prefix("sha256:").unwrap_or_default();
if hex.len() == 64 && hex.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) {
Ok(())
} else {
Err(format!(
"'{field}' must be a fingerprint of the form \
'sha256:<64 lowercase hex digits>', found '{value}'"
))
}
})
}
fn key_identity(with: &serde_json::Value, field: &'static str) -> Vec<ParamError> {
named_value(with, field, |value| {
let lower_hex = |s: &str, len: usize| {
s.len() == len && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
};
let matches_form = match value.split_once(':') {
Some(("sha256", hex)) => lower_hex(hex, 64),
Some(("cmac-aes", hex)) => lower_hex(hex, 6),
_ => false,
};
if matches_form {
Ok(())
} else {
Err(format!(
"'{field}' must name the key either as 'sha256:<64 lowercase hex digits>', \
the fingerprint of a public key, or as 'cmac-aes:<6 lowercase hex digits>', \
the check value of a symmetric key. Found '{value}'"
))
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn sole(action: ActionType, with: &serde_json::Value) -> String {
let errors = check(action, with);
let [error] = errors.as_slice() else {
panic!("expected exactly one error, got {errors:?}");
};
error.message.clone()
}
#[test]
fn rejects_a_value_outside_the_vocabulary() {
assert!(
sole(ActionType::GenerateKey, &json!({"algorithm": "RSA-9999"}))
.contains("unknown key algorithm")
);
assert!(
sole(ActionType::SignData, &json!({"algorithm": "RSA-PSS-SHA1"}))
.contains("unknown signature algorithm")
);
assert!(
sole(
ActionType::IssueCertificate,
&json!({"profile": "wildcard"})
)
.contains("unknown certificate profile")
);
assert!(
sole(
ActionType::WrapKey,
&json!({"expect_recipient": "deadbeef"})
)
.contains("sha256:<64 lowercase hex digits>")
);
}
#[test]
fn rejects_a_fingerprint_that_is_not_lowercase() {
let upper = format!("sha256:{}", "AB".repeat(32));
let message = sole(ActionType::WrapKey, &json!({ "expect_recipient": upper }));
assert!(message.contains("lowercase"), "{message}");
let lower = format!("sha256:{}", "ab".repeat(32));
assert!(
check(ActionType::WrapKey, &json!({ "expect_recipient": lower })).is_empty(),
"the shape the runtime produces is the shape that passes"
);
}
#[test]
fn rejects_a_scheme_encrypt_data_does_not_write() {
let message = sole(ActionType::EncryptData, &json!({"scheme": "AES-256-KW"}));
assert!(
message.contains("encrypt_data writes CMS-AES-256-GCM"),
"{message}"
);
let message = sole(ActionType::EncryptData, &json!({"scheme": "CMS-RSA-CBC"}));
assert!(message.contains("unknown wrapping scheme"), "{message}");
assert!(
check(
ActionType::EncryptData,
&json!({"scheme": "CMS-AES-256-GCM"})
)
.is_empty()
);
assert!(
check(ActionType::EncryptData, &json!({})).is_empty(),
"deferred"
);
assert!(check(ActionType::WrapKey, &json!({"scheme": "AES-256-KW"})).is_empty());
}
#[test]
fn rejects_a_wrapping_scheme_outside_the_vocabulary() {
let message = sole(ActionType::WrapKey, &json!({"scheme": "CMS-RSA-CBC"}));
assert!(message.contains("unknown wrapping scheme"), "{message}");
assert!(
check(
ActionType::WrapKey,
&json!({"scheme": "RSA-AES-KEY-WRAP-SHA256"})
)
.is_empty()
);
assert!(
check(ActionType::WrapKey, &json!({})).is_empty(),
"deferred"
);
}
#[test]
fn accepts_the_vocabulary() {
for (action, with) in [
(ActionType::GenerateKey, json!({"algorithm": "RSA-4096"})),
(ActionType::SignData, json!({"algorithm": "RSA-PSS-SHA256"})),
(ActionType::IssueCertificate, json!({"profile": "root_ca"})),
(
ActionType::UnwrapKey,
json!({"expect_key": format!("sha256:{}", "ab".repeat(32))}),
),
(
ActionType::UnwrapKey,
json!({"algorithm": "AES-256", "expect_key": "cmac-aes:763cbc"}),
),
] {
assert!(check(action, &with).is_empty(), "{action} rejected {with}");
}
}
#[test]
fn expect_key_takes_a_fingerprint_or_a_check_value() {
let message = sole(
ActionType::UnwrapKey,
&json!({"expect_key": "cmac-aes:763CBC"}),
);
assert!(message.contains("lowercase"), "{message}");
let message = sole(
ActionType::UnwrapKey,
&json!({"expect_key": format!("cmac-aes:{}", "ab".repeat(32))}),
);
assert!(message.contains("6 lowercase hex digits"), "{message}");
let message = sole(ActionType::UnwrapKey, &json!({"expect_key": "763cbc"}));
assert!(
message.contains("sha256:") && message.contains("cmac-aes:"),
"{message}"
);
}
#[test]
fn rejects_an_unwrap_algorithm_outside_the_vocabulary() {
let message = sole(ActionType::UnwrapKey, &json!({"algorithm": "AES-192"}));
assert!(message.contains("unknown key algorithm"), "{message}");
}
#[test]
fn an_absent_field_is_deferred_not_missing() {
assert!(check(ActionType::GenerateKey, &json!({})).is_empty());
assert!(check(ActionType::IssueCertificate, &json!({})).is_empty());
}
#[test]
fn rejects_an_unknown_key_usage() {
let message = sole(
ActionType::GenerateKey,
&json!({"policy": {"usages": ["sign", "key_cert_sign"]}}),
);
assert!(message.contains("unknown key usage"), "{message}");
assert!(
message.contains("wrap"),
"the message should list what is available: {message}"
);
}
#[test]
fn accepts_the_pkcs11_usages() {
let with = json!({"policy": {"usages": ["sign", "verify", "wrap", "unwrap", "derive"]}});
assert!(check(ActionType::GenerateKey, &with).is_empty());
}
#[test]
fn a_non_string_value_is_rejected_whatever_it_would_have_meant() {
assert!(
sole(ActionType::GenerateKey, &json!({"algorithm": 4096})).contains("must be a string")
);
}
#[test]
fn reveal_checks_its_format_and_length() {
assert!(
check(
ActionType::Reveal,
&json!({"message": "Write this down", "format": "paper32", "length": 32})
)
.is_empty()
);
assert!(sole(ActionType::Reveal, &json!({"format": "words"})).contains("not a format"));
assert!(sole(ActionType::Reveal, &json!({"length": 0})).contains("positive integer"));
assert!(
check(
ActionType::Reveal,
&json!({"format": "bip39", "length": 24})
)
.is_empty()
);
assert!(
sole(
ActionType::Reveal,
&json!({"format": "bip39", "length": 32})
)
.contains("counts words")
);
assert!(
sole(ActionType::EnterShare, &json!({"format": "bip39"}))
.contains("a share is written")
);
assert!(check(ActionType::EnterShare, &json!({"length": 35})).is_empty());
assert!(sole(ActionType::EnterShare, &json!({"length": 3})).contains("longer than 3"));
assert!(sole(ActionType::EnterShare, &json!({"length": 0})).contains("positive integer"));
}
#[test]
fn split_secret_counts_are_bounded_by_the_scheme() {
assert!(
check(
ActionType::SplitSecret,
&json!({"threshold": 2, "shares": 100})
)
.is_empty()
);
assert!(
sole(
ActionType::SplitSecret,
&json!({"threshold": 2, "shares": 101})
)
.contains("from 2 to 100")
);
assert!(
sole(
ActionType::SplitSecret,
&json!({"threshold": 1, "shares": 3})
)
.contains("from 2 to 100")
);
assert!(
sole(
ActionType::SplitSecret,
&json!({"threshold": 4, "shares": 3})
)
.contains("fewer shares than the threshold")
);
assert!(
sole(
ActionType::SplitSecret,
&json!({"threshold": "two", "shares": 3})
)
.contains("must be an integer")
);
assert!(
sole(
ActionType::SplitSecret,
&json!({"scheme": "sss/v9", "threshold": 2, "shares": 3})
)
.contains("unknown sharing scheme")
);
assert!(check(ActionType::SplitSecret, &json!({"shares": 3})).is_empty());
}
#[test]
fn an_entry_shape_becomes_one_rule() {
let shape = EntryShape {
format: Some(FormatSpec::Named(Format::Digits)),
length: Some(6),
..EntryShape::default()
};
assert!(matches!(
shape.validator(),
Ok(ValidatorSpec::Format {
format: Format::Digits,
min_length: Some(6),
max_length: Some(6),
})
));
let shape = EntryShape {
max_length: Some(8),
..EntryShape::default()
};
assert!(matches!(
shape.validator(),
Ok(ValidatorSpec::Format {
format: Format::Text,
min_length: None,
max_length: Some(8),
})
));
assert!(matches!(
EntryShape::default().validator(),
Ok(ValidatorSpec::NonEmpty)
));
assert!(matches!(
EntryShape {
format: Some(FormatSpec::Pattern {
pattern: "[a-z]+".to_string()
}),
..EntryShape::default()
}
.validator(),
Ok(ValidatorSpec::Regex(_))
));
}
#[test]
fn rejects_an_entry_shape_that_is_two_rules() {
assert!(
sole(
ActionType::EnterValue,
&json!({"format": {"pattern": "[0-9]+"}, "length": 6})
)
.contains("pattern")
);
assert!(
sole(
ActionType::EnterSecret,
&json!({"length": 6, "min_length": 4})
)
.contains("'length' is exact")
);
assert!(
sole(
ActionType::EnterSecret,
&json!({"min_length": 8, "max_length": 6})
)
.contains("no value fits")
);
}
#[test]
fn rejects_an_entry_shape_outside_the_vocabulary() {
assert!(
sole(ActionType::EnterSecret, &json!({"format": "emoji"})).contains("unknown format")
);
assert!(
sole(
ActionType::EnterSecret,
&json!({"format": {"pattern": "[0-9"}})
)
.contains("invalid pattern")
);
assert!(
sole(
ActionType::EnterSecret,
&json!({"format": {"regex": "[0-9]+"}})
)
.contains("'format' must name a format")
);
assert!(sole(ActionType::EnterSecret, &json!({"length": 0})).contains("positive integer"));
assert!(
sole(ActionType::EnterValue, &json!({"format": "paper32"})).contains("enter_secret")
);
assert!(
check(
ActionType::EnterSecret,
&json!({"format": "paper32", "length": 32})
)
.is_empty()
);
assert!(
sole(ActionType::EnterSecret, &json!({"length": "six"})).contains("positive integer")
);
assert!(sole(ActionType::EnterValue, &json!({"format": "bip39"})).contains("enter_secret"));
assert!(
check(
ActionType::EnterSecret,
&json!({"format": "bip39", "length": 24})
)
.is_empty()
);
assert!(
check(
ActionType::EnterSecret,
&json!({"format": "bip39", "min_length": 12, "max_length": 24})
)
.is_empty()
);
assert!(
sole(
ActionType::EnterSecret,
&json!({"format": "bip39", "length": 32})
)
.contains("12, 15, 18, 21 or 24")
);
assert!(
sole(
ActionType::EnterSecret,
&json!({"format": "bip39", "min_length": 13, "max_length": 14})
)
.contains("12, 15, 18, 21 or 24")
);
assert!(check(ActionType::EnterSecret, &json!({"message": "PIN"})).is_empty());
assert!(
check(
ActionType::EnterSecret,
&json!({"message": "Key", "format": "hex", "length": 32})
)
.is_empty()
);
}
}