Browse Source

Merge 4e3d999e89 into 660faee68e

pull/7444/merge
Tom 24 hours ago
committed by GitHub
parent
commit
2c4e5be1c2
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 11
      .env.template
  2. 68
      src/api/admin.rs
  3. 108
      src/api/core/two_factor/authenticator.rs
  4. 16
      src/api/web.rs
  5. 253
      src/config.rs
  6. 155
      src/static/scripts/admin_settings.js
  7. 21
      src/static/scripts/qrcode-generator-2.0.4.LICENSE.txt
  8. 2
      src/static/scripts/qrcode-generator-2.0.4.js
  9. 3
      src/static/templates/admin/login.hbs
  10. 74
      src/static/templates/admin/settings.hbs

11
.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

68
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<Ht
"page_content": "admin/login",
"error": msg,
"redirect": redirect,
"totp_enabled": configured_admin_totp_secret().is_some(),
"urlpath": CONFIG.domain_path()
});
@ -178,6 +182,7 @@ fn render_admin_login(msg: Option<&str>, redirect: Option<&str>) -> ApiResult<Ht
#[derive(FromForm)]
struct LoginForm {
token: String,
totp: Option<String>,
redirect: Option<String>,
}
@ -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<Option<(String, i64)>> = Mutex::new(None);
fn configured_admin_totp_secret() -> Option<String> {
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,

108
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::<Sha1>(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::<Sha1>(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<DisableAuthenticatorData>, 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::<Sha1>(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, &current, 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, &current, 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));
}
}

16
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"));
}
}

253
src/config.rs

@ -54,9 +54,11 @@ pub static CONFIG: LazyLock<Config> = 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<String> 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<GroupData> = 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(&current_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("</form>").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(&current);
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(&current);
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(&current);
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());
}
}

155
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();
});
});

21
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.

2
src/static/scripts/qrcode-generator-2.0.4.js

File diff suppressed because one or more lines are too long

3
src/static/templates/admin/login.hbs

@ -14,6 +14,9 @@
<form class="form-inline" method="post" action="{{urlpath}}/admin">
<input type="password" autocomplete="password" class="form-control w-50 mr-2" name="token" placeholder="Enter admin token" autofocus="autofocus">
{{#if totp_enabled}}
<input type="text" inputmode="numeric" autocomplete="one-time-code" maxlength="6" class="form-control w-50 mr-2 mt-2" name="totp" placeholder="Enter 2FA code">
{{/if}}
{{#if redirect}}
<input type="hidden" id="redirect" name="redirect" value="/{{redirect}}">
{{/if}}

74
src/static/templates/admin/settings.hbs

@ -26,6 +26,40 @@
{{#case type "text" "number" "password"}}
<label for="input_{{name}}" class="col-sm-3 col-form-label">{{doc.name}}</label>
<div class="col-sm-8">
{{#if write_only}}
<div class="input-group">
<input class="form-control conf-write-only" id="input_{{name}}" type="password"
name="{{name}}" value="" autocomplete="off" spellcheck="false"
{{#if configured}}
placeholder="Configured — enter a new value to replace it"
{{else}}
placeholder="Enter a new value"
{{/if}}>
{{#if (eq name "admin_totp_secret")}}
<button class="btn btn-outline-primary input-group-text" type="button"
data-bs-toggle="modal" data-bs-target="#adminTotpQrDialog">Generate QR</button>
{{/if}}
<button class="btn btn-outline-secondary input-group-text" type="button" data-vw-pw-toggle="input_{{name}}">Show/hide</button>
</div>
{{#if user_configured}}
<div class="form-check mt-1">
<input class="form-check-input conf-write-only-clear" type="checkbox" id="clear_{{name}}"
data-vw-clear-target="input_{{name}}">
<label class="form-check-label" for="clear_{{name}}">
{{#if overridden}}
Remove saved override (the environment value will become active)
{{else}}
Remove saved value
{{/if}}
</label>
</div>
{{else}}
{{#if configured}}
<div class="form-text">Set by the environment. Enter a value to save a config override.</div>
{{/if}}
{{/if}}
<div class="form-text">When replacing this secret, add the new value to your authenticator before saving. Closing the current session before testing it may lock you out.</div>
{{else}}
<div class="input-group">
<input class="form-control conf-{{type}}" id="input_{{name}}" type="{{type}}"
name="{{name}}" value="{{value}}" {{#if default}} placeholder="Default: {{default}}"{{/if}}>
@ -33,6 +67,7 @@
<button class="btn btn-outline-secondary input-group-text" type="button" data-vw-pw-toggle="input_{{name}}">Show/hide</button>
{{/case}}
</div>
{{/if}}
</div>
{{/case}}
{{#case type "checkbox"}}
@ -140,6 +175,39 @@
</form>
</div>
</div>
<div id="adminTotpQrDialog" class="modal fade" tabindex="-1" aria-labelledby="adminTotpQrDialogTitle" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="adminTotpQrDialogTitle">Set up Admin TOTP</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>
A new 20-byte secret and QR code are generated only in this browser. Nothing is sent or saved
until you use the generated secret and save the configuration.
</p>
<div id="adminTotpQrError" class="alert alert-danger d-none" role="alert"></div>
<div class="text-center mb-3">
<img id="adminTotpQrCode" class="d-none img-fluid border rounded" alt="QR code for the generated Admin TOTP secret">
</div>
<label for="adminTotpQrSecret" class="form-label">Manual setup key</label>
<input id="adminTotpQrSecret" class="form-control font-monospace" type="text" value="" readonly
autocomplete="off" spellcheck="false">
<div class="alert alert-warning mt-3 mb-0">
Scan the QR code before using this secret. This helper does not verify your authenticator.
Keep the current admin session open and test the new code before logging out.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" id="adminTotpQrRegenerate">Generate another</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="adminTotpQrUse" data-bs-dismiss="modal" disabled>Use generated secret</button>
</div>
</div>
</div>
</div>
</main>
<style>
#config-block ::placeholder {
@ -152,5 +220,11 @@
--bs-alert-bg: #fff3cd;
--bs-alert-border-color: #ffecb5;
}
#adminTotpQrCode {
width: 285px;
height: auto;
}
</style>
<script src="{{urlpath}}/vw_static/qrcode-generator-2.0.4.js"></script>
<script src="{{urlpath}}/vw_static/admin_settings.js"></script>

Loading…
Cancel
Save