Skip to main content

passless_rs/storage/
credential.rs

1//! Storage credential types with stable serialization
2//!
3//! These types are used **only for disk storage** and never exposed outside this module.
4//! The runtime always uses `soft_fido2::Credential`.
5//!
6//! ## Storage Format
7//!
8//! Our format is frozen and will never change:
9//! - Vec<u8> fields as CBOR arrays of integers (not byte strings)
10//! - snake_case field names (not camelCase)
11//! - Based on original soft-fido2 pre-serde_bytes format
12//!
13//! This decouples storage from soft-fido2's serialization changes.
14//!
15//! ## Architecture
16//!
17//! ```text
18//! ┌─────────────────┐
19//! │  Authenticator  │ ← soft_fido2::Credential (runtime)
20//! └────────┬────────┘
21//!          │
22//!   read() returns soft_fido2::Credential
23//!          │
24//! ┌────────▼────────┐
25//! │Storage Backends │ ← Credential (disk only)
26//! │ local/pass/tpm  │   • from_bytes() to read
27//! └─────────────────┘   • to_bytes() to write
28//! ```
29
30use soft_fido2_ctap::SecBytes;
31
32use serde::{Deserialize, Serialize};
33
34/// Relying Party information (storage-only)
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36pub struct RelyingParty<'a> {
37    /// Relying party identifier (e.g., "example.com")
38    #[serde(borrow)]
39    pub id: std::borrow::Cow<'a, str>,
40
41    /// Human-readable name (optional)
42    #[serde(borrow)]
43    pub name: Option<std::borrow::Cow<'a, str>>,
44}
45
46impl<'a> RelyingParty<'a> {
47    /// Create a new RelyingParty from a soft_fido2 RelyingParty (zero-copy)
48    #[inline]
49    pub fn from_soft_fido2(rp: &'a soft_fido2_ctap::types::RelyingParty) -> Self {
50        Self {
51            id: std::borrow::Cow::Borrowed(&rp.id),
52            name: rp
53                .name
54                .as_ref()
55                .map(|n| std::borrow::Cow::Borrowed(n.as_str())),
56        }
57    }
58
59    /// Convert to owned soft_fido2 RelyingParty
60    pub fn to_soft_fido2(&self) -> soft_fido2_ctap::types::RelyingParty {
61        soft_fido2_ctap::types::RelyingParty {
62            id: self.id.to_string(),
63            name: self.name.as_ref().map(|n| n.to_string()),
64        }
65    }
66
67    /// Convert to owned version
68    #[allow(dead_code)]
69    pub fn into_owned(self) -> RelyingParty<'static> {
70        RelyingParty {
71            id: std::borrow::Cow::Owned(self.id.into_owned()),
72            name: self.name.map(|n| std::borrow::Cow::Owned(n.into_owned())),
73        }
74    }
75}
76
77/// User information (storage-only)
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79pub struct User<'a> {
80    #[serde(borrow)]
81    pub id: std::borrow::Cow<'a, [u8]>,
82
83    #[serde(borrow)]
84    pub name: Option<std::borrow::Cow<'a, str>>,
85
86    #[serde(borrow)]
87    pub display_name: Option<std::borrow::Cow<'a, str>>,
88}
89
90impl<'a> User<'a> {
91    /// Create a new User from a soft_fido2 User (zero-copy)
92    #[inline]
93    pub fn from_soft_fido2(user: &'a soft_fido2_ctap::types::User) -> Self {
94        Self {
95            id: std::borrow::Cow::Borrowed(&user.id),
96            name: user
97                .name
98                .as_ref()
99                .map(|n| std::borrow::Cow::Borrowed(n.as_str())),
100            display_name: user
101                .display_name
102                .as_ref()
103                .map(|n| std::borrow::Cow::Borrowed(n.as_str())),
104        }
105    }
106
107    /// Convert to owned soft_fido2 User
108    pub fn to_soft_fido2(&self) -> soft_fido2_ctap::types::User {
109        soft_fido2_ctap::types::User {
110            id: self.id.to_vec(),
111            name: self.name.as_ref().map(|n| n.to_string()),
112            display_name: self.display_name.as_ref().map(|n| n.to_string()),
113        }
114    }
115
116    /// Convert to owned version
117    #[allow(dead_code)]
118    pub fn into_owned(self) -> User<'static> {
119        User {
120            id: std::borrow::Cow::Owned(self.id.into_owned()),
121            name: self.name.map(|n| std::borrow::Cow::Owned(n.into_owned())),
122            display_name: self
123                .display_name
124                .map(|n| std::borrow::Cow::Owned(n.into_owned())),
125        }
126    }
127}
128
129/// Credential extension data
130#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
131pub struct Extensions {
132    /// Credential protection level
133    pub cred_protect: Option<u8>,
134    /// HMAC secret extension
135    pub hmac_secret: Option<bool>,
136    /// HMAC secret credential random (32 bytes)
137    /// Used to compute HMAC outputs for the hmac-secret extension
138    #[serde(skip_serializing_if = "Option::is_none", default)]
139    pub cred_random: Option<Vec<u8>>,
140}
141
142/// Storage credential (internal to storage module)
143///
144/// Zero-copy wrapper for disk serialization. Never exposed outside storage.
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146pub struct Credential<'a> {
147    #[serde(borrow)]
148    pub id: std::borrow::Cow<'a, [u8]>,
149
150    #[serde(borrow)]
151    pub rp: RelyingParty<'a>,
152
153    #[serde(borrow)]
154    pub user: User<'a>,
155
156    pub sign_count: u32,
157
158    pub alg: i32,
159
160    #[serde(with = "sec_bytes_serde")]
161    pub private_key: SecBytes,
162
163    #[serde(default)]
164    pub key_provider: Option<Vec<u8>>,
165
166    #[serde(default)]
167    pub key_format_version: Option<u16>,
168
169    pub created: i64,
170
171    pub discoverable: bool,
172
173    /// Backup eligibility and current backup state.
174    #[serde(default)]
175    pub backup_state: soft_fido2::CredentialBackupState,
176
177    #[serde(default)]
178    pub extensions: Extensions,
179}
180
181impl<'a> Credential<'a> {
182    /// Create a new Credential from a soft_fido2::Credential (zero-copy)
183    #[inline]
184    pub fn from_soft_fido2(cred: &'a soft_fido2::Credential) -> Self {
185        let is_software = cred.key.provider.is_software();
186        Self {
187            id: std::borrow::Cow::Borrowed(&cred.id),
188            rp: RelyingParty::from_soft_fido2(&cred.rp),
189            user: User::from_soft_fido2(&cred.user),
190            sign_count: cred.sign_count,
191            alg: cred.alg,
192            private_key: cred.key.material.clone(),
193            key_provider: if is_software {
194                None
195            } else {
196                Some(cred.key.provider.as_bytes().to_vec())
197            },
198            key_format_version: if is_software {
199                None
200            } else {
201                Some(cred.key.format_version)
202            },
203            created: cred.created,
204            discoverable: cred.discoverable,
205            backup_state: cred.backup_state,
206            extensions: Extensions {
207                cred_protect: cred.extensions.cred_protect,
208                hmac_secret: cred.extensions.hmac_secret,
209                cred_random: cred.extensions.cred_random.as_ref().map(|cr| cr.to_vec()),
210            },
211        }
212    }
213
214    /// Create a new Credential from a soft_fido2::CredentialRef (zero-copy)
215    #[inline]
216    #[allow(dead_code)]
217    pub fn from_soft_fido2_ref(cred_ref: soft_fido2::CredentialRef<'a>) -> Self {
218        let is_software = cred_ref.key.provider.is_software();
219        Self {
220            id: std::borrow::Cow::Borrowed(cred_ref.id),
221            rp: RelyingParty {
222                id: std::borrow::Cow::Borrowed(cred_ref.rp_id),
223                name: cred_ref.rp_name.map(std::borrow::Cow::Borrowed),
224            },
225            user: User {
226                id: std::borrow::Cow::Borrowed(cred_ref.user_id),
227                name: cred_ref.user_name.map(std::borrow::Cow::Borrowed),
228                display_name: cred_ref.user_display_name.map(std::borrow::Cow::Borrowed),
229            },
230            sign_count: *cred_ref.sign_count,
231            alg: *cred_ref.alg,
232            private_key: cred_ref.key.material.clone(),
233            key_provider: if is_software {
234                None
235            } else {
236                Some(cred_ref.key.provider.as_bytes().to_vec())
237            },
238            key_format_version: if is_software {
239                None
240            } else {
241                Some(cred_ref.key.format_version)
242            },
243            created: *cred_ref.created,
244            discoverable: *cred_ref.discoverable,
245            backup_state: *cred_ref.backup_state,
246            extensions: Extensions {
247                cred_protect: cred_ref.cred_protect.copied(),
248                hmac_secret: None,
249                cred_random: cred_ref.cred_random.map(|cr| cr.to_vec()),
250            },
251        }
252    }
253
254    /// Convert to owned soft_fido2::Credential
255    pub fn to_soft_fido2(&self) -> soft_fido2::Credential {
256        let key = match (&self.key_provider, self.key_format_version) {
257            (Some(provider_bytes), Some(version)) => soft_fido2_ctap::CredentialKey::new(
258                soft_fido2_ctap::CredentialKeyProviderId::new(provider_bytes),
259                version,
260                self.private_key.clone(),
261            ),
262            _ => soft_fido2_ctap::CredentialKey::software(self.private_key.clone()),
263        };
264
265        soft_fido2::Credential {
266            id: self.id.to_vec(),
267            rp: self.rp.to_soft_fido2(),
268            user: self.user.to_soft_fido2(),
269            sign_count: self.sign_count,
270            alg: self.alg,
271            key,
272            created: self.created,
273            discoverable: self.discoverable,
274            backup_state: self.backup_state,
275            extensions: soft_fido2::Extensions {
276                cred_protect: self.extensions.cred_protect,
277                hmac_secret: self.extensions.hmac_secret,
278                cred_random: self
279                    .extensions
280                    .cred_random
281                    .as_ref()
282                    .map(|cr| soft_fido2_ctap::SecBytes::from_slice(cr)),
283            },
284        }
285    }
286
287    /// Convert to owned version
288    #[allow(dead_code)]
289    pub fn into_owned(self) -> Credential<'static> {
290        Credential {
291            id: std::borrow::Cow::Owned(self.id.into_owned()),
292            rp: self.rp.into_owned(),
293            user: self.user.into_owned(),
294            sign_count: self.sign_count,
295            alg: self.alg,
296            private_key: self.private_key,
297            key_provider: self.key_provider,
298            key_format_version: self.key_format_version,
299            created: self.created,
300            discoverable: self.discoverable,
301            backup_state: self.backup_state,
302            extensions: self.extensions,
303        }
304    }
305
306    /// Serialize to our stable storage format
307    pub fn to_bytes(&self) -> Result<Vec<u8>, soft_fido2::Error> {
308        let mut buf = Vec::new();
309        soft_fido2_ctap::cbor::into_writer(self, &mut buf).map_err(|_| soft_fido2::Error::Other)?;
310        Ok(buf)
311    }
312
313    /// Deserialize from our stable storage format
314    ///
315    /// Format: Vec<u8> as CBOR arrays of integers (original soft-fido2 pre-serde_bytes format)
316    pub fn from_bytes(data: &[u8]) -> Result<Credential<'static>, soft_fido2::Error> {
317        mod flexible_backup_state {
318            use serde::de::{self, Visitor};
319
320            pub fn deserialize<'de, D>(
321                deserializer: D,
322            ) -> Result<soft_fido2::CredentialBackupState, D::Error>
323            where
324                D: de::Deserializer<'de>,
325            {
326                struct BackupStateVisitor;
327
328                impl<'de> Visitor<'de> for BackupStateVisitor {
329                    type Value = soft_fido2::CredentialBackupState;
330
331                    fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
332                        f.write_str("a boolean or camelCase string")
333                    }
334
335                    fn visit_bool<E>(self, v: bool) -> Result<Self::Value, E> {
336                        Ok(if v {
337                            soft_fido2::CredentialBackupState::BackedUp
338                        } else {
339                            soft_fido2::CredentialBackupState::NotEligible
340                        })
341                    }
342
343                    fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
344                        match v {
345                            "notEligible" => Ok(soft_fido2::CredentialBackupState::NotEligible),
346                            "eligible" => Ok(soft_fido2::CredentialBackupState::Eligible),
347                            "backedUp" => Ok(soft_fido2::CredentialBackupState::BackedUp),
348                            _ => Err(de::Error::unknown_variant(
349                                v,
350                                &["notEligible", "eligible", "backedUp"],
351                            )),
352                        }
353                    }
354                }
355
356                deserializer.deserialize_any(BackupStateVisitor)
357            }
358        }
359
360        #[derive(serde::Deserialize)]
361        struct OwnedCredential {
362            #[serde(deserialize_with = "flexible_bytes::deserialize")]
363            id: Vec<u8>,
364            rp: OwnedRelyingParty,
365            user: OwnedUser,
366            sign_count: u32,
367            alg: i32,
368            #[serde(deserialize_with = "flexible_bytes::deserialize")]
369            private_key: Vec<u8>,
370            #[serde(default)]
371            key_provider: Option<Vec<u8>>,
372            #[serde(default)]
373            key_format_version: Option<u16>,
374            created: i64,
375            discoverable: bool,
376            #[serde(default, deserialize_with = "flexible_backup_state::deserialize")]
377            backup_state: soft_fido2::CredentialBackupState,
378            #[serde(default)]
379            extensions: Extensions,
380        }
381
382        #[derive(serde::Deserialize)]
383        struct OwnedRelyingParty {
384            id: String,
385            name: Option<String>,
386        }
387
388        #[derive(serde::Deserialize)]
389        struct OwnedUser {
390            #[serde(deserialize_with = "flexible_bytes::deserialize")]
391            id: Vec<u8>,
392            name: Option<String>,
393            display_name: Option<String>,
394        }
395
396        let owned: OwnedCredential =
397            soft_fido2_ctap::cbor::decode(data).map_err(|_| soft_fido2::Error::Other)?;
398
399        Ok(Credential {
400            id: std::borrow::Cow::Owned(owned.id),
401            rp: RelyingParty {
402                id: std::borrow::Cow::Owned(owned.rp.id),
403                name: owned.rp.name.map(std::borrow::Cow::Owned),
404            },
405            user: User {
406                id: std::borrow::Cow::Owned(owned.user.id),
407                name: owned.user.name.map(std::borrow::Cow::Owned),
408                display_name: owned.user.display_name.map(std::borrow::Cow::Owned),
409            },
410            sign_count: owned.sign_count,
411            alg: owned.alg,
412            private_key: SecBytes::new(owned.private_key),
413            key_provider: owned.key_provider,
414            key_format_version: owned.key_format_version,
415            created: owned.created,
416            discoverable: owned.discoverable,
417            backup_state: owned.backup_state,
418            extensions: owned.extensions,
419        })
420    }
421}
422
423/// Flexible Vec<u8> deserializer
424///
425/// Accepts both CBOR byte strings and arrays of integers.
426/// This ensures compatibility with the original soft-fido2 format.
427mod flexible_bytes {
428    use serde::de::{Deserializer, SeqAccess, Visitor};
429
430    struct BytesVisitor;
431
432    impl<'de> Visitor<'de> for BytesVisitor {
433        type Value = Vec<u8>;
434
435        fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
436            formatter.write_str("byte string or array of integers")
437        }
438
439        fn visit_bytes<E>(self, v: &[u8]) -> Result<Self::Value, E>
440        where
441            E: serde::de::Error,
442        {
443            Ok(v.to_vec())
444        }
445
446        fn visit_byte_buf<E>(self, v: Vec<u8>) -> Result<Self::Value, E>
447        where
448            E: serde::de::Error,
449        {
450            Ok(v)
451        }
452
453        fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
454        where
455            A: SeqAccess<'de>,
456        {
457            let mut bytes = Vec::new();
458            while let Some(value) = seq.next_element::<u8>()? {
459                bytes.push(value);
460            }
461            Ok(bytes)
462        }
463    }
464
465    pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
466    where
467        D: Deserializer<'de>,
468    {
469        deserializer.deserialize_any(BytesVisitor)
470    }
471}
472
473/// SecBytes serialization (as CBOR array)
474mod sec_bytes_serde {
475    use soft_fido2_ctap::SecBytes;
476
477    use serde::{Deserializer, Serialize, Serializer};
478
479    pub fn serialize<S>(sec_bytes: &SecBytes, serializer: S) -> Result<S::Ok, S::Error>
480    where
481        S: Serializer,
482    {
483        let bytes: Vec<u8> = sec_bytes.as_slice().to_vec();
484        bytes.serialize(serializer)
485    }
486
487    pub fn deserialize<'de, D>(deserializer: D) -> Result<SecBytes, D::Error>
488    where
489        D: Deserializer<'de>,
490    {
491        let bytes = super::flexible_bytes::deserialize(deserializer)?;
492        Ok(SecBytes::new(bytes))
493    }
494}
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499
500    #[test]
501    fn test_relying_party_zero_copy() {
502        let soft_rp = soft_fido2_ctap::types::RelyingParty {
503            id: "example.com".to_string(),
504            name: Some("Example".to_string()),
505        };
506
507        let our_rp = RelyingParty::from_soft_fido2(&soft_rp);
508
509        assert_eq!(our_rp.id, "example.com");
510        assert_eq!(our_rp.name.as_ref().map(|s| s.as_ref()), Some("Example"));
511
512        // Verify zero-copy by checking pointer equality
513        match our_rp.id {
514            std::borrow::Cow::Borrowed(s) => assert_eq!(s, soft_rp.id.as_str()),
515            std::borrow::Cow::Owned(_) => panic!("Expected borrowed data"),
516        }
517    }
518
519    #[test]
520    fn test_user_zero_copy() {
521        let soft_user = soft_fido2_ctap::types::User {
522            id: vec![1, 2, 3, 4],
523            name: Some("user@example.com".to_string()),
524            display_name: Some("User Name".to_string()),
525        };
526
527        let our_user = User::from_soft_fido2(&soft_user);
528
529        assert_eq!(our_user.id.as_ref(), &[1, 2, 3, 4]);
530        assert_eq!(
531            our_user.name.as_ref().map(|s| s.as_ref()),
532            Some("user@example.com")
533        );
534
535        // Verify zero-copy by checking pointer equality
536        match our_user.id {
537            std::borrow::Cow::Borrowed(s) => assert_eq!(s, soft_user.id.as_slice()),
538            std::borrow::Cow::Owned(_) => panic!("Expected borrowed data"),
539        }
540    }
541
542    #[test]
543    fn test_credential_serialization_roundtrip() {
544        let soft_cred = soft_fido2::Credential {
545            id: vec![1, 2, 3, 4],
546            rp: soft_fido2_ctap::types::RelyingParty {
547                id: "example.com".to_string(),
548                name: Some("Example".to_string()),
549            },
550            user: soft_fido2_ctap::types::User {
551                id: vec![5, 6, 7, 8],
552                name: Some("user@example.com".to_string()),
553                display_name: Some("User Name".to_string()),
554            },
555            sign_count: 42,
556            alg: -7,
557            key: soft_fido2_ctap::CredentialKey::software(SecBytes::new(vec![0u8; 32])),
558            created: 1234567890,
559            discoverable: true,
560            backup_state: soft_fido2::CredentialBackupState::NotEligible,
561            extensions: soft_fido2::Extensions {
562                cred_protect: Some(1),
563                hmac_secret: None,
564                cred_random: None,
565            },
566        };
567
568        let our_cred = Credential::from_soft_fido2(&soft_cred);
569
570        let our_bytes = our_cred.to_bytes().expect("Serialization should succeed");
571        let deserialized =
572            Credential::from_bytes(&our_bytes).expect("Should deserialize our format");
573
574        // Verify all fields match
575        assert_eq!(deserialized.id.as_ref(), &[1, 2, 3, 4]);
576        assert_eq!(deserialized.rp.id, "example.com");
577        assert_eq!(deserialized.user.id.as_ref(), &[5, 6, 7, 8]);
578        assert_eq!(deserialized.sign_count, 42);
579        assert_eq!(deserialized.alg, -7);
580        assert_eq!(deserialized.created, 1234567890);
581        assert!(deserialized.discoverable);
582        assert_eq!(
583            deserialized.backup_state,
584            soft_fido2::CredentialBackupState::NotEligible
585        );
586    }
587
588    #[test]
589    fn test_minimal_credential_roundtrip() {
590        let our_cred = Credential {
591            id: std::borrow::Cow::Owned(vec![1, 2, 3, 4]),
592            rp: RelyingParty {
593                id: std::borrow::Cow::Owned("example.com".to_string()),
594                name: None,
595            },
596            user: User {
597                id: std::borrow::Cow::Owned(vec![5, 6, 7, 8]),
598                name: Some(std::borrow::Cow::Owned("user".to_string())),
599                display_name: None,
600            },
601            sign_count: 0,
602            alg: -7,
603            private_key: SecBytes::new(vec![0u8; 32]),
604            key_provider: None,
605            key_format_version: None,
606            created: 0,
607            discoverable: true,
608            backup_state: soft_fido2::CredentialBackupState::NotEligible,
609            extensions: Extensions::default(),
610        };
611
612        let our_bytes = our_cred.to_bytes().expect("Should serialize our format");
613        let deserialized =
614            Credential::from_bytes(&our_bytes).expect("Should deserialize our format");
615
616        assert_eq!(deserialized.id.as_ref(), &[1, 2, 3, 4]);
617        assert_eq!(deserialized.rp.id, "example.com");
618        assert_eq!(deserialized.user.id.as_ref(), &[5, 6, 7, 8]);
619    }
620
621    #[test]
622    fn test_serialization_roundtrip() {
623        // Create a credential
624        let soft_cred = soft_fido2::Credential {
625            id: vec![1, 2, 3, 4],
626            rp: soft_fido2_ctap::types::RelyingParty {
627                id: "example.com".to_string(),
628                name: Some("Example".to_string()),
629            },
630            user: soft_fido2_ctap::types::User {
631                id: vec![5, 6, 7, 8],
632                name: Some("user@example.com".to_string()),
633                display_name: Some("User Name".to_string()),
634            },
635            sign_count: 42,
636            alg: -7,
637            key: soft_fido2_ctap::CredentialKey::software(SecBytes::new(vec![0u8; 32])),
638            created: 1234567890,
639            discoverable: true,
640            backup_state: soft_fido2::CredentialBackupState::NotEligible,
641            extensions: soft_fido2::Extensions {
642                cred_protect: Some(1),
643                hmac_secret: None,
644                cred_random: None,
645            },
646        };
647
648        // Serialize with our format
649        let our_cred = Credential::from_soft_fido2(&soft_cred);
650        let our_bytes = our_cred
651            .to_bytes()
652            .expect("Should be able to serialize our credential format");
653
654        // Deserialize and verify
655        let deserialized =
656            Credential::from_bytes(&our_bytes).expect("Should deserialize our own format");
657
658        assert_eq!(deserialized.id.as_ref(), &[1, 2, 3, 4]);
659        assert_eq!(deserialized.rp.id, "example.com");
660        assert_eq!(deserialized.user.id.as_ref(), &[5, 6, 7, 8]);
661        assert_eq!(
662            deserialized.user.name.as_ref().map(|s| s.as_ref()),
663            Some("user@example.com")
664        );
665        assert_eq!(
666            deserialized.user.display_name.as_ref().map(|s| s.as_ref()),
667            Some("User Name")
668        );
669        assert_eq!(deserialized.sign_count, 42);
670        assert_eq!(deserialized.alg, -7);
671        assert_eq!(deserialized.created, 1234567890);
672        assert!(deserialized.discoverable);
673    }
674}