diff --git a/.env.template b/.env.template index 0d922774..70aa78eb 100644 --- a/.env.template +++ b/.env.template @@ -428,6 +428,17 @@ ## Old plain text string (Will generate warnings in favor of Argon2) # ADMIN_TOKEN=Vy2VyYTTsKPv8W5aEOWUbB/Bt3DEKePbHmI4m9VcemUMS2rEviDowNAFqYi1xjmp +## 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 (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 +## 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=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 # DISABLE_ADMIN_TOKEN=false diff --git a/src/api/admin.rs b/src/api/admin.rs index 7037bfb1..177a8077 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -1,4 +1,7 @@ -use std::{env, sync::LazyLock}; +use std::{ + env, + sync::{LazyLock, Mutex}, +}; use reqwest::Method; use rocket::{ @@ -167,6 +170,7 @@ fn render_admin_login(msg: Option<&str>, redirect: Option<&str>) -> ApiResult, redirect: Option<&str>) -> ApiResult, redirect: Option, } @@ -198,8 +203,8 @@ fn post_admin_login( ))); } - // If the token is invalid, redirect to login page - if validate_token(&data.token) { + // If the token or the TOTP code is invalid, redirect to login page + if validate_token(&data.token) && validate_totp(data.totp.as_deref()) { // If the token received is valid, generate JWT and save it as a cookie let claims = generate_admin_claims(); let jwt = encode_jwt(&claims); @@ -218,9 +223,9 @@ fn post_admin_login( Err(AdminResponse::Ok(render_admin_page())) } } else { - error!("Invalid admin token. IP: {}", ip.ip); + error!("Invalid admin credentials. IP: {}", ip.ip); Err(AdminResponse::Unauthorized(render_admin_login( - Some("Invalid admin token, please try again."), + Some("Invalid credentials, please try again."), redirect.as_deref(), ))) } @@ -246,6 +251,59 @@ fn validate_token(token: &str) -> bool { } } +// 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(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) = configured_admin_totp_secret() else { + // No TOTP secret configured, the admin token alone is sufficient + return true; + }; + let Some(code) = code.map(str::trim) else { + return false; + }; + check_totp_code(&secret, code) +} + +fn check_totp_code(secret: &str, code: &str) -> bool { + use two_factor::authenticator::{TotpValidation, verify_totp}; + + 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 { + error!("The configured admin TOTP secret is not a valid base32-encoded string"); + return false; + }; + + let current_timestamp = chrono::Utc::now().timestamp(); + 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) { + TotpValidation::Accepted(time_step) => { + *replay_state = Some((fingerprint, time_step)); + true + } + TotpValidation::Reused => { + warn!("This admin TOTP code has already been used"); + false + } + TotpValidation::Rejected => false, + } +} + #[derive(Serialize)] struct AdminTemplateData { page_content: String, diff --git a/src/api/core/two_factor/authenticator.rs b/src/api/core/two_factor/authenticator.rs index 692e8248..270946d6 100644 --- a/src/api/core/two_factor/authenticator.rs +++ b/src/api/core/two_factor/authenticator.rs @@ -112,6 +112,41 @@ pub async fn validate_totp_code_str( validate_totp_code(user_id, totp_code, secret, ip, conn).await } +/// The outcome of checking a TOTP `code` against the allowed time window. +pub(crate) enum TotpValidation { + /// The code is valid. Contains the accepted time step, which must be stored as the + /// new "last used" value so the same code cannot be reused. + Accepted(i64), + /// The code matched a time step that has already been used (replay protection). + Reused, + /// The code did not match any step within the allowed window. + Rejected, +} + +/// Verifies a 6-digit TOTP `code` against the already base32-decoded `secret`, allowing +/// `steps` (>= 0) time steps of ±30 seconds drift in either direction and rejecting any +/// time step that is not newer than `last_used`. The comparison is constant-time. +/// Shared by the user 2FA flow and the admin-page 2FA. +pub(crate) fn verify_totp(secret: &[u8], code: &str, timestamp: i64, last_used: i64, steps: i64) -> TotpValidation { + use totp_lite::{Sha1, totp_custom}; + + for step in -steps..=steps { + let time_step = timestamp / 30i64 + step; + // The generator needs the time as an u64; we only ever deal with times >= 0. + let time: u64 = (timestamp + step * 30i64).cast_unsigned(); + let generated = totp_custom::(30, 6, secret, time); + + if crypto::ct_eq(&generated, code) { + return if time_step > last_used { + TotpValidation::Accepted(time_step) + } else { + TotpValidation::Reused + }; + } + } + TotpValidation::Rejected +} + pub async fn validate_totp_code( user_id: &UserId, totp_code: &str, @@ -119,8 +154,6 @@ pub async fn validate_totp_code( ip: &ClientIp, conn: &DbConn, ) -> EmptyResult { - use totp_lite::{Sha1, totp_custom}; - let Ok(decoded_secret) = BASE32.decode(secret.as_bytes()) else { err!("Invalid TOTP secret") }; @@ -140,17 +173,10 @@ pub async fn validate_totp_code( let current_time = chrono::Utc::now(); let current_timestamp = current_time.timestamp(); - for step in -steps..=steps { - let time_step = current_timestamp / 30i64 + step; - - // We need to calculate the time offsite and cast it as an u64. - // Since we only have times into the future and the totp generator needs an u64 instead of the default i64. - let time: u64 = (current_timestamp + step * 30i64).cast_unsigned(); - let generated = totp_custom::(30, 6, &decoded_secret, time); - - // Check the given code equals the generated and if the time_step is larger then the one last used. - if generated == totp_code && time_step > twofactor.last_used { - // If the step does not equals 0 the time is drifted either server or client side. + match verify_totp(&decoded_secret, totp_code, current_timestamp, twofactor.last_used, steps) { + TotpValidation::Accepted(time_step) => { + // If the accepted step is not the current one the time is drifted either server or client side. + let step = time_step - current_timestamp / 30i64; if step != 0 { warn!("TOTP Time drift detected. The step offset is {step}"); } @@ -159,25 +185,27 @@ pub async fn validate_totp_code( // This will also save a newly created twofactor if the code is correct. twofactor.last_used = time_step; twofactor.save(conn).await?; - return Ok(()); - } else if generated == totp_code && time_step <= twofactor.last_used { + Ok(()) + } + TotpValidation::Reused => { warn!("This TOTP or a TOTP code within {steps} steps back or forward has already been used!"); err!( format!("Invalid TOTP code! Server time: {} IP: {}", current_time.format("%F %T UTC"), ip.ip), ErrorEvent { event: EventType::UserFailedLogIn2fa } - ); + ) } - } - - // Else no valid code received, deny access - err!( - format!("Invalid TOTP code! Server time: {} IP: {}", current_time.format("%F %T UTC"), ip.ip), - ErrorEvent { - event: EventType::UserFailedLogIn2fa + // Else no valid code received, deny access + TotpValidation::Rejected => { + err!( + format!("Invalid TOTP code! Server time: {} IP: {}", current_time.format("%F %T UTC"), ip.ip), + ErrorEvent { + event: EventType::UserFailedLogIn2fa + } + ) } - ); + } } #[derive(Debug, Deserialize)] @@ -217,3 +245,35 @@ async fn disable_authenticator(data: Json, headers: He "object": "twoFactorProvider" }))) } + +#[cfg(test)] +mod tests { + use super::{TotpValidation, verify_totp}; + use data_encoding::BASE32; + use totp_lite::{Sha1, totp_custom}; + + fn code_at(secret: &[u8], timestamp: i64) -> String { + totp_custom::(30, 6, secret, timestamp.cast_unsigned()) + } + + #[test] + fn totp_accepts_rejects_and_detects_reuse() { + let secret = BASE32.decode(b"JBSWY3DPEHPK3PXP").unwrap(); + let ts = 1_700_000_000i64; + let step = ts / 30; + let current = code_at(&secret, ts); + + // A fresh current code is accepted and reports the current time step. + assert!(matches!(verify_totp(&secret, ¤t, ts, 0, 1), TotpValidation::Accepted(s) if s == step)); + // The same code is rejected as reused once its step has been recorded. + assert!(matches!(verify_totp(&secret, ¤t, ts, step, 1), TotpValidation::Reused)); + // A wrong code is rejected. + assert!(matches!(verify_totp(&secret, "000000", ts, 0, 1), TotpValidation::Rejected)); + + // The previous 30s window is accepted with one step of drift... + let previous = code_at(&secret, ts - 30); + assert!(matches!(verify_totp(&secret, &previous, ts, 0, 1), TotpValidation::Accepted(_))); + // ...but rejected when drift is disabled (steps = 0). + assert!(matches!(verify_totp(&secret, &previous, ts, 0, 0), TotpValidation::Rejected)); + } +} diff --git a/src/api/web.rs b/src/api/web.rs index 5bd4c85d..e66bbcbd 100644 --- a/src/api/web.rs +++ b/src/api/web.rs @@ -256,6 +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"))), + "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"))) @@ -274,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 49281b6c..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 }; @@ -336,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, @@ -362,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" @@ -377,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![ @@ -395,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>]))), @@ -652,6 +672,9 @@ 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 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(); @@ -920,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)] @@ -1258,6 +1305,10 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { } _ => {} } + + if let Some(ref secret) = cfg.admin_totp_secret { + validate_admin_totp_secret(secret)?; + } } if cfg.increase_note_size_limit { @@ -1450,8 +1501,11 @@ 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 + // 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(); } @@ -1737,7 +1791,6 @@ where reg!("admin/users"); reg!("admin/organizations"); reg!("admin/diagnostics"); - reg!("404"); reg!(@withfallback "scss/vaultwarden.scss"); @@ -1824,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/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/login.hbs b/src/static/templates/admin/login.hbs index 2d36faba..e9d088da 100644 --- a/src/static/templates/admin/login.hbs +++ b/src/static/templates/admin/login.hbs @@ -14,6 +14,9 @@
+ {{#if write_only}} +
+ + {{#if (eq name "admin_totp_secret")}} + + {{/if}} + +
+ {{#if user_configured}} +
+ + +
+ {{else}} + {{#if configured}} +
Set by the environment. Enter a value to save a config override.
+ {{/if}} + {{/if}} +
When replacing this secret, add the new value to your authenticator before saving. Closing the current session before testing it may lock you out.
+ {{else}}
@@ -33,6 +67,7 @@ {{/case}}
+ {{/if}}
{{/case}} {{#case type "checkbox"}} @@ -140,6 +175,39 @@ + + +