Skip to main content

keepass_ng/db/
otp.rs

1use crate::db::Entry;
2use base32;
3use std::time::{Duration, SystemTime, SystemTimeError, UNIX_EPOCH};
4use totp_lite::{Sha1, Sha256, Sha512, totp_custom};
5use url::Url;
6use zeroize::{Zeroize, ZeroizeOnDrop};
7
8const DEFAULT_PERIOD: u64 = 30;
9const DEFAULT_DIGITS: u32 = 8;
10
11/// Choices of hash algorithm for TOTP
12#[derive(Debug, PartialEq, Eq, Zeroize, ZeroizeOnDrop)]
13pub enum TOTPAlgorithm {
14    Sha1,
15    Sha256,
16    Sha512,
17}
18
19impl std::str::FromStr for TOTPAlgorithm {
20    type Err = TOTPError;
21
22    fn from_str(s: &str) -> Result<Self, Self::Err> {
23        match s.to_uppercase().as_str() {
24            "SHA1" => Ok(TOTPAlgorithm::Sha1),
25            "SHA256" => Ok(TOTPAlgorithm::Sha256),
26            "SHA512" => Ok(TOTPAlgorithm::Sha512),
27            _ => Err(TOTPError::BadAlgorithm(s.to_string())),
28        }
29    }
30}
31
32impl std::fmt::Display for TOTPAlgorithm {
33    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
34        match self {
35            TOTPAlgorithm::Sha1 => write!(f, "SHA1"),
36            TOTPAlgorithm::Sha256 => write!(f, "SHA256"),
37            TOTPAlgorithm::Sha512 => write!(f, "SHA512"),
38        }
39    }
40}
41
42/// Time-based one time password settings
43#[derive(Debug, PartialEq, Eq, Zeroize, ZeroizeOnDrop)]
44pub struct TOTP {
45    pub label: String,
46    secret: Vec<u8>,
47    pub issuer: Option<String>,
48    pub period: u64,
49    pub digits: u32,
50    pub algorithm: TOTPAlgorithm,
51}
52
53/// A generated one time password
54pub struct OTPCode {
55    pub code: String,
56    pub valid_for: Duration,
57    pub period: Duration,
58}
59
60impl std::fmt::Display for OTPCode {
61    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
62        write!(
63            f,
64            "Code: {}, valid for: {}/{}s",
65            self.code,
66            self.valid_for.as_secs(),
67            self.period.as_secs(),
68        )
69    }
70}
71
72/// Errors while processing a TOTP specification
73#[derive(Debug, thiserror::Error)]
74pub enum TOTPError {
75    #[error(transparent)]
76    UrlFormat(#[from] url::ParseError),
77
78    #[error(transparent)]
79    IntFormat(#[from] std::num::ParseIntError),
80
81    #[error("Missing TOTP field: {}", _0)]
82    MissingField(&'static str),
83
84    #[error(transparent)]
85    Time(#[from] SystemTimeError),
86
87    #[error("Base32 decoding error")]
88    Base32,
89
90    #[error("No OTP record found")]
91    NoRecord,
92
93    #[error("Bad URL scheme: '{}'", _0)]
94    BadScheme(String),
95
96    #[error("Bad hash algorithm: '{}'", _0)]
97    BadAlgorithm(String),
98}
99
100impl std::str::FromStr for TOTP {
101    type Err = TOTPError;
102
103    fn from_str(s: &str) -> Result<Self, Self::Err> {
104        let parsed = Url::parse(s)?;
105
106        if parsed.scheme() != "otpauth" {
107            return Err(TOTPError::BadScheme(parsed.scheme().to_string()));
108        }
109        let query_pairs = parsed.query_pairs();
110
111        let label: String = parsed.path().trim_start_matches('/').to_string();
112        let mut secret: Option<String> = None;
113        let mut issuer: Option<String> = None;
114        let mut period: u64 = DEFAULT_PERIOD;
115        let mut digits: u32 = DEFAULT_DIGITS;
116        let mut algorithm: TOTPAlgorithm = TOTPAlgorithm::Sha1;
117
118        for pair in query_pairs {
119            let (k, v) = pair;
120            match k.as_ref() {
121                "secret" => secret = Some(v.to_string()),
122                "issuer" => issuer = Some(v.to_string()),
123                "period" => period = v.parse()?,
124                "digits" => digits = v.parse()?,
125                "algorithm" => algorithm = v.parse()?,
126                _ => {}
127            }
128        }
129
130        let secret = secret.ok_or(TOTPError::MissingField("secret"))?;
131
132        let secret = base32::decode(base32::Alphabet::Rfc4648 { padding: true }, &secret).ok_or(TOTPError::Base32)?;
133
134        Ok(TOTP {
135            label,
136            secret,
137            issuer,
138            period,
139            digits,
140            algorithm,
141        })
142    }
143}
144
145impl std::fmt::Display for TOTP {
146    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
147        write!(
148            f,
149            "otpauth://totp/{}?secret={}&period={}&digits={}&issuer={}&algorithm={:?}",
150            self.label,
151            base32::encode(base32::Alphabet::Rfc4648 { padding: true }, &self.secret),
152            self.period,
153            self.digits,
154            self.issuer.as_deref().unwrap_or(""),
155            self.algorithm
156        )
157    }
158}
159
160impl TOTP {
161    /// Get the one-time code for a specific unix timestamp
162    pub fn value_at(&self, time: u64) -> OTPCode {
163        let code = match self.algorithm {
164            TOTPAlgorithm::Sha1 => totp_custom::<Sha1>(self.period, self.digits, &self.secret, time),
165            TOTPAlgorithm::Sha256 => totp_custom::<Sha256>(self.period, self.digits, &self.secret, time),
166            TOTPAlgorithm::Sha512 => totp_custom::<Sha512>(self.period, self.digits, &self.secret, time),
167        };
168
169        let valid_for = Duration::from_secs(self.period - (time % self.period));
170
171        OTPCode {
172            code,
173            valid_for,
174            period: Duration::from_secs(self.period),
175        }
176    }
177
178    /// Get the current one-time code
179    pub fn value_now(&self) -> Result<OTPCode, SystemTimeError> {
180        let time: u64 = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
181        Ok(self.value_at(time))
182    }
183
184    pub fn get_secret(&self) -> String {
185        base32::encode(base32::Alphabet::Rfc4648 { padding: true }, &self.secret)
186    }
187}
188
189impl<'a> Entry {
190    /// Convenience method for getting a TOTP from this entry
191    pub fn get_otp(&'a self) -> Result<TOTP, TOTPError> {
192        self.get_raw_otp_value().ok_or(TOTPError::NoRecord)?.parse()
193    }
194
195    pub fn set_otp(&mut self, otp: Option<&TOTP>) {
196        self.set_raw_otp_value(otp.map(|o| o.to_string()).as_deref());
197    }
198
199    /// Convenience method for setting a TOTP to this entry
200    pub fn set_raw_otp_value(&mut self, value: Option<&str>) {
201        self.set_protected_field_pair("otp", value);
202    }
203
204    /// Convenience method for getting the raw value of the 'otp' field
205    pub fn get_raw_otp_value(&'a self) -> Option<&'a str> {
206        self.get("otp")
207    }
208}
209
210#[cfg(test)]
211mod kdbx4_otp_tests {
212    use super::{TOTP, TOTPAlgorithm, TOTPError};
213    use crate::{
214        db::{Database, Entry, Group, Node, with_node},
215        key::DatabaseKey,
216    };
217    use std::{fs::File, path::Path};
218
219    #[test]
220    fn kdbx4_entry() -> Result<(), Box<dyn std::error::Error>> {
221        // KDBX4 database format Base64 encodes ExpiryTime (and all other XML timestamps)
222        let path = Path::new("tests/resources/test_db_kdbx4_with_totp_entry.kdbx");
223        let key = DatabaseKey::new().with_password("test");
224        let db = Database::open(&mut File::open(path)?, key)?;
225
226        let otp_str = "otpauth://totp/KeePassXC:none?secret=JBSWY3DPEHPK3PXP&period=30&digits=6&issuer=KeePassXC";
227
228        // get an entry on the root node
229        let entry = Group::get(&db.root, &["this entry has totp"]).unwrap();
230        with_node::<Entry, _, _>(&entry, |e| {
231            assert_eq!(e.get_title(), Some("this entry has totp"));
232            assert_eq!(e.get_raw_otp_value(), Some(otp_str));
233        })
234        .unwrap();
235
236        Ok(())
237    }
238
239    #[test]
240    fn totp_default() -> Result<(), TOTPError> {
241        let otp_str = "otpauth://totp/KeePassXC:none?secret=JBSWY3DPEHPK3PXP&period=30&digits=6&issuer=KeePassXC";
242
243        let expected = TOTP {
244            label: "KeePassXC:none".to_string(),
245            secret: b"Hello!\xDE\xAD\xBE\xEF".to_vec(),
246            issuer: Some("KeePassXC".to_string()),
247            period: 30,
248            digits: 6,
249            algorithm: TOTPAlgorithm::Sha1,
250        };
251
252        assert_eq!(otp_str.parse::<TOTP>()?, expected);
253
254        Ok(())
255    }
256
257    #[test]
258    fn totp_get_secret() -> Result<(), TOTPError> {
259        let otp_str = "otpauth://totp/KeePassXC:none?secret=JBSWY3DPEHPK3PXP&period=30&digits=6&issuer=KeePassXC";
260
261        let otp = otp_str.parse::<TOTP>()?;
262
263        assert_eq!(otp.get_secret(), "JBSWY3DPEHPK3PXP".to_string());
264
265        Ok(())
266    }
267
268    #[test]
269    fn totp_sha512() -> Result<(), TOTPError> {
270        let otp_str = "otpauth://totp/sha512%20totp:none?secret=GEZDGNBVGY%3D%3D%3D%3D%3D%3D&period=30&digits=6&issuer=sha512%20totp&algorithm=SHA512";
271
272        let expected = TOTP {
273            label: "sha512%20totp:none".to_string(),
274            secret: b"123456".to_vec(),
275            issuer: Some("sha512 totp".to_string()),
276            period: 30,
277            digits: 6,
278            algorithm: TOTPAlgorithm::Sha512,
279        };
280
281        assert_eq!(otp_str.parse::<TOTP>()?, expected);
282
283        Ok(())
284    }
285
286    #[test]
287    fn totp_value() {
288        let totp = TOTP {
289            label: "KeePassXC:none".to_string(),
290            secret: b"Hello!\xDE\xAD\xBE\xEF".to_vec(),
291            issuer: Some("KeePassXC".to_string()),
292            period: 30,
293            digits: 6,
294            algorithm: TOTPAlgorithm::Sha1,
295        };
296
297        assert_eq!(totp.value_at(1234).code, "806863")
298    }
299
300    #[test]
301    fn totp_bad() {
302        assert!(matches!("not a totp string".parse::<TOTP>(), Err(TOTPError::UrlFormat(_))));
303
304        assert!(matches!(
305            "http://totp/sha512%20totp:none?secret=GEZDGNBVGY%3D%3D%3D%3D%3D%3D&period=30&digits=6&issuer=sha512%20totp&algorithm=SHA512"
306                .parse::<TOTP>(),
307            Err(TOTPError::BadScheme(_))
308        ));
309
310        assert!(matches!(
311            "otpauth://totp/sha512%20totp:none?secret=GEZDGNBVGY%3D%3D%3D%3D%3D%3D&period=30&digits=6&issuer=sha512%20totp&algorithm=SHA123".parse::<TOTP>(),
312            Err(TOTPError::BadAlgorithm(_))
313        ));
314
315        assert!(matches!(
316            "otpauth://missing_fields".parse::<TOTP>(),
317            Err(TOTPError::MissingField("secret"))
318        ));
319    }
320
321    #[test]
322    fn totp_minimal() -> Result<(), TOTPError> {
323        let otp_str = "otpauth://totp/KeePassXC:none?secret=JBSWY3DPEHPK3PXP&period=30&digits=6";
324
325        let expected = TOTP {
326            label: "KeePassXC:none".to_string(),
327            secret: b"Hello!\xDE\xAD\xBE\xEF".to_vec(),
328            issuer: None,
329            period: 30,
330            digits: 6,
331            algorithm: TOTPAlgorithm::Sha1,
332        };
333
334        assert_eq!(otp_str.parse::<TOTP>()?, expected);
335
336        Ok(())
337    }
338}