diff --git a/.env.template b/.env.template index 5d9862e1..70aa78eb 100644 --- a/.env.template +++ b/.env.template @@ -430,13 +430,14 @@ ## Optional second factor for the admin page login. ## When set, logging in at /admin additionally requires a 6-digit time-based -## one-time code (TOTP) generated from this Base32-encoded secret. +## one-time code (TOTP) generated from this Base32-encoded secret (at least 16 bytes). ## Add the same secret to any authenticator app to generate the codes. ## Generate a secret with e.g.: openssl rand 20 | base32 -## Tip: Instead of setting this variable you can enroll interactively with a -## QR code on the "Admin 2FA" page of the admin panel (/admin/totp). +## The admin panel's configuration page can generate a secret and QR code locally +## in the browser, then save the secret to config.json. Once saved, the existing +## secret is never shown there again. ## Note: This has no effect when DISABLE_ADMIN_TOKEN is enabled. -# ADMIN_TOTP_SECRET=JBSWY3DPEHPK3PXP +# ADMIN_TOTP_SECRET=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP ## Enable this to bypass the admin panel security. This option is only ## meant to be used with the use of a separate auth layer in front diff --git a/Cargo.lock b/Cargo.lock index a937f4e3..0715098c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4042,12 +4042,6 @@ dependencies = [ "psl-types", ] -[[package]] -name = "qrcode" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" - [[package]] name = "quanta" version = "0.12.6" @@ -5944,7 +5938,6 @@ dependencies = [ "pastey 0.2.3", "percent-encoding", "pico-args", - "qrcode", "rand 0.10.2", "regex", "reqsign-aws-v4", diff --git a/Cargo.toml b/Cargo.toml index d712b397..909570e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -147,9 +147,6 @@ jsonwebtoken = { version = "10.4.0", default-features = false, features = ["rust # TOTP library totp-lite = "2.0.1" -# QR code generation (admin page TOTP enrollment) -qrcode = { version = "0.14.1", default-features = false, features = ["svg"] } - # Yubico Library yubico = { package = "yubico_ng", version = "0.15.0", default-features = false, features = ["online-tokio"] } diff --git a/src/api/admin.rs b/src/api/admin.rs index afb95bb2..177a8077 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -74,10 +74,6 @@ pub fn routes() -> Vec { get_diagnostics_config, resend_user_invite, get_diagnostics_http, - totp_page, - totp_generate, - totp_enable, - totp_disable, ] } @@ -174,7 +170,7 @@ fn render_admin_login(msg: Option<&str>, redirect: Option<&str>) -> ApiResult bool { } } -// The last accepted TOTP time step, kept in memory since there is no database record for the admin. +// The active-secret fingerprint and its last accepted TOTP time step, kept in memory since there is no database +// record for the admin. Keeping the fingerprint resets replay protection when the configured secret changes. // Restarting Vaultwarden resets it, which at worst allows a code from the previous 30 seconds to be reused once. -static ADMIN_TOTP_LAST_USED: Mutex = Mutex::new(0); +static ADMIN_TOTP_LAST_USED: Mutex> = Mutex::new(None); + +fn configured_admin_totp_secret() -> Option { + CONFIG.admin_totp_secret().filter(|secret| !secret.trim().is_empty()) +} fn validate_totp(code: Option<&str>) -> bool { - let Some(secret) = CONFIG.admin_totp_secret() else { + let Some(secret) = configured_admin_totp_secret() else { // No TOTP secret configured, the admin token alone is sufficient return true; }; @@ -273,7 +274,7 @@ fn validate_totp(code: Option<&str>) -> bool { fn check_totp_code(secret: &str, code: &str) -> bool { use two_factor::authenticator::{TotpValidation, verify_totp}; - if code.len() != 6 || !code.chars().all(char::is_numeric) { + if code.len() != 6 || !code.bytes().all(|digit| digit.is_ascii_digit()) { return false; } let Ok(decoded_secret) = data_encoding::BASE32.decode(secret.trim().to_uppercase().as_bytes()) else { @@ -282,12 +283,17 @@ fn check_totp_code(secret: &str, code: &str) -> bool { }; let current_timestamp = chrono::Utc::now().timestamp(); - let mut last_used = ADMIN_TOTP_LAST_USED.lock().expect("admin TOTP mutex poisoned"); + let fingerprint = crate::crypto::sha256_hex(&decoded_secret); + let mut replay_state = ADMIN_TOTP_LAST_USED.lock().expect("admin TOTP mutex poisoned"); + let last_used = replay_state + .as_ref() + .filter(|(active_fingerprint, _)| active_fingerprint == &fingerprint) + .map_or(0, |(_, last_used)| *last_used); // Reuse the shared verifier; allow one step of time drift in either direction, like the user 2FA does. - match verify_totp(&decoded_secret, code, current_timestamp, *last_used, 1) { + match verify_totp(&decoded_secret, code, current_timestamp, last_used, 1) { TotpValidation::Accepted(time_step) => { - *last_used = time_step; + *replay_state = Some((fingerprint, time_step)); true } TotpValidation::Reused => { @@ -298,87 +304,6 @@ fn check_totp_code(secret: &str, code: &str) -> bool { } } -#[get("/totp")] -fn totp_page(_token: AdminToken) -> ApiResult> { - let (enabled, source) = CONFIG.admin_totp_status(); - let page_data = json!({ - "enabled": enabled, - "via_env": source == "environment", - }); - let text = AdminTemplateData::new("admin/totp", page_data).render()?; - Ok(Html(text)) -} - -#[post("/totp/generate", format = "application/json")] -fn totp_generate(_token: AdminToken) -> JsonResult { - use qrcode::{QrCode, render::svg}; - - let secret = crate::crypto::encode_random_bytes::<20>(&data_encoding::BASE32); - let uri = format!("otpauth://totp/Vaultwarden%20Admin?secret={secret}&issuer=Vaultwarden%20Admin"); - let Ok(qr) = QrCode::new(uri.as_bytes()) else { - err!("Failed to generate QR code") - }; - let qr_svg = qr.render::>().min_dimensions(240, 240).build(); - - Ok(Json(json!({ - "secret": secret, - "uri": uri, - "qr_svg": qr_svg, - }))) -} - -#[derive(Deserialize)] -struct TotpEnableData { - secret: String, - code: String, -} - -#[post("/totp/enable", format = "application/json", data = "")] -async fn totp_enable(data: Json, _token: AdminToken) -> EmptyResult { - let data = data.into_inner(); - if CONFIG.admin_totp_status().0 { - err!("Admin page TOTP is already enabled") - } - let secret = data.secret.trim().to_uppercase(); - if secret.is_empty() || data_encoding::BASE32.decode(secret.as_bytes()).is_err() { - err!("Invalid TOTP secret") - } - if !check_totp_code(&secret, data.code.trim()) { - err!("Invalid TOTP code, please try again") - } - if let Err(e) = CONFIG.set_admin_totp_secret(Some(secret)).await { - err!(format!("Unable to save the TOTP secret: {e:?}")) - } - Ok(()) -} - -#[derive(Deserialize)] -struct TotpDisableData { - code: String, -} - -#[post("/totp/disable", format = "application/json", data = "")] -async fn totp_disable(data: Json, _token: AdminToken) -> EmptyResult { - let data = data.into_inner(); - let (enabled, source) = CONFIG.admin_totp_status(); - if !enabled { - err!("Admin page TOTP is not enabled") - } - if source == "environment" { - err!("The TOTP secret is set via `ADMIN_TOTP_SECRET` and can only be removed from the environment") - } - let Some(secret) = CONFIG.admin_totp_secret() else { - err!("Admin page TOTP is not enabled") - }; - if !check_totp_code(&secret, data.code.trim()) { - err!("Invalid TOTP code, please try again") - } - if let Err(e) = CONFIG.set_admin_totp_secret(None).await { - err!(format!("Unable to remove the TOTP secret: {e:?}")) - } - Ok(()) -} - #[derive(Serialize)] struct AdminTemplateData { page_content: String, diff --git a/src/api/web.rs b/src/api/web.rs index 77bffe87..e66bbcbd 100644 --- a/src/api/web.rs +++ b/src/api/web.rs @@ -256,7 +256,9 @@ pub fn static_files(filename: &str) -> Result<(ContentType, &'static [u8]), Erro "admin.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/admin.css"))), "admin.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin.js"))), "admin_settings.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_settings.js"))), - "admin_totp.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_totp.js"))), + "qrcode-generator-2.0.4.js" => { + Ok((ContentType::JavaScript, include_bytes!("../static/scripts/qrcode-generator-2.0.4.js"))) + } "admin_users.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_users.js"))), "admin_organizations.js" => { Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_organizations.js"))) @@ -275,3 +277,16 @@ pub fn static_files(filename: &str) -> Result<(ContentType, &'static [u8]), Erro _ => err!(format!("Static file not found: {filename}")), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn qrcode_generator_is_available_in_production_assets() { + let (content_type, body) = static_files("qrcode-generator-2.0.4.js").unwrap(); + + assert_eq!(content_type, ContentType::JavaScript); + assert!(body.starts_with(b"/*! qrcode-generator 2.0.4")); + } +} diff --git a/src/config.rs b/src/config.rs index 9cb5ad57..1b3cf425 100644 --- a/src/config.rs +++ b/src/config.rs @@ -54,9 +54,11 @@ pub static CONFIG: LazyLock = LazyLock::new(|| { }); pub type Pass = String; +pub type AdminTotpSecret = String; macro_rules! make_config { // Support string print + ( @supportstr $name:ident, $value:expr, AdminTotpSecret, option ) => { serde_json::to_value(&$value.as_ref().map(|_| String::from("***"))).unwrap() }; ( @supportstr $name:ident, $value:expr, Pass, option ) => { serde_json::to_value(&$value.as_ref().map(|_| String::from("***"))).unwrap() }; // Optional pass, we map to an Option with "***" ( @supportstr $name:ident, $value:expr, Pass, $none_action:ident ) => { "***".into() }; // Required pass, we return "***" ( @supportstr $name:ident, $value:expr, $ty:ty, option ) => { serde_json::to_value(&$value).unwrap() }; // Optional other or string, we convert to json @@ -67,6 +69,18 @@ macro_rules! make_config { ( @show ) => { "" }; ( @show $lit:literal ) => { $lit }; + // Write-only values are shown as empty inputs and never serialized to the admin settings page. + ( @formvalue $value:expr, AdminTotpSecret ) => { serde_json::Value::Null }; + ( @formvalue $value:expr, $ty:ident ) => { serde_json::to_value($value).unwrap_or_default() }; + ( @writeonly AdminTotpSecret ) => { true }; + ( @writeonly $ty:ident ) => { false }; + ( @configured $value:expr, AdminTotpSecret ) => { + $value.as_ref().is_some_and(|value| !value.trim().is_empty()) + }; + ( @configured $value:expr, $ty:ident ) => { false }; + ( @userconfigured $value:expr, AdminTotpSecret ) => { $value.is_some() }; + ( @userconfigured $value:expr, $ty:ident ) => { false }; + // Wrap the optionals in an Option type ( @type $ty:ty, option) => { Option<$ty> }; ( @type $ty:ty, $id:ident) => { $ty }; @@ -274,15 +288,6 @@ macro_rules! make_config { )+)+ } - /// Clears all editable values, leaving only the non-editable ones (inverse of `clear_non_editable`). - fn clear_editable(&mut self) { - $($( - if $editable { - self.$name = None; - } - )+)+ - } - /// Merges the values of both builders into a new builder. /// If both have the same element, `other` wins. fn merge(&self, other: &Self, show_overrides: bool, overrides: &mut Vec<&str>) -> Self { @@ -345,6 +350,9 @@ macro_rules! make_config { name: &'static str, value: serde_json::Value, default: serde_json::Value, + write_only: bool, + configured: bool, + user_configured: bool, #[serde(rename = "type")] r#type: &'static str, doc: ElementDoc, @@ -371,7 +379,7 @@ macro_rules! make_config { pub fn prepare_json(&self) -> serde_json::Value { fn get_form_type(rust_type: &'static str) -> &'static str { match rust_type { - "Pass" => "password", + "Pass" | "AdminTotpSecret" => "password", "String" => "text", "bool" => "checkbox", _ => "number" @@ -386,10 +394,10 @@ macro_rules! make_config { } } - let (def, cfg, overridden) = { + let (def, cfg, usr, overridden) = { // Lock the inner as short as possible and clone what is needed to prevent deadlocks let inner = &self.inner.read().unwrap(); - (inner._env.build(), inner.config.clone(), inner._overrides.clone()) + (inner._env.build(), inner.config.clone(), inner._usr.clone(), inner._overrides.clone()) }; let data: Vec = vec![ @@ -404,8 +412,11 @@ macro_rules! make_config { ElementData { editable: $editable, name: stringify!($name), - value: serde_json::to_value(&cfg.$name).unwrap_or_default(), - default: serde_json::to_value(&def.$name).unwrap_or_default(), + value: make_config! { @formvalue &cfg.$name, $ty }, + default: make_config! { @formvalue &def.$name, $ty }, + write_only: make_config! { @writeonly $ty }, + configured: make_config! { @configured &cfg.$name, $ty }, + user_configured: make_config! { @userconfigured &usr.$name, $ty }, r#type: get_form_type(stringify!($ty)), doc: get_doc(concat!($($doc),+)), overridden: overridden.contains(&pastey::paste!(stringify!([<$name:upper>]))), @@ -661,8 +672,8 @@ make_config! { /// Admin token/Argon2 PHC |> The plain text token or Argon2 PHC string used to authenticate in this very same page. Changing it here will not deauthorize the current session! admin_token: Pass, true, option; - /// Admin TOTP secret |> Base32-encoded secret. When set, logging in to the admin page additionally requires a time-based one-time code. Manage it via the Admin 2FA page. Has no effect when DISABLE_ADMIN_TOKEN is enabled! - admin_totp_secret: Pass, false, option; + /// Admin TOTP secret |> Base32-encoded secret containing at least 16 bytes of secret data. When set, logging in to the admin page additionally requires a time-based one-time code. Leave blank to keep the saved secret. Has no effect when DISABLE_ADMIN_TOKEN is enabled! + admin_totp_secret: AdminTotpSecret, true, option; /// Invitation organization name |> Name shown in the invitation emails that don't come from a specific organization invitation_org_name: String, true, def, "Vaultwarden".to_owned(); @@ -932,6 +943,30 @@ make_config! { }, } +impl ConfigBuilder { + /// Applies the write-only admin form semantics for the TOTP secret: + /// - an omitted value keeps the value currently stored in `config.json`; + /// - an empty value removes the stored override; + /// - a non-empty value replaces it. + fn prepare_admin_update(&mut self, current_user_config: &Self) { + self.admin_totp_secret = match self.admin_totp_secret.take() { + None => current_user_config.admin_totp_secret.clone(), + Some(secret) if secret.trim().is_empty() => None, + Some(secret) => Some(secret.trim().to_uppercase()), + }; + } +} + +fn validate_admin_totp_secret(secret: &str) -> Result<(), Error> { + let Ok(decoded_secret) = data_encoding::BASE32.decode(secret.trim().to_uppercase().as_bytes()) else { + err!("`ADMIN_TOTP_SECRET` is not a valid base32-encoded string") + }; + if decoded_secret.len() < 16 { + err!("`ADMIN_TOTP_SECRET` must contain at least 128 bits (16 bytes) of secret data") + } + Ok(()) +} + fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { // Validate connection URL is valid and DB feature is enabled #[cfg(sqlite)] @@ -1271,10 +1306,8 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { _ => {} } - if let Some(ref secret) = cfg.admin_totp_secret - && data_encoding::BASE32.decode(secret.trim().to_uppercase().as_bytes()).is_err() - { - err!("`ADMIN_TOTP_SECRET` is not a valid base32-encoded string") + if let Some(ref secret) = cfg.admin_totp_secret { + validate_admin_totp_secret(secret)?; } } @@ -1468,15 +1501,12 @@ impl Config { // TODO: Remove values that are defaults, above only checks those set by env and not the defaults let mut builder = other; - // Remove values that are not editable, but keep previously saved non-editable values - // (e.g. `_duo_akey` or an enrolled `admin_totp_secret`). Those are not part of the - // posted settings form and would otherwise be lost on every settings save. + // Remove values that are not editable. Preserve an omitted write-only TOTP value, + // while still allowing an explicit empty value to remove the saved override. if ignore_non_editable { + let current_user_config = self.inner.read().unwrap()._usr.clone(); + builder.prepare_admin_update(¤t_user_config); builder.clear_non_editable(); - let mut preserved = self.inner.read().unwrap()._usr.clone(); - preserved.clear_editable(); - let mut ignored_overrides = Vec::new(); - builder = preserved.merge(&builder, false, &mut ignored_overrides); } // Serialize now before we consume the builder @@ -1612,34 +1642,6 @@ impl Config { } } - /// Persists (or removes, on `None`) the admin page TOTP secret in the saved config file. - pub async fn set_admin_totp_secret(&self, secret: Option) -> Result<(), Error> { - let mut builder = self.inner.read().unwrap()._usr.clone(); - builder.admin_totp_secret = secret; - self.update_config(builder, false).await - } - - /// Returns whether admin page TOTP is active, and where the secret comes from: - /// - `"environment"`: set via the `ADMIN_TOTP_SECRET` env var; it can only be changed - /// there and therefore cannot be disabled from the admin page. - /// - `"config"`: present in the saved config file; it can be managed from the admin page. - /// A secret written to the config file by hand is indistinguishable from one enrolled - /// via the admin page, so both are reported as `"config"`. - /// - /// The environment is checked first: if the secret is set there it stays effective even - /// after removing a (stale) copy from the config file, so it must not be reported as removable. - pub fn admin_totp_status(&self) -> (bool, &'static str) { - let inner = self.inner.read().unwrap(); - let is_set = |v: &Option| v.as_deref().is_some_and(|s| !s.trim().is_empty()); - if is_set(&inner._env.admin_totp_secret) { - (true, "environment") - } else if is_set(&inner._usr.admin_totp_secret) { - (true, "config") - } else { - (false, "none") - } - } - pub fn is_webauthn_2fa_supported(&self) -> bool { Url::parse(&self.domain()).expect("DOMAIN not a valid URL").domain().is_some() } @@ -1789,8 +1791,6 @@ where reg!("admin/users"); reg!("admin/organizations"); reg!("admin/diagnostics"); - reg!("admin/totp"); - reg!("404"); reg!(@withfallback "scss/vaultwarden.scss"); @@ -1877,3 +1877,189 @@ handlebars::handlebars_helper!(webver: | web_vault_version: String | handlebars::handlebars_helper!(vwver: | vw_version: String | semver::VersionReq::parse(&vw_version).expect("Invalid Vaultwarden version compare string").matches(&VW_VERSION) ); + +#[cfg(test)] +mod tests { + use super::*; + + const ENV_TOTP_SECRET: &str = "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP"; + const USER_TOTP_SECRET: &str = "KRSXG5DSNFXGOIDBKRSXG5DSNFXGOIDB"; + + fn config_with(env: ConfigBuilder, usr: ConfigBuilder) -> Config { + let mut overrides = Vec::new(); + let config = env.merge(&usr, false, &mut overrides).build(); + + Config { + inner: RwLock::new(Inner { + rocket_shutdown_handle: None, + templates: Handlebars::new(), + config, + _env: env, + _usr: usr, + _overrides: overrides, + }), + } + } + + fn admin_totp_element(config: &Config) -> serde_json::Value { + config + .prepare_json() + .as_array() + .unwrap() + .iter() + .flat_map(|group| group["elements"].as_array().unwrap()) + .find(|element| element["name"] == "admin_totp_secret") + .unwrap() + .clone() + } + + #[test] + fn config_file_admin_totp_secret_overrides_environment() { + let env = ConfigBuilder { + admin_totp_secret: Some(ENV_TOTP_SECRET.to_owned()), + ..Default::default() + }; + let usr = ConfigBuilder { + admin_totp_secret: Some(USER_TOTP_SECRET.to_owned()), + ..Default::default() + }; + let config = config_with(env, usr); + + assert_eq!(config.admin_totp_secret().as_deref(), Some(USER_TOTP_SECRET)); + assert!(config.inner.read().unwrap()._overrides.contains(&"ADMIN_TOTP_SECRET")); + } + + #[test] + fn admin_totp_secret_is_write_only_in_settings_json() { + let env = ConfigBuilder { + admin_totp_secret: Some(ENV_TOTP_SECRET.to_owned()), + ..Default::default() + }; + let usr = ConfigBuilder { + admin_totp_secret: Some(USER_TOTP_SECRET.to_owned()), + ..Default::default() + }; + let config = config_with(env, usr); + let json = config.prepare_json(); + let serialized = json.to_string(); + let support_json = config.get_support_json().to_string(); + + assert!(!serialized.contains(ENV_TOTP_SECRET)); + assert!(!serialized.contains(USER_TOTP_SECRET)); + assert!(!support_json.contains(ENV_TOTP_SECRET)); + assert!(!support_json.contains(USER_TOTP_SECRET)); + + let element = admin_totp_element(&config); + assert_eq!(element["value"], serde_json::Value::Null); + assert_eq!(element["default"], serde_json::Value::Null); + assert_eq!(element["write_only"], true); + assert_eq!(element["configured"], true); + assert_eq!(element["user_configured"], true); + assert_eq!(element["overridden"], true); + + let empty_override = config_with( + ConfigBuilder { + admin_totp_secret: Some(ENV_TOTP_SECRET.to_owned()), + ..Default::default() + }, + ConfigBuilder { + admin_totp_secret: Some(String::new()), + ..Default::default() + }, + ); + let element = admin_totp_element(&empty_override); + assert_eq!(element["configured"], false); + assert_eq!(element["user_configured"], true); + assert_eq!(element["overridden"], true); + } + + #[test] + fn admin_settings_template_never_renders_admin_totp_secret() { + let env = ConfigBuilder { + admin_totp_secret: Some(ENV_TOTP_SECRET.to_owned()), + ..Default::default() + }; + let usr = ConfigBuilder { + admin_totp_secret: Some(USER_TOTP_SECRET.to_owned()), + ..Default::default() + }; + let config = config_with(env, usr); + + let mut handlebars = Handlebars::new(); + handlebars.set_strict_mode(true); + handlebars.register_helper("case", Box::new(case_helper)); + handlebars + .register_template_string("admin/settings", include_str!("static/templates/admin/settings.hbs")) + .unwrap(); + + let rendered = handlebars + .render( + "admin/settings", + &serde_json::json!({ + "page_data": { + "config": config.prepare_json(), + "can_backup": false, + }, + "urlpath": "", + }), + ) + .unwrap(); + + assert!(!rendered.contains(ENV_TOTP_SECRET)); + assert!(!rendered.contains(USER_TOTP_SECRET)); + assert!(rendered.contains("id=\"input_admin_totp_secret\"")); + assert!(rendered.contains("placeholder=\"Configured — enter a new value to replace it\"")); + assert!(rendered.contains("data-bs-target=\"#adminTotpQrDialog\"")); + assert!(rendered.contains("id=\"adminTotpQrCode\"")); + assert!(rendered.contains("/vw_static/qrcode-generator-2.0.4.js")); + assert!(rendered.find("").unwrap() < rendered.find("id=\"adminTotpQrDialog\"").unwrap()); + } + + #[test] + fn admin_totp_secret_form_supports_keep_set_and_clear() { + let current = ConfigBuilder { + admin_totp_secret: Some(USER_TOTP_SECRET.to_owned()), + ..Default::default() + }; + + let mut keep = ConfigBuilder::default(); + keep.prepare_admin_update(¤t); + assert_eq!(keep.admin_totp_secret.as_deref(), Some(USER_TOTP_SECRET)); + + let mut no_saved_override = ConfigBuilder::default(); + no_saved_override.prepare_admin_update(&ConfigBuilder::default()); + assert_eq!(no_saved_override.admin_totp_secret, None); + + let mut replace = ConfigBuilder { + admin_totp_secret: Some(format!(" {} ", ENV_TOTP_SECRET.to_lowercase())), + ..Default::default() + }; + replace.prepare_admin_update(¤t); + assert_eq!(replace.admin_totp_secret.as_deref(), Some(ENV_TOTP_SECRET)); + + let mut clear = ConfigBuilder { + admin_totp_secret: Some(" ".to_owned()), + ..Default::default() + }; + clear.prepare_admin_update(¤t); + assert_eq!(clear.admin_totp_secret, None); + + let env = ConfigBuilder { + admin_totp_secret: Some(ENV_TOTP_SECRET.to_owned()), + ..Default::default() + }; + let mut overrides = Vec::new(); + assert_eq!( + env.merge(&clear, false, &mut overrides).build().admin_totp_secret.as_deref(), + Some(ENV_TOTP_SECRET) + ); + } + + #[test] + fn admin_totp_secret_validation_rejects_empty_short_and_invalid_values() { + for secret in ["", "JBSWY3DPEHPK3PXP", "not-base32"] { + assert!(validate_admin_totp_secret(secret).is_err(), "secret should be rejected: {secret:?}"); + } + assert!(validate_admin_totp_secret(USER_TOTP_SECRET).is_ok()); + } +} diff --git a/src/static/scripts/admin_settings.js b/src/static/scripts/admin_settings.js index 3d61a508..3623539b 100644 --- a/src/static/scripts/admin_settings.js +++ b/src/static/scripts/admin_settings.js @@ -1,6 +1,126 @@ "use strict"; /* eslint-env es2017, browser */ -/* global _post:readable, BASE_URL:readable */ +/* global _post:readable, BASE_URL:readable, qrcode:readable */ + +const ADMIN_TOTP_SECRET_BYTES = 20; +const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; +let generatedAdminTotpSecret = null; + +function encodeBase32(bytes) { + let output = ""; + let buffer = 0; + let bits = 0; + + bytes.forEach(byte => { + buffer = (buffer << 8) | byte; + bits += 8; + + while (bits >= 5) { + bits -= 5; + output += BASE32_ALPHABET[(buffer >>> bits) & 31]; + } + buffer &= (1 << bits) - 1; + }); + + if (bits > 0) { + output += BASE32_ALPHABET[(buffer << (5 - bits)) & 31]; + } + return output; +} + +function buildAdminTotpUri(secret) { + const issuer = "Vaultwarden"; + const account = "Admin"; + const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(account)}`; + return `otpauth://totp/${label}?secret=${secret}&issuer=${encodeURIComponent(issuer)}` + + "&algorithm=SHA1&digits=6&period=30"; +} + +function resetAdminTotpQrDialog() { + generatedAdminTotpSecret = null; + + const secretOutput = document.getElementById("adminTotpQrSecret"); + const qrImage = document.getElementById("adminTotpQrCode"); + const error = document.getElementById("adminTotpQrError"); + const useButton = document.getElementById("adminTotpQrUse"); + + secretOutput.value = ""; + qrImage.removeAttribute("src"); + qrImage.classList.add("d-none"); + error.textContent = ""; + error.classList.add("d-none"); + useButton.disabled = true; +} + +function showAdminTotpQrError(message) { + const error = document.getElementById("adminTotpQrError"); + error.textContent = message; + error.classList.remove("d-none"); +} + +function generateAdminTotpQr() { + resetAdminTotpQrDialog(); + + if (!window.crypto || typeof window.crypto.getRandomValues !== "function") { + showAdminTotpQrError("Secure random number generation is not available in this browser."); + return; + } + if (typeof qrcode !== "function") { + showAdminTotpQrError("The local QR code generator could not be loaded."); + return; + } + + const randomBytes = new Uint8Array(ADMIN_TOTP_SECRET_BYTES); + let secret; + try { + window.crypto.getRandomValues(randomBytes); + secret = encodeBase32(randomBytes); + } catch (_error) { + showAdminTotpQrError("Unable to generate a secure TOTP secret in this browser."); + return; + } finally { + randomBytes.fill(0); + } + + try { + const qr = qrcode(0, "M"); + qr.addData(buildAdminTotpUri(secret), "Byte"); + qr.make(); + + generatedAdminTotpSecret = secret; + document.getElementById("adminTotpQrSecret").value = secret; + const qrImage = document.getElementById("adminTotpQrCode"); + qrImage.src = qr.createDataURL(5, 20); + qrImage.classList.remove("d-none"); + document.getElementById("adminTotpQrUse").disabled = false; + } catch (_error) { + resetAdminTotpQrDialog(); + showAdminTotpQrError("Unable to create the QR code in this browser."); + } +} + +function regenerateAdminTotpQr() { + if (generatedAdminTotpSecret && + !confirm("Generate a different secret? If you already scanned this QR code, you will need to scan the new one.")) { + return; + } + generateAdminTotpQr(); +} + +function useGeneratedAdminTotpSecret() { + if (!generatedAdminTotpSecret) { + return; + } + + const input = document.getElementById("input_admin_totp_secret"); + const clear = document.querySelector('[data-vw-clear-target="input_admin_totp_secret"]'); + input.disabled = false; + input.value = generatedAdminTotpSecret; + if (clear) { + clear.checked = false; + } + input.dispatchEvent(new Event("input", { bubbles: true })); +} function smtpTest(event) { event.preventDefault(); @@ -40,6 +160,17 @@ function getFormData() { document.querySelectorAll(".conf-text, .conf-password").forEach(function (e) { data[e.name] = e.value || null; }); + + document.querySelectorAll(".conf-write-only").forEach(function (e) { + const clear = document.querySelector(`[data-vw-clear-target="${e.id}"]`); + if (clear && clear.checked) { + // An explicit empty string removes the saved config value. + data[e.name] = ""; + } else if (e.value.trim()) { + // Omitting an untouched write-only field keeps the currently saved value. + data[e.name] = e.value.trim(); + } + }); return data; } @@ -141,6 +272,14 @@ function toggleVis(event) { } } +function toggleWriteOnlyClear(event) { + const input = document.getElementById(event.target.dataset.vwClearTarget); + input.disabled = event.target.checked; + if (event.target.checked) { + input.value = ""; + } +} + function masterCheck(check_id, inputs_query) { function onChanged(checkbox, inputs_query) { return function _fn() { @@ -213,6 +352,18 @@ document.addEventListener("DOMContentLoaded", (/*event*/) => { password_toggle_btn.addEventListener("click", toggleVis); }); + document.querySelectorAll("input[data-vw-clear-target]").forEach(clear_checkbox => { + clear_checkbox.addEventListener("change", toggleWriteOnlyClear); + }); + + const adminTotpQrDialog = document.getElementById("adminTotpQrDialog"); + if (adminTotpQrDialog) { + adminTotpQrDialog.addEventListener("show.bs.modal", generateAdminTotpQr); + adminTotpQrDialog.addEventListener("hidden.bs.modal", resetAdminTotpQrDialog); + document.getElementById("adminTotpQrRegenerate").addEventListener("click", regenerateAdminTotpQr); + document.getElementById("adminTotpQrUse").addEventListener("click", useGeneratedAdminTotpSecret); + } + const btnBackupDatabase = document.getElementById("backupDatabase"); if (btnBackupDatabase) { btnBackupDatabase.addEventListener("click", backupDatabase); @@ -229,4 +380,4 @@ document.addEventListener("DOMContentLoaded", (/*event*/) => { config_form.addEventListener("submit", saveConfig); showWarnings(); -}); \ No newline at end of file +}); diff --git a/src/static/scripts/admin_totp.js b/src/static/scripts/admin_totp.js deleted file mode 100644 index a07962b6..00000000 --- a/src/static/scripts/admin_totp.js +++ /dev/null @@ -1,63 +0,0 @@ -"use strict"; -/* eslint-env es2017, browser */ -/* global BASE_URL:readable, _post:readable */ - -function totpGenerate() { - fetch(`${BASE_URL}/admin/totp/generate`, { - method: "POST", - mode: "same-origin", - credentials: "same-origin", - headers: { "Content-Type": "application/json" }, - }).then(resp => { - if (!resp.ok) { - return resp.text().then(body => Promise.reject(body)); - } - return resp.json(); - }).then(data => { - const enroll = document.getElementById("totp-enroll"); - enroll.dataset.secret = data.secret; - document.getElementById("totp-qr").innerHTML = data.qr_svg; - document.getElementById("totp-secret").textContent = data.secret; - enroll.classList.remove("d-none"); - document.getElementById("totp-generate").textContent = "Generate a new secret"; - document.getElementById("totp-enable-code").focus(); - }).catch(e => { - alert(`Failed to generate a TOTP secret\n${e}`); - }); -} - -function totpEnable() { - const secret = document.getElementById("totp-enroll").dataset.secret; - const code = document.getElementById("totp-enable-code").value.trim(); - if (!code) { - alert("Please enter the 6-digit code from your authenticator app"); - return; - } - _post(`${BASE_URL}/admin/totp/enable`, - "2FA for the admin page is now enabled", - "Failed to enable 2FA", - JSON.stringify({ secret, code }) - ); -} - -function totpDisable() { - const code = document.getElementById("totp-disable-code").value.trim(); - if (!code) { - alert("Please enter the current 6-digit code from your authenticator app"); - return; - } - if (!confirm("Really disable two-factor authentication for the admin page?")) { - return; - } - _post(`${BASE_URL}/admin/totp/disable`, - "2FA for the admin page is now disabled", - "Failed to disable 2FA", - JSON.stringify({ code }) - ); -} - -document.addEventListener("DOMContentLoaded", (/*event*/) => { - document.getElementById("totp-generate")?.addEventListener("click", totpGenerate); - document.getElementById("totp-enable")?.addEventListener("click", totpEnable); - document.getElementById("totp-disable")?.addEventListener("click", totpDisable); -}); diff --git a/src/static/scripts/qrcode-generator-2.0.4.LICENSE.txt b/src/static/scripts/qrcode-generator-2.0.4.LICENSE.txt new file mode 100644 index 00000000..150770c0 --- /dev/null +++ b/src/static/scripts/qrcode-generator-2.0.4.LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2009 Kazuhiko Arase + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/static/scripts/qrcode-generator-2.0.4.js b/src/static/scripts/qrcode-generator-2.0.4.js new file mode 100644 index 00000000..860c1fb6 --- /dev/null +++ b/src/static/scripts/qrcode-generator-2.0.4.js @@ -0,0 +1,2 @@ +/*! qrcode-generator 2.0.4 | Copyright (c) 2009 Kazuhiko Arase | MIT | https://github.com/kazuhikoarase/qrcode-generator/tree/js2.0.4 | Minified with Terser 5.44.0 */ +var qrcode=function(){var t=function(t,r){var e=t,n=g[r],o=null,i=0,a=null,u=[],f={},c=function(t,r){o=function(t){for(var r=new Array(t),e=0;e=7&&v(t),null==a&&(a=p(e,n,u)),w(a,r)},l=function(t,r){for(var e=-1;e<=7;e+=1)if(!(t+e<=-1||i<=t+e))for(var n=-1;n<=7;n+=1)r+n<=-1||i<=r+n||(o[t+e][r+n]=0<=e&&e<=6&&(0==n||6==n)||0<=n&&n<=6&&(0==e||6==e)||2<=e&&e<=4&&2<=n&&n<=4)},h=function(){for(var t=8;t>n&1);o[Math.floor(n/3)][n%3+i-8-3]=a}for(n=0;n<18;n+=1){a=!t&&1==(r>>n&1);o[n%3+i-8-3][Math.floor(n/3)]=a}},d=function(t,r){for(var e=n<<3|r,a=B.getBCHTypeInfo(e),u=0;u<15;u+=1){var f=!t&&1==(a>>u&1);u<6?o[u][8]=f:u<8?o[u+1][8]=f:o[i-15+u][8]=f}for(u=0;u<15;u+=1){f=!t&&1==(a>>u&1);u<8?o[8][i-u-1]=f:u<9?o[8][15-u-1+1]=f:o[8][15-u-1]=f}o[i-8][8]=!t},w=function(t,r){for(var e=-1,n=i-1,a=7,u=0,f=B.getMaskFunction(r),c=i-1;c>0;c-=2)for(6==c&&(c-=1);;){for(var g=0;g<2;g+=1)if(null==o[n][c-g]){var l=!1;u>>a&1)),f(n,c-g)&&(l=!l),o[n][c-g]=l,-1==(a-=1)&&(u+=1,a=7)}if((n+=e)<0||i<=n){n-=e,e=-e;break}}},p=function(t,r,e){for(var n=A.getRSBlocks(t,r),o=b(),i=0;i8*u)throw"code length overflow. ("+o.getLengthInBits()+">"+8*u+")";for(o.getLengthInBits()+4<=8*u&&o.put(0,4);o.getLengthInBits()%8!=0;)o.putBit(!1);for(;!(o.getLengthInBits()>=8*u||(o.put(236,8),o.getLengthInBits()>=8*u));)o.put(17,8);return function(t,r){for(var e=0,n=0,o=0,i=new Array(r.length),a=new Array(r.length),u=0;u=0?h.getAt(s):0}}var v=0;for(g=0;gn)&&(t=n,r=e)}return r}())},f.createTableTag=function(t,r){t=t||2;var e="";e+='',e+="";for(var n=0;n";for(var o=0;o';e+=""}return e+="",e+="
"},f.createSvgTag=function(t,r,e,n){var o={};"object"==typeof arguments[0]&&(t=(o=arguments[0]).cellSize,r=o.margin,e=o.alt,n=o.title),t=t||2,r=void 0===r?4*t:r,(e="string"==typeof e?{text:e}:e||{}).text=e.text||null,e.id=e.text?e.id||"qrcode-description":null,(n="string"==typeof n?{text:n}:n||{}).text=n.text||null,n.id=n.text?n.id||"qrcode-title":null;var i,a,u,c,g=f.getModuleCount()*t+2*r,l="";for(c="l"+t+",0 0,"+t+" -"+t+",0 0,-"+t+"z ",l+=''+y(n.text)+"":"",l+=e.text?''+y(e.text)+"":"",l+='',l+='":r+=">";break;case"&":r+="&";break;case'"':r+=""";break;default:r+=n}}return r};return f.createASCII=function(t,r){if((t=t||1)<2)return function(t){t=void 0===t?2:t;var r,e,n,o,i,a=1*f.getModuleCount()+2*t,u=t,c=a-t,g={"██":"█","█ ":"▀"," █":"▄"," ":" "},l={"██":"▀","█ ":"▀"," █":" "," ":" "},h="";for(r=0;r=c?l[i]:g[i];h+="\n"}return a%2&&t>0?h.substring(0,h.length-a-1)+Array(a+1).join("▀"):h.substring(0,h.length-1)}(r);t-=1,r=void 0===r?2*t:r;var e,n,o,i,a=f.getModuleCount()*t+2*r,u=r,c=a-r,g=Array(t+1).join("██"),l=Array(t+1).join(" "),h="",s="";for(e=0;e>>8),r.push(255&a)):r.push(n)}}return r}};var r,e,n,o,i,a=1,u=2,f=4,c=8,g={L:1,M:0,Q:3,H:2},l=0,h=1,s=2,v=3,d=4,w=5,p=6,y=7,B=(r=[[],[6,18],[6,22],[6,26],[6,30],[6,34],[6,22,38],[6,24,42],[6,26,46],[6,28,50],[6,30,54],[6,32,58],[6,34,62],[6,26,46,66],[6,26,48,70],[6,26,50,74],[6,30,54,78],[6,30,56,82],[6,30,58,86],[6,34,62,90],[6,28,50,72,94],[6,26,50,74,98],[6,30,54,78,102],[6,28,54,80,106],[6,32,58,84,110],[6,30,58,86,114],[6,34,62,90,118],[6,26,50,74,98,122],[6,30,54,78,102,126],[6,26,52,78,104,130],[6,30,56,82,108,134],[6,34,60,86,112,138],[6,30,58,86,114,142],[6,34,62,90,118,146],[6,30,54,78,102,126,150],[6,24,50,76,102,128,154],[6,28,54,80,106,132,158],[6,32,58,84,110,136,162],[6,26,54,82,110,138,166],[6,30,58,86,114,142,170]],e=1335,n=7973,i=function(t){for(var r=0;0!=t;)r+=1,t>>>=1;return r},(o={}).getBCHTypeInfo=function(t){for(var r=t<<10;i(r)-i(e)>=0;)r^=e<=0;)r^=n<5&&(e+=3+i-5)}for(n=0;n=256;)r-=255;return t[r]}};return n}();function k(t,r){if(void 0===t.length)throw t.length+"/"+r;var e=function(){for(var e=0;e>>7-r%8&1)},put:function(t,r){for(var n=0;n>>r-n-1&1))},getLengthInBits:function(){return r},putBit:function(e){var n=Math.floor(r/8);t.length<=n&&t.push(0),e&&(t[n]|=128>>>r%8),r+=1}};return e},M=function(t){var r=a,e=t,n={getMode:function(){return r},getLength:function(t){return e.length},write:function(t){for(var r=e,n=0;n+2>>8&255)+(255&n),t.put(n,13),e+=2}if(e>>8)},writeBytes:function(t,e,n){e=e||0,n=n||t.length;for(var o=0;o0&&(r+=","),r+=t[e];return r+="]"}};return r},S=function(t){var r=t,e=0,n=0,o=0,i={read:function(){for(;o<8;){if(e>=r.length){if(0==o)return-1;throw"unexpected end of file./"+o}var t=r.charAt(e);if(e+=1,"="==t)return o=0,-1;t.match(/^\s$/)||(n=n<<6|a(t.charCodeAt(0)),o+=6)}var i=n>>>o-8&255;return o-=8,i}},a=function(t){if(65<=t&&t<=90)return t-65;if(97<=t&&t<=122)return t-97+26;if(48<=t&&t<=57)return t-48+52;if(43==t)return 62;if(47==t)return 63;throw"c:"+t};return i},I=function(t,r,e){for(var n=function(t,r){var e=t,n=r,o=new Array(t*r),i={setPixel:function(t,r,n){o[r*e+t]=n},write:function(t){t.writeString("GIF87a"),t.writeShort(e),t.writeShort(n),t.writeByte(128),t.writeByte(0),t.writeByte(0),t.writeByte(0),t.writeByte(0),t.writeByte(0),t.writeByte(255),t.writeByte(255),t.writeByte(255),t.writeString(","),t.writeShort(0),t.writeShort(0),t.writeShort(e),t.writeShort(n),t.writeByte(0);var r=a(2);t.writeByte(2);for(var o=0;r.length-o>255;)t.writeByte(255),t.writeBytes(r,o,255),o+=255;t.writeByte(r.length-o),t.writeBytes(r,o,r.length-o),t.writeByte(0),t.writeString(";")}},a=function(t){for(var r=1<>>r!=0)throw"length over";for(;c+r>=8;)f.writeByte(255&(t<>>=8-c,g=0,c=0;g|=t<0&&f.writeByte(g)}});h.write(r,n);var s=0,v=String.fromCharCode(o[s]);for(s+=1;s=6;)i(t>>>r-6),r-=6},o.flush=function(){if(r>0&&(i(t<<6-r),t=0,r=0),e%3!=0)for(var o=3-e%3,a=0;a>6,128|63&n):n<55296||n>=57344?r.push(224|n>>12,128|n>>6&63,128|63&n):(e++,n=65536+((1023&n)<<10|1023&t.charCodeAt(e)),r.push(240|n>>18,128|n>>12&63,128|n>>6&63,128|63&n))}return r}(t)},function(t){"function"==typeof define&&define.amd?define([],t):"object"==typeof exports&&(module.exports=t())}(function(){return qrcode}); diff --git a/src/static/templates/admin/base.hbs b/src/static/templates/admin/base.hbs index aa63fd2a..e1dcacb5 100644 --- a/src/static/templates/admin/base.hbs +++ b/src/static/templates/admin/base.hbs @@ -48,9 +48,6 @@ - {{/if}}