aegis_vault_utils/
otp.rs

1use color_eyre::eyre::{Result, eyre};
2use libreauth::{hash::HashFunction, oath::TOTPBuilder};
3use serde::Deserialize;
4use std::time::{SystemTime, UNIX_EPOCH};
5
6/// Hashing algorithm to use when generating the OTP
7#[derive(Debug, Deserialize, Eq, PartialEq, Clone, Copy)]
8#[serde(rename_all = "UPPERCASE")]
9pub enum HashAlgorithm {
10    Sha1,
11    Sha256,
12    Sha512,
13}
14
15impl From<HashAlgorithm> for HashFunction {
16    fn from(algo: HashAlgorithm) -> Self {
17        match algo {
18            HashAlgorithm::Sha1 => HashFunction::Sha1,
19            HashAlgorithm::Sha256 => HashFunction::Sha256,
20            HashAlgorithm::Sha512 => HashFunction::Sha512,
21        }
22    }
23}
24
25/// HOTP (HMAC-based One Time Pad)
26#[derive(Debug, Deserialize, Eq, PartialEq, Clone)]
27pub struct EntryInfoHotp {
28    /// Base32 encoded secret
29    pub secret: String,
30    /// Hashing algorithm to use
31    pub algo: HashAlgorithm,
32    /// Number of digits in the OTP
33    pub digits: i32,
34    /// The counter value to use when generating the OTP
35    pub counter: u64,
36}
37
38/// Time-based One Time Pads (TOTP)
39///
40/// [RFC 6238](https://datatracker.ietf.org/doc/html/rfc6238)
41#[derive(Debug, Deserialize, Eq, PartialEq, Clone)]
42pub struct EntryInfoTotp {
43    /// Base32 encoded secret
44    pub secret: String,
45    /// Hashing algorithm to use
46    pub algo: HashAlgorithm,
47    /// Number of digits in the OTP
48    pub digits: i32,
49    /// The time step in seconds since the UNIX epoch
50    pub period: i32,
51}
52
53/// Steam Guard OTP
54///
55/// Essentially a TOTP with a 5 digit code and 30 second period using the SHA1 hashing algorithm
56#[derive(Debug, Deserialize, Eq, PartialEq, Clone)]
57pub struct EntryInfoSteam {
58    /// Base32 encoded secret
59    pub secret: String,
60    /// Number of digits in the OTP (default: 5)
61    pub digits: i32,
62    /// The time step in seconds since the UNIX epoch (default: 30)
63    pub period: i32,
64    // TODO: Remove digits and period from this struct since the values are fixed?
65}
66
67/// Yandex OTP
68///
69/// Not implemented.
70#[derive(Debug, Deserialize, Eq, PartialEq, Clone)]
71pub struct EntryInfoYandex {}
72
73/// Information used to generate one time codes
74#[derive(Debug, Deserialize, Eq, PartialEq, Clone)]
75#[serde(rename_all = "snake_case")]
76#[serde(tag = "type", content = "info")]
77pub enum EntryInfo {
78    /// Not implemented.
79    ///
80    /// [RFC 4226](https://datatracker.ietf.org/doc/html/rfc4226)
81    Hotp(EntryInfoHotp),
82
83    /// [RFC 6238](https://datatracker.ietf.org/doc/html/rfc6238)
84    Totp(EntryInfoTotp),
85
86    /// Not implemented.
87    Steam(EntryInfoSteam),
88
89    /// Not implemented.
90    Yandex(EntryInfoYandex),
91}
92
93/// Entry with metadata and information used to generate one time codes
94#[derive(Debug, Deserialize, Eq, PartialEq, Clone)]
95pub struct Entry {
96    /// Information used to generate the OTP such as the secret and algorithm
97    #[serde(flatten)]
98    pub info: EntryInfo,
99    // /// A UUID (version 4)
100    // pub uuid: String,
101    /// The account name
102    pub name: String,
103    /// The service that the token is for
104    pub issuer: String,
105    // /// A personal note about the entry
106    // pub note: String,
107    // /// Whether the entry is a favorite or not
108    // pub favorite: bool,
109    // /// JPEGa's encoded in Base64 with padding
110    // pub icon: String,
111}
112
113/// Current time since the UNIX epoch in seconds
114fn time_since_epoch() -> i32 {
115    SystemTime::now()
116        .duration_since(UNIX_EPOCH)
117        .expect("Time went backwards")
118        .as_secs() as i32
119}
120
121/// Generates a one time password based on the entry information and the current time
122///
123/// # Arguments
124/// * `entry_info` - The information used to generate the OTP
125/// * `time_since_epoch` - The time since the UNIX epoch in seconds
126///
127/// # Returns
128/// The generated one time password
129///
130/// # Errors
131/// Returns an error if the entry type is not implemented or if the entry information is invalid
132pub fn generate_otp(entry_info: &EntryInfo) -> Result<String> {
133    generate_otp_impl(entry_info, time_since_epoch())
134}
135
136fn generate_otp_impl(entry_info: &EntryInfo, time_since_epoch: i32) -> Result<String> {
137    let code = match entry_info {
138        // TODO: Add full support for HOTP
139        /*
140        EntryType::Hotp(info) => HOTPBuilder::new()
141            .base32_key(&info.secret.to_string())
142            .hash_function(info.algo.into())
143            .output_len(info.digits.try_into()?)
144            .counter(info.counter)
145            .finalize()?
146            .generate(),
147        */
148        EntryInfo::Totp(info) => TOTPBuilder::new()
149            .timestamp(time_since_epoch as i64)
150            .base32_key(&info.secret.to_string())
151            .hash_function(info.algo.into())
152            .output_len(info.digits.try_into()?)
153            .period(info.period.try_into()?)
154            .finalize()?
155            .generate(),
156        _ => return Err(eyre!("Not implemented")),
157    };
158
159    Ok(code)
160}
161
162/// Calculates the remaining time until the next period starts
163pub fn calculate_remaining_time(entry_info: &EntryInfo) -> Result<i32> {
164    calculate_remaining_time_impl(entry_info, time_since_epoch())
165}
166
167fn calculate_remaining_time_impl(entry_info: &EntryInfo, seconds_since_epoch: i32) -> Result<i32> {
168    let period_length_s = match entry_info {
169        EntryInfo::Totp(info) => info.period,
170        _ => return Err(eyre!("Not implemented")),
171    } as i32;
172
173    Ok(period_length_s - (seconds_since_epoch % period_length_s))
174}
175
176#[cfg(test)]
177mod test {
178    use super::*;
179    use crate::otp::{
180        Entry, EntryInfo, EntryInfoHotp, EntryInfoSteam, EntryInfoTotp, HashAlgorithm,
181    };
182
183    fn totp_entry() -> Entry {
184        Entry {
185            info: EntryInfo::Totp(EntryInfoTotp {
186                secret: "4SJHB4GSD43FZBAI7C2HLRJGPQ".to_string(),
187                algo: HashAlgorithm::Sha1,
188                digits: 6,
189                period: 30,
190            }),
191            name: "Mason".to_string(),
192            issuer: "Deno".to_string(),
193        }
194    }
195
196    #[test]
197    fn test_totp_generate() {
198        let entry = totp_entry();
199        let otp_0 = generate_otp_impl(&entry.info, 0).unwrap();
200        let otp_10 = generate_otp_impl(&entry.info, 10).unwrap();
201        let otp_50 = generate_otp_impl(&entry.info, 50).unwrap();
202        assert_eq!(otp_0, "591295");
203        assert_eq!(otp_10, "591295");
204        assert_eq!(otp_50, "526156");
205    }
206
207    #[test]
208    fn test_totp_time_remaining() {
209        let entry = totp_entry();
210        let remaining_time = calculate_remaining_time_impl(&entry.info, 0).unwrap();
211        assert_eq!(remaining_time, 30);
212
213        let remaining_time = calculate_remaining_time_impl(&entry.info, 10).unwrap();
214        assert_eq!(remaining_time, 20);
215
216        let remaining_time = calculate_remaining_time_impl(&entry.info, 50).unwrap();
217        assert_eq!(remaining_time, 10);
218    }
219
220    #[test]
221    fn parse_hotp() {
222        let json = r#"
223            {
224              "type": "hotp",
225              "uuid": "b25f8815-007f-40f7-a700-ce058ac05435",
226              "name": "Mason",
227              "issuer": "WWE",
228              "icon": null,
229              "info": {
230                "secret": "5VAML3X35THCEBVRLV24CGBKOY",
231                "algo": "SHA512",
232                "digits": 8,
233                "counter": 10300
234              }
235            }"#;
236        let hotp_entry = Entry {
237            info: EntryInfo::Hotp(EntryInfoHotp {
238                secret: "5VAML3X35THCEBVRLV24CGBKOY".to_string(),
239                algo: HashAlgorithm::Sha512,
240                digits: 8,
241                counter: 10300,
242            }),
243            name: "Mason".to_string(),
244            issuer: "WWE".to_string(),
245        };
246
247        let deserialized = serde_json::from_str::<Entry>(json).unwrap();
248        assert_eq!(deserialized, hotp_entry);
249    }
250
251    #[test]
252    fn parse_totp() {
253        let json = r#"
254            {
255              "type": "totp",
256              "uuid": "3ae6f1ad-2e65-4ed2-a953-1ec0dff2386d",
257              "name": "Mason",
258              "issuer": "Deno",
259              "icon": null,
260              "info": {
261                "secret": "4SJHB4GSD43FZBAI7C2HLRJGPQ",
262                "algo": "SHA1",
263                "digits": 6,
264                "period": 30
265              }
266            }"#;
267        let totp_entry = Entry {
268            info: EntryInfo::Totp(EntryInfoTotp {
269                secret: "4SJHB4GSD43FZBAI7C2HLRJGPQ".to_string(),
270                algo: HashAlgorithm::Sha1,
271                digits: 6,
272                period: 30,
273            }),
274            name: "Mason".to_string(),
275            issuer: "Deno".to_string(),
276        };
277
278        let deserialized = serde_json::from_str::<Entry>(json).unwrap();
279        assert_eq!(deserialized, totp_entry);
280    }
281
282    #[test]
283    fn parse_steam() {
284        let json = r#"
285            {
286              "type": "steam",
287              "uuid": "5b11ae3b-6fc3-4d46-8ca7-cf0aea7de920",
288              "name": "Sophia",
289              "issuer": "Boeing",
290              "icon": null,
291              "info": {
292                "secret": "JRZCL47CMXVOQMNPZR2F7J4RGI",
293                "algo": "SHA1",
294                "digits": 5,
295                "period": 30
296              }
297            }"#;
298        let steam_entry = Entry {
299            info: EntryInfo::Steam(EntryInfoSteam {
300                secret: "JRZCL47CMXVOQMNPZR2F7J4RGI".to_string(),
301                digits: 5,
302                period: 30,
303            }),
304            name: "Sophia".to_string(),
305            issuer: "Boeing".to_string(),
306        };
307
308        let deserialized = serde_json::from_str::<Entry>(json).unwrap();
309        assert_eq!(deserialized, steam_entry);
310    }
311}