authenticator/backup/
andotp.rs

1use anyhow::{Context, Result};
2use gettextrs::gettext;
3use gtk::prelude::*;
4use ring::{
5    aead::{AES_256_GCM, Aad, LessSafeKey, Nonce, UnboundKey},
6    digest,
7};
8use serde::{Deserialize, Serialize};
9use zeroize::{Zeroize, ZeroizeOnDrop};
10
11use super::{Backupable, Restorable, RestorableItem};
12use crate::models::{Account, Algorithm, Method, Provider, ProvidersModel};
13
14const HEADER_SIZE: usize = size_of::<EncryptedAndOTPHeader>();
15
16#[derive(Serialize, Deserialize, Zeroize, ZeroizeOnDrop)]
17pub struct AndOTP(Vec<AndOTPItem>);
18
19#[derive(bincode::Decode)]
20pub struct EncryptedAndOTPHeader {
21    iterations: u32,
22    salt: [u8; 12],
23    iv: [u8; 12],
24}
25
26impl EncryptedAndOTPHeader {
27    fn from_bytes(from: &[u8]) -> Result<Self> {
28        let config = bincode::config::standard()
29            .with_fixed_int_encoding()
30            .with_limit::<HEADER_SIZE>()
31            .with_big_endian();
32
33        let header: EncryptedAndOTPHeader =
34            bincode::decode_from_slice(&from[..HEADER_SIZE], config)?.0;
35
36        Ok(header)
37    }
38}
39
40#[allow(clippy::upper_case_acronyms)]
41#[derive(Serialize, Deserialize, Zeroize, ZeroizeOnDrop)]
42pub struct AndOTPItem {
43    pub secret: String,
44    #[zeroize(skip)]
45    pub issuer: String,
46    #[zeroize(skip)]
47    pub label: String,
48    #[zeroize(skip)]
49    pub digits: u32,
50    #[serde(rename = "type")]
51    #[zeroize(skip)]
52    pub method: Method,
53    #[zeroize(skip)]
54    pub algorithm: Algorithm,
55    #[zeroize(skip)]
56    pub thumbnail: Option<String>,
57    #[zeroize(skip)]
58    pub last_used: i64,
59    #[zeroize(skip)]
60    pub used_frequency: i32,
61    #[zeroize(skip)]
62    pub counter: Option<u32>,
63    #[zeroize(skip)]
64    pub tags: Vec<String>,
65    #[zeroize(skip)]
66    pub period: Option<u32>,
67}
68
69impl RestorableItem for AndOTPItem {
70    fn account(&self) -> String {
71        self.label.clone()
72    }
73
74    fn issuer(&self) -> String {
75        self.issuer.clone()
76    }
77
78    fn secret(&self) -> String {
79        self.secret.trim_end_matches('=').to_owned()
80    }
81
82    fn period(&self) -> Option<u32> {
83        self.period
84    }
85
86    fn method(&self) -> Method {
87        self.method
88    }
89
90    fn algorithm(&self) -> Algorithm {
91        self.algorithm
92    }
93
94    fn digits(&self) -> Option<u32> {
95        Some(self.digits)
96    }
97
98    fn counter(&self) -> Option<u32> {
99        self.counter
100    }
101}
102
103impl Backupable for AndOTP {
104    const ENCRYPTABLE: bool = false;
105    const IDENTIFIER: &'static str = "andotp";
106
107    fn title() -> String {
108        // Translators: This is for making a backup for the andOTP Android app.
109        gettext("a_ndOTP")
110    }
111
112    fn subtitle() -> String {
113        gettext("Into a plain-text JSON file")
114    }
115
116    fn backup(model: &ProvidersModel, _key: Option<&str>) -> Result<Vec<u8>> {
117        let mut items = Vec::new();
118
119        for i in 0..model.n_items() {
120            let provider = model.item(i).and_downcast::<Provider>().unwrap();
121            let accounts = provider.accounts_model();
122
123            for j in 0..accounts.n_items() {
124                let account = accounts.item(j).and_downcast::<Account>().unwrap();
125
126                let otp_item = AndOTPItem {
127                    secret: account.otp().secret(),
128                    issuer: provider.name(),
129                    label: account.name(),
130                    digits: provider.digits(),
131                    method: provider.method(),
132                    algorithm: provider.algorithm(),
133                    thumbnail: None,
134                    last_used: 0,
135                    used_frequency: 0,
136                    counter: Some(account.counter()),
137                    tags: vec![],
138                    period: Some(provider.period()),
139                };
140                items.push(otp_item);
141            }
142        }
143
144        let content = serde_json::ser::to_string_pretty(&items)?;
145        Ok(content.as_bytes().to_vec())
146    }
147}
148
149impl Restorable for AndOTP {
150    const ENCRYPTABLE: bool = true;
151    const SCANNABLE: bool = false;
152    const IDENTIFIER: &'static str = "andotp";
153    type Item = AndOTPItem;
154
155    fn title() -> String {
156        // Translators: This is for restoring a backup from the andOTP Android app.
157        gettext("an_dOTP")
158    }
159
160    fn subtitle() -> String {
161        gettext("From a plain-text JSON file")
162    }
163
164    fn restore_from_data(from: &[u8], key: Option<&str>) -> Result<Vec<Self::Item>> {
165        if let Some(key) = key {
166            AndOTP::decrypt(from, key.as_bytes())
167        } else {
168            let items: Vec<AndOTPItem> = serde_json::de::from_slice(from)?;
169            Ok(items)
170        }
171    }
172}
173
174impl AndOTP {
175    fn decrypt(from: &[u8], secret: &[u8]) -> Result<Vec<AndOTPItem>> {
176        let header = EncryptedAndOTPHeader::from_bytes(&from[..HEADER_SIZE])?;
177        let mut blob = from[HEADER_SIZE..].to_vec();
178
179        let iv = header.iv;
180        let iterations = std::num::NonZeroU32::new(header.iterations)
181            .context("AndOTP header has iterations set to 0")?;
182
183        let mut pbkdf2_key = [0; digest::SHA256_OUTPUT_LEN];
184        ring::pbkdf2::derive(
185            ring::pbkdf2::PBKDF2_HMAC_SHA1,
186            iterations,
187            &header.salt,
188            secret,
189            &mut pbkdf2_key,
190        );
191
192        let pbkdf2_key = UnboundKey::new(&AES_256_GCM, &pbkdf2_key)
193            .ok()
194            .context("Failed to generate unbound key")?;
195        let pbkdf2_key = LessSafeKey::new(pbkdf2_key);
196
197        let decrypted = pbkdf2_key
198            .open_in_place(Nonce::assume_unique_for_key(iv), Aad::empty(), &mut blob)
199            .ok()
200            .context("Error while decrypting")?;
201
202        let items: Vec<AndOTPItem> = serde_json::de::from_slice(decrypted)?;
203
204        Ok(items)
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::{super::RestorableItem, *};
211    use crate::models::{Algorithm, Method};
212
213    #[test]
214    fn test_deserialize_header() {
215        let binary_header = [
216            0, 2, 69, 247, 55, 242, 73, 138, 187, 197, 27, 200, 251, 155, 241, 15, 178, 203, 129,
217            8, 36, 143, 1, 75, 219, 36, 241, 215,
218        ];
219
220        let header = EncryptedAndOTPHeader::from_bytes(&binary_header).unwrap();
221
222        assert_eq!(binary_header.len(), size_of::<EncryptedAndOTPHeader>());
223        assert_eq!(header.iterations, 148983);
224        assert_eq!(header.salt, binary_header[4..16]);
225        assert_eq!(header.iv, binary_header[16..]);
226    }
227
228    #[test]
229    fn test_andotp_decrypt() {
230        // Taken from https://github.com/asmw/andOTP-decrypt
231        let data = std::fs::read("./src/backup/tests/andotp_enc.json.aes").unwrap();
232        let secret = b"123456";
233
234        let items = AndOTP::decrypt(&data, secret).unwrap();
235        assert_eq!(items.len(), 7);
236    }
237
238    #[test]
239    fn parse() {
240        let data = std::fs::read_to_string("./src/backup/tests/andotp_plain.json").unwrap();
241        let items = AndOTP::restore_from_data(data.as_bytes(), None).unwrap();
242
243        assert_eq!(items[0].account(), "Mason");
244        assert_eq!(items[0].issuer(), "Deno");
245        assert_eq!(items[0].secret(), "4SJHB4GSD43FZBAI7C2HLRJGPQ");
246        assert_eq!(items[0].period(), Some(30));
247        assert_eq!(items[0].method(), Method::TOTP);
248        assert_eq!(items[0].algorithm(), Algorithm::SHA1);
249        assert_eq!(items[0].digits(), Some(6));
250        assert_eq!(items[0].counter(), None);
251
252        assert_eq!(items[1].account(), "James");
253        assert_eq!(items[1].issuer(), "SPDX");
254        assert_eq!(items[1].secret(), "5OM4WOOGPLQEF6UGN3CPEOOLWU");
255        assert_eq!(items[1].period(), Some(20));
256        assert_eq!(items[1].method(), Method::TOTP);
257        assert_eq!(items[1].algorithm(), Algorithm::SHA256);
258        assert_eq!(items[1].digits(), Some(7));
259        assert_eq!(items[1].counter(), None);
260
261        assert_eq!(items[2].account(), "Elijah");
262        assert_eq!(items[2].issuer(), "Airbnb");
263        assert_eq!(items[2].secret(), "7ELGJSGXNCCTV3O6LKJWYFV2RA");
264        assert_eq!(items[2].period(), Some(50));
265        assert_eq!(items[2].method(), Method::TOTP);
266        assert_eq!(items[2].algorithm(), Algorithm::SHA512);
267        assert_eq!(items[2].digits(), Some(8));
268        assert_eq!(items[2].counter(), None);
269
270        assert_eq!(items[3].account(), "James");
271        assert_eq!(items[3].issuer(), "Issuu");
272        assert_eq!(items[3].secret(), "YOOMIXWS5GN6RTBPUFFWKTW5M4");
273        assert_eq!(items[3].period(), None);
274        assert_eq!(items[3].method(), Method::HOTP);
275        assert_eq!(items[3].algorithm(), Algorithm::SHA1);
276        assert_eq!(items[3].digits(), Some(6));
277        assert_eq!(items[3].counter(), Some(1));
278
279        assert_eq!(items[4].account(), "Benjamin");
280        assert_eq!(items[4].issuer(), "Air Canada");
281        assert_eq!(items[4].secret(), "KUVJJOM753IHTNDSZVCNKL7GII");
282        assert_eq!(items[4].period(), None);
283        assert_eq!(items[4].method(), Method::HOTP);
284        assert_eq!(items[4].algorithm(), Algorithm::SHA256);
285        assert_eq!(items[4].digits(), Some(7));
286        assert_eq!(items[4].counter(), Some(50));
287
288        assert_eq!(items[5].account(), "Mason");
289        assert_eq!(items[5].issuer(), "WWE");
290        assert_eq!(items[5].secret(), "5VAML3X35THCEBVRLV24CGBKOY");
291        assert_eq!(items[5].period(), None);
292        assert_eq!(items[5].method(), Method::HOTP);
293        assert_eq!(items[5].algorithm(), Algorithm::SHA512);
294        assert_eq!(items[5].digits(), Some(8));
295        assert_eq!(items[5].counter(), Some(10300));
296
297        assert_eq!(items[6].account(), "Sophia");
298        assert_eq!(items[6].issuer(), "Boeing");
299        assert_eq!(items[6].secret(), "JRZCL47CMXVOQMNPZR2F7J4RGI");
300        assert_eq!(items[6].period(), None);
301        assert_eq!(items[6].method(), Method::Steam);
302        assert_eq!(items[6].algorithm(), Algorithm::SHA1);
303        assert_eq!(items[6].digits(), Some(5));
304        assert_eq!(items[6].counter(), None);
305    }
306}