From 14139cbd7a89f3574359d6b12df72f0fb7fb6775 Mon Sep 17 00:00:00 2001 From: tom27052006 <83423411+tom27052006@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:44:58 +0200 Subject: [PATCH] Move the device approvals page to the web vault The stand-in page in the admin panel asked an administrator of an organization for their vault master password inside the panel of the server operator, which are two different roles, and it carried its own partial login: no Argon2, no two-step login, and no fingerprint of the asking device to compare against. The page belongs where that already exists. The web vault has the whole login stack and, in its AGPL part, the same unwrap the approval needs, since account recovery does it too. Upstream keeps only the page itself in the licensed part; the navigation entry and the string for it are already in every build. So the endpoints stay and the page goes, to be added to the web vault build instead. The notification mail points at the route it lives under there. A member who loses every trusted device is not stranded meanwhile: account recovery gets them back in under the same conditions, at the price of a new master password. --- .env.template | 3 + src/api/admin.rs | 13 - src/api/core/accounts.rs | 4 +- src/api/web.rs | 3 - src/config.rs | 1 - src/mail.rs | 7 +- src/static/scripts/admin_device_approvals.js | 342 ------------------ src/static/templates/admin/base.hbs | 3 - .../templates/admin/device_approvals.hbs | 68 ---- 9 files changed, 10 insertions(+), 434 deletions(-) delete mode 100644 src/static/scripts/admin_device_approvals.js delete mode 100644 src/static/templates/admin/device_approvals.hbs diff --git a/.env.template b/.env.template index dbae390a..30ec49f0 100644 --- a/.env.template +++ b/.env.template @@ -559,6 +559,9 @@ ## To turn this off again, clear this setting but leave `SSO_ENABLED` on: users without a master ## password keep receiving their keys while they still have a trusted device, so their client can ## walk them through setting one. Turning off `SSO_ENABLED` instead leaves them no way to log in. +## Answering a device approval needs the "Device approvals" page of the organization settings, +## which a stock web vault does not build. Without it, a member who lost every trusted device is +## recovered through account recovery instead, which works but hands them a new master password. # SSO_TRUSTED_DEVICE_ENCRYPTION=false ######################## diff --git a/src/api/admin.rs b/src/api/admin.rs index 2cd38792..7037bfb1 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -67,7 +67,6 @@ pub fn routes() -> Vec { users_overview, organizations_overview, delete_organization, - device_approvals, diagnostics, get_diagnostics_config, resend_user_invite, @@ -616,18 +615,6 @@ async fn delete_organization(org_id: OrganizationId, _token: AdminToken, conn: D org.delete(&conn).await } -/// Stand-in for the "Device approvals" page of the admin console, which lives in the part of -/// bitwarden/clients that is not AGPL licensed and is therefore in no web vault build. -/// -/// This page only serves the markup. Everything else happens in the browser against the regular -/// API, because answering a request needs the master password of an administrator of the -/// organization: the server keeps its private key encrypted with a key it does not have. -#[get("/device-approvals")] -fn device_approvals(_token: AdminToken) -> ApiResult> { - let text = AdminTemplateData::new("admin/device_approvals", json!({})).render()?; - Ok(Html(text)) -} - #[derive(Deserialize)] struct GitRelease { tag_name: String, diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index 22282d28..2852a3bd 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -2019,7 +2019,9 @@ async fn notify_device_approval_requested(user: &User, org_id: &OrganizationId, continue; }; - if let Err(e) = mail::send_device_approval_requested(&admin.email, &org.name, &user.email, &user.name).await { + if let Err(e) = + mail::send_device_approval_requested(&admin.email, org_id, &org.name, &user.email, &user.name).await + { error!("Error sending device approval request email: {e:#?}"); } } diff --git a/src/api/web.rs b/src/api/web.rs index 7bca3583..5bd4c85d 100644 --- a/src/api/web.rs +++ b/src/api/web.rs @@ -260,9 +260,6 @@ pub fn static_files(filename: &str) -> Result<(ContentType, &'static [u8]), Erro "admin_organizations.js" => { Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_organizations.js"))) } - "admin_device_approvals.js" => { - Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_device_approvals.js"))) - } "admin_diagnostics.js" => { Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_diagnostics.js"))) } diff --git a/src/config.rs b/src/config.rs index f581697f..61b8c697 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1769,7 +1769,6 @@ where reg!("admin/settings"); reg!("admin/users"); reg!("admin/organizations"); - reg!("admin/device_approvals"); reg!("admin/diagnostics"); reg!("404"); diff --git a/src/mail.rs b/src/mail.rs index ac4336e4..d3de1ef1 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -536,6 +536,7 @@ pub async fn send_new_device_logged_in(address: &str, ip: &str, dt: &NaiveDateTi /// of their own left to ask. pub async fn send_device_approval_requested( address: &str, + org_id: &OrganizationId, org_name: &str, user_email: &str, user_name: &str, @@ -543,9 +544,9 @@ pub async fn send_device_approval_requested( let (subject, body_html, body_text) = get_text( "email/device_approval_requested", json!({ - // The page that can actually answer these, which is in the admin panel rather than in - // the web vault: the one upstream uses is not part of any open source build. - "url": format!("{}/admin/device-approvals", CONFIG.domain()), + // Straight to the page that answers these, the same route the upstream admin console + // uses for them. + "url": format!("{}/#/organizations/{}/settings/device-approvals", CONFIG.domain(), org_id), "img_src": CONFIG._smtp_img_src(), "org_name": org_name, "user_email": user_email, diff --git a/src/static/scripts/admin_device_approvals.js b/src/static/scripts/admin_device_approvals.js deleted file mode 100644 index 4b386323..00000000 --- a/src/static/scripts/admin_device_approvals.js +++ /dev/null @@ -1,342 +0,0 @@ -"use strict"; -/* eslint-env es2017, browser */ -/* global BASE_URL:readable */ - -// Answering a device approval means handing a member their own user key, encrypted for the key -// pair of the device that is asking. The server cannot do that: it holds the organization's -// private key only encrypted with the organization key, and that one exists solely as RSA -// envelopes addressed to each administrator. So the whole chain runs here, in the browser, and -// the master password never leaves this page. -// -// master password -> master key PBKDF2-SHA256(password, email, iterations) -// -> own user key AES from profile.key -// -> own private key AES from profile.privateKey -// -> organization key RSA from profile.organizations[].key -// -> org private key AES from reset-password-details.encryptedPrivateKey -// -> member's user key RSA from reset-password-details.resetPasswordKey -// -> encryptedUserKey RSA for the public key out of the request - -const DEVICE_IDENTIFIER_KEY = "vw_admin_device_approvals_device_id"; - -let session = null; // { token, profile, privateKey } -let requests = []; - -function element(id) { - return document.getElementById(id); -} - -function setStatus(message, kind) { - const box = element("approval-status"); - box.textContent = message; - box.className = message ? `alert alert-${kind || "info"}` : "d-none"; -} - -function fromBase64(value) { - return Uint8Array.from(atob(value), c => c.charCodeAt(0)); -} - -function toBase64(bytes) { - return btoa(String.fromCharCode(...new Uint8Array(bytes))); -} - -function concat(a, b) { - const out = new Uint8Array(a.length + b.length); - out.set(a, 0); - out.set(b, a.length); - return out; -} - -// --------------------------------------------------------------------------- crypto - -async function pbkdf2(password, salt, iterations) { - const key = await crypto.subtle.importKey("raw", password, "PBKDF2", false, ["deriveBits"]); - const bits = await crypto.subtle.deriveBits( - { name: "PBKDF2", salt: salt, iterations: iterations, hash: "SHA-256" }, key, 256); - return new Uint8Array(bits); -} - -// HKDF-Expand only, with the master key used directly as the pseudorandom key. WebCrypto's HKDF -// always runs the extract step first, which would give a different result, so this is by hand. -async function hkdfExpand(prk, info) { - const key = await crypto.subtle.importKey("raw", prk, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); - const input = concat(new TextEncoder().encode(info), new Uint8Array([1])); - return new Uint8Array(await crypto.subtle.sign("HMAC", key, input)); -} - -async function stretch(masterKey) { - return [await hkdfExpand(masterKey, "enc"), await hkdfExpand(masterKey, "mac")]; -} - -// A user key or organization key is 64 bytes: the AES half followed by the HMAC half. -async function splitKey(key) { - if (key.length === 32) { - return stretch(key); - } - if (key.length === 64) { - return [key.slice(0, 32), key.slice(32)]; - } - throw new Error(`Unexpected key length ${key.length}`); -} - -// EncString type 2: 2.iv|ciphertext|mac -async function decryptSymmetric(encString, encKey, macKey) { - const [kind, rest] = [encString.slice(0, encString.indexOf(".")), encString.slice(encString.indexOf(".") + 1)]; - if (kind !== "2") { - throw new Error(`Expected a symmetrically encrypted value, got type ${kind}`); - } - - const [iv, ciphertext, mac] = rest.split("|").map(fromBase64); - - const macCryptoKey = await crypto.subtle.importKey("raw", macKey, { name: "HMAC", hash: "SHA-256" }, false, ["verify"]); - if (!await crypto.subtle.verify("HMAC", macCryptoKey, mac, concat(iv, ciphertext))) { - throw new Error("The stored value does not match its signature. Wrong master password?"); - } - - const aesKey = await crypto.subtle.importKey("raw", encKey, { name: "AES-CBC" }, false, ["decrypt"]); - return new Uint8Array(await crypto.subtle.decrypt({ name: "AES-CBC", iv: iv }, aesKey, ciphertext)); -} - -// EncString type 4 or 6: RSA-OAEP with SHA-1. Type 6 carries an extra signature we do not need. -async function decryptAsymmetric(encString, privateKey) { - const kind = encString.slice(0, encString.indexOf(".")); - if (kind !== "4" && kind !== "6") { - throw new Error(`Expected an RSA encrypted value, got type ${kind}`); - } - - const data = fromBase64(encString.slice(encString.indexOf(".") + 1).split("|")[0]); - return new Uint8Array(await crypto.subtle.decrypt({ name: "RSA-OAEP" }, privateKey, data)); -} - -async function encryptAsymmetric(plain, publicKeyB64) { - const publicKey = await crypto.subtle.importKey( - "spki", fromBase64(publicKeyB64), { name: "RSA-OAEP", hash: "SHA-1" }, false, ["encrypt"]); - const encrypted = await crypto.subtle.encrypt({ name: "RSA-OAEP" }, publicKey, plain); - return "4." + toBase64(encrypted); -} - -async function importPrivateKey(pkcs8) { - return crypto.subtle.importKey("pkcs8", pkcs8, { name: "RSA-OAEP", hash: "SHA-1" }, false, ["decrypt"]); -} - -// --------------------------------------------------------------------------- api - -async function api(method, path, body, options) { - const settings = options || {}; - const headers = {}; - if (session && !settings.anonymous) { - headers["Authorization"] = `Bearer ${session.token}`; - } - - let payload = null; - if (body !== undefined && body !== null) { - if (settings.form) { - headers["Content-Type"] = "application/x-www-form-urlencoded"; - payload = new URLSearchParams(body).toString(); - } else { - headers["Content-Type"] = "application/json"; - payload = JSON.stringify(body); - } - } - - const response = await fetch(BASE_URL + path, { method: method, headers: headers, body: payload }); - const text = await response.text(); - let parsed = null; - try { - parsed = text ? JSON.parse(text) : null; - } catch (e) { - parsed = { message: text.slice(0, 200) }; - } - - if (!response.ok) { - throw new Error((parsed && (parsed.message || parsed.ErrorModel?.Message)) || `HTTP ${response.status}`); - } - return parsed; -} - -function deviceIdentifier() { - let identifier = localStorage.getItem(DEVICE_IDENTIFIER_KEY); - if (!identifier) { - identifier = crypto.randomUUID(); - localStorage.setItem(DEVICE_IDENTIFIER_KEY, identifier); - } - return identifier; -} - -// --------------------------------------------------------------------------- flow - -async function signIn(email, password) { - const prelogin = await api("POST", "/identity/accounts/prelogin", { email: email }, { anonymous: true }); - if (prelogin.kdf !== 0) { - throw new Error("This account uses Argon2, which this page does not implement. Use APPROVE_DEVICE from the command line."); - } - - const encoder = new TextEncoder(); - const masterKey = await pbkdf2(encoder.encode(password), encoder.encode(email.trim().toLowerCase()), prelogin.kdfIterations); - const passwordHash = toBase64(await pbkdf2(masterKey, encoder.encode(password), 1)); - - const token = await api("POST", "/identity/connect/token", { - grant_type: "password", - client_id: "web", - username: email, - password: passwordHash, - scope: "api offline_access", - deviceIdentifier: deviceIdentifier(), - deviceName: "Vaultwarden admin", - deviceType: 9, - }, { anonymous: true, form: true }); - - if (token.TwoFactorProviders || token.TwoFactorProviders2) { - throw new Error("Two-step login is active for this account, which this page does not implement."); - } - - session = { token: token.access_token }; - - const sync = await api("GET", "/api/sync?excludeDomains=true"); - const profile = sync.profile; - const userKey = await decryptSymmetric(profile.key, ...await stretch(masterKey)); - const privateKey = await importPrivateKey(await decryptSymmetric(profile.privateKey, ...await splitKey(userKey))); - - session = { token: token.access_token, profile: profile, privateKey: privateKey }; -} - -async function loadRequests() { - requests = []; - for (const org of session.profile.organizations) { - let pending; - try { - pending = await api("GET", `/api/organizations/${org.id}/auth-requests`); - } catch (e) { - continue; // not an administrator of this one - } - for (const request of pending.data) { - request.organization = org; - requests.push(request); - } - } -} - -async function memberUserKey(request) { - const org = request.organization; - const orgKey = await decryptAsymmetric(org.key, session.privateKey); - - const details = await api( - "GET", `/api/organizations/${org.id}/users/${request.organizationUserId}/reset-password-details`); - if (!details.resetPasswordKey) { - throw new Error("This member is not enrolled in account recovery, so nobody can hand out their key."); - } - - const orgPrivateKey = await importPrivateKey(await decryptSymmetric(details.encryptedPrivateKey, ...await splitKey(orgKey))); - return decryptAsymmetric(details.resetPasswordKey, orgPrivateKey); -} - -async function answer(request, approved) { - const path = `/api/organizations/${request.organization.id}/auth-requests/${request.id}`; - - if (!approved) { - await api("POST", path, { requestApproved: false }); - setStatus(`Denied the request from ${request.email}.`, "secondary"); - return; - } - - const encryptedUserKey = await encryptAsymmetric(await memberUserKey(request), request.publicKey); - await api("POST", path, { requestApproved: true, encryptedUserKey: encryptedUserKey }); - setStatus(`Approved. ${request.email} can open their vault on that device now.`, "success"); -} - -// --------------------------------------------------------------------------- rendering - -function renderRequests() { - const tbody = element("approval-rows"); - tbody.innerHTML = ""; - - element("approval-empty").classList.toggle("d-none", requests.length > 0); - element("approval-table").classList.toggle("d-none", requests.length === 0); - - requests.forEach((request, index) => { - const row = document.createElement("tr"); - - const cell = (text) => { - const td = document.createElement("td"); - td.textContent = text; - return td; - }; - - row.appendChild(cell(request.email)); - row.appendChild(cell(request.organization.name)); - row.appendChild(cell(request.requestDeviceType)); - row.appendChild(cell(request.requestIpAddress)); - row.appendChild(cell(new Date(request.creationDate).toLocaleString())); - - const actions = document.createElement("td"); - for (const [label, style, approved] of [["Approve", "btn-primary", true], ["Deny", "btn-outline-secondary", false]]) { - const button = document.createElement("button"); - button.type = "button"; - button.className = `btn btn-sm ${style} me-1`; - button.textContent = label; - button.addEventListener("click", () => void handleAnswer(index, approved, button)); - actions.appendChild(button); - } - row.appendChild(actions); - - tbody.appendChild(row); - }); -} - -function busy(on) { - document.querySelectorAll("#approval-rows button, #approval-reload").forEach(b => { b.disabled = on; }); -} - -async function handleAnswer(index, approved, button) { - busy(true); - button.textContent = approved ? "Approving..." : "Denying..."; - try { - await answer(requests[index], approved); - await refresh(); - } catch (e) { - setStatus(e.message, "danger"); - } finally { - busy(false); - } -} - -async function refresh() { - await loadRequests(); - renderRequests(); -} - -// --------------------------------------------------------------------------- wiring - -document.addEventListener("DOMContentLoaded", () => { - element("approval-signin").addEventListener("submit", async (event) => { - event.preventDefault(); - const button = element("approval-signin-button"); - button.disabled = true; - setStatus("Signing in and unlocking the keys...", "info"); - - try { - await signIn(element("approval-email").value.trim(), element("approval-password").value); - element("approval-password").value = ""; - element("approval-signin").classList.add("d-none"); - element("approval-list").classList.remove("d-none"); - element("approval-signed-in-as").textContent = session.profile.email; - await refresh(); - setStatus("", null); - } catch (e) { - session = null; - setStatus(e.message, "danger"); - } finally { - button.disabled = false; - } - }); - - element("approval-reload").addEventListener("click", async () => { - busy(true); - try { - await refresh(); - } catch (e) { - setStatus(e.message, "danger"); - } finally { - busy(false); - } - }); -}); diff --git a/src/static/templates/admin/base.hbs b/src/static/templates/admin/base.hbs index 26923f45..e1dcacb5 100644 --- a/src/static/templates/admin/base.hbs +++ b/src/static/templates/admin/base.hbs @@ -45,9 +45,6 @@ - diff --git a/src/static/templates/admin/device_approvals.hbs b/src/static/templates/admin/device_approvals.hbs deleted file mode 100644 index 354e2d55..00000000 --- a/src/static/templates/admin/device_approvals.hbs +++ /dev/null @@ -1,68 +0,0 @@ -
-
-
Device approvals
- -

- A member who unlocks with a trusted device and has no other device of their own left to - ask can ask an administrator of their organization instead. Answering hands them their - own user key, encrypted for the device that is asking. It only works for members who - enrolled into account recovery. -

- -
- -
-
-
- This asks for a vault account, not for the admin token. - The server cannot answer these requests on its own: it holds the organization's - private key only encrypted with a key that never leaves its members. So sign in - below as an administrator of the organization and the whole chain is - unwrapped here in your browser. Your master password is not sent anywhere; only - the same login hash a regular sign-in would send leaves this page. -
-
-
- - -
-
- - -
-
- -
-
- -
-

- Signed in as -

- -

No requests are waiting for an answer.

- -
- - - - - - - - - - - - -
MemberOrganizationDeviceIP addressAsked atActions
-
- -
- -
-
-
-
- -