Skip to main content

inferadb_ledger_types/types/
credentials.rs

1//! User credential types: passkeys, TOTP, recovery codes.
2
3use std::fmt;
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7
8use super::{PrimaryAuthMethod, UserCredentialId, UserId, UserSlug};
9
10// ============================================================================
11// Credential Type Discriminator
12// ============================================================================
13
14/// Discriminator for credential types stored in Ledger.
15///
16/// Each variant corresponds to a distinct authentication mechanism.
17/// Email-code authentication is not a credential type — it's the
18/// built-in primary auth method.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum CredentialType {
22    /// WebAuthn passkey (FIDO2). Stores public key material.
23    Passkey,
24    /// Time-based One-Time Password (RFC 6238). At most one per user.
25    Totp,
26    /// One-time recovery codes for TOTP bypass. At most one set per user.
27    RecoveryCode,
28}
29
30impl fmt::Display for CredentialType {
31    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
32        match self {
33            Self::Passkey => write!(f, "passkey"),
34            Self::Totp => write!(f, "totp"),
35            Self::RecoveryCode => write!(f, "recovery_code"),
36        }
37    }
38}
39
40// ============================================================================
41// TOTP Types
42// ============================================================================
43
44/// TOTP hash algorithm (RFC 6238 §5.2).
45///
46/// SHA1 has the widest authenticator compatibility and is the default.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49pub enum TotpAlgorithm {
50    /// HMAC-SHA1 (default, widest authenticator compatibility).
51    #[default]
52    Sha1,
53    /// HMAC-SHA256.
54    Sha256,
55    /// HMAC-SHA512.
56    Sha512,
57}
58
59impl fmt::Display for TotpAlgorithm {
60    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61        match self {
62            Self::Sha1 => write!(f, "sha1"),
63            Self::Sha256 => write!(f, "sha256"),
64            Self::Sha512 => write!(f, "sha512"),
65        }
66    }
67}
68
69// ============================================================================
70// Credential Data Structs
71// ============================================================================
72
73/// WebAuthn passkey credential data.
74///
75/// Stores the public key material and metadata from a WebAuthn
76/// registration ceremony. The private key never leaves the
77/// authenticator device.
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79pub struct PasskeyCredential {
80    /// WebAuthn credential ID (opaque, authenticator-generated).
81    pub credential_id: Vec<u8>,
82    /// COSE-encoded public key for signature verification.
83    pub public_key: Vec<u8>,
84    /// Monotonic counter for replay protection (updated on each use).
85    pub sign_count: u32,
86    /// Transport hints from the authenticator (e.g., "internal", "usb", "ble", "nfc").
87    pub transports: Vec<String>,
88    /// Whether the credential is eligible for multi-device sync.
89    pub backup_eligible: bool,
90    /// Whether the credential is currently synced across devices.
91    pub backup_state: bool,
92    /// Attestation statement format (e.g., "packed", "tpm"), if provided.
93    pub attestation_format: Option<String>,
94    /// Authenticator Attestation GUID identifying the authenticator model
95    /// (e.g., YubiKey 5, Touch ID). Used for policy enforcement and admin
96    /// visibility. `None` if the authenticator did not provide an AAGUID.
97    pub aaguid: Option<[u8; 16]>,
98}
99
100/// TOTP credential data (RFC 6238).
101///
102/// The `secret` field is the shared HMAC key used to generate time-based
103/// codes. It is wrapped in [`Zeroizing<Vec<u8>>`](zeroize::Zeroizing) to
104/// clear memory on drop. Secrets are write-once — never returned after
105/// initial creation.
106#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
107pub struct TotpCredential {
108    /// HMAC secret (RFC 6238). Zeroed from memory on drop.
109    /// Typically 20 bytes for SHA1, 32 for SHA256, or 64 for SHA512.
110    #[serde(with = "zeroize_vec_serde")]
111    pub secret: zeroize::Zeroizing<Vec<u8>>,
112    /// Hash algorithm for TOTP computation.
113    #[serde(default)]
114    pub algorithm: TotpAlgorithm,
115    /// Number of digits in the generated code (standard: 6).
116    #[serde(default = "default_totp_digits")]
117    pub digits: u8,
118    /// Time step in seconds (standard: 30).
119    #[serde(default = "default_totp_period")]
120    pub period: u32,
121}
122
123impl fmt::Debug for TotpCredential {
124    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125        f.debug_struct("TotpCredential")
126            .field("secret", &"[REDACTED]")
127            .field("algorithm", &self.algorithm)
128            .field("digits", &self.digits)
129            .field("period", &self.period)
130            .finish()
131    }
132}
133
134const fn default_totp_digits() -> u8 {
135    6
136}
137
138const fn default_totp_period() -> u32 {
139    30
140}
141
142/// Recovery code credential data.
143///
144/// Stores SHA-256 hashes of one-time use recovery codes. Each code
145/// is consumed atomically — its hash is removed from the list on use.
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
147pub struct RecoveryCodeCredential {
148    /// SHA-256 hashes of unused recovery codes.
149    pub code_hashes: Vec<[u8; 32]>,
150    /// Original number of codes generated (e.g., 10).
151    pub total_generated: u8,
152}
153
154/// Type-specific credential data stored alongside a [`UserCredential`].
155///
156/// Uses serde's default externally-tagged representation for postcard
157/// compatibility (internally-tagged enums are not supported by postcard).
158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
159#[serde(rename_all = "snake_case")]
160pub enum CredentialData {
161    /// WebAuthn passkey data.
162    Passkey(PasskeyCredential),
163    /// TOTP shared secret and parameters.
164    Totp(TotpCredential),
165    /// Recovery code hashes.
166    RecoveryCode(RecoveryCodeCredential),
167}
168
169impl CredentialData {
170    /// Returns the [`CredentialType`] discriminator for this data variant.
171    pub fn credential_type(&self) -> CredentialType {
172        match self {
173            Self::Passkey(_) => CredentialType::Passkey,
174            Self::Totp(_) => CredentialType::Totp,
175            Self::RecoveryCode(_) => CredentialType::RecoveryCode,
176        }
177    }
178}
179
180/// A user authentication credential stored in Ledger.
181///
182/// Each credential has an independent lifecycle — passkeys track
183/// `sign_count`, recovery codes are consumed individually, and TOTP
184/// credentials are immutable after creation (delete and re-create
185/// to change).
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
187pub struct UserCredential {
188    /// Unique credential identifier.
189    pub id: UserCredentialId,
190    /// Owning user.
191    pub user: UserId,
192    /// High-level credential type discriminator.
193    pub credential_type: CredentialType,
194    /// Type-specific credential data.
195    pub credential_data: CredentialData,
196    /// Human-readable label (e.g., "MacBook Touch ID", "Authenticator app").
197    pub name: String,
198    /// Whether this credential is active. Disabled credentials are skipped
199    /// during authentication but preserved for audit.
200    pub enabled: bool,
201    /// When this credential was registered.
202    pub created_at: DateTime<Utc>,
203    /// Last successful authentication using this credential.
204    pub last_used_at: Option<DateTime<Utc>>,
205}
206
207/// Ephemeral challenge created after primary authentication for a
208/// TOTP-enabled user.
209///
210/// Stored under `_tmp:totp_challenge:{user_id}:{nonce_hex}` with a
211/// 5-minute TTL. Consumed atomically by `VerifyTotp` or
212/// `ConsumeRecoveryCode` to create a session.
213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
214pub struct PendingTotpChallenge {
215    /// Random one-time-use nonce (32 bytes).
216    pub nonce: [u8; 32],
217    /// User who completed primary authentication.
218    pub user: UserId,
219    /// User slug for session creation after TOTP verification.
220    pub user_slug: UserSlug,
221    /// Absolute expiry time (5-minute TTL from creation).
222    pub expires_at: DateTime<Utc>,
223    /// Failed TOTP attempts against this challenge (max 3, Raft-persisted).
224    pub attempts: u8,
225    /// Primary auth method that preceded this challenge.
226    pub primary_method: PrimaryAuthMethod,
227}
228
229/// Serde helper for `Zeroizing<Vec<u8>>` — serializes as a plain `Vec<u8>`.
230mod zeroize_vec_serde {
231    use serde::{Deserialize, Deserializer, Serialize, Serializer};
232    use zeroize::Zeroizing;
233
234    pub fn serialize<S: Serializer>(value: &Zeroizing<Vec<u8>>, s: S) -> Result<S::Ok, S::Error> {
235        value.serialize(s)
236    }
237
238    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Zeroizing<Vec<u8>>, D::Error> {
239        Vec::<u8>::deserialize(d).map(Zeroizing::new)
240    }
241}