Dead code cleanup, subcommand refactor (#206)

- clean up dead code
- fix lints
- move Session type to legacy module
- refactor service names into constants
- refactor build_url to be less restrictive for service names
- refactor most commands into their own modules
This commit is contained in:
Carson McManus 2023-06-23 13:36:23 -04:00 committed by GitHub
parent 8defefb11e
commit cbc46ad8eb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
39 changed files with 1027 additions and 1768 deletions

1
Cargo.lock generated
View file

@ -2096,6 +2096,7 @@ dependencies = [
"text_io",
"thiserror",
"uuid",
"zeroize",
]
[[package]]

View file

@ -55,6 +55,7 @@ crossterm = { version = "0.23.2", features = ["event-stream"] }
qrcode = { version = "0.12.0", optional = true }
gethostname = "0.4.3"
secrecy = { version = "0.8", features = ["serde"] }
zeroize = "^1.4.3"
[dev-dependencies]
tempdir = "0.3"

View file

@ -7,7 +7,7 @@ use std::fs::File;
use std::io::{BufReader, Read, Write};
use std::path::Path;
use std::sync::{Arc, Mutex};
use steamguard::{ExposeSecret, SteamGuardAccount};
use steamguard::SteamGuardAccount;
use thiserror::Error;
mod legacy;

View file

@ -1,3 +1,5 @@
#![allow(deprecated)]
use std::{
fs::File,
io::{BufReader, Read},
@ -5,9 +7,10 @@ use std::{
};
use log::debug;
use secrecy::ExposeSecret;
use secrecy::{CloneableSecret, DebugSecret, ExposeSecret};
use serde::Deserialize;
use steamguard::{token::TwoFactorSecret, SecretString, SteamGuardAccount};
use zeroize::Zeroize;
use crate::encryption::{EncryptionScheme, EntryEncryptor};
@ -75,8 +78,7 @@ impl EntryLoader<SdaAccount> for SdaManifestEntry {
debug!("loading entry: {:?}", path);
let file = File::open(path)?;
let mut reader = BufReader::new(file);
let account: SdaAccount;
match (&passkey, encryption_params.as_ref()) {
let account: SdaAccount = match (&passkey, encryption_params.as_ref()) {
(Some(passkey), Some(params)) => {
let mut ciphertext: Vec<u8> = vec![];
reader.read_to_end(&mut ciphertext)?;
@ -86,14 +88,12 @@ impl EntryLoader<SdaAccount> for SdaManifestEntry {
return Err(ManifestAccountLoadError::IncorrectPasskey);
}
let s = std::str::from_utf8(&plaintext).unwrap();
account = serde_json::from_str(s)?;
serde_json::from_str(s)?
}
(None, Some(_)) => {
return Err(ManifestAccountLoadError::MissingPasskey);
}
(_, None) => {
account = serde_json::from_reader(reader)?;
}
(_, None) => serde_json::from_reader(reader)?,
};
Ok(account)
}
@ -135,9 +135,30 @@ pub struct SdaAccount {
#[serde(with = "crate::secret_string")]
pub secret_1: SecretString,
#[serde(default, rename = "Session")]
pub session: Option<secrecy::Secret<steamguard::steamapi::Session>>,
pub session: Option<secrecy::Secret<Session>>,
}
#[derive(Debug, Clone, Deserialize, Zeroize)]
#[zeroize(drop)]
#[deprecated(note = "this is not used anymore, the closest equivalent is `Tokens`")]
pub struct Session {
#[serde(rename = "SessionID")]
pub session_id: String,
#[serde(rename = "SteamLogin")]
pub steam_login: String,
#[serde(rename = "SteamLoginSecure")]
pub steam_login_secure: String,
#[serde(default, rename = "WebCookie")]
pub web_cookie: Option<String>,
#[serde(rename = "OAuthToken")]
pub token: String,
#[serde(rename = "SteamID")]
pub steam_id: u64,
}
impl CloneableSecret for Session {}
impl DebugSecret for Session {}
impl From<SdaAccount> for SteamGuardAccount {
fn from(value: SdaAccount) -> Self {
let steam_id = value

View file

@ -40,8 +40,8 @@ fn do_migrate(
let mut file = File::open(manifest_path)?;
let mut buffer = String::new();
file.read_to_string(&mut buffer)?;
let mut manifest: MigratingManifest = deserialize_manifest(buffer)
.map_err(|err| MigrationError::ManifestDeserializeFailed(err))?;
let mut manifest: MigratingManifest =
deserialize_manifest(buffer).map_err(MigrationError::ManifestDeserializeFailed)?;
if manifest.is_encrypted() && passkey.is_none() {
return Err(MigrationError::MissingPasskey);
@ -94,35 +94,25 @@ pub(crate) enum MigrationError {
#[derive(Debug)]
enum MigratingManifest {
SDA(SdaManifest),
Sda(SdaManifest),
ManifestV1(ManifestV1),
}
impl MigratingManifest {
pub fn upgrade(self) -> Self {
match self {
Self::SDA(sda) => Self::ManifestV1(sda.into()),
Self::Sda(sda) => Self::ManifestV1(sda.into()),
Self::ManifestV1(_) => self,
}
}
pub fn is_latest(&self) -> bool {
match self {
Self::ManifestV1(_) => true,
_ => false,
}
}
pub fn version(&self) -> u32 {
match self {
Self::SDA(_) => 0,
Self::ManifestV1(_) => 1,
}
matches!(self, Self::ManifestV1(_))
}
pub fn is_encrypted(&self) -> bool {
match self {
Self::SDA(manifest) => manifest.entries.iter().any(|e| e.encryption.is_some()),
Self::Sda(manifest) => manifest.entries.iter().any(|e| e.encryption.is_some()),
Self::ManifestV1(manifest) => manifest.entries.iter().any(|e| e.encryption.is_some()),
}
}
@ -134,7 +124,7 @@ impl MigratingManifest {
) -> anyhow::Result<Vec<MigratingAccount>> {
debug!("loading all accounts for migration");
let accounts = match self {
Self::SDA(sda) => {
Self::Sda(sda) => {
let (accounts, errors) = sda
.entries
.iter()
@ -201,7 +191,7 @@ fn deserialize_manifest(text: String) -> Result<MigratingManifest, serde_json::E
Ok(MigratingManifest::ManifestV1(manifest))
} else if json["version"] == serde_json::Value::Null {
let manifest: SdaManifest = serde_json::from_str(&text)?;
Ok(MigratingManifest::SDA(manifest))
Ok(MigratingManifest::Sda(manifest))
} else {
Err(serde_json::Error::custom(format!(
"Unknown manifest version: {}",
@ -225,10 +215,7 @@ impl MigratingAccount {
}
pub fn is_latest(&self) -> bool {
match self {
Self::ManifestV1(_) => true,
_ => false,
}
matches!(self, Self::ManifestV1(_))
}
}

View file

@ -1,193 +0,0 @@
use clap::{clap_derive::ArgEnum, Parser};
use clap_complete::Shell;
use std::str::FromStr;
#[derive(Debug, Clone, Parser)]
#[clap(name="steamguard-cli", bin_name="steamguard", author, version, about = "Generate Steam 2FA codes and confirm Steam trades from the command line.", long_about = None)]
pub(crate) struct Args {
#[clap(
short,
long,
conflicts_with = "all",
help = "Steam username, case-sensitive.",
long_help = "Select the account you want by steam username. Case-sensitive. By default, the first account in the manifest is selected."
)]
pub username: Option<String>,
#[clap(
short,
long,
conflicts_with = "username",
help = "Select all accounts in the manifest."
)]
pub all: bool,
/// The path to the maFiles directory.
#[clap(
short,
long,
help = "Specify which folder your maFiles are in. This should be a path to a folder that contains manifest.json. Default: ~/.config/steamguard-cli/maFiles"
)]
pub mafiles_path: Option<String>,
#[clap(
short,
long,
env = "STEAMGUARD_CLI_PASSKEY",
help = "Specify your encryption passkey."
)]
pub passkey: Option<String>,
#[clap(short, long, arg_enum, default_value_t=Verbosity::Info, help = "Set the log level. Be warned, trace is capable of printing sensitive data.")]
pub verbosity: Verbosity,
#[clap(subcommand)]
pub sub: Option<Subcommands>,
#[clap(flatten)]
pub code: ArgsCode,
}
#[derive(Debug, Clone, Parser)]
pub(crate) enum Subcommands {
Debug(ArgsDebug),
Completion(ArgsCompletions),
Setup(ArgsSetup),
Import(ArgsImport),
Trade(ArgsTrade),
Remove(ArgsRemove),
Encrypt(ArgsEncrypt),
Decrypt(ArgsDecrypt),
Code(ArgsCode),
#[cfg(feature = "qr")]
Qr(ArgsQr),
#[cfg(debug_assertions)]
TestLogin,
}
#[derive(Debug, Clone, Copy, ArgEnum)]
pub(crate) enum Verbosity {
Error = 0,
Warn = 1,
Info = 2,
Debug = 3,
Trace = 4,
}
impl std::fmt::Display for Verbosity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_fmt(format_args!(
"{}",
match self {
Verbosity::Error => "error",
Verbosity::Warn => "warn",
Verbosity::Info => "info",
Verbosity::Debug => "debug",
Verbosity::Trace => "trace",
}
))
}
}
impl FromStr for Verbosity {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"error" => Ok(Verbosity::Error),
"warn" => Ok(Verbosity::Warn),
"info" => Ok(Verbosity::Info),
"debug" => Ok(Verbosity::Debug),
"trace" => Ok(Verbosity::Trace),
_ => Err(anyhow!("Invalid verbosity level: {}", s)),
}
}
}
#[derive(Debug, Clone, Parser)]
#[clap(about = "Debug stuff, not useful for most users.")]
pub(crate) struct ArgsDebug {
#[clap(long, help = "Show a text prompt.")]
pub demo_prompt: bool,
#[clap(long, help = "Show a \"press any key\" prompt.")]
pub demo_pause: bool,
#[clap(long, help = "Show a character prompt.")]
pub demo_prompt_char: bool,
#[clap(long, help = "Show an example confirmation menu using dummy data.")]
pub demo_conf_menu: bool,
}
#[derive(Debug, Clone, Parser)]
#[clap(about = "Generate shell completions")]
pub(crate) struct ArgsCompletions {
#[clap(short, long, arg_enum, help = "The shell to generate completions for.")]
pub shell: Shell,
}
#[derive(Debug, Clone, Parser)]
#[clap(about = "Set up a new account with steamguard-cli")]
pub(crate) struct ArgsSetup {}
#[derive(Debug, Clone, Parser)]
#[clap(about = "Import an account with steamguard already set up")]
pub(crate) struct ArgsImport {
#[clap(long, help = "Whether or not the provided maFiles are from SDA.")]
pub sda: bool,
#[clap(long, help = "Paths to one or more maFiles, eg. \"./gaben.maFile\"")]
pub files: Vec<String>,
}
#[derive(Debug, Clone, Parser)]
#[clap(about = "Interactive interface for trade confirmations")]
pub(crate) struct ArgsTrade {
#[clap(
short,
long,
help = "Accept all open trade confirmations. Does not open interactive interface."
)]
pub accept_all: bool,
#[clap(
short,
long,
help = "If submitting a confirmation response fails, exit immediately."
)]
pub fail_fast: bool,
}
#[derive(Debug, Clone, Parser)]
#[clap(about = "Remove the authenticator from an account.")]
pub(crate) struct ArgsRemove;
#[derive(Debug, Clone, Parser)]
#[clap(about = "Encrypt all maFiles")]
pub(crate) struct ArgsEncrypt;
#[derive(Debug, Clone, Parser)]
#[clap(about = "Decrypt all maFiles")]
pub(crate) struct ArgsDecrypt;
#[derive(Debug, Clone, Parser)]
#[clap(about = "Generate 2FA codes")]
pub(crate) struct ArgsCode {
#[clap(
long,
help = "Assume the computer's time is correct. Don't ask Steam for the time when generating codes."
)]
pub offline: bool,
}
// HACK: the derive API doesn't support default subcommands, so we are going to make it so that it'll be easier to switch over when it's implemented.
// See: https://github.com/clap-rs/clap/issues/3857
impl From<Args> for ArgsCode {
fn from(args: Args) -> Self {
args.code
}
}
#[derive(Debug, Clone, Parser)]
#[clap(about = "Generate QR codes. This *will* print sensitive data to stdout.")]
#[cfg(feature = "qr")]
pub(crate) struct ArgsQr {
#[clap(
long,
help = "Force using ASCII chars to generate QR codes. Useful for terminals that don't support unicode."
)]
pub ascii: bool,
}

167
src/commands.rs Normal file
View file

@ -0,0 +1,167 @@
use std::sync::{Arc, Mutex};
use clap::{clap_derive::ArgEnum, Parser};
use clap_complete::Shell;
use std::str::FromStr;
use steamguard::SteamGuardAccount;
use crate::AccountManager;
pub mod code;
pub mod completions;
pub mod debug;
pub mod decrypt;
pub mod encrypt;
pub mod import;
#[cfg(feature = "qr")]
pub mod qr;
pub mod remove;
pub mod setup;
pub mod trade;
pub use code::CodeCommand;
pub use completions::CompletionsCommand;
pub use debug::DebugCommand;
pub use decrypt::DecryptCommand;
pub use encrypt::EncryptCommand;
pub use import::ImportCommand;
#[cfg(feature = "qr")]
pub use qr::QrCommand;
pub use remove::RemoveCommand;
pub use setup::SetupCommand;
pub use trade::TradeCommand;
/// A command that does not operate on the manifest or individual accounts.
pub(crate) trait ConstCommand {
fn execute(&self) -> anyhow::Result<()>;
}
/// A command that operates the manifest as a whole
pub(crate) trait ManifestCommand {
fn execute(&self, manager: &mut AccountManager) -> anyhow::Result<()>;
}
/// A command that operates on individual accounts.
pub(crate) trait AccountCommand {
fn execute(
&self,
manager: &mut AccountManager,
accounts: Vec<Arc<Mutex<SteamGuardAccount>>>,
) -> anyhow::Result<()>;
}
pub(crate) enum CommandType {
Const(Box<dyn ConstCommand>),
Manifest(Box<dyn ManifestCommand>),
Account(Box<dyn AccountCommand>),
}
#[derive(Debug, Clone, Parser)]
#[clap(name="steamguard-cli", bin_name="steamguard", author, version, about = "Generate Steam 2FA codes and confirm Steam trades from the command line.", long_about = None)]
pub(crate) struct Args {
#[clap(flatten)]
pub global: GlobalArgs,
#[clap(subcommand)]
pub sub: Option<Subcommands>,
#[clap(flatten)]
pub code: CodeCommand,
}
#[derive(Debug, Clone, Parser)]
pub(crate) struct GlobalArgs {
#[clap(
short,
long,
conflicts_with = "all",
help = "Steam username, case-sensitive.",
long_help = "Select the account you want by steam username. Case-sensitive. By default, the first account in the manifest is selected."
)]
pub username: Option<String>,
#[clap(
short,
long,
conflicts_with = "username",
help = "Select all accounts in the manifest."
)]
pub all: bool,
/// The path to the maFiles directory.
#[clap(
short,
long,
help = "Specify which folder your maFiles are in. This should be a path to a folder that contains manifest.json. Default: ~/.config/steamguard-cli/maFiles"
)]
pub mafiles_path: Option<String>,
#[clap(
short,
long,
env = "STEAMGUARD_CLI_PASSKEY",
help = "Specify your encryption passkey."
)]
pub passkey: Option<String>,
#[clap(short, long, arg_enum, default_value_t=Verbosity::Info, help = "Set the log level. Be warned, trace is capable of printing sensitive data.")]
pub verbosity: Verbosity,
}
#[derive(Debug, Clone, Parser)]
pub(crate) enum Subcommands {
Debug(DebugCommand),
Completion(CompletionsCommand),
Setup(SetupCommand),
Import(ImportCommand),
Trade(TradeCommand),
Remove(RemoveCommand),
Encrypt(EncryptCommand),
Decrypt(DecryptCommand),
Code(CodeCommand),
#[cfg(feature = "qr")]
Qr(QrCommand),
}
#[derive(Debug, Clone, Copy, ArgEnum)]
pub(crate) enum Verbosity {
Error = 0,
Warn = 1,
Info = 2,
Debug = 3,
Trace = 4,
}
impl std::fmt::Display for Verbosity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_fmt(format_args!(
"{}",
match self {
Verbosity::Error => "error",
Verbosity::Warn => "warn",
Verbosity::Info => "info",
Verbosity::Debug => "debug",
Verbosity::Trace => "trace",
}
))
}
}
impl FromStr for Verbosity {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"error" => Ok(Verbosity::Error),
"warn" => Ok(Verbosity::Warn),
"info" => Ok(Verbosity::Info),
"debug" => Ok(Verbosity::Debug),
"trace" => Ok(Verbosity::Trace),
_ => Err(anyhow!("Invalid verbosity level: {}", s)),
}
}
}
// HACK: the derive API doesn't support default subcommands, so we are going to make it so that it'll be easier to switch over when it's implemented.
// See: https://github.com/clap-rs/clap/issues/3857
impl From<Args> for CodeCommand {
fn from(args: Args) -> Self {
args.code
}
}

