signstar_yubihsm2/automation/
runner.rs

1//! Scenario runner
2
3use std::{fmt::Debug, fs::read_to_string, io::Write, path::Path, time::Duration};
4
5use log::info;
6use serde::Serialize;
7use signstar_crypto::passphrase::Passphrase;
8use yubihsm::{
9    Client,
10    Connector,
11    Credentials,
12    authentication,
13    ed25519::Signature,
14    wrap::{self, Message},
15};
16
17use crate::{
18    Error,
19    automation::{Auth, Command},
20    object::{KeyInfo, ObjectId},
21};
22
23/// Derives an authentication key from a UTF-8-encoded file.
24///
25/// # Errors
26///
27/// Returns an error if `path` cannot be read to [`String`].
28fn derive_key_from_file(path: impl AsRef<Path>) -> Result<authentication::Key, Error> {
29    let passphrase = read_to_string(&path).map_err(|source| Error::IoPath {
30        path: path.as_ref().into(),
31        context: "reading key from file",
32        source,
33    })?;
34    let passphrase = Passphrase::new(passphrase);
35    let key = authentication::Key::derive_from_password(passphrase.expose_borrowed().as_bytes());
36    Ok(key)
37}
38
39/// Signature made using the ed25519 signing algorithm.
40#[derive(Debug, Serialize)]
41struct Ed25519Signature {
42    /// Raw bytes of the `R` component of the signature.
43    r: Vec<u8>,
44    /// Raw bytes of the `S` component of the signature.
45    s: Vec<u8>,
46}
47
48impl From<Signature> for Ed25519Signature {
49    fn from(value: Signature) -> Self {
50        Self {
51            r: value.r_bytes().to_vec(),
52            s: value.s_bytes().to_vec(),
53        }
54    }
55}
56
57/// Serializes an `object` to JSON, suffixed by a newline.
58///
59/// # Errors
60///
61/// Returns an error if
62/// - serialization fails
63/// - writing to the `writer` fails
64fn serialize_with_newline(mut writer: &mut dyn Write, object: impl Serialize) -> Result<(), Error> {
65    serde_json::to_writer(&mut writer, &object).map_err(|source| Error::Json {
66        context: "serializing response",
67        source,
68    })?;
69    writer.write_all(b"\n").map_err(|source| Error::Io {
70        context: "writing record delimiter",
71        source,
72    })?;
73    Ok(())
74}
75
76/// Runs commands against a physical or in-memory YubiHSM2 token.
77pub struct ScenarioRunner {
78    client: Client,
79}
80
81impl Debug for ScenarioRunner {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        // Client is not Debug so we cannot derive Debug for ScenarioRunner
84        f.debug_struct("ScenarioRunner").finish()
85    }
86}
87
88impl ScenarioRunner {
89    /// Creates a new [`ScenarioRunner`] for given `connector` and `auth`.
90    ///
91    /// # Errors
92    ///
93    /// Returns an error if
94    /// - deriving authentication key fails
95    /// - opening the connection to the client fails
96    pub fn new(connector: Connector, auth: Auth) -> Result<Self, Error> {
97        let user = auth.user;
98        let passphrase_file = auth.passphrase_file;
99        let credentials = Credentials::new(user, derive_key_from_file(passphrase_file)?);
100        let client =
101            Client::open(connector, credentials, true).map_err(|source| Error::Client {
102                context: "connecting to client for running a scenario",
103                source,
104            })?;
105        Ok(Self { client })
106    }
107
108    /// Runs a list of [`Command`] objects.
109    ///
110    /// The `writer` will receive [JSONL]-formatted responses for commands which generate them.
111    ///
112    /// # Errors
113    ///
114    /// Returns an error if
115    /// - executing the command fails
116    ///
117    /// [JSONL]: https://jsonlines.org/
118    pub fn run_steps(&mut self, steps: &[Command], writer: &mut dyn Write) -> Result<(), Error> {
119        for command in steps.iter() {
120            info!("Executing {command:?}");
121            self.run_command(command, writer)?;
122        }
123        Ok(())
124    }
125
126    /// Runs a single [`Command`].
127    ///
128    /// The `writer` will receive [JSONL]-formatted responses for commands which generate them.
129    ///
130    /// # Errors
131    ///
132    /// Returns an error if
133    /// - executing the command on device fails
134    /// - serializing the response to JSON fails
135    /// - writing the response to the `writer` fails
136    /// - reading or writing associated files fails
137    ///
138    /// [JSONL]: https://jsonlines.org/
139    fn run_command(&mut self, command: &Command, writer: &mut dyn Write) -> Result<(), Error> {
140        match command {
141            Command::Info => {
142                serialize_with_newline(
143                    writer,
144                    &self.client.device_info().map_err(|source| Error::Client {
145                        context: "executing device info command",
146                        source,
147                    })?,
148                )?;
149            }
150            Command::Reset => {
151                self.client
152                    .reset_device_and_reconnect(Duration::from_secs(2))
153                    .map_err(|source| Error::Client {
154                        context: "executing device info command",
155                        source,
156                    })?;
157            }
158            Command::PutAuthKey {
159                info:
160                    KeyInfo {
161                        key_id,
162                        domain,
163                        caps,
164                    },
165                delegated_caps,
166                passphrase_file,
167            } => {
168                let key = derive_key_from_file(passphrase_file)?;
169                self.client
170                    .put_authentication_key(
171                        *key_id,
172                        Default::default(),
173                        (*domain).into(),
174                        caps.into(),
175                        delegated_caps.into(),
176                        Default::default(),
177                        key,
178                    )
179                    .map_err(|source| Error::Client {
180                        context: "putting authentication key",
181                        source,
182                    })?;
183            }
184            Command::GenerateKey {
185                info:
186                    KeyInfo {
187                        key_id,
188                        domain,
189                        caps,
190                    },
191            } => {
192                self.client
193                    .generate_asymmetric_key(
194                        *key_id,
195                        Default::default(),
196                        (*domain).into(),
197                        caps.into(),
198                        yubihsm::asymmetric::Algorithm::Ed25519,
199                    )
200                    .map_err(|source| Error::Client {
201                        context: "generating asymmetric key",
202                        source,
203                    })?;
204            }
205            Command::SignEd25519 { key_id, data } => {
206                let sig = self
207                    .client
208                    .sign_ed25519(*key_id, &data[..])
209                    .map_err(|source| Error::Client {
210                        context: "signing with ed25519 key",
211                        source,
212                    })?;
213                serialize_with_newline(writer, Ed25519Signature::from(sig))?;
214            }
215            Command::PutWrapKey {
216                info:
217                    KeyInfo {
218                        key_id,
219                        domain,
220                        caps,
221                    },
222                delegated_caps,
223                passphrase_file,
224            } => {
225                let key = derive_key_from_file(passphrase_file)?;
226                self.client
227                    .put_wrap_key(
228                        *key_id,
229                        Default::default(),
230                        (*domain).into(),
231                        caps.into(),
232                        delegated_caps.into(),
233                        wrap::Algorithm::Aes256Ccm,
234                        key.as_secret_slice(),
235                    )
236                    .map_err(|source| Error::Client {
237                        context: "putting wrap key",
238                        source,
239                    })?;
240            }
241            Command::ExportWrapped {
242                wrap_key_id,
243                object,
244                wrapped_file,
245            } => {
246                let wrapped = self
247                    .client
248                    .export_wrapped(*wrap_key_id, object.object_type(), object.id())
249                    .map_err(|source| Error::Client {
250                        context: "exporting wrapped key",
251                        source,
252                    })?;
253
254                serialize_with_newline(writer, &wrapped)?;
255                std::fs::write(wrapped_file, wrapped.into_vec()).map_err(|source| {
256                    Error::IoPath {
257                        context: "writing wrapped file",
258                        source,
259                        path: wrapped_file.into(),
260                    }
261                })?;
262            }
263            Command::ImportWrapped {
264                wrap_key_id,
265                wrapped_file,
266            } => {
267                let wrapped = Message::from_vec(std::fs::read(wrapped_file).map_err(|source| {
268                    Error::IoPath {
269                        context: "reading wrapped file",
270                        source,
271                        path: wrapped_file.into(),
272                    }
273                })?)
274                .map_err(|source| Error::InvalidWrap {
275                    context: "reading the wrapped file",
276                    source,
277                })?;
278                let imported =
279                    self.client
280                        .import_wrapped(*wrap_key_id, wrapped)
281                        .map_err(|source| Error::Client {
282                            context: "importing wrapped key",
283                            source,
284                        })?;
285
286                serialize_with_newline(writer, ObjectId::from(imported))?;
287            }
288            Command::Auth(Auth {
289                user,
290                passphrase_file,
291            }) => {
292                let credentials = Credentials::new(*user, derive_key_from_file(passphrase_file)?);
293
294                self.client = Client::open(self.client.connector().clone(), credentials, true)
295                    .map_err(|source| Error::Client {
296                        context: "opening new client",
297                        source,
298                    })?;
299            }
300            Command::Delete(object) => {
301                self.client
302                    .delete_object(object.id(), object.object_type())
303                    .map_err(|source| Error::Client {
304                        context: "deleting object",
305                        source,
306                    })?;
307            }
308            Command::GetInfo(object) => {
309                let info = self
310                    .client
311                    .get_object_info(object.id(), object.object_type())
312                    .map_err(|source| Error::Client {
313                        context: "getting object info",
314                        source,
315                    })?;
316
317                serialize_with_newline(writer, &info)?;
318            }
319            Command::ForceAudit(setting) => {
320                self.client
321                    .set_force_audit_option((*setting).into())
322                    .map_err(|source| Error::Client {
323                        context: "setting force audit option",
324                        source,
325                    })?;
326            }
327            Command::CommandAudit { command, setting } => {
328                self.client
329                    .set_command_audit_option(*command, (*setting).into())
330                    .map_err(|source| Error::Client {
331                        context: "setting command audit option",
332                        source,
333                    })?;
334            }
335            Command::GetLog => {
336                let log = self
337                    .client
338                    .get_log_entries()
339                    .map_err(|source| Error::Client {
340                        context: "getting log entries",
341                        source,
342                    })?;
343
344                serialize_with_newline(writer, &log)?;
345            }
346        }
347        Ok(())
348    }
349}
350
351#[cfg(all(test, feature = "mockhsm"))]
352mod tests {
353    use std::path::PathBuf;
354    use std::{fs::File, io::stdout};
355
356    use rstest::rstest;
357    use testresult::TestResult;
358
359    use super::*;
360    use crate::automation::Scenario;
361
362    fn run_scenario(scenario_file: impl AsRef<Path>) -> TestResult {
363        let scenario_file = scenario_file.as_ref();
364        eprintln!(
365            "Running scenario file {scenario_file}",
366            scenario_file = scenario_file.display()
367        );
368        let scenario: Scenario = serde_json::from_reader(File::open(scenario_file)?)?;
369        let mut runner = ScenarioRunner::new(Connector::mockhsm(), scenario.auth)?;
370        runner.run_steps(&scenario.steps, &mut stdout())?;
371        Ok(())
372    }
373
374    #[rstest]
375    fn scenario_test(#[files("tests/scenarios/*.json")] scenario_file: PathBuf) -> TestResult {
376        run_scenario(scenario_file)?;
377        Ok(())
378    }
379
380    #[test]
381    fn wrapping_test() -> TestResult {
382        // these two need to run in order: first exporting to a file, then importing that file
383        run_scenario("tests/scenarios/wrapping/export-wrapped.json")?;
384        run_scenario("tests/scenarios/wrapping/import-wrapped.json")?;
385        Ok(())
386    }
387}