Compare commits

..
Author SHA1 Message Date
Daniel García a2e285248e Implement V2 key rotation support 2026-10-07 23:38:51 +02:00
Daniel García 274ee69db3 Implement V2 registration support 2026-10-07 23:38:51 +02:00
Daniel García 46f4ec83df Support blob-encrypted ciphers and match upstream's key id checks 2026-10-07 23:38:50 +02:00
Pier Carlo Cadoppi df2cd3c869 Add biometrics-sdk-ipc feature flag (#7813) 2026-10-07 19:20:23 +02:00
Tom 0e93d15b73 Add Windows desktop autotype support (#7808) 2026-10-07 19:20:04 +02:00
will-lottkowitz-prismandDaniel García d1cbd027cd Add revision_date to org policies (#7571)
* Add revision_date to org policies

Org policy JSON responses never included a revisionDate field, unlike
every other synced entity. The official Bitwarden server always sets
it, and current official clients (recently migrated to the WASM SDK's
typed PolicyView) now assume it is always a valid date string. Its
absence deserializes to undefined client-side, which then throws
(RangeError: Invalid time value) when the client formats it, breaking
the whole policy sync pipeline and silently emptying the vault view in
the browser and desktop apps.

Adds a revision_date column (mirroring the existing pattern used by
Send), stamped on creation and bumped on every save.

* Fix migration correctness for org policy revision_date

Caught by review: SQLite rejects non-constant defaults in ALTER TABLE
ADD COLUMN, so the sqlite migration would fail outright on every
existing installation (vaultwarden's default backend) rather than the
postgresql setup this was developed against.

- sqlite/mysql: add the column with a constant placeholder default,
  then backfill via UPDATE, since neither allows a volatile default
  in ALTER TABLE ADD COLUMN (mysql does allow it, but a constant
  keeps the three backends' migrations symmetric).
- mysql: use DATETIME instead of TIMESTAMP, matching this repo's
  existing revision_date columns, and backfill with UTC_TIMESTAMP()
  instead of a session-timezone-dependent CURRENT_TIMESTAMP.
- postgresql: backfill with `now() AT TIME ZONE 'utc'` instead of a
  DEFAULT now(), since assigning timestamptz now() into this naive
  TIMESTAMP column would otherwise cast through the server's TimeZone
  GUC and store local wall-clock instead of UTC on non-UTC servers.
- mysql/postgresql: add a real down.sql (DROP COLUMN) instead of
  leaving it empty.

* Move org policy revision_date migration after the latest one

---------

Co-authored-by: Daniel García <dani-garcia@users.noreply.github.com>
2026-10-07 19:19:26 +02:00
Daniel García 1f802f8e6a Update web-vault to v2026.8.0 (#7828) 2026-10-07 00:56:01 +02:00
a2efadc650 Fix email 2FA for SSO logins on iOS and Android (#7827)
* Allow email-only /api/two-factor/send-email-login for mobile clients

Mobile clients (iOS) may call /api/two-factor/send-email-login with only the user's email and without a MasterPasswordHash or an AuthRequest. Permit email-only requests so the server will send the email 2FA token in that flow.\n\nModified: src/api/core/two_factor/email.rs

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(email-2fa): use device-identifier fallback when no password hash submitted

iOS clients call /api/two-factor/send-email-login with an email and
DeviceIdentifier but without masterPasswordHash or authRequestId after
receiving a 2FA-required response from the token endpoint. The previous
empty else-block allowed any caller to trigger a 2FA email for any
account knowing only the email address.

Replace the empty block with a device-identifier-based fallback:
- Look up the most-recently-active device via find_by_device_for_email2fa.
- Verify the device's associated user email matches the submitted email.
- Log (debug) when the fallback path is exercised for operator visibility.
- Reject with the original error when no device identifier is provided or
  when the device maps to a different account.

Fixes #7568

* Check the pending 2FA login for this user and device

Look up the pending 2FA login for the user and device instead of the
latest one on the device, answer a failed check with the same error and
IP/username log detail as a wrong password, and escape the device id in
the logs.

---------

Co-authored-by: Arunabha-Mukhopadhyay <dkarunabha2006@gmail.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-07 00:16:01 +02:00
acbf49018f [web-v2026.7.1] 2FA userVerificationToken for Authenticator, Email and Duo (#7563)
* 2FA using userVerificationToken

* Fix 2FA Yubikeys

* TwoFactor.find_by_user_and_type take enum parameter not i32

* log_user_event take enum parameter not i32

* Fix 2fa webauthn

* Prevent 2FA claims reuse

* 2fa webauthn return keys when deleting

* 2fa reset twofactor_remember

* Reject 2FA verification tokens issued for another provider

The Email and Duo claims only have optional fields, so a userVerificationToken minted for another
provider deserialized into them and was accepted. Each provider's claims now reject unknown fields,
with a test that every token only parses as its own provider.

Also fix the email 2FA login in the Playwright user setup, which used an undefined `mailBuffer`.

---------

Co-authored-by: Timshel <timshel@users.noreply.github.com>
Co-authored-by: Daniel García <dani-garcia@users.noreply.github.com>
2026-10-06 23:45:55 +02:00
TomandDaniel García b8089e31c2 [Web 2026.9.0] Support new account recovery password payload (#7747)
* Support new account recovery password payload

* Address account recovery review feedback

* Check the account recovery password before sending the recovery email

---------

Co-authored-by: Daniel García <dani-garcia@users.noreply.github.com>
2026-10-06 20:45:12 +02:00
Tom 8647af8f89 [Web 2026.9.0] Support new Emergency Access password payload (#7746)
* Support new emergency access password payload

* Apply PR 7747 review changes to emergency access

* Add Playwright test for emergency access takeover
2026-10-06 20:28:51 +02:00
JogiusandDaniel García c687cacb6b Fix group import to update groups on data change (#7671)
Co-authored-by: Daniel García <dani-garcia@users.noreply.github.com>
2026-10-06 19:29:58 +02:00
Wu ShuwenandDaniel García 8b56926077 docs: fix Playwright test env link (#7739)
Co-authored-by: Daniel García <dani-garcia@users.noreply.github.com>
2026-10-06 19:26:09 +02:00
ump45noseandDaniel García de6d2066b3 docs: fix "runnning" typo in playwright README (#7787)
Signed-off-by: yuwk <1729065730@qq.com>
Co-authored-by: Daniel García <dani-garcia@users.noreply.github.com>
2026-10-06 19:25:51 +02:00
IainandDaniel García 415df400f4 Validate the Duo and SSO purge schedules on startup (#7819)
Co-authored-by: Daniel García <dani-garcia@users.noreply.github.com>
2026-10-06 19:21:46 +02:00
42aa3ee1c7 Close WebSocket connections that stop responding (#7807)
Both notification hubs send a Ping every 15 seconds but ignore the
Pong, so the loop only ends on a Close frame, EOF or a socket error.
When a client disappears without the TCP connection being closed, for
example behind a reverse proxy or CDN that keeps the upstream
connection open, none of these ever happens. The connection, its file
descriptor and its map entry are then kept forever, until the process
runs out of file descriptors and stops accepting connections and
opening the database.

Keep track of when something was last received from the client, Pongs
included, and close the connection when nothing was received for 30
seconds. This is the default ClientTimeoutInterval of ASP.NET Core
SignalR, which the official server uses.

Fixes #7805

Co-authored-by: Cassian433 <262026629+Cassian433@users.noreply.github.com>
Co-authored-by: Daniel García <dani-garcia@users.noreply.github.com>
2026-10-06 19:10:49 +02:00
62 changed files with 1751 additions and 900 deletions

No files matched your search

+4
View File
@@ -394,11 +394,15 @@
## - "pm-5594-safari-account-switching": Enable account switching in Safari. (Safari >= 2026.2.0)
## - "pm-32413-multi-client-password-management": Enable changing the master password directly in the client. (Desktop/Extension >= 2026.4.0)
## - "ssh-agent-v2": Enable newer SSH agent support. (Desktop >= 2026.2.1)
## - "windows-desktop-autotype": Enable the autotype feature preview on Windows. (Desktop >= 2025.8.0)
## - "windows-desktop-autotype-ga": Enable the new, still in development, autotype implementation on Windows. (Desktop >= 2026.9.1)
## Only enable one of the two autotype flags, the clients disable autotype completely when both are enabled.
## - "pm-30529-webauthn-related-origins":
## - "pm-32009-new-item-types": Enable new item types: Bank Account, Driver's License, and Passport (Clients >= 2026.4.0)
## - "pm-34171-card-scanner": Enable the new card scanner feature on mobile (Android >= 2026.4.1, iOS >= 2026.4.1)
## - "enable-basic-auth-response": Enable HTTP Basic Auth autofill in the browser extension (Browser >= 2026.9.0)
## - "undetermined-cipher-scenario-logic": Enable the rewritten add/update login notification triggering logic in the browser extension (Browser >= 2026.2.0)
## - "biometrics-sdk-ipc": Enable biometric unlock over the SDK IPC framework (Desktop >= 2026.9.0, Browser >= 2026.9.0)
# EXPERIMENTAL_CLIENT_FEATURE_FLAGS=
## Require new device emails. When a user logs in an email is required to be sent.
+2 -5
View File
@@ -14,11 +14,8 @@ extend-ignore-re = [
# In SMTP it's called HELO, so ignore it
"(?i)helo_name",
"Server name sent during.+HELO",
# COSE Is short for CBOR Object Signing and Encryption, ignore these specific items
"COSE",
"COSEKey",
"COSEAlgorithm",
"CoseEncrypt0",
# COSE Is short for CBOR Object Signing and Encryption
"(?i)cose",
# Ignore this specific string as it's valid
"Ensure they are valid OTPs",
# This word is misspelled upstream
+2 -2
View File
@@ -1,6 +1,6 @@
---
vault_version: "v2026.7.0"
vault_image_digest: "sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c"
vault_version: "v2026.8.0"
vault_image_digest: "sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a"
# Cross Compile Docker Helper Scripts v1.9.0
# We use the linux/amd64 platform shell scripts since there is no difference between the different platform scripts
# https://github.com/tonistiigi/xx | https://hub.docker.com/r/tonistiigi/xx/tags
+6 -6
View File
@@ -19,15 +19,15 @@
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
# click the tag name to view the digest of the image it currently points to.
# - From the command line:
# $ docker pull docker.io/vaultwarden/web-vault:v2026.7.0
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.7.0
# [docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c]
# $ docker pull docker.io/vaultwarden/web-vault:v2026.8.0
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.8.0
# [docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a]
#
# - Conversely, to get the tag name from the digest:
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c
# [docker.io/vaultwarden/web-vault:v2026.7.0]
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a
# [docker.io/vaultwarden/web-vault:v2026.8.0]
#
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c AS vault
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a AS vault
########################## ALPINE BUILD IMAGES ##########################
## NOTE: The Alpine Base Images do not support other platforms then linux/amd64 and linux/arm64
+6 -6
View File
@@ -19,15 +19,15 @@
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
# click the tag name to view the digest of the image it currently points to.
# - From the command line:
# $ docker pull docker.io/vaultwarden/web-vault:v2026.7.0
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.7.0
# [docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c]
# $ docker pull docker.io/vaultwarden/web-vault:v2026.8.0
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.8.0
# [docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a]
#
# - Conversely, to get the tag name from the digest:
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c
# [docker.io/vaultwarden/web-vault:v2026.7.0]
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a
# [docker.io/vaultwarden/web-vault:v2026.8.0]
#
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c AS vault
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a AS vault
########################## Cross Compile Docker Helper Scripts ##########################
## We use the linux/amd64 no matter which Build Platform, since these are all bash scripts
@@ -0,0 +1 @@
ALTER TABLE org_policies DROP COLUMN revision_date;
@@ -0,0 +1,7 @@
-- DATETIME (not TIMESTAMP) to match this repo's convention for revision_date
-- columns elsewhere, and to avoid MySQL's implicit session-timezone
-- conversion and 2038 range limit on TIMESTAMP.
ALTER TABLE org_policies
ADD COLUMN revision_date DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00';
UPDATE org_policies SET revision_date = UTC_TIMESTAMP();
Whitespace-only changes.
@@ -0,0 +1,3 @@
-- A blob-encrypted cipher keeps all of its content in `data`, up to 500,000 characters, so the
-- 64 KiB of TEXT is too small. Like upstream, which uses LONGTEXT.
ALTER TABLE ciphers MODIFY data LONGTEXT NOT NULL;
@@ -0,0 +1 @@
ALTER TABLE org_policies DROP COLUMN revision_date;
@@ -0,0 +1,9 @@
-- Backfill via `now() AT TIME ZONE 'utc'` rather than a DEFAULT of now():
-- assigning timestamptz now() into a naive TIMESTAMP column casts through
-- the server's TimeZone GUC, so a DEFAULT now() would store local wall-clock
-- instead of UTC on non-UTC servers, unlike every other naive-UTC timestamp
-- column in this schema.
ALTER TABLE org_policies
ADD COLUMN revision_date TIMESTAMP NOT NULL DEFAULT '1970-01-01 00:00:00';
UPDATE org_policies SET revision_date = (now() AT TIME ZONE 'utc');
Whitespace-only changes.
@@ -0,0 +1 @@
-- TEXT has no size limit here, only MySQL needed the change
Whitespace-only changes.
@@ -0,0 +1,6 @@
-- SQLite forbids non-constant defaults in ALTER TABLE ... ADD COLUMN, so add
-- the column with a constant placeholder and backfill separately.
ALTER TABLE org_policies
ADD COLUMN revision_date DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00';
UPDATE org_policies SET revision_date = CURRENT_TIMESTAMP;
Whitespace-only changes.
@@ -0,0 +1 @@
-- TEXT has no size limit here, only MySQL needed the change
+2 -2
View File
@@ -2,7 +2,7 @@
This allows running integration tests using [Playwright](https://playwright.dev/).
\
It usse its own [test.env](/test/scenarios/test.env) with different ports to not collide with a running dev instance.
It uses its own [test.env](./test.env) with different ports to not collide with a running dev instance.
## Install
@@ -62,7 +62,7 @@ DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Pl
### Keep services running
If you want you can keep the DB and Keycloak runnning (states are not impacted by the tests):
If you want you can keep the DB and Keycloak running (states are not impacted by the tests):
```bash
PW_KEEP_SERVICE_RUNNING=true npx playwright test
+131
View File
@@ -0,0 +1,131 @@
import { test, expect, type Page, type TestInfo, Test } from '@playwright/test';
import { MailDev } from 'maildev';
import * as utils from "../global-utils";
import { createAccount, logUser } from './setups/user';
import { activateTOTP } from './setups/2fa';
let users = utils.loadEnv();
let mailserver;
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
mailserver = new MailDev({
port: process.env.MAILDEV_SMTP_PORT,
web: { port: process.env.MAILDEV_HTTP_PORT },
})
await mailserver.listen();
await utils.startVault(browser, testInfo, {
SMTP_HOST: process.env.MAILDEV_HOST,
SMTP_FROM: process.env.PW_SMTP_FROM,
});
});
test.afterAll('Teardown', async ({}) => {
utils.stopVault();
if( mailserver ){
await mailserver.close();
}
});
async function emergencyAccess(test: Test, page: Page, user: { name: string }) {
await test.step('Navigate', async () => {
await page.getByRole('button', { name: user.name }).click();
await page.getByRole('menuitem', { name: 'Account settings' }).click();
await page.getByRole('link', { name: 'Emergency access' }).click();
await expect(page.locator('#main-content').getByText('Emergency access', { exact: true })).toBeVisible();
});
}
test('Emergency access', async ({ browser, page }) => {
const context2 = await browser.newContext();
const page2 = await context2.newPage();
const mailBuffer = mailserver.buffer(users.user1.email);
const mailBuffer2 = mailserver.buffer(users.user2.email);
await createAccount(test, page, users.user1);
await createAccount(test, page2, users.user2);
await test.step('Add test2', async () => {
await emergencyAccess(test, page, users.user1);
await page.getByRole('button', { name: 'Add emergency contact' }).click();
await page.getByRole('textbox', { name: 'Email * (required)' }).fill(users.user2.email);
await page.getByRole('radio', { name: 'Takeover Can reset your' }).check();
await page.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'User(s) invited');
});
await test.step('Accept', async () => {
const email = await mailBuffer2.expect((m) => m.subject === "Emergency access for " + users.user1.name);
const pageE = await context2.newPage();
await pageE.setContent(email.html);
const link = await pageE.getByTestId("emergency").getAttribute("href");
await pageE.close();
await page2.goto(link);
await utils.checkNotification(page2, 'Invitation accepted');
});
await test.step('Confirm', async () => {
await emergencyAccess(test, page, users.user1);
await expect(page.locator('#main-content').getByText('Needs confirmation')).toBeVisible();
await page.getByRole('button', { name: 'Options' }).click();
await page.getByRole('menuitem', { name: 'Confirm' }).click();
await page.getByRole('button', { name: 'Confirm' }).click();
await utils.checkNotification(page, users.user2.name + ' confirmed');
await mailBuffer2.expect((m) => m.subject === "Emergency access contact for " + users.user1.name + " confirmed");
});
await test.step('Request', async () => {
await emergencyAccess(test, page2, users.user2);
await page2.getByRole('button', { name: 'Options' }).click();
await page2.getByRole('menuitem', { name: 'Request Access' }).click();
await page2.getByRole('button', { name: 'Request Access' }).click();
await utils.checkNotification(page2, 'Emergency access requested');
await mailBuffer.expect((m) => m.subject === "Emergency access request by " + users.user2.name + " initiated");
});
await test.step('Approved', async () => {
await emergencyAccess(test, page, users.user1);
await page.getByRole('button', { name: 'Options' }).click();
await page.getByRole('menuitem', { name: 'Approve' }).click();
await page.getByRole('button', { name: 'Approve' }).click();
await utils.checkNotification(page, 'Emergency access approved');
await mailBuffer2.expect((m) => m.subject === "Emergency access request for " + users.user1.name + " approved");
});
await activateTOTP(test, page, users.user1);
let newPassword = "TotoNewPassword";
await test.step('Access', async () => {
await emergencyAccess(test, page2, users.user2);
await page2.getByRole('button', { name: 'Options' }).click();
await page2.getByRole('menuitem', { name: 'Takeover' }).click();
await page2.getByRole('textbox', { name: 'New master password * (required)', exact: true }).fill(newPassword);
await page2.getByRole('textbox', { name: 'Confirm new master password' }).fill(newPassword);
await page2.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page2, 'Password reset for ' + users.user1.name);
});
await test.step('Changed no 2fa', async () => {
users.user1.password = newPassword;
await logUser(test, page, users.user1);
});
await test.step('Reject', async () => {
await emergencyAccess(test, page, users.user1);
await page.getByRole('button', { name: 'Options' }).click();
await page.getByRole('menuitem', { name: 'Reject' }).click();
await utils.checkNotification(page, 'Emergency access rejected');
await mailBuffer2.expect((m) => m.subject === "Emergency access request to " + users.user1.name + " rejected");
});
await test.step('Remove', async () => {
await page.getByRole('button', { name: 'Options' }).click();
await page.getByRole('menuitem', { name: 'Remove' }).click();
await page.getByRole('button', { name: 'Yes' }).click();
await utils.checkNotification(page, 'Removed user ' + users.user2.name);
await expect(page.getByText('You have not added any emergency contacts')).toBeVisible();
});
});
+29 -8
View File
@@ -3,7 +3,7 @@ import * as OTPAuth from "otpauth";
import * as utils from "../global-utils";
import { createAccount, logUser } from './setups/user';
import { activateTOTP, disableTOTP } from './setups/2fa';
import { activateTOTP, disableTOTP, recoveryCodes } from './setups/2fa';
let users = utils.loadEnv();
let totp;
@@ -31,21 +31,42 @@ test('Authenticator 2fa', async ({ page }) => {
await utils.logout(test, page, users.user1);
await test.step('login', async () => {
let timestamp = Date.now(); // Needed to use the next token
timestamp = timestamp + (totp.period - (Math.floor(timestamp / 1000) % totp.period) + 1) * 1000;
await logUser(test, page, users.user1, { totp });
await disableTOTP(test, page, users.user1);
});
test('Recovery codes', async ({ context, page }) => {
await logUser(test, page, users.user1);
await activateTOTP(test, page, users.user1);
let recovery = await recoveryCodes(test, page, users.user1);
await utils.logout(test, page, users.user1);
await test.step('login', async () => {
await page.getByLabel(/Email address/).fill(users.user1.email);
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user1.password);
await page.getByRole('button', { name: 'Log in', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible();
await page.getByLabel(/Verification code/).fill(totp.generate({timestamp}));
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page).toHaveTitle(/Vaultwarden Web/);
});
await disableTOTP(test, page, users.user1);
const newPagePromise = context.waitForEvent('page');
await page.getByRole('button', { name: 'Use your recovery code' }).click();
const newPage = await newPagePromise;
const tabs = context.pages();
await tabs[1].bringToFront();
await expect(tabs[1].getByRole('heading', { name: 'Recover account two-step login' })).toBeVisible();
await tabs[1].getByRole('textbox', { name: 'Email address * (required)' }).fill(users.user1.email);
await tabs[1].getByRole('textbox', { name: 'Master password * (required)' }).fill(users.user1.password);
await tabs[1].getByRole('textbox', { name: 'Recovery code * (required)' }).fill(recovery);
await tabs[1].getByRole('button', { name: 'Submit' }).click();
await expect(tabs[1]).toHaveTitle(/Two-step login/);
});
});
+18 -1
View File
@@ -4,6 +4,24 @@ import * as OTPAuth from "otpauth";
import * as utils from '../../global-utils';
export async function recoveryCodes(test: Test, page: Page, user: { name: string, password: string }): string {
return await test.step('Recovery code', async () => {
await page.getByRole('button', { name: user.name }).click();
await page.getByRole('menuitem', { name: 'Account settings' }).click();
await page.getByRole('link', { name: 'Security' }).click();
await page.getByRole('link', { name: 'Two-step login' }).click();
await page.getByRole('button', { name: 'View recovery code' }).click();
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
await page.getByRole('button', { name: 'Continue' }).click();
const recovery = await page.getByRole('code').innerText();
await page.getByLabel('Close').click();
return recovery;
})
}
export async function activateTOTP(test: Test, page: Page, user: { name: string, password: string }): OTPAuth.TOTP {
return await test.step('Activate TOTP 2FA', async () => {
await page.getByRole('button', { name: user.name }).click();
@@ -21,7 +39,6 @@ export async function activateTOTP(test: Test, page: Page, user: { name: string,
await page.getByLabel(/Verification code/).fill(totp.generate());
await page.getByRole('button', { name: 'Turn on' }).click();
await page.getByRole('heading', { name: 'Turned on', exact: true });
await page.getByLabel('Close').click();
return totp;
})
+16 -2
View File
@@ -2,6 +2,7 @@ import { expect, type Browser, Page } from '@playwright/test';
import { type MailBuffer } from 'maildev';
import * as OTPAuth from "otpauth";
import * as utils from '../../global-utils';
import { retrieveEmailCode } from './2fa';
@@ -43,6 +44,7 @@ export async function logUser(
mailBuffer ?: MailBuffer,
mail2fa?: boolean,
notNewDevice?: boolean,
totp?: OTPAuth.TOTP,
} = {}
) {
await test.step(`Log user ${user.email}`, async () => {
@@ -55,11 +57,23 @@ export async function logUser(
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
await page.getByRole('button', { name: 'Log in', exact: true }).click();
if( options.mail2fa ){
if( options.mail2fa || options.totp ){
let code;
await test.step('2FA check', async () => {
await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible();
let code = await retrieveEmailCode(test, page, options.mailBuffer);
if( options.totp ) {
const totp = options.totp;
let timestamp = Date.now(); // Needed to use the next token
timestamp = timestamp + (totp.period - (Math.floor(timestamp / 1000) % totp.period) + 1) * 1000;
code = totp.generate({timestamp});
} else if( options.mail2fa ){
code = await retrieveEmailCode(test, page, options.mailBuffer);
}
await page.getByLabel(/Verification code/).fill(code);
await page.getByRole('button', { name: 'Continue' }).click();
});
}
+409 -260
View File
File diff suppressed because it is too large. Load diff
+177 -76
View File
@@ -190,14 +190,14 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
// https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26
"masterKeyEncryptedUserKey": headers.user.akey,
"masterKeyWrappedUserKey": headers.user.akey,
"salt": headers.user.email,
"salt": headers.user.master_password_salt(),
"containedKeyId": headers.user.key_id,
})
} else {
Value::Null
};
// Upstream omits these two when unset rather than sending null.
// Upstream omits these when unset rather than sending null
let mut user_decryption = json!({ "masterPasswordUnlock": master_password_unlock });
if let Some(key_id) = &headers.user.key_id {
user_decryption["userKeyId"] = json!(key_id);
@@ -277,7 +277,8 @@ pub struct CipherData {
key: Option<String>,
pub encrypted_for: UserId, // Added in web-v2025.6.0
// Added in web-v2025.6.0. Deprecated upstream for `encrypted_by_key_id`, but still checked when sent
pub encrypted_for: Option<UserId>,
// Added in web-v2025.8.1, Optional for compat
pub encrypted_by_key_id: Option<KeyId>,
@@ -344,12 +345,22 @@ impl From<CipherValidationError> for crate::Error {
}
}
/// A user-owned cipher must be encrypted with the user's current key. Organization ciphers use the
/// organization key, which has no id yet, and either id may be missing: from a client that predates
/// the field, or a user whose key id isn't known yet. There is nothing to compare in those cases.
/// The cipher must have been encrypted for the acting user. Only checked when the client sends the field.
///
/// Ref: upstream's `CiphersController.ValidateCipherEncryptedByUser`
fn validate_encrypted_by_user_key(data: &CipherData, user: &User, is_org_cipher: bool) -> EmptyResult {
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Controllers/CiphersController.cs#L1857-L1870>
fn validate_encrypted_for_user(data: &CipherData, user_id: &UserId) -> EmptyResult {
if data.encrypted_for.as_ref().is_some_and(|encrypted_for| encrypted_for != user_id) {
err!("Cipher was not encrypted for the current user. Please try again.")
}
Ok(())
}
/// [`validate_encrypted_for_user`], plus the key id of a user-owned cipher, when both ids are known.
///
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Controllers/CiphersController.cs#L1886-L1911>
fn validate_encrypted_by_user(data: &CipherData, user: &User, is_org_cipher: bool) -> EmptyResult {
validate_encrypted_for_user(data, &user.uuid)?;
if !is_org_cipher
&& let (Some(cipher_key_id), Some(user_key_id)) = (&data.encrypted_by_key_id, &user.key_id)
&& cipher_key_id != user_key_id
@@ -363,16 +374,34 @@ fn validate_encrypted_by_user_key(data: &CipherData, user: &User, is_org_cipher:
const MAX_CIPHER_DATA_LENGTH: usize = 500_000;
impl CipherData {
/// Whether the content is a single blob rather than the per-type fields. This parses `data`, so
/// callers that need the answer more than once should keep it.
/// Whether `data` is a blob rather than the per-type fields. Parses `data`, so keep the result.
pub fn is_blob(&self) -> bool {
self.data.as_deref().is_some_and(is_data_blob_encrypted)
}
/// Checks the content the way upstream's model validation does, before anything is saved.
/// `is_blob` is [`Self::is_blob`].
/// Saving's last checks: a known type, and its data unless it's a blob.
pub fn validate_type_data(&self, is_blob: bool) -> EmptyResult {
let type_data = match self.r#type {
1 => &self.login,
2 => &self.secure_note,
3 => &self.card,
4 => &self.identity,
5 => &self.ssh_key,
6 => &self.bank_account,
7 => &self.drivers_license,
8 => &self.passport,
_ => err!("Invalid type"),
};
if !is_blob && type_data.is_none() {
err!("Data missing")
}
Ok(())
}
/// Upstream's model checks that depend on the format: the size of `data`, and a name unless it's a blob.
///
/// Ref: <https://github.com/bitwarden/server/blob/main/src/Api/Vault/Models/Request/CipherRequestModel.cs>
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Models/Request/CipherRequestModel.cs#L76-L77>
/// and <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Models/Request/CipherRequestModel.cs#L91-L99>
pub fn validate_content(&self, is_blob: bool) -> Result<(), CipherValidationError> {
if let Some(data) = &self.data
&& data.len() > MAX_CIPHER_DATA_LENGTH
@@ -428,17 +457,14 @@ async fn post_ciphers_create(
) -> JsonResult {
let mut data: ShareCipherData = data.into_inner();
if data.cipher.encrypted_for != headers.user.uuid {
err_code!("Invalid user cipher", Status::UnprocessableEntity.code);
}
validate_encrypted_by_user_key(&data.cipher, &headers.user, data.cipher.organization_id.is_some())?;
validate_encrypted_by_user(&data.cipher, &headers.user, data.cipher.organization_id.is_some())?;
// This check is usually only needed in update_cipher_from_data(), but we
// need it here as well to avoid creating an empty cipher in the call to
// cipher.save() below.
enforce_personal_ownership_policy(Some(&data.cipher), &headers, &conn).await?;
let mut cipher = Cipher::new(data.cipher.r#type, String::new());
let mut cipher = Cipher::new(data.cipher.r#type);
cipher.user_uuid = Some(headers.user.uuid.clone());
cipher.save(&conn).await?;
@@ -462,11 +488,7 @@ async fn post_ciphers_create(
async fn post_ciphers(data: Json<CipherData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
let mut data: CipherData = data.into_inner();
if data.encrypted_for != headers.user.uuid {
err_code!("Invalid user cipher", Status::UnprocessableEntity.code);
}
validate_encrypted_by_user_key(&data, &headers.user, data.organization_id.is_some())?;
validate_encrypted_by_user(&data, &headers.user, data.organization_id.is_some())?;
// The web/browser clients set this field to null as expected, but the
// mobile clients seem to set the invalid value `0001-01-01T00:00:00`,
@@ -474,7 +496,7 @@ async fn post_ciphers(data: Json<CipherData>, headers: Headers, conn: DbConn, nt
// needed when creating a new cipher, so just ignore it unconditionally.
data.last_known_revision_date = None;
let mut cipher = Cipher::new(data.r#type, String::new());
let mut cipher = Cipher::new(data.r#type);
update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherCreate).await?;
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?))
@@ -498,6 +520,44 @@ async fn enforce_personal_ownership_policy(data: Option<&CipherData>, headers: &
Ok(())
}
/// The checks of saving a cipher, for callers that run them before writing anything else
async fn validate_cipher_update(
cipher: &Cipher,
data: &CipherData,
headers: &Headers,
conn: &DbConn,
ut: UpdateType,
) -> EmptyResult {
// Check that the client isn't updating an existing cipher with stale data.
// And only perform this check when not importing ciphers, else the date/time check will fail.
if ut != UpdateType::None
&& let Some(dt) = &data.last_known_revision_date
{
match NaiveDateTime::parse_from_str(dt, "%+") {
// ISO 8601 format
Err(err) => warn!("Error parsing LastKnownRevisionDate '{dt}': {err}"),
Ok(dt) if cipher.updated_at.signed_duration_since(dt).num_seconds() > 1 => {
err!("The client copy of this cipher is out of date. Resync the client and try again.")
}
Ok(_) => (),
}
}
if let Some(note) = &data.notes {
let max_note_size = CONFIG._max_note_size();
if note.len() > max_note_size {
err!(format!("The field Notes exceeds the maximum encrypted value length of {max_note_size} characters."))
}
}
if let Some(folder_id) = &data.folder_id
&& Folder::find_by_uuid_and_user(folder_id, &headers.user.uuid, conn).await.is_none()
{
err!("Invalid folder", "Folder does not exist or belongs to another user");
}
Ok(())
}
pub async fn update_cipher_from_data(
cipher: &mut Cipher,
data: CipherData,
@@ -506,6 +566,40 @@ pub async fn update_cipher_from_data(
conn: &DbConn,
nt: &Notify<'_>,
ut: UpdateType,
) -> EmptyResult {
enforce_personal_ownership_policy(Some(&data), headers, conn).await?;
save_cipher_data(cipher, data, headers, shared_to_collections, conn, nt, ut).await
}
/// [`update_cipher_from_data`] for a rotation: like upstream, the personal ownership policy doesn't apply.
pub async fn rotate_cipher_data(
cipher: &mut Cipher,
data: CipherData,
headers: &Headers,
conn: &DbConn,
nt: &Notify<'_>,
) -> EmptyResult {
save_cipher_data(cipher, data, headers, None, conn, nt, UpdateType::None).await
}
/// The checks of [`rotate_cipher_data`] that need the database, to run on every cipher before the first is saved
pub async fn validate_rotated_cipher(
cipher: &Cipher,
data: &CipherData,
headers: &Headers,
conn: &DbConn,
) -> EmptyResult {
validate_cipher_update(cipher, data, headers, conn, UpdateType::None).await
}
async fn save_cipher_data(
cipher: &mut Cipher,
data: CipherData,
headers: &Headers,
shared_to_collections: Option<Vec<CollectionId>>,
conn: &DbConn,
nt: &Notify<'_>,
ut: UpdateType,
) -> EmptyResult {
// Cleanup cipher data, like removing the 'Response' key.
// This key is somewhere generated during Javascript so no way for us this fix this.
@@ -521,37 +615,14 @@ pub async fn update_cipher_from_data(
json_data
}
enforce_personal_ownership_policy(Some(&data), headers, conn).await?;
let is_blob = data.is_blob();
data.validate_content(is_blob)?;
// Check that the client isn't updating an existing cipher with stale data.
// And only perform this check when not importing ciphers, else the date/time check will fail.
if ut != UpdateType::None
&& let Some(dt) = data.last_known_revision_date
{
match NaiveDateTime::parse_from_str(&dt, "%+") {
// ISO 8601 format
Err(err) => warn!("Error parsing LastKnownRevisionDate '{dt}': {err}"),
Ok(dt) if cipher.updated_at.signed_duration_since(dt).num_seconds() > 1 => {
err!("The client copy of this cipher is out of date. Resync the client and try again.")
}
Ok(_) => (),
}
}
validate_cipher_update(cipher, &data, headers, conn, ut).await?;
if cipher.organization_uuid.is_some() && cipher.organization_uuid != data.organization_id {
err!("Organization mismatch. Please resync the client before updating the cipher")
}
if let Some(note) = &data.notes {
let max_note_size = CONFIG._max_note_size();
if note.len() > max_note_size {
err!(format!("The field Notes exceeds the maximum encrypted value length of {max_note_size} characters."))
}
}
// Check if this cipher is being transferred from a personal to an organization vault
let transfer_cipher = cipher.organization_uuid.is_none() && data.organization_id.is_some();
@@ -582,12 +653,6 @@ pub async fn update_cipher_from_data(
cipher.user_uuid = Some(headers.user.uuid.clone());
}
if let Some(ref folder_id) = data.folder_id
&& Folder::find_by_uuid_and_user(folder_id, &headers.user.uuid, conn).await.is_none()
{
err!("Invalid folder", "Folder does not exist or belongs to another user");
}
// Modify attachments name and keys when rotating
if let Some(attachments) = data.attachments2 {
for (id, attachment) in attachments {
@@ -627,8 +692,7 @@ pub async fn update_cipher_from_data(
};
if let Some(blob) = data.data.filter(|_| is_blob) {
// A blob holds all of the content, the name included, so nothing is kept outside it. The name
// column can't be null, so it's left empty; `to_json` reports it as null, as upstream does.
// A blob holds everything, the name included; the column can't be null, so it's left empty
// TODO: Make `ciphers.name` nullable and store `None` here instead.
cipher.name = String::new();
cipher.notes = None;
@@ -753,7 +817,7 @@ async fn post_ciphers_import(data: Json<ImportData>, headers: Headers, conn: DbC
let folder_id = relations_map.get(&index).and_then(|i| folders.get(*i).cloned());
cipher_data.folder_id = folder_id;
let mut cipher = Cipher::new(cipher_data.r#type, String::new());
let mut cipher = Cipher::new(cipher_data.r#type);
update_cipher_from_data(&mut cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None).await?;
}
@@ -821,7 +885,8 @@ async fn put_cipher(
err!("Cipher is not write accessible")
}
validate_encrypted_by_user_key(&data, &headers.user, cipher.organization_uuid.is_some())?;
// Against the cipher we hold rather than the organization the client claims, like upstream
validate_encrypted_by_user(&data, &headers.user, cipher.organization_uuid.is_some())?;
update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherUpdate).await?;
@@ -1081,9 +1146,7 @@ async fn post_cipher_share(
conn: DbConn,
nt: Notify<'_>,
) -> JsonResult {
let data: ShareCipherData = data.into_inner();
share_cipher_by_uuid(&cipher_id, data, &headers, &conn, &nt, None).await
put_cipher_share(cipher_id, data, headers, conn, nt).await
}
#[put("/ciphers/<cipher_id>/share", data = "<data>")]
@@ -1096,6 +1159,21 @@ async fn put_cipher_share(
) -> JsonResult {
let data: ShareCipherData = data.into_inner();
// Upstream's `PutShare` checks, before anything is written.
// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Controllers/CiphersController.cs#L899-L912>
// and <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Models/Request/CipherRequestModel.cs#L498-L519>
if data.cipher.organization_id.is_none() {
err!("Cipher OrganizationId is required.")
}
if data.collection_ids.is_empty() {
err!("You must select at least one collection.")
}
if !Cipher::find_by_uuid(&cipher_id, &conn).await.is_some_and(|c| c.user_uuid.as_ref() == Some(&headers.user.uuid))
{
err_code!("Cipher doesn't exist", Status::NotFound.code)
}
validate_encrypted_for_user(&data.cipher, &headers.user.uuid)?;
share_cipher_by_uuid(&cipher_id, data, &headers, &conn, &nt, None).await
}
@@ -1115,18 +1193,35 @@ async fn put_cipher_share_selected(
) -> EmptyResult {
let mut data: ShareSelectedCipherData = data.into_inner();
// Upstream's `PutShareMany` checks, before anything is written.
// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Controllers/CiphersController.cs#L1395-L1421>
// and <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Models/Request/CipherRequestModel.cs#L560-L604>
if data.ciphers.is_empty() {
err!("You must select at least one cipher.")
}
if data.ciphers.iter().any(|c| c.id.is_none() || c.organization_id.is_none()) {
err!("All Ciphers must have an Id and OrganizationId.")
}
if data.ciphers.iter().map(|c| &c.organization_id).collect::<HashSet<_>>().len() != 1 {
err!("All ciphers must be for the same organization.")
}
if data.collection_ids.is_empty() {
err!("You must select at least one collection.")
}
for cipher in &data.ciphers {
if cipher.id.is_none() {
err!("Request missing ids field")
}
let is_blob = cipher.is_blob();
cipher.validate_content(is_blob)?;
cipher.validate_type_data(is_blob)?;
}
for cipher in &data.ciphers {
validate_encrypted_for_user(cipher, &headers.user.uuid)?;
}
let owned_ciphers = Cipher::find_owned_by_user(&headers.user.uuid, &conn).await;
for cipher_data in &data.ciphers {
let Some(cipher) = owned_ciphers.iter().find(|c| cipher_data.id.as_ref() == Some(&c.uuid)) else {
err!("Trying to share ciphers that you do not own.")
};
validate_cipher_update(cipher, cipher_data, &headers, &conn, UpdateType::None).await?;
}
while let Some(cipher) = data.ciphers.pop() {
@@ -1173,6 +1268,22 @@ async fn share_cipher_by_uuid(
err!("Organization mismatch. Please resync the client before updating the cipher")
}
// When LastKnownRevisionDate is None, it is a new cipher, so send CipherCreate.
// If there is an override, like when handling multiple items, we want to prevent a push notification for every single item
let ut = if let Some(ut) = override_ut {
ut
} else if data.cipher.last_known_revision_date.is_some() {
UpdateType::SyncCipherUpdate
} else {
UpdateType::SyncCipherCreate
};
// For the same reason, the other checks of saving
let is_blob = data.cipher.is_blob();
data.cipher.validate_content(is_blob)?;
data.cipher.validate_type_data(is_blob)?;
validate_cipher_update(&cipher, &data.cipher, headers, conn, ut).await?;
let mut shared_to_collections = vec![];
if let Some(organization_id) = &data.cipher.organization_id {
@@ -1191,16 +1302,6 @@ async fn share_cipher_by_uuid(
}
}
// When LastKnownRevisionDate is None, it is a new cipher, so send CipherCreate.
// If there is an override, like when handling multiple items, we want to prevent a push notification for every single item
let ut = if let Some(ut) = override_ut {
ut
} else if data.cipher.last_known_revision_date.is_some() {
UpdateType::SyncCipherUpdate
} else {
UpdateType::SyncCipherCreate
};
update_cipher_from_data(&mut cipher, data.cipher, headers, Some(shared_to_collections), conn, nt, ut).await?;
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await?))
+28 -6
View File
@@ -6,7 +6,10 @@ use crate::{
CONFIG,
api::{
EmptyResult, JsonResult,
core::{CipherSyncData, CipherSyncType},
core::{
CipherSyncData, CipherSyncType,
accounts::{AuthenticationData, UnlockData},
},
},
auth::{Headers, decode_emergency_access_invite},
db::{
@@ -615,6 +618,7 @@ async fn takeover_emergency_access(emer_id: EmergencyAccessId, headers: Headers,
"kdfMemory": grantor_user.client_kdf_memory,
"kdfParallelism": grantor_user.client_kdf_parallelism,
"keyEncrypted": &emergency_access.key_encrypted,
"salt": grantor_user.master_password_salt(),
"object": "emergencyAccessTakeover",
});
@@ -624,8 +628,13 @@ async fn takeover_emergency_access(emer_id: EmergencyAccessId, headers: Headers,
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct EmergencyAccessPasswordData {
new_master_password_hash: String,
key: String,
// Legacy payload
new_master_password_hash: Option<String>,
key: Option<String>,
// Current payload
authentication_data: Option<AuthenticationData>,
unlock_data: Option<UnlockData>,
}
#[post("/emergency-access/<emer_id>/password", data = "<data>")]
@@ -638,8 +647,6 @@ async fn password_emergency_access(
check_emergency_access_enabled()?;
let data: EmergencyAccessPasswordData = data.into_inner();
let new_master_password_hash = &data.new_master_password_hash;
//let key = &data.Key;
let requesting_user = headers.user;
let Some(emergency_access) =
@@ -656,8 +663,23 @@ async fn password_emergency_access(
err!("Grantor user not found.")
};
let (new_master_password_hash, new_key) =
if let (Some(authentication_data), Some(unlock_data)) = (data.authentication_data, data.unlock_data) {
authentication_data.check(&grantor_user, &unlock_data)?;
if !authentication_data.kdf.matches_user(&grantor_user) {
err!("KDF settings do not match the grantor account")
}
(authentication_data.master_password_authentication_hash, unlock_data.master_key_wrapped_user_key)
} else if let (Some(new_master_password_hash), Some(new_key)) = (data.new_master_password_hash, data.key) {
(new_master_password_hash, new_key)
} else {
err!("Invalid request!")
};
// change grantor_user password
grantor_user.set_password(new_master_password_hash, Some(data.key), true, None, &conn).await?;
grantor_user.set_password(&new_master_password_hash, Some(new_key), true, None, &conn).await?;
grantor_user.save(&conn).await?;
// Disable TwoFactor providers since they will otherwise block logins
+2 -2
View File
@@ -242,11 +242,11 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
Ok(())
}
pub async fn log_user_event(event_type: i32, user_id: &UserId, device_type: i32, ip: &IpAddr, conn: &DbConn) {
pub async fn log_user_event(event_type: EventType, user_id: &UserId, device_type: i32, ip: &IpAddr, conn: &DbConn) {
if !CONFIG.org_events_enabled() {
return;
}
log_user_event_impl(event_type, user_id, device_type, None, ip, conn).await;
log_user_event_impl(event_type as i32, user_id, device_type, None, ip, conn).await;
}
async fn log_user_event_impl(
+77 -15
View File
@@ -8,7 +8,7 @@ use crate::{
CONFIG,
api::admin::FAKE_ADMIN_UUID,
api::{
EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType,
ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType,
core::{CipherSyncData, CipherSyncType, accept_org_invite, log_event, two_factor},
},
auth::{AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, decode_invite},
@@ -26,6 +26,8 @@ use crate::{
util::{NumberOrString, convert_json_key_lcase_first},
};
use super::accounts::{AuthenticationData, UnlockData};
pub fn routes() -> Vec<Route> {
routes![
get_organization,
@@ -1829,7 +1831,7 @@ async fn post_org_import(
cipher_data.folder_id = None;
// Replace the client-provided, unvalidated organizationId with the real target org
cipher_data.organization_id = Some(org_id.clone());
let mut cipher = Cipher::new(cipher_data.r#type, String::new());
let mut cipher = Cipher::new(cipher_data.r#type);
update_cipher_from_data(
&mut cipher,
cipher_data,
@@ -2751,9 +2753,14 @@ struct OrganizationUserResetPasswordEnrollmentRequest {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct OrganizationUserRecoverAccountRequest {
// Legacy payload
new_master_password_hash: Option<String>,
key: Option<String>,
// Current payload
authentication_data: Option<AuthenticationData>,
unlock_data: Option<UnlockData>,
#[serde(default)]
reset_master_password: bool,
#[serde(default)]
@@ -2764,8 +2771,26 @@ struct OrganizationUserRecoverAccountRequest {
// But the clients do not seem to use this at all
// Just add it here in case they will
#[get("/organizations/<org_id>/public-key")]
async fn get_organization_public_key(org_id: OrganizationId, headers: OrgMemberHeaders, conn: DbConn) -> JsonResult {
if org_id != headers.membership.org_uuid {
async fn get_organization_public_key(
org_id: OrganizationId,
headers: Headers,
member: Result<OrgMemberHeaders, &'static str>,
conn: DbConn,
) -> JsonResult {
// SSO users without an org get the fake one, whose key the v2 JIT password flow fetches anyway
if org_id.eq_ignore_ascii_case(FAKE_SSO_IDENTIFIER) && headers.user.private_key.is_none() {
return Ok(Json(json!({
"object": "organizationPublicKey",
"publicKey": fake_sso_org_public_key().await?,
})));
}
let member = match member {
Ok(member) => member,
// The guard already logged it
Err(e) => return Err(crate::error::Error::new_msg(e).with_code(Status::Unauthorized.code)),
};
if org_id != member.membership.org_uuid {
err!("Organization not found", "Organization id's do not match");
}
let Some(org) = Organization::find_by_uuid(&org_id, &conn).await else {
@@ -2778,11 +2803,30 @@ async fn get_organization_public_key(org_id: OrganizationId, headers: OrgMemberH
})))
}
/// The SDK only uses this key to wrap an account recovery key that it never sends for the fake org,
/// so it is made once and its private half dropped.
async fn fake_sso_org_public_key() -> ApiResult<String> {
static PUBLIC_KEY: std::sync::LazyLock<Option<String>> = std::sync::LazyLock::new(|| {
let der = openssl::rsa::Rsa::generate(2048).and_then(|rsa| rsa.public_key_to_der()).ok()?;
Some(data_encoding::BASE64.encode(&der))
});
let Ok(Some(public_key)) = tokio::task::spawn_blocking(|| PUBLIC_KEY.clone()).await else {
err!("Failed to generate the organization key")
};
Ok(public_key)
}
// Obsolete - Renamed to public-key (2023.8), left for backwards compatibility with older clients
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Controllers/OrganizationsController.cs#L487-L492
#[get("/organizations/<org_id>/keys")]
async fn get_organization_keys(org_id: OrganizationId, headers: OrgMemberHeaders, conn: DbConn) -> JsonResult {
get_organization_public_key(org_id, headers, conn).await
async fn get_organization_keys(
org_id: OrganizationId,
headers: Headers,
member: Result<OrgMemberHeaders, &'static str>,
conn: DbConn,
) -> JsonResult {
get_organization_public_key(org_id, headers, member, conn).await
}
// Will allow to reset 2FA too
@@ -2833,11 +2877,34 @@ async fn recover_account(
let fallback_2fa_email =
if req.reset_two_factor && CONFIG.mail_enabled() && CONFIG.email_2fa_auto_fallback() && user.verified() {
TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email as i32, &conn).await.is_none()
TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email, &conn).await.is_none()
} else {
false
};
// Check the new password before the email below, so that a rejected request doesn't tell the user
// their password was reset
let new_password = if req.reset_master_password {
let (new_master_password_hash, new_key) = if let (Some(authentication_data), Some(unlock_data)) =
(req.authentication_data, req.unlock_data)
{
authentication_data.check(&user, &unlock_data)?;
if !authentication_data.kdf.matches_user(&user) {
err!("KDF settings do not match the user account")
}
(authentication_data.master_password_authentication_hash, unlock_data.master_key_wrapped_user_key)
} else if let (Some(new_master_password_hash), Some(new_key)) = (req.new_master_password_hash, req.key) {
(new_master_password_hash, new_key)
} else {
err_code!("Unprocessable request", "Missing fields to reset password", Status::UnprocessableEntity.code);
};
Some((new_master_password_hash, new_key))
} else {
None
};
// Sending email first ensure working email configuration and the resulting user notification.
// Also this might add some protection against security flaws and misuse
if let Err(e) = mail::send_admin_account_recovery(
@@ -2853,14 +2920,8 @@ async fn recover_account(
err!(format!("Error sending user reset password email: {e:#?}"));
}
if req.reset_master_password {
if let Some(key) = req.key
&& let Some(hash) = req.new_master_password_hash
{
user.set_password(hash.as_str(), Some(key), true, None, &conn).await?;
} else {
err_code!("Unprocessable request", "Missing fields to reset password", Status::UnprocessableEntity.code);
}
if let Some((new_master_password_hash, new_key)) = new_password {
user.set_password(&new_master_password_hash, Some(new_key), true, None, &conn).await?;
}
if req.reset_two_factor {
@@ -2919,6 +2980,7 @@ async fn get_reset_password_details(
"kdfIterations": user.client_kdf_iter,
"kdfMemory": user.client_kdf_memory,
"kdfParallelism": user.client_kdf_parallelism,
"masterPasswordSalt": user.master_password_salt(),
"resetPasswordKey": member.reset_password_key,
"encryptedPrivateKey": org.private_key,
})))
+5 -1
View File
@@ -137,9 +137,13 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
if CONFIG.org_groups_enabled() {
for group_data in &data.groups {
let group_uuid = if let Some(group) =
let group_uuid = if let Some(mut group) =
Group::find_by_external_id_and_org(&group_data.external_id, &org_id, &conn).await
{
if group.name != group_data.name {
group.name = group_data.name.clone();
group.save(&conn).await?;
}
group.uuid
} else {
let mut group =
+23 -45
View File
@@ -3,11 +3,11 @@ use rocket::{Route, serde::json::Json};
use crate::{
api::{EmptyResult, JsonResult, PasswordOrOtpData, core::log_user_event, core::two_factor::generate_recover_code},
auth::{ClientIp, Headers},
auth::{ClientIp, Headers, two_factor},
crypto,
db::{
DbConn,
models::{Device, EventType, TwoFactor, TwoFactorType, UserId},
models::{EventType, TwoFactor, TwoFactorType, UserId},
},
util::NumberOrString,
};
@@ -20,27 +20,23 @@ pub fn routes() -> Vec<Route> {
#[post("/two-factor/get-authenticator", data = "<data>")]
async fn generate_authenticator(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult {
let data: PasswordOrOtpData = data.into_inner();
let user = headers.user;
data.validate(&user, false, &conn).await?;
let type_ = TwoFactorType::Authenticator as i32;
let twofactor = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await;
let twofactor = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Authenticator, &conn).await;
let (enabled, key) = match twofactor {
Some(tf) => (true, tf.data),
_ => (false, crypto::encode_random_bytes::<20>(&BASE32)),
};
// Upstream seems to also return `userVerificationToken`, but doesn't seem to be used at all.
// It should help prevent TOTP disclosure if someone keeps their vault unlocked.
// Since it doesn't seem to be used, and also does not cause any issues, lets leave it out of the response.
// See: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Auth/Controllers/TwoFactorController.cs#L94
Ok(Json(json!({
"enabled": enabled,
"key": key,
"object": "twoFactorAuthenticator"
"authenticator": json!({
"enabled": enabled,
"key": key,
}),
"userVerificationToken": two_factor::authenticator_token(user.uuid, key, enabled),
})))
}
@@ -49,8 +45,7 @@ async fn generate_authenticator(data: Json<PasswordOrOtpData>, headers: Headers,
struct EnableAuthenticatorData {
key: String,
token: NumberOrString,
master_password_hash: Option<String>,
otp: Option<String>,
user_verification_token: String,
}
#[post("/two-factor/authenticator", data = "<data>")]
@@ -61,12 +56,7 @@ async fn activate_authenticator(data: Json<EnableAuthenticatorData>, headers: He
let mut user = headers.user;
PasswordOrOtpData {
master_password_hash: data.master_password_hash,
otp: data.otp,
}
.validate(&user, true, &conn)
.await?;
two_factor::validate_authenticator(&data.user_verification_token, &user.uuid, &key, false)?;
// Validate key as base32 and 20 bytes length
let decoded_key: Vec<u8> = if let Ok(decoded) = BASE32.decode(key.as_bytes()) {
@@ -84,12 +74,13 @@ async fn activate_authenticator(data: Json<EnableAuthenticatorData>, headers: He
generate_recover_code(&mut user, &conn).await;
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
log_user_event(EventType::UserUpdated2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
Ok(Json(json!({
"enabled": true,
"key": key,
"object": "twoFactorAuthenticator"
"authenticator": json!({
"enabled": true,
"key": key,
}),
})))
}
@@ -125,8 +116,7 @@ pub async fn validate_totp_code(
err!("Invalid TOTP secret")
};
let mut twofactor = match TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Authenticator as i32, conn).await
{
let mut twofactor = match TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Authenticator, conn).await {
Some(tf) => tf,
_ => TwoFactor::new(user_id.clone(), TwoFactorType::Authenticator, secret.to_owned()),
};
@@ -184,37 +174,25 @@ pub async fn validate_totp_code(
#[serde(rename_all = "camelCase")]
struct DisableAuthenticatorData {
key: String,
master_password_hash: String,
r#type: NumberOrString,
user_verification_token: String,
}
#[delete("/two-factor/authenticator", data = "<data>")]
async fn disable_authenticator(data: Json<DisableAuthenticatorData>, headers: Headers, conn: DbConn) -> JsonResult {
async fn disable_authenticator(data: Json<DisableAuthenticatorData>, headers: Headers, conn: DbConn) -> EmptyResult {
let user = headers.user;
let type_ = data.r#type.into_i32()?;
if !user.check_valid_password(&data.master_password_hash) {
err!("Invalid password");
}
two_factor::validate_authenticator(&data.user_verification_token, &user.uuid, &data.key, true)?;
if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await {
if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Authenticator, &conn).await {
if twofactor.data == data.key {
twofactor.delete(&conn).await?;
Device::clear_twofactor_remember_by_user(&user.uuid, &conn).await?;
log_user_event(EventType::UserDisabled2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn)
.await;
log_user_event(EventType::UserDisabled2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
} else {
err!(format!("TOTP key for user {} does not match recorded value, cannot deactivate", &user.email));
}
}
if TwoFactor::find_by_user(&user.uuid, &conn).await.is_empty() {
super::enforce_2fa_policy(&user, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await?;
}
super::check_2fa_state(&user, headers.device.atype, &headers.ip.ip, &conn).await?;
Ok(Json(json!({
"enabled": false,
"keys": type_,
"object": "twoFactorProvider"
})))
Ok(())
}
+48 -77
View File
@@ -2,12 +2,12 @@ use chrono::Utc;
use rocket::{Route, serde::json::Json};
use crate::{
CONFIG,
api::{
ApiResult, EmptyResult, JsonResult, PasswordOrOtpData, core::log_user_event,
core::two_factor::generate_recover_code,
ApiResult, EmptyResult, JsonResult, PasswordOrOtpData,
core::log_user_event,
core::two_factor::{VerificationTokenData, generate_recover_code},
},
auth::Headers,
auth::{Headers, two_factor, two_factor::DuoData},
crypto,
db::{
DbConn,
@@ -18,55 +18,7 @@ use crate::{
};
pub fn routes() -> Vec<Route> {
routes![get_duo, activate_duo, activate_duo_put,]
}
#[derive(Serialize, Deserialize)]
struct DuoData {
host: String, // Duo API hostname
ik: String, // client id
sk: String, // client secret
}
impl DuoData {
fn global() -> Option<Self> {
match (CONFIG._enable_duo(), CONFIG.duo_host()) {
(true, Some(host)) => Some(Self {
host,
ik: CONFIG.duo_ikey().unwrap(),
sk: CONFIG.duo_skey().unwrap(),
}),
_ => None,
}
}
fn msg(s: &str) -> Self {
Self {
host: s.into(),
ik: s.into(),
sk: s.into(),
}
}
fn secret() -> Self {
Self::msg("<global_secret>")
}
fn obscure(self) -> Self {
let mut host = self.host;
let mut ik = self.ik;
let mut sk = self.sk;
let digits = 4;
let replaced = "************";
host.replace_range(digits.., replaced);
ik.replace_range(digits.., replaced);
sk.replace_range(digits.., replaced);
Self {
host,
ik,
sk,
}
}
routes![get_duo, activate_duo, activate_duo_put, disable_duo,]
}
enum DuoStatus {
@@ -95,22 +47,19 @@ async fn get_duo(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn)
data.validate(&user, false, &conn).await?;
let data = get_user_duo_data(&user.uuid, &conn).await;
let (enabled, data) = match data {
let (enabled, duo) = match get_user_duo_data(&user.uuid, &conn).await {
DuoStatus::Global(_) => (true, Some(DuoData::secret())),
DuoStatus::User(data) => (true, Some(data.obscure())),
DuoStatus::Disabled(true) => (false, Some(DuoData::msg(DISABLED_MESSAGE_DEFAULT))),
DuoStatus::Disabled(false) => (false, None),
};
let json = if let Some(data) = data {
let duo_json = if let Some(data) = duo.as_ref() {
json!({
"enabled": enabled,
"host": data.host,
"clientSecret": data.sk,
"clientId": data.ik,
"object": "twoFactorDuo"
})
} else {
json!({
@@ -118,11 +67,13 @@ async fn get_duo(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn)
"host": null,
"clientSecret": null,
"clientId": null,
"object": "twoFactorDuo"
})
};
Ok(Json(json))
Ok(Json(rocket::serde::json::json!({
"duo": duo_json,
"userVerificationToken": two_factor::duo_token(user.uuid, duo, enabled),
})))
}
#[derive(Deserialize)]
@@ -131,8 +82,7 @@ struct EnableDuoData {
host: String,
client_secret: String,
client_id: String,
master_password_hash: Option<String>,
otp: Option<String>,
user_verification_token: String,
}
impl From<EnableDuoData> for DuoData {
@@ -159,12 +109,7 @@ async fn activate_duo(data: Json<EnableDuoData>, headers: Headers, conn: DbConn)
let data: EnableDuoData = data.into_inner();
let mut user = headers.user;
PasswordOrOtpData {
master_password_hash: data.master_password_hash.clone(),
otp: data.otp.clone(),
}
.validate(&user, true, &conn)
.await?;
two_factor::validate_duo(&data.user_verification_token, &user.uuid, None, false)?;
let (data, data_str) = if check_duo_fields_custom(&data) {
let data_req: DuoData = data.into();
@@ -181,14 +126,15 @@ async fn activate_duo(data: Json<EnableDuoData>, headers: Headers, conn: DbConn)
generate_recover_code(&mut user, &conn).await;
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
log_user_event(EventType::UserUpdated2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
Ok(Json(json!({
"enabled": true,
"host": data.host,
"clientSecret": data.sk,
"clientId": data.ik,
"object": "twoFactorDuo"
"duo": json!({
"enabled": true,
"host": data.host,
"clientSecret": data.sk,
"clientId": data.ik,
}),
})))
}
@@ -197,6 +143,29 @@ async fn activate_duo_put(data: Json<EnableDuoData>, headers: Headers, conn: DbC
activate_duo(data, headers, conn).await
}
#[delete("/two-factor/duo", data = "<data>")]
async fn disable_duo(data: Json<VerificationTokenData>, headers: Headers, conn: DbConn) -> EmptyResult {
let user = headers.user;
if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Duo, &conn).await {
// Apply the same transformation than in `get_duo` to check we are disabling the correct one
let duo = match to_user_duo_data(&twofactor) {
DuoStatus::Global(_) => Some(DuoData::secret()),
DuoStatus::User(data) => Some(data.obscure()),
DuoStatus::Disabled(_) => None,
};
two_factor::validate_duo(&data.user_verification_token, &user.uuid, duo.as_ref(), true)?;
twofactor.delete(&conn).await?;
log_user_event(EventType::UserDisabled2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
}
super::check_2fa_state(&user, headers.device.atype, &headers.ip.ip, &conn).await?;
Ok(())
}
async fn duo_api_request(method: &str, path: &str, params: &str, data: &DuoData) -> EmptyResult {
use reqwest::{Method, header};
use std::str::FromStr;
@@ -222,13 +191,15 @@ async fn duo_api_request(method: &str, path: &str, params: &str, data: &DuoData)
}
async fn get_user_duo_data(user_id: &UserId, conn: &DbConn) -> DuoStatus {
let type_ = TwoFactorType::Duo as i32;
// If the user doesn't have an entry, disabled
let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, type_, conn).await else {
let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Duo, conn).await else {
return DuoStatus::Disabled(DuoData::global().is_some());
};
to_user_duo_data(&twofactor)
}
fn to_user_duo_data(twofactor: &TwoFactor) -> DuoStatus {
// If the user has the required values, we use those
if let Ok(data) = serde_json::from_str(&twofactor.data) {
return DuoStatus::User(data);
+71 -52
View File
@@ -5,20 +5,26 @@ use crate::{
CONFIG,
api::{
EmptyResult, JsonResult, PasswordOrOtpData,
core::{log_user_event, two_factor::generate_recover_code},
core::{
log_user_event,
two_factor::{VerificationTokenData, generate_recover_code},
},
},
auth::{ClientHeaders, Headers},
auth::{ClientHeaders, Headers, two_factor},
crypto,
db::{
DbConn,
models::{AuthRequest, AuthRequestId, DeviceId, EventType, TwoFactor, TwoFactorType, User, UserId},
models::{
AuthRequest, AuthRequestId, DeviceId, EventType, TwoFactor, TwoFactorIncomplete, TwoFactorType, User,
UserId,
},
},
error::{Error, MapResult},
mail,
};
pub fn routes() -> Vec<Route> {
routes![get_email, send_email_login, send_email, email,]
routes![get_email, send_email_login, send_email, email, disable_email]
}
#[derive(Deserialize)]
@@ -91,6 +97,20 @@ async fn send_email_login(data: Json<SendEmailLoginData>, client_headers: Client
{
err!("AuthRequest doesn't exist", "Invalid device, IP or code")
}
} else if let Some(device_identifier) = &data.device_identifier {
// iOS/Android SSO logins send the email and device id but no password hash,
// so accept a device that has a pending 2FA login for this user
if TwoFactorIncomplete::find_by_user_and_device(&user.uuid, device_identifier, &conn).await.is_none() {
err!(
"Username or password is incorrect. Try again",
format!("IP: {}. Username: {}.", client_headers.ip.ip, email.escape_debug())
)
}
debug!(
"Email 2FA fallback: pending login. Username: {}. Device: {}.",
user.email,
device_identifier.to_string().escape_debug()
);
} else {
err!("No password hash has been submitted.")
}
@@ -104,7 +124,7 @@ async fn send_email_login(data: Json<SendEmailLoginData>, client_headers: Client
let Some(user) = User::find_by_device_for_email2fa(device_identifier, &conn).await else {
err!(
"Username or password is incorrect. Try again",
format!("IP: {}. Device: {device_identifier}.", client_headers.ip.ip)
format!("IP: {}. Device: {}.", client_headers.ip.ip, device_identifier.to_string().escape_debug())
)
};
@@ -116,8 +136,8 @@ async fn send_email_login(data: Json<SendEmailLoginData>, client_headers: Client
/// Generate the token, save the data for later verification and send email to user
pub async fn send_token(user_id: &UserId, conn: &DbConn) -> EmptyResult {
let type_ = TwoFactorType::Email as i32;
let mut twofactor = TwoFactor::find_by_user_and_type(user_id, type_, conn).await.map_res("Two factor not found")?;
let mut twofactor =
TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Email, conn).await.map_res("Two factor not found")?;
let generated_token = crypto::generate_email_token(CONFIG.email_token_size());
@@ -140,18 +160,19 @@ async fn get_email(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn
data.validate(&user, false, &conn).await?;
let (enabled, mfa_email) =
match TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email as i32, &conn).await {
Some(x) => {
let twofactor_data = EmailTokenData::from_json(&x.data)?;
(true, json!(twofactor_data.email))
}
_ => (false, serde_json::value::Value::Null),
if let Some(x) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email, &conn).await {
let twofactor_data = EmailTokenData::from_json(&x.data)?;
(true, Some(twofactor_data.email))
} else {
(false, None)
};
Ok(Json(json!({
"email": mfa_email,
"enabled": enabled,
"object": "twoFactorEmail"
Ok(Json(rocket::serde::json::json!({
"email": rocket::serde::json::json!({
"enabled": enabled,
"email": mfa_email,
}),
"userVerificationToken": two_factor::email_token(user.uuid, mfa_email, enabled),
})))
}
@@ -160,30 +181,22 @@ async fn get_email(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn
struct SendEmailData {
/// Email where 2FA codes will be sent to, can be different than user email account.
email: String,
master_password_hash: Option<String>,
otp: Option<String>,
user_verification_token: String,
}
/// Send a verification email to the specified email address to check whether it exists/belongs to user.
#[post("/two-factor/send-email", data = "<data>")]
async fn send_email(data: Json<SendEmailData>, headers: Headers, conn: DbConn) -> EmptyResult {
async fn send_email(data: Json<SendEmailData>, headers: Headers, conn: DbConn) -> JsonResult {
let data: SendEmailData = data.into_inner();
let user = headers.user;
PasswordOrOtpData {
master_password_hash: data.master_password_hash,
otp: data.otp,
}
.validate(&user, false, &conn)
.await?;
two_factor::validate_email(&data.user_verification_token, &user.uuid, data.email.clone(), false)?;
if !CONFIG._enable_email_2fa() {
err!("Email 2FA is disabled")
}
let type_ = TwoFactorType::Email as i32;
if let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await {
if let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email, &conn).await {
tf.delete(&conn).await?;
}
@@ -191,12 +204,13 @@ async fn send_email(data: Json<SendEmailData>, headers: Headers, conn: DbConn) -
let twofactor_data = EmailTokenData::new(data.email, Some(generated_token));
// Uses EmailVerificationChallenge as type to show that it's not verified yet.
let twofactor = TwoFactor::new(user.uuid, TwoFactorType::EmailVerificationChallenge, twofactor_data.to_json());
let twofactor =
TwoFactor::new(user.uuid.clone(), TwoFactorType::EmailVerificationChallenge, twofactor_data.to_json());
twofactor.save(&conn).await?;
mail::send_token(&twofactor_data.email, &twofactor_data.last_token.map_res("Token is empty")?).await?;
Ok(())
Ok(Json(json!({})))
}
#[derive(Deserialize, Serialize)]
@@ -204,8 +218,7 @@ async fn send_email(data: Json<SendEmailData>, headers: Headers, conn: DbConn) -
struct EmailData {
email: String,
token: String,
master_password_hash: Option<String>,
otp: Option<String>,
user_verification_token: String,
}
/// Verify email belongs to user and can be used for 2FA email codes.
@@ -214,17 +227,11 @@ async fn email(data: Json<EmailData>, headers: Headers, conn: DbConn) -> JsonRes
let data: EmailData = data.into_inner();
let mut user = headers.user;
// This is the last step in the verification process, delete the otp directly afterwards
PasswordOrOtpData {
master_password_hash: data.master_password_hash,
otp: data.otp,
}
.validate(&user, true, &conn)
.await?;
two_factor::validate_email(&data.user_verification_token, &user.uuid, data.email, false)?;
let type_ = TwoFactorType::EmailVerificationChallenge as i32;
let mut twofactor =
TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await.map_res("Two factor not found")?;
let mut twofactor = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::EmailVerificationChallenge, &conn)
.await
.map_res("Two factor not found")?;
let mut email_data = EmailTokenData::from_json(&twofactor.data)?;
@@ -243,13 +250,26 @@ async fn email(data: Json<EmailData>, headers: Headers, conn: DbConn) -> JsonRes
generate_recover_code(&mut user, &conn).await;
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
log_user_event(EventType::UserUpdated2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
Ok(Json(json!({
"email": email_data.email,
"enabled": "true",
"object": "twoFactorEmail"
})))
Ok(Json(json!({})))
}
#[delete("/two-factor/email", data = "<data>")]
async fn disable_email(data: Json<VerificationTokenData>, headers: Headers, conn: DbConn) -> EmptyResult {
let user = headers.user;
if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email, &conn).await {
let twofactor_data = EmailTokenData::from_json(&twofactor.data)?;
two_factor::validate_email(&data.user_verification_token, &user.uuid, twofactor_data.email, true)?;
twofactor.delete(&conn).await?;
log_user_event(EventType::UserDisabled2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
}
super::check_2fa_state(&user, headers.device.atype, &headers.ip.ip, &conn).await?;
Ok(())
}
/// Validate the email code when used as TwoFactor token mechanism
@@ -261,9 +281,8 @@ pub async fn validate_email_code_str(
conn: &DbConn,
) -> EmptyResult {
let mut email_data = EmailTokenData::from_json(data)?;
let mut twofactor = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Email as i32, conn)
.await
.map_res("Two factor not found")?;
let mut twofactor =
TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Email, conn).await.map_res("Two factor not found")?;
let Some(issued_token) = &email_data.last_token else {
err!(
format!("No token available! IP: {ip}"),
+15 -44
View File
@@ -7,10 +7,7 @@ use serde_json::Value;
use crate::{
CONFIG,
api::{
EmptyResult, JsonResult, PasswordOrOtpData,
core::{log_event, log_user_event},
},
api::{EmptyResult, JsonResult, PasswordOrOtpData, core::log_event},
auth::Headers,
crypto,
db::{
@@ -21,7 +18,6 @@ use crate::{
},
},
mail,
util::NumberOrString,
};
pub mod authenticator;
@@ -69,7 +65,7 @@ pub fn is_twofactor_provider_usable(provider_type: &TwoFactorType, provider_data
}
pub fn routes() -> Vec<Route> {
let mut routes = routes![get_twofactor, get_recover, disable_twofactor, get_device_verification_settings];
let mut routes = routes![get_twofactor, get_recover, get_device_verification_settings];
routes.append(&mut authenticator::routes());
routes.append(&mut duo::routes());
@@ -81,6 +77,12 @@ pub fn routes() -> Vec<Route> {
routes
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct VerificationTokenData {
user_verification_token: String,
}
#[get("/two-factor")]
async fn get_twofactor(headers: Headers, conn: DbConn) -> Json<Value> {
let twofactors = TwoFactor::find_by_user(&headers.user.uuid, &conn).await;
@@ -120,45 +122,14 @@ async fn generate_recover_code(user: &mut User, conn: &DbConn) {
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct DisableTwoFactorData {
master_password_hash: Option<String>,
otp: Option<String>,
r#type: NumberOrString,
}
#[put("/two-factor/disable", data = "<data>")]
async fn disable_twofactor(data: Json<DisableTwoFactorData>, headers: Headers, conn: DbConn) -> JsonResult {
let data: DisableTwoFactorData = data.into_inner();
let user = headers.user;
// Delete directly after a valid token has been provided
PasswordOrOtpData {
master_password_hash: data.master_password_hash,
otp: data.otp,
/// Call after a 2FA provider, or one of its keys, was removed. No remembered device may keep skipping
/// the providers that are left, and once every provider is gone the 2FA policy applies.
pub async fn check_2fa_state(user: &User, device_type: i32, ip: &std::net::IpAddr, conn: &DbConn) -> EmptyResult {
Device::clear_twofactor_remember_by_user(&user.uuid, conn).await?;
if TwoFactor::find_by_user(&user.uuid, conn).await.is_empty() {
enforce_2fa_policy(user, &user.uuid, device_type, ip, conn).await?;
}
.validate(&user, true, &conn)
.await?;
let type_ = data.r#type.into_i32()?;
if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await {
twofactor.delete(&conn).await?;
Device::clear_twofactor_remember_by_user(&user.uuid, &conn).await?;
log_user_event(EventType::UserDisabled2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn)
.await;
}
if TwoFactor::find_by_user(&user.uuid, &conn).await.is_empty() {
enforce_2fa_policy(&user, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await?;
}
Ok(Json(json!({
"enabled": false,
"type": type_,
"object": "twoFactorProvider"
})))
Ok(())
}
pub async fn enforce_2fa_policy(
+2 -3
View File
@@ -72,8 +72,7 @@ async fn request_otp(headers: Headers, conn: DbConn) -> EmptyResult {
let user = headers.user;
// Only one Protected Action per user is allowed to take place, delete the previous one
if let Some(pa) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::ProtectedActions as i32, &conn).await
{
if let Some(pa) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::ProtectedActions, &conn).await {
let pa_data = ProtectedActionData::from_json(&pa.data)?;
let elapsed = pa_data.time_since_sent().num_seconds();
let delay = 30;
@@ -125,7 +124,7 @@ pub async fn validate_protected_action_otp(
delete_if_valid: bool,
conn: &DbConn,
) -> EmptyResult {
let mut pa = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::ProtectedActions as i32, conn)
let mut pa = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::ProtectedActions, conn)
.await
.map_res("Protected action token not found, try sending the code again or restart the process")?;
let mut pa_data = ProtectedActionData::from_json(&pa.data)?;
+111 -72
View File
@@ -1,4 +1,4 @@
use std::{str::FromStr, sync::LazyLock, time::Duration};
use std::{collections::HashSet, str::FromStr, sync::LazyLock, time::Duration};
use rocket::{Route, serde::json::Json};
use serde_json::Value;
@@ -18,16 +18,18 @@ use crate::{
CONFIG,
api::{
EmptyResult, JsonResult, PasswordOrOtpData,
core::{log_user_event, two_factor::generate_recover_code},
core::{
log_user_event,
two_factor::{VerificationTokenData, generate_recover_code},
},
},
auth::Headers,
auth::{Headers, two_factor},
crypto::ct_eq,
db::{
DbConn,
models::{Device, EventType, TwoFactor, TwoFactorType, UserId},
models::{EventType, TwoFactor, TwoFactorType, UserId},
},
error::Error,
util::NumberOrString,
};
static WEBAUTHN: LazyLock<Webauthn> = LazyLock::new(|| {
@@ -45,7 +47,14 @@ static WEBAUTHN: LazyLock<Webauthn> = LazyLock::new(|| {
});
pub fn routes() -> Vec<Route> {
routes![get_webauthn, generate_webauthn_challenge, activate_webauthn, activate_webauthn_put, delete_webauthn,]
routes![
get_webauthn,
generate_webauthn_challenge,
activate_webauthn,
activate_webauthn_put,
delete_webauthn,
delete_webauthns
]
}
// Some old u2f structs still needed for migrating from u2f to WebAuthn
@@ -119,34 +128,36 @@ async fn get_webauthn(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbC
data.validate(&user, false, &conn).await?;
let (enabled, registrations) = get_webauthn_registrations(&user.uuid, &conn).await?;
let keys: Vec<i32> = registrations.iter().map(|r| r.id).collect();
let registrations_json: Vec<Value> = registrations.iter().map(WebauthnRegistration::to_json).collect();
Ok(Json(json!({
"enabled": enabled,
"keys": registrations_json,
"object": "twoFactorWebAuthn"
"webAuthn": json!({
"enabled": enabled,
"keys": registrations_json,
}),
"userVerificationToken": two_factor::webauthn_token(user.uuid, keys, enabled),
})))
}
#[post("/two-factor/get-webauthn-challenge", data = "<data>")]
async fn generate_webauthn_challenge(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult {
let data: PasswordOrOtpData = data.into_inner();
async fn generate_webauthn_challenge(data: Json<VerificationTokenData>, headers: Headers, conn: DbConn) -> JsonResult {
let user = headers.user;
data.validate(&user, false, &conn).await?;
let registrations = get_webauthn_registrations(&user.uuid, &conn)
.await?
.1
let (enabled, registrations) = get_webauthn_registrations(&user.uuid, &conn).await?;
let keys: Vec<i32> = registrations.iter().map(|r| r.id).collect();
let creds = registrations
.into_iter()
.map(|r| r.credential.cred_id().to_owned()) // We return the credentialIds to the clients to avoid double registering
.collect();
two_factor::validate_webauthn(&data.user_verification_token, &user.uuid, &keys, enabled)?;
let (mut challenge, state) = WEBAUTHN.start_passkey_registration(
Uuid::from_str(&user.uuid).expect("Failed to parse UUID"), // Should never fail
&user.email,
user.display_name(),
Some(registrations),
Some(creds),
)?;
let mut state = serde_json::to_value(&state)?;
@@ -166,17 +177,19 @@ async fn generate_webauthn_challenge(data: Json<PasswordOrOtpData>, headers: Hea
let mut challenge_value = serde_json::to_value(challenge.public_key)?;
challenge_value["status"] = "ok".into();
challenge_value["errorMessage"] = "".into();
Ok(Json(challenge_value))
Ok(Json(json!({
"options": challenge_value
})))
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct EnableWebauthnData {
id: NumberOrString, // 1..5
id: i32,
name: String,
device_response: RegisterPublicKeyCredentialCopy,
master_password_hash: Option<String>,
otp: Option<String>,
user_verification_token: String,
}
#[derive(Debug, Deserialize)]
@@ -257,16 +270,14 @@ async fn activate_webauthn(data: Json<EnableWebauthnData>, headers: Headers, con
let data: EnableWebauthnData = data.into_inner();
let mut user = headers.user;
PasswordOrOtpData {
master_password_hash: data.master_password_hash,
otp: data.otp,
}
.validate(&user, true, &conn)
.await?;
let mut registrations: Vec<_> = get_webauthn_registrations(&user.uuid, &conn).await?.1;
let keys: Vec<i32> = registrations.iter().map(|r| r.id).collect();
two_factor::validate_webauthn(&data.user_verification_token, &user.uuid, &keys, !keys.is_empty())?;
// Retrieve and delete the saved challenge state
let type_ = TwoFactorType::WebauthnRegisterChallenge as i32;
let state = if let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await {
let state = if let Some(tf) =
TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::WebauthnRegisterChallenge, &conn).await
{
let state: PasskeyRegistration = serde_json::from_str(&tf.data)?;
tf.delete(&conn).await?;
state
@@ -277,10 +288,9 @@ async fn activate_webauthn(data: Json<EnableWebauthnData>, headers: Headers, con
// Verify the credentials with the saved state
let credential = WEBAUTHN.finish_passkey_registration(&data.device_response.into(), &state)?;
let mut registrations: Vec<_> = get_webauthn_registrations(&user.uuid, &conn).await?.1;
// TODO: Check for repeated ID's
registrations.push(WebauthnRegistration {
id: data.id.into_i32()?,
id: data.id,
name: data.name,
migrated: false,
@@ -293,13 +303,13 @@ async fn activate_webauthn(data: Json<EnableWebauthnData>, headers: Headers, con
.await?;
generate_recover_code(&mut user, &conn).await;
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
log_user_event(EventType::UserUpdated2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
let keys_json: Vec<Value> = registrations.iter().map(WebauthnRegistration::to_json).collect();
Ok(Json(json!({
"enabled": true,
"keys": keys_json,
"object": "twoFactorU2f"
"webAuthn": json!({
"enabled": true,
"keys": registrations.iter().map(WebauthnRegistration::to_json).collect::<Vec<Value>>(),
}),
})))
}
@@ -310,58 +320,87 @@ async fn activate_webauthn_put(data: Json<EnableWebauthnData>, headers: Headers,
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct DeleteU2FData {
id: NumberOrString,
master_password_hash: String,
struct DeleteWebauthnData {
id: i32,
user_verification_token: String,
}
#[delete("/two-factor/webauthn", data = "<data>")]
async fn delete_webauthn(data: Json<DeleteU2FData>, headers: Headers, conn: DbConn) -> JsonResult {
let id = data.id.into_i32()?;
if !headers.user.check_valid_password(&data.master_password_hash) {
err!("Invalid password");
}
async fn delete_webauthn(data: Json<DeleteWebauthnData>, headers: Headers, conn: DbConn) -> JsonResult {
inner_delete_webauthns(&data.user_verification_token, |key| key.id != data.id, headers, &conn).await
}
let Some(mut tf) =
TwoFactor::find_by_user_and_type(&headers.user.uuid, TwoFactorType::Webauthn as i32, &conn).await
else {
#[delete("/two-factor/webauthn/all", data = "<data>")]
async fn delete_webauthns(data: Json<VerificationTokenData>, headers: Headers, conn: DbConn) -> JsonResult {
inner_delete_webauthns(&data.user_verification_token, |_| false, headers, &conn).await
}
async fn inner_delete_webauthns(
token: &str,
retain: impl Fn(&WebauthnRegistration) -> bool,
headers: Headers,
conn: &DbConn,
) -> JsonResult {
let user = headers.user;
let Some(mut tf) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Webauthn, conn).await else {
err!("Webauthn data not found!")
};
let mut data: Vec<WebauthnRegistration> = serde_json::from_str(&tf.data)?;
let mut keys: Vec<WebauthnRegistration> = serde_json::from_str(&tf.data)?;
let keys_id: Vec<i32> = keys.iter().map(|r| r.id).collect();
let Some(item_pos) = data.iter().position(|r| r.id == id) else {
two_factor::validate_webauthn(token, &user.uuid, &keys_id, true)?;
let mut removed: HashSet<Vec<u8>> = HashSet::new();
let mut migrated = false;
keys.retain(|key| {
let retained = retain(key);
if !retained {
removed.insert(key.credential.cred_id().to_vec());
migrated = migrated || key.migrated;
}
retained
});
if removed.is_empty() {
err!("Webauthn entry not found")
};
}
let removed_item = data.remove(item_pos);
tf.data = serde_json::to_string(&data)?;
tf.save(&conn).await?;
drop(tf);
Device::clear_twofactor_remember_by_user(&headers.user.uuid, &conn).await?;
if keys.is_empty() {
tf.delete(conn).await?;
log_user_event(EventType::UserDisabled2fa, &user.uuid, headers.device.atype, &headers.ip.ip, conn).await;
} else {
tf.data = serde_json::to_string(&keys)?;
tf.save(conn).await?;
drop(tf);
}
// If entry is migrated from u2f, delete the u2f entry as well
if let Some(mut u2f) = TwoFactor::find_by_user_and_type(&headers.user.uuid, TwoFactorType::U2f as i32, &conn).await
{
let mut data: Vec<U2FRegistration> = if let Ok(d) = serde_json::from_str(&u2f.data) {
d
} else {
if migrated && let Some(mut u2f) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::U2f, conn).await {
let Ok(mut data) = serde_json::from_str::<Vec<U2FRegistration>>(&u2f.data) else {
err!("Error parsing U2F data")
};
data.retain(|r| r.reg.key_handle != removed_item.credential.cred_id().as_slice());
let new_data_str = serde_json::to_string(&data)?;
data.retain(|old| !removed.contains(&old.reg.key_handle));
u2f.data = new_data_str;
u2f.save(&conn).await?;
if data.is_empty() {
u2f.delete(conn).await?;
} else {
let new_data_str = serde_json::to_string(&data)?;
u2f.data = new_data_str;
u2f.save(conn).await?;
}
}
let keys_json: Vec<Value> = data.iter().map(WebauthnRegistration::to_json).collect();
super::check_2fa_state(&user, headers.device.atype, &headers.ip.ip, conn).await?;
Ok(Json(json!({
"enabled": true,
"keys": keys_json,
"object": "twoFactorU2f"
"webAuthn": json!({
"enabled": !keys.is_empty(),
"keys": keys.iter().map(WebauthnRegistration::to_json).collect::<Vec<Value>>(),
}),
})))
}
@@ -369,8 +408,7 @@ pub async fn get_webauthn_registrations(
user_id: &UserId,
conn: &DbConn,
) -> Result<(bool, Vec<WebauthnRegistration>), Error> {
let type_ = TwoFactorType::Webauthn as i32;
match TwoFactor::find_by_user_and_type(user_id, type_, conn).await {
match TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Webauthn, conn).await {
Some(tf) => Ok((tf.enabled, serde_json::from_str(&tf.data)?)),
None => Ok((false, Vec::new())), // If no data, return empty list
}
@@ -417,8 +455,9 @@ pub async fn generate_webauthn_login(user_id: &UserId, conn: &DbConn) -> JsonRes
}
pub async fn validate_webauthn_login(user_id: &UserId, response: &str, conn: &DbConn) -> EmptyResult {
let type_ = TwoFactorType::WebauthnLoginChallenge as i32;
let mut state = if let Some(tf) = TwoFactor::find_by_user_and_type(user_id, type_, conn).await {
let mut state = if let Some(tf) =
TwoFactor::find_by_user_and_type(user_id, TwoFactorType::WebauthnLoginChallenge, conn).await
{
let state: PasskeyAuthentication = serde_json::from_str(&tf.data)?;
tf.delete(conn).await?;
state
+53 -45
View File
@@ -10,9 +10,12 @@ use crate::{
CONFIG,
api::{
EmptyResult, JsonResult, PasswordOrOtpData,
core::{log_user_event, two_factor::generate_recover_code},
core::{
log_user_event,
two_factor::{VerificationTokenData, generate_recover_code},
},
},
auth::Headers,
auth::{Headers, two_factor},
db::{
DbConn,
models::{EventType, TwoFactor, TwoFactorType},
@@ -22,7 +25,7 @@ use crate::{
};
pub fn routes() -> Vec<Route> {
routes![generate_yubikey, activate_yubikey, activate_yubikey_put,]
routes![generate_yubikey, activate_yubikey, activate_yubikey_put, delete_yubikeys,]
}
struct HttpClientTransport {
@@ -60,8 +63,7 @@ struct EnableYubikeyData {
key4: Option<String>,
key5: Option<String>,
nfc: bool,
master_password_hash: Option<String>,
otp: Option<String>,
user_verification_token: String,
}
#[derive(Deserialize, Serialize, Debug)]
@@ -125,48 +127,29 @@ async fn generate_yubikey(data: Json<PasswordOrOtpData>, headers: Headers, conn:
data.validate(&user, false, &conn).await?;
let user_id = &user.uuid;
let yubikey_type = TwoFactorType::YubiKey as i32;
let r = TwoFactor::find_by_user_and_type(user_id, yubikey_type, &conn).await;
if let Some(r) = r {
let yubikey_metadata: YubikeyMetadata = serde_json::from_str(&r.data)?;
let mut result = jsonify_yubikeys(yubikey_metadata.keys);
result["enabled"] = Value::Bool(true);
result["nfc"] = Value::Bool(yubikey_metadata.nfc);
result["object"] = Value::String("twoFactorU2f".to_owned());
Ok(Json(result))
} else {
Ok(Json(json!({
"enabled": false,
"object": "twoFactorU2f",
})))
}
let (enabled, keys, yubikey_json) =
if let Some(r) = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::YubiKey, &conn).await {
let yubikey_metadata: YubikeyMetadata = serde_json::from_str(&r.data)?;
let enabled = !yubikey_metadata.keys.is_empty();
let mut result = jsonify_yubikeys(yubikey_metadata.keys.clone());
result["enabled"] = Value::Bool(enabled);
result["nfc"] = Value::Bool(yubikey_metadata.nfc);
(enabled, yubikey_metadata.keys, result)
} else {
(false, Vec::new(), json!({"enabled": false}))
};
Ok(Json(json!({
"yubiKey": yubikey_json,
"userVerificationToken": two_factor::yubikey_token(user.uuid, keys, enabled),
})))
}
#[post("/two-factor/yubikey", data = "<data>")]
async fn activate_yubikey(data: Json<EnableYubikeyData>, headers: Headers, conn: DbConn) -> JsonResult {
let data: EnableYubikeyData = data.into_inner();
let mut user = headers.user;
PasswordOrOtpData {
master_password_hash: data.master_password_hash.clone(),
otp: data.otp.clone(),
}
.validate(&user, true, &conn)
.await?;
// Check if we already have some data
let mut yubikey_data =
match TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::YubiKey as i32, &conn).await {
Some(data) => data,
None => TwoFactor::new(user.uuid.clone(), TwoFactorType::YubiKey, String::new()),
};
let yubikeys = parse_yubikeys(&data);
let mut user = headers.user;
if yubikeys.is_empty() {
// Return an error to prevent saving empty keys which would cause users not being able to login anymore.
@@ -174,6 +157,17 @@ async fn activate_yubikey(data: Json<EnableYubikeyData>, headers: Headers, conn:
err!("A key is required.");
}
// Check if we already have some data
let mut yubikey_data =
if let Some(yd) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::YubiKey, &conn).await {
let ym: YubikeyMetadata = serde_json::from_str(&yd.data)?;
two_factor::validate_yubikey(&data.user_verification_token, &user.uuid, &ym.keys, !ym.keys.is_empty())?;
yd
} else {
two_factor::validate_yubikey(&data.user_verification_token, &user.uuid, &Vec::new(), false)?;
TwoFactor::new(user.uuid.clone(), TwoFactorType::YubiKey, String::new())
};
// Ensure they are valid OTPs
for yubikey in &yubikeys {
if yubikey.is_empty() || yubikey.len() == 12 {
@@ -195,15 +189,12 @@ async fn activate_yubikey(data: Json<EnableYubikeyData>, headers: Headers, conn:
generate_recover_code(&mut user, &conn).await;
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
log_user_event(EventType::UserUpdated2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
let mut result = jsonify_yubikeys(yubikey_metadata.keys);
result["enabled"] = Value::Bool(true);
result["nfc"] = Value::Bool(yubikey_metadata.nfc);
result["object"] = Value::String("twoFactorU2f".to_owned());
Ok(Json(result))
Ok(Json(json!({"yubiKey": result})))
}
#[put("/two-factor/yubikey", data = "<data>")]
@@ -211,6 +202,23 @@ async fn activate_yubikey_put(data: Json<EnableYubikeyData>, headers: Headers, c
activate_yubikey(data, headers, conn).await
}
#[delete("/two-factor/yubikey", data = "<data>")]
async fn delete_yubikeys(data: Json<VerificationTokenData>, headers: Headers, conn: DbConn) -> EmptyResult {
let user = headers.user;
if let Some(r) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::YubiKey, &conn).await {
let yubikey_metadata: YubikeyMetadata = serde_json::from_str(&r.data)?;
two_factor::validate_yubikey(&data.user_verification_token, &user.uuid, &yubikey_metadata.keys, true)?;
r.delete(&conn).await?;
log_user_event(EventType::UserDisabled2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
}
super::check_2fa_state(&user, headers.device.atype, &headers.ip.ip, &conn).await?;
Ok(())
}
pub async fn validate_yubikey_login(response: &str, twofactor_data: &str) -> EmptyResult {
if response.len() != 44 {
err!("Invalid Yubikey OTP length");
+7 -8
View File
@@ -118,7 +118,7 @@ async fn login(data: Form<ConnectData>, client_header: ClientHeaders, conn: DbCo
match &login_result {
Ok(_) => {
log_user_event(
EventType::UserLoggedIn as i32,
EventType::UserLoggedIn,
&user_id,
client_header.device_type,
&client_header.ip.ip,
@@ -128,8 +128,7 @@ async fn login(data: Form<ConnectData>, client_header: ClientHeaders, conn: DbCo
}
Err(e) => {
if let Some(ev) = e.get_event() {
log_user_event(ev.event as i32, &user_id, client_header.device_type, &client_header.ip.ip, &conn)
.await;
log_user_event(ev.event, &user_id, client_header.device_type, &client_header.ip.ip, &conn).await;
}
}
}
@@ -546,7 +545,7 @@ async fn authenticated_response(
// https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26
"MasterKeyEncryptedUserKey": user.akey,
"MasterKeyWrappedUserKey": user.akey,
"Salt": user.email
"Salt": user.master_password_salt()
})
} else {
Value::Null
@@ -692,7 +691,7 @@ async fn user_api_key_login(
// https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26
"MasterKeyEncryptedUserKey": user.akey,
"MasterKeyWrappedUserKey": user.akey,
"Salt": user.email
"Salt": user.master_password_salt()
})
} else {
Value::Null
@@ -882,7 +881,7 @@ async fn twofactor_auth(
enforce_2fa_policy(user, &user.uuid, device.atype, &ip.ip, conn).await?;
log_user_event(EventType::UserRecovered2fa as i32, &user.uuid, device.atype, &ip.ip, conn).await;
log_user_event(EventType::UserRecovered2fa, &user.uuid, device.atype, &ip.ip, conn).await;
if CONFIG.mail_enabled()
&& let Err(e) =
@@ -952,7 +951,7 @@ async fn json_err_twofactor(providers: &[i32], user: &User, data: &ConnectData,
}
Some(tf_type @ TwoFactorType::YubiKey) => {
let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, tf_type as i32, conn).await else {
let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, tf_type, conn).await else {
err!("No YubiKey devices registered")
};
@@ -964,7 +963,7 @@ async fn json_err_twofactor(providers: &[i32], user: &User, data: &ConnectData,
}
Some(tf_type @ TwoFactorType::Email) => {
let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, tf_type as i32, conn).await else {
let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, tf_type, conn).await else {
err!("No twofactor email registered")
};
+1 -1
View File
@@ -46,7 +46,7 @@ pub type JsonResult = ApiResult<Json<Value>>;
pub type EmptyResult = ApiResult<()>;
// Common structs representing JSON data received
#[derive(Deserialize)]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PasswordOrOtpData {
#[serde(alias = "MasterPasswordHash")]
+36 -10
View File
@@ -1,7 +1,7 @@
use std::{
net::IpAddr,
sync::{Arc, LazyLock},
time::Duration,
time::{Duration, Instant},
};
use chrono::{NaiveDateTime, Utc};
@@ -41,6 +41,14 @@ pub static WS_ANONYMOUS_SUBSCRIPTIONS: LazyLock<Arc<AnonymousWebSocketSubscripti
/// One connection is needed per pending login request, several at once are only expected behind NAT.
const MAX_ANONYMOUS_CONNECTIONS_PER_IP: u32 = 25;
/// How often a Ping is sent to the client.
const WS_PING_INTERVAL: Duration = Duration::from_secs(15);
/// Close the connection if nothing, not even a Pong, was received from the client for this long.
/// Otherwise half-open connections (client gone without a FIN, e.g. behind a proxy or NAT) are kept forever.
/// Same as the default `ClientTimeoutInterval` of ASP.NET Core SignalR, which the official server uses.
const WS_CLIENT_TIMEOUT: Duration = Duration::from_secs(30);
static NOTIFICATIONS_DISABLED: LazyLock<bool> = LazyLock::new(|| !CONFIG.enable_websocket() && !CONFIG.push_enabled());
pub fn routes() -> Vec<Route> {
@@ -156,12 +164,16 @@ fn websockets_hub<'r>(
rocket_ws::Stream! { ws => {
let mut ws = ws;
let _guard = guard;
let mut interval = tokio::time::interval(Duration::from_secs(15));
let mut interval = tokio::time::interval(WS_PING_INTERVAL);
let mut last_received = Instant::now();
loop {
tokio::select! {
res = ws.next() => {
match res {
Some(Ok(message)) => {
// Any message, including a Pong, means the client is still there
last_received = Instant::now();
match message {
// Respond to any pings
Message::Ping(ping) => yield Message::Pong(ping),
@@ -195,7 +207,13 @@ fn websockets_hub<'r>(
}
}
_ = interval.tick() => yield Message::Ping(create_ping())
_ = interval.tick() => {
// The client stopped responding without closing the connection, drop it
if last_received.elapsed() > WS_CLIENT_TIMEOUT {
break;
}
yield Message::Ping(create_ping());
}
}
}
}}
@@ -229,12 +247,16 @@ fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> R
rocket_ws::Stream! { ws => {
let mut ws = ws;
let _guard = guard;
let mut interval = tokio::time::interval(Duration::from_secs(15));
let mut interval = tokio::time::interval(WS_PING_INTERVAL);
let mut last_received = Instant::now();
loop {
tokio::select! {
res = ws.next() => {
match res {
Some(Ok(message)) => {
// Any message, including a Pong, means the client is still there
last_received = Instant::now();
match message {
// Respond to any pings
Message::Ping(ping) => yield Message::Pong(ping),
@@ -268,7 +290,13 @@ fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> R
}
}
_ = interval.tick() => yield Message::Ping(create_ping())
_ = interval.tick() => {
// The client stopped responding without closing the connection, drop it
if last_received.elapsed() > WS_CLIENT_TIMEOUT {
break;
}
yield Message::Ping(create_ping());
}
}
}
}}
@@ -386,8 +414,7 @@ impl WebSocketUsers {
self.send_logout_with_reason(user, acting_device, None, conn).await;
}
/// A logout that clients may handle differently depending on `reason`, e.g. syncing instead of
/// logging out after a key rotation that kept the sessions alive.
/// A logout that clients may handle differently depending on `reason`
pub async fn send_logout_with_reason(
&self,
user: &User,
@@ -680,10 +707,9 @@ fn create_ping() -> Vec<u8> {
serialize(&Value::Array(vec![6.into()]))
}
/// Why a logout was pushed. Absent for a plain logout.
/// Why a logout was pushed, absent for a plain one. `KdfChange` isn't sent yet.
///
/// Ref: <https://github.com/bitwarden/server/blob/main/src/Core/Enums/PushNotificationLogOutReason.cs>
/// (`KdfChange = 0` is not sent by vaultwarden yet.)
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Core/Enums/PushNotificationLogOutReason.cs#L3-L7>
#[derive(Copy, Clone, Eq, PartialEq)]
pub enum LogOutReason {
KeyRotation = 1,
+4 -2
View File
@@ -1,3 +1,6 @@
#[path = "auth/two_factor.rs"]
pub mod two_factor;
#[path = "auth/send.rs"]
pub mod send;
pub type SendTokens = send::SendTokens;
@@ -686,8 +689,7 @@ impl<'r> FromRequest<'r> for Headers {
err_handler!("Error getting current route for stamp exception")
};
// Expired exceptions are dropped the next time the stamp is reset, not here: writing them
// back from this request could undo a reset made since the user was read.
// Expired exceptions are only dropped on reset; writing them back here could undo a newer reset
if !user.stamp_exceptions().iter().any(|e| e.allows(&claims.sstamp, current_route)) {
err_handler!("Invalid security stamp")
}
+281
View File
@@ -0,0 +1,281 @@
use chrono::{TimeDelta, Utc};
use serde::{de::DeserializeOwned, ser::Serialize};
use std::sync::LazyLock;
use crate::{
CONFIG,
api::{ApiResult, EmptyResult},
auth::{decode_jwt, encode_jwt},
db::models::UserId,
};
static JWT_2FA_AUTH_ISSUER: LazyLock<String> = LazyLock::new(|| format!("{}|api.2fa", CONFIG.domain_origin()));
#[derive(Serialize, Deserialize)]
pub struct TwoFactorClaims<T> {
// Not before
pub nbf: i64,
// Expiration time
pub exp: i64,
// Issuer
pub iss: String,
// Subject
pub sub: UserId,
pub enabled: bool,
pub claims: T,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AuthenticatorClaims {
#[serde(rename = "authenticator_key")]
pub key: String,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DuoClaims {
#[serde(rename = "duo_data")]
pub data: Option<DuoData>,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WebauthnClaims {
#[serde(rename = "webauthn_keys")]
pub keys: Vec<i32>,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct YubikeyClaims {
#[serde(rename = "yubi_keys")]
pub keys: Vec<String>,
}
#[derive(Serialize, Deserialize, PartialEq)]
pub struct DuoData {
pub host: String, // Duo API hostname
pub ik: String, // client id
pub sk: String, // client secret
}
impl DuoData {
pub fn global() -> Option<Self> {
match (CONFIG._enable_duo(), CONFIG.duo_host()) {
(true, Some(host)) => Some(Self {
host,
ik: CONFIG.duo_ikey().unwrap(),
sk: CONFIG.duo_skey().unwrap(),
}),
_ => None,
}
}
pub fn msg(s: &str) -> Self {
Self {
host: s.into(),
ik: s.into(),
sk: s.into(),
}
}
pub fn secret() -> Self {
Self::msg("<global_secret>")
}
pub fn obscure(self) -> Self {
let mut host = self.host;
let mut ik = self.ik;
let mut sk = self.sk;
let digits = 4;
let replaced = "************";
host.replace_range(digits.., replaced);
ik.replace_range(digits.., replaced);
sk.replace_range(digits.., replaced);
Self {
host,
ik,
sk,
}
}
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EmailClaims {
pub email: Option<String>,
}
fn token<T: Serialize>(user_id: UserId, enabled: bool, claims: T) -> String {
let time_now = Utc::now();
let claims = TwoFactorClaims {
nbf: time_now.timestamp(),
exp: (time_now + TimeDelta::try_minutes(5).unwrap()).timestamp(),
iss: JWT_2FA_AUTH_ISSUER.to_string(),
sub: user_id,
enabled,
claims,
};
encode_jwt(&claims)
}
fn validate<T: DeserializeOwned>(token: &str, user_id: &UserId, enabled: bool) -> ApiResult<T> {
match decode_jwt::<TwoFactorClaims<T>>(token, JWT_2FA_AUTH_ISSUER.to_string()) {
Ok(claims) => {
if claims.sub != *user_id {
err!("Invalid verification token: Invalid user");
}
if claims.enabled != enabled {
err!("Invalid verification token: Invalid state");
}
Ok(claims.claims)
}
Err(err) => err!(format!("Failed to decode verification token: {err}")),
}
}
pub fn authenticator_token(user_id: UserId, key: String, enabled: bool) -> String {
token(
user_id,
enabled,
AuthenticatorClaims {
key,
},
)
}
pub fn validate_authenticator(token: &str, user_id: &UserId, key: &str, enabled: bool) -> EmptyResult {
let claims = validate::<AuthenticatorClaims>(token, user_id, enabled)?;
if claims.key != key {
err!("Invalid verification token: Invalid key");
}
Ok(())
}
pub fn duo_token(user_id: UserId, data: Option<DuoData>, enabled: bool) -> String {
token(
user_id,
enabled,
DuoClaims {
data,
},
)
}
// When disabling we check that it's the correct data
pub fn validate_duo(token: &str, user_id: &UserId, data: Option<&DuoData>, enabled: bool) -> EmptyResult {
let claims = validate::<DuoClaims>(token, user_id, enabled)?;
if enabled && claims.data.as_ref() != data {
err!("Invalid verification token: Invalid duo data");
}
Ok(())
}
pub fn email_token(user_id: UserId, email: Option<String>, enabled: bool) -> String {
token(
user_id,
enabled,
EmailClaims {
email,
},
)
}
// When disabling we check that it's the correct `email`
pub fn validate_email(token: &str, user_id: &UserId, email: String, enabled: bool) -> EmptyResult {
let claims = validate::<EmailClaims>(token, user_id, enabled)?;
if enabled && claims.email != Some(email) {
err!("Invalid verification token: Invalid email");
}
Ok(())
}
pub fn webauthn_token(user_id: UserId, keys: Vec<i32>, enabled: bool) -> String {
token(
user_id,
enabled,
WebauthnClaims {
keys,
},
)
}
pub fn validate_webauthn(token: &str, user_id: &UserId, keys: &[i32], enabled: bool) -> EmptyResult {
let claims = validate::<WebauthnClaims>(token, user_id, enabled)?;
if keys != claims.keys {
err!("Invalid verification token: Invalid keys");
}
Ok(())
}
pub fn yubikey_token(user_id: UserId, keys: Vec<String>, enabled: bool) -> String {
token(
user_id,
enabled,
YubikeyClaims {
keys,
},
)
}
pub fn validate_yubikey(token: &str, user_id: &UserId, keys: &Vec<String>, enabled: bool) -> EmptyResult {
let claims = validate::<YubikeyClaims>(token, user_id, enabled)?;
if *keys != claims.keys {
err!("Invalid verification token: Invalid keys");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::{Value, from_value, to_value};
fn token<T: Serialize>(claims: T) -> Value {
to_value(TwoFactorClaims {
nbf: 0,
exp: 0,
iss: String::new(),
sub: UserId::from(String::from("4ff0f0a4-0aa4-4c1d-9d43-1f2bd4d5e8b1")),
enabled: false,
claims,
})
.unwrap()
}
#[test]
fn claims_only_parse_as_their_own_provider() {
let tokens = [
token(AuthenticatorClaims {
key: String::from("JBSWY3DPEHPK3PXP"),
}),
token(DuoClaims {
data: None,
}),
token(WebauthnClaims {
keys: vec![1],
}),
token(YubikeyClaims {
keys: Vec::new(),
}),
token(EmailClaims {
email: None,
}),
];
for (issued, token) in tokens.iter().enumerate() {
let parsed = [
from_value::<TwoFactorClaims<AuthenticatorClaims>>(token.clone()).is_ok(),
from_value::<TwoFactorClaims<DuoClaims>>(token.clone()).is_ok(),
from_value::<TwoFactorClaims<WebauthnClaims>>(token.clone()).is_ok(),
from_value::<TwoFactorClaims<YubikeyClaims>>(token.clone()).is_ok(),
from_value::<TwoFactorClaims<EmailClaims>>(token.clone()).is_ok(),
];
for (checked, ok) in parsed.into_iter().enumerate() {
assert_eq!(ok, issued == checked, "token {issued} parsed as {checked}");
}
}
}
}
+14 -3
View File
@@ -1266,6 +1266,14 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
err!("`AUTH_REQUEST_PURGE_SCHEDULE` is not a valid cron expression")
}
if !cfg.duo_context_purge_schedule.is_empty() && cfg.duo_context_purge_schedule.parse::<Schedule>().is_err() {
err!("`DUO_CONTEXT_PURGE_SCHEDULE` is not a valid cron expression")
}
if !cfg.purge_incomplete_sso_auth.is_empty() && cfg.purge_incomplete_sso_auth.parse::<Schedule>().is_err() {
err!("`PURGE_INCOMPLETE_SSO_AUTH` is not a valid cron expression")
}
if !cfg.disable_admin_token {
match cfg.admin_token.as_ref() {
Some(t) if t.starts_with("$argon2") => {
@@ -1422,12 +1430,15 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[
"undetermined-cipher-scenario-logic",
"enable-basic-auth-response",
"ssh-agent-v2",
"windows-desktop-autotype",
"windows-desktop-autotype-ga",
// Key Management Team
"biometrics-sdk-ipc",
"windows-native-credential-sync",
"pm-31050-no-logout-key-upgrade-rotation",
"pm-27278-v2-password-registration",
"enable-account-encryption-v2-jit-password-registration",
"pm-30144-sdk-key-rotation",
"pm-31050-no-logout-key-upgrade-rotation",
"enable-account-encryption-v2-jit-password-registration",
"pm-27278-v2-password-registration",
"force-upgrade-v2-encryption",
// Mobile Team
"pm-34171-card-scanner",
+78 -73
View File
@@ -19,7 +19,7 @@ use crate::{
},
},
error::MapResult,
util::LowerCase,
util::{LowerCase, convert_json_key_lcase_first},
};
use macros::UuidFromParam;
@@ -63,13 +63,9 @@ pub struct Cipher {
pub reprompt: Option<i32>,
}
/// Whether `data` is a sealed cipher blob rather than the legacy per-type JSON.
/// Whether `data` is a v2 cipher blob, recognized like upstream by a top-level `format_version` key.
///
/// Ciphers of v2 accounts are encrypted as a single blob that carries everything, name included,
/// and is opaque to us. It is recognized the same way upstream does, by a top-level
/// `format_version` key that the legacy JSON never has.
///
/// Ref: <https://github.com/bitwarden/server/blob/main/src/Core/Vault/Entities/Cipher.cs>
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Core/Vault/Entities/Cipher.cs#L36-L41>
pub fn is_data_blob_encrypted(data: &str) -> bool {
serde_json::from_str::<Value>(data).is_ok_and(|d| is_blob_value(&d))
}
@@ -86,7 +82,8 @@ pub enum RepromptType {
/// Local methods
impl Cipher {
pub fn new(atype: i32, name: String) -> Self {
/// The name is set when the data is saved, since a blob-encrypted cipher has it inside `data`
pub fn new(atype: i32) -> Self {
let now = Utc::now().naive_utc();
Self {
@@ -100,7 +97,7 @@ impl Cipher {
key: None,
atype,
name,
name: String::new(),
notes: None,
fields: None,
@@ -175,9 +172,8 @@ impl Cipher {
) -> Result<Value, crate::Error> {
use crate::util::{format_date, validate_and_format_date};
// Parsed once here, since `data` can be large and this runs for every cipher in a sync.
// `LowerCase` only lowercases the first letter of each key, so it keeps `format_version`.
let type_data = serde_json::from_str::<LowerCase<Value>>(&self.data).map(|d| d.data);
// Parsed once, since `data` can be large and this runs for every cipher in a sync
let type_data = serde_json::from_str::<Value>(&self.data);
let is_blob_encrypted = type_data.as_ref().is_ok_and(is_blob_value);
let mut attachments_json: Value = Value::Null;
@@ -215,62 +211,70 @@ impl Cipher {
(false, false, false)
};
// Like upstream, a cipher that was stored without fields or password history reports them as
// null rather than as an empty list; clients keep the two apart.
let fields_json: Option<Vec<_>> = self.fields.as_ref().map(|s| {
serde_json::from_str::<Vec<LowerCase<Value>>>(s)
.inspect_err(|e| warn!("Error parsing fields {e:?} for {}", self.uuid))
.ok()
.map(|d| {
d.into_iter()
.map(|mut f| {
// Check if the `type` key is a number, strings break some clients
// The fallback type is the hidden type `1`. this should prevent accidental data disclosure
// If not try to convert the string value to a number and fallback to `1`
// If it is both not a number and not a string, fallback to `1`
match f.data.get("type") {
Some(t) if t.is_number() => {}
Some(t) if t.is_string() => {
let type_num = &t.as_str().unwrap_or("1").parse::<u8>().unwrap_or(1);
f.data["type"] = json!(type_num);
}
_ => {
f.data["type"] = json!(1);
}
let fields_json: Vec<_> = self
.fields
.as_ref()
.and_then(|s| {
serde_json::from_str::<Vec<LowerCase<Value>>>(s)
.inspect_err(|e| warn!("Error parsing fields {e:?} for {}", self.uuid))
.ok()
})
.map(|d| {
d.into_iter()
.map(|mut f| {
// Check if the `type` key is a number, strings break some clients
// The fallback type is the hidden type `1`. this should prevent accidental data disclosure
// If not try to convert the string value to a number and fallback to `1`
// If it is both not a number and not a string, fallback to `1`
match f.data.get("type") {
Some(t) if t.is_number() => {}
Some(t) if t.is_string() => {
let type_num = &t.as_str().unwrap_or("1").parse::<u8>().unwrap_or(1);
f.data["type"] = json!(type_num);
}
f.data
})
.collect()
})
.unwrap_or_default()
});
_ => {
f.data["type"] = json!(1);
}
}
f.data
})
.collect()
})
.unwrap_or_default();
let password_history_json: Option<Vec<_>> = self.password_history.as_ref().map(|s| {
serde_json::from_str::<Vec<LowerCase<Value>>>(s)
.inspect_err(|e| warn!("Error parsing password history {e:?} for {}", self.uuid))
.ok()
.map(|d| {
// Check every password history item if they are valid and return it.
// If a password field has the type `null` skip it, it breaks newer Bitwarden clients
// A second check is done to verify the lastUsedDate exists and is a valid DateTime string, if not the epoch start time will be used
d.into_iter()
.filter_map(|d| match d.data.get("password") {
Some(p) if p.is_string() => Some(d.data),
_ => None,
})
.map(|mut d| {
let lud = if let Some(l) = d.get("lastUsedDate").and_then(|l| l.as_str()) {
validate_and_format_date(l)
} else {
"1970-01-01T00:00:00.000000Z".to_owned()
};
d["lastUsedDate"] = json!(lud);
d
})
.collect()
})
.unwrap_or_default()
});
let password_history_json: Vec<_> = self
.password_history
.as_ref()
.and_then(|s| {
serde_json::from_str::<Vec<LowerCase<Value>>>(s)
.inspect_err(|e| warn!("Error parsing password history {e:?} for {}", self.uuid))
.ok()
})
.map(|d| {
// Check every password history item if they are valid and return it.
// If a password field has the type `null` skip it, it breaks newer Bitwarden clients
// A second check is done to verify the lastUsedDate exists and is a valid DateTime string, if not the epoch start time will be used
d.into_iter()
.filter_map(|d| match d.data.get("password") {
Some(p) if p.is_string() => Some(d.data),
_ => None,
})
.map(|mut d| {
let lud = if let Some(l) = d.get("lastUsedDate").and_then(|l| l.as_str()) {
validate_and_format_date(l)
} else {
"1970-01-01T00:00:00.000000Z".to_owned()
};
d["lastUsedDate"] = json!(lud);
d
})
.collect()
})
.unwrap_or_default();
// Like upstream, fields and password history stored as NULL are sent as null, not `[]`
let fields_json = self.fields.is_some().then_some(fields_json);
let password_history_json = self.password_history.is_some().then_some(password_history_json);
let collection_ids = if let Some(cipher_sync_data) = cipher_sync_data {
if let Some(cipher_collections) = cipher_sync_data.cipher_collections.get(&self.uuid) {
@@ -368,8 +372,7 @@ impl Cipher {
};
if is_blob_encrypted {
// The blob holds all of the content, so it is sent back as-is and the structured fields
// stay null, as upstream does. Only the name needs clearing, the others are never stored.
// Like upstream, sent as-is with the structured fields null
json_object["data"] = json!(self.data);
json_object["name"] = Value::Null;
} else {
@@ -378,16 +381,18 @@ impl Cipher {
Ok(json_object)
}
/// The per-type data (`login`, `card`, …) of a legacy cipher, from `type_data` as parsed from
/// `self.data`, with fixups for values that are known to break clients.
/// The per-type data of a legacy cipher, with fixups for values known to break clients.
fn legacy_type_data_json(&self, type_data: Result<Value, serde_json::Error>) -> Value {
use crate::util::validate_and_format_date;
// Get the type_data or a default to an empty json object '{}'.
// If not passing an empty object, mobile clients will crash.
let mut type_data_json = type_data
.inspect_err(|_| warn!("Error parsing data field for {}", self.uuid))
.unwrap_or_else(|_| Value::Object(serde_json::Map::new()));
let mut type_data_json = if let Ok(data @ Value::Object(_)) = type_data {
convert_json_key_lcase_first(data)
} else {
warn!("Error parsing data field for {}", self.uuid);
Value::Object(serde_json::Map::new())
};
// NOTE: This was marked as *Backwards Compatibility Code*, but as of January 2021 this is still being used by upstream
// Set the first element of the Uris array as Uri, this is needed several (mobile) clients.
+13 -7
View File
@@ -1,3 +1,4 @@
use chrono::{NaiveDateTime, Utc};
use derive_more::{AsRef, From};
use diesel::prelude::*;
use serde::Deserialize;
@@ -11,6 +12,7 @@ use crate::{
schema::{org_policies, users_organizations},
},
error::MapResult,
util::format_date,
};
use super::{Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId, TwoFactor, UserId};
@@ -24,6 +26,7 @@ pub struct OrgPolicy {
pub atype: i32,
pub enabled: bool,
pub data: String,
pub revision_date: NaiveDateTime,
}
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Enums/PolicyType.cs
@@ -46,7 +49,7 @@ pub enum OrgPolicyType {
RemoveUnlockWithPin = 14,
RestrictedItemTypes = 15,
UriMatchDefaults = 16,
// AutotypeDefaultSetting = 17, // Not supported yet
AutotypeDefaultSetting = 17,
// AutoConfirm = 18, // Not supported (not implemented yet)
// BlockClaimedDomainAccountCreation = 19, // Not supported (Not AGPLv3 Licensed)
OrganizationUserNotification = 20,
@@ -77,6 +80,7 @@ impl OrgPolicy {
atype: atype as i32,
enabled,
data,
revision_date: Utc::now().naive_utc(),
}
}
@@ -92,7 +96,7 @@ impl OrgPolicy {
"type": self.atype,
"data": data_json,
"enabled": self.enabled,
"revisionDate": null,
"revisionDate": format_date(&self.revision_date),
"object": "policy",
});
@@ -110,11 +114,13 @@ impl OrgPolicy {
/// Database methods
impl OrgPolicy {
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
pub async fn save(&mut self, conn: &DbConn) -> EmptyResult {
self.revision_date = Utc::now().naive_utc();
db_run! { conn:
sqlite, mysql {
match diesel::replace_into(org_policies::table)
.values(self)
.values(&*self)
.execute(conn)
{
Ok(_) => Ok(()),
@@ -122,7 +128,7 @@ impl OrgPolicy {
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(org_policies::table)
.filter(org_policies::uuid.eq(&self.uuid))
.set(self)
.set(&*self)
.execute(conn)
.map_res("Error saving org_policy")
}
@@ -142,10 +148,10 @@ impl OrgPolicy {
.map_res("Error deleting org_policy for insert")?;
diesel::insert_into(org_policies::table)
.values(self)
.values(&*self)
.on_conflict(org_policies::uuid)
.do_update()
.set(self)
.set(&*self)
.execute(conn)
.map_res("Error saving org_policy")
}
+1 -3
View File
@@ -54,9 +54,7 @@ pub struct Membership {
pub atype: i32,
pub reset_password_key: Option<String>,
pub external_id: Option<String>,
/// A copy of the user's v2 upgrade token, kept when a v1 -> v2 upgrade leaves the account
/// recovery key (`reset_password_key`) wrapped by the v1 user key. It lets the organization
/// unwrap the v2 user key from it. Opaque to us, like `User::v2_upgrade_token`.
/// The user's v2 upgrade token, for the org to unwrap the v2 user key after an upgrade. Opaque to us.
pub v2_upgrade_token: Option<String>,
}
+2 -2
View File
@@ -137,11 +137,11 @@ impl TwoFactor {
.await
}
pub async fn find_by_user_and_type(user_uuid: &UserId, atype: i32, conn: &DbConn) -> Option<Self> {
pub async fn find_by_user_and_type(user_uuid: &UserId, atype: TwoFactorType, conn: &DbConn) -> Option<Self> {
conn.run(move |conn| {
twofactor::table
.filter(twofactor::user_uuid.eq(user_uuid))
.filter(twofactor::atype.eq(atype))
.filter(twofactor::atype.eq(atype as i32))
.first::<Self>(conn)
.ok()
})
+37 -52
View File
@@ -74,13 +74,11 @@ pub struct User {
pub key_id: Option<KeyId>,
// "v2" account cryptographic state. Either all of these are set (together with a row in
// `user_signature_key_pairs`) or none of them are; see `User::is_v2`.
// The v2 state: all set, with a `user_signature_key_pairs` row, or none, see `User::is_v2`
pub signed_public_key: Option<String>,
pub security_state: Option<String>,
pub security_version: Option<i32>,
/// JSON `{"wrappedUserKey1": ..., "wrappedUserKey2": ...}`, letting clients that still hold the
/// v1 user key obtain the v2 one after another client performed the upgrade. Opaque to us.
/// JSON `{"wrappedUserKey1", "wrappedUserKey2"}` for sessions that still hold the v1 user key. Opaque to us.
pub v2_upgrade_token: Option<String>,
}
@@ -205,6 +203,11 @@ impl User {
)
}
/// Upstream's `GetMasterPasswordSalt`: there is no separate salt, so the normalized email
pub fn master_password_salt(&self) -> String {
self.email.trim().to_lowercase()
}
pub fn check_valid_recovery_code(&self, recovery_code: &str) -> bool {
if let Some(ref totp_recover) = self.totp_recover {
crypto::ct_eq(recovery_code, totp_recover.to_lowercase())
@@ -255,9 +258,7 @@ impl User {
pub async fn reset_security_stamp(&mut self, conn: &DbConn) -> EmptyResult {
self.security_stamp = get_uuid();
// A reset is meant to end the other sessions, which a key rotation's grace would undo. The
// route exceptions stay, since they are set right before a reset, for the stamp it replaces.
// This is also where expired exceptions get dropped.
// Keeps the unexpired route exceptions, set for the stamp being replaced, and drops a rotation's grace
let mut exceptions = self.stamp_exceptions();
exceptions.retain(|e| e.routes.is_some() && !e.is_expired());
self.set_stamp_exceptions(&exceptions);
@@ -265,13 +266,7 @@ impl User {
Ok(())
}
/// Resets the security stamp after a key rotation, while letting the access tokens already
/// issued keep working until they expire.
///
/// This is what upstream does: there the stamp is only checked when a token is refreshed, so a
/// rotation invalidates refresh tokens but not the access tokens in use. Clients rely on it to
/// keep a session going across a rotation. Chained rotations keep every earlier stamp that is
/// still within its window.
/// Resets the stamp but lets issued access tokens run out, as upstream only checks it on refresh.
pub async fn reset_security_stamp_after_key_rotation(&mut self, conn: &DbConn) -> EmptyResult {
let previous_stamp = self.security_stamp.clone();
let mut grace: Vec<UserStampException> =
@@ -347,34 +342,35 @@ impl User {
UserSignatureKeyPair::find_by_user(&self.uuid, conn).await
}
/// The `accountKeys` object (upstream's `PrivateKeysResponseModel`), null without a key pair
pub async fn account_keys_json(&self, conn: &DbConn) -> Value {
if self.private_key.is_none() {
return Value::Null;
if self.private_key.is_some() {
let (signed_public_key, signature_key_pair, security_state) = match self.v2_signature_key_pair(conn).await {
Some(key_pair) => (
json!(self.signed_public_key),
key_pair.to_json(),
json!({
"securityState": self.security_state,
"securityVersion": self.security_version,
}),
),
None => (Value::Null, Value::Null, Value::Null),
};
json!({
"publicKeyEncryptionKeyPair": {
"wrappedPrivateKey": self.private_key,
"publicKey": self.public_key,
"signedPublicKey": signed_public_key,
"object": "publicKeyEncryptionKeyPair",
},
"securityState": security_state,
"signatureKeyPair": signature_key_pair,
"object": "privateKeys"
})
} else {
Value::Null
}
let (signed_public_key, signature_key_pair, security_state) = match self.v2_signature_key_pair(conn).await {
Some(key_pair) => (
json!(self.signed_public_key),
key_pair.to_json(),
json!({
"securityState": self.security_state,
"securityVersion": self.security_version,
}),
),
None => (Value::Null, Value::Null, Value::Null),
};
json!({
"publicKeyEncryptionKeyPair": {
"wrappedPrivateKey": self.private_key,
"publicKey": self.public_key,
"signedPublicKey": signed_public_key,
"object": "publicKeyEncryptionKeyPair",
},
"signatureKeyPair": signature_key_pair,
"securityState": security_state,
"object": "privateKeys"
})
}
pub async fn public_keys_json(&self, conn: &DbConn) -> Value {
@@ -680,18 +676,7 @@ pub struct UserId(String);
)]
#[deref(forward)]
#[from(forward)]
pub struct KeyId(#[serde(deserialize_with = "deserialize_key_id")] String);
/// Rejects a key id from a request that isn't 16 bytes as lowercase hex, as upstream's `[KeyId]` does.
///
/// Ref: <https://github.com/bitwarden/server/blob/main/src/Core/KeyManagement/Models/Data/KeyId.cs>
fn deserialize_key_id<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<String, D::Error> {
let key_id = <String as serde::Deserialize>::deserialize(deserializer)?;
if key_id.len() != 32 || !key_id.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) {
return Err(serde::de::Error::custom("Key id must be a 32 character lowercase hex-encoded string."));
}
Ok(key_id)
}
pub struct KeyId(String);
impl SsoUser {
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
+3 -8
View File
@@ -12,14 +12,9 @@ use crate::{
use super::UserId;
/// A user's signature key pair, part of the "v2" account cryptographic state.
/// A user's signature key pair, part of the v2 state, in its own table like upstream.
///
/// Upstream keeps this in its own table rather than as columns on the user, with a unique index on
/// the user id. The stated intent is to eventually keep superseded key pairs around (an `active`
/// flag was sketched but not shipped), which is why this is modelled as a row with its own identity
/// instead of a set of user attributes.
///
/// Ref: <https://github.com/bitwarden/server/blob/main/src/Sql/dbo/KeyManagement/Tables/UserSignatureKeyPair.sql>
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Sql/dbo/KeyManagement/Tables/UserSignatureKeyPair.sql#L1-L15>
#[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)]
#[diesel(table_name = user_signature_key_pairs)]
#[diesel(treat_none_as_null = true)]
@@ -38,7 +33,7 @@ pub struct UserSignatureKeyPair {
pub updated_at: NaiveDateTime,
}
/// https://github.com/bitwarden/server/blob/main/src/Core/KeyManagement/Enums/SignatureAlgorithm.cs
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Core/KeyManagement/Enums/SignatureAlgorithm.cs#L6-L10>
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SignatureAlgorithm {
Ed25519 = 0,
+1
View File
@@ -116,6 +116,7 @@ table! {
atype -> Integer,
enabled -> Bool,
data -> Text,
revision_date -> Timestamp,
}
}
@@ -9,7 +9,7 @@ Emergency access for {{{grantor_name}}}
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: center;" valign="top" align="center">
<a href="{{{url}}}"
<a data-testid="emergency" href="{{{url}}}"
clicktracking=off target="_blank" style="color: #ffffff; text-decoration: none; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; background-color: #3c8dbc; border-color: #3c8dbc; border-style: solid; border-width: 10px 20px; margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
Become emergency contact
</a>
+6
View File
@@ -839,6 +839,12 @@ pub fn parse_experimental_client_feature_flags(
.collect()
}
/// Whether the admin enabled this supported client feature flag
pub fn is_client_feature_flag_enabled(flag: &str) -> bool {
parse_experimental_client_feature_flags(&CONFIG.experimental_client_feature_flags(), &FeatureFlagFilter::ValidOnly)
.contains_key(flag)
}
/// TODO: This is extracted from IpAddr::is_global, which is unstable:
/// https://doc.rust-lang.org/nightly/std/net/enum.IpAddr.html#method.is_global
/// Remove once https://github.com/rust-lang/rust/issues/27709 is merged