Browse Source

fix(auth): enable passkeys by default and resolve upstream conflicts

pull/7784/head
Akhil Babel 15 hours ago
parent
commit
8150e01325
Failed to extract signature
  1. 31
      .env.template
  2. 2
      .github/workflows/trivy.yml
  3. 2
      .github/workflows/typos.yml
  4. 2
      .pre-commit-config.yaml
  5. 360
      Cargo.lock
  6. 22
      Cargo.toml
  7. 30
      README.md
  8. 4
      docker/DockerSettings.yaml
  9. 20
      docker/Dockerfile.alpine
  10. 14
      docker/Dockerfile.debian
  11. 1
      docker/README.md
  12. 6
      docker/docker-bake.hcl
  13. 1
      migrations/mysql/2026-10-07-120000_add_org_policy_revision_date/down.sql
  14. 7
      migrations/mysql/2026-10-07-120000_add_org_policy_revision_date/up.sql
  15. 1
      migrations/postgresql/2026-10-07-120000_add_org_policy_revision_date/down.sql
  16. 9
      migrations/postgresql/2026-10-07-120000_add_org_policy_revision_date/up.sql
  17. 0
      migrations/sqlite/2026-10-07-120000_add_org_policy_revision_date/down.sql
  18. 6
      migrations/sqlite/2026-10-07-120000_add_org_policy_revision_date/up.sql
  19. 4
      playwright/README.md
  20. 131
      playwright/tests/emergency.spec.ts
  21. 37
      playwright/tests/login.spec.ts
  22. 19
      playwright/tests/setups/2fa.ts
  23. 18
      playwright/tests/setups/user.ts
  24. 2
      rust-toolchain.toml
  25. 27
      src/api/admin.rs
  26. 303
      src/api/core/accounts.rs
  27. 23
      src/api/core/ciphers.rs
  28. 36
      src/api/core/emergency_access.rs
  29. 47
      src/api/core/events.rs
  30. 284
      src/api/core/organizations.rs
  31. 6
      src/api/core/passkeys.rs
  32. 32
      src/api/core/public.rs
  33. 204
      src/api/core/sends.rs
  34. 65
      src/api/core/two_factor/authenticator.rs
  35. 244
      src/api/core/two_factor/duo.rs
  36. 4
      src/api/core/two_factor/duo_oidc.rs
  37. 136
      src/api/core/two_factor/email.rs
  38. 70
      src/api/core/two_factor/mod.rs
  39. 5
      src/api/core/two_factor/protected_actions.rs
  40. 180
      src/api/core/two_factor/webauthn.rs
  41. 102
      src/api/core/two_factor/yubikey.rs
  42. 4
      src/api/icons.rs
  43. 262
      src/api/identity.rs
  44. 20
      src/api/mod.rs
  45. 38
      src/api/notifications.rs
  46. 3
      src/api/web.rs
  47. 114
      src/auth.rs
  48. 6
      src/auth/send.rs
  49. 281
      src/auth/two_factor.rs
  50. 65
      src/config.rs
  51. 2
      src/db/models/attachment.rs
  52. 26
      src/db/models/auth_request.rs
  53. 1
      src/db/models/cipher.rs
  54. 21
      src/db/models/group.rs
  55. 20
      src/db/models/org_policy.rs
  56. 10
      src/db/models/organization.rs
  57. 30
      src/db/models/send.rs
  58. 4
      src/db/models/two_factor.rs
  59. 53
      src/db/models/user.rs
  60. 1
      src/db/schema.rs
  61. 18
      src/http_client.rs
  62. 15
      src/mail.rs
  63. 2
      src/main.rs
  64. 32
      src/sso.rs
  65. 19
      src/sso_client.rs
  66. 4
      src/static/scripts/datatables.css
  67. 650
      src/static/scripts/datatables.js
  68. 9
      src/static/templates/email/recover_twofactor.hbs
  69. 26
      src/static/templates/email/recover_twofactor.html.hbs
  70. 2
      src/static/templates/email/send_emergency_access_invite.html.hbs
  71. 39
      src/static/templates/scss/vaultwarden.scss.hbs
  72. 20
      src/util.rs
  73. 2
      tests/README.md
  74. 151
      tests/passkey_unlock.rs

31
.env.template

@ -180,7 +180,7 @@
## Defaults to every minute. Set blank to disable this job. ## Defaults to every minute. Set blank to disable this job.
# AUTH_REQUEST_PURGE_SCHEDULE="30 * * * * *" # 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. ## Defaults to every minute. Set blank to disable this job.
# DUO_CONTEXT_PURGE_SCHEDULE="30 * * * * *" # 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 ## "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 ## 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. ## 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 # IP_HEADER_TRUSTED_PROXIES=local
## Icon service ## Icon service
@ -391,24 +393,16 @@
## The following flags are available: ## The following flags are available:
## - "pm-5594-safari-account-switching": Enable account switching in Safari. (Safari >= 2026.2.0) ## - "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) ## - "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-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) ## - "windows-desktop-autotype": Enable the autotype feature preview on Windows. (Desktop >= 2025.8.0)
## - "pm-25373-windows-biometrics-v2": Enable the new implementation of biometrics on Windows. (Desktop >= 2025.11.0) ## - "windows-desktop-autotype-ga": Enable the new, still in development, autotype implementation on Windows. (Desktop >= 2026.9.1)
## - "pm-26340-linux-biometrics-v2": Enable the new implementation of biometrics on Linux. (Desktop >= 2025.11.0) ## Only enable one of the two autotype flags, the clients disable autotype completely when both are enabled.
## - "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)
## - "pm-30529-webauthn-related-origins": ## - "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-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) ## - "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) ## - "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= # EXPERIMENTAL_CLIENT_FEATURE_FLAGS=
## Require new device emails. When a user logs in an email is required to be sent. ## Require new device emails. When a user logs in an email is required to be sent.
@ -522,10 +516,6 @@
## Prevent users from logging in directly without going through SSO ## Prevent users from logging in directly without going through SSO
# SSO_ONLY=false # SSO_ONLY=false
## Passkey sign-in and optional PRF vault unlock. Disabled until compatible clients are qualified.
## Independent passkey sign-in is unavailable when SSO_ONLY is enabled.
# PASSKEYS_ENABLED=false
## Allow SSO flow to create account. You probably want to disable it when using a public provider. ## Allow SSO flow to create account. You probably want to disable it when using a public provider.
# SSO_SIGNUPS_ALLOWED=true # SSO_SIGNUPS_ALLOWED=true
@ -591,11 +581,6 @@
# DUO_HOST=<API Hostname> # DUO_HOST=<API Hostname>
## After that, you should be able to follow the rest of the guide linked above, ## 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. ## 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 2FA settings
## Email token size ## Email token size

2
.github/workflows/trivy.yml

@ -50,6 +50,6 @@ jobs:
severity: CRITICAL,HIGH severity: CRITICAL,HIGH
- name: Upload Trivy scan results to GitHub Security tab - 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: with:
sarif_file: 'trivy-results.sarif' sarif_file: 'trivy-results.sarif'

2
.github/workflows/typos.yml

@ -23,4 +23,4 @@ jobs:
# When this version is updated, do not forget to update this in `.pre-commit-config.yaml` too # When this version is updated, do not forget to update this in `.pre-commit-config.yaml` too
- name: Spell Check Repo - name: Spell Check Repo
uses: crate-ci/typos@512fc24f32f44ab01972217aaaf3dc86ec234d53 # v1.50.2 uses: crate-ci/typos@00f422f3b19c57bc6338715ebfe3316d38768461 # v1.50.3

2
.pre-commit-config.yaml

@ -18,7 +18,7 @@ repos:
# When this version is updated, do not forget to update this in `.github/workflows/typos.yaml` too # When this version is updated, do not forget to update this in `.github/workflows/typos.yaml` too
- repo: https://github.com/crate-ci/typos - repo: https://github.com/crate-ci/typos
rev: 512fc24f32f44ab01972217aaaf3dc86ec234d53 # v1.50.2 rev: 00f422f3b19c57bc6338715ebfe3316d38768461 # v1.50.3
hooks: hooks:
- id: typos - id: typos
always_run: true always_run: true

360
Cargo.lock

