diff --git a/Cargo.lock b/Cargo.lock index 91aeb54..08e956f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1873,6 +1873,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "regex-syntax" version = "0.8.11" @@ -1930,7 +1936,10 @@ dependencies = [ name = "rite-model" version = "0.6.0" dependencies = [ + "base16ct 1.0.0", + "base64ct", "indexmap", + "regex-lite", "rite-sdk", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index a5cc4a1..7fa5754 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/crates/rite-ls/src/actions.rs b/crates/rite-ls/src/actions.rs index 0663a5c..1206138 100644 --- a/crates/rite-ls/src/actions.rs +++ b/crates/rite-ls/src/actions.rs @@ -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", diff --git a/crates/rite-model/Cargo.toml b/crates/rite-model/Cargo.toml index 830f3c0..4c8ec9f 100644 --- a/crates/rite-model/Cargo.toml +++ b/crates/rite-model/Cargo.toml @@ -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] diff --git a/crates/rite-model/src/lib.rs b/crates/rite-model/src/lib.rs index c0f5d79..5a394d6 100644 --- a/crates/rite-model/src/lib.rs +++ b/crates/rite-model/src/lib.rs @@ -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, }; diff --git a/crates/rite-model/src/params.rs b/crates/rite-model/src/params.rs index 3069349..119dce4 100644 --- a/crates/rite-model/src/params.rs +++ b/crates/rite-model/src/params.rs @@ -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}; @@ -112,6 +115,7 @@ pub fn check(action: ActionType, with: &serde_json::Value) -> Vec { })); errors } + ActionType::EnterValue | ActionType::EnterSecret => entry_shape(with), ActionType::ClockCheck | ActionType::Confirm @@ -189,6 +193,157 @@ fn named_value( .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 { + let mut errors = Vec::new(); + let mut length = |field: &'static str| -> Option { + 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 { + 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, + /// `length:`, an exact length. + pub length: Option, + /// `min_length:`. + pub min_length: Option, + /// `max_length:`. + pub max_length: Option, +} + +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 { + 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 @@ -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() + ); + } } diff --git a/crates/rite-model/src/transcript.rs b/crates/rite-model/src/transcript.rs index 16a3db0..56f7b49 100644 --- a/crates/rite-model/src/transcript.rs +++ b/crates/rite-model/src/transcript.rs @@ -12,22 +12,263 @@ use std::path::PathBuf; +use base64ct::Encoding as _; use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; use crate::ir::{ActId, RoleId, StepId}; -/// Validator applied by the runtime to a free-form text or literal response -/// before it is accepted. +/// Validator applied by the runtime to a typed response before it is +/// accepted. +/// +/// The rule is part of the prompt, so it is recorded with it: a transcript +/// says a six-digit secret was entered, never which one. [`check`](Self::check) +/// is the one place a rule is applied, so `rite check` and the running +/// ceremony agree on what a pattern accepts. #[non_exhaustive] -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(tag = "kind", content = "value", rename_all = "snake_case")] pub enum ValidatorSpec { /// Reject empty or whitespace-only input. + #[default] NonEmpty, - /// Input must match this regular expression. + /// Input must match this regular expression in full. Regex(String), /// Named, runtime-defined predicate (e.g. `serial_number`). Predefined(String), + /// A value of one [`Format`], with a length within bounds. + /// + /// What a PIN or a key component needs, stated without a pattern the + /// person writing the ceremony has to get right. The length is counted + /// in the format's own units: characters for text, bytes for an + /// encoding. + Format { + /// What kind of value this is. + format: Format, + /// Fewest units accepted, if bounded. + min_length: Option, + /// Most units accepted, if bounded. + max_length: Option, + }, +} + +/// What kind of value a person types. +/// +/// Each format has one canonical representation, which is what an entry step +/// keeps: the text as typed for a text format, the decoded bytes for an +/// encoding. An encoding is forgiving of the grouping a person types it in, +/// so whitespace is dropped before decoding. +#[non_exhaustive] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Format { + /// Anything the person can type. + Text, + /// `0` to `9`. + Digits, + /// ASCII letters and digits. + Alphanumeric, + /// Bytes as hexadecimal, in either case, two digits per byte. + Hex, + /// Bytes as standard base64 with padding, as `openssl base64` writes it. + Base64, +} + +impl Format { + /// Whether the value stands for bytes, which is what the step then keeps. + #[must_use] + pub fn is_encoding(self) -> bool { + match self { + Format::Text | Format::Digits | Format::Alphanumeric => false, + Format::Hex | Format::Base64 => true, + } + } + + /// Whether a text format accepts this character. An encoding accepts + /// whatever decodes. + fn accepts(self, c: char) -> bool { + match self { + Format::Text | Format::Hex | Format::Base64 => true, + Format::Digits => c.is_ascii_digit(), + Format::Alphanumeric => c.is_ascii_alphanumeric(), + } + } + + /// Decode a typed value of an encoding. + /// + /// # Errors + /// + /// Returns why the value is not this encoding, or that the format is not + /// one, worded for the person who typed it and naming nothing of what + /// they typed. + pub fn decode(self, value: &str) -> Result, String> { + // The value may be a secret, so the copy without its whitespace is + // wiped with the call. + let compact: Zeroizing = + Zeroizing::new(value.chars().filter(|c| !c.is_whitespace()).collect()); + match self { + Format::Hex => base16ct::mixed::decode_vec(compact.as_bytes()) + .map_err(|_| "value must be hex, two digits per byte".to_string()), + Format::Base64 => base64ct::Base64::decode_vec(&compact) + .map_err(|_| "value must be standard base64, with padding".to_string()), + Format::Text | Format::Digits | Format::Alphanumeric => { + Err(format!("{} is text and does not decode", self.describe())) + } + } + } + + /// The unit a length counts, as a person reads it in a hint. + #[must_use] + pub fn describe(self) -> &'static str { + match self { + Format::Text => "characters", + Format::Digits => "digits", + Format::Alphanumeric => "letters or digits", + Format::Hex => "bytes as hex", + Format::Base64 => "bytes as base64", + } + } + + /// A stand-in of `length` units that satisfies the format, for a run + /// with no one at the keyboard. Never a real value. + /// + /// `None` above [`PLACEHOLDER_LIMIT`]: a length a ceremony declares is + /// not bounded, and a stand-in that size would be allocated for nothing. + /// The caller then declines to answer rather than answering with + /// something the rule refuses, which would be asked again without end. + #[must_use] + pub fn placeholder(self, length: usize) -> Option { + if length > PLACEHOLDER_LIMIT { + return None; + } + Some(match self { + Format::Text | Format::Alphanumeric => "x".repeat(length), + Format::Digits => "0".repeat(length), + Format::Hex => base16ct::lower::encode_string(&vec![0u8; length]), + Format::Base64 => base64ct::Base64::encode_string(&vec![0u8; length]), + }) + } +} + +/// The longest stand-in a [`Format::placeholder`] builds, in the format's +/// units. Large enough for any value a person would type or paste, and the +/// entry itself is not bounded by it. +pub const PLACEHOLDER_LIMIT: usize = 64 * 1024; + +impl std::str::FromStr for Format { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "text" => Ok(Format::Text), + "digits" => Ok(Format::Digits), + "alphanumeric" => Ok(Format::Alphanumeric), + "hex" => Ok(Format::Hex), + "base64" => Ok(Format::Base64), + other => Err(format!( + "unknown format '{other}': expected text, digits, alphanumeric, hex or base64" + )), + } + } +} + +impl ValidatorSpec { + /// Apply the rule to a typed value. + /// + /// # Errors + /// + /// Returns what the value fails to satisfy, worded for the person who + /// typed it, or why the rule itself cannot be applied. + pub fn check(&self, value: &str) -> Result<(), String> { + match self { + ValidatorSpec::NonEmpty => { + if value.trim().is_empty() { + Err("value must not be empty".to_string()) + } else { + Ok(()) + } + } + ValidatorSpec::Regex(pattern) => { + let regex = compile_pattern(pattern)?; + if regex.is_match(value) { + Ok(()) + } else { + Err(format!("value must match {pattern}")) + } + } + // Named predicates will land alongside the actions that need them. + ValidatorSpec::Predefined(name) => Err(format!("unknown validator: {name}")), + ValidatorSpec::Format { + format, + min_length, + max_length, + } => { + let hint = self.hint().unwrap_or_default(); + if value.trim().is_empty() { + return Err("value must not be empty".to_string()); + } + // An encoding is decoded and discarded, wiped on the way out: + // the value may be a secret, and this runs before the step + // holds it. + let length = if format.is_encoding() { + Zeroizing::new(format.decode(value)?).len() + } else { + if !value.chars().all(|c| format.accepts(c)) { + return Err(format!("value must be {hint}")); + } + value.chars().count() + }; + if min_length.is_some_and(|min| length < min) + || max_length.is_some_and(|max| length > max) + { + return Err(format!("value must be {hint}, this is {length}")); + } + Ok(()) + } + } + } + + /// The rule as a person reads it before typing, if there is one worth + /// stating. + /// + /// `NonEmpty` says nothing: every prompt asks for something. A pattern is + /// shown as written, since a regular expression has no better rendering. + #[must_use] + pub fn hint(&self) -> Option { + match self { + ValidatorSpec::NonEmpty | ValidatorSpec::Predefined(_) => None, + ValidatorSpec::Regex(pattern) => Some(format!("matching {pattern}")), + ValidatorSpec::Format { + format, + min_length, + max_length, + } => { + let what = format.describe(); + Some(match (min_length, max_length) { + (Some(min), Some(max)) if min == max => format!("{min} {what}"), + (Some(min), Some(max)) => format!("{min} to {max} {what}"), + (Some(min), None) => format!("at least {min} {what}"), + (None, Some(max)) => format!("at most {max} {what}"), + (None, None) if format.is_encoding() => what.to_string(), + (None, None) => format!("{what} only"), + }) + } + } + } +} + +/// Compile a pattern so that it must match the whole value. +/// +/// A rule that read `[0-9]+` and accepted `abc123def` would pass what its +/// author meant to refuse, so the anchors are always supplied here rather +/// than expected of the author. +/// +/// # Errors +/// +/// Returns why the pattern is not a regular expression. +pub fn compile_pattern(pattern: &str) -> Result { + regex_lite::Regex::new(&format!("^(?:{pattern})$")) + .map_err(|e| format!("invalid pattern '{pattern}': {e}")) } /// Request for user input, recorded into the transcript as part of @@ -55,6 +296,11 @@ pub enum Prompt { Secret { /// Label shown to the user. label: String, + /// Validator applied before the response is accepted. Absent in + /// transcripts written before a secret carried one, which asked for + /// nothing beyond a non-empty value. + #[serde(default)] + validator: ValidatorSpec, }, /// User must type a specific literal string exactly. Validation is /// performed by the runtime against `expected`. @@ -406,6 +652,157 @@ impl StepFact { /// /// Breaking the format is allowed in early beta, **deliberately**, update /// the fixture in the same commit so the diff documents the wire change. +#[cfg(test)] +mod validator_tests { + use super::*; + + fn shape(format: Format, min: usize, max: usize) -> ValidatorSpec { + ValidatorSpec::Format { + format, + min_length: Some(min), + max_length: Some(max), + } + } + + #[test] + fn a_text_format_checks_characters_and_length() { + let pin = shape(Format::Digits, 6, 8); + assert!(pin.check("123456").is_ok()); + assert!(pin.check("12345678").is_ok()); + assert!(pin.check("12345").unwrap_err().contains("this is 5")); + assert!(pin.check("123456789").unwrap_err().contains("this is 9")); + assert!(pin.check("12345a").unwrap_err().contains("digits")); + + assert!(shape(Format::Alphanumeric, 1, 4).check("ab12").is_ok()); + assert!(shape(Format::Alphanumeric, 1, 4).check("ab-1").is_err()); + assert!(shape(Format::Text, 1, 4).check("ab-1").is_ok()); + assert!( + ValidatorSpec::Format { + format: Format::Text, + min_length: None, + max_length: None, + } + .check(" ") + .is_err(), + "a value is still something" + ); + } + + /// The pattern covers the whole value: a rule that read `[0-9]+` and + /// accepted `abc123` would pass what its author meant to refuse. + #[test] + fn a_pattern_must_match_the_whole_value() { + let digits = ValidatorSpec::Regex("[0-9]+".to_string()); + assert!(digits.check("123").is_ok()); + assert!(digits.check("abc123").is_err()); + assert!(digits.check("123abc").is_err()); + // Alternation is grouped before anchoring. + let either = ValidatorSpec::Regex("yes|no".to_string()); + assert!(either.check("no").is_ok()); + assert!(either.check("nope").is_err()); + } + + #[test] + fn a_refusal_names_the_rule_and_not_the_value() { + let err = shape(Format::Digits, 6, 6).check("hunter2").unwrap_err(); + assert!(!err.contains("hunter2")); + let err = ValidatorSpec::Regex("[0-9]+".to_string()) + .check("hunter2") + .unwrap_err(); + assert!(!err.contains("hunter2")); + } + + #[test] + fn a_hint_reads_as_a_person_would_say_it() { + assert_eq!(shape(Format::Digits, 6, 6).hint().unwrap(), "6 digits"); + assert_eq!( + shape(Format::Text, 6, 8).hint().unwrap(), + "6 to 8 characters" + ); + assert_eq!( + ValidatorSpec::Format { + format: Format::Hex, + min_length: Some(32), + max_length: None, + } + .hint() + .unwrap(), + "at least 32 bytes as hex" + ); + assert_eq!( + ValidatorSpec::Format { + format: Format::Alphanumeric, + min_length: None, + max_length: None, + } + .hint() + .unwrap(), + "letters or digits only" + ); + assert_eq!( + ValidatorSpec::Format { + format: Format::Base64, + min_length: None, + max_length: None, + } + .hint() + .unwrap(), + "bytes as base64" + ); + assert!(ValidatorSpec::NonEmpty.hint().is_none()); + } + + /// The string is transport: grouping and case are dropped, and what is + /// checked is the bytes. + #[test] + fn an_encoding_decodes_and_counts_bytes() { + let key = shape(Format::Hex, 4, 4); + assert!(key.check("deadbeef").is_ok()); + assert!(key.check("DE AD be ef").is_ok()); + assert!(key.check("deadbe").unwrap_err().contains("this is 3")); + assert!(key.check("deadbeefx").unwrap_err().contains("hex")); + assert_eq!(Format::Hex.decode("DE AD").unwrap(), vec![0xde, 0xad]); + + let b64 = ValidatorSpec::Format { + format: Format::Base64, + min_length: None, + max_length: None, + }; + assert!(b64.check("aGVsbG8=").is_ok()); + assert!(b64.check("aGVsbG8").is_err(), "padding is required"); + assert_eq!(Format::Base64.decode("aGVs\nbG8=").unwrap(), b"hello"); + assert!(Format::Digits.decode("12").is_err(), "text does not decode"); + } + + #[test] + fn a_placeholder_satisfies_its_own_format() { + for format in [ + Format::Text, + Format::Digits, + Format::Alphanumeric, + Format::Hex, + Format::Base64, + ] { + let stand_in = format.placeholder(32).unwrap(); + assert!(shape(format, 32, 32).check(&stand_in).is_ok()); + } + assert!(Format::Text.placeholder(PLACEHOLDER_LIMIT + 1).is_none()); + } + + /// A transcript written before secrets carried a rule still reads. + #[test] + fn a_secret_prompt_without_a_validator_still_parses() { + let prompt: Prompt = serde_json::from_str(r#"{"type":"secret","label":"PIN"}"#).unwrap(); + assert!(matches!( + prompt, + Prompt::Secret { + validator: ValidatorSpec::NonEmpty, + .. + } + )); + } +} + #[cfg(test)] mod schema_snapshot_tests { use super::*; @@ -568,13 +965,18 @@ mod schema_snapshot_tests { step: Some(StepId::new("s1")), prompt: Prompt::Secret { label: "PIN".to_string(), + validator: ValidatorSpec::NonEmpty, }, response: ResponseRecord::SecretRedacted {}, }, &json!({ "type": "prompt_answered", "step": "s1", - "prompt": { "type": "secret", "label": "PIN" }, + "prompt": { + "type": "secret", + "label": "PIN", + "validator": { "kind": "non_empty" }, + }, "response": { "type": "secret_redacted" }, }), ); diff --git a/crates/rite-model/src/types.rs b/crates/rite-model/src/types.rs index caaeecd..008b4cf 100644 --- a/crates/rite-model/src/types.rs +++ b/crates/rite-model/src/types.rs @@ -90,6 +90,24 @@ pub enum ActionType { /// Records device identity to prove which machine ran the ceremony. /// Should be placed early in ceremony to establish machine context. MachineInfo, + /// A person types a value the ceremony needs and can record: a serial + /// number read off a device, an address shown on a screen. + /// + /// The value becomes a text artifact, so a later step compares it or + /// prints it, and the transcript carries it. `format:` and the length + /// fields say what shape the value has, so a slip is refused at the + /// keyboard rather than found at the end. + EnterValue, + /// A person types a secret the ceremony needs and must not record: a + /// passphrase, a PIN. + /// + /// `enter_value` for a value the transcript may not carry. Echo is off, + /// the artifact is held wiped in memory, and the transcript records that + /// a secret was entered at this step and nothing derived from it. A step + /// names it in `reads:`, which borrows the value. An expression in + /// `with:` copies it into the step's parameters, as it would opened + /// content. + EnterSecret, /// Generate a key: a keypair for an asymmetric algorithm, one secret for /// a symmetric one. @@ -382,6 +400,12 @@ pub struct ReadsContract { /// Groups from which exactly one input must be named. Each group holds the /// alternatives in the order they should be listed to the author. pub exactly_one_of: &'static [&'static [&'static str]], + /// Inputs a step may name and the action reads when it does. + /// + /// Listed so that a key the action would never look at is refused rather + /// than dropped: a `reads:` entry that nothing reads is a step running + /// without what its author gave it. + pub optional: &'static [&'static str], /// `with:` fields that mean nothing unless the step also names a given /// input, as `(field, input)` pairs. /// @@ -420,6 +444,7 @@ impl ReadsContract { Self { required: fields, exactly_one_of: &[], + optional: &[], with_field_requires: &[], lists: &[], } @@ -434,7 +459,18 @@ impl ReadsContract { /// /// `with_field_requires` is a rule over `with:`, so it is not counted here. pub fn is_empty(&self) -> bool { - self.required.is_empty() && self.exactly_one_of.is_empty() && self.lists.is_empty() + self.required.is_empty() + && self.exactly_one_of.is_empty() + && self.optional.is_empty() + && self.lists.is_empty() + } + + /// Whether a step of this action may name this input. + pub fn accepts(&self, key: &str) -> bool { + self.required.contains(&key) + || self.optional.contains(&key) + || self.exactly_one_of.iter().any(|group| group.contains(&key)) + || self.lists.iter().any(|list| list.name == key) } } @@ -451,6 +487,8 @@ impl ActionType { ActionType::CheckValue, ActionType::OralReadback, ActionType::MachineInfo, + ActionType::EnterValue, + ActionType::EnterSecret, ActionType::GenerateKey, ActionType::WrapKey, ActionType::UnwrapKey, @@ -502,6 +540,8 @@ impl ActionType { | ActionType::CheckValue | ActionType::OralReadback | ActionType::MachineInfo + | ActionType::EnterValue + | ActionType::EnterSecret | ActionType::Attest | ActionType::CombineShares | ActionType::GatherEntropy => BackendUsage::Unused, @@ -524,6 +564,9 @@ impl ActionType { // what it is lifting rather than the step guessing. ActionType::ImportKey => &["algorithm"], ActionType::SplitSecret => &["threshold", "shares"], + // The label is what the person sees at the keyboard, and there is + // no default that names what they are being asked for. + ActionType::EnterValue | ActionType::EnterSecret => &["message"], ActionType::ClockCheck | ActionType::Confirm @@ -565,7 +608,7 @@ impl ActionType { match self { ActionType::ClockCheck | ActionType::Confirm => &["message"], ActionType::CheckValue => &["actual", "expected", "message", "sensitive"], - ActionType::OralReadback => &["value", "format", "characters", "sensitive", "message"], + ActionType::OralReadback => &["value", "format", "characters", "message"], ActionType::MachineInfo => &[ "include_machine_id", "include_cpu", @@ -573,6 +616,9 @@ impl ActionType { "include_security_features", "message", ], + ActionType::EnterValue | ActionType::EnterSecret => { + &["message", "format", "length", "min_length", "max_length"] + } ActionType::GenerateKey => &["algorithm", "policy", "slot"], ActionType::WrapKey => &["scheme", "expect_recipient"], ActionType::UnwrapKey | ActionType::ImportKey => { @@ -610,13 +656,23 @@ impl ActionType { ActionType::WrapKey => ReadsContract { required: &["key_to_wrap"], exactly_one_of: &[&["wrapping_key", "recipient"]], + optional: &[], // `expect_recipient` is compared against the recipient a wrap // is given, and only the external path has one. with_field_requires: &[("expect_recipient", "recipient")], lists: &[], }, ActionType::UnwrapKey => ReadsContract::required(&["unwrapping_key", "wrapped_data"]), - ActionType::ImportKey => ReadsContract::required(&["key_material"]), + ActionType::ImportKey => ReadsContract { + required: &["key_material"], + exactly_one_of: &[], + // What opens an encrypted private key. Read from the store + // rather than given in `with:`, so the value is borrowed and + // never copied into the step's parameters. + optional: &["passphrase"], + with_field_requires: &[], + lists: &[], + }, ActionType::EncryptData => ReadsContract::required(&["data", "encryption_key"]), ActionType::DecryptData => { ReadsContract::required(&["encrypted_data", "decryption_key"]) @@ -627,6 +683,7 @@ impl ActionType { ActionType::CombineShares => ReadsContract { required: &[], exactly_one_of: &[], + optional: &[], with_field_requires: &[], lists: &[ListInput { name: "shares", @@ -635,7 +692,16 @@ impl ActionType { }, ActionType::SignData => ReadsContract::required(&["key", "data"]), ActionType::VerifySignature => ReadsContract::required(&["key", "data", "signature"]), - ActionType::IssueCertificate => ReadsContract::required(&["signing_key", "csr"]), + // Without `issuer_cert` the certificate is self-issued under the + // CSR's subject; with it, the issuer name and key identifier come + // from the CA certificate. + ActionType::IssueCertificate => ReadsContract { + required: &["signing_key", "csr"], + exactly_one_of: &[], + optional: &["issuer_cert"], + with_field_requires: &[], + lists: &[], + }, ActionType::GenerateCsr => ReadsContract::required(&["signing_key"]), ActionType::ClockCheck @@ -643,6 +709,8 @@ impl ActionType { | ActionType::CheckValue | ActionType::OralReadback | ActionType::MachineInfo + | ActionType::EnterValue + | ActionType::EnterSecret | ActionType::GenerateKey | ActionType::ExportPublic | ActionType::Attest @@ -665,6 +733,8 @@ impl ActionType { ActionType::CheckValue => "Verify a value matches an expected result.", ActionType::OralReadback => "Read back a value aloud for verification.", ActionType::MachineInfo => "Record system and environment information.", + ActionType::EnterValue => "Type a value the ceremony records.", + ActionType::EnterSecret => "Type a secret the ceremony holds and never records.", ActionType::Attest => "Record a signed attestation from a participant.", ActionType::GatherEntropy => "Fold human-supplied entropy into the ceremony seed.", ActionType::TpmAttest => "Record TPM platform attestation (PCR values).", @@ -698,6 +768,8 @@ impl std::fmt::Display for ActionType { ActionType::CheckValue => write!(f, "check_value"), ActionType::OralReadback => write!(f, "oral_readback"), ActionType::MachineInfo => write!(f, "machine_info"), + ActionType::EnterValue => write!(f, "enter_value"), + ActionType::EnterSecret => write!(f, "enter_secret"), ActionType::GenerateKey => write!(f, "generate_key"), ActionType::WrapKey => write!(f, "wrap_key"), ActionType::UnwrapKey => write!(f, "unwrap_key"), @@ -1087,6 +1159,8 @@ mod tests { | ActionType::CheckValue | ActionType::OralReadback | ActionType::MachineInfo + | ActionType::EnterValue + | ActionType::EnterSecret | ActionType::GenerateKey | ActionType::WrapKey | ActionType::UnwrapKey diff --git a/crates/rite-openssl/src/backend.rs b/crates/rite-openssl/src/backend.rs index 460a337..1cbde47 100644 --- a/crates/rite-openssl/src/backend.rs +++ b/crates/rite-openssl/src/backend.rs @@ -639,46 +639,138 @@ fn unsupported_ml_dsa(operation: &str) -> BackendError { /// Parse imported private key material in either encoding. /// /// PEM says what it is in its first line, so material that starts with a -/// preamble is read as PEM and everything else as DER. Encrypted PEM needs a -/// passphrase that nothing carries here, and is refused by name rather than as -/// a parse failure. +/// preamble is read as PEM and everything else as DER. An encrypted key is +/// opened with the passphrase when one is given and refused by name when none +/// is, rather than failing as a parse error. A passphrase given for a key that +/// is not encrypted is refused too: the step records that a passphrase was +/// supplied, and that record has to mean it was used. /// /// Separate from [`parse_private_key_der`], which unwrapping uses: a wrapped /// key is always DER, so accepting PEM there would accept material no wrap /// produces. -fn parse_imported_private_key(bytes: &[u8]) -> Result, BackendError> { - if !bytes.trim_ascii_start().starts_with(b"-----BEGIN") { - return parse_private_key_der(bytes); +fn parse_imported_private_key( + bytes: &[u8], + passphrase: Option<&[u8]>, +) -> Result, BackendError> { + let parsed = if bytes.trim_ascii_start().starts_with(b"-----BEGIN") { + parse_pem_private_key(bytes, passphrase)? + } else { + parse_der_private_key(bytes, passphrase)? + }; + match (parsed, passphrase) { + (Parsed::Plain(_), Some(_)) => Err(BackendError::InvalidData( + "a passphrase was supplied, but this private key is not encrypted, so it was \ + not used" + .to_string(), + )), + (Parsed::Plain(key) | Parsed::Opened(key), _) => Ok(key), } +} - // Always the callback form, never `private_key_from_pem`. That one hands - // OpenSSL a null callback, and OpenSSL's own default reads a passphrase - // from the terminal, which in a running ceremony is a prompt written over - // the frontend. This callback supplies nothing, so an encrypted key fails - // to parse instead of blocking. - // - // It is also how the encryption is detected. OpenSSL asks for a passphrase - // exactly when the block is encrypted, in every encoding it knows, which is - // a stronger test than matching the header strings each encoding happens to - // use: PKCS#8 says so in the preamble, and the traditional format that - // `openssl genrsa -traditional -aes256` still writes says so in RFC 1421 - // headers inside an ordinary one. +/// A private key that parsed, and whether a passphrase was needed to do it. +enum Parsed { + /// Read as it was, with no passphrase asked for. + Plain(PKey), + /// Decrypted with the passphrase. + Opened(PKey), +} + +/// What the parser says when the material wants a passphrase. +fn refuse_encrypted(encoding: &str, passphrase: Option<&[u8]>) -> BackendError { + match passphrase { + Some(_) => BackendError::InvalidData(format!( + "the passphrase does not open this encrypted {encoding} private key" + )), + None => BackendError::InvalidData(format!( + "this is an encrypted {encoding} private key, and the ceremony supplies no \ + passphrase to open it. Read one with 'enter_secret' and name it as 'passphrase' \ + beside the material." + )), + } +} + +/// Parse a PEM private key, encrypted or not. +/// +/// Always the callback form, never `private_key_from_pem`. That one hands +/// OpenSSL a null callback, and OpenSSL's own default reads a passphrase +/// from the terminal, which in a running ceremony is a prompt written over +/// the frontend. This callback answers from what the ceremony holds, or with +/// nothing, so an encrypted key with no passphrase fails to parse instead of +/// blocking. +/// +/// It is also how the encryption is detected. OpenSSL asks for a passphrase +/// exactly when the block is encrypted, in every encoding it knows, which is +/// a stronger test than matching the header strings each encoding happens to +/// use: PKCS#8 says so in the preamble, and the traditional format that +/// `openssl genrsa -traditional -aes256` still writes says so in RFC 1421 +/// headers inside an ordinary one. +fn parse_pem_private_key(bytes: &[u8], passphrase: Option<&[u8]>) -> Result { let asked = std::cell::Cell::new(false); - PKey::private_key_from_pem_callback(bytes, |_| { + let parsed = PKey::private_key_from_pem_callback(bytes, |buf| { asked.set(true); - Ok(0) - }) - .map_err(|e| { - if asked.get() { - BackendError::InvalidData( - "this is an encrypted PEM private key, and a ceremony carries no passphrase \ - to open it. Decrypt it first with 'openssl pkey -in -out '." - .to_string(), - ) - } else { - ossl_err("Parse PEM private key", &e) + match passphrase { + Some(passphrase) if passphrase.len() <= buf.len() => { + if let Some(slot) = buf.get_mut(..passphrase.len()) { + slot.copy_from_slice(passphrase); + } + Ok(passphrase.len()) + } + _ => Ok(0), } - }) + }); + match parsed { + Ok(key) if asked.get() => Ok(Parsed::Opened(key)), + Ok(key) => Ok(Parsed::Plain(key)), + Err(_) if asked.get() => Err(refuse_encrypted("PEM", passphrase)), + Err(e) => Err(ossl_err("Parse PEM private key", &e)), + } +} + +/// Parse a DER private key, as PKCS#8 `EncryptedPrivateKeyInfo` when the +/// plain encodings refuse it. +/// +/// The plain parse runs first whether or not a passphrase was given, so a +/// passphrase supplied for material that never needed one is found rather +/// than swallowed. +fn parse_der_private_key(bytes: &[u8], passphrase: Option<&[u8]>) -> Result { + let plain = match parse_private_key_der(bytes) { + Ok(key) => return Ok(Parsed::Plain(key)), + Err(e) => e, + }; + // Structural test: which DER declares itself encrypted. Without it a + // plain parse failure of any other kind would be reported as a wrong + // passphrase. + if !looks_like_encrypted_pkcs8(bytes) { + return Err(plain); + } + match passphrase { + Some(passphrase) => PKey::private_key_from_pkcs8_passphrase(bytes, passphrase) + .map(Parsed::Opened) + .map_err(|_| refuse_encrypted("DER", Some(passphrase))), + None => Err(refuse_encrypted("DER", None)), + } +} + +/// Whether DER is a PKCS#8 `EncryptedPrivateKeyInfo`: a SEQUENCE whose first +/// element is an `AlgorithmIdentifier` naming PBES2 or a PKCS#12 PBE scheme. +/// +/// A plain `PrivateKeyInfo` starts with an INTEGER version instead, which is +/// the one byte that tells the two apart. +fn looks_like_encrypted_pkcs8(bytes: &[u8]) -> bool { + // SEQUENCE, a length of one to four bytes, then SEQUENCE (the algorithm + // identifier) rather than INTEGER (the version of a plain key). + let Some((&0x30, rest)) = bytes.split_first() else { + return false; + }; + let Some((&first, rest)) = rest.split_first() else { + return false; + }; + let skip = if first & 0x80 == 0 { + 0 + } else { + usize::from(first & 0x7f) + }; + rest.get(skip) == Some(&0x30) } /// Parse a private key from DER bytes, trying PKCS#8, traditional PKCS#1 (RSA), @@ -744,11 +836,22 @@ impl KeyStoreBackend for OpenSslBackend { self.store_key(spec.algorithm, spec.label, pkey, spec.policy) } - fn import_key(&mut self, spec: KeySpec, key_bytes: &[u8]) -> Result { + fn import_key( + &mut self, + spec: KeySpec, + key_bytes: &[u8], + passphrase: Option<&[u8]>, + ) -> Result { // A symmetric key is its own bytes. Selected by the algorithm because // nothing in the bytes distinguishes a 32-byte secret from anything // else of that length, so the caller has to say which it means. if let Some(length) = spec.algorithm.key_bytes() { + if passphrase.is_some() { + return Err(BackendError::InvalidData(format!( + "{} is a raw key with nothing to decrypt, so a passphrase cannot apply to it", + spec.algorithm + ))); + } if key_bytes.len() != length { return Err(BackendError::InvalidData(format!( "{} is a {length}-byte key, and this is {}", @@ -763,7 +866,7 @@ impl KeyStoreBackend for OpenSslBackend { spec.policy, ); } - let pkey = parse_imported_private_key(key_bytes)?; + let pkey = parse_imported_private_key(key_bytes, passphrase)?; // Declared and actual must agree, as they must at unwrap. Storing the // material under a name the key does not answer to would put that name // in the transcript. @@ -2355,7 +2458,7 @@ mod tests { let mut backend = OpenSslBackend::try_new("test").unwrap(); let meta = backend - .import_key(spec(KeyAlgorithm::Rsa2048, "imported"), &pkcs8_der) + .import_key(spec(KeyAlgorithm::Rsa2048, "imported"), &pkcs8_der, None) .unwrap(); let message = b"import round-trip verification message"; @@ -2461,7 +2564,7 @@ mod tests { for (algorithm, material) in cases.into_iter().chain(post_quantum_material()) { let mut backend = OpenSslBackend::try_new("test").unwrap(); let meta = backend - .import_key(spec(algorithm, "imported"), &material) + .import_key(spec(algorithm, "imported"), &material, None) .unwrap_or_else(|e| panic!("import_key must accept {algorithm}: {e}")); assert_eq!(meta.algorithm, algorithm); @@ -2484,6 +2587,78 @@ mod tests { } } + /// Encrypted PKCS#8 DER, which `openssl pkcs8 -topk8` writes, has no + /// callback form and is told apart from a plain key by its structure. + #[test] + fn opens_encrypted_pkcs8_der_with_the_passphrase() { + let key = PKey::from_rsa(Rsa::generate(2048).unwrap()).unwrap(); + let cipher = openssl::symm::Cipher::aes_256_cbc(); + let encrypted = key + .private_key_to_pkcs8_passphrase(cipher, b"correct horse") + .unwrap(); + assert!(looks_like_encrypted_pkcs8(&encrypted)); + assert!(!looks_like_encrypted_pkcs8( + &key.private_key_to_pkcs8().unwrap() + )); + + let mut backend = OpenSslBackend::try_new("test").unwrap(); + let meta = backend + .import_key( + spec(KeyAlgorithm::Rsa2048, "imported"), + &encrypted, + Some(b"correct horse"), + ) + .expect("the passphrase opens the key"); + assert_eq!( + meta.public_key.unwrap().as_bytes(), + key.public_key_to_der().unwrap() + ); + + let mut backend = OpenSslBackend::try_new("test").unwrap(); + let error = backend + .import_key(spec(KeyAlgorithm::Rsa2048, "imported"), &encrypted, None) + .expect_err("no passphrase, no key"); + assert!(error.to_string().contains("encrypted DER"), "{error}"); + + let mut backend = OpenSslBackend::try_new("test").unwrap(); + let error = backend + .import_key( + spec(KeyAlgorithm::Rsa2048, "imported"), + &encrypted, + Some(b"wrong horse"), + ) + .expect_err("the wrong passphrase does not open it"); + assert!(error.to_string().contains("does not open"), "{error}"); + } + + /// A raw key has nothing to decrypt, and a passphrase the record would + /// say was used cannot have been. + #[test] + fn refuses_a_passphrase_where_nothing_is_encrypted() { + let mut backend = OpenSslBackend::try_new("test").unwrap(); + let error = backend + .import_key( + spec(KeyAlgorithm::Aes256, "kek"), + &[7u8; 32], + Some(b"correct horse"), + ) + .expect_err("a raw key takes no passphrase"); + assert!(error.to_string().contains("raw key"), "{error}"); + + let plain = PKey::from_rsa(Rsa::generate(2048).unwrap()) + .unwrap() + .private_key_to_pkcs8() + .unwrap(); + let error = backend + .import_key( + spec(KeyAlgorithm::Rsa2048, "imported"), + &plain, + Some(b"correct horse"), + ) + .expect_err("plain DER takes no passphrase"); + assert!(error.to_string().contains("not encrypted"), "{error}"); + } + /// The declared algorithm is checked against what the material turns out to /// be, including between two parameter sets of one family. #[test] @@ -2497,7 +2672,7 @@ mod tests { let mut backend = OpenSslBackend::try_new("test").unwrap(); let error = backend - .import_key(spec(KeyAlgorithm::MlDsa87, "imported"), &material) + .import_key(spec(KeyAlgorithm::MlDsa87, "imported"), &material, None) .expect_err("ML-DSA-44 material declared as ML-DSA-87 must be refused"); let message = error.to_string(); @@ -3273,6 +3448,7 @@ mod tests { ..spec(KeyAlgorithm::Aes256, "kek") }, &base16ct::lower::decode_vec(FROZEN_KEK).unwrap(), + None, ) .unwrap(); diff --git a/crates/rite-openssl/tests/interop.rs b/crates/rite-openssl/tests/interop.rs index 0b0fe54..4108296 100644 --- a/crates/rite-openssl/tests/interop.rs +++ b/crates/rite-openssl/tests/interop.rs @@ -309,6 +309,7 @@ fn openssl_cli_decrypts_a_wrap_under_a_symmetric_key() { location_hint: None, }, &secret, + None, ) .unwrap(); let payload = backend @@ -392,6 +393,7 @@ fn rite_unwraps_what_the_openssl_cli_wrote() { location_hint: None, }, &secret, + None, ) .unwrap(); @@ -487,6 +489,7 @@ fn the_openssl_cli_opens_content_sealed_to_a_data_key() { location_hint: None, }, &secret, + None, ) .unwrap(); diff --git a/crates/rite-piv/src/backend.rs b/crates/rite-piv/src/backend.rs index 77f3c53..45e885d 100644 --- a/crates/rite-piv/src/backend.rs +++ b/crates/rite-piv/src/backend.rs @@ -101,6 +101,7 @@ impl KeyStoreBackend for PivCardBackend { &mut self, _spec: KeySpec, _key_bytes: &[u8], + _passphrase: Option<&[u8]>, ) -> Result { Err(BackendError::UnsupportedOperation( "PIV key import requires the 'untested' yubikey feature".to_string(), diff --git a/crates/rite-resolver/src/diagnostic.rs b/crates/rite-resolver/src/diagnostic.rs index bbdb58e..80ccc5e 100644 --- a/crates/rite-resolver/src/diagnostic.rs +++ b/crates/rite-resolver/src/diagnostic.rs @@ -330,6 +330,7 @@ impl SpanMap { | ResolveError::WithFieldNeedsInput { step, .. } | ResolveError::InvalidWithValue { step, .. } | ResolveError::MissingReadsInput { step, .. } + | ResolveError::UnknownReadsInput { step, .. } | ResolveError::AmbiguousReadsInput { step, .. } | ResolveError::TooFewReadsInputs { step, .. } | ResolveError::ReadsInputShape { step, .. } diff --git a/crates/rite-resolver/src/error.rs b/crates/rite-resolver/src/error.rs index 6dd572b..70d2727 100644 --- a/crates/rite-resolver/src/error.rs +++ b/crates/rite-resolver/src/error.rs @@ -260,6 +260,25 @@ pub enum ResolveError { field: &'static str, }, + /// Step `reads:` map names an input the action never reads. + /// + /// The same failure as an unknown `with:` key, and reported for the same + /// reason: the value would be resolved and then ignored, and the step + /// would run without what its author gave it. Only actions that declare + /// their inputs are checked, since a contract that names none accepts + /// whatever the step reads. + #[error("Step '{step}': action '{action}' reads no '{field}' input. Accepted: {accepted}")] + UnknownReadsInput { + /// The step ID. + step: StepId, + /// The action whose contract lacks the key. + action: ActionType, + /// The `reads:` key nothing reads. + field: String, + /// The inputs the action does read, quoted and comma-separated. + accepted: String, + }, + /// Step `reads:` map names neither or both of two alternative inputs, so /// the path the action would take is undetermined. #[error( diff --git a/crates/rite-resolver/src/resolve.rs b/crates/rite-resolver/src/resolve.rs index 984a3dd..b2281e6 100644 --- a/crates/rite-resolver/src/resolve.rs +++ b/crates/rite-resolver/src/resolve.rs @@ -670,6 +670,12 @@ impl ResolveContext { return; } let names = |key: &str| reads_names(step, key); + let quote = |keys: &[&str]| { + keys.iter() + .map(|key| format!("'{key}'")) + .collect::>() + .join(", ") + }; for field in contract.required { if !names(field) { @@ -681,12 +687,6 @@ impl ResolveContext { } } - let quote = |keys: &[&str]| { - keys.iter() - .map(|key| format!("'{key}'")) - .collect::>() - .join(", ") - }; let found = |present: &[&str]| { if present.is_empty() { "none".to_string() @@ -695,6 +695,37 @@ impl ResolveContext { } }; + // Keys the action never reads, checked by name as `with:` keys are. + let declared: Vec<&str> = step + .reads + .as_ref() + .and_then(|r| r.as_object()) + .map(|m| m.keys().map(String::as_str).collect()) + .unwrap_or_default(); + for field in declared { + if !contract.accepts(field) { + let accepted: Vec<&str> = contract + .required + .iter() + .chain( + contract + .exactly_one_of + .iter() + .flat_map(|group| group.iter()), + ) + .chain(contract.optional.iter()) + .copied() + .chain(contract.lists.iter().map(|list| list.name)) + .collect(); + self.add_error(ResolveError::UnknownReadsInput { + step: id.clone(), + action: step.action, + field: field.to_string(), + accepted: quote(&accepted), + }); + } + } + for group in contract.exactly_one_of { let present: Vec<&str> = group.iter().copied().filter(|key| names(key)).collect(); if present.len() == 1 { @@ -2133,6 +2164,14 @@ sections: } fn resolve_wrap_step(reads: serde_json::Value) -> ResolveResult { + resolve_reading_step(ActionType::WrapKey, reads, serde_json::json!({})) + } + + fn resolve_reading_step( + action: ActionType, + reads: serde_json::Value, + with: serde_json::Value, + ) -> ResolveResult { let mut ceremony = minimal_ceremony(); ceremony.backends.insert( "openssl".to_string(), @@ -2142,9 +2181,10 @@ sections: }, ); let mut step = make_step_body(); - step.action = ActionType::WrapKey; + step.action = action; step.backend = Some("openssl".to_string()); step.reads = Some(reads); + step.with = Some(with); ceremony .sections .get_mut("main") @@ -2220,6 +2260,80 @@ sections: } } + /// A `reads:` key nothing reads is refused as an unknown `with:` key is: + /// the value would be resolved and then ignored. + #[test] + fn errors_on_a_reads_input_the_action_never_reads() { + let result = resolve_reading_step( + ActionType::ImportKey, + serde_json::json!({ + "key_material": "${artifact.escrowed}", + "passprhase": "${artifact.escrow_passphrase}", + }), + serde_json::json!({ "algorithm": "RSA-4096" }), + ); + let unknown: Vec<(&str, &str)> = result + .errors + .iter() + .filter_map(|e| match e { + ResolveError::UnknownReadsInput { + field, accepted, .. + } => Some((field.as_str(), accepted.as_str())), + _ => None, + }) + .collect(); + assert_eq!( + unknown, + vec![("passprhase", "'key_material', 'passphrase'")] + ); + } + + #[test] + fn accepts_an_optional_reads_input_named_or_not() { + for reads in [ + serde_json::json!({ "key_material": "${artifact.escrowed}" }), + serde_json::json!({ + "key_material": "${artifact.escrowed}", + "passphrase": "${artifact.escrow_passphrase}", + }), + ] { + let result = resolve_reading_step( + ActionType::ImportKey, + reads, + serde_json::json!({ "algorithm": "RSA-4096" }), + ); + assert!( + !result.errors.iter().any(|e| matches!( + e, + ResolveError::UnknownReadsInput { .. } | ResolveError::MissingReadsInput { .. } + )), + "{:?}", + result.errors + ); + } + } + + #[test] + fn accepts_the_issuer_certificate_of_a_subordinate_issue() { + let result = resolve_reading_step( + ActionType::IssueCertificate, + serde_json::json!({ + "signing_key": "${artifact.root_key}", + "csr": "${artifact.intermediate_csr}", + "issuer_cert": "${artifact.root_cert}", + }), + serde_json::json!({}), + ); + assert!( + !result + .errors + .iter() + .any(|e| matches!(e, ResolveError::UnknownReadsInput { .. })), + "{:?}", + result.errors + ); + } + #[test] fn detects_undeclared_backend() { let mut ceremony = minimal_ceremony(); diff --git a/crates/rite-runtime/src/backend/registry.rs b/crates/rite-runtime/src/backend/registry.rs index f32ef21..9616b44 100644 --- a/crates/rite-runtime/src/backend/registry.rs +++ b/crates/rite-runtime/src/backend/registry.rs @@ -323,6 +323,7 @@ mod tests { &mut self, spec: KeySpec, _key_bytes: &[u8], + _passphrase: Option<&[u8]>, ) -> Result { Ok(KeyMetadata { key_id: KeyId::new("mock-imported-1"), diff --git a/crates/rite-runtime/src/reporter.rs b/crates/rite-runtime/src/reporter.rs index 04a459c..02da885 100644 --- a/crates/rite-runtime/src/reporter.rs +++ b/crates/rite-runtime/src/reporter.rs @@ -27,6 +27,7 @@ use std::sync::Arc; use crossbeam_channel::{Receiver, Sender, TryRecvError}; use rite_model::{ErrorClass, ErrorRecord, Prompt, ResponseRecord, StepFact, StepId}; +use secrecy::ExposeSecret; use thiserror::Error; use crate::clock::Clock; @@ -473,17 +474,18 @@ fn response_to_record(response: &Response) -> ResponseRecord { /// 1. **Shape**: the response variant matches the prompt variant (e.g. /// a [`Prompt::Confirm`] requires a [`Response::Bool`]). A mismatch /// indicates a frontend bug. -/// 2. **Content**: the [`Prompt::Text`] validator is applied to the text, -/// and [`Prompt::Literal`] requires byte-for-byte equality with the -/// expected string. +/// 2. **Content**: the [`Prompt::Text`] and [`Prompt::Secret`] validators +/// are applied to what was typed, and [`Prompt::Literal`] requires +/// byte-for-byte equality with the expected string. A secret that fails +/// its rule is refused with the rule, never with the value. fn validate(prompt: &Prompt, response: &Response) -> Result<(), String> { match (prompt, response) { (Prompt::Confirm { .. }, Response::Bool(_)) - | (Prompt::Continue { .. }, Response::Acknowledge) - | (Prompt::Secret { .. }, Response::Secret(_)) => Ok(()), + | (Prompt::Continue { .. }, Response::Acknowledge) => Ok(()), - (Prompt::Text { validator, .. }, Response::Text(value)) => { - apply_validator(validator, value) + (Prompt::Text { validator, .. }, Response::Text(value)) => validator.check(value), + (Prompt::Secret { validator, .. }, Response::Secret(value)) => { + validator.check(value.expose_secret()) } (Prompt::Literal { expected, .. }, Response::Text(value)) => { @@ -498,25 +500,6 @@ fn validate(prompt: &Prompt, response: &Response) -> Result<(), String> { } } -fn apply_validator(spec: &rite_model::ValidatorSpec, value: &str) -> Result<(), String> { - use rite_model::ValidatorSpec; - match spec { - ValidatorSpec::NonEmpty => { - if value.trim().is_empty() { - Err("value must not be empty".to_string()) - } else { - Ok(()) - } - } - // Regex validation requires an additional dependency; not yet wired in. - ValidatorSpec::Regex(_) => Err("regex validation is not yet implemented".to_string()), - // Named predicates will land alongside specific ceremony actions - // that need them (serial numbers, hex strings, etc.). - ValidatorSpec::Predefined(name) => Err(format!("unknown validator: {name}")), - _ => Err("unknown validator variant".to_string()), - } -} - #[cfg(test)] mod tests { use crossbeam_channel::unbounded; @@ -761,6 +744,7 @@ mod tests { reporter .prompt(&Prompt::Secret { label: "PIN".to_string(), + validator: ValidatorSpec::NonEmpty, }) .expect("prompt"); @@ -871,16 +855,27 @@ mod tests { } #[test] - fn regex_validator_not_yet_implemented_returns_rejection() { - let err = super::validate( - &Prompt::Text { - label: "id".to_string(), - validator: ValidatorSpec::Regex(r"^[a-z]+$".to_string()), - }, - &Response::Text("abc".to_string()), - ) - .expect_err("regex not implemented"); - assert!(err.contains("regex")); + fn regex_validator_applies_to_text_and_secret_alike() { + let text = Prompt::Text { + label: "id".to_string(), + validator: ValidatorSpec::Regex(r"[a-z]+".to_string()), + }; + assert!(super::validate(&text, &Response::Text("abc".to_string())).is_ok()); + let err = super::validate(&text, &Response::Text("abc1".to_string())) + .expect_err("digit outside the pattern"); + assert!(err.contains("[a-z]+"), "{err}"); + + // The rule is applied to the secret, and the refusal names the rule + // and not what was typed. + let secret = Prompt::Secret { + label: "PIN".to_string(), + validator: ValidatorSpec::Regex(r"[0-9]{6}".to_string()), + }; + assert!(super::validate(&secret, &Response::Secret("123456".to_string().into())).is_ok()); + let err = super::validate(&secret, &Response::Secret("12345".to_string().into())) + .expect_err("too short"); + // No message: on failure it would print the value this checks for. + assert!(!err.contains("12345")); } #[test] diff --git a/crates/rite-runtime/src/runner.rs b/crates/rite-runtime/src/runner.rs index d413bdd..859d0bd 100644 --- a/crates/rite-runtime/src/runner.rs +++ b/crates/rite-runtime/src/runner.rs @@ -1502,6 +1502,7 @@ sections: BeforeFail::Prompt => { reporter.prompt(&Prompt::Secret { label: "Enter PIN".to_string(), + validator: rite_model::ValidatorSpec::NonEmpty, })?; } BeforeFail::Deviation => { diff --git a/crates/rite-sdk/src/backend.rs b/crates/rite-sdk/src/backend.rs index 91ce66b..28705f5 100644 --- a/crates/rite-sdk/src/backend.rs +++ b/crates/rite-sdk/src/backend.rs @@ -203,14 +203,25 @@ pub trait KeyStoreBackend: Backend { /// /// `spec.algorithm` says how to read `key_bytes`, because nothing in the /// bytes distinguishes a 32-byte secret from any other 32 bytes: a symmetric - /// algorithm takes the raw key, every other one takes PKCS#8 DER. The - /// metadata that comes back carries a check value in the first case and a - /// public key in the second, which is the identity each kind has. + /// algorithm takes the raw key, every other one takes a private key, as + /// PKCS#8 DER or as PEM. The metadata that comes back carries a check value + /// in the first case and a public key in the second, which is the identity + /// each kind has. + /// + /// `passphrase` opens a private key that is encrypted, in any encoding the + /// backend reads. Material that needs one and is given none is refused by + /// name, and so is a passphrase given for material that is not encrypted: + /// a passphrase the transcript says was used has to have been used. /// /// Only supported by backends that allow key import (software, some HSMs). /// Hardware security modules may reject it for security reasons, and a /// device that holds only one kind of key refuses the other by name. - fn import_key(&mut self, spec: KeySpec, key_bytes: &[u8]) -> Result; + fn import_key( + &mut self, + spec: KeySpec, + key_bytes: &[u8], + passphrase: Option<&[u8]>, + ) -> Result; /// Export the public key for `key_id`. fn export_public_key(&self, key_id: &KeyId) -> Result; diff --git a/crates/rite-stdlib/src/backend/mock.rs b/crates/rite-stdlib/src/backend/mock.rs index 8a7dd20..8cdef56 100644 --- a/crates/rite-stdlib/src/backend/mock.rs +++ b/crates/rite-stdlib/src/backend/mock.rs @@ -128,6 +128,7 @@ impl MockBackend { location_hint: None, }, MOCK_SLOT_PRIVATE_KEY, + None, ) .expect("the committed slot key fixture is a P-256 PKCS#8 key") .key_id; @@ -222,8 +223,13 @@ impl KeyStoreBackend for MockBackend { self.crypto.generate_key(spec) } - fn import_key(&mut self, spec: KeySpec, key_bytes: &[u8]) -> Result { - self.crypto.import_key(spec, key_bytes) + fn import_key( + &mut self, + spec: KeySpec, + key_bytes: &[u8], + passphrase: Option<&[u8]>, + ) -> Result { + self.crypto.import_key(spec, key_bytes, passphrase) } fn export_public_key(&self, key_id: &KeyId) -> Result { diff --git a/crates/rite-stdlib/src/crypto/import_key.rs b/crates/rite-stdlib/src/crypto/import_key.rs index 1bb04f0..e9e8937 100644 --- a/crates/rite-stdlib/src/crypto/import_key.rs +++ b/crates/rite-stdlib/src/crypto/import_key.rs @@ -1,11 +1,12 @@ //! `import_key` action, lift bytes the ceremony holds into a backend key. -use rite_model::{ActionType, StepFact}; +use rite_model::{ActionType, ArtifactRef, StepFact}; use rite_runtime::{ Action, ActionError, ArtifactValue, HandlerContext, Icon, Reporter, StepInfo, StepResult, compute_fingerprint, parse_params, resolve_artifact_bytes, }; use rite_sdk::{Backend, KeyAlgorithm, KeySpec}; +use secrecy::ExposeSecret; use serde_json::json; use crate::params::{ImportKeyParams, installed_key_default_policy}; @@ -17,6 +18,11 @@ use crate::params::{ImportKeyParams, installed_key_default_policy}; /// carried into the room, or a step earlier in the same run. Rite can say /// nothing about where material it did not produce came from, so the record /// names the artifact the bytes were read from and claims no more than that. +/// +/// An encrypted private key is opened with the secret a step named +/// `passphrase` in `reads:` holds, which is how a key that arrives on sealed +/// media is imported in the room where its passphrase holder stands, with no +/// decrypted copy on any disk. pub struct ImportKeyAction; impl Action for ImportKeyAction { @@ -50,6 +56,11 @@ impl Action for ImportKeyAction { }, )?; + let passphrase_ref = step.named_input("passphrase"); + let passphrase = passphrase_ref + .map(|reference| resolve_passphrase(ctx, reference)) + .transpose()?; + let label = typed .label .clone() @@ -76,15 +87,7 @@ impl Action for ImportKeyAction { )) })?; - // What an imported key may do is the ceremony's claim, exactly as at - // unwrap: nothing travels with raw material saying what it is for. - let defaults = installed_key_default_policy(Some(algorithm)); - let policy = match &typed.policy { - None => defaults, - Some(declared) => declared - .resolve_from(&defaults) - .map_err(ActionError::Failed)?, - }; + let policy = declared_policy(&typed, algorithm)?; let spec = KeySpec { algorithm, @@ -92,7 +95,7 @@ impl Action for ImportKeyAction { policy: policy.clone(), location_hint: None, }; - let metadata = keystore.import_key(spec, key_bytes)?; + let metadata = keystore.import_key(spec, key_bytes, passphrase)?; let imported_fingerprint = metadata .public_key @@ -115,7 +118,13 @@ impl Action for ImportKeyAction { reporter.fact(StepFact::BackendOperation { step: step.id.clone(), kind: "import_key".to_string(), - inputs: requested(&typed, &material_ref.display_name(), &label, &policy), + inputs: requested( + &typed, + &material_ref.display_name(), + passphrase_ref.map(ArtifactRef::display_name).as_deref(), + &label, + &policy, + ), outputs: produced( &backend_name, &backend_fingerprint, @@ -150,12 +159,83 @@ impl Action for ImportKeyAction { } } +/// What an imported key may do, which is the ceremony's claim exactly as at +/// unwrap: nothing travels with raw material saying what it is for. +fn declared_policy( + typed: &ImportKeyParams, + algorithm: KeyAlgorithm, +) -> Result { + let defaults = installed_key_default_policy(Some(algorithm)); + match &typed.policy { + None => Ok(defaults), + Some(declared) => declared + .resolve_from(&defaults) + .map_err(ActionError::Failed), + } +} + +/// The passphrase a step named, borrowed from the store. +/// +/// Only a secret artifact is accepted. A passphrase read from a material file +/// or a text artifact would be a passphrase that sat on a disk, which is what +/// reading it at the keyboard exists to avoid, and the refusal says where to +/// get one instead. +fn resolve_passphrase<'a>( + ctx: &'a HandlerContext, + reference: &ArtifactRef, +) -> Result<&'a [u8], ActionError> { + // A secret has no properties, and a reference naming one would otherwise + // be read as the whole secret, so a misspelling supplies the wrong value + // without a word. + if let Some(property) = reference.property() { + return Err(ActionError::Failed(format!( + "'{}' names a property '.{property}', and a passphrase has none", + reference.display_name() + ))); + } + let id = reference.artifact_id(); + let artifact = ctx.get_artifact(&id).ok_or_else(|| { + ActionError::Failed(format!( + "Passphrase artifact '{}' not found", + reference.display_name() + )) + })?; + match artifact { + ArtifactValue::Secret(secret) => Ok(secret.expose_secret()), + // Named by kind and never displayed: a text artifact prints its + // text, and this message becomes a fact. + other => Err(ActionError::Failed(format!( + "'{}' is {}, not a secret. A passphrase is read at the keyboard by \ + 'enter_secret', so that no copy of it is on any disk.", + reference.display_name(), + kind_of(other) + ))), + } +} + +/// What an artifact is, for a message that must not show what it holds. +fn kind_of(artifact: &ArtifactValue) -> &'static str { + match artifact { + ArtifactValue::BackendKey { .. } => "a backend key", + ArtifactValue::WrappedKey(_) => "a wrapped key", + ArtifactValue::EncryptedData(_) => "encrypted content", + ArtifactValue::PublicKey(_) => "a public key", + ArtifactValue::Bytes(_) => "bytes", + ArtifactValue::Secret(_) => "a secret", + ArtifactValue::Text(_) => "text", + ArtifactValue::Certificate(_) => "a certificate", + ArtifactValue::Shares(_) => "a set of shares", + } +} + /// What the ceremony asked this step to lift, and what it committed to first. /// /// `key_material` names the artifact the bytes were read from, which is the /// whole of what Rite knows about their origin: where that artifact was /// produced in this run a verifier can trace it, and where it was a material -/// carried into the room there is nothing to trace. +/// carried into the room there is nothing to trace. `passphrase` likewise +/// names the artifact and nothing about its value: that a secret was entered +/// is already on the step that read it. /// /// Deliberately no digest of the material. That would be a hash of a secret, /// which is the shape rite#126 removed; what the key is answers under @@ -163,11 +243,13 @@ impl Action for ImportKeyAction { fn requested( typed: &ImportKeyParams, key_material: &str, + passphrase: Option<&str>, label: &str, policy: &rite_sdk::KeyPolicy, ) -> serde_json::Value { json!({ "key_material": key_material, + "passphrase": passphrase, "algorithm": typed.algorithm, "label": label, "expect_key": typed.expect_key, diff --git a/crates/rite-stdlib/src/entry/mod.rs b/crates/rite-stdlib/src/entry/mod.rs new file mode 100644 index 0000000..0a24f0b --- /dev/null +++ b/crates/rite-stdlib/src/entry/mod.rs @@ -0,0 +1,161 @@ +//! `enter_value` and `enter_secret` actions, take a typed value into the +//! ceremony. +//! +//! One implementation under two names. The name is the claim: `enter_value` +//! makes a text artifact the transcript carries, and `enter_secret` makes a +//! secret artifact it never does. Nothing in the block decides which, so a +//! passphrase step reads as one in the YAML and in the printed script, and a +//! flag that could be left off is not what stands between a secret and the +//! transcript. + +use rite_model::params::EntryShape; +use rite_model::{ActionType, Prompt, ValidatorSpec}; +use rite_runtime::{ + Action, ActionError, ArtifactValue, HandlerContext, Icon, Reporter, Response, StepInfo, + StepResult, parse_params, +}; +use rite_sdk::Backend; +use secrecy::{ExposeSecret, SecretBox, SecretString}; + +use crate::params::EntryParams; + +/// Take a value a person types, as a text artifact and a transcript fact. +pub struct EnterValueAction; + +/// Take a secret a person types, as a secret artifact and nothing else. +pub struct EnterSecretAction; + +impl Action for EnterValueAction { + fn action_type(&self) -> ActionType { + ActionType::EnterValue + } + + fn execute( + &self, + step: &StepInfo, + _ctx: &HandlerContext, + params: &serde_json::Value, + reporter: &mut Reporter<'_>, + _backend: Option<&mut dyn Backend>, + ) -> Result { + let (label, validator) = entry(params)?; + let response = reporter.prompt(&Prompt::Text { + label, + validator: validator.clone(), + })?; + let Response::Text(value) = response else { + return Err(ActionError::Failed( + "expected a text response for the entered value".to_string(), + )); + }; + // A step with nothing to create still records what was typed: the + // answer is on the prompt fact, which is evidence on its own. + let Some(produces) = step.produces.clone() else { + return Ok(StepResult::completed(format!("Recorded '{value}'"))); + }; + reporter.log( + Icon::Checkmark, + format!("Recorded as artifact '{produces}'"), + )?; + // What was typed is on the prompt fact; the artifact is the value in + // its canonical form, which for an encoding is what it decodes to. + let artifact = match canonical(&validator, &value)? { + Some(bytes) => ArtifactValue::Bytes(bytes), + None => ArtifactValue::Text(value.clone()), + }; + Ok(StepResult::completed_with_artifact( + format!("Recorded '{value}'"), + produces, + artifact, + )) + } +} + +impl Action for EnterSecretAction { + fn action_type(&self) -> ActionType { + ActionType::EnterSecret + } + + fn execute( + &self, + step: &StepInfo, + _ctx: &HandlerContext, + params: &serde_json::Value, + reporter: &mut Reporter<'_>, + _backend: Option<&mut dyn Backend>, + ) -> Result { + // Checked before the prompt: a secret typed into a step that holds + // it nowhere would be asked for and thrown away. + let produces = step.produces.clone().ok_or_else(|| { + ActionError::Failed( + "enter_secret holds the secret as an artifact, so the step needs 'creates:'" + .to_string(), + ) + })?; + let (label, validator) = entry(params)?; + let response = reporter.prompt(&Prompt::Secret { + label, + validator: validator.clone(), + })?; + let Response::Secret(secret) = response else { + return Err(ActionError::Failed( + "expected a secret response for the entered secret".to_string(), + )); + }; + reporter.log( + Icon::Checkmark, + format!("Secret held as artifact '{produces}'"), + )?; + Ok(StepResult::completed_with_artifact( + "Secret entered", + produces, + hold(&validator, &secret)?, + )) + } +} + +/// The prompt an entry step puts to the person: its label, with the rule +/// stated after it, and the rule itself. +/// +/// The rule is stated up front so the person knows what is wanted before +/// typing, not only after a refusal. +fn entry(params: &serde_json::Value) -> Result<(String, ValidatorSpec), ActionError> { + let typed: EntryParams = parse_params(params)?; + let validator = EntryShape { + format: typed.format, + length: typed.length, + min_length: typed.min_length, + max_length: typed.max_length, + } + .validator() + .map_err(ActionError::Failed)?; + let label = match validator.hint() { + Some(hint) => format!("{} ({hint})", typed.message), + None => typed.message, + }; + Ok((label, validator)) +} + +/// The bytes a typed value stands for under its rule, when the rule is an +/// encoding; `None` when the value is its own canonical form. +/// +/// The prompt loop already refused a value that does not decode, so a +/// failure here is the rule and the check disagreeing, which is a bug. The +/// buffer is returned to be moved into an artifact, never copied. +fn canonical(validator: &ValidatorSpec, value: &str) -> Result>, ActionError> { + match validator { + ValidatorSpec::Format { format, .. } if format.is_encoding() => { + format.decode(value).map(Some).map_err(ActionError::Failed) + } + _ => Ok(None), + } +} + +/// The typed secret as an artifact, in a box that wipes on drop. +fn hold(validator: &ValidatorSpec, secret: &SecretString) -> Result { + let bytes = match canonical(validator, secret.expose_secret())? { + Some(decoded) => decoded, + None => secret.expose_secret().as_bytes().to_vec(), + }; + Ok(ArtifactValue::Secret(SecretBox::new(Box::new(bytes)))) +} diff --git a/crates/rite-stdlib/src/lib.rs b/crates/rite-stdlib/src/lib.rs index 2fe21d3..222bc5b 100644 --- a/crates/rite-stdlib/src/lib.rs +++ b/crates/rite-stdlib/src/lib.rs @@ -3,6 +3,7 @@ //! This crate provides the built-in action implementations: //! //! - **Verification**: `clock_check`, `confirm`, `check_value`, `oral_readback`, `machine_info` +//! - **Entry**: `enter_value`, `enter_secret` //! - **Attestation**: `attest` //! - **Crypto**: `generate_key`, `export_public`, `wrap_key`, `unwrap_key`, //! `sign_data`, `verify_signature` @@ -51,6 +52,7 @@ pub mod attestation; #[cfg(feature = "crypto")] pub mod crypto; pub mod entropy; +pub mod entry; #[cfg(feature = "piv")] pub mod piv; #[cfg(feature = "pki")] @@ -73,6 +75,7 @@ pub use crypto::{ SignDataAction, UnwrapKeyAction, VerifySignatureAction, WrapKeyAction, }; pub use entropy::GatherEntropyAction; +pub use entry::{EnterSecretAction, EnterValueAction}; #[cfg(feature = "yubikey")] pub use piv::YubikeyAttestSlotAction; #[cfg(feature = "piv")] @@ -111,9 +114,11 @@ pub fn register_stdlib(registry: &mut ActionRegistry) { registry.register(Arc::new(AttestAction)); } - // Human-entropy gathering has no optional dependencies, so it is always - // available rather than gated behind a feature. + // Human-entropy gathering and typed entry have no optional dependencies, + // so they are always available rather than gated behind a feature. registry.register(Arc::new(GatherEntropyAction)); + registry.register(Arc::new(EnterValueAction)); + registry.register(Arc::new(EnterSecretAction)); // The arithmetic is dependency-free and the randomness comes from // whichever backend the step names, so sharing needs no feature either. diff --git a/crates/rite-stdlib/src/params.rs b/crates/rite-stdlib/src/params.rs index 584e865..608196d 100644 --- a/crates/rite-stdlib/src/params.rs +++ b/crates/rite-stdlib/src/params.rs @@ -1,5 +1,6 @@ //! Parameter structs for action handlers. +use rite_model::params::FormatSpec; use serde::{Deserialize, Serialize}; /// Params for `clock_check` action. @@ -60,9 +61,6 @@ pub struct OralReadbackParams { /// Limit number of characters to read (for long values). #[serde(default)] pub characters: Option, - /// If true, only record pass/fail result in evidence (no value recorded). - #[serde(default)] - pub sensitive: bool, } /// Params for `check_value` action. @@ -129,6 +127,30 @@ pub struct AttestParams { pub statement: Option, } +/// Params for the `enter_value` and `enter_secret` actions. +/// +/// The shape fields are turned into one rule by +/// [`EntryShape`](rite_model::params::EntryShape), which is also what +/// `rite check` applies to them. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct EntryParams { + /// What the person is asked for, shown at the prompt. + pub message: String, + /// What kind of value it is: a format name, or `{ pattern: "..." }`. + /// Text by default. An encoding makes the artifact the decoded bytes. + #[serde(default)] + pub format: Option, + /// An exact length, in characters for text and bytes for an encoding. + #[serde(default)] + pub length: Option, + /// The fewest units accepted. + #[serde(default)] + pub min_length: Option, + /// The most units accepted. + #[serde(default)] + pub max_length: Option, +} + /// Params for `gather_entropy` action. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct GatherEntropyParams { @@ -557,6 +579,8 @@ mod schema_drift_tests { ActionType::GatherEntropy, serde_keys(GatherEntropyParams::default()), ), + (ActionType::EnterValue, serde_keys(EntryParams::default())), + (ActionType::EnterSecret, serde_keys(EntryParams::default())), #[cfg(feature = "crypto")] ( ActionType::GenerateKey, diff --git a/crates/rite-stdlib/src/piv/sign.rs b/crates/rite-stdlib/src/piv/sign.rs index 2f61907..8b316f1 100644 --- a/crates/rite-stdlib/src/piv/sign.rs +++ b/crates/rite-stdlib/src/piv/sign.rs @@ -1,6 +1,6 @@ //! `piv_sign` action: sign data with a PIV smart card on-device key. -use rite_model::{ActionType, Prompt, StepFact, StepInputs}; +use rite_model::{ActionType, Format, Prompt, StepFact, StepInputs, ValidatorSpec}; use rite_runtime::{ Action, ActionError, ArtifactValue, HandlerContext, Icon, Reporter, Response, StepInfo, StepResult, compute_fingerprint, parse_params, resolve_artifact_bytes, @@ -69,34 +69,7 @@ impl Action for PivSignAction { let backend_name = backend.name().to_string(); let backend_fingerprint = backend.fingerprint(); - // PIN verification through the PIV capability. - { - let piv = backend.as_piv_mut().ok_or_else(|| { - ActionError::Failed(format!( - "Backend '{backend_name}' does not support PIV operations" - )) - })?; - - let retries = piv.pin_retries()?; - if retries <= 1 { - reporter.log( - Icon::Warning, - format!("Only {retries} PIN attempt(s) remaining. Card will lock on failure."), - )?; - } - - let response = reporter.prompt(&Prompt::Secret { - label: "Enter PIV PIN".to_string(), - })?; - let Response::Secret(pin) = response else { - return Err(ActionError::Failed( - "expected a secret response for the PIV PIN".to_string(), - )); - }; - - piv.verify_pin(pin.expose_secret().as_bytes())?; - reporter.log(Icon::Checkmark, "PIN verified")?; - } + verify_pin(backend, &backend_name, reporter)?; // Signing through the Sign capability. let signature = { @@ -149,6 +122,57 @@ impl Action for PivSignAction { /// What `piv_sign` offers to ceremony authors. /// +/// Ask for the PIN and verify it through the PIV capability. +fn verify_pin( + backend: &mut dyn Backend, + backend_name: &str, + reporter: &mut Reporter<'_>, +) -> Result<(), ActionError> { + let piv = backend.as_piv_mut().ok_or_else(|| { + ActionError::Failed(format!( + "Backend '{backend_name}' does not support PIV operations" + )) + })?; + + let retries = piv.pin_retries()?; + if retries <= 1 { + reporter.log( + Icon::Warning, + format!("Only {retries} PIN attempt(s) remaining. Card will lock on failure."), + )?; + } + + // Six to eight bytes, which is what SP 800-73 gives a PIV PIN and what + // the card will refuse otherwise. Checked here so a slip costs a retype + // rather than one of the card's few attempts. + let response = reporter.prompt(&Prompt::Secret { + label: "Enter PIV PIN (6 to 8 characters)".to_string(), + validator: ValidatorSpec::Format { + format: Format::Text, + min_length: Some(6), + max_length: Some(8), + }, + })?; + let Response::Secret(pin) = response else { + return Err(ActionError::Failed( + "expected a secret response for the PIV PIN".to_string(), + )); + }; + + // The prompt counts characters and the card counts bytes, so a PIN + // with a character outside ASCII can pass the one and fail the other. + let length = pin.expose_secret().len(); + if !(6..=8).contains(&length) { + return Err(ActionError::Failed(format!( + "the PIN is {length} bytes and a PIV PIN is 6 to 8; \ + nothing was sent to the card" + ))); + } + piv.verify_pin(pin.expose_secret().as_bytes())?; + reporter.log(Icon::Checkmark, "PIN verified")?; + Ok(()) +} + /// The action-level allowlist, applied on top of the SDK's parsing. RSA-PSS is /// absent because PIV cards apply a raw RSA operation and the client-side PSS /// encoding is not implemented; Ed25519 and ML-DSA because no PIV card does them. diff --git a/crates/rite-stdlib/tests/actions.rs b/crates/rite-stdlib/tests/actions.rs index 7d277ec..6de0019 100644 --- a/crates/rite-stdlib/tests/actions.rs +++ b/crates/rite-stdlib/tests/actions.rs @@ -5,7 +5,9 @@ use std::collections::HashMap; -use rite_model::{ArtifactId, ArtifactRef, NamedInput, StepFact, StepId, StepInputs}; +use rite_model::{ + ArtifactId, ArtifactRef, NamedInput, Prompt, ResponseRecord, StepFact, StepId, StepInputs, +}; use rite_runtime::{ Action, ArtifactValue, ExecutionState, Response, Share, ShareSet, StepInfo, test_support::ReporterHarness, @@ -14,11 +16,11 @@ use rite_sdk::{KeyAlgorithm, KeyPolicy, KeySpec, KeyStoreBackend}; use rite_stdlib::sharing::{gf256, wire}; use rite_stdlib::{ AttestAction, CheckValueAction, ClockCheckAction, CombineSharesAction, ConfirmAction, - DecryptDataAction, EncryptDataAction, ExportPublicAction, GatherEntropyAction, ImportKeyAction, - MachineInfoAction, MockBackend, OralReadbackAction, SplitSecretAction, UnwrapKeyAction, - WrapKeyAction, + DecryptDataAction, EncryptDataAction, EnterSecretAction, EnterValueAction, ExportPublicAction, + GatherEntropyAction, ImportKeyAction, MachineInfoAction, MockBackend, OralReadbackAction, + SplitSecretAction, UnwrapKeyAction, WrapKeyAction, }; -use secrecy::ExposeSecret; +use secrecy::{ExposeSecret, SecretBox, SecretString}; fn make_state() -> ExecutionState { ExecutionState::new(HashMap::new(), HashMap::new(), HashMap::new(), false) @@ -103,6 +105,241 @@ fn gather_entropy_completes_with_a_contribution() { result.expect("a non-empty contribution is folded and the step completes"); } +// ── typed entry ───────────────────────────────────────────────────────────── + +/// A step that creates `produces` and reads nothing, as an entry step does. +fn creating_step(id: &str, produces: &str) -> StepInfo { + StepInfo::new( + StepId::new(id), + None, + None, + Some(ArtifactId::new(produces)), + None, + ) +} + +#[test] +fn enter_value_records_what_was_typed_as_a_text_artifact() { + let mut harness = ReporterHarness::new(); + harness.enqueue_response(Response::Text("SN-4471".to_string())); + let state = make_state(); + let step = creating_step("read_serial", "serial"); + + let result = { + let ctx = state.handler_context(); + let mut reporter = harness.reporter(step.id.clone()); + EnterValueAction + .execute( + &step, + &ctx, + &serde_json::json!({ "message": "Serial number on the device" }), + &mut reporter, + None, + ) + .expect("a typed value completes the step") + }; + + assert!(matches!( + produced(&result.artifacts, "serial"), + ArtifactValue::Text(value) if value == "SN-4471" + )); + // The value is evidence, so the prompt fact carries it. + assert!(harness.facts().iter().any(|fact| matches!( + fact, + StepFact::PromptAnswered { + response: ResponseRecord::Text { value }, + .. + } if value == "SN-4471" + ))); +} + +#[test] +fn enter_secret_holds_the_secret_and_records_only_that_one_was_entered() { + let mut harness = ReporterHarness::new(); + harness.enqueue_response(Response::Secret(SecretString::from("correct horse"))); + let state = make_state(); + let step = creating_step("unlock", "escrow_passphrase"); + + let result = { + let ctx = state.handler_context(); + let mut reporter = harness.reporter(step.id.clone()); + EnterSecretAction + .execute( + &step, + &ctx, + &serde_json::json!({ "message": "Passphrase for the escrow key" }), + &mut reporter, + None, + ) + .expect("a typed secret completes the step") + }; + + match produced(&result.artifacts, "escrow_passphrase") { + ArtifactValue::Secret(secret) => { + assert_eq!(secret.expose_secret(), b"correct horse"); + } + other => panic!("enter_secret must produce a Secret, got {other:?}"), + } + // The fact says a secret was entered, and nothing else about it. + let recorded = harness + .facts() + .iter() + .find_map(|fact| match fact { + StepFact::PromptAnswered { response, .. } => Some(response), + _ => None, + }) + .expect("the prompt is recorded"); + assert!(matches!(recorded, ResponseRecord::SecretRedacted {})); + let serialized = serde_json::to_string(harness.facts()).unwrap(); + assert!(!serialized.contains("correct horse")); +} + +#[test] +fn enter_secret_refuses_a_step_that_holds_the_secret_nowhere() { + let mut harness = ReporterHarness::new(); + let state = make_state(); + let step = bare_step("unlock"); + + let ctx = state.handler_context(); + let mut reporter = harness.reporter(step.id.clone()); + let error = EnterSecretAction + .execute( + &step, + &ctx, + &serde_json::json!({ "message": "Passphrase" }), + &mut reporter, + None, + ) + .expect_err("a secret with no artifact to hold it would be typed and thrown away"); + + assert!(error.to_string().contains("creates:"), "{error}"); + assert!( + harness.facts().is_empty(), + "the refusal comes before the person is asked" + ); +} + +/// The shape is stated in the label, so the person reads the rule before +/// typing. Applying it is the reporter's job, tested there. +#[test] +fn entry_states_its_shape_in_the_prompt() { + let mut harness = ReporterHarness::new(); + harness.enqueue_response(Response::Secret(SecretString::from("123456"))); + let state = make_state(); + let step = creating_step("pin", "pin"); + + { + let ctx = state.handler_context(); + let mut reporter = harness.reporter(step.id.clone()); + EnterSecretAction + .execute( + &step, + &ctx, + &serde_json::json!({ "message": "PIN", "format": "digits", "length": 6 }), + &mut reporter, + None, + ) + .expect("six digits fit the shape"); + } + + let prompt = harness + .facts() + .iter() + .find_map(|fact| match fact { + StepFact::PromptAnswered { prompt, .. } => Some(prompt), + _ => None, + }) + .expect("the prompt is recorded"); + assert!(matches!( + prompt, + Prompt::Secret { label, .. } if label == "PIN (6 digits)" + )); +} + +#[test] +fn entry_refuses_a_shape_that_describes_no_rule() { + let mut harness = ReporterHarness::new(); + let state = make_state(); + let step = creating_step("pin", "pin"); + + let ctx = state.handler_context(); + let mut reporter = harness.reporter(step.id.clone()); + let error = EnterValueAction + .execute( + &step, + &ctx, + &serde_json::json!({ "message": "PIN", "format": { "pattern": "[0-9]+" }, "length": 6 }), + &mut reporter, + None, + ) + .expect_err("a pattern beside a length is two rules"); + + assert!(error.to_string().contains("pattern"), "{error}"); +} + +/// With an encoding the string is transport: the prompt fact keeps what was +/// typed and the artifact keeps what it decodes to. +#[test] +fn enter_value_with_an_encoding_keeps_the_decoded_bytes() { + let mut harness = ReporterHarness::new(); + harness.enqueue_response(Response::Text("DE AD be ef".to_string())); + let state = make_state(); + let step = creating_step("read_kcv", "kcv"); + + let result = { + let ctx = state.handler_context(); + let mut reporter = harness.reporter(step.id.clone()); + EnterValueAction + .execute( + &step, + &ctx, + &serde_json::json!({ "message": "KCV", "format": "hex", "length": 4 }), + &mut reporter, + None, + ) + .expect("four hex bytes, grouped as typed") + }; + + assert!(matches!( + produced(&result.artifacts, "kcv"), + ArtifactValue::Bytes(bytes) if bytes == &[0xde, 0xad, 0xbe, 0xef] + )); + assert!(harness.facts().iter().any(|fact| matches!( + fact, + StepFact::PromptAnswered { + response: ResponseRecord::Text { value }, + .. + } if value == "DE AD be ef" + ))); +} + +#[test] +fn enter_secret_with_an_encoding_holds_the_decoded_bytes() { + let mut harness = ReporterHarness::new(); + harness.enqueue_response(Response::Secret(SecretString::from("00".repeat(32)))); + let state = make_state(); + let step = creating_step("component", "kek_component"); + + let result = { + let ctx = state.handler_context(); + let mut reporter = harness.reporter(step.id.clone()); + EnterSecretAction + .execute( + &step, + &ctx, + &serde_json::json!({ "message": "Component", "format": "hex", "length": 32 }), + &mut reporter, + None, + ) + .expect("a 32-byte component as hex") + }; + + match produced(&result.artifacts, "kek_component") { + ArtifactValue::Secret(secret) => assert_eq!(secret.expose_secret(), &[0u8; 32]), + other => panic!("enter_secret must produce a Secret, got {other:?}"), + } +} + #[test] fn oral_readback_completes_on_confirmation() { let mut harness = ReporterHarness::new(); @@ -634,50 +871,234 @@ fn import_key_accepts_a_pem_private_key() { /// places: PKCS#8 in the preamble, the traditional format in RFC 1421 headers /// inside an ordinary one. Missing the second would leave OpenSSL to ask for /// the passphrase itself, on the terminal, in the middle of a ceremony. -#[test] -fn import_key_refuses_an_encrypted_pem_by_name() { +fn encrypted_pems(passphrase: &[u8]) -> Vec> { let rsa = openssl::rsa::Rsa::generate(2048).unwrap(); let key = openssl::pkey::PKey::from_rsa(rsa).unwrap(); let cipher = openssl::symm::Cipher::aes_256_cbc(); let pkcs8 = key - .private_key_to_pem_pkcs8_passphrase(cipher, b"secret") + .private_key_to_pem_pkcs8_passphrase(cipher, passphrase) .unwrap(); let traditional = key .rsa() .unwrap() - .private_key_to_pem_passphrase(cipher, b"secret") + .private_key_to_pem_passphrase(cipher, passphrase) .unwrap(); assert!( traditional.windows(10).any(|w| w == b"Proc-Type:"), "the traditional form marks the body, not the preamble" ); + vec![pkcs8, traditional] +} + +fn secret(text: &str) -> ArtifactValue { + ArtifactValue::Secret(SecretBox::new(Box::new(text.as_bytes().to_vec()))) +} + +/// An import step reading `escrowed` as the material and, when given, an +/// artifact as the passphrase. +fn passphrase_import(passphrase: Option<&str>) -> StepInfo { + let mut reads = vec![("key_material", ArtifactId::new("escrowed"))]; + if let Some(id) = passphrase { + reads.push(("passphrase", ArtifactId::new(id))); + } + step_named("import", "restored", &reads) +} - for pem in [pkcs8, traditional] { - let mut backend = MockBackend::new("mock".to_string(), "seed".to_string()); - let material_id = ArtifactId::new("escrowed"); - let state = make_state().with_material(material_id.clone(), ArtifactValue::Bytes(pem)); - let step = import_step("import", "restored", material_id); +fn run_import( + state: &ExecutionState, + step: &StepInfo, + harness: &mut ReporterHarness, +) -> Result { + let mut backend = MockBackend::new("mock".to_string(), "seed".to_string()); + let ctx = state.handler_context(); + let mut reporter = harness.reporter(step.id.clone()); + ImportKeyAction.execute( + step, + &ctx, + &serde_json::json!({ "algorithm": "RSA-2048" }), + &mut reporter, + Some(&mut backend), + ) +} +#[test] +fn import_key_refuses_an_encrypted_pem_by_name() { + for pem in encrypted_pems(b"secret") { + let state = + make_state().with_material(ArtifactId::new("escrowed"), ArtifactValue::Bytes(pem)); + let step = passphrase_import(None); let mut harness = ReporterHarness::new(); - let ctx = state.handler_context(); - let mut reporter = harness.reporter(step.id.clone()); - let error = ImportKeyAction - .execute( - &step, - &ctx, - &serde_json::json!({ "algorithm": "RSA-2048" }), - &mut reporter, - Some(&mut backend), - ) - .expect_err("a ceremony carries no passphrase"); + let error = run_import(&state, &step, &mut harness) + .expect_err("the ceremony supplies no passphrase"); + + let message = error.to_string(); assert!( - error.to_string().contains("encrypted PEM"), - "the refusal should name the encoding, got: {error}" + message.contains("encrypted PEM") && message.contains("enter_secret"), + "the refusal should name the encoding and the way out, got: {message}" ); } } +#[test] +fn import_key_opens_an_encrypted_pem_with_the_secret_a_step_read() { + for pem in encrypted_pems(b"correct horse") { + let state = make_state() + .with_material(ArtifactId::new("escrowed"), ArtifactValue::Bytes(pem)) + .with_material( + ArtifactId::new("escrow_passphrase"), + secret("correct horse"), + ); + let step = passphrase_import(Some("escrow_passphrase")); + let mut harness = ReporterHarness::new(); + + let imported = + run_import(&state, &step, &mut harness).expect("the passphrase opens the key"); + + assert!(matches!( + produced(&imported.artifacts, "restored"), + ArtifactValue::BackendKey { .. } + )); + // The record names the artifact the passphrase came from and carries + // nothing of its value. + let inputs = harness + .facts() + .iter() + .find_map(|fact| match fact { + StepFact::BackendOperation { inputs, .. } => Some(inputs), + _ => None, + }) + .expect("the import is recorded"); + assert_eq!(inputs["passphrase"], "escrow_passphrase"); + assert!( + !serde_json::to_string(harness.facts()) + .unwrap() + .contains("correct horse") + ); + } +} + +#[test] +fn import_key_refuses_the_wrong_passphrase_without_naming_it() { + let pem = encrypted_pems(b"correct horse").swap_remove(0); + let state = make_state() + .with_material(ArtifactId::new("escrowed"), ArtifactValue::Bytes(pem)) + .with_material(ArtifactId::new("escrow_passphrase"), secret("wrong horse")); + let step = passphrase_import(Some("escrow_passphrase")); + let mut harness = ReporterHarness::new(); + + let error = run_import(&state, &step, &mut harness).expect_err("the passphrase is wrong"); + + let message = error.to_string(); + assert!(message.contains("does not open"), "{message}"); + assert!(!message.contains("wrong horse")); +} + +#[test] +fn import_key_refuses_a_passphrase_that_was_never_needed() { + let rsa = openssl::rsa::Rsa::generate(2048).unwrap(); + let pem = openssl::pkey::PKey::from_rsa(rsa) + .unwrap() + .private_key_to_pem_pkcs8() + .unwrap(); + let state = make_state() + .with_material(ArtifactId::new("escrowed"), ArtifactValue::Bytes(pem)) + .with_material( + ArtifactId::new("escrow_passphrase"), + secret("correct horse"), + ); + let step = passphrase_import(Some("escrow_passphrase")); + let mut harness = ReporterHarness::new(); + + let error = run_import(&state, &step, &mut harness) + .expect_err("a passphrase the record says was used has to have been used"); + + assert!(error.to_string().contains("not encrypted"), "{error}"); +} + +/// A passphrase that sat on a disk is what reading one at the keyboard +/// exists to avoid, so only a secret artifact is accepted. +#[test] +fn import_key_refuses_a_passphrase_that_is_not_a_secret() { + let pem = encrypted_pems(b"correct horse").swap_remove(0); + let state = make_state() + .with_material(ArtifactId::new("escrowed"), ArtifactValue::Bytes(pem)) + .with_material( + ArtifactId::new("escrow_passphrase"), + ArtifactValue::Bytes(b"correct horse".to_vec()), + ); + let step = passphrase_import(Some("escrow_passphrase")); + let mut harness = ReporterHarness::new(); + + let error = run_import(&state, &step, &mut harness).expect_err("bytes are not a secret"); + + let message = error.to_string(); + assert!(message.contains("enter_secret"), "{message}"); + assert!( + harness.facts().is_empty(), + "the refusal comes before anything is recorded" + ); + + // A text artifact prints its text, and this message becomes a fact, so + // the refusal names the kind and nothing more. + let state = make_state() + .with_material( + ArtifactId::new("escrowed"), + ArtifactValue::Bytes(Vec::new()), + ) + .with_material( + ArtifactId::new("escrow_passphrase"), + ArtifactValue::Text("correct horse".to_string()), + ); + let error = run_import(&state, &step, &mut harness).expect_err("text is not a secret"); + let message = error.to_string(); + assert!(message.contains("is text"), "{message}"); + assert!(!message.contains("correct horse")); +} + +/// A secret has no properties, so a reference naming one is a slip that +/// would otherwise supply the whole secret without a word. +#[test] +fn import_key_refuses_a_passphrase_reference_with_a_property() { + let pem = encrypted_pems(b"correct horse").swap_remove(0); + let state = make_state() + .with_material(ArtifactId::new("escrowed"), ArtifactValue::Bytes(pem)) + .with_material( + ArtifactId::new("escrow_passphrase"), + secret("correct horse"), + ); + let reads = vec![ + ( + "key_material".to_string(), + NamedInput::One(ArtifactRef::Produced { + id: ArtifactId::new("escrowed"), + property: None, + }), + ), + ( + "passphrase".to_string(), + NamedInput::One(ArtifactRef::Produced { + id: ArtifactId::new("escrow_passphrase"), + property: Some("value".to_string()), + }), + ), + ] + .into_iter() + .collect(); + let step = StepInfo::new( + StepId::new("import"), + None, + Some("mock".to_string()), + Some(ArtifactId::new("restored")), + Some(StepInputs::Named(reads)), + ); + let mut harness = ReporterHarness::new(); + + let error = run_import(&state, &step, &mut harness).expect_err("a property on a secret"); + + assert!(error.to_string().contains("'.value'"), "{error}"); +} + #[test] fn import_key_refuses_material_that_is_not_the_declared_algorithm() { let mut backend = MockBackend::new("mock".to_string(), "seed".to_string()); diff --git a/crates/rite-tui/src/view.rs b/crates/rite-tui/src/view.rs index 15019ac..933ac33 100644 --- a/crates/rite-tui/src/view.rs +++ b/crates/rite-tui/src/view.rs @@ -329,7 +329,7 @@ fn render_prompt(pending: &crate::model::PendingPrompt, frame: &mut Frame<'_>, a Line::from(label.clone()), Line::from(format!("> {}", pending.input)), ], - Prompt::Secret { label } => vec![ + Prompt::Secret { label, .. } => vec![ Line::from(label.clone()), Line::from(format!("> {}", "•".repeat(pending.input.len()))), ], diff --git a/crates/rite-yubikey/src/backend.rs b/crates/rite-yubikey/src/backend.rs index 30a4e0d..561dafb 100644 --- a/crates/rite-yubikey/src/backend.rs +++ b/crates/rite-yubikey/src/backend.rs @@ -102,6 +102,7 @@ impl KeyStoreBackend for YubikeyDevice { &mut self, _spec: KeySpec, _key_bytes: &[u8], + _passphrase: Option<&[u8]>, ) -> Result { Err(BackendError::UnsupportedOperation( "YubiKey key import requires the 'untested' yubikey feature".to_string(), diff --git a/crates/rite/src/console.rs b/crates/rite/src/console.rs index a1cf88a..df9f20f 100644 --- a/crates/rite/src/console.rs +++ b/crates/rite/src/console.rs @@ -192,7 +192,7 @@ fn read_response( let line = read_line(stdin)?; Ok(Response::Text(line.trim().to_string())) } - Prompt::Secret { label } => { + Prompt::Secret { label, .. } => { let value = rpassword::prompt_password(format!("{label}: "))?; Ok(Response::Secret(SecretString::from(value))) } diff --git a/crates/rite/src/container_checks.rs b/crates/rite/src/container_checks.rs index c3dad79..f061bd4 100644 --- a/crates/rite/src/container_checks.rs +++ b/crates/rite/src/container_checks.rs @@ -801,6 +801,7 @@ mod tests { location_hint: None, }, &[7u8; 32], + None, ) .unwrap(); let target = backend diff --git a/crates/rite/src/headless.rs b/crates/rite/src/headless.rs index 039f3c4..bf68b69 100644 --- a/crates/rite/src/headless.rs +++ b/crates/rite/src/headless.rs @@ -80,27 +80,50 @@ fn default_response(prompt: &Prompt) -> io::Result { Prompt::Confirm { default, .. } => Ok(Response::Bool(default.unwrap_or(true))), Prompt::Continue { .. } => Ok(Response::Acknowledge), Prompt::Literal { expected, .. } => Ok(Response::Text(expected.clone())), - // Free-form text: a fixed placeholder satisfies an unconstrained - // (`NonEmpty`) prompt. A validated prompt (regex, named predicate) can't - // be answered generically, so it still fails fast until the step carries - // an explicit value. - Prompt::Text { label, validator } => match validator { - ValidatorSpec::NonEmpty => Ok(Response::Text(PLACEHOLDER_TEXT.to_string())), - _ => Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!( - "headless driver cannot answer the validated text prompt: '{label}'. \ - Use --frontend=console for an interactive run." - ), - )), - }, - Prompt::Secret { .. } => Ok(Response::Secret(SecretString::from(PLACEHOLDER_SECRET))), + // Free-form text: a placeholder that satisfies the prompt's rule + // where one can be built. A pattern can't be answered generically, so + // it fails fast rather than being refused and asked again without end. + Prompt::Text { label, validator } => placeholder(validator, PLACEHOLDER_TEXT) + .map(Response::Text) + .ok_or_else(|| cannot_answer("text", label)), + Prompt::Secret { label, validator } => placeholder(validator, PLACEHOLDER_SECRET) + .map(|value| Response::Secret(SecretString::from(value))) + .ok_or_else(|| cannot_answer("secret", label)), _ => Err(io::Error::other(format!( "headless driver does not know how to handle prompt: {prompt:?}" ))), } } +/// A fixed stand-in that satisfies the rule, if one can be built from it. +/// +/// A format is answered with the format's own placeholder at its shortest +/// length, so a dry run walks through a PIN prompt the way it walks through +/// any other. What is typed there is never a real secret, so the value is +/// chosen to be obviously not one. +fn placeholder(validator: &ValidatorSpec, unconstrained: &str) -> Option { + match validator { + ValidatorSpec::NonEmpty => Some(unconstrained.to_string()), + ValidatorSpec::Format { + format, + min_length, + max_length, + } => format.placeholder(min_length.or(*max_length).unwrap_or(1).max(1)), + // A pattern and a named predicate, and whatever the model adds next. + _ => None, + } +} + +fn cannot_answer(kind: &str, label: &str) -> io::Error { + io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "headless driver cannot answer the validated {kind} prompt: '{label}'. \ + Use --frontend=console for an interactive run." + ), + ) +} + fn render_fact(out: &mut W, fact: &StepFact) -> io::Result<()> { match fact { StepFact::CeremonyStarted { name, .. } => writeln!(out, "[ceremony] {name}"), @@ -130,6 +153,7 @@ fn render_signal(out: &mut W, signal: &UiSignal) -> io::Result<()> { mod tests { use crossbeam_channel::unbounded; use rite_model::StepId; + use secrecy::ExposeSecret; use super::*; use rite_runtime::PromptId; @@ -201,11 +225,60 @@ mod tests { fn secret_prompt_gets_placeholder() { let resp = default_response(&Prompt::Secret { label: "pin".to_string(), + validator: rite_model::ValidatorSpec::NonEmpty, }) .expect("response"); assert!(matches!(resp, Response::Secret(_))); } + #[test] + fn formatted_secret_prompt_gets_a_placeholder_of_that_format() { + for (format, min, max) in [ + (rite_model::Format::Digits, Some(6), Some(8)), + (rite_model::Format::Base64, Some(32), None), + (rite_model::Format::Hex, None, None), + ] { + let validator = rite_model::ValidatorSpec::Format { + format, + min_length: min, + max_length: max, + }; + let resp = default_response(&Prompt::Secret { + label: "pin".to_string(), + validator: validator.clone(), + }) + .expect("response"); + let Response::Secret(value) = resp else { + panic!("expected a secret"); + }; + assert!(validator.check(value.expose_secret()).is_ok(), "{format:?}"); + } + } + + #[test] + fn oversized_secret_prompt_fails_fast() { + let err = default_response(&Prompt::Secret { + label: "blob".to_string(), + validator: rite_model::ValidatorSpec::Format { + format: rite_model::Format::Text, + min_length: Some(rite_model::PLACEHOLDER_LIMIT + 1), + max_length: None, + }, + }) + .expect_err("a stand-in that size is not built"); + assert_eq!(err.kind(), io::ErrorKind::InvalidInput); + } + + #[test] + fn patterned_secret_prompt_fails_fast() { + let err = default_response(&Prompt::Secret { + label: "pin".to_string(), + validator: rite_model::ValidatorSpec::Regex("[0-9]{6}".to_string()), + }) + .expect_err("a placeholder cannot satisfy a pattern"); + assert_eq!(err.kind(), io::ErrorKind::InvalidInput); + } + #[test] fn run_replies_to_each_prompt_and_completes() { let (cmd_tx, cmd_rx) = unbounded::(); diff --git a/docs/key-wrapping.md b/docs/key-wrapping.md index 7cee3a0..d713688 100644 --- a/docs/key-wrapping.md +++ b/docs/key-wrapping.md @@ -200,7 +200,10 @@ key stored under a name it does not answer to fails the step instead. A private key is read as PEM or DER, whichever it is, since PEM announces itself in its first line and is what `openssl` writes by default. An encrypted -PEM is refused by name, because a ceremony carries no passphrase to open one. +key, in either PEM encoding or as PKCS#8 DER, is opened with the secret a step +named `passphrase` in `reads:` holds, read at the keyboard by `enter_secret`; +see [typed-entry.md](typed-entry.md). Without one it is refused by name, and +so is a passphrase given for a key that is not encrypted. `expect_key` and `policy` work as they do at unwrap, and `expect_key` matters more here. It is the only evidence the ceremony has about material it did not diff --git a/docs/typed-entry.md b/docs/typed-entry.md new file mode 100644 index 0000000..25e068f --- /dev/null +++ b/docs/typed-entry.md @@ -0,0 +1,150 @@ +# Typing a value into a ceremony + +`enter_value` and `enter_secret` take something a person types and make it +an artifact. The first is for a value the ceremony records: a serial number +read off a device, an address shown on a screen. The second is for a value it +must not: a passphrase, a PIN. + +```yaml +read_the_serial: + action: enter_value + role: operator + with: + message: "Serial number printed on the token" + format: alphanumeric + length: 12 + creates: token_serial + +unlock_the_escrow_key: + action: enter_secret + role: custodian + with: + message: "Passphrase for the escrow key" + creates: escrow_passphrase +``` + +The two are one implementation under two names, and the name is the claim. +A reviewer reading the definition sees which steps take a secret without +opening each block, `rite script` prints the step as one, and there is no flag +whose absence would put a passphrase in the transcript. + +## What each one makes + +`enter_value` makes an artifact a later step reads as it reads any other: +`${artifact.token_serial}` in a message, in a `check_value` beside a +parameter, or as a `reads:` input. The transcript carries the value on the +prompt fact, since typing it is evidence. A step with no `creates:` still +records what was typed. + +`enter_secret` makes a secret artifact. Echo is off at the prompt, the bytes +are held wiped in memory and dropped with the run, and the transcript records +that a secret was entered at this step and nothing derived from it, not a +digest either: a hash of a low-entropy secret is a guessing oracle for anyone +holding the transcript. The step needs `creates:`, because a secret typed into +a step that holds it nowhere would be asked for and thrown away. + +A secret reaches a later step through `reads:`, not through `with:`. A +`with:` value is evaluated into the step's parameters, which is a copy nothing +wipes and which a step may record; a `reads:` input is borrowed from the store +by name. `import_key` reads one as `passphrase`, which is how a private key +that arrives encrypted on sealed media is imported in the room where its +passphrase holder stands, with no decrypted copy on any disk: + +```yaml +install_the_escrow_key: + action: import_key + backend: openssl + reads: + key_material: ${artifact.escrow_key} + passphrase: ${artifact.escrow_passphrase} + with: + algorithm: RSA-4096 + creates: escrow_keypair +``` + +The import records the name of the passphrase artifact and nothing about its +value. It refuses a passphrase read from anything but a secret artifact, since +a passphrase that sat in a material file is what typing one exists to avoid, +and it refuses a passphrase given for a key that turns out not to be +encrypted: the record says one was supplied, and that has to mean it was used. + +As with opened content, what happens next is the author's call, made in the +definition. Declaring a secret artifact under `output:` writes it to the run +directory; an expression exposes what the author asks of it. Rite does not +second-guess either. + +## Saying what kind of value it is + +Both actions take `format:` and a length, so a slip is refused at the keyboard +rather than found later, and the rule is stated in the prompt before typing. + +| `format` | The value is | The artifact is | +| --- | --- | --- | +| `text` (default) | anything typed | the text | +| `digits` | `0` to `9` | the text | +| `alphanumeric` | ASCII letters and digits | the text | +| `hex` | bytes, two digits per byte, either case | the decoded bytes | +| `base64` | bytes, standard base64 with padding | the decoded bytes | +| `{ pattern: "..." }` | whatever the regular expression accepts, in full | the text | + +Each format has one canonical representation, and that is what the step +keeps. For text formats it is the text as typed. For an encoding it is the +bytes: the string was transport, and its grouping and case are dropped on the +way in, so a key component typed as `DE AD BE EF` and one typed as `deadbeef` +make the same artifact. What was typed is still on the prompt fact of an +`enter_value` step, since that is the evidence; the artifact is what the +ceremony uses. A pattern is anchored at both ends, so `[0-9]+` accepts `1234` +and not `abc1234`. + +`length`, or `min_length` and `max_length`, bound the value in the format's +own unit: characters for text, bytes for an encoding. `format: hex` with +`length: 32` asks for a 32-byte key, however it is grouped. + +```yaml +enter_the_component: + action: enter_secret + role: custodian + with: + message: "Your component of the transport key" + format: hex + length: 32 + creates: kek_component +``` + +`length` and the bounds are exclusive, and a pattern takes no length, because +a value described twice is a rule the author would have to guess the +combination of. `rite check` reports either, a format outside the vocabulary, +and a pattern that does not compile. + +Nothing given asks only for a non-empty value. A refusal at the prompt names +the rule and never what was typed, so a secret that missed its shape is not +echoed back by the message that refused it. + +The rule is part of the prompt, so it is recorded with it: the transcript says +a six-digit secret was entered, which is what the definition already said. + +Encodings with a checksum and a wordlist, `bech32m` and `bip39`, are not in +the vocabulary yet. They follow the same rule when they land: one canonical +form each, which for a BIP-39 phrase is the normalized words rather than the +entropy, since that is what a wallet takes. + +## Dry runs + +A dry run has no person at the keyboard. The headless driver answers an +unconstrained prompt with a fixed placeholder and a formatted one with a +placeholder of that format at its shortest length, so a rehearsal walks +through a PIN or a passphrase step as it walks through any other. A pattern +cannot be answered generically, and a step carrying one fails fast in a dry +run rather than being refused and asked again without end. + +The placeholder is never the real passphrase, so an `import_key` that needs +one fails in a dry run. + +## A wrong value found later + +A rule catches a slip in shape, not in substance. A passphrase that is the +wrong passphrase passes `enter_secret` and fails `import_key` three steps +later, and a retry of the import re-reads the same artifact. Today the way +out is to abort and start again. Re-entering the value from the failed step +is a change to the execution model, recorded as a design question rather +than done here. diff --git a/examples/showcase/README.md b/examples/showcase/README.md index 09664b1..dc9fa54 100644 --- a/examples/showcase/README.md +++ b/examples/showcase/README.md @@ -58,12 +58,16 @@ certificate carrying one, in DER or PEM. Adding a `backend` chooses who runs the check, for a deployment that requires it inside a validated boundary, and does not change what the step accepts. -### `import_key.rite.yaml` — Importing a Transport Key +### `import_key.rite.yaml` — Importing Keys the Ceremony Holds -Takes a key-encryption key that was produced somewhere else, installs it with -`import_key`, and uses it to wrap a key generated in the room. The component -arrives as a material, thirty-two raw bytes on the media a custodian carried -in, which is the case the payments world calls key component entry. +Takes two keys that were produced somewhere else and installs them with +`import_key`. The first is a key-encryption key that arrives as a material, +thirty-two raw bytes on the media a custodian carried in, which is the case +the payments world calls key component entry; it then wraps a key generated in +the room. The second is an escrow keypair as an encrypted PEM, opened with a +passphrase its holder types at an `enter_secret` step and names in `reads:`, +so no decrypted copy of the key touches a disk. An `enter_value` step records +the media serial on the way. `algorithm` is required, because raw material says nothing about itself and a secret's bytes look like any others of the same length. `expect_key` is @@ -71,6 +75,11 @@ optional and given here, checked against what the backend computed after the import, so a component swapped on the way in fails the step rather than becoming a key the ceremony trusts. +The escrow fixture is encrypted under the passphrase `placeholder-secret`, +which is what the headless driver types for a secret it cannot know, so the +ceremony completes in a dry run with no setup. Type it yourself in a real run. +See `docs/typed-entry.md`. + `rite verify` reports the wrap as `addressed to a key imported into this ceremony`, which is a weaker claim than the one a generated key earns and is stated rather than left out. See `docs/key-wrapping.md`. diff --git a/examples/showcase/import_key.rite.yaml b/examples/showcase/import_key.rite.yaml index 2a1386c..f13c568 100644 --- a/examples/showcase/import_key.rite.yaml +++ b/examples/showcase/import_key.rite.yaml @@ -1,18 +1,21 @@ version: "0.3" -name: "Importing a Transport Key" +name: "Importing Keys the Ceremony Holds" description: | - Take a key-encryption key that was produced somewhere else, install it in a - backend, and use it to wrap a key generated here. + Take keys that were produced somewhere else and install them in a backend: + a key-encryption key that arrives as raw bytes, and an escrow keypair that + arrives as a PEM file protected by a passphrase its holder types in the + room. - Demonstrates import_key. Everything else in Rite makes a key: generate_key - produces one, unwrap_key recovers one from a blob it can decrypt. import_key - is the third way, and the plainest: it lifts bytes the ceremony already holds - into a key of a named algorithm. + Demonstrates import_key, enter_secret and enter_value. Everything else in + Rite makes a key: generate_key produces one, unwrap_key recovers one from a + blob it can decrypt. import_key is the third way, and the plainest: it + lifts bytes the ceremony already holds into a key of a named algorithm. - The bytes can come from anywhere a byte artifact can. Here they arrive as a - material, a component a custodian carried into the room, which is the case - the payments world calls key component entry. They could equally be the - output of an earlier step in the same run. + The bytes can come from anywhere a byte artifact can. In the first section + they arrive as a material, a component a custodian carried into the room, + which is the case the payments world calls key component entry. In the + second they are a private key encrypted under a passphrase, opened by a + secret a step read at the keyboard rather than by a file on a disk. backends: openssl: @@ -24,11 +27,22 @@ materials: path: "test_keys/transport_kek_component.bin" title: "Transport KEK component" description: "Thirty-two raw bytes on the media the custodian brought." + escrow_key: + type: digital + path: "test_keys/escrow_private.pem" + title: "Escrow private key" + description: | + An RSA-2048 private key as encrypted PKCS#8 PEM, the way openssl writes + one with a passphrase. For this showcase the passphrase is + "placeholder-secret", which is also what a dry run types. roles: crypto_officer: name: "Crypto Officer" person: "Alice Rivera" + escrow_holder: + name: "Escrow Holder" + person: "Ben Okafor" output: wrapped_working_key: @@ -93,3 +107,59 @@ sections: This is the same step a ceremony would write against a KEK it had generated itself, which is the point: what import_key produces is not a second-class artifact. + + recover: + name: "Install the Escrow Key from Protected Media" + role: ${role.escrow_holder} + steps: + record_the_media_serial: + action: enter_value + with: + message: "Serial number printed on the escrow media" + format: alphanumeric + min_length: 6 + max_length: 16 + creates: escrow_media_serial + description: | + enter_value takes a value a person types and records it: the serial + becomes a text artifact and the transcript carries what was typed, + since typing it is the evidence. format: and the length bounds say + what shape the value has, so a slip is refused at the keyboard with + the rule, not found at the end. + + unlock_the_escrow_key: + action: enter_secret + with: + message: "Passphrase for the escrow key" + creates: escrow_passphrase + description: | + enter_secret is enter_value for a value the transcript must not + carry. Echo is off, the artifact is wiped from memory when the run + ends, and the transcript records that a secret was entered here and + nothing derived from it. The holder types it in the room; no + decrypted copy of the key ever sits on a disk. + + For this showcase the passphrase is "placeholder-secret". + + install_the_escrow_key: + action: import_key + backend: openssl + reads: + key_material: ${artifact.escrow_key} + passphrase: ${artifact.escrow_passphrase} + with: + algorithm: RSA-2048 + label: escrow-key + expect_key: "sha256:52039f14c6659f4c7e6d49abd5cf6247fba4a0de4d5255a24d28ffc3fae586c0" + creates: escrow_keypair + description: | + The passphrase is named in reads:, beside the material, and never + in with:. A reads: input is borrowed from the store by name; a + with: value is copied into the step's parameters, which a record + may carry. Only a secret artifact is accepted here, so a passphrase + that sat in a file is refused by name. + + expect_key: is the fingerprint of the public half, read from the + paperwork that travelled with the media. The wrong file, or a file + that was swapped, fails this step rather than becoming the escrow + key the ceremony trusts. diff --git a/examples/showcase/test_keys/escrow_private.pem b/examples/showcase/test_keys/escrow_private.pem new file mode 100644 index 0000000..3734d57 --- /dev/null +++ b/examples/showcase/test_keys/escrow_private.pem @@ -0,0 +1,30 @@ +-----BEGIN ENCRYPTED PRIVATE KEY----- +MIIFNTBfBgkqhkiG9w0BBQ0wUjAxBgkqhkiG9w0BBQwwJAQQuzJ6dxbnOiqVw8tK +TAEuMgICCAAwDAYIKoZIhvcNAgkFADAdBglghkgBZQMEASoEEBMrOpMrolkEI16i +PWQRZzoEggTQpchYksQMnQa0Ywfi7w4gXDHCBkqKRkyyraaI/C6A7Q98rixIzZiP +BRSuV/JGdfaIXnwPXiAwp6NdRDktGeuocvNjBWrh4EAOBNbdJ9OLyAG/qhwIQAOr +99UjVbbNM0x1mkTOfW50o2OIbpQ3fMTRuYnRTLR7MwTRCpXwNllTsgSDBrHYBofP +3MxaUqdPT8ISbm611LuXgDwdSEu2KreHeJjVnrOYBKZbS6wDZoi1GhO/9rf1Hh8w +JQ6f/vI81ANHqdCs0Sf+qMYr2k0gvvdoowY+txyihmQtGWTH/sfIWG6gVwpF3SM6 +dwD1QJ4ZQUR2PMu5k4HcFVjhKFYtd44m/Ml8mmMd3s759mGXcKoojz5DXMZEcyEV +oa2sMfw35ArY2gsvZYGd/7TzgmAVD1REM8DBj/yu+VGd/L2eNKNwz70Qey0CyEAn +lhVLSfrs9M7Lb2wAbCZOn3OHBtLavSAws7q598XCHcRu/B+vW97MrJrmrTbe3iw1 +bHS4SufdeZ2cPk5mc1NtxjNEX9icZvG0YirpkjYa06htMGyeiw4nBGXygIBrp4bz +u2L3Wf5kaE3QqqSZbyOMbq8HOsAsf8Q+OXCL6gSeJj29i+UrbWb074gPiyMTNW1o +wjSCxQsBWvK5nAn8NNncG/kPSJ6ttHUqOZGfnMlyVHde78GQZtKuEymKlNxUzjD+ +vsb3Hp8lyRHK5Dvnr1eXON9EsOi2LiseH/q9gwDneuQoeVBcZZ4WGMIlgt2gNaxg +r7D+F92jtrTnwc9+gf3dOW7aVcqXgqqq3ey/fq+ReW72xk1WoPGvYr3ssD3DEyjo +usSKstwYOz6aw5cRRSP9ceVqzXvinK2U0cUi0MhvGu5f2uBKMfLPMd8ZLviox21t +mpFiKq4NoMQB7ItdkXy4659DEZwyoiEt4hCwsaZRw1vWgcjdnsQe38ofVpQnio5H +MwUvBaFRvooD98BT15nfirnR9AK+fR6ar+8cgJc6e0sznXE4yeZAbX8VYwypVVor +dnDNbhTtO51k2Fst1HAQsaHDMq0I/nLVLqEidVTUX65nPHvYrQwBPMCKXd88iUcY +/gsfTIXRN7I7UdBDkGHofG++ayPQ3Y7oZair51ddF8fyBjTUvo4BkFJOW453ouCH +uYa36KSM8D3pVIK1wkYnvkW8pqIE5JlKc/rBHJaSndrQ6KMqIfNcSwtNgxTJPQXK +8ycAm00Yt8VoPdTmDXnVTghXsThjxqvnXivaM9Jq568o/zyPh4MMXlqiA4c1lx1/ +snDQLWcMl+j1B3s0ccDv7VaVAcL2mdZFwFNHLJpZWCmfPtqsLekvIY89ijuTDXcA +yWNbAM0Y1z03/Cc18S8ubMxN/BTNfCb4Y014KOhZqd7wKXZw6+Z5gHvREpbeoa2M +LekhWloj2jNjrH/gn/zu7s96VXDDtU8cCyv+ldbqmkZ1ZaDnaqcm7VibB6zZ1je5 +doBOiSuK0UVsI7f10GUthABEe4pvU4p/gDeQI3ZpnDluk2PrYVAjfXWMd/VYlLwV +6SbQjk6FI58ctVtNssY2vtSOfLate2UIfcmhxhAmkTTOL86XtfTHjThkU51hadT9 +R7aMnCn7yKyFZwqa5POc77WHVuY3FmEi34NLU2FV/UHPxQkuR1gH+zg= +-----END ENCRYPTED PRIVATE KEY-----