45
src/commands/code.rs Normal file
View file

@ -0,0 +1,45 @@
use std::{
sync::{Arc, Mutex},
time::{SystemTime, UNIX_EPOCH},
};
use log::*;
use steamguard::{steamapi, SteamGuardAccount};
use crate::AccountManager;
use super::*;
#[derive(Debug, Clone, Parser)]
#[clap(about = "Generate 2FA codes")]
pub struct CodeCommand {
#[clap(
long,
help = "Assume the computer's time is correct. Don't ask Steam for the time when generating codes."
)]
pub offline: bool,
}
impl AccountCommand for CodeCommand {
fn execute(
&self,
_manager: &mut AccountManager,
accounts: Vec<Arc<Mutex<SteamGuardAccount>>>,
) -> anyhow::Result<()> {
let server_time = if self.offline {
SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs()
} else {
steamapi::get_server_time()?.server_time()
};
debug!("Time used to generate codes: {}", server_time);
for account in accounts {
let account = account.lock().unwrap();
info!("Generating code for {}", account.account_name);
trace!("{:?}", account);
let code = account.generate_code(server_time);
println!("{}", code);
}
Ok(())
}
}

View file

@ -0,0 +1,18 @@
use clap::CommandFactory;
use super::*;
#[derive(Debug, Clone, Parser)]
#[clap(about = "Generate shell completions")]
pub struct CompletionsCommand {
#[clap(short, long, arg_enum, help = "The shell to generate completions for.")]
pub shell: Shell,
}
impl ConstCommand for CompletionsCommand {
fn execute(&self) -> anyhow::Result<()> {
let mut app = Args::command_for_update();
clap_complete::generate(self.shell, &mut app, "steamguard", &mut std::io::stdout());
Ok(())
}
}

View file

@ -1,7 +1,41 @@
use crate::tui;
use log::*;
use steamguard::{Confirmation, ConfirmationType};
use crate::tui;
use super::*;
#[derive(Debug, Clone, Parser, Default)]
#[clap(about = "Debug stuff, not useful for most users.")]
pub struct DebugCommand {
#[clap(long, help = "Show a text prompt.")]
pub demo_prompt: bool,
#[clap(long, help = "Show a \"press any key\" prompt.")]
pub demo_pause: bool,
#[clap(long, help = "Show a character prompt.")]
pub demo_prompt_char: bool,
#[clap(long, help = "Show an example confirmation menu using dummy data.")]
pub demo_conf_menu: bool,
}
impl ConstCommand for DebugCommand {
fn execute(&self) -> anyhow::Result<()> {
if self.demo_prompt {
demo_prompt();
}
if self.demo_pause {
demo_pause();
}
if self.demo_prompt_char {
demo_prompt_char();
}
if self.demo_conf_menu {
demo_confirmation_menu();
}
Ok(())
}
}
pub fn demo_prompt() {
print!("Prompt: ");
let result = tui::prompt();

43
src/commands/decrypt.rs Normal file
View file

@ -0,0 +1,43 @@
use log::*;
use crate::{AccountManager, ManifestAccountLoadError};
use super::*;
#[derive(Debug, Clone, Parser)]
#[clap(about = "Decrypt all maFiles")]
pub struct DecryptCommand;
impl ManifestCommand for DecryptCommand {
fn execute(&self, manager: &mut AccountManager) -> anyhow::Result<()> {
load_accounts_with_prompts(manager)?;
for mut entry in manager.iter_mut() {
entry.encryption = None;
}
manager.submit_passkey(None);
manager.save()?;
Ok(())
}
}
fn load_accounts_with_prompts(manager: &mut AccountManager) -> anyhow::Result<()> {
loop {
match manager.load_accounts() {
Ok(_) => return Ok(()),
Err(
ManifestAccountLoadError::MissingPasskey
| ManifestAccountLoadError::IncorrectPasskey,
) => {
if manager.has_passkey() {
error!("Incorrect passkey");
}
let passkey = rpassword::prompt_password_stdout("Enter encryption passkey: ").ok();
manager.submit_passkey(passkey);
}
Err(e) => {
error!("Could not load accounts: {}", e);
return Err(e.into());
}
}
}
}

39
src/commands/encrypt.rs Normal file
View file

@ -0,0 +1,39 @@
use log::*;
use crate::AccountManager;
use super::*;
#[derive(Debug, Clone, Parser)]
#[clap(about = "Encrypt all maFiles")]
pub struct EncryptCommand;
impl ManifestCommand for EncryptCommand {
fn execute(&self, manager: &mut AccountManager) -> anyhow::Result<()> {
if !manager.has_passkey() {
let mut passkey;
loop {
passkey = rpassword::prompt_password_stdout("Enter encryption passkey: ").ok();
if let Some(p) = passkey.as_ref() {
if p.is_empty() {
error!("Passkey cannot be empty, try again.");
continue;
}
}
let passkey_confirm =
rpassword::prompt_password_stdout("Confirm encryption passkey: ").ok();
if passkey == passkey_confirm {
break;
}
error!("Passkeys do not match, try again.");
}
manager.submit_passkey(passkey);
}
manager.load_accounts()?;
for entry in manager.iter_mut() {
entry.encryption = Some(crate::accountmanager::EntryEncryptionParams::generate());
}
manager.save()?;
Ok(())
}
}

42
src/commands/import.rs Normal file
View file

@ -0,0 +1,42 @@
use std::path::Path;
use log::*;
use crate::AccountManager;
use super::*;
#[derive(Debug, Clone, Parser, Default)]
#[clap(about = "Import an account with steamguard already set up")]
pub struct ImportCommand {
#[clap(long, help = "Whether or not the provided maFiles are from SDA.")]
pub sda: bool,
#[clap(long, help = "Paths to one or more maFiles, eg. \"./gaben.maFile\"")]
pub files: Vec<String>,
}
impl ManifestCommand for ImportCommand {
fn execute(&self, manager: &mut AccountManager) -> anyhow::Result<()> {
for file_path in self.files.iter() {
if self.sda {
let path = Path::new(&file_path);
let account = crate::accountmanager::migrate::load_and_upgrade_sda_account(path)?;
manager.add_account(account);
info!("Imported account: {}", &file_path);
} else {
match manager.import_account(file_path) {
Ok(_) => {
info!("Imported account: {}", &file_path);
}
Err(err) => {
bail!("Failed to import account: {} {}", &file_path, err);
}
}
}
}
manager.save()?;
Ok(())
}
}

55
src/commands/qr.rs Normal file
View file

@ -0,0 +1,55 @@
use std::sync::{Arc, Mutex};
use log::*;
use qrcode::QrCode;
use secrecy::ExposeSecret;
use crate::AccountManager;
use super::*;
#[derive(Debug, Clone, Parser)]
#[clap(about = "Generate QR codes. This *will* print sensitive data to stdout.")]
pub struct QrCommand {
#[clap(
long,
help = "Force using ASCII chars to generate QR codes. Useful for terminals that don't support unicode."
)]
pub ascii: bool,
}
impl AccountCommand for QrCommand {
fn execute(
&self,
_manager: &mut AccountManager,
accounts: Vec<Arc<Mutex<SteamGuardAccount>>>,
) -> anyhow::Result<()> {
use anyhow::Context;
info!("Generating QR codes for {} accounts", accounts.len());
for account in accounts {
let account = account.lock().unwrap();
let qr = QrCode::new(account.uri.expose_secret())
.context(format!("generating qr code for {}", account.account_name))?;
info!("Printing QR code for {}", account.account_name);
let qr_string = if self.ascii {
qr.render()
.light_color(' ')
.dark_color('#')
.module_dimensions(2, 1)
.build()
} else {
use qrcode::render::unicode;
qr.render::<unicode::Dense1x2>()
.dark_color(unicode::Dense1x2::Light)
.light_color(unicode::Dense1x2::Dark)
.build()
};
println!("{}", qr_string);
}
Ok(())
}
}

80
src/commands/remove.rs Normal file
View file

@ -0,0 +1,80 @@
use std::sync::{Arc, Mutex};
use log::*;
use crate::{errors::UserError, tui, AccountManager};
use super::*;
#[derive(Debug, Clone, Parser)]
#[clap(about = "Remove the authenticator from an account.")]
pub struct RemoveCommand;
impl AccountCommand for RemoveCommand {
fn execute(
&self,
manager: &mut AccountManager,
accounts: Vec<Arc<Mutex<SteamGuardAccount>>>,
) -> anyhow::Result<()> {
eprintln!(
"This will remove the mobile authenticator from {} accounts: {}",
accounts.len(),
accounts
.iter()
.map(|a| a.lock().unwrap().account_name.clone())
.collect::<Vec<String>>()
.join(", ")
);
match tui::prompt_char("Do you want to continue?", "yN") {
'y' => {}
_ => {
info!("Aborting!");
return Err(UserError::Aborted.into());
}
}
let mut successful = vec![];
for a in accounts {
let mut account = a.lock().unwrap();
if !account.is_logged_in() {
info!("Account does not have tokens, logging in");
crate::do_login(&mut account)?;
}
match account.remove_authenticator(None) {
Ok(success) => {
if success {
println!("Removed authenticator from {}", account.account_name);
successful.push(account.account_name.clone());
} else {
println!(
"Failed to remove authenticator from {}",
account.account_name
);
if tui::prompt_char(
"Would you like to remove it from the manifest anyway?",
"yN",
) == 'y'
{
successful.push(account.account_name.clone());
}
}
}
Err(err) => {
error!(
"Unexpected error when removing authenticator from {}: {}",
account.account_name, err
);
}
}
}
for account_name in successful {
manager.remove_account(account_name);
}
manager.save()?;
Ok(())
}
}

137
src/commands/setup.rs Normal file
View file

@ -0,0 +1,137 @@
use log::*;
use secrecy::ExposeSecret;
use steamguard::{
accountlinker::AccountLinkSuccess, AccountLinkError, AccountLinker, FinalizeLinkError,
};
use crate::{tui, AccountManager};
use super::*;
#[derive(Debug, Clone, Parser)]
#[clap(about = "Set up a new account with steamguard-cli")]
pub struct SetupCommand;
impl ManifestCommand for SetupCommand {
fn execute(&self, manager: &mut AccountManager) -> anyhow::Result<()> {
eprintln!("Log in to the account that you want to link to steamguard-cli");
eprint!("Username: ");
let username = tui::prompt().to_lowercase();
let account_name = username.clone();
if manager.account_exists(&username) {
bail!(
"Account {} already exists in manifest, remove it first",
username
);
}
info!("Logging in to {}", username);
let session =
crate::do_login_raw(username).expect("Failed to log in. Account has not been linked.");
info!("Adding authenticator...");
let mut linker = AccountLinker::new(session);
let link: AccountLinkSuccess;
loop {
match linker.link() {
Ok(a) => {
link = a;
break;
}
Err(AccountLinkError::MustRemovePhoneNumber) => {
println!("There is already a phone number on this account, please remove it and try again.");
bail!("There is already a phone number on this account, please remove it and try again.");
}
Err(AccountLinkError::MustProvidePhoneNumber) => {
println!("Enter your phone number in the following format: +1 123-456-7890");
print!("Phone number: ");
linker.phone_number = tui::prompt().replace(&['(', ')', '-'][..], "");
}
Err(AccountLinkError::AuthenticatorPresent) => {
println!("An authenticator is already present on this account.");
bail!("An authenticator is already present on this account.");
}
Err(AccountLinkError::MustConfirmEmail) => {
println!("Check your email and click the link.");
tui::pause();
}
Err(err) => {
error!(
"Failed to link authenticator. Account has not been linked. {}",
err
);
return Err(err.into());
}
}
}
let mut server_time = link.server_time();
let phone_number_hint = link.phone_number_hint().to_owned();
manager.add_account(link.into_account());
match manager.save() {
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);
println!(
"Just in case, here is the account info. Save it somewhere just in case!\n{:#?}",
manager.get_account(&account_name).unwrap().lock().unwrap()
);
return Err(err);
}
}
let account_arc = manager
.get_account(&account_name)
.expect("account was not present in manifest");
let mut account = account_arc.lock().unwrap();
println!("Authenticator has not yet been linked. Before continuing with finalization, please take the time to write down your revocation code: {}", account.revocation_code.expose_secret());
tui::pause();
debug!("attempting link finalization");
println!(
"A code has been sent to your phone number ending in {}.",
phone_number_hint
);
print!("Enter SMS code: ");
let sms_code = tui::prompt();
let mut tries = 0;
loop {
match linker.finalize(server_time, &mut account, sms_code.clone()) {
Ok(_) => break,
Err(FinalizeLinkError::WantMore { server_time: s }) => {
server_time = s;
debug!("steam wants more 2fa codes (tries: {})", tries);
tries += 1;
if tries >= 30 {
error!("Failed to finalize: unable to generate valid 2fa codes");
bail!("Failed to finalize: unable to generate valid 2fa codes");
}
}
Err(err) => {
error!("Failed to finalize: {}", err);
return Err(err.into());
}
}
}
let revocation_code = account.revocation_code.clone();
drop(account); // explicitly drop the lock so we don't hang on the mutex
println!("Authenticator finalized.");
match manager.save() {
Ok(_) => {}
Err(err) => {
println!(
"Failed to save manifest, but we were able to save it before. {}",
err
);
return Err(err);
}
}
println!(
"Authenticator has been finalized. Please actually write down your revocation code: {}",
revocation_code.expose_secret()
);
Ok(())
}
}

115
src/commands/trade.rs Normal file
View file

