From cba102508b5d53f493909511e61edea3f6bea0cc Mon Sep 17 00:00:00 2001 From: Eugen Nekhai Date: Wed, 13 May 2026 15:03:34 +0300 Subject: [PATCH 1/2] add support to login throw scanning QR code --- Cargo.lock | 66 ++++++--------------------------- README.md | 4 ++ crates/tokscale-cli/Cargo.toml | 1 + crates/tokscale-cli/src/auth.rs | 48 ++++++++++++++++++++++++ crates/tokscale-cli/src/main.rs | 10 +++++ 5 files changed, 75 insertions(+), 54 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e83eb03f0..9e75b58ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,17 +24,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures", -] - [[package]] name = "ahash" version = "0.8.12" @@ -333,15 +322,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "block-padding" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" -dependencies = [ - "generic-array", -] - [[package]] name = "bstr" version = "1.12.1" @@ -398,15 +378,6 @@ dependencies = [ "rustversion", ] -[[package]] -name = "cbc" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" -dependencies = [ - "cipher", -] - [[package]] name = "cc" version = "1.2.55" @@ -439,16 +410,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common", - "inout", -] - [[package]] name = "clap" version = "4.5.57" @@ -1568,16 +1529,6 @@ dependencies = [ "rustversion", ] -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "block-padding", - "generic-array", -] - [[package]] name = "instability" version = "0.3.11" @@ -2401,6 +2352,15 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" +dependencies = [ + "image", +] + [[package]] name = "quick-error" version = "2.0.1" @@ -3421,15 +3381,12 @@ dependencies = [ [[package]] name = "tokscale-cli" -version = "2.1.3" +version = "2.1.1" dependencies = [ "ab_glyph", - "aes", "anyhow", "arboard", "assert_cmd", - "base64", - "cbc", "chrono", "clap", "colored", @@ -3442,6 +3399,7 @@ dependencies = [ "imageproc", "indicatif", "predicates", + "qrcode", "ratatui", "rayon", "reqwest", @@ -3463,7 +3421,7 @@ dependencies = [ [[package]] name = "tokscale-core" -version = "2.1.3" +version = "2.1.1" dependencies = [ "bincode", "chrono", diff --git a/README.md b/README.md index fcbfefe9d..173a0af26 100644 --- a/README.md +++ b/README.md @@ -472,6 +472,10 @@ tokscale login --token tt_xxx # Check who you're logged in as tokscale whoami +# Display your saved API token as a QR code (useful for sharing to another device) +# Encodes {"token":"tt_xxx","username":"..."} — scan with any QR reader +tokscale qr + # Submit your usage data to the leaderboard tokscale submit diff --git a/crates/tokscale-cli/Cargo.toml b/crates/tokscale-cli/Cargo.toml index b12e2414c..c673f8a38 100644 --- a/crates/tokscale-cli/Cargo.toml +++ b/crates/tokscale-cli/Cargo.toml @@ -37,6 +37,7 @@ ab_glyph = "0.2" resvg = "0.43" usvg = "0.43" hostname = "0.4" +qrcode = "0.14" uuid = { version = "1.0", features = ["v4"] } rpassword = "7.0" sha2 = "0.10" diff --git a/crates/tokscale-cli/src/auth.rs b/crates/tokscale-cli/src/auth.rs index 41c0177a7..e026cdd6b 100644 --- a/crates/tokscale-cli/src/auth.rs +++ b/crates/tokscale-cli/src/auth.rs @@ -454,6 +454,54 @@ pub fn whoami() -> Result<()> { Ok(()) } +pub fn show_qr() -> Result<()> { + use colored::Colorize; + use qrcode::render::unicode; + use qrcode::QrCode; + + let Some(creds) = load_credentials() else { + println!("\n {}", "Not logged in.".yellow()); + println!( + "{}", + " Run 'bunx tokscale@latest login' to authenticate.\n".bright_black() + ); + return Ok(()); + }; + + let payload = format!( + r#"{{"token":"{}","username":"{}"}}"#, + creds.token, creds.username + ); + let code = QrCode::new(payload.as_bytes()).context("Failed to generate QR code")?; + + let image = code + .render::() + .dark_color(unicode::Dense1x2::Light) + .light_color(unicode::Dense1x2::Dark) + .quiet_zone(true) + .build(); + + println!("\n {}\n", "Tokscale - API Token QR Code".cyan()); + println!(" {}\n", "Scan to get your API token:".bright_black()); + + for line in image.lines() { + println!(" {}", line); + } + + println!( + "\n {}: {}", + "Token".bright_black(), + creds.token.bold() + ); + println!( + " {}: {}\n", + "User".bright_black(), + creds.username.bold() + ); + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/tokscale-cli/src/main.rs b/crates/tokscale-cli/src/main.rs index a0561d263..a7f04819a 100644 --- a/crates/tokscale-cli/src/main.rs +++ b/crates/tokscale-cli/src/main.rs @@ -187,6 +187,8 @@ enum Commands { Logout, #[command(about = "Show current logged in user")] Whoami, + #[command(about = "Display saved API token as QR code")] + Qr, #[command(about = "Export contribution graph data as JSON")] Graph { #[arg(long, help = "Write to file instead of stdout")] @@ -541,6 +543,10 @@ fn main() -> Result<()> { reject_unsupported_home_override(&cli.home, "whoami")?; run_whoami_command() } + Some(Commands::Qr) => { + reject_unsupported_home_override(&cli.home, "qr")?; + run_qr_command() + } Some(Commands::Graph { output, clients, @@ -3755,6 +3761,10 @@ fn run_whoami_command() -> Result<()> { auth::whoami() } +fn run_qr_command() -> Result<()> { + auth::show_qr() +} + fn run_delete_data_command() -> Result<()> { use colored::Colorize; use std::io::{self, Write}; From 87e47b28498ffae49600a14f688579bada319acb Mon Sep 17 00:00:00 2001 From: Junho Yeo Date: Mon, 25 May 2026 01:12:16 +0900 Subject: [PATCH 2/2] security(qr): escape payload via serde_json, require confirmation, redact plaintext MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hand-rolled `format!(r#"{{"token":"{}","username":"{}"}}"#, ...)` payload broke or injected fields whenever a username or token contained `"` or `\`. Build the JSON via `serde_json::to_string` so embedded quotes/backslashes can't escape the structure. The previous flow also printed the bearer token to the terminal twice — once in the QR and once in plaintext below it — with no confirmation. Anyone able to see the screen (screen share, recorded demo, office camera) could replay the token. The token is now only inside the QR (no plaintext echo), and rendering is gated on a `[y/N]` prompt unless `--yes` is passed. Non-TTY stdin without `--yes` aborts instead of dumping the QR into a log stream. Constraint: QR command was already a published surface; --yes flag preserves scripted use Rejected: drop --yes entirely | breaks every existing automation that pipes from this command Confidence: high Scope-risk: narrow Directive: do NOT re-add a plaintext token echo below the QR — the QR is the *only* place the token should appear on screen Not-tested: interactive confirmation flow (would need a pty harness); covered manually --- Cargo.lock | 56 +++++++++++++++++- crates/tokscale-cli/src/auth.rs | 102 +++++++++++++++++++++++++++----- crates/tokscale-cli/src/main.rs | 13 ++-- 3 files changed, 150 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9e75b58ef..759dffc27 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,6 +24,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "ahash" version = "0.8.12" @@ -322,6 +333,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "bstr" version = "1.12.1" @@ -378,6 +398,15 @@ dependencies = [ "rustversion", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.55" @@ -410,6 +439,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "4.5.57" @@ -1529,6 +1568,16 @@ dependencies = [ "rustversion", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "instability" version = "0.3.11" @@ -3381,12 +3430,15 @@ dependencies = [ [[package]] name = "tokscale-cli" -version = "2.1.1" +version = "2.1.3" dependencies = [ "ab_glyph", + "aes", "anyhow", "arboard", "assert_cmd", + "base64", + "cbc", "chrono", "clap", "colored", @@ -3421,7 +3473,7 @@ dependencies = [ [[package]] name = "tokscale-core" -version = "2.1.1" +version = "2.1.3" dependencies = [ "bincode", "chrono", diff --git a/crates/tokscale-cli/src/auth.rs b/crates/tokscale-cli/src/auth.rs index e026cdd6b..27fd3d78c 100644 --- a/crates/tokscale-cli/src/auth.rs +++ b/crates/tokscale-cli/src/auth.rs @@ -454,7 +454,19 @@ pub fn whoami() -> Result<()> { Ok(()) } -pub fn show_qr() -> Result<()> { +/// Build the JSON payload encoded into the login QR code. +/// +/// Uses `serde_json` so that usernames or tokens containing `"` / `\` cannot +/// break the payload structure or inject extra fields. Exposed for tests. +pub(crate) fn qr_login_payload(token: &str, username: &str) -> Result { + serde_json::to_string(&serde_json::json!({ + "token": token, + "username": username, + })) + .context("Failed to encode QR payload") +} + +pub fn show_qr(yes: bool) -> Result<()> { use colored::Colorize; use qrcode::render::unicode; use qrcode::QrCode; @@ -468,10 +480,44 @@ pub fn show_qr() -> Result<()> { return Ok(()); }; - let payload = format!( - r#"{{"token":"{}","username":"{}"}}"#, - creds.token, creds.username + // Anyone who can see the terminal can scan and replay the token: screen + // shares, recorded demos, office cameras, shoulder surfing. Block unless + // the user explicitly confirms (or passes --yes for scripted use). + println!(); + println!( + " {}", + "⚠ This will render your API token as a QR code on screen.".yellow() + ); + println!( + " {}", + "Anyone who can see your terminal (screen share, recording, camera)" + .bright_black() + ); + println!( + " {}", + "can scan it and gain full access to your tokscale account.".bright_black() ); + println!(); + + if !yes { + if !std::io::stdin().is_terminal() { + anyhow::bail!( + "Refusing to render token QR: stdin is not a TTY. Pass --yes to bypass." + ); + } + print!(" Continue? [y/N] "); + std::io::stdout().flush().ok(); + let mut answer = String::new(); + std::io::stdin() + .read_line(&mut answer) + .context("Failed to read confirmation")?; + if !matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes") { + println!("\n {}\n", "Aborted.".bright_black()); + return Ok(()); + } + } + + let payload = qr_login_payload(&creds.token, &creds.username)?; let code = QrCode::new(payload.as_bytes()).context("Failed to generate QR code")?; let image = code @@ -488,16 +534,7 @@ pub fn show_qr() -> Result<()> { println!(" {}", line); } - println!( - "\n {}: {}", - "Token".bright_black(), - creds.token.bold() - ); - println!( - " {}: {}\n", - "User".bright_black(), - creds.username.bold() - ); + println!("\n {}: {}\n", "User".bright_black(), creds.username.bold()); Ok(()) } @@ -903,4 +940,41 @@ mod tests { assert_eq!(auth.username.as_deref(), None); assert_eq!(auth.source, ApiTokenSource::Environment); } + + #[test] + fn qr_login_payload_round_trips_through_json() { + let payload = qr_login_payload("tok_abc123", "alice").unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&payload).unwrap(); + assert_eq!(parsed["token"], "tok_abc123"); + assert_eq!(parsed["username"], "alice"); + } + + #[test] + fn qr_login_payload_escapes_dangerous_username_chars() { + // The old hand-rolled format!() would have produced invalid JSON for + // any of these inputs; serde_json must escape them safely. + for bad in [ + "alice\"; DROP TABLE users;--", + "alice\\", + "with\nnewline", + "tab\there", + r#"quote"and\backslash"#, + ] { + let payload = qr_login_payload("tok", bad).unwrap(); + let parsed: serde_json::Value = + serde_json::from_str(&payload).expect("must remain valid JSON"); + assert_eq!( + parsed["username"], bad, + "round-trip must preserve the original username" + ); + } + } + + #[test] + fn qr_login_payload_escapes_dangerous_token_chars() { + let payload = qr_login_payload(r#"tok"with"quotes"#, "bob").unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&payload).unwrap(); + assert_eq!(parsed["token"], r#"tok"with"quotes"#); + assert_eq!(parsed["username"], "bob"); + } } diff --git a/crates/tokscale-cli/src/main.rs b/crates/tokscale-cli/src/main.rs index a7f04819a..89adca35e 100644 --- a/crates/tokscale-cli/src/main.rs +++ b/crates/tokscale-cli/src/main.rs @@ -188,7 +188,10 @@ enum Commands { #[command(about = "Show current logged in user")] Whoami, #[command(about = "Display saved API token as QR code")] - Qr, + Qr { + #[arg(long, help = "Skip the on-screen warning + confirmation prompt")] + yes: bool, + }, #[command(about = "Export contribution graph data as JSON")] Graph { #[arg(long, help = "Write to file instead of stdout")] @@ -543,9 +546,9 @@ fn main() -> Result<()> { reject_unsupported_home_override(&cli.home, "whoami")?; run_whoami_command() } - Some(Commands::Qr) => { + Some(Commands::Qr { yes }) => { reject_unsupported_home_override(&cli.home, "qr")?; - run_qr_command() + run_qr_command(yes) } Some(Commands::Graph { output, @@ -3761,8 +3764,8 @@ fn run_whoami_command() -> Result<()> { auth::whoami() } -fn run_qr_command() -> Result<()> { - auth::show_qr() +fn run_qr_command(yes: bool) -> Result<()> { + auth::show_qr(yes) } fn run_delete_data_command() -> Result<()> {