commit
73c3f54051
9 changed files with 671 additions and 38 deletions
169
Cargo.lock
generated
169
Cargo.lock
generated
|
@ -8,6 +8,18 @@ version = "1.0.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe"
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "495ee669413bfbe9e8cace80f4d3d78e6d8c8d99579f97fb93bde351b185f2d4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
"opaque-debug",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "0.7.15"
|
||||
|
@ -86,12 +98,43 @@ version = "1.0.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0d27fb6b6f1e43147af148af49d49329413ba781aa0d5e10979831c210173b5"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e11e16035ea35e4e5997b393eacbf6f63983188f7a2ad25bfb13465f5ad59de"
|
||||
dependencies = [
|
||||
"bit-vec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693"
|
||||
|
||||
[[package]]
|
||||
name = "block-modes"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2cb03d1bed155d89dce0f845b7899b18a9a163e148fd004e1c28421a783e2d8e"
|
||||
dependencies = [
|
||||
"block-padding",
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-padding"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8d696c370c750c948ada61c69a0ee2cbbb9c50b1019ddb86d9317157a99c2cae"
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.6.1"
|
||||
|
@ -135,6 +178,15 @@ dependencies = [
|
|||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ee52072ec15386f770805afd189a01c8841be8696bed250fa2f13c4c0d6dfb7"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "2.33.3"
|
||||
|
@ -211,6 +263,15 @@ version = "0.8.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea221b5284a47e40033bf9b66f35f984ec0ea2931eb03505246cd27a963f981b"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66c99696f6c9dd7f35d486b9d04d7e6e202aa3e8c40d553f2fdf5e7e0c6a71ef"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.2.1"
|
||||
|
@ -690,9 +751,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.90"
|
||||
version = "0.2.99"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba4aede83fc3617411dc6993bc8c70919750c1c257c6ca6a502aed6e0e2394ae"
|
||||
checksum = "a7f823d141fe0a24df1e23b4af4e3c7ba9e5966ec514ea068c93024aa7deb765"
|
||||
|
||||
[[package]]
|
||||
name = "libm"
|
||||
|
@ -897,10 +958,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "af8b08b04175473088b46763e51ee54da5f9a164bc162f615b91bc179dbf15a3"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.33"
|
||||
name = "opaque-debug"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a61075b62a23fef5a29815de7536d940aa35ce96d18ce0cc5076272db678a577"
|
||||
checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "549430950c79ae24e6d02e0b7404534ecf311d94cc9f861e9e4020187d13d885"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
|
@ -918,9 +985,9 @@ checksum = "77af24da69f9d9341038eba93a073b1fdaaa1b788221b00a69bce9e762cb32de"
|
|||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.61"
|
||||
version = "0.9.65"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "313752393519e876837e09e1fa183ddef0be7735868dced3196f4472d536277f"
|
||||
checksum = "7a7907e3bfa08bb85105209cdfcb6c63d109f8f6c1ed6ca318fff5c1853fbc1d"
|
||||
dependencies = [
|
||||
"autocfg 1.0.1",
|
||||
"cc",
|
||||
|
@ -1096,6 +1163,26 @@ dependencies = [
|
|||
"unicode-xid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proptest"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e0d9cc07f18492d879586c92b485def06bc850da3118075cd45d50e9c95b0e5"
|
||||
dependencies = [
|
||||
"bit-set",
|
||||
"bitflags",
|
||||
"byteorder",
|
||||
"lazy_static 1.4.0",
|
||||
"num-traits",
|
||||
"quick-error 2.0.1",
|
||||
"rand 0.8.4",
|
||||
"rand_chacha 0.3.0",
|
||||
"rand_xorshift",
|
||||
"regex-syntax",
|
||||
"rusty-fork",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "publicsuffix"
|
||||
version = "1.5.6"
|
||||
|
@ -1106,6 +1193,18 @@ dependencies = [
|
|||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "1.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.9"
|
||||
|
@ -1234,6 +1333,15 @@ dependencies = [
|
|||
"rand_core 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_xorshift"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d25bf25ec5ae4a3f1b92f929810509a2f53d7dca2f50b794ff57e3face536c8f"
|
||||
dependencies = [
|
||||
"rand_core 0.6.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rdrand"
|
||||
version = "0.4.0"
|
||||
|
@ -1337,6 +1445,21 @@ dependencies = [
|
|||
"winreg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.16.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"spin",
|
||||
"untrusted",
|
||||
"web-sys",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rpassword"
|
||||
version = "5.0.1"
|
||||
|
@ -1385,6 +1508,18 @@ dependencies = [
|
|||
"semver 0.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusty-fork"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb3dcc6e454c328bb824492db107ab7c0ae8fcffe4ad210136ef014458c1bc4f"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"quick-error 1.2.3",
|
||||
"tempfile",
|
||||
"wait-timeout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.5"
|
||||
|
@ -1706,17 +1841,21 @@ dependencies = [
|
|||
name = "steamguard-cli"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"anyhow",
|
||||
"base64",
|
||||
"block-modes",
|
||||
"clap",
|
||||
"cookie",
|
||||
"dirs",
|
||||
"hmac-sha1",
|
||||
"lazy_static 1.4.0",
|
||||
"log",
|
||||
"proptest",
|
||||
"rand 0.8.4",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"ring",
|
||||
"rpassword",
|
||||
"rsa",
|
||||
"serde",
|
||||
|
@ -1727,6 +1866,7 @@ dependencies = [
|
|||
"tempdir",
|
||||
"termion",
|
||||
"text_io",
|
||||
"thiserror",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
|
@ -2082,6 +2222,12 @@ dependencies = [
|
|||
"void",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.2.1"
|
||||
|
@ -2133,6 +2279,15 @@ version = "1.0.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d"
|
||||
|
||||
[[package]]
|
||||
name = "wait-timeout"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f200f5b12eb75f8c1ed65abd4b2db8a6e1b138a20de009dacee265a2498f3f6"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "want"
|
||||
version = "0.3.0"
|
||||
|
|
|
@ -34,6 +34,11 @@ uuid = { version = "0.8", features = ["v4"] }
|
|||
termion = "1.5.6"
|
||||
steamguard = { path = "./steamguard" }
|
||||
dirs = "3.0.2"
|
||||
ring = "0.16.20"
|
||||
aes = "0.7.4"
|
||||
block-modes = "0.8.1"
|
||||
thiserror = "1.0.26"
|
||||
|
||||
[dev-dependencies]
|
||||
tempdir = "0.3"
|
||||
proptest = "1"
|
||||
|
|
|
@ -1,15 +1,19 @@
|
|||
pub use crate::encryption::EntryEncryptionParams;
|
||||
use crate::encryption::EntryEncryptor;
|
||||
use log::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::{BufReader, Write};
|
||||
use std::fs::File;
|
||||
use std::io::{BufReader, Read, Write};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::{cell::Cell, fs::File};
|
||||
use steamguard::SteamGuardAccount;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Manifest {
|
||||
pub encrypted: bool,
|
||||
pub entries: Vec<ManifestEntry>,
|
||||
/// Not really used, kept mostly for compatibility with SDA.
|
||||
pub encrypted: bool,
|
||||
/// Not implemented, kept for compatibility with SDA.
|
||||
pub first_run: bool,
|
||||
/// Not implemented, kept for compatibility with SDA.
|
||||
|
@ -40,14 +44,6 @@ pub struct ManifestEntry {
|
|||
pub encryption: Option<EntryEncryptionParams>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EntryEncryptionParams {
|
||||
#[serde(rename = "encryption_iv")]
|
||||
pub iv: String,
|
||||
#[serde(rename = "encryption_salt")]
|
||||
pub salt: String,
|
||||
}
|
||||
|
||||
impl Default for Manifest {
|
||||
fn default() -> Self {
|
||||
Manifest {
|
||||
|
@ -84,13 +80,36 @@ impl Manifest {
|
|||
return Ok(manifest);
|
||||
}
|
||||
|
||||
pub fn load_accounts(&mut self) -> anyhow::Result<()> {
|
||||
pub fn load_accounts(
|
||||
&mut self,
|
||||
passkey: &Option<String>,
|
||||
) -> anyhow::Result<(), ManifestAccountLoadError> {
|
||||
for entry in &mut self.entries {
|
||||
let path = Path::new(&self.folder).join(&entry.filename);
|
||||
debug!("loading account: {:?}", path);
|
||||
let file = File::open(path)?;
|
||||
let reader = BufReader::new(file);
|
||||
let account: SteamGuardAccount = serde_json::from_reader(reader)?;
|
||||
let mut reader = BufReader::new(file);
|
||||
let account: SteamGuardAccount;
|
||||
match (passkey, entry.encryption.as_ref()) {
|
||||
(Some(passkey), Some(params)) => {
|
||||
let mut ciphertext: Vec<u8> = vec![];
|
||||
reader.read_to_end(&mut ciphertext)?;
|
||||
let plaintext = crate::encryption::LegacySdaCompatible::decrypt(
|
||||
passkey, params, ciphertext,
|
||||
)?;
|
||||
if plaintext[0] != '{' as u8 && plaintext[plaintext.len() - 1] != '}' as u8 {
|
||||
return Err(ManifestAccountLoadError::IncorrectPasskey);
|
||||
}
|
||||
let s = std::str::from_utf8(&plaintext).unwrap();
|
||||
account = serde_json::from_str(&s)?;
|
||||
}
|
||||
(None, Some(_)) => {
|
||||
return Err(ManifestAccountLoadError::MissingPasskey);
|
||||
}
|
||||
(_, None) => {
|
||||
account = serde_json::from_reader(reader)?;
|
||||
}
|
||||
};
|
||||
entry.account_name = account.account_name.clone();
|
||||
self.accounts.push(Arc::new(Mutex::new(account)));
|
||||
}
|
||||
|
@ -132,21 +151,37 @@ impl Manifest {
|
|||
self.entries.remove(index);
|
||||
}
|
||||
|
||||
pub fn save(&self) -> anyhow::Result<()> {
|
||||
pub fn save(&self, passkey: &Option<String>) -> anyhow::Result<()> {
|
||||
ensure!(
|
||||
self.entries.len() == self.accounts.len(),
|
||||
"Manifest entries don't match accounts."
|
||||
);
|
||||
for (entry, account) in self.entries.iter().zip(&self.accounts) {
|
||||
debug!("saving {}", entry.filename);
|
||||
let serialized = serde_json::to_string(account.as_ref())?;
|
||||
let serialized = serde_json::to_vec(account.as_ref())?;
|
||||
ensure!(
|
||||
serialized.len() > 2,
|
||||
"Something extra weird happened and the account was serialized into nothing."
|
||||
);
|
||||
|
||||
let final_buffer: Vec<u8>;
|
||||
match (passkey, entry.encryption.as_ref()) {
|
||||
(Some(passkey), Some(params)) => {
|
||||
final_buffer = crate::encryption::LegacySdaCompatible::encrypt(
|
||||
passkey, params, serialized,
|
||||
)?;
|
||||
}
|
||||
(None, Some(_)) => {
|
||||
bail!("maFiles are encrypted, but no passkey was provided.");
|
||||
}
|
||||
(_, None) => {
|
||||
final_buffer = serialized;
|
||||
}
|
||||
};
|
||||
|
||||
let path = Path::new(&self.folder).join(&entry.filename);
|
||||
let mut file = File::create(path)?;
|
||||
file.write_all(serialized.as_bytes())?;
|
||||
file.write_all(final_buffer.as_slice())?;
|
||||
file.sync_data()?;
|
||||
}
|
||||
debug!("saving manifest");
|
||||
|
@ -159,6 +194,41 @@ impl Manifest {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ManifestAccountLoadError {
|
||||
#[error("Manifest accounts are encrypted, but no passkey was provided.")]
|
||||
MissingPasskey,
|
||||
#[error("Incorrect passkey provided.")]
|
||||
IncorrectPasskey,
|
||||
#[error("Failed to decrypt account. {self:?}")]
|
||||
DecryptionFailed(#[from] crate::encryption::EntryEncryptionError),
|
||||
#[error("Failed to deserialize the account.")]
|
||||
DeserializationFailed(#[from] serde_json::Error),
|
||||
#[error(transparent)]
|
||||
Unknown(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
impl From<block_modes::BlockModeError> for ManifestAccountLoadError {
|
||||
fn from(error: block_modes::BlockModeError) -> Self {
|
||||
return Self::Unknown(anyhow::Error::from(error));
|
||||
}
|
||||
}
|
||||
impl From<base64::DecodeError> for ManifestAccountLoadError {
|
||||
fn from(error: base64::DecodeError) -> Self {
|
||||
return Self::Unknown(anyhow::Error::from(error));
|
||||
}
|
||||
}
|
||||
impl From<block_modes::InvalidKeyIvLength> for ManifestAccountLoadError {
|
||||
fn from(error: block_modes::InvalidKeyIvLength) -> Self {
|
||||
return Self::Unknown(anyhow::Error::from(error));
|
||||
}
|
||||
}
|
||||
impl From<std::io::Error> for ManifestAccountLoadError {
|
||||
fn from(error: std::io::Error) -> Self {
|
||||
return Self::Unknown(anyhow::Error::from(error));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
@ -169,7 +239,7 @@ mod tests {
|
|||
let tmp_dir = TempDir::new("steamguard-cli-test").unwrap();
|
||||
let manifest_path = tmp_dir.path().join("manifest.json");
|
||||
let manifest = Manifest::new(manifest_path.as_path());
|
||||
assert!(matches!(manifest.save(), Ok(_)));
|
||||
assert!(matches!(manifest.save(&None), Ok(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
@ -182,12 +252,12 @@ mod tests {
|
|||
account.revocation_code = "R12345".into();
|
||||
account.shared_secret = "secret".into();
|
||||
manifest.add_account(account);
|
||||
assert!(matches!(manifest.save(), Ok(_)));
|
||||
assert!(matches!(manifest.save(&None), Ok(_)));
|
||||
|
||||
let mut loaded_manifest = Manifest::load(manifest_path.as_path()).unwrap();
|
||||
assert_eq!(loaded_manifest.entries.len(), 1);
|
||||
assert_eq!(loaded_manifest.entries[0].filename, "asdf1234.maFile");
|
||||
assert!(matches!(loaded_manifest.load_accounts(), Ok(_)));
|
||||
assert!(matches!(loaded_manifest.load_accounts(&None), Ok(_)));
|
||||
assert_eq!(
|
||||
loaded_manifest.entries.len(),
|
||||
loaded_manifest.accounts.len()
|
||||
|
@ -206,6 +276,82 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_save_and_load_manifest_encrypted() {
|
||||
let passkey: Option<String> = Some("password".into());
|
||||
let tmp_dir = TempDir::new("steamguard-cli-test").unwrap();
|
||||
let manifest_path = tmp_dir.path().join("manifest.json");
|
||||
let mut manifest = Manifest::new(manifest_path.as_path());
|
||||
let mut account = SteamGuardAccount::new();
|
||||
account.account_name = "asdf1234".into();
|
||||
account.revocation_code = "R12345".into();
|
||||
account.shared_secret = "secret".into();
|
||||
manifest.add_account(account);
|
||||
manifest.entries[0].encryption = Some(EntryEncryptionParams::generate());
|
||||
assert!(matches!(manifest.save(&passkey), Ok(_)));
|
||||
|
||||
let mut loaded_manifest = Manifest::load(manifest_path.as_path()).unwrap();
|
||||
assert_eq!(loaded_manifest.entries.len(), 1);
|
||||
assert_eq!(loaded_manifest.entries[0].filename, "asdf1234.maFile");
|
||||
assert!(matches!(loaded_manifest.load_accounts(&passkey), Ok(_)));
|
||||
assert_eq!(
|
||||
loaded_manifest.entries.len(),
|
||||
loaded_manifest.accounts.len()
|
||||
);
|
||||
assert_eq!(
|
||||
loaded_manifest.accounts[0].lock().unwrap().account_name,
|
||||
"asdf1234"
|
||||
);
|
||||
assert_eq!(
|
||||
loaded_manifest.accounts[0].lock().unwrap().revocation_code,
|
||||
"R12345"
|
||||
);
|
||||
assert_eq!(
|
||||
loaded_manifest.accounts[0].lock().unwrap().shared_secret,
|
||||
"secret"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_save_and_load_manifest_encrypted_longer() -> anyhow::Result<()> {
|
||||
let passkey: Option<String> = Some("password".into());
|
||||
let tmp_dir = TempDir::new("steamguard-cli-test").unwrap();
|
||||
let manifest_path = tmp_dir.path().join("manifest.json");
|
||||
let mut manifest = Manifest::new(manifest_path.as_path());
|
||||
let mut account = SteamGuardAccount::new();
|
||||
account.account_name = "asdf1234".into();
|
||||
account.revocation_code = "R12345".into();
|
||||
account.shared_secret = "secret".into();
|
||||
account.uri = "otpauth://;laksdjf;lkasdjf;lkasdj;flkasdjlkf;asjdlkfjslk;adjfl;kasdjf;lksdjflk;asjd;lfajs;ldkfjaslk;djf;lsakdjf;lksdj".into();
|
||||
account.token_gid = "asdf1234".into();
|
||||
manifest.add_account(account);
|
||||
manifest.entries[0].encryption = Some(EntryEncryptionParams::generate());
|
||||
manifest.save(&passkey)?;
|
||||
|
||||
let mut loaded_manifest = Manifest::load(manifest_path.as_path())?;
|
||||
assert_eq!(loaded_manifest.entries.len(), 1);
|
||||
assert_eq!(loaded_manifest.entries[0].filename, "asdf1234.maFile");
|
||||
loaded_manifest.load_accounts(&passkey)?;
|
||||
assert_eq!(
|
||||
loaded_manifest.entries.len(),
|
||||
loaded_manifest.accounts.len()
|
||||
);
|
||||
assert_eq!(
|
||||
loaded_manifest.accounts[0].lock().unwrap().account_name,
|
||||
"asdf1234"
|
||||
);
|
||||
assert_eq!(
|
||||
loaded_manifest.accounts[0].lock().unwrap().revocation_code,
|
||||
"R12345"
|
||||
);
|
||||
assert_eq!(
|
||||
loaded_manifest.accounts[0].lock().unwrap().shared_secret,
|
||||
"secret"
|
||||
);
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_import() {
|
||||
let tmp_dir = TempDir::new("steamguard-cli-test").unwrap();
|
||||
|
@ -216,7 +362,7 @@ mod tests {
|
|||
account.revocation_code = "R12345".into();
|
||||
account.shared_secret = "secret".into();
|
||||
manifest.add_account(account);
|
||||
assert!(matches!(manifest.save(), Ok(_)));
|
||||
assert!(matches!(manifest.save(&None), Ok(_)));
|
||||
std::fs::remove_file(&manifest_path).unwrap();
|
||||
|
||||
let mut loaded_manifest = Manifest::new(manifest_path.as_path());
|
||||
|
@ -251,13 +397,42 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_sda_compatibility_1() {
|
||||
let path = Path::new("src/fixtures/maFiles/1-account/manifest.json");
|
||||
let path = Path::new("src/fixtures/maFiles/compat/1-account/manifest.json");
|
||||
assert!(path.is_file());
|
||||
let result = Manifest::load(path);
|
||||
assert!(matches!(result, Ok(_)));
|
||||
let mut manifest = result.unwrap();
|
||||
assert!(matches!(manifest.entries.last().unwrap().encryption, None));
|
||||
assert!(matches!(manifest.load_accounts(), Ok(_)));
|
||||
assert!(matches!(manifest.load_accounts(&None), Ok(_)));
|
||||
assert_eq!(
|
||||
manifest.entries.last().unwrap().account_name,
|
||||
manifest
|
||||
.accounts
|
||||
.last()
|
||||
.unwrap()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.account_name
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sda_compatibility_1_encrypted() {
|
||||
let path = Path::new("src/fixtures/maFiles/compat/1-account-encrypted/manifest.json");
|
||||
assert!(path.is_file());
|
||||
let result = Manifest::load(path);
|
||||
assert!(matches!(result, Ok(_)));
|
||||
let mut manifest = result.unwrap();
|
||||
assert!(matches!(
|
||||
manifest.entries.last().unwrap().encryption,
|
||||
Some(_)
|
||||
));
|
||||
let result = manifest.load_accounts(&Some("password".into()));
|
||||
assert!(
|
||||
matches!(result, Ok(_)),
|
||||
"error when loading accounts: {:?}",
|
||||
result.unwrap_err()
|
||||
);
|
||||
assert_eq!(
|
||||
manifest.entries.last().unwrap().account_name,
|
||||
manifest
|
||||
|
|
242
src/encryption.rs
Normal file
242
src/encryption.rs
Normal file
|
@ -0,0 +1,242 @@
|
|||
use aes::Aes256;
|
||||
use block_modes::block_padding::{NoPadding, Padding, Pkcs7};
|
||||
use block_modes::{BlockMode, Cbc};
|
||||
use ring::pbkdf2;
|
||||
use ring::rand::SecureRandom;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
const SALT_LENGTH: usize = 8;
|
||||
const IV_LENGTH: usize = 16;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EntryEncryptionParams {
|
||||
#[serde(rename = "encryption_iv")]
|
||||
pub iv: String,
|
||||
#[serde(rename = "encryption_salt")]
|
||||
pub salt: String,
|
||||
#[serde(default, rename = "encryption_scheme")]
|
||||
pub scheme: EncryptionScheme,
|
||||
}
|
||||
|
||||
impl EntryEncryptionParams {
|
||||
pub fn generate() -> EntryEncryptionParams {
|
||||
let rng = ring::rand::SystemRandom::new();
|
||||
let mut salt = [0u8; SALT_LENGTH];
|
||||
let mut iv = [0u8; IV_LENGTH];
|
||||
rng.fill(&mut salt).expect("Unable to generate salt.");
|
||||
rng.fill(&mut iv).expect("Unable to generate IV.");
|
||||
EntryEncryptionParams {
|
||||
salt: base64::encode(salt),
|
||||
iv: base64::encode(iv),
|
||||
scheme: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum EncryptionScheme {
|
||||
/// Encryption scheme that is compatible with SteamDesktopAuthenticator.
|
||||
LegacySdaCompatible = -1,
|
||||
}
|
||||
|
||||
impl Default for EncryptionScheme {
|
||||
fn default() -> Self {
|
||||
Self::LegacySdaCompatible
|
||||
}
|
||||
}
|
||||
|
||||
pub trait EntryEncryptor {
|
||||
fn encrypt(
|
||||
passkey: &String,
|
||||
params: &EntryEncryptionParams,
|
||||
plaintext: Vec<u8>,
|
||||
) -> anyhow::Result<Vec<u8>, EntryEncryptionError>;
|
||||
fn decrypt(
|
||||
passkey: &String,
|
||||
params: &EntryEncryptionParams,
|
||||
ciphertext: Vec<u8>,
|
||||
) -> anyhow::Result<Vec<u8>, EntryEncryptionError>;
|
||||
}
|
||||
|
||||
/// Encryption scheme that is compatible with SteamDesktopAuthenticator.
|
||||
pub struct LegacySdaCompatible;
|
||||
|
||||
impl LegacySdaCompatible {
|
||||
const PBKDF2_ITERATIONS: u32 = 50000; // This is excessive, but necessary to maintain compatibility with SteamDesktopAuthenticator.
|
||||
const KEY_SIZE_BYTES: usize = 32;
|
||||
|
||||
fn get_encryption_key(
|
||||
passkey: &String,
|
||||
salt: &String,
|
||||
) -> anyhow::Result<[u8; Self::KEY_SIZE_BYTES]> {
|
||||
let password_bytes = passkey.as_bytes();
|
||||
let salt_bytes = base64::decode(salt)?;
|
||||
let mut full_key: [u8; Self::KEY_SIZE_BYTES] = [0u8; Self::KEY_SIZE_BYTES];
|
||||
pbkdf2::derive(
|
||||
pbkdf2::PBKDF2_HMAC_SHA1,
|
||||
std::num::NonZeroU32::new(Self::PBKDF2_ITERATIONS).unwrap(),
|
||||
&salt_bytes,
|
||||
password_bytes,
|
||||
&mut full_key,
|
||||
);
|
||||
return Ok(full_key);
|
||||
}
|
||||
}
|
||||
|
||||
type Aes256Cbc = Cbc<Aes256, NoPadding>;
|
||||
impl EntryEncryptor for LegacySdaCompatible {
|
||||
// ngl, this logic sucks ass. its kinda annoying that the logic is not completely symetric.
|
||||
|
||||
fn encrypt(
|
||||
passkey: &String,
|
||||
params: &EntryEncryptionParams,
|
||||
plaintext: Vec<u8>,
|
||||
) -> anyhow::Result<Vec<u8>, EntryEncryptionError> {
|
||||
let key = Self::get_encryption_key(&passkey.into(), ¶ms.salt)?;
|
||||
let iv = base64::decode(¶ms.iv)?;
|
||||
let cipher = Aes256Cbc::new_from_slices(&key, &iv)?;
|
||||
|
||||
let origsize = plaintext.len();
|
||||
let buffersize: usize = (origsize / 16 + (if origsize % 16 == 0 { 0 } else { 1 })) * 16;
|
||||
let mut buffer = vec![];
|
||||
for chunk in plaintext.as_slice().chunks(256) {
|
||||
let chunksize = chunk.len();
|
||||
let buffersize = (chunksize / 16 + (if chunksize % 16 == 0 { 0 } else { 1 })) * 16;
|
||||
let mut chunkbuffer = vec![0xffu8; buffersize];
|
||||
chunkbuffer[..chunksize].copy_from_slice(&chunk);
|
||||
if buffersize != chunksize {
|
||||
chunkbuffer = Pkcs7::pad(&mut chunkbuffer, chunksize, buffersize)
|
||||
.unwrap()
|
||||
.to_vec();
|
||||
}
|
||||
buffer.append(&mut chunkbuffer);
|
||||
}
|
||||
let ciphertext = cipher.encrypt(&mut buffer, buffersize)?;
|
||||
let final_buffer = base64::encode(&ciphertext);
|
||||
return Ok(final_buffer.as_bytes().to_vec());
|
||||
}
|
||||
|
||||
fn decrypt(
|
||||
passkey: &String,
|
||||
params: &EntryEncryptionParams,
|
||||
ciphertext: Vec<u8>,
|
||||
) -> anyhow::Result<Vec<u8>, EntryEncryptionError> {
|
||||
let key = Self::get_encryption_key(&passkey.into(), ¶ms.salt)?;
|
||||
let iv = base64::decode(¶ms.iv)?;
|
||||
let cipher = Aes256Cbc::new_from_slices(&key, &iv)?;
|
||||
|
||||
let decoded = base64::decode(ciphertext)?;
|
||||
let size: usize = decoded.len() / 16 + (if decoded.len() % 16 == 0 { 0 } else { 1 });
|
||||
let mut buffer = vec![0xffu8; 16 * size];
|
||||
buffer[..decoded.len()].copy_from_slice(&decoded);
|
||||
let mut decrypted = cipher.decrypt(&mut buffer)?;
|
||||
let unpadded = Pkcs7::unpad(&mut decrypted)?;
|
||||
return Ok(unpadded.to_vec());
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum EntryEncryptionError {
|
||||
#[error(transparent)]
|
||||
Unknown(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
/// For some reason, these errors do not get converted to `ManifestAccountLoadError`s, even though they get converted into `anyhow::Error` just fine. I am too lazy to figure out why right now.
|
||||
impl From<block_modes::BlockModeError> for EntryEncryptionError {
|
||||
fn from(error: block_modes::BlockModeError) -> Self {
|
||||
return Self::Unknown(anyhow::Error::from(error));
|
||||
}
|
||||
}
|
||||
impl From<block_modes::InvalidKeyIvLength> for EntryEncryptionError {
|
||||
fn from(error: block_modes::InvalidKeyIvLength) -> Self {
|
||||
return Self::Unknown(anyhow::Error::from(error));
|
||||
}
|
||||
}
|
||||
impl From<block_modes::block_padding::PadError> for EntryEncryptionError {
|
||||
fn from(error: block_modes::block_padding::PadError) -> Self {
|
||||
return Self::Unknown(anyhow!("PadError"));
|
||||
}
|
||||
}
|
||||
impl From<block_modes::block_padding::UnpadError> for EntryEncryptionError {
|
||||
fn from(error: block_modes::block_padding::UnpadError) -> Self {
|
||||
return Self::Unknown(anyhow!("UnpadError"));
|
||||
}
|
||||
}
|
||||
impl From<base64::DecodeError> for EntryEncryptionError {
|
||||
fn from(error: base64::DecodeError) -> Self {
|
||||
return Self::Unknown(anyhow::Error::from(error));
|
||||
}
|
||||
}
|
||||
impl From<std::io::Error> for EntryEncryptionError {
|
||||
fn from(error: std::io::Error) -> Self {
|
||||
return Self::Unknown(anyhow::Error::from(error));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use proptest::prelude::*;
|
||||
|
||||
/// This test ensures compatibility with SteamDesktopAuthenticator and with previous versions of steamguard-cli
|
||||
#[test]
|
||||
fn test_encryption_key() {
|
||||
assert_eq!(
|
||||
LegacySdaCompatible::get_encryption_key(&"password".into(), &"GMhL0N2hqXg=".into())
|
||||
.unwrap(),
|
||||
base64::decode("KtiRa4/OxW83MlB6URf+Z8rAGj7CBY+pDlwD/NuVo6Y=")
|
||||
.unwrap()
|
||||
.as_slice()
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
LegacySdaCompatible::get_encryption_key(&"password".into(), &"wTzTE9A6aN8=".into())
|
||||
.unwrap(),
|
||||
base64::decode("Dqpej/3DqEat0roJaHmu3luYgDzRCUmzX94n4fqvWj8=")
|
||||
.unwrap()
|
||||
.as_slice()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_encryption_symmetric() -> anyhow::Result<()> {
|
||||
let passkey = "password";
|
||||
let params = EntryEncryptionParams::generate();
|
||||
let orig = "tactical glizzy".as_bytes().to_vec();
|
||||
let encrypted =
|
||||
LegacySdaCompatible::encrypt(&passkey.clone().into(), ¶ms, orig.clone()).unwrap();
|
||||
let result = LegacySdaCompatible::decrypt(&passkey.into(), ¶ms, encrypted).unwrap();
|
||||
assert_eq!(orig, result.to_vec());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
prop_compose! {
|
||||
/// An insecure but reproducible strategy for generating encryption params.
|
||||
fn encryption_params()(salt in any::<[u8; SALT_LENGTH]>(), iv in any::<[u8; IV_LENGTH]>()) -> EntryEncryptionParams {
|
||||
EntryEncryptionParams {
|
||||
salt: base64::encode(&salt),
|
||||
iv: base64::encode(&iv),
|
||||
scheme: EncryptionScheme::LegacySdaCompatible,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// proptest! {
|
||||
// #[test]
|
||||
// fn ensure_encryption_symmetric(
|
||||
// passkey in ".{1,}",
|
||||
// params in encryption_params(),
|
||||
// data in any::<Vec<u8>>(),
|
||||
// ) {
|
||||
// prop_assume!(data.len() >= 2);
|
||||
// let mut orig = data;
|
||||
// orig[0] = '{' as u8;
|
||||
// let n = orig.len() - 1;
|
||||
// orig[n] = '}' as u8;
|
||||
// let encrypted = LegacySdaCompatible::encrypt(&passkey.clone().into(), ¶ms, orig.clone()).unwrap();
|
||||
// let result = LegacySdaCompatible::decrypt(&passkey.into(), ¶ms, encrypted).unwrap();
|
||||
// prop_assert_eq!(orig, result.to_vec());
|
||||
// }
|
||||
// }
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
ruZBuGG4+r5RU4JPjjHgkBLB205+1qSJjikDR5jrOpZsYgPSdJV9N4Rp7OnOQ63YBSiu1JpoNIUrSxJnW4UaPBIkgbWHsAngSiuZAhkZq/FJuRie0OcGN2NnwuE2xZUQNfREqU4DTiq8VLRBqvHTueeI5pReJ1vGradhjOgyWVG4MxMlVylVTIPqmZf1NrfBQbEL6Nip3dRGdzXsLhtnxhr8/0meBNOwtk+5sm6b+XVEd81aSsQNYEK5RUdmzNbOdK+UGUTOgACw6rBoBUP3zpv7U0gEC7u+iIIv1CQXp0HUH4p06Edu8r0APDQHIgt8/WmnRtm6EAWfgo/RxbMoTFTKo6Qfa18baGCkQnNJPiiIIQc1e4/31mw9DbGhDfkYJL4O5A9wbWeSRg92qxe2d4odHx0NIfyZ9CsKfIYc4/azq0I7K3hjcpt5JgYOQowm4YrYMQrmkyw90HMAMcOHoKtMVU5i58JumY3cYAnf+skCOra29D0Py7k0mGqm+9W2OWmO+XE3QfhuOK0FwFts5umyVI5AK4qZG90ioPt6CHgDZKgCdzuV7iTWwhuhd+EYalcbuSAQHX5cnNVVt9Z+0c9t+fnjz0t9w8wChpKhRcFGUeMvzOFO0lhP5kneJ3N/xKo0bhO2spjzzUw8lXE5wFv7TFmr058m+rzW/ucvtcl6KSvvr0RGNBcwUo+G8Q4jvJNTDFhqT0ElnWpgF2Rxb9m5UKZz5bfLszlf0KbSO2ZXg9wO65/itqXQnKzq1ALI
|
|
@ -0,0 +1 @@
|
|||
{"encrypted":true,"first_run":true,"entries":[{"encryption_iv":"xLHUZJzinEfUwLzEzqfI7Q==","encryption_salt":"bcp2z6P88A0=","filename":"1234.maFile","steamid":1234}],"periodic_checking":false,"periodic_checking_interval":5,"periodic_checking_checkall":false,"auto_confirm_market_transactions":false,"auto_confirm_trades":false}
|
70
src/main.rs
70
src/main.rs
|
@ -16,9 +16,14 @@ use steamguard::{
|
|||
extern crate lazy_static;
|
||||
#[macro_use]
|
||||
extern crate anyhow;
|
||||
extern crate base64;
|
||||
extern crate dirs;
|
||||
#[cfg(test)]
|
||||
extern crate proptest;
|
||||
extern crate ring;
|
||||
mod accountmanager;
|
||||
mod demos;
|
||||
mod encryption;
|
||||
mod tui;
|
||||
|
||||
fn cli() -> App<'static, 'static> {
|
||||
|
@ -55,6 +60,7 @@ fn cli() -> App<'static, 'static> {
|
|||
.long("passkey")
|
||||
.short("p")
|
||||
.help("Specify your encryption passkey.")
|
||||
.takes_value(true)
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("verbosity")
|
||||
|
@ -101,6 +107,14 @@ fn cli() -> App<'static, 'static> {
|
|||
App::new("remove")
|
||||
.about("Remove the authenticator from an account.")
|
||||
)
|
||||
.subcommand(
|
||||
App::new("encrypt")
|
||||
.about("Encrypt maFiles.")
|
||||
)
|
||||
.subcommand(
|
||||
App::new("decrypt")
|
||||
.about("Decrypt maFiles.")
|
||||
)
|
||||
.subcommand(
|
||||
App::new("debug")
|
||||
.arg(
|
||||
|
@ -172,9 +186,26 @@ fn main() {
|
|||
}
|
||||
}
|
||||
|
||||
manifest
|
||||
.load_accounts()
|
||||
.expect("Failed to load accounts in manifest");
|
||||
let mut passkey: Option<String> = matches.value_of("passkey").map(|s| s.into());
|
||||
|
||||
loop {
|
||||
match manifest.load_accounts(&passkey) {
|
||||
Ok(_) => break,
|
||||
Err(
|
||||
accountmanager::ManifestAccountLoadError::MissingPasskey
|
||||
| accountmanager::ManifestAccountLoadError::IncorrectPasskey,
|
||||
) => {
|
||||
if passkey.is_some() {
|
||||
error!("Incorrect passkey");
|
||||
}
|
||||
passkey = rpassword::prompt_password_stdout("Enter encryption passkey: ").ok();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Could not load accounts: {}", e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if matches.is_present("setup") {
|
||||
println!("Log in to the account that you want to link to steamguard-cli");
|
||||
|
@ -215,7 +246,7 @@ fn main() {
|
|||
}
|
||||
}
|
||||
manifest.add_account(account);
|
||||
match manifest.save() {
|
||||
match manifest.save(&passkey) {
|
||||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
error!("Aborting the account linking process because we failed to save the manifest. This is really bad. Here is the error: {}", err);
|
||||
|
@ -260,7 +291,7 @@ fn main() {
|
|||
}
|
||||
|
||||
println!("Authenticator finalized.");
|
||||
match manifest.save() {
|
||||
match manifest.save(&None) {
|
||||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
println!(
|
||||
|
@ -284,7 +315,30 @@ fn main() {
|
|||
}
|
||||
}
|
||||
|
||||
manifest.save().expect("Failed to save manifest.");
|
||||
manifest.save(&passkey).expect("Failed to save manifest.");
|
||||
return;
|
||||
} else if matches.is_present("encrypt") {
|
||||
if passkey.is_none() {
|
||||
loop {
|
||||
passkey = rpassword::prompt_password_stdout("Enter encryption passkey: ").ok();
|
||||
let passkey_confirm =
|
||||
rpassword::prompt_password_stdout("Confirm encryption passkey: ").ok();
|
||||
if passkey == passkey_confirm {
|
||||
break;
|
||||
}
|
||||
error!("Passkeys do not match, try again.");
|
||||
}
|
||||
}
|
||||
for entry in &mut manifest.entries {
|
||||
entry.encryption = Some(accountmanager::EntryEncryptionParams::generate());
|
||||
}
|
||||
manifest.save(&passkey).expect("Failed to save manifest.");
|
||||
return;
|
||||
} else if matches.is_present("decrypt") {
|
||||
for entry in &mut manifest.entries {
|
||||
entry.encryption = None;
|
||||
}
|
||||
manifest.save(&passkey).expect("Failed to save manifest.");
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -361,7 +415,7 @@ fn main() {
|
|||
}
|
||||
}
|
||||
|
||||
manifest.save().expect("Failed to save manifest");
|
||||
manifest.save(&passkey).expect("Failed to save manifest");
|
||||
} else if let Some(_) = matches.subcommand_matches("remove") {
|
||||
println!(
|
||||
"This will remove the mobile authenticator from {} accounts: {}",
|
||||
|
@ -409,7 +463,7 @@ fn main() {
|
|||
manifest.remove_account(account_name);
|
||||
}
|
||||
|
||||
manifest.save().expect("Failed to save manifest.");
|
||||
manifest.save(&passkey).expect("Failed to save manifest.");
|
||||
} else {
|
||||
let server_time = steamapi::get_server_time();
|
||||
for account in selected_accounts {
|
||||
|
|
Loading…
Reference in a new issue