Add import of Raivo OTP files

This adds support for importing exports from [Raivo OTP], which are
AES-encrypted ZIP archives.

[Raivo OTP]: https://raivo-otp.com/
This commit is contained in:
Evan Hahn 2023-12-07 09:13:23 -06:00
parent cfeb8797e7
commit b2e2f3e5ed
6 changed files with 284 additions and 6 deletions

124
Cargo.lock generated
View file

@ -278,6 +278,7 @@ dependencies = [
"uuid",
"zbar-rust",
"zeroize",
"zip",
]
[[package]]
@ -307,6 +308,12 @@ version = "0.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ba43ea6f343b788c8764558649e08df62f86c6ef251fdaeb1ffd010a9ae50a2"
[[package]]
name = "base64ct"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b"
[[package]]
name = "bitflags"
version = "1.3.2"
@ -327,7 +334,7 @@ checksum = "23285ad32269793932e830392f2fe2f83e26488fd3ec778883a93c8323735780"
dependencies = [
"arrayref",
"arrayvec",
"constant_time_eq",
"constant_time_eq 0.3.0",
]
[[package]]
@ -400,6 +407,27 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223"
[[package]]
name = "bzip2"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8"
dependencies = [
"bzip2-sys",
"libc",
]
[[package]]
name = "bzip2-sys"
version = "0.1.11+1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "736a955f3fa7875102d57c82b8cac37ec45224a07fd32d58f9f7a186b6cd4cdc"
dependencies = [
"cc",
"libc",
"pkg-config",
]
[[package]]
name = "cairo-rs"
version = "0.18.2"
@ -471,6 +499,7 @@ version = "1.0.83"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0"
dependencies = [
"jobserver",
"libc",
]
@ -534,6 +563,12 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "constant_time_eq"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc"
[[package]]
name = "constant_time_eq"
version = "0.3.0"
@ -1890,6 +1925,15 @@ version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38"
[[package]]
name = "jobserver"
version = "0.1.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c37f63953c4c63420ed5fd3d6d398c719489b9f872b9fa683262f8edd363c7d"
dependencies = [
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.64"
@ -2285,7 +2329,7 @@ dependencies = [
"num",
"num-bigint-dig",
"once_cell",
"pbkdf2",
"pbkdf2 0.12.2",
"rand",
"serde",
"sha2",
@ -2418,12 +2462,35 @@ dependencies = [
"windows-targets",
]
[[package]]
name = "password-hash"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700"
dependencies = [
"base64ct",
"rand_core",
"subtle",
]
[[package]]
name = "paste"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c"
[[package]]
name = "pbkdf2"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917"
dependencies = [
"digest",
"hmac",
"password-hash",
"sha2",
]
[[package]]
name = "pbkdf2"
version = "0.12.2"
@ -2774,7 +2841,7 @@ checksum = "1e71971821b3ae0e769e4a4328dbcb517607b434db7697e9aba17203ec14e46a"
dependencies = [
"base64",
"blake2b_simd",
"constant_time_eq",
"constant_time_eq 0.3.0",
]
[[package]]
@ -2873,7 +2940,7 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f"
dependencies = [
"pbkdf2",
"pbkdf2 0.12.2",
"salsa20",
"sha2",
]
@ -3845,6 +3912,55 @@ dependencies = [
"syn 2.0.37",
]
[[package]]
name = "zip"
version = "0.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261"
dependencies = [
"aes",
"byteorder",
"bzip2",
"constant_time_eq 0.1.5",
"crc32fast",
"crossbeam-utils",
"flate2",
"hmac",
"pbkdf2 0.11.0",
"sha1",
"time",
"zstd",
]
[[package]]
name = "zstd"
version = "0.11.2+zstd.1.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4"
dependencies = [
"zstd-safe",
]
[[package]]
name = "zstd-safe"
version = "5.0.2+zstd.1.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db"
dependencies = [
"libc",
"zstd-sys",
]
[[package]]
name = "zstd-sys"
version = "2.0.9+zstd.1.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e16efa8a874a0481a574084d34cc26fdb3b99627480f785888deb6386506656"
dependencies = [
"cc",
"pkg-config",
]
[[package]]
name = "zvariant"
version = "3.15.0"

View file

@ -48,3 +48,4 @@ url = "2.2"
uuid = {version = "1.0", features = ["v4"]}
zbar-rust = "0.0"
zeroize = {version = "1", features = ["zeroize_derive"]}
zip = { version = "0.6.6", features = ["aes-crypto"] }

View file

@ -93,7 +93,8 @@ mod freeotp;
mod freeotp_json;
mod google;
mod legacy;
mod raivootp;
pub use self::{
aegis::Aegis, andotp::AndOTP, bitwarden::Bitwarden, freeotp::FreeOTP,
freeotp_json::FreeOTPJSON, google::Google, legacy::LegacyAuthenticator,
freeotp_json::FreeOTPJSON, google::Google, legacy::LegacyAuthenticator, raivootp::RaivoOTP,
};

159
src/backup/raivootp.rs Normal file
View file

@ -0,0 +1,159 @@
use anyhow::Result;
use gettextrs::gettext;
use serde::{de::Deserializer, Deserialize, Serialize};
use std::io::Cursor;
use zip::{self, ZipArchive};
use super::{Restorable, RestorableItem};
use crate::models::{Algorithm, Method};
#[allow(clippy::upper_case_acronyms)]
#[derive(Serialize, Deserialize)]
pub struct RaivoOTP;
/// A Raivo OTP entry.
///
/// [See Raivo's source code for each item's serialized form.][0]
///
/// [0]: https://github.com/raivo-otp/ios-application/blob/3a8aaa0ea16a761e6205abd2700ac90dd4c9c9b6/Raivo/Models/Password.swift#L104-L116
#[derive(Deserialize)]
pub struct Item {
issuer: String,
account: String,
secret: String,
algorithm: Algorithm,
#[serde(deserialize_with = "deserialize_raivo_u32")]
digits: Option<u32>,
#[serde(rename = "kind")]
method: Method,
#[serde(rename = "timer")]
#[serde(deserialize_with = "deserialize_raivo_u32")]
period: Option<u32>,
#[serde(deserialize_with = "deserialize_raivo_u32")]
counter: Option<u32>,
}
fn deserialize_raivo_u32<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
where
D: Deserializer<'de>,
{
let n: u32 = String::deserialize(deserializer)?
.parse()
.map_err(serde::de::Error::custom)?;
Ok(Some(n))
}
impl RestorableItem for Item {
fn account(&self) -> String {
self.account.clone()
}
fn issuer(&self) -> String {
self.issuer.clone()
}
fn secret(&self) -> String {
self.secret.clone()
}
fn period(&self) -> Option<u32> {
match self.method() {
Method::TOTP => self.period,
Method::HOTP | Method::Steam => None,
}
}
fn method(&self) -> Method {
self.method
}
fn algorithm(&self) -> Algorithm {
self.algorithm
}
fn digits(&self) -> Option<u32> {
self.digits
}
fn counter(&self) -> Option<u32> {
match self.method() {
Method::HOTP => self.counter,
Method::TOTP | Method::Steam => None,
}
}
}
impl Restorable for RaivoOTP {
const ENCRYPTABLE: bool = true;
const SCANNABLE: bool = false;
const IDENTIFIER: &'static str = "raivootp";
type Item = Item;
fn title() -> String {
gettext("Raivo OTP")
}
fn subtitle() -> String {
gettext("From a ZIP export generated by Raivo OTP")
}
/// Restore from a ZIP file generated by Raivo OTP.
///
/// See Raivo's source code for [exporting the AES-encrypted ZIP][0].
///
/// [0]: https://github.com/raivo-otp/ios-application/blob/3a8aaa0ea16a761e6205abd2700ac90dd4c9c9b6/Raivo/Features/DataExportFeature.swift#L188-L195
fn restore_from_data(from: &[u8], key: Option<&str>) -> Result<Vec<Self::Item>> {
let password: &[u8] = match key {
None => &[],
Some(k) => k.as_bytes(),
};
let mut archive = ZipArchive::new(Cursor::new(from))?;
let file = archive.by_name_decrypt("raivo-otp-export.json", password)??;
let items = serde_json::from_reader(file)?;
Ok(items)
}
}
#[cfg(test)]
mod tests {
use super::{super::RestorableItem, *};
use crate::models::{Algorithm, Method};
#[test]
fn parse() {
let data = std::fs::read("./src/backup/tests/raivootp.zip").unwrap();
let items = RaivoOTP::restore_from_data(&data, Some("RaivoTest123")).unwrap();
assert_eq!(items.len(), 2);
assert_eq!(items[0].account(), "mason");
assert_eq!(items[0].issuer(), "Example A");
assert_eq!(items[0].secret(), "ABCDEFGHIJKLMNOPQRSTUVWXYZ23456789");
assert_eq!(items[0].period(), Some(30));
assert_eq!(items[0].method(), Method::TOTP);
assert_eq!(items[0].algorithm(), Algorithm::SHA1);
assert_eq!(items[0].digits(), Some(6));
assert_eq!(items[0].counter(), None);
assert_eq!(items[1].account(), "james");
assert_eq!(items[1].issuer(), "Example B");
assert_eq!(items[1].secret(), "12345678ABCDEFGHIJKLMNOPQRSTUVWXYZ");
assert_eq!(items[1].period(), Some(123));
assert_eq!(items[1].method(), Method::TOTP);
assert_eq!(items[1].algorithm(), Algorithm::SHA256);
assert_eq!(items[1].digits(), Some(8));
assert_eq!(items[1].counter(), None);
}
#[test]
fn invalid_zip() {
let data: [u8; 3] = [1, 2, 3];
assert!(RaivoOTP::restore_from_data(&data, Some("RaivoTest123")).is_err());
}
#[test]
fn invalid_password() {
let data = std::fs::read("./src/backup/tests/raivootp.zip").unwrap();
assert!(RaivoOTP::restore_from_data(&data, Some("bad password")).is_err());
}
}

Binary file not shown.

View file

@ -11,7 +11,7 @@ use super::{camera_page::CameraPage, password_page::PasswordPage};
use crate::{
backup::{
Aegis, AndOTP, Backupable, Bitwarden, FreeOTP, FreeOTPJSON, Google, LegacyAuthenticator,
Operation, Restorable, RestorableItem,
Operation, RaivoOTP, Restorable, RestorableItem,
},
models::{ProvidersModel, SETTINGS},
utils::spawn,
@ -174,6 +174,7 @@ impl PreferencesWindow {
self.register_restore::<Bitwarden>(&["application/json"]);
self.register_restore::<Google>(&[]);
self.register_restore::<LegacyAuthenticator>(&["application/json"]);
self.register_restore::<RaivoOTP>(&["application/zip"]);
}
fn register_backup<T: Backupable>(&self, filters: &'static [&str]) {