Add qr-login command to be able to approve qr code logins on other devices (#214)
- add QrApprover - implement qr-login subcommand closes #197
This commit is contained in:
parent
bff16bd341
commit
a5be7b26bb
9 changed files with 273 additions and 4 deletions
7
Cargo.lock
generated
7
Cargo.lock
generated
|
@ -686,6 +686,12 @@ dependencies = [
|
||||||
"sha1 0.2.0",
|
"sha1 0.2.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hmac-sha256"
|
||||||
|
version = "1.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3688e69b38018fec1557254f64c8dc2cc8ec502890182f395dbb0aa997aa5735"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "html5ever"
|
name = "html5ever"
|
||||||
version = "0.25.2"
|
version = "0.25.2"
|
||||||
|
@ -2050,6 +2056,7 @@ dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"cookie 0.14.4",
|
"cookie 0.14.4",
|
||||||
"hmac-sha1",
|
"hmac-sha1",
|
||||||
|
"hmac-sha256",
|
||||||
"lazy_static 1.4.0",
|
"lazy_static 1.4.0",
|
||||||
"log",
|
"log",
|
||||||
"maplit",
|
"maplit",
|
||||||
|
|
|
@ -15,6 +15,7 @@ pub mod encrypt;
|
||||||
pub mod import;
|
pub mod import;
|
||||||
#[cfg(feature = "qr")]
|
#[cfg(feature = "qr")]
|
||||||
pub mod qr;
|
pub mod qr;
|
||||||
|
pub mod qr_login;
|
||||||
pub mod remove;
|
pub mod remove;
|
||||||
pub mod setup;
|
pub mod setup;
|
||||||
pub mod trade;
|
pub mod trade;
|
||||||
|
@ -27,6 +28,7 @@ pub use encrypt::EncryptCommand;
|
||||||
pub use import::ImportCommand;
|
pub use import::ImportCommand;
|
||||||
#[cfg(feature = "qr")]
|
#[cfg(feature = "qr")]
|
||||||
pub use qr::QrCommand;
|
pub use qr::QrCommand;
|
||||||
|
pub use qr_login::QrLoginCommand;
|
||||||
pub use remove::RemoveCommand;
|
pub use remove::RemoveCommand;
|
||||||
pub use setup::SetupCommand;
|
pub use setup::SetupCommand;
|
||||||
pub use trade::TradeCommand;
|
pub use trade::TradeCommand;
|
||||||
|
@ -117,6 +119,7 @@ pub(crate) enum Subcommands {
|
||||||
Code(CodeCommand),
|
Code(CodeCommand),
|
||||||
#[cfg(feature = "qr")]
|
#[cfg(feature = "qr")]
|
||||||
Qr(QrCommand),
|
Qr(QrCommand),
|
||||||
|
QrLogin(QrLoginCommand),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, ArgEnum)]
|
#[derive(Debug, Clone, Copy, ArgEnum)]
|
||||||
|
|
64
src/commands/qr_login.rs
Normal file
64
src/commands/qr_login.rs
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use log::*;
|
||||||
|
use steamguard::{QrApprover, QrApproverError};
|
||||||
|
|
||||||
|
use crate::AccountManager;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Parser)]
|
||||||
|
#[clap(about = "Log in to Steam on another device using the QR code that it's displaying.")]
|
||||||
|
pub struct QrLoginCommand {
|
||||||
|
#[clap(
|
||||||
|
long,
|
||||||
|
help = "The URL that would normally open in the Steam app. This is the URL that the QR code is displaying. It should start with \"https://s.team/...\""
|
||||||
|
)]
|
||||||
|
pub url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AccountCommand for QrLoginCommand {
|
||||||
|
fn execute(
|
||||||
|
&self,
|
||||||
|
_manager: &mut AccountManager,
|
||||||
|
accounts: Vec<Arc<Mutex<SteamGuardAccount>>>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
ensure!(
|
||||||
|
accounts.len() == 1,
|
||||||
|
"You can only log in to one account at a time."
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut account = accounts[0].lock().unwrap();
|
||||||
|
|
||||||
|
info!("Approving login to {}", account.account_name);
|
||||||
|
|
||||||
|
if account.tokens.is_none() {
|
||||||
|
crate::do_login(&mut account)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let Some(tokens) = account.tokens.as_ref() else {
|
||||||
|
error!("No tokens found for {}. Can't approve login if we aren't logged in ourselves.", account.account_name);
|
||||||
|
return Err(anyhow!("No tokens found for {}", account.account_name));
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut approver = QrApprover::new(tokens);
|
||||||
|
match approver.approve(&account, &self.url) {
|
||||||
|
Ok(_) => {
|
||||||
|
info!("Login approved.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(QrApproverError::Unauthorized) => {
|
||||||
|
warn!("tokens are invalid. Attempting to log in again.");
|
||||||
|
crate::do_login(&mut account)?;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to approve login: {}", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
|
@ -69,6 +69,7 @@ fn run() -> anyhow::Result<()> {
|
||||||
Subcommands::Code(args) => CommandType::Account(Box::new(args)),
|
Subcommands::Code(args) => CommandType::Account(Box::new(args)),
|
||||||
#[cfg(feature = "qr")]
|
#[cfg(feature = "qr")]
|
||||||
Subcommands::Qr(args) => CommandType::Account(Box::new(args)),
|
Subcommands::Qr(args) => CommandType::Account(Box::new(args)),
|
||||||
|
Subcommands::QrLogin(args) => CommandType::Account(Box::new(args)),
|
||||||
};
|
};
|
||||||
|
|
||||||
if let CommandType::Const(cmd) = cmd {
|
if let CommandType::Const(cmd) = cmd {
|
||||||
|
|
|
@ -32,6 +32,7 @@ secrecy = { version = "0.8", features = ["serde"] }
|
||||||
zeroize = "^1.4.3"
|
zeroize = "^1.4.3"
|
||||||
protobuf = "3.2.0"
|
protobuf = "3.2.0"
|
||||||
protobuf-json-mapping = "3.2.0"
|
protobuf-json-mapping = "3.2.0"
|
||||||
|
hmac-sha256 = "1.1.7"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
anyhow = "^1.0"
|
anyhow = "^1.0"
|
||||||
|
|
|
@ -9,6 +9,7 @@ use anyhow::Result;
|
||||||
pub use confirmation::{Confirmation, ConfirmationType};
|
pub use confirmation::{Confirmation, ConfirmationType};
|
||||||
use hmacsha1::hmac_sha1;
|
use hmacsha1::hmac_sha1;
|
||||||
use log::*;
|
use log::*;
|
||||||
|
pub use qrapprover::{QrApprover, QrApproverError};
|
||||||
use reqwest::{
|
use reqwest::{
|
||||||
cookie::CookieStore,
|
cookie::CookieStore,
|
||||||
header::{COOKIE, USER_AGENT},
|
header::{COOKIE, USER_AGENT},
|
||||||
|
@ -30,6 +31,7 @@ pub mod accountlinker;
|
||||||
mod api_responses;
|
mod api_responses;
|
||||||
mod confirmation;
|
mod confirmation;
|
||||||
pub mod protobufs;
|
pub mod protobufs;
|
||||||
|
mod qrapprover;
|
||||||
pub mod refresher;
|
pub mod refresher;
|
||||||
mod secret_string;
|
mod secret_string;
|
||||||
pub mod steamapi;
|
pub mod steamapi;
|
||||||
|
|
183
steamguard/src/qrapprover.rs
Normal file
183
steamguard/src/qrapprover.rs
Normal file
|
@ -0,0 +1,183 @@
|
||||||
|
use log::debug;
|
||||||
|
use reqwest::IntoUrl;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
protobufs::steammessages_auth_steamclient::CAuthentication_UpdateAuthSessionWithMobileConfirmation_Request,
|
||||||
|
steamapi::{AuthenticationClient, EResult},
|
||||||
|
token::{Tokens, TwoFactorSecret},
|
||||||
|
transport::WebApiTransport,
|
||||||
|
SteamGuardAccount,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// QR code login approver
|
||||||
|
///
|
||||||
|
/// This can be used to approve a login request from another device that is displaying a QR code.
|
||||||
|
pub struct QrApprover<'a> {
|
||||||
|
tokens: &'a Tokens,
|
||||||
|
client: AuthenticationClient<WebApiTransport>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> QrApprover<'a> {
|
||||||
|
pub fn new(tokens: &'a Tokens) -> Self {
|
||||||
|
let client = AuthenticationClient::new(WebApiTransport::new());
|
||||||
|
Self { tokens, client }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Approve a login request from a challenge URL
|
||||||
|
pub fn approve(
|
||||||
|
&mut self,
|
||||||
|
account: &SteamGuardAccount,
|
||||||
|
challenge_url: impl IntoUrl,
|
||||||
|
) -> Result<(), QrApproverError> {
|
||||||
|
debug!("building signature");
|
||||||
|
let challenge = parse_challenge_url(challenge_url)?;
|
||||||
|
let signature = build_signature(&account.shared_secret, account.steam_id, &challenge);
|
||||||
|
|
||||||
|
debug!("approving login");
|
||||||
|
let mut req = CAuthentication_UpdateAuthSessionWithMobileConfirmation_Request::new();
|
||||||
|
req.set_steamid(account.steam_id);
|
||||||
|
req.set_version(challenge.version.into());
|
||||||
|
req.set_client_id(challenge.client_id);
|
||||||
|
req.set_signature(signature.to_vec());
|
||||||
|
req.set_confirm(true);
|
||||||
|
req.set_persistence(
|
||||||
|
crate::protobufs::enums::ESessionPersistence::k_ESessionPersistence_Persistent,
|
||||||
|
);
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.update_session_with_mobile_confirmation(req, self.tokens.access_token())?;
|
||||||
|
|
||||||
|
if resp.result != EResult::OK {
|
||||||
|
return Err(resp.result.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_signature(
|
||||||
|
shared_secret: &TwoFactorSecret,
|
||||||
|
steam_id: u64,
|
||||||
|
challenge: &Challenge,
|
||||||
|
) -> [u8; 32] {
|
||||||
|
let mut data = Vec::<u8>::with_capacity(18);
|
||||||
|
data.extend_from_slice(&challenge.version.to_le_bytes());
|
||||||
|
data.extend_from_slice(&challenge.client_id.to_le_bytes());
|
||||||
|
data.extend_from_slice(&steam_id.to_le_bytes());
|
||||||
|
|
||||||
|
hmac_sha256::HMAC::mac(data, shared_secret.expose_secret())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_challenge_url(challenge_url: impl IntoUrl) -> Result<Challenge, QrApproverError> {
|
||||||
|
let url = challenge_url
|
||||||
|
.into_url()
|
||||||
|
.map_err(|_| QrApproverError::InvalidChallengeUrl)?;
|
||||||
|
|
||||||
|
let regex = regex::Regex::new(r"^https?://s.team/q/(\d+)/(\d+)(\?|$)").unwrap();
|
||||||
|
|
||||||
|
let captures = regex
|
||||||
|
.captures(url.as_str())
|
||||||
|
.ok_or(QrApproverError::InvalidChallengeUrl)?;
|
||||||
|
|
||||||
|
let version = captures[1].parse().expect("regex should only match digits");
|
||||||
|
let client_id = captures[2].parse().expect("regex should only match digits");
|
||||||
|
|
||||||
|
Ok(Challenge { version, client_id })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct Challenge {
|
||||||
|
version: u16,
|
||||||
|
client_id: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum QrApproverError {
|
||||||
|
#[error("Invalid challenge URL")]
|
||||||
|
InvalidChallengeUrl,
|
||||||
|
#[error("Steam says that this qr login challege has already been used. Try again with a new QR code.")]
|
||||||
|
DuplicateRequest,
|
||||||
|
#[error("Steam says that this qr login challege has expired. Try again with a new QR code.")]
|
||||||
|
Expired,
|
||||||
|
#[error("Unauthorized")]
|
||||||
|
Unauthorized,
|
||||||
|
#[error("Transport error: {0}")]
|
||||||
|
TransportError(crate::transport::TransportError),
|
||||||
|
#[error("Unknown EResult: {0:?}")]
|
||||||
|
UnknownEResult(EResult),
|
||||||
|
#[error("Unknown error: {0}")]
|
||||||
|
Unknown(anyhow::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<EResult> for QrApproverError {
|
||||||
|
fn from(result: EResult) -> Self {
|
||||||
|
match result {
|
||||||
|
EResult::DuplicateRequest => Self::DuplicateRequest,
|
||||||
|
_ => Self::UnknownEResult(result),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<anyhow::Error> for QrApproverError {
|
||||||
|
fn from(err: anyhow::Error) -> Self {
|
||||||
|
Self::Unknown(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<crate::transport::TransportError> for QrApproverError {
|
||||||
|
fn from(err: crate::transport::TransportError) -> Self {
|
||||||
|
match err {
|
||||||
|
crate::transport::TransportError::Unauthorized => Self::Unauthorized,
|
||||||
|
_ => Self::TransportError(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_challenge_url() {
|
||||||
|
let url = "https://s.team/q/1/2372462679780599330";
|
||||||
|
let challenge = parse_challenge_url(url).unwrap();
|
||||||
|
assert_eq!(challenge.version, 1);
|
||||||
|
assert_eq!(challenge.client_id, 2372462679780599330);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_challenge_url_fail() {
|
||||||
|
let urls = [
|
||||||
|
"https://s.team/q/1/asdf",
|
||||||
|
"https://s.team/q/1/123asdf",
|
||||||
|
"https://s.team/q/a/123",
|
||||||
|
"https://s.team/q/123a/123",
|
||||||
|
];
|
||||||
|
for url in urls {
|
||||||
|
let challenge = parse_challenge_url(url);
|
||||||
|
assert!(challenge.is_err(), "url: {}", url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_signature() {
|
||||||
|
let challenge = Challenge {
|
||||||
|
version: 1,
|
||||||
|
client_id: 2372462679780599330,
|
||||||
|
};
|
||||||
|
let secret =
|
||||||
|
TwoFactorSecret::parse_shared_secret("zvIayp3JPvtvX/QGHqsqKBk/44s=".to_owned())
|
||||||
|
.unwrap();
|
||||||
|
let steam_id = 76561197960265728;
|
||||||
|
let signature = build_signature(&secret, steam_id, &challenge);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
signature,
|
||||||
|
[
|
||||||
|
56, 233, 253, 249, 254, 89, 110, 161, 18, 35, 35, 144, 14, 217, 210, 150, 170, 110,
|
||||||
|
61, 166, 176, 161, 140, 211, 108, 78, 138, 202, 61, 52, 85, 46
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,7 +4,7 @@ use crate::{
|
||||||
steammessages_auth_steamclient::*,
|
steammessages_auth_steamclient::*,
|
||||||
},
|
},
|
||||||
token::Jwt,
|
token::Jwt,
|
||||||
transport::Transport,
|
transport::{Transport, TransportError},
|
||||||
};
|
};
|
||||||
|
|
||||||
const SERVICE_NAME: &str = "IAuthenticationService";
|
const SERVICE_NAME: &str = "IAuthenticationService";
|
||||||
|
@ -138,14 +138,18 @@ where
|
||||||
pub fn update_session_with_mobile_confirmation(
|
pub fn update_session_with_mobile_confirmation(
|
||||||
&mut self,
|
&mut self,
|
||||||
req: CAuthentication_UpdateAuthSessionWithMobileConfirmation_Request,
|
req: CAuthentication_UpdateAuthSessionWithMobileConfirmation_Request,
|
||||||
) -> anyhow::Result<ApiResponse<CAuthentication_UpdateAuthSessionWithMobileConfirmation_Response>>
|
access_token: &Jwt,
|
||||||
{
|
) -> Result<
|
||||||
|
ApiResponse<CAuthentication_UpdateAuthSessionWithMobileConfirmation_Response>,
|
||||||
|
TransportError,
|
||||||
|
> {
|
||||||
let req = ApiRequest::new(
|
let req = ApiRequest::new(
|
||||||
SERVICE_NAME,
|
SERVICE_NAME,
|
||||||
"UpdateAuthSessionWithMobileConfirmation",
|
"UpdateAuthSessionWithMobileConfirmation",
|
||||||
1u32,
|
1u32,
|
||||||
req,
|
req,
|
||||||
);
|
)
|
||||||
|
.with_access_token(access_token);
|
||||||
let resp = self
|
let resp = self
|
||||||
.transport
|
.transport
|
||||||
.send_request::<CAuthentication_UpdateAuthSessionWithMobileConfirmation_Request, CAuthentication_UpdateAuthSessionWithMobileConfirmation_Response>(
|
.send_request::<CAuthentication_UpdateAuthSessionWithMobileConfirmation_Request, CAuthentication_UpdateAuthSessionWithMobileConfirmation_Response>(
|
||||||
|
|
|
@ -52,6 +52,10 @@ impl TwoFactorSecret {
|
||||||
|
|
||||||
String::from_utf8(code_array.to_vec()).unwrap()
|
String::from_utf8(code_array.to_vec()).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn expose_secret(&self) -> &[u8; 20] {
|
||||||
|
self.0.expose_secret()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Serialize for TwoFactorSecret {
|
impl Serialize for TwoFactorSecret {
|
||||||
|
|
Loading…
Reference in a new issue