Browse Source

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.
pull/7534/head
tom27052006 2 weeks ago
parent
commit
14139cbd7a
  1. 3
      .env.template
  2. 13
      src/api/admin.rs
  3. 4
      src/api/core/accounts.rs
  4. 3
      src/api/web.rs
  5. 1
      src/config.rs
  6. 7
      src/mail.rs
  7. 342
      src/static/scripts/admin_device_approvals.js
  8. 3
      src/static/templates/admin/base.hbs
  9. 68
      src/static/templates/admin/device_approvals.hbs

3
.env.template

@ -559,6 +559,9 @@
## To turn this off again, clear this setting but leave `SSO_ENABLED` on: users without a master ## 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 ## 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. ## 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 # SSO_TRUSTED_DEVICE_ENCRYPTION=false
######################## ########################

13
src/api/admin.rs

@ -67,7 +67,6 @@ pub fn routes() -> Vec<Route> {
users_overview, users_overview,
organizations_overview, organizations_overview,
delete_organization, delete_organization,
device_approvals,
diagnostics, diagnostics,
get_diagnostics_config, get_diagnostics_config,
resend_user_invite, resend_user_invite,
@ -616,18 +615,6 @@ async fn delete_organization(org_id: OrganizationId, _token: AdminToken, conn: D
org.delete(&conn).await 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<Html<String>> {
let text = AdminTemplateData::new("admin/device_approvals", json!({})).render()?;
Ok(Html(text))
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct GitRelease { struct GitRelease {
tag_name: String, tag_name: String,

4
src/api/core/accounts.rs

@ -2019,7 +2019,9 @@ async fn notify_device_approval_requested(user: &User, org_id: &OrganizationId,
continue; 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:#?}"); error!("Error sending device approval request email: {e:#?}");
} }
} }

3
src/api/web.rs

@ -260,9 +260,6 @@ pub fn static_files(filename: &str) -> Result<(ContentType, &'static [u8]), Erro
"admin_organizations.js" => { "admin_organizations.js" => {
Ok((ContentType::JavaScript, include_bytes!("../static/scripts/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" => { "admin_diagnostics.js" => {
Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_diagnostics.js"))) Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_diagnostics.js")))
} }

1
src/config.rs

@ -1769,7 +1769,6 @@ where
reg!("admin/settings"); reg!("admin/settings");
reg!("admin/users"); reg!("admin/users");
reg!("admin/organizations"); reg!("admin/organizations");
reg!("admin/device_approvals");
reg!("admin/diagnostics"); reg!("admin/diagnostics");
reg!("404"); reg!("404");

7
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. /// of their own left to ask.
pub async fn send_device_approval_requested( pub async fn send_device_approval_requested(
address: &str, address: &str,
org_id: &OrganizationId,
org_name: &str, org_name: &str,
user_email: &str, user_email: &str,
user_name: &str, user_name: &str,
@ -543,9 +544,9 @@ pub async fn send_device_approval_requested(
let (subject, body_html, body_text) = get_text( let (subject, body_html, body_text) = get_text(
"email/device_approval_requested", "email/device_approval_requested",
json!({ json!({
// The page that can actually answer these, which is in the admin panel rather than in // Straight to the page that answers these, the same route the upstream admin console
// the web vault: the one upstream uses is not part of any open source build. // uses for them.
"url": format!("{}/admin/device-approvals", CONFIG.domain()), "url": format!("{}/#/organizations/{}/settings/device-approvals", CONFIG.domain(), org_id),
"img_src": CONFIG._smtp_img_src(), "img_src": CONFIG._smtp_img_src(),
"org_name": org_name, "org_name": org_name,
"user_email": user_email, "user_email": user_email,

342
src/static/scripts/admin_device_approvals.js

@ -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);
}
});
});

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

@ -45,9 +45,6 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{urlpath}}/admin/organizations/overview">Organizations</a> <a class="nav-link" href="{{urlpath}}/admin/organizations/overview">Organizations</a>
</li> </li>
<li class="nav-item">
<a class="nav-link" href="{{urlpath}}/admin/device-approvals">Device approvals</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{urlpath}}/admin/diagnostics">Diagnostics</a> <a class="nav-link" href="{{urlpath}}/admin/diagnostics">Diagnostics</a>
</li> </li>

68
src/static/templates/admin/device_approvals.hbs

@ -1,68 +0,0 @@
<main class="container-xxl">
<div id="device-approvals-block" class="my-3 p-3 rounded shadow">
<h6 class="border-bottom pb-2 mb-3">Device approvals</h6>
<p class="small">
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.
</p>
<div id="approval-status" class="d-none"></div>
<form id="approval-signin" class="row g-2 align-items-end">
<div class="col-12">
<div class="alert alert-warning small mb-2">
<strong>This asks for a vault account, not for the admin token.</strong>
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 <em>of the organization</em> 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.
</div>
</div>
<div class="col-md-5">
<label for="approval-email" class="form-label small mb-1">Email of an organization administrator</label>
<input type="email" class="form-control form-control-sm" id="approval-email" autocomplete="username" required>
</div>
<div class="col-md-5">
<label for="approval-password" class="form-label small mb-1">Master password</label>
<input type="password" class="form-control form-control-sm" id="approval-password" autocomplete="current-password" required>
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-sm btn-primary w-100" id="approval-signin-button">Unlock</button>
</div>
</form>
<div id="approval-list" class="d-none">
<p class="small mb-3">
Signed in as <span class="badge bg-success font-monospace" id="approval-signed-in-as"></span>
</p>
<p id="approval-empty" class="small fst-italic">No requests are waiting for an answer.</p>
<div class="table-responsive-xl small d-none" id="approval-table">
<table class="table table-sm table-striped table-hover">
<thead>
<tr>
<th>Member</th>
<th>Organization</th>
<th>Device</th>
<th>IP address</th>
<th>Asked at</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="approval-rows"></tbody>
</table>
</div>
<div class="mt-3 clearfix">
<button type="button" class="btn btn-sm btn-primary float-end" id="approval-reload">Reload requests</button>
</div>
</div>
</div>
</main>
<script src="{{urlpath}}/vw_static/admin_device_approvals.js"></script>
Loading…
Cancel
Save