diff --git a/Cargo.lock b/Cargo.lock index 6d02a761f..85f07bbc9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,6 +24,41 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.8.12" @@ -111,9 +146,9 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" @@ -165,6 +200,18 @@ dependencies = [ "num-traits", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -384,6 +431,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.5.3" @@ -417,6 +470,15 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block" version = "0.1.6" @@ -569,9 +631,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.56" +version = "1.2.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" dependencies = [ "find-msvc-tools", "jobserver", @@ -627,6 +689,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[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.6.0" @@ -733,9 +805,9 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "com" @@ -1016,6 +1088,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -1025,6 +1098,15 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e162d0c2e2068eb736b71e5597eff0b9944e6b973cd9f37b6a288ab9bf20e300" +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "cursor-icon" version = "1.2.0" @@ -1105,6 +1187,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -1254,9 +1337,9 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "embed-resource" -version = "3.0.6" +version = "3.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55a075fc573c64510038d7ee9abc7990635863992f83ebc52c8b433b8411a02e" +checksum = "47ec73ddcf6b7f23173d5c3c5a32b5507dc0a734de7730aa14abc5d5e296bb5f" dependencies = [ "cc", "memchr", @@ -1765,6 +1848,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gif" version = "0.13.3" @@ -2476,6 +2569,15 @@ dependencies = [ "libc", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "instant" version = "0.1.13" @@ -3051,9 +3153,9 @@ dependencies = [ [[package]] name = "num_enum" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" dependencies = [ "num_enum_derive", "rustversion", @@ -3061,9 +3163,9 @@ dependencies = [ [[package]] name = "num_enum_derive" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -3396,6 +3498,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "open" version = "5.3.3" @@ -3624,6 +3732,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "paste" version = "1.0.15" @@ -3788,6 +3907,18 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -3877,6 +4008,25 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +[[package]] +name = "ql_auth" +version = "0.0.0" +dependencies = [ + "aes-gcm", + "argon2", + "base64", + "keyring", + "owo-colors", + "ql_core", + "rand 0.8.5", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "urlencoding", +] + [[package]] name = "ql_core" version = "0.0.0" @@ -3910,8 +4060,8 @@ dependencies = [ "cfg-if", "chrono", "indicatif", - "keyring", "owo-colors", + "ql_auth", "ql_core", "ql_java_handler", "reqwest", @@ -3920,7 +4070,6 @@ dependencies = [ "serde_json", "thiserror 2.0.18", "tokio", - "urlencoding", "zip", ] @@ -4017,6 +4166,7 @@ dependencies = [ "open", "owo-colors", "paste", + "ql_auth", "ql_core", "ql_instances", "ql_mod_manager", @@ -4024,6 +4174,7 @@ dependencies = [ "ql_servers", "rand 0.10.0", "rfd", + "rpassword", "semver", "serde", "serde_json", @@ -4137,6 +4288,8 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ + "libc", + "rand_chacha 0.3.1", "rand_core 0.6.4", ] @@ -4146,7 +4299,7 @@ version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ - "rand_chacha", + "rand_chacha 0.9.0", "rand_core 0.9.5", ] @@ -4161,6 +4314,16 @@ dependencies = [ "rand_core 0.10.0", ] +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -4176,6 +4339,9 @@ name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] [[package]] name = "rand_core" @@ -4435,6 +4601,27 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +[[package]] +name = "rpassword" +version = "7.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66d4c8b64f049c6721ec8ccec37ddfc3d641c4a7fca57e8f2a89de509c73df39" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.59.0", +] + +[[package]] +name = "rtoolbox" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7cc970b249fbe527d6e02e0a227762c9108b2f49d81094fe357ffc6d14d7f6f" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "rust-ini" version = "0.21.3" @@ -5397,9 +5584,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -5642,9 +5829,9 @@ checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "uds_windows" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51b70b87d15e91f553711b40df3048faf27a7a04e01e0ddc0cf9309f0af7c2ca" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", @@ -5744,6 +5931,16 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -7119,9 +7316,9 @@ dependencies = [ [[package]] name = "zbus" -version = "5.13.2" +version = "5.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfeff997a0aaa3eb20c4652baf788d2dfa6d2839a0ead0b3ff69ce2f9c4bdd1" +checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" dependencies = [ "async-broadcast", "async-executor", @@ -7155,9 +7352,9 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.13.2" +version = "5.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bbd5a90dbe8feee5b13def448427ae314ccd26a49cac47905cafefb9ff846f1" +checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -7328,9 +7525,9 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.9.2" +version = "5.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68b64ef4f40c7951337ddc7023dd03528a57a3ce3408ee9da5e948bd29b232c4" +checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" dependencies = [ "endi", "enumflags2", @@ -7343,9 +7540,9 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.9.2" +version = "5.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "484d5d975eb7afb52cc6b929c13d3719a20ad650fea4120e6310de3fc55e415c" +checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" dependencies = [ "proc-macro-crate", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index 19b298216..bfd00ea1b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ members = [ "tests", # Utility Libraries "crates/ql_java_handler", - "crates/ezshortcut", + "crates/ezshortcut", "crates/ql_auth", ] default-members = ["quantum_launcher"] resolver = "2" diff --git a/crates/ql_auth/Cargo.toml b/crates/ql_auth/Cargo.toml new file mode 100644 index 000000000..4161a40b8 --- /dev/null +++ b/crates/ql_auth/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "ql_auth" +version = "0.0.0" +edition = "2021" + +[dependencies] +ql_core.path = "../ql_core" + +thiserror.workspace = true +serde.workspace = true +serde_json.workspace = true +reqwest.workspace = true +tokio.workspace = true +owo-colors.workspace = true + +urlencoding = "2" + +# For generating keys +aes-gcm = "0.10" +argon2 = "0.5" +rand = "0.8" +base64 = "0.22" + +[target.'cfg(target_os = "windows")'.dependencies] +keyring = { version = "3", features = ["windows-native"] } +[target.'cfg(target_os = "macos")'.dependencies] +keyring = { version = "3", features = ["apple-native"] } +[target.'cfg(target_os = "linux")'.dependencies] +keyring = { version = "3", features = ["sync-secret-service", "vendored"] } +[target.'cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))'.dependencies] +keyring = { version = "3", features = ["sync-secret-service"] } diff --git a/crates/ql_instances/src/auth/alt.rs b/crates/ql_auth/src/alt.rs similarity index 94% rename from crates/ql_instances/src/auth/alt.rs rename to crates/ql_auth/src/alt.rs index 6732acb4d..a880056ae 100644 --- a/crates/ql_instances/src/auth/alt.rs +++ b/crates/ql_auth/src/alt.rs @@ -1,7 +1,7 @@ use ql_core::{JsonError, RequestError}; use serde::Deserialize; -use crate::auth::KeyringError; +use crate::{token_store::TokenStoreError, KeyringError}; use super::AccountData; @@ -30,6 +30,8 @@ pub enum Error { Response(#[from] AccountResponseError), #[error("{AUTH_ERR_PREFIX}{0}")] KeyringError(#[from] KeyringError), + #[error("{AUTH_ERR_PREFIX}{0}")] + TokenStore(#[from] TokenStoreError), #[error("{AUTH_ERR_PREFIX}Littleskin response:\n{0}")] LittleSkin(String), #[error("incorrect password entered (ely.by/littleskin account)")] diff --git a/crates/ql_instances/src/auth/authlib.rs b/crates/ql_auth/src/authlib.rs similarity index 100% rename from crates/ql_instances/src/auth/authlib.rs rename to crates/ql_auth/src/authlib.rs diff --git a/crates/ql_auth/src/encrypted_store.rs b/crates/ql_auth/src/encrypted_store.rs new file mode 100644 index 000000000..93f7ffaf3 --- /dev/null +++ b/crates/ql_auth/src/encrypted_store.rs @@ -0,0 +1,440 @@ +//! Encrypted file-based token storage. +//! +//! Provides an alternative to system keyring storage +//! by encrypting tokens using AES-256-GCM with a password-derived key. +//! +//! # Security +//! - Uses Argon2id for key derivation (resistant to GPU/ASIC attacks) +//! - AES-256-GCM for authenticated encryption +//! - Unique salt per encryption file +//! - Unique nonce per token entry +//! +//! # Portability +//! The encrypted file (`encrypted_tokens.json`) lives in the launcher +//! directory and can be copied to other machines along with `config.json`. +//! Unlocking with the same password on the other machine restores all accounts. + +use aes_gcm::{ + aead::{Aead, KeyInit, OsRng}, + Aes256Gcm, Nonce, +}; +use argon2::{password_hash::SaltString, Argon2, PasswordHasher}; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +use ql_core::{info, IntoIoError, IntoJsonError, IoError, JsonError, LAUNCHER_DIR}; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use std::{ + collections::HashMap, + io::ErrorKind, + path::PathBuf, + sync::{Arc, RwLock}, +}; + +/// The encrypted tokens file stored in the launcher directory. +const TOKENS_FILE: &str = "encrypted_tokens.json"; + +// Argon2 parameters for key derivation. +// Tuned for security while remaining reasonable on most hardware. +const ARGON2_M_COST: u32 = 65536; // 64 MB memory +const ARGON2_T_COST: u32 = 3; // 3 iterations +const ARGON2_P_COST: u32 = 4; // 4 parallel threads + +/// In-memory cache for decrypted tokens. +/// Populated after the user enters their password on startup. +static TOKEN_CACHE: std::sync::LazyLock>>> = + std::sync::LazyLock::new(|| Arc::new(RwLock::new(None))); + +/// Cached decrypted tokens and derived key for the session. +struct TokenCache { + /// The derived encryption key (kept for storing new tokens). + key: [u8; 32], + /// Decrypted tokens: storage_key -> refresh_token + tokens: HashMap, + /// The salt used for key derivation (needed for saving). + salt: String, +} + +/// A known string we encrypt to verify password correctness. +const VERIFICATION_PLAINTEXT: &str = "QuantumLauncher_PasswordVerification_v1"; + +/// The on-disk format for encrypted tokens. +#[derive(Serialize, Deserialize)] +struct EncryptedTokensFile { + /// Version of the file format (for future migrations). + version: u32, + /// Base64-encoded salt used for Argon2 key derivation. + salt: String, + /// Encrypted verification token to check password correctness. + verification: EncryptedToken, + /// Map of storage_key to encrypted token data. + tokens: HashMap, +} + +/// A single encrypted token entry. +#[derive(Serialize, Deserialize, Clone)] +struct EncryptedToken { + /// Base64-encoded 12-byte nonce for AES-GCM. + nonce: String, + /// Base64-encoded ciphertext (token + auth tag). + ciphertext: String, +} + +const ERR_PREFIX: &str = "while reading account data (from encrypted store):\n"; + +/// Errors that can occur during encrypted storage operations. +#[derive(Debug, thiserror::Error)] +pub enum EncryptedStoreError { + #[error("{ERR_PREFIX}{0}")] + Io(#[from] IoError), + #[error("{ERR_PREFIX}{0}")] + Json(#[from] JsonError), + + #[error("Encrypted token store (containing account data) is locked.\nPlease enter your password to unlock.")] + NotUnlocked, + #[error("{ERR_PREFIX}Encrypted tokens file not found. Please set up encrypted storage first.")] + FileNotFound, + #[error("{ERR_PREFIX}Invalid password. Please try again.")] + InvalidPassword, + #[error("{ERR_PREFIX}No token found for user: {0}")] + TokenNotFound(String), + + #[error("{ERR_PREFIX}Encryption/decryption failed: {0}")] + Encryption(String), + + #[error("{ERR_PREFIX}Base64 decode error: {0}")] + Base64(#[from] base64::DecodeError), +} + +/// Get the path to the encrypted tokens file. +fn get_tokens_path() -> PathBuf { + LAUNCHER_DIR.join(TOKENS_FILE) +} + +/// Check if the encrypted tokens file exists. +#[must_use] +pub fn file_exists() -> bool { + get_tokens_path().exists() +} + +/// Check if the encrypted store is currently unlocked (password has been entered). +#[must_use] +pub fn is_unlocked() -> bool { + TOKEN_CACHE + .read() + .map(|cache| cache.is_some()) + .unwrap_or(false) +} + +/// Derive an encryption key from a password using Argon2id. +fn derive_key(password: &str, salt: &SaltString) -> Result<[u8; 32], EncryptedStoreError> { + let argon2 = Argon2::new( + argon2::Algorithm::Argon2id, + argon2::Version::V0x13, + argon2::Params::new(ARGON2_M_COST, ARGON2_T_COST, ARGON2_P_COST, Some(32)) + .map_err(|e| EncryptedStoreError::Encryption(e.to_string()))?, + ); + + let hash = argon2 + .hash_password(password.as_bytes(), salt) + .map_err(|e| EncryptedStoreError::Encryption(e.to_string()))?; + + let hash_bytes = hash + .hash + .ok_or_else(|| EncryptedStoreError::Encryption("Failed to get hash output".to_string()))?; + + let mut key = [0u8; 32]; + key.copy_from_slice(hash_bytes.as_bytes()); + Ok(key) +} + +/// Initialize a new encrypted tokens file with the given password. +/// +/// Creates a new file with a fresh salt. Call when the user first sets up +/// encrypted storage or wants to change their password. +pub fn initialize_new(password: &str) -> Result<(), EncryptedStoreError> { + info!("Initializing new encrypted token store..."); + + let salt = SaltString::generate(&mut OsRng); + let key = derive_key(password, &salt)?; + + let cipher = Aes256Gcm::new_from_slice(&key) + .map_err(|e| EncryptedStoreError::Encryption(e.to_string()))?; + + let mut nonce_bytes = [0u8; 12]; + OsRng.fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + + let ciphertext = cipher + .encrypt(nonce, VERIFICATION_PLAINTEXT.as_bytes()) + .map_err(|e| EncryptedStoreError::Encryption(e.to_string()))?; + + let verification = EncryptedToken { + nonce: BASE64.encode(nonce_bytes), + ciphertext: BASE64.encode(ciphertext), + }; + + let file = EncryptedTokensFile { + version: 1, + salt: salt.to_string(), + verification, + tokens: HashMap::new(), + }; + + let json = serde_json::to_string_pretty(&file).json_to()?; + let path = get_tokens_path(); + std::fs::write(&path, json).path(&path)?; + + let mut cache = TOKEN_CACHE.write().map_err(|e| { + EncryptedStoreError::Encryption(format!("Failed to acquire cache lock: {e}")) + })?; + *cache = Some(TokenCache { + key, + tokens: HashMap::new(), + salt: salt.to_string(), + }); + + info!("Encrypted token store initialized successfully"); + Ok(()) +} + +/// Unlock the encrypted store with the given password. +/// +/// Decrypts all tokens and caches them in memory for the session. +/// Must be called before any token operations when using encrypted storage. +pub fn unlock(password: &str) -> Result<(), EncryptedStoreError> { + info!(no_log, "Unlocking encrypted token store..."); + + let file = read_json_file()?; + + let salt = SaltString::from_b64(&file.salt) + .map_err(|e| EncryptedStoreError::Encryption(format!("Invalid salt: {e}")))?; + let key = derive_key(password, &salt)?; + + let cipher = Aes256Gcm::new_from_slice(&key) + .map_err(|e| EncryptedStoreError::Encryption(e.to_string()))?; + + // Verify password by decrypting the verification token + let verify_nonce_bytes = BASE64.decode(&file.verification.nonce)?; + let verify_ciphertext = BASE64.decode(&file.verification.ciphertext)?; + let verify_nonce = Nonce::from_slice(&verify_nonce_bytes); + + let verify_plaintext = cipher + .decrypt(verify_nonce, verify_ciphertext.as_ref()) + .map_err(|_| { + ql_core::err!(no_log, "Wrong passphrase! Please try again."); + EncryptedStoreError::InvalidPassword + })?; + + if verify_plaintext != VERIFICATION_PLAINTEXT.as_bytes() { + ql_core::err!(no_log, "Wrong passphrase! Please try again."); + return Err(EncryptedStoreError::InvalidPassword); + } + + // Decrypt all tokens + let mut decrypted_tokens = HashMap::new(); + for (storage_key, encrypted) in &file.tokens { + let nonce_bytes = BASE64.decode(&encrypted.nonce)?; + let ciphertext = BASE64.decode(&encrypted.ciphertext)?; + let nonce = Nonce::from_slice(&nonce_bytes); + + let plaintext = cipher + .decrypt(nonce, ciphertext.as_ref()) + .map_err(|_| EncryptedStoreError::InvalidPassword)?; + + let token = String::from_utf8(plaintext) + .map_err(|e| EncryptedStoreError::Encryption(format!("Invalid UTF-8: {e}")))?; + decrypted_tokens.insert(storage_key.clone(), token); + } + + let mut cache = TOKEN_CACHE.write().map_err(|e| { + EncryptedStoreError::Encryption(format!("Failed to acquire cache lock: {e}")) + })?; + *cache = Some(TokenCache { + key, + tokens: decrypted_tokens, + salt: file.salt, + }); + + info!(no_log, "Encrypted token store unlocked successfully"); + Ok(()) +} + +fn read_json_file() -> Result { + let path = get_tokens_path(); + let content = match std::fs::read_to_string(&path) { + Ok(n) => n, + Err(err) if err.kind() == ErrorKind::NotFound => { + return Err(EncryptedStoreError::FileNotFound); + } + Err(err) => { + return Err(IoError::Io { error: err, path }.into()); + } + }; + let file: EncryptedTokensFile = serde_json::from_str(&content).json(content)?; + Ok(file) +} + +/// Lock the encrypted store (clear the in-memory cache). +pub fn lock() { + if let Ok(mut cache) = TOKEN_CACHE.write() { + *cache = None; + } +} + +/// Delete the encrypted store file from disk. +/// +/// The in-memory cache should be cleared separately by calling [`lock`]. +pub fn delete_store() -> Result<(), EncryptedStoreError> { + let path = get_tokens_path(); + if path.exists() { + std::fs::remove_file(&path).path(&path)?; + info!("Encrypted token store deleted"); + } + Ok(()) +} + +/// Store a token for the given storage key. +/// +/// The store must be unlocked first. +pub fn store_token(storage_key: &str, token: &str) -> Result<(), EncryptedStoreError> { + let mut cache_guard = TOKEN_CACHE.write().map_err(|e| { + EncryptedStoreError::Encryption(format!("Failed to acquire cache lock: {e}")) + })?; + + let cache = cache_guard + .as_mut() + .ok_or(EncryptedStoreError::NotUnlocked)?; + + cache + .tokens + .insert(storage_key.to_string(), token.to_string()); + save_to_disk(&cache.key, &cache.salt, &cache.tokens)?; + Ok(()) +} + +/// Read a token for the given storage key. +/// +/// The store must be unlocked first. +pub fn read_token(storage_key: &str) -> Result { + let cache_guard = TOKEN_CACHE.read().map_err(|e| { + EncryptedStoreError::Encryption(format!("Failed to acquire cache lock: {e}")) + })?; + + let cache = cache_guard + .as_ref() + .ok_or(EncryptedStoreError::NotUnlocked)?; + + cache + .tokens + .get(storage_key) + .cloned() + .ok_or_else(|| EncryptedStoreError::TokenNotFound(storage_key.to_string())) +} + +/// Delete a token for the given storage key. +/// +/// The store must be unlocked first. +pub fn delete_token(storage_key: &str) -> Result<(), EncryptedStoreError> { + let mut cache_guard = TOKEN_CACHE.write().map_err(|e| { + EncryptedStoreError::Encryption(format!("Failed to acquire cache lock: {e}")) + })?; + + let cache = cache_guard + .as_mut() + .ok_or(EncryptedStoreError::NotUnlocked)?; + + cache.tokens.remove(storage_key); + save_to_disk(&cache.key, &cache.salt, &cache.tokens)?; + Ok(()) +} + +/// Change the password for the encrypted store. +/// +/// Re-encrypts all tokens with a new key derived from the new password. +fn change_password(old_password: &str, new_password: &str) -> Result<(), EncryptedStoreError> { + if !is_unlocked() { + unlock(old_password)?; + } + + let cache_guard = TOKEN_CACHE.read().map_err(|e| { + EncryptedStoreError::Encryption(format!("Failed to acquire cache lock: {e}")) + })?; + + let old_cache = cache_guard + .as_ref() + .ok_or(EncryptedStoreError::NotUnlocked)?; + let tokens = old_cache.tokens.clone(); + drop(cache_guard); + + let new_salt = SaltString::generate(&mut OsRng); + let new_key = derive_key(new_password, &new_salt)?; + + save_to_disk(&new_key, new_salt.as_str(), &tokens)?; + + let mut cache_guard = TOKEN_CACHE.write().map_err(|e| { + EncryptedStoreError::Encryption(format!("Failed to acquire cache lock: {e}")) + })?; + *cache_guard = Some(TokenCache { + key: new_key, + tokens, + salt: new_salt.to_string(), + }); + + info!("Password changed successfully"); + Ok(()) +} + +/// Save all tokens to disk (called after any modification). +fn save_to_disk( + key: &[u8; 32], + salt: &str, + tokens: &HashMap, +) -> Result<(), EncryptedStoreError> { + let cipher = Aes256Gcm::new_from_slice(key) + .map_err(|e| EncryptedStoreError::Encryption(e.to_string()))?; + + // Create a fresh verification token (re-encrypted each save) + let mut verify_nonce_bytes = [0u8; 12]; + OsRng.fill_bytes(&mut verify_nonce_bytes); + let verify_nonce = Nonce::from_slice(&verify_nonce_bytes); + + let verify_ciphertext = cipher + .encrypt(verify_nonce, VERIFICATION_PLAINTEXT.as_bytes()) + .map_err(|e| EncryptedStoreError::Encryption(e.to_string()))?; + + let verification = EncryptedToken { + nonce: BASE64.encode(verify_nonce_bytes), + ciphertext: BASE64.encode(verify_ciphertext), + }; + + let mut encrypted_tokens = HashMap::new(); + for (storage_key, token) in tokens { + let mut nonce_bytes = [0u8; 12]; + OsRng.fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + + let ciphertext = cipher + .encrypt(nonce, token.as_bytes()) + .map_err(|e| EncryptedStoreError::Encryption(e.to_string()))?; + + encrypted_tokens.insert( + storage_key.clone(), + EncryptedToken { + nonce: BASE64.encode(nonce_bytes), + ciphertext: BASE64.encode(ciphertext), + }, + ); + } + + let file = EncryptedTokensFile { + version: 1, + salt: salt.to_string(), + verification, + tokens: encrypted_tokens, + }; + + let json = serde_json::to_string_pretty(&file).json_to()?; + let path = get_tokens_path(); + std::fs::write(&path, json).path(&path)?; + Ok(()) +} diff --git a/crates/ql_instances/src/auth/mod.rs b/crates/ql_auth/src/lib.rs similarity index 84% rename from crates/ql_instances/src/auth/mod.rs rename to crates/ql_auth/src/lib.rs index ecb1ea438..f557d7d3d 100644 --- a/crates/ql_instances/src/auth/mod.rs +++ b/crates/ql_auth/src/lib.rs @@ -1,12 +1,16 @@ -use ql_core::{IntoStringError, err}; use serde::{Deserialize, Serialize}; use std::fmt::Display; mod alt; pub mod authlib; +pub mod encrypted_store; pub mod ms; +pub mod token_store; pub mod yggdrasil; pub use authlib::get_authlib_injector; +pub use token_store::{ + logout, logout_from, read_refresh_token, read_refresh_token_from, TokenStorageMethod, +}; #[derive(Debug, Clone)] pub struct AccountData { @@ -37,7 +41,7 @@ impl AccountData { } } -#[derive(Serialize, Deserialize, Default, Debug, Clone, Copy)] +#[derive(Serialize, Deserialize, Default, Debug, Clone, Copy, PartialEq, Eq)] pub enum AccountType { ElyBy, LittleSkin, @@ -88,17 +92,22 @@ impl AccountType { fn get_keyring_entry(self, username: &str) -> Result { Ok(keyring::Entry::new( "QuantumLauncher", - &format!( - "{username}{}", - match self { - AccountType::Microsoft => "", - AccountType::ElyBy => "#elyby", - AccountType::LittleSkin => "#littleskin", - } - ), + &self.create_storage_key(username), )?) } + #[must_use] + pub fn create_storage_key(self, username: &str) -> String { + format!( + "{username}{}", + match self { + AccountType::Microsoft => "", + AccountType::ElyBy => "#elyby", + AccountType::LittleSkin => "#littleskin", + } + ) + } + #[must_use] pub(crate) fn get_client_id(self) -> &'static str { match self { @@ -182,20 +191,3 @@ Now after this, in the sidebar, right click it and click "Set as Default""# } } } - -pub fn read_refresh_token( - username: &str, - account_type: AccountType, -) -> Result { - let entry = account_type.get_keyring_entry(username)?; - let refresh_token = entry.get_password()?; - Ok(refresh_token) -} - -pub fn logout(username: &str, account_type: AccountType) -> Result<(), String> { - let entry = account_type.get_keyring_entry(username).strerr()?; - if let Err(err) = entry.delete_credential() { - err!("Couldn't remove {account_type} account credential (Username: {username}):\n{err}"); - } - Ok(()) -} diff --git a/crates/ql_instances/src/auth/ms.rs b/crates/ql_auth/src/ms.rs similarity index 96% rename from crates/ql_instances/src/auth/ms.rs rename to crates/ql_auth/src/ms.rs index 928073e9f..7217c5290 100644 --- a/crates/ql_instances/src/auth/ms.rs +++ b/crates/ql_auth/src/ms.rs @@ -17,7 +17,7 @@ //! //! ```no_run //! # async fn do1() -> Result<(), Box> { -//! use ql_instances::auth::ms::login_1_link; +//! use ql_auth::ms::login_1_link; //! let auth_code_response = login_1_link().await?; //! // AuthCodeResponse { verification_uri, user_code, .. } //! # Ok(()) } @@ -30,7 +30,7 @@ //! ```no_run //! # async fn do2() -> Result<(), Box> { //! # // Default construction -//! # let auth_code_response = ql_instances::auth::ms::AuthCodeResponse { +//! # let auth_code_response = ql_auth::ms::AuthCodeResponse { //! # user_code: String::new(), //! # device_code: String::new(), //! # verification_uri: String::new(), @@ -38,8 +38,8 @@ //! # interval: 0, //! # message: String::new(), //! # }; -//! use ql_instances::auth::ms::login_3_xbox; -//! use ql_instances::auth::ms::login_2_wait; +//! use ql_auth::ms::login_3_xbox; +//! use ql_auth::ms::login_2_wait; //! //! let auth_token_response = login_2_wait(auth_code_response).await?; //! // AuthTokenResponse { access_token, refresh_token } @@ -60,7 +60,7 @@ //! # async fn do3() -> Result<(), Box> { //! # let username = String::new(); //! # let refresh_token = String::new(); -//! use ql_instances::auth::ms::login_refresh; +//! use ql_auth::ms::login_refresh; //! let account_data = login_refresh(username, refresh_token, None).await?; //! # Ok(()) } //! ``` @@ -71,9 +71,10 @@ use serde::Deserialize; use serde_json::json; use std::collections::HashMap; -use crate::auth::AccountType; +use crate::AccountType; -use super::{AccountData, KeyringError}; +use super::token_store::TokenStoreError; +use super::{token_store, AccountData, KeyringError}; /// The API key for logging into Minecraft. /// @@ -183,6 +184,8 @@ pub enum Error { #[error("{AUTH_ERR_PREFIX}{0}")] KeyringError(#[from] KeyringError), #[error("{AUTH_ERR_PREFIX}{0}")] + TokenStore(#[from] TokenStoreError), + #[error("{AUTH_ERR_PREFIX}{0}")] Response(MsaResponseError), #[error( @@ -239,8 +242,7 @@ pub async fn login_refresh( let data: RefreshResponse = serde_json::from_str(&response).json(response)?; - let entry = keyring::Entry::new("QuantumLauncher", &username)?; - entry.set_password(&data.refresh_token)?; + token_store::store_token(&username, AccountType::Microsoft, &data.refresh_token)?; let data = login_3_xbox( AuthTokenResponse { @@ -304,8 +306,11 @@ pub async fn login_3_xbox( } } - let entry = keyring::Entry::new("QuantumLauncher", &final_details.name)?; - entry.set_password(&data.refresh_token)?; + token_store::store_token( + &final_details.name, + AccountType::Microsoft, + &data.refresh_token, + )?; let data = AccountData { access_token: Some(minecraft.access_token), diff --git a/crates/ql_auth/src/token_store.rs b/crates/ql_auth/src/token_store.rs new file mode 100644 index 000000000..ab43fcbed --- /dev/null +++ b/crates/ql_auth/src/token_store.rs @@ -0,0 +1,181 @@ +//! Abstraction layer for token storage backends. +//! +//! Routes between system keyring and the encrypted file backend. +//! The global `STORAGE_METHOD` tracks which backend is currently active. + +use std::sync::atomic::{AtomicU8, Ordering}; + +use super::{ + encrypted_store::{self, EncryptedStoreError}, + AccountType, KeyringError, +}; + +use ql_core::{err, IntoStringError}; + +/// Global storage method selector. +/// 0 = Keyring, 1 = EncryptedFile +static STORAGE_METHOD: AtomicU8 = AtomicU8::new(0); + +/// Which backend to use for token storage. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum TokenStorageMethod { + Keyring = 0, + EncryptedFile = 1, +} + +impl TokenStorageMethod { + #[must_use] + pub fn from_u8(value: u8) -> Self { + match value { + 1 => TokenStorageMethod::EncryptedFile, + _ => TokenStorageMethod::Keyring, + } + } +} + +impl std::fmt::Display for TokenStorageMethod { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TokenStorageMethod::Keyring => write!(f, "System Keyring"), + TokenStorageMethod::EncryptedFile => write!(f, "Encrypted File"), + } + } +} + +/// Set the global storage method. +pub fn set_storage_method(method: TokenStorageMethod) { + STORAGE_METHOD.store(method as u8, Ordering::Relaxed); +} + +/// Get the current global storage method. +#[must_use] +pub fn get_storage_method() -> TokenStorageMethod { + TokenStorageMethod::from_u8(STORAGE_METHOD.load(Ordering::Relaxed)) +} + +/// Check if the system keyring is available and functional. +/// Especially useful on Linux to detect missing secret-service. +#[must_use] +pub fn is_keyring_available() -> bool { + let entry = match keyring::Entry::new("QuantumLauncher", "availability_test") { + Ok(e) => e, + Err(_) => return false, + }; + + // To accurately check if the keyring is functional and unlocked (or unlockable), + // we attempt to set a dummy password. This forces a check of the underlying + // storage subsystem and will trigger an unlock prompt if necessary. + // Since this result is usually cached by the caller, it's safe to do once. + if entry.set_password("availability_test").is_err() { + return false; + } + + // Clean up the test entry + _ = entry.delete_credential(); + true +} + +/// Errors from the token store abstraction layer. +#[derive(Debug, thiserror::Error)] +pub enum TokenStoreError { + #[error("Keyring error: {0}")] + Keyring(#[from] KeyringError), + #[error("{0}")] + EncryptedStore(#[from] EncryptedStoreError), +} + +/// Store a token using the *currently active* backend. +pub fn store_token( + username: &str, + account_type: AccountType, + token: &str, +) -> Result<(), TokenStoreError> { + store_token_with(username, account_type, token, get_storage_method()) +} + +/// Store a token using a *specific* backend (ignores global method). +pub fn store_token_with( + username: &str, + account_type: AccountType, + token: &str, + method: TokenStorageMethod, +) -> Result<(), TokenStoreError> { + match method { + TokenStorageMethod::Keyring => { + let entry = account_type.get_keyring_entry(username)?; + entry.set_password(token).map_err(KeyringError)?; + Ok(()) + } + TokenStorageMethod::EncryptedFile => { + let key = account_type.create_storage_key(username); + encrypted_store::store_token(&key, token)?; + Ok(()) + } + } +} + +fn read_refresh_token_keyring( + username: &str, + account_type: AccountType, +) -> Result { + let entry = account_type.get_keyring_entry(username)?; + let refresh_token = entry.get_password()?; + Ok(refresh_token) +} + +/// Read a token using the *currently active* backend. +pub fn read_refresh_token( + username: &str, + account_type: AccountType, +) -> Result { + read_refresh_token_from(username, account_type, get_storage_method()) +} + +/// Read a token using a *specific* backend (ignores global method). +pub fn read_refresh_token_from( + username: &str, + account_type: AccountType, + method: TokenStorageMethod, +) -> Result { + match method { + TokenStorageMethod::Keyring => Ok(read_refresh_token_keyring(username, account_type)?), + TokenStorageMethod::EncryptedFile => { + let key = account_type.create_storage_key(username); + let token = encrypted_store::read_token(&key)?; + Ok(token) + } + } +} + +fn logout_keyring(username: &str, account_type: AccountType) -> Result<(), String> { + let entry = account_type.get_keyring_entry(username).strerr()?; + if let Err(err) = entry.delete_credential() { + err!("Couldn't remove {account_type} account credential (Username: {username}):\n{err}"); + } + Ok(()) +} + +/// Delete a token using the *currently active* backend. +pub fn logout(username: &str, account_type: AccountType) -> Result<(), TokenStoreError> { + logout_from(username, account_type, get_storage_method()) +} + +/// Delete a token using a *specific* backend. +pub fn logout_from( + username: &str, + account_type: AccountType, + method: TokenStorageMethod, +) -> Result<(), TokenStoreError> { + match method { + TokenStorageMethod::Keyring => { + logout_keyring(username, account_type) + .map_err(|_| TokenStoreError::Keyring(KeyringError(keyring::Error::NoEntry)))?; + Ok(()) + } + TokenStorageMethod::EncryptedFile => { + let key = account_type.create_storage_key(username); + encrypted_store::delete_token(&key)?; + Ok(()) + } + } +} diff --git a/crates/ql_instances/src/auth/yggdrasil/mod.rs b/crates/ql_auth/src/yggdrasil/mod.rs similarity index 87% rename from crates/ql_instances/src/auth/yggdrasil/mod.rs rename to crates/ql_auth/src/yggdrasil/mod.rs index fd4ed610d..903337f62 100644 --- a/crates/ql_instances/src/auth/yggdrasil/mod.rs +++ b/crates/ql_auth/src/yggdrasil/mod.rs @@ -1,7 +1,7 @@ -use crate::auth::alt::AccountResponse; +use crate::alt::AccountResponse; -use super::{AccountData, AccountType}; -use ql_core::{CLIENT, IntoJsonError, info, pt}; +use super::{token_store, AccountData, AccountType}; +use ql_core::{info, pt, IntoJsonError, CLIENT}; pub use super::alt::{Account, AccountResponseError, Error}; use ql_core::request::check_for_success; @@ -59,8 +59,12 @@ pub async fn login_new( } }; - let entry = account_type.get_keyring_entry(&email_or_username)?; - entry.set_password(&account_response.accessToken)?; + token_store::store_token_with( + &email_or_username, + account_type, + &account_response.accessToken, + token_store::get_storage_method(), + )?; Ok(Account::Account(AccountData { access_token: Some(account_response.accessToken.clone()), @@ -89,7 +93,6 @@ pub async fn login_refresh( account_type: AccountType, ) -> Result { pt!("Refreshing {account_type} account..."); - let entry = account_type.get_keyring_entry(&email_or_username)?; let mut value = serde_json::json!({ "accessToken": refresh_token, @@ -105,7 +108,12 @@ pub async fn login_refresh( let text = response.text().await?; let account_response = serde_json::from_str::(&text).json(text.clone())?; - entry.set_password(&account_response.accessToken)?; + token_store::store_token_with( + &email_or_username, + account_type, + &account_response.accessToken, + token_store::get_storage_method(), + )?; Ok(AccountData { access_token: Some(account_response.accessToken.clone()), diff --git a/crates/ql_instances/src/auth/yggdrasil/oauth.rs b/crates/ql_auth/src/yggdrasil/oauth.rs similarity index 95% rename from crates/ql_instances/src/auth/yggdrasil/oauth.rs rename to crates/ql_auth/src/yggdrasil/oauth.rs index ab4990188..26f9f63ff 100644 --- a/crates/ql_instances/src/auth/yggdrasil/oauth.rs +++ b/crates/ql_auth/src/yggdrasil/oauth.rs @@ -1,5 +1,4 @@ -use crate::auth::alt::OauthError; -use keyring; +use crate::{alt::OauthError, token_store, AccountType}; use ql_core::request::check_for_success; use ql_core::{CLIENT, IntoJsonError}; use serde::{Deserialize, Serialize}; @@ -96,12 +95,13 @@ pub async fn poll_device_token( } } - // Store Minecraft token in keyring (same convention as password flow) - keyring::Entry::new( - "QuantumLauncher", - &format!("{}#littleskin", user_info.username), - ) - .and_then(|e| e.set_password(&mc_token_resp.access_token))?; + // Store Minecraft token via token_store (respects active backend: keyring or encrypted file) + token_store::store_token_with( + &user_info.username, + AccountType::LittleSkin, + &mc_token_resp.access_token, + token_store::get_storage_method(), + )?; // Build account data compatible with existing flows Ok(super::Account::Account(super::AccountData { @@ -118,7 +118,7 @@ pub async fn poll_device_token( .map_or_else(|| user_info.username.clone(), |p| p.name.clone()), refresh_token: mc_token_resp.access_token, needs_refresh: false, - account_type: crate::auth::AccountType::LittleSkin, + account_type: AccountType::LittleSkin, })) } diff --git a/crates/ql_instances/Cargo.toml b/crates/ql_instances/Cargo.toml index 08c5782f1..3fa7531c1 100644 --- a/crates/ql_instances/Cargo.toml +++ b/crates/ql_instances/Cargo.toml @@ -14,6 +14,7 @@ simulate_macos_arm64 = ["ql_core/simulate_macos_arm64", "ql_java_handler/simulat [dependencies] ql_core.path = "../ql_core" ql_java_handler.path = "../ql_java_handler" +ql_auth.path = "../ql_auth" chrono.workspace = true semver.workspace = true @@ -29,14 +30,3 @@ serde_json.workspace = true owo-colors.workspace = true thiserror.workspace = true indicatif = "0.18.*" - -urlencoding = "2" - -[target.'cfg(target_os = "windows")'.dependencies] -keyring = { version = "3", features = ["windows-native"] } -[target.'cfg(target_os = "macos")'.dependencies] -keyring = { version = "3", features = ["apple-native"] } -[target.'cfg(target_os = "linux")'.dependencies] -keyring = { version = "3", features = ["sync-secret-service", "vendored"] } -[target.'cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))'.dependencies] -keyring = { version = "3", features = ["sync-secret-service"] } diff --git a/crates/ql_instances/src/instance/launch/error.rs b/crates/ql_instances/src/instance/launch/error.rs index a8abd8ed5..376736d76 100644 --- a/crates/ql_instances/src/instance/launch/error.rs +++ b/crates/ql_instances/src/instance/launch/error.rs @@ -42,7 +42,7 @@ pub enum GameLaunchError { JarMod(#[from] JarModError), #[error("{GAME_ERR_PREFIX}{0}")] - MsAuth(#[from] crate::auth::ms::Error), + MsAuth(#[from] ql_auth::ms::Error), #[error( "{GAME_ERR_PREFIX}Microsoft account token was not loaded\n\nTry logging out of your account and logging back in" )] diff --git a/crates/ql_instances/src/instance/launch/launcher.rs b/crates/ql_instances/src/instance/launch/launcher.rs index ce3749adb..a55238d25 100644 --- a/crates/ql_instances/src/instance/launch/launcher.rs +++ b/crates/ql_instances/src/instance/launch/launcher.rs @@ -1,8 +1,5 @@ -use crate::{ - auth::{AccountData, AccountType, ms::CLIENT_ID}, - download::GameDownloader, - jarmod, -}; +use crate::{download::GameDownloader, jarmod}; +use ql_auth::{ms::CLIENT_ID, AccountData, AccountType}; use ql_core::{ CLASSPATH_SEPARATOR, GenericProgress, Instance, IntoIoError, IntoJsonError, IoError, JsonFileError, LAUNCHER_DIR, Loader, err, @@ -267,7 +264,7 @@ impl GameLauncher { args.push("-Dminecraft.api.session.host=https://nope.invalid".to_owned()); args.push("-Dminecraft.api.services.host=https://nope.invalid".to_owned()); } else if let Some(authlib) = auth.and_then(AccountData::get_authlib_url) { - args.push(crate::auth::get_authlib_injector(authlib).await?); + args.push(ql_auth::get_authlib_injector(authlib).await?); } if cfg!(target_pointer_width = "32") { diff --git a/crates/ql_instances/src/instance/launch/mod.rs b/crates/ql_instances/src/instance/launch/mod.rs index 6d0f86e5e..ffba79564 100644 --- a/crates/ql_instances/src/instance/launch/mod.rs +++ b/crates/ql_instances/src/instance/launch/mod.rs @@ -1,5 +1,5 @@ -use crate::auth::AccountData; use error::GameLaunchError; +use ql_auth::AccountData; use ql_core::{GenericProgress, Instance, LaunchedProcess, REDACT_SENSITIVE_INFO, err, info}; use std::sync::{Arc, mpsc::Sender}; use tokio::sync::Mutex; diff --git a/crates/ql_instances/src/lib.rs b/crates/ql_instances/src/lib.rs index 766a34a4c..2ab0643c2 100644 --- a/crates/ql_instances/src/lib.rs +++ b/crates/ql_instances/src/lib.rs @@ -50,7 +50,6 @@ #![allow(clippy::cast_possible_truncation)] #![allow(clippy::cast_sign_loss)] -pub mod auth; mod download; mod instance; mod json_profiles; diff --git a/quantum_launcher/Cargo.toml b/quantum_launcher/Cargo.toml index 50e4ea9cb..e4535df8b 100644 --- a/quantum_launcher/Cargo.toml +++ b/quantum_launcher/Cargo.toml @@ -38,6 +38,7 @@ ql_mod_manager.path = "../crates/ql_mod_manager" ql_core.path = "../crates/ql_core" ql_servers.path = "../crates/ql_servers" ql_packager.path = "../crates/ql_packager" +ql_auth.path = "../crates/ql_auth" ezshortcut.path = "../crates/ezshortcut" # For the GUI @@ -67,6 +68,7 @@ zip.workspace = true owo-colors.workspace = true clap.workspace = true terminal_size = "0.4" +rpassword = "7" # OS APIs notify = "8" diff --git a/quantum_launcher/src/cli/account.rs b/quantum_launcher/src/cli/account.rs index ec2b7a517..26b7ab9ed 100644 --- a/quantum_launcher/src/cli/account.rs +++ b/quantum_launcher/src/cli/account.rs @@ -1,8 +1,11 @@ use owo_colors::OwoColorize; -use std::process::exit; +use std::{ + io::{self, IsTerminal}, + process::exit, +}; +use ql_auth::{AccountType, TokenStorageMethod}; use ql_core::err; -use ql_instances::auth::{self, AccountType}; use crate::{ cli::show_notification, @@ -14,7 +17,7 @@ pub async fn refresh_account( use_account: bool, show_progress: bool, override_account_type: Option<&str>, -) -> Result, Box> { +) -> Result, Box> { if !use_account { if show_progress { tokio::task::spawn_blocking(|| { @@ -30,6 +33,10 @@ pub async fn refresh_account( exit(1); }; let refresh_name = account.get_keyring_identifier(username); + let method = account.c_token_storage(); + + ql_auth::token_store::set_storage_method(method); + unlock_encrypted_store_if_needed(method)?; if show_progress { tokio::task::spawn_blocking(|| { @@ -37,22 +44,64 @@ pub async fn refresh_account( }); } - let refresh_token = - auth::read_refresh_token(refresh_name, account.account_type.unwrap_or_default())?; + let refresh_token = ql_auth::token_store::read_refresh_token_from( + refresh_name, + account.account_type.unwrap_or_default(), + method, + )?; // Hook: Account types let account = if let Some(account_type @ (AccountType::ElyBy | AccountType::LittleSkin)) = account.account_type { - auth::yggdrasil::login_refresh(refresh_name.to_owned(), refresh_token, account_type).await? + ql_auth::yggdrasil::login_refresh(refresh_name.to_owned(), refresh_token, account_type) + .await? } else { - let refresh_token = auth::read_refresh_token(username, AccountType::Microsoft)?; - auth::ms::login_refresh(username.clone(), refresh_token, None).await? + let refresh_token = ql_auth::token_store::read_refresh_token_from( + username, + AccountType::Microsoft, + method, + )?; + ql_auth::ms::login_refresh(username.clone(), refresh_token, None).await? }; Ok(Some(account)) } +fn unlock_encrypted_store_if_needed( + method: TokenStorageMethod, +) -> Result<(), Box> { + if method != TokenStorageMethod::EncryptedFile { + return Ok(()); + } + if ql_auth::encrypted_store::is_unlocked() { + return Ok(()); + } + if !ql_auth::encrypted_store::file_exists() { + return Err(ql_auth::encrypted_store::EncryptedStoreError::FileNotFound.into()); + } + + if let Ok(secret) = std::env::var("QL_FILE_SECRET") { + let secret = secret.trim(); + if !secret.is_empty() { + ql_auth::encrypted_store::unlock(secret)?; + return Ok(()); + } + } + + if !io::stdin().is_terminal() { + return Err(io::Error::new( + io::ErrorKind::Other, + "Encrypted token store is locked. Set QL_FILE_SECRET or run in an interactive terminal to enter the password.", + ) + .into()); + } + + let password = rpassword::prompt_password("Encrypted token store password: ")?; + ql_auth::encrypted_store::unlock(&password)?; + Ok(()) +} + fn get_account<'a>( config: &'a LauncherConfig, username: &str, diff --git a/quantum_launcher/src/config/mod.rs b/quantum_launcher/src/config/mod.rs index f6500461b..3a7c0a070 100644 --- a/quantum_launcher/src/config/mod.rs +++ b/quantum_launcher/src/config/mod.rs @@ -5,7 +5,7 @@ use ql_core::{ InstanceKind, IntoIoError, IntoJsonError, JsonFileError, LAUNCHER_DIR, LAUNCHER_VERSION_NAME, ListEntryKind, err, json::GlobalSettings, }; -use ql_instances::auth::{AccountData, AccountType}; +use ql_auth::{AccountData, AccountType, TokenStorageMethod}; use serde::{Deserialize, Serialize}; use std::sync::Arc; use std::{ @@ -59,7 +59,7 @@ pub struct LauncherConfig { /// `String (username) : ConfigAccount { uuid: String, skin: None (unimplemented) }` /// /// Upon opening the launcher, - /// `read_refresh_token(username)` (in [`ql_instances::auth`]) + /// `read_refresh_token(username)` (in [`ql_auth`]) /// is called on each account's key value (username) /// to get the refresh token (stored securely on disk). // Since: v0.4 @@ -107,6 +107,15 @@ pub struct LauncherConfig { #[cfg(feature = "auto_update")] pub last_update_check: Option, + /// Which token storage backend is currently active globally. + // Since: TBD(probably 0.6?) + pub token_storage: Option, + + /// Selected account when using the encrypted-file backend. + /// Kept separate from `account_selected` so each backend remembers + /// its own default independently. + pub account_selected_file: Option, + /// Preserve fields when downgrading #[serde(flatten)] _extra: HashMap, @@ -131,6 +140,8 @@ impl Default for LauncherConfig { ui: None, persistent: None, sidebar: None, + token_storage: None, + account_selected_file: None, _extra: HashMap::new(), #[cfg(feature = "auto_update")] last_update_check: None, @@ -226,6 +237,11 @@ impl LauncherConfig { self.account_selected = None; } } + if let (Some(accounts), Some(selected)) = (&self.accounts, &self.account_selected_file) { + if !accounts.contains_key(selected) { + self.account_selected_file = None; + } + } #[allow(deprecated)] { @@ -315,6 +331,28 @@ impl LauncherConfig { } } + pub fn c_token_storage(&self) -> TokenStorageMethod { + self.token_storage.unwrap_or(TokenStorageMethod::Keyring) + } + + /// Returns the selected account for the currently active storage backend. + pub fn c_account_selected(&self) -> Option<&str> { + match self.c_token_storage() { + TokenStorageMethod::Keyring => self.account_selected.as_deref(), + TokenStorageMethod::EncryptedFile => self.account_selected_file.as_deref(), + } + } + + /// Saves the selected account into the field for the currently active backend. + pub fn set_account_selected(&mut self, account: &str) { + match self.c_token_storage() { + TokenStorageMethod::Keyring => self.account_selected = Some(account.to_owned()), + TokenStorageMethod::EncryptedFile => { + self.account_selected_file = Some(account.to_owned()) + } + } + } + #[cfg(feature = "auto_update")] pub fn should_update_check(&self) -> bool { const INTERVAL_SECS: u64 = 60 * 60; @@ -376,21 +414,30 @@ pub struct ConfigAccount { /// would be an email. pub username_nice: Option, + /// Which backend stores the token for this account. + /// None means keyring (backwards compatible default). + pub token_storage: Option, + #[serde(flatten)] _extra: HashMap, } impl ConfigAccount { pub fn from_account(data: &AccountData) -> Self { + let method = ql_auth::token_store::get_storage_method(); Self { uuid: data.uuid.clone(), skin: None, account_type: Some(data.account_type), keyring_identifier: Some(data.username.clone()), username_nice: Some(data.nice_username.clone()), + token_storage: Some(method), _extra: HashMap::new(), } } + pub fn c_token_storage(&self) -> TokenStorageMethod { + self.token_storage.unwrap_or(TokenStorageMethod::Keyring) + } pub fn get_keyring_identifier<'a>(&'a self, key_username: &'a str) -> &'a str { self.keyring_identifier.as_deref().unwrap_or_else(|| { diff --git a/quantum_launcher/src/menu_renderer/login.rs b/quantum_launcher/src/menu_renderer/login.rs index a34227f37..088eee584 100644 --- a/quantum_launcher/src/menu_renderer/login.rs +++ b/quantum_launcher/src/menu_renderer/login.rs @@ -3,11 +3,156 @@ use iced::{Alignment, Length, widget}; use crate::{ icons, menu_renderer::tsubtitle, - state::{AccountMessage, MenuLoginAlternate, MenuLoginMS, Message, NEW_ACCOUNT_NAME}, + state::{ + AccountMessage, MenuLoginAlternate, MenuLoginMS, MenuTokenPassword, Message, + TokenPasswordMessage, NEW_ACCOUNT_NAME, + }, + stylesheet::styles::LauncherTheme, }; use super::{Element, back_button, button_with_icon, center_x}; +impl MenuTokenPassword { + pub fn view(&self, tick_timer: usize) -> Element<'_> { + let input_padding = iced::Padding { + top: 8.0, + bottom: 8.0, + right: 12.0, + left: 12.0, + }; + + let is_create = self.confirm_password.is_some(); + + let title = if is_create { + "Create Encrypted Token Store" + } else { + "Unlock Encrypted Token Store" + }; + + let description = if is_create { + "Set a password to protect your account tokens.\nThis encrypted file can be copied to other machines." + } else { + "Enter your password to unlock the encrypted account store." + }; + + let submit_on_enter: Message = TokenPasswordMessage::Submit.into(); + + let password_input = widget::text_input("Password...", &self.password) + .padding(input_padding) + .width(320) + .on_input(|n| TokenPasswordMessage::PasswordChanged(n).into()) + .on_submit(submit_on_enter); + let password_input = if self.password.is_empty() || self.show_password { + password_input + } else { + password_input.font(iced::Font::with_name("Password Asterisks")) + }; + + let show_toggle = widget::checkbox("Show Password", self.show_password) + .size(12) + .text_size(12) + .on_toggle(|t| TokenPasswordMessage::ToggleShowPassword(t).into()); + + let is_loading = self.is_loading; + let submit_label = if is_create { "Create" } else { "Unlock" }; + let submit_btn: Element = if is_loading { + let dots = ".".repeat((tick_timer % 3) + 1); + widget::button( + widget::row![icons::gear(), widget::text!(" Loading{dots}").size(14)] + .align_y(Alignment::Center), + ) + .padding([7, 16]) + .into() + } else { + widget::button( + widget::row![icons::checkmark(), widget::text(submit_label).size(14)] + .align_y(Alignment::Center) + .spacing(6), + ) + .on_press(TokenPasswordMessage::Submit.into()) + .padding([7, 16]) + .into() + }; + + let skip_btn: Element = widget::button( + widget::row![icons::close(), widget::text("Skip").size(14)] + .align_y(Alignment::Center) + .spacing(6), + ) + .on_press(TokenPasswordMessage::Skip.into()) + .padding([7, 16]) + .into(); + + // Card + let mut card = widget::column![ + widget::text(title).size(22), + widget::horizontal_rule(1), + widget::text(description).size(12).style(tsubtitle), + widget::Space::with_height(4), + widget::row![ + widget::text("Password:").size(13), + widget::horizontal_space(), + show_toggle, + ] + .align_y(Alignment::Center), + password_input, + ] + .spacing(8) + .width(340); + + if let Some(confirm) = &self.confirm_password { + let confirm_input = widget::text_input("Confirm Password...", confirm) + .padding(input_padding) + .width(320) + .on_input(|n| TokenPasswordMessage::ConfirmPasswordChanged(n).into()) + .on_submit(TokenPasswordMessage::Submit.into()); + let confirm_input = if confirm.is_empty() || self.show_password { + confirm_input + } else { + confirm_input.font(iced::Font::with_name("Password Asterisks")) + }; + card = card + .push(widget::text("Confirm Password:").size(13)) + .push(confirm_input); + } + + if let Some(err) = &self.error { + card = card.push( + widget::text(err.as_str()) + .size(12) + .style(|t: &_| tsubtitle(t)), + ); + } + + card = card.push(widget::Space::with_height(4)).push( + widget::row![submit_btn, skip_btn] + .spacing(10) + .align_y(Alignment::Center), + ); + + let card_container = widget::container(card.padding(24)).style(|t: &LauncherTheme| { + t.style_container_round_box( + crate::stylesheet::styles::BORDER_WIDTH, + crate::stylesheet::color::Color::Dark, + crate::stylesheet::styles::BORDER_RADIUS, + ) + }); + + widget::column![ + widget::vertical_space(), + widget::row![ + widget::horizontal_space(), + card_container, + widget::horizontal_space(), + ], + widget::vertical_space(), + ] + .width(Length::Fill) + .height(Length::Fill) + .into() + } +} + impl MenuLoginAlternate { pub fn view(&'_ self, tick_timer: usize) -> Element<'_> { if let Some(oauth) = &self.oauth { diff --git a/quantum_launcher/src/menu_renderer/mod.rs b/quantum_launcher/src/menu_renderer/mod.rs index 8c3f8e747..0c3339a50 100644 --- a/quantum_launcher/src/menu_renderer/mod.rs +++ b/quantum_launcher/src/menu_renderer/mod.rs @@ -2,8 +2,8 @@ use iced::{ Alignment, Length, widget::{self, column, row, tooltip::Position}, }; +use ql_auth::AccountType; use ql_core::Progress; -use ql_instances::auth::AccountType; use crate::{ config::LauncherConfig, diff --git a/quantum_launcher/src/menu_renderer/onboarding/welcome.rs b/quantum_launcher/src/menu_renderer/onboarding/welcome.rs index d65310013..50eb0d9c2 100644 --- a/quantum_launcher/src/menu_renderer/onboarding/welcome.rs +++ b/quantum_launcher/src/menu_renderer/onboarding/welcome.rs @@ -2,7 +2,7 @@ use iced::{ Alignment, Length, widget::{self, column, row}, }; -use ql_instances::auth::AccountType; +use ql_auth::AccountType; use crate::{ config::LauncherConfig, diff --git a/quantum_launcher/src/menu_renderer/settings/mod.rs b/quantum_launcher/src/menu_renderer/settings/mod.rs index 820248ccc..33636ddf6 100644 --- a/quantum_launcher/src/menu_renderer/settings/mod.rs +++ b/quantum_launcher/src/menu_renderer/settings/mod.rs @@ -16,6 +16,7 @@ use crate::{ mod tab_about; mod tab_game; mod tab_ui; +mod tab_security; pub static IMG_ICED: LazyLock = LazyLock::new(|| { widget::image::Handle::from_bytes(include_bytes!("../../../../assets/iced.png").as_slice()) @@ -25,7 +26,11 @@ pub const PREFIX_EXPLANATION: &str = "Commands to add before the game launch command\nEg: prime-run/gamemoderun/mangohud"; impl MenuLauncherSettings { - pub fn view<'a>(&'a self, config: &'a LauncherConfig) -> Element<'a> { + pub fn view<'a>( + &'a self, + config: &'a LauncherConfig, + keyring_available: bool, + ) -> Element<'a> { widget::row![ sidebar( "MenuLauncherSettings:sidebar", @@ -53,7 +58,7 @@ impl MenuLauncherSettings { border: iced::Border::default(), shadow: iced::Shadow::default() }), - widget::scrollable(self.selected_tab.view(config, self)) + widget::scrollable(self.selected_tab.view(config, self, keyring_available)) .width(Length::Fill) .spacing(0) .style(LauncherTheme::style_scrollable_flat_dark) @@ -98,10 +103,12 @@ impl LauncherSettingsTab { &'a self, config: &'a LauncherConfig, menu: &'a MenuLauncherSettings, + keyring_available: bool, ) -> Element<'a> { match self { LauncherSettingsTab::UserInterface => menu.view_ui_tab(config), LauncherSettingsTab::Game => menu.view_game_tab(config), + LauncherSettingsTab::Security => tab_security::view(config, keyring_available), LauncherSettingsTab::About => tab_about::view(), } .into() diff --git a/quantum_launcher/src/menu_renderer/settings/tab_security.rs b/quantum_launcher/src/menu_renderer/settings/tab_security.rs new file mode 100644 index 000000000..5857991fe --- /dev/null +++ b/quantum_launcher/src/menu_renderer/settings/tab_security.rs @@ -0,0 +1,113 @@ +use iced::{Alignment, widget}; +use ql_auth::{TokenStorageMethod, encrypted_store}; +use ql_core::LAUNCHER_DIR; + +use crate::{ + config::LauncherConfig, + icons, + menu_renderer::{Column, button_with_icon, checkered_list, tsubtitle}, + state::{Message, TokenStoreMessage}, +}; + +pub(super) fn view<'a>(config: &'a LauncherConfig, keyring_available: bool) -> Column<'a> { + let current_method = config.c_token_storage(); + let file_exists = encrypted_store::file_exists(); + let is_unlocked = encrypted_store::is_unlocked(); + + let method_label = widget::text("Account Token Storage:").size(14); + + let keyring_btn = widget::button(if current_method == TokenStorageMethod::Keyring { + "* System Keyring" + } else { + " System Keyring" + }) + .on_press_maybe( + (current_method != TokenStorageMethod::Keyring) + .then_some(TokenStoreMessage::TokenStorageChanged(TokenStorageMethod::Keyring).into()), + ); + + let encrypted_btn = widget::button(if current_method == TokenStorageMethod::EncryptedFile { + "* Encrypted File" + } else { + " Encrypted File" + }) + .on_press_maybe( + (current_method != TokenStorageMethod::EncryptedFile).then_some( + TokenStoreMessage::TokenStorageChanged(TokenStorageMethod::EncryptedFile).into(), + ), + ); + + let mut security = widget::column![ + method_label, + widget::row![keyring_btn, encrypted_btn].spacing(8), + ] + .spacing(8); + + if current_method == TokenStorageMethod::Keyring && !keyring_available { + security = security.push( + widget::text("SYSTEM keyring is unavailable") + .size(12) + .style(tsubtitle) + .color(iced::Color::from_rgb(0.9, 0.3, 0.3)), + ); + } + + security = security + .push( + widget::text( + "Encrypted File stores tokens in an AES-256-GCM encrypted file\nthat can be moved to other machines.", + ) + .size(12), + ) + .push(widget::horizontal_rule(1)); + + if current_method == TokenStorageMethod::EncryptedFile || file_exists { + if file_exists { + let status_text = if is_unlocked { + "Status: Unlocked" + } else { + "Status: Locked" + }; + security = security.push(widget::text(status_text).size(12)); + + if !is_unlocked { + security = security.push( + widget::button("Unlock Store") + .on_press(TokenStoreMessage::UnlockEncryptedStore.into()), + ); + } + + security = security.push( + widget::row![ + button_with_icon(icons::bin_s(12), "Delete Store", 12) + .padding([5, 10]) + .on_press(TokenStoreMessage::DeleteEncryptedStore.into()), + widget::text("Deletes the encrypted file and removes all associated accounts.") + .size(12), + ] + .spacing(8) + .align_y(Alignment::Center), + ); + } else { + security = security.push( + widget::column![ + widget::text("No encrypted store exists yet.").size(12), + widget::button("Create Encrypted Store") + .on_press(TokenStoreMessage::SetupEncryptedStore.into()), + ] + .spacing(6), + ); + } + + security = security.push( + button_with_icon(icons::folder_s(12), "Open Launcher Folder", 12) + .padding([5, 10]) + .on_press(Message::CoreOpenPath(LAUNCHER_DIR.clone())), + ); + } + + checkered_list([ + widget::column![widget::text("Security").size(20)], + security, + ]) +} diff --git a/quantum_launcher/src/message_handler/iced_event.rs b/quantum_launcher/src/message_handler/iced_event.rs index 3bc0920ef..78141f84d 100644 --- a/quantum_launcher/src/message_handler/iced_event.rs +++ b/quantum_launcher/src/message_handler/iced_event.rs @@ -400,6 +400,7 @@ impl Launcher { | State::LoginAlternate(_) | State::LogUploadResult { .. } | State::RecommendedMods(MenuRecommendedMods::Loading { .. }) + | State::TokenPasswordPrompt(_) | State::Launch(_) => {} } diff --git a/quantum_launcher/src/message_handler/mod.rs b/quantum_launcher/src/message_handler/mod.rs index bbb7b4cbc..31126ab0e 100644 --- a/quantum_launcher/src/message_handler/mod.rs +++ b/quantum_launcher/src/message_handler/mod.rs @@ -9,12 +9,13 @@ use crate::{ Launcher, Message, state::{ EditPresetsMessage, ManageModsMessage, MenuEditInstance, MenuEditMods, MenuInstallForge, - MenuLaunch, OFFLINE_ACCOUNT_NAME, ProgressBar, SelectedState, State, + MenuLaunch, MenuTokenPassword, OFFLINE_ACCOUNT_NAME, ProgressBar, SelectedState, State, }, }; use iced::Task; use iced::futures::executor::block_on; use iced::widget::scrollable::AbsoluteOffset; +use ql_auth::{AccountData, TokenStorageMethod, encrypted_store}; use ql_core::file_utils::exists; use ql_core::json::VersionDetails; use ql_core::json::instance_config::ModTypeInfo; @@ -24,7 +25,6 @@ use ql_core::{ json::instance_config::InstanceConfigJson, }; use ql_core::{InstanceKind, LaunchedProcess, info, pt}; -use ql_instances::auth::AccountData; use ql_mod_manager::{loaders, store::ModIndex}; use std::{ collections::HashSet, @@ -613,6 +613,32 @@ impl Launcher { return Task::none(); } + // Block launch if the selected account's encrypted store is locked + if self.account_selected != OFFLINE_ACCOUNT_NAME { + if let Some(acct) = self + .config + .accounts + .as_ref() + .and_then(|m| m.get(&self.account_selected)) + { + if acct.c_token_storage() == TokenStorageMethod::EncryptedFile + && !encrypted_store::is_unlocked() + { + self.state = State::TokenPasswordPrompt(MenuTokenPassword { + password: String::new(), + confirm_password: None, + show_password: false, + error: Some( + "Unlock the encrypted store to play as this account." + .to_owned(), + ), + is_loading: false, + }); + return Task::none(); + } + } + } + self.is_launching_game = true; let account_data = self.get_selected_account_data(); // If the user is loading an existing login from disk diff --git a/quantum_launcher/src/message_update/accounts.rs b/quantum_launcher/src/message_update/accounts.rs index 1b8ff0afa..0bad32631 100644 --- a/quantum_launcher/src/message_update/accounts.rs +++ b/quantum_launcher/src/message_update/accounts.rs @@ -3,10 +3,10 @@ use std::{ time::{Duration, Instant}, }; -use auth::AccountData; use iced::Task; +use ql_auth::AccountData; +use ql_auth::AccountType; use ql_core::IntoStringError; -use ql_instances::auth::{self, AccountType}; use crate::{ config::ConfigAccount, @@ -75,7 +75,7 @@ impl Launcher { // Start polling for token let device_code_clone = device_code.clone(); return Task::perform( - auth::yggdrasil::oauth::poll_device_token( + ql_auth::yggdrasil::oauth::poll_device_token( device_code_clone, interval, expires_in, @@ -100,7 +100,8 @@ impl Launcher { .get(&username) .map_or(AccountType::Microsoft, |n| n.account_type); - if let Err(err) = auth::logout(account_type.strip_name(&username), account_type) { + if let Err(err) = ql_auth::logout(account_type.strip_name(&username), account_type) + { self.set_error(err); } if let Some(accounts) = &mut self.config.accounts { @@ -138,7 +139,7 @@ impl Launcher { } => match kind { AccountType::Microsoft => { self.state = State::GenericMessage("Loading Login...".to_owned()); - return Task::perform(auth::ms::login_1_link(), move |n| { + return Task::perform(ql_auth::ms::login_1_link(), move |n| { AccountMessage::Response1 { r: n.strerr(), is_from_welcome_screen, @@ -195,7 +196,7 @@ impl Launcher { menu.is_loading = true; return Task::perform( - auth::yggdrasil::login_new( + ql_auth::yggdrasil::login_new( menu.username.clone(), password, if menu.is_littleskin { @@ -212,10 +213,10 @@ impl Launcher { if let State::LoginAlternate(menu) = &mut self.state { menu.is_loading = false; match acc { - auth::yggdrasil::Account::Account(data) => { + ql_auth::yggdrasil::Account::Account(data) => { return self.account_response_3(data); } - auth::yggdrasil::Account::NeedsOTP => { + ql_auth::yggdrasil::Account::NeedsOTP => { menu.otp = Some(String::new()); } } @@ -228,7 +229,7 @@ impl Launcher { menu.is_incorrect_password = false; } - return Task::perform(auth::yggdrasil::oauth::request_device_code(), |resp| { + return Task::perform(ql_auth::yggdrasil::oauth::request_device_code(), |resp| { Message::Account(match resp { Ok(code) => AccountMessage::LittleSkinDeviceCodeReady { user_code: code.user_code, @@ -250,7 +251,8 @@ impl Launcher { self.state = State::AccountLogin; } else { if account != OFFLINE_ACCOUNT_NAME { - self.config.account_selected = Some(account.clone()); + self.config.set_account_selected(&account); + self.autosave.remove(&AutoSaveKind::LauncherConfig); } self.account_selected = account; } @@ -263,7 +265,7 @@ impl Launcher { self.state = State::AccountLoginProgress(ProgressBar::with_recv(receiver)); Task::perform( - auth::ms::login_refresh( + ql_auth::ms::login_refresh( account.username.clone(), account.refresh_token.clone(), Some(sender), @@ -272,7 +274,7 @@ impl Launcher { ) } AccountType::ElyBy | AccountType::LittleSkin => Task::perform( - auth::yggdrasil::login_refresh( + ql_auth::yggdrasil::login_refresh( account.username.clone(), account.refresh_token.clone(), account.account_type, @@ -298,26 +300,28 @@ impl Launcher { let config_accounts = self.config.accounts.get_or_insert_with(HashMap::new); config_accounts.insert(username.clone(), ConfigAccount::from_account(&data)); + self.config.set_account_selected(&username); self.account_selected.clone_from(&username); self.accounts.insert(username.clone(), data); + self.autosave.remove(&AutoSaveKind::LauncherConfig); self.go_to_main_menu(None) } - fn account_response_2(&mut self, token: auth::ms::AuthTokenResponse) -> Task { + fn account_response_2(&mut self, token: ql_auth::ms::AuthTokenResponse) -> Task { let (sender, receiver) = std::sync::mpsc::channel(); self.state = State::AccountLoginProgress(ProgressBar::with_recv(receiver)); - Task::perform(auth::ms::login_3_xbox(token, Some(sender), true), |n| { + Task::perform(ql_auth::ms::login_3_xbox(token, Some(sender), true), |n| { AccountMessage::Response3(n.strerr()).into() }) } fn account_response_1( &mut self, - code: auth::ms::AuthCodeResponse, + code: ql_auth::ms::AuthCodeResponse, is_from_welcome_screen: bool, ) -> Task { - let (task, handle) = Task::perform(auth::ms::login_2_wait(code.clone()), |n| { + let (task, handle) = Task::perform(ql_auth::ms::login_2_wait(code.clone()), |n| { AccountMessage::Response2(n.strerr()).into() }) .abortable(); diff --git a/quantum_launcher/src/message_update/mod.rs b/quantum_launcher/src/message_update/mod.rs index 1b85fa01d..697fc2957 100644 --- a/quantum_launcher/src/message_update/mod.rs +++ b/quantum_launcher/src/message_update/mod.rs @@ -5,15 +5,6 @@ use iced::{Task, futures::executor::block_on, widget::text_editor}; use ql_core::{IntoStringError, Loader, OptifineUniqueVersion, err}; use ql_mod_manager::{loaders, store}; -mod accounts; -mod create_instance; -mod edit_instance; -mod launch; -mod manage_mods; -mod mod_store; -mod presets; -mod recommended; - use crate::config::UiWindowDecorations; use crate::state::{ AutoSaveKind, GameLogMessage, InfoMessage, InstanceNotes, MenuLaunch, MenuModDescription, @@ -28,7 +19,16 @@ use crate::{ }, }; +mod accounts; +mod create_instance; +mod edit_instance; +mod launch; +mod manage_mods; +mod mod_store; +mod presets; +mod recommended; mod shortcuts; +mod token; pub const MSG_RESIZE: &str = "Resize your window to apply the changes."; diff --git a/quantum_launcher/src/message_update/token.rs b/quantum_launcher/src/message_update/token.rs new file mode 100644 index 000000000..8caff4d45 --- /dev/null +++ b/quantum_launcher/src/message_update/token.rs @@ -0,0 +1,197 @@ +//! Handler for `TokenPasswordMessage` — password prompt for the encrypted token store. + +use iced::Task; +use ql_auth::{encrypted_store, TokenStorageMethod}; + +use crate::{ + state::{ + load_accounts, LauncherSettingsMessage, LauncherSettingsTab, MenuTokenPassword, + TokenPasswordMessage, TokenStoreMessage, NEW_ACCOUNT_NAME, OFFLINE_ACCOUNT_NAME, + }, + Launcher, Message, State, +}; + +impl Launcher { + pub fn update_token_password(&mut self, msg: TokenPasswordMessage) -> Task { + match msg { + TokenPasswordMessage::PasswordChanged(p) => { + if let State::TokenPasswordPrompt(menu) = &mut self.state { + menu.password = p; + menu.error = None; + } + } + TokenPasswordMessage::ConfirmPasswordChanged(p) => { + if let State::TokenPasswordPrompt(menu) = &mut self.state { + if let Some(confirm) = &mut menu.confirm_password { + *confirm = p; + } + menu.error = None; + } + } + TokenPasswordMessage::ToggleShowPassword(show) => { + if let State::TokenPasswordPrompt(menu) = &mut self.state { + menu.show_password = show; + } + } + TokenPasswordMessage::Submit => { + if let State::TokenPasswordPrompt(menu) = &mut self.state { + menu.is_loading = true; + let password = menu.password.clone(); + let confirm = menu.confirm_password.clone(); + + if let Some(confirm_pw) = confirm { + // "Create new store" flow + if password != confirm_pw { + menu.is_loading = false; + menu.error = Some("Passwords do not match.".to_owned()); + return Task::none(); + } + return Task::perform( + async move { + tokio::task::spawn_blocking(move || { + encrypted_store::initialize_new(&password) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string()) + .and_then(|r| r) + }, + |res| Message::TokenPassword(TokenPasswordMessage::SubmitDone(res)), + ); + } else { + // "Unlock existing store" flow + return Task::perform( + async move { + tokio::task::spawn_blocking(move || { + encrypted_store::unlock(&password).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string()) + .and_then(|r| r) + }, + |res| Message::TokenPassword(TokenPasswordMessage::SubmitDone(res)), + ); + } + } + } + TokenPasswordMessage::SubmitDone(result) => { + if let State::TokenPasswordPrompt(menu) = &mut self.state { + menu.is_loading = false; + match result { + Ok(()) => { + let new_accounts = + crate::state::reload_encrypted_accounts(&mut self.config); + self.accounts.extend(new_accounts.0); + for entry in new_accounts.1 { + if !self.accounts_dropdown.contains(&entry) { + self.accounts_dropdown.insert(0, entry); + } + } + if let Some(saved) = self.config.c_account_selected() { + if self.accounts.contains_key(saved) { + self.account_selected = saved.to_owned(); + } + } + return self.go_to_main_menu(None); + } + Err(err) => { + menu.error = Some(err); + } + } + } + } + TokenPasswordMessage::Skip => { + return self.go_to_main_menu(None); + } + } + Task::none() + } + + pub fn update_token_store(&mut self, msg: TokenStoreMessage) -> Task { + match msg { + TokenStoreMessage::TokenEnsureLoaded => { + if self.config.c_token_storage() == TokenStorageMethod::EncryptedFile + && !encrypted_store::is_unlocked() + && encrypted_store::file_exists() + { + self.state = State::TokenPasswordPrompt(MenuTokenPassword { + password: String::new(), + confirm_password: None, + show_password: false, + error: None, + is_loading: false, + }); + } + } + TokenStoreMessage::TokenStorageChanged(method) => { + self.config.token_storage = Some(method); + ql_auth::token_store::set_storage_method(method); + // Reload the account list so the dropdown immediately reflects the new backend + let (accounts, dropdown, selected, keyring_failed) = load_accounts(&mut self.config); + self.accounts = accounts; + self.accounts_dropdown = dropdown; + self.account_selected = selected; + + if keyring_failed { + self.keyring_available = false; + } else if method == ql_auth::TokenStorageMethod::Keyring { + self.keyring_available = ql_auth::token_store::is_keyring_available(); + } + } + TokenStoreMessage::SetupEncryptedStore => { + self.state = State::TokenPasswordPrompt(MenuTokenPassword { + password: String::new(), + confirm_password: Some(String::new()), + show_password: false, + error: None, + is_loading: false, + }); + } + TokenStoreMessage::UnlockEncryptedStore => { + self.state = State::TokenPasswordPrompt(MenuTokenPassword { + password: String::new(), + confirm_password: None, + show_password: false, + error: None, + is_loading: false, + }); + } + TokenStoreMessage::DeleteEncryptedStore => { + self.state = State::ConfirmAction { + msg1: "delete the encrypted token store".to_owned(), + msg2: "All accounts using encrypted storage will be removed from the launcher." + .to_owned(), + yes: TokenStoreMessage::DeleteEncryptedStoreConfirm.into(), + no: LauncherSettingsMessage::ChangeTab(LauncherSettingsTab::Security).into(), + }; + } + TokenStoreMessage::DeleteEncryptedStoreConfirm => { + if let Err(err) = encrypted_store::delete_store() { + self.set_error(format!("Could not delete encrypted store: {err}")); + return Task::none(); + } + encrypted_store::lock(); + // Remove encrypted-file accounts from config + if let Some(accounts) = &mut self.config.accounts { + accounts + .retain(|_, v| v.c_token_storage() != TokenStorageMethod::EncryptedFile); + } + // Remove them from the in-memory dropdown/map + let config_accounts = self.config.accounts.clone(); + self.accounts + .retain(|k, _| config_accounts.as_ref().is_some_and(|a| a.contains_key(k))); + self.accounts_dropdown.retain(|entry| { + self.accounts.contains_key(entry) + || entry == OFFLINE_ACCOUNT_NAME + || entry == NEW_ACCOUNT_NAME + }); + // Switch back to keyring backend + self.config.token_storage = Some(TokenStorageMethod::Keyring); + ql_auth::token_store::set_storage_method(TokenStorageMethod::Keyring); + self.go_to_launcher_settings(); + return Task::none(); + } + } + Task::none() + } +} diff --git a/quantum_launcher/src/state/menu.rs b/quantum_launcher/src/state/menu.rs index ad7a033de..fce0dc53c 100644 --- a/quantum_launcher/src/state/menu.rs +++ b/quantum_launcher/src/state/menu.rs @@ -583,6 +583,7 @@ pub struct MenuLauncherSettings { pub enum LauncherSettingsTab { UserInterface, Game, + Security, About, } @@ -591,25 +592,33 @@ impl std::fmt::Display for LauncherSettingsTab { f.write_str(match self { LauncherSettingsTab::UserInterface => "Appearance", LauncherSettingsTab::Game => "Game", + LauncherSettingsTab::Security => "Security", LauncherSettingsTab::About => "About", }) } } impl LauncherSettingsTab { - pub const ALL: &'static [Self] = &[Self::UserInterface, Self::Game, Self::About]; + pub const ALL: &'static [Self] = &[ + Self::UserInterface, + Self::Game, + Self::Security, + Self::About, + ]; pub const fn next(self) -> Self { match self { Self::UserInterface => Self::Game, - Self::Game | Self::About => Self::About, + Self::Game => Self::Security, + Self::Security | Self::About => Self::About, } } pub const fn prev(self) -> Self { match self { Self::UserInterface | Self::Game => Self::UserInterface, - Self::About => Self::Game, + Self::Security => Self::Game, + Self::About => Self::Security, } } } @@ -751,6 +760,8 @@ pub enum State { CreateShortcut(MenuShortcut), License(MenuLicense), + + TokenPasswordPrompt(MenuTokenPassword), } pub struct MenuShortcut { @@ -766,6 +777,23 @@ pub struct MenuLicense { pub content: widget::text_editor::Content, } +/// Password prompt for the encrypted token store. +/// Shown at startup when the encrypted file exists but isn't unlocked, +/// or when the user manually triggers unlock from settings. +pub struct MenuTokenPassword { + /// The password the user is currently typing. + pub password: String, + /// Whether the password field is visible as plain text. + pub show_password: bool, + /// An error message to display (e.g., wrong password). + pub error: Option, + /// Whether a pending crypto operation is in progress. + pub is_loading: bool, + /// If `Some`, this is a "create new store" flow with a confirm field. + /// Otherwise it's an "unlock existing store" flow. + pub confirm_password: Option, +} + #[derive(Debug, Clone, Copy, PartialEq)] pub enum LicenseTab { Gpl3, diff --git a/quantum_launcher/src/state/message.rs b/quantum_launcher/src/state/message.rs index 2f88765d5..bd7e4a105 100644 --- a/quantum_launcher/src/state/message.rs +++ b/quantum_launcher/src/state/message.rs @@ -7,6 +7,10 @@ use crate::{ stylesheet::styles::{LauncherThemeColor, LauncherThemeLightness}, }; use iced::widget::{self, scrollable::AbsoluteOffset}; +use ql_auth::{ + ms::{AuthCodeResponse, AuthTokenResponse}, + AccountData, AccountType, TokenStorageMethod, +}; use ql_core::{ Instance, InstanceKind, LaunchedProcess, ListEntry, Loader, file_utils::DirItem, @@ -14,10 +18,6 @@ use ql_core::{ json::instance_config::{MainClassMode, PreLaunchPrefixMode}, read_log::Diagnostic, }; -use ql_instances::auth::{ - AccountData, AccountType, - ms::{AuthCodeResponse, AuthTokenResponse}, -}; use ql_mod_manager::{ loaders::{fabric, paper::PaperVersion}, store::{ @@ -258,7 +258,7 @@ pub enum AccountMessage { AltOtpInput(String), AltShowPassword(bool), AltLogin, - AltLoginResponse(Res), + AltLoginResponse(Res), LittleSkinOauthButtonClicked, LittleSkinDeviceCodeReady { @@ -345,6 +345,16 @@ impl ListMessage { } } +#[derive(Debug, Clone)] +pub enum TokenPasswordMessage { + PasswordChanged(String), + ConfirmPasswordChanged(String), + ToggleShowPassword(bool), + Submit, + SubmitDone(crate::state::Res), + Skip, +} + #[derive(Debug, Clone)] pub enum NotesMessage { Loaded(Res), @@ -405,6 +415,16 @@ pub enum ShortcutMessage { Done(Res), } +#[derive(Debug, Clone)] +pub enum TokenStoreMessage { + TokenEnsureLoaded, + TokenStorageChanged(TokenStorageMethod), + SetupEncryptedStore, + UnlockEncryptedStore, + DeleteEncryptedStore, + DeleteEncryptedStoreConfirm, +} + #[derive(Debug, Clone)] pub enum ModDescriptionMessage { Open(ModId), @@ -442,6 +462,7 @@ pub enum Message { MainMenu(MainMenuMessage), Sidebar(SidebarMessage), ModDescription(ModDescriptionMessage), + TokenStore(TokenStoreMessage), MScreenOpen { message: Option, @@ -504,6 +525,8 @@ pub enum Message { LicenseOpen, LicenseChangeTab(LicenseTab), LicenseAction(widget::text_editor::Action), + + TokenPassword(TokenPasswordMessage), } macro_rules! from_m { @@ -535,3 +558,5 @@ from_m!(GameLog, GameLogMessage); from_m!(Window, WindowMessage); from_m!(Shortcut, ShortcutMessage); from_m!(ModDescription, ModDescriptionMessage); +from_m!(TokenPassword, TokenPasswordMessage); +from_m!(TokenStore, TokenStoreMessage); diff --git a/quantum_launcher/src/state/mod.rs b/quantum_launcher/src/state/mod.rs index b50077cf7..74a4f60c4 100644 --- a/quantum_launcher/src/state/mod.rs +++ b/quantum_launcher/src/state/mod.rs @@ -7,13 +7,13 @@ use std::{ use iced::Task; use notify::Watcher; +use ql_auth::{AccountData, AccountType, TokenStorageMethod, encrypted_store, ms::CLIENT_ID}; use ql_core::{ GenericProgress, Instance, InstanceKind, IntoIoError, IntoStringError, IoError, JsonFileError, LAUNCHER_DIR, LAUNCHER_VERSION_NAME, LaunchedProcess, Progress, err, file_utils::{self, exists}, read_log::LogLine, }; -use ql_instances::auth::{AccountData, AccountType, ms::CLIENT_ID}; use tokio::process::ChildStdin; use crate::{ @@ -76,6 +76,8 @@ pub struct Launcher { pub window_state: WindowState, pub keys_pressed: HashSet, pub modifiers_pressed: iced::keyboard::Modifiers, + + pub keyring_available: bool, } /// Used to temporarily "block" auto-saving something, @@ -151,14 +153,44 @@ impl Launcher { let (window_width, window_height) = config.c_window_size(); let mut launch = MenuLaunch::default(); + + let mut is_keyring_available = ql_auth::token_store::is_keyring_available(); + + if config.c_token_storage() == TokenStorageMethod::Keyring && !is_keyring_available { + launch.message = Some(InfoMessage::error( + "SYSTEM keyring is unavailable, sessions might not persist", + )); + } + launch.resize_sidebar(SIDEBAR_WIDTH); + + let (accounts, accounts_dropdown, account_selected, keyring_failed) = + load_accounts(&mut config); + + if keyring_failed { + launch.message = Some(InfoMessage::error( + "SYSTEM keyring is unavailable, sessions might not persist", + )); + // Also update the cached availability + is_keyring_available = false; + } let launch = State::Launch(launch); - // The version field was added in 0.3 - let version = config.version.as_deref().unwrap_or("0.3.0"); + let version = config.version.as_deref().unwrap_or("0.3.0"); // field added in 0.3 let state = if is_new_user { State::Welcome(MenuWelcome::P1InitialScreen) + } else if config.c_token_storage() == TokenStorageMethod::EncryptedFile + && encrypted_store::file_exists() + && !encrypted_store::is_unlocked() + { + State::TokenPasswordPrompt(MenuTokenPassword { + password: String::new(), + confirm_password: None, + show_password: false, + error: None, + is_loading: false, + }) } else if version == LAUNCHER_VERSION_NAME { launch } else { @@ -169,7 +201,8 @@ impl Launcher { State::ChangeLog }; - let (accounts, accounts_dropdown, account_selected) = load_accounts(&mut config); + // Set global storage method from config + ql_auth::token_store::set_storage_method(config.c_token_storage()); let persistent = config.c_persistent(); let selected_instance = persistent @@ -221,6 +254,8 @@ impl Launcher { autosave: HashSet::new(), images: ImageState::default(), modifiers_pressed: iced::keyboard::Modifiers::empty(), + + keyring_available: is_keyring_available, }) } @@ -285,6 +320,8 @@ impl Launcher { accounts_dropdown: vec![OFFLINE_ACCOUNT_NAME.to_owned(), NEW_ACCOUNT_NAME.to_owned()], account_selected: OFFLINE_ACCOUNT_NAME.to_owned(), modifiers_pressed: iced::keyboard::Modifiers::empty(), + + keyring_available: ql_auth::token_store::is_keyring_available(), } } @@ -313,23 +350,60 @@ impl Launcher { } } -fn load_accounts( +/// Re-load only the encrypted-file accounts after the user unlocks the store. +/// Returns `(accounts_map, dropdown_entries)` to merge into the launcher state. +pub fn reload_encrypted_accounts( config: &mut LauncherConfig, -) -> (HashMap, Vec, String) { +) -> (HashMap, Vec) { + let mut accounts = HashMap::new(); + let mut dropdown_entries = Vec::new(); + let mut accounts_to_remove = Vec::new(); + + for (username, account) in config.accounts.iter_mut().flatten() { + if account.c_token_storage() != TokenStorageMethod::EncryptedFile { + continue; + } + load_account( + &mut accounts, + &mut dropdown_entries, + &mut accounts_to_remove, + username, + account, + ); + } + + if let Some(acc) = &mut config.accounts { + for rem in accounts_to_remove { + acc.remove(&rem); + } + } + + (accounts, dropdown_entries) +} + +pub fn load_accounts( + config: &mut LauncherConfig, +) -> (HashMap, Vec, String, bool) { + // Set global storage method from config before loading accounts + ql_auth::token_store::set_storage_method(config.c_token_storage()); + let mut accounts = HashMap::new(); let mut accounts_dropdown = vec![OFFLINE_ACCOUNT_NAME.to_owned(), NEW_ACCOUNT_NAME.to_owned()]; let mut accounts_to_remove = Vec::new(); + let mut keyring_failed = false; for (username, account) in config.accounts.iter_mut().flatten() { - load_account( + if load_account( &mut accounts, &mut accounts_dropdown, &mut accounts_to_remove, username, account, - ); + ) { + keyring_failed = true; + } } if let Some(accounts) = &mut config.accounts { @@ -338,13 +412,21 @@ fn load_accounts( } } - let selected_account = config.account_selected.clone().unwrap_or( - accounts_dropdown - .first() - .cloned() - .unwrap_or_else(|| OFFLINE_ACCOUNT_NAME.to_owned()), - ); - (accounts, accounts_dropdown, selected_account) + let selected_account = config + .c_account_selected() + .map(str::to_owned) + .unwrap_or_else(|| { + accounts_dropdown + .first() + .cloned() + .unwrap_or_else(|| OFFLINE_ACCOUNT_NAME.to_owned()) + }); + ( + accounts, + accounts_dropdown, + selected_account, + keyring_failed, + ) } fn load_account( @@ -353,18 +435,44 @@ fn load_account( accounts_to_remove: &mut Vec, username: &str, account: &mut crate::config::ConfigAccount, -) { - let account_type = if username.ends_with(" (elyby)") { - AccountType::ElyBy - } else if username.ends_with(" (littleskin)") { - AccountType::LittleSkin - } else { - account.account_type.unwrap_or_default() - }; +) -> bool { + fn get_refresh_token_for_account_type( + account_type: AccountType, + username: &str, + account: &crate::config::ConfigAccount, + method: TokenStorageMethod, + ) -> Result { + let keyring_username = account.get_keyring_identifier(username); + ql_auth::read_refresh_token_from(keyring_username, account_type, method) + .map_err(|e| e.to_string()) + } + + let per_account_method = account.c_token_storage(); + let current_method = ql_auth::token_store::get_storage_method(); + + // Only show accounts that belong to the currently active storage backend. + // Keyring accounts are hidden in file mode and vice versa. + if per_account_method != current_method { + return false; + } + // If this account uses the encrypted store and it's locked, skip it + if per_account_method == TokenStorageMethod::EncryptedFile && !encrypted_store::is_unlocked() { + return false; + } + + let account_type = + if account.account_type == Some(AccountType::ElyBy) || username.ends_with(" (elyby)") { + AccountType::ElyBy + } else if account.account_type == Some(AccountType::LittleSkin) + || username.ends_with(" (littleskin)") + { + AccountType::LittleSkin + } else { + AccountType::Microsoft + }; - let keyring_username = account.get_keyring_identifier(username); let refresh_token = - ql_instances::auth::read_refresh_token(keyring_username, account_type).strerr(); + get_refresh_token_for_account_type(account_type, username, account, per_account_method); let keyring_username = account.get_keyring_identifier(username); @@ -387,6 +495,7 @@ fn load_account( .unwrap_or_else(|| username.to_owned()), }, ); + false } Err(err) => { err!( @@ -394,6 +503,11 @@ fn load_account( account_type.to_string() ); accounts_to_remove.push(username.to_owned()); + if per_account_method == TokenStorageMethod::Keyring { + true + } else { + false + } } } } diff --git a/quantum_launcher/src/tick.rs b/quantum_launcher/src/tick.rs index 8a1836dea..baedcc28d 100644 --- a/quantum_launcher/src/tick.rs +++ b/quantum_launcher/src/tick.rs @@ -182,6 +182,7 @@ impl Launcher { | State::LogUploadResult { .. } | State::InstallPaper(_) | State::CreateShortcut(_) + | State::TokenPasswordPrompt(_) | State::ModDescription(_) | State::ExportMods(_) => {} } diff --git a/quantum_launcher/src/update.rs b/quantum_launcher/src/update.rs index 35490a9fc..3c5540828 100644 --- a/quantum_launcher/src/update.rs +++ b/quantum_launcher/src/update.rs @@ -56,6 +56,13 @@ impl Launcher { self.state = State::Welcome(MenuWelcome::P3Auth); } + Message::CoreFocusNext => { + return iced::widget::focus_next(); + } + Message::CoreHideModal => { + self.hide_submenu(); + } + Message::MainMenu(msg) => return self.update_main_menu(msg), Message::Sidebar(msg) => return self.update_sidebar(msg), Message::Account(msg) => return self.update_account(msg), @@ -70,7 +77,8 @@ impl Launcher { Ok(n) => return n, Err(e) => self.set_error(e), }, - + Message::TokenPassword(msg) => return self.update_token_password(msg), + Message::TokenStore(msg) => return self.update_token_store(msg), Message::LauncherSettings(msg) => return self.update_launcher_settings(msg), Message::InstallOptifine(msg) => return self.update_install_optifine(msg), Message::InstallPaper(msg) => return self.update_install_paper(msg), @@ -397,12 +405,6 @@ impl Launcher { } } } - Message::CoreFocusNext => { - return iced::widget::focus_next(); - } - Message::CoreHideModal => { - self.hide_submenu(); - } } Task::none() } diff --git a/quantum_launcher/src/view.rs b/quantum_launcher/src/view.rs index f149c30d6..0c86bdd98 100644 --- a/quantum_launcher/src/view.rs +++ b/quantum_launcher/src/view.rs @@ -127,7 +127,7 @@ impl Launcher { .into(), State::ModsDownload(menu) => menu.view(&self.images, self.tick_timer), State::ModDescription(menu) => menu.view(&self.images, self.tick_timer), - State::LauncherSettings(menu) => menu.view(&self.config), + State::LauncherSettings(menu) => menu.view(&self.config, self.keyring_available), State::InstallPaper(menu) => menu.view(self.tick_timer), State::ChangeLog => view_changelog(), State::Welcome(menu) => menu.view(&self.config), @@ -153,6 +153,7 @@ impl Launcher { State::InstallOptifine(menu) => menu.view(), State::ManagePresets(menu) => menu.view(), State::RecommendedMods(menu) => menu.view(), + State::TokenPasswordPrompt(menu) => menu.view(self.tick_timer), }; widget::mouse_area(if let State::Launch(_) = &self.state {