1use color_eyre::eyre::{Result, eyre};
2use libreauth::{hash::HashFunction, oath::TOTPBuilder};
3use serde::Deserialize;
4use std::time::{SystemTime, UNIX_EPOCH};
5
6#[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#[derive(Debug, Deserialize, Eq, PartialEq, Clone)]
27pub struct EntryInfoHotp {
28 pub secret: String,
30 pub algo: HashAlgorithm,
32 pub digits: i32,
34 pub counter: u64,
36}
37
38#[derive(Debug, Deserialize, Eq, PartialEq, Clone)]
42pub struct EntryInfoTotp {
43 pub secret: String,
45 pub algo: HashAlgorithm,
47 pub digits: i32,
49 pub period: i32,
51}
52
53#[derive(Debug, Deserialize, Eq, PartialEq, Clone)]
57pub struct EntryInfoSteam {
58 pub secret: String,
60 pub digits: i32,
62 pub period: i32,
64 }
66
67#[derive(Debug, Deserialize, Eq, PartialEq, Clone)]
71pub struct EntryInfoYandex {}
72
73#[derive(Debug, Deserialize, Eq, PartialEq, Clone)]
75#[serde(rename_all = "snake_case")]
76#[serde(tag = "type", content = "info")]
77pub enum EntryInfo {
78 Hotp(EntryInfoHotp),
82
83 Totp(EntryInfoTotp),
85
86 Steam(EntryInfoSteam),
88
89 Yandex(EntryInfoYandex),
91}
92
93#[derive(Debug, Deserialize, Eq, PartialEq, Clone)]
95pub struct Entry {
96 #[serde(flatten)]
98 pub info: EntryInfo,
99 pub name: String,
103 pub issuer: String,
105 }
112
113fn time_since_epoch() -> i32 {
115 SystemTime::now()
116 .duration_since(UNIX_EPOCH)
117 .expect("Time went backwards")
118 .as_secs() as i32
119}
120
121pub 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 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
162pub 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}