Skip to content

Commit d75672a

Browse files
authored
Implement FreeOTP+ exporter (#384)
2 parents e8e0ac2 + 949cdc5 commit d75672a

File tree

12 files changed

+282
-33
lines changed

12 files changed

+282
-33
lines changed

.github/workflows/build.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ on:
33
pull_request:
44
push:
55
branches:
6-
- master
6+
- main
77

88
jobs:
99
lints:

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "cotp"
3-
version = "1.3.0"
3+
version = "1.4.0"
44
authors = ["replydev <[email protected]>"]
55
edition = "2021"
66
description = "Trustworthy, encrypted, command-line TOTP/HOTP authenticator app with import functionality."
@@ -29,7 +29,6 @@ strip = "symbols"
2929
[dependencies]
3030
serde = { version = "1.0.196", features = ["derive"] }
3131
serde_json = "1.0.113"
32-
serde = { version = "1.0.195", features = ["derive"] }
3332
dirs = "5.0.1"
3433
rpassword = "7.3.1"
3534
data-encoding = "2.5.0"

src/args.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,10 @@ pub struct ExportFormat {
200200
/// Export into an OTP URI
201201
#[arg(short, long = "otp-uri")]
202202
pub otp_uri: bool,
203+
204+
/// Export into the FreeOTP+ database format
205+
#[arg(short, long = "freeotp-plus")]
206+
pub freeotp_plus: bool,
203207
}
204208

205209
impl Default for ExportFormat {
@@ -208,6 +212,7 @@ impl Default for ExportFormat {
208212
cotp: true,
209213
andotp: false,
210214
otp_uri: false,
215+
freeotp_plus: false,
211216
}
212217
}
213218
}

src/argument_functions.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,9 @@ pub fn export(matches: ExportArgs, database: OTPDatabase) -> color_eyre::Result<
149149
} else if export_format.otp_uri {
150150
let otp_uri_list: OtpUriList = (&database).into();
151151
do_export(&otp_uri_list, exported_path)
152+
} else if export_format.freeotp_plus {
153+
let freeotp_plus: FreeOTPPlusJson = (&database).try_into()?;
154+
do_export(&freeotp_plus, exported_path)
152155
} else {
153156
unreachable!("Unreachable code");
154157
}

src/exporters/freeotp_plus.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
use color_eyre::eyre::{ErrReport, Result};
2+
use data_encoding::BASE32_NOPAD;
3+
4+
use crate::{
5+
importers::freeotp_plus::{FreeOTPElement, FreeOTPPlusJson},
6+
otp::otp_element::{OTPDatabase, OTPElement},
7+
};
8+
9+
impl TryFrom<&OTPDatabase> for FreeOTPPlusJson {
10+
type Error = ErrReport;
11+
fn try_from(otp_database: &OTPDatabase) -> Result<Self, Self::Error> {
12+
otp_database
13+
.elements
14+
.iter()
15+
.map(|e| e.try_into())
16+
.collect::<Result<Vec<FreeOTPElement>, ErrReport>>()
17+
.map(FreeOTPPlusJson::new)
18+
}
19+
}
20+
21+
impl TryFrom<&OTPElement> for FreeOTPElement {
22+
type Error = ErrReport;
23+
fn try_from(otp_element: &OTPElement) -> Result<Self, Self::Error> {
24+
Ok(FreeOTPElement {
25+
secret: decode_secret(otp_element.secret.clone())?,
26+
algo: otp_element.algorithm.to_string(),
27+
counter: otp_element.counter.unwrap_or(0),
28+
digits: otp_element.digits,
29+
issuer_ext: otp_element.issuer.clone(),
30+
_label: otp_element.label.clone(),
31+
period: otp_element.period,
32+
_type: otp_element.type_.to_string(),
33+
})
34+
}
35+
}
36+
37+
fn decode_secret(secret: String) -> Result<Vec<i8>> {
38+
BASE32_NOPAD
39+
.decode(secret.as_bytes())
40+
.map(|v| v.into_iter().map(|n| n as i8).collect::<Vec<i8>>())
41+
.map_err(ErrReport::from)
42+
}

src/exporters/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use serde::Serialize;
44
use zeroize::Zeroize;
55

66
pub mod andotp;
7+
pub mod freeotp_plus;
78
pub mod otp_uri;
89

910
pub fn do_export<T>(to_be_saved: &T, exported_path: PathBuf) -> Result<PathBuf, String>

src/importers/freeotp_plus.rs

Lines changed: 140 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,49 @@
11
use data_encoding::BASE32_NOPAD;
2-
use serde::Deserialize;
2+
use serde::{Deserialize, Serialize};
33

44
use crate::otp::{otp_algorithm::OTPAlgorithm, otp_element::OTPElement, otp_type::OTPType};
55

