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_terminal_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-audit-log".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".parse()?,
895                },
896                YubiHsm2UserMapping::HermeticAuditLog {
897                    authentication_key_id: 4,
898                    system_user: "yubihsm2-hermetic-audit-log".parse()?,
899                },
900                YubiHsm2UserMapping::Signing {
901                    authentication_key_id: 5,
902                    signing_key_id: 1,
903                    key_setup: SigningKeySetup::new(
904                        KeyType::Curve25519,
905                        vec![KeyMechanism::EdDsaSignature],
906                        None,
907                        SignatureType::EdDsa,
908                        CryptographicKeyContext::OpenPgp {
909                            notations: Default::default(),
910                            user_ids: OpenPgpUserIdList::new(vec![
911                                "Foobar McFooface <foobar@mcfooface.org>".parse()?,
912                            ])?,
913                            version: "v4".parse()?,
914                        },
915                    )?,
916                    ssh_authorized_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh96uFTnvX6P1ebbLxXFvy6sK7qFqlMHDOuJ0TmuXQQ user@host".parse()?,
917                    system_user: "yubihsm2-signing".parse()?,
918                    domain: Domain::One,
919                }
920            ]),
921        )?)
922    }
923
924    /// Creates a default [`Confi`].
925    #[fixture]
926    fn config(yubihsm2_config: TestResult<YubiHsm2Config>) -> TestResult<Config> {
927        Ok(ConfigBuilder::new(SystemConfig::new(
928            1,
929            AdministrativeSecretHandling::Plaintext,
930            NonAdministrativeSecretHandling::Plaintext,
931            BTreeSet::from_iter([]),
932        )?)
933        .set_yubihsm2_config(yubihsm2_config?)
934        .finish()?)
935    }
936
937    /// Creates a [`YubiHsm2Config`], without non-admin users.
938    #[fixture]
939    fn yubihsm2_config_no_non_admin_users() -> TestResult<YubiHsm2Config> {
940        Ok(YubiHsm2Config::new(
941            BTreeSet::from_iter([Connection::Mock]),
942            BTreeSet::from_iter([YubiHsm2UserMapping::Admin {
943                authentication_key_id: 1,
944            }]),
945        )?)
946    }
947
948    /// Creates [`YubiHsm2Config`] with fully mismatching user IDs (compared to the default).
949    #[fixture]
950    fn yubihsm2_config_fully_mismatching_ids() -> TestResult<YubiHsm2Config> {
951        Ok(YubiHsm2Config::new(
952            BTreeSet::from_iter([
953                Connection::Mock
954            ]),
955            BTreeSet::from_iter([
956                YubiHsm2UserMapping::Admin { authentication_key_id: 7 },
957                YubiHsm2UserMapping::AuditLog {
958                    authentication_key_id: 9,
959                    ssh_authorized_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPkpXKiNhy39A3bZ1u19a5d4sFwYMBkWQyCbzgUfdKBm user@host".parse()?,
960                    system_user: "yubihsm2-audit-log".parse()?,
961                },
962                YubiHsm2UserMapping::Backup{
963                    authentication_key_id: 8,
964                    ssh_authorized_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOOCMo+ODRchqIiXm89TxF7avi+LXRtqWZdBAvJ1SG5g user@host".parse()?,
965                    system_user: "yubihsm2-backup".parse()?,
966                },
967                YubiHsm2UserMapping::HermeticAuditLog {
968                    authentication_key_id: 10,
969                    system_user: "yubihsm2-hermetic-audit-log".parse()?,
970                },
971                YubiHsm2UserMapping::Signing {
972                    authentication_key_id: 11,
973                    signing_key_id: 2,
974                    key_setup: SigningKeySetup::new(
975                        KeyType::Curve25519,
976                        vec![KeyMechanism::EdDsaSignature],
977                        None,
978                        SignatureType::EdDsa,
979                        CryptographicKeyContext::OpenPgp {
980                            notations: Default::default(),
981                            user_ids: OpenPgpUserIdList::new(vec![
982                                "Foobar McFooface <foobar@mcfooface.org>".parse()?,
983                            ])?,
984                            version: "v4".parse()?,
985                        },
986                    )?,
987                    ssh_authorized_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh96uFTnvX6P1ebbLxXFvy6sK7qFqlMHDOuJ0TmuXQQ user@host".parse()?,
988                    system_user: "yubihsm2-signing".parse()?,
989                    domain: Domain::One,
990                }
991            ]),
992        )?)
993    }
994
995    /// Creates [`YubiHsm2Config`] with mismatching roles (compared to the default).
996    #[fixture]
997    fn yubihsm2_config_mismatching_roles() -> TestResult<YubiHsm2Config> {
998        Ok(YubiHsm2Config::new(
999            BTreeSet::from_iter([
1000                Connection::Mock
1001            ]),
1002            BTreeSet::from_iter([
1003                YubiHsm2UserMapping::Admin { authentication_key_id: 5 },
1004                YubiHsm2UserMapping::AuditLog {
1005                    authentication_key_id: 2,
1006                    ssh_authorized_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPkpXKiNhy39A3bZ1u19a5d4sFwYMBkWQyCbzgUfdKBm user@host".parse()?,
1007                    system_user: "yubihsm2-audit-log".parse()?,
1008                },
1009                YubiHsm2UserMapping::Backup{
1010                    authentication_key_id: 3,
1011                    ssh_authorized_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOOCMo+ODRchqIiXm89TxF7avi+LXRtqWZdBAvJ1SG5g user@host".parse()?,
1012                    system_user: "yubihsm2-backup".parse()?,
1013                },
1014                YubiHsm2UserMapping::HermeticAuditLog {
1015                    authentication_key_id: 4,
1016                    system_user: "yubihsm2-hermetic-audit-log".parse()?,
1017                },
1018                YubiHsm2UserMapping::Signing {
1019                    authentication_key_id: 1,
1020                    signing_key_id: 1,
1021                    key_setup: SigningKeySetup::new(
1022                        KeyType::Curve25519,
1023                        vec![KeyMechanism::EdDsaSignature],
1024                        None,
1025                        SignatureType::EdDsa,
1026                        CryptographicKeyContext::OpenPgp {
1027                            notations: Default::default(),
1028                            user_ids: OpenPgpUserIdList::new(vec![
1029                                "Foobar McFooface <foobar@mcfooface.org>".parse()?,
1030                            ])?,
1031                            version: "v4".parse()?,
1032                        },
1033                    )?,
1034                    ssh_authorized_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh96uFTnvX6P1ebbLxXFvy6sK7qFqlMHDOuJ0TmuXQQ user@host".parse()?,
1035                    system_user: "yubihsm2-signing".parse()?,
1036                    domain: Domain::One,
1037                }
1038            ]),
1039        )?)
1040    }
1041
1042    /// Creates [`YubiHsm2Config`] with mismatching signing key ID (compared to the default).
1043    #[fixture]
1044    fn yubihsm2_config_mismatching_signing_key_id() -> TestResult<YubiHsm2Config> {
1045        Ok(YubiHsm2Config::new(
1046            BTreeSet::from_iter([
1047                Connection::Mock
1048            ]),
1049            BTreeSet::from_iter([
1050                YubiHsm2UserMapping::Admin { authentication_key_id: 1 },
1051                YubiHsm2UserMapping::AuditLog {
1052                    authentication_key_id: 3,
1053                    ssh_authorized_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPkpXKiNhy39A3bZ1u19a5d4sFwYMBkWQyCbzgUfdKBm user@host".parse()?,
1054                    system_user: "yubihsm2-audit-log".parse()?,
1055                },
1056                YubiHsm2UserMapping::Backup{
1057                    authentication_key_id: 2,
1058                    ssh_authorized_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOOCMo+ODRchqIiXm89TxF7avi+LXRtqWZdBAvJ1SG5g user@host".parse()?,
1059                    system_user: "yubihsm2-backup".parse()?,
1060                },
1061                YubiHsm2UserMapping::HermeticAuditLog {
1062                    authentication_key_id: 4,
1063                    system_user: "yubihsm2-hermetic-audit-log".parse()?,
1064                },
1065                YubiHsm2UserMapping::Signing {
1066                    authentication_key_id: 5,
1067                    signing_key_id: 2,
1068                    key_setup: SigningKeySetup::new(
1069                        KeyType::Curve25519,
1070                        vec![KeyMechanism::EdDsaSignature],
1071                        None,
1072                        SignatureType::EdDsa,
1073                        CryptographicKeyContext::OpenPgp {
1074                            notations: Default::default(),
1075                            user_ids: OpenPgpUserIdList::new(vec![
1076                                "Foobar McFooface <foobar@mcfooface.org>".parse()?,
1077                            ])?,
1078                            version: "v4".parse()?,
1079                        },
1080                    )?,
1081                    ssh_authorized_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh96uFTnvX6P1ebbLxXFvy6sK7qFqlMHDOuJ0TmuXQQ user@host".parse()?,
1082                    system_user: "yubihsm2-signing".parse()?,
1083                    domain: Domain::One,
1084                }
1085            ]),
1086        )?)
1087    }
1088
1089    /// Creates a [`YubiHsm2Config`] with an additional signing key (compared to the default).
1090    #[fixture]
1091    fn yubihsm2_config_additional_signing_key() -> TestResult<YubiHsm2Config> {
1092        Ok(YubiHsm2Config::new(
1093            BTreeSet::from_iter([
1094                Connection::Mock
1095            ]),
1096            BTreeSet::from_iter([
1097                YubiHsm2UserMapping::Admin { authentication_key_id: 1 },
1098                YubiHsm2UserMapping::AuditLog {
1099                    authentication_key_id: 3,
1100                    ssh_authorized_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPkpXKiNhy39A3bZ1u19a5d4sFwYMBkWQyCbzgUfdKBm user@host".parse()?,
1101                    system_user: "yubihsm2-audit-log".parse()?,
1102                },
1103                YubiHsm2UserMapping::Backup{
1104                    authentication_key_id: 2,
1105                    ssh_authorized_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOOCMo+ODRchqIiXm89TxF7avi+LXRtqWZdBAvJ1SG5g user@host".parse()?,
1106                    system_user: "yubihsm2-backup".parse()?,
1107                },
1108                YubiHsm2UserMapping::HermeticAuditLog {
1109                    authentication_key_id: 4,
1110                    system_user: "yubihsm2-hermetic-audit-log".parse()?,
1111                },
1112                YubiHsm2UserMapping::Signing {
1113                    authentication_key_id: 5,
1114                    signing_key_id: 1,
1115                    key_setup: SigningKeySetup::new(
1116                        KeyType::Curve25519,
1117                        vec![KeyMechanism::EdDsaSignature],
1118                        None,
1119                        SignatureType::EdDsa,
1120                        CryptographicKeyContext::OpenPgp {
1121                            notations: Default::default(),
1122                            user_ids: OpenPgpUserIdList::new(vec![
1123                                "Foobar McFooface <foobar@mcfooface.org>".parse()?,
1124                            ])?,
1125                            version: "v4".parse()?,
1126                        },
1127                    )?,
1128                    ssh_authorized_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh96uFTnvX6P1ebbLxXFvy6sK7qFqlMHDOuJ0TmuXQQ user@host".parse()?,
1129                    system_user: "yubihsm2-signing".parse()?,
1130                    domain: Domain::One,
1131                },
1132                YubiHsm2UserMapping::Signing {
1133                    authentication_key_id: 6,
1134                    signing_key_id: 2,
1135                    key_setup: SigningKeySetup::new(
1136                        KeyType::Curve25519,
1137                        vec![KeyMechanism::EdDsaSignature],
1138                        None,
1139                        SignatureType::EdDsa,
1140                        CryptographicKeyContext::OpenPgp {
1141                            notations: Default::default(),
1142                            user_ids: OpenPgpUserIdList::new(vec![
1143                                "Foobar McBehface <foobar@mcbehface.org>".parse()?,
1144                            ])?,
1145                            version: "v4".parse()?,
1146                        },
1147                    )?,
1148                    ssh_authorized_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGoUTEsi84KZGtD3jqSDbQxkLQPqSJsdc0mxQjODm/Oy user@host".parse()?,
1149                    system_user: "yubihsm2-signing2".parse()?,
1150                    domain: Domain::Two,
1151                },
1152            ]),
1153        )?)
1154    }
1155
1156    /// Creates [`YubiHsm2Config`] with a mismatching signing key domain (compared to the default).
1157    #[fixture]
1158    fn yubihsm2_config_mismatching_signing_key_domain() -> TestResult<YubiHsm2Config> {
1159        Ok(YubiHsm2Config::new(
1160            BTreeSet::from_iter([
1161                Connection::Mock
1162            ]),
1163            BTreeSet::from_iter([
1164                YubiHsm2UserMapping::Admin { authentication_key_id: 1 },
1165                YubiHsm2UserMapping::AuditLog {
1166                    authentication_key_id: 3,
1167                    ssh_authorized_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPkpXKiNhy39A3bZ1u19a5d4sFwYMBkWQyCbzgUfdKBm user@host".parse()?,
1168                    system_user: "yubihsm2-audit-log".parse()?,
1169                },
1170                YubiHsm2UserMapping::Backup{
1171                    authentication_key_id: 2,
1172                    ssh_authorized_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOOCMo+ODRchqIiXm89TxF7avi+LXRtqWZdBAvJ1SG5g user@host".parse()?,
1173                    system_user: "yubihsm2-backup".parse()?,
1174                },
1175                YubiHsm2UserMapping::HermeticAuditLog {
1176                    authentication_key_id: 4,
1177                    system_user: "yubihsm2-hermetic-audit-log".parse()?,
1178                },
1179                YubiHsm2UserMapping::Signing {
1180                    authentication_key_id: 5,
1181                    signing_key_id: 1,
1182                    key_setup: SigningKeySetup::new(
1183                        KeyType::Curve25519,
1184                        vec![KeyMechanism::EdDsaSignature],
1185                        None,
1186                        SignatureType::EdDsa,
1187                        CryptographicKeyContext::OpenPgp {
1188                            notations: Default::default(),
1189                            user_ids: OpenPgpUserIdList::new(vec![
1190                                "Foobar McFooface <foobar@mcfooface.org>".parse()?,
1191                            ])?,
1192                            version: "v4".parse()?,
1193                        },
1194                    )?,
1195                    ssh_authorized_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh96uFTnvX6P1ebbLxXFvy6sK7qFqlMHDOuJ0TmuXQQ user@host".parse()?,
1196                    system_user: "yubihsm2-signing".parse()?,
1197                    domain: Domain::Two,
1198                }
1199            ]),
1200        )?)
1201    }
1202
1203    #[fixture]
1204    fn yubihsm2_mappings() -> TestResult<[YubiHsm2UserMapping; 5]> {
1205        Ok([
1206                    YubiHsm2UserMapping::Admin { authentication_key_id: "1".parse()? },
1207                    YubiHsm2UserMapping::Backup{
1208                        authentication_key_id: "2".parse()?,
1209                        ssh_authorized_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh9BTe81DC6A0YZALsq9dWcyl6xjjqlxWPwlExTFgBt user@host".parse()?,
1210                        system_user: "yubihsm2-backup".parse()?,
1211                    },
1212                    YubiHsm2UserMapping::AuditLog {
1213                        authentication_key_id: "3".parse()?,
1214                        ssh_authorized_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPkpXKiNhy39A3bZ1u19a5d4sFwYMBkWQyCbzgUfdKBm user@host".parse()?,
1215                        system_user: "yubihsm2-audit-log".parse()?,
1216                    },
1217                    YubiHsm2UserMapping::HermeticAuditLog {
1218                        authentication_key_id: "4".parse()?,
1219                        system_user: "yubihsm2-hermetic-audit-log".parse()?,
1220                    },
1221                    YubiHsm2UserMapping::Signing {
1222                        authentication_key_id: "5".parse()?,
1223                        signing_key_id: "1".parse()?,
1224                        key_setup: SigningKeySetup::new(
1225                            KeyType::Curve25519,
1226                            vec![KeyMechanism::EdDsaSignature],
1227                            None,
1228                            SignatureType::EdDsa,
1229                            CryptographicKeyContext::OpenPgp {
1230                                notations: Default::default(),
1231                                user_ids: OpenPgpUserIdList::new(vec![
1232                                    "Foobar McFooface <foobar@mcfooface.org>".parse()?,
1233                                ])?,
1234                                version: "v4".parse()?,
1235                            },
1236                        )?,
1237                        ssh_authorized_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh96uFTnvX6P1ebbLxXFvy6sK7qFqlMHDOuJ0TmuXQQ user@host".parse()?,
1238                        system_user: "yubihsm2-signing".parse()?,
1239                        domain: Domain::One,
1240                    }
1241                ])
1242    }
1243
1244    /// Returns a set of default non-administrative credentials (used with the default YubiHSM2
1245    /// config).
1246    #[fixture]
1247    fn yubihsm2_non_admin_credentials() -> TestResult<Vec<Credentials>> {
1248        Ok(vec![
1249            Credentials::new("2".parse()?, Passphrase::generate(Some(50))),
1250            Credentials::new("3".parse()?, Passphrase::generate(Some(50))),
1251            Credentials::new("4".parse()?, Passphrase::generate(Some(50))),
1252            Credentials::new("5".parse()?, Passphrase::generate(Some(50))),
1253        ])
1254    }
1255
1256    /// A helper struct to expose [`Config`], [`Connector`] and [`YubiHsm2AdminCredentials`].
1257    struct ConfigConnectorCreds {
1258        pub config: Config,
1259        pub connector: Connector,
1260        pub admin_creds: YubiHsm2AdminCredentials,
1261    }
1262
1263    /// Creates a new, pre-populated YubiHSM2 backend and accompanying non-admin creds.
1264    ///
1265    /// The data in the backend is based on the default YubiHSM2 config and non-admin credentials
1266    /// fixtures.
1267    #[fixture]
1268    fn config_connector_creds(
1269        connector: Connector,
1270        config: TestResult<Config>,
1271        yubihsm2_admin_credentials: TestResult<YubiHsm2AdminCredentials>,
1272        yubihsm2_non_admin_credentials: TestResult<Vec<Credentials>>,
1273    ) -> TestResult<ConfigConnectorCreds> {
1274        let config = config?;
1275        let admin_creds = yubihsm2_admin_credentials?;
1276        let non_admin_creds = yubihsm2_non_admin_credentials?;
1277
1278        let Some(backend) = YubiHsm2Backend::new(connector.clone(), &admin_creds, &config)? else {
1279            panic!("No YubiHsm2Config in the provided Signstar config");
1280        };
1281        backend.sync(&non_admin_creds)?;
1282
1283        Ok(ConfigConnectorCreds {
1284            config,
1285            connector,
1286            admin_creds,
1287        })
1288    }
1289
1290    /// Ensures that [`YubiHsm2ConfigUserData`] is displayed correctly.
1291    #[rstest]
1292    #[case::single_cap_single_domain(
1293        AuthType::Signing,
1294        Capabilities::from(vec![Capability::SignEddsa].as_slice()),
1295        Domains::from(vec![Domain::One].as_slice()),
1296        "1 (auth type: signing; capabilities: sign-eddsa; domains: 1)"
1297    )]
1298    #[case::multi_cap_multi_domain(
1299        AuthType::Signing,
1300        Capabilities::from(vec![Capability::SignEddsa, Capability::SignEcdsa].as_slice()),
1301        Domains::from(vec![Domain::One, Domain::Two].as_slice()),
1302        "1 (auth type: signing; capabilities: sign-ecdsa, sign-eddsa; domains: 1, 2)"
1303    )]
1304    #[case::multi_cap_single_domain(
1305        AuthType::Signing,
1306        Capabilities::from(vec![Capability::SignEddsa, Capability::SignEcdsa].as_slice()),
1307        Domains::from(vec![Domain::One].as_slice()),
1308        "1 (auth type: signing; capabilities: sign-ecdsa, sign-eddsa; domains: 1)"
1309    )]
1310    fn yubihsm2_config_user_data_display(
1311        #[case] auth_type: AuthType,
1312        #[case] capabilities: Capabilities,
1313        #[case] domains: Domains,
1314        #[case] display: &str,
1315    ) -> TestResult {
1316        let data = YubiHsm2ConfigUserData {
1317            authentication_key_id: "1".parse()?,
1318            auth_type,
1319            capabilities,
1320            domains,
1321        };
1322
1323        assert_eq!(format!("{data}"), display);
1324
1325        Ok(())
1326    }
1327
1328    /// Ensures that [`YubiHsm2ConfigUserKeyData`] is displayed correctly.
1329    #[test]
1330    fn yubihsm2_config_user_key_data_display() -> TestResult {
1331        let capabilities = Capabilities::from(vec![Capability::SignEddsa].as_slice());
1332        let domain = Domain::One;
1333        let key_setup = SigningKeySetup::new(
1334            KeyType::Curve25519,
1335            vec![KeyMechanism::EdDsaSignature],
1336            None,
1337            SignatureType::EdDsa,
1338            CryptographicKeyContext::OpenPgp {
1339                notations: Default::default(),
1340                user_ids: vec!["John Doe <john.doe@example.org>".to_string()].try_into()?,
1341                version: "4".parse()?,
1342            },
1343        )?;
1344        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>\"))";
1345        let data = YubiHsm2ConfigUserKeyData {
1346            authentication_key_id: &"1".parse()?,
1347            signing_key_id: &"1".parse()?,
1348            capabilities,
1349            domain: &domain,
1350            key_setup: &key_setup,
1351        };
1352
1353        assert_eq!(data.to_string(), display);
1354
1355        Ok(())
1356    }
1357
1358    /// Ensures that [`YubiHsm2ConfigState`] can be created from [`YubiHsm2Config`].
1359    #[rstest]
1360    fn yubihsm2_config_state_from_yubihsm_config(
1361        yubihsm2_config: TestResult<YubiHsm2Config>,
1362        yubihsm2_mappings: TestResult<[YubiHsm2UserMapping; 5]>,
1363    ) -> TestResult {
1364        setup_terminal_logging(LevelFilter::Debug)?;
1365        let yubihsm2_config = yubihsm2_config?;
1366        let yubihsm2_mappings = yubihsm2_mappings?;
1367        let state = YubiHsm2ConfigState::from(&yubihsm2_config);
1368
1369        for authentication_key_id in yubihsm2_mappings
1370            .iter()
1371            .map(|mapping| mapping.backend_user_id())
1372        {
1373            debug!(
1374                "Ensuring that the YubiHSM2 authentication key ID {authentication_key_id} can be found in the YubiHSM2 config state."
1375            );
1376            assert!(
1377                state
1378                    .user_data
1379                    .iter()
1380                    .any(|user_data| user_data.authentication_key_id == authentication_key_id)
1381            );
1382        }
1383
1384        for (authentication_key_id, signing_key_id) in
1385            yubihsm2_mappings.iter().filter_map(|mapping| {
1386                if let YubiHsm2UserMapping::Signing {
1387                    authentication_key_id,
1388                    signing_key_id,
1389                    ..
1390                } = mapping
1391                {
1392                    Some((authentication_key_id, signing_key_id))
1393                } else {
1394                    None
1395                }
1396            })
1397        {
1398            debug!(
1399                "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."
1400            );
1401            assert!(
1402                state
1403                    .key_data
1404                    .iter()
1405                    .any(|data| data.authentication_key_id == authentication_key_id
1406                        && data.signing_key_id == signing_key_id)
1407            );
1408        }
1409
1410        Ok(())
1411    }
1412
1413    /// Ensures, that [`YubiHsm2ConfigState::state_name`] returns the correct data.
1414    #[rstest]
1415    fn yubihsm_config_state_state_name(yubihsm2_config: TestResult<YubiHsm2Config>) -> TestResult {
1416        setup_terminal_logging(LevelFilter::Debug)?;
1417        let yubihsm2_config = yubihsm2_config?;
1418        let state = YubiHsm2ConfigState::from(&yubihsm2_config);
1419
1420        assert_eq!(state.state_name(), YubiHsm2ConfigState::STATE_NAME);
1421
1422        Ok(())
1423    }
1424
1425    /// Ensures, that [`YubiHsm2ConfigState::state_name`] returns the correct data.
1426    #[rstest]
1427    fn yubihsm_config_state_state_origin(
1428        yubihsm2_config: TestResult<YubiHsm2Config>,
1429    ) -> TestResult {
1430        setup_terminal_logging(LevelFilter::Debug)?;
1431        let yubihsm2_config = yubihsm2_config?;
1432        let state = YubiHsm2ConfigState::from(&yubihsm2_config);
1433
1434        assert_eq!(state.state_origin(), StateOrigin::Config);
1435
1436        Ok(())
1437    }
1438
1439    /// Ensures that [`YubiHsm2Diff::diff`] fails on mismatching backend and config.
1440    #[rstest]
1441    #[case::no_non_admin_users(
1442        yubihsm2_config_no_non_admin_users()?,
1443        "State diff of pre-populated YubiHSM2 backend and mismatching YubiHSM2 config: No non-admin users.",
1444    )]
1445    #[case::fully_mismatching_user_ids(
1446        yubihsm2_config_fully_mismatching_ids()?,
1447        "State diff of pre-populated YubiHSM2 backend and mismatching YubiHSM2 config: Fully mismatching user IDs.",
1448    )]
1449    #[case::fully_mismatching_roles(
1450        yubihsm2_config_mismatching_roles()?,
1451        "State diff of pre-populated YubiHSM2 backend and mismatching YubiHSM2 config: Mismatching user roles.",
1452    )]
1453    #[case::mismatching_signing_key_id(
1454        yubihsm2_config_mismatching_signing_key_id()?,
1455        "State diff of pre-populated YubiHSM2 backend and mismatching YubiHSM2 config: Mismatching signing key ID.",
1456    )]
1457    #[case::mismatching_additional_signing_key(
1458        yubihsm2_config_additional_signing_key()?,
1459        "State diff of pre-populated YubiHSM2 backend and mismatching YubiHSM2 config: Additional signing key.",
1460    )]
1461    #[case::mismatching_mismatching_signing_key_domain(
1462        yubihsm2_config_mismatching_signing_key_domain()?,
1463        "State diff of pre-populated YubiHSM2 backend and mismatching YubiHSM2 config: Mismatching signing key domain.",
1464    )]
1465    fn yubihsm2_diff_diff_fails_on_mismatching_data(
1466        config_connector_creds: TestResult<ConfigConnectorCreds>,
1467        #[case] yubihsm2_config: YubiHsm2Config,
1468        #[case] description: &str,
1469    ) -> TestResult {
1470        let config_connector_creds = config_connector_creds?;
1471
1472        let Some(backend) = YubiHsm2Backend::new(
1473            config_connector_creds.connector.clone(),
1474            &config_connector_creds.admin_creds,
1475            &config_connector_creds.config,
1476        )?
1477        else {
1478            panic!("No YubiHsm2Config in the provided Signstar config");
1479        };
1480        let backend_state = YubiHsm2BackendState::try_from(&backend)?;
1481        let yubihsm2_config_state = YubiHsm2ConfigState::from(&yubihsm2_config);
1482
1483        let yubihsm2_diff = YubiHsm2Diff {
1484            config: &yubihsm2_config_state,
1485            backend: &backend_state,
1486        };
1487        let diff = yubihsm2_diff.diff();
1488        with_settings!({
1489            description => description,
1490            snapshot_path => SNAPSHOT_PATH,
1491            prepend_module_to_snapshot => false,
1492            // NOTE: This filter prevents that the snapshot contains potentially misaligned data.
1493            //
1494            // With OpenPGPv4, multi-precision integers (MPIs) may be optimized in such a way, that certificate sizes in the backend may be up to 3 bytes lower, than the ones calculated using the Signstar configuration data.
1495            // This in turn would lead to sporadic cargo-insta failures.
1496            //
1497            // See <https://www.rfc-editor.org/info/rfc4880/#section-3.2> for details on MPIs in OpenPGPv4.
1498            filters => vec![(r"length: \d+", "length: [DYNAMIC LENGTH WITH OPENPGPv4]")]
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_terminal_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
1680    // Ensures, that a [`YubiHsm2BackendState`] can be successfully compared to a
1681    // [`YubiHsm2ConfigState`], even if the former contains an OpenPGP certificate with an optimized
1682    // MPI, resulting in a slightly smaller certificate size.
1683}