Skip to content

Commit 534e8ef

Browse files
authored
Add flags for QR codes compatible with Bitwarden and KeePassXC (#502)
i feared my preferred password manager Bitwarden was also part of the "some applications that do not generate correct 2fa codes from the secret" as described in the readme. Luckily I found #468 and saw you said you wouldn't oppose a PR to implement flags for the qr command to more cleanly enable the export of secrets for those 2 password managers so I decided to make one! Let me know if I need to change anything, I will try to update accordingly. Closes #468
1 parent 3f4bb8e commit 534e8ef

2 files changed

Lines changed: 89 additions & 1 deletion

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,12 @@ There are some applications that do not generate correct 2fa codes from the secr
9090
- Google Authenticator
9191
- Authy
9292

93+
Other applications require a different format. If you use either Bitwarden or KeePassXC, you can generate a compatible QR code using their respective flags:
94+
```bash
95+
steamguard qr --format bitwarden # Bitwarden compatible format
96+
steamguard qr --format keepassxc # KeePassXC compatible format
97+
```
98+
9399
# Contributing
94100

95101
By contributing code to this project, you give me and any future maintainers a non-exclusive transferable license to use that code for this project, including permission to modify, redistribute, and relicense it.

src/commands/qr.rs

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use std::sync::{Arc, Mutex};
22

3+
use base64::Engine;
34
use log::*;
45
use qrcode::QrCode;
56
use secrecy::ExposeSecret;
@@ -8,6 +9,17 @@ use crate::AccountManager;
89

910
use super::*;
1011

12+
#[derive(Debug, Clone, clap::ValueEnum)]
13+
#[clap(rename_all = "lowercase")]
14+
pub(crate) enum QrFormat {
15+
/// The default Steam otpauth URI
16+
Steam,
17+
/// Bitwarden-compatible format: steam://<secret>
18+
Bitwarden,
19+
/// KeePassXC-compatible otpauth URI with period, digits, and encoder parameters
20+
KeePassXc,
21+
}
22+
1123
#[derive(Debug, Clone, Parser)]
1224
#[clap(about = "Generate QR codes. This *will* print sensitive data to stdout.")]
1325
pub struct QrCommand {
@@ -16,6 +28,10 @@ pub struct QrCommand {
1628
help = "Force using ASCII chars to generate QR codes. Useful for terminals that don't support unicode."
1729
)]
1830
pub ascii: bool,
31+
32+
/// Output format for the QR code content.
33+
#[clap(long, value_enum, default_value = "steam")]
34+
pub format: QrFormat,
1935
}
2036

2137
impl<T> AccountCommand<T> for QrCommand
@@ -35,7 +51,22 @@ where
3551

3652
for account in accounts {
3753
let account = account.lock().unwrap();
38-
let qr = QrCode::new(account.uri.expose_secret())
54+
let secret_b64 = base64::engine::general_purpose::STANDARD
55+
.encode(account.shared_secret.expose_secret());
56+
57+
let qr_content: String = match self.format {
58+
QrFormat::Steam => account.uri.expose_secret().to_owned(),
59+
QrFormat::Bitwarden => format!("steam://{}", secret_b64),
60+
QrFormat::KeePassXc => {
61+
let username = percent_encode_username(&account.account_name);
62+
format!(
63+
"otpauth://totp/Steam:{}?secret={}&period=30&digits=5&issuer=Steam&encoder=steam",
64+
username, secret_b64
65+
)
66+
}
67+
};
68+
69+
let qr = QrCode::new(qr_content.as_bytes())
3970
.context(format!("generating qr code for {}", account.account_name))?;
4071

4172
info!("Printing QR code for {}", account.account_name);
@@ -58,3 +89,54 @@ where
5889
Ok(())
5990
}
6091
}
92+
93+
/// Percent-encode characters that are unsafe in a URI path component.
94+
///
95+
/// Only encodes characters that would structurally break a URI (delimiters,
96+
/// the percent escape character itself, and non-ASCII bytes). Safe unreserved
97+
/// characters (RFC 3986) pass through unchanged.
98+
fn percent_encode_username(s: &str) -> String {
99+
let mut out = String::with_capacity(s.len());
100+
for b in s.bytes() {
101+
match b {
102+
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
103+
out.push(b as char);
104+
}
105+
_ => {
106+
out.push('%');
107+
out.push_str(&format!("{:02X}", b));
108+
}
109+
}
110+
}
111+
out
112+
}
113+
114+
#[cfg(test)]
115+
mod tests {
116+
use super::*;
117+
118+
#[test]
119+
fn percent_encode_username_passes_through_safe_chars() {
120+
assert_eq!(percent_encode_username("abc123_-"), "abc123_-");
121+
}
122+
123+
#[test]
124+
fn percent_encode_username_encodes_reserved_chars() {
125+
assert_eq!(percent_encode_username("user?name"), "user%3Fname");
126+
assert_eq!(percent_encode_username("user&name"), "user%26name");
127+
assert_eq!(percent_encode_username("user#name"), "user%23name");
128+
assert_eq!(percent_encode_username("user:name"), "user%3Aname");
129+
assert_eq!(percent_encode_username("user/name"), "user%2Fname");
130+
assert_eq!(percent_encode_username("user%20name"), "user%2520name");
131+
}
132+
133+
#[test]
134+
fn percent_encode_username_encodes_space() {
135+
assert_eq!(percent_encode_username("user name"), "user%20name");
136+
}
137+
138+
#[test]
139+
fn percent_encode_username_encodes_non_ascii() {
140+
assert_eq!(percent_encode_username("usér"), "us%C3%A9r");
141+
}
142+
}

0 commit comments

Comments
 (0)