diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 2ba661fd..f75ef54b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -115,6 +115,12 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -521,6 +527,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.1" @@ -747,6 +759,12 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "combine" version = "4.6.7" @@ -1351,6 +1369,7 @@ dependencies = [ "font-kit", "hex", "hmac 0.12.1", + "image", "lazy_static", "md-5", "notify", @@ -1358,6 +1377,7 @@ dependencies = [ "portable-pty", "rand 0.8.5", "reqwest", + "rqrr", "russh", "russh-sftp", "serde", @@ -1948,6 +1968,34 @@ dependencies = [ "byteorder", ] +[[package]] +name = "g2gen" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5a7e0eb46f83a20260b850117d204366674e85d3a908d90865c78df9a6b1dfc" +dependencies = [ + "g2poly", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "g2p" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "539e2644c030d3bf4cd208cb842d2ce2f80e82e6e8472390bcef83ceba0d80ad" +dependencies = [ + "g2gen", + "g2poly", +] + +[[package]] +name = "g2poly" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312d2295c7302019c395cfb90dacd00a82a2eabd700429bba9c7a3f38dbbe11b" + [[package]] name = "gdk" version = "0.18.2" @@ -2142,6 +2190,16 @@ dependencies = [ "polyval 0.7.1", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gio" version = "0.18.4" @@ -2332,6 +2390,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -2555,7 +2615,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" dependencies = [ "byteorder", - "png", + "png 0.17.16", ] [[package]] @@ -2672,6 +2732,34 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png 0.18.1", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -3089,6 +3177,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -3242,6 +3339,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "muda" version = "0.17.1" @@ -3257,7 +3364,7 @@ dependencies = [ "objc2-core-foundation", "objc2-foundation", "once_cell", - "png", + "png 0.17.16", "serde", "thiserror 2.0.18", "windows-sys 0.60.2", @@ -4211,6 +4318,19 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.10.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "polling" version = "3.11.0" @@ -4426,6 +4546,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.38.4" @@ -4810,6 +4942,17 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rqrr" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2260da7f69877ba68c49a0c2d9946829848236c708dd40d2a6baf8c868ee887" +dependencies = [ + "g2p", + "image", + "lru", +] + [[package]] name = "rsa" version = "0.9.10" @@ -6046,7 +6189,7 @@ dependencies = [ "ico", "json-patch", "plist", - "png", + "png 0.17.16", "proc-macro2", "quote", "semver", @@ -6698,7 +6841,7 @@ dependencies = [ "objc2-core-graphics", "objc2-foundation", "once_cell", - "png", + "png 0.17.16", "serde", "thiserror 2.0.18", "windows-sys 0.60.2", @@ -7187,6 +7330,12 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "winapi" version = "0.3.9" @@ -8157,6 +8306,21 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + [[package]] name = "zvariant" version = "5.9.2" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9f0d94cb..773cbf1a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -74,6 +74,8 @@ hex = "0.4" urlencoding = "2" zip = "8" encoding_rs = "0.8" +image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "gif", "webp"] } +rqrr = "0.9" [profile.release] lto = true diff --git a/src-tauri/crates/otp/src/totp.rs b/src-tauri/crates/otp/src/totp.rs index 721c4ab0..5642866b 100644 --- a/src-tauri/crates/otp/src/totp.rs +++ b/src-tauri/crates/otp/src/totp.rs @@ -239,6 +239,11 @@ impl Totp { self.hotp.secret() } + #[inline] + pub fn period(&self) -> u64 { + self.period + } + /// Parses a TOTP configuration from a URI string in the [Key URI Format]. /// /// This function supports URIs of the form: diff --git a/src-tauri/src/cmd/mod.rs b/src-tauri/src/cmd/mod.rs index 037bc73f..e307c135 100644 --- a/src-tauri/src/cmd/mod.rs +++ b/src-tauri/src/cmd/mod.rs @@ -1,5 +1,6 @@ pub mod connection; pub mod importer; +pub mod otp; pub mod proxy; pub mod session; pub mod settings; diff --git a/src-tauri/src/cmd/otp.rs b/src-tauri/src/cmd/otp.rs new file mode 100644 index 00000000..aec04e86 --- /dev/null +++ b/src-tauri/src/cmd/otp.rs @@ -0,0 +1,191 @@ +use crate::config; +use crate::config::OtpEntry; +use crate::error::AppResult; +use crate::utils::crypto; +use serde::Serialize; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OtpCodeResult { + pub code: String, + pub remaining_seconds: u64, +} + +#[tauri::command] +pub fn get_otp_entries(app: tauri::AppHandle) -> AppResult> { + let mut cfg = config::load_otp_entries(&app)?; + for entry in &mut cfg.entries { + entry.secret = None; + } + Ok(cfg.entries) +} + +#[tauri::command] +pub fn get_otp_secret_value(app: tauri::AppHandle, id: String) -> AppResult> { + Ok(config::load_otp_entry_by_id(&app, &id)?.secret) +} + +#[tauri::command] +pub fn save_otp_entry(app: tauri::AppHandle, mut entry: OtpEntry) -> AppResult { + let mut cfg = config::load_otp_entries(&app)?; + + if entry.id.is_empty() { + entry.id = uuid::Uuid::new_v4().to_string(); + } + let target_id = entry.id.clone(); + let existing = cfg.entries.iter().find(|e| e.id == target_id); + + entry.secret = match entry.secret.as_deref() { + Some(plain) if !plain.is_empty() => Some(crypto::encrypt(plain)?), + _ => existing.and_then(|e| e.secret.clone()), + }; + + if let Some(ex) = cfg.entries.iter_mut().find(|e| e.id == target_id) { + *ex = entry; + } else { + cfg.entries.push(entry); + } + config::save_otp_entries(&app, &cfg)?; + Ok(target_id) +} + +#[tauri::command] +pub fn delete_otp_entry(app: tauri::AppHandle, id: String) -> AppResult<()> { + let mut cfg = config::load_otp_entries(&app)?; + cfg.entries.retain(|e| e.id != id); + config::save_otp_entries(&app, &cfg) +} + +#[tauri::command] +pub fn generate_otp_code(app: tauri::AppHandle, id: String) -> AppResult { + generate_otp_for_entry(&app, &id) +} + +#[tauri::command] +pub fn import_otp_from_qr(path: String) -> AppResult { + use crate::error::AppError; + + let img = image::open(&path) + .map_err(|e| AppError::Config(format!("Failed to open image: {e}")))?; + let gray = img.to_luma8(); + + let mut prepared = rqrr::PreparedImage::prepare(gray); + let grids = prepared.detect_grids(); + + let grid = grids + .first() + .ok_or_else(|| AppError::Config("No QR code found in the image".to_string()))?; + + let (_, uri) = grid + .decode() + .map_err(|e| AppError::Config(format!("Failed to decode QR code: {e}")))?; + + if uri.starts_with("otpauth://totp/") { + let totp = dragonfly_otp::Totp::from_uri(&uri) + .map_err(|e| AppError::Config(format!("Invalid TOTP URI: {e}")))?; + Ok(OtpEntry { + id: String::new(), + otp_type: "totp".to_string(), + issuer: totp.issuer().to_string(), + username: totp.label().to_string(), + secret: Some(totp.secret().into_base32()), + algorithm: totp.alg().to_string(), + digits: totp.digits(), + period: totp.period(), + counter: 0, + has_secret: false, + }) + } else if uri.starts_with("otpauth://hotp/") { + let hotp = dragonfly_otp::Hotp::from_uri(&uri) + .map_err(|e| AppError::Config(format!("Invalid HOTP URI: {e}")))?; + Ok(OtpEntry { + id: String::new(), + otp_type: "hotp".to_string(), + issuer: hotp.issuer().to_string(), + username: hotp.label().to_string(), + secret: Some(hotp.secret().into_base32()), + algorithm: hotp.alg().to_string(), + digits: hotp.digits(), + period: 30, + counter: hotp.counter(), + has_secret: false, + }) + } else { + Err(AppError::Config(format!( + "QR code does not contain a valid otpauth:// URI: {uri}" + ))) + } +} + +/// Shared logic for generating an OTP code from a stored entry. +pub(crate) fn generate_otp_for_entry( + app: &tauri::AppHandle, + id: &str, +) -> AppResult { + let entry = config::load_otp_entry_by_id(app, id)?; + let secret_str = entry + .secret + .as_deref() + .ok_or_else(|| crate::error::AppError::Config("OTP entry has no secret".to_string()))?; + + let alg = match entry.algorithm.as_str() { + "SHA256" => dragonfly_otp::Algorithm::SHA256, + "SHA512" => dragonfly_otp::Algorithm::SHA512, + _ => dragonfly_otp::Algorithm::SHA1, + }; + + let secret = dragonfly_otp::Secret::from_base32(secret_str) + .map_err(|e| crate::error::AppError::Config(format!("Invalid base32 secret: {e:?}")))?; + + match entry.otp_type.as_str() { + "hotp" => { + let mut hotp = dragonfly_otp::Hotp::new( + alg, + entry.issuer.clone(), + entry.username.clone(), + entry.digits, + entry.counter, + secret, + ); + let raw = hotp.generate(); + let code = format!("{:0>width$}", raw, width = entry.digits as usize); + + // Increment the counter and persist + let mut cfg = config::load_otp_entries(app)?; + if let Some(e) = cfg.entries.iter_mut().find(|e| e.id == id) { + e.counter += 1; + } + config::save_otp_entries(app, &cfg)?; + + Ok(OtpCodeResult { + code, + remaining_seconds: 0, + }) + } + _ => { + let period = if entry.period > 0 { entry.period } else { 30 }; + let totp = dragonfly_otp::Totp::new( + alg, + entry.issuer.clone(), + entry.username.clone(), + entry.digits, + period, + secret, + ); + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock before UNIX epoch") + .as_secs(); + + let raw = totp.generate_at(now); + let code = format!("{:0>width$}", raw, width = entry.digits as usize); + let remaining = period - (now % period); + + Ok(OtpCodeResult { + code, + remaining_seconds: remaining, + }) + } + } +} diff --git a/src-tauri/src/config/connection.rs b/src-tauri/src/config/connection.rs index 5dff0833..a205f7cf 100644 --- a/src-tauri/src/config/connection.rs +++ b/src-tauri/src/config/connection.rs @@ -51,6 +51,14 @@ pub struct SavedConnection { #[serde(default)] pub proxy_id: Option, + /// References an OTP entry for two-factor authentication. + #[serde(default)] + pub otp_id: Option, + + /// When true, auto-fill the OTP code during keyboard-interactive auth. + #[serde(default)] + pub auto_fill_otp: bool, + #[serde(default)] pub network: ConnectionNetworkSettings, } diff --git a/src-tauri/src/config/mod.rs b/src-tauri/src/config/mod.rs index 308612e2..301baf86 100644 --- a/src-tauri/src/config/mod.rs +++ b/src-tauri/src/config/mod.rs @@ -4,6 +4,7 @@ mod connection; mod key; +mod otp; mod password; mod proxy; mod quick_command; @@ -23,6 +24,8 @@ pub use password::{ load_password_by_id, load_passwords, save_passwords, PasswordsConfig, SavedPassword, }; #[allow(unused_imports)] +pub use otp::{load_otp_entries, load_otp_entry_by_id, save_otp_entries, OtpConfig, OtpEntry}; +#[allow(unused_imports)] pub use proxy::{load_proxies, load_proxy_by_id, save_proxies, ProxyConfig}; #[allow(unused_imports)] pub use quick_command::{ diff --git a/src-tauri/src/config/otp.rs b/src-tauri/src/config/otp.rs new file mode 100644 index 00000000..ff2008bd --- /dev/null +++ b/src-tauri/src/config/otp.rs @@ -0,0 +1,78 @@ +use super::{get_config_dir, load_json, save_json, uuid_v4}; +use crate::error::{AppError, AppResult}; +use crate::utils::crypto; +use serde::{Deserialize, Serialize}; +use tauri::AppHandle; + +fn default_digits() -> u8 { + 6 +} + +fn default_period() -> u64 { + 30 +} + +/// Stored OTP entry. The secret is AES-256-GCM encrypted on disk. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OtpEntry { + #[serde(default = "uuid_v4")] + pub id: String, + /// `"totp"` or `"hotp"`. + pub otp_type: String, + pub issuer: String, + pub username: String, + /// Encrypted secret on disk; plaintext only after explicit decryption. + #[serde(default)] + pub secret: Option, + #[serde(default = "default_algorithm")] + pub algorithm: String, + #[serde(default = "default_digits")] + pub digits: u8, + /// Time step in seconds (TOTP only). + #[serde(default = "default_period")] + pub period: u64, + /// Counter value (HOTP only). + #[serde(default)] + pub counter: u64, + /// Transient: true when encrypted secret data exists on disk. + #[serde(default, skip_serializing)] + pub has_secret: bool, +} + +fn default_algorithm() -> String { + "SHA1".to_string() +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct OtpConfig { + #[serde(default)] + pub entries: Vec, +} + +pub fn load_otp_entries(app: &AppHandle) -> AppResult { + let dir = get_config_dir(app)?; + let path = dir.join("otp.json"); + let mut config: OtpConfig = load_json(&path)?; + for entry in &mut config.entries { + entry.has_secret = entry.secret.is_some(); + } + Ok(config) +} + +pub fn save_otp_entries(app: &AppHandle, config: &OtpConfig) -> AppResult<()> { + let dir = get_config_dir(app)?; + save_json(&dir.join("otp.json"), config) +} + +pub fn load_otp_entry_by_id(app: &AppHandle, id: &str) -> AppResult { + let cfg = load_otp_entries(app)?; + let mut entry = cfg + .entries + .into_iter() + .find(|e| e.id == id) + .ok_or_else(|| AppError::Config(format!("OTP entry '{}' not found", id)))?; + if let Some(ct) = entry.secret.clone() { + entry.secret = crypto::decrypt(&ct).ok(); + } + Ok(entry) +} diff --git a/src-tauri/src/core/importer.rs b/src-tauri/src/core/importer.rs index 6803f133..f3138602 100644 --- a/src-tauri/src/core/importer.rs +++ b/src-tauri/src/core/importer.rs @@ -427,6 +427,8 @@ pub fn import_sessions(app: tauri::AppHandle, file_path: String) -> AppResult, + otp_entry_id: Option, } pub(crate) fn load_saved_ssh_config(app: &AppHandle, connection_id: &str) -> AppResult { @@ -123,6 +124,19 @@ pub(super) async fn authenticate_handle( password_error: &str, key_error: &str, ) -> AppResult<()> { + authenticate_handle_with_otp(handle, config, app, password_error, key_error, None).await +} + +pub(super) async fn authenticate_handle_with_otp( + handle: &mut client::Handle, + config: &SshConfig, + app: &AppHandle, + password_error: &str, + key_error: &str, + connection_id: Option<&str>, +) -> AppResult<()> { + let otp_info = connection_id.and_then(|cid| resolve_otp_info(app, cid)); + match &config.auth { SshAuth::Password { password } => { let authenticated = handle @@ -137,6 +151,7 @@ pub(super) async fn authenticate_handle( &config.name, app, password_error, + otp_info.as_ref(), ) .await?; } @@ -166,6 +181,7 @@ pub(super) async fn authenticate_handle( &config.name, app, key_error, + otp_info.as_ref(), ) .await?; } @@ -174,14 +190,32 @@ pub(super) async fn authenticate_handle( Ok(()) } +struct OtpAutoFillInfo { + otp_id: String, + auto_fill: bool, +} + +fn resolve_otp_info(app: &AppHandle, connection_id: &str) -> Option { + let conn = crate::config::load_connection_by_id(app, connection_id).ok()?; + let otp_id = conn.otp_id?; + Some(OtpAutoFillInfo { + otp_id, + auto_fill: conn.auto_fill_otp, + }) +} + /// Runs the keyboard-interactive auth state machine, emitting `otp-request` events /// to the frontend for each `InfoRequest` that contains prompts, and automatically /// responding with an empty array for empty `InfoRequest`s. +/// +/// When `otp_info` is present with `auto_fill == true`, the OTP code is generated +/// automatically and used as the response without prompting the user. async fn finish_keyboard_interactive( handle: &mut client::Handle, username: &str, connection_name: &str, app: &AppHandle, + otp_info: Option<&OtpAutoFillInfo>, ) -> AppResult<()> { let pending_mgr = app .try_state::>() @@ -208,6 +242,11 @@ async fn finish_keyboard_interactive( } => { let responses = if prompts.is_empty() { Vec::new() + } else if let Some(info) = otp_info.filter(|i| i.auto_fill) { + tracing::info!("Auto-filling OTP for keyboard-interactive auth"); + let result = + crate::cmd::otp::generate_otp_for_entry(app, &info.otp_id)?; + prompts.iter().map(|_| result.code.clone()).collect() } else { let request_id = uuid::Uuid::new_v4().to_string(); let rx = pending_mgr.register(request_id.clone()).await; @@ -222,6 +261,7 @@ async fn finish_keyboard_interactive( echo: prompt.echo, }) .collect(), + otp_entry_id: otp_info.map(|i| i.otp_id.clone()), }; let _ = app.emit("otp-request", &payload); @@ -260,6 +300,7 @@ async fn try_keyboard_interactive_after_partial( connection_name: &str, app: &AppHandle, fallback_error: &str, + otp_info: Option<&OtpAutoFillInfo>, ) -> AppResult<()> { match auth_result { client::AuthResult::Success => Ok(()), @@ -271,7 +312,7 @@ async fn try_keyboard_interactive_after_partial( tracing::info!( "Primary auth partial success, continuing with keyboard-interactive" ); - finish_keyboard_interactive(handle, username, connection_name, app).await + finish_keyboard_interactive(handle, username, connection_name, app, otp_info).await } else { Err(AppError::Auth(fallback_error.to_string())) } diff --git a/src-tauri/src/core/ssh/session.rs b/src-tauri/src/core/ssh/session.rs index 0bb935e7..dc1cb46c 100644 --- a/src-tauri/src/core/ssh/session.rs +++ b/src-tauri/src/core/ssh/session.rs @@ -1,4 +1,4 @@ -use super::auth::{authenticate_handle, load_saved_ssh_config}; +use super::auth::{authenticate_handle, authenticate_handle_with_otp, load_saved_ssh_config}; use super::client::{build_client_config, connect_with_proxy, SshConfig, SshHandle, SshHandler}; use super::io::{open_shell_channel, ssh_io_loop}; use crate::error::AppResult; @@ -56,12 +56,13 @@ pub async fn create_ssh_session( let mut handle = connect_with_proxy(&config, Arc::new(build_client_config(&app)), handler).await?; - authenticate_handle( + authenticate_handle_with_otp( &mut handle, &config, &app, "Authentication failed: invalid credentials", "Authentication failed: key rejected", + connection_id.as_deref(), ) .await?; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index acc0ba53..1827d113 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -95,6 +95,12 @@ pub fn run() { cmd::proxy::save_proxy, cmd::proxy::delete_proxy, cmd::proxy::get_proxy_password, + cmd::otp::get_otp_entries, + cmd::otp::get_otp_secret_value, + cmd::otp::save_otp_entry, + cmd::otp::delete_otp_entry, + cmd::otp::generate_otp_code, + cmd::otp::import_otp_from_qr, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/components/dialog/connections/OtpDialog.tsx b/src/components/dialog/connections/OtpDialog.tsx index c944affc..7bdfedd6 100644 --- a/src/components/dialog/connections/OtpDialog.tsx +++ b/src/components/dialog/connections/OtpDialog.tsx @@ -1,6 +1,7 @@ import { invoke } from "@tauri-apps/api/core"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import { OtpCodePanel } from "@/components/panel/OtpCodePanel"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -22,6 +23,7 @@ export interface OtpRequest { requestId: string; connectionName: string; prompts: OtpPrompt[]; + otpEntryId?: string; } interface OtpDialogProps { @@ -79,6 +81,13 @@ export function OtpDialog({ request, onDone }: OtpDialogProps) { } }; + const handleSendToInput = (code: string) => { + if (!request) return; + const next = [...responses]; + next[0] = code; + setResponses(next); + }; + return ( - - {t("otp.title")} - + {t("otp.title")} {t("otp.description", { name: request?.connectionName })}
- {request?.prompts.map((p, i) => ( -
+ {request?.prompts.map((p, promptIndex) => ( +
{ const next = [...responses]; - next[i] = e.target.value; + next[promptIndex] = e.target.value; setResponses(next); }} />
))} + + {request?.otpEntryId && ( + + )}
diff --git a/src/components/panel/OtpCodePanel.tsx b/src/components/panel/OtpCodePanel.tsx new file mode 100644 index 00000000..1a8cf7ba --- /dev/null +++ b/src/components/panel/OtpCodePanel.tsx @@ -0,0 +1,231 @@ +import { invoke } from "@tauri-apps/api/core"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { MdCheck, MdContentCopy, MdRefresh } from "react-icons/md"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { useOtpCode } from "@/hooks/useOtpCode"; +import type { OtpEntry } from "@/types/global"; + +interface OtpCodePanelProps { + className?: string; + onSendToInput?: (code: string) => void; + otpEntryId: string; + otpType?: string; + period?: number; + variant?: "dialog" | "list"; +} + +function formatOtpCode(code: string) { + return code.match(/.{1,3}/g)?.join(" ") ?? code; +} + +function LoadingState({ label }: { label: string }) { + return ( +
+ + {label} +
+ ); +} + +export function OtpCodePanel({ + className = "", + onSendToInput, + otpEntryId, + otpType, + period, + variant = "list", +}: OtpCodePanelProps) { + const { t } = useTranslation(); + const [entryMeta, setEntryMeta] = useState | null>(null); + const [metaLoading, setMetaLoading] = useState(false); + const metaRequestRef = useRef(0); + + useEffect(() => { + if (otpType || !otpEntryId) { + setEntryMeta(null); + setMetaLoading(false); + return; + } + + const requestId = ++metaRequestRef.current; + setMetaLoading(true); + + invoke("get_otp_entries") + .then((entries) => { + if (metaRequestRef.current !== requestId) return; + const match = entries.find((entry) => entry.id === otpEntryId); + setEntryMeta(match ? { otp_type: match.otp_type, period: match.period } : null); + }) + .catch(() => { + if (metaRequestRef.current !== requestId) return; + setEntryMeta(null); + }) + .finally(() => { + if (metaRequestRef.current === requestId) { + setMetaLoading(false); + } + }); + }, [otpEntryId, otpType]); + + const resolvedType = otpType ?? entryMeta?.otp_type; + const resolvedPeriod = period ?? entryMeta?.period ?? 30; + const containerClassName = useMemo( + () => + [ + "rounded-md border border-border/70 bg-background/70 px-3 py-3", + variant === "dialog" ? "space-y-3" : "space-y-2.5", + className, + ] + .filter(Boolean) + .join(" "), + [className, variant], + ); + + const { + code, + copied, + copyCode, + fetchCode, + hasCode, + isHotp, + isTotp, + loading, + progressPercent, + remaining, + } = useOtpCode({ + autoStart: resolvedType === "totp", + otpEntryId, + otpType: resolvedType, + period: resolvedPeriod, + }); + + if (metaLoading || !resolvedType) { + return ( +
+ +
+ ); + } + + const showGenerateButton = isHotp; + const showProgress = isTotp && hasCode; + const displayCode = hasCode ? formatOtpCode(code) : "--- ---"; + const statusText = showProgress + ? t("otp.expiresIn", { seconds: remaining }) + : isHotp + ? t("otp.hotpHint") + : t("otp.loadingCode"); + + return ( +
+
+ + + {resolvedType.toUpperCase()} + +
+ + {loading && !hasCode ? ( + + ) : isHotp && !hasCode ? ( +
+

{t("otp.hotpHint")}

+ +
+ ) : ( + <> +
+
+
+ {displayCode} +
+
{statusText}
+
+ + {showProgress ? ( +
+
+ {remaining} +
+
+ s +
+
+ ) : null} +
+ + {showProgress ? ( +
+
+
+ ) : null} + +
+ + + {showGenerateButton ? ( + + ) : null} + + {onSendToInput ? ( + + ) : null} +
+ + )} +
+ ); +} diff --git a/src/components/panel/SecurityAuthPanel.tsx b/src/components/panel/SecurityAuthPanel.tsx index 7d9a9c31..5ef258c1 100644 --- a/src/components/panel/SecurityAuthPanel.tsx +++ b/src/components/panel/SecurityAuthPanel.tsx @@ -2,16 +2,21 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import PanelHeader from "@/components/layout/PanelHeader"; import { KeyManagementTab } from "@/components/settings/KeyManagementTab"; +import { OtpManagementTab } from "@/components/settings/OtpManagementTab"; import { PasswordManagementTab } from "@/components/settings/PasswordManagementTab"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -type SecurityAuthTab = "keys" | "passwords"; +type SecurityAuthTab = "keys" | "passwords" | "otp"; export default function SecurityAuthPanel() { const { t } = useTranslation(); const [activeTab, setActiveTab] = useState("keys"); const [keyCount, setKeyCount] = useState(0); const [passwordCount, setPasswordCount] = useState(0); + const [otpCount, setOtpCount] = useState(0); + + const displayCount = + activeTab === "keys" ? keyCount : activeTab === "passwords" ? passwordCount : otpCount; return (
@@ -19,7 +24,7 @@ export default function SecurityAuthPanel() { title={t("securityAuth.title")} actions={ - {activeTab === "keys" ? keyCount : passwordCount} + {displayCount} } /> @@ -29,13 +34,16 @@ export default function SecurityAuthPanel() { onValueChange={(value) => setActiveTab(value as SecurityAuthTab)} className="w-full" > - + {t("securityAuth.keys")} {t("securityAuth.passwords")} + + {t("securityAuth.otp")} + @@ -43,6 +51,9 @@ export default function SecurityAuthPanel() { + + +
diff --git a/src/components/settings/OtpManagementTab.tsx b/src/components/settings/OtpManagementTab.tsx new file mode 100644 index 00000000..fed971a8 --- /dev/null +++ b/src/components/settings/OtpManagementTab.tsx @@ -0,0 +1,529 @@ +import { invoke } from "@tauri-apps/api/core"; +import { open as openFileDialog } from "@tauri-apps/plugin-dialog"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + MdAdd, + MdChevronRight, + MdDelete, + MdEdit, + MdQrCodeScanner, + MdRefresh, + MdVisibility, + MdVisibilityOff, +} from "react-icons/md"; +import { toast } from "sonner"; +import { OtpCodePanel } from "@/components/panel/OtpCodePanel"; +import { Button } from "@/components/ui/button"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { NumberInput } from "@/components/ui/number-input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import type { OtpEntry } from "@/types/global"; + +interface OtpManagementTabProps { + onCountChange?: (count: number) => void; +} + +function otpTypeTagClass(otpType: string) { + return otpType === "hotp" ? "text-amber-600 dark:text-amber-400" : "text-primary"; +} + +interface OtpEditorProps { + entry: Partial; + onCancel: () => void; + onChange: (patch: Partial) => void; + onSave: () => void; + saveDisabled: boolean; + secretLoading: boolean; + t: ReturnType["t"]; +} + +function OtpEditor({ + entry, + onCancel, + onChange, + onSave, + saveDisabled, + secretLoading, + t, +}: OtpEditorProps) { + const [advancedOpen, setAdvancedOpen] = useState(false); + const otpType = entry.otp_type ?? "totp"; + + return ( +
+ onChange({ otp_type: value })} + className="w-full" + > + + + TOTP + + + HOTP + + + + + onChange({ issuer: event.target.value })} + autoFocus + /> + onChange({ username: event.target.value })} + /> + onChange({ secret: event.target.value })} + disabled={secretLoading} + /> + + + + + {t("otpManager.advanced")} + + +
+ + +
+ +
+ + onChange({ digits: value })} + min={4} + max={10} + /> +
+ + {otpType === "totp" ? ( +
+ + onChange({ period: value })} + min={10} + max={300} + /> +
+ ) : ( +
+ + onChange({ counter: value })} + min={0} + max={999999999} + /> +
+ )} +
+
+ +
+ + +
+
+ ); +} + +export function OtpManagementTab({ onCountChange }: OtpManagementTabProps) { + const { t } = useTranslation(); + const [entries, setEntries] = useState([]); + const [editingId, setEditingId] = useState(null); + const [editEntry, setEditEntry] = useState>({}); + const [secretLoading, setSecretLoading] = useState(false); + const [isNew, setIsNew] = useState(false); + const [qrImporting, setQrImporting] = useState(false); + const [deletingEntry, setDeletingEntry] = useState(null); + const [visibleOtpIds, setVisibleOtpIds] = useState>(() => new Set()); + const editRequestRef = useRef(0); + + const loadEntries = useCallback(async () => { + try { + const result = await invoke("get_otp_entries"); + setEntries(result); + setVisibleOtpIds((prev) => { + const next = new Set(); + for (const entry of result) { + if (prev.has(entry.id)) next.add(entry.id); + } + return next; + }); + onCountChange?.(result.length); + } catch { + /* ignore */ + } + }, [onCountChange]); + + useEffect(() => { + void loadEntries(); + }, [loadEntries]); + + const resetEdit = useCallback(() => { + editRequestRef.current += 1; + setEditingId(null); + setEditEntry({}); + setSecretLoading(false); + setIsNew(false); + }, []); + + const handleAdd = useCallback(() => { + resetEdit(); + setEditingId("__new__"); + setEditEntry({ + algorithm: "SHA1", + counter: 0, + digits: 6, + otp_type: "totp", + period: 30, + }); + setIsNew(true); + }, [resetEdit]); + + const handleImportQr = useCallback(async () => { + if (qrImporting || editingId !== null) return; + + setQrImporting(true); + try { + const selected = await openFileDialog({ + multiple: false, + title: t("otpManager.selectQrImage"), + filters: [{ name: "Images", extensions: ["png", "jpg", "jpeg", "bmp", "gif", "webp"] }], + }); + const selectedPath = Array.isArray(selected) ? selected[0] : selected; + if (!selectedPath) return; + + const parsed = await invoke("import_otp_from_qr", { path: selectedPath }); + resetEdit(); + setEditingId("__new__"); + setEditEntry({ + algorithm: parsed.algorithm, + counter: parsed.counter, + digits: parsed.digits, + issuer: parsed.issuer, + otp_type: parsed.otp_type, + period: parsed.period, + secret: parsed.secret ?? "", + username: parsed.username, + }); + setIsNew(true); + } catch (error) { + toast.error(t("otpManager.qrImportFailed"), { description: String(error) }); + } finally { + setQrImporting(false); + } + }, [editingId, qrImporting, resetEdit, t]); + + const handleEdit = useCallback(async (entry: OtpEntry) => { + const requestId = ++editRequestRef.current; + setEditingId(entry.id); + setEditEntry({ + ...entry, + secret: "", + }); + setSecretLoading(true); + setIsNew(false); + + try { + const secret = await invoke("get_otp_secret_value", { id: entry.id }); + if (editRequestRef.current !== requestId) return; + setEditEntry((prev) => ({ + ...prev, + secret: secret ?? "", + })); + } catch { + if (editRequestRef.current !== requestId) return; + setEditEntry((prev) => ({ + ...prev, + secret: "", + })); + } finally { + if (editRequestRef.current === requestId) { + setSecretLoading(false); + } + } + }, []); + + const handleSave = useCallback(async () => { + if (!editEntry.issuer?.trim()) return; + if (isNew && !editEntry.secret) return; + + try { + await invoke("save_otp_entry", { + entry: { + algorithm: editEntry.algorithm ?? "SHA1", + counter: editEntry.counter ?? 0, + digits: editEntry.digits ?? 6, + id: isNew ? "" : editingId, + issuer: editEntry.issuer.trim(), + otp_type: editEntry.otp_type ?? "totp", + period: editEntry.period ?? 30, + secret: editEntry.secret || undefined, + username: editEntry.username?.trim() ?? "", + }, + }); + resetEdit(); + await loadEntries(); + } catch { + /* ignore */ + } + }, [editEntry, editingId, isNew, loadEntries, resetEdit]); + + const handleDeleteConfirm = useCallback(async () => { + if (!deletingEntry) return; + + try { + await invoke("delete_otp_entry", { id: deletingEntry.id }); + await loadEntries(); + } catch { + /* ignore */ + } + + setDeletingEntry(null); + }, [deletingEntry, loadEntries]); + + const handleChange = useCallback((patch: Partial) => { + setEditEntry((prev) => ({ ...prev, ...patch })); + }, []); + + const toggleCodeVisibility = useCallback((id: string) => { + setVisibleOtpIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, []); + + const actionsDisabled = editingId !== null || qrImporting; + + return ( +
+
+
+ +
+ + +
+
+ +
+ {isNew && editingId === "__new__" ? ( + + ) : null} + + {entries.map((entry) => { + const codeVisible = visibleOtpIds.has(entry.id); + + return ( +
+ {editingId === entry.id && !isNew ? ( + + ) : ( +
+
+
+
+
{entry.issuer}
+ + [{entry.otp_type.toUpperCase()}] + +
+
+ {entry.username} +
+ +
+ + + +
+
+
+ + {codeVisible ? ( + + ) : null} +
+ )} +
+ ); + })} + + {entries.length === 0 && !isNew ? ( +
+ {t("otpManager.noEntries")} +
+ ) : null} +
+
+ + !open && setDeletingEntry(null)} + > + + + {t("otpManager.deleteTitle")} + + {t("otpManager.deleteConfirm", { name: deletingEntry?.issuer })} + + + + + + + + +
+ ); +} diff --git a/src/hooks/useOtpCode.ts b/src/hooks/useOtpCode.ts new file mode 100644 index 00000000..96a2f476 --- /dev/null +++ b/src/hooks/useOtpCode.ts @@ -0,0 +1,185 @@ +import { invoke } from "@tauri-apps/api/core"; +import { useCallback, useEffect, useRef, useState } from "react"; +import type { OtpCodeResult } from "@/types/global"; + +interface UseOtpCodeOptions { + autoStart?: boolean; + otpEntryId: string; + otpType?: string | null; + period?: number; +} + +interface UseOtpCodeResult { + code: string; + copied: boolean; + copyCode: () => void; + fetchCode: () => Promise; + hasCode: boolean; + isHotp: boolean; + isTotp: boolean; + loading: boolean; + progressPercent: number; + remaining: number; +} + +const COPY_FEEDBACK_MS = 2000; +const AUTO_FETCH_RESULT_TTL_MS = 1000; +const pendingAutoRequests = new Map>(); +const recentAutoResults = new Map(); + +async function invokeOtpCode(otpEntryId: string) { + return invoke("generate_otp_code", { id: otpEntryId }); +} + +async function requestOtpCodeWithAutoCache(otpEntryId: string, cacheKey: string) { + const now = Date.now(); + const recent = recentAutoResults.get(cacheKey); + if (recent && recent.expiresAt > now) { + return recent.result; + } + + const pending = pendingAutoRequests.get(cacheKey); + if (pending) { + return pending; + } + + const request = invokeOtpCode(otpEntryId) + .then((result) => { + recentAutoResults.set(cacheKey, { + expiresAt: Date.now() + AUTO_FETCH_RESULT_TTL_MS, + result, + }); + return result; + }) + .finally(() => { + pendingAutoRequests.delete(cacheKey); + }); + + pendingAutoRequests.set(cacheKey, request); + return request; +} + +export function useOtpCode({ + autoStart = true, + otpEntryId, + otpType, + period = 30, +}: UseOtpCodeOptions): UseOtpCodeResult { + const [code, setCode] = useState(""); + const [copied, setCopied] = useState(false); + const [loading, setLoading] = useState(false); + const [remaining, setRemaining] = useState(0); + const requestRef = useRef(0); + const copyTimerRef = useRef | null>(null); + + const normalizedType = otpType?.toLowerCase(); + const normalizedPeriod = period > 0 ? period : 30; + const isHotp = normalizedType === "hotp"; + const isTotp = normalizedType === "totp"; + const resetKey = `${otpEntryId}:${normalizedType ?? ""}:${normalizedPeriod}`; + const autoRequestKey = isTotp ? resetKey : ""; + + const clearCopyTimer = useCallback(() => { + if (copyTimerRef.current) { + clearTimeout(copyTimerRef.current); + copyTimerRef.current = null; + } + }, []); + + const fetchCodeInternal = useCallback( + async (useAutoCache = false) => { + if (!otpEntryId || !normalizedType) return; + + const requestId = ++requestRef.current; + setLoading(true); + + try { + const result = + useAutoCache && autoRequestKey + ? await requestOtpCodeWithAutoCache(otpEntryId, autoRequestKey) + : await invokeOtpCode(otpEntryId); + if (requestRef.current !== requestId) return; + + setCode(result.code); + setRemaining(isTotp ? result.remainingSeconds : 0); + } catch { + if (requestRef.current !== requestId) return; + setCode(""); + setRemaining(0); + } finally { + if (requestRef.current === requestId) { + setLoading(false); + } + } + }, + [autoRequestKey, isTotp, normalizedType, otpEntryId], + ); + + const fetchCode = useCallback(async () => { + await fetchCodeInternal(false); + }, [fetchCodeInternal]); + + const copyCode = useCallback(() => { + if (!code) return; + + navigator.clipboard.writeText(code).catch(() => {}); + setCopied(true); + clearCopyTimer(); + copyTimerRef.current = setTimeout(() => { + setCopied(false); + copyTimerRef.current = null; + }, COPY_FEEDBACK_MS); + }, [clearCopyTimer, code]); + + useEffect(() => { + void resetKey; + requestRef.current += 1; + setCode(""); + setCopied(false); + setLoading(false); + setRemaining(0); + clearCopyTimer(); + }, [clearCopyTimer, resetKey]); + + useEffect(() => { + if (!isTotp || !autoStart || loading || code) return; + void fetchCodeInternal(true); + }, [autoStart, code, fetchCodeInternal, isTotp, loading]); + + useEffect(() => { + if (!isTotp || !autoStart || remaining <= 0) return; + + const timer = setTimeout(() => { + setRemaining((prev) => { + if (prev <= 1) { + void fetchCode(); + return 0; + } + return prev - 1; + }); + }, 1000); + + return () => clearTimeout(timer); + }, [autoStart, fetchCode, isTotp, remaining]); + + useEffect( + () => () => { + requestRef.current += 1; + clearCopyTimer(); + }, + [clearCopyTimer], + ); + + return { + code, + copied, + copyCode, + fetchCode, + hasCode: Boolean(code), + isHotp, + isTotp, + loading, + progressPercent: isTotp ? Math.max(0, Math.min(100, (remaining / normalizedPeriod) * 100)) : 0, + remaining, + }; +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index ffafbd0c..955240f2 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -209,6 +209,10 @@ "advancedConfig": "Advanced Configuration", "proxySelect": "Proxy", "noProxy": "No Proxy", + "twoFactorAuth": "Two-Factor Authentication", + "selectOtp": "OTP Entry", + "noOtp": "None", + "autoFillOtp": "Auto-fill verification code", "connectionSaved": "Connection saved successfully!", "cancel": "Cancel", "save": "Save", @@ -686,7 +690,8 @@ "securityAuth": { "title": "Security Authentication", "passwords": "Passwords", - "keys": "Keys" + "keys": "Keys", + "otp": "OTP" }, "passwordManager": { "title": "Password Manager", @@ -711,7 +716,39 @@ "description": "\"{{name}}\" requires additional verification.", "submit": "Verify", "cancel": "Cancel", - "cancelled": "2FA authentication cancelled" + "cancelled": "2FA authentication cancelled", + "currentCode": "Current Code", + "sendToInput": "Send to Input", + "copyCode": "Copy Code", + "copied": "Copied", + "generateCode": "Generate Code", + "loadingCode": "Loading code", + "expiresIn": "{{seconds}}s remaining", + "hotpHint": "Generate on demand. The counter advances each time." + }, + "otpManager": { + "title": "OTP Entries", + "add": "Add", + "noEntries": "No OTP entries", + "issuerPlaceholder": "Issuer (e.g. GitHub)", + "usernamePlaceholder": "Username (e.g. user@example.com)", + "secretPlaceholder": "Secret (Base32)", + "secretUnchanged": "Secret unchanged", + "advanced": "Advanced Settings", + "algorithm": "Algorithm", + "digits": "Digits", + "period": "Period (seconds)", + "counter": "Counter", + "deleteTitle": "Delete OTP Entry", + "deleteConfirm": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.", + "view": "View", + "hide": "Hide", + "scanQr": "Scan QR", + "showCodes": "Show codes", + "hideCodes": "Hide codes", + "scanningQr": "Scanning...", + "qrImportFailed": "Failed to import from QR code", + "selectQrImage": "Select QR Code Image" } } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index f84722a3..1301dede 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -209,6 +209,10 @@ "advancedConfig": "高级配置", "proxySelect": "代理", "noProxy": "不使用代理", + "twoFactorAuth": "双因素认证", + "selectOtp": "OTP 条目", + "noOtp": "无", + "autoFillOtp": "自动填入验证码", "connectionSaved": "连接保存成功!", "cancel": "取消", "save": "保存", @@ -701,7 +705,8 @@ "securityAuth": { "title": "安全认证", "passwords": "密码", - "keys": "密钥" + "keys": "密钥", + "otp": "双因素" }, "passwordManager": { "title": "密码", @@ -726,6 +731,38 @@ "description": "\"{{name}}\" 需要额外的身份验证。", "submit": "验证", "cancel": "取消", - "cancelled": "双因素认证已取消" + "cancelled": "双因素认证已取消", + "currentCode": "当前验证码", + "sendToInput": "发送到输入框", + "copyCode": "复制验证码", + "copied": "已复制", + "generateCode": "生成验证码", + "loadingCode": "正在加载验证码", + "expiresIn": "剩余 {{seconds}} 秒", + "hotpHint": "按需生成验证码,每次生成都会推进计数器。" + }, + "otpManager": { + "title": "OTP 条目", + "add": "添加", + "noEntries": "暂无 OTP 条目", + "issuerPlaceholder": "发行方(如 GitHub)", + "usernamePlaceholder": "用户名(如 user@example.com)", + "secretPlaceholder": "密钥(Base32 编码)", + "secretUnchanged": "密钥未更改", + "advanced": "高级设置", + "algorithm": "算法", + "digits": "位数", + "period": "时间间隔(秒)", + "counter": "计数器", + "deleteTitle": "删除 OTP 条目", + "deleteConfirm": "确定要删除「{{name}}」吗?此操作无法撤销。", + "view": "查看", + "hide": "隐藏", + "scanQr": "扫描二维码", + "showCodes": "显示验证码", + "hideCodes": "隐藏验证码", + "scanningQr": "扫描中...", + "qrImportFailed": "从二维码导入失败", + "selectQrImage": "选择二维码图片" } } diff --git a/src/types/global.d.ts b/src/types/global.d.ts index b588f9b0..c3b5cca3 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -85,9 +85,38 @@ export interface SavedConnection { icon?: string; /** References a standalone proxy config by id. */ proxy_id?: string; + /** References an OTP entry for two-factor authentication. */ + otp_id?: string; + /** When true, auto-fill the OTP code during keyboard-interactive auth. */ + auto_fill_otp?: boolean; network?: ConnectionNetworkSettings; } +/** Stored OTP entry for two-factor authentication. */ +export interface OtpEntry { + id: string; + /** "totp" or "hotp". */ + otp_type: string; + issuer: string; + username: string; + /** Base32-encoded secret (only sent when creating/updating). */ + secret?: string; + algorithm: string; + digits: number; + /** Time step in seconds (TOTP only). */ + period: number; + /** Counter value (HOTP only). */ + counter: number; + /** True when encrypted secret data exists on disk. */ + has_secret?: boolean; +} + +/** Result of generating an OTP code. */ +export interface OtpCodeResult { + code: string; + remainingSeconds: number; +} + /** Saved tab state for startup restoration. */ export interface RestorableTab { title: string;