authenticator/backup/
aegis.rs

1//! Aegis Import/Export Module
2//!
3//! See <https://github.com/beemdevelopment/Aegis/blob/master/docs/vault.md> for a description of the
4//! aegis vault format.
5//!
6//! This module does not convert all information from aegis (note, icon, group
7//! are lost). When exporting to the aegis json format the icon, url, help url,
8//! and tags are lost.
9//!
10//! Exported files by this module cannot be decrypted by the python script
11//! provided in the aegis repository (<https://github.com/beemdevelopment/Aegis/blob/master/docs/decrypt.py>). However,
12//! aegis android app is able to read the files! See line 173 for a discussion.
13
14use 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/// Plaintext version of the JSON format.
33#[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/// Encrypted version of the JSON format. `db` is simply a base64 encoded string
54/// with an encrypted AegisDatabase.
55#[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            // This is an implementation error. Thus, panic is here okay.
74            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        // Create a new master key
80        let mut rng = rand::rng();
81        let mut master_key = [0u8; 32];
82        rng.fill_bytes(&mut master_key);
83
84        // Create a new header (including defaults for a password slot)
85        let mut header = Header {
86            params: Some(HeaderParam::default()),
87            slots: Some(vec![HeaderSlot::default()]),
88        };
89
90        // We only support password encrypted database so far so we don't have to do any
91        // checks for the slot type
92        let password_slot = &mut header.slots.as_mut().unwrap().get_mut(0).unwrap();
93        // Derive key from given password
94        let mut derived_key: [u8; 32] = [0u8; 32];
95        let params = scrypt::Params::new(
96            // TODO log2 for u64 is not stable yet. Change this in the future.
97            (password_slot.n() as f64).log2() as u8,
98            password_slot.r(),
99            password_slot.p(),
100            scrypt::Params::RECOMMENDED_LEN,
101        )
102        // All parameters are default values. Thus, this should always work and unwrap is okay.
103        .expect("Scrypt params creation");
104        scrypt::scrypt(
105            password.as_bytes(),
106            password_slot.salt(),
107            &params,
108            &mut derived_key,
109        )
110        .map_err(|_| anyhow::anyhow!("Scrypt key derivation"))?;
111
112        // Encrypt new master key with derived key
113        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        // Add encrypted master key and tag to our password slot. If this assignment
122        // fails, we have a mistake in our logic, thus unwrap is okay.
123        password_slot.key_params.tag = ciphertext.split_off(32).try_into().unwrap();
124        password_slot.key = ciphertext.try_into().unwrap();
125
126        // Finally, we get the JSON string for the database and encrypt it.
127        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            // This is an implementation error. Thus, panic is okay.
151            panic!("Encrypt can only be called on a plaintext object.")
152        }
153
154        Ok(())
155    }
156}
157
158/// Header of the Encrypted Aegis JSON File
159///
160/// Contains all necessary information for encrypting / decrypting the vault (db
161/// field).
162#[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/// Header Slots
171///
172/// Containts information to decrypt the master key.
173#[derive(Debug, Serialize, Deserialize)]
174pub struct HeaderSlot {
175    // We are not interested in biometric slots at the moment. Thus, we omit these information.
176    // However, in the future, authenticator app might be able to lock / unlock the database using
177    // fingerprint sensors (see <https://gitlab.gnome.org/World/Authenticator/-/issues/106> for more
178    // information). Thus, it might be possible to read also these biometric slots and unlock them
179    // with a fingerprint reader used by authenticar. However, it would be ncessary that aegis
180    // android app (thus the android system) and authenticator use the same mechanisms to derive
181    // keys from biometric input. This has to be checked beforehand.
182    //
183    // TODO rename should be changed to `rename = 2`. However this does not work yet with serde,
184    // see: <https://github.com/serde-rs/serde/issues/745>. This allows decrypting the exported file
185    // with the python script provided in the aegis repository. The python script expects an
186    // integer but we provide a string. Thus, change the string in header / slots / password
187    // slot / `type = "1"` to `type = 1` to use the python script.
188    #[serde(rename = "type")]
189    pub type_: u32,
190    pub uuid: String,
191    #[serde(with = "hex::serde")]
192    pub key: [u8; 32],
193    // First tuple entry is the nonce, the second is the tag.
194    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/// Parameters to Database Encryption
240#[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/// Contains All OTP Entries
262#[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/// An OTP Entry
278#[derive(Debug, Serialize, Deserialize)]
279pub struct Item {
280    #[serde(rename = "type")]
281    pub method: Method,
282    // UUID is omitted
283    #[serde(rename = "name")]
284    pub label: String,
285    pub issuer: Option<String>,
286    // TODO tags are not imported/exported right now.
287    #[serde(rename = "group")]
288    pub tags: Option<String>,
289    // Note is omitted
290    // Icon:
291    // TODO: Aegis encodes icons as JPEG's encoded in Base64 with padding. Does authenticator
292    // support this?
293    // TODO tags are not imported/exported right now.
294    #[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/// OTP Entry Details
342#[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        // Translators: This is for making a backup for the aegis Android app.
399        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        // Create structure
408        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        // Translators: This is for restoring a backup from the aegis Android app.
439        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        // TODO check whether file / database is encrypted by aegis
448        let aegis_root: Aegis = serde_json::de::from_slice(from)?;
449        let mut items = Vec::new();
450
451        // Check whether file is encrypted or in plaintext
452        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                // Check for correct aegis vault version and correct database version.
461                if plain_text.version != 1 {
462                    anyhow::bail!(
463                        "Aegis vault version expected to be 1. Found {} instead.",
464                        plain_text.version
465                    );
466                // There is no version 0. So this should be okay ...
467                } 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                // Check for correct aegis vault version and whether a password was supplied.
487                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                // Ciphertext is stored in base64, we have to decode it.
497                let mut ciphertext = data_encoding::BASE64
498                    .decode(encrypted.db.as_bytes())
499                    .context("Cannot decode (base64) encoded database")?;
500
501                // Add the encryption tag
502                ciphertext.append(&mut encrypted.header.params.as_ref().unwrap().tag.into());
503
504                // Find slots with type password and derive the corresponding key. This key is
505                // used to decrypt the master key which in turn can be used to
506                // decrypt the database.
507                let master_keys: Vec<Vec<u8>> = encrypted
508                    .header
509                    .slots
510                    .as_ref()
511                    .unwrap()
512                    .iter()
513                    .filter(|slot| slot.type_ == 1) // We don't handle biometric slots for now
514                    .map(|slot| -> Result<Vec<u8>> {
515                        tracing::info!("Found possible master key with UUID {}.", slot.uuid);
516
517                        // Create parameters for scrypt function and derive decryption key for
518                        // master key
519                        //
520                        // Somehow, scrypt errors do not implement StdErr and cannot be converted to
521                        // anyhow::Error. Should be possible but don't know why it doesn't work.
522                        let params = scrypt::Params::new(
523                            // TODO log2 for u64 is not stable yet. Change this in the future.
524                            (slot.n() as f64).log2() as u8, // Defaults to 15 by aegis
525                            slot.r(),                       // Defaults to 8 by aegis
526                            slot.p(),                       // Defaults to 1 by aegis
527                            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                            &params,
535                            &mut temp_key,
536                        )
537                        .map_err(|_| anyhow::anyhow!("Scrypt key derivation failed"))?;
538
539                        // Now, try to decrypt the master key.
540                        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                        // Here we get the master key. The decrypt function does not return an error
545                        // implementing std error. Thus, we have to convert it.
546                        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                    // Here, we don't want to fail the whole function because one key slot failed to
554                    // get the correct master key. Maybe there is another slot we were able to
555                    // decrypt.
556                    .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                // Choose the first valid master key. I don't think there are aegis
566                // installations with two valid password slots.
567                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                // Try to decrypt the database with this master key.
582                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                    // Decrypt does not return an error implementing std error, thus we convert it.
591                    .map_err(|_| anyhow::anyhow!("Cannot decrypt database"))?;
592
593                // Now, we have the decrypted string. Trying to load it with JSON.
594                let db: Database = serde_json::de::from_slice(&plaintext)
595                    .context("Deserialize decrypted database failed")?;
596
597                // Check version of the database
598                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                // Return items
607                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        // See <https://github.com/beemdevelopment/Aegis/blob/master/app/src/test/resources/com/beemdevelopment/aegis/importers/aegis_encrypted.json>
669        // for this example file.
670        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    // TODO: add tests for importing
702}