Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ ratatui = { version = "0.30.0", default-features = false, features = [
crossterm = "0.29.0"
dashmap = "6.1.0"
subtle = "2.6.1"
regex-lite = "0.1.8"
sysinfo = { version = "0.39.0", default-features = false, features = ["system", "disk"] }
x509-cert = { version = "0.3.0", features = ["builder"] }
signature = "3.0.0"
Expand Down
10 changes: 10 additions & 0 deletions crates/rite-ls/src/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@ pub static ALL: &[ActionMeta] = &[
short: "Capture machine information (hostname, CPU, OS) as evidence",
long: "Capture machine information (hostname, CPU, OS) as evidence. Records device identity to prove which machine ran the ceremony.",
},
ActionMeta {
name: "enter_value",
short: "A person types a value the ceremony records",
long: "A person types a value the ceremony records: a serial number read off a device, an address shown on a screen. The value becomes a text artifact under `creates:` and appears in the transcript. `format:` says what kind of value it is: text (the default), digits, alphanumeric, hex, base64, or `{ pattern: \"...\" }`; `length:` or `min_length:`/`max_length:` bound it, in characters for text and bytes for an encoding. A slip is refused at the keyboard and the rule is shown before typing. For hex or base64 the artifact is the decoded bytes.",
},
ActionMeta {
name: "enter_secret",
short: "A person types a secret the ceremony holds and never records",
long: "A person types a secret the ceremony holds and never records: a passphrase, a PIN. Echo is off, the artifact under `creates:` is wiped from memory when the run ends, and the transcript says only that a secret was entered at this step. A later step names it in `reads:`; `import_key` takes it as `passphrase:` to open an encrypted private key. Takes the same `format:`, `length:`, `min_length:` and `max_length:` as `enter_value`.",
},
ActionMeta {
name: "generate_key",
short: "Generate a key through a backend",
Expand Down
3 changes: 3 additions & 0 deletions crates/rite-model/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ rite-sdk = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
indexmap = { workspace = true }
regex-lite = { workspace = true }
base16ct = { workspace = true }
base64ct = { workspace = true }
zeroize = { workspace = true }

[lints]
Expand Down
3 changes: 2 additions & 1 deletion crates/rite-model/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,5 +45,6 @@ pub use ir::{
};

pub use transcript::{
ErrorClass, ErrorRecord, Prompt, ResponseRecord, StepFact, StepOutcome, ValidatorSpec,
ErrorClass, ErrorRecord, Format, PLACEHOLDER_LIMIT, Prompt, ResponseRecord, StepFact,
StepOutcome, ValidatorSpec, compile_pattern,
};
260 changes: 260 additions & 0 deletions crates/rite-model/src/params.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
//! checking, and that question is answered by the action handler instead, at
//! the point where a backend exists to ask.

use serde::{Deserialize, Serialize};

use crate::transcript::{Format, ValidatorSpec, compile_pattern};
use crate::types::{ActionType, CertProfile, SharingScheme};
use rite_sdk::{KeyAlgorithm, KeyUsages, SignAlgorithm, WrapScheme};

Expand Down Expand Up @@ -112,6 +115,7 @@ pub fn check(action: ActionType, with: &serde_json::Value) -> Vec<ParamError> {
}));
errors
}
ActionType::EnterValue | ActionType::EnterSecret => entry_shape(with),

ActionType::ClockCheck
| ActionType::Confirm
Expand Down Expand Up @@ -189,6 +193,157 @@ fn named_value<T>(
.collect()
}

/// The shape an `enter_value` or `enter_secret` step gives the value it asks
/// for, checked the way the step will build it.
///
/// A field still carrying an expression is absent from the projection, so the
/// rule is built from what is literal, and a conflict between a literal field
/// and one resolved at run time is found there.
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
}

/// `format:` as a step writes it: the name of a [`Format`], or a pattern.
///
/// A pattern is a format too, one whose argument is the expression, so it is
/// written under the same key rather than beside it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum FormatSpec {
/// `format: hex`
Named(Format),
/// `format: { pattern: "..." }`
Pattern {
/// A regular expression the whole value must match.
pattern: String,
},
}

impl FormatSpec {
/// Read the field, with a message that says what the two forms are.
///
/// # Errors
///
/// Returns why the value is neither a format name nor a pattern.
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) or be \
{{ pattern: \"...\" }}, found {value}"
)),
}
}
}

/// What an `enter_value` or `enter_secret` step says about the value it asks
/// for, as the `with:` fields spell it.
///
/// One place turns these into a [`ValidatorSpec`], so `rite check` and the
/// running step cannot disagree about which combinations mean something.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct EntryShape {
/// `format:`, what kind of value it is.
pub format: Option<FormatSpec>,
/// `length:`, an exact length.
pub length: Option<usize>,
/// `min_length:`.
pub min_length: Option<usize>,
/// `max_length:`.
pub max_length: Option<usize>,
}

impl EntryShape {
/// The rule these fields describe.
///
/// Nothing given asks only for a non-empty value. A pattern says the
/// whole shape, so a length beside it is refused rather than combined
/// with it in a way the author would have to guess at. Lengths count the
/// format's own units: characters for text, bytes for an encoding.
///
/// # Errors
///
/// Returns why the fields do not describe one rule, phrased for the
/// ceremony author.
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"
));
}
match format {
None if !bounded => Ok(ValidatorSpec::NonEmpty),
format => Ok(ValidatorSpec::Format {
format: format.unwrap_or(Format::Text),
min_length,
max_length,
}),
}
}
}

/// Check the names under `policy: { usages: [...] }`.
///
/// These are PKCS#11 usages, what the token permits the key to do. The X.509
Expand Down Expand Up @@ -508,4 +663,109 @@ mod tests {
// deferred, not reported.
assert!(check(ActionType::SplitSecret, &json!({"shares": 3})).is_empty());
}

/// One rule from the shape fields, the same one the step will apply.
#[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),
})
));

// Bounds alone: the format is text.
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::EnterSecret, &json!({"length": "six"})).contains("positive integer")
);
assert!(check(ActionType::EnterSecret, &json!({"message": "PIN"})).is_empty());
assert!(
check(
ActionType::EnterSecret,
&json!({"message": "Key", "format": "hex", "length": 32})
)
.is_empty()
);
}
}
Loading
Loading