@ -0,0 +1,115 @@
use std::sync::{Arc, Mutex};
use crossterm::tty::IsTty;
use log::*;
use steamguard::Confirmation;
use crate::{tui, AccountManager};
use super::*;
#[derive(Debug, Clone, Parser)]
#[clap(about = "Interactive interface for trade confirmations")]
pub struct TradeCommand {
#[clap(
short,
long,
help = "Accept all open trade confirmations. Does not open interactive interface."
)]
pub accept_all: bool,
#[clap(
short,
long,
help = "If submitting a confirmation response fails, exit immediately."
)]
pub fail_fast: bool,
}
impl AccountCommand for TradeCommand {
fn execute(
&self,
manager: &mut AccountManager,
accounts: Vec<Arc<Mutex<SteamGuardAccount>>>,
) -> anyhow::Result<()> {
for a in accounts {
let mut account = a.lock().unwrap();
if !account.is_logged_in() {
info!("Account does not have tokens, logging in");
crate::do_login(&mut account)?;
}
info!("Checking for trade confirmations");
let confirmations: Vec<Confirmation>;
loop {
match account.get_trade_confirmations() {
Ok(confs) => {
confirmations = confs;
break;
}
Err(err) => {
error!("Failed to get trade confirmations: {:#?}", err);
info!("failed to get trade confirmations, asking user to log in");
crate::do_login(&mut account)?;
}
}
}
let mut any_failed = false;
if self.accept_all {
info!("accepting all confirmations");
for conf in &confirmations {
let result = account.accept_confirmation(conf);
if result.is_err() {
warn!("accept confirmation result: {:?}", result);
any_failed = true;
if self.fail_fast {
return result;
}
} else {
debug!("accept confirmation result: {:?}", result);
}
}
} else if std::io::stdout().is_tty() {
let (accept, deny) = tui::prompt_confirmation_menu(confirmations)?;
for conf in &accept {
let result = account.accept_confirmation(conf);
if result.is_err() {
warn!("accept confirmation result: {:?}", result);
any_failed = true;
if self.fail_fast {
return result;
}
} else {
debug!("accept confirmation result: {:?}", result);
}
}
for conf in &deny {
let result = account.deny_confirmation(conf);
debug!("deny confirmation result: {:?}", result);
if result.is_err() {
warn!("deny confirmation result: {:?}", result);
any_failed = true;
if self.fail_fast {
return result;
}
} else {
debug!("deny confirmation result: {:?}", result);
}
}
} else {
warn!("not a tty, not showing menu");
for conf in &confirmations {
println!("{}", conf.description());
}
}
if any_failed {
error!("Failed to respond to some confirmations.");
}
}
manager.save()?;
Ok(())
}
}

View file

