Merge pull request #92 from dyc3/encryption

encryption
This commit is contained in:
Carson McManus 2021-08-20 11:13:52 -04:00 committed by GitHub
commit 73c3f54051
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 671 additions and 38 deletions

169
Cargo.lock generated
View file

@ -8,6 +8,18 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" 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]] [[package]]
name = "aho-corasick" name = "aho-corasick"
version = "0.7.15" version = "0.7.15"
@ -86,12 +98,43 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0d27fb6b6f1e43147af148af49d49329413ba781aa0d5e10979831c210173b5" 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]] [[package]]
name = "bitflags" name = "bitflags"
version = "1.2.1" version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" 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]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.6.1" version = "3.6.1"
@ -135,6 +178,15 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "cipher"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ee52072ec15386f770805afd189a01c8841be8696bed250fa2f13c4c0d6dfb7"
dependencies = [
"generic-array",
]
[[package]] [[package]]
name = "clap" name = "clap"
version = "2.33.3" version = "2.33.3"
@ -211,6 +263,15 @@ version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea221b5284a47e40033bf9b66f35f984ec0ea2931eb03505246cd27a963f981b" checksum = "ea221b5284a47e40033bf9b66f35f984ec0ea2931eb03505246cd27a963f981b"
[[package]]
name = "cpufeatures"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66c99696f6c9dd7f35d486b9d04d7e6e202aa3e8c40d553f2fdf5e7e0c6a71ef"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "crc32fast" name = "crc32fast"
version = "1.2.1" version = "1.2.1"
@ -690,9 +751,9 @@ dependencies = [
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.90" version = "0.2.99"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba4aede83fc3617411dc6993bc8c70919750c1c257c6ca6a502aed6e0e2394ae" checksum = "a7f823d141fe0a24df1e23b4af4e3c7ba9e5966ec514ea068c93024aa7deb765"
[[package]] [[package]]
name = "libm" name = "libm"
@ -897,10 +958,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af8b08b04175473088b46763e51ee54da5f9a164bc162f615b91bc179dbf15a3" checksum = "af8b08b04175473088b46763e51ee54da5f9a164bc162f615b91bc179dbf15a3"
[[package]] [[package]]
name = "openssl" name = "opaque-debug"
version = "0.10.33" version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" 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 = [ dependencies = [
"bitflags", "bitflags",
"cfg-if", "cfg-if",
@ -918,9 +985,9 @@ checksum = "77af24da69f9d9341038eba93a073b1fdaaa1b788221b00a69bce9e762cb32de"
[[package]] [[package]]
name = "openssl-sys" name = "openssl-sys"
version = "0.9.61" version = "0.9.65"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "313752393519e876837e09e1fa183ddef0be7735868dced3196f4472d536277f" checksum = "7a7907e3bfa08bb85105209cdfcb6c63d109f8f6c1ed6ca318fff5c1853fbc1d"
dependencies = [ dependencies = [
"autocfg 1.0.1", "autocfg 1.0.1",
"cc", "cc",
@ -1096,6 +1163,26 @@ dependencies = [
"unicode-xid", "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]] [[package]]
name = "publicsuffix" name = "publicsuffix"
version = "1.5.6" version = "1.5.6"
@ -1106,6 +1193,18 @@ dependencies = [
"url", "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]] [[package]]
name = "quote" name = "quote"
version = "1.0.9" version = "1.0.9"
@ -1234,6 +1333,15 @@ dependencies = [
"rand_core 0.5.1", "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]] [[package]]
name = "rdrand" name = "rdrand"
version = "0.4.0" version = "0.4.0"
@ -1337,6 +1445,21 @@ dependencies = [
"winreg", "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]] [[package]]
name = "rpassword" name = "rpassword"
version = "5.0.1" version = "5.0.1"
@ -1385,6 +1508,18 @@ dependencies = [
"semver 0.11.0", "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]] [[package]]
name = "ryu" name = "ryu"
version = "1.0.5" version = "1.0.5"
@ -1706,17 +1841,21 @@ dependencies = [
name = "steamguard-cli" name = "steamguard-cli"
version = "0.2.0" version = "0.2.0"
dependencies = [ dependencies = [
"aes",
"anyhow", "anyhow",
"base64", "base64",
"block-modes",
"clap", "clap",
"cookie", "cookie",
"dirs", "dirs",
"hmac-sha1", "hmac-sha1",
"lazy_static 1.4.0", "lazy_static 1.4.0",
"log", "log",
"proptest",
"rand 0.8.4", "rand 0.8.4",
"regex", "regex",
"reqwest", "reqwest",
"ring",
"rpassword", "rpassword",
"rsa", "rsa",
"serde", "serde",
@ -1727,6 +1866,7 @@ dependencies = [
"tempdir", "tempdir",
"termion", "termion",
"text_io", "text_io",
"thiserror",
"uuid", "uuid",
] ]
@ -2082,6 +2222,12 @@ dependencies = [
"void", "void",
] ]
[[package]]
name = "untrusted"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
[[package]] [[package]]
name = "url" name = "url"
version = "2.2.1" version = "2.2.1"
@ -2133,6 +2279,15 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" 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]] [[package]]
name = "want" name = "want"
version = "0.3.0" version = "0.3.0"

View file

@ -34,6 +34,11 @@ uuid = { version = "0.8", features = ["v4"] }
termion = "1.5.6" termion = "1.5.6"
steamguard = { path = "./steamguard" } steamguard = { path = "./steamguard" }
dirs = "3.0.2" dirs = "3.0.2"
ring = "0.16.20"
aes = "0.7.4"
block-modes = "0.8.1"
thiserror = "1.0.26"
[dev-dependencies] [dev-dependencies]
tempdir = "0.3" tempdir = "0.3"
proptest = "1"

View file

@ -1,15 +1,19 @@
pub use crate::encryption::EntryEncryptionParams;
use crate::encryption::EntryEncryptor;
use log::*; use log::*;
use serde::{Deserialize, Serialize}; 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::path::Path;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::{cell::Cell, fs::File};
use steamguard::SteamGuardAccount; use steamguard::SteamGuardAccount;
use thiserror::Error;
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct Manifest { pub struct Manifest {
pub encrypted: bool,
pub entries: Vec<ManifestEntry>, pub entries: Vec<ManifestEntry>,
/// Not really used, kept mostly for compatibility with SDA.
pub encrypted: bool,
/// Not implemented, kept for compatibility with SDA. /// Not implemented, kept for compatibility with SDA.
pub first_run: bool, pub first_run: bool,
/// Not implemented, kept for compatibility with SDA. /// Not implemented, kept for compatibility with SDA.
@ -40,14 +44,6 @@ pub struct ManifestEntry {
pub encryption: Option<EntryEncryptionParams>, 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 { impl Default for Manifest {
fn default() -> Self { fn default() -> Self {
Manifest { Manifest {
@ -84,13 +80,36 @@ impl Manifest {
return Ok(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 { for entry in &mut self.entries {
let path = Path::new(&self.folder).join(&entry.filename); let path = Path::new(&self.folder).join(&entry.filename);
debug!("loading account: {:?}", path); debug!("loading account: {:?}", path);
let file = File::open(path)?; let file = File::open(path)?;
let reader = BufReader::new(file); let mut reader = BufReader::new(file);
let account: SteamGuardAccount = serde_json::from_reader(reader)?; 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(); entry.account_name = account.account_name.clone();
self.accounts.push(Arc::new(Mutex::new(account))); self.accounts.push(Arc::new(Mutex::new(account)));
} }
@ -132,21 +151,37 @@ impl Manifest {
self.entries.remove(index); self.entries.remove(index);
} }
pub fn save(&self) -> anyhow::Result<()> { pub fn save(&self, passkey: &Option<String>) -> anyhow::Result<()> {
ensure!( ensure!(
self.entries.len() == self.accounts.len(), self.entries.len() == self.accounts.len(),
"Manifest entries don't match accounts." "Manifest entries don't match accounts."
); );
for (entry, account) in self.entries.iter().zip(&self.accounts) { for (entry, account) in self.entries.iter().zip(&self.accounts) {
debug!("saving {}", entry.filename); debug!("saving {}", entry.filename);
let serialized = serde_json::to_string(account.as_ref())?; let serialized = serde_json::to_vec(account.as_ref())?;
ensure!( ensure!(
serialized.len() > 2, serialized.len() > 2,
"Something extra weird happened and the account was serialized into nothing." "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 path = Path::new(&self.folder).join(&entry.filename);
let mut file = File::create(path)?; let mut file = File::create(path)?;
file.write_all(serialized.as_bytes())?; file.write_all(final_buffer.as_slice())?;
file.sync_data()?; file.sync_data()?;
} }
debug!("saving manifest"); 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -169,7 +239,7 @@ mod tests {
let tmp_dir = TempDir::new("steamguard-cli-test").unwrap(); let tmp_dir = TempDir::new("steamguard-cli-test").unwrap();
let manifest_path = tmp_dir.path().join("manifest.json"); let manifest_path = tmp_dir.path().join("manifest.json");
let manifest = Manifest::new(manifest_path.as_path()); let manifest = Manifest::new(manifest_path.as_path());
assert!(matches!(manifest.save(), Ok(_))); assert!(matches!(manifest.save(&None), Ok(_)));
} }
#[test] #[test]
@ -182,12 +252,12 @@ mod tests {
account.revocation_code = "R12345".into(); account.revocation_code = "R12345".into();
account.shared_secret = "secret".into(); account.shared_secret = "secret".into();
manifest.add_account(account); 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(); let mut loaded_manifest = Manifest::load(manifest_path.as_path()).unwrap();
assert_eq!(loaded_manifest.entries.len(), 1); assert_eq!(loaded_manifest.entries.len(), 1);
assert_eq!(loaded_manifest.entries[0].filename, "asdf1234.maFile"); 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!( assert_eq!(
loaded_manifest.entries.len(), loaded_manifest.entries.len(),
loaded_manifest.accounts.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] #[test]
fn test_should_import() { fn test_should_import() {
let tmp_dir = TempDir::new("steamguard-cli-test").unwrap(); let tmp_dir = TempDir::new("steamguard-cli-test").unwrap();
@ -216,7 +362,7 @@ mod tests {
account.revocation_code = "R12345".into(); account.revocation_code = "R12345".into();
account.shared_secret = "secret".into(); account.shared_secret = "secret".into();
manifest.add_account(account); manifest.add_account(account);
assert!(matches!(manifest.save(), Ok(_))); assert!(matches!(manifest.save(&None), Ok(_)));
std::fs::remove_file(&manifest_path).unwrap(); std::fs::remove_file(&manifest_path).unwrap();
let mut loaded_manifest = Manifest::new(manifest_path.as_path()); let mut loaded_manifest = Manifest::new(manifest_path.as_path());
@ -251,13 +397,42 @@ mod tests {
#[test] #[test]
fn test_sda_compatibility_1() { 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()); assert!(path.is_file());
let result = Manifest::load(path); let result = Manifest::load(path);
assert!(matches!(result, Ok(_))); assert!(matches!(result, Ok(_)));
let mut manifest = result.unwrap(); let mut manifest = result.unwrap();
assert!(matches!(manifest.entries.last().unwrap().encryption, None)); 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!( assert_eq!(
manifest.entries.last().unwrap().account_name, manifest.entries.last().unwrap().account_name,
manifest manifest

242
src/encryption.rs Normal file
View 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(), &params.salt)?;
let iv = base64::decode(&params.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(), &params.salt)?;
let iv = base64::decode(&params.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(), &params, orig.clone()).unwrap();
let result = LegacySdaCompatible::decrypt(&passkey.into(), &params, 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(), &params, orig.clone()).unwrap();
// let result = LegacySdaCompatible::decrypt(&passkey.into(), &params, encrypted).unwrap();
// prop_assert_eq!(orig, result.to_vec());
// }
// }
}

View file

@ -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

View file

@ -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}

View file

@ -16,9 +16,14 @@ use steamguard::{
extern crate lazy_static; extern crate lazy_static;
#[macro_use] #[macro_use]
extern crate anyhow; extern crate anyhow;
extern crate base64;
extern crate dirs; extern crate dirs;
#[cfg(test)]
extern crate proptest;
extern crate ring;
mod accountmanager; mod accountmanager;
mod demos; mod demos;
mod encryption;
mod tui; mod tui;
fn cli() -> App<'static, 'static> { fn cli() -> App<'static, 'static> {
@ -55,6 +60,7 @@ fn cli() -> App<'static, 'static> {
.long("passkey") .long("passkey")
.short("p") .short("p")
.help("Specify your encryption passkey.") .help("Specify your encryption passkey.")
.takes_value(true)
) )
.arg( .arg(
Arg::with_name("verbosity") Arg::with_name("verbosity")
@ -101,6 +107,14 @@ fn cli() -> App<'static, 'static> {
App::new("remove") App::new("remove")
.about("Remove the authenticator from an account.") .about("Remove the authenticator from an account.")
) )
.subcommand(
App::new("encrypt")
.about("Encrypt maFiles.")
)
.subcommand(
App::new("decrypt")
.about("Decrypt maFiles.")
)
.subcommand( .subcommand(
App::new("debug") App::new("debug")
.arg( .arg(
@ -172,9 +186,26 @@ fn main() {
} }
} }
manifest let mut passkey: Option<String> = matches.value_of("passkey").map(|s| s.into());
.load_accounts()
.expect("Failed to load accounts in manifest"); 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") { if matches.is_present("setup") {
println!("Log in to the account that you want to link to steamguard-cli"); println!("Log in to the account that you want to link to steamguard-cli");
@ -215,7 +246,7 @@ fn main() {
} }
} }
manifest.add_account(account); manifest.add_account(account);
match manifest.save() { match manifest.save(&passkey) {
Ok(_) => {} Ok(_) => {}
Err(err) => { Err(err) => {
error!("Aborting the account linking process because we failed to save the manifest. This is really bad. Here is the error: {}", 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."); println!("Authenticator finalized.");
match manifest.save() { match manifest.save(&None) {
Ok(_) => {} Ok(_) => {}
Err(err) => { Err(err) => {
println!( 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; 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") { } else if let Some(_) = matches.subcommand_matches("remove") {
println!( println!(
"This will remove the mobile authenticator from {} accounts: {}", "This will remove the mobile authenticator from {} accounts: {}",
@ -409,7 +463,7 @@ fn main() {
manifest.remove_account(account_name); manifest.remove_account(account_name);
} }
manifest.save().expect("Failed to save manifest."); manifest.save(&passkey).expect("Failed to save manifest.");
} else { } else {
let server_time = steamapi::get_server_time(); let server_time = steamapi::get_server_time();
for account in selected_accounts { for account in selected_accounts {