From 91b4b5b7a47b10ec770ff86bc833646accc5bfa6 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 13:09:34 -0400 Subject: [PATCH] feat(core): flesh out TotpCore + TotpConfig + TotpAlgorithm + TotpKind Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/idfoto-core/src/item_types/totp.rs | 88 +++++++++++++++++++++-- 1 file changed, 84 insertions(+), 4 deletions(-) diff --git a/crates/idfoto-core/src/item_types/totp.rs b/crates/idfoto-core/src/item_types/totp.rs index 253398e..1b6c52f 100644 --- a/crates/idfoto-core/src/item_types/totp.rs +++ b/crates/idfoto-core/src/item_types/totp.rs @@ -1,10 +1,38 @@ +//! TOTP: standalone 2FA item type. Also reused as TotpConfig field on Login. + use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; #[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct TotpCore {} +pub struct TotpCore { + pub config: TotpConfig, + #[serde(skip_serializing_if = "Option::is_none")] + pub issuer: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub label: Option, +} -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct TotpConfig {} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TotpConfig { + /// Raw bytes of the TOTP secret (decoded from base32 when imported). + pub secret: Zeroizing>, + pub algorithm: TotpAlgorithm, + pub digits: u8, + pub period_seconds: u32, + pub kind: TotpKind, +} + +impl Default for TotpConfig { + fn default() -> Self { + Self { + secret: Zeroizing::new(Vec::new()), + algorithm: TotpAlgorithm::Sha1, + digits: 6, + period_seconds: 30, + kind: TotpKind::Totp, + } + } +} #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] @@ -15,7 +43,7 @@ pub enum TotpAlgorithm { Sha512, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum TotpKind { Totp, @@ -26,3 +54,55 @@ pub enum TotpKind { impl Default for TotpKind { fn default() -> Self { TotpKind::Totp } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn totp_default_is_sha1_6_30_totp() { + let cfg = TotpConfig::default(); + assert_eq!(cfg.algorithm, TotpAlgorithm::Sha1); + assert_eq!(cfg.digits, 6); + assert_eq!(cfg.period_seconds, 30); + assert_eq!(cfg.kind, TotpKind::Totp); + } + + #[test] + fn totp_round_trip() { + let core = TotpCore { + config: TotpConfig { + secret: Zeroizing::new(vec![0x12, 0x34, 0x56]), + algorithm: TotpAlgorithm::Sha256, + digits: 8, + period_seconds: 60, + kind: TotpKind::Totp, + }, + issuer: Some("github".into()), + label: Some("alice@github".into()), + }; + let json = serde_json::to_string(&core).unwrap(); + let parsed: TotpCore = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.config.digits, 8); + assert_eq!(parsed.config.algorithm, TotpAlgorithm::Sha256); + assert_eq!(parsed.issuer.as_deref(), Some("github")); + } + + #[test] + fn hotp_carries_counter() { + let cfg = TotpConfig { kind: TotpKind::Hotp { counter: 42 }, ..TotpConfig::default() }; + let json = serde_json::to_string(&cfg).unwrap(); + let parsed: TotpConfig = serde_json::from_str(&json).unwrap(); + match parsed.kind { + TotpKind::Hotp { counter } => assert_eq!(counter, 42), + other => panic!("expected Hotp, got {:?}", other), + } + } + + #[test] + fn steam_kind_serializes() { + let cfg = TotpConfig { kind: TotpKind::Steam, ..TotpConfig::default() }; + let json = serde_json::to_string(&cfg).unwrap(); + assert!(json.contains("steam")); + } +}