@ -32,15 +32,15 @@ dependencies = [
[[package]] [[package]]
name = "alloc-no-stdlib" name = "alloc-no-stdlib"
version = "2.0.4" version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a"
[[package]] [[package]]
name = "alloc-stdlib" name = "alloc-stdlib"
version = "0.2.4" version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1"
dependencies = [ dependencies = [
"alloc-no-stdlib", "alloc-no-stdlib",
] ]
@ -151,9 +151,9 @@ dependencies = [
[[package]] [[package]]
name = "async-compression" name = "async-compression"
version = "0.4.48" version = "0.4.50"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb61aea1a7def73ee7c350a184f0e70b32c182344e2e75bf70c9b621b83417fd" checksum = "ee19bd99b43e3691acbad4e840420a4881cea6c0b66a208125a824f8fd53f5a1"
dependencies = [ dependencies = [
"compression-codecs", "compression-codecs",
"compression-core", "compression-core",
@ -323,9 +323,9 @@ dependencies = [
[[package]] [[package]]
name = "asyncband" name = "asyncband"
version = "0.7.2" version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2d85fd3d291fabcc40c7232c92c280ec1754fd7b5d7ea769f143222143e179a" checksum = "5161fb43cb8f231f06983cb2b8f9ee9cee14be0b1356d8383398cc0f312800d1"
[[package]] [[package]]
name = "atomic" name = "atomic"
@ -399,9 +399,9 @@ dependencies = [
[[package]] [[package]]
name = "aws-runtime" name = "aws-runtime"
version = "1.9.4" version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25b43ad47adc2517efe3d706559d94b97e50e80e0321b3cadbc9f77cee88adcd" checksum = "2b8a9911551b4ea6ca13805ef52ed96f7d2bbb43cc3b4a14cb0776a71f33cfaa"
dependencies = [ dependencies = [
"aws-credential-types", "aws-credential-types",
"aws-sigv4", "aws-sigv4",
@ -424,9 +424,9 @@ dependencies = [
[[package]] [[package]]
name = "aws-sdk-sso" name = "aws-sdk-sso"
version = "1.111.0" version = "1.114.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65700d75f6da78c89a61a49f1539979400a4609d3329376b1dc2133d7a1b2b90" checksum = "12be2f9c8eef7f5fc919c96d538e629698469a02b4cb75408b26b1bd984ebe79"
dependencies = [ dependencies = [
"arc-swap", "arc-swap",
"aws-credential-types", "aws-credential-types",
@ -442,7 +442,6 @@ dependencies = [
"aws-types", "aws-types",
"bytes", "bytes",
"fastrand", "fastrand",
"http 0.2.12",
"http 1.5.0", "http 1.5.0",
"regex-lite", "regex-lite",
"tracing", "tracing",
@ -450,9 +449,9 @@ dependencies = [
[[package]] [[package]]
name = "aws-sdk-ssooidc" name = "aws-sdk-ssooidc"
version = "1.113.0" version = "1.116.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb1147c254af7b6cee3ace90ae7da6f21366ce9a9b899035fa8a0fdf586eb75a" checksum = "d7645db8724ea3b82fdccfb67e1b0f637c9d8ab0e7ef29d84884d8d8d73f805d"
dependencies = [ dependencies = [
"arc-swap", "arc-swap",
"aws-credential-types", "aws-credential-types",
@ -468,7 +467,6 @@ dependencies = [
"aws-types", "aws-types",
"bytes", "bytes",
"fastrand", "fastrand",
"http 0.2.12",
"http 1.5.0", "http 1.5.0",
"regex-lite", "regex-lite",
"tracing", "tracing",
@ -476,9 +474,9 @@ dependencies = [
[[package]] [[package]]
name = "aws-sdk-sts" name = "aws-sdk-sts"
version = "1.116.0" version = "1.119.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "035c7392e751d20bf1060d625d023587eb14099646110a9cff7c697bfe4b253a" checksum = "03e490aa904849b38e770922faac779dd245f5cf8be81f19065acf18d276e4ad"
dependencies = [ dependencies = [
"arc-swap", "arc-swap",
"aws-credential-types", "aws-credential-types",
@ -495,7 +493,6 @@ dependencies = [
"aws-smithy-xml", "aws-smithy-xml",
"aws-types", "aws-types",
"fastrand", "fastrand",
"http 0.2.12",
"http 1.5.0", "http 1.5.0",
"regex-lite", "regex-lite",
"tracing", "tracing",
@ -503,9 +500,9 @@ dependencies = [
[[package]] [[package]]
name = "aws-sigv4" name = "aws-sigv4"
version = "1.5.3" version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31d955e76ff96acd555bf06fa0fa6d5bf9335fa84ae7c64481b20ae61d231f70" checksum = "2312577f088c9fbf4206dfdb884cf1de9407b43e1a923cbed5237775116fc24b"
dependencies = [ dependencies = [
"aws-credential-types", "aws-credential-types",
"aws-smithy-http", "aws-smithy-http",
@ -515,7 +512,6 @@ dependencies = [
"form_urlencoded", "form_urlencoded",
"hex", "hex",
"hmac 0.13.0", "hmac 0.13.0",
"http 0.2.12",
"http 1.5.0", "http 1.5.0",
"percent-encoding", "percent-encoding",
"sha2 0.11.0", "sha2 0.11.0",
@ -536,9 +532,9 @@ dependencies = [
[[package]] [[package]]
name = "aws-smithy-http" name = "aws-smithy-http"
version = "0.64.0" version = "0.64.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37843d9add67c3aff5856f409c6dc315d3cdff60f9c0cb5b670dab1e9920306d" checksum = "639b4d8f8555f24a9be649811c3eb0b4d4616f4d61daf0c32e28873bc1ea9af1"
dependencies = [ dependencies = [
"aws-smithy-runtime-api", "aws-smithy-runtime-api",
"aws-smithy-types", "aws-smithy-types",
@ -590,9 +586,9 @@ dependencies = [
[[package]] [[package]]
name = "aws-smithy-runtime" name = "aws-smithy-runtime"
version = "1.14.2" version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3296253d3a91b3f938a3f2bcce4daebadcb4aa4228153fcec90f9d23532b4484" checksum = "d6e302ac1d88b99652489df31abdec6ac42a2ab2ac3982ad0ac49f64dfaf28ba"
dependencies = [ dependencies = [
"aws-smithy-async", "aws-smithy-async",
"aws-smithy-http", "aws-smithy-http",
@ -602,9 +598,7 @@ dependencies = [
"aws-smithy-types", "aws-smithy-types",
"bytes", "bytes",
"fastrand", "fastrand",
"http 0.2.12",
"http 1.5.0", "http 1.5.0",
"http-body 0.4.6",
"http-body 1.1.0", "http-body 1.1.0",
"http-body-util", "http-body-util",
"pin-project-lite", "pin-project-lite",
@ -615,15 +609,14 @@ dependencies = [
[[package]] [[package]]
name = "aws-smithy-runtime-api" name = "aws-smithy-runtime-api"
version = "1.16.2" version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d881a7b7ad179fd6611680c9de89f716fb00ab40299a9a7b8c6913e8f7511a8" checksum = "c0730c16f91124c6a2abb4932c77e299288b3dd9f967ea2e9ec48cc6731e87a4"
dependencies = [ dependencies = [
"aws-smithy-async", "aws-smithy-async",
"aws-smithy-runtime-api-macros", "aws-smithy-runtime-api-macros",
"aws-smithy-types", "aws-smithy-types",
"bytes", "bytes",
"http 0.2.12",
"http 1.5.0", "http 1.5.0",
"pin-project-lite", "pin-project-lite",
"tokio", "tokio",
@ -655,16 +648,14 @@ dependencies = [
[[package]] [[package]]
name = "aws-smithy-types" name = "aws-smithy-types"
version = "1.6.4" version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b791f3ac597193fe1d08b82366986eb1f5bc31f2ac6c194c0855276116c76cd" checksum = "69bb407740a197147da48238ecc94498493c9e85445732360cec180296ca45f1"
dependencies = [ dependencies = [
"base64-simd", "base64-simd",
"bytes", "bytes",
"bytes-utils", "bytes-utils",
"http 0.2.12",
"http 1.5.0", "http 1.5.0",
"http-body 0.4.6",
"http-body 1.1.0", "http-body 1.1.0",
"http-body-util", "http-body-util",
"itoa", "itoa",
@ -756,9 +747,9 @@ dependencies = [
[[package]] [[package]]
name = "bigdecimal" name = "bigdecimal"
version = "0.4.10" version = "0.4.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" checksum = "8fa3f3d8cbf4dffcfe4991de61d012bef509a409ecbe9dd41049bfe32b4d4653"
dependencies = [ dependencies = [
"autocfg", "autocfg",
"libm", "libm",
@ -827,9 +818,9 @@ dependencies = [
[[package]] [[package]]
name = "brotli" name = "brotli"
version = "8.0.4" version = "9.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" checksum = "f8b851b75c23ca7873623d612fe49bd1989aeb03d08fb9432187eb253d3d4c6b"
dependencies = [ dependencies = [
"alloc-no-stdlib", "alloc-no-stdlib",
"alloc-stdlib", "alloc-stdlib",
@ -838,9 +829,9 @@ dependencies = [
[[package]] [[package]]
name = "brotli-decompressor" name = "brotli-decompressor"
version = "5.0.3" version = "6.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" checksum = "941cd9bd4ddab83cb46fa5a2d428f1c857b24ac78cb876cf7beb710840934bd7"
dependencies = [ dependencies = [
"alloc-no-stdlib", "alloc-no-stdlib",
"alloc-stdlib", "alloc-stdlib",
@ -891,9 +882,9 @@ dependencies = [
[[package]] [[package]]
name = "cached" name = "cached"
version = "4.0.0" version = "4.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5a6cf8262820194a1488ece477f5fb5ba9256ef25229d525470e71d6e9a5835" checksum = "292f39b5c459538e148825347c400cb2005a618b897d0e6be183bad677d460b7"
dependencies = [ dependencies = [
"ahash", "ahash",
"async-lock", "async-lock",
@ -901,7 +892,7 @@ dependencies = [
"cached_proc_macro_types", "cached_proc_macro_types",
"hashbrown 0.17.1", "hashbrown 0.17.1",
"parking_lot", "parking_lot",
"thiserror 2.0.20", "thiserror 2.0.21",
"web-time", "web-time",
] ]
@ -926,9 +917,9 @@ checksum = "f5813789573ae815c8b4be58c4428e0e7ae05f0227678ba9de332ded585b9159"
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.4.7" version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"jobserver", "jobserver",
@ -1007,9 +998,9 @@ dependencies = [
[[package]] [[package]]
name = "compression-codecs" name = "compression-codecs"
version = "0.4.43" version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef16c47ba2797aa6a909cc37d39911f3a6743811fe7408ac0b0cc0276b656e9" checksum = "98fc98460ba0ad5317075d3632b8dfc45d0be8c4a49347c2a38272019717614a"
dependencies = [ dependencies = [
"brotli", "brotli",
"compression-core", "compression-core",
@ -1446,7 +1437,7 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
dependencies = [ dependencies = [
"thiserror 2.0.20", "thiserror 2.0.21",
] ]
[[package]] [[package]]
@ -1811,13 +1802,12 @@ dependencies = [
[[package]] [[package]]
name = "encoding_rs" name = "encoding_rs"
version = "0.8.41" version = "0.8.42"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b5ef0006ac9ab233c38522f5ae99cae3625151de8f706cacee1cba4b8e2832a" checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"core_detect", "core_detect",
"multiversion",
"multiversion_no_op", "multiversion_no_op",
"rustversion", "rustversion",
"scopeguard", "scopeguard",
@ -1916,9 +1906,9 @@ dependencies = [
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.13" version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
[[package]] [[package]]
name = "flate2" name = "flate2"
@ -2245,7 +2235,7 @@ dependencies = [
"pest_derive", "pest_derive",
"serde", "serde",
"serde_json", "serde_json",
"thiserror 2.0.20", "thiserror 2.0.21",
"walkdir", "walkdir",
] ]
@ -2321,8 +2311,8 @@ dependencies = [
"idna", "idna",
"ipnet", "ipnet",
"jni", "jni",
"rand 0.10.2", "rand 0.10.3",
"thiserror 2.0.20", "thiserror 2.0.21",
"tinyvec", "tinyvec",
"tokio", "tokio",
"tracing", "tracing",
@ -2341,9 +2331,9 @@ dependencies = [
"jni", "jni",
"once_cell", "once_cell",
"prefix-trie", "prefix-trie",
"rand 0.10.2", "rand 0.10.3",
"ring", "ring",
"thiserror 2.0.20", "thiserror 2.0.21",
"tinyvec", "tinyvec",
"tracing", "tracing",
"url", "url",
@ -2366,11 +2356,11 @@ dependencies = [
"ndk-context", "ndk-context",
"once_cell", "once_cell",
"parking_lot", "parking_lot",
"rand 0.10.2", "rand 0.10.3",
"resolv-conf", "resolv-conf",
"smallvec", "smallvec",
"system-configuration", "system-configuration",
"thiserror 2.0.20", "thiserror 2.0.21",
"tokio", "tokio",
"tracing", "tracing",
] ]
@ -2544,31 +2534,32 @@ dependencies = [
[[package]] [[package]]
name = "hyper-rustls" name = "hyper-rustls"
version = "0.27.9" version = "0.27.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
dependencies = [ dependencies = [
"http 1.5.0", "http 1.5.0",
"hyper 1.11.1", "hyper 1.11.1",
"hyper-util", "hyper-util",
"rustls 0.23.45", "rustls 0.23.45",
"tokio", "tokio",
"tokio-rustls 0.26.5", "tokio-rustls 0.26.6",
"tower-service", "tower-service",
] ]
[[package]] [[package]]
name = "hyper-util" name = "hyper-util"
version = "0.1.20" version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.23.1",
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-util", "futures-util",
"http 1.5.0", "http 1.5.0",
"http-body 1.1.0", "http-body 1.1.0",
"httparse",
"hyper 1.11.1", "hyper 1.11.1",
"ipnet", "ipnet",
"libc", "libc",
@ -2867,7 +2858,7 @@ dependencies = [
"jni-sys", "jni-sys",
"log", "log",
"simd_cesu8", "simd_cesu8",
"thiserror 2.0.20", "thiserror 2.0.21",
"walkdir", "walkdir",
"windows-link", "windows-link",
] ]
@ -2927,9 +2918,9 @@ dependencies = [
[[package]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.105" version = "0.3.106"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"futures-util", "futures-util",
@ -2980,9 +2971,9 @@ dependencies = [
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
version = "1.5.0" version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
dependencies = [ dependencies = [
"spin 0.9.9", "spin 0.9.9",
] ]
@ -3013,16 +3004,16 @@ dependencies = [
"serde", "serde",
"socket2 0.6.5", "socket2 0.6.5",
"tokio", "tokio",
"tokio-rustls 0.26.5", "tokio-rustls 0.26.6",
"tracing", "tracing",
"url", "url",
] ]
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.189" version = "0.2.190"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
[[package]] [[package]]
name = "libm" name = "libm"
@ -3128,16 +3119,6 @@ dependencies = [
"digest 0.11.3", "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]] [[package]]
name = "memchr" name = "memchr"
version = "2.8.3" version = "2.8.3"
@ -3198,9 +3179,9 @@ dependencies = [
[[package]] [[package]]
name = "mio" name = "mio"
version = "1.2.3" version = "1.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
dependencies = [ dependencies = [
"libc", "libc",
"wasi", "wasi",
@ -3246,27 +3227,6 @@ dependencies = [
"version_check", "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]] [[package]]
name = "multiversion_no_op" name = "multiversion_no_op"
version = "1.0.0" version = "1.0.0"
@ -3400,9 +3360,9 @@ dependencies = [
[[package]] [[package]]
name = "num-modular" name = "num-modular"
version = "0.6.5" version = "0.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd8e500409e6cd603b03e477c26a6caecdc27ac58979a53e881c75eafc079f44" checksum = "26ac76200f74e658124f95fa63e1a82b2fd2181c5b2fdde80b3d89d2d3f905e7"
[[package]] [[package]]
name = "num-order" name = "num-order"
@ -3482,9 +3442,9 @@ dependencies = [
[[package]] [[package]]
name = "opendal" name = "opendal"
version = "0.59.2" version = "0.59.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fe43e16d96bed57937eb7c4a28559bf4c5fc4a3951656505d1fdd4f856349b4" checksum = "3cbd193aec6d62efaff87611dfae52c132aed650bb3cb0c684c0a9394f09a42a"
dependencies = [ dependencies = [
"opendal-core", "opendal-core",
"opendal-service-fs", "opendal-service-fs",
@ -3493,9 +3453,9 @@ dependencies = [
[[package]] [[package]]
name = "opendal-core" name = "opendal-core"
version = "0.59.2" version = "0.59.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de4566a412776e3f65d53dd9fceced5b432a0a9c9a1e8d50ccf58823795e5c5b" checksum = "54b5555d753dd6a6383dc0891254b72f3cdc40c011ba0372b560e0ae5aa29f5c"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"asyncband", "asyncband",
@ -3507,7 +3467,7 @@ dependencies = [
"log", "log",
"md-5", "md-5",
"percent-encoding", "percent-encoding",
"quick-xml", "quick-xml 0.41.0",
"reqsign-core", "reqsign-core",
"serde", "serde",
"serde_json", "serde_json",
@ -3519,9 +3479,9 @@ dependencies = [
[[package]] [[package]]
name = "opendal-http-transport-reqwest" name = "opendal-http-transport-reqwest"
version = "0.59.2" version = "0.59.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f772ce6c137ab43647726116d505a649a88d41928fcd813438eb30b02312bcbb" checksum = "4d6588678f3040a3eab1819364dcbc6a746a8286d1ceb23a70a6614faa01dc6c"
dependencies = [ dependencies = [
"bytes", "bytes",
"futures", "futures",
@ -3533,9 +3493,9 @@ dependencies = [
[[package]] [[package]]
name = "opendal-service-fs" name = "opendal-service-fs"
version = "0.59.2" version = "0.59.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0e7d8974aebd33be899200c9d0d50b2141fd5fa5c462b29e20653e3ddbfe339" checksum = "ec5c16fe91b77c0364f66546a1c32b2999e9c3c3c75c2e908a282268e0890bac"
dependencies = [ dependencies = [
"bytes", "bytes",
"log", "log",
@ -3547,9 +3507,9 @@ dependencies = [
[[package]] [[package]]
name = "opendal-service-s3" name = "opendal-service-s3"
version = "0.59.2" version = "0.59.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2942f2d8d3d4953c0e8d07cd1244879628d084948e48ce6b6d808c5938f7858d" checksum = "1d83e716c25a81fe2ca701894b8e14b9966acca3812af250e50132accb19da02"
dependencies = [ dependencies = [
"base64 0.23.1", "base64 0.23.1",
"bytes", "bytes",
@ -3558,7 +3518,7 @@ dependencies = [
"log", "log",
"md-5", "md-5",
"opendal-core", "opendal-core",
"quick-xml", "quick-xml 0.41.0",
"reqsign-aws-v4", "reqsign-aws-v4",
"reqsign-core", "reqsign-core",
"reqsign-file-read-tokio", "reqsign-file-read-tokio",
@ -3800,9 +3760,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]] [[package]]
name = "pest" name = "pest"
version = "2.9.1" version = "2.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d45aeb61b4bf818e12d4205f2466f8c4748f85f4fce0146d1c03d69d753f0ad" checksum = "45d3aca230fad2e6f6317ca0a72724338c4960cb97168a85cdee66df4a9a21a8"
dependencies = [ dependencies = [
"memchr", "memchr",
"ucd-trie", "ucd-trie",
@ -3810,9 +3770,9 @@ dependencies = [
[[package]] [[package]]
name = "pest_derive" name = "pest_derive"
version = "2.9.1" version = "2.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89cc5a242e25ed4e7704d0be240f2cfbe20a8c27e7e252d94835be93d92dc39f" checksum = "284b60557f2c4a2e72ad3f2d34d42685a2fa4a6a61d0d2a10c0ae2a5e916c2cf"
dependencies = [ dependencies = [
"pest", "pest",
"pest_generator", "pest_generator",
@ -3820,9 +3780,9 @@ dependencies = [
[[package]] [[package]]
name = "pest_generator" name = "pest_generator"
version = "2.9.1" version = "2.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7abf21475cc3820fe4b2ca2dc2142902f67a02189f3b5b3a229f4febc01a43e5" checksum = "1d9d1f08a115309ee99268cf85e5228e0e56aa9caf8841ec12866b6be07c3109"
dependencies = [ dependencies = [
"pest", "pest",
"pest_meta", "pest_meta",
@ -3833,9 +3793,9 @@ dependencies = [
[[package]] [[package]]
name = "pest_meta" name = "pest_meta"
version = "2.9.1" version = "2.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adba4db388f687393c18c51348d44a41d870ca9df71a2c98172ea3035dc6936e" checksum = "ed93ba1a9ffcca32130a5188701c81c0c49cf00d4b7c5007d5148951d743adcb"
dependencies = [ dependencies = [
"pest", "pest",
] ]
@ -3925,9 +3885,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]] [[package]]
name = "pin-utils" name = "pin-utils"
version = "0.1.0" version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" checksum = "13bee6c73da26345c729282832b60b0363cf3dd9f4bfd81d8551b7a1c889a113"
[[package]] [[package]]
name = "piper" name = "piper"
@ -4007,9 +3967,9 @@ dependencies = [
[[package]] [[package]]
name = "powerfmt" name = "powerfmt"
version = "0.2.0" version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
[[package]] [[package]]
name = "ppv-lite86" name = "ppv-lite86"
@ -4022,9 +3982,9 @@ dependencies = [
[[package]] [[package]]
name = "pq-sys" name = "pq-sys"
version = "0.7.5" version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "574ddd6a267294433f140b02a726b0640c43cf7c6f717084684aaa3b285aba61" checksum = "9152654059e4fb5b66a4052acf3ee43a7a7132332e44cdb72a81ee93bd038950"
dependencies = [ dependencies = [
"libc", "libc",
"pkg-config", "pkg-config",
@ -4129,6 +4089,16 @@ dependencies = [
"serde", "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]] [[package]]
name = "quote" name = "quote"
version = "1.0.47" version = "1.0.47"
@ -4190,9 +4160,9 @@ dependencies = [
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.10.2" version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
dependencies = [ dependencies = [
"chacha20", "chacha20",
"getrandom 0.4.3", "getrandom 0.4.3",
@ -4329,9 +4299,9 @@ dependencies = [
[[package]] [[package]]
name = "reqsign-aws-core" name = "reqsign-aws-core"
version = "3.1.1" version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bac4749b7dfa7bfaccd01eb03e9dc795ed37e3f20d6f0f38e2c67ee85ad6bc86" checksum = "d38c902d8ec745bd9eadd1abbcbe227f4c1fc2926b2ec8929f89234af3114500"
dependencies = [ dependencies = [
"bytes", "bytes",
"form_urlencoded", "form_urlencoded",
@ -4339,7 +4309,7 @@ dependencies = [
"http 1.5.0", "http 1.5.0",
"log", "log",
"percent-encoding", "percent-encoding",
"quick-xml", "quick-xml 0.42.0",
"reqsign-core", "reqsign-core",
"rust-ini", "rust-ini",
"serde", "serde",
@ -4350,14 +4320,14 @@ dependencies = [
[[package]] [[package]]
name = "reqsign-aws-v4" name = "reqsign-aws-v4"
version = "3.3.0" version = "3.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff250f0fd0b913fbd565e405acc553da0f13bde30bfb5403178c9d0313cdc15f" checksum = "906bfd028f96bfe73fdb892ce26f3b3f99b4f30feb9bd024be58bd6657f68b2b"
dependencies = [ dependencies = [
"bytes", "bytes",
"http 1.5.0", "http 1.5.0",
"log", "log",
"quick-xml", "quick-xml 0.42.0",
"reqsign-aws-core", "reqsign-aws-core",
"reqsign-core", "reqsign-core",
"serde", "serde",
@ -4365,20 +4335,19 @@ dependencies = [
[[package]] [[package]]
name = "reqsign-core" name = "reqsign-core"
version = "3.3.1" version = "3.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff052daffb0599681c50f85c59e7236438976efe991ab864edd9f3b235501a0f" checksum = "85fed85c436b6b920e4b36310f699a77771a91e38ae483df48447bd7d162671c"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"asyncband",
"base64 0.23.1", "base64 0.23.1",
"bytes", "bytes",
"futures",
"hex", "hex",
"hmac 0.13.0", "hmac 0.13.0",
"http 1.5.0", "http 1.5.0",
"jiff", "jiff",
"log", "log",
"mea",
"percent-encoding", "percent-encoding",
"sha1 0.11.0", "sha1 0.11.0",
"sha2 0.11.0", "sha2 0.11.0",
@ -4387,9 +4356,9 @@ dependencies = [
[[package]] [[package]]
name = "reqsign-file-read-tokio" name = "reqsign-file-read-tokio"
version = "3.0.6" version = "3.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3235df90a6bca681aa47dd86f2393d122a6d77042aa8a7c81e218cd45c5bfc0" checksum = "586a8e198057773f9616901fd723a4bc186812b9765bc0f20368661d6169d906"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"reqsign-core", "reqsign-core",
@ -4429,7 +4398,7 @@ dependencies = [
"serde_urlencoded", "serde_urlencoded",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-rustls 0.26.5", "tokio-rustls 0.26.6",
"tokio-util", "tokio-util",
"tower", "tower",
"tower-http", "tower-http",
@ -4622,7 +4591,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
dependencies = [ dependencies = [
"hashbrown 0.16.1", "hashbrown 0.16.1",
"thiserror 2.0.20", "thiserror 2.0.21",
] ]
[[package]] [[package]]
@ -4735,9 +4704,9 @@ dependencies = [
[[package]] [[package]]
name = "rustls-platform-verifier" name = "rustls-platform-verifier"
version = "0.7.0" version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
dependencies = [ dependencies = [
"core-foundation 0.10.1", "core-foundation 0.10.1",
"core-foundation-sys", "core-foundation-sys",
@ -4756,9 +4725,9 @@ dependencies = [
[[package]] [[package]]
name = "rustls-platform-verifier-android" name = "rustls-platform-verifier-android"
version = "0.1.1" version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
[[package]] [[package]]
name = "rustls-webpki" name = "rustls-webpki"
@ -5024,9 +4993,9 @@ dependencies = [
[[package]] [[package]]
name = "serde_with" name = "serde_with"
version = "3.23.0" version = "3.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "935177bb8c0cd8ca1a4e6d1a2ac8988bea69cab4f9d3a31311e012ad27868ea4" checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f"
dependencies = [ dependencies = [
"base64 0.23.1", "base64 0.23.1",
"bs58", "bs58",
@ -5045,9 +5014,9 @@ dependencies = [
[[package]] [[package]]
name = "serde_with_macros" name = "serde_with_macros"
version = "3.23.0" version = "3.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d607aa01a3cb0ad757d6fd216136910db3c97b102fe686585689615a02dbcdc" checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49"
dependencies = [ dependencies = [
"darling 0.24.1", "darling 0.24.1",
"proc-macro2", "proc-macro2",
@ -5174,15 +5143,15 @@ checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
dependencies = [ dependencies = [
"num-bigint", "num-bigint",
"num-traits", "num-traits",
"thiserror 2.0.20", "thiserror 2.0.21",
"time", "time",
] ]
[[package]] [[package]]
name = "siphasher" name = "siphasher"
version = "1.0.3" version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
[[package]] [[package]]
name = "slab" name = "slab"
@ -5192,9 +5161,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]] [[package]]
name = "smallvec" name = "smallvec"
version = "1.16.1" version = "1.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
[[package]] [[package]]
name = "socket2" name = "socket2"
@ -5424,11 +5393,11 @@ dependencies = [
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.20" version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [ dependencies = [
"thiserror-impl 2.0.20", "thiserror-impl 2.0.21",
] ]
[[package]] [[package]]
@ -5444,9 +5413,9 @@ dependencies = [
[[package]] [[package]]
name = "thiserror-impl" name = "thiserror-impl"
version = "2.0.20" version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -5521,9 +5490,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.53.1" version = "1.53.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
dependencies = [ dependencies = [
"bytes", "bytes",
"libc", "libc",
@ -5559,9 +5528,9 @@ dependencies = [
[[package]] [[package]]
name = "tokio-rustls" name = "tokio-rustls"
version = "0.26.5" version = "0.26.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
dependencies = [ dependencies = [
"rustls 0.23.45", "rustls 0.23.45",
"tokio", "tokio",
@ -5935,9 +5904,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.26.1" version = "1.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a"
dependencies = [ dependencies = [
"getrandom 0.4.3", "getrandom 0.4.3",
"js-sys", "js-sys",
@ -6008,7 +5977,7 @@ dependencies = [
"pastey 0.2.3", "pastey 0.2.3",
"percent-encoding", "percent-encoding",
"pico-args", "pico-args",
"rand 0.10.2", "rand 0.10.3",
"regex", "regex",
"reqsign-aws-v4", "reqsign-aws-v4",
"reqsign-core", "reqsign-core",
@ -6093,9 +6062,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.128" version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"once_cell", "once_cell",
@ -6106,19 +6075,20 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-futures" name = "wasm-bindgen-futures"
version = "0.4.78" version = "0.4.79"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e"
dependencies = [ dependencies = [
"js-sys", "js-sys",
"tokio",
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]] [[package]]
name = "wasm-bindgen-macro" name = "wasm-bindgen-macro"
version = "0.2.128" version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
dependencies = [ dependencies = [
"quote", "quote",
"wasm-bindgen-macro-support", "wasm-bindgen-macro-support",
@ -6126,9 +6096,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro-support" name = "wasm-bindgen-macro-support"
version = "0.2.128" version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
dependencies = [ dependencies = [
"bumpalo", "bumpalo",
"proc-macro2", "proc-macro2",
@ -6139,9 +6109,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-shared" name = "wasm-bindgen-shared"
version = "0.2.128" version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
@ -6161,9 +6131,9 @@ dependencies = [
[[package]] [[package]]
name = "web-sys" name = "web-sys"
version = "0.3.105" version = "0.3.106"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4"
dependencies = [ dependencies = [
"js-sys", "js-sys",
"wasm-bindgen", "wasm-bindgen",
@ -6611,9 +6581,9 @@ dependencies = [
[[package]] [[package]]
name = "yoke-derive" name = "yoke-derive"
version = "0.8.3" version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -6636,18 +6606,18 @@ dependencies = [
[[package]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.57" version = "0.8.59"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb"
dependencies = [ dependencies = [
"zerocopy-derive", "zerocopy-derive",
] ]
[[package]] [[package]]
name = "zerocopy-derive" name = "zerocopy-derive"
version = "0.8.57" version = "0.8.59"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",

22
Cargo.toml

@ -1,6 +1,6 @@
[workspace.package] [workspace.package]
edition = "2024" edition = "2024"
rust-version = "1.96.1" rust-version = "1.97.1"
license = "AGPL-3.0-only" license = "AGPL-3.0-only"
repository = "https://github.com/dani-garcia/vaultwarden" repository = "https://github.com/dani-garcia/vaultwarden"
publish = false publish = false
@ -77,7 +77,7 @@ dotenvy = { version = "0.15.7", default-features = false }
# Numerical libraries # Numerical libraries
num-traits = "0.2.19" num-traits = "0.2.19"
num-derive = "0.5.1" num-derive = "0.5.1"
bigdecimal = "0.4.10" bigdecimal = "0.4.11"
# Web framework # Web framework
rocket = { version = "0.5.1", default-features = false, features = ["json", "tls"] } rocket = { version = "0.5.1", default-features = false, features = ["json", "tls"] }
@ -91,7 +91,7 @@ dashmap = "6.2.1"
# Async futures # Async futures
futures = "0.3.34" futures = "0.3.34"
tokio = { version = "1.53.1", features = [ tokio = { version = "1.53.2", features = [
"fs", "fs",
"io-util", "io-util",
"net", "net",
@ -123,13 +123,13 @@ diesel-derive-newtype = "2.1.3"
libsqlite3-sys = { version = "0.38.2", optional = true } libsqlite3-sys = { version = "0.38.2", optional = true }
# Crypto-related libraries # Crypto-related libraries
rand = "0.10.2" rand = "0.10.3"
ring = "0.17.14" ring = "0.17.14"
rustls = { version = "0.23.45", features = ["ring", "std"], default-features = false } rustls = { version = "0.23.45", features = ["ring", "std"], default-features = false }
subtle = "2.6.1" subtle = "2.6.1"
# UUID generation # UUID generation
uuid = { version = "1.26.1", features = ["v4"] } uuid = { version = "1.27.0", features = ["v4"] }
# Date and time libraries # Date and time libraries
chrono = { version = "0.4.45", default-features = false, features = ["clock", "serde"] } chrono = { version = "0.4.45", default-features = false, features = ["clock", "serde"] }
@ -215,7 +215,7 @@ bytes = "1.12.1"
svg-hush = "0.9.7" svg-hush = "0.9.7"
# Cache function results (Used for version check and favicon fetching) # 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 # Used for custom short lived cookie jar during favicon extraction
cookie = "0.18.2" cookie = "0.18.2"
@ -257,8 +257,8 @@ rpassword = "7.5.4"
grass_compiler = { version = "0.13.4", default-features = false } grass_compiler = { version = "0.13.4", default-features = false }
# File are accessed through Apache OpenDAL # File are accessed through Apache OpenDAL
opendal = { version = "0.59.2", default-features = false, features = ["services-fs"] } opendal = { version = "0.59.4", default-features = false, features = ["services-fs"] }
opendal-http-transport-reqwest = { version = "0.59.2", default-features = false, features = ["rustls-no-provider"], optional = true } 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 # For retrieving AWS credentials, including temporary SSO credentials
aws-config = { version = "1.12.0", optional = true, default-features = false, features = [ 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", "sso",
] } ] }
aws-credential-types = { version = "1.3.0", optional = true } 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 } http = { version = "1.5.0", optional = true }
reqsign-aws-v4 = { version = "3.3.0", optional = true } reqsign-aws-v4 = { version = "3.3.1", optional = true }
reqsign-core = { version = "3.3.1", optional = true } reqsign-core = { version = "3.3.2", optional = true }
# Strip debuginfo from the release builds # Strip debuginfo from the release builds
# The debug symbols are to provide better panic traces # The debug symbols are to provide better panic traces

30
README.md

@ -35,7 +35,7 @@ A nearly complete implementation of the Bitwarden Client API is provided, includ
* [Attachments](https://bitwarden.com/help/attachments/) * [Attachments](https://bitwarden.com/help/attachments/)
* [Website icons](https://bitwarden.com/help/website-icons/) * [Website icons](https://bitwarden.com/help/website-icons/)
* [Personal API Key](https://bitwarden.com/help/personal-api-key/) * [Personal API Key](https://bitwarden.com/help/personal-api-key/)
* [Passkey login and vault unlock](#passkeys-experimental) (experimental, opt-in) * [Passkey login and vault unlock](#passkeys)
* [Organizations](https://bitwarden.com/help/getting-started-organizations/) * [Organizations](https://bitwarden.com/help/getting-started-organizations/)
- [Collections](https://bitwarden.com/help/about-collections/), - [Collections](https://bitwarden.com/help/about-collections/),
[Password Sharing](https://bitwarden.com/help/sharing/), [Password Sharing](https://bitwarden.com/help/sharing/),
@ -76,21 +76,19 @@ While Vaultwarden is based upon the [Rocket web framework](https://rocket.rs) wh
> [!TIP] > [!TIP]
>**For more detailed examples on how to install, use and configure Vaultwarden you can check our [Wiki](https://github.com/dani-garcia/vaultwarden/wiki).** >**For more detailed examples on how to install, use and configure Vaultwarden you can check our [Wiki](https://github.com/dani-garcia/vaultwarden/wiki).**
### Passkeys (experimental) ### Passkeys
Set `PASSKEYS_ENABLED=true` to enable passkey registration and sign-in with Compatible clients can register passkeys and use them to sign in over HTTPS.
compatible clients. A passkey can also unlock the vault when the client, browser A passkey can also unlock the vault when the client, browser and authenticator
and authenticator support WebAuthn PRF and encryption is enabled during support WebAuthn PRF and encryption is enabled during enrollment. Without PRF,
enrollment. Without PRF, a passkey signs you in but the master password is still a passkey signs you in but the master password is still needed to unlock the
needed to unlock the vault. Existing master-password unlock remains available. vault. Existing master-password unlock remains available.
With `SSO_ONLY=true`, users must still sign in through their identity provider; SSO is optional. Installations configured with `SSO_ONLY=true` still require
an enrolled PRF-capable passkey can unlock the vault afterward. Passkeys do not identity-provider sign-in; an enrolled PRF-capable passkey can unlock the vault
reset a forgotten master password. Keep a working fallback and regular backups afterward. A passkey does not reset a forgotten master password. Keep a working
of your database and files before testing this experimental feature. Disabling fallback and regular backups of your database and files. An older server cannot
the flag rejects passkey sign-in and stops advertising passkey unlock without provide this new unlock method.
deleting registrations; it cannot revoke data already cached by a client. An
older server cannot provide this new unlock method.
### Docker/Podman CLI ### Docker/Podman CLI

4
docker/DockerSettings.yaml

@ -1,6 +1,6 @@
--- ---
vault_version: "v2026.7.0" vault_version: "v2026.8.0"
vault_image_digest: "sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c" vault_image_digest: "sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a"
# Cross Compile Docker Helper Scripts v1.9.0 # 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 # 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 # https://github.com/tonistiigi/xx | https://hub.docker.com/r/tonistiigi/xx/tags

20
docker/Dockerfile.alpine

@ -19,23 +19,23 @@
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags, # - 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. # click the tag name to view the digest of the image it currently points to.
# - From the command line: # - From the command line:
# $ docker pull docker.io/vaultwarden/web-vault:v2026.7.0 # $ docker pull docker.io/vaultwarden/web-vault:v2026.8.0
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.7.0 # $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.8.0
# [docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c] # [docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a]
# #
# - Conversely, to get the tag name from the digest: # - Conversely, to get the tag name from the digest:
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c # $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a
# [docker.io/vaultwarden/web-vault:v2026.7.0] # [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 ########################## ########################## ALPINE BUILD IMAGES ##########################
## NOTE: The Alpine Base Images do not support other platforms then linux/amd64 and linux/arm64 ## 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 ## 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:x86_64-musl-stable-1.99.0 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:aarch64-musl-stable-1.99.0 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:armv7-musleabihf-stable-1.99.0 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:arm-musleabi-stable-1.99.0 AS build_armv6
########################## BUILD IMAGE ########################## ########################## BUILD IMAGE ##########################
# hadolint ignore=DL3006 # hadolint ignore=DL3006

14
docker/Dockerfile.debian

@ -19,15 +19,15 @@
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags, # - 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. # click the tag name to view the digest of the image it currently points to.
# - From the command line: # - From the command line:
# $ docker pull docker.io/vaultwarden/web-vault:v2026.7.0 # $ docker pull docker.io/vaultwarden/web-vault:v2026.8.0
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.7.0 # $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.8.0
# [docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c] # [docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a]
# #
# - Conversely, to get the tag name from the digest: # - Conversely, to get the tag name from the digest:
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c # $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a
# [docker.io/vaultwarden/web-vault:v2026.7.0] # [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 ########################## ########################## Cross Compile Docker Helper Scripts ##########################
## We use the linux/amd64 no matter which Build Platform, since these are all bash 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 ########################## ########################## BUILD IMAGE ##########################
# hadolint ignore=DL3006 # 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 # hadolint ignore=DL3067
COPY --from=xx / / COPY --from=xx / /
ARG TARGETARCH ARG TARGETARCH

1
docker/README.md

@ -15,6 +15,7 @@ Some unsupported platforms for Debian based images. These are not built and test
- 386 (linux/386) - 386 (linux/386)
- ppc64le (linux/ppc64le) - ppc64le (linux/ppc64le)
- s390x (linux/s390x) - s390x (linux/s390x)
- riscv64 (linux/riscv64)
To build these containers you need to enable QEMU binfmt support to be able to run/emulate architectures which are different then your host.<br> To build these containers you need to enable QEMU binfmt support to be able to run/emulate architectures which are different then your host.<br>
This ensures the container build process can run binaries from other architectures.<br> This ensures the container build process can run binaries from other architectures.<br>

6
docker/docker-bake.hcl

@ -148,6 +148,12 @@ target "debian-s390x" {
platforms = ["linux/s390x"] platforms = ["linux/s390x"]
tags = generate_tags("", "-s390x") tags = generate_tags("", "-s390x")
} }
target "debian-riscv64" {
inherits = ["debian"]
platforms = ["linux/riscv64"]
tags = generate_tags("", "-riscv64")
}
// ==== End of unsupported Debian architecture targets === // ==== End of unsupported Debian architecture targets ===
// A Group to build all platforms individually for local testing // A Group to build all platforms individually for local testing

1
migrations/mysql/2026-10-07-120000_add_org_policy_revision_date/down.sql

@ -0,0 +1 @@
ALTER TABLE org_policies DROP COLUMN revision_date;

7
migrations/mysql/2026-10-07-120000_add_org_policy_revision_date/up.sql

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

1
migrations/postgresql/2026-10-07-120000_add_org_policy_revision_date/down.sql

@ -0,0 +1 @@
ALTER TABLE org_policies DROP COLUMN revision_date;

9
migrations/postgresql/2026-10-07-120000_add_org_policy_revision_date/up.sql

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

0
migrations/sqlite/2026-10-07-120000_add_org_policy_revision_date/down.sql

6
migrations/sqlite/2026-10-07-120000_add_org_policy_revision_date/up.sql

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

4
playwright/README.md

@ -2,7 +2,7 @@
This allows running integration tests using [Playwright](https://playwright.dev/). 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 ## Install
@ -62,7 +62,7 @@ DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Pl
### Keep services running ### 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 ```bash
PW_KEEP_SERVICE_RUNNING=true npx playwright test PW_KEEP_SERVICE_RUNNING=true npx playwright test

131
playwright/tests/emergency.spec.ts

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

37
playwright/tests/login.spec.ts

@ -3,7 +3,7 @@ import * as OTPAuth from "otpauth";
import * as utils from "../global-utils"; import * as utils from "../global-utils";
import { createAccount, logUser } from './setups/user'; import { createAccount, logUser } from './setups/user';
import { activateTOTP, disableTOTP } from './setups/2fa'; import { activateTOTP, disableTOTP, recoveryCodes } from './setups/2fa';
let users = utils.loadEnv(); let users = utils.loadEnv();
let totp; let totp;
@ -31,21 +31,42 @@ test('Authenticator 2fa', async ({ page }) => {
await utils.logout(test, page, users.user1); await utils.logout(test, page, users.user1);
await test.step('login', async () => { await logUser(test, page, users.user1, { totp });
let timestamp = Date.now(); // Needed to use the next token
timestamp = timestamp + (totp.period - (Math.floor(timestamp / 1000) % totp.period) + 1) * 1000; 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.getByLabel(/Email address/).fill(users.user1.email);
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user1.password); 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 page.getByRole('button', { name: 'Log in', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible(); 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 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/);
});
}); });

19
playwright/tests/setups/2fa.ts

@ -4,6 +4,24 @@ import * as OTPAuth from "otpauth";
import * as utils from '../../global-utils'; 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 { export async function activateTOTP(test: Test, page: Page, user: { name: string, password: string }): OTPAuth.TOTP {
return await test.step('Activate TOTP 2FA', async () => { return await test.step('Activate TOTP 2FA', async () => {
await page.getByRole('button', { name: user.name }).click(); 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.getByLabel(/Verification code/).fill(totp.generate());
await page.getByRole('button', { name: 'Turn on' }).click(); await page.getByRole('button', { name: 'Turn on' }).click();
await page.getByRole('heading', { name: 'Turned on', exact: true }); await page.getByRole('heading', { name: 'Turned on', exact: true });
await page.getByLabel('Close').click();
return totp; return totp;
}) })

18
playwright/tests/setups/user.ts

@ -2,6 +2,7 @@ import { expect, type Browser, Page } from '@playwright/test';
import { type MailBuffer } from 'maildev'; import { type MailBuffer } from 'maildev';
import * as OTPAuth from "otpauth";
import * as utils from '../../global-utils'; import * as utils from '../../global-utils';
import { retrieveEmailCode } from './2fa'; import { retrieveEmailCode } from './2fa';
@ -43,6 +44,7 @@ export async function logUser(
mailBuffer ?: MailBuffer, mailBuffer ?: MailBuffer,
mail2fa?: boolean, mail2fa?: boolean,
notNewDevice?: boolean, notNewDevice?: boolean,
totp?: OTPAuth.TOTP,
} = {} } = {}
) { ) {
await test.step(`Log user ${user.email}`, async () => { 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('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
await page.getByRole('button', { name: 'Log in', exact: true }).click(); 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 test.step('2FA check', async () => {
await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible(); 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.getByLabel(/Verification code/).fill(code);
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
}); });
} }

2
rust-toolchain.toml

@ -1,4 +1,4 @@
[toolchain] [toolchain]
channel = "1.98.1" channel = "1.99.0"
components = [ "rustfmt", "clippy" ] components = [ "rustfmt", "clippy" ]
profile = "minimal" profile = "minimal"

27
src/api/admin.rs

@ -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 { async fn remove_2fa(user_id: UserId, token: AdminToken, conn: DbConn) -> EmptyResult {
let mut user = get_user_or_404(&user_id, &conn).await?; let mut user = get_user_or_404(&user_id, &conn).await?;
TwoFactor::delete_all_by_user(&user.uuid, &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?; two_factor::enforce_2fa_policy(&user, &ACTING_ADMIN_USER.into(), 14, &token.ip.ip, &conn).await?;
user.totp_recover = None; user.totp_recover = None;
user.save(&conn).await user.save(&conn).await
@ -905,23 +906,23 @@ mod tests {
fn validate_web_vault_compare() { fn validate_web_vault_compare() {
// web_vault_compare(active, latest) // web_vault_compare(active, latest)
// Test normal versions // Test normal versions
assert!(web_vault_compare("2025.12.0", "2025.12.1") == -1); assert_eq!(web_vault_compare("2025.12.0", "2025.12.1"), -1);
assert!(web_vault_compare("2025.12.1", "2025.12.1") == 0); assert_eq!(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.2", "2025.12.1"), 1);
// Test patched/+build.n versions // Test patched/+build.n versions
// Newer latest version // Newer latest version
assert!(web_vault_compare("2025.12.0+build.1", "2025.12.1") == -1); assert_eq!(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_eq!(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_eq!(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.1+build.1", "2025.12.1+build.2"), -1);
// Equal versions // Equal versions
assert!(web_vault_compare("2025.12.1+build.1", "2025.12.1+build.1") == 0); assert_eq!(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.2+build.2", "2025.12.2+build.2"), 0);
// Newer active version // Newer active version
assert!(web_vault_compare("2025.12.1+build.1", "2025.12.1") == 1); assert_eq!(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_eq!(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_eq!(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.3", "2025.12.1+build.2"), 1);
} }
} }

303
src/api/core/accounts.rs

@ -32,7 +32,7 @@ use crate::{
use super::{ use super::{
ciphers::{CipherData, update_cipher_from_data}, ciphers::{CipherData, update_cipher_from_data},
sends::{SendData, update_send_from_data}, sends::SendData,
}; };
pub fn routes() -> Vec<rocket::Route> { pub fn routes() -> Vec<rocket::Route> {
@ -42,6 +42,7 @@ pub fn routes() -> Vec<rocket::Route> {
post_profile, post_profile,
put_avatar, put_avatar,
get_public_keys, get_public_keys,
get_keys,
post_keys, post_keys,
post_password, post_password,
post_set_password, post_set_password,
@ -59,7 +60,6 @@ pub fn routes() -> Vec<rocket::Route> {
delete_account, delete_account,
revision_date, revision_date,
password_hint, password_hint,
post_prelogin,
verify_password, verify_password,
post_api_key, post_api_key,
rotate_api_key, rotate_api_key,
@ -93,6 +93,15 @@ pub struct KDFData {
kdf_parallelism: Option<i32>, kdf_parallelism: Option<i32>,
} }
impl KDFData {
pub(super) fn matches_user(&self, user: &User) -> bool {
self.kdf == user.client_kdf_type
&& self.kdf_iterations == user.client_kdf_iter
&& self.kdf_memory == user.client_kdf_memory
&& self.kdf_parallelism == user.client_kdf_parallelism
}
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct RegisterData { pub struct RegisterData {
@ -106,11 +115,8 @@ pub struct RegisterData {
master_password_hint: Option<String>, master_password_hint: Option<String>,
name: Option<String>,
organization_user_id: Option<MembershipId>, organization_user_id: Option<MembershipId>,
// Used only from the register/finish endpoint
email_verification_token: Option<String>, email_verification_token: Option<String>,
accept_emergency_access_id: Option<EmergencyAccessId>, accept_emergency_access_id: Option<EmergencyAccessId>,
accept_emergency_access_invite_token: Option<String>, accept_emergency_access_invite_token: Option<String>,
@ -163,7 +169,9 @@ struct RegisterDataOld {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct RegisterDataCur { struct RegisterDataCur {
#[serde(alias = "MasterPasswordAuthentication")]
master_password_authentication: MasterPasswordAuthentication, master_password_authentication: MasterPasswordAuthentication,
#[serde(alias = "MasterPasswordUnlock")]
master_password_unlock: MasterPasswordUnlock, master_password_unlock: MasterPasswordUnlock,
} }
@ -197,10 +205,12 @@ struct KeysData {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct MasterPasswordAuthentication { pub struct MasterPasswordAuthentication {
#[serde(alias = "Kdf")]
kdf: KDFData, kdf: KDFData,
#[serde(alias = "Salt")]
salt: String, salt: String,
#[serde(alias = "masterPasswordAuthenticationHash")] #[serde(alias = "masterPasswordAuthenticationHash", alias = "MasterPasswordAuthenticationHash")]
hash: String, hash: String,
} }
@ -257,12 +267,11 @@ async fn is_email_2fa_required(member_id: Option<MembershipId>, conn: &DbConn) -
false false
} }
pub async fn register(data: Json<RegisterData>, email_verification: bool, conn: DbConn) -> JsonResult { pub async fn register(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
let mut data: RegisterData = data.into_inner(); let data: RegisterData = data.into_inner();
let email = data.email.to_lowercase(); let email = data.email.to_lowercase();
let mut email_verified = false; let mut name = None;
let mut pending_emergency_access = None; let mut pending_emergency_access = None;
if data.unprocessable() { if data.unprocessable() {
@ -270,69 +279,65 @@ pub async fn register(data: Json<RegisterData>, email_verification: bool, conn:
} }
// First, validate the provided verification tokens // First, validate the provided verification tokens
if email_verification { let mut email_verified = match (
match ( &data.email_verification_token,
&data.email_verification_token, &data.accept_emergency_access_id,
&data.accept_emergency_access_id, &data.accept_emergency_access_invite_token,
&data.accept_emergency_access_invite_token, &data.organization_user_id,
&data.organization_user_id, &data.org_invite_token,
&data.org_invite_token, ) {
) { // Normal user registration, when email verification is required
// Normal user registration, when email verification is required (Some(email_verification_token), None, None, None, None) => {
(Some(email_verification_token), None, None, None, None) => { let claims = crate::auth::decode_register_verify(email_verification_token)?;
let claims = crate::auth::decode_register_verify(email_verification_token)?; if claims.sub != data.email {
if claims.sub != data.email { err!("Email verification token does not match email");
err!("Email verification token does not match email");
}
// During this call we don't get the name, so extract it from the claims
if claims.name.is_some() {
data.name = claims.name;
}
email_verified = claims.verified;
} }
// Emergency access registration
(None, Some(accept_emergency_access_id), Some(accept_emergency_access_invite_token), None, None) => {
if !CONFIG.emergency_access_allowed() {
err!("Emergency access is not enabled.")
}
let claims = crate::auth::decode_emergency_access_invite(accept_emergency_access_invite_token)?; // During this call we don't get the name, so extract it from the claims
name = claims.name;
claims.verified
}
// Emergency access registration
(None, Some(accept_emergency_access_id), Some(accept_emergency_access_invite_token), None, None) => {
if !CONFIG.emergency_access_allowed() {
err!("Emergency access is not enabled.")
}
if claims.email != data.email { let claims = crate::auth::decode_emergency_access_invite(accept_emergency_access_invite_token)?;
err!("Claim email does not match email")
}
if &claims.emer_id != accept_emergency_access_id {
err!("Claim emer_id does not match accept_emergency_access_id")
}
pending_emergency_access = Some((accept_emergency_access_id, claims)); if claims.email != data.email {
email_verified = true; err!("Claim email does not match email")
}
if &claims.emer_id != accept_emergency_access_id {
err!("Claim emer_id does not match accept_emergency_access_id")
} }
// Org invite
(None, None, None, Some(organization_user_id), Some(org_invite_token)) => {
let claims = decode_invite(org_invite_token)?;
if claims.email != data.email {
err!("Claim email does not match email")
}
if &claims.member_id != organization_user_id { pending_emergency_access = Some((accept_emergency_access_id, claims));
err!("Claim org_user_id does not match organization_user_id") true
} }
// Org invite
(None, None, None, Some(organization_user_id), Some(org_invite_token)) => {
let claims = decode_invite(org_invite_token)?;
email_verified = true; if claims.email != data.email {
err!("Claim email does not match email")
} }
_ => { if &claims.member_id != organization_user_id {
err!("Registration is missing required parameters") err!("Claim org_user_id does not match organization_user_id")
} }
true
} }
}
_ => {
err!("Registration is missing required parameters")
}
};
// Check if the length of the username exceeds 50 characters (Same is Upstream Bitwarden) // Check if the length of the username exceeds 50 characters (Same is Upstream Bitwarden)
// This also prevents issues with very long usernames causing to large JWT's. See #2419 // This also prevents issues with very long usernames causing to large JWT's. See #2419
if let Some(ref name) = data.name if let Some(ref name) = name
&& name.len() > 50 && name.len() > 50
{ {
err!("The field Name must be a string with a maximum length of 50."); err!("The field Name must be a string with a maximum length of 50.");
@ -394,7 +399,7 @@ pub async fn register(data: Json<RegisterData>, email_verification: bool, conn:
user.password_hint = password_hint; user.password_hint = password_hint;
// Add extra fields if present // Add extra fields if present
if let Some(name) = data.name { if let Some(name) = name {
user.name = name; user.name = name;
} }
@ -432,8 +437,7 @@ pub async fn register(data: Json<RegisterData>, email_verification: bool, conn:
} }
Ok(Json(json!({ Ok(Json(json!({
"object": "register", "object": "registerFinish",
"captchaBypassToken": "",
}))) })))
} }
@ -442,7 +446,7 @@ async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn:
let data: SetPasswordData = data.into_inner(); let data: SetPasswordData = data.into_inner();
let mut user = headers.user; let mut user = headers.user;
if user.private_key.is_some() { if user.private_key.is_some() || !user.password_hash.is_empty() {
err!("Account already initialized, cannot set password") err!("Account already initialized, cannot set password")
} }
@ -489,14 +493,12 @@ async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn:
Membership::accept_user_invitations(&user.uuid, &conn).await?; Membership::accept_user_invitations(&user.uuid, &conn).await?;
} }
log_user_event(EventType::UserChangedPassword as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn) log_user_event(EventType::UserChangedPassword, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
.await;
user.save(&conn).await?; user.save(&conn).await?;
Ok(Json(json!({ Ok(Json(json!({
"object": "set-password", "object": "set-password",
"captchaBypassToken": "",
}))) })))
} }
@ -575,12 +577,34 @@ async fn get_public_keys(user_id: UserId, _headers: Headers, conn: DbConn) -> Js
}))) })))
} }
#[get("/accounts/keys")]
fn get_keys(headers: Headers) -> JsonResult {
let user = headers.user;
// The SDK reads a 404 as the user having no key pair yet
if user.private_key.is_none() || user.public_key.is_none() {
err_code!("User has no key pair", Status::NotFound.code)
}
Ok(Json(json!({
"key": (!user.akey.is_empty()).then_some(&user.akey),
"publicKey": user.public_key,
"privateKey": user.private_key,
"accountKeys": user.account_keys_json(),
"object": "keys",
})))
}
#[post("/accounts/keys", data = "<data>")] #[post("/accounts/keys", data = "<data>")]
async fn post_keys(data: Json<KeysData>, headers: Headers, conn: DbConn) -> JsonResult { async fn post_keys(data: Json<KeysData>, headers: Headers, conn: DbConn) -> JsonResult {
let data: KeysData = data.into_inner(); let data: KeysData = data.into_inner();
let mut user = headers.user; let mut user = headers.user;
if user.private_key.is_some() || user.public_key.is_some() {
err!("User has existing keypair")
}
user.replace_keypair_if_current(data.encrypted_private_key, data.public_key, &conn).await?; user.replace_keypair_if_current(data.encrypted_private_key, data.public_key, &conn).await?;
Ok(Json(json!({ Ok(Json(json!({
@ -612,8 +636,7 @@ async fn post_password(data: Json<ChangePassData>, headers: Headers, conn: DbCon
err!("Invalid password") err!("Invalid password")
} }
log_user_event(EventType::UserChangedPassword as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn) log_user_event(EventType::UserChangedPassword, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
.await;
let (new_master_password_hash, new_key) = let (new_master_password_hash, new_key) =
if let (Some(unlock_data), Some(authentication_data)) = (data.unlock_data, data.authentication_data) { if let (Some(unlock_data), Some(authentication_data)) = (data.unlock_data, data.authentication_data) {
@ -698,18 +721,35 @@ fn set_kdf_data(user: &mut User, data: &KDFData) -> EmptyResult {
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct AuthenticationData { pub(super) struct AuthenticationData {
#[serde(alias = "Salt")]
salt: String, salt: String,
kdf: KDFData, #[serde(alias = "Kdf")]
master_password_authentication_hash: String, pub(super) kdf: KDFData,
#[serde(alias = "MasterPasswordAuthenticationHash")]
pub(super) master_password_authentication_hash: String,
}
impl AuthenticationData {
pub(super) fn check(&self, user: &User, unlock: &UnlockData) -> EmptyResult {
if self.kdf != unlock.kdf {
err!("KDF settings must be equal for authentication and unlock")
}
if self.salt != user.master_password_salt() || self.salt != unlock.salt {
err!("Invalid master password salt")
}
Ok(())
}
} }
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct UnlockData { pub(super) struct UnlockData {
salt: String, salt: String,
kdf: KDFData, kdf: KDFData,
master_key_wrapped_user_key: String, pub(super) master_key_wrapped_user_key: String,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@ -728,13 +768,7 @@ async fn post_kdf(data: Json<ChangeKdfData>, headers: Headers, conn: DbConn, nt:
err!("Invalid password") err!("Invalid password")
} }
if data.authentication_data.kdf != data.unlock_data.kdf { data.authentication_data.check(&headers.user, &data.unlock_data)?;
err!("KDF settings must be equal for authentication and unlock")
}
if headers.user.email != data.authentication_data.salt || headers.user.email != data.unlock_data.salt {
err!("Invalid master password salt")
}
let mut user = headers.user; let mut user = headers.user;
@ -918,12 +952,8 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
// TODO: See if we can optimize the whole cipher adding/importing and prevent duplicate code and checks. // TODO: See if we can optimize the whole cipher adding/importing and prevent duplicate code and checks.
Cipher::validate_cipher_data(&data.account_data.ciphers)?; Cipher::validate_cipher_data(&data.account_data.ciphers)?;
let atomic_passkeys = CONFIG.passkeys_enabled()
|| !crate::db::models::WebAuthnCredential::find_by_user(&headers.user.uuid, &conn).await?.is_empty();
let rotation = async { let rotation = async {
if atomic_passkeys { crate::db::models::PasskeyAccount::from_user(&headers.user)?.lock_for_rotation(&conn).await?;
crate::db::models::PasskeyAccount::from_user(&headers.user)?.lock_for_rotation(&conn).await?;
}
let user_id = &headers.user.uuid; let user_id = &headers.user.uuid;
let mut existing_ciphers = Cipher::find_owned_by_user(user_id, &conn).await; let mut existing_ciphers = Cipher::find_owned_by_user(user_id, &conn).await;
@ -992,18 +1022,20 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
// Update send data // Update send data
for send_data in data.account_data.sends { for send_data in data.account_data.sends {
let Some(send) = existing_sends.iter_mut().find(|s| &s.uuid == send_data.id.as_ref().unwrap()) else { let Some(send) = send_data.id.as_ref().and_then(|id| existing_sends.iter_mut().find(|s| &s.uuid == id))
else {
err!("Send doesn't exist") err!("Send doesn't exist")
}; };
update_send_from_data(send, send_data, &headers, &conn, &nt, UpdateType::None).await?; send.akey = send_data.key;
send.save(&conn).await?;
} }
// Update cipher data // Update cipher data
for cipher_data in data.account_data.ciphers { for cipher_data in data.account_data.ciphers {
if cipher_data.organization_id.is_none() { if cipher_data.organization_id.is_none() {
let Some(saved_cipher) = let Some(saved_cipher) =
existing_ciphers.iter_mut().find(|c| &c.uuid == cipher_data.id.as_ref().unwrap()) cipher_data.id.as_ref().and_then(|id| existing_ciphers.iter_mut().find(|c| &c.uuid == id))
else { else {
err!("Cipher doesn't exist") err!("Cipher doesn't exist")
}; };
@ -1047,11 +1079,7 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
save_result?; save_result?;
Ok((user, headers.device)) Ok((user, headers.device))
}; };
let (user, device) = if atomic_passkeys { let (user, device) = conn.transaction(rotation).await?;
conn.transaction(rotation).await?
} else {
rotation.await?
};
// Notify only after commit. Preserve the initiating client's native session behavior. // Notify only after commit. Preserve the initiating client's native session behavior.
nt.send_logout(&user, Some(&device), &conn).await; nt.send_logout(&user, Some(&device), &conn).await;
Ok(()) Ok(())
@ -1388,19 +1416,33 @@ pub struct PreloginData {
email: String, email: String,
} }
#[post("/accounts/prelogin", data = "<data>")] /// Like upstream, an unknown email gets KDF settings picked from a list of common ones by a keyed hash of the email,
async fn post_prelogin(data: Json<PreloginData>, ip: ClientIp, conn: DbConn) -> JsonResult { /// so they stay the same between requests. The salt stays null, as for existing users.
prelogin(data, ip, conn).await fn unknown_email_kdf(email: &str) -> (i32, i32, Option<i32>, Option<i32>) {
const PBKDF2: i32 = UserKdfType::Pbkdf2 as i32;
const ARGON2ID: i32 = UserKdfType::Argon2id as i32;
// Same list as upstream, the default is in it twice to give it more weight
const KDF_SETTINGS: [(i32, i32, Option<i32>, Option<i32>); 6] = [
(User::CLIENT_KDF_TYPE_DEFAULT, User::CLIENT_KDF_ITER_DEFAULT, None, None),
(User::CLIENT_KDF_TYPE_DEFAULT, User::CLIENT_KDF_ITER_DEFAULT, None, None),
(PBKDF2, 100_000, None, None),
(PBKDF2, 5_000, None, None),
(ARGON2ID, 3, Some(64), Some(4)),
(ARGON2ID, 6, Some(32), Some(4)),
];
KDF_SETTINGS[crate::auth::prelogin_kdf_index(email, KDF_SETTINGS.len())]
} }
pub async fn prelogin(data: Json<PreloginData>, ip: ClientIp, conn: DbConn) -> JsonResult { pub async fn prelogin(data: Json<PreloginData>, ip: ClientIp, conn: DbConn) -> JsonResult {
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?; crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
let data: PreloginData = data.into_inner(); let data: PreloginData = data.into_inner();
// Normalized once, so the lookup and the settings for an unknown email use the same value
let email = data.email.trim().to_lowercase();
let (kdf_type, kdf_iter, kdf_mem, kdf_para) = match User::find_by_mail(&data.email, &conn).await { let (kdf_type, kdf_iter, kdf_mem, kdf_para) = match User::find_by_mail(&email, &conn).await {
Some(user) => (user.client_kdf_type, user.client_kdf_iter, user.client_kdf_memory, user.client_kdf_parallelism), Some(user) => (user.client_kdf_type, user.client_kdf_iter, user.client_kdf_memory, user.client_kdf_parallelism),
None => (User::CLIENT_KDF_TYPE_DEFAULT, User::CLIENT_KDF_ITER_DEFAULT, None, None), None => unknown_email_kdf(&email),
}; };
Ok(Json(json!({ Ok(Json(json!({
@ -1673,7 +1715,7 @@ async fn post_auth_request(
nt.send_auth_request(&user.uuid, &auth_request.uuid, &device, &conn).await; nt.send_auth_request(&user.uuid, &auth_request.uuid, &device, &conn).await;
log_user_event( log_user_event(
EventType::UserRequestedDeviceApproval as i32, EventType::UserRequestedDeviceApproval,
&user.uuid, &user.uuid,
client_headers.device_type, client_headers.device_type,
&client_headers.ip.ip, &client_headers.ip.ip,
@ -1767,7 +1809,7 @@ async fn put_auth_request(
nt.send_auth_response(&auth_request.user_uuid, &auth_request.uuid, &headers.device, &conn).await; nt.send_auth_response(&auth_request.user_uuid, &auth_request.uuid, &headers.device, &conn).await;
log_user_event( log_user_event(
EventType::OrganizationUserApprovedAuthRequest as i32, EventType::OrganizationUserApprovedAuthRequest,
&headers.user.uuid, &headers.user.uuid,
headers.device.atype, headers.device.atype,
&headers.ip.ip, &headers.ip.ip,
@ -1778,7 +1820,7 @@ async fn put_auth_request(
// If denied, there's no reason to keep the request // If denied, there's no reason to keep the request
auth_request.delete(&conn).await?; auth_request.delete(&conn).await?;
log_user_event( log_user_event(
EventType::OrganizationUserRejectedAuthRequest as i32, EventType::OrganizationUserRejectedAuthRequest,
&headers.user.uuid, &headers.user.uuid,
headers.device.atype, headers.device.atype,
&headers.ip.ip, &headers.ip.ip,
@ -1884,3 +1926,62 @@ pub async fn purge_auth_requests(pool: DbPool) {
error!("Failed to get DB connection while purging auth requests"); error!("Failed to get DB connection while purging auth requests");
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_register_data_current_format() {
// Web and iOS since the master password authentication/unlock split
let data: RegisterData = serde_json::from_str(
r#"{
"email": "user@example.com",
"emailVerificationToken": "token",
"userAsymmetricKeys": {"publicKey": "pub", "encryptedPrivateKey": "priv"},
"masterPasswordAuthentication": {
"kdf": {"kdfType": 0, "iterations": 600000},
"salt": "user@example.com",
"masterPasswordAuthenticationHash": "hash"
},
"masterPasswordUnlock": {
"kdf": {"kdfType": 0, "iterations": 600000},
"salt": "user@example.com",
"masterKeyWrappedUserKey": "key"
}
}"#,
)
.unwrap();
assert!(!data.unprocessable());
assert_eq!(data.hash(), "hash");
assert_eq!(data.key(), "key");
assert_eq!(data.kdf().kdf_iterations, 600_000);
assert!(data.keys.is_some());
assert_eq!(data.email_verification_token.as_deref(), Some("token"));
}
#[test]
fn test_register_data_flat_format() {
// Android before 2026.9.0
let data: RegisterData = serde_json::from_str(
r#"{
"email": "user@example.com",
"emailVerificationToken": "token",
"masterPasswordHash": "hash",
"masterPasswordHint": null,
"userSymmetricKey": "key",
"userAsymmetricKeys": {"publicKey": "pub", "encryptedPrivateKey": "priv"},
"kdf": 0,
"kdfIterations": 600000
}"#,
)
.unwrap();
assert!(!data.unprocessable());
assert_eq!(data.hash(), "hash");
assert_eq!(data.key(), "key");
assert_eq!(data.kdf().kdf_iterations, 600_000);
assert!(data.keys.is_some());
}
}

23
src/api/core/ciphers.rs

@ -29,7 +29,7 @@ use crate::{
util::{NumberOrString, deser_opt_nonempty_str, save_temp_file}, 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> { pub fn routes() -> Vec<Route> {
// Note that many routes have an `admin` variant; this seems to be // Note that many routes have an `admin` variant; this seems to be
@ -448,10 +448,10 @@ 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. // 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. // 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 { fn clean_cipher_data(mut json_data: Value) -> Value {
if json_data.is_array() { if let Some(items) = json_data.as_array_mut() {
json_data.as_array_mut().unwrap().iter_mut().for_each(|ref mut f| { for item in items.iter_mut().filter_map(Value::as_object_mut) {
f.as_object_mut().unwrap().remove("response"); item.remove("response");
}); }
} }
json_data json_data
} }
@ -493,9 +493,10 @@ pub async fn update_cipher_from_data(
Some(member) => { Some(member) => {
// A non-empty list of collections implies the caller already validated the user's write // 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. // 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()) if shared_to_collections.as_ref().is_some_and(|cols| !cols.is_empty())
|| member.has_full_access() || 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); cipher.organization_uuid = Some(org_id);
// After some discussion in PR #1329 re-added the user_uuid = None again. // After some discussion in PR #1329 re-added the user_uuid = None again.
@ -559,7 +560,9 @@ pub async fn update_cipher_from_data(
let type_data = if let Some(mut data) = type_data_opt { let type_data = if let Some(mut data) = type_data_opt {
// Remove the 'Response' key from the base object. // Remove the 'Response' key from the base object.
data.as_object_mut().unwrap().remove("response"); if let Some(data_obj) = data.as_object_mut() {
data_obj.remove("response");
}
// Remove the 'Response' key from every Uri. // Remove the 'Response' key from every Uri.
if data["uris"].is_array() { if data["uris"].is_array() {
data["uris"] = clean_cipher_data(data["uris"].clone()); data["uris"] = clean_cipher_data(data["uris"].clone());
@ -1189,6 +1192,10 @@ async fn post_attachment_v2(
if file_size < 0 { if file_size < 0 {
err!("Attachment size can't be negative") 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_id = crypto::generate_attachment_id();
let attachment = let attachment =
Attachment::new(attachment_id.clone(), cipher.uuid.clone(), data.file_name, file_size, Some(data.key)); Attachment::new(attachment_id.clone(), cipher.uuid.clone(), data.file_name, file_size, Some(data.key));
@ -1876,7 +1883,7 @@ async fn delete_cipher_by_uuid(
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct CipherIdsData { struct CipherIdsData {
ids: Vec<CipherId>, ids: HashSet<CipherId>,
} }
async fn delete_multiple_ciphers( async fn delete_multiple_ciphers(

36
src/api/core/emergency_access.rs

@ -6,7 +6,10 @@ use crate::{
CONFIG, CONFIG,
api::{ api::{
EmptyResult, JsonResult, EmptyResult, JsonResult,
core::{CipherSyncData, CipherSyncType}, core::{
CipherSyncData, CipherSyncType,
accounts::{AuthenticationData, UnlockData},
},
}, },
auth::{Headers, decode_emergency_access_invite}, auth::{Headers, decode_emergency_access_invite},
db::{ 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 { let (grantee_user, new_user) = match User::find_by_mail(&email, &conn).await {
None => { None => {
if !CONFIG.invitations_allowed() { 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) { 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, "kdfMemory": grantor_user.client_kdf_memory,
"kdfParallelism": grantor_user.client_kdf_parallelism, "kdfParallelism": grantor_user.client_kdf_parallelism,
"keyEncrypted": &emergency_access.key_encrypted, "keyEncrypted": &emergency_access.key_encrypted,
"salt": grantor_user.master_password_salt(),
"object": "emergencyAccessTakeover", "object": "emergencyAccessTakeover",
}); });
@ -624,8 +628,13 @@ async fn takeover_emergency_access(emer_id: EmergencyAccessId, headers: Headers,
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct EmergencyAccessPasswordData { struct EmergencyAccessPasswordData {
new_master_password_hash: String, // Legacy payload
key: String, 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>")] #[post("/emergency-access/<emer_id>/password", data = "<data>")]
@ -638,8 +647,6 @@ async fn password_emergency_access(
check_emergency_access_enabled()?; check_emergency_access_enabled()?;
let data: EmergencyAccessPasswordData = data.into_inner(); 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 requesting_user = headers.user;
let Some(emergency_access) = let Some(emergency_access) =
@ -656,8 +663,23 @@ async fn password_emergency_access(
err!("Grantor user not found.") 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 // 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?; grantor_user.save(&conn).await?;
// Disable TwoFactor providers since they will otherwise block logins // Disable TwoFactor providers since they will otherwise block logins

47
src/api/core/events.rs

@ -6,12 +6,13 @@ use serde_json::Value;
use crate::{ use crate::{
CONFIG, CONFIG,
api::{EmptyResult, JsonResult}, api::{ApiResult, EmptyResult, JsonResult},
auth::{AdminHeaders, Headers}, auth::{AdminHeaders, Headers},
db::{ db::{
DbConn, DbPool, DbConn, DbPool,
models::{Cipher, CipherId, Event, EventType, Membership, MembershipId, OrganizationId, UserId}, models::{Cipher, CipherId, Event, EventType, Membership, MembershipId, OrganizationId, UserId},
}, },
error::MapResult,
util::parse_date, util::parse_date,
}; };
@ -29,6 +30,14 @@ struct EventRange {
continuation_token: Option<String>, 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 // Upstream: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Controllers/EventsController.cs#L87
#[get("/organizations/<org_id>/events?<data..>")] #[get("/organizations/<org_id>/events?<data..>")]
async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: AdminHeaders, conn: DbConn) -> JsonResult { 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. // Return an empty vec when we org events are disabled.
// This prevents client errors // This prevents client errors
let events_json: Vec<Value> = if CONFIG.org_events_enabled() { let events_json: Vec<Value> = if CONFIG.org_events_enabled() {
let start_date = parse_date(&data.start); let (start_date, end_date) = data.date_range()?;
let end_date = if let Some(before_date) = &data.continuation_token {
parse_date(before_date)
} else {
parse_date(&data.end)
};
Event::find_by_organization_uuid(&org_id, &start_date, &end_date, &conn) Event::find_by_organization_uuid(&org_id, &start_date, &end_date, &conn)
.await .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() 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 && Membership::user_has_ge_admin_access_to_cipher(&headers.user.uuid, &cipher_id, &conn).await
{ {
let start_date = parse_date(&data.start); let (start_date, end_date) = data.date_range()?;
let end_date = if let Some(before_date) = &data.continuation_token {
parse_date(before_date)
} else {
parse_date(&data.end)
};
Event::find_by_cipher_uuid(&cipher_id, &start_date, &end_date, &conn).await.iter().map(Event::to_json).collect() Event::find_by_cipher_uuid(&cipher_id, &start_date, &end_date, &conn).await.iter().map(Event::to_json).collect()
} else { } else {
@ -102,12 +101,7 @@ async fn get_user_events(
// Return an empty vec when we org events are disabled. // Return an empty vec when we org events are disabled.
// This prevents client errors // This prevents client errors
let events_json: Vec<Value> = if CONFIG.org_events_enabled() { let events_json: Vec<Value> = if CONFIG.org_events_enabled() {
let start_date = parse_date(&data.start); let (start_date, end_date) = data.date_range()?;
let end_date = if let Some(before_date) = &data.continuation_token {
parse_date(before_date)
} else {
parse_date(&data.end)
};
Event::find_by_org_and_member(&org_id, &member_id, &start_date, &end_date, &conn) Event::find_by_org_and_member(&org_id, &member_id, &start_date, &end_date, &conn)
.await .await
@ -168,9 +162,10 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
} }
for event in data.iter() { 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 { match event.r#type {
1000..=1099 => { t if t == EventType::UserClientExportedVault as i32 => {
log_user_event_impl( log_user_event_impl(
event.r#type, event.r#type,
&headers.user.uuid, &headers.user.uuid,
@ -181,7 +176,7 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
) )
.await; .await;
} }
1600..=1699 => { t if t == EventType::OrganizationClientExportedVault as i32 => {
// Only allow logging events for an organization the user is actually a member of. // Only allow logging events for an organization the user is actually a member of.
if let Some(org_id) = &event.organization_id if let Some(org_id) = &event.organization_id
&& Membership::find_confirmed_by_user_and_org(&headers.user.uuid, org_id, &conn).await.is_some() && 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; .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 // 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. // user can actually access it instead of trusting the provided cipher uuid.
if let Some(cipher_uuid) = &event.cipher_id if let Some(cipher_uuid) = &event.cipher_id
@ -240,16 +236,17 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
.await; .await;
} }
} }
_ => {}
} }
} }
Ok(()) 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() { if !CONFIG.org_events_enabled() {
return; 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( async fn log_user_event_impl(

284
src/api/core/organizations.rs

@ -15,10 +15,10 @@ use crate::{
db::{ db::{
DbConn, DbConn,
models::{ models::{
Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, EventType, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, Device,
Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus, MembershipType, EventType, Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus,
OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, TwoFactor, TwoFactorType, User, MembershipType, OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, TwoFactor,
UserId, TwoFactorType, User, UserId,
}, },
}, },
mail, mail,
@ -26,12 +26,13 @@ use crate::{
util::{NumberOrString, convert_json_key_lcase_first}, util::{NumberOrString, convert_json_key_lcase_first},
}; };
use super::accounts::{AuthenticationData, UnlockData};
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
routes![ routes![
get_organization, get_organization,
create_organization, create_organization,
delete_organization, delete_organization,
post_delete_organization,
leave_organization, leave_organization,
get_user_collections, get_user_collections,
get_org_collections, get_org_collections,
@ -39,13 +40,10 @@ pub fn routes() -> Vec<Route> {
get_org_collection_detail, get_org_collection_detail,
get_collection_users, get_collection_users,
put_organization, put_organization,
post_organization,
post_organization_collections, post_organization_collections,
post_bulk_access_collections, post_bulk_access_collections,
post_organization_collection_update,
put_organization_collection_update, put_organization_collection_update,
delete_organization_collection, delete_organization_collection,
post_organization_collection_delete,
bulk_delete_organization_collections, bulk_delete_organization_collections,
post_bulk_collections, post_bulk_collections,
get_org_details, get_org_details,
@ -60,7 +58,6 @@ pub fn routes() -> Vec<Route> {
get_org_user_mini_details, get_org_user_mini_details,
get_user, get_user,
edit_member, edit_member,
put_member,
delete_member, delete_member,
bulk_delete_member, bulk_delete_member,
post_org_import, post_org_import,
@ -70,7 +67,6 @@ pub fn routes() -> Vec<Route> {
get_master_password_policy, get_master_password_policy,
get_policy, get_policy,
put_policy, put_policy,
put_policy_vnext,
get_plans, get_plans,
post_org_keys, post_org_keys,
get_organization_keys, get_organization_keys,
@ -86,22 +82,16 @@ pub fn routes() -> Vec<Route> {
post_groups, post_groups,
get_group, get_group,
put_group, put_group,
post_group,
get_group_details, get_group_details,
delete_group, delete_group,
post_delete_group,
bulk_delete_groups, bulk_delete_groups,
get_group_members, get_group_members,
put_group_members,
post_delete_group_member,
put_reset_password_enrollment, put_reset_password_enrollment,
get_reset_password_details, get_reset_password_details,
put_reset_password,
put_recover_account, put_recover_account,
get_org_export, get_org_export,
post_api_key, post_api_key,
rotate_api_key, rotate_api_key,
get_billing_metadata,
get_billing_warnings, get_billing_warnings,
get_auto_enroll_status, get_auto_enroll_status,
get_self_host_billing_metadata, 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")] #[post("/organizations/<org_id>/leave")]
async fn leave_organization(org_id: OrganizationId, headers: OrgMemberHeaders, conn: DbConn) -> EmptyResult { async fn leave_organization(org_id: OrganizationId, headers: OrgMemberHeaders, conn: DbConn) -> EmptyResult {
if headers.membership.status != MembershipStatus::Confirmed as i32 { if headers.membership.status != MembershipStatus::Confirmed as i32 {
@ -300,16 +280,6 @@ async fn put_organization(
headers: OwnerHeaders, headers: OwnerHeaders,
data: Json<OrganizationUpdateData>, data: Json<OrganizationUpdateData>,
conn: DbConn, 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 { ) -> JsonResult {
if org_id != headers.org_id { if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match"); err!("Organization not found", "Organization id's do not match");
@ -640,17 +610,6 @@ async fn put_organization_collection_update(
headers: ManagerHeaders, headers: ManagerHeaders,
data: Json<FullCollectionData>, data: Json<FullCollectionData>,
conn: DbConn, 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 { ) -> JsonResult {
if org_id != headers.org_id { if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match"); err!("Organization not found", "Organization id's do not match");
@ -746,16 +705,6 @@ async fn delete_organization_collection(
delete_organization_collection_impl(&org_id, &col_id, &headers, &conn).await 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)] #[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct BulkCollectionIds { struct BulkCollectionIds {
@ -1081,7 +1030,7 @@ async fn send_invite(
let user = match User::find_by_mail(email, &conn).await { let user = match User::find_by_mail(email, &conn).await {
None => { None => {
if !CONFIG.invitations_allowed() { 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) { if !CONFIG.is_email_domain_allowed(email) {
@ -1099,7 +1048,7 @@ async fn send_invite(
} }
Some(user) => { Some(user) => {
if Membership::find_by_user_and_org(&user.uuid, &org_id, &conn).await.is_some() { 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() { if !CONFIG.mail_enabled() {
@ -1374,7 +1323,9 @@ async fn bulk_confirm_invite(
match data.keys { match data.keys {
Some(keys) => { Some(keys) => {
for invite in 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 user_key = invite.key.unwrap_or_default();
let err_msg = match confirm_invite_impl(&org_id, &member_id, &user_key, &headers, &conn, &nt).await { let err_msg = match confirm_invite_impl(&org_id, &member_id, &user_key, &headers, &conn, &nt).await {
Ok(()) => String::new(), Ok(()) => String::new(),
@ -1532,17 +1483,6 @@ struct EditUserData {
} }
#[put("/organizations/<org_id>/users/<member_id>", data = "<data>", rank = 1)] #[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( async fn edit_member(
org_id: OrganizationId, org_id: OrganizationId,
member_id: MembershipId, member_id: MembershipId,
@ -1922,7 +1862,7 @@ async fn post_org_import(
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct BulkCollectionsData { struct BulkCollectionsData {
organization_id: OrganizationId, organization_id: OrganizationId,
cipher_ids: Vec<CipherId>, cipher_ids: HashSet<CipherId>,
collection_ids: HashSet<CollectionId>, collection_ids: HashSet<CollectionId>,
remove_collections: bool, remove_collections: bool,
} }
@ -2201,18 +2141,6 @@ async fn put_policy(
Ok(Json(policy.to_json())) 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")] #[get("/plans")]
fn get_plans() -> Json<Value> { fn get_plans() -> Json<Value> {
// Respond with a minimal json just enough to allow the creation of an new organization. // Respond with a minimal json just enough to allow the creation of an new organization.
@ -2241,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")] #[get("/organizations/<_org_id>/billing/vnext/warnings")]
fn get_billing_warnings(_org_id: OrganizationId, _headers: OrgMemberHeaders) -> Json<Value> { fn get_billing_warnings(_org_id: OrganizationId, _headers: OrgMemberHeaders) -> Json<Value> {
Json(json!({ Json(json!({
@ -2266,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)] #[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct BulkRevokeMembershipIds { struct BulkRevokeMembershipIds {
@ -2592,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>")] #[post("/organizations/<org_id>/groups", data = "<data>")]
async fn post_groups( async fn post_groups(
org_id: OrganizationId, org_id: OrganizationId,
@ -2712,7 +2615,6 @@ async fn add_update_group(
"id": group.uuid, "id": group.uuid,
"organizationId": group.organizations_uuid, "organizationId": group.organizations_uuid,
"name": group.name, "name": group.name,
"accessAll": group.access_all,
"externalId": group.external_id, "externalId": group.external_id,
"object": "group" "object": "group"
}))) })))
@ -2739,16 +2641,6 @@ async fn get_group_details(
Ok(Json(group.to_json_details(&conn).await)) 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>")] #[delete("/organizations/<org_id>/groups/<group_id>")]
async fn delete_group(org_id: OrganizationId, group_id: GroupId, headers: AdminHeaders, conn: DbConn) -> EmptyResult { 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 delete_group_impl(&org_id, &group_id, &headers, &conn).await
@ -2850,90 +2742,6 @@ async fn get_group_members(
Ok(Json(json!(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)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct OrganizationUserResetPasswordEnrollmentRequest { struct OrganizationUserResetPasswordEnrollmentRequest {
@ -2945,9 +2753,14 @@ struct OrganizationUserResetPasswordEnrollmentRequest {
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct OrganizationUserRecoverAccountRequest { struct OrganizationUserRecoverAccountRequest {
// Legacy payload
new_master_password_hash: Option<String>, new_master_password_hash: Option<String>,
key: Option<String>, key: Option<String>,
// Current payload
authentication_data: Option<AuthenticationData>,
unlock_data: Option<UnlockData>,
#[serde(default)] #[serde(default)]
reset_master_password: bool, reset_master_password: bool,
#[serde(default)] #[serde(default)]
@ -2993,19 +2806,6 @@ async fn put_recover_account(
recover_account(org_id, member_id, headers, data.into_inner(), conn, nt).await 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( async fn recover_account(
org_id: OrganizationId, org_id: OrganizationId,
member_id: MembershipId, member_id: MembershipId,
@ -3038,10 +2838,34 @@ async fn recover_account(
err!("Organization user must be confirmed for password reset functionality"); err!("Organization user must be confirmed for password reset functionality");
} }
let fallback_2fa_email = if req.reset_two_factor && CONFIG.email_2fa_auto_fallback() { let fallback_2fa_email =
TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email as i32, &conn).await.is_none() 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 { } else {
false None
}; };
// Sending email first ensure working email configuration and the resulting user notification. // Sending email first ensure working email configuration and the resulting user notification.
@ -3059,18 +2883,13 @@ async fn recover_account(
err!(format!("Error sending user reset password email: {e:#?}")); err!(format!("Error sending user reset password email: {e:#?}"));
} }
if req.reset_master_password { if let Some((new_master_password_hash, new_key)) = new_password {
if let Some(key) = req.key user.set_password(&new_master_password_hash, Some(new_key), true, None, &conn).await?;
&& 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 req.reset_two_factor { if req.reset_two_factor {
TwoFactor::delete_all_by_user(&user.uuid, &conn).await?; 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() { 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) two_factor::enforce_2fa_policy(&user, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn)
.await?; .await?;
@ -3124,6 +2943,7 @@ async fn get_reset_password_details(
"kdfIterations": user.client_kdf_iter, "kdfIterations": user.client_kdf_iter,
"kdfMemory": user.client_kdf_memory, "kdfMemory": user.client_kdf_memory,
"kdfParallelism": user.client_kdf_parallelism, "kdfParallelism": user.client_kdf_parallelism,
"masterPasswordSalt": user.master_password_salt(),
"resetPasswordKey": member.reset_password_key, "resetPasswordKey": member.reset_password_key,
"encryptedPrivateKey": org.private_key, "encryptedPrivateKey": org.private_key,
}))) })))
@ -3239,7 +3059,7 @@ async fn api_key(
org_id: &OrganizationId, org_id: &OrganizationId,
data: Json<PasswordOrOtpData>, data: Json<PasswordOrOtpData>,
rotate: bool, rotate: bool,
headers: AdminHeaders, headers: OwnerHeaders,
conn: DbConn, conn: DbConn,
) -> JsonResult { ) -> JsonResult {
if org_id != &headers.org_id { if org_id != &headers.org_id {
@ -3248,7 +3068,7 @@ async fn api_key(
let data: PasswordOrOtpData = data.into_inner(); let data: PasswordOrOtpData = data.into_inner();
let user = headers.user; let user = headers.user;
// Validate the admin users password/otp // Validate the owner users password/otp
data.validate(&user, true, &conn).await?; 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 { let org_api_key = if let Some(mut org_api_key) = OrganizationApiKey::find_by_org_uuid(org_id, &conn).await {
@ -3276,7 +3096,7 @@ async fn api_key(
async fn post_api_key( async fn post_api_key(
org_id: OrganizationId, org_id: OrganizationId,
data: Json<PasswordOrOtpData>, data: Json<PasswordOrOtpData>,
headers: AdminHeaders, headers: OwnerHeaders,
conn: DbConn, conn: DbConn,
) -> JsonResult { ) -> JsonResult {
api_key(&org_id, data, false, headers, conn).await api_key(&org_id, data, false, headers, conn).await
@ -3286,7 +3106,7 @@ async fn post_api_key(
async fn rotate_api_key( async fn rotate_api_key(
org_id: OrganizationId, org_id: OrganizationId,
data: Json<PasswordOrOtpData>, data: Json<PasswordOrOtpData>,
headers: AdminHeaders, headers: OwnerHeaders,
conn: DbConn, conn: DbConn,
) -> JsonResult { ) -> JsonResult {
api_key(&org_id, data, true, headers, conn).await api_key(&org_id, data, true, headers, conn).await

6
src/api/core/passkeys.rs

@ -250,7 +250,7 @@ fn passkey_count_limit_reached(count: usize) -> bool {
} }
pub(crate) fn account_passkeys_allowed() -> bool { pub(crate) fn account_passkeys_allowed() -> bool {
CONFIG.passkeys_enabled() && CONFIG.is_webauthn_2fa_supported() CONFIG.is_webauthn_2fa_supported()
} }
pub(crate) fn passkey_counter(passkey: &Passkey) -> u32 { pub(crate) fn passkey_counter(passkey: &Passkey) -> u32 {
@ -494,7 +494,7 @@ async fn post_api_webauthn(
err!("Maximum number of passkeys reached") err!("Maximum number of passkeys reached")
} }
let type_ = TwoFactorType::WebauthnPasskeyRegisterChallenge as i32; let type_ = TwoFactorType::WebauthnPasskeyRegisterChallenge;
let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await else { let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await else {
err!("No registration challenge found. Please try again.") err!("No registration challenge found. Please try again.")
}; };
@ -584,7 +584,7 @@ async fn put_api_webauthn(
err!("User not found") err!("User not found")
}; };
let type_ = TwoFactorType::WebauthnPasskeyAssertionChallenge as i32; let type_ = TwoFactorType::WebauthnPasskeyAssertionChallenge;
let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await else { let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await else {
err!("No assertion challenge found. Please try again.") err!("No assertion challenge found. Please try again.")
}; };

32
src/api/core/public.rs

@ -58,25 +58,13 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
let org_id = token.0; let org_id = token.0;
let data = data.into_inner(); 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 { for user_data in &data.members {
let mut user_created: bool = false; let mut user_created: bool = false;
if user_data.deleted { if user_data.deleted {
// If user is marked for deletion and it exists, revoke it // 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 { 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 = member.atype != MembershipType::Owner && member.revoke();
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 ext_modified = member.set_external_id(Some(user_data.external_id.clone())); let ext_modified = member.set_external_id(Some(user_data.external_id.clone()));
if revoked || ext_modified { if revoked || ext_modified {
member.save(&conn).await?; 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 // 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 { } 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())); let ext_modified = member.set_external_id(Some(user_data.external_id.clone()));
// Enforce org policies as every other restore path does. // 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. // 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() { if CONFIG.org_groups_enabled() {
for group_data in &data.groups { 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 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 group.uuid
} else { } else {
let mut group = 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 { for member in Membership::find_by_org(&org_id, &conn).await {
if let Some(ref user_external_id) = member.external_id if let Some(ref user_external_id) = member.external_id
&& !sync_members.contains(user_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?; member.delete(&conn).await?;
} }
} }

204
src/api/core/sends.rs

@ -12,7 +12,7 @@ use serde_json::Value;
use crate::{ use crate::{
CONFIG, CONFIG,
api::{ApiResult, EmptyResult, JsonResult, Notify, UpdateType}, api::{ApiResult, EmptyResult, JsonResult, Notify, UpdateType},
auth::{ClientIp, Headers, Host, SendHeaders}, auth::{Headers, Host, SendHeaders},
config::PathType, config::PathType,
db::{ db::{
DbConn, DbPool, DbConn, DbPool,
@ -23,7 +23,7 @@ use crate::{
const SEND_INACCESSIBLE_MSG: &str = "Send does not exist or is no longer available"; const SEND_INACCESSIBLE_MSG: &str = "Send does not exist or is no longer available";
static ANON_PUSH_DEVICE: LazyLock<Device> = LazyLock::new(|| { 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 { Device {
uuid: String::from("00000000-0000-0000-0000-000000000000").into(), uuid: String::from("00000000-0000-0000-0000-000000000000").into(),
created_at: dt, 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 // 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> { pub fn routes() -> Vec<rocket::Route> {
routes![ routes![
get_sends, get_sends,
get_send, get_send,
post_send, post_send,
post_send_file,
post_access, post_access,
post_access_legacy,
post_access_file, post_access_file,
post_access_file_legacy,
put_send, put_send,
delete_send, delete_send,
put_remove_password, put_remove_password,
@ -73,7 +70,7 @@ pub async fn purge_sends(pool: DbPool) {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct SendData { pub struct SendData {
r#type: i32, r#type: i32,
key: String, pub key: String,
password: Option<String>, password: Option<String>,
max_access_count: Option<NumberOrString>, max_access_count: Option<NumberOrString>,
expiration_date: Option<DateTime<Utc>>, 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?; enforce_disable_hide_email_policy(&data, &headers, &conn).await?;
if data.r#type == SendType::File as i32 { 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)?; 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())) Ok(Json(send.to_json()))
} }
#[derive(FromForm)]
struct UploadData<'f> {
model: Json<SendData>,
data: TempFile<'f>,
}
#[derive(FromForm)] #[derive(FromForm)]
struct UploadDataV2<'f> { struct UploadDataV2<'f> {
data: TempFile<'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 // Upstream: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/SendsController.cs#L165
#[post("/sends/file/v2", data = "<data>")] #[post("/sends/file/v2", data = "<data>")]
async fn post_send_file_v2(data: Json<SendData>, headers: Headers, conn: DbConn) -> JsonResult { 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.", "Send file name does not match.",
format!( format!(
"Expected file name '{}' got '{}'", "Expected file name '{}' got '{}'",
send_data.fileName, send_data.fileName.escape_debug(),
raw_file_name.dangerous_unsafe_unsanitized_raw() raw_file_name.dangerous_unsafe_unsanitized_raw().as_str().escape_debug()
) )
), ),
_ => err!("Send file name does not match or is not provided."), _ => 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")] #[post("/sends/access")]
async fn post_access(headers: SendHeaders, conn: DbConn, nt: Notify<'_>) -> JsonResult { 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) err_code!(SEND_INACCESSIBLE_MSG, 404)
}; };
if !send.is_accessible() { if !send.is_accessible() {
err_code!(SEND_INACCESSIBLE_MSG, 404) 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 // Files are incremented during the download
if send.atype == SendType::Text as i32 { if send.atype == SendType::Text as i32 && !send.register_access(&conn).await? {
if !send.register_access(&conn).await? { err_code!(SEND_INACCESSIBLE_MSG, 404)
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
} else {
send.save(&conn).await?;
} }
process_access(send, conn, nt).await process_access(send, conn, nt).await
} }
@ -531,55 +399,27 @@ async fn post_access_file(
conn: DbConn, conn: DbConn,
nt: Notify<'_>, nt: Notify<'_>,
) -> JsonResult { ) -> 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) err_code!(SEND_INACCESSIBLE_MSG, 404)
}; };
if !send.is_accessible() { if !send.is_accessible() {
err_code!(SEND_INACCESSIBLE_MSG, 404) err_code!(SEND_INACCESSIBLE_MSG, 404)
} }
process_access_file(send, file_id, host, conn, nt).await check_send_file_id(&send, &file_id)?;
} if !send.register_access(&conn).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)
}
if !send.is_accessible() {
err_code!(SEND_INACCESSIBLE_MSG, 404) err_code!(SEND_INACCESSIBLE_MSG, 404)
} }
process_access_file(send, file_id, host, conn, nt).await
}
if send.password_hash.is_some() { fn check_send_file_id(send: &Send, file_id: &SendFileId) -> EmptyResult {
match data.into_inner().password { if send.atype != SendType::File as i32 {
Some(ref p) if send.check_password(p) => { /* Nothing to do here */ } err!("Send is not a file type send.");
Some(_) => err!("Invalid password."),
None => err_code!("Password not provided", 401),
}
} }
match serde_json::from_str::<SendFileData>(&send.data) {
if !send.register_access(&conn).await? { Ok(data) if &data.id == file_id => Ok(()),
err_code!(SEND_INACCESSIBLE_MSG, 404) _ => 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 { 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())) Ok(Json(send.to_json()))
} }
pub async fn update_send_from_data( async fn update_send_from_data(
send: &mut Send, send: &mut Send,
data: SendData, data: SendData,
headers: &Headers, headers: &Headers,

65
src/api/core/two_factor/authenticator.rs

@ -3,7 +3,7 @@ use rocket::{Route, serde::json::Json};
use crate::{ use crate::{
api::{EmptyResult, JsonResult, PasswordOrOtpData, core::log_user_event, core::two_factor::generate_recover_code}, api::{EmptyResult, JsonResult, PasswordOrOtpData, core::log_user_event, core::two_factor::generate_recover_code},
auth::{ClientIp, Headers}, auth::{ClientIp, Headers, two_factor},
crypto, crypto,
db::{ db::{
DbConn, DbConn,
@ -20,27 +20,23 @@ pub fn routes() -> Vec<Route> {
#[post("/two-factor/get-authenticator", data = "<data>")] #[post("/two-factor/get-authenticator", data = "<data>")]
async fn generate_authenticator(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult { async fn generate_authenticator(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult {
let data: PasswordOrOtpData = data.into_inner();
let user = headers.user; let user = headers.user;
data.validate(&user, false, &conn).await?; data.validate(&user, false, &conn).await?;
let type_ = TwoFactorType::Authenticator as i32; let twofactor = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Authenticator, &conn).await;
let twofactor = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await;
let (enabled, key) = match twofactor { let (enabled, key) = match twofactor {
Some(tf) => (true, tf.data), Some(tf) => (true, tf.data),
_ => (false, crypto::encode_random_bytes::<20>(&BASE32)), _ => (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!({ Ok(Json(json!({
"enabled": enabled, "authenticator": json!({
"key": key, "enabled": enabled,
"object": "twoFactorAuthenticator" "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 { struct EnableAuthenticatorData {
key: String, key: String,
token: NumberOrString, token: NumberOrString,
master_password_hash: Option<String>, user_verification_token: String,
otp: Option<String>,
} }
#[post("/two-factor/authenticator", data = "<data>")] #[post("/two-factor/authenticator", data = "<data>")]
@ -61,12 +56,7 @@ async fn activate_authenticator(data: Json<EnableAuthenticatorData>, headers: He
let mut user = headers.user; let mut user = headers.user;
PasswordOrOtpData { two_factor::validate_authenticator(&data.user_verification_token, &user.uuid, &key, false)?;
master_password_hash: data.master_password_hash,
otp: data.otp,
}
.validate(&user, true, &conn)
.await?;
// Validate key as base32 and 20 bytes length // Validate key as base32 and 20 bytes length
let decoded_key: Vec<u8> = if let Ok(decoded) = BASE32.decode(key.as_bytes()) { 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; 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!({ Ok(Json(json!({
"enabled": true, "authenticator": json!({
"key": key, "enabled": true,
"object": "twoFactorAuthenticator" "key": key,
}),
}))) })))
} }
@ -125,8 +116,7 @@ pub async fn validate_totp_code(
err!("Invalid TOTP secret") 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, Some(tf) => tf,
_ => TwoFactor::new(user_id.clone(), TwoFactorType::Authenticator, secret.to_owned()), _ => TwoFactor::new(user_id.clone(), TwoFactorType::Authenticator, secret.to_owned()),
}; };
@ -184,36 +174,25 @@ pub async fn validate_totp_code(
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct DisableAuthenticatorData { struct DisableAuthenticatorData {
key: String, key: String,
master_password_hash: String, user_verification_token: String,
r#type: NumberOrString,
} }
#[delete("/two-factor/authenticator", data = "<data>")] #[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 user = headers.user;
let type_ = data.r#type.into_i32()?;
if !user.check_valid_password(&data.master_password_hash) { two_factor::validate_authenticator(&data.user_verification_token, &user.uuid, &data.key, true)?;
err!("Invalid password");
}
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 { if twofactor.data == data.key {
twofactor.delete(&conn).await?; twofactor.delete(&conn).await?;
log_user_event(EventType::UserDisabled2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn) log_user_event(EventType::UserDisabled2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
.await;
} else { } else {
err!(format!("TOTP key for user {} does not match recorded value, cannot deactivate", &user.email)); 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::check_2fa_state(&user, headers.device.atype, &headers.ip.ip, &conn).await?;
super::enforce_2fa_policy(&user, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await?;
}
Ok(Json(json!({ Ok(())
"enabled": false,
"keys": type_,
"object": "twoFactorProvider"
})))
} }

244
src/api/core/two_factor/duo.rs

@ -1,14 +1,13 @@
use chrono::Utc; use chrono::Utc;
use data_encoding::BASE64;
use rocket::{Route, serde::json::Json}; use rocket::{Route, serde::json::Json};
use crate::{ use crate::{
CONFIG,
api::{ api::{
ApiResult, EmptyResult, JsonResult, PasswordOrOtpData, core::log_user_event, ApiResult, EmptyResult, JsonResult, PasswordOrOtpData,
core::two_factor::generate_recover_code, core::log_user_event,
core::two_factor::{VerificationTokenData, generate_recover_code},
}, },
auth::Headers, auth::{Headers, two_factor, two_factor::DuoData},
crypto, crypto,
db::{ db::{
DbConn, DbConn,
@ -19,55 +18,7 @@ use crate::{
}; };
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
routes![get_duo, activate_duo, activate_duo_put,] routes![get_duo, activate_duo, activate_duo_put, disable_duo,]
}
#[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,
}
}
} }
enum DuoStatus { enum DuoStatus {
@ -96,22 +47,19 @@ async fn get_duo(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn)
data.validate(&user, false, &conn).await?; data.validate(&user, false, &conn).await?;
let data = get_user_duo_data(&user.uuid, &conn).await; let (enabled, duo) = match get_user_duo_data(&user.uuid, &conn).await {
let (enabled, data) = match data {
DuoStatus::Global(_) => (true, Some(DuoData::secret())), DuoStatus::Global(_) => (true, Some(DuoData::secret())),
DuoStatus::User(data) => (true, Some(data.obscure())), DuoStatus::User(data) => (true, Some(data.obscure())),
DuoStatus::Disabled(true) => (false, Some(DuoData::msg(DISABLED_MESSAGE_DEFAULT))), DuoStatus::Disabled(true) => (false, Some(DuoData::msg(DISABLED_MESSAGE_DEFAULT))),
DuoStatus::Disabled(false) => (false, None), DuoStatus::Disabled(false) => (false, None),
}; };
let json = if let Some(data) = data { let duo_json = if let Some(data) = duo.as_ref() {
json!({ json!({
"enabled": enabled, "enabled": enabled,
"host": data.host, "host": data.host,
"clientSecret": data.sk, "clientSecret": data.sk,
"clientId": data.ik, "clientId": data.ik,
"object": "twoFactorDuo"
}) })
} else { } else {
json!({ json!({
@ -119,11 +67,13 @@ async fn get_duo(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn)
"host": null, "host": null,
"clientSecret": null, "clientSecret": null,
"clientId": 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)] #[derive(Deserialize)]
@ -132,8 +82,7 @@ struct EnableDuoData {
host: String, host: String,
client_secret: String, client_secret: String,
client_id: String, client_id: String,
master_password_hash: Option<String>, user_verification_token: String,
otp: Option<String>,
} }
impl From<EnableDuoData> for DuoData { 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 data: EnableDuoData = data.into_inner();
let mut user = headers.user; let mut user = headers.user;
PasswordOrOtpData { two_factor::validate_duo(&data.user_verification_token, &user.uuid, None, false)?;
master_password_hash: data.master_password_hash.clone(),
otp: data.otp.clone(),
}
.validate(&user, true, &conn)
.await?;
let (data, data_str) = if check_duo_fields_custom(&data) { let (data, data_str) = if check_duo_fields_custom(&data) {
let data_req: DuoData = data.into(); 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; 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!({ Ok(Json(json!({
"enabled": true, "duo": json!({
"host": data.host, "enabled": true,
"clientSecret": data.sk, "host": data.host,
"clientId": data.ik, "clientSecret": data.sk,
"object": "twoFactorDuo" "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 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 { async fn duo_api_request(method: &str, path: &str, params: &str, data: &DuoData) -> EmptyResult {
use reqwest::{Method, header}; use reqwest::{Method, header};
use std::str::FromStr; use std::str::FromStr;
@ -222,21 +190,16 @@ async fn duo_api_request(method: &str, path: &str, params: &str, data: &DuoData)
Ok(()) 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 { 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 // 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()); 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 the user has the required values, we use those
if let Ok(data) = serde_json::from_str(&twofactor.data) { if let Ok(data) = serde_json::from_str(&twofactor.data) {
return DuoStatus::User(data); return DuoStatus::User(data);
@ -251,118 +214,13 @@ async fn get_user_duo_data(user_id: &UserId, conn: &DbConn) -> DuoStatus {
DuoStatus::Disabled(false) DuoStatus::Disabled(false)
} }
// let (ik, sk, ak, host) = get_duo_keys(); // let (ik, sk, host) = get_duo_keys_email();
pub(crate) async fn get_duo_keys_email(email: &str, conn: &DbConn) -> ApiResult<(String, String, String, String)> { 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 { let data = match User::find_by_mail(email, conn).await {
Some(u) => get_user_duo_data(&u.uuid, conn).await.data(), Some(u) => get_user_duo_data(&u.uuid, conn).await.data(),
_ => DuoData::global(), _ => DuoData::global(),
} }
.map_res("Can't fetch Duo Keys")?; .map_res("Can't fetch Duo Keys")?;
Ok((data.ik, data.sk, CONFIG.get_duo_akey().await, data.host)) Ok((data.ik, data.sk, 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())
} }

4
src/api/core/two_factor/duo_oidc.rs

@ -383,7 +383,7 @@ pub async fn get_duo_auth_url(
device_identifier: &DeviceId, device_identifier: &DeviceId,
conn: &DbConn, conn: &DbConn,
) -> Result<String, Error> { ) -> 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) { let callback_url = match make_callback_url(client_id) {
Ok(url) => url, Ok(url) => url,
@ -435,7 +435,7 @@ pub async fn validate_duo_login(
let code = split[0]; let code = split[0];
let state = split[1]; 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, // 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. // it means the context is either missing or expired.

136
src/api/core/two_factor/email.rs

@ -5,20 +5,26 @@ use crate::{
CONFIG, CONFIG,
api::{ api::{
EmptyResult, JsonResult, PasswordOrOtpData, 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, crypto,
db::{ db::{
DbConn, DbConn,
models::{AuthRequest, AuthRequestId, DeviceId, EventType, TwoFactor, TwoFactorType, User, UserId}, models::{
AuthRequest, AuthRequestId, DeviceId, EventType, TwoFactor, TwoFactorIncomplete, TwoFactorType, User,
UserId,
},
}, },
error::{Error, MapResult}, error::{Error, MapResult},
mail, mail,
}; };
pub fn routes() -> Vec<Route> { 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)] #[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 { let Some(user) = User::find_by_mail(email, &conn).await else {
err!( err!(
"Username or password is incorrect. Try again", "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) { if !user.check_valid_password(master_password_hash) {
err!( err!(
"Username or password is incorrect. Try again", "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 { } 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") 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 { } else {
err!("No password hash has been submitted.") 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 { let Some(user) = User::find_by_device_for_email2fa(device_identifier, &conn).await else {
err!( err!(
"Username or password is incorrect. Try again", "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 /// 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 { pub async fn send_token(user_id: &UserId, conn: &DbConn) -> EmptyResult {
let type_ = TwoFactorType::Email as i32; let mut twofactor =
let mut twofactor = TwoFactor::find_by_user_and_type(user_id, type_, conn).await.map_res("Two factor not found")?; 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()); 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?; data.validate(&user, false, &conn).await?;
let (enabled, mfa_email) = let (enabled, mfa_email) =
match TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email as i32, &conn).await { if let Some(x) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email, &conn).await {
Some(x) => { let twofactor_data = EmailTokenData::from_json(&x.data)?;
let twofactor_data = EmailTokenData::from_json(&x.data)?; (true, Some(twofactor_data.email))
(true, json!(twofactor_data.email)) } else {
} (false, None)
_ => (false, serde_json::value::Value::Null),
}; };
Ok(Json(json!({ Ok(Json(rocket::serde::json::json!({
"email": mfa_email, "email": rocket::serde::json::json!({
"enabled": enabled, "enabled": enabled,
"object": "twoFactorEmail" "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 { struct SendEmailData {
/// Email where 2FA codes will be sent to, can be different than user email account. /// Email where 2FA codes will be sent to, can be different than user email account.
email: String, email: String,
master_password_hash: Option<String>, user_verification_token: String,
otp: Option<String>,
} }
/// Send a verification email to the specified email address to check whether it exists/belongs to user. /// Send a verification email to the specified email address to check whether it exists/belongs to user.
#[post("/two-factor/send-email", data = "<data>")] #[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 data: SendEmailData = data.into_inner();
let user = headers.user; let user = headers.user;
PasswordOrOtpData { two_factor::validate_email(&data.user_verification_token, &user.uuid, data.email.clone(), false)?;
master_password_hash: data.master_password_hash,
otp: data.otp,
}
.validate(&user, false, &conn)
.await?;
if !CONFIG._enable_email_2fa() { if !CONFIG._enable_email_2fa() {
err!("Email 2FA is disabled") err!("Email 2FA is disabled")
} }
let type_ = TwoFactorType::Email as i32; if let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email, &conn).await {
if let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await {
tf.delete(&conn).await?; tf.delete(&conn).await?;
} }
let generated_token = crypto::generate_email_token(CONFIG.email_token_size()); 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. // 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?; twofactor.save(&conn).await?;
mail::send_token(&twofactor_data.email, &twofactor_data.last_token.map_res("Token is empty")?).await?; mail::send_token(&twofactor_data.email, &twofactor_data.last_token.map_res("Token is empty")?).await?;
Ok(()) Ok(Json(json!({})))
} }
#[derive(Deserialize, Serialize)] #[derive(Deserialize, Serialize)]
@ -204,8 +218,7 @@ async fn send_email(data: Json<SendEmailData>, headers: Headers, conn: DbConn) -
struct EmailData { struct EmailData {
email: String, email: String,
token: String, token: String,
master_password_hash: Option<String>, user_verification_token: String,
otp: Option<String>,
} }
/// Verify email belongs to user and can be used for 2FA email codes. /// 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 data: EmailData = data.into_inner();
let mut user = headers.user; let mut user = headers.user;
// This is the last step in the verification process, delete the otp directly afterwards two_factor::validate_email(&data.user_verification_token, &user.uuid, data.email, false)?;
PasswordOrOtpData {
master_password_hash: data.master_password_hash,
otp: data.otp,
}
.validate(&user, true, &conn)
.await?;
let type_ = TwoFactorType::EmailVerificationChallenge as i32; let mut twofactor = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::EmailVerificationChallenge, &conn)
let mut twofactor = .await
TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await.map_res("Two factor not found")?; .map_res("Two factor not found")?;
let mut email_data = EmailTokenData::from_json(&twofactor.data)?; 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; 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!({ Ok(Json(json!({})))
"email": email_data.email, }
"enabled": "true",
"object": "twoFactorEmail" #[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 /// Validate the email code when used as TwoFactor token mechanism
@ -261,9 +281,8 @@ pub async fn validate_email_code_str(
conn: &DbConn, conn: &DbConn,
) -> EmptyResult { ) -> EmptyResult {
let mut email_data = EmailTokenData::from_json(data)?; let mut email_data = EmailTokenData::from_json(data)?;
let mut twofactor = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Email as i32, conn) let mut twofactor =
.await TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Email, conn).await.map_res("Two factor not found")?;
.map_res("Two factor not found")?;
let Some(issued_token) = &email_data.last_token else { let Some(issued_token) = &email_data.last_token else {
err!( err!(
format!("No token available! IP: {ip}"), format!("No token available! IP: {ip}"),
@ -322,10 +341,10 @@ pub struct EmailTokenData {
} }
impl EmailTokenData { impl EmailTokenData {
pub fn new(email: String, token: String) -> EmailTokenData { pub fn new(email: String, token: Option<String>) -> EmailTokenData {
EmailTokenData { EmailTokenData {
email, email,
last_token: Some(token), last_token: token,
token_sent: Utc::now().timestamp(), token_sent: Utc::now().timestamp(),
attempts: 0, attempts: 0,
} }
@ -363,7 +382,8 @@ pub async fn activate_email_2fa(user: &User, conn: &DbConn) -> EmptyResult {
if user.verified_at.is_none() { if user.verified_at.is_none() {
err!("Auto-enabling of email 2FA failed because the users email address has not been verified!"); 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()); let twofactor = TwoFactor::new(user.uuid.clone(), TwoFactorType::Email, twofactor_data.to_json());
twofactor.save(conn).await twofactor.save(conn).await
} }

70
src/api/core/two_factor/mod.rs

@ -7,10 +7,7 @@ use serde_json::Value;
use crate::{ use crate::{
CONFIG, CONFIG,
api::{ api::{EmptyResult, JsonResult, PasswordOrOtpData, core::log_event},
EmptyResult, JsonResult, PasswordOrOtpData,
core::{log_event, log_user_event},
},
auth::Headers, auth::Headers,
crypto, crypto,
db::{ db::{
@ -21,7 +18,6 @@ use crate::{
}, },
}, },
mail, mail,
util::NumberOrString,
}; };
pub mod authenticator; pub mod authenticator;
@ -71,13 +67,7 @@ pub fn is_twofactor_provider_usable(provider_type: &TwoFactorType, provider_data
} }
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
let mut routes = routes![ let mut routes = routes![get_twofactor, get_recover, get_device_verification_settings];
get_twofactor,
get_recover,
disable_twofactor,
disable_twofactor_put,
get_device_verification_settings,
];
routes.append(&mut authenticator::routes()); routes.append(&mut authenticator::routes());
routes.append(&mut duo::routes()); routes.append(&mut duo::routes());
@ -89,6 +79,12 @@ pub fn routes() -> Vec<Route> {
routes routes
} }
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct VerificationTokenData {
user_verification_token: String,
}
#[get("/two-factor")] #[get("/two-factor")]
async fn get_twofactor(headers: Headers, conn: DbConn) -> Json<Value> { async fn get_twofactor(headers: Headers, conn: DbConn) -> Json<Value> {
let twofactors = TwoFactor::find_by_user(&headers.user.uuid, &conn).await; let twofactors = TwoFactor::find_by_user(&headers.user.uuid, &conn).await;
@ -128,50 +124,14 @@ async fn generate_recover_code(user: &mut User, conn: &DbConn) {
} }
} }
#[derive(Deserialize)] /// Call after a 2FA provider, or one of its keys, was removed. No remembered device may keep skipping
#[serde(rename_all = "camelCase")] /// the providers that are left, and once every provider is gone the 2FA policy applies.
struct DisableTwoFactorData { pub async fn check_2fa_state(user: &User, device_type: i32, ip: &std::net::IpAddr, conn: &DbConn) -> EmptyResult {
master_password_hash: Option<String>, Device::clear_twofactor_remember_by_user(&user.uuid, conn).await?;
otp: Option<String>, if TwoFactor::find_by_user(&user.uuid, conn).await.is_empty() {
r#type: NumberOrString, enforce_2fa_policy(user, &user.uuid, device_type, ip, conn).await?;
}
#[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,
} }
.validate(&user, true, &conn) Ok(())
.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
} }
pub async fn enforce_2fa_policy( pub async fn enforce_2fa_policy(

5
src/api/core/two_factor/protected_actions.rs

@ -72,8 +72,7 @@ async fn request_otp(headers: Headers, conn: DbConn) -> EmptyResult {
let user = headers.user; let user = headers.user;
// Only one Protected Action per user is allowed to take place, delete the previous one // 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 pa_data = ProtectedActionData::from_json(&pa.data)?;
let elapsed = pa_data.time_since_sent().num_seconds(); let elapsed = pa_data.time_since_sent().num_seconds();
let delay = 30; let delay = 30;
@ -125,7 +124,7 @@ pub async fn validate_protected_action_otp(
delete_if_valid: bool, delete_if_valid: bool,
conn: &DbConn, conn: &DbConn,
) -> EmptyResult { ) -> 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 .await
.map_res("Protected action token not found, try sending the code again or restart the process")?; .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)?; let mut pa_data = ProtectedActionData::from_json(&pa.data)?;

180
src/api/core/two_factor/webauthn.rs

@ -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 rocket::{Route, serde::json::Json};
use serde_json::Value; use serde_json::Value;
@ -18,16 +18,18 @@ use crate::{
CONFIG, CONFIG,
api::{ api::{
EmptyResult, JsonResult, PasswordOrOtpData, 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, crypto::ct_eq,
db::{ db::{
DbConn, DbConn,
models::{EventType, TwoFactor, TwoFactorType, UserId}, models::{EventType, TwoFactor, TwoFactorType, UserId},
}, },
error::Error, error::Error,
util::NumberOrString,
}; };
pub(crate) static WEBAUTHN: LazyLock<Webauthn> = LazyLock::new(|| { pub(crate) static WEBAUTHN: LazyLock<Webauthn> = LazyLock::new(|| {
@ -45,7 +47,14 @@ pub(crate) static WEBAUTHN: LazyLock<Webauthn> = LazyLock::new(|| {
}); });
pub fn routes() -> Vec<Route> { 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 // 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?; data.validate(&user, false, &conn).await?;
let (enabled, registrations) = get_webauthn_registrations(&user.uuid, &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(); let registrations_json: Vec<Value> = registrations.iter().map(WebauthnRegistration::to_json).collect();
Ok(Json(json!({ Ok(Json(json!({
"enabled": enabled, "webAuthn": json!({
"keys": registrations_json, "enabled": enabled,
"object": "twoFactorWebAuthn" "keys": registrations_json,
}),
"userVerificationToken": two_factor::webauthn_token(user.uuid, keys, enabled),
}))) })))
} }
#[post("/two-factor/get-webauthn-challenge", data = "<data>")] #[post("/two-factor/get-webauthn-challenge", data = "<data>")]
async fn generate_webauthn_challenge(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult { async fn generate_webauthn_challenge(data: Json<VerificationTokenData>, headers: Headers, conn: DbConn) -> JsonResult {
let data: PasswordOrOtpData = data.into_inner();
let user = headers.user; let user = headers.user;
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 = get_webauthn_registrations(&user.uuid, &conn) let creds = registrations
.await?
.1
.into_iter() .into_iter()
.map(|r| r.credential.cred_id().to_owned()) // We return the credentialIds to the clients to avoid double registering .map(|r| r.credential.cred_id().to_owned()) // We return the credentialIds to the clients to avoid double registering
.collect(); .collect();
two_factor::validate_webauthn(&data.user_verification_token, &user.uuid, &keys, enabled)?;
let (mut challenge, state) = WEBAUTHN.start_passkey_registration( let (mut challenge, state) = WEBAUTHN.start_passkey_registration(
Uuid::from_str(&user.uuid).expect("Failed to parse UUID"), // Should never fail Uuid::from_str(&user.uuid).expect("Failed to parse UUID"), // Should never fail
&user.email, &user.email,
user.display_name(), user.display_name(),
Some(registrations), Some(creds),
)?; )?;
let mut state = serde_json::to_value(&state)?; 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)?; let mut challenge_value = serde_json::to_value(challenge.public_key)?;
challenge_value["status"] = "ok".into(); challenge_value["status"] = "ok".into();
challenge_value["errorMessage"] = "".into(); challenge_value["errorMessage"] = "".into();
Ok(Json(challenge_value))
Ok(Json(json!({
"options": challenge_value
})))
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct EnableWebauthnData { struct EnableWebauthnData {
id: NumberOrString, // 1..5 id: i32,
name: String, name: String,
device_response: RegisterPublicKeyCredentialCopy, device_response: RegisterPublicKeyCredentialCopy,
master_password_hash: Option<String>, user_verification_token: String,
otp: Option<String>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -257,16 +270,14 @@ async fn activate_webauthn(data: Json<EnableWebauthnData>, headers: Headers, con
let data: EnableWebauthnData = data.into_inner(); let data: EnableWebauthnData = data.into_inner();
let mut user = headers.user; let mut user = headers.user;
PasswordOrOtpData { let mut registrations: Vec<_> = get_webauthn_registrations(&user.uuid, &conn).await?.1;
master_password_hash: data.master_password_hash, let keys: Vec<i32> = registrations.iter().map(|r| r.id).collect();
otp: data.otp, two_factor::validate_webauthn(&data.user_verification_token, &user.uuid, &keys, !keys.is_empty())?;
}
.validate(&user, true, &conn)
.await?;
// Retrieve and delete the saved challenge state // Retrieve and delete the saved challenge state
let type_ = TwoFactorType::WebauthnRegisterChallenge as i32; let state = if let Some(tf) =
let state = if let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await { TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::WebauthnRegisterChallenge, &conn).await
{
let state: PasskeyRegistration = serde_json::from_str(&tf.data)?; let state: PasskeyRegistration = serde_json::from_str(&tf.data)?;
tf.delete(&conn).await?; tf.delete(&conn).await?;
state state
@ -277,10 +288,9 @@ async fn activate_webauthn(data: Json<EnableWebauthnData>, headers: Headers, con
// Verify the credentials with the saved state // Verify the credentials with the saved state
let credential = WEBAUTHN.finish_passkey_registration(&data.device_response.into(), &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 // TODO: Check for repeated ID's
registrations.push(WebauthnRegistration { registrations.push(WebauthnRegistration {
id: data.id.into_i32()?, id: data.id,
name: data.name, name: data.name,
migrated: false, migrated: false,
@ -293,13 +303,13 @@ async fn activate_webauthn(data: Json<EnableWebauthnData>, headers: Headers, con
.await?; .await?;
generate_recover_code(&mut user, &conn).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!({ Ok(Json(json!({
"enabled": true, "webAuthn": json!({
"keys": keys_json, "enabled": true,
"object": "twoFactorU2f" "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)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct DeleteU2FData { struct DeleteWebauthnData {
id: NumberOrString, id: i32,
master_password_hash: String, user_verification_token: String,
} }
#[delete("/two-factor/webauthn", data = "<data>")] #[delete("/two-factor/webauthn", data = "<data>")]
async fn delete_webauthn(data: Json<DeleteU2FData>, headers: Headers, conn: DbConn) -> JsonResult { async fn delete_webauthn(data: Json<DeleteWebauthnData>, headers: Headers, conn: DbConn) -> JsonResult {
let id = data.id.into_i32()?; inner_delete_webauthns(&data.user_verification_token, |key| key.id != data.id, headers, &conn).await
if !headers.user.check_valid_password(&data.master_password_hash) { }
err!("Invalid password");
} #[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) = let Some(mut tf) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Webauthn, conn).await else {
TwoFactor::find_by_user_and_type(&headers.user.uuid, TwoFactorType::Webauthn as i32, &conn).await
else {
err!("Webauthn data not found!") 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();
two_factor::validate_webauthn(token, &user.uuid, &keys_id, true)?;
let Some(item_pos) = data.iter().position(|r| r.id == id) else { 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") err!("Webauthn entry not found")
}; }
let removed_item = data.remove(item_pos); if keys.is_empty() {
tf.data = serde_json::to_string(&data)?; tf.delete(conn).await?;
tf.save(&conn).await?; log_user_event(EventType::UserDisabled2fa, &user.uuid, headers.device.atype, &headers.ip.ip, conn).await;
drop(tf); } 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 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 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 {
let mut data: Vec<U2FRegistration> = if let Ok(d) = serde_json::from_str(&u2f.data) {
d
} else {
err!("Error parsing U2F data") err!("Error parsing U2F data")
}; };
data.retain(|r| r.reg.key_handle != removed_item.credential.cred_id().as_slice()); data.retain(|old| !removed.contains(&old.reg.key_handle));
let new_data_str = serde_json::to_string(&data)?;
u2f.data = new_data_str; if data.is_empty() {
u2f.save(&conn).await?; 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!({ Ok(Json(json!({
"enabled": true, "webAuthn": json!({
"keys": keys_json, "enabled": !keys.is_empty(),
"object": "twoFactorU2f" "keys": keys.iter().map(WebauthnRegistration::to_json).collect::<Vec<Value>>(),
}),
}))) })))
} }
@ -368,8 +408,7 @@ pub async fn get_webauthn_registrations(
user_id: &UserId, user_id: &UserId,
conn: &DbConn, conn: &DbConn,
) -> Result<(bool, Vec<WebauthnRegistration>), Error> { ) -> Result<(bool, Vec<WebauthnRegistration>), Error> {
let type_ = TwoFactorType::Webauthn as i32; match TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Webauthn, conn).await {
match TwoFactor::find_by_user_and_type(user_id, type_, conn).await {
Some(tf) => Ok((tf.enabled, serde_json::from_str(&tf.data)?)), Some(tf) => Ok((tf.enabled, serde_json::from_str(&tf.data)?)),
None => Ok((false, Vec::new())), // If no data, return empty list 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 { 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) =
let mut state = if let Some(tf) = TwoFactor::find_by_user_and_type(user_id, type_, conn).await { TwoFactor::find_by_user_and_type(user_id, TwoFactorType::WebauthnLoginChallenge, conn).await
{
let state: PasskeyAuthentication = serde_json::from_str(&tf.data)?; let state: PasskeyAuthentication = serde_json::from_str(&tf.data)?;
tf.delete(conn).await?; tf.delete(conn).await?;
state state

102
src/api/core/two_factor/yubikey.rs

@ -10,9 +10,12 @@ use crate::{
CONFIG, CONFIG,
api::{ api::{
EmptyResult, JsonResult, PasswordOrOtpData, 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::{ db::{
DbConn, DbConn,
models::{EventType, TwoFactor, TwoFactorType}, models::{EventType, TwoFactor, TwoFactorType},
@ -22,7 +25,7 @@ use crate::{
}; };
pub fn routes() -> Vec<Route> { 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 { struct HttpClientTransport {
@ -60,8 +63,7 @@ struct EnableYubikeyData {
key4: Option<String>, key4: Option<String>,
key5: Option<String>, key5: Option<String>,
nfc: bool, nfc: bool,
master_password_hash: Option<String>, user_verification_token: String,
otp: Option<String>,
} }
#[derive(Deserialize, Serialize, Debug)] #[derive(Deserialize, Serialize, Debug)]
@ -125,48 +127,29 @@ async fn generate_yubikey(data: Json<PasswordOrOtpData>, headers: Headers, conn:
data.validate(&user, false, &conn).await?; data.validate(&user, false, &conn).await?;
let user_id = &user.uuid; 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; let (enabled, keys, yubikey_json) =
if let Some(r) = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::YubiKey, &conn).await {
if let Some(r) = r { let yubikey_metadata: YubikeyMetadata = serde_json::from_str(&r.data)?;
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());
let mut result = jsonify_yubikeys(yubikey_metadata.keys); result["enabled"] = Value::Bool(enabled);
result["nfc"] = Value::Bool(yubikey_metadata.nfc);
result["enabled"] = Value::Bool(true); (enabled, yubikey_metadata.keys, result)
result["nfc"] = Value::Bool(yubikey_metadata.nfc); } else {
result["object"] = Value::String("twoFactorU2f".to_owned()); (false, Vec::new(), json!({"enabled": false}))
};
Ok(Json(result)) Ok(Json(json!({
} else { "yubiKey": yubikey_json,
Ok(Json(json!({ "userVerificationToken": two_factor::yubikey_token(user.uuid, keys, enabled),
"enabled": false, })))
"object": "twoFactorU2f",
})))
}
} }
#[post("/two-factor/yubikey", data = "<data>")] #[post("/two-factor/yubikey", data = "<data>")]
async fn activate_yubikey(data: Json<EnableYubikeyData>, headers: Headers, conn: DbConn) -> JsonResult { async fn activate_yubikey(data: Json<EnableYubikeyData>, headers: Headers, conn: DbConn) -> JsonResult {
let data: EnableYubikeyData = data.into_inner(); 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 yubikeys = parse_yubikeys(&data);
let mut user = headers.user;
if yubikeys.is_empty() { if yubikeys.is_empty() {
// Return an error to prevent saving empty keys which would cause users not being able to login anymore. // 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."); 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 // Ensure they are valid OTPs
for yubikey in &yubikeys { for yubikey in &yubikeys {
if yubikey.is_empty() || yubikey.len() == 12 { 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; 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); let mut result = jsonify_yubikeys(yubikey_metadata.keys);
result["enabled"] = Value::Bool(true); result["enabled"] = Value::Bool(true);
result["nfc"] = Value::Bool(yubikey_metadata.nfc); result["nfc"] = Value::Bool(yubikey_metadata.nfc);
result["object"] = Value::String("twoFactorU2f".to_owned()); Ok(Json(json!({"yubiKey": result})))
Ok(Json(result))
} }
#[put("/two-factor/yubikey", data = "<data>")] #[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 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 { pub async fn validate_yubikey_login(response: &str, twofactor_data: &str) -> EmptyResult {
if response.len() != 44 { if response.len() != 44 {
err!("Invalid Yubikey OTP length"); err!("Invalid Yubikey OTP length");
} }
let yubikey_metadata: YubikeyMetadata = serde_json::from_str(twofactor_data).expect("Can't parse Yubikey Metadata"); 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()) { if !yubikey_metadata.keys.contains(&response_id.to_owned()) {
err!("Given Yubikey is not registered"); err!("Given Yubikey is not registered");

4
src/api/icons.rs

@ -85,7 +85,7 @@ static ICON_SIZE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?x)(\d+
#[get("/<host>/icon.png")] #[get("/<host>/icon.png")]
fn icon_external(host: &str) -> Cached<Option<Redirect>> { fn icon_external(host: &str) -> Cached<Option<Redirect>> {
let Ok(host) = get_valid_host(host) else { 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); 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"); const FALLBACK_ICON: &[u8] = include_bytes!("../static/images/fallback-icon.png");
let Ok(host) = get_valid_host(host) else { let Ok(host) = get_valid_host(host) else {
warn!("Invalid host: {host}"); warn!("Invalid host: {}", host.escape_debug());
return Cached::ttl( return Cached::ttl(
(ContentType::new("image", "png"), FALLBACK_ICON.to_vec()), (ContentType::new("image", "png"), FALLBACK_ICON.to_vec()),
CONFIG.icon_cache_negttl(), CONFIG.icon_cache_negttl(),

262
src/api/identity.rs

@ -20,15 +20,14 @@ use crate::{
log_user_event, log_user_event,
two_factor::webauthn::WEBAUTHN, two_factor::webauthn::WEBAUTHN,
two_factor::{ two_factor::{
authenticator, duo, duo_oidc, email, enforce_2fa_policy, is_twofactor_provider_usable, webauthn, authenticator, duo_oidc, email, enforce_2fa_policy, is_twofactor_provider_usable, webauthn, yubikey,
yubikey,
}, },
}, },
master_password_policy, identity_master_password_policy,
push::register_push_device, push::register_push_device,
}, },
auth, 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, crypto,
db::{ db::{
DbConn, DbConn,
@ -49,7 +48,6 @@ pub fn routes() -> Vec<Route> {
login, login,
post_prelogin, post_prelogin,
prelogin_password, prelogin_password,
identity_register,
register_verification_email, register_verification_email,
register_finish, register_finish,
prevalidate, prevalidate,
@ -61,12 +59,7 @@ pub fn routes() -> Vec<Route> {
} }
#[post("/connect/token", data = "<data>")] #[post("/connect/token", data = "<data>")]
async fn login( async fn login(data: Form<ConnectData>, client_header: ClientHeaders, conn: DbConn) -> JsonResult {
data: Form<ConnectData>,
client_header: ClientHeaders,
client_version: Option<ClientVersion>,
conn: DbConn,
) -> JsonResult {
let data: ConnectData = data.into_inner(); let data: ConnectData = data.into_inner();
let mut user_id: Option<UserId> = None; let mut user_id: Option<UserId> = None;
@ -77,7 +70,7 @@ async fn login(
refresh_login(data, &conn, &client_header.ip).await refresh_login(data, &conn, &client_header.ip).await
} }
"webauthn" | "password" if CONFIG.sso_enabled() && CONFIG.sso_only() => err!("SSO sign-in is required"), "webauthn" | "password" if CONFIG.sso_enabled() && CONFIG.sso_only() => err!("SSO sign-in is required"),
"webauthn" if CONFIG.passkeys_enabled() && CONFIG.is_webauthn_2fa_supported() => { "webauthn" if CONFIG.is_webauthn_2fa_supported() => {
check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?; check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?;
check_is_some(data.token.as_ref(), "token cannot be blank")?; check_is_some(data.token.as_ref(), "token cannot be blank")?;
check_is_some(data.device_response.as_ref(), "deviceResponse cannot be blank")?; check_is_some(data.device_response.as_ref(), "deviceResponse cannot be blank")?;
@ -87,7 +80,7 @@ async fn login(
check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?; check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
webauthn_login(data, &mut user_id, &conn, &client_header.ip).await webauthn_login(data, &mut user_id, &conn, &client_header.ip).await
} }
"webauthn" => err!("Passkey sign-in is not enabled"), "webauthn" => err!("Passkey sign-in is unavailable"),
"password" => { "password" => {
check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?; check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?;
check_is_some(data.password.as_ref(), "password cannot be blank")?; check_is_some(data.password.as_ref(), "password cannot be blank")?;
@ -98,17 +91,13 @@ async fn login(
check_is_some(data.device_name.as_ref(), "device_name 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")?; 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" => { "client_credentials" => {
check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?; 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.client_secret.as_ref(), "client_secret cannot be blank")?;
check_is_some(data.scope.as_ref(), "scope 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 api_key_login(data, &mut user_id, &conn, &client_header.ip).await
} }
"authorization_code" if CONFIG.sso_enabled() => { "authorization_code" if CONFIG.sso_enabled() => {
@ -120,7 +109,7 @@ async fn login(
check_is_some(data.device_name.as_ref(), "device_name 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")?; 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"), "authorization_code" => err!("SSO sign-in is not available"),
"send_access" => { "send_access" => {
@ -144,7 +133,7 @@ async fn login(
match &login_result { match &login_result {
Ok(_) => { Ok(_) => {
log_user_event( log_user_event(
EventType::UserLoggedIn as i32, EventType::UserLoggedIn,
&user_id, &user_id,
client_header.device_type, client_header.device_type,
&client_header.ip.ip, &client_header.ip.ip,
@ -154,8 +143,7 @@ async fn login(
} }
Err(e) => { Err(e) => {
if let Some(ev) = e.get_event() { 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) log_user_event(ev.event, &user_id, client_header.device_type, &client_header.ip.ip, &conn).await;
.await;
} }
} }
} }
@ -205,13 +193,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 // After exchanging the code we need to check first if 2FA is needed before continuing
async fn sso_login( async fn sso_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbConn, ip: &ClientIp) -> JsonResult {
data: ConnectData,
user_id: &mut Option<UserId>,
conn: &DbConn,
ip: &ClientIp,
client_version: Option<&ClientVersion>,
) -> JsonResult {
AuthMethod::Sso.check_scope(data.scope.as_ref())?; AuthMethod::Sso.check_scope(data.scope.as_ref())?;
// Ratelimit the login // Ratelimit the login
@ -368,7 +350,7 @@ async fn sso_login(
Some((mut user, sso_user)) => { Some((mut user, sso_user)) => {
let mut device = get_device(&data, conn, &user).await?; 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() { if user.private_key.is_none() {
// User was invited a stub was created // User was invited a stub was created
@ -400,13 +382,7 @@ async fn sso_login(
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await
} }
async fn password_login( async fn password_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbConn, ip: &ClientIp) -> JsonResult {
data: ConnectData,
user_id: &mut Option<UserId>,
conn: &DbConn,
ip: &ClientIp,
client_version: Option<&ClientVersion>,
) -> JsonResult {
// Validate scope // Validate scope
AuthMethod::Password.check_scope(data.scope.as_ref())?; AuthMethod::Password.check_scope(data.scope.as_ref())?;
@ -415,24 +391,14 @@ async fn password_login(
// Get the user // Get the user
let username = data.username.as_ref().unwrap().trim(); 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 { 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. // Set the user_id here to be passed back used for event logging.
*user_id = Some(user.uuid.clone()); *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(); 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 // If we get an auth request, we don't check the user's password, but the access code of the auth request
@ -440,7 +406,7 @@ async fn password_login(
let Some(auth_request) = AuthRequest::find_by_uuid_and_user(auth_request_id, &user.uuid, conn).await else { let Some(auth_request) = AuthRequest::find_by_uuid_and_user(auth_request_id, &user.uuid, conn).await else {
err!( err!(
"Auth request not found. Try again.", "Auth request not found. Try again.",
format!("IP: {}. Username: {username}.", ip.ip), format!("IP: {}. Username: {log_username}.", ip.ip),
ErrorEvent { ErrorEvent {
event: EventType::UserFailedLogIn, event: EventType::UserFailedLogIn,
} }
@ -452,13 +418,14 @@ async fn password_login(
if auth_request.user_uuid != user.uuid if auth_request.user_uuid != user.uuid
|| !auth_request.approved.unwrap_or(false) || !auth_request.approved.unwrap_or(false)
|| auth_request.authentication_date.is_some()
|| request_expired || request_expired
|| ip.ip.to_string() != auth_request.request_ip || ip.ip.to_string() != auth_request.request_ip
|| !auth_request.check_access_code(password) || !auth_request.check_access_code(password)
{ {
err!( err!(
"Username or access code is incorrect. Try again", "Username or access code is incorrect. Try again",
format!("IP: {}. Username: {username}.", ip.ip), format!("IP: {}. Username: {log_username}.", ip.ip),
ErrorEvent { ErrorEvent {
event: EventType::UserFailedLogIn, event: EventType::UserFailedLogIn,
} }
@ -467,13 +434,24 @@ async fn password_login(
} else if !user.check_valid_password(password) { } else if !user.check_valid_password(password) {
err!( err!(
"Username or password is incorrect. Try again", "Username or password is incorrect. Try again",
format!("IP: {}. Username: {username}.", ip.ip), format!("IP: {}. Username: {log_username}.", ip.ip),
ErrorEvent { ErrorEvent {
event: EventType::UserFailedLogIn, 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) // Change the KDF Iterations (only when not logging in with an auth request)
if data.auth_request.is_none() { if data.auth_request.is_none() {
kdf_upgrade(&mut user, password, conn).await?; kdf_upgrade(&mut user, password, conn).await?;
@ -506,7 +484,7 @@ async fn password_login(
// We still want the login to fail until they actually verified the email address // We still want the login to fail until they actually verified the email address
err!( err!(
"Please verify your email before trying again.", "Please verify your email before trying again.",
format!("IP: {}. Username: {username}.", ip.ip), format!("IP: {}. Username: {log_username}.", ip.ip),
ErrorEvent { ErrorEvent {
event: EventType::UserFailedLogIn event: EventType::UserFailedLogIn
} }
@ -515,7 +493,20 @@ async fn password_login(
let mut device = get_device(&data, conn, &user).await?; 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); let auth_tokens = auth::AuthTokens::new(&device, &user, AuthMethod::Password, data.client_id);
@ -526,9 +517,8 @@ async fn password_login(
// database and consume it atomically before WebAuthn verification. // database and consume it atomically before WebAuthn verification.
#[get("/accounts/webauthn/assertion-options")] #[get("/accounts/webauthn/assertion-options")]
async fn get_webauthn_login_assertion_options(conn: DbConn, ip: ClientIp) -> JsonResult { async fn get_webauthn_login_assertion_options(conn: DbConn, ip: ClientIp) -> JsonResult {
if !CONFIG.passkeys_enabled() || !CONFIG.is_webauthn_2fa_supported() || (CONFIG.sso_enabled() && CONFIG.sso_only()) if !CONFIG.is_webauthn_2fa_supported() || (CONFIG.sso_enabled() && CONFIG.sso_only()) {
{ err!("Passkey sign-in is unavailable")
err!("Passkey sign-in is not enabled")
} }
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?; crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
let (mut response, state) = WEBAUTHN.start_passkey_authentication(&[])?; let (mut response, state) = WEBAUTHN.start_passkey_authentication(&[])?;
@ -560,6 +550,9 @@ async fn webauthn_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &
if !user.enabled { if !user.enabled {
err!("This user has been disabled") err!("This user has been disabled")
} }
if user.verified_at.is_none() && CONFIG.mail_enabled() && CONFIG.signups_verify() {
err!("Please verify your email before trying again.")
}
let Some(handle) = response.response.user_handle.as_ref() else { let Some(handle) = response.response.user_handle.as_ref() else {
err!("Invalid passkey user handle") err!("Invalid passkey user handle")
}; };
@ -604,6 +597,9 @@ async fn webauthn_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &
if !current_user.enabled { if !current_user.enabled {
err!("This user has been disabled") err!("This user has been disabled")
} }
if current_user.verified_at.is_none() && CONFIG.mail_enabled() && CONFIG.signups_verify() {
err!("Please verify your email before trying again.")
}
// User verification by the authenticator satisfies this grant's login factor. // User verification by the authenticator satisfies this grant's login factor.
// Other grants retain their existing two-step-login behavior and enrollment. // Other grants retain their existing two-step-login behavior and enrollment.
let mut device = get_device(&data, conn, &current_user).await?; let mut device = get_device(&data, conn, &current_user).await?;
@ -653,7 +649,7 @@ async fn authenticated_response(
// Save to update `device.updated_at` to track usage and toggle new status // Save to update `device.updated_at` to track usage and toggle new status
device.save(true, conn).await?; 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 has_master_password = !user.password_hash.is_empty();
let master_password_unlock = if has_master_password { let master_password_unlock = if has_master_password {
@ -697,7 +693,6 @@ async fn authenticated_response(
"KdfIterations": user.client_kdf_iter, "KdfIterations": user.client_kdf_iter,
"KdfMemory": user.client_kdf_memory, "KdfMemory": user.client_kdf_memory,
"KdfParallelism": user.client_kdf_parallelism, "KdfParallelism": user.client_kdf_parallelism,
"ResetMasterPassword": false, // TODO: Same as above
"ForcePasswordReset": false, "ForcePasswordReset": false,
"MasterPasswordPolicy": master_password_policy, "MasterPasswordPolicy": master_password_policy,
"scope": auth_tokens.scope(), "scope": auth_tokens.scope(),
@ -727,7 +722,14 @@ async fn api_key_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &D
// Validate scope // Validate scope
match data.scope.as_ref() { 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, Some(scope) if scope == &AuthMethod::OrgApiKey.scope() => organization_api_key_login(data, conn, ip).await,
_ => err!("Scope not supported"), _ => err!("Scope not supported"),
} }
@ -851,7 +853,6 @@ async fn user_api_key_login(
"KdfIterations": user.client_kdf_iter, "KdfIterations": user.client_kdf_iter,
"KdfMemory": user.client_kdf_memory, "KdfMemory": user.client_kdf_memory,
"KdfParallelism": user.client_kdf_parallelism, "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, "ForcePasswordReset": false,
"scope": AuthMethod::UserApiKey.scope(), "scope": AuthMethod::UserApiKey.scope(),
"AccountKeys": account_keys, "AccountKeys": account_keys,
@ -917,7 +918,6 @@ async fn twofactor_auth(
data: &ConnectData, data: &ConnectData,
device: &mut Device, device: &mut Device,
ip: &ClientIp, ip: &ClientIp,
client_version: Option<&ClientVersion>,
conn: &DbConn, conn: &DbConn,
) -> ApiResult<Option<String>> { ) -> ApiResult<Option<String>> {
let twofactors = TwoFactor::find_by_user(&user.uuid, conn).await; let twofactors = TwoFactor::find_by_user(&user.uuid, conn).await;
@ -946,17 +946,12 @@ async fn twofactor_auth(
if ![TwoFactorType::Remember as i32, TwoFactorType::RecoveryCode as i32].contains(&selected_id) if ![TwoFactorType::Remember as i32, TwoFactorType::RecoveryCode as i32].contains(&selected_id)
&& !twofactor_ids.contains(&selected_id) && !twofactor_ids.contains(&selected_id)
{ {
err_json!( err_json!(json_err_twofactor(&twofactor_ids, user, data, conn).await?, "Invalid two factor provider")
json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?,
"Invalid two factor provider"
)
} }
let Some(ref twofactor_code) = data.two_factor_token else { // Like upstream, a blank token counts as not provided
err_json!( let Some(twofactor_code) = data.two_factor_token.as_deref().filter(|t| !t.trim().is_empty()) else {
json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?, err_json!(json_err_twofactor(&twofactor_ids, user, data, conn).await?, "2FA token not provided")
"2FA token not provided"
)
}; };
let selected_twofactor = twofactors.into_iter().find(|tf| tf.atype == selected_id && tf.enabled); let selected_twofactor = twofactors.into_iter().find(|tf| tf.atype == selected_id && tf.enabled);
@ -970,20 +965,14 @@ async fn twofactor_auth(
Some(TwoFactorType::Webauthn) => webauthn::validate_webauthn_login(&user.uuid, twofactor_code, conn).await?, 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::YubiKey) => yubikey::validate_yubikey_login(twofactor_code, &selected_data?).await?,
Some(TwoFactorType::Duo) => { Some(TwoFactorType::Duo) => {
if CONFIG.duo_use_iframe() { duo_oidc::validate_duo_login(
// Legacy iframe prompt flow &user.email,
duo::validate_duo_login(&user.email, twofactor_code, conn).await?; twofactor_code,
} else { data.client_id.as_ref().unwrap(),
// OIDC based flow data.device_identifier.as_ref().unwrap(),
duo_oidc::validate_duo_login( conn,
&user.email, )
twofactor_code, .await?;
data.client_id.as_ref().unwrap(),
data.device_identifier.as_ref().unwrap(),
conn,
)
.await?;
}
} }
Some(TwoFactorType::Email) => { Some(TwoFactorType::Email) => {
email::validate_email_code_str(&user.uuid, twofactor_code, &selected_data?, &ip.ip, conn).await?; email::validate_email_code_str(&user.uuid, twofactor_code, &selected_data?, &ip.ip, conn).await?;
@ -1005,16 +994,22 @@ async fn twofactor_auth(
device.save(true, conn).await?; device.save(true, conn).await?;
} }
err_json!( 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" "2FA Remember token not provided or expired"
) )
} }
} }
} }
Some(TwoFactorType::RecoveryCode) => { Some(TwoFactorType::RecoveryCode) => {
// Check if recovery code is correct // Like upstream, spaces and case don't matter
if !user.check_valid_recovery_code(twofactor_code) { let recovery_code = twofactor_code.replace(' ', "").trim().to_lowercase();
err!("Recovery code is incorrect. Try again.") 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 // Remove all twofactors from the user
@ -1027,7 +1022,14 @@ async fn twofactor_auth(
enforce_2fa_policy(user, &user.uuid, device.atype, &ip.ip, conn).await?; 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 // Remove the recovery code, not needed without twofactors
user.totp_recover = None; user.totp_recover = None;
@ -1056,21 +1058,14 @@ fn selected_data(tf: Option<TwoFactor>) -> ApiResult<String> {
tf.map(|t| t.data).map_res("Two factor doesn't exist") tf.map(|t| t.data).map_res("Two factor doesn't exist")
} }
async fn json_err_twofactor( async fn json_err_twofactor(providers: &[i32], user: &User, data: &ConnectData, conn: &DbConn) -> ApiResult<Value> {
providers: &[i32], let user_id = &user.uuid;
user_id: &UserId,
data: &ConnectData,
client_version: Option<&ClientVersion>,
conn: &DbConn,
) -> ApiResult<Value> {
let mut result = json!({ let mut result = json!({
"error" : "invalid_grant", "error" : "invalid_grant",
"error_description" : "Two factor required.", "error_description" : "Two factor required.",
"TwoFactorProviders" : providers.iter().map(ToString::to_string).collect::<Vec<String>>(), "TwoFactorProviders" : providers.iter().map(ToString::to_string).collect::<Vec<String>>(),
"TwoFactorProviders2" : {}, // { "0" : null } "TwoFactorProviders2" : {}, // { "0" : null }
"MasterPasswordPolicy": { "MasterPasswordPolicy": identity_master_password_policy(user, conn).await,
"Object": "masterPasswordPolicy"
}
}); });
for provider in providers { for provider in providers {
@ -1083,37 +1078,21 @@ async fn json_err_twofactor(
} }
Some(TwoFactorType::Duo) => { Some(TwoFactorType::Duo) => {
let email = if let Some(u) = User::find_by_uuid(user_id, conn).await { let auth_url = duo_oidc::get_duo_auth_url(
u.email &user.email,
} else { data.client_id.as_ref().unwrap(),
err!("User does not exist") data.device_identifier.as_ref().unwrap(),
}; conn,
)
if CONFIG.duo_use_iframe() { .await?;
// 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!({ result["TwoFactorProviders2"][provider.to_string()] = json!({
"AuthUrl": auth_url, "AuthUrl": auth_url,
}); });
}
} }
Some(tf_type @ TwoFactorType::YubiKey) => { 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") err!("No YubiKey devices registered")
}; };
@ -1125,23 +1104,10 @@ async fn json_err_twofactor(
} }
Some(tf_type @ TwoFactorType::Email) => { 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") 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)?; let email_data = email::EmailTokenData::from_json(&twofactor.data)?;
result["TwoFactorProviders2"][provider.to_string()] = json!({ result["TwoFactorProviders2"][provider.to_string()] = json!({
"Email": email::obscure_email(&email_data.email), "Email": email::obscure_email(&email_data.email),
@ -1181,11 +1147,6 @@ async fn prelogin_password(data: Json<PreloginData>, ip: ClientIp, conn: DbConn)
prelogin(data, ip, conn).await 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)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct RegisterVerificationData { struct RegisterVerificationData {
@ -1257,8 +1218,9 @@ async fn register_verification_email(
} }
#[post("/accounts/register/finish", data = "<data>")] #[post("/accounts/register/finish", data = "<data>")]
async fn register_finish(data: Json<RegisterData>, conn: DbConn) -> JsonResult { async fn register_finish(data: Json<RegisterData>, ip: ClientIp, conn: DbConn) -> JsonResult {
register(data, true, conn).await crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
register(data, conn).await
} }
// https://github.com/bitwarden/jslib/blob/master/common/src/models/request/tokenRequest.ts // https://github.com/bitwarden/jslib/blob/master/common/src/models/request/tokenRequest.ts
@ -1395,7 +1357,7 @@ async fn oidcsignin_redirect(
let state = sso::decode_state(&base64_state)?; let state = sso::decode_state(&base64_state)?;
let Some(mut sso_auth) = SsoAuth::find(&state, conn).await else { 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 // Browser-binding check
@ -1404,7 +1366,7 @@ async fn oidcsignin_redirect(
let provided_hash = cookie_value.as_deref().map(|v| crypto::sha256_hex(v.as_bytes())); 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()) { match (sso_auth.binding_hash.as_deref(), provided_hash.as_deref()) {
(Some(expected), Some(actual)) if crypto::ct_eq(expected, actual) => {} (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 cookies
.remove(Cookie::build(SSO_BINDING_COOKIE).path(format!("{}/identity/connect/", CONFIG.domain_path())).build()); .remove(Cookie::build(SSO_BINDING_COOKIE).path(format!("{}/identity/connect/", CONFIG.domain_path())).build());

20
src/api/mod.rs

@ -46,7 +46,7 @@ pub type JsonResult = ApiResult<Json<Value>>;
pub type EmptyResult = ApiResult<()>; pub type EmptyResult = ApiResult<()>;
// Common structs representing JSON data received // Common structs representing JSON data received
#[derive(Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct PasswordOrOtpData { struct PasswordOrOtpData {
#[serde(alias = "MasterPasswordHash")] #[serde(alias = "MasterPasswordHash")]
@ -124,3 +124,21 @@ async fn master_password_policy(user: &User, conn: &DbConn) -> Value {
mpp_json["Object"] = json!("masterPasswordPolicy"); mpp_json["Object"] = json!("masterPasswordPolicy");
mpp_json 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,
}
}

38
src/api/notifications.rs

@ -1,7 +1,7 @@
use std::{ use std::{
net::IpAddr, net::IpAddr,
sync::{Arc, LazyLock}, sync::{Arc, LazyLock},
time::Duration, time::{Duration, Instant},
}; };
use chrono::{NaiveDateTime, Utc}; 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. /// One connection is needed per pending login request, several at once are only expected behind NAT.
const MAX_ANONYMOUS_CONNECTIONS_PER_IP: u32 = 25; 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()); static NOTIFICATIONS_DISABLED: LazyLock<bool> = LazyLock::new(|| !CONFIG.enable_websocket() && !CONFIG.push_enabled());
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
@ -156,12 +164,16 @@ fn websockets_hub<'r>(
rocket_ws::Stream! { ws => { rocket_ws::Stream! { ws => {
let mut ws = ws; let mut ws = ws;
let _guard = guard; 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 { loop {
tokio::select! { tokio::select! {
res = ws.next() => { res = ws.next() => {
match res { match res {
Some(Ok(message)) => { Some(Ok(message)) => {
// Any message, including a Pong, means the client is still there
last_received = Instant::now();
match message { match message {
// Respond to any pings // Respond to any pings
Message::Ping(ping) => yield Message::Pong(ping), 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 => { rocket_ws::Stream! { ws => {
let mut ws = ws; let mut ws = ws;
let _guard = guard; 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 { loop {
tokio::select! { tokio::select! {
res = ws.next() => { res = ws.next() => {
match res { match res {
Some(Ok(message)) => { Some(Ok(message)) => {
// Any message, including a Pong, means the client is still there
last_received = Instant::now();
match message { match message {
// Respond to any pings // Respond to any pings
Message::Ping(ping) => yield Message::Pong(ping), 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());
}
} }
} }
}} }}

3
src/api/web.rs

@ -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 // Else, there is either no cache, or reload_templates is true and we need to rebuild the CSS
let css_options = json!({ let css_options = json!({
"email_change_allowed": CONFIG.email_change_allowed(),
"emergency_access_allowed": CONFIG.emergency_access_allowed(), "emergency_access_allowed": CONFIG.emergency_access_allowed(),
"load_user_scss": true, "load_user_scss": true,
"mail_2fa_enabled": CONFIG._enable_email_2fa(), "mail_2fa_enabled": CONFIG._enable_email_2fa(),
@ -100,7 +101,7 @@ fn vaultwarden_css() -> EtagCached<Css<String>> {
"sso_enabled": CONFIG.sso_enabled(), "sso_enabled": CONFIG.sso_enabled(),
"sso_only": CONFIG.sso_enabled() && CONFIG.sso_only(), "sso_only": CONFIG.sso_enabled() && CONFIG.sso_only(),
"webauthn_2fa_supported": CONFIG.is_webauthn_2fa_supported(), "webauthn_2fa_supported": CONFIG.is_webauthn_2fa_supported(),
"passkeys_enabled": crate::api::core::passkeys::account_passkeys_allowed(), "passkey_account_supported": crate::api::core::passkeys::account_passkeys_allowed(),
"passkey_login_available": crate::api::core::passkeys::account_passkeys_allowed() "passkey_login_available": crate::api::core::passkeys::account_passkeys_allowed()
&& !(CONFIG.sso_enabled() && CONFIG.sso_only()), && !(CONFIG.sso_enabled() && CONFIG.sso_only()),
"yubico_enabled": CONFIG._enable_yubico() && CONFIG.yubico_client_id().is_some() && CONFIG.yubico_secret_key().is_some(), "yubico_enabled": CONFIG._enable_yubico() && CONFIG.yubico_client_id().is_some() && CONFIG.yubico_secret_key().is_some(),

114
src/auth.rs

@ -1,3 +1,6 @@
#[path = "auth/two_factor.rs"]
pub mod two_factor;
#[path = "auth/send.rs"] #[path = "auth/send.rs"]
pub mod send; pub mod send;
pub type SendTokens = send::SendTokens; pub type SendTokens = send::SendTokens;
@ -14,6 +17,7 @@ use ipnet::IpNet;
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, errors::ErrorKind}; use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, errors::ErrorKind};
use num_traits::FromPrimitive; use num_traits::FromPrimitive;
use openssl::rsa::Rsa; use openssl::rsa::Rsa;
use ring::hmac;
use serde::{de::DeserializeOwned, ser::Serialize}; use serde::{de::DeserializeOwned, ser::Serialize};
use rocket::{ use rocket::{
@ -65,6 +69,7 @@ static JWT_2FA_REMEMBER_ISSUER: LazyLock<String> = LazyLock::new(|| format!("{}|
static PRIVATE_RSA_KEY: OnceLock<EncodingKey> = OnceLock::new(); static PRIVATE_RSA_KEY: OnceLock<EncodingKey> = OnceLock::new();
static PUBLIC_RSA_KEY: OnceLock<DecodingKey> = 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> { pub async fn initialize_keys() -> Result<(), Error> {
use std::io::Error as IoError; 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() { if PUBLIC_RSA_KEY.set(dec).is_err() {
err!("PUBLIC_RSA_KEY must only be initialized once") 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(()) 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 { pub fn encode_jwt<T: Serialize>(claims: &T) -> String {
match jsonwebtoken::encode(&JWT_HEADER, claims, PRIVATE_RSA_KEY.wait()) { match jsonwebtoken::encode(&JWT_HEADER, claims, PRIVATE_RSA_KEY.wait()) {
Ok(token) => token, Ok(token) => token,
@ -253,7 +271,7 @@ impl LoginJwtClaims {
premium: true, premium: true,
name: user.name.clone(), name: user.name.clone(),
email: user.email.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 // 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(); let time_now = Utc::now();
FileDownloadClaims { FileDownloadClaims {
nbf: time_now.timestamp(), 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(), iss: JWT_FILE_DOWNLOAD_ISSUER.to_string(),
sub: cipher_id, sub: cipher_id,
file_id, file_id,
@ -1091,6 +1109,31 @@ fn ip_header_is_trusted(remote: Option<IpAddr>) -> bool {
trusted.split(',').filter_map(parse_trusted_proxy).any(|net| net.contains(&remote)) 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] #[rocket::async_trait]
impl<'r> FromRequest<'r> for ClientIp { impl<'r> FromRequest<'r> for ClientIp {
type Error = (); type Error = ();
@ -1099,15 +1142,7 @@ impl<'r> FromRequest<'r> for ClientIp {
let remote = req.remote().map(|r| r.ip()); let remote = req.remote().map(|r| r.ip());
let ip = if CONFIG._ip_header_enabled() && ip_header_is_trusted(remote) { let ip = if CONFIG._ip_header_enabled() && ip_header_is_trusted(remote) {
req.headers().get_one(&CONFIG.ip_header()).and_then(|ip| { client_ip_from_header(req, &CONFIG.ip_header(), |ip| ip_header_is_trusted(Some(ip)))
match ip.find(',') {
Some(idx) => &ip[..idx],
None => ip,
}
.parse()
.map_err(|_| warn!("'{}' header is malformed: {ip}", CONFIG.ip_header()))
.ok()
})
} else { } else {
if CONFIG._ip_header_enabled() && req.headers().get_one(&CONFIG.ip_header()).is_some() { 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 // Log the canonical IP, which is what the user filter will need to match against
@ -1227,7 +1262,7 @@ impl AuthMethod {
match scope { match scope {
None => err!("Missing scope"), None => err!("Missing scope"),
Some(scope) if scope == &method_scope => Ok(method_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())),
} }
} }
} }
@ -1341,11 +1376,7 @@ pub async fn refresh_tokens(
AuthMethod::Sso => err!("SSO is now disabled, Login again using email and master password"), AuthMethod::Sso => err!("SSO is now disabled, Login again using email and master password"),
AuthMethod::Password if CONFIG.sso_enabled() && CONFIG.sso_only() => err!("SSO is now required, Login again"), AuthMethod::Password if CONFIG.sso_enabled() && CONFIG.sso_only() => err!("SSO is now required, Login again"),
AuthMethod::Password => AuthTokens::new(&device, &user, refresh_claims.sub, client_id), AuthMethod::Password => AuthTokens::new(&device, &user, refresh_claims.sub, client_id),
AuthMethod::Webauthn AuthMethod::Webauthn if CONFIG.is_webauthn_2fa_supported() && !(CONFIG.sso_enabled() && CONFIG.sso_only()) => {
if CONFIG.passkeys_enabled()
&& CONFIG.is_webauthn_2fa_supported()
&& !(CONFIG.sso_enabled() && CONFIG.sso_only()) =>
{
AuthTokens::new(&device, &user, refresh_claims.sub, client_id) AuthTokens::new(&device, &user, refresh_claims.sub, client_id)
} }
AuthMethod::Webauthn => err!("Passkey sign-in is no longer available"), AuthMethod::Webauthn => err!("Passkey sign-in is no longer available"),
@ -1354,3 +1385,52 @@ pub async fn refresh_tokens(
Ok((device, auth_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"));
}
}

6
src/auth/send.rs

@ -75,7 +75,7 @@ impl SendTokens {
return Self::invalid_error(&format!("Can't convert {access_id}"), "send_id_invalid", false); 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); 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 { Ok(Self {
access_claims: generate_send_access_claims(&send_id), access_claims: generate_send_access_claims(&send_id),
}) })

281
src/auth/two_factor.rs

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

65
src/config.rs

@ -561,7 +561,7 @@ make_config! {
/// Auth Request cleanup schedule |> Cron schedule of the job that cleans old auth requests from the auth request. /// 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. /// Defaults to every minute. Set blank to disable this job.
auth_request_purge_schedule: String, false, def, "30 * * * * *".to_owned(); 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. /// Defaults to once every minute. Set blank to disable this job.
duo_context_purge_schedule: String, false, def, "30 * * * * *".to_owned(); 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. /// Purge incomplete SSO auth. |> Cron schedule of the job that cleans leftover auth in db due to incomplete SSO login.
@ -582,8 +582,6 @@ make_config! {
domain_path: String, false, auto, |c| extract_url_path(&c.domain); domain_path: String, false, auto, |c| extract_url_path(&c.domain);
/// Enable web vault /// Enable web vault
web_vault_enabled: bool, false, def, true; web_vault_enabled: bool, false, def, true;
/// Passkey login and vault unlock |> Default-off support for passkey sign-in and optional PRF vault unlock.
passkeys_enabled: bool, false, def, false;
/// Allow Sends |> Controls whether users are allowed to create Bitwarden Sends. /// Allow Sends |> Controls whether users are allowed to create Bitwarden Sends.
/// This setting applies globally to all users. To control this on a per-org basis instead, use the "Disable Send" org policy. /// This setting applies globally to all users. To control this on a per-org basis instead, use the "Disable Send" org policy.
@ -677,7 +675,8 @@ make_config! {
/// other address use the remote IP instead, so a client can't spoof the header. /// 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 /// 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, /// 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(); ip_header_trusted_proxies: String, true, def, "local".to_owned();
/// Icon service |> The predefined icon services are: internal, bitwarden, duckduckgo, google. /// 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 `{}`, /// To specify a custom icon service, set a URL template with exactly one instance of `{}`,
@ -867,16 +866,12 @@ make_config! {
duo: _enable_duo { duo: _enable_duo {
/// Enabled /// Enabled
_enable_duo: bool, true, def, true; _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 /// Client Id
duo_ikey: String, true, option; duo_ikey: String, true, option;
/// Client Secret /// Client Secret
duo_skey: Pass, true, option; duo_skey: Pass, true, option;
/// Host /// Host
duo_host: String, true, option; duo_host: String, true, option;
/// Application Key (generated automatically)
_duo_akey: Pass, false, option;
}, },
/// SMTP Email Settings /// SMTP Email Settings
@ -1271,6 +1266,14 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
err!("`AUTH_REQUEST_PURGE_SCHEDULE` is not a valid cron expression") 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 { if !cfg.disable_admin_token {
match cfg.admin_token.as_ref() { match cfg.admin_token.as_ref() {
Some(t) if t.starts_with("$argon2") => { Some(t) if t.starts_with("$argon2") => {
@ -1420,28 +1423,19 @@ 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 // 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 // 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] = &[ 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 // Auth Team
"pm-5594-safari-account-switching", "pm-5594-safari-account-switching",
"pm-32413-multi-client-password-management", "pm-32413-multi-client-password-management",
// Autofill Team // Autofill Team
"undetermined-cipher-scenario-logic",
"enable-basic-auth-response", "enable-basic-auth-response",
"ssh-agent",
"ssh-agent-v2", "ssh-agent-v2",
"windows-desktop-autotype",
"windows-desktop-autotype-ga",
// Key Management Team // Key Management Team
"ssh-key-vault-item", "biometrics-sdk-ipc",
"pm-25373-windows-biometrics-v2", "windows-native-credential-sync",
"pm-26340-linux-biometrics-v2",
// Mobile Team // Mobile Team
"anon-addy-self-host-alias",
"simple-login-self-host-alias",
"mutual-tls",
"cxp-import-mobile",
"cxp-export-mobile",
"pm-34171-card-scanner", "pm-34171-card-scanner",
// Platform Team // Platform Team
"pm-30529-webauthn-related-origins", "pm-30529-webauthn-related-origins",
@ -1518,15 +1512,6 @@ impl Config {
Ok(()) 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 /// 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 /// is in signups_domains_whitelist, or if no whitelist is set (so there
/// are no domain restrictions in effect). /// are no domain restrictions in effect).
@ -1622,23 +1607,6 @@ impl Config {
inner._enable_smtp && (inner.smtp_host.is_some() || inner.use_sendmail) 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 { pub fn is_webauthn_2fa_supported(&self) -> bool {
Url::parse(&self.domain()).expect("DOMAIN not a valid URL").domain().is_some() Url::parse(&self.domain()).expect("DOMAIN not a valid URL").domain().is_some()
} }
@ -1770,6 +1738,7 @@ where
reg!("email/protected_action", ".html"); reg!("email/protected_action", ".html");
reg!("email/pw_hint_none", ".html"); reg!("email/pw_hint_none", ".html");
reg!("email/pw_hint_some", ".html"); reg!("email/pw_hint_some", ".html");
reg!("email/recover_twofactor", ".html");
reg!("email/register_verify_email", ".html"); reg!("email/register_verify_email", ".html");
reg!("email/send_2fa_removed_from_org", ".html"); reg!("email/send_2fa_removed_from_org", ".html");
reg!("email/send_emergency_access_invite", ".html"); reg!("email/send_emergency_access_invite", ".html");

2
src/db/models/attachment.rs

@ -61,7 +61,7 @@ impl Attachment {
let token = encode_jwt(&generate_file_download_claims(self.cipher_uuid.clone(), self.id.clone())); 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)) Ok(format!("{host}/attachments/{}/{}?token={token}", self.cipher_uuid, self.id))
} else { } 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())
} }
} }

26
src/db/models/auth_request.rs

@ -4,7 +4,7 @@ use diesel::prelude::*;
use serde_json::Value; use serde_json::Value;
use crate::{ use crate::{
api::EmptyResult, api::{ApiResult, EmptyResult},
crypto::ct_eq, crypto::ct_eq,
db::{DbConn, schema::auth_requests}, db::{DbConn, schema::auth_requests},
error::MapResult, error::MapResult,
@ -166,6 +166,30 @@ impl AuthRequest {
.await .await
} }
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
conn.run(move |conn| {
diesel::delete(auth_requests::table.filter(auth_requests::user_uuid.eq(user_uuid)))
.execute(conn)
.map_res("Error deleting auth requests for user")
})
.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 { pub fn check_access_code(&self, access_code: &str) -> bool {
ct_eq(&self.access_code, access_code) ct_eq(&self.access_code, access_code)
} }

1
src/db/models/cipher.rs

@ -711,6 +711,7 @@ impl Cipher {
// Only allow group access via a confirmed membership, since // Only allow group access via a confirmed membership, since
// groups_users rows are kept when a membership is revoked. // groups_users rows are kept when a membership is revoked.
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .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)) .select((collections_groups::read_only, collections_groups::hide_passwords, collections_groups::manage))
.load::<(bool, bool, bool)>(conn) .load::<(bool, bool, bool)>(conn)
.expect("Error getting group access restrictions") .expect("Error getting group access restrictions")

21
src/db/models/group.rs

@ -104,7 +104,6 @@ impl Group {
"id": self.uuid, "id": self.uuid,
"organizationId": self.organizations_uuid, "organizationId": self.organizations_uuid,
"name": self.name, "name": self.name,
"accessAll": self.access_all,
"externalId": self.external_id, "externalId": self.external_id,
"collections": collections_groups, "collections": collections_groups,
"object": "groupDetails" "object": "groupDetails"
@ -574,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 { 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; let group_users = GroupUser::find_by_group(group_uuid, org_uuid, conn).await;
for group_user in group_users { for group_user in group_users {

20
src/db/models/org_policy.rs

@ -1,3 +1,4 @@
use chrono::{NaiveDateTime, Utc};
use derive_more::{AsRef, From}; use derive_more::{AsRef, From};
use diesel::prelude::*; use diesel::prelude::*;
use serde::Deserialize; use serde::Deserialize;
@ -11,6 +12,7 @@ use crate::{
schema::{org_policies, users_organizations}, schema::{org_policies, users_organizations},
}, },
error::MapResult, error::MapResult,
util::format_date,
}; };
use super::{Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId, TwoFactor, UserId}; use super::{Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId, TwoFactor, UserId};
@ -24,6 +26,7 @@ pub struct OrgPolicy {
pub atype: i32, pub atype: i32,
pub enabled: bool, pub enabled: bool,
pub data: String, pub data: String,
pub revision_date: NaiveDateTime,
} }
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Enums/PolicyType.cs // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Enums/PolicyType.cs
@ -46,7 +49,7 @@ pub enum OrgPolicyType {
RemoveUnlockWithPin = 14, RemoveUnlockWithPin = 14,
RestrictedItemTypes = 15, RestrictedItemTypes = 15,
UriMatchDefaults = 16, UriMatchDefaults = 16,
// AutotypeDefaultSetting = 17, // Not supported yet AutotypeDefaultSetting = 17,
// AutoConfirm = 18, // Not supported (not implemented yet) // AutoConfirm = 18, // Not supported (not implemented yet)
// BlockClaimedDomainAccountCreation = 19, // Not supported (Not AGPLv3 Licensed) // BlockClaimedDomainAccountCreation = 19, // Not supported (Not AGPLv3 Licensed)
OrganizationUserNotification = 20, OrganizationUserNotification = 20,
@ -77,6 +80,7 @@ impl OrgPolicy {
atype: atype as i32, atype: atype as i32,
enabled, enabled,
data, data,
revision_date: Utc::now().naive_utc(),
} }
} }
@ -92,7 +96,7 @@ impl OrgPolicy {
"type": self.atype, "type": self.atype,
"data": data_json, "data": data_json,
"enabled": self.enabled, "enabled": self.enabled,
"revisionDate": null, "revisionDate": format_date(&self.revision_date),
"object": "policy", "object": "policy",
}); });
@ -110,11 +114,13 @@ impl OrgPolicy {
/// Database methods /// Database methods
impl OrgPolicy { 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: db_run! { conn:
sqlite, mysql { sqlite, mysql {
match diesel::replace_into(org_policies::table) match diesel::replace_into(org_policies::table)
.values(self) .values(&*self)
.execute(conn) .execute(conn)
{ {
Ok(_) => Ok(()), Ok(_) => Ok(()),
@ -122,7 +128,7 @@ impl OrgPolicy {
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(org_policies::table) diesel::update(org_policies::table)
.filter(org_policies::uuid.eq(&self.uuid)) .filter(org_policies::uuid.eq(&self.uuid))
.set(self) .set(&*self)
.execute(conn) .execute(conn)
.map_res("Error saving org_policy") .map_res("Error saving org_policy")
} }
@ -142,10 +148,10 @@ impl OrgPolicy {
.map_res("Error deleting org_policy for insert")?; .map_res("Error deleting org_policy for insert")?;
diesel::insert_into(org_policies::table) diesel::insert_into(org_policies::table)
.values(self) .values(&*self)
.on_conflict(org_policies::uuid) .on_conflict(org_policies::uuid)
.do_update() .do_update()
.set(self) .set(&*self)
.execute(conn) .execute(conn)
.map_res("Error saving org_policy") .map_res("Error saving org_policy")
} }

10
src/db/models/organization.rs

@ -342,7 +342,7 @@ impl OrganizationApiKey {
impl Organization { impl Organization {
pub async fn save(&self, conn: &DbConn) -> EmptyResult { pub async fn save(&self, conn: &DbConn) -> EmptyResult {
if !crate::util::is_valid_email(&self.billing_email) { 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 { for member in &Membership::find_by_org(&self.uuid, conn).await {
@ -515,8 +515,7 @@ impl Membership {
"limitCollectionDeletion": true, "limitCollectionDeletion": true,
"limitItemDeletion": false, "limitItemDeletion": false,
"allowAdminAccessToAllCollectionItems": true, "allowAdminAccessToAllCollectionItems": true,
"userIsManagedByOrganization": false, // Means not managed via the Members UI, like SSO "userIsClaimedByOrganization": false, // Means not managed via the Members UI, like SSO
"userIsClaimedByOrganization": false, // The new key instead of the obsolete userIsManagedByOrganization
"permissions": permissions, "permissions": permissions,
@ -626,7 +625,6 @@ impl Membership {
"status": status, "status": status,
"type": membership_type, "type": membership_type,
"accessAll": self.access_all,
"twoFactorEnabled": twofactor_enabled, "twoFactorEnabled": twofactor_enabled,
"resetPasswordEnrolled": self.reset_password_key.is_some(), "resetPasswordEnrolled": self.reset_password_key.is_some(),
"hasMasterPassword": !user.password_hash.is_empty(), "hasMasterPassword": !user.password_hash.is_empty(),
@ -634,7 +632,6 @@ impl Membership {
"permissions": permissions, "permissions": permissions,
"ssoBound": false, // Not supported "ssoBound": false, // Not supported
"managedByOrganization": false, // This key is obsolete replaced by claimedByOrganization
"claimedByOrganization": false, // Means not managed via the Members UI, like SSO "claimedByOrganization": false, // Means not managed via the Members UI, like SSO
"usesKeyConnector": false, // Not supported "usesKeyConnector": false, // Not supported
"accessSecretsManager": false, // Not supported (Not AGPLv3 Licensed) "accessSecretsManager": false, // Not supported (Not AGPLv3 Licensed)
@ -685,7 +682,6 @@ impl Membership {
"status": status, "status": status,
"type": self.atype, "type": self.atype,
"accessAll": self.access_all,
"collections": coll_uuids, "collections": coll_uuids,
"object": "organizationUserDetails", "object": "organizationUserDetails",
@ -1028,6 +1024,7 @@ impl Membership {
conn.run(move |conn| { conn.run(move |conn| {
users_organizations::table users_organizations::table
.filter(users_organizations::org_uuid.eq(org_uuid)) .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(users_collections::table.on(users_collections::user_uuid.eq(users_organizations::user_uuid)))
.left_join( .left_join(
ciphers_collections::table.on(ciphers_collections::collection_uuid ciphers_collections::table.on(ciphers_collections::collection_uuid
@ -1054,6 +1051,7 @@ impl Membership {
conn.run(move |conn| { conn.run(move |conn| {
users_organizations::table users_organizations::table
.filter(users_organizations::org_uuid.eq(org_uuid)) .filter(users_organizations::org_uuid.eq(org_uuid))
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.inner_join( .inner_join(
groups_users::table.on(groups_users::users_organizations_uuid.eq(users_organizations::uuid)), groups_users::table.on(groups_users::users_organizations_uuid.eq(users_organizations::uuid)),
) )

30
src/db/models/send.rs

@ -140,6 +140,10 @@ impl Send {
None 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 { pub fn to_json(&self) -> Value {
let mut data = serde_json::from_str::<LowerCase<Value>>(&self.data).map(|d| d.data).unwrap_or_default(); 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!({ json!({
"id": self.uuid, "id": self.uuid,
"accessId": BASE64URL_NOPAD.encode(Uuid::parse_str(&self.uuid).unwrap_or_default().as_bytes()), "accessId": self.access_id(),
"type": self.atype, "type": self.atype,
"name": self.name, "name": self.name,
@ -182,7 +186,7 @@ impl Send {
} }
json!({ json!({
"id": self.uuid, "id": self.access_id(),
"type": self.atype, "type": self.atype,
"name": self.name, "name": self.name,
@ -256,7 +260,7 @@ impl Send {
/// Whether the Send is currently within its validity window: not disabled, not past its /// 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` /// 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 { pub fn is_accessible(&self) -> bool {
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
if self.disabled { if self.disabled {
@ -309,19 +313,6 @@ impl Send {
Ok(()) 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> { 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 conn.run(move |conn| sends::table.filter(sends::uuid.eq(uuid)).first::<Self>(conn).ok()).await
} }
@ -361,13 +352,6 @@ impl Send {
Some(total) 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> { pub async fn find_by_past_deletion_date(conn: &DbConn) -> Vec<Self> {
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
conn.run(move |conn| { conn.run(move |conn| {

4
src/db/models/two_factor.rs

@ -139,11 +139,11 @@ impl TwoFactor {
.await .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| { conn.run(move |conn| {
twofactor::table twofactor::table
.filter(twofactor::user_uuid.eq(user_uuid)) .filter(twofactor::user_uuid.eq(user_uuid))
.filter(twofactor::atype.eq(atype)) .filter(twofactor::atype.eq(atype as i32))
.first::<Self>(conn) .first::<Self>(conn)
.ok() .ok()
}) })

53
src/db/models/user.rs

@ -19,7 +19,8 @@ use crate::{
use macros::UuidFromParam; use macros::UuidFromParam;
use super::{ use super::{
Cipher, Device, EmergencyAccess, Favorite, Folder, Membership, MembershipType, TwoFactor, TwoFactorIncomplete, AuthRequest, Cipher, Device, EmergencyAccess, Favorite, Folder, Membership, MembershipType, TwoFactor,
TwoFactorIncomplete,
}; };
#[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)] #[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)]
@ -170,6 +171,10 @@ impl User {
) )
} }
pub fn master_password_salt(&self) -> String {
self.email.trim().to_lowercase()
}
pub fn check_valid_recovery_code(&self, recovery_code: &str) -> bool { pub fn check_valid_recovery_code(&self, recovery_code: &str) -> bool {
if let Some(ref totp_recover) = self.totp_recover { if let Some(ref totp_recover) = self.totp_recover {
crypto::ct_eq(recovery_code, totp_recover.to_lowercase()) crypto::ct_eq(recovery_code, totp_recover.to_lowercase())
@ -257,6 +262,29 @@ impl User {
/// Database methods /// Database methods
impl User { impl User {
pub fn verified(&self) -> bool {
!CONFIG.mail_enabled() || self.verified_at.is_some()
}
/// The `accountKeys` object (upstream's `PrivateKeysResponseModel`), null without a key pair
pub fn account_keys_json(&self) -> Value {
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
}
}
pub async fn to_json(&self, conn: &DbConn) -> Value { pub async fn to_json(&self, conn: &DbConn) -> Value {
let mut orgs_json = Vec::new(); let mut orgs_json = Vec::new();
for c in Membership::find_confirmed_by_user(&self.uuid, conn).await { for c in Membership::find_confirmed_by_user(&self.uuid, conn).await {
@ -277,21 +305,7 @@ impl User {
UserStatus::Enabled UserStatus::Enabled
}; };
let account_keys = if self.private_key.is_some() { let account_keys = self.account_keys_json();
json!({
"publicKeyEncryptionKeyPair": {
"wrappedPrivateKey": self.private_key,
"publicKey": self.public_key,
"signedPublicKey": null,
"object": "publicKeyEncryptionKeyPair",
},
"securityState": null,
"signatureKeyPair": null,
"object": "privateKeys"
})
} else {
Value::Null
};
json!({ json!({
"_status": status as i32, "_status": status as i32,
@ -299,7 +313,7 @@ impl User {
"id": self.uuid, "id": self.uuid,
"name": self.name, "name": self.name,
"email": self.email, "email": self.email,
"emailVerified": !CONFIG.mail_enabled() || self.verified_at.is_some(), "emailVerified": self.verified(),
"premium": true, "premium": true,
"premiumFromOrganization": false, "premiumFromOrganization": false,
"culture": "en-US", "culture": "en-US",
@ -321,7 +335,7 @@ impl User {
pub async fn save(&mut self, conn: &DbConn) -> EmptyResult { pub async fn save(&mut self, conn: &DbConn) -> EmptyResult {
if !crate::util::is_valid_email(&self.email) { 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(); self.updated_at = Utc::now().naive_utc();
@ -417,6 +431,7 @@ impl User {
Cipher::delete_all_by_user(&self.uuid, conn).await?; Cipher::delete_all_by_user(&self.uuid, conn).await?;
Favorite::delete_all_by_user(&self.uuid, conn).await?; Favorite::delete_all_by_user(&self.uuid, conn).await?;
Folder::delete_all_by_user(&self.uuid, conn).await?; Folder::delete_all_by_user(&self.uuid, conn).await?;
AuthRequest::delete_all_by_user(&self.uuid, conn).await?;
Device::delete_all_by_user(&self.uuid, conn).await?; Device::delete_all_by_user(&self.uuid, conn).await?;
TwoFactor::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?; TwoFactorIncomplete::delete_all_by_user(&self.uuid, conn).await?;
@ -522,7 +537,7 @@ impl Invitation {
pub async fn save(&self, conn: &DbConn) -> EmptyResult { pub async fn save(&self, conn: &DbConn) -> EmptyResult {
if !crate::util::is_valid_email(&self.email) { 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: db_run! { conn:

1
src/db/schema.rs

@ -116,6 +116,7 @@ table! {
atype -> Integer, atype -> Integer,
enabled -> Bool, enabled -> Bool,
data -> Text, data -> Text,
revision_date -> Timestamp,
} }
} }

18
src/http_client.rs

@ -466,7 +466,7 @@ mod tests {
#[test] #[test]
fn dotted_decimal_loopback_normalizes() { fn dotted_decimal_loopback_normalizes() {
let ip = parse_to_ip("127.0.0.1").unwrap(); 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)); assert!(!is_global_hardcoded(ip));
} }
@ -474,21 +474,21 @@ mod tests {
fn single_decimal_loopback_normalizes() { fn single_decimal_loopback_normalizes() {
// 127.0.0.1 == 2130706433 // 127.0.0.1 == 2130706433
let ip = parse_to_ip("2130706433").unwrap(); 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)); assert!(!is_global_hardcoded(ip));
} }
#[test] #[test]
fn hex_loopback_normalizes() { fn hex_loopback_normalizes() {
let ip = parse_to_ip("0x7f000001").unwrap(); 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)); assert!(!is_global_hardcoded(ip));
} }
#[test] #[test]
fn dotted_hex_loopback_normalizes() { fn dotted_hex_loopback_normalizes() {
let ip = parse_to_ip("0x7f.0.0.1").unwrap(); 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)); assert!(!is_global_hardcoded(ip));
} }
@ -496,14 +496,14 @@ mod tests {
fn octal_loopback_normalizes() { fn octal_loopback_normalizes() {
// 017700000001 == 127.0.0.1 // 017700000001 == 127.0.0.1
let ip = parse_to_ip("017700000001").unwrap(); 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)); assert!(!is_global_hardcoded(ip));
} }
#[test] #[test]
fn dotted_octal_loopback_normalizes() { fn dotted_octal_loopback_normalizes() {
let ip = parse_to_ip("0177.0.0.01").unwrap(); 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)); assert!(!is_global_hardcoded(ip));
} }
@ -535,19 +535,19 @@ mod tests {
#[test] #[test]
fn get_valid_host_normalizes_decimal_int() { fn get_valid_host_normalizes_decimal_int() {
let h = get_valid_host("2130706433").expect("valid"); 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] #[test]
fn get_valid_host_normalizes_hex() { fn get_valid_host_normalizes_hex() {
let h = get_valid_host("0x7f000001").expect("valid"); 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] #[test]
fn get_valid_host_normalizes_octal() { fn get_valid_host_normalizes_octal() {
let h = get_valid_host("017700000001").expect("valid"); 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
src/mail.rs

@ -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 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( pub async fn send_incomplete_2fa_login(
address: &str, address: &str,
ip: &str, ip: &str,

2
src/main.rs

@ -720,7 +720,7 @@ fn schedule_jobs(pool: db::DbPool) {
} }
// Clean unused, expired Duo authentication contexts. // 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(), || { sched.add(Job::new(CONFIG.duo_context_purge_schedule().parse().unwrap(), || {
runtime.spawn(purge_duo_contexts(pool.clone())); runtime.spawn(purge_duo_contexts(pool.clone()));
})); }));

32
src/sso.rs

@ -2,7 +2,6 @@ use std::{sync::LazyLock, time::Duration};
use chrono::Utc; use chrono::Utc;
use derive_more::{AsRef, Deref, Display, From, Into}; use derive_more::{AsRef, Deref, Display, From, Into};
use regex::Regex;
use url::Url; use url::Url;
use crate::{ use crate::{
@ -175,16 +174,16 @@ pub fn decode_state(base64_state: &str) -> ApiResult<OIDCState> {
if let Ok(valid) = String::from_utf8(vec) { if let Ok(valid) = String::from_utf8(vec) {
OIDCState(valid) OIDCState(valid)
} else { } 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 { } else {
err!(format!("Failed to decode {base64_state} using base64")) err!(format!("Failed to decode {} using base64", base64_state.escape_debug()))
}; };
Ok(state) 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( pub async fn authorize_url(
state: OIDCState, state: OIDCState,
client_challenge: OIDCCodeChallenge, client_challenge: OIDCCodeChallenge,
@ -193,20 +192,21 @@ pub async fn authorize_url(
binding_hash: Option<String>, binding_hash: Option<String>,
conn: DbConn, conn: DbConn,
) -> ApiResult<Url> { ) -> 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 { let redirect_uri = match client_id {
"web" | "browser" => format!("{}/sso-connector.html", CONFIG.domain()), "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" => { "cli" => {
let port_regex = Regex::new(r"^http://localhost:([0-9]{4})$").unwrap(); let Some(uri) = localhost_redirect_uri else {
if let Some(port) = err!(format!("Unsupported redirect uri {}", raw_redirect_uri.escape_debug()))
port_regex.captures(raw_redirect_uri).and_then(|captures| captures.get(1).map(|c| c.as_str())) };
{ uri
format!("http://localhost:{port}")
} else {
err!("Failed to extract port number")
}
} }
_ => 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?; 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 // During the 2FA flow we will
// - retrieve the user information and then only discover he needs 2FA. // - 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. // The `SsoAuth` will ensure that the user is authorized only once.
pub async fn exchange_code( pub async fn exchange_code(
code: &OIDCCode, code: &OIDCCode,
@ -256,6 +257,7 @@ pub async fn exchange_code(
}; };
if let Some(authenticated_user) = sso_auth.auth_response.clone() { if let Some(authenticated_user) = sso_auth.auth_response.clone() {
Client::check_client_verifier(&client_verifier, &sso_auth)?;
return Ok((sso_auth, authenticated_user)); return Ok((sso_auth, authenticated_user));
} }

19
src/sso_client.rs

@ -212,6 +212,16 @@ impl Client {
Ok((auth_url, SsoAuth::new(state, client_challenge, nonce.secret().clone(), redirect_uri, binding_hash))) 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( pub async fn exchange_code(
&self, &self,
code: OIDCCode, code: OIDCCode,
@ -234,15 +244,10 @@ impl Client {
let mut exchange = self.core_client.exchange_code(oidc_code); let mut exchange = self.core_client.exchange_code(oidc_code);
let verifier = PkceCodeVerifier::new(client_verifier.into());
if CONFIG.sso_pkce() { if CONFIG.sso_pkce() {
exchange = exchange.set_pkce_verifier(verifier); exchange = exchange.set_pkce_verifier(PkceCodeVerifier::new(client_verifier.into()));
} else { } else {
let challenge = PkceCodeChallenge::from_code_verifier_sha256(&verifier); Self::check_client_verifier(&client_verifier, sso_auth)?;
if challenge.as_str() != String::from(sso_auth.client_challenge.clone()) {
err!("PKCE client challenge failed")
// Might need to notify admin ? how ?
}
} }
match exchange.request_async(&self.http_client).await { match exchange.request_async(&self.http_client).await {

4
src/static/scripts/datatables.css

@ -4,10 +4,10 @@
* *
* To rebuild or modify this file with the latest versions of the included * To rebuild or modify this file with the latest versions of the included
* software please visit: * software please visit:
* https://datatables.net/download/#bs5/dt-3.0.4 * https://datatables.net/download/#bs5/dt-3.1.3
* *
* Included libraries: * Included libraries:
* DataTables 3.0.4 * DataTables 3.1.3
*/ */
/*! DataTables Bootstrap 5 integration /*! DataTables Bootstrap 5 integration

650
src/static/scripts/datatables.js

File diff suppressed because it is too large

9
src/static/templates/email/recover_twofactor.hbs

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

26
src/static/templates/email/recover_twofactor.html.hbs

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

2
src/static/templates/email/send_emergency_access_invite.html.hbs

@ -9,7 +9,7 @@ Emergency access for {{{grantor_name}}}
</tr> </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;"> <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"> <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;"> 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 Become emergency contact
</a> </a>

39
src/static/templates/scss/vaultwarden.scss.hbs

@ -42,51 +42,34 @@ a[href$="/settings/sponsored-families"] {
{{/if}} {{/if}}
/* Hide the `Enterprise Single Sign-On` button on the login page */ /* Hide the `Enterprise Single Sign-On` button on the login page */
{{#if (webver ">=2025.5.1")}}
{{#if (not sso_enabled)}} {{#if (not sso_enabled)}}
.vw-sso-login { .vw-sso-login {
@extend %vw-hide; @extend %vw-hide;
} }
{{/if}} {{/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}}
{{#unless passkeys_enabled}} {{#unless passkey_account_supported}}
/* Hide the `Log in with passkey` settings */ /* Hide the `Log in with passkey` settings */
app-user-layout app-password-settings app-webauthn-login-settings { app-user-layout app-password-settings app-webauthn-login-settings {
@extend %vw-hide; @extend %vw-hide;
} }
{{/unless}} {{/unless}}
{{#unless passkey_login_available}} {{#unless passkey_login_available}}
{{#if (webver ">=2025.5.1")}} /* Hide Log in with passkey on the login page */
.vw-passkey-login { .vw-passkey-login {
@extend %vw-hide; @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}}
{{/unless}} {{/unless}}
/* Hide the or text followed by the two buttons hidden above */ /* Hide the or text followed by the two buttons hidden above */
{{#if (webver ">=2025.5.1")}} {{#if (and (not passkey_login_available) (or (not sso_enabled) sso_only))}}
{{#if (or (not sso_enabled) sso_only)}}
.vw-or-text { .vw-or-text {
@extend %vw-hide; @extend %vw-hide;
} }
{{/if}} {{/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 */ /* Hide the `Other` button on the login page */
{{#if (or (not sso_enabled) sso_only)}} {{#if (and (not passkey_login_available) (or (not sso_enabled) sso_only))}}
.vw-other-login { .vw-other-login {
@extend %vw-hide; @extend %vw-hide;
} }
@ -157,17 +140,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 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 */ when it shouldn't, we'll keep this style in place for a couple of versions */
/* Hide the register link on the login screen */ /* 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"] { app-root a[routerlink="/signup"] {
@extend %vw-hide; @extend %vw-hide;
} }
{{/if}} {{/if}}
{{/if}}
{{#if remember_2fa_disabled}} {{#if remember_2fa_disabled}}
/* Hide checkbox to remember 2FA token for 30 days */ /* Hide checkbox to remember 2FA token for 30 days */
@ -223,6 +199,13 @@ auth-input-password > form > bit-form-field:nth-child(4) {
@extend %vw-hide; @extend %vw-hide;
} }
{{/unless}} {{/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 ****/ /**** End Dynamic Vaultwarden Changes ****/
/**** Include a special user stylesheet for custom changes ****/ /**** Include a special user stylesheet for custom changes ****/
{{#if load_user_scss}} {{#if load_user_scss}}

20
src/util.rs

@ -380,7 +380,7 @@ pub fn get_display_size(size: i64) -> String {
let mut unit_counter = 0; let mut unit_counter = 0;
loop { loop {
if size > 1024. { if size > 1024. && unit_counter < UNITS.len() - 1 {
size /= 1024.; size /= 1024.;
unit_counter += 1; unit_counter += 1;
} else { } else {
@ -522,8 +522,8 @@ pub fn format_datetime_http(dt: &DateTime<Local>) -> String {
expiry_time.to_rfc2822().replace("+0000", "GMT") expiry_time.to_rfc2822().replace("+0000", "GMT")
} }
pub fn parse_date(date: &str) -> NaiveDateTime { pub fn parse_date(date: &str) -> Option<NaiveDateTime> {
DateTime::parse_from_rfc3339(date).unwrap().naive_utc() DateTime::parse_from_rfc3339(date).ok().map(|dt| dt.naive_utc())
} }
/// Returns true or false if an email address is valid or not /// Returns true or false if an email address is valid or not
@ -943,15 +943,15 @@ mod tests {
use std::net::IpAddr; use std::net::IpAddr;
#[test] #[test]
#[ignore] #[ignore = "exhaustive IPv4 check is too slow for the regular test suite"]
fn test_ipv4_global() { fn test_ipv4_global() {
for a in 0..u8::MAX { for a in 0..u8::MAX {
println!("Iter: {}/255", a); println!("Iter: {a}/255");
for b in 0..u8::MAX { for b in 0..u8::MAX {
for c in 0..u8::MAX { for c in 0..u8::MAX {
for d in 0..u8::MAX { for d in 0..u8::MAX {
let ip = IpAddr::V4(std::net::Ipv4Addr::new(a, b, c, d)); 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 +959,15 @@ mod tests {
} }
#[test] #[test]
#[ignore] #[ignore = "random IPv6 check is too slow for the regular test suite"]
fn test_ipv6_global() { fn test_ipv6_global() {
use rand::Rng; use rand::RngExt;
std::thread::scope(|s| { std::thread::scope(|s| {
for t in 0..16 { for t in 0..16 {
let handle = s.spawn(move || { let _handle = s.spawn(move || {
let mut v = [0u8; 16]; let mut v = [0u8; 16];
let mut rng = rand::thread_rng(); let mut rng = rand::rng();
for i in 0..20 { for i in 0..20 {
println!("Thread {t} Iter: {i}/50"); println!("Thread {t} Iter: {i}/50");

2
tests/README.md

@ -26,7 +26,7 @@ VW_PASSKEY_TEST_DATABASE_URL='postgresql://passkey_test:synthetic-passkey-test@1
The PostgreSQL-only concurrency and interruption cases are marked `ignore` The PostgreSQL-only concurrency and interruption cases are marked `ignore`
because they need that instance. A default test run does not cover them. because they need that instance. A default test run does not cover them.
Before enabling passkeys for real vaults, separately test compatible clients and Before relying on passkeys for real vaults, separately test compatible clients and
authenticators: enrollment, fresh sign-in, PRF decryption of existing personal authenticators: enrollment, fresh sign-in, PRF decryption of existing personal
and shared items, master-password fallback, multiple passkeys, removal, and and shared items, master-password fallback, multiple passkeys, removal, and
password/KDF/account-key changes. For SSO-only installations, verify SSO is still password/KDF/account-key changes. For SSO-only installations, verify SSO is still

151
tests/passkey_unlock.rs

@ -101,10 +101,13 @@ struct Fixture {
postgres: Option<(String, String)>, postgres: Option<(String, String)>,
} }
impl Fixture { impl Fixture {
async fn new(enabled: bool) -> Self { async fn new() -> Self {
Self::with_policy(enabled, true).await Self::with_policy(true).await
} }
async fn with_policy(enabled: bool, sso_only: bool) -> Self { async fn with_policy(sso_only: bool) -> Self {
Self::with_verification_policy(sso_only, false).await
}
async fn with_verification_policy(sso_only: bool, verify_email: bool) -> Self {
static CRYPTO: std::sync::Once = std::sync::Once::new(); static CRYPTO: std::sync::Once = std::sync::Once::new();
CRYPTO.call_once(|| rustls::crypto::ring::default_provider().install_default().unwrap()); CRYPTO.call_once(|| rustls::crypto::ring::default_provider().install_default().unwrap());
let id = || uuid::Uuid::new_v4().to_string(); let id = || uuid::Uuid::new_v4().to_string();
@ -142,7 +145,6 @@ impl Fixture {
.env("LOGIN_RATELIMIT_MAX_BURST", "100") .env("LOGIN_RATELIMIT_MAX_BURST", "100")
.env("DATABASE_MAX_CONNS", "4") .env("DATABASE_MAX_CONNS", "4")
.env("DATABASE_MIN_CONNS", "1") .env("DATABASE_MIN_CONNS", "1")
.env("PASSKEYS_ENABLED", enabled.to_string())
.env("SSO_ENABLED", sso_only.to_string()) .env("SSO_ENABLED", sso_only.to_string())
.env("SSO_ONLY", sso_only.to_string()) .env("SSO_ONLY", sso_only.to_string())
.env("SSO_CLIENT_ID", "synthetic-client") .env("SSO_CLIENT_ID", "synthetic-client")
@ -150,6 +152,14 @@ impl Fixture {
.env("SSO_AUTHORITY", "http://127.0.0.1:9") .env("SSO_AUTHORITY", "http://127.0.0.1:9")
.stdout(Stdio::from(log.try_clone().unwrap())) .stdout(Stdio::from(log.try_clone().unwrap()))
.stderr(Stdio::from(log)); .stderr(Stdio::from(log));
if verify_email {
command
.env("SIGNUPS_VERIFY", "true")
.env("SMTP_HOST", "127.0.0.1")
.env("SMTP_PORT", "9")
.env("SMTP_SECURITY", "off")
.env("SMTP_FROM", "notifications@example.test");
}
for name in ["DYLD_LIBRARY_PATH", "LD_LIBRARY_PATH"] { for name in ["DYLD_LIBRARY_PATH", "LD_LIBRARY_PATH"] {
if let Some(value) = std::env::var_os(name) { if let Some(value) = std::env::var_os(name) {
command.env(name, value); command.env(name, value);
@ -294,7 +304,7 @@ impl Drop for Fixture {
#[tokio::test] #[tokio::test]
async fn passkey_enrollment_requires_step_up_and_never_adds_an_sso_bypass() { async fn passkey_enrollment_requires_step_up_and_never_adds_an_sso_bypass() {
let f = Fixture::new(true).await; let f = Fixture::new().await;
for proof in [json!({}), json!({"masterPasswordHash":"wrong"})] { for proof in [json!({}), json!({"masterPasswordHash":"wrong"})] {
assert_eq!(f.post("/api/webauthn/attestation-options", proof).await.status(), StatusCode::BAD_REQUEST); assert_eq!(f.post("/api/webauthn/attestation-options", proof).await.status(), StatusCode::BAD_REQUEST);
} }
@ -321,29 +331,16 @@ async fn passkey_enrollment_requires_step_up_and_never_adds_an_sso_bypass() {
} }
#[tokio::test] #[tokio::test]
async fn passkey_disabled_preserves_existing_sync_and_list_and_denies_enrollment() { async fn passkeys_are_available_without_opt_in_and_keep_master_password_unlock() {
let f = Fixture::new(false).await; let f = Fixture::with_policy(false).await;
// The shared keypair endpoint retains its ordinary behavior with the new
// feature disabled, including first initialization and serial replacement.
f.sql(&format!("UPDATE users SET private_key=NULL,public_key=NULL WHERE uuid='{}'", f.user)); f.sql(&format!("UPDATE users SET private_key=NULL,public_key=NULL WHERE uuid='{}'", f.user));
for private_key in [&keys().original_account_private_key, &keys().repaired_private_key] { let keypair = json!({"publicKey":keys().repaired_public_key,
assert_eq!( "encryptedPrivateKey":keys().original_account_private_key});
f.post( assert_eq!(f.post("/api/accounts/keys", keypair.clone()).await.status(), StatusCode::OK);
"/api/accounts/keys", assert_eq!(f.post("/api/accounts/keys", keypair).await.status(), StatusCode::BAD_REQUEST);
json!({"publicKey":keys().repaired_public_key, assert_eq!(
"encryptedPrivateKey":private_key}) f.post("/api/webauthn/attestation-options", json!({"masterPasswordHash":PASSWORD})).await.status(),
) StatusCode::OK
.await
.status(),
StatusCode::OK
);
}
assert!(
!f.post("/api/webauthn/attestation-options", json!({"masterPasswordHash":PASSWORD}))
.await
.status()
.is_success()
); );
assert_eq!(f.get("/api/webauthn").await["data"], json!([])); assert_eq!(f.get("/api/webauthn").await["data"], json!([]));
let sync = f.get("/api/sync").await; let sync = f.get("/api/sync").await;
@ -402,7 +399,7 @@ fn registration(options: &Value, origin: &str, uv: bool) -> Value {
#[tokio::test] #[tokio::test]
async fn passkey_registration_verifies_origin_uv_replay_and_complete_wrapped_keys() { async fn passkey_registration_verifies_origin_uv_replay_and_complete_wrapped_keys() {
let f = Fixture::new(true).await; let f = Fixture::new().await;
for (origin, uv) in [("http://unrelated.example", true), (ORIGIN, false)] { for (origin, uv) in [("http://unrelated.example", true), (ORIGIN, false)] {
let o = f.options().await; let o = f.options().await;
let r = f.post("/api/webauthn", registration(&o, origin, uv)).await; let r = f.post("/api/webauthn", registration(&o, origin, uv)).await;
@ -431,7 +428,7 @@ async fn passkey_registration_verifies_origin_uv_replay_and_complete_wrapped_key
#[tokio::test] #[tokio::test]
async fn passkey_multiple_options_removal_and_password_wrapping_are_consistent() { async fn passkey_multiple_options_removal_and_password_wrapping_are_consistent() {
let f = Fixture::new(true).await; let f = Fixture::new().await;
for _ in 0..2 { for _ in 0..2 {
let o = f.options().await; let o = f.options().await;
assert_eq!(f.post("/api/webauthn", registration(&o, ORIGIN, true)).await.status(), StatusCode::OK); assert_eq!(f.post("/api/webauthn", registration(&o, ORIGIN, true)).await.status(), StatusCode::OK);
@ -461,7 +458,7 @@ fn rotate_request(passkeys: &Value) -> Value {
#[tokio::test] #[tokio::test]
async fn passkey_rotation_validates_before_mutation_and_rewraps_all_credentials() { async fn passkey_rotation_validates_before_mutation_and_rewraps_all_credentials() {
let f = Fixture::new(true).await; let f = Fixture::new().await;
f.sql(&format!("UPDATE users SET public_key='synthetic-public-key' WHERE uuid='{}'", f.user)); f.sql(&format!("UPDATE users SET public_key='synthetic-public-key' WHERE uuid='{}'", f.user));
let o = f.options().await; let o = f.options().await;
assert_eq!(f.post("/api/webauthn", registration(&o, ORIGIN, true)).await.status(), StatusCode::OK); assert_eq!(f.post("/api/webauthn", registration(&o, ORIGIN, true)).await.status(), StatusCode::OK);
@ -515,7 +512,7 @@ async fn passkey_rotation_validates_before_mutation_and_rewraps_all_credentials(
#[tokio::test] #[tokio::test]
async fn passkey_challenges_are_fresh_session_bound_and_consumed_exactly_once() { async fn passkey_challenges_are_fresh_session_bound_and_consumed_exactly_once() {
let f = Fixture::new(true).await; let f = Fixture::new().await;
for change in [ for change in [
"json_set(data,'$.createdAt',0)", "json_set(data,'$.createdAt',0)",
"json_set(data,'$.deviceUuid','another-device')", "json_set(data,'$.deviceUuid','another-device')",
@ -538,7 +535,7 @@ async fn passkey_challenges_are_fresh_session_bound_and_consumed_exactly_once()
#[tokio::test] #[tokio::test]
async fn passkey_prf_update_verifies_assertion_and_rejects_replay() { async fn passkey_prf_update_verifies_assertion_and_rejects_replay() {
let f = Fixture::new(true).await; let f = Fixture::new().await;
let o = f.options().await; let o = f.options().await;
let (registration, key) = registration_with_key(&o, ORIGIN, true); let (registration, key) = registration_with_key(&o, ORIGIN, true);
let credential = registration["deviceResponse"]["rawId"].clone(); let credential = registration["deviceResponse"]["rawId"].clone();
@ -571,7 +568,7 @@ async fn passkey_prf_update_verifies_assertion_and_rejects_replay() {
#[tokio::test] #[tokio::test]
async fn passkey_credentials_cannot_be_read_or_removed_by_another_account() { async fn passkey_credentials_cannot_be_read_or_removed_by_another_account() {
let f = Fixture::new(true).await; let f = Fixture::new().await;
let o = f.options().await; let o = f.options().await;
assert_eq!(f.post("/api/webauthn", registration(&o, ORIGIN, true)).await.status(), StatusCode::OK); assert_eq!(f.post("/api/webauthn", registration(&o, ORIGIN, true)).await.status(), StatusCode::OK);
let list = f.get("/api/webauthn").await; let list = f.get("/api/webauthn").await;
@ -636,7 +633,7 @@ fn local_postgres_database() -> Option<(String, String)> {
#[tokio::test] #[tokio::test]
async fn passkey_rewrap_failure_rolls_back_account_keys_and_can_retry() { async fn passkey_rewrap_failure_rolls_back_account_keys_and_can_retry() {
let f = Fixture::new(true).await; let f = Fixture::new().await;
f.sql(&format!("UPDATE users SET public_key='synthetic-public-key' WHERE uuid='{}'", f.user)); f.sql(&format!("UPDATE users SET public_key='synthetic-public-key' WHERE uuid='{}'", f.user));
let o = f.options().await; let o = f.options().await;
assert_eq!(f.post("/api/webauthn", registration(&o, ORIGIN, true)).await.status(), StatusCode::OK); assert_eq!(f.post("/api/webauthn", registration(&o, ORIGIN, true)).await.status(), StatusCode::OK);
@ -736,8 +733,8 @@ impl Fixture {
#[tokio::test] #[tokio::test]
#[ignore = "requires a disposable PostgreSQL instance; see tests/README.md"] #[ignore = "requires a disposable PostgreSQL instance; see tests/README.md"]
async fn passkey_postgres_rotation_serializes_deletion_and_stale_rotation() { async fn passkey_postgres_rotation_serializes_deletion_and_stale_rotation() {
for competing_action in ["delete", "rotate", "repair"] { for competing_action in ["delete", "rotate"] {
let f = Fixture::new(true).await; let f = Fixture::new().await;
let (data, id) = f.paused_rotation_data().await; let (data, id) = f.paused_rotation_data().await;
let mut pause = PausedRotation::new(&f); let mut pause = PausedRotation::new(&f);
let first = f.start_rotation(&data); let first = f.start_rotation(&data);
@ -749,11 +746,6 @@ async fn passkey_postgres_rotation_serializes_deletion_and_stale_rotation() {
.bearer_auth(&f.token) .bearer_auth(&f.token)
.json(&json!({"masterPasswordHash":PASSWORD})); .json(&json!({"masterPasswordHash":PASSWORD}));
tokio::spawn(async move { request.send().await }) tokio::spawn(async move { request.send().await })
} else if competing_action == "repair" {
let request = f.client.post(format!("{}/api/accounts/keys", f.url)).bearer_auth(&f.token).json(
&json!({"publicKey":keys().repaired_public_key,"encryptedPrivateKey":keys().repaired_private_key}),
);
tokio::spawn(async move { request.send().await })
} else { } else {
f.start_rotation(&data) f.start_rotation(&data)
}; };
@ -779,7 +771,7 @@ async fn passkey_postgres_rotation_serializes_deletion_and_stale_rotation() {
#[tokio::test] #[tokio::test]
#[ignore = "requires a disposable PostgreSQL instance; see tests/README.md"] #[ignore = "requires a disposable PostgreSQL instance; see tests/README.md"]
async fn passkey_postgres_cancelled_statement_rolls_back_and_retries() { async fn passkey_postgres_cancelled_statement_rolls_back_and_retries() {
let f = Fixture::new(true).await; let f = Fixture::new().await;
let (data, _) = f.paused_rotation_data().await; let (data, _) = f.paused_rotation_data().await;
let mut pause = PausedRotation::new(&f); let mut pause = PausedRotation::new(&f);
let request = f.start_rotation(&data); let request = f.start_rotation(&data);
@ -797,7 +789,7 @@ async fn passkey_postgres_cancelled_statement_rolls_back_and_retries() {
#[tokio::test] #[tokio::test]
#[ignore = "requires a disposable PostgreSQL instance; see tests/README.md"] #[ignore = "requires a disposable PostgreSQL instance; see tests/README.md"]
async fn passkey_postgres_server_interruption_rolls_back_vault_and_keys() { async fn passkey_postgres_server_interruption_rolls_back_vault_and_keys() {
let mut f = Fixture::new(true).await; let mut f = Fixture::new().await;
let (data, _) = f.paused_rotation_data().await; let (data, _) = f.paused_rotation_data().await;
let mut pause = PausedRotation::new(&f); let mut pause = PausedRotation::new(&f);
let request = f.start_rotation(&data); let request = f.start_rotation(&data);
@ -820,7 +812,7 @@ async fn passkey_postgres_server_interruption_rolls_back_vault_and_keys() {
#[tokio::test] #[tokio::test]
async fn passkey_native_registration_response_and_options_match_bitwarden() { async fn passkey_native_registration_response_and_options_match_bitwarden() {
let f = Fixture::new(true).await; let f = Fixture::new().await;
let options = f.options().await; let options = f.options().await;
assert!(options["options"]["extensions"]["hmacCreateSecret"].is_null()); assert!(options["options"]["extensions"]["hmacCreateSecret"].is_null());
let mut request = registration(&options, ORIGIN, true); let mut request = registration(&options, ORIGIN, true);
@ -850,7 +842,7 @@ async fn passkey_native_registration_response_and_options_match_bitwarden() {
#[tokio::test] #[tokio::test]
async fn passkey_rejects_malformed_wrapped_keys_before_advertising_them() { async fn passkey_rejects_malformed_wrapped_keys_before_advertising_them() {
let f = Fixture::new(true).await; let f = Fixture::new().await;
for field in ["encryptedUserKey", "encryptedPublicKey", "encryptedPrivateKey"] { for field in ["encryptedUserKey", "encryptedPublicKey", "encryptedPrivateKey"] {
for invalid in [ for invalid in [
"plain text".to_owned(), "plain text".to_owned(),
@ -870,7 +862,7 @@ async fn passkey_rejects_malformed_wrapped_keys_before_advertising_them() {
#[tokio::test] #[tokio::test]
async fn passkey_excludes_registered_credentials_and_limits_enrollment_to_five() { async fn passkey_excludes_registered_credentials_and_limits_enrollment_to_five() {
let f = Fixture::new(true).await; let f = Fixture::new().await;
let mut ids = Vec::new(); let mut ids = Vec::new();
for count in 0..5 { for count in 0..5 {
let options = f.options().await; let options = f.options().await;
@ -891,8 +883,8 @@ async fn passkey_excludes_registered_credentials_and_limits_enrollment_to_five()
} }
#[tokio::test] #[tokio::test]
async fn passkey_survives_account_keypair_repair_and_remains_in_rotation() { async fn passkey_rejects_keypair_replacement_and_remains_in_rotation() {
let f = Fixture::new(true).await; let f = Fixture::new().await;
f.sql(&format!("UPDATE users SET private_key=NULL,public_key=NULL WHERE uuid='{}'", f.user)); f.sql(&format!("UPDATE users SET private_key=NULL,public_key=NULL WHERE uuid='{}'", f.user));
assert_eq!( assert_eq!(
f.post( f.post(
@ -908,8 +900,8 @@ async fn passkey_survives_account_keypair_repair_and_remains_in_rotation() {
assert_eq!(f.post("/api/webauthn", registration(&options, ORIGIN, true)).await.status(), StatusCode::OK); assert_eq!(f.post("/api/webauthn", registration(&options, ORIGIN, true)).await.status(), StatusCode::OK);
let before = f.get("/api/sync").await["userDecryption"].clone(); let before = f.get("/api/sync").await["userDecryption"].clone();
let credential = f.get("/api/webauthn").await["data"][0].clone(); let credential = f.get("/api/webauthn").await["data"][0].clone();
// Rewrapping the same account asymmetric pair changes ciphertext, but not // Upstream rejects replacing an existing account keypair. The rejection
// that pair or the user key. It must not hide an unchanged PRF keyset. // must preserve its existing master-password and passkey unlock options.
let response = f let response = f
.post( .post(
"/api/accounts/keys", "/api/accounts/keys",
@ -917,7 +909,7 @@ async fn passkey_survives_account_keypair_repair_and_remains_in_rotation() {
"encryptedPrivateKey":keys().repaired_private_key}), "encryptedPrivateKey":keys().repaired_private_key}),
) )
.await; .await;
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::BAD_REQUEST);
assert!( assert!(
f.get("/api/sync").await["userDecryption"] == before, f.get("/api/sync").await["userDecryption"] == before,
"keypair repair must preserve both native unlock options" "keypair repair must preserve both native unlock options"
@ -982,7 +974,7 @@ fn native_assertion(
#[tokio::test] #[tokio::test]
async fn native_passkey_login_uses_one_challenge_and_returns_prf_unlock() { async fn native_passkey_login_uses_one_challenge_and_returns_prf_unlock() {
let f = Fixture::with_policy(true, false).await; let f = Fixture::with_policy(false).await;
let registration_options = f.options().await; let registration_options = f.options().await;
let (registration, key) = registration_with_key(&registration_options, ORIGIN, true); let (registration, key) = registration_with_key(&registration_options, ORIGIN, true);
let credential = registration["deviceResponse"]["rawId"].as_str().unwrap().to_owned(); let credential = registration["deviceResponse"]["rawId"].as_str().unwrap().to_owned();
@ -1028,7 +1020,7 @@ async fn native_passkey_login_uses_one_challenge_and_returns_prf_unlock() {
#[tokio::test] #[tokio::test]
async fn native_passkey_login_rejects_wrong_handle_origin_and_uv() { async fn native_passkey_login_rejects_wrong_handle_origin_and_uv() {
let f = Fixture::with_policy(true, false).await; let f = Fixture::with_policy(false).await;
let registration_options = f.options().await; let registration_options = f.options().await;
let (registration, key) = registration_with_key(&registration_options, ORIGIN, true); let (registration, key) = registration_with_key(&registration_options, ORIGIN, true);
let credential = registration["deviceResponse"]["rawId"].as_str().unwrap().to_owned(); let credential = registration["deviceResponse"]["rawId"].as_str().unwrap().to_owned();
@ -1071,7 +1063,7 @@ async fn native_passkey_login_rejects_wrong_handle_origin_and_uv() {
#[tokio::test] #[tokio::test]
async fn native_passkey_without_prf_keeps_master_password_unlock_and_refresh() { async fn native_passkey_without_prf_keeps_master_password_unlock_and_refresh() {
let f = Fixture::with_policy(true, false).await; let f = Fixture::with_policy(false).await;
let registration_options = f.options().await; let registration_options = f.options().await;
let (mut registration, key) = registration_with_key(&registration_options, ORIGIN, true); let (mut registration, key) = registration_with_key(&registration_options, ORIGIN, true);
let credential = registration["deviceResponse"]["rawId"].as_str().unwrap().to_owned(); let credential = registration["deviceResponse"]["rawId"].as_str().unwrap().to_owned();
@ -1125,3 +1117,54 @@ async fn native_passkey_without_prf_keeps_master_password_unlock_and_refresh() {
.unwrap(); .unwrap();
assert!(refresh["access_token"].as_str().is_some_and(|v| !v.is_empty())); assert!(refresh["access_token"].as_str().is_some_and(|v| !v.is_empty()));
} }
#[tokio::test]
async fn native_passkey_login_enforces_configured_email_verification() {
let f = Fixture::with_verification_policy(false, true).await;
let registration_options = f.options().await;
let (registration, key) = registration_with_key(&registration_options, ORIGIN, true);
let credential = registration["deviceResponse"]["rawId"].as_str().unwrap().to_owned();
assert_eq!(f.post("/api/webauthn", registration).await.status(), StatusCode::OK);
for verified in [false, true] {
if verified {
f.sql("UPDATE users SET verified_at=CURRENT_TIMESTAMP");
}
let options: Value = f
.client
.get(format!("{}/identity/accounts/webauthn/assertion-options", f.url))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let response = native_assertion(&options, &credential, &key, &f.user, ORIGIN, true).to_string();
let result = f
.client
.post(format!("{}/identity/connect/token", f.url))
.form(&[
("grant_type", "webauthn"),
("scope", "api offline_access"),
("client_id", "web"),
("token", options["token"].as_str().unwrap()),
("deviceResponse", response.as_str()),
("deviceIdentifier", f.device.as_str()),
("deviceName", "Synthetic browser"),
("deviceType", "14"),
])
.send()
.await
.unwrap();
assert_eq!(
result.status(),
if verified {
StatusCode::OK
} else {
StatusCode::BAD_REQUEST
}
);
let body: Value = result.json().await.unwrap();
assert_eq!(body.get("access_token").is_some(), verified);
assert_eq!(body.get("refresh_token").is_some(), verified);
}
}

Loading…
Cancel
Save