From 3d697360e29d02beb2f7fde53b1781c9c5f82159 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lomig=20Me=CC=81gard?= Date: Tue, 22 Sep 2026 21:14:09 +0200 Subject: [PATCH 1/2] feat!: read a value or a secret at the keyboard, and open an encrypted key with one `enter_value` and `enter_secret` are one implementation under two names, and the name is the claim. The first makes an artifact the transcript carries: a serial number read off a device, an address shown on a screen. The second makes a secret artifact it never does: echo is off, the artifact is wiped from memory when the run ends, and the transcript records that a secret was entered at this step and nothing derived from it. 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. Both say what kind of value they ask for with one field, `format:`: text, digits, alphanumeric, hex, base64, or `{ pattern: "..." }`, the same scalar-or-map shape `retry:` takes. Each format has one canonical representation and that is the artifact. For a text format it is the text as typed. For an encoding it is the decoded bytes: the string was transport, so grouping and case are dropped on the way in and a component typed as `DE AD BE EF` makes the same artifact as `deadbeef`. What was typed stays on the prompt fact of an `enter_value` step, which is the evidence. A length counts the format's own unit, characters for text and bytes for an encoding. `ValidatorSpec` gains `Format` for the rule, its `Regex` variant is implemented rather than refused, and `Prompt::Secret` carries a validator as `Prompt::Text` does. The rule is built in one place, `EntryShape::validator`, and decoding lives in the model beside the check, so the prompt loop, `rite check` and the step share one implementation; the copies the check makes are wiped with the call, since the value may be a secret. The rule is stated in the label before typing rather than only after a refusal, and a refusal names the rule and never the value. The PIV PIN prompt takes the same route with the six to eight characters it asks for, and the step checks the six to eight bytes SP 800-73 gives a PIN before anything reaches the card, so a typo costs a retype rather than one of the card's few attempts. `import_key` reads the secret as `passphrase:` beside the material, 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. Through `reads:` and never `with:`, since a `with:` value is copied into unwiped JSON that a `BackendOperation` fact may record; a `reads:` input is borrowed from the store. Only a secret artifact is accepted there, a passphrase read from a material file would be a passphrase that sat on a disk, and a refusal names the artifact's kind rather than showing it. A reference carrying a property is refused, since a secret has none. `ReadsContract` gains the optional slot this needs, and the resolver now refuses a `reads:` key an action never looks at, as it already refused an unknown `with:` key; `issue_certificate` declares its optional `issuer_cert` accordingly. `KeyStoreBackend::import_key` takes the passphrase. The OpenSSL backend opens an encrypted PEM in either encoding through the callback it already used to detect one, and encrypted PKCS#8 DER by its own structure. A passphrase given for material that is not encrypted is refused: the step records that a passphrase was supplied, and that record has to mean it was used. The headless driver answers a formatted prompt with the format's own stand-in at its shortest length, so a dry run walks through a PIN or a passphrase step as it walks through any other; above 64 KiB, or for a pattern, it declines, which fails fast rather than answering with something the rule refuses again without end. `oral_readback` loses its `sensitive` flag, which nothing read and which had nothing to hide, since the step records no value in any fact. The `import_key` showcase gains a section that installs an escrow keypair from an encrypted PEM, with the media serial read by `enter_value` and the passphrase by `enter_secret`. The fixture is encrypted under the headless driver's stand-in, so the example completes in a dry run. --- Cargo.lock | 9 + Cargo.toml | 1 + crates/rite-ls/src/actions.rs | 10 + crates/rite-model/Cargo.toml | 3 + crates/rite-model/src/lib.rs | 3 +- crates/rite-model/src/params.rs | 260 ++++++++++ crates/rite-model/src/transcript.rs | 412 ++++++++++++++- crates/rite-model/src/types.rs | 82 ++- crates/rite-openssl/src/backend.rs | 248 +++++++-- crates/rite-openssl/tests/interop.rs | 3 + crates/rite-piv/src/backend.rs | 1 + crates/rite-resolver/src/diagnostic.rs | 1 + crates/rite-resolver/src/error.rs | 19 + crates/rite-resolver/src/resolve.rs | 128 ++++- crates/rite-runtime/src/backend/registry.rs | 1 + crates/rite-runtime/src/reporter.rs | 66 ++- crates/rite-runtime/src/runner.rs | 1 + crates/rite-sdk/src/backend.rs | 19 +- crates/rite-stdlib/src/backend/mock.rs | 10 +- crates/rite-stdlib/src/crypto/import_key.rs | 108 +++- crates/rite-stdlib/src/entry/mod.rs | 161 ++++++ crates/rite-stdlib/src/lib.rs | 9 +- crates/rite-stdlib/src/params.rs | 30 +- crates/rite-stdlib/src/piv/sign.rs | 82 +-- crates/rite-stdlib/tests/actions.rs | 475 +++++++++++++++++- crates/rite-tui/src/view.rs | 2 +- crates/rite-yubikey/src/backend.rs | 1 + crates/rite/src/console.rs | 2 +- crates/rite/src/container_checks.rs | 1 + crates/rite/src/headless.rs | 103 +++- docs/key-wrapping.md | 5 +- docs/typed-entry.md | 150 ++++++ examples/showcase/README.md | 19 +- examples/showcase/import_key.rite.yaml | 92 +++- .../showcase/test_keys/escrow_private.pem | 30 ++ 35 files changed, 2344 insertions(+), 203 deletions(-) create mode 100644 crates/rite-stdlib/src/entry/mod.rs create mode 100644 docs/typed-entry.md create mode 100644 examples/showcase/test_keys/escrow_private.pem 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..9dfedf3 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"), "{err}"); + let err = ValidatorSpec::Regex("[0-9]+".to_string()) + .check("hunter2") + .unwrap_err(); + assert!(!err.contains("hunter2"), "{err}"); + } + + #[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..dbc01ad 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,26 @@ 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"); + assert!(!err.contains("12345"), "{err}"); } #[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..24c73f9 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"), "{message}"); +} + +#[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"), "{message}"); +} + +/// 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----- From 5ec520e487d3c08414be356d42398a6a1392b976 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lomig=20Me=CC=81gard?= Date: Tue, 22 Sep 2026 22:45:06 +0200 Subject: [PATCH 2/2] test: a check that a secret is absent does not print the text it searched --- crates/rite-model/src/transcript.rs | 4 ++-- crates/rite-runtime/src/reporter.rs | 3 ++- crates/rite-stdlib/tests/actions.rs | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/rite-model/src/transcript.rs b/crates/rite-model/src/transcript.rs index 9dfedf3..56f7b49 100644 --- a/crates/rite-model/src/transcript.rs +++ b/crates/rite-model/src/transcript.rs @@ -705,11 +705,11 @@ mod validator_tests { #[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"), "{err}"); + assert!(!err.contains("hunter2")); let err = ValidatorSpec::Regex("[0-9]+".to_string()) .check("hunter2") .unwrap_err(); - assert!(!err.contains("hunter2"), "{err}"); + assert!(!err.contains("hunter2")); } #[test] diff --git a/crates/rite-runtime/src/reporter.rs b/crates/rite-runtime/src/reporter.rs index dbc01ad..02da885 100644 --- a/crates/rite-runtime/src/reporter.rs +++ b/crates/rite-runtime/src/reporter.rs @@ -874,7 +874,8 @@ mod tests { 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"); - assert!(!err.contains("12345"), "{err}"); + // No message: on failure it would print the value this checks for. + assert!(!err.contains("12345")); } #[test] diff --git a/crates/rite-stdlib/tests/actions.rs b/crates/rite-stdlib/tests/actions.rs index 24c73f9..6de0019 100644 --- a/crates/rite-stdlib/tests/actions.rs +++ b/crates/rite-stdlib/tests/actions.rs @@ -991,7 +991,7 @@ fn import_key_refuses_the_wrong_passphrase_without_naming_it() { let message = error.to_string(); assert!(message.contains("does not open"), "{message}"); - assert!(!message.contains("wrong horse"), "{message}"); + assert!(!message.contains("wrong horse")); } #[test] @@ -1053,7 +1053,7 @@ fn import_key_refuses_a_passphrase_that_is_not_a_secret() { 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"), "{message}"); + assert!(!message.contains("correct horse")); } /// A secret has no properties, so a reference naming one is a slip that