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
Mathijs van Veluw 7c188967e7 Update crates, Rust and other dependencies (#7814)
- Update Rust to v1.99.0
- Updated GitHub Actions
- Updated DataTables

Signed-off-by: BlackDex <black.dex@gmail.com>
2026-10-05 14:39:21 +02:00
Daniel García 895ec2b1e7 Align API behaviour with upstream and return errors on malformed input (#7810)
- Attachments: reject a declared v2 attachment size above the upload limit,
  keep display sizes within the largest unit, and make download links valid
  for 1 minute like upstream.
- Ciphers: moving a personal cipher into an organization needs collection
  write access or full access, like upstream.
- Events: /events/collect only accepts the client event types upstream accepts.
- Client IP: for list headers like X-Forwarded-For, use the rightmost address
  that isn't in IP_HEADER_TRUSTED_PROXIES, reading every occurrence.
- Prelogin: unknown emails get stable KDF settings from upstream's list.
- Login with device: an auth request can be used for one login, like upstream.
- Directory Connector import: never revokes, restores or removes Owners,
  like upstream.
- SSO: check the PKCE verifier again when the code exchange is reused during
  2FA, and accept the same redirect URIs as upstream (desktop localhost
  callback for AppImage and dev builds, CLI on ports 8065-8070).
- Identity: send the merged master password policy in the 2FA response and
  use PascalCase policy keys in identity responses, like upstream.
  Organization API key login no longer needs device fields. Recovery code
  login normalises the code, uses upstream's error message, logs a failed 2FA
  event and sends a "two-step login recovered" email.
- Add GET /api/accounts/keys.
- Return errors instead of panicking on malformed event dates, cipher data,
  key rotation cipher ids, bulk confirm entries and YubiKey OTPs.
2026-10-05 14:38:01 +02:00
Daniel García 1f99d9d276 Remove legacy API endpoints and compatibility code dropped upstream (#7809)
Remove endpoints, response fields, feature flags and compatibility code
that upstream Bitwarden has removed and that no current client uses:

- Legacy POST /identity/accounts/register and POST /api/accounts/prelogin;
  register/finish is rate limited like the other unauthenticated endpoints.
- Legacy organization routes: PUT groups/{id}/users, PUT policies/{type}/vnext,
  PUT users/{id}/reset-password, GET billing/metadata, and the POST aliases of
  the organization, collection, group and member PUT/DELETE routes.
- POST /two-factor/disable (PUT stays).
- ResetMasterPassword, captchaBypassToken, the legacy registration name field,
  userIsManagedByOrganization/managedByOrganization and accessAll.
- The automatic email 2FA code for clients older than 2025.5.0.
- DUO_USE_IFRAME and the Duo Traditional Prompt code.
- Experimental client feature flags that no client reads anymore.
- Web vault CSS selectors for web vaults older than 2025.5.1.

Also accept the PascalCase fields Android 2026.9.0+ sends on registration
and password change, and align a few account, login/2FA and organization
checks with upstream.

Adds tests for the registration body formats.
2026-10-04 22:16:55 +02:00
Daniel García f4f1a8e105 Send API cleanup: remove legacy endpoints and align access with upstream (#7806) 2026-10-03 23:05:56 +02:00
1c89e1772b 2fa email fallback need a verified email (#7770)
Co-authored-by: Timshel <timshel@users.noreply.github.com>
Co-authored-by: Mathijs van Veluw <black.dex@gmail.com>
2026-10-03 14:01:26 +02:00
Tom 3714b504f6 Fix Clippy warnings across all targets (#7782) 2026-10-03 13:50:29 +02:00
Tom f9f011d50c Hide the whole change-email section when EMAIL_CHANGE_ALLOWED is false (#7759) 2026-10-03 13:49:53 +02:00
Julian Speckmann cb89088bc3 Add Windows native credential sync to supported feature flags (#7798) 2026-10-03 13:49:44 +02:00
Pier Carlo Cadoppi 4444442075 Add undetermined-cipher-scenario-logic feature flag (#7802)
Introduced in Browser 2026.2.0 via bitwarden/clients#18395. Rewrites the
post-submit add/update login notification triggering logic, deferring
cipher-type inference until the vault is unlocked. Client-side only.
2026-10-03 13:49:37 +02:00
Mathijs van Veluw 061694d0cb Fix cortex-a53 build issues when using xx-cargo (#7774)
When using xx-cargo it uses a different linker then the default one.
This causes some default fixes Debian/Ubuntu add for the Cortex-A53
architectures to not be included.

This commit fixes that by checking if the target architecture is arm64
and if so, will add the needed fix argument. This should prevent issues
reported here #7754.

We can not say this with 100% certainty since this bug sometimes appears
and sometimes it does not.

Fixes #7754

Signed-off-by: BlackDex <black.dex@gmail.com>
2026-09-25 21:53:06 +02:00
Daniel García 32098ca7d1 Require confirmed membership for user access checks, not for admin views (#7763) 2026-09-23 23:22:43 +02:00
92 changed files with 4114 additions and 2402 deletions

No files matched your search

+8 -19
View File
@@ -180,7 +180,7 @@
## Defaults to every minute. Set blank to disable this job.
# AUTH_REQUEST_PURGE_SCHEDULE="30 * * * * *"
##
## Cron schedule of the job that cleans expired Duo contexts from the database. Does nothing if Duo MFA is disabled or set to use the legacy iframe prompt.
## Cron schedule of the job that cleans expired Duo contexts from the database. Does nothing if Duo MFA is disabled.
## Defaults to every minute. Set blank to disable this job.
# DUO_CONTEXT_PURGE_SCHEDULE="30 * * * * *"
#
@@ -338,6 +338,8 @@
## "local" accepts it from any non global address, which covers a reverse proxy running on the same
## host or container network. Use "all" to accept it from anywhere, or list the addresses of your
## proxy as IPs and CIDR ranges if it connects from a public address.
## For a list header like X-Forwarded-For, the client IP is the rightmost address that isn't one of
## these proxies, so with several proxies in a row (a CDN in front of nginx, for example) list all of them.
# IP_HEADER_TRUSTED_PROXIES=local
## Icon service
@@ -391,24 +393,16 @@
## The following flags are available:
## - "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": Enable SSH agent support on Desktop. (Desktop >= 2024.12.0)
## - "ssh-agent-v2": Enable newer SSH agent support. (Desktop >= 2026.2.1)
## - "ssh-key-vault-item": Enable the creation and use of SSH key vault items. (Clients >= 2024.12.0)
## - "pm-25373-windows-biometrics-v2": Enable the new implementation of biometrics on Windows. (Desktop >= 2025.11.0)
## - "pm-26340-linux-biometrics-v2": Enable the new implementation of biometrics on Linux. (Desktop >= 2025.11.0)
## - "anon-addy-self-host-alias": Enable configuring self-hosted Anon Addy alias generator. (Android >= 2025.3.0, iOS >= 2025.4.0)
## - "simple-login-self-host-alias": Enable configuring self-hosted Simple Login alias generator. (Android >= 2025.3.0, iOS >= 2025.4.0)
## - "mutual-tls": Enable the use of mutual TLS on Android (Clients >= 2025.2.0)
## - "cxp-import-mobile": Enable the import via CXP on iOS (Clients >= 2025.9.2)
## - "cxp-export-mobile": Enable the export via CXP on iOS (Clients >= 2025.9.2)
## - "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)
## - "desktop-ui-migration-milestone-1": Special feature flag for desktop UI (Desktop >= 2026.2.0)
## - "desktop-ui-migration-milestone-2": Special feature flag for desktop UI (Desktop >= 2026.2.0)
## - "desktop-ui-migration-milestone-3": Special feature flag for desktop UI (Desktop >= 2026.2.0)
## - "desktop-ui-migration-milestone-4": Special feature flag for desktop UI (Desktop >= 2026.2.0)
## - "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.
@@ -587,11 +581,6 @@
# DUO_HOST=<API Hostname>
## After that, you should be able to follow the rest of the guide linked above,
## ignoring the fields that ask for the values that you already configured beforehand.
##
## If you want to attempt to use Duo's 'Traditional Prompt' (deprecated, iframe based) set DUO_USE_IFRAME to 'true'.
## Duo no longer supports this, but it still works for some integrations.
## If you aren't sure, leave this alone.
# DUO_USE_IFRAME=false
## Email 2FA settings
## Email token size
+1 -1
View File
@@ -50,6 +50,6 @@ jobs:
severity: CRITICAL,HIGH
- name: Upload Trivy scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
with:
sarif_file: 'trivy-results.sarif'
+1 -1
View File
@@ -23,4 +23,4 @@ jobs:
# When this version is updated, do not forget to update this in `.pre-commit-config.yaml` too
- name: Spell Check Repo
uses: crate-ci/typos@512fc24f32f44ab01972217aaaf3dc86ec234d53 # v1.50.2
uses: crate-ci/typos@00f422f3b19c57bc6338715ebfe3316d38768461 # v1.50.3
+1 -1
View File
@@ -18,7 +18,7 @@ repos:
# When this version is updated, do not forget to update this in `.github/workflows/typos.yaml` too
- repo: https://github.com/crate-ci/typos
rev: 512fc24f32f44ab01972217aaaf3dc86ec234d53 # v1.50.2
rev: 00f422f3b19c57bc6338715ebfe3316d38768461 # v1.50.3
hooks:
- id: typos
always_run: true
+2 -3
View File
@@ -14,9 +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
"COSEKey",
"COSEAlgorithm",
# 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
Generated
+165 -195
View File
@@ -32,15 +32,15 @@ dependencies = [
[[package]]
name = "alloc-no-stdlib"
version = "2.0.4"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a"
[[package]]
name = "alloc-stdlib"
version = "0.2.4"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195"
checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1"
dependencies = [
"alloc-no-stdlib",
]
@@ -151,9 +151,9 @@ dependencies = [
[[package]]
name = "async-compression"
version = "0.4.48"
version = "0.4.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb61aea1a7def73ee7c350a184f0e70b32c182344e2e75bf70c9b621b83417fd"
checksum = "ee19bd99b43e3691acbad4e840420a4881cea6c0b66a208125a824f8fd53f5a1"
dependencies = [
"compression-codecs",
"compression-core",
@@ -323,9 +323,9 @@ dependencies = [
[[package]]
name = "asyncband"
version = "0.7.2"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2d85fd3d291fabcc40c7232c92c280ec1754fd7b5d7ea769f143222143e179a"
checksum = "5161fb43cb8f231f06983cb2b8f9ee9cee14be0b1356d8383398cc0f312800d1"
[[package]]
name = "atomic"
@@ -399,9 +399,9 @@ dependencies = [
[[package]]
name = "aws-runtime"
version = "1.9.4"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25b43ad47adc2517efe3d706559d94b97e50e80e0321b3cadbc9f77cee88adcd"
checksum = "2b8a9911551b4ea6ca13805ef52ed96f7d2bbb43cc3b4a14cb0776a71f33cfaa"
dependencies = [
"aws-credential-types",
"aws-sigv4",
@@ -424,9 +424,9 @@ dependencies = [
[[package]]
name = "aws-sdk-sso"
version = "1.111.0"
version = "1.114.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65700d75f6da78c89a61a49f1539979400a4609d3329376b1dc2133d7a1b2b90"
checksum = "12be2f9c8eef7f5fc919c96d538e629698469a02b4cb75408b26b1bd984ebe79"
dependencies = [
"arc-swap",
"aws-credential-types",
@@ -442,7 +442,6 @@ dependencies = [
"aws-types",
"bytes",
"fastrand",
"http 0.2.12",
"http 1.5.0",
"regex-lite",
"tracing",
@@ -450,9 +449,9 @@ dependencies = [
[[package]]
name = "aws-sdk-ssooidc"
version = "1.113.0"
version = "1.116.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb1147c254af7b6cee3ace90ae7da6f21366ce9a9b899035fa8a0fdf586eb75a"
checksum = "d7645db8724ea3b82fdccfb67e1b0f637c9d8ab0e7ef29d84884d8d8d73f805d"
dependencies = [
"arc-swap",
"aws-credential-types",
@@ -468,7 +467,6 @@ dependencies = [
"aws-types",
"bytes",
"fastrand",
"http 0.2.12",
"http 1.5.0",
"regex-lite",
"tracing",
@@ -476,9 +474,9 @@ dependencies = [
[[package]]
name = "aws-sdk-sts"
version = "1.116.0"
version = "1.119.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "035c7392e751d20bf1060d625d023587eb14099646110a9cff7c697bfe4b253a"
checksum = "03e490aa904849b38e770922faac779dd245f5cf8be81f19065acf18d276e4ad"
dependencies = [
"arc-swap",
"aws-credential-types",
@@ -495,7 +493,6 @@ dependencies = [
"aws-smithy-xml",
"aws-types",
"fastrand",
"http 0.2.12",
"http 1.5.0",
"regex-lite",
"tracing",
@@ -503,9 +500,9 @@ dependencies = [
[[package]]
name = "aws-sigv4"
version = "1.5.3"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31d955e76ff96acd555bf06fa0fa6d5bf9335fa84ae7c64481b20ae61d231f70"
checksum = "2312577f088c9fbf4206dfdb884cf1de9407b43e1a923cbed5237775116fc24b"
dependencies = [
"aws-credential-types",
"aws-smithy-http",
@@ -515,7 +512,6 @@ dependencies = [
"form_urlencoded",
"hex",
"hmac 0.13.0",
"http 0.2.12",
"http 1.5.0",
"percent-encoding",
"sha2 0.11.0",
@@ -536,9 +532,9 @@ dependencies = [
[[package]]
name = "aws-smithy-http"
version = "0.64.0"
version = "0.64.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37843d9add67c3aff5856f409c6dc315d3cdff60f9c0cb5b670dab1e9920306d"
checksum = "639b4d8f8555f24a9be649811c3eb0b4d4616f4d61daf0c32e28873bc1ea9af1"
dependencies = [
"aws-smithy-runtime-api",
"aws-smithy-types",
@@ -590,9 +586,9 @@ dependencies = [
[[package]]
name = "aws-smithy-runtime"
version = "1.14.2"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3296253d3a91b3f938a3f2bcce4daebadcb4aa4228153fcec90f9d23532b4484"
checksum = "d6e302ac1d88b99652489df31abdec6ac42a2ab2ac3982ad0ac49f64dfaf28ba"
dependencies = [
"aws-smithy-async",
"aws-smithy-http",
@@ -602,9 +598,7 @@ dependencies = [
"aws-smithy-types",
"bytes",
"fastrand",
"http 0.2.12",
"http 1.5.0",
"http-body 0.4.6",
"http-body 1.1.0",
"http-body-util",
"pin-project-lite",
@@ -615,15 +609,14 @@ dependencies = [
[[package]]
name = "aws-smithy-runtime-api"
version = "1.16.2"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d881a7b7ad179fd6611680c9de89f716fb00ab40299a9a7b8c6913e8f7511a8"
checksum = "c0730c16f91124c6a2abb4932c77e299288b3dd9f967ea2e9ec48cc6731e87a4"
dependencies = [
"aws-smithy-async",
"aws-smithy-runtime-api-macros",
"aws-smithy-types",
"bytes",
"http 0.2.12",
"http 1.5.0",
"pin-project-lite",
"tokio",
@@ -655,16 +648,14 @@ dependencies = [
[[package]]
name = "aws-smithy-types"
version = "1.6.4"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b791f3ac597193fe1d08b82366986eb1f5bc31f2ac6c194c0855276116c76cd"
checksum = "69bb407740a197147da48238ecc94498493c9e85445732360cec180296ca45f1"
dependencies = [
"base64-simd",
"bytes",
"bytes-utils",
"http 0.2.12",
"http 1.5.0",
"http-body 0.4.6",
"http-body 1.1.0",
"http-body-util",
"itoa",
@@ -756,9 +747,9 @@ dependencies = [
[[package]]
name = "bigdecimal"
version = "0.4.10"
version = "0.4.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695"
checksum = "8fa3f3d8cbf4dffcfe4991de61d012bef509a409ecbe9dd41049bfe32b4d4653"
dependencies = [
"autocfg",
"libm",
@@ -827,9 +818,9 @@ dependencies = [
[[package]]
name = "brotli"
version = "8.0.4"
version = "9.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3"
checksum = "f8b851b75c23ca7873623d612fe49bd1989aeb03d08fb9432187eb253d3d4c6b"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
@@ -838,9 +829,9 @@ dependencies = [
[[package]]
name = "brotli-decompressor"
version = "5.0.3"
version = "6.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583"
checksum = "941cd9bd4ddab83cb46fa5a2d428f1c857b24ac78cb876cf7beb710840934bd7"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
@@ -891,9 +882,9 @@ dependencies = [
[[package]]
name = "cached"
version = "4.0.0"
version = "4.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5a6cf8262820194a1488ece477f5fb5ba9256ef25229d525470e71d6e9a5835"
checksum = "292f39b5c459538e148825347c400cb2005a618b897d0e6be183bad677d460b7"
dependencies = [
"ahash",
"async-lock",
@@ -901,7 +892,7 @@ dependencies = [
"cached_proc_macro_types",
"hashbrown 0.17.1",
"parking_lot",
"thiserror 2.0.20",
"thiserror 2.0.21",
"web-time",
]
@@ -926,9 +917,9 @@ checksum = "f5813789573ae815c8b4be58c4428e0e7ae05f0227678ba9de332ded585b9159"
[[package]]
name = "cc"
version = "1.4.7"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313"
checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -1007,9 +998,9 @@ dependencies = [
[[package]]
name = "compression-codecs"
version = "0.4.43"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef16c47ba2797aa6a909cc37d39911f3a6743811fe7408ac0b0cc0276b656e9"
checksum = "98fc98460ba0ad5317075d3632b8dfc45d0be8c4a49347c2a38272019717614a"
dependencies = [
"brotli",
"compression-core",
@@ -1446,7 +1437,7 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
dependencies = [
"thiserror 2.0.20",
"thiserror 2.0.21",
]
[[package]]
@@ -1811,13 +1802,12 @@ dependencies = [
[[package]]
name = "encoding_rs"
version = "0.8.41"
version = "0.8.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b5ef0006ac9ab233c38522f5ae99cae3625151de8f706cacee1cba4b8e2832a"
checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679"
dependencies = [
"cfg-if",
"core_detect",
"multiversion",
"multiversion_no_op",
"rustversion",
"scopeguard",
@@ -1916,9 +1906,9 @@ dependencies = [
[[package]]
name = "find-msvc-tools"
version = "0.1.13"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
[[package]]
name = "flate2"
@@ -2245,7 +2235,7 @@ dependencies = [
"pest_derive",
"serde",
"serde_json",
"thiserror 2.0.20",
"thiserror 2.0.21",
"walkdir",
]
@@ -2321,8 +2311,8 @@ dependencies = [
"idna",
"ipnet",
"jni",
"rand 0.10.2",
"thiserror 2.0.20",
"rand 0.10.3",
"thiserror 2.0.21",
"tinyvec",
"tokio",
"tracing",
@@ -2341,9 +2331,9 @@ dependencies = [
"jni",
"once_cell",
"prefix-trie",
"rand 0.10.2",
"rand 0.10.3",
"ring",
"thiserror 2.0.20",
"thiserror 2.0.21",
"tinyvec",
"tracing",
"url",
@@ -2366,11 +2356,11 @@ dependencies = [
"ndk-context",
"once_cell",
"parking_lot",
"rand 0.10.2",
"rand 0.10.3",
"resolv-conf",
"smallvec",
"system-configuration",
"thiserror 2.0.20",
"thiserror 2.0.21",
"tokio",
"tracing",
]
@@ -2544,31 +2534,32 @@ dependencies = [
[[package]]
name = "hyper-rustls"
version = "0.27.9"
version = "0.27.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
dependencies = [
"http 1.5.0",
"hyper 1.11.1",
"hyper-util",
"rustls 0.23.45",
"tokio",
"tokio-rustls 0.26.5",
"tokio-rustls 0.26.6",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
dependencies = [
"base64 0.22.1",
"base64 0.23.1",
"bytes",
"futures-channel",
"futures-util",
"http 1.5.0",
"http-body 1.1.0",
"httparse",
"hyper 1.11.1",
"ipnet",
"libc",
@@ -2867,7 +2858,7 @@ dependencies = [
"jni-sys",
"log",
"simd_cesu8",
"thiserror 2.0.20",
"thiserror 2.0.21",
"walkdir",
"windows-link",
]
@@ -2927,9 +2918,9 @@ dependencies = [
[[package]]
name = "js-sys"
version = "0.3.105"
version = "0.3.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"
checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
dependencies = [
"cfg-if",
"futures-util",
@@ -2980,9 +2971,9 @@ dependencies = [
[[package]]
name = "lazy_static"
version = "1.5.0"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
dependencies = [
"spin 0.9.9",
]
@@ -3013,16 +3004,16 @@ dependencies = [
"serde",
"socket2 0.6.5",
"tokio",
"tokio-rustls 0.26.5",
"tokio-rustls 0.26.6",
"tracing",
"url",
]
[[package]]
name = "libc"
version = "0.2.189"
version = "0.2.190"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
[[package]]
name = "libm"
@@ -3128,16 +3119,6 @@ dependencies = [
"digest 0.11.3",
]
[[package]]
name = "mea"
version = "0.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c709842c4ce65cb91e2666ad5319dfc1efc3af0d34f02075eddca9000d9f8afb"
dependencies = [
"hashbrown 0.17.1",
"slab",
]
[[package]]
name = "memchr"
version = "2.8.3"
@@ -3198,9 +3179,9 @@ dependencies = [
[[package]]
name = "mio"
version = "1.2.3"
version = "1.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
dependencies = [
"libc",
"wasi",
@@ -3246,27 +3227,6 @@ dependencies = [
"version_check",
]
[[package]]
name = "multiversion"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ca4bea16ffc3f443cf7d866912118196bfef4c6a1556ca00f9f9b00bb43f7c"
dependencies = [
"multiversion-macros",
]
[[package]]
name = "multiversion-macros"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d416831a7317ef4b08bee00b69cbbb9c8763da7959a7026244d6266869f9c83"
dependencies = [
"proc-macro2",
"quote",
"rustversion",
"syn 3.0.6",
]
[[package]]
name = "multiversion_no_op"
version = "1.0.0"
@@ -3400,9 +3360,9 @@ dependencies = [
[[package]]
name = "num-modular"
version = "0.6.5"
version = "0.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd8e500409e6cd603b03e477c26a6caecdc27ac58979a53e881c75eafc079f44"
checksum = "26ac76200f74e658124f95fa63e1a82b2fd2181c5b2fdde80b3d89d2d3f905e7"
[[package]]
name = "num-order"
@@ -3482,9 +3442,9 @@ dependencies = [
[[package]]
name = "opendal"
version = "0.59.2"
version = "0.59.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fe43e16d96bed57937eb7c4a28559bf4c5fc4a3951656505d1fdd4f856349b4"
checksum = "3cbd193aec6d62efaff87611dfae52c132aed650bb3cb0c684c0a9394f09a42a"
dependencies = [
"opendal-core",
"opendal-service-fs",
@@ -3493,9 +3453,9 @@ dependencies = [
[[package]]
name = "opendal-core"
version = "0.59.2"
version = "0.59.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de4566a412776e3f65d53dd9fceced5b432a0a9c9a1e8d50ccf58823795e5c5b"
checksum = "54b5555d753dd6a6383dc0891254b72f3cdc40c011ba0372b560e0ae5aa29f5c"
dependencies = [
"anyhow",
"asyncband",
@@ -3507,7 +3467,7 @@ dependencies = [
"log",
"md-5",
"percent-encoding",
"quick-xml",
"quick-xml 0.41.0",
"reqsign-core",
"serde",
"serde_json",
@@ -3519,9 +3479,9 @@ dependencies = [
[[package]]
name = "opendal-http-transport-reqwest"
version = "0.59.2"
version = "0.59.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f772ce6c137ab43647726116d505a649a88d41928fcd813438eb30b02312bcbb"
checksum = "4d6588678f3040a3eab1819364dcbc6a746a8286d1ceb23a70a6614faa01dc6c"
dependencies = [
"bytes",
"futures",
@@ -3533,9 +3493,9 @@ dependencies = [
[[package]]
name = "opendal-service-fs"
version = "0.59.2"
version = "0.59.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0e7d8974aebd33be899200c9d0d50b2141fd5fa5c462b29e20653e3ddbfe339"
checksum = "ec5c16fe91b77c0364f66546a1c32b2999e9c3c3c75c2e908a282268e0890bac"
dependencies = [
"bytes",
"log",
@@ -3547,9 +3507,9 @@ dependencies = [
[[package]]
name = "opendal-service-s3"
version = "0.59.2"
version = "0.59.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2942f2d8d3d4953c0e8d07cd1244879628d084948e48ce6b6d808c5938f7858d"
checksum = "1d83e716c25a81fe2ca701894b8e14b9966acca3812af250e50132accb19da02"
dependencies = [
"base64 0.23.1",
"bytes",
@@ -3558,7 +3518,7 @@ dependencies = [
"log",
"md-5",
"opendal-core",
"quick-xml",
"quick-xml 0.41.0",
"reqsign-aws-v4",
"reqsign-core",
"reqsign-file-read-tokio",
@@ -3800,9 +3760,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pest"
version = "2.9.1"
version = "2.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d45aeb61b4bf818e12d4205f2466f8c4748f85f4fce0146d1c03d69d753f0ad"
checksum = "45d3aca230fad2e6f6317ca0a72724338c4960cb97168a85cdee66df4a9a21a8"
dependencies = [
"memchr",
"ucd-trie",
@@ -3810,9 +3770,9 @@ dependencies = [
[[package]]
name = "pest_derive"
version = "2.9.1"
version = "2.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89cc5a242e25ed4e7704d0be240f2cfbe20a8c27e7e252d94835be93d92dc39f"
checksum = "284b60557f2c4a2e72ad3f2d34d42685a2fa4a6a61d0d2a10c0ae2a5e916c2cf"
dependencies = [
"pest",
"pest_generator",
@@ -3820,9 +3780,9 @@ dependencies = [
[[package]]
name = "pest_generator"
version = "2.9.1"
version = "2.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7abf21475cc3820fe4b2ca2dc2142902f67a02189f3b5b3a229f4febc01a43e5"
checksum = "1d9d1f08a115309ee99268cf85e5228e0e56aa9caf8841ec12866b6be07c3109"
dependencies = [
"pest",
"pest_meta",
@@ -3833,9 +3793,9 @@ dependencies = [
[[package]]
name = "pest_meta"
version = "2.9.1"
version = "2.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adba4db388f687393c18c51348d44a41d870ca9df71a2c98172ea3035dc6936e"
checksum = "ed93ba1a9ffcca32130a5188701c81c0c49cf00d4b7c5007d5148951d743adcb"
dependencies = [
"pest",
]
@@ -3925,9 +3885,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pin-utils"
version = "0.1.0"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
checksum = "13bee6c73da26345c729282832b60b0363cf3dd9f4bfd81d8551b7a1c889a113"
[[package]]
name = "piper"
@@ -4007,9 +3967,9 @@ dependencies = [
[[package]]
name = "powerfmt"
version = "0.2.0"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
[[package]]
name = "ppv-lite86"
@@ -4022,9 +3982,9 @@ dependencies = [
[[package]]
name = "pq-sys"
version = "0.7.5"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "574ddd6a267294433f140b02a726b0640c43cf7c6f717084684aaa3b285aba61"
checksum = "9152654059e4fb5b66a4052acf3ee43a7a7132332e44cdb72a81ee93bd038950"
dependencies = [
"libc",
"pkg-config",
@@ -4129,6 +4089,16 @@ dependencies = [
"serde",
]
[[package]]
name = "quick-xml"
version = "0.42.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b"
dependencies = [
"memchr",
"serde",
]
[[package]]
name = "quote"
version = "1.0.47"
@@ -4190,9 +4160,9 @@ dependencies = [
[[package]]
name = "rand"
version = "0.10.2"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
dependencies = [
"chacha20",
"getrandom 0.4.3",
@@ -4329,9 +4299,9 @@ dependencies = [
[[package]]
name = "reqsign-aws-core"
version = "3.1.1"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bac4749b7dfa7bfaccd01eb03e9dc795ed37e3f20d6f0f38e2c67ee85ad6bc86"
checksum = "d38c902d8ec745bd9eadd1abbcbe227f4c1fc2926b2ec8929f89234af3114500"
dependencies = [
"bytes",
"form_urlencoded",
@@ -4339,7 +4309,7 @@ dependencies = [
"http 1.5.0",
"log",
"percent-encoding",
"quick-xml",
"quick-xml 0.42.0",
"reqsign-core",
"rust-ini",
"serde",
@@ -4350,14 +4320,14 @@ dependencies = [
[[package]]
name = "reqsign-aws-v4"
version = "3.3.0"
version = "3.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff250f0fd0b913fbd565e405acc553da0f13bde30bfb5403178c9d0313cdc15f"
checksum = "906bfd028f96bfe73fdb892ce26f3b3f99b4f30feb9bd024be58bd6657f68b2b"
dependencies = [
"bytes",
"http 1.5.0",
"log",
"quick-xml",
"quick-xml 0.42.0",
"reqsign-aws-core",
"reqsign-core",
"serde",
@@ -4365,20 +4335,19 @@ dependencies = [
[[package]]
name = "reqsign-core"
version = "3.3.1"
version = "3.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff052daffb0599681c50f85c59e7236438976efe991ab864edd9f3b235501a0f"
checksum = "85fed85c436b6b920e4b36310f699a77771a91e38ae483df48447bd7d162671c"
dependencies = [
"anyhow",
"asyncband",
"base64 0.23.1",
"bytes",
"futures",
"hex",
"hmac 0.13.0",
"http 1.5.0",
"jiff",
"log",
"mea",
"percent-encoding",
"sha1 0.11.0",
"sha2 0.11.0",
@@ -4387,9 +4356,9 @@ dependencies = [
[[package]]
name = "reqsign-file-read-tokio"
version = "3.0.6"
version = "3.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3235df90a6bca681aa47dd86f2393d122a6d77042aa8a7c81e218cd45c5bfc0"
checksum = "586a8e198057773f9616901fd723a4bc186812b9765bc0f20368661d6169d906"
dependencies = [
"anyhow",
"reqsign-core",
@@ -4429,7 +4398,7 @@ dependencies = [
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls 0.26.5",
"tokio-rustls 0.26.6",
"tokio-util",
"tower",
"tower-http",
@@ -4622,7 +4591,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
dependencies = [
"hashbrown 0.16.1",
"thiserror 2.0.20",
"thiserror 2.0.21",
]
[[package]]
@@ -4735,9 +4704,9 @@ dependencies = [
[[package]]
name = "rustls-platform-verifier"
version = "0.7.0"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
@@ -4756,9 +4725,9 @@ dependencies = [
[[package]]
name = "rustls-platform-verifier-android"
version = "0.1.1"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
[[package]]
name = "rustls-webpki"
@@ -5024,9 +4993,9 @@ dependencies = [
[[package]]
name = "serde_with"
version = "3.23.0"
version = "3.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "935177bb8c0cd8ca1a4e6d1a2ac8988bea69cab4f9d3a31311e012ad27868ea4"
checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f"
dependencies = [
"base64 0.23.1",
"bs58",
@@ -5045,9 +5014,9 @@ dependencies = [
[[package]]
name = "serde_with_macros"
version = "3.23.0"
version = "3.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d607aa01a3cb0ad757d6fd216136910db3c97b102fe686585689615a02dbcdc"
checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49"
dependencies = [
"darling 0.24.1",
"proc-macro2",
@@ -5174,15 +5143,15 @@ checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
dependencies = [
"num-bigint",
"num-traits",
"thiserror 2.0.20",
"thiserror 2.0.21",
"time",
]
[[package]]
name = "siphasher"
version = "1.0.3"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
[[package]]
name = "slab"
@@ -5192,9 +5161,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.16.1"
version = "1.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891"
checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
[[package]]
name = "socket2"
@@ -5424,11 +5393,11 @@ dependencies = [
[[package]]
name = "thiserror"
version = "2.0.20"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
"thiserror-impl 2.0.20",
"thiserror-impl 2.0.21",
]
[[package]]
@@ -5444,9 +5413,9 @@ dependencies = [
[[package]]
name = "thiserror-impl"
version = "2.0.20"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
@@ -5521,9 +5490,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]]
name = "tokio"
version = "1.53.1"
version = "1.53.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
dependencies = [
"bytes",
"libc",
@@ -5559,9 +5528,9 @@ dependencies = [
[[package]]
name = "tokio-rustls"
version = "0.26.5"
version = "0.26.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67"
checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
dependencies = [
"rustls 0.23.45",
"tokio",
@@ -5935,9 +5904,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "uuid"
version = "1.26.1"
version = "1.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a"
dependencies = [
"getrandom 0.4.3",
"js-sys",
@@ -6008,7 +5977,7 @@ dependencies = [
"pastey 0.2.3",
"percent-encoding",
"pico-args",
"rand 0.10.2",
"rand 0.10.3",
"regex",
"reqsign-aws-v4",
"reqsign-core",
@@ -6093,9 +6062,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
version = "0.2.128"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
dependencies = [
"cfg-if",
"once_cell",
@@ -6106,19 +6075,20 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.78"
version = "0.4.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928"
checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e"
dependencies = [
"js-sys",
"tokio",
"wasm-bindgen",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.128"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"
checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -6126,9 +6096,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.128"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"
checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -6139,9 +6109,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.128"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"
checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
dependencies = [
"unicode-ident",
]
@@ -6161,9 +6131,9 @@ dependencies = [
[[package]]
name = "web-sys"
version = "0.3.105"
version = "0.3.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8"
checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -6611,9 +6581,9 @@ dependencies = [
[[package]]
name = "yoke-derive"
version = "0.8.3"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78"
checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
dependencies = [
"proc-macro2",
"quote",
@@ -6636,18 +6606,18 @@ dependencies = [
[[package]]
name = "zerocopy"
version = "0.8.57"
version = "0.8.59"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873"
checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.57"
version = "0.8.59"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc"
checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82"
dependencies = [
"proc-macro2",
"quote",
+12 -12
View File
@@ -1,6 +1,6 @@
[workspace.package]
edition = "2024"
rust-version = "1.96.1"
rust-version = "1.97.1"
license = "AGPL-3.0-only"
repository = "https://github.com/dani-garcia/vaultwarden"
publish = false
@@ -77,7 +77,7 @@ dotenvy = { version = "0.15.7", default-features = false }
# Numerical libraries
num-traits = "0.2.19"
num-derive = "0.5.1"
bigdecimal = "0.4.10"
bigdecimal = "0.4.11"
# Web framework
rocket = { version = "0.5.1", default-features = false, features = ["json", "tls"] }
@@ -91,7 +91,7 @@ dashmap = "6.2.1"
# Async futures
futures = "0.3.34"
tokio = { version = "1.53.1", features = [
tokio = { version = "1.53.2", features = [
"fs",
"io-util",
"net",
@@ -107,7 +107,7 @@ serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
# A safe, extensible ORM and Query builder
diesel = { version = "2.3.13", features = ["chrono", "r2d2", "numeric"] }
diesel = { version = "2.3.13", features = ["chrono", "r2d2", "numeric", "64-column-tables"] }
diesel_migrations = "2.3.2"
derive_more = { version = "2.1.1", features = [
@@ -123,13 +123,13 @@ diesel-derive-newtype = "2.1.3"
libsqlite3-sys = { version = "0.38.2", optional = true }
# Crypto-related libraries
rand = "0.10.2"
rand = "0.10.3"
ring = "0.17.14"
rustls = { version = "0.23.45", features = ["ring", "std"], default-features = false }
subtle = "2.6.1"
# UUID generation
uuid = { version = "1.26.1", features = ["v4"] }
uuid = { version = "1.27.0", features = ["v4"] }
# Date and time libraries
chrono = { version = "0.4.45", default-features = false, features = ["clock", "serde"] }
@@ -215,7 +215,7 @@ bytes = "1.12.1"
svg-hush = "0.9.7"
# Cache function results (Used for version check and favicon fetching)
cached = { version = "4.0.0", features = ["async"] }
cached = { version = "4.0.1", features = ["async"] }
# Used for custom short lived cookie jar during favicon extraction
cookie = "0.18.2"
@@ -257,8 +257,8 @@ rpassword = "7.5.4"
grass_compiler = { version = "0.13.4", default-features = false }
# File are accessed through Apache OpenDAL
opendal = { version = "0.59.2", default-features = false, features = ["services-fs"] }
opendal-http-transport-reqwest = { version = "0.59.2", default-features = false, features = ["rustls-no-provider"], optional = true }
opendal = { version = "0.59.4", default-features = false, features = ["services-fs"] }
opendal-http-transport-reqwest = { version = "0.59.4", default-features = false, features = ["rustls-no-provider"], optional = true }
# For retrieving AWS credentials, including temporary SSO credentials
aws-config = { version = "1.12.0", optional = true, default-features = false, features = [
@@ -268,10 +268,10 @@ aws-config = { version = "1.12.0", optional = true, default-features = false, fe
"sso",
] }
aws-credential-types = { version = "1.3.0", optional = true }
aws-smithy-runtime-api = { version = "1.16.2", optional = true }
aws-smithy-runtime-api = { version = "1.19.0", optional = true }
http = { version = "1.5.0", optional = true }
reqsign-aws-v4 = { version = "3.3.0", optional = true }
reqsign-core = { version = "3.3.1", optional = true }
reqsign-aws-v4 = { version = "3.3.1", optional = true }
reqsign-core = { version = "3.3.2", optional = true }
# Strip debuginfo from the release builds
# The debug symbols are to provide better panic traces
+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
+10 -10
View File
@@ -19,23 +19,23 @@
# - 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
## And for Alpine we define all build images here, they will only be loaded when actually used
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.98.1 AS build_amd64
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.98.1 AS build_arm64
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.98.1 AS build_armv7
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.98.1 AS build_armv6
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.99.0 AS build_amd64
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.99.0 AS build_arm64
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.99.0 AS build_armv7
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.99.0 AS build_armv6
########################## BUILD IMAGE ##########################
# hadolint ignore=DL3006
+27 -11
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
@@ -36,7 +36,7 @@ FROM --platform=linux/amd64 docker.io/tonistiigi/xx@sha256:c64defb9ed5a91eacb37f
########################## BUILD IMAGE ##########################
# hadolint ignore=DL3006
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.98.1-slim-trixie AS build
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.99.0-slim-trixie AS build
# hadolint ignore=DL3067
COPY --from=xx / /
ARG TARGETARCH
@@ -101,9 +101,17 @@ RUN . /env-cargo && \
# https://github.com/tonistiigi/xx/pull/108#issuecomment-3700635977
# https://github.com/dani-garcia/vaultwarden/discussions/7522
if xx-info is-cross; then \
XX_RUSTFLAGS="-C link-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \
export XX_RUSTFLAGS; \
XX_RUSTFLAGS="-Clink-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \
fi && \
# Debian's GCC adds a special fix for cortex-a53 arch's which gets losts when using xx-cargo, which uses clang
# Checking if the arch is arm64 and adding this extra link-arg should cause this fix to be applied
# This should fix issues reported when running on a RPi3 which is a Cortex-A35 chip
# See: https://salsa.debian.org/toolchain-team/gcc/-/blob/master/debian/rules2?pow_referer=#L543-548
# See: https://github.com/dani-garcia/vaultwarden/issues/7754
if [ "$(xx-info arch)" = "arm64" ]; then \
XX_RUSTFLAGS="${XX_RUSTFLAGS} -Clink-arg=-Wl,--fix-cortex-a53-843419"; \
fi && \
export XX_RUSTFLAGS && \
PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}" && \
find . -not -path "./target*" -delete
@@ -123,9 +131,17 @@ RUN . /env-cargo && \
# https://github.com/tonistiigi/xx/pull/108#issuecomment-3700635977
# https://github.com/dani-garcia/vaultwarden/discussions/7522
if xx-info is-cross; then \
XX_RUSTFLAGS="-C link-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \
export XX_RUSTFLAGS; \
XX_RUSTFLAGS="-Clink-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \
fi && \
# Debian's GCC adds a special fix for cortex-a53 arch's which gets losts when using xx-cargo, which uses clang
# Checking if the arch is arm64 and adding this extra link-arg should cause this fix to be applied
# This should fix issues reported when running on a RPi3 which is a Cortex-A35 chip
# See: https://salsa.debian.org/toolchain-team/gcc/-/blob/master/debian/rules2?pow_referer=#L543-548
# See: https://github.com/dani-garcia/vaultwarden/issues/7754
if [ "$(xx-info arch)" = "arm64" ]; then \
XX_RUSTFLAGS="${XX_RUSTFLAGS} -Clink-arg=-Wl,--fix-cortex-a53-843419"; \
fi && \
export XX_RUSTFLAGS && \
PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}" && \
if [ "${CARGO_PROFILE}" = "dev" ] ; then \
ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \
+10 -2
View File
@@ -32,9 +32,17 @@
# https://github.com/tonistiigi/xx/pull/108#issuecomment-3700635977
# https://github.com/dani-garcia/vaultwarden/discussions/7522
if xx-info is-cross; then \
XX_RUSTFLAGS="-C link-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \
export XX_RUSTFLAGS; \
XX_RUSTFLAGS="-Clink-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \
fi && \
# Debian's GCC adds a special fix for cortex-a53 arch's which gets losts when using xx-cargo, which uses clang
# Checking if the arch is arm64 and adding this extra link-arg should cause this fix to be applied
# This should fix issues reported when running on a RPi3 which is a Cortex-A35 chip
# See: https://salsa.debian.org/toolchain-team/gcc/-/blob/master/debian/rules2?pow_referer=#L543-548
# See: https://github.com/dani-garcia/vaultwarden/issues/7754
if [ "$(xx-info arch)" = "arm64" ]; then \
XX_RUSTFLAGS="${XX_RUSTFLAGS} -Clink-arg=-Wl,--fix-cortex-a53-843419"; \
fi && \
export XX_RUSTFLAGS && \
PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}"
{%- endmacro %}
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@{{ vault_image_digest }} AS vault
@@ -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,5 @@
DROP TABLE IF EXISTS user_signature_key_pairs;
ALTER TABLE users DROP COLUMN signed_public_key;
ALTER TABLE users DROP COLUMN security_state;
ALTER TABLE users DROP COLUMN security_version;
@@ -0,0 +1,14 @@
ALTER TABLE users ADD COLUMN signed_public_key TEXT;
ALTER TABLE users ADD COLUMN security_state TEXT;
ALTER TABLE users ADD COLUMN security_version INTEGER;
CREATE TABLE user_signature_key_pairs (
uuid CHAR(36) NOT NULL PRIMARY KEY,
user_uuid CHAR(36) NOT NULL UNIQUE,
signature_algorithm INTEGER NOT NULL, -- 0 = ed25519, 1 = mldsa44
signing_key TEXT NOT NULL,
verifying_key TEXT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (user_uuid) REFERENCES users (uuid) ON DELETE CASCADE
);
@@ -0,0 +1,2 @@
ALTER TABLE users DROP COLUMN v2_upgrade_token;
ALTER TABLE users_organizations DROP COLUMN v2_upgrade_token;
@@ -0,0 +1,2 @@
ALTER TABLE users ADD COLUMN v2_upgrade_token TEXT;
ALTER TABLE users_organizations ADD COLUMN v2_upgrade_token TEXT;
@@ -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
@@ -0,0 +1,5 @@
DROP TABLE IF EXISTS user_signature_key_pairs;
ALTER TABLE users DROP COLUMN signed_public_key;
ALTER TABLE users DROP COLUMN security_state;
ALTER TABLE users DROP COLUMN security_version;
@@ -0,0 +1,13 @@
ALTER TABLE users ADD COLUMN signed_public_key TEXT;
ALTER TABLE users ADD COLUMN security_state TEXT;
ALTER TABLE users ADD COLUMN security_version INTEGER;
CREATE TABLE user_signature_key_pairs (
uuid CHAR(36) NOT NULL PRIMARY KEY,
user_uuid CHAR(36) NOT NULL UNIQUE REFERENCES users (uuid) ON DELETE CASCADE,
signature_algorithm INTEGER NOT NULL, -- 0 = ed25519, 1 = mldsa44
signing_key TEXT NOT NULL,
verifying_key TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
@@ -0,0 +1,2 @@
ALTER TABLE users DROP COLUMN v2_upgrade_token;
ALTER TABLE users_organizations DROP COLUMN v2_upgrade_token;
@@ -0,0 +1,2 @@
ALTER TABLE users ADD COLUMN v2_upgrade_token TEXT;
ALTER TABLE users_organizations ADD COLUMN v2_upgrade_token TEXT;
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
@@ -0,0 +1,5 @@
DROP TABLE IF EXISTS user_signature_key_pairs;
ALTER TABLE users DROP COLUMN signed_public_key;
ALTER TABLE users DROP COLUMN security_state;
ALTER TABLE users DROP COLUMN security_version;
@@ -0,0 +1,13 @@
ALTER TABLE users ADD COLUMN signed_public_key TEXT;
ALTER TABLE users ADD COLUMN security_state TEXT;
ALTER TABLE users ADD COLUMN security_version INTEGER;
CREATE TABLE user_signature_key_pairs (
uuid TEXT NOT NULL PRIMARY KEY,
user_uuid TEXT NOT NULL UNIQUE REFERENCES users (uuid) ON DELETE CASCADE,
signature_algorithm INTEGER NOT NULL, -- 0 = ed25519, 1 = mldsa44
signing_key TEXT NOT NULL,
verifying_key TEXT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
@@ -0,0 +1,2 @@
ALTER TABLE users DROP COLUMN v2_upgrade_token;
ALTER TABLE users_organizations DROP COLUMN v2_upgrade_token;
@@ -0,0 +1,2 @@
ALTER TABLE users ADD COLUMN v2_upgrade_token TEXT;
ALTER TABLE users_organizations ADD COLUMN v2_upgrade_token TEXT;
+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();
});
}
+1 -1
View File
@@ -1,4 +1,4 @@
[toolchain]
channel = "1.98.1"
channel = "1.99.0"
components = [ "rustfmt", "clippy" ]
profile = "minimal"
+14 -13
View File
@@ -508,6 +508,7 @@ async fn enable_user(user_id: UserId, _token: AdminToken, conn: DbConn) -> Empty
async fn remove_2fa(user_id: UserId, token: AdminToken, conn: DbConn) -> EmptyResult {
let mut user = get_user_or_404(&user_id, &conn).await?;
TwoFactor::delete_all_by_user(&user.uuid, &conn).await?;
Device::clear_twofactor_remember_by_user(&user.uuid, &conn).await?;
two_factor::enforce_2fa_policy(&user, &ACTING_ADMIN_USER.into(), 14, &token.ip.ip, &conn).await?;
user.totp_recover = None;
user.save(&conn).await
@@ -905,23 +906,23 @@ mod tests {
fn validate_web_vault_compare() {
// web_vault_compare(active, latest)
// Test normal versions
assert!(web_vault_compare("2025.12.0", "2025.12.1") == -1);
assert!(web_vault_compare("2025.12.1", "2025.12.1") == 0);
assert!(web_vault_compare("2025.12.2", "2025.12.1") == 1);
assert_eq!(web_vault_compare("2025.12.0", "2025.12.1"), -1);
assert_eq!(web_vault_compare("2025.12.1", "2025.12.1"), 0);
assert_eq!(web_vault_compare("2025.12.2", "2025.12.1"), 1);
// Test patched/+build.n versions
// Newer latest version
assert!(web_vault_compare("2025.12.0+build.1", "2025.12.1") == -1);
assert!(web_vault_compare("2025.12.1", "2025.12.1+build.1") == -1);
assert!(web_vault_compare("2025.12.0+build.1", "2025.12.1+build.1") == -1);
assert!(web_vault_compare("2025.12.1+build.1", "2025.12.1+build.2") == -1);
assert_eq!(web_vault_compare("2025.12.0+build.1", "2025.12.1"), -1);
assert_eq!(web_vault_compare("2025.12.1", "2025.12.1+build.1"), -1);
assert_eq!(web_vault_compare("2025.12.0+build.1", "2025.12.1+build.1"), -1);
assert_eq!(web_vault_compare("2025.12.1+build.1", "2025.12.1+build.2"), -1);
// Equal versions
assert!(web_vault_compare("2025.12.1+build.1", "2025.12.1+build.1") == 0);
assert!(web_vault_compare("2025.12.2+build.2", "2025.12.2+build.2") == 0);
assert_eq!(web_vault_compare("2025.12.1+build.1", "2025.12.1+build.1"), 0);
assert_eq!(web_vault_compare("2025.12.2+build.2", "2025.12.2+build.2"), 0);
// Newer active version
assert!(web_vault_compare("2025.12.1+build.1", "2025.12.1") == 1);
assert!(web_vault_compare("2025.12.2", "2025.12.1+build.1") == 1);
assert!(web_vault_compare("2025.12.2+build.1", "2025.12.1+build.1") == 1);
assert!(web_vault_compare("2025.12.1+build.3", "2025.12.1+build.2") == 1);
assert_eq!(web_vault_compare("2025.12.1+build.1", "2025.12.1"), 1);
assert_eq!(web_vault_compare("2025.12.2", "2025.12.1+build.1"), 1);
assert_eq!(web_vault_compare("2025.12.2+build.1", "2025.12.1+build.1"), 1);
assert_eq!(web_vault_compare("2025.12.1+build.3", "2025.12.1+build.2"), 1);
}
}
+1136 -253
View File
File diff suppressed because it is too large. Load diff
+297 -101
View File
@@ -23,13 +23,14 @@ use crate::{
models::{
Archive, Attachment, AttachmentId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup,
CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, KeyId,
Membership, MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, UserId,
Membership, MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, User, UserId,
is_data_blob_encrypted,
},
},
util::{NumberOrString, deser_opt_nonempty_str, save_temp_file},
};
use super::folders::FolderData;
use super::{folders::FolderData, sends::SIZE_525_MB};
pub fn routes() -> Vec<Route> {
// Note that many routes have an `admin` variant; this seems to be
@@ -189,12 +190,22 @@ 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 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);
}
if let Some(v2_upgrade_token) = headers.user.v2_upgrade_token_json() {
user_decryption["v2UpgradeToken"] = v2_upgrade_token;
}
Ok(Json(json!({
"profile": user_json,
"folders": folders_json,
@@ -204,10 +215,7 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
"ciphers": ciphers_json,
"domains": domains_json,
"sends": sends_json,
"userDecryption": {
"masterPasswordUnlock": master_password_unlock,
"userKeyId": headers.user.key_id,
},
"userDecryption": user_decryption,
"object": "sync"
})))
}
@@ -269,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>,
@@ -284,7 +293,8 @@ pub struct CipherData {
Passport = 8
*/
pub r#type: i32,
pub name: String,
// Absent on a blob-encrypted cipher, whose name is sealed inside `data`
pub name: Option<String>,
pub notes: Option<String>,
fields: Option<Value>,
@@ -298,6 +308,9 @@ pub struct CipherData {
drivers_license: Option<Value>,
passport: Option<Value>,
// The sealed blob of a v2 account's cipher, which replaces all of the fields above
data: Option<String>,
favorite: Option<bool>,
reprompt: Option<i32>,
@@ -319,6 +332,98 @@ pub struct CipherData {
archived_date: Option<String>,
}
/// A field of a [`CipherData`] that fails upstream's model validation.
#[derive(Debug)]
pub struct CipherValidationError {
pub field: &'static str,
pub message: String,
}
impl From<CipherValidationError> for crate::Error {
fn from(e: CipherValidationError) -> Self {
Self::new_msg(e.message)
}
}
/// The cipher must have been encrypted for the acting user. Only checked when the client sends the field.
///
/// 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
{
err!("Cipher was not encrypted with the current user key. Please try again.")
}
Ok(())
}
/// Upstream's `[StringLength(500000)]` on `CipherRequestModel.Data`
const MAX_CIPHER_DATA_LENGTH: usize = 500_000;
impl CipherData {
/// 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)
}
/// 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/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
{
return Err(CipherValidationError {
field: "Data",
message: format!("The field Data must be a string with a maximum length of {MAX_CIPHER_DATA_LENGTH}."),
});
}
// A blob carries the name inside it, so only the other formats need one
if !is_blob && self.name.as_deref().is_none_or(|n| n.trim().is_empty()) {
return Err(CipherValidationError {
field: "Name",
message: String::from("The Name field is required."),
});
}
Ok(())
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PartialCipherData {
@@ -352,16 +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(&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, data.cipher.name.clone());
let mut cipher = Cipher::new(data.cipher.r#type);
cipher.user_uuid = Some(headers.user.uuid.clone());
cipher.save(&conn).await?;
@@ -385,16 +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);
}
if let Some(cipher_key_id) = &data.encrypted_by_key_id
&& let Some(user_key_id) = &headers.user.key_id
&& cipher_key_id != user_key_id
{
err_code!("Invalid key cipher", Status::UnprocessableEntity.code);
}
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`,
@@ -402,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, data.name.clone());
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?))
@@ -426,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,
@@ -434,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.
@@ -441,42 +607,22 @@ pub async fn update_cipher_from_data(
// We do not mind which data is in it, the keep our model more flexible when there are upstream changes.
// But, we at least know we do not need to store and return this specific key.
fn clean_cipher_data(mut json_data: Value) -> Value {
if json_data.is_array() {
json_data.as_array_mut().unwrap().iter_mut().for_each(|ref mut f| {
f.as_object_mut().unwrap().remove("response");
});
if let Some(items) = json_data.as_array_mut() {
for item in items.iter_mut().filter_map(Value::as_object_mut) {
item.remove("response");
}
}
json_data
}
enforce_personal_ownership_policy(Some(&data), headers, conn).await?;
// 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(_) => (),
}
}
let is_blob = data.is_blob();
data.validate_content(is_blob)?;
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();
@@ -486,9 +632,10 @@ pub async fn update_cipher_from_data(
Some(member) => {
// A non-empty list of collections implies the caller already validated the user's write
// access to them, so we can move the cipher into the organization on that basis.
// Write access to the cipher itself only counts when it already belongs to this organization.
if shared_to_collections.as_ref().is_some_and(|cols| !cols.is_empty())
|| member.has_full_access()
|| cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await
|| (!transfer_cipher && cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await)
{
cipher.organization_uuid = Some(org_id);
// After some discussion in PR #1329 re-added the user_uuid = None again.
@@ -506,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 {
@@ -550,24 +691,36 @@ pub async fn update_cipher_from_data(
_ => err!("Invalid type"),
};
let type_data = if let Some(mut data) = type_data_opt {
// Remove the 'Response' key from the base object.
data.as_object_mut().unwrap().remove("response");
// Remove the 'Response' key from every Uri.
if data["uris"].is_array() {
data["uris"] = clean_cipher_data(data["uris"].clone());
}
data
if let Some(blob) = data.data.filter(|_| is_blob) {
// 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;
cipher.fields = None;
cipher.password_history = None;
cipher.data = blob;
} else {
err!("Data missing")
};
let Some(mut type_data) = type_data_opt else {
err!("Data missing")
};
// Remove the 'Response' key from the base object.
if let Some(data_obj) = type_data.as_object_mut() {
data_obj.remove("response");
}
// Remove the 'Response' key from every Uri.
if type_data["uris"].is_array() {
type_data["uris"] = clean_cipher_data(type_data["uris"].clone());
}
// `validate_content` made sure there is a name
cipher.name = data.name.unwrap_or_default();
cipher.notes = data.notes;
cipher.fields = data.fields.map(|f| clean_cipher_data(f).to_string());
cipher.password_history = data.password_history.map(|f| f.to_string());
cipher.data = type_data.to_string();
}
cipher.key = data.key;
cipher.name = data.name;
cipher.notes = data.notes;
cipher.fields = data.fields.map(|f| clean_cipher_data(f).to_string());
cipher.data = type_data.to_string();
cipher.password_history = data.password_history.map(|f| f.to_string());
cipher.reprompt = data.reprompt.filter(|r| *r == RepromptType::None as i32 || *r == RepromptType::Password as i32);
cipher.save(conn).await?;
@@ -664,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, cipher_data.name.clone());
let mut cipher = Cipher::new(cipher_data.r#type);
update_cipher_from_data(&mut cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None).await?;
}
@@ -732,6 +885,9 @@ async fn put_cipher(
err!("Cipher is not write accessible")
}
// 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?;
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?))
@@ -990,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>")]
@@ -1005,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
}
@@ -1024,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() {
@@ -1082,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 {
@@ -1100,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?))
@@ -1182,6 +1374,10 @@ async fn post_attachment_v2(
if file_size < 0 {
err!("Attachment size can't be negative")
}
// Matches the "file" upload limit in main.rs, nothing larger can be uploaded
if file_size > SIZE_525_MB {
err!("Max file size is 525 MB.")
}
let attachment_id = crypto::generate_attachment_id();
let attachment =
Attachment::new(attachment_id.clone(), cipher.uuid.clone(), data.file_name, file_size, Some(data.key));
@@ -1869,7 +2065,7 @@ async fn delete_cipher_by_uuid(
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct CipherIdsData {
ids: Vec<CipherId>,
ids: HashSet<CipherId>,
}
async fn delete_multiple_ciphers(
@@ -2180,8 +2376,15 @@ impl CipherSyncData {
}
}
// Generate a HashMap with the Organization UUID as key and the Membership record
let members: HashMap<OrganizationId, Membership> = Membership::find_confirmed_by_user(user_id, conn)
.await
.into_iter()
.map(|m| (m.org_uuid.clone(), m))
.collect();
// Generate a list of Cipher UUID's containing a Vec with one or more Attachment records
let orgs = Membership::get_orgs_by_user(user_id, conn).await;
let orgs: Vec<OrganizationId> = members.keys().cloned().collect();
let attachments = Attachment::find_all_by_user_and_orgs(user_id, &orgs, conn).await;
let mut cipher_attachments: HashMap<CipherId, Vec<Attachment>> = HashMap::with_capacity(attachments.len());
for attachment in attachments {
@@ -2196,13 +2399,6 @@ impl CipherSyncData {
cipher_collections.entry(cipher).or_default().push(collection);
}
// Generate a HashMap with the Organization UUID as key and the Membership record
let members: HashMap<OrganizationId, Membership> = Membership::find_confirmed_by_user(user_id, conn)
.await
.into_iter()
.map(|m| (m.org_uuid.clone(), m))
.collect();
// Generate a HashMap with the User_Collections UUID as key and the CollectionUser record
let user_collections: HashMap<CollectionId, CollectionUser> = CollectionUser::find_by_user(user_id, conn)
.await
+29 -7
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::{
@@ -225,7 +228,7 @@ async fn send_invite(data: Json<EmergencyAccessInviteData>, headers: Headers, co
let (grantee_user, new_user) = match User::find_by_mail(&email, &conn).await {
None => {
if !CONFIG.invitations_allowed() {
err!(format!("Grantee user does not exist: {email}"))
err!(format!("Grantee user does not exist: {}", email.escape_debug()))
}
if !CONFIG.is_email_domain_allowed(&email) {
@@ -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
+22 -25
View File
@@ -6,12 +6,13 @@ use serde_json::Value;
use crate::{
CONFIG,
api::{EmptyResult, JsonResult},
api::{ApiResult, EmptyResult, JsonResult},
auth::{AdminHeaders, Headers},
db::{
DbConn, DbPool,
models::{Cipher, CipherId, Event, EventType, Membership, MembershipId, OrganizationId, UserId},
},
error::MapResult,
util::parse_date,
};
@@ -29,6 +30,14 @@ struct EventRange {
continuation_token: Option<String>,
}
impl EventRange {
fn date_range(&self) -> ApiResult<(NaiveDateTime, NaiveDateTime)> {
let start_date = parse_date(&self.start).map_res("Invalid start date")?;
let end_date = parse_date(self.continuation_token.as_ref().unwrap_or(&self.end)).map_res("Invalid end date")?;
Ok((start_date, end_date))
}
}
// Upstream: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Controllers/EventsController.cs#L87
#[get("/organizations/<org_id>/events?<data..>")]
async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: AdminHeaders, conn: DbConn) -> JsonResult {
@@ -39,12 +48,7 @@ async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: Admin
// Return an empty vec when we org events are disabled.
// This prevents client errors
let events_json: Vec<Value> = if CONFIG.org_events_enabled() {
let start_date = parse_date(&data.start);
let end_date = if let Some(before_date) = &data.continuation_token {
parse_date(before_date)
} else {
parse_date(&data.end)
};
let (start_date, end_date) = data.date_range()?;
Event::find_by_organization_uuid(&org_id, &start_date, &end_date, &conn)
.await
@@ -69,12 +73,7 @@ async fn get_cipher_events(cipher_id: CipherId, data: EventRange, headers: Heade
let events_json: Vec<Value> = if CONFIG.org_events_enabled()
&& Membership::user_has_ge_admin_access_to_cipher(&headers.user.uuid, &cipher_id, &conn).await
{
let start_date = parse_date(&data.start);
let end_date = if let Some(before_date) = &data.continuation_token {
parse_date(before_date)
} else {
parse_date(&data.end)
};
let (start_date, end_date) = data.date_range()?;
Event::find_by_cipher_uuid(&cipher_id, &start_date, &end_date, &conn).await.iter().map(Event::to_json).collect()
} else {
@@ -102,12 +101,7 @@ async fn get_user_events(
// Return an empty vec when we org events are disabled.
// This prevents client errors
let events_json: Vec<Value> = if CONFIG.org_events_enabled() {
let start_date = parse_date(&data.start);
let end_date = if let Some(before_date) = &data.continuation_token {
parse_date(before_date)
} else {
parse_date(&data.end)
};
let (start_date, end_date) = data.date_range()?;
Event::find_by_org_and_member(&org_id, &member_id, &start_date, &end_date, &conn)
.await
@@ -168,9 +162,10 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
}
for event in data.iter() {
let event_date = parse_date(&event.date);
let event_date = parse_date(&event.date).map_res("Invalid event date")?;
// Only the event types upstream accepts from clients, the rest are written server-side.
match event.r#type {
1000..=1099 => {
t if t == EventType::UserClientExportedVault as i32 => {
log_user_event_impl(
event.r#type,
&headers.user.uuid,
@@ -181,7 +176,7 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
)
.await;
}
1600..=1699 => {
t if t == EventType::OrganizationClientExportedVault as i32 => {
// Only allow logging events for an organization the user is actually a member of.
if let Some(org_id) = &event.organization_id
&& Membership::find_confirmed_by_user_and_org(&headers.user.uuid, org_id, &conn).await.is_some()
@@ -219,7 +214,8 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
.await;
}
}
_ => {
// Client-side cipher events (viewed, copied, toggled, autofilled).
1107..=1114 | 1117 | 1119..=1132 => {
// The cipher determines the organization the event is logged to, so make sure the
// user can actually access it instead of trusting the provided cipher uuid.
if let Some(cipher_uuid) = &event.cipher_id
@@ -240,16 +236,17 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
.await;
}
}
_ => {}
}
}
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(
+109 -246
View File
@@ -8,17 +8,17 @@ 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},
db::{
DbConn,
models::{
Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, EventType,
Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus, MembershipType,
OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, TwoFactor, TwoFactorType, User,
UserId,
Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, Device,
EventType, Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus,
MembershipType, OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, TwoFactor,
TwoFactorType, User, UserId,
},
},
mail,
@@ -26,12 +26,13 @@ use crate::{
util::{NumberOrString, convert_json_key_lcase_first},
};
use super::accounts::{AuthenticationData, UnlockData};
pub fn routes() -> Vec<Route> {
routes![
get_organization,
create_organization,
delete_organization,
post_delete_organization,
leave_organization,
get_user_collections,
get_org_collections,
@@ -39,13 +40,10 @@ pub fn routes() -> Vec<Route> {
get_org_collection_detail,
get_collection_users,
put_organization,
post_organization,
post_organization_collections,
post_bulk_access_collections,
post_organization_collection_update,
put_organization_collection_update,
delete_organization_collection,
post_organization_collection_delete,
bulk_delete_organization_collections,
post_bulk_collections,
get_org_details,
@@ -60,7 +58,6 @@ pub fn routes() -> Vec<Route> {
get_org_user_mini_details,
get_user,
edit_member,
put_member,
delete_member,
bulk_delete_member,
post_org_import,
@@ -70,7 +67,6 @@ pub fn routes() -> Vec<Route> {
get_master_password_policy,
get_policy,
put_policy,
put_policy_vnext,
get_plans,
post_org_keys,
get_organization_keys,
@@ -86,22 +82,16 @@ pub fn routes() -> Vec<Route> {
post_groups,
get_group,
put_group,
post_group,
get_group_details,
delete_group,
post_delete_group,
bulk_delete_groups,
get_group_members,
put_group_members,
post_delete_group_member,
put_reset_password_enrollment,
get_reset_password_details,
put_reset_password,
put_recover_account,
get_org_export,
post_api_key,
rotate_api_key,
get_billing_metadata,
get_billing_warnings,
get_auto_enroll_status,
get_self_host_billing_metadata,
@@ -245,16 +235,6 @@ async fn delete_organization(
}
}
#[post("/organizations/<org_id>/delete", data = "<data>")]
async fn post_delete_organization(
org_id: OrganizationId,
data: Json<PasswordOrOtpData>,
headers: OwnerHeaders,
conn: DbConn,
) -> EmptyResult {
delete_organization(org_id, data, headers, conn).await
}
#[post("/organizations/<org_id>/leave")]
async fn leave_organization(org_id: OrganizationId, headers: OrgMemberHeaders, conn: DbConn) -> EmptyResult {
if headers.membership.status != MembershipStatus::Confirmed as i32 {
@@ -300,16 +280,6 @@ async fn put_organization(
headers: OwnerHeaders,
data: Json<OrganizationUpdateData>,
conn: DbConn,
) -> JsonResult {
post_organization(org_id, headers, data, conn).await
}
#[post("/organizations/<org_id>", data = "<data>")]
async fn post_organization(
org_id: OrganizationId,
headers: OwnerHeaders,
data: Json<OrganizationUpdateData>,
conn: DbConn,
) -> JsonResult {
if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match");
@@ -415,8 +385,9 @@ async fn get_org_collections_details(org_id: OrganizationId, headers: ManagerHea
let col_users = CollectionUser::find_by_organization_swap_user_uuid_with_member_uuid(&org_id, &conn).await;
// Generate a HashMap to get the correct MembershipType per user to determine the manage permission
// We use the uuid instead of the user_uuid here, since that is what is used in CollectionUser
// This lists other members for admins, so it must not depend on the membership status
let membership_type: HashMap<MembershipId, i32> =
Membership::find_confirmed_by_org(&org_id, &conn).await.into_iter().map(|m| (m.uuid, m.atype)).collect();
Membership::find_by_org(&org_id, &conn).await.into_iter().map(|m| (m.uuid, m.atype)).collect();
// check if current user has full access to the organization (either directly or via any group)
let has_full_access_to_org = member.has_full_access()
@@ -639,17 +610,6 @@ async fn put_organization_collection_update(
headers: ManagerHeaders,
data: Json<FullCollectionData>,
conn: DbConn,
) -> JsonResult {
post_organization_collection_update(org_id, col_id, headers, data, conn).await
}
#[post("/organizations/<org_id>/collections/<col_id>", data = "<data>", rank = 2)]
async fn post_organization_collection_update(
org_id: OrganizationId,
col_id: CollectionId,
headers: ManagerHeaders,
data: Json<FullCollectionData>,
conn: DbConn,
) -> JsonResult {
if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match");
@@ -745,16 +705,6 @@ async fn delete_organization_collection(
delete_organization_collection_impl(&org_id, &col_id, &headers, &conn).await
}
#[post("/organizations/<org_id>/collections/<col_id>/delete")]
async fn post_organization_collection_delete(
org_id: OrganizationId,
col_id: CollectionId,
headers: ManagerHeaders,
conn: DbConn,
) -> EmptyResult {
delete_organization_collection_impl(&org_id, &col_id, &headers, &conn).await
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct BulkCollectionIds {
@@ -818,11 +768,9 @@ async fn get_org_collection_detail(
// Generate a HashMap to get the correct MembershipType per user to determine the manage permission
// We use the uuid instead of the user_uuid here, since that is what is used in CollectionUser
let membership_type: HashMap<MembershipId, i32> = Membership::find_confirmed_by_org(&org_id, &conn)
.await
.into_iter()
.map(|m| (m.uuid, m.atype))
.collect();
// This lists other members for admins, so it must not depend on the membership status
let membership_type: HashMap<MembershipId, i32> =
Membership::find_by_org(&org_id, &conn).await.into_iter().map(|m| (m.uuid, m.atype)).collect();
let users: Vec<Value> =
CollectionUser::find_by_org_and_coll_swap_user_uuid_with_member_uuid(&org_id, &collection.uuid, &conn)
@@ -960,8 +908,9 @@ async fn get_members(
let mut users_json = Vec::new();
for u in Membership::find_by_org(&org_id, &conn).await {
// The user can be a manager instead of an admin, but we've checked above that they have full access
users_json.push(
u.to_json_user_details(
u.to_json_details_for_admin(
data.include_collections.unwrap_or(false),
data.include_groups.unwrap_or(false),
&conn,
@@ -1081,7 +1030,7 @@ async fn send_invite(
let user = match User::find_by_mail(email, &conn).await {
None => {
if !CONFIG.invitations_allowed() {
err!(format!("User does not exist: {email}"))
err!(format!("User does not exist: {}", email.escape_debug()))
}
if !CONFIG.is_email_domain_allowed(email) {
@@ -1099,7 +1048,7 @@ async fn send_invite(
}
Some(user) => {
if Membership::find_by_user_and_org(&user.uuid, &org_id, &conn).await.is_some() {
err!(format!("User already in organization: {email}"))
err!(format!("User already in organization: {}", email.escape_debug()))
}
if !CONFIG.mail_enabled() {
@@ -1374,7 +1323,9 @@ async fn bulk_confirm_invite(
match data.keys {
Some(keys) => {
for invite in keys {
let member_id = invite.id.unwrap();
let Some(member_id) = invite.id else {
err!("Member id is required")
};
let user_key = invite.key.unwrap_or_default();
let err_msg = match confirm_invite_impl(&org_id, &member_id, &user_key, &headers, &conn, &nt).await {
Ok(()) => String::new(),
@@ -1516,7 +1467,9 @@ async fn get_user(
// In this case, when groups are requested we also need to include collections.
// Else these will not be shown in the interface, and could lead to missing collections when saved.
let include_groups = data.include_groups.unwrap_or(false);
Ok(Json(user.to_json_user_details(data.include_collections.unwrap_or(include_groups), include_groups, &conn).await))
Ok(Json(
user.to_json_details_for_admin(data.include_collections.unwrap_or(include_groups), include_groups, &conn).await,
))
}
#[derive(Deserialize)]
@@ -1530,17 +1483,6 @@ struct EditUserData {
}
#[put("/organizations/<org_id>/users/<member_id>", data = "<data>", rank = 1)]
async fn put_member(
org_id: OrganizationId,
member_id: MembershipId,
data: Json<EditUserData>,
headers: AdminHeaders,
conn: DbConn,
) -> EmptyResult {
edit_member(org_id, member_id, data, headers, conn).await
}
#[post("/organizations/<org_id>/users/<member_id>", data = "<data>", rank = 1)]
async fn edit_member(
org_id: OrganizationId,
member_id: MembershipId,
@@ -1835,6 +1777,10 @@ async fn post_org_import(
if org_id != headers.membership.org_uuid {
err!("Organization not found", "Organization id's do not match");
}
// OrgMemberHeaders also allows invited and accepted members, which are not allowed to import
if headers.membership.status != MembershipStatus::Confirmed as i32 {
err!("You need to be a Member of the Organization to call this endpoint")
}
let data: ImportData = data.into_inner();
// Validate the import before continuing
@@ -1885,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, cipher_data.name.clone());
let mut cipher = Cipher::new(cipher_data.r#type);
update_cipher_from_data(
&mut cipher,
cipher_data,
@@ -1916,7 +1862,7 @@ async fn post_org_import(
#[serde(rename_all = "camelCase")]
struct BulkCollectionsData {
organization_id: OrganizationId,
cipher_ids: Vec<CipherId>,
cipher_ids: HashSet<CipherId>,
collection_ids: HashSet<CollectionId>,
remove_collections: bool,
}
@@ -2195,18 +2141,6 @@ async fn put_policy(
Ok(Json(policy.to_json()))
}
// Deprecated with client v2026.5.0
#[put("/organizations/<org_id>/policies/<pol_type>/vnext", data = "<data>")]
async fn put_policy_vnext(
org_id: OrganizationId,
pol_type: i32,
data: Json<PutPolicy>,
headers: AdminHeaders,
conn: DbConn,
) -> JsonResult {
put_policy(org_id, pol_type, data, headers, conn).await
}
#[get("/plans")]
fn get_plans() -> Json<Value> {
// Respond with a minimal json just enough to allow the creation of an new organization.
@@ -2235,12 +2169,6 @@ fn get_plans() -> Json<Value> {
}))
}
#[get("/organizations/<_org_id>/billing/metadata")]
fn get_billing_metadata(_org_id: OrganizationId, _headers: OrgMemberHeaders) -> Json<Value> {
// Prevent a 404 error, which also causes Javascript errors.
Json(empty_data_json())
}
#[get("/organizations/<_org_id>/billing/vnext/warnings")]
fn get_billing_warnings(_org_id: OrganizationId, _headers: OrgMemberHeaders) -> Json<Value> {
Json(json!({
@@ -2260,14 +2188,6 @@ fn get_self_host_billing_metadata(_org_id: OrganizationId, _headers: OrgMemberHe
}))
}
fn empty_data_json() -> Value {
json!({
"object": "list",
"data": [],
"continuationToken": null
})
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct BulkRevokeMembershipIds {
@@ -2586,17 +2506,6 @@ impl CollectionData {
}
}
#[post("/organizations/<org_id>/groups/<group_id>", data = "<data>")]
async fn post_group(
org_id: OrganizationId,
group_id: GroupId,
data: Json<GroupRequest>,
headers: AdminHeaders,
conn: DbConn,
) -> JsonResult {
put_group(org_id, group_id, data, headers, conn).await
}
#[post("/organizations/<org_id>/groups", data = "<data>")]
async fn post_groups(
org_id: OrganizationId,
@@ -2706,7 +2615,6 @@ async fn add_update_group(
"id": group.uuid,
"organizationId": group.organizations_uuid,
"name": group.name,
"accessAll": group.access_all,
"externalId": group.external_id,
"object": "group"
})))
@@ -2733,16 +2641,6 @@ async fn get_group_details(
Ok(Json(group.to_json_details(&conn).await))
}
#[post("/organizations/<org_id>/groups/<group_id>/delete")]
async fn post_delete_group(
org_id: OrganizationId,
group_id: GroupId,
headers: AdminHeaders,
conn: DbConn,
) -> EmptyResult {
delete_group_impl(&org_id, &group_id, &headers, &conn).await
}
#[delete("/organizations/<org_id>/groups/<group_id>")]
async fn delete_group(org_id: OrganizationId, group_id: GroupId, headers: AdminHeaders, conn: DbConn) -> EmptyResult {
delete_group_impl(&org_id, &group_id, &headers, &conn).await
@@ -2844,90 +2742,6 @@ async fn get_group_members(
Ok(Json(json!(group_members)))
}
#[put("/organizations/<org_id>/groups/<group_id>/users", data = "<data>")]
async fn put_group_members(
org_id: OrganizationId,
group_id: GroupId,
headers: AdminHeaders,
data: Json<Vec<MembershipId>>,
conn: DbConn,
) -> EmptyResult {
if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match");
}
if !CONFIG.org_groups_enabled() {
err!("Group support is disabled");
}
if Group::find_by_uuid_and_org(&group_id, &org_id, &conn).await.is_none() {
err!("Group could not be found!", "Group uuid is invalid or does not belong to the organization")
}
let assigned_members = data.into_inner();
let org_memberships = Membership::find_by_org(&org_id, &conn).await;
let org_membership_ids: HashSet<&MembershipId> = org_memberships.iter().map(|m| &m.uuid).collect();
if let Some(e) = assigned_members.iter().find(|m| !org_membership_ids.contains(m)) {
err!("Invalid member", format!("Member {} does not belong to organization {}!", e, org_id))
}
GroupUser::delete_all_by_group(&group_id, &org_id, &conn).await?;
for assigned_member in assigned_members {
let mut user_entry = GroupUser::new(group_id.clone(), assigned_member.clone());
user_entry.save(&conn).await?;
log_event(
EventType::OrganizationUserUpdatedGroups,
&assigned_member,
&org_id,
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
&conn,
)
.await;
}
Ok(())
}
#[post("/organizations/<org_id>/groups/<group_id>/delete-user/<member_id>")]
async fn post_delete_group_member(
org_id: OrganizationId,
group_id: GroupId,
member_id: MembershipId,
headers: AdminHeaders,
conn: DbConn,
) -> EmptyResult {
if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match");
}
if !CONFIG.org_groups_enabled() {
err!("Group support is disabled");
}
if Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await.is_none() {
err!("User could not be found or does not belong to the organization.");
}
if Group::find_by_uuid_and_org(&group_id, &org_id, &conn).await.is_none() {
err!("Group could not be found or does not belong to the organization.");
}
log_event(
EventType::OrganizationUserUpdatedGroups,
&member_id,
&org_id,
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
&conn,
)
.await;
GroupUser::delete_by_group_and_member(&group_id, &member_id, &conn).await
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct OrganizationUserResetPasswordEnrollmentRequest {
@@ -2939,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)]
@@ -2952,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 {
@@ -2966,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
@@ -2987,19 +2843,6 @@ async fn put_recover_account(
recover_account(org_id, member_id, headers, data.into_inner(), conn, nt).await
}
// Deprecated since `v2026.4.2`
#[put("/organizations/<org_id>/users/<member_id>/reset-password", data = "<data>")]
async fn put_reset_password(
org_id: OrganizationId,
member_id: MembershipId,
headers: AdminHeaders,
data: Json<OrganizationUserRecoverAccountRequest>,
conn: DbConn,
nt: Notify<'_>,
) -> EmptyResult {
recover_account(org_id, member_id, headers, data.into_inner(), conn, nt).await
}
async fn recover_account(
org_id: OrganizationId,
member_id: MembershipId,
@@ -3032,10 +2875,34 @@ async fn recover_account(
err!("Organization user must be confirmed for password reset functionality");
}
let fallback_2fa_email = if req.reset_two_factor && CONFIG.email_2fa_auto_fallback() {
TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email as i32, &conn).await.is_none()
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, &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 {
false
None
};
// Sending email first ensure working email configuration and the resulting user notification.
@@ -3053,18 +2920,13 @@ 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 {
TwoFactor::delete_all_by_user(&user.uuid, &conn).await?;
Device::clear_twofactor_remember_by_user(&user.uuid, &conn).await?;
if !fallback_2fa_email || two_factor::email::find_and_activate_email_2fa(&user.uuid, &conn).await.is_err() {
two_factor::enforce_2fa_policy(&user, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn)
.await?;
@@ -3118,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,
})))
@@ -3233,7 +3096,7 @@ async fn api_key(
org_id: &OrganizationId,
data: Json<PasswordOrOtpData>,
rotate: bool,
headers: AdminHeaders,
headers: OwnerHeaders,
conn: DbConn,
) -> JsonResult {
if org_id != &headers.org_id {
@@ -3242,7 +3105,7 @@ async fn api_key(
let data: PasswordOrOtpData = data.into_inner();
let user = headers.user;
// Validate the admin users password/otp
// Validate the owner users password/otp
data.validate(&user, true, &conn).await?;
let org_api_key = if let Some(mut org_api_key) = OrganizationApiKey::find_by_org_uuid(org_id, &conn).await {
@@ -3270,7 +3133,7 @@ async fn api_key(
async fn post_api_key(
org_id: OrganizationId,
data: Json<PasswordOrOtpData>,
headers: AdminHeaders,
headers: OwnerHeaders,
conn: DbConn,
) -> JsonResult {
api_key(&org_id, data, false, headers, conn).await
@@ -3280,7 +3143,7 @@ async fn post_api_key(
async fn rotate_api_key(
org_id: OrganizationId,
data: Json<PasswordOrOtpData>,
headers: AdminHeaders,
headers: OwnerHeaders,
conn: DbConn,
) -> JsonResult {
api_key(&org_id, data, true, headers, conn).await
+9 -23
View File
@@ -58,25 +58,13 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
let org_id = token.0;
let data = data.into_inner();
// Like upstream, the import never removes Owners, so it doesn't revoke or restore them either.
for user_data in &data.members {
let mut user_created: bool = false;
if user_data.deleted {
// If user is marked for deletion and it exists, revoke it
if let Some(mut member) = Membership::find_by_email_and_org(&user_data.email, &org_id, &conn).await {
// Only revoke a user if it is not the last confirmed owner
let revoked = if member.atype == MembershipType::Owner
&& member.status == MembershipStatus::Confirmed as i32
{
if Membership::count_confirmed_by_org_and_type(&org_id, MembershipType::Owner, &conn).await <= 1 {
warn!("Can't revoke the last owner");
false
} else {
member.revoke()
}
} else {
member.revoke()
};
let revoked = member.atype != MembershipType::Owner && member.revoke();
let ext_modified = member.set_external_id(Some(user_data.external_id.clone()));
if revoked || ext_modified {
member.save(&conn).await?;
@@ -84,7 +72,7 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
}
// If user is part of the organization, restore it
} else if let Some(mut member) = Membership::find_by_email_and_org(&user_data.email, &org_id, &conn).await {
let mut restored = member.restore();
let mut restored = member.atype != MembershipType::Owner && member.restore();
let ext_modified = member.set_external_id(Some(user_data.external_id.clone()));
// Enforce org policies as every other restore path does.
// If the user is not allowed, we revoke again and continue so the external_id is still updated.
@@ -149,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 =
@@ -180,14 +172,8 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
for member in Membership::find_by_org(&org_id, &conn).await {
if let Some(ref user_external_id) = member.external_id
&& !sync_members.contains(user_external_id)
&& member.atype != MembershipType::Owner
{
if member.atype == MembershipType::Owner && member.status == MembershipStatus::Confirmed as i32 {
// Removing owner, check that there is at least one other confirmed owner
if Membership::count_confirmed_by_org_and_type(&org_id, MembershipType::Owner, &conn).await <= 1 {
warn!("Can't delete the last owner");
continue;
}
}
member.delete(&conn).await?;
}
}
+22 -182
View File
@@ -12,7 +12,7 @@ use serde_json::Value;
use crate::{
CONFIG,
api::{ApiResult, EmptyResult, JsonResult, Notify, UpdateType},
auth::{ClientIp, Headers, Host, SendHeaders},
auth::{Headers, Host, SendHeaders},
config::PathType,
db::{
DbConn, DbPool,
@@ -23,7 +23,7 @@ use crate::{
const SEND_INACCESSIBLE_MSG: &str = "Send does not exist or is no longer available";
static ANON_PUSH_DEVICE: LazyLock<Device> = LazyLock::new(|| {
let dt = crate::util::parse_date("1970-01-01T00:00:00.000000Z");
let dt = DateTime::UNIX_EPOCH.naive_utc();
Device {
uuid: String::from("00000000-0000-0000-0000-000000000000").into(),
created_at: dt,
@@ -39,18 +39,15 @@ static ANON_PUSH_DEVICE: LazyLock<Device> = LazyLock::new(|| {
});
// The max file size allowed by Bitwarden clients and add an extra 5% to avoid issues
const SIZE_525_MB: i64 = 550_502_400;
pub(crate) const SIZE_525_MB: i64 = 550_502_400;
pub fn routes() -> Vec<rocket::Route> {
routes![
get_sends,
get_send,
post_send,
post_send_file,
post_access,
post_access_legacy,
post_access_file,
post_access_file_legacy,
put_send,
delete_send,
put_remove_password,
@@ -73,7 +70,7 @@ pub async fn purge_sends(pool: DbPool) {
#[serde(rename_all = "camelCase")]
pub struct SendData {
r#type: i32,
key: String,
pub key: String,
password: Option<String>,
max_access_count: Option<NumberOrString>,
expiration_date: Option<DateTime<Utc>>,
@@ -201,7 +198,7 @@ async fn post_send(data: Json<SendData>, headers: Headers, conn: DbConn, nt: Not
enforce_disable_hide_email_policy(&data, &headers, &conn).await?;
if data.r#type == SendType::File as i32 {
err!("File sends should use /api/sends/file")
err!("File sends should use /api/sends/file/v2")
}
let mut send = create_send(data, headers.user.uuid)?;
@@ -218,93 +215,11 @@ async fn post_send(data: Json<SendData>, headers: Headers, conn: DbConn, nt: Not
Ok(Json(send.to_json()))
}
#[derive(FromForm)]
struct UploadData<'f> {
model: Json<SendData>,
data: TempFile<'f>,
}
#[derive(FromForm)]
struct UploadDataV2<'f> {
data: TempFile<'f>,
}
// @deprecated Mar 25 2021: This method has been deprecated in favor of direct uploads (v2).
// This method still exists to support older clients, probably need to remove it sometime.
// Upstream: https://github.com/bitwarden/server/blob/d0c793c95181dfb1b447eb450f85ba0bfd7ef643/src/Api/Controllers/SendsController.cs#L164-L167
// 2025: This endpoint doesn't seem to exists anymore in the latest version
// See: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/SendsController.cs
#[post("/sends/file", format = "multipart/form-data", data = "<data>")]
async fn post_send_file(data: Form<UploadData<'_>>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
enforce_disable_send_policy(&headers, &conn).await?;
let UploadData {
model,
data,
} = data.into_inner();
let model = model.into_inner();
let Some(size) = data.len().to_i64() else {
err!("Invalid send size");
};
if size < 0 {
err!("Send size can't be negative")
}
enforce_disable_hide_email_policy(&model, &headers, &conn).await?;
let size_limit = match CONFIG.user_send_limit() {
Some(0) => err!("File uploads are disabled"),
Some(limit_kb) => {
let Some(already_used) = Send::size_by_user(&headers.user.uuid, &conn).await else {
err!("Existing sends overflow")
};
let Some(left) = limit_kb.checked_mul(1024).and_then(|l| l.checked_sub(already_used)) else {
err!("Send size overflow");
};
if left <= 0 {
err!("Send storage limit reached! Delete some sends to free up space")
}
i64::clamp(left, 0, SIZE_525_MB)
}
None => SIZE_525_MB,
};
if size > size_limit {
err!("Send storage limit exceeded with this file");
}
let mut send = create_send(model, headers.user.uuid)?;
if send.atype != SendType::File as i32 {
err!("Send content is not a file");
}
let file_id = crate::crypto::generate_send_file_id();
save_temp_file(&PathType::Sends, &format!("{}/{file_id}", send.uuid), data, true).await?;
let mut data_value: Value = serde_json::from_str(&send.data)?;
if let Some(o) = data_value.as_object_mut() {
o.insert(String::from("id"), Value::String(file_id));
o.insert(String::from("size"), Value::Number(size.into()));
o.insert(String::from("sizeName"), Value::String(crate::util::get_display_size(size)));
}
send.data = serde_json::to_string(&data_value)?;
// Save the changes in the database
send.save(&conn).await?;
nt.send_send_update(
UpdateType::SyncSendCreate,
&send,
&send.update_users_revision(&conn).await,
&headers.device,
&conn,
)
.await;
Ok(Json(send.to_json()))
}
// Upstream: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/SendsController.cs#L165
#[post("/sends/file/v2", data = "<data>")]
async fn post_send_file_v2(data: Json<SendData>, headers: Headers, conn: DbConn) -> JsonResult {
@@ -413,8 +328,8 @@ async fn post_send_file_v2_data(
"Send file name does not match.",
format!(
"Expected file name '{}' got '{}'",
send_data.fileName,
raw_file_name.dangerous_unsafe_unsanitized_raw()
send_data.fileName.escape_debug(),
raw_file_name.dangerous_unsafe_unsanitized_raw().as_str().escape_debug()
)
),
_ => err!("Send file name does not match or is not provided."),
@@ -450,63 +365,16 @@ async fn post_send_file_v2_data(
#[post("/sends/access")]
async fn post_access(headers: SendHeaders, conn: DbConn, nt: Notify<'_>) -> JsonResult {
let Some(send) = Send::find_by_uuid(&headers.send_id, &conn).await else {
let Some(mut send) = Send::find_by_uuid(&headers.send_id, &conn).await else {
err_code!(SEND_INACCESSIBLE_MSG, 404)
};
if !send.is_accessible() {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
process_access(send, conn, nt).await
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SendAccessData {
pub password: Option<String>,
}
// Legacy since web-2026.6.0
#[post("/sends/access/<access_id>", data = "<data>")]
async fn post_access_legacy(
access_id: &str,
data: Json<SendAccessData>,
conn: DbConn,
ip: ClientIp,
nt: Notify<'_>,
) -> JsonResult {
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
let Some(mut send) = Send::find_by_access_id(access_id, &conn).await else {
err_code!(SEND_INACCESSIBLE_MSG, 404)
};
if let Some(max_access_count) = send.max_access_count
&& send.access_count >= max_access_count
{
err_code!(SEND_INACCESSIBLE_MSG, 404);
}
if !send.is_accessible() {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
if send.password_hash.is_some() {
match data.into_inner().password {
Some(ref p) if send.check_password(p) => { /* Nothing to do here */ }
Some(_) => err!("Invalid password", format!("IP: {}.", ip.ip)),
None => err_code!("Password not provided", format!("IP: {}.", ip.ip), 401),
}
}
// Files are incremented during the download
if send.atype == SendType::Text as i32 {
if !send.register_access(&conn).await? {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
} else {
send.save(&conn).await?;
if send.atype == SendType::Text as i32 && !send.register_access(&conn).await? {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
process_access(send, conn, nt).await
}
@@ -531,55 +399,27 @@ async fn post_access_file(
conn: DbConn,
nt: Notify<'_>,
) -> JsonResult {
let Some(send) = Send::find_by_uuid(&headers.send_id, &conn).await else {
let Some(mut send) = Send::find_by_uuid(&headers.send_id, &conn).await else {
err_code!(SEND_INACCESSIBLE_MSG, 404)
};
if !send.is_accessible() {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
check_send_file_id(&send, &file_id)?;
if !send.register_access(&conn).await? {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
process_access_file(send, file_id, host, conn, nt).await
}
// Legacy since web-2026.6.0
#[post("/sends/<send_id>/access/file/<file_id>", data = "<data>")]
async fn post_access_file_legacy(
send_id: SendId,
file_id: SendFileId,
data: Json<SendAccessData>,
host: Host,
conn: DbConn,
ip: ClientIp,
nt: Notify<'_>,
) -> JsonResult {
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
let Some(mut send) = Send::find_by_uuid(&send_id, &conn).await else {
err_code!(SEND_INACCESSIBLE_MSG, 404)
};
if let Some(max_access_count) = send.max_access_count
&& send.access_count >= max_access_count
{
err_code!(SEND_INACCESSIBLE_MSG, 404)
fn check_send_file_id(send: &Send, file_id: &SendFileId) -> EmptyResult {
if send.atype != SendType::File as i32 {
err!("Send is not a file type send.");
}
if !send.is_accessible() {
err_code!(SEND_INACCESSIBLE_MSG, 404)
match serde_json::from_str::<SendFileData>(&send.data) {
Ok(data) if &data.id == file_id => Ok(()),
_ => err_code!(SEND_INACCESSIBLE_MSG, 404),
}
if send.password_hash.is_some() {
match data.into_inner().password {
Some(ref p) if send.check_password(p) => { /* Nothing to do here */ }
Some(_) => err!("Invalid password."),
None => err_code!("Password not provided", 401),
}
}
if !send.register_access(&conn).await? {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
process_access_file(send, file_id, host, conn, nt).await
}
async fn process_access_file(send: Send, file_id: SendFileId, host: Host, conn: DbConn, nt: Notify<'_>) -> JsonResult {
@@ -642,7 +482,7 @@ async fn put_send(send_id: SendId, data: Json<SendData>, headers: Headers, conn:
Ok(Json(send.to_json()))
}
pub async fn update_send_from_data(
async fn update_send_from_data(
send: &mut Send,
data: SendData,
headers: &Headers,
+22 -43
View File
@@ -3,7 +3,7 @@ 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,
@@ -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,36 +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?;
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(())
}
+51 -193
View File
@@ -1,14 +1,13 @@
use chrono::Utc;
use data_encoding::BASE64;
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,
@@ -19,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 {
@@ -96,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!({
@@ -119,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)]
@@ -132,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 {
@@ -160,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();
@@ -182,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,
}),
})))
}
@@ -198,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,21 +190,16 @@ async fn duo_api_request(method: &str, path: &str, params: &str, data: &DuoData)
Ok(())
}
const DUO_EXPIRE: i64 = 300;
const APP_EXPIRE: i64 = 3600;
const AUTH_PREFIX: &str = "AUTH";
const DUO_PREFIX: &str = "TX";
const APP_PREFIX: &str = "APP";
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);
@@ -251,118 +214,13 @@ async fn get_user_duo_data(user_id: &UserId, conn: &DbConn) -> DuoStatus {
DuoStatus::Disabled(false)
}
// let (ik, sk, ak, host) = get_duo_keys();
pub(crate) async fn get_duo_keys_email(email: &str, conn: &DbConn) -> ApiResult<(String, String, String, String)> {
// let (ik, sk, host) = get_duo_keys_email();
pub(crate) async fn get_duo_keys_email(email: &str, conn: &DbConn) -> ApiResult<(String, String, String)> {
let data = match User::find_by_mail(email, conn).await {
Some(u) => get_user_duo_data(&u.uuid, conn).await.data(),
_ => DuoData::global(),
}
.map_res("Can't fetch Duo Keys")?;
Ok((data.ik, data.sk, CONFIG.get_duo_akey().await, data.host))
}
pub async fn generate_duo_signature(email: &str, conn: &DbConn) -> ApiResult<(String, String)> {
let now = Utc::now().timestamp();
let (ik, sk, ak, host) = get_duo_keys_email(email, conn).await?;
let duo_sign = sign_duo_values(&sk, email, &ik, DUO_PREFIX, now + DUO_EXPIRE);
let app_sign = sign_duo_values(&ak, email, &ik, APP_PREFIX, now + APP_EXPIRE);
Ok((format!("{duo_sign}:{app_sign}"), host))
}
fn sign_duo_values(key: &str, email: &str, ikey: &str, prefix: &str, expire: i64) -> String {
let val = format!("{email}|{ikey}|{expire}");
let cookie = format!("{prefix}|{}", BASE64.encode(val.as_bytes()));
format!("{cookie}|{}", crypto::hmac_sign(key, &cookie))
}
pub async fn validate_duo_login(email: &str, response: &str, conn: &DbConn) -> EmptyResult {
let split: Vec<&str> = response.split(':').collect();
if split.len() != 2 {
err!(
"Invalid response length",
ErrorEvent {
event: EventType::UserFailedLogIn2fa
}
);
}
let auth_sig = split[0];
let app_sig = split[1];
let now = Utc::now().timestamp();
let (ik, sk, ak, _host) = get_duo_keys_email(email, conn).await?;
let auth_user = parse_duo_values(&sk, auth_sig, &ik, AUTH_PREFIX, now)?;
let app_user = parse_duo_values(&ak, app_sig, &ik, APP_PREFIX, now)?;
if !crypto::ct_eq(&auth_user, app_user) || !crypto::ct_eq(&auth_user, email) {
err!(
"Error validating duo authentication",
ErrorEvent {
event: EventType::UserFailedLogIn2fa
}
)
}
Ok(())
}
fn parse_duo_values(key: &str, val: &str, ikey: &str, prefix: &str, time: i64) -> ApiResult<String> {
let split: Vec<&str> = val.split('|').collect();
if split.len() != 3 {
err!("Invalid value length")
}
let u_prefix = split[0];
let u_b64 = split[1];
let u_sig = split[2];
let sig = crypto::hmac_sign(key, &format!("{u_prefix}|{u_b64}"));
if !crypto::ct_eq(crypto::hmac_sign(key, &sig), crypto::hmac_sign(key, u_sig)) {
err!("Duo signatures don't match")
}
if u_prefix != prefix {
err!("Prefixes don't match")
}
let Ok(cookie_vec) = BASE64.decode(u_b64.as_bytes()) else {
err!("Invalid Duo cookie encoding")
};
let Ok(cookie) = String::from_utf8(cookie_vec) else {
err!("Invalid Duo cookie encoding")
};
let cookie_split: Vec<&str> = cookie.split('|').collect();
if cookie_split.len() != 3 {
err!("Invalid cookie length")
}
let username = cookie_split[0];
let u_ikey = cookie_split[1];
let expire = cookie_split[2];
if !crypto::ct_eq(ikey, u_ikey) {
err!("Invalid ikey")
}
let expire: i64 = if let Ok(e) = expire.parse() {
e
} else {
err!("Invalid expire time")
};
if time >= expire {
err!("Expired authorization")
}
Ok(username.into())
Ok((data.ik, data.sk, data.host))
}
+2 -2
View File
@@ -383,7 +383,7 @@ pub async fn get_duo_auth_url(
device_identifier: &DeviceId,
conn: &DbConn,
) -> Result<String, Error> {
let (ik, sk, _, host) = get_duo_keys_email(email, conn).await?;
let (ik, sk, host) = get_duo_keys_email(email, conn).await?;
let callback_url = match make_callback_url(client_id) {
Ok(url) => url,
@@ -435,7 +435,7 @@ pub async fn validate_duo_login(
let code = split[0];
let state = split[1];
let (ik, sk, _, host) = get_duo_keys_email(email, conn).await?;
let (ik, sk, host) = get_duo_keys_email(email, conn).await?;
// Get the context by the state reported by the client. If we don't have one,
// it means the context is either missing or expired.
+78 -58
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)]
@@ -65,7 +71,7 @@ async fn send_email_login(data: Json<SendEmailLoginData>, client_headers: Client
let Some(user) = User::find_by_mail(email, &conn).await else {
err!(
"Username or password is incorrect. Try again",
format!("IP: {}. Username: {email}.", client_headers.ip.ip)
format!("IP: {}. Username: {}.", client_headers.ip.ip, email.escape_debug())
)
};
@@ -74,7 +80,7 @@ async fn send_email_login(data: Json<SendEmailLoginData>, client_headers: Client
if !user.check_valid_password(master_password_hash) {
err!(
"Username or password is incorrect. Try again",
format!("IP: {}. Username: {email}.", client_headers.ip.ip)
format!("IP: {}. Username: {}.", client_headers.ip.ip, email.escape_debug())
)
}
} else if let Some(auth_request_id) = auth_request_id {
@@ -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,43 +181,36 @@ 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?;
}
let generated_token = crypto::generate_email_token(CONFIG.email_token_size());
let twofactor_data = EmailTokenData::new(data.email, generated_token);
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}"),
@@ -322,10 +341,10 @@ pub struct EmailTokenData {
}
impl EmailTokenData {
pub fn new(email: String, token: String) -> EmailTokenData {
pub fn new(email: String, token: Option<String>) -> EmailTokenData {
EmailTokenData {
email,
last_token: Some(token),
last_token: token,
token_sent: Utc::now().timestamp(),
attempts: 0,
}
@@ -363,7 +382,8 @@ pub async fn activate_email_2fa(user: &User, conn: &DbConn) -> EmptyResult {
if user.verified_at.is_none() {
err!("Auto-enabling of email 2FA failed because the users email address has not been verified!");
}
let twofactor_data = EmailTokenData::new(user.email.clone(), String::new());
// The token is set when the first code is sent
let twofactor_data = EmailTokenData::new(user.email.clone(), None);
let twofactor = TwoFactor::new(user.uuid.clone(), TwoFactorType::Email, twofactor_data.to_json());
twofactor.save(conn).await
}
+15 -55
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,13 +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,
disable_twofactor_put,
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());
@@ -87,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;
@@ -126,50 +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,
}
#[post("/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"
})))
}
#[put("/two-factor/disable", data = "<data>")]
async fn disable_twofactor_put(data: Json<DisableTwoFactorData>, headers: Headers, conn: DbConn) -> JsonResult {
disable_twofactor(data, headers, conn).await
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)?;
+110 -70
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::{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,57 +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);
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>>(),
}),
})))
}
@@ -368,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
}
@@ -416,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
+56 -46
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,13 +202,32 @@ 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");
}
let yubikey_metadata: YubikeyMetadata = serde_json::from_str(twofactor_data).expect("Can't parse Yubikey Metadata");
let response_id = &response[..12];
let Some(response_id) = response.get(..12) else {
err!("Invalid Yubikey OTP");
};
if !yubikey_metadata.keys.contains(&response_id.to_owned()) {
err!("Given Yubikey is not registered");
+2 -2
View File
@@ -85,7 +85,7 @@ static ICON_SIZE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?x)(\d+
#[get("/<host>/icon.png")]
fn icon_external(host: &str) -> Cached<Option<Redirect>> {
let Ok(host) = get_valid_host(host) else {
warn!("Invalid host: {host}");
warn!("Invalid host: {}", host.escape_debug());
return Cached::ttl(None, CONFIG.icon_cache_negttl(), true);
};
@@ -113,7 +113,7 @@ async fn icon_internal(host: &str) -> Cached<(ContentType, Vec<u8>)> {
const FALLBACK_ICON: &[u8] = include_bytes!("../static/images/fallback-icon.png");
let Ok(host) = get_valid_host(host) else {
warn!("Invalid host: {host}");
warn!("Invalid host: {}", host.escape_debug());
return Cached::ttl(
(ContentType::new("image", "png"), FALLBACK_ICON.to_vec()),
CONFIG.icon_cache_negttl(),
+106 -171
View File
@@ -17,15 +17,14 @@ use crate::{
accounts::{PreloginData, RegisterData, kdf_upgrade, prelogin, register},
log_user_event,
two_factor::{
authenticator, duo, duo_oidc, email, enforce_2fa_policy, is_twofactor_provider_usable, webauthn,
yubikey,
authenticator, duo_oidc, email, enforce_2fa_policy, is_twofactor_provider_usable, webauthn, yubikey,
},
},
master_password_policy,
identity_master_password_policy,
push::register_push_device,
},
auth,
auth::{AuthMethod, ClientHeaders, ClientIp, ClientVersion, Secure, generate_organization_api_key_login_claims},
auth::{AuthMethod, ClientHeaders, ClientIp, Secure, generate_organization_api_key_login_claims},
crypto,
db::{
DbConn,
@@ -46,7 +45,6 @@ pub fn routes() -> Vec<Route> {
login,
post_prelogin,
prelogin_password,
identity_register,
register_verification_email,
register_finish,
prevalidate,
@@ -57,12 +55,7 @@ pub fn routes() -> Vec<Route> {
}
#[post("/connect/token", data = "<data>")]
async fn login(
data: Form<ConnectData>,
client_header: ClientHeaders,
client_version: Option<ClientVersion>,
conn: DbConn,
) -> JsonResult {
async fn login(data: Form<ConnectData>, client_header: ClientHeaders, conn: DbConn) -> JsonResult {
let data: ConnectData = data.into_inner();
let mut user_id: Option<UserId> = None;
@@ -83,17 +76,13 @@ async fn login(
check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
password_login(data, &mut user_id, &conn, &client_header.ip, client_version.as_ref()).await
password_login(data, &mut user_id, &conn, &client_header.ip).await
}
"client_credentials" => {
check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?;
check_is_some(data.client_secret.as_ref(), "client_secret cannot be blank")?;
check_is_some(data.scope.as_ref(), "scope cannot be blank")?;
check_is_some(data.device_identifier.as_ref(), "device_identifier cannot be blank")?;
check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
api_key_login(data, &mut user_id, &conn, &client_header.ip).await
}
"authorization_code" if CONFIG.sso_enabled() => {
@@ -105,7 +94,7 @@ async fn login(
check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
sso_login(data, &mut user_id, &conn, &client_header.ip, client_version.as_ref()).await
sso_login(data, &mut user_id, &conn, &client_header.ip).await
}
"authorization_code" => err!("SSO sign-in is not available"),
"send_access" => {
@@ -129,7 +118,7 @@ async fn login(
match &login_result {
Ok(_) => {
log_user_event(
EventType::UserLoggedIn as i32,
EventType::UserLoggedIn,
&user_id,
client_header.device_type,
&client_header.ip.ip,
@@ -139,8 +128,7 @@ async fn login(
}
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;
}
}
}
@@ -190,13 +178,7 @@ async fn refresh_login(data: ConnectData, conn: &DbConn, ip: &ClientIp) -> JsonR
}
// After exchanging the code we need to check first if 2FA is needed before continuing
async fn sso_login(
data: ConnectData,
user_id: &mut Option<UserId>,
conn: &DbConn,
ip: &ClientIp,
client_version: Option<&ClientVersion>,
) -> JsonResult {
async fn sso_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbConn, ip: &ClientIp) -> JsonResult {
AuthMethod::Sso.check_scope(data.scope.as_ref())?;
// Ratelimit the login
@@ -353,7 +335,7 @@ async fn sso_login(
Some((mut user, sso_user)) => {
let mut device = get_device(&data, conn, &user).await?;
let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, client_version, conn).await?;
let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, conn).await?;
if user.private_key.is_none() {
// User was invited a stub was created
@@ -385,13 +367,7 @@ async fn sso_login(
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await
}
async fn password_login(
data: ConnectData,
user_id: &mut Option<UserId>,
conn: &DbConn,
ip: &ClientIp,
client_version: Option<&ClientVersion>,
) -> JsonResult {
async fn password_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbConn, ip: &ClientIp) -> JsonResult {
// Validate scope
AuthMethod::Password.check_scope(data.scope.as_ref())?;
@@ -400,24 +376,14 @@ async fn password_login(
// Get the user
let username = data.username.as_ref().unwrap().trim();
let log_username = username.escape_debug();
let Some(mut user) = User::find_by_mail(username, conn).await else {
err!("Username or password is incorrect. Try again", format!("IP: {}. Username: {username}.", ip.ip))
err!("Username or password is incorrect. Try again", format!("IP: {}. Username: {log_username}.", ip.ip))
};
// Set the user_id here to be passed back used for event logging.
*user_id = Some(user.uuid.clone());
// Check if the user is disabled
if !user.enabled {
err!(
"This user has been disabled",
format!("IP: {}. Username: {username}.", ip.ip),
ErrorEvent {
event: EventType::UserFailedLogIn
}
)
}
let password = data.password.as_ref().unwrap();
// If we get an auth request, we don't check the user's password, but the access code of the auth request
@@ -425,7 +391,7 @@ async fn password_login(
let Some(auth_request) = AuthRequest::find_by_uuid_and_user(auth_request_id, &user.uuid, conn).await else {
err!(
"Auth request not found. Try again.",
format!("IP: {}. Username: {username}.", ip.ip),
format!("IP: {}. Username: {log_username}.", ip.ip),
ErrorEvent {
event: EventType::UserFailedLogIn,
}
@@ -437,13 +403,14 @@ async fn password_login(
if auth_request.user_uuid != user.uuid
|| !auth_request.approved.unwrap_or(false)
|| auth_request.authentication_date.is_some()
|| request_expired
|| ip.ip.to_string() != auth_request.request_ip
|| !auth_request.check_access_code(password)
{
err!(
"Username or access code is incorrect. Try again",
format!("IP: {}. Username: {username}.", ip.ip),
format!("IP: {}. Username: {log_username}.", ip.ip),
ErrorEvent {
event: EventType::UserFailedLogIn,
}
@@ -452,13 +419,24 @@ async fn password_login(
} else if !user.check_valid_password(password) {
err!(
"Username or password is incorrect. Try again",
format!("IP: {}. Username: {username}.", ip.ip),
format!("IP: {}. Username: {log_username}.", ip.ip),
ErrorEvent {
event: EventType::UserFailedLogIn,
}
)
}
// Check if the user is disabled
if !user.enabled {
err!(
"This user has been disabled",
format!("IP: {}. Username: {log_username}.", ip.ip),
ErrorEvent {
event: EventType::UserFailedLogIn
}
)
}
// Change the KDF Iterations (only when not logging in with an auth request)
if data.auth_request.is_none() {
kdf_upgrade(&mut user, password, conn).await?;
@@ -491,7 +469,7 @@ async fn password_login(
// We still want the login to fail until they actually verified the email address
err!(
"Please verify your email before trying again.",
format!("IP: {}. Username: {username}.", ip.ip),
format!("IP: {}. Username: {log_username}.", ip.ip),
ErrorEvent {
event: EventType::UserFailedLogIn
}
@@ -500,7 +478,20 @@ async fn password_login(
let mut device = get_device(&data, conn, &user).await?;
let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, client_version, conn).await?;
let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, conn).await?;
// Like upstream, an auth request can be used for one login only. Marked after 2FA so a 2FA prompt doesn't use it up.
if let Some(ref auth_request_id) = data.auth_request
&& !AuthRequest::set_authentication_date(auth_request_id, conn).await?
{
err!(
"Username or access code is incorrect. Try again",
format!("IP: {}. Username: {log_username}.", ip.ip),
ErrorEvent {
event: EventType::UserFailedLogIn,
}
)
}
let auth_tokens = auth::AuthTokens::new(&device, &user, AuthMethod::Password, data.client_id);
@@ -539,7 +530,7 @@ async fn authenticated_response(
// Save to update `device.updated_at` to track usage and toggle new status
device.save(true, conn).await?;
let master_password_policy = master_password_policy(user, conn).await;
let master_password_policy = identity_master_password_policy(user, conn).await;
let has_master_password = !user.password_hash.is_empty();
let master_password_unlock = if has_master_password {
@@ -554,24 +545,13 @@ 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
};
let account_keys = if user.private_key.is_some() {
json!({
"publicKeyEncryptionKeyPair": {
"wrappedPrivateKey": user.private_key,
"publicKey": user.public_key,
"Object": "publicKeyEncryptionKeyPair"
},
"Object": "privateKeys"
})
} else {
Value::Null
};
let account_keys = user.account_keys_json(conn).await;
let mut result = json!({
"access_token": auth_tokens.access_token(),
@@ -583,7 +563,6 @@ async fn authenticated_response(
"KdfIterations": user.client_kdf_iter,
"KdfMemory": user.client_kdf_memory,
"KdfParallelism": user.client_kdf_parallelism,
"ResetMasterPassword": false, // TODO: Same as above
"ForcePasswordReset": false,
"MasterPasswordPolicy": master_password_policy,
"scope": auth_tokens.scope(),
@@ -613,7 +592,14 @@ async fn api_key_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &D
// Validate scope
match data.scope.as_ref() {
Some(scope) if scope == &AuthMethod::UserApiKey.scope() => user_api_key_login(data, user_id, conn, ip).await,
Some(scope) if scope == &AuthMethod::UserApiKey.scope() => {
// Like upstream, only the user API key logs in a device
check_is_some(data.device_identifier.as_ref(), "device_identifier cannot be blank")?;
check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
user_api_key_login(data, user_id, conn, ip).await
}
Some(scope) if scope == &AuthMethod::OrgApiKey.scope() => organization_api_key_login(data, conn, ip).await,
_ => err!("Scope not supported"),
}
@@ -705,24 +691,13 @@ 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
};
let account_keys = if user.private_key.is_some() {
json!({
"publicKeyEncryptionKeyPair": {
"wrappedPrivateKey": user.private_key,
"publicKey": user.public_key,
"Object": "publicKeyEncryptionKeyPair"
},
"Object": "privateKeys"
})
} else {
Value::Null
};
let account_keys = user.account_keys_json(conn).await;
// Note: No refresh_token is returned. The CLI just repeats the
// client_credentials login flow when the existing token expires.
@@ -737,7 +712,6 @@ async fn user_api_key_login(
"KdfIterations": user.client_kdf_iter,
"KdfMemory": user.client_kdf_memory,
"KdfParallelism": user.client_kdf_parallelism,
"ResetMasterPassword": false, // TODO: according to official server seems something like: user.password_hash.is_empty(), but would need testing
"ForcePasswordReset": false,
"scope": AuthMethod::UserApiKey.scope(),
"AccountKeys": account_keys,
@@ -803,7 +777,6 @@ async fn twofactor_auth(
data: &ConnectData,
device: &mut Device,
ip: &ClientIp,
client_version: Option<&ClientVersion>,
conn: &DbConn,
) -> ApiResult<Option<String>> {
let twofactors = TwoFactor::find_by_user(&user.uuid, conn).await;
@@ -832,17 +805,12 @@ async fn twofactor_auth(
if ![TwoFactorType::Remember as i32, TwoFactorType::RecoveryCode as i32].contains(&selected_id)
&& !twofactor_ids.contains(&selected_id)
{
err_json!(
json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?,
"Invalid two factor provider"
)
err_json!(json_err_twofactor(&twofactor_ids, user, data, conn).await?, "Invalid two factor provider")
}
let Some(ref twofactor_code) = data.two_factor_token else {
err_json!(
json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?,
"2FA token not provided"
)
// Like upstream, a blank token counts as not provided
let Some(twofactor_code) = data.two_factor_token.as_deref().filter(|t| !t.trim().is_empty()) else {
err_json!(json_err_twofactor(&twofactor_ids, user, data, conn).await?, "2FA token not provided")
};
let selected_twofactor = twofactors.into_iter().find(|tf| tf.atype == selected_id && tf.enabled);
@@ -856,20 +824,14 @@ async fn twofactor_auth(
Some(TwoFactorType::Webauthn) => webauthn::validate_webauthn_login(&user.uuid, twofactor_code, conn).await?,
Some(TwoFactorType::YubiKey) => yubikey::validate_yubikey_login(twofactor_code, &selected_data?).await?,
Some(TwoFactorType::Duo) => {
if CONFIG.duo_use_iframe() {
// Legacy iframe prompt flow
duo::validate_duo_login(&user.email, twofactor_code, conn).await?;
} else {
// OIDC based flow
duo_oidc::validate_duo_login(
&user.email,
twofactor_code,
data.client_id.as_ref().unwrap(),
data.device_identifier.as_ref().unwrap(),
conn,
)
.await?;
}
duo_oidc::validate_duo_login(
&user.email,
twofactor_code,
data.client_id.as_ref().unwrap(),
data.device_identifier.as_ref().unwrap(),
conn,
)
.await?;
}
Some(TwoFactorType::Email) => {
email::validate_email_code_str(&user.uuid, twofactor_code, &selected_data?, &ip.ip, conn).await?;
@@ -891,16 +853,22 @@ async fn twofactor_auth(
device.save(true, conn).await?;
}
err_json!(
json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?,
json_err_twofactor(&twofactor_ids, user, data, conn).await?,
"2FA Remember token not provided or expired"
)
}
}
}
Some(TwoFactorType::RecoveryCode) => {
// Check if recovery code is correct
if !user.check_valid_recovery_code(twofactor_code) {
err!("Recovery code is incorrect. Try again.")
// Like upstream, spaces and case don't matter
let recovery_code = twofactor_code.replace(' ', "").trim().to_lowercase();
if !user.check_valid_recovery_code(&recovery_code) {
err!(
"Two-step token is invalid. Try again.",
ErrorEvent {
event: EventType::UserFailedLogIn2fa
}
)
}
// Remove all twofactors from the user
@@ -913,7 +881,14 @@ 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) =
mail::send_recover_twofactor(&user.email, &ip.ip.to_string(), &Utc::now().naive_utc()).await
{
error!("Error sending two-step login recovered email: {e:#?}");
}
// Remove the recovery code, not needed without twofactors
user.totp_recover = None;
@@ -942,21 +917,14 @@ fn selected_data(tf: Option<TwoFactor>) -> ApiResult<String> {
tf.map(|t| t.data).map_res("Two factor doesn't exist")
}
async fn json_err_twofactor(
providers: &[i32],
user_id: &UserId,
data: &ConnectData,
client_version: Option<&ClientVersion>,
conn: &DbConn,
) -> ApiResult<Value> {
async fn json_err_twofactor(providers: &[i32], user: &User, data: &ConnectData, conn: &DbConn) -> ApiResult<Value> {
let user_id = &user.uuid;
let mut result = json!({
"error" : "invalid_grant",
"error_description" : "Two factor required.",
"TwoFactorProviders" : providers.iter().map(ToString::to_string).collect::<Vec<String>>(),
"TwoFactorProviders2" : {}, // { "0" : null }
"MasterPasswordPolicy": {
"Object": "masterPasswordPolicy"
}
"MasterPasswordPolicy": identity_master_password_policy(user, conn).await,
});
for provider in providers {
@@ -969,37 +937,21 @@ async fn json_err_twofactor(
}
Some(TwoFactorType::Duo) => {
let email = if let Some(u) = User::find_by_uuid(user_id, conn).await {
u.email
} else {
err!("User does not exist")
};
let auth_url = duo_oidc::get_duo_auth_url(
&user.email,
data.client_id.as_ref().unwrap(),
data.device_identifier.as_ref().unwrap(),
conn,
)
.await?;
if CONFIG.duo_use_iframe() {
// Legacy iframe prompt flow
let (signature, host) = duo::generate_duo_signature(&email, conn).await?;
result["TwoFactorProviders2"][provider.to_string()] = json!({
"Host": host,
"Signature": signature,
});
} else {
// OIDC based flow
let auth_url = duo_oidc::get_duo_auth_url(
&email,
data.client_id.as_ref().unwrap(),
data.device_identifier.as_ref().unwrap(),
conn,
)
.await?;
result["TwoFactorProviders2"][provider.to_string()] = json!({
"AuthUrl": auth_url,
});
}
result["TwoFactorProviders2"][provider.to_string()] = json!({
"AuthUrl": auth_url,
});
}
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")
};
@@ -1011,23 +963,10 @@ async fn json_err_twofactor(
}
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")
};
// Starting with version 2025.5.0 the client will call `/api/two-factor/send-email-login`.
let disabled_send = if let Some(cv) = client_version {
let ver_match = semver::VersionReq::parse(">=2025.5.0").unwrap();
ver_match.matches(&cv.0)
} else {
false
};
// Send email immediately if email is the only 2FA option.
if providers.len() == 1 && !disabled_send {
email::send_token(user_id, conn).await?;
}
let email_data = email::EmailTokenData::from_json(&twofactor.data)?;
result["TwoFactorProviders2"][provider.to_string()] = json!({
"Email": email::obscure_email(&email_data.email),
@@ -1065,11 +1004,6 @@ async fn prelogin_password(data: Json<PreloginData>, ip: ClientIp, conn: DbConn)
prelogin(data, ip, conn).await
}
#[post("/accounts/register", data = "<data>")]
async fn identity_register(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
register(data, false, conn).await
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RegisterVerificationData {
@@ -1141,8 +1075,9 @@ async fn register_verification_email(
}
#[post("/accounts/register/finish", data = "<data>")]
async fn register_finish(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
register(data, true, conn).await
async fn register_finish(data: Json<RegisterData>, ip: ClientIp, conn: DbConn) -> JsonResult {
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
register(data, conn).await
}
// https://github.com/bitwarden/jslib/blob/master/common/src/models/request/tokenRequest.ts
@@ -1272,7 +1207,7 @@ async fn oidcsignin_redirect(
let state = sso::decode_state(&base64_state)?;
let Some(mut sso_auth) = SsoAuth::find(&state, conn).await else {
err!(format!("Cannot retrieve sso_auth for {state}"))
err!(format!("Cannot retrieve sso_auth for {}", state.escape_debug()))
};
// Browser-binding check
@@ -1281,7 +1216,7 @@ async fn oidcsignin_redirect(
let provided_hash = cookie_value.as_deref().map(|v| crypto::sha256_hex(v.as_bytes()));
match (sso_auth.binding_hash.as_deref(), provided_hash.as_deref()) {
(Some(expected), Some(actual)) if crypto::ct_eq(expected, actual) => {}
_ => err!(format!("SSO session binding mismatch for {state}")),
_ => err!(format!("SSO session binding mismatch for {}", state.escape_debug())),
}
cookies
.remove(Cookie::build(SSO_BINDING_COOKIE).path(format!("{}/identity/connect/", CONFIG.domain_path())).build());
+20 -2
View File
@@ -23,7 +23,7 @@ pub use crate::api::{
icons::routes as icons_routes,
identity::routes as identity_routes,
notifications::routes as notifications_routes,
notifications::{AnonymousNotify, Notify, UpdateType, WS_ANONYMOUS_SUBSCRIPTIONS, WS_USERS},
notifications::{AnonymousNotify, LogOutReason, Notify, UpdateType, WS_ANONYMOUS_SUBSCRIPTIONS, WS_USERS},
push::{
push_cipher_update, push_folder_update, push_logout, push_send_update, push_user_update, register_push_device,
unregister_push_device,
@@ -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")]
@@ -124,3 +124,21 @@ async fn master_password_policy(user: &User, conn: &DbConn) -> Value {
mpp_json["Object"] = json!("masterPasswordPolicy");
mpp_json
}
// Identity responses use PascalCase keys like upstream, some clients (Android) only read those
async fn identity_master_password_policy(user: &User, conn: &DbConn) -> Value {
match master_password_policy(user, conn).await {
Value::Object(policy) => Value::Object(
policy
.into_iter()
.map(|(mut key, value)| {
if let Some(first) = key.get_mut(..1) {
first.make_ascii_uppercase();
}
(key, value)
})
.collect(),
),
policy => policy,
}
}
+59 -11
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());
}
}
}
}}
@@ -383,23 +411,35 @@ impl WebSocketUsers {
}
pub async fn send_logout(&self, user: &User, acting_device: Option<&Device>, conn: &DbConn) {
self.send_logout_with_reason(user, acting_device, None, conn).await;
}
/// A logout that clients may handle differently depending on `reason`
pub async fn send_logout_with_reason(
&self,
user: &User,
acting_device: Option<&Device>,
reason: Option<LogOutReason>,
conn: &DbConn,
) {
// Skip any processing if both WebSockets and Push are not active
if *NOTIFICATIONS_DISABLED {
return;
}
let acting_device_id = acting_device.map(|d| d.uuid.clone());
let data = create_update(
vec![("UserId".into(), user.uuid.to_string().into()), ("Date".into(), serialize_date(user.updated_at))],
UpdateType::LogOut,
acting_device_id,
);
let mut payload =
vec![("UserId".into(), user.uuid.to_string().into()), ("Date".into(), serialize_date(user.updated_at))];
if let Some(reason) = reason {
payload.push(("Reason".into(), (reason as i32).into()));
}
let data = create_update(payload, UpdateType::LogOut, acting_device_id);
if CONFIG.enable_websocket() {
self.send_update(&user.uuid, &data).await;
}
if CONFIG.push_enabled() {
push_logout(user, acting_device, conn).await;
push_logout(user, acting_device, reason, conn).await;
}
}
@@ -667,6 +707,14 @@ fn create_ping() -> Vec<u8> {
serialize(&Value::Array(vec![6.into()]))
}
/// Why a logout was pushed, absent for a plain one. `KdfChange` isn't sent 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,
}
// https://github.com/bitwarden/server/blob/375af7c43b10d9da03525d41452f95de3f921541/src/Core/Enums/PushType.cs
#[derive(Copy, Clone, Eq, PartialEq)]
pub enum UpdateType {
+4 -3
View File
@@ -12,7 +12,7 @@ use tokio::sync::RwLock;
use crate::{
CONFIG,
api::{ApiResult, EmptyResult, UpdateType},
api::{ApiResult, EmptyResult, LogOutReason, UpdateType},
db::{
DbConn,
models::{AuthRequestId, Cipher, Device, Folder, PushId, Send, User, UserId},
@@ -188,7 +188,7 @@ pub async fn push_cipher_update(ut: UpdateType, cipher: &Cipher, device: &Device
}
}
pub async fn push_logout(user: &User, acting_device: Option<&Device>, conn: &DbConn) {
pub async fn push_logout(user: &User, acting_device: Option<&Device>, reason: Option<LogOutReason>, conn: &DbConn) {
if Device::check_user_has_push_device(&user.uuid, conn).await {
tokio::task::spawn(send_to_push_relay(json!({
"userId": user.uuid,
@@ -198,7 +198,8 @@ pub async fn push_logout(user: &User, acting_device: Option<&Device>, conn: &DbC
"type": UpdateType::LogOut as i32,
"payload": {
"userId": user.uuid,
"date": format_date(&user.updated_at)
"date": format_date(&user.updated_at),
"reason": reason.map(|r| r as i32),
},
"clientType": null,
"installationId": null
+1
View File
@@ -89,6 +89,7 @@ fn vaultwarden_css() -> EtagCached<Css<String>> {
// Else, there is either no cache, or reload_templates is true and we need to rebuild the CSS
let css_options = json!({
"email_change_allowed": CONFIG.email_change_allowed(),
"emergency_access_allowed": CONFIG.emergency_access_allowed(),
"load_user_scss": true,
"mail_2fa_enabled": CONFIG._enable_email_2fa(),
+102 -37
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;
@@ -14,6 +17,7 @@ use ipnet::IpNet;
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, errors::ErrorKind};
use num_traits::FromPrimitive;
use openssl::rsa::Rsa;
use ring::hmac;
use serde::{de::DeserializeOwned, ser::Serialize};
use rocket::{
@@ -30,7 +34,7 @@ use crate::{
models::{
AttachmentId, CipherId, Collection, CollectionId, Device, DeviceId, DeviceType, EmergencyAccessId,
EventType, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, OrganizationId,
SendFileId, SendId, User, UserId, UserStampException,
SendFileId, SendId, User, UserId,
},
},
error::Error,
@@ -65,6 +69,7 @@ static JWT_2FA_REMEMBER_ISSUER: LazyLock<String> = LazyLock::new(|| format!("{}|
static PRIVATE_RSA_KEY: OnceLock<EncodingKey> = OnceLock::new();
static PUBLIC_RSA_KEY: OnceLock<DecodingKey> = OnceLock::new();
static PRELOGIN_KDF_KEY: OnceLock<hmac::Key> = OnceLock::new();
pub async fn initialize_keys() -> Result<(), Error> {
use std::io::Error as IoError;
@@ -99,9 +104,22 @@ pub async fn initialize_keys() -> Result<(), Error> {
if PUBLIC_RSA_KEY.set(dec).is_err() {
err!("PUBLIC_RSA_KEY must only be initialized once")
}
// Derived from the RSA key, so it stays the same across restarts without storing another secret
let prelogin_kdf_key = hmac::sign(&hmac::Key::new(hmac::HMAC_SHA256, &priv_key_buffer), b"prelogin-kdf");
if PRELOGIN_KDF_KEY.set(hmac::Key::new(hmac::HMAC_SHA256, prelogin_kdf_key.as_ref())).is_err() {
err!("PRELOGIN_KDF_KEY must only be initialized once")
}
Ok(())
}
/// Returns an index in `0..len` derived from the email, the same for every request
pub fn prelogin_kdf_index(email: &str, len: usize) -> usize {
let tag = hmac::sign(PRELOGIN_KDF_KEY.wait(), email.as_bytes());
let tag = tag.as_ref();
usize::from(u16::from_be_bytes([tag[0], tag[1]])) % len
}
pub fn encode_jwt<T: Serialize>(claims: &T) -> String {
match jsonwebtoken::encode(&JWT_HEADER, claims, PRIVATE_RSA_KEY.wait()) {
Ok(token) => token,
@@ -253,7 +271,7 @@ impl LoginJwtClaims {
premium: true,
name: user.name.clone(),
email: user.email.clone(),
email_verified: !CONFIG.mail_enabled() || user.verified_at.is_some(),
email_verified: user.verified(),
// ---
// Disabled these keys to be added to the JWT since they could cause the JWT to get too large
@@ -422,7 +440,7 @@ pub fn generate_file_download_claims(cipher_id: CipherId, file_id: AttachmentId)
let time_now = Utc::now();
FileDownloadClaims {
nbf: time_now.timestamp(),
exp: (time_now + TimeDelta::try_minutes(5).unwrap()).timestamp(),
exp: (time_now + TimeDelta::try_minutes(1).unwrap()).timestamp(),
iss: JWT_FILE_DOWNLOAD_ISSUER.to_string(),
sub: cipher_id,
file_id,
@@ -667,31 +685,12 @@ impl<'r> FromRequest<'r> for Headers {
};
if user.security_stamp != claims.sstamp {
if let Some(stamp_exception) =
user.stamp_exception.as_deref().and_then(|s| serde_json::from_str::<UserStampException>(s).ok())
{
let Some(current_route) = request.route().and_then(|r| r.name.as_deref()) else {
err_handler!("Error getting current route for stamp exception")
};
let Some(current_route) = request.route().and_then(|r| r.name.as_deref()) else {
err_handler!("Error getting current route for stamp exception")
};
// Check if the stamp exception has expired first.
// Then, check if the current route matches any of the allowed routes.
// After that check the stamp in exception matches the one in the claims.
if Utc::now().timestamp() > stamp_exception.expire {
// If the stamp exception has been expired remove it from the database.
// This prevents checking this stamp exception for new requests.
let mut user = user;
user.reset_stamp_exception();
if let Err(e) = user.save(&conn).await {
error!("Error updating user: {e:#?}");
}
err_handler!("Stamp exception is expired")
} else if !stamp_exception.routes.contains(&current_route.to_owned()) {
err_handler!("Invalid security stamp: Current route and exception route do not match")
} else if stamp_exception.security_stamp != claims.sstamp {
err_handler!("Invalid security stamp for matched stamp exception")
}
} else {
// 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")
}
}
@@ -1091,6 +1090,31 @@ fn ip_header_is_trusted(remote: Option<IpAddr>) -> bool {
trusted.split(',').filter_map(parse_trusted_proxy).any(|net| net.contains(&remote))
}
/// Each proxy appends the address it received the request from to a list header (`X-Forwarded-For: client, proxy1`).
/// Like ASP.NET's ForwardedHeaders, walk the entries of every occurrence of the header from the right and take the
/// first one that isn't a trusted proxy. If they all are, the leftmost one is the client.
fn client_ip_from_header(req: &Request<'_>, header: &str, is_trusted: impl Fn(IpAddr) -> bool) -> Option<IpAddr> {
let values: Vec<&str> = req.headers().get(header).collect();
client_ip_from_header_values(&values, header, is_trusted)
}
fn client_ip_from_header_values(values: &[&str], header: &str, is_trusted: impl Fn(IpAddr) -> bool) -> Option<IpAddr> {
let entries: Vec<&str> = values.iter().flat_map(|value| value.split(',')).map(str::trim).collect();
let mut client_ip = None;
for entry in entries.into_iter().rev() {
let Ok(ip) = entry.parse() else {
warn!("'{header}' header is malformed: {entry}");
break;
};
client_ip = Some(ip);
if !is_trusted(ip) {
break;
}
}
client_ip
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for ClientIp {
type Error = ();
@@ -1099,15 +1123,7 @@ impl<'r> FromRequest<'r> for ClientIp {
let remote = req.remote().map(|r| r.ip());
let ip = if CONFIG._ip_header_enabled() && ip_header_is_trusted(remote) {
req.headers().get_one(&CONFIG.ip_header()).and_then(|ip| {
match ip.find(',') {
Some(idx) => &ip[..idx],
None => ip,
}
.parse()
.map_err(|_| warn!("'{}' header is malformed: {ip}", CONFIG.ip_header()))
.ok()
})
client_ip_from_header(req, &CONFIG.ip_header(), |ip| ip_header_is_trusted(Some(ip)))
} else {
if CONFIG._ip_header_enabled() && req.headers().get_one(&CONFIG.ip_header()).is_some() {
// Log the canonical IP, which is what the user filter will need to match against
@@ -1226,7 +1242,7 @@ impl AuthMethod {
match scope {
None => err!("Missing scope"),
Some(scope) if scope == &method_scope => Ok(method_scope),
Some(scope) => err!(format!("Scope ({scope}) not supported")),
Some(scope) => err!(format!("Scope ({}) not supported", scope.escape_debug())),
}
}
}
@@ -1345,3 +1361,52 @@ pub async fn refresh_tokens(
Ok((device, auth_tokens))
}
#[cfg(test)]
mod tests {
use super::*;
fn client_ip(values: &[&str]) -> Option<IpAddr> {
let is_trusted = |ip: IpAddr| match ip {
IpAddr::V4(v4) => v4.is_private() || v4.is_loopback(),
IpAddr::V6(v6) => v6.is_loopback(),
};
client_ip_from_header_values(values, "X-Forwarded-For", is_trusted)
}
fn ip(s: &str) -> Option<IpAddr> {
Some(s.parse().unwrap())
}
#[test]
fn ip_header_single_value() {
assert_eq!(client_ip(&["203.0.113.5"]), ip("203.0.113.5"));
assert_eq!(client_ip(&["10.0.0.7"]), ip("10.0.0.7"));
assert_eq!(client_ip(&["2001:db8::1"]), ip("2001:db8::1"));
}
#[test]
fn ip_header_uses_rightmost_untrusted_entry() {
// Entries already in the request come before the one the proxy appended
assert_eq!(client_ip(&["198.51.100.1, 203.0.113.5"]), ip("203.0.113.5"));
// Trusted proxies on the right are skipped
assert_eq!(client_ip(&["198.51.100.1, 203.0.113.5, 10.0.0.2, 127.0.0.1"]), ip("203.0.113.5"));
// All trusted, so the leftmost entry is the client
assert_eq!(client_ip(&["192.168.1.10, 10.0.0.2"]), ip("192.168.1.10"));
}
#[test]
fn ip_header_reads_every_occurrence() {
assert_eq!(client_ip(&["198.51.100.1", "203.0.113.5"]), ip("203.0.113.5"));
assert_eq!(client_ip(&["198.51.100.1", "203.0.113.5, 10.0.0.2"]), ip("203.0.113.5"));
}
#[test]
fn ip_header_malformed_entries() {
assert_eq!(client_ip(&[]), None);
assert_eq!(client_ip(&[""]), None);
assert_eq!(client_ip(&["203.0.113.5, not-an-ip"]), None);
// Stops at the malformed entry and keeps the closest proxy
assert_eq!(client_ip(&["not-an-ip, 10.0.0.2"]), ip("10.0.0.2"));
}
}
+1 -5
View File
@@ -75,7 +75,7 @@ impl SendTokens {
return Self::invalid_error(&format!("Can't convert {access_id}"), "send_id_invalid", false);
};
let Some(mut send) = Send::find_by_uuid(&send_id, conn).await else {
let Some(send) = Send::find_by_uuid(&send_id, conn).await else {
return Self::invalid_error(&format!("Can't find {send_id}"), "send_id_invalid", false);
};
@@ -103,10 +103,6 @@ impl SendTokens {
}
}
if !send.register_access(conn).await? {
return Self::invalid_error(&format!("Send {send_id}, max access reached"), "send_id_invalid", true);
}
Ok(Self {
access_claims: generate_send_access_claims(&send_id),
})
+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}");
}
}
}
}
+22 -46
View File
@@ -561,7 +561,7 @@ make_config! {
/// Auth Request cleanup schedule |> Cron schedule of the job that cleans old auth requests from the auth request.
/// Defaults to every minute. Set blank to disable this job.
auth_request_purge_schedule: String, false, def, "30 * * * * *".to_owned();
/// Duo Auth context cleanup schedule |> Cron schedule of the job that cleans expired Duo contexts from the database. Does nothing if Duo MFA is disabled or set to use the legacy iframe prompt.
/// Duo Auth context cleanup schedule |> Cron schedule of the job that cleans expired Duo contexts from the database. Does nothing if Duo MFA is disabled.
/// Defaults to once every minute. Set blank to disable this job.
duo_context_purge_schedule: String, false, def, "30 * * * * *".to_owned();
/// Purge incomplete SSO auth. |> Cron schedule of the job that cleans leftover auth in db due to incomplete SSO login.
@@ -675,7 +675,8 @@ make_config! {
/// other address use the remote IP instead, so a client can't spoof the header.
/// Either the string "local" (the default, any non-global address, which covers a reverse proxy
/// running on the same host or container network), the string "all" to accept it from anywhere,
/// or a comma separated list of IPs and CIDR ranges.
/// or a comma separated list of IPs and CIDR ranges. For a list header like X-Forwarded-For, the
/// client IP is the rightmost address that isn't a trusted proxy, so list every proxy in the chain.
ip_header_trusted_proxies: String, true, def, "local".to_owned();
/// Icon service |> The predefined icon services are: internal, bitwarden, duckduckgo, google.
/// To specify a custom icon service, set a URL template with exactly one instance of `{}`,
@@ -865,16 +866,12 @@ make_config! {
duo: _enable_duo {
/// Enabled
_enable_duo: bool, true, def, true;
/// Attempt to use deprecated iframe-based Traditional Prompt (Duo WebSDK 2)
duo_use_iframe: bool, false, def, false;
/// Client Id
duo_ikey: String, true, option;
/// Client Secret
duo_skey: Pass, true, option;
/// Host
duo_host: String, true, option;
/// Application Key (generated automatically)
_duo_akey: Pass, false, option;
},
/// SMTP Email Settings
@@ -1269,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") => {
@@ -1418,28 +1423,24 @@ pub enum PathType {
// Android (v2026.2.1): https://github.com/bitwarden/android/blob/6902c19c0093fa476bbf74ccaa70c9f14afbb82f/core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt#L31
// iOS (v2026.2.1): https://github.com/bitwarden/ios/blob/cdd9ba1770ca2ffc098d02d12cc3208e3a830454/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift#L7
pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[
// Architecture
"desktop-ui-migration-milestone-1",
"desktop-ui-migration-milestone-2",
"desktop-ui-migration-milestone-3",
"desktop-ui-migration-milestone-4",
// Auth Team
"pm-5594-safari-account-switching",
"pm-32413-multi-client-password-management",
// Autofill Team
"undetermined-cipher-scenario-logic",
"enable-basic-auth-response",
"ssh-agent",
"ssh-agent-v2",
"windows-desktop-autotype",
"windows-desktop-autotype-ga",
// Key Management Team
"ssh-key-vault-item",
"pm-25373-windows-biometrics-v2",
"pm-26340-linux-biometrics-v2",
"biometrics-sdk-ipc",
"windows-native-credential-sync",
"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
"anon-addy-self-host-alias",
"simple-login-self-host-alias",
"mutual-tls",
"cxp-import-mobile",
"cxp-export-mobile",
"pm-34171-card-scanner",
// Platform Team
"pm-30529-webauthn-related-origins",
@@ -1516,15 +1517,6 @@ impl Config {
Ok(())
}
async fn update_config_partial(&self, other: ConfigBuilder) -> Result<(), Error> {
let builder = {
let usr = &self.inner.read().unwrap()._usr;
let mut overrides = Vec::new();
usr.merge(&other, false, &mut overrides)
};
self.update_config(builder, false).await
}
/// Tests whether an email's domain is allowed. A domain is allowed if it
/// is in signups_domains_whitelist, or if no whitelist is set (so there
/// are no domain restrictions in effect).
@@ -1620,23 +1612,6 @@ impl Config {
inner._enable_smtp && (inner.smtp_host.is_some() || inner.use_sendmail)
}
pub async fn get_duo_akey(&self) -> String {
if let Some(akey) = self._duo_akey() {
akey
} else {
let akey_s = crate::crypto::encode_random_bytes::<64>(&data_encoding::BASE64);
// Save the new value
let builder = ConfigBuilder {
_duo_akey: Some(akey_s.clone()),
..Default::default()
};
self.update_config_partial(builder).await.ok();
akey_s
}
}
pub fn is_webauthn_2fa_supported(&self) -> bool {
Url::parse(&self.domain()).expect("DOMAIN not a valid URL").domain().is_some()
}
@@ -1768,6 +1743,7 @@ where
reg!("email/protected_action", ".html");
reg!("email/pw_hint_none", ".html");
reg!("email/pw_hint_some", ".html");
reg!("email/recover_twofactor", ".html");
reg!("email/register_verify_email", ".html");
reg!("email/send_2fa_removed_from_org", ".html");
reg!("email/send_emergency_access_invite", ".html");
+1 -1
View File
@@ -61,7 +61,7 @@ impl Attachment {
let token = encode_jwt(&generate_file_download_claims(self.cipher_uuid.clone(), self.id.clone()));
Ok(format!("{host}/attachments/{}/{}?token={token}", self.cipher_uuid, self.id))
} else {
Ok(operator.presign_read(&self.get_file_path(), Duration::from_mins(5)).await?.uri().to_string())
Ok(operator.presign_read(&self.get_file_path(), Duration::from_mins(1)).await?.uri().to_string())
}
}
+16 -1
View File
@@ -4,7 +4,7 @@ use diesel::prelude::*;
use serde_json::Value;
use crate::{
api::EmptyResult,
api::{ApiResult, EmptyResult},
crypto::ct_eq,
db::{DbConn, schema::auth_requests},
error::MapResult,
@@ -166,6 +166,21 @@ impl AuthRequest {
.await
}
/// Marks the request as used to log in. Returns false when it already was, so it only works once.
pub async fn set_authentication_date(uuid: &AuthRequestId, conn: &DbConn) -> ApiResult<bool> {
let now = Utc::now().naive_utc();
conn.run(move |conn| {
diesel::update(auth_requests::table)
.filter(auth_requests::uuid.eq(uuid))
.filter(auth_requests::authentication_date.is_null())
.set(auth_requests::authentication_date.eq(now))
.execute(conn)
.map(|updated| updated == 1)
.map_res("Error updating auth request")
})
.await
}
pub fn check_access_code(&self, access_code: &str) -> bool {
ct_eq(&self.access_code, access_code)
}
+109 -61
View File
@@ -19,7 +19,7 @@ use crate::{
},
},
error::MapResult,
util::LowerCase,
util::{LowerCase, convert_json_key_lcase_first},
};
use macros::UuidFromParam;
@@ -63,6 +63,18 @@ pub struct Cipher {
pub reprompt: Option<i32>,
}
/// Whether `data` is a v2 cipher blob, recognized like upstream by a top-level `format_version` key.
///
/// 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))
}
/// [`is_data_blob_encrypted`] for `data` that was already parsed.
fn is_blob_value(data: &Value) -> bool {
data.get("format_version").is_some()
}
pub enum RepromptType {
None = 0,
Password = 1,
@@ -70,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 {
@@ -84,7 +97,7 @@ impl Cipher {
key: None,
atype,
name,
name: String::new(),
notes: None,
fields: None,
@@ -110,6 +123,11 @@ impl Cipher {
.insert(format!("Ciphers[{index}].Notes"), serde_json::to_value([&max_note_size_msg]).unwrap());
}
if let Err(e) = cipher.validate_content(cipher.is_blob()) {
validation_errors
.insert(format!("Ciphers[{index}].{}", e.field), serde_json::to_value([e.message]).unwrap());
}
// Validate the password history if it contains `null` values and if so, return a warning
if let Some(Value::Array(password_history)) = &cipher.password_history {
for pwh in password_history {
@@ -154,6 +172,10 @@ impl Cipher {
) -> Result<Value, crate::Error> {
use crate::util::{format_date, validate_and_format_date};
// 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;
if let Some(cipher_sync_data) = cipher_sync_data {
if let Some(attachments) = cipher_sync_data.cipher_attachments.get(&self.uuid)
@@ -250,63 +272,9 @@ impl Cipher {
})
.unwrap_or_default();
// 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 = serde_json::from_str::<LowerCase<Value>>(&self.data)
.inspect_err(|_| warn!("Error parsing data field for {}", self.uuid))
.map_or_else(|_| Value::Object(serde_json::Map::new()), |d| d.data);
// 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.
if self.atype == 1 {
// Upstream always has an `uri` key/value
type_data_json["uri"] = Value::Null;
if let Some(uris) = type_data_json["uris"].as_array_mut()
&& !uris.is_empty()
{
// Fix uri match values first, they are only allowed to be a number or null
// If it is a string, convert it to an int or null if that fails
for uri in &mut *uris {
if uri["match"].is_string() {
let match_value = match uri["match"].as_str().unwrap_or_default().parse::<u8>() {
Ok(n) => json!(n),
_ => Value::Null,
};
uri["match"] = match_value;
}
}
type_data_json["uri"] = uris[0]["uri"].clone();
}
// Check if `passwordRevisionDate` is a valid date, else convert it
if let Some(pw_revision) = type_data_json["passwordRevisionDate"].as_str() {
type_data_json["passwordRevisionDate"] = json!(validate_and_format_date(pw_revision));
}
}
// Fix secure note issues when data is invalid
// This breaks at least the native mobile clients
if self.atype == 2 {
match type_data_json {
Value::Object(ref t) if t.get("type").is_some_and(Value::is_number) => {}
_ => {
type_data_json = json!({"type": 0});
}
}
}
// Fix invalid SSH Entries
// This breaks at least the native mobile client if invalid
// The only way to fix this is by setting type_data_json to `null`
// Opening this ssh-key in the mobile client will probably crash the client, but you can edit, save and afterwards delete it
if self.atype == 5
&& (type_data_json["keyFingerprint"].as_str().is_none_or(str::is_empty)
|| type_data_json["privateKey"].as_str().is_none_or(str::is_empty)
|| type_data_json["publicKey"].as_str().is_none_or(str::is_empty))
{
warn!("Error parsing ssh-key, mandatory fields are invalid for {}", self.uuid);
type_data_json = Value::Null;
}
// 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) {
@@ -403,10 +371,84 @@ impl Cipher {
_ => err!(format!("Cipher {} has an invalid type {}", self.uuid, self.atype)),
};
json_object[key] = type_data_json;
if is_blob_encrypted {
// Like upstream, sent as-is with the structured fields null
json_object["data"] = json!(self.data);
json_object["name"] = Value::Null;
} else {
json_object[key] = self.legacy_type_data_json(type_data);
}
Ok(json_object)
}
/// 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 = 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.
if self.atype == 1 {
// Upstream always has an `uri` key/value
type_data_json["uri"] = Value::Null;
if let Some(uris) = type_data_json["uris"].as_array_mut()
&& !uris.is_empty()
{
// Fix uri match values first, they are only allowed to be a number or null
// If it is a string, convert it to an int or null if that fails
for uri in &mut *uris {
if uri["match"].is_string() {
let match_value = match uri["match"].as_str().unwrap_or_default().parse::<u8>() {
Ok(n) => json!(n),
_ => Value::Null,
};
uri["match"] = match_value;
}
}
type_data_json["uri"] = uris[0]["uri"].clone();
}
// Check if `passwordRevisionDate` is a valid date, else convert it
if let Some(pw_revision) = type_data_json["passwordRevisionDate"].as_str() {
type_data_json["passwordRevisionDate"] = json!(validate_and_format_date(pw_revision));
}
}
// Fix secure note issues when data is invalid
// This breaks at least the native mobile clients
if self.atype == 2 {
match type_data_json {
Value::Object(ref t) if t.get("type").is_some_and(Value::is_number) => {}
_ => {
type_data_json = json!({"type": 0});
}
}
}
// Fix invalid SSH Entries
// This breaks at least the native mobile client if invalid
// The only way to fix this is by setting type_data_json to `null`
// Opening this ssh-key in the mobile client will probably crash the client, but you can edit, save and afterwards delete it
if self.atype == 5
&& (type_data_json["keyFingerprint"].as_str().is_none_or(str::is_empty)
|| type_data_json["privateKey"].as_str().is_none_or(str::is_empty)
|| type_data_json["publicKey"].as_str().is_none_or(str::is_empty))
{
warn!("Error parsing ssh-key, mandatory fields are invalid for {}", self.uuid);
type_data_json = Value::Null;
}
type_data_json
}
pub async fn update_users_revision(&self, conn: &DbConn) -> Vec<UserId> {
let mut user_uuids = Vec::new();
match self.user_uuid {
@@ -711,6 +753,7 @@ impl Cipher {
// Only allow group access via a confirmed membership, since
// groups_users rows are kept when a membership is revoked.
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(ciphers::organization_uuid.eq(users_organizations::org_uuid.nullable()))
.select((collections_groups::read_only, collections_groups::hide_passwords, collections_groups::manage))
.load::<(bool, bool, bool)>(conn)
.expect("Error getting group access restrictions")
@@ -1003,6 +1046,7 @@ impl Cipher {
.eq(ciphers_collections::collection_uuid)
.and(collections_groups::groups_uuid.eq(groups::uuid))),
)
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(
users_organizations::access_all
.eq(true) // User has access all
@@ -1034,6 +1078,7 @@ impl Cipher {
.eq(ciphers_collections::collection_uuid)
.and(users_collections::user_uuid.eq(user_uuid.clone()))),
)
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(
users_organizations::access_all
.eq(true) // User has access all
@@ -1078,6 +1123,7 @@ impl Cipher {
.eq(ciphers_collections::collection_uuid)
.and(collections_groups::groups_uuid.eq(groups::uuid))),
)
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(
users_organizations::access_all
.eq(true) // User has access all
@@ -1110,6 +1156,7 @@ impl Cipher {
.eq(ciphers_collections::collection_uuid)
.and(users_collections::user_uuid.eq(user_uuid.clone()))),
)
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(
users_organizations::access_all
.eq(true) // User has access all
@@ -1161,6 +1208,7 @@ impl Cipher {
.or_filter(users_organizations::atype.le(MembershipType::Admin as i32)) // User is admin or owner
.or_filter(groups::access_all.eq(true)) //Access via group
.or_filter(collections_groups::collections_uuid.is_not_null()) //Access via group
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.select(ciphers_collections::all_columns)
.distinct()
.load::<(CipherId, CollectionId)>(conn)
+17 -2
View File
@@ -367,6 +367,7 @@ impl Collection {
.and(collections_groups::collections_uuid.eq(collections::uuid))),
)
.filter(collections::uuid.eq(uuid))
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(
users_collections::collection_uuid
.eq(uuid)
@@ -406,6 +407,7 @@ impl Collection {
.and(users_organizations::user_uuid.eq(user_uuid))),
)
.filter(collections::uuid.eq(uuid))
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(users_collections::collection_uuid.eq(uuid).or(
// Directly accessed collection
users_organizations::access_all.eq(true).or(
@@ -430,7 +432,8 @@ impl Collection {
.inner_join(
users_organizations::table.on(collections::org_uuid
.eq(users_organizations::org_uuid)
.and(users_organizations::user_uuid.eq(user_uuid.clone()))),
.and(users_organizations::user_uuid.eq(user_uuid.clone()))
.and(users_organizations::status.eq(MembershipStatus::Confirmed as i32))),
)
.left_join(
users_collections::table.on(users_collections::collection_uuid
@@ -476,7 +479,8 @@ impl Collection {
.inner_join(
users_organizations::table.on(collections::org_uuid
.eq(users_organizations::org_uuid)
.and(users_organizations::user_uuid.eq(user_uuid.clone()))),
.and(users_organizations::user_uuid.eq(user_uuid.clone()))
.and(users_organizations::status.eq(MembershipStatus::Confirmed as i32))),
)
.left_join(
users_collections::table.on(users_collections::collection_uuid
@@ -527,6 +531,7 @@ impl Collection {
.and(collections_groups::collections_uuid.eq(collections::uuid))),
)
.filter(collections::uuid.eq(&self.uuid))
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(
users_collections::collection_uuid
.eq(&self.uuid)
@@ -586,6 +591,7 @@ impl Collection {
.and(collections_groups::collections_uuid.eq(collections::uuid))),
)
.filter(collections::uuid.eq(&uuid))
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(
users_collections::collection_uuid
.eq(&uuid)
@@ -650,6 +656,7 @@ impl Collection {
.and(collections_groups::collections_uuid.eq(collections::uuid))),
)
.filter(collections::org_uuid.eq(&org_uuid))
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(
// Manage permission on a collection assigned directly or via a group.
users_collections::manage.eq(true).or(collections_groups::manage.eq(true)),
@@ -819,10 +826,18 @@ impl CollectionUser {
.await
}
// Only returns the collections of organizations the user is a confirmed member of
pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
conn.run(move |conn| {
users_collections::table
.inner_join(collections::table.on(collections::uuid.eq(users_collections::collection_uuid)))
.inner_join(
users_organizations::table.on(users_organizations::org_uuid
.eq(collections::org_uuid)
.and(users_organizations::user_uuid.eq(users_collections::user_uuid))),
)
.filter(users_collections::user_uuid.eq(user_uuid))
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.select(users_collections::all_columns)
.load::<Self>(conn)
.expect("Error loading users_collections")
+2 -21
View File
@@ -104,7 +104,6 @@ impl Group {
"id": self.uuid,
"organizationId": self.organizations_uuid,
"name": self.name,
"accessAll": self.access_all,
"externalId": self.external_id,
"collections": collections_groups,
"object": "groupDetails"
@@ -249,6 +248,7 @@ impl Group {
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))),
)
.filter(users_organizations::user_uuid.eq(user_uuid))
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(groups::access_all.eq(true))
.select(groups::organizations_uuid)
.distinct()
@@ -387,6 +387,7 @@ impl CollectionGroup {
.and(collections::org_uuid.eq(groups::organizations_uuid))),
)
.filter(users_organizations::user_uuid.eq(user_uuid))
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.select(collections_groups::all_columns)
.load::<Self>(conn)
.expect("Error loading user collection groups")
@@ -572,26 +573,6 @@ impl GroupUser {
}
}
pub async fn delete_by_group_and_member(
group_uuid: &GroupId,
member_uuid: &MembershipId,
conn: &DbConn,
) -> EmptyResult {
match Membership::find_by_uuid(member_uuid, conn).await {
Some(member) => User::update_uuid_revision(&member.user_uuid, conn).await,
None => warn!("Member could not be found!"),
}
conn.run(move |conn| {
diesel::delete(groups_users::table)
.filter(groups_users::groups_uuid.eq(group_uuid))
.filter(groups_users::users_organizations_uuid.eq(member_uuid))
.execute(conn)
.map_res("Error deleting group users")
})
.await
}
pub async fn delete_all_by_group(group_uuid: &GroupId, org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult {
let group_users = GroupUser::find_by_group(group_uuid, org_uuid, conn).await;
for group_user in group_users {
+4 -2
View File
@@ -17,11 +17,12 @@ mod two_factor;
mod two_factor_duo_context;
mod two_factor_incomplete;
mod user;
mod user_signature_key_pair;
pub use self::archive::Archive;
pub use self::attachment::{Attachment, AttachmentId};
pub use self::auth_request::{AuthRequest, AuthRequestId};
pub use self::cipher::{Cipher, CipherId, RepromptType};
pub use self::cipher::{Cipher, CipherId, RepromptType, is_data_blob_encrypted};
pub use self::collection::{Collection, CollectionCipher, CollectionId, CollectionUser};
pub use self::device::{Device, DeviceId, DeviceType, DeviceWithAuthRequest, PushId};
pub use self::emergency_access::{EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType};
@@ -39,4 +40,5 @@ pub use self::sso_auth::{OIDCAuthenticatedUser, OIDCCodeResponseError, SsoAuth};
pub use self::two_factor::{TwoFactor, TwoFactorType};
pub use self::two_factor_duo_context::TwoFactorDuoContext;
pub use self::two_factor_incomplete::TwoFactorIncomplete;
pub use self::user::{Invitation, KeyId, SsoUser, User, UserId, UserKdfType, UserStampException};
pub use self::user::{Invitation, KeyId, SsoUser, User, UserId, UserKdfType};
pub use self::user_signature_key_pair::{SignatureAlgorithm, UserSignatureKeyPair};
+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")
}
+29 -64
View File
@@ -1,7 +1,4 @@
use std::{
cmp::Ordering,
collections::{HashMap, HashSet},
};
use std::cmp::Ordering;
use chrono::{NaiveDateTime, Utc};
use derive_more::{AsRef, Deref, Display, From};
@@ -24,8 +21,8 @@ use crate::{
use macros::UuidFromParam;
use super::{
Cipher, CipherId, Collection, CollectionGroup, CollectionId, CollectionUser, Group, GroupId, GroupUser, OrgPolicy,
OrgPolicyType, TwoFactor, User, UserId,
Cipher, CipherId, Collection, CollectionId, CollectionUser, Group, GroupId, GroupUser, OrgPolicy, OrgPolicyType,
TwoFactor, User, UserId,
};
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
@@ -57,6 +54,8 @@ pub struct Membership {
pub atype: i32,
pub reset_password_key: Option<String>,
pub external_id: Option<String>,
/// 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>,
}
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
@@ -274,6 +273,7 @@ impl Membership {
atype: MembershipType::User as i32,
reset_password_key: None,
external_id: None,
v2_upgrade_token: None,
}
}
@@ -345,7 +345,7 @@ impl OrganizationApiKey {
impl Organization {
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
if !crate::util::is_valid_email(&self.billing_email) {
err!(format!("BillingEmail {} is not a valid email address", self.billing_email))
err!(format!("BillingEmail {} is not a valid email address", self.billing_email.escape_debug()))
}
for member in &Membership::find_by_org(&self.uuid, conn).await {
@@ -518,8 +518,7 @@ impl Membership {
"limitCollectionDeletion": true,
"limitItemDeletion": false,
"allowAdminAccessToAllCollectionItems": true,
"userIsManagedByOrganization": false, // Means not managed via the Members UI, like SSO
"userIsClaimedByOrganization": false, // The new key instead of the obsolete userIsManagedByOrganization
"userIsClaimedByOrganization": false, // Means not managed via the Members UI, like SSO
"permissions": permissions,
@@ -536,7 +535,13 @@ impl Membership {
})
}
pub async fn to_json_user_details(&self, include_collections: bool, include_groups: bool, conn: &DbConn) -> Value {
// Used by admins to view other members, so nothing here may depend on this member's status
pub async fn to_json_details_for_admin(
&self,
include_collections: bool,
include_groups: bool,
conn: &DbConn,
) -> Value {
let user = User::find_by_uuid(&self.user_uuid, conn).await.unwrap();
// Because BitWarden want the status to be -1 for revoked users we need to catch that here.
@@ -559,52 +564,23 @@ impl Membership {
// Check if a user is in a group which has access to all collections
// If that is the case, we should not return individual collections!
// This is used by admins to view other members, so it must not depend on the membership status
let full_access_group =
CONFIG.org_groups_enabled() && Group::is_in_full_access_group(&self.user_uuid, &self.org_uuid, conn).await;
CONFIG.org_groups_enabled() && GroupUser::has_full_access_by_member(&self.org_uuid, &self.uuid, conn).await;
// If collections are to be included, only include them if the user does not have full access via a group or defined to the user it self
// Only the collections assigned directly are returned, the ones assigned via a group are returned via a special group endpoint
let collections: Vec<Value> = if include_collections && !(full_access_group || self.access_all) {
// Get all collections for the user here already to prevent more queries
let cu: HashMap<CollectionId, CollectionUser> =
CollectionUser::find_by_organization_and_user_uuid(&self.org_uuid, &self.user_uuid, conn)
.await
.into_iter()
.map(|cu| (cu.collection_uuid.clone(), cu))
.collect();
// Get all collection groups for this user to prevent there inclusion
let cg: HashSet<CollectionId> = CollectionGroup::find_by_user(&self.user_uuid, conn)
CollectionUser::find_by_organization_and_user_uuid(&self.org_uuid, &self.user_uuid, conn)
.await
.into_iter()
.map(|cg| cg.collections_uuid)
.collect();
Collection::find_by_organization_and_user_uuid(&self.org_uuid, &self.user_uuid, conn)
.await
.into_iter()
.filter_map(|c| {
let (read_only, hide_passwords, manage) = if self.has_full_access() {
(false, false, self.atype >= MembershipType::Manager)
} else if let Some(cu) = cu.get(&c.uuid) {
(
cu.read_only,
cu.hide_passwords,
cu.manage || (self.atype == MembershipType::Manager && !cu.read_only && !cu.hide_passwords),
)
// If previous checks failed it might be that this user has access via a group, but we should not return those elements here
// Those are returned via a special group endpoint
} else if cg.contains(&c.uuid) {
return None;
} else {
(true, true, false)
};
Some(json!({
"id": c.uuid,
"readOnly": read_only,
"hidePasswords": hide_passwords,
"manage": manage,
}))
.map(|cu| {
json!({
"id": cu.collection_uuid,
"readOnly": cu.read_only,
"hidePasswords": cu.hide_passwords,
"manage": cu.manage || (self.atype == MembershipType::Manager && !cu.read_only && !cu.hide_passwords),
})
})
.collect()
} else {
@@ -652,7 +628,6 @@ impl Membership {
"status": status,
"type": membership_type,
"accessAll": self.access_all,
"twoFactorEnabled": twofactor_enabled,
"resetPasswordEnrolled": self.reset_password_key.is_some(),
"hasMasterPassword": !user.password_hash.is_empty(),
@@ -660,7 +635,6 @@ impl Membership {
"permissions": permissions,
"ssoBound": false, // Not supported
"managedByOrganization": false, // This key is obsolete replaced by claimedByOrganization
"claimedByOrganization": false, // Means not managed via the Members UI, like SSO
"usesKeyConnector": false, // Not supported
"accessSecretsManager": false, // Not supported (Not AGPLv3 Licensed)
@@ -711,7 +685,6 @@ impl Membership {
"status": status,
"type": self.atype,
"accessAll": self.access_all,
"collections": coll_uuids,
"object": "organizationUserDetails",
@@ -1032,17 +1005,6 @@ impl Membership {
.await
}
pub async fn get_orgs_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<OrganizationId> {
conn.run(move |conn| {
users_organizations::table
.filter(users_organizations::user_uuid.eq(user_uuid))
.select(users_organizations::org_uuid)
.load::<OrganizationId>(conn)
.unwrap_or_default()
})
.await
}
pub async fn find_by_user_and_policy(user_uuid: &UserId, policy_type: OrgPolicyType, conn: &DbConn) -> Vec<Self> {
conn.run(move |conn| {
users_organizations::table
@@ -1065,6 +1027,7 @@ impl Membership {
conn.run(move |conn| {
users_organizations::table
.filter(users_organizations::org_uuid.eq(org_uuid))
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.left_join(users_collections::table.on(users_collections::user_uuid.eq(users_organizations::user_uuid)))
.left_join(
ciphers_collections::table.on(ciphers_collections::collection_uuid
@@ -1091,6 +1054,7 @@ impl Membership {
conn.run(move |conn| {
users_organizations::table
.filter(users_organizations::org_uuid.eq(org_uuid))
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.inner_join(
groups_users::table.on(groups_users::users_organizations_uuid.eq(users_organizations::uuid)),
)
@@ -1126,6 +1090,7 @@ impl Membership {
.and(ciphers::organization_uuid.eq(users_organizations::org_uuid.nullable()))),
)
.filter(users_organizations::user_uuid.eq(user_uuid))
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(
users_organizations::atype.eq_any(vec![MembershipType::Owner as i32, MembershipType::Admin as i32]),
)
+7 -23
View File
@@ -140,6 +140,10 @@ impl Send {
None
}
fn access_id(&self) -> String {
BASE64URL_NOPAD.encode(Uuid::parse_str(&self.uuid).unwrap_or_default().as_bytes())
}
pub fn to_json(&self) -> Value {
let mut data = serde_json::from_str::<LowerCase<Value>>(&self.data).map(|d| d.data).unwrap_or_default();
@@ -150,7 +154,7 @@ impl Send {
json!({
"id": self.uuid,
"accessId": BASE64URL_NOPAD.encode(Uuid::parse_str(&self.uuid).unwrap_or_default().as_bytes()),
"accessId": self.access_id(),
"type": self.atype,
"name": self.name,
@@ -182,7 +186,7 @@ impl Send {
}
json!({
"id": self.uuid,
"id": self.access_id(),
"type": self.atype,
"name": self.name,
@@ -256,7 +260,7 @@ impl Send {
/// Whether the Send is currently within its validity window: not disabled, not past its
/// expiration date, and not past its deletion date. Does not consider `max_access_count`
/// (consumed at token issuance) or the password.
/// (counted on each access) or the password.
pub fn is_accessible(&self) -> bool {
let now = Utc::now().naive_utc();
if self.disabled {
@@ -309,19 +313,6 @@ impl Send {
Ok(())
}
pub async fn find_by_access_id(access_id: &str, conn: &DbConn) -> Option<Self> {
let Ok(uuid_vec) = BASE64URL_NOPAD.decode(access_id.as_bytes()) else {
return None;
};
let uuid = match Uuid::from_slice(&uuid_vec) {
Ok(u) => SendId::from(u.to_string()),
Err(_) => return None,
};
Self::find_by_uuid(&uuid, conn).await
}
pub async fn find_by_uuid(uuid: &SendId, conn: &DbConn) -> Option<Self> {
conn.run(move |conn| sends::table.filter(sends::uuid.eq(uuid)).first::<Self>(conn).ok()).await
}
@@ -361,13 +352,6 @@ impl Send {
Some(total)
}
pub async fn find_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> Vec<Self> {
conn.run(move |conn| {
sends::table.filter(sends::organization_uuid.eq(org_uuid)).load::<Self>(conn).expect("Error loading sends")
})
.await
}
pub async fn find_by_past_deletion_date(conn: &DbConn) -> Vec<Self> {
let now = Utc::now().naive_utc();
conn.run(move |conn| {
+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()
})
+147 -26
View File
@@ -6,6 +6,7 @@ use serde_json::Value;
use crate::{
CONFIG,
api::EmptyResult,
auth::DEFAULT_ACCESS_VALIDITY,
crypto,
db::{
DbConn,
@@ -20,6 +21,7 @@ use macros::UuidFromParam;
use super::{
Cipher, Device, EmergencyAccess, Favorite, Folder, Membership, MembershipType, TwoFactor, TwoFactorIncomplete,
UserSignatureKeyPair,
};
#[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)]
@@ -71,6 +73,13 @@ pub struct User {
pub external_id: Option<String>, // Todo: Needs to be removed in the future, this is not used anymore.
pub key_id: Option<KeyId>,
// 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"}` for sessions that still hold the v1 user key. Opaque to us.
pub v2_upgrade_token: Option<String>,
}
#[derive(Identifiable, Queryable, Insertable)]
@@ -99,13 +108,28 @@ enum UserStatus {
_Disabled = 2,
}
/// A previous security stamp that is still accepted, until `expire`.
#[derive(Serialize, Deserialize)]
pub struct UserStampException {
pub routes: Vec<String>,
/// The routes the stamp is still accepted on, or `None` for any route.
pub routes: Option<Vec<String>>,
pub security_stamp: String,
pub expire: i64,
}
impl UserStampException {
pub fn is_expired(&self) -> bool {
Utc::now().timestamp() > self.expire
}
/// Whether this exception lets a token carrying `security_stamp` through on `route`.
pub fn allows(&self, security_stamp: &str, route: &str) -> bool {
!self.is_expired()
&& self.security_stamp == security_stamp
&& self.routes.as_ref().is_none_or(|routes| routes.iter().any(|r| r == route))
}
}
/// Local methods
impl User {
pub const CLIENT_KDF_TYPE_DEFAULT: i32 = UserKdfType::Pbkdf2 as i32;
@@ -158,9 +182,18 @@ impl User {
external_id: None, // Todo: Needs to be removed in the future, this is not used anymore.
key_id: None,
signed_public_key: None,
security_state: None,
security_version: None,
v2_upgrade_token: None,
}
}
pub fn is_v2(&self) -> bool {
self.signed_public_key.is_some() && self.security_state.is_some() && self.security_version.is_some()
}
pub fn check_valid_password(&self, password: &str) -> bool {
crypto::verify_password_hash(
password.as_bytes(),
@@ -170,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())
@@ -220,10 +258,33 @@ impl User {
pub async fn reset_security_stamp(&mut self, conn: &DbConn) -> EmptyResult {
self.security_stamp = get_uuid();
// 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);
Device::rotate_refresh_tokens_by_user(&self.uuid, conn).await?;
Ok(())
}
/// 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> =
self.stamp_exceptions().into_iter().filter(|e| e.routes.is_none() && !e.is_expired()).collect();
self.reset_security_stamp(conn).await?;
grace.push(UserStampException {
routes: None,
security_stamp: previous_stamp,
expire: (Utc::now() + *DEFAULT_ACCESS_VALIDITY).timestamp(),
});
let mut exceptions = self.stamp_exceptions();
exceptions.extend(grace);
self.set_stamp_exceptions(&exceptions);
Ok(())
}
/// Set the stamp_exception to only allow a subsequent request matching a specific route using the current security-stamp.
///
/// # Arguments
@@ -232,17 +293,30 @@ impl User {
/// After these 2 minutes this stamp will expire.
///
pub fn set_stamp_exception(&mut self, route_exception: Vec<String>) {
let stamp_exception = UserStampException {
routes: route_exception,
self.set_stamp_exceptions(&[UserStampException {
routes: Some(route_exception),
security_stamp: self.security_stamp.clone(),
expire: (Utc::now() + TimeDelta::try_minutes(2).unwrap()).timestamp(),
};
self.stamp_exception = Some(serde_json::to_string(&stamp_exception).unwrap_or_default());
}]);
}
/// Resets the stamp_exception to prevent re-use of the previous security-stamp
pub fn reset_stamp_exception(&mut self) {
self.stamp_exception = None;
/// The previous security stamps that are still accepted, expired ones included.
pub fn stamp_exceptions(&self) -> Vec<UserStampException> {
let Some(stored) = self.stamp_exception.as_deref() else {
return Vec::new();
};
// Before key rotations kept sessions alive, only a single route exception was stored.
serde_json::from_str::<Vec<UserStampException>>(stored)
.or_else(|_| serde_json::from_str::<UserStampException>(stored).map(|e| vec![e]))
.unwrap_or_default()
}
fn set_stamp_exceptions(&mut self, exceptions: &[UserStampException]) {
self.stamp_exception = if exceptions.is_empty() {
None
} else {
Some(serde_json::to_string(&exceptions).unwrap_or_default())
};
}
pub fn display_name(&self) -> &str {
@@ -257,6 +331,66 @@ impl User {
/// Database methods
impl User {
pub fn verified(&self) -> bool {
!CONFIG.mail_enabled() || self.verified_at.is_some()
}
async fn v2_signature_key_pair(&self, conn: &DbConn) -> Option<UserSignatureKeyPair> {
if !self.is_v2() {
return None;
}
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_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
}
}
pub async fn public_keys_json(&self, conn: &DbConn) -> Value {
let (signed_public_key, verifying_key) = match self.v2_signature_key_pair(conn).await {
Some(key_pair) => (json!(self.signed_public_key), json!(key_pair.verifying_key)),
None => (Value::Null, Value::Null),
};
json!({
"publicKey": self.public_key,
"signedPublicKey": signed_public_key,
"verifyingKey": verifying_key,
"object": "publicKeys"
})
}
pub fn v2_upgrade_token_json(&self) -> Option<Value> {
self.v2_upgrade_token.as_ref().and_then(|token| serde_json::from_str(token).ok())
}
pub async fn to_json(&self, conn: &DbConn) -> Value {
let mut orgs_json = Vec::new();
for c in Membership::find_confirmed_by_user(&self.uuid, conn).await {
@@ -277,21 +411,7 @@ impl User {
UserStatus::Enabled
};
let account_keys = if self.private_key.is_some() {
json!({
"publicKeyEncryptionKeyPair": {
"wrappedPrivateKey": self.private_key,
"publicKey": self.public_key,
"signedPublicKey": null,
"object": "publicKeyEncryptionKeyPair",
},
"securityState": null,
"signatureKeyPair": null,
"object": "privateKeys"
})
} else {
Value::Null
};
let account_keys = self.account_keys_json(conn).await;
json!({
"_status": status as i32,
@@ -299,7 +419,7 @@ impl User {
"id": self.uuid,
"name": self.name,
"email": self.email,
"emailVerified": !CONFIG.mail_enabled() || self.verified_at.is_some(),
"emailVerified": self.verified(),
"premium": true,
"premiumFromOrganization": false,
"culture": "en-US",
@@ -321,7 +441,7 @@ impl User {
pub async fn save(&mut self, conn: &DbConn) -> EmptyResult {
if !crate::util::is_valid_email(&self.email) {
err!(format!("User email {} is not a valid email address", self.email))
err!(format!("User email {} is not a valid email address", self.email.escape_debug()))
}
self.updated_at = Utc::now().naive_utc();
@@ -367,6 +487,7 @@ impl User {
Device::delete_all_by_user(&self.uuid, conn).await?;
TwoFactor::delete_all_by_user(&self.uuid, conn).await?;
TwoFactorIncomplete::delete_all_by_user(&self.uuid, conn).await?;
UserSignatureKeyPair::delete_all_by_user(&self.uuid, conn).await?;
Invitation::take(&self.email, conn).await; // Delete invitation if any
conn.run(move |conn| {
@@ -469,7 +590,7 @@ impl Invitation {
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
if !crate::util::is_valid_email(&self.email) {
err!(format!("Invitation email {} is not a valid email address", self.email))
err!(format!("Invitation email {} is not a valid email address", self.email.escape_debug()))
}
db_run! { conn:
+132
View File
@@ -0,0 +1,132 @@
use chrono::{NaiveDateTime, Utc};
use derive_more::{AsRef, Deref, Display, From};
use diesel::prelude::*;
use serde_json::Value;
use crate::{
api::EmptyResult,
db::{DbConn, schema::user_signature_key_pairs},
error::MapResult,
util::get_uuid,
};
use super::UserId;
/// A user's signature key pair, part of the v2 state, in its own table like upstream.
///
/// 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)]
#[diesel(primary_key(uuid))]
pub struct UserSignatureKeyPair {
pub uuid: UserSignatureKeyPairId,
pub user_uuid: UserId,
pub signature_algorithm: i32,
/// The signing (private) key, wrapped by the user key.
pub signing_key: String,
/// The COSE-encoded public verifying key.
pub verifying_key: String,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
}
/// 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,
MlDsa44 = 1,
}
impl SignatureAlgorithm {
pub fn parse(algorithm: &str) -> Option<Self> {
match algorithm {
"ed25519" => Some(Self::Ed25519),
"mldsa44" => Some(Self::MlDsa44),
_ => None,
}
}
}
/// Local methods
impl UserSignatureKeyPair {
pub fn new(
user_uuid: UserId,
signature_algorithm: SignatureAlgorithm,
signing_key: String,
verifying_key: String,
) -> Self {
let now = Utc::now().naive_utc();
Self {
uuid: UserSignatureKeyPairId(get_uuid()),
user_uuid,
signature_algorithm: signature_algorithm as i32,
signing_key,
verifying_key,
created_at: now,
updated_at: now,
}
}
pub fn to_json(&self) -> Value {
json!({
"wrappedSigningKey": self.signing_key,
"verifyingKey": self.verifying_key,
"object": "signatureKeyPair",
})
}
}
/// Database methods
impl UserSignatureKeyPair {
pub async fn save(&mut self, conn: &DbConn) -> EmptyResult {
self.updated_at = Utc::now().naive_utc();
db_run! { conn:
mysql {
diesel::insert_into(user_signature_key_pairs::table)
.values(&*self)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(&*self)
.execute(conn)
.map_res("Error saving user signature key pair")
}
postgresql, sqlite {
diesel::insert_into(user_signature_key_pairs::table)
.values(&*self)
.on_conflict(user_signature_key_pairs::user_uuid)
.do_update()
.set(&*self)
.execute(conn)
.map_res("Error saving user signature key pair")
}
}
}
/// The user's key pair. There is at most one, enforced by a unique index on `user_uuid`.
pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Option<Self> {
conn.run(move |conn| {
user_signature_key_pairs::table
.filter(user_signature_key_pairs::user_uuid.eq(user_uuid))
.first::<Self>(conn)
.ok()
})
.await
}
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
conn.run(move |conn| {
diesel::delete(user_signature_key_pairs::table.filter(user_signature_key_pairs::user_uuid.eq(user_uuid)))
.execute(conn)
.map_res("Error deleting user signature key pairs")
})
.await
}
}
#[derive(Clone, Debug, AsRef, Deref, DieselNewType, Display, From, Hash, PartialEq, Eq, Serialize, Deserialize)]
pub struct UserSignatureKeyPairId(String);
+20
View File
@@ -116,6 +116,7 @@ table! {
atype -> Integer,
enabled -> Bool,
data -> Text,
revision_date -> Timestamp,
}
}
@@ -218,6 +219,22 @@ table! {
avatar_color -> Nullable<Text>,
external_id -> Nullable<Text>,
key_id -> Nullable<Text>,
signed_public_key -> Nullable<Text>,
security_state -> Nullable<Text>,
security_version -> Nullable<Integer>,
v2_upgrade_token -> Nullable<Text>,
}
}
table! {
user_signature_key_pairs (uuid) {
uuid -> Text,
user_uuid -> Text,
signature_algorithm -> Integer,
signing_key -> Text,
verifying_key -> Text,
created_at -> Timestamp,
updated_at -> Timestamp,
}
}
@@ -243,6 +260,7 @@ table! {
atype -> Integer,
reset_password_key -> Nullable<Text>,
external_id -> Nullable<Text>,
v2_upgrade_token -> Nullable<Text>,
}
}
@@ -383,6 +401,7 @@ joinable!(collections_groups -> groups (groups_uuid));
joinable!(event -> users_organizations (uuid));
joinable!(auth_requests -> users (user_uuid));
joinable!(sso_users -> users (user_uuid));
joinable!(user_signature_key_pairs -> users (user_uuid));
allow_tables_to_appear_in_same_query!(
archives,
@@ -409,4 +428,5 @@ allow_tables_to_appear_in_same_query!(
collections_groups,
event,
auth_requests,
user_signature_key_pairs,
);
+9 -9
View File
@@ -466,7 +466,7 @@ mod tests {
#[test]
fn dotted_decimal_loopback_normalizes() {
let ip = parse_to_ip("127.0.0.1").unwrap();
assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
assert_eq!(ip, IpAddr::V4(Ipv4Addr::LOCALHOST));
assert!(!is_global_hardcoded(ip));
}
@@ -474,21 +474,21 @@ mod tests {
fn single_decimal_loopback_normalizes() {
// 127.0.0.1 == 2130706433
let ip = parse_to_ip("2130706433").unwrap();
assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
assert_eq!(ip, IpAddr::V4(Ipv4Addr::LOCALHOST));
assert!(!is_global_hardcoded(ip));
}
#[test]
fn hex_loopback_normalizes() {
let ip = parse_to_ip("0x7f000001").unwrap();
assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
assert_eq!(ip, IpAddr::V4(Ipv4Addr::LOCALHOST));
assert!(!is_global_hardcoded(ip));
}
#[test]
fn dotted_hex_loopback_normalizes() {
let ip = parse_to_ip("0x7f.0.0.1").unwrap();
assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
assert_eq!(ip, IpAddr::V4(Ipv4Addr::LOCALHOST));
assert!(!is_global_hardcoded(ip));
}
@@ -496,14 +496,14 @@ mod tests {
fn octal_loopback_normalizes() {
// 017700000001 == 127.0.0.1
let ip = parse_to_ip("017700000001").unwrap();
assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
assert_eq!(ip, IpAddr::V4(Ipv4Addr::LOCALHOST));
assert!(!is_global_hardcoded(ip));
}
#[test]
fn dotted_octal_loopback_normalizes() {
let ip = parse_to_ip("0177.0.0.01").unwrap();
assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
assert_eq!(ip, IpAddr::V4(Ipv4Addr::LOCALHOST));
assert!(!is_global_hardcoded(ip));
}
@@ -535,19 +535,19 @@ mod tests {
#[test]
fn get_valid_host_normalizes_decimal_int() {
let h = get_valid_host("2130706433").expect("valid");
assert!(matches!(h, Host::Ipv4(ip) if ip == Ipv4Addr::new(127, 0, 0, 1)));
assert!(matches!(h, Host::Ipv4(ip) if ip == Ipv4Addr::LOCALHOST));
}
#[test]
fn get_valid_host_normalizes_hex() {
let h = get_valid_host("0x7f000001").expect("valid");
assert!(matches!(h, Host::Ipv4(ip) if ip == Ipv4Addr::new(127, 0, 0, 1)));
assert!(matches!(h, Host::Ipv4(ip) if ip == Ipv4Addr::LOCALHOST));
}
#[test]
fn get_valid_host_normalizes_octal() {
let h = get_valid_host("017700000001").expect("valid");
assert!(matches!(h, Host::Ipv4(ip) if ip == Ipv4Addr::new(127, 0, 0, 1)));
assert!(matches!(h, Host::Ipv4(ip) if ip == Ipv4Addr::LOCALHOST));
}
// ===
+15
View File
@@ -531,6 +531,21 @@ pub async fn send_new_device_logged_in(address: &str, ip: &str, dt: &NaiveDateTi
send_email(address, &subject, body_html, body_text).await
}
pub async fn send_recover_twofactor(address: &str, ip: &str, dt: &NaiveDateTime) -> EmptyResult {
let fmt = "%A, %B %_d, %Y at %r %Z";
let (subject, body_html, body_text) = get_text(
"email/recover_twofactor",
json!({
"url": CONFIG.domain(),
"img_src": CONFIG._smtp_img_src(),
"ip": ip,
"datetime": crate::util::format_naive_datetime_local(dt, fmt),
}),
)?;
send_email(address, &subject, body_html, body_text).await
}
pub async fn send_incomplete_2fa_login(
address: &str,
ip: &str,
+1 -1
View File
@@ -720,7 +720,7 @@ fn schedule_jobs(pool: db::DbPool) {
}
// Clean unused, expired Duo authentication contexts.
if !CONFIG.duo_context_purge_schedule().is_empty() && CONFIG._enable_duo() && !CONFIG.duo_use_iframe() {
if !CONFIG.duo_context_purge_schedule().is_empty() && CONFIG._enable_duo() {
sched.add(Job::new(CONFIG.duo_context_purge_schedule().parse().unwrap(), || {
runtime.spawn(purge_duo_contexts(pool.clone()));
}));
+17 -15
View File
@@ -2,7 +2,6 @@ use std::{sync::LazyLock, time::Duration};
use chrono::Utc;
use derive_more::{AsRef, Deref, Display, From, Into};
use regex::Regex;
use url::Url;
use crate::{
@@ -175,16 +174,16 @@ pub fn decode_state(base64_state: &str) -> ApiResult<OIDCState> {
if let Ok(valid) = String::from_utf8(vec) {
OIDCState(valid)
} else {
err!(format!("Invalid utf8 chars in {base64_state} after base64 decoding"))
err!(format!("Invalid utf8 chars in {} after base64 decoding", base64_state.escape_debug()))
}
} else {
err!(format!("Failed to decode {base64_state} using base64"))
err!(format!("Failed to decode {} using base64", base64_state.escape_debug()))
};
Ok(state)
}
// redirect_uri from: https://github.com/bitwarden/server/blob/main/src/Identity/IdentityServer/ApiClient.cs
// redirect_uri from: https://github.com/bitwarden/server/blob/main/src/Identity/IdentityServer/StaticClientStore.cs
pub async fn authorize_url(
state: OIDCState,
client_challenge: OIDCCodeChallenge,
@@ -193,20 +192,21 @@ pub async fn authorize_url(
binding_hash: Option<String>,
conn: DbConn,
) -> ApiResult<Url> {
// The desktop app uses the localhost callback when it can't register the custom scheme (AppImage, dev builds)
let localhost_redirect_uri =
(8065..=8070).map(|port| format!("http://localhost:{port}")).find(|uri| uri == raw_redirect_uri);
let redirect_uri = match client_id {
"web" | "browser" => format!("{}/sso-connector.html", CONFIG.domain()),
"desktop" | "mobile" => "bitwarden://sso-callback".to_owned(),
"desktop" => localhost_redirect_uri.unwrap_or_else(|| "bitwarden://sso-callback".to_owned()),
"mobile" => "bitwarden://sso-callback".to_owned(),
"cli" => {
let port_regex = Regex::new(r"^http://localhost:([0-9]{4})$").unwrap();
if let Some(port) =
port_regex.captures(raw_redirect_uri).and_then(|captures| captures.get(1).map(|c| c.as_str()))
{
format!("http://localhost:{port}")
} else {
err!("Failed to extract port number")
}
let Some(uri) = localhost_redirect_uri else {
err!(format!("Unsupported redirect uri {}", raw_redirect_uri.escape_debug()))
};
uri
}
_ => err!(format!("Unsupported client {client_id}")),
_ => err!(format!("Unsupported client {}", client_id.escape_debug())),
};
let (auth_url, sso_auth) = Client::authorize_url(state, client_challenge, redirect_uri, binding_hash).await?;
@@ -242,7 +242,8 @@ impl OIDCIdentifier {
// During the 2FA flow we will
// - retrieve the user information and then only discover he needs 2FA.
// - second time we will rely on `SsoAuth.auth_response` since the `code` has already been exchanged.
// - second time we will rely on `SsoAuth.auth_response` since the `code` has already been exchanged,
// after checking the PKCE verifier again like upstream does on every redemption.
// The `SsoAuth` will ensure that the user is authorized only once.
pub async fn exchange_code(
code: &OIDCCode,
@@ -256,6 +257,7 @@ pub async fn exchange_code(
};
if let Some(authenticated_user) = sso_auth.auth_response.clone() {
Client::check_client_verifier(&client_verifier, &sso_auth)?;
return Ok((sso_auth, authenticated_user));
}
+12 -7
View File
@@ -212,6 +212,16 @@ impl Client {
Ok((auth_url, SsoAuth::new(state, client_challenge, nonce.secret().clone(), redirect_uri, binding_hash)))
}
// Check the client's PKCE verifier against the challenge it sent when starting the flow
pub fn check_client_verifier(client_verifier: &OIDCCodeVerifier, sso_auth: &SsoAuth) -> EmptyResult {
let verifier = PkceCodeVerifier::new(client_verifier.to_string());
let challenge = PkceCodeChallenge::from_code_verifier_sha256(&verifier);
if challenge.as_str() != &*sso_auth.client_challenge {
err!("PKCE client challenge failed")
}
Ok(())
}
pub async fn exchange_code(
&self,
code: OIDCCode,
@@ -234,15 +244,10 @@ impl Client {
let mut exchange = self.core_client.exchange_code(oidc_code);
let verifier = PkceCodeVerifier::new(client_verifier.into());
if CONFIG.sso_pkce() {
exchange = exchange.set_pkce_verifier(verifier);
exchange = exchange.set_pkce_verifier(PkceCodeVerifier::new(client_verifier.into()));
} else {
let challenge = PkceCodeChallenge::from_code_verifier_sha256(&verifier);
if challenge.as_str() != String::from(sso_auth.client_challenge.clone()) {
err!("PKCE client challenge failed")
// Might need to notify admin ? how ?
}
Self::check_client_verifier(&client_verifier, sso_auth)?;
}
match exchange.request_async(&self.http_client).await {
+2 -2
View File
@@ -4,10 +4,10 @@
*
* To rebuild or modify this file with the latest versions of the included
* software please visit:
* https://datatables.net/download/#bs5/dt-3.0.4
* https://datatables.net/download/#bs5/dt-3.1.3
*
* Included libraries:
* DataTables 3.0.4
* DataTables 3.1.3
*/
/*! DataTables Bootstrap 5 integration
+396 -254
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,9 @@
Two-Step Login Recovered From {{ip}}
<!---------------->
Two-step login on your account has been disabled by using the account's recovery code.
* Date: {{datetime}}
* IP Address: {{ip}}
If you did not perform this action, you should immediately take steps to secure your account.
{{> email/email_footer_text }}
@@ -0,0 +1,26 @@
Two-Step Login Recovered From {{ip}}
<!---------------->
{{> email/email_header }}
<table width="100%" cellpadding="0" cellspacing="0" 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;">
<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;" valign="top">
Two-step login on your account has been disabled by using the account's recovery code.
</td>
</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;" valign="top">
<b>Date:</b> {{datetime}}
</td>
</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;" valign="top">
<b>IP Address:</b> {{ip}}
</td>
</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 last" 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; -webkit-text-size-adjust: none;" valign="top">
If you did not perform this action, you should immediately take steps to secure your account.
</td>
</tr>
</table>
{{> email/email_footer }}
@@ -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>
+7 -25
View File
@@ -42,45 +42,27 @@ a[href$="/settings/sponsored-families"] {
{{/if}}
/* Hide the `Enterprise Single Sign-On` button on the login page */
{{#if (webver ">=2025.5.1")}}
{{#if (not sso_enabled)}}
.vw-sso-login {
@extend %vw-hide;
}
{{/if}}
{{else}}
app-root ng-component > form > div:nth-child(1) > div > button[buttontype="secondary"].\!tw-text-primary-600:nth-child(4) {
@extend %vw-hide;
}
{{/if}}
/* Hide the `Log in with passkey` settings */
app-user-layout app-password-settings app-webauthn-login-settings {
@extend %vw-hide;
}
/* Hide Log in with passkey on the login page */
{{#if (webver ">=2025.5.1")}}
.vw-passkey-login {
@extend %vw-hide;
}
{{else}}
app-root ng-component > form > div:nth-child(1) > div > button[buttontype="secondary"].\!tw-text-primary-600:nth-child(3) {
@extend %vw-hide;
}
{{/if}}
/* Hide the or text followed by the two buttons hidden above */
{{#if (webver ">=2025.5.1")}}
{{#if (or (not sso_enabled) sso_only)}}
.vw-or-text {
@extend %vw-hide;
}
{{/if}}
{{else}}
app-root ng-component > form > div:nth-child(1) > div:nth-child(3) > div:nth-child(2) {
@extend %vw-hide;
}
{{/if}}
/* Hide the `Other` button on the login page */
{{#if (or (not sso_enabled) sso_only)}}
@@ -154,17 +136,10 @@ ng-dropdown-panel div.ng-dropdown-panel-items div:has(> [title="DuckDuckGo"]) {
take a while for the change to take effect. To avoid the button appearing
when it shouldn't, we'll keep this style in place for a couple of versions */
/* Hide the register link on the login screen */
{{#if (webver "<2025.3.0")}}
app-login form div + div + div + div + hr,
app-login form div + div + div + div + hr + p {
@extend %vw-hide;
}
{{else}}
app-root a[routerlink="/signup"] {
@extend %vw-hide;
}
{{/if}}
{{/if}}
{{#if remember_2fa_disabled}}
/* Hide checkbox to remember 2FA token for 30 days */
@@ -220,6 +195,13 @@ auth-input-password > form > bit-form-field:nth-child(4) {
@extend %vw-hide;
}
{{/unless}}
{{#unless email_change_allowed}}
/* Hide the whole `Change email` section, including its heading, if not allowed */
div:has(> app-change-email) {
@extend %vw-hide;
}
{{/unless}}
/**** End Dynamic Vaultwarden Changes ****/
/**** Include a special user stylesheet for custom changes ****/
{{#if load_user_scss}}
+16 -10
View File
@@ -380,7 +380,7 @@ pub fn get_display_size(size: i64) -> String {
let mut unit_counter = 0;
loop {
if size > 1024. {
if size > 1024. && unit_counter < UNITS.len() - 1 {
size /= 1024.;
unit_counter += 1;
} else {
@@ -522,8 +522,8 @@ pub fn format_datetime_http(dt: &DateTime<Local>) -> String {
expiry_time.to_rfc2822().replace("+0000", "GMT")
}
pub fn parse_date(date: &str) -> NaiveDateTime {
DateTime::parse_from_rfc3339(date).unwrap().naive_utc()
pub fn parse_date(date: &str) -> Option<NaiveDateTime> {
DateTime::parse_from_rfc3339(date).ok().map(|dt| dt.naive_utc())
}
/// Returns true or false if an email address is valid or not
@@ -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
@@ -943,15 +949,15 @@ mod tests {
use std::net::IpAddr;
#[test]
#[ignore]
#[ignore = "exhaustive IPv4 check is too slow for the regular test suite"]
fn test_ipv4_global() {
for a in 0..u8::MAX {
println!("Iter: {}/255", a);
println!("Iter: {a}/255");
for b in 0..u8::MAX {
for c in 0..u8::MAX {
for d in 0..u8::MAX {
let ip = IpAddr::V4(std::net::Ipv4Addr::new(a, b, c, d));
assert_eq!(ip.is_global(), is_global_hardcoded(ip), "IP mismatch: {}", ip)
assert_eq!(ip.is_global(), is_global_hardcoded(ip), "IP mismatch: {ip}");
}
}
}
@@ -959,15 +965,15 @@ mod tests {
}
#[test]
#[ignore]
#[ignore = "random IPv6 check is too slow for the regular test suite"]
fn test_ipv6_global() {
use rand::Rng;
use rand::RngExt;
std::thread::scope(|s| {
for t in 0..16 {
let handle = s.spawn(move || {
let _handle = s.spawn(move || {
let mut v = [0u8; 16];
let mut rng = rand::thread_rng();
let mut rng = rand::rng();
for i in 0..20 {
println!("Thread {t} Iter: {i}/50");