6-
#[derive(Deserialize)]
6+
#[derive(Serialize, Deserialize)]
77
pub struct FreeOTPPlusJson {
88
#[serde(rename = "tokenOrder")]
99
token_order: Vec<String>,
1010
tokens: Vec<FreeOTPElement>,
1111
}
1212

13-
#[derive(Deserialize)]
14-
struct FreeOTPElement {
15-
algo: String,
16-
counter: u64,
17-
digits: u64,
13+
impl FreeOTPPlusJson {
14+
/// Creates a new instance of FreeOTPPlusJSON. Currently we clone the tokens label to retrieve the tokens order.
15+
pub fn new(tokens: Vec<FreeOTPElement>) -> Self {
16+
let token_order: Vec<String> = tokens
17+
.iter()
18+
.map(|e| {
19+
if e.issuer_ext.is_empty() {
20+
e._label.clone()
21+
} else {
22+
format!("{}:{}", e.issuer_ext, e._label)
23+
}
24+
})
25+
.collect();
26+
27+
Self {
28+
token_order,
29+
tokens,
30+
}
31+
}
32+
}
33+
34+
#[derive(Serialize, Deserialize, PartialEq, Debug)]
35+
pub struct FreeOTPElement {
36+
pub algo: String,
37+
pub counter: u64,
38+
pub digits: u64,
1839
#[serde(rename = "issuerExt")]
19-
issuer_ext: String,
40+
pub issuer_ext: String,
2041
#[serde(rename = "label")]
21-
_label: String,
22-
period: u64,
23-
secret: Vec<i8>,
42+
pub _label: String,
43+
pub period: u64,
44+
pub secret: Vec<i8>,
2445
#[serde(rename = "type")]
25-
_type: String,
46+
pub _type: String,
2647
}
2748

2849
impl From<FreeOTPElement> for OTPElement {
@@ -49,20 +70,7 @@ impl From<FreeOTPElement> for OTPElement {
4970
impl TryFrom<FreeOTPPlusJson> for Vec<OTPElement> {
5071
type Error = String;
5172
fn try_from(freeotp: FreeOTPPlusJson) -> Result<Self, Self::Error> {
52-
Ok(freeotp
53-
.tokens
54-
.into_iter()
55-
.enumerate()
56-
.map(|(i, mut token)| {
57-
token._label = freeotp
58-
.token_order
59-
.get(i)
60-
.unwrap_or(&String::from("No Label"))
61-
.to_owned();
62-
token
63-
})
64-
.map(|e| e.into())
65-
.collect())
73+
Ok(freeotp.tokens.into_iter().map(|e| e.into()).collect())
6674
}
6775
}
6876

@@ -78,7 +86,19 @@ fn encode_secret(secret: &[i8]) -> String {
7886

7987
#[cfg(test)]
8088
mod tests {
81-
use super::encode_secret;
89+
use std::path::PathBuf;
90+
91+
use crate::{
92+
importers::{freeotp_plus::FreeOTPElement, importer::import_from_path},
93+
otp::{otp_algorithm::OTPAlgorithm, otp_element::OTPElement, otp_type::OTPType},
94+
};
95+
96+
use std::fs;
97+
98+
use crate::otp::otp_element::OTPDatabase;
99+
use color_eyre::Result;
100+
101+
use super::{encode_secret, FreeOTPPlusJson};
82102

83103
#[test]
84104
fn test_secret_conversion() {
@@ -92,4 +112,97 @@ mod tests {
92112
String::from("3ZUNXX2SU6RZP4QFMS32YAILJFS2I2T2UZXYSHSX7IIMPAQZAC753NG2")
93113
);
94114
}
115+
116+
#[test]
117+
fn test_conversion() {
118+
let imported = import_from_path::<FreeOTPPlusJson>(PathBuf::from(
119+
"test_samples/freeotp_plus_example1.json",
120+
));
121+
122+
assert_eq!(
123+
vec![
124+
OTPElement {
125+
secret: "AAAAAAAAAAAAAAAA".to_string(),
126+
issuer: "Example2".to_string(),
127+
label: "Label2".to_string(),
128+
digits: 6,
129+
type_: OTPType::Totp,
130+
algorithm: OTPAlgorithm::Sha1,
131+
period: 30,
132+
counter: None,
133+
pin: None
134+
},
135+
OTPElement {
136+
secret: "AAAAAAAA".to_string(),
137+
issuer: "Example1".to_string(),
138+
label: "Label1".to_string(),
139+
digits: 6,
140+
type_: OTPType::Totp,
141+
algorithm: OTPAlgorithm::Sha256,
142+
period: 30,
143+
counter: None,
144+
pin: None
145+
}
146+
],
147+
imported.unwrap()
148+
)
149+
}
150+
151+
#[test]
152+
fn test_freeotp_export() {
153+
// Arrange
154+
let input_json: String = fs::read_to_string(PathBuf::from("test_samples/cotp_input.json"))
155+
.expect("Cannot read input file for test");
156+
let input_cotp_database: OTPDatabase =
157+
serde_json::from_str(input_json.as_str()).expect("Cannot deserialize into input JSON");
158+
159+
// Act
160+
let converted: Result<FreeOTPPlusJson> = (&input_cotp_database).try_into();
161+
162+
// Assert
163+
let free_otp = converted.unwrap();
164+
165+
assert_eq!(
166+
vec!["label1".to_string(), "ciccio:label2".to_string()],
167+
free_otp.token_order
168+
);
169+
170+
assert_eq!(
171+
vec![
172+
FreeOTPElement {
173+
algo: "SHA1".to_string(),
174+
counter: 0,
175+
digits: 6,
176+
issuer_ext: String::default(),
177+
_label: "label1".to_string(),
178+
period: 30,
179+
secret: vec![
180+
7, -40, 73, 126, -112, -25, 37, 28, 72, -39, 115, 50, -127, 46, 74, 117,
181+
-40, 124, -109, 58, -19, 54, 35, 117, -120, -106, -40, -39, -116, 107,
182+
-123, 127, 111, -93, -71, 6, 92, -116, 31, 4, 103, -59, 75, -106, 57, 54,
183+
-3, 104, 103, -26, -57, 59, -69, 98, -16, -102, 91, 89, 98, 90, -100, -21,
184+
44, 28, -105, -45, 92, -128, 82, 30, -23, -105, -30, 91, 17, -51, 24, -7,
185+
-61, 75, -38, -116, -122, 106, 79, 37, 82, -62, -125, -30, -27, 116, 116,
186+
82, -55, 72, 87, 41, 15, -25, -27, 65, 6, -104, 49, -26, -111, 10
187+
],
188+
_type: "TOTP".to_string()
189+
},
190+
FreeOTPElement {
191+
algo: "SHA256".to_string(),
192+
counter: 3,
193+
digits: 6,
194+
issuer_ext: "ciccio".to_string(),
195+
_label: "label2".to_string(),
196+
period: 30,
197+
secret: vec![
198+
35, -75, 13, 47, -2, -128, -100, -27, 64, -115, -72, 14, -78, -122, 88, 62,
199+
-32, 57, 37, -111, 90, -70, -58, -15, -113, 111, -94, 91, -90, 90, -91, 61,
200+
-9, -23, 54, 4, -31, -93, -8, -9, 27, 125, -21, 112, -80, -30, 64, 46, 10
201+
],
202+
_type: "HOTP".to_string()
203+
}
204+
],
205+
free_otp.tokens
206+
)
207+
}
95208
}

src/otp/otp_algorithm.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,13 @@ pub enum OTPAlgorithm {
1515

1616
impl fmt::Display for OTPAlgorithm {
1717
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
18-
write!(f, "{self:?}")
18+
let to_string = match self {
19+
OTPAlgorithm::Sha1 => "SHA1",
20+
OTPAlgorithm::Sha256 => "SHA256",
21+
OTPAlgorithm::Sha512 => "SHA512",
22+
OTPAlgorithm::Md5 => "MD5",
23+
};
24+
write!(f, "{to_string}")
1925
}
2026
}
2127

src/otp/otp_type.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,14 @@ pub enum OTPType {
2626

2727
impl fmt::Display for OTPType {
2828
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
29-
write!(f, "{self:?}")
29+
let to_string = match self {
30+
OTPType::Totp => "TOTP",
31+
OTPType::Hotp => "HOTP",
32+
OTPType::Steam => "STEAM",
33+
OTPType::Yandex => "YANDEX",
34+
OTPType::Motp => "MOTP",
35+
};
36+
write!(f, "{to_string}")
3037
}
3138
}
3239

test_samples/cotp_input.json

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"version": 2,
3+
"elements": [
4+
{
5+
"secret": "A7MES7UQ44SRYSGZOMZICLSKOXMHZEZ25U3CG5MIS3MNTDDLQV7W7I5ZAZOIYHYEM7CUXFRZG36WQZ7GY453WYXQTJNVSYS2TTVSYHEX2NOIAUQ65GL6EWYRZUMPTQ2L3KGIM2SPEVJMFA7C4V2HIUWJJBLSSD7H4VAQNGBR42IQU",
6+
"issuer": "",
7+
"label": "label1",
8+
"digits": 6,
9+
"type": "TOTP",
10+
"algorithm": "SHA1",
11+
"period": 30,
12+
"counter": null,
13+
"pin": null
14+
},
15+
{
16+
"secret": "EO2Q2L76QCOOKQENXAHLFBSYH3QDSJMRLK5MN4MPN6RFXJS2UU67P2JWATQ2H6HXDN66W4FQ4JAC4CQ",
17+
"issuer": "ciccio",
18+
"label": "label2",
19+
"digits": 6,
20+
"type": "HOTP",
21+
"algorithm": "SHA256",
22+
"period": 30,
23+
"counter": 3,
24+
"pin": null
25+
}
26+
]
27+
}

0 commit comments

Comments
 (0)