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}