1use aes_gcm::{KeyInit, aead::Aead};
15use anyhow::{Context, Result};
16use gettextrs::gettext;
17use gtk::prelude::*;
18use rand::Rng;
19use serde::{Deserialize, Serialize};
20use zeroize::{Zeroize, ZeroizeOnDrop};
21
22use super::{Backupable, Restorable, RestorableItem};
23use crate::models::{Account, Algorithm, Method, Provider, ProvidersModel};
24
25#[derive(Debug, Serialize, Deserialize)]
26#[serde(untagged)]
27pub enum Aegis {
28 Encrypted(AegisEncrypted),
29 Plaintext(AegisPlainText),
30}
31
32#[derive(Debug, Serialize, Deserialize)]
34pub struct AegisPlainText {
35 version: u32,
36 header: Header,
37 db: Database,
38}
39
40impl Default for AegisPlainText {
41 fn default() -> Self {
42 Self {
43 version: 1,
44 header: Header {
45 params: None,
46 slots: Default::default(),
47 },
48 db: Default::default(),
49 }
50 }
51}
52
53#[derive(Debug, Serialize, Deserialize)]
56pub struct AegisEncrypted {
57 version: u32,
58 header: Header,
59 db: String,
60}
61
62impl Default for Aegis {
63 fn default() -> Self {
64 Self::Plaintext(AegisPlainText::default())
65 }
66}
67
68impl Aegis {
69 pub fn add_item(&mut self, item: Item) {
70 if let Self::Plaintext(plain_text) = self {
71 plain_text.db.entries.push(item);
72 } else {
73 panic!("Trying to add an OTP item to an encrypted aegis database")
75 }
76 }
77
78 pub fn encrypt(&mut self, password: &str) -> Result<()> {
79 let mut rng = rand::rng();
81 let mut master_key = [0u8; 32];
82 rng.fill_bytes(&mut master_key);
83
84 let mut header = Header {
86 params: Some(HeaderParam::default()),
87 slots: Some(vec![HeaderSlot::default()]),
88 };
89
90 let password_slot = &mut header.slots.as_mut().unwrap().get_mut(0).unwrap();
93 let mut derived_key: [u8; 32] = [0u8; 32];
95 let params = scrypt::Params::new(
96 (password_slot.n() as f64).log2() as u8,
98 password_slot.r(),
99 password_slot.p(),
100 scrypt::Params::RECOMMENDED_LEN,
101 )
102 .expect("Scrypt params creation");
104 scrypt::scrypt(
105 password.as_bytes(),
106 password_slot.salt(),
107 ¶ms,
108 &mut derived_key,
109 )
110 .map_err(|_| anyhow::anyhow!("Scrypt key derivation"))?;
111
112 let cipher = aes_gcm::Aes256Gcm::new_from_slice(&derived_key)?;
114 let mut ciphertext: Vec<u8> = cipher
115 .encrypt(
116 aes_gcm::Nonce::from_slice(&password_slot.key_params.nonce),
117 master_key.as_ref(),
118 )
119 .map_err(|_| anyhow::anyhow!("Encrypter master key"))?;
120
121 password_slot.key_params.tag = ciphertext.split_off(32).try_into().unwrap();
124 password_slot.key = ciphertext.try_into().unwrap();
125
126 if let Self::Plaintext(plain_text) = self {
128 let db_json: Vec<u8> = serde_json::ser::to_string_pretty(&plain_text.db)?
129 .as_bytes()
130 .to_vec();
131 let cipher = aes_gcm::Aes256Gcm::new_from_slice(&master_key)?;
132 let mut ciphertext: Vec<u8> = cipher
133 .encrypt(
134 aes_gcm::Nonce::from_slice(&header.params.as_ref().unwrap().nonce),
135 db_json.as_ref(),
136 )
137 .map_err(|_| anyhow::anyhow!("Encrypting aegis database"))?;
138 header.params.as_mut().unwrap().tag = ciphertext
139 .split_off(ciphertext.len() - 16)
140 .try_into()
141 .unwrap();
142 let db_encrypted = ciphertext;
143
144 *self = Self::Encrypted(AegisEncrypted {
145 version: plain_text.version,
146 header,
147 db: data_encoding::BASE64.encode(&db_encrypted),
148 });
149 } else {
150 panic!("Encrypt can only be called on a plaintext object.")
152 }
153
154 Ok(())
155 }
156}
157
158#[derive(Debug, Serialize, Deserialize)]
163pub struct Header {
164 #[serde(default)]
165 pub slots: Option<Vec<HeaderSlot>>,
166 #[serde(default)]
167 pub params: Option<HeaderParam>,
168}
169
170#[derive(Debug, Serialize, Deserialize)]
174pub struct HeaderSlot {
175 #[serde(rename = "type")]
189 pub type_: u32,
190 pub uuid: String,
191 #[serde(with = "hex::serde")]
192 pub key: [u8; 32],
193 pub key_params: HeaderParam,
195 n: Option<u32>,
196 r: Option<u32>,
197 p: Option<u32>,
198 #[serde(default, with = "hex::serde")]
199 salt: [u8; 32],
200}
201
202impl HeaderSlot {
203 pub fn n(&self) -> u32 {
204 self.n.unwrap_or_else(|| 2_u32.pow(15))
205 }
206
207 pub fn r(&self) -> u32 {
208 self.r.unwrap_or(8)
209 }
210
211 pub fn p(&self) -> u32 {
212 self.p.unwrap_or(1)
213 }
214
215 pub fn salt(&self) -> &[u8; 32] {
216 &self.salt
217 }
218}
219
220impl Default for HeaderSlot {
221 fn default() -> Self {
222 let mut rng = rand::rng();
223 let mut salt = [0u8; 32];
224 rng.fill_bytes(&mut salt);
225
226 Self {
227 type_: 1,
228 uuid: uuid::Uuid::new_v4().to_string(),
229 key: [0u8; 32],
230 key_params: HeaderParam::default(),
231 n: Some(2_u32.pow(15)),
232 r: Some(8),
233 p: Some(1),
234 salt,
235 }
236 }
237}
238
239#[derive(Debug, Serialize, Deserialize)]
241pub struct HeaderParam {
242 #[serde(with = "hex::serde")]
243 pub nonce: [u8; 12],
244 #[serde(with = "hex::serde")]
245 pub tag: [u8; 16],
246}
247
248impl Default for HeaderParam {
249 fn default() -> Self {
250 let mut rng = rand::rng();
251 let mut nonce = [0u8; 12];
252 rng.fill_bytes(&mut nonce);
253
254 Self {
255 nonce,
256 tag: [0u8; 16],
257 }
258 }
259}
260
261#[derive(Debug, Serialize, Deserialize)]
263pub struct Database {
264 pub version: u32,
265 pub entries: Vec<Item>,
266}
267
268impl Default for Database {
269 fn default() -> Self {
270 Self {
271 version: 2,
272 entries: std::vec::Vec::new(),
273 }
274 }
275}
276
277#[derive(Debug, Serialize, Deserialize)]
279pub struct Item {
280 #[serde(rename = "type")]
281 pub method: Method,
282 #[serde(rename = "name")]
284 pub label: String,
285 pub issuer: Option<String>,
286 #[serde(rename = "group")]
288 pub tags: Option<String>,
289 #[serde(rename = "icon")]
295 pub thumbnail: Option<String>,
296 pub info: Detail,
297}
298
299impl Item {
300 pub fn new(account: &Account) -> Self {
301 let provider = account.provider();
302
303 let mut detail = Detail {
304 secret: account.otp().secret(),
305 algorithm: provider.algorithm(),
306 digits: provider.digits(),
307 period: None,
308 counter: None,
309 };
310
311 if provider.method().is_event_based() {
312 detail.counter = Some(account.counter());
313 } else {
314 detail.period = Some(provider.period());
315 }
316
317 Self {
318 method: provider.method(),
319 label: account.name(),
320 issuer: Some(provider.name()),
321 tags: None,
322 thumbnail: None,
323 info: detail,
324 }
325 }
326
327 pub fn fix_empty_issuer(&mut self) -> Result<()> {
328 if self.issuer.is_none() {
329 let mut vals: Vec<&str> = self.label.split('@').collect();
330 if vals.len() > 1 {
331 self.issuer = vals.pop().map(ToOwned::to_owned);
332 self.label = vals.join("@");
333 } else {
334 anyhow::bail!("Entry {} has an empty issuer", self.label);
335 }
336 }
337 Ok(())
338 }
339}
340
341#[derive(Debug, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)]
343pub struct Detail {
344 pub secret: String,
345 #[serde(rename = "algo")]
346 #[zeroize(skip)]
347 pub algorithm: Algorithm,
348 #[zeroize(skip)]
349 pub digits: u32,
350 #[zeroize(skip)]
351 pub period: Option<u32>,
352 #[zeroize(skip)]
353 pub counter: Option<u32>,
354}
355
356impl RestorableItem for Item {
357 fn account(&self) -> String {
358 self.label.clone()
359 }
360
361 fn issuer(&self) -> String {
362 self.issuer
363 .as_ref()
364 .map(ToOwned::to_owned)
365 .unwrap_or_default()
366 }
367
368 fn secret(&self) -> String {
369 self.info.secret.clone()
370 }
371
372 fn period(&self) -> Option<u32> {
373 self.info.period
374 }
375
376 fn method(&self) -> Method {
377 self.method
378 }
379
380 fn algorithm(&self) -> Algorithm {
381 self.info.algorithm
382 }
383
384 fn digits(&self) -> Option<u32> {
385 Some(self.info.digits)
386 }
387
388 fn counter(&self) -> Option<u32> {
389 self.info.counter
390 }
391}
392
393impl Backupable for Aegis {
394 const ENCRYPTABLE: bool = true;
395 const IDENTIFIER: &'static str = "aegis";
396
397 fn title() -> String {
398 gettext("Aegis")
400 }
401
402 fn subtitle() -> String {
403 gettext("Into a JSON file containing plain-text or encrypted fields")
404 }
405
406 fn backup(model: &ProvidersModel, key: Option<&str>) -> Result<Vec<u8>> {
407 let mut aegis_root = Aegis::default();
409
410 for i in 0..model.n_items() {
411 let provider = model.item(i).and_downcast::<Provider>().unwrap();
412 let accounts = provider.accounts_model();
413
414 for j in 0..accounts.n_items() {
415 let account = accounts.item(j).and_downcast::<Account>().unwrap();
416 let otp_item = Item::new(&account);
417 aegis_root.add_item(otp_item);
418 }
419 }
420
421 if let Some(password) = key {
422 aegis_root.encrypt(password)?;
423 }
424
425 let content = serde_json::ser::to_string_pretty(&aegis_root)?;
426
427 Ok(content.as_bytes().to_vec())
428 }
429}
430
431impl Restorable for Aegis {
432 const ENCRYPTABLE: bool = true;
433 const SCANNABLE: bool = false;
434 const IDENTIFIER: &'static str = "aegis";
435 type Item = Item;
436
437 fn title() -> String {
438 gettext("Aegis")
440 }
441
442 fn subtitle() -> String {
443 gettext("From a JSON file containing plain-text or encrypted fields")
444 }
445
446 fn restore_from_data(from: &[u8], key: Option<&str>) -> Result<Vec<Self::Item>> {
447 let aegis_root: Aegis = serde_json::de::from_slice(from)?;
449 let mut items = Vec::new();
450
451 match aegis_root {
453 Aegis::Plaintext(plain_text) => {
454 tracing::info!(
455 "Found unencrypted aegis vault with version {} and database version {}.",
456 plain_text.version,
457 plain_text.db.version
458 );
459
460 if plain_text.version != 1 {
462 anyhow::bail!(
463 "Aegis vault version expected to be 1. Found {} instead.",
464 plain_text.version
465 );
466 } else if plain_text.db.version > 2 {
468 anyhow::bail!(
469 "Aegis database version expected to be 1 or 2. Found {} instead.",
470 plain_text.db.version
471 );
472 } else {
473 for mut item in plain_text.db.entries {
474 item.fix_empty_issuer()?;
475 items.push(item);
476 }
477 Ok(items)
478 }
479 }
480 Aegis::Encrypted(encrypted) => {
481 tracing::info!(
482 "Found encrypted aegis vault with version {}.",
483 encrypted.version
484 );
485
486 if encrypted.version != 1 {
488 anyhow::bail!(
489 "Aegis vault version expected to be 1. Found {} instead.",
490 encrypted.version
491 );
492 } else if key.is_none() {
493 anyhow::bail!("Found encrypted aegis database but no password given.");
494 }
495
496 let mut ciphertext = data_encoding::BASE64
498 .decode(encrypted.db.as_bytes())
499 .context("Cannot decode (base64) encoded database")?;
500
501 ciphertext.append(&mut encrypted.header.params.as_ref().unwrap().tag.into());
503
504 let master_keys: Vec<Vec<u8>> = encrypted
508 .header
509 .slots
510 .as_ref()
511 .unwrap()
512 .iter()
513 .filter(|slot| slot.type_ == 1) .map(|slot| -> Result<Vec<u8>> {
515 tracing::info!("Found possible master key with UUID {}.", slot.uuid);
516
517 let params = scrypt::Params::new(
523 (slot.n() as f64).log2() as u8, slot.r(), slot.p(), scrypt::Params::RECOMMENDED_LEN,
528 )
529 .map_err(|_| anyhow::anyhow!("Invalid scrypt parameters"))?;
530 let mut temp_key: [u8; 32] = [0u8; 32];
531 scrypt::scrypt(
532 key.unwrap().as_bytes(),
533 slot.salt(),
534 ¶ms,
535 &mut temp_key,
536 )
537 .map_err(|_| anyhow::anyhow!("Scrypt key derivation failed"))?;
538
539 let cipher = aes_gcm::Aes256Gcm::new_from_slice(&temp_key)?;
541 let mut ciphertext: Vec<u8> = slot.key.to_vec();
542 ciphertext.append(&mut slot.key_params.tag.to_vec());
543
544 cipher
547 .decrypt(
548 aes_gcm::Nonce::from_slice(&slot.key_params.nonce),
549 ciphertext.as_ref(),
550 )
551 .map_err(|_| anyhow::anyhow!("Cannot decrypt master key"))
552 })
553 .filter_map(|x| match x {
557 Ok(x) => Some(x),
558 Err(e) => {
559 tracing::error!("Decrypting master key failed: {:?}", e);
560 None
561 }
562 })
563 .collect();
564
565 tracing::info!(
568 "Found {} valid password slots / master keys.",
569 master_keys.len()
570 );
571 let master_key = match master_keys.first() {
572 Some(x) => {
573 tracing::info!("Using only the first valid key slot / master key.");
574 x
575 }
576 None => anyhow::bail!(
577 "Did not find at least one slot with a valid key. Wrong password?"
578 ),
579 };
580
581 let cipher = aes_gcm::Aes256Gcm::new_from_slice(master_key)?;
583 let plaintext = cipher
584 .decrypt(
585 aes_gcm::Nonce::from_slice(
586 &encrypted.header.params.as_ref().unwrap().nonce,
587 ),
588 ciphertext.as_ref(),
589 )
590 .map_err(|_| anyhow::anyhow!("Cannot decrypt database"))?;
592
593 let db: Database = serde_json::de::from_slice(&plaintext)
595 .context("Deserialize decrypted database failed")?;
596
597 tracing::info!("Found aegis database with version {}.", db.version);
599 if encrypted.version > 2 {
600 anyhow::bail!(
601 "Aegis database version expected to be 1 or 2. Found {} instead.",
602 db.version
603 );
604 }
605
606 for mut item in db.entries {
608 item.fix_empty_issuer()?;
609 items.push(item);
610 }
611 Ok(items)
612 }
613 }
614 }
615}
616
617#[cfg(test)]
618mod tests {
619 use super::*;
620
621 #[test]
622 fn issuer_from_name() {
623 let data =
624 std::fs::read_to_string("./src/backup/tests/aegis_issuer_from_name.json").unwrap();
625 let items = Aegis::restore_from_data(data.as_bytes(), None).unwrap();
626
627 assert_eq!(items[0].issuer(), "issuer");
628 assert_eq!(items[0].account(), "missing-issuer");
629 assert_eq!(items[1].issuer(), "issuer");
630 assert_eq!(items[1].account(), "missing-issuer@domain.com");
631 }
632
633 #[test]
634 fn parse_plain() {
635 let data = std::fs::read_to_string("./src/backup/tests/aegis_plain.json").unwrap();
636 let items = Aegis::restore_from_data(data.as_bytes(), None).unwrap();
637
638 assert_eq!(items[0].account(), "Bob");
639 assert_eq!(items[0].issuer(), "Google");
640 assert_eq!(items[0].secret(), "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567");
641 assert_eq!(items[0].period(), Some(30));
642 assert_eq!(items[0].algorithm(), Algorithm::SHA1);
643 assert_eq!(items[0].digits(), Some(6));
644 assert_eq!(items[0].counter(), None);
645 assert_eq!(items[0].method(), Method::TOTP);
646
647 assert_eq!(items[1].account(), "Benjamin");
648 assert_eq!(items[1].issuer(), "Air Canada");
649 assert_eq!(items[1].secret(), "KUVJJOM753IHTNDSZVCNKL7GII");
650 assert_eq!(items[1].period(), None);
651 assert_eq!(items[1].algorithm(), Algorithm::SHA256);
652 assert_eq!(items[1].digits(), Some(7));
653 assert_eq!(items[1].counter(), Some(50));
654 assert_eq!(items[1].method(), Method::HOTP);
655
656 assert_eq!(items[2].account(), "Sophia");
657 assert_eq!(items[2].issuer(), "Boeing");
658 assert_eq!(items[2].secret(), "JRZCL47CMXVOQMNPZR2F7J4RGI");
659 assert_eq!(items[2].period(), Some(30));
660 assert_eq!(items[2].algorithm(), Algorithm::SHA1);
661 assert_eq!(items[2].digits(), Some(5));
662 assert_eq!(items[2].counter(), None);
663 assert_eq!(items[2].method(), Method::Steam);
664 }
665
666 #[test]
667 fn parse_encrypted() {
668 let data = std::fs::read_to_string("./src/backup/tests/aegis_encrypted.json").unwrap();
671 let items = Aegis::restore_from_data(data.as_bytes(), Some("test")).unwrap();
672
673 assert_eq!(items[0].account(), "Mason");
674 assert_eq!(items[0].issuer(), "Deno");
675 assert_eq!(items[0].secret(), "4SJHB4GSD43FZBAI7C2HLRJGPQ");
676 assert_eq!(items[0].period(), Some(30));
677 assert_eq!(items[0].algorithm(), Algorithm::SHA1);
678 assert_eq!(items[0].digits(), Some(6));
679 assert_eq!(items[0].counter(), None);
680 assert_eq!(items[0].method(), Method::TOTP);
681
682 assert_eq!(items[3].account(), "James");
683 assert_eq!(items[3].issuer(), "Issuu");
684 assert_eq!(items[3].secret(), "YOOMIXWS5GN6RTBPUFFWKTW5M4");
685 assert_eq!(items[3].period(), None);
686 assert_eq!(items[3].algorithm(), Algorithm::SHA1);
687 assert_eq!(items[3].digits(), Some(6));
688 assert_eq!(items[3].counter(), Some(1));
689 assert_eq!(items[3].method(), Method::HOTP);
690
691 assert_eq!(items[6].account(), "Sophia");
692 assert_eq!(items[6].issuer(), "Boeing");
693 assert_eq!(items[6].secret(), "JRZCL47CMXVOQMNPZR2F7J4RGI");
694 assert_eq!(items[6].period(), Some(30));
695 assert_eq!(items[6].algorithm(), Algorithm::SHA1);
696 assert_eq!(items[6].digits(), Some(5));
697 assert_eq!(items[6].counter(), None);
698 assert_eq!(items[6].method(), Method::Steam);
699 }
700
701 }