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",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hmac-sha256"
|
||||
version = "1.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3688e69b38018fec1557254f64c8dc2cc8ec502890182f395dbb0aa997aa5735"
|
||||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.25.2"
|
||||
|
@ -2050,6 +2056,7 @@ dependencies = [
|
|||
"base64",
|
||||
"cookie 0.14.4",
|
||||
"hmac-sha1",
|
||||
"hmac-sha256",
|
||||
"lazy_static 1.4.0",
|
||||
"log",
|
||||
"maplit",
|
||||
|
|
|
@ -15,6 +15,7 @@ pub mod encrypt;
|
|||
pub mod import;
|
||||
#[cfg(feature = "qr")]
|
||||
pub mod qr;
|
||||
pub mod qr_login;
|
||||
pub mod remove;
|
||||
pub mod setup;
|
||||
pub mod trade;
|
||||
|
@ -27,6 +28,7 @@ pub use encrypt::EncryptCommand;
|
|||
pub use import::ImportCommand;
|
||||
#[cfg(feature = "qr")]
|
||||
pub use qr::QrCommand;
|
||||
pub use qr_login::QrLoginCommand;
|
||||
pub use remove::RemoveCommand;
|
||||
pub use setup::SetupCommand;
|
||||
pub use trade::TradeCommand;
|
||||
|
@ -117,6 +119,7 @@ pub(crate) enum Subcommands {
|
|||
Code(CodeCommand),
|
||||
#[cfg(feature = "qr")]
|
||||
Qr(QrCommand),
|
||||
QrLogin(QrLoginCommand),
|
||||
}
|
||||
|
||||
#[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)),
|
||||
#[cfg(feature = "qr")]
|
||||
Subcommands::Qr(args) => CommandType::Account(Box::new(args)),
|
||||
Subcommands::QrLogin(args) => CommandType::Account(Box::new(args)),
|
||||
};
|
||||
|
||||
if let CommandType::Const(cmd) = cmd {
|
||||
|
|
|
@ -32,6 +32,7 @@ secrecy = { version = "0.8", features = ["serde"] }
|
|||
zeroize = "^1.4.3"
|
||||
protobuf = "3.2.0"
|
||||
protobuf-json-mapping = "3.2.0"
|
||||
hmac-sha256 = "1.1.7"
|
||||
|
||||
[build-dependencies]
|
||||
anyhow = "^1.0"
|
||||
|
|
|
@ -9,6 +9,7 @@ use anyhow::Result;
|
|||
pub use confirmation::{Confirmation, ConfirmationType};
|
||||
use hmacsha1::hmac_sha1;
|
||||
use log::*;
|
||||
pub use qrapprover::{QrApprover, QrApproverError};
|
||||
use reqwest::{
|
||||
cookie::CookieStore,
|
||||
header::{COOKIE, USER_AGENT},
|
||||
|
@ -30,6 +31,7 @@ pub mod accountlinker;
|
|||
mod api_responses;
|
||||
mod confirmation;
|
||||
pub mod protobufs;
|
||||
mod qrapprover;
|
||||
pub mod refresher;
|
||||
mod secret_string;
|
||||
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::*,
|
||||
},
|
||||
token::Jwt,
|
||||
transport::Transport,
|
||||
transport::{Transport, TransportError},
|
||||
};
|
||||
|
||||
const SERVICE_NAME: &str = "IAuthenticationService";
|
||||
|
@ -138,14 +138,18 @@ where
|
|||
pub fn update_session_with_mobile_confirmation(
|
||||
&mut self,
|
||||
req: CAuthentication_UpdateAuthSessionWithMobileConfirmation_Request,
|
||||
) -> anyhow::Result<ApiResponse<CAuthentication_UpdateAuthSessionWithMobileConfirmation_Response>>
|
||||
{
|
||||
access_token: &Jwt,
|
||||
) -> Result<
|
||||
ApiResponse<CAuthentication_UpdateAuthSessionWithMobileConfirmation_Response>,
|
||||
TransportError,
|
||||
> {
|
||||
let req = ApiRequest::new(
|
||||
SERVICE_NAME,
|
||||
"UpdateAuthSessionWithMobileConfirmation",
|
||||
1u32,
|
||||
req,
|
||||
);
|
||||
)
|
||||
.with_access_token(access_token);
|
||||
let resp = self
|
||||
.transport
|
||||
.send_request::<CAuthentication_UpdateAuthSessionWithMobileConfirmation_Request, CAuthentication_UpdateAuthSessionWithMobileConfirmation_Response>(
|
||||
|
|
|
@ -52,6 +52,10 @@ impl TwoFactorSecret {
|
|||
|
||||
String::from_utf8(code_array.to_vec()).unwrap()
|
||||
}
|
||||
|
||||
pub(crate) fn expose_secret(&self) -> &[u8; 20] {
|
||||
self.0.expose_secret()
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for TwoFactorSecret {
|
||||
|
|
Loading…
Reference in a new issue