Skip to main content

signstar_yubihsm2/automation/
runner.rs

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