@ -45,12 +45,12 @@ impl Default for EncryptionScheme {
pub trait EntryEncryptor {
fn encrypt(
passkey: &String,
passkey: &str,
params: &EntryEncryptionParams,
plaintext: Vec<u8>,
) -> anyhow::Result<Vec<u8>, EntryEncryptionError>;
fn decrypt(
passkey: &String,
passkey: &str,
params: &EntryEncryptionParams,
ciphertext: Vec<u8>,
) -> anyhow::Result<Vec<u8>, EntryEncryptionError>;
@ -63,10 +63,7 @@ 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]> {
fn get_encryption_key(passkey: &str, salt: &str) -> 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];
@ -86,11 +83,11 @@ impl EntryEncryptor for LegacySdaCompatible {
// ngl, this logic sucks ass. its kinda annoying that the logic is not completely symetric.
fn encrypt(
passkey: &String,
passkey: &str,
params: &EntryEncryptionParams,
plaintext: Vec<u8>,
) -> anyhow::Result<Vec<u8>, EntryEncryptionError> {
let key = Self::get_encryption_key(&passkey.into(), &params.salt)?;
let key = Self::get_encryption_key(passkey, &params.salt)?;
let iv = base64::decode(&params.iv)?;
let cipher = Aes256Cbc::new_from_slices(&key, &iv)?;
@ -116,11 +113,11 @@ impl EntryEncryptor for LegacySdaCompatible {
}
fn decrypt(
passkey: &String,
passkey: &str,
params: &EntryEncryptionParams,
ciphertext: Vec<u8>,
) -> anyhow::Result<Vec<u8>, EntryEncryptionError> {
let key = Self::get_encryption_key(&passkey.into(), &params.salt)?;
let key = Self::get_encryption_key(passkey, &params.salt)?;
let iv = base64::decode(&params.iv)?;
let cipher = Aes256Cbc::new_from_slices(&key, &iv)?;
@ -181,16 +178,14 @@ mod tests {
#[test]
fn test_encryption_key() {
assert_eq!(
LegacySdaCompatible::get_encryption_key(&"password".into(), &"GMhL0N2hqXg=".into())
.unwrap(),
LegacySdaCompatible::get_encryption_key("password", "GMhL0N2hqXg=").unwrap(),
base64::decode("KtiRa4/OxW83MlB6URf+Z8rAGj7CBY+pDlwD/NuVo6Y=")
.unwrap()
.as_slice()
);
assert_eq!(
LegacySdaCompatible::get_encryption_key(&"password".into(), &"wTzTE9A6aN8=".into())
.unwrap(),
LegacySdaCompatible::get_encryption_key("password", "wTzTE9A6aN8=").unwrap(),
base64::decode("Dqpej/3DqEat0roJaHmu3luYgDzRCUmzX94n4fqvWj8=")
.unwrap()
.as_slice()
@ -202,9 +197,8 @@ mod tests {
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();
let encrypted = LegacySdaCompatible::encrypt(passkey, &params, orig.clone()).unwrap();
let result = LegacySdaCompatible::decrypt(passkey, &params, encrypted).unwrap();
assert_eq!(orig, result.to_vec());
Ok(())
}

View file

@ -4,6 +4,4 @@ use thiserror::Error;
pub(crate) enum UserError {
#[error("User aborted the operation.")]
Aborted,
#[error("Unknown subcommand. It may need to be implemented.")]
UnknownSubcommand,
}

View file

@ -1,29 +1,21 @@
extern crate rpassword;
use clap::{IntoApp, Parser};
use crossterm::tty::IsTty;
use clap::Parser;
use log::*;
#[cfg(feature = "qr")]
use qrcode::QrCode;
use std::time::{SystemTime, UNIX_EPOCH};
use std::{
io::{stdout, Write},
io::Write,
path::Path,
sync::{Arc, Mutex},
};
use steamguard::accountlinker::AccountLinkSuccess;
use steamguard::protobufs::steammessages_auth_steamclient::{
EAuthSessionGuardType, EAuthTokenPlatformType,
};
use steamguard::token::Tokens;
use steamguard::{
steamapi, AccountLinkError, AccountLinker, Confirmation, DeviceDetails, ExposeSecret,
FinalizeLinkError, LoginError, SteamGuardAccount, UserLogin,
};
use steamguard::{steamapi, DeviceDetails, LoginError, SteamGuardAccount, UserLogin};
use crate::accountmanager::migrate::load_and_migrate;
use crate::accountmanager::{AccountManager, ManifestAccountLoadError, ManifestLoadError};
pub use crate::accountmanager::{AccountManager, ManifestAccountLoadError, ManifestLoadError};
use crate::commands::{CommandType, Subcommands};
#[macro_use]
extern crate lazy_static;
#[macro_use]
extern crate anyhow;
@ -33,12 +25,10 @@ extern crate dirs;
extern crate proptest;
extern crate ring;
mod accountmanager;
mod cli;
mod demos;
mod commands;
mod encryption;
mod errors;
mod secret_string;
mod test_login;
pub(crate) mod tui;
fn main() {
@ -52,27 +42,37 @@ fn main() {
}
fn run() -> anyhow::Result<()> {
let args = cli::Args::parse();
let args = commands::Args::parse();
info!("{:?}", args);
let globalargs = args.global;
stderrlog::new()
.verbosity(args.verbosity as usize)
.verbosity(globalargs.verbosity as usize)
.module(module_path!())
.module("steamguard")
.init()
.unwrap();
match args.sub {
Some(cli::Subcommands::Debug(args)) => {
return do_subcmd_debug(args);
}
Some(cli::Subcommands::Completion(args)) => {
return do_subcmd_completion(args);
}
_ => {}
let cmd: CommandType = match args.sub.unwrap_or(Subcommands::Code(args.code)) {
Subcommands::Debug(args) => CommandType::Const(Box::new(args)),
Subcommands::Completion(args) => CommandType::Const(Box::new(args)),
Subcommands::Setup(args) => CommandType::Manifest(Box::new(args)),
Subcommands::Import(args) => CommandType::Manifest(Box::new(args)),
Subcommands::Encrypt(args) => CommandType::Manifest(Box::new(args)),
Subcommands::Decrypt(args) => CommandType::Manifest(Box::new(args)),
Subcommands::Trade(args) => CommandType::Account(Box::new(args)),
Subcommands::Remove(args) => CommandType::Account(Box::new(args)),
Subcommands::Code(args) => CommandType::Account(Box::new(args)),
#[cfg(feature = "qr")]
Subcommands::Qr(args) => CommandType::Account(Box::new(args)),
};
let mafiles_dir = if let Some(mafiles_path) = &args.mafiles_path {
if let CommandType::Const(cmd) = cmd {
return cmd.execute();
}
let mafiles_dir = if let Some(mafiles_path) = &globalargs.mafiles_path {
mafiles_path.clone()
} else {
get_mafiles_dir()
@ -82,15 +82,13 @@ fn run() -> anyhow::Result<()> {
let mut manager: accountmanager::AccountManager;
if !path.exists() {
error!("Did not find manifest in {}", mafiles_dir);
match tui::prompt_char(
if tui::prompt_char(
format!("Would you like to create a manifest in {} ?", mafiles_dir).as_str(),
"Yn",
) {
'n' => {
info!("Aborting!");
return Err(errors::UserError::Aborted.into());
}
_ => {}
) == 'n'
{
info!("Aborting!");
return Err(errors::UserError::Aborted.into());
}
std::fs::create_dir_all(mafiles_dir)?;
@ -101,10 +99,11 @@ fn run() -> anyhow::Result<()> {
Ok(m) => m,
Err(ManifestLoadError::MigrationNeeded) => {
info!("Migrating manifest");
let (manifest, accounts) = load_and_migrate(path.as_path(), args.passkey.as_ref())?;
let (manifest, accounts) =
load_and_migrate(path.as_path(), globalargs.passkey.as_ref())?;
let mut manager = AccountManager::from_manifest(manifest, mafiles_dir);
manager.register_accounts(accounts);
manager.submit_passkey(args.passkey.clone());
manager.submit_passkey(globalargs.passkey.clone());
manager.save()?;
manager
}
@ -115,7 +114,7 @@ fn run() -> anyhow::Result<()> {
}
}
let mut passkey = args.passkey.clone();
let mut passkey = globalargs.passkey.clone();
manager.submit_passkey(passkey);
loop {
@ -146,25 +145,14 @@ fn run() -> anyhow::Result<()> {
}
}
match args.sub {
Some(cli::Subcommands::Setup(args)) => {
return do_subcmd_setup(args, &mut manager);
}
Some(cli::Subcommands::Import(args)) => {
return do_subcmd_import(args, &mut manager);
}
Some(cli::Subcommands::Encrypt(args)) => {
return do_subcmd_encrypt(args, &mut manager);
}
Some(cli::Subcommands::Decrypt(args)) => {
return do_subcmd_decrypt(args, &mut manager);
}
_ => {}
if let CommandType::Manifest(cmd) = cmd {
cmd.execute(&mut manager)?;
return Ok(());
}
let selected_accounts: Vec<Arc<Mutex<SteamGuardAccount>>>;
loop {
match get_selected_accounts(&args, &mut manager) {
match get_selected_accounts(&globalargs, &mut manager) {
Ok(accounts) => {
selected_accounts = accounts;
break;
@ -194,23 +182,15 @@ fn run() -> anyhow::Result<()> {
.collect::<Vec<String>>()
);
match args.sub.unwrap_or(cli::Subcommands::Code(args.code)) {
cli::Subcommands::Trade(args) => do_subcmd_trade(args, &mut manager, selected_accounts),
cli::Subcommands::Remove(args) => do_subcmd_remove(args, &mut manager, selected_accounts),
cli::Subcommands::Code(args) => do_subcmd_code(args, selected_accounts),
#[cfg(feature = "qr")]
cli::Subcommands::Qr(args) => do_subcmd_qr(args, selected_accounts),
#[cfg(debug_assertions)]
cli::Subcommands::TestLogin => test_login::do_subcmd_test_login(selected_accounts),
s => {
error!("Unknown subcommand: {:?}", s);
Err(errors::UserError::UnknownSubcommand.into())
}
if let CommandType::Account(cmd) = cmd {
return cmd.execute(&mut manager, selected_accounts);
}
Ok(())
}
fn get_selected_accounts(
args: &cli::Args,
args: &commands::GlobalArgs,
manifest: &mut accountmanager::AccountManager,
) -> anyhow::Result<Vec<Arc<Mutex<SteamGuardAccount>>>, ManifestAccountLoadError> {
let mut selected_accounts: Vec<Arc<Mutex<SteamGuardAccount>>> = vec![];
@ -274,13 +254,10 @@ fn do_login_impl(
password: String,
account: Option<&SteamGuardAccount>,
) -> anyhow::Result<Tokens> {
let mut login = UserLogin::new(
EAuthTokenPlatformType::k_EAuthTokenPlatformType_MobileApp,
build_device_details(),
);
let mut login = UserLogin::new(build_device_details());
let mut password = password;
let mut confirmation_methods;
let confirmation_methods;
loop {
match login.begin_auth_via_credentials(&username, &password) {
Ok(methods) => {
@ -306,7 +283,7 @@ fn do_login_impl(
}
}
for (method) in confirmation_methods {
for method in confirmation_methods {
match method.confirmation_type {
EAuthSessionGuardType::k_EAuthSessionGuardType_DeviceConfirmation => {
eprintln!("Please confirm this login on your other device.");
@ -325,7 +302,7 @@ fn do_login_impl(
EAuthSessionGuardType::k_EAuthSessionGuardType_DeviceCode => {
let code = if let Some(account) = account {
debug!("Generating 2fa code...");
let time = steamapi::get_server_time()?.server_time;
let time = steamapi::get_server_time()?.server_time();
account.generate_code(time)
} else {
eprint!("Enter the 2fa code from your device: ");
@ -381,456 +358,3 @@ fn get_mafiles_dir() -> String {
return paths[0].to_str().unwrap().into();
}
fn load_accounts_with_prompts(manifest: &mut accountmanager::AccountManager) -> anyhow::Result<()> {
loop {
match manifest.load_accounts() {
Ok(_) => return Ok(()),
Err(
accountmanager::ManifestAccountLoadError::MissingPasskey
| accountmanager::ManifestAccountLoadError::IncorrectPasskey,
) => {
if manifest.has_passkey() {
error!("Incorrect passkey");
}
let passkey = rpassword::prompt_password_stdout("Enter encryption passkey: ").ok();
manifest.submit_passkey(passkey);
}
Err(e) => {
error!("Could not load accounts: {}", e);
return Err(e.into());
}
}
}
}
fn do_subcmd_debug(args: cli::ArgsDebug) -> anyhow::Result<()> {
if args.demo_prompt {
demos::demo_prompt();
}
if args.demo_pause {
demos::demo_pause();
}
if args.demo_prompt_char {
demos::demo_prompt_char();
}
if args.demo_conf_menu {
demos::demo_confirmation_menu();
}
Ok(())
}
fn do_subcmd_completion(args: cli::ArgsCompletions) -> Result<(), anyhow::Error> {
let mut app = cli::Args::command_for_update();
clap_complete::generate(args.shell, &mut app, "steamguard", &mut std::io::stdout());
Ok(())
}
fn do_subcmd_setup(
_args: cli::ArgsSetup,
manifest: &mut accountmanager::AccountManager,
) -> anyhow::Result<()> {
eprintln!("Log in to the account that you want to link to steamguard-cli");
eprint!("Username: ");
let username = tui::prompt().to_lowercase();
let account_name = username.clone();
if manifest.account_exists(&username) {
bail!(
"Account {} already exists in manifest, remove it first",
username
);
}
info!("Logging in to {}", username);
let session = do_login_raw(username).expect("Failed to log in. Account has not been linked.");
info!("Adding authenticator...");
let mut linker = AccountLinker::new(session);
let link: AccountLinkSuccess;
loop {
match linker.link() {
Ok(a) => {
link = a;
break;
}
Err(AccountLinkError::MustRemovePhoneNumber) => {
println!("There is already a phone number on this account, please remove it and try again.");
bail!("There is already a phone number on this account, please remove it and try again.");
}
Err(AccountLinkError::MustProvidePhoneNumber) => {
println!("Enter your phone number in the following format: +1 123-456-7890");
print!("Phone number: ");
linker.phone_number = tui::prompt().replace(&['(', ')', '-'][..], "");
}
Err(AccountLinkError::AuthenticatorPresent) => {
println!("An authenticator is already present on this account.");
bail!("An authenticator is already present on this account.");
}
Err(AccountLinkError::MustConfirmEmail) => {
println!("Check your email and click the link.");
tui::pause();
}
Err(err) => {
error!(
"Failed to link authenticator. Account has not been linked. {}",
err
);
return Err(err.into());
}
}
}
let mut server_time = link.server_time();
let phone_number_hint = link.phone_number_hint().to_owned();
manifest.add_account(link.into_account());
match manifest.save() {
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);
println!(
"Just in case, here is the account info. Save it somewhere just in case!\n{:#?}",
manifest.get_account(&account_name).unwrap().lock().unwrap()
);
return Err(err);
}
}
let account_arc = manifest
.get_account(&account_name)
.expect("account was not present in manifest");
let mut account = account_arc.lock().unwrap();
println!("Authenticator has not yet been linked. Before continuing with finalization, please take the time to write down your revocation code: {}", account.revocation_code.expose_secret());
tui::pause();
debug!("attempting link finalization");
println!(
"A code has been sent to your phone number ending in {}.",
phone_number_hint
);
print!("Enter SMS code: ");
let sms_code = tui::prompt();
let mut tries = 0;
loop {
match linker.finalize(server_time, &mut account, sms_code.clone()) {
Ok(_) => break,
Err(FinalizeLinkError::WantMore { server_time: s }) => {
server_time = s;
debug!("steam wants more 2fa codes (tries: {})", tries);
tries += 1;
if tries >= 30 {
error!("Failed to finalize: unable to generate valid 2fa codes");
bail!("Failed to finalize: unable to generate valid 2fa codes");
}
}
Err(err) => {
error!("Failed to finalize: {}", err);
return Err(err.into());
}
}
}
let revocation_code = account.revocation_code.clone();
drop(account); // explicitly drop the lock so we don't hang on the mutex
println!("Authenticator finalized.");
match manifest.save() {
Ok(_) => {}
Err(err) => {
println!(
"Failed to save manifest, but we were able to save it before. {}",
err
);
return Err(err);
}
}
println!(
"Authenticator has been finalized. Please actually write down your revocation code: {}",
revocation_code.expose_secret()
);
Ok(())
}
fn do_subcmd_import(
args: cli::ArgsImport,
manifest: &mut accountmanager::AccountManager,
) -> anyhow::Result<()> {
for file_path in args.files {
if args.sda {
let path = Path::new(&file_path);
let account = accountmanager::migrate::load_and_upgrade_sda_account(path)?;
manifest.add_account(account);
info!("Imported account: {}", &file_path);
} else {
match manifest.import_account(&file_path) {
Ok(_) => {
info!("Imported account: {}", &file_path);
}
Err(err) => {
bail!("Failed to import account: {} {}", &file_path, err);
}
}
}
}
manifest.save()?;
Ok(())
}
fn do_subcmd_trade(
args: cli::ArgsTrade,
manifest: &mut accountmanager::AccountManager,
mut selected_accounts: Vec<Arc<Mutex<SteamGuardAccount>>>,
) -> anyhow::Result<()> {
for a in selected_accounts.iter_mut() {
let mut account = a.lock().unwrap();
if !account.is_logged_in() {
info!("Account does not have tokens, logging in");
do_login(&mut account)?;
}
info!("Checking for trade confirmations");
let confirmations: Vec<Confirmation>;
loop {
match account.get_trade_confirmations() {
Ok(confs) => {
confirmations = confs;
break;
}
Err(err) => {
error!("Failed to get trade confirmations: {:#?}", err);
info!("failed to get trade confirmations, asking user to log in");
do_login(&mut account)?;
}
}
}
let mut any_failed = false;
if args.accept_all {
info!("accepting all confirmations");
for conf in &confirmations {
let result = account.accept_confirmation(conf);
if result.is_err() {
warn!("accept confirmation result: {:?}", result);
any_failed = true;
if args.fail_fast {
return result;
}
} else {
debug!("accept confirmation result: {:?}", result);
}
}
} else if stdout().is_tty() {
let (accept, deny) = tui::prompt_confirmation_menu(confirmations)?;
for conf in &accept {
let result = account.accept_confirmation(conf);
if result.is_err() {
warn!("accept confirmation result: {:?}", result);
any_failed = true;
if args.fail_fast {
return result;
}
} else {
debug!("accept confirmation result: {:?}", result);
}
}
for conf in &deny {
let result = account.deny_confirmation(conf);
debug!("deny confirmation result: {:?}", result);
if result.is_err() {
warn!("deny confirmation result: {:?}", result);
any_failed = true;
if args.fail_fast {
return result;
}
} else {
debug!("deny confirmation result: {:?}", result);
}
}
} else {
warn!("not a tty, not showing menu");
for conf in &confirmations {
println!("{}", conf.description());
}
}
if any_failed {
error!("Failed to respond to some confirmations.");
}
}
manifest.save()?;
Ok(())
}
fn do_subcmd_remove(
_args: cli::ArgsRemove,
manifest: &mut accountmanager::AccountManager,
selected_accounts: Vec<Arc<Mutex<SteamGuardAccount>>>,
) -> anyhow::Result<()> {
println!(
"This will remove the mobile authenticator from {} accounts: {}",
selected_accounts.len(),
selected_accounts
.iter()
.map(|a| a.lock().unwrap().account_name.clone())
.collect::<Vec<String>>()
.join(", ")
);
match tui::prompt_char("Do you want to continue?", "yN") {
'y' => {}
_ => {
info!("Aborting!");
return Err(errors::UserError::Aborted.into());
}
}
let mut successful = vec![];
for a in selected_accounts {
let mut account = a.lock().unwrap();
if !account.is_logged_in() {
info!("Account does not have tokens, logging in");
do_login(&mut account)?;
}
match account.remove_authenticator(None) {
Ok(success) => {
if success {
println!("Removed authenticator from {}", account.account_name);
successful.push(account.account_name.clone());
} else {
println!(
"Failed to remove authenticator from {}",
account.account_name
);
match tui::prompt_char(
"Would you like to remove it from the manifest anyway?",
"yN",
) {
'y' => {
successful.push(account.account_name.clone());
}
_ => {}
}
}
}
Err(err) => {
error!(
"Unexpected error when removing authenticator from {}: {}",
account.account_name, err
);
}
}
}
for account_name in successful {
manifest.remove_account(account_name);
}
manifest.save()?;
Ok(())
}
fn do_subcmd_encrypt(
_args: cli::ArgsEncrypt,
manifest: &mut accountmanager::AccountManager,
) -> anyhow::Result<()> {
if !manifest.has_passkey() {
let mut passkey;
loop {
passkey = rpassword::prompt_password_stdout("Enter encryption passkey: ").ok();
if let Some(p) = passkey.as_ref() {
if p.is_empty() {
error!("Passkey cannot be empty, try again.");
continue;
}
}
let passkey_confirm =
rpassword::prompt_password_stdout("Confirm encryption passkey: ").ok();
if passkey == passkey_confirm {
break;
}
error!("Passkeys do not match, try again.");
}
manifest.submit_passkey(passkey);
}
manifest.load_accounts()?;
for entry in manifest.iter_mut() {
entry.encryption = Some(accountmanager::EntryEncryptionParams::generate());
}
manifest.save()?;
Ok(())
}
fn do_subcmd_decrypt(
_args: cli::ArgsDecrypt,
manifest: &mut accountmanager::AccountManager,
) -> anyhow::Result<()> {
load_accounts_with_prompts(manifest)?;
for mut entry in manifest.iter_mut() {
entry.encryption = None;
}
manifest.submit_passkey(None);
manifest.save()?;
Ok(())
}
fn do_subcmd_code(
args: cli::ArgsCode,
selected_accounts: Vec<Arc<Mutex<SteamGuardAccount>>>,
) -> anyhow::Result<()> {
let server_time = if args.offline {
SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs()
} else {
steamapi::get_server_time()?.server_time
};
debug!("Time used to generate codes: {}", server_time);
for account in selected_accounts {
info!(
"Generating code for {}",
account.lock().unwrap().account_name
);
trace!("{:?}", account);
let code = account.lock().unwrap().generate_code(server_time);
println!("{}", code);
}
Ok(())
}
#[cfg(feature = "qr")]
fn do_subcmd_qr(
args: cli::ArgsQr,
selected_accounts: Vec<Arc<Mutex<SteamGuardAccount>>>,
) -> anyhow::Result<()> {
use anyhow::Context;
info!(
"Generating QR codes for {} accounts",
selected_accounts.len()
);
for account in selected_accounts {
let account = account.lock().unwrap();
let qr = QrCode::new(account.uri.expose_secret())
.context(format!("generating qr code for {}", account.account_name))?;
info!("Printing QR code for {}", account.account_name);
let qr_string = if args.ascii {
qr.render()
.light_color(' ')
.dark_color('#')
.module_dimensions(2, 1)
.build()
} else {
use qrcode::render::unicode;
qr.render::<unicode::Dense1x2>()
.dark_color(unicode::Dense1x2::Light)
.light_color(unicode::Dense1x2::Dark)
.build()
};
println!("{}", qr_string);
}
Ok(())
}

View file

@ -1,13 +1,5 @@
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Deserializer, Serializer};
/// Helper to allow serializing a [secrecy::SecretString] as a [String]
pub(crate) fn serialize<S>(secret_string: &SecretString, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(secret_string.expose_secret())
}
use secrecy::SecretString;
use serde::{Deserialize, Deserializer};
/// Helper to allow deserializing a [String] as a [secrecy::SecretString]
pub(crate) fn deserialize<'de, D>(d: D) -> Result<secrecy::SecretString, D::Error>
@ -20,35 +12,18 @@ where
#[cfg(test)]
mod test {
use serde::Serialize;
use secrecy::ExposeSecret;
use super::*;
#[test]
fn test_secret_string_round_trip() {
#[derive(Serialize, Deserialize)]
struct Foo {
#[serde(with = "super")]
secret: SecretString,
}
let foo = Foo {
secret: String::from("hello").into(),
};
let s = serde_json::to_string(&foo).unwrap();
let foo2: Foo = serde_json::from_str(&s).unwrap();
assert_eq!(foo.secret.expose_secret(), foo2.secret.expose_secret());
#[derive(Deserialize)]
struct Foo {
#[serde(with = "super")]
secret: SecretString,
}
#[test]
fn test_secret_string_deserialize() {
#[derive(Serialize, Deserialize)]
struct Foo {
#[serde(with = "super")]
secret: SecretString,
}
let foo: Foo = serde_json::from_str("{\"secret\": \"hello\"}").unwrap();
assert_eq!(foo.secret.expose_secret(), "hello");
}

View file

@ -1,18 +0,0 @@
use std::sync::{Arc, Mutex};
use log::info;
use steamguard::SteamGuardAccount;
use crate::do_login;
pub fn do_subcmd_test_login(
selected_accounts: Vec<Arc<Mutex<SteamGuardAccount>>>,
) -> anyhow::Result<()> {
for account in selected_accounts {
let mut account = account.lock().unwrap();
do_login(&mut account)?;
info!("Logged in successfully!");
}
Ok(())
}

View file

@ -6,39 +6,10 @@ use crossterm::{
terminal::{Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen},
QueueableCommand,
};
use log::*;
use regex::Regex;
use std::collections::HashSet;
use std::io::{stderr, stdout, Write};
use steamguard::Confirmation;
lazy_static! {
static ref CAPTCHA_VALID_CHARS: Regex =
Regex::new("^([A-H]|[J-N]|[P-R]|[T-Z]|[2-4]|[7-9]|[@%&])+$").unwrap();
}
pub fn validate_captcha_text(text: &String) -> bool {
CAPTCHA_VALID_CHARS.is_match(text)
}
#[test]
fn test_validate_captcha_text() {
assert!(validate_captcha_text(&String::from("2WWUA@")));
assert!(validate_captcha_text(&String::from("3G8HT2")));
assert!(validate_captcha_text(&String::from("3J%@X3")));
assert!(validate_captcha_text(&String::from("2GCZ4A")));
assert!(validate_captcha_text(&String::from("3G8HT2")));
assert!(!validate_captcha_text(&String::from("asd823")));
assert!(!validate_captcha_text(&String::from("!PQ4RD")));
assert!(!validate_captcha_text(&String::from("1GQ4XZ")));
assert!(!validate_captcha_text(&String::from("8GO4XZ")));
assert!(!validate_captcha_text(&String::from("IPQ4RD")));
assert!(!validate_captcha_text(&String::from("0PT4RD")));
assert!(!validate_captcha_text(&String::from("APTSRD")));
assert!(!validate_captcha_text(&String::from("AP5TRD")));
assert!(!validate_captcha_text(&String::from("AP6TRD")));
}
/// Prompt the user for text input.
pub(crate) fn prompt() -> String {
stdout().flush().expect("failed to flush stdout");
@ -60,20 +31,6 @@ pub(crate) fn prompt() -> String {
line
}
pub(crate) fn prompt_captcha_text(captcha_gid: &String) -> String {
eprintln!("Captcha required. Open this link in your web browser: https://steamcommunity.com/public/captcha.php?gid={}", captcha_gid);
let mut captcha_text;
loop {
eprint!("Enter captcha text: ");
captcha_text = prompt();
if !captcha_text.is_empty() && validate_captcha_text(&captcha_text) {
break;
}
warn!("Invalid chars for captcha text found in user's input. Prompting again...");
}
captcha_text
}
/// Prompt the user for a single character response. Useful for asking yes or no questions.
///
/// `chars` should be all lowercase characters, with at most 1 uppercase character. The uppercase character is the default answer if no answer is provided.
@ -149,7 +106,7 @@ pub(crate) fn prompt_confirmation_menu(
),
)?;
for i in 0..confirmations.len() {
for (i, conf) in confirmations.iter().enumerate() {
stdout().queue(Print("\r"))?;
if selected_idx == i {
stdout().queue(SetForegroundColor(Color::Yellow))?;
@ -173,7 +130,7 @@ pub(crate) fn prompt_confirmation_menu(
stdout().queue(SetForegroundColor(Color::Yellow))?;
}
stdout().queue(Print(format!(" {}\n", confirmations[i].description())))?;
stdout().queue(Print(format!(" {}\n", conf.description())))?;
}
stdout().flush()?;

View file

@ -4,11 +4,7 @@ use crate::protobufs::service_twofactor::{
use crate::steamapi::twofactor::TwoFactorClient;
use crate::token::TwoFactorSecret;
use crate::transport::WebApiTransport;
use crate::{
steamapi::{EResult, Session, SteamApiClient},
token::Tokens,
SteamGuardAccount,
};
use crate::{steamapi::EResult, token::Tokens, SteamGuardAccount};
use log::*;
use thiserror::Error;
@ -81,14 +77,14 @@ impl AccountLinker {
revocation_code: resp.take_revocation_code().into(),
uri: resp.take_uri().into(),
shared_secret: TwoFactorSecret::from_bytes(resp.take_shared_secret()),
token_gid: resp.take_token_gid().into(),
identity_secret: base64::encode(&resp.take_identity_secret()).into(),
token_gid: resp.take_token_gid(),
identity_secret: base64::encode(resp.take_identity_secret()).into(),
device_id: self.device_id.clone(),
secret_1: base64::encode(&resp.take_secret_1()).into(),
secret_1: base64::encode(resp.take_secret_1()).into(),
tokens: Some(self.tokens.clone()),
};
let success = AccountLinkSuccess {
account: account,
account,
server_time: resp.server_time(),
phone_number_hint: resp.take_phone_number_hint(),
};
@ -128,7 +124,7 @@ impl AccountLinker {
}
self.finalized = true;
return Ok(());
Ok(())
}
}
@ -158,7 +154,7 @@ impl AccountLinkSuccess {
}
fn generate_device_id() -> String {
return format!("android:{}", uuid::Uuid::new_v4().to_string());
format!("android:{}", uuid::Uuid::new_v4())
}
#[derive(Error, Debug)]

View file

@ -1,115 +0,0 @@
use crate::{token::TwoFactorSecret, SteamGuardAccount};
use super::parse_json_string_as_number;
use serde::{Deserialize, Serialize};
/// Represents the response from `/ITwoFactorService/QueryTime/v0001`
#[deprecated]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueryTimeResponse {
/// The time that the server will use to check your two factor code.
#[serde(deserialize_with = "parse_json_string_as_number")]
pub server_time: u64,
#[serde(deserialize_with = "parse_json_string_as_number")]
pub skew_tolerance_seconds: u64,
#[serde(deserialize_with = "parse_json_string_as_number")]
pub large_time_jink: u64,
pub probe_frequency_seconds: u64,
pub adjusted_time_probe_frequency_seconds: u64,
pub hint_probe_frequency_seconds: u64,
pub sync_timeout: u64,
pub try_again_seconds: u64,
pub max_attempts: u64,
}
#[deprecated]
#[derive(Debug, Clone, Deserialize)]
pub struct AddAuthenticatorResponse {
/// Shared secret between server and authenticator
#[serde(default)]
pub shared_secret: String,
/// Authenticator serial number (unique per token)
#[serde(default)]
pub serial_number: String,
/// code used to revoke authenticator
#[serde(default)]
pub revocation_code: String,
/// URI for QR code generation
#[serde(default)]
pub uri: String,
/// Current server time
#[serde(default, deserialize_with = "parse_json_string_as_number")]
pub server_time: u64,
/// Account name to display on token client
#[serde(default)]
pub account_name: String,
/// Token GID assigned by server
#[serde(default)]
pub token_gid: String,
/// Secret used for identity attestation (e.g., for eventing)
#[serde(default)]
pub identity_secret: String,
/// Spare shared secret
#[serde(default)]
pub secret_1: String,
/// Result code
pub status: i32,
#[serde(default)]
pub phone_number_hint: Option<String>,
}
#[deprecated]
#[derive(Debug, Clone, Deserialize)]
pub struct FinalizeAddAuthenticatorResponse {
pub status: i32,
#[serde(deserialize_with = "parse_json_string_as_number")]
pub server_time: u64,
pub want_more: bool,
pub success: bool,
}
#[deprecated]
#[derive(Debug, Clone, Deserialize)]
pub struct RemoveAuthenticatorResponse {
pub success: bool,
}
#[cfg(test)]
mod test {
use super::*;
use crate::api_responses::SteamApiResponse;
#[test]
fn test_parse_add_auth_response() {
let result = serde_json::from_str::<SteamApiResponse<AddAuthenticatorResponse>>(
include_str!("../fixtures/api-responses/add-authenticator-1.json"),
);
assert!(
matches!(result, Ok(_)),
"got error: {}",
result.unwrap_err()
);
let resp = result.unwrap().response;
assert_eq!(resp.server_time, 1628559846);
assert_eq!(resp.shared_secret, "wGwZx=sX5MmTxi6QgA3Gi");
assert_eq!(resp.revocation_code, "R123456");
}
#[test]
fn test_parse_add_auth_response2() {
let result = serde_json::from_str::<SteamApiResponse<AddAuthenticatorResponse>>(
include_str!("../fixtures/api-responses/add-authenticator-2.json"),
);
assert!(
matches!(result, Ok(_)),
"got error: {}",
result.unwrap_err()
);
let resp = result.unwrap().response;
assert_eq!(resp.status, 29);
}
}

View file

@ -1,15 +1,4 @@
use serde::{Deserialize, Deserializer, Serialize};
use super::parse_json_string_as_number;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoginTransferParameters {
pub steamid: String,
pub token_secure: String,
pub auth: String,
pub remember_login: bool,
pub webcookie: String,
}
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize)]
pub struct OAuthData {
@ -21,57 +10,6 @@ pub struct OAuthData {
pub webcookie: String,
}
#[derive(Debug, Clone, Deserialize)]
#[deprecated]
pub struct RsaResponse {
pub success: bool,
pub publickey_exp: String,
pub publickey_mod: String,
pub timestamp: String,
pub token_gid: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct LoginResponse {
pub success: bool,
#[serde(default)]
pub login_complete: bool,
#[serde(default)]
pub captcha_needed: bool,
#[serde(default)]
pub captcha_gid: String,
#[serde(default, deserialize_with = "parse_json_string_as_number")]
pub emailsteamid: u64,
#[serde(default)]
pub emailauth_needed: bool,
#[serde(default)]
pub requires_twofactor: bool,
#[serde(default)]
pub message: String,
#[serde(default, deserialize_with = "oauth_data_from_string")]
pub oauth: Option<OAuthData>,
pub transfer_urls: Option<Vec<String>>,
pub transfer_parameters: Option<LoginTransferParameters>,
}
/// For some reason, the `oauth` field in the login response is a string of JSON, not a JSON object.
/// Deserializes to `Option` because the `oauth` field is not always there.
fn oauth_data_from_string<'de, D>(deserializer: D) -> Result<Option<OAuthData>, D::Error>
where
D: Deserializer<'de>,
{
// for some reason, deserializing to &str doesn't work but this does.
let s: String = Deserialize::deserialize(deserializer)?;
let data: OAuthData = serde_json::from_str(s.as_str()).map_err(serde::de::Error::custom)?;
Ok(Some(data))
}
impl LoginResponse {
pub fn needs_transfer_login(&self) -> bool {
self.transfer_urls.is_some() || self.transfer_parameters.is_some()
}
}
#[cfg(test)]
mod test {
use super::*;
@ -90,49 +28,4 @@ mod test {
);
assert_eq!(oauth.webcookie, "6298070A226E5DAD49938D78BCF36F7A7118FDD5");
}
#[test]
fn test_login_response_parse() {
let result = serde_json::from_str::<LoginResponse>(include_str!(
"../fixtures/api-responses/login-response1.json"
));
assert!(
matches!(result, Ok(_)),
"got error: {}",
result.unwrap_err()
);
let resp = result.unwrap();
let oauth = resp.oauth.unwrap();
assert_eq!(oauth.steamid, "78562647129469312");
assert_eq!(oauth.oauth_token, "fd2fdb3d0717bad2220d98c7ec61c7bd");
assert_eq!(oauth.wgtoken, "72E7013D598A4F68C7E268F6FA3767D89D763732");
assert_eq!(
oauth.wgtoken_secure,
"21061EA13C36D7C29812CAED900A215171AD13A2"
);
assert_eq!(oauth.webcookie, "6298070A226E5DAD49938D78BCF36F7A7118FDD5");
}
#[test]
fn test_login_response_parse_missing_webcookie() {
let result = serde_json::from_str::<LoginResponse>(include_str!(
"../fixtures/api-responses/login-response-missing-webcookie.json"
));
assert!(
matches!(result, Ok(_)),
"got error: {}",
result.unwrap_err()
);
let resp = result.unwrap();
let oauth = resp.oauth.unwrap();
assert_eq!(oauth.steamid, "92591609556178617");
assert_eq!(oauth.oauth_token, "1cc83205dab2979e558534dab29f6f3aa");
assert_eq!(oauth.wgtoken, "3EDA9DEF07D7B39361D95203525D8AFE82A");
assert_eq!(oauth.wgtoken_secure, "F31641B9AFC2F8B0EE7B6F44D7E73EA3FA48");
assert_eq!(oauth.webcookie, "");
}
}

View file

@ -1,25 +1,7 @@
mod i_authentication_service;
mod i_two_factor_service;
mod login;
mod phone_ajax;
pub use i_authentication_service::*;
pub use i_two_factor_service::*;
pub use login::*;
pub use phone_ajax::*;
use serde::{Deserialize, Deserializer};
pub(crate) fn parse_json_string_as_number<'de, D>(deserializer: D) -> Result<u64, D::Error>
where
D: Deserializer<'de>,
{
// for some reason, deserializing to &str doesn't work but this does.
let s: String = Deserialize::deserialize(deserializer)?;
Ok(s.parse().unwrap())
}
#[derive(Debug, Clone, Deserialize)]
pub struct SteamApiResponse<T> {
pub response: T,
}

View file

@ -1 +0,0 @@
{"success":true,"requires_twofactor":false,"redirect_uri":"steammobile:\/\/mobileloginsucceeded","login_complete":true,"oauth":"{\"steamid\":\"92591609556178617\",\"account_name\":\"hydrastar2\",\"oauth_token\":\"1cc83205dab2979e558534dab29f6f3aa\",\"wgtoken\":\"3EDA9DEF07D7B39361D95203525D8AFE82A\",\"wgtoken_secure\":\"F31641B9AFC2F8B0EE7B6F44D7E73EA3FA48\"}"}

View file

@ -1 +0,0 @@
{"success":true,"requires_twofactor":false,"redirect_uri":"steammobile://mobileloginsucceeded","login_complete":true,"oauth":"{\"steamid\":\"78562647129469312\",\"account_name\":\"feuarus\",\"oauth_token\":\"fd2fdb3d0717bad2220d98c7ec61c7bd\",\"wgtoken\":\"72E7013D598A4F68C7E268F6FA3767D89D763732\",\"wgtoken_secure\":\"21061EA13C36D7C29812CAED900A215171AD13A2\",\"webcookie\":\"6298070A226E5DAD49938D78BCF36F7A7118FDD5\"}"}

View file

@ -1,8 +1,5 @@
use crate::api_responses::SteamApiResponse;
use crate::confirmation::{ConfirmationListResponse, SendConfirmationResponse};
use crate::protobufs::service_twofactor::{
CTwoFactor_RemoveAuthenticator_Request, CTwoFactor_RemoveAuthenticator_Response,
};
use crate::protobufs::service_twofactor::CTwoFactor_RemoveAuthenticator_Request;
use crate::steamapi::EResult;
use crate::{
steamapi::twofactor::TwoFactorClient, token::TwoFactorSecret, transport::WebApiTransport,
@ -17,11 +14,9 @@ use reqwest::{
header::{COOKIE, USER_AGENT},
Url,
};
use scraper::{Html, Selector};
pub use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
use std::{collections::HashMap, convert::TryInto, io::Read};
use steamapi::SteamApiClient;
use std::{collections::HashMap, io::Read};
use token::Tokens;
pub use userlogin::{DeviceDetails, LoginError, UserLogin};
@ -29,7 +24,6 @@ pub use userlogin::{DeviceDetails, LoginError, UserLogin};
extern crate lazy_static;
#[macro_use]
extern crate anyhow;
#[macro_use]
extern crate maplit;
pub mod accountlinker;
@ -66,22 +60,25 @@ pub struct SteamGuardAccount {
}
fn build_time_bytes(time: u64) -> [u8; 8] {
return time.to_be_bytes();
time.to_be_bytes()
}
fn generate_confirmation_hash_for_time(time: u64, tag: &str, identity_secret: &String) -> String {
let decode: &[u8] = &base64::decode(&identity_secret).unwrap();
fn generate_confirmation_hash_for_time(
time: u64,
tag: &str,
identity_secret: impl AsRef<[u8]>,
) -> String {
let decode: &[u8] = &base64::decode(identity_secret).unwrap();
let time_bytes = build_time_bytes(time);
let tag_bytes = tag.as_bytes();
let array = [&time_bytes, tag_bytes].concat();
let hash = hmac_sha1(decode, &array);
let encoded = base64::encode(hash);
return encoded;
base64::encode(hash)
}
impl SteamGuardAccount {
pub fn new() -> Self {
return SteamGuardAccount {
impl Default for SteamGuardAccount {
fn default() -> Self {
Self {
account_name: String::from(""),
steam_id: 0,
serial_number: String::from(""),
@ -93,7 +90,13 @@ impl SteamGuardAccount {
device_id: String::from(""),
secret_1: String::from("").into(),
tokens: None,
};
}
}
}
impl SteamGuardAccount {
pub fn new() -> Self {
Self::default()
}
pub fn from_reader<T>(r: T) -> anyhow::Result<Self>
@ -108,11 +111,11 @@ impl SteamGuardAccount {
}
pub fn is_logged_in(&self) -> bool {
return self.tokens.is_some();
self.tokens.is_some()
}
pub fn generate_code(&self, time: u64) -> String {
return self.shared_secret.generate_code(time);
self.shared_secret.generate_code(time)
}
fn get_confirmation_query_params(&self, tag: &str, time: u64) -> HashMap<&str, String> {
@ -121,12 +124,12 @@ impl SteamGuardAccount {
params.insert("a", self.steam_id.to_string());
params.insert(
"k",
generate_confirmation_hash_for_time(time, tag, &self.identity_secret.expose_secret()),
generate_confirmation_hash_for_time(time, tag, self.identity_secret.expose_secret()),
);
params.insert("t", time.to_string());
params.insert("m", String::from("android"));
params.insert("tag", String::from(tag));
return params;
params
}
fn build_cookie_jar(&self) -> reqwest::cookie::Jar {
@ -149,7 +152,7 @@ impl SteamGuardAccount {
.as_str(),
&url,
);
return cookies;
cookies
}
pub fn get_trade_confirmations(&self) -> Result<Vec<Confirmation>, anyhow::Error> {
@ -161,7 +164,7 @@ impl SteamGuardAccount {
.cookie_store(true)
.build()?;
let time = steamapi::get_server_time()?.server_time;
let time = steamapi::get_server_time()?.server_time();
let resp = client
.get("https://steamcommunity.com/mobileconf/getlist".parse::<Url>().unwrap())
.header("X-Requested-With", "com.valvesoftware.android.steam.community")
@ -193,7 +196,7 @@ impl SteamGuardAccount {
.cookie_store(true)
.build()?;
let time = steamapi::get_server_time()?.server_time;
let time = steamapi::get_server_time()?.server_time();
let mut query_params = self.get_confirmation_query_params("conf", time);
query_params.insert("op", operation);
query_params.insert("cid", conf.id.to_string());
@ -246,7 +249,7 @@ impl SteamGuardAccount {
.cookie_store(true)
.build()?;
let time = steamapi::get_server_time()?.server_time;
let time = steamapi::get_server_time()?.server_time();
let query_params = self.get_confirmation_query_params("details", time);
let resp: ConfirmationDetailsResponse = client.get(format!("https://steamcommunity.com/mobileconf/details/{}", conf.id).parse::<Url>().unwrap())
@ -292,11 +295,7 @@ mod tests {
#[test]
fn test_generate_confirmation_hash_for_time() {
assert_eq!(
generate_confirmation_hash_for_time(
1617591917,
"conf",
&String::from("GQP46b73Ws7gr8GmZFR0sDuau5c=")
),
generate_confirmation_hash_for_time(1617591917, "conf", "GQP46b73Ws7gr8GmZFR0sDuau5c="),
String::from("NaL8EIMhfy/7vBounJ0CvpKbrPk=")
);
}

View file

@ -1,11 +1,3 @@
use std::fmt::Formatter;
use std::marker::PhantomData;
use protobuf::EnumFull;
use protobuf::EnumOrUnknown;
use protobuf::MessageField;
use serde::{Deserialize, Serialize};
include!(concat!(env!("OUT_DIR"), "/protobufs/mod.rs"));
#[cfg(test)]

View file

@ -1,5 +1,5 @@
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde::{Deserialize, Deserializer, Serializer};
/// Helper to allow serializing a [secrecy::SecretString] as a [String]
pub(crate) fn serialize<S>(secret_string: &SecretString, serializer: S) -> Result<S::Ok, S::Error>
@ -20,16 +20,18 @@ where
#[cfg(test)]
mod test {
use serde::Serialize;
use super::*;
#[derive(Serialize, Deserialize)]
struct Foo {
#[serde(with = "super")]
secret: SecretString,
}
#[test]
fn test_secret_string_round_trip() {
#[derive(Serialize, Deserialize)]
struct Foo {
#[serde(with = "super")]
secret: SecretString,
}
let foo = Foo {
secret: String::from("hello").into(),
};
@ -41,12 +43,6 @@ mod test {
#[test]
fn test_secret_string_deserialize() {
#[derive(Serialize, Deserialize)]
struct Foo {
#[serde(with = "super")]
secret: SecretString,
}
let foo: Foo = serde_json::from_str("{\"secret\": \"hello\"}").unwrap();
assert_eq!(foo.secret.expose_secret(), "hello");
}

View file

@ -1,482 +1,31 @@
pub mod authentication;
pub mod twofactor;
use crate::{api_responses::*, token::Jwt};
use log::*;
use reqwest::{
blocking::RequestBuilder,
cookie::CookieStore,
header::COOKIE,
header::{HeaderMap, HeaderName, HeaderValue, SET_COOKIE},
Url,
use crate::{
protobufs::service_twofactor::CTwoFactor_Time_Response, token::Jwt, transport::WebApiTransport,
};
use secrecy::{CloneableSecret, DebugSecret, ExposeSecret, SerializableSecret};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::iter::FromIterator;
use std::str::FromStr;
use std::time::{SystemTime, UNIX_EPOCH};
use zeroize::Zeroize;
use reqwest::Url;
use serde::Deserialize;
pub use self::authentication::AuthenticationClient;
pub use self::twofactor::TwoFactorClient;
lazy_static! {
static ref STEAM_COOKIE_URL: Url = "https://steamcommunity.com".parse::<Url>().unwrap();
static ref STEAM_API_BASE: String = "https://api.steampowered.com".into();
}
#[derive(Debug, Clone, Serialize, Deserialize, Zeroize)]
#[zeroize(drop)]
pub struct Session {
#[serde(rename = "SessionID")]
pub session_id: String,
#[serde(rename = "SteamLogin")]
pub steam_login: String,
#[serde(rename = "SteamLoginSecure")]
pub steam_login_secure: String,
#[serde(default, rename = "WebCookie")]
pub web_cookie: Option<String>,
#[serde(rename = "OAuthToken")]
pub token: String,
#[serde(rename = "SteamID")]
pub steam_id: u64,
}
impl SerializableSecret for Session {}
impl CloneableSecret for Session {}
impl DebugSecret for Session {}
/// Queries Steam for the current time.
/// Queries Steam for the current time. A convenience function around TwoFactorClient.
///
/// Endpoint: `/ITwoFactorService/QueryTime/v0001`
///
/// Example Response:
/// ```json
/// {
/// "response": {
/// "server_time": "1655768666",
/// "skew_tolerance_seconds": "60",
/// "large_time_jink": "86400",
/// "probe_frequency_seconds": 3600,
/// "adjusted_time_probe_frequency_seconds": 300,
/// "hint_probe_frequency_seconds": 60,
/// "sync_timeout": 60,
/// "try_again_seconds": 900,
/// "max_attempts": 3
/// }
/// }
/// ```
pub fn get_server_time() -> anyhow::Result<QueryTimeResponse> {
let client = reqwest::blocking::Client::new();
let resp = client
.post("https://api.steampowered.com/ITwoFactorService/QueryTime/v0001")
.body("steamid=0")
.send()?;
let resp: SteamApiResponse<QueryTimeResponse> = resp.json()?;
return Ok(resp.response);
}
/// Provides raw access to the Steam API. Handles cookies, some deserialization, etc. to make it easier. It covers `ITwoFactorService` from the Steam web API, and some mobile app specific api endpoints.
#[derive(Debug)]
pub struct SteamApiClient {
cookies: reqwest::cookie::Jar,
client: reqwest::blocking::Client,
pub session: Option<secrecy::Secret<Session>>,
}
impl SteamApiClient {
pub fn new(session: Option<secrecy::Secret<Session>>) -> SteamApiClient {
SteamApiClient {
cookies: reqwest::cookie::Jar::default(),
client: reqwest::blocking::ClientBuilder::new()
.cookie_store(true)
.user_agent("Mozilla/5.0 (Linux; U; Android 4.1.1; en-us; Google Nexus 4 - 4.1.1 - API 16 - 768x1280 Build/JRO03S) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30")
.default_headers(HeaderMap::from_iter(hashmap! {
HeaderName::from_str("X-Requested-With").expect("could not build default request headers") => HeaderValue::from_str("com.valvesoftware.android.steam.community").expect("could not build default request headers")
}.into_iter()))
.build()
.unwrap(),
session: session,
}
pub fn get_server_time() -> anyhow::Result<CTwoFactor_Time_Response> {
let mut client = TwoFactorClient::new(WebApiTransport::new());
let resp = client.query_time()?;
if resp.result != EResult::OK {
return Err(anyhow::anyhow!("QueryTime failed: {:?}", resp));
}
fn build_session(&self, data: &OAuthData) -> Session {
trace!("SteamApiClient::build_session");
return Session {
token: data.oauth_token.clone(),
steam_id: data.steamid.parse().unwrap(),
steam_login: format!("{}%7C%7C{}", data.steamid, data.wgtoken),
steam_login_secure: format!("{}%7C%7C{}", data.steamid, data.wgtoken_secure),
session_id: self
.extract_session_id()
.expect("failed to extract session id from cookies"),
web_cookie: Some(data.webcookie.clone()),
};
}
fn extract_session_id(&self) -> Option<String> {
let cookies = self.cookies.cookies(&STEAM_COOKIE_URL).unwrap();
let all_cookies = cookies.to_str().unwrap();
for cookie in all_cookies
.split(";")
.map(|s| cookie::Cookie::parse(s).unwrap())
{
if cookie.name() == "sessionid" {
return Some(cookie.value().into());
}
}
return None;
}
pub fn save_cookies_from_response(&mut self, response: &reqwest::blocking::Response) {
let set_cookie_iter = response.headers().get_all(SET_COOKIE);
for c in set_cookie_iter {
c.to_str()
.into_iter()
.for_each(|cookie_str| self.cookies.add_cookie_str(cookie_str, &STEAM_COOKIE_URL));
}
}
pub fn request<U: reqwest::IntoUrl + std::fmt::Display>(
&self,
method: reqwest::Method,
url: U,
) -> RequestBuilder {
trace!("making request: {} {}", method, url);
self.cookies
.add_cookie_str("mobileClientVersion=0 (2.1.3)", &STEAM_COOKIE_URL);
self.cookies
.add_cookie_str("mobileClient=android", &STEAM_COOKIE_URL);
self.cookies
.add_cookie_str("Steam_Language=english", &STEAM_COOKIE_URL);
if let Some(session) = &self.session {
self.cookies.add_cookie_str(
format!("sessionid={}", session.expose_secret().session_id).as_str(),
&STEAM_COOKIE_URL,
);
}
self.client
.request(method, url)
.header(COOKIE, self.cookies.cookies(&STEAM_COOKIE_URL).unwrap())
}
pub fn get<U: reqwest::IntoUrl + std::fmt::Display>(&self, url: U) -> RequestBuilder {
self.request(reqwest::Method::GET, url)
}
pub fn post<U: reqwest::IntoUrl + std::fmt::Display>(&self, url: U) -> RequestBuilder {
self.request(reqwest::Method::POST, url)
}
/// Updates the cookie jar with the session cookies by pinging steam servers.
pub fn update_session(&mut self) -> anyhow::Result<()> {
trace!("SteamApiClient::update_session");
let resp = self
.get("https://steamcommunity.com/login?oauth_client_id=DE45CD61&oauth_scope=read_profile%20write_profile%20read_client%20write_client".parse::<Url>().unwrap())
.send()?;
self.save_cookies_from_response(&resp);
trace!("{:?}", resp);
trace!("cookies: {:?}", self.cookies);
Ok(())
}
/// Endpoint: POST /login/dologin
pub fn login(
&mut self,
username: String,
encrypted_password: String,
twofactor_code: String,
email_code: String,
captcha_gid: String,
captcha_text: String,
rsa_timestamp: String,
) -> anyhow::Result<LoginResponse> {
let params = hashmap! {
"donotcache" => format!(
"{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
* 1000
),
"username" => username,
"password" => encrypted_password,
"twofactorcode" => twofactor_code,
"emailauth" => email_code,
"captchagid" => captcha_gid,
"captcha_text" => captcha_text,
"rsatimestamp" => rsa_timestamp,
"remember_login" => "true".into(),
"oauth_client_id" => "DE45CD61".into(),
"oauth_scope" => "read_profile write_profile read_client write_client".into(),
};
let resp = self
.post("https://steamcommunity.com/login/dologin")
.form(&params)
.send()?;
self.save_cookies_from_response(&resp);
let text = resp.text()?;
trace!("raw login response: {}", text);
let login_resp: LoginResponse = serde_json::from_str(text.as_str())?;
if let Some(oauth) = &login_resp.oauth {
self.session = Some(secrecy::Secret::new(self.build_session(&oauth)));
}
return Ok(login_resp);
}
/// A secondary step in the login flow. Does not seem to always be needed?
/// Endpoints: provided by `login()`
pub fn transfer_login(&mut self, login_resp: LoginResponse) -> anyhow::Result<OAuthData> {
match (login_resp.transfer_urls, login_resp.transfer_parameters) {
(Some(urls), Some(params)) => {
debug!("received transfer parameters, relaying data...");
for url in urls {
trace!("posting transfer to {}", url);
let resp = self.client.post(url).json(&params).send()?;
self.save_cookies_from_response(&resp);
}
let oauth = OAuthData {
oauth_token: params.auth,
steamid: params.steamid.parse().unwrap(),
wgtoken: params.token_secure.clone(), // guessing
wgtoken_secure: params.token_secure,
webcookie: params.webcookie,
};
self.session = Some(secrecy::Secret::new(self.build_session(&oauth)));
return Ok(oauth);
}
(None, None) => {
bail!("did not receive transfer_urls and transfer_parameters");
}
(_, None) => {
bail!("did not receive transfer_parameters");
}
(None, _) => {
bail!("did not receive transfer_urls");
}
}
}
/// Likely removed now
///
/// One of the endpoints that handles phone number things. Can check to see if phone is present on account, and maybe do some other stuff. It's not really super clear.
///
/// Host: steamcommunity.com
/// Endpoint: POST /steamguard/phoneajax
/// Requires `sessionid` cookie to be set.
fn phoneajax(&self, op: &str, arg: &str) -> anyhow::Result<bool> {
let mut params = hashmap! {
"op" => op,
"arg" => arg,
"sessionid" => self.session.as_ref().unwrap().expose_secret().session_id.as_str(),
};
if op == "check_sms_code" {
params.insert("checkfortos", "0");
params.insert("skipvoip", "1");
}
let resp = self
.post("https://steamcommunity.com/steamguard/phoneajax")
.form(&params)
.send()?;
trace!("phoneajax: status={}", resp.status());
let result: Value = resp.json()?;
trace!("phoneajax: {:?}", result);
if result["has_phone"] != Value::Null {
trace!("op: {} - found has_phone field", op);
return result["has_phone"]
.as_bool()
.ok_or(anyhow!("failed to parse has_phone field into boolean"));
} else if result["success"] != Value::Null {
trace!("op: {} - found success field", op);
return result["success"]
.as_bool()
.ok_or(anyhow!("failed to parse success field into boolean"));
} else {
trace!("op: {} - did not find any expected field", op);
return Ok(false);
}
}
/// Works similar to phoneajax. Used in the process to add a phone number to a steam account.
/// Valid ops:
/// - get_phone_number => `input` is treated as a phone number to add to the account. Yes, this is somewhat counter intuitive.
/// - resend_sms
/// - get_sms_code => `input` is treated as a the SMS code that was texted to the phone number. Again, this is somewhat counter intuitive. After this succeeds, the phone number is added to the account.
/// - email_verification => If the account is protected with steam guard email, a verification link is sent. After the link in the email is clicked, send this op. After, an SMS code is sent to the phone number.
/// - retry_email_verification
///
/// Host: store.steampowered.com
/// Endpoint: /phone/add_ajaxop
fn phone_add_ajaxop(&self, op: &str, input: &str) -> anyhow::Result<()> {
trace!("phone_add_ajaxop: op={} input={}", op, input);
let params = hashmap! {
"op" => op,
"input" => input,
"sessionid" => self.session.as_ref().unwrap().expose_secret().session_id.as_str(),
};
let resp = self
.post("https://store.steampowered.com/phone/add_ajaxop")
.form(&params)
.send()?;
trace!("phone_add_ajaxop: http status={}", resp.status());
let text = resp.text()?;
trace!("phone_add_ajaxop response: {}", text);
todo!();
}
pub fn has_phone(&self) -> anyhow::Result<bool> {
return self.phoneajax("has_phone", "null");
}
pub fn check_sms_code(&self, sms_code: String) -> anyhow::Result<bool> {
return self.phoneajax("check_sms_code", sms_code.as_str());
}
pub fn check_email_confirmation(&self) -> anyhow::Result<bool> {
return self.phoneajax("email_confirmation", "");
}
pub fn add_phone_number(&self, phone_number: String) -> anyhow::Result<bool> {
// return self.phoneajax("add_phone_number", phone_number.as_str());
todo!();
}
/// Provides lots of juicy information, like if the number is a VOIP number.
/// Host: store.steampowered.com
/// Endpoint: POST /phone/validate
/// Body format: form data
/// Example:
/// ```form
/// sessionID=FOO&phoneNumber=%2B1+1234567890
/// ```
/// Found on page: https://store.steampowered.com/phone/add
pub fn phone_validate(&self, phone_number: &String) -> anyhow::Result<PhoneValidateResponse> {
let params = hashmap! {
"sessionID" => self.session.as_ref().unwrap().expose_secret().session_id.as_str(),
"phoneNumber" => phone_number.as_str(),
};
let resp = self
.client
.post("https://store.steampowered.com/phone/validate")
.form(&params)
.send()?
.json::<PhoneValidateResponse>()?;
return Ok(resp);
}
/// Starts the authenticator linking process.
/// This doesn't check any prereqisites to ensure the request will pass validation on Steam's side (eg. sms/email confirmations).
/// A valid `Session` is required for this request. Cookies are not needed for this request, but they are set anyway.
///
/// Host: api.steampowered.com
/// Endpoint: POST /ITwoFactorService/AddAuthenticator/v0001
pub fn add_authenticator(
&mut self,
device_id: String,
) -> anyhow::Result<AddAuthenticatorResponse> {
ensure!(matches!(self.session, Some(_)));
let params = hashmap! {
"access_token" => self.session.as_ref().unwrap().expose_secret().token.clone(),
"steamid" => self.session.as_ref().unwrap().expose_secret().steam_id.to_string(),
"authenticator_type" => "1".into(),
"device_identifier" => device_id,
"sms_phone_id" => "1".into(),
};
let resp = self
.post(format!(
"{}/ITwoFactorService/AddAuthenticator/v0001",
STEAM_API_BASE.to_string()
))
.form(&params)
.send()?;
self.save_cookies_from_response(&resp);
let text = resp.text()?;
trace!("raw add authenticator response: {}", text);
let resp: SteamApiResponse<AddAuthenticatorResponse> = serde_json::from_str(text.as_str())?;
Ok(resp.response)
}
/// Host: api.steampowered.com
/// Endpoint: POST /ITwoFactorService/FinalizeAddAuthenticator/v0001
pub fn finalize_authenticator(
&self,
sms_code: String,
code_2fa: String,
time_2fa: u64,
) -> anyhow::Result<FinalizeAddAuthenticatorResponse> {
ensure!(matches!(self.session, Some(_)));
let params = hashmap! {
"steamid" => self.session.as_ref().unwrap().expose_secret().steam_id.to_string(),
"access_token" => self.session.as_ref().unwrap().expose_secret().token.clone(),
"activation_code" => sms_code,
"authenticator_code" => code_2fa,
"authenticator_time" => time_2fa.to_string(),
};
let resp = self
.post(format!(
"{}/ITwoFactorService/FinalizeAddAuthenticator/v0001",
STEAM_API_BASE.to_string()
))
.form(&params)
.send()?;
let text = resp.text()?;
trace!("raw finalize authenticator response: {}", text);
let resp: SteamApiResponse<FinalizeAddAuthenticatorResponse> =
serde_json::from_str(text.as_str())?;
return Ok(resp.response);
}
/// Host: api.steampowered.com
/// Endpoint: POST /ITwoFactorService/RemoveAuthenticator/v0001
pub fn remove_authenticator(
&self,
revocation_code: String,
) -> anyhow::Result<RemoveAuthenticatorResponse> {
let params = hashmap! {
"steamid" => self.session.as_ref().unwrap().expose_secret().steam_id.to_string(),
"steamguard_scheme" => "2".into(),
"revocation_code" => revocation_code,
"access_token" => self.session.as_ref().unwrap().expose_secret().token.to_string(),
};
let resp = self
.post(format!(
"{}/ITwoFactorService/RemoveAuthenticator/v0001",
STEAM_API_BASE.to_string()
))
.form(&params)
.send()?;
let text = resp.text()?;
trace!("raw remove authenticator response: {}", text);
let resp: SteamApiResponse<RemoveAuthenticatorResponse> =
serde_json::from_str(text.as_str())?;
return Ok(resp.response);
}
Ok(resp.into_response_data())
}
pub trait BuildableRequest {
@ -521,11 +70,8 @@ impl<'a, T: BuildableRequest> ApiRequest<'a, T> {
pub(crate) fn build_url(&self) -> String {
format!(
"{}/I{}Service/{}/v{}",
STEAM_API_BASE.to_string(),
self.api_interface,
self.api_method,
self.api_version
"{}/{}/{}/v{}",
*STEAM_API_BASE, self.api_interface, self.api_method, self.api_version
)
}
@ -546,11 +92,6 @@ impl<T> ApiResponse<T> {
self.result
}
pub(crate) fn with_error_message(mut self, error_message: Option<String>) -> Self {
self.error_message = error_message;
self
}
pub fn error_message(&self) -> Option<&String> {
self.error_message.as_ref()
}

View file

@ -1,34 +1,14 @@
use crate::{
protobufs::{
custom::CAuthentication_BeginAuthSessionViaCredentials_Request_BinaryGuardData,
steammessages_auth_steamclient::{
CAuthenticationSupport_RevokeToken_Request,
CAuthenticationSupport_RevokeToken_Response,
CAuthentication_AccessToken_GenerateForApp_Request,
CAuthentication_AccessToken_GenerateForApp_Response,
CAuthentication_BeginAuthSessionViaCredentials_Request,
CAuthentication_BeginAuthSessionViaCredentials_Response,
CAuthentication_BeginAuthSessionViaQR_Request,
CAuthentication_BeginAuthSessionViaQR_Response,
CAuthentication_GetAuthSessionInfo_Request,
CAuthentication_GetPasswordRSAPublicKey_Request,
CAuthentication_GetPasswordRSAPublicKey_Response,
CAuthentication_MigrateMobileSession_Request,
CAuthentication_MigrateMobileSession_Response,
CAuthentication_PollAuthSessionStatus_Request,
CAuthentication_PollAuthSessionStatus_Response,
CAuthentication_RefreshToken_Revoke_Request,
CAuthentication_RefreshToken_Revoke_Response,
CAuthentication_UpdateAuthSessionWithMobileConfirmation_Request,
CAuthentication_UpdateAuthSessionWithMobileConfirmation_Response,
CAuthentication_UpdateAuthSessionWithSteamGuardCode_Request,
CAuthentication_UpdateAuthSessionWithSteamGuardCode_Response, EAuthSessionGuardType,
},
steammessages_auth_steamclient::*,
},
token::Jwt,
transport::Transport,
};
const SERVICE_NAME: &str = "IAuthenticationService";
use super::{ApiRequest, ApiResponse, BuildableRequest};
#[derive(Debug)]
@ -52,12 +32,7 @@ where
&mut self,
req: CAuthentication_BeginAuthSessionViaCredentials_Request_BinaryGuardData,
) -> anyhow::Result<ApiResponse<CAuthentication_BeginAuthSessionViaCredentials_Response>> {
let req = ApiRequest::new(
"Authentication",
"BeginAuthSessionViaCredentials",
1u32,
req,
);
let req = ApiRequest::new(SERVICE_NAME, "BeginAuthSessionViaCredentials", 1u32, req);
let resp = self.transport.send_request::<
CAuthentication_BeginAuthSessionViaCredentials_Request_BinaryGuardData,
CAuthentication_BeginAuthSessionViaCredentials_Response>(req)?;
@ -68,7 +43,7 @@ where
&mut self,
req: CAuthentication_BeginAuthSessionViaQR_Request,
) -> anyhow::Result<ApiResponse<CAuthentication_BeginAuthSessionViaQR_Response>> {
let req = ApiRequest::new("Authentication", "BeginAuthSessionViaQR", 1u32, req);
let req = ApiRequest::new(SERVICE_NAME, "BeginAuthSessionViaQR", 1u32, req);
let resp = self
.transport
.send_request::<CAuthentication_BeginAuthSessionViaQR_Request, CAuthentication_BeginAuthSessionViaQR_Response>(
@ -82,7 +57,7 @@ where
req: CAuthentication_AccessToken_GenerateForApp_Request,
access_token: &Jwt,
) -> anyhow::Result<ApiResponse<CAuthentication_AccessToken_GenerateForApp_Response>> {
let req = ApiRequest::new("Authentication", "GenerateAccessTokenForApp", 1u32, req)
let req = ApiRequest::new(SERVICE_NAME, "GenerateAccessTokenForApp", 1u32, req)
.with_access_token(access_token);
let resp = self
.transport
@ -98,21 +73,21 @@ where
) -> anyhow::Result<ApiResponse<CAuthentication_GetPasswordRSAPublicKey_Response>> {
let mut inner = CAuthentication_GetPasswordRSAPublicKey_Request::new();
inner.set_account_name(account_name);
let req = ApiRequest::new("Authentication", "GetPasswordRSAPublicKey", 1u32, inner);
let req = ApiRequest::new(SERVICE_NAME, "GetPasswordRSAPublicKey", 1u32, inner);
let resp = self
.transport
.send_request::<CAuthentication_GetPasswordRSAPublicKey_Request, CAuthentication_GetPasswordRSAPublicKey_Response>(
req,
)?;
return Ok(resp);
Ok(resp)
}
pub fn migrate_mobile_session(
&mut self,
req: CAuthentication_MigrateMobileSession_Request,
) -> anyhow::Result<ApiResponse<CAuthentication_MigrateMobileSession_Response>> {
let req = ApiRequest::new("Authentication", "MigrateMobileSession", 1u32, req);
let req = ApiRequest::new(SERVICE_NAME, "MigrateMobileSession", 1u32, req);
let resp = self
.transport
.send_request::<CAuthentication_MigrateMobileSession_Request, CAuthentication_MigrateMobileSession_Response>(
@ -125,7 +100,7 @@ where
&mut self,
req: CAuthentication_PollAuthSessionStatus_Request,
) -> anyhow::Result<ApiResponse<CAuthentication_PollAuthSessionStatus_Response>> {
let req = ApiRequest::new("Authentication", "PollAuthSessionStatus", 1u32, req);
let req = ApiRequest::new(SERVICE_NAME, "PollAuthSessionStatus", 1u32, req);
let resp = self
.transport
.send_request::<CAuthentication_PollAuthSessionStatus_Request, CAuthentication_PollAuthSessionStatus_Response>(
@ -138,7 +113,7 @@ where
&mut self,
req: CAuthentication_RefreshToken_Revoke_Request,
) -> anyhow::Result<ApiResponse<CAuthentication_RefreshToken_Revoke_Response>> {
let req = ApiRequest::new("Authentication", "RevokeRefreshToken", 1u32, req);
let req = ApiRequest::new(SERVICE_NAME, "RevokeRefreshToken", 1u32, req);
let resp = self
.transport
.send_request::<CAuthentication_RefreshToken_Revoke_Request, CAuthentication_RefreshToken_Revoke_Response>(
@ -151,7 +126,7 @@ where
&mut self,
req: CAuthenticationSupport_RevokeToken_Request,
) -> anyhow::Result<ApiResponse<CAuthenticationSupport_RevokeToken_Response>> {
let req = ApiRequest::new("Authentication", "RevokeToken", 1u32, req);
let req = ApiRequest::new(SERVICE_NAME, "RevokeToken", 1u32, req);
let resp = self
.transport
.send_request::<CAuthenticationSupport_RevokeToken_Request, CAuthenticationSupport_RevokeToken_Response>(
@ -166,7 +141,7 @@ where
) -> anyhow::Result<ApiResponse<CAuthentication_UpdateAuthSessionWithMobileConfirmation_Response>>
{
let req = ApiRequest::new(
"Authentication",
SERVICE_NAME,
"UpdateAuthSessionWithMobileConfirmation",
1u32,
req,
@ -184,7 +159,7 @@ where
req: CAuthentication_UpdateAuthSessionWithSteamGuardCode_Request,
) -> anyhow::Result<ApiResponse<CAuthentication_UpdateAuthSessionWithSteamGuardCode_Response>> {
let req = ApiRequest::new(
"Authentication",
SERVICE_NAME,
"UpdateAuthSessionWithSteamGuardCode",
1u32,
req,

View file

@ -6,6 +6,8 @@ use super::{ApiRequest, ApiResponse, BuildableRequest};
use crate::protobufs::custom::CTwoFactor_Time_Request;
use crate::protobufs::service_twofactor::*;
const SERVICE_NAME: &str = "ITwoFactorService";
#[derive(Debug)]
pub struct TwoFactorClient<T>
where
@ -28,7 +30,7 @@ where
req: CTwoFactor_AddAuthenticator_Request,
access_token: &Jwt,
) -> anyhow::Result<ApiResponse<CTwoFactor_AddAuthenticator_Response>> {
let req = ApiRequest::new("TwoFactor", "AddAuthenticator", 1, req)
let req = ApiRequest::new(SERVICE_NAME, "AddAuthenticator", 1, req)
.with_access_token(access_token);
let resp = self
.transport
@ -43,7 +45,7 @@ where
req: CTwoFactor_FinalizeAddAuthenticator_Request,
access_token: &Jwt,
) -> anyhow::Result<ApiResponse<CTwoFactor_FinalizeAddAuthenticator_Response>> {
let req = ApiRequest::new("TwoFactor", "FinalizeAddAuthenticator", 1, req)
let req = ApiRequest::new(SERVICE_NAME, "FinalizeAddAuthenticator", 1, req)
.with_access_token(access_token);
let resp = self
.transport
@ -58,7 +60,7 @@ where
req: CTwoFactor_RemoveAuthenticator_Request,
access_token: &Jwt,
) -> anyhow::Result<ApiResponse<CTwoFactor_RemoveAuthenticator_Response>> {
let req = ApiRequest::new("TwoFactor", "RemoveAuthenticator", 1, req)
let req = ApiRequest::new(SERVICE_NAME, "RemoveAuthenticator", 1, req)
.with_access_token(access_token);
let resp = self
.transport
@ -74,7 +76,7 @@ where
access_token: &Jwt,
) -> anyhow::Result<ApiResponse<CTwoFactor_Status_Response>> {
let req =
ApiRequest::new("TwoFactor", "QueryStatus", 1, req).with_access_token(access_token);
ApiRequest::new(SERVICE_NAME, "QueryStatus", 1, req).with_access_token(access_token);
let resp = self
.transport
.send_request::<CTwoFactor_Status_Request, CTwoFactor_Status_Response>(req)?;
@ -82,7 +84,7 @@ where
}
pub fn query_time(&mut self) -> anyhow::Result<ApiResponse<CTwoFactor_Time_Response>> {
let req = ApiRequest::new("TwoFactor", "QueryTime", 1, CTwoFactor_Time_Request::new());
let req = ApiRequest::new(SERVICE_NAME, "QueryTime", 1, CTwoFactor_Time_Request::new());
let resp = self
.transport
.send_request::<CTwoFactor_Time_Request, CTwoFactor_Time_Response>(req)?;

View file

@ -1,27 +1,30 @@
use regex::bytes;
use secrecy::{ExposeSecret, Secret, SecretString};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::convert::TryInto;
use zeroize::Zeroize;
#[derive(Debug, Clone)]
pub struct TwoFactorSecret(Secret<[u8; 20]>);
// pub struct TwoFactorSecret(Secret<Vec<u8>>);
impl Default for TwoFactorSecret {
fn default() -> Self {
Self::new()
}
}
impl TwoFactorSecret {
pub fn new() -> Self {
return Self([0u8; 20].into());
Self([0u8; 20].into())
}
pub fn from_bytes(bytes: Vec<u8>) -> Self {
let bytes: [u8; 20] = bytes[..].try_into().unwrap();
return Self(bytes.into());
Self(bytes.into())
}
pub fn parse_shared_secret(secret: String) -> anyhow::Result<Self> {
ensure!(secret.len() != 0, "unable to parse empty shared secret");
ensure!(!secret.is_empty(), "unable to parse empty shared secret");
let result: [u8; 20] = base64::decode(secret)?.try_into().unwrap();
return Ok(Self(result.into()));
Ok(Self(result.into()))
}
/// Generate a 5 character 2FA code to that can be used to log in to Steam.
@ -37,17 +40,17 @@ impl TwoFactorSecret {
let mut code_array: [u8; 5] = [0; 5];
let b = (hashed_data[19] & 0xF) as usize;
let mut code_point: i32 = ((hashed_data[b] & 0x7F) as i32) << 24
| ((hashed_data[b + 1] & 0xFF) as i32) << 16
| ((hashed_data[b + 2] & 0xFF) as i32) << 8
| ((hashed_data[b + 3] & 0xFF) as i32);
| (hashed_data[b + 1] as i32) << 16
| (hashed_data[b + 2] as i32) << 8
| (hashed_data[b + 3] as i32);
for i in 0..5 {
code_array[i] = steam_guard_code_translations
for item in &mut code_array {
*item = steam_guard_code_translations
[code_point as usize % steam_guard_code_translations.len()];
code_point /= steam_guard_code_translations.len() as i32;
}
return String::from_utf8(code_array.iter().map(|c| *c).collect()).unwrap();
String::from_utf8(code_array.to_vec()).unwrap()
}
}
@ -56,7 +59,7 @@ impl Serialize for TwoFactorSecret {
where
S: Serializer,
{
serializer.serialize_str(base64::encode(&self.0.expose_secret()).as_str())
serializer.serialize_str(base64::encode(self.0.expose_secret()).as_str())
}
}
@ -71,14 +74,14 @@ impl<'de> Deserialize<'de> for TwoFactorSecret {
impl PartialEq for TwoFactorSecret {
fn eq(&self, other: &Self) -> bool {
return self.0.expose_secret() == other.0.expose_secret();
self.0.expose_secret() == other.0.expose_secret()
}
}
impl Eq for TwoFactorSecret {}
fn build_time_bytes(time: u64) -> [u8; 8] {
return time.to_be_bytes();
time.to_be_bytes()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -112,7 +115,7 @@ impl Serialize for Jwt {
where
S: Serializer,
{
serializer.serialize_str(&self.0.expose_secret())
serializer.serialize_str(self.0.expose_secret())
}
}
@ -132,8 +135,8 @@ impl From<String> for Jwt {
}
}
fn decode_jwt(jwt: &String) -> anyhow::Result<SteamJwtData> {
let parts = jwt.split(".").collect::<Vec<&str>>();
fn decode_jwt(jwt: impl AsRef<str>) -> anyhow::Result<SteamJwtData> {
let parts = jwt.as_ref().split('.').collect::<Vec<&str>>();
ensure!(parts.len() == 3, "Invalid JWT");
let data = parts[1];
@ -164,13 +167,13 @@ impl SteamJwtData {
mod tests {
use super::*;
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
struct FooBar {
secret: TwoFactorSecret,
}
#[test]
fn test_serialize_2fa_secret() -> anyhow::Result<()> {
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
struct FooBar {
secret: TwoFactorSecret,
}
let secret = FooBar {
secret: TwoFactorSecret::parse_shared_secret("zvIayp3JPvtvX/QGHqsqKBk/44s=".into())?,
};
@ -178,32 +181,21 @@ mod tests {
let serialized = serde_json::to_string(&secret)?;
assert_eq!(serialized, "{\"secret\":\"zvIayp3JPvtvX/QGHqsqKBk/44s=\"}");
return Ok(());
Ok(())
}
#[test]
fn test_deserialize_2fa_secret() -> anyhow::Result<()> {
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
struct FooBar {
secret: TwoFactorSecret,
}
let secret: FooBar =
serde_json::from_str(&"{\"secret\":\"zvIayp3JPvtvX/QGHqsqKBk/44s=\"}")?;
let secret: FooBar = serde_json::from_str("{\"secret\":\"zvIayp3JPvtvX/QGHqsqKBk/44s=\"}")?;
let code = secret.secret.generate_code(1616374841u64);
assert_eq!(code, "2F9J5");
return Ok(());
Ok(())
}
#[test]
fn test_serialize_and_deserialize_2fa_secret() -> anyhow::Result<()> {
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
struct FooBar {
secret: TwoFactorSecret,
}
let secret = FooBar {
secret: TwoFactorSecret::parse_shared_secret("zvIayp3JPvtvX/QGHqsqKBk/44s=".into())?,
};
@ -212,7 +204,7 @@ mod tests {
let deserialized: FooBar = serde_json::from_str(&serialized)?;
assert_eq!(deserialized, secret);
return Ok(());
Ok(())
}
#[test]
@ -232,7 +224,7 @@ mod tests {
let code = secret.generate_code(1616374841u64);
assert_eq!(code, "2F9J5");
return Ok(());
Ok(())
}
#[test]

View file

@ -1,7 +1,6 @@
pub mod webapi;
use protobuf::MessageFull;
use serde::{Deserialize, Serialize};
pub use webapi::WebApiTransport;
use crate::steamapi::{ApiRequest, ApiResponse, BuildableRequest};

View file

@ -15,16 +15,22 @@ pub struct WebApiTransport {
client: reqwest::blocking::Client,
}
impl Default for WebApiTransport {
fn default() -> Self {
Self::new()
}
}
impl WebApiTransport {
pub fn new() -> WebApiTransport {
return WebApiTransport {
Self {
client: reqwest::blocking::Client::new(),
// client: reqwest::blocking::Client::builder()
// .danger_accept_invalid_certs(true)
// .proxy(reqwest::Proxy::all("http://localhost:8080").unwrap())
// .build()
// .unwrap(),
};
}
}
}
@ -95,7 +101,7 @@ impl Transport for WebApiTransport {
response_data: res,
};
return Ok(api_resp);
Ok(api_resp)
}
fn close(&mut self) {}
@ -108,8 +114,7 @@ fn encode_msg<T: MessageFull>(msg: &T, config: base64::Config) -> anyhow::Result
}
fn decode_msg<T: MessageFull>(bytes: &[u8]) -> anyhow::Result<T> {
// let bytes = base64::decode_config(b64, base64::STANDARD)?;
let msg = T::parse_from_bytes(bytes.as_ref())?;
let msg = T::parse_from_bytes(bytes)?;
Ok(msg)
}

View file

@ -7,37 +7,22 @@ use crate::protobufs::steammessages_auth_steamclient::{
EAuthSessionGuardType,
};
use crate::steamapi::authentication::AuthenticationClient;
use crate::steamapi::{ApiRequest, ApiResponse, EResult};
use crate::steamapi::EResult;
use crate::token::Tokens;
use crate::transport::Transport;
use crate::{
api_responses::{LoginResponse, RsaResponse},
protobufs::steammessages_auth_steamclient::{
CAuthenticationSupport_RevokeToken_Request, CAuthenticationSupport_RevokeToken_Response,
CAuthentication_AccessToken_GenerateForApp_Request,
CAuthentication_AccessToken_GenerateForApp_Response,
CAuthentication_BeginAuthSessionViaCredentials_Request,
CAuthentication_BeginAuthSessionViaCredentials_Response,
CAuthentication_BeginAuthSessionViaQR_Request,
CAuthentication_BeginAuthSessionViaQR_Response,
CAuthentication_GetPasswordRSAPublicKey_Request,
CAuthentication_GetPasswordRSAPublicKey_Response,
CAuthentication_MigrateMobileSession_Request,
CAuthentication_MigrateMobileSession_Response, CAuthentication_RefreshToken_Revoke_Request,
CAuthentication_RefreshToken_Revoke_Response,
CAuthentication_UpdateAuthSessionWithMobileConfirmation_Request,
CAuthentication_UpdateAuthSessionWithMobileConfirmation_Response,
CAuthentication_UpdateAuthSessionWithSteamGuardCode_Request,
CAuthentication_UpdateAuthSessionWithSteamGuardCode_Response, EAuthTokenPlatformType,
},
steamapi::{Session, SteamApiClient},
transport::WebApiTransport,
};
use log::*;
use rsa::{PublicKey, RsaPublicKey};
use secrecy::ExposeSecret;
use serde::{Deserialize, Serialize};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use std::time::Duration;
#[derive(Debug)]
pub enum LoginError {
@ -98,7 +83,6 @@ impl BeginQrLoginResponse {
/// Handles the user login flow.
#[derive(Debug)]
pub struct UserLogin {
platform_type: EAuthTokenPlatformType,
client: AuthenticationClient<WebApiTransport>,
device_details: DeviceDetails,
@ -106,32 +90,31 @@ pub struct UserLogin {
}
impl UserLogin {
pub fn new(platform_type: EAuthTokenPlatformType, device_details: DeviceDetails) -> Self {
return Self {
platform_type,
pub fn new(device_details: DeviceDetails) -> Self {
Self {
client: AuthenticationClient::new(WebApiTransport::new()),
device_details,
started_auth: None,
};
}
}
pub fn begin_auth_via_credentials(
&mut self,
account_name: &String,
password: &String,
account_name: &str,
password: &str,
) -> anyhow::Result<Vec<AllowedConfirmation>, LoginError> {
if self.started_auth.is_some() {
return Err(LoginError::AuthAlreadyStarted);
}
trace!("UserLogin::begin_auth_via_credentials");
let rsa = self.client.fetch_rsa_key(account_name.clone())?;
let rsa = self.client.fetch_rsa_key(account_name.to_owned())?;
let mut req = CAuthentication_BeginAuthSessionViaCredentials_Request_BinaryGuardData::new();
req.set_account_name(account_name.clone());
req.set_account_name(account_name.to_owned());
let rsa_resp = rsa.into_response_data();
req.set_encryption_timestamp(rsa_resp.timestamp());
let encrypted_password = encrypt_password(rsa_resp, &password);
let encrypted_password = encrypt_password(rsa_resp, password);
req.set_encrypted_password(encrypted_password);
req.set_persistence(ESessionPersistence::k_ESessionPersistence_Persistent);
req.device_details = self.device_details.clone().into_message_field();
@ -280,7 +263,7 @@ impl UserLogin {
fn encrypt_password(
rsa_resp: CAuthentication_GetPasswordRSAPublicKey_Response,
password: &String,
password: impl AsRef<[u8]>,
) -> String {
let rsa_exponent = rsa::BigUint::parse_bytes(rsa_resp.publickey_exp().as_bytes(), 16).unwrap();
let rsa_modulus = rsa::BigUint::parse_bytes(rsa_resp.publickey_mod().as_bytes(), 16).unwrap();
@ -290,12 +273,11 @@ fn encrypt_password(
#[cfg(not(test))]
let mut rng = rand::rngs::OsRng;
let padding = rsa::PaddingScheme::new_pkcs1v15_encrypt();
let encrypted_password = base64::encode(
base64::encode(
public_key
.encrypt(&mut rng, padding, password.as_bytes())
.encrypt(&mut rng, padding, password.as_ref())
.unwrap(),
);
return encrypted_password;
)
}
#[derive(Debug)]
@ -427,7 +409,7 @@ mod tests {
rsa_resp.set_publickey_exp(String::from("010001"));
rsa_resp.set_publickey_mod(String::from("98f9088c1250b17fe19d2b2422d54a1eef0036875301731f11bd17900e215318eb6de1546727c0b7b61b86cefccdcb2f8108c813154d9a7d55631965eece810d4ab9d8a59c486bda778651b876176070598a93c2325c275cb9c17bdbcacf8edc9c18c0c5d59bc35703505ef8a09ed4c62b9f92a3fac5740ce25e490ab0e26d872140e4103d912d1e3958f844264211277ee08d2b4dd3ac58b030b25342bd5c949ae7794e46a8eab26d5a8deca683bfd381da6c305b19868b8c7cd321ce72c693310a6ebf2ecd43642518f825894602f6c239cf193cb4346ce64beac31e20ef88f934f2f776597734bb9eae1ebdf4a453973b6df9d5e90777bffe5db83dd1757b"));
rsa_resp.set_timestamp(1);
let result = encrypt_password(rsa_resp, &String::from("kelwleofpsm3n4ofc"));
let result = encrypt_password(rsa_resp, "kelwleofpsm3n4ofc");
assert_eq!(result.len(), 344);
assert_eq!(result, "RUo/3IfbkVcJi1q1S5QlpKn1mEn3gNJoc/Z4VwxRV9DImV6veq/YISEuSrHB3885U5MYFLn1g94Y+cWRL6HGXoV+gOaVZe43m7O92RwiVz6OZQXMfAv3UC/jcqn/xkitnj+tNtmx55gCxmGbO2KbqQ0TQqAyqCOOw565B+Cwr2OOorpMZAViv9sKA/G3Q6yzscU6rhua179c8QjC1Hk3idUoSzpWfT4sHNBW/EREXZ3Dkjwu17xzpfwIUpnBVIlR8Vj3coHgUCpTsKVRA3T814v9BYPlvLYwmw5DW3ddx+2SyTY0P5uuog36TN2PqYS7ioF5eDe16gyfRR4Nzn/7wA==");
}
@ -438,7 +420,7 @@ mod tests {
rsa_resp.set_publickey_exp(String::from("010001"));
rsa_resp.set_publickey_mod(String::from("ca6a8dc290279b25c38a282b9a7b01306c5978bd7a2f60dcfd52134ac58faf121568ebd85ca6a2128413b76ec70fb3150b3181bbe2a1a8349b68da9c303960bdf4e34296b27bd4ea29b4d1a695168ddfc974bb6ba427206fdcdb088bf27261a52f343a51e19759fe4072b7a2047a6bc31361950d9e87d7977b31b71696572babe45ea6a7d132547984462fd5787607e0d9ff1c637e04d593e7538c880c3cdd252b75bcb703a7b8bb01cd8898b04980f40b76235d50fc1544c39ccbe763892322fc6d0a5acaf8be09efbc20fcfebcd3b02a1eb95d9d0c338e96674c17edbb0257cd43d04974423f1f995a28b9e159322d9db2708826804c0eccafffc94dd2a3d5"));
rsa_resp.set_timestamp(104444850000);
let result = encrypt_password(rsa_resp, &String::from("foo"));
let result = encrypt_password(rsa_resp, "foo");
assert_eq!(result, "jmlMXmhbweWn+wJnnf96W3Lsh0dRmzrBfMxREUuEW11rRYcfXWupBIT3eK1fmQHMZmyJeMhZiRpgIaZ7DafojQT6djJr+RKeREJs0ys9hKwxD5FGlqsTLXXEeuyopyd2smHBbmmF47voe59KEoiZZapP+eYnpJy3O2k7e1P9BH9LsKIN/nWF1ogM2jjJ328AejUpM64tPl/kInFJ1CHrLiAAKDPk42fLAAKs97xIi0JkosG6yp+8HhFqQxxZ8/bNI1IVkQC1Hdc2AN0QlNKxbDXquAn6ARgw/4b5DwUpnOb9de+Q6iX3v1/M07Se7JV8/4tuz8Thy2Chbxsf9E1TuQ==");
}
}