Browse Source

Add a device approvals page to the admin panel

Stand-in for the page of the same name in the admin console, which lives in
the part of bitwarden/clients that is not AGPL licensed and is therefore in
no web vault build. Without it the endpoints added earlier had no caller
outside of scripts.

The server cannot answer these requests by itself, and that is the point:
it keeps the organization's private key encrypted with the organization key,
and that one exists only as RSA envelopes addressed to each administrator.
So the page ships markup and the whole chain runs in the browser against the
regular API:

  master password -> master key        PBKDF2-SHA256
                  -> 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
                  -> member's user key RSA from reset-password-details
                  -> encryptedUserKey  RSA for the public key of the request

The master password itself never leaves the page; what goes out is the same
login hash any sign-in sends. WebCrypto covers all of it except HKDF-Expand,
which is done by hand because WebCrypto always runs the extract step first
and Bitwarden uses the master key directly as the pseudorandom key.

The page asks for the account of an administrator of the organization, not
for the admin token, and says so: the two are not the same person on every
installation, and the admin panel has held no user key material until now.
pull/7534/head
tom27052006 2 weeks ago
parent
commit
e910b85a15
  1. 13
      src/api/admin.rs
  2. 3
      src/api/web.rs
  3. 1
      src/config.rs
  4. 342
      src/static/scripts/admin_device_approvals.js
  5. 3
      src/static/templates/admin/base.hbs
  6. 68
      src/static/templates/admin/device_approvals.hbs

13
src/api/admin.rs

@ -67,6 +67,7 @@ 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,
@ -615,6 +616,18 @@ 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,

3
src/api/web.rs

@ -260,6 +260,9 @@ 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

@ -1765,6 +1765,7 @@ 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");

342
src/static/scripts/admin_device_approvals.js

@ -0,0 +1,342 @@
"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,6 +45,9 @@
<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

@ -0,0 +1,68 @@
<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