From be8dada80465b582c4215d8795e08b02838b1d61 Mon Sep 17 00:00:00 2001 From: ZHANG Cheng Date: Wed, 2 Sep 2026 11:30:09 +0800 Subject: [PATCH] fix: 2FA secret QR code for Bitwarden/KeePassXC needs to be Base32 encoded instead Base64 --- src/commands/qr.rs | 133 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 118 insertions(+), 15 deletions(-) diff --git a/src/commands/qr.rs b/src/commands/qr.rs index 4a22158..6ebb3de 100644 --- a/src/commands/qr.rs +++ b/src/commands/qr.rs @@ -1,6 +1,5 @@ use std::sync::{Arc, Mutex}; -use base64::Engine; use log::*; use qrcode::QrCode; use secrecy::ExposeSecret; @@ -34,6 +33,24 @@ pub struct QrCommand { pub format: QrFormat, } +impl QrCommand { + pub(crate) fn qr_content(&self, account: &SteamGuardAccount) -> String { + let secret_b32 = base32_encode_unpadded(account.shared_secret.expose_secret()); + + match self.format { + QrFormat::Steam => account.uri.expose_secret().to_owned(), + QrFormat::Bitwarden => format!("steam://{}", secret_b32), + QrFormat::KeePassXc => { + let username = percent_encode_username(&account.account_name); + format!( + "otpauth://totp/Steam:{}?secret={}&period=30&digits=5&issuer=Steam&encoder=steam", + username, secret_b32 + ) + } + } + } +} + impl AccountCommand for QrCommand where T: Transport, @@ -51,20 +68,7 @@ where for account in accounts { let account = account.lock().unwrap(); - let secret_b64 = base64::engine::general_purpose::STANDARD - .encode(account.shared_secret.expose_secret()); - - let qr_content: String = match self.format { - QrFormat::Steam => account.uri.expose_secret().to_owned(), - QrFormat::Bitwarden => format!("steam://{}", secret_b64), - QrFormat::KeePassXc => { - let username = percent_encode_username(&account.account_name); - format!( - "otpauth://totp/Steam:{}?secret={}&period=30&digits=5&issuer=Steam&encoder=steam", - username, secret_b64 - ) - } - }; + let qr_content = self.qr_content(&account); let qr = QrCode::new(qr_content.as_bytes()) .context(format!("generating qr code for {}", account.account_name))?; @@ -90,6 +94,31 @@ where } } +/// Encode raw bytes to an unpadded Base32 string (RFC 3548 / RFC 4648). +fn base32_encode_unpadded(data: &[u8]) -> String { + const ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + let mut out = String::new(); + let mut buffer: u32 = 0; + let mut bits: u32 = 0; + + for &byte in data { + buffer = (buffer << 8) | (byte as u32); + bits += 8; + while bits >= 5 { + bits -= 5; + let index = ((buffer >> bits) & 0x1F) as usize; + out.push(ALPHABET[index] as char); + } + } + + if bits > 0 { + let index = ((buffer << (5 - bits)) & 0x1F) as usize; + out.push(ALPHABET[index] as char); + } + + out +} + /// Percent-encode characters that are unsafe in a URI path component. /// /// Only encodes characters that would structurally break a URI (delimiters, @@ -139,4 +168,78 @@ mod tests { fn percent_encode_username_encodes_non_ascii() { assert_eq!(percent_encode_username("usér"), "us%C3%A9r"); } + + #[test] + fn test_base32_encode_unpadded() { + assert_eq!(base32_encode_unpadded(b"f"), "MY"); + assert_eq!(base32_encode_unpadded(b"fo"), "MZXQ"); + assert_eq!(base32_encode_unpadded(b"foo"), "MZXW6"); + assert_eq!(base32_encode_unpadded(b"foob"), "MZXW6YQ"); + assert_eq!(base32_encode_unpadded(b"fooba"), "MZXW6YTB"); + assert_eq!(base32_encode_unpadded(b"foobar"), "MZXW6YTBOI"); + } + + #[test] + fn test_qr_format_secret_base32_check() { + let account = SteamGuardAccount { + account_name: "test_user".to_string(), + shared_secret: steamguard::token::TwoFactorSecret::parse_shared_secret( + "zvIayp3JPvtvX/QGHqsqKBk/44s=".to_string(), + ) + .unwrap(), + uri: secrecy::SecretString::from( + "otpauth://totp/Steam:test_user?secret=ASDF&issuer=Steam".to_string(), + ), + ..Default::default() + }; + + // Bitwarden format check + let bw_cmd = QrCommand { + ascii: false, + format: QrFormat::Bitwarden, + }; + let bw_content = bw_cmd.qr_content(&account); + let bw_secret = bw_content + .strip_prefix("steam://") + .expect("should start with steam://"); + let bw_is_unpadded_base32 = bw_secret + .chars() + .all(|c| matches!(c, 'A'..='Z' | '2'..='7')) + && !bw_secret.contains('='); + assert!( + bw_is_unpadded_base32, + "Bitwarden QR format secret should be unpadded base32 encoded, but got: {}", + bw_secret + ); + + // KeePassXC format check + let keepass_cmd = QrCommand { + ascii: false, + format: QrFormat::KeePassXc, + }; + let keepass_content = keepass_cmd.qr_content(&account); + let keepass_secret = keepass_content + .split('?') + .nth(1) + .and_then(|query| { + query.split('&').find_map(|pair| { + let mut parts = pair.split('='); + if parts.next()? == "secret" { + parts.next().map(ToString::to_string) + } else { + None + } + }) + }) + .expect("secret parameter should be present"); + let keepass_is_unpadded_base32 = keepass_secret + .chars() + .all(|c| matches!(c, 'A'..='Z' | '2'..='7')) + && !keepass_secret.contains('='); + assert!( + keepass_is_unpadded_base32, + "KeePassXC QR format secret should be unpadded base32 encoded, but got: {}", + keepass_secret + ); + } }