Skip to main content

signstar_config/yubihsm2/
state.rs

1//! Common types for state representation of a YubiHSM2.
2
3use std::{collections::HashSet, fmt::Display};
4
5use log::{debug, info, warn};
6use signstar_crypto::key::{
7    SigningKeySetup,
8    base::{CryptographicKeyContext, KeyType},
9};
10use signstar_yubihsm2::{
11    automation::ObjectType,
12    backup::Label,
13    object::{
14        AsymmetricAlgorithm,
15        Capabilities,
16        Capability,
17        Domain,
18        Domains,
19        ObjectAlgorithm,
20        WrapKeyKind,
21    },
22    yubihsm::Id,
23};
24
25use crate::{
26    state::{
27        StateDiff,
28        StateDiffFailure,
29        StateDiffFailureTarget,
30        StateDiffReport,
31        StateOrigin,
32        StateOriginInfo,
33    },
34    yubihsm2::{YubiHsm2Backend, YubiHsm2Config, YubiHsm2UserMapping, config::AuthType},
35};
36
37/// Returns information on the (implicitly defined) wrapping key used to backup all objects.
38fn implicit_wrap_key_state() -> YubiHsm2BackendUserKeyData {
39    YubiHsm2BackendUserKeyData {
40        id: YubiHsm2Config::WRAP_KEY_ID,
41        object_type: ObjectType::WrapKey,
42        capabilities: Capabilities::from(YubiHsm2UserMapping::CAP_BACKUP),
43        domains: Domains::all(),
44        algorithm: ObjectAlgorithm::Wrap(WrapKeyKind::Aes256),
45        label: YubiHsm2Config::wrap_key_label(),
46        length: 32,
47    }
48}
49
50/// The state of a user in a [`YubiHsm2Backend`].
51#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
52pub(crate) struct YubiHsm2BackendUserData {
53    /// The ID of the user.
54    pub id: Id,
55
56    /// The capabilities of the user.
57    pub capabilities: Capabilities,
58
59    /// The domains of the user.
60    pub domains: Domains,
61}
62
63impl Display for YubiHsm2BackendUserData {
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        write!(
66            f,
67            "{} (capabilities: {}; domains: {})",
68            self.id, self.capabilities, self.domains
69        )
70    }
71}
72
73impl PartialEq<YubiHsm2ConfigUserData> for YubiHsm2BackendUserData {
74    fn eq(&self, other: &YubiHsm2ConfigUserData) -> bool {
75        self.id == other.authentication_key_id
76            && self.capabilities == other.capabilities
77            && self.domains == other.domains
78    }
79}
80
81/// The state of a key in a [`YubiHsm2Backend`].
82#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
83pub(crate) struct YubiHsm2BackendUserKeyData {
84    /// The ID of the signing key.
85    pub id: Id,
86
87    /// The type of object.
88    pub object_type: ObjectType,
89
90    /// The capabilities of the signing key.
91    pub capabilities: Capabilities,
92
93    /// The domain of the signing key.
94    pub domains: Domains,
95
96    /// The object's algorithm.
97    pub algorithm: ObjectAlgorithm,
98
99    /// The object's label.
100    pub label: Label,
101
102    /// The object's size in bytes.
103    pub length: u16,
104}
105
106impl Display for YubiHsm2BackendUserKeyData {
107    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108        write!(
109            f,
110            "{} (type: {}; algorithm: {}; capabilities: {}; domains: {}; label: {}; length: {})",
111            self.id,
112            self.object_type,
113            self.algorithm,
114            self.capabilities,
115            self.domains,
116            self.label,
117            self.length
118        )
119    }
120}
121
122impl<'config> PartialEq<YubiHsm2ConfigUserKeyData<'config>> for YubiHsm2BackendUserKeyData {
123    fn eq(&self, other: &YubiHsm2ConfigUserKeyData<'config>) -> bool {
124        self.id == *other.signing_key_id
125            && self.object_type == ObjectType::AsymmetricKey
126            && other.key_setup.key_type() == KeyType::Curve25519
127            && self.algorithm == ObjectAlgorithm::Asymmetric(AsymmetricAlgorithm::Ed25519)
128            && self.capabilities == other.capabilities
129            && self.domains == Domains::from(*other.domain)
130    }
131}
132
133/// The state of a [`YubiHsm2Backend`].
134///
135/// This tracks two lists of data in the backend:
136///
137/// - the authentication keys ("user credentials"), their capabilities and domains
138/// - all other key types, their capabilities, domains, algorithms, labels and lengths
139#[derive(Debug, Eq, PartialEq)]
140pub struct YubiHsm2BackendState {
141    /// The user states.
142    pub(crate) user_data: Vec<YubiHsm2BackendUserData>,
143
144    /// The key states.
145    pub(crate) key_data: Vec<YubiHsm2BackendUserKeyData>,
146}
147
148impl YubiHsm2BackendState {
149    /// The name of the origin for the state.
150    pub const STATE_NAME: &'static str = "YubiHSM2 backend";
151}
152
153impl<'admin_creds, 'config> TryFrom<&YubiHsm2Backend<'admin_creds, 'config>>
154    for YubiHsm2BackendState
155{
156    type Error = crate::Error;
157
158    /// Creates a new [`YubiHsm2BackendState`] from a [`YubiHsm2Backend`].
159    ///
160    /// # Errors
161    ///
162    /// Returns an error if retrieving the user or key states from the backend fails.
163    fn try_from(value: &YubiHsm2Backend<'admin_creds, 'config>) -> Result<Self, Self::Error> {
164        debug!(
165            "Retrieve state of the YubiHSM2 backend at {}",
166            value
167                .yubihsm2_config()
168                .connections()
169                .iter()
170                .map(|connection| format!("{connection:?}"))
171                .collect::<Vec<_>>()
172                .join(", ")
173        );
174
175        Ok(Self {
176            user_data: value.user_states()?,
177            key_data: value.key_states()?,
178        })
179    }
180}
181
182impl<'config> PartialEq<YubiHsm2ConfigState<'config>> for YubiHsm2BackendState {
183    fn eq(&self, other: &YubiHsm2ConfigState) -> bool {
184        debug!(
185            "Compare backend state ({} users, {} keys) and config state ({} users, {} keys)",
186            self.user_data.len(),
187            self.key_data.len(),
188            other.user_data.len(),
189            other.key_data.len()
190        );
191
192        let (found_self_user_data, found_other_user_data) = {
193            let mut found_self_user_data: HashSet<&YubiHsm2BackendUserData> = HashSet::new();
194            let mut found_other_user_data: HashSet<&YubiHsm2ConfigUserData> = HashSet::new();
195
196            'outer: for other_user_data in other.user_data.iter() {
197                for self_user_data in self.user_data.iter() {
198                    if self_user_data == other_user_data
199                        // NOTE: For administrative credentials we only track a subset of the entirety
200                        // of available capabilities in the config (the set of capabilities we require).
201                        // We only check if those are available in the set of capabilities (no complete
202                        // match).
203                        || (other_user_data.auth_type == AuthType::Admin
204                            && other_user_data.authentication_key_id == self_user_data.id
205                            && other_user_data
206                                .capabilities
207                                .as_ref()
208                                .is_subset(self_user_data.capabilities.as_ref())
209                            && other_user_data.domains == self_user_data.domains)
210                    {
211                        found_self_user_data.insert(self_user_data);
212                        found_other_user_data.insert(other_user_data);
213                        debug!(
214                            "Found matching config item for backend user {}",
215                            self_user_data.id
216                        );
217                        continue 'outer;
218                    }
219                }
220
221                debug!(
222                    "Unable to find a matching backend item for config user {}",
223                    other_user_data.authentication_key_id
224                );
225                return false;
226            }
227
228            (found_self_user_data, found_other_user_data)
229        };
230
231        // The implicit wrap key must be present.
232        if !self.key_data.iter().any(|key_data| {
233            key_data.id == YubiHsm2Config::WRAP_KEY_ID
234                && key_data.object_type == ObjectType::WrapKey
235        }) {
236            debug!("The implicitly defined wrap key is not present");
237            return false;
238        }
239
240        let self_key_data_without_wrap_key = self.key_data.iter().filter(|key_data| {
241            !(key_data.id == YubiHsm2Config::WRAP_KEY_ID
242                && key_data.object_type == ObjectType::WrapKey)
243        });
244
245        let (found_self_user_key_data, found_other_user_key_data) = {
246            let mut found_self_user_key_data: HashSet<(Id, ObjectType)> = HashSet::new();
247            let mut found_other_user_key_data: HashSet<&YubiHsm2ConfigUserKeyData> = HashSet::new();
248
249            'outer: for other_user_key_data in other.key_data.iter() {
250                // For OpenPGP we require the asymmetric key (configured) and the OpenPGP
251                // certificate (implicitly defined).
252                if matches!(
253                    other_user_key_data.key_setup.key_context(),
254                    CryptographicKeyContext::OpenPgp { .. }
255                ) {
256                    let data = self_key_data_without_wrap_key
257                        .clone()
258                        .filter_map(|key_data| {
259                            if key_data.id == *other_user_key_data.signing_key_id
260                                && (key_data.object_type == ObjectType::AsymmetricKey
261                                    || key_data.object_type == ObjectType::Opaque)
262                            {
263                                Some((key_data.id, key_data.object_type))
264                            } else {
265                                None
266                            }
267                        })
268                        .collect::<HashSet<_>>();
269
270                    if !(data.contains(&(*other_user_key_data.signing_key_id, ObjectType::Opaque))
271                        && data.contains(&(
272                            *other_user_key_data.signing_key_id,
273                            ObjectType::AsymmetricKey,
274                        )))
275                    {
276                        debug!(
277                            "Unable to find a matching backend asymmetric key and certificate for OpenPGP key in config: {}",
278                            other_user_key_data.authentication_key_id
279                        );
280                        return false;
281                    }
282
283                    debug!(
284                        "Found matching backend item for OpenPGP key {} in config",
285                        other_user_key_data.signing_key_id
286                    );
287                    found_self_user_key_data.extend(data);
288                    found_other_user_key_data.insert(other_user_key_data);
289                    continue 'outer;
290                }
291
292                // For all other key contexts we assume, that we do a direct comparison and that no
293                // implicit objects need to be considered.
294                for self_user_key_data in self.key_data.iter().filter(|key_data| {
295                    !(key_data.id == YubiHsm2Config::WRAP_KEY_ID
296                        && key_data.object_type == ObjectType::WrapKey)
297                }) {
298                    if self_user_key_data == other_user_key_data {
299                        found_self_user_key_data
300                            .insert((self_user_key_data.id, self_user_key_data.object_type));
301                        found_other_user_key_data.insert(other_user_key_data);
302                        debug!(
303                            "Found matching config item for backend key {}",
304                            other_user_key_data.signing_key_id
305                        );
306                        continue 'outer;
307                    }
308                }
309
310                debug!(
311                    "Unable to find a matching backend item for key {} in config",
312                    other_user_key_data.authentication_key_id
313                );
314                return false;
315            }
316
317            (found_self_user_key_data, found_other_user_key_data)
318        };
319
320        let mut return_value = true;
321        if found_self_user_data.len() != self.user_data.len() {
322            debug!(
323                "The following backend users have no matching item in the config: {}",
324                HashSet::from_iter(self.user_data.iter())
325                    .difference(&found_self_user_data)
326                    .map(ToString::to_string)
327                    .collect::<Vec<_>>()
328                    .join(", ")
329            );
330            return_value = false;
331        }
332        if found_other_user_data.len() != other.user_data.len() {
333            debug!(
334                "The following config users have no matching item in the backend: {}",
335                HashSet::from_iter(other.user_data.iter())
336                    .difference(&found_other_user_data)
337                    .map(ToString::to_string)
338                    .collect::<Vec<_>>()
339                    .join(", ")
340            );
341            return_value = false;
342        }
343
344        if found_self_user_key_data.len() != self_key_data_without_wrap_key.clone().count() {
345            debug!(
346                "The following backend keys have no matching item in the config: {}",
347                HashSet::from_iter(
348                    self_key_data_without_wrap_key
349                        .clone()
350                        .map(|key_data| { (key_data.id, key_data.object_type) })
351                )
352                .difference(&found_self_user_key_data)
353                .map(|data| format!("{} ({})", data.0, data.1))
354                .collect::<Vec<_>>()
355                .join(", ")
356            );
357            return_value = false;
358        }
359        if found_other_user_key_data.len() != other.key_data.len() {
360            debug!(
361                "The following config keys have no matching item in the backend: {}",
362                HashSet::from_iter(other.key_data.iter())
363                    .difference(&found_other_user_key_data)
364                    .map(ToString::to_string)
365                    .collect::<Vec<_>>()
366                    .join(", ")
367            );
368            return_value = false;
369        }
370
371        if return_value {
372            debug!("The backend state and config state are considered equal.");
373        }
374
375        return_value
376    }
377}
378
379impl StateOriginInfo for YubiHsm2BackendState {
380    fn state_name(&self) -> &str {
381        Self::STATE_NAME
382    }
383
384    fn state_origin(&self) -> StateOrigin {
385        StateOrigin::Backend
386    }
387}
388
389/// Data about a YubiHSM2 user.
390#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
391pub(crate) struct YubiHsm2ConfigUserData {
392    /// The ID of the authentication key.
393    pub authentication_key_id: Id,
394
395    /// The user type.
396    pub auth_type: AuthType,
397
398    /// The capabilities of the authentication key.
399    pub capabilities: Capabilities,
400
401    /// The optional domains of the authentication key.
402    pub domains: Domains,
403}
404
405impl Display for YubiHsm2ConfigUserData {
406    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
407        write!(
408            f,
409            "{} (auth type: {}; capabilities: {}; domains: {})",
410            self.authentication_key_id, self.auth_type, self.capabilities, self.domains
411        )?;
412
413        Ok(())
414    }
415}
416
417/// Data about a YubiHSM2 signing user associated with a signing key.
418#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
419pub(crate) struct YubiHsm2ConfigUserKeyData<'config> {
420    /// The ID of the signing key.
421    pub signing_key_id: &'config Id,
422
423    /// The ID of the authentication key.
424    pub authentication_key_id: &'config Id,
425
426    /// The capabilities of the signing key.
427    pub capabilities: Capabilities,
428
429    /// The domain of the signing key.
430    pub domain: &'config Domain,
431
432    /// The setup of the signing key.
433    pub key_setup: &'config SigningKeySetup,
434}
435
436impl<'config> Display for YubiHsm2ConfigUserKeyData<'config> {
437    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
438        write!(
439            f,
440            "{} (authentication: {}; capabilities: {}; domain: {}; ",
441            self.signing_key_id, self.authentication_key_id, self.capabilities, self.domain,
442        )?;
443        write!(f, "type: {}; ", self.key_setup.key_type())?;
444        write!(
445            f,
446            "mechanisms: {}; ",
447            self.key_setup
448                .key_mechanisms()
449                .iter()
450                .map(|mechanism| mechanism.to_string())
451                .collect::<Vec<String>>()
452                .join(", ")
453        )?;
454        write!(f, "context: {}", self.key_setup.key_context())?;
455        write!(f, ")")?;
456
457        Ok(())
458    }
459}
460
461/// The state of a YubiHSM2 configuration.
462///
463/// Tracks the available backend authentication keys, their capabilities and domains, as well as the
464/// signing key setups associated with those authentication keys.
465#[derive(Debug)]
466pub struct YubiHsm2ConfigState<'config> {
467    /// The user states.
468    pub(crate) user_data: Vec<YubiHsm2ConfigUserData>,
469
470    /// The key states.
471    pub(crate) key_data: Vec<YubiHsm2ConfigUserKeyData<'config>>,
472}
473
474impl<'config> YubiHsm2ConfigState<'config> {
475    /// The name of the origin for the state.
476    pub const STATE_NAME: &'static str = "YubiHSM2 config";
477}
478
479impl<'config> From<&'config YubiHsm2Config> for YubiHsm2ConfigState<'config> {
480    /// Creates a new [`YubiHsm2ConfigState`] from a [`YubiHsm2Config`].
481    fn from(value: &'config YubiHsm2Config) -> Self {
482        let mut user_data = Vec::new();
483        let mut key_data = Vec::new();
484
485        for mapping in value.mappings() {
486            if let YubiHsm2UserMapping::Signing {
487                authentication_key_id,
488                key_setup,
489                domain,
490                signing_key_id,
491                ..
492            } = mapping
493            {
494                key_data.push(YubiHsm2ConfigUserKeyData {
495                    signing_key_id,
496                    authentication_key_id,
497                    capabilities: mapping.capabilities(),
498                    domain,
499                    key_setup,
500                })
501            }
502
503            user_data.push(YubiHsm2ConfigUserData {
504                authentication_key_id: mapping.backend_user_id(),
505                auth_type: mapping.into(),
506                capabilities: mapping.capabilities(),
507                domains: mapping.domains(),
508            })
509        }
510
511        Self {
512            user_data,
513            key_data,
514        }
515    }
516}
517
518impl<'config> StateOriginInfo for YubiHsm2ConfigState<'config> {
519    fn state_name(&self) -> &str {
520        Self::STATE_NAME
521    }
522
523    fn state_origin(&self) -> StateOrigin {
524        StateOrigin::Config
525    }
526}
527
528/// The diff between [`YubiHsm2ConfigState`] and [`YubiHsm2BackendState`].
529#[derive(Debug)]
530pub struct YubiHsm2Diff<'config_state, 'backend_state, 'config_items> {
531    /// The reference to the state of a NetHSM config.
532    pub(crate) config: &'config_state YubiHsm2ConfigState<'config_items>,
533
534    /// The reference to the state of a NetHSM backend.
535    pub(crate) backend: &'backend_state YubiHsm2BackendState,
536}
537
538impl<'config_state, 'backend_state, 'config_items> StateDiff<'config_state, 'backend_state>
539    for YubiHsm2Diff<'config_state, 'backend_state, 'config_items>
540{
541    fn diff(&self) -> StateDiffReport<'config_state, 'backend_state> {
542        info!(
543            "Creating state diff report between {} and {}",
544            self.config.state_name(),
545            self.backend.state_name()
546        );
547
548        if self.backend == self.config {
549            debug!(
550                "The states of {} and {} are considered equal.",
551                self.config.state_name(),
552                self.backend.state_name()
553            );
554            return StateDiffReport::Success;
555        }
556
557        warn!(
558            "The states of {} and {} are not considered equal. Collecting discrepancies...",
559            self.config.state_name(),
560            self.backend.state_name()
561        );
562        let mut messages = Vec::new();
563
564        {
565            let mut matched_config_states = Vec::new();
566
567            'outer: for backend_user_data in self.backend.user_data.iter() {
568                for config_user_data in self.config.user_data.iter() {
569                    // The states match, or these are administrative credentials.
570                    //
571                    // NOTE: For administrative credentials we only track a subset of the entirety
572                    // of available capabilities in the config (the set of capabilities we require).
573                    // We only check if those are available in the set of capabilities (no complete
574                    // match).
575                    if (backend_user_data == config_user_data)
576                        || (config_user_data.auth_type == AuthType::Admin
577                            && backend_user_data.id == config_user_data.authentication_key_id
578                            && config_user_data
579                                .capabilities
580                                .as_ref()
581                                .is_subset(backend_user_data.capabilities.as_ref())
582                            && backend_user_data.domains == config_user_data.domains)
583                    {
584                        matched_config_states.push(config_user_data);
585                        debug!(
586                            "Found matching config item for backend user {}.",
587                            backend_user_data.id
588                        );
589                        continue 'outer;
590                    }
591
592                    // The unique backend authentication key ID matches, but not the remaining data.
593                    if backend_user_data.id == config_user_data.authentication_key_id {
594                        matched_config_states.push(config_user_data);
595                        messages.push(StateDiffFailure::Mismatch {
596                            one: Box::new(self.config),
597                            other: Box::new(self.backend),
598                            one_state: config_user_data.to_string(),
599                            other_state: backend_user_data.to_string(),
600                        });
601                        debug!(
602                            "Found mismatching data in config and backend for authentication key {}.",
603                            backend_user_data.id
604                        );
605                        continue 'outer;
606                    }
607                }
608
609                // No match has been found.
610                debug!(
611                    "Unable to find a matching config item for backend user {}.",
612                    backend_user_data.id
613                );
614                messages.push(StateDiffFailure::DoesNotExist {
615                    one: Box::new(self.config),
616                    other: Box::new(self.backend),
617                    target: StateDiffFailureTarget::One,
618                    state: backend_user_data.to_string(),
619                });
620            }
621
622            // Unmatched config states.
623            self.config
624                .user_data
625                .iter()
626                .filter(|state| !matched_config_states.contains(state))
627                .for_each(|config_user_data| {
628                    debug!(
629                        "Unable to find a matching backend item for config user {}.",
630                        config_user_data.authentication_key_id
631                    );
632                    messages.push(StateDiffFailure::DoesNotExist {
633                        one: Box::new(self.config),
634                        other: Box::new(self.backend),
635                        target: StateDiffFailureTarget::Other,
636                        state: config_user_data.to_string(),
637                    })
638                });
639        }
640
641        {
642            let implicit_wrap_key_state = implicit_wrap_key_state();
643            let mut matched_config_states = Vec::new();
644
645            // Check whether there is at least one wrap key.
646            if !self
647                .backend
648                .key_data
649                .iter()
650                .any(|key_state| key_state.object_type == ObjectType::WrapKey)
651            {
652                debug!("Unable to find any wrap key in backend.");
653                messages.push(StateDiffFailure::DoesNotExist {
654                    one: Box::new(self.config),
655                    other: Box::new(self.backend),
656                    target: StateDiffFailureTarget::Other,
657                    state: implicit_wrap_key_state.to_string(),
658                });
659            }
660
661            'outer: for backend_user_key_data in self.backend.key_data.iter() {
662                match backend_user_key_data.object_type {
663                    ObjectType::WrapKey => {
664                        // NOTE: The backup key is an implicit object, that is not part of the
665                        // specific YubiHSM2 configuration object.
666                        // It differs from signing keys, which is why we compare it here explicitly
667                        // to ensure, that it matches our implicit
668                        // expectations.
669                        if backend_user_key_data != &implicit_wrap_key_state {
670                            debug!("The implicit wrap key in the backend is not correct.");
671                            messages.push(StateDiffFailure::Mismatch {
672                                one: Box::new(self.config),
673                                other: Box::new(self.backend),
674                                one_state: implicit_wrap_key_state.to_string(),
675                                other_state: backend_user_key_data.to_string(),
676                            });
677                        }
678                    }
679                    ObjectType::AsymmetricKey => {
680                        for config_user_key_data in self.config.key_data.iter() {
681                            // The states match.
682                            if backend_user_key_data == config_user_key_data {
683                                matched_config_states.push(config_user_key_data);
684                                debug!(
685                                    "Found matching config item for asymmetric key {} in backend.",
686                                    backend_user_key_data.id
687                                );
688                                continue 'outer;
689                            }
690
691                            // The unique backend ID matches, but not the remaining data.
692                            if &backend_user_key_data.id == config_user_key_data.signing_key_id {
693                                matched_config_states.push(config_user_key_data);
694                                messages.push(StateDiffFailure::Mismatch {
695                                    one: Box::new(self.config),
696                                    other: Box::new(self.backend),
697                                    one_state: config_user_key_data.to_string(),
698                                    other_state: backend_user_key_data.to_string(),
699                                });
700                                debug!(
701                                    "Found mismatching data in config and backend for asymmetric key {}.",
702                                    backend_user_key_data.id
703                                );
704                                continue 'outer;
705                            }
706                        }
707
708                        // No match has been found.
709                        debug!(
710                            "Unable to find a matching config item for asymmetric key {} in backend.",
711                            backend_user_key_data.id
712                        );
713                        messages.push(StateDiffFailure::DoesNotExist {
714                            one: Box::new(self.config),
715                            other: Box::new(self.backend),
716                            target: StateDiffFailureTarget::One,
717                            state: backend_user_key_data.to_string(),
718                        });
719                    }
720                    // NOTE: Certificates (e.g. OpenPGP) are added as opaque data with the same
721                    // object ID as the key they are created from.
722                    ObjectType::Opaque => {
723                        for config_user_key_data in self.config.key_data.iter() {
724                            // The states match.
725                            if (backend_user_key_data == config_user_key_data)
726                                || (backend_user_key_data.id
727                                    == *config_user_key_data.signing_key_id
728                                    && backend_user_key_data.capabilities
729                                        == Capabilities::from(
730                                            vec![Capability::ExportableUnderWrap].as_slice(),
731                                        )
732                                    && backend_user_key_data.domains
733                                        == Domains::from(*config_user_key_data.domain)
734                                    && backend_user_key_data.label
735                                        == YubiHsm2Config::openpgp_certificate_label())
736                            {
737                                matched_config_states.push(config_user_key_data);
738                                debug!(
739                                    "Found matching config item for opaque object {} in backend.",
740                                    backend_user_key_data.id
741                                );
742                                continue 'outer;
743                            }
744
745                            // The unique backend ID matches, but not the remaining data.
746                            if &backend_user_key_data.id == config_user_key_data.signing_key_id {
747                                matched_config_states.push(config_user_key_data);
748                                messages.push(StateDiffFailure::Mismatch {
749                                    one: Box::new(self.config),
750                                    other: Box::new(self.backend),
751                                    one_state: config_user_key_data.to_string(),
752                                    other_state: backend_user_key_data.to_string(),
753                                });
754                                debug!(
755                                    "Found mismatching data in config and backend for opaque object {}.",
756                                    backend_user_key_data.id
757                                );
758                                continue 'outer;
759                            }
760                        }
761
762                        // No match has been found.
763                        debug!(
764                            "Unable to find a matching config item for opaque object {} in backend.",
765                            backend_user_key_data.id
766                        );
767                        messages.push(StateDiffFailure::DoesNotExist {
768                            one: Box::new(self.config),
769                            other: Box::new(self.backend),
770                            target: StateDiffFailureTarget::One,
771                            state: backend_user_key_data.to_string(),
772                        });
773                    }
774                    ObjectType::AuthenticationKey
775                    | ObjectType::HmacKey
776                    | ObjectType::SymmetricKey
777                    | ObjectType::Template
778                    | ObjectType::OtpAeakey => {
779                        // NOTE: We do not support these key types.
780                        warn!(
781                            "Found unsupported key type ({}) for backend key {}",
782                            backend_user_key_data.object_type, backend_user_key_data.id
783                        );
784                        messages.push(StateDiffFailure::DoesNotExist {
785                            one: Box::new(self.config),
786                            other: Box::new(self.backend),
787                            target: StateDiffFailureTarget::One,
788                            state: backend_user_key_data.to_string(),
789                        });
790                    }
791                }
792            }
793
794            // Unmatched config states.
795            self.config
796                .key_data
797                .iter()
798                .filter(|state| !matched_config_states.contains(state))
799                .for_each(|key_data| {
800                    debug!(
801                        "Unable to find a matching backend item for config key {}",
802                        key_data.signing_key_id
803                    );
804                    messages.push(StateDiffFailure::DoesNotExist {
805                        one: Box::new(self.config),
806                        other: Box::new(self.backend),
807                        target: StateDiffFailureTarget::Other,
808                        state: key_data.to_string(),
809                    })
810                });
811        }
812
813        StateDiffReport::Failure { messages }
814    }
815}
816
817#[cfg(all(test, feature = "_yubihsm2-mockhsm"))]
818mod tests {
819    use std::{collections::BTreeSet, thread::current};
820
821    use insta::{assert_snapshot, with_settings};
822    use log::{LevelFilter, info};
823    use rstest::{fixture, rstest};
824    use signstar_common::logging::setup_logging;
825    use signstar_crypto::{
826        AdministrativeSecretHandling,
827        NonAdministrativeSecretHandling,
828        key::{
829            CryptographicKeyContext,
830
831            SigningKeySetup,
832            base::{KeyMechanism, KeyType, SignatureType},
833        },
834        openpgp::OpenPgpUserIdList,
835        passphrase::Passphrase,
836    };
837    use signstar_yubihsm2::{
838        Connection,
839        Credentials,
840        object::{Domain, WrapKey, WrapKeyFromPassphrase},
841        yubihsm::{Client, Connector},
842    };
843    use testresult::TestResult;
844
845    use super::*;
846    use crate::{
847        admin_credentials::AdminCredentials,
848        config::{Config, ConfigBuilder, SystemConfig},
849        state::{StateDiff, StateDiffReport},
850        yubihsm2::{
851            YubiHsm2Config,
852            YubiHsm2UserMapping,
853            admin_credentials::YubiHsm2AdminCredentials,
854        },
855    };
856
857    const SNAPSHOT_PATH: &str = "fixtures/state/";
858
859    /// Creates a MockHSM [`Connector`].
860    #[fixture]
861    fn connector() -> Connector {
862        Connector::mockhsm()
863    }
864
865    /// Creates a default [`YubiHsm2AdminCredentials`].
866    #[fixture]
867    fn yubihsm2_admin_credentials() -> TestResult<YubiHsm2AdminCredentials> {
868        Ok(YubiHsm2AdminCredentials::new(
869                1,
870                Passphrase::new("backup-passphrase-really-just-for-testing-i-promise-but-it-is-really-really-long-sufficiently-long-really".to_string()),
871                vec![
872                    Credentials::new(1, Passphrase::new("admin-passphrase-really-just-for-testing-i-promise".to_string())),
873                ]
874            )?)
875    }
876
877    /// Creates a basic, default [`YubiHsm2Config`].
878    #[fixture]
879    fn yubihsm2_config() -> TestResult<YubiHsm2Config> {
880        Ok(YubiHsm2Config::new(
881            BTreeSet::from_iter([
882                Connection::Mock
883            ]),
884            BTreeSet::from_iter([
885                YubiHsm2UserMapping::Admin { authentication_key_id: 1 },
886                YubiHsm2UserMapping::AuditLog {
887                    authentication_key_id: 3,
888                    ssh_authorized_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPkpXKiNhy39A3bZ1u19a5d4sFwYMBkWQyCbzgUfdKBm user@host".parse()?,
889                    system_user: "yubihsm2-metrics-user".parse()?,
890                },
891                YubiHsm2UserMapping::Backup{
892                    authentication_key_id: 2,
893                    ssh_authorized_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOOCMo+ODRchqIiXm89TxF7avi+LXRtqWZdBAvJ1SG5g user@host".parse()?,
894                    system_user: "yubihsm2-backup-user".parse()?,
895                    wrapping_key_id: 1,
896                },
897                YubiHsm2UserMapping::HermeticAuditLog {
898                    authentication_key_id: 4,
899                    system_user: "yubihsm2-hermetic-metrics-user".parse()?,
900                },
901                YubiHsm2UserMapping::Signing {
902                    authentication_key_id: 5,
903                    signing_key_id: 1,
904                    key_setup: SigningKeySetup::new(
905                        KeyType::Curve25519,
906                        vec![KeyMechanism::EdDsaSignature],
907                        None,
908                        SignatureType::EdDsa,
909                        CryptographicKeyContext::OpenPgp {
910                            notations: Default::default(),
911                            user_ids: OpenPgpUserIdList::new(vec![
912                                "Foobar McFooface <foobar@mcfooface.org>".parse()?,
913                            ])?,
914                            version: "v4".parse()?,
915                        },
916                    )?,
917                    ssh_authorized_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh96uFTnvX6P1ebbLxXFvy6sK7qFqlMHDOuJ0TmuXQQ user@host".parse()?,
918                    system_user: "yubihsm2-signing-user".parse()?,
919                    domain: Domain::One,
920                }
921            ]),
922        )?)
923    }
924
925    /// Creates a default [`Confi`].
926    #[fixture]
927    fn config(yubihsm2_config: TestResult<YubiHsm2Config>) -> TestResult<Config> {
928        Ok(ConfigBuilder::new(SystemConfig::new(
929            1,
930            AdministrativeSecretHandling::Plaintext,
931            NonAdministrativeSecretHandling::Plaintext,
932            BTreeSet::from_iter([]),
933        )?)
934        .set_yubihsm2_config(yubihsm2_config?)
935        .finish()?)
936    }
937
938    /// Creates a [`YubiHsm2Config`], without non-admin users.
939    #[fixture]
940    fn yubihsm2_config_no_non_admin_users() -> TestResult<YubiHsm2Config> {
941        Ok(YubiHsm2Config::new(
942            BTreeSet::from_iter([Connection::Mock]),
943            BTreeSet::from_iter([YubiHsm2UserMapping::Admin {
944                authentication_key_id: 1,
945            }]),
946        )?)
947    }
948
949    /// Creates [`YubiHsm2Config`] with fully mismatching user IDs (compared to the default).
950    #[fixture]
951    fn yubihsm2_config_fully_mismatching_ids() -> TestResult<YubiHsm2Config> {
952        Ok(YubiHsm2Config::new(
953            BTreeSet::from_iter([
954                Connection::Mock
955            ]),
956            BTreeSet::from_iter([
957                YubiHsm2UserMapping::Admin { authentication_key_id: 7 },
958                YubiHsm2UserMapping::AuditLog {
959                    authentication_key_id: 9,
960                    ssh_authorized_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPkpXKiNhy39A3bZ1u19a5d4sFwYMBkWQyCbzgUfdKBm user@host".parse()?,
961                    system_user: "yubihsm2-metrics-user".parse()?,
962                },
963                YubiHsm2UserMapping::Backup{
964                    authentication_key_id: 8,
965                    ssh_authorized_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOOCMo+ODRchqIiXm89TxF7avi+LXRtqWZdBAvJ1SG5g user@host".parse()?,
966                    system_user: "yubihsm2-backup-user".parse()?,
967                    wrapping_key_id: 1,
968                },
969                YubiHsm2UserMapping::HermeticAuditLog {
970                    authentication_key_id: 10,
971                    system_user: "yubihsm2-hermetic-metrics-user".parse()?,
972                },
973                YubiHsm2UserMapping::Signing {
974                    authentication_key_id: 11,
975                    signing_key_id: 2,
976                    key_setup: SigningKeySetup::new(
977                        KeyType::Curve25519,
978                        vec![KeyMechanism::EdDsaSignature],
979                        None,
980                        SignatureType::EdDsa,
981                        CryptographicKeyContext::OpenPgp {
982                            notations: Default::default(),
983                            user_ids: OpenPgpUserIdList::new(vec![
984                                "Foobar McFooface <foobar@mcfooface.org>".parse()?,
985                            ])?,
986                            version: "v4".parse()?,
987                        },
988                    )?,
989                    ssh_authorized_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh96uFTnvX6P1ebbLxXFvy6sK7qFqlMHDOuJ0TmuXQQ user@host".parse()?,
990                    system_user: "yubihsm2-signing-user".parse()?,
991                    domain: Domain::One,
992                }
993            ]),
994        )?)
995    }
996
997    /// Creates [`YubiHsm2Config`] with mismatching roles (compared to the default).
998    #[fixture]
999    fn yubihsm2_config_mismatching_roles() -> TestResult<YubiHsm2Config> {
1000        Ok(YubiHsm2Config::new(
1001            BTreeSet::from_iter([
1002                Connection::Mock
1003            ]),
1004            BTreeSet::from_iter([
1005                YubiHsm2UserMapping::Admin { authentication_key_id: 5 },
1006                YubiHsm2UserMapping::AuditLog {
1007                    authentication_key_id: 2,
1008                    ssh_authorized_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPkpXKiNhy39A3bZ1u19a5d4sFwYMBkWQyCbzgUfdKBm user@host".parse()?,
1009                    system_user: "yubihsm2-metrics-user".parse()?,
1010                },
1011                YubiHsm2UserMapping::Backup{
1012                    authentication_key_id: 3,
1013                    ssh_authorized_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOOCMo+ODRchqIiXm89TxF7avi+LXRtqWZdBAvJ1SG5g user@host".parse()?,
1014                    system_user: "yubihsm2-backup-user".parse()?,
1015                    wrapping_key_id: 1,
1016                },
1017                YubiHsm2UserMapping::HermeticAuditLog {
1018                    authentication_key_id: 4,
1019                    system_user: "yubihsm2-hermetic-metrics-user".parse()?,
1020                },
1021                YubiHsm2UserMapping::Signing {
1022                    authentication_key_id: 1,
1023                    signing_key_id: 1,
1024                    key_setup: SigningKeySetup::new(
1025                        KeyType::Curve25519,
1026                        vec![KeyMechanism::EdDsaSignature],
1027                        None,
1028                        SignatureType::EdDsa,
1029                        CryptographicKeyContext::OpenPgp {
1030                            notations: Default::default(),
1031                            user_ids: OpenPgpUserIdList::new(vec![
1032                                "Foobar McFooface <foobar@mcfooface.org>".parse()?,
1033                            ])?,
1034                            version: "v4".parse()?,
1035                        },
1036                    )?,
1037                    ssh_authorized_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh96uFTnvX6P1ebbLxXFvy6sK7qFqlMHDOuJ0TmuXQQ user@host".parse()?,
1038                    system_user: "yubihsm2-signing-user".parse()?,
1039                    domain: Domain::One,
1040                }
1041            ]),
1042        )?)
1043    }
1044
1045    /// Creates [`YubiHsm2Config`] with mismatching signing key ID (compared to the default).
1046    #[fixture]
1047    fn yubihsm2_config_mismatching_signing_key_id() -> TestResult<YubiHsm2Config> {
1048        Ok(YubiHsm2Config::new(
1049            BTreeSet::from_iter([
1050                Connection::Mock
1051            ]),
1052            BTreeSet::from_iter([
1053                YubiHsm2UserMapping::Admin { authentication_key_id: 1 },
1054                YubiHsm2UserMapping::AuditLog {
1055                    authentication_key_id: 3,
1056                    ssh_authorized_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPkpXKiNhy39A3bZ1u19a5d4sFwYMBkWQyCbzgUfdKBm user@host".parse()?,
1057                    system_user: "yubihsm2-metrics-user".parse()?,
1058                },
1059                YubiHsm2UserMapping::Backup{
1060                    authentication_key_id: 2,
1061                    ssh_authorized_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOOCMo+ODRchqIiXm89TxF7avi+LXRtqWZdBAvJ1SG5g user@host".parse()?,
1062                    system_user: "yubihsm2-backup-user".parse()?,
1063                    wrapping_key_id: 1,
1064                },
1065                YubiHsm2UserMapping::HermeticAuditLog {
1066                    authentication_key_id: 4,
1067                    system_user: "yubihsm2-hermetic-metrics-user".parse()?,
1068                },
1069                YubiHsm2UserMapping::Signing {
1070                    authentication_key_id: 5,
1071                    signing_key_id: 2,
1072                    key_setup: SigningKeySetup::new(
1073                        KeyType::Curve25519,
1074                        vec![KeyMechanism::EdDsaSignature],
1075                        None,
1076                        SignatureType::EdDsa,
1077                        CryptographicKeyContext::OpenPgp {
1078                            notations: Default::default(),
1079                            user_ids: OpenPgpUserIdList::new(vec![
1080                                "Foobar McFooface <foobar@mcfooface.org>".parse()?,
1081                            ])?,
1082                            version: "v4".parse()?,
1083                        },
1084                    )?,
1085                    ssh_authorized_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh96uFTnvX6P1ebbLxXFvy6sK7qFqlMHDOuJ0TmuXQQ user@host".parse()?,
1086                    system_user: "yubihsm2-signing-user".parse()?,
1087                    domain: Domain::One,
1088                }
1089            ]),
1090        )?)
1091    }
1092
1093    /// Creates a [`YubiHsm2Config`] with an additional signing key (compared to the default).
1094    #[fixture]
1095    fn yubihsm2_config_additional_signing_key() -> TestResult<YubiHsm2Config> {
1096        Ok(YubiHsm2Config::new(
1097            BTreeSet::from_iter([
1098                Connection::Mock
1099            ]),
1100            BTreeSet::from_iter([
1101                YubiHsm2UserMapping::Admin { authentication_key_id: 1 },
1102                YubiHsm2UserMapping::AuditLog {
1103                    authentication_key_id: 3,
1104                    ssh_authorized_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPkpXKiNhy39A3bZ1u19a5d4sFwYMBkWQyCbzgUfdKBm user@host".parse()?,
1105                    system_user: "yubihsm2-metrics-user".parse()?,
1106                },
1107                YubiHsm2UserMapping::Backup{
1108                    authentication_key_id: 2,
1109                    ssh_authorized_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOOCMo+ODRchqIiXm89TxF7avi+LXRtqWZdBAvJ1SG5g user@host".parse()?,
1110                    system_user: "yubihsm2-backup-user".parse()?,
1111                    wrapping_key_id: 1,
1112                },
1113                YubiHsm2UserMapping::HermeticAuditLog {
1114                    authentication_key_id: 4,
1115                    system_user: "yubihsm2-hermetic-metrics-user".parse()?,
1116                },
1117                YubiHsm2UserMapping::Signing {
1118                    authentication_key_id: 5,
1119                    signing_key_id: 1,
1120                    key_setup: SigningKeySetup::new(
1121                        KeyType::Curve25519,
1122                        vec![KeyMechanism::EdDsaSignature],
1123                        None,
1124                        SignatureType::EdDsa,
1125                        CryptographicKeyContext::OpenPgp {
1126                            notations: Default::default(),
1127                            user_ids: OpenPgpUserIdList::new(vec![
1128                                "Foobar McFooface <foobar@mcfooface.org>".parse()?,
1129                            ])?,
1130                            version: "v4".parse()?,
1131                        },
1132                    )?,
1133                    ssh_authorized_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh96uFTnvX6P1ebbLxXFvy6sK7qFqlMHDOuJ0TmuXQQ user@host".parse()?,
1134                    system_user: "yubihsm2-signing-user".parse()?,
1135                    domain: Domain::One,
1136                },
1137                YubiHsm2UserMapping::Signing {
1138                    authentication_key_id: 6,
1139                    signing_key_id: 2,
1140                    key_setup: SigningKeySetup::new(
1141                        KeyType::Curve25519,
1142                        vec![KeyMechanism::EdDsaSignature],
1143                        None,
1144                        SignatureType::EdDsa,
1145                        CryptographicKeyContext::OpenPgp {
1146                            notations: Default::default(),
1147                            user_ids: OpenPgpUserIdList::new(vec![
1148                                "Foobar McBehface <foobar@mcbehface.org>".parse()?,
1149                            ])?,
1150                            version: "v4".parse()?,
1151                        },
1152                    )?,
1153                    ssh_authorized_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGoUTEsi84KZGtD3jqSDbQxkLQPqSJsdc0mxQjODm/Oy user@host".parse()?,
1154                    system_user: "yubihsm2-signing-user-2".parse()?,
1155                    domain: Domain::Two,
1156                },
1157            ]),
1158        )?)
1159    }
1160
1161    /// Creates [`YubiHsm2Config`] with a mismatching signing key domain (compared to the default).
1162    #[fixture]
1163    fn yubihsm2_config_mismatching_signing_key_domain() -> TestResult<YubiHsm2Config> {
1164        Ok(YubiHsm2Config::new(
1165            BTreeSet::from_iter([
1166                Connection::Mock
1167            ]),
1168            BTreeSet::from_iter([
1169                YubiHsm2UserMapping::Admin { authentication_key_id: 1 },
1170                YubiHsm2UserMapping::AuditLog {
1171                    authentication_key_id: 3,
1172                    ssh_authorized_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPkpXKiNhy39A3bZ1u19a5d4sFwYMBkWQyCbzgUfdKBm user@host".parse()?,
1173                    system_user: "yubihsm2-metrics-user".parse()?,
1174                },
1175                YubiHsm2UserMapping::Backup{
1176                    authentication_key_id: 2,
1177                    ssh_authorized_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOOCMo+ODRchqIiXm89TxF7avi+LXRtqWZdBAvJ1SG5g user@host".parse()?,
1178                    system_user: "yubihsm2-backup-user".parse()?,
1179                    wrapping_key_id: 1,
1180                },
1181                YubiHsm2UserMapping::HermeticAuditLog {
1182                    authentication_key_id: 4,
1183                    system_user: "yubihsm2-hermetic-metrics-user".parse()?,
1184                },
1185                YubiHsm2UserMapping::Signing {
1186                    authentication_key_id: 5,
1187                    signing_key_id: 1,
1188                    key_setup: SigningKeySetup::new(
1189                        KeyType::Curve25519,
1190                        vec![KeyMechanism::EdDsaSignature],
1191                        None,
1192                        SignatureType::EdDsa,
1193                        CryptographicKeyContext::OpenPgp {
1194                            notations: Default::default(),
1195                            user_ids: OpenPgpUserIdList::new(vec![
1196                                "Foobar McFooface <foobar@mcfooface.org>".parse()?,
1197                            ])?,
1198                            version: "v4".parse()?,
1199                        },
1200                    )?,
1201                    ssh_authorized_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh96uFTnvX6P1ebbLxXFvy6sK7qFqlMHDOuJ0TmuXQQ user@host".parse()?,
1202                    system_user: "yubihsm2-signing-user".parse()?,
1203                    domain: Domain::Two,
1204                }
1205            ]),
1206        )?)
1207    }
1208
1209    #[fixture]
1210    fn yubihsm2_mappings() -> TestResult<[YubiHsm2UserMapping; 5]> {
1211        Ok([
1212                    YubiHsm2UserMapping::Admin { authentication_key_id: "1".parse()? },
1213                    YubiHsm2UserMapping::Backup{
1214                        authentication_key_id: "2".parse()?,
1215                        ssh_authorized_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh9BTe81DC6A0YZALsq9dWcyl6xjjqlxWPwlExTFgBt user@host".parse()?,
1216                        system_user: "backup-user".parse()?,
1217                        wrapping_key_id: "1".parse()?,
1218                    },
1219                    YubiHsm2UserMapping::AuditLog {
1220                        authentication_key_id: "3".parse()?,
1221                        ssh_authorized_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPkpXKiNhy39A3bZ1u19a5d4sFwYMBkWQyCbzgUfdKBm user@host".parse()?,
1222                        system_user: "metrics-user".parse()?,
1223                    },
1224                    YubiHsm2UserMapping::HermeticAuditLog {
1225                        authentication_key_id: "4".parse()?,
1226                        system_user: "hermetic-metrics".parse()?,
1227                    },
1228                    YubiHsm2UserMapping::Signing {
1229                        authentication_key_id: "5".parse()?,
1230                        signing_key_id: "1".parse()?,
1231                        key_setup: SigningKeySetup::new(
1232                            KeyType::Curve25519,
1233                            vec![KeyMechanism::EdDsaSignature],
1234                            None,
1235                            SignatureType::EdDsa,
1236                            CryptographicKeyContext::OpenPgp {
1237                                notations: Default::default(),
1238                                user_ids: OpenPgpUserIdList::new(vec![
1239                                    "Foobar McFooface <foobar@mcfooface.org>".parse()?,
1240                                ])?,
1241                                version: "v4".parse()?,
1242                            },
1243                        )?,
1244                        ssh_authorized_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh96uFTnvX6P1ebbLxXFvy6sK7qFqlMHDOuJ0TmuXQQ user@host".parse()?,
1245                        system_user: "signing-user".parse()?,
1246                        domain: Domain::One,
1247                    }
1248                ])
1249    }
1250
1251    /// Returns a set of default non-administrative credentials (used with the default YubiHSM2
1252    /// config).
1253    #[fixture]
1254    fn yubihsm2_non_admin_credentials() -> TestResult<Vec<Credentials>> {
1255        Ok(vec![
1256            Credentials::new("2".parse()?, Passphrase::generate(Some(50))),
1257            Credentials::new("3".parse()?, Passphrase::generate(Some(50))),
1258            Credentials::new("4".parse()?, Passphrase::generate(Some(50))),
1259            Credentials::new("5".parse()?, Passphrase::generate(Some(50))),
1260        ])
1261    }
1262
1263    /// A helper struct to expose [`Config`], [`Connector`] and [`YubiHsm2AdminCredentials`].
1264    struct ConfigConnectorCreds {
1265        pub config: Config,
1266        pub connector: Connector,
1267        pub admin_creds: YubiHsm2AdminCredentials,
1268    }
1269
1270    /// Creates a new, pre-populated YubiHSM2 backend and accompanying non-admin creds.
1271    ///
1272    /// The data in the backend is based on the default YubiHSM2 config and non-admin credentials
1273    /// fixtures.
1274    #[fixture]
1275    fn config_connector_creds(
1276        connector: Connector,
1277        config: TestResult<Config>,
1278        yubihsm2_admin_credentials: TestResult<YubiHsm2AdminCredentials>,
1279        yubihsm2_non_admin_credentials: TestResult<Vec<Credentials>>,
1280    ) -> TestResult<ConfigConnectorCreds> {
1281        let config = config?;
1282        let admin_creds = yubihsm2_admin_credentials?;
1283        let non_admin_creds = yubihsm2_non_admin_credentials?;
1284
1285        let Some(backend) = YubiHsm2Backend::new(connector.clone(), &admin_creds, &config)? else {
1286            panic!("No YubiHsm2Config in the provided Signstar config");
1287        };
1288        backend.sync(&non_admin_creds)?;
1289
1290        Ok(ConfigConnectorCreds {
1291            config,
1292            connector,
1293            admin_creds,
1294        })
1295    }
1296
1297    /// Ensures that [`YubiHsm2ConfigUserData`] is displayed correctly.
1298    #[rstest]
1299    #[case::single_cap_single_domain(
1300        AuthType::Signing,
1301        Capabilities::from(vec![Capability::SignEddsa].as_slice()),
1302        Domains::from(vec![Domain::One].as_slice()),
1303        "1 (auth type: signing; capabilities: sign-eddsa; domains: 1)"
1304    )]
1305    #[case::multi_cap_multi_domain(
1306        AuthType::Signing,
1307        Capabilities::from(vec![Capability::SignEddsa, Capability::SignEcdsa].as_slice()),
1308        Domains::from(vec![Domain::One, Domain::Two].as_slice()),
1309        "1 (auth type: signing; capabilities: sign-ecdsa, sign-eddsa; domains: 1, 2)"
1310    )]
1311    #[case::multi_cap_single_domain(
1312        AuthType::Signing,
1313        Capabilities::from(vec![Capability::SignEddsa, Capability::SignEcdsa].as_slice()),
1314        Domains::from(vec![Domain::One].as_slice()),
1315        "1 (auth type: signing; capabilities: sign-ecdsa, sign-eddsa; domains: 1)"
1316    )]
1317    fn yubihsm2_config_user_data_display(
1318        #[case] auth_type: AuthType,
1319        #[case] capabilities: Capabilities,
1320        #[case] domains: Domains,
1321        #[case] display: &str,
1322    ) -> TestResult {
1323        let data = YubiHsm2ConfigUserData {
1324            authentication_key_id: "1".parse()?,
1325            auth_type,
1326            capabilities,
1327            domains,
1328        };
1329
1330        assert_eq!(format!("{data}"), display);
1331
1332        Ok(())
1333    }
1334
1335    /// Ensures that [`YubiHsm2ConfigUserKeyData`] is displayed correctly.
1336    #[test]
1337    fn yubihsm2_config_user_key_data_display() -> TestResult {
1338        let capabilities = Capabilities::from(vec![Capability::SignEddsa].as_slice());
1339        let domain = Domain::One;
1340        let key_setup = SigningKeySetup::new(
1341            KeyType::Curve25519,
1342            vec![KeyMechanism::EdDsaSignature],
1343            None,
1344            SignatureType::EdDsa,
1345            CryptographicKeyContext::OpenPgp {
1346                notations: Default::default(),
1347                user_ids: vec!["John Doe <john.doe@example.org>".to_string()].try_into()?,
1348                version: "4".parse()?,
1349            },
1350        )?;
1351        let display = "1 (authentication: 1; capabilities: sign-eddsa; domain: 1; type: Curve25519; mechanisms: EdDsaSignature; context: OpenPGP (Version: 4; User IDs: \"John Doe <john.doe@example.org>\"))";
1352        let data = YubiHsm2ConfigUserKeyData {
1353            authentication_key_id: &"1".parse()?,
1354            signing_key_id: &"1".parse()?,
1355            capabilities,
1356            domain: &domain,
1357            key_setup: &key_setup,
1358        };
1359
1360        assert_eq!(data.to_string(), display);
1361
1362        Ok(())
1363    }
1364
1365    /// Ensures that [`YubiHsm2ConfigState`] can be created from [`YubiHsm2Config`].
1366    #[rstest]
1367    fn yubihsm2_config_state_from_yubihsm_config(
1368        yubihsm2_config: TestResult<YubiHsm2Config>,
1369        yubihsm2_mappings: TestResult<[YubiHsm2UserMapping; 5]>,
1370    ) -> TestResult {
1371        setup_logging(LevelFilter::Debug)?;
1372        let yubihsm2_config = yubihsm2_config?;
1373        let yubihsm2_mappings = yubihsm2_mappings?;
1374        let state = YubiHsm2ConfigState::from(&yubihsm2_config);
1375
1376        for authentication_key_id in yubihsm2_mappings
1377            .iter()
1378            .map(|mapping| mapping.backend_user_id())
1379        {
1380            debug!(
1381                "Ensuring that the YubiHSM2 authentication key ID {authentication_key_id} can be found in the YubiHSM2 config state."
1382            );
1383            assert!(
1384                state
1385                    .user_data
1386                    .iter()
1387                    .any(|user_data| user_data.authentication_key_id == authentication_key_id)
1388            );
1389        }
1390
1391        for (authentication_key_id, signing_key_id) in
1392            yubihsm2_mappings.iter().filter_map(|mapping| {
1393                if let YubiHsm2UserMapping::Signing {
1394                    authentication_key_id,
1395                    signing_key_id,
1396                    ..
1397                } = mapping
1398                {
1399                    Some((authentication_key_id, signing_key_id))
1400                } else {
1401                    None
1402                }
1403            })
1404        {
1405            debug!(
1406                "Ensuring that the YubiHSM2 authentication key ID {authentication_key_id} and signing key ID {signing_key_id} can be found in the YubiHSM2 config state."
1407            );
1408            assert!(
1409                state
1410                    .key_data
1411                    .iter()
1412                    .any(|data| data.authentication_key_id == authentication_key_id
1413                        && data.signing_key_id == signing_key_id)
1414            );
1415        }
1416
1417        Ok(())
1418    }
1419
1420    /// Ensures, that [`YubiHsm2ConfigState::state_name`] returns the correct data.
1421    #[rstest]
1422    fn yubihsm_config_state_state_name(yubihsm2_config: TestResult<YubiHsm2Config>) -> TestResult {
1423        setup_logging(LevelFilter::Debug)?;
1424        let yubihsm2_config = yubihsm2_config?;
1425        let state = YubiHsm2ConfigState::from(&yubihsm2_config);
1426
1427        assert_eq!(state.state_name(), YubiHsm2ConfigState::STATE_NAME);
1428
1429        Ok(())
1430    }
1431
1432    /// Ensures, that [`YubiHsm2ConfigState::state_name`] returns the correct data.
1433    #[rstest]
1434    fn yubihsm_config_state_state_origin(
1435        yubihsm2_config: TestResult<YubiHsm2Config>,
1436    ) -> TestResult {
1437        setup_logging(LevelFilter::Debug)?;
1438        let yubihsm2_config = yubihsm2_config?;
1439        let state = YubiHsm2ConfigState::from(&yubihsm2_config);
1440
1441        assert_eq!(state.state_origin(), StateOrigin::Config);
1442
1443        Ok(())
1444    }
1445
1446    /// Ensures that [`YubiHsm2Diff::diff`] fails on mismatching backend and config.
1447    #[rstest]
1448    #[case::no_non_admin_users(
1449        yubihsm2_config_no_non_admin_users()?,
1450        "State diff of pre-populated YubiHSM2 backend and mismatching YubiHSM2 config: No non-admin users.",
1451    )]
1452    #[case::fully_mismatching_user_ids(
1453        yubihsm2_config_fully_mismatching_ids()?,
1454        "State diff of pre-populated YubiHSM2 backend and mismatching YubiHSM2 config: Fully mismatching user IDs.",
1455    )]
1456    #[case::fully_mismatching_roles(
1457        yubihsm2_config_mismatching_roles()?,
1458        "State diff of pre-populated YubiHSM2 backend and mismatching YubiHSM2 config: Mismatching user roles.",
1459    )]
1460    #[case::mismatching_signing_key_id(
1461        yubihsm2_config_mismatching_signing_key_id()?,
1462        "State diff of pre-populated YubiHSM2 backend and mismatching YubiHSM2 config: Mismatching signing key ID.",
1463    )]
1464    #[case::mismatching_additional_signing_key(
1465        yubihsm2_config_additional_signing_key()?,
1466        "State diff of pre-populated YubiHSM2 backend and mismatching YubiHSM2 config: Additional signing key.",
1467    )]
1468    #[case::mismatching_mismatching_signing_key_domain(
1469        yubihsm2_config_mismatching_signing_key_domain()?,
1470        "State diff of pre-populated YubiHSM2 backend and mismatching YubiHSM2 config: Mismatching signing key domain.",
1471    )]
1472    fn yubihsm2_diff_diff_fails_on_mismatching_data(
1473        config_connector_creds: TestResult<ConfigConnectorCreds>,
1474        #[case] yubihsm2_config: YubiHsm2Config,
1475        #[case] description: &str,
1476    ) -> TestResult {
1477        let config_connector_creds = config_connector_creds?;
1478
1479        let Some(backend) = YubiHsm2Backend::new(
1480            config_connector_creds.connector.clone(),
1481            &config_connector_creds.admin_creds,
1482            &config_connector_creds.config,
1483        )?
1484        else {
1485            panic!("No YubiHsm2Config in the provided Signstar config");
1486        };
1487        let backend_state = YubiHsm2BackendState::try_from(&backend)?;
1488        let yubihsm2_config_state = YubiHsm2ConfigState::from(&yubihsm2_config);
1489
1490        let yubihsm2_diff = YubiHsm2Diff {
1491            config: &yubihsm2_config_state,
1492            backend: &backend_state,
1493        };
1494        let diff = yubihsm2_diff.diff();
1495        with_settings!({
1496            description => description,
1497            snapshot_path => SNAPSHOT_PATH,
1498            prepend_module_to_snapshot => false,
1499        }, {
1500            assert_snapshot!(current().name().expect("current thread should have a name").to_string().replace("::", "__"), diff);
1501        });
1502        info!("StateDiffReport:\n{diff}");
1503        assert!(matches!(diff, StateDiffReport::Failure { .. }));
1504
1505        Ok(())
1506    }
1507
1508    /// Ensures, that [`YubiHsm2Diff::diff`] fails on an unprovisioned backend.
1509    #[rstest]
1510    fn yubihsm2_diff_diff_fails_on_unprovisioned_backend(
1511        connector: Connector,
1512        yubihsm2_config: TestResult<YubiHsm2Config>,
1513        yubihsm2_admin_credentials: TestResult<YubiHsm2AdminCredentials>,
1514    ) -> TestResult {
1515        let yubihsm2_admin_credentials = yubihsm2_admin_credentials?;
1516        let yubihsm2_config = yubihsm2_config?;
1517        let config = ConfigBuilder::new(SystemConfig::new(
1518            1,
1519            AdministrativeSecretHandling::Plaintext,
1520            NonAdministrativeSecretHandling::Plaintext,
1521            BTreeSet::from_iter([]),
1522        )?)
1523        .set_yubihsm2_config(yubihsm2_config.clone())
1524        .finish()?;
1525        let Some(backend) =
1526            YubiHsm2Backend::new(connector.clone(), &yubihsm2_admin_credentials, &config)?
1527        else {
1528            panic!("No YubiHsm2Config in the provided Signstar config");
1529        };
1530        let backend_state = YubiHsm2BackendState::try_from(&backend)?;
1531        let yubihsm2_config_state = YubiHsm2ConfigState::from(&yubihsm2_config);
1532
1533        let yubihsm2_diff = YubiHsm2Diff {
1534            config: &yubihsm2_config_state,
1535            backend: &backend_state,
1536        };
1537        let diff = yubihsm2_diff.diff();
1538        info!("StateDiffReport:\n{diff}");
1539        assert!(matches!(diff, StateDiffReport::Failure { .. }));
1540
1541        with_settings!({
1542            description => "State diff of unprovisioned YubiHSM2 backend and YubiHSM2 config.",
1543            snapshot_path => SNAPSHOT_PATH,
1544            prepend_module_to_snapshot => false,
1545        }, {
1546            assert_snapshot!(current().name().expect("current thread should have a name").to_string().replace("::", "__"), diff);
1547        });
1548
1549        Ok(())
1550    }
1551
1552    /// Ensures, that [`YubiHsm2Diff::diff`] fails on a provisioned backend, where the wrap key has
1553    /// a mismatching key ID.
1554    #[rstest]
1555    fn yubihsm2_diff_diff_fails_on_mismatching_wrap_key_id(
1556        config_connector_creds: TestResult<ConfigConnectorCreds>,
1557    ) -> TestResult {
1558        let config_connector_creds = config_connector_creds?;
1559        let config = config_connector_creds.config;
1560
1561        // Delete the default wrap key.
1562        let client = Client::open(
1563            config_connector_creds.connector.clone(),
1564            config_connector_creds
1565                .admin_creds
1566                .administrators()
1567                .first()
1568                .expect("there to be at least one administrator credentials")
1569                .into(),
1570            false,
1571        )?;
1572        client.delete_object(YubiHsm2Config::WRAP_KEY_ID, (&ObjectType::WrapKey).into())?;
1573
1574        // Add a new wrap key, with mismatching key ID.
1575        let passphrase = config_connector_creds.admin_creds.backup_passphrase();
1576        let wrapping_key: WrapKey =
1577            WrapKeyFromPassphrase::new(passphrase, WrapKeyKind::Aes256)?.try_into()?;
1578        client.put_wrap_key(
1579            2,
1580            (&Label::from_truncated_str("foo")).into(),
1581            (&Domains::all()).into(),
1582            (&Capabilities::from(YubiHsm2UserMapping::CAP_BACKUP)).into(),
1583            (&Capabilities::from(YubiHsm2UserMapping::CAP_BACKUP)).into(),
1584            (&WrapKeyKind::default()).into(),
1585            &wrapping_key,
1586        )?;
1587
1588        // Diff config and backend state.
1589        let Some(backend) = YubiHsm2Backend::new(
1590            config_connector_creds.connector.clone(),
1591            &config_connector_creds.admin_creds,
1592            &config,
1593        )?
1594        else {
1595            panic!("No YubiHsm2Config in the provided Signstar config");
1596        };
1597        let backend_state = YubiHsm2BackendState::try_from(&backend)?;
1598
1599        let Some(yubihsm2_config) = config.yubihsm2() else {
1600            panic!("No YubiHsm2Config in the provided Signstar config");
1601        };
1602        let yubihsm2_config_state = YubiHsm2ConfigState::from(yubihsm2_config);
1603
1604        let yubihsm2_diff = YubiHsm2Diff {
1605            config: &yubihsm2_config_state,
1606            backend: &backend_state,
1607        };
1608        let diff = yubihsm2_diff.diff();
1609        info!("StateDiffReport:\n{diff}");
1610        assert!(matches!(diff, StateDiffReport::Failure { .. }));
1611
1612        with_settings!({
1613            description => "State diff of provisioned YubiHSM2 backend and YubiHSM2 config: Mismatching wrap key ID.",
1614            snapshot_path => SNAPSHOT_PATH,
1615            prepend_module_to_snapshot => false,
1616        }, {
1617            assert_snapshot!(current().name().expect("current thread should have a name").to_string().replace("::", "__"), diff);
1618        });
1619
1620        Ok(())
1621    }
1622
1623    /// Ensures, that [`YubiHsm2Diff::diff`] succeeds (against a MockHSM backend).
1624    #[rstest]
1625    fn yubihsm2_diff_diff_succeeds(
1626        connector: Connector,
1627        yubihsm2_admin_credentials: TestResult<YubiHsm2AdminCredentials>,
1628        yubihsm2_non_admin_credentials: TestResult<Vec<Credentials>>,
1629        config: TestResult<Config>,
1630    ) -> TestResult {
1631        setup_logging(LevelFilter::Debug)?;
1632        let yubihsm2_admin_credentials = yubihsm2_admin_credentials?;
1633        let yubihsm2_non_admin_credentials = yubihsm2_non_admin_credentials?;
1634        let config = config?;
1635
1636        let Some(yubihsm2_config) = config.yubihsm2() else {
1637            panic!("There should be a YubiHSM2 config");
1638        };
1639        let yubihsm2_config_state = YubiHsm2ConfigState::from(yubihsm2_config);
1640        let Some(backend) = YubiHsm2Backend::new(connector, &yubihsm2_admin_credentials, &config)?
1641        else {
1642            panic!("No YubiHsm2Config in the provided Signstar config");
1643        };
1644
1645        let backend_state = YubiHsm2BackendState::try_from(&backend)?;
1646        let yubihsm2_diff = YubiHsm2Diff {
1647            config: &yubihsm2_config_state,
1648            backend: &backend_state,
1649        };
1650        let diff = yubihsm2_diff.diff();
1651        info!("StateDiffReport:\n{diff}");
1652        assert!(matches!(diff, StateDiffReport::Failure { .. }));
1653
1654        backend.sync(&yubihsm2_non_admin_credentials)?;
1655
1656        let backend_state = YubiHsm2BackendState::try_from(&backend)?;
1657        let yubihsm2_diff = YubiHsm2Diff {
1658            config: &yubihsm2_config_state,
1659            backend: &backend_state,
1660        };
1661        let diff = yubihsm2_diff.diff();
1662        info!("StateDiffReport:\n{diff}");
1663        assert!(matches!(diff, StateDiffReport::Success));
1664
1665        // Re-run the sync
1666        backend.sync(&yubihsm2_non_admin_credentials)?;
1667
1668        let backend_state = YubiHsm2BackendState::try_from(&backend)?;
1669        let yubihsm2_diff = YubiHsm2Diff {
1670            config: &yubihsm2_config_state,
1671            backend: &backend_state,
1672        };
1673        let diff = yubihsm2_diff.diff();
1674        info!("StateDiffReport:\n{diff}");
1675        assert!(matches!(diff, StateDiffReport::Success));
1676
1677        Ok(())
1678    }
1679}