diff --git a/.dockerignore b/.dockerignore index d6ac6b9b..a9a358a3 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,7 +1,7 @@ -# Ignore everything +// Ignore everything * -# Allow what is needed +// Allow what is needed !.git !docker/healthcheck.sh !docker/start.sh diff --git a/.env.template b/.env.template index 7484053f..03990820 100644 --- a/.env.template +++ b/.env.template @@ -50,11 +50,10 @@ ######################### ## Database URL -## When using SQLite, this should use the sqlite:// scheme followed by the path -## to the DB file. It defaults to sqlite://%DATA_FOLDER%/db.sqlite3. -## Bare paths without the sqlite:// scheme are supported for backwards compatibility, -## but only if the database file already exists. -# DATABASE_URL=sqlite://data/db.sqlite3 +## When using SQLite, this is the path to the DB file, and it defaults to +## %DATA_FOLDER%/db.sqlite3. If DATA_FOLDER is set to an external location, this +## must be set to a local sqlite3 file path. +# DATABASE_URL=data/db.sqlite3 ## When using MySQL, specify an appropriate connection URI. ## Details: https://docs.diesel.rs/2.1.x/diesel/mysql/struct.MysqlConnection.html # DATABASE_URL=mysql://user:password@host[:port]/database_name @@ -78,7 +77,7 @@ # DB_CONNECTION_RETRIES=15 ## Database timeout -## Timeout in seconds when acquiring database connection +## Timeout when acquiring database connection # DATABASE_TIMEOUT=30 ## Database idle timeout @@ -173,14 +172,14 @@ ## Defaults to daily. Set blank to disable this job. Also without EVENTS_DAYS_RETAIN set, this job will not start. # EVENT_CLEANUP_SCHEDULE="0 10 0 * * *" ## Number of days to retain events stored in the database. -## If unset (the default) or 0, events are kept indefinitely and the scheduled job is disabled! +## If unset (the default), events are kept indefinitely and the scheduled job is disabled! # EVENTS_DAYS_RETAIN= ## ## Cron schedule of the job that cleans old auth requests from the auth request. ## Defaults to every minute. Set blank to disable this job. # AUTH_REQUEST_PURGE_SCHEDULE="30 * * * * *" ## -## Cron schedule of the job that cleans expired Duo contexts from the database. Does nothing if Duo MFA is disabled. +## 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. ## Defaults to every minute. Set blank to disable this job. # DUO_CONTEXT_PURGE_SCHEDULE="30 * * * * *" # @@ -233,7 +232,7 @@ # USER_SEND_LIMIT= ## Number of days to wait before auto-deleting a trashed item. -## If unset (the default) or 0, trashed items are not auto-deleted. +## If unset (the default), trashed items are not auto-deleted. ## This setting applies globally, so make sure to inform all users of any changes to this setting. # TRASH_AUTO_DELETE_DAYS= @@ -316,14 +315,6 @@ ## unauthenticated access to potentially sensitive data. # SHOW_PASSWORD_HINT=false -######################### -### Client settings ### -######################### - -## Control whether clients onboarding interstitials are suppressed -## (post-login welcome dialogs, extension install prompts, setup extension redirects, and premium upsell modals) -# CLIENT_SUPPRESS_ONBOARDING=false - ######################### ### Advanced settings ### ######################### @@ -332,16 +323,6 @@ ## Set to the string "none" (without quotes), to disable any headers and just use the remote IP # IP_HEADER=X-Real-IP -## Which addresses the header above is accepted from, defaults to "local". -## Anyone able to reach Vaultwarden can set the header, and the client IP is used for the login and -## admin rate limits, so it is only trusted when the request comes from a proxy listed here. -## "local" accepts it from any non global address, which covers a reverse proxy running on the same -## host or container network. Use "all" to accept it from anywhere, or list the addresses of your -## proxy as IPs and CIDR ranges if it connects from a public address. -## For a list header like X-Forwarded-For, the client IP is the rightmost address that isn't one of -## these proxies, so with several proxies in a row (a CDN in front of nginx, for example) list all of them. -# IP_HEADER_TRUSTED_PROXIES=local - ## Icon service ## The predefined icon services are: internal, bitwarden, duckduckgo, google. ## To specify a custom icon service, set a URL template with exactly one instance of `{}`, @@ -386,34 +367,26 @@ # HTTP_REQUEST_BLOCK_NON_GLOBAL_IPS=true ## Client Settings -## Server-wide Fill Assist rules feed base directory URL, not a JSON file. -## Defaults to https://fillassist.bitwarden.com. -## Must be a valid HTTPS URL without credentials, query parameters, fragments, or surrounding whitespace. -# FILL_ASSIST_RULES_URL=https://fillassist.bitwarden.com - ## Enable experimental feature flags for clients. ## This is a comma-separated list of flags, e.g. "flag1,flag2,flag3". ## Note that clients cache the /api/config endpoint for about 1 hour and it could take some time before they are enabled or disabled! ## ## The following flags are available: -## - "fill-assist-targeting-rules": Enable Fill Assist targeting rules in the browser extension. (Browser >= 2026.6.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) +## - "ssh-agent": Enable SSH agent support on Desktop. (Desktop >= 2024.12.0) ## - "ssh-agent-v2": Enable newer SSH agent support. (Desktop >= 2026.2.1) -## - "windows-desktop-autotype": Enable the autotype feature preview on Windows. (Desktop >= 2025.8.0) -## - "windows-desktop-autotype-ga": Enable the new, still in development, autotype implementation on Windows. (Desktop >= 2026.9.1) -## Only enable one of the two autotype flags, the clients disable autotype completely when both are enabled. -## - "pm-30529-webauthn-related-origins": Allow passkeys stored in the vault to be used on related origins listed in a site's /.well-known/webauthn file (Browser >= 2026.2.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) -## - "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) -## - "windows-native-credential-sync": Register the desktop app as a passkey provider in Windows, so passkeys in the vault can be used by other apps and browsers. -## Requires the Microsoft Store (appx) build of the desktop app. Stable releases up to and including 2026.9.1 lack the required plugin registration, so it has no effect there yet. -## - "pm-27278-v2-password-registration": Register new accounts with v2 encryption. (Web >= 2026.6.4, Desktop/Browser/CLI >= 2026.7.0, Android >= 2026.4.1, iOS >= 2026.5.1) -## - "enable-account-encryption-v2-jit-password-registration": Give SSO users who set their master password on their first login v2 encryption. (Web >= 2026.1.1, Desktop/Browser/CLI >= 2026.1.0, Android >= 2026.4.1, iOS >= 2026.6.0) -## These two flags can't be undone: a v2 account stays v2, and it can only log in from clients that are 2026.10.1 or newer (2026.10.2 for the desktop app), so the versions above are only enough to create one. +## - "ssh-key-vault-item": Enable the creation and use of SSH key vault items. (Clients >= 2024.12.0) +## - "pm-25373-windows-biometrics-v2": Enable the new implementation of biometrics on Windows. (Desktop >= 2025.11.0) +## - "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": +## - "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) # EXPERIMENTAL_CLIENT_FEATURE_FLAGS= ## Require new device emails. When a user logs in an email is required to be sent. @@ -474,7 +447,7 @@ ## Allowed connect-src (Know the risks!) ## https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/connect-src -## Allows other domains for URLs which can be loaded using script interfaces like the Forwarded email alias feature +## Allows other domains to URLs which can be loaded using script interfaces like the Forwarded email alias feature ## This adds the configured value to the 'Content-Security-Policy' headers 'connect-src' value. ## Multiple values must be separated with a whitespace. And only HTTPS values are allowed. ## Example: "https://my-addy-io.domain.tld https://my-simplelogin.domain.tld" @@ -486,13 +459,6 @@ ## Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2. # LOGIN_RATELIMIT_MAX_BURST=10 -## Number of seconds, on average, between requests from the same IP address to one of the rate limited -## unauthenticated endpoints, like the password hint, the account recovery mails or accessing a Send. -# UNAUTHENTICATED_RATELIMIT_SECONDS=60 -## Allow a burst of requests of up to this size, while maintaining the average indicated by `UNAUTHENTICATED_RATELIMIT_SECONDS`. -## This budget is shared between all of those endpoints, so it is more lenient than the login one. -# UNAUTHENTICATED_RATELIMIT_MAX_BURST=50 - ## BETA FEATURE: Groups ## Controls whether group support is enabled for organizations ## This setting applies to organizations. @@ -500,15 +466,6 @@ ## KNOW WHAT YOU ARE DOING! # ORG_GROUPS_ENABLED=false -## EXPERIMENTAL FEATURE: Custom roles -## Enables the Custom member role with granular permissions for organizations. -## This is still being developed: only the permissions this version enforces can be granted. -## This is enforced by the server and is unrelated to EXPERIMENTAL_CLIENT_FEATURE_FLAGS. -## Disabling it again keeps the stored Custom roles and permissions, but they grant nothing until it is re-enabled. -## Disabled by default because this is an experimental feature! -## KNOW WHAT YOU ARE DOING! -# EXPERIMENTAL_CUSTOM_ROLES=false - ## Increase secure note size limit (Know the risks!) ## Sets the secure note size limit to 100_000 instead of the default 10_000. ## WARNING: This could cause issues with clients. Also exports will not work on Bitwarden servers! @@ -536,9 +493,6 @@ ## Prevent users from logging in directly without going through SSO # SSO_ONLY=false -## Allow SSO flow to create account. You probably want to disable it when using a public provider. -# SSO_SIGNUPS_ALLOWED=true - ## On SSO Signup if a user with a matching email already exists make the association # SSO_SIGNUPS_MATCH_EMAIL=true @@ -601,6 +555,11 @@ # DUO_HOST= ## After that, you should be able to follow the rest of the guide linked above, ## ignoring the fields that ask for the values that you already configured beforehand. +## +## If you want to attempt to use Duo's 'Traditional Prompt' (deprecated, iframe based) set DUO_USE_IFRAME to 'true'. +## Duo no longer supports this, but it still works for some integrations. +## If you aren't sure, leave this alone. +# DUO_USE_IFRAME=false ## Email 2FA settings ## Email token size @@ -626,9 +585,6 @@ ## Note that the checkbox would still be present, but ignored. # DISABLE_2FA_REMEMBER=false ## -## Number of days before a remembered 2FA login expires (min: 1, max: 90). -# TWO_FACTOR_REMEMBER_DAYS=30 -## ## Authenticator Settings ## Disable authenticator time drifted codes to be valid. ## TOTP codes of the previous and next 30 seconds will be invalid @@ -637,7 +593,7 @@ ## we allow by default the TOTP code which was valid one step back and one in the future. ## This can however allow attackers to be a bit more lucky with their attempts because there are 3 valid codes. ## You can disable this, so that only the current TOTP Code is allowed. -## Keep in mind that when a server drifts out of time, valid codes could be marked as invalid. +## Keep in mind that when a sever drifts out of time, valid codes could be marked as invalid. ## In any case, if a code has been used it can not be used again, also codes which predates it will be invalid. # AUTHENTICATOR_DISABLE_TIME_DRIFT=false diff --git a/.gitattributes b/.gitattributes index 4d7cadd3..b33a6211 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,3 @@ # Ignore vendored scripts in GitHub stats src/static/scripts/* linguist-vendored + diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 673137cf..6269e595 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -62,7 +62,7 @@ jobs: # Checkout the repo - name: "Checkout" - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false fetch-depth: 0 @@ -113,7 +113,7 @@ jobs: # Enable Rust Caching - name: Rust Caching - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: # Use a custom prefix-key to force a fresh start. This is sometimes needed with bigger changes. # Like changing the build host from Ubuntu 20.04 to 22.04 for example. diff --git a/.github/workflows/check-templates.yml b/.github/workflows/check-templates.yml index da4d90fd..57b53bf4 100644 --- a/.github/workflows/check-templates.yml +++ b/.github/workflows/check-templates.yml @@ -20,7 +20,7 @@ jobs: steps: # Checkout the repo - name: "Checkout" - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false # End Checkout the repo diff --git a/.github/workflows/hadolint.yml b/.github/workflows/hadolint.yml index c4b83aa6..2b476904 100644 --- a/.github/workflows/hadolint.yml +++ b/.github/workflows/hadolint.yml @@ -20,7 +20,7 @@ jobs: steps: # Start Docker Buildx - name: Setup Docker Buildx - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 # https://github.com/moby/buildkit/issues/3969 # Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills with: @@ -30,25 +30,24 @@ jobs: driver-opts: | network=host + # Download hadolint - https://github.com/hadolint/hadolint/releases + - name: Download hadolint + run: | + sudo curl -L https://github.com/hadolint/hadolint/releases/download/v${HADOLINT_VERSION}/hadolint-$(uname -s)-$(uname -m) -o /usr/local/bin/hadolint && \ + sudo chmod +x /usr/local/bin/hadolint + env: + HADOLINT_VERSION: 2.14.0 + # End Download hadolint # Checkout the repo - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false # End Checkout the repo # Test Dockerfiles with hadolint - # Uses the Docker-based action (hadolint pre-bundled in ghcr.io/hadolint/hadolint:v2.14.0-debian) - # so no binary is downloaded at runtime. Pinned by commit SHA for supply-chain safety. - - name: Run hadolint on Dockerfile.debian - uses: hadolint/hadolint-action@06be81baf89a55ffd0e24b8f04a4185738dd3387 # v3.5.0 - with: - dockerfile: docker/Dockerfile.debian - - - name: Run hadolint on Dockerfile.alpine - uses: hadolint/hadolint-action@06be81baf89a55ffd0e24b8f04a4185738dd3387 # v3.5.0 - with: - dockerfile: docker/Dockerfile.alpine + - name: Run hadolint + run: hadolint docker/Dockerfile.{debian,alpine} # End Test Dockerfiles with hadolint # Test Dockerfiles with docker build checks diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6f80509d..777997ad 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,9 +38,7 @@ jobs: docker-build: name: Build Vaultwarden containers if: ${{ github.repository == 'dani-garcia/vaultwarden' }} - environment: - name: release - deployment: false + environment: release permissions: packages: write # Needed to upload packages and artifacts contents: read @@ -58,13 +56,13 @@ jobs: steps: - name: Initialize QEMU binfmt support - uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 + uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 with: platforms: "arm64,arm" # Start Docker Buildx - name: Setup Docker Buildx - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 # https://github.com/moby/buildkit/issues/3969 # Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills with: @@ -77,7 +75,7 @@ jobs: # Checkout the repo - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 # We need fetch-depth of 0 so we also get all the tag metadata with: persist-credentials: false @@ -106,7 +104,7 @@ jobs: # Login to Docker Hub - name: Login to Docker Hub - uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -121,7 +119,7 @@ jobs: # Login to GitHub Container Registry - name: Login to GitHub Container Registry - uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -137,7 +135,7 @@ jobs: # Login to Quay.io - name: Login to Quay.io - uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: quay.io username: ${{ secrets.QUAY_USERNAME }} @@ -185,7 +183,7 @@ jobs: - name: Bake ${{ matrix.base_image }} containers id: bake_vw - uses: docker/bake-action@59cb1203cd42bcefd40a1b6d73b589e48c86040e # v7.5.0 + uses: docker/bake-action@a66e1c87e2eca0503c343edf1d208c716d54b8a8 # v7.1.0 env: BASE_TAGS: "${{ steps.determine-version.outputs.BASE_TAGS }}" SOURCE_COMMIT: "${{ env.SOURCE_COMMIT }}" @@ -222,7 +220,7 @@ jobs: touch "${RUNNER_TEMP}/digests/${digest#sha256:}" - name: Upload digest - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: digests-${{ env.NORMALIZED_ARCH }}-${{ matrix.base_image }} path: ${{ runner.temp }}/digests/* @@ -237,23 +235,21 @@ jobs: # Upload artifacts to Github Actions and Attest the binaries - name: Attest binaries - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 with: subject-path: vaultwarden-${{ env.NORMALIZED_ARCH }} - name: Upload binaries as artifacts - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-${{ env.NORMALIZED_ARCH }}-${{ matrix.base_image }} path: vaultwarden-${{ env.NORMALIZED_ARCH }} merge-manifests: name: Merge manifests - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest needs: docker-build - environment: - name: release - deployment: false + environment: release permissions: packages: write # Needed to upload packages and artifacts attestations: write # Needed to generate an artifact attestation for a build @@ -264,7 +260,7 @@ jobs: steps: - name: Download digests - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: ${{ runner.temp }}/digests pattern: digests-*-${{ matrix.base_image }} @@ -272,7 +268,7 @@ jobs: # Login to Docker Hub - name: Login to Docker Hub - uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -287,7 +283,7 @@ jobs: # Login to GitHub Container Registry - name: Login to GitHub Container Registry - uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -303,7 +299,7 @@ jobs: # Login to Quay.io - name: Login to Quay.io - uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: quay.io username: ${{ secrets.QUAY_USERNAME }} @@ -365,7 +361,7 @@ jobs: # Attest container images - name: Attest - docker.io - ${{ matrix.base_image }} if: ${{ vars.DOCKERHUB_REPO != '' && env.DIGEST_SHA != ''}} - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 with: subject-name: ${{ vars.DOCKERHUB_REPO }} subject-digest: ${{ env.DIGEST_SHA }} @@ -373,7 +369,7 @@ jobs: - name: Attest - ghcr.io - ${{ matrix.base_image }} if: ${{ vars.GHCR_REPO != '' && env.DIGEST_SHA != ''}} - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 with: subject-name: ${{ vars.GHCR_REPO }} subject-digest: ${{ env.DIGEST_SHA }} @@ -381,7 +377,7 @@ jobs: - name: Attest - quay.io - ${{ matrix.base_image }} if: ${{ vars.QUAY_REPO != '' && env.DIGEST_SHA != ''}} - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 with: subject-name: ${{ vars.QUAY_REPO }} subject-digest: ${{ env.DIGEST_SHA }} diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index 7ffbe632..c9e02cf9 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -33,12 +33,12 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Run Trivy vulnerability scanner - uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 env: TRIVY_DB_REPOSITORY: docker.io/aquasec/trivy-db:2,public.ecr.aws/aquasecurity/trivy-db:2,ghcr.io/aquasecurity/trivy-db:2 TRIVY_JAVA_DB_REPOSITORY: docker.io/aquasec/trivy-java-db:1,public.ecr.aws/aquasecurity/trivy-java-db:1,ghcr.io/aquasecurity/trivy-java-db:1 @@ -50,6 +50,6 @@ jobs: severity: CRITICAL,HIGH - name: Upload Trivy scan results to GitHub Security tab - uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 + uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 with: sarif_file: 'trivy-results.sarif' diff --git a/.github/workflows/typos.yml b/.github/workflows/typos.yml index f7d1f92c..f68ef29d 100644 --- a/.github/workflows/typos.yml +++ b/.github/workflows/typos.yml @@ -16,11 +16,11 @@ jobs: steps: # Checkout the repo - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false # End Checkout the repo # When this version is updated, do not forget to update this in `.pre-commit-config.yaml` too - name: Spell Check Repo - uses: crate-ci/typos@7f9d0819ca1c008ade73f4b41fa9d258d48f605b # v1.51.1 + uses: crate-ci/typos@02ea592e44b3a53c302f697cddca7641cd051c3d # v1.45.0 diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index 7372759b..4bd40db3 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -14,17 +14,17 @@ on: jobs: zizmor: name: Run zizmor - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest permissions: security-events: write # To write the security report steps: - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Run zizmor - uses: zizmorcore/zizmor-action@53290badfa369cb0bf7ad16342cb62c2fa47ac7b # v0.8.0 + uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2 with: # intentionally not scanning the entire repository, # since it contains integration tests. diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b6352bbb..0b6ad451 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,59 +1,58 @@ --- repos: - - repo: https://github.com/pre-commit/pre-commit-hooks +- repo: https://github.com/pre-commit/pre-commit-hooks rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # v6.0.0 hooks: - - id: check-yaml - - id: check-json - - id: check-toml - - id: mixed-line-ending - args: [ "--fix=no" ] - - id: end-of-file-fixer - exclude: "(.*js$|.*css$)" - - id: check-case-conflict - - id: check-merge-conflict - - id: detect-private-key - - id: check-symlinks - - id: forbid-submodules - - # When this version is updated, do not forget to update this in `.github/workflows/typos.yaml` too - - repo: https://github.com/crate-ci/typos - rev: 7f9d0819ca1c008ade73f4b41fa9d258d48f605b # v1.51.1 + - id: check-yaml + - id: check-json + - id: check-toml + - id: mixed-line-ending + args: ["--fix=no"] + - id: end-of-file-fixer + exclude: "(.*js$|.*css$)" + - id: check-case-conflict + - id: check-merge-conflict + - id: detect-private-key + - id: check-symlinks + - id: forbid-submodules +- repo: local hooks: - - id: typos - always_run: true - - - repo: local - hooks: - - id: fmt - name: fmt - description: Format files with cargo fmt. - entry: cargo fmt - language: system - always_run: true - pass_filenames: false - args: [ "--", "--check" ] - - id: cargo-test - name: cargo test - description: Test the package for errors. - entry: cargo test - language: system - args: [ "--features", "sqlite,mysql,postgresql", "--" ] - types_or: [ rust, toml ] # Cargo.lock matches toml type which is intended - pass_filenames: false - - id: cargo-clippy - name: cargo clippy - description: Lint Rust sources - entry: cargo clippy - language: system - args: [ "--features", "sqlite,mysql,postgresql", "--", "-D", "warnings" ] - types_or: [ rust, toml ] # Cargo.lock matches toml type which is intended - pass_filenames: false - - id: check-docker-templates - name: check docker templates - description: Check if the Docker templates are updated - language: system - entry: sh - args: - - "-c" - - "cd docker && make" + - id: fmt + name: fmt + description: Format files with cargo fmt. + entry: cargo fmt + language: system + always_run: true + pass_filenames: false + args: ["--", "--check"] + - id: cargo-test + name: cargo test + description: Test the package for errors. + entry: cargo test + language: system + args: ["--features", "sqlite,mysql,postgresql", "--"] + types_or: [rust, file] + files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$) + pass_filenames: false + - id: cargo-clippy + name: cargo clippy + description: Lint Rust sources + entry: cargo clippy + language: system + args: ["--features", "sqlite,mysql,postgresql", "--", "-D", "warnings"] + types_or: [rust, file] + files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$) + pass_filenames: false + - id: check-docker-templates + name: check docker templates + description: Check if the Docker templates are updated + language: system + entry: sh + args: + - "-c" + - "cd docker && make" +# When this version is updated, do not forget to update this in `.github/workflows/typos.yaml` too +- repo: https://github.com/crate-ci/typos + rev: 02ea592e44b3a53c302f697cddca7641cd051c3d # v1.45.0 + hooks: + - id: typos diff --git a/.typos.toml b/.typos.toml index dabcadb4..59f6d7d6 100644 --- a/.typos.toml +++ b/.typos.toml @@ -14,14 +14,13 @@ extend-ignore-re = [ # In SMTP it's called HELO, so ignore it "(?i)helo_name", "Server name sent during.+HELO", - # COSE Is short for CBOR Object Signing and Encryption - "(?i)cose", + # COSE Is short for CBOR Object Signing and Encryption, ignore these specific items + "COSEKey", + "COSEAlgorithm", # Ignore this specific string as it's valid "Ensure they are valid OTPs", # This word is misspelled upstream # https://github.com/bitwarden/server/blob/dff9f1cf538198819911cf2c20f8cda3307701c5/src/Notifications/HubHelpers.cs#L86 # https://github.com/bitwarden/clients/blob/9612a4ac45063e372a6fbe87eb253c7cb3c588fb/libs/common/src/auth/services/anonymous-hub.service.ts#L45 "AuthRequestResponseRecieved", - # Ignore Punycode/IDN tests - "xn--.+" ] diff --git a/Cargo.lock b/Cargo.lock index 587ac6b6..3d4d5921 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "ahash" version = "0.8.12" @@ -15,7 +26,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", - "getrandom 0.3.4", "once_cell", "version_check", "zerocopy", @@ -23,24 +33,24 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.5" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] [[package]] name = "alloc-no-stdlib" -version = "3.0.0" +version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" [[package]] name = "alloc-stdlib" -version = "0.3.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" dependencies = [ "alloc-no-stdlib", ] @@ -53,37 +63,28 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "android_system_properties" -version = "0.1.6" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" dependencies = [ "libc", ] [[package]] name = "anyhow" -version = "1.0.104" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" - -[[package]] -name = "arc-swap" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "380962b3d93b99642e50b9febf72c071e97e8b32d86eae9cf4ef582bb960ae6e" -dependencies = [ - "rustversion", -] +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "argon2" -version = "0.6.0" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" dependencies = [ "base64ct", "blake2", - "cpufeatures 0.3.1", + "cpufeatures 0.2.17", "password-hash", ] @@ -111,8 +112,8 @@ checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", - "synstructure 0.13.2", + "syn", + "synstructure", ] [[package]] @@ -123,7 +124,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -151,9 +152,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.51" +version = "0.4.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d5755640120b24961f8231e05892e4a4f7e0d04e8aeca161508c9c397946970" +checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1" dependencies = [ "compression-codecs", "compression-core", @@ -214,7 +215,7 @@ version = "3.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" dependencies = [ - "event-listener 5.4.2", + "event-listener 5.4.1", "event-listener-strategy", "pin-project-lite", ] @@ -232,7 +233,7 @@ dependencies = [ "async-task", "blocking", "cfg-if", - "event-listener 5.4.2", + "event-listener 5.4.1", "futures-lite", "rustix", ] @@ -301,7 +302,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -312,20 +313,20 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.92" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn", ] [[package]] -name = "asyncband" -version = "0.7.4" +name = "atomic" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d2050892d4691cf110586070786712b504985a4d37ed64a9cd8a402718e1706" +checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" [[package]] name = "atomic" @@ -344,15 +345,15 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-config" -version = "1.13.0" +version = "1.8.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "939e256cfe0ed68e96d32f194339d88210d5c10685cff0787400158f852c6819" +checksum = "11493b0bad143270fb8ad284a096dd529ba91924c5409adeac856cc1bf047dbc" dependencies = [ "aws-credential-types", "aws-runtime", @@ -364,14 +365,13 @@ dependencies = [ "aws-smithy-json", "aws-smithy-runtime", "aws-smithy-runtime-api", - "aws-smithy-schema", "aws-smithy-types", "aws-types", "bytes", "fastrand", "hex", - "http 1.5.0", - "sha1 0.10.7", + "http 1.4.0", + "sha1", "time", "tokio", "tracing", @@ -381,9 +381,9 @@ dependencies = [ [[package]] name = "aws-credential-types" -version = "1.3.1" +version = "1.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "576e8da35751dc85409ec1cd15999f692c277a1fbe9336780fe6f15e49dd637d" +checksum = "8f20799b373a1be121fe3005fba0c2090af9411573878f224df44b42727fcaf7" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api", @@ -393,9 +393,9 @@ dependencies = [ [[package]] name = "aws-runtime" -version = "1.10.2" +version = "1.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a0cc2144962cc5e358467b16e3d82981b1d424a7c3d34602be0036103814a1a" +checksum = "5fc0651c57e384202e47153c1260b84a9936e19803d747615edf199dc3b98d17" dependencies = [ "aws-credential-types", "aws-sigv4", @@ -408,8 +408,8 @@ dependencies = [ "bytes", "bytes-utils", "fastrand", - "http 1.5.0", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "percent-encoding", "pin-project-lite", "tracing", @@ -418,11 +418,10 @@ dependencies = [ [[package]] name = "aws-sdk-sso" -version = "1.119.0" +version = "1.97.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c414f25ec4aa9dc18f5cc8f55e18848bb509fcda4ce7c77bee6072e95806bc9" +checksum = "9aadc669e184501caaa6beafb28c6267fc1baef0810fb58f9b205485ca3f2567" dependencies = [ - "arc-swap", "aws-credential-types", "aws-runtime", "aws-smithy-async", @@ -431,23 +430,22 @@ dependencies = [ "aws-smithy-observability", "aws-smithy-runtime", "aws-smithy-runtime-api", - "aws-smithy-schema", "aws-smithy-types", "aws-types", "bytes", "fastrand", - "http 1.5.0", + "http 0.2.12", + "http 1.4.0", "regex-lite", "tracing", ] [[package]] name = "aws-sdk-ssooidc" -version = "1.121.0" +version = "1.99.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc638bb4355e6514334dcc3228cd4622f5ee54d704bd6a9010a5a91d7856d9d9" +checksum = "1342a7db8f358d3de0aed2007a0b54e875458e39848d54cc1d46700b2bfcb0a8" dependencies = [ - "arc-swap", "aws-credential-types", "aws-runtime", "aws-smithy-async", @@ -456,23 +454,22 @@ dependencies = [ "aws-smithy-observability", "aws-smithy-runtime", "aws-smithy-runtime-api", - "aws-smithy-schema", "aws-smithy-types", "aws-types", "bytes", "fastrand", - "http 1.5.0", + "http 0.2.12", + "http 1.4.0", "regex-lite", "tracing", ] [[package]] name = "aws-sdk-sts" -version = "1.124.0" +version = "1.101.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f8a0dd8fbde04ba58ad722113c7addb11bf94cf549b1ea0eb9ea427fb87198a" +checksum = "ab41ad64e4051ecabeea802d6a17845a91e83287e1dd249e6963ea1ba78c428a" dependencies = [ - "arc-swap", "aws-credential-types", "aws-runtime", "aws-smithy-async", @@ -482,21 +479,21 @@ dependencies = [ "aws-smithy-query", "aws-smithy-runtime", "aws-smithy-runtime-api", - "aws-smithy-schema", "aws-smithy-types", "aws-smithy-xml", "aws-types", "fastrand", - "http 1.5.0", + "http 0.2.12", + "http 1.4.0", "regex-lite", "tracing", ] [[package]] name = "aws-sigv4" -version = "1.6.4" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f385da9b7757ed4c8214797ad663ff534fb622006143530aa80e21f8e06ff85c" +checksum = "b0b660013a6683ab23797778e21f1f854744fdf05f68204b4cca4c8c04b5d1f4" dependencies = [ "aws-credential-types", "aws-smithy-http", @@ -505,19 +502,20 @@ dependencies = [ "bytes", "form_urlencoded", "hex", - "hmac 0.13.0", - "http 1.5.0", + "hmac", + "http 0.2.12", + "http 1.4.0", "percent-encoding", - "sha2 0.11.1", + "sha2", "time", "tracing", ] [[package]] name = "aws-smithy-async" -version = "1.3.1" +version = "1.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de681a9df7ff79823b759ee17061076ec049cd61a8fa28ece4293d89c9f6964e" +checksum = "2ffcaf626bdda484571968400c326a244598634dc75fd451325a54ad1a59acfc" dependencies = [ "futures-util", "pin-project-lite", @@ -526,9 +524,9 @@ dependencies = [ [[package]] name = "aws-smithy-http" -version = "0.64.3" +version = "0.63.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19af609dd5edf8b056cd0bb001c706c8b3b1458055328b334ec13d7f46f6aaa8" +checksum = "ba1ab2dc1c2c3749ead27180d333c42f11be8b0e934058fb4b2258ee8dbe5231" dependencies = [ "aws-smithy-runtime-api", "aws-smithy-types", @@ -536,123 +534,102 @@ dependencies = [ "bytes-utils", "futures-core", "futures-util", - "http 1.5.0", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "percent-encoding", "pin-project-lite", + "pin-utils", "tracing", ] [[package]] name = "aws-smithy-json" -version = "0.65.1" +version = "0.62.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b45f9fc77ffe92c92c341165a01a565bd30c39ecf5aa8c0719b41719009604f9" +checksum = "9648b0bb82a2eedd844052c6ad2a1a822d1f8e3adee5fbf668366717e428856a" dependencies = [ - "aws-smithy-runtime-api", - "aws-smithy-schema", "aws-smithy-types", ] [[package]] name = "aws-smithy-observability" -version = "0.3.0" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e86338c869539a581bf161247762a6e87f92c5c075060057b5ed6d06632ed0c" +checksum = "a06c2315d173edbf1920da8ba3a7189695827002e4c0fc961973ab1c54abca9c" dependencies = [ "aws-smithy-runtime-api", ] [[package]] name = "aws-smithy-query" -version = "0.64.0" +version = "0.60.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "165f6265342a313ad6f7a1ac17a0cdba30c522039d3f41d8d6871ce8a2b34ecd" +checksum = "1a56d79744fb3edb5d722ef79d86081e121d3b9422cb209eb03aea6aa4f21ebd" dependencies = [ - "aws-smithy-runtime-api", - "aws-smithy-schema", "aws-smithy-types", - "aws-smithy-xml", "urlencoding", ] [[package]] name = "aws-smithy-runtime" -version = "1.16.2" +version = "1.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cb80fb19c084da5fca9d797845735e184445e5b958599440d67529bf6ef1b64" +checksum = "028999056d2d2fd58a697232f9eec4a643cf73a71cf327690a7edad1d2af2110" dependencies = [ "aws-smithy-async", "aws-smithy-http", "aws-smithy-observability", "aws-smithy-runtime-api", - "aws-smithy-schema", "aws-smithy-types", "bytes", "fastrand", - "http 1.5.0", - "http-body", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", "http-body-util", "pin-project-lite", + "pin-utils", "tokio", "tracing", ] [[package]] name = "aws-smithy-runtime-api" -version = "1.19.2" +version = "1.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c4e85cffe9944b10d576b075432bd8f98dbc081e8681cb37d5b2d87dc27bc84" +checksum = "876ab3c9c29791ba4ba02b780a3049e21ec63dabda09268b175272c3733a79e6" dependencies = [ "aws-smithy-async", - "aws-smithy-runtime-api-macros", "aws-smithy-types", "bytes", - "http 1.5.0", + "http 0.2.12", + "http 1.4.0", "pin-project-lite", "tokio", "tracing", "zeroize", ] -[[package]] -name = "aws-smithy-runtime-api-macros" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce237f890a2283c9b328955c3b0918d2d5bf66a1cb6ceccd36a0047246fbc6e6" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "aws-smithy-schema" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9543fc722ed4865ab29fb66bd7ed0bef7b029d265ba8c587144511e30006d431" -dependencies = [ - "aws-smithy-runtime-api", - "aws-smithy-types", - "http 1.5.0", -] - [[package]] name = "aws-smithy-types" -version = "1.9.1" +version = "1.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf183d70774ea2cfcc101ca74a8884e4c76be8b9df65cd303f6a1175c88e8f80" +checksum = "9d73dbfbaa8e4bc57b9045137680b958d274823509a360abfd8e1d514d40c95c" dependencies = [ "base64-simd", "bytes", "bytes-utils", - "http 1.5.0", - "http-body", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", "http-body-util", "itoa", "num-integer", "pin-project-lite", + "pin-utils", "ryu", "serde", "time", @@ -660,43 +637,44 @@ dependencies = [ [[package]] name = "aws-smithy-xml" -version = "0.64.1" +version = "0.60.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "590fdc1a70546b2f7f2061c42eaf173fb6f26d935330d069edc04dbd8f01d924" +checksum = "0ce02add1aa3677d022f8adf81dcbe3046a95f17a1b1e8979c145cd21d3d22b3" dependencies = [ - "aws-smithy-runtime-api", - "aws-smithy-schema", - "aws-smithy-types", "xmlparser", ] [[package]] name = "aws-types" -version = "1.7.0" +version = "1.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba0d970d3947bb064d2ae1d823efa186c1dbde88f88afa8f6549946e5d8deee" +checksum = "47c8323699dd9b3c8d5b3c13051ae9cdef58fd179957c882f8374dd8725962d9" dependencies = [ "aws-credential-types", "aws-smithy-async", "aws-smithy-runtime-api", - "aws-smithy-schema", "aws-smithy-types", "rustc_version", "tracing", ] +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", + "gloo-timers", + "tokio", +] + [[package]] name = "base16ct" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" -[[package]] -name = "base16ct" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" - [[package]] name = "base64" version = "0.21.7" @@ -709,12 +687,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "base64" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" - [[package]] name = "base64-simd" version = "0.8.0" @@ -733,9 +705,9 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "base64urlsafedata" -version = "0.5.5" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b08e33815c87d8cadcddb1e74ac307368a3751fbe40c961538afa21a1899f21c" +checksum = "42f7f6be94fa637132933fd0a68b9140bcb60e3d46164cb68e82a2bb8d102b3a" dependencies = [ "base64 0.21.7", "pastey 0.1.1", @@ -744,9 +716,9 @@ dependencies = [ [[package]] name = "bigdecimal" -version = "0.4.11" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fa3f3d8cbf4dffcfe4991de61d012bef509a409ecbe9dd41049bfe32b4d4653" +checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" dependencies = [ "autocfg", "libm", @@ -763,23 +735,17 @@ checksum = "383d29d513d8764dcdc42ea295d979eb99c3c9f00607b3692cf68a431f7dca72" [[package]] name = "bitflags" -version = "1.3.2" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "bitflags" -version = "2.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" [[package]] name = "blake2" -version = "0.11.0" +version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" dependencies = [ - "digest 0.11.3", + "digest", ] [[package]] @@ -792,19 +758,19 @@ dependencies = [ ] [[package]] -name = "block-buffer" -version = "0.12.1" +name = "block-padding" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" dependencies = [ - "hybrid-array", + "generic-array", ] [[package]] name = "blocking" -version = "1.7.0" +version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" dependencies = [ "async-channel 2.5.0", "async-task", @@ -815,9 +781,9 @@ dependencies = [ [[package]] name = "brotli" -version = "9.1.1" +version = "8.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "627b7cfb0578f0eafce087a90fcd40add1231900b69e4d4de2d456260a4f5d81" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -826,34 +792,25 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "6.2.0" +version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34c6783f42c21c952ad7112b354f0ba2f10760e9decea5b1b60523d6e270faaf" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", ] -[[package]] -name = "bs58" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" -dependencies = [ - "tinyvec", -] - [[package]] name = "bumpalo" -version = "3.20.3" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "bytemuck" -version = "1.25.2" +version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" [[package]] name = "byteorder" @@ -863,9 +820,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.12.1" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "bytes-utils" @@ -879,44 +836,55 @@ dependencies = [ [[package]] name = "cached" -version = "4.1.0" +version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f886e989b5b6e7991606f2550e45a99e130024465ba8d67f9f9b256eb7044bf" +checksum = "53b6f5d101f0f6322c8646a45b7c581a673e476329040d97565815c2461dd0c4" dependencies = [ "ahash", - "async-lock", + "async-trait", "cached_proc_macro", "cached_proc_macro_types", - "hashbrown 0.17.1", - "parking_lot 0.12.5", - "thiserror 2.0.21", + "futures", + "hashbrown 0.16.1", + "once_cell", + "parking_lot", + "thiserror 2.0.18", + "tokio", "web-time", ] [[package]] name = "cached_proc_macro" -version = "3.0.1" +version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ece0579b43cf6e927b3370c7d44359c7122e7e52b3ea635bc8484da571442edb" +checksum = "8ebcf9c75f17a17d55d11afc98e46167d4790a263f428891b8705ab2f793eca3" dependencies = [ "darling 0.20.11", - "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] name = "cached_proc_macro_types" -version = "3.0.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5813789573ae815c8b4be58c4428e0e7ae05f0227678ba9de332ded585b9159" +checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0" + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] [[package]] name = "cc" -version = "1.10.0" +version = "1.2.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" dependencies = [ "find-msvc-tools", "jobserver", @@ -926,32 +894,32 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.5" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.2" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chacha20" -version = "0.10.2" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ "cfg-if", - "cpufeatures 0.3.1", - "rand_core 0.10.1", + "cpufeatures 0.3.0", + "rand_core 0.10.0", ] [[package]] name = "chrono" -version = "0.4.45" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", "js-sys", @@ -972,10 +940,14 @@ dependencies = [ ] [[package]] -name = "cmov" -version = "0.5.4" +name = "cipher" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] [[package]] name = "codemap" @@ -983,21 +955,11 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e769b5c8c8283982a987c6e948e540254f1058d5a74b8794914d4ef5fc2a24" -[[package]] -name = "combine" -version = "4.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" -dependencies = [ - "bytes", - "memchr", -] - [[package]] name = "compression-codecs" -version = "0.4.46" +version = "0.4.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cae36683101b2899f48bd31f8db9839437356188e3b1ff23096e73dab09ba8fe" +checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7" dependencies = [ "brotli", "compression-core", @@ -1009,9 +971,9 @@ dependencies = [ [[package]] name = "compression-core" -version = "0.4.34" +version = "0.4.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ebdda89edc534bf8d5dd2857562633b6495557e5846552ade994b99ed13c36a" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" [[package]] name = "concurrent-queue" @@ -1028,12 +990,6 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" -[[package]] -name = "const-oid" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" - [[package]] name = "const-random" version = "0.1.18" @@ -1065,9 +1021,9 @@ dependencies = [ [[package]] name = "cookie" -version = "0.18.2" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" dependencies = [ "percent-encoding", "time", @@ -1118,18 +1074,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "core_detect" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" - -[[package]] -name = "cpubits" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" - [[package]] name = "cpufeatures" version = "0.2.17" @@ -1141,28 +1085,27 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.3.1" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" dependencies = [ "libc", ] [[package]] -name = "crc-fast" -version = "1.10.0" +name = "crc32c" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e75b2483e97a5a7da73ac68a05b629f9c53cff58d8ed1c77866079e18b00dba5" +checksum = "3a47af21622d091a8f0fb295b88bc886ac74efcc613efc19f5d0b21de5c89e47" dependencies = [ - "digest 0.10.7", - "spin 0.10.1", + "rustc_version", ] [[package]] name = "crc32fast" -version = "1.5.3" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ "cfg-if", ] @@ -1175,39 +1118,38 @@ checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" [[package]] name = "cron" -version = "0.17.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5dcd6f69605c2956916ce24e8af637b754964c9a83f4662d3a2361654cdba09" +checksum = "5877d3fbf742507b66bc2a1945106bd30dd8504019d596901ddd012a4dd01740" dependencies = [ "chrono", "once_cell", - "phf 0.11.3", - "winnow 0.7.15", + "winnow 0.6.26", ] [[package]] name = "crossbeam-channel" -version = "0.5.17" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-epoch" -version = "0.9.21" +version = "0.9.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.23" +version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crunchy" @@ -1227,22 +1169,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "crypto-bigint" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c035cb64cba03dad2fb921f3e218785e8cf9a41790da5eb3a1400f62fa2be11" -dependencies = [ - "cpubits", - "ctutils", - "getrandom 0.4.3", - "hybrid-array", - "num-traits", - "rand_core 0.10.1", - "subtle", - "zeroize", -] - [[package]] name = "crypto-common" version = "0.1.6" @@ -1253,27 +1179,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "crypto-common" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" -dependencies = [ - "getrandom 0.4.3", - "hybrid-array", - "rand_core 0.10.1", -] - -[[package]] -name = "ctutils" -version = "0.4.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2376ffd6f96d8a9db59875b4b6b0237d6900b5baf64279714e05493ce64828a" -dependencies = [ - "cmov", - "subtle", -] - [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -1283,24 +1188,8 @@ dependencies = [ "cfg-if", "cpufeatures 0.2.17", "curve25519-dalek-derive", - "digest 0.10.7", - "fiat-crypto 0.2.9", - "rustc_version", - "subtle", - "zeroize", -] - -[[package]] -name = "curve25519-dalek" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.1", - "curve25519-dalek-derive", - "digest 0.11.3", - "fiat-crypto 0.3.0", + "digest", + "fiat-crypto", "rustc_version", "subtle", "zeroize", @@ -1314,7 +1203,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -1339,12 +1228,12 @@ dependencies = [ [[package]] name = "darling" -version = "0.24.1" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core 0.24.1", - "darling_macro 0.24.1", + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] @@ -1358,7 +1247,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.119", + "syn", ] [[package]] @@ -1372,20 +1261,20 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.119", + "syn", ] [[package]] name = "darling_core" -version = "0.24.1" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" dependencies = [ "ident_case", "proc-macro2", "quote", "strsim", - "syn 3.0.9", + "syn", ] [[package]] @@ -1396,7 +1285,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -1407,39 +1296,39 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core 0.21.3", "quote", - "syn 2.0.119", + "syn", ] [[package]] name = "darling_macro" -version = "0.24.1" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core 0.24.1", + "darling_core 0.23.0", "quote", - "syn 3.0.9", + "syn", ] [[package]] name = "dashmap" -version = "6.2.1" +version = "6.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" dependencies = [ "cfg-if", "crossbeam-utils", "hashbrown 0.14.5", - "lock_api 0.4.14", + "lock_api", "once_cell", - "parking_lot_core 0.9.12", + "parking_lot_core", ] [[package]] name = "data-encoding" -version = "2.11.1" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" [[package]] name = "data-url" @@ -1447,56 +1336,14 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" -[[package]] -name = "defmt" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4a0795ac740cb54d7b3775c9c17345f6aaecf884c01733b39d6017738e0e3e1" -dependencies = [ - "bitflags 1.3.2", - "defmt-macros", -] - -[[package]] -name = "defmt-macros" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "840bb056bd99c495c9188198a258ab787c79f306d864db3e6e8451442d6d8366" -dependencies = [ - "defmt-parser", - "proc-macro2", - "quote", - "syn 3.0.9", -] - -[[package]] -name = "defmt-parser" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71958f69fb2de856c5dd51ac9377e986ca1aa5919f931bf5395a807277f889a0" -dependencies = [ - "thiserror 2.0.21", -] - [[package]] name = "der" version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid 0.9.6", - "pem-rfc7468 0.7.0", - "zeroize", -] - -[[package]] -name = "der" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a996a108feb8b427a6e12fcf6af4aa83409b79d72c15337d7741368131ee004" -dependencies = [ - "const-oid 0.10.2", - "pem-rfc7468 1.0.0", + "const-oid", + "pem-rfc7468", "zeroize", ] @@ -1516,10 +1363,11 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.9" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ + "powerfmt", "serde_core", ] @@ -1541,7 +1389,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -1551,7 +1399,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.119", + "syn", ] [[package]] @@ -1573,15 +1421,15 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.119", + "syn", "unicode-xid", ] [[package]] name = "devise" -version = "0.5.0" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "143afb21c617f66c792ef2d75f9fb4c9dd774ff206bcc24df08a1bda294a37bc" +checksum = "f1d90b0c4c777a2cad215e3c7be59ac7c15adf45cf76317009b7d096d46f651d" dependencies = [ "devise_codegen", "devise_core", @@ -1589,9 +1437,9 @@ dependencies = [ [[package]] name = "devise_codegen" -version = "0.5.0" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b3187e4283046e01d16d724ed05326427aa9c1c3a376548ef31820e6eb9717" +checksum = "71b28680d8be17a570a2334922518be6adc3f58ecc880cbb404eaeb8624fd867" dependencies = [ "devise_core", "quote", @@ -1599,25 +1447,25 @@ dependencies = [ [[package]] name = "devise_core" -version = "0.5.0" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "970e4b2fcd605ffa79715510663a9fe76b87f5483d59857399129cf047f73d27" +checksum = "b035a542cf7abf01f2e3c4d5a7acbaebfefe120ae4efc7bde3df98186e4b8af7" dependencies = [ - "bitflags 2.13.2", + "bitflags", "proc-macro2", - "proc-macro2-diagnostics 0.11.0", + "proc-macro2-diagnostics", "quote", - "syn 3.0.9", + "syn", ] [[package]] name = "diesel" -version = "2.3.14" +version = "2.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe9f7eaef33febd60290c5a9f3b0571d03c4c04a24739cd64808ce30e65a5d3" +checksum = "f4ae09a41a4b89f94ec1e053623da8340d996bc32c6517d325a9daad9b239358" dependencies = [ "bigdecimal", - "bitflags 2.13.2", + "bitflags", "byteorder", "chrono", "diesel_derives", @@ -1638,33 +1486,33 @@ dependencies = [ [[package]] name = "diesel-derive-newtype" -version = "2.1.3" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c9c687e77914afc18b1e797d523ace0e5f08dc7805285bcdabd8646ea8d4de7" +checksum = "d5adf688c584fe33726ce0e2898f608a2a92578ac94a4a92fcecf73214fe0716" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] name = "diesel_derives" -version = "2.3.10" +version = "2.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecbd51fb6c020672543641167efa4e6417ff7ad76849ed556ace3595e72de03a" +checksum = "47618bf0fac06bb670c036e48404c26a865e6a71af4114dfd97dfe89936e404e" dependencies = [ "diesel_table_macro_syntax", "dsl_auto_type", "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] name = "diesel_migrations" -version = "2.3.2" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d0f4a98124ba6d4ca75da535f65984badec16a003b6e2f94a01e31a79490b8" +checksum = "745fd255645f0f1135f9ec55c7b00e0882192af9683ab4731e4bba3da82b8f9c" dependencies = [ "diesel", "migrations_internals", @@ -1677,7 +1525,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe2444076b48641147115697648dc743c2c00b61adade0f01ce67133c7babe8c" dependencies = [ - "syn 2.0.119", + "syn", ] [[package]] @@ -1686,33 +1534,21 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer 0.10.4", - "const-oid 0.9.6", - "crypto-common 0.1.6", + "block-buffer", + "const-oid", + "crypto-common", "subtle", ] -[[package]] -name = "digest" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" -dependencies = [ - "block-buffer 0.12.1", - "const-oid 0.10.2", - "crypto-common 0.2.2", - "ctutils", -] - [[package]] name = "displaydoc" -version = "0.2.7" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn", ] [[package]] @@ -1756,7 +1592,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -1771,27 +1607,12 @@ version = "0.16.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ - "der 0.7.10", - "digest 0.10.7", - "elliptic-curve 0.13.8", - "rfc6979 0.4.0", - "signature 2.2.0", - "spki 0.7.3", -] - -[[package]] -name = "ecdsa" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" -dependencies = [ - "der 0.8.3", - "digest 0.11.3", - "elliptic-curve 0.14.3", - "rfc6979 0.6.0", - "signature 3.0.0", - "spki 0.8.1", - "zeroize", + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", ] [[package]] @@ -1800,18 +1621,8 @@ version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ - "pkcs8 0.10.2", - "signature 2.2.0", -] - -[[package]] -name = "ed25519" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" -dependencies = [ - "pkcs8 0.11.0", - "signature 3.0.0", + "pkcs8", + "signature", ] [[package]] @@ -1820,34 +1631,19 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ - "curve25519-dalek 4.1.3", - "ed25519 2.2.3", + "curve25519-dalek", + "ed25519", "serde", - "sha2 0.10.9", - "subtle", - "zeroize", -] - -[[package]] -name = "ed25519-dalek" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" -dependencies = [ - "curve25519-dalek 5.0.0", - "ed25519 3.0.0", - "serde", - "sha2 0.11.1", - "signature 3.0.0", + "sha2", "subtle", "zeroize", ] [[package]] name = "either" -version = "1.19.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "elliptic-curve" @@ -1855,49 +1651,28 @@ version = "0.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ - "base16ct 0.2.0", - "crypto-bigint 0.5.5", - "digest 0.10.7", - "ff 0.13.1", + "base16ct", + "crypto-bigint", + "digest", + "ff", "generic-array", - "group 0.13.0", + "group", "hkdf", - "pem-rfc7468 0.7.0", - "pkcs8 0.10.2", + "pem-rfc7468", + "pkcs8", "rand_core 0.6.4", - "sec1 0.7.3", - "subtle", - "zeroize", -] - -[[package]] -name = "elliptic-curve" -version = "0.14.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "900f37e7e18d74a2347389af6cd69e3a30d5cc9e697f40004865679aa75e9c7c" -dependencies = [ - "base16ct 1.0.0", - "crypto-bigint 0.7.10", - "crypto-common 0.2.2", - "digest 0.11.3", - "ff 0.14.0", - "group 0.14.0", - "hybrid-array", - "pem-rfc7468 1.0.0", - "pkcs8 0.11.0", - "rand_core 0.10.1", - "sec1 0.8.1", + "sec1", "subtle", "zeroize", ] [[package]] name = "email-encoding" -version = "0.4.2" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "420b9da095f052ea597503e39073b5b3c522f7db933fbac202d91d24492693fd" +checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" dependencies = [ - "base64 0.23.1", + "base64 0.22.1", "memchr", ] @@ -1912,23 +1687,30 @@ dependencies = [ [[package]] name = "encoding_rs" -version = "0.8.44" +version = "0.8.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89253175e704ab869b04f9db224d38630e0211a53841a79181deae0889e169f5" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" dependencies = [ "cfg-if", - "core_detect", - "multiversion_no_op", - "rustversion", - "scopeguard", - "simdutf8", +] + +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", ] [[package]] name = "equivalent" -version = "1.0.3" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" @@ -1948,10 +1730,11 @@ checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" [[package]] name = "event-listener" -version = "5.4.2" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" dependencies = [ + "concurrent-queue", "parking", "pin-project-lite", ] @@ -1962,15 +1745,15 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" dependencies = [ - "event-listener 5.4.2", + "event-listener 5.4.1", "pin-project-lite", ] [[package]] name = "fastrand" -version = "2.5.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fern" @@ -1994,36 +1777,20 @@ dependencies = [ "subtle", ] -[[package]] -name = "ff" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" -dependencies = [ - "rand_core 0.10.1", - "subtle", -] - [[package]] name = "fiat-crypto" version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" -[[package]] -name = "fiat-crypto" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" - [[package]] name = "figment" version = "0.10.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" dependencies = [ - "atomic", - "pear 0.2.9", + "atomic 0.6.1", + "pear", "serde", "toml 0.8.23", "uncased", @@ -2032,19 +1799,18 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.15" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "flate2" -version = "1.1.10" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", - "zlib-rs", ] [[package]] @@ -2053,6 +1819,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -2085,9 +1857,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", @@ -2100,9 +1872,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -2110,15 +1882,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -2127,9 +1899,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-lite" @@ -2146,38 +1918,38 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn", ] [[package]] name = "futures-sink" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-timer" -version = "3.0.4" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" [[package]] name = "futures-util" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -2243,23 +2015,23 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.3" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi 6.0.0", - "rand_core 0.10.1", - "wasm-bindgen", + "rand_core 0.10.0", + "wasip2", + "wasip3", ] [[package]] name = "glob" -version = "0.3.4" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "gloo-timers" @@ -2287,10 +2059,10 @@ dependencies = [ "getrandom 0.3.4", "hashbrown 0.16.1", "nonzero_ext", - "parking_lot 0.12.5", + "parking_lot", "portable-atomic", "quanta", - "rand 0.9.5", + "rand 0.9.3", "smallvec", "spinning_top", "web-time", @@ -2303,7 +2075,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d9e3df7f0222ce5184154973d247c591d9aadc28ce7a73c6cd31100c9facff6" dependencies = [ "codemap", - "indexmap 2.14.2", + "indexmap 2.14.0", "lasso", "once_cell", "phf 0.11.3", @@ -2315,35 +2087,24 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ - "ff 0.13.1", + "ff", "rand_core 0.6.4", "subtle", ] -[[package]] -name = "group" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" -dependencies = [ - "ff 0.14.0", - "rand_core 0.10.1", - "subtle", -] - [[package]] name = "h2" -version = "0.4.20" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" dependencies = [ "atomic-waker", "bytes", "fnv", "futures-core", "futures-sink", - "http 1.5.0", - "indexmap 2.14.2", + "http 1.4.0", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -2363,9 +2124,9 @@ dependencies = [ [[package]] name = "handlebars" -version = "6.4.4" +version = "6.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75c54236f9045c8004a77942bebc52145b4844639db934a5c70fe08617fbe61a" +checksum = "9b3f9296c208515b87bd915a2f5d1163d4b3f863ba83337d7713cf478055948e" dependencies = [ "derive_builder", "log", @@ -2374,7 +2135,7 @@ dependencies = [ "pest_derive", "serde", "serde_json", - "thiserror 2.0.21", + "thiserror 2.0.18", "walkdir", ] @@ -2394,6 +2155,15 @@ dependencies = [ "allocator-api2", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -2402,19 +2172,14 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] name = "hashbrown" -version = "0.17.1" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash", -] +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" [[package]] name = "heck" @@ -2424,9 +2189,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" -version = "0.5.3" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" [[package]] name = "hex" @@ -2435,71 +2200,47 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] -name = "hickory-net" -version = "0.26.3" +name = "hickory-proto" +version = "0.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c480823ed7c2c5d0f09c41020cb6b7c28029ce60ec42dc942158dcf22f8e0a4d" +checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" dependencies = [ "async-trait", "cfg-if", "data-encoding", + "enum-as-inner", "futures-channel", "futures-io", "futures-util", - "hickory-proto", "idna", "ipnet", - "jni", - "rand 0.10.3", - "thiserror 2.0.21", + "once_cell", + "rand 0.9.3", + "ring", + "thiserror 2.0.18", "tinyvec", "tokio", "tracing", "url", ] -[[package]] -name = "hickory-proto" -version = "0.26.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12b92608f679a6fa515dd1d15c1ff89443026e391200a2c840c7afcba482893d" -dependencies = [ - "data-encoding", - "idna", - "ipnet", - "jni", - "once_cell", - "prefix-trie", - "rand 0.10.3", - "ring", - "thiserror 2.0.21", - "tinyvec", - "tracing", - "url", -] - [[package]] name = "hickory-resolver" -version = "0.26.3" +version = "0.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f3da5255c95d5a716857d54b5b8f4e8d67c3484d3beaaaae2ce25063b3ba981" +checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" dependencies = [ "cfg-if", "futures-util", - "hickory-net", "hickory-proto", "ipconfig", - "ipnet", - "jni", "moka", - "ndk-context", "once_cell", - "parking_lot 0.12.5", - "rand 0.10.3", + "parking_lot", + "rand 0.9.3", "resolv-conf", "smallvec", - "system-configuration", - "thiserror 2.0.21", + "thiserror 2.0.18", "tokio", "tracing", ] @@ -2510,7 +2251,7 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac 0.12.1", + "hmac", ] [[package]] @@ -2519,16 +2260,16 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest 0.10.7", + "digest", ] [[package]] -name = "hmac" -version = "0.13.0" +name = "home" +version = "0.5.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" dependencies = [ - "digest 0.11.3", + "windows-sys 0.61.2", ] [[package]] @@ -2544,9 +2285,12 @@ dependencies = [ [[package]] name = "html5gum" -version = "0.9.0" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae3133814a10216af5242a254420a74cb0d2f7715646924f5a8e7720cd188b" +checksum = "12d29324a6ba370667998f63c6dd2b2511e2297f07e827f69026684907adc3b5" +dependencies = [ + "jetscii", +] [[package]] name = "http" @@ -2561,9 +2305,9 @@ dependencies = [ [[package]] name = "http" -version = "1.5.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", "itoa", @@ -2571,24 +2315,35 @@ dependencies = [ [[package]] name = "http-body" -version = "1.1.0" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" dependencies = [ "bytes", - "http 1.5.0", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.4.0", ] [[package]] name = "http-body-util" -version = "0.1.5" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http 1.5.0", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -2605,31 +2360,42 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] -name = "hybrid-array" -version = "0.4.15" +name = "hyper" +version = "0.14.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" dependencies = [ - "subtle", - "typenum", - "zeroize", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", ] [[package]] name = "hyper" -version = "1.12.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", "futures-channel", "futures-core", "h2", - "http 1.5.0", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "httparse", - "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -2639,38 +2405,40 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.10" +version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ - "http 1.5.0", - "hyper", + "http 1.4.0", + "hyper 1.9.0", "hyper-util", - "rustls", + "rustls 0.23.37", + "rustls-native-certs", + "rustls-pki-types", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tower-service", + "webpki-roots", ] [[package]] name = "hyper-util" -version = "0.1.21" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64 0.23.1", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", - "http 1.5.0", - "http-body", - "httparse", - "hyper", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.9.0", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.3", "system-configuration", "tokio", "tower-service", @@ -2704,9 +2472,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.4.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", @@ -2718,9 +2486,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.4.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -2731,9 +2499,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.4.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -2745,17 +2513,16 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.4.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.4.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ - "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -2766,15 +2533,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.4.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.4.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -2785,6 +2552,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -2804,9 +2577,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.2" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" dependencies = [ "icu_normalizer", "icu_properties", @@ -2825,12 +2598,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.2" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.1", + "hashbrown 0.17.0", "serde", "serde_core", ] @@ -2841,13 +2614,23 @@ version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "ipconfig" version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" dependencies = [ - "socket2", + "socket2 0.6.3", "widestring", "windows-registry", "windows-result", @@ -2856,10 +2639,17 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.12.2" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" dependencies = [ + "memchr", "serde", ] @@ -2876,9 +2666,9 @@ dependencies = [ [[package]] name = "itertools" -version = "0.14.0" +version = "0.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" dependencies = [ "either", ] @@ -2890,51 +2680,42 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] -name = "jiff" -version = "0.2.38" +name = "jetscii" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383" +checksum = "47f142fe24a9c9944451e8349de0a56af5f3e7226dc46f3ed4d4ecc0b85af75e" + +[[package]] +name = "jiff" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" dependencies = [ - "defmt", - "jiff-core", "jiff-static", "jiff-tzdb-platform", - "js-sys", "log", "portable-atomic", "portable-atomic-util", "serde_core", - "wasm-bindgen", - "windows-link", -] - -[[package]] -name = "jiff-core" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" -dependencies = [ - "defmt", - "log", + "windows-sys 0.61.2", ] [[package]] name = "jiff-static" -version = "0.2.38" +version = "0.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c" +checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" dependencies = [ - "jiff-core", "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] name = "jiff-tzdb" -version = "0.1.9" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa8377070c6bae868759445e5a77f66d84f0b72f3a054bfb00e6d038b8282da7" +checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076" [[package]] name = "jiff-tzdb-platform" @@ -2945,60 +2726,11 @@ dependencies = [ "jiff-tzdb", ] -[[package]] -name = "jni" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" -dependencies = [ - "cfg-if", - "combine", - "jni-macros", - "jni-sys", - "log", - "simd_cesu8", - "thiserror 2.0.21", - "walkdir", - "windows-link", -] - -[[package]] -name = "jni-macros" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" -dependencies = [ - "proc-macro2", - "quote", - "rustc_version", - "simd_cesu8", - "syn 2.0.119", -] - -[[package]] -name = "jni-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" -dependencies = [ - "jni-sys-macros", -] - -[[package]] -name = "jni-sys-macros" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" -dependencies = [ - "quote", - "syn 2.0.119", -] - [[package]] name = "job_scheduler_ng" -version = "2.5.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "576b4255ab9de8ce7b81060ec54b1b7f8499dfd6c16a66c4cd4cb1ad4eba27e3" +checksum = "217723d58ee473953675d15f11e56898a611aca8ea044d5a34eabeade99ef613" dependencies = [ "chrono", "cron", @@ -3007,47 +2739,62 @@ dependencies = [ [[package]] name = "jobserver" -version = "0.1.35" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ - "getrandom 0.4.3", + "getrandom 0.3.4", "libc", ] [[package]] name = "js-sys" -version = "0.3.107" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" dependencies = [ "cfg-if", "futures-util", + "once_cell", "wasm-bindgen", ] [[package]] name = "jsonwebtoken" -version = "11.1.0" +version = "9.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e75fe14a82d81e5f5af639997db37d8b96045938a7ac6ab18cdbe1c7467e05e1" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" dependencies = [ "base64 0.22.1", - "ed25519-dalek 2.2.0", - "getrandom 0.2.17", - "hmac 0.12.1", "js-sys", - "p256 0.13.2", - "p384 0.13.1", "pem", - "rand 0.8.8", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "jsonwebtoken" +version = "10.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" +dependencies = [ + "base64 0.22.1", + "ed25519-dalek", + "getrandom 0.2.17", + "hmac", + "js-sys", + "p256", + "p384", + "pem", + "rand 0.8.5", "rsa", "serde", "serde_json", - "sha2 0.10.9", - "signature 2.2.0", + "sha2", + "signature", "simple_asn1", - "zeroize", ] [[package]] @@ -3070,22 +2817,28 @@ dependencies = [ [[package]] name = "lazy_static" -version = "1.5.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" dependencies = [ - "spin 0.9.9", + "spin", ] [[package]] -name = "lettre" -version = "0.11.23" +name = "leb128fmt" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2c646bd5cc763b1087b15493e29a64be6147ba8f19342004fa52048ee596eae" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "lettre" +version = "0.11.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dabda5859ee7c06b995b9d1165aa52c39110e079ef609db97178d86aeb051fa7" dependencies = [ "async-std", "async-trait", - "base64 0.23.1", + "base64 0.22.1", "email-encoding", "email_address", "fastrand", @@ -3098,21 +2851,21 @@ dependencies = [ "nom 8.0.0", "percent-encoding", "quoted_printable", - "rustls", + "rustls 0.23.37", "rustls-native-certs", "serde", - "socket2", + "socket2 0.6.3", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tracing", "url", ] [[package]] name = "libc" -version = "0.2.191" +version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" [[package]] name = "libm" @@ -3122,18 +2875,19 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libmimalloc-sys" -version = "0.1.49" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9" +checksum = "667f4fec20f29dfc6bc7357c582d91796c169ad7e2fce709468aefeb2c099870" dependencies = [ "cc", + "libc", ] [[package]] name = "libsqlite3-sys" -version = "0.38.2" +version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" +checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a" dependencies = [ "cc", "pkg-config", @@ -3148,15 +2902,15 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.3" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "litrs" -version = "1.0.1" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "lock_api" @@ -3167,20 +2921,11 @@ dependencies = [ "scopeguard", ] -[[package]] -name = "lock_api" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65d91b22da179613dcf4d6ec8349563f52a363f6849e03b00dd0a185ec4a59af" -dependencies = [ - "scopeguard", -] - [[package]] name = "log" -version = "0.4.35" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" dependencies = [ "value-bag", ] @@ -3200,12 +2945,18 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "macros" version = "0.1.0" dependencies = [ "quote", - "syn 3.0.9", + "syn", ] [[package]] @@ -3219,19 +2970,19 @@ dependencies = [ [[package]] name = "md-5" -version = "0.11.0" +version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", - "digest 0.11.3", + "digest", ] [[package]] name = "memchr" -version = "2.8.3" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "migrations_internals" @@ -3245,21 +2996,20 @@ dependencies = [ [[package]] name = "migrations_macros" -version = "2.3.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9423d6affd681fb4d25d35885d0d0262c52f8ade16e96f87c064c442e5aa5d47" +checksum = "36fc5ac76be324cfd2d3f2cf0fdf5d5d3c4f14ed8aaebadb09e304ba42282703" dependencies = [ "migrations_internals", "proc-macro2", "quote", - "syn 3.0.9", ] [[package]] name = "mimalloc" -version = "0.1.52" +version = "0.1.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862" +checksum = "e1ee66a4b64c74f4ef288bcbb9192ad9c3feaad75193129ac8509af543894fd8" dependencies = [ "libmimalloc-sys", ] @@ -3278,9 +3028,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" -version = "0.9.1" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", "simd-adler32", @@ -3288,9 +3038,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.4" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi", @@ -3299,18 +3049,18 @@ dependencies = [ [[package]] name = "moka" -version = "0.12.16" +version = "0.12.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4293f18e7567a1caf3c584855554377025c65e0aa445344d04171f5ad63d19b9" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" dependencies = [ "async-lock", "crossbeam-channel", "crossbeam-epoch", "crossbeam-utils", "equivalent", - "event-listener 5.4.2", + "event-listener 5.4.1", "futures-util", - "parking_lot 0.12.5", + "parking_lot", "portable-atomic", "smallvec", "tagptr", @@ -3326,51 +3076,27 @@ dependencies = [ "bytes", "encoding_rs", "futures-util", - "http 1.5.0", + "http 1.4.0", "httparse", "memchr", "mime", - "spin 0.9.9", + "spin", "tokio", "tokio-util", "version_check", ] -[[package]] -name = "multiversion_no_op" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" - [[package]] name = "mysqlclient-sys" -version = "0.5.2" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b72511f8f6991fe4ac86421ea0625630fd94e360b906cc59720506499f9e8f3b" +checksum = "822bc60a9459abe384dd85d81ac59167ed2da99fba6eb810000e6ab64d9404b2" dependencies = [ "pkg-config", "semver", "vcpkg", ] -[[package]] -name = "ndk-context" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" - -[[package]] -name = "nix" -version = "0.31.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" -dependencies = [ - "bitflags 2.13.2", - "cfg-if", - "cfg_aliases", - "libc", -] - [[package]] name = "nom" version = "7.1.3" @@ -3407,9 +3133,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.8" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ "num-integer", "num-traits", @@ -3426,52 +3152,53 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.8", + "rand 0.8.5", "smallvec", "zeroize", ] [[package]] name = "num-conv" -version = "0.2.2" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-derive" -version = "0.5.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4e98dc3b890f6c23a0f9d3d491a2823d0dea0fa656302a13dd225fa924112a8" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn", ] [[package]] name = "num-integer" -version = "0.1.47" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" dependencies = [ "num-traits", ] [[package]] name = "num-iter" -version = "0.1.46" +version = "0.1.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" dependencies = [ + "autocfg", "num-integer", "num-traits", ] [[package]] name = "num-modular" -version = "0.6.5" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd8e500409e6cd603b03e477c26a6caecdc27ac58979a53e881c75eafc079f44" +checksum = "17bb261bf36fa7d83f4c294f834e91256769097b3cb505d44831e0a179ac647f" [[package]] name = "num-order" @@ -3504,9 +3231,9 @@ dependencies = [ [[package]] name = "num_threads" -version = "0.1.8" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cabcc654086861f9e1bc5115258de62ef92c79b20f4615a1b0cc57c4e82027d4" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" dependencies = [ "libc", ] @@ -3520,12 +3247,13 @@ dependencies = [ "base64 0.22.1", "chrono", "getrandom 0.2.17", - "http 1.5.0", - "rand 0.8.8", + "http 1.4.0", + "rand 0.8.5", + "reqwest", "serde", "serde_json", "serde_path_to_error", - "sha2 0.10.9", + "sha2", "thiserror 1.0.69", "url", ] @@ -3551,109 +3279,51 @@ dependencies = [ [[package]] name = "opendal" -version = "0.59.5" +version = "0.55.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46081824c74bac9f68f0bc4408077f77b0e315c006514719c97af1c2227ac2d2" -dependencies = [ - "opendal-core", - "opendal-service-fs", - "opendal-service-s3", -] - -[[package]] -name = "opendal-core" -version = "0.59.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33d5a635dfea36a7a0c3bbb2ca34c97af17f98dd69b0cdf31a1d436a346e0a12" +checksum = "d075ab8a203a6ab4bc1bce0a4b9fe486a72bf8b939037f4b78d95386384bc80a" dependencies = [ "anyhow", - "asyncband", - "base64 0.23.1", + "backon", + "base64 0.22.1", "bytes", + "crc32c", "futures", - "http 1.5.0", + "getrandom 0.2.17", + "http 1.4.0", + "http-body 1.0.1", "jiff", "log", "md-5", "percent-encoding", - "quick-xml", - "reqsign-core", + "quick-xml 0.38.4", + "reqsign", + "reqwest", "serde", "serde_json", "tokio", "url", "uuid", - "web-time", -] - -[[package]] -name = "opendal-http-transport-reqwest" -version = "0.59.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc025e163c43993e30cf1829a30b791934e5b0f7cd4d7e03acc902a9efb7e8f5" -dependencies = [ - "bytes", - "futures", - "http 1.5.0", - "http-body", - "opendal-core", - "reqwest", -] - -[[package]] -name = "opendal-service-fs" -version = "0.59.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70768b8f5af6996a494e784a1dc2a76b5cf890c422aff31ad094319bde98feb7" -dependencies = [ - "bytes", - "log", - "opendal-core", - "serde", - "tokio", - "xattr", -] - -[[package]] -name = "opendal-service-s3" -version = "0.59.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0932321e27c712a30ef72db56b12f31e41e9f0d2d3841d63a91bc8075cd8f0ef" -dependencies = [ - "base64 0.23.1", - "bytes", - "crc-fast", - "http 1.5.0", - "log", - "md-5", - "opendal-core", - "quick-xml", - "reqsign-aws-v4", - "reqsign-core", - "reqsign-file-read-tokio", - "serde", - "url", ] [[package]] name = "openidconnect" -version = "4.1.0" +version = "4.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df0d098a59353679624d406825940139e2b2d554a52b64c9fb1b1bc42a11fe5c" +checksum = "0d8c6709ba2ea764bbed26bce1adf3c10517113ddea6f2d4196e4851757ef2b2" dependencies = [ - "base64 0.22.1", + "base64 0.21.7", "chrono", "dyn-clone", - "ed25519-dalek 3.0.0", - "getrandom 0.4.3", - "hmac 0.12.1", - "http 1.5.0", + "ed25519-dalek", + "hmac", + "http 1.4.0", "itertools", "log", "oauth2", - "p256 0.14.0", - "p384 0.14.0", - "rand 0.8.8", + "p256", + "p384", + "rand 0.8.5", "rsa", "serde", "serde-value", @@ -3661,7 +3331,7 @@ dependencies = [ "serde_path_to_error", "serde_plain", "serde_with", - "sha2 0.10.9", + "sha2", "subtle", "thiserror 1.0.69", "url", @@ -3669,14 +3339,15 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.81" +version = "0.10.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" dependencies = [ - "bitflags 2.13.2", + "bitflags", "cfg-if", "foreign-types", "libc", + "once_cell", "openssl-macros", "openssl-sys", ] @@ -3689,29 +3360,29 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] name = "openssl-probe" -version = "0.2.2" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e036b411754132fd6b74e94b74e9a13c5cf606fa93d1885c35860d3060820b1" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-src" -version = "300.6.1+3.6.3" +version = "300.6.0+3.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" +checksum = "a8e8cbfd3a4a8c8f089147fd7aaa33cf8c7450c4d09f8f80698a0cf093abeff4" dependencies = [ "cc", ] [[package]] name = "openssl-sys" -version = "0.9.117" +version = "0.9.112" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" dependencies = [ "cc", "libc", @@ -3751,23 +3422,10 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" dependencies = [ - "ecdsa 0.16.9", - "elliptic-curve 0.13.8", - "primeorder 0.13.6", - "sha2 0.10.9", -] - -[[package]] -name = "p256" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" -dependencies = [ - "ecdsa 0.17.0", - "elliptic-curve 0.14.3", - "primefield", - "primeorder 0.14.0", - "sha2 0.11.1", + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", ] [[package]] @@ -3776,24 +3434,10 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" dependencies = [ - "ecdsa 0.16.9", - "elliptic-curve 0.13.8", - "primeorder 0.13.6", - "sha2 0.10.9", -] - -[[package]] -name = "p384" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d17b851e6b3e378ab4ecb07fa2ed23f4d15f075735f8fec9fa1e7bdce5f8301f" -dependencies = [ - "ecdsa 0.17.0", - "elliptic-curve 0.14.3", - "fiat-crypto 0.3.0", - "primefield", - "primeorder 0.14.0", - "sha2 0.11.1", + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", ] [[package]] @@ -3808,18 +3452,8 @@ version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ - "lock_api 0.4.14", - "parking_lot_core 0.9.12", -] - -[[package]] -name = "parking_lot" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9cf937ca4e1d0b7578784559063422fc39ae7c5a00f02471f2614d5486fd226" -dependencies = [ - "lock_api 0.5.0", - "parking_lot_core 0.10.0", + "lock_api", + "parking_lot_core", ] [[package]] @@ -3830,31 +3464,20 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.18", - "smallvec", - "windows-link", -] - -[[package]] -name = "parking_lot_core" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cfff060068cc18aa7815010fcaf80c68c7fb9e85fc7f5c0f9bf142c21d19826" -dependencies = [ - "libc", - "redox_syscall 0.9.5", + "redox_syscall", "smallvec", "windows-link", ] [[package]] name = "password-hash" -version = "0.6.1" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" dependencies = [ - "getrandom 0.4.3", - "phc", + "base64ct", + "rand_core 0.6.4", + "subtle", ] [[package]] @@ -3865,9 +3488,19 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" [[package]] name = "pastey" -version = "0.2.3" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" +checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] [[package]] name = "pear" @@ -3876,18 +3509,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" dependencies = [ "inlinable_string", - "pear_codegen 0.2.9", - "yansi", -] - -[[package]] -name = "pear" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bdc582f5ece4663ba62cb9cc81b9f86aa23884ee92cddb1d5e75a94991119cb" -dependencies = [ - "inlinable_string", - "pear_codegen 0.3.0", + "pear_codegen", "yansi", ] @@ -3898,21 +3520,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" dependencies = [ "proc-macro2", - "proc-macro2-diagnostics 0.10.1", + "proc-macro2-diagnostics", "quote", - "syn 2.0.119", -] - -[[package]] -name = "pear_codegen" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b4262794e70dac544a759fd50088c6331e7a2ba4fc204b4b6e29c9b5257f9c4" -dependencies = [ - "proc-macro2", - "proc-macro2-diagnostics 0.11.0", - "quote", - "syn 3.0.9", + "syn", ] [[package]] @@ -3934,15 +3544,6 @@ dependencies = [ "base64ct", ] -[[package]] -name = "pem-rfc7468" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" -dependencies = [ - "base64ct", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -3951,20 +3552,19 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.9.5" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8db75153506001cb36ed924f08cadc862eb3f8c4d0d534139f29074769d7660" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" dependencies = [ "memchr", - "pest_stack_guard", "ucd-trie", ] [[package]] name = "pest_derive" -version = "2.9.5" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20aa17e11fb35664b67d5214a0afac57a360d231b6ec53d6eba0700b27d933b4" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" dependencies = [ "pest", "pest_generator", @@ -3972,45 +3572,25 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.9.5" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "626ec5afccf44ccbfeb2b232316510bb55b4bf34d891afcb0fb07d2b17d7fe9d" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 3.0.9", + "syn", ] [[package]] name = "pest_meta" -version = "2.9.5" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3f45b6caa65c1b08186832745159c2529994146340a7bf44168095469720f5d" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" dependencies = [ "pest", -] - -[[package]] -name = "pest_stack_guard" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97e6cd2217057fa86b6eaa512917e58089d875b957a31e35e7ab729977e0a5f2" -dependencies = [ - "psm", - "stacker", -] - -[[package]] -name = "phc" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892" -dependencies = [ - "base64ct", - "ctutils", - "getrandom 0.4.3", + "sha2", ] [[package]] @@ -4039,7 +3619,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared 0.11.3", - "rand 0.8.8", + "rand 0.8.5", ] [[package]] @@ -4052,7 +3632,7 @@ dependencies = [ "phf_shared 0.11.3", "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -4087,9 +3667,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pin-utils" -version = "0.1.1" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13bee6c73da26345c729282832b60b0363cf3dd9f4bfd81d8551b7a1c889a113" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "piper" @@ -4108,9 +3688,24 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" dependencies = [ - "der 0.7.10", - "pkcs8 0.10.2", - "spki 0.7.3", + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs5" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e847e2c91a18bfa887dd028ec33f2fe6f25db77db3619024764914affe8b69a6" +dependencies = [ + "aes", + "cbc", + "der", + "pbkdf2", + "scrypt", + "sha2", + "spki", ] [[package]] @@ -4119,25 +3714,17 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der 0.7.10", - "spki 0.7.3", -] - -[[package]] -name = "pkcs8" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" -dependencies = [ - "der 0.8.3", - "spki 0.8.1", + "der", + "pkcs5", + "rand_core 0.6.4", + "spki", ] [[package]] name = "pkg-config" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "polling" @@ -4155,33 +3742,33 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.15.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.8" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" dependencies = [ "portable-atomic", ] [[package]] name = "potential_utf" -version = "0.1.6" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] [[package]] name = "powerfmt" -version = "0.2.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "ppv-lite86" @@ -4194,9 +3781,9 @@ dependencies = [ [[package]] name = "pq-sys" -version = "0.7.8" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "297a95d6f2378891f949d9556723e15140e50d29eac07458dabcee8688995b1d" +checksum = "574ddd6a267294433f140b02a726b0640c43cf7c6f717084684aaa3b285aba61" dependencies = [ "libc", "pkg-config", @@ -4204,28 +3791,13 @@ dependencies = [ ] [[package]] -name = "prefix-trie" -version = "0.8.4" +name = "prettyplease" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ - "either", - "ipnet", - "num-traits", -] - -[[package]] -name = "primefield" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" -dependencies = [ - "crypto-bigint 0.7.10", - "crypto-common 0.2.2", - "ff 0.14.0", - "rand_core 0.10.1", - "subtle", - "zeroize", + "proc-macro2", + "syn", ] [[package]] @@ -4234,36 +3806,14 @@ version = "0.13.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" dependencies = [ - "elliptic-curve 0.13.8", -] - -[[package]] -name = "primeorder" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" -dependencies = [ - "elliptic-curve 0.14.3", - "once_cell", - "primefield", - "serdect", - "wnaf", -] - -[[package]] -name = "proc-macro-crate" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" -dependencies = [ - "toml_edit 0.25.17+spec-1.1.0", + "elliptic-curve", ] [[package]] name = "proc-macro2" -version = "1.0.107" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -4276,38 +3826,17 @@ checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", "version_check", "yansi", ] -[[package]] -name = "proc-macro2-diagnostics" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df921e1ef974ebc8dd9effdb0c037a96928ae8cbf871438e02d6fd67204b595e" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", - "version_check", -] - [[package]] name = "psl-types" version = "2.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" -[[package]] -name = "psm" -version = "0.1.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "200b9ff220857e53e184257720a14553b2f4aa02577d2ed9842d45d4b9654810" -dependencies = [ - "cc", -] - [[package]] name = "publicsuffix" version = "2.3.0" @@ -4341,19 +3870,84 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quick-xml" -version = "0.42.0" +version = "0.37.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" dependencies = [ "memchr", "serde", ] [[package]] -name = "quote" -version = "1.0.47" +name = "quick-xml" +version = "0.38.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls 0.23.37", + "socket2 0.6.3", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.3", + "ring", + "rustc-hash", + "rustls 0.23.37", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.3", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -4383,15 +3977,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93" dependencies = [ "log", - "parking_lot 0.12.5", + "parking_lot", "scheduled-thread-pool", ] [[package]] name = "rand" -version = "0.8.8" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -4400,9 +3994,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.5" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -4410,13 +4004,13 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.3" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ "chacha20", - "getrandom 0.4.3", - "rand_core 0.10.1", + "getrandom 0.4.2", + "rand_core 0.10.0", ] [[package]] @@ -4459,9 +4053,9 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.10.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" [[package]] name = "raw-cpuid" @@ -4469,7 +4063,7 @@ version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" dependencies = [ - "bitflags 2.13.2", + "bitflags", ] [[package]] @@ -4478,49 +4072,34 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.2", -] - -[[package]] -name = "redox_syscall" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "580c875845b41ecb1564ad1ae4703044cef774145e3d7a94dc2b3fa7c97868fd" -dependencies = [ - "bitflags 2.13.2", + "bitflags", ] [[package]] name = "ref-cast" -version = "1.0.27" +version = "1.0.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.27" +version = "1.0.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn", ] -[[package]] -name = "ref-swap" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09c30c54dffee5b40af088d5d50aa3455c91a0127164b51f0215efc4cb28fb3c" - [[package]] name = "regex" -version = "1.13.1" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -4530,9 +4109,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.18" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -4547,9 +4126,9 @@ checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" [[package]] name = "regex-syntax" -version = "0.8.11" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "reopen" @@ -4563,91 +4142,57 @@ dependencies = [ ] [[package]] -name = "reqsign-aws-core" -version = "3.2.0" +name = "reqsign" +version = "0.16.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38c902d8ec745bd9eadd1abbcbe227f4c1fc2926b2ec8929f89234af3114500" +checksum = "43451dbf3590a7590684c25fb8d12ecdcc90ed3ac123433e500447c7d77ed701" dependencies = [ - "bytes", + "anyhow", + "async-trait", + "base64 0.22.1", + "chrono", "form_urlencoded", + "getrandom 0.2.17", "hex", - "http 1.5.0", + "hmac", + "home", + "http 1.4.0", + "jsonwebtoken 9.3.1", "log", + "once_cell", "percent-encoding", - "quick-xml", - "reqsign-core", + "quick-xml 0.37.5", + "rand 0.8.5", + "reqwest", + "rsa", "rust-ini", "serde", "serde_json", - "serde_urlencoded", - "sha1 0.11.0", -] - -[[package]] -name = "reqsign-aws-v4" -version = "3.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "906bfd028f96bfe73fdb892ce26f3b3f99b4f30feb9bd024be58bd6657f68b2b" -dependencies = [ - "bytes", - "http 1.5.0", - "log", - "quick-xml", - "reqsign-aws-core", - "reqsign-core", - "serde", -] - -[[package]] -name = "reqsign-core" -version = "3.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85fed85c436b6b920e4b36310f699a77771a91e38ae483df48447bd7d162671c" -dependencies = [ - "anyhow", - "asyncband", - "base64 0.23.1", - "bytes", - "hex", - "hmac 0.13.0", - "http 1.5.0", - "jiff", - "log", - "percent-encoding", - "sha1 0.11.0", - "sha2 0.11.1", - "windows-sys 0.61.2", -] - -[[package]] -name = "reqsign-file-read-tokio" -version = "3.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "586a8e198057773f9616901fd723a4bc186812b9765bc0f20368661d6169d906" -dependencies = [ - "anyhow", - "reqsign-core", + "sha1", + "sha2", "tokio", + "toml 0.8.23", ] [[package]] name = "reqwest" -version = "0.13.5" +version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64 0.23.1", + "base64 0.22.1", "bytes", "cookie", "cookie_store", "encoding_rs", + "futures-channel", "futures-core", "futures-util", "h2", - "http 1.5.0", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.9.0", "hyper-rustls", "hyper-util", "js-sys", @@ -4655,15 +4200,16 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rustls", + "quinn", + "rustls 0.23.37", + "rustls-native-certs", "rustls-pki-types", - "rustls-platform-verifier", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tokio-util", "tower", "tower-http", @@ -4673,6 +4219,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", + "webpki-roots", ] [[package]] @@ -4687,20 +4234,10 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" dependencies = [ - "hmac 0.12.1", + "hmac", "subtle", ] -[[package]] -name = "rfc6979" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" -dependencies = [ - "crypto-bigint 0.7.10", - "hmac 0.13.0", -] - [[package]] name = "ring" version = "0.17.14" @@ -4735,28 +4272,27 @@ dependencies = [ [[package]] name = "rocket" -version = "0.5.2" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c17431360eb641a861453b57107ee1b425a799f49ec1b0fe9b8a45fe437447ce" +checksum = "a516907296a31df7dc04310e7043b61d71954d703b603cc6867a026d7e72d73f" dependencies = [ "async-stream", "async-trait", + "atomic 0.5.3", "binascii", "bytes", "either", "figment", "futures", - "http-body-util", - "hyper-util", - "indexmap 2.14.2", + "indexmap 2.14.0", "log", "memchr", "multer", "num_cpus", - "parking_lot 0.12.5", + "parking_lot", "pin-project-lite", + "rand 0.8.5", "ref-cast", - "ref-swap", "rocket_codegen", "rocket_http", "serde", @@ -4774,50 +4310,48 @@ dependencies = [ [[package]] name = "rocket_codegen" -version = "0.5.2" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "352bd0b773ca6f5bea6cd18efc2fd7f04fcd34c75c242dad6ef1bbd729cc619d" +checksum = "575d32d7ec1a9770108c879fc7c47815a80073f96ca07ff9525a94fcede1dd46" dependencies = [ "devise", "glob", - "indexmap 2.14.2", + "indexmap 2.14.0", "proc-macro2", - "proc-macro2-diagnostics 0.11.0", "quote", "rocket_http", - "syn 3.0.9", + "syn", "unicode-xid", "version_check", ] [[package]] name = "rocket_http" -version = "0.5.2" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "847886ef963dff77126f110fd2b4401f5149440ab32973bef16c4a2cc83e525f" +checksum = "e274915a20ee3065f611c044bd63c40757396b6dbc057d6046aec27f14f882b9" dependencies = [ "cookie", "either", "futures", "http 0.2.12", - "hyper", - "indexmap 2.14.2", + "hyper 0.14.32", + "indexmap 2.14.0", "log", "memchr", - "pear 0.3.0", + "pear", "percent-encoding", "pin-project-lite", "ref-cast", - "rustls", - "rustls-pki-types", - "rustls-webpki", + "rustls 0.21.12", + "rustls-pemfile", "serde", "smallvec", "stable-pattern", "state", "time", "tokio", - "tokio-rustls", + "tokio-rustls 0.24.1", "uncased", ] @@ -4833,13 +4367,13 @@ dependencies = [ [[package]] name = "rpassword" -version = "7.5.4" +version = "7.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196" +checksum = "66d4c8b64f049c6721ec8ccec37ddfc3d641c4a7fca57e8f2a89de509c73df39" dependencies = [ "libc", "rtoolbox", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4848,37 +4382,39 @@ version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ - "const-oid 0.9.6", - "digest 0.10.7", + "const-oid", + "digest", "num-bigint-dig", "num-integer", "num-traits", "pkcs1", - "pkcs8 0.10.2", + "pkcs8", "rand_core 0.6.4", - "signature 2.2.0", - "spki 0.7.3", + "sha2", + "signature", + "spki", "subtle", "zeroize", ] [[package]] name = "rsqlite-vfs" -version = "0.2.0" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "353b74f9810d5b085b6c42c9d86a5f0fdb795553235cdd48753b32e3809d18a7" +checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" dependencies = [ - "thiserror 2.0.21", + "hashbrown 0.16.1", + "thiserror 2.0.18", ] [[package]] name = "rtoolbox" -version = "0.0.6" +version = "0.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a1efe12a1469752d0e6ff5ebec0b6ef4924cc5c4c71046b0ec730040535819d" +checksum = "a7cc970b249fbe527d6e02e0a227762c9108b2f49d81094fe357ffc6d14d7f6f" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4891,6 +4427,12 @@ dependencies = [ "ordered-multimap", ] +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustc_version" version = "0.4.1" @@ -4911,11 +4453,11 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.6" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.13.2", + "bitflags", "errno", "libc", "linux-raw-sys", @@ -4924,24 +4466,36 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.46" +version = "0.21.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ "log", "once_cell", "ring", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.103.11", "subtle", "zeroize", ] [[package]] name = "rustls-native-certs" -version = "0.8.5" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -4950,46 +4504,39 @@ dependencies = [ ] [[package]] -name = "rustls-pki-types" -version = "1.15.1" +name = "rustls-pemfile" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" dependencies = [ + "base64 0.21.7", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", "zeroize", ] [[package]] -name = "rustls-platform-verifier" -version = "0.7.1" +name = "rustls-webpki" +version = "0.101.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" dependencies = [ - "core-foundation 0.10.1", - "core-foundation-sys", - "jni", - "log", - "once_cell", - "rustls", - "rustls-native-certs", - "rustls-platform-verifier-android", - "rustls-webpki", - "security-framework", - "security-framework-sys", - "webpki-root-certs", - "windows-sys 0.61.2", + "ring", + "untrusted", ] -[[package]] -name = "rustls-platform-verifier-android" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" - [[package]] name = "rustls-webpki" -version = "0.103.16" +version = "0.103.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +checksum = "20a6af516fea4b20eccceaf166e8aa666ac996208e8a644ce3ef5aa783bc7cd4" dependencies = [ "ring", "rustls-pki-types", @@ -4998,9 +4545,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.23" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" @@ -5008,6 +4555,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + [[package]] name = "same-file" version = "1.0.6" @@ -5019,20 +4575,20 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.30" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ "windows-sys 0.61.2", ] [[package]] name = "scheduled-thread-pool" -version = "0.2.8" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f304b290c05b371b2aa1e8718242ff0394530f9334ecedcb9ae51330f37fc1ba" +checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19" dependencies = [ - "parking_lot 0.13.0", + "parking_lot", ] [[package]] @@ -5049,9 +4605,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.3" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ "dyn-clone", "ref-cast", @@ -5071,30 +4627,37 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "pbkdf2", + "salsa20", + "sha2", +] + +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "sec1" version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ - "base16ct 0.2.0", - "der 0.7.10", + "base16ct", + "der", "generic-array", - "pkcs8 0.10.2", - "subtle", - "zeroize", -] - -[[package]] -name = "sec1" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" -dependencies = [ - "base16ct 1.0.0", - "ctutils", - "der 0.8.3", - "hybrid-array", + "pkcs8", "subtle", "zeroize", ] @@ -5105,7 +4668,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.13.2", + "bitflags", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -5130,9 +4693,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.229" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", "serde_derive", @@ -5160,29 +4723,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.229" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.229" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn", ] [[package]] name = "serde_json" -version = "1.0.154" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", @@ -5222,9 +4785,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.1.2" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ "serde_core", ] @@ -5243,19 +4806,17 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.24.0" +version = "3.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f" +checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" dependencies = [ - "base64 0.23.1", - "bs58", + "base64 0.22.1", "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.14.2", - "jiff", + "indexmap 2.14.0", "schemars 0.9.0", - "schemars 1.2.3", + "schemars 1.2.1", "serde_core", "serde_json", "serde_with_macros", @@ -5264,46 +4825,25 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.24.0" +version = "3.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49" +checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" dependencies = [ - "darling 0.24.1", + "darling 0.23.0", "proc-macro2", "quote", - "syn 3.0.9", -] - -[[package]] -name = "serdect" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" -dependencies = [ - "base16ct 1.0.0", - "serde", + "syn", ] [[package]] name = "sha1" -version = "0.10.7" +version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest 0.10.7", -] - -[[package]] -name = "sha1" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.1", - "digest 0.11.3", + "digest", ] [[package]] @@ -5314,18 +4854,7 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest 0.10.7", -] - -[[package]] -name = "sha2" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.1", - "digest 0.11.3", + "digest", ] [[package]] @@ -5339,9 +4868,9 @@ dependencies = [ [[package]] name = "shlex" -version = "2.0.1" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook" @@ -5369,41 +4898,15 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest 0.10.7", + "digest", "rand_core 0.6.4", ] -[[package]] -name = "signature" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" -dependencies = [ - "digest 0.11.3", - "rand_core 0.10.1", -] - [[package]] name = "simd-adler32" -version = "0.3.10" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" - -[[package]] -name = "simd_cesu8" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" -dependencies = [ - "rustc_version", - "simdutf8", -] - -[[package]] -name = "simdutf8" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "simple_asn1" @@ -5413,15 +4916,15 @@ checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" dependencies = [ "num-bigint", "num-traits", - "thiserror 2.0.21", + "thiserror 2.0.18", "time", ] [[package]] name = "siphasher" -version = "1.0.4" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" [[package]] name = "slab" @@ -5431,15 +4934,25 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.16.4" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.5" +version = "0.5.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", "windows-sys 0.61.2", @@ -5447,23 +4960,17 @@ dependencies = [ [[package]] name = "spin" -version = "0.9.9" +version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" - -[[package]] -name = "spin" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" [[package]] name = "spinning_top" -version = "0.3.1" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf02766a1eb909bc7653557c66b384e44edc3ad979e6257c6173c65ea51bc7d" +checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" dependencies = [ - "lock_api 0.4.14", + "lock_api", ] [[package]] @@ -5473,27 +4980,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der 0.7.10", -] - -[[package]] -name = "spki" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9" -dependencies = [ - "base64ct", - "der 0.8.3", + "der", ] [[package]] name = "sqlite-wasm-rs" -version = "0.6.2" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36bd8e29bf08189a005b2df1df99823aa0861cabe9044e5f31ed54ca292ee618" +checksum = "2f4206ed3a67690b9c29b77d728f6acc3ce78f16bf846d83c94f76400320181b" dependencies = [ "cc", + "js-sys", "rsqlite-vfs", + "wasm-bindgen", ] [[package]] @@ -5511,19 +5010,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" -[[package]] -name = "stacker" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" -dependencies = [ - "cc", - "cfg-if", - "libc", - "psm", - "windows-sys 0.61.2", -] - [[package]] name = "state" version = "0.6.0" @@ -5547,11 +5033,11 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "svg-hush" -version = "0.9.7" +version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e690409a034dc81758d2986dcc0e01ffe4a77c65902a120777f5182dd40b1f7f" +checksum = "929223e80cdcec0482207576ea09692dd71b2b559057fc172e292ecec9a97559" dependencies = [ - "base64 0.23.1", + "base64 0.22.1", "data-url", "quick-error", "url", @@ -5560,20 +5046,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.119" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -5597,18 +5072,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", -] - -[[package]] -name = "synstructure" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", + "syn", ] [[package]] @@ -5629,7 +5093,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.13.2", + "bitflags", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -5657,7 +5121,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", @@ -5674,11 +5138,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.21" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.21", + "thiserror-impl 2.0.18", ] [[package]] @@ -5689,36 +5153,46 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] name = "thiserror-impl" -version = "2.0.21" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn", ] [[package]] name = "thread_local" -version = "1.1.10" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" dependencies = [ "cfg-if", ] [[package]] -name = "time" -version = "0.3.55" +name = "threadpool" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" +dependencies = [ + "num_cpus", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", + "itoa", "libc", "num-conv", "num_threads", @@ -5730,15 +5204,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.9" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.32" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -5755,9 +5229,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.5" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -5765,53 +5239,72 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.13.3" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.54.1" +version = "1.51.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" dependencies = [ "bytes", "libc", "mio", - "parking_lot 0.12.5", + "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.3", "tokio-macros", "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.7.2" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn", ] [[package]] name = "tokio-rustls" -version = "0.26.6" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" dependencies = [ - "rustls", + "rustls 0.21.12", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls 0.23.37", "tokio", ] [[package]] name = "tokio-stream" -version = "0.1.19" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" dependencies = [ "futures-core", "pin-project-lite", @@ -5832,15 +5325,14 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.20" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", "futures-io", "futures-sink", - "libc", "pin-project-lite", "tokio", ] @@ -5854,7 +5346,7 @@ dependencies = [ "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", - "toml_edit 0.22.27", + "toml_edit", ] [[package]] @@ -5864,7 +5356,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ "serde_core", - "serde_spanned 1.1.2", + "serde_spanned 1.1.1", "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "winnow 0.7.15", @@ -5888,22 +5380,13 @@ dependencies = [ "serde_core", ] -[[package]] -name = "toml_datetime" -version = "1.1.2+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" -dependencies = [ - "serde_core", -] - [[package]] name = "toml_edit" version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap 2.14.2", + "indexmap 2.14.0", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", @@ -5911,25 +5394,13 @@ dependencies = [ "winnow 0.7.15", ] -[[package]] -name = "toml_edit" -version = "0.25.17+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" -dependencies = [ - "indexmap 2.14.2", - "toml_datetime 1.1.2+spec-1.1.0", - "toml_parser", - "winnow 1.0.4", -] - [[package]] name = "toml_parser" -version = "1.1.5+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.4", + "winnow 1.0.1", ] [[package]] @@ -5944,10 +5415,10 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8e43134db17199f7f721803383ac5854edd0d3d523cc34dba321d6acfbe76c3" dependencies = [ - "digest 0.10.7", - "hmac 0.12.1", - "sha1 0.10.7", - "sha2 0.10.9", + "digest", + "hmac", + "sha1", + "sha2", ] [[package]] @@ -5967,25 +5438,25 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.11" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "async-compression", - "bitflags 2.13.2", + "bitflags", "bytes", "futures-core", "futures-util", - "http 1.5.0", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", + "iri-string", "pin-project-lite", "tokio", "tokio-util", "tower", "tower-layer", "tower-service", - "url", ] [[package]] @@ -6020,7 +5491,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -6077,11 +5548,11 @@ dependencies = [ "byteorder", "bytes", "data-encoding", - "http 1.5.0", + "http 1.4.0", "httparse", "log", - "rand 0.8.8", - "sha1 0.10.7", + "rand 0.8.5", + "sha1", "thiserror 1.0.69", "url", "utf-8", @@ -6089,9 +5560,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.20.1" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "ubyte" @@ -6120,15 +5591,15 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.27" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.13.3" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-xid" @@ -6169,26 +5640,17 @@ checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" [[package]] name = "utf8_iter" -version = "1.1.0" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af3bd9d3e571c26fcce9d033b87ea5ab061b8d2ffe9b7a5675d810d68f03edf0" -dependencies = [ - "utf_types", -] - -[[package]] -name = "utf_types" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.29.0" +version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ - "getrandom 0.4.3", + "getrandom 0.4.2", "js-sys", "serde_core", "wasm-bindgen", @@ -6202,14 +5664,15 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] name = "value-bag" -version = "1.14.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2799ffb329a792ecfd902b71306c8a815a6ef1c0470fa9953a6aa4d4cecbe511" +checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" [[package]] name = "vaultwarden" version = "1.0.0" dependencies = [ + "anyhow", "argon2", "aws-config", "aws-credential-types", @@ -6237,37 +5700,32 @@ dependencies = [ "handlebars", "hickory-resolver", "html5gum", - "http 1.5.0", - "ipnet", + "http 1.4.0", "job_scheduler_ng", - "jsonwebtoken", + "jsonwebtoken 10.3.0", "lettre", "libsqlite3-sys", "log", "macros", "mimalloc", "moka", - "nix", "num-derive", "num-traits", "opendal", - "opendal-http-transport-reqwest", "openidconnect", "openssl", - "pastey 0.2.3", + "pastey 0.2.1", "percent-encoding", "pico-args", - "rand 0.10.3", + "rand 0.10.1", "regex", - "reqsign-aws-v4", - "reqsign-core", + "reqsign", "reqwest", "ring", "rmpv", "rocket", "rocket_ws", "rpassword", - "rustls", "semver", "serde", "serde_json", @@ -6318,9 +5776,9 @@ dependencies = [ [[package]] name = "want" -version = "0.3.2" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" dependencies = [ "try-lock", ] @@ -6333,20 +5791,30 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.4+wasi-0.2.12" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.130" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" dependencies = [ "cfg-if", + "once_cell", "rustversion", "wasm-bindgen-macro", "wasm-bindgen-shared", @@ -6354,20 +5822,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.80" +version = "0.4.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e14e0327cb5368ad60ccb22d04a9827a693c95a926d8d125e9fa39e0baec501" +checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" dependencies = [ "js-sys", - "tokio", "wasm-bindgen", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.130" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -6375,31 +5842,53 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.130" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.9", + "syn", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.130" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" dependencies = [ "unicode-ident", ] [[package]] -name = "wasm-streams" -version = "0.5.0" +name = "wasm-encoder" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.14.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" dependencies = [ "futures-util", "js-sys", @@ -6409,10 +5898,22 @@ dependencies = [ ] [[package]] -name = "web-sys" -version = "0.3.107" +name = "wasmparser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7d179596ff5ec0f6de4db64d54011bde6289cc49603d3a6e4ce3f329207ab88" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" dependencies = [ "js-sys", "wasm-bindgen", @@ -6430,9 +5931,9 @@ dependencies = [ [[package]] name = "webauthn-attestation-ca" -version = "0.5.5" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6475c0bbd1a3f04afaa3e98880408c5be61680c5e6bd3c6f8c250990d5d3e18e" +checksum = "fafcf13f7dc1fb292ed4aea22cdd3757c285d7559e9748950ee390249da4da6b" dependencies = [ "base64urlsafedata", "openssl", @@ -6444,9 +5945,9 @@ dependencies = [ [[package]] name = "webauthn-rs" -version = "0.5.5" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c548915e0e92ee946bbf2aecf01ea21bef53d974b0793cc6732ba81a03fc422" +checksum = "1b24d082d3360258fefb6ffe56123beef7d6868c765c779f97b7a2fcf06727f8" dependencies = [ "base64urlsafedata", "serde", @@ -6458,9 +5959,9 @@ dependencies = [ [[package]] name = "webauthn-rs-core" -version = "0.5.5" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "296d2d501feb715d80b8e186fb88bab1073bca17f460303a1013d17b673bea6a" +checksum = "15784340a24c170ce60567282fb956a0938742dbfbf9eff5df793a686a009b8b" dependencies = [ "base64 0.21.7", "base64urlsafedata", @@ -6469,7 +5970,7 @@ dependencies = [ "nom 7.1.3", "openssl", "openssl-sys", - "rand 0.9.5", + "rand 0.9.3", "rand_chacha 0.9.0", "serde", "serde_cbor_2", @@ -6485,9 +5986,9 @@ dependencies = [ [[package]] name = "webauthn-rs-proto" -version = "0.5.5" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c37393beac9c1ed1ca6dbb30b1e01783fb316ab3a45d90ecd48c99052dd7ef1e" +checksum = "16a1fb2580ce73baa42d3011a24de2ceab0d428de1879ece06e02e8c416e497c" dependencies = [ "base64 0.21.7", "base64urlsafedata", @@ -6497,19 +5998,19 @@ dependencies = [ ] [[package]] -name = "webpki-root-certs" -version = "1.0.11" +name = "webpki-roots" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" dependencies = [ "rustls-pki-types", ] [[package]] name = "which" -version = "8.0.6" +version = "8.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bae2f2b2b816647a1cab1acc91f5bd20812d53cb344382635ec2181940c8034f" +checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459" dependencies = [ "libc", ] @@ -6581,7 +6082,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -6592,7 +6093,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -6639,6 +6140,24 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -6672,13 +6191,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -6691,6 +6227,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -6703,6 +6245,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -6715,12 +6263,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -6733,6 +6293,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -6745,6 +6311,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -6757,6 +6329,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -6769,6 +6347,21 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.6.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e90edd2ac1aa278a5c4599b1d89cf03074b610800f866d4026dc199d7929a28" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "0.7.15" @@ -6780,36 +6373,103 @@ dependencies = [ [[package]] name = "winnow" -version = "1.0.4" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" -dependencies = [ - "memchr", -] +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" [[package]] name = "wit-bindgen" -version = "0.57.1" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] [[package]] -name = "wnaf" -version = "0.14.1" +name = "wit-bindgen-core" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "795ca18b3fdb5e62bf982199278341ddcf7ebf7d32e25e212ad05d496e95f6fa" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ - "ff 0.14.0", - "group 0.14.0", - "hybrid-array", - "primefield", + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.14.0", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap 2.14.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.14.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", ] [[package]] name = "writeable" -version = "0.6.4" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "x509-parser" @@ -6828,21 +6488,11 @@ dependencies = [ "time", ] -[[package]] -name = "xattr" -version = "1.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "963d6fab92d5504b971dc8bd90ca743c2575e590bc8071ea5bc571598667ae08" -dependencies = [ - "libc", - "rustix", -] - [[package]] name = "xml" -version = "1.4.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f45bb2c13fec6a6cb4c0f76a7e94839e110a14ec803ec2940777a94c347bc52" +checksum = "b8aa498d22c9bbaf482329839bc5620c46be275a19a812e9a22a2b07529a642a" [[package]] name = "xmlparser" @@ -6861,9 +6511,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.4" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -6872,95 +6522,84 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.4" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", - "synstructure 0.14.0", + "syn", + "synstructure", ] [[package]] name = "yubico_ng" -version = "1.0.0" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "563eb0ab41031e758446e3737231541bb4556d6af1e893e94faa09c989f794af" +checksum = "929981f5b46b8fb8ee54b144de6b55c3a94fbe26635ee25b0e126e184250867c" dependencies = [ - "base64 0.23.1", + "base64 0.22.1", "form_urlencoded", - "getrandom 0.4.3", - "hmac 0.13.0", - "sha1 0.11.0", + "futures", + "hmac", + "rand 0.9.3", + "reqwest", + "sha1", + "threadpool", ] [[package]] name = "zerocopy" -version = "0.8.63" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.63" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] name = "zerofrom" -version = "0.1.8" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.8" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", - "synstructure 0.14.0", + "syn", + "synstructure", ] [[package]] name = "zeroize" -version = "1.9.1" +version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zerotrie" -version = "0.2.5" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -6969,9 +6608,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.9" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -6980,50 +6619,44 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.7" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn", ] -[[package]] -name = "zlib-rs" -version = "0.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" - [[package]] name = "zmij" -version = "1.0.23" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" [[package]] name = "zstd" -version = "0.14.3" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db3ae9bf90af6e40197bef767eeefc46ad6397420fe873830bd85e5fbbfb3c70" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" dependencies = [ "zstd-safe", ] [[package]] name = "zstd-safe" -version = "8.2.0" +version = "7.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ded520f16b29e1a9b771c722221b2b0a0398c2316d9bb3c42b458a5414d7434b" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" dependencies = [ "zstd-sys", ] [[package]] name = "zstd-sys" -version = "2.1.2+zstd.1.5.7" +version = "2.0.16+zstd.1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "481ada9c543b2323cbb7bbdbb5249177fbf0188ed959ed2c15e4b22421c84ed3" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" dependencies = [ "cc", "pkg-config", diff --git a/Cargo.toml b/Cargo.toml index 1717ec63..1ba9ddfd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace.package] -edition = "2024" -rust-version = "1.97.1" +edition = "2021" +rust-version = "1.92.0" license = "AGPL-3.0-only" repository = "https://github.com/dani-garcia/vaultwarden" publish = false @@ -12,7 +12,9 @@ members = ["macros"] name = "vaultwarden" version = "1.0.0" authors = ["Daniel García "] +readme = "README.md" build = "build.rs" +resolver = "2" repository.workspace = true edition.workspace = true rust-version.workspace = true @@ -22,32 +24,20 @@ publish.workspace = true [features] default = [ # "sqlite", - # "sqlite_system", # "mysql", # "postgresql", ] # Empty to keep compatibility, prefer to set USE_SYSLOG=true enable_syslog = [] -# Please enable at least one of these DB backends. mysql = ["diesel/mysql", "diesel_migrations/mysql"] postgresql = ["diesel/postgres", "diesel_migrations/postgres"] -sqlite_system = ["diesel/sqlite", "diesel_migrations/sqlite"] # Dynamically link SQLite -sqlite = ["sqlite_system", "libsqlite3-sys/bundled"] # Statically link SQLite into the binary instead of dynamically. +sqlite = ["diesel/sqlite", "diesel_migrations/sqlite", "dep:libsqlite3-sys"] # Enable to use a vendored and statically linked openssl vendored_openssl = ["openssl/vendored"] # Enable MiMalloc memory allocator to replace the default malloc # This can improve performance for Alpine builds enable_mimalloc = ["dep:mimalloc"] -s3 = [ - "opendal/services-s3", - "dep:opendal-http-transport-reqwest", - "dep:aws-config", - "dep:aws-credential-types", - "dep:aws-smithy-runtime-api", - "dep:http", - "dep:reqsign-aws-v4", - "dep:reqsign-core", -] +s3 = ["opendal/services-s3", "dep:aws-config", "dep:aws-credential-types", "dep:aws-smithy-runtime-api", "dep:anyhow", "dep:http", "dep:reqsign"] # OIDC specific features oidc-accept-rfc3339-timestamps = ["openidconnect/accept-rfc3339-timestamps"] @@ -58,7 +48,6 @@ oidc-accept-string-booleans = ["openidconnect/accept-string-booleans"] unstable = [] [target."cfg(unix)".dependencies] -nix = { version = "0.31.3", features = ["fs"] } # Logging syslog = "7.0.0" @@ -66,212 +55,154 @@ syslog = "7.0.0" macros = { path = "./macros" } # Logging -log = "0.4.35" +log = "0.4.29" fern = { version = "0.7.1", features = ["syslog-7", "reopen-1"] } -# We need the `log` feature for `tracing` to enable logging for several crates to work, like lettre or webauthn-rs -tracing = { version = "0.1.44", features = ["log"] } +tracing = { version = "0.1.44", features = ["log"] } # Needed to have lettre and webauthn-rs trace logging to work # A `dotenv` implementation for Rust dotenvy = { version = "0.15.7", default-features = false } # Numerical libraries num-traits = "0.2.19" -num-derive = "0.5.1" -bigdecimal = "0.4.11" +num-derive = "0.4.2" +bigdecimal = "0.4.10" # Web framework -rocket = { version = "0.5.2", default-features = false, features = ["json", "tls"] } -rocket_ws = { version = "0.1.1" } +rocket = { version = "0.5.1", features = ["tls", "json"], default-features = false } +rocket_ws = { version ="0.1.1" } # WebSockets libraries rmpv = "1.3.1" # MessagePack library # Concurrent HashMap used for WebSocket messaging and favicons -dashmap = "6.2.1" +dashmap = "6.1.0" # Async futures -futures = "0.3.34" -tokio = { version = "1.54.1", features = [ - "fs", - "io-util", - "net", - "parking_lot", - "rt-multi-thread", - "signal", - "time", -] } -tokio-util = { version = "0.7.20", features = ["compat"] } +futures = "0.3.32" +tokio = { version = "1.51.1", features = ["rt-multi-thread", "fs", "io-util", "parking_lot", "time", "signal", "net"] } +tokio-util = { version = "0.7.18", features = ["compat"]} # A generic serialization/deserialization framework -serde = { version = "1.0.229", features = ["derive"] } -serde_json = "1.0.154" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.149" # A safe, extensible ORM and Query builder -diesel = { version = "2.3.14", features = ["chrono", "r2d2", "numeric", "64-column-tables"] } -diesel_migrations = "2.3.2" +# Currently pinned diesel to v2.3.3 as newer version break MySQL/MariaDB compatibility +diesel = { version = "2.3.7", features = ["chrono", "r2d2", "numeric"] } +diesel_migrations = "2.3.1" -derive_more = { version = "2.1.1", features = [ - "as_ref", - "deref", - "display", - "from", - "into", -] } -diesel-derive-newtype = "2.1.3" +derive_more = { version = "2.1.1", features = ["from", "into", "as_ref", "deref", "display"] } +diesel-derive-newtype = "2.1.2" -# SQLite, statically bundled unless the `sqlite_system` feature is enabled -libsqlite3-sys = { version = "0.38.2", optional = true } +# Bundled/Static SQLite +libsqlite3-sys = { version = "0.36.0", features = ["bundled"], optional = true } # Crypto-related libraries -rand = "0.10.3" +rand = "0.10.1" ring = "0.17.14" -rustls = { version = "0.23.46", features = ["ring", "std"], default-features = false } subtle = "2.6.1" # UUID generation -uuid = { version = "1.29.0", features = ["v4"] } +uuid = { version = "1.23.0", features = ["v4"] } # Date and time libraries -chrono = { version = "0.4.45", default-features = false, features = ["clock", "serde"] } +chrono = { version = "0.4.44", features = ["clock", "serde"], default-features = false } chrono-tz = "0.10.4" -time = "0.3.55" +time = "0.3.47" # Job scheduler -job_scheduler_ng = "2.5.0" +job_scheduler_ng = "2.4.0" # Data encoding library Hex/Base32/Base64 -data-encoding = "2.11.1" +data-encoding = "2.10.0" # JWT library -jsonwebtoken = { version = "11.1.0", default-features = false, features = ["rust_crypto", "use_pem"] } +jsonwebtoken = { version = "10.3.0", features = ["use_pem", "rust_crypto"], default-features = false } # TOTP library totp-lite = "2.0.1" # Yubico Library -yubico_ng = { version = "1.0.0", default-features = false } +yubico = { package = "yubico_ng", version = "0.14.1", features = ["online-tokio"], default-features = false } # WebAuthn libraries # danger-allow-state-serialisation is needed to save the state in the db # danger-credential-internals is needed to support U2F to Webauthn migration -webauthn-rs = { version = "0.5.5", features = ["danger-allow-state-serialisation", "danger-credential-internals"] } -webauthn-rs-proto = "0.5.5" -webauthn-rs-core = "0.5.5" +webauthn-rs = { version = "0.5.4", features = ["danger-allow-state-serialisation", "danger-credential-internals"] } +webauthn-rs-proto = "0.5.4" +webauthn-rs-core = "0.5.4" # Handling of URL's for WebAuthn and favicons url = "2.5.8" # Email libraries -lettre = { version = "0.11.23", default-features = false, features = [ - # Misc - "tracing", - "serde", - "builder", - "hostname", - # TLS/Security - "ring", - "rustls-native-certs", - "tokio1-rustls", - # Transport - "smtp-transport", - "sendmail-transport", -] } +lettre = { version = "0.11.21", features = ["smtp-transport", "sendmail-transport", "builder", "serde", "hostname", "tracing", "tokio1-rustls", "ring", "rustls-native-certs"], default-features = false } percent-encoding = "2.3.2" # URL encoding library used for URL's in the emails email_address = "0.2.9" # HTML Template library -handlebars = { version = "6.4.4", features = ["dir_source"] } +handlebars = { version = "6.4.0", features = ["dir_source"] } # HTTP client (Used for favicons, version check, DUO and HIBP API) -reqwest = { version = "0.13.5", default-features = false, features = [ - # Misc - "charset", - "cookies", - "http2", - "json", - "form", - "rustls-no-provider", - "stream", - # Compression - "brotli", - "deflate", - "gzip", - "zstd", - # Proxy - "socks", - "system-proxy", -] } -hickory-resolver = "0.26.3" +reqwest = { version = "0.12.28", features = ["rustls-tls", "rustls-tls-native-roots", "stream", "json", "deflate", "gzip", "brotli", "zstd", "socks", "cookies", "charset", "http2", "system-proxy"], default-features = false} +hickory-resolver = "0.25.2" # Favicon extraction libraries -html5gum = "0.9.0" -regex = { version = "1.13.1", default-features = false, features = [ - "perf", - "std", - "unicode-perl", -] } +html5gum = "0.8.3" +regex = { version = "1.12.3", features = ["std", "perf", "unicode-perl"], default-features = false } data-url = "0.3.2" -bytes = "1.12.1" -svg-hush = "0.9.7" +bytes = "1.11.1" +svg-hush = "0.9.6" # Cache function results (Used for version check and favicon fetching) -cached = { version = "4.1.0", features = ["async"] } +cached = { version = "0.59.0", features = ["async"] } # Used for custom short lived cookie jar during favicon extraction -cookie = "0.18.2" +cookie = "0.18.1" cookie_store = "0.22.1" # Used by U2F, JWT and PostgreSQL -openssl = "0.10.81" +openssl = "0.10.76" # CLI argument parsing pico-args = "0.5.0" # Macro ident concatenation -pastey = "0.2.3" +pastey = "0.2.1" governor = "0.10.4" -# CIDR parsing for the trusted proxies of the client IP header -ipnet = "2.12.2" - # OIDC for SSO -openidconnect = { version = "4.1.0", default-features = false } -moka = { version = "0.12.16", features = ["future"] } +openidconnect = { version = "4.0.1", features = ["reqwest", "rustls-tls"] } +moka = { version = "0.12.15", features = ["future"] } # Check client versions for specific features. semver = "1.0.28" # Allow overriding the default memory allocator # Mainly used for the musl builds, since the default musl malloc is very slow -mimalloc = { version = "0.1.52", optional = true, default-features = false, features = ["secure"] } +mimalloc = { version = "0.1.48", features = ["secure"], default-features = false, optional = true } -which = "8.0.6" +which = "8.0.2" # Argon2 library with support for the PHC format -argon2 = "0.6.0" +argon2 = "0.5.3" # Reading a password from the cli for generating the Argon2id ADMIN_TOKEN -rpassword = "7.5.4" +rpassword = "7.4.0" # Loading a dynamic CSS Stylesheet grass_compiler = { version = "0.13.4", default-features = false } # File are accessed through Apache OpenDAL -opendal = { version = "0.59.5", default-features = false, features = ["services-fs"] } -opendal-http-transport-reqwest = { version = "0.59.5", default-features = false, features = ["rustls-no-provider"], optional = true } +opendal = { version = "0.55.0", features = ["services-fs"], default-features = false } # For retrieving AWS credentials, including temporary SSO credentials -aws-config = { version = "1.13.0", optional = true, default-features = false, features = [ - "behavior-version-latest", - "credentials-process", - "rt-tokio", - "sso", -] } -aws-credential-types = { version = "1.3.1", optional = true } -aws-smithy-runtime-api = { version = "1.19.2", optional = true } -http = { version = "1.5.0", optional = true } -reqsign-aws-v4 = { version = "3.3.1", optional = true } -reqsign-core = { version = "3.3.2", optional = true } +anyhow = { version = "1.0.102", optional = true } +aws-config = { version = "1.8.15", features = ["behavior-version-latest", "rt-tokio", "credentials-process", "sso"], default-features = false, optional = true } +aws-credential-types = { version = "1.2.14", optional = true } +aws-smithy-runtime-api = { version = "1.11.6", optional = true } +http = { version = "1.4.0", optional = true } +reqsign = { version = "0.16.5", optional = true } # Strip debuginfo from the release builds # The debug symbols are to provide better panic traces @@ -331,74 +262,75 @@ unsafe_code = "forbid" non_ascii_idents = "forbid" # Deny -warnings = "deny" # Explicitly deny all warnings since we deny all warnings in the end - -# Deny lint groups +deprecated_in_future = "deny" deprecated_safe = { level = "deny", priority = -1 } future_incompatible = { level = "deny", priority = -1 } keyword_idents = { level = "deny", priority = -1 } let_underscore = { level = "deny", priority = -1 } nonstandard_style = { level = "deny", priority = -1 } +noop_method_call = "deny" refining_impl_trait = { level = "deny", priority = -1 } rust_2018_idioms = { level = "deny", priority = -1 } rust_2021_compatibility = { level = "deny", priority = -1 } rust_2024_compatibility = { level = "deny", priority = -1 } -unused = { level = "deny", priority = -1 } - -# Deny individual lints -closure_returning_async_block = "deny" -deprecated_in_future = "deny" single_use_lifetimes = "deny" trivial_casts = "deny" trivial_numeric_casts = "deny" +unused = { level = "deny", priority = -1 } unused_import_braces = "deny" unused_lifetimes = "deny" unused_qualifications = "deny" variant_size_differences = "deny" +# Allow the following lints since these cause issues with Rust v1.84.0 or newer +# Building Vaultwarden with Rust v1.85.0 with edition 2024 also works without issues +edition_2024_expr_fragment_specifier = "allow" # Once changed to Rust 2024 this should be removed and macro's should be validated again +if_let_rescope = "allow" +tail_expr_drop_order = "allow" # https://rust-lang.github.io/rust-clippy/stable/index.html [workspace.lints.clippy] -# Warn only so you can still use these during development, but not in the final code +# Warn dbg_macro = "warn" todo = "warn" # Ignore/Allow result_large_err = "allow" -# Warn on these lint group (Some might be warn by default already though) -# Will be denied during CI! -complexity = { level = "warn", priority = -1 } -pedantic = { level = "warn", priority = -1 } -perf = { level = "warn", priority = -1 } -style = { level = "warn", priority = -1 } -suspicious = { level = "warn", priority = -1 } - -# Deny individual lints +# Deny branches_sharing_code = "deny" +case_sensitive_file_extension_comparisons = "deny" +cast_lossless = "deny" clone_on_ref_ptr = "deny" equatable_if_let = "deny" +excessive_precision = "deny" +filter_map_next = "deny" float_cmp_const = "deny" +implicit_clone = "deny" +inefficient_to_string = "deny" iter_on_empty_collections = "deny" iter_on_single_items = "deny" +linkedlist = "deny" +macro_use_imports = "deny" +manual_assert = "deny" +manual_instant_elapsed = "deny" +manual_string_new = "deny" +match_wildcard_for_single_variants = "deny" mem_forget = "deny" +needless_borrow = "deny" needless_collect = "deny" +needless_continue = "deny" +needless_lifetimes = "deny" +option_option = "deny" redundant_clone = "deny" +string_add_assign = "deny" +unnecessary_join = "deny" unnecessary_self_imports = "deny" +unnested_or_patterns = "deny" +unused_async = "deny" +unused_self = "deny" useless_let_if_seq = "deny" verbose_file_reads = "deny" -str_to_string = "deny" - -# Pedantic Opt-Outs -inline_always = "allow" # We use this sparsely -struct_field_names = "allow" # Noisy and some items are Bitwarden controlled -large_futures = "allow" # Causes a fail in some Rocket macro's, since we experience no issues, allow it -too_many_lines = "allow" # For now, allow this, good to enable in the future and see if we can refactor -unnecessary_wraps = "allow" # Too much false positives because of Rocket integrations -# We do not use these doc items -doc_link_with_quotes = "allow" -doc_markdown = "allow" -missing_errors_doc = "allow" -missing_panics_doc = "allow" +zero_sized_map_values = "deny" [lints] workspace = true diff --git a/README.md b/README.md index 0b24ba69..c84a9c40 100644 --- a/README.md +++ b/README.md @@ -59,9 +59,8 @@ A nearly complete implementation of the Bitwarden Client API is provided, includ ## Usage > [!IMPORTANT] -> The web-vault requires the use of HTTPS and a secure context for the [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API).
-> That means it will only work if you [enable HTTPS](https://github.com/dani-garcia/vaultwarden/wiki/Enabling-HTTPS).
-> We also suggest to use a [reverse proxy](https://github.com/dani-garcia/vaultwarden/wiki/Proxy-examples). +> The web-vault requires the use a secure context for the [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API). +> That means it will only work via `http://localhost:8000` (using the port from the example below) or if you [enable HTTPS](https://github.com/dani-garcia/vaultwarden/wiki/Enabling-HTTPS). The recommended way to install and use Vaultwarden is via our container images which are published to [ghcr.io](https://github.com/dani-garcia/vaultwarden/pkgs/container/vaultwarden), [docker.io](https://hub.docker.com/r/vaultwarden/server) and [quay.io](https://quay.io/repository/vaultwarden/server). See [which container image to use](https://github.com/dani-garcia/vaultwarden/wiki/Which-container-image-to-use) for an explanation of the provided tags. diff --git a/build.rs b/build.rs index 32fcf845..4a831737 100644 --- a/build.rs +++ b/build.rs @@ -1,21 +1,22 @@ -use std::{env, io::Error, process::Command}; +use std::env; +use std::process::Command; fn main() { - // These allow using e.g. #[cfg(mysql)] instead of #[cfg(feature = "mysql")], which helps when trying to add them through macros - #[cfg(feature = "sqlite_system")] // The `sqlite` feature implies this one. + // This allow using #[cfg(sqlite)] instead of #[cfg(feature = "sqlite")], which helps when trying to add them through macros + #[cfg(feature = "sqlite")] println!("cargo:rustc-cfg=sqlite"); #[cfg(feature = "mysql")] println!("cargo:rustc-cfg=mysql"); #[cfg(feature = "postgresql")] println!("cargo:rustc-cfg=postgresql"); - #[cfg(not(any(feature = "sqlite_system", feature = "mysql", feature = "postgresql")))] + #[cfg(feature = "s3")] + println!("cargo:rustc-cfg=s3"); + + #[cfg(not(any(feature = "sqlite", feature = "mysql", feature = "postgresql")))] compile_error!( "You need to enable one DB backend. To build with previous defaults do: cargo build --features sqlite" ); - #[cfg(feature = "s3")] - println!("cargo:rustc-cfg=s3"); - // Use check-cfg to let cargo know which cfg's we define, // and avoid warnings when they are used in the code. println!("cargo::rustc-check-cfg=cfg(sqlite)"); @@ -41,12 +42,13 @@ fn main() { } } -fn run(args: &[&str]) -> Result { +fn run(args: &[&str]) -> Result { let out = Command::new(args[0]).args(&args[1..]).output()?; if !out.status.success() { + use std::io::Error; return Err(Error::other("Command not successful")); } - Ok(String::from_utf8(out.stdout).unwrap().trim().to_owned()) + Ok(String::from_utf8(out.stdout).unwrap().trim().to_string()) } /// This method reads info from Git, namely tags, branch, and revision @@ -56,7 +58,7 @@ fn run(args: &[&str]) -> Result { /// - `env!("GIT_BRANCH")` /// - `env!("GIT_REV")` /// - `env!("VW_VERSION")` -fn version_from_git_info() -> Result { +fn version_from_git_info() -> Result { // The exact tag for the current commit, can be empty when // the current commit doesn't have an associated tag let exact_tag = run(&["git", "describe", "--abbrev=0", "--tags", "--exact-match"]).ok(); diff --git a/diesel.toml b/diesel.toml index 71215dbf..5a78b550 100644 --- a/diesel.toml +++ b/diesel.toml @@ -2,4 +2,4 @@ # see diesel.rs/guides/configuring-diesel-cli [print_schema] -file = "src/db/schema.rs" +file = "src/db/schema.rs" \ No newline at end of file diff --git a/docker/DockerSettings.yaml b/docker/DockerSettings.yaml index 4ef24c9d..c679b0da 100644 --- a/docker/DockerSettings.yaml +++ b/docker/DockerSettings.yaml @@ -1,14 +1,13 @@ --- -vault_version: "v2026.10.1" -vault_image_digest: "sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52" -# Cross Compile Docker Helper Scripts v1.10.0 +vault_version: "v2026.2.0" +vault_image_digest: "sha256:37c8661fa59dcdfbd3baa8366b6e950ef292b15adfeff1f57812b075c1fd3447" +# Cross Compile Docker Helper Scripts v1.9.0 # We use the linux/amd64 platform shell scripts since there is no difference between the different platform scripts # https://github.com/tonistiigi/xx | https://hub.docker.com/r/tonistiigi/xx/tags -xx_image_digest: "sha256:40fe21f44bb8cddf1eb1226db190d0a984118f62ee02a91b77d99448d28264a6" -# The `rust_version` variable is extracted from `rust-toolchain.toml` -# rust_version: x.yy.z # Rust version to be used +xx_image_digest: "sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707" +rust_version: 1.94.1 # Rust version to be used debian_version: trixie # Debian release name to be used -alpine_version: "3.24" # Alpine version to be used +alpine_version: "3.23" # Alpine version to be used # For which platforms/architectures will we try to build images platforms: ["linux/amd64", "linux/arm64", "linux/arm/v7", "linux/arm/v6"] # Determine the build images per OS/Arch diff --git a/docker/Dockerfile.alpine b/docker/Dockerfile.alpine index 95e8784d..ddcc9efe 100644 --- a/docker/Dockerfile.alpine +++ b/docker/Dockerfile.alpine @@ -19,23 +19,23 @@ # - From https://hub.docker.com/r/vaultwarden/web-vault/tags, # click the tag name to view the digest of the image it currently points to. # - From the command line: -# $ docker pull docker.io/vaultwarden/web-vault:v2026.10.1 -# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.10.1 -# [docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52] +# $ docker pull docker.io/vaultwarden/web-vault:v2026.2.0 +# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.2.0 +# [docker.io/vaultwarden/web-vault@sha256:37c8661fa59dcdfbd3baa8366b6e950ef292b15adfeff1f57812b075c1fd3447] # # - Conversely, to get the tag name from the digest: -# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52 -# [docker.io/vaultwarden/web-vault:v2026.10.1] +# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:37c8661fa59dcdfbd3baa8366b6e950ef292b15adfeff1f57812b075c1fd3447 +# [docker.io/vaultwarden/web-vault:v2026.2.0] # -FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52 AS vault +FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:37c8661fa59dcdfbd3baa8366b6e950ef292b15adfeff1f57812b075c1fd3447 AS vault ########################## ALPINE BUILD IMAGES ########################## ## NOTE: The Alpine Base Images do not support other platforms then linux/amd64 and linux/arm64 ## And for Alpine we define all build images here, they will only be loaded when actually used -FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.99.1 AS build_amd64 -FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.99.1 AS build_arm64 -FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.99.1 AS build_armv7 -FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.99.1 AS build_armv6 +FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.94.1 AS build_amd64 +FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.94.1 AS build_arm64 +FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.94.1 AS build_armv7 +FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.94.1 AS build_armv6 ########################## BUILD IMAGE ########################## # hadolint ignore=DL3006 @@ -57,6 +57,7 @@ ENV DEBIAN_FRONTEND=noninteractive \ # Debian Trixie uses libpq v17 PQ_LIB_DIR="/usr/local/musl/pq17/lib" + # Create CARGO_HOME folder and don't download rust docs RUN mkdir -pv "${CARGO_HOME}" && \ rustup set profile minimal @@ -66,11 +67,11 @@ RUN USER=root cargo new --bin /app WORKDIR /app # Environment variables for Cargo on Alpine based builds -RUN echo "export CARGO_TARGET=${CARGO_BUILD_TARGET}" >> /env-cargo && \ +RUN echo "export CARGO_TARGET=${RUST_MUSL_CROSS_TARGET}" >> /env-cargo && \ # Output the current contents of the file cat /env-cargo -RUN . /env-cargo && \ +RUN source /env-cargo && \ rustup target add "${CARGO_TARGET}" # Copies over *only* your manifests and build files @@ -86,7 +87,7 @@ ARG DB=sqlite,mysql,postgresql,enable_mimalloc # Builds your dependencies and removes the # dummy project, except the target folder # This folder contains the compiled dependencies -RUN . /env-cargo && \ +RUN source /env-cargo && \ cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \ find . -not -path "./target*" -delete @@ -97,13 +98,13 @@ COPY . . ARG VW_VERSION # Builds again, this time it will be the actual source files being build -RUN . /env-cargo && \ +RUN source /env-cargo && \ # Make sure that we actually build the project by updating the src/main.rs timestamp # Also do this for build.rs to ensure the version is rechecked touch build.rs src/main.rs && \ # Create a symlink to the binary target folder to easy copy the binary in the final stage cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \ - if [ "${CARGO_PROFILE}" = "dev" ] ; then \ + if [[ "${CARGO_PROFILE}" == "dev" ]] ; then \ ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \ else \ ln -vfsr "/app/target/${CARGO_TARGET}/${CARGO_PROFILE}" /app/target/final ; \ @@ -126,8 +127,7 @@ RUN . /env-cargo && \ # To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*' # # We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742 -# hadolint ignore=DL3065 -FROM --platform=$TARGETPLATFORM docker.io/library/alpine:3.24 +FROM --platform=$TARGETPLATFORM docker.io/library/alpine:3.23 ENV ROCKET_PROFILE="release" \ ROCKET_ADDRESS=0.0.0.0 \ diff --git a/docker/Dockerfile.debian b/docker/Dockerfile.debian index 621cfafe..18dd3d6c 100644 --- a/docker/Dockerfile.debian +++ b/docker/Dockerfile.debian @@ -19,25 +19,24 @@ # - From https://hub.docker.com/r/vaultwarden/web-vault/tags, # click the tag name to view the digest of the image it currently points to. # - From the command line: -# $ docker pull docker.io/vaultwarden/web-vault:v2026.10.1 -# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.10.1 -# [docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52] +# $ docker pull docker.io/vaultwarden/web-vault:v2026.2.0 +# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.2.0 +# [docker.io/vaultwarden/web-vault@sha256:37c8661fa59dcdfbd3baa8366b6e950ef292b15adfeff1f57812b075c1fd3447] # # - Conversely, to get the tag name from the digest: -# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52 -# [docker.io/vaultwarden/web-vault:v2026.10.1] +# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:37c8661fa59dcdfbd3baa8366b6e950ef292b15adfeff1f57812b075c1fd3447 +# [docker.io/vaultwarden/web-vault:v2026.2.0] # -FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52 AS vault +FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:37c8661fa59dcdfbd3baa8366b6e950ef292b15adfeff1f57812b075c1fd3447 AS vault ########################## Cross Compile Docker Helper Scripts ########################## ## We use the linux/amd64 no matter which Build Platform, since these are all bash scripts ## And these bash scripts do not have any significant difference if at all -FROM --platform=linux/amd64 docker.io/tonistiigi/xx@sha256:40fe21f44bb8cddf1eb1226db190d0a984118f62ee02a91b77d99448d28264a6 AS xx +FROM --platform=linux/amd64 docker.io/tonistiigi/xx@sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707 AS xx ########################## BUILD IMAGE ########################## # hadolint ignore=DL3006 -FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.99.1-slim-trixie AS build -# hadolint ignore=DL3067 +FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.94.1-slim-trixie AS build COPY --from=xx / / ARG TARGETARCH ARG TARGETVARIANT @@ -52,7 +51,7 @@ ENV DEBIAN_FRONTEND=noninteractive \ TERM=xterm-256color \ CARGO_HOME="/root/.cargo" \ USER="root" -# Install clang && xx-c-essentials to get `xx-cargo` working +# Install clang to get `xx-cargo` working # Install pkg-config to allow amd64 builds to find all libraries # Install git so build.rs can determine the correct version # Install the libc cross packages based upon the debian-arch @@ -60,16 +59,19 @@ RUN apt-get update && \ apt-get install -y \ --no-install-recommends \ clang \ - git && \ + pkg-config \ + git \ + "libc6-$(xx-info debian-arch)-cross" \ + "libc6-dev-$(xx-info debian-arch)-cross" \ + "linux-libc-dev-$(xx-info debian-arch)-cross" && \ xx-apt-get install -y \ --no-install-recommends \ + gcc \ libpq-dev \ libpq5 \ libssl-dev \ libmariadb-dev \ - pkg-config \ - zlib1g-dev \ - xx-c-essentials && \ + zlib1g-dev && \ # Run xx-cargo early, since it sometimes seems to break when run at a later stage echo "export CARGO_TARGET=$(xx-cargo --print-target-triple)" >> /env-cargo @@ -81,7 +83,30 @@ RUN mkdir -pv "${CARGO_HOME}" && \ RUN USER=root cargo new --bin /app WORKDIR /app -RUN . /env-cargo && \ +# Environment variables for Cargo on Debian based builds +ARG TARGET_PKG_CONFIG_PATH + +RUN source /env-cargo && \ + if xx-info is-cross ; then \ + # We can't use xx-cargo since that uses clang, which doesn't work for our libraries. + # Because of this we generate the needed environment variables here which we can load in the needed steps. + echo "export CC_$(echo "${CARGO_TARGET}" | tr '[:upper:]' '[:lower:]' | tr - _)=/usr/bin/$(xx-info)-gcc" >> /env-cargo && \ + echo "export CARGO_TARGET_$(echo "${CARGO_TARGET}" | tr '[:lower:]' '[:upper:]' | tr - _)_LINKER=/usr/bin/$(xx-info)-gcc" >> /env-cargo && \ + echo "export CROSS_COMPILE=1" >> /env-cargo && \ + echo "export PKG_CONFIG_ALLOW_CROSS=1" >> /env-cargo && \ + # For some architectures `xx-info` returns a triple which doesn't matches the path on disk + # In those cases you can override this by setting the `TARGET_PKG_CONFIG_PATH` build-arg + if [[ -n "${TARGET_PKG_CONFIG_PATH}" ]]; then \ + echo "export TARGET_PKG_CONFIG_PATH=${TARGET_PKG_CONFIG_PATH}" >> /env-cargo ; \ + else \ + echo "export PKG_CONFIG_PATH=/usr/lib/$(xx-info)/pkgconfig" >> /env-cargo ; \ + fi && \ + echo "# End of env-cargo" >> /env-cargo ; \ + fi && \ + # Output the current contents of the file + cat /env-cargo + +RUN source /env-cargo && \ rustup target add "${CARGO_TARGET}" # Copies over *only* your manifests and build files @@ -96,23 +121,8 @@ ARG DB=sqlite,mysql,postgresql # Builds your dependencies and removes the # dummy project, except the target folder # This folder contains the compiled dependencies -RUN . /env-cargo && \ - # Configure xx-cargo for target pkg-config and Debian transitive library lookup - # https://github.com/tonistiigi/xx/pull/108#issuecomment-3700635977 - # https://github.com/dani-garcia/vaultwarden/discussions/7522 - if xx-info is-cross; then \ - XX_RUSTFLAGS="-Clink-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \ - fi && \ - # Debian's GCC adds a special fix for cortex-a53 arch's which gets losts when using xx-cargo, which uses clang - # Checking if the arch is arm64 and adding this extra link-arg should cause this fix to be applied - # This should fix issues reported when running on a RPi3 which is a Cortex-A35 chip - # See: https://salsa.debian.org/toolchain-team/gcc/-/blob/master/debian/rules2?pow_referer=#L543-548 - # See: https://github.com/dani-garcia/vaultwarden/issues/7754 - if [ "$(xx-info arch)" = "arm64" ]; then \ - XX_RUSTFLAGS="${XX_RUSTFLAGS} -Clink-arg=-Wl,--fix-cortex-a53-843419"; \ - fi && \ - export XX_RUSTFLAGS && \ - PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}" && \ +RUN source /env-cargo && \ + cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \ find . -not -path "./target*" -delete # Copies the complete project @@ -122,28 +132,13 @@ COPY . . ARG VW_VERSION # Builds again, this time it will be the actual source files being build -RUN . /env-cargo && \ +RUN source /env-cargo && \ # Make sure that we actually build the project by updating the src/main.rs timestamp # Also do this for build.rs to ensure the version is rechecked touch build.rs src/main.rs && \ # Create a symlink to the binary target folder to easy copy the binary in the final stage - # Configure xx-cargo for target pkg-config and Debian transitive library lookup - # https://github.com/tonistiigi/xx/pull/108#issuecomment-3700635977 - # https://github.com/dani-garcia/vaultwarden/discussions/7522 - if xx-info is-cross; then \ - XX_RUSTFLAGS="-Clink-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \ - fi && \ - # Debian's GCC adds a special fix for cortex-a53 arch's which gets losts when using xx-cargo, which uses clang - # Checking if the arch is arm64 and adding this extra link-arg should cause this fix to be applied - # This should fix issues reported when running on a RPi3 which is a Cortex-A35 chip - # See: https://salsa.debian.org/toolchain-team/gcc/-/blob/master/debian/rules2?pow_referer=#L543-548 - # See: https://github.com/dani-garcia/vaultwarden/issues/7754 - if [ "$(xx-info arch)" = "arm64" ]; then \ - XX_RUSTFLAGS="${XX_RUSTFLAGS} -Clink-arg=-Wl,--fix-cortex-a53-843419"; \ - fi && \ - export XX_RUSTFLAGS && \ - PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}" && \ - if [ "${CARGO_PROFILE}" = "dev" ] ; then \ + cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \ + if [[ "${CARGO_PROFILE}" == "dev" ]] ; then \ ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \ else \ ln -vfsr "/app/target/${CARGO_TARGET}/${CARGO_PROFILE}" /app/target/final ; \ @@ -166,7 +161,6 @@ RUN . /env-cargo && \ # To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*' # # We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742 -# hadolint ignore=DL3065 FROM --platform=$TARGETPLATFORM docker.io/library/debian:trixie-slim ENV ROCKET_PROFILE="release" \ diff --git a/docker/Dockerfile.j2 b/docker/Dockerfile.j2 index 2dc175b0..f745780e 100644 --- a/docker/Dockerfile.j2 +++ b/docker/Dockerfile.j2 @@ -27,24 +27,6 @@ # $ docker image inspect --format "{{ '{{' }}.RepoTags}}" docker.io/vaultwarden/web-vault@{{ vault_image_digest }} # [docker.io/vaultwarden/web-vault:{{ vault_version | replace('+', '_') }}] # -{% macro xx_cargo_config() -%} -# Configure xx-cargo for target pkg-config and Debian transitive library lookup - # https://github.com/tonistiigi/xx/pull/108#issuecomment-3700635977 - # https://github.com/dani-garcia/vaultwarden/discussions/7522 - if xx-info is-cross; then \ - XX_RUSTFLAGS="-Clink-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \ - fi && \ - # Debian's GCC adds a special fix for cortex-a53 arch's which gets losts when using xx-cargo, which uses clang - # Checking if the arch is arm64 and adding this extra link-arg should cause this fix to be applied - # This should fix issues reported when running on a RPi3 which is a Cortex-A35 chip - # See: https://salsa.debian.org/toolchain-team/gcc/-/blob/master/debian/rules2?pow_referer=#L543-548 - # See: https://github.com/dani-garcia/vaultwarden/issues/7754 - if [ "$(xx-info arch)" = "arm64" ]; then \ - XX_RUSTFLAGS="${XX_RUSTFLAGS} -Clink-arg=-Wl,--fix-cortex-a53-843419"; \ - fi && \ - export XX_RUSTFLAGS && \ - PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}" -{%- endmacro %} FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@{{ vault_image_digest }} AS vault {% if base == "debian" %} @@ -65,7 +47,6 @@ FROM --platform=$BUILDPLATFORM {{ build_stage_image[base].arch_image[arch] }} AS # hadolint ignore=DL3006 FROM --platform=$BUILDPLATFORM {{ build_stage_image[base].image }} AS build {% if base == "debian" %} -# hadolint ignore=DL3067 COPY --from=xx / / {% endif %} ARG TARGETARCH @@ -85,10 +66,10 @@ ENV DEBIAN_FRONTEND=noninteractive \ # Use PostgreSQL v17 during Alpine/MUSL builds instead of the default v16 # Debian Trixie uses libpq v17 PQ_LIB_DIR="/usr/local/musl/pq17/lib" -{%- endif %} +{% endif %} {% if base == "debian" %} -# Install clang && xx-c-essentials to get `xx-cargo` working +# Install clang to get `xx-cargo` working # Install pkg-config to allow amd64 builds to find all libraries # Install git so build.rs can determine the correct version # Install the libc cross packages based upon the debian-arch @@ -96,16 +77,19 @@ RUN apt-get update && \ apt-get install -y \ --no-install-recommends \ clang \ - git && \ + pkg-config \ + git \ + "libc6-$(xx-info debian-arch)-cross" \ + "libc6-dev-$(xx-info debian-arch)-cross" \ + "linux-libc-dev-$(xx-info debian-arch)-cross" && \ xx-apt-get install -y \ --no-install-recommends \ + gcc \ libpq-dev \ libpq5 \ libssl-dev \ libmariadb-dev \ - pkg-config \ - zlib1g-dev \ - xx-c-essentials && \ + zlib1g-dev && \ # Run xx-cargo early, since it sometimes seems to break when run at a later stage echo "export CARGO_TARGET=$(xx-cargo --print-target-triple)" >> /env-cargo {% endif %} @@ -118,14 +102,38 @@ RUN mkdir -pv "${CARGO_HOME}" && \ RUN USER=root cargo new --bin /app WORKDIR /app -{% if base == "alpine" %} +{% if base == "debian" %} +# Environment variables for Cargo on Debian based builds +ARG TARGET_PKG_CONFIG_PATH + +RUN source /env-cargo && \ + if xx-info is-cross ; then \ + # We can't use xx-cargo since that uses clang, which doesn't work for our libraries. + # Because of this we generate the needed environment variables here which we can load in the needed steps. + echo "export CC_$(echo "${CARGO_TARGET}" | tr '[:upper:]' '[:lower:]' | tr - _)=/usr/bin/$(xx-info)-gcc" >> /env-cargo && \ + echo "export CARGO_TARGET_$(echo "${CARGO_TARGET}" | tr '[:lower:]' '[:upper:]' | tr - _)_LINKER=/usr/bin/$(xx-info)-gcc" >> /env-cargo && \ + echo "export CROSS_COMPILE=1" >> /env-cargo && \ + echo "export PKG_CONFIG_ALLOW_CROSS=1" >> /env-cargo && \ + # For some architectures `xx-info` returns a triple which doesn't matches the path on disk + # In those cases you can override this by setting the `TARGET_PKG_CONFIG_PATH` build-arg + if [[ -n "${TARGET_PKG_CONFIG_PATH}" ]]; then \ + echo "export TARGET_PKG_CONFIG_PATH=${TARGET_PKG_CONFIG_PATH}" >> /env-cargo ; \ + else \ + echo "export PKG_CONFIG_PATH=/usr/lib/$(xx-info)/pkgconfig" >> /env-cargo ; \ + fi && \ + echo "# End of env-cargo" >> /env-cargo ; \ + fi && \ + # Output the current contents of the file + cat /env-cargo + +{% elif base == "alpine" %} # Environment variables for Cargo on Alpine based builds -RUN echo "export CARGO_TARGET=${CARGO_BUILD_TARGET}" >> /env-cargo && \ +RUN echo "export CARGO_TARGET=${RUST_MUSL_CROSS_TARGET}" >> /env-cargo && \ # Output the current contents of the file cat /env-cargo {% endif %} -RUN . /env-cargo && \ +RUN source /env-cargo && \ rustup target add "${CARGO_TARGET}" # Copies over *only* your manifests and build files @@ -145,12 +153,8 @@ ARG DB=sqlite,mysql,postgresql,enable_mimalloc # Builds your dependencies and removes the # dummy project, except the target folder # This folder contains the compiled dependencies -RUN . /env-cargo && \ -{% if base == "debian" %} - {{ xx_cargo_config() }} && \ -{% elif base == "alpine" %} +RUN source /env-cargo && \ cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \ -{% endif %} find . -not -path "./target*" -delete # Copies the complete project @@ -160,17 +164,13 @@ COPY . . ARG VW_VERSION # Builds again, this time it will be the actual source files being build -RUN . /env-cargo && \ +RUN source /env-cargo && \ # Make sure that we actually build the project by updating the src/main.rs timestamp # Also do this for build.rs to ensure the version is rechecked touch build.rs src/main.rs && \ # Create a symlink to the binary target folder to easy copy the binary in the final stage -{% if base == "debian" %} - {{ xx_cargo_config() }} && \ -{% elif base == "alpine" %} cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \ -{% endif %} - if [ "${CARGO_PROFILE}" = "dev" ] ; then \ + if [[ "${CARGO_PROFILE}" == "dev" ]] ; then \ ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \ else \ ln -vfsr "/app/target/${CARGO_TARGET}/${CARGO_PROFILE}" /app/target/final ; \ @@ -193,7 +193,6 @@ RUN . /env-cargo && \ # To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*' # # We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742 -# hadolint ignore=DL3065 FROM --platform=$TARGETPLATFORM {{ runtime_stage_image[base] }} ENV ROCKET_PROFILE="release" \ diff --git a/docker/README.md b/docker/README.md index 3bbc31e1..d64d5789 100644 --- a/docker/README.md +++ b/docker/README.md @@ -15,7 +15,6 @@ Some unsupported platforms for Debian based images. These are not built and test - 386 (linux/386) - ppc64le (linux/ppc64le) - 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.
This ensures the container build process can run binaries from other architectures.
diff --git a/docker/docker-bake.hcl b/docker/docker-bake.hcl index 30902bba..c27c39a6 100644 --- a/docker/docker-bake.hcl +++ b/docker/docker-bake.hcl @@ -148,12 +148,6 @@ target "debian-s390x" { platforms = ["linux/s390x"] tags = generate_tags("", "-s390x") } - -target "debian-riscv64" { - inherits = ["debian"] - platforms = ["linux/riscv64"] - tags = generate_tags("", "-riscv64") -} // ==== End of unsupported Debian architecture targets === // A Group to build all platforms individually for local testing diff --git a/docker/render_template b/docker/render_template index 84ca8ead..401e0ad0 100755 --- a/docker/render_template +++ b/docker/render_template @@ -3,23 +3,17 @@ import os import argparse import json -import tomllib import yaml import jinja2 # Load settings file -with open('DockerSettings.yaml', 'r', encoding='utf-8') as yaml_file: +with open("DockerSettings.yaml", 'r') as yaml_file: yaml_data = yaml.safe_load(yaml_file) -# Extract the rust_version from the rust-toolchain.toml file -script_dir = os.path.dirname(os.path.abspath(__file__)) -with open(os.path.join(script_dir, '..', 'rust-toolchain.toml'), 'rb') as toolchain_file: - yaml_data["rust_version"] = tomllib.load(toolchain_file)["toolchain"]["channel"] - settings_env = jinja2.Environment( loader=jinja2.FileSystemLoader(os.getcwd()), ) -settings_yaml = yaml.safe_load(settings_env.get_template('DockerSettings.yaml').render(yaml_data)) +settings_yaml = yaml.safe_load(settings_env.get_template("DockerSettings.yaml").render(yaml_data)) args_parser = argparse.ArgumentParser() args_parser.add_argument('template_file', help='Jinja2 template file to render.') diff --git a/macros/Cargo.toml b/macros/Cargo.toml index ee9fd206..eb3bd670 100644 --- a/macros/Cargo.toml +++ b/macros/Cargo.toml @@ -13,8 +13,8 @@ path = "src/lib.rs" proc-macro = true [dependencies] -quote = "1.0.47" -syn = "3.0.9" +quote = "1.0.45" +syn = "2.0.117" [lints] workspace = true diff --git a/macros/src/lib.rs b/macros/src/lib.rs index 73b23a22..2d923ce1 100644 --- a/macros/src/lib.rs +++ b/macros/src/lib.rs @@ -1,15 +1,14 @@ use proc_macro::TokenStream; use quote::quote; -use syn::{DeriveInput, parse_macro_input}; #[proc_macro_derive(UuidFromParam)] pub fn derive_uuid_from_param(input: TokenStream) -> TokenStream { - let ast = parse_macro_input!(input as DeriveInput); + let ast = syn::parse(input).unwrap(); impl_derive_uuid_macro(&ast) } -fn impl_derive_uuid_macro(ast: &DeriveInput) -> TokenStream { +fn impl_derive_uuid_macro(ast: &syn::DeriveInput) -> TokenStream { let name = &ast.ident; let gen_derive = quote! { #[automatically_derived] @@ -31,12 +30,12 @@ fn impl_derive_uuid_macro(ast: &DeriveInput) -> TokenStream { #[proc_macro_derive(IdFromParam)] pub fn derive_id_from_param(input: TokenStream) -> TokenStream { - let ast = parse_macro_input!(input as DeriveInput); + let ast = syn::parse(input).unwrap(); impl_derive_safestring_macro(&ast) } -fn impl_derive_safestring_macro(ast: &DeriveInput) -> TokenStream { +fn impl_derive_safestring_macro(ast: &syn::DeriveInput) -> TokenStream { let name = &ast.ident; let gen_derive = quote! { #[automatically_derived] diff --git a/migrations/cockroachdb/2026-09-02-120000_add_key_id/down.sql b/migrations/cockroachdb/2026-09-02-120000_add_key_id/down.sql deleted file mode 100644 index e69de29b..00000000 diff --git a/migrations/cockroachdb/2026-09-02-120000_add_key_id/up.sql b/migrations/cockroachdb/2026-09-02-120000_add_key_id/up.sql deleted file mode 100644 index d25396cf..00000000 --- a/migrations/cockroachdb/2026-09-02-120000_add_key_id/up.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE users ADD COLUMN key_id TEXT; diff --git a/migrations/mysql/2024-03-13-170000_sso_users_cascade/up.sql b/migrations/mysql/2024-03-13-170000_sso_users_cascade/up.sql index 8d1eb178..9e5e46df 100644 --- a/migrations/mysql/2024-03-13-170000_sso_users_cascade/up.sql +++ b/migrations/mysql/2024-03-13-170000_sso_users_cascade/up.sql @@ -1,31 +1,15 @@ -SELECT if ( - EXISTS( - SELECT CONSTRAINT_NAME FROM information_schema.table_constraints - WHERE TABLE_SCHEMA = DATABASE() - AND TABLE_NAME = 'sso_users' - AND CONSTRAINT_TYPE = 'FOREIGN KEY' - AND CONSTRAINT_NAME = 'sso_users_ibfk_1' - ) - ,'ALTER TABLE sso_users DROP FOREIGN KEY `sso_users_ibfk_1`' - ,'SELECT "info: FK sso_users_ibfk_1 does not exist."' -) INTO @drop_stmt; -PREPARE drop_stmt FROM @drop_stmt; -EXECUTE drop_stmt; - -SELECT if ( - EXISTS( - SELECT CONSTRAINT_NAME FROM information_schema.table_constraints - WHERE TABLE_SCHEMA = DATABASE() - AND TABLE_NAME = 'sso_users' - AND CONSTRAINT_TYPE = 'FOREIGN KEY' - AND CONSTRAINT_NAME = '1' - ) - ,'ALTER TABLE sso_users DROP FOREIGN KEY `1`' - ,'SELECT "info: FK sso_users 1 does not exist."' -) INTO @drop_stmt; -PREPARE drop_stmt FROM @drop_stmt; -EXECUTE drop_stmt; - -DEALLOCATE PREPARE drop_stmt; +-- Dynamically create DROP FOREIGN KEY +-- Some versions of MySQL or MariaDB might fail if the key doesn't exists +-- This checks if the key exists, and if so, will drop it. +SET @drop_sso_fk = IF((SELECT true FROM information_schema.TABLE_CONSTRAINTS WHERE + CONSTRAINT_SCHEMA = DATABASE() AND + TABLE_NAME = 'sso_users' AND + CONSTRAINT_NAME = 'sso_users_ibfk_1' AND + CONSTRAINT_TYPE = 'FOREIGN KEY') = true, + 'ALTER TABLE sso_users DROP FOREIGN KEY sso_users_ibfk_1', + 'SELECT 1'); +PREPARE stmt FROM @drop_sso_fk; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; ALTER TABLE sso_users ADD FOREIGN KEY(user_uuid) REFERENCES users(uuid) ON UPDATE CASCADE ON DELETE CASCADE; diff --git a/migrations/mysql/2026-03-09-005927_add_archives/down.sql b/migrations/mysql/2026-03-09-005927_add_archives/down.sql deleted file mode 100644 index a3ef20c3..00000000 --- a/migrations/mysql/2026-03-09-005927_add_archives/down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS archives; diff --git a/migrations/mysql/2026-03-09-005927_add_archives/up.sql b/migrations/mysql/2026-03-09-005927_add_archives/up.sql deleted file mode 100644 index 6d7a7024..00000000 --- a/migrations/mysql/2026-03-09-005927_add_archives/up.sql +++ /dev/null @@ -1,10 +0,0 @@ -DROP TABLE IF EXISTS archives; - -CREATE TABLE archives ( - user_uuid CHAR(36) NOT NULL, - cipher_uuid CHAR(36) NOT NULL, - archived_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (user_uuid, cipher_uuid), - FOREIGN KEY (user_uuid) REFERENCES users (uuid) ON DELETE CASCADE, - FOREIGN KEY (cipher_uuid) REFERENCES ciphers (uuid) ON DELETE CASCADE -); diff --git a/migrations/mysql/2026-04-25-120000_sso_auth_binding/down.sql b/migrations/mysql/2026-04-25-120000_sso_auth_binding/down.sql deleted file mode 100644 index 17e3d8c7..00000000 --- a/migrations/mysql/2026-04-25-120000_sso_auth_binding/down.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE sso_auth DROP COLUMN binding_hash; diff --git a/migrations/mysql/2026-04-25-120000_sso_auth_binding/up.sql b/migrations/mysql/2026-04-25-120000_sso_auth_binding/up.sql deleted file mode 100644 index 53ee8063..00000000 --- a/migrations/mysql/2026-04-25-120000_sso_auth_binding/up.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE sso_auth ADD COLUMN binding_hash TEXT; diff --git a/migrations/mysql/2026-05-05-120000_sso_auth_error/down.sql b/migrations/mysql/2026-05-05-120000_sso_auth_error/down.sql deleted file mode 100644 index 98a6d836..00000000 --- a/migrations/mysql/2026-05-05-120000_sso_auth_error/down.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE sso_auth DROP COLUMN code_response_error; diff --git a/migrations/mysql/2026-05-05-120000_sso_auth_error/up.sql b/migrations/mysql/2026-05-05-120000_sso_auth_error/up.sql deleted file mode 100644 index 6042a7d4..00000000 --- a/migrations/mysql/2026-05-05-120000_sso_auth_error/up.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE sso_auth ADD COLUMN code_response_error TEXT; diff --git a/migrations/mysql/2026-09-02-120000_add_key_id/down.sql b/migrations/mysql/2026-09-02-120000_add_key_id/down.sql deleted file mode 100644 index e69de29b..00000000 diff --git a/migrations/mysql/2026-09-02-120000_add_key_id/up.sql b/migrations/mysql/2026-09-02-120000_add_key_id/up.sql deleted file mode 100644 index d25396cf..00000000 --- a/migrations/mysql/2026-09-02-120000_add_key_id/up.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE users ADD COLUMN key_id TEXT; diff --git a/migrations/mysql/2026-10-07-120000_add_org_policy_revision_date/down.sql b/migrations/mysql/2026-10-07-120000_add_org_policy_revision_date/down.sql deleted file mode 100644 index 27c33623..00000000 --- a/migrations/mysql/2026-10-07-120000_add_org_policy_revision_date/down.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE org_policies DROP COLUMN revision_date; diff --git a/migrations/mysql/2026-10-07-120000_add_org_policy_revision_date/up.sql b/migrations/mysql/2026-10-07-120000_add_org_policy_revision_date/up.sql deleted file mode 100644 index 97a84117..00000000 --- a/migrations/mysql/2026-10-07-120000_add_org_policy_revision_date/up.sql +++ /dev/null @@ -1,7 +0,0 @@ --- 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(); diff --git a/migrations/mysql/2026-10-08-120000_cipher_data_longtext/down.sql b/migrations/mysql/2026-10-08-120000_cipher_data_longtext/down.sql deleted file mode 100644 index e69de29b..00000000 diff --git a/migrations/mysql/2026-10-08-120000_cipher_data_longtext/up.sql b/migrations/mysql/2026-10-08-120000_cipher_data_longtext/up.sql deleted file mode 100644 index 09abbaee..00000000 --- a/migrations/mysql/2026-10-08-120000_cipher_data_longtext/up.sql +++ /dev/null @@ -1,3 +0,0 @@ --- A blob-encrypted cipher keeps all of its content in `data`, up to 500,000 characters, so the --- 64 KiB of TEXT is too small. Like upstream, which uses LONGTEXT. -ALTER TABLE ciphers MODIFY data LONGTEXT NOT NULL; diff --git a/migrations/mysql/2026-10-09-120000_user_crypto_v2/down.sql b/migrations/mysql/2026-10-09-120000_user_crypto_v2/down.sql deleted file mode 100644 index 49700440..00000000 --- a/migrations/mysql/2026-10-09-120000_user_crypto_v2/down.sql +++ /dev/null @@ -1,5 +0,0 @@ -DROP TABLE IF EXISTS user_signature_key_pairs; - -ALTER TABLE users DROP COLUMN signed_public_key; -ALTER TABLE users DROP COLUMN security_state; -ALTER TABLE users DROP COLUMN security_version; diff --git a/migrations/mysql/2026-10-09-120000_user_crypto_v2/up.sql b/migrations/mysql/2026-10-09-120000_user_crypto_v2/up.sql deleted file mode 100644 index 73edfa6a..00000000 --- a/migrations/mysql/2026-10-09-120000_user_crypto_v2/up.sql +++ /dev/null @@ -1,14 +0,0 @@ -ALTER TABLE users ADD COLUMN signed_public_key TEXT; -ALTER TABLE users ADD COLUMN security_state TEXT; -ALTER TABLE users ADD COLUMN security_version INTEGER; - -CREATE TABLE user_signature_key_pairs ( - uuid CHAR(36) NOT NULL PRIMARY KEY, - user_uuid CHAR(36) NOT NULL UNIQUE, - signature_algorithm INTEGER NOT NULL, -- 0 = ed25519, 1 = mldsa44 - signing_key TEXT NOT NULL, - verifying_key TEXT NOT NULL, - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL, - FOREIGN KEY (user_uuid) REFERENCES users (uuid) ON DELETE CASCADE -); diff --git a/migrations/mysql/2026-10-10-120000_add_custom_role_permissions/down.sql b/migrations/mysql/2026-10-10-120000_add_custom_role_permissions/down.sql deleted file mode 100644 index 85ebcbfb..00000000 --- a/migrations/mysql/2026-10-10-120000_add_custom_role_permissions/down.sql +++ /dev/null @@ -1,22 +0,0 @@ --- Dropping the permissions would silently take them away from the Custom members, who would also keep a role (4) the --- versions before this one don't know. So while there are any, this fails on the duplicate key below, before anything --- is dropped: change their role first, or restore the backup taken before the upgrade. --- A failed attempt leaves the temporary table on this connection. Clear it before retrying. -DROP TEMPORARY TABLE IF EXISTS custom_role_downgrade; -CREATE TEMPORARY TABLE custom_role_downgrade (blocked_by VARCHAR(255) NOT NULL PRIMARY KEY); -INSERT INTO custom_role_downgrade VALUES ('Custom members: change their role first, or restore the backup taken before the upgrade'); -INSERT INTO custom_role_downgrade - SELECT 'Custom members: change their role first, or restore the backup taken before the upgrade' - FROM users_organizations WHERE atype = 4 LIMIT 1; -DROP TEMPORARY TABLE custom_role_downgrade; - -ALTER TABLE users_organizations - DROP COLUMN manage_users, - DROP COLUMN manage_groups, - DROP COLUMN manage_policies, - DROP COLUMN create_new_collections, - DROP COLUMN edit_any_collection, - DROP COLUMN delete_any_collection, - DROP COLUMN access_event_logs, - DROP COLUMN access_import_export, - DROP COLUMN access_reports; diff --git a/migrations/mysql/2026-10-10-120000_add_custom_role_permissions/up.sql b/migrations/mysql/2026-10-10-120000_add_custom_role_permissions/up.sql deleted file mode 100644 index d28b0d64..00000000 --- a/migrations/mysql/2026-10-10-120000_add_custom_role_permissions/up.sql +++ /dev/null @@ -1,13 +0,0 @@ --- The granular permissions of the Custom member role. They only take effect on a Custom membership --- (atype 4) while EXPERIMENTAL_CUSTOM_ROLES is enabled, and every existing membership starts without any. --- A single statement, so an interrupted upgrade cannot leave only some of the columns behind. -ALTER TABLE users_organizations - ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT FALSE, - ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT FALSE, - ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT FALSE, - ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT FALSE, - ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE, - ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE, - ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT FALSE, - ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT FALSE, - ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/migrations/mysql/2026-11-02-120000_reset_user_key_id/down.sql b/migrations/mysql/2026-11-02-120000_reset_user_key_id/down.sql deleted file mode 100644 index e69de29b..00000000 diff --git a/migrations/mysql/2026-11-02-120000_reset_user_key_id/up.sql b/migrations/mysql/2026-11-02-120000_reset_user_key_id/up.sql deleted file mode 100644 index eeffa854..00000000 --- a/migrations/mysql/2026-11-02-120000_reset_user_key_id/up.sql +++ /dev/null @@ -1,4 +0,0 @@ --- Key rotations before this kept the id of the old user key, so every new --- cipher was refused from then on. Stale ids can't be told apart, so clear --- all of them; clients record their current key's id again when it's empty. -UPDATE users SET key_id = NULL; diff --git a/migrations/mysql/2026-11-04-120000_add_v2_upgrade_token/down.sql b/migrations/mysql/2026-11-04-120000_add_v2_upgrade_token/down.sql deleted file mode 100644 index 89e12c15..00000000 --- a/migrations/mysql/2026-11-04-120000_add_v2_upgrade_token/down.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE users DROP COLUMN v2_upgrade_token; -ALTER TABLE users_organizations DROP COLUMN v2_upgrade_token; diff --git a/migrations/mysql/2026-11-04-120000_add_v2_upgrade_token/up.sql b/migrations/mysql/2026-11-04-120000_add_v2_upgrade_token/up.sql deleted file mode 100644 index 45c58491..00000000 --- a/migrations/mysql/2026-11-04-120000_add_v2_upgrade_token/up.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE users ADD COLUMN v2_upgrade_token TEXT; -ALTER TABLE users_organizations ADD COLUMN v2_upgrade_token TEXT; diff --git a/migrations/postgresql/2026-03-09-005927_add_archives/down.sql b/migrations/postgresql/2026-03-09-005927_add_archives/down.sql deleted file mode 100644 index a3ef20c3..00000000 --- a/migrations/postgresql/2026-03-09-005927_add_archives/down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS archives; diff --git a/migrations/postgresql/2026-03-09-005927_add_archives/up.sql b/migrations/postgresql/2026-03-09-005927_add_archives/up.sql deleted file mode 100644 index c56d01a0..00000000 --- a/migrations/postgresql/2026-03-09-005927_add_archives/up.sql +++ /dev/null @@ -1,8 +0,0 @@ -DROP TABLE IF EXISTS archives; - -CREATE TABLE archives ( - user_uuid CHAR(36) NOT NULL REFERENCES users (uuid) ON DELETE CASCADE, - cipher_uuid CHAR(36) NOT NULL REFERENCES ciphers (uuid) ON DELETE CASCADE, - archived_at TIMESTAMP NOT NULL DEFAULT now(), - PRIMARY KEY (user_uuid, cipher_uuid) -); diff --git a/migrations/postgresql/2026-04-25-120000_sso_auth_binding/down.sql b/migrations/postgresql/2026-04-25-120000_sso_auth_binding/down.sql deleted file mode 100644 index 17e3d8c7..00000000 --- a/migrations/postgresql/2026-04-25-120000_sso_auth_binding/down.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE sso_auth DROP COLUMN binding_hash; diff --git a/migrations/postgresql/2026-04-25-120000_sso_auth_binding/up.sql b/migrations/postgresql/2026-04-25-120000_sso_auth_binding/up.sql deleted file mode 100644 index 53ee8063..00000000 --- a/migrations/postgresql/2026-04-25-120000_sso_auth_binding/up.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE sso_auth ADD COLUMN binding_hash TEXT; diff --git a/migrations/postgresql/2026-05-05-120000_sso_auth_error/down.sql b/migrations/postgresql/2026-05-05-120000_sso_auth_error/down.sql deleted file mode 100644 index fae11ae3..00000000 --- a/migrations/postgresql/2026-05-05-120000_sso_auth_error/down.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE sso_auth DROP COLUMN IF EXISTS code_response_error; diff --git a/migrations/postgresql/2026-05-05-120000_sso_auth_error/up.sql b/migrations/postgresql/2026-05-05-120000_sso_auth_error/up.sql deleted file mode 100644 index d4524898..00000000 --- a/migrations/postgresql/2026-05-05-120000_sso_auth_error/up.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE sso_auth ADD COLUMN IF NOT EXISTS code_response_error TEXT; diff --git a/migrations/postgresql/2026-09-02-120000_add_key_id/down.sql b/migrations/postgresql/2026-09-02-120000_add_key_id/down.sql deleted file mode 100644 index e69de29b..00000000 diff --git a/migrations/postgresql/2026-09-02-120000_add_key_id/up.sql b/migrations/postgresql/2026-09-02-120000_add_key_id/up.sql deleted file mode 100644 index d25396cf..00000000 --- a/migrations/postgresql/2026-09-02-120000_add_key_id/up.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE users ADD COLUMN key_id TEXT; diff --git a/migrations/postgresql/2026-10-07-120000_add_org_policy_revision_date/down.sql b/migrations/postgresql/2026-10-07-120000_add_org_policy_revision_date/down.sql deleted file mode 100644 index 27c33623..00000000 --- a/migrations/postgresql/2026-10-07-120000_add_org_policy_revision_date/down.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE org_policies DROP COLUMN revision_date; diff --git a/migrations/postgresql/2026-10-07-120000_add_org_policy_revision_date/up.sql b/migrations/postgresql/2026-10-07-120000_add_org_policy_revision_date/up.sql deleted file mode 100644 index 44229de2..00000000 --- a/migrations/postgresql/2026-10-07-120000_add_org_policy_revision_date/up.sql +++ /dev/null @@ -1,9 +0,0 @@ --- 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'); diff --git a/migrations/postgresql/2026-10-08-120000_cipher_data_longtext/down.sql b/migrations/postgresql/2026-10-08-120000_cipher_data_longtext/down.sql deleted file mode 100644 index e69de29b..00000000 diff --git a/migrations/postgresql/2026-10-08-120000_cipher_data_longtext/up.sql b/migrations/postgresql/2026-10-08-120000_cipher_data_longtext/up.sql deleted file mode 100644 index aab2e86a..00000000 --- a/migrations/postgresql/2026-10-08-120000_cipher_data_longtext/up.sql +++ /dev/null @@ -1,3 +0,0 @@ --- TEXT has no size limit here, only MySQL needed the change. --- PostgreSQL rejects a migration that is only comments ("Received an empty query"), hence the no-op. -SELECT 1; diff --git a/migrations/postgresql/2026-10-09-120000_user_crypto_v2/down.sql b/migrations/postgresql/2026-10-09-120000_user_crypto_v2/down.sql deleted file mode 100644 index 49700440..00000000 --- a/migrations/postgresql/2026-10-09-120000_user_crypto_v2/down.sql +++ /dev/null @@ -1,5 +0,0 @@ -DROP TABLE IF EXISTS user_signature_key_pairs; - -ALTER TABLE users DROP COLUMN signed_public_key; -ALTER TABLE users DROP COLUMN security_state; -ALTER TABLE users DROP COLUMN security_version; diff --git a/migrations/postgresql/2026-10-09-120000_user_crypto_v2/up.sql b/migrations/postgresql/2026-10-09-120000_user_crypto_v2/up.sql deleted file mode 100644 index d3186ef0..00000000 --- a/migrations/postgresql/2026-10-09-120000_user_crypto_v2/up.sql +++ /dev/null @@ -1,13 +0,0 @@ -ALTER TABLE users ADD COLUMN signed_public_key TEXT; -ALTER TABLE users ADD COLUMN security_state TEXT; -ALTER TABLE users ADD COLUMN security_version INTEGER; - -CREATE TABLE user_signature_key_pairs ( - uuid CHAR(36) NOT NULL PRIMARY KEY, - user_uuid CHAR(36) NOT NULL UNIQUE REFERENCES users (uuid) ON DELETE CASCADE, - signature_algorithm INTEGER NOT NULL, -- 0 = ed25519, 1 = mldsa44 - signing_key TEXT NOT NULL, - verifying_key TEXT NOT NULL, - created_at TIMESTAMP NOT NULL, - updated_at TIMESTAMP NOT NULL -); diff --git a/migrations/postgresql/2026-10-10-120000_add_custom_role_permissions/down.sql b/migrations/postgresql/2026-10-10-120000_add_custom_role_permissions/down.sql deleted file mode 100644 index 0cc5dd03..00000000 --- a/migrations/postgresql/2026-10-10-120000_add_custom_role_permissions/down.sql +++ /dev/null @@ -1,20 +0,0 @@ --- Dropping the permissions would silently take them away from the Custom members, who would also keep a role (4) the --- versions before this one don't know. So while there are any, this fails on the duplicate key below: change their --- role first, or restore the backup taken before the upgrade. It needs to run in one transaction, as Diesel does. -CREATE TEMPORARY TABLE custom_role_downgrade (blocked_by VARCHAR(255) NOT NULL PRIMARY KEY); -INSERT INTO custom_role_downgrade VALUES ('Custom members: change their role first, or restore the backup taken before the upgrade'); -INSERT INTO custom_role_downgrade - SELECT 'Custom members: change their role first, or restore the backup taken before the upgrade' - FROM users_organizations WHERE atype = 4 LIMIT 1; -DROP TABLE custom_role_downgrade; - -ALTER TABLE users_organizations - DROP COLUMN manage_users, - DROP COLUMN manage_groups, - DROP COLUMN manage_policies, - DROP COLUMN create_new_collections, - DROP COLUMN edit_any_collection, - DROP COLUMN delete_any_collection, - DROP COLUMN access_event_logs, - DROP COLUMN access_import_export, - DROP COLUMN access_reports; diff --git a/migrations/postgresql/2026-10-10-120000_add_custom_role_permissions/up.sql b/migrations/postgresql/2026-10-10-120000_add_custom_role_permissions/up.sql deleted file mode 100644 index d28b0d64..00000000 --- a/migrations/postgresql/2026-10-10-120000_add_custom_role_permissions/up.sql +++ /dev/null @@ -1,13 +0,0 @@ --- The granular permissions of the Custom member role. They only take effect on a Custom membership --- (atype 4) while EXPERIMENTAL_CUSTOM_ROLES is enabled, and every existing membership starts without any. --- A single statement, so an interrupted upgrade cannot leave only some of the columns behind. -ALTER TABLE users_organizations - ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT FALSE, - ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT FALSE, - ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT FALSE, - ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT FALSE, - ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE, - ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE, - ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT FALSE, - ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT FALSE, - ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/migrations/postgresql/2026-11-02-120000_reset_user_key_id/down.sql b/migrations/postgresql/2026-11-02-120000_reset_user_key_id/down.sql deleted file mode 100644 index e69de29b..00000000 diff --git a/migrations/postgresql/2026-11-02-120000_reset_user_key_id/up.sql b/migrations/postgresql/2026-11-02-120000_reset_user_key_id/up.sql deleted file mode 100644 index eeffa854..00000000 --- a/migrations/postgresql/2026-11-02-120000_reset_user_key_id/up.sql +++ /dev/null @@ -1,4 +0,0 @@ --- Key rotations before this kept the id of the old user key, so every new --- cipher was refused from then on. Stale ids can't be told apart, so clear --- all of them; clients record their current key's id again when it's empty. -UPDATE users SET key_id = NULL; diff --git a/migrations/postgresql/2026-11-04-120000_add_v2_upgrade_token/down.sql b/migrations/postgresql/2026-11-04-120000_add_v2_upgrade_token/down.sql deleted file mode 100644 index 89e12c15..00000000 --- a/migrations/postgresql/2026-11-04-120000_add_v2_upgrade_token/down.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE users DROP COLUMN v2_upgrade_token; -ALTER TABLE users_organizations DROP COLUMN v2_upgrade_token; diff --git a/migrations/postgresql/2026-11-04-120000_add_v2_upgrade_token/up.sql b/migrations/postgresql/2026-11-04-120000_add_v2_upgrade_token/up.sql deleted file mode 100644 index 45c58491..00000000 --- a/migrations/postgresql/2026-11-04-120000_add_v2_upgrade_token/up.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE users ADD COLUMN v2_upgrade_token TEXT; -ALTER TABLE users_organizations ADD COLUMN v2_upgrade_token TEXT; diff --git a/migrations/sqlite/2026-03-09-005927_add_archives/down.sql b/migrations/sqlite/2026-03-09-005927_add_archives/down.sql deleted file mode 100644 index a3ef20c3..00000000 --- a/migrations/sqlite/2026-03-09-005927_add_archives/down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS archives; diff --git a/migrations/sqlite/2026-03-09-005927_add_archives/up.sql b/migrations/sqlite/2026-03-09-005927_add_archives/up.sql deleted file mode 100644 index d624f57b..00000000 --- a/migrations/sqlite/2026-03-09-005927_add_archives/up.sql +++ /dev/null @@ -1,8 +0,0 @@ -DROP TABLE IF EXISTS archives; - -CREATE TABLE archives ( - user_uuid CHAR(36) NOT NULL REFERENCES users (uuid) ON DELETE CASCADE, - cipher_uuid CHAR(36) NOT NULL REFERENCES ciphers (uuid) ON DELETE CASCADE, - archived_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (user_uuid, cipher_uuid) -); diff --git a/migrations/sqlite/2026-04-25-120000_sso_auth_binding/down.sql b/migrations/sqlite/2026-04-25-120000_sso_auth_binding/down.sql deleted file mode 100644 index 17e3d8c7..00000000 --- a/migrations/sqlite/2026-04-25-120000_sso_auth_binding/down.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE sso_auth DROP COLUMN binding_hash; diff --git a/migrations/sqlite/2026-04-25-120000_sso_auth_binding/up.sql b/migrations/sqlite/2026-04-25-120000_sso_auth_binding/up.sql deleted file mode 100644 index 53ee8063..00000000 --- a/migrations/sqlite/2026-04-25-120000_sso_auth_binding/up.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE sso_auth ADD COLUMN binding_hash TEXT; diff --git a/migrations/sqlite/2026-05-05-120000_sso_auth_error/down.sql b/migrations/sqlite/2026-05-05-120000_sso_auth_error/down.sql deleted file mode 100644 index 98a6d836..00000000 --- a/migrations/sqlite/2026-05-05-120000_sso_auth_error/down.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE sso_auth DROP COLUMN code_response_error; diff --git a/migrations/sqlite/2026-05-05-120000_sso_auth_error/up.sql b/migrations/sqlite/2026-05-05-120000_sso_auth_error/up.sql deleted file mode 100644 index 6042a7d4..00000000 --- a/migrations/sqlite/2026-05-05-120000_sso_auth_error/up.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE sso_auth ADD COLUMN code_response_error TEXT; diff --git a/migrations/sqlite/2026-09-02-120000_add_key_id/down.sql b/migrations/sqlite/2026-09-02-120000_add_key_id/down.sql deleted file mode 100644 index e69de29b..00000000 diff --git a/migrations/sqlite/2026-09-02-120000_add_key_id/up.sql b/migrations/sqlite/2026-09-02-120000_add_key_id/up.sql deleted file mode 100644 index d25396cf..00000000 --- a/migrations/sqlite/2026-09-02-120000_add_key_id/up.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE users ADD COLUMN key_id TEXT; diff --git a/migrations/sqlite/2026-10-07-120000_add_org_policy_revision_date/down.sql b/migrations/sqlite/2026-10-07-120000_add_org_policy_revision_date/down.sql deleted file mode 100644 index e69de29b..00000000 diff --git a/migrations/sqlite/2026-10-07-120000_add_org_policy_revision_date/up.sql b/migrations/sqlite/2026-10-07-120000_add_org_policy_revision_date/up.sql deleted file mode 100644 index 86595711..00000000 --- a/migrations/sqlite/2026-10-07-120000_add_org_policy_revision_date/up.sql +++ /dev/null @@ -1,6 +0,0 @@ --- 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; diff --git a/migrations/sqlite/2026-10-08-120000_cipher_data_longtext/down.sql b/migrations/sqlite/2026-10-08-120000_cipher_data_longtext/down.sql deleted file mode 100644 index e69de29b..00000000 diff --git a/migrations/sqlite/2026-10-08-120000_cipher_data_longtext/up.sql b/migrations/sqlite/2026-10-08-120000_cipher_data_longtext/up.sql deleted file mode 100644 index f18eb816..00000000 --- a/migrations/sqlite/2026-10-08-120000_cipher_data_longtext/up.sql +++ /dev/null @@ -1 +0,0 @@ --- TEXT has no size limit here, only MySQL needed the change diff --git a/migrations/sqlite/2026-10-09-120000_user_crypto_v2/down.sql b/migrations/sqlite/2026-10-09-120000_user_crypto_v2/down.sql deleted file mode 100644 index 49700440..00000000 --- a/migrations/sqlite/2026-10-09-120000_user_crypto_v2/down.sql +++ /dev/null @@ -1,5 +0,0 @@ -DROP TABLE IF EXISTS user_signature_key_pairs; - -ALTER TABLE users DROP COLUMN signed_public_key; -ALTER TABLE users DROP COLUMN security_state; -ALTER TABLE users DROP COLUMN security_version; diff --git a/migrations/sqlite/2026-10-09-120000_user_crypto_v2/up.sql b/migrations/sqlite/2026-10-09-120000_user_crypto_v2/up.sql deleted file mode 100644 index c15e5b5e..00000000 --- a/migrations/sqlite/2026-10-09-120000_user_crypto_v2/up.sql +++ /dev/null @@ -1,13 +0,0 @@ -ALTER TABLE users ADD COLUMN signed_public_key TEXT; -ALTER TABLE users ADD COLUMN security_state TEXT; -ALTER TABLE users ADD COLUMN security_version INTEGER; - -CREATE TABLE user_signature_key_pairs ( - uuid TEXT NOT NULL PRIMARY KEY, - user_uuid TEXT NOT NULL UNIQUE REFERENCES users (uuid) ON DELETE CASCADE, - signature_algorithm INTEGER NOT NULL, -- 0 = ed25519, 1 = mldsa44 - signing_key TEXT NOT NULL, - verifying_key TEXT NOT NULL, - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL -); diff --git a/migrations/sqlite/2026-10-10-120000_add_custom_role_permissions/down.sql b/migrations/sqlite/2026-10-10-120000_add_custom_role_permissions/down.sql deleted file mode 100644 index 11c30bec..00000000 --- a/migrations/sqlite/2026-10-10-120000_add_custom_role_permissions/down.sql +++ /dev/null @@ -1,19 +0,0 @@ --- Dropping the permissions would silently take them away from the Custom members, who would also keep a role (4) the --- versions before this one don't know. So while there are any, this fails on the duplicate key below: change their --- role first, or restore the backup taken before the upgrade. It needs to run in one transaction, as Diesel does. -CREATE TEMPORARY TABLE custom_role_downgrade (blocked_by VARCHAR(255) NOT NULL PRIMARY KEY); -INSERT INTO custom_role_downgrade VALUES ('Custom members: change their role first, or restore the backup taken before the upgrade'); -INSERT INTO custom_role_downgrade - SELECT 'Custom members: change their role first, or restore the backup taken before the upgrade' - FROM users_organizations WHERE atype = 4 LIMIT 1; -DROP TABLE custom_role_downgrade; - -ALTER TABLE users_organizations DROP COLUMN manage_users; -ALTER TABLE users_organizations DROP COLUMN manage_groups; -ALTER TABLE users_organizations DROP COLUMN manage_policies; -ALTER TABLE users_organizations DROP COLUMN create_new_collections; -ALTER TABLE users_organizations DROP COLUMN edit_any_collection; -ALTER TABLE users_organizations DROP COLUMN delete_any_collection; -ALTER TABLE users_organizations DROP COLUMN access_event_logs; -ALTER TABLE users_organizations DROP COLUMN access_import_export; -ALTER TABLE users_organizations DROP COLUMN access_reports; diff --git a/migrations/sqlite/2026-10-10-120000_add_custom_role_permissions/up.sql b/migrations/sqlite/2026-10-10-120000_add_custom_role_permissions/up.sql deleted file mode 100644 index c708c693..00000000 --- a/migrations/sqlite/2026-10-10-120000_add_custom_role_permissions/up.sql +++ /dev/null @@ -1,11 +0,0 @@ --- The granular permissions of the Custom member role. They only take effect on a Custom membership --- (atype 4) while EXPERIMENTAL_CUSTOM_ROLES is enabled, and every existing membership starts without any. -ALTER TABLE users_organizations ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT 0; -- FALSE -ALTER TABLE users_organizations ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT 0; -- FALSE -ALTER TABLE users_organizations ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT 0; -- FALSE -ALTER TABLE users_organizations ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT 0; -- FALSE -ALTER TABLE users_organizations ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT 0; -- FALSE -ALTER TABLE users_organizations ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT 0; -- FALSE -ALTER TABLE users_organizations ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT 0; -- FALSE -ALTER TABLE users_organizations ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT 0; -- FALSE -ALTER TABLE users_organizations ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT 0; -- FALSE diff --git a/migrations/sqlite/2026-11-02-120000_reset_user_key_id/down.sql b/migrations/sqlite/2026-11-02-120000_reset_user_key_id/down.sql deleted file mode 100644 index e69de29b..00000000 diff --git a/migrations/sqlite/2026-11-02-120000_reset_user_key_id/up.sql b/migrations/sqlite/2026-11-02-120000_reset_user_key_id/up.sql deleted file mode 100644 index eeffa854..00000000 --- a/migrations/sqlite/2026-11-02-120000_reset_user_key_id/up.sql +++ /dev/null @@ -1,4 +0,0 @@ --- Key rotations before this kept the id of the old user key, so every new --- cipher was refused from then on. Stale ids can't be told apart, so clear --- all of them; clients record their current key's id again when it's empty. -UPDATE users SET key_id = NULL; diff --git a/migrations/sqlite/2026-11-04-120000_add_v2_upgrade_token/down.sql b/migrations/sqlite/2026-11-04-120000_add_v2_upgrade_token/down.sql deleted file mode 100644 index 89e12c15..00000000 --- a/migrations/sqlite/2026-11-04-120000_add_v2_upgrade_token/down.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE users DROP COLUMN v2_upgrade_token; -ALTER TABLE users_organizations DROP COLUMN v2_upgrade_token; diff --git a/migrations/sqlite/2026-11-04-120000_add_v2_upgrade_token/up.sql b/migrations/sqlite/2026-11-04-120000_add_v2_upgrade_token/up.sql deleted file mode 100644 index 45c58491..00000000 --- a/migrations/sqlite/2026-11-04-120000_add_v2_upgrade_token/up.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE users ADD COLUMN v2_upgrade_token TEXT; -ALTER TABLE users_organizations ADD COLUMN v2_upgrade_token TEXT; diff --git a/playwright/.env.template b/playwright/.env.template index 4ead281d..a6696aab 100644 --- a/playwright/.env.template +++ b/playwright/.env.template @@ -21,19 +21,11 @@ TEST_USER3=test3 TEST_USER3_PASSWORD=${TEST_USER3} TEST_USER3_MAIL=${TEST_USER3}@yopmail.com -TEST_USER4=test4 -TEST_USER4_PASSWORD=${TEST_USER4} -TEST_USER4_MAIL=${TEST_USER4}@yopmail.com - -TEST_USER5=test5 -TEST_USER5_PASSWORD=${TEST_USER5} -TEST_USER5_MAIL=${TEST_USER5}@yopmail.com - ################### # Keycloak Config # ################### -KC_BOOTSTRAP_ADMIN_USERNAME=admin -KC_BOOTSTRAP_ADMIN_PASSWORD=${KC_BOOTSTRAP_ADMIN_USERNAME} +KEYCLOAK_ADMIN=admin +KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN} KC_HTTP_HOST=127.0.0.1 KC_HTTP_PORT=8080 @@ -47,10 +39,8 @@ DUMMY_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${DUMMY_REALM} ###################### ROCKET_ADDRESS=0.0.0.0 ROCKET_PORT=8000 -ROCKET_TLS={certs="/data/ssl/localhost.crt",key="/data/ssl/localhost.key"} -DOMAIN=https://127.0.0.1:${ROCKET_PORT} +DOMAIN=http://localhost:${ROCKET_PORT} LOG_LEVEL=info,oidcwarden::sso=debug -SSO_DEBUG_TOKENS=true I_REALLY_WANT_VOLATILE_STORAGE=true SSO_ENABLED=true diff --git a/playwright/README.md b/playwright/README.md index cd18bebd..a27e6105 100644 --- a/playwright/README.md +++ b/playwright/README.md @@ -1,8 +1,8 @@ # Integration tests This allows running integration tests using [Playwright](https://playwright.dev/). -\ -It uses its own [test.env](./test.env) with different ports to not collide with a running dev instance. + +It uses its own `test.env` with different ports to not collide with a running dev instance. ## Install @@ -11,11 +11,11 @@ Databases (`Mariadb`, `Mysql` and `Postgres`) and `Playwright` will run in conta ### Running Playwright outside docker -It's possible to run `Playwright` outside of the container, this remove the need to rebuild the image for each change. -You'll additionally need `nodejs` then run: +It is possible to run `Playwright` outside of the container, this removes the need to rebuild the image for each change. +You will additionally need `nodejs` then run: ```bash -npm ci --ignore-scripts --allow-git=none --allow-remote=none +npm ci --ignore-scripts npx playwright install-deps npx playwright install firefox ``` @@ -62,10 +62,10 @@ DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Pl ### Keep services running -If you want you can keep the DB and Keycloak running (states are not impacted by the tests): +If you want you can keep the DB and Keycloak runnning (states are not impacted by the tests): ```bash -PW_KEEP_SERVICE_RUNNING=true npx playwright test +PW_KEEP_SERVICE_RUNNNING=true npx playwright test ``` ### Running specific tests @@ -77,7 +77,7 @@ DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Pl DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite login ``` -To run only a specific test (It might fail if it has dependency): +To run only a specifc test (It might fail if it has dependency): ```bash DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite -g "Account creation" @@ -92,7 +92,7 @@ This does not start the server, you will need to start it manually. ```bash DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env up Vaultwarden -npx playwright codegen "https://127.0.0.1:8000" --ignore-https-errors +npx playwright codegen "http://127.0.0.1:8003" ``` ## Override web-vault @@ -112,11 +112,12 @@ You can check the result running: DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env up Vaultwarden ``` -Then check `https://127.0.0.1:8003/admin/diagnostics` with `admin`. +Then check `http://127.0.0.1:8003/admin/diagnostics` with `admin`. # OpenID Connect test setup -Additionally this `docker-compose` template allow to run locally `Vaultwarden`, [Keycloak](https://www.keycloak.org/) and [Maildev](https://github.com/timshel/maildev) to test OIDC. +Additionally this `docker-compose` template allows to run locally Vaultwarden, +[Keycloak](https://www.keycloak.org/) and [Maildev](https://github.com/timshel/maildev) to test OIDC. ## Setup @@ -130,17 +131,18 @@ Then start the stack (the `profile` is required to run `Vaultwarden`) : ```bash > docker compose --profile vaultwarden --env-file .env up .... -keycloakSetup_1 | Logging into https://127.0.0.1:8080 as user admin of realm master +keycloakSetup_1 | Logging into http://127.0.0.1:8080 as user admin of realm master keycloakSetup_1 | Created new realm with id 'test' keycloakSetup_1 | 74af4933-e386-4e64-ba15-a7b61212c45e oidc_keycloakSetup_1 exited with code 0 ``` -Wait until `oidc_keycloakSetup_1 exited with code 0` which indicate the correct setup of the Keycloak realm, client and user (It's normal for this container to stop once the configuration is done). +Wait until `oidc_keycloakSetup_1 exited with code 0` which indicates the correct setup of the Keycloak realm, client and user +(It is normal for this container to stop once the configuration is done). Then you can access : -- `Vaultwarden` on https://0.0.0.0:8000 with the default user `test@yopmail.com/test`. +- `Vaultwarden` on http://0.0.0.0:8000 with the default user `test@yopmail.com/test`. - `Keycloak` on http://0.0.0.0:8080/admin/master/console/ with the default user `admin/admin` - `Maildev` on http://0.0.0.0:1080 @@ -169,7 +171,7 @@ docker compose --profile vaultwarden --env-file .env build VaultwardenPrebuild V All configuration for `keycloak` / `Vaultwarden` / `keycloak_setup.sh` can be found in [.env](.env.template). The content of the file will be loaded as environment variables in all containers. -- `keycloak` [configuration](https://www.keycloak.org/server/all-config) include `KC_BOOTSTRAP_ADMIN_USERNAME` / `KC_BOOTSTRAP_ADMIN_PASSWORD` and any variable prefixed `KC_` ([more information](https://www.keycloak.org/server/configuration#_example_configuring_the_db_url_host_parameter)). +- `keycloak` [configuration](https://www.keycloak.org/server/all-config) includes `KEYCLOAK_ADMIN` / `KEYCLOAK_ADMIN_PASSWORD` and any variable prefixed `KC_` ([more information](https://www.keycloak.org/server/configuration#_example_configuring_the_db_url_host_parameter)). - All `Vaultwarden` configuration can be set (EX: `SMTP_*`) ## Cleanup diff --git a/playwright/compose/keycloak/setup.sh b/playwright/compose/keycloak/setup.sh index f1d8a303..a27caaff 100755 --- a/playwright/compose/keycloak/setup.sh +++ b/playwright/compose/keycloak/setup.sh @@ -17,7 +17,7 @@ done set -e -kcadm.sh config credentials --server "http://${KC_HTTP_HOST}:${KC_HTTP_PORT}" --realm master --user "$KC_BOOTSTRAP_ADMIN_USERNAME" --password "$KC_BOOTSTRAP_ADMIN_PASSWORD" --client admin-cli +kcadm.sh config credentials --server "http://${KC_HTTP_HOST}:${KC_HTTP_PORT}" --realm master --user "$KEYCLOAK_ADMIN" --password "$KEYCLOAK_ADMIN_PASSWORD" --client admin-cli kcadm.sh create realms -s realm="$TEST_REALM" -s enabled=true -s "accessTokenLifespan=600" kcadm.sh create clients -r test -s "clientId=$SSO_CLIENT_ID" -s "secret=$SSO_CLIENT_SECRET" -s "redirectUris=[\"$DOMAIN/*\"]" -i @@ -39,6 +39,6 @@ kcadm.sh create realms -s realm="$DUMMY_REALM" -s enabled=true -s "accessTokenLi # THEN in another terminal: # docker exec -it keycloakSetup-dev /bin/bash # export PATH=$PATH:/opt/keycloak/bin -# kcadm.sh config credentials --server "http://${KC_HTTP_HOST}:${KC_HTTP_PORT}" --realm master --user "$KC_BOOTSTRAP_ADMIN_USERNAME" --password "$KC_BOOTSTRAP_ADMIN_PASSWORD" --client admin-cli +# kcadm.sh config credentials --server "http://${KC_HTTP_HOST}:${KC_HTTP_PORT}" --realm master --user "$KEYCLOAK_ADMIN" --password "$KEYCLOAK_ADMIN_PASSWORD" --client admin-cli # ENJOY # Doc: https://wjw465150.gitbooks.io/keycloak-documentation/content/server_admin/topics/admin-cli.html diff --git a/playwright/compose/playwright/Dockerfile b/playwright/compose/playwright/Dockerfile index 6b48c7dc..4dae1ae4 100644 --- a/playwright/compose/playwright/Dockerfile +++ b/playwright/compose/playwright/Dockerfile @@ -28,7 +28,7 @@ RUN mkdir /playwright WORKDIR /playwright COPY package.json package-lock.json . -RUN npm ci --ignore-scripts --allow-git=none --allow-remote=none && npx playwright install-deps && npx playwright install firefox +RUN npm ci --ignore-scripts && npx playwright install-deps && npx playwright install firefox COPY docker-compose.yml test.env ./ COPY compose ./compose diff --git a/playwright/compose/warden/Dockerfile b/playwright/compose/warden/Dockerfile index 9a369dab..e472d207 100644 --- a/playwright/compose/warden/Dockerfile +++ b/playwright/compose/warden/Dockerfile @@ -35,7 +35,6 @@ WORKDIR / COPY --from=prebuilt /start.sh . COPY --from=prebuilt /vaultwarden . -COPY --from=build /data ./data COPY --from=build /web-vault ./web-vault ENTRYPOINT ["/start.sh"] diff --git a/playwright/compose/warden/build.sh b/playwright/compose/warden/build.sh index ee8b47fe..37e9a25e 100755 --- a/playwright/compose/warden/build.sh +++ b/playwright/compose/warden/build.sh @@ -22,14 +22,3 @@ if [[ ! -z "$REPO_URL" ]] && [[ ! -z "$COMMIT_HASH" ]] ; then mv build /web-vault fi - -# Lower the KDF iterations default for faster tests. -sed -i 's/(6e5,2e6,6e5)/(1e5,2e6,1e5)/' /web-vault/app/main.*.js - -# Generate a self signed cert -mkdir -p /data/ssl; cd /data/ssl - -openssl req -x509 -out localhost.crt -keyout localhost.key \ - -newkey rsa:2048 -nodes -sha256 \ - -subj '/CN=localhost' -extensions EXT -config <( \ - printf "[dn]\nCN=localhost\n[req]\ndistinguished_name = dn\n[EXT]\nsubjectAltName=DNS:localhost\nkeyUsage=digitalSignature\nextendedKeyUsage=serverAuth") diff --git a/playwright/docker-compose.yml b/playwright/docker-compose.yml index 5bfc47a5..f4402326 100644 --- a/playwright/docker-compose.yml +++ b/playwright/docker-compose.yml @@ -24,15 +24,12 @@ services: environment: - ADMIN_TOKEN - DATABASE_URL - - CLIENT_SUPPRESS_ONBOARDING - - EMAIL_2FA_AUTO_FALLBACK - I_REALLY_WANT_VOLATILE_STORAGE - LOG_LEVEL - LOGIN_RATELIMIT_MAX_BURST - SMTP_HOST - SMTP_FROM - SMTP_DEBUG - - SSO_AUTH_ONLY_NOT_SESSION - SSO_DEBUG_TOKENS - SSO_ENABLED - SSO_FRONTEND @@ -61,7 +58,7 @@ services: Mariadb: profiles: ["playwright"] container_name: playwright_mariadb - image: mariadb:12.2.2 + image: mariadb:11.2.4 env_file: test.env healthcheck: test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] @@ -73,7 +70,7 @@ services: Mysql: profiles: ["playwright"] container_name: playwright_mysql - image: mysql:9.7.0 + image: mysql:8.4.1 env_file: test.env healthcheck: test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"] @@ -85,7 +82,7 @@ services: Postgres: profiles: ["playwright"] container_name: playwright_postgres - image: postgres:18.4 + image: postgres:16.3 env_file: test.env healthcheck: test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"] @@ -97,7 +94,7 @@ services: Maildev: profiles: ["vaultwarden", "maildev"] container_name: maildev - image: timshel/maildev:3.2.19 + image: timshel/maildev:3.0.4 ports: - ${SMTP_PORT}:1025 - 1080:1080 @@ -105,7 +102,7 @@ services: Keycloak: profiles: ["keycloak", "vaultwarden"] container_name: keycloak-${ENV:-dev} - image: quay.io/keycloak/keycloak:26.6.2 + image: quay.io/keycloak/keycloak:26.3.4 network_mode: "host" command: - start-dev @@ -115,12 +112,12 @@ services: profiles: ["keycloak", "vaultwarden"] container_name: keycloakSetup-${ENV:-dev} image: keycloak_setup-${ENV:-dev} - network_mode: "host" build: context: compose/keycloak dockerfile: Dockerfile args: - KEYCLOAK_VERSION: 26.6.2 + KEYCLOAK_VERSION: 26.3.4 + network_mode: "host" depends_on: - Keycloak restart: "no" diff --git a/playwright/global-setup.ts b/playwright/global-setup.ts index 9959d247..89405f12 100644 --- a/playwright/global-setup.ts +++ b/playwright/global-setup.ts @@ -1,4 +1,4 @@ -import { type FullConfig } from '@playwright/test'; +import { firefox, type FullConfig } from '@playwright/test'; import { execSync } from 'node:child_process'; import fs from 'fs'; diff --git a/playwright/global-utils.ts b/playwright/global-utils.ts index 937de651..224bb4b8 100644 --- a/playwright/global-utils.ts +++ b/playwright/global-utils.ts @@ -207,7 +207,7 @@ export async function startVault(browser: Browser, testInfo: TestInfo, env = {}, } export async function stopVault(force: boolean = false) { - if( force === false && process.env.PW_KEEP_SERVICE_RUNNING === "true" ) { + if( force === false && process.env.PW_KEEP_SERVICE_RUNNNING === "true" ) { console.log(`Keep vaultwarden running on: ${process.env.DOMAIN}`); } else { console.log(`Vaultwarden stopping`); @@ -231,7 +231,6 @@ export async function checkNotification(page: Page, hasText: string) { } export async function cleanLanding(page: Page) { - await page.context().clearCookies(); await page.goto('/', { waitUntil: 'domcontentloaded' }); await expect(page.getByRole('button').nth(0)).toBeVisible(); @@ -249,3 +248,15 @@ export async function logout(test: Test, page: Page, user: { name: string }) { await expect(page.getByRole('heading', { name: 'Log in' })).toBeVisible(); }); } + +export async function ignoreExtension(page: Page) { + await page.waitForLoadState('domcontentloaded'); + + try { + await page.getByRole('button', { name: 'Add it later' }).click({timeout: 5_000}); + await page.getByRole('link', { name: 'Skip to web app' }).click(); + } catch (error) { + console.log('Extension setup not visible. Continuing'); + } + +} diff --git a/playwright/package-lock.json b/playwright/package-lock.json index 57f5bcaf..2f4cd0c1 100644 --- a/playwright/package-lock.json +++ b/playwright/package-lock.json @@ -9,56 +9,41 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "mysql2": "3.22.3", - "otpauth": "9.5.1", - "pg": "8.21.0" + "mysql2": "3.15.3", + "otpauth": "9.4.1", + "pg": "8.16.3" }, "devDependencies": { - "@playwright/test": "1.60.0", - "dotenv": "17.4.2", - "dotenv-expand": "13.0.0", - "maildev": "npm:@timshel_npm/maildev@3.2.19" + "@playwright/test": "1.56.1", + "dotenv": "17.2.3", + "dotenv-expand": "12.0.3", + "maildev": "npm:@timshel_npm/maildev@3.2.5" } }, "node_modules/@asamuzakjp/css-color": { - "version": "5.1.11", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", - "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.0.5.tgz", + "integrity": "sha512-lMrXidNhPGsDjytDy11Vwlb6OIGrT3CmLg3VWNFyWkLWtijKl7xjvForlh8vuj0SHGjgl4qZEQzUmYTeQA2JFQ==", "dev": true, "dependencies": { - "@asamuzakjp/generational-cache": "^1.0.1", - "@csstools/css-calc": "^3.2.0", - "@csstools/css-color-parser": "^4.1.0", - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "@csstools/css-calc": "^2.1.4", + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "lru-cache": "^11.2.1" } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", - "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "version": "6.7.3", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.3.tgz", + "integrity": "sha512-kiGFeY+Hxf5KbPpjRLf+ffWbkos1aGo8MBfd91oxS3O57RgU3XhZrt/6UzoVF9VMpWbC3v87SRc9jxGrc9qHtQ==", "dev": true, "dependencies": { - "@asamuzakjp/generational-cache": "^1.0.1", "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", - "css-tree": "^3.2.1", - "is-potential-custom-element-name": "^1.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/@asamuzakjp/generational-cache": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", - "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", - "dev": true, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.2" } }, "node_modules/@asamuzakjp/nwsapi": { @@ -67,22 +52,10 @@ "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", "dev": true }, - "node_modules/@bramus/specificity": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", - "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", - "dev": true, - "dependencies": { - "css-tree": "^3.0.0" - }, - "bin": { - "specificity": "bin/cli.js" - } - }, "node_modules/@csstools/color-helpers": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", - "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", "dev": true, "funding": [ { @@ -95,13 +68,13 @@ } ], "engines": { - "node": ">=20.19.0" + "node": ">=18" } }, "node_modules/@csstools/css-calc": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", - "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", "dev": true, "funding": [ { @@ -114,17 +87,17 @@ } ], "engines": { - "node": ">=20.19.0" + "node": ">=18" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" } }, "node_modules/@csstools/css-color-parser": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz", - "integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", "dev": true, "funding": [ { @@ -137,21 +110,21 @@ } ], "dependencies": { - "@csstools/color-helpers": "^6.0.2", - "@csstools/css-calc": "^3.2.1" + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" }, "engines": { - "node": ">=20.19.0" + "node": ">=18" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" } }, "node_modules/@csstools/css-parser-algorithms": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", - "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", "dev": true, "funding": [ { @@ -164,16 +137,16 @@ } ], "engines": { - "node": ">=20.19.0" + "node": ">=18" }, "peerDependencies": { - "@csstools/css-tokenizer": "^4.0.0" + "@csstools/css-tokenizer": "^3.0.4" } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.4.tgz", - "integrity": "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==", + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.15.tgz", + "integrity": "sha512-q0p6zkVq2lJnmzZVPR33doA51G7YOja+FBvRdp5ISIthL0MtFCgYHHhR563z9WFGxcOn0WfjSkPDJ5Qig3H3Sw==", "dev": true, "funding": [ { @@ -185,19 +158,14 @@ "url": "https://opencollective.com/csstools" } ], - "peerDependencies": { - "css-tree": "^3.2.1" - }, - "peerDependenciesMeta": { - "css-tree": { - "optional": true - } + "engines": { + "node": ">=18" } }, "node_modules/@csstools/css-tokenizer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", - "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", "dev": true, "funding": [ { @@ -210,44 +178,27 @@ } ], "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/@exodus/bytes": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", - "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", - "dev": true, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@noble/hashes": "^1.8.0 || ^2.0.0" - }, - "peerDependenciesMeta": { - "@noble/hashes": { - "optional": true - } + "node": ">=18" } }, "node_modules/@noble/hashes": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", - "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", "engines": { - "node": ">= 20.19.0" + "node": "^14.21.3 || >=16" }, "funding": { "url": "https://paulmillr.com/funding/" } }, "node_modules/@playwright/test": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", - "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz", + "integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==", "dev": true, "dependencies": { - "playwright": "1.60.0" + "playwright": "1.56.1" }, "bin": { "playwright": "cli.js" @@ -295,11 +246,12 @@ } }, "node_modules/@types/node": { - "version": "24.5.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.5.2.tgz", - "integrity": "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ==", + "version": "24.2.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.1.tgz", + "integrity": "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ==", + "dev": true, "dependencies": { - "undici-types": "~7.12.0" + "undici-types": "~7.10.0" } }, "node_modules/@types/trusted-types": { @@ -309,38 +261,6 @@ "dev": true, "optional": true }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@zone-eu/mailsplit": { - "version": "5.4.8", - "resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.8.tgz", - "integrity": "sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA==", - "dev": true, - "dependencies": { - "libbase64": "1.3.0", - "libmime": "5.3.7", - "libqp": "2.1.1" - } - }, - "node_modules/@zone-eu/mailsplit/node_modules/libmime": { - "version": "5.3.7", - "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.7.tgz", - "integrity": "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==", - "dev": true, - "dependencies": { - "encoding-japanese": "2.2.0", - "iconv-lite": "0.6.3", - "libbase64": "1.3.0", - "libqp": "2.1.1" - } - }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -369,6 +289,15 @@ "integrity": "sha512-aQX7AISOMM7HFE0iZ3+YnD07oIeJqWGVnJ+ZIKaBZAk03ftmVYVqsGas/rbXKR21n4D/hKCSHypvcyOkds/xzg==", "dev": true }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "engines": { + "node": ">= 14" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -383,6 +312,15 @@ "node": ">= 6.0.0" } }, + "node_modules/base32.js": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.1.0.tgz", + "integrity": "sha512-n3TkB02ixgBOhTvANakDb4xaMXnYUVkNoRFJjQflcqMQhyEKxEHdj3E6N8t8sUQ0mjH/3/JxzlXuz3ul/J90pQ==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/base64id": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", @@ -402,33 +340,29 @@ } }, "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", + "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", "dev": true, "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", - "debug": "^4.4.3", + "debug": "^4.4.0", "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "iconv-lite": "^0.6.3", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.14.0", + "raw-body": "^3.0.0", + "type-is": "^2.0.0" }, "engines": { "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" } }, "node_modules/body-parser/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", "dev": true, "dependencies": { "ms": "^2.1.3" @@ -442,22 +376,6 @@ } } }, - "node_modules/body-parser/node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "dev": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/body-parser/node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -503,9 +421,9 @@ } }, "node_modules/commander": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.1.tgz", + "integrity": "sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A==", "dev": true, "engines": { "node": ">=20" @@ -542,16 +460,15 @@ } }, "node_modules/content-disposition": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", - "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", "dev": true, - "engines": { - "node": ">=18" + "dependencies": { + "safe-buffer": "5.2.1" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "engines": { + "node": ">= 0.6" } }, "node_modules/content-type": { @@ -582,9 +499,9 @@ } }, "node_modules/cors": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", "dev": true, "dependencies": { "object-assign": "^4", @@ -592,36 +509,46 @@ }, "engines": { "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" } }, "node_modules/css-tree": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", - "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", "dev": true, "dependencies": { - "mdn-data": "2.27.1", - "source-map-js": "^1.2.1" + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" }, "engines": { "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, - "node_modules/data-urls": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", - "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "node_modules/cssstyle": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.1.tgz", + "integrity": "sha512-g5PC9Aiph9eiczFpcgUhd9S4UUO3F+LHGRIi5NUMZ+4xtoIYbHNZwZnWA2JsFGe8OU8nl4WyaEFiZuGuxlutJQ==", "dev": true, "dependencies": { - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0" + "@asamuzakjp/css-color": "^4.0.3", + "@csstools/css-syntax-patches-for-csstree": "^1.0.14", + "css-tree": "^3.1.0" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": ">=20" + } + }, + "node_modules/data-urls": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", + "integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==", + "dev": true, + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.0.0" + }, + "engines": { + "node": ">=20" } }, "node_modules/debug": { @@ -707,9 +634,9 @@ } }, "node_modules/dompurify": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.1.tgz", - "integrity": "sha512-JahakDAIg1gyOm7dlgWSDjV4n7Ip2PKR55NIT6jrMfIgLFgWo81vdr1/QGqWtFNRqXP9UV71oVePtjqS2ebnPw==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.0.tgz", + "integrity": "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==", "dev": true, "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -730,9 +657,9 @@ } }, "node_modules/dotenv": { - "version": "17.4.2", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", - "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", "dev": true, "engines": { "node": ">=12" @@ -742,12 +669,12 @@ } }, "node_modules/dotenv-expand": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-13.0.0.tgz", - "integrity": "sha512-aBfBS8eYIeXmpHI9ThIlA7/WLq+SLt18iXUZhb52rW89QLKQFoIpPG1bPeewoPZsTyjSSO3T7234FBVUM1V2rA==", + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.3.tgz", + "integrity": "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==", "dev": true, "dependencies": { - "dotenv": "^17.4.2" + "dotenv": "^16.4.5" }, "engines": { "node": ">=12" @@ -756,6 +683,18 @@ "url": "https://dotenvx.com" } }, + "node_modules/dotenv-expand/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -795,21 +734,20 @@ } }, "node_modules/engine.io": { - "version": "6.6.8", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.8.tgz", - "integrity": "sha512-2agL3ueZhqxoVrfmntO8yuVj+uNSlIOnhykYHk3Cq0ShYPdUjjUiSJrQvXjq01I9jAuI0Zl2YO8Evv5Mqytm5g==", + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.4.tgz", + "integrity": "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==", "dev": true, "dependencies": { "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", - "@types/ws": "^8.5.12", "accepts": "~1.3.4", "base64id": "2.0.0", "cookie": "~0.7.2", "cors": "~2.8.5", - "debug": "~4.4.1", + "debug": "~4.3.1", "engine.io-parser": "~5.2.1", - "ws": "~8.20.1" + "ws": "~8.17.1" }, "engines": { "node": ">=10.2.0" @@ -838,9 +776,9 @@ } }, "node_modules/engine.io/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", "dev": true, "dependencies": { "ms": "^2.1.3" @@ -890,6 +828,27 @@ "node": ">= 0.6" } }, + "node_modules/engine.io/node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -948,19 +907,18 @@ } }, "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", "dev": true, "dependencies": { "accepts": "^2.0.0", - "body-parser": "^2.2.1", + "body-parser": "^2.2.0", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", - "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", @@ -991,9 +949,9 @@ } }, "node_modules/express/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", "dev": true, "dependencies": { "ms": "^2.1.3" @@ -1014,9 +972,9 @@ "dev": true }, "node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", "dev": true, "dependencies": { "debug": "^4.4.0", @@ -1027,17 +985,13 @@ "statuses": "^2.0.1" }, "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">= 0.8" } }, "node_modules/finalhandler/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", "dev": true, "dependencies": { "ms": "^2.1.3" @@ -1168,9 +1122,9 @@ } }, "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "dev": true, "dependencies": { "function-bind": "^1.1.2" @@ -1189,15 +1143,15 @@ } }, "node_modules/html-encoding-sniffer": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", - "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", "dev": true, "dependencies": { - "@exodus/bytes": "^1.6.0" + "whatwg-encoding": "^3.1.1" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": ">=18" } }, "node_modules/html-to-text": { @@ -1236,25 +1190,102 @@ } }, "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", "dev": true, "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" }, "engines": { "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" } }, + "node_modules/http-errors/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/http-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -1306,35 +1337,34 @@ "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==" }, "node_modules/jsdom": { - "version": "29.1.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.0.tgz", - "integrity": "sha512-YNUc7fB9QuvSSQWfrH0xF+TyABkxUwx8sswgIDaCrw4Hol8BghdZDkITtZheRJeMtzWlnTfsM3bBBusRvpO1wg==", + "version": "27.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.0.1.tgz", + "integrity": "sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA==", "dev": true, "dependencies": { - "@asamuzakjp/css-color": "^5.1.11", - "@asamuzakjp/dom-selector": "^7.1.1", - "@bramus/specificity": "^2.4.2", - "@csstools/css-syntax-patches-for-csstree": "^1.1.3", - "@exodus/bytes": "^1.15.0", - "css-tree": "^3.2.1", - "data-urls": "^7.0.0", + "@asamuzakjp/dom-selector": "^6.7.2", + "cssstyle": "^5.3.1", + "data-urls": "^6.0.0", "decimal.js": "^10.6.0", - "html-encoding-sniffer": "^6.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.3.5", - "parse5": "^8.0.1", + "parse5": "^8.0.0", + "rrweb-cssom": "^0.8.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.1", - "undici": "^7.25.0", + "tough-cookie": "^6.0.0", "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^8.0.1", - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.1", + "webidl-conversions": "^8.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", "xml-name-validator": "^5.0.0" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + "node": ">=20" }, "peerDependencies": { "canvas": "^3.0.0" @@ -1361,33 +1391,17 @@ "dev": true }, "node_modules/libmime": { - "version": "5.3.8", - "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.8.tgz", - "integrity": "sha512-ZrCY+Q66mPvasAfjsQ/IgahzoBvfE1VdtGRpo1hwRB1oK3wJKxhKA3GOcd2a6j7AH5eMFccxK9fBoCpRZTf8ng==", + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.7.tgz", + "integrity": "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==", "dev": true, "dependencies": { "encoding-japanese": "2.2.0", - "iconv-lite": "0.7.2", + "iconv-lite": "0.6.3", "libbase64": "1.3.0", "libqp": "2.1.1" } }, - "node_modules/libmime/node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "dev": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/libqp": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz", @@ -1409,18 +1423,18 @@ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==" }, "node_modules/lru-cache": { - "version": "11.5.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", - "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", + "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", "dev": true, "engines": { "node": "20 || >=22" } }, "node_modules/lru.min": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", - "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.2.tgz", + "integrity": "sha512-Nv9KddBcQSlQopmBHXSsZVY5xsdlZkdH/Iey0BlcBYggMd4two7cZnKOK9vmy3nY0O5RGH99z1PCeTpPqszUYg==", "engines": { "bun": ">=1.0.0", "deno": ">=1.30.0", @@ -1433,25 +1447,25 @@ }, "node_modules/maildev": { "name": "@timshel_npm/maildev", - "version": "3.2.19", - "resolved": "https://registry.npmjs.org/@timshel_npm/maildev/-/maildev-3.2.19.tgz", - "integrity": "sha512-A/f07Fe7hCFy/2cUo0xg2r349RvOAHi4TuqOlZXxPuWShWbiSbvYcWr5wDz7G8aV5RyggNP0m+yB/lwmYx5OQg==", + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/@timshel_npm/maildev/-/maildev-3.2.5.tgz", + "integrity": "sha512-suWQu2s2kmO+MXtNJYW9peklznhd+aorIUb4tSNrfaKoEJjDa3vLXTvWf+3cb67o4Yv4Z6nPeKdMTCDZVn/Nyw==", "dev": true, "dependencies": { "@types/mailparser": "3.4.6", "addressparser": "1.0.1", "async": "3.2.6", - "commander": "14.0.3", + "commander": "14.0.1", "compression": "1.8.1", - "cors": "2.8.6", - "dompurify": "3.4.1", - "express": "5.2.1", - "jsdom": "29.1.0", - "mailparser": "3.9.8", + "cors": "2.8.5", + "dompurify": "3.3.0", + "express": "5.1.0", + "jsdom": "27.0.1", + "mailparser": "3.7.5", "mime": "4.1.0", - "nodemailer": "8.0.7", - "smtp-server": "3.18.4", - "socket.io": "4.8.3", + "nodemailer": "7.0.9", + "smtp-server": "3.15.0", + "socket.io": "4.8.1", "wildstring": "1.0.9" }, "bin": { @@ -1462,27 +1476,27 @@ } }, "node_modules/mailparser": { - "version": "3.9.8", - "resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.8.tgz", - "integrity": "sha512-7jSlFGXiianVnhnb6wdutJFloD34488nrHY7r6FNqwXAhZ7YiJDYrKKTxZJ0oSrXcAPHm8YoYnh97xyGtrBQ3w==", + "version": "3.7.5", + "resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.7.5.tgz", + "integrity": "sha512-o59RgZC+4SyCOn4xRH1mtRiZ1PbEmi6si6Ufnd3tbX/V9zmZN1qcqu8xbXY62H6CwIclOT3ppm5u/wV2nujn4g==", "dev": true, "dependencies": { - "@zone-eu/mailsplit": "5.4.8", "encoding-japanese": "2.2.0", "he": "1.2.0", "html-to-text": "9.0.5", - "iconv-lite": "0.7.2", - "libmime": "5.3.8", + "iconv-lite": "0.7.0", + "libmime": "5.3.7", "linkify-it": "5.0.0", - "nodemailer": "8.0.5", + "mailsplit": "5.4.6", + "nodemailer": "7.0.9", "punycode.js": "2.3.1", - "tlds": "1.261.0" + "tlds": "1.260.0" } }, "node_modules/mailparser/node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", "dev": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -1495,13 +1509,16 @@ "url": "https://opencollective.com/express" } }, - "node_modules/mailparser/node_modules/nodemailer": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz", - "integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==", + "node_modules/mailsplit": { + "version": "5.4.6", + "resolved": "https://registry.npmjs.org/mailsplit/-/mailsplit-5.4.6.tgz", + "integrity": "sha512-M+cqmzaPG/mEiCDmqQUz8L177JZLZmXAUpq38owtpq2xlXlTSw+kntnxRt2xsxVFFV6+T8Mj/U0l5s7s6e0rNw==", + "deprecated": "This package has been renamed to @zone-eu/mailsplit. Please update your dependencies.", "dev": true, - "engines": { - "node": ">=6.0.0" + "dependencies": { + "libbase64": "1.3.0", + "libmime": "5.3.7", + "libqp": "2.1.1" } }, "node_modules/math-intrinsics": { @@ -1514,9 +1531,9 @@ } }, "node_modules/mdn-data": { - "version": "2.27.1", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", - "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", "dev": true }, "node_modules/media-typer": { @@ -1565,19 +1582,15 @@ } }, "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", "dev": true, "dependencies": { "mime-db": "^1.54.0" }, "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">= 0.6" } }, "node_modules/ms": { @@ -1587,30 +1600,28 @@ "dev": true }, "node_modules/mysql2": { - "version": "3.22.3", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.22.3.tgz", - "integrity": "sha512-uWWxvZSRvRhtBdh2CdcuK83YcOfPdmEeEYB069bAmPnV93QApDGVPuvCQOLjlh7tYHEWdgQPrn6kosDxHBVLkA==", + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", + "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", "dependencies": { - "aws-ssl-profiles": "^1.1.2", + "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", "generate-function": "^2.3.1", - "iconv-lite": "^0.7.2", - "long": "^5.3.2", - "lru.min": "^1.1.4", - "named-placeholders": "^1.1.6", - "sql-escaper": "^1.3.3" + "iconv-lite": "^0.7.0", + "long": "^5.2.1", + "lru.min": "^1.0.0", + "named-placeholders": "^1.1.3", + "seq-queue": "^0.0.5", + "sqlstring": "^2.3.2" }, "engines": { "node": ">= 8.0" - }, - "peerDependencies": { - "@types/node": ">= 8" } }, "node_modules/mysql2/node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, @@ -1623,14 +1634,22 @@ } }, "node_modules/named-placeholders": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", - "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz", + "integrity": "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==", "dependencies": { - "lru.min": "^1.1.0" + "lru-cache": "^7.14.1" }, "engines": { - "node": ">=8.0.0" + "node": ">=12.0.0" + } + }, + "node_modules/named-placeholders/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "engines": { + "node": ">=12" } }, "node_modules/negotiator": { @@ -1643,9 +1662,9 @@ } }, "node_modules/nodemailer": { - "version": "8.0.7", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz", - "integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==", + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.9.tgz", + "integrity": "sha512-9/Qm0qXIByEP8lEV2qOqcAW7bRpL8CR9jcTwk3NBnHJNmP9fIJ86g2fgmIXqHY+nj55ZEMwWqYAT2QTDpRUYiQ==", "dev": true, "engines": { "node": ">=6.0.0" @@ -1703,35 +1722,35 @@ } }, "node_modules/otpauth": { - "version": "9.5.1", - "resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.5.1.tgz", - "integrity": "sha512-fJmDAHc8wImfqqqOXIlBvT1dEKrZK0Cmb2VEgScpNTolCz0PHh6ExUZGv4sLtOsWNaHCQlD+rRqaPgnoxFoZjQ==", + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.4.1.tgz", + "integrity": "sha512-+iVvys36CFsyXEqfNftQm1II7SW23W1wx9RwNk0Cd97lbvorqAhBDksb/0bYry087QMxjiuBS0wokdoZ0iUeAw==", "dependencies": { - "@noble/hashes": "2.2.0" + "@noble/hashes": "1.8.0" }, "funding": { "url": "https://github.com/hectorm/otpauth?sponsor=1" } }, "node_modules/parse5": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", - "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", "dev": true, "dependencies": { - "entities": "^8.0.0" + "entities": "^6.0.0" }, "funding": { "url": "https://github.com/inikulin/parse5?sponsor=1" } }, "node_modules/parse5/node_modules/entities": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", - "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", "dev": true, "engines": { - "node": ">=20.19.0" + "node": ">=0.12" }, "funding": { "url": "https://github.com/fb55/entities?sponsor=1" @@ -1760,13 +1779,12 @@ } }, "node_modules/path-to-regexp": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", - "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", + "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", "dev": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "engines": { + "node": ">=16" } }, "node_modules/peberminta": { @@ -1779,13 +1797,13 @@ } }, "node_modules/pg": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", - "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", + "version": "8.16.3", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", + "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "dependencies": { - "pg-connection-string": "^2.13.0", - "pg-pool": "^3.14.0", - "pg-protocol": "^1.14.0", + "pg-connection-string": "^2.9.1", + "pg-pool": "^3.10.1", + "pg-protocol": "^1.10.3", "pg-types": "2.2.0", "pgpass": "1.0.5" }, @@ -1793,7 +1811,7 @@ "node": ">= 16.0.0" }, "optionalDependencies": { - "pg-cloudflare": "^1.4.0" + "pg-cloudflare": "^1.2.7" }, "peerDependencies": { "pg-native": ">=3.0.1" @@ -1805,15 +1823,15 @@ } }, "node_modules/pg-cloudflare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", - "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz", + "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==", "optional": true }, "node_modules/pg-connection-string": { - "version": "2.13.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz", - "integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==" + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz", + "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==" }, "node_modules/pg-int8": { "version": "1.0.1", @@ -1824,17 +1842,17 @@ } }, "node_modules/pg-pool": { - "version": "3.14.0", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", - "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz", + "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==", "peerDependencies": { "pg": ">=8.0" } }, "node_modules/pg-protocol": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz", - "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==" + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz", + "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==" }, "node_modules/pg-types": { "version": "2.2.0", @@ -1860,12 +1878,12 @@ } }, "node_modules/playwright": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", - "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", + "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", "dev": true, "dependencies": { - "playwright-core": "1.60.0" + "playwright-core": "1.56.1" }, "bin": { "playwright": "cli.js" @@ -1878,9 +1896,9 @@ } }, "node_modules/playwright-core": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", - "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", + "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", "dev": true, "bin": { "playwright-core": "cli.js" @@ -1956,9 +1974,9 @@ } }, "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", "dev": true, "dependencies": { "side-channel": "^1.1.0" @@ -1980,34 +1998,18 @@ } }, "node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", + "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", "dev": true, "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.6.3", + "unpipe": "1.0.0" }, "engines": { - "node": ">= 0.10" - } - }, - "node_modules/raw-body/node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "dev": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">= 0.8" } }, "node_modules/require-from-string": { @@ -2036,9 +2038,9 @@ } }, "node_modules/router/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", "dev": true, "dependencies": { "ms": "^2.1.3" @@ -2058,6 +2060,12 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -2108,35 +2116,31 @@ } }, "node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", "dev": true, "dependencies": { - "debug": "^4.4.3", + "debug": "^4.3.5", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", - "statuses": "^2.0.2" + "statuses": "^2.0.1" }, "engines": { "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" } }, "node_modules/send/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", "dev": true, "dependencies": { "ms": "^2.1.3" @@ -2156,10 +2160,15 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true }, + "node_modules/seq-queue": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" + }, "node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", "dev": true, "dependencies": { "encodeurl": "^2.0.0", @@ -2169,10 +2178,6 @@ }, "engines": { "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" } }, "node_modules/setprototypeof": { @@ -2201,13 +2206,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", - "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", "dev": true, "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.4" + "object-inspect": "^1.13.3" }, "engines": { "node": ">= 0.4" @@ -2254,38 +2259,30 @@ } }, "node_modules/smtp-server": { - "version": "3.18.4", - "resolved": "https://registry.npmjs.org/smtp-server/-/smtp-server-3.18.4.tgz", - "integrity": "sha512-9EnXPG4Tv+2P/TSEUdFTduYn9IxtxNRsOq/ryVj8ZlT+6MU2um9gn2Td2hHlgH1n+saagMWtici3hn5J5PhU+g==", + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/smtp-server/-/smtp-server-3.15.0.tgz", + "integrity": "sha512-yv945vk0/xcukSKAoIhGz6GOlcXoCyGQH2w9IlLrTKk3SJiOBH9bcO6tD0ILTZYJsMqRa6OTRZAyqeuLXkv59Q==", "dev": true, "dependencies": { + "base32.js": "0.1.0", "ipv6-normalize": "1.0.1", - "nodemailer": "8.0.5", + "nodemailer": "7.0.9", "punycode.js": "2.3.1" }, "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/smtp-server/node_modules/nodemailer": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz", - "integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==", - "dev": true, - "engines": { - "node": ">=6.0.0" + "node": ">=12.0.0" } }, "node_modules/socket.io": { - "version": "4.8.3", - "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", - "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz", + "integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==", "dev": true, "dependencies": { "accepts": "~1.3.4", "base64id": "~2.0.0", "cors": "~2.8.5", - "debug": "~4.4.1", + "debug": "~4.3.2", "engine.io": "~6.6.0", "socket.io-adapter": "~2.5.2", "socket.io-parser": "~4.2.4" @@ -2295,19 +2292,19 @@ } }, "node_modules/socket.io-adapter": { - "version": "2.5.7", - "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.7.tgz", - "integrity": "sha512-e0LyK91f3cUxTmv95/KzoLg47+zF+s/sbxRGDNsyG4dmIP8ZSX8ax6byOxfJXeNNtS/8AZlfD+uP7gBeR7DLlg==", + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz", + "integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==", "dev": true, "dependencies": { - "debug": "~4.4.1", - "ws": "~8.20.1" + "debug": "~4.3.4", + "ws": "~8.17.1" } }, "node_modules/socket.io-adapter/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", "dev": true, "dependencies": { "ms": "^2.1.3" @@ -2327,23 +2324,44 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true }, + "node_modules/socket.io-adapter/node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/socket.io-parser": { - "version": "4.2.6", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", - "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", "dev": true, "dependencies": { "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.4.1" + "debug": "~4.3.1" }, "engines": { "node": ">=10.0.0" } }, "node_modules/socket.io-parser/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", "dev": true, "dependencies": { "ms": "^2.1.3" @@ -2377,9 +2395,9 @@ } }, "node_modules/socket.io/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", "dev": true, "dependencies": { "ms": "^2.1.3" @@ -2446,18 +2464,12 @@ "node": ">= 10.x" } }, - "node_modules/sql-escaper": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz", - "integrity": "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==", + "node_modules/sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", "engines": { - "bun": ">=1.0.0", - "deno": ">=2.0.0", - "node": ">=12.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" + "node": ">= 0.6" } }, "node_modules/statuses": { @@ -2476,30 +2488,30 @@ "dev": true }, "node_modules/tlds": { - "version": "1.261.0", - "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz", - "integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==", + "version": "1.260.0", + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.260.0.tgz", + "integrity": "sha512-78+28EWBhCEE7qlyaHA9OR3IPvbCLiDh3Ckla593TksfFc9vfTsgvH7eS+dr3o9qr31gwGbogcI16yN91PoRjQ==", "dev": true, "bin": { "tlds": "bin.js" } }, "node_modules/tldts": { - "version": "7.0.30", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz", - "integrity": "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==", + "version": "7.0.17", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.17.tgz", + "integrity": "sha512-Y1KQBgDd/NUc+LfOtKS6mNsC9CCaH+m2P1RoIZy7RAPo3C3/t8X45+zgut31cRZtZ3xKPjfn3TkGTrctC2TQIQ==", "dev": true, "dependencies": { - "tldts-core": "^7.0.30" + "tldts-core": "^7.0.17" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.0.30", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.30.tgz", - "integrity": "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==", + "version": "7.0.17", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.17.tgz", + "integrity": "sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g==", "dev": true }, "node_modules/toidentifier": { @@ -2512,9 +2524,9 @@ } }, "node_modules/tough-cookie": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", - "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", "dev": true, "dependencies": { "tldts": "^7.0.5" @@ -2536,34 +2548,17 @@ } }, "node_modules/type-is": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", - "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", "dev": true, "dependencies": { - "content-type": "^2.0.0", + "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/type-is/node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", - "dev": true, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">= 0.6" } }, "node_modules/uc.micro": { @@ -2572,19 +2567,11 @@ "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", "dev": true }, - "node_modules/undici": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", - "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", - "dev": true, - "engines": { - "node": ">=20.18.1" - } - }, "node_modules/undici-types": { - "version": "7.12.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz", - "integrity": "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==" + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", + "dev": true }, "node_modules/unpipe": { "version": "1.0.0", @@ -2617,35 +2604,46 @@ } }, "node_modules/webidl-conversions": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", - "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz", + "integrity": "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==", "dev": true, "engines": { "node": ">=20" } }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dev": true, + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-mimetype": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", - "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", "dev": true, "engines": { - "node": ">=20" + "node": ">=18" } }, "node_modules/whatwg-url": { - "version": "16.0.1", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", - "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", "dev": true, "dependencies": { - "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", - "webidl-conversions": "^8.0.1" + "webidl-conversions": "^8.0.0" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": ">=20" } }, "node_modules/wildstring": { @@ -2661,9 +2659,9 @@ "dev": true }, "node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "dev": true, "engines": { "node": ">=10.0.0" diff --git a/playwright/package.json b/playwright/package.json index a7a35734..f47ec5dc 100644 --- a/playwright/package.json +++ b/playwright/package.json @@ -8,14 +8,14 @@ "author": "", "license": "ISC", "devDependencies": { - "@playwright/test": "1.60.0", - "dotenv": "17.4.2", - "dotenv-expand": "13.0.0", - "maildev": "npm:@timshel_npm/maildev@3.2.19" + "@playwright/test": "1.56.1", + "dotenv": "17.2.3", + "dotenv-expand": "12.0.3", + "maildev": "npm:@timshel_npm/maildev@3.2.5" }, "dependencies": { - "mysql2": "3.22.3", - "otpauth": "9.5.1", - "pg": "8.21.0" + "mysql2": "3.15.3", + "otpauth": "9.4.1", + "pg": "8.16.3" } } diff --git a/playwright/playwright.config.ts b/playwright/playwright.config.ts index ba5885d9..de721aa3 100644 --- a/playwright/playwright.config.ts +++ b/playwright/playwright.config.ts @@ -25,12 +25,10 @@ export default defineConfig({ /* Long global timeout for complex tests * But short action/nav/expect timeouts to fail on specific step (raise locally if not enough). */ - timeout: 240 * 1000, - actionTimeout: 40 * 1000, - navigationTimeout: 40 * 1000, - expect: { timeout: 40 * 1000 }, - - "permissions": ["clipboard-read"], + timeout: 120 * 1000, + actionTimeout: 20 * 1000, + navigationTimeout: 20 * 1000, + expect: { timeout: 20 * 1000 }, /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { @@ -39,10 +37,6 @@ export default defineConfig({ browserName: 'firefox', locale: 'en-GB', timezoneId: 'Europe/London', - ignoreHTTPSErrors: true, - launchOptions: { - args: ['--ignore-certificate-errors'] - }, /* Always collect trace (other values add random test failures) See https://playwright.dev/docs/trace-viewer */ trace: 'on', diff --git a/playwright/test.env b/playwright/test.env index 2260f860..df182ebe 100644 --- a/playwright/test.env +++ b/playwright/test.env @@ -10,7 +10,7 @@ DOCKER_BUILDKIT=1 ##################### # Playwright Config # ##################### -PW_KEEP_SERVICE_RUNNING=${PW_KEEP_SERVICE_RUNNING:-false} +PW_KEEP_SERVICE_RUNNNING=${PW_KEEP_SERVICE_RUNNNING:-false} PW_SMTP_FROM=vaultwarden@playwright.test ##################### @@ -38,8 +38,8 @@ TEST_USER3_MAIL=${TEST_USER3}@example.com ################### # Keycloak Config # ################### -KC_BOOTSTRAP_ADMIN_USERNAME=admin -KC_BOOTSTRAP_ADMIN_PASSWORD=${KC_BOOTSTRAP_ADMIN_USERNAME} +KEYCLOAK_ADMIN=admin +KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN} KC_HTTP_HOST=127.0.0.1 KC_HTTP_PORT=8081 @@ -52,12 +52,10 @@ DUMMY_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${DUMMY_REALM} # Vaultwarden Config # ###################### ROCKET_PORT=8003 -ROCKET_TLS={certs="/data/ssl/localhost.crt",key="/data/ssl/localhost.key"} -DOMAIN=https://127.0.0.1:${ROCKET_PORT} +DOMAIN=http://localhost:${ROCKET_PORT} LOG_LEVEL=info,oidcwarden::sso=debug LOGIN_RATELIMIT_MAX_BURST=100 ADMIN_TOKEN=admin -CLIENT_SUPPRESS_ONBOARDING=true SMTP_SECURITY=off SMTP_PORT=${MAILDEV_SMTP_PORT} diff --git a/playwright/tests/collection.spec.ts b/playwright/tests/collection.spec.ts index 867386a5..786a4644 100644 --- a/playwright/tests/collection.spec.ts +++ b/playwright/tests/collection.spec.ts @@ -1,8 +1,6 @@ import { test, expect, type TestInfo } from '@playwright/test'; import * as utils from "../global-utils"; - -import * as orgs from './setups/orgs'; import { createAccount } from './setups/user'; let users = utils.loadEnv(); @@ -18,12 +16,20 @@ test.afterAll('Teardown', async ({}) => { test('Create', async ({ page }) => { await createAccount(test, page, users.user1); - await orgs.create(test, page, 'New organisation'); + await test.step('Create Org', async () => { + await page.getByRole('link', { name: 'New organisation' }).click(); + await page.getByLabel('Organisation name (required)').fill('Test'); + await page.getByRole('button', { name: 'Submit' }).click(); + await page.locator('div').filter({ hasText: 'Members' }).nth(2).click(); + + await utils.checkNotification(page, 'Organisation created'); + }); await test.step('Create Collection', async () => { - await page.getByRole('button', { name: 'New', exact: true }).click(); + await page.getByRole('link', { name: 'Collections' }).click(); + await page.getByRole('button', { name: 'New' }).click(); await page.getByRole('menuitem', { name: 'Collection' }).click(); - await page.getByRole('textbox', { name: 'Name * (required)', exact: true }).fill('RandomCollec'); + await page.getByLabel('Name (required)').fill('RandomCollec'); await page.getByRole('button', { name: 'Save' }).click(); await utils.checkNotification(page, 'Created collection RandomCollec'); await expect(page.getByRole('button', { name: 'RandomCollec' })).toBeVisible(); diff --git a/playwright/tests/cyphers.spec.ts b/playwright/tests/cyphers.spec.ts deleted file mode 100644 index 679874de..00000000 --- a/playwright/tests/cyphers.spec.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { test, expect, type Page, type TestInfo } from '@playwright/test'; -import * as OTPAuth from "otpauth"; - -import * as utils from "../global-utils"; -import { createAccount, logUser } from './setups/user'; -import { activateTOTP, disableTOTP } from './setups/2fa'; - -let users = utils.loadEnv(); -let totp; - -test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { - await utils.startVault(browser, testInfo, {}); -}); - -test.afterAll('Teardown', async ({}) => { - utils.stopVault(); -}); - -test('Change Key settings', async ({ page }) => { - await createAccount(test, page, users.user1); - - await test.step('Change SHA-256 Iterations', async () => { - await page.getByRole('button', { name: 'Toggle collapse Settings' }).click(); - await page.getByRole('link', { name: 'Security' }).click(); - await page.getByRole('link', { name: 'Keys' }).click(); - - await page.getByRole('spinbutton', { name: 'KDF iterations * (required)'}).fill('700000'); - - await page.getByRole('button', { name: 'Update encryption settings' }).click(); - await page.getByRole('textbox', { name: 'Master password * (required)' }).fill(users.user1.password); - await page.getByRole('button', { name: 'Update settings' }).click(); - await page.getByRole('heading', { name: 'Log in' }).click(); - }); - - await logUser(test, page, users.user1); - - await test.step('Switch to Argon2', async () => { - await page.getByRole('button', { name: 'Toggle collapse Settings' }).click(); - await page.getByRole('link', { name: 'Security' }).click(); - await page.getByRole('link', { name: 'Keys' }).click(); - - await page.locator('.ng-arrow-wrapper').click(); - await page.getByText('Argon2id').click(); - - await page.getByRole('spinbutton', { name: 'KDF memory (MB) * (required)'}).fill('16'); - await page.getByRole('spinbutton', { name: 'KDF iterations * (required)'}).fill('2'); - await page.getByRole('spinbutton', { name: 'KDF parallelism * (required)'}).fill('1'); - - await page.getByRole('button', { name: 'Update encryption settings' }).click(); - await page.getByRole('textbox', { name: 'Master password * (required)' }).fill(users.user1.password); - await page.getByRole('button', { name: 'Update settings' }).click(); - await page.getByRole('heading', { name: 'Log in' }).click(); - }); - - await logUser(test, page, users.user1); -}); diff --git a/playwright/tests/emergency.spec.ts b/playwright/tests/emergency.spec.ts deleted file mode 100644 index 4c836986..00000000 --- a/playwright/tests/emergency.spec.ts +++ /dev/null @@ -1,131 +0,0 @@ -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(); - }); -}); diff --git a/playwright/tests/login.smtp.spec.ts b/playwright/tests/login.smtp.spec.ts index c5c4d9ba..87474b79 100644 --- a/playwright/tests/login.smtp.spec.ts +++ b/playwright/tests/login.smtp.spec.ts @@ -41,10 +41,13 @@ test('Account creation', async ({ page }) => { test('Login', async ({ context, page }) => { const mailBuffer = mailserver.buffer(users.user1.email); - await logUser(test, page, users.user1, { mailBuffer }); + await logUser(test, page, users.user1, mailBuffer); await test.step('verify email', async () => { - await page.getByRole('button', { name: "Send email" }).click(); + await page.getByText('Verify your account\'s email').click(); + await expect(page.getByText('Verify your account\'s email')).toBeVisible(); + await page.getByRole('button', { name: 'Send email' }).click(); + await utils.checkNotification(page, 'Check your email inbox for a verification link'); const verify = await mailBuffer.expect((m) => m.subject === "Verify Your Email"); @@ -75,10 +78,26 @@ test('Activate 2fa', async ({ page }) => { test('2fa', async ({ page }) => { const emails = mailserver.buffer(users.user1.email); - await logUser(test, page, users.user1, { - mailBuffer: emails, - mail2fa: true, - }); + await test.step('login', async () => { + await page.goto('/'); + + await page.getByLabel(/Email address/).fill(users.user1.email); + await page.getByRole('button', { name: 'Continue' }).click(); + await page.getByLabel('Master password').fill(users.user1.password); + await page.getByRole('button', { name: 'Log in with master password' }).click(); + + await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible(); + const code = await retrieveEmailCode(test, page, emails); + await page.getByLabel(/Verification code/).fill(code); + await page.getByRole('button', { name: 'Continue' }).click(); + + await page.getByRole('button', { name: 'Add it later' }).click(); + await page.getByRole('link', { name: 'Skip to web app' }).click(); + + await expect(page).toHaveTitle(/Vaults/); + }) + + await disableEmail(test, page, users.user1); emails.close(); }); diff --git a/playwright/tests/login.spec.ts b/playwright/tests/login.spec.ts index bc229c43..aaac4708 100644 --- a/playwright/tests/login.spec.ts +++ b/playwright/tests/login.spec.ts @@ -3,7 +3,7 @@ import * as OTPAuth from "otpauth"; import * as utils from "../global-utils"; import { createAccount, logUser } from './setups/user'; -import { activateTOTP, disableTOTP, recoveryCodes } from './setups/2fa'; +import { activateTOTP, disableTOTP } from './setups/2fa'; let users = utils.loadEnv(); let totp; @@ -31,42 +31,21 @@ test('Authenticator 2fa', async ({ page }) => { await utils.logout(test, page, users.user1); - await logUser(test, page, users.user1, { totp }); + await test.step('login', async () => { + let timestamp = Date.now(); // Needed to use the next token + timestamp = timestamp + (totp.period - (Math.floor(timestamp / 1000) % totp.period) + 1) * 1000; + + await page.getByLabel(/Email address/).fill(users.user1.email); + await page.getByRole('button', { name: 'Continue' }).click(); + await page.getByLabel('Master password').fill(users.user1.password); + await page.getByRole('button', { name: 'Log in with master password' }).click(); + + await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible(); + await page.getByLabel(/Verification code/).fill(totp.generate({timestamp})); + await page.getByRole('button', { name: 'Continue' }).click(); + + await expect(page).toHaveTitle(/Vaultwarden Web/); + }); await disableTOTP(test, page, users.user1); }); - -test('Recovery codes', async ({ context, page }) => { - await logUser(test, page, users.user1); - - await activateTOTP(test, page, users.user1); - let recovery = await recoveryCodes(test, page, users.user1); - - await utils.logout(test, page, users.user1); - - await test.step('login', async () => { - await page.getByLabel(/Email address/).fill(users.user1.email); - await page.getByRole('button', { name: 'Continue' }).click(); - await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user1.password); - await page.getByRole('button', { name: 'Log in', exact: true }).click(); - - await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible(); - - await expect(page).toHaveTitle(/Vaultwarden Web/); - - 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/); - }); -}); - diff --git a/playwright/tests/organization.smtp.spec.ts b/playwright/tests/organization.smtp.spec.ts index 1e97ed5d..35dfcdb1 100644 --- a/playwright/tests/organization.smtp.spec.ts +++ b/playwright/tests/organization.smtp.spec.ts @@ -4,7 +4,6 @@ import { MailDev } from 'maildev'; import * as utils from '../global-utils'; import * as orgs from './setups/orgs'; import { createAccount, logUser } from './setups/user'; -import { activateTOTP } from './setups/2fa'; let users = utils.loadEnv(); @@ -21,7 +20,6 @@ test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { await utils.startVault(browser, testInfo, { SMTP_HOST: process.env.MAILDEV_HOST, SMTP_FROM: process.env.PW_SMTP_FROM, - EMAIL_2FA_AUTO_FALLBACK: "true", }); mail1Buffer = mailServer.buffer(users.user1.email); @@ -42,16 +40,6 @@ test('Invite users', async ({ page }) => { await createAccount(test, page, users.user1, mail1Buffer); await orgs.create(test, page, 'Test'); - - await test.step(`Set account recovery`, async () => { - await orgs.policies(test, page, 'Test'); - await page.getByRole('button', { name: 'Account recovery' }).click(); - await page.getByRole('checkbox', { name: 'Turn on' }).check(); - await page.getByRole('checkbox', { name: 'Automatically enroll new' }).check(); - await page.getByRole('button', { name: 'Save' }).click(); - await utils.checkNotification(page, 'Edited policy Account recovery'); - }); - await orgs.members(test, page, 'Test'); await orgs.invite(test, page, 'Test', users.user2.email); await orgs.invite(test, page, 'Test', users.user3.email, { @@ -68,16 +56,18 @@ test('invited with new account', async ({ page }) => { await page.goto(link); await expect(page).toHaveTitle(/Create account | Vaultwarden Web/); - // await page.getByLabel('Name').fill(users.user2.name); - await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user2.password); - await page.getByRole('textbox', { name: 'Confirm master password * (' }).fill(users.user2.password); + //await page.getByLabel('Name').fill(users.user2.name); + await page.getByLabel('Master password (required)', { exact: true }).fill(users.user2.password); + await page.getByLabel('Confirm master password (').fill(users.user2.password); await page.getByRole('button', { name: 'Create account' }).click(); await utils.checkNotification(page, 'Your new account has been created'); + await utils.checkNotification(page, 'Invitation accepted'); + await utils.ignoreExtension(page); + // Redirected to the vault await expect(page).toHaveTitle('Vaults | Vaultwarden Web'); // await utils.checkNotification(page, 'You have been logged in!'); - await utils.checkNotification(page, 'Successfully accepted your invitation'); }); await test.step('Check mails', async () => { @@ -100,19 +90,21 @@ test('invited with existing account', async ({ page }) => { await page.getByRole('button', { name: 'Continue' }).click(); // Unlock page - await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user3.password); - await page.getByRole('button', { name: 'Log in', exact: true }).click(); + await page.getByLabel('Master password').fill(users.user3.password); + await page.getByRole('button', { name: 'Log in with master password' }).click(); + + await utils.checkNotification(page, 'Invitation accepted'); + await utils.ignoreExtension(page); // We are now in the default vault page await expect(page).toHaveTitle(/Vaultwarden Web/); - await utils.checkNotification(page, 'Successfully accepted your invitation'); await mail3Buffer.expect((m) => m.subject === 'New Device Logged In From Firefox'); await mail1Buffer.expect((m) => m.subject.includes('Invitation to Test accepted')); }); test('Confirm invited user', async ({ page }) => { - await logUser(test, page, users.user1, { mailBuffer: mail1Buffer }); + await logUser(test, page, users.user1, mail1Buffer); await orgs.members(test, page, 'Test'); await orgs.confirm(test, page, 'Test', users.user2.email); @@ -121,40 +113,7 @@ test('Confirm invited user', async ({ page }) => { }); test('Organization is visible', async ({ page }) => { - await logUser(test, page, users.user2, { mailBuffer: mail2Buffer }); + await logUser(test, page, users.user2, mail2Buffer); await page.getByRole('button', { name: 'vault: Test', exact: true }).click(); await expect(page.getByLabel('Filter: Default collection')).toBeVisible(); }); - -test('Recover user password', async ({ page }) => { - await logUser(test, page, users.user2, { mailBuffer: mail2Buffer }); - await activateTOTP(test, page, users.user2); - - await logUser(test, page, users.user1, { mailBuffer: mail1Buffer }); - - let newPassword = "TotoNewPassword"; - - await orgs.members(test, page, 'Test'); - await test.step(`Recover ${users.user2.email}`, async () => { - await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible(); - await page.getByRole('row').filter({hasText: users.user2.email}).getByLabel('Options').click(); - await page.getByRole('menuitem', { name: 'Recover account' }).click(); - await page.getByRole('textbox', { name: 'New master password * (required)', exact: true }).fill(newPassword); - await page.getByRole('textbox', { name: 'Confirm new master password * (' }).fill(newPassword); - await page.getByRole('checkbox', { name: 'Reset two-step login' }).check(); - await page.getByRole('button', { name: 'Save' }).click(); - await utils.checkNotification(page, 'Account recovery success'); - await mail2Buffer.expect((m) => m.subject.includes('Admin account recovery from Test organization')); - }); - - let user2 = { - email: users.user2.email, - name: users.user2.name, - password: newPassword, - }; - await logUser(test, page, user2, { - mailBuffer: mail2Buffer, - mail2fa: true, - notNewDevice: true, - }); -}); diff --git a/playwright/tests/secrets.spec.ts b/playwright/tests/secrets.spec.ts deleted file mode 100644 index e229d400..00000000 --- a/playwright/tests/secrets.spec.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { test, expect, type Page, type TestInfo } from '@playwright/test'; -import * as OTPAuth from "otpauth"; - -import * as utils from "../global-utils"; -import { createAccount, logUser } from './setups/user'; - -let users = utils.loadEnv(); -let totp; - -test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { - await utils.startVault(browser, testInfo, {}); - - const context = await browser.newContext(); - const page = await context.newPage(); - await createAccount(test, page, users.user1); - await context.close(); -}); - -test.afterAll('Teardown', async ({}) => { - utils.stopVault(); -}); - -test('Password', async ({ context, page }, testInfo: TestInfo) => { - const label = 'Test Password'; - - await logUser(test, page, users.user1); - - await test.step('Create password entry', async () => { - await page.getByRole('button', { name: 'New item' }).click(); - await page.getByRole('textbox', { name: 'Item name * (required)' }).fill(label); - await page.getByRole('textbox', { name: 'Username' }).fill(users.user1.name); - await page.getByRole('textbox', { name: 'Password' }).fill(users.user1.password); - await page.getByRole('button', { name: 'Save' }).click(); - await utils.checkNotification(page, 'Item added'); - await page.getByRole('button', { name: 'Close' }).click(); - }); - - // Log again - await logUser(test, page, users.user1); - - await test.step('Check', async () => { - await page.getByRole('row').filter({ hasText: label }).getByRole('button', { name: label }).click(); - await page.getByTestId('copy-username').click(); - await utils.checkNotification(page, 'Username copied'); - expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(users.user1.name) - await page.getByTestId('copy-password').click(); - await utils.checkNotification(page, 'Password copied'); - expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(users.user1.password) - await page.getByRole('button', { name: 'Close' }).click(); - }); - - await test.step('Delete', async () => { - await page.getByRole('row').filter({ hasText: label }).getByLabel('Options').click(); - await page.getByRole('menuitem', { name: 'Delete' }).click(); - await page.getByRole('button', { name: 'Yes' }).click(); - await utils.checkNotification(page, 'Item sent to bin'); - }); - - // Log again - await logUser(test, page, users.user1); - - await test.step('Deleted', async () => { - await expect(page.getByRole('row').filter({ hasText: label })).toHaveCount(0) - }); -}); - - -test('SSH Key', async ({ context, page }, testInfo: TestInfo) => { - const label = 'Test SSH key'; - - await logUser(test, page, users.user1); - - const privateKey = await test.step('Create key entry', async () => { - await page.getByRole('button', { name: 'New', exact: true }).click(); - await page.getByRole('menuitem', { name: 'SSH key' }).click(); - await page.getByRole('textbox', { name: 'Item name * (required)' }).fill('Test SSH key'); - await page.getByRole('button', { name: 'Save' }).click(); - await utils.checkNotification(page, 'Item added'); - - await page.getByRole('button', { name: 'Copy private key' }).click(); - await utils.checkNotification(page, 'Private key copied'); - return await page.evaluate(() => navigator.clipboard.readText()); - }); - - // Log again - await logUser(test, page, users.user1); - - await test.step('Check', async () => { - await page.getByRole('row').filter({ hasText: label }).getByRole('button', { name: label }).click(); - - await page.getByRole('button', { name: 'Copy private key' }).click(); - await utils.checkNotification(page, 'Private key copied'); - expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(privateKey) - await page.getByRole('button', { name: 'Close' }).click(); - }); - - await test.step('Delete', async () => { - await page.getByRole('row').filter({ hasText: label }).getByLabel('Options').click(); - await page.getByRole('menuitem', { name: 'Delete' }).click(); - await page.getByRole('button', { name: 'Yes' }).click(); - await utils.checkNotification(page, 'Item sent to bin'); - }); - - // Log again - await logUser(test, page, users.user1); - - await test.step('Deleted', async () => { - await expect(page.getByRole('row').filter({ hasText: label })).toHaveCount(0) - }) -}); diff --git a/playwright/tests/send.spec.ts b/playwright/tests/send.spec.ts deleted file mode 100644 index ddb8009d..00000000 --- a/playwright/tests/send.spec.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { test, expect, type Page, type TestInfo } from '@playwright/test'; -import * as OTPAuth from "otpauth"; - -import * as utils from "../global-utils"; -import { createAccount } from './setups/user'; - -let users = utils.loadEnv(); - -test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { - await utils.startVault(browser, testInfo, {}); -}); - -test.afterAll('Teardown', async ({}) => { - utils.stopVault(); -}); - -test('Send', async ({ browser, page }) => { - await createAccount(test, page, users.user1); - - const send_url = await test.step('Create', async () => { - await page.getByRole('link', { name: 'Send' }).click(); - await expect(page.locator('#main-content').getByText('Send', { exact: true })).toBeVisible(); - - await page.getByRole('button', { name: 'New Send', exact: true }).click(); - await page.getByRole('menuitem', { name: 'Text' }).click(); - - await page.getByRole('textbox', { name: 'Send name * (required)' }).fill('Test'); - await page.getByRole('textbox', { name: 'Text to share * (required)' }).fill('test'); - await page.getByRole('button', { name: 'Save' }).click(); - - await page.locator('footer').getByRole('button', { name: 'Copy link' }).click(); - - return await page.evaluate(() => navigator.clipboard.readText()); - }); - - const context2 = await browser.newContext(); - const page2 = await context2.newPage(); - - await test.step('View', async () => { - await page2.goto(send_url, { waitUntil: 'domcontentloaded' }); - await expect(page2.getByRole('heading', { name: 'View Send' })).toBeVisible(); - await expect(await page2.getByRole('paragraph').filter({ hasText: 'Test' })).toBeVisible(); - }); - - const pwd_url = await test.step('Create with password', async () => { - await page.getByRole('link', { name: 'Send' }).click(); - await expect(page.locator('#main-content').getByText('Send', { exact: true })).toBeVisible(); - - await page.getByRole('button', { name: 'New' }).click(); - await page.getByRole('menuitem', { name: 'Text' }).click(); - - await page.getByRole('textbox', { name: 'Send name * (required)' }).fill('Password'); - await page.getByRole('textbox', { name: 'Text to share * (required)' }).fill('password'); - await page.getByRole('combobox', { name: 'Who can view' }).click(); - await page.getByText('Anyone with a password set by you').click(); - await page.getByRole('textbox', { name: 'Password * (required)', exact: true }).fill('password'); - - await page.getByRole('button', { name: 'Save' }).click(); - await page.locator('footer').getByRole('button', { name: 'Copy link' }).click(); - - return await page.evaluate(() => navigator.clipboard.readText()); - }); - - await test.step('View with password', async () => { - await page2.goto(pwd_url, { waitUntil: 'domcontentloaded' }); - await expect(page2.getByRole('heading', { name: 'Enter the password to view' })).toBeVisible(); - await page2.getByRole('textbox', { name: 'Password * (required)' }).fill('password'); - await page2.getByRole('button', { name: 'Continue' }).click(); - await expect(page2.getByRole('heading', { name: 'View Send' })).toBeVisible(); - await expect(await page2.getByRole('paragraph').filter({ hasText: 'Password' })).toBeVisible(); - }); -}); diff --git a/playwright/tests/setups/2fa.ts b/playwright/tests/setups/2fa.ts index 0b1711a1..d7936420 100644 --- a/playwright/tests/setups/2fa.ts +++ b/playwright/tests/setups/2fa.ts @@ -4,24 +4,6 @@ import * as OTPAuth from "otpauth"; import * as utils from '../../global-utils'; -export async function recoveryCodes(test: Test, page: Page, user: { name: string, password: string }): string { - return await test.step('Recovery code', async () => { - await page.getByRole('button', { name: user.name }).click(); - await page.getByRole('menuitem', { name: 'Account settings' }).click(); - await page.getByRole('link', { name: 'Security' }).click(); - await page.getByRole('link', { name: 'Two-step login' }).click(); - - await page.getByRole('button', { name: 'View recovery code' }).click(); - await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password); - await page.getByRole('button', { name: 'Continue' }).click(); - - const recovery = await page.getByRole('code').innerText(); - await page.getByLabel('Close').click(); - - return recovery; - }) -} - export async function activateTOTP(test: Test, page: Page, user: { name: string, password: string }): OTPAuth.TOTP { return await test.step('Activate TOTP 2FA', async () => { await page.getByRole('button', { name: user.name }).click(); @@ -29,16 +11,16 @@ export async function activateTOTP(test: Test, page: Page, user: { name: string, await page.getByRole('link', { name: 'Security' }).click(); await page.getByRole('link', { name: 'Two-step login' }).click(); await page.locator('bit-item').filter({ hasText: /Authenticator app/ }).getByRole('button').click(); - await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password); + await page.getByLabel('Master password (required)').fill(user.password); await page.getByRole('button', { name: 'Continue' }).click(); - const secret = await page.getByLabel('Key', { exact: true }).innerText(); - + const secret = await page.getByLabel('Key').innerText(); let totp = new OTPAuth.TOTP({ secret, period: 30 }); await page.getByLabel(/Verification code/).fill(totp.generate()); await page.getByRole('button', { name: 'Turn on' }).click(); await page.getByRole('heading', { name: 'Turned on', exact: true }); + await page.getByLabel('Close').click(); return totp; }) @@ -51,8 +33,8 @@ export async function disableTOTP(test: Test, page: Page, user: { password: stri await page.getByRole('link', { name: 'Security' }).click(); await page.getByRole('link', { name: 'Two-step login' }).click(); await page.locator('bit-item').filter({ hasText: /Authenticator app/ }).getByRole('button').click(); - await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).click() - await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password); + await page.getByLabel('Master password (required)').click(); + await page.getByLabel('Master password (required)').fill(user.password); await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Turn off' }).click(); await page.getByRole('button', { name: 'Yes' }).click(); @@ -67,7 +49,7 @@ export async function activateEmail(test: Test, page: Page, user: { name: string await page.getByRole('link', { name: 'Security' }).click(); await page.getByRole('link', { name: 'Two-step login' }).click(); await page.locator('bit-item').filter({ hasText: 'Enter a code sent to your email' }).getByRole('button').click(); - await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password); + await page.getByLabel('Master password (required)').fill(user.password); await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Send email' }).click(); }); @@ -99,8 +81,8 @@ export async function disableEmail(test: Test, page: Page, user: { password: str await page.getByRole('link', { name: 'Security' }).click(); await page.getByRole('link', { name: 'Two-step login' }).click(); await page.locator('bit-item').filter({ hasText: 'Email' }).getByRole('button').click(); - await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).click() - await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password); + await page.getByLabel('Master password (required)').click(); + await page.getByLabel('Master password (required)').fill(user.password); await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Turn off' }).click(); await page.getByRole('button', { name: 'Yes' }).click(); diff --git a/playwright/tests/setups/admin.ts b/playwright/tests/setups/admin.ts deleted file mode 100644 index 354c9ee7..00000000 --- a/playwright/tests/setups/admin.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { expect, type Browser, Page } from '@playwright/test'; -import * as utils from '../../global-utils'; - -utils.loadEnv(); - -export async function login(test, page: Page) { - await test.step(`Admin login`, async () => { - await page.goto('/admin'); - await page.getByRole('textbox', { name: 'Enter admin token' }).fill(process.env.ADMIN_TOKEN); - await page.getByRole('button', { name: 'Enter' }).click(); - }); -} - -export async function invite(test, page: Page, email: string) { - await test.step(`Invite user with ${email}`, async () => { - await page.getByRole('link', { name: 'Users' }).click(); - await page.getByRole('textbox', { name: 'Enter email' }).fill(email); - await page.getByRole('button', { name: 'Invite' }).click(); - await expect(page.getByRole('row', { name: email })).toHaveText(/Invited/); - }); -} diff --git a/playwright/tests/setups/db-teardown.ts b/playwright/tests/setups/db-teardown.ts index 86d40ac5..5f753a9d 100644 --- a/playwright/tests/setups/db-teardown.ts +++ b/playwright/tests/setups/db-teardown.ts @@ -5,7 +5,7 @@ const utils = require('../../global-utils'); utils.loadEnv(); test('DB teardown ?', async ({ serviceName }) => { - if( process.env.PW_KEEP_SERVICE_RUNNING !== "true" ) { + if( process.env.PW_KEEP_SERVICE_RUNNNING !== "true" ) { utils.stopComposeService(serviceName); } }); diff --git a/playwright/tests/setups/orgs.ts b/playwright/tests/setups/orgs.ts index ce12c50e..04d81b45 100644 --- a/playwright/tests/setups/orgs.ts +++ b/playwright/tests/setups/orgs.ts @@ -3,14 +3,11 @@ import { expect, type Browser,Page } from '@playwright/test'; import * as utils from '../../global-utils'; export async function create(test, page: Page, name: string) { - await test.step(`Create Org ${name}`, async () => { - let pm_locator = page.locator('a').filter({ hasText: 'Password Manager' }); - if( await pm_locator.count() > 0 ){ - pm_locator.first().click(); - } + await test.step('Create Org', async () => { + await page.locator('a').filter({ hasText: 'Password Manager' }).first().click(); await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible(); await page.getByRole('link', { name: 'New organisation' }).click(); - await page.getByRole('textbox', { name: 'Organisation name * (required)', exact: true }).fill(name); + await page.getByLabel('Organisation name (required)').fill(name); await page.getByRole('button', { name: 'Submit' }).click(); await utils.checkNotification(page, 'Organisation created'); @@ -21,7 +18,7 @@ export async function policies(test, page: Page, name: string) { await test.step(`Navigate to ${name} policies`, async () => { await page.locator('a').filter({ hasText: 'Admin Console' }).first().click(); await page.locator('org-switcher').getByLabel(/Toggle collapse/).click(); - await page.locator('org-switcher > bit-nav-group > div > bit-nav-item').filter({ hasText: `${name}` }).first().click(); + await page.locator('org-switcher').getByRole('link', { name: `${name}` }).first().click(); await expect(page.getByRole('heading', { name: `${name} collections` })).toBeVisible(); await page.getByRole('button', { name: 'Toggle collapse Settings' }).click(); await page.getByRole('link', { name: 'Policies' }).click(); @@ -33,11 +30,11 @@ export async function members(test, page: Page, name: string) { await test.step(`Navigate to ${name} members`, async () => { await page.locator('a').filter({ hasText: 'Admin Console' }).first().click(); await page.locator('org-switcher').getByLabel(/Toggle collapse/).click(); - await page.locator('org-switcher > bit-nav-group > div > bit-nav-item').filter({ hasText: `${name}` }).first().click(); + await page.locator('org-switcher').getByRole('link', { name: `${name}` }).first().click(); await expect(page.getByRole('heading', { name: `${name} collections` })).toBeVisible(); - await page.getByRole('link', { name: 'Members' }).click(); + await page.locator('div').filter({ hasText: 'Members' }).nth(2).click(); await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible(); - await expect(page.getByRole('columnheader', { name: 'Select all' })).toBeVisible(); + await expect(page.getByRole('cell', { name: 'All' })).toBeVisible(); }); } @@ -45,13 +42,13 @@ export async function invite(test, page: Page, name: string, email: string) { await test.step(`Invite ${email}`, async () => { await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible(); await page.getByRole('button', { name: 'Invite member' }).click(); - await page.getByRole('textbox', { name: 'Email * (required)', exact: true }).fill(email); + await page.getByLabel('Email (required)').fill(email); await page.getByRole('tab', { name: 'Collections' }).click(); await page.getByRole('combobox', { name: 'Permission' }).click(); await page.getByText('Edit items', { exact: true }).click(); - await page.getByRole('combobox', { name: 'Select collections' }).click(); - await page.getByLabel('Options List').getByText('Default collection').click(); - await page.getByRole('columnheader', { name: 'Collection', exact: true }).click(); + await page.getByLabel('Select collections').click(); + await page.getByText('Default collection').click(); + await page.getByRole('cell', { name: 'Collection', exact: true }).click(); await page.getByRole('button', { name: 'Save' }).click(); await utils.checkNotification(page, 'User(s) invited'); }); diff --git a/playwright/tests/setups/sso-teardown.ts b/playwright/tests/setups/sso-teardown.ts index 22934b75..2899afff 100644 --- a/playwright/tests/setups/sso-teardown.ts +++ b/playwright/tests/setups/sso-teardown.ts @@ -6,7 +6,7 @@ const utils = require('../../global-utils'); utils.loadEnv(); test('Keycloak teardown', async () => { - if( process.env.PW_KEEP_SERVICE_RUNNING === "true" ) { + if( process.env.PW_KEEP_SERVICE_RUNNNING === "true" ) { console.log("Keep Keycloak running"); } else { console.log("Keycloak stopping"); diff --git a/playwright/tests/setups/sso.ts b/playwright/tests/setups/sso.ts index 0ad0cffb..6317f8b0 100644 --- a/playwright/tests/setups/sso.ts +++ b/playwright/tests/setups/sso.ts @@ -15,8 +15,11 @@ export async function logNewUser( options: { mailBuffer?: MailBuffer } = {} ) { await test.step(`Create user ${user.name}`, async () => { + await page.context().clearCookies(); + await test.step('Landing page', async () => { await utils.cleanLanding(page); + await page.locator("input[type=email].vw-email-sso").fill(user.email); await page.getByRole('button', { name: /Use single sign-on/ }).click(); }); @@ -30,24 +33,26 @@ export async function logNewUser( await test.step('Create Vault account', async () => { await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible(); - await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password); - await page.getByRole('textbox', { name: 'Confirm master password * (' }).fill(user.password); + await page.getByLabel('Master password (required)', { exact: true }).fill(user.password); + await page.getByLabel('Confirm master password (').fill(user.password); await page.getByRole('button', { name: 'Create account' }).click(); }); + await utils.checkNotification(page, 'Account successfully created!'); + await utils.checkNotification(page, 'Invitation accepted'); + + await utils.ignoreExtension(page); + await test.step('Default vault page', async () => { await expect(page).toHaveTitle(/Vaultwarden Web/); await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible(); }); - await utils.checkNotification(page, 'Account successfully created!'); - await utils.checkNotification(page, 'Invitation accepted'); - if( options.mailBuffer ){ let mailBuffer = options.mailBuffer; await test.step('Check emails', async () => { - await mailBuffer.expect((m) => m.subject.includes("New Device Logged")); await mailBuffer.expect((m) => m.subject === "Welcome"); + await mailBuffer.expect((m) => m.subject.includes("New Device Logged")); }); } }); @@ -64,14 +69,16 @@ export async function logUser( mailBuffer ?: MailBuffer, totp?: OTPAuth.TOTP, mail2fa?: boolean, - notNewDevice?: boolean, } = {} ) { let mailBuffer = options.mailBuffer; await test.step(`Log user ${user.email}`, async () => { + await page.context().clearCookies(); + await test.step('Landing page', async () => { await utils.cleanLanding(page); + await page.locator("input[type=email].vw-email-sso").fill(user.email); await page.getByRole('button', { name: /Use single sign-on/ }).click(); }); @@ -110,12 +117,14 @@ export async function logUser( await page.getByRole('button', { name: 'Unlock' }).click(); }); + await utils.ignoreExtension(page); + await test.step('Default vault page', async () => { await expect(page).toHaveTitle(/Vaultwarden Web/); await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible(); }); - if( mailBuffer && !options.notNewDevice ){ + if( mailBuffer ){ await test.step('Check email', async () => { await mailBuffer.expect((m) => m.subject.includes("New Device Logged")); }); diff --git a/playwright/tests/setups/user.ts b/playwright/tests/setups/user.ts index 11b39763..395196ae 100644 --- a/playwright/tests/setups/user.ts +++ b/playwright/tests/setups/user.ts @@ -2,9 +2,7 @@ import { expect, type Browser, Page } from '@playwright/test'; import { type MailBuffer } from 'maildev'; -import * as OTPAuth from "otpauth"; import * as utils from '../../global-utils'; -import { retrieveEmailCode } from './2fa'; export async function createAccount(test, page: Page, user: { email: string, name: string, password: string }, mailBuffer?: MailBuffer) { await test.step(`Create user ${user.name}`, async () => { @@ -19,11 +17,12 @@ export async function createAccount(test, page: Page, user: { email: string, nam await page.getByRole('button', { name: 'Continue' }).click(); // Vault finish Creation - await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password); - await page.getByRole('textbox', { name: 'Confirm master password * (' }).fill(user.password); + await page.getByLabel('Master password (required)', { exact: true }).fill(user.password); + await page.getByLabel('Confirm master password (').fill(user.password); await page.getByRole('button', { name: 'Create account' }).click(); await utils.checkNotification(page, 'Your new account has been created') + await utils.ignoreExtension(page); // We are now in the default vault page await expect(page).toHaveTitle('Vaults | Vaultwarden Web'); @@ -36,17 +35,7 @@ export async function createAccount(test, page: Page, user: { email: string, nam }); } -export async function logUser( - test, - page: Page, - user: { email: string, password: string }, - options: { - mailBuffer ?: MailBuffer, - mail2fa?: boolean, - notNewDevice?: boolean, - totp?: OTPAuth.TOTP, - } = {} -) { +export async function logUser(test, page: Page, user: { email: string, password: string }, mailBuffer?: MailBuffer) { await test.step(`Log user ${user.email}`, async () => { await utils.cleanLanding(page); @@ -54,35 +43,16 @@ export async function logUser( await page.getByRole('button', { name: 'Continue' }).click(); // Unlock page - 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.getByLabel('Master password').fill(user.password); + await page.getByRole('button', { name: 'Log in with master password' }).click(); - if( options.mail2fa || options.totp ){ - let code; - - await test.step('2FA check', async () => { - await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible(); - - if( options.totp ) { - const totp = options.totp; - let timestamp = Date.now(); // Needed to use the next token - timestamp = timestamp + (totp.period - (Math.floor(timestamp / 1000) % totp.period) + 1) * 1000; - code = totp.generate({timestamp}); - } else if( options.mail2fa ){ - code = await retrieveEmailCode(test, page, options.mailBuffer); - } - - await page.getByLabel(/Verification code/).fill(code); - - await page.getByRole('button', { name: 'Continue' }).click(); - }); - } + await utils.ignoreExtension(page); // We are now in the default vault page await expect(page).toHaveTitle(/Vaultwarden Web/); - if( options.mailBuffer && !options.notNewDevice ){ - await options.mailBuffer.expect((m) => m.subject === "New Device Logged In From Firefox"); + if( mailBuffer ){ + await mailBuffer.expect((m) => m.subject === "New Device Logged In From Firefox"); } }); } diff --git a/playwright/tests/sso_login.smtp.spec.ts b/playwright/tests/sso_login.smtp.spec.ts index 1f5c9361..7a615cd6 100644 --- a/playwright/tests/sso_login.smtp.spec.ts +++ b/playwright/tests/sso_login.smtp.spec.ts @@ -1,7 +1,6 @@ import { test, expect, type TestInfo } from '@playwright/test'; import { MailDev } from 'maildev'; -import * as admin from "./setups/admin"; import { logNewUser, logUser } from './setups/sso'; import { activateEmail, disableEmail } from './setups/2fa'; import * as utils from "../global-utils"; @@ -20,7 +19,7 @@ test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { await utils.startVault(browser, testInfo, { SSO_ENABLED: true, - SSO_ONLY: true, + SSO_ONLY: false, SMTP_HOST: process.env.MAILDEV_HOST, SMTP_FROM: process.env.PW_SMTP_FROM, }); @@ -33,64 +32,22 @@ test.afterAll('Teardown', async ({}) => { } }); -test('2FA email', async ({ page }) => { - +test('Create and activate 2FA', async ({ page }) => { const mailBuffer = mailserver.buffer(users.user1.email); await logNewUser(test, page, users.user1, {mailBuffer: mailBuffer}); await activateEmail(test, page, users.user1, mailBuffer); - await logUser(test, page, users.user1, {mailBuffer: mailBuffer, mail2fa: true, notNewDevice: true}); + mailBuffer.close(); +}); + +test('Log and disable', async ({ page }) => { + const mailBuffer = mailserver.buffer(users.user1.email); + + await logUser(test, page, users.user1, {mailBuffer: mailBuffer, mail2fa: true}); await disableEmail(test, page, users.user1); mailBuffer.close(); }); - - -test('Admin invite', async ({ page }) => { - const mailBuffer = mailserver.buffer(users.user2.email); - - await admin.login(test, page); - await admin.invite(test, page, users.user2.email); - - - const link = await test.step('Extract email link', async () => { - const invited = await mailBuffer.expect((m) => m.subject === "Join Vaultwarden"); - await page.setContent(invited.html); - return await page.getByTestId("invite").getAttribute("href"); - }); - - await test.step('Redirect to Keycloak', async () => { - await page.goto(link); - }); - - await test.step('Keycloak login', async () => { - await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible(); - await page.getByLabel(/Username/).fill(users.user2.name); - await page.getByLabel('Password', { exact: true }).fill(users.user2.password); - await page.getByRole('button', { name: 'Sign In' }).click(); - }); - - await test.step('Create Vault account', async () => { - await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible(); - await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user2.password); - await page.getByRole('textbox', { name: 'Confirm master password * (' }).fill(users.user2.password); - await page.getByRole('button', { name: 'Create account' }).click(); - }); - - await test.step('Default vault page', async () => { - await expect(page).toHaveTitle('Vaults | Vaultwarden Web'); - - await utils.checkNotification(page, 'Account successfully created!'); - await utils.checkNotification(page, 'Invitation accepted'); - }); - - await test.step('Check mails', async () => { - await mailBuffer.expect((m) => m.subject.includes("New Device Logged")); - await mailBuffer.expect((m) => m.subject === "Welcome"); - }); - - mailBuffer.close(); -}); diff --git a/playwright/tests/sso_login.spec.ts b/playwright/tests/sso_login.spec.ts index e93aab14..8a1bb9ab 100644 --- a/playwright/tests/sso_login.spec.ts +++ b/playwright/tests/sso_login.spec.ts @@ -33,8 +33,8 @@ test('Non SSO login', async ({ page }) => { await page.getByRole('button', { name: 'Other' }).click(); // Unlock page - 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.getByLabel('Master password').fill(users.user1.password); + await page.getByRole('button', { name: 'Log in with master password' }).click(); // We are now in the default vault page await expect(page).toHaveTitle(/Vaultwarden Web/); @@ -58,7 +58,6 @@ test('Non SSO login impossible', async ({ page, browser }, testInfo: TestInfo) = // Landing page await page.goto('/'); - await page.locator("input[type=email].vw-email-sso").fill(users.user1.email); // Check that SSO login is available await expect(page.getByRole('button', { name: /Use single sign-on/ })).toHaveCount(1); @@ -67,6 +66,7 @@ test('Non SSO login impossible', async ({ page, browser }, testInfo: TestInfo) = await expect(page.getByRole('button', { name: 'Other' })).toHaveCount(0); }); + test('No SSO login', async ({ page }, testInfo: TestInfo) => { await utils.restartVault(page, testInfo, { SSO_ENABLED: false @@ -74,14 +74,12 @@ test('No SSO login', async ({ page }, testInfo: TestInfo) => { // Landing page await page.goto('/'); - await page.getByLabel(/Email address/).fill(users.user1.email); // No SSO button (rely on a correct selector checked in previous test) - await page.getByLabel('Master password'); await expect(page.getByRole('button', { name: /Use single sign-on/ })).toHaveCount(0); // Can continue to Master password await page.getByLabel(/Email address/).fill(users.user1.email); await page.getByRole('button', { name: 'Continue' }).click(); - await expect(page.getByRole('button', { name: 'Log in' })).toHaveCount(1); + await expect(page.getByRole('button', { name: 'Log in with master password' })).toHaveCount(1); }); diff --git a/playwright/tests/sso_organization.smtp.spec.ts b/playwright/tests/sso_organization.smtp.spec.ts index eef4f83d..92813f72 100644 --- a/playwright/tests/sso_organization.smtp.spec.ts +++ b/playwright/tests/sso_organization.smtp.spec.ts @@ -67,16 +67,17 @@ test('invited with new account', async ({ page }) => { await test.step('Create Vault account', async () => { await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible(); - await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user2.password); - await page.getByRole('textbox', { name: 'Confirm master password * (' }).fill(users.user2.password); + await page.getByLabel('Master password (required)', { exact: true }).fill(users.user2.password); + await page.getByLabel('Confirm master password (').fill(users.user2.password); await page.getByRole('button', { name: 'Create account' }).click(); + + await utils.checkNotification(page, 'Account successfully created!'); + await utils.checkNotification(page, 'Invitation accepted'); + await utils.ignoreExtension(page); }); await test.step('Default vault page', async () => { await expect(page).toHaveTitle(/Vaultwarden Web/); - - await utils.checkNotification(page, 'Account successfully created!'); - await utils.checkNotification(page, 'Invitation accepted'); }); await test.step('Check mails', async () => { @@ -94,7 +95,6 @@ test('invited with existing account', async ({ page }) => { await test.step('Redirect to Keycloak', async () => { await page.goto(link); - await page.getByRole('button', { name: /Use single sign-on/ }).click(); }); await test.step('Keycloak login', async () => { @@ -108,11 +108,13 @@ test('invited with existing account', async ({ page }) => { await expect(page).toHaveTitle('Vaultwarden Web'); await page.getByLabel('Master password').fill(users.user3.password); await page.getByRole('button', { name: 'Unlock' }).click(); + + await utils.checkNotification(page, 'Invitation accepted'); + await utils.ignoreExtension(page); }); await test.step('Default vault page', async () => { await expect(page).toHaveTitle(/Vaultwarden Web/); - await utils.checkNotification(page, 'Successfully accepted your invitation'); }); await test.step('Check mails', async () => { diff --git a/playwright/tests/sso_organization.spec.ts b/playwright/tests/sso_organization.spec.ts index ee7e28f6..c1238d45 100644 --- a/playwright/tests/sso_organization.spec.ts +++ b/playwright/tests/sso_organization.spec.ts @@ -49,7 +49,7 @@ test('Organization is visible', async ({ page }) => { await expect(page.getByLabel('Filter: Default collection')).toBeVisible(); }); -test('Activate password policy', async ({ page }) => { +test('Enforce password policy', async ({ page }) => { await logUser(test, page, users.user1); await orgs.policies(test, page, '/Test'); @@ -61,27 +61,16 @@ test('Activate password policy', async ({ page }) => { await page.getByRole('button', { name: 'Save' }).click(); await utils.checkNotification(page, 'Edited policy Master password requirements.'); }); -}); -test('Unlock trigger policyy', async ({ page }) => { - await page.goto('/', { waitUntil: 'domcontentloaded' }); + await utils.logout(test, page, users.user1); - await page.locator("input[type=email].vw-email-sso").fill(users.user2.email); - await page.getByRole('button', { name: /Use single sign-on/ }).click(); + await test.step(`Unlock trigger policy`, async () => { + await page.locator("input[type=email].vw-email-sso").fill(users.user1.email); + await page.getByRole('button', { name: 'Use single sign-on' }).click(); - await test.step('Keycloak login', async () => { - await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible(); - await page.getByLabel(/Username/).fill(users.user2.name); - await page.getByLabel('Password', { exact: true }).fill(users.user2.password); - await page.getByRole('button', { name: 'Sign In' }).click(); - }); - - await test.step('Unlock vault', async () => { - await expect(page).toHaveTitle('Vaultwarden Web'); - await expect(page.getByRole('heading', { name: 'Your vault is locked' })).toBeVisible(); - await page.getByLabel('Master password').fill(users.user2.password); + await page.getByRole('textbox', { name: 'Master password (required)' }).fill(users.user1.password); await page.getByRole('button', { name: 'Unlock' }).click(); - }); - await expect(page.getByRole('heading', { name: 'Update master password' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Update master password' })).toBeVisible(); + }); }); diff --git a/rust-toolchain.toml b/rust-toolchain.toml index b195f645..151be09f 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] -channel = "1.99.1" +channel = "1.94.1" components = [ "rustfmt", "clippy" ] profile = "minimal" diff --git a/rustfmt.toml b/rustfmt.toml index a00d27e0..1d5e440f 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -1,4 +1,4 @@ -edition = "2024" +edition = "2021" max_width = 120 newline_style = "Unix" use_small_heuristics = "Off" diff --git a/src/api/admin.rs b/src/api/admin.rs index 40df44ae..1546676f 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -1,42 +1,40 @@ -use std::{collections::HashMap, env, sync::LazyLock}; +use std::{env, sync::LazyLock}; use reqwest::Method; use rocket::{ - Catcher, Route, form::Form, http::{Cookie, CookieJar, MediaType, SameSite, Status}, request::{FromRequest, Outcome, Request}, - response::{Redirect, content::RawHtml as Html}, + response::{content::RawHtml as Html, Redirect}, serde::json::Json, + Catcher, Route, }; use serde::de::DeserializeOwned; use serde_json::Value; use crate::{ - CONFIG, VERSION, api::{ - ApiResult, EmptyResult, JsonResult, Notify, core::{log_event, two_factor}, - push_logout_to_relay, unregister_push_device, + unregister_push_device, ApiResult, EmptyResult, JsonResult, Notify, }, - auth::{ClientIp, Secure, decode_admin, encode_jwt, generate_admin_claims}, + auth::{decode_admin, encode_jwt, generate_admin_claims, ClientIp, Secure}, config::ConfigBuilder, db::{ - ACTIVE_DB_TYPE, DbConn, DbConnType, backup_sqlite, get_sql_server_version, + backup_sqlite, get_sql_server_version, models::{ Attachment, Cipher, Collection, Device, Event, EventType, Group, Invitation, Membership, MembershipId, MembershipType, OrgPolicy, Organization, OrganizationId, SsoUser, TwoFactor, User, UserId, - custom_roles_enabled, }, + DbConn, DbConnType, ACTIVE_DB_TYPE, }, error::{Error, MapResult}, http_client::make_http_request, mail, - sso::FAKE_SSO_IDENTIFIER, util::{ - FeatureFlagFilter, NumberOrString, container_base_image, format_naive_datetime_local, get_active_web_release, - get_display_size, is_running_in_container, parse_experimental_client_feature_flags, + container_base_image, format_naive_datetime_local, get_active_web_release, get_display_size, + is_running_in_container, parse_experimental_client_feature_flags, FeatureFlagFilter, NumberOrString, }, + CONFIG, VERSION, }; pub fn routes() -> Vec { @@ -94,7 +92,8 @@ static DB_TYPE: LazyLock<&str> = LazyLock::new(|| match ACTIVE_DB_TYPE.get() { }); #[cfg(sqlite)] -static CAN_BACKUP: LazyLock = LazyLock::new(|| ACTIVE_DB_TYPE.get().is_some_and(|t| *t == DbConnType::Sqlite)); +static CAN_BACKUP: LazyLock = + LazyLock::new(|| ACTIVE_DB_TYPE.get().map(|t| *t == DbConnType::Sqlite).unwrap_or(false)); #[cfg(not(sqlite))] static CAN_BACKUP: LazyLock = LazyLock::new(|| false); @@ -200,17 +199,20 @@ fn post_admin_login( } // If the token is invalid, redirect to login page - if validate_token(&data.token) { + if !_validate_token(&data.token) { + error!("Invalid admin token. IP: {}", ip.ip); + Err(AdminResponse::Unauthorized(render_admin_login( + Some("Invalid admin token, please try again."), + redirect.as_deref(), + ))) + } else { // If the token received is valid, generate JWT and save it as a cookie - let claims = match generate_admin_claims() { - Ok(claims) => claims, - Err(e) => return Err(AdminResponse::Ok(render_admin_login(Some(e.message()), redirect.as_deref()))), - }; + let claims = generate_admin_claims(); let jwt = encode_jwt(&claims); let cookie = Cookie::build((COOKIE_NAME, jwt)) .path(admin_path()) - .max_age(time::Duration::minutes(i64::from(CONFIG.admin_session_lifetime()))) + .max_age(time::Duration::minutes(CONFIG.admin_session_lifetime())) .same_site(SameSite::Strict) .http_only(true) .secure(secure.https); @@ -221,21 +223,15 @@ fn post_admin_login( } else { Err(AdminResponse::Ok(render_admin_page())) } - } else { - error!("Invalid admin token. IP: {}", ip.ip); - Err(AdminResponse::Unauthorized(render_admin_login( - Some("Invalid admin token, please try again."), - redirect.as_deref(), - ))) } } -fn validate_token(token: &str) -> bool { +fn _validate_token(token: &str) -> bool { match CONFIG.admin_token().as_ref() { None => false, Some(t) if t.starts_with("$argon2") => { use argon2::password_hash::PasswordVerifier; - match argon2::password_hash::phc::PasswordHash::new(t) { + match argon2::password_hash::PasswordHash::new(t) { Ok(h) => { // NOTE: hash params from `ADMIN_TOKEN` are used instead of what is configured in the `Argon2` instance. argon2::Argon2::default().verify_password(token.trim().as_ref(), &h).is_ok() @@ -257,7 +253,6 @@ struct AdminTemplateData { logged_in: bool, urlpath: String, sso_enabled: bool, - custom_roles_enabled: bool, } impl AdminTemplateData { @@ -268,7 +263,6 @@ impl AdminTemplateData { logged_in: true, urlpath: CONFIG.domain_path(), sso_enabled: CONFIG.sso_enabled(), - custom_roles_enabled: custom_roles_enabled(), } } @@ -312,21 +306,6 @@ async fn get_user_or_404(user_id: &UserId, conn: &DbConn) -> ApiResult { #[post("/invite", format = "application/json", data = "")] async fn invite_user(data: Json, _token: AdminToken, conn: DbConn) -> JsonResult { - async fn generate_invite(user: &User, conn: &DbConn) -> EmptyResult { - if CONFIG.mail_enabled() { - let org_id: OrganizationId = if CONFIG.sso_enabled() { - FAKE_SSO_IDENTIFIER.into() - } else { - FAKE_ADMIN_UUID.into() - }; - let member_id: MembershipId = FAKE_ADMIN_UUID.to_owned().into(); - mail::send_invite(user, org_id, member_id, &CONFIG.invitation_org_name(), None).await - } else { - let invitation = Invitation::new(&user.email); - invitation.save(conn).await - } - } - let data: InviteData = data.into_inner(); if User::find_by_mail(&data.email, &conn).await.is_some() { err_code!("User already exists", Status::Conflict.code) @@ -334,7 +313,18 @@ async fn invite_user(data: Json, _token: AdminToken, conn: DbConn) - let mut user = User::new(&data.email, None); - generate_invite(&user, &conn).await.map_err(|e| e.with_code(Status::InternalServerError.code))?; + async fn _generate_invite(user: &User, conn: &DbConn) -> EmptyResult { + if CONFIG.mail_enabled() { + let org_id: OrganizationId = FAKE_ADMIN_UUID.to_string().into(); + let member_id: MembershipId = FAKE_ADMIN_UUID.to_string().into(); + mail::send_invite(user, org_id, member_id, &CONFIG.invitation_org_name(), None).await + } else { + let invitation = Invitation::new(&user.email); + invitation.save(conn).await + } + } + + _generate_invite(&user, &conn).await.map_err(|e| e.with_code(Status::InternalServerError.code))?; user.save(&conn).await.map_err(|e| e.with_code(Status::InternalServerError.code))?; Ok(Json(user.to_json(&conn).await)) @@ -391,19 +381,7 @@ async fn users_overview(_token: AdminToken, conn: DbConn) -> ApiResult json!("Never"), }; - usr["sso_identifier"] = json!(sso_u.map_or(String::new(), |u| u.identifier.to_string())); - - // The clients are shown a Manager as a Custom member, but the admin panel tells the two roles apart - let roles: HashMap = Membership::find_confirmed_by_user(&u.uuid, &conn) - .await - .into_iter() - .map(|member| (member.org_uuid.to_string(), member.atype)) - .collect(); - for org in usr["organizations"].as_array_mut().into_iter().flatten() { - if let Some(atype) = org["id"].as_str().and_then(|org_id| roles.get(org_id)) { - org["type"] = json!(atype); - } - } + usr["sso_identifier"] = json!(sso_u.map(|u| u.identifier.to_string()).unwrap_or(String::new())); users_json.push(usr); } @@ -434,24 +412,16 @@ async fn get_user_json(user_id: UserId, _token: AdminToken, conn: DbConn) -> Jso } #[post("/users//delete", format = "application/json")] -async fn delete_user(user_id: UserId, token: AdminToken, conn: DbConn, nt: Notify<'_>) -> EmptyResult { +async fn delete_user(user_id: UserId, token: AdminToken, conn: DbConn) -> EmptyResult { let user = get_user_or_404(&user_id, &conn).await?; - // Get the membership records and push devices before deleting the actual user + // Get the membership records before deleting the actual user let memberships = Membership::find_any_state_by_user(&user_id, &conn).await; - let has_push_device = CONFIG.push_enabled() && Device::check_user_has_push_device(&user_id, &conn).await; let res = user.delete(&conn).await; - if res.is_ok() { - nt.send_logout(&user, None, &conn).await; - if has_push_device { - push_logout_to_relay(&user, None, None); - } - } - for membership in memberships { log_event( - EventType::OrganizationUserDeleted, + EventType::OrganizationUserDeleted as i32, &membership.uuid, &membership.org_uuid, &ACTING_ADMIN_USER.into(), @@ -472,7 +442,7 @@ async fn delete_sso_user(user_id: UserId, token: AdminToken, conn: DbConn) -> Em for membership in memberships { log_event( - EventType::OrganizationUserUnlinkedSso, + EventType::OrganizationUserUnlinkedSso as i32, &membership.uuid, &membership.org_uuid, &ACTING_ADMIN_USER.into(), @@ -494,10 +464,10 @@ async fn deauth_user(user_id: UserId, _token: AdminToken, conn: DbConn, nt: Noti if CONFIG.push_enabled() { for device in Device::find_push_devices_by_user(&user.uuid, &conn).await { - match unregister_push_device(device.push_uuid.as_ref()).await { + match unregister_push_device(&device.push_uuid).await { Ok(r) => r, Err(e) => error!("Unable to unregister devices from Bitwarden server: {e}"), - } + }; } } @@ -534,7 +504,6 @@ async fn enable_user(user_id: UserId, _token: AdminToken, conn: DbConn) -> Empty async fn remove_2fa(user_id: UserId, token: AdminToken, conn: DbConn) -> EmptyResult { let mut user = get_user_or_404(&user_id, &conn).await?; TwoFactor::delete_all_by_user(&user.uuid, &conn).await?; - Device::clear_twofactor_remember_by_user(&user.uuid, &conn).await?; two_factor::enforce_2fa_policy(&user, &ACTING_ADMIN_USER.into(), 14, &token.ip.ip, &conn).await?; user.totp_recover = None; user.save(&conn).await @@ -549,12 +518,8 @@ async fn resend_user_invite(user_id: UserId, _token: AdminToken, conn: DbConn) - } if CONFIG.mail_enabled() { - let org_id: OrganizationId = if CONFIG.sso_enabled() { - FAKE_SSO_IDENTIFIER.into() - } else { - FAKE_ADMIN_UUID.into() - }; - let member_id: MembershipId = FAKE_ADMIN_UUID.to_owned().into(); + let org_id: OrganizationId = FAKE_ADMIN_UUID.to_string().into(); + let member_id: MembershipId = FAKE_ADMIN_UUID.to_string().into(); mail::send_invite(&user, org_id, member_id, &CONFIG.invitation_org_name(), None).await } else { Ok(()) @@ -581,12 +546,7 @@ async fn update_membership_type(data: Json, token: AdminToke }; let new_type = match MembershipType::from_str(&data.user_type.into_string()) { - // Unlike the clients, the admin panel tells the Custom and the legacy Manager role apart. Unless Custom roles - // are enabled, a Custom role is a Manager here as well, but an existing Custom member is kept as one. - Some(MembershipType::Custom) if !custom_roles_enabled() && !member_to_edit.has_type(MembershipType::Custom) => { - MembershipType::Manager - } - Some(new_type) => new_type, + Some(new_type) => new_type as i32, None => err!("Invalid type"), }; @@ -597,19 +557,12 @@ async fn update_membership_type(data: Json, token: AdminToke } } - // A Manager only becomes a Custom member if that changes nothing they may do - if new_type == MembershipType::Custom - && let Some(loss) = member_to_edit.custom_role_conversion_loss(&conn).await - { - err!(loss) - } - - member_to_edit.set_type(new_type); + member_to_edit.atype = new_type; // This check is also done at api::organizations::{accept_invite, _confirm_invite, _activate_member, edit_member}, update_membership_type OrgPolicy::check_user_allowed(&member_to_edit, "modify", &conn).await?; log_event( - EventType::OrganizationUserUpdated, + EventType::OrganizationUserUpdated as i32, &member_to_edit.uuid, &data.org_uuid, &ACTING_ADMIN_USER.into(), @@ -681,11 +634,11 @@ async fn has_http_access() -> bool { } } -use cached::macros::cached; +use cached::proc_macro::cached; /// Cache this function to prevent API call rate limit. Github only allows 60 requests per hour, and we use 3 here already /// It will cache this function for 600 seconds (10 minutes) which should prevent the exhaustion of the rate limit /// Any cache will be lost if Vaultwarden is restarted -#[cached(ttl_secs = 600, sync_writes = "default")] +#[cached(time = 600, sync_writes = "default")] async fn get_release_info(has_http_access: bool) -> (String, String, String) { // If the HTTP Check failed, do not even attempt to check for new versions since we were not able to connect with github.com anyway. if has_http_access { @@ -694,40 +647,42 @@ async fn get_release_info(has_http_access: bool) -> (String, String, String) { .await { Ok(r) => r.tag_name, - _ => "-".to_owned(), + _ => "-".to_string(), }, match get_json_api::("https://api.github.com/repos/dani-garcia/vaultwarden/commits/main").await { Ok(mut c) => { c.sha.truncate(8); c.sha } - _ => "-".to_owned(), + _ => "-".to_string(), }, // Do not fetch the web-vault version when running within a container // The web-vault version is embedded within the container it self, and should not be updated manually match get_json_api::("https://api.github.com/repos/dani-garcia/bw_web_builds/releases/latest") .await { - Ok(r) => r.tag_name.trim_start_matches('v').to_owned(), - _ => "-".to_owned(), + Ok(r) => r.tag_name.trim_start_matches('v').to_string(), + _ => "-".to_string(), }, ) } else { - ("-".to_owned(), "-".to_owned(), "-".to_owned()) + ("-".to_string(), "-".to_string(), "-".to_string()) } } async fn get_ntp_time(has_http_access: bool) -> String { - if has_http_access && let Ok(cf_trace) = get_text_api("https://cloudflare.com/cdn-cgi/trace").await { - for line in cf_trace.lines() { - if let Some((key, value)) = line.split_once('=') - && key == "ts" - { - let ts = value.split_once('.').map_or(value, |(s, _)| s); - if let Ok(dt) = chrono::DateTime::parse_from_str(ts, "%s") { - return dt.format("%Y-%m-%d %H:%M:%S UTC").to_string(); + if has_http_access { + if let Ok(cf_trace) = get_text_api("https://cloudflare.com/cdn-cgi/trace").await { + for line in cf_trace.lines() { + if let Some((key, value)) = line.split_once('=') { + if key == "ts" { + let ts = value.split_once('.').map_or(value, |(s, _)| s); + if let Ok(dt) = chrono::DateTime::parse_from_str(ts, "%s") { + return dt.format("%Y-%m-%d %H:%M:%S UTC").to_string(); + } + break; + } } - break; } } } @@ -754,36 +709,6 @@ fn web_vault_compare(active: &str, latest: &str) -> i8 { } } -fn check_template_overrides() -> Vec<&'static str> { - let template_folder = std::path::PathBuf::from(CONFIG.templates_folder()); - let mut overrides = Vec::new(); - for folder in ["admin", "email", "scss"] { - if folder_has_hbs_files(&template_folder.join(folder)) { - overrides.push(folder); - } - } - - if folder_has_hbs_files(&template_folder) { - overrides.push("other"); - } - - overrides -} - -fn folder_has_hbs_files(dir: &std::path::Path) -> bool { - let Ok(files) = std::fs::read_dir(dir) else { - // No files in this directory at all, so we can return false - return false; - }; - - files.flatten().any(|f| { - // Validate if it is a file and if it has the `.hbs` extension and starts with a-z or 0-9 - f.file_type().is_ok_and(|t| t.is_file()) - && f.path().extension().is_some_and(|e| e.eq_ignore_ascii_case("hbs")) - && f.file_name().to_str().is_some_and(|n| n.starts_with(|c: char| c.is_ascii_alphanumeric())) - }) -} - #[get("/diagnostics")] async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> ApiResult> { use chrono::prelude::*; @@ -800,7 +725,7 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> A // Check if we are able to resolve DNS entries let dns_resolved = match ("github.com", 0).to_socket_addrs().map(|mut i| i.next()) { Ok(Some(a)) => a.ip().to_string(), - _ => "Unable to resolve domain name.".to_owned(), + _ => "Unable to resolve domain name.".to_string(), }; let (latest_vw_release, latest_vw_commit, latest_web_release) = get_release_info(has_http_access).await; @@ -811,13 +736,11 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> A let invalid_feature_flags: Vec = parse_experimental_client_feature_flags( &CONFIG.experimental_client_feature_flags(), - &FeatureFlagFilter::InvalidOnly, + FeatureFlagFilter::InvalidOnly, ) .into_keys() .collect(); - let invalid_env_vars = crate::util::INVALID_ENV_VARS.lock().unwrap().join(", "); - let diagnostics_json = json!({ "dns_resolved": dns_resolved, "current_release": VERSION, @@ -840,9 +763,6 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> A "db_version": get_sql_server_version(&conn).await, "admin_url": format!("{}/diagnostics", admin_url()), "overrides": &CONFIG.get_overrides().join(", "), - "config_file_status": &CONFIG.get_config_file_status(), - "template_overrides": check_template_overrides().join(", "), - "invalid_env_vars": invalid_env_vars, "invalid_feature_flags": invalid_feature_flags, "host_arch": env::consts::ARCH, "host_os": env::consts::OS, @@ -905,30 +825,33 @@ impl<'r> FromRequest<'r> for AdminToken { type Error = &'static str; async fn from_request(request: &'r Request<'_>) -> Outcome { - let Outcome::Success(ip) = ClientIp::from_request(request).await else { - err_handler!("Error getting Client IP") + let ip = match ClientIp::from_request(request).await { + Outcome::Success(ip) => ip, + _ => err_handler!("Error getting Client IP"), }; if !CONFIG.disable_admin_token() { let cookies = request.cookies(); - let access_token = if let Some(cookie) = cookies.get(COOKIE_NAME) { - cookie.value() - } else { - let requested_page = - request.segments::(0..).unwrap_or_default().display().to_string(); - // When the requested page is empty, it is `/admin`, in that case, Forward, so it will render the login page - // Else, return a 401 failure, which will be caught - if requested_page.is_empty() { - return Outcome::Forward(Status::Unauthorized); + let access_token = match cookies.get(COOKIE_NAME) { + Some(cookie) => cookie.value(), + None => { + let requested_page = + request.segments::(0..).unwrap_or_default().display().to_string(); + // When the requested page is empty, it is `/admin`, in that case, Forward, so it will render the login page + // Else, return a 401 failure, which will be caught + if requested_page.is_empty() { + return Outcome::Forward(Status::Unauthorized); + } else { + return Outcome::Error((Status::Unauthorized, "Unauthorized")); + } } - return Outcome::Error((Status::Unauthorized, "Unauthorized")); }; if decode_admin(access_token).is_err() { // Remove admin cookie cookies.remove(Cookie::build(COOKIE_NAME).path(admin_path())); - error!("Invalid or expired admin JWT. IP: {}.", ip.ip); + error!("Invalid or expired admin JWT. IP: {}.", &ip.ip); return Outcome::Error((Status::Unauthorized, "Session expired")); } } @@ -947,23 +870,23 @@ mod tests { fn validate_web_vault_compare() { // web_vault_compare(active, latest) // Test normal versions - assert_eq!(web_vault_compare("2025.12.0", "2025.12.1"), -1); - assert_eq!(web_vault_compare("2025.12.1", "2025.12.1"), 0); - assert_eq!(web_vault_compare("2025.12.2", "2025.12.1"), 1); + assert!(web_vault_compare("2025.12.0", "2025.12.1") == -1); + assert!(web_vault_compare("2025.12.1", "2025.12.1") == 0); + assert!(web_vault_compare("2025.12.2", "2025.12.1") == 1); // Test patched/+build.n versions // Newer latest version - assert_eq!(web_vault_compare("2025.12.0+build.1", "2025.12.1"), -1); - assert_eq!(web_vault_compare("2025.12.1", "2025.12.1+build.1"), -1); - assert_eq!(web_vault_compare("2025.12.0+build.1", "2025.12.1+build.1"), -1); - assert_eq!(web_vault_compare("2025.12.1+build.1", "2025.12.1+build.2"), -1); + assert!(web_vault_compare("2025.12.0+build.1", "2025.12.1") == -1); + assert!(web_vault_compare("2025.12.1", "2025.12.1+build.1") == -1); + assert!(web_vault_compare("2025.12.0+build.1", "2025.12.1+build.1") == -1); + assert!(web_vault_compare("2025.12.1+build.1", "2025.12.1+build.2") == -1); // Equal versions - assert_eq!(web_vault_compare("2025.12.1+build.1", "2025.12.1+build.1"), 0); - assert_eq!(web_vault_compare("2025.12.2+build.2", "2025.12.2+build.2"), 0); + assert!(web_vault_compare("2025.12.1+build.1", "2025.12.1+build.1") == 0); + assert!(web_vault_compare("2025.12.2+build.2", "2025.12.2+build.2") == 0); // Newer active version - assert_eq!(web_vault_compare("2025.12.1+build.1", "2025.12.1"), 1); - assert_eq!(web_vault_compare("2025.12.2", "2025.12.1+build.1"), 1); - assert_eq!(web_vault_compare("2025.12.2+build.1", "2025.12.1+build.1"), 1); - assert_eq!(web_vault_compare("2025.12.1+build.3", "2025.12.1+build.2"), 1); + assert!(web_vault_compare("2025.12.1+build.1", "2025.12.1") == 1); + assert!(web_vault_compare("2025.12.2", "2025.12.1+build.1") == 1); + assert!(web_vault_compare("2025.12.2+build.1", "2025.12.1+build.1") == 1); + assert!(web_vault_compare("2025.12.1+build.3", "2025.12.1+build.2") == 1); } } diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index b2dd5845..8841c184 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -1,39 +1,34 @@ use std::collections::HashSet; +use crate::db::DbPool; use chrono::Utc; -use num_traits::FromPrimitive; -use rocket::{ - http::Status, - request::{FromRequest, Outcome, Request}, - serde::json::Json, -}; +use rocket::serde::json::Json; use serde_json::Value; use crate::{ - CONFIG, api::{ - AnonymousNotify, ApiResult, EmptyResult, JsonResult, LogOutReason, Notify, PasswordOrOtpData, core::{accept_org_invite, log_user_event, two_factor::email}, - master_password_policy, push_logout_to_relay, register_push_device, unregister_push_device, + master_password_policy, register_push_device, unregister_push_device, AnonymousNotify, ApiResult, EmptyResult, + JsonResult, Notify, PasswordOrOtpData, UpdateType, }, - auth::{ClientHeaders, ClientIp, Headers, decode_delete, decode_invite, decode_verify_email}, + auth::{decode_delete, decode_invite, decode_verify_email, ClientHeaders, Headers}, crypto, db::{ - DbConn, DbPool, models::{ - AuthRequest, AuthRequestId, Cipher, CipherId, Device, DeviceId, DeviceType, DeviceWithAuthRequest, - EmergencyAccess, EmergencyAccessId, EventType, Folder, FolderId, Invitation, KeyId, Membership, - MembershipId, OrgPolicy, OrgPolicyType, Organization, OrganizationId, Send, SendId, SignatureAlgorithm, - User, UserId, UserKdfType, UserSignatureKeyPair, + AuthRequest, AuthRequestId, Cipher, CipherId, Device, DeviceId, DeviceType, EmergencyAccess, + EmergencyAccessId, EventType, Folder, FolderId, Invitation, Membership, MembershipId, OrgPolicy, + OrgPolicyType, Organization, OrganizationId, Send, SendId, User, UserId, UserKdfType, }, + DbConn, }, mail, - util::{NumberOrString, deser_opt_nonempty_str, format_date}, + util::{deser_opt_nonempty_str, format_date, NumberOrString}, + CONFIG, }; -use super::{ - ciphers::{CipherData, rotate_cipher_data, validate_rotated_cipher}, - sends::SendData, +use rocket::{ + http::Status, + request::{FromRequest, Outcome, Request}, }; pub fn routes() -> Vec { @@ -43,16 +38,11 @@ pub fn routes() -> Vec { post_profile, put_avatar, get_public_keys, - get_account_public_keys, - get_keys, post_keys, post_password, post_set_password, post_kdf, - get_key_rotation_data, post_rotatekey, - post_user_key, - post_rotate_user_keys, post_sstamp, post_email_token, post_email, @@ -64,8 +54,9 @@ pub fn routes() -> Vec { delete_account, revision_date, password_hint, + prelogin, verify_password, - post_api_key, + api_key, rotate_api_key, get_known_device, get_all_devices, @@ -97,33 +88,27 @@ pub struct KDFData { kdf_parallelism: Option, } -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)] #[serde(rename_all = "camelCase")] pub struct RegisterData { email: String, #[serde(flatten)] - compat: RegisterDataCompat, + kdf: KDFData, + #[serde(alias = "userSymmetricKey")] + key: String, #[serde(alias = "userAsymmetricKeys")] keys: Option, - // Supersedes `keys`, and the only way a v2 account can be registered. - account_keys: Option, - + master_password_hash: String, master_password_hint: Option, + name: Option, + organization_user_id: Option, + // Used only from the register/finish endpoint email_verification_token: Option, accept_emergency_access_id: Option, accept_emergency_access_invite_token: Option, @@ -132,80 +117,16 @@ pub struct RegisterData { } #[derive(Debug, Deserialize)] -struct RegisterDataOld { +#[serde(rename_all = "camelCase")] +pub struct SetPasswordData { #[serde(flatten)] kdf: KDFData, - #[serde(alias = "userSymmetricKey")] key: String, - - #[serde(alias = "masterPasswordHash")] + keys: Option, master_password_hash: String, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct RegisterDataCur { - #[serde(alias = "MasterPasswordAuthentication")] - master_password_authentication: MasterPasswordAuthentication, - #[serde(alias = "MasterPasswordUnlock")] - master_password_unlock: MasterPasswordUnlock, -} - -#[derive(Debug, Deserialize)] -#[serde(untagged)] -enum RegisterDataCompat { - RegisterDataOld(RegisterDataOld), - RegisterDataCur(RegisterDataCur), -} - -impl RegisterDataCompat { - fn fold<'a, T>( - &'a self, - fct: impl FnOnce(&'a RegisterDataOld) -> &'a T, - fcu: impl FnOnce(&'a RegisterDataCur) -> &'a T, - ) -> &'a T { - match self { - RegisterDataCompat::RegisterDataOld(rdc) => fct(rdc), - RegisterDataCompat::RegisterDataCur(rdcu) => fcu(rdcu), - } - } - - fn hash(&self) -> String { - self.fold(|rdc| &rdc.master_password_hash, |rdcu| &rdcu.master_password_authentication.hash).to_owned() - } - - fn kdf(&self) -> &KDFData { - self.fold(|rdc| &rdc.kdf, |rdcu| &rdcu.master_password_authentication.kdf) - } - - fn key(&self) -> String { - self.fold(|rdc| &rdc.key, |rdcu| &rdcu.master_password_unlock.key).to_owned() - } - - /// The id of the user key, which only the current format carries. - fn key_id(&self) -> Option { - match self { - RegisterDataCompat::RegisterDataOld(_) => None, - RegisterDataCompat::RegisterDataCur(rdcu) => rdcu.master_password_unlock.contained_key_id.clone(), - } - } - - // When comparing with salt, email need to be normalized: - // - https://github.com/bitwarden/clients/blob/web-v2026.5.0/libs/common/src/key-management/master-password/services/master-password.service.ts#L171 - fn unprocessable(&self, email: &str) -> bool { - let mut unprocessable = false; - *self.fold( - |_| &false, - |rdcu| { - let email = email.trim().to_lowercase(); - unprocessable = rdcu.master_password_authentication.kdf != rdcu.master_password_unlock.kdf - || rdcu.master_password_authentication.salt != email - || rdcu.master_password_unlock.salt != email; - &unprocessable - }, - ) - } + master_password_hint: Option, + org_identifier: Option, } #[derive(Debug, Deserialize)] @@ -215,264 +136,18 @@ struct KeysData { public_key: String, } -/// Upstream's implicit `[Required]` on a non-nullable string: present and not blank. -fn required(value: Option, field: &str) -> ApiResult { - match value { - Some(value) if !value.trim().is_empty() => Ok(value), - _ => err!(format!("The {field} field is required.")), - } -} - -/// The `accountKeys` payload: a v1 key pair, or v2 with a signature key pair and security state. -/// -/// Ref: -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct AccountKeysData { - // Required like upstream, but only used without `public_key_encryption_key_pair` - user_key_encrypted_account_private_key: Option, - account_public_key: Option, - - public_key_encryption_key_pair: Option, - signature_key_pair: Option, - security_state: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct PublicKeyEncryptionKeyPairData { - wrapped_private_key: String, - public_key: String, - signed_public_key: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct SignatureKeyPairData { - signature_algorithm: String, - wrapped_signing_key: String, - verifying_key: String, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct SecurityStateData { - security_state: String, - security_version: i32, -} - -struct ValidatedAccountKeys { - private_key: String, - public_key: String, - v2: Option, -} - -struct ValidatedV2AccountKeys { - signed_public_key: String, - signing_key: String, - verifying_key: String, - signature_algorithm: SignatureAlgorithm, - security_state: String, - security_version: i32, -} - -impl AccountKeysData { - /// Upstream's model checks, plus the v2 fields all or none: no client can unlock a partial v2 state. - fn validate(self) -> ApiResult { - let top_private_key = - required(self.user_key_encrypted_account_private_key, "UserKeyEncryptedAccountPrivateKey")?; - let top_public_key = required(self.account_public_key, "AccountPublicKey")?; - let (private_key, public_key, signed_public_key) = match self.public_key_encryption_key_pair { - Some(key_pair) => ( - required(Some(key_pair.wrapped_private_key), "WrappedPrivateKey")?, - required(Some(key_pair.public_key), "PublicKey")?, - key_pair.signed_public_key, - ), - // Older clients only send the top-level fields, which are always v1 - None => (top_private_key, top_public_key, None), - }; - - if let Some(signature_key_pair) = &self.signature_key_pair { - required(Some(signature_key_pair.signature_algorithm.clone()), "SignatureAlgorithm")?; - required(Some(signature_key_pair.wrapped_signing_key.clone()), "WrappedSigningKey")?; - required(Some(signature_key_pair.verifying_key.clone()), "VerifyingKey")?; - } - if let Some(security_state) = &self.security_state { - required(Some(security_state.security_state.clone()), "SecurityState")?; - if security_state.security_state.encode_utf16().count() > 10_000 { - err!("The field SecurityState must be a string with a maximum length of 10000.") - } - } - - let v2 = match (signed_public_key, self.signature_key_pair, self.security_state) { - (Some(signed_public_key), Some(signature_key_pair), Some(security_state)) => { - // Upstream fails on a null one, but takes an empty one that no client can unlock with - let signed_public_key = required(Some(signed_public_key), "SignedPublicKey")?; - let Some(signature_algorithm) = SignatureAlgorithm::parse(&signature_key_pair.signature_algorithm) - else { - err!(format!( - "Unsupported signature algorithm: {}", - signature_key_pair.signature_algorithm.escape_debug() - )) - }; - - Some(ValidatedV2AccountKeys { - signed_public_key, - signing_key: signature_key_pair.wrapped_signing_key, - verifying_key: signature_key_pair.verifying_key, - signature_algorithm, - security_state: security_state.security_state, - security_version: security_state.security_version, - }) - } - (None, None, None) => None, - _ => err!( - "Invalid account keys: the signed public key, signature key pair and security state must either all be present or all be absent" - ), - }; - - Ok(ValidatedAccountKeys { - private_key, - public_key, - v2, - }) - } -} - -impl WrappedAccountCryptographicState { - /// v2-only: the nested pair stands in for the top-level keys, so a partial state can't pass as v1. - fn validate(self) -> ApiResult { - let key_pair = self.public_key_encryption_key_pair; - AccountKeysData { - user_key_encrypted_account_private_key: Some(key_pair.wrapped_private_key.clone()), - account_public_key: Some(key_pair.public_key.clone()), - public_key_encryption_key_pair: Some(key_pair), - signature_key_pair: Some(self.signature_key_pair), - security_state: Some(self.security_state), - } - .validate() - } -} - -impl From for ValidatedAccountKeys { - fn from(keys: KeysData) -> Self { - Self { - private_key: keys.encrypted_private_key, - public_key: keys.public_key, - v2: None, - } - } -} - -impl ValidatedAccountKeys { - /// The keys of a registration: `accountKeys` only for a v2 account, like upstream. - /// - /// Ref: - fn for_registration(account_keys: Option, keys: Option) -> ApiResult { - if let Some(account_keys) = account_keys { - let account_keys = account_keys.validate()?; - if account_keys.is_v2() { - return Ok(account_keys); - } - } - let Some(keys) = keys else { - err!("PublicKey and WrappedPrivateKey not found in RequestModel") - }; - Ok(keys.into()) - } - - fn is_v2(&self) -> bool { - self.v2.is_some() - } - - /// Sets the keys on the user. Save [`Self::save_signature_key_pair`] before the user: a v2 user without its key - /// pair can't unlock, while a key pair saved for a user who isn't v2 yet is ignored until it is, and replaced on - /// the next try. A rotation of a v2 account replaces its key pair, see `rotate_account`. - fn apply(&self, user: &mut User) -> EmptyResult { - user.private_key = Some(self.private_key.clone()); - user.public_key = Some(self.public_key.clone()); - - user.signed_public_key = self.v2.as_ref().map(|v2| v2.signed_public_key.clone()); - user.security_state = self.v2.as_ref().map(|v2| v2.security_state.clone()); - user.security_version = self.v2.as_ref().map(|v2| v2.security_version); - - Ok(()) - } - - /// Saves the signature key pair, which needs the user to exist for its foreign key. - async fn save_signature_key_pair(&self, user_id: &UserId, conn: &DbConn) -> EmptyResult { - // Skip if the account is v1, since v1 accounts don't have a signature key pair. - let Some(v2) = &self.v2 else { - return Ok(()); - }; - - let mut key_pair = match UserSignatureKeyPair::find_by_user(user_id, conn).await { - Some(mut key_pair) => { - key_pair.signature_algorithm = v2.signature_algorithm as i32; - key_pair.signing_key.clone_from(&v2.signing_key); - key_pair.verifying_key.clone_from(&v2.verifying_key); - key_pair - } - None => UserSignatureKeyPair::new( - user_id.clone(), - v2.signature_algorithm, - v2.signing_key.clone(), - v2.verifying_key.clone(), - ), - }; - key_pair.save(conn).await - } -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct MasterPasswordAuthentication { - #[serde(alias = "Kdf")] - kdf: KDFData, - #[serde(alias = "Salt")] - salt: String, - - #[serde(alias = "masterPasswordAuthenticationHash", alias = "MasterPasswordAuthenticationHash")] - hash: String, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct MasterPasswordUnlock { - kdf: KDFData, - salt: String, - - #[serde(alias = "masterKeyWrappedUserKey")] - key: String, - contained_key_id: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SetPasswordData { - #[serde(flatten)] - compat: RegisterDataCompat, - - keys: Option, - // Supersedes `keys`, and the only way a v2 account can be initialized here. - account_keys: Option, - - master_password_hint: Option, - org_identifier: Option, -} - /// Trims whitespace from password hints, and converts blank password hints to `None`. -fn clean_password_hint(password_hint: Option<&String>) -> Option { +fn clean_password_hint(password_hint: &Option) -> Option { match password_hint { None => None, Some(h) => match h.trim() { "" => None, - ht => Some(ht.to_owned()), + ht => Some(ht.to_string()), }, } } -fn enforce_password_hint_setting(password_hint: Option<&String>) -> EmptyResult { +fn enforce_password_hint_setting(password_hint: &Option) -> EmptyResult { if password_hint.is_some() && !CONFIG.password_hints_allowed() { err!("Password hints have been disabled by the administrator. Remove the hint and try again."); } @@ -491,98 +166,96 @@ async fn is_email_2fa_required(member_id: Option, conn: &DbConn) - false } -pub async fn register(data: Json, conn: DbConn) -> JsonResult { - let data: RegisterData = data.into_inner(); +pub async fn _register(data: Json, email_verification: bool, conn: DbConn) -> JsonResult { + let mut data: RegisterData = data.into_inner(); let email = data.email.to_lowercase(); - let mut name = None; + let mut email_verified = false; + let mut pending_emergency_access = None; - if data.compat.unprocessable(&data.email) { - err_code!("Unexpected RegisterData format", Status::UnprocessableEntity.code); - } - // First, validate the provided verification tokens - let mut email_verified = match ( - &data.email_verification_token, - &data.accept_emergency_access_id, - &data.accept_emergency_access_invite_token, - &data.organization_user_id, - &data.org_invite_token, - ) { - // Normal user registration, when email verification is required - (Some(email_verification_token), None, None, None, None) => { - let claims = crate::auth::decode_register_verify(email_verification_token)?; - if claims.sub != data.email { - err!("Email verification token does not match email"); + if email_verification { + match ( + &data.email_verification_token, + &data.accept_emergency_access_id, + &data.accept_emergency_access_invite_token, + &data.organization_user_id, + &data.org_invite_token, + ) { + // Normal user registration, when email verification is required + (Some(email_verification_token), None, None, None, None) => { + let claims = crate::auth::decode_register_verify(email_verification_token)?; + if claims.sub != data.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)?; + + if claims.email != data.email { + 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)); + email_verified = true; + } + // 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 { + err!("Claim org_user_id does not match organization_user_id") + } + + email_verified = true; } - // During this call we don't get the name, so extract it from the claims - name = claims.name; - claims.verified + _ => { + err!("Registration is missing required parameters") + } } - // 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)?; - - if claims.email != data.email { - 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)); - true - } - // 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 { - 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) // This also prevents issues with very long usernames causing to large JWT's. See #2419 - if let Some(ref name) = name - && name.len() > 50 - { - err!("The field Name must be a string with a maximum length of 50."); + if let Some(ref name) = data.name { + if name.len() > 50 { + err!("The field Name must be a string with a maximum length of 50."); + } } - // Check against the password hint setting and the keys here so if they fail, - // the user can retry without losing their invitation below. - let password_hint = clean_password_hint(data.master_password_hint.as_ref()); - enforce_password_hint_setting(password_hint.as_ref())?; - let account_keys = ValidatedAccountKeys::for_registration(data.account_keys, data.keys)?; + // Check against the password hint setting here so if it fails, the user + // can retry without losing their invitation below. + let password_hint = clean_password_hint(&data.master_password_hint); + enforce_password_hint_setting(&password_hint)?; - let existing_user = User::find_by_mail(&email, &conn).await; - let is_new_user = existing_user.is_none(); - let mut user = match existing_user { + let mut user = match User::find_by_mail(&email, &conn).await { Some(user) => { if !user.password_hash.is_empty() { err!("Registration not allowed or user already exists") } - if let Some(token) = data.org_invite_token.as_ref() { - let claims = decode_invite(token)?; + if let Some(token) = data.org_invite_token { + let claims = decode_invite(&token)?; if claims.email == email { // Verify the email address when signing up via a valid invite token email_verified = true; @@ -620,20 +293,19 @@ pub async fn register(data: Json, conn: DbConn) -> JsonResult { // Make sure we don't leave a lingering invitation. Invitation::take(&email, &conn).await; - set_kdf_data(&mut user, data.compat.kdf())?; + set_kdf_data(&mut user, &data.kdf)?; - user.set_password(&data.compat.hash(), Some(data.compat.key()), true, None, &conn).await?; + user.set_password(&data.master_password_hash, Some(data.key), true, None, &conn).await?; user.password_hint = password_hint; // Add extra fields if present - if let Some(name) = name { + if let Some(name) = data.name { user.name = name; } - account_keys.apply(&mut user)?; - // Like upstream, only a v2 registration records the key id; v1 accounts report it through `user-key-id` - if account_keys.is_v2() { - user.key_id = data.compat.key_id(); + if let Some(keys) = data.keys { + user.private_key = Some(keys.encrypted_private_key); + user.public_key = Some(keys.public_key); } if email_verified { @@ -655,14 +327,6 @@ pub async fn register(data: Json, conn: DbConn) -> JsonResult { } } - // The key pair first, as in a rotation, but its foreign key needs the user: a new one is saved before without a - // password or keys, like an invited user, so a failure in between leaves a registration that can be retried. - if is_new_user && account_keys.is_v2() { - let mut unregistered = User::new(&user.email, None); - unregistered.uuid.clone_from(&user.uuid); - unregistered.save(&conn).await?; - } - account_keys.save_signature_key_pair(&user.uuid, &conn).await?; user.save(&conn).await?; // accept any open emergency access invitations @@ -673,85 +337,30 @@ pub async fn register(data: Json, conn: DbConn) -> JsonResult { } Ok(Json(json!({ - "object": "registerFinish", + "object": "register", + "captchaBypassToken": "", }))) } -/// Upstream's shape checks of the set-password body, on the raw JSON that the untagged compat would hide. -/// -/// Ref: -fn validate_set_password_shape(body: &Value) -> EmptyResult { - let has = |name: &str| { - body.as_object().is_some_and(|o| o.iter().any(|(k, v)| k.eq_ignore_ascii_case(name) && !v.is_null())) - }; - - if has("accountKeys") && has("keys") { - err!("Cannot specify both AccountKeys and Keys. Provide exactly one keypair.") - } - let (authentication, unlock) = (has("masterPasswordAuthentication"), has("masterPasswordUnlock")); - if authentication != unlock { - err!( - "Must provide both MasterPasswordAuthentication and MasterPasswordUnlock together. Cannot provide one without the other." - ) - } - if authentication && (has("masterPasswordHash") || has("key") || has("kdf")) { - err!( - "Cannot mix modern (MasterPasswordAuthentication/MasterPasswordUnlock) and legacy (MasterPasswordHash/Key/Kdf) fields. Provide one shape or the other." - ) - } - Ok(()) -} - #[post("/accounts/set-password", data = "")] -async fn post_set_password(data: Json, headers: Headers, conn: DbConn) -> JsonResult { - let data = data.into_inner(); - validate_set_password_shape(&data)?; - let Ok(data) = serde_json::from_value::(data) else { - err_code!("Unexpected SetPasswordData format", Status::UnprocessableEntity.code) - }; - if data.master_password_hint.as_ref().is_some_and(|h| h.encode_utf16().count() > 50) { - err!("The field MasterPasswordHint must be a string with a maximum length of 50.") - } +async fn post_set_password(data: Json, headers: Headers, conn: DbConn) -> JsonResult { + let data: SetPasswordData = data.into_inner(); let mut user = headers.user; - if user.private_key.is_some() || !user.password_hash.is_empty() { + if user.private_key.is_some() { err!("Account already initialized, cannot set password") } - if data.compat.unprocessable(&user.email) { - err_code!("Unexpected SetPasswordData format", Status::UnprocessableEntity.code); - } - // Check against the password hint setting here so if it fails, // the user can retry without losing their invitation below. - let password_hint = clean_password_hint(data.master_password_hint.as_ref()); - enforce_password_hint_setting(password_hint.as_ref())?; + let password_hint = clean_password_hint(&data.master_password_hint); + enforce_password_hint_setting(&password_hint)?; - // Like upstream, `accountKeys` only through the v2 JIT flow - // Ref: - let account_keys = match data.account_keys { - Some(account_keys) => { - if !matches!(data.compat, RegisterDataCompat::RegisterDataCur(_)) - || !crate::util::is_client_feature_flag_enabled( - "enable-account-encryption-v2-jit-password-registration", - ) - { - err!("Request includes V2 AccountKeys but V2 encryption is not enabled.") - } - let account_keys = account_keys.validate()?; - if !account_keys.is_v2() { - err!("AccountKeys are only supported for V2 encryption.") - } - Some(account_keys) - } - None => data.keys.map(ValidatedAccountKeys::from), - }; - - set_kdf_data(&mut user, data.compat.kdf())?; + set_kdf_data(&mut user, &data.kdf)?; user.set_password( - &data.compat.hash(), - Some(data.compat.key()), + &data.master_password_hash, + Some(data.key), false, Some(vec![String::from("revision_date")]), // We need to allow revision-date to use the old security_timestamp &conn, @@ -759,27 +368,23 @@ async fn post_set_password(data: Json, headers: Headers, conn: DbConn) -> .await?; user.password_hint = password_hint; - if let Some(ref account_keys) = account_keys { - account_keys.apply(&mut user)?; - // As in `register`, only a v2 account records the user key id here - if account_keys.is_v2() { - user.key_id = data.compat.key_id(); - } + if let Some(keys) = data.keys { + user.private_key = Some(keys.encrypted_private_key); + user.public_key = Some(keys.public_key); } - if let Some(identifier) = data.org_identifier - && identifier != crate::sso::FAKE_SSO_IDENTIFIER - && identifier != crate::api::admin::FAKE_ADMIN_UUID - { - let Some(org) = Organization::find_by_uuid(&identifier.into(), &conn).await else { - err!("Failed to retrieve the associated organization") - }; + if let Some(identifier) = data.org_identifier { + if identifier != crate::sso::FAKE_IDENTIFIER && identifier != crate::api::admin::FAKE_ADMIN_UUID { + let Some(org) = Organization::find_by_uuid(&identifier.into(), &conn).await else { + err!("Failed to retrieve the associated organization") + }; - let Some(membership) = Membership::find_by_user_and_org(&user.uuid, &org.uuid, &conn).await else { - err!("Failed to retrieve the invitation") - }; + let Some(membership) = Membership::find_by_user_and_org(&user.uuid, &org.uuid, &conn).await else { + err!("Failed to retrieve the invitation") + }; - accept_org_invite(&user, membership, None, &conn).await?; + accept_org_invite(&user, membership, None, &conn).await?; + } } if CONFIG.mail_enabled() { @@ -788,16 +393,14 @@ async fn post_set_password(data: Json, headers: Headers, conn: DbConn) -> Membership::accept_user_invitations(&user.uuid, &conn).await?; } - log_user_event(EventType::UserChangedPassword, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; + log_user_event(EventType::UserChangedPassword as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn) + .await; - // The key pair first, as in a rotation: if saving the user then fails, the password isn't set either - if let Some(account_keys) = account_keys { - account_keys.save_signature_key_pair(&user.uuid, &conn).await?; - } user.save(&conn).await?; Ok(Json(json!({ "object": "set-password", + "captchaBypassToken": "", }))) } @@ -848,10 +451,10 @@ async fn put_avatar(data: Json, headers: Headers, conn: DbConn) -> J // It looks like it only supports the 6 hex color format. // If you try to add the short value it will not show that color. // Check and force 7 chars, including the #. - if let Some(color) = &data.avatar_color - && color.len() != 7 - { - err!("The field AvatarColor must be a HTML/Hex color code with a length of 7 characters") + if let Some(color) = &data.avatar_color { + if color.len() != 7 { + err!("The field AvatarColor must be a HTML/Hex color code with a length of 7 characters") + } } let mut user = headers.user; @@ -876,91 +479,20 @@ async fn get_public_keys(user_id: UserId, _headers: Headers, conn: DbConn) -> Js }))) } -#[get("/users//keys")] -async fn get_account_public_keys(user_id: UserId, _headers: Headers, conn: DbConn) -> JsonResult { - let user = match User::find_by_uuid(&user_id, &conn).await { - Some(user) if user.public_key.is_some() => user, - Some(_) => err_code!("User has no public_key", Status::NotFound.code), - None => err_code!("User doesn't exist", Status::NotFound.code), - }; - - Ok(Json(user.public_keys_json(&conn).await)) -} - -#[get("/accounts/keys")] -async fn get_keys(headers: Headers, conn: DbConn) -> 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(&conn).await, - "object": "keys", - }))) -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct PostKeysData { - // Required like upstream, even next to `accountKeys` - public_key: Option, - encrypted_private_key: Option, - account_keys: Option, - // The id of the user key these account keys belong to, only honored for v2 `accountKeys` - user_key_id: Option, -} - #[post("/accounts/keys", data = "")] -async fn post_keys(data: Json, headers: Headers, conn: DbConn) -> JsonResult { - let data: PostKeysData = data.into_inner(); +async fn post_keys(data: Json, headers: Headers, conn: DbConn) -> JsonResult { + let data: KeysData = data.into_inner(); let mut user = headers.user; - // Only for an account without keys, replacing them is what a key rotation does - if user.private_key.is_some() || user.public_key.is_some() { - err!("User has existing keypair") - } + user.private_key = Some(data.encrypted_private_key); + user.public_key = Some(data.public_key); - // Ref: - let public_key = required(data.public_key, "PublicKey")?; - let encrypted_private_key = required(data.encrypted_private_key, "EncryptedPrivateKey")?; - - // `accountKeys` supersedes the flat keys when both are sent - let account_keys = match data.account_keys { - Some(account_keys) => { - let account_keys = account_keys.validate()?; - if !account_keys.is_v2() { - err!("AccountKeys are only supported for V2 encryption.") - } - // A client that predates key ids sends none, and reports it later through `user-key-id` - if data.user_key_id.is_some() { - user.key_id = data.user_key_id; - } - account_keys - } - None => KeysData { - encrypted_private_key, - public_key, - } - .into(), - }; - - account_keys.apply(&mut user)?; - // The key pair first, as in a rotation: if saving the user then fails, the account stays without keys - account_keys.save_signature_key_pair(&user.uuid, &conn).await?; user.save(&conn).await?; Ok(Json(json!({ - "key": (!user.akey.is_empty()).then_some(&user.akey), "privateKey": user.private_key, "publicKey": user.public_key, - "accountKeys": user.account_keys_json(&conn).await, "object":"keys" }))) } @@ -969,54 +501,29 @@ async fn post_keys(data: Json, headers: Headers, conn: DbConn) -> #[serde(rename_all = "camelCase")] struct ChangePassData { master_password_hash: String, + new_master_password_hash: String, master_password_hint: Option, - authentication_data: Option, - unlock_data: Option, - - // Outdated values, might still be used by older clients - new_master_password_hash: Option, - key: Option, + key: String, } #[post("/accounts/password", data = "")] async fn post_password(data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { let data: ChangePassData = data.into_inner(); - let user = headers.user; + let mut user = headers.user; if !user.check_valid_password(&data.master_password_hash) { err!("Invalid password") } - log_user_event(EventType::UserChangedPassword, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; + user.password_hint = clean_password_hint(&data.master_password_hint); + enforce_password_hint_setting(&user.password_hint)?; - let (new_master_password_hash, new_key) = - if let (Some(unlock_data), Some(authentication_data)) = (data.unlock_data, data.authentication_data) { - if authentication_data.kdf != unlock_data.kdf { - err!("KDF settings must be equal for authentication and unlock") - } - - let salt = user.master_password_salt(); - if salt != authentication_data.salt || salt != unlock_data.salt { - err!("Invalid master password salt") - } - - validate_key_id_unchanged(&user, &unlock_data)?; - - (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!") - }; - - let mut user = user; - - user.password_hint = clean_password_hint(data.master_password_hint.as_ref()); - enforce_password_hint_setting(user.password_hint.as_ref())?; + log_user_event(EventType::UserChangedPassword as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn) + .await; user.set_password( - &new_master_password_hash, - Some(new_key), + &data.new_master_password_hash, + Some(data.key), true, Some(vec![ String::from("post_rotatekey"), @@ -1075,64 +582,27 @@ fn set_kdf_data(user: &mut User, data: &KDFData) -> EmptyResult { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -pub(super) struct AuthenticationData { - #[serde(alias = "Salt")] +struct AuthenticationData { salt: String, - #[serde(alias = "Kdf")] - 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(()) - } + kdf: KDFData, + master_password_authentication_hash: String, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -pub(super) struct UnlockData { +struct UnlockData { salt: String, kdf: KDFData, - pub(super) master_key_wrapped_user_key: String, - contained_key_id: Option, -} - -/// A password or KDF change keeps the user key, so its key id must be the current one, when both are known. -/// -/// Ref: -fn validate_key_id_unchanged(user: &User, unlock_data: &UnlockData) -> EmptyResult { - if let (Some(current), Some(contained)) = (&user.key_id, &unlock_data.contained_key_id) - && current != contained - { - err!("Invalid user key sent in master-password unlock data.") - } - Ok(()) -} - -/// A rotation's unlock data has to wrap the new user key. Neither id is sent by clients that predate them. -/// -/// Ref: -fn validate_contained_key_id(contained_key_id: Option<&KeyId>, new_user_key_id: Option<&KeyId>) -> EmptyResult { - match (contained_key_id, new_user_key_id) { - (None, None) => Ok(()), - (Some(contained), Some(new)) if contained == new => Ok(()), - _ => err!("Invalid user key sent in master-password unlock data."), - } + master_key_wrapped_user_key: String, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct ChangeKdfData { + #[allow(dead_code)] + new_master_password_hash: String, + #[allow(dead_code)] + key: String, authentication_data: AuthenticationData, unlock_data: UnlockData, master_password_hash: String, @@ -1146,9 +616,13 @@ async fn post_kdf(data: Json, headers: Headers, conn: DbConn, nt: err!("Invalid password") } - data.authentication_data.check(&headers.user, &data.unlock_data)?; + if data.authentication_data.kdf != data.unlock_data.kdf { + err!("KDF settings must be equal for authentication and unlock") + } - validate_key_id_unchanged(&headers.user, &data.unlock_data)?; + 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; @@ -1184,72 +658,53 @@ struct UpdateFolderData { #[serde(rename_all = "camelCase")] struct UpdateEmergencyAccessData { id: EmergencyAccessId, - key_encrypted: Option, - // Only validated, upstream doesn't store it either - wait_time_days: Option, + key_encrypted: String, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct UpdateResetPasswordData { organization_id: OrganizationId, - // Absent in a v1 -> v2 upgrade, which keeps the key the organization already holds - reset_password_key: Option, + reset_password_key: String, } +use super::ciphers::CipherData; +use super::sends::{update_send_from_data, SendData}; + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct KeyData { account_unlock_data: RotateAccountUnlockData, - account_keys: AccountKeysData, + account_keys: RotateAccountKeys, account_data: RotateAccountData, old_master_key_authentication_hash: String, - old_user_key_id: Option, - new_user_key_id: Option, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct RotateAccountUnlockData { + emergency_access_unlock_data: Vec, master_password_unlock_data: MasterPasswordUnlockData, - #[serde(flatten)] - common: CommonUnlockData, + organization_account_recovery_unlock_data: Vec, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct MasterPasswordUnlockData { - #[serde(flatten)] - kdf: KDFData, + kdf_type: i32, + kdf_iterations: i32, + kdf_parallelism: Option, + kdf_memory: Option, email: String, master_key_authentication_hash: String, master_key_encrypted_user_key: String, - master_password_hint: Option, - master_password_salt: Option, - contained_key_id: Option, } -/// The unlock data of both rotation endpoints; passkey and device keys are ignored, as we support neither. -/// -/// Ref: #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct CommonUnlockData { - emergency_access_unlock_data: Vec, - organization_account_recovery_unlock_data: Vec, - #[expect(dead_code, reason = "Required like upstream, but unused")] - passkey_unlock_data: Vec, - #[expect(dead_code, reason = "Required like upstream, but unused")] - device_key_unlock_data: Vec, - v2_upgrade_token: Option, -} - -/// Lets other sessions with the v1 user key unwrap the v2 one after an upgrade. Opaque to us. -#[derive(Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -struct V2UpgradeTokenData { - wrapped_user_key1: String, - wrapped_user_key2: String, +struct RotateAccountKeys { + user_key_encrypted_account_private_key: String, + account_public_key: String, } #[derive(Deserialize)] @@ -1260,117 +715,27 @@ struct RotateAccountData { sends: Vec, } -/// Body of `rotate-user-keys`, which rotates the keys without touching the master password. -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct RotateUserKeysData { - wrapped_account_cryptographic_state: WrappedAccountCryptographicState, - unlock_data: CommonUnlockData, - account_data: RotateAccountData, - unlock_method_data: UnlockMethodData, - old_user_key_id: Option, - new_user_key_id: Option, -} - -/// The v2-only account cryptographic state, where all three parts are mandatory. -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct WrappedAccountCryptographicState { - public_key_encryption_key_pair: PublicKeyEncryptionKeyPairData, - signature_key_pair: SignatureKeyPairData, - security_state: SecurityStateData, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct UnlockMethodData { - unlock_method: i32, - master_password_unlock_data: Option, - key_connector_key_wrapped_user_key: Option, -} - -/// Ref: -#[derive(num_derive::FromPrimitive)] -enum UnlockMethod { - Tde = 0, - MasterPassword = 1, - KeyConnector = 2, -} - -/// The rotation checks on the new keys: the public key never changes, nor the verifying key of a v2 account. -/// -/// Ref: -async fn validate_rotation_account_keys(keys: &ValidatedAccountKeys, user: &User, conn: &DbConn) -> EmptyResult { - if user.public_key.as_ref() != Some(&keys.public_key) { - err!("Changing the asymmetric keypair is not possible during key rotation") - } - - let Some(v2) = &keys.v2 else { - if user.is_v2() { - err!("Cannot downgrade an account from v2 to v1 encryption during key rotation") - } - // A v1 rotation: the private key stays wrapped by an AES-CBC-HMAC user key - if enc_string_type(&keys.private_key) != Some(ENC_TYPE_AES_CBC_256_HMAC_SHA256) { - err!("The provided account private key was not wrapped with AES-256-CBC-HMAC") - } - return Ok(()); - }; - - // Both a v2 rotation and the v1 -> v2 upgrade end up with a COSE user key wrapping the private keys - if enc_string_type(&v2.signing_key) != Some(ENC_TYPE_COSE_ENCRYPT0) { - err!("The provided signing key data is not wrapped with XChaCha20-Poly1305.") - } - if enc_string_type(&keys.private_key) != Some(ENC_TYPE_COSE_ENCRYPT0) { - err!("The provided private key encryption key is not wrapped with XChaCha20-Poly1305.") - } - if v2.verifying_key.is_empty() || v2.signed_public_key.is_empty() || v2.security_state.is_empty() { - err!("The v2 account keys are missing the verifying key, signed public key or security state") - } - - if !user.is_v2() { - // The v1 -> v2 upgrade, which is where the signature key pair comes from - return Ok(()); - } - - let Some(key_pair) = UserSignatureKeyPair::find_by_user(&user.uuid, conn).await else { - err!("The account is missing its signature key pair") - }; - if key_pair.verifying_key != v2.verifying_key { - err!("Changing the verifying key is not possible during key rotation") - } - - Ok(()) -} - -/// `AesCbc256_HmacSha256_B64`, the v1 user key -const ENC_TYPE_AES_CBC_256_HMAC_SHA256: &str = "2"; -/// `CoseEncrypt0B64`, the v2 user key -const ENC_TYPE_COSE_ENCRYPT0: &str = "7"; - -/// The encryption type of an EncString, the number before the first `.`. -fn enc_string_type(enc_string: &str) -> Option<&str> { - enc_string.split_once('.').map(|(enc_type, _)| enc_type) -} - -impl KDFData { - /// Whether these are the settings the user already has, which a key rotation can't change. - fn is_unchanged_for(&self, user: &User) -> bool { - user.client_kdf_type == self.kdf - && user.client_kdf_iter == self.kdf_iterations - && user.client_kdf_memory == self.kdf_memory - && user.client_kdf_parallelism == self.kdf_parallelism - } -} - -/// Every existing item must be in the rotation, or it would be unreadable afterwards. -fn validate_rotation_data( - data: &RotateData, +fn validate_keydata( + data: &KeyData, existing_ciphers: &[Cipher], existing_folders: &[Folder], existing_emergency_access: &[EmergencyAccess], existing_memberships: &[Membership], existing_sends: &[Send], + user: &User, ) -> EmptyResult { + if user.client_kdf_type != data.account_unlock_data.master_password_unlock_data.kdf_type + || user.client_kdf_iter != data.account_unlock_data.master_password_unlock_data.kdf_iterations + || user.client_kdf_memory != data.account_unlock_data.master_password_unlock_data.kdf_memory + || user.client_kdf_parallelism != data.account_unlock_data.master_password_unlock_data.kdf_parallelism + || user.email != data.account_unlock_data.master_password_unlock_data.email + { + err!("Changing the kdf variant or email is not supported during key rotation"); + } + if user.public_key.as_ref() != Some(&data.account_keys.account_public_key) { + err!("Changing the asymmetric keypair is not possible during key rotation") + } + // Check that we're correctly rotating all the user's ciphers let existing_cipher_ids = existing_ciphers.iter().map(|c| &c.uuid).collect::>(); let provided_cipher_ids = data @@ -1395,8 +760,12 @@ fn validate_rotation_data( // Check that we're correctly rotating all the user's emergency access keys let existing_emergency_access_ids = existing_emergency_access.iter().map(|ea| &ea.uuid).collect::>(); - let provided_emergency_access_ids = - data.unlock_data.emergency_access_unlock_data.iter().map(|ea| &ea.id).collect::>(); + let provided_emergency_access_ids = data + .account_unlock_data + .emergency_access_unlock_data + .iter() + .map(|ea| &ea.id) + .collect::>(); if !provided_emergency_access_ids.is_superset(&existing_emergency_access_ids) { err!("All existing emergency access keys must be included in the rotation") } @@ -1405,7 +774,7 @@ fn validate_rotation_data( let existing_reset_password_ids = existing_memberships.iter().map(|m| &m.org_uuid).collect::>(); let provided_reset_password_ids = data - .unlock_data + .account_unlock_data .organization_account_recovery_unlock_data .iter() .map(|rp| &rp.organization_id) @@ -1424,459 +793,128 @@ fn validate_rotation_data( Ok(()) } -/// The keys a rotation re-shares the new user key with. The SDK needs all four lists, even when empty. -/// -/// Ref: -#[get("/accounts/key-management/key-rotation-data")] -async fn get_key_rotation_data(headers: Headers, conn: DbConn) -> JsonResult { - let user_id = &headers.user.uuid; - - let mut organization_data = Vec::new(); - for membership in Membership::find_by_user(user_id, &conn).await { - // Only memberships actually enrolled in account recovery take part in the rotation. - if membership.reset_password_key.is_none() { - continue; - } - let Some(org) = Organization::find_by_uuid(&membership.org_uuid, &conn).await else { - continue; - }; - let Some(public_key) = org.public_key else { - continue; - }; - organization_data.push(json!({ - "organizationId": org.uuid, - "organizationName": org.name, - "organizationPublicKey": public_key, - "object": "organizationPasswordResetKeyData", - })); - } - - let mut emergency_access_data = Vec::new(); - for emergency_access in EmergencyAccess::find_all_confirmed_by_grantor_uuid(user_id, &conn).await { - // Without a stored key there is nothing to re-share. - if emergency_access.key_encrypted.is_none() { - continue; - } - let Some(grantee_id) = emergency_access.grantee_uuid.clone() else { - continue; - }; - let Some(grantee) = User::find_by_uuid(&grantee_id, &conn).await else { - continue; - }; - let Some(public_key) = grantee.public_key.clone() else { - continue; - }; - emergency_access_data.push(json!({ - "id": emergency_access.uuid, - "granteeId": grantee_id, - "granteeName": grantee.name, - "granteeEmail": grantee.email, - "publicKey": public_key, - "object": "emergencyAccessKeyData", - })); - } - - Ok(Json(json!({ - "organizationPasswordResetKeyData": organization_data, - "emergencyAccessKeyData": emergency_access_data, - "trustedDeviceKeyData": [], - "passkeyKeyData": [], - "object": "keyRotationData", - }))) -} - -/// Everything a rotation replaces, once each endpoint's wrapper has been peeled off. -struct RotateData { - account_keys: ValidatedAccountKeys, - account_data: RotateAccountData, - unlock_data: CommonUnlockData, - /// The id of the key the client rotates from, `None` from clients that don't send it. - old_user_key_id: Option, - /// `None` from clients that predate key ids. - new_user_key_id: Option, - /// The new user key, wrapped by the master key. - wrapped_user_key: String, - /// Only for `rotate-user-account-keys`: the new password hash and hint. - new_password: Option<(String, Option)>, -} - -/// Upstream's `[StringLength(max)]`, which counts UTF-16 code units. -fn validate_max_length(value: &str, max: usize, field: &str) -> EmptyResult { - if value.encode_utf16().count() > max { - err!(format!("The field {field} must be a string with a maximum length of {max}.")) - } - Ok(()) -} - -impl KDFData { - /// Upstream's check that the parameters fit the KDF type. - fn validate_parameters(&self) -> EmptyResult { - let has_argon2_parameters = (self.kdf_memory.is_some(), self.kdf_parallelism.is_some()); - if self.kdf == UserKdfType::Pbkdf2 as i32 { - if has_argon2_parameters != (false, false) { - err!("KdfMemory and KdfParallelism must be null for PBKDF2_SHA256") - } - } else if self.kdf == UserKdfType::Argon2id as i32 { - if has_argon2_parameters != (true, true) { - err!("KdfMemory and KdfParallelism must have values for Argon2id") - } - } else { - err!("Invalid KdfType") - } - Ok(()) - } -} - -impl MasterPasswordUnlockData { - /// Upstream's model validation. - /// - /// Ref: - fn validate(&self) -> EmptyResult { - self.kdf.validate_parameters()?; - if !crate::util::is_valid_email(&self.email) { - err!("The Email field is not a supported e-mail address format.") - } - validate_max_length(&self.email, 256, "Email")?; - required(Some(self.master_key_authentication_hash.clone()), "MasterKeyAuthenticationHash")?; - validate_max_length(&self.master_key_authentication_hash, 300, "MasterKeyAuthenticationHash")?; - required(Some(self.master_key_encrypted_user_key.clone()), "MasterKeyEncryptedUserKey")?; - if let Some(hint) = &self.master_password_hint { - validate_max_length(hint, 50, "MasterPasswordHint")?; - } - if self.master_password_salt.as_ref().is_some_and(|salt| salt.encode_utf16().count() > 256) { - err!("The field MasterPasswordSalt must be a string or array type with a maximum length of '256'.") - } - Ok(()) - } -} - -impl CommonUnlockData { - /// Upstream's model validation. - fn validate(&self) -> EmptyResult { - // Ref: - for emergency_access in &self.emergency_access_unlock_data { - if !emergency_access.wait_time_days.is_some_and(|days| (1..=i32::from(i16::MAX)).contains(&days)) { - err!("The field WaitTimeDays must be between 1 and 32767.") - } - } - if let Some(token) = &self.v2_upgrade_token { - required(Some(token.wrapped_user_key1.clone()), "WrappedUserKey1")?; - required(Some(token.wrapped_user_key2.clone()), "WrappedUserKey2")?; - } - Ok(()) - } -} - -/// The model validation of the parts both rotation endpoints share, which upstream runs before anything else. -fn validate_rotation_request(account_data: &RotateAccountData, unlock_data: &CommonUnlockData) -> EmptyResult { - unlock_data.validate()?; - - // Validate the import before continuing - // Bitwarden does not process the import if there is one item invalid. - // Since we check for the size of the encrypted note length, we need to do that here to pre-validate it. - // TODO: See if we can optimize the whole cipher adding/importing and prevent duplicate code and checks. - Cipher::validate_cipher_data(&account_data.ciphers)?; - - if account_data.ciphers.iter().any(|cipher| cipher.id.is_none()) { - err!("The Id field is required.") - } - if account_data.sends.iter().any(|send| send.id.is_none()) { - err!("The Id field is required.") - } - Ok(()) -} - #[post("/accounts/key-management/rotate-user-account-keys", data = "")] async fn post_rotatekey(data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { + // TODO: See if we can wrap everything within a SQL Transaction. If something fails it should revert everything. let data: KeyData = data.into_inner(); - let unlock_data = data.account_unlock_data.master_password_unlock_data; - validate_max_length(&data.old_master_key_authentication_hash, 300, "OldMasterKeyAuthenticationHash")?; - unlock_data.validate()?; - validate_rotation_request(&data.account_data, &data.account_unlock_data.common)?; - if !headers.user.check_valid_password(&data.old_master_key_authentication_hash) { err!("Invalid password") } - // Ref: - let salt = unlock_data.master_password_salt.as_ref().unwrap_or(&unlock_data.email); - if !unlock_data.kdf.is_unchanged_for(&headers.user) || *salt != headers.user.master_password_salt() { - err!("The provided master password unlock data is not valid for this user.") - } - validate_contained_key_id(unlock_data.contained_key_id.as_ref(), data.new_user_key_id.as_ref())?; - let password_hint = clean_password_hint(unlock_data.master_password_hint.as_ref()); - enforce_password_hint_setting(password_hint.as_ref())?; + // Validate the import before continuing + // Bitwarden does not process the import if there is one item invalid. + // Since we check for the size of the encrypted note length, we need to do that here to pre-validate it. + // 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)?; - let mut common = data.account_unlock_data.common; - // Like upstream, this endpoint logs every session out, so an upgrade token is never kept - // Ref: - common.v2_upgrade_token = None; - - rotate_account( - RotateData { - account_keys: data.account_keys.validate()?, - account_data: data.account_data, - unlock_data: common, - old_user_key_id: data.old_user_key_id, - new_user_key_id: data.new_user_key_id, - wrapped_user_key: unlock_data.master_key_encrypted_user_key, - new_password: Some((unlock_data.master_key_authentication_hash, password_hint)), - }, - headers, - conn, - nt, - ) - .await -} - -/// Both rotation endpoints: every check first, then the re-encrypted data and the new keys are saved. -async fn rotate_account(data: RotateData, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { let user_id = &headers.user.uuid; + + // TODO: Ideally we'd do everything after this point in a single transaction. + let mut existing_ciphers = Cipher::find_owned_by_user(user_id, &conn).await; let mut existing_folders = Folder::find_by_user(user_id, &conn).await; let mut existing_emergency_access = EmergencyAccess::find_all_confirmed_by_grantor_uuid(user_id, &conn).await; - // Like upstream, only the ones with a key take part - existing_emergency_access.retain(|ea| ea.key_encrypted.is_some()); let mut existing_memberships = Membership::find_by_user(user_id, &conn).await; // We only rotate the reset password key if it is set. existing_memberships.retain(|m| m.reset_password_key.is_some()); let mut existing_sends = Send::find_by_user(user_id, &conn).await; - validate_rotation_data( + validate_keydata( &data, &existing_ciphers, &existing_folders, &existing_emergency_access, &existing_memberships, &existing_sends, + &headers.user, )?; - // Like upstream, a client that rotates a user key that was already replaced has to sync first - // Ref: - if let (Some(current), Some(old)) = (&headers.user.key_id, &data.old_user_key_id) - && current != old - { - err_code!("The user key being rotated is not the current user key. Sync and try again.", Status::Conflict.code) - } - - validate_rotation_account_keys(&data.account_keys, &headers.user, &conn).await?; - - // An upgrade keeps the sessions; for an account already on v2 the token means nothing and is dropped - let is_upgrade = data.unlock_data.v2_upgrade_token.is_some() && !headers.user.is_v2(); - let upgrade_token = match &data.unlock_data.v2_upgrade_token { - Some(token) if is_upgrade => Some(serde_json::to_string(token)?), - _ => None, - }; - - // An upgrade can't re-wrap the recovery keys without a trust prompt, so they're kept and get the token - // Ref: - for membership in &existing_memberships { - let has_key = data - .unlock_data - .organization_account_recovery_unlock_data - .iter() - .find(|rp| rp.organization_id == membership.org_uuid) - .and_then(|rp| rp.reset_password_key.as_deref()) - .is_some_and(|key| !key.trim().is_empty()); - if is_upgrade && has_key { - err!("Account recovery keys cannot be rotated during a V1 to V2 upgrade rotation.") - } - if !is_upgrade && !has_key { - err!("Account recovery keys cannot be null or empty during rotation.") - } - } - - // Ref: - for emergency_access in &existing_emergency_access { - if data - .unlock_data - .emergency_access_unlock_data - .iter() - .find(|ea| ea.id == emergency_access.uuid) - .is_some_and(|ea| ea.key_encrypted.is_none()) - { - err!("Emergency access keys cannot be set to null during rotation.") - } - } - - // Saving's own checks, so that they can't fail halfway - for cipher_data in data.account_data.ciphers.iter().filter(|c| c.organization_id.is_none()) { - if let Some(cipher) = existing_ciphers.iter().find(|c| cipher_data.id.as_ref() == Some(&c.uuid)) { - validate_rotated_cipher(cipher, cipher_data, &headers, &conn).await?; - } - } - - // TODO: Ideally we'd do everything after this point in a single transaction. - - // Update folder data, ignoring ids that aren't the user's, like upstream + // Update folder data for folder_data in data.account_data.folders { // Skip `null` folder id entries. // See: https://github.com/bitwarden/clients/issues/8453 - if let Some(saved_folder) = folder_data.id.and_then(|id| existing_folders.iter_mut().find(|f| f.uuid == id)) { + if let Some(folder_id) = folder_data.id { + let Some(saved_folder) = existing_folders.iter_mut().find(|f| f.uuid == folder_id) else { + err!("Folder doesn't exist") + }; + saved_folder.name = folder_data.name; - saved_folder.save(&conn).await?; + saved_folder.save(&conn).await? } } - // Like upstream, the unlock data below comes from the first entry for each, as checked above - for saved_emergency_access in &mut existing_emergency_access { - if let Some(emergency_access_data) = - data.unlock_data.emergency_access_unlock_data.iter().find(|ea| ea.id == saved_emergency_access.uuid) - { - saved_emergency_access.key_encrypted.clone_from(&emergency_access_data.key_encrypted); - saved_emergency_access.save(&conn).await?; - } + // Update emergency access data + for emergency_access_data in data.account_unlock_data.emergency_access_unlock_data { + let Some(saved_emergency_access) = + existing_emergency_access.iter_mut().find(|ea| ea.uuid == emergency_access_data.id) + else { + err!("Emergency access doesn't exist or is not owned by the user") + }; + + saved_emergency_access.key_encrypted = Some(emergency_access_data.key_encrypted); + saved_emergency_access.save(&conn).await? } - for membership in &mut existing_memberships { - if let Some(reset_password_data) = data - .unlock_data - .organization_account_recovery_unlock_data - .iter() - .find(|rp| rp.organization_id == membership.org_uuid) - { - if !is_upgrade { - membership.reset_password_key.clone_from(&reset_password_data.reset_password_key); - } - membership.v2_upgrade_token.clone_from(&upgrade_token); - membership.save(&conn).await?; - } + // Update reset password data + for reset_password_data in data.account_unlock_data.organization_account_recovery_unlock_data { + let Some(membership) = + existing_memberships.iter_mut().find(|m| m.org_uuid == reset_password_data.organization_id) + else { + err!("Reset password doesn't exist") + }; + + membership.reset_password_key = Some(reset_password_data.reset_password_key); + membership.save(&conn).await? } // Update send data for send_data in data.account_data.sends { - if let Some(send) = send_data.id.as_ref().and_then(|id| existing_sends.iter_mut().find(|s| &s.uuid == id)) { - // Like upstream, only the key changes on rotation - send.akey = send_data.key; - send.save(&conn).await?; - } + let Some(send) = existing_sends.iter_mut().find(|s| &s.uuid == send_data.id.as_ref().unwrap()) else { + err!("Send doesn't exist") + }; + + update_send_from_data(send, send_data, &headers, &conn, &nt, UpdateType::None).await?; } // Update cipher data + use super::ciphers::update_cipher_from_data; + for cipher_data in data.account_data.ciphers { - if cipher_data.organization_id.is_none() - && let Some(saved_cipher) = - cipher_data.id.as_ref().and_then(|id| existing_ciphers.iter_mut().find(|c| &c.uuid == id)) - { - // No cipher pushes: the other sessions still hold the old user key - rotate_cipher_data(saved_cipher, cipher_data, &headers, &conn, &nt).await?; + if cipher_data.organization_id.is_none() { + let Some(saved_cipher) = existing_ciphers.iter_mut().find(|c| &c.uuid == cipher_data.id.as_ref().unwrap()) + else { + err!("Cipher doesn't exist") + }; + + // Prevent triggering cipher updates via WebSockets by settings UpdateType::None + // The user sessions are invalidated because all the ciphers were re-encrypted and thus triggering an update could cause issues. + // We force the users to logout after the user has been saved to try and prevent these issues. + update_cipher_from_data(saved_cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None).await? } } // Update user data let mut user = headers.user; - user.v2_upgrade_token = upgrade_token; - data.account_keys.apply(&mut user)?; - // The old id names a key that no longer exists, so it is replaced even when the new key has none. - user.key_id = data.new_user_key_id; + user.private_key = Some(data.account_keys.user_key_encrypted_account_private_key); + user.set_password( + &data.account_unlock_data.master_password_unlock_data.master_key_authentication_hash, + Some(data.account_unlock_data.master_password_unlock_data.master_key_encrypted_user_key), + true, + None, + &conn, + ) + .await?; - user.akey = data.wrapped_user_key; - if let Some((new_password_hash, new_password_hint)) = data.new_password { - // A password change ends every session, as `/accounts/password` does - user.password_hint = new_password_hint; - user.set_password(&new_password_hash, None, true, None, &conn).await?; - } else if !is_upgrade { - // An upgrade keeps the sessions alive, see `is_upgrade` above - user.reset_security_stamp_after_key_rotation(&conn).await?; - } - - // The key pair first, so that a v2 account is never saved without one. The data above is already re-encrypted by - // now, and for an account that was already v2 so is the signing key saved here, so a failure in either save - // leaves them under a key that isn't stored (see the TODO before the first write). - data.account_keys.save_signature_key_pair(&user.uuid, &conn).await?; - user.save(&conn).await?; + let save_result = user.save(&conn).await; // Prevent logging out the client where the user requested this endpoint from. // If you do logout the user it will causes issues at the client side. // Adding the device uuid will prevent this. - // On an upgrade, the reason lets clients sync instead - let reason = is_upgrade.then_some(LogOutReason::KeyRotation); - nt.send_logout_with_reason(&user, Some(&headers.device), reason, &conn).await; + nt.send_logout(&user, Some(&headers.device), &conn).await; - Ok(()) -} - -/// Rotates the keys without changing the master password; like upstream, the session is the only proof. -/// -/// Ref: -#[post("/accounts/key-management/rotate-user-keys", data = "")] -async fn post_rotate_user_keys( - data: Json, - headers: Headers, - conn: DbConn, - nt: Notify<'_>, -) -> EmptyResult { - let data: RotateUserKeysData = data.into_inner(); - validate_rotation_request(&data.account_data, &data.unlock_data)?; - - // Ref: - let wrapped_user_key = match UnlockMethod::from_i32(data.unlock_method_data.unlock_method) { - Some(UnlockMethod::MasterPassword) => { - let (Some(unlock_data), None) = ( - data.unlock_method_data.master_password_unlock_data, - data.unlock_method_data.key_connector_key_wrapped_user_key, - ) else { - err!( - "Invalid MasterPassword unlock method request, MasterPasswordUnlockData must be provided and KeyConnectorKeyWrappedUserKey must be null" - ) - }; - required(Some(unlock_data.master_key_wrapped_user_key.clone()), "MasterKeyWrappedUserKey")?; - required(Some(unlock_data.salt.clone()), "Salt")?; - validate_max_length(&unlock_data.salt, 256, "Salt")?; - - // Ref: - if headers.user.password_hash.is_empty() || headers.user.akey.is_empty() { - err!("User is in an invalid state for master password key rotation.") - } - if unlock_data.salt != headers.user.master_password_salt() { - err!("Invalid master password salt.") - } - if !unlock_data.kdf.is_unchanged_for(&headers.user) { - err!("Invalid KDF settings.") - } - validate_contained_key_id(unlock_data.contained_key_id.as_ref(), data.new_user_key_id.as_ref())?; - unlock_data.master_key_wrapped_user_key - } - Some(UnlockMethod::Tde) => err!("Trusted device encryption is not supported"), - Some(UnlockMethod::KeyConnector) => err!("Key connector is not supported"), - None => err!("Unrecognized unlock method"), - }; - - rotate_account( - RotateData { - account_keys: data.wrapped_account_cryptographic_state.validate()?, - account_data: data.account_data, - unlock_data: data.unlock_data, - old_user_key_id: data.old_user_key_id, - new_user_key_id: data.new_user_key_id, - wrapped_user_key, - new_password: None, - }, - headers, - conn, - nt, - ) - .await -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct KeyIdData { - user_key_id: KeyId, -} - -#[post("/accounts/key-management/user-key-id", data = "")] -async fn post_user_key(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { - let mut user = headers.user; - // Only a backfill for accounts that have none. Afterwards the id changes with the key, in a rotation. - if user.key_id.is_some() { - err!("User key id is already set.") - } - - user.key_id = Some(data.into_inner().user_key_id); - user.save(&conn).await + save_result } #[post("/accounts/security-stamp", data = "")] @@ -1946,11 +984,7 @@ async fn post_email_token(data: Json, headers: Headers, conn: Db error!("Error sending change-email email: {e:#?}"); } } else { - debug!( - "Email change request for user ({}) to email ({}) with token ({token})", - user.uuid, - data.new_email.escape_debug() - ); + debug!("Email change request for user ({}) to email ({}) with token ({token})", user.uuid, data.new_email); } user.email_new = Some(data.new_email); @@ -1986,22 +1020,24 @@ async fn post_email(data: Json, headers: Headers, conn: DbConn, err!("Email already in use"); } - if let Some(ref val) = user.email_new { - if val != &data.new_email { - err!("Email change mismatch"); + match user.email_new { + Some(ref val) => { + if val != &data.new_email { + err!("Email change mismatch"); + } } - } else { - err!("No email change pending") + None => err!("No email change pending"), } if CONFIG.mail_enabled() { // Only check the token if we sent out an email... - if let Some(ref val) = user.email_new_token { - if *val != data.token.into_string() { - err!("Token mismatch"); + match user.email_new_token { + Some(ref val) => { + if *val != data.token.into_string() { + err!("Token mismatch"); + } } - } else { - err!("No email change pending") + None => err!("No email change pending"), } user.verified_at = Some(Utc::now().naive_utc()); } else { @@ -2074,16 +1110,14 @@ struct DeleteRecoverData { } #[post("/accounts/delete-recover", data = "")] -async fn post_delete_recover(data: Json, ip: ClientIp, conn: DbConn) -> EmptyResult { - crate::ratelimit::check_limit_unauthenticated(&ip.ip)?; - +async fn post_delete_recover(data: Json, conn: DbConn) -> EmptyResult { let data: DeleteRecoverData = data.into_inner(); if CONFIG.mail_enabled() { - if let Some(user) = User::find_by_mail(&data.email, &conn).await - && let Err(e) = mail::send_delete_account(&user.email, &user.uuid).await - { - error!("Error sending delete account email: {e:#?}"); + if let Some(user) = User::find_by_mail(&data.email, &conn).await { + if let Err(e) = mail::send_delete_account(&user.email, &user.uuid).await { + error!("Error sending delete account email: {e:#?}"); + } } Ok(()) } else { @@ -2103,7 +1137,7 @@ struct DeleteRecoverTokenData { } #[post("/accounts/delete-recover-token", data = "")] -async fn post_delete_recover_token(data: Json, conn: DbConn, nt: Notify<'_>) -> EmptyResult { +async fn post_delete_recover_token(data: Json, conn: DbConn) -> EmptyResult { let data: DeleteRecoverTokenData = data.into_inner(); let Ok(claims) = decode_delete(&data.token) else { @@ -2117,47 +1151,24 @@ async fn post_delete_recover_token(data: Json, conn: DbC if claims.sub != *user.uuid { err!("Invalid claim"); } - - // The devices are deleted together with the user, so check for push devices beforehand - let has_push_device = CONFIG.push_enabled() && Device::check_user_has_push_device(&user.uuid, &conn).await; - user.delete(&conn).await?; - - nt.send_logout(&user, None, &conn).await; - if has_push_device { - push_logout_to_relay(&user, None, None); - } - Ok(()) + user.delete(&conn).await } #[post("/accounts/delete", data = "")] -async fn post_delete_account( - data: Json, - headers: Headers, - conn: DbConn, - nt: Notify<'_>, -) -> EmptyResult { - delete_account(data, headers, conn, nt).await +async fn post_delete_account(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { + delete_account(data, headers, conn).await } #[delete("/accounts", data = "")] -async fn delete_account(data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { +async fn delete_account(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { let data: PasswordOrOtpData = data.into_inner(); let user = headers.user; data.validate(&user, true, &conn).await?; - // The devices are deleted together with the user, so check for push devices beforehand - let has_push_device = CONFIG.push_enabled() && Device::check_user_has_push_device(&user.uuid, &conn).await; - user.delete(&conn).await?; - - nt.send_logout(&user, None, &conn).await; - if has_push_device { - push_logout_to_relay(&user, None, None); - } - Ok(()) + user.delete(&conn).await } -#[expect(clippy::needless_pass_by_value, reason = "Not beneficial for Headers")] #[get("/accounts/revision-date")] fn revision_date(headers: Headers) -> JsonResult { let revision_date = headers.user.updated_at.and_utc().timestamp_millis(); @@ -2171,15 +1182,13 @@ struct PasswordHintData { } #[post("/accounts/password-hint", data = "")] -async fn password_hint(data: Json, ip: ClientIp, conn: DbConn) -> EmptyResult { - const NO_HINT: &str = "Sorry, you have no password hint..."; - - crate::ratelimit::check_limit_unauthenticated(&ip.ip)?; - +async fn password_hint(data: Json, conn: DbConn) -> EmptyResult { if !CONFIG.password_hints_allowed() || (!CONFIG.mail_enabled() && !CONFIG.show_password_hint()) { err!("This server is not configured to provide password hints."); } + const NO_HINT: &str = "Sorry, you have no password hint..."; + let data: PasswordHintData = data.into_inner(); let email = &data.email; @@ -2190,9 +1199,9 @@ async fn password_hint(data: Json, ip: ClientIp, conn: DbConn) // There is still a timing side channel here in that the code // paths that send mail take noticeably longer than ones that // don't. Add a randomized sleep to mitigate this somewhat. - use rand::{RngExt, rngs::SmallRng}; + use rand::{rngs::SmallRng, RngExt}; let mut rng: SmallRng = rand::make_rng(); - let sleep_ms: u64 = rng.random_range(900..=1100); + let sleep_ms = rng.random_range(900..=1100) as u64; tokio::time::sleep(tokio::time::Duration::from_millis(sleep_ms)).await; Ok(()) } else { @@ -2205,7 +1214,7 @@ async fn password_hint(data: Json, ip: ClientIp, conn: DbConn) mail::send_password_hint(email, hint).await?; Ok(()) } else if let Some(hint) = hint { - err_silent!(format!("Your password hint is: {hint}")); + err!(format!("Your password hint is: {hint}")); } else { err!(NO_HINT); } @@ -2219,48 +1228,25 @@ pub struct PreloginData { email: String, } -/// Like upstream, an unknown email gets KDF settings picked from a list of common ones by a keyed hash of the email, -/// so they stay the same between requests. The salt stays null, as for existing users. -fn unknown_email_kdf(email: &str) -> (i32, i32, Option, Option) { - 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, Option); 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())] +#[post("/accounts/prelogin", data = "")] +async fn prelogin(data: Json, conn: DbConn) -> Json { + _prelogin(data, conn).await } -pub async fn prelogin(data: Json, ip: ClientIp, conn: DbConn) -> JsonResult { - crate::ratelimit::check_limit_unauthenticated(&ip.ip)?; - +pub async fn _prelogin(data: Json, conn: DbConn) -> Json { 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(&email, &conn).await { + let (kdf_type, kdf_iter, kdf_mem, kdf_para) = match User::find_by_mail(&data.email, &conn).await { Some(user) => (user.client_kdf_type, user.client_kdf_iter, user.client_kdf_memory, user.client_kdf_parallelism), - None => unknown_email_kdf(&email), + None => (User::CLIENT_KDF_TYPE_DEFAULT, User::CLIENT_KDF_ITER_DEFAULT, None, None), }; - Ok(Json(json!({ + Json(json!({ "kdf": kdf_type, "kdfIterations": kdf_iter, "kdfMemory": kdf_mem, "kdfParallelism": kdf_para, - "kdfSettings": { - "iterations": kdf_iter, - "kdfType": kdf_type, - "memory": kdf_mem, - "parallelism": kdf_para - }, - "salt": null, - }))) + })) } // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Auth/Models/Request/Accounts/SecretVerificationRequestModel.cs @@ -2297,7 +1283,9 @@ async fn verify_password(data: Json, headers: Headers Ok(Json(master_password_policy(&user, &conn).await)) } -async fn update_api_key(data: Json, rotate: bool, headers: Headers, conn: DbConn) -> JsonResult { +async fn _api_key(data: Json, rotate: bool, headers: Headers, conn: DbConn) -> JsonResult { + use crate::util::format_date; + let data: PasswordOrOtpData = data.into_inner(); let mut user = headers.user; @@ -2316,13 +1304,13 @@ async fn update_api_key(data: Json, rotate: bool, headers: He } #[post("/accounts/api-key", data = "")] -async fn post_api_key(data: Json, headers: Headers, conn: DbConn) -> JsonResult { - update_api_key(data, false, headers, conn).await +async fn api_key(data: Json, headers: Headers, conn: DbConn) -> JsonResult { + _api_key(data, false, headers, conn).await } #[post("/accounts/rotate-api-key", data = "")] async fn rotate_api_key(data: Json, headers: Headers, conn: DbConn) -> JsonResult { - update_api_key(data, true, headers, conn).await + _api_key(data, true, headers, conn).await } #[get("/devices/knowndevice")] @@ -2365,7 +1353,7 @@ impl<'r> FromRequest<'r> for KnownDevice { }; let uuid = if let Some(uuid) = req.headers().get_one("X-Device-Identifier") { - uuid.to_owned().into() + uuid.to_string().into() } else { return Outcome::Error((Status::BadRequest, "X-Device-Identifier value is required")); }; @@ -2380,7 +1368,7 @@ impl<'r> FromRequest<'r> for KnownDevice { #[get("/devices")] async fn get_all_devices(headers: Headers, conn: DbConn) -> JsonResult { let devices = Device::find_with_auth_request_by_user(&headers.user.uuid, &conn).await; - let devices = devices.iter().map(DeviceWithAuthRequest::to_json).collect::>(); + let devices = devices.iter().map(|device| device.to_json()).collect::>(); Ok(Json(json!({ "data": devices, @@ -2436,9 +1424,7 @@ async fn put_device_token(device_id: DeviceId, data: Json, headers: H } #[put("/devices/identifier//clear-token")] -async fn put_clear_device_token(device_id: DeviceId, ip: ClientIp, conn: DbConn) -> EmptyResult { - crate::ratelimit::check_limit_unauthenticated(&ip.ip)?; - +async fn put_clear_device_token(device_id: DeviceId, conn: DbConn) -> EmptyResult { // This only clears push token // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Controllers/DevicesController.cs#L215 // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/Services/Implementations/DeviceService.cs#L37 @@ -2452,7 +1438,7 @@ async fn put_clear_device_token(device_id: DeviceId, ip: ClientIp, conn: DbConn) if let Some(device) = Device::find_by_uuid(&device_id, &conn).await { Device::clear_push_token_by_uuid(&device_id, &conn).await?; - unregister_push_device(device.push_uuid.as_ref()).await?; + unregister_push_device(&device.push_uuid).await?; } Ok(()) @@ -2460,8 +1446,8 @@ async fn put_clear_device_token(device_id: DeviceId, ip: ClientIp, conn: DbConn) // On upstream server, both PUT and POST are declared. Implementing the POST method in case it would be useful somewhere #[post("/devices/identifier//clear-token")] -async fn post_clear_device_token(device_id: DeviceId, ip: ClientIp, conn: DbConn) -> EmptyResult { - put_clear_device_token(device_id, ip, conn).await +async fn post_clear_device_token(device_id: DeviceId, conn: DbConn) -> EmptyResult { + put_clear_device_token(device_id, conn).await } #[get("/tasks")] @@ -2491,8 +1477,6 @@ async fn post_auth_request( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - crate::ratelimit::check_limit_unauthenticated(&client_headers.ip.ip)?; - let data = data.into_inner(); let Some(user) = User::find_by_mail(&data.email, &conn).await else { @@ -2505,7 +1489,7 @@ async fn post_auth_request( _ => err!("AuthRequest doesn't exist", "Device verification failed"), }; - let auth_request = AuthRequest::new( + let mut auth_request = AuthRequest::new( user.uuid.clone(), data.device_identifier.clone(), client_headers.device_type, @@ -2518,7 +1502,7 @@ async fn post_auth_request( nt.send_auth_request(&user.uuid, &auth_request.uuid, &device, &conn).await; log_user_event( - EventType::UserRequestedDeviceApproval, + EventType::UserRequestedDeviceApproval as i32, &user.uuid, client_headers.device_type, &client_headers.ip.ip, @@ -2612,7 +1596,7 @@ async fn put_auth_request( nt.send_auth_response(&auth_request.user_uuid, &auth_request.uuid, &headers.device, &conn).await; log_user_event( - EventType::OrganizationUserApprovedAuthRequest, + EventType::OrganizationUserApprovedAuthRequest as i32, &headers.user.uuid, headers.device.atype, &headers.ip.ip, @@ -2623,7 +1607,7 @@ async fn put_auth_request( // If denied, there's no reason to keep the request auth_request.delete(&conn).await?; log_user_event( - EventType::OrganizationUserRejectedAuthRequest, + EventType::OrganizationUserRejectedAuthRequest as i32, &headers.user.uuid, headers.device.atype, &headers.ip.ip, @@ -2654,8 +1638,6 @@ async fn get_auth_request_response( client_headers: ClientHeaders, conn: DbConn, ) -> JsonResult { - crate::ratelimit::check_limit_unauthenticated(&client_headers.ip.ip)?; - let Some(auth_request) = AuthRequest::find_by_uuid(&auth_request_id, &conn).await else { err!("AuthRequest doesn't exist", "User not found") }; @@ -2726,65 +1708,6 @@ pub async fn purge_auth_requests(pool: DbPool) { if let Ok(conn) = pool.get().await { AuthRequest::purge_expired_auth_requests(&conn).await; } else { - 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.compat.unprocessable(&data.email)); - assert_eq!(data.compat.hash(), "hash"); - assert_eq!(data.compat.key(), "key"); - assert_eq!(data.compat.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.compat.unprocessable(&data.email)); - assert_eq!(data.compat.hash(), "hash"); - assert_eq!(data.compat.key(), "key"); - assert_eq!(data.compat.kdf().kdf_iterations, 600_000); - assert!(data.keys.is_some()); + error!("Failed to get DB connection while purging auth requests") } } diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs index 749e8910..6d4e1f41 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -2,35 +2,33 @@ use std::collections::{HashMap, HashSet}; use chrono::{NaiveDateTime, Utc}; use num_traits::ToPrimitive; +use rocket::fs::TempFile; +use rocket::serde::json::Json; use rocket::{ - Route, form::{Form, FromForm}, - fs::TempFile, - http::Status, - serde::json::Json, + Route, }; use serde_json::Value; +use crate::auth::ClientVersion; +use crate::util::{deser_opt_nonempty_str, save_temp_file, NumberOrString}; use crate::{ - CONFIG, - api::{self, ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, core::log_event}, - auth::ClientVersion, + api::{self, core::log_event, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType}, auth::{Headers, OrgIdGuard, OwnerHeaders}, config::PathType, crypto, db::{ - DbConn, DbPool, models::{ - Archive, Attachment, AttachmentId, Cipher, CipherAccessScope, CipherId, Collection, CollectionCipher, - CollectionGroup, CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, - KeyId, Membership, MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, User, - UserId, is_data_blob_encrypted, + Attachment, AttachmentId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, + CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, Membership, MembershipType, + OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, UserId, }, + DbConn, DbPool, }, - util::{NumberOrString, deser_opt_nonempty_str, save_temp_file}, + CONFIG, }; -use super::{folders::FolderData, sends::SIZE_525_MB}; +use super::folders::FolderData; pub fn routes() -> Vec { // Note that many routes have an `admin` variant; this seems to be @@ -55,7 +53,6 @@ pub fn routes() -> Vec { post_ciphers_create, post_ciphers_import, get_attachment, - get_attachment_admin, post_attachment_v2, post_attachment_v2_data, post_attachment, // legacy @@ -99,10 +96,6 @@ pub fn routes() -> Vec { post_collections_update, post_collections_admin, put_collections_admin, - archive_cipher_put, - archive_cipher_selected, - unarchive_cipher_put, - unarchive_cipher_selected, ] } @@ -111,7 +104,7 @@ pub async fn purge_trashed_ciphers(pool: DbPool) { if let Ok(conn) = pool.get().await { Cipher::purge_trash(&conn).await; } else { - error!("Failed to get DB connection while purging trashed ciphers"); + error!("Failed to get DB connection while purging trashed ciphers") } } @@ -164,19 +157,13 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option = OrgPolicy::find_confirmed_by_user(&headers.user.uuid, &conn).await.iter().map(OrgPolicy::to_json).collect(); - let policies_new_json: Vec = OrgPolicy::find_accepted_and_confirmed_by_user(&headers.user.uuid, &conn) - .await - .iter() - .map(OrgPolicy::to_json) - .collect(); - let domains_json = if data.exclude_domains { Value::Null } else { - api::core::get_eq_domains(&headers, true).into_inner() + api::core::_get_eq_domains(&headers, true).into_inner() }; - // This is very similar to the userDecryptionOptions sent in connect/token, + // This is very similar to the the userDecryptionOptions sent in connect/token, // but as of 2025-12-19 they're both using different casing conventions. let has_master_password = !headers.user.password_hash.is_empty(); let master_password_unlock = if has_master_password { @@ -191,32 +178,23 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option JsonResult { #[get("/ciphers/")] async fn get_cipher(cipher_id: CipherId, headers: Headers, conn: DbConn) -> JsonResult { - get_cipher_impl(cipher_id, &headers, CipherAccessScope::User, &conn).await + let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else { + err!("Cipher doesn't exist") + }; + + if !cipher.is_accessible_to_user(&headers.user.uuid, &conn).await { + err!("Cipher is not owned by user") + } + + Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?)) } #[get("/ciphers//admin")] async fn get_cipher_admin(cipher_id: CipherId, headers: Headers, conn: DbConn) -> JsonResult { - get_cipher_impl(cipher_id, &headers, CipherAccessScope::OrganizationAdmin, &conn).await + // TODO: Implement this correctly + get_cipher(cipher_id, headers, conn).await } #[get("/ciphers//details")] @@ -255,38 +242,6 @@ async fn get_cipher_details(cipher_id: CipherId, headers: Headers, conn: DbConn) get_cipher(cipher_id, headers, conn).await } -async fn get_cipher_impl( - cipher_id: CipherId, - headers: &Headers, - scope: CipherAccessScope, - conn: &DbConn, -) -> JsonResult { - let Some(cipher) = Cipher::find_by_uuid(&cipher_id, conn).await else { - err!("Cipher doesn't exist") - }; - - if !cipher.is_accessible_to_user(&headers.user.uuid, scope, conn).await { - err!("Cipher is not owned by user") - } - - Ok(Json(cipher_json_for_scope(&cipher, headers, scope, conn).await?)) -} - -/// The cipher, as answered to a route authorized with `scope`. -async fn cipher_json_for_scope( - cipher: &Cipher, - headers: &Headers, - scope: CipherAccessScope, - conn: &DbConn, -) -> Result { - match scope { - CipherAccessScope::User => { - cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await - } - CipherAccessScope::OrganizationAdmin => cipher.to_json_org_admin(&headers.host, &headers.user.uuid, conn).await, - } -} - #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CipherData { @@ -301,24 +256,15 @@ pub struct CipherData { key: Option, - // Added in web-v2025.6.0. Deprecated upstream for `encrypted_by_key_id`, but still checked when sent - pub encrypted_for: Option, - // Added in web-v2025.8.1, Optional for compat - pub encrypted_by_key_id: Option, - /* Login = 1, SecureNote = 2, Card = 3, Identity = 4, SshKey = 5 - BankAccount = 6 - DriversLicense = 7 - Passport = 8 */ pub r#type: i32, - // Absent on a blob-encrypted cipher, whose name is sealed inside `data` - pub name: Option, + pub name: String, pub notes: Option, fields: Option, @@ -328,12 +274,6 @@ pub struct CipherData { card: Option, identity: Option, ssh_key: Option, - bank_account: Option, - drivers_license: Option, - passport: Option, - - // The sealed blob of a v2 account's cipher, which replaces all of the fields above - data: Option, favorite: Option, reprompt: Option, @@ -353,99 +293,6 @@ pub struct CipherData { // when using older client versions, or if the operation doesn't involve // updating an existing cipher. last_known_revision_date: Option, - archived_date: Option, -} - -/// A field of a [`CipherData`] that fails upstream's model validation. -#[derive(Debug)] -pub struct CipherValidationError { - pub field: &'static str, - pub message: String, -} - -impl From for crate::Error { - fn from(e: CipherValidationError) -> Self { - Self::new_msg(e.message) - } -} - -/// The cipher must have been encrypted for the acting user. Only checked when the client sends the field. -/// -/// Ref: -fn validate_encrypted_for_user(data: &CipherData, user_id: &UserId) -> EmptyResult { - if data.encrypted_for.as_ref().is_some_and(|encrypted_for| encrypted_for != user_id) { - err!("Cipher was not encrypted for the current user. Please try again.") - } - Ok(()) -} - -/// [`validate_encrypted_for_user`], plus the key id of a user-owned cipher, when both ids are known. -/// -/// Ref: -fn validate_encrypted_by_user(data: &CipherData, user: &User, is_org_cipher: bool) -> EmptyResult { - validate_encrypted_for_user(data, &user.uuid)?; - - if !is_org_cipher - && let (Some(cipher_key_id), Some(user_key_id)) = (&data.encrypted_by_key_id, &user.key_id) - && cipher_key_id != user_key_id - { - err!("Cipher was not encrypted with the current user key. Please try again.") - } - Ok(()) -} - -/// Upstream's `[StringLength(500000)]` on `CipherRequestModel.Data` -const MAX_CIPHER_DATA_LENGTH: usize = 500_000; - -impl CipherData { - /// Whether `data` is a blob rather than the per-type fields. Parses `data`, so keep the result. - pub fn is_blob(&self) -> bool { - self.data.as_deref().is_some_and(is_data_blob_encrypted) - } - - /// Saving's last checks: a known type, and its data unless it's a blob. - pub fn validate_type_data(&self, is_blob: bool) -> EmptyResult { - let type_data = match self.r#type { - 1 => &self.login, - 2 => &self.secure_note, - 3 => &self.card, - 4 => &self.identity, - 5 => &self.ssh_key, - 6 => &self.bank_account, - 7 => &self.drivers_license, - 8 => &self.passport, - _ => err!("Invalid type"), - }; - if !is_blob && type_data.is_none() { - err!("Data missing") - } - Ok(()) - } - - /// Upstream's model checks that depend on the format: the size of `data`, and a name unless it's a blob. - /// - /// Ref: - /// and - pub fn validate_content(&self, is_blob: bool) -> Result<(), CipherValidationError> { - if let Some(data) = &self.data - && data.len() > MAX_CIPHER_DATA_LENGTH - { - return Err(CipherValidationError { - field: "Data", - message: format!("The field Data must be a string with a maximum length of {MAX_CIPHER_DATA_LENGTH}."), - }); - } - - // A blob carries the name inside it, so only the other formats need one - if !is_blob && self.name.as_deref().is_none_or(|n| n.trim().is_empty()) { - return Err(CipherValidationError { - field: "Name", - message: String::from("The Name field is required."), - }); - } - - Ok(()) - } } #[derive(Debug, Deserialize)] @@ -466,8 +313,7 @@ pub struct Attachments2Data { /// Called when an org admin clones an org cipher. #[post("/ciphers/admin", data = "")] async fn post_ciphers_admin(data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { - // The same as /ciphers/create, only answered like the other administrative routes - post_ciphers_create_impl(data, headers, CipherAccessScope::OrganizationAdmin, conn, nt).await + post_ciphers_create(data, headers, conn, nt).await } /// Called when creating a new org-owned cipher, or cloning a cipher (whether @@ -479,40 +325,17 @@ async fn post_ciphers_create( headers: Headers, conn: DbConn, nt: Notify<'_>, -) -> JsonResult { - post_ciphers_create_impl(data, headers, CipherAccessScope::User, conn, nt).await -} - -async fn post_ciphers_create_impl( - data: Json, - headers: Headers, - response_scope: CipherAccessScope, - conn: DbConn, - nt: Notify<'_>, ) -> JsonResult { let mut data: ShareCipherData = data.into_inner(); - validate_encrypted_by_user(&data.cipher, &headers.user, data.cipher.organization_id.is_some())?; - // This check is usually only needed in update_cipher_from_data(), but we // need it here as well to avoid creating an empty cipher in the call to // cipher.save() below. enforce_personal_ownership_policy(Some(&data.cipher), &headers, &conn).await?; - if response_scope == CipherAccessScope::OrganizationAdmin { - let Some(org_id) = data.cipher.organization_id.as_ref() else { - err!("Cipher is not owned by an organization") - }; - if !Membership::find_confirmed_by_user_and_org(&headers.user.uuid, org_id, &conn) - .await - .is_some_and(|member| member.can_edit_any_collection()) - { - err!("Cipher is not write accessible") - } - } - - let mut cipher = Cipher::new(data.cipher.r#type); + let mut cipher = Cipher::new(data.cipher.r#type, data.cipher.name.clone()); cipher.user_uuid = Some(headers.user.uuid.clone()); + cipher.save(&conn).await?; // When cloning a cipher, the Bitwarden clients seem to set this field // based on the cipher being cloned (when creating a new cipher, it's set @@ -522,7 +345,11 @@ async fn post_ciphers_create_impl( // or otherwise), we can just ignore this field entirely. data.cipher.last_known_revision_date = None; - share_cipher_by_uuid(&cipher.uuid.clone(), Some(cipher), data, &headers, response_scope, &conn, &nt, None).await + let res = share_cipher_by_uuid(&cipher.uuid, data, &headers, &conn, &nt, None).await; + if res.is_err() { + cipher.delete(&conn).await?; + } + res } /// Called when creating a new user-owned cipher. @@ -530,16 +357,14 @@ async fn post_ciphers_create_impl( async fn post_ciphers(data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { let mut data: CipherData = data.into_inner(); - validate_encrypted_by_user(&data, &headers.user, data.organization_id.is_some())?; - // The web/browser clients set this field to null as expected, but the // mobile clients seem to set the invalid value `0001-01-01T00:00:00`, // which results in a warning message being logged. This field isn't // needed when creating a new cipher, so just ignore it unconditionally. data.last_known_revision_date = None; - let mut cipher = Cipher::new(data.r#type); - update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherCreate, true).await?; + let mut cipher = Cipher::new(data.r#type, data.name.clone()); + update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherCreate).await?; Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?)) } @@ -562,29 +387,36 @@ async fn enforce_personal_ownership_policy(data: Option<&CipherData>, headers: & Ok(()) } -/// The checks of saving a cipher, for callers that run them before writing anything else -async fn validate_cipher_update( - cipher: &Cipher, - data: &CipherData, +pub async fn update_cipher_from_data( + cipher: &mut Cipher, + data: CipherData, headers: &Headers, + shared_to_collections: Option>, conn: &DbConn, + nt: &Notify<'_>, ut: UpdateType, ) -> EmptyResult { + enforce_personal_ownership_policy(Some(&data), headers, conn).await?; + // Check that the client isn't updating an existing cipher with stale data. // And only perform this check when not importing ciphers, else the date/time check will fail. - if ut != UpdateType::None - && let Some(dt) = &data.last_known_revision_date - { - match NaiveDateTime::parse_from_str(dt, "%+") { - // ISO 8601 format - Err(err) => warn!("Error parsing LastKnownRevisionDate '{}': {err}", dt.escape_debug()), - Ok(dt) if cipher.updated_at.signed_duration_since(dt).num_seconds() > 1 => { - err!("The client copy of this cipher is out of date. Resync the client and try again.") + if ut != UpdateType::None { + if let Some(dt) = data.last_known_revision_date { + match NaiveDateTime::parse_from_str(&dt, "%+") { + // ISO 8601 format + Err(err) => warn!("Error parsing LastKnownRevisionDate '{dt}': {err}"), + Ok(dt) if cipher.updated_at.signed_duration_since(dt).num_seconds() > 1 => { + err!("The client copy of this cipher is out of date. Resync the client and try again.") + } + Ok(_) => (), } - Ok(_) => (), } } + if cipher.organization_uuid.is_some() && cipher.organization_uuid != data.organization_id { + err!("Organization mismatch. Please resync the client before updating the cipher") + } + if let Some(note) = &data.notes { let max_note_size = CONFIG._max_note_size(); if note.len() > max_note_size { @@ -592,90 +424,6 @@ async fn validate_cipher_update( } } - if let Some(folder_id) = &data.folder_id - && Folder::find_by_uuid_and_user(folder_id, &headers.user.uuid, conn).await.is_none() - { - err!("Invalid folder", "Folder does not exist or belongs to another user"); - } - Ok(()) -} - -#[expect( - clippy::too_many_arguments, - reason = "Cipher updates need separate access, notification and transaction controls" -)] -pub async fn update_cipher_from_data( - cipher: &mut Cipher, - data: CipherData, - headers: &Headers, - shared_to_collections: Option>, - conn: &DbConn, - nt: &Notify<'_>, - ut: UpdateType, - emit_side_effects: bool, -) -> EmptyResult { - enforce_personal_ownership_policy(Some(&data), headers, conn).await?; - save_cipher_data(cipher, data, headers, shared_to_collections, conn, nt, ut, emit_side_effects).await -} - -/// [`update_cipher_from_data`] for a rotation: like upstream, the personal ownership policy doesn't apply. -pub async fn rotate_cipher_data( - cipher: &mut Cipher, - data: CipherData, - headers: &Headers, - conn: &DbConn, - nt: &Notify<'_>, -) -> EmptyResult { - save_cipher_data(cipher, data, headers, None, conn, nt, UpdateType::None, false).await -} - -/// The checks of [`rotate_cipher_data`] that need the database, to run on every cipher before the first is saved -pub async fn validate_rotated_cipher( - cipher: &Cipher, - data: &CipherData, - headers: &Headers, - conn: &DbConn, -) -> EmptyResult { - validate_cipher_update(cipher, data, headers, conn, UpdateType::None).await -} - -#[expect( - clippy::too_many_arguments, - reason = "Cipher updates need separate access, notification and transaction controls" -)] -async fn save_cipher_data( - cipher: &mut Cipher, - data: CipherData, - headers: &Headers, - shared_to_collections: Option>, - conn: &DbConn, - nt: &Notify<'_>, - ut: UpdateType, - emit_side_effects: bool, -) -> EmptyResult { - // Cleanup cipher data, like removing the 'Response' key. - // This key is somewhere generated during Javascript so no way for us this fix this. - // Also, upstream only retrieves keys they actually want to store, and thus skip the 'Response' key. - // We do not mind which data is in it, the keep our model more flexible when there are upstream changes. - // But, we at least know we do not need to store and return this specific key. - fn clean_cipher_data(mut json_data: Value) -> Value { - if let Some(items) = json_data.as_array_mut() { - for item in items.iter_mut().filter_map(Value::as_object_mut) { - item.remove("response"); - } - } - json_data - } - - let is_blob = data.is_blob(); - data.validate_content(is_blob)?; - data.validate_type_data(is_blob)?; - validate_cipher_update(cipher, &data, headers, conn, ut).await?; - - if cipher.organization_uuid.is_some() && cipher.organization_uuid != data.organization_id { - err!("Organization mismatch. Please resync the client before updating the cipher") - } - // Check if this cipher is being transferred from a personal to an organization vault let transfer_cipher = cipher.organization_uuid.is_none() && data.organization_id.is_some(); @@ -683,13 +431,9 @@ async fn save_cipher_data( match Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &org_id, conn).await { None => err!("You don't have permission to add item to organization"), Some(member) => { - // A non-empty list of collections implies the caller already validated the user's write - // access to them, so we can move the cipher into the organization on that basis. - // Write access to the cipher itself only counts when it already belongs to this organization. - if shared_to_collections.as_ref().is_some_and(|cols| !cols.is_empty()) - || member.can_edit_any_collection() - || (!transfer_cipher - && cipher.is_write_accessible_to_user(&headers.user.uuid, CipherAccessScope::User, conn).await) + if shared_to_collections.is_some() + || member.has_full_access() + || cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await { cipher.organization_uuid = Some(org_id); // After some discussion in PR #1329 re-added the user_uuid = None again. @@ -707,16 +451,25 @@ async fn save_cipher_data( cipher.user_uuid = Some(headers.user.uuid.clone()); } + if let Some(ref folder_id) = data.folder_id { + if Folder::find_by_uuid_and_user(folder_id, &headers.user.uuid, conn).await.is_none() { + err!("Invalid folder", "Folder does not exist or belongs to another user"); + } + } + // Modify attachments name and keys when rotating if let Some(attachments) = data.attachments2 { for (id, attachment) in attachments { - let Some(mut saved_att) = Attachment::find_by_id(&id, conn).await else { - // Warn and continue here. - // A missing attachment means it was removed via an other client. - // Also the Desktop Client supports removing attachments and save an update afterwards. - // Bitwarden it self ignores these mismatches server side. - warn!("Attachment {} doesn't exist", id.escape_debug()); - continue; + let mut saved_att = match Attachment::find_by_id(&id, conn).await { + Some(att) => att, + None => { + // Warn and continue here. + // A missing attachment means it was removed via an other client. + // Also the Desktop Client supports removing attachments and save an update afterwards. + // Bitwarden it self ignores these mismatches server side. + warn!("Attachment {id} doesn't exist"); + continue; + } }; if saved_att.cipher_uuid != cipher.uuid { @@ -733,63 +486,55 @@ async fn save_cipher_data( } } + // Cleanup cipher data, like removing the 'Response' key. + // This key is somewhere generated during Javascript so no way for us this fix this. + // Also, upstream only retrieves keys they actually want to store, and thus skip the 'Response' key. + // We do not mind which data is in it, the keep our model more flexible when there are upstream changes. + // But, we at least know we do not need to store and return this specific key. + fn _clean_cipher_data(mut json_data: Value) -> Value { + if json_data.is_array() { + json_data.as_array_mut().unwrap().iter_mut().for_each(|ref mut f| { + f.as_object_mut().unwrap().remove("response"); + }); + }; + json_data + } + let type_data_opt = match data.r#type { 1 => data.login, 2 => data.secure_note, 3 => data.card, 4 => data.identity, 5 => data.ssh_key, - 6 => data.bank_account, - 7 => data.drivers_license, - 8 => data.passport, _ => err!("Invalid type"), }; - if let Some(blob) = data.data.filter(|_| is_blob) { - // A blob holds everything, the name included; the column can't be null, so it's left empty - // TODO: Make `ciphers.name` nullable and store `None` here instead. - cipher.name = String::new(); - cipher.notes = None; - cipher.fields = None; - cipher.password_history = None; - cipher.data = blob; - } else { - let Some(mut type_data) = type_data_opt else { - err!("Data missing") - }; - // Remove the 'Response' key from the base object. - if let Some(data_obj) = type_data.as_object_mut() { - data_obj.remove("response"); + let type_data = match type_data_opt { + Some(mut data) => { + // Remove the 'Response' key from the base object. + data.as_object_mut().unwrap().remove("response"); + // Remove the 'Response' key from every Uri. + if data["uris"].is_array() { + data["uris"] = _clean_cipher_data(data["uris"].clone()); + } + data } - // Remove the 'Response' key from every Uri. - if type_data["uris"].is_array() { - type_data["uris"] = clean_cipher_data(type_data["uris"].clone()); - } - - // `validate_content` made sure there is a name - cipher.name = data.name.unwrap_or_default(); - cipher.notes = data.notes; - cipher.fields = data.fields.map(|f| clean_cipher_data(f).to_string()); - cipher.password_history = data.password_history.map(|f| f.to_string()); - cipher.data = type_data.to_string(); - } + None => err!("Data missing"), + }; cipher.key = data.key; + cipher.name = data.name; + cipher.notes = data.notes; + cipher.fields = data.fields.map(|f| _clean_cipher_data(f).to_string()); + cipher.data = type_data.to_string(); + cipher.password_history = data.password_history.map(|f| f.to_string()); cipher.reprompt = data.reprompt.filter(|r| *r == RepromptType::None as i32 || *r == RepromptType::Password as i32); cipher.save(conn).await?; cipher.move_to_folder(data.folder_id, &headers.user.uuid, conn).await?; cipher.set_favorite(data.favorite, &headers.user.uuid, conn).await?; - match data.archived_date { - Some(dt_str) => match NaiveDateTime::parse_from_str(&dt_str, "%+") { - Ok(dt) => cipher.set_archived_at(dt, &headers.user.uuid, conn).await?, - Err(err) => warn!("Error parsing ArchivedDate '{}': {err}", dt_str.escape_debug()), - }, - None => cipher.unarchive(&headers.user.uuid, conn).await?, - } - - if ut != UpdateType::None && emit_side_effects { + if ut != UpdateType::None { // Only log events for organizational ciphers if let Some(org_id) = &cipher.organization_uuid { let event_type = match (&ut, transfer_cipher) { @@ -798,8 +543,16 @@ async fn save_cipher_data( (_, _) => EventType::CipherUpdated, }; - log_event(event_type, &cipher.uuid, org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, conn) - .await; + log_event( + event_type as i32, + &cipher.uuid, + org_id, + &headers.user.uuid, + headers.device.atype, + &headers.ip.ip, + conn, + ) + .await; } nt.send_cipher_update( ut, @@ -847,7 +600,7 @@ async fn post_ciphers_import(data: Json, headers: Headers, conn: DbC let existing_folders: HashSet> = Folder::find_by_user(&headers.user.uuid, &conn).await.into_iter().map(|f| Some(f.uuid)).collect(); let mut folders: Vec = Vec::with_capacity(data.folders.len()); - for folder in data.folders { + for folder in data.folders.into_iter() { let folder_id = if existing_folders.contains(&folder.id) { folder.id.unwrap() } else { @@ -868,19 +621,16 @@ async fn post_ciphers_import(data: Json, headers: Headers, conn: DbC // Read and create the ciphers for (index, mut cipher_data) in data.ciphers.into_iter().enumerate() { - let folder_id = relations_map.get(&index).and_then(|i| folders.get(*i).cloned()); + let folder_id = relations_map.get(&index).map(|i| folders[*i].clone()); cipher_data.folder_id = folder_id; - // A personal import always creates personal ciphers, the organization ID is ignored instead of rejected - // Upstream: https://github.com/bitwarden/server/blob/b06b81c1fb33ce9c9872b96397ba2b4d879b66af/src/Api/Tools/Controllers/ImportCiphersController.cs#L58 - cipher_data.organization_id = None; - let mut cipher = Cipher::new(cipher_data.r#type); - update_cipher_from_data(&mut cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None, true).await?; + let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone()); + update_cipher_from_data(&mut cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None).await?; } let mut user = headers.user; user.update_revision(&conn).await?; - nt.send_user_update(UpdateType::SyncVault, &user, headers.device.push_uuid.as_ref(), &conn).await; + nt.send_user_update(UpdateType::SyncVault, &user, &headers.device.push_uuid, &conn).await; Ok(()) } @@ -894,12 +644,7 @@ async fn put_cipher_admin( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - // Only organization ciphers can be edited here, put_cipher() rejects an organization ID that isn't the cipher's - if data.organization_id.is_none() { - err_code!("Resource not found.", "Cipher is not an organization cipher", Status::NotFound.code); - } - - put_cipher_impl(cipher_id, data, headers, CipherAccessScope::OrganizationAdmin, conn, nt).await + put_cipher(cipher_id, data, headers, conn, nt).await } #[post("/ciphers//admin", data = "")] @@ -910,7 +655,7 @@ async fn post_cipher_admin( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - put_cipher_admin(cipher_id, data, headers, conn, nt).await + post_cipher(cipher_id, data, headers, conn, nt).await } #[post("/ciphers/", data = "")] @@ -931,17 +676,6 @@ async fn put_cipher( headers: Headers, conn: DbConn, nt: Notify<'_>, -) -> JsonResult { - put_cipher_impl(cipher_id, data, headers, CipherAccessScope::User, conn, nt).await -} - -async fn put_cipher_impl( - cipher_id: CipherId, - data: Json, - headers: Headers, - scope: CipherAccessScope, - conn: DbConn, - nt: Notify<'_>, ) -> JsonResult { let data: CipherData = data.into_inner(); @@ -954,21 +688,13 @@ async fn put_cipher_impl( // cipher itself, so the user shouldn't need write access to change these. // Interestingly, upstream Bitwarden doesn't properly handle this either. - if !cipher.is_write_accessible_to_user(&headers.user.uuid, scope, &conn).await { + if !cipher.is_write_accessible_to_user(&headers.user.uuid, &conn).await { err!("Cipher is not write accessible") } - // Against the cipher we hold rather than the organization the client claims, like upstream - validate_encrypted_by_user(&data, &headers.user, cipher.organization_uuid.is_some())?; + update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherUpdate).await?; - // Moving a cipher into an organization is only possible through the share endpoints - if cipher.organization_uuid != data.organization_id { - err!("Organization mismatch. Please resync the client before updating the cipher") - } - - update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherUpdate, true).await?; - - Ok(Json(cipher_json_for_scope(&cipher, &headers, scope, &conn).await?)) + Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?)) } #[post("/ciphers//partial", data = "")] @@ -995,14 +721,14 @@ async fn put_cipher_partial( err!("Cipher does not exist") }; - if !cipher.is_accessible_to_user(&headers.user.uuid, CipherAccessScope::User, &conn).await { + if !cipher.is_accessible_to_user(&headers.user.uuid, &conn).await { err!("Cipher does not exist", "Cipher is not accessible for the current user") } - if let Some(ref folder_id) = data.folder_id - && Folder::find_by_uuid_and_user(folder_id, &headers.user.uuid, &conn).await.is_none() - { - err!("Invalid folder", "Folder does not exist or belongs to another user"); + if let Some(ref folder_id) = data.folder_id { + if Folder::find_by_uuid_and_user(folder_id, &headers.user.uuid, &conn).await.is_none() { + err!("Invalid folder", "Folder does not exist or belongs to another user"); + } } // Move cipher @@ -1066,7 +792,61 @@ async fn post_collections_update( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - update_collections_by_scope(cipher_id, data.into_inner(), headers, CipherAccessScope::User, conn, nt).await + let data: CollectionsAdminData = data.into_inner(); + + let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else { + err!("Cipher doesn't exist") + }; + + if !cipher.is_in_editable_collection_by_user(&headers.user.uuid, &conn).await { + err!("Collection cannot be changed") + } + + let posted_collections = HashSet::::from_iter(data.collection_ids); + let current_collections = + HashSet::::from_iter(cipher.get_collections(headers.user.uuid.clone(), &conn).await); + + for collection in posted_collections.symmetric_difference(¤t_collections) { + match Collection::find_by_uuid_and_org(collection, cipher.organization_uuid.as_ref().unwrap(), &conn).await { + None => err!("Invalid collection ID provided"), + Some(collection) => { + if collection.is_writable_by_user(&headers.user.uuid, &conn).await { + if posted_collections.contains(&collection.uuid) { + // Add to collection + CollectionCipher::save(&cipher.uuid, &collection.uuid, &conn).await?; + } else { + // Remove from collection + CollectionCipher::delete(&cipher.uuid, &collection.uuid, &conn).await?; + } + } else { + err!("No rights to modify the collection") + } + } + } + } + + nt.send_cipher_update( + UpdateType::SyncCipherUpdate, + &cipher, + &cipher.update_users_revision(&conn).await, + &headers.device, + Some(Vec::from_iter(posted_collections)), + &conn, + ) + .await; + + log_event( + EventType::CipherUpdatedCollections as i32, + &cipher.uuid, + &cipher.organization_uuid.clone().unwrap(), + &headers.user.uuid, + headers.device.atype, + &headers.ip.ip, + &conn, + ) + .await; + + Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?)) } #[put("/ciphers//collections-admin", data = "")] @@ -1076,7 +856,7 @@ async fn put_collections_admin( headers: Headers, conn: DbConn, nt: Notify<'_>, -) -> JsonResult { +) -> EmptyResult { post_collections_admin(cipher_id, data, headers, conn, nt).await } @@ -1087,95 +867,62 @@ async fn post_collections_admin( headers: Headers, conn: DbConn, nt: Notify<'_>, -) -> JsonResult { - update_collections_by_scope(cipher_id, data.into_inner(), headers, CipherAccessScope::OrganizationAdmin, conn, nt) - .await -} +) -> EmptyResult { + let data: CollectionsAdminData = data.into_inner(); -async fn update_collections_by_scope( - cipher_id: CipherId, - data: CollectionsAdminData, - headers: Headers, - scope: CipherAccessScope, - conn: DbConn, - nt: Notify<'_>, -) -> JsonResult { - let (cipher, posted_collections, org_uuid, affected_users) = conn - .transaction(async || { - let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else { - err!("Cipher doesn't exist") - }; - if !cipher.is_in_editable_collection_by_user(&headers.user.uuid, scope, &conn).await { - err!("Collection cannot be changed") - } - let Some(org_uuid) = cipher.organization_uuid.clone() else { - err!("Cipher is not owned by an organization") - }; - let posted = HashSet::::from_iter(data.collection_ids); - let current = HashSet::::from_iter(match scope { - CipherAccessScope::User => cipher.get_collections(headers.user.uuid.clone(), &conn).await, - CipherAccessScope::OrganizationAdmin => { - cipher.get_admin_collections(headers.user.uuid.clone(), &conn).await - } - }); - // The admin route accepts Edit Any for all collections of this cipher's organization. - // The personal route keeps the direct/group write check, even for that same member. - let admin_edit_any = scope == CipherAccessScope::OrganizationAdmin - && Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &org_uuid, &conn) - .await - .is_some_and(|member| member.can_edit_any_collection()); - for id in &posted { - if Collection::find_by_uuid_and_org(id, &org_uuid, &conn).await.is_none() { - err!("Invalid collection ID provided") - } - } - let mut changes = Vec::new(); - for id in posted.symmetric_difference(¤t) { - let Some(collection) = Collection::find_by_uuid_and_org(id, &org_uuid, &conn).await else { - err!("Invalid collection ID provided") - }; - if !admin_edit_any && !collection.is_writable_by_user(&headers.user.uuid, &conn).await { + let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else { + err!("Cipher doesn't exist") + }; + + if !cipher.is_in_editable_collection_by_user(&headers.user.uuid, &conn).await { + err!("Collection cannot be changed") + } + + let posted_collections = HashSet::::from_iter(data.collection_ids); + let current_collections = + HashSet::::from_iter(cipher.get_admin_collections(headers.user.uuid.clone(), &conn).await); + + for collection in posted_collections.symmetric_difference(¤t_collections) { + match Collection::find_by_uuid_and_org(collection, cipher.organization_uuid.as_ref().unwrap(), &conn).await { + None => err!("Invalid collection ID provided"), + Some(collection) => { + if collection.is_writable_by_user(&headers.user.uuid, &conn).await { + if posted_collections.contains(&collection.uuid) { + // Add to collection + CollectionCipher::save(&cipher.uuid, &collection.uuid, &conn).await?; + } else { + // Remove from collection + CollectionCipher::delete(&cipher.uuid, &collection.uuid, &conn).await?; + } + } else { err!("No rights to modify the collection") } - changes.push((collection.uuid, posted.contains(id))); } - // Every ID and permission has been checked. A database error rolls back links and revisions. - for (id, add) in changes { - if add { - CollectionCipher::save(&cipher.uuid, &id, &conn).await?; - } else { - CollectionCipher::delete(&cipher.uuid, &id, &conn).await?; - } - } - let affected_users = cipher.update_users_revision(&conn).await; - Ok((cipher, posted, org_uuid, affected_users)) - }) - .await?; + } + } nt.send_cipher_update( UpdateType::SyncCipherUpdate, &cipher, - &affected_users, + &cipher.update_users_revision(&conn).await, &headers.device, Some(Vec::from_iter(posted_collections)), &conn, ) .await; + log_event( - EventType::CipherUpdatedCollections, + EventType::CipherUpdatedCollections as i32, &cipher.uuid, - &org_uuid, + &cipher.organization_uuid.unwrap(), &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn, ) .await; - let sync_type = match scope { - CipherAccessScope::User => CipherSyncType::User, - CipherAccessScope::OrganizationAdmin => CipherSyncType::Organization, - }; - Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, sync_type, &conn).await?)) + + Ok(()) } #[derive(Deserialize)] @@ -1195,7 +942,9 @@ async fn post_cipher_share( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - put_cipher_share(cipher_id, data, headers, conn, nt).await + let data: ShareCipherData = data.into_inner(); + + share_cipher_by_uuid(&cipher_id, data, &headers, &conn, &nt, None).await } #[put("/ciphers//share", data = "")] @@ -1208,22 +957,7 @@ async fn put_cipher_share( ) -> JsonResult { let data: ShareCipherData = data.into_inner(); - // Upstream's `PutShare` checks, before anything is written. - // Ref: - // and - if data.cipher.organization_id.is_none() { - err!("Cipher OrganizationId is required.") - } - if data.collection_ids.is_empty() { - err!("You must select at least one collection.") - } - if !Cipher::find_by_uuid(&cipher_id, &conn).await.is_some_and(|c| c.user_uuid.as_ref() == Some(&headers.user.uuid)) - { - err_code!("Cipher doesn't exist", Status::NotFound.code) - } - validate_encrypted_for_user(&data.cipher, &headers.user.uuid)?; - - share_cipher_by_uuid(&cipher_id, None, data, &headers, CipherAccessScope::User, &conn, &nt, None).await + share_cipher_by_uuid(&cipher_id, data, &headers, &conn, &nt, None).await } #[derive(Deserialize)] @@ -1233,140 +967,86 @@ struct ShareSelectedCipherData { collection_ids: Vec, } -// A bulk share returns cipherMini models, not the cipherDetails models used by -// the single-share route. Keep the encrypted legacy fields and a future v2 -// `data` blob, but do not expose details-only fields in this response. -fn cipher_mini_from_json(mut cipher: Value) -> Value { - const FIELDS: &[&str] = &[ - "id", - "organizationId", - "type", - "data", - "partialData", - "name", - "notes", - "login", - "card", - "identity", - "secureNote", - "sshKey", - "bankAccount", - "driversLicense", - "passport", - "fields", - "passwordHistory", - "attachments", - "organizationUseTotp", - "revisionDate", - "creationDate", - "deletedDate", - "reprompt", - "key", - ]; - - let Some(object) = cipher.as_object_mut() else { - return json!({ "object": "cipherMini" }); - }; - object.retain(|field, _| FIELDS.contains(&field.as_str())); - object.insert("object".into(), "cipherMini".into()); - cipher -} - #[put("/ciphers/share", data = "")] async fn put_cipher_share_selected( data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>, -) -> JsonResult { - let data: ShareSelectedCipherData = data.into_inner(); +) -> EmptyResult { + let mut data: ShareSelectedCipherData = data.into_inner(); - // Upstream's `PutShareMany` checks, before anything is written. - // Ref: - // and if data.ciphers.is_empty() { err!("You must select at least one cipher.") } - if data.ciphers.iter().any(|c| c.id.is_none() || c.organization_id.is_none()) { - err!("All Ciphers must have an Id and OrganizationId.") - } - if data.ciphers.iter().map(|c| &c.organization_id).collect::>().len() != 1 { - err!("All ciphers must be for the same organization.") - } + if data.collection_ids.is_empty() { err!("You must select at least one collection.") } - for cipher in &data.ciphers { - let is_blob = cipher.is_blob(); - cipher.validate_content(is_blob)?; - cipher.validate_type_data(is_blob)?; - } - for cipher in &data.ciphers { - validate_encrypted_for_user(cipher, &headers.user.uuid)?; - } - let owned_ciphers = Cipher::find_owned_by_user(&headers.user.uuid, &conn).await; - for cipher_data in &data.ciphers { - let Some(cipher) = owned_ciphers.iter().find(|c| cipher_data.id.as_ref() == Some(&c.uuid)) else { - err!("Trying to share ciphers that you do not own.") - }; - validate_cipher_update(cipher, cipher_data, &headers, &conn, UpdateType::None).await?; + + for cipher in data.ciphers.iter() { + if cipher.id.is_none() { + err!("Request missing ids field") + } } - let mut responses = Vec::with_capacity(data.ciphers.len()); - for cipher in data.ciphers { + while let Some(cipher) = data.ciphers.pop() { let mut shared_cipher_data = ShareCipherData { cipher, collection_ids: data.collection_ids.clone(), }; - if let Some(id) = shared_cipher_data.cipher.id.take() { - let response = share_cipher_by_uuid( - &id, - None, - shared_cipher_data, - &headers, - CipherAccessScope::User, - &conn, - &nt, - Some(UpdateType::None), - ) - .await?; - - // Like upstream's VisibleToClient, withhold a cipher if sharing it - // removed the user's access to it. Only then include its encrypted - // mini response; the full single-share response stays internal. - if let Some(shared_cipher) = Cipher::find_by_uuid(&id, &conn).await - && shared_cipher.is_accessible_to_user(&headers.user.uuid, CipherAccessScope::User, &conn).await - { - responses.push(cipher_mini_from_json(response.into_inner())); + match shared_cipher_data.cipher.id.take() { + Some(id) => { + share_cipher_by_uuid(&id, shared_cipher_data, &headers, &conn, &nt, Some(UpdateType::None)).await? } - } else { - err!("Request missing ids field") - } + None => err!("Request missing ids field"), + }; } // Multi share actions do not send out a push for each cipher, we need to send a general sync here - nt.send_user_update(UpdateType::SyncCiphers, &headers.user, headers.device.push_uuid.as_ref(), &conn).await; + nt.send_user_update(UpdateType::SyncCiphers, &headers.user, &headers.device.push_uuid, &conn).await; - Ok(Json(json!({ - "data": responses, - "object": "list", - "continuationToken": null, - }))) + Ok(()) } -#[expect(clippy::too_many_arguments, reason = "New and existing ciphers share one atomic validation path")] async fn share_cipher_by_uuid( cipher_id: &CipherId, - new_cipher: Option, data: ShareCipherData, headers: &Headers, - // Only the admin creation route may use organization-wide collection authority. - response_scope: CipherAccessScope, conn: &DbConn, nt: &Notify<'_>, override_ut: Option, ) -> JsonResult { + let mut cipher = match Cipher::find_by_uuid(cipher_id, conn).await { + Some(cipher) => { + if cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await { + cipher + } else { + err!("Cipher is not write accessible") + } + } + None => err!("Cipher doesn't exist"), + }; + + let mut shared_to_collections = vec![]; + + if let Some(organization_id) = &data.cipher.organization_id { + for col_id in &data.collection_ids { + match Collection::find_by_uuid_and_org(col_id, organization_id, conn).await { + None => err!("Invalid collection ID provided"), + Some(collection) => { + if collection.is_writable_by_user(&headers.user.uuid, conn).await { + CollectionCipher::save(&cipher.uuid, &collection.uuid, conn).await?; + shared_to_collections.push(collection.uuid); + } else { + err!("No rights to modify the collection") + } + } + } + } + }; + // When LastKnownRevisionDate is None, it is a new cipher, so send CipherCreate. // If there is an override, like when handling multiple items, we want to prevent a push notification for every single item let ut = if let Some(ut) = override_ut { @@ -1376,81 +1056,10 @@ async fn share_cipher_by_uuid( } else { UpdateType::SyncCipherCreate }; - let (cipher, shared_to_collections, transfer_cipher, affected_users) = conn - .transaction(async || { - let is_new = new_cipher.is_some(); - let mut cipher = if let Some(cipher) = new_cipher { - cipher - } else if let Some(cipher) = Cipher::find_by_uuid(cipher_id, conn).await { - cipher - } else { - err!("Cipher doesn't exist") - }; - if !cipher.is_write_accessible_to_user(&headers.user.uuid, CipherAccessScope::User, conn).await { - err!("Cipher is not write accessible") - } - if cipher.organization_uuid.is_some() && cipher.organization_uuid != data.cipher.organization_id { - err!("Organization mismatch. Please resync the client before updating the cipher") - } - let transfer_cipher = cipher.organization_uuid.is_none() && data.cipher.organization_id.is_some(); - let mut shared_to_collections = Vec::new(); - if let Some(organization_id) = &data.cipher.organization_id { - let admin_edit_any = response_scope == CipherAccessScope::OrganizationAdmin - && Membership::find_confirmed_by_user_and_org(&headers.user.uuid, organization_id, conn) - .await - .is_some_and(|member| member.can_edit_any_collection()); - if response_scope == CipherAccessScope::OrganizationAdmin && !admin_edit_any { - err!("Cipher is not write accessible") - } - for col_id in HashSet::::from_iter(data.collection_ids) { - let Some(collection) = Collection::find_by_uuid_and_org(&col_id, organization_id, conn).await - else { - err!("Invalid collection ID provided") - }; - if !admin_edit_any && !collection.is_writable_by_user(&headers.user.uuid, conn).await { - err!("No rights to modify the collection") - } - shared_to_collections.push(collection.uuid); - } - } - if is_new { - cipher.save(conn).await?; - } - // Keep the cipher, collection links and affected revisions in one transaction. - // The helper still validates stale writes, ownership, folders and cipher data. - update_cipher_from_data( - &mut cipher, - data.cipher, - headers, - Some(shared_to_collections.clone()), - conn, - nt, - ut, - false, - ) - .await?; - for col_id in &shared_to_collections { - CollectionCipher::save(&cipher.uuid, col_id, conn).await?; - } - // Bulk sharing skips per-cipher pushes, but new group-derived recipients still need a revision update. - let affected_users = cipher.update_users_revision(conn).await; - Ok((cipher, shared_to_collections, transfer_cipher, affected_users)) - }) - .await?; - if ut != UpdateType::None { - if let Some(org_id) = &cipher.organization_uuid { - let event_type = match (&ut, transfer_cipher) { - (UpdateType::SyncCipherCreate, true) => EventType::CipherCreated, - (UpdateType::SyncCipherUpdate, true) => EventType::CipherShared, - (_, _) => EventType::CipherUpdated, - }; - log_event(event_type, &cipher.uuid, org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, conn) - .await; - } - nt.send_cipher_update(ut, &cipher, &affected_users, &headers.device, Some(shared_to_collections), conn).await; - } - Ok(Json(cipher_json_for_scope(&cipher, headers, response_scope, conn).await?)) + update_cipher_from_data(&mut cipher, data.cipher, headers, Some(shared_to_collections), conn, nt, ut).await?; + + Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await?)) } /// v2 API for downloading an attachment. This just redirects the client to @@ -1465,44 +1074,12 @@ async fn get_attachment( attachment_id: AttachmentId, headers: Headers, conn: DbConn, -) -> JsonResult { - get_attachment_data(cipher_id, attachment_id, headers, CipherAccessScope::User, conn).await -} - -#[get("/ciphers//attachment//admin")] -async fn get_attachment_admin( - cipher_id: CipherId, - attachment_id: AttachmentId, - headers: Headers, - conn: DbConn, -) -> JsonResult { - get_attachment_data(cipher_id, attachment_id, headers, CipherAccessScope::OrganizationAdmin, conn).await -} - -async fn get_attachment_data( - cipher_id: CipherId, - attachment_id: AttachmentId, - headers: Headers, - scope: CipherAccessScope, - conn: DbConn, ) -> JsonResult { let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else { err!("Cipher doesn't exist") }; - if scope == CipherAccessScope::OrganizationAdmin { - // NotFound as in Bitwarden, the web vault then falls back to the attachment's own URL - let Some(org_id) = cipher.organization_uuid.as_ref() else { - err_code!("Cipher is not owned by an organization", Status::NotFound.code) - }; - if !Membership::find_confirmed_by_user_and_org(&headers.user.uuid, org_id, &conn) - .await - .is_some_and(|member| member.can_edit_any_collection()) - || !cipher.is_write_accessible_to_user(&headers.user.uuid, scope, &conn).await - { - err_code!("Cipher is not accessible", Status::NotFound.code) - } - } else if !cipher.is_accessible_to_user(&headers.user.uuid, scope, &conn).await { + if !cipher.is_accessible_to_user(&headers.user.uuid, &conn).await { err!("Cipher is not accessible") } @@ -1527,40 +1104,6 @@ enum FileUploadType { // Azure = 1, // only used upstream } -/// Remaining attachment storage in bytes for the cipher's owner, `None` when unlimited. -/// `size_adjust` credits a v2 attachment whose record already counts as used. -async fn attachment_size_limit(cipher: &Cipher, size_adjust: i64, conn: &DbConn) -> ApiResult> { - let (limit_kb, already_used) = if let Some(ref user_id) = cipher.user_uuid { - let Some(limit_kb) = CONFIG.user_attachment_limit() else { - return Ok(None); - }; - (limit_kb, Attachment::size_by_user(user_id, conn).await) - } else if let Some(ref org_id) = cipher.organization_uuid { - let Some(limit_kb) = CONFIG.org_attachment_limit() else { - return Ok(None); - }; - (limit_kb, Attachment::size_by_org(org_id, conn).await) - } else { - err!("Cipher is neither owned by a user nor an organization"); - }; - - if limit_kb == 0 { - err!("Attachments are disabled") - } - - let Some(left) = - limit_kb.checked_mul(1024).and_then(|l| l.checked_sub(already_used)).and_then(|l| l.checked_add(size_adjust)) - else { - err!("Attachment size overflow"); - }; - - if left <= 0 { - err!("Attachment storage limit reached! Delete some attachments to free up space") - } - - Ok(Some(left)) -} - /// v2 API for creating an attachment associated with a cipher. /// This redirects the client to the API it should use to upload the attachment. /// For upstream's cloud-hosted service, it's an Azure object storage API. @@ -1571,47 +1114,30 @@ async fn post_attachment_v2( data: Json, headers: Headers, conn: DbConn, - nt: Notify<'_>, ) -> JsonResult { - let data: AttachmentRequestData = data.into_inner(); - - // `adminRequest` picks the check, the administrative one still needs a member who may edit any collection - let scope = CipherAccessScope::requested(data.admin_request); - let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else { err!("Cipher doesn't exist") }; - if !cipher.is_write_accessible_to_user(&headers.user.uuid, scope, &conn).await { + if !cipher.is_write_accessible_to_user(&headers.user.uuid, &conn).await { err!("Cipher is not write accessible") } + let data: AttachmentRequestData = data.into_inner(); let file_size = data.file_size.into_i64()?; if file_size < 0 { err!("Attachment size can't be negative") } - // Matches the "file" upload limit in main.rs, nothing larger can be uploaded - if file_size > SIZE_525_MB { - err!("Max file size is 525 MB.") - } - - if let Some(size_limit) = attachment_size_limit(&cipher, 0, &conn).await? - && file_size > size_limit - { - err!("Attachment storage limit exceeded with this file"); - } - let attachment_id = crypto::generate_attachment_id(); let attachment = Attachment::new(attachment_id.clone(), cipher.uuid.clone(), data.file_name, file_size, Some(data.key)); attachment.save(&conn).await.expect("Error saving attachment"); - notify_attachment_created(&cipher, &headers, &conn, &nt).await; let url = format!("/ciphers/{}/attachment/{attachment_id}", cipher.uuid); - let response_key = match scope { - CipherAccessScope::OrganizationAdmin => "cipherMiniResponse", - CipherAccessScope::User => "cipherResponse", + let response_key = match data.admin_request { + Some(b) if b => "cipherMiniResponse", + _ => "cipherResponse", }; Ok(Json(json!({ // AttachmentUploadDataResponseModel @@ -1619,7 +1145,7 @@ async fn post_attachment_v2( "attachmentId": attachment_id, "url": url, "fileUploadType": FileUploadType::Direct as i32, - response_key: cipher_json_for_scope(&cipher, &headers, scope, &conn).await?, + response_key: cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?, }))) } @@ -1642,7 +1168,6 @@ async fn save_attachment( cipher_id: CipherId, data: Form>, headers: &Headers, - scope: CipherAccessScope, conn: DbConn, nt: Notify<'_>, ) -> Result<(Cipher, DbConn), crate::error::Error> { @@ -1659,7 +1184,7 @@ async fn save_attachment( err!("Cipher doesn't exist") }; - if !cipher.is_write_accessible_to_user(&headers.user.uuid, scope, &conn).await { + if !cipher.is_write_accessible_to_user(&headers.user.uuid, &conn).await { err!("Cipher is not write accessible") } @@ -1670,10 +1195,58 @@ async fn save_attachment( Some(a) => a.file_size, // v2 API }; - if let Some(size_limit) = attachment_size_limit(&cipher, size_adjust, &conn).await? - && size > size_limit - { - err!("Attachment storage limit exceeded with this file"); + let size_limit = if let Some(ref user_id) = cipher.user_uuid { + match CONFIG.user_attachment_limit() { + Some(0) => err!("Attachments are disabled"), + Some(limit_kb) => { + let already_used = Attachment::size_by_user(user_id, &conn).await; + let left = limit_kb + .checked_mul(1024) + .and_then(|l| l.checked_sub(already_used)) + .and_then(|l| l.checked_add(size_adjust)); + + let Some(left) = left else { + err!("Attachment size overflow"); + }; + + if left <= 0 { + err!("Attachment storage limit reached! Delete some attachments to free up space") + } + + Some(left) + } + None => None, + } + } else if let Some(ref org_id) = cipher.organization_uuid { + match CONFIG.org_attachment_limit() { + Some(0) => err!("Attachments are disabled"), + Some(limit_kb) => { + let already_used = Attachment::size_by_org(org_id, &conn).await; + let left = limit_kb + .checked_mul(1024) + .and_then(|l| l.checked_sub(already_used)) + .and_then(|l| l.checked_add(size_adjust)); + + let Some(left) = left else { + err!("Attachment size overflow"); + }; + + if left <= 0 { + err!("Attachment storage limit reached! Delete some attachments to free up space") + } + + Some(left) + } + None => None, + } + } else { + err!("Cipher is neither owned by a user nor an organization"); + }; + + if let Some(size_limit) = size_limit { + if size > size_limit { + err!("Attachment storage limit exceeded with this file"); + } } let file_id = match &attachment { @@ -1727,38 +1300,30 @@ async fn save_attachment( save_temp_file(&PathType::Attachments, &format!("{cipher_id}/{file_id}"), data.data, true).await?; - // With the v2 API, post_attachment_v2() already did this when it created the attachment record - if attachment.is_none() { - notify_attachment_created(&cipher, headers, &conn, &nt).await; - } - - Ok((cipher, conn)) -} - -/// Sends the cipher update to the clients and logs the attachment event -async fn notify_attachment_created(cipher: &Cipher, headers: &Headers, conn: &DbConn, nt: &Notify<'_>) { nt.send_cipher_update( UpdateType::SyncCipherUpdate, - cipher, - &cipher.update_users_revision(conn).await, + &cipher, + &cipher.update_users_revision(&conn).await, &headers.device, None, - conn, + &conn, ) .await; if let Some(org_id) = &cipher.organization_uuid { log_event( - EventType::CipherAttachmentCreated, + EventType::CipherAttachmentCreated as i32, &cipher.uuid, org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, - conn, + &conn, ) .await; } + + Ok((cipher, conn)) } /// v2 API for uploading the actual data content of an attachment. @@ -1780,26 +1345,11 @@ async fn post_attachment_v2_data( None => err!("Attachment doesn't exist"), }; - // This request does not tell whether it is administrative, the member's own membership decides - let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else { - err!("Cipher doesn't exist") - }; - let scope = cipher_scope_for_member(&cipher, &headers.user.uuid, &conn).await; - - save_attachment(attachment, cipher_id, data, &headers, scope, conn, nt).await?; + save_attachment(attachment, cipher_id, data, &headers, conn, nt).await?; Ok(()) } -/// The access for a route on `cipher` which does not tell which one it is. -async fn cipher_scope_for_member(cipher: &Cipher, user_id: &UserId, conn: &DbConn) -> CipherAccessScope { - let Some(org_id) = cipher.organization_uuid.as_ref() else { - return CipherAccessScope::User; - }; - - CipherAccessScope::for_member(Membership::find_confirmed_by_user_and_org(user_id, org_id, conn).await.as_ref()) -} - /// Legacy API for creating an attachment associated with a cipher. #[post("/ciphers//attachment", format = "multipart/form-data", data = "")] async fn post_attachment( @@ -1809,7 +1359,13 @@ async fn post_attachment( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - post_attachment_impl(cipher_id, data, headers, CipherAccessScope::User, conn, nt).await + // Setting this as None signifies to save_attachment() that it should create + // the attachment database record as well as saving the data to disk. + let attachment = None; + + let (cipher, conn) = save_attachment(attachment, cipher_id, data, &headers, conn, nt).await?; + + Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?)) } #[post("/ciphers//attachment-admin", format = "multipart/form-data", data = "")] @@ -1820,24 +1376,7 @@ async fn post_attachment_admin( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - post_attachment_impl(cipher_id, data, headers, CipherAccessScope::OrganizationAdmin, conn, nt).await -} - -async fn post_attachment_impl( - cipher_id: CipherId, - data: Form>, - headers: Headers, - scope: CipherAccessScope, - conn: DbConn, - nt: Notify<'_>, -) -> JsonResult { - // Setting this as None signifies to save_attachment() that it should create - // the attachment database record as well as saving the data to disk. - let attachment = None; - - let (cipher, conn) = save_attachment(attachment, cipher_id, data, &headers, scope, conn, nt).await?; - - Ok(Json(cipher_json_for_scope(&cipher, &headers, scope, &conn).await?)) + post_attachment(cipher_id, data, headers, conn, nt).await } #[post("/ciphers//attachment//share", format = "multipart/form-data", data = "")] @@ -1849,7 +1388,7 @@ async fn post_attachment_share( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - delete_cipher_attachment_by_id(&cipher_id, &attachment_id, &headers, CipherAccessScope::User, &conn, &nt).await?; + _delete_cipher_attachment_by_id(&cipher_id, &attachment_id, &headers, &conn, &nt).await?; post_attachment(cipher_id, data, headers, conn, nt).await } @@ -1861,15 +1400,7 @@ async fn delete_attachment_post_admin( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - delete_cipher_attachment_by_id( - &cipher_id, - &attachment_id, - &headers, - CipherAccessScope::OrganizationAdmin, - &conn, - &nt, - ) - .await + delete_attachment(cipher_id, attachment_id, headers, conn, nt).await } #[post("/ciphers//attachment//delete")] @@ -1891,7 +1422,7 @@ async fn delete_attachment( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - delete_cipher_attachment_by_id(&cipher_id, &attachment_id, &headers, CipherAccessScope::User, &conn, &nt).await + _delete_cipher_attachment_by_id(&cipher_id, &attachment_id, &headers, &conn, &nt).await } #[delete("/ciphers//attachment//admin")] @@ -1902,77 +1433,42 @@ async fn delete_attachment_admin( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - delete_cipher_attachment_by_id( - &cipher_id, - &attachment_id, - &headers, - CipherAccessScope::OrganizationAdmin, - &conn, - &nt, - ) - .await + _delete_cipher_attachment_by_id(&cipher_id, &attachment_id, &headers, &conn, &nt).await } #[post("/ciphers//delete")] async fn delete_cipher_post(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { - delete_cipher_by_uuid(&cipher_id, &headers, CipherAccessScope::User, &conn, &CipherDeleteOptions::HardSingle, &nt) - .await + _delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::HardSingle, &nt).await // permanent delete } #[post("/ciphers//delete-admin")] async fn delete_cipher_post_admin(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { - delete_cipher_by_uuid( - &cipher_id, - &headers, - CipherAccessScope::OrganizationAdmin, - &conn, - &CipherDeleteOptions::HardSingle, - &nt, - ) - .await + _delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::HardSingle, &nt).await // permanent delete } #[put("/ciphers//delete")] async fn delete_cipher_put(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { - delete_cipher_by_uuid(&cipher_id, &headers, CipherAccessScope::User, &conn, &CipherDeleteOptions::SoftSingle, &nt) - .await + _delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::SoftSingle, &nt).await // soft delete } #[put("/ciphers//delete-admin")] async fn delete_cipher_put_admin(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { - delete_cipher_by_uuid( - &cipher_id, - &headers, - CipherAccessScope::OrganizationAdmin, - &conn, - &CipherDeleteOptions::SoftSingle, - &nt, - ) - .await + _delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::SoftSingle, &nt).await // soft delete } #[delete("/ciphers/")] async fn delete_cipher(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { - delete_cipher_by_uuid(&cipher_id, &headers, CipherAccessScope::User, &conn, &CipherDeleteOptions::HardSingle, &nt) - .await + _delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::HardSingle, &nt).await // permanent delete } #[delete("/ciphers//admin")] async fn delete_cipher_admin(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { - delete_cipher_by_uuid( - &cipher_id, - &headers, - CipherAccessScope::OrganizationAdmin, - &conn, - &CipherDeleteOptions::HardSingle, - &nt, - ) - .await + _delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::HardSingle, &nt).await // permanent delete } @@ -1983,7 +1479,7 @@ async fn delete_cipher_selected( conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { - delete_multiple_ciphers(data, headers, CipherAccessScope::User, conn, CipherDeleteOptions::HardMulti, nt).await + _delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await // permanent delete } @@ -1994,7 +1490,7 @@ async fn delete_cipher_selected_post( conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { - delete_multiple_ciphers(data, headers, CipherAccessScope::User, conn, CipherDeleteOptions::HardMulti, nt).await + _delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await // permanent delete } @@ -2005,7 +1501,7 @@ async fn delete_cipher_selected_put( conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { - delete_multiple_ciphers(data, headers, CipherAccessScope::User, conn, CipherDeleteOptions::SoftMulti, nt).await + _delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::SoftMulti, nt).await // soft delete } @@ -2016,15 +1512,7 @@ async fn delete_cipher_selected_admin( conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { - delete_multiple_ciphers( - data, - headers, - CipherAccessScope::OrganizationAdmin, - conn, - CipherDeleteOptions::HardMulti, - nt, - ) - .await + _delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await // permanent delete } @@ -2035,15 +1523,7 @@ async fn delete_cipher_selected_post_admin( conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { - delete_multiple_ciphers( - data, - headers, - CipherAccessScope::OrganizationAdmin, - conn, - CipherDeleteOptions::HardMulti, - nt, - ) - .await + _delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await // permanent delete } @@ -2054,26 +1534,18 @@ async fn delete_cipher_selected_put_admin( conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { - delete_multiple_ciphers( - data, - headers, - CipherAccessScope::OrganizationAdmin, - conn, - CipherDeleteOptions::SoftMulti, - nt, - ) - .await + _delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::SoftMulti, nt).await // soft delete } #[put("/ciphers//restore")] async fn restore_cipher_put(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { - restore_cipher_by_uuid(&cipher_id, &headers, false, CipherAccessScope::User, &conn, &nt).await + _restore_cipher_by_uuid(&cipher_id, &headers, false, &conn, &nt).await } #[put("/ciphers//restore-admin")] async fn restore_cipher_put_admin(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { - restore_cipher_by_uuid(&cipher_id, &headers, false, CipherAccessScope::OrganizationAdmin, &conn, &nt).await + _restore_cipher_by_uuid(&cipher_id, &headers, false, &conn, &nt).await } #[put("/ciphers/restore-admin", data = "")] @@ -2083,7 +1555,7 @@ async fn restore_cipher_selected_admin( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - restore_multiple_ciphers(data, &headers, CipherAccessScope::OrganizationAdmin, &conn, &nt).await + _restore_multiple_ciphers(data, &headers, &conn, &nt).await } #[put("/ciphers/restore", data = "")] @@ -2093,7 +1565,7 @@ async fn restore_cipher_selected( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - restore_multiple_ciphers(data, &headers, CipherAccessScope::User, &conn, &nt).await + _restore_multiple_ciphers(data, &headers, &conn, &nt).await } #[derive(Deserialize)] @@ -2114,10 +1586,10 @@ async fn move_cipher_selected( let data = data.into_inner(); let user_id = &headers.user.uuid; - if let Some(ref folder_id) = data.folder_id - && Folder::find_by_uuid_and_user(folder_id, user_id, &conn).await.is_none() - { - err!("Invalid folder", "Folder does not exist or belongs to another user"); + if let Some(ref folder_id) = data.folder_id { + if Folder::find_by_uuid_and_user(folder_id, user_id, &conn).await.is_none() { + err!("Invalid folder", "Folder does not exist or belongs to another user"); + } } let cipher_count = data.ids.len(); @@ -2127,22 +1599,12 @@ async fn move_cipher_selected( // Find all ciphers a user has access to, all others will be ignored let accessible_ciphers = Cipher::find_by_user_and_ciphers(user_id, &data.ids, &conn).await; let accessible_ciphers_count = accessible_ciphers.len(); - let mut move_result = Ok(()); for cipher in accessible_ciphers { - move_result = cipher.move_to_folder_without_revision_update(data.folder_id.clone(), user_id, &conn).await; - if move_result.is_err() { - break; - } + cipher.move_to_folder(data.folder_id.clone(), user_id, &conn).await?; if cipher_count == 1 { single_cipher = Some(cipher); } } - // Update the revision once for the whole batch. Also when a move failed halfway, - // since the ciphers moved before it are already changed. - if accessible_ciphers_count > 0 { - User::update_uuid_revision(user_id, &conn).await; - } - move_result?; if let Some(cipher) = single_cipher { nt.send_cipher_update( @@ -2156,7 +1618,7 @@ async fn move_cipher_selected( .await; } else { // Multi move actions do not send out a push for each cipher, we need to send a general sync here - nt.send_user_update(UpdateType::SyncCiphers, &headers.user, headers.device.push_uuid.as_ref(), &conn).await; + nt.send_user_update(UpdateType::SyncCiphers, &headers.user, &headers.device.push_uuid, &conn).await; } if cipher_count != accessible_ciphers_count { @@ -2208,10 +1670,10 @@ async fn purge_org_vault( match Membership::find_confirmed_by_user_and_org(&user.uuid, &organization.org_id, &conn).await { Some(member) if member.atype == MembershipType::Owner => { Cipher::delete_all_by_organization(&organization.org_id, &conn).await?; - nt.send_user_update(UpdateType::SyncVault, &user, headers.device.push_uuid.as_ref(), &conn).await; + nt.send_user_update(UpdateType::SyncVault, &user, &headers.device.push_uuid, &conn).await; log_event( - EventType::OrganizationPurgedVault, + EventType::OrganizationPurgedVault as i32, &organization.org_id, &organization.org_id, &user.uuid, @@ -2248,41 +1710,11 @@ async fn purge_personal_vault( } user.update_revision(&conn).await?; - nt.send_user_update(UpdateType::SyncVault, &user, headers.device.push_uuid.as_ref(), &conn).await; + nt.send_user_update(UpdateType::SyncVault, &user, &headers.device.push_uuid, &conn).await; Ok(()) } -#[put("/ciphers//archive")] -async fn archive_cipher_put(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { - archive_cipher(&cipher_id, &headers, false, &conn, &nt).await -} - -#[put("/ciphers/archive", data = "")] -async fn archive_cipher_selected( - data: Json, - headers: Headers, - conn: DbConn, - nt: Notify<'_>, -) -> JsonResult { - archive_multiple_ciphers(data, &headers, &conn, &nt).await -} - -#[put("/ciphers//unarchive")] -async fn unarchive_cipher_put(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { - unarchive_cipher(&cipher_id, &headers, false, &conn, &nt).await -} - -#[put("/ciphers/unarchive", data = "")] -async fn unarchive_cipher_selected( - data: Json, - headers: Headers, - conn: DbConn, - nt: Notify<'_>, -) -> JsonResult { - unarchive_multiple_ciphers(data, &headers, &conn, &nt).await -} - #[derive(PartialEq)] pub enum CipherDeleteOptions { SoftSingle, @@ -2291,10 +1723,9 @@ pub enum CipherDeleteOptions { HardMulti, } -async fn delete_cipher_by_uuid( +async fn _delete_cipher_by_uuid( cipher_id: &CipherId, headers: &Headers, - scope: CipherAccessScope, conn: &DbConn, delete_options: &CipherDeleteOptions, nt: &Notify<'_>, @@ -2303,7 +1734,7 @@ async fn delete_cipher_by_uuid( err!("Cipher doesn't exist") }; - if !cipher.is_write_accessible_to_user(&headers.user.uuid, scope, conn).await { + if !cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await { err!("Cipher can't be deleted by user") } @@ -2340,9 +1771,9 @@ async fn delete_cipher_by_uuid( let event_type = if *delete_options == CipherDeleteOptions::SoftSingle || *delete_options == CipherDeleteOptions::SoftMulti { - EventType::CipherSoftDeleted + EventType::CipherSoftDeleted as i32 } else { - EventType::CipherDeleted + EventType::CipherDeleted as i32 }; log_event(event_type, &cipher.uuid, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, conn) @@ -2355,13 +1786,12 @@ async fn delete_cipher_by_uuid( #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct CipherIdsData { - ids: HashSet, + ids: Vec, } -async fn delete_multiple_ciphers( +async fn _delete_multiple_ciphers( data: Json, headers: Headers, - scope: CipherAccessScope, conn: DbConn, delete_options: CipherDeleteOptions, nt: Notify<'_>, @@ -2369,22 +1799,21 @@ async fn delete_multiple_ciphers( let data = data.into_inner(); for cipher_id in data.ids { - if let error @ Err(_) = delete_cipher_by_uuid(&cipher_id, &headers, scope, &conn, &delete_options, &nt).await { + if let error @ Err(_) = _delete_cipher_by_uuid(&cipher_id, &headers, &conn, &delete_options, &nt).await { return error; - } + }; } // Multi delete actions do not send out a push for each cipher, we need to send a general sync here - nt.send_user_update(UpdateType::SyncCiphers, &headers.user, headers.device.push_uuid.as_ref(), &conn).await; + nt.send_user_update(UpdateType::SyncCiphers, &headers.user, &headers.device.push_uuid, &conn).await; Ok(()) } -async fn restore_cipher_by_uuid( +async fn _restore_cipher_by_uuid( cipher_id: &CipherId, headers: &Headers, multi_restore: bool, - scope: CipherAccessScope, conn: &DbConn, nt: &Notify<'_>, ) -> JsonResult { @@ -2392,7 +1821,7 @@ async fn restore_cipher_by_uuid( err!("Cipher doesn't exist") }; - if !cipher.is_write_accessible_to_user(&headers.user.uuid, scope, conn).await { + if !cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await { err!("Cipher can't be restored by user") } @@ -2413,7 +1842,7 @@ async fn restore_cipher_by_uuid( if let Some(org_id) = &cipher.organization_uuid { log_event( - EventType::CipherRestored, + EventType::CipherRestored as i32, &cipher.uuid.clone(), org_id, &headers.user.uuid, @@ -2424,13 +1853,12 @@ async fn restore_cipher_by_uuid( .await; } - Ok(Json(cipher_json_for_scope(&cipher, headers, scope, conn).await?)) + Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await?)) } -async fn restore_multiple_ciphers( +async fn _restore_multiple_ciphers( data: Json, headers: &Headers, - scope: CipherAccessScope, conn: &DbConn, nt: &Notify<'_>, ) -> JsonResult { @@ -2438,14 +1866,14 @@ async fn restore_multiple_ciphers( let mut ciphers: Vec = Vec::new(); for cipher_id in data.ids { - match restore_cipher_by_uuid(&cipher_id, headers, true, scope, conn, nt).await { + match _restore_cipher_by_uuid(&cipher_id, headers, true, conn, nt).await { Ok(json) => ciphers.push(json.into_inner()), err => return err, } } // Multi move actions do not send out a push for each cipher, we need to send a general sync here - nt.send_user_update(UpdateType::SyncCiphers, &headers.user, headers.device.push_uuid.as_ref(), conn).await; + nt.send_user_update(UpdateType::SyncCiphers, &headers.user, &headers.device.push_uuid, conn).await; Ok(Json(json!({ "data": ciphers, @@ -2454,11 +1882,10 @@ async fn restore_multiple_ciphers( }))) } -async fn delete_cipher_attachment_by_id( +async fn _delete_cipher_attachment_by_id( cipher_id: &CipherId, attachment_id: &AttachmentId, headers: &Headers, - scope: CipherAccessScope, conn: &DbConn, nt: &Notify<'_>, ) -> JsonResult { @@ -2474,7 +1901,7 @@ async fn delete_cipher_attachment_by_id( err!("Cipher doesn't exist") }; - if !cipher.is_write_accessible_to_user(&headers.user.uuid, scope, conn).await { + if !cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await { err!("Cipher cannot be deleted by user") } @@ -2492,7 +1919,7 @@ async fn delete_cipher_attachment_by_id( if let Some(ref org_id) = cipher.organization_uuid { log_event( - EventType::CipherAttachmentDeleted, + EventType::CipherAttachmentDeleted as i32, &cipher.uuid, org_id, &headers.user.uuid, @@ -2502,126 +1929,10 @@ async fn delete_cipher_attachment_by_id( ) .await; } - let cipher_json = cipher_json_for_scope(&cipher, headers, scope, conn).await?; + let cipher_json = cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await?; Ok(Json(json!({"cipher":cipher_json}))) } -async fn archive_cipher( - cipher_id: &CipherId, - headers: &Headers, - multi_archive: bool, - conn: &DbConn, - nt: &Notify<'_>, -) -> JsonResult { - let Some(cipher) = Cipher::find_by_uuid(cipher_id, conn).await else { - err!("Cipher doesn't exist") - }; - - if !cipher.is_accessible_to_user(&headers.user.uuid, CipherAccessScope::User, conn).await { - err!("Cipher is not accessible for the current user") - } - - cipher.set_archived_at(Utc::now().naive_utc(), &headers.user.uuid, conn).await?; - - if !multi_archive { - nt.send_cipher_update( - UpdateType::SyncCipherUpdate, - &cipher, - &cipher.update_users_revision(conn).await, - &headers.device, - None, - conn, - ) - .await; - } - - Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await?)) -} - -async fn unarchive_cipher( - cipher_id: &CipherId, - headers: &Headers, - multi_unarchive: bool, - conn: &DbConn, - nt: &Notify<'_>, -) -> JsonResult { - let Some(cipher) = Cipher::find_by_uuid(cipher_id, conn).await else { - err!("Cipher doesn't exist") - }; - - if !cipher.is_accessible_to_user(&headers.user.uuid, CipherAccessScope::User, conn).await { - err!("Cipher is not accessible for the current user") - } - - cipher.unarchive(&headers.user.uuid, conn).await?; - - if !multi_unarchive { - nt.send_cipher_update( - UpdateType::SyncCipherUpdate, - &cipher, - &cipher.update_users_revision(conn).await, - &headers.device, - None, - conn, - ) - .await; - } - - Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await?)) -} - -async fn archive_multiple_ciphers( - data: Json, - headers: &Headers, - conn: &DbConn, - nt: &Notify<'_>, -) -> JsonResult { - let data = data.into_inner(); - - let mut ciphers: Vec = Vec::new(); - for cipher_id in data.ids { - match archive_cipher(&cipher_id, headers, true, conn, nt).await { - Ok(json) => ciphers.push(json.into_inner()), - err => return err, - } - } - - // Multi archive does not send out a push for each cipher, we need to send a general sync here - nt.send_user_update(UpdateType::SyncCiphers, &headers.user, headers.device.push_uuid.as_ref(), conn).await; - - Ok(Json(json!({ - "data": ciphers, - "object": "list", - "continuationToken": null - }))) -} - -async fn unarchive_multiple_ciphers( - data: Json, - headers: &Headers, - conn: &DbConn, - nt: &Notify<'_>, -) -> JsonResult { - let data = data.into_inner(); - - let mut ciphers: Vec = Vec::new(); - for cipher_id in data.ids { - match unarchive_cipher(&cipher_id, headers, true, conn, nt).await { - Ok(json) => ciphers.push(json.into_inner()), - err => return err, - } - } - - // Multi unarchive does not send out a push for each cipher, we need to send a general sync here - nt.send_user_update(UpdateType::SyncCiphers, &headers.user, headers.device.push_uuid.as_ref(), conn).await; - - Ok(Json(json!({ - "data": ciphers, - "object": "list", - "continuationToken": null - }))) -} - /// This will hold all the necessary data to improve a full sync of all the ciphers /// It can be used during the `Cipher::to_json()` call. /// It will prevent the so called N+1 SQL issue by running just a few queries which will hold all the data needed. @@ -2631,7 +1942,6 @@ pub struct CipherSyncData { pub cipher_folders: HashMap, pub cipher_favorites: HashSet, pub cipher_collections: HashMap>, - pub cipher_archives: HashMap, pub members: HashMap, pub user_collections: HashMap, pub user_collections_groups: HashMap, @@ -2648,37 +1958,25 @@ impl CipherSyncData { pub async fn new(user_id: &UserId, sync_type: CipherSyncType, conn: &DbConn) -> Self { let cipher_folders: HashMap; let cipher_favorites: HashSet; - let cipher_archives: HashMap; match sync_type { - // User Sync supports Folders, Favorites, and Archives + // User Sync supports Folders and Favorites CipherSyncType::User => { // Generate a HashMap with the Cipher UUID as key and the Folder UUID as value cipher_folders = FolderCipher::find_by_user(user_id, conn).await.into_iter().collect(); // Generate a HashSet of all the Cipher UUID's which are marked as favorite cipher_favorites = Favorite::get_all_cipher_uuid_by_user(user_id, conn).await.into_iter().collect(); - - // Generate a HashMap with the Cipher UUID as key and the archived date time as value - cipher_archives = Archive::find_by_user(user_id, conn).await.into_iter().collect(); } - // Organization Sync does not support Folders, Favorites, or Archives. + // Organization Sync does not support Folders and Favorites. // If these are set, it will cause issues in the web-vault. CipherSyncType::Organization => { - cipher_folders = HashMap::new(); - cipher_favorites = HashSet::new(); - cipher_archives = HashMap::new(); + cipher_folders = HashMap::with_capacity(0); + cipher_favorites = HashSet::with_capacity(0); } } - // Generate a HashMap with the Organization UUID as key and the Membership record - let members: HashMap = Membership::find_confirmed_by_user(user_id, conn) - .await - .into_iter() - .map(|m| (m.org_uuid.clone(), m)) - .collect(); - // Generate a list of Cipher UUID's containing a Vec with one or more Attachment records - let orgs: Vec = members.keys().cloned().collect(); + let orgs = Membership::get_orgs_by_user(user_id, conn).await; let attachments = Attachment::find_all_by_user_and_orgs(user_id, &orgs, conn).await; let mut cipher_attachments: HashMap> = HashMap::with_capacity(attachments.len()); for attachment in attachments { @@ -2693,6 +1991,13 @@ impl CipherSyncData { cipher_collections.entry(cipher).or_default().push(collection); } + // Generate a HashMap with the Organization UUID as key and the Membership record + let members: HashMap = Membership::find_confirmed_by_user(user_id, conn) + .await + .into_iter() + .map(|m| (m.org_uuid.clone(), m)) + .collect(); + // Generate a HashMap with the User_Collections UUID as key and the CollectionUser record let user_collections: HashMap = CollectionUser::find_by_user(user_id, conn) .await @@ -2733,7 +2038,6 @@ impl CipherSyncData { cipher_folders, cipher_favorites, cipher_collections, - cipher_archives, members, user_collections, user_collections_groups, @@ -2741,146 +2045,3 @@ impl CipherSyncData { } } } - -#[cfg(all(test, sqlite))] -mod custom_role_tests { - use super::*; - use crate::{ - api::notifications::{WS_USERS, WebSocketUsers}, - auth::ClientIp, - db::{ - models::{CustomPermission, Device, User}, - test_db, - }, - }; - use chrono::Datelike; - use diesel::connection::SimpleConnection; - use std::sync::Arc; - - #[test] - fn bulk_share_preserves_vault_scope_collection_ids_and_user_revisions() { - test_db::run(async |db| { - let conn = db.conn(); - let (org, user, mut member) = test_db::custom_member(&conn, CustomPermission::EditAnyCollection).await; - let (reader, _) = test_db::member(&conn, &org.uuid, MembershipType::User, &[]).await; - let collection = test_db::collection(&conn, &org.uuid).await; - let hidden = test_db::collection(&conn, &org.uuid).await; - CollectionUser::save(&reader.uuid, &collection.uuid, false, false, false, &conn).await.unwrap(); - let mut ids = Vec::new(); - for _ in 0..2 { - let mut cipher = Cipher::new(2); - cipher.name = "Personal".into(); - cipher.user_uuid = Some(user.uuid.clone()); - cipher.save(&conn).await.unwrap(); - ids.push(cipher.uuid); - } - let rocket = rocket::build().manage(Arc::clone(&WS_USERS)); - let notify = rocket.state::>().unwrap(); - for assigned in [false, true] { - if assigned { - CollectionUser::save(&user.uuid, &collection.uuid, false, false, true, &conn).await.unwrap(); - } - conn.run(|db| db.batch_execute("UPDATE users SET updated_at='2000-01-01 00:00:00'")).await.unwrap(); - let ciphers = ids - .iter() - .map(|id| { - serde_json::from_value(json!({ - "id": id, "name": "Shared", "type": 2, "organizationId": org.uuid, - "encryptedFor": user.uuid, "secureNote": {"type": 0} - })) - .unwrap() - }) - .collect(); - let headers = Headers { - host: "http://localhost".into(), - device: Device::new("test".to_owned().into(), user.uuid.clone(), "test".into(), 1), - user: User::find_by_uuid(&user.uuid, &conn).await.unwrap(), - ip: ClientIp { - ip: "127.0.0.1".parse().unwrap(), - }, - }; - let result = put_cipher_share_selected( - Json(ShareSelectedCipherData { - ciphers, - collection_ids: vec![collection.uuid.clone()], - }), - headers, - db.conn(), - notify.into(), - ) - .await; - assert_eq!(result.is_ok(), assigned); - if assigned { - let response = result.unwrap().into_inner(); - assert_eq!(response["object"], "list"); - let ciphers = response["data"].as_array().unwrap(); - assert_eq!(ciphers.len(), ids.len()); - for cipher in ciphers { - assert_eq!(cipher["object"], "cipherMini"); - assert_eq!(cipher["organizationId"], json!(org.uuid)); - assert!(cipher.get("collectionIds").is_none()); - assert!(cipher.get("edit").is_none()); - } - } - for id in &ids { - let cipher = Cipher::find_by_uuid(id, &conn).await.unwrap(); - assert_eq!(cipher.organization_uuid.as_ref(), assigned.then_some(&org.uuid)); - assert_eq!(cipher.user_uuid.as_ref(), (!assigned).then_some(&user.uuid)); - } - let revision = User::find_by_uuid(&reader.uuid, &conn).await.unwrap().updated_at; - assert_eq!(revision.year() > 2000, assigned); - } - let mut cipher = Cipher::find_by_uuid(&ids[0], &conn).await.unwrap(); - CollectionCipher::save(&cipher.uuid, &hidden.uuid, &conn).await.unwrap(); - let personal = - cipher.to_json("http://localhost", &user.uuid, None, CipherSyncType::User, &conn).await.unwrap(); - let admin = cipher.to_json_org_admin("http://localhost", &member.user_uuid, &conn).await.unwrap(); - assert_eq!(personal["collectionIds"], json!([collection.uuid])); - let admin_ids = admin["collectionIds"].as_array().unwrap(); - assert_eq!(admin_ids.len(), 1); - assert!(admin_ids.contains(&json!(collection.uuid))); - // Exercise PR2's SQL authorization independently of Custom permission support added in PR3. - let sql = format!("DELETE FROM users_collections WHERE user_uuid='{}'", user.uuid); - conn.run(move |c| c.batch_execute(&sql)).await.unwrap(); - let foreign = test_db::organization(&conn).await; - let admin_scope = CipherAccessScope::requested(Some(true)); - assert_eq!(admin_scope, CipherAccessScope::OrganizationAdmin); - assert_eq!(CipherAccessScope::for_member(None), CipherAccessScope::User); - for request in [None, Some(false)] { - assert_eq!(CipherAccessScope::requested(request), CipherAccessScope::User); - } - for (role, status, all, allowed) in [ - (MembershipType::User, 2, false, false), - (MembershipType::Custom, 2, false, false), - (MembershipType::Manager, 2, false, false), - (MembershipType::Manager, 2, true, true), - (MembershipType::Owner, 2, false, true), - (MembershipType::Admin, 2, false, true), - (MembershipType::Admin, 1, false, false), - (MembershipType::Admin, -126, false, false), - ] { - member.set_type(role); - member.status = status; - member.access_all = all; - member.save(&conn).await.unwrap(); - assert_eq!( - CipherAccessScope::for_member(Some(&member)), - if allowed { - admin_scope - } else { - CipherAccessScope::User - } - ); - for org_id in [&org.uuid, &foreign.uuid] { - cipher.organization_uuid = Some(org_id.clone()); - for scope in [CipherAccessScope::User, admin_scope] { - assert_eq!( - cipher.is_write_accessible_to_user(&user.uuid, scope, &conn).await, - allowed && org_id == &org.uuid - ); - } - } - } - }); - } -} diff --git a/src/api/core/emergency_access.rs b/src/api/core/emergency_access.rs index 773697e5..29a15c8d 100644 --- a/src/api/core/emergency_access.rs +++ b/src/api/core/emergency_access.rs @@ -1,26 +1,23 @@ -use chrono::Utc; -use rocket::{Route, serde::json::Json}; +use chrono::{TimeDelta, Utc}; +use rocket::{serde::json::Json, Route}; use serde_json::Value; use crate::{ - CONFIG, api::{ + core::{CipherSyncData, CipherSyncType}, EmptyResult, JsonResult, - core::{ - CipherSyncData, CipherSyncType, - accounts::{AuthenticationData, UnlockData}, - }, }, - auth::{Headers, decode_emergency_access_invite}, + auth::{decode_emergency_access_invite, Headers}, db::{ - DbConn, DbPool, models::{ - Cipher, EMERGENCY_ACCESS_TIMEOUT_RANGE, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, - EmergencyAccessType, Invitation, Membership, MembershipType, OrgPolicy, TwoFactor, User, UserId, + Cipher, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType, Invitation, + Membership, MembershipType, OrgPolicy, TwoFactor, User, UserId, }, + DbConn, DbPool, }, mail, util::NumberOrString, + CONFIG, }; pub fn routes() -> Vec { @@ -58,7 +55,7 @@ async fn get_contacts(headers: Headers, conn: DbConn) -> Json { let mut emergency_access_list_json = Vec::with_capacity(emergency_access_list.len()); for ea in emergency_access_list { if let Some(grantee) = ea.to_json_grantee_details(&conn).await { - emergency_access_list_json.push(grantee); + emergency_access_list_json.push(grantee) } } @@ -92,14 +89,11 @@ async fn get_grantees(headers: Headers, conn: DbConn) -> Json { async fn get_emergency_access(emer_id: EmergencyAccessId, headers: Headers, conn: DbConn) -> JsonResult { check_emergency_access_enabled()?; - if let Some(emergency_access) = - EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await - { - Ok(Json( + match EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await { + Some(emergency_access) => Ok(Json( emergency_access.to_json_grantee_details(&conn).await.expect("Grantee user should exist but does not!"), - )) - } else { - err!("Emergency access not valid.") + )), + None => err!("Emergency access not valid."), } } @@ -136,21 +130,18 @@ async fn post_emergency_access( let data: EmergencyAccessUpdateData = data.into_inner(); - let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) else { - err!("Invalid emergency access type.") - }; - - if !(EMERGENCY_ACCESS_TIMEOUT_RANGE).contains(&data.wait_time_days) { - err!("Emergency access wait time is out-of-bounds!") - } - let Some(mut emergency_access) = EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await else { err!("Emergency access not valid.") }; - emergency_access.atype = new_type as i32; + let new_type = match EmergencyAccessType::from_str(&data.r#type.into_string()) { + Some(new_type) => new_type as i32, + None => err!("Invalid emergency access type."), + }; + + emergency_access.atype = new_type; emergency_access.wait_time_days = data.wait_time_days; if data.key_encrypted.is_some() { emergency_access.key_encrypted = data.key_encrypted; @@ -212,12 +203,11 @@ async fn send_invite(data: Json, headers: Headers, co let email = data.email.to_lowercase(); let wait_time_days = data.wait_time_days; - if !(EMERGENCY_ACCESS_TIMEOUT_RANGE).contains(&wait_time_days) { - err!("Emergency access wait time is out-of-bounds!") - } + let emergency_access_status = EmergencyAccessStatus::Invited as i32; - let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) else { - err!("Invalid emergency access type.") + let new_type = match EmergencyAccessType::from_str(&data.r#type.into_string()) { + Some(new_type) => new_type as i32, + None => err!("Invalid emergency access type."), }; let grantor_user = headers.user; @@ -230,7 +220,7 @@ async fn send_invite(data: Json, headers: Headers, co let (grantee_user, new_user) = match User::find_by_mail(&email, &conn).await { None => { if !CONFIG.invitations_allowed() { - err!(format!("Grantee user does not exist: {}", email.escape_debug())) + err!(format!("Grantee user does not exist: {email}")) } if !CONFIG.is_email_domain_allowed(&email) { @@ -262,8 +252,6 @@ async fn send_invite(data: Json, headers: Headers, co err!(format!("Grantee user already invited: {}", &grantee_user.email)) } - let new_type = new_type as i32; - let emergency_access_status = EmergencyAccessStatus::Invited as i32; let mut new_emergency_access = EmergencyAccess::new(grantor_user.uuid, grantee_user.email, emergency_access_status, new_type, wait_time_days); new_emergency_access.save(&conn).await?; @@ -296,7 +284,7 @@ async fn resend_invite(emer_id: EmergencyAccessId, headers: Headers, conn: DbCon }; if emergency_access.status != EmergencyAccessStatus::Invited as i32 { - err!("The grantee user is already accepted or confirmed for emergency access"); + err!("The grantee user is already accepted or confirmed to the organization"); } let Some(email) = emergency_access.email.clone() else { @@ -354,11 +342,12 @@ async fn accept_invite( err!("Claim email does not match current users email") } - let grantee_user = if let Some(user) = User::find_by_mail(&claims.email, &conn).await { - Invitation::take(&claims.email, &conn).await; - user - } else { - err!("Invited user not found") + let grantee_user = match User::find_by_mail(&claims.email, &conn).await { + Some(user) => { + Invitation::take(&claims.email, &conn).await; + user + } + None => err!("Invited user not found"), }; // We need to search for the uuid in combination with the email, since we do not yet store the uuid of the grantee in the database. @@ -457,24 +446,11 @@ async fn initiate_emergency_access(emer_id: EmergencyAccessId, headers: Headers, let Some(mut emergency_access) = EmergencyAccess::find_by_uuid_and_grantee_uuid(&emer_id, &initiating_user.uuid, &conn).await else { - err!( - "Emergency access not valid.", - format!("Emergency access '{emer_id}', not found for user '{}'.", initiating_user.email) - ) + err!("Emergency access not valid.") }; if emergency_access.status != EmergencyAccessStatus::Confirmed as i32 { - err!( - "Emergency access not valid.", - format!("Emergency access '{emer_id}', not confirmed for user '{}'.", initiating_user.email) - ) - } - - if !EMERGENCY_ACCESS_TIMEOUT_RANGE.contains(&emergency_access.wait_time_days) { - err!( - "Emergency access not valid.", - format!("Emergency access '{emer_id}' timeout range '{}' is invalid", emergency_access.wait_time_days) - ) + err!("Emergency access not valid.") } let Some(grantor_user) = User::find_by_uuid(&emergency_access.grantor_uuid, &conn).await else { @@ -507,17 +483,11 @@ async fn approve_emergency_access(emer_id: EmergencyAccessId, headers: Headers, let Some(mut emergency_access) = EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await else { - err!( - "Emergency access not valid.", - format!("Emergency access '{emer_id}', not found for user '{}'.", headers.user.email) - ) + err!("Emergency access not valid.") }; if emergency_access.status != EmergencyAccessStatus::RecoveryInitiated as i32 { - err!( - "Emergency access not valid.", - format!("Emergency access '{emer_id}', not initiated for user '{}'.", headers.user.email) - ) + err!("Emergency access not valid.") } let Some(grantor_user) = User::find_by_uuid(&headers.user.uuid, &conn).await else { @@ -641,7 +611,6 @@ async fn takeover_emergency_access(emer_id: EmergencyAccessId, headers: Headers, "kdfMemory": grantor_user.client_kdf_memory, "kdfParallelism": grantor_user.client_kdf_parallelism, "keyEncrypted": &emergency_access.key_encrypted, - "salt": grantor_user.master_password_salt(), "object": "emergencyAccessTakeover", }); @@ -651,13 +620,8 @@ async fn takeover_emergency_access(emer_id: EmergencyAccessId, headers: Headers, #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct EmergencyAccessPasswordData { - // Legacy payload - new_master_password_hash: Option, - key: Option, - - // Current payload - authentication_data: Option, - unlock_data: Option, + new_master_password_hash: String, + key: String, } #[post("/emergency-access//password", data = "")] @@ -670,6 +634,8 @@ async fn password_emergency_access( check_emergency_access_enabled()?; let data: EmergencyAccessPasswordData = data.into_inner(); + let new_master_password_hash = &data.new_master_password_hash; + //let key = &data.Key; let requesting_user = headers.user; let Some(emergency_access) = @@ -686,23 +652,8 @@ async fn password_emergency_access( err!("Grantor user not found.") }; - let (new_master_password_hash, new_key) = - if let (Some(authentication_data), Some(unlock_data)) = (data.authentication_data, data.unlock_data) { - authentication_data.check(&grantor_user, &unlock_data)?; - - if !authentication_data.kdf.matches_user(&grantor_user) { - err!("KDF settings do not match the grantor account") - } - - (authentication_data.master_password_authentication_hash, unlock_data.master_key_wrapped_user_key) - } else if let (Some(new_master_password_hash), Some(new_key)) = (data.new_master_password_hash, data.key) { - (new_master_password_hash, new_key) - } else { - err!("Invalid request!") - }; - // change grantor_user password - grantor_user.set_password(&new_master_password_hash, Some(new_key), true, None, &conn).await?; + grantor_user.set_password(new_master_password_hash, Some(data.key), true, None, &conn).await?; grantor_user.save(&conn).await?; // Disable TwoFactor providers since they will otherwise block logins @@ -770,71 +721,52 @@ pub async fn emergency_request_timeout_job(pool: DbPool) { return; } - let Ok(conn) = pool.get().await else { - error!("Failed to get DB connection for emergency time-outs!"); - return; - }; + if let Ok(conn) = pool.get().await { + let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await; - let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await; - - if emergency_access_list.is_empty() { - debug!("No emergency request timeout to approve"); - return; - } - - let now = Utc::now().naive_utc(); - for mut emer in emergency_access_list { - match emer.is_recovery_allowed(&now) { - Ok(true) => {} // Recovery is allowed - Ok(false) => continue, - Err(e) => { - error!("Failed to check if recovery is allowed for '{}': {e}", emer.uuid); - continue; - } + if emergency_access_list.is_empty() { + debug!("No emergency request timeout to approve"); } - // Get grantor user to send Accepted email - let Some(grantor_user) = User::find_by_uuid(&emer.grantor_uuid, &conn).await else { - error!("Grantor user '{}' not found for emergency access '{}'", emer.grantor_uuid, emer.uuid); - continue; - }; + let now = Utc::now().naive_utc(); + for mut emer in emergency_access_list { + // The find_all_recoveries_initiated already checks if the recovery_initiated_at is not null (None) + let recovery_allowed_at = + emer.recovery_initiated_at.unwrap() + TimeDelta::try_days(i64::from(emer.wait_time_days)).unwrap(); + if recovery_allowed_at.le(&now) { + // Only update the access status + // Updating the whole record could cause issues when the emergency_notification_reminder_job is also active + emer.update_access_status_and_save(EmergencyAccessStatus::RecoveryApproved as i32, &now, &conn) + .await + .expect("Unable to update emergency access status"); - let Some(grantee_uuid) = emer.grantee_uuid.as_ref() else { - error!("Missing grantee for emergency access '{}'", emer.uuid); - continue; - }; + if CONFIG.mail_enabled() { + // get grantor user to send Accepted email + let grantor_user = + User::find_by_uuid(&emer.grantor_uuid, &conn).await.expect("Grantor user not found"); - // Get grantee user to send Accepted email - let Some(grantee_user) = User::find_by_uuid(grantee_uuid, &conn).await else { - error!("Grantee user '{grantee_uuid}' not found for emergency access '{}'", emer.uuid); - continue; - }; + // get grantee user to send Accepted email + let grantee_user = + User::find_by_uuid(&emer.grantee_uuid.clone().expect("Grantee user invalid"), &conn) + .await + .expect("Grantee user not found"); - // Only update the access status - // Updating the whole record could cause issues when the emergency_notification_reminder_job is also active - let status_initiated = EmergencyAccessStatus::RecoveryInitiated as i32; - let status_approved = EmergencyAccessStatus::RecoveryApproved as i32; - if let Err(e) = emer.update_access_status_and_save(status_initiated, status_approved, &now, &conn).await { - error!("Unable to update emergency access status for '{}': {e}", emer.uuid); - continue; - } + mail::send_emergency_access_recovery_timed_out( + &grantor_user.email, + &grantee_user.name, + emer.get_type_as_str(), + ) + .await + .expect("Error on sending email"); - if CONFIG.mail_enabled() { - if let Err(e) = mail::send_emergency_access_recovery_timed_out( - &grantor_user.email, - &grantee_user.name, - emer.get_type_as_str(), - ) - .await - { - error!("Failed to send emergency access recovery time-out email for '{}': {e}", emer.uuid); - } - - if let Err(e) = mail::send_emergency_access_recovery_approved(&grantee_user.email, &grantor_user.name).await - { - error!("Failed to send emergency access recovery approved email for '{}': {e}", emer.uuid); + mail::send_emergency_access_recovery_approved(&grantee_user.email, &grantor_user.name) + .await + .expect("Error on sending email"); + } } } + } else { + error!("Failed to get DB connection while searching emergency request timed out") } } @@ -844,85 +776,55 @@ pub async fn emergency_notification_reminder_job(pool: DbPool) { return; } - let Ok(conn) = pool.get().await else { - error!("Failed to get DB connection for emergency reminders!"); - return; - }; + if let Ok(conn) = pool.get().await { + let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await; - let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await; - - if emergency_access_list.is_empty() { - debug!("No emergency request reminder notification to send"); - return; - } - - let now = Utc::now().naive_utc(); - for mut emer in emergency_access_list { - let Ok(deadline_at) = emer.deadline_at() else { - error!("Invalid deadline for emergency access '{}'", emer.uuid); - continue; - }; - - let Some(final_reminder_at) = deadline_at.checked_sub_days(chrono::Days::new(1)) else { - error!("Cannot calculate reminder date for emergency access '{}'", emer.uuid); - continue; - }; - - // Only send reminders during the last 24 hours, else continue - if now < final_reminder_at || now >= deadline_at { - continue; + if emergency_access_list.is_empty() { + debug!("No emergency request reminder notification to send"); } - // Check if we did not sent a recovery mail in the past 24h already, else skip it. - // In case something goes wrong with the date calculation or `last_notification_at` is None we use now. - // This is to prevent possible missed reminders, this might cause an extra reminder, - // but that is better then no reminder at all. - let next_reminder_at = if let Some(last_notification_at) = emer.last_notification_at { - last_notification_at.checked_add_days(chrono::Days::new(1)).unwrap_or(now) - } else { - now - }; + let now = Utc::now().naive_utc(); + for mut emer in emergency_access_list { + // The find_all_recoveries_initiated already checks if the recovery_initiated_at is not null (None) + // Calculate the day before the recovery will become active + let final_recovery_reminder_at = + emer.recovery_initiated_at.unwrap() + TimeDelta::try_days(i64::from(emer.wait_time_days - 1)).unwrap(); + // Calculate if a day has passed since the previous notification, else no notification has been sent before + let next_recovery_reminder_at = if let Some(last_notification_at) = emer.last_notification_at { + last_notification_at + TimeDelta::try_days(1).unwrap() + } else { + now + }; + if final_recovery_reminder_at.le(&now) && next_recovery_reminder_at.le(&now) { + // Only update the last notification date + // Updating the whole record could cause issues when the emergency_request_timeout_job is also active + emer.update_last_notification_date_and_save(&now, &conn) + .await + .expect("Unable to update emergency access notification date"); - // Only send a reminder if the `next_reminder_at` is less then or equal to `now` - // Else we can skip the current emergency access this run. - if next_reminder_at.gt(&now) { - continue; - } - - // Get grantor user to send Reminder email - let Some(grantor_user) = User::find_by_uuid(&emer.grantor_uuid, &conn).await else { - error!("Grantor user '{}' not found for emergency access '{}'", emer.grantor_uuid, emer.uuid); - continue; - }; - - let Some(grantee_uuid) = emer.grantee_uuid.as_ref() else { - error!("Missing grantee for emergency access '{}'", emer.uuid); - continue; - }; - - // Get grantee user to send Reminder email - let Some(grantee_user) = User::find_by_uuid(grantee_uuid, &conn).await else { - error!("Grantee user '{grantee_uuid}' not found for emergency access '{}'", emer.uuid); - continue; - }; - - // Only update the last notification date - // Updating the whole record could cause issues when the emergency_request_timeout_job is also active - if let Err(e) = emer.update_last_notification_date_and_save(&now, &conn).await { - error!("Unable to update emergency access reminder date for '{}': {e}", emer.uuid); - continue; - } - - if CONFIG.mail_enabled() - && let Err(e) = mail::send_emergency_access_recovery_reminder( - &grantor_user.email, - &grantee_user.name, - emer.get_type_as_str(), - "1", // This notification is only triggered one day before the activation - ) - .await - { - error!("Failed to send emergency access reminder email for '{}': {e}", emer.uuid); + if CONFIG.mail_enabled() { + // get grantor user to send Accepted email + let grantor_user = + User::find_by_uuid(&emer.grantor_uuid, &conn).await.expect("Grantor user not found"); + + // get grantee user to send Accepted email + let grantee_user = + User::find_by_uuid(&emer.grantee_uuid.clone().expect("Grantee user invalid"), &conn) + .await + .expect("Grantee user not found"); + + mail::send_emergency_access_recovery_reminder( + &grantor_user.email, + &grantee_user.name, + emer.get_type_as_str(), + "1", // This notification is only triggered one day before the activation + ) + .await + .expect("Error on sending email"); + } + } } + } else { + error!("Failed to get DB connection while searching emergency notification reminder") } } diff --git a/src/api/core/events.rs b/src/api/core/events.rs index a256d854..d1612255 100644 --- a/src/api/core/events.rs +++ b/src/api/core/events.rs @@ -1,21 +1,18 @@ use std::net::IpAddr; use chrono::NaiveDateTime; -use rocket::{Route, form::FromForm, serde::json::Json}; +use rocket::{form::FromForm, serde::json::Json, Route}; use serde_json::Value; use crate::{ - CONFIG, - api::{ApiResult, EmptyResult, JsonResult}, + api::{EmptyResult, JsonResult}, auth::{AdminHeaders, Headers}, db::{ + models::{Cipher, CipherId, Event, Membership, MembershipId, OrganizationId, UserId}, DbConn, DbPool, - models::{ - Cipher, CipherAccessScope, CipherId, Event, EventType, Membership, MembershipId, OrganizationId, UserId, - }, }, - error::MapResult, util::parse_date, + CONFIG, }; /// ############################################################################################################### @@ -32,14 +29,6 @@ struct EventRange { continuation_token: Option, } -impl EventRange { - fn date_range(&self) -> ApiResult<(NaiveDateTime, NaiveDateTime)> { - let start_date = parse_date(&self.start).map_res("Invalid start date")?; - let end_date = parse_date(self.continuation_token.as_ref().unwrap_or(&self.end)).map_res("Invalid end date")?; - Ok((start_date, end_date)) - } -} - // Upstream: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Controllers/EventsController.cs#L87 #[get("/organizations//events?")] async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: AdminHeaders, conn: DbConn) -> JsonResult { @@ -49,16 +38,21 @@ async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: Admin // Return an empty vec when we org events are disabled. // This prevents client errors - let events_json: Vec = if CONFIG.org_events_enabled() { - let (start_date, end_date) = data.date_range()?; + let events_json: Vec = if !CONFIG.org_events_enabled() { + Vec::with_capacity(0) + } else { + let start_date = parse_date(&data.start); + let end_date = if let Some(before_date) = &data.continuation_token { + parse_date(before_date) + } else { + parse_date(&data.end) + }; Event::find_by_organization_uuid(&org_id, &start_date, &end_date, &conn) .await .iter() - .map(Event::to_json) + .map(|e| e.to_json()) .collect() - } else { - Vec::new() }; Ok(Json(json!({ @@ -70,16 +64,27 @@ async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: Admin #[get("/ciphers//events?")] async fn get_cipher_events(cipher_id: CipherId, data: EventRange, headers: Headers, conn: DbConn) -> JsonResult { - // Return an empty vec when org events are disabled. + // Return an empty vec when we org events are disabled. // This prevents client errors - let events_json: Vec = if CONFIG.org_events_enabled() - && Membership::user_has_ge_admin_access_to_cipher(&headers.user.uuid, &cipher_id, &conn).await - { - let (start_date, end_date) = data.date_range()?; - - Event::find_by_cipher_uuid(&cipher_id, &start_date, &end_date, &conn).await.iter().map(Event::to_json).collect() + let events_json: Vec = if !CONFIG.org_events_enabled() { + Vec::with_capacity(0) } else { - Vec::new() + let mut events_json = Vec::with_capacity(0); + if Membership::user_has_ge_admin_access_to_cipher(&headers.user.uuid, &cipher_id, &conn).await { + let start_date = parse_date(&data.start); + let end_date = if let Some(before_date) = &data.continuation_token { + parse_date(before_date) + } else { + parse_date(&data.end) + }; + + events_json = Event::find_by_cipher_uuid(&cipher_id, &start_date, &end_date, &conn) + .await + .iter() + .map(|e| e.to_json()) + .collect() + } + events_json }; Ok(Json(json!({ @@ -102,16 +107,21 @@ async fn get_user_events( } // Return an empty vec when we org events are disabled. // This prevents client errors - let events_json: Vec = if CONFIG.org_events_enabled() { - let (start_date, end_date) = data.date_range()?; + let events_json: Vec = if !CONFIG.org_events_enabled() { + Vec::with_capacity(0) + } else { + let start_date = parse_date(&data.start); + let end_date = if let Some(before_date) = &data.continuation_token { + parse_date(before_date) + } else { + parse_date(&data.end) + }; Event::find_by_org_and_member(&org_id, &member_id, &start_date, &end_date, &conn) .await .iter() - .map(Event::to_json) + .map(|e| e.to_json()) .collect() - } else { - Vec::new() }; Ok(Json(json!({ @@ -124,8 +134,7 @@ async fn get_user_events( fn get_continuation_token(events_json: &[Value]) -> Option<&str> { // When the length of the vec equals the max page_size there probably is more data // When it is less, then all events are loaded. - #[expect(clippy::cast_possible_truncation, reason = "PAGE_SIZE fits within usize")] - if events_json.len() == Event::PAGE_SIZE as usize { + if events_json.len() as i64 == Event::PAGE_SIZE { if let Some(last_event) = events_json.last() { last_event["date"].as_str() } else { @@ -164,11 +173,10 @@ async fn post_events_collect(data: Json>, headers: Headers, } for event in data.iter() { - 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. + let event_date = parse_date(&event.date); match event.r#type { - t if t == EventType::UserClientExportedVault as i32 => { - log_user_event_impl( + 1000..=1099 => { + _log_user_event( event.r#type, &headers.user.uuid, headers.device.atype, @@ -178,12 +186,9 @@ async fn post_events_collect(data: Json>, headers: Headers, ) .await; } - t if t == EventType::OrganizationClientExportedVault as i32 => { - // Only allow logging events for an organization the user is actually a member of. - if let Some(org_id) = &event.organization_id - && Membership::find_confirmed_by_user_and_org(&headers.user.uuid, org_id, &conn).await.is_some() - { - log_event_impl( + 1600..=1699 => { + if let Some(org_id) = &event.organization_id { + _log_event( event.r#type, org_id, org_id, @@ -196,62 +201,38 @@ async fn post_events_collect(data: Json>, headers: Headers, .await; } } - // Only the vault notification banner click is accepted from clients. The rest of - // the 1500..=1599 range is written server-side and must not be forgeable by a client. - t if t == EventType::OrganizationUserNotificationBannerActionClicked as i32 => { - if let Some(org_id) = &event.organization_id - && let Some(membership) = - Membership::find_confirmed_by_user_and_org(&headers.user.uuid, org_id, &conn).await - { - log_event_impl( - event.r#type, - &membership.uuid, - org_id, - &headers.user.uuid, - headers.device.atype, - Some(event_date), - &headers.ip.ip, - &conn, - ) - .await; + _ => { + if let Some(cipher_uuid) = &event.cipher_id { + if let Some(cipher) = Cipher::find_by_uuid(cipher_uuid, &conn).await { + if let Some(org_id) = cipher.organization_uuid { + _log_event( + event.r#type, + cipher_uuid, + &org_id, + &headers.user.uuid, + headers.device.atype, + Some(event_date), + &headers.ip.ip, + &conn, + ) + .await; + } + } } } - // Client-side cipher events (viewed, copied, toggled, autofilled, HTTP auth). - 1107..=1114 | 1117 | 1119..=1133 => { - // The cipher determines the organization the event is logged to, so make sure the - // user can actually access it instead of trusting the provided cipher uuid. - if let Some(cipher_uuid) = &event.cipher_id - && let Some(cipher) = Cipher::find_by_uuid(cipher_uuid, &conn).await - && cipher.is_accessible_to_user(&headers.user.uuid, CipherAccessScope::User, &conn).await - && let Some(org_id) = cipher.organization_uuid - { - log_event_impl( - event.r#type, - cipher_uuid, - &org_id, - &headers.user.uuid, - headers.device.atype, - Some(event_date), - &headers.ip.ip, - &conn, - ) - .await; - } - } - _ => {} } } Ok(()) } -pub async fn log_user_event(event_type: EventType, user_id: &UserId, device_type: i32, ip: &IpAddr, conn: &DbConn) { +pub async fn log_user_event(event_type: i32, user_id: &UserId, device_type: i32, ip: &IpAddr, conn: &DbConn) { if !CONFIG.org_events_enabled() { return; } - log_user_event_impl(event_type as i32, user_id, device_type, None, ip, conn).await; + _log_user_event(event_type, user_id, device_type, None, ip, conn).await; } -async fn log_user_event_impl( +async fn _log_user_event( event_type: i32, user_id: &UserId, device_type: i32, @@ -286,7 +267,7 @@ async fn log_user_event_impl( } pub async fn log_event( - event_type: EventType, + event_type: i32, source_uuid: &str, org_id: &OrganizationId, act_user_id: &UserId, @@ -297,11 +278,11 @@ pub async fn log_event( if !CONFIG.org_events_enabled() { return; } - log_event_impl(event_type as i32, source_uuid, org_id, act_user_id, device_type, None, ip, conn).await; + _log_event(event_type, source_uuid, org_id, act_user_id, device_type, None, ip, conn).await; } -#[expect(clippy::too_many_arguments)] -async fn log_event_impl( +#[allow(clippy::too_many_arguments)] +async fn _log_event( event_type: i32, source_uuid: &str, org_id: &OrganizationId, @@ -317,24 +298,24 @@ async fn log_event_impl( // 1000..=1099 Are user events, they need to be logged via log_user_event() // Cipher Events 1100..=1199 => { - event.cipher_uuid = Some(source_uuid.to_owned().into()); + event.cipher_uuid = Some(source_uuid.to_string().into()); } // Collection Events 1300..=1399 => { - event.collection_uuid = Some(source_uuid.to_owned().into()); + event.collection_uuid = Some(source_uuid.to_string().into()); } // Group Events 1400..=1499 => { - event.group_uuid = Some(source_uuid.to_owned().into()); + event.group_uuid = Some(source_uuid.to_string().into()); } // Org User Events 1500..=1599 => { - event.org_user_uuid = Some(source_uuid.to_owned().into()); + event.org_user_uuid = Some(source_uuid.to_string().into()); } // 1600..=1699 Are organizational events, and they do not need the source_uuid // Policy Events 1700..=1799 => { - event.policy_uuid = Some(source_uuid.to_owned().into()); + event.policy_uuid = Some(source_uuid.to_string().into()); } // Ignore others _ => {} @@ -349,17 +330,14 @@ async fn log_event_impl( pub async fn event_cleanup_job(pool: DbPool) { debug!("Start events cleanup job"); - if CONFIG.events_days_retain().is_none_or(|d| d == 0) { - debug!("events_days_retain is not configured or set to 0, skipping"); + if CONFIG.events_days_retain().is_none() { + debug!("events_days_retain is not configured, abort"); return; } - let Ok(conn) = pool.get().await else { - error!("Failed to get DB connection while trying to cleanup the events table"); - return; - }; - - if let Err(e) = Event::clean_events(&conn).await { - error!("Failed to cleanup the events table: {e}"); + if let Ok(conn) = pool.get().await { + Event::clean_events(&conn).await.ok(); + } else { + error!("Failed to get DB connection while trying to cleanup the events table") } } diff --git a/src/api/core/folders.rs b/src/api/core/folders.rs index 8c930093..1b3fd714 100644 --- a/src/api/core/folders.rs +++ b/src/api/core/folders.rs @@ -5,8 +5,8 @@ use crate::{ api::{EmptyResult, JsonResult, Notify, UpdateType}, auth::Headers, db::{ - DbConn, models::{Folder, FolderId}, + DbConn, }, util::deser_opt_nonempty_str, }; @@ -29,10 +29,9 @@ async fn get_folders(headers: Headers, conn: DbConn) -> Json { #[get("/folders/")] async fn get_folder(folder_id: FolderId, headers: Headers, conn: DbConn) -> JsonResult { - if let Some(folder) = Folder::find_by_uuid_and_user(&folder_id, &headers.user.uuid, &conn).await { - Ok(Json(folder.to_json())) - } else { - err!("Invalid folder", "Folder does not exist or belongs to another user") + match Folder::find_by_uuid_and_user(&folder_id, &headers.user.uuid, &conn).await { + Some(folder) => Ok(Json(folder.to_json())), + _ => err!("Invalid folder", "Folder does not exist or belongs to another user"), } } diff --git a/src/api/core/mod.rs b/src/api/core/mod.rs index b7923982..038b9a6d 100644 --- a/src/api/core/mod.rs +++ b/src/api/core/mod.rs @@ -1,6 +1,4 @@ pub mod accounts; -pub mod two_factor; - mod ciphers; mod emergency_access; mod events; @@ -8,32 +6,17 @@ mod folders; mod organizations; mod public; mod sends; +pub mod two_factor; pub use accounts::purge_auth_requests; -pub use ciphers::{CipherData, CipherSyncData, CipherSyncType, purge_trashed_ciphers}; +pub use ciphers::{purge_trashed_ciphers, CipherData, CipherSyncData, CipherSyncType}; pub use emergency_access::{emergency_notification_reminder_job, emergency_request_timeout_job}; pub use events::{event_cleanup_job, log_event, log_user_event}; +use reqwest::Method; pub use sends::purge_sends; -use reqwest::Method; -use rocket::{Catcher, Route, serde::json::Json, serde::json::Value}; - -use crate::{ - CONFIG, - api::{EmptyResult, JsonResult, Notify, UpdateType}, - auth::Headers, - db::{ - DbConn, - models::{Membership, MembershipStatus, OrgPolicy, Organization, User}, - }, - error::Error, - http_client::make_http_request, - mail, - util::{FeatureFlagFilter, parse_experimental_client_feature_flags}, -}; - pub fn routes() -> Vec { - let mut eq_domains_routes = routes![get_settings_domains, post_settings_domains, put_settings_domains]; + let mut eq_domains_routes = routes![get_eq_domains, post_eq_domains, put_eq_domains]; let mut hibp_routes = routes![hibp_breach]; let mut meta_routes = routes![alive, now, version, config, get_api_webauthn]; @@ -61,6 +44,25 @@ pub fn events_routes() -> Vec { routes } +// +// Move this somewhere else +// +use rocket::{serde::json::Json, serde::json::Value, Catcher, Route}; + +use crate::{ + api::{EmptyResult, JsonResult, Notify, UpdateType}, + auth::Headers, + db::{ + models::{Membership, MembershipStatus, OrgPolicy, Organization, User}, + DbConn, + }, + error::Error, + http_client::make_http_request, + mail, + util::{parse_experimental_client_feature_flags, FeatureFlagFilter}, + CONFIG, +}; + #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct GlobalDomain { @@ -71,16 +73,14 @@ struct GlobalDomain { const GLOBAL_DOMAINS: &str = include_str!("../../static/global_domains.json"); -#[expect(clippy::needless_pass_by_value, reason = "Not beneficial for Headers")] #[get("/settings/domains")] -fn get_settings_domains(headers: Headers) -> Json { - get_eq_domains(&headers, false) +fn get_eq_domains(headers: Headers) -> Json { + _get_eq_domains(&headers, false) } -fn get_eq_domains(headers: &Headers, no_excluded: bool) -> Json { - use serde_json::from_str; - +fn _get_eq_domains(headers: &Headers, no_excluded: bool) -> Json { let user = &headers.user; + use serde_json::from_str; let equivalent_domains: Vec> = from_str(&user.equivalent_domains).unwrap(); let excluded_globals: Vec = from_str(&user.excluded_globals).unwrap(); @@ -110,39 +110,28 @@ struct EquivDomainData { } #[post("/settings/domains", data = "")] -async fn post_settings_domains( - data: Json, - headers: Headers, - conn: DbConn, - nt: Notify<'_>, -) -> JsonResult { - use serde_json::to_string; - +async fn post_eq_domains(data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { let data: EquivDomainData = data.into_inner(); let excluded_globals = data.excluded_global_equivalent_domains.unwrap_or_default(); let equivalent_domains = data.equivalent_domains.unwrap_or_default(); let mut user = headers.user; + use serde_json::to_string; - user.excluded_globals = to_string(&excluded_globals).unwrap_or_else(|_| "[]".to_owned()); - user.equivalent_domains = to_string(&equivalent_domains).unwrap_or_else(|_| "[]".to_owned()); + user.excluded_globals = to_string(&excluded_globals).unwrap_or_else(|_| "[]".to_string()); + user.equivalent_domains = to_string(&equivalent_domains).unwrap_or_else(|_| "[]".to_string()); user.save(&conn).await?; - nt.send_user_update(UpdateType::SyncSettings, &user, headers.device.push_uuid.as_ref(), &conn).await; + nt.send_user_update(UpdateType::SyncSettings, &user, &headers.device.push_uuid, &conn).await; Ok(Json(json!({}))) } #[put("/settings/domains", data = "")] -async fn put_settings_domains( - data: Json, - headers: Headers, - conn: DbConn, - nt: Notify<'_>, -) -> JsonResult { - post_settings_domains(data, headers, conn, nt).await +async fn put_eq_domains(data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { + post_eq_domains(data, headers, conn, nt).await } #[get("/hibp/breach?")] @@ -215,11 +204,11 @@ fn config() -> Json { // Client (v2026.2.1): https://github.com/bitwarden/clients/blob/f96380c3138291a028bdd2c7a5fee540d5c98ba5/libs/common/src/enums/feature-flag.enum.ts#L12 // 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 - let mut feature_states = parse_experimental_client_feature_flags( + let feature_states = parse_experimental_client_feature_flags( &CONFIG.experimental_client_feature_flags(), - &FeatureFlagFilter::ValidOnly, + FeatureFlagFilter::ValidOnly, ); - feature_states.insert("pm-19148-innovation-archive".to_owned(), true); + // Add default feature_states here if needed, currently no features are needed by default. Json(json!({ // Note: The clients use this version to handle backwards compatibility concerns @@ -228,17 +217,14 @@ fn config() -> Json { // Version history: // - Individual cipher key encryption: 2024.2.0 // - Mobile app support for MasterPasswordUnlockData: 2025.8.0 - "version": "2026.6.0", + "version": "2025.12.0", "gitHash": option_env!("GIT_REV"), "server": { "name": "Vaultwarden", "url": "https://github.com/dani-garcia/vaultwarden" }, "settings": { - "disableUserRegistration": CONFIG.is_signup_disabled(), - // When enabled, this setting signals to clients that onboarding interstitials - // (post-login welcome dialogs, extension install prompts, setup extension redirects, and premium upsell modals) should be suppressed - "suppressOnboardingInterstitials": CONFIG.client_suppress_onboarding(), + "disableUserRegistration": CONFIG.is_signup_disabled() }, "environment": { "vault": domain, @@ -247,7 +233,6 @@ fn config() -> Json { "notifications": format!("{domain}/notifications"), "sso": "", "cloudRegion": null, - "fillAssistRules": CONFIG.fill_assist_rules_url(), }, // Bitwarden uses this for the self-hosted servers to indicate the default push technology "push": { @@ -255,10 +240,6 @@ fn config() -> Json { "vapidPublicKey": null }, "featureStates": feature_states, - // Not supported right now - // Used for by clients to learn if the server requires extra work to establish a connection. - // See: https://github.com/bitwarden/server/pull/6892 | https://github.com/bitwarden/server/commit/52955d1860b4dfb905f67bbe39d9b10bbd61ded0 - "communication": null, "object": "config", })) } @@ -297,8 +278,9 @@ async fn accept_org_invite( member.save(conn).await?; if CONFIG.mail_enabled() { - let Some(org) = Organization::find_by_uuid(&member.org_uuid, conn).await else { - err!("Organization not found.") + let org = match Organization::find_by_uuid(&member.org_uuid, conn).await { + Some(org) => org, + None => err!("Organization not found."), }; // User was invited to an organization, so they must be confirmed manually after acceptance mail::send_invite_accepted(&user.email, &member.invited_by_email.unwrap_or(org.billing_email), &org.name) diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 2460ae93..254f60b4 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -1,39 +1,35 @@ +use num_traits::FromPrimitive; +use rocket::serde::json::Json; +use rocket::Route; +use serde_json::Value; use std::collections::{HashMap, HashSet}; -use num_traits::FromPrimitive; -use rocket::{Route, http::Status, serde::json::Json}; -use serde_json::Value; - +use crate::api::admin::FAKE_ADMIN_UUID; use crate::{ - CONFIG, - api::admin::FAKE_ADMIN_UUID, api::{ - ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, - core::{CipherSyncData, CipherSyncType, accept_org_invite, log_event, two_factor}, + core::{accept_org_invite, log_event, two_factor, CipherSyncData, CipherSyncType}, + EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, }, - auth::{AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, decode_invite}, + auth::{decode_invite, AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders}, db::{ - DbConn, models::{ - Cipher, CipherAccessScope, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, - CollectionUser, CustomPermission, Device, EventType, FillAssistPolicyData, Group, GroupId, GroupUser, - Invitation, Membership, MembershipId, MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType, - Organization, OrganizationApiKey, OrganizationId, TwoFactor, TwoFactorType, User, UserId, - custom_roles_enabled, + Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, EventType, + Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus, MembershipType, + OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, User, UserId, }, + DbConn, }, mail, - sso::FAKE_SSO_IDENTIFIER, - util::{NumberOrString, convert_json_key_lcase_first}, + util::{convert_json_key_lcase_first, get_uuid, NumberOrString}, + CONFIG, }; -use super::accounts::{AuthenticationData, UnlockData}; - pub fn routes() -> Vec { routes![ get_organization, create_organization, delete_organization, + post_delete_organization, leave_organization, get_user_collections, get_org_collections, @@ -41,14 +37,16 @@ pub fn routes() -> Vec { get_org_collection_detail, get_collection_users, put_organization, + post_organization, post_organization_collections, post_bulk_access_collections, + post_organization_collection_update, put_organization_collection_update, delete_organization_collection, + post_organization_collection_delete, bulk_delete_organization_collections, post_bulk_collections, get_org_details, - get_assigned_org_details, get_org_domain_sso_verified, get_members, send_invite, @@ -60,15 +58,16 @@ pub fn routes() -> Vec { get_org_user_mini_details, get_user, edit_member, + put_member, delete_member, bulk_delete_member, post_org_import, list_policies, list_policies_token, - get_dummy_master_password_policy, get_master_password_policy, get_policy, put_policy, + put_policy_vnext, get_plans, post_org_keys, get_organization_keys, @@ -77,26 +76,29 @@ pub fn routes() -> Vec { revoke_member, bulk_revoke_members, restore_member, - restore_member_vnext, bulk_restore_members, get_groups, get_groups_details, post_groups, get_group, put_group, + post_group, get_group_details, delete_group, + post_delete_group, bulk_delete_groups, get_group_members, + put_group_members, + post_delete_group_member, put_reset_password_enrollment, get_reset_password_details, - put_recover_account, + put_reset_password, get_org_export, - post_api_key, + api_key, rotate_api_key, + get_billing_metadata, get_billing_warnings, get_auto_enroll_status, - get_self_host_billing_metadata, ] } @@ -125,6 +127,7 @@ struct FullCollectionData { name: String, groups: Vec, users: Vec, + id: Option, external_id: Option, } @@ -133,16 +136,13 @@ impl FullCollectionData { let org_groups = Group::find_by_organization(org_id, conn).await; let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|c| &c.uuid).collect(); if let Some(e) = self.groups.iter().find(|g| !org_group_ids.contains(&g.id)) { - err!("Invalid group", format!("Group {} does not belong to organization {}!", e.id.escape_debug(), org_id)) + err!("Invalid group", format!("Group {} does not belong to organization {}!", e.id, org_id)) } 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) = self.users.iter().find(|m| !org_membership_ids.contains(&m.id)) { - err!( - "Invalid member", - format!("Member {} does not belong to organization {}!", e.id.escape_debug(), org_id) - ) + err!("Invalid member", format!("Member {} does not belong to organization {}!", e.id, org_id)) } Ok(()) @@ -240,6 +240,16 @@ async fn delete_organization( } } +#[post("/organizations//delete", data = "")] +async fn post_delete_organization( + org_id: OrganizationId, + data: Json, + headers: OwnerHeaders, + conn: DbConn, +) -> EmptyResult { + delete_organization(org_id, data, headers, conn).await +} + #[post("/organizations//leave")] async fn leave_organization(org_id: OrganizationId, headers: OrgMemberHeaders, conn: DbConn) -> EmptyResult { if headers.membership.status != MembershipStatus::Confirmed as i32 { @@ -254,7 +264,7 @@ async fn leave_organization(org_id: OrganizationId, headers: OrgMemberHeaders, c } log_event( - EventType::OrganizationUserLeft, + EventType::OrganizationUserLeft as i32, &membership.uuid, &org_id, &headers.user.uuid, @@ -272,10 +282,9 @@ async fn get_organization(org_id: OrganizationId, headers: OwnerHeaders, conn: D if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } - if let Some(organization) = Organization::find_by_uuid(&org_id, &conn).await { - Ok(Json(organization.to_json())) - } else { - err!("Can't find organization details") + match Organization::find_by_uuid(&org_id, &conn).await { + Some(organization) => Ok(Json(organization.to_json())), + None => err!("Can't find organization details"), } } @@ -285,6 +294,16 @@ async fn put_organization( headers: OwnerHeaders, data: Json, conn: DbConn, +) -> JsonResult { + post_organization(org_id, headers, data, conn).await +} + +#[post("/organizations/", data = "")] +async fn post_organization( + org_id: OrganizationId, + headers: OwnerHeaders, + data: Json, + conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); @@ -302,7 +321,7 @@ async fn put_organization( org.save(&conn).await?; log_event( - EventType::OrganizationUpdated, + EventType::OrganizationUpdated as i32, org_id.as_ref(), &org_id, &headers.user.uuid, @@ -334,7 +353,7 @@ async fn get_user_collections(headers: Headers, conn: DbConn) -> Json { // The returned `Id` will then be passed to `get_master_password_policy` which will mainly ignore it #[get("/organizations//auto-enroll-status")] async fn get_auto_enroll_status(identifier: &str, headers: Headers, conn: DbConn) -> JsonResult { - let org = if identifier == FAKE_SSO_IDENTIFIER { + let org = if identifier == crate::sso::FAKE_IDENTIFIER { match Membership::find_main_user_org(&headers.user.uuid, &conn).await { Some(member) => Organization::find_by_uuid(&member.org_uuid, &conn).await, None => None, @@ -344,7 +363,7 @@ async fn get_auto_enroll_status(identifier: &str, headers: Headers, conn: DbConn }; let (id, identifier, rp_auto_enroll) = match org { - None => (identifier.to_owned(), identifier.to_owned(), false), + None => (get_uuid(), identifier.to_string(), false), Some(org) => ( org.uuid.to_string(), org.uuid.to_string(), @@ -366,11 +385,11 @@ async fn get_org_collections(org_id: OrganizationId, headers: ManagerHeadersLoos } if !headers.membership.has_full_access() { - err_code!("Resource not found.", "User does not have full access", Status::NotFound.code); + err_code!("Resource not found.", "User does not have full access", rocket::http::Status::NotFound.code); } Ok(Json(json!({ - "data": get_org_collections_impl(&org_id, &conn).await, + "data": _get_org_collections(&org_id, &conn).await, "object": "list", "continuationToken": null, }))) @@ -390,9 +409,8 @@ async fn get_org_collections_details(org_id: OrganizationId, headers: ManagerHea let col_users = CollectionUser::find_by_organization_swap_user_uuid_with_member_uuid(&org_id, &conn).await; // Generate a HashMap to get the correct MembershipType per user to determine the manage permission // We use the uuid instead of the user_uuid here, since that is what is used in CollectionUser - // This lists other members for admins, so it must not depend on the membership status let membership_type: HashMap = - Membership::find_by_org(&org_id, &conn).await.into_iter().map(|m| (m.uuid, m.atype)).collect(); + Membership::find_confirmed_by_org(&org_id, &conn).await.into_iter().map(|m| (m.uuid, m.atype)).collect(); // check if current user has full access to the organization (either directly or via any group) let has_full_access_to_org = member.has_full_access() @@ -443,10 +461,10 @@ async fn get_org_collections_details(org_id: OrganizationId, headers: ManagerHea CollectionGroup::find_by_collection(&col.uuid, &conn) .await .iter() - .map(CollectionGroup::to_json_details_for_group) + .map(|collection_group| collection_group.to_json_details_for_group()) .collect() } else { - Vec::new() + Vec::with_capacity(0) }; let mut json_object = col.to_json_details(&headers.user.uuid, None, &conn).await; @@ -455,7 +473,7 @@ async fn get_org_collections_details(org_id: OrganizationId, headers: ManagerHea json_object["groups"] = json!(groups); json_object["object"] = json!("collectionAccessDetails"); json_object["unmanaged"] = json!(false); - data.push(json_object); + data.push(json_object) } Ok(Json(json!({ @@ -465,7 +483,7 @@ async fn get_org_collections_details(org_id: OrganizationId, headers: ManagerHea }))) } -async fn get_org_collections_impl(org_id: &OrganizationId, conn: &DbConn) -> Value { +async fn _get_org_collections(org_id: &OrganizationId, conn: &DbConn) -> Value { Collection::find_by_organization(org_id, conn).await.iter().map(Collection::to_json).collect::() } @@ -490,7 +508,7 @@ async fn post_organization_collections( collection.save(&conn).await?; log_event( - EventType::CollectionCreated, + EventType::CollectionCreated as i32, &collection.uuid, &org_id, &headers.user.uuid, @@ -551,14 +569,7 @@ async fn post_bulk_access_collections( if Organization::find_by_uuid(&org_id, &conn).await.is_none() { err!("Can't find organization details") - } - - // The collections and members are checked below, the groups only here. - let org_groups = Group::find_by_organization(&org_id, &conn).await; - let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|g| &g.uuid).collect(); - if let Some(g) = data.groups.iter().find(|g| !org_group_ids.contains(&g.id)) { - err!("Invalid group", format!("Group {} does not belong to organization {}!", g.id.escape_debug(), org_id)) - } + }; for col_id in data.collection_ids { let Some(collection) = Collection::find_by_uuid_and_org(&col_id, &org_id, &conn).await else { @@ -573,7 +584,7 @@ async fn post_bulk_access_collections( collection.save(&conn).await?; log_event( - EventType::CollectionUpdated, + EventType::CollectionUpdated as i32, &collection.uuid, &org_id, &headers.user.uuid, @@ -615,6 +626,17 @@ async fn put_organization_collection_update( headers: ManagerHeaders, data: Json, conn: DbConn, +) -> JsonResult { + post_organization_collection_update(org_id, col_id, headers, data, conn).await +} + +#[post("/organizations//collections/", data = "", rank = 2)] +async fn post_organization_collection_update( + org_id: OrganizationId, + col_id: CollectionId, + headers: ManagerHeaders, + data: Json, + conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); @@ -624,7 +646,7 @@ async fn put_organization_collection_update( if Organization::find_by_uuid(&org_id, &conn).await.is_none() { err!("Can't find organization details") - } + }; let Some(mut collection) = Collection::find_by_uuid_and_org(&col_id, &org_id, &conn).await else { err!("Collection not found") @@ -639,7 +661,7 @@ async fn put_organization_collection_update( collection.save(&conn).await?; log_event( - EventType::CollectionUpdated, + EventType::CollectionUpdated as i32, &collection.uuid, &org_id, &headers.user.uuid, @@ -675,7 +697,7 @@ async fn put_organization_collection_update( Ok(Json(collection.to_json_details(&headers.user.uuid, None, &conn).await)) } -async fn delete_organization_collection_impl( +async fn _delete_organization_collection( org_id: &OrganizationId, col_id: &CollectionId, headers: &ManagerHeaders, @@ -688,7 +710,7 @@ async fn delete_organization_collection_impl( err!("Collection not found", "Collection does not exist or does not belong to this organization") }; log_event( - EventType::CollectionDeleted, + EventType::CollectionDeleted as i32, &collection.uuid, org_id, &headers.user.uuid, @@ -707,7 +729,17 @@ async fn delete_organization_collection( headers: ManagerHeaders, conn: DbConn, ) -> EmptyResult { - delete_organization_collection_impl(&org_id, &col_id, &headers, &conn).await + _delete_organization_collection(&org_id, &col_id, &headers, &conn).await +} + +#[post("/organizations//collections//delete")] +async fn post_organization_collection_delete( + org_id: OrganizationId, + col_id: CollectionId, + headers: ManagerHeaders, + conn: DbConn, +) -> EmptyResult { + _delete_organization_collection(&org_id, &col_id, &headers, &conn).await } #[derive(Deserialize, Debug)] @@ -733,7 +765,7 @@ async fn bulk_delete_organization_collections( let headers = ManagerHeaders::from_loose(headers, &collections, &conn).await?; for col_id in collections { - delete_organization_collection_impl(&org_id, &col_id, &headers, &conn).await?; + _delete_organization_collection(&org_id, &col_id, &headers, &conn).await? } Ok(()) } @@ -763,19 +795,21 @@ async fn get_org_collection_detail( CollectionGroup::find_by_collection(&collection.uuid, &conn) .await .iter() - .map(CollectionGroup::to_json_details_for_group) + .map(|collection_group| collection_group.to_json_details_for_group()) .collect() } else { // The Bitwarden clients seem to call this API regardless of whether groups are enabled, // so just act as if there are no groups. - Vec::new() + Vec::with_capacity(0) }; // Generate a HashMap to get the correct MembershipType per user to determine the manage permission // We use the uuid instead of the user_uuid here, since that is what is used in CollectionUser - // This lists other members for admins, so it must not depend on the membership status - let membership_type: HashMap = - Membership::find_by_org(&org_id, &conn).await.into_iter().map(|m| (m.uuid, m.atype)).collect(); + let membership_type: HashMap = Membership::find_confirmed_by_org(&org_id, &conn) + .await + .into_iter() + .map(|m| (m.uuid, m.atype)) + .collect(); let users: Vec = CollectionUser::find_by_org_and_coll_swap_user_uuid_with_member_uuid(&org_id, &collection.uuid, &conn) @@ -840,33 +874,28 @@ struct OrgIdData { #[get("/ciphers/organization-details?")] async fn get_org_details(data: OrgIdData, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult { if data.organization_id != headers.membership.org_uuid { - err_code!("Resource not found.", "Organization id's do not match", Status::NotFound.code); + err_code!("Resource not found.", "Organization id's do not match", rocket::http::Status::NotFound.code); } - if !headers.membership.can_edit_any_collection() { - err_code!("Resource not found.", "User does not have full access", Status::NotFound.code); + if !headers.membership.has_full_access() { + err_code!("Resource not found.", "User does not have full access", rocket::http::Status::NotFound.code); } Ok(Json(json!({ - "data": get_org_details_impl(&data.organization_id, &headers.host, &headers.user.uuid, &conn).await?, + "data": _get_org_details(&data.organization_id, &headers.host, &headers.user.uuid, &conn).await?, "object": "list", "continuationToken": null, }))) } -async fn get_org_details_impl( +async fn _get_org_details( org_id: &OrganizationId, host: &str, user_id: &UserId, conn: &DbConn, ) -> Result { let ciphers = Cipher::find_by_org(org_id, conn).await; - let mut cipher_sync_data = CipherSyncData::new(user_id, CipherSyncType::Organization, conn).await; - // Every cipher of the organization is listed, so list every collection of them too - cipher_sync_data.cipher_collections = HashMap::new(); - for (cipher_id, collection_id) in Cipher::get_collections_with_cipher_by_organization(org_id, conn).await { - cipher_sync_data.cipher_collections.entry(cipher_id).or_default().push(collection_id); - } + let cipher_sync_data = CipherSyncData::new(user_id, CipherSyncType::Organization, conn).await; let mut ciphers_json = Vec::with_capacity(ciphers.len()); for c in ciphers { @@ -875,54 +904,36 @@ async fn get_org_details_impl( Ok(json!(ciphers_json)) } -// The Admin Console lists these instead of every cipher of the organization for a member who may not edit every -// cipher, like a Custom member with Delete any collection only. As in Bitwarden, any confirmed member may list them. -#[get("/ciphers/organization-details/assigned?")] -async fn get_assigned_org_details(data: OrgIdData, headers: Headers, conn: DbConn) -> JsonResult { - let Some(member) = - Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &data.organization_id, &conn).await - else { - err_code!("Resource not found.", "User is not a confirmed member of the organization", Status::NotFound.code); - }; - - let (assigned, unassigned) = Cipher::find_assigned_to_member(&member, &conn).await; - let mut ciphers_json = Vec::with_capacity(assigned.len() + unassigned.len()); - // The ciphers of the member's own vault, as they are there - let cipher_sync_data = CipherSyncData::new(&headers.user.uuid, CipherSyncType::User, &conn).await; - for cipher in assigned { - ciphers_json.push( - cipher - .to_json(&headers.host, &headers.user.uuid, Some(&cipher_sync_data), CipherSyncType::User, &conn) - .await?, - ); - } - // The ciphers in no collection, as the member may use them in the Admin Console - for cipher in unassigned { - ciphers_json.push(cipher.to_json_org_admin(&headers.host, &headers.user.uuid, &conn).await?); - } - - Ok(Json(json!({ - "data": ciphers_json, - "object": "list", - "continuationToken": null, - }))) +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct OrgDomainDetails { + email: String, } // Returning a Domain/Organization here allow to prefill it and prevent prompting the user -// So we return a dummy value, since we only support a single SSO integration, and do not use the response anywhere +// So we either return an Org name associated to the user or a dummy value. // In use since `v2025.6.0`, appears to use only the first `organizationIdentifier` -#[post("/organizations/domain/sso/verified")] -fn get_org_domain_sso_verified() -> JsonResult { - // Always return a dummy value, no matter if SSO is enabled or not +#[post("/organizations/domain/sso/verified", data = "")] +async fn get_org_domain_sso_verified(data: Json, conn: DbConn) -> JsonResult { + let data: OrgDomainDetails = data.into_inner(); + + let identifiers = match Organization::find_org_user_email(&data.email, &conn) + .await + .into_iter() + .map(|o| (o.name, o.uuid.to_string())) + .collect::>() + { + v if !v.is_empty() => v, + _ => vec![(crate::sso::FAKE_IDENTIFIER.to_string(), crate::sso::FAKE_IDENTIFIER.to_string())], + }; + Ok(Json(json!({ "object": "list", - "data": [{ - "organizationIdentifier": FAKE_SSO_IDENTIFIER, - // These appear to be unused - "organizationName": FAKE_SSO_IDENTIFIER, - "domainName": CONFIG.domain() - }], - "continuationToken": null + "data": identifiers.into_iter().map(|(name, identifier)| json!({ + "organizationName": name, // appear unused + "organizationIdentifier": identifier, + "domainName": CONFIG.domain(), // appear unused + })).collect::>() }))) } @@ -944,16 +955,10 @@ async fn get_members( if org_id != headers.membership.org_uuid { err!("Organization not found", "Organization id's do not match"); } - - if !headers.membership.has_full_access() { - err_code!("Resource not found.", "User does not have full access", Status::NotFound.code); - } - let mut users_json = Vec::new(); for u in Membership::find_by_org(&org_id, &conn).await { - // The user can be a manager instead of an admin, but we've checked above that they have full access users_json.push( - u.to_json_details_for_admin( + u.to_json_user_details( data.include_collections.unwrap_or(false), data.include_groups.unwrap_or(false), &conn, @@ -981,13 +986,14 @@ async fn post_org_keys( } let data: OrgKeyData = data.into_inner(); - let mut org = if let Some(organization) = Organization::find_by_uuid(&org_id, &conn).await { - if organization.private_key.is_some() && organization.public_key.is_some() { - err!("Organization Keys already exist") + let mut org = match Organization::find_by_uuid(&org_id, &conn).await { + Some(organization) => { + if organization.private_key.is_some() && organization.public_key.is_some() { + err!("Organization Keys already exist") + } + organization } - organization - } else { - err!("Can't find organization details") + None => err!("Can't find organization details"), }; org.private_key = Some(data.encrypted_private_key); @@ -1018,16 +1024,13 @@ impl InviteData { let org_collections = Collection::find_by_organization(org_id, conn).await; let org_collection_ids: HashSet<&CollectionId> = org_collections.iter().map(|c| &c.uuid).collect(); if let Some(e) = self.collections.iter().flatten().find(|c| !org_collection_ids.contains(&c.id)) { - err!( - "Invalid collection", - format!("Collection {} does not belong to organization {}!", e.id.escape_debug(), org_id) - ) + err!("Invalid collection", format!("Collection {} does not belong to organization {}!", e.id, org_id)) } let org_groups = Group::find_by_organization(org_id, conn).await; let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|c| &c.uuid).collect(); if let Some(e) = self.groups.iter().find(|g| !org_group_ids.contains(g)) { - err!("Invalid group", format!("Group {} does not belong to organization {}!", e.escape_debug(), org_id)) + err!("Invalid group", format!("Group {} does not belong to organization {}!", e, org_id)) } Ok(()) @@ -1048,12 +1051,13 @@ async fn send_invite( data.validate(&org_id, &conn).await?; // HACK: We need the raw user-type to be sure custom role is selected to determine the access_all permission - // Unless Custom roles are enabled, a custom role (4) is converted to a manager role (3) + // The from_str() will convert the custom role type into a manager role type let raw_type = &data.r#type.into_string(); - let Some(requested_type) = MembershipType::from_str(raw_type) else { - err!("Invalid type") + // Membership::from_str will convert custom (4) to manager (3) + let new_type = match MembershipType::from_str(raw_type) { + Some(new_type) => new_type as i32, + None => err!("Invalid type"), }; - let new_type = MembershipType::requested(requested_type, None, custom_roles_enabled()); if new_type != MembershipType::User && headers.membership_type != MembershipType::Owner { err!("Only Owners can invite Managers, Admins or Owners") @@ -1063,26 +1067,18 @@ async fn send_invite( // Since the parent checkbox is not sent to the server we need to check and verify the child checkboxes // If the box is not checked, the user will still be a manager, but not with the access_all permission let access_all = new_type >= MembershipType::Admin - || (new_type == MembershipType::Manager - && raw_type.eq("4") + || (raw_type.eq("4") && data.permissions.get("editAnyCollection") == Some(&json!(true)) && data.permissions.get("deleteAnyCollection") == Some(&json!(true)) && data.permissions.get("createNewCollections") == Some(&json!(true))); - // Checked before anything is created, so a rejected permission leaves nothing behind - let custom_permissions = if new_type == MembershipType::Custom { - Some(CustomPermission::requested(&data.permissions)?) - } else { - None - }; - - let mut user_created: bool; - for email in &data.emails { + let mut user_created: bool = false; + for email in data.emails.iter() { let mut member_status = MembershipStatus::Invited as i32; let user = match User::find_by_mail(email, &conn).await { None => { if !CONFIG.invitations_allowed() { - err!(format!("User does not exist: {}", email.escape_debug())) + err!(format!("User does not exist: {email}")) } if !CONFIG.is_email_domain_allowed(email) { @@ -1100,36 +1096,27 @@ async fn send_invite( } Some(user) => { if Membership::find_by_user_and_org(&user.uuid, &org_id, &conn).await.is_some() { - err!(format!("User already in organization: {}", email.escape_debug())) - } - - if !CONFIG.mail_enabled() { - if user.password_hash.is_empty() { - Invitation::new(email).save(&conn).await?; - } else { - // automatically accept existing users if mail is disabled + err!(format!("User already in organization: {email}")) + } else { + // automatically accept existing users if mail is disabled + if !CONFIG.mail_enabled() && !user.password_hash.is_empty() { member_status = MembershipStatus::Accepted as i32; } + user } - user_created = false; - user } }; let mut new_member = Membership::new(user.uuid.clone(), org_id.clone(), Some(headers.user.email.clone())); - new_member.set_type(new_type); new_member.access_all = access_all; - if let Some(permissions) = &custom_permissions { - new_member.set_custom_permissions(permissions); - } + new_member.atype = new_type; new_member.status = member_status; new_member.save(&conn).await?; if CONFIG.mail_enabled() { - let org_name = if let Some(org) = Organization::find_by_uuid(&org_id, &conn).await { - org.name - } else { - err!("Error looking up organization") + let org_name = match Organization::find_by_uuid(&org_id, &conn).await { + Some(org) => org.name, + None => err!("Error looking up organization"), }; if let Err(e) = mail::send_invite( @@ -1153,7 +1140,7 @@ async fn send_invite( } log_event( - EventType::OrganizationUserInvited, + EventType::OrganizationUserInvited as i32, &new_member.uuid, &org_id, &headers.user.uuid, @@ -1183,10 +1170,7 @@ async fn send_invite( } } - for group_id in &data.groups { - if Group::find_by_uuid_and_org(group_id, &org_id, &conn).await.is_none() { - err!("Group not found in Organization") - } + for group_id in data.groups.iter() { let mut group_entry = GroupUser::new(group_id.clone(), new_member.uuid.clone()); group_entry.save(&conn).await?; } @@ -1209,12 +1193,9 @@ async fn bulk_reinvite_members( let mut bulk_response = Vec::new(); for member_id in data.ids { - let err_msg = match reinvite_member_impl(&org_id, &member_id, &headers.user.email, &conn).await { - Ok(()) => String::new(), - Err(e) => { - warn!("Unable to reinvite member {}: {e:?}", member_id.escape_debug()); - e.message().to_owned() - } + let err_msg = match _reinvite_member(&org_id, &member_id, &headers.user.email, &conn).await { + Ok(_) => String::new(), + Err(e) => format!("{e:?}"), }; bulk_response.push(json!( @@ -1223,7 +1204,7 @@ async fn bulk_reinvite_members( "id": member_id, "error": err_msg } - )); + )) } Ok(Json(json!({ @@ -1243,10 +1224,10 @@ async fn reinvite_member( if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } - reinvite_member_impl(&org_id, &member_id, &headers.user.email, &conn).await + _reinvite_member(&org_id, &member_id, &headers.user.email, &conn).await } -async fn reinvite_member_impl( +async fn _reinvite_member( org_id: &OrganizationId, member_id: &MembershipId, invited_by_email: &str, @@ -1268,14 +1249,13 @@ async fn reinvite_member_impl( err!("Invitations are not allowed.") } - let org_name = if let Some(org) = Organization::find_by_uuid(org_id, conn).await { - org.name - } else { - err!("Error looking up organization.") + let org_name = match Organization::find_by_uuid(org_id, conn).await { + Some(org) => org.name, + None => err!("Error looking up organization."), }; if CONFIG.mail_enabled() { - mail::send_invite(&user, org_id.clone(), member.uuid, &org_name, Some(invited_by_email.to_owned())).await?; + mail::send_invite(&user, org_id.clone(), member.uuid, &org_name, Some(invited_by_email.to_string())).await?; } else if user.password_hash.is_empty() { let invitation = Invitation::new(&user.email); invitation.save(conn).await?; @@ -1381,16 +1361,11 @@ async fn bulk_confirm_invite( match data.keys { Some(keys) => { for invite in keys { - let Some(member_id) = invite.id else { - err!("Member id is required") - }; + let member_id = invite.id.unwrap(); let user_key = invite.key.unwrap_or_default(); - let err_msg = match confirm_invite_impl(&org_id, &member_id, &user_key, &headers, &conn, &nt).await { - Ok(()) => String::new(), - Err(e) => { - warn!("Unable to confirm member {}: {e:?}", member_id.escape_debug()); - e.message().to_owned() - } + let err_msg = match _confirm_invite(&org_id, &member_id, &user_key, &headers, &conn, &nt).await { + Ok(_) => String::new(), + Err(e) => format!("{e:?}"), }; bulk_response.push(json!( @@ -1423,10 +1398,10 @@ async fn confirm_invite( ) -> EmptyResult { let data = data.into_inner(); let user_key = data.key.unwrap_or_default(); - confirm_invite_impl(&org_id, &member_id, &user_key, &headers, &conn, &nt).await + _confirm_invite(&org_id, &member_id, &user_key, &headers, &conn, &nt).await } -async fn confirm_invite_impl( +async fn _confirm_invite( org_id: &OrganizationId, member_id: &MembershipId, key: &str, @@ -1454,13 +1429,13 @@ async fn confirm_invite_impl( } member_to_confirm.status = MembershipStatus::Confirmed as i32; - member_to_confirm.akey = key.to_owned(); + member_to_confirm.akey = key.to_string(); // This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type OrgPolicy::check_user_allowed(&member_to_confirm, "confirm", conn).await?; log_event( - EventType::OrganizationUserConfirmed, + EventType::OrganizationUserConfirmed as i32, &member_to_confirm.uuid, org_id, &headers.user.uuid, @@ -1471,15 +1446,13 @@ async fn confirm_invite_impl( .await; if CONFIG.mail_enabled() { - let org_name = if let Some(org) = Organization::find_by_uuid(org_id, conn).await { - org.name - } else { - err!("Error looking up organization.") + let org_name = match Organization::find_by_uuid(org_id, conn).await { + Some(org) => org.name, + None => err!("Error looking up organization."), }; - let address = if let Some(user) = User::find_by_uuid(&member_to_confirm.user_uuid, conn).await { - user.email - } else { - err!("Error looking up user.") + let address = match User::find_by_uuid(&member_to_confirm.user_uuid, conn).await { + Some(user) => user.email, + None => err!("Error looking up user."), }; mail::send_invite_confirmed(&address, &org_name).await?; } @@ -1487,7 +1460,7 @@ async fn confirm_invite_impl( let save_result = member_to_confirm.save(conn).await; if let Some(user) = User::find_by_uuid(&member_to_confirm.user_uuid, conn).await { - nt.send_user_update(UpdateType::SyncOrgKeys, &user, headers.device.push_uuid.as_ref(), conn).await; + nt.send_user_update(UpdateType::SyncOrgKeys, &user, &headers.device.push_uuid, conn).await; } save_result @@ -1528,9 +1501,7 @@ async fn get_user( // In this case, when groups are requested we also need to include collections. // Else these will not be shown in the interface, and could lead to missing collections when saved. let include_groups = data.include_groups.unwrap_or(false); - Ok(Json( - user.to_json_details_for_admin(data.include_collections.unwrap_or(include_groups), include_groups, &conn).await, - )) + Ok(Json(user.to_json_user_details(data.include_collections.unwrap_or(include_groups), include_groups, &conn).await)) } #[derive(Deserialize)] @@ -1544,6 +1515,17 @@ struct EditUserData { } #[put("/organizations//users/", data = "", rank = 1)] +async fn put_member( + org_id: OrganizationId, + member_id: MembershipId, + data: Json, + headers: AdminHeaders, + conn: DbConn, +) -> EmptyResult { + edit_member(org_id, member_id, data, headers, conn).await +} + +#[post("/organizations//users/", data = "", rank = 1)] async fn edit_member( org_id: OrganizationId, member_id: MembershipId, @@ -1557,32 +1539,24 @@ async fn edit_member( let data: EditUserData = data.into_inner(); // HACK: We need the raw user-type to be sure custom role is selected to determine the access_all permission - // Unless Custom roles are enabled, a custom role (4) is converted to a manager role (3) + // The from_str() will convert the custom role type into a manager role type let raw_type = &data.r#type.into_string(); - let Some(requested_type) = MembershipType::from_str(raw_type) else { + // MembershipType::from_str will convert custom (4) to manager (3) + let Some(new_type) = MembershipType::from_str(raw_type) else { err!("Invalid type") }; - let Some(mut member_to_edit) = Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await else { - err!("The specified user isn't member of the organization") - }; - let new_type = MembershipType::requested(requested_type, Some(&member_to_edit), custom_roles_enabled()); - // HACK: This converts the Custom role which has the `Manage all collections` box checked into an access_all flag // Since the parent checkbox is not sent to the server we need to check and verify the child checkboxes // If the box is not checked, the user will still be a manager, but not with the access_all permission let access_all = new_type >= MembershipType::Admin - || (new_type == MembershipType::Manager - && raw_type.eq("4") + || (raw_type.eq("4") && data.permissions.get("editAnyCollection") == Some(&json!(true)) && data.permissions.get("deleteAnyCollection") == Some(&json!(true)) && data.permissions.get("createNewCollections") == Some(&json!(true))); - // While Custom roles are disabled, a Custom member keeps their stored permissions untouched - let custom_permissions = if new_type == MembershipType::Custom && custom_roles_enabled() { - Some(CustomPermission::requested(&data.permissions)?) - } else { - None + let Some(mut member_to_edit) = Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await else { + err!("The specified user isn't member of the organization") }; if new_type != member_to_edit.atype @@ -1606,11 +1580,8 @@ async fn edit_member( } } - member_to_edit.set_type(new_type); member_to_edit.access_all = access_all; - if let Some(permissions) = &custom_permissions { - member_to_edit.set_custom_permissions(permissions); - } + member_to_edit.atype = new_type as i32; // This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type // We need to perform the check after changing the type since `admin` is exempt. @@ -1644,15 +1615,12 @@ async fn edit_member( GroupUser::delete_all_by_member(&member_to_edit.uuid, &conn).await?; for group_id in data.groups.iter().flatten() { - if Group::find_by_uuid_and_org(group_id, &org_id, &conn).await.is_none() { - err!("Group not found in Organization") - } let mut group_entry = GroupUser::new(group_id.clone(), member_to_edit.uuid.clone()); group_entry.save(&conn).await?; } log_event( - EventType::OrganizationUserUpdated, + EventType::OrganizationUserUpdated as i32, &member_to_edit.uuid, &org_id, &headers.user.uuid, @@ -1680,12 +1648,9 @@ async fn bulk_delete_member( let mut bulk_response = Vec::new(); for member_id in data.ids { - let err_msg = match delete_member_impl(&org_id, &member_id, &headers, &conn, &nt).await { - Ok(()) => String::new(), - Err(e) => { - warn!("Unable to delete member {}: {e:?}", member_id.escape_debug()); - e.message().to_owned() - } + let err_msg = match _delete_member(&org_id, &member_id, &headers, &conn, &nt).await { + Ok(_) => String::new(), + Err(e) => format!("{e:?}"), }; bulk_response.push(json!( @@ -1694,7 +1659,7 @@ async fn bulk_delete_member( "id": member_id, "error": err_msg } - )); + )) } Ok(Json(json!({ @@ -1712,10 +1677,10 @@ async fn delete_member( conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { - delete_member_impl(&org_id, &member_id, &headers, &conn, &nt).await + _delete_member(&org_id, &member_id, &headers, &conn, &nt).await } -async fn delete_member_impl( +async fn _delete_member( org_id: &OrganizationId, member_id: &MembershipId, headers: &AdminHeaders, @@ -1742,7 +1707,7 @@ async fn delete_member_impl( } log_event( - EventType::OrganizationUserRemoved, + EventType::OrganizationUserRemoved as i32, &member_to_delete.uuid, org_id, &headers.user.uuid, @@ -1753,16 +1718,7 @@ async fn delete_member_impl( .await; if let Some(user) = User::find_by_uuid(&member_to_delete.user_uuid, conn).await { - nt.send_user_update(UpdateType::SyncOrgKeys, &user, headers.device.push_uuid.as_ref(), conn).await; - - if !CONFIG.mail_enabled() - && !Membership::find_invited_by_user(&user.uuid, conn) - .await - .into_iter() - .any(|m| m.uuid != member_to_delete.uuid) - { - Invitation::take(&user.email, conn).await; - } + nt.send_user_update(UpdateType::SyncOrgKeys, &user, &headers.device.push_uuid, conn).await; } member_to_delete.delete(conn).await @@ -1808,25 +1764,14 @@ async fn bulk_public_keys( }))) } -use super::ciphers::CipherData; use super::ciphers::update_cipher_from_data; - -// The import endpoint only ever uses the name/id/external_id of a collection. -// Bitwarden's own server ignores `groups`/`users` here too, so do not make them -// mandatory: clients are free to leave them out. -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct ImportCollectionData { - name: String, - id: Option, - external_id: Option, -} +use super::ciphers::CipherData; #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct ImportData { ciphers: Vec, - collections: Vec, + collections: Vec, collection_relationships: Vec, } @@ -1852,10 +1797,6 @@ async fn post_org_import( if org_id != headers.membership.org_uuid { err!("Organization not found", "Organization id's do not match"); } - // OrgMemberHeaders also allows invited and accepted members, which are not allowed to import - if headers.membership.status != MembershipStatus::Confirmed as i32 { - err!("You need to be a Member of the Organization to call this endpoint") - } let data: ImportData = data.into_inner(); // Validate the import before continuing @@ -1864,19 +1805,19 @@ async fn post_org_import( // TODO: See if we can optimize the whole cipher adding/importing and prevent duplicate code and checks. Cipher::validate_cipher_data(&data.ciphers)?; - let existing_collections: HashMap = - Collection::find_by_organization(&org_id, &conn).await.into_iter().map(|c| (c.uuid.clone(), c)).collect(); + let existing_collections: HashSet> = + Collection::find_by_organization(&org_id, &conn).await.into_iter().map(|c| Some(c.uuid)).collect(); let mut collections: Vec = Vec::with_capacity(data.collections.len()); for col in data.collections { - let existing = col.id.as_ref().and_then(|col_id| existing_collections.get(col_id)); - let collection_uuid = if let Some(collection) = existing { - // When not an Owner or Admin, check if the member is allowed to write to the collection. + let collection_uuid = if existing_collections.contains(&col.id) { + let col_id = col.id.unwrap(); + // When not an Owner or Admin, check if the member is allowed to access the collection. if headers.membership.atype < MembershipType::Admin - && !collection.is_writable_by_user(&headers.membership.user_uuid, &conn).await + && !Collection::can_access_collection(&headers.membership, &col_id, &conn).await { err!(Compact, "The current user isn't allowed to manage this collection") } - collection.uuid.clone() + col_id } else { // We do not allow users or managers which can not manage all collections to create new collections // If there is any collection other than an existing import collection, abort the import. @@ -1904,9 +1845,7 @@ async fn post_org_import( for mut cipher_data in data.ciphers { // Always clear folder_id's via an organization import cipher_data.folder_id = None; - // Replace the client-provided, unvalidated organizationId with the real target org - cipher_data.organization_id = Some(org_id.clone()); - let mut cipher = Cipher::new(cipher_data.r#type); + let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone()); update_cipher_from_data( &mut cipher, cipher_data, @@ -1915,7 +1854,6 @@ async fn post_org_import( &conn, &nt, UpdateType::None, - true, ) .await .ok(); @@ -1924,9 +1862,8 @@ async fn post_org_import( // Assign the collections for (cipher_index, col_index) in relations { - let (Some(cipher_id), Some(col_id)) = (ciphers.get(cipher_index), collections.get(col_index)) else { - err!(Compact, "Invalid collection relationship") - }; + let cipher_id = &ciphers[cipher_index]; + let col_id = &collections[col_index]; CollectionCipher::save(cipher_id, col_id, &conn).await?; } @@ -1938,7 +1875,7 @@ async fn post_org_import( #[serde(rename_all = "camelCase")] struct BulkCollectionsData { organization_id: OrganizationId, - cipher_ids: HashSet, + cipher_ids: Vec, collection_ids: HashSet, remove_collections: bool, } @@ -1949,59 +1886,51 @@ struct BulkCollectionsData { async fn post_bulk_collections(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { let data: BulkCollectionsData = data.into_inner(); - let Some(member) = - Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &data.organization_id, &conn).await - else { + if Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &data.organization_id, &conn).await.is_none() { err!("You need to be a Member of the Organization to call this endpoint") - }; - // A member who may edit any collection may use every collection of the organization - let can_edit_any_collection = member.can_edit_any_collection(); + } // Get all the collection available to the user in one query // Also filter based upon the provided collections - let collections = if can_edit_any_collection { - Collection::find_by_organization(&data.organization_id, &conn).await - } else { - Collection::find_by_organization_and_user_uuid(&data.organization_id, &headers.user.uuid, &conn).await - }; - let user_collections: HashMap = collections - .into_iter() - .filter_map(|c| { - if data.collection_ids.contains(&c.uuid) { - Some((c.uuid.clone(), c)) - } else { - None - } - }) - .collect(); + let user_collections: HashMap = + Collection::find_by_organization_and_user_uuid(&data.organization_id, &headers.user.uuid, &conn) + .await + .into_iter() + .filter_map(|c| { + if data.collection_ids.contains(&c.uuid) { + Some((c.uuid.clone(), c)) + } else { + None + } + }) + .collect(); // Verify if all the collections requested exists and are writable for the user, else abort for collection_uuid in &data.collection_ids { match user_collections.get(collection_uuid) { - Some(collection) - if can_edit_any_collection || collection.is_writable_by_user(&headers.user.uuid, &conn).await => {} + Some(collection) if collection.is_writable_by_user(&headers.user.uuid, &conn).await => (), _ => err_code!("Resource not found", "User does not have access to a collection", 404), } } - for cipher_id in &data.cipher_ids { + for cipher_id in data.cipher_ids.iter() { // Only act on existing cipher uuid's // Do not abort the operation just ignore it, it could be a cipher was just deleted for example - if let Some(cipher) = Cipher::find_by_uuid_and_org(cipher_id, &data.organization_id, &conn).await - && cipher.is_write_accessible_to_user(&headers.user.uuid, CipherAccessScope::OrganizationAdmin, &conn).await - { - // When selecting a specific collection from the left filter list, and use the bulk option, you can remove an item from that collection - // In these cases the client will call this endpoint twice, once for adding the new collections and a second for deleting. - if data.remove_collections { - for collection in &data.collection_ids { - CollectionCipher::delete(&cipher.uuid, collection, &conn).await?; - } - } else { - for collection in &data.collection_ids { - CollectionCipher::save(&cipher.uuid, collection, &conn).await?; + if let Some(cipher) = Cipher::find_by_uuid_and_org(cipher_id, &data.organization_id, &conn).await { + if cipher.is_write_accessible_to_user(&headers.user.uuid, &conn).await { + // When selecting a specific collection from the left filter list, and use the bulk option, you can remove an item from that collection + // In these cases the client will call this endpoint twice, once for adding the new collections and a second for deleting. + if data.remove_collections { + for collection in &data.collection_ids { + CollectionCipher::delete(&cipher.uuid, collection, &conn).await?; + } + } else { + for collection in &data.collection_ids { + CollectionCipher::save(&cipher.uuid, collection, &conn).await?; + } } } - } + }; } Ok(()) @@ -2046,25 +1975,15 @@ async fn list_policies_token(org_id: OrganizationId, token: &str, conn: DbConn) }))) } -// Called during the SSO enrollment return the default policy -#[get("/organizations/00000000-01DC-01DC-01DC-000000000000/policies/master-password", rank = 1)] -fn get_dummy_master_password_policy() -> JsonResult { - let (enabled, data) = match CONFIG.sso_master_password_policy_value() { - Some(policy) if CONFIG.sso_enabled() => (true, policy.to_string()), - _ => (false, "null".to_owned()), - }; - let policy = OrgPolicy::new(FAKE_SSO_IDENTIFIER.into(), OrgPolicyType::MasterPassword, enabled, data); - Ok(Json(policy.to_json())) -} - -// Called during the SSO enrollment return the org policy if it exists -#[get("/organizations//policies/master-password", rank = 2)] +// Called during the SSO enrollment. +// Return the org policy if it exists, otherwise use the default one. +#[get("/organizations//policies/master-password", rank = 1)] async fn get_master_password_policy(org_id: OrganizationId, _headers: OrgMemberHeaders, conn: DbConn) -> JsonResult { let policy = OrgPolicy::find_by_org_and_type(&org_id, OrgPolicyType::MasterPassword, &conn).await.unwrap_or_else(|| { let (enabled, data) = match CONFIG.sso_master_password_policy_value() { Some(policy) if CONFIG.sso_enabled() => (true, policy.to_string()), - _ => (false, "null".to_owned()), + _ => (false, "null".to_string()), }; OrgPolicy::new(org_id, OrgPolicyType::MasterPassword, enabled, data) @@ -2073,7 +1992,7 @@ async fn get_master_password_policy(org_id: OrganizationId, _headers: OrgMemberH Ok(Json(policy.to_json())) } -#[get("/organizations//policies/", rank = 3)] +#[get("/organizations//policies/", rank = 2)] async fn get_policy(org_id: OrganizationId, pol_type: i32, headers: AdminHeaders, conn: DbConn) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); @@ -2085,7 +2004,7 @@ async fn get_policy(org_id: OrganizationId, pol_type: i32, headers: AdminHeaders let policy = match OrgPolicy::find_by_org_and_type(&org_id, pol_type_enum, &conn).await { Some(p) => p, - None => OrgPolicy::new(org_id.clone(), pol_type_enum, false, "null".to_owned()), + None => OrgPolicy::new(org_id.clone(), pol_type_enum, false, "null".to_string()), }; Ok(Json(policy.to_json())) @@ -2097,55 +2016,23 @@ struct PolicyData { data: Option, } -#[derive(Deserialize)] -struct PutPolicy { - policy: PolicyData, - // Ignore metadata for now as we do not yet support this - // "metadata": { - // "defaultUserCollectionName": "2.xx|xx==|xx=" - // } -} - #[put("/organizations//policies/", data = "")] async fn put_policy( org_id: OrganizationId, pol_type: i32, - data: Json, + data: Json, headers: AdminHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } - let data: PolicyData = data.into_inner().policy; + let data: PolicyData = data.into_inner(); let Some(pol_type_enum) = OrgPolicyType::from_i32(pol_type) else { err!("Invalid or unsupported policy type") }; - let current_policy = OrgPolicy::find_by_org_and_type(&org_id, pol_type_enum, &conn).await; - let is_currently_enabled = current_policy.as_ref().is_some_and(|policy| policy.enabled); - - // Fill Assist depends on Single Organization. Match Bitwarden's dependency checks by only checking when the - // policy changes state. - if pol_type_enum == OrgPolicyType::FillAssist && data.enabled && !is_currently_enabled { - let single_org_policy_enabled = - OrgPolicy::find_by_org_and_type(&org_id, OrgPolicyType::SingleOrg, &conn).await.is_some_and(|p| p.enabled); - - if !single_org_policy_enabled { - err!("Turn on the Single organization policy because it is required for the Activate fill assist policy.") - } - } - - if pol_type_enum == OrgPolicyType::SingleOrg && !data.enabled && is_currently_enabled { - let fill_assist_policy_enabled = - OrgPolicy::find_by_org_and_type(&org_id, OrgPolicyType::FillAssist, &conn).await.is_some_and(|p| p.enabled); - - if fill_assist_policy_enabled { - err!("Turn off the Activate fill assist policy because it requires the Single organization policy.") - } - } - // Bitwarden only allows the Reset Password policy when Single Org policy is enabled // Vaultwarden encouraged to use multiple orgs instead of groups because groups were not available in the past // Now that groups are available we can enforce this option when wanted. @@ -2178,37 +2065,6 @@ async fn put_policy( } } - if pol_type_enum == OrgPolicyType::FillAssist { - let fill_assist_data = match data.data.as_ref() { - None | Some(Value::Null) => FillAssistPolicyData { - rules_url: None, - }, - Some(data) => { - let Ok(data) = serde_json::from_value::(data.clone()) else { - err!("Invalid data for FillAssist policy: rulesUrl has an invalid value.") - }; - data - } - }; - - if data.enabled { - let Some(rules_url) = fill_assist_data.rules_url.filter(|url| !url.trim().is_empty()) else { - err!("The RulesUrl field is required.") - }; - let Ok(rules_url) = url::Url::parse(&rules_url) else { - err!("The RulesUrl field is not a valid fully-qualified http, https, or ftp URL.") - }; - - if !rules_url.has_host() || !matches!(rules_url.scheme(), "http" | "https" | "ftp") { - err!("The RulesUrl field is not a valid fully-qualified http, https, or ftp URL.") - } - - if rules_url.scheme() != "https" { - err!("RulesUrl must use HTTPS.") - } - } - } - // When enabling the TwoFactorAuthentication policy, revoke all members that do not have 2FA if pol_type_enum == OrgPolicyType::TwoFactorAuthentication && data.enabled { two_factor::enforce_2fa_policy_for_org( @@ -2223,7 +2079,7 @@ async fn put_policy( // When enabling the SingleOrg policy, remove this org's members that are members of other orgs if pol_type_enum == OrgPolicyType::SingleOrg && data.enabled { - for mut member in Membership::find_by_org(&org_id, &conn).await { + for mut member in Membership::find_by_org(&org_id, &conn).await.into_iter() { // Policy only applies to non-Owner/non-Admin members who have accepted joining the org // Exclude invited and revoked users when checking for this policy. // Those users will not be allowed to accept or be activated because of the policy checks done there. @@ -2240,7 +2096,7 @@ async fn put_policy( } log_event( - EventType::OrganizationUserRevokedSingleOrganizationNonCompliance, + EventType::OrganizationUserRemoved as i32, &member.uuid, &org_id, &headers.user.uuid, @@ -2256,9 +2112,9 @@ async fn put_policy( } } - let mut policy = match current_policy { + let mut policy = match OrgPolicy::find_by_org_and_type(&org_id, pol_type_enum, &conn).await { Some(p) => p, - None => OrgPolicy::new(org_id.clone(), pol_type_enum, false, "{}".to_owned()), + None => OrgPolicy::new(org_id.clone(), pol_type_enum, false, "{}".to_string()), }; policy.enabled = data.enabled; @@ -2266,7 +2122,7 @@ async fn put_policy( policy.save(&conn).await?; log_event( - EventType::PolicyUpdated, + EventType::PolicyUpdated as i32, policy.uuid.as_ref(), &org_id, &headers.user.uuid, @@ -2279,6 +2135,28 @@ async fn put_policy( Ok(Json(policy.to_json())) } +#[derive(Deserialize)] +struct PolicyDataVnext { + policy: PolicyData, + // Ignore metadata for now as we do not yet support this + // "metadata": { + // "defaultUserCollectionName": "2.xx|xx==|xx=" + // } +} + +#[put("/organizations//policies//vnext", data = "")] +async fn put_policy_vnext( + org_id: OrganizationId, + pol_type: i32, + data: Json, + headers: AdminHeaders, + conn: DbConn, +) -> JsonResult { + let data: PolicyDataVnext = data.into_inner(); + let policy: PolicyData = data.policy; + put_policy(org_id, pol_type, Json(policy), headers, conn).await +} + #[get("/plans")] fn get_plans() -> Json { // Respond with a minimal json just enough to allow the creation of an new organization. @@ -2307,6 +2185,12 @@ fn get_plans() -> Json { })) } +#[get("/organizations/<_org_id>/billing/metadata")] +fn get_billing_metadata(_org_id: OrganizationId, _headers: OrgMemberHeaders) -> Json { + // Prevent a 404 error, which also causes Javascript errors. + Json(_empty_data_json()) +} + #[get("/organizations/<_org_id>/billing/vnext/warnings")] fn get_billing_warnings(_org_id: OrganizationId, _headers: OrgMemberHeaders) -> Json { Json(json!({ @@ -2317,13 +2201,12 @@ fn get_billing_warnings(_org_id: OrganizationId, _headers: OrgMemberHeaders) -> })) } -#[get("/organizations/<_org_id>/billing/vnext/self-host/metadata")] -fn get_self_host_billing_metadata(_org_id: OrganizationId, _headers: OrgMemberHeaders) -> Json { - // Prevent a 404 error, which also causes Javascript errors. - Json(json!({ - "isOnSecretsManagerStandalone": false, // Secrets Manager is not supported by Vaultwarden - "organizationOccupiedSeats": 0 // Vaultwarden does not count seats - })) +fn _empty_data_json() -> Value { + json!({ + "object": "list", + "data": [], + "continuationToken": null + }) } #[derive(Deserialize, Debug)] @@ -2339,7 +2222,7 @@ async fn revoke_member( headers: AdminHeaders, conn: DbConn, ) -> EmptyResult { - revoke_member_impl(&org_id, &member_id, &headers, &conn).await + _revoke_member(&org_id, &member_id, &headers, &conn).await } #[put("/organizations//users/revoke", data = "")] @@ -2358,12 +2241,9 @@ async fn bulk_revoke_members( match data.ids { Some(members) => { for member_id in members { - let err_msg = match revoke_member_impl(&org_id, &member_id, &headers, &conn).await { - Ok(()) => String::new(), - Err(e) => { - warn!("Unable to revoke member {}: {e:?}", member_id.escape_debug()); - e.message().to_owned() - } + let err_msg = match _revoke_member(&org_id, &member_id, &headers, &conn).await { + Ok(_) => String::new(), + Err(e) => format!("{e:?}"), }; bulk_response.push(json!( @@ -2385,7 +2265,7 @@ async fn bulk_revoke_members( }))) } -async fn revoke_member_impl( +async fn _revoke_member( org_id: &OrganizationId, member_id: &MembershipId, headers: &AdminHeaders, @@ -2412,7 +2292,7 @@ async fn revoke_member_impl( member.save(conn).await?; log_event( - EventType::OrganizationUserRevoked, + EventType::OrganizationUserRevoked as i32, &member.uuid, org_id, &headers.user.uuid, @@ -2428,18 +2308,6 @@ async fn revoke_member_impl( Ok(()) } -#[put("/organizations//users//restore/vnext")] -async fn restore_member_vnext( - org_id: OrganizationId, - member_id: MembershipId, - headers: AdminHeaders, - conn: DbConn, -) -> EmptyResult { - // Vaultwarden does not (yet) support the per User Collection linked to the `Enforce organization data ownership` policy. - // Therefor we ignore the `defaultUserCollectionName` data sent and just call restore_member - restore_member_impl(&org_id, &member_id, &headers, &conn).await -} - #[put("/organizations//users//restore")] async fn restore_member( org_id: OrganizationId, @@ -2447,7 +2315,7 @@ async fn restore_member( headers: AdminHeaders, conn: DbConn, ) -> EmptyResult { - restore_member_impl(&org_id, &member_id, &headers, &conn).await + _restore_member(&org_id, &member_id, &headers, &conn).await } #[put("/organizations//users/restore", data = "")] @@ -2464,12 +2332,9 @@ async fn bulk_restore_members( let mut bulk_response = Vec::new(); for member_id in data.ids { - let err_msg = match restore_member_impl(&org_id, &member_id, &headers, &conn).await { - Ok(()) => String::new(), - Err(e) => { - warn!("Unable to restore member {}: {e:?}", member_id.escape_debug()); - e.message().to_owned() - } + let err_msg = match _restore_member(&org_id, &member_id, &headers, &conn).await { + Ok(_) => String::new(), + Err(e) => format!("{e:?}"), }; bulk_response.push(json!( @@ -2488,7 +2353,7 @@ async fn bulk_restore_members( }))) } -async fn restore_member_impl( +async fn _restore_member( org_id: &OrganizationId, member_id: &MembershipId, headers: &AdminHeaders, @@ -2513,7 +2378,7 @@ async fn restore_member_impl( member.save(conn).await?; log_event( - EventType::OrganizationUserRestored, + EventType::OrganizationUserRestored as i32, &member.uuid, org_id, &headers.user.uuid, @@ -2538,41 +2403,24 @@ async fn get_groups_data( if org_id != headers.membership.org_uuid { err!("Organization not found", "Organization id's do not match"); } - - // The details view (group→collection/user mappings) needs full org access; the plain list only - // needs manage access to a collection, so a manager of a collection (directly or via a group) - // can load it to assign groups. - let has_full_access = headers.membership.has_full_access() - || (CONFIG.org_groups_enabled() - && GroupUser::has_full_access_by_member(&org_id, &headers.membership.uuid, &conn).await); - let allowed = if details { - has_full_access - } else { - has_full_access - || Collection::has_manageable_collection_by_user(&org_id, &headers.membership.user_uuid, &conn).await - }; - if !allowed { - err_code!("Resource not found.", "User does not have access", Status::NotFound.code); - } - let groups: Vec = if CONFIG.org_groups_enabled() { let groups = Group::find_by_organization(&org_id, &conn).await; let mut groups_json = Vec::with_capacity(groups.len()); if details { for g in groups { - groups_json.push(g.to_json_details(&conn).await); + groups_json.push(g.to_json_details(&conn).await) } } else { for g in groups { - groups_json.push(g.to_json()); + groups_json.push(g.to_json()) } } groups_json } else { // The Bitwarden clients seem to call this API regardless of whether groups are enabled, // so just act as if there are no groups. - Vec::new() + Vec::with_capacity(0) }; Ok(Json(json!({ @@ -2622,16 +2470,13 @@ impl GroupRequest { let org_collections = Collection::find_by_organization(org_id, conn).await; let org_collection_ids: HashSet<&CollectionId> = org_collections.iter().map(|c| &c.uuid).collect(); if let Some(e) = self.collections.iter().find(|c| !org_collection_ids.contains(&c.id)) { - err!( - "Invalid collection", - format!("Collection {} does not belong to organization {}!", e.id.escape_debug(), org_id) - ) + err!("Invalid collection", format!("Collection {} does not belong to organization {}!", e.id, org_id)) } 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) = self.users.iter().find(|m| !org_membership_ids.contains(m)) { - err!("Invalid member", format!("Member {} does not belong to organization {}!", e.escape_debug(), org_id)) + err!("Invalid member", format!("Member {} does not belong to organization {}!", e, org_id)) } Ok(()) @@ -2653,6 +2498,17 @@ impl CollectionData { } } +#[post("/organizations//groups/", data = "")] +async fn post_group( + org_id: OrganizationId, + group_id: GroupId, + data: Json, + headers: AdminHeaders, + conn: DbConn, +) -> JsonResult { + put_group(org_id, group_id, data, headers, conn).await +} + #[post("/organizations//groups", data = "")] async fn post_groups( org_id: OrganizationId, @@ -2673,7 +2529,7 @@ async fn post_groups( let group = group_request.to_group(&org_id); log_event( - EventType::GroupCreated, + EventType::GroupCreated as i32, &group.uuid, &org_id, &headers.user.uuid, @@ -2714,7 +2570,7 @@ async fn put_group( GroupUser::delete_all_by_group(&group_id, &org_id, &conn).await?; log_event( - EventType::GroupUpdated, + EventType::GroupUpdated as i32, &updated_group.uuid, &org_id, &headers.user.uuid, @@ -2747,7 +2603,7 @@ async fn add_update_group( user_entry.save(conn).await?; log_event( - EventType::OrganizationUserUpdatedGroups, + EventType::OrganizationUserUpdatedGroups as i32, &assigned_member, &org_id, &headers.user.uuid, @@ -2762,6 +2618,7 @@ async fn add_update_group( "id": group.uuid, "organizationId": group.organizations_uuid, "name": group.name, + "accessAll": group.access_all, "externalId": group.external_id, "object": "group" }))) @@ -2788,12 +2645,22 @@ async fn get_group_details( Ok(Json(group.to_json_details(&conn).await)) } -#[delete("/organizations//groups/")] -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 +#[post("/organizations//groups//delete")] +async fn post_delete_group( + org_id: OrganizationId, + group_id: GroupId, + headers: AdminHeaders, + conn: DbConn, +) -> EmptyResult { + _delete_group(&org_id, &group_id, &headers, &conn).await } -async fn delete_group_impl( +#[delete("/organizations//groups/")] +async fn delete_group(org_id: OrganizationId, group_id: GroupId, headers: AdminHeaders, conn: DbConn) -> EmptyResult { + _delete_group(&org_id, &group_id, &headers, &conn).await +} + +async fn _delete_group( org_id: &OrganizationId, group_id: &GroupId, headers: &AdminHeaders, @@ -2811,7 +2678,7 @@ async fn delete_group_impl( }; log_event( - EventType::GroupDeleted, + EventType::GroupDeleted as i32, &group.uuid, org_id, &headers.user.uuid, @@ -2841,7 +2708,7 @@ async fn bulk_delete_groups( let data: BulkGroupIds = data.into_inner(); for group_id in data.ids { - delete_group_impl(&org_id, &group_id, &headers, &conn).await?; + _delete_group(&org_id, &group_id, &headers, &conn).await? } Ok(()) } @@ -2878,7 +2745,7 @@ async fn get_group_members( 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 group_members: Vec = GroupUser::find_by_group(&group_id, &org_id, &conn) .await @@ -2889,6 +2756,90 @@ async fn get_group_members( Ok(Json(json!(group_members))) } +#[put("/organizations//groups//users", data = "")] +async fn put_group_members( + org_id: OrganizationId, + group_id: GroupId, + headers: AdminHeaders, + data: Json>, + 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 as i32, + &assigned_member, + &org_id, + &headers.user.uuid, + headers.device.atype, + &headers.ip.ip, + &conn, + ) + .await; + } + + Ok(()) +} + +#[post("/organizations//groups//delete-user/")] +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 as i32, + &member_id, + &org_id, + &headers.user.uuid, + headers.device.atype, + &headers.ip.ip, + &conn, + ) + .await; + + GroupUser::delete_by_group_and_member(&group_id, &member_id, &conn).await +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct OrganizationUserResetPasswordEnrollmentRequest { @@ -2899,45 +2850,17 @@ struct OrganizationUserResetPasswordEnrollmentRequest { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct OrganizationUserRecoverAccountRequest { - // Legacy payload - new_master_password_hash: Option, - key: Option, - - // Current payload - authentication_data: Option, - unlock_data: Option, - - #[serde(default)] - reset_master_password: bool, - #[serde(default)] - reset_two_factor: bool, +struct OrganizationUserResetPasswordRequest { + new_master_password_hash: String, + key: String, } // Upstream reports this is the renamed endpoint instead of `/keys` // But the clients do not seem to use this at all // Just add it here in case they will #[get("/organizations//public-key")] -async fn get_organization_public_key( - org_id: OrganizationId, - headers: Headers, - member: Result, - conn: DbConn, -) -> JsonResult { - // SSO users without an org get the fake one, whose key the v2 JIT password flow fetches anyway - if org_id.eq_ignore_ascii_case(FAKE_SSO_IDENTIFIER) && headers.user.private_key.is_none() { - return Ok(Json(json!({ - "object": "organizationPublicKey", - "publicKey": fake_sso_org_public_key().await?, - }))); - } - let member = match member { - Ok(member) => member, - // The guard already logged it - Err(e) => return Err(crate::error::Error::new_msg(e).with_code(Status::Unauthorized.code)), - }; - - if org_id != member.membership.org_uuid { +async fn get_organization_public_key(org_id: OrganizationId, headers: OrgMemberHeaders, conn: DbConn) -> JsonResult { + if org_id != headers.membership.org_uuid { err!("Organization not found", "Organization id's do not match"); } let Some(org) = Organization::find_by_uuid(&org_id, &conn).await else { @@ -2950,51 +2873,19 @@ async fn get_organization_public_key( }))) } -/// The SDK only uses this key to wrap an account recovery key that it never sends for the fake org, -/// so it is made once and its private half dropped. -async fn fake_sso_org_public_key() -> ApiResult { - static PUBLIC_KEY: std::sync::LazyLock> = std::sync::LazyLock::new(|| { - let der = openssl::rsa::Rsa::generate(2048).and_then(|rsa| rsa.public_key_to_der()).ok()?; - Some(data_encoding::BASE64.encode(&der)) - }); - - let Ok(Some(public_key)) = tokio::task::spawn_blocking(|| PUBLIC_KEY.clone()).await else { - err!("Failed to generate the organization key") - }; - Ok(public_key) -} - // Obsolete - Renamed to public-key (2023.8), left for backwards compatibility with older clients // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Controllers/OrganizationsController.cs#L487-L492 #[get("/organizations//keys")] -async fn get_organization_keys( - org_id: OrganizationId, - headers: Headers, - member: Result, - conn: DbConn, -) -> JsonResult { - get_organization_public_key(org_id, headers, member, conn).await +async fn get_organization_keys(org_id: OrganizationId, headers: OrgMemberHeaders, conn: DbConn) -> JsonResult { + get_organization_public_key(org_id, headers, conn).await } -// Will allow to reset 2FA too -// https://github.com/bitwarden/clients/blob/web-v2026.4.2/libs/admin-console/src/common/organization-user/models/requests/organization-user-reset-password.request.ts -#[put("/organizations//users//recover-account", data = "")] -async fn put_recover_account( +#[put("/organizations//users//reset-password", data = "")] +async fn put_reset_password( org_id: OrganizationId, member_id: MembershipId, headers: AdminHeaders, - data: Json, - conn: DbConn, - nt: Notify<'_>, -) -> EmptyResult { - recover_account(org_id, member_id, headers, data.into_inner(), conn, nt).await -} - -async fn recover_account( - org_id: OrganizationId, - member_id: MembershipId, - headers: AdminHeaders, - req: OrganizationUserRecoverAccountRequest, + data: Json, conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { @@ -3009,7 +2900,7 @@ async fn recover_account( err!("User to reset isn't member of required organization") }; - let Some(mut user) = User::find_by_uuid(&member.user_uuid, &conn).await else { + let Some(user) = User::find_by_uuid(&member.user_uuid, &conn).await else { err!("User not found") }; @@ -3022,74 +2913,31 @@ async fn recover_account( err!("Organization user must be confirmed for password reset functionality"); } - let fallback_2fa_email = 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(); - - // Check the new password before the email below, so that a rejected request doesn't tell the user - // their password was reset - let new_password = if req.reset_master_password { - let (new_master_password_hash, new_key) = if let (Some(authentication_data), Some(unlock_data)) = - (req.authentication_data, req.unlock_data) - { - authentication_data.check(&user, &unlock_data)?; - - if !authentication_data.kdf.matches_user(&user) { - err!("KDF settings do not match the user account") - } - - (authentication_data.master_password_authentication_hash, unlock_data.master_key_wrapped_user_key) - } else if let (Some(new_master_password_hash), Some(new_key)) = (req.new_master_password_hash, req.key) { - (new_master_password_hash, new_key) - } else { - err_code!("Unprocessable request", "Missing fields to reset password", Status::UnprocessableEntity.code); - }; - Some((new_master_password_hash, new_key)) - } else { - None - }; - - // Sending email first ensure working email configuration and the resulting user notification. - // Also this might add some protection against security flaws and misuse - if let Err(e) = mail::send_admin_account_recovery( - &user.email, - user.display_name(), - &org.name, - req.reset_master_password, - req.reset_two_factor, - fallback_2fa_email, - ) - .await - { + // Sending email before resetting password to ensure working email configuration and the resulting + // user notification. Also this might add some protection against security flaws and misuse + if let Err(e) = mail::send_admin_reset_password(&user.email, user.display_name(), &org.name).await { err!(format!("Error sending user reset password email: {e:#?}")); } - if let Some((new_master_password_hash, new_key)) = new_password { - user.set_password(&new_master_password_hash, Some(new_key), true, None, &conn).await?; - } - - if req.reset_two_factor { - TwoFactor::delete_all_by_user(&user.uuid, &conn).await?; - Device::clear_twofactor_remember_by_user(&user.uuid, &conn).await?; - if !fallback_2fa_email || two_factor::email::find_and_activate_email_2fa(&user.uuid, &conn).await.is_err() { - two_factor::enforce_2fa_policy(&user, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn) - .await?; - } - } + let reset_request = data.into_inner(); + let mut user = user; + user.set_password(reset_request.new_master_password_hash.as_str(), Some(reset_request.key), true, None, &conn) + .await?; user.save(&conn).await?; nt.send_logout(&user, None, &conn).await; - if req.reset_master_password { - headers.log_event(EventType::OrganizationUserAdminResetPassword, &member_id, &org_id, &conn).await; - } - - if req.reset_two_factor { - headers.log_event(EventType::OrganizationUserAdminResetTwoFactor, &member_id, &org_id, &conn).await; - } + log_event( + EventType::OrganizationUserAdminResetPassword as i32, + &member_id, + &org_id, + &headers.user.uuid, + headers.device.atype, + &headers.ip.ip, + &conn, + ) + .await; Ok(()) } @@ -3126,7 +2974,6 @@ async fn get_reset_password_details( "kdfIterations": user.client_kdf_iter, "kdfMemory": user.client_kdf_memory, "kdfParallelism": user.client_kdf_parallelism, - "masterPasswordSalt": user.master_password_salt(), "resetPasswordKey": member.reset_password_key, "encryptedPrivateKey": org.private_key, }))) @@ -3180,7 +3027,10 @@ async fn put_reset_password_enrollment( err!("User to enroll isn't member of required organization", "The user_id and acting user do not match"); } - let mut membership = headers.membership; + let Some(mut membership) = Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &org_id, &conn).await + else { + err!("User to enroll isn't member of required organization") + }; check_reset_password_applicable(&org_id, &conn).await?; @@ -3209,9 +3059,9 @@ async fn put_reset_password_enrollment( membership.save(&conn).await?; let event_type = if membership.reset_password_key.is_some() { - EventType::OrganizationUserResetPasswordEnroll + EventType::OrganizationUserResetPasswordEnroll as i32 } else { - EventType::OrganizationUserResetPasswordWithdraw + EventType::OrganizationUserResetPasswordWithdraw as i32 }; log_event(event_type, &membership.uuid, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn) @@ -3233,16 +3083,16 @@ async fn get_org_export(org_id: OrganizationId, headers: AdminHeaders, conn: DbC } Ok(Json(json!({ - "collections": convert_json_key_lcase_first(get_org_collections_impl(&org_id, &conn).await), - "ciphers": convert_json_key_lcase_first(get_org_details_impl(&org_id, &headers.host, &headers.user.uuid, &conn).await?), + "collections": convert_json_key_lcase_first(_get_org_collections(&org_id, &conn).await), + "ciphers": convert_json_key_lcase_first(_get_org_details(&org_id, &headers.host, &headers.user.uuid, &conn).await?), }))) } -async fn api_key( +async fn _api_key( org_id: &OrganizationId, data: Json, rotate: bool, - headers: OwnerHeaders, + headers: AdminHeaders, conn: DbConn, ) -> JsonResult { if org_id != &headers.org_id { @@ -3251,21 +3101,24 @@ async fn api_key( let data: PasswordOrOtpData = data.into_inner(); let user = headers.user; - // Validate the owner users password/otp + // Validate the admin users password/otp data.validate(&user, true, &conn).await?; - let org_api_key = if let Some(mut org_api_key) = OrganizationApiKey::find_by_org_uuid(org_id, &conn).await { - if rotate { - org_api_key.api_key = crate::crypto::generate_api_key(); - org_api_key.revision_date = chrono::Utc::now().naive_utc(); - org_api_key.save(&conn).await.expect("Error rotating organization API Key"); + let org_api_key = match OrganizationApiKey::find_by_org_uuid(org_id, &conn).await { + Some(mut org_api_key) => { + if rotate { + org_api_key.api_key = crate::crypto::generate_api_key(); + org_api_key.revision_date = chrono::Utc::now().naive_utc(); + org_api_key.save(&conn).await.expect("Error rotating organization API Key"); + } + org_api_key + } + None => { + let api_key = crate::crypto::generate_api_key(); + let new_org_api_key = OrganizationApiKey::new(org_id.clone(), api_key); + new_org_api_key.save(&conn).await.expect("Error creating organization API Key"); + new_org_api_key } - org_api_key - } else { - let api_key = crate::crypto::generate_api_key(); - let new_org_api_key = OrganizationApiKey::new(org_id.clone(), api_key); - new_org_api_key.save(&conn).await.expect("Error creating organization API Key"); - new_org_api_key }; Ok(Json(json!({ @@ -3276,21 +3129,21 @@ async fn api_key( } #[post("/organizations//api-key", data = "")] -async fn post_api_key( +async fn api_key( org_id: OrganizationId, data: Json, - headers: OwnerHeaders, + headers: AdminHeaders, conn: DbConn, ) -> JsonResult { - api_key(&org_id, data, false, headers, conn).await + _api_key(&org_id, data, false, headers, conn).await } #[post("/organizations//rotate-api-key", data = "")] async fn rotate_api_key( org_id: OrganizationId, data: Json, - headers: OwnerHeaders, + headers: AdminHeaders, conn: DbConn, ) -> JsonResult { - api_key(&org_id, data, true, headers, conn).await + _api_key(&org_id, data, true, headers, conn).await } diff --git a/src/api/core/public.rs b/src/api/core/public.rs index 1ee80515..d757d953 100644 --- a/src/api/core/public.rs +++ b/src/api/core/public.rs @@ -1,24 +1,23 @@ -use std::collections::HashSet; - use chrono::Utc; use rocket::{ - Request, Route, request::{FromRequest, Outcome}, serde::json::Json, + Request, Route, }; +use std::collections::HashSet; + use crate::{ - CONFIG, api::EmptyResult, auth, db::{ - DbConn, models::{ - Group, GroupUser, Invitation, Membership, MembershipStatus, MembershipType, OrgPolicy, Organization, + Group, GroupUser, Invitation, Membership, MembershipStatus, MembershipType, Organization, OrganizationApiKey, OrganizationId, User, }, + DbConn, }, - mail, + mail, CONFIG, }; pub fn routes() -> Vec { @@ -58,13 +57,25 @@ async fn ldap_import(data: Json, token: PublicToken, conn: DbConn let org_id = token.0; let data = data.into_inner(); - // Like upstream, the import never removes Owners, so it doesn't revoke or restore them either. for user_data in &data.members { let mut user_created: bool = false; if user_data.deleted { // If user is marked for deletion and it exists, revoke it if let Some(mut member) = Membership::find_by_email_and_org(&user_data.email, &org_id, &conn).await { - let revoked = member.atype != MembershipType::Owner && member.revoke(); + // Only revoke a user if it is not the last confirmed owner + let revoked = if member.atype == MembershipType::Owner + && member.status == MembershipStatus::Confirmed as i32 + { + if Membership::count_confirmed_by_org_and_type(&org_id, MembershipType::Owner, &conn).await <= 1 { + warn!("Can't revoke the last owner"); + false + } else { + member.revoke() + } + } else { + member.revoke() + }; + let ext_modified = member.set_external_id(Some(user_data.external_id.clone())); if revoked || ext_modified { member.save(&conn).await?; @@ -72,32 +83,26 @@ async fn ldap_import(data: Json, token: PublicToken, conn: DbConn } // If user is part of the organization, restore it } else if let Some(mut member) = Membership::find_by_email_and_org(&user_data.email, &org_id, &conn).await { - let mut restored = member.atype != MembershipType::Owner && member.restore(); + let restored = member.restore(); let ext_modified = member.set_external_id(Some(user_data.external_id.clone())); - // Enforce org policies as every other restore path does. - // If the user is not allowed, we revoke again and continue so the external_id is still updated. - if restored && let Err(e) = OrgPolicy::check_user_allowed(&member, "restore", &conn).await { - warn!("Not restoring {}: {e:?}", user_data.email); - member.revoke(); - restored = false; - } if restored || ext_modified { member.save(&conn).await?; } } else { // If user is not part of the organization - let user = if let Some(user) = User::find_by_mail(&user_data.email, &conn).await { - user - } else { - // User does not exist yet - let mut new_user = User::new(&user_data.email, None); - new_user.save(&conn).await?; + let user = match User::find_by_mail(&user_data.email, &conn).await { + Some(user) => user, // exists in vaultwarden + None => { + // User does not exist yet + let mut new_user = User::new(&user_data.email, None); + new_user.save(&conn).await?; - if !CONFIG.mail_enabled() { - Invitation::new(&new_user.email).save(&conn).await?; + if !CONFIG.mail_enabled() { + Invitation::new(&new_user.email).save(&conn).await?; + } + user_created = true; + new_user } - user_created = true; - new_user }; let member_status = if CONFIG.mail_enabled() || user.password_hash.is_empty() { MembershipStatus::Invited as i32 @@ -105,10 +110,9 @@ async fn ldap_import(data: Json, token: PublicToken, conn: DbConn MembershipStatus::Accepted as i32 // Automatically mark user as accepted if no email invites }; - let (org_name, org_email) = if let Some(org) = Organization::find_by_uuid(&org_id, &conn).await { - (org.name, org.billing_email) - } else { - err!("Error looking up organization") + let (org_name, org_email) = match Organization::find_by_uuid(&org_id, &conn).await { + Some(org) => (org.name, org.billing_email), + None => err!("Error looking up organization"), }; let mut new_member = Membership::new(user.uuid.clone(), org_id.clone(), Some(org_email.clone())); @@ -119,37 +123,37 @@ async fn ldap_import(data: Json, token: PublicToken, conn: DbConn new_member.save(&conn).await?; - if CONFIG.mail_enabled() - && let Err(e) = + if CONFIG.mail_enabled() { + if let Err(e) = mail::send_invite(&user, org_id.clone(), new_member.uuid.clone(), &org_name, Some(org_email)).await - { - // Upon error delete the user, invite and org member records when needed - if user_created { - user.delete(&conn).await?; - } else { - new_member.delete(&conn).await?; - } + { + // Upon error delete the user, invite and org member records when needed + if user_created { + user.delete(&conn).await?; + } else { + new_member.delete(&conn).await?; + } - err!(format!("Error sending invite: {e:?} ")); + err!(format!("Error sending invite: {e:?} ")); + } } } } if CONFIG.org_groups_enabled() { for group_data in &data.groups { - let group_uuid = if let Some(mut group) = - Group::find_by_external_id_and_org(&group_data.external_id, &org_id, &conn).await - { - if group.name != group_data.name { - group.name = group_data.name.clone(); + let group_uuid = match Group::find_by_external_id_and_org(&group_data.external_id, &org_id, &conn).await { + Some(group) => group.uuid, + None => { + let mut group = Group::new( + org_id.clone(), + group_data.name.clone(), + false, + Some(group_data.external_id.clone()), + ); group.save(&conn).await?; + group.uuid } - group.uuid - } else { - let mut group = - Group::new(org_id.clone(), group_data.name.clone(), false, Some(group_data.external_id.clone())); - group.save(&conn).await?; - group.uuid }; GroupUser::delete_all_by_group(&group_uuid, &org_id, &conn).await?; @@ -170,11 +174,18 @@ async fn ldap_import(data: Json, token: PublicToken, conn: DbConn // Generate a HashSet to quickly verify if a member is listed or not. let sync_members: HashSet = data.members.into_iter().map(|m| m.external_id).collect(); for member in Membership::find_by_org(&org_id, &conn).await { - if let Some(ref user_external_id) = member.external_id - && !sync_members.contains(user_external_id) - && member.atype != MembershipType::Owner - { - member.delete(&conn).await?; + if let Some(ref user_external_id) = member.external_id { + if !sync_members.contains(user_external_id) { + 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?; + } } } } @@ -191,14 +202,12 @@ impl<'r> FromRequest<'r> for PublicToken { async fn from_request(request: &'r Request<'_>) -> Outcome { let headers = request.headers(); // Get access_token - let access_token: &str = if let Some(a) = headers.get_one("Authorization") { - if let Some(split) = a.rsplit("Bearer ").next() { - split - } else { - err_handler!("No access token provided") - } - } else { - err_handler!("No access token provided") + let access_token: &str = match headers.get_one("Authorization") { + Some(a) => match a.rsplit("Bearer ").next() { + Some(split) => split, + None => err_handler!("No access token provided"), + }, + None => err_handler!("No access token provided"), }; // Check JWT token is valid and get device and user from it let Ok(claims) = auth::decode_api_org(access_token) else { @@ -220,13 +229,14 @@ impl<'r> FromRequest<'r> for PublicToken { // Check if claims.sub is org_api_key.uuid // Check if claims.client_sub is org_api_key.org_uuid - let Outcome::Success(conn) = DbConn::from_request(request).await else { - err_handler!("Error getting DB") + let conn = match DbConn::from_request(request).await { + Outcome::Success(conn) => conn, + _ => err_handler!("Error getting DB"), }; let Some(org_id) = claims.client_id.strip_prefix("organization.") else { err_handler!("Malformed client_id") }; - let org_id: OrganizationId = org_id.to_owned().into(); + let org_id: OrganizationId = org_id.to_string().into(); let Some(org_api_key) = OrganizationApiKey::find_by_org_uuid(&org_id, &conn).await else { err_handler!("Invalid client_id") }; diff --git a/src/api/core/sends.rs b/src/api/core/sends.rs index f8cc92bc..10bf85be 100644 --- a/src/api/core/sends.rs +++ b/src/api/core/sends.rs @@ -10,20 +10,20 @@ use rocket::{ use serde_json::Value; use crate::{ - CONFIG, api::{ApiResult, EmptyResult, JsonResult, Notify, UpdateType}, - auth::{Headers, Host, SendHeaders}, + auth::{ClientIp, Headers, Host}, config::PathType, db::{ - DbConn, DbPool, models::{Device, OrgPolicy, OrgPolicyType, Send, SendFileId, SendId, SendType, UserId}, + DbConn, DbPool, }, - util::{NumberOrString, save_temp_file}, + util::{save_temp_file, NumberOrString}, + CONFIG, }; const SEND_INACCESSIBLE_MSG: &str = "Send does not exist or is no longer available"; static ANON_PUSH_DEVICE: LazyLock = LazyLock::new(|| { - let dt = DateTime::UNIX_EPOCH.naive_utc(); + let dt = crate::util::parse_date("1970-01-01T00:00:00.000000Z"); Device { uuid: String::from("00000000-0000-0000-0000-000000000000").into(), created_at: dt, @@ -39,13 +39,14 @@ static ANON_PUSH_DEVICE: LazyLock = LazyLock::new(|| { }); // The max file size allowed by Bitwarden clients and add an extra 5% to avoid issues -pub(crate) const SIZE_525_MB: i64 = 550_502_400; +const SIZE_525_MB: i64 = 550_502_400; pub fn routes() -> Vec { routes![ get_sends, get_send, post_send, + post_send_file, post_access, post_access_file, put_send, @@ -62,7 +63,7 @@ pub async fn purge_sends(pool: DbPool) { if let Ok(conn) = pool.get().await { Send::purge(&conn).await; } else { - error!("Failed to get DB connection while purging sends"); + error!("Failed to get DB connection while purging sends") } } @@ -70,14 +71,13 @@ pub async fn purge_sends(pool: DbPool) { #[serde(rename_all = "camelCase")] pub struct SendData { r#type: i32, - pub key: String, + key: String, password: Option, max_access_count: Option, expiration_date: Option>, deletion_date: DateTime, disabled: bool, hide_email: Option, - emails: Option, // Data field name: String, @@ -148,10 +148,6 @@ fn create_send(data: SendData, user_id: UserId) -> ApiResult { ); } - if data.emails.is_some() { - err!("Sends with email verification is not supported"); - } - let mut send = Send::new(data.r#type, data.name, data_str, data.key, data.deletion_date.naive_utc()); send.user_uuid = Some(user_id); send.notes = data.notes; @@ -172,7 +168,7 @@ fn create_send(data: SendData, user_id: UserId) -> ApiResult { #[get("/sends")] async fn get_sends(headers: Headers, conn: DbConn) -> Json { let sends = Send::find_by_user(&headers.user.uuid, &conn); - let sends_json: Vec = sends.await.iter().map(Send::to_json).collect(); + let sends_json: Vec = sends.await.iter().map(|s| s.to_json()).collect(); Json(json!({ "data": sends_json, @@ -183,10 +179,9 @@ async fn get_sends(headers: Headers, conn: DbConn) -> Json { #[get("/sends/")] async fn get_send(send_id: SendId, headers: Headers, conn: DbConn) -> JsonResult { - if let Some(send) = Send::find_by_uuid_and_user(&send_id, &headers.user.uuid, &conn).await { - Ok(Json(send.to_json())) - } else { - err!("Send not found", "Invalid send uuid or does not belong to user") + match Send::find_by_uuid_and_user(&send_id, &headers.user.uuid, &conn).await { + Some(send) => Ok(Json(send.to_json())), + None => err!("Send not found", "Invalid send uuid or does not belong to user"), } } @@ -198,7 +193,7 @@ async fn post_send(data: Json, headers: Headers, conn: DbConn, nt: Not enforce_disable_hide_email_policy(&data, &headers, &conn).await?; if data.r#type == SendType::File as i32 { - err!("File sends should use /api/sends/file/v2") + err!("File sends should use /api/sends/file") } let mut send = create_send(data, headers.user.uuid)?; @@ -215,11 +210,93 @@ async fn post_send(data: Json, headers: Headers, conn: DbConn, nt: Not Ok(Json(send.to_json())) } +#[derive(FromForm)] +struct UploadData<'f> { + model: Json, + data: TempFile<'f>, +} + #[derive(FromForm)] struct UploadDataV2<'f> { data: TempFile<'f>, } +// @deprecated Mar 25 2021: This method has been deprecated in favor of direct uploads (v2). +// This method still exists to support older clients, probably need to remove it sometime. +// Upstream: https://github.com/bitwarden/server/blob/d0c793c95181dfb1b447eb450f85ba0bfd7ef643/src/Api/Controllers/SendsController.cs#L164-L167 +// 2025: This endpoint doesn't seem to exists anymore in the latest version +// See: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/SendsController.cs +#[post("/sends/file", format = "multipart/form-data", data = "")] +async fn post_send_file(data: Form>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { + enforce_disable_send_policy(&headers, &conn).await?; + + let UploadData { + model, + data, + } = data.into_inner(); + let model = model.into_inner(); + + let Some(size) = data.len().to_i64() else { + err!("Invalid send size"); + }; + if size < 0 { + err!("Send size can't be negative") + } + + enforce_disable_hide_email_policy(&model, &headers, &conn).await?; + + let size_limit = match CONFIG.user_send_limit() { + Some(0) => err!("File uploads are disabled"), + Some(limit_kb) => { + let Some(already_used) = Send::size_by_user(&headers.user.uuid, &conn).await else { + err!("Existing sends overflow") + }; + let Some(left) = limit_kb.checked_mul(1024).and_then(|l| l.checked_sub(already_used)) else { + err!("Send size overflow"); + }; + if left <= 0 { + err!("Send storage limit reached! Delete some sends to free up space") + } + i64::clamp(left, 0, SIZE_525_MB) + } + None => SIZE_525_MB, + }; + + if size > size_limit { + err!("Send storage limit exceeded with this file"); + } + + let mut send = create_send(model, headers.user.uuid)?; + if send.atype != SendType::File as i32 { + err!("Send content is not a file"); + } + + let file_id = crate::crypto::generate_send_file_id(); + + save_temp_file(&PathType::Sends, &format!("{}/{file_id}", send.uuid), data, true).await?; + + let mut data_value: Value = serde_json::from_str(&send.data)?; + if let Some(o) = data_value.as_object_mut() { + o.insert(String::from("id"), Value::String(file_id)); + o.insert(String::from("size"), Value::Number(size.into())); + o.insert(String::from("sizeName"), Value::String(crate::util::get_display_size(size))); + } + send.data = serde_json::to_string(&data_value)?; + + // Save the changes in the database + send.save(&conn).await?; + nt.send_send_update( + UpdateType::SyncSendCreate, + &send, + &send.update_users_revision(&conn).await, + &headers.device, + &conn, + ) + .await; + + Ok(Json(send.to_json())) +} + // Upstream: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/SendsController.cs#L165 #[post("/sends/file/v2", data = "")] async fn post_send_file_v2(data: Json, headers: Headers, conn: DbConn) -> JsonResult { @@ -233,10 +310,9 @@ async fn post_send_file_v2(data: Json, headers: Headers, conn: DbConn) enforce_disable_hide_email_policy(&data, &headers, &conn).await?; - let file_length = if let Some(m) = &data.file_length { - m.into_i64()? - } else { - err!("Invalid send length") + let file_length = match &data.file_length { + Some(m) => m.into_i64()?, + _ => err!("Invalid send length"), }; if file_length < 0 { err!("Send size can't be negative") @@ -268,12 +344,11 @@ async fn post_send_file_v2(data: Json, headers: Headers, conn: DbConn) let file_id = crate::crypto::generate_send_file_id(); let mut data_value: Value = serde_json::from_str(&send.data)?; - let Some(o) = data_value.as_object_mut() else { - err!("Invalid Send file data") - }; - o.insert(String::from("id"), Value::String(file_id.clone())); - o.insert(String::from("size"), Value::Number(file_length.into())); - o.insert(String::from("sizeName"), Value::String(crate::util::get_display_size(file_length))); + if let Some(o) = data_value.as_object_mut() { + o.insert(String::from("id"), Value::String(file_id.clone())); + o.insert(String::from("size"), Value::Number(file_length.into())); + o.insert(String::from("sizeName"), Value::String(crate::util::get_display_size(file_length))); + } send.data = serde_json::to_string(&data_value)?; send.save(&conn).await?; @@ -294,7 +369,7 @@ pub struct SendFileData { } // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/SendsController.cs#L195 -#[post("/sends//file/", format = "multipart/form-data", data = "", rank = 2)] +#[post("/sends//file/", format = "multipart/form-data", data = "")] async fn post_send_file_v2_data( send_id: SendId, file_id: SendFileId, @@ -329,8 +404,8 @@ async fn post_send_file_v2_data( "Send file name does not match.", format!( "Expected file name '{}' got '{}'", - send_data.fileName.escape_debug(), - raw_file_name.dangerous_unsafe_unsanitized_raw().as_str().escape_debug() + send_data.fileName, + raw_file_name.dangerous_unsafe_unsanitized_raw() ) ), _ => err!("Send file name does not match or is not provided."), @@ -364,22 +439,59 @@ async fn post_send_file_v2_data( Ok(()) } -#[post("/sends/access")] -async fn post_access(headers: SendHeaders, conn: DbConn, nt: Notify<'_>) -> JsonResult { - let Some(mut send) = Send::find_by_uuid(&headers.send_id, &conn).await else { - err_code!(SEND_INACCESSIBLE_MSG, 404) - }; - if !send.is_accessible() { - err_code!(SEND_INACCESSIBLE_MSG, 404) - } - // Files are incremented during the download - if send.atype == SendType::Text as i32 && !send.register_access(&conn).await? { - err_code!(SEND_INACCESSIBLE_MSG, 404) - } - process_access(send, conn, nt).await +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SendAccessData { + pub password: Option, } -async fn process_access(send: Send, conn: DbConn, nt: Notify<'_>) -> JsonResult { +#[post("/sends/access/", data = "")] +async fn post_access( + access_id: &str, + data: Json, + conn: DbConn, + ip: ClientIp, + nt: Notify<'_>, +) -> JsonResult { + 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 { + if send.access_count >= max_access_count { + err_code!(SEND_INACCESSIBLE_MSG, 404); + } + } + + if let Some(expiration) = send.expiration_date { + if Utc::now().naive_utc() >= expiration { + err_code!(SEND_INACCESSIBLE_MSG, 404) + } + } + + if Utc::now().naive_utc() >= send.deletion_date { + err_code!(SEND_INACCESSIBLE_MSG, 404) + } + + if send.disabled { + err_code!(SEND_INACCESSIBLE_MSG, 404) + } + + if send.password_hash.is_some() { + match data.into_inner().password { + Some(ref p) if send.check_password(p) => { /* Nothing to do here */ } + Some(_) => err!("Invalid password", format!("IP: {}.", ip.ip)), + None => err_code!("Password not provided", format!("IP: {}.", ip.ip), 401), + } + } + + // Files are incremented during the download + if send.atype == SendType::Text as i32 { + send.access_count += 1; + } + + send.save(&conn).await?; + nt.send_send_update( UpdateType::SyncSendUpdate, &send, @@ -392,38 +504,51 @@ async fn process_access(send: Send, conn: DbConn, nt: Notify<'_>) -> JsonResult Ok(Json(send.to_json_access(&conn).await)) } -#[post("/sends/access/file/", rank = 1)] +#[post("/sends//access/file/", data = "")] async fn post_access_file( + send_id: SendId, file_id: SendFileId, - headers: SendHeaders, + data: Json, host: Host, conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - let Some(mut send) = Send::find_by_uuid(&headers.send_id, &conn).await else { + let Some(mut send) = Send::find_by_uuid(&send_id, &conn).await else { err_code!(SEND_INACCESSIBLE_MSG, 404) }; - if !send.is_accessible() { + + if let Some(max_access_count) = send.max_access_count { + if send.access_count >= max_access_count { + err_code!(SEND_INACCESSIBLE_MSG, 404) + } + } + + if let Some(expiration) = send.expiration_date { + if Utc::now().naive_utc() >= expiration { + err_code!(SEND_INACCESSIBLE_MSG, 404) + } + } + + if Utc::now().naive_utc() >= send.deletion_date { err_code!(SEND_INACCESSIBLE_MSG, 404) } - check_send_file_id(&send, &file_id)?; - if !send.register_access(&conn).await? { + + if send.disabled { err_code!(SEND_INACCESSIBLE_MSG, 404) } - process_access_file(send, file_id, host, conn, nt).await -} -fn check_send_file_id(send: &Send, file_id: &SendFileId) -> EmptyResult { - if send.atype != SendType::File as i32 { - err!("Send is not a file type send."); + if send.password_hash.is_some() { + match data.into_inner().password { + Some(ref p) if send.check_password(p) => { /* Nothing to do here */ } + Some(_) => err!("Invalid password."), + None => err_code!("Password not provided", 401), + } } - match serde_json::from_str::(&send.data) { - Ok(data) if &data.id == file_id => Ok(()), - _ => err_code!(SEND_INACCESSIBLE_MSG, 404), - } -} -async fn process_access_file(send: Send, file_id: SendFileId, host: Host, conn: DbConn, nt: Notify<'_>) -> JsonResult { + send.access_count += 1; + + send.save(&conn).await?; + nt.send_send_update( UpdateType::SyncSendUpdate, &send, @@ -436,29 +561,29 @@ async fn process_access_file(send: Send, file_id: SendFileId, host: Host, conn: Ok(Json(json!({ "object": "send-fileDownload", "id": file_id, - "url": download_url(&host, &send.uuid, &file_id).await?, + "url": download_url(&host, &send_id, &file_id).await?, }))) } async fn download_url(host: &Host, send_id: &SendId, file_id: &SendFileId) -> Result { let operator = CONFIG.opendal_operator_for_path_type(&PathType::Sends)?; - if crate::storage::is_fs_operator(&operator) { + if operator.info().scheme() == <&'static str>::from(opendal::Scheme::Fs) { let token_claims = crate::auth::generate_send_claims(send_id, file_id); let token = crate::auth::encode_jwt(&token_claims); - Ok(format!("{}/api/sends/{send_id}/{file_id}?t={token}", host.host)) + Ok(format!("{}/api/sends/{send_id}/{file_id}?t={token}", &host.host)) } else { - Ok(operator.presign_read(&format!("{send_id}/{file_id}"), Duration::from_mins(5)).await?.uri().to_string()) + Ok(operator.presign_read(&format!("{send_id}/{file_id}"), Duration::from_secs(5 * 60)).await?.uri().to_string()) } } #[get("/sends//?")] async fn download_send(send_id: SendId, file_id: SendFileId, t: &str) -> Option { - if let Ok(claims) = crate::auth::decode_send(t) - && claims.sub == format!("{send_id}/{file_id}") - { - return NamedFile::open(Path::new(&CONFIG.sends_folder()).join(send_id).join(file_id)).await.ok(); + if let Ok(claims) = crate::auth::decode_send(t) { + if claims.sub == format!("{send_id}/{file_id}") { + return NamedFile::open(Path::new(&CONFIG.sends_folder()).join(send_id).join(file_id)).await.ok(); + } } None } @@ -474,16 +599,12 @@ async fn put_send(send_id: SendId, data: Json, headers: Headers, conn: err!("Send not found", "Send send_id is invalid or does not belong to user") }; - if data.emails.is_some() { - err!("Sends with email verification is not supported"); - } - update_send_from_data(&mut send, data, &headers, &conn, &nt, UpdateType::SyncSendUpdate).await?; Ok(Json(send.to_json())) } -async fn update_send_from_data( +pub async fn update_send_from_data( send: &mut Send, data: SendData, headers: &Headers, diff --git a/src/api/core/two_factor/authenticator.rs b/src/api/core/two_factor/authenticator.rs index 55984443..4759aa3c 100644 --- a/src/api/core/two_factor/authenticator.rs +++ b/src/api/core/two_factor/authenticator.rs @@ -1,13 +1,14 @@ use data_encoding::BASE32; -use rocket::{Route, serde::json::Json}; +use rocket::serde::json::Json; +use rocket::Route; use crate::{ - api::{EmptyResult, JsonResult, PasswordOrOtpData, core::log_user_event, core::two_factor::generate_recover_code}, - auth::{ClientIp, Headers, two_factor}, + api::{core::log_user_event, core::two_factor::_generate_recover_code, EmptyResult, JsonResult, PasswordOrOtpData}, + auth::{ClientIp, Headers}, crypto, db::{ - DbConn, models::{EventType, TwoFactor, TwoFactorType, UserId}, + DbConn, }, util::NumberOrString, }; @@ -20,23 +21,27 @@ pub fn routes() -> Vec { #[post("/two-factor/get-authenticator", data = "")] async fn generate_authenticator(data: Json, headers: Headers, conn: DbConn) -> JsonResult { + let data: PasswordOrOtpData = data.into_inner(); let user = headers.user; data.validate(&user, false, &conn).await?; - let twofactor = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Authenticator, &conn).await; + let type_ = TwoFactorType::Authenticator as i32; + let twofactor = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await; let (enabled, key) = match twofactor { Some(tf) => (true, tf.data), _ => (false, crypto::encode_random_bytes::<20>(&BASE32)), }; + // Upstream seems to also return `userVerificationToken`, but doesn't seem to be used at all. + // It should help prevent TOTP disclosure if someone keeps their vault unlocked. + // Since it doesn't seem to be used, and also does not cause any issues, lets leave it out of the response. + // See: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Auth/Controllers/TwoFactorController.cs#L94 Ok(Json(json!({ - "authenticator": json!({ - "enabled": enabled, - "key": key, - }), - "userVerificationToken": two_factor::authenticator_token(user.uuid, key, enabled), + "enabled": enabled, + "key": key, + "object": "twoFactorAuthenticator" }))) } @@ -45,7 +50,8 @@ async fn generate_authenticator(data: Json, headers: Headers, struct EnableAuthenticatorData { key: String, token: NumberOrString, - user_verification_token: String, + master_password_hash: Option, + otp: Option, } #[post("/two-factor/authenticator", data = "")] @@ -56,13 +62,17 @@ async fn activate_authenticator(data: Json, headers: He let mut user = headers.user; - two_factor::validate_authenticator(&data.user_verification_token, &user.uuid, &key, false)?; + PasswordOrOtpData { + master_password_hash: data.master_password_hash, + otp: data.otp, + } + .validate(&user, true, &conn) + .await?; // Validate key as base32 and 20 bytes length - let decoded_key: Vec = if let Ok(decoded) = BASE32.decode(key.as_bytes()) { - decoded - } else { - err!("Invalid totp secret") + let decoded_key: Vec = match BASE32.decode(key.as_bytes()) { + Ok(decoded) => decoded, + _ => err!("Invalid totp secret"), }; if decoded_key.len() != 20 { @@ -72,15 +82,14 @@ async fn activate_authenticator(data: Json, headers: He // Validate the token provided with the key, and save new twofactor validate_totp_code(&user.uuid, &token, &key.to_uppercase(), &headers.ip, &conn).await?; - generate_recover_code(&mut user, &conn).await; + _generate_recover_code(&mut user, &conn).await; - log_user_event(EventType::UserUpdated2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; + log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; Ok(Json(json!({ - "authenticator": json!({ - "enabled": true, - "key": key, - }), + "enabled": true, + "key": key, + "object": "twoFactorAuthenticator" }))) } @@ -110,15 +119,16 @@ pub async fn validate_totp_code( ip: &ClientIp, conn: &DbConn, ) -> EmptyResult { - use totp_lite::{Sha1, totp_custom}; + use totp_lite::{totp_custom, Sha1}; let Ok(decoded_secret) = BASE32.decode(secret.as_bytes()) else { err!("Invalid TOTP secret") }; - let mut twofactor = match TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Authenticator, conn).await { + let mut twofactor = match TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Authenticator as i32, conn).await + { Some(tf) => tf, - _ => TwoFactor::new(user_id.clone(), TwoFactorType::Authenticator, secret.to_owned()), + _ => TwoFactor::new(user_id.clone(), TwoFactorType::Authenticator, secret.to_string()), }; // The amount of steps back and forward in time @@ -135,7 +145,7 @@ pub async fn validate_totp_code( // We need to calculate the time offsite and cast it as an u64. // Since we only have times into the future and the totp generator needs an u64 instead of the default i64. - let time: u64 = (current_timestamp + step * 30i64).cast_unsigned(); + let time = (current_timestamp + step * 30i64) as u64; let generated = totp_custom::(30, 6, &decoded_secret, time); // Check the given code equals the generated and if the time_step is larger then the one last used. @@ -174,25 +184,36 @@ pub async fn validate_totp_code( #[serde(rename_all = "camelCase")] struct DisableAuthenticatorData { key: String, - user_verification_token: String, + master_password_hash: String, + r#type: NumberOrString, } #[delete("/two-factor/authenticator", data = "")] -async fn disable_authenticator(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { +async fn disable_authenticator(data: Json, headers: Headers, conn: DbConn) -> JsonResult { let user = headers.user; + let type_ = data.r#type.into_i32()?; - two_factor::validate_authenticator(&data.user_verification_token, &user.uuid, &data.key, true)?; + if !user.check_valid_password(&data.master_password_hash) { + err!("Invalid password"); + } - if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Authenticator, &conn).await { + if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await { if twofactor.data == data.key { twofactor.delete(&conn).await?; - log_user_event(EventType::UserDisabled2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; + log_user_event(EventType::UserDisabled2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn) + .await; } else { err!(format!("TOTP key for user {} does not match recorded value, cannot deactivate", &user.email)); } } - super::check_2fa_state(&user, headers.device.atype, &headers.ip.ip, &conn).await?; + if TwoFactor::find_by_user(&user.uuid, &conn).await.is_empty() { + super::enforce_2fa_policy(&user, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await?; + } - Ok(()) + Ok(Json(json!({ + "enabled": false, + "keys": type_, + "object": "twoFactorProvider" + }))) } diff --git a/src/api/core/two_factor/duo.rs b/src/api/core/two_factor/duo.rs index 23116b2c..f2de50c3 100644 --- a/src/api/core/two_factor/duo.rs +++ b/src/api/core/two_factor/duo.rs @@ -1,24 +1,74 @@ use chrono::Utc; -use rocket::{Route, serde::json::Json}; +use data_encoding::BASE64; +use rocket::serde::json::Json; +use rocket::Route; use crate::{ api::{ - ApiResult, EmptyResult, JsonResult, PasswordOrOtpData, - core::log_user_event, - core::two_factor::{VerificationTokenData, generate_recover_code}, + core::log_user_event, core::two_factor::_generate_recover_code, ApiResult, EmptyResult, JsonResult, + PasswordOrOtpData, }, - auth::{Headers, two_factor, two_factor::DuoData}, + auth::Headers, crypto, db::{ - DbConn, models::{EventType, TwoFactor, TwoFactorType, User, UserId}, + DbConn, }, error::MapResult, http_client::make_http_request, + CONFIG, }; pub fn routes() -> Vec { - routes![get_duo, activate_duo, activate_duo_put, disable_duo,] + routes![get_duo, activate_duo, activate_duo_put,] +} + +#[derive(Serialize, Deserialize)] +struct DuoData { + host: String, // Duo API hostname + ik: String, // client id + sk: String, // client secret +} + +impl DuoData { + fn global() -> Option { + 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("") + } + 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 { @@ -32,7 +82,8 @@ enum DuoStatus { impl DuoStatus { fn data(self) -> Option { match self { - DuoStatus::Global(data) | DuoStatus::User(data) => Some(data), + DuoStatus::Global(data) => Some(data), + DuoStatus::User(data) => Some(data), DuoStatus::Disabled(_) => None, } } @@ -47,19 +98,22 @@ async fn get_duo(data: Json, headers: Headers, conn: DbConn) data.validate(&user, false, &conn).await?; - let (enabled, duo) = match get_user_duo_data(&user.uuid, &conn).await { + let data = get_user_duo_data(&user.uuid, &conn).await; + + let (enabled, data) = match data { DuoStatus::Global(_) => (true, Some(DuoData::secret())), DuoStatus::User(data) => (true, Some(data.obscure())), DuoStatus::Disabled(true) => (false, Some(DuoData::msg(DISABLED_MESSAGE_DEFAULT))), DuoStatus::Disabled(false) => (false, None), }; - let duo_json = if let Some(data) = duo.as_ref() { + let json = if let Some(data) = data { json!({ "enabled": enabled, "host": data.host, "clientSecret": data.sk, "clientId": data.ik, + "object": "twoFactorDuo" }) } else { json!({ @@ -67,13 +121,11 @@ async fn get_duo(data: Json, headers: Headers, conn: DbConn) "host": null, "clientSecret": null, "clientId": null, + "object": "twoFactorDuo" }) }; - Ok(Json(rocket::serde::json::json!({ - "duo": duo_json, - "userVerificationToken": two_factor::duo_token(user.uuid, duo, enabled), - }))) + Ok(Json(json)) } #[derive(Deserialize)] @@ -82,7 +134,8 @@ struct EnableDuoData { host: String, client_secret: String, client_id: String, - user_verification_token: String, + master_password_hash: Option, + otp: Option, } impl From for DuoData { @@ -109,7 +162,12 @@ async fn activate_duo(data: Json, headers: Headers, conn: DbConn) let data: EnableDuoData = data.into_inner(); let mut user = headers.user; - two_factor::validate_duo(&data.user_verification_token, &user.uuid, None, false)?; + PasswordOrOtpData { + 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_req: DuoData = data.into(); @@ -124,17 +182,16 @@ async fn activate_duo(data: Json, headers: Headers, conn: DbConn) let twofactor = TwoFactor::new(user.uuid.clone(), type_, data_str); twofactor.save(&conn).await?; - generate_recover_code(&mut user, &conn).await; + _generate_recover_code(&mut user, &conn).await; - log_user_event(EventType::UserUpdated2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; + log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; Ok(Json(json!({ - "duo": json!({ - "enabled": true, - "host": data.host, - "clientSecret": data.sk, - "clientId": data.ik, - }), + "enabled": true, + "host": data.host, + "clientSecret": data.sk, + "clientId": data.ik, + "object": "twoFactorDuo" }))) } @@ -143,38 +200,15 @@ async fn activate_duo_put(data: Json, headers: Headers, conn: DbC activate_duo(data, headers, conn).await } -#[delete("/two-factor/duo", data = "")] -async fn disable_duo(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { - let user = headers.user; - - if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Duo, &conn).await { - // Apply the same transformation than in `get_duo` to check we are disabling the correct one - let duo = match to_user_duo_data(&twofactor) { - DuoStatus::Global(_) => Some(DuoData::secret()), - DuoStatus::User(data) => Some(data.obscure()), - DuoStatus::Disabled(_) => None, - }; - - two_factor::validate_duo(&data.user_verification_token, &user.uuid, duo.as_ref(), true)?; - - twofactor.delete(&conn).await?; - log_user_event(EventType::UserDisabled2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; - } - - super::check_2fa_state(&user, headers.device.atype, &headers.ip.ip, &conn).await?; - - Ok(()) -} - async fn duo_api_request(method: &str, path: &str, params: &str, data: &DuoData) -> EmptyResult { - use reqwest::{Method, header}; + use reqwest::{header, Method}; use std::str::FromStr; // https://duo.com/docs/authapi#api-details - let url = format!("https://{}{path}", data.host); - let dt = Utc::now().to_rfc2822(); + let url = format!("https://{}{path}", &data.host); + let date = Utc::now().to_rfc2822(); let username = &data.ik; - let fields = [&dt, method, &data.host, path, params]; + let fields = [&date, method, &data.host, path, params]; let password = crypto::hmac_sign(&data.sk, &fields.join("\n")); let m = Method::from_str(method).unwrap_or_default(); @@ -182,7 +216,7 @@ async fn duo_api_request(method: &str, path: &str, params: &str, data: &DuoData) make_http_request(m, &url)? .basic_auth(username, Some(password)) .header(header::USER_AGENT, "vaultwarden:Duo/1.0 (Rust)") - .header(header::DATE, dt) + .header(header::DATE, date) .send() .await? .error_for_status()?; @@ -190,16 +224,21 @@ async fn duo_api_request(method: &str, path: &str, params: &str, data: &DuoData) Ok(()) } +const DUO_EXPIRE: i64 = 300; +const APP_EXPIRE: i64 = 3600; + +const AUTH_PREFIX: &str = "AUTH"; +const DUO_PREFIX: &str = "TX"; +const APP_PREFIX: &str = "APP"; + async fn get_user_duo_data(user_id: &UserId, conn: &DbConn) -> DuoStatus { + let type_ = TwoFactorType::Duo as i32; + // If the user doesn't have an entry, disabled - let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Duo, conn).await else { + let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, type_, conn).await else { return DuoStatus::Disabled(DuoData::global().is_some()); }; - to_user_duo_data(&twofactor) -} - -fn to_user_duo_data(twofactor: &TwoFactor) -> DuoStatus { // If the user has the required values, we use those if let Ok(data) = serde_json::from_str(&twofactor.data) { return DuoStatus::User(data); @@ -214,13 +253,117 @@ fn to_user_duo_data(twofactor: &TwoFactor) -> DuoStatus { DuoStatus::Disabled(false) } -// let (ik, sk, host) = get_duo_keys_email(); -pub(crate) async fn get_duo_keys_email(email: &str, conn: &DbConn) -> ApiResult<(String, String, String)> { +// let (ik, sk, ak, host) = get_duo_keys(); +pub(crate) async fn get_duo_keys_email(email: &str, conn: &DbConn) -> ApiResult<(String, String, String, String)> { let data = match User::find_by_mail(email, conn).await { Some(u) => get_user_duo_data(&u.uuid, conn).await.data(), _ => DuoData::global(), } .map_res("Can't fetch Duo Keys")?; - Ok((data.ik, data.sk, data.host)) + Ok((data.ik, data.sk, CONFIG.get_duo_akey().await, data.host)) +} + +pub async fn generate_duo_signature(email: &str, conn: &DbConn) -> ApiResult<(String, String)> { + let now = Utc::now().timestamp(); + + let (ik, sk, ak, host) = get_duo_keys_email(email, conn).await?; + + let duo_sign = sign_duo_values(&sk, email, &ik, DUO_PREFIX, now + DUO_EXPIRE); + let app_sign = sign_duo_values(&ak, email, &ik, APP_PREFIX, now + APP_EXPIRE); + + Ok((format!("{duo_sign}:{app_sign}"), host)) +} + +fn sign_duo_values(key: &str, email: &str, ikey: &str, prefix: &str, expire: i64) -> String { + let val = format!("{email}|{ikey}|{expire}"); + let cookie = format!("{prefix}|{}", BASE64.encode(val.as_bytes())); + + format!("{cookie}|{}", crypto::hmac_sign(key, &cookie)) +} + +pub async fn validate_duo_login(email: &str, response: &str, conn: &DbConn) -> EmptyResult { + let split: Vec<&str> = response.split(':').collect(); + if split.len() != 2 { + err!( + "Invalid response length", + ErrorEvent { + event: EventType::UserFailedLogIn2fa + } + ); + } + + let auth_sig = split[0]; + let app_sig = split[1]; + + let now = Utc::now().timestamp(); + + let (ik, sk, ak, _host) = get_duo_keys_email(email, conn).await?; + + let auth_user = parse_duo_values(&sk, auth_sig, &ik, AUTH_PREFIX, now)?; + let app_user = parse_duo_values(&ak, app_sig, &ik, APP_PREFIX, now)?; + + if !crypto::ct_eq(&auth_user, app_user) || !crypto::ct_eq(&auth_user, email) { + err!( + "Error validating duo authentication", + ErrorEvent { + event: EventType::UserFailedLogIn2fa + } + ) + } + + Ok(()) +} + +fn parse_duo_values(key: &str, val: &str, ikey: &str, prefix: &str, time: i64) -> ApiResult { + 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 = match expire.parse() { + Ok(e) => e, + Err(_) => err!("Invalid expire time"), + }; + + if time >= expire { + err!("Expired authorization") + } + + Ok(username.into()) } diff --git a/src/api/core/two_factor/duo_oidc.rs b/src/api/core/two_factor/duo_oidc.rs index e5e1df0f..144ffe84 100644 --- a/src/api/core/two_factor/duo_oidc.rs +++ b/src/api/core/two_factor/duo_oidc.rs @@ -1,24 +1,23 @@ -use std::collections::HashMap; - use chrono::Utc; use data_encoding::HEXLOWER; use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation}; -use reqwest::{StatusCode, header}; -use ring::digest::{Digest, SHA512_256, digest}; +use reqwest::{header, StatusCode}; +use ring::digest::{digest, Digest, SHA512_256}; use serde::Serialize; -use url::Url; +use std::collections::HashMap; use crate::{ - CONFIG, - api::{EmptyResult, core::two_factor::duo::get_duo_keys_email}, + api::{core::two_factor::duo::get_duo_keys_email, EmptyResult}, crypto, db::{ - DbConn, DbPool, models::{DeviceId, EventType, TwoFactorDuoContext}, + DbConn, DbPool, }, error::Error, http_client::make_http_request, + CONFIG, }; +use url::Url; // The location on this service that Duo should redirect users to. For us, this is a bridge // built in to the Bitwarden clients. @@ -125,7 +124,7 @@ impl DuoClient { ClientAssertion { iss: self.client_id.clone(), sub: self.client_id.clone(), - aud: url.to_owned(), + aud: url.to_string(), exp: now + JWT_VALIDITY_SECS, jti: jwt_id, iat: now, @@ -303,7 +302,7 @@ impl DuoClient { if !(matching_nonces && matching_usernames) { err!("Error validating Duo authorization, nonce or username mismatch.") - } + }; Ok(()) } @@ -348,7 +347,7 @@ pub async fn purge_duo_contexts(pool: DbPool) { if let Ok(conn) = pool.get().await { TwoFactorDuoContext::purge_expired_duo_contexts(&conn).await; } else { - error!("Failed to get DB connection while purging expired Duo authentications"); + error!("Failed to get DB connection while purging expired Duo authentications") } } @@ -383,7 +382,7 @@ pub async fn get_duo_auth_url( device_identifier: &DeviceId, conn: &DbConn, ) -> Result { - let (ik, sk, host) = get_duo_keys_email(email, conn).await?; + let (ik, sk, _, host) = get_duo_keys_email(email, conn).await?; let callback_url = match make_callback_url(client_id) { Ok(url) => url, @@ -395,7 +394,7 @@ pub async fn get_duo_auth_url( match client.health_check().await { Ok(()) => {} Err(e) => return Err(e), - } + }; // Generate random OAuth2 state and OIDC Nonce let state: String = crypto::get_random_string_alphanum(STATE_LENGTH); @@ -435,17 +434,20 @@ pub async fn validate_duo_login( let code = split[0]; let state = split[1]; - let (ik, sk, host) = get_duo_keys_email(email, conn).await?; + let (ik, sk, _, host) = get_duo_keys_email(email, conn).await?; // Get the context by the state reported by the client. If we don't have one, // it means the context is either missing or expired. - let Some(ctx) = extract_context(state, conn).await else { - err!( - "Error validating duo authentication", - ErrorEvent { - event: EventType::UserFailedLogIn2fa - } - ) + let ctx = match extract_context(state, conn).await { + Some(c) => c, + None => { + err!( + "Error validating duo authentication", + ErrorEvent { + event: EventType::UserFailedLogIn2fa + } + ) + } }; // Context validation steps @@ -474,13 +476,13 @@ pub async fn validate_duo_login( match client.health_check().await { Ok(()) => {} Err(e) => return Err(e), - } + }; let d: Digest = digest(&SHA512_256, format!("{}{device_identifier}", ctx.nonce).as_bytes()); let hash: String = HEXLOWER.encode(d.as_ref()); match client.exchange_authz_code_for_result(code, email, hash.as_str()).await { - Ok(()) => Ok(()), + Ok(_) => Ok(()), Err(_) => { err!( "Error validating duo authentication", diff --git a/src/api/core/two_factor/email.rs b/src/api/core/two_factor/email.rs index 15bfdbc3..e7d1aed2 100644 --- a/src/api/core/two_factor/email.rs +++ b/src/api/core/two_factor/email.rs @@ -1,37 +1,31 @@ use chrono::{DateTime, TimeDelta, Utc}; -use rocket::{Route, serde::json::Json}; +use rocket::serde::json::Json; +use rocket::Route; use crate::{ - CONFIG, api::{ + core::{log_user_event, two_factor::_generate_recover_code}, EmptyResult, JsonResult, PasswordOrOtpData, - core::{ - log_user_event, - two_factor::{VerificationTokenData, generate_recover_code}, - }, }, - auth::{ClientHeaders, Headers, two_factor}, + auth::{ClientHeaders, Headers}, crypto, db::{ + models::{AuthRequest, AuthRequestId, DeviceId, EventType, TwoFactor, TwoFactorType, User, UserId}, DbConn, - models::{ - AuthRequest, AuthRequestId, DeviceId, EventType, TwoFactor, TwoFactorIncomplete, TwoFactorType, User, - UserId, - }, }, error::{Error, MapResult}, - mail, + mail, CONFIG, }; pub fn routes() -> Vec { - routes![get_email, send_email_login, send_email, email, disable_email] + routes![get_email, send_email_login, send_email, email,] } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct SendEmailLoginData { #[serde(alias = "DeviceIdentifier")] - device_identifier: Option, + device_identifier: DeviceId, #[serde(alias = "Email")] email: Option, #[serde(alias = "MasterPasswordHash")] @@ -69,71 +63,37 @@ async fn send_email_login(data: Json, client_headers: Client let user = if let Some(email) = email { let Some(user) = User::find_by_mail(email, &conn).await else { - err!( - "Username or password is incorrect. Try again", - format!("IP: {}. Username: {}.", client_headers.ip.ip, email.escape_debug()) - ) + err!("Username or password is incorrect. Try again.") }; if let Some(master_password_hash) = master_password_hash { // Check password if !user.check_valid_password(master_password_hash) { - err!( - "Username or password is incorrect. Try again", - format!("IP: {}. Username: {}.", client_headers.ip.ip, email.escape_debug()) - ) + err!("Username or password is incorrect. Try again.") } } else if let Some(auth_request_id) = auth_request_id { + let Some(auth_request) = AuthRequest::find_by_uuid(auth_request_id, &conn).await else { + err!("AuthRequest doesn't exist", "User not found") + }; let Some(code) = &data.auth_request_access_code else { err!("no auth request access code") }; - let Some(auth_request) = AuthRequest::find_by_uuid_and_user(auth_request_id, &user.uuid, &conn).await - else { - err!("AuthRequest doesn't exist", "Invalid auth request or does not belong to user") - }; - let request_expired = Utc::now().naive_utc() >= auth_request.creation_date + TimeDelta::minutes(5); - if !auth_request.approved.unwrap_or(false) - || auth_request.authentication_date.is_some() - || request_expired - || auth_request.device_type != client_headers.device_type + if auth_request.device_type != client_headers.device_type || auth_request.request_ip != client_headers.ip.ip.to_string() || !auth_request.check_access_code(code) { - err!( - "AuthRequest doesn't exist", - "Auth request not approved, already used or expired, or 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 { err!("No password hash has been submitted.") } user } else { - let Some(device_identifier) = &data.device_identifier else { - err!("No device identifier has been submitted.") - }; // SSO login only sends device id, so we get the user by the most recently used device - let Some(user) = User::find_by_device_for_email2fa(device_identifier, &conn).await else { - err!( - "Username or password is incorrect. Try again", - format!("IP: {}. Device: {}.", client_headers.ip.ip, device_identifier.to_string().escape_debug()) - ) + let Some(user) = User::find_by_device_for_email2fa(&data.device_identifier, &conn).await else { + err!("Username or password is incorrect. Try again.") }; user @@ -144,8 +104,8 @@ async fn send_email_login(data: Json, client_headers: Client /// Generate the token, save the data for later verification and send email to user pub async fn send_token(user_id: &UserId, conn: &DbConn) -> EmptyResult { - let mut twofactor = - TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Email, conn).await.map_res("Two factor not found")?; + let type_ = TwoFactorType::Email as i32; + let mut twofactor = TwoFactor::find_by_user_and_type(user_id, type_, conn).await.map_res("Two factor not found")?; let generated_token = crypto::generate_email_token(CONFIG.email_token_size()); @@ -168,19 +128,18 @@ async fn get_email(data: Json, headers: Headers, conn: DbConn data.validate(&user, false, &conn).await?; let (enabled, mfa_email) = - if let Some(x) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email, &conn).await { - let twofactor_data = EmailTokenData::from_json(&x.data)?; - (true, Some(twofactor_data.email)) - } else { - (false, None) + match TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email as i32, &conn).await { + Some(x) => { + let twofactor_data = EmailTokenData::from_json(&x.data)?; + (true, json!(twofactor_data.email)) + } + _ => (false, serde_json::value::Value::Null), }; - Ok(Json(rocket::serde::json::json!({ - "email": rocket::serde::json::json!({ - "enabled": enabled, - "email": mfa_email, - }), - "userVerificationToken": two_factor::email_token(user.uuid, mfa_email, enabled), + Ok(Json(json!({ + "email": mfa_email, + "enabled": enabled, + "object": "twoFactorEmail" }))) } @@ -189,36 +148,43 @@ async fn get_email(data: Json, headers: Headers, conn: DbConn struct SendEmailData { /// Email where 2FA codes will be sent to, can be different than user email account. email: String, - user_verification_token: String, + master_password_hash: Option, + otp: Option, } /// Send a verification email to the specified email address to check whether it exists/belongs to user. #[post("/two-factor/send-email", data = "")] -async fn send_email(data: Json, headers: Headers, conn: DbConn) -> JsonResult { +async fn send_email(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { let data: SendEmailData = data.into_inner(); let user = headers.user; - two_factor::validate_email(&data.user_verification_token, &user.uuid, data.email.clone(), false)?; + PasswordOrOtpData { + master_password_hash: data.master_password_hash, + otp: data.otp, + } + .validate(&user, false, &conn) + .await?; if !CONFIG._enable_email_2fa() { err!("Email 2FA is disabled") } - if let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email, &conn).await { + let type_ = TwoFactorType::Email as i32; + + if let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await { tf.delete(&conn).await?; } let generated_token = crypto::generate_email_token(CONFIG.email_token_size()); - let twofactor_data = EmailTokenData::new(data.email, Some(generated_token)); + let twofactor_data = EmailTokenData::new(data.email, generated_token); // Uses EmailVerificationChallenge as type to show that it's not verified yet. - let twofactor = - TwoFactor::new(user.uuid.clone(), TwoFactorType::EmailVerificationChallenge, twofactor_data.to_json()); + let twofactor = TwoFactor::new(user.uuid, TwoFactorType::EmailVerificationChallenge, twofactor_data.to_json()); twofactor.save(&conn).await?; mail::send_token(&twofactor_data.email, &twofactor_data.last_token.map_res("Token is empty")?).await?; - Ok(Json(json!({}))) + Ok(()) } #[derive(Deserialize, Serialize)] @@ -226,7 +192,8 @@ async fn send_email(data: Json, headers: Headers, conn: DbConn) - struct EmailData { email: String, token: String, - user_verification_token: String, + master_password_hash: Option, + otp: Option, } /// Verify email belongs to user and can be used for 2FA email codes. @@ -235,11 +202,17 @@ async fn email(data: Json, headers: Headers, conn: DbConn) -> JsonRes let data: EmailData = data.into_inner(); let mut user = headers.user; - two_factor::validate_email(&data.user_verification_token, &user.uuid, data.email, false)?; + // This is the last step in the verification process, delete the otp directly afterwards + PasswordOrOtpData { + master_password_hash: data.master_password_hash, + otp: data.otp, + } + .validate(&user, true, &conn) + .await?; - let mut twofactor = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::EmailVerificationChallenge, &conn) - .await - .map_res("Two factor not found")?; + let type_ = TwoFactorType::EmailVerificationChallenge as i32; + let mut twofactor = + TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await.map_res("Two factor not found")?; let mut email_data = EmailTokenData::from_json(&twofactor.data)?; @@ -256,28 +229,15 @@ async fn email(data: Json, headers: Headers, conn: DbConn) -> JsonRes twofactor.data = email_data.to_json(); twofactor.save(&conn).await?; - generate_recover_code(&mut user, &conn).await; + _generate_recover_code(&mut user, &conn).await; - log_user_event(EventType::UserUpdated2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; + log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; - Ok(Json(json!({}))) -} - -#[delete("/two-factor/email", data = "")] -async fn disable_email(data: Json, 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(()) + Ok(Json(json!({ + "email": email_data.email, + "enabled": "true", + "object": "twoFactorEmail" + }))) } /// Validate the email code when used as TwoFactor token mechanism @@ -289,8 +249,9 @@ pub async fn validate_email_code_str( conn: &DbConn, ) -> EmptyResult { let mut email_data = EmailTokenData::from_json(data)?; - let mut twofactor = - TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Email, conn).await.map_res("Two factor not found")?; + let mut twofactor = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Email as i32, conn) + .await + .map_res("Two factor not found")?; let Some(issued_token) = &email_data.last_token else { err!( format!("No token available! IP: {ip}"), @@ -302,7 +263,7 @@ pub async fn validate_email_code_str( if !crypto::ct_eq(issued_token, token) { email_data.add_attempt(); - if email_data.attempts >= u64::from(CONFIG.email_attempts_limit()) { + if email_data.attempts >= CONFIG.email_attempts_limit() { email_data.reset_token(); } twofactor.data = email_data.to_json(); @@ -320,9 +281,9 @@ pub async fn validate_email_code_str( twofactor.data = email_data.to_json(); twofactor.save(conn).await?; - let dt = DateTime::from_timestamp(email_data.token_sent, 0).expect("Email token timestamp invalid.").naive_utc(); - let max_time = i64::from(CONFIG.email_expiration_time()); - if dt + TimeDelta::try_seconds(max_time).unwrap() < Utc::now().naive_utc() { + let date = DateTime::from_timestamp(email_data.token_sent, 0).expect("Email token timestamp invalid.").naive_utc(); + let max_time = CONFIG.email_expiration_time() as i64; + if date + TimeDelta::try_seconds(max_time).unwrap() < Utc::now().naive_utc() { err!( "Token has expired", ErrorEvent { @@ -349,10 +310,10 @@ pub struct EmailTokenData { } impl EmailTokenData { - pub fn new(email: String, token: Option) -> EmailTokenData { + pub fn new(email: String, token: String) -> EmailTokenData { EmailTokenData { email, - last_token: token, + last_token: Some(token), token_sent: Utc::now().timestamp(), attempts: 0, } @@ -378,10 +339,9 @@ impl EmailTokenData { pub fn from_json(string: &str) -> Result { let res: Result = serde_json::from_str(string); - if let Ok(x) = res { - Ok(x) - } else { - err!("Could not decode EmailTokenData from string") + match res { + Ok(x) => Ok(x), + Err(_) => err!("Could not decode EmailTokenData from string"), } } } @@ -390,8 +350,7 @@ pub async fn activate_email_2fa(user: &User, conn: &DbConn) -> EmptyResult { if user.verified_at.is_none() { err!("Auto-enabling of email 2FA failed because the users email address has not been verified!"); } - // The token is set when the first code is sent - let twofactor_data = EmailTokenData::new(user.email.clone(), None); + let twofactor_data = EmailTokenData::new(user.email.clone(), String::new()); let twofactor = TwoFactor::new(user.uuid.clone(), TwoFactorType::Email, twofactor_data.to_json()); twofactor.save(conn).await } @@ -400,17 +359,18 @@ pub async fn activate_email_2fa(user: &User, conn: &DbConn) -> EmptyResult { pub fn obscure_email(email: &str) -> String { let split: Vec<&str> = email.rsplitn(2, '@').collect(); - let mut name = split[1].to_owned(); + let mut name = split[1].to_string(); let domain = &split[0]; let name_size = name.chars().count(); - let new_name = if let 1..=3 = name_size { - "*".repeat(name_size) - } else { - let stars = "*".repeat(name_size - 2); - name.truncate(2); - format!("{name}{stars}") + let new_name = match name_size { + 1..=3 => "*".repeat(name_size), + _ => { + let stars = "*".repeat(name_size - 2); + name.truncate(2); + format!("{name}{stars}") + } }; format!("{new_name}@{domain}") diff --git a/src/api/core/two_factor/mod.rs b/src/api/core/two_factor/mod.rs index cfec98c7..3a503a23 100644 --- a/src/api/core/two_factor/mod.rs +++ b/src/api/core/two_factor/mod.rs @@ -1,23 +1,28 @@ use chrono::{TimeDelta, Utc}; use data_encoding::BASE32; use num_traits::FromPrimitive; -use rocket::{Route, serde::json::Json}; +use rocket::serde::json::Json; +use rocket::Route; use serde::Deserialize; use serde_json::Value; use crate::{ - CONFIG, - api::{EmptyResult, JsonResult, PasswordOrOtpData, core::log_event}, + api::{ + core::{log_event, log_user_event}, + EmptyResult, JsonResult, PasswordOrOtpData, + }, auth::Headers, crypto, db::{ - DbConn, DbPool, models::{ - Device, DeviceType, EventType, Membership, MembershipType, OrgPolicyType, Organization, OrganizationId, - TwoFactor, TwoFactorIncomplete, TwoFactorType, User, UserId, + DeviceType, EventType, Membership, MembershipType, OrgPolicyType, Organization, OrganizationId, TwoFactor, + TwoFactorIncomplete, TwoFactorType, User, UserId, }, + DbConn, DbPool, }, mail, + util::NumberOrString, + CONFIG, }; pub mod authenticator; @@ -32,7 +37,7 @@ fn has_global_duo_credentials() -> bool { CONFIG._enable_duo() && CONFIG.duo_host().is_some() && CONFIG.duo_ikey().is_some() && CONFIG.duo_skey().is_some() } -pub fn is_twofactor_provider_usable(provider_type: &TwoFactorType, provider_data: Option<&str>) -> bool { +pub fn is_twofactor_provider_usable(provider_type: TwoFactorType, provider_data: Option<&str>) -> bool { #[derive(Deserialize)] struct DuoProviderData { host: String, @@ -41,7 +46,7 @@ pub fn is_twofactor_provider_usable(provider_type: &TwoFactorType, provider_data } match provider_type { - TwoFactorType::Authenticator | TwoFactorType::RecoveryCode => true, + TwoFactorType::Authenticator => true, TwoFactorType::Email => CONFIG._enable_email_2fa(), TwoFactorType::Duo | TwoFactorType::OrganizationDuo => { provider_data @@ -54,6 +59,7 @@ pub fn is_twofactor_provider_usable(provider_type: &TwoFactorType, provider_data } TwoFactorType::Webauthn => CONFIG.is_webauthn_2fa_supported(), TwoFactorType::Remember => !CONFIG.disable_2fa_remember(), + TwoFactorType::RecoveryCode => true, TwoFactorType::U2f | TwoFactorType::U2fRegisterChallenge | TwoFactorType::U2fLoginChallenge @@ -65,7 +71,13 @@ pub fn is_twofactor_provider_usable(provider_type: &TwoFactorType, provider_data } pub fn routes() -> Vec { - let mut routes = routes![get_twofactor, get_recover, get_device_verification_settings]; + let mut routes = routes![ + get_twofactor, + get_recover, + disable_twofactor, + disable_twofactor_put, + get_device_verification_settings, + ]; routes.append(&mut authenticator::routes()); routes.append(&mut duo::routes()); @@ -77,12 +89,6 @@ pub fn routes() -> Vec { routes } -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct VerificationTokenData { - user_verification_token: String, -} - #[get("/two-factor")] async fn get_twofactor(headers: Headers, conn: DbConn) -> Json { let twofactors = TwoFactor::find_by_user(&headers.user.uuid, &conn).await; @@ -90,7 +96,7 @@ async fn get_twofactor(headers: Headers, conn: DbConn) -> Json { .iter() .filter_map(|tf| { let provider_type = TwoFactorType::from_i32(tf.atype)?; - is_twofactor_provider_usable(&provider_type, Some(&tf.data)).then(|| TwoFactor::to_json_provider(tf)) + is_twofactor_provider_usable(provider_type, Some(&tf.data)).then(|| TwoFactor::to_json_provider(tf)) }) .collect(); @@ -114,7 +120,7 @@ async fn get_recover(data: Json, headers: Headers, conn: DbCo }))) } -async fn generate_recover_code(user: &mut User, conn: &DbConn) { +async fn _generate_recover_code(user: &mut User, conn: &DbConn) { if user.totp_recover.is_none() { let totp_recover = crypto::encode_random_bytes::<20>(&BASE32); user.totp_recover = Some(totp_recover); @@ -122,14 +128,49 @@ async fn generate_recover_code(user: &mut User, conn: &DbConn) { } } -/// Call after a 2FA provider, or one of its keys, was removed. No remembered device may keep skipping -/// the providers that are left, and once every provider is gone the 2FA policy applies. -pub async fn check_2fa_state(user: &User, device_type: i32, ip: &std::net::IpAddr, conn: &DbConn) -> EmptyResult { - Device::clear_twofactor_remember_by_user(&user.uuid, conn).await?; - if TwoFactor::find_by_user(&user.uuid, conn).await.is_empty() { - enforce_2fa_policy(user, &user.uuid, device_type, ip, conn).await?; +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct DisableTwoFactorData { + master_password_hash: Option, + otp: Option, + r#type: NumberOrString, +} + +#[post("/two-factor/disable", data = "")] +async fn disable_twofactor(data: Json, 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, } - Ok(()) + .validate(&user, true, &conn) + .await?; + + let type_ = data.r#type.into_i32()?; + + if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await { + twofactor.delete(&conn).await?; + 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 = "")] +async fn disable_twofactor_put(data: Json, headers: Headers, conn: DbConn) -> JsonResult { + disable_twofactor(data, headers, conn).await } pub async fn enforce_2fa_policy( @@ -139,7 +180,9 @@ pub async fn enforce_2fa_policy( ip: &std::net::IpAddr, conn: &DbConn, ) -> EmptyResult { - for member in Membership::find_by_user_and_policy(&user.uuid, OrgPolicyType::TwoFactorAuthentication, conn).await { + for member in + Membership::find_by_user_and_policy(&user.uuid, OrgPolicyType::TwoFactorAuthentication, conn).await.into_iter() + { // Policy only applies to non-Owner/non-Admin members who have accepted joining the org if member.atype < MembershipType::Admin { if CONFIG.mail_enabled() { @@ -151,7 +194,7 @@ pub async fn enforce_2fa_policy( member.save(conn).await?; log_event( - EventType::OrganizationUserRevokedTwoFactorNonCompliance, + EventType::OrganizationUserRevoked as i32, &member.uuid, &member.org_uuid, act_user_id, @@ -174,7 +217,7 @@ pub async fn enforce_2fa_policy_for_org( conn: &DbConn, ) -> EmptyResult { let org = Organization::find_by_uuid(org_id, conn).await.unwrap(); - for member in Membership::find_confirmed_by_org(org_id, conn).await { + for member in Membership::find_confirmed_by_org(org_id, conn).await.into_iter() { // Don't enforce the policy for Admins and Owners. if member.atype < MembershipType::Admin && TwoFactor::find_by_user(&member.user_uuid, conn).await.is_empty() { if CONFIG.mail_enabled() { @@ -186,7 +229,7 @@ pub async fn enforce_2fa_policy_for_org( member.save(conn).await?; log_event( - EventType::OrganizationUserRevokedTwoFactorNonCompliance, + EventType::OrganizationUserRevoked as i32, &member.uuid, org_id, act_user_id, @@ -204,31 +247,24 @@ pub async fn enforce_2fa_policy_for_org( pub async fn send_incomplete_2fa_notifications(pool: DbPool) { debug!("Sending notifications for incomplete 2FA logins"); - if CONFIG.incomplete_2fa_time_limit() == 0 || !CONFIG.mail_enabled() { + if CONFIG.incomplete_2fa_time_limit() <= 0 || !CONFIG.mail_enabled() { return; } - let Ok(conn) = pool.get().await else { - error!("Failed to get DB connection in send_incomplete_2fa_notifications()"); - return; + let conn = match pool.get().await { + Ok(conn) => conn, + _ => { + error!("Failed to get DB connection in send_incomplete_2fa_notifications()"); + return; + } }; let now = Utc::now().naive_utc(); - let Some(time_limit) = TimeDelta::try_minutes(i64::from(CONFIG.incomplete_2fa_time_limit())) else { - error!("Failed to calculate the time limit for sending incomplete 2fa attempts"); - return; - }; - let Some(time_before) = now.checked_sub_signed(time_limit) else { - error!("Failed to calculate the timestamp for sending incomplete 2fa attempts"); - return; - }; - + let time_limit = TimeDelta::try_minutes(CONFIG.incomplete_2fa_time_limit()).unwrap(); + let time_before = now - time_limit; let incomplete_logins = TwoFactorIncomplete::find_logins_before(&time_before, &conn).await; for login in incomplete_logins { - let Some(user) = User::find_by_uuid(&login.user_uuid, &conn).await else { - error!("User '{}', not found!", login.user_uuid); - continue; - }; + let user = User::find_by_uuid(&login.user_uuid, &conn).await.expect("User not found"); info!( "User {} did not complete a 2FA login within the configured time limit. IP: {}", user.email, login.ip_address @@ -242,7 +278,7 @@ pub async fn send_incomplete_2fa_notifications(pool: DbPool) { ) .await { - Ok(()) => { + Ok(_) => { if let Err(e) = login.delete(&conn).await { error!("Error deleting incomplete 2FA record: {e:#?}"); } diff --git a/src/api/core/two_factor/protected_actions.rs b/src/api/core/two_factor/protected_actions.rs index bf757d14..800a6cf4 100644 --- a/src/api/core/two_factor/protected_actions.rs +++ b/src/api/core/two_factor/protected_actions.rs @@ -1,17 +1,16 @@ -use chrono::{NaiveDateTime, TimeDelta, Utc, naive::serde::ts_seconds}; -use rocket::{Route, serde::json::Json}; +use chrono::{naive::serde::ts_seconds, NaiveDateTime, TimeDelta, Utc}; +use rocket::{serde::json::Json, Route}; use crate::{ - CONFIG, api::EmptyResult, auth::Headers, crypto, db::{ - DbConn, models::{TwoFactor, TwoFactorType, UserId}, + DbConn, }, error::{Error, MapResult}, - mail, + mail, CONFIG, }; pub fn routes() -> Vec { @@ -45,10 +44,9 @@ impl ProtectedActionData { pub fn from_json(string: &str) -> Result { let res: Result = serde_json::from_str(string); - if let Ok(x) = res { - Ok(x) - } else { - err!("Could not decode ProtectedActionData from string") + match res { + Ok(x) => Ok(x), + Err(_) => err!("Could not decode ProtectedActionData from string"), } } @@ -64,15 +62,14 @@ impl ProtectedActionData { #[post("/accounts/request-otp")] async fn request_otp(headers: Headers, conn: DbConn) -> EmptyResult { if !CONFIG.mail_enabled() { - err!( - "Email is disabled for this server. Either enable email or login using your master password instead of login via device." - ); + err!("Email is disabled for this server. Either enable email or login using your master password instead of login via device."); } let user = headers.user; // Only one Protected Action per user is allowed to take place, delete the previous one - if let Some(pa) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::ProtectedActions, &conn).await { + if let Some(pa) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::ProtectedActions as i32, &conn).await + { let pa_data = ProtectedActionData::from_json(&pa.data)?; let elapsed = pa_data.time_since_sent().num_seconds(); let delay = 30; @@ -105,9 +102,7 @@ struct ProtectedActionVerify { #[post("/accounts/verify-otp", data = "")] async fn verify_otp(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { if !CONFIG.mail_enabled() { - err!( - "Email is disabled for this server. Either enable email or login using your master password instead of login via device." - ); + err!("Email is disabled for this server. Either enable email or login using your master password instead of login via device."); } let user = headers.user; @@ -124,28 +119,27 @@ pub async fn validate_protected_action_otp( delete_if_valid: bool, conn: &DbConn, ) -> EmptyResult { - let mut pa = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::ProtectedActions, conn) + let mut pa = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::ProtectedActions as i32, conn) .await .map_res("Protected action token not found, try sending the code again or restart the process")?; let mut pa_data = ProtectedActionData::from_json(&pa.data)?; + pa_data.add_attempt(); + pa.data = pa_data.to_json(); // Fail after x attempts if the token has been used too many times. // Don't delete it, as we use it to keep track of attempts. - if pa_data.attempts >= u64::from(CONFIG.email_attempts_limit()) { + if pa_data.attempts >= CONFIG.email_attempts_limit() { err!("Token has expired") } // Check if the token has expired (Using the email 2fa expiration time) - let max_time = i64::from(CONFIG.email_expiration_time()); + let max_time = CONFIG.email_expiration_time() as i64; if pa_data.time_since_sent().num_seconds() > max_time { pa.delete(conn).await?; err!("Token has expired") } if !crypto::ct_eq(&pa_data.token, otp) { - pa_data.add_attempt(); - pa.data = pa_data.to_json(); - pa.save(conn).await?; err!("Token is invalid") } diff --git a/src/api/core/two_factor/webauthn.rs b/src/api/core/two_factor/webauthn.rs index 7eab5546..0ec0e30e 100644 --- a/src/api/core/two_factor/webauthn.rs +++ b/src/api/core/two_factor/webauthn.rs @@ -1,37 +1,34 @@ -use std::{collections::HashSet, str::FromStr, sync::LazyLock, time::Duration}; - -use rocket::{Route, serde::json::Json}; +use crate::{ + api::{ + core::{log_user_event, two_factor::_generate_recover_code}, + EmptyResult, JsonResult, PasswordOrOtpData, + }, + auth::Headers, + crypto::ct_eq, + db::{ + models::{EventType, TwoFactor, TwoFactorType, UserId}, + DbConn, + }, + error::Error, + util::NumberOrString, + CONFIG, +}; +use rocket::serde::json::Json; +use rocket::Route; use serde_json::Value; +use std::str::FromStr; +use std::sync::LazyLock; +use std::time::Duration; use url::Url; use uuid::Uuid; -use webauthn_rs::{ - Webauthn, WebauthnBuilder, - prelude::{Base64UrlSafeData, Credential, Passkey, PasskeyAuthentication, PasskeyRegistration}, -}; +use webauthn_rs::prelude::{Base64UrlSafeData, Credential, Passkey, PasskeyAuthentication, PasskeyRegistration}; +use webauthn_rs::{Webauthn, WebauthnBuilder}; use webauthn_rs_proto::{ AuthenticationExtensionsClientOutputs, AuthenticatorAssertionResponseRaw, AuthenticatorAttestationResponseRaw, PublicKeyCredential, RegisterPublicKeyCredential, RegistrationExtensionsClientOutputs, RequestAuthenticationExtensions, UserVerificationPolicy, }; -use crate::{ - CONFIG, - api::{ - EmptyResult, JsonResult, PasswordOrOtpData, - core::{ - log_user_event, - two_factor::{VerificationTokenData, generate_recover_code}, - }, - }, - auth::{Headers, two_factor}, - crypto::ct_eq, - db::{ - DbConn, - models::{EventType, TwoFactor, TwoFactorType, UserId}, - }, - error::Error, -}; - static WEBAUTHN: LazyLock = LazyLock::new(|| { let domain = CONFIG.domain(); let domain_origin = CONFIG.domain_origin(); @@ -41,20 +38,13 @@ static WEBAUTHN: LazyLock = LazyLock::new(|| { let webauthn = WebauthnBuilder::new(&rp_id, &rp_origin) .expect("Creating WebauthnBuilder failed") .rp_name(&domain) - .timeout(Duration::from_mins(1)); + .timeout(Duration::from_millis(60000)); webauthn.build().expect("Building Webauthn failed") }); pub fn routes() -> Vec { - routes![ - get_webauthn, - generate_webauthn_challenge, - activate_webauthn, - activate_webauthn_put, - delete_webauthn, - delete_webauthns - ] + routes![get_webauthn, generate_webauthn_challenge, activate_webauthn, activate_webauthn_put, delete_webauthn,] } // Some old u2f structs still needed for migrating from u2f to WebAuthn @@ -128,40 +118,38 @@ async fn get_webauthn(data: Json, headers: Headers, conn: DbC data.validate(&user, false, &conn).await?; let (enabled, registrations) = get_webauthn_registrations(&user.uuid, &conn).await?; - let keys: Vec = registrations.iter().map(|r| r.id).collect(); let registrations_json: Vec = registrations.iter().map(WebauthnRegistration::to_json).collect(); Ok(Json(json!({ - "webAuthn": json!({ - "enabled": enabled, - "keys": registrations_json, - }), - "userVerificationToken": two_factor::webauthn_token(user.uuid, keys, enabled), + "enabled": enabled, + "keys": registrations_json, + "object": "twoFactorWebAuthn" }))) } #[post("/two-factor/get-webauthn-challenge", data = "")] -async fn generate_webauthn_challenge(data: Json, headers: Headers, conn: DbConn) -> JsonResult { +async fn generate_webauthn_challenge(data: Json, headers: Headers, conn: DbConn) -> JsonResult { + let data: PasswordOrOtpData = data.into_inner(); let user = headers.user; - let (enabled, registrations) = get_webauthn_registrations(&user.uuid, &conn).await?; - let keys: Vec = registrations.iter().map(|r| r.id).collect(); - let creds = registrations + data.validate(&user, false, &conn).await?; + + let registrations = get_webauthn_registrations(&user.uuid, &conn) + .await? + .1 .into_iter() .map(|r| r.credential.cred_id().to_owned()) // We return the credentialIds to the clients to avoid double registering .collect(); - two_factor::validate_webauthn(&data.user_verification_token, &user.uuid, &keys, enabled)?; - let (mut challenge, state) = WEBAUTHN.start_passkey_registration( Uuid::from_str(&user.uuid).expect("Failed to parse UUID"), // Should never fail &user.email, user.display_name(), - Some(creds), + Some(registrations), )?; let mut state = serde_json::to_value(&state)?; - state["rs"]["policy"] = Value::String("discouraged".to_owned()); + state["rs"]["policy"] = Value::String("discouraged".to_string()); state["rs"]["extensions"].as_object_mut().unwrap().clear(); let type_ = TwoFactorType::WebauthnRegisterChallenge; @@ -177,19 +165,17 @@ async fn generate_webauthn_challenge(data: Json, headers: let mut challenge_value = serde_json::to_value(challenge.public_key)?; challenge_value["status"] = "ok".into(); challenge_value["errorMessage"] = "".into(); - - Ok(Json(json!({ - "options": challenge_value - }))) + Ok(Json(challenge_value)) } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct EnableWebauthnData { - id: i32, + id: NumberOrString, // 1..5 name: String, device_response: RegisterPublicKeyCredentialCopy, - user_verification_token: String, + master_password_hash: Option, + otp: Option, } #[derive(Debug, Deserialize)] @@ -270,27 +256,31 @@ async fn activate_webauthn(data: Json, headers: Headers, con let data: EnableWebauthnData = data.into_inner(); let mut user = headers.user; - let mut registrations: Vec<_> = get_webauthn_registrations(&user.uuid, &conn).await?.1; - let keys: Vec = registrations.iter().map(|r| r.id).collect(); - two_factor::validate_webauthn(&data.user_verification_token, &user.uuid, &keys, !keys.is_empty())?; + PasswordOrOtpData { + master_password_hash: data.master_password_hash, + otp: data.otp, + } + .validate(&user, true, &conn) + .await?; // Retrieve and delete the saved challenge state - let state = if let Some(tf) = - TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::WebauthnRegisterChallenge, &conn).await - { - let state: PasskeyRegistration = serde_json::from_str(&tf.data)?; - tf.delete(&conn).await?; - state - } else { - err!("Can't recover challenge") + let type_ = TwoFactorType::WebauthnRegisterChallenge as i32; + let state = match TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await { + Some(tf) => { + let state: PasskeyRegistration = serde_json::from_str(&tf.data)?; + tf.delete(&conn).await?; + state + } + None => err!("Can't recover challenge"), }; // Verify the credentials with the saved state let credential = WEBAUTHN.finish_passkey_registration(&data.device_response.into(), &state)?; + let mut registrations: Vec<_> = get_webauthn_registrations(&user.uuid, &conn).await?.1; // TODO: Check for repeated ID's registrations.push(WebauthnRegistration { - id: data.id, + id: data.id.into_i32()?, name: data.name, migrated: false, @@ -301,15 +291,15 @@ async fn activate_webauthn(data: Json, headers: Headers, con TwoFactor::new(user.uuid.clone(), TwoFactorType::Webauthn, serde_json::to_string(®istrations)?) .save(&conn) .await?; - generate_recover_code(&mut user, &conn).await; + _generate_recover_code(&mut user, &conn).await; - log_user_event(EventType::UserUpdated2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; + log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; + let keys_json: Vec = registrations.iter().map(WebauthnRegistration::to_json).collect(); Ok(Json(json!({ - "webAuthn": json!({ - "enabled": true, - "keys": registrations.iter().map(WebauthnRegistration::to_json).collect::>(), - }), + "enabled": true, + "keys": keys_json, + "object": "twoFactorU2f" }))) } @@ -320,87 +310,56 @@ async fn activate_webauthn_put(data: Json, headers: Headers, #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -struct DeleteWebauthnData { - id: i32, - user_verification_token: String, +struct DeleteU2FData { + id: NumberOrString, + master_password_hash: String, } #[delete("/two-factor/webauthn", data = "")] -async fn delete_webauthn(data: Json, headers: Headers, conn: DbConn) -> JsonResult { - inner_delete_webauthns(&data.user_verification_token, |key| key.id != data.id, headers, &conn).await -} +async fn delete_webauthn(data: Json, headers: Headers, conn: DbConn) -> JsonResult { + let id = data.id.into_i32()?; + if !headers.user.check_valid_password(&data.master_password_hash) { + err!("Invalid password"); + } -#[delete("/two-factor/webauthn/all", data = "")] -async fn delete_webauthns(data: Json, headers: Headers, conn: DbConn) -> JsonResult { - inner_delete_webauthns(&data.user_verification_token, |_| false, headers, &conn).await -} - -async fn inner_delete_webauthns( - token: &str, - retain: impl Fn(&WebauthnRegistration) -> bool, - headers: Headers, - conn: &DbConn, -) -> JsonResult { - let user = headers.user; - - let Some(mut tf) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Webauthn, conn).await else { + let Some(mut tf) = + TwoFactor::find_by_user_and_type(&headers.user.uuid, TwoFactorType::Webauthn as i32, &conn).await + else { err!("Webauthn data not found!") }; - let mut keys: Vec = serde_json::from_str(&tf.data)?; - let keys_id: Vec = keys.iter().map(|r| r.id).collect(); + let mut data: Vec = serde_json::from_str(&tf.data)?; - two_factor::validate_webauthn(token, &user.uuid, &keys_id, true)?; - - let mut removed: HashSet> = 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() { + let Some(item_pos) = data.iter().position(|r| r.id == id) else { err!("Webauthn entry not found") - } + }; - if keys.is_empty() { - tf.delete(conn).await?; - log_user_event(EventType::UserDisabled2fa, &user.uuid, headers.device.atype, &headers.ip.ip, conn).await; - } else { - tf.data = serde_json::to_string(&keys)?; - tf.save(conn).await?; - drop(tf); - } + let removed_item = data.remove(item_pos); + tf.data = serde_json::to_string(&data)?; + tf.save(&conn).await?; + drop(tf); // If entry is migrated from u2f, delete the u2f entry as well - 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::>(&u2f.data) else { - err!("Error parsing U2F data") + if let Some(mut u2f) = TwoFactor::find_by_user_and_type(&headers.user.uuid, TwoFactorType::U2f as i32, &conn).await + { + let mut data: Vec = match serde_json::from_str(&u2f.data) { + Ok(d) => d, + Err(_) => err!("Error parsing U2F data"), }; - data.retain(|old| !removed.contains(&old.reg.key_handle)); + data.retain(|r| r.reg.key_handle != removed_item.credential.cred_id().as_slice()); + let new_data_str = serde_json::to_string(&data)?; - if data.is_empty() { - u2f.delete(conn).await?; - } else { - let new_data_str = serde_json::to_string(&data)?; - u2f.data = new_data_str; - u2f.save(conn).await?; - } + u2f.data = new_data_str; + u2f.save(&conn).await?; } - super::check_2fa_state(&user, headers.device.atype, &headers.ip.ip, conn).await?; + let keys_json: Vec = data.iter().map(WebauthnRegistration::to_json).collect(); Ok(Json(json!({ - "webAuthn": json!({ - "enabled": !keys.is_empty(), - "keys": keys.iter().map(WebauthnRegistration::to_json).collect::>(), - }), + "enabled": true, + "keys": keys_json, + "object": "twoFactorU2f" }))) } @@ -408,7 +367,8 @@ pub async fn get_webauthn_registrations( user_id: &UserId, conn: &DbConn, ) -> Result<(bool, Vec), Error> { - match TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Webauthn, conn).await { + let type_ = TwoFactorType::Webauthn as i32; + match TwoFactor::find_by_user_and_type(user_id, type_, conn).await { Some(tf) => Ok((tf.enabled, serde_json::from_str(&tf.data)?)), None => Ok((false, Vec::new())), // If no data, return empty list } @@ -428,10 +388,10 @@ pub async fn generate_webauthn_login(user_id: &UserId, conn: &DbConn) -> JsonRes // Modify to discourage user verification let mut state = serde_json::to_value(&state)?; - state["ast"]["policy"] = Value::String("discouraged".to_owned()); + state["ast"]["policy"] = Value::String("discouraged".to_string()); // Add appid, this is only needed for U2F compatibility, so maybe it can be removed as well - let app_id = format!("{}/app-id.json", CONFIG.domain()); + let app_id = format!("{}/app-id.json", &CONFIG.domain()); state["ast"]["appid"] = Value::String(app_id.clone()); response.public_key.user_verification = UserVerificationPolicy::Discouraged_DO_NOT_USE; @@ -455,19 +415,19 @@ pub async fn generate_webauthn_login(user_id: &UserId, conn: &DbConn) -> JsonRes } pub async fn validate_webauthn_login(user_id: &UserId, response: &str, conn: &DbConn) -> EmptyResult { - let mut state = if let Some(tf) = - TwoFactor::find_by_user_and_type(user_id, TwoFactorType::WebauthnLoginChallenge, conn).await - { - let state: PasskeyAuthentication = serde_json::from_str(&tf.data)?; - tf.delete(conn).await?; - state - } else { - err!( + let type_ = TwoFactorType::WebauthnLoginChallenge as i32; + let mut state = match TwoFactor::find_by_user_and_type(user_id, type_, conn).await { + Some(tf) => { + let state: PasskeyAuthentication = serde_json::from_str(&tf.data)?; + tf.delete(conn).await?; + state + } + None => err!( "Can't recover login challenge", ErrorEvent { event: EventType::UserFailedLogIn2fa } - ) + ), }; let rsp: PublicKeyCredentialCopy = serde_json::from_str(response)?; diff --git a/src/api/core/two_factor/yubikey.rs b/src/api/core/two_factor/yubikey.rs index 17c4e9a0..1cf11255 100644 --- a/src/api/core/two_factor/yubikey.rs +++ b/src/api/core/two_factor/yubikey.rs @@ -1,57 +1,24 @@ -use rocket::{Route, serde::json::Json}; +use rocket::serde::json::Json; +use rocket::Route; use serde_json::Value; -use yubico_ng::{ - Verifier, YubicoError, - config::Config, - transport::{AsyncTransport, Response}, -}; +use yubico::{config::Config, verify_async}; use crate::{ - CONFIG, api::{ + core::{log_user_event, two_factor::_generate_recover_code}, EmptyResult, JsonResult, PasswordOrOtpData, - core::{ - log_user_event, - two_factor::{VerificationTokenData, generate_recover_code}, - }, }, - auth::{Headers, two_factor}, + auth::Headers, db::{ - DbConn, models::{EventType, TwoFactor, TwoFactorType}, + DbConn, }, error::{Error, MapResult}, - http_client, + CONFIG, }; pub fn routes() -> Vec { - routes![generate_yubikey, activate_yubikey, activate_yubikey_put, delete_yubikeys,] -} - -struct HttpClientTransport { - client: reqwest::Client, -} - -impl HttpClientTransport { - fn new() -> Result { - http_client::get_reqwest_client_builder(false).redirect(reqwest::redirect::Policy::none()).build().map( - |client| Self { - client, - }, - ) - } -} - -impl AsyncTransport for HttpClientTransport { - type Error = YubicoError; - - async fn yubico_get(&self, url: &str) -> Result { - let response = self.client.get(url).send().await.map_err(YubicoError::transport)?; - Ok(Response { - status: response.status().as_u16(), - body: response.text().await.map_err(YubicoError::transport)?, - }) - } + routes![generate_yubikey, activate_yubikey, activate_yubikey_put,] } #[derive(Debug, Deserialize)] @@ -63,7 +30,8 @@ struct EnableYubikeyData { key4: Option, key5: Option, nfc: bool, - user_verification_token: String, + master_password_hash: Option, + otp: Option, } #[derive(Deserialize, Serialize, Debug)] @@ -77,7 +45,8 @@ pub struct YubikeyMetadata { fn parse_yubikeys(data: &EnableYubikeyData) -> Vec { let data_keys = [&data.key1, &data.key2, &data.key3, &data.key4, &data.key5]; - data_keys.into_iter().flatten().filter(|e| !e.is_empty()).cloned().collect() + + data_keys.iter().filter_map(|e| e.as_ref().cloned()).collect() } fn jsonify_yubikeys(yubikeys: Vec) -> Value { @@ -95,25 +64,22 @@ fn get_yubico_credentials() -> Result<(String, String), Error> { err!("Yubico support is disabled"); } - if let (Some(id), Some(secret)) = (CONFIG.yubico_client_id(), CONFIG.yubico_secret_key()) { - Ok((id, secret)) - } else { - err!("`YUBICO_CLIENT_ID` or `YUBICO_SECRET_KEY` environment variable is not set. Yubikey OTP Disabled") + match (CONFIG.yubico_client_id(), CONFIG.yubico_secret_key()) { + (Some(id), Some(secret)) => Ok((id, secret)), + _ => err!("`YUBICO_CLIENT_ID` or `YUBICO_SECRET_KEY` environment variable is not set. Yubikey OTP Disabled"), } } async fn verify_yubikey_otp(otp: String) -> EmptyResult { let (yubico_id, yubico_secret) = get_yubico_credentials()?; - let mut config = Config::default().set_client_id(yubico_id).set_key(yubico_secret)?; - if let Some(yubico_server) = CONFIG.yubico_server() { - config = config.set_api_host(yubico_server); + let config = Config::default().set_client_id(yubico_id).set_key(yubico_secret); + + match CONFIG.yubico_server() { + Some(server) => verify_async(otp, config.set_api_hosts(vec![server])).await, + None => verify_async(otp, config).await, } - - let client = HttpClientTransport::new()?; - let verifier = Verifier::with_client(config, client)?; - - verifier.verify(otp).await.map_res("Failed to verify OTP") + .map_res("Failed to verify OTP") } #[post("/two-factor/get-yubikey", data = "")] @@ -127,47 +93,56 @@ async fn generate_yubikey(data: Json, headers: Headers, conn: data.validate(&user, false, &conn).await?; let user_id = &user.uuid; + let yubikey_type = TwoFactorType::YubiKey as i32; - let (enabled, keys, yubikey_json) = - if let Some(r) = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::YubiKey, &conn).await { - let yubikey_metadata: YubikeyMetadata = serde_json::from_str(&r.data)?; - let enabled = !yubikey_metadata.keys.is_empty(); - let mut result = jsonify_yubikeys(yubikey_metadata.keys.clone()); - result["enabled"] = Value::Bool(enabled); - result["nfc"] = Value::Bool(yubikey_metadata.nfc); - (enabled, yubikey_metadata.keys, result) - } else { - (false, Vec::new(), json!({"enabled": false})) - }; - Ok(Json(json!({ - "yubiKey": yubikey_json, - "userVerificationToken": two_factor::yubikey_token(user.uuid, keys, enabled), - }))) + let r = TwoFactor::find_by_user_and_type(user_id, yubikey_type, &conn).await; + + if let Some(r) = r { + let yubikey_metadata: YubikeyMetadata = serde_json::from_str(&r.data)?; + + let mut result = jsonify_yubikeys(yubikey_metadata.keys); + + result["enabled"] = Value::Bool(true); + result["nfc"] = Value::Bool(yubikey_metadata.nfc); + result["object"] = Value::String("twoFactorU2f".to_owned()); + + Ok(Json(result)) + } else { + Ok(Json(json!({ + "enabled": false, + "object": "twoFactorU2f", + }))) + } } #[post("/two-factor/yubikey", data = "")] async fn activate_yubikey(data: Json, headers: Headers, conn: DbConn) -> JsonResult { let data: EnableYubikeyData = data.into_inner(); - let yubikeys = parse_yubikeys(&data); let mut user = headers.user; - if yubikeys.is_empty() { - // Return an error to prevent saving empty keys which would cause users not being able to login anymore. - // To remove all keys users should click the `Deactivate all keys` button - err!("A key is required."); + 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 = - 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()) + 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); + + if yubikeys.is_empty() { + return Ok(Json(json!({ + "enabled": false, + "object": "twoFactorU2f", + }))); + } + // Ensure they are valid OTPs for yubikey in &yubikeys { if yubikey.is_empty() || yubikey.len() == 12 { @@ -187,14 +162,17 @@ async fn activate_yubikey(data: Json, headers: Headers, conn: yubikey_data.data = serde_json::to_string(&yubikey_metadata).unwrap(); yubikey_data.save(&conn).await?; - generate_recover_code(&mut user, &conn).await; + _generate_recover_code(&mut user, &conn).await; - log_user_event(EventType::UserUpdated2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; + log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; let mut result = jsonify_yubikeys(yubikey_metadata.keys); + result["enabled"] = Value::Bool(true); result["nfc"] = Value::Bool(yubikey_metadata.nfc); - Ok(Json(json!({"yubiKey": result}))) + result["object"] = Value::String("twoFactorU2f".to_owned()); + + Ok(Json(result)) } #[put("/two-factor/yubikey", data = "")] @@ -202,32 +180,13 @@ async fn activate_yubikey_put(data: Json, headers: Headers, c activate_yubikey(data, headers, conn).await } -#[delete("/two-factor/yubikey", data = "")] -async fn delete_yubikeys(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { - let user = headers.user; - - if let Some(r) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::YubiKey, &conn).await { - let yubikey_metadata: YubikeyMetadata = serde_json::from_str(&r.data)?; - two_factor::validate_yubikey(&data.user_verification_token, &user.uuid, &yubikey_metadata.keys, true)?; - - r.delete(&conn).await?; - log_user_event(EventType::UserDisabled2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; - } - - super::check_2fa_state(&user, headers.device.atype, &headers.ip.ip, &conn).await?; - - Ok(()) -} - pub async fn validate_yubikey_login(response: &str, twofactor_data: &str) -> EmptyResult { if response.len() != 44 { err!("Invalid Yubikey OTP length"); } let yubikey_metadata: YubikeyMetadata = serde_json::from_str(twofactor_data).expect("Can't parse Yubikey Metadata"); - let Some(response_id) = response.get(..12) else { - err!("Invalid Yubikey OTP"); - }; + let response_id = &response[..12]; if !yubikey_metadata.keys.contains(&response_id.to_owned()) { err!("Given Yubikey is not registered"); diff --git a/src/api/icons.rs b/src/api/icons.rs index 1c7b2a57..da83d0c4 100644 --- a/src/api/icons.rs +++ b/src/api/icons.rs @@ -6,29 +6,28 @@ use std::{ }; use bytes::{Bytes, BytesMut}; -use futures::{TryFutureExt, stream::StreamExt}; +use futures::{stream::StreamExt, TryFutureExt}; use html5gum::{Emitter, HtmlString, Readable, StringReader, Tokenizer}; use regex::Regex; use reqwest::{ - Client, Response, header::{self, HeaderMap, HeaderValue}, + Client, Response, }; -use rocket::{Route, http::ContentType, response::Redirect}; -use svg_hush::{Filter, data_url_filter}; +use rocket::{http::ContentType, response::Redirect, Route}; +use svg_hush::{data_url_filter, Filter}; use crate::{ - CONFIG, config::PathType, error::Error, - http_client::{CustomHttpClientError, get_reqwest_client_builder, get_valid_host, should_block_host}, + http_client::{get_reqwest_client_builder, should_block_address, CustomHttpClientError}, util::Cached, + CONFIG, }; pub fn routes() -> Vec { - if CONFIG.icon_service().as_str() == "internal" { - routes![icon_internal] - } else { - routes![icon_external] + match CONFIG.icon_service().as_str() { + "internal" => routes![icon_internal], + _ => routes![icon_external], } } @@ -62,10 +61,10 @@ static CLIENT: LazyLock = LazyLock::new(|| { // Generate the cookie store let cookie_store = Arc::new(Jar::default()); - let icon_download_timeout = Duration::from_secs(u64::from(CONFIG.icon_download_timeout())); + let icon_download_timeout = Duration::from_secs(CONFIG.icon_download_timeout()); let pool_idle_timeout = Duration::from_secs(10); // Reuse the client between requests - get_reqwest_client_builder(true) + get_reqwest_client_builder() .cookie_provider(Arc::clone(&cookie_store)) .timeout(icon_download_timeout) .pool_max_idle_per_host(5) // Configure the Hyper Pool to only have max 5 idle connections @@ -82,19 +81,19 @@ static ICON_SIZE_REGEX: LazyLock = LazyLock::new(|| Regex::new(r"(?x)(\d+ // The function name `icon_external` is checked in the `on_response` function in `AppHeaders` // It is used to prevent sending a specific header which breaks icon downloads. // If this function needs to be renamed, also adjust the code in `util.rs` -#[get("//icon.png")] -fn icon_external(host: &str) -> Cached> { - let Ok(host) = get_valid_host(host) else { - warn!("Invalid host: {}", host.escape_debug()); - return Cached::ttl(None, CONFIG.icon_cache_negttl(), true); - }; - - if should_block_host(&host).is_err() { - warn!("Blocked address: {host}"); +#[get("//icon.png")] +fn icon_external(domain: &str) -> Cached> { + if !is_valid_domain(domain) { + warn!("Invalid domain: {domain}"); return Cached::ttl(None, CONFIG.icon_cache_negttl(), true); } - let url = CONFIG._icon_service_url().replace("{}", &host.to_string()); + if should_block_address(domain) { + warn!("Blocked address: {domain}"); + return Cached::ttl(None, CONFIG.icon_cache_negttl(), true); + } + + let url = CONFIG._icon_service_url().replace("{}", domain); let redir = match CONFIG.icon_redirect_code() { 301 => Some(Redirect::moved(url)), // legacy permanent redirect 302 => Some(Redirect::found(url)), // legacy temporary redirect @@ -108,21 +107,12 @@ fn icon_external(host: &str) -> Cached> { Cached::ttl(redir, CONFIG.icon_cache_ttl(), true) } -#[get("//icon.png")] -async fn icon_internal(host: &str) -> Cached<(ContentType, Vec)> { +#[get("//icon.png")] +async fn icon_internal(domain: &str) -> Cached<(ContentType, Vec)> { const FALLBACK_ICON: &[u8] = include_bytes!("../static/images/fallback-icon.png"); - let Ok(host) = get_valid_host(host) else { - warn!("Invalid host: {}", host.escape_debug()); - return Cached::ttl( - (ContentType::new("image", "png"), FALLBACK_ICON.to_vec()), - CONFIG.icon_cache_negttl(), - true, - ); - }; - - if should_block_host(&host).is_err() { - warn!("Blocked address: {host}"); + if !is_valid_domain(domain) { + warn!("Invalid domain: {domain}"); return Cached::ttl( (ContentType::new("image", "png"), FALLBACK_ICON.to_vec()), CONFIG.icon_cache_negttl(), @@ -130,7 +120,16 @@ async fn icon_internal(host: &str) -> Cached<(ContentType, Vec)> { ); } - match get_icon(&host.to_string()).await { + if should_block_address(domain) { + warn!("Blocked address: {domain}"); + return Cached::ttl( + (ContentType::new("image", "png"), FALLBACK_ICON.to_vec()), + CONFIG.icon_cache_negttl(), + true, + ); + } + + match get_icon(domain).await { Some((icon, icon_type)) => { Cached::ttl((ContentType::new("image", icon_type), icon), CONFIG.icon_cache_ttl(), true) } @@ -138,6 +137,42 @@ async fn icon_internal(host: &str) -> Cached<(ContentType, Vec)> { } } +/// Returns if the domain provided is valid or not. +/// +/// This does some manual checks and makes use of Url to do some basic checking. +/// domains can't be larger then 63 characters (not counting multiple subdomains) according to the RFC's, but we limit the total size to 255. +fn is_valid_domain(domain: &str) -> bool { + const ALLOWED_CHARS: &str = "-."; + + // If parsing the domain fails using Url, it will not work with reqwest. + if let Err(parse_error) = url::Url::parse(format!("https://{domain}").as_str()) { + debug!("Domain parse error: '{domain}' - {parse_error:?}"); + return false; + } else if domain.is_empty() + || domain.contains("..") + || domain.starts_with('.') + || domain.starts_with('-') + || domain.ends_with('-') + { + debug!( + "Domain validation error: '{domain}' is either empty, contains '..', starts with an '.', starts or ends with a '-'" + ); + return false; + } else if domain.len() > 255 { + debug!("Domain validation error: '{domain}' exceeds 255 characters"); + return false; + } + + for c in domain.chars() { + if !c.is_alphanumeric() && !ALLOWED_CHARS.contains(c) { + debug!("Domain validation error: '{domain}' contains an invalid character '{c}'"); + return false; + } + } + + true +} + async fn get_icon(domain: &str) -> Option<(Vec, String)> { let path = format!("{domain}.png"); @@ -148,7 +183,7 @@ async fn get_icon(domain: &str) -> Option<(Vec, String)> { if let Some(icon) = get_cached_icon(&path).await { let icon_type = get_icon_type(&icon).unwrap_or("x-icon"); - return Some((icon, icon_type.to_owned())); + return Some((icon, icon_type.to_string())); } if CONFIG.disable_icon_download() { @@ -159,7 +194,7 @@ async fn get_icon(domain: &str) -> Option<(Vec, String)> { match download_icon(domain).await { Ok((icon, icon_type)) => { save_icon(&path, icon.to_vec()).await; - Some((icon.to_vec(), icon_type.unwrap_or("x-icon").to_owned())) + Some((icon.to_vec(), icon_type.unwrap_or("x-icon").to_string())) } Err(e) => { // If this error comes from the custom resolver, this means this is a blocked domain @@ -184,23 +219,23 @@ async fn get_cached_icon(path: &str) -> Option> { } // Try to read the cached icon, and return it if it exists - if let Ok(operator) = CONFIG.opendal_operator_for_path_type(&PathType::IconCache) - && let Ok(buf) = operator.read(path).await - { - return Some(buf.to_vec()); + if let Ok(operator) = CONFIG.opendal_operator_for_path_type(&PathType::IconCache) { + if let Ok(buf) = operator.read(path).await { + return Some(buf.to_vec()); + } } None } -async fn file_is_expired(path: &str, ttl: u32) -> Result { +async fn file_is_expired(path: &str, ttl: u64) -> Result { let operator = CONFIG.opendal_operator_for_path_type(&PathType::IconCache)?; let meta = operator.stat(path).await?; let modified = meta.last_modified().ok_or_else(|| std::io::Error::other(format!("No last modified time for `{path}`")))?; let age = SystemTime::now().duration_since(modified.into())?; - Ok(ttl > 0 && u64::from(ttl) <= age.as_secs()) + Ok(ttl > 0 && ttl <= age.as_secs()) } async fn icon_is_negcached(path: &str) -> bool { @@ -264,7 +299,7 @@ fn get_favicons_node(dom: Tokenizer, FaviconEmitter>, icons: &m TAG_BASE => { base_url = if let Some(href) = token.tag.attributes.get(ATTR_HREF) { let href = std::str::from_utf8(href).unwrap_or_default(); - debug!("Found base href: {}", href.escape_debug()); + debug!("Found base href: {href}"); match base_url.join(href) { Ok(inner_url) => inner_url, _ => continue, @@ -281,17 +316,17 @@ fn get_favicons_node(dom: Tokenizer, FaviconEmitter>, icons: &m } for icon_tag in icon_tags { - if let Some(icon_href) = icon_tag.attributes.get(ATTR_HREF) - && let Ok(full_href) = base_url.join(std::str::from_utf8(icon_href).unwrap_or_default()) - { - let sizes = if let Some(v) = icon_tag.attributes.get(ATTR_SIZES) { - std::str::from_utf8(v).unwrap_or_default() - } else { - "" - }; - let priority = get_icon_priority(full_href.as_str(), sizes); - icons.push(Icon::new(priority, full_href.to_string())); - } + if let Some(icon_href) = icon_tag.attributes.get(ATTR_HREF) { + if let Ok(full_href) = base_url.join(std::str::from_utf8(icon_href).unwrap_or_default()) { + let sizes = if let Some(v) = icon_tag.attributes.get(ATTR_SIZES) { + std::str::from_utf8(v).unwrap_or_default() + } else { + "" + }; + let priority = get_icon_priority(full_href.as_str(), sizes); + icons.push(Icon::new(priority, full_href.to_string())); + } + }; } } @@ -332,7 +367,7 @@ async fn get_icon_url(domain: &str) -> Result { tld = domain_parts.next_back().unwrap(), base = domain_parts.next_back().unwrap() ); - if get_valid_host(&base_domain).is_ok() { + if is_valid_domain(&base_domain) { let sslbase = format!("https://{base_domain}"); let httpbase = format!("http://{base_domain}"); debug!("[get_icon_url]: Trying without subdomains '{base_domain}'"); @@ -343,7 +378,7 @@ async fn get_icon_url(domain: &str) -> Result { // When the domain is not an IP, and has less then 2 dots, try to add www. infront of it. } else if is_ip.is_err() && domain.matches('.').count() < 2 { let www_domain = format!("www.{domain}"); - if get_valid_host(&www_domain).is_ok() { + if is_valid_domain(&www_domain) { let sslwww = format!("https://{www_domain}"); let httpwww = format!("http://{www_domain}"); debug!("[get_icon_url]: Trying with www. prefix '{www_domain}'"); @@ -405,25 +440,9 @@ async fn get_page(url: &str) -> Result { } async fn get_page_with_referer(url: &str, referer: &str) -> Result { - // The resolver only sees hosts needing name resolution, so IP-literal hrefs from - // attacker-controlled HTML never reach `post_resolve()`. Check them here. - let Ok(parsed_url) = url::Url::parse(url) else { - err_silent!("Invalid URL", url) - }; - - if !matches!(parsed_url.scheme(), "http" | "https") { - err_silent!("Invalid scheme", url) - } - - let Some(host) = parsed_url.host() else { - err_silent!("Invalid host", url) - }; - - should_block_host(&host)?; - let mut client = CLIENT.get(url); if !referer.is_empty() { - client = client.header("Referer", referer); + client = client.header("Referer", referer) } Ok(client.send().await?.error_for_status()?) @@ -511,10 +530,11 @@ async fn download_icon(domain: &str) -> Result<(Bytes, Option<&str>), Error> { let mut buffer = Bytes::new(); let mut icon_type: Option<&str> = None; - let mut icons = icon_result.iconlist.iter().take(5).peekable(); - while let Some(icon) = icons.next() { + use data_url::DataUrl; + + for icon in icon_result.iconlist.iter().take(5) { if icon.href.starts_with("data:image") { - let Ok(datauri) = data_url::DataUrl::process(&icon.href) else { + let Ok(datauri) = DataUrl::process(&icon.href) else { continue; }; // Check if we are able to decode the data uri @@ -524,7 +544,7 @@ async fn download_icon(domain: &str) -> Result<(Bytes, Option<&str>), Error> { Ok(()) }) { Ok(_) => { - // Also check if the size is at least 67 bytes, which seems to be the smallest png I could create + // Also check if the size is atleast 67 bytes, which seems to be the smallest png i could create if body.len() >= 67 { // Check if the icon type is allowed, else try an icon from the list. icon_type = get_icon_type(&body); @@ -538,25 +558,13 @@ async fn download_icon(domain: &str) -> Result<(Bytes, Option<&str>), Error> { } } _ => debug!("Extracted icon from data:image uri is invalid"), - } - } else { - debug!("Trying {}", icon.href); - // Make sure all icons are checked before returning error - let res = match get_page_with_referer(&icon.href, &icon_result.referer).await { - Ok(r) => r, - Err(e) if icons.peek().is_none() => return Err(e), - Err(e) if CustomHttpClientError::downcast_ref(&e).is_some() => return Err(e), // If blacklisted stop immediately instead of checking the rest of the icons. see explanation and actual handling inside get_icon() - Err(e) => { - warn!("Unable to download icon: {e:?}"); - - // Continue to next icon - continue; - } }; + } else { + let res = get_page_with_referer(&icon.href, &icon_result.referer).await?; buffer = stream_to_bytes_limit(res, 5120 * 1024).await?; // 5120KB/5MB for each icon max (Same as icons.bitwarden.net) - // Check if the icon type is allowed, else try another icon from the list. + // Check if the icon type is allowed, else try an icon from the list. icon_type = get_icon_type(&buffer); if icon_type.is_none() { buffer.clear(); @@ -602,25 +610,22 @@ async fn save_icon(path: &str, icon: Vec) { fn get_icon_type(bytes: &[u8]) -> Option<&'static str> { fn check_svg_after_xml_declaration(bytes: &[u8]) -> Option<&'static str> { // Look for SVG tag within the first 1KB - if let Ok(content) = std::str::from_utf8(&bytes[..bytes.len().min(1024)]) - && (content.contains(" Some("png"), - [0, 0, 1, 0, n1, n2, ..] if u16::from_le_bytes([*n1, *n2]) > 0 => Some("x-icon"), // https://en.wikipedia.org/wiki/ICO_(file_format) - [82, 73, 70, 70, _, _, _, _, 87, 69, 66, 80, ..] => Some("webp"), // Only match WebP Images - [255, 216, 255, b, ..] if *b >= 0xC0 => Some("jpeg"), - [71, 73, 70, 56, 55 | 57, 97, ..] => Some("gif"), - [66, 77, _, _, _, _, 0, 0, 0, 0, ..] => Some("bmp"), // https://en.wikipedia.org/wiki/BMP_file_format - [60, 115, 118, 103, ..] => Some("svg+xml"), // Normal svg + [137, 80, 78, 71, ..] => Some("png"), + [0, 0, 1, 0, ..] => Some("x-icon"), + [82, 73, 70, 70, ..] => Some("webp"), + [255, 216, 255, ..] => Some("jpeg"), + [71, 73, 70, 56, ..] => Some("gif"), + [66, 77, ..] => Some("bmp"), + [60, 115, 118, 103, ..] => Some("svg+xml"), // Normal svg [60, 63, 120, 109, 108, ..] => check_svg_after_xml_declaration(bytes), // An svg starting with None, } @@ -663,17 +668,6 @@ impl reqwest::cookie::CookieStore for Jar { use time::Duration; let mut cookie_store = self.0.write().unwrap(); - - // The store only skips expired cookies when reading them, so remove them here or they are kept forever - let expired: Vec<_> = cookie_store - .iter_any() - .filter(|c| c.is_expired()) - .map(|c| (String::from(&c.domain), String::from(&c.path), c.name().to_owned())) - .collect(); - for (domain, path, name) in expired { - cookie_store.remove(&domain, &path, &name); - } - let cookies = cookie_headers.filter_map(|val| { std::str::from_utf8(val.as_bytes()) .map_err(RawCookieParseError::from) @@ -759,7 +753,7 @@ impl FaviconEmitter { let rel_value = std::str::from_utf8(token.tag.attributes.get(ATTR_REL).unwrap()).unwrap_or_default(); if rel_value.contains("icon") && !rel_value.contains("mask-icon") { - self.emit_token = true; + self.emit_token = true } } _ => (), @@ -832,13 +826,13 @@ impl Emitter for FaviconEmitter { fn push_attribute_name(&mut self, s: &[u8]) { if let Some(attr) = &mut self.current_attribute { - attr.0.extend(s); + attr.0.extend(s) } } fn push_attribute_value(&mut self, s: &[u8]) { if let Some(attr) = &mut self.current_attribute { - attr.1.extend(s); + attr.1.extend(s) } } diff --git a/src/api/identity.rs b/src/api/identity.rs index d7ae5068..b9a753b9 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -1,52 +1,49 @@ use chrono::Utc; use num_traits::FromPrimitive; use rocket::{ - Route, form::{Form, FromForm}, - http::{Accept, Cookie, CookieJar, MediaType, SameSite}, + http::Status, response::Redirect, serde::json::Json, + Route, }; use serde_json::Value; use crate::{ - CONFIG, api::{ - ApiResult, EmptyResult, JsonResult, core::{ - accounts::{PreloginData, RegisterData, kdf_upgrade, prelogin, register}, + accounts::{PreloginData, RegisterData, _prelogin, _register, kdf_upgrade}, log_user_event, two_factor::{ - authenticator, duo_oidc, email, enforce_2fa_policy, is_twofactor_provider_usable, webauthn, yubikey, + authenticator, duo, duo_oidc, email, enforce_2fa_policy, is_twofactor_provider_usable, webauthn, + yubikey, }, }, - identity_master_password_policy, + master_password_policy, push::register_push_device, + ApiResult, EmptyResult, JsonResult, }, auth, - auth::{AuthMethod, ClientHeaders, ClientIp, Secure, generate_organization_api_key_login_claims}, - crypto, + auth::{generate_organization_api_key_login_claims, AuthMethod, ClientHeaders, ClientIp, ClientVersion}, db::{ - DbConn, models::{ - AuthRequest, AuthRequestId, Device, DeviceId, DeviceType, EventType, Invitation, OIDCCodeResponseError, - OrganizationApiKey, OrganizationId, SendId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete, - TwoFactorType, User, UserId, + AuthRequest, AuthRequestId, Device, DeviceId, EventType, Invitation, OIDCCodeWrapper, OrganizationApiKey, + OrganizationId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete, TwoFactorType, User, UserId, }, + DbConn, }, error::MapResult, mail, sso, sso::{OIDCCode, OIDCCodeChallenge, OIDCCodeVerifier, OIDCState}, - util, + util, CONFIG, }; pub fn routes() -> Vec { routes![ login, - post_prelogin, - prelogin_password, + prelogin, + identity_register, register_verification_email, - register_verification_email_clicked, register_finish, prevalidate, authorize, @@ -56,70 +53,65 @@ pub fn routes() -> Vec { } #[post("/connect/token", data = "")] -async fn login(data: Form, client_header: ClientHeaders, conn: DbConn) -> JsonResult { +async fn login( + data: Form, + client_header: ClientHeaders, + client_version: Option, + conn: DbConn, +) -> JsonResult { let data: ConnectData = data.into_inner(); let mut user_id: Option = None; let login_result = match data.grant_type.as_ref() { "refresh_token" => { - check_is_some(data.refresh_token.as_ref(), "refresh_token cannot be blank")?; - refresh_login(data, &conn, &client_header.ip).await + _check_is_some(&data.refresh_token, "refresh_token cannot be blank")?; + _refresh_login(data, &conn, &client_header.ip).await } "password" if CONFIG.sso_enabled() && CONFIG.sso_only() => err!("SSO sign-in is required"), "password" => { - 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.scope.as_ref(), "scope cannot be blank")?; - check_is_some(data.username.as_ref(), "username cannot be blank")?; + _check_is_some(&data.client_id, "client_id cannot be blank")?; + _check_is_some(&data.password, "password cannot be blank")?; + _check_is_some(&data.scope, "scope cannot be blank")?; + _check_is_some(&data.username, "username 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")?; + _check_is_some(&data.device_identifier, "device_identifier cannot be blank")?; + _check_is_some(&data.device_name, "device_name cannot be blank")?; + _check_is_some(&data.device_type, "device_type cannot be blank")?; - password_login(data, &mut user_id, &conn, &client_header).await + _password_login(data, &mut user_id, &conn, &client_header.ip, &client_version).await } "client_credentials" => { - check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?; - check_is_some(data.client_secret.as_ref(), "client_secret cannot be blank")?; - check_is_some(data.scope.as_ref(), "scope cannot be blank")?; + _check_is_some(&data.client_id, "client_id cannot be blank")?; + _check_is_some(&data.client_secret, "client_secret cannot be blank")?; + _check_is_some(&data.scope, "scope cannot be blank")?; - api_key_login(data, &mut user_id, &conn, &client_header).await + _check_is_some(&data.device_identifier, "device_identifier cannot be blank")?; + _check_is_some(&data.device_name, "device_name cannot be blank")?; + _check_is_some(&data.device_type, "device_type cannot be blank")?; + + _api_key_login(data, &mut user_id, &conn, &client_header.ip).await } "authorization_code" if CONFIG.sso_enabled() => { - check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?; - check_is_some(data.code.as_ref(), "code cannot be blank")?; - check_is_some(data.code_verifier.as_ref(), "code verifier cannot be blank")?; + _check_is_some(&data.client_id, "client_id cannot be blank")?; + _check_is_some(&data.code, "code cannot be blank")?; + _check_is_some(&data.code_verifier, "code verifier 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")?; + _check_is_some(&data.device_identifier, "device_identifier cannot be blank")?; + _check_is_some(&data.device_name, "device_name cannot be blank")?; + _check_is_some(&data.device_type, "device_type cannot be blank")?; - sso_login(data, &mut user_id, &conn, &client_header).await + _sso_login(data, &mut user_id, &conn, &client_header.ip, &client_version).await } "authorization_code" => err!("SSO sign-in is not available"), - "send_access" => { - crate::ratelimit::check_limit_unauthenticated(&client_header.ip.ip)?; - check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?; - check_is_some(data.send_id.as_ref(), "send_id cannot be blank")?; - - let tokens = auth::SendTokens::generate_tokens( - data.send_id.as_ref().unwrap(), - data.password_hash_b64, - &client_header.ip, - &conn, - ) - .await?; - Ok(Json(tokens.to_json())) - } - t => err!("Invalid type", t.escape_debug().to_string()), + t => err!("Invalid type", t), }; if let Some(user_id) = user_id { match &login_result { Ok(_) => { log_user_event( - EventType::UserLoggedIn, + EventType::UserLoggedIn as i32, &user_id, client_header.device_type, &client_header.ip.ip, @@ -129,7 +121,8 @@ async fn login(data: Form, client_header: ClientHeaders, conn: DbCo } Err(e) => { if let Some(ev) = e.get_event() { - log_user_event(ev.event, &user_id, client_header.device_type, &client_header.ip.ip, &conn).await; + log_user_event(ev.event as i32, &user_id, client_header.device_type, &client_header.ip.ip, &conn) + .await } } } @@ -138,14 +131,12 @@ async fn login(data: Form, client_header: ClientHeaders, conn: DbCo login_result } -async fn refresh_login(data: ConnectData, conn: &DbConn, ip: &ClientIp) -> JsonResult { - // When a refresh token is invalid or missing we need to respond with an HTTP BadRequest (400) - // It also needs to return a json which holds at least a key `error` with the value `invalid_grant` - // See the link below for details - // https://github.com/bitwarden/clients/blob/2ee158e720a5e7dbe3641caf80b569e97a1dd91b/libs/common/src/services/api.service.ts#L1786-L1797 - - let Some(refresh_token) = data.refresh_token else { - err_json!(json!({"error": "invalid_grant"}), "Missing refresh_token") +// Return Status::Unauthorized to trigger logout +async fn _refresh_login(data: ConnectData, conn: &DbConn, ip: &ClientIp) -> JsonResult { + // Extract token + let refresh_token = match data.refresh_token { + Some(token) => token, + None => err_code!("Missing refresh_token", Status::Unauthorized.code), }; // --- @@ -156,10 +147,7 @@ async fn refresh_login(data: ConnectData, conn: &DbConn, ip: &ClientIp) -> JsonR // let members = Membership::find_confirmed_by_user(&user.uuid, conn).await; match auth::refresh_tokens(ip, &refresh_token, data.client_id, conn).await { Err(err) => { - err_json!( - json!({"error": "invalid_grant"}), - format!("Unable to refresh login credentials: {}", err.message()) - ) + err_code!(format!("Unable to refresh login credentials: {}", err.message()), Status::Unauthorized.code) } Ok((mut device, auth_tokens)) => { // Save to update `device.updated_at` to track usage and toggle new status @@ -179,19 +167,19 @@ async fn refresh_login(data: ConnectData, conn: &DbConn, ip: &ClientIp) -> JsonR } // After exchanging the code we need to check first if 2FA is needed before continuing -async fn sso_login( +async fn _sso_login( data: ConnectData, user_id: &mut Option, conn: &DbConn, - client_header: &ClientHeaders, + ip: &ClientIp, + client_version: &Option, ) -> JsonResult { - let ip = &client_header.ip; AuthMethod::Sso.check_scope(data.scope.as_ref())?; // Ratelimit the login crate::ratelimit::check_limit_login(&ip.ip)?; - let (code, code_verifier) = match (data.code.as_ref(), data.code_verifier.as_ref()) { + let (state, code_verifier) = match (data.code.as_ref(), data.code_verifier.as_ref()) { (None, _) => err!( "Got no code in OIDC data", ErrorEvent { @@ -207,7 +195,7 @@ async fn sso_login( (Some(code), Some(code_verifier)) => (code, code_verifier.clone()), }; - let (sso_auth, user_infos) = sso::exchange_code(code, code_verifier, conn).await?; + let (sso_auth, user_infos) = sso::exchange_code(state, code_verifier, conn).await?; let user_with_sso = match SsoUser::find_by_identifier(&user_infos.identifier, conn).await { None => match SsoUser::find_by_mail(&user_infos.email, conn).await { None => None, @@ -223,24 +211,6 @@ async fn sso_login( } ) } - Some((user, None)) - if user.private_key.is_none() - && !CONFIG.sso_signups_allowed() - && !CONFIG.is_email_domain_allowed(&user.email) - && !CONFIG.mail_enabled() - && Invitation::find_by_mail(&user.email, conn).await.is_none() => - { - error!( - "Login failure ({}), no invitation with email ({}) was found", - user_infos.identifier, user.email - ); - err_silent!( - "Missing invitation", - ErrorEvent { - event: EventType::UserFailedLogIn - } - ) - } Some((user, None)) if user.private_key.is_some() && !CONFIG.sso_signups_match_email() => { error!( "Login failure ({}), existing non SSO user ({}) with same email ({}) and association is disabled", @@ -253,33 +223,7 @@ async fn sso_login( } ) } - Some((user, None)) => match user_infos.email_verified { - None if !CONFIG.sso_allow_unknown_email_verification() => { - error!( - "Login failure ({}), existing non SSO user ({}) with same email ({}) and email verification status is unknown", - user_infos.identifier, user.uuid, user.email - ); - err_silent!( - "Email verification status is unknown", - ErrorEvent { - event: EventType::UserFailedLogIn - } - ) - } - Some(false) => { - error!( - "Login failure ({}), existing non SSO user ({}) with same email ({}) and email is not verified", - user_infos.identifier, user.uuid, user.email - ); - err_silent!( - "Email is not verified by the SSO provider", - ErrorEvent { - event: EventType::UserFailedLogIn - } - ) - } - _ => Some((user, None)), - }, + Some((user, None)) => Some((user, None)), }, Some((user, sso_user)) => Some((user, Some(sso_user))), }; @@ -288,15 +232,7 @@ async fn sso_login( // Will trigger 2FA flow if needed let (user, mut device, twofactor_token, sso_user) = match user_with_sso { None => { - if !CONFIG.is_sso_signup_allowed(&user_infos.email) { - if CONFIG.signups_domains_whitelist().is_empty() { - err!( - "Signups are disabled. You will need an invitation", - ErrorEvent { - event: EventType::UserFailedLogIn - } - ); - } + if !CONFIG.is_email_domain_allowed(&user_infos.email) { err!( "Email domain not allowed", ErrorEvent { @@ -323,8 +259,6 @@ async fn sso_login( } let mut user = User::new(&user_infos.email, user_infos.user_name.clone()); - // Before saving, so that a client that can't log in doesn't create the account - check_client_version(&user, client_header)?; user.verified_at = Some(now); user.save(conn).await?; @@ -333,23 +267,18 @@ async fn sso_login( (user, device, None, None) } Some((user, _)) if !user.enabled => { - // Set in both arms, not only after the match, so that their failed logins are logged - *user_id = Some(user.uuid.clone()); err!( "This user has been disabled", - format!("IP: {}. Username: {}.", ip.ip, user.email), + format!("IP: {}. Username: {}.", ip.ip, user.display_name()), ErrorEvent { event: EventType::UserFailedLogIn } ) } Some((mut user, sso_user)) => { - *user_id = Some(user.uuid.clone()); - check_client_version(&user, client_header)?; - let mut device = get_device(&data, conn, &user).await?; - let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, conn).await?; + let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, client_version, conn).await?; if user.private_key.is_none() { // User was invited a stub was created @@ -381,13 +310,13 @@ async fn sso_login( 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, conn: &DbConn, - client_header: &ClientHeaders, + ip: &ClientIp, + client_version: &Option, ) -> JsonResult { - let ip = &client_header.ip; // Validate scope AuthMethod::Password.check_scope(data.scope.as_ref())?; @@ -396,14 +325,24 @@ async fn password_login( // Get the user let username = data.username.as_ref().unwrap().trim(); - let log_username = username.escape_debug(); let Some(mut user) = User::find_by_mail(username, conn).await else { - err!("Username or password is incorrect. Try again", format!("IP: {}. Username: {log_username}.", ip.ip)) + err!("Username or password is incorrect. Try again", format!("IP: {}. Username: {username}.", ip.ip)) }; // Set the user_id here to be passed back used for event logging. *user_id = Some(user.uuid.clone()); + // Check if the user is disabled + if !user.enabled { + err!( + "This user has been disabled", + format!("IP: {}. Username: {username}.", ip.ip), + ErrorEvent { + event: EventType::UserFailedLogIn + } + ) + } + let password = data.password.as_ref().unwrap(); // If we get an auth request, we don't check the user's password, but the access code of the auth request @@ -411,7 +350,7 @@ async fn password_login( let Some(auth_request) = AuthRequest::find_by_uuid_and_user(auth_request_id, &user.uuid, conn).await else { err!( "Auth request not found. Try again.", - format!("IP: {}. Username: {log_username}.", ip.ip), + format!("IP: {}. Username: {username}.", ip.ip), ErrorEvent { event: EventType::UserFailedLogIn, } @@ -423,14 +362,13 @@ async fn password_login( if auth_request.user_uuid != user.uuid || !auth_request.approved.unwrap_or(false) - || auth_request.authentication_date.is_some() || request_expired || ip.ip.to_string() != auth_request.request_ip || !auth_request.check_access_code(password) { err!( "Username or access code is incorrect. Try again", - format!("IP: {}. Username: {log_username}.", ip.ip), + format!("IP: {}. Username: {username}.", ip.ip), ErrorEvent { event: EventType::UserFailedLogIn, } @@ -439,26 +377,13 @@ async fn password_login( } else if !user.check_valid_password(password) { err!( "Username or password is incorrect. Try again", - format!("IP: {}. Username: {log_username}.", ip.ip), + format!("IP: {}. Username: {username}.", ip.ip), ErrorEvent { event: EventType::UserFailedLogIn, } ) } - // Check if the user is disabled - if !user.enabled { - err!( - "This user has been disabled", - format!("IP: {}. Username: {log_username}.", ip.ip), - ErrorEvent { - event: EventType::UserFailedLogIn - } - ) - } - - check_client_version(&user, client_header)?; - // Change the KDF Iterations (only when not logging in with an auth request) if data.auth_request.is_none() { kdf_upgrade(&mut user, password, conn).await?; @@ -469,9 +394,9 @@ async fn password_login( if user.verified_at.is_none() && CONFIG.mail_enabled() && CONFIG.signups_verify() { if user.last_verifying_at.is_none() || now.signed_duration_since(user.last_verifying_at.unwrap()).num_seconds() - > i64::from(CONFIG.signups_verify_resend_time()) + > CONFIG.signups_verify_resend_time() as i64 { - let resend_limit = i32::from(CONFIG.signups_verify_resend_limit()); + let resend_limit = CONFIG.signups_verify_resend_limit() as i32; if resend_limit == 0 || user.login_verify_count < resend_limit { // We want to send another email verification if we require signups to verify // their email address, and we haven't sent them a reminder in a while... @@ -491,7 +416,7 @@ async fn password_login( // We still want the login to fail until they actually verified the email address err!( "Please verify your email before trying again.", - format!("IP: {}. Username: {log_username}.", ip.ip), + format!("IP: {}. Username: {username}.", ip.ip), ErrorEvent { event: EventType::UserFailedLogIn } @@ -500,86 +425,13 @@ async fn password_login( let mut device = get_device(&data, conn, &user).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 twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, client_version, conn).await?; let auth_tokens = auth::AuthTokens::new(&device, &user, AuthMethod::Password, data.client_id); authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await } -/// Like upstream's `ClientVersionValidator`, every login needs the `Bitwarden-Client-Version` header, and a V2 account -/// ([`User::has_v2_encryption`]) a client of at least 2026.10.1 (2026.10.2 for the desktop app). Called right after the -/// credentials are checked, like upstream, so that it doesn't tell anyone else which accounts are V2. -/// -/// Ref: -/// Ref: -fn check_client_version(user: &User, client_header: &ClientHeaders) -> EmptyResult { - let Some((error, description)) = client_version_error( - user.has_v2_encryption(), - client_header.client_version.as_ref(), - client_header.device_type, - ) else { - return Ok(()); - }; - Err(crate::error::Error::from(( - // Escaped: on a first SSO login the email comes from the provider and isn't validated yet - format!("IP: {}. Username: {}. {error}", client_header.ip.ip, user.email.escape_debug()), - json!({ - "error": error, - "error_description": description, - "ErrorModel": { "Message": description, "Object": "error" }, - }), - )) - .with_event(crate::error::ErrorEvent { - event: EventType::UserFailedLogIn, - })) -} - -/// The error id and message of [`check_client_version`], `None` when the client may log in. -fn client_version_error( - is_v2: bool, - client_version: Option<&semver::Version>, - device_type: i32, -) -> Option<(&'static str, &'static str)> { - const MIN_V2_CLIENT: semver::Version = semver::Version::new(2026, 10, 1); - const MIN_V2_DESKTOP_CLIENT: semver::Version = semver::Version::new(2026, 10, 2); - - let Some(version) = client_version else { - return Some(( - "version_header_missing", - "No client version header found, required to prevent encryption errors. Please confirm your client is supplying the header: \"Bitwarden-Client-Version\"", - )); - }; - if !is_v2 { - return None; - } - // Upstream's desktop device types - // Ref: - let is_desktop = matches!( - DeviceType::from_i32(device_type), - DeviceType::WindowsDesktop | DeviceType::MacOsDesktop | DeviceType::LinuxDesktop | DeviceType::Uwp - ); - let min_version = if is_desktop { - &MIN_V2_DESKTOP_CLIENT - } else { - &MIN_V2_CLIENT - }; - (version < min_version).then_some(("invalid_client_version", "Please update your app to continue using Bitwarden")) -} - async fn authenticated_response( user: &User, device: &mut Device, @@ -612,7 +464,7 @@ async fn authenticated_response( // Save to update `device.updated_at` to track usage and toggle new status device.save(true, conn).await?; - let master_password_policy = identity_master_password_policy(user, conn).await; + let master_password_policy = master_password_policy(user, conn).await; let has_master_password = !user.password_hash.is_empty(); let master_password_unlock = if has_master_password { @@ -627,13 +479,24 @@ async fn authenticated_response( // https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26 "MasterKeyEncryptedUserKey": user.akey, "MasterKeyWrappedUserKey": user.akey, - "Salt": user.master_password_salt() + "Salt": user.email }) } else { Value::Null }; - let account_keys = user.account_keys_json(conn).await; + let account_keys = if user.private_key.is_some() { + json!({ + "publicKeyEncryptionKeyPair": { + "wrappedPrivateKey": user.private_key, + "publicKey": user.public_key, + "Object": "publicKeyEncryptionKeyPair" + }, + "Object": "privateKeys" + }) + } else { + Value::Null + }; let mut result = json!({ "access_token": auth_tokens.access_token(), @@ -645,6 +508,7 @@ async fn authenticated_response( "KdfIterations": user.client_kdf_iter, "KdfMemory": user.client_kdf_memory, "KdfParallelism": user.client_kdf_parallelism, + "ResetMasterPassword": false, // TODO: Same as above "ForcePasswordReset": false, "MasterPasswordPolicy": master_password_policy, "scope": auth_tokens.scope(), @@ -664,42 +528,28 @@ async fn authenticated_response( result["TwoFactorToken"] = Value::String(token); } - info!("User {} logged in successfully. IP: {}", user.email, ip.ip); + info!("User {} logged in successfully. IP: {}", user.display_name(), ip.ip); Ok(Json(result)) } -async fn api_key_login( - data: ConnectData, - user_id: &mut Option, - conn: &DbConn, - client_header: &ClientHeaders, -) -> JsonResult { - let ip = &client_header.ip; +async fn _api_key_login(data: ConnectData, user_id: &mut Option, conn: &DbConn, ip: &ClientIp) -> JsonResult { // Ratelimit the login crate::ratelimit::check_limit_login(&ip.ip)?; // Validate scope match data.scope.as_ref() { - 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, client_header).await - } - Some(scope) if scope == &AuthMethod::OrgApiKey.scope() => organization_api_key_login(data, conn, ip).await, + Some(scope) if scope == &AuthMethod::UserApiKey.scope() => _user_api_key_login(data, user_id, conn, ip).await, + Some(scope) if scope == &AuthMethod::OrgApiKey.scope() => _organization_api_key_login(data, conn, ip).await, _ => err!("Scope not supported"), } } -async fn user_api_key_login( +async fn _user_api_key_login( data: ConnectData, user_id: &mut Option, conn: &DbConn, - client_header: &ClientHeaders, + ip: &ClientIp, ) -> JsonResult { - let ip = &client_header.ip; // Get the user via the client_id let client_id = data.client_id.as_ref().unwrap(); let Some(client_user_id) = client_id.strip_prefix("user.") else { @@ -736,8 +586,6 @@ async fn user_api_key_login( ) } - check_client_version(&user, client_header)?; - let mut device = get_device(&data, conn, &user).await?; if CONFIG.mail_enabled() && device.is_new() { @@ -782,13 +630,24 @@ async fn user_api_key_login( // https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26 "MasterKeyEncryptedUserKey": user.akey, "MasterKeyWrappedUserKey": user.akey, - "Salt": user.master_password_salt() + "Salt": user.email }) } else { Value::Null }; - let account_keys = user.account_keys_json(conn).await; + let account_keys = if user.private_key.is_some() { + json!({ + "publicKeyEncryptionKeyPair": { + "wrappedPrivateKey": user.private_key, + "publicKey": user.public_key, + "Object": "publicKeyEncryptionKeyPair" + }, + "Object": "privateKeys" + }) + } else { + Value::Null + }; // Note: No refresh_token is returned. The CLI just repeats the // client_credentials login flow when the existing token expires. @@ -803,6 +662,7 @@ async fn user_api_key_login( "KdfIterations": user.client_kdf_iter, "KdfMemory": user.client_kdf_memory, "KdfParallelism": user.client_kdf_parallelism, + "ResetMasterPassword": false, // TODO: according to official server seems something like: user.password_hash.is_empty(), but would need testing "ForcePasswordReset": false, "scope": AuthMethod::UserApiKey.scope(), "AccountKeys": account_keys, @@ -816,13 +676,13 @@ async fn user_api_key_login( Ok(Json(result)) } -async fn organization_api_key_login(data: ConnectData, conn: &DbConn, ip: &ClientIp) -> JsonResult { +async fn _organization_api_key_login(data: ConnectData, conn: &DbConn, ip: &ClientIp) -> JsonResult { // Get the org via the client_id let client_id = data.client_id.as_ref().unwrap(); let Some(org_id) = client_id.strip_prefix("organization.") else { err!("Malformed client_id", format!("IP: {}.", ip.ip)) }; - let org_id: OrganizationId = org_id.to_owned().into(); + let org_id: OrganizationId = org_id.to_string().into(); let Some(org_api_key) = OrganizationApiKey::find_by_org_uuid(&org_id, conn).await else { err!("Invalid client_id", format!("IP: {}.", ip.ip)) }; @@ -853,13 +713,14 @@ async fn get_device(data: &ConnectData, conn: &DbConn, user: &User) -> ApiResult let device_name = data.device_name.clone().expect("No device name provided"); // Find device or create new - if let Some(device) = Device::find_by_uuid_and_user(&device_id, &user.uuid, conn).await { - Ok(device) - } else { - let mut device = Device::new(device_id, user.uuid.clone(), device_name, device_type); - // save device without updating `device.updated_at` - device.save(false, conn).await?; - Ok(device) + match Device::find_by_uuid_and_user(&device_id, &user.uuid, conn).await { + Some(device) => Ok(device), + None => { + let mut device = Device::new(device_id, user.uuid.clone(), device_name, device_type); + // save device without updating `device.updated_at` + device.save(false, conn).await?; + Ok(device) + } } } @@ -868,6 +729,7 @@ async fn twofactor_auth( data: &ConnectData, device: &mut Device, ip: &ClientIp, + client_version: &Option, conn: &DbConn, ) -> ApiResult> { let twofactors = TwoFactor::find_by_user(&user.uuid, conn).await; @@ -884,7 +746,7 @@ async fn twofactor_auth( .iter() .filter_map(|tf| { let provider_type = TwoFactorType::from_i32(tf.atype)?; - (tf.enabled && is_twofactor_provider_usable(&provider_type, Some(&tf.data))).then_some(tf.atype) + (tf.enabled && is_twofactor_provider_usable(provider_type, Some(&tf.data))).then_some(tf.atype) }) .collect(); if twofactor_ids.is_empty() { @@ -892,40 +754,56 @@ async fn twofactor_auth( } let selected_id = data.two_factor_provider.unwrap_or(twofactor_ids[0]); // If we aren't given a two factor provider, assume the first one - // Ignore Remember and RecoveryCode Types during this check, these are special - if ![TwoFactorType::Remember as i32, TwoFactorType::RecoveryCode as i32].contains(&selected_id) - && !twofactor_ids.contains(&selected_id) - { - err_json!(json_err_twofactor(&twofactor_ids, user, data, conn).await?, "Invalid two factor provider") + if !twofactor_ids.contains(&selected_id) { + err_json!( + _json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?, + "Invalid two factor provider" + ) } - // Like upstream, a blank token counts as not provided - let Some(twofactor_code) = data.two_factor_token.as_deref().filter(|t| !t.trim().is_empty()) else { - err_json!(json_err_twofactor(&twofactor_ids, user, data, conn).await?, "2FA token not provided") + let twofactor_code = match data.two_factor_token { + Some(ref code) => code, + None => { + err_json!( + _json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?, + "2FA token not provided" + ) + } }; let selected_twofactor = twofactors.into_iter().find(|tf| tf.atype == selected_id && tf.enabled); - let selected_data = selected_data(selected_twofactor); + use crate::crypto::ct_eq; + + let selected_data = _selected_data(selected_twofactor); match TwoFactorType::from_i32(selected_id) { Some(TwoFactorType::Authenticator) => { - authenticator::validate_totp_code_str(&user.uuid, twofactor_code, &selected_data?, ip, conn).await?; + authenticator::validate_totp_code_str(&user.uuid, twofactor_code, &selected_data?, ip, 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::Duo) => { - duo_oidc::validate_duo_login( - &user.email, - twofactor_code, - data.client_id.as_ref().unwrap(), - data.device_identifier.as_ref().unwrap(), - conn, - ) - .await?; + match CONFIG.duo_use_iframe() { + true => { + // Legacy iframe prompt flow + duo::validate_duo_login(&user.email, twofactor_code, conn).await? + } + false => { + // OIDC based flow + duo_oidc::validate_duo_login( + &user.email, + twofactor_code, + data.client_id.as_ref().unwrap(), + data.device_identifier.as_ref().unwrap(), + conn, + ) + .await? + } + } } 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? } Some(TwoFactorType::Remember) => { match device.twofactor_remember { @@ -933,7 +811,7 @@ async fn twofactor_auth( // If it is invalid we need to trigger the 2FA Login prompt Some(ref token) if !CONFIG.disable_2fa_remember() - && (crypto::ct_eq(token, twofactor_code) + && (ct_eq(token, twofactor_code) && auth::decode_2fa_remember(twofactor_code) .is_ok_and(|t| t.sub == device.uuid && t.user_uuid == user.uuid)) => {} _ => { @@ -944,42 +822,23 @@ async fn twofactor_auth( device.save(true, conn).await?; } err_json!( - json_err_twofactor(&twofactor_ids, user, data, conn).await?, + _json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?, "2FA Remember token not provided or expired" ) } } } Some(TwoFactorType::RecoveryCode) => { - // Like upstream, spaces and case don't matter - let recovery_code = twofactor_code.replace(' ', "").trim().to_lowercase(); - if !user.check_valid_recovery_code(&recovery_code) { - err!( - "Two-step token is invalid. Try again.", - ErrorEvent { - event: EventType::UserFailedLogIn2fa - } - ) + // Check if recovery code is correct + if !user.check_valid_recovery_code(twofactor_code) { + err!("Recovery code is incorrect. Try again.") } // Remove all twofactors from the user TwoFactor::delete_all_by_user(&user.uuid, conn).await?; - - // No device may keep skipping 2FA once every second factor is gone. - // `device` is cleared in memory too, since saving it later would restore its token. - Device::clear_twofactor_remember_by_user(&user.uuid, conn).await?; - device.delete_twofactor_remember(); - enforce_2fa_policy(user, &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:#?}"); - } + log_user_event(EventType::UserRecovered2fa as i32, &user.uuid, device.atype, &ip.ip, conn).await; // Remove the recovery code, not needed without twofactors user.totp_recover = None; @@ -1004,45 +863,72 @@ async fn twofactor_auth( Ok(two_factor) } -fn selected_data(tf: Option) -> ApiResult { +fn _selected_data(tf: Option) -> ApiResult { tf.map(|t| t.data).map_res("Two factor doesn't exist") } -async fn json_err_twofactor(providers: &[i32], user: &User, data: &ConnectData, conn: &DbConn) -> ApiResult { - let user_id = &user.uuid; +async fn _json_err_twofactor( + providers: &[i32], + user_id: &UserId, + data: &ConnectData, + client_version: &Option, + conn: &DbConn, +) -> ApiResult { let mut result = json!({ "error" : "invalid_grant", "error_description" : "Two factor required.", "TwoFactorProviders" : providers.iter().map(ToString::to_string).collect::>(), "TwoFactorProviders2" : {}, // { "0" : null } - "MasterPasswordPolicy": identity_master_password_policy(user, conn).await, + "MasterPasswordPolicy": { + "Object": "masterPasswordPolicy" + } }); for provider in providers { result["TwoFactorProviders2"][provider.to_string()] = Value::Null; match TwoFactorType::from_i32(*provider) { + Some(TwoFactorType::Authenticator) => { /* Nothing to do for TOTP */ } + Some(TwoFactorType::Webauthn) if CONFIG.is_webauthn_2fa_supported() => { let request = webauthn::generate_webauthn_login(user_id, conn).await?; result["TwoFactorProviders2"][provider.to_string()] = request.0; } Some(TwoFactorType::Duo) => { - let auth_url = duo_oidc::get_duo_auth_url( - &user.email, - data.client_id.as_ref().unwrap(), - data.device_identifier.as_ref().unwrap(), - conn, - ) - .await?; + let email = match User::find_by_uuid(user_id, conn).await { + Some(u) => u.email, + None => err!("User does not exist"), + }; - result["TwoFactorProviders2"][provider.to_string()] = json!({ - "AuthUrl": auth_url, - }); + match CONFIG.duo_use_iframe() { + true => { + // Legacy iframe prompt flow + let (signature, host) = duo::generate_duo_signature(&email, conn).await?; + result["TwoFactorProviders2"][provider.to_string()] = json!({ + "Host": host, + "Signature": signature, + }) + } + false => { + // OIDC based flow + let auth_url = duo_oidc::get_duo_auth_url( + &email, + data.client_id.as_ref().unwrap(), + data.device_identifier.as_ref().unwrap(), + conn, + ) + .await?; + + result["TwoFactorProviders2"][provider.to_string()] = json!({ + "AuthUrl": auth_url, + }) + } + } } Some(tf_type @ TwoFactorType::YubiKey) => { - let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, tf_type, conn).await else { + let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, tf_type as i32, conn).await else { err!("No YubiKey devices registered") }; @@ -1050,35 +936,34 @@ async fn json_err_twofactor(providers: &[i32], user: &User, data: &ConnectData, result["TwoFactorProviders2"][provider.to_string()] = json!({ "Nfc": yubikey_metadata.nfc, - }); + }) } Some(tf_type @ TwoFactorType::Email) => { - let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, tf_type, conn).await else { + let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, tf_type as i32, conn).await else { err!("No twofactor email registered") }; + // Starting with version 2025.5.0 the client will call `/api/two-factor/send-email-login`. + let disabled_send = if let Some(cv) = client_version { + let ver_match = semver::VersionReq::parse(">=2025.5.0").unwrap(); + ver_match.matches(&cv.0) + } else { + false + }; + + // Send email immediately if email is the only 2FA option. + if providers.len() == 1 && !disabled_send { + email::send_token(user_id, conn).await? + } + let email_data = email::EmailTokenData::from_json(&twofactor.data)?; result["TwoFactorProviders2"][provider.to_string()] = json!({ "Email": email::obscure_email(&email_data.email), - }); + }) } - None - | Some( - TwoFactorType::Authenticator - | TwoFactorType::EmailVerificationChallenge - | TwoFactorType::OrganizationDuo - | TwoFactorType::ProtectedActions - | TwoFactorType::RecoveryCode - | TwoFactorType::Remember - | TwoFactorType::U2f - | TwoFactorType::U2fLoginChallenge - | TwoFactorType::U2fRegisterChallenge - | TwoFactorType::Webauthn - | TwoFactorType::WebauthnLoginChallenge - | TwoFactorType::WebauthnRegisterChallenge, - ) => { /* Nothing special to do for these providers */ } + _ => {} } } @@ -1086,13 +971,13 @@ async fn json_err_twofactor(providers: &[i32], user: &User, data: &ConnectData, } #[post("/accounts/prelogin", data = "")] -async fn post_prelogin(data: Json, ip: ClientIp, conn: DbConn) -> JsonResult { - prelogin(data, ip, conn).await +async fn prelogin(data: Json, conn: DbConn) -> Json { + _prelogin(data, conn).await } -#[post("/accounts/prelogin/password", data = "")] -async fn prelogin_password(data: Json, ip: ClientIp, conn: DbConn) -> JsonResult { - prelogin(data, ip, conn).await +#[post("/accounts/register", data = "")] +async fn identity_register(data: Json, conn: DbConn) -> JsonResult { + _register(data, false, conn).await } #[derive(Debug, Deserialize)] @@ -1103,35 +988,18 @@ struct RegisterVerificationData { // receiveMarketingEmails: bool, } -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct RegisterVerificationEmailClickedData { - email: String, - email_verification_token: String, -} - #[derive(rocket::Responder)] enum RegisterVerificationResponse { #[response(status = 204)] NoContent(()), Token(Json), - PlainToken(String), -} - -// Return JSON only when the client explicitly requests it, otherwise return plain text. -fn accepts_json(accept: Option<&Accept>) -> bool { - accept.is_some_and(|accept| accept.preferred().media_type() == &MediaType::JSON) } #[post("/accounts/register/send-verification-email", data = "")] async fn register_verification_email( data: Json, - accept: Option<&Accept>, - ip: ClientIp, conn: DbConn, ) -> ApiResult { - crate::ratelimit::check_limit_unauthenticated(&ip.ip)?; - let data = data.into_inner(); // the registration can only continue if signup is allowed or there exists an invitation @@ -1148,13 +1016,13 @@ async fn register_verification_email( if should_send_mail { let user = User::find_by_mail(&data.email, &conn).await; - if user.as_ref().is_some_and(|u| u.private_key.is_some()) { + if user.filter(|u| u.private_key.is_some()).is_some() { // There is still a timing side channel here in that the code // paths that send mail take noticeably longer than ones that don't. // Add a randomized sleep to mitigate this somewhat. - use rand::{RngExt, rngs::SmallRng}; + use rand::{rngs::SmallRng, RngExt}; let mut rng: SmallRng = rand::make_rng(); - let sleep_ms: u64 = rng.random_range(900..=1100); + let sleep_ms = rng.random_range(900..=1100) as u64; tokio::time::sleep(tokio::time::Duration::from_millis(sleep_ms)).await; } else { mail::send_register_verify_email(&data.email, &token).await?; @@ -1164,37 +1032,13 @@ async fn register_verification_email( } else { // If email verification is not required, return the token directly // the clients will use this token to finish the registration - Ok(if accepts_json(accept) { - RegisterVerificationResponse::Token(Json(token)) - } else { - RegisterVerificationResponse::PlainToken(token) - }) + Ok(RegisterVerificationResponse::Token(Json(token))) } } -#[post("/accounts/register/verification-email-clicked", data = "")] -async fn register_verification_email_clicked( - data: Json, - conn: DbConn, -) -> EmptyResult { - let data = data.into_inner(); - let token_valid = auth::decode_register_verify(&data.email_verification_token) - .is_ok_and(|claims| claims.verified && claims.sub.eq_ignore_ascii_case(&data.email)); - let user_exists = User::find_by_mail(&data.email, &conn) - .await - .is_some_and(|user| !user.password_hash.is_empty() || user.private_key.is_some()); - - if !token_valid || user_exists { - err!("Expired link. Please restart registration or try logging in. You may already have an account") - } - - Ok(()) -} - #[post("/accounts/register/finish", data = "")] -async fn register_finish(data: Json, ip: ClientIp, conn: DbConn) -> JsonResult { - crate::ratelimit::check_limit_unauthenticated(&ip.ip)?; - register(data, conn).await +async fn register_finish(data: Json, conn: DbConn) -> JsonResult { + _register(data, true, conn).await } // https://github.com/bitwarden/jslib/blob/master/common/src/models/request/tokenRequest.ts @@ -1253,15 +1097,11 @@ struct ConnectData { // Needed for authorization code #[field(name = uncased("code"))] - code: Option, + code: Option, #[field(name = uncased("code_verifier"))] code_verifier: Option, - - // Needed for send access - send_id: Option, - password_hash_b64: Option, } -fn check_is_some(value: Option<&T>, msg: &str) -> EmptyResult { +fn _check_is_some(value: &Option, msg: &str) -> EmptyResult { if value.is_none() { err!(msg) } @@ -1280,32 +1120,33 @@ fn prevalidate() -> JsonResult { } } -const SSO_BINDING_COOKIE: &str = "VW_SSO_BINDING"; - #[get("/connect/oidc-signin?&", rank = 1)] -async fn oidcsignin(code: OIDCCode, state: String, cookies: &CookieJar<'_>, mut conn: DbConn) -> ApiResult { - oidcsignin_redirect(state, code, None, cookies, &mut conn).await +async fn oidcsignin(code: OIDCCode, state: String, mut conn: DbConn) -> ApiResult { + _oidcsignin_redirect( + state, + OIDCCodeWrapper::Ok { + code, + }, + &mut conn, + ) + .await } -// Bitwarden client appear to only care for code and state -// We save the error in the database and set the encoded state as the code to be able to retrieve them later on -// cf: https://github.com/bitwarden/clients/blob/afd36d290ce18fb0048e0575e7d5a8f78b5dbffc/libs/auth/src/angular/sso/sso.component.ts#L156 +// Bitwarden client appear to only care for code and state so we pipe it through +// cf: https://github.com/bitwarden/clients/blob/80b74b3300e15b4ae414dc06044cc9b02b6c10a6/libs/auth/src/angular/sso/sso.component.ts#L141 #[get("/connect/oidc-signin?&&", rank = 2)] async fn oidcsignin_error( state: String, error: String, error_description: Option, - cookies: &CookieJar<'_>, mut conn: DbConn, ) -> ApiResult { - oidcsignin_redirect( - state.clone(), - state.into(), - Some(OIDCCodeResponseError { + _oidcsignin_redirect( + state, + OIDCCodeWrapper::Error { error, error_description, - }), - cookies, + }, &mut conn, ) .await @@ -1314,32 +1155,18 @@ async fn oidcsignin_error( // The state was encoded using Base64 to ensure no issue with providers. // iss and scope parameters are needed for redirection to work on IOS. // We pass the state as the code to get it back later on. -async fn oidcsignin_redirect( +async fn _oidcsignin_redirect( base64_state: String, - code: OIDCCode, - error: Option, - cookies: &CookieJar<'_>, + code_response: OIDCCodeWrapper, conn: &mut DbConn, ) -> ApiResult { let state = sso::decode_state(&base64_state)?; - let Some(mut sso_auth) = SsoAuth::find(&state, conn).await else { - err!(format!("Cannot retrieve sso_auth for {}", state.escape_debug())) + let mut sso_auth = match SsoAuth::find(&state, conn).await { + None => err!(format!("Cannot retrieve sso_auth for {state}")), + Some(sso_auth) => sso_auth, }; - - // Browser-binding check - // The cookie was set on /connect/authorize and must come from the same browser that initiated the flow. - let cookie_value = cookies.get(SSO_BINDING_COOKIE).map(|c| c.value().to_owned()); - let provided_hash = cookie_value.as_deref().map(|v| crypto::sha256_hex(v.as_bytes())); - match (sso_auth.binding_hash.as_deref(), provided_hash.as_deref()) { - (Some(expected), Some(actual)) if crypto::ct_eq(expected, actual) => {} - _ => err!(format!("SSO session binding mismatch for {}", state.escape_debug())), - } - cookies - .remove(Cookie::build(SSO_BINDING_COOKIE).path(format!("{}/identity/connect/", CONFIG.domain_path())).build()); - - sso_auth.code_response = Some(code.clone()); - sso_auth.code_response_error = error; + sso_auth.code_response = Some(code_response); sso_auth.updated_at = Utc::now().naive_utc(); sso_auth.save(conn).await?; @@ -1349,7 +1176,7 @@ async fn oidcsignin_redirect( }; url.query_pairs_mut() - .append_pair("code", &code) + .append_pair("code", &state) .append_pair("state", &state) .append_pair("scope", &AuthMethod::Sso.scope()) .append_pair("iss", &CONFIG.domain()); @@ -1385,7 +1212,7 @@ struct AuthorizeData { // The `redirect_uri` will change depending of the client (web, android, ios ..) #[get("/connect/authorize?")] -async fn authorize(data: AuthorizeData, cookies: &CookieJar<'_>, secure: Secure, conn: DbConn) -> ApiResult { +async fn authorize(data: AuthorizeData, conn: DbConn) -> ApiResult { let AuthorizeData { client_id, redirect_uri, @@ -1399,53 +1226,7 @@ async fn authorize(data: AuthorizeData, cookies: &CookieJar<'_>, secure: Secure, err!("Unsupported code challenge method"); } - // Generate browser-binding token. Stored hashed in DB; raw value handed to the browser as a cookie. - // Validated on /connect/oidc-signin - let binding_token = data_encoding::BASE64URL_NOPAD.encode(&crypto::get_random_bytes::<32>()); - let binding_hash = crypto::sha256_hex(binding_token.as_bytes()); - - let auth_url = - sso::authorize_url(state, code_challenge, &client_id, &redirect_uri, Some(binding_hash), conn).await?; - - cookies.add( - Cookie::build((SSO_BINDING_COOKIE, binding_token)) - .path(format!("{}/identity/connect/", CONFIG.domain_path())) - .max_age(time::Duration::seconds(sso::SSO_AUTH_EXPIRATION.num_seconds())) - .same_site(SameSite::Lax) // Lax is needed because the IdP runs on a different FQDN - .http_only(true) - .secure(secure.https) - .build(), - ); + let auth_url = sso::authorize_url(state, code_challenge, &client_id, &redirect_uri, conn).await?; Ok(Redirect::temporary(String::from(auth_url))) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_client_version_error() { - let version = |v| semver::Version::parse(v).unwrap(); - let web = DeviceType::ChromeBrowser as i32; - let desktop = DeviceType::WindowsDesktop as i32; - // Every login needs the header - assert_eq!(client_version_error(false, None, web).map(|e| e.0), Some("version_header_missing")); - assert_eq!(client_version_error(true, None, desktop).map(|e| e.0), Some("version_header_missing")); - // V1 accounts log in from any version - assert_eq!(client_version_error(false, Some(&version("2024.1.0")), web), None); - assert_eq!(client_version_error(false, Some(&version("2024.1.0")), desktop), None); - // V2 accounts need 2026.10.1, and 2026.10.2 for the desktop app - assert_eq!( - client_version_error(true, Some(&version("2026.10.0")), web).map(|e| e.0), - Some("invalid_client_version") - ); - assert_eq!(client_version_error(true, Some(&version("2026.10.1")), web), None); - assert_eq!( - client_version_error(true, Some(&version("2026.10.1")), desktop).map(|e| e.0), - Some("invalid_client_version") - ); - assert_eq!(client_version_error(true, Some(&version("2026.10.2")), DeviceType::Uwp as i32), None); - assert_eq!(client_version_error(true, Some(&version("2026.11.0")), desktop), None); - } -} diff --git a/src/api/mod.rs b/src/api/mod.rs index 42c6d53d..ecdf9408 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -23,22 +23,20 @@ pub use crate::api::{ icons::routes as icons_routes, identity::routes as identity_routes, notifications::routes as notifications_routes, - notifications::{AnonymousNotify, LogOutReason, Notify, UpdateType, WS_ANONYMOUS_SUBSCRIPTIONS, WS_USERS}, + notifications::{AnonymousNotify, Notify, UpdateType, WS_ANONYMOUS_SUBSCRIPTIONS, WS_USERS}, push::{ - push_cipher_update, push_folder_update, push_logout, push_logout_to_relay, push_send_update, push_user_update, - register_push_device, unregister_push_device, + push_cipher_update, push_folder_update, push_logout, push_send_update, push_user_update, register_push_device, + unregister_push_device, }, web::catchers as web_catchers, web::routes as web_routes, - web::{STATIC_FILES, invalidate_css_cache}, + web::static_files, }; -use crate::{ - CONFIG, - db::{ - DbConn, - models::{OrgPolicy, OrgPolicyType, User}, - }, +use crate::db::{ + models::{OrgPolicy, OrgPolicyType, User}, + DbConn, }; +use crate::CONFIG; // Type aliases for API methods results pub type ApiResult = Result; @@ -46,7 +44,7 @@ pub type JsonResult = ApiResult>; pub type EmptyResult = ApiResult<()>; // Common structs representing JSON data received -#[derive(Debug, Deserialize)] +#[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct PasswordOrOtpData { #[serde(alias = "MasterPasswordHash")] @@ -76,7 +74,6 @@ impl PasswordOrOtpData { } } -#[expect(clippy::struct_excessive_bools, reason = "Bitwarden clients expect the data in this specific format")] #[derive(Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct MasterPasswordPolicy { @@ -124,21 +121,3 @@ async fn master_password_policy(user: &User, conn: &DbConn) -> Value { mpp_json["Object"] = json!("masterPasswordPolicy"); mpp_json } - -// Identity responses use PascalCase keys like upstream, some clients (Android) only read those -async fn identity_master_password_policy(user: &User, conn: &DbConn) -> Value { - match master_password_policy(user, conn).await { - Value::Object(policy) => Value::Object( - policy - .into_iter() - .map(|(mut key, value)| { - if let Some(first) = key.get_mut(..1) { - first.make_ascii_uppercase(); - } - (key, value) - }) - .collect(), - ), - policy => policy, - } -} diff --git a/src/api/notifications.rs b/src/api/notifications.rs index 304ce447..492fdb19 100644 --- a/src/api/notifications.rs +++ b/src/api/notifications.rs @@ -1,27 +1,22 @@ use std::{ net::IpAddr, sync::{Arc, LazyLock}, - time::{Duration, Instant}, + time::Duration, }; use chrono::{NaiveDateTime, Utc}; use rmpv::Value; -use rocket::{Route, futures::StreamExt}; +use rocket::{futures::StreamExt, Route}; use rocket_ws::{Message, WebSocket}; use tokio::sync::mpsc::Sender; use crate::{ - CONFIG, Error, auth::{ClientIp, WsAccessTokenHeader}, db::{ - DbConn, models::{AuthRequestId, Cipher, CollectionId, Device, DeviceId, Folder, PushId, Send as DbSend, User, UserId}, + DbConn, }, -}; - -use super::{ - push::push_auth_request, push::push_auth_response, push_cipher_update, push_folder_update, push_logout, - push_send_update, push_user_update, + Error, CONFIG, }; pub static WS_USERS: LazyLock> = LazyLock::new(|| { @@ -33,21 +28,13 @@ pub static WS_USERS: LazyLock> = LazyLock::new(|| { pub static WS_ANONYMOUS_SUBSCRIPTIONS: LazyLock> = LazyLock::new(|| { Arc::new(AnonymousWebSocketSubscriptions { map: Arc::new(dashmap::DashMap::new()), - connections: Arc::new(dashmap::DashMap::new()), }) }); -/// The anonymous hub needs no authentication, so bound how much a single client can hold open. -/// One connection is needed per pending login request, several at once are only expected behind NAT. -const MAX_ANONYMOUS_CONNECTIONS_PER_IP: u32 = 25; - -/// How often a Ping is sent to the client. -const WS_PING_INTERVAL: Duration = Duration::from_secs(15); - -/// Close the connection if nothing, not even a Pong, was received from the client for this long. -/// Otherwise half-open connections (client gone without a FIN, e.g. behind a proxy or NAT) are kept forever. -/// Same as the default `ClientTimeoutInterval` of ASP.NET Core SignalR, which the official server uses. -const WS_CLIENT_TIMEOUT: Duration = Duration::from_secs(30); +use super::{ + push::push_auth_request, push::push_auth_response, push_cipher_update, push_folder_update, push_logout, + push_send_update, push_user_update, +}; static NOTIFICATIONS_DISABLED: LazyLock = LazyLock::new(|| !CONFIG.enable_websocket() && !CONFIG.push_enabled()); @@ -95,21 +82,14 @@ impl Drop for WSEntryMapGuard { struct WSAnonymousEntryMapGuard { subscriptions: Arc, token: String, - entry_uuid: uuid::Uuid, addr: IpAddr, } impl WSAnonymousEntryMapGuard { - fn new( - subscriptions: Arc, - token: String, - entry_uuid: uuid::Uuid, - addr: IpAddr, - ) -> Self { + fn new(subscriptions: Arc, token: String, addr: IpAddr) -> Self { Self { subscriptions, token, - entry_uuid, addr, } } @@ -118,15 +98,11 @@ impl WSAnonymousEntryMapGuard { impl Drop for WSAnonymousEntryMapGuard { fn drop(&mut self) { info!("Closing WS connection from {}", self.addr); - if let Some(mut entry) = self.subscriptions.map.get_mut(&self.token) { - entry.retain(|(uuid, _)| uuid != &self.entry_uuid); - } - self.subscriptions.map.remove_if(&self.token, |_, senders| senders.is_empty()); - self.subscriptions.release(self.addr); + self.subscriptions.map.remove(&self.token); } } -#[expect(tail_expr_drop_order)] +#[allow(tail_expr_drop_order)] #[get("/hub?")] fn websockets_hub<'r>( ws: WebSocket, @@ -164,16 +140,12 @@ fn websockets_hub<'r>( rocket_ws::Stream! { ws => { let mut ws = ws; let _guard = guard; - let mut interval = tokio::time::interval(WS_PING_INTERVAL); - let mut last_received = Instant::now(); + let mut interval = tokio::time::interval(Duration::from_secs(15)); loop { tokio::select! { res = ws.next() => { match res { Some(Ok(message)) => { - // Any message, including a Pong, means the client is still there - last_received = Instant::now(); - match message { // Respond to any pings Message::Ping(ping) => yield Message::Pong(ping), @@ -207,20 +179,14 @@ fn websockets_hub<'r>( } } - _ = interval.tick() => { - // The client stopped responding without closing the connection, drop it - if last_received.elapsed() > WS_CLIENT_TIMEOUT { - break; - } - yield Message::Ping(PING.to_vec()); - } + _ = interval.tick() => yield Message::Ping(create_ping()) } } }} }) } -#[expect(tail_expr_drop_order)] +#[allow(tail_expr_drop_order)] #[get("/anonymous-hub?")] fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> Result { info!("Accepting Anonymous Rocket WS connection from {}", ip.ip); @@ -228,35 +194,24 @@ fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> R let (mut rx, guard) = { let subscriptions = Arc::clone(&WS_ANONYMOUS_SUBSCRIPTIONS); - if !subscriptions.try_reserve(ip.ip) { - err_code!("Too many connections", 429) - } - - // Add a channel to send messages to this client to the map. - // Clients reconnect with the same token while a login request is still pending, so keep - // every subscriber instead of replacing, otherwise the older one takes the newer one down. + // Add a channel to send messages to this client to the map let (tx, rx) = tokio::sync::mpsc::channel::(100); - let entry_uuid = uuid::Uuid::new_v4(); - subscriptions.map.entry(token.clone()).or_default().push((entry_uuid, tx)); + subscriptions.map.insert(token.clone(), tx); // Once the guard goes out of scope, the connection will have been closed and the entry will be deleted from the map - (rx, WSAnonymousEntryMapGuard::new(subscriptions, token, entry_uuid, ip.ip)) + (rx, WSAnonymousEntryMapGuard::new(subscriptions, token, ip.ip)) }; Ok({ rocket_ws::Stream! { ws => { let mut ws = ws; let _guard = guard; - let mut interval = tokio::time::interval(WS_PING_INTERVAL); - let mut last_received = Instant::now(); + let mut interval = tokio::time::interval(Duration::from_secs(15)); loop { tokio::select! { res = ws.next() => { match res { Some(Ok(message)) => { - // Any message, including a Pong, means the client is still there - last_received = Instant::now(); - match message { // Respond to any pings Message::Ping(ping) => yield Message::Pong(ping), @@ -290,13 +245,7 @@ fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> R } } - _ = interval.tick() => { - // The client stopped responding without closing the connection, drop it - if last_received.elapsed() > WS_CLIENT_TIMEOUT { - break; - } - yield Message::Ping(PING.to_vec()); - } + _ = interval.tick() => yield Message::Ping(create_ping()) } } }} @@ -319,15 +268,14 @@ fn serialize(val: &Value) -> Vec { let mut len_buf: Vec = Vec::new(); loop { - #[expect(clippy::cast_possible_truncation, reason = "masked to 7 bits, fits u8")] - let mut size_part = (size & 0x7f) as u8; + let mut size_part = size & 0x7f; size >>= 7; if size > 0 { size_part |= 0x80; } - len_buf.push(size_part); + len_buf.push(size_part as u8); if size == 0 { break; @@ -359,7 +307,6 @@ fn convert_option>(option: Option) -> Value { const RECORD_SEPARATOR: u8 = 0x1e; const INITIAL_RESPONSE: [u8; 3] = [0x7b, 0x7d, RECORD_SEPARATOR]; // {, }, -const PING: [u8; 3] = [0x02, 0x91, 0x06]; #[derive(Deserialize, Copy, Clone, Eq, PartialEq)] struct InitialMessage<'a> { @@ -382,7 +329,7 @@ pub struct WebSocketUsers { impl WebSocketUsers { async fn send_update(&self, user_id: &UserId, data: &[u8]) { if let Some(user) = self.map.get(user_id.as_ref()).map(|v| v.clone()) { - for (_, sender) in &user { + for (_, sender) in user.iter() { if let Err(e) = sender.send(Message::binary(data)).await { error!("Error sending WS update {e}"); } @@ -391,7 +338,7 @@ impl WebSocketUsers { } // NOTE: The last modified date needs to be updated before calling these methods - pub async fn send_user_update(&self, ut: UpdateType, user: &User, push_uuid: Option<&PushId>, conn: &DbConn) { + pub async fn send_user_update(&self, ut: UpdateType, user: &User, push_uuid: &Option, conn: &DbConn) { // Skip any processing if both WebSockets and Push are not active if *NOTIFICATIONS_DISABLED { return; @@ -412,35 +359,23 @@ impl WebSocketUsers { } pub async fn send_logout(&self, user: &User, acting_device: Option<&Device>, conn: &DbConn) { - self.send_logout_with_reason(user, acting_device, None, conn).await; - } - - /// A logout that clients may handle differently depending on `reason` - pub async fn send_logout_with_reason( - &self, - user: &User, - acting_device: Option<&Device>, - reason: Option, - conn: &DbConn, - ) { // Skip any processing if both WebSockets and Push are not active if *NOTIFICATIONS_DISABLED { return; } let acting_device_id = acting_device.map(|d| d.uuid.clone()); - let mut payload = - vec![("UserId".into(), user.uuid.to_string().into()), ("Date".into(), serialize_date(user.updated_at))]; - if let Some(reason) = reason { - payload.push(("Reason".into(), (reason as i32).into())); - } - let data = create_update(payload, UpdateType::LogOut, acting_device_id); + let data = create_update( + vec![("UserId".into(), user.uuid.to_string().into()), ("Date".into(), serialize_date(user.updated_at))], + UpdateType::LogOut, + acting_device_id, + ); if CONFIG.enable_websocket() { self.send_update(&user.uuid, &data).await; } if CONFIG.push_enabled() { - push_logout(user, acting_device, reason, conn).await; + push_logout(user, acting_device, conn).await; } } @@ -598,39 +533,12 @@ impl WebSocketUsers { #[derive(Clone)] pub struct AnonymousWebSocketSubscriptions { - map: Arc>>, - connections: Arc>, + map: Arc>>, } impl AnonymousWebSocketSubscriptions { - /// Takes a connection slot for this address, returns false when it already reached the limit. - fn try_reserve(&self, addr: IpAddr) -> bool { - let mut count = self.connections.entry(addr).or_insert(0); - if *count >= MAX_ANONYMOUS_CONNECTIONS_PER_IP { - return false; - } - *count += 1; - true - } - - /// Releases a slot taken by `try_reserve`. - fn release(&self, addr: IpAddr) { - let empty = if let Some(mut count) = self.connections.get_mut(&addr) { - *count = count.saturating_sub(1); - *count == 0 - } else { - false - }; - // Only remove once the guard above is dropped, otherwise this deadlocks. - if empty { - self.connections.remove_if(&addr, |_, count| *count == 0); - } - } - async fn send_update(&self, token: &str, data: &[u8]) { - // Clone the senders so the map isn't kept locked while sending. - let senders = self.map.get(token).map(|v| v.clone()).unwrap_or_default(); - for (_, sender) in senders { + if let Some(sender) = self.map.get(token).map(|v| v.clone()) { if let Err(e) = sender.send(Message::binary(data)).await { error!("Error sending WS update {e}"); } @@ -674,7 +582,7 @@ fn create_update(payload: Vec<(Value, Value)>, ut: UpdateType, acting_device_id: V::Nil, "ReceiveMessage".into(), V::Array(vec![V::Map(vec![ - ("ContextId".into(), acting_device_id.map_or(V::Nil, |v| v.to_string().into())), + ("ContextId".into(), acting_device_id.map(|v| v.to_string().into()).unwrap_or_else(|| V::Nil)), ("Type".into(), (ut as i32).into()), ("Payload".into(), payload.into()), ])]), @@ -704,12 +612,8 @@ fn create_anonymous_update(payload: Vec<(Value, Value)>, ut: UpdateType, user_id serialize(&value) } -/// Why a logout was pushed, absent for a plain one. `KdfChange` isn't sent yet. -/// -/// Ref: -#[derive(Copy, Clone, Eq, PartialEq)] -pub enum LogOutReason { - KeyRotation = 1, +fn create_ping() -> Vec { + serialize(&Value::Array(vec![6.into()])) } // https://github.com/bitwarden/server/blob/375af7c43b10d9da03525d41452f95de3f921541/src/Core/Enums/PushType.cs diff --git a/src/api/push.rs b/src/api/push.rs index dab31c22..5000869d 100644 --- a/src/api/push.rs +++ b/src/api/push.rs @@ -4,21 +4,21 @@ use std::{ }; use reqwest::{ - Method, header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE}, + Method, }; use serde_json::Value; use tokio::sync::RwLock; use crate::{ - CONFIG, - api::{ApiResult, EmptyResult, LogOutReason, UpdateType}, + api::{ApiResult, EmptyResult, UpdateType}, db::{ - DbConn, models::{AuthRequestId, Cipher, Device, Folder, PushId, Send, User, UserId}, + DbConn, }, http_client::make_http_request, util::{format_date, get_uuid}, + CONFIG, }; #[derive(Deserialize)] @@ -74,9 +74,9 @@ async fn get_auth_api_token() -> ApiResult { }; let mut api_token = API_TOKEN.write().await; - // Token valid for half the specified time - let half_expires_in = u64::from((json_pushtoken.expires_in / 2).max(0).cast_unsigned()); - api_token.valid_until = Instant::now().checked_add(Duration::from_secs(half_expires_in)).unwrap(); + api_token.valid_until = Instant::now() + .checked_add(Duration::new((json_pushtoken.expires_in / 2) as u64, 0)) // Token valid for half the specified time + .unwrap(); api_token.access_token = json_pushtoken.access_token; @@ -135,29 +135,25 @@ pub async fn register_push_device(device: &mut Device, conn: &DbConn) -> EmptyRe Ok(()) } -pub async fn unregister_push_device(push_id: Option<&PushId>) -> EmptyResult { +pub async fn unregister_push_device(push_id: &Option) -> EmptyResult { if !CONFIG.push_enabled() || push_id.is_none() { return Ok(()); } - let data = json!({ - "id": push_id.as_ref().unwrap(), - }); - let auth_api_token = get_auth_api_token().await?; + let auth_header = format!("Bearer {auth_api_token}"); - if let Err(e) = make_http_request(Method::POST, &(CONFIG.push_relay_uri() + "/push/delete"))? - .header(CONTENT_TYPE, "application/json") - .header(ACCEPT, "application/json") - .header(AUTHORIZATION, auth_header) - .json(&data) - .send() - .await? - .error_for_status() + match make_http_request( + Method::POST, + &format!("{}/push/delete/{}", CONFIG.push_relay_uri(), push_id.as_ref().unwrap()), + )? + .header(AUTHORIZATION, auth_header) + .send() + .await { - err!(format!("An error occurred during device unregistration: {e}")); - } - + Ok(r) => r, + Err(e) => err!(format!("An error occurred during device unregistration: {e}")), + }; Ok(()) } @@ -165,7 +161,7 @@ pub async fn push_cipher_update(ut: UpdateType, cipher: &Cipher, device: &Device // We shouldn't send a push notification on cipher update if the cipher belongs to an organization, this isn't implemented in the upstream server too. if cipher.organization_uuid.is_some() { return; - } + }; let Some(user_id) = &cipher.user_uuid else { debug!("Cipher has no uuid"); return; @@ -192,32 +188,25 @@ pub async fn push_cipher_update(ut: UpdateType, cipher: &Cipher, device: &Device } } -pub async fn push_logout(user: &User, acting_device: Option<&Device>, reason: Option, conn: &DbConn) { +pub async fn push_logout(user: &User, acting_device: Option<&Device>, conn: &DbConn) { if Device::check_user_has_push_device(&user.uuid, conn).await { - push_logout_to_relay(user, acting_device, reason); + tokio::task::spawn(send_to_push_relay(json!({ + "userId": user.uuid, + "organizationId": (), + "deviceId": acting_device.and_then(|d| d.push_uuid.as_ref()), + "identifier": acting_device.map(|d| &d.uuid), + "type": UpdateType::LogOut as i32, + "payload": { + "userId": user.uuid, + "date": format_date(&user.updated_at) + }, + "clientType": null, + "installationId": null + }))); } } -// Sends the logout without checking for push devices first. -// Needed after deleting a user, when the devices are already gone from the database. -pub fn push_logout_to_relay(user: &User, acting_device: Option<&Device>, reason: Option) { - tokio::task::spawn(send_to_push_relay(json!({ - "userId": user.uuid, - "organizationId": (), - "deviceId": acting_device.and_then(|d| d.push_uuid.as_ref()), - "identifier": acting_device.map(|d| &d.uuid), - "type": UpdateType::LogOut as i32, - "payload": { - "userId": user.uuid, - "date": format_date(&user.updated_at), - "reason": reason.map(|r| r as i32), - }, - "clientType": null, - "installationId": null - }))); -} - -pub async fn push_user_update(ut: UpdateType, user: &User, push_uuid: Option<&PushId>, conn: &DbConn) { +pub async fn push_user_update(ut: UpdateType, user: &User, push_uuid: &Option, conn: &DbConn) { if Device::check_user_has_push_device(&user.uuid, conn).await { tokio::task::spawn(send_to_push_relay(json!({ "userId": user.uuid, @@ -255,23 +244,23 @@ pub async fn push_folder_update(ut: UpdateType, folder: &Folder, device: &Device } pub async fn push_send_update(ut: UpdateType, send: &Send, device: &Device, conn: &DbConn) { - if let Some(s) = &send.user_uuid - && Device::check_user_has_push_device(s, conn).await - { - tokio::task::spawn(send_to_push_relay(json!({ - "userId": send.user_uuid, - "organizationId": null, - "deviceId": device.push_uuid, // Should be the records unique uuid of the acting device (unique uuid per user/device) - "identifier": device.uuid, // Should be the acting device id (aka uuid per device/app) - "type": ut as i32, - "payload": { - "id": send.uuid, + if let Some(s) = &send.user_uuid { + if Device::check_user_has_push_device(s, conn).await { + tokio::task::spawn(send_to_push_relay(json!({ "userId": send.user_uuid, - "revisionDate": format_date(&send.revision_date) - }, - "clientType": null, - "installationId": null - }))); + "organizationId": null, + "deviceId": device.push_uuid, // Should be the records unique uuid of the acting device (unique uuid per user/device) + "identifier": device.uuid, // Should be the acting device id (aka uuid per device/app) + "type": ut as i32, + "payload": { + "id": send.uuid, + "userId": send.user_uuid, + "revisionDate": format_date(&send.revision_date) + }, + "clientType": null, + "installationId": null + }))); + } } } @@ -307,7 +296,7 @@ async fn send_to_push_relay(notification_data: Value) { .await { error!("An error occurred while sending a send update to the push relay: {e}"); - } + }; } pub async fn push_auth_request(user_id: &UserId, auth_request_id: &str, device: &Device, conn: &DbConn) { diff --git a/src/api/web.rs b/src/api/web.rs index 2818b5b7..0ae9c7db 100644 --- a/src/api/web.rs +++ b/src/api/web.rs @@ -1,29 +1,21 @@ -use std::{ - collections::HashMap, - path::{Path, PathBuf}, - sync::{Arc, LazyLock, RwLock}, -}; +use std::path::{Path, PathBuf}; use rocket::{ - Catcher, Route, fs::NamedFile, http::ContentType, - response::{Redirect, content::RawHtml as Html}, + response::{content::RawCss as Css, content::RawHtml as Html, Redirect}, serde::json::Json, + Catcher, Route, }; use serde_json::Value; use crate::{ - CONFIG, - api::{ApiResult, EmptyResult, core::now}, + api::{core::now, ApiResult, EmptyResult}, auth::decode_file_download, - crypto::sha256_hex, - db::{ - DbConn, - models::{AttachmentId, CipherId, custom_roles_enabled}, - }, + db::models::{AttachmentId, CipherId}, error::Error, - util::{Cached, EtagCached}, + util::Cached, + CONFIG, }; pub fn routes() -> Vec { @@ -31,20 +23,12 @@ pub fn routes() -> Vec { // crate::utils::LOGGED_ROUTES to make sure they appear in the log let mut routes = routes![attachments, alive, alive_head, static_files]; if CONFIG.web_vault_enabled() { - routes.append(&mut routes![ - web_index, - web_index_direct, - web_index_head, - app_id, - apple_app_site_association, - web_files, - vaultwarden_css - ]); + routes.append(&mut routes![web_index, web_index_direct, web_index_head, app_id, web_files, vaultwarden_css]); } #[cfg(debug_assertions)] if CONFIG.reload_templates() { - routes.append(&mut routes![static_files_dev]); + routes.append(&mut routes![_static_files_dev]); } routes @@ -68,30 +52,9 @@ fn not_found() -> ApiResult> { Ok(Html(text)) } -struct CssCache { - css: String, - etag: String, -} - -static CSS_CACHE: RwLock>> = RwLock::new(None); - -pub fn invalidate_css_cache() { - *CSS_CACHE.write().unwrap() = None; -} - #[get("/css/vaultwarden.css")] -fn vaultwarden_css() -> EtagCached { - // If reload_templates is false, and we already have the CSS Cached, return this - if !CONFIG.reload_templates() - && let Some(cached) = CSS_CACHE.read().unwrap().as_ref() - { - return EtagCached::new(cached.css.clone(), ContentType::CSS, &cached.etag); - } - - // Else, there is either no cache, or reload_templates is true and we need to rebuild the CSS +fn vaultwarden_css() -> Cached> { let css_options = json!({ - "custom_roles_enabled": custom_roles_enabled(), - "email_change_allowed": CONFIG.email_change_allowed(), "emergency_access_allowed": CONFIG.emergency_access_allowed(), "load_user_scss": true, "mail_2fa_enabled": CONFIG._enable_email_2fa(), @@ -138,18 +101,8 @@ fn vaultwarden_css() -> EtagCached { } }; - let etag = sha256_hex(css.as_bytes()); - let cached = Arc::new(CssCache { - css, - etag, - }); - - if !CONFIG.reload_templates() { - *CSS_CACHE.write().unwrap() = Some(Arc::clone(&cached)); - } - - // Etag Caching will let the browser send us an etag to verify and send new content if needed - EtagCached::new(cached.css.clone(), ContentType::CSS, &cached.etag) + // Cache for one day should be enough and not too much + Cached::ttl(Css(css), 86_400, false) } #[get("/")] @@ -207,24 +160,6 @@ fn app_id() -> Cached<(ContentType, Json)> { ) } -#[get("/.well-known/apple-app-site-association")] -fn apple_app_site_association() -> Cached<(ContentType, Json)> { - Cached::long( - ( - ContentType::JSON, - Json(json!({ - "webcredentials": { - "apps": [ - "LTZ2PFU5D6.com.8bit.bitwarden", - "LTZ2PFU5D6.com.8bit.bitwarden.beta" - ] - } - })), - ), - true, - ) -} - #[get("/", rank = 10)] // Only match this if the other routes don't match async fn web_files(p: PathBuf) -> Cached> { Cached::long(NamedFile::open(Path::new(&CONFIG.web_vault_folder()).join(p)).await.ok(), true) @@ -243,6 +178,7 @@ async fn attachments(cipher_id: CipherId, file_id: AttachmentId, token: String) } // We use DbConn here to let the alive healthcheck also verify the database connection. +use crate::db::DbConn; #[get("/alive")] fn alive(_conn: DbConn) -> Json { now() @@ -258,10 +194,10 @@ fn alive_head(_conn: DbConn) -> EmptyResult { // This endpoint/function is used during development and development only. // It allows to easily develop the admin interface by always loading the files from disk instead from a slice of bytes // This will only be active during a debug build and only when `RELOAD_TEMPLATES` is set to `true` -// NOTE: Do not forget to add any new files added to the `STATIC_FILES` HashMap below! +// NOTE: Do not forget to add any new files added to the `static_files` function below! #[cfg(debug_assertions)] #[get("/vw_static/", rank = 1)] -pub async fn static_files_dev(filename: PathBuf) -> Option { +pub async fn _static_files_dev(filename: PathBuf) -> Option { warn!("LOADING STATIC FILES FROM DISK"); let file = filename.to_str().unwrap_or_default(); let ext = filename.extension().unwrap_or_default(); @@ -274,65 +210,39 @@ pub async fn static_files_dev(filename: PathBuf) -> Option { if let Ok(path) = path { return NamedFile::open(path).await.ok(); - } + }; None } -pub struct StaticFile { - pub content_type: ContentType, - pub bytes: &'static [u8], - pub etag: String, -} - -pub static STATIC_FILES: LazyLock> = LazyLock::new(|| { - [ - ("404.png", ContentType::PNG, &include_bytes!("../static/images/404.png")[..]), - ("mail-github.png", ContentType::PNG, &include_bytes!("../static/images/mail-github.png")[..]), - ("logo-gray.png", ContentType::PNG, &include_bytes!("../static/images/logo-gray.png")[..]), - ("error-x.svg", ContentType::SVG, &include_bytes!("../static/images/error-x.svg")[..]), - ("hibp.png", ContentType::PNG, &include_bytes!("../static/images/hibp.png")[..]), - ("vaultwarden-icon.png", ContentType::PNG, &include_bytes!("../static/images/vaultwarden-icon.png")[..]), - ("vaultwarden-favicon.png", ContentType::PNG, &include_bytes!("../static/images/vaultwarden-favicon.png")[..]), - ("404.css", ContentType::CSS, &include_bytes!("../static/scripts/404.css")[..]), - ("admin.css", ContentType::CSS, &include_bytes!("../static/scripts/admin.css")[..]), - ("admin.js", ContentType::JavaScript, &include_bytes!("../static/scripts/admin.js")[..]), - ("admin_settings.js", ContentType::JavaScript, &include_bytes!("../static/scripts/admin_settings.js")[..]), - ("admin_users.js", ContentType::JavaScript, &include_bytes!("../static/scripts/admin_users.js")[..]), - ( - "admin_organizations.js", - ContentType::JavaScript, - &include_bytes!("../static/scripts/admin_organizations.js")[..], - ), - ( - "admin_diagnostics.js", - ContentType::JavaScript, - &include_bytes!("../static/scripts/admin_diagnostics.js")[..], - ), - ("bootstrap.css", ContentType::CSS, &include_bytes!("../static/scripts/bootstrap.css")[..]), - ("bootstrap.bundle.js", ContentType::JavaScript, &include_bytes!("../static/scripts/bootstrap.bundle.js")[..]), - ("jdenticon-3.3.0.js", ContentType::JavaScript, &include_bytes!("../static/scripts/jdenticon-3.3.0.js")[..]), - ("datatables.js", ContentType::JavaScript, &include_bytes!("../static/scripts/datatables.js")[..]), - ("datatables.css", ContentType::CSS, &include_bytes!("../static/scripts/datatables.css")[..]), - ] - .into_iter() - .map(|(name, content_type, bytes)| { - ( - name, - StaticFile { - content_type, - bytes, - etag: sha256_hex(bytes), - }, - ) - }) - .collect() -}); - #[get("/vw_static/", rank = 2)] -pub fn static_files(filename: &str) -> Result, Error> { - if let Some(file) = STATIC_FILES.get(filename) { - Ok(EtagCached::new(file.bytes, file.content_type.clone(), &file.etag)) - } else { - err!(format!("Static file not found: {}", filename.escape_debug())) +pub fn static_files(filename: &str) -> Result<(ContentType, &'static [u8]), Error> { + match filename { + "404.png" => Ok((ContentType::PNG, include_bytes!("../static/images/404.png"))), + "mail-github.png" => Ok((ContentType::PNG, include_bytes!("../static/images/mail-github.png"))), + "logo-gray.png" => Ok((ContentType::PNG, include_bytes!("../static/images/logo-gray.png"))), + "error-x.svg" => Ok((ContentType::SVG, include_bytes!("../static/images/error-x.svg"))), + "hibp.png" => Ok((ContentType::PNG, include_bytes!("../static/images/hibp.png"))), + "vaultwarden-icon.png" => Ok((ContentType::PNG, include_bytes!("../static/images/vaultwarden-icon.png"))), + "vaultwarden-favicon.png" => Ok((ContentType::PNG, include_bytes!("../static/images/vaultwarden-favicon.png"))), + "404.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/404.css"))), + "admin.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/admin.css"))), + "admin.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin.js"))), + "admin_settings.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_settings.js"))), + "admin_users.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_users.js"))), + "admin_organizations.js" => { + Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_organizations.js"))) + } + "admin_diagnostics.js" => { + Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_diagnostics.js"))) + } + "bootstrap.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/bootstrap.css"))), + "bootstrap.bundle.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/bootstrap.bundle.js"))), + "jdenticon-3.3.0.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/jdenticon-3.3.0.js"))), + "datatables.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/datatables.js"))), + "datatables.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/datatables.css"))), + "jquery-4.0.0.slim.js" => { + Ok((ContentType::JavaScript, include_bytes!("../static/scripts/jquery-4.0.0.slim.js"))) + } + _ => err!(format!("Static file not found: {filename}")), } } diff --git a/src/auth.rs b/src/auth.rs index ff858c0e..43184369 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,11 +1,3 @@ -#[path = "auth/two_factor.rs"] -pub mod two_factor; - -#[path = "auth/send.rs"] -pub mod send; -pub type SendTokens = send::SendTokens; -pub type SendHeaders = send::SendHeaders; - use std::{ env, net::IpAddr, @@ -13,32 +5,21 @@ use std::{ }; use chrono::{DateTime, TimeDelta, Utc}; -use ipnet::IpNet; -use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, errors::ErrorKind}; +use jsonwebtoken::{errors::ErrorKind, Algorithm, DecodingKey, EncodingKey, Header}; use num_traits::FromPrimitive; use openssl::rsa::Rsa; -use ring::hmac; -use serde::{de::DeserializeOwned, ser::Serialize}; - -use rocket::{ - outcome::try_outcome, - request::{FromRequest, Outcome, Request}, -}; +use serde::de::DeserializeOwned; +use serde::ser::Serialize; use crate::{ - CONFIG, - api::{ApiResult, core::log_event}, + api::ApiResult, config::PathType, - db::{ - DbConn, - models::{ - AttachmentId, CipherId, Collection, CollectionId, Device, DeviceId, DeviceType, EmergencyAccessId, - EventType, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, OrganizationId, - SendFileId, SendId, User, UserId, - }, + db::models::{ + AttachmentId, CipherId, CollectionId, DeviceId, DeviceType, EmergencyAccessId, MembershipId, OrgApiKeyId, + OrganizationId, SendFileId, SendId, UserId, }, error::Error, - sso, + sso, CONFIG, }; const JWT_ALGORITHM: Algorithm = Algorithm::RS256; @@ -69,15 +50,18 @@ static JWT_2FA_REMEMBER_ISSUER: LazyLock = LazyLock::new(|| format!("{}| static PRIVATE_RSA_KEY: OnceLock = OnceLock::new(); static PUBLIC_RSA_KEY: OnceLock = OnceLock::new(); -static PRELOGIN_KDF_KEY: OnceLock = OnceLock::new(); pub async fn initialize_keys() -> Result<(), Error> { - use std::io::Error as IoError; + use std::io::Error; - let rsa_key_filename = crate::storage::file_name(&CONFIG.private_rsa_key()) - .ok_or_else(|| IoError::other("Private RSA key path missing filename"))?; + let rsa_key_filename = std::path::PathBuf::from(CONFIG.private_rsa_key()) + .file_name() + .ok_or_else(|| Error::other("Private RSA key path missing filename"))? + .to_str() + .ok_or_else(|| Error::other("Private RSA key path filename is not valid UTF-8"))? + .to_string(); - let operator = CONFIG.opendal_operator_for_path_type(&PathType::RsaKey).map_err(IoError::other)?; + let operator = CONFIG.opendal_operator_for_path_type(&PathType::RsaKey).map_err(Error::other)?; let priv_key_buffer = match operator.read(&rsa_key_filename).await { Ok(buffer) => Some(buffer), @@ -104,22 +88,9 @@ pub async fn initialize_keys() -> Result<(), Error> { if PUBLIC_RSA_KEY.set(dec).is_err() { err!("PUBLIC_RSA_KEY must only be initialized once") } - - // Derived from the RSA key, so it stays the same across restarts without storing another secret - let prelogin_kdf_key = hmac::sign(&hmac::Key::new(hmac::HMAC_SHA256, &priv_key_buffer), b"prelogin-kdf"); - if PRELOGIN_KDF_KEY.set(hmac::Key::new(hmac::HMAC_SHA256, prelogin_kdf_key.as_ref())).is_err() { - err!("PRELOGIN_KDF_KEY must only be initialized once") - } Ok(()) } -/// Returns an index in `0..len` derived from the email, the same for every request -pub fn prelogin_kdf_index(email: &str, len: usize) -> usize { - let tag = hmac::sign(PRELOGIN_KDF_KEY.wait(), email.as_bytes()); - let tag = tag.as_ref(); - usize::from(u16::from_be_bytes([tag[0], tag[1]])) % len -} - pub fn encode_jwt(claims: &T) -> String { match jsonwebtoken::encode(&JWT_HEADER, claims, PRIVATE_RSA_KEY.wait()) { Ok(token) => token, @@ -259,7 +230,7 @@ impl LoginJwtClaims { // let orgmanager: Vec<_> = orgs.iter().filter(|o| o.atype == 3).map(|o| o.org_uuid.clone()).collect(); if exp <= (now + *BW_EXPIRATION).timestamp() { - warn!("Raise access_token lifetime to more than 5min."); + warn!("Raise access_token lifetime to more than 5min.") } // Create the JWT claims struct, to send to the client @@ -271,7 +242,7 @@ impl LoginJwtClaims { premium: true, name: user.name.clone(), email: user.email.clone(), - email_verified: user.verified(), + email_verified: !CONFIG.mail_enabled() || user.verified_at.is_some(), // --- // Disabled these keys to be added to the JWT since they could cause the JWT to get too large @@ -286,7 +257,7 @@ impl LoginJwtClaims { sstamp: user.security_stamp.clone(), device: device.uuid.clone(), devicetype: DeviceType::from_i32(device.atype).to_string(), - client_id: client_id.unwrap_or("undefined".to_owned()), + client_id: client_id.unwrap_or("undefined".to_string()), scope, amr: vec!["Application".into()], } @@ -314,17 +285,6 @@ impl LoginJwtClaims { } } -fn calculate_invite_expire_hours(now: &DateTime) -> Result, Error> { - let expire_hours = i64::from(CONFIG.invitation_expiration_hours()); - let Some(hours_delta) = TimeDelta::try_hours(expire_hours) else { - err!("Unable to calculate time delta for invite expire hours. Check `INVITATION_EXPIRATION_HOURS`."); - }; - let Some(exp) = now.checked_add_signed(hours_delta) else { - err!("Unable to calculate invitation expire date. Check `INVITATION_EXPIRATION_HOURS`."); - }; - Ok(exp) -} - #[derive(Debug, Serialize, Deserialize)] pub struct InviteJwtClaims { // Not before @@ -348,19 +308,19 @@ pub fn generate_invite_claims( org_id: OrganizationId, member_id: MembershipId, invited_by_email: Option, -) -> Result { +) -> InviteJwtClaims { let time_now = Utc::now(); - let exp = calculate_invite_expire_hours(&time_now)?; - Ok(InviteJwtClaims { + let expire_hours = i64::from(CONFIG.invitation_expiration_hours()); + InviteJwtClaims { nbf: time_now.timestamp(), - exp: exp.timestamp(), + exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(), iss: JWT_INVITE_ISSUER.to_string(), sub: user_id, email, org_id, member_id, invited_by_email, - }) + } } #[derive(Debug, Serialize, Deserialize)] @@ -386,19 +346,19 @@ pub fn generate_emergency_access_invite_claims( emer_id: EmergencyAccessId, grantor_name: String, grantor_email: String, -) -> Result { +) -> EmergencyAccessInviteJwtClaims { let time_now = Utc::now(); - let exp = calculate_invite_expire_hours(&time_now)?; - Ok(EmergencyAccessInviteJwtClaims { + let expire_hours = i64::from(CONFIG.invitation_expiration_hours()); + EmergencyAccessInviteJwtClaims { nbf: time_now.timestamp(), - exp: exp.timestamp(), + exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(), iss: JWT_EMERGENCY_ACCESS_INVITE_ISSUER.to_string(), sub: user_id, email, emer_id, grantor_name, grantor_email, - }) + } } #[derive(Debug, Serialize, Deserialize)] @@ -451,7 +411,7 @@ pub fn generate_file_download_claims(cipher_id: CipherId, file_id: AttachmentId) let time_now = Utc::now(); FileDownloadClaims { nbf: time_now.timestamp(), - exp: (time_now + TimeDelta::try_minutes(1).unwrap()).timestamp(), + exp: (time_now + TimeDelta::try_minutes(5).unwrap()).timestamp(), iss: JWT_FILE_DOWNLOAD_ISSUER.to_string(), sub: cipher_id, file_id, @@ -503,17 +463,13 @@ pub fn generate_2fa_remember_claims(device_uuid: DeviceId, user_uuid: UserId) -> let time_now = Utc::now(); TwoFactorRememberClaims { nbf: time_now.timestamp(), - exp: two_factor_remember_expiration(time_now, CONFIG.two_factor_remember_days()), + exp: (time_now + TimeDelta::try_days(30).unwrap()).timestamp(), iss: JWT_2FA_REMEMBER_ISSUER.to_string(), sub: device_uuid, user_uuid, } } -fn two_factor_remember_expiration(time_now: DateTime, remember_days: i64) -> i64 { - (time_now + TimeDelta::try_days(remember_days).expect("TWO_FACTOR_REMEMBER_DAYS is validated")).timestamp() -} - #[derive(Debug, Serialize, Deserialize)] pub struct BasicJwtClaims { // Not before @@ -526,53 +482,36 @@ pub struct BasicJwtClaims { pub sub: String, } -impl BasicJwtClaims { - pub fn expires_in(&self) -> i64 { - self.exp - Utc::now().timestamp() - } - - pub fn token(&self) -> String { - encode_jwt(&self) - } -} - -pub fn generate_delete_claims(uuid: String) -> Result { +pub fn generate_delete_claims(uuid: String) -> BasicJwtClaims { let time_now = Utc::now(); - let exp = calculate_invite_expire_hours(&time_now)?; - Ok(BasicJwtClaims { + let expire_hours = i64::from(CONFIG.invitation_expiration_hours()); + BasicJwtClaims { nbf: time_now.timestamp(), - exp: exp.timestamp(), + exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(), iss: JWT_DELETE_ISSUER.to_string(), sub: uuid, - }) + } } -pub fn generate_verify_email_claims(user_id: &UserId) -> Result { +pub fn generate_verify_email_claims(user_id: &UserId) -> BasicJwtClaims { let time_now = Utc::now(); - let exp = calculate_invite_expire_hours(&time_now)?; - Ok(BasicJwtClaims { + let expire_hours = i64::from(CONFIG.invitation_expiration_hours()); + BasicJwtClaims { nbf: time_now.timestamp(), - exp: exp.timestamp(), + exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(), iss: JWT_VERIFYEMAIL_ISSUER.to_string(), sub: user_id.to_string(), - }) + } } -pub fn generate_admin_claims() -> Result { +pub fn generate_admin_claims() -> BasicJwtClaims { let time_now = Utc::now(); - let lifetime_minutes = i64::from(CONFIG.admin_session_lifetime()); - let Some(minutes_delta) = TimeDelta::try_minutes(lifetime_minutes) else { - err!("Unable to calculate time delta for admin session lifetime. Check `ADMIN_SESSION_LIFETIME`."); - }; - let Some(exp) = time_now.checked_add_signed(minutes_delta) else { - err!("Unable to calculate admin session lifetime. Check `ADMIN_SESSION_LIFETIME`."); - }; - Ok(BasicJwtClaims { + BasicJwtClaims { nbf: time_now.timestamp(), - exp: exp.timestamp(), + exp: (time_now + TimeDelta::try_minutes(CONFIG.admin_session_lifetime()).unwrap()).timestamp(), iss: JWT_ADMIN_ISSUER.to_string(), - sub: "admin_panel".to_owned(), - }) + sub: "admin_panel".to_string(), + } } pub fn generate_send_claims(send_id: &SendId, file_id: &SendFileId) -> BasicJwtClaims { @@ -588,6 +527,16 @@ pub fn generate_send_claims(send_id: &SendId, file_id: &SendFileId) -> BasicJwtC // // Bearer token authentication // +use rocket::{ + outcome::try_outcome, + request::{FromRequest, Outcome, Request}, +}; + +use crate::db::{ + models::{Collection, Device, Membership, MembershipStatus, MembershipType, User, UserStampException}, + DbConn, +}; + pub struct Host { pub host: String, } @@ -603,7 +552,7 @@ impl<'r> FromRequest<'r> for Host { let host = if CONFIG.domain_set() { CONFIG.domain() } else if let Some(referer) = headers.get_one("Referer") { - referer.to_owned() + referer.to_string() } else { // Try to guess from the headers let protocol = if let Some(proto) = headers.get_one("X-Forwarded-Proto") { @@ -632,9 +581,6 @@ impl<'r> FromRequest<'r> for Host { pub struct ClientHeaders { pub device_type: i32, pub ip: ClientIp, - /// `Bitwarden-Client-Version`, `None` when missing or not a version. Unlike the [`ClientVersion`] guard, this - /// doesn't log an error, as not every client sends it. - pub client_version: Option, } #[rocket::async_trait] @@ -642,22 +588,17 @@ impl<'r> FromRequest<'r> for ClientHeaders { type Error = &'static str; async fn from_request(request: &'r Request<'_>) -> Outcome { - let Outcome::Success(ip) = ClientIp::from_request(request).await else { - err_handler!("Error getting Client IP") + let ip = match ClientIp::from_request(request).await { + Outcome::Success(ip) => ip, + _ => err_handler!("Error getting Client IP"), }; - // When unknown or unable to parse, return 'UnknownBrowser' - let device_type: i32 = request - .headers() - .get_one("device-type") - .and_then(|d| d.parse().ok()) - .unwrap_or(DeviceType::UnknownBrowser as i32); - let client_version = - request.headers().get_one("Bitwarden-Client-Version").and_then(|v| semver::Version::parse(v).ok()); + // When unknown or unable to parse, return 14, which is 'Unknown Browser' + let device_type: i32 = + request.headers().get_one("device-type").map(|d| d.parse().unwrap_or(14)).unwrap_or_else(|| 14); Outcome::Success(ClientHeaders { device_type, ip, - client_version, }) } } @@ -677,19 +618,18 @@ impl<'r> FromRequest<'r> for Headers { let headers = request.headers(); let host = try_outcome!(Host::from_request(request).await).host; - let Outcome::Success(ip) = ClientIp::from_request(request).await else { - err_handler!("Error getting Client IP") + let ip = match ClientIp::from_request(request).await { + Outcome::Success(ip) => ip, + _ => err_handler!("Error getting Client IP"), }; // Get access_token - let access_token: &str = if let Some(a) = headers.get_one("Authorization") { - if let Some(split) = a.rsplit("Bearer ").next() { - split - } else { - err_handler!("No access token provided") - } - } else { - err_handler!("No access token provided") + let access_token: &str = match headers.get_one("Authorization") { + Some(a) => match a.rsplit("Bearer ").next() { + Some(split) => split, + None => err_handler!("No access token provided"), + }, + None => err_handler!("No access token provided"), }; // Check JWT token is valid and get device and user from it @@ -700,8 +640,9 @@ impl<'r> FromRequest<'r> for Headers { let device_id = claims.device; let user_id = claims.sub; - let Outcome::Success(conn) = DbConn::from_request(request).await else { - err_handler!("Error getting DB") + let conn = match DbConn::from_request(request).await { + Outcome::Success(conn) => conn, + _ => err_handler!("Error getting DB"), }; let Some(device) = Device::find_by_uuid_and_user(&device_id, &user_id, &conn).await else { @@ -713,12 +654,31 @@ impl<'r> FromRequest<'r> for Headers { }; if user.security_stamp != claims.sstamp { - let Some(current_route) = request.route().and_then(|r| r.name.as_deref()) else { - err_handler!("Error getting current route for stamp exception") - }; + if let Some(stamp_exception) = + user.stamp_exception.as_deref().and_then(|s| serde_json::from_str::(s).ok()) + { + let Some(current_route) = request.route().and_then(|r| r.name.as_deref()) else { + err_handler!("Error getting current route for stamp exception") + }; - // Expired exceptions are only dropped on reset; writing them back here could undo a newer reset - if !user.stamp_exceptions().iter().any(|e| e.allows(&claims.sstamp, current_route)) { + // Check if the stamp exception has expired first. + // Then, check if the current route matches any of the allowed routes. + // After that check the stamp in exception matches the one in the claims. + if Utc::now().timestamp() > stamp_exception.expire { + // If the stamp exception has been expired remove it from the database. + // This prevents checking this stamp exception for new requests. + let mut user = user; + user.reset_stamp_exception(); + if let Err(e) = user.save(&conn).await { + error!("Error updating user: {e:#?}"); + } + err_handler!("Stamp exception is expired") + } else if !stamp_exception.routes.contains(¤t_route.to_string()) { + err_handler!("Invalid security stamp: Current route and exception route do not match") + } else if stamp_exception.security_stamp != claims.sstamp { + err_handler!("Invalid security stamp for matched stamp exception") + } + } else { err_handler!("Invalid security stamp") } } @@ -801,8 +761,9 @@ impl<'r> FromRequest<'r> for OrgHeaders { match url_org_id { Some(org_id) if uuid::Uuid::parse_str(&org_id).is_ok() => { - let Outcome::Success(conn) = DbConn::from_request(request).await else { - err_handler!("Error getting DB") + let conn = match DbConn::from_request(request).await { + Outcome::Success(conn) => conn, + _ => err_handler!("Error getting DB"), }; let user = headers.user; @@ -849,12 +810,6 @@ pub struct AdminHeaders { pub org_id: OrganizationId, } -impl AdminHeaders { - pub async fn log_event(&self, event_type: EventType, source_uuid: &str, org_id: &OrganizationId, conn: &DbConn) { - log_event(event_type, source_uuid, org_id, &self.user.uuid, self.device.atype, &self.ip.ip, conn).await; - } -} - #[rocket::async_trait] impl<'r> FromRequest<'r> for AdminHeaders { type Error = &'static str; @@ -880,16 +835,16 @@ impl<'r> FromRequest<'r> for AdminHeaders { // but there could be cases where it is a query value. // First check the path, if this is not a valid uuid, try the query values. fn get_col_id(request: &Request<'_>) -> Option { - if let Some(Ok(col_id)) = request.param::(3) - && uuid::Uuid::parse_str(&col_id).is_ok() - { - return Some(col_id.into()); + if let Some(Ok(col_id)) = request.param::(3) { + if uuid::Uuid::parse_str(&col_id).is_ok() { + return Some(col_id.into()); + } } - if let Some(Ok(col_id)) = request.query_value::("collectionId") - && uuid::Uuid::parse_str(&col_id).is_ok() - { - return Some(col_id.into()); + if let Some(Ok(col_id)) = request.query_value::("collectionId") { + if uuid::Uuid::parse_str(&col_id).is_ok() { + return Some(col_id.into()); + } } None @@ -913,16 +868,18 @@ impl<'r> FromRequest<'r> for ManagerHeaders { async fn from_request(request: &'r Request<'_>) -> Outcome { let headers = try_outcome!(OrgHeaders::from_request(request).await); if headers.is_confirmed_and_manager() { - if let Some(col_id) = get_col_id(request) { - let Outcome::Success(conn) = DbConn::from_request(request).await else { - err_handler!("Error getting DB") - }; + match get_col_id(request) { + Some(col_id) => { + let conn = match DbConn::from_request(request).await { + Outcome::Success(conn) => conn, + _ => err_handler!("Error getting DB"), + }; - if !Collection::is_coll_manageable_by_user(&col_id, &headers.membership.user_uuid, &conn).await { - err_handler!("The current user isn't a manager for this collection") + if !Collection::is_coll_manageable_by_user(&col_id, &headers.membership.user_uuid, &conn).await { + err_handler!("The current user isn't a manager for this collection") + } } - } else { - err_handler!("Error getting the collection id") + _ => err_handler!("Error getting the collection id"), } Outcome::Success(Self { @@ -1083,85 +1040,31 @@ impl From for Headers { // // Client IP address detection // -#[derive(Copy, Clone)] + pub struct ClientIp { pub ip: IpAddr, } -/// Parses a single entry of `ip_header_trusted_proxies`, which can be a CIDR range or a plain IP. -pub fn parse_trusted_proxy(entry: &str) -> Option { - let entry = entry.trim(); - match entry.parse::() { - Ok(net) => Some(net), - // Without a prefix length it is a single address, which is a valid way to write this. - Err(_) => entry.parse::().ok().map(IpNet::from), - } -} - -/// The client IP header can be set by anyone able to reach us, so only accept it from a proxy we trust. -fn ip_header_is_trusted(remote: Option) -> bool { - let trusted = CONFIG.ip_header_trusted_proxies(); - let trusted = trusted.trim(); - if trusted.eq_ignore_ascii_case("all") { - return true; - } - - let Some(remote) = remote else { - return false; - }; - // A dual stack listener reports IPv4 clients as IPv4-mapped IPv6, which `is_global()` reports as - // non global. That is what we want when blocking outgoing requests, but here it would trust them. - let remote = remote.to_canonical(); - if trusted.eq_ignore_ascii_case("local") { - return !crate::util::is_global(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 { - 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 { - let entries: Vec<&str> = values.iter().flat_map(|value| value.split(',')).map(str::trim).collect(); - - let mut client_ip = None; - for entry in entries.into_iter().rev() { - let Ok(ip) = entry.parse() else { - warn!("'{header}' header is malformed: {entry}"); - break; - }; - client_ip = Some(ip); - if !is_trusted(ip) { - break; - } - } - client_ip -} - #[rocket::async_trait] impl<'r> FromRequest<'r> for ClientIp { type Error = (); async fn from_request(req: &'r Request<'_>) -> Outcome { - let remote = req.remote().map(|r| r.ip()); - - let ip = if CONFIG._ip_header_enabled() && ip_header_is_trusted(remote) { - client_ip_from_header(req, &CONFIG.ip_header(), |ip| ip_header_is_trusted(Some(ip))) + let ip = if CONFIG._ip_header_enabled() { + req.headers().get_one(&CONFIG.ip_header()).and_then(|ip| { + match ip.find(',') { + Some(idx) => &ip[..idx], + None => ip, + } + .parse() + .map_err(|_| warn!("'{}' header is malformed: {ip}", CONFIG.ip_header())) + .ok() + }) } else { - if CONFIG._ip_header_enabled() && req.headers().get_one(&CONFIG.ip_header()).is_some() { - // Log the canonical IP, which is what the user filter will need to match against - let remote = remote.map(|ip| ip.to_canonical()); - debug!("Ignoring the '{}' header, {remote:?} is not a trusted proxy", CONFIG.ip_header()); - } None }; - let ip = ip.or(remote).unwrap_or_else(|| "0.0.0.0".parse().unwrap()); + let ip = ip.or_else(|| req.remote().map(|r| r.ip())).unwrap_or_else(|| "0.0.0.0".parse().unwrap()); Outcome::Success(ClientIp { ip, @@ -1169,7 +1072,6 @@ impl<'r> FromRequest<'r> for ClientIp { } } -#[derive(Copy, Clone)] pub struct Secure { pub https: bool, } @@ -1255,14 +1157,15 @@ pub enum AuthMethod { impl AuthMethod { pub fn scope(&self) -> String { match self { - AuthMethod::OrgApiKey => "api.organization".to_owned(), - AuthMethod::UserApiKey => "api".to_owned(), - AuthMethod::Password | AuthMethod::Sso => "api offline_access".to_owned(), + AuthMethod::OrgApiKey => "api.organization".to_string(), + AuthMethod::Password => "api offline_access".to_string(), + AuthMethod::Sso => "api offline_access".to_string(), + AuthMethod::UserApiKey => "api".to_string(), } } pub fn scope_vec(&self) -> Vec { - self.scope().split_whitespace().map(str::to_owned).collect() + self.scope().split_whitespace().map(str::to_string).collect() } pub fn check_scope(&self, scope: Option<&String>) -> ApiResult { @@ -1270,7 +1173,7 @@ impl AuthMethod { match scope { None => err!("Missing scope"), Some(scope) if scope == &method_scope => Ok(method_scope), - Some(scope) => err!(format!("Scope ({}) not supported", scope.escape_debug())), + Some(scope) => err!(format!("Scope ({scope}) not supported")), } } } @@ -1356,22 +1259,36 @@ pub async fn refresh_tokens( ) -> ApiResult<(Device, AuthTokens)> { let refresh_claims = match decode_refresh(refresh_token) { Err(err) => { - error!("Failed to decode refresh_token from {}: {err:?}", ip.ip); - err_silent!("Invalid refresh token") + error!("Failed to decode {} refresh_token: {refresh_token}: {err:?}", ip.ip); + //err_silent!(format!("Impossible to read refresh_token: {}", err.message())) + + // If the token failed to decode, it was probably one of the old style tokens that was just a Base64 string. + // We can generate a claim for them for backwards compatibility. Note that the password refresh claims don't + // check expiration or issuer, so they're not included here. + RefreshJwtClaims { + nbf: 0, + exp: 0, + iss: String::new(), + sub: AuthMethod::Password, + device_token: refresh_token.into(), + token: None, + } } Ok(claims) => claims, }; // Get device by refresh token - let Some(mut device) = Device::find_by_refresh_token(&refresh_claims.device_token, conn).await else { - err!("Invalid refresh token") + let mut device = match Device::find_by_refresh_token(&refresh_claims.device_token, conn).await { + None => err!("Invalid refresh token"), + Some(device) => device, }; // Save to update `updated_at`. device.save(true, conn).await?; - let Some(user) = User::find_by_uuid(&device.user_uuid, conn).await else { - err!("Impossible to find user") + let user = match User::find_by_uuid(&device.user_uuid, conn).await { + None => err!("Impossible to find user"), + Some(user) => user, }; let auth_tokens = match refresh_claims.sub { @@ -1389,52 +1306,3 @@ pub async fn refresh_tokens( Ok((device, auth_tokens)) } - -#[cfg(test)] -mod tests { - use super::*; - - fn client_ip(values: &[&str]) -> Option { - 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 { - 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")); - } -} diff --git a/src/auth/send.rs b/src/auth/send.rs deleted file mode 100644 index 85b91d99..00000000 --- a/src/auth/send.rs +++ /dev/null @@ -1,147 +0,0 @@ -use chrono::{TimeDelta, Utc}; - -use rocket::request::{FromRequest, Outcome, Request}; - -use crate::{ - api::ApiResult, - auth, - auth::{BasicJwtClaims, ClientIp}, - db::{ - DbConn, - models::{Send, SendId}, - }, - error::{Error, ErrorKind}, -}; - -fn generate_send_access_claims(send_id: &SendId) -> BasicJwtClaims { - let time_now = Utc::now(); - BasicJwtClaims { - nbf: time_now.timestamp(), - exp: (time_now + TimeDelta::try_minutes(2).unwrap()).timestamp(), - iss: auth::JWT_SEND_ISSUER.to_string(), - sub: format!("{send_id}"), - } -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct SendTokens { - pub access_claims: BasicJwtClaims, -} - -impl SendTokens { - pub fn as_send_id(access_id: &str) -> Option { - data_encoding::BASE64URL_NOPAD - .decode(access_id.as_bytes()) - .ok() - .and_then(|uuid_vec| uuid::Uuid::from_slice(&uuid_vec).ok().map(|u| SendId::from(u.to_string()))) - } - - pub fn to_json(&self) -> serde_json::Value { - json!({ - "access_token": self.access_claims.token(), - "expires_in": self.access_claims.expires_in(), - "token_type": "Bearer", - "scope": "api.send.access", - }) - } - - fn expected_error(msg: &str, error_type: &str) -> ApiResult { - let err = json!({ - "kind": "expected_server", - "error": "invalid_request", - "send_access_error_type": error_type, - }); - - Err(Error::new_msg(msg).with_kind(ErrorKind::Json(err)).silent()) - } - - fn invalid_error(msg: &str, error_type: &str, silent: bool) -> ApiResult { - let err = json!({ - "kind": "expected_server", - "error": "invalid_grant", - "send_access_error_type": error_type, - }); - - Err(Error::new_msg(msg).with_kind(ErrorKind::Json(err)).with_code(404).with_silent(silent)) - } - - pub async fn generate_tokens( - access_id: &str, - password: Option, - ip: &ClientIp, - conn: &DbConn, - ) -> ApiResult { - let Some(send_id) = Self::as_send_id(access_id) else { - return Self::invalid_error( - &format!("Can't convert {}", access_id.escape_debug()), - "send_id_invalid", - false, - ); - }; - - 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); - }; - - if let Some(max_access_count) = send.max_access_count - && send.access_count >= max_access_count - { - return Self::invalid_error(&format!("Send {send_id}, max access reached"), "send_id_invalid", true); - } - - if !send.is_accessible() { - return Self::invalid_error(&format!("Send {send_id}, not accessible"), "send_id_invalid", true); - } - - if send.password_hash.is_some() { - match password { - Some(ref p) if send.check_password(p) => { /* Nothing to do here */ } - Some(_) => { - return Self::invalid_error( - &format!("Send {send_id}, Invalid password from {}", ip.ip), - "password_hash_b64_invalid", - false, - ); - } - None => return Self::expected_error("Password required", "password_hash_b64_required"), - } - } - - Ok(Self { - access_claims: generate_send_access_claims(&send_id), - }) - } -} - -pub struct SendHeaders { - pub send_id: SendId, -} - -#[rocket::async_trait] -impl<'r> FromRequest<'r> for SendHeaders { - type Error = &'static str; - - async fn from_request(request: &'r Request<'_>) -> Outcome { - let headers = request.headers(); - - // Get access_token - let access_token: &str = if let Some(a) = headers.get_one("Authorization") { - if let Some(split) = a.rsplit("Bearer ").next() { - split - } else { - err_handler!("No access token provided") - } - } else { - err_handler!("No access token provided") - }; - - // Check JWT token is valid and get send_id - let Ok(claims) = auth::decode_send(access_token) else { - err_handler!("Invalid claim") - }; - - Outcome::Success(SendHeaders { - send_id: claims.sub.into(), - }) - } -} diff --git a/src/auth/two_factor.rs b/src/auth/two_factor.rs deleted file mode 100644 index 533c0cf4..00000000 --- a/src/auth/two_factor.rs +++ /dev/null @@ -1,281 +0,0 @@ -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 = LazyLock::new(|| format!("{}|api.2fa", CONFIG.domain_origin())); - -#[derive(Serialize, Deserialize)] -pub struct TwoFactorClaims { - // 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, -} - -#[derive(Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct WebauthnClaims { - #[serde(rename = "webauthn_keys")] - pub keys: Vec, -} - -#[derive(Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct YubikeyClaims { - #[serde(rename = "yubi_keys")] - pub keys: Vec, -} - -#[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 { - 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("") - } - 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, -} - -fn token(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(token: &str, user_id: &UserId, enabled: bool) -> ApiResult { - match decode_jwt::>(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::(token, user_id, enabled)?; - if claims.key != key { - err!("Invalid verification token: Invalid key"); - } - Ok(()) -} - -pub fn duo_token(user_id: UserId, data: Option, 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::(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, 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::(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, 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::(token, user_id, enabled)?; - if keys != claims.keys { - err!("Invalid verification token: Invalid keys"); - } - Ok(()) -} - -pub fn yubikey_token(user_id: UserId, keys: Vec, enabled: bool) -> String { - token( - user_id, - enabled, - YubikeyClaims { - keys, - }, - ) -} - -pub fn validate_yubikey(token: &str, user_id: &UserId, keys: &Vec, enabled: bool) -> EmptyResult { - let claims = validate::(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(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::>(token.clone()).is_ok(), - from_value::>(token.clone()).is_ok(), - from_value::>(token.clone()).is_ok(), - from_value::>(token.clone()).is_ok(), - from_value::>(token.clone()).is_ok(), - ]; - for (checked, ok) in parsed.into_iter().enumerate() { - assert_eq!(ok, issued == checked, "token {issued} parsed as {checked}"); - } - } - } -} diff --git a/src/config.rs b/src/config.rs index 3f04b8d7..6ff09467 100644 --- a/src/config.rs +++ b/src/config.rs @@ -3,8 +3,8 @@ use std::{ fmt, process::exit, sync::{ - LazyLock, RwLock, atomic::{AtomicBool, Ordering}, + LazyLock, RwLock, }, }; @@ -14,23 +14,26 @@ use serde::de::{self, Deserialize, Deserializer, MapAccess, Visitor}; use crate::{ error::Error, - storage, util::{ - FeatureFlagFilter, get_active_web_release, get_env, get_env_bool, is_valid_email, - parse_experimental_client_feature_flags, + get_active_web_release, get_env, get_env_bool, is_valid_email, parse_experimental_client_feature_flags, + FeatureFlagFilter, }, }; static CONFIG_FILE: LazyLock = LazyLock::new(|| { let data_folder = get_env("DATA_FOLDER").unwrap_or_else(|| String::from("data")); - get_env("CONFIG_FILE").unwrap_or_else(|| storage::join_path(&data_folder, "config.json")) + get_env("CONFIG_FILE").unwrap_or_else(|| format!("{data_folder}/config.json")) }); -static CONFIG_FILE_PARENT_DIR: LazyLock = - LazyLock::new(|| storage::parent(&CONFIG_FILE).unwrap_or_else(|| "data".to_owned())); +static CONFIG_FILE_PARENT_DIR: LazyLock = LazyLock::new(|| { + let path = std::path::PathBuf::from(&*CONFIG_FILE); + path.parent().unwrap_or(std::path::Path::new("data")).to_str().unwrap_or("data").to_string() +}); -static CONFIG_FILENAME: LazyLock = - LazyLock::new(|| storage::file_name(&CONFIG_FILE).unwrap_or_else(|| "config.json".to_owned())); +static CONFIG_FILENAME: LazyLock = LazyLock::new(|| { + let path = std::path::PathBuf::from(&*CONFIG_FILE); + path.file_name().unwrap_or(std::ffi::OsStr::new("config.json")).to_str().unwrap_or("config.json").to_string() +}); pub static SKIP_CONFIG_VALIDATION: AtomicBool = AtomicBool::new(false); @@ -111,7 +114,6 @@ macro_rules! make_config { _usr: ConfigBuilder, _overrides: Vec<&'static str>, - _config_file_status: ConfigFileStatus, } // Custom Deserialize for ConfigBuilder, mainly based upon https://serde.rs/deserialize-struct.html @@ -260,22 +262,11 @@ macro_rules! make_config { builder } - async fn from_file() -> Result, Error> { - // The Fs operator would create the folder if it doesn't exist, which we never want - if !CONFIG_FILE_PARENT_DIR.starts_with("s3://") && !std::path::Path::new(&*CONFIG_FILE_PARENT_DIR).is_dir() { - return Ok(None); - } - let operator = storage::operator_for_path(&CONFIG_FILE_PARENT_DIR)?; - let config_bytes = match operator.read(&CONFIG_FILENAME).await { - Ok(bytes) => bytes, - // No config file is fine, should not return any error - Err(e) if e.kind() == opendal::ErrorKind::NotFound => return Ok(None), - // Any other error might be an issue and should be passed back to the caller - Err(e) => return Err(e.into()), - }; - let builder = serde_json::from_slice(&config_bytes.to_vec())?; + async fn from_file() -> Result { + let operator = opendal_operator_for_path(&CONFIG_FILE_PARENT_DIR)?; + let config_bytes = operator.read(&CONFIG_FILENAME).await?; println!("[INFO] Using saved config from `{}` for configuration.\n", *CONFIG_FILE); - Ok(Some(builder)) + serde_json::from_slice(&config_bytes.to_vec()).map_err(Into::into) } fn clear_non_editable(&mut self) { @@ -372,7 +363,13 @@ macro_rules! make_config { )+)+ pub fn prepare_json(&self) -> serde_json::Value { - fn get_form_type(rust_type: &'static str) -> &'static str { + let (def, cfg, overridden) = { + // Lock the inner as short as possible and clone what is needed to prevent deadlocks + let inner = &self.inner.read().unwrap(); + (inner._env.build(), inner.config.clone(), inner._overrides.clone()) + }; + + fn _get_form_type(rust_type: &'static str) -> &'static str { match rust_type { "Pass" => "password", "String" => "text", @@ -381,7 +378,7 @@ macro_rules! make_config { } } - fn get_doc(doc_str: &'static str) -> ElementDoc { + fn _get_doc(doc_str: &'static str) -> ElementDoc { let mut split = doc_str.split("|>").map(str::trim); ElementDoc { name: split.next().unwrap_or_default(), @@ -389,12 +386,6 @@ macro_rules! make_config { } } - let (def, cfg, overridden) = { - // Lock the inner as short as possible and clone what is needed to prevent deadlocks - let inner = &self.inner.read().unwrap(); - (inner._env.build(), inner.config.clone(), inner._overrides.clone()) - }; - let data: Vec = vec![ $( // This repetition is for each group GroupData { @@ -409,8 +400,8 @@ macro_rules! make_config { name: stringify!($name), value: serde_json::to_value(&cfg.$name).unwrap_or_default(), default: serde_json::to_value(&def.$name).unwrap_or_default(), - r#type: get_form_type(stringify!($ty)), - doc: get_doc(concat!($($doc),+)), + r#type: _get_form_type(stringify!($ty)), + doc: _get_doc(concat!($($doc),+)), overridden: overridden.contains(&pastey::paste!(stringify!([<$name:upper>]))), }, )+], // End of elements repetition @@ -420,9 +411,38 @@ macro_rules! make_config { } pub fn get_support_json(&self) -> serde_json::Value { + // Define which config keys need to be masked. + // Pass types will always be masked and no need to put them in the list. + // Besides Pass, only String types will be masked via _privacy_mask. + const PRIVACY_CONFIG: &[&str] = &[ + "allowed_connect_src", + "allowed_iframe_ancestors", + "database_url", + "domain_origin", + "domain_path", + "domain", + "helo_name", + "org_creation_users", + "signups_domains_whitelist", + "_smtp_img_src", + "smtp_from_name", + "smtp_from", + "smtp_host", + "smtp_username", + "sso_authority", + "sso_callback_path", + "sso_client_id", + ]; + + let cfg = { + // Lock the inner as short as possible and clone what is needed to prevent deadlocks + let inner = &self.inner.read().unwrap(); + inner.config.clone() + }; + /// We map over the string and remove all alphanumeric, _ and - characters. /// This is the fastest way (within micro-seconds) instead of using a regex (which takes mili-seconds) - fn privacy_mask(value: &str) -> String { + fn _privacy_mask(value: &str) -> String { let mut n: u16 = 0; let mut colon_match = false; value @@ -442,39 +462,6 @@ macro_rules! make_config { .collect::() } - // Define which config keys need to be masked. - // Pass types will always be masked and no need to put them in the list. - // Besides Pass, only String types will be masked via privacy_mask. - const PRIVACY_CONFIG: &[&str] = &[ - "allowed_connect_src", - "allowed_iframe_ancestors", - "database_url", - "domain_origin", - "domain_path", - "domain", - "duo_ikey", - "duo_host", - "helo_name", - "org_creation_users", - "signups_domains_whitelist", - "_smtp_img_src", - "smtp_from_name", - "smtp_from", - "smtp_host", - "smtp_username", - "sso_authority", - "sso_callback_path", - "sso_client_id", - "yubico_client_id", - "yubico_server", - ]; - - let cfg = { - // Lock the inner as short as possible and clone what is needed to prevent deadlocks - let inner = &self.inner.read().unwrap(); - inner.config.clone() - }; - serde_json::Value::Object({ let mut json = serde_json::Map::new(); $($( @@ -484,7 +471,7 @@ macro_rules! make_config { for mask_key in PRIVACY_CONFIG { if let Some(value) = json.get_mut(*mask_key) { if let Some(s) = value.as_str() { - *value = privacy_mask(s).into(); + *value = _privacy_mask(s).into(); } } } @@ -499,10 +486,6 @@ macro_rules! make_config { }; overrides } - - pub fn get_config_file_status(&self) -> ConfigFileStatus { - self.inner.read().unwrap()._config_file_status - } } }; } @@ -522,23 +505,23 @@ macro_rules! make_config { make_config! { folders { /// Data folder |> Main data folder - data_folder: String, false, def, "data".to_owned(); + data_folder: String, false, def, "data".to_string(); /// Database URL - database_url: String, false, auto, |c| format!("sqlite://{}", storage::join_path(&c.data_folder, "db.sqlite3")); + database_url: String, false, auto, |c| format!("{}/db.sqlite3", c.data_folder); /// Icon cache folder - icon_cache_folder: String, false, auto, |c| storage::join_path(&c.data_folder, "icon_cache"); + icon_cache_folder: String, false, auto, |c| format!("{}/icon_cache", c.data_folder); /// Attachments folder - attachments_folder: String, false, auto, |c| storage::join_path(&c.data_folder, "attachments"); + attachments_folder: String, false, auto, |c| format!("{}/attachments", c.data_folder); /// Sends folder - sends_folder: String, false, auto, |c| storage::join_path(&c.data_folder, "sends"); + sends_folder: String, false, auto, |c| format!("{}/sends", c.data_folder); /// Temp folder |> Used for storing temporary file uploads - tmp_folder: String, false, auto, |c| storage::join_path(&c.data_folder, "tmp"); + tmp_folder: String, false, auto, |c| format!("{}/tmp", c.data_folder); /// Templates folder - templates_folder: String, false, auto, |c| storage::join_path(&c.data_folder, "templates"); + templates_folder: String, false, auto, |c| format!("{}/templates", c.data_folder); /// Session JWT key - rsa_key_filename: String, false, auto, |c| storage::join_path(&c.data_folder, "rsa_key"); + rsa_key_filename: String, false, auto, |c| format!("{}/rsa_key", c.data_folder); /// Web vault folder - web_vault_folder: String, false, def, "web-vault/".to_owned(); + web_vault_folder: String, false, def, "web-vault/".to_string(); }, ws { /// Enable websocket notifications @@ -548,9 +531,9 @@ make_config! { /// Enable push notifications push_enabled: bool, false, def, false; /// Push relay uri - push_relay_uri: String, false, def, "https://push.bitwarden.com".to_owned(); + push_relay_uri: String, false, def, "https://push.bitwarden.com".to_string(); /// Push identity uri - push_identity_uri: String, false, def, "https://identity.bitwarden.com".to_owned(); + push_identity_uri: String, false, def, "https://identity.bitwarden.com".to_string(); /// Installation id |> The installation id from https://bitwarden.com/host push_installation_id: Pass, false, def, String::new(); /// Installation key |> The installation key from https://bitwarden.com/host @@ -559,41 +542,41 @@ make_config! { jobs { /// Job scheduler poll interval |> How often the job scheduler thread checks for jobs to run. /// Set to 0 to globally disable scheduled jobs. - job_poll_interval_ms: u32, false, def, 30_000; + job_poll_interval_ms: u64, false, def, 30_000; /// Send purge schedule |> Cron schedule of the job that checks for Sends past their deletion date. /// Defaults to hourly. Set blank to disable this job. - send_purge_schedule: String, false, def, "0 5 * * * *".to_owned(); + send_purge_schedule: String, false, def, "0 5 * * * *".to_string(); /// Trash purge schedule |> Cron schedule of the job that checks for trashed items to delete permanently. /// Defaults to daily. Set blank to disable this job. - trash_purge_schedule: String, false, def, "0 5 0 * * *".to_owned(); + trash_purge_schedule: String, false, def, "0 5 0 * * *".to_string(); /// Incomplete 2FA login schedule |> Cron schedule of the job that checks for incomplete 2FA logins. /// Defaults to once every minute. Set blank to disable this job. - incomplete_2fa_schedule: String, false, def, "30 * * * * *".to_owned(); + incomplete_2fa_schedule: String, false, def, "30 * * * * *".to_string(); /// Emergency notification reminder schedule |> Cron schedule of the job that sends expiration reminders to emergency access grantors. /// Defaults to hourly. (3 minutes after the hour) Set blank to disable this job. - emergency_notification_reminder_schedule: String, false, def, "0 3 * * * *".to_owned(); + emergency_notification_reminder_schedule: String, false, def, "0 3 * * * *".to_string(); /// Emergency request timeout schedule |> Cron schedule of the job that grants emergency access requests that have met the required wait time. /// Defaults to hourly. (7 minutes after the hour) Set blank to disable this job. - emergency_request_timeout_schedule: String, false, def, "0 7 * * * *".to_owned(); + emergency_request_timeout_schedule: String, false, def, "0 7 * * * *".to_string(); /// Event cleanup schedule |> Cron schedule of the job that cleans old events from the event table. /// Defaults to daily. Set blank to disable this job. - event_cleanup_schedule: String, false, def, "0 10 0 * * *".to_owned(); + event_cleanup_schedule: String, false, def, "0 10 0 * * *".to_string(); /// Auth Request cleanup schedule |> Cron schedule of the job that cleans old auth requests from the auth request. /// Defaults to every minute. Set blank to disable this job. - auth_request_purge_schedule: String, false, def, "30 * * * * *".to_owned(); - /// Duo Auth context cleanup schedule |> Cron schedule of the job that cleans expired Duo contexts from the database. Does nothing if Duo MFA is disabled. + auth_request_purge_schedule: String, false, def, "30 * * * * *".to_string(); + /// 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. /// 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_string(); /// Purge incomplete SSO auth. |> Cron schedule of the job that cleans leftover auth in db due to incomplete SSO login. /// Defaults to daily. Set blank to disable this job. - purge_incomplete_sso_auth: String, false, def, "0 20 0 * * *".to_owned(); + purge_incomplete_sso_auth: String, false, def, "0 20 0 * * *".to_string(); }, /// General settings settings { /// Domain URL |> This needs to be set to the URL used to access the server, including 'http[s]://' /// and port, if it's different than the default. Some server functions don't work correctly without this value - domain: String, true, def, "http://localhost".to_owned(); + domain: String, true, def, "http://localhost".to_string(); /// Domain Set |> Indicates if the domain is set by the admin. Otherwise the default will be used. domain_set: bool, false, def, false; /// Domain origin |> Domain URL origin (in https://example.com:8443/path, https://example.com:8443 is the origin) @@ -607,9 +590,6 @@ make_config! { /// This setting applies globally to all users. To control this on a per-org basis instead, use the "Disable Send" org policy. sends_allowed: bool, true, def, true; - /// Fill Assist Rules URL |> HTTPS URL for the server-wide Fill Assist rules feed base directory. - fill_assist_rules_url: String, true, def, "https://fillassist.bitwarden.com".to_owned(); - /// HIBP Api Key |> HaveIBeenPwned API Key, request it here: https://haveibeenpwned.com/API/Key hibp_api_key: Pass, true, option; @@ -618,19 +598,19 @@ make_config! { /// Per-organization attachment storage limit (KB) |> Max kilobytes of attachment storage allowed per org. When this limit is reached, org members will not be allowed to upload further attachments for ciphers owned by that org. org_attachment_limit: i64, true, option; /// Per-user send storage limit (KB) |> Max kilobytes of sends storage allowed per user. When this limit is reached, the user will not be allowed to upload further sends. - user_send_limit: i64, true, option; + user_send_limit: i64, true, option; /// Trash auto-delete days |> Number of days to wait before auto-deleting a trashed item. - /// If unset or 0, trashed items are not auto-deleted. This setting applies globally, so make + /// If unset, trashed items are not auto-deleted. This setting applies globally, so make /// sure to inform all users of any changes to this setting. - trash_auto_delete_days: u16, true, option; + trash_auto_delete_days: i64, true, option; /// Incomplete 2FA time limit |> Number of minutes to wait before a 2FA-enabled login is /// considered incomplete, resulting in an email notification. An incomplete 2FA login is one /// where the correct master password was provided but the required 2FA step was not completed, /// which potentially indicates a master password compromise. Set to 0 to disable this check. /// This setting applies globally to all users. - incomplete_2fa_time_limit: u16, true, def, 3; + incomplete_2fa_time_limit: i64, true, def, 3; /// Disable icon downloads |> Set to true to disable icon downloading in the internal icon service. /// This still serves existing icons from $ICON_CACHE_FOLDER, without generating any external @@ -643,9 +623,9 @@ make_config! { /// this will prevent logins from succeeding until the address has been verified signups_verify: bool, true, def, false; /// If signups require email verification, automatically re-send verification email if it hasn't been sent for a while (in seconds) - signups_verify_resend_time: u32, true, def, 3_600; + signups_verify_resend_time: u64, true, def, 3_600; /// If signups require email verification, limit how many emails are automatically sent when login is attempted (0 means no limit) - signups_verify_resend_limit: u16, true, def, 6; + signups_verify_resend_limit: u32, true, def, 6; /// Email domain whitelist |> Allow signups only from this list of comma-separated domains, even when signups are otherwise disabled signups_domains_whitelist: String, true, def, String::new(); /// Enable event logging |> Enables event logging for organizations. @@ -657,7 +637,7 @@ make_config! { invitations_allowed: bool, true, def, true; /// Invitation token expiration time (in hours) |> The number of hours after which an organization invite token, emergency access invite token, /// email verification token and deletion request token will expire (must be at least 1) - invitation_expiration_hours: u16, false, def, 120; + invitation_expiration_hours: u32, false, def, 120; /// Enable emergency access |> Controls whether users can enable emergency access to their accounts. This setting applies globally to all users. emergency_access_allowed: bool, true, def, true; /// Allow email change |> Controls whether users can change their email. This setting applies globally to all users. @@ -676,38 +656,26 @@ make_config! { admin_token: Pass, true, option; /// Invitation organization name |> Name shown in the invitation emails that don't come from a specific organization - invitation_org_name: String, true, def, "Vaultwarden".to_owned(); + invitation_org_name: String, true, def, "Vaultwarden".to_string(); - /// Events days retain |> Number of days to retain events stored in the database. If unset or 0, events are kept indefinitely. - events_days_retain: u16, false, option; - }, - - client { - /// Control whether clients onboarding interstitials are suppressed |> post-login welcome dialogs, extension install prompts, setup extension redirects, and premium upsell modals - client_suppress_onboarding: bool, true, def, false; + /// Events days retain |> Number of days to retain events stored in the database. If unset, events are kept indefinitely. + events_days_retain: i64, false, option; }, /// Advanced settings advanced { /// Client IP header |> If not present, the remote IP is used. /// Set to the string "none" (without quotes), to disable any headers and just use the remote IP - ip_header: String, true, def, "X-Real-IP".to_owned(); + ip_header: String, true, def, "X-Real-IP".to_string(); /// Internal IP header property, used to avoid recomputing each time _ip_header_enabled: bool, false, generated, |c| &c.ip_header.trim().to_lowercase() != "none"; - /// Trusted proxies |> Which addresses the client IP header is accepted from. Requests from any - /// other address use the remote IP instead, so a client can't spoof the header. - /// Either the string "local" (the default, any non-global address, which covers a reverse proxy - /// running on the same host or container network), the string "all" to accept it from anywhere, - /// or a comma separated list of IPs and CIDR ranges. For a list header like X-Forwarded-For, the - /// client IP is the rightmost address that isn't a trusted proxy, so list every proxy in the chain. - ip_header_trusted_proxies: String, true, def, "local".to_owned(); /// Icon service |> The predefined icon services are: internal, bitwarden, duckduckgo, google. /// To specify a custom icon service, set a URL template with exactly one instance of `{}`, /// which is replaced with the domain. For example: `https://icon.example.com/domain/{}`. /// `internal` refers to Vaultwarden's built-in icon fetching implementation. If an external /// service is set, an icon request to Vaultwarden will return an HTTP redirect to the /// corresponding icon at the external service. - icon_service: String, false, def, "internal".to_owned(); + icon_service: String, false, def, "internal".to_string(); /// _icon_service_url _icon_service_url: String, false, generated, |c| generate_icon_service_url(&c.icon_service); /// _icon_service_csp @@ -717,13 +685,13 @@ make_config! { /// Temporary redirects are useful while testing different icon services, but once a service /// has been decided on, consider using permanent redirects for cacheability. The legacy codes /// are currently better supported by the Bitwarden clients. - icon_redirect_code: u16, true, def, 302; + icon_redirect_code: u32, true, def, 302; /// Positive icon cache expiry |> Number of seconds to consider that an already cached icon is fresh. After this period, the icon will be refreshed - icon_cache_ttl: u32, true, def, 2_592_000; + icon_cache_ttl: u64, true, def, 2_592_000; /// Negative icon cache expiry |> Number of seconds before trying to download an icon that failed again. - icon_cache_negttl: u32, true, def, 259_200; + icon_cache_negttl: u64, true, def, 259_200; /// Icon download timeout |> Number of seconds when to stop attempting to download an icon. - icon_download_timeout: u16, true, def, 10; + icon_download_timeout: u64, true, def, 10; /// [Deprecated] Icon blacklist Regex |> Use `http_request_block_regex` instead icon_blacklist_regex: String, false, option; @@ -740,8 +708,6 @@ make_config! { /// Disable Two-Factor remember |> Enabling this would force the users to use a second factor to login every time. /// Note that the checkbox would still be present, but ignored. disable_2fa_remember: bool, true, def, false; - /// Two-Factor remember duration |> Number of days before a remembered Two-Factor login expires (min: 1, max: 90). - two_factor_remember_days: i64, true, def, 30; /// Disable authenticator time drifted codes to be valid |> Enabling this only allows the current TOTP code to be valid /// TOTP codes of the previous and next 30 seconds will be invalid. @@ -760,14 +726,14 @@ make_config! { /// Enable extended logging extended_logging: bool, false, def, true; /// Log timestamp format - log_timestamp_format: String, true, def, "%Y-%m-%d %H:%M:%S.%3f".to_owned(); + log_timestamp_format: String, true, def, "%Y-%m-%d %H:%M:%S.%3f".to_string(); /// Enable the log to output to Syslog use_syslog: bool, false, def, false; /// Log file path log_file: String, false, option; /// Log level |> Valid values are "trace", "debug", "info", "warn", "error" and "off" /// For a specific module append it as a comma separated value "info,path::to::module=debug" - log_level: String, false, def, "info".to_owned(); + log_level: String, false, def, "info".to_string(); /// Enable DB WAL |> Turning this off might lead to worse performance, but might help if using vaultwarden on some exotic filesystems, /// that do not support WAL. Please make sure you read project wiki on the topic before changing this setting. @@ -776,11 +742,11 @@ make_config! { /// Max database connection retries |> Number of times to retry the database connection during startup, with 1 second between each retry, set to 0 to retry indefinitely db_connection_retries: u32, false, def, 15; - /// Timeout in seconds when acquiring database connection - database_timeout: u32, false, def, 30; + /// Timeout when acquiring database connection + database_timeout: u64, false, def, 30; /// Timeout in seconds before idle connections to the database are closed - database_idle_timeout: u32, false, def, 600; + database_idle_timeout: u64, false, def, 600; /// Database connection max pool size database_max_conns: u32, false, def, 10; @@ -797,34 +763,25 @@ make_config! { /// Allowed iframe ancestors (Know the risks!) |> Allows other domains to embed the web vault into an iframe, useful for embedding into secure intranets allowed_iframe_ancestors: String, true, def, String::new(); - /// Allowed connect-src (Know the risks!) |> Allows other domains for URLs which can be loaded using script interfaces like the Forwarded email alias feature + /// Allowed connect-src (Know the risks!) |> Allows other domains to URLs which can be loaded using script interfaces like the Forwarded email alias feature allowed_connect_src: String, true, def, String::new(); /// Seconds between login requests |> Number of seconds, on average, between login and 2FA requests from the same IP address before rate limiting kicks in - login_ratelimit_seconds: u16, false, def, 60; + login_ratelimit_seconds: u64, false, def, 60; /// Max burst size for login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `login_ratelimit_seconds`. Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2 - login_ratelimit_max_burst: u16, false, def, 10; - - /// Seconds between unauthenticated requests |> Number of seconds, on average, between requests from the same IP address to any of the rate limited unauthenticated endpoints - unauthenticated_ratelimit_seconds: u16, false, def, 60; - /// Max burst size for unauthenticated requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `unauthenticated_ratelimit_seconds`. This is shared between several endpoints, so it needs to be more lenient than the login one - unauthenticated_ratelimit_max_burst: u16, false, def, 50; + login_ratelimit_max_burst: u32, false, def, 10; /// Seconds between admin login requests |> Number of seconds, on average, between admin requests from the same IP address before rate limiting kicks in - admin_ratelimit_seconds: u16, false, def, 300; + admin_ratelimit_seconds: u64, false, def, 300; /// Max burst size for admin login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `admin_ratelimit_seconds` - admin_ratelimit_max_burst: u16, false, def, 3; + admin_ratelimit_max_burst: u32, false, def, 3; /// Admin session lifetime |> Set the lifetime of admin sessions to this value (in minutes). - admin_session_lifetime: u32, true, def, 20; + admin_session_lifetime: i64, true, def, 20; /// Enable groups (BETA!) (Know the risks!) |> Enables groups support for organizations (Currently contains known issues!). org_groups_enabled: bool, false, def, false; - /// Enable Custom roles (EXPERIMENTAL!) (Know the risks!) |> Enables the in-development Custom member role with granular permissions for organizations. - /// Only the permissions this version enforces can be granted. Disabling it again keeps the stored Custom roles, but they grant nothing until it is re-enabled. - experimental_custom_roles: bool, false, def, false; - /// Increase note size limit (Know the risks!) |> Sets the secure note size limit to 100_000 instead of the default 10_000. /// WARNING: This could cause issues with clients. Also exports will not work on Bitwarden servers! increase_note_size_limit: bool, true, def, false; @@ -847,8 +804,6 @@ make_config! { sso_enabled: bool, true, def, false; /// Only SSO login |> Disable Email+Master Password login sso_only: bool, true, def, false; - /// Allow SSO flow to create account |> You probably want to disable it when using a public provider - sso_signups_allowed: bool, true, def, true; /// Allow email association |> Associate existing non-SSO user based on email sso_signups_match_email: bool, true, def, true; /// Allow unknown email verification status |> Allowing this with `SSO_SIGNUPS_MATCH_EMAIL=true` open potential account takeover. @@ -860,7 +815,7 @@ make_config! { /// Authority Server |> Base url of the OIDC provider discovery endpoint (without `/.well-known/openid-configuration`) sso_authority: String, true, def, String::new(); /// Authorization request scopes |> List the of the needed scope (`openid` is implicit) - sso_scopes: String, true, def, "email profile".to_owned(); + sso_scopes: String, true, def, "email profile".to_string(); /// Authorization request extra parameters sso_authorize_extra_params: String, true, def, String::new(); /// Use PKCE during Authorization flow @@ -873,8 +828,8 @@ make_config! { sso_master_password_policy: String, true, option; /// Use SSO only for auth not the session lifecycle |> Use default Vaultwarden session lifecycle (Idle refresh token valid for 30days) sso_auth_only_not_session: bool, true, def, false; - /// Client cache for discovery endpoint. |> Duration in seconds (0 to disable). More details: https://github.com/dani-garcia/vaultwarden/wiki/Enabling-SSO-support-using-OpenId-Connect#client-cache - sso_client_cache_expiration: u32, true, def, 0; + /// Client cache for discovery endpoint. |> Duration in seconds (0 or less to disable). More details: https://github.com/dani-garcia/vaultwarden/wiki/Enabling-SSO-support-using-OpenId-Connect#client-cache + sso_client_cache_expiration: u64, true, def, 0; /// Log all tokens |> `LOG_LEVEL=debug` or `LOG_LEVEL=info,vaultwarden::sso=debug` is required sso_debug_tokens: bool, true, def, false; }, @@ -895,12 +850,16 @@ make_config! { duo: _enable_duo { /// Enabled _enable_duo: bool, true, def, true; + /// Attempt to use deprecated iframe-based Traditional Prompt (Duo WebSDK 2) + duo_use_iframe: bool, false, def, false; /// Client Id duo_ikey: String, true, option; /// Client Secret duo_skey: Pass, true, option; /// Host duo_host: String, true, option; + /// Application Key (generated automatically) + _duo_akey: Pass, false, option; }, /// SMTP Email Settings @@ -924,7 +883,7 @@ make_config! { /// From Address smtp_from: String, true, def, String::new(); /// From Name - smtp_from_name: String, true, def, "Vaultwarden".to_owned(); + smtp_from_name: String, true, def, "Vaultwarden".to_string(); /// Username smtp_username: String, true, option; /// Password @@ -932,11 +891,11 @@ make_config! { /// SMTP Auth mechanism |> Defaults for SSL is "Plain" and "Login" and nothing for Non-SSL connections. Possible values: ["Plain", "Login", "Xoauth2"]. Multiple options need to be separated by a comma ','. smtp_auth_mechanism: String, true, option; /// SMTP connection timeout |> Number of seconds when to stop trying to connect to the SMTP server - smtp_timeout: u16, true, def, 15; + smtp_timeout: u64, true, def, 15; /// Server name sent during HELO |> By default this value should be the machine's hostname, but might need to be changed in case it trips some anti-spam filters helo_name: String, true, option; /// Embed images as email attachments. - smtp_embed_images: bool, true, def, true; + smtp_embed_images: bool, true, def, true; /// _smtp_img_src _smtp_img_src: String, false, generated, |c| generate_smtp_img_src(c.smtp_embed_images, &c.domain); /// Enable SMTP debugging (Know the risks!) |> DANGEROUS: Enabling this will output very detailed SMTP messages. This could contain sensitive information like passwords and usernames! Only enable this during troubleshooting! @@ -954,9 +913,9 @@ make_config! { /// Email token size |> Number of digits in an email 2FA token (min: 6, max: 255). Note that the Bitwarden clients are hardcoded to mention 6 digit codes regardless of this setting. email_token_size: u8, true, def, 6; /// Token expiration time |> Maximum time in seconds a token is valid. The time the user has to open email client and copy token. - email_expiration_time: u32, true, def, 600; + email_expiration_time: u64, true, def, 600; /// Maximum attempts |> Maximum attempts before an email token is reset and a new email will need to be sent - email_attempts_limit: u8, true, def, 3; + email_attempts_limit: u64, true, def, 3; /// Setup email 2FA at signup |> Setup email 2FA provider on registration regardless of any organization policy email_2fa_enforce_on_verified_invite: bool, true, def, false; /// Auto-enable 2FA (Know the risks!) |> Automatically setup email 2FA as fallback provider when needed @@ -970,13 +929,10 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { { use crate::db::DbConnType; let url = &cfg.database_url; - if DbConnType::from_url(url)? == DbConnType::Sqlite { - let file_path = url.strip_prefix("sqlite://").unwrap_or(url); - if file_path.contains('/') { - let path = std::path::Path::new(file_path); - if let Some(parent) = path.parent() - && !parent.is_dir() - { + if DbConnType::from_url(url)? == DbConnType::Sqlite && url.contains('/') { + let path = std::path::Path::new(&url); + if let Some(parent) = path.parent() { + if !parent.is_dir() { err!(format!( "SQLite database directory `{}` does not exist or is not a directory", parent.display() @@ -986,18 +942,6 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { } } - let trusted_proxies = cfg.ip_header_trusted_proxies.trim(); - if !trusted_proxies.eq_ignore_ascii_case("all") && !trusted_proxies.eq_ignore_ascii_case("local") { - for entry in trusted_proxies.split(',').filter(|e| !e.trim().is_empty()) { - if crate::auth::parse_trusted_proxy(entry).is_none() { - err!(format!( - "Invalid IP_HEADER_TRUSTED_PROXIES entry `{}`, expected an IP or CIDR range", - entry.trim() - )); - } - } - } - if cfg.password_iterations < 100_000 { err!("PASSWORD_ITERATIONS should be at least 100000 or higher. The default is 600000!"); } @@ -1012,21 +956,13 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { } if cfg.database_min_conns > cfg.database_max_conns { - err!("`DATABASE_MIN_CONNS` must be less than or equal to `DATABASE_MAX_CONNS`."); + err!(format!("`DATABASE_MIN_CONNS` must be smaller than or equal to `DATABASE_MAX_CONNS`.",)); } - if cfg.database_timeout < 1 { - err!("`DATABASE_TIMEOUT` must be at least 1 second"); - } - - if cfg.database_idle_timeout < 1 { - err!("`DATABASE_IDLE_TIMEOUT` must be at least 1 second"); - } - - if let Some(log_file) = &cfg.log_file - && std::fs::OpenOptions::new().append(true).create(true).open(log_file).is_err() - { - err!("Unable to write to log file", log_file); + if let Some(log_file) = &cfg.log_file { + if std::fs::OpenOptions::new().append(true).create(true).open(log_file).is_err() { + err!("Unable to write to log file", log_file); + } } let dom = cfg.domain.to_lowercase(); @@ -1036,27 +972,10 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { ); } - let valid_fill_assist_rules_url = Url::parse(&cfg.fill_assist_rules_url).is_ok_and(|url| { - url.scheme() == "https" - && url.has_host() - && url.username().is_empty() - && url.password().is_none() - && url.query().is_none() - && url.fragment().is_none() - && cfg.fill_assist_rules_url.trim() == cfg.fill_assist_rules_url - }); - if !valid_fill_assist_rules_url { - err!( - "`FILL_ASSIST_RULES_URL` must be a valid HTTPS URL without credentials, query parameters, fragments, or surrounding whitespace." - ); - } - let connect_src = cfg.allowed_connect_src.to_lowercase(); for url in connect_src.split_whitespace() { if !url.starts_with("https://") || Url::parse(url).is_err() { - err!( - "ALLOWED_CONNECT_SRC variable contains one or more invalid URLs. Only FQDN's starting with https are allowed" - ); + err!("ALLOWED_CONNECT_SRC variable contains one or more invalid URLs. Only FQDN's starting with https are allowed"); } } @@ -1072,12 +991,11 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { err!("`ORG_CREATION_USERS` contains invalid email addresses"); } - if let Some(ref token) = cfg.admin_token - && token.trim().is_empty() - && !cfg.disable_admin_token - { - println!("[WARNING] `ADMIN_TOKEN` is enabled but has an empty value, so the admin page will be disabled."); - println!("[WARNING] To enable the admin page without a token, use `DISABLE_ADMIN_TOKEN`."); + if let Some(ref token) = cfg.admin_token { + if token.trim().is_empty() && !cfg.disable_admin_token { + println!("[WARNING] `ADMIN_TOKEN` is enabled but has an empty value, so the admin page will be disabled."); + println!("[WARNING] To enable the admin page without a token, use `DISABLE_ADMIN_TOKEN`."); + } } if cfg.push_enabled && (cfg.push_installation_id == String::new() || cfg.push_installation_key == String::new()) { @@ -1111,41 +1029,37 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { } } - let invalid_flags = parse_experimental_client_feature_flags( - &cfg.experimental_client_feature_flags, - &FeatureFlagFilter::InvalidOnly, - ); + let invalid_flags = + parse_experimental_client_feature_flags(&cfg.experimental_client_feature_flags, FeatureFlagFilter::InvalidOnly); if !invalid_flags.is_empty() { - let feature_flags_error = format!( - "Unrecognized experimental client feature flags: {invalid_flags:?}.\n\ + let feature_flags_error = format!("Unrecognized experimental client feature flags: {:?}.\n\ Please ensure all feature flags are spelled correctly and that they are supported in this version.\n\ - Supported flags: {SUPPORTED_FEATURE_FLAGS:?}\n" - ); + Supported flags: {:?}\n", invalid_flags, SUPPORTED_FEATURE_FLAGS); if on_update { err!(feature_flags_error); + } else { + println!("[WARNING] {feature_flags_error}"); } - println!("[WARNING] {feature_flags_error}"); } - #[expect(clippy::items_after_statements, reason = "Keep this close to where it is used")] const MAX_FILESIZE_KB: i64 = i64::MAX >> 10; - if let Some(limit) = cfg.user_attachment_limit - && !(0i64..=MAX_FILESIZE_KB).contains(&limit) - { - err!("`USER_ATTACHMENT_LIMIT` is out of bounds"); + if let Some(limit) = cfg.user_attachment_limit { + if !(0i64..=MAX_FILESIZE_KB).contains(&limit) { + err!("`USER_ATTACHMENT_LIMIT` is out of bounds"); + } } - if let Some(limit) = cfg.org_attachment_limit - && !(0i64..=MAX_FILESIZE_KB).contains(&limit) - { - err!("`ORG_ATTACHMENT_LIMIT` is out of bounds"); + if let Some(limit) = cfg.org_attachment_limit { + if !(0i64..=MAX_FILESIZE_KB).contains(&limit) { + err!("`ORG_ATTACHMENT_LIMIT` is out of bounds"); + } } - if let Some(limit) = cfg.user_send_limit - && !(0i64..=MAX_FILESIZE_KB).contains(&limit) - { - err!("`USER_SEND_LIMIT` is out of bounds"); + if let Some(limit) = cfg.user_send_limit { + if !(0i64..=MAX_FILESIZE_KB).contains(&limit) { + err!("`USER_SEND_LIMIT` is out of bounds"); + } } if cfg._enable_duo @@ -1162,7 +1076,7 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { validate_internal_sso_issuer_url(&cfg.sso_authority)?; validate_internal_sso_redirect_url(&cfg.sso_callback_path)?; - validate_sso_master_password_policy(cfg.sso_master_password_policy.as_ref())?; + validate_sso_master_password_policy(&cfg.sso_master_password_policy)?; } if cfg._enable_yubico { @@ -1173,9 +1087,7 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { if let Some(yubico_server) = &cfg.yubico_server { let yubico_server = yubico_server.to_lowercase(); if !yubico_server.starts_with("https://") { - err!( - "`YUBICO_SERVER` must be a valid URL and start with 'https://'. Either unset this variable or provide a valid URL." - ) + err!("`YUBICO_SERVER` must be a valid URL and start with 'https://'. Either unset this variable or provide a valid URL.") } } } @@ -1213,8 +1125,11 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { } #[cfg(unix)] - if nix::unistd::access(&path, nix::unistd::AccessFlags::X_OK).is_err() { - err!(format!("sendmail command at `{path:?}` isn't executable")); + { + use std::os::unix::fs::PermissionsExt; + if !metadata.permissions().mode() & 0o111 != 0 { + err!(format!("sendmail command at `{path:?}` isn't executable")); + } } } } @@ -1224,9 +1139,7 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { } if cfg.smtp_username.is_some() != cfg.smtp_password.is_some() { - err!( - "Both `SMTP_USERNAME` and `SMTP_PASSWORD` need to be set to enable email authentication without `USE_SENDMAIL`" - ) + err!("Both `SMTP_USERNAME` and `SMTP_PASSWORD` need to be set to enable email authentication without `USE_SENDMAIL`") } } @@ -1285,10 +1198,6 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { err!("`INVITATION_EXPIRATION_HOURS` has a minimum duration of 1 hour") } - if !(1..=90).contains(&cfg.two_factor_remember_days) { - err!("`TWO_FACTOR_REMEMBER_DAYS` must be between 1 and 90 days") - } - // Validate schedule crontab format if !cfg.send_purge_schedule.is_empty() && cfg.send_purge_schedule.parse::().is_err() { err!("`SEND_PURGE_SCHEDULE` is not a valid cron expression") @@ -1322,42 +1231,10 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { err!("`AUTH_REQUEST_PURGE_SCHEDULE` is not a valid cron expression") } - if !cfg.duo_context_purge_schedule.is_empty() && cfg.duo_context_purge_schedule.parse::().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::().is_err() { - err!("`PURGE_INCOMPLETE_SSO_AUTH` is not a valid cron expression") - } - - if cfg.login_ratelimit_seconds < 1 { - err!("`LOGIN_RATELIMIT_SECONDS` must be at least 1 second"); - } - - if cfg.login_ratelimit_max_burst < 1 { - err!("`LOGIN_RATELIMIT_MAX_BURST` must be at least 1"); - } - - if cfg.admin_ratelimit_seconds < 1 { - err!("`ADMIN_RATELIMIT_SECONDS` must be at least 1 second"); - } - - if cfg.admin_ratelimit_max_burst < 1 { - err!("`ADMIN_RATELIMIT_MAX_BURST` must be at least 1"); - } - - if cfg.unauthenticated_ratelimit_seconds < 1 { - err!("`UNAUTHENTICATED_RATELIMIT_SECONDS` must be at least 1 second"); - } - - if cfg.unauthenticated_ratelimit_max_burst < 1 { - err!("`UNAUTHENTICATED_RATELIMIT_MAX_BURST` must be at least 1"); - } - if !cfg.disable_admin_token { match cfg.admin_token.as_ref() { Some(t) if t.starts_with("$argon2") => { - if let Err(e) = argon2::password_hash::phc::PasswordHash::new(t) { + if let Err(e) = argon2::password_hash::PasswordHash::new(t) { err!(format!("The configured Argon2 PHC in `ADMIN_TOKEN` is invalid: '{e}'")) } } @@ -1394,7 +1271,7 @@ fn validate_internal_sso_redirect_url(sso_callback_path: &String) -> Result, + sso_master_password_policy: &Option, ) -> Result, Error> { let policy = sso_master_password_policy.as_ref().map(|mpp| serde_json::from_str::(mpp)); @@ -1423,7 +1300,7 @@ fn extract_url_origin(url: &str) -> String { /// All trailing '/' chars are trimmed, even if the path is a lone '/'. fn extract_url_path(url: &str) -> String { match Url::parse(url) { - Ok(u) => u.path().trim_end_matches('/').to_owned(), + Ok(u) => u.path().trim_end_matches('/').to_string(), Err(_) => { // We already print it in the method above, no need to do it again String::new() @@ -1433,7 +1310,7 @@ fn extract_url_path(url: &str) -> String { fn generate_smtp_img_src(embed_images: bool, domain: &str) -> String { if embed_images { - "cid:".to_owned() + "cid:".to_string() } else { // normalize base_url let base_url = domain.trim_end_matches('/'); @@ -1452,10 +1329,10 @@ fn generate_sso_callback_path(domain: &str) -> String { fn generate_icon_service_url(icon_service: &str) -> String { match icon_service { "internal" => String::new(), - "bitwarden" => "https://icons.bitwarden.net/{}/icon.png".to_owned(), - "duckduckgo" => "https://icons.duckduckgo.com/ip3/{}.ico".to_owned(), - "google" => "https://www.google.com/s2/favicons?domain={}&sz=32".to_owned(), - _ => icon_service.to_owned(), + "bitwarden" => "https://icons.bitwarden.net/{}/icon.png".to_string(), + "duckduckgo" => "https://icons.duckduckgo.com/ip3/{}.ico".to_string(), + "google" => "https://www.google.com/s2/favicons?domain={}&sz=32".to_string(), + _ => icon_service.to_string(), } } @@ -1464,7 +1341,7 @@ fn generate_icon_service_csp(icon_service: &str, icon_service_url: &str) -> Stri // We split on the first '{', since that is the variable delimiter for an icon service URL. // Everything up until the first '{' should be fixed and can be used as an CSP string. let csp_string = match icon_service_url.split_once('{') { - Some((c, _)) => c.to_owned(), + Some((c, _)) => c.to_string(), None => String::new(), }; @@ -1481,12 +1358,96 @@ fn smtp_convert_deprecated_ssl_options(smtp_ssl: Option, smtp_explicit_tls println!("[DEPRECATED]: `SMTP_SSL` or `SMTP_EXPLICIT_TLS` is set. Please use `SMTP_SECURITY` instead."); } if smtp_explicit_tls.is_some() && smtp_explicit_tls.unwrap() { - return "force_tls".to_owned(); + return "force_tls".to_string(); } else if smtp_ssl.is_some() && !smtp_ssl.unwrap() { - return "off".to_owned(); + return "off".to_string(); } // Return the default `starttls` in all other cases - "starttls".to_owned() + "starttls".to_string() +} + +fn opendal_operator_for_path(path: &str) -> Result { + // Cache of previously built operators by path + static OPERATORS_BY_PATH: LazyLock> = + LazyLock::new(dashmap::DashMap::new); + + if let Some(operator) = OPERATORS_BY_PATH.get(path) { + return Ok(operator.clone()); + } + + let operator = if path.starts_with("s3://") { + #[cfg(not(s3))] + return Err(opendal::Error::new(opendal::ErrorKind::ConfigInvalid, "S3 support is not enabled").into()); + + #[cfg(s3)] + opendal_s3_operator_for_path(path)? + } else { + let builder = opendal::services::Fs::default().root(path); + opendal::Operator::new(builder)?.finish() + }; + + OPERATORS_BY_PATH.insert(path.to_string(), operator.clone()); + + Ok(operator) +} + +#[cfg(s3)] +fn opendal_s3_operator_for_path(path: &str) -> Result { + use crate::http_client::aws::AwsReqwestConnector; + use aws_config::{default_provider::credentials::DefaultCredentialsChain, provider_config::ProviderConfig}; + + // This is a custom AWS credential loader that uses the official AWS Rust + // SDK config crate to load credentials. This ensures maximum compatibility + // with AWS credential configurations. For example, OpenDAL doesn't support + // AWS SSO temporary credentials yet. + struct OpenDALS3CredentialLoader {} + + #[async_trait] + impl reqsign::AwsCredentialLoad for OpenDALS3CredentialLoader { + async fn load_credential(&self, _client: reqwest::Client) -> anyhow::Result> { + use aws_credential_types::provider::ProvideCredentials as _; + use tokio::sync::OnceCell; + + static DEFAULT_CREDENTIAL_CHAIN: OnceCell = OnceCell::const_new(); + + let chain = DEFAULT_CREDENTIAL_CHAIN + .get_or_init(|| { + let reqwest_client = reqwest::Client::builder().build().unwrap(); + let connector = AwsReqwestConnector { + client: reqwest_client, + }; + + let conf = ProviderConfig::default().with_http_client(connector); + + DefaultCredentialsChain::builder().configure(conf).build() + }) + .await; + + let creds = chain.provide_credentials().await?; + + Ok(Some(reqsign::AwsCredential { + access_key_id: creds.access_key_id().to_string(), + secret_access_key: creds.secret_access_key().to_string(), + session_token: creds.session_token().map(|s| s.to_string()), + expires_in: creds.expiry().map(|expiration| expiration.into()), + })) + } + } + + const OPEN_DAL_S3_CREDENTIAL_LOADER: OpenDALS3CredentialLoader = OpenDALS3CredentialLoader {}; + + let url = Url::parse(path).map_err(|e| format!("Invalid path S3 URL path {path:?}: {e}"))?; + + let bucket = url.host_str().ok_or_else(|| format!("Missing Bucket name in data folder S3 URL {path:?}"))?; + + let builder = opendal::services::S3::default() + .customized_credential_load(Box::new(OPEN_DAL_S3_CREDENTIAL_LOADER)) + .enable_virtual_host_style() + .bucket(bucket) + .root(url.path()) + .default_storage_class("INTELLIGENT_TIERING"); + + Ok(opendal::Operator::new(builder)?.finish()) } pub enum PathType { @@ -1503,61 +1464,38 @@ pub enum PathType { // Android (v2026.2.1): https://github.com/bitwarden/android/blob/6902c19c0093fa476bbf74ccaa70c9f14afbb82f/core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt#L31 // iOS (v2026.2.1): https://github.com/bitwarden/ios/blob/cdd9ba1770ca2ffc098d02d12cc3208e3a830454/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift#L7 pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[ + // Architecture + "desktop-ui-migration-milestone-1", + "desktop-ui-migration-milestone-2", + "desktop-ui-migration-milestone-3", + "desktop-ui-migration-milestone-4", // Auth Team "pm-5594-safari-account-switching", - "pm-32413-multi-client-password-management", // Autofill Team - "undetermined-cipher-scenario-logic", - "enable-basic-auth-response", - "fill-assist-targeting-rules", + "ssh-agent", "ssh-agent-v2", - "windows-desktop-autotype", - "windows-desktop-autotype-ga", // Key Management Team - "biometrics-sdk-ipc", - "windows-native-credential-sync", - "enable-account-encryption-v2-jit-password-registration", - "pm-27278-v2-password-registration", + "ssh-key-vault-item", + "pm-25373-windows-biometrics-v2", // Mobile Team - "pm-34171-card-scanner", + "anon-addy-self-host-alias", + "simple-login-self-host-alias", + "mutual-tls", + "cxp-import-mobile", + "cxp-export-mobile", // Platform Team "pm-30529-webauthn-related-origins", - // Vault Team - "pm-32009-new-item-types", ]; -#[derive(Clone, Copy, Serialize)] -pub enum ConfigFileStatus { - NotFound, - Loaded, - Error, -} - impl Config { pub async fn load() -> Result { // Loading from env and file - let env = ConfigBuilder::from_env(); - let (usr, config_file_status) = match ConfigBuilder::from_file().await { - Ok(Some(usr)) => (usr, ConfigFileStatus::Loaded), - Ok(None) => (ConfigBuilder::default(), ConfigFileStatus::NotFound), - Err(e) => { - println!( - "[ERROR] Failed to load configuration from `{}`\n\ - #####################################################################################\n\ - # The file seems to exist but might contain invalid JSON or invalid values. #\n\ - # Check the syntax and make sure every value is valid and within its allowed range. #\n\ - # This file is not used until those errors are resolved. #\n\ - #####################################################################################\n\ - {e:?}\n", - *CONFIG_FILE - ); - (ConfigBuilder::default(), ConfigFileStatus::Error) - } - }; + let _env = ConfigBuilder::from_env(); + let _usr = ConfigBuilder::from_file().await.unwrap_or_default(); // Create merged config, config file overwrites env - let mut overrides = Vec::new(); - let builder = env.merge(&usr, true, &mut overrides); + let mut _overrides = Vec::new(); + let builder = _env.merge(&_usr, true, &mut _overrides); // Fill any missing with defaults let config = builder.build(); @@ -1570,10 +1508,9 @@ impl Config { rocket_shutdown_handle: None, templates: load_templates(&config.templates_folder), config, - _env: env, - _usr: usr, - _overrides: overrides, - _config_file_status: config_file_status, + _env, + _usr, + _overrides, }), }) } @@ -1607,26 +1544,31 @@ impl Config { writer.config = config; writer._usr = builder; writer._overrides = overrides; - writer._config_file_status = ConfigFileStatus::Loaded; } //Save to file - let operator = storage::operator_for_path(&CONFIG_FILE_PARENT_DIR)?; + let operator = opendal_operator_for_path(&CONFIG_FILE_PARENT_DIR)?; operator.write(&CONFIG_FILENAME, config_str).await?; - // Invalidate CSS Cache because several config items might have impact on the rendered CSS - crate::api::invalidate_css_cache(); - Ok(()) } + async fn update_config_partial(&self, other: ConfigBuilder) -> Result<(), Error> { + let builder = { + let usr = &self.inner.read().unwrap()._usr; + let mut _overrides = Vec::new(); + usr.merge(&other, false, &mut _overrides) + }; + self.update_config(builder, false).await + } + /// Tests whether an email's domain is allowed. A domain is allowed if it /// is in signups_domains_whitelist, or if no whitelist is set (so there /// are no domain restrictions in effect). pub fn is_email_domain_allowed(&self, email: &str) -> bool { let e: Vec<&str> = email.rsplitn(2, '@').collect(); if e.len() != 2 || e[0].is_empty() || e[1].is_empty() { - warn!("Failed to parse email address '{}'", email.escape_debug()); + warn!("Failed to parse email address '{email}'"); return false; } let email_domain = e[0].to_lowercase(); @@ -1638,22 +1580,11 @@ impl Config { /// Tests whether signup is allowed for an email address, taking into /// account the signups_allowed and signups_domains_whitelist settings. pub fn is_signup_allowed(&self, email: &str) -> bool { - if self.signups_domains_whitelist().is_empty() { + if !self.signups_domains_whitelist().is_empty() { + // The whitelist setting overrides the signups_allowed setting. + self.is_email_domain_allowed(email) + } else { self.signups_allowed() - } else { - // The whitelist setting overrides the signups_allowed setting. - self.is_email_domain_allowed(email) - } - } - - /// Tests whether SSO signup is allowed for an email address, taking into - /// account the sso_signups_allowed and signups_domains_whitelist settings. - pub fn is_sso_signup_allowed(&self, email: &str) -> bool { - if self.signups_domains_whitelist().is_empty() { - self.sso_signups_allowed() - } else { - // The whitelist setting overrides the signups_allowed setting. - self.is_email_domain_allowed(email) } } @@ -1681,7 +1612,7 @@ impl Config { } pub async fn delete_user_config(&self) -> Result<(), Error> { - let operator = storage::operator_for_path(&CONFIG_FILE_PARENT_DIR)?; + let operator = opendal_operator_for_path(&CONFIG_FILE_PARENT_DIR)?; operator.delete(&CONFIG_FILENAME).await?; // Empty user config @@ -1699,23 +1630,36 @@ impl Config { writer.config = config; writer._usr = usr; writer._overrides = Vec::new(); - writer._config_file_status = ConfigFileStatus::NotFound; } - // Invalidate CSS Cache because several config items might have impact on the rendered CSS - crate::api::invalidate_css_cache(); - Ok(()) } pub fn private_rsa_key(&self) -> String { - storage::with_extension(&self.rsa_key_filename(), "pem") + format!("{}.pem", self.rsa_key_filename()) } pub fn mail_enabled(&self) -> bool { let inner = &self.inner.read().unwrap().config; inner._enable_smtp && (inner.smtp_host.is_some() || inner.use_sendmail) } + pub async fn get_duo_akey(&self) -> String { + if let Some(akey) = self._duo_akey() { + akey + } else { + let akey_s = crate::crypto::encode_random_bytes::<64>(&data_encoding::BASE64); + + // Save the new value + let builder = ConfigBuilder { + _duo_akey: Some(akey_s.clone()), + ..Default::default() + }; + self.update_config_partial(builder).await.ok(); + + akey_s + } + } + pub fn is_webauthn_2fa_supported(&self) -> bool { Url::parse(&self.domain()).expect("DOMAIN not a valid URL").domain().is_some() } @@ -1733,11 +1677,15 @@ impl Config { PathType::IconCache => self.icon_cache_folder(), PathType::Attachments => self.attachments_folder(), PathType::Sends => self.sends_folder(), - PathType::RsaKey => storage::parent(&self.private_rsa_key()) - .ok_or_else(|| std::io::Error::other("Failed to get directory of RSA key file"))?, + PathType::RsaKey => std::path::Path::new(&self.rsa_key_filename()) + .parent() + .ok_or_else(|| std::io::Error::other("Failed to get directory of RSA key file"))? + .to_str() + .ok_or_else(|| std::io::Error::other("Failed to convert RSA key file directory to UTF-8 string"))? + .to_string(), }; - storage::operator_for_path(&path) + opendal_operator_for_path(&path) } pub fn render_template(&self, name: &str, data: &T) -> Result { @@ -1761,10 +1709,10 @@ impl Config { } pub fn shutdown(&self) { - if let Ok(mut c) = self.inner.write() - && let Some(handle) = c.rocket_shutdown_handle.take() - { - handle.notify(); + if let Ok(mut c) = self.inner.write() { + if let Some(handle) = c.rocket_shutdown_handle.take() { + handle.notify(); + } } } @@ -1777,11 +1725,11 @@ impl Config { } pub fn sso_master_password_policy_value(&self) -> Option { - validate_sso_master_password_policy(self.sso_master_password_policy().as_ref()).ok().flatten() + validate_sso_master_password_policy(&self.sso_master_password_policy()).ok().flatten() } pub fn sso_scopes_vec(&self) -> Vec { - self.sso_scopes().split_whitespace().map(str::to_owned).collect() + self.sso_scopes().split_whitespace().map(str::to_string).collect() } pub fn sso_authorize_extra_params_vec(&self) -> Vec<(String, String)> { @@ -1828,7 +1776,7 @@ where reg!("email/email_footer"); reg!("email/email_footer_text"); - reg!("email/admin_account_recovery", ".html"); + reg!("email/admin_reset_password", ".html"); reg!("email/change_email_existing", ".html"); reg!("email/change_email_invited", ".html"); reg!("email/change_email", ".html"); @@ -1847,7 +1795,6 @@ where reg!("email/protected_action", ".html"); reg!("email/pw_hint_none", ".html"); reg!("email/pw_hint_some", ".html"); - reg!("email/recover_twofactor", ".html"); reg!("email/register_verify_email", ".html"); reg!("email/send_2fa_removed_from_org", ".html"); reg!("email/send_emergency_access_invite", ".html"); @@ -1892,7 +1839,7 @@ fn case_helper<'reg, 'rc>( let value = param.value().clone(); if h.params().iter().skip(1).any(|x| x.value() == &value) { - h.template().map_or(Ok(()), |t| t.render(r, ctx, rc, out)) + h.template().map(|t| t.render(r, ctx, rc, out)).unwrap_or_else(|| Ok(())) } else { Ok(()) } diff --git a/src/crypto.rs b/src/crypto.rs index 46d305a5..1930f380 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -113,10 +113,3 @@ pub fn ct_eq, U: AsRef<[u8]>>(a: T, b: U) -> bool { use subtle::ConstantTimeEq; a.as_ref().ct_eq(b.as_ref()).into() } - -// -// SHA256 -// -pub fn sha256_hex(data: &[u8]) -> String { - HEXLOWER.encode(digest::digest(&digest::SHA256, data).as_ref()) -} diff --git a/src/db/mod.rs b/src/db/mod.rs index 3caa6cde..d2ed9479 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,32 +1,30 @@ mod query_logger; use std::{ - future::Future, - sync::{ - Arc, OnceLock, - atomic::{AtomicBool, Ordering}, - }, + sync::{Arc, OnceLock}, time::Duration, }; use diesel::{ - Connection, RunQueryDsl, connection::SimpleConnection, r2d2::{CustomizeConnection, Pool, PooledConnection}, + Connection, RunQueryDsl, }; + use rocket::{ - Request, http::Status, request::{FromRequest, Outcome}, + Request, }; + use tokio::{ sync::{Mutex, OwnedSemaphorePermit, Semaphore}, time::timeout, }; use crate::{ - CONFIG, error::{Error, MapResult}, + CONFIG, }; // These changes are based on Rocket 0.5-rc wrapper of Diesel: https://github.com/SergioBenitez/Rocket/blob/v0.5-rc/contrib/sync_db_pools @@ -64,7 +62,7 @@ pub struct DbConnManager { impl DbConnManager { pub fn new(database_url: &str) -> Self { Self { - database_url: database_url.to_owned(), + database_url: database_url.to_string(), } } @@ -127,7 +125,6 @@ pub static ACTIVE_DB_TYPE: OnceLock = OnceLock::new(); pub struct DbConn { conn: Arc>>>, permit: Option, - transaction_open: AtomicBool, } #[derive(Debug)] @@ -155,7 +152,6 @@ impl Drop for DbConn { fn drop(&mut self) { let conn = Arc::clone(&self.conn); let permit = self.permit.take(); - let transaction_open = self.transaction_open.load(Ordering::Acquire); // Since connection can't be on the stack in an async fn during an // await, we have to spawn a new blocking-safe thread... @@ -163,13 +159,7 @@ impl Drop for DbConn { // And then re-enter the runtime to wait on the async mutex, but in a blocking fashion. let mut conn = tokio::runtime::Handle::current().block_on(conn.lock_owned()); - if let Some(mut conn) = conn.take() { - if transaction_open { - // A cancelled request must not return an open transaction to the pool. - if let Err(error) = conn.batch_execute("ROLLBACK") { - error!("Failed to roll back a cancelled database transaction: {error}"); - } - } + if let Some(conn) = conn.take() { drop(conn); } @@ -224,8 +214,8 @@ impl DbPool { let pool = Pool::builder() .max_size(max_conns) .min_idle(Some(CONFIG.database_min_conns())) - .idle_timeout(Some(Duration::from_secs(u64::from(CONFIG.database_idle_timeout())))) - .connection_timeout(Duration::from_secs(u64::from(CONFIG.database_timeout()))) + .idle_timeout(Some(Duration::from_secs(CONFIG.database_idle_timeout()))) + .connection_timeout(Duration::from_secs(CONFIG.database_timeout())) .connection_customizer(Box::new(DbConnOptions { init_stmts: conn_type.get_init_stmts(), })) @@ -234,7 +224,7 @@ impl DbPool { // Set a global to determine the database more easily throughout the rest of the code if ACTIVE_DB_TYPE.set(conn_type).is_err() { - error!("Tried to set the active database connection type more than once."); + error!("Tried to set the active database connection type more than once.") } Ok(DbPool { @@ -245,7 +235,7 @@ impl DbPool { // Get a connection from the pool pub async fn get(&self) -> Result { - let duration = Duration::from_secs(u64::from(CONFIG.database_timeout())); + let duration = Duration::from_secs(CONFIG.database_timeout()); let permit = match timeout(duration, Arc::clone(&self.semaphore).acquire_owned()).await { Ok(p) => p.expect("Semaphore should be open"), Err(_) => { @@ -260,7 +250,6 @@ impl DbPool { Ok(DbConn { conn: Arc::new(Mutex::new(Some(c))), permit: Some(permit), - transaction_open: AtomicBool::new(false), }) } } @@ -283,40 +272,22 @@ impl DbConnType { #[cfg(not(postgresql))] err!("`DATABASE_URL` is a PostgreSQL URL, but the 'postgresql' feature is not enabled") - // Sqlite (explicit) - } else if url.len() > 7 && &url[..7] == "sqlite:" { + //Sqlite + } else { #[cfg(sqlite)] return Ok(DbConnType::Sqlite); #[cfg(not(sqlite))] - err!("`DATABASE_URL` is a SQLite URL, but the 'sqlite' feature is not enabled") + err!("`DATABASE_URL` looks like a SQLite URL, but 'sqlite' feature is not enabled") } - - // No recognized scheme — assume legacy bare-path SQLite, but the database file must already exist. - // This prevents misconfigured URLs (typos, quoted strings) from silently creating a new empty SQLite database. - #[cfg(sqlite)] - { - if std::path::Path::new(url).exists() { - return Ok(DbConnType::Sqlite); - } - err!(format!( - "`DATABASE_URL` does not match any known database scheme (mysql://, postgresql://, sqlite://) \ - and no existing SQLite database was found at '{url}'. \ - If you intend to use SQLite, use an explicit `sqlite://` scheme in your `DATABASE_URL`. \ - Otherwise, check your DATABASE_URL for typos or quoting issues." - )) - } - - #[cfg(not(sqlite))] - err!("`DATABASE_URL` does not match any known database scheme (mysql://, postgresql://, sqlite://)") } pub fn get_init_stmts(&self) -> String { let init_stmts = CONFIG.database_conn_init(); - if init_stmts.is_empty() { - self.default_init_stmts() - } else { + if !init_stmts.is_empty() { init_stmts + } else { + self.default_init_stmts() } } @@ -327,35 +298,12 @@ impl DbConnType { #[cfg(postgresql)] Self::Postgresql => String::new(), #[cfg(sqlite)] - Self::Sqlite => "PRAGMA busy_timeout = 5000; PRAGMA synchronous = NORMAL;".to_owned(), + Self::Sqlite => "PRAGMA busy_timeout = 5000; PRAGMA synchronous = NORMAL;".to_string(), } } } impl DbConn { - /// Run asynchronous model calls on this request's pooled connection in one transaction. - /// Send external notifications only after this returns successfully. - pub async fn transaction(&self, operation: F) -> Result - where - F: FnOnce() -> Fut, - Fut: Future>, - { - self.run(|conn| conn.batch_execute("BEGIN")).await.map_res("Error starting database transaction")?; - self.transaction_open.store(true, Ordering::Release); - let result = operation().await; - if result.is_ok() { - let committed = self.run(|conn| conn.batch_execute("COMMIT")).await; - let closed = committed.is_ok() || self.run(|conn| conn.batch_execute("ROLLBACK")).await.is_ok(); - if closed { - self.transaction_open.store(false, Ordering::Release); - } - committed.map_res("Error committing database transaction")?; - } else if self.run(|conn| conn.batch_execute("ROLLBACK")).await.is_ok() { - self.transaction_open.store(false, Ordering::Release); - } - result - } - pub async fn run(&self, f: F) -> R where F: FnOnce(&mut DbConnInner) -> R + Send, @@ -441,13 +389,12 @@ pub fn backup_sqlite() -> Result { use diesel::Connection; let db_url = CONFIG.database_url(); - if DbConnType::from_url(&CONFIG.database_url()).is_ok_and(|t| t == DbConnType::Sqlite) { - // Strip the sqlite:// prefix if present to get the raw file path - let file_path = db_url.strip_prefix("sqlite://").unwrap_or(&db_url); - // Open a read-only connection for the backup - let mut conn = diesel::sqlite::SqliteConnection::establish(&format!("sqlite://{file_path}?mode=ro"))?; + if DbConnType::from_url(&CONFIG.database_url()).map(|t| t == DbConnType::Sqlite).unwrap_or(false) { + // Since we do not allow any schema for sqlite database_url's like `file:` or `sqlite:` to be set, we can assume here it isn't + // This way we can set a readonly flag on the opening mode without issues. + let mut conn = diesel::sqlite::SqliteConnection::establish(&format!("sqlite://{db_url}?mode=ro"))?; - let db_path = std::path::Path::new(file_path).parent().unwrap(); + let db_path = std::path::Path::new(&db_url).parent().unwrap(); let backup_file = db_path .join(format!("db_{}.sqlite3", chrono::Utc::now().format("%Y%m%d_%H%M%S"))) .to_string_lossy() @@ -476,12 +423,12 @@ pub async fn get_sql_server_version(conn: &DbConn) -> String { postgresql,mysql { diesel::select(diesel::dsl::sql::("version();")) .get_result::(conn) - .unwrap_or_else(|_| "Unknown".to_owned()) + .unwrap_or_else(|_| "Unknown".to_string()) } sqlite { diesel::select(diesel::dsl::sql::("sqlite_version();")) .get_result::(conn) - .unwrap_or_else(|_| "Unknown".to_owned()) + .unwrap_or_else(|_| "Unknown".to_string()) } } } @@ -570,192 +517,3 @@ mod postgresql_migrations { Ok(()) } } - -/// A throwaway SQLite database with the real schema, for tests that need to run queries. -#[cfg(all(test, sqlite))] -pub mod test_db { - use super::{DbConn, DbConnManager, sqlite_migrations::MIGRATIONS}; - use diesel::{Connection, connection::SimpleConnection, r2d2::Pool, sqlite::SqliteConnection}; - use diesel_migrations::MigrationHarness; - use std::sync::{Arc, atomic::AtomicBool}; - use tokio::sync::Mutex; - - pub struct TestDb { - pool: Pool, - // Keep the named in-memory database alive across pooled connections. - _keeper: SqliteConnection, - } - - impl TestDb { - fn new() -> Self { - let url = format!("sqlite://{}?mode=memory&cache=shared", crate::util::get_uuid()); - let mut keeper = SqliteConnection::establish(&url).unwrap(); - keeper.run_pending_migrations(MIGRATIONS).unwrap(); - let pool = Pool::builder().max_size(2).build(DbConnManager::new(&url)).unwrap(); - Self { - pool, - _keeper: keeper, - } - } - - pub fn conn(&self) -> DbConn { - DbConn { - conn: Arc::new(Mutex::new(Some(self.pool.get().unwrap()))), - permit: None, - transaction_open: AtomicBool::new(false), - } - } - } - - // CONFIG must initialize in a child process: the DNS bootstrap test needs it uninitialized. - pub fn run(test: impl AsyncFnOnce(&TestDb)) { - if std::env::var_os("VAULTWARDEN_ISOLATED_CONFIG_TEST").is_none() { - let output = std::process::Command::new(std::env::current_exe().unwrap()) - .args(["--exact", std::thread::current().name().unwrap(), "--nocapture"]) - .env("VAULTWARDEN_ISOLATED_CONFIG_TEST", "1") - .env("DATABASE_URL", "sqlite://:memory:") - .output() - .unwrap(); - assert!( - output.status.success() && String::from_utf8_lossy(&output.stdout).contains("1 passed; 0 failed"), - "{}\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - return; - } - // DbConn::run uses block_in_place, requiring a multi-threaded runtime. - tokio::runtime::Builder::new_multi_thread().enable_all().build().unwrap().block_on(async { - test(&TestDb::new()).await; - }); - } - - #[test] - fn custom_role_migration_preserves_memberships_and_guards_downgrade() { - use super::schema::users_organizations::dsl::*; - use diesel::{migration::MigrationSource, prelude::*}; - let mut conn = SqliteConnection::establish(":memory:").unwrap(); - for migration in conn.pending_migrations(MIGRATIONS).unwrap() { - if migration.name().version().to_string() == "20261010120000" { - break; - } - conn.run_migration(&migration).unwrap(); - } - conn.batch_execute( - "PRAGMA foreign_keys=OFF; - INSERT INTO organizations (uuid,name,billing_email) VALUES ('org','Org','a@b'); - INSERT INTO users_organizations (uuid,user_uuid,org_uuid,access_all,akey,status,atype) VALUES - ('0','0','org',TRUE,'key',2,0), ('1','1','org',TRUE,'key',2,1), - ('2','2','org',FALSE,'key',1,2), ('3','3','org',FALSE,'key',2,3), ('4','4','org',TRUE,'key',2,3);", - ) - .unwrap(); - let rows = |conn: &mut SqliteConnection| { - users_organizations - .select((uuid, user_uuid, org_uuid, access_all, akey, status, atype)) - .order(uuid) - .load::<(String, String, String, bool, String, i32, i32)>(conn) - .unwrap() - }; - let before = rows(&mut conn); - conn.run_pending_migrations(MIGRATIONS).unwrap(); - assert_eq!(rows(&mut conn), before); - let grants = |conn: &mut SqliteConnection| { - diesel::select(diesel::dsl::sql::( - "(SELECT COUNT(*) FROM users_organizations WHERE manage_users OR manage_groups OR manage_policies - OR create_new_collections OR edit_any_collection OR delete_any_collection - OR access_event_logs OR access_import_export OR access_reports)", - )) - .get_result::(conn) - .unwrap() - }; - assert_eq!(grants(&mut conn), 0); - let migration = MigrationSource::::migrations(&MIGRATIONS) - .unwrap() - .into_iter() - .find(|m| m.name().version().to_string() == "20261010120000") - .unwrap(); - for member_status in [1, 2] { - conn.batch_execute(&format!( - "UPDATE users_organizations SET atype=4,manage_users=TRUE,status={member_status} WHERE uuid='2'" - )) - .unwrap(); - let custom = rows(&mut conn); - assert!(conn.revert_migration(&*migration).is_err()); - assert_eq!(rows(&mut conn), custom); - assert!(users_organizations.filter(uuid.eq("2")).select(manage_users).first::(&mut conn).unwrap()); - } - conn.batch_execute("UPDATE users_organizations SET atype=2,manage_users=FALSE,status=1 WHERE uuid='2'") - .unwrap(); - conn.revert_migration(&*migration).unwrap(); - assert!(diesel::sql_query("SELECT manage_users FROM users_organizations").execute(&mut conn).is_err()); - conn.run_pending_migrations(MIGRATIONS).unwrap(); - assert_eq!(grants(&mut conn), 0); - assert_eq!(rows(&mut conn), before); - } - - use super::models::{Collection, CustomPermission, Membership, MembershipType, Organization, OrganizationId, User}; - - pub async fn organization(conn: &DbConn) -> Organization { - let org = Organization::new("Org".into(), "billing@example.com", None, None); - org.save(conn).await.unwrap(); - org - } - - pub async fn member( - conn: &DbConn, - org: &OrganizationId, - role: MembershipType, - permissions: &[CustomPermission], - ) -> (User, Membership) { - let mut user = User::new(&format!("{}@example.com", crate::util::get_uuid()), None); - user.password_hash = b"existing-account".to_vec(); - user.save(conn).await.unwrap(); - let mut member = Membership::new(user.uuid.clone(), org.clone(), None); - member.set_type(role); - member.status = 2; - member.set_custom_permissions(&permissions.iter().copied().collect()); - member.save(conn).await.unwrap(); - (user, member) - } - - pub async fn custom_member(conn: &DbConn, permission: CustomPermission) -> (Organization, User, Membership) { - let org = organization(conn).await; - let (user, member) = member(conn, &org.uuid, MembershipType::Custom, &[permission]).await; - (org, user, member) - } - - pub async fn collection(conn: &DbConn, org: &OrganizationId) -> Collection { - let collection = Collection::new(org.clone(), "Collection".into(), None); - collection.save(conn).await.unwrap(); - collection - } - - #[test] - fn transactions_rollback_before_connection_reuse_and_commit_success() { - use crate::error::MapResult; - use diesel::RunQueryDsl; - run(async |db| { - let conn = db.conn(); - conn.run(|c| c.batch_execute("CREATE TABLE probe (id INTEGER PRIMARY KEY)")).await.unwrap(); - for (sql, expected) in [ - ("INSERT INTO probe VALUES (1); INSERT INTO probe VALUES (1)", 0_i64), - ("INSERT INTO probe VALUES (1)", 1), - ] { - let result: Result<(), super::Error> = conn - .transaction(async || conn.run(move |c| c.batch_execute(sql)).await.map_res("Insert failed")) - .await; - assert_eq!(result.is_ok(), expected == 1); - let count: i64 = conn - .run(|c| { - diesel::select(diesel::dsl::sql::("(SELECT COUNT(*) FROM probe)")) - .get_result(c) - }) - .await - .unwrap(); - assert_eq!(count, expected); - // The same live connection must accept another transaction, before DbConn::drop can help. - conn.run(|c| c.batch_execute("BEGIN; ROLLBACK")).await.unwrap(); - } - }); - } -} diff --git a/src/db/models/archive.rs b/src/db/models/archive.rs deleted file mode 100644 index 2330fac4..00000000 --- a/src/db/models/archive.rs +++ /dev/null @@ -1,98 +0,0 @@ -use chrono::NaiveDateTime; -use diesel::prelude::*; - -use crate::{ - api::EmptyResult, - db::{DbConn, schema::archives}, - error::MapResult, -}; - -use super::{CipherId, User, UserId}; - -#[derive(Identifiable, Queryable, Insertable)] -#[diesel(table_name = archives)] -#[diesel(primary_key(user_uuid, cipher_uuid))] -pub struct Archive { - pub user_uuid: UserId, - pub cipher_uuid: CipherId, - pub archived_at: NaiveDateTime, -} - -impl Archive { - // Returns the date the specified cipher was archived - pub async fn get_archived_at(cipher_uuid: &CipherId, user_uuid: &UserId, conn: &DbConn) -> Option { - conn.run(move |conn| { - archives::table - .filter(archives::cipher_uuid.eq(cipher_uuid)) - .filter(archives::user_uuid.eq(user_uuid)) - .select(archives::archived_at) - .first::(conn) - .ok() - }) - .await - } - - // Saves (inserts or updates) an archive record with the provided timestamp - pub async fn save( - user_uuid: &UserId, - cipher_uuid: &CipherId, - archived_at: NaiveDateTime, - conn: &DbConn, - ) -> EmptyResult { - User::update_uuid_revision(user_uuid, conn).await; - db_run! { conn: - mysql { - diesel::insert_into(archives::table) - .values(( - archives::user_uuid.eq(user_uuid), - archives::cipher_uuid.eq(cipher_uuid), - archives::archived_at.eq(archived_at), - )) - .on_conflict(diesel::dsl::DuplicatedKeys) - .do_update() - .set(archives::archived_at.eq(archived_at)) - .execute(conn) - .map_res("Error saving archive") - } - postgresql, sqlite { - diesel::insert_into(archives::table) - .values(( - archives::user_uuid.eq(user_uuid), - archives::cipher_uuid.eq(cipher_uuid), - archives::archived_at.eq(archived_at), - )) - .on_conflict((archives::user_uuid, archives::cipher_uuid)) - .do_update() - .set(archives::archived_at.eq(archived_at)) - .execute(conn) - .map_res("Error saving archive") - } - } - } - - // Deletes an archive record for a specific cipher - pub async fn delete_by_cipher(user_uuid: &UserId, cipher_uuid: &CipherId, conn: &DbConn) -> EmptyResult { - User::update_uuid_revision(user_uuid, conn).await; - conn.run(move |conn| { - diesel::delete( - archives::table.filter(archives::user_uuid.eq(user_uuid)).filter(archives::cipher_uuid.eq(cipher_uuid)), - ) - .execute(conn) - .map_res("Error deleting archive") - }) - .await - } - - /// Return a vec with (cipher_uuid, archived_at) - /// This is used during a full sync so we only need one query for all archive matches - pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<(CipherId, NaiveDateTime)> { - conn.run(move |conn| { - archives::table - .filter(archives::user_uuid.eq(user_uuid)) - .select((archives::cipher_uuid, archives::archived_at)) - .load::<(CipherId, NaiveDateTime)>(conn) - .unwrap_or_default() - }) - .await - } -} diff --git a/src/db/models/attachment.rs b/src/db/models/attachment.rs index 659648d0..4273c22a 100644 --- a/src/db/models/attachment.rs +++ b/src/db/models/attachment.rs @@ -1,24 +1,13 @@ -use std::time::Duration; - use bigdecimal::{BigDecimal, ToPrimitive}; use derive_more::{AsRef, Deref, Display}; use diesel::prelude::*; use serde_json::Value; - -use crate::{ - CONFIG, - api::EmptyResult, - auth::{encode_jwt, generate_file_download_claims}, - config::PathType, - db::{ - DbConn, - schema::{attachments, ciphers}, - }, - error::MapResult, -}; -use macros::IdFromParam; +use std::time::Duration; use super::{CipherId, OrganizationId, UserId}; +use crate::db::schema::{attachments, ciphers}; +use crate::{config::PathType, CONFIG}; +use macros::IdFromParam; #[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[diesel(table_name = attachments)] @@ -57,11 +46,11 @@ impl Attachment { pub async fn get_url(&self, host: &str) -> Result { let operator = CONFIG.opendal_operator_for_path_type(&PathType::Attachments)?; - if crate::storage::is_fs_operator(&operator) { + if operator.info().scheme() == <&'static str>::from(opendal::Scheme::Fs) { let token = encode_jwt(&generate_file_download_claims(self.cipher_uuid.clone(), self.id.clone())); Ok(format!("{host}/attachments/{}/{}?token={token}", self.cipher_uuid, self.id)) } else { - Ok(operator.presign_read(&self.get_file_path(), Duration::from_mins(1)).await?.uri().to_string()) + Ok(operator.presign_read(&self.get_file_path(), Duration::from_secs(5 * 60)).await?.uri().to_string()) } } @@ -78,20 +67,34 @@ impl Attachment { } } +use crate::auth::{encode_jwt, generate_file_download_claims}; +use crate::db::DbConn; + +use crate::api::EmptyResult; +use crate::error::MapResult; + /// Database methods impl Attachment { pub async fn save(&self, conn: &DbConn) -> EmptyResult { db_run! { conn: - mysql { - diesel::insert_into(attachments::table) + sqlite, mysql { + match diesel::replace_into(attachments::table) .values(self) - .on_conflict(diesel::dsl::DuplicatedKeys) - .do_update() - .set(self) .execute(conn) - .map_res("Error saving attachment") + { + Ok(_) => Ok(()), + // Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first. + Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { + diesel::update(attachments::table) + .filter(attachments::id.eq(&self.id)) + .set(self) + .execute(conn) + .map_res("Error saving attachment") + } + Err(e) => Err(e.into()), + }.map_res("Error saving attachment") } - postgresql, sqlite { + postgresql { diesel::insert_into(attachments::table) .values(self) .on_conflict(attachments::id) @@ -104,15 +107,15 @@ impl Attachment { } pub async fn delete(&self, conn: &DbConn) -> EmptyResult { - conn.run(move |conn| { - crate::util::retry( - || diesel::delete(attachments::table.filter(attachments::id.eq(&self.id))).execute(conn), + db_run! { conn: { + crate::util::retry(|| + diesel::delete(attachments::table.filter(attachments::id.eq(&self.id))) + .execute(conn), 10, ) .map(|_| ()) .map_res("Error deleting attachment") - }) - .await?; + }}?; let operator = CONFIG.opendal_operator_for_path_type(&PathType::Attachments)?; let file_path = self.get_file_path(); @@ -136,22 +139,25 @@ impl Attachment { } pub async fn find_by_id(id: &AttachmentId, conn: &DbConn) -> Option { - conn.run(move |conn| attachments::table.filter(attachments::id.eq(id.to_lowercase())).first::(conn).ok()) - .await + db_run! { conn: { + attachments::table + .filter(attachments::id.eq(id.to_lowercase())) + .first::(conn) + .ok() + }} } pub async fn find_by_cipher(cipher_uuid: &CipherId, conn: &DbConn) -> Vec { - conn.run(move |conn| { + db_run! { conn: { attachments::table .filter(attachments::cipher_uuid.eq(cipher_uuid)) .load::(conn) .expect("Error loading attachments") - }) - .await + }} } pub async fn size_by_user(user_uuid: &UserId, conn: &DbConn) -> i64 { - conn.run(move |conn| { + db_run! { conn: { let result: Option = attachments::table .left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid))) .filter(ciphers::user_uuid.eq(user_uuid)) @@ -162,26 +168,24 @@ impl Attachment { match result.map(|r| r.to_i64()) { Some(Some(r)) => r, Some(None) => i64::MAX, - None => 0, + None => 0 } - }) - .await + }} } pub async fn count_by_user(user_uuid: &UserId, conn: &DbConn) -> i64 { - conn.run(move |conn| { + db_run! { conn: { attachments::table .left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid))) .filter(ciphers::user_uuid.eq(user_uuid)) .count() .first(conn) .unwrap_or(0) - }) - .await + }} } pub async fn size_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> i64 { - conn.run(move |conn| { + db_run! { conn: { let result: Option = attachments::table .left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid))) .filter(ciphers::organization_uuid.eq(org_uuid)) @@ -192,22 +196,20 @@ impl Attachment { match result.map(|r| r.to_i64()) { Some(Some(r)) => r, Some(None) => i64::MAX, - None => 0, + None => 0 } - }) - .await + }} } pub async fn count_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> i64 { - conn.run(move |conn| { + db_run! { conn: { attachments::table .left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid))) .filter(ciphers::organization_uuid.eq(org_uuid)) .count() .first(conn) .unwrap_or(0) - }) - .await + }} } // This will return all attachments linked to the user or org @@ -218,7 +220,7 @@ impl Attachment { org_uuids: &Vec, conn: &DbConn, ) -> Vec { - conn.run(move |conn| { + db_run! { conn: { attachments::table .left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid))) .filter(ciphers::user_uuid.eq(user_uuid)) @@ -226,8 +228,7 @@ impl Attachment { .select(attachments::all_columns) .load::(conn) .expect("Error loading attachments") - }) - .await + }} } } diff --git a/src/db/models/auth_request.rs b/src/db/models/auth_request.rs index f2b0ecdc..93c6e445 100644 --- a/src/db/models/auth_request.rs +++ b/src/db/models/auth_request.rs @@ -1,18 +1,11 @@ +use super::{DeviceId, OrganizationId, UserId}; +use crate::db::schema::auth_requests; +use crate::{crypto::ct_eq, util::format_date}; use chrono::{NaiveDateTime, Utc}; use derive_more::{AsRef, Deref, Display, From}; use diesel::prelude::*; -use serde_json::Value; - -use crate::{ - api::{ApiResult, EmptyResult}, - crypto::ct_eq, - db::{DbConn, schema::auth_requests}, - error::MapResult, - util::format_date, -}; use macros::UuidFromParam; - -use super::{DeviceId, OrganizationId, UserId}; +use serde_json::Value; #[derive(Identifiable, Queryable, Insertable, AsChangeset, Deserialize, Serialize)] #[diesel(table_name = auth_requests)] @@ -81,24 +74,37 @@ impl AuthRequest { } } +use crate::db::DbConn; + +use crate::api::EmptyResult; +use crate::error::MapResult; + impl AuthRequest { - pub async fn save(&self, conn: &DbConn) -> EmptyResult { + pub async fn save(&mut self, conn: &DbConn) -> EmptyResult { db_run! { conn: - mysql { - diesel::insert_into(auth_requests::table) - .values(self) - .on_conflict(diesel::dsl::DuplicatedKeys) - .do_update() - .set(self) + sqlite, mysql { + match diesel::replace_into(auth_requests::table) + .values(&*self) .execute(conn) - .map_res("Error saving auth_request") + { + Ok(_) => Ok(()), + // Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first. + Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { + diesel::update(auth_requests::table) + .filter(auth_requests::uuid.eq(&self.uuid)) + .set(&*self) + .execute(conn) + .map_res("Error auth_request") + } + Err(e) => Err(e.into()), + }.map_res("Error auth_request") } - postgresql, sqlite { + postgresql { diesel::insert_into(auth_requests::table) - .values(self) + .values(&*self) .on_conflict(auth_requests::uuid) .do_update() - .set(self) + .set(&*self) .execute(conn) .map_res("Error saving auth_request") } @@ -106,28 +112,31 @@ impl AuthRequest { } pub async fn find_by_uuid(uuid: &AuthRequestId, conn: &DbConn) -> Option { - conn.run(move |conn| auth_requests::table.filter(auth_requests::uuid.eq(uuid)).first::(conn).ok()).await + db_run! { conn: { + auth_requests::table + .filter(auth_requests::uuid.eq(uuid)) + .first::(conn) + .ok() + }} } pub async fn find_by_uuid_and_user(uuid: &AuthRequestId, user_uuid: &UserId, conn: &DbConn) -> Option { - conn.run(move |conn| { + db_run! { conn: { auth_requests::table .filter(auth_requests::uuid.eq(uuid)) .filter(auth_requests::user_uuid.eq(user_uuid)) .first::(conn) .ok() - }) - .await + }} } pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { - conn.run(move |conn| { + db_run! { conn: { auth_requests::table .filter(auth_requests::user_uuid.eq(user_uuid)) .load::(conn) .expect("Error loading auth_requests") - }) - .await + }} } pub async fn find_by_user_and_requested_device( @@ -135,7 +144,7 @@ impl AuthRequest { device_uuid: &DeviceId, conn: &DbConn, ) -> Option { - conn.run(move |conn| { + db_run! { conn: { auth_requests::table .filter(auth_requests::user_uuid.eq(user_uuid)) .filter(auth_requests::request_device_identifier.eq(device_uuid)) @@ -143,51 +152,24 @@ impl AuthRequest { .order_by(auth_requests::creation_date.desc()) .first::(conn) .ok() - }) - .await + }} } pub async fn find_created_before(dt: &NaiveDateTime, conn: &DbConn) -> Vec { - conn.run(move |conn| { + db_run! { conn: { auth_requests::table .filter(auth_requests::creation_date.lt(dt)) .load::(conn) .expect("Error loading auth_requests") - }) - .await + }} } pub async fn delete(&self, conn: &DbConn) -> EmptyResult { - conn.run(move |conn| { + db_run! { conn: { diesel::delete(auth_requests::table.filter(auth_requests::uuid.eq(&self.uuid))) .execute(conn) .map_res("Error deleting auth request") - }) - .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 { - 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 { @@ -197,7 +179,7 @@ impl AuthRequest { pub async fn purge_expired_auth_requests(conn: &DbConn) { // delete auth requests older than 15 minutes which is functionally equivalent to upstream: // https://github.com/bitwarden/server/blob/f8ee2270409f7a13125cd414c450740af605a175/src/Sql/dbo/Auth/Stored%20Procedures/AuthRequest_DeleteIfExpired.sql - let expiry_time = Utc::now().naive_utc() - chrono::TimeDelta::minutes(15); + let expiry_time = Utc::now().naive_utc() - chrono::TimeDelta::try_minutes(15).unwrap(); for auth_request in Self::find_created_before(&expiry_time, conn).await { auth_request.delete(conn).await.ok(); } diff --git a/src/db/models/cipher.rs b/src/db/models/cipher.rs index e940511c..edc5f8c9 100644 --- a/src/db/models/cipher.rs +++ b/src/db/models/cipher.rs @@ -1,32 +1,22 @@ -use std::{borrow::Cow, collections::HashSet}; - +use crate::db::schema::{ + ciphers, ciphers_collections, collections, collections_groups, folders, folders_ciphers, groups, groups_users, + users_collections, users_organizations, +}; +use crate::util::LowerCase; +use crate::CONFIG; use chrono::{NaiveDateTime, TimeDelta, Utc}; use derive_more::{AsRef, Deref, Display, From}; use diesel::prelude::*; use serde_json::Value; -use crate::{ - CONFIG, - api::{ - EmptyResult, - core::{CipherData, CipherSyncData, CipherSyncType}, - }, - db::{ - DbConn, - schema::{ - ciphers, ciphers_collections, collections, collections_groups, folders, folders_ciphers, groups, - groups_users, users_collections, users_organizations, - }, - }, - error::MapResult, - util::{LowerCase, convert_json_key_lcase_first}, +use super::{ + Attachment, CollectionCipher, CollectionId, Favorite, FolderCipher, FolderId, Group, Membership, MembershipStatus, + MembershipType, OrganizationId, User, UserId, }; +use crate::api::core::{CipherData, CipherSyncData, CipherSyncType}; use macros::UuidFromParam; -use super::{ - Archive, Attachment, CollectionCipher, CollectionId, Favorite, FolderCipher, FolderId, Group, Membership, - MembershipStatus, OrganizationId, User, UserId, organization::ORG_ADMIN_ATYPES, -}; +use std::borrow::Cow; #[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[diesel(table_name = ciphers)] @@ -42,15 +32,13 @@ pub struct Cipher { pub key: Option, - // See (v2026.7.0): https://github.com/bitwarden/server/blob/5d4461aa42cadbacfef8fe2166c5453a5c52773a/src/Core/Vault/Enums/CipherType.cs - // Login = 1, - // SecureNote = 2, - // Card = 3, - // Identity = 4, - // SSHKey = 5 - // BankAccount = 6, - // DriversLicense = 7, - // Passport = 8, + /* + Login = 1, + SecureNote = 2, + Card = 3, + Identity = 4, + SshKey = 5 + */ pub atype: i32, pub name: String, pub notes: Option, @@ -63,68 +51,14 @@ pub struct Cipher { pub reprompt: Option, } -/// Whether `data` is a v2 cipher blob, recognized like upstream by a top-level `format_version` key. -/// -/// Ref: -pub fn is_data_blob_encrypted(data: &str) -> bool { - serde_json::from_str::(data).is_ok_and(|d| is_blob_value(&d)) -} - -/// [`is_data_blob_encrypted`] for `data` that was already parsed. -fn is_blob_value(data: &Value) -> bool { - data.get("format_version").is_some() -} - pub enum RepromptType { None = 0, Password = 1, } -/// Which access a cipher operation is authorized with. -/// -/// The administrative cipher routes (`/ciphers//admin` and friends) are served by the same handlers as the -/// regular vault routes, so the handlers state which one applies. They only differ for a Custom member with Edit -/// any collection, who reaches every cipher of the organization on the administrative routes, but keeps their -/// own vault limited to the collections they are assigned to. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum CipherAccessScope { - User, - OrganizationAdmin, -} - -impl CipherAccessScope { - /// Whether `member` reaches every cipher of their organization with this access. - fn reaches_all_ciphers(self, member: &Membership) -> bool { - match self { - CipherAccessScope::User => member.has_full_access(), - CipherAccessScope::OrganizationAdmin => member.can_edit_any_collection(), - } - } - - /// The access the v2 attachment upload asks for with `adminRequest`. It only picks which check runs, the - /// administrative one still needs a member who may edit any collection. - pub fn requested(admin_request: Option) -> Self { - if admin_request == Some(true) { - CipherAccessScope::OrganizationAdmin - } else { - CipherAccessScope::User - } - } - - /// The access for a route that does not tell which one it is, from the member's own membership. - pub fn for_member(member: Option<&Membership>) -> Self { - if member.is_some_and(Membership::can_edit_any_collection) { - CipherAccessScope::OrganizationAdmin - } else { - CipherAccessScope::User - } - } -} - /// Local methods impl Cipher { - /// The name is set when the data is saved, since a blob-encrypted cipher has it inside `data` - pub fn new(atype: i32) -> Self { + pub fn new(atype: i32, name: String) -> Self { let now = Utc::now().naive_utc(); Self { @@ -138,7 +72,7 @@ impl Cipher { key: None, atype, - name: String::new(), + name, notes: None, fields: None, @@ -157,37 +91,27 @@ impl Cipher { format!("The field Notes exceeds the maximum encrypted value length of {max_note_size} characters."); for (index, cipher) in cipher_data.iter().enumerate() { // Validate the note size and if it is exceeded return a warning - if let Some(note) = &cipher.notes - && note.len() > max_note_size - { - validation_errors - .insert(format!("Ciphers[{index}].Notes"), serde_json::to_value([&max_note_size_msg]).unwrap()); - } - - let is_blob = cipher.is_blob(); - if let Err(e) = cipher.validate_content(is_blob) { - validation_errors - .insert(format!("Ciphers[{index}].{}", e.field), serde_json::to_value([e.message]).unwrap()); - } - if let Err(e) = cipher.validate_type_data(is_blob) { - validation_errors - .insert(format!("Ciphers[{index}].Type"), serde_json::to_value([e.message()]).unwrap()); + if let Some(note) = &cipher.notes { + if note.len() > max_note_size { + validation_errors + .insert(format!("Ciphers[{index}].Notes"), serde_json::to_value([&max_note_size_msg]).unwrap()); + } } // Validate the password history if it contains `null` values and if so, return a warning if let Some(Value::Array(password_history)) = &cipher.password_history { for pwh in password_history { - if let Value::Object(pwo) = pwh - && pwo.get("password").is_some_and(|p| !p.is_string()) - { - validation_errors.insert( - format!("Ciphers[{index}].Notes"), - serde_json::to_value([ - "The password history contains a `null` value. Only strings are allowed.", - ]) - .unwrap(), - ); - break; + if let Value::Object(pwo) = pwh { + if pwo.get("password").is_some_and(|p| !p.is_string()) { + validation_errors.insert( + format!("Ciphers[{index}].Notes"), + serde_json::to_value([ + "The password history contains a `null` value. Only strings are allowed.", + ]) + .unwrap(), + ); + break; + } } } } @@ -200,12 +124,17 @@ impl Cipher { "object": "error" }); err_json!(err_json, "Import validation errors") + } else { + Ok(()) } - - Ok(()) } } +use crate::db::DbConn; + +use crate::api::EmptyResult; +use crate::error::MapResult; + /// Database methods impl Cipher { pub async fn to_json( @@ -215,46 +144,19 @@ impl Cipher { cipher_sync_data: Option<&CipherSyncData>, sync_type: CipherSyncType, conn: &DbConn, - ) -> Result { - self.to_json_scoped(host, user_uuid, cipher_sync_data, sync_type, CipherAccessScope::User, conn).await - } - - /// [`Cipher::to_json`] for an administrative route, telling what the member may do with the cipher there. - pub async fn to_json_org_admin( - &self, - host: &str, - user_uuid: &UserId, - conn: &DbConn, - ) -> Result { - self.to_json_scoped(host, user_uuid, None, CipherSyncType::User, CipherAccessScope::OrganizationAdmin, conn) - .await - } - - async fn to_json_scoped( - &self, - host: &str, - user_uuid: &UserId, - cipher_sync_data: Option<&CipherSyncData>, - sync_type: CipherSyncType, - scope: CipherAccessScope, - conn: &DbConn, ) -> Result { use crate::util::{format_date, validate_and_format_date}; - // Parsed once, since `data` can be large and this runs for every cipher in a sync - let type_data = serde_json::from_str::(&self.data); - let is_blob_encrypted = type_data.as_ref().is_ok_and(is_blob_value); - let mut attachments_json: Value = Value::Null; if let Some(cipher_sync_data) = cipher_sync_data { - if let Some(attachments) = cipher_sync_data.cipher_attachments.get(&self.uuid) - && !attachments.is_empty() - { - let mut attachments_json_vec = vec![]; - for attachment in attachments { - attachments_json_vec.push(attachment.to_json(host).await?); + if let Some(attachments) = cipher_sync_data.cipher_attachments.get(&self.uuid) { + if !attachments.is_empty() { + let mut attachments_json_vec = vec![]; + for attachment in attachments { + attachments_json_vec.push(attachment.to_json(host).await?); + } + attachments_json = Value::Array(attachments_json_vec); } - attachments_json = Value::Array(attachments_json_vec); } } else { let attachments = Attachment::find_by_cipher(&self.uuid, conn).await; @@ -270,11 +172,12 @@ impl Cipher { // We don't need these values at all for Organizational syncs // Skip any other database calls if this is the case and just return false. let (read_only, hide_passwords, _) = if sync_type == CipherSyncType::User { - if let Some((ro, hp, mn)) = self.get_access_restrictions(user_uuid, scope, cipher_sync_data, conn).await { - (ro, hp, mn) - } else { - error!("Cipher ownership assertion failure"); - (true, true, false) + match self.get_access_restrictions(user_uuid, cipher_sync_data, conn).await { + Some((ro, hp, mn)) => (ro, hp, mn), + None => { + error!("Cipher ownership assertion failure"); + (true, true, false) + } } } else { (false, false, false) @@ -328,28 +231,95 @@ impl Cipher { Some(p) if p.is_string() => Some(d.data), _ => None, }) - .map(|mut d| { - let lud = if let Some(l) = d.get("lastUsedDate").and_then(|l| l.as_str()) { - validate_and_format_date(l) - } else { - "1970-01-01T00:00:00.000000Z".to_owned() - }; - d["lastUsedDate"] = json!(lud); - d + .map(|mut d| match d.get("lastUsedDate").and_then(|l| l.as_str()) { + Some(l) => { + d["lastUsedDate"] = json!(validate_and_format_date(l)); + d + } + _ => { + d["lastUsedDate"] = json!("1970-01-01T00:00:00.000000Z"); + d + } }) .collect() }) .unwrap_or_default(); - // Like upstream, fields and password history stored as NULL are sent as null, not `[]` - let fields_json = self.fields.is_some().then_some(fields_json); - let password_history_json = self.password_history.is_some().then_some(password_history_json); + // Get the type_data or a default to an empty json object '{}'. + // If not passing an empty object, mobile clients will crash. + let mut type_data_json = + serde_json::from_str::>(&self.data).map(|d| d.data).unwrap_or_else(|_| { + warn!("Error parsing data field for {}", self.uuid); + Value::Object(serde_json::Map::new()) + }); + + // NOTE: This was marked as *Backwards Compatibility Code*, but as of January 2021 this is still being used by upstream + // Set the first element of the Uris array as Uri, this is needed several (mobile) clients. + if self.atype == 1 { + // Upstream always has an `uri` key/value + type_data_json["uri"] = Value::Null; + if let Some(uris) = type_data_json["uris"].as_array_mut() { + if !uris.is_empty() { + // Fix uri match values first, they are only allowed to be a number or null + // If it is a string, convert it to an int or null if that fails + for uri in &mut *uris { + if uri["match"].is_string() { + let match_value = match uri["match"].as_str().unwrap_or_default().parse::() { + Ok(n) => json!(n), + _ => Value::Null, + }; + uri["match"] = match_value; + } + } + type_data_json["uri"] = uris[0]["uri"].clone(); + } + } + + // Check if `passwordRevisionDate` is a valid date, else convert it + if let Some(pw_revision) = type_data_json["passwordRevisionDate"].as_str() { + type_data_json["passwordRevisionDate"] = json!(validate_and_format_date(pw_revision)); + } + } + + // Fix secure note issues when data is invalid + // This breaks at least the native mobile clients + if self.atype == 2 { + match type_data_json { + Value::Object(ref t) if t.get("type").is_some_and(|t| t.is_number()) => {} + _ => { + type_data_json = json!({"type": 0}); + } + } + } + + // Fix invalid SSH Entries + // This breaks at least the native mobile client if invalid + // The only way to fix this is by setting type_data_json to `null` + // Opening this ssh-key in the mobile client will probably crash the client, but you can edit, save and afterwards delete it + if self.atype == 5 + && (type_data_json["keyFingerprint"].as_str().is_none_or(|v| v.is_empty()) + || type_data_json["privateKey"].as_str().is_none_or(|v| v.is_empty()) + || type_data_json["publicKey"].as_str().is_none_or(|v| v.is_empty())) + { + warn!("Error parsing ssh-key, mandatory fields are invalid for {}", self.uuid); + type_data_json = Value::Null; + } + + // Clone the type_data and add some default value. + let mut data_json = type_data_json.clone(); + + // NOTE: This was marked as *Backwards Compatibility Code*, but as of January 2021 this is still being used by upstream + // data_json should always contain the following keys with every atype + data_json["fields"] = json!(fields_json); + data_json["name"] = json!(self.name); + data_json["notes"] = json!(self.notes); + data_json["passwordHistory"] = Value::Array(password_history_json.clone()); let collection_ids = if let Some(cipher_sync_data) = cipher_sync_data { if let Some(cipher_collections) = cipher_sync_data.cipher_collections.get(&self.uuid) { Cow::from(cipher_collections) } else { - Cow::from(Vec::new()) + Cow::from(Vec::with_capacity(0)) } } else { Cow::from(self.get_admin_collections(user_uuid.clone(), conn).await) @@ -384,6 +354,8 @@ impl Cipher { "notes": self.notes, "fields": fields_json, + "data": data_json, + "passwordHistory": password_history_json, // All Cipher types are included by default as null, but only the matching one will be populated @@ -392,9 +364,6 @@ impl Cipher { "card": null, "identity": null, "sshKey": null, - "bankAccount": null, - "driversLicense": null, - "passport": null, }); // These values are only needed for user/default syncs @@ -411,11 +380,6 @@ impl Cipher { } else { self.is_favorite(user_uuid, conn).await }); - json_object["archivedDate"] = json!(if let Some(cipher_sync_data) = cipher_sync_data { - cipher_sync_data.cipher_archives.get(&self.uuid).map_or(Value::Null, |d| Value::String(format_date(d))) - } else { - self.get_archived_at(user_uuid, conn).await.map_or(Value::Null, |d| Value::String(format_date(&d))) - }); // These values are true by default, but can be false if the // cipher belongs to a collection or group where the org owner has enabled // the "Read Only" or "Hide Passwords" restrictions for the user. @@ -434,96 +398,19 @@ impl Cipher { 3 => "card", 4 => "identity", 5 => "sshKey", - 6 => "bankAccount", - 7 => "driversLicense", - 8 => "passport", - _ => err!(format!("Cipher {} has an invalid type {}", self.uuid, self.atype)), + _ => panic!("Wrong type"), }; - if is_blob_encrypted { - // Like upstream, sent as-is with the structured fields null - json_object["data"] = json!(self.data); - json_object["name"] = Value::Null; - } else { - json_object[key] = self.legacy_type_data_json(type_data); - } + json_object[key] = type_data_json; Ok(json_object) } - /// The per-type data of a legacy cipher, with fixups for values known to break clients. - fn legacy_type_data_json(&self, type_data: Result) -> Value { - use crate::util::validate_and_format_date; - - // Get the type_data or a default to an empty json object '{}'. - // If not passing an empty object, mobile clients will crash. - let mut type_data_json = if let Ok(data @ Value::Object(_)) = type_data { - convert_json_key_lcase_first(data) - } else { - warn!("Error parsing data field for {}", self.uuid); - Value::Object(serde_json::Map::new()) - }; - - // NOTE: This was marked as *Backwards Compatibility Code*, but as of January 2021 this is still being used by upstream - // Set the first element of the Uris array as Uri, this is needed several (mobile) clients. - if self.atype == 1 { - // Upstream always has an `uri` key/value - type_data_json["uri"] = Value::Null; - if let Some(uris) = type_data_json["uris"].as_array_mut() - && !uris.is_empty() - { - // Fix uri match values first, they are only allowed to be a number or null - // If it is a string, convert it to an int or null if that fails - for uri in &mut *uris { - if uri["match"].is_string() { - let match_value = match uri["match"].as_str().unwrap_or_default().parse::() { - Ok(n) => json!(n), - _ => Value::Null, - }; - uri["match"] = match_value; - } - } - type_data_json["uri"] = uris[0]["uri"].clone(); - } - - // Check if `passwordRevisionDate` is a valid date, else convert it - if let Some(pw_revision) = type_data_json["passwordRevisionDate"].as_str() { - type_data_json["passwordRevisionDate"] = json!(validate_and_format_date(pw_revision)); - } - } - - // Fix secure note issues when data is invalid - // This breaks at least the native mobile clients - if self.atype == 2 { - match type_data_json { - Value::Object(ref t) if t.get("type").is_some_and(Value::is_number) => {} - _ => { - type_data_json = json!({"type": 0}); - } - } - } - - // Fix invalid SSH Entries - // This breaks at least the native mobile client if invalid - // The only way to fix this is by setting type_data_json to `null` - // Opening this ssh-key in the mobile client will probably crash the client, but you can edit, save and afterwards delete it - if self.atype == 5 - && (type_data_json["keyFingerprint"].as_str().is_none_or(str::is_empty) - || type_data_json["privateKey"].as_str().is_none_or(str::is_empty) - || type_data_json["publicKey"].as_str().is_none_or(str::is_empty)) - { - warn!("Error parsing ssh-key, mandatory fields are invalid for {}", self.uuid); - type_data_json = Value::Null; - } - - type_data_json - } - pub async fn update_users_revision(&self, conn: &DbConn) -> Vec { let mut user_uuids = Vec::new(); match self.user_uuid { Some(ref user_uuid) => { User::update_uuid_revision(user_uuid, conn).await; - user_uuids.push(user_uuid.clone()); + user_uuids.push(user_uuid.clone()) } None => { // Belongs to Organization, need to update affected users @@ -538,11 +425,11 @@ impl Cipher { } for member in collection_users { User::update_uuid_revision(&member.user_uuid, conn).await; - user_uuids.push(member.user_uuid.clone()); + user_uuids.push(member.user_uuid.clone()) } } } - } + }; user_uuids } @@ -551,16 +438,24 @@ impl Cipher { self.updated_at = Utc::now().naive_utc(); db_run! { conn: - mysql { - diesel::insert_into(ciphers::table) + sqlite, mysql { + match diesel::replace_into(ciphers::table) .values(&*self) - .on_conflict(diesel::dsl::DuplicatedKeys) - .do_update() - .set(&*self) .execute(conn) - .map_res("Error saving cipher") + { + Ok(_) => Ok(()), + // Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first. + Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { + diesel::update(ciphers::table) + .filter(ciphers::uuid.eq(&self.uuid)) + .set(&*self) + .execute(conn) + .map_res("Error saving cipher") + } + Err(e) => Err(e.into()), + }.map_res("Error saving cipher") } - postgresql, sqlite { + postgresql { diesel::insert_into(ciphers::table) .values(&*self) .on_conflict(ciphers::uuid) @@ -580,12 +475,11 @@ impl Cipher { Attachment::delete_all_by_cipher(&self.uuid, conn).await?; Favorite::delete_all_by_cipher(&self.uuid, conn).await?; - conn.run(move |conn| { + db_run! { conn: { diesel::delete(ciphers::table.filter(ciphers::uuid.eq(&self.uuid))) .execute(conn) .map_res("Error deleting cipher") - }) - .await + }} } pub async fn delete_all_by_organization(org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult { @@ -605,22 +499,12 @@ impl Cipher { /// Purge all ciphers that are old enough to be auto-deleted. pub async fn purge_trash(conn: &DbConn) { - let Some(auto_delete_days) = CONFIG.trash_auto_delete_days().filter(|&d| d != 0) else { - debug!("trash_auto_delete_days is not configured or set to 0, skipping"); - return; - }; - - let Some(days) = TimeDelta::try_days(i64::from(auto_delete_days)) else { - error!("Unable to calculate auto delete days!"); - return; - }; - let now = Utc::now().naive_utc(); - let Some(dt) = now.checked_sub_signed(days) else { - error!("Unable to calculate trash purge date!"); - return; - }; - for cipher in Self::find_deleted_before(&dt, conn).await { - cipher.delete(conn).await.ok(); + if let Some(auto_delete_days) = CONFIG.trash_auto_delete_days() { + let now = Utc::now().naive_utc(); + let dt = now - TimeDelta::try_days(auto_delete_days).unwrap(); + for cipher in Self::find_deleted_before(&dt, conn).await { + cipher.delete(conn).await.ok(); + } } } @@ -631,16 +515,7 @@ impl Cipher { conn: &DbConn, ) -> EmptyResult { User::update_uuid_revision(user_uuid, conn).await; - self.move_to_folder_without_revision_update(folder_uuid, user_uuid, conn).await - } - /// Same as `move_to_folder`, but the caller is responsible for updating the user's revision. - pub async fn move_to_folder_without_revision_update( - &self, - folder_uuid: Option, - user_uuid: &UserId, - conn: &DbConn, - ) -> EmptyResult { match (self.get_folder_uuid(user_uuid, conn).await, folder_uuid) { // No changes (None, None) => Ok(()), @@ -651,10 +526,9 @@ impl Cipher { // Remove from folder (Some(old_folder), None) => { - if let Some(old_folder) = FolderCipher::find_by_folder_and_cipher(&old_folder, &self.uuid, conn).await { - old_folder.delete(conn).await - } else { - err!("Couldn't move from previous folder") + match FolderCipher::find_by_folder_and_cipher(&old_folder, &self.uuid, conn).await { + Some(old_folder) => old_folder.delete(conn).await, + None => err!("Couldn't move from previous folder"), } } @@ -673,21 +547,20 @@ impl Cipher { self.user_uuid.is_some() && self.user_uuid.as_ref().unwrap() == user_uuid } - /// Returns whether this cipher is owned by an org in which the user reaches every cipher with `scope`. + /// Returns whether this cipher is owned by an org in which the user has full access. async fn is_in_full_access_org( &self, user_uuid: &UserId, - scope: CipherAccessScope, cipher_sync_data: Option<&CipherSyncData>, conn: &DbConn, ) -> bool { if let Some(ref org_uuid) = self.organization_uuid { if let Some(cipher_sync_data) = cipher_sync_data { if let Some(cached_member) = cipher_sync_data.members.get(org_uuid) { - return scope.reaches_all_ciphers(cached_member); + return cached_member.has_full_access(); } } else if let Some(member) = Membership::find_confirmed_by_user_and_org(user_uuid, org_uuid, conn).await { - return scope.reaches_all_ciphers(&member); + return member.has_full_access(); } } false @@ -706,8 +579,9 @@ impl Cipher { if let Some(ref org_uuid) = self.organization_uuid { if let Some(cipher_sync_data) = cipher_sync_data { return cipher_sync_data.user_group_full_access_for_organizations.contains(org_uuid); + } else { + return Group::is_in_full_access_group(user_uuid, org_uuid, conn).await; } - return Group::is_in_full_access_group(user_uuid, org_uuid, conn).await; } false } @@ -720,7 +594,6 @@ impl Cipher { pub async fn get_access_restrictions( &self, user_uuid: &UserId, - scope: CipherAccessScope, cipher_sync_data: Option<&CipherSyncData>, conn: &DbConn, ) -> Option<(bool, bool, bool)> { @@ -728,7 +601,7 @@ impl Cipher { // a collection that the user has full access to. If so, there are no // access restrictions. if self.is_owned_by_user(user_uuid) - || self.is_in_full_access_org(user_uuid, scope, cipher_sync_data, conn).await + || self.is_in_full_access_org(user_uuid, cipher_sync_data, conn).await || self.is_in_full_access_group(user_uuid, cipher_sync_data, conn).await { return Some((false, false, true)); @@ -750,10 +623,10 @@ impl Cipher { rows } else { let user_permissions = self.get_user_collections_access_flags(user_uuid, conn).await; - if user_permissions.is_empty() { - self.get_group_collections_access_flags(user_uuid, conn).await - } else { + if !user_permissions.is_empty() { user_permissions + } else { + self.get_group_collections_access_flags(user_uuid, conn).await } }; @@ -779,7 +652,7 @@ impl Cipher { let mut read_only = true; let mut hide_passwords = true; let mut manage = false; - for (ro, hp, mn) in &rows { + for (ro, hp, mn) in rows.iter() { read_only &= ro; hide_passwords &= hp; manage |= mn; @@ -789,75 +662,55 @@ impl Cipher { } async fn get_user_collections_access_flags(&self, user_uuid: &UserId, conn: &DbConn) -> Vec<(bool, bool, bool)> { - let user_uuid = user_uuid.to_string(); - conn.run(move |conn| { + db_run! { conn: { // Check whether this cipher is in any collections accessible to the // user. If so, retrieve the access flags for each collection. - // The user must have a confirmed membership in the cipher's - // organization, since users_collections rows are kept when a - // membership is revoked or not confirmed yet. ciphers::table .filter(ciphers::uuid.eq(&self.uuid)) - .inner_join(ciphers_collections::table.on(ciphers::uuid.eq(ciphers_collections::cipher_uuid))) - .inner_join( - users_organizations::table.on(ciphers::organization_uuid - .eq(users_organizations::org_uuid.nullable()) - .and(users_organizations::user_uuid.eq(user_uuid)) - .and(users_organizations::status.eq(MembershipStatus::Confirmed as i32))), - ) - .inner_join( - users_collections::table.on(ciphers_collections::collection_uuid - .eq(users_collections::collection_uuid) - // Only allow collection access via the confirmed membership. - .and(users_organizations::user_uuid.eq(users_collections::user_uuid))), - ) + .inner_join(ciphers_collections::table.on( + ciphers::uuid.eq(ciphers_collections::cipher_uuid) + )) + .inner_join(users_collections::table.on( + ciphers_collections::collection_uuid.eq(users_collections::collection_uuid) + .and(users_collections::user_uuid.eq(user_uuid)) + )) .select((users_collections::read_only, users_collections::hide_passwords, users_collections::manage)) .load::<(bool, bool, bool)>(conn) .expect("Error getting user access restrictions") - }) - .await + }} } async fn get_group_collections_access_flags(&self, user_uuid: &UserId, conn: &DbConn) -> Vec<(bool, bool, bool)> { if !CONFIG.org_groups_enabled() { return Vec::new(); } - conn.run(move |conn| { + db_run! { conn: { ciphers::table .filter(ciphers::uuid.eq(&self.uuid)) - .inner_join(ciphers_collections::table.on(ciphers::uuid.eq(ciphers_collections::cipher_uuid))) - .inner_join( - collections_groups::table - .on(collections_groups::collections_uuid.eq(ciphers_collections::collection_uuid)), - ) - .inner_join(groups_users::table.on(groups_users::groups_uuid.eq(collections_groups::groups_uuid))) - .inner_join( - users_organizations::table.on(users_organizations::uuid.eq(groups_users::users_organizations_uuid)), - ) - .inner_join( - groups::table.on(groups::uuid - .eq(collections_groups::groups_uuid) - .and(groups::organizations_uuid.eq(users_organizations::org_uuid))), - ) + .inner_join(ciphers_collections::table.on( + ciphers::uuid.eq(ciphers_collections::cipher_uuid) + )) + .inner_join(collections_groups::table.on( + collections_groups::collections_uuid.eq(ciphers_collections::collection_uuid) + )) + .inner_join(groups_users::table.on( + groups_users::groups_uuid.eq(collections_groups::groups_uuid) + )) + .inner_join(users_organizations::table.on( + users_organizations::uuid.eq(groups_users::users_organizations_uuid) + )) + .inner_join(groups::table.on(groups::uuid.eq(collections_groups::groups_uuid) + .and(groups::organizations_uuid.eq(users_organizations::org_uuid)) + )) .filter(users_organizations::user_uuid.eq(user_uuid)) - // Only allow group access via a confirmed membership, since - // groups_users rows are kept when a membership is revoked. - .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) - .filter(ciphers::organization_uuid.eq(users_organizations::org_uuid.nullable())) .select((collections_groups::read_only, collections_groups::hide_passwords, collections_groups::manage)) .load::<(bool, bool, bool)>(conn) .expect("Error getting group access restrictions") - }) - .await + }} } - pub async fn is_write_accessible_to_user( - &self, - user_uuid: &UserId, - scope: CipherAccessScope, - conn: &DbConn, - ) -> bool { - match self.get_access_restrictions(user_uuid, scope, None, conn).await { + pub async fn is_write_accessible_to_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool { + match self.get_access_restrictions(user_uuid, None, conn).await { Some((read_only, _hide_passwords, manage)) => !read_only || manage, None => false, } @@ -865,20 +718,15 @@ impl Cipher { // used for checking if collection can be edited (only if user has access to a collection they // can write to and also passwords are not hidden to prevent privilege escalation) - pub async fn is_in_editable_collection_by_user( - &self, - user_uuid: &UserId, - scope: CipherAccessScope, - conn: &DbConn, - ) -> bool { - match self.get_access_restrictions(user_uuid, scope, None, conn).await { + pub async fn is_in_editable_collection_by_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool { + match self.get_access_restrictions(user_uuid, None, conn).await { Some((read_only, hide_passwords, manage)) => (!read_only && !hide_passwords) || manage, None => false, } } - pub async fn is_accessible_to_user(&self, user_uuid: &UserId, scope: CipherAccessScope, conn: &DbConn) -> bool { - self.get_access_restrictions(user_uuid, scope, None, conn).await.is_some() + pub async fn is_accessible_to_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool { + self.get_access_restrictions(user_uuid, None, conn).await.is_some() } // Returns whether this cipher is a favorite of the specified user. @@ -894,20 +742,8 @@ impl Cipher { } } - pub async fn get_archived_at(&self, user_uuid: &UserId, conn: &DbConn) -> Option { - Archive::get_archived_at(&self.uuid, user_uuid, conn).await - } - - pub async fn set_archived_at(&self, archived_at: NaiveDateTime, user_uuid: &UserId, conn: &DbConn) -> EmptyResult { - Archive::save(user_uuid, &self.uuid, archived_at, conn).await - } - - pub async fn unarchive(&self, user_uuid: &UserId, conn: &DbConn) -> EmptyResult { - Archive::delete_by_cipher(user_uuid, &self.uuid, conn).await - } - pub async fn get_folder_uuid(&self, user_uuid: &UserId, conn: &DbConn) -> Option { - conn.run(move |conn| { + db_run! { conn: { folders_ciphers::table .inner_join(folders::table) .filter(folders::user_uuid.eq(&user_uuid)) @@ -915,12 +751,16 @@ impl Cipher { .select(folders_ciphers::folder_uuid) .first::(conn) .ok() - }) - .await + }} } pub async fn find_by_uuid(uuid: &CipherId, conn: &DbConn) -> Option { - conn.run(move |conn| ciphers::table.filter(ciphers::uuid.eq(uuid)).first::(conn).ok()).await + db_run! { conn: { + ciphers::table + .filter(ciphers::uuid.eq(uuid)) + .first::(conn) + .ok() + }} } pub async fn find_by_uuid_and_org( @@ -928,14 +768,13 @@ impl Cipher { org_uuid: &OrganizationId, conn: &DbConn, ) -> Option { - conn.run(move |conn| { + db_run! { conn: { ciphers::table .filter(ciphers::uuid.eq(cipher_uuid)) .filter(ciphers::organization_uuid.eq(org_uuid)) .first::(conn) .ok() - }) - .await + }} } // Find all ciphers accessible or visible to the specified user. @@ -957,35 +796,32 @@ impl Cipher { conn: &DbConn, ) -> Vec { if CONFIG.org_groups_enabled() { - conn.run(move |conn| { + db_run! { conn: { let mut query = ciphers::table - .left_join(ciphers_collections::table.on(ciphers::uuid.eq(ciphers_collections::cipher_uuid))) - .left_join( - users_organizations::table.on(ciphers::organization_uuid - .eq(users_organizations::org_uuid.nullable()) + .left_join(ciphers_collections::table.on( + ciphers::uuid.eq(ciphers_collections::cipher_uuid) + )) + .left_join(users_organizations::table.on( + ciphers::organization_uuid.eq(users_organizations::org_uuid.nullable()) .and(users_organizations::user_uuid.eq(user_uuid)) - .and(users_organizations::status.eq(MembershipStatus::Confirmed as i32))), - ) - .left_join( - users_collections::table.on(ciphers_collections::collection_uuid - .eq(users_collections::collection_uuid) + .and(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) + )) + .left_join(users_collections::table.on( + ciphers_collections::collection_uuid.eq(users_collections::collection_uuid) // Ensure that users_collections::user_uuid is NULL for unconfirmed users. - .and(users_organizations::user_uuid.eq(users_collections::user_uuid))), - ) - .left_join( - groups_users::table.on(groups_users::users_organizations_uuid.eq(users_organizations::uuid)), - ) - .left_join( - groups::table.on(groups::uuid - .eq(groups_users::groups_uuid) - // Ensure that group and membership belong to the same org - .and(groups::organizations_uuid.eq(users_organizations::org_uuid))), - ) - .left_join( - collections_groups::table.on(collections_groups::collections_uuid - .eq(ciphers_collections::collection_uuid) - .and(collections_groups::groups_uuid.eq(groups::uuid))), - ) + .and(users_organizations::user_uuid.eq(users_collections::user_uuid)) + )) + .left_join(groups_users::table.on( + groups_users::users_organizations_uuid.eq(users_organizations::uuid) + )) + .left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid) + // Ensure that group and membership belong to the same org + .and(groups::organizations_uuid.eq(users_organizations::org_uuid)) + )) + .left_join(collections_groups::table.on( + collections_groups::collections_uuid.eq(ciphers_collections::collection_uuid) + .and(collections_groups::groups_uuid.eq(groups::uuid)) + )) .filter(ciphers::user_uuid.eq(user_uuid)) // Cipher owner .or_filter(users_organizations::access_all.eq(true)) // access_all in org .or_filter(users_collections::user_uuid.eq(user_uuid)) // Access to collection @@ -995,34 +831,39 @@ impl Cipher { if !visible_only { query = query.or_filter( - users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin/owner + users_organizations::atype.le(MembershipType::Admin as i32) // Org admin/owner ); } // Only filter for one specific cipher if !cipher_uuids.is_empty() { - query = query.filter(ciphers::uuid.eq_any(cipher_uuids)); + query = query.filter( + ciphers::uuid.eq_any(cipher_uuids) + ); } - query.select(ciphers::all_columns).distinct().load::(conn).expect("Error loading ciphers") - }) - .await + query + .select(ciphers::all_columns) + .distinct() + .load::(conn) + .expect("Error loading ciphers") + }} } else { - conn.run(move |conn| { + db_run! { conn: { let mut query = ciphers::table - .left_join(ciphers_collections::table.on(ciphers::uuid.eq(ciphers_collections::cipher_uuid))) - .left_join( - users_organizations::table.on(ciphers::organization_uuid - .eq(users_organizations::org_uuid.nullable()) + .left_join(ciphers_collections::table.on( + ciphers::uuid.eq(ciphers_collections::cipher_uuid) + )) + .left_join(users_organizations::table.on( + ciphers::organization_uuid.eq(users_organizations::org_uuid.nullable()) .and(users_organizations::user_uuid.eq(user_uuid)) - .and(users_organizations::status.eq(MembershipStatus::Confirmed as i32))), - ) - .left_join( - users_collections::table.on(ciphers_collections::collection_uuid - .eq(users_collections::collection_uuid) + .and(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) + )) + .left_join(users_collections::table.on( + ciphers_collections::collection_uuid.eq(users_collections::collection_uuid) // Ensure that users_collections::user_uuid is NULL for unconfirmed users. - .and(users_organizations::user_uuid.eq(users_collections::user_uuid))), - ) + .and(users_organizations::user_uuid.eq(users_collections::user_uuid)) + )) .filter(ciphers::user_uuid.eq(user_uuid)) // Cipher owner .or_filter(users_organizations::access_all.eq(true)) // access_all in org .or_filter(users_collections::user_uuid.eq(user_uuid)) // Access to collection @@ -1030,18 +871,23 @@ impl Cipher { if !visible_only { query = query.or_filter( - users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin/owner + users_organizations::atype.le(MembershipType::Admin as i32) // Org admin/owner ); } // Only filter for one specific cipher if !cipher_uuids.is_empty() { - query = query.filter(ciphers::uuid.eq_any(cipher_uuids)); + query = query.filter( + ciphers::uuid.eq_any(cipher_uuids) + ); } - query.select(ciphers::all_columns).distinct().load::(conn).expect("Error loading ciphers") - }) - .await + query + .select(ciphers::all_columns) + .distinct() + .load::(conn) + .expect("Error loading ciphers") + }} } } @@ -1064,212 +910,193 @@ impl Cipher { // Find all ciphers directly owned by the specified user. pub async fn find_owned_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { - conn.run(move |conn| { + db_run! { conn: { ciphers::table - .filter(ciphers::user_uuid.eq(user_uuid).and(ciphers::organization_uuid.is_null())) + .filter( + ciphers::user_uuid.eq(user_uuid) + .and(ciphers::organization_uuid.is_null()) + ) .load::(conn) .expect("Error loading ciphers") - }) - .await + }} } pub async fn count_owned_by_user(user_uuid: &UserId, conn: &DbConn) -> i64 { - conn.run(move |conn| { - ciphers::table.filter(ciphers::user_uuid.eq(user_uuid)).count().first::(conn).ok().unwrap_or(0) - }) - .await + db_run! { conn: { + ciphers::table + .filter(ciphers::user_uuid.eq(user_uuid)) + .count() + .first::(conn) + .ok() + .unwrap_or(0) + }} } pub async fn find_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> Vec { - conn.run(move |conn| { + db_run! { conn: { ciphers::table .filter(ciphers::organization_uuid.eq(org_uuid)) .load::(conn) .expect("Error loading ciphers") - }) - .await + }} } pub async fn count_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> i64 { - conn.run(move |conn| { - ciphers::table.filter(ciphers::organization_uuid.eq(org_uuid)).count().first::(conn).ok().unwrap_or(0) - }) - .await + db_run! { conn: { + ciphers::table + .filter(ciphers::organization_uuid.eq(org_uuid)) + .count() + .first::(conn) + .ok() + .unwrap_or(0) + }} } pub async fn find_by_folder(folder_uuid: &FolderId, conn: &DbConn) -> Vec { - conn.run(move |conn| { - folders_ciphers::table - .inner_join(ciphers::table) + db_run! { conn: { + folders_ciphers::table.inner_join(ciphers::table) .filter(folders_ciphers::folder_uuid.eq(folder_uuid)) .select(ciphers::all_columns) .load::(conn) .expect("Error loading ciphers") - }) - .await + }} } /// Find all ciphers that were deleted before the specified datetime. pub async fn find_deleted_before(dt: &NaiveDateTime, conn: &DbConn) -> Vec { - conn.run(move |conn| { - ciphers::table.filter(ciphers::deleted_at.lt(dt)).load::(conn).expect("Error loading ciphers") - }) - .await + db_run! { conn: { + ciphers::table + .filter(ciphers::deleted_at.lt(dt)) + .load::(conn) + .expect("Error loading ciphers") + }} } pub async fn get_collections(&self, user_uuid: UserId, conn: &DbConn) -> Vec { if CONFIG.org_groups_enabled() { - conn.run(move |conn| { + db_run! { conn: { ciphers_collections::table .filter(ciphers_collections::cipher_uuid.eq(&self.uuid)) - .inner_join(collections::table.on(collections::uuid.eq(ciphers_collections::collection_uuid))) - .left_join( - users_organizations::table.on(users_organizations::org_uuid - .eq(collections::org_uuid) - .and(users_organizations::user_uuid.eq(user_uuid.clone()))), - ) - .left_join( - users_collections::table.on(users_collections::collection_uuid - .eq(ciphers_collections::collection_uuid) - .and(users_collections::user_uuid.eq(user_uuid.clone()))), - ) - .left_join( - groups_users::table.on(groups_users::users_organizations_uuid.eq(users_organizations::uuid)), - ) - .left_join( - groups::table.on(groups::uuid - .eq(groups_users::groups_uuid) - .and(groups::organizations_uuid.eq(users_organizations::org_uuid))), - ) - .left_join( - collections_groups::table.on(collections_groups::collections_uuid - .eq(ciphers_collections::collection_uuid) - .and(collections_groups::groups_uuid.eq(groups::uuid))), - ) - .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) - .filter( - users_organizations::access_all - .eq(true) // User has access all - .or(users_collections::user_uuid - .eq(user_uuid) // User has access to collection - .and(users_collections::read_only.eq(false))) - .or(groups::access_all.eq(true)) // Access via groups - .or(collections_groups::collections_uuid - .is_not_null() // Access via groups - .and(collections_groups::read_only.eq(false))), + .inner_join(collections::table.on( + collections::uuid.eq(ciphers_collections::collection_uuid) + )) + .left_join(users_organizations::table.on( + users_organizations::org_uuid.eq(collections::org_uuid) + .and(users_organizations::user_uuid.eq(user_uuid.clone())) + )) + .left_join(users_collections::table.on( + users_collections::collection_uuid.eq(ciphers_collections::collection_uuid) + .and(users_collections::user_uuid.eq(user_uuid.clone())) + )) + .left_join(groups_users::table.on( + groups_users::users_organizations_uuid.eq(users_organizations::uuid) + )) + .left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid) + .and(groups::organizations_uuid.eq(users_organizations::org_uuid)) + )) + .left_join(collections_groups::table.on( + collections_groups::collections_uuid.eq(ciphers_collections::collection_uuid) + .and(collections_groups::groups_uuid.eq(groups::uuid)) + )) + .filter(users_organizations::access_all.eq(true) // User has access all + .or(users_collections::user_uuid.eq(user_uuid) // User has access to collection + .and(users_collections::read_only.eq(false))) + .or(groups::access_all.eq(true)) // Access via groups + .or(collections_groups::collections_uuid.is_not_null() // Access via groups + .and(collections_groups::read_only.eq(false))) ) .select(ciphers_collections::collection_uuid) .load::(conn) .unwrap_or_default() - }) - .await + }} } else { - conn.run(move |conn| { + db_run! { conn: { ciphers_collections::table .filter(ciphers_collections::cipher_uuid.eq(&self.uuid)) - .inner_join(collections::table.on(collections::uuid.eq(ciphers_collections::collection_uuid))) - .inner_join( - users_organizations::table.on(users_organizations::org_uuid - .eq(collections::org_uuid) - .and(users_organizations::user_uuid.eq(user_uuid.clone()))), - ) - .left_join( - users_collections::table.on(users_collections::collection_uuid - .eq(ciphers_collections::collection_uuid) - .and(users_collections::user_uuid.eq(user_uuid.clone()))), - ) - .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) - .filter( - users_organizations::access_all - .eq(true) // User has access all - .or(users_collections::user_uuid - .eq(user_uuid) // User has access to collection - .and(users_collections::read_only.eq(false))), + .inner_join(collections::table.on( + collections::uuid.eq(ciphers_collections::collection_uuid) + )) + .inner_join(users_organizations::table.on( + users_organizations::org_uuid.eq(collections::org_uuid) + .and(users_organizations::user_uuid.eq(user_uuid.clone())) + )) + .left_join(users_collections::table.on( + users_collections::collection_uuid.eq(ciphers_collections::collection_uuid) + .and(users_collections::user_uuid.eq(user_uuid.clone())) + )) + .filter(users_organizations::access_all.eq(true) // User has access all + .or(users_collections::user_uuid.eq(user_uuid) // User has access to collection + .and(users_collections::read_only.eq(false))) ) .select(ciphers_collections::collection_uuid) .load::(conn) .unwrap_or_default() - }) - .await + }} } } pub async fn get_admin_collections(&self, user_uuid: UserId, conn: &DbConn) -> Vec { if CONFIG.org_groups_enabled() { - conn.run(move |conn| { + db_run! { conn: { ciphers_collections::table .filter(ciphers_collections::cipher_uuid.eq(&self.uuid)) - .inner_join(collections::table.on(collections::uuid.eq(ciphers_collections::collection_uuid))) - .left_join( - users_organizations::table.on(users_organizations::org_uuid - .eq(collections::org_uuid) - .and(users_organizations::user_uuid.eq(user_uuid.clone()))), - ) - .left_join( - users_collections::table.on(users_collections::collection_uuid - .eq(ciphers_collections::collection_uuid) - .and(users_collections::user_uuid.eq(user_uuid.clone()))), - ) - .left_join( - groups_users::table.on(groups_users::users_organizations_uuid.eq(users_organizations::uuid)), - ) - .left_join( - groups::table.on(groups::uuid - .eq(groups_users::groups_uuid) - .and(groups::organizations_uuid.eq(users_organizations::org_uuid))), - ) - .left_join( - collections_groups::table.on(collections_groups::collections_uuid - .eq(ciphers_collections::collection_uuid) - .and(collections_groups::groups_uuid.eq(groups::uuid))), - ) - .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) - .filter( - users_organizations::access_all - .eq(true) // User has access all - .or(users_collections::user_uuid - .eq(user_uuid) // User has access to collection - .and(users_collections::read_only.eq(false))) - .or(groups::access_all.eq(true)) // Access via groups - .or(collections_groups::collections_uuid - .is_not_null() // Access via groups - .and(collections_groups::read_only.eq(false))) - .or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)), // User is admin or owner + .inner_join(collections::table.on( + collections::uuid.eq(ciphers_collections::collection_uuid) + )) + .left_join(users_organizations::table.on( + users_organizations::org_uuid.eq(collections::org_uuid) + .and(users_organizations::user_uuid.eq(user_uuid.clone())) + )) + .left_join(users_collections::table.on( + users_collections::collection_uuid.eq(ciphers_collections::collection_uuid) + .and(users_collections::user_uuid.eq(user_uuid.clone())) + )) + .left_join(groups_users::table.on( + groups_users::users_organizations_uuid.eq(users_organizations::uuid) + )) + .left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid) + .and(groups::organizations_uuid.eq(users_organizations::org_uuid)) + )) + .left_join(collections_groups::table.on( + collections_groups::collections_uuid.eq(ciphers_collections::collection_uuid) + .and(collections_groups::groups_uuid.eq(groups::uuid)) + )) + .filter(users_organizations::access_all.eq(true) // User has access all + .or(users_collections::user_uuid.eq(user_uuid) // User has access to collection + .and(users_collections::read_only.eq(false))) + .or(groups::access_all.eq(true)) // Access via groups + .or(collections_groups::collections_uuid.is_not_null() // Access via groups + .and(collections_groups::read_only.eq(false))) + .or(users_organizations::atype.le(MembershipType::Admin as i32)) // User is admin or owner ) .select(ciphers_collections::collection_uuid) .load::(conn) .unwrap_or_default() - }) - .await + }} } else { - conn.run(move |conn| { + db_run! { conn: { ciphers_collections::table .filter(ciphers_collections::cipher_uuid.eq(&self.uuid)) - .inner_join(collections::table.on(collections::uuid.eq(ciphers_collections::collection_uuid))) - .inner_join( - users_organizations::table.on(users_organizations::org_uuid - .eq(collections::org_uuid) - .and(users_organizations::user_uuid.eq(user_uuid.clone()))), - ) - .left_join( - users_collections::table.on(users_collections::collection_uuid - .eq(ciphers_collections::collection_uuid) - .and(users_collections::user_uuid.eq(user_uuid.clone()))), - ) - .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) - .filter( - users_organizations::access_all - .eq(true) // User has access all - .or(users_collections::user_uuid - .eq(user_uuid) // User has access to collection - .and(users_collections::read_only.eq(false))) - .or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)), // User is admin or owner + .inner_join(collections::table.on( + collections::uuid.eq(ciphers_collections::collection_uuid) + )) + .inner_join(users_organizations::table.on( + users_organizations::org_uuid.eq(collections::org_uuid) + .and(users_organizations::user_uuid.eq(user_uuid.clone())) + )) + .left_join(users_collections::table.on( + users_collections::collection_uuid.eq(ciphers_collections::collection_uuid) + .and(users_collections::user_uuid.eq(user_uuid.clone())) + )) + .filter(users_organizations::access_all.eq(true) // User has access all + .or(users_collections::user_uuid.eq(user_uuid) // User has access to collection + .and(users_collections::read_only.eq(false))) + .or(users_organizations::atype.le(MembershipType::Admin as i32)) // User is admin or owner ) .select(ciphers_collections::collection_uuid) .load::(conn) .unwrap_or_default() - }) - .await + }} } } @@ -1279,86 +1106,42 @@ impl Cipher { user_uuid: UserId, conn: &DbConn, ) -> Vec<(CipherId, CollectionId)> { - conn.run(move |conn| { + db_run! { conn: { ciphers_collections::table - .inner_join(collections::table.on(collections::uuid.eq(ciphers_collections::collection_uuid))) - .inner_join( - users_organizations::table.on(users_organizations::org_uuid - .eq(collections::org_uuid) - .and(users_organizations::user_uuid.eq(user_uuid.clone()))), + .inner_join(collections::table.on( + collections::uuid.eq(ciphers_collections::collection_uuid) + )) + .inner_join(users_organizations::table.on( + users_organizations::org_uuid.eq(collections::org_uuid).and( + users_organizations::user_uuid.eq(user_uuid.clone()) ) - .left_join( - users_collections::table.on(users_collections::collection_uuid - .eq(ciphers_collections::collection_uuid) - .and(users_collections::user_uuid.eq(user_uuid.clone()))), + )) + .left_join(users_collections::table.on( + users_collections::collection_uuid.eq(ciphers_collections::collection_uuid).and( + users_collections::user_uuid.eq(user_uuid.clone()) ) - .left_join(groups_users::table.on(groups_users::users_organizations_uuid.eq(users_organizations::uuid))) - .left_join( - groups::table.on(groups::uuid - .eq(groups_users::groups_uuid) - .and(groups::organizations_uuid.eq(users_organizations::org_uuid))), + )) + .left_join(groups_users::table.on( + groups_users::users_organizations_uuid.eq(users_organizations::uuid) + )) + .left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid) + .and(groups::organizations_uuid.eq(users_organizations::org_uuid)) + )) + .left_join(collections_groups::table.on( + collections_groups::collections_uuid.eq(ciphers_collections::collection_uuid).and( + collections_groups::groups_uuid.eq(groups::uuid) ) - .left_join( - collections_groups::table.on(collections_groups::collections_uuid - .eq(ciphers_collections::collection_uuid) - .and(collections_groups::groups_uuid.eq(groups::uuid))), - ) - .or_filter(users_collections::user_uuid.eq(user_uuid)) // User has access to collection - .or_filter(users_organizations::access_all.eq(true)) // User has access all - .or_filter(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)) // User is admin or owner - .or_filter(groups::access_all.eq(true)) //Access via group - .or_filter(collections_groups::collections_uuid.is_not_null()) //Access via group - .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) - .select(ciphers_collections::all_columns) - .distinct() - .load::<(CipherId, CollectionId)>(conn) - .unwrap_or_default() - }) - .await - } - - /// Every collection of every cipher of the organization, for the members who may see all of them. - pub async fn get_collections_with_cipher_by_organization( - org_uuid: &OrganizationId, - conn: &DbConn, - ) -> Vec<(CipherId, CollectionId)> { - conn.run(move |conn| { - ciphers_collections::table - .inner_join(collections::table.on(collections::uuid.eq(ciphers_collections::collection_uuid))) - .filter(collections::org_uuid.eq(org_uuid)) - .select(ciphers_collections::all_columns) - .load::<(CipherId, CollectionId)>(conn) - .unwrap_or_default() - }) - .await - } - - /// The ciphers of the organization of `member` the Admin Console lists when they may not edit every cipher, like - /// Bitwarden does: the ones in the member's own vault, then, for a member who may edit any collection, the ones in - /// no collection at all. - pub async fn find_assigned_to_member(member: &Membership, conn: &DbConn) -> (Vec, Vec) { - let assigned: Vec = Self::find_by_user_visible(&member.user_uuid, conn) - .await - .into_iter() - .filter(|cipher| cipher.organization_uuid.as_ref() == Some(&member.org_uuid)) - .collect(); - if !member.can_edit_any_collection() { - return (assigned, Vec::new()); - } - - let listed: HashSet = assigned.iter().map(|cipher| cipher.uuid.clone()).collect(); - let in_collections: HashSet = - Self::get_collections_with_cipher_by_organization(&member.org_uuid, conn) - .await - .into_iter() - .map(|(cipher, _)| cipher) - .collect(); - let unassigned = Self::find_by_org(&member.org_uuid, conn) - .await - .into_iter() - .filter(|cipher| !in_collections.contains(&cipher.uuid) && !listed.contains(&cipher.uuid)) - .collect(); - (assigned, unassigned) + )) + .or_filter(users_collections::user_uuid.eq(user_uuid)) // User has access to collection + .or_filter(users_organizations::access_all.eq(true)) // User has access all + .or_filter(users_organizations::atype.le(MembershipType::Admin as i32)) // User is admin or owner + .or_filter(groups::access_all.eq(true)) //Access via group + .or_filter(collections_groups::collections_uuid.is_not_null()) //Access via group + .select(ciphers_collections::all_columns) + .distinct() + .load::<(CipherId, CollectionId)>(conn) + .unwrap_or_default() + }} } } diff --git a/src/db/models/collection.rs b/src/db/models/collection.rs index 68c48ce9..b1f82335 100644 --- a/src/db/models/collection.rs +++ b/src/db/models/collection.rs @@ -1,27 +1,17 @@ use derive_more::{AsRef, Deref, Display, From}; -use diesel::prelude::*; use serde_json::Value; -use crate::{ - CONFIG, - api::EmptyResult, - db::{ - DbConn, - schema::{ - ciphers_collections, collections, collections_groups, groups, groups_users, users_collections, - users_organizations, - }, - }, - error::MapResult, -}; -use macros::UuidFromParam; - use super::{ CipherId, CollectionGroup, GroupUser, Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId, - User, UserId, organization::ORG_ADMIN_ATYPES, + User, UserId, }; +use crate::db::schema::{ + ciphers_collections, collections, collections_groups, groups, groups_users, users_collections, users_organizations, +}; +use crate::CONFIG; +use diesel::prelude::*; +use macros::UuidFromParam; -// See (v2026.7.0): https://github.com/bitwarden/server/blob/5d4461aa42cadbacfef8fe2166c5453a5c52773a/src/Core/AdminConsole/Entities/Collection.cs #[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[diesel(table_name = collections)] #[diesel(treat_none_as_null = true)] @@ -72,11 +62,6 @@ impl Collection { "id": self.uuid, "organizationId": self.org_uuid, "name": self.name, - // Collection types are either 0: SharedCollection or 1: DefaultUserCollection, of which we do not yet support DefaultUserCollection. - // See (v2026.7.0): https://github.com/bitwarden/server/blob/5d4461aa42cadbacfef8fe2166c5453a5c52773a/src/Core/AdminConsole/Enums/CollectionType.cs - "type": 0, - // This is only used together with MyItems/DefaultUserCollection, which we do not yet support. - "defaultUserCollectionEmail": null, "object": "collection", }) } @@ -89,7 +74,7 @@ impl Collection { if external_id.is_empty() { self.external_id = None; } else { - self.external_id = Some(external_id); + self.external_id = Some(external_id) } } None => self.external_id = None, @@ -162,22 +147,35 @@ impl Collection { } } +use crate::db::DbConn; + +use crate::api::EmptyResult; +use crate::error::MapResult; + /// Database methods impl Collection { pub async fn save(&self, conn: &DbConn) -> EmptyResult { self.update_users_revision(conn).await; db_run! { conn: - mysql { - diesel::insert_into(collections::table) + sqlite, mysql { + match diesel::replace_into(collections::table) .values(self) - .on_conflict(diesel::dsl::DuplicatedKeys) - .do_update() - .set(self) .execute(conn) - .map_res("Error saving collection") + { + Ok(_) => Ok(()), + // Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first. + Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { + diesel::update(collections::table) + .filter(collections::uuid.eq(&self.uuid)) + .set(self) + .execute(conn) + .map_res("Error saving collection") + } + Err(e) => Err(e.into()), + }.map_res("Error saving collection") } - postgresql, sqlite { + postgresql { diesel::insert_into(collections::table) .values(self) .on_conflict(collections::uuid) @@ -195,12 +193,11 @@ impl Collection { CollectionUser::delete_all_by_collection(&self.uuid, conn).await?; CollectionGroup::delete_all_by_collection(&self.uuid, &self.org_uuid, conn).await?; - conn.run(move |conn| { + db_run! { conn: { diesel::delete(collections::table.filter(collections::uuid.eq(self.uuid))) .execute(conn) .map_res("Error deleting collection") - }) - .await + }} } pub async fn delete_all_by_organization(org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult { @@ -211,90 +208,90 @@ impl Collection { } pub async fn update_users_revision(&self, conn: &DbConn) { - for member in &Membership::find_by_collection_and_org(&self.uuid, &self.org_uuid, conn).await { + for member in Membership::find_by_collection_and_org(&self.uuid, &self.org_uuid, conn).await.iter() { User::update_uuid_revision(&member.user_uuid, conn).await; } } pub async fn find_by_uuid(uuid: &CollectionId, conn: &DbConn) -> Option { - conn.run(move |conn| collections::table.filter(collections::uuid.eq(uuid)).first::(conn).ok()).await + db_run! { conn: { + collections::table + .filter(collections::uuid.eq(uuid)) + .first::(conn) + .ok() + }} } pub async fn find_by_user_uuid(user_uuid: UserId, conn: &DbConn) -> Vec { if CONFIG.org_groups_enabled() { - conn.run(move |conn| { + db_run! { conn: { collections::table - .left_join( - users_collections::table.on(users_collections::collection_uuid - .eq(collections::uuid) - .and(users_collections::user_uuid.eq(user_uuid.clone()))), + .left_join(users_collections::table.on( + users_collections::collection_uuid.eq(collections::uuid).and( + users_collections::user_uuid.eq(user_uuid.clone()) ) - .left_join( - users_organizations::table.on(collections::org_uuid - .eq(users_organizations::org_uuid) - .and(users_organizations::user_uuid.eq(user_uuid.clone()))), + )) + .left_join(users_organizations::table.on( + collections::org_uuid.eq(users_organizations::org_uuid).and( + users_organizations::user_uuid.eq(user_uuid.clone()) ) - .left_join( - groups_users::table.on(groups_users::users_organizations_uuid.eq(users_organizations::uuid)), + )) + .left_join(groups_users::table.on( + groups_users::users_organizations_uuid.eq(users_organizations::uuid) + )) + .left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid) + .and(groups::organizations_uuid.eq(users_organizations::org_uuid)) + )) + .left_join(collections_groups::table.on( + collections_groups::groups_uuid.eq(groups_users::groups_uuid).and( + collections_groups::collections_uuid.eq(collections::uuid) ) - .left_join( - groups::table.on(groups::uuid - .eq(groups_users::groups_uuid) - .and(groups::organizations_uuid.eq(users_organizations::org_uuid))), + )) + .filter( + users_organizations::status.eq(MembershipStatus::Confirmed as i32) + ) + .filter( + users_collections::user_uuid.eq(user_uuid).or( // Directly accessed collection + users_organizations::access_all.eq(true) // access_all in Organization + ).or( + groups::access_all.eq(true) // access_all in groups + ).or( // access via groups + groups_users::users_organizations_uuid.eq(users_organizations::uuid).and( + collections_groups::collections_uuid.is_not_null() + ) ) - .left_join( - collections_groups::table.on(collections_groups::groups_uuid - .eq(groups_users::groups_uuid) - .and(collections_groups::collections_uuid.eq(collections::uuid))), - ) - .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) - .filter( - users_collections::user_uuid - .eq(user_uuid) - .or( - // Directly accessed collection - users_organizations::access_all.eq(true), // access_all in Organization - ) - .or( - groups::access_all.eq(true), // access_all in groups - ) - .or( - // access via groups - groups_users::users_organizations_uuid - .eq(users_organizations::uuid) - .and(collections_groups::collections_uuid.is_not_null()), - ), - ) - .select(collections::all_columns) - .distinct() - .load::(conn) - .expect("Error loading collections") - }) - .await + ) + .select(collections::all_columns) + .distinct() + .load::(conn) + .expect("Error loading collections") + }} } else { - conn.run(move |conn| { + db_run! { conn: { collections::table - .left_join( - users_collections::table.on(users_collections::collection_uuid - .eq(collections::uuid) - .and(users_collections::user_uuid.eq(user_uuid.clone()))), + .left_join(users_collections::table.on( + users_collections::collection_uuid.eq(collections::uuid).and( + users_collections::user_uuid.eq(user_uuid.clone()) ) - .left_join( - users_organizations::table.on(collections::org_uuid - .eq(users_organizations::org_uuid) - .and(users_organizations::user_uuid.eq(user_uuid.clone()))), + )) + .left_join(users_organizations::table.on( + collections::org_uuid.eq(users_organizations::org_uuid).and( + users_organizations::user_uuid.eq(user_uuid.clone()) ) - .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) - .filter(users_collections::user_uuid.eq(user_uuid).or( - // Directly accessed collection - users_organizations::access_all.eq(true), // access_all in Organization - )) - .select(collections::all_columns) - .distinct() - .load::(conn) - .expect("Error loading collections") - }) - .await + )) + .filter( + users_organizations::status.eq(MembershipStatus::Confirmed as i32) + ) + .filter( + users_collections::user_uuid.eq(user_uuid).or( // Directly accessed collection + users_organizations::access_all.eq(true) // access_all in Organization + ) + ) + .select(collections::all_columns) + .distinct() + .load::(conn) + .expect("Error loading collections") + }} } } @@ -311,364 +308,261 @@ impl Collection { } pub async fn find_by_organization(org_uuid: &OrganizationId, conn: &DbConn) -> Vec { - conn.run(move |conn| { + db_run! { conn: { collections::table .filter(collections::org_uuid.eq(org_uuid)) .load::(conn) .expect("Error loading collections") - }) - .await + }} } pub async fn count_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> i64 { - conn.run(move |conn| { - collections::table.filter(collections::org_uuid.eq(org_uuid)).count().first::(conn).ok().unwrap_or(0) - }) - .await + db_run! { conn: { + collections::table + .filter(collections::org_uuid.eq(org_uuid)) + .count() + .first::(conn) + .ok() + .unwrap_or(0) + }} } pub async fn find_by_uuid_and_org(uuid: &CollectionId, org_uuid: &OrganizationId, conn: &DbConn) -> Option { - conn.run(move |conn| { + db_run! { conn: { collections::table .filter(collections::uuid.eq(uuid)) .filter(collections::org_uuid.eq(org_uuid)) .select(collections::all_columns) .first::(conn) .ok() - }) - .await + }} } pub async fn find_by_uuid_and_user(uuid: &CollectionId, user_uuid: UserId, conn: &DbConn) -> Option { if CONFIG.org_groups_enabled() { - conn.run(move |conn| { + db_run! { conn: { collections::table - .left_join( - users_collections::table.on(users_collections::collection_uuid - .eq(collections::uuid) - .and(users_collections::user_uuid.eq(user_uuid.clone()))), + .left_join(users_collections::table.on( + users_collections::collection_uuid.eq(collections::uuid).and( + users_collections::user_uuid.eq(user_uuid.clone()) ) - .left_join( - users_organizations::table.on(collections::org_uuid - .eq(users_organizations::org_uuid) - .and(users_organizations::user_uuid.eq(user_uuid))), + )) + .left_join(users_organizations::table.on( + collections::org_uuid.eq(users_organizations::org_uuid).and( + users_organizations::user_uuid.eq(user_uuid) ) - .left_join( - groups_users::table.on(groups_users::users_organizations_uuid.eq(users_organizations::uuid)), + )) + .left_join(groups_users::table.on( + groups_users::users_organizations_uuid.eq(users_organizations::uuid) + )) + .left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid) + .and(groups::organizations_uuid.eq(users_organizations::org_uuid)) + )) + .left_join(collections_groups::table.on( + collections_groups::groups_uuid.eq(groups_users::groups_uuid).and( + collections_groups::collections_uuid.eq(collections::uuid) ) - .left_join( - groups::table.on(groups::uuid - .eq(groups_users::groups_uuid) - .and(groups::organizations_uuid.eq(users_organizations::org_uuid))), + )) + .filter(collections::uuid.eq(uuid)) + .filter( + users_collections::collection_uuid.eq(uuid).or( // Directly accessed collection + users_organizations::access_all.eq(true).or( // access_all in Organization + users_organizations::atype.le(MembershipType::Admin as i32) // Org admin or owner + )).or( + groups::access_all.eq(true) // access_all in groups + ).or( // access via groups + groups_users::users_organizations_uuid.eq(users_organizations::uuid).and( + collections_groups::collections_uuid.is_not_null() + ) ) - .left_join( - collections_groups::table.on(collections_groups::groups_uuid - .eq(groups_users::groups_uuid) - .and(collections_groups::collections_uuid.eq(collections::uuid))), - ) - .filter(collections::uuid.eq(uuid)) - .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) - .filter( - users_collections::collection_uuid - .eq(uuid) - .or( - // Directly accessed collection - users_organizations::access_all.eq(true).or( - // access_all in Organization - users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin or owner - ), - ) - .or( - groups::access_all.eq(true), // access_all in groups - ) - .or( - // access via groups - groups_users::users_organizations_uuid - .eq(users_organizations::uuid) - .and(collections_groups::collections_uuid.is_not_null()), - ), - ) - .select(collections::all_columns) - .first::(conn) - .ok() - }) - .await + ).select(collections::all_columns) + .first::(conn) + .ok() + }} } else { - conn.run(move |conn| { + db_run! { conn: { collections::table - .left_join( - users_collections::table.on(users_collections::collection_uuid - .eq(collections::uuid) - .and(users_collections::user_uuid.eq(user_uuid.clone()))), + .left_join(users_collections::table.on( + users_collections::collection_uuid.eq(collections::uuid).and( + users_collections::user_uuid.eq(user_uuid.clone()) ) - .left_join( - users_organizations::table.on(collections::org_uuid - .eq(users_organizations::org_uuid) - .and(users_organizations::user_uuid.eq(user_uuid))), + )) + .left_join(users_organizations::table.on( + collections::org_uuid.eq(users_organizations::org_uuid).and( + users_organizations::user_uuid.eq(user_uuid) ) - .filter(collections::uuid.eq(uuid)) - .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) - .filter(users_collections::collection_uuid.eq(uuid).or( - // Directly accessed collection - users_organizations::access_all.eq(true).or( - // access_all in Organization - users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin or owner - ), + )) + .filter(collections::uuid.eq(uuid)) + .filter( + users_collections::collection_uuid.eq(uuid).or( // Directly accessed collection + users_organizations::access_all.eq(true).or( // access_all in Organization + users_organizations::atype.le(MembershipType::Admin as i32) // Org admin or owner )) - .select(collections::all_columns) - .first::(conn) - .ok() - }) - .await + ).select(collections::all_columns) + .first::(conn) + .ok() + }} } } pub async fn is_writable_by_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool { let user_uuid = user_uuid.to_string(); if CONFIG.org_groups_enabled() { - conn.run(move |conn| { + db_run! { conn: { collections::table .filter(collections::uuid.eq(&self.uuid)) - .inner_join( - users_organizations::table.on(collections::org_uuid - .eq(users_organizations::org_uuid) - .and(users_organizations::user_uuid.eq(user_uuid.clone())) - .and(users_organizations::status.eq(MembershipStatus::Confirmed as i32))), - ) - .left_join( - users_collections::table.on(users_collections::collection_uuid - .eq(collections::uuid) - .and(users_collections::user_uuid.eq(user_uuid))), - ) - .left_join( - groups_users::table.on(groups_users::users_organizations_uuid.eq(users_organizations::uuid)), - ) - .left_join( - groups::table.on(groups::uuid - .eq(groups_users::groups_uuid) - .and(groups::organizations_uuid.eq(users_organizations::org_uuid))), - ) - .left_join( - collections_groups::table.on(collections_groups::groups_uuid - .eq(groups_users::groups_uuid) - .and(collections_groups::collections_uuid.eq(collections::uuid))), - ) - .filter( - users_organizations::atype - .eq_any(ORG_ADMIN_ATYPES) // Org admin or owner - .or(users_organizations::access_all.eq(true)) // access_all via membership - .or(users_collections::collection_uuid - .eq(&self.uuid) // write access given to collection - .and(users_collections::read_only.eq(false))) - .or(groups::access_all.eq(true)) // access_all via group - .or(collections_groups::collections_uuid - .is_not_null() // write access given via group - .and(collections_groups::read_only.eq(false))), + .inner_join(users_organizations::table.on( + collections::org_uuid.eq(users_organizations::org_uuid) + .and(users_organizations::user_uuid.eq(user_uuid.clone())) + )) + .left_join(users_collections::table.on( + users_collections::collection_uuid.eq(collections::uuid) + .and(users_collections::user_uuid.eq(user_uuid)) + )) + .left_join(groups_users::table.on( + groups_users::users_organizations_uuid.eq(users_organizations::uuid) + )) + .left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid) + .and(groups::organizations_uuid.eq(users_organizations::org_uuid)) + )) + .left_join(collections_groups::table.on( + collections_groups::groups_uuid.eq(groups_users::groups_uuid) + .and(collections_groups::collections_uuid.eq(collections::uuid)) + )) + .filter(users_organizations::atype.le(MembershipType::Admin as i32) // Org admin or owner + .or(users_organizations::access_all.eq(true)) // access_all via membership + .or(users_collections::collection_uuid.eq(&self.uuid) // write access given to collection + .and(users_collections::read_only.eq(false))) + .or(groups::access_all.eq(true)) // access_all via group + .or(collections_groups::collections_uuid.is_not_null() // write access given via group + .and(collections_groups::read_only.eq(false))) ) .count() .first::(conn) .ok() - .unwrap_or(0) - != 0 - }) - .await + .unwrap_or(0) != 0 + }} } else { - conn.run(move |conn| { + db_run! { conn: { collections::table .filter(collections::uuid.eq(&self.uuid)) - .inner_join( - users_organizations::table.on(collections::org_uuid - .eq(users_organizations::org_uuid) - .and(users_organizations::user_uuid.eq(user_uuid.clone())) - .and(users_organizations::status.eq(MembershipStatus::Confirmed as i32))), - ) - .left_join( - users_collections::table.on(users_collections::collection_uuid - .eq(collections::uuid) - .and(users_collections::user_uuid.eq(user_uuid))), - ) - .filter( - users_organizations::atype - .eq_any(ORG_ADMIN_ATYPES) // Org admin or owner - .or(users_organizations::access_all.eq(true)) // access_all via membership - .or(users_collections::collection_uuid - .eq(&self.uuid) // write access given to collection - .and(users_collections::read_only.eq(false))), + .inner_join(users_organizations::table.on( + collections::org_uuid.eq(users_organizations::org_uuid) + .and(users_organizations::user_uuid.eq(user_uuid.clone())) + )) + .left_join(users_collections::table.on( + users_collections::collection_uuid.eq(collections::uuid) + .and(users_collections::user_uuid.eq(user_uuid)) + )) + .filter(users_organizations::atype.le(MembershipType::Admin as i32) // Org admin or owner + .or(users_organizations::access_all.eq(true)) // access_all via membership + .or(users_collections::collection_uuid.eq(&self.uuid) // write access given to collection + .and(users_collections::read_only.eq(false))) ) .count() .first::(conn) .ok() - .unwrap_or(0) - != 0 - }) - .await + .unwrap_or(0) != 0 + }} } } pub async fn hide_passwords_for_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool { let user_uuid = user_uuid.to_string(); - conn.run(move |conn| { + db_run! { conn: { collections::table - .left_join( - users_collections::table.on(users_collections::collection_uuid - .eq(collections::uuid) - .and(users_collections::user_uuid.eq(user_uuid.clone()))), + .left_join(users_collections::table.on( + users_collections::collection_uuid.eq(collections::uuid).and( + users_collections::user_uuid.eq(user_uuid.clone()) ) - .left_join( - users_organizations::table.on(collections::org_uuid - .eq(users_organizations::org_uuid) - .and(users_organizations::user_uuid.eq(user_uuid))), + )) + .left_join(users_organizations::table.on( + collections::org_uuid.eq(users_organizations::org_uuid).and( + users_organizations::user_uuid.eq(user_uuid) ) - .left_join(groups_users::table.on(groups_users::users_organizations_uuid.eq(users_organizations::uuid))) - .left_join( - groups::table.on(groups::uuid - .eq(groups_users::groups_uuid) - .and(groups::organizations_uuid.eq(users_organizations::org_uuid))), + )) + .left_join(groups_users::table.on( + groups_users::users_organizations_uuid.eq(users_organizations::uuid) + )) + .left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid) + .and(groups::organizations_uuid.eq(users_organizations::org_uuid)) + )) + .left_join(collections_groups::table.on( + collections_groups::groups_uuid.eq(groups_users::groups_uuid).and( + collections_groups::collections_uuid.eq(collections::uuid) ) - .left_join( - collections_groups::table.on(collections_groups::groups_uuid - .eq(groups_users::groups_uuid) - .and(collections_groups::collections_uuid.eq(collections::uuid))), + )) + .filter(collections::uuid.eq(&self.uuid)) + .filter( + users_collections::collection_uuid.eq(&self.uuid).and(users_collections::hide_passwords.eq(true)).or(// Directly accessed collection + users_organizations::access_all.eq(true).or( // access_all in Organization + users_organizations::atype.le(MembershipType::Admin as i32) // Org admin or owner + )).or( + groups::access_all.eq(true) // access_all in groups + ).or( // access via groups + groups_users::users_organizations_uuid.eq(users_organizations::uuid).and( + collections_groups::collections_uuid.is_not_null().and( + collections_groups::hide_passwords.eq(true)) + ) ) - .filter(collections::uuid.eq(&self.uuid)) - .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) - .filter( - users_collections::collection_uuid - .eq(&self.uuid) - .and(users_collections::hide_passwords.eq(true)) - .or( - // Directly accessed collection - users_organizations::access_all.eq(true).or( - // access_all in Organization - users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin or owner - ), - ) - .or( - groups::access_all.eq(true), // access_all in groups - ) - .or( - // access via groups - groups_users::users_organizations_uuid.eq(users_organizations::uuid).and( - collections_groups::collections_uuid - .is_not_null() - .and(collections_groups::hide_passwords.eq(true)), - ), - ), - ) - .count() - .first::(conn) - .ok() - .unwrap_or(0) - != 0 - }) - .await + ) + .count() + .first::(conn) + .ok() + .unwrap_or(0) != 0 + }} } pub async fn is_coll_manageable_by_user(uuid: &CollectionId, user_uuid: &UserId, conn: &DbConn) -> bool { let uuid = uuid.to_string(); let user_uuid = user_uuid.to_string(); - conn.run(move |conn| { + db_run! { conn: { collections::table - .left_join( - users_collections::table.on(users_collections::collection_uuid - .eq(collections::uuid) - .and(users_collections::user_uuid.eq(user_uuid.clone()))), + .left_join(users_collections::table.on( + users_collections::collection_uuid.eq(collections::uuid).and( + users_collections::user_uuid.eq(user_uuid.clone()) ) - .left_join( - users_organizations::table.on(collections::org_uuid - .eq(users_organizations::org_uuid) - .and(users_organizations::user_uuid.eq(user_uuid))), + )) + .left_join(users_organizations::table.on( + collections::org_uuid.eq(users_organizations::org_uuid).and( + users_organizations::user_uuid.eq(user_uuid) ) - .left_join(groups_users::table.on(groups_users::users_organizations_uuid.eq(users_organizations::uuid))) - .left_join( - groups::table.on(groups::uuid - .eq(groups_users::groups_uuid) - .and(groups::organizations_uuid.eq(users_organizations::org_uuid))), + )) + .left_join(groups_users::table.on( + groups_users::users_organizations_uuid.eq(users_organizations::uuid) + )) + .left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid) + .and(groups::organizations_uuid.eq(users_organizations::org_uuid)) + )) + .left_join(collections_groups::table.on( + collections_groups::groups_uuid.eq(groups_users::groups_uuid).and( + collections_groups::collections_uuid.eq(collections::uuid) ) - .left_join( - collections_groups::table.on(collections_groups::groups_uuid - .eq(groups_users::groups_uuid) - .and(collections_groups::collections_uuid.eq(collections::uuid))), + )) + .filter(collections::uuid.eq(&uuid)) + .filter( + users_collections::collection_uuid.eq(&uuid).and(users_collections::manage.eq(true)).or(// Directly accessed collection + users_organizations::access_all.eq(true).or( // access_all in Organization + users_organizations::atype.le(MembershipType::Admin as i32) // Org admin or owner + )).or( + groups::access_all.eq(true) // access_all in groups + ).or( // access via groups + groups_users::users_organizations_uuid.eq(users_organizations::uuid).and( + collections_groups::collections_uuid.is_not_null().and( + collections_groups::manage.eq(true)) + ) ) - .filter(collections::uuid.eq(&uuid)) - .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) - .filter( - users_collections::collection_uuid - .eq(&uuid) - .and(users_collections::manage.eq(true)) - .or( - // Directly accessed collection - users_organizations::access_all.eq(true).or( - // access_all in Organization - users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin or owner - ), - ) - .or( - groups::access_all.eq(true), // access_all in groups - ) - .or( - // access via groups - groups_users::users_organizations_uuid.eq(users_organizations::uuid).and( - collections_groups::collections_uuid - .is_not_null() - .and(collections_groups::manage.eq(true)), - ), - ), - ) - .count() - .first::(conn) - .ok() - .unwrap_or(0) - != 0 - }) - .await + ) + .count() + .first::(conn) + .ok() + .unwrap_or(0) != 0 + }} } pub async fn is_manageable_by_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool { Self::is_coll_manageable_by_user(&self.uuid, user_uuid, conn).await } - - // Whether the user has manage access to at least one collection in the org, directly or via a - // group. Org-scoped counterpart of is_coll_manageable_by_user. - pub async fn has_manageable_collection_by_user( - org_uuid: &OrganizationId, - user_uuid: &UserId, - conn: &DbConn, - ) -> bool { - let org_uuid = org_uuid.to_string(); - let user_uuid = user_uuid.to_string(); - conn.run(move |conn| { - collections::table - .left_join( - users_collections::table.on(users_collections::collection_uuid - .eq(collections::uuid) - .and(users_collections::user_uuid.eq(user_uuid.clone()))), - ) - .left_join( - users_organizations::table.on(collections::org_uuid - .eq(users_organizations::org_uuid) - .and(users_organizations::user_uuid.eq(user_uuid))), - ) - .left_join(groups_users::table.on(groups_users::users_organizations_uuid.eq(users_organizations::uuid))) - .left_join( - collections_groups::table.on(collections_groups::groups_uuid - .eq(groups_users::groups_uuid) - .and(collections_groups::collections_uuid.eq(collections::uuid))), - ) - .filter(collections::org_uuid.eq(&org_uuid)) - .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) - .filter( - // Manage permission on a collection assigned directly or via a group. - users_collections::manage.eq(true).or(collections_groups::manage.eq(true)), - ) - .count() - .first::(conn) - .ok() - .unwrap_or(0) - != 0 - }) - .await - } } /// Database methods @@ -678,7 +572,7 @@ impl CollectionUser { user_uuid: &UserId, conn: &DbConn, ) -> Vec { - conn.run(move |conn| { + db_run! { conn: { users_collections::table .filter(users_collections::user_uuid.eq(user_uuid)) .inner_join(collections::table.on(collections::uuid.eq(users_collections::collection_uuid))) @@ -686,35 +580,24 @@ impl CollectionUser { .select(users_collections::all_columns) .load::(conn) .expect("Error loading users_collections") - }) - .await + }} } pub async fn find_by_organization_swap_user_uuid_with_member_uuid( org_uuid: &OrganizationId, conn: &DbConn, ) -> Vec { - let col_users = conn - .run(move |conn| { - users_collections::table - .inner_join(collections::table.on(collections::uuid.eq(users_collections::collection_uuid))) - .filter(collections::org_uuid.eq(org_uuid)) - .inner_join( - users_organizations::table.on(users_organizations::user_uuid.eq(users_collections::user_uuid)), - ) - .filter(users_organizations::org_uuid.eq(org_uuid)) - .select(( - users_organizations::uuid, - users_collections::collection_uuid, - users_collections::read_only, - users_collections::hide_passwords, - users_collections::manage, - )) - .load::(conn) - .expect("Error loading users_collections") - }) - .await; - col_users.into_iter().map(Into::into).collect() + let col_users = db_run! { conn: { + users_collections::table + .inner_join(collections::table.on(collections::uuid.eq(users_collections::collection_uuid))) + .filter(collections::org_uuid.eq(org_uuid)) + .inner_join(users_organizations::table.on(users_organizations::user_uuid.eq(users_collections::user_uuid))) + .filter(users_organizations::org_uuid.eq(org_uuid)) + .select((users_organizations::uuid, users_collections::collection_uuid, users_collections::read_only, users_collections::hide_passwords, users_collections::manage)) + .load::(conn) + .expect("Error loading users_collections") + }}; + col_users.into_iter().map(|c| c.into()).collect() } pub async fn save( @@ -727,30 +610,53 @@ impl CollectionUser { ) -> EmptyResult { User::update_uuid_revision(user_uuid, conn).await; - let values = ( - users_collections::user_uuid.eq(user_uuid), - users_collections::collection_uuid.eq(collection_uuid), - users_collections::read_only.eq(read_only), - users_collections::hide_passwords.eq(hide_passwords), - users_collections::manage.eq(manage), - ); - db_run! { conn: - mysql { - diesel::insert_into(users_collections::table) - .values(values) - .on_conflict(diesel::dsl::DuplicatedKeys) - .do_update() - .set(values) + sqlite, mysql { + match diesel::replace_into(users_collections::table) + .values(( + users_collections::user_uuid.eq(user_uuid), + users_collections::collection_uuid.eq(collection_uuid), + users_collections::read_only.eq(read_only), + users_collections::hide_passwords.eq(hide_passwords), + users_collections::manage.eq(manage), + )) .execute(conn) - .map_res("Error adding user to collection") + { + Ok(_) => Ok(()), + // Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first. + Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { + diesel::update(users_collections::table) + .filter(users_collections::user_uuid.eq(user_uuid)) + .filter(users_collections::collection_uuid.eq(collection_uuid)) + .set(( + users_collections::user_uuid.eq(user_uuid), + users_collections::collection_uuid.eq(collection_uuid), + users_collections::read_only.eq(read_only), + users_collections::hide_passwords.eq(hide_passwords), + users_collections::manage.eq(manage), + )) + .execute(conn) + .map_res("Error adding user to collection") + } + Err(e) => Err(e.into()), + }.map_res("Error adding user to collection") } - postgresql, sqlite { + postgresql { diesel::insert_into(users_collections::table) - .values(values) + .values(( + users_collections::user_uuid.eq(user_uuid), + users_collections::collection_uuid.eq(collection_uuid), + users_collections::read_only.eq(read_only), + users_collections::hide_passwords.eq(hide_passwords), + users_collections::manage.eq(manage), + )) .on_conflict((users_collections::user_uuid, users_collections::collection_uuid)) .do_update() - .set(values) + .set(( + users_collections::read_only.eq(read_only), + users_collections::hide_passwords.eq(hide_passwords), + users_collections::manage.eq(manage), + )) .execute(conn) .map_res("Error adding user to collection") } @@ -760,7 +666,7 @@ impl CollectionUser { pub async fn delete(self, conn: &DbConn) -> EmptyResult { User::update_uuid_revision(&self.user_uuid, conn).await; - conn.run(move |conn| { + db_run! { conn: { diesel::delete( users_collections::table .filter(users_collections::user_uuid.eq(&self.user_uuid)) @@ -768,19 +674,17 @@ impl CollectionUser { ) .execute(conn) .map_res("Error removing user from collection") - }) - .await + }} } pub async fn find_by_collection(collection_uuid: &CollectionId, conn: &DbConn) -> Vec { - conn.run(move |conn| { + db_run! { conn: { users_collections::table .filter(users_collections::collection_uuid.eq(collection_uuid)) .select(users_collections::all_columns) .load::(conn) .expect("Error loading users_collections") - }) - .await + }} } pub async fn find_by_org_and_coll_swap_user_uuid_with_member_uuid( @@ -788,26 +692,16 @@ impl CollectionUser { collection_uuid: &CollectionId, conn: &DbConn, ) -> Vec { - let col_users = conn - .run(move |conn| { - users_collections::table - .filter(users_collections::collection_uuid.eq(collection_uuid)) - .filter(users_organizations::org_uuid.eq(org_uuid)) - .inner_join( - users_organizations::table.on(users_organizations::user_uuid.eq(users_collections::user_uuid)), - ) - .select(( - users_organizations::uuid, - users_collections::collection_uuid, - users_collections::read_only, - users_collections::hide_passwords, - users_collections::manage, - )) - .load::(conn) - .expect("Error loading users_collections") - }) - .await; - col_users.into_iter().map(Into::into).collect() + let col_users = db_run! { conn: { + users_collections::table + .filter(users_collections::collection_uuid.eq(collection_uuid)) + .filter(users_organizations::org_uuid.eq(org_uuid)) + .inner_join(users_organizations::table.on(users_organizations::user_uuid.eq(users_collections::user_uuid))) + .select((users_organizations::uuid, users_collections::collection_uuid, users_collections::read_only, users_collections::hide_passwords, users_collections::manage)) + .load::(conn) + .expect("Error loading users_collections") + }}; + col_users.into_iter().map(|c| c.into()).collect() } pub async fn find_by_collection_and_user( @@ -815,47 +709,36 @@ impl CollectionUser { user_uuid: &UserId, conn: &DbConn, ) -> Option { - conn.run(move |conn| { + db_run! { conn: { users_collections::table .filter(users_collections::collection_uuid.eq(collection_uuid)) .filter(users_collections::user_uuid.eq(user_uuid)) .select(users_collections::all_columns) .first::(conn) .ok() - }) - .await + }} } - // Only returns the collections of organizations the user is a confirmed member of pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { - conn.run(move |conn| { + db_run! { conn: { users_collections::table - .inner_join(collections::table.on(collections::uuid.eq(users_collections::collection_uuid))) - .inner_join( - users_organizations::table.on(users_organizations::org_uuid - .eq(collections::org_uuid) - .and(users_organizations::user_uuid.eq(users_collections::user_uuid))), - ) .filter(users_collections::user_uuid.eq(user_uuid)) - .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .select(users_collections::all_columns) .load::(conn) .expect("Error loading users_collections") - }) - .await + }} } pub async fn delete_all_by_collection(collection_uuid: &CollectionId, conn: &DbConn) -> EmptyResult { - for collection in &CollectionUser::find_by_collection(collection_uuid, conn).await { + for collection in CollectionUser::find_by_collection(collection_uuid, conn).await.iter() { User::update_uuid_revision(&collection.user_uuid, conn).await; } - conn.run(move |conn| { + db_run! { conn: { diesel::delete(users_collections::table.filter(users_collections::collection_uuid.eq(collection_uuid))) .execute(conn) .map_res("Error deleting users from collection") - }) - .await + }} } pub async fn delete_all_by_user_and_org( @@ -865,21 +748,17 @@ impl CollectionUser { ) -> EmptyResult { let collectionusers = Self::find_by_organization_and_user_uuid(org_uuid, user_uuid, conn).await; - conn.run(move |conn| { + db_run! { conn: { for user in collectionusers { - let _: () = diesel::delete( - users_collections::table.filter( - users_collections::user_uuid - .eq(user_uuid) - .and(users_collections::collection_uuid.eq(user.collection_uuid)), - ), - ) - .execute(conn) - .map_res("Error removing user from collections")?; + let _: () = diesel::delete(users_collections::table.filter( + users_collections::user_uuid.eq(user_uuid) + .and(users_collections::collection_uuid.eq(user.collection_uuid)) + )) + .execute(conn) + .map_res("Error removing user from collections")?; } Ok(()) - }) - .await + }} } pub async fn has_access_to_collection_by_user(col_id: &CollectionId, user_uuid: &UserId, conn: &DbConn) -> bool { @@ -893,18 +772,19 @@ impl CollectionCipher { Self::update_users_revision(collection_uuid, conn).await; db_run! { conn: - mysql { - diesel::insert_into(ciphers_collections::table) + sqlite, mysql { + // Not checking for ForeignKey Constraints here. + // Table ciphers_collections does not have ForeignKey Constraints which would cause conflicts. + // This table has no constraints pointing to itself, but only to others. + diesel::replace_into(ciphers_collections::table) .values(( ciphers_collections::cipher_uuid.eq(cipher_uuid), ciphers_collections::collection_uuid.eq(collection_uuid), )) - .on_conflict(diesel::dsl::DuplicatedKeys) - .do_nothing() .execute(conn) .map_res("Error adding cipher to collection") } - postgresql, sqlite { + postgresql { diesel::insert_into(ciphers_collections::table) .values(( ciphers_collections::cipher_uuid.eq(cipher_uuid), @@ -921,7 +801,7 @@ impl CollectionCipher { pub async fn delete(cipher_uuid: &CipherId, collection_uuid: &CollectionId, conn: &DbConn) -> EmptyResult { Self::update_users_revision(collection_uuid, conn).await; - conn.run(move |conn| { + db_run! { conn: { diesel::delete( ciphers_collections::table .filter(ciphers_collections::cipher_uuid.eq(cipher_uuid)) @@ -929,26 +809,23 @@ impl CollectionCipher { ) .execute(conn) .map_res("Error deleting cipher from collection") - }) - .await + }} } pub async fn delete_all_by_cipher(cipher_uuid: &CipherId, conn: &DbConn) -> EmptyResult { - conn.run(move |conn| { + db_run! { conn: { diesel::delete(ciphers_collections::table.filter(ciphers_collections::cipher_uuid.eq(cipher_uuid))) .execute(conn) .map_res("Error removing cipher from collections") - }) - .await + }} } pub async fn delete_all_by_collection(collection_uuid: &CollectionId, conn: &DbConn) -> EmptyResult { - conn.run(move |conn| { + db_run! { conn: { diesel::delete(ciphers_collections::table.filter(ciphers_collections::collection_uuid.eq(collection_uuid))) .execute(conn) .map_res("Error removing ciphers from collection") - }) - .await + }} } pub async fn update_users_revision(collection_uuid: &CollectionId, conn: &DbConn) { diff --git a/src/db/models/device.rs b/src/db/models/device.rs index 5e5f1f97..1026574c 100644 --- a/src/db/models/device.rs +++ b/src/db/models/device.rs @@ -1,20 +1,18 @@ use chrono::{NaiveDateTime, Utc}; + use data_encoding::BASE64URL; use derive_more::{Display, From}; -use diesel::prelude::*; use serde_json::Value; +use super::{AuthRequest, UserId}; +use crate::db::schema::devices; use crate::{ - api::EmptyResult, crypto, - db::{DbConn, schema::devices}, - error::MapResult, util::{format_date, get_uuid}, }; +use diesel::prelude::*; use macros::{IdFromParam, UuidFromParam}; -use super::{AuthRequest, UserId}; - #[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[diesel(table_name = devices)] #[diesel(treat_none_as_null = true)] @@ -27,7 +25,7 @@ pub struct Device { pub user_uuid: UserId, pub name: String, - pub atype: i32, // https://github.com/bitwarden/server/blob/8d547dcc280babab70dd4a3c94ced6a34b12dfbf/src/Core/Enums/DeviceType.cs + pub atype: i32, // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/Enums/DeviceType.cs pub push_uuid: Option, pub push_token: Option, @@ -137,6 +135,10 @@ impl DeviceWithAuthRequest { } } } +use crate::db::DbConn; + +use crate::api::EmptyResult; +use crate::error::MapResult; /// Database methods impl Device { @@ -146,18 +148,15 @@ impl Device { } db_run! { conn: - mysql { + sqlite, mysql { crate::util::retry(|| - diesel::insert_into(devices::table) + diesel::replace_into(devices::table) .values(&*self) - .on_conflict(diesel::dsl::DuplicatedKeys) - .do_update() - .set(&*self) .execute(conn), 10, ).map_res("Error saving device") } - postgresql, sqlite { + postgresql { crate::util::retry(|| diesel::insert_into(devices::table) .values(&*self) @@ -172,23 +171,21 @@ impl Device { } pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { - conn.run(move |conn| { + db_run! { conn: { diesel::delete(devices::table.filter(devices::user_uuid.eq(user_uuid))) .execute(conn) .map_res("Error removing devices for user") - }) - .await + }} } pub async fn find_by_uuid_and_user(uuid: &DeviceId, user_uuid: &UserId, conn: &DbConn) -> Option { - conn.run(move |conn| { + db_run! { conn: { devices::table .filter(devices::uuid.eq(uuid)) .filter(devices::user_uuid.eq(user_uuid)) .first::(conn) .ok() - }) - .await + }} } pub async fn find_with_auth_request_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { @@ -202,65 +199,71 @@ impl Device { } pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { - conn.run(move |conn| { - devices::table.filter(devices::user_uuid.eq(user_uuid)).load::(conn).expect("Error loading devices") - }) - .await + db_run! { conn: { + devices::table + .filter(devices::user_uuid.eq(user_uuid)) + .load::(conn) + .expect("Error loading devices") + }} } pub async fn find_by_uuid(uuid: &DeviceId, conn: &DbConn) -> Option { - conn.run(move |conn| devices::table.filter(devices::uuid.eq(uuid)).first::(conn).ok()).await + db_run! { conn: { + devices::table + .filter(devices::uuid.eq(uuid)) + .first::(conn) + .ok() + }} } pub async fn clear_push_token_by_uuid(uuid: &DeviceId, conn: &DbConn) -> EmptyResult { - conn.run(move |conn| { + db_run! { conn: { diesel::update(devices::table) .filter(devices::uuid.eq(uuid)) .set(devices::push_token.eq::>(None)) .execute(conn) .map_res("Error removing push token") - }) - .await + }} } pub async fn find_by_refresh_token(refresh_token: &str, conn: &DbConn) -> Option { - conn.run(move |conn| devices::table.filter(devices::refresh_token.eq(refresh_token)).first::(conn).ok()) - .await + db_run! { conn: { + devices::table + .filter(devices::refresh_token.eq(refresh_token)) + .first::(conn) + .ok() + }} } pub async fn find_latest_active_by_user(user_uuid: &UserId, conn: &DbConn) -> Option { - conn.run(move |conn| { + db_run! { conn: { devices::table .filter(devices::user_uuid.eq(user_uuid)) .order(devices::updated_at.desc()) .first::(conn) .ok() - }) - .await + }} } pub async fn find_push_devices_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { - conn.run(move |conn| { + db_run! { conn: { devices::table .filter(devices::user_uuid.eq(user_uuid)) .filter(devices::push_token.is_not_null()) .load::(conn) .expect("Error loading push devices") - }) - .await + }} } pub async fn check_user_has_push_device(user_uuid: &UserId, conn: &DbConn) -> bool { - conn.run(move |conn| { + db_run! { conn: { devices::table - .filter(devices::user_uuid.eq(user_uuid)) - .filter(devices::push_token.is_not_null()) - .count() - .first::(conn) - .ok() - .unwrap_or(0) - != 0 - }) - .await + .filter(devices::user_uuid.eq(user_uuid)) + .filter(devices::push_token.is_not_null()) + .count() + .first::(conn) + .ok() + .unwrap_or(0) != 0 + }} } pub async fn rotate_refresh_tokens_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { @@ -269,22 +272,10 @@ impl Device { let devices = Self::find_by_user(user_uuid, conn).await; for mut device in devices { device.refresh_token = Device::generate_refresh_token(); - device.twofactor_remember = None; device.save(false, conn).await?; } Ok(()) } - - pub async fn clear_twofactor_remember_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { - conn.run(move |conn| { - diesel::update(devices::table) - .filter(devices::user_uuid.eq(user_uuid)) - .set(devices::twofactor_remember.eq::>(None)) - .execute(conn) - .map_res("Error removing two factor remember tokens") - }) - .await - } } #[derive(Display)] @@ -341,12 +332,9 @@ pub enum DeviceType { MacOsCLI = 24, #[display("Linux CLI")] LinuxCLI = 25, - #[display("DuckDuckGo")] - DuckDuckGoBrowser = 26, } impl DeviceType { - #[expect(clippy::match_same_arms, reason = "Specifically define 14 and have a fallback for new types")] pub fn from_i32(value: i32) -> DeviceType { match value { 0 => DeviceType::Android, @@ -375,7 +363,6 @@ impl DeviceType { 23 => DeviceType::WindowsCLI, 24 => DeviceType::MacOsCLI, 25 => DeviceType::LinuxCLI, - 26 => DeviceType::DuckDuckGoBrowser, _ => DeviceType::UnknownBrowser, } } diff --git a/src/db/models/emergency_access.rs b/src/db/models/emergency_access.rs index 02c05d5e..cf7f5385 100644 --- a/src/db/models/emergency_access.rs +++ b/src/db/models/emergency_access.rs @@ -1,16 +1,12 @@ -use chrono::{NaiveDateTime, TimeDelta, Utc}; +use chrono::{NaiveDateTime, Utc}; use derive_more::{AsRef, Deref, Display, From}; -use diesel::prelude::*; use serde_json::Value; -use crate::{ - api::EmptyResult, - db::{DbConn, schema::emergency_access}, - error::MapResult, -}; -use macros::UuidFromParam; - use super::{User, UserId}; +use crate::db::schema::emergency_access; +use crate::{api::EmptyResult, db::DbConn, error::MapResult}; +use diesel::prelude::*; +use macros::UuidFromParam; #[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[diesel(table_name = emergency_access)] @@ -89,15 +85,17 @@ impl EmergencyAccess { pub async fn to_json_grantee_details(&self, conn: &DbConn) -> Option { let grantee_user = if let Some(grantee_uuid) = &self.grantee_uuid { User::find_by_uuid(grantee_uuid, conn).await.expect("Grantee user not found.") - } else { - let email = self.email.as_deref()?; - if let Some(user) = User::find_by_mail(email, conn).await { - user - } else { - // remove outstanding invitations which should not exist - Self::delete_all_by_grantee_email(email, conn).await.ok(); - return None; + } else if let Some(email) = self.email.as_deref() { + match User::find_by_mail(email, conn).await { + Some(user) => user, + None => { + // remove outstanding invitations which should not exist + Self::delete_all_by_grantee_email(email, conn).await.ok(); + return None; + } } + } else { + return None; }; Some(json!({ @@ -112,26 +110,8 @@ impl EmergencyAccess { "object": "emergencyAccessGranteeDetails", })) } - - pub fn deadline_at(&self) -> Result { - if !(EMERGENCY_ACCESS_TIMEOUT_RANGE).contains(&self.wait_time_days) { - err!("Emergency access wait time is out-of-bounds!") - } - - let initiated_at = self.recovery_initiated_at.map_res("Emergency access initiation date missing!")?; - - TimeDelta::try_days(i64::from(self.wait_time_days)) - .and_then(|w| initiated_at.checked_add_signed(w)) - .map_res("Emergency access deadline is invalid") - } - - pub fn is_recovery_allowed(&self, now: &NaiveDateTime) -> Result { - Ok(self.deadline_at()?.le(now)) - } } -pub const EMERGENCY_ACCESS_TIMEOUT_RANGE: std::ops::RangeInclusive = 1..=90; - #[derive(Copy, Clone)] pub enum EmergencyAccessType { View = 0, @@ -164,16 +144,24 @@ impl EmergencyAccess { self.updated_at = Utc::now().naive_utc(); db_run! { conn: - mysql { - diesel::insert_into(emergency_access::table) + sqlite, mysql { + match diesel::replace_into(emergency_access::table) .values(&*self) - .on_conflict(diesel::dsl::DuplicatedKeys) - .do_update() - .set(&*self) .execute(conn) - .map_res("Error saving emergency access") + { + Ok(_) => Ok(()), + // Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first. + Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { + diesel::update(emergency_access::table) + .filter(emergency_access::uuid.eq(&self.uuid)) + .set(&*self) + .execute(conn) + .map_res("Error updating emergency access") + } + Err(e) => Err(e.into()), + }.map_res("Error saving emergency access") } - postgresql, sqlite { + postgresql { diesel::insert_into(emergency_access::table) .values(&*self) .on_conflict(emergency_access::uuid) @@ -187,60 +175,37 @@ impl EmergencyAccess { pub async fn update_access_status_and_save( &mut self, - from_status: i32, - to_status: i32, + status: i32, date: &NaiveDateTime, conn: &DbConn, ) -> EmptyResult { - let uuid = self.uuid.clone(); - let updated_at = *date; - let res = conn - .run(move |conn| { - crate::util::retry( - || { - diesel::update( - emergency_access::table - .filter(emergency_access::uuid.eq(&uuid)) - .filter(emergency_access::status.eq(from_status)), - ) - .set((emergency_access::status.eq(to_status), emergency_access::updated_at.eq(updated_at))) - .execute(conn) - }, - 10, - ) - }) - .await; - - match res { - Ok(1) => {} // One record updated, which is valid - Ok(_) => err!("Emergency access status was changed already"), - Err(e) => err!("Error updating emergency access status", format!("{e}")), - } - - self.status = to_status; - self.updated_at = updated_at; - // Update the grantee so that it will refresh it's status. User::update_uuid_revision(self.grantee_uuid.as_ref().expect("Error getting grantee"), conn).await; - Ok(()) + self.status = status; + date.clone_into(&mut self.updated_at); + + db_run! { conn: { + crate::util::retry(|| { + diesel::update(emergency_access::table.filter(emergency_access::uuid.eq(&self.uuid))) + .set((emergency_access::status.eq(status), emergency_access::updated_at.eq(date))) + .execute(conn) + }, 10) + .map_res("Error updating emergency access status") + }} } pub async fn update_last_notification_date_and_save(&mut self, date: &NaiveDateTime, conn: &DbConn) -> EmptyResult { self.last_notification_at = Some(date.to_owned()); date.clone_into(&mut self.updated_at); - conn.run(move |conn| { - crate::util::retry( - || { - diesel::update(emergency_access::table.filter(emergency_access::uuid.eq(&self.uuid))) - .set((emergency_access::last_notification_at.eq(date), emergency_access::updated_at.eq(date))) - .execute(conn) - }, - 10, - ) + db_run! { conn: { + crate::util::retry(|| { + diesel::update(emergency_access::table.filter(emergency_access::uuid.eq(&self.uuid))) + .set((emergency_access::last_notification_at.eq(date), emergency_access::updated_at.eq(date))) + .execute(conn) + }, 10) .map_res("Error updating emergency access status") - }) - .await + }} } pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { @@ -263,12 +228,11 @@ impl EmergencyAccess { pub async fn delete(self, conn: &DbConn) -> EmptyResult { User::update_uuid_revision(&self.grantor_uuid, conn).await; - conn.run(move |conn| { + db_run! { conn: { diesel::delete(emergency_access::table.filter(emergency_access::uuid.eq(self.uuid))) .execute(conn) .map_res("Error removing user from emergency access") - }) - .await + }} } pub async fn find_by_grantor_uuid_and_grantee_uuid_or_email( @@ -277,25 +241,23 @@ impl EmergencyAccess { email: &str, conn: &DbConn, ) -> Option { - conn.run(move |conn| { + db_run! { conn: { emergency_access::table .filter(emergency_access::grantor_uuid.eq(grantor_uuid)) .filter(emergency_access::grantee_uuid.eq(grantee_uuid).or(emergency_access::email.eq(email))) .first::(conn) .ok() - }) - .await + }} } pub async fn find_all_recoveries_initiated(conn: &DbConn) -> Vec { - conn.run(move |conn| { + db_run! { conn: { emergency_access::table .filter(emergency_access::status.eq(EmergencyAccessStatus::RecoveryInitiated as i32)) .filter(emergency_access::recovery_initiated_at.is_not_null()) .load::(conn) .expect("Error loading emergency_access") - }) - .await + }} } pub async fn find_by_uuid_and_grantor_uuid( @@ -303,14 +265,13 @@ impl EmergencyAccess { grantor_uuid: &UserId, conn: &DbConn, ) -> Option { - conn.run(move |conn| { + db_run! { conn: { emergency_access::table .filter(emergency_access::uuid.eq(uuid)) .filter(emergency_access::grantor_uuid.eq(grantor_uuid)) .first::(conn) .ok() - }) - .await + }} } pub async fn find_by_uuid_and_grantee_uuid( @@ -318,14 +279,13 @@ impl EmergencyAccess { grantee_uuid: &UserId, conn: &DbConn, ) -> Option { - conn.run(move |conn| { + db_run! { conn: { emergency_access::table .filter(emergency_access::uuid.eq(uuid)) .filter(emergency_access::grantee_uuid.eq(grantee_uuid)) .first::(conn) .ok() - }) - .await + }} } pub async fn find_by_uuid_and_grantee_email( @@ -333,67 +293,61 @@ impl EmergencyAccess { grantee_email: &str, conn: &DbConn, ) -> Option { - conn.run(move |conn| { + db_run! { conn: { emergency_access::table .filter(emergency_access::uuid.eq(uuid)) .filter(emergency_access::email.eq(grantee_email)) .first::(conn) .ok() - }) - .await + }} } pub async fn find_all_by_grantee_uuid(grantee_uuid: &UserId, conn: &DbConn) -> Vec { - conn.run(move |conn| { + db_run! { conn: { emergency_access::table .filter(emergency_access::grantee_uuid.eq(grantee_uuid)) .load::(conn) .expect("Error loading emergency_access") - }) - .await + }} } pub async fn find_invited_by_grantee_email(grantee_email: &str, conn: &DbConn) -> Option { - conn.run(move |conn| { + db_run! { conn: { emergency_access::table .filter(emergency_access::email.eq(grantee_email)) .filter(emergency_access::status.eq(EmergencyAccessStatus::Invited as i32)) .first::(conn) .ok() - }) - .await + }} } pub async fn find_all_invited_by_grantee_email(grantee_email: &str, conn: &DbConn) -> Vec { - conn.run(move |conn| { + db_run! { conn: { emergency_access::table .filter(emergency_access::email.eq(grantee_email)) .filter(emergency_access::status.eq(EmergencyAccessStatus::Invited as i32)) .load::(conn) .expect("Error loading emergency_access") - }) - .await + }} } pub async fn find_all_by_grantor_uuid(grantor_uuid: &UserId, conn: &DbConn) -> Vec { - conn.run(move |conn| { + db_run! { conn: { emergency_access::table .filter(emergency_access::grantor_uuid.eq(grantor_uuid)) .load::(conn) .expect("Error loading emergency_access") - }) - .await + }} } pub async fn find_all_confirmed_by_grantor_uuid(grantor_uuid: &UserId, conn: &DbConn) -> Vec { - conn.run(move |conn| { + db_run! { conn: { emergency_access::table .filter(emergency_access::grantor_uuid.eq(grantor_uuid)) .filter(emergency_access::status.ge(EmergencyAccessStatus::Confirmed as i32)) .load::(conn) .expect("Error loading emergency_access") - }) - .await + }} } pub async fn accept_invite(&mut self, grantee_uuid: &UserId, grantee_email: &str, conn: &DbConn) -> EmptyResult { diff --git a/src/db/models/event.rs b/src/db/models/event.rs index e020bb3d..bd4b2310 100644 --- a/src/db/models/event.rs +++ b/src/db/models/event.rs @@ -1,18 +1,11 @@ use chrono::{NaiveDateTime, TimeDelta, Utc}; -use diesel::prelude::*; +//use derive_more::{AsRef, Deref, Display, From}; use serde_json::Value; -use crate::{ - CONFIG, - api::EmptyResult, - db::{ - DbConn, - schema::{event, users_organizations}, - }, - error::MapResult, -}; - use super::{CipherId, CollectionId, GroupId, MembershipId, OrgPolicyId, OrganizationId, UserId}; +use crate::db::schema::{event, users_organizations}; +use crate::{api::EmptyResult, db::DbConn, error::MapResult, CONFIG}; +use diesel::prelude::*; // https://bitwarden.com/help/event-logs/ @@ -43,7 +36,7 @@ pub struct Event { pub provider_org_uuid: Option, } -// Upstream enum: https://github.com/bitwarden/server/blob/v2026.6.2/src/Core/Dirt/Enums/EventType.cs +// Upstream enum: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Enums/EventType.cs #[derive(Debug, Copy, Clone)] pub enum EventType { // User @@ -79,7 +72,6 @@ pub enum EventType { CipherSoftDeleted = 1115, CipherRestored = 1116, CipherClientToggledCardNumberVisible = 1117, - CipherClientHttpAuthReleased = 1133, // Collection CollectionCreated = 1300, @@ -109,12 +101,6 @@ pub enum EventType { OrganizationUserRejectedAuthRequest = 1514, OrganizationUserDeleted = 1515, // Both user and organization user data were deleted OrganizationUserLeft = 1516, // User voluntarily left the organization - // OrganizationUserAutomaticallyConfirmed = 1517, - // OrganizationUserSelfRevoked = 1518, // User self-revoked due to declining organization data ownership policy - OrganizationUserAdminResetTwoFactor = 1519, - OrganizationUserRevokedTwoFactorNonCompliance = 1520, - OrganizationUserRevokedSingleOrganizationNonCompliance = 1521, - OrganizationUserNotificationBannerActionClicked = 1522, // Organization OrganizationUpdated = 1600, @@ -209,23 +195,20 @@ impl Event { /// Basic Queries pub async fn save(&self, conn: &DbConn) -> EmptyResult { db_run! { conn: - mysql { - diesel::insert_into(event::table) - .values(self) - .on_conflict(diesel::dsl::DuplicatedKeys) - .do_update() - .set(self) - .execute(conn) - .map_res("Error saving event") + sqlite, mysql { + diesel::replace_into(event::table) + .values(self) + .execute(conn) + .map_res("Error saving event") } - postgresql, sqlite { + postgresql { diesel::insert_into(event::table) - .values(self) - .on_conflict(event::uuid) - .do_update() - .set(self) - .execute(conn) - .map_res("Error saving event") + .values(self) + .on_conflict(event::uuid) + .do_update() + .set(self) + .execute(conn) + .map_res("Error saving event") } } } @@ -266,10 +249,11 @@ impl Event { } pub async fn delete(self, conn: &DbConn) -> EmptyResult { - conn.run(move |conn| { - diesel::delete(event::table.filter(event::uuid.eq(self.uuid))).execute(conn).map_res("Error deleting event") - }) - .await + db_run! { conn: { + diesel::delete(event::table.filter(event::uuid.eq(self.uuid))) + .execute(conn) + .map_res("Error deleting event") + }} } /// ############## @@ -280,7 +264,7 @@ impl Event { end: &NaiveDateTime, conn: &DbConn, ) -> Vec { - conn.run(move |conn| { + db_run! { conn: { event::table .filter(event::org_uuid.eq(org_uuid)) .filter(event::event_date.between(start, end)) @@ -288,15 +272,18 @@ impl Event { .limit(Self::PAGE_SIZE) .load::(conn) .expect("Error filtering events") - }) - .await + }} } pub async fn count_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> i64 { - conn.run(move |conn| { - event::table.filter(event::org_uuid.eq(org_uuid)).count().first::(conn).ok().unwrap_or(0) - }) - .await + db_run! { conn: { + event::table + .filter(event::org_uuid.eq(org_uuid)) + .count() + .first::(conn) + .ok() + .unwrap_or(0) + }} } pub async fn find_by_org_and_member( @@ -306,27 +293,18 @@ impl Event { end: &NaiveDateTime, conn: &DbConn, ) -> Vec { - conn.run(move |conn| { + db_run! { conn: { event::table - .inner_join( - users_organizations::table - .on(users_organizations::uuid.eq(member_uuid).and(users_organizations::org_uuid.eq(org_uuid))), - ) + .inner_join(users_organizations::table.on(users_organizations::uuid.eq(member_uuid))) .filter(event::org_uuid.eq(org_uuid)) .filter(event::event_date.between(start, end)) - .filter( - event::org_user_uuid - .eq(member_uuid) - .or(event::user_uuid.eq(users_organizations::user_uuid.nullable())) - .or(event::act_user_uuid.eq(users_organizations::user_uuid.nullable())), - ) + .filter(event::user_uuid.eq(users_organizations::user_uuid.nullable()).or(event::act_user_uuid.eq(users_organizations::user_uuid.nullable()))) .select(event::all_columns) .order_by(event::event_date.desc()) .limit(Self::PAGE_SIZE) .load::(conn) .expect("Error filtering events") - }) - .await + }} } pub async fn find_by_cipher_uuid( @@ -335,7 +313,7 @@ impl Event { end: &NaiveDateTime, conn: &DbConn, ) -> Vec { - conn.run(move |conn| { + db_run! { conn: { event::table .filter(event::cipher_uuid.eq(cipher_uuid)) .filter(event::event_date.between(start, end)) @@ -343,28 +321,20 @@ impl Event { .limit(Self::PAGE_SIZE) .load::(conn) .expect("Error filtering events") - }) - .await + }} } pub async fn clean_events(conn: &DbConn) -> EmptyResult { - let Some(days_to_retain) = CONFIG.events_days_retain().filter(|&d| d != 0) else { - return Ok(()); - }; - - let Some(days_to_retain) = TimeDelta::try_days(i64::from(days_to_retain)) else { - err!("Unable to calculate the event retention date, check 'EVENTS_DAYS_RETAIN'") - }; - - let Some(dt) = Utc::now().naive_utc().checked_sub_signed(days_to_retain) else { - err!("Unable to calculate the event retention date, check 'EVENTS_DAYS_RETAIN'") - }; - conn.run(move |conn| { - diesel::delete(event::table.filter(event::event_date.lt(dt))) + if let Some(days_to_retain) = CONFIG.events_days_retain() { + let dt = Utc::now().naive_utc() - TimeDelta::try_days(days_to_retain).unwrap(); + db_run! { conn: { + diesel::delete(event::table.filter(event::event_date.lt(dt))) .execute(conn) .map_res("Error cleaning old events") - }) - .await + }} + } else { + Ok(()) + } } } diff --git a/src/db/models/favorite.rs b/src/db/models/favorite.rs index ee79857a..d7aa74bb 100644 --- a/src/db/models/favorite.rs +++ b/src/db/models/favorite.rs @@ -1,12 +1,6 @@ -use diesel::prelude::*; - -use crate::{ - api::EmptyResult, - db::{DbConn, schema::favorites}, - error::MapResult, -}; - use super::{CipherId, User, UserId}; +use crate::db::schema::favorites; +use diesel::prelude::*; #[derive(Identifiable, Queryable, Insertable)] #[diesel(table_name = favorites)] @@ -16,18 +10,24 @@ pub struct Favorite { pub cipher_uuid: CipherId, } +use crate::db::DbConn; + +use crate::api::EmptyResult; +use crate::error::MapResult; + impl Favorite { // Returns whether the specified cipher is a favorite of the specified user. pub async fn is_favorite(cipher_uuid: &CipherId, user_uuid: &UserId, conn: &DbConn) -> bool { - conn.run(move |conn| { + db_run! { conn: { let query = favorites::table .filter(favorites::cipher_uuid.eq(cipher_uuid)) .filter(favorites::user_uuid.eq(user_uuid)) .count(); - query.first::(conn).ok().unwrap_or(0) != 0 - }) - .await + query.first::(conn) + .ok() + .unwrap_or(0) != 0 + }} } // Sets whether the specified cipher is a favorite of the specified user. @@ -41,26 +41,27 @@ impl Favorite { match (old, new) { (false, true) => { User::update_uuid_revision(user_uuid, conn).await; - conn.run(move |conn| { - diesel::insert_into(favorites::table) - .values((favorites::user_uuid.eq(user_uuid), favorites::cipher_uuid.eq(cipher_uuid))) - .execute(conn) - .map_res("Error adding favorite") - }) - .await + db_run! { conn: { + diesel::insert_into(favorites::table) + .values(( + favorites::user_uuid.eq(user_uuid), + favorites::cipher_uuid.eq(cipher_uuid), + )) + .execute(conn) + .map_res("Error adding favorite") + }} } (true, false) => { User::update_uuid_revision(user_uuid, conn).await; - conn.run(move |conn| { + db_run! { conn: { diesel::delete( favorites::table .filter(favorites::user_uuid.eq(user_uuid)) - .filter(favorites::cipher_uuid.eq(cipher_uuid)), + .filter(favorites::cipher_uuid.eq(cipher_uuid)) ) .execute(conn) .map_res("Error removing favorite") - }) - .await + }} } // Otherwise, the favorite status is already what it should be. _ => Ok(()), @@ -69,34 +70,31 @@ impl Favorite { // Delete all favorite entries associated with the specified cipher. pub async fn delete_all_by_cipher(cipher_uuid: &CipherId, conn: &DbConn) -> EmptyResult { - conn.run(move |conn| { + db_run! { conn: { diesel::delete(favorites::table.filter(favorites::cipher_uuid.eq(cipher_uuid))) .execute(conn) .map_res("Error removing favorites by cipher") - }) - .await + }} } // Delete all favorite entries associated with the specified user. pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { - conn.run(move |conn| { + db_run! { conn: { diesel::delete(favorites::table.filter(favorites::user_uuid.eq(user_uuid))) .execute(conn) .map_res("Error removing favorites by user") - }) - .await + }} } /// Return a vec with (cipher_uuid) this will only contain favorite flagged ciphers /// This is used during a full sync so we only need one query for all favorite cipher matches. pub async fn get_all_cipher_uuid_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { - conn.run(move |conn| { + db_run! { conn: { favorites::table .filter(favorites::user_uuid.eq(user_uuid)) .select(favorites::cipher_uuid) .load::(conn) .unwrap_or_default() - }) - .await + }} } } diff --git a/src/db/models/folder.rs b/src/db/models/folder.rs index adbe993f..b4cbc7ff 100644 --- a/src/db/models/folder.rs +++ b/src/db/models/folder.rs @@ -1,19 +1,11 @@ use chrono::{NaiveDateTime, Utc}; use derive_more::{AsRef, Deref, Display, From}; -use diesel::prelude::*; use serde_json::Value; -use crate::{ - api::EmptyResult, - db::{ - DbConn, - schema::{folders, folders_ciphers}, - }, - error::MapResult, -}; -use macros::UuidFromParam; - use super::{CipherId, User, UserId}; +use crate::db::schema::{folders, folders_ciphers}; +use diesel::prelude::*; +use macros::UuidFromParam; #[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[diesel(table_name = folders)] @@ -64,12 +56,17 @@ impl Folder { impl FolderCipher { pub fn new(folder_uuid: FolderId, cipher_uuid: CipherId) -> Self { Self { - cipher_uuid, folder_uuid, + cipher_uuid, } } } +use crate::db::DbConn; + +use crate::api::EmptyResult; +use crate::error::MapResult; + /// Database methods impl Folder { pub async fn save(&mut self, conn: &DbConn) -> EmptyResult { @@ -77,16 +74,24 @@ impl Folder { self.updated_at = Utc::now().naive_utc(); db_run! { conn: - mysql { - diesel::insert_into(folders::table) + sqlite, mysql { + match diesel::replace_into(folders::table) .values(&*self) - .on_conflict(diesel::dsl::DuplicatedKeys) - .do_update() - .set(&*self) .execute(conn) - .map_res("Error saving folder") + { + Ok(_) => Ok(()), + // Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first. + Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { + diesel::update(folders::table) + .filter(folders::uuid.eq(&self.uuid)) + .set(&*self) + .execute(conn) + .map_res("Error saving folder") + } + Err(e) => Err(e.into()), + }.map_res("Error saving folder") } - postgresql, sqlite { + postgresql { diesel::insert_into(folders::table) .values(&*self) .on_conflict(folders::uuid) @@ -102,12 +107,11 @@ impl Folder { User::update_uuid_revision(&self.user_uuid, conn).await; FolderCipher::delete_all_by_folder(&self.uuid, conn).await?; - conn.run(move |conn| { + db_run! { conn: { diesel::delete(folders::table.filter(folders::uuid.eq(&self.uuid))) .execute(conn) .map_res("Error deleting folder") - }) - .await + }} } pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { @@ -118,36 +122,38 @@ impl Folder { } pub async fn find_by_uuid_and_user(uuid: &FolderId, user_uuid: &UserId, conn: &DbConn) -> Option { - conn.run(move |conn| { + db_run! { conn: { folders::table .filter(folders::uuid.eq(uuid)) .filter(folders::user_uuid.eq(user_uuid)) .first::(conn) .ok() - }) - .await + }} } pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { - conn.run(move |conn| { - folders::table.filter(folders::user_uuid.eq(user_uuid)).load::(conn).expect("Error loading folders") - }) - .await + db_run! { conn: { + folders::table + .filter(folders::user_uuid.eq(user_uuid)) + .load::(conn) + .expect("Error loading folders") + }} } } impl FolderCipher { pub async fn save(&self, conn: &DbConn) -> EmptyResult { db_run! { conn: - mysql { - diesel::insert_into(folders_ciphers::table) + sqlite, mysql { + // Not checking for ForeignKey Constraints here. + // Table folders_ciphers does not have ForeignKey Constraints which would cause conflicts. + // This table has no constraints pointing to itself, but only to others. + diesel::replace_into(folders_ciphers::table) .values(self) - .on_conflict(diesel::dsl::DuplicatedKeys) - .do_nothing() .execute(conn) .map_res("Error adding cipher to folder") } - postgresql, sqlite { + postgresql { diesel::insert_into(folders_ciphers::table) .values(self) .on_conflict((folders_ciphers::cipher_uuid, folders_ciphers::folder_uuid)) @@ -159,7 +165,7 @@ impl FolderCipher { } pub async fn delete(self, conn: &DbConn) -> EmptyResult { - conn.run(move |conn| { + db_run! { conn: { diesel::delete( folders_ciphers::table .filter(folders_ciphers::cipher_uuid.eq(self.cipher_uuid)) @@ -167,26 +173,23 @@ impl FolderCipher { ) .execute(conn) .map_res("Error removing cipher from folder") - }) - .await + }} } pub async fn delete_all_by_cipher(cipher_uuid: &CipherId, conn: &DbConn) -> EmptyResult { - conn.run(move |conn| { + db_run! { conn: { diesel::delete(folders_ciphers::table.filter(folders_ciphers::cipher_uuid.eq(cipher_uuid))) .execute(conn) .map_res("Error removing cipher from folders") - }) - .await + }} } pub async fn delete_all_by_folder(folder_uuid: &FolderId, conn: &DbConn) -> EmptyResult { - conn.run(move |conn| { + db_run! { conn: { diesel::delete(folders_ciphers::table.filter(folders_ciphers::folder_uuid.eq(folder_uuid))) .execute(conn) .map_res("Error removing ciphers from folder") - }) - .await + }} } pub async fn find_by_folder_and_cipher( @@ -194,38 +197,35 @@ impl FolderCipher { cipher_uuid: &CipherId, conn: &DbConn, ) -> Option { - conn.run(move |conn| { + db_run! { conn: { folders_ciphers::table .filter(folders_ciphers::folder_uuid.eq(folder_uuid)) .filter(folders_ciphers::cipher_uuid.eq(cipher_uuid)) .first::(conn) .ok() - }) - .await + }} } pub async fn find_by_folder(folder_uuid: &FolderId, conn: &DbConn) -> Vec { - conn.run(move |conn| { + db_run! { conn: { folders_ciphers::table .filter(folders_ciphers::folder_uuid.eq(folder_uuid)) .load::(conn) .expect("Error loading folders") - }) - .await + }} } /// Return a vec with (cipher_uuid, folder_uuid) /// This is used during a full sync so we only need one query for all folder matches. pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<(CipherId, FolderId)> { - conn.run(move |conn| { + db_run! { conn: { folders_ciphers::table .inner_join(folders::table) .filter(folders::user_uuid.eq(user_uuid)) .select(folders_ciphers::all_columns) .load::<(CipherId, FolderId)>(conn) .unwrap_or_default() - }) - .await + }} } } diff --git a/src/db/models/group.rs b/src/db/models/group.rs index c8df7ef2..f41ad9ca 100644 --- a/src/db/models/group.rs +++ b/src/db/models/group.rs @@ -1,19 +1,13 @@ +use super::{CollectionId, Membership, MembershipId, OrganizationId, User, UserId}; +use crate::api::EmptyResult; +use crate::db::schema::{collections, collections_groups, groups, groups_users, users_organizations}; +use crate::db::DbConn; +use crate::error::MapResult; use chrono::{NaiveDateTime, Utc}; use derive_more::{AsRef, Deref, Display, From}; use diesel::prelude::*; -use serde_json::Value; - -use crate::{ - api::EmptyResult, - db::{ - DbConn, - schema::{collections, collections_groups, groups, groups_users, users_organizations}, - }, - error::MapResult, -}; use macros::UuidFromParam; - -use super::{CollectionId, Membership, MembershipId, MembershipStatus, OrganizationId, User, UserId}; +use serde_json::Value; #[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[diesel(table_name = groups)] @@ -104,6 +98,7 @@ impl Group { "id": self.uuid, "organizationId": self.organizations_uuid, "name": self.name, + "accessAll": self.access_all, "externalId": self.external_id, "collections": collections_groups, "object": "groupDetails" @@ -137,15 +132,15 @@ impl CollectionGroup { } pub fn to_json_details_for_group(&self) -> Value { - // Return the stored manage flag. Deriving it from !read_only && !hide_passwords turned the group - // permission "Can edit items" (readOnly=false, hidePasswords=false, manage=false) into "Can manage" - // in GET /organizations//collections/details, while GET /groups//details reported the stored - // value. Saving the collection access dialog then persisted manage=true. + // If both read_only and hide_passwords are false, then manage should be true + // You can't have an entry with read_only and manage, or hide_passwords and manage + // Or an entry with everything to false + // For backwards compatibility and migration proposes we keep checking read_only and hide_password json!({ "id": self.groups_uuid, "readOnly": self.read_only, "hidePasswords": self.hide_passwords, - "manage": self.manage, + "manage": self.manage || (!self.read_only && !self.hide_passwords), }) } } @@ -165,16 +160,24 @@ impl Group { self.revision_date = Utc::now().naive_utc(); db_run! { conn: - mysql { - diesel::insert_into(groups::table) + sqlite, mysql { + match diesel::replace_into(groups::table) .values(&*self) - .on_conflict(diesel::dsl::DuplicatedKeys) - .do_update() - .set(&*self) .execute(conn) - .map_res("Error saving group") + { + Ok(_) => Ok(()), + // Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first. + Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { + diesel::update(groups::table) + .filter(groups::uuid.eq(&self.uuid)) + .set(&*self) + .execute(conn) + .map_res("Error saving group") + } + Err(e) => Err(e.into()), + }.map_res("Error saving group") } - postgresql, sqlite { + postgresql { diesel::insert_into(groups::table) .values(&*self) .on_conflict(groups::uuid) @@ -194,31 +197,33 @@ impl Group { } pub async fn find_by_organization(org_uuid: &OrganizationId, conn: &DbConn) -> Vec { - conn.run(move |conn| { + db_run! { conn: { groups::table .filter(groups::organizations_uuid.eq(org_uuid)) .load::(conn) .expect("Error loading groups") - }) - .await + }} } pub async fn count_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> i64 { - conn.run(move |conn| { - groups::table.filter(groups::organizations_uuid.eq(org_uuid)).count().first::(conn).ok().unwrap_or(0) - }) - .await + db_run! { conn: { + groups::table + .filter(groups::organizations_uuid.eq(org_uuid)) + .count() + .first::(conn) + .ok() + .unwrap_or(0) + }} } pub async fn find_by_uuid_and_org(uuid: &GroupId, org_uuid: &OrganizationId, conn: &DbConn) -> Option { - conn.run(move |conn| { + db_run! { conn: { groups::table .filter(groups::uuid.eq(uuid)) .filter(groups::organizations_uuid.eq(org_uuid)) .first::(conn) .ok() - }) - .await + }} } pub async fn find_by_external_id_and_org( @@ -226,91 +231,77 @@ impl Group { org_uuid: &OrganizationId, conn: &DbConn, ) -> Option { - conn.run(move |conn| { + db_run! { conn: { groups::table .filter(groups::external_id.eq(external_id)) .filter(groups::organizations_uuid.eq(org_uuid)) .first::(conn) .ok() - }) - .await + }} } //Returns all organizations the user has full access to pub async fn get_orgs_by_user_with_full_access(user_uuid: &UserId, conn: &DbConn) -> Vec { - conn.run(move |conn| { + db_run! { conn: { groups_users::table - .inner_join( - users_organizations::table.on(users_organizations::uuid.eq(groups_users::users_organizations_uuid)), - ) - .inner_join( - groups::table.on(groups::uuid - .eq(groups_users::groups_uuid) - .and(groups::organizations_uuid.eq(users_organizations::org_uuid))), - ) + .inner_join(users_organizations::table.on( + users_organizations::uuid.eq(groups_users::users_organizations_uuid) + )) + .inner_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid) + .and(groups::organizations_uuid.eq(users_organizations::org_uuid)) + )) .filter(users_organizations::user_uuid.eq(user_uuid)) - .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .filter(groups::access_all.eq(true)) .select(groups::organizations_uuid) .distinct() .load::(conn) .expect("Error loading organization group full access information for user") - }) - .await + }} } pub async fn is_in_full_access_group(user_uuid: &UserId, org_uuid: &OrganizationId, conn: &DbConn) -> bool { - conn.run(move |conn| { + db_run! { conn: { groups::table - .inner_join(groups_users::table.on(groups_users::groups_uuid.eq(groups::uuid))) - .inner_join( - users_organizations::table.on(users_organizations::uuid - .eq(groups_users::users_organizations_uuid) - .and(users_organizations::org_uuid.eq(groups::organizations_uuid))), - ) + .inner_join(groups_users::table.on( + groups_users::groups_uuid.eq(groups::uuid) + )) + .inner_join(users_organizations::table.on( + users_organizations::uuid.eq(groups_users::users_organizations_uuid) + )) .filter(users_organizations::user_uuid.eq(user_uuid)) - // Only allow full access via a confirmed membership, since - // groups_users rows are kept when a membership is revoked. - .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .filter(groups::organizations_uuid.eq(org_uuid)) .filter(groups::access_all.eq(true)) .select(groups::access_all) .first::(conn) .unwrap_or_default() - }) - .await + }} } pub async fn delete(&self, org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult { CollectionGroup::delete_all_by_group(&self.uuid, org_uuid, conn).await?; GroupUser::delete_all_by_group(&self.uuid, org_uuid, conn).await?; - conn.run(move |conn| { + db_run! { conn: { diesel::delete(groups::table.filter(groups::uuid.eq(&self.uuid))) .execute(conn) .map_res("Error deleting group") - }) - .await + }} } pub async fn update_revision(uuid: &GroupId, conn: &DbConn) { - if let Err(e) = Self::update_revision_impl(uuid, &Utc::now().naive_utc(), conn).await { + if let Err(e) = Self::_update_revision(uuid, &Utc::now().naive_utc(), conn).await { warn!("Failed to update revision for {uuid}: {e:#?}"); } } - async fn update_revision_impl(uuid: &GroupId, date: &NaiveDateTime, conn: &DbConn) -> EmptyResult { - conn.run(move |conn| { - crate::util::retry( - || { - diesel::update(groups::table.filter(groups::uuid.eq(uuid))) - .set(groups::revision_date.eq(date)) - .execute(conn) - }, - 10, - ) + async fn _update_revision(uuid: &GroupId, date: &NaiveDateTime, conn: &DbConn) -> EmptyResult { + db_run! { conn: { + crate::util::retry(|| { + diesel::update(groups::table.filter(groups::uuid.eq(uuid))) + .set(groups::revision_date.eq(date)) + .execute(conn) + }, 10) .map_res("Error updating group revision") - }) - .await + }} } } @@ -321,30 +312,53 @@ impl CollectionGroup { group_user.update_user_revision(conn).await; } - let values = ( - collections_groups::collections_uuid.eq(&self.collections_uuid), - collections_groups::groups_uuid.eq(&self.groups_uuid), - collections_groups::read_only.eq(&self.read_only), - collections_groups::hide_passwords.eq(&self.hide_passwords), - collections_groups::manage.eq(&self.manage), - ); - db_run! { conn: - mysql { - diesel::insert_into(collections_groups::table) - .values(values) - .on_conflict(diesel::dsl::DuplicatedKeys) - .do_update() - .set(values) + sqlite, mysql { + match diesel::replace_into(collections_groups::table) + .values(( + collections_groups::collections_uuid.eq(&self.collections_uuid), + collections_groups::groups_uuid.eq(&self.groups_uuid), + collections_groups::read_only.eq(&self.read_only), + collections_groups::hide_passwords.eq(&self.hide_passwords), + collections_groups::manage.eq(&self.manage), + )) .execute(conn) - .map_res("Error adding group to collection") + { + Ok(_) => Ok(()), + // Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first. + Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { + diesel::update(collections_groups::table) + .filter(collections_groups::collections_uuid.eq(&self.collections_uuid)) + .filter(collections_groups::groups_uuid.eq(&self.groups_uuid)) + .set(( + collections_groups::collections_uuid.eq(&self.collections_uuid), + collections_groups::groups_uuid.eq(&self.groups_uuid), + collections_groups::read_only.eq(&self.read_only), + collections_groups::hide_passwords.eq(&self.hide_passwords), + collections_groups::manage.eq(&self.manage), + )) + .execute(conn) + .map_res("Error adding group to collection") + } + Err(e) => Err(e.into()), + }.map_res("Error adding group to collection") } - postgresql, sqlite { + postgresql { diesel::insert_into(collections_groups::table) - .values(values) + .values(( + collections_groups::collections_uuid.eq(&self.collections_uuid), + collections_groups::groups_uuid.eq(&self.groups_uuid), + collections_groups::read_only.eq(self.read_only), + collections_groups::hide_passwords.eq(self.hide_passwords), + collections_groups::manage.eq(self.manage), + )) .on_conflict((collections_groups::collections_uuid, collections_groups::groups_uuid)) .do_update() - .set(values) + .set(( + collections_groups::read_only.eq(self.read_only), + collections_groups::hide_passwords.eq(self.hide_passwords), + collections_groups::manage.eq(self.manage), + )) .execute(conn) .map_res("Error adding group to collection") } @@ -352,64 +366,60 @@ impl CollectionGroup { } pub async fn find_by_group(group_uuid: &GroupId, org_uuid: &OrganizationId, conn: &DbConn) -> Vec { - conn.run(move |conn| { + db_run! { conn: { collections_groups::table - .inner_join(groups::table.on(groups::uuid.eq(collections_groups::groups_uuid))) - .inner_join( - collections::table.on(collections::uuid - .eq(collections_groups::collections_uuid) - .and(collections::org_uuid.eq(groups::organizations_uuid))), - ) + .inner_join(groups::table.on( + groups::uuid.eq(collections_groups::groups_uuid) + )) + .inner_join(collections::table.on( + collections::uuid.eq(collections_groups::collections_uuid) + .and(collections::org_uuid.eq(groups::organizations_uuid)) + )) .filter(collections_groups::groups_uuid.eq(group_uuid)) .filter(collections::org_uuid.eq(org_uuid)) .select(collections_groups::all_columns) .load::(conn) .expect("Error loading collection groups") - }) - .await + }} } pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { - conn.run(move |conn| { + db_run! { conn: { collections_groups::table - .inner_join(groups_users::table.on(groups_users::groups_uuid.eq(collections_groups::groups_uuid))) - .inner_join( - users_organizations::table.on(users_organizations::uuid.eq(groups_users::users_organizations_uuid)), - ) - .inner_join( - groups::table.on(groups::uuid - .eq(collections_groups::groups_uuid) - .and(groups::organizations_uuid.eq(users_organizations::org_uuid))), - ) - .inner_join( - collections::table.on(collections::uuid - .eq(collections_groups::collections_uuid) - .and(collections::org_uuid.eq(groups::organizations_uuid))), - ) + .inner_join(groups_users::table.on( + groups_users::groups_uuid.eq(collections_groups::groups_uuid) + )) + .inner_join(users_organizations::table.on( + users_organizations::uuid.eq(groups_users::users_organizations_uuid) + )) + .inner_join(groups::table.on(groups::uuid.eq(collections_groups::groups_uuid) + .and(groups::organizations_uuid.eq(users_organizations::org_uuid)) + )) + .inner_join(collections::table.on( + collections::uuid.eq(collections_groups::collections_uuid) + .and(collections::org_uuid.eq(groups::organizations_uuid)) + )) .filter(users_organizations::user_uuid.eq(user_uuid)) - .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .select(collections_groups::all_columns) .load::(conn) .expect("Error loading user collection groups") - }) - .await + }} } pub async fn find_by_collection(collection_uuid: &CollectionId, conn: &DbConn) -> Vec { - conn.run(move |conn| { + db_run! { conn: { collections_groups::table .filter(collections_groups::collections_uuid.eq(collection_uuid)) - .inner_join(collections::table.on(collections::uuid.eq(collections_groups::collections_uuid))) - .inner_join( - groups::table.on(groups::uuid - .eq(collections_groups::groups_uuid) - .and(groups::organizations_uuid.eq(collections::org_uuid))), - ) + .inner_join(collections::table.on( + collections::uuid.eq(collections_groups::collections_uuid) + )) + .inner_join(groups::table.on(groups::uuid.eq(collections_groups::groups_uuid) + .and(groups::organizations_uuid.eq(collections::org_uuid)) + )) .select(collections_groups::all_columns) .load::(conn) .expect("Error loading collection groups") - }) - .await + }} } pub async fn delete(&self, org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult { @@ -418,14 +428,13 @@ impl CollectionGroup { group_user.update_user_revision(conn).await; } - conn.run(move |conn| { + db_run! { conn: { diesel::delete(collections_groups::table) .filter(collections_groups::collections_uuid.eq(&self.collections_uuid)) .filter(collections_groups::groups_uuid.eq(&self.groups_uuid)) .execute(conn) .map_res("Error deleting collection group") - }) - .await + }} } pub async fn delete_all_by_group(group_uuid: &GroupId, org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult { @@ -434,13 +443,12 @@ impl CollectionGroup { group_user.update_user_revision(conn).await; } - conn.run(move |conn| { + db_run! { conn: { diesel::delete(collections_groups::table) .filter(collections_groups::groups_uuid.eq(group_uuid)) .execute(conn) .map_res("Error deleting collection group") - }) - .await + }} } pub async fn delete_all_by_collection( @@ -456,13 +464,12 @@ impl CollectionGroup { } } - conn.run(move |conn| { + db_run! { conn: { diesel::delete(collections_groups::table) .filter(collections_groups::collections_uuid.eq(collection_uuid)) .execute(conn) .map_res("Error deleting collection group") - }) - .await + }} } } @@ -470,25 +477,43 @@ impl GroupUser { pub async fn save(&mut self, conn: &DbConn) -> EmptyResult { self.update_user_revision(conn).await; - let values = ( - groups_users::users_organizations_uuid.eq(&self.users_organizations_uuid), - groups_users::groups_uuid.eq(&self.groups_uuid), - ); - db_run! { conn: - mysql { - diesel::insert_into(groups_users::table) - .values(values) - .on_conflict(diesel::dsl::DuplicatedKeys) - .do_nothing() + sqlite, mysql { + match diesel::replace_into(groups_users::table) + .values(( + groups_users::users_organizations_uuid.eq(&self.users_organizations_uuid), + groups_users::groups_uuid.eq(&self.groups_uuid), + )) .execute(conn) - .map_res("Error adding user to group") + { + Ok(_) => Ok(()), + // Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first. + Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { + diesel::update(groups_users::table) + .filter(groups_users::users_organizations_uuid.eq(&self.users_organizations_uuid)) + .filter(groups_users::groups_uuid.eq(&self.groups_uuid)) + .set(( + groups_users::users_organizations_uuid.eq(&self.users_organizations_uuid), + groups_users::groups_uuid.eq(&self.groups_uuid), + )) + .execute(conn) + .map_res("Error adding user to group") + } + Err(e) => Err(e.into()), + }.map_res("Error adding user to group") } - postgresql, sqlite { + postgresql { diesel::insert_into(groups_users::table) - .values(values) + .values(( + groups_users::users_organizations_uuid.eq(&self.users_organizations_uuid), + groups_users::groups_uuid.eq(&self.groups_uuid), + )) .on_conflict((groups_users::users_organizations_uuid, groups_users::groups_uuid)) - .do_nothing() + .do_update() + .set(( + groups_users::users_organizations_uuid.eq(&self.users_organizations_uuid), + groups_users::groups_uuid.eq(&self.groups_uuid), + )) .execute(conn) .map_res("Error adding user to group") } @@ -496,31 +521,30 @@ impl GroupUser { } pub async fn find_by_group(group_uuid: &GroupId, org_uuid: &OrganizationId, conn: &DbConn) -> Vec { - conn.run(move |conn| { + db_run! { conn: { groups_users::table - .inner_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid))) - .inner_join( - users_organizations::table.on(users_organizations::uuid - .eq(groups_users::users_organizations_uuid) - .and(users_organizations::org_uuid.eq(groups::organizations_uuid))), - ) + .inner_join(groups::table.on( + groups::uuid.eq(groups_users::groups_uuid) + )) + .inner_join(users_organizations::table.on( + users_organizations::uuid.eq(groups_users::users_organizations_uuid) + .and(users_organizations::org_uuid.eq(groups::organizations_uuid)) + )) .filter(groups_users::groups_uuid.eq(group_uuid)) .filter(groups::organizations_uuid.eq(org_uuid)) .select(groups_users::all_columns) .load::(conn) .expect("Error loading group users") - }) - .await + }} } pub async fn find_by_member(member_uuid: &MembershipId, conn: &DbConn) -> Vec { - conn.run(move |conn| { + db_run! { conn: { groups_users::table .filter(groups_users::users_organizations_uuid.eq(member_uuid)) .load::(conn) .expect("Error loading groups for user") - }) - .await + }} } pub async fn has_access_to_collection_by_member( @@ -528,23 +552,24 @@ impl GroupUser { member_uuid: &MembershipId, conn: &DbConn, ) -> bool { - conn.run(move |conn| { + db_run! { conn: { groups_users::table - .inner_join(collections_groups::table.on(collections_groups::groups_uuid.eq(groups_users::groups_uuid))) - .inner_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid))) - .inner_join( - collections::table.on(collections::uuid - .eq(collections_groups::collections_uuid) - .and(collections::org_uuid.eq(groups::organizations_uuid))), - ) + .inner_join(collections_groups::table.on( + collections_groups::groups_uuid.eq(groups_users::groups_uuid) + )) + .inner_join(groups::table.on( + groups::uuid.eq(groups_users::groups_uuid) + )) + .inner_join(collections::table.on( + collections::uuid.eq(collections_groups::collections_uuid) + .and(collections::org_uuid.eq(groups::organizations_uuid)) + )) .filter(collections_groups::collections_uuid.eq(collection_uuid)) .filter(groups_users::users_organizations_uuid.eq(member_uuid)) .count() .first::(conn) - .unwrap_or(0) - != 0 - }) - .await + .unwrap_or(0) != 0 + }} } pub async fn has_full_access_by_member( @@ -552,18 +577,18 @@ impl GroupUser { member_uuid: &MembershipId, conn: &DbConn, ) -> bool { - conn.run(move |conn| { + db_run! { conn: { groups_users::table - .inner_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid))) + .inner_join(groups::table.on( + groups::uuid.eq(groups_users::groups_uuid) + )) .filter(groups::organizations_uuid.eq(org_uuid)) .filter(groups::access_all.eq(true)) .filter(groups_users::users_organizations_uuid.eq(member_uuid)) .count() .first::(conn) - .unwrap_or(0) - != 0 - }) - .await + .unwrap_or(0) != 0 + }} } pub async fn update_user_revision(&self, conn: &DbConn) { @@ -573,19 +598,37 @@ 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!"), + }; + + db_run! { 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") + }} + } + pub async fn delete_all_by_group(group_uuid: &GroupId, org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult { let group_users = GroupUser::find_by_group(group_uuid, org_uuid, conn).await; for group_user in group_users { group_user.update_user_revision(conn).await; } - conn.run(move |conn| { + db_run! { conn: { diesel::delete(groups_users::table) .filter(groups_users::groups_uuid.eq(group_uuid)) .execute(conn) .map_res("Error deleting group users") - }) - .await + }} } pub async fn delete_all_by_member(member_uuid: &MembershipId, conn: &DbConn) -> EmptyResult { @@ -594,13 +637,12 @@ impl GroupUser { None => warn!("Member could not be found!"), } - conn.run(move |conn| { + db_run! { conn: { diesel::delete(groups_users::table) .filter(groups_users::users_organizations_uuid.eq(member_uuid)) .execute(conn) .map_res("Error deleting user groups") - }) - .await + }} } } diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index b41d36ae..b4fcf658 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -1,4 +1,3 @@ -mod archive; mod attachment; mod auth_request; mod cipher; @@ -17,30 +16,28 @@ mod two_factor; mod two_factor_duo_context; mod two_factor_incomplete; mod user; -mod user_signature_key_pair; -pub use self::archive::Archive; pub use self::attachment::{Attachment, AttachmentId}; pub use self::auth_request::{AuthRequest, AuthRequestId}; -pub use self::cipher::{Cipher, CipherAccessScope, CipherId, RepromptType, is_data_blob_encrypted}; +pub use self::cipher::{Cipher, CipherId, RepromptType}; pub use self::collection::{Collection, CollectionCipher, CollectionId, CollectionUser}; -pub use self::device::{Device, DeviceId, DeviceType, DeviceWithAuthRequest, PushId}; -pub use self::emergency_access::{ - EMERGENCY_ACCESS_TIMEOUT_RANGE, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType, -}; +pub use self::device::{Device, DeviceId, DeviceType, PushId}; +pub use self::emergency_access::{EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType}; pub use self::event::{Event, EventType}; pub use self::favorite::Favorite; pub use self::folder::{Folder, FolderCipher, FolderId}; pub use self::group::{CollectionGroup, Group, GroupId, GroupUser}; -pub use self::org_policy::{FillAssistPolicyData, OrgPolicy, OrgPolicyId, OrgPolicyType}; +pub use self::org_policy::{OrgPolicy, OrgPolicyId, OrgPolicyType}; pub use self::organization::{ - CustomPermission, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, - OrganizationApiKey, OrganizationId, custom_roles_enabled, + Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, OrganizationApiKey, + OrganizationId, }; -pub use self::send::{Send, SendFileId, SendId, SendType}; -pub use self::sso_auth::{OIDCAuthenticatedUser, OIDCCodeResponseError, SsoAuth}; +pub use self::send::{ + id::{SendFileId, SendId}, + Send, SendType, +}; +pub use self::sso_auth::{OIDCAuthenticatedUser, OIDCCodeWrapper, SsoAuth}; pub use self::two_factor::{TwoFactor, TwoFactorType}; pub use self::two_factor_duo_context::TwoFactorDuoContext; pub use self::two_factor_incomplete::TwoFactorIncomplete; -pub use self::user::{Invitation, KeyId, SsoUser, User, UserId, UserKdfType}; -pub use self::user_signature_key_pair::{SignatureAlgorithm, UserSignatureKeyPair}; +pub use self::user::{Invitation, SsoUser, User, UserId, UserKdfType, UserStampException}; diff --git a/src/db/models/org_policy.rs b/src/db/models/org_policy.rs index 554f51fc..7e922f35 100644 --- a/src/db/models/org_policy.rs +++ b/src/db/models/org_policy.rs @@ -1,19 +1,14 @@ -use chrono::{NaiveDateTime, Utc}; use derive_more::{AsRef, From}; -use diesel::prelude::*; use serde::Deserialize; use serde_json::Value; -use crate::{ - CONFIG, - api::{EmptyResult, core::two_factor}, - db::{ - DbConn, - schema::{org_policies, users_organizations}, - }, - error::MapResult, - util::format_date, -}; +use crate::api::core::two_factor; +use crate::api::EmptyResult; +use crate::db::schema::{org_policies, users_organizations}; +use crate::db::DbConn; +use crate::error::MapResult; +use crate::CONFIG; +use diesel::prelude::*; use super::{Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId, TwoFactor, UserId}; @@ -26,7 +21,6 @@ pub struct OrgPolicy { pub atype: i32, pub enabled: bool, pub data: String, - pub revision_date: NaiveDateTime, } // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Enums/PolicyType.cs @@ -49,12 +43,9 @@ pub enum OrgPolicyType { RemoveUnlockWithPin = 14, RestrictedItemTypes = 15, UriMatchDefaults = 16, - AutotypeDefaultSetting = 17, + // AutotypeDefaultSetting = 17, // Not supported yet // AutoConfirm = 18, // Not supported (not implemented yet) // BlockClaimedDomainAccountCreation = 19, // Not supported (Not AGPLv3 Licensed) - OrganizationUserNotification = 20, - // SendControls = 21, // Not supported yet - FillAssist = 22, } // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Models/Data/Organizations/Policies/SendOptionsPolicyData.cs#L5 @@ -73,14 +64,6 @@ pub struct ResetPasswordDataModel { pub auto_enroll_enabled: bool, } -// https://github.com/bitwarden/server/blob/13bc813de8e715a71d3410f1e584e87ccdf76025/src/Core/AdminConsole/Models/Data/Organizations/Policies/FillAssistPolicyData.cs -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct FillAssistPolicyData { - #[serde(alias = "RulesUrl")] - pub rules_url: Option, -} - /// Local methods impl OrgPolicy { pub fn new(org_uuid: OrganizationId, atype: OrgPolicyType, enabled: bool, data: String) -> Self { @@ -90,7 +73,6 @@ impl OrgPolicy { atype: atype as i32, enabled, data, - revision_date: Utc::now().naive_utc(), } } @@ -106,7 +88,6 @@ impl OrgPolicy { "type": self.atype, "data": data_json, "enabled": self.enabled, - "revisionDate": format_date(&self.revision_date), "object": "policy", }); @@ -124,13 +105,11 @@ impl OrgPolicy { /// Database methods impl OrgPolicy { - pub async fn save(&mut self, conn: &DbConn) -> EmptyResult { - self.revision_date = Utc::now().naive_utc(); - + pub async fn save(&self, conn: &DbConn) -> EmptyResult { db_run! { conn: sqlite, mysql { match diesel::replace_into(org_policies::table) - .values(&*self) + .values(self) .execute(conn) { Ok(_) => Ok(()), @@ -138,7 +117,7 @@ impl OrgPolicy { Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { diesel::update(org_policies::table) .filter(org_policies::uuid.eq(&self.uuid)) - .set(&*self) + .set(self) .execute(conn) .map_res("Error saving org_policy") } @@ -158,10 +137,10 @@ impl OrgPolicy { .map_res("Error deleting org_policy for insert")?; diesel::insert_into(org_policies::table) - .values(&*self) + .values(self) .on_conflict(org_policies::uuid) .do_update() - .set(&*self) + .set(self) .execute(conn) .map_res("Error saving org_policy") } @@ -169,58 +148,37 @@ impl OrgPolicy { } pub async fn delete(self, conn: &DbConn) -> EmptyResult { - conn.run(move |conn| { + db_run! { conn: { diesel::delete(org_policies::table.filter(org_policies::uuid.eq(self.uuid))) .execute(conn) .map_res("Error deleting org_policy") - }) - .await + }} } pub async fn find_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> Vec { - conn.run(move |conn| { + db_run! { conn: { org_policies::table .filter(org_policies::org_uuid.eq(org_uuid)) .load::(conn) .expect("Error loading org_policy") - }) - .await + }} } pub async fn find_confirmed_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { - conn.run(move |conn| { + db_run! { conn: { org_policies::table .inner_join( - users_organizations::table.on(users_organizations::org_uuid - .eq(org_policies::org_uuid) - .and(users_organizations::user_uuid.eq(user_uuid))), - ) - .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) - .select(org_policies::all_columns) - .load::(conn) - .expect("Error loading org_policy") - }) - .await - } - - pub async fn find_accepted_and_confirmed_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { - conn.run(move |conn| { - org_policies::table - .inner_join( - users_organizations::table.on(users_organizations::org_uuid - .eq(org_policies::org_uuid) - .and(users_organizations::user_uuid.eq(user_uuid))), + users_organizations::table.on( + users_organizations::org_uuid.eq(org_policies::org_uuid) + .and(users_organizations::user_uuid.eq(user_uuid))) ) .filter( - users_organizations::status - .eq(MembershipStatus::Accepted as i32) - .or(users_organizations::status.eq(MembershipStatus::Confirmed as i32)), + users_organizations::status.eq(MembershipStatus::Confirmed as i32) ) .select(org_policies::all_columns) .load::(conn) .expect("Error loading org_policy") - }) - .await + }} } pub async fn find_by_org_and_type( @@ -228,23 +186,21 @@ impl OrgPolicy { policy_type: OrgPolicyType, conn: &DbConn, ) -> Option { - conn.run(move |conn| { + db_run! { conn: { org_policies::table .filter(org_policies::org_uuid.eq(org_uuid)) .filter(org_policies::atype.eq(policy_type as i32)) .first::(conn) .ok() - }) - .await + }} } pub async fn delete_all_by_organization(org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult { - conn.run(move |conn| { + db_run! { conn: { diesel::delete(org_policies::table.filter(org_policies::org_uuid.eq(org_uuid))) .execute(conn) .map_res("Error deleting org_policy") - }) - .await + }} } pub async fn find_accepted_and_confirmed_by_user_and_active_policy( @@ -252,22 +208,25 @@ impl OrgPolicy { policy_type: OrgPolicyType, conn: &DbConn, ) -> Vec { - conn.run(move |conn| { + db_run! { conn: { org_policies::table .inner_join( - users_organizations::table.on(users_organizations::org_uuid - .eq(org_policies::org_uuid) - .and(users_organizations::user_uuid.eq(user_uuid))), + users_organizations::table.on( + users_organizations::org_uuid.eq(org_policies::org_uuid) + .and(users_organizations::user_uuid.eq(user_uuid))) + ) + .filter( + users_organizations::status.eq(MembershipStatus::Accepted as i32) + ) + .or_filter( + users_organizations::status.eq(MembershipStatus::Confirmed as i32) ) - .filter(users_organizations::status.eq(MembershipStatus::Accepted as i32)) - .or_filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .filter(org_policies::atype.eq(policy_type as i32)) .filter(org_policies::enabled.eq(true)) .select(org_policies::all_columns) .load::(conn) .expect("Error loading org_policy") - }) - .await + }} } pub async fn find_confirmed_by_user_and_active_policy( @@ -275,21 +234,22 @@ impl OrgPolicy { policy_type: OrgPolicyType, conn: &DbConn, ) -> Vec { - conn.run(move |conn| { + db_run! { conn: { org_policies::table .inner_join( - users_organizations::table.on(users_organizations::org_uuid - .eq(org_policies::org_uuid) - .and(users_organizations::user_uuid.eq(user_uuid))), + users_organizations::table.on( + users_organizations::org_uuid.eq(org_policies::org_uuid) + .and(users_organizations::user_uuid.eq(user_uuid))) + ) + .filter( + users_organizations::status.eq(MembershipStatus::Confirmed as i32) ) - .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .filter(org_policies::atype.eq(policy_type as i32)) .filter(org_policies::enabled.eq(true)) .select(org_policies::all_columns) .load::(conn) .expect("Error loading org_policy") - }) - .await + }} } /// Returns true if the user belongs to an org that has enabled the specified policy type, @@ -309,10 +269,10 @@ impl OrgPolicy { continue; } - if let Some(user) = Membership::find_confirmed_by_user_and_org(user_uuid, &policy.org_uuid, conn).await - && user.atype < MembershipType::Admin - { - return true; + if let Some(user) = Membership::find_confirmed_by_user_and_org(user_uuid, &policy.org_uuid, conn).await { + if user.atype < MembershipType::Admin { + return true; + } } } false @@ -322,13 +282,13 @@ impl OrgPolicy { if m.atype < MembershipType::Admin && m.status > (MembershipStatus::Invited as i32) { // Enforce TwoFactor/TwoStep login if let Some(p) = Self::find_by_org_and_type(&m.org_uuid, OrgPolicyType::TwoFactorAuthentication, conn).await - && p.enabled - && TwoFactor::find_by_user(&m.user_uuid, conn).await.is_empty() { - if CONFIG.email_2fa_auto_fallback() { - two_factor::email::find_and_activate_email_2fa(&m.user_uuid, conn).await?; - } else { - err!(format!("Cannot {} because 2FA is required (membership {})", action, m.uuid)); + if p.enabled && TwoFactor::find_by_user(&m.user_uuid, conn).await.is_empty() { + if CONFIG.email_2fa_auto_fallback() { + two_factor::email::find_and_activate_email_2fa(&m.user_uuid, conn).await?; + } else { + err!(format!("Cannot {} because 2FA is required (membership {})", action, m.uuid)); + } } } @@ -340,14 +300,12 @@ impl OrgPolicy { )); } - if let Some(p) = Self::find_by_org_and_type(&m.org_uuid, OrgPolicyType::SingleOrg, conn).await - && p.enabled - && Membership::count_accepted_and_confirmed_by_user(&m.user_uuid, &m.org_uuid, conn).await > 0 - { - err!(format!( - "Cannot {} because the organization policy forbids being part of other organization (membership {})", - action, m.uuid - )); + if let Some(p) = Self::find_by_org_and_type(&m.org_uuid, OrgPolicyType::SingleOrg, conn).await { + if p.enabled + && Membership::count_accepted_and_confirmed_by_user(&m.user_uuid, &m.org_uuid, conn).await > 0 + { + err!(format!("Cannot {} because the organization policy forbids being part of other organization (membership {})", action, m.uuid)); + } } } @@ -355,13 +313,6 @@ impl OrgPolicy { } pub async fn org_is_reset_password_auto_enroll(org_uuid: &OrganizationId, conn: &DbConn) -> bool { - // Account recovery depends on outbound mail. When SMTP is disabled, treat the - // auto-enroll policy as inactive so invites/registration are not forced to - // supply a reset-password key (see check_reset_password_applicable). - if !CONFIG.mail_enabled() { - return false; - } - match OrgPolicy::find_by_org_and_type(org_uuid, OrgPolicyType::ResetPassword, conn).await { Some(policy) => match serde_json::from_str::(&policy.data) { Ok(opts) => { @@ -381,16 +332,16 @@ impl OrgPolicy { for policy in OrgPolicy::find_confirmed_by_user_and_active_policy(user_uuid, OrgPolicyType::SendOptions, conn).await { - if let Some(user) = Membership::find_confirmed_by_user_and_org(user_uuid, &policy.org_uuid, conn).await - && user.atype < MembershipType::Admin - { - match serde_json::from_str::(&policy.data) { - Ok(opts) => { - if opts.disable_hide_email { - return true; + if let Some(user) = Membership::find_confirmed_by_user_and_org(user_uuid, &policy.org_uuid, conn).await { + if user.atype < MembershipType::Admin { + match serde_json::from_str::(&policy.data) { + Ok(opts) => { + if opts.disable_hide_email { + return true; + } } + _ => error!("Failed to deserialize SendOptionsPolicyData: {}", policy.data), } - _ => error!("Failed to deserialize SendOptionsPolicyData: {}", policy.data), } } } @@ -398,10 +349,10 @@ impl OrgPolicy { } pub async fn is_enabled_for_member(member_uuid: &MembershipId, policy_type: OrgPolicyType, conn: &DbConn) -> bool { - if let Some(member) = Membership::find_by_uuid(member_uuid, conn).await - && let Some(policy) = OrgPolicy::find_by_org_and_type(&member.org_uuid, policy_type, conn).await - { - return policy.enabled; + if let Some(member) = Membership::find_by_uuid(member_uuid, conn).await { + if let Some(policy) = OrgPolicy::find_by_org_and_type(&member.org_uuid, policy_type, conn).await { + return policy.enabled; + } } false } diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index f1e03354..ae19b30c 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -1,32 +1,23 @@ -use std::{ - cmp::Ordering, - collections::{HashMap, HashSet}, -}; - use chrono::{NaiveDateTime, Utc}; use derive_more::{AsRef, Deref, Display, From}; use diesel::prelude::*; use num_traits::FromPrimitive; use serde_json::Value; - -use crate::{ - CONFIG, - api::EmptyResult, - db::{ - DbConn, - schema::{ - ciphers, ciphers_collections, collections_groups, groups, groups_users, org_policies, organization_api_key, - organizations, users, users_collections, users_organizations, - }, - }, - error::MapResult, +use std::{ + cmp::Ordering, + collections::{HashMap, HashSet}, }; -use macros::UuidFromParam; use super::{ - Cipher, CipherId, Collection, CollectionGroup, CollectionId, CollectionUser, Group, GroupId, GroupUser, OrgPolicy, + CipherId, Collection, CollectionGroup, CollectionId, CollectionUser, Group, GroupId, GroupUser, OrgPolicy, OrgPolicyType, TwoFactor, User, UserId, }; +use crate::db::schema::{ + ciphers, ciphers_collections, collections_groups, groups, groups_users, org_policies, organization_api_key, + organizations, users, users_collections, users_organizations, +}; +use crate::CONFIG; +use macros::UuidFromParam; #[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[diesel(table_name = organizations)] @@ -44,7 +35,6 @@ pub struct Organization { #[diesel(table_name = users_organizations)] #[diesel(treat_none_as_null = true)] #[diesel(primary_key(uuid))] -#[expect(clippy::struct_excessive_bools, reason = "Each Custom role permission is a column of its own")] pub struct Membership { pub uuid: MembershipId, pub user_uuid: UserId, @@ -58,20 +48,6 @@ pub struct Membership { pub atype: i32, pub reset_password_key: Option, pub external_id: Option, - - // The permissions of a Custom role, only accessed through `CustomPermission`. - manage_users: bool, - manage_groups: bool, - manage_policies: bool, - create_new_collections: bool, - edit_any_collection: bool, - delete_any_collection: bool, - access_event_logs: bool, - access_import_export: bool, - access_reports: bool, - - /// The user's v2 upgrade token, for the org to unwrap the v2 user key after an upgrade. Opaque to us. - pub v2_upgrade_token: Option, } #[derive(Identifiable, Queryable, Insertable, AsChangeset)] @@ -107,15 +83,12 @@ impl MembershipStatus { } } -#[derive(Copy, Clone, Debug, PartialEq, Eq, num_derive::FromPrimitive)] +#[derive(Copy, Clone, PartialEq, Eq, num_derive::FromPrimitive)] pub enum MembershipType { Owner = 0, Admin = 1, User = 2, - // The legacy Manager role. The Bitwarden clients no longer know it and are shown a Custom role instead. Manager = 3, - // Only granted while EXPERIMENTAL_CUSTOM_ROLES is enabled. - Custom = 4, } impl MembershipType { @@ -125,50 +98,23 @@ impl MembershipType { "1" | "Admin" => Some(MembershipType::Admin), "2" | "User" => Some(MembershipType::User), "3" | "Manager" => Some(MembershipType::Manager), - "4" | "Custom" => Some(MembershipType::Custom), + // HACK: We convert the custom role to a manager role + "4" | "Custom" => Some(MembershipType::Manager), _ => None, } } - - /// The role a member invite or edit from the Bitwarden clients asks for, given the member's current - /// role (`None` for an invite). - /// - /// The clients no longer know the legacy Manager role and are shown a Custom role in its place, so a - /// requested Custom role is the legacy Manager unless EXPERIMENTAL_CUSTOM_ROLES is enabled. An existing - /// Manager or Custom member keeps their role when the request repeats it, so an unrelated edit never - /// turns one into the other. - pub fn requested( - requested: MembershipType, - current: Option<&Membership>, - custom_roles_enabled: bool, - ) -> MembershipType { - if requested != MembershipType::Custom { - return requested; - } - match current { - Some(member) if member.has_type(MembershipType::Custom) => MembershipType::Custom, - Some(member) if member.has_type(MembershipType::Manager) => MembershipType::Manager, - _ if custom_roles_enabled => MembershipType::Custom, - _ => MembershipType::Manager, - } - } - - /// The authority of a role, which is what roles are compared by. A Custom member gets its authority from - /// its permissions, never from its role, so the role itself ranks right above User and below Manager. - const fn rank(self) -> u8 { - match self { - MembershipType::User => 0, - MembershipType::Custom => 1, - MembershipType::Manager => 2, - MembershipType::Admin => 3, - MembershipType::Owner => 4, - } - } } impl Ord for MembershipType { fn cmp(&self, other: &MembershipType) -> Ordering { - self.rank().cmp(&other.rank()) + // For easy comparison, map each variant to an access level (where 0 is lowest). + const ACCESS_LEVEL: [i32; 4] = [ + 3, // Owner + 2, // Admin + 0, // User + 1, // Manager && Custom + ]; + ACCESS_LEVEL[*self as usize].cmp(&ACCESS_LEVEL[*other as usize]) } } @@ -224,91 +170,6 @@ impl PartialOrd for i32 { } } -/// The granular permissions of the Custom role. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum CustomPermission { - ManageUsers, - ManageGroups, - ManagePolicies, - CreateNewCollections, - EditAnyCollection, - DeleteAnyCollection, - AccessEventLogs, - AccessImportExport, - AccessReports, -} - -/// The permissions this version enforces on every endpoint they open. Only these can be granted, a stored -/// grant of any other one grants nothing. They are implemented one at a time, so this grows with them. -const SUPPORTED_CUSTOM_PERMISSIONS: &[CustomPermission] = &[]; - -impl CustomPermission { - pub const ALL: [CustomPermission; 9] = [ - CustomPermission::ManageUsers, - CustomPermission::ManageGroups, - CustomPermission::ManagePolicies, - CustomPermission::CreateNewCollections, - CustomPermission::EditAnyCollection, - CustomPermission::DeleteAnyCollection, - CustomPermission::AccessEventLogs, - CustomPermission::AccessImportExport, - CustomPermission::AccessReports, - ]; - - /// The key of the permission in the `permissions` object of the Bitwarden clients. - pub const fn json_key(self) -> &'static str { - match self { - CustomPermission::ManageUsers => "manageUsers", - CustomPermission::ManageGroups => "manageGroups", - CustomPermission::ManagePolicies => "managePolicies", - CustomPermission::CreateNewCollections => "createNewCollections", - CustomPermission::EditAnyCollection => "editAnyCollection", - CustomPermission::DeleteAnyCollection => "deleteAnyCollection", - CustomPermission::AccessEventLogs => "accessEventLogs", - CustomPermission::AccessImportExport => "accessImportExport", - CustomPermission::AccessReports => "accessReports", - } - } - - pub fn is_supported(self) -> bool { - SUPPORTED_CUSTOM_PERMISSIONS.contains(&self) - } - - /// The permissions a member invite or edit grants a Custom role. - /// - /// A permission is only granted when it is set to `true`, and anything but a boolean is rejected: this - /// replaces the member's permissions, so a malformed value must not silently remove one. Granting a - /// permission this version does not enforce is rejected as well. The clients also send permissions - /// Vaultwarden does not know at all, like `manageSso`, those are ignored. - pub fn requested(permissions: &HashMap) -> Result, crate::Error> { - let mut granted = HashSet::new(); - for permission in CustomPermission::ALL { - match permissions.get(permission.json_key()) { - None | Some(Value::Bool(false)) => {} - Some(Value::Bool(true)) if permission.is_supported() => { - granted.insert(permission); - } - Some(Value::Bool(true)) => { - err!(format!("The Custom role permission '{}' is not supported yet", permission.json_key())) - } - Some(_) => { - err!(format!("The Custom role permission '{}' must be true or false", permission.json_key())) - } - } - } - Ok(granted) - } -} - -/// The stored roles with authority over the whole organization. Queries match them by value, comparing -/// `atype <= Admin` would also match any unknown value below it. -pub(super) const ORG_ADMIN_ATYPES: [i32; 2] = [MembershipType::Owner as i32, MembershipType::Admin as i32]; - -/// Whether the experimental Custom role is enabled. Nothing is ever granted through a Custom role otherwise. -pub fn custom_roles_enabled() -> bool { - CONFIG.experimental_custom_roles() -} - /// Local methods impl Organization { pub fn new(name: String, billing_email: &str, private_key: Option, public_key: Option) -> Self { @@ -343,18 +204,11 @@ impl Organization { "useSecretsManager": false, // Not supported (Not AGPLv3 Licensed) "selfHost": true, "useApi": true, - "useDisableSMAdsForUsers": true, // Hide Secrets Manager ads - "useInviteLinks": false, // Not (yet) supported - "useMyItems": false, // Not (yet) supported - "useOrganizationDomains": false, // Not supported (Linked to SSO) - "usePam": false, // Not supported - "usePhishingBlocker": false, "hasPublicAndPrivateKeys": self.private_key.is_some() && self.public_key.is_some(), "useResetPassword": CONFIG.mail_enabled(), "allowAdminAccessToAllCollectionItems": true, "limitCollectionCreation": true, "limitCollectionDeletion": true, - "limitItemDeletion": false, "businessName": self.name, "businessAddress1": null, @@ -400,123 +254,9 @@ impl Membership { atype: MembershipType::User as i32, reset_password_key: None, external_id: None, - - manage_users: false, - manage_groups: false, - manage_policies: false, - create_new_collections: false, - edit_any_collection: false, - delete_any_collection: false, - access_event_logs: false, - access_import_export: false, - access_reports: false, - - v2_upgrade_token: None, } } - fn custom_permission_mut(&mut self, permission: CustomPermission) -> &mut bool { - match permission { - CustomPermission::ManageUsers => &mut self.manage_users, - CustomPermission::ManageGroups => &mut self.manage_groups, - CustomPermission::ManagePolicies => &mut self.manage_policies, - CustomPermission::CreateNewCollections => &mut self.create_new_collections, - CustomPermission::EditAnyCollection => &mut self.edit_any_collection, - CustomPermission::DeleteAnyCollection => &mut self.delete_any_collection, - CustomPermission::AccessEventLogs => &mut self.access_event_logs, - CustomPermission::AccessImportExport => &mut self.access_import_export, - CustomPermission::AccessReports => &mut self.access_reports, - } - } - - /// Change the role. Nothing the old role carried survives a role change: the Custom role permissions are - /// dropped, so changing the role back later starts from none. A Custom role never keeps the legacy - /// `access_all` flag, its reach only comes from its permissions. - pub fn set_type(&mut self, new_type: MembershipType) { - if self.atype != new_type as i32 { - for permission in CustomPermission::ALL { - *self.custom_permission_mut(permission) = false; - } - } - if new_type == MembershipType::Custom { - self.access_all = false; - } - self.atype = new_type as i32; - } - - /// Grant this Custom member exactly `permissions` out of those this version enforces. A stored grant of a - /// permission this version does not enforce is kept as it is: it grants nothing here. - pub fn set_custom_permissions(&mut self, permissions: &HashSet) { - for permission in CustomPermission::ALL.into_iter().filter(|p| p.is_supported()) { - *self.custom_permission_mut(permission) = permissions.contains(&permission); - } - } - - /// Why this legacy Manager can't become a Custom member without losing what they may do now, if they can't. A - /// conversion must not silently change their access, and a Custom role can't keep access to all collections - /// (which also puts every collection in the Manager's own vault), nor manage any collection yet. - pub async fn custom_role_conversion_loss(&self, conn: &DbConn) -> Option<&'static str> { - if !self.has_type(MembershipType::Manager) { - return None; - } - if self.access_all { - return Some( - "This Manager has access to all collections, which a Custom member can't have: the collections they \ - aren't assigned to would disappear from their vault. Uncheck Manage all collections for them first, \ - or keep the Manager role.", - ); - } - // Whatever their status, the collections they manage directly, through a group or through a group with - // access to all collections - let mut manages = GroupUser::has_full_access_by_member(&self.org_uuid, &self.uuid, conn).await - || CollectionUser::find_by_organization_and_user_uuid(&self.org_uuid, &self.user_uuid, conn) - .await - .iter() - .any(|assignment| assignment.manage); - for group_user in GroupUser::find_by_member(&self.uuid, conn).await { - if manages { - break; - } - manages = CollectionGroup::find_by_group(&group_user.groups_uuid, &self.org_uuid, conn) - .await - .iter() - .any(|assignment| assignment.manage); - } - manages.then_some( - "This Manager manages collections, with the manage permission or through a group with access to all \ - collections, which a Custom member can't do yet. Remove that first, or keep the Manager role.", - ) - } - - fn custom_permission(&self, permission: CustomPermission) -> bool { - match permission { - CustomPermission::ManageUsers => self.manage_users, - CustomPermission::ManageGroups => self.manage_groups, - CustomPermission::ManagePolicies => self.manage_policies, - CustomPermission::CreateNewCollections => self.create_new_collections, - CustomPermission::EditAnyCollection => self.edit_any_collection, - CustomPermission::DeleteAnyCollection => self.delete_any_collection, - CustomPermission::AccessEventLogs => self.access_event_logs, - CustomPermission::AccessImportExport => self.access_import_export, - CustomPermission::AccessReports => self.access_reports, - } - } - - /// Whether the role of this member grants `permission`. Only a Custom role grants any, only while Custom roles - /// are enabled, and only the permissions this version enforces. - fn grants_custom_permission(&self, permission: CustomPermission) -> bool { - custom_roles_enabled() - && permission.is_supported() - && self.has_type(MembershipType::Custom) - && self.custom_permission(permission) - } - - /// Whether this member may act with `permission`, granted by their Custom role. Like the authority of any - /// other role, it needs a confirmed membership. - pub fn has_custom_permission(&self, permission: CustomPermission) -> bool { - self.has_status(MembershipStatus::Confirmed) && self.grants_custom_permission(permission) - } - pub fn restore(&mut self) -> bool { if self.status < MembershipStatus::Invited as i32 { self.status += ACTIVATE_REVOKE_DIFF; @@ -581,28 +321,42 @@ impl OrganizationApiKey { } } +use crate::db::DbConn; + +use crate::api::EmptyResult; +use crate::error::MapResult; + /// Database methods impl Organization { pub async fn save(&self, conn: &DbConn) -> EmptyResult { if !crate::util::is_valid_email(&self.billing_email) { - err!(format!("BillingEmail {} is not a valid email address", self.billing_email.escape_debug())) + err!(format!("BillingEmail {} is not a valid email address", self.billing_email)) } - for member in &Membership::find_by_org(&self.uuid, conn).await { + for member in Membership::find_by_org(&self.uuid, conn).await.iter() { User::update_uuid_revision(&member.user_uuid, conn).await; } db_run! { conn: - mysql { - diesel::insert_into(organizations::table) + sqlite, mysql { + match diesel::replace_into(organizations::table) .values(self) - .on_conflict(diesel::dsl::DuplicatedKeys) - .do_update() - .set(self) .execute(conn) - .map_res("Error saving organization") + { + Ok(_) => Ok(()), + // Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first. + Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { + diesel::update(organizations::table) + .filter(organizations::uuid.eq(&self.uuid)) + .set(self) + .execute(conn) + .map_res("Error saving organization") + } + Err(e) => Err(e.into()), + }.map_res("Error saving organization") + } - postgresql, sqlite { + postgresql { diesel::insert_into(organizations::table) .values(self) .on_conflict(organizations::uuid) @@ -615,6 +369,8 @@ impl Organization { } pub async fn delete(self, conn: &DbConn) -> EmptyResult { + use super::{Cipher, Collection}; + Cipher::delete_all_by_organization(&self.uuid, conn).await?; Collection::delete_all_by_organization(&self.uuid, conn).await?; Membership::delete_all_by_organization(&self.uuid, conn).await?; @@ -622,30 +378,43 @@ impl Organization { Group::delete_all_by_organization(&self.uuid, conn).await?; OrganizationApiKey::delete_all_by_organization(&self.uuid, conn).await?; - conn.run(move |conn| { + db_run! { conn: { diesel::delete(organizations::table.filter(organizations::uuid.eq(self.uuid))) .execute(conn) .map_res("Error saving organization") - }) - .await + }} } pub async fn find_by_uuid(uuid: &OrganizationId, conn: &DbConn) -> Option { - conn.run(move |conn| organizations::table.filter(organizations::uuid.eq(uuid)).first::(conn).ok()).await + db_run! { conn: { + organizations::table + .filter(organizations::uuid.eq(uuid)) + .first::(conn) + .ok() + }} } pub async fn find_by_name(name: &str, conn: &DbConn) -> Option { - conn.run(move |conn| organizations::table.filter(organizations::name.eq(name)).first::(conn).ok()).await + db_run! { conn: { + organizations::table + .filter(organizations::name.eq(name)) + .first::(conn) + .ok() + }} } pub async fn get_all(conn: &DbConn) -> Vec { - conn.run(move |conn| organizations::table.load::(conn).expect("Error loading organizations")).await + db_run! { conn: { + organizations::table + .load::(conn) + .expect("Error loading organizations") + }} } pub async fn find_main_org_user_email(user_email: &str, conn: &DbConn) -> Option { let lower_mail = user_email.to_lowercase(); - conn.run(move |conn| { + db_run! { conn: { organizations::table .inner_join(users_organizations::table.on(users_organizations::org_uuid.eq(organizations::uuid))) .inner_join(users::table.on(users::uuid.eq(users_organizations::user_uuid))) @@ -655,14 +424,13 @@ impl Organization { .select(organizations::all_columns) .first::(conn) .ok() - }) - .await + }} } pub async fn find_org_user_email(user_email: &str, conn: &DbConn) -> Vec { let lower_mail = user_email.to_lowercase(); - conn.run(move |conn| { + db_run! { conn: { organizations::table .inner_join(users_organizations::table.on(users_organizations::org_uuid.eq(organizations::uuid))) .inner_join(users::table.on(users::uuid.eq(users_organizations::user_uuid))) @@ -672,8 +440,7 @@ impl Organization { .select(organizations::all_columns) .load::(conn) .expect("Error loading user orgs") - }) - .await + }} } } @@ -733,12 +500,6 @@ impl Membership { "useActivateAutofillPolicy": false, "useAdminSponsoredFamilies": false, "useRiskInsights": false, // Not supported (Not AGPLv3 Licensed) - "useDisableSMAdsForUsers": true, // Hide Secrets Manager ads - "useInviteLinks": false, // Not (yet) supported - "useMyItems": false, // Not (yet) supported - "useOrganizationDomains": false, // Not supported (Linked to SSO) - "usePam": false, // Not supported - "usePhishingBlocker": false, "organizationUserId": self.uuid, "providerId": null, @@ -758,7 +519,8 @@ impl Membership { "limitCollectionDeletion": true, "limitItemDeletion": false, "allowAdminAccessToAllCollectionItems": true, - "userIsClaimedByOrganization": false, // Means not managed via the Members UI, like SSO + "userIsManagedByOrganization": false, // Means not managed via the Members UI, like SSO + "userIsClaimedByOrganization": false, // The new key instead of the obsolete userIsManagedByOrganization "permissions": permissions, @@ -766,8 +528,7 @@ impl Membership { // These are per user "userId": self.user_uuid, - // Accepted (not yet confirmed) members have no key yet; send null like upstream, not "" - "key": (!self.akey.is_empty()).then_some(&self.akey), + "key": self.akey, "status": self.status, "type": membership_type, "enabled": true, @@ -776,13 +537,7 @@ impl Membership { }) } - // Used by admins to view other members, so nothing here may depend on this member's status - pub async fn to_json_details_for_admin( - &self, - include_collections: bool, - include_groups: bool, - conn: &DbConn, - ) -> Value { + pub async fn to_json_user_details(&self, include_collections: bool, include_groups: bool, conn: &DbConn) -> Value { let user = User::find_by_uuid(&self.user_uuid, conn).await.unwrap(); // Because BitWarden want the status to be -1 for revoked users we need to catch that here. @@ -800,32 +555,61 @@ impl Membership { } else { // The Bitwarden clients seem to call this API regardless of whether groups are enabled, // so just act as if there are no groups. - Vec::new() + Vec::with_capacity(0) }; // Check if a user is in a group which has access to all collections // If that is the case, we should not return individual collections! - // This is used by admins to view other members, so it must not depend on the membership status let full_access_group = - CONFIG.org_groups_enabled() && GroupUser::has_full_access_by_member(&self.org_uuid, &self.uuid, conn).await; + CONFIG.org_groups_enabled() && Group::is_in_full_access_group(&self.user_uuid, &self.org_uuid, conn).await; // If collections are to be included, only include them if the user does not have full access via a group or defined to the user it self - // Only the collections assigned directly are returned, the ones assigned via a group are returned via a special group endpoint let collections: Vec = if include_collections && !(full_access_group || self.access_all) { - CollectionUser::find_by_organization_and_user_uuid(&self.org_uuid, &self.user_uuid, conn) + // Get all collections for the user here already to prevent more queries + let cu: HashMap = + CollectionUser::find_by_organization_and_user_uuid(&self.org_uuid, &self.user_uuid, conn) + .await + .into_iter() + .map(|cu| (cu.collection_uuid.clone(), cu)) + .collect(); + + // Get all collection groups for this user to prevent there inclusion + let cg: HashSet = CollectionGroup::find_by_user(&self.user_uuid, conn) .await .into_iter() - .map(|cu| { - json!({ - "id": cu.collection_uuid, - "readOnly": cu.read_only, - "hidePasswords": cu.hide_passwords, - "manage": cu.manage || (self.atype == MembershipType::Manager && !cu.read_only && !cu.hide_passwords), - }) + .map(|cg| cg.collections_uuid) + .collect(); + + Collection::find_by_organization_and_user_uuid(&self.org_uuid, &self.user_uuid, conn) + .await + .into_iter() + .filter_map(|c| { + let (read_only, hide_passwords, manage) = if self.has_full_access() { + (false, false, self.atype >= MembershipType::Manager) + } else if let Some(cu) = cu.get(&c.uuid) { + ( + cu.read_only, + cu.hide_passwords, + cu.manage || (self.atype == MembershipType::Manager && !cu.read_only && !cu.hide_passwords), + ) + // If previous checks failed it might be that this user has access via a group, but we should not return those elements here + // Those are returned via a special group endpoint + } else if cg.contains(&c.uuid) { + return None; + } else { + (true, true, false) + }; + + Some(json!({ + "id": c.uuid, + "readOnly": read_only, + "hidePasswords": hide_passwords, + "manage": manage, + })) }) .collect() } else { - Vec::new() + Vec::with_capacity(0) }; // HACK: Convert the manager type to a custom type @@ -869,6 +653,7 @@ impl Membership { "status": status, "type": membership_type, + "accessAll": self.access_all, "twoFactorEnabled": twofactor_enabled, "resetPasswordEnrolled": self.reset_password_key.is_some(), "hasMasterPassword": !user.password_hash.is_empty(), @@ -876,6 +661,7 @@ impl Membership { "permissions": permissions, "ssoBound": false, // Not supported + "managedByOrganization": false, // This key is obsolete replaced by claimedByOrganization "claimedByOrganization": false, // Means not managed via the Members UI, like SSO "usesKeyConnector": false, // Not supported "accessSecretsManager": false, // Not supported (Not AGPLv3 Licensed) @@ -926,6 +712,7 @@ impl Membership { "status": status, "type": self.atype, + "accessAll": self.access_all, "collections": coll_uuids, "object": "organizationUserDetails", @@ -955,25 +742,27 @@ impl Membership { } pub async fn save(&self, conn: &DbConn) -> EmptyResult { - // The legacy `access_all` flag reaches every collection, which a Custom role may only do through its - // permissions, so it must never be stored on one. `set_type` takes care of this. - if self.has_type(MembershipType::Custom) && self.access_all { - err!("A Custom role membership can't have access to all collections") - } - User::update_uuid_revision(&self.user_uuid, conn).await; db_run! { conn: - mysql { - diesel::insert_into(users_organizations::table) + sqlite, mysql { + match diesel::replace_into(users_organizations::table) .values(self) - .on_conflict(diesel::dsl::DuplicatedKeys) - .do_update() - .set(self) .execute(conn) - .map_res("Error adding user to organization") + { + Ok(_) => Ok(()), + // Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first. + Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { + diesel::update(users_organizations::table) + .filter(users_organizations::uuid.eq(&self.uuid)) + .set(self) + .execute(conn) + .map_res("Error adding user to organization") + }, + Err(e) => Err(e.into()), + }.map_res("Error adding user to organization") } - postgresql, sqlite { + postgresql { diesel::insert_into(users_organizations::table) .values(self) .on_conflict(users_organizations::uuid) @@ -991,12 +780,11 @@ impl Membership { CollectionUser::delete_all_by_user_and_org(&self.user_uuid, &self.org_uuid, conn).await?; GroupUser::delete_all_by_member(&self.uuid, conn).await?; - conn.run(move |conn| { + db_run! { conn: { diesel::delete(users_organizations::table.filter(users_organizations::uuid.eq(self.uuid))) .execute(conn) .map_res("Error removing user from organization") - }) - .await + }} } pub async fn delete_all_by_organization(org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult { @@ -1014,10 +802,10 @@ impl Membership { } pub async fn find_by_email_and_org(email: &str, org_uuid: &OrganizationId, conn: &DbConn) -> Option { - if let Some(user) = User::find_by_mail(email, conn).await - && let Some(member) = Membership::find_by_user_and_org(&user.uuid, org_uuid, conn).await - { - return Some(member); + if let Some(user) = User::find_by_mail(email, conn).await { + if let Some(member) = Membership::find_by_user_and_org(&user.uuid, org_uuid, conn).await { + return Some(member); + } } None @@ -1035,90 +823,65 @@ impl Membership { (self.access_all || self.atype >= MembershipType::Admin) && self.has_status(MembershipStatus::Confirmed) } - /// Whether this member may edit every collection and the items in them in the Admin Console: every member with - /// full access, and a Custom member with Edit any collection. Unlike full access, the permission does not add - /// anything to the member's own vault. - pub fn can_edit_any_collection(&self) -> bool { - self.has_full_access() || self.has_custom_permission(CustomPermission::EditAnyCollection) - } - pub async fn find_by_uuid(uuid: &MembershipId, conn: &DbConn) -> Option { - conn.run(move |conn| { - users_organizations::table.filter(users_organizations::uuid.eq(uuid)).first::(conn).ok() - }) - .await + db_run! { conn: { + users_organizations::table + .filter(users_organizations::uuid.eq(uuid)) + .first::(conn) + .ok() + }} } pub async fn find_by_uuid_and_org(uuid: &MembershipId, org_uuid: &OrganizationId, conn: &DbConn) -> Option { - conn.run(move |conn| { + db_run! { conn: { users_organizations::table .filter(users_organizations::uuid.eq(uuid)) .filter(users_organizations::org_uuid.eq(org_uuid)) .first::(conn) .ok() - }) - .await + }} } pub async fn find_confirmed_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { - conn.run(move |conn| { + db_run! { conn: { users_organizations::table .filter(users_organizations::user_uuid.eq(user_uuid)) .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .load::(conn) .unwrap_or_default() - }) - .await - } - - pub async fn find_accepted_and_confirmed_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { - conn.run(move |conn| { - users_organizations::table - .filter(users_organizations::user_uuid.eq(user_uuid)) - .filter( - users_organizations::status - .eq(MembershipStatus::Accepted as i32) - .or(users_organizations::status.eq(MembershipStatus::Confirmed as i32)), - ) - .load::(conn) - .unwrap_or_default() - }) - .await + }} } pub async fn find_invited_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { - conn.run(move |conn| { + db_run! { conn: { users_organizations::table .filter(users_organizations::user_uuid.eq(user_uuid)) .filter(users_organizations::status.eq(MembershipStatus::Invited as i32)) .load::(conn) .unwrap_or_default() - }) - .await + }} } // Should be used only when email are disabled. // In Organizations::send_invite status is set to Accepted only if the user has a password. pub async fn accept_user_invitations(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { - conn.run(move |conn| { + db_run! { conn: { diesel::update(users_organizations::table) .filter(users_organizations::user_uuid.eq(user_uuid)) .filter(users_organizations::status.eq(MembershipStatus::Invited as i32)) .set(users_organizations::status.eq(MembershipStatus::Accepted as i32)) .execute(conn) .map_res("Error confirming invitations") - }) - .await + }} } pub async fn find_any_state_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { - conn.run(move |conn| { + db_run! { conn: { users_organizations::table .filter(users_organizations::user_uuid.eq(user_uuid)) .load::(conn) .unwrap_or_default() - }) - .await + }} } pub async fn count_accepted_and_confirmed_by_user( @@ -1126,83 +889,70 @@ impl Membership { excluded_org: &OrganizationId, conn: &DbConn, ) -> i64 { - conn.run(move |conn| { + db_run! { conn: { users_organizations::table .filter(users_organizations::user_uuid.eq(user_uuid)) .filter(users_organizations::org_uuid.ne(excluded_org)) - .filter( - users_organizations::status - .eq(MembershipStatus::Accepted as i32) - .or(users_organizations::status.eq(MembershipStatus::Confirmed as i32)), - ) + .filter(users_organizations::status.eq(MembershipStatus::Accepted as i32).or(users_organizations::status.eq(MembershipStatus::Confirmed as i32))) .count() .first::(conn) .unwrap_or(0) - }) - .await + }} } pub async fn find_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> Vec { - conn.run(move |conn| { + db_run! { conn: { users_organizations::table .filter(users_organizations::org_uuid.eq(org_uuid)) .load::(conn) .expect("Error loading user organizations") - }) - .await + }} } pub async fn find_confirmed_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> Vec { - conn.run(move |conn| { + db_run! { conn: { users_organizations::table .filter(users_organizations::org_uuid.eq(org_uuid)) .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .load::(conn) .unwrap_or_default() - }) - .await + }} } // Get all users which are either owner or admin, or a manager which can manage/access all pub async fn find_confirmed_and_manage_all_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> Vec { - conn.run(move |conn| { + db_run! { conn: { users_organizations::table .filter(users_organizations::org_uuid.eq(org_uuid)) .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .filter( - users_organizations::atype - .eq_any(vec![MembershipType::Owner as i32, MembershipType::Admin as i32]) - .or(users_organizations::atype - .eq(MembershipType::Manager as i32) - .and(users_organizations::access_all.eq(true))), + users_organizations::atype.eq_any(vec![MembershipType::Owner as i32, MembershipType::Admin as i32]) + .or(users_organizations::atype.eq(MembershipType::Manager as i32).and(users_organizations::access_all.eq(true))) ) .load::(conn) .unwrap_or_default() - }) - .await + }} } pub async fn count_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> i64 { - conn.run(move |conn| { + db_run! { conn: { users_organizations::table .filter(users_organizations::org_uuid.eq(org_uuid)) .count() .first::(conn) .ok() .unwrap_or(0) - }) - .await + }} } pub async fn find_by_org_and_type(org_uuid: &OrganizationId, atype: MembershipType, conn: &DbConn) -> Vec { - conn.run(move |conn| { + db_run! { conn: { users_organizations::table .filter(users_organizations::org_uuid.eq(org_uuid)) .filter(users_organizations::atype.eq(atype as i32)) .load::(conn) .expect("Error loading user organizations") - }) - .await + }} } pub async fn count_confirmed_by_org_and_type( @@ -1210,7 +960,7 @@ impl Membership { atype: MembershipType, conn: &DbConn, ) -> i64 { - conn.run(move |conn| { + db_run! { conn: { users_organizations::table .filter(users_organizations::org_uuid.eq(org_uuid)) .filter(users_organizations::atype.eq(atype as i32)) @@ -1218,19 +968,17 @@ impl Membership { .count() .first::(conn) .unwrap_or(0) - }) - .await + }} } pub async fn find_by_user_and_org(user_uuid: &UserId, org_uuid: &OrganizationId, conn: &DbConn) -> Option { - conn.run(move |conn| { + db_run! { conn: { users_organizations::table .filter(users_organizations::user_uuid.eq(user_uuid)) .filter(users_organizations::org_uuid.eq(org_uuid)) .first::(conn) .ok() - }) - .await + }} } pub async fn find_confirmed_by_user_and_org( @@ -1238,66 +986,78 @@ impl Membership { org_uuid: &OrganizationId, conn: &DbConn, ) -> Option { - conn.run(move |conn| { + db_run! { conn: { users_organizations::table .filter(users_organizations::user_uuid.eq(user_uuid)) .filter(users_organizations::org_uuid.eq(org_uuid)) - .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) + .filter( + users_organizations::status.eq(MembershipStatus::Confirmed as i32) + ) .first::(conn) .ok() - }) - .await + }} } pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { - conn.run(move |conn| { + db_run! { conn: { users_organizations::table .filter(users_organizations::user_uuid.eq(user_uuid)) .load::(conn) .expect("Error loading user organizations") - }) - .await + }} + } + + pub async fn get_orgs_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { + db_run! { conn: { + users_organizations::table + .filter(users_organizations::user_uuid.eq(user_uuid)) + .select(users_organizations::org_uuid) + .load::(conn) + .unwrap_or_default() + }} } pub async fn find_by_user_and_policy(user_uuid: &UserId, policy_type: OrgPolicyType, conn: &DbConn) -> Vec { - conn.run(move |conn| { + db_run! { conn: { users_organizations::table .inner_join( - org_policies::table.on(org_policies::org_uuid - .eq(users_organizations::org_uuid) - .and(users_organizations::user_uuid.eq(user_uuid)) - .and(org_policies::atype.eq(policy_type as i32)) - .and(org_policies::enabled.eq(true))), + org_policies::table.on( + org_policies::org_uuid.eq(users_organizations::org_uuid) + .and(users_organizations::user_uuid.eq(user_uuid)) + .and(org_policies::atype.eq(policy_type as i32)) + .and(org_policies::enabled.eq(true))) + ) + .filter( + users_organizations::status.eq(MembershipStatus::Confirmed as i32) ) - .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .select(users_organizations::all_columns) .load::(conn) .unwrap_or_default() - }) - .await + }} } pub async fn find_by_cipher_and_org(cipher_uuid: &CipherId, org_uuid: &OrganizationId, conn: &DbConn) -> Vec { - conn.run(move |conn| { + db_run! { conn: { users_organizations::table - .filter(users_organizations::org_uuid.eq(org_uuid)) - .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) - .left_join(users_collections::table.on(users_collections::user_uuid.eq(users_organizations::user_uuid))) - .left_join( - ciphers_collections::table.on(ciphers_collections::collection_uuid - .eq(users_collections::collection_uuid) - .and(ciphers_collections::cipher_uuid.eq(&cipher_uuid))), + .filter(users_organizations::org_uuid.eq(org_uuid)) + .left_join(users_collections::table.on( + users_collections::user_uuid.eq(users_organizations::user_uuid) + )) + .left_join(ciphers_collections::table.on( + ciphers_collections::collection_uuid.eq(users_collections::collection_uuid).and( + ciphers_collections::cipher_uuid.eq(&cipher_uuid) ) - .filter(users_organizations::access_all.eq(true).or( - // AccessAll.. - ciphers_collections::cipher_uuid.eq(&cipher_uuid), // ..or access to collection with cipher - )) - .select(users_organizations::all_columns) - .distinct() - .load::(conn) - .expect("Error loading user organizations") - }) - .await + )) + .filter( + users_organizations::access_all.eq(true).or( // AccessAll.. + ciphers_collections::cipher_uuid.eq(&cipher_uuid) // ..or access to collection with cipher + ) + ) + .select(users_organizations::all_columns) + .distinct() + .load::(conn) + .expect("Error loading user organizations") + }} } pub async fn find_by_cipher_and_org_with_group( @@ -1305,56 +1065,45 @@ impl Membership { org_uuid: &OrganizationId, conn: &DbConn, ) -> Vec { - conn.run(move |conn| { + db_run! { conn: { users_organizations::table - .filter(users_organizations::org_uuid.eq(org_uuid)) - .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) - .inner_join( - groups_users::table.on(groups_users::users_organizations_uuid.eq(users_organizations::uuid)), - ) - .left_join(collections_groups::table.on(collections_groups::groups_uuid.eq(groups_users::groups_uuid))) - .left_join( - groups::table.on(groups::uuid - .eq(groups_users::groups_uuid) - .and(groups::organizations_uuid.eq(users_organizations::org_uuid))), - ) - .left_join( - ciphers_collections::table.on(ciphers_collections::collection_uuid - .eq(collections_groups::collections_uuid) - .and(ciphers_collections::cipher_uuid.eq(&cipher_uuid))), - ) - .filter(groups::access_all.eq(true).or( - // AccessAll via groups - ciphers_collections::cipher_uuid.eq(&cipher_uuid), // ..or access to collection via group + .filter(users_organizations::org_uuid.eq(org_uuid)) + .inner_join(groups_users::table.on( + groups_users::users_organizations_uuid.eq(users_organizations::uuid) + )) + .left_join(collections_groups::table.on( + collections_groups::groups_uuid.eq(groups_users::groups_uuid) + )) + .left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid) + .and(groups::organizations_uuid.eq(users_organizations::org_uuid)) + )) + .left_join(ciphers_collections::table.on( + ciphers_collections::collection_uuid.eq(collections_groups::collections_uuid).and(ciphers_collections::cipher_uuid.eq(&cipher_uuid)) + )) + .filter( + groups::access_all.eq(true).or( // AccessAll via groups + ciphers_collections::cipher_uuid.eq(&cipher_uuid) // ..or access to collection via group + ) + ) .select(users_organizations::all_columns) .distinct() - .load::(conn) - .expect("Error loading user organizations with groups") - }) - .await + .load::(conn) + .expect("Error loading user organizations with groups") + }} } pub async fn user_has_ge_admin_access_to_cipher(user_uuid: &UserId, cipher_uuid: &CipherId, conn: &DbConn) -> bool { - conn.run(move |conn| { + db_run! { conn: { users_organizations::table - .inner_join( - ciphers::table.on(ciphers::uuid - .eq(cipher_uuid) - .and(ciphers::organization_uuid.eq(users_organizations::org_uuid.nullable()))), - ) - .filter(users_organizations::user_uuid.eq(user_uuid)) - .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) - .filter( - users_organizations::atype.eq_any(vec![MembershipType::Owner as i32, MembershipType::Admin as i32]), - ) - .count() - .first::(conn) - .ok() - .unwrap_or(0) - != 0 - }) - .await + .inner_join(ciphers::table.on(ciphers::uuid.eq(cipher_uuid).and(ciphers::organization_uuid.eq(users_organizations::org_uuid.nullable())))) + .filter(users_organizations::user_uuid.eq(user_uuid)) + .filter(users_organizations::atype.eq_any(vec![MembershipType::Owner as i32, MembershipType::Admin as i32])) + .count() + .first::(conn) + .ok() + .unwrap_or(0) != 0 + }} } pub async fn find_by_collection_and_org( @@ -1362,57 +1111,69 @@ impl Membership { org_uuid: &OrganizationId, conn: &DbConn, ) -> Vec { - conn.run(move |conn| { + db_run! { conn: { users_organizations::table - .filter(users_organizations::org_uuid.eq(org_uuid)) - .left_join(users_collections::table.on(users_collections::user_uuid.eq(users_organizations::user_uuid))) - .filter(users_organizations::access_all.eq(true).or( - // AccessAll.. - users_collections::collection_uuid.eq(&collection_uuid), // ..or access to collection with cipher - )) - .select(users_organizations::all_columns) - .load::(conn) - .expect("Error loading user organizations") - }) - .await + .filter(users_organizations::org_uuid.eq(org_uuid)) + .left_join(users_collections::table.on( + users_collections::user_uuid.eq(users_organizations::user_uuid) + )) + .filter( + users_organizations::access_all.eq(true).or( // AccessAll.. + users_collections::collection_uuid.eq(&collection_uuid) // ..or access to collection with cipher + ) + ) + .select(users_organizations::all_columns) + .load::(conn) + .expect("Error loading user organizations") + }} } pub async fn find_by_external_id_and_org(ext_id: &str, org_uuid: &OrganizationId, conn: &DbConn) -> Option { - conn.run(move |conn| { + db_run! { conn: { users_organizations::table - .filter(users_organizations::external_id.eq(ext_id).and(users_organizations::org_uuid.eq(org_uuid))) - .first::(conn) - .ok() - }) - .await + .filter( + users_organizations::external_id.eq(ext_id) + .and(users_organizations::org_uuid.eq(org_uuid)) + ) + .first::(conn) + .ok() + }} } pub async fn find_main_user_org(user_uuid: &str, conn: &DbConn) -> Option { - conn.run(move |conn| { + db_run! { conn: { users_organizations::table .filter(users_organizations::user_uuid.eq(user_uuid)) .filter(users_organizations::status.ne(MembershipStatus::Revoked as i32)) .order(users_organizations::atype.asc()) .first::(conn) .ok() - }) - .await + }} } } impl OrganizationApiKey { pub async fn save(&self, conn: &DbConn) -> EmptyResult { db_run! { conn: - mysql { - diesel::insert_into(organization_api_key::table) + sqlite, mysql { + match diesel::replace_into(organization_api_key::table) .values(self) - .on_conflict(diesel::dsl::DuplicatedKeys) - .do_update() - .set(self) .execute(conn) - .map_res("Error saving organization") + { + Ok(_) => Ok(()), + // Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first. + Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { + diesel::update(organization_api_key::table) + .filter(organization_api_key::uuid.eq(&self.uuid)) + .set(self) + .execute(conn) + .map_res("Error saving organization") + } + Err(e) => Err(e.into()), + }.map_res("Error saving organization") + } - postgresql, sqlite { + postgresql { diesel::insert_into(organization_api_key::table) .values(self) .on_conflict((organization_api_key::uuid, organization_api_key::org_uuid)) @@ -1425,19 +1186,20 @@ impl OrganizationApiKey { } pub async fn find_by_org_uuid(org_uuid: &OrganizationId, conn: &DbConn) -> Option { - conn.run(move |conn| { - organization_api_key::table.filter(organization_api_key::org_uuid.eq(org_uuid)).first::(conn).ok() - }) - .await + db_run! { conn: { + organization_api_key::table + .filter(organization_api_key::org_uuid.eq(org_uuid)) + .first::(conn) + .ok() + }} } pub async fn delete_all_by_organization(org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult { - conn.run(move |conn| { + db_run! { conn: { diesel::delete(organization_api_key::table.filter(organization_api_key::org_uuid.eq(org_uuid))) .execute(conn) .map_res("Error removing organization api key from organization") - }) - .await + }} } } @@ -1484,93 +1246,13 @@ pub struct OrgApiKeyId(String); #[cfg(test)] mod tests { use super::*; - use MembershipType::{Admin, Custom, Manager, Owner, User}; - - fn member(role: MembershipType) -> Membership { - let mut member = Membership::new("user".into(), "org".into(), None); - member.atype = role as i32; - member.status = MembershipStatus::Confirmed as i32; - member - } #[test] - fn membership_type_authority() { - assert!(Owner > Admin && Admin > Manager && Manager > Custom && Custom > User); - assert!((Custom as i32) >= User && (Custom as i32) < Manager && (Custom as i32) < Admin); - for raw in [99, -1] { - assert_eq!((raw >= User, raw >= Manager, raw >= Admin, raw < Admin), (false, false, false, true)); - } - for (value, expected) in [ - ("3", Some(Manager)), - ("Manager", Some(Manager)), - ("4", Some(Custom)), - ("Custom", Some(Custom)), - ("5", None), - ] { - assert_eq!(MembershipType::from_str(value), expected); - } - } - - #[test] - fn requested_membership_type() { - for (current, disabled, enabled) in [ - (None, Manager, Custom), - (Some(User), Manager, Custom), - (Some(Manager), Manager, Manager), - (Some(Custom), Custom, Custom), - ] { - let current = current.map(member); - for (flag, expected) in [(false, disabled), (true, enabled)] { - assert_eq!(MembershipType::requested(Custom, current.as_ref(), flag), expected); - } - } - assert_eq!(MembershipType::requested(User, Some(&member(Custom)), true), User); - assert_eq!(MembershipType::requested(User, Some(&member(Manager)), true), User); - for requested in [Owner, Admin, Manager] { - assert_eq!(MembershipType::requested(requested, Some(&member(Custom)), true), requested); - } - } - - #[test] - fn custom_permission_requests() { - let ignored = serde_json::from_value(json!({"manageUsers":false,"manageSso":true})).unwrap(); - assert!(CustomPermission::requested(&ignored).unwrap().is_empty()); - for permission in CustomPermission::ALL { - for value in [json!(true), json!(null), json!("true"), json!(1), json!([true])] { - let request = HashMap::from([(permission.json_key().to_owned(), value.clone())]); - let result = CustomPermission::requested(&request); - if value == json!(true) && SUPPORTED_CUSTOM_PERMISSIONS.contains(&permission) { - assert_eq!(result.unwrap(), HashSet::from([permission])); - } else { - assert!(result.is_err(), "{permission:?}: {value}"); - } - } - } - assert_eq!(SUPPORTED_CUSTOM_PERMISSIONS, []); - } - - #[test] - fn role_changes_drop_custom_permissions() { - for (from, to) in [ - (Custom, Owner), - (Custom, Admin), - (Custom, Manager), - (Custom, User), - (User, Custom), - (Manager, Custom), - (Custom, Custom), - (Admin, User), - ] { - let mut member = member(from); - member.access_all = true; - for permission in CustomPermission::ALL { - *member.custom_permission_mut(permission) = true; - } - member.set_type(to); - for permission in CustomPermission::ALL { - assert_eq!(*member.custom_permission_mut(permission), from == to, "{from:?} -> {to:?}"); - } - assert_eq!(member.access_all, to != Custom); - } + #[allow(non_snake_case)] + fn partial_cmp_MembershipType() { + assert!(MembershipType::Owner > MembershipType::Admin); + assert!(MembershipType::Admin > MembershipType::Manager); + assert!(MembershipType::Manager > MembershipType::User); + assert!(MembershipType::Manager == MembershipType::from_str("4").unwrap()); } } diff --git a/src/db/models/send.rs b/src/db/models/send.rs index 118f947f..84802c54 100644 --- a/src/db/models/send.rs +++ b/src/db/models/send.rs @@ -1,23 +1,12 @@ -use std::path::Path; - use chrono::{NaiveDateTime, Utc}; -use data_encoding::BASE64URL_NOPAD; -use derive_more::{AsRef, Deref, Display, From}; -use diesel::prelude::*; -use macros::{IdFromParam, UuidFromParam}; use serde_json::Value; -use uuid::Uuid; -use crate::{ - CONFIG, - api::EmptyResult, - config::PathType, - db::{DbConn, schema::sends}, - error::MapResult, - util::{LowerCase, NumberOrString, format_date}, -}; +use crate::{config::PathType, util::LowerCase, CONFIG}; use super::{OrganizationId, User, UserId}; +use crate::db::schema::sends; +use diesel::prelude::*; +use id::SendId; #[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[diesel(table_name = sends)] @@ -118,43 +107,43 @@ impl Send { pub fn check_password(&self, password: &str) -> bool { match (&self.password_hash, &self.password_salt, self.password_iter) { (Some(hash), Some(salt), Some(iter)) => { - crate::crypto::verify_password_hash(password.as_bytes(), salt, hash, iter.cast_unsigned()) + crate::crypto::verify_password_hash(password.as_bytes(), salt, hash, iter as u32) } _ => false, } } pub async fn creator_identifier(&self, conn: &DbConn) -> Option { - if let Some(hide_email) = self.hide_email - && hide_email - { - return None; + if let Some(hide_email) = self.hide_email { + if hide_email { + return None; + } } - if let Some(user_uuid) = &self.user_uuid - && let Some(user) = User::find_by_uuid(user_uuid, conn).await - { - return Some(user.email); + if let Some(user_uuid) = &self.user_uuid { + if let Some(user) = User::find_by_uuid(user_uuid, conn).await { + return Some(user.email); + } } 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 { + use crate::util::format_date; + use data_encoding::BASE64URL_NOPAD; + use uuid::Uuid; + let mut data = serde_json::from_str::>(&self.data).map(|d| d.data).unwrap_or_default(); // Mobile clients expect size to be a string instead of a number - if let Some(size) = data.get("size").and_then(Value::as_i64) { + if let Some(size) = data.get("size").and_then(|v| v.as_i64()) { data["size"] = Value::String(size.to_string()); } json!({ "id": self.uuid, - "accessId": self.access_id(), + "accessId": BASE64URL_NOPAD.encode(Uuid::parse_str(&self.uuid).unwrap_or_default().as_bytes()), "type": self.atype, "name": self.name, @@ -168,7 +157,7 @@ impl Send { "password": self.password_hash.as_deref().map(|h| BASE64URL_NOPAD.encode(h)), "authType": if self.password_hash.is_some() { SendAuthType::Password as i32 } else { SendAuthType::None as i32 }, "disabled": self.disabled, - "hideEmail": self.hide_email.unwrap_or(false), + "hideEmail": self.hide_email, "revisionDate": format_date(&self.revision_date), "expirationDate": self.expiration_date.as_ref().map(format_date), @@ -178,15 +167,17 @@ impl Send { } pub async fn to_json_access(&self, conn: &DbConn) -> Value { + use crate::util::format_date; + let mut data = serde_json::from_str::>(&self.data).map(|d| d.data).unwrap_or_default(); // Mobile clients expect size to be a string instead of a number - if let Some(size) = data.get("size").and_then(Value::as_i64) { + if let Some(size) = data.get("size").and_then(|v| v.as_i64()) { data["size"] = Value::String(size.to_string()); } json!({ - "id": self.access_id(), + "id": self.uuid, "type": self.atype, "name": self.name, @@ -200,22 +191,36 @@ impl Send { } } +use crate::db::DbConn; + +use crate::api::EmptyResult; +use crate::error::MapResult; +use crate::util::NumberOrString; + impl Send { pub async fn save(&mut self, conn: &DbConn) -> EmptyResult { self.update_users_revision(conn).await; self.revision_date = Utc::now().naive_utc(); db_run! { conn: - mysql { - diesel::insert_into(sends::table) + sqlite, mysql { + match diesel::replace_into(sends::table) .values(&*self) - .on_conflict(diesel::dsl::DuplicatedKeys) - .do_update() - .set(&*self) .execute(conn) - .map_res("Error saving send") + { + Ok(_) => Ok(()), + // Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first. + Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { + diesel::update(sends::table) + .filter(sends::uuid.eq(&self.uuid)) + .set(&*self) + .execute(conn) + .map_res("Error saving send") + } + Err(e) => Err(e.into()), + }.map_res("Error saving send") } - postgresql, sqlite { + postgresql { diesel::insert_into(sends::table) .values(&*self) .on_conflict(sends::uuid) @@ -227,65 +232,19 @@ impl Send { } } - /// Registers an access, incrementing `access_count` only while below `max_access_count`. - /// Returns false when the limit was already reached. The check and the increment are a single - /// statement, otherwise concurrent accesses can both pass the check and exceed the limit. - pub async fn register_access(&mut self, conn: &DbConn) -> Result { - self.update_users_revision(conn).await; - - let revision_date = Utc::now().naive_utc(); - let uuid = self.uuid.clone(); - let updated = conn - .run(move |conn| { - diesel::update(sends::table) - .filter(sends::uuid.eq(uuid)) - .filter( - sends::max_access_count - .is_null() - .or(sends::access_count.nullable().lt(sends::max_access_count)), - ) - .set((sends::access_count.eq(sends::access_count + 1), sends::revision_date.eq(revision_date))) - .execute(conn) - }) - .await?; - - if updated == 0 { - return Ok(false); - } - - self.access_count += 1; - self.revision_date = revision_date; - Ok(true) - } - - /// 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` - /// (counted on each access) or the password. - pub fn is_accessible(&self) -> bool { - let now = Utc::now().naive_utc(); - if self.disabled { - return false; - } - if let Some(expiration) = self.expiration_date - && now >= expiration - { - return false; - } - now < self.deletion_date - } - pub async fn delete(&self, conn: &DbConn) -> EmptyResult { self.update_users_revision(conn).await; if self.atype == SendType::File as i32 { let operator = CONFIG.opendal_operator_for_path_type(&PathType::Sends)?; - operator.delete_with(&self.uuid).recursive(true).await.ok(); + operator.remove_all(&self.uuid).await.ok(); } - conn.run(move |conn| { - diesel::delete(sends::table.filter(sends::uuid.eq(&self.uuid))).execute(conn).map_res("Error deleting send") - }) - .await + db_run! { conn: { + diesel::delete(sends::table.filter(sends::uuid.eq(&self.uuid))) + .execute(conn) + .map_res("Error deleting send") + }} } /// Purge all sends that are past their deletion date. @@ -297,12 +256,15 @@ impl Send { pub async fn update_users_revision(&self, conn: &DbConn) -> Vec { let mut user_uuids = Vec::new(); - if let Some(user_uuid) = &self.user_uuid { - User::update_uuid_revision(user_uuid, conn).await; - user_uuids.push(user_uuid.clone()); - } else { - // Belongs to Organization, not implemented - } + match &self.user_uuid { + Some(user_uuid) => { + User::update_uuid_revision(user_uuid, conn).await; + user_uuids.push(user_uuid.clone()) + } + None => { + // Belongs to Organization, not implemented + } + }; user_uuids } @@ -313,86 +275,134 @@ impl Send { Ok(()) } + pub async fn find_by_access_id(access_id: &str, conn: &DbConn) -> Option { + use data_encoding::BASE64URL_NOPAD; + use uuid::Uuid; + + 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 { - conn.run(move |conn| sends::table.filter(sends::uuid.eq(uuid)).first::(conn).ok()).await + db_run! { conn: { + sends::table + .filter(sends::uuid.eq(uuid)) + .first::(conn) + .ok() + }} } pub async fn find_by_uuid_and_user(uuid: &SendId, user_uuid: &UserId, conn: &DbConn) -> Option { - conn.run(move |conn| { - sends::table.filter(sends::uuid.eq(uuid)).filter(sends::user_uuid.eq(user_uuid)).first::(conn).ok() - }) - .await + db_run! { conn: { + sends::table + .filter(sends::uuid.eq(uuid)) + .filter(sends::user_uuid.eq(user_uuid)) + .first::(conn) + .ok() + }} } pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { - conn.run(move |conn| { - sends::table.filter(sends::user_uuid.eq(user_uuid)).load::(conn).expect("Error loading sends") - }) - .await + db_run! { conn: { + sends::table + .filter(sends::user_uuid.eq(user_uuid)) + .load::(conn) + .expect("Error loading sends") + }} } pub async fn size_by_user(user_uuid: &UserId, conn: &DbConn) -> Option { + let sends = Self::find_by_user(user_uuid, conn).await; + #[derive(serde::Deserialize)] struct FileData { + #[serde(rename = "size", alias = "Size")] size: NumberOrString, } - let sends = Self::find_by_user(user_uuid, conn).await; let mut total: i64 = 0; for send in sends { - if send.atype == SendType::File as i32 - && let Ok(size) = + if send.atype == SendType::File as i32 { + if let Ok(size) = serde_json::from_str::(&send.data).map_err(Into::into).and_then(|d| d.size.into_i64()) - { - total = total.checked_add(size)?; + { + total = total.checked_add(size)?; + }; } } Some(total) } + pub async fn find_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> Vec { + db_run! { conn: { + sends::table + .filter(sends::organization_uuid.eq(org_uuid)) + .load::(conn) + .expect("Error loading sends") + }} + } + pub async fn find_by_past_deletion_date(conn: &DbConn) -> Vec { let now = Utc::now().naive_utc(); - conn.run(move |conn| { - sends::table.filter(sends::deletion_date.lt(now)).load::(conn).expect("Error loading sends") - }) - .await + db_run! { conn: { + sends::table + .filter(sends::deletion_date.lt(now)) + .load::(conn) + .expect("Error loading sends") + }} } } -#[derive( - Clone, - Debug, - AsRef, - Deref, - DieselNewType, - Display, - From, - FromForm, - Hash, - PartialEq, - Eq, - Serialize, - Deserialize, - UuidFromParam, -)] -pub struct SendId(String); +// separate namespace to avoid name collision with std::marker::Send +pub mod id { + use derive_more::{AsRef, Deref, Display, From}; + use macros::{IdFromParam, UuidFromParam}; + use std::marker::Send; + use std::path::Path; -impl AsRef for SendId { - #[inline] - fn as_ref(&self) -> &Path { - Path::new(&self.0) - } -} - -#[derive( - Clone, Debug, AsRef, Deref, Display, From, FromForm, Hash, PartialEq, Eq, Serialize, Deserialize, IdFromParam, -)] -pub struct SendFileId(String); - -impl AsRef for SendFileId { - #[inline] - fn as_ref(&self) -> &Path { - Path::new(&self.0) + #[derive( + Clone, + Debug, + AsRef, + Deref, + DieselNewType, + Display, + From, + FromForm, + Hash, + PartialEq, + Eq, + Serialize, + Deserialize, + UuidFromParam, + )] + pub struct SendId(String); + + impl AsRef for SendId { + #[inline] + fn as_ref(&self) -> &Path { + Path::new(&self.0) + } + } + + #[derive( + Clone, Debug, AsRef, Deref, Display, From, FromForm, Hash, PartialEq, Eq, Serialize, Deserialize, IdFromParam, + )] + pub struct SendFileId(String); + + impl AsRef for SendFileId { + #[inline] + fn as_ref(&self) -> &Path { + Path::new(&self.0) + } } } diff --git a/src/db/models/sso_auth.rs b/src/db/models/sso_auth.rs index 311e9bf9..fec0433a 100644 --- a/src/db/models/sso_auth.rs +++ b/src/db/models/sso_auth.rs @@ -1,29 +1,31 @@ +use chrono::{NaiveDateTime, Utc}; use std::time::Duration; -use chrono::{NaiveDateTime, Utc}; -use diesel::{ - deserialize::FromSql, - expression::AsExpression, - prelude::*, - serialize::{Output, ToSql}, - sql_types::Text, -}; +use crate::api::EmptyResult; +use crate::db::schema::sso_auth; +use crate::db::{DbConn, DbPool}; +use crate::error::MapResult; +use crate::sso::{OIDCCode, OIDCCodeChallenge, OIDCIdentifier, OIDCState, SSO_AUTH_EXPIRATION}; -use crate::{ - api::EmptyResult, - db::{DbConn, DbPool, schema::sso_auth}, - error::MapResult, - sso::{OIDCCode, OIDCCodeChallenge, OIDCIdentifier, OIDCState, SSO_AUTH_EXPIRATION}, -}; +use diesel::deserialize::FromSql; +use diesel::expression::AsExpression; +use diesel::prelude::*; +use diesel::serialize::{Output, ToSql}; +use diesel::sql_types::Text; #[derive(AsExpression, Clone, Debug, Serialize, Deserialize, FromSqlRow)] #[diesel(sql_type = Text)] -pub struct OIDCCodeResponseError { - pub error: String, - pub error_description: Option, +pub enum OIDCCodeWrapper { + Ok { + code: OIDCCode, + }, + Error { + error: String, + error_description: Option, + }, } -impl_FromToSqlText!(OIDCCodeResponseError); +impl_FromToSqlText!(OIDCCodeWrapper); #[derive(AsExpression, Clone, Debug, Serialize, Deserialize, FromSqlRow)] #[diesel(sql_type = Text)] @@ -48,23 +50,15 @@ pub struct SsoAuth { pub client_challenge: OIDCCodeChallenge, pub nonce: String, pub redirect_uri: String, - pub code_response: Option, - pub code_response_error: Option, + pub code_response: Option, pub auth_response: Option, pub created_at: NaiveDateTime, pub updated_at: NaiveDateTime, - pub binding_hash: Option, } /// Local methods impl SsoAuth { - pub fn new( - state: OIDCState, - client_challenge: OIDCCodeChallenge, - nonce: String, - redirect_uri: String, - binding_hash: Option, - ) -> Self { + pub fn new(state: OIDCState, client_challenge: OIDCCodeChallenge, nonce: String, redirect_uri: String) -> Self { let now = Utc::now().naive_utc(); SsoAuth { @@ -75,9 +69,7 @@ impl SsoAuth { created_at: now, updated_at: now, code_response: None, - code_response_error: None, auth_response: None, - binding_hash, } } } @@ -108,22 +100,10 @@ impl SsoAuth { } pub async fn find(state: &OIDCState, conn: &DbConn) -> Option { - let oldest = Utc::now().naive_utc() - *SSO_AUTH_EXPIRATION; - conn.run(move |conn| { - sso_auth::table - .filter(sso_auth::state.eq(state)) - .filter(sso_auth::created_at.ge(oldest)) - .first::(conn) - .ok() - }) - .await - } - - pub async fn find_by_code(code: &OIDCCode, conn: &DbConn) -> Option { let oldest = Utc::now().naive_utc() - *SSO_AUTH_EXPIRATION; db_run! { conn: { sso_auth::table - .filter(sso_auth::code_response.eq(code)) + .filter(sso_auth::state.eq(state)) .filter(sso_auth::created_at.ge(oldest)) .first::(conn) .ok() @@ -131,24 +111,22 @@ impl SsoAuth { } pub async fn delete(self, conn: &DbConn) -> EmptyResult { - conn.run(move |conn| { + db_run! {conn: { diesel::delete(sso_auth::table.filter(sso_auth::state.eq(self.state))) .execute(conn) .map_res("Error deleting sso_auth") - }) - .await + }} } pub async fn delete_expired(pool: DbPool) -> EmptyResult { debug!("Purging expired sso_auth"); if let Ok(conn) = pool.get().await { let oldest = Utc::now().naive_utc() - *SSO_AUTH_EXPIRATION; - conn.run(move |conn| { + db_run! { conn: { diesel::delete(sso_auth::table.filter(sso_auth::created_at.lt(oldest))) .execute(conn) .map_res("Error deleting expired SSO nonce") - }) - .await + }} } else { err!("Failed to get DB connection while purging expired sso_auth") } diff --git a/src/db/models/two_factor.rs b/src/db/models/two_factor.rs index 051ea4df..0dc08e3e 100644 --- a/src/db/models/two_factor.rs +++ b/src/db/models/two_factor.rs @@ -1,17 +1,13 @@ +use super::UserId; +use crate::api::core::two_factor::webauthn::WebauthnRegistration; +use crate::db::schema::twofactor; +use crate::{api::EmptyResult, db::DbConn, error::MapResult}; use diesel::prelude::*; use serde_json::Value; use webauthn_rs::prelude::{Credential, ParsedAttestation}; use webauthn_rs_core::proto::CredentialV3; use webauthn_rs_proto::{AttestationFormat, RegisteredExtensions}; -use crate::{ - api::{EmptyResult, core::two_factor::webauthn::WebauthnRegistration}, - db::{DbConn, schema::twofactor}, - error::MapResult, -}; - -use super::UserId; - #[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[diesel(table_name = twofactor)] #[diesel(primary_key(uuid))] @@ -118,59 +114,54 @@ impl TwoFactor { } pub async fn delete(self, conn: &DbConn) -> EmptyResult { - conn.run(move |conn| { + db_run! { conn: { diesel::delete(twofactor::table.filter(twofactor::uuid.eq(self.uuid))) .execute(conn) .map_res("Error deleting twofactor") - }) - .await + }} } pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { - conn.run(move |conn| { + db_run! { conn: { twofactor::table .filter(twofactor::user_uuid.eq(user_uuid)) .filter(twofactor::atype.lt(1000)) // Filter implementation types .load::(conn) .expect("Error loading twofactor") - }) - .await + }} } - pub async fn find_by_user_and_type(user_uuid: &UserId, atype: TwoFactorType, conn: &DbConn) -> Option { - conn.run(move |conn| { + pub async fn find_by_user_and_type(user_uuid: &UserId, atype: i32, conn: &DbConn) -> Option { + db_run! { conn: { twofactor::table .filter(twofactor::user_uuid.eq(user_uuid)) - .filter(twofactor::atype.eq(atype as i32)) + .filter(twofactor::atype.eq(atype)) .first::(conn) .ok() - }) - .await + }} } pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { - conn.run(move |conn| { + db_run! { conn: { diesel::delete(twofactor::table.filter(twofactor::user_uuid.eq(user_uuid))) .execute(conn) .map_res("Error deleting twofactors") - }) - .await + }} } pub async fn migrate_u2f_to_webauthn(conn: &DbConn) -> EmptyResult { - use crate::api::core::two_factor::webauthn::{U2FRegistration, get_webauthn_registrations}; + let u2f_factors = db_run! { conn: { + twofactor::table + .filter(twofactor::atype.eq(TwoFactorType::U2f as i32)) + .load::(conn) + .expect("Error loading twofactor") + }}; + + use crate::api::core::two_factor::webauthn::U2FRegistration; + use crate::api::core::two_factor::webauthn::{get_webauthn_registrations, WebauthnRegistration}; use webauthn_rs::prelude::{COSEEC2Key, COSEKey, COSEKeyType, ECDSACurve}; use webauthn_rs_proto::{COSEAlgorithm, UserVerificationPolicy}; - let u2f_factors = conn - .run(move |conn| { - twofactor::table - .filter(twofactor::atype.eq(TwoFactorType::U2f as i32)) - .load::(conn) - .expect("Error loading twofactor") - }) - .await; - for mut u2f in u2f_factors { let mut regs: Vec = serde_json::from_str(&u2f.data)?; // If there are no registrations or they are migrated (we do the migration in batch so we can consider them all migrated when the first one is) @@ -236,14 +227,12 @@ impl TwoFactor { } pub async fn migrate_credential_to_passkey(conn: &DbConn) -> EmptyResult { - let webauthn_factors = conn - .run(move |conn| { - twofactor::table - .filter(twofactor::atype.eq(TwoFactorType::Webauthn as i32)) - .load::(conn) - .expect("Error loading twofactor") - }) - .await; + let webauthn_factors = db_run! { conn: { + twofactor::table + .filter(twofactor::atype.eq(TwoFactorType::Webauthn as i32)) + .load::(conn) + .expect("Error loading twofactor") + }}; for webauthn_factor in webauthn_factors { // assume that a failure to parse into the old struct, means that it was already converted @@ -252,7 +241,7 @@ impl TwoFactor { continue; }; - let regs = regs.into_iter().map(Into::into).collect::>(); + let regs = regs.into_iter().map(|r| r.into()).collect::>(); TwoFactor::new(webauthn_factor.user_uuid.clone(), TwoFactorType::Webauthn, serde_json::to_string(®s)?) .save(conn) diff --git a/src/db/models/two_factor_duo_context.rs b/src/db/models/two_factor_duo_context.rs index 1a4ae266..205a57d8 100644 --- a/src/db/models/two_factor_duo_context.rs +++ b/src/db/models/two_factor_duo_context.rs @@ -1,11 +1,8 @@ use chrono::Utc; -use diesel::prelude::*; -use crate::{ - api::EmptyResult, - db::{DbConn, schema::twofactor_duo_ctx}, - error::MapResult, -}; +use crate::db::schema::twofactor_duo_ctx; +use crate::{api::EmptyResult, db::DbConn, error::MapResult}; +use diesel::prelude::*; #[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[diesel(table_name = twofactor_duo_ctx)] @@ -19,10 +16,12 @@ pub struct TwoFactorDuoContext { impl TwoFactorDuoContext { pub async fn find_by_state(state: &str, conn: &DbConn) -> Option { - conn.run(move |conn| { - twofactor_duo_ctx::table.filter(twofactor_duo_ctx::state.eq(state)).first::(conn).ok() - }) - .await + db_run! { conn: { + twofactor_duo_ctx::table + .filter(twofactor_duo_ctx::state.eq(state)) + .first::(conn) + .ok() + }} } pub async fn save(state: &str, user_email: &str, nonce: &str, ttl: i64, conn: &DbConn) -> EmptyResult { @@ -30,42 +29,41 @@ impl TwoFactorDuoContext { let exists = Self::find_by_state(state, conn).await; if exists.is_some() { return Ok(()); - } + }; let exp = Utc::now().timestamp() + ttl; - conn.run(move |conn| { + db_run! { conn: { diesel::insert_into(twofactor_duo_ctx::table) .values(( twofactor_duo_ctx::state.eq(state), twofactor_duo_ctx::user_email.eq(user_email), twofactor_duo_ctx::nonce.eq(nonce), - twofactor_duo_ctx::exp.eq(exp), - )) - .execute(conn) - .map_res("Error saving context to twofactor_duo_ctx") - }) - .await + twofactor_duo_ctx::exp.eq(exp) + )) + .execute(conn) + .map_res("Error saving context to twofactor_duo_ctx") + }} } pub async fn find_expired(conn: &DbConn) -> Vec { let now = Utc::now().timestamp(); - conn.run(move |conn| { + db_run! { conn: { twofactor_duo_ctx::table .filter(twofactor_duo_ctx::exp.lt(now)) .load::(conn) .expect("Error finding expired contexts in twofactor_duo_ctx") - }) - .await + }} } pub async fn delete(&self, conn: &DbConn) -> EmptyResult { - conn.run(move |conn| { - diesel::delete(twofactor_duo_ctx::table.filter(twofactor_duo_ctx::state.eq(&self.state))) + db_run! { conn: { + diesel::delete( + twofactor_duo_ctx::table + .filter(twofactor_duo_ctx::state.eq(&self.state))) .execute(conn) .map_res("Error deleting from twofactor_duo_ctx") - }) - .await + }} } pub async fn purge_expired_duo_contexts(conn: &DbConn) { diff --git a/src/db/models/two_factor_incomplete.rs b/src/db/models/two_factor_incomplete.rs index e4c7e308..2f7e4779 100644 --- a/src/db/models/two_factor_incomplete.rs +++ b/src/db/models/two_factor_incomplete.rs @@ -1,17 +1,17 @@ use chrono::{NaiveDateTime, Utc}; -use diesel::prelude::*; +use crate::db::schema::twofactor_incomplete; use crate::{ - CONFIG, api::EmptyResult, auth::ClientIp, db::{ - DbConn, models::{DeviceId, UserId}, - schema::twofactor_incomplete, + DbConn, }, error::MapResult, + CONFIG, }; +use diesel::prelude::*; #[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[diesel(table_name = twofactor_incomplete)] @@ -37,7 +37,7 @@ impl TwoFactorIncomplete { ip: &ClientIp, conn: &DbConn, ) -> EmptyResult { - if CONFIG.incomplete_2fa_time_limit() == 0 || !CONFIG.mail_enabled() { + if CONFIG.incomplete_2fa_time_limit() <= 0 || !CONFIG.mail_enabled() { return Ok(()); } @@ -49,7 +49,7 @@ impl TwoFactorIncomplete { return Ok(()); } - conn.run(move |conn| { + db_run! { conn: { diesel::insert_into(twofactor_incomplete::table) .values(( twofactor_incomplete::user_uuid.eq(user_uuid), @@ -61,12 +61,11 @@ impl TwoFactorIncomplete { )) .execute(conn) .map_res("Error adding twofactor_incomplete record") - }) - .await + }} } pub async fn mark_complete(user_uuid: &UserId, device_uuid: &DeviceId, conn: &DbConn) -> EmptyResult { - if CONFIG.incomplete_2fa_time_limit() == 0 || !CONFIG.mail_enabled() { + if CONFIG.incomplete_2fa_time_limit() <= 0 || !CONFIG.mail_enabled() { return Ok(()); } @@ -74,24 +73,22 @@ impl TwoFactorIncomplete { } pub async fn find_by_user_and_device(user_uuid: &UserId, device_uuid: &DeviceId, conn: &DbConn) -> Option { - conn.run(move |conn| { + db_run! { conn: { twofactor_incomplete::table .filter(twofactor_incomplete::user_uuid.eq(user_uuid)) .filter(twofactor_incomplete::device_uuid.eq(device_uuid)) .first::(conn) .ok() - }) - .await + }} } pub async fn find_logins_before(dt: &NaiveDateTime, conn: &DbConn) -> Vec { - conn.run(move |conn| { + db_run! { conn: { twofactor_incomplete::table .filter(twofactor_incomplete::login_time.lt(dt)) .load::(conn) .expect("Error loading twofactor_incomplete") - }) - .await + }} } pub async fn delete(self, conn: &DbConn) -> EmptyResult { @@ -99,24 +96,20 @@ impl TwoFactorIncomplete { } pub async fn delete_by_user_and_device(user_uuid: &UserId, device_uuid: &DeviceId, conn: &DbConn) -> EmptyResult { - conn.run(move |conn| { - diesel::delete( - twofactor_incomplete::table - .filter(twofactor_incomplete::user_uuid.eq(user_uuid)) - .filter(twofactor_incomplete::device_uuid.eq(device_uuid)), - ) - .execute(conn) - .map_res("Error in twofactor_incomplete::delete_by_user_and_device()") - }) - .await + db_run! { conn: { + diesel::delete(twofactor_incomplete::table + .filter(twofactor_incomplete::user_uuid.eq(user_uuid)) + .filter(twofactor_incomplete::device_uuid.eq(device_uuid))) + .execute(conn) + .map_res("Error in twofactor_incomplete::delete_by_user_and_device()") + }} } pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { - conn.run(move |conn| { + db_run! { conn: { diesel::delete(twofactor_incomplete::table.filter(twofactor_incomplete::user_uuid.eq(user_uuid))) .execute(conn) .map_res("Error in twofactor_incomplete::delete_all_by_user()") - }) - .await + }} } } diff --git a/src/db/models/user.rs b/src/db/models/user.rs index 49ea67b7..ebc72101 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -1,29 +1,23 @@ +use crate::db::schema::{invitations, sso_users, twofactor_incomplete, users}; use chrono::{NaiveDateTime, TimeDelta, Utc}; use derive_more::{AsRef, Deref, Display, From}; use diesel::prelude::*; use serde_json::Value; +use super::{ + Cipher, Device, EmergencyAccess, Favorite, Folder, Membership, MembershipType, TwoFactor, TwoFactorIncomplete, +}; use crate::{ - CONFIG, api::EmptyResult, - auth::DEFAULT_ACCESS_VALIDITY, crypto, - db::{ - DbConn, - models::DeviceId, - schema::{invitations, sso_users, twofactor_incomplete, users}, - }, + db::{models::DeviceId, DbConn}, error::MapResult, sso::OIDCIdentifier, util::{format_date, get_uuid, retry}, + CONFIG, }; use macros::UuidFromParam; -use super::{ - AuthRequest, Cipher, Device, EmergencyAccess, Favorite, Folder, Membership, MembershipType, TwoFactor, - TwoFactorIncomplete, UserSignatureKeyPair, -}; - #[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)] #[diesel(table_name = users)] #[diesel(treat_none_as_null = true)] @@ -71,15 +65,6 @@ pub struct User { pub avatar_color: Option, pub external_id: Option, // Todo: Needs to be removed in the future, this is not used anymore. - - pub key_id: Option, - - // The v2 state: all set, with a `user_signature_key_pairs` row, or none, see `User::is_v2` - pub signed_public_key: Option, - pub security_state: Option, - pub security_version: Option, - /// JSON `{"wrappedUserKey1", "wrappedUserKey2"}` for sessions that still hold the v1 user key. Opaque to us. - pub v2_upgrade_token: Option, } #[derive(Identifiable, Queryable, Insertable)] @@ -108,28 +93,13 @@ enum UserStatus { _Disabled = 2, } -/// A previous security stamp that is still accepted, until `expire`. #[derive(Serialize, Deserialize)] pub struct UserStampException { - /// The routes the stamp is still accepted on, or `None` for any route. - pub routes: Option>, + pub routes: Vec, pub security_stamp: String, pub expire: i64, } -impl UserStampException { - pub fn is_expired(&self) -> bool { - Utc::now().timestamp() > self.expire - } - - /// Whether this exception lets a token carrying `security_stamp` through on `route`. - pub fn allows(&self, security_stamp: &str, route: &str) -> bool { - !self.is_expired() - && self.security_stamp == security_stamp - && self.routes.as_ref().is_none_or(|routes| routes.iter().any(|r| r == route)) - } -} - /// Local methods impl User { pub const CLIENT_KDF_TYPE_DEFAULT: i32 = UserKdfType::Pbkdf2 as i32; @@ -167,8 +137,8 @@ impl User { _totp_secret: None, totp_recover: None, - equivalent_domains: "[]".to_owned(), - excluded_globals: "[]".to_owned(), + equivalent_domains: "[]".to_string(), + excluded_globals: "[]".to_string(), client_kdf_type: Self::CLIENT_KDF_TYPE_DEFAULT, client_kdf_iter: Self::CLIENT_KDF_ITER_DEFAULT, @@ -180,47 +150,18 @@ impl User { avatar_color: None, external_id: None, // Todo: Needs to be removed in the future, this is not used anymore. - - key_id: None, - - signed_public_key: None, - security_state: None, - security_version: None, - v2_upgrade_token: None, } } - pub fn is_v2(&self) -> bool { - self.signed_public_key.is_some() && self.security_state.is_some() && self.security_version.is_some() - } - - /// Upstream's `HasV2Encryption`: a private key wrapped as `CoseEncrypt0B64` (type 7) and security version 2. - /// Upstream only uses it to decide which accounts need a v2-capable client. - /// - /// Ref: - pub fn has_v2_encryption(&self) -> bool { - self.security_version == Some(2) - && self - .private_key - .as_deref() - .and_then(|private_key| private_key.split_once('.')) - .is_some_and(|(enc_type, _)| enc_type.parse::() == Ok(7)) - } - pub fn check_valid_password(&self, password: &str) -> bool { crypto::verify_password_hash( password.as_bytes(), &self.salt, &self.password_hash, - self.password_iterations.cast_unsigned(), + self.password_iterations as u32, ) } - /// Upstream's `GetMasterPasswordSalt`: there is no separate salt, so the normalized email - pub fn master_password_salt(&self) -> String { - self.email.trim().to_lowercase() - } - pub fn check_valid_recovery_code(&self, recovery_code: &str) -> bool { if let Some(ref totp_recover) = self.totp_recover { crypto::ct_eq(recovery_code, totp_recover.to_lowercase()) @@ -252,8 +193,7 @@ impl User { allow_next_route: Option>, conn: &DbConn, ) -> EmptyResult { - self.password_hash = - crypto::hash_password(password.as_bytes(), &self.salt, self.password_iterations.cast_unsigned()); + self.password_hash = crypto::hash_password(password.as_bytes(), &self.salt, self.password_iterations as u32); if let Some(route) = allow_next_route { self.set_stamp_exception(route); @@ -271,33 +211,10 @@ impl User { pub async fn reset_security_stamp(&mut self, conn: &DbConn) -> EmptyResult { self.security_stamp = get_uuid(); - // Keeps the unexpired route exceptions, set for the stamp being replaced, and drops a rotation's grace - let mut exceptions = self.stamp_exceptions(); - exceptions.retain(|e| e.routes.is_some() && !e.is_expired()); - self.set_stamp_exceptions(&exceptions); Device::rotate_refresh_tokens_by_user(&self.uuid, conn).await?; Ok(()) } - /// Resets the stamp but lets issued access tokens run out, as upstream only checks it on refresh. - pub async fn reset_security_stamp_after_key_rotation(&mut self, conn: &DbConn) -> EmptyResult { - let previous_stamp = self.security_stamp.clone(); - let mut grace: Vec = - self.stamp_exceptions().into_iter().filter(|e| e.routes.is_none() && !e.is_expired()).collect(); - - self.reset_security_stamp(conn).await?; - - grace.push(UserStampException { - routes: None, - security_stamp: previous_stamp, - expire: (Utc::now() + *DEFAULT_ACCESS_VALIDITY).timestamp(), - }); - let mut exceptions = self.stamp_exceptions(); - exceptions.extend(grace); - self.set_stamp_exceptions(&exceptions); - Ok(()) - } - /// Set the stamp_exception to only allow a subsequent request matching a specific route using the current security-stamp. /// /// # Arguments @@ -306,115 +223,37 @@ impl User { /// After these 2 minutes this stamp will expire. /// pub fn set_stamp_exception(&mut self, route_exception: Vec) { - self.set_stamp_exceptions(&[UserStampException { - routes: Some(route_exception), + let stamp_exception = UserStampException { + routes: route_exception, security_stamp: self.security_stamp.clone(), expire: (Utc::now() + TimeDelta::try_minutes(2).unwrap()).timestamp(), - }]); + }; + self.stamp_exception = Some(serde_json::to_string(&stamp_exception).unwrap_or_default()); } - /// The previous security stamps that are still accepted, expired ones included. - pub fn stamp_exceptions(&self) -> Vec { - let Some(stored) = self.stamp_exception.as_deref() else { - return Vec::new(); - }; - // Before key rotations kept sessions alive, only a single route exception was stored. - serde_json::from_str::>(stored) - .or_else(|_| serde_json::from_str::(stored).map(|e| vec![e])) - .unwrap_or_default() - } - - fn set_stamp_exceptions(&mut self, exceptions: &[UserStampException]) { - self.stamp_exception = if exceptions.is_empty() { - None - } else { - Some(serde_json::to_string(&exceptions).unwrap_or_default()) - }; + /// Resets the stamp_exception to prevent re-use of the previous security-stamp + pub fn reset_stamp_exception(&mut self) { + self.stamp_exception = None; } pub fn display_name(&self) -> &str { // default to email if name is empty - if self.name.is_empty() { - &self.email - } else { + if !&self.name.is_empty() { &self.name + } else { + &self.email } } } /// Database methods impl User { - pub fn verified(&self) -> bool { - !CONFIG.mail_enabled() || self.verified_at.is_some() - } - - async fn v2_signature_key_pair(&self, conn: &DbConn) -> Option { - if !self.is_v2() { - return None; - } - UserSignatureKeyPair::find_by_user(&self.uuid, conn).await - } - - /// The `accountKeys` object (upstream's `PrivateKeysResponseModel`), null without a key pair - pub async fn account_keys_json(&self, conn: &DbConn) -> Value { - if self.private_key.is_some() { - let (signed_public_key, signature_key_pair, security_state) = match self.v2_signature_key_pair(conn).await { - Some(key_pair) => ( - json!(self.signed_public_key), - key_pair.to_json(), - json!({ - "securityState": self.security_state, - "securityVersion": self.security_version, - }), - ), - None => (Value::Null, Value::Null, Value::Null), - }; - - json!({ - "publicKeyEncryptionKeyPair": { - "wrappedPrivateKey": self.private_key, - "publicKey": self.public_key, - "signedPublicKey": signed_public_key, - "object": "publicKeyEncryptionKeyPair", - }, - "securityState": security_state, - "signatureKeyPair": signature_key_pair, - "object": "privateKeys" - }) - } else { - Value::Null - } - } - - pub async fn public_keys_json(&self, conn: &DbConn) -> Value { - let (signed_public_key, verifying_key) = match self.v2_signature_key_pair(conn).await { - Some(key_pair) => (json!(self.signed_public_key), json!(key_pair.verifying_key)), - None => (Value::Null, Value::Null), - }; - - json!({ - "publicKey": self.public_key, - "signedPublicKey": signed_public_key, - "verifyingKey": verifying_key, - "object": "publicKeys" - }) - } - - pub fn v2_upgrade_token_json(&self) -> Option { - self.v2_upgrade_token.as_ref().and_then(|token| serde_json::from_str(token).ok()) - } - pub async fn to_json(&self, conn: &DbConn) -> Value { let mut orgs_json = Vec::new(); for c in Membership::find_confirmed_by_user(&self.uuid, conn).await { orgs_json.push(c.to_json(conn).await); } - let mut orgs_new_json = Vec::new(); - for c in Membership::find_accepted_and_confirmed_by_user(&self.uuid, conn).await { - orgs_new_json.push(c.to_json(conn).await); - } - let twofactor_enabled = !TwoFactor::find_by_user(&self.uuid, conn).await.is_empty(); // TODO: Might want to save the status field in the DB @@ -424,15 +263,12 @@ impl User { UserStatus::Enabled }; - let account_keys = self.account_keys_json(conn).await; - json!({ "_status": status as i32, - "accountKeys": account_keys, "id": self.uuid, "name": self.name, "email": self.email, - "emailVerified": self.verified(), + "emailVerified": !CONFIG.mail_enabled() || self.verified_at.is_some(), "premium": true, "premiumFromOrganization": false, "culture": "en-US", @@ -441,7 +277,6 @@ impl User { "privateKey": self.private_key, "securityStamp": self.security_stamp, "organizations": orgs_json, - "organizationsNew": orgs_new_json, "providers": [], "providerOrganizations": [], "forcePasswordReset": false, @@ -454,7 +289,7 @@ impl User { pub async fn save(&mut self, conn: &DbConn) -> EmptyResult { if !crate::util::is_valid_email(&self.email) { - err!(format!("User email {} is not a valid email address", self.email.escape_debug())) + err!(format!("User email {} is not a valid email address", self.email)) } self.updated_at = Utc::now().naive_utc(); @@ -481,7 +316,7 @@ impl User { } } - pub async fn delete(&self, conn: &DbConn) -> EmptyResult { + pub async fn delete(self, conn: &DbConn) -> EmptyResult { for member in Membership::find_confirmed_by_user(&self.uuid, conn).await { if member.atype == MembershipType::Owner && Membership::count_confirmed_by_org_and_type(&member.org_uuid, MembershipType::Owner, conn).await <= 1 @@ -497,21 +332,20 @@ impl User { Cipher::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?; - AuthRequest::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?; TwoFactorIncomplete::delete_all_by_user(&self.uuid, conn).await?; - UserSignatureKeyPair::delete_all_by_user(&self.uuid, conn).await?; Invitation::take(&self.email, conn).await; // Delete invitation if any - conn.run(move |conn| { - diesel::delete(users::table.filter(users::uuid.eq(&self.uuid))).execute(conn).map_res("Error deleting user") - }) - .await + db_run! { conn: { + diesel::delete(users::table.filter(users::uuid.eq(self.uuid))) + .execute(conn) + .map_res("Error deleting user") + }} } pub async fn update_uuid_revision(uuid: &UserId, conn: &DbConn) { - if let Err(e) = Self::update_revision_impl(uuid, &Utc::now().naive_utc(), conn).await { + if let Err(e) = Self::_update_revision(uuid, &Utc::now().naive_utc(), conn).await { warn!("Failed to update revision for {uuid}: {e:#?}"); } } @@ -519,62 +353,68 @@ impl User { pub async fn update_all_revisions(conn: &DbConn) -> EmptyResult { let updated_at = Utc::now().naive_utc(); - conn.run(move |conn| { - retry(|| diesel::update(users::table).set(users::updated_at.eq(updated_at)).execute(conn), 10) - .map_res("Error updating revision date for all users") - }) - .await + db_run! { conn: { + retry(|| { + diesel::update(users::table) + .set(users::updated_at.eq(updated_at)) + .execute(conn) + }, 10) + .map_res("Error updating revision date for all users") + }} } pub async fn update_revision(&mut self, conn: &DbConn) -> EmptyResult { self.updated_at = Utc::now().naive_utc(); - Self::update_revision_impl(&self.uuid, &self.updated_at, conn).await + Self::_update_revision(&self.uuid, &self.updated_at, conn).await } - async fn update_revision_impl(uuid: &UserId, date: &NaiveDateTime, conn: &DbConn) -> EmptyResult { - conn.run(move |conn| { - retry( - || { - diesel::update(users::table.filter(users::uuid.eq(uuid))) - .set(users::updated_at.eq(date)) - .execute(conn) - }, - 10, - ) + async fn _update_revision(uuid: &UserId, date: &NaiveDateTime, conn: &DbConn) -> EmptyResult { + db_run! { conn: { + retry(|| { + diesel::update(users::table.filter(users::uuid.eq(uuid))) + .set(users::updated_at.eq(date)) + .execute(conn) + }, 10) .map_res("Error updating user revision") - }) - .await + }} } pub async fn find_by_mail(mail: &str, conn: &DbConn) -> Option { let lower_mail = mail.to_lowercase(); - conn.run(move |conn| users::table.filter(users::email.eq(lower_mail)).first::(conn).ok()).await + db_run! { conn: { + users::table + .filter(users::email.eq(lower_mail)) + .first::(conn) + .ok() + }} } pub async fn find_by_uuid(uuid: &UserId, conn: &DbConn) -> Option { - conn.run(move |conn| users::table.filter(users::uuid.eq(uuid)).first::(conn).ok()).await + db_run! { conn: { + users::table + .filter(users::uuid.eq(uuid)) + .first::(conn) + .ok() + }} } pub async fn find_by_device_for_email2fa(device_uuid: &DeviceId, conn: &DbConn) -> Option { - if let Some(user_uuid) = conn - .run(move |conn| { - twofactor_incomplete::table - .filter(twofactor_incomplete::device_uuid.eq(device_uuid)) - .order_by(twofactor_incomplete::login_time.desc()) - .select(twofactor_incomplete::user_uuid) - .first::(conn) - .ok() - }) - .await - { + if let Some(user_uuid) = db_run! ( conn: { + twofactor_incomplete::table + .filter(twofactor_incomplete::device_uuid.eq(device_uuid)) + .order_by(twofactor_incomplete::login_time.desc()) + .select(twofactor_incomplete::user_uuid) + .first::(conn) + .ok() + }) { return Self::find_by_uuid(&user_uuid, conn).await; } None } pub async fn get_all(conn: &DbConn) -> Vec<(Self, Option)> { - conn.run(move |conn| { + db_run! { conn: { users::table .left_join(sso_users::table) .select(<(Self, Option)>::as_select()) @@ -582,8 +422,7 @@ impl User { .expect("Error loading groups for user") .into_iter() .collect() - }) - .await + }} } pub async fn last_active(&self, conn: &DbConn) -> Option { @@ -604,21 +443,19 @@ impl Invitation { pub async fn save(&self, conn: &DbConn) -> EmptyResult { if !crate::util::is_valid_email(&self.email) { - err!(format!("Invitation email {} is not a valid email address", self.email.escape_debug())) + err!(format!("Invitation email {} is not a valid email address", self.email)) } db_run! { conn: - // Not checking for ForeignKey Constraints here - // Table invitations does not have any ForeignKey Constraints. - mysql { - diesel::insert_into(invitations::table) + sqlite, mysql { + // Not checking for ForeignKey Constraints here + // Table invitations does not have any ForeignKey Constraints. + diesel::replace_into(invitations::table) .values(self) - .on_conflict(diesel::dsl::DuplicatedKeys) - .do_nothing() .execute(conn) .map_res("Error saving invitation") } - postgresql, sqlite { + postgresql { diesel::insert_into(invitations::table) .values(self) .on_conflict(invitations::email) @@ -630,18 +467,21 @@ impl Invitation { } pub async fn delete(self, conn: &DbConn) -> EmptyResult { - conn.run(move |conn| { + db_run! { conn: { diesel::delete(invitations::table.filter(invitations::email.eq(self.email))) .execute(conn) .map_res("Error deleting invitation") - }) - .await + }} } pub async fn find_by_mail(mail: &str, conn: &DbConn) -> Option { let lower_mail = mail.to_lowercase(); - conn.run(move |conn| invitations::table.filter(invitations::email.eq(lower_mail)).first::(conn).ok()) - .await + db_run! { conn: { + invitations::table + .filter(invitations::email.eq(lower_mail)) + .first::(conn) + .ok() + }} } pub async fn take(mail: &str, conn: &DbConn) -> bool { @@ -672,37 +512,16 @@ impl Invitation { #[from(forward)] pub struct UserId(String); -#[derive( - Clone, Debug, DieselNewType, FromForm, PartialEq, Eq, Hash, Serialize, AsRef, Deref, Display, From, UuidFromParam, -)] -#[deref(forward)] -#[from(forward)] -pub struct KeyId(String); - -/// Like upstream, a key id is 32 lowercase hex characters. Key ids are compared as strings, so one in any other -/// format would never match the id the clients send. -/// -/// Ref: -impl<'de> serde::Deserialize<'de> for KeyId { - fn deserialize>(deserializer: D) -> Result { - let key_id = String::deserialize(deserializer)?; - if key_id.len() != 32 || !key_id.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) { - return Err(serde::de::Error::custom("Key id must be 32 lowercase hex characters")); - } - Ok(Self(key_id)) - } -} - impl SsoUser { pub async fn save(&self, conn: &DbConn) -> EmptyResult { db_run! { conn: - mysql { - diesel::insert_into(sso_users::table) + sqlite, mysql { + diesel::replace_into(sso_users::table) .values(self) .execute(conn) .map_res("Error saving SSO user") } - postgresql, sqlite { + postgresql { diesel::insert_into(sso_users::table) .values(self) .execute(conn) @@ -712,90 +531,34 @@ impl SsoUser { } pub async fn find_by_identifier(identifier: &str, conn: &DbConn) -> Option<(User, Self)> { - conn.run(move |conn| { + db_run! { conn: { users::table .inner_join(sso_users::table) .select(<(User, Self)>::as_select()) .filter(sso_users::identifier.eq(identifier)) .first::<(User, Self)>(conn) .ok() - }) - .await + }} } pub async fn find_by_mail(mail: &str, conn: &DbConn) -> Option<(User, Option)> { let lower_mail = mail.to_lowercase(); - conn.run(move |conn| { + db_run! { conn: { users::table .left_join(sso_users::table) .select(<(User, Option)>::as_select()) .filter(users::email.eq(lower_mail)) .first::<(User, Option)>(conn) .ok() - }) - .await + }} } pub async fn delete(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { - conn.run(move |conn| { + db_run! { conn: { diesel::delete(sso_users::table.filter(sso_users::user_uuid.eq(user_uuid))) .execute(conn) .map_res("Error deleting sso user") - }) - .await - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_key_id_format() { - let key_id: KeyId = serde_json::from_str(r#""0123456789abcdef0123456789abcdef""#).unwrap(); - assert_eq!(key_id.as_ref(), "0123456789abcdef0123456789abcdef"); - - for invalid in ["", "0123456789ABCDEF0123456789ABCDEF", "0123456789abcdef", "0123456789abcdef0123456789abcdeg"] - { - assert!(serde_json::from_str::(&format!("\"{invalid}\"")).is_err(), "{invalid:?}"); - } - - // An absent key id stays optional - assert_eq!(serde_json::from_str::>("null").unwrap(), None); - } - - #[test] - fn test_has_v2_encryption() { - // User::new loads CONFIG; isolate it from the DNS bootstrap test and filesystem configuration. - if std::env::var_os("VAULTWARDEN_ISOLATED_CONFIG_TEST").is_none() { - let output = std::process::Command::new(std::env::current_exe().unwrap()) - .args(["--exact", std::thread::current().name().unwrap(), "--nocapture"]) - .env("VAULTWARDEN_ISOLATED_CONFIG_TEST", "1") - .env("DATABASE_URL", "sqlite://:memory:") - .output() - .unwrap(); - assert!( - output.status.success() && String::from_utf8_lossy(&output.stdout).contains("1 passed; 0 failed"), - "{}\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - return; - } - - let user = |private_key: Option<&str>, security_version: Option| { - let mut user = User::new("user@example.com", None); - user.private_key = private_key.map(String::from); - user.security_version = security_version; - user - }; - - assert!(user(Some("7.cose"), Some(2)).has_v2_encryption()); - assert!(!user(Some("7.cose"), Some(1)).has_v2_encryption()); - assert!(!user(Some("7.cose"), None).has_v2_encryption()); - assert!(!user(Some("2.iv|data|mac"), Some(2)).has_v2_encryption()); - assert!(!user(Some("7"), Some(2)).has_v2_encryption()); - assert!(!user(None, Some(2)).has_v2_encryption()); + }} } } diff --git a/src/db/models/user_signature_key_pair.rs b/src/db/models/user_signature_key_pair.rs deleted file mode 100644 index 72509761..00000000 --- a/src/db/models/user_signature_key_pair.rs +++ /dev/null @@ -1,132 +0,0 @@ -use chrono::{NaiveDateTime, Utc}; -use derive_more::{AsRef, Deref, Display, From}; -use diesel::prelude::*; -use serde_json::Value; - -use crate::{ - api::EmptyResult, - db::{DbConn, schema::user_signature_key_pairs}, - error::MapResult, - util::get_uuid, -}; - -use super::UserId; - -/// A user's signature key pair, part of the v2 state, in its own table like upstream. -/// -/// Ref: -#[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)] -#[diesel(table_name = user_signature_key_pairs)] -#[diesel(treat_none_as_null = true)] -#[diesel(primary_key(uuid))] -pub struct UserSignatureKeyPair { - pub uuid: UserSignatureKeyPairId, - pub user_uuid: UserId, - - pub signature_algorithm: i32, - /// The signing (private) key, wrapped by the user key. - pub signing_key: String, - /// The COSE-encoded public verifying key. - pub verifying_key: String, - - pub created_at: NaiveDateTime, - pub updated_at: NaiveDateTime, -} - -/// Ref: -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum SignatureAlgorithm { - Ed25519 = 0, - MlDsa44 = 1, -} - -impl SignatureAlgorithm { - pub fn parse(algorithm: &str) -> Option { - match algorithm { - "ed25519" => Some(Self::Ed25519), - "mldsa44" => Some(Self::MlDsa44), - _ => None, - } - } -} - -/// Local methods -impl UserSignatureKeyPair { - pub fn new( - user_uuid: UserId, - signature_algorithm: SignatureAlgorithm, - signing_key: String, - verifying_key: String, - ) -> Self { - let now = Utc::now().naive_utc(); - - Self { - uuid: UserSignatureKeyPairId(get_uuid()), - user_uuid, - signature_algorithm: signature_algorithm as i32, - signing_key, - verifying_key, - created_at: now, - updated_at: now, - } - } - - pub fn to_json(&self) -> Value { - json!({ - "wrappedSigningKey": self.signing_key, - "verifyingKey": self.verifying_key, - "object": "signatureKeyPair", - }) - } -} - -/// Database methods -impl UserSignatureKeyPair { - pub async fn save(&mut self, conn: &DbConn) -> EmptyResult { - self.updated_at = Utc::now().naive_utc(); - - db_run! { conn: - mysql { - diesel::insert_into(user_signature_key_pairs::table) - .values(&*self) - .on_conflict(diesel::dsl::DuplicatedKeys) - .do_update() - .set(&*self) - .execute(conn) - .map_res("Error saving user signature key pair") - } - postgresql, sqlite { - diesel::insert_into(user_signature_key_pairs::table) - .values(&*self) - .on_conflict(user_signature_key_pairs::user_uuid) - .do_update() - .set(&*self) - .execute(conn) - .map_res("Error saving user signature key pair") - } - } - } - - /// The user's key pair. There is at most one, enforced by a unique index on `user_uuid`. - pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Option { - conn.run(move |conn| { - user_signature_key_pairs::table - .filter(user_signature_key_pairs::user_uuid.eq(user_uuid)) - .first::(conn) - .ok() - }) - .await - } - - pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { - conn.run(move |conn| { - diesel::delete(user_signature_key_pairs::table.filter(user_signature_key_pairs::user_uuid.eq(user_uuid))) - .execute(conn) - .map_res("Error deleting user signature key pairs") - }) - .await - } -} - -#[derive(Clone, Debug, AsRef, Deref, DieselNewType, Display, From, Hash, PartialEq, Eq, Serialize, Deserialize)] -pub struct UserSignatureKeyPairId(String); diff --git a/src/db/query_logger.rs b/src/db/query_logger.rs index 89a1f3b5..0a207918 100644 --- a/src/db/query_logger.rs +++ b/src/db/query_logger.rs @@ -1,6 +1,5 @@ -use std::{cell::RefCell, collections::HashMap, time::Instant}; - use diesel::connection::{Instrumentation, InstrumentationEvent}; +use std::{cell::RefCell, collections::HashMap, time::Instant}; thread_local! { static QUERY_PERF_TRACKER: RefCell> = RefCell::new(HashMap::new()); @@ -12,7 +11,7 @@ pub fn simple_logger() -> Option> { url, .. } => { - debug!("Establishing connection: {url}"); + debug!("Establishing connection: {url}") } InstrumentationEvent::FinishEstablishConnection { url, @@ -20,9 +19,9 @@ pub fn simple_logger() -> Option> { .. } => { if let Some(e) = error { - error!("Error during establishing a connection with {url}: {e:?}"); + error!("Error during establishing a connection with {url}: {e:?}") } else { - debug!("Connection established: {url}"); + debug!("Connection established: {url}") } } InstrumentationEvent::StartQuery { @@ -48,7 +47,7 @@ pub fn simple_logger() -> Option> { } else if duration.as_secs() >= 1 { info!("SLOW QUERY [{:.2}s]: {}", duration.as_secs_f32(), query_string); } else { - debug!("QUERY [{duration:?}]: {query_string}"); + debug!("QUERY [{:?}]: {}", duration, query_string); } } }); diff --git a/src/db/schema.rs b/src/db/schema.rs index 98792674..914b4fe9 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -116,7 +116,6 @@ table! { atype -> Integer, enabled -> Bool, data -> Text, - revision_date -> Timestamp, } } @@ -218,23 +217,6 @@ table! { api_key -> Nullable, avatar_color -> Nullable, external_id -> Nullable, - key_id -> Nullable, - signed_public_key -> Nullable, - security_state -> Nullable, - security_version -> Nullable, - v2_upgrade_token -> Nullable, - } -} - -table! { - user_signature_key_pairs (uuid) { - uuid -> Text, - user_uuid -> Text, - signature_algorithm -> Integer, - signing_key -> Text, - verifying_key -> Text, - created_at -> Timestamp, - updated_at -> Timestamp, } } @@ -260,16 +242,6 @@ table! { atype -> Integer, reset_password_key -> Nullable, external_id -> Nullable, - manage_users -> Bool, - manage_groups -> Bool, - manage_policies -> Bool, - create_new_collections -> Bool, - edit_any_collection -> Bool, - delete_any_collection -> Bool, - access_event_logs -> Bool, - access_import_export -> Bool, - access_reports -> Bool, - v2_upgrade_token -> Nullable, } } @@ -290,11 +262,9 @@ table! { nonce -> Text, redirect_uri -> Text, code_response -> Nullable, - code_response_error -> Nullable, auth_response -> Nullable, created_at -> Timestamp, updated_at -> Timestamp, - binding_hash -> Nullable, } } @@ -371,16 +341,6 @@ table! { } } -table! { - archives (user_uuid, cipher_uuid) { - user_uuid -> Text, - cipher_uuid -> Text, - archived_at -> Timestamp, - } -} - -joinable!(archives -> users (user_uuid)); -joinable!(archives -> ciphers (cipher_uuid)); joinable!(attachments -> ciphers (cipher_uuid)); joinable!(ciphers -> organizations (organization_uuid)); joinable!(ciphers -> users (user_uuid)); @@ -410,10 +370,8 @@ joinable!(collections_groups -> groups (groups_uuid)); joinable!(event -> users_organizations (uuid)); joinable!(auth_requests -> users (user_uuid)); joinable!(sso_users -> users (user_uuid)); -joinable!(user_signature_key_pairs -> users (user_uuid)); allow_tables_to_appear_in_same_query!( - archives, attachments, ciphers, ciphers_collections, @@ -437,5 +395,4 @@ allow_tables_to_appear_in_same_query!( collections_groups, event, auth_requests, - user_signature_key_pairs, ); diff --git a/src/error.rs b/src/error.rs index 3fee3dc4..1a258fd1 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,11 +1,10 @@ // // Error generator macro // -use std::error::Error as StdError; - use crate::db::models::EventType; use crate::http_client::CustomHttpClientError; use serde::ser::{Serialize, SerializeStruct, Serializer}; +use std::error::Error as StdError; macro_rules! make_error { ( $( $name:ident ( $ty:ty ): $src_fn:expr, $usr_msg_fun:expr ),+ $(,)? ) => { @@ -15,24 +14,24 @@ macro_rules! make_error { #[derive(Debug)] pub struct ErrorEvent { pub event: EventType } - pub struct Error { message: String, kind: ErrorKind, code: u16, event: Option, silent: bool } + pub struct Error { message: String, error: ErrorKind, error_code: u16, event: Option } $(impl From<$ty> for Error { fn from(err: $ty) -> Self { Error::from((stringify!($name), err)) } })+ $(impl> From<(S, $ty)> for Error { fn from(val: (S, $ty)) -> Self { - Error { message: val.0.into(), kind: ErrorKind::$name(val.1), code: BAD_REQUEST, event: None, silent: false } + Error { message: val.0.into(), error: ErrorKind::$name(val.1), error_code: BAD_REQUEST, event: None } } })+ impl StdError for Error { fn source(&self) -> Option<&(dyn StdError + 'static)> { - match &self.kind {$( ErrorKind::$name(e) => $src_fn(e), )+} + match &self.error {$( ErrorKind::$name(e) => $src_fn(e), )+} } } impl std::fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match &self.kind {$( + match &self.error {$( ErrorKind::$name(e) => f.write_str(&$usr_msg_fun(e, &self.message)), )+} } @@ -40,10 +39,10 @@ macro_rules! make_error { }; } -use diesel::ConnectionError as DieselConErr; use diesel::r2d2::Error as R2d2Err; use diesel::r2d2::PoolError as R2d2PoolErr; use diesel::result::Error as DieselErr; +use diesel::ConnectionError as DieselConErr; use handlebars::RenderError as HbErr; use jsonwebtoken::errors::Error as JwtErr; use lettre::address::AddressError as AddrErr; @@ -58,7 +57,7 @@ use serde_json::{Error as SerdeErr, Value}; use std::io::Error as IoErr; use std::time::SystemTimeError as TimeErr; use webauthn_rs::prelude::WebauthnError as WebauthnErr; -use yubico_ng::error::YubicoError as YubiErr; +use yubico::yubicoerror::YubicoError as YubiErr; #[derive(Serialize)] pub struct Empty {} @@ -72,46 +71,46 @@ pub struct Compact {} // The second one contains the function used to obtain the response sent to the client make_error! { // Just an empty error - Empty(Empty): no_source, serialize, + Empty(Empty): _no_source, _serialize, // Used to represent err! calls - Simple(String): no_source, api_error, - Compact(Compact): no_source, compact_api_error, + Simple(String): _no_source, _api_error, + Compact(Compact): _no_source, _compact_api_error, // Used in our custom http client to handle non-global IPs and blocked domains - CustomHttpClient(CustomHttpClientError): has_source, api_error, + CustomHttpClient(CustomHttpClientError): _has_source, _api_error, // Used for special return values, like 2FA errors - Json(Value): no_source, serialize, - Db(DieselErr): has_source, api_error, - R2d2(R2d2Err): has_source, api_error, - R2d2Pool(R2d2PoolErr): has_source, api_error, - Serde(SerdeErr): has_source, api_error, - JWt(JwtErr): has_source, api_error, - Handlebars(HbErr): has_source, api_error, + Json(Value): _no_source, _serialize, + Db(DieselErr): _has_source, _api_error, + R2d2(R2d2Err): _has_source, _api_error, + R2d2Pool(R2d2PoolErr): _has_source, _api_error, + Serde(SerdeErr): _has_source, _api_error, + JWt(JwtErr): _has_source, _api_error, + Handlebars(HbErr): _has_source, _api_error, - Io(IoErr): has_source, api_error, - Time(TimeErr): has_source, api_error, - Req(ReqErr): has_source, api_error, - Regex(RegexErr): has_source, api_error, - Yubico(YubiErr): has_source, api_error, + Io(IoErr): _has_source, _api_error, + Time(TimeErr): _has_source, _api_error, + Req(ReqErr): _has_source, _api_error, + Regex(RegexErr): _has_source, _api_error, + Yubico(YubiErr): _has_source, _api_error, - Lettre(LettreErr): has_source, api_error, - Address(AddrErr): has_source, api_error, - Smtp(SmtpErr): has_source, api_error, - OpenSSL(SSLErr): has_source, api_error, - Rocket(RocketErr): has_source, api_error, + Lettre(LettreErr): _has_source, _api_error, + Address(AddrErr): _has_source, _api_error, + Smtp(SmtpErr): _has_source, _api_error, + OpenSSL(SSLErr): _has_source, _api_error, + Rocket(RocketErr): _has_source, _api_error, - DieselCon(DieselConErr): has_source, api_error, - Webauthn(WebauthnErr): has_source, api_error, + DieselCon(DieselConErr): _has_source, _api_error, + Webauthn(WebauthnErr): _has_source, _api_error, - OpenDAL(OpenDALErr): has_source, api_error, + OpenDAL(OpenDALErr): _has_source, _api_error, } impl std::fmt::Debug for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self.source() { Some(e) => write!(f, "{}.\n[CAUSE] {:#?}", self.message, e), - None => match self.kind { + None => match self.error { ErrorKind::Empty(_) => Ok(()), ErrorKind::Simple(ref s) => { if &self.message == s { @@ -136,7 +135,6 @@ impl Error { (usr_msg.clone(), usr_msg.into()).into() } - #[must_use] pub fn empty() -> Self { Empty {}.into() } @@ -149,13 +147,13 @@ impl Error { #[must_use] pub fn with_kind(mut self, kind: ErrorKind) -> Self { - self.kind = kind; + self.error = kind; self } #[must_use] pub const fn with_code(mut self, code: u16) -> Self { - self.code = code; + self.error_code = code; self } @@ -172,18 +170,6 @@ impl Error { pub fn message(&self) -> &str { &self.message } - - #[must_use] - pub fn silent(mut self) -> Self { - self.silent = true; - self - } - - #[must_use] - pub fn with_silent(mut self, silent: bool) -> Self { - self.silent = silent; - self - } } pub trait MapResult { @@ -208,14 +194,14 @@ impl MapResult for Option { } } -const fn has_source(e: T) -> Option { +const fn _has_source(e: T) -> Option { Some(e) } -fn no_source(_: T) -> Option { +fn _no_source(_: T) -> Option { None } -fn serialize(e: &impl Serialize, _msg: &str) -> String { +fn _serialize(e: &impl Serialize, _msg: &str) -> String { serde_json::to_string(e).unwrap() } @@ -294,14 +280,14 @@ struct ApiErrorResponse<'a>(ApiErrorMsg<'a>); /// The custom serialization adds all other needed fields struct CompactApiErrorResponse<'a>(ApiErrorMsg<'a>); -fn api_error(_: &impl std::any::Any, msg: &str) -> String { +fn _api_error(_: &impl std::any::Any, msg: &str) -> String { let response = ApiErrorMsg { message: msg, }; serde_json::to_string(&ApiErrorResponse(response)).unwrap() } -fn compact_api_error(_: &impl std::any::Any, msg: &str) -> String { +fn _compact_api_error(_: &impl std::any::Any, msg: &str) -> String { let response = ApiErrorMsg { message: msg, }; @@ -313,22 +299,18 @@ fn compact_api_error(_: &impl std::any::Any, msg: &str) -> String { // use std::io::Cursor; -use rocket::{ - http::{ContentType, Status}, - request::Request, - response::{self, Responder, Response}, -}; +use rocket::http::{ContentType, Status}; +use rocket::request::Request; +use rocket::response::{self, Responder, Response}; impl Responder<'_, 'static> for Error { fn respond_to(self, _: &Request<'_>) -> response::Result<'static> { - if !self.silent { - match self.kind { - ErrorKind::Empty(_) | ErrorKind::Simple(_) | ErrorKind::Compact(_) => {} // Don't print the error in this situation - _ => error!(target: "error", "{self:#?}"), - } - } + match self.error { + ErrorKind::Empty(_) | ErrorKind::Simple(_) | ErrorKind::Compact(_) => {} // Don't print the error in this situation + _ => error!(target: "error", "{self:#?}"), + }; - let code = Status::from_code(self.code).unwrap_or(Status::BadRequest); + let code = Status::from_code(self.error_code).unwrap_or(Status::BadRequest); let body = self.to_string(); Response::build().status(code).header(ContentType::JSON).sized_body(Some(body.len()), Cursor::new(body)).ok() } @@ -425,12 +407,12 @@ macro_rules! err_json { macro_rules! err_handler { ($expr:expr) => {{ error!(target: "auth", "Unauthorized Error: {}", $expr); - return ::rocket::request::Outcome::Error((::rocket::http::Status::Unauthorized, $expr)); + return ::rocket::request::Outcome::Error((rocket::http::Status::Unauthorized, $expr)); }}; ($usr_msg:expr, $log_value:expr) => {{ let usr_msg = $usr_msg; let log_value = $log_value; error!(target: "auth", "Unauthorized Error: {usr_msg}. {log_value}"); - return ::rocket::request::Outcome::Error((::rocket::http::Status::Unauthorized, usr_msg)); + return ::rocket::request::Outcome::Error((rocket::http::Status::Unauthorized, usr_msg)); }}; } diff --git a/src/http_client.rs b/src/http_client.rs index 6225e233..5462ef8e 100644 --- a/src/http_client.rs +++ b/src/http_client.rs @@ -1,28 +1,22 @@ use std::{ fmt, net::{IpAddr, SocketAddr}, + str::FromStr, sync::{Arc, LazyLock, Mutex}, time::Duration, }; -use hickory_resolver::{TokioResolver, net::runtime::TokioRuntimeProvider}; +use hickory_resolver::{name_server::TokioConnectionProvider, TokioResolver}; use regex::Regex; use reqwest::{ - Client, ClientBuilder, dns::{Name, Resolve, Resolving}, - header, + header, Client, ClientBuilder, }; use url::Host; -use crate::{ - CONFIG, - util::{get_env_bool, is_global}, -}; +use crate::{util::is_global, CONFIG}; pub fn make_http_request(method: reqwest::Method, url: &str) -> Result { - static INSTANCE: LazyLock = - LazyLock::new(|| get_reqwest_client_builder(true).build().expect("Failed to build client")); - let Ok(url) = url::Url::parse(url) else { err!("Invalid URL"); }; @@ -32,14 +26,17 @@ pub fn make_http_request(method: reqwest::Method, url: &str) -> Result = + LazyLock::new(|| get_reqwest_client_builder().build().expect("Failed to build client")); + Ok(INSTANCE.request(method, url)) } -pub fn get_reqwest_client_builder(enforce_block: bool) -> ClientBuilder { +pub fn get_reqwest_client_builder() -> ClientBuilder { let mut headers = header::HeaderMap::new(); headers.insert(header::USER_AGENT, header::HeaderValue::from_static("Vaultwarden")); - let redirect_policy = reqwest::redirect::Policy::custom(move |attempt| { + let redirect_policy = reqwest::redirect::Policy::custom(|attempt| { if attempt.previous().len() >= 5 { return attempt.error("Too many redirects"); } @@ -48,7 +45,7 @@ pub fn get_reqwest_client_builder(enforce_block: bool) -> ClientBuilder { return attempt.error("Invalid host"); }; - if enforce_block && let Err(e) = should_block_host(&host) { + if let Err(e) = should_block_host(&host) { return attempt.error(e); } @@ -58,16 +55,18 @@ pub fn get_reqwest_client_builder(enforce_block: bool) -> ClientBuilder { Client::builder() .default_headers(headers) .redirect(redirect_policy) - .dns_resolver(CustomDns::instance(enforce_block)) + .dns_resolver(CustomDnsResolver::instance()) .timeout(Duration::from_secs(10)) } -fn dns_prefer_ipv6() -> bool { - // CONFIG may require DNS to initialize, so avoid forcing it during bootstrap. - match LazyLock::get(&CONFIG) { - Some(config) => config.dns_prefer_ipv6(), - None => get_env_bool("DNS_PREFER_IPV6").unwrap_or(false), +pub fn should_block_address(domain_or_ip: &str) -> bool { + if let Ok(ip) = IpAddr::from_str(domain_or_ip) { + if should_block_ip(ip) { + return true; + } } + + should_block_address_regex(domain_or_ip) } fn should_block_ip(ip: IpAddr) -> bool { @@ -79,19 +78,18 @@ fn should_block_ip(ip: IpAddr) -> bool { } fn should_block_address_regex(domain_or_ip: &str) -> bool { - static COMPILED_REGEX: Mutex> = Mutex::new(None); - let Some(block_regex) = CONFIG.http_request_block_regex() else { return false; }; + static COMPILED_REGEX: Mutex> = Mutex::new(None); let mut guard = COMPILED_REGEX.lock().unwrap(); // If the stored regex is up to date, use it - if let Some((value, regex)) = &*guard - && value == &block_regex - { - return regex.is_match(domain_or_ip); + if let Some((value, regex)) = &*guard { + if value == &block_regex { + return regex.is_match(domain_or_ip); + } } // If we don't have a regex stored, or it's not up to date, recreate it @@ -102,63 +100,20 @@ fn should_block_address_regex(domain_or_ip: &str) -> bool { is_match } -pub fn get_valid_host(host: &str) -> Result { - let Ok(host) = Host::parse(host) else { - return Err(CustomHttpClientError::Invalid { - domain: host.to_owned(), - }); - }; - - // Some extra checks to validate hosts - match host { - Host::Domain(ref domain) => { - // Host::parse() does not verify length or all possible invalid characters - // We do some extra checks here to prevent issues - if domain.len() > 253 { - debug!("Domain validation error: '{domain}' exceeds 253 characters"); - return Err(CustomHttpClientError::Invalid { - domain: host.to_string(), - }); - } - if !domain.split('.').all(|label| { - !label.is_empty() - // Labels can't be longer than 63 chars - && label.len() <= 63 - // Labels are not allowed to start or end with a hyphen `-` - && !label.starts_with('-') - && !label.ends_with('-') - // Only ASCII Alphanumeric characters are allowed - // We already received a punycoded domain back, so no unicode should exists here - && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') - }) { - debug!( - "Domain validation error: '{domain}' labels contain invalid characters or exceed the maximum length" - ); - return Err(CustomHttpClientError::Invalid { - domain: host.to_string(), - }); - } - } - Host::Ipv4(_) | Host::Ipv6(_) => {} - } - - Ok(host) -} - -pub fn should_block_host>(host: &Host) -> Result<(), CustomHttpClientError> { +fn should_block_host(host: &Host<&str>) -> Result<(), CustomHttpClientError> { let (ip, host_str): (Option, String) = match host { Host::Ipv4(ip) => (Some(IpAddr::V4(*ip)), ip.to_string()), Host::Ipv6(ip) => (Some(IpAddr::V6(*ip)), ip.to_string()), - Host::Domain(d) => (None, d.as_ref().to_owned()), + Host::Domain(d) => (None, (*d).to_string()), }; - if let Some(ip) = ip - && should_block_ip(ip) - { - return Err(CustomHttpClientError::NonGlobalIp { - domain: None, - ip, - }); + if let Some(ip) = ip { + if should_block_ip(ip) { + return Err(CustomHttpClientError::NonGlobalIp { + domain: None, + ip, + }); + } } if should_block_address_regex(&host_str) { @@ -179,33 +134,9 @@ pub enum CustomHttpClientError { domain: Option, ip: IpAddr, }, - Invalid { - domain: String, - }, } impl CustomHttpClientError { - /// Attach the domain that resolved to this address, which `should_block_host()` can't know. - fn with_domain(self, name: &str) -> Self { - match self { - Self::NonGlobalIp { - ip, - .. - } => Self::NonGlobalIp { - domain: Some(name.to_owned()), - ip, - }, - Self::Blocked { - domain, - } => Self::Blocked { - domain: format!("{name} ({domain})"), - }, - other @ Self::Invalid { - .. - } => other, - } - } - pub fn downcast_ref(e: &dyn std::error::Error) -> Option<&Self> { let mut source = e.source(); @@ -224,7 +155,7 @@ impl fmt::Display for CustomHttpClientError { match self { Self::Blocked { domain, - } => write!(f, "Blocked domain: '{domain}' matched HTTP_REQUEST_BLOCK_REGEX"), + } => write!(f, "Blocked domain: {domain} matched HTTP_REQUEST_BLOCK_REGEX"), Self::NonGlobalIp { domain: Some(domain), ip, @@ -232,21 +163,13 @@ impl fmt::Display for CustomHttpClientError { Self::NonGlobalIp { domain: None, ip, - } => write!(f, "IP '{ip}' is not a global IP!"), - Self::Invalid { - domain, - } => write!(f, "Invalid host: '{domain}' contains invalid characters or exceeds the maximum length"), + } => write!(f, "IP {ip} is not a global IP!"), } } } impl std::error::Error for CustomHttpClientError {} -pub struct CustomDns { - enforce_block: bool, - resolver: Arc, -} - #[derive(Debug, Clone)] enum CustomDnsResolver { Default(), @@ -254,69 +177,49 @@ enum CustomDnsResolver { } type BoxError = Box; -impl CustomDns { - fn instance(enforce_block: bool) -> Self { - static INSTANCE: LazyLock> = LazyLock::new(CustomDnsResolver::new); - - CustomDns { - enforce_block, - resolver: Arc::clone(&*INSTANCE), - } - } -} - impl CustomDnsResolver { + fn instance() -> Arc { + static INSTANCE: LazyLock> = LazyLock::new(CustomDnsResolver::new); + Arc::clone(&*INSTANCE) + } + fn new() -> Arc { - TokioResolver::builder(TokioRuntimeProvider::default()) - .and_then(|mut builder| { - // Query both families; the preferred order is applied per lookup below. - builder.options_mut().ip_strategy = hickory_resolver::config::LookupIpStrategy::Ipv4AndIpv6; - builder.build() - }) - .inspect_err(|e| warn!("Error creating Hickory resolver, falling back to default: {e:?}")) - .map_or_else(|_| Arc::new(Self::Default()), |resolver| Arc::new(Self::Hickory(Arc::new(resolver)))) + match TokioResolver::builder(TokioConnectionProvider::default()) { + Ok(mut builder) => { + if CONFIG.dns_prefer_ipv6() { + builder.options_mut().ip_strategy = hickory_resolver::config::LookupIpStrategy::Ipv6thenIpv4; + } + let resolver = builder.build(); + Arc::new(Self::Hickory(Arc::new(resolver))) + } + Err(e) => { + warn!("Error creating Hickory resolver, falling back to default: {e:?}"); + Arc::new(Self::Default()) + } + } } // Note that we get an iterator of addresses, but we only grab the first one for convenience - async fn resolve_domain(&self, name: &str, enforce_block: bool) -> Result, BoxError> { - pre_resolve(name, enforce_block)?; + async fn resolve_domain(&self, name: &str) -> Result, BoxError> { + pre_resolve(name)?; - let results: Vec = match self { - Self::Default() => tokio::net::lookup_host((name, 0)).await?.collect(), - Self::Hickory(r) => r.lookup_ip(name).await?.iter().map(|i| SocketAddr::new(i, 0)).collect(), + let result = match self { + Self::Default() => tokio::net::lookup_host(name).await?.next(), + Self::Hickory(r) => r.lookup_ip(name).await?.iter().next().map(|a| SocketAddr::new(a, 0)), }; - if enforce_block { - for addr in &results { - post_resolve(name, addr.ip())?; - } + if let Some(addr) = &result { + post_resolve(name, addr.ip())?; } - Ok(results) + Ok(result) } } -fn sort_addresses(addresses: &mut [SocketAddr], prefer_ipv6: bool) { - // `sort_by_key` orders `false` before `true`. - // When IPv6 is preferred, IPv6 addresses return `false` for `is_ipv4()` and sort first. - // When IPv4 is preferred, IPv4 addresses return `false` for `is_ipv6()` and sort first. - if prefer_ipv6 { - addresses.sort_by_key(SocketAddr::is_ipv4); - } else { - addresses.sort_by_key(SocketAddr::is_ipv6); - } -} - -fn pre_resolve(name: &str, enforce_block: bool) -> Result<(), CustomHttpClientError> { - let Ok(host) = get_valid_host(name) else { - return Err(CustomHttpClientError::Invalid { - domain: name.to_owned(), - }); - }; - - if enforce_block && should_block_host(&host).is_err() { +fn pre_resolve(name: &str) -> Result<(), CustomHttpClientError> { + if should_block_address(name) { return Err(CustomHttpClientError::Blocked { - domain: name.to_owned(), + domain: name.to_string(), }); } @@ -324,27 +227,23 @@ fn pre_resolve(name: &str, enforce_block: bool) -> Result<(), CustomHttpClientEr } fn post_resolve(name: &str, ip: IpAddr) -> Result<(), CustomHttpClientError> { - let host: Host<&str> = match ip { - IpAddr::V4(ip) => Host::Ipv4(ip), - IpAddr::V6(ip) => Host::Ipv6(ip), - }; - - should_block_host(&host).map_err(|e| e.with_domain(name)) + if should_block_ip(ip) { + Err(CustomHttpClientError::NonGlobalIp { + domain: Some(name.to_string()), + ip, + }) + } else { + Ok(()) + } } -impl Resolve for CustomDns { +impl Resolve for CustomDnsResolver { fn resolve(&self, name: Name) -> Resolving { - let enforce_block = self.enforce_block; - let this = Arc::clone(&self.resolver); + let this = self.clone(); Box::pin(async move { let name = name.as_str(); - let mut results = this.resolve_domain(name, enforce_block).await?; - // Recheck after bootstrap so long-lived clients adopt the loaded config. - sort_addresses(&mut results, dns_prefer_ipv6()); - if results.is_empty() { - warn!("Unable to resolve {name} to any valid IP address"); - } - Ok::(Box::new(results.into_iter())) + let result = this.resolve_domain(name).await?; + Ok::(Box::new(result.into_iter())) }) } } @@ -359,29 +258,10 @@ pub(crate) mod aws { }; use reqwest::Client; - use super::get_reqwest_client_builder; - // Adapter that wraps reqwest to be compatible with the AWS SDK #[derive(Debug)] pub(crate) struct AwsReqwestConnector { - client: Client, - } - - impl AwsReqwestConnector { - pub(crate) fn new() -> Self { - let client = get_reqwest_client_builder(false).build().expect("Failed to build AWS HTTP client"); - Self { - client, - } - } - } - - fn connector_error(error: reqwest::Error) -> ConnectorError { - if error.is_timeout() { - ConnectorError::timeout(Box::new(error)) - } else { - ConnectorError::io(Box::new(error)) - } + pub(crate) client: Client, } impl HttpConnector for AwsReqwestConnector { @@ -391,7 +271,7 @@ pub(crate) mod aws { let future = async move { let method = reqwest::Method::from_bytes(request.method().as_bytes()) .map_err(|e| ConnectorError::user(Box::new(e)))?; - let mut req_builder = client.request(method, request.uri().to_owned()); + let mut req_builder = client.request(method, request.uri().to_string()); for (name, value) in request.headers() { req_builder = req_builder.header(name, value); @@ -401,10 +281,10 @@ pub(crate) mod aws { req_builder = req_builder.body(body_bytes.to_vec()); } - let response = req_builder.send().await.map_err(connector_error)?; + let response = req_builder.send().await.map_err(|e| ConnectorError::io(Box::new(e)))?; let status = response.status().into(); - let bytes = response.bytes().await.map_err(connector_error)?; + let bytes = response.bytes().await.map_err(|e| ConnectorError::io(Box::new(e)))?; Ok(HttpResponse::new(status, bytes.into())) }; @@ -425,229 +305,3 @@ pub(crate) mod aws { } } } - -#[cfg(test)] -mod tests { - use super::*; - use crate::util::is_global_hardcoded; - use std::net::{Ipv4Addr, Ipv6Addr}; - use url::Host; - - // === - // IPv4 numeric-format normalization - fn parse_to_ip(s: &str) -> Option { - match Host::parse(s).ok()? { - Host::Ipv4(v4) => Some(IpAddr::V4(v4)), - Host::Ipv6(v6) => Some(IpAddr::V6(v6)), - Host::Domain(_) => None, - } - } - - #[test] - fn dns_setup_does_not_initialize_config() { - assert!(LazyLock::get(&CONFIG).is_none()); - drop(CustomDns::instance(false)); - assert!(LazyLock::get(&CONFIG).is_none()); - } - - #[test] - fn dns_preference_orders_addresses() { - let ipv4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0); - let ipv6 = SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 0); - let mut addresses = [ipv6, ipv4]; - - sort_addresses(&mut addresses, false); - assert_eq!(addresses, [ipv4, ipv6]); - - sort_addresses(&mut addresses, true); - assert_eq!(addresses, [ipv6, ipv4]); - } - - #[test] - fn dotted_decimal_loopback_normalizes() { - let ip = parse_to_ip("127.0.0.1").unwrap(); - assert_eq!(ip, IpAddr::V4(Ipv4Addr::LOCALHOST)); - assert!(!is_global_hardcoded(ip)); - } - - #[test] - fn single_decimal_loopback_normalizes() { - // 127.0.0.1 == 2130706433 - let ip = parse_to_ip("2130706433").unwrap(); - assert_eq!(ip, IpAddr::V4(Ipv4Addr::LOCALHOST)); - assert!(!is_global_hardcoded(ip)); - } - - #[test] - fn hex_loopback_normalizes() { - let ip = parse_to_ip("0x7f000001").unwrap(); - assert_eq!(ip, IpAddr::V4(Ipv4Addr::LOCALHOST)); - assert!(!is_global_hardcoded(ip)); - } - - #[test] - fn dotted_hex_loopback_normalizes() { - let ip = parse_to_ip("0x7f.0.0.1").unwrap(); - assert_eq!(ip, IpAddr::V4(Ipv4Addr::LOCALHOST)); - assert!(!is_global_hardcoded(ip)); - } - - #[test] - fn octal_loopback_normalizes() { - // 017700000001 == 127.0.0.1 - let ip = parse_to_ip("017700000001").unwrap(); - assert_eq!(ip, IpAddr::V4(Ipv4Addr::LOCALHOST)); - assert!(!is_global_hardcoded(ip)); - } - - #[test] - fn dotted_octal_loopback_normalizes() { - let ip = parse_to_ip("0177.0.0.01").unwrap(); - assert_eq!(ip, IpAddr::V4(Ipv4Addr::LOCALHOST)); - assert!(!is_global_hardcoded(ip)); - } - - #[test] - fn aws_metadata_decimal_blocked() { - // 169.254.169.254 == 2852039166 (link-local, AWS IMDS) - let ip = parse_to_ip("2852039166").unwrap(); - assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254))); - assert!(!is_global_hardcoded(ip)); - } - - #[test] - fn rfc1918_hex_blocked() { - // 10.0.0.1 - let ip = parse_to_ip("0x0a000001").unwrap(); - assert!(!is_global_hardcoded(ip)); - } - - #[test] - fn public_ip_decimal_allowed() { - // 8.8.8.8 == 134744072 - let ip = parse_to_ip("134744072").unwrap(); - assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))); - assert!(is_global_hardcoded(ip)); - } - - // === - // get_valid_host integration: numeric forms become Host::Ipv4 - #[test] - fn get_valid_host_normalizes_decimal_int() { - let h = get_valid_host("2130706433").expect("valid"); - assert!(matches!(h, Host::Ipv4(ip) if ip == Ipv4Addr::LOCALHOST)); - } - - #[test] - fn get_valid_host_normalizes_hex() { - let h = get_valid_host("0x7f000001").expect("valid"); - assert!(matches!(h, Host::Ipv4(ip) if ip == Ipv4Addr::LOCALHOST)); - } - - #[test] - fn get_valid_host_normalizes_octal() { - let h = get_valid_host("017700000001").expect("valid"); - assert!(matches!(h, Host::Ipv4(ip) if ip == Ipv4Addr::LOCALHOST)); - } - - // === - // IPv6 formats - #[test] - fn ipv6_loopback_blocked() { - let h = get_valid_host("[::1]").expect("valid"); - let Host::Ipv6(ip) = h else { - panic!("expected v6") - }; - assert!(!is_global_hardcoded(IpAddr::V6(ip))); - } - - #[test] - fn ipv4_mapped_in_ipv6_loopback_blocked() { - // ::ffff:127.0.0.1 — v4-mapped form; is_global_hardcoded blocks via ::ffff:0:0/96 - let h = get_valid_host("[::ffff:127.0.0.1]").expect("valid"); - let Host::Ipv6(ip) = h else { - panic!("expected v6") - }; - assert!(!is_global_hardcoded(IpAddr::V6(ip))); - } - - #[test] - fn ipv6_unique_local_blocked() { - let h = get_valid_host("[fc00::1]").expect("valid"); - let Host::Ipv6(ip) = h else { - panic!("expected v6") - }; - assert!(!is_global_hardcoded(IpAddr::V6(ip))); - } - - // === - // Punycode / IDN - #[test] - fn punycode_passthrough() { - let h = get_valid_host("xn--deadbeafcaf-lbb.test").expect("valid"); - match h { - Host::Domain(d) => assert_eq!(d, "xn--deadbeafcaf-lbb.test"), - _ => panic!("expected domain"), - } - } - - #[test] - fn idn_unicode_gets_punycoded() { - let h = get_valid_host("deadbeafcafé.test").expect("valid"); - match h { - Host::Domain(d) => assert_eq!(d, "xn--deadbeafcaf-lbb.test"), - _ => panic!("expected domain"), - } - } - - #[test] - fn idn_unicode_gets_punycoded_tld() { - let h = get_valid_host("deadbeaf.café").expect("valid"); - match h { - Host::Domain(d) => assert_eq!(d, "deadbeaf.xn--caf-dma"), - _ => panic!("expected domain"), - } - } - - #[test] - fn idn_emoji_gets_punycoded() { - let h = get_valid_host("xn--t88h.test").expect("valid"); // 🛡️.test - match h { - Host::Domain(d) => assert_eq!(d, "xn--t88h.test"), - _ => panic!("expected domain"), - } - } - - #[test] - fn idn_unicode_to_punycode_roundtrip() { - let from_unicode = get_valid_host("🛡️.test").expect("valid"); - let from_puny = get_valid_host("xn--t88h.test").expect("valid"); - match (from_unicode, from_puny) { - (Host::Domain(a), Host::Domain(b)) => assert_eq!(a, b), - _ => panic!("expected domains"), - } - } - - #[test] - fn invalid_punycode_rejected() { - // bare invalid punycode - assert!(get_valid_host("xn--").is_err()); - } - - #[test] - fn underscore_in_label_rejected() { - assert!(get_valid_host("dead_beaf.cafe").is_err()); - } - - #[test] - fn label_too_long_rejected() { - let label = "a".repeat(64); - assert!(get_valid_host(&format!("{label}.test")).is_err()); - } - - #[test] - fn domain_too_long_rejected() { - let big = "a.".repeat(130) + "test"; // > 253 - assert!(get_valid_host(&big).is_err()); - } -} diff --git a/src/mail.rs b/src/mail.rs index 14294213..cdbd269a 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -1,17 +1,16 @@ +use chrono::NaiveDateTime; +use percent_encoding::{percent_encode, NON_ALPHANUMERIC}; use std::{env::consts::EXE_SUFFIX, str::FromStr}; -use chrono::NaiveDateTime; use lettre::{ - Address, AsyncSendmailTransport, AsyncSmtpTransport, AsyncTransport, Tokio1Executor, message::{Attachment, Body, Mailbox, Message, MultiPart, SinglePart}, transport::smtp::authentication::{Credentials, Mechanism as SmtpAuthMechanism}, transport::smtp::client::{Tls, TlsParameters}, transport::smtp::extension::ClientId, + Address, AsyncSendmailTransport, AsyncSmtpTransport, AsyncTransport, Tokio1Executor, }; -use percent_encoding::{NON_ALPHANUMERIC, percent_encode}; use crate::{ - CONFIG, api::EmptyResult, auth::{ encode_jwt, generate_delete_claims, generate_emergency_access_invite_claims, generate_invite_claims, @@ -19,7 +18,7 @@ use crate::{ }, db::models::{Device, DeviceType, EmergencyAccessId, MembershipId, OrganizationId, User, UserId}, error::Error, - util::upcase_first, + CONFIG, }; fn sendmail_transport() -> AsyncSendmailTransport { @@ -36,12 +35,10 @@ fn smtp_transport() -> AsyncSmtpTransport { let smtp_client = AsyncSmtpTransport::::builder_dangerous(host.as_str()) .port(CONFIG.smtp_port()) - .timeout(Some(Duration::from_secs(u64::from(CONFIG.smtp_timeout())))); + .timeout(Some(Duration::from_secs(CONFIG.smtp_timeout()))); // Determine security - let smtp_client = if CONFIG.smtp_security() == *"off" { - smtp_client - } else { + let smtp_client = if CONFIG.smtp_security() != *"off" { let mut tls_parameters = TlsParameters::builder(host); if CONFIG.smtp_accept_invalid_hostnames() { tls_parameters = tls_parameters.dangerous_accept_invalid_hostnames(true); @@ -56,6 +53,8 @@ fn smtp_transport() -> AsyncSmtpTransport { } else { smtp_client.tls(Tls::Required(tls_parameters)) } + } else { + smtp_client }; let smtp_client = match (CONFIG.smtp_username(), CONFIG.smtp_password()) { @@ -82,12 +81,12 @@ fn smtp_transport() -> AsyncSmtpTransport { } } - if selected_mechanisms.is_empty() { + if !selected_mechanisms.is_empty() { + smtp_client.authentication(selected_mechanisms) + } else { // Only show a warning, and return without setting an actual authentication mechanism warn!("No valid SMTP Auth mechanism found for '{mechanism}', using default values"); smtp_client - } else { - smtp_client.authentication(selected_mechanisms) } } _ => smtp_client, @@ -130,16 +129,14 @@ fn get_template(template_name: &str, data: &serde_json::Value) -> Result<(String let text = CONFIG.render_template(template_name, data)?; let mut text_split = text.split(""); - let subject = if let Some(s) = text_split.next() { - s.trim().to_owned() - } else { - err!("Template doesn't contain subject") + let subject = match text_split.next() { + Some(s) => s.trim().to_string(), + None => err!("Template doesn't contain subject"), }; - let body = if let Some(s) = text_split.next() { - s.trim().to_owned() - } else { - err!("Template doesn't contain body") + let body = match text_split.next() { + Some(s) => s.trim().to_string(), + None => err!("Template doesn't contain body"), }; if text_split.next().is_some() { @@ -169,7 +166,7 @@ pub async fn send_password_hint(address: &str, hint: Option) -> EmptyRes } pub async fn send_delete_account(address: &str, user_id: &UserId) -> EmptyResult { - let claims = generate_delete_claims(user_id.to_string())?; + let claims = generate_delete_claims(user_id.to_string()); let delete_token = encode_jwt(&claims); let (subject, body_html, body_text) = get_text( @@ -187,7 +184,7 @@ pub async fn send_delete_account(address: &str, user_id: &UserId) -> EmptyResult } pub async fn send_verify_email(address: &str, user_id: &UserId) -> EmptyResult { - let claims = generate_verify_email_claims(user_id)?; + let claims = generate_verify_email_claims(user_id); let verify_email_token = encode_jwt(&claims); let (subject, body_html, body_text) = get_text( @@ -206,9 +203,10 @@ pub async fn send_verify_email(address: &str, user_id: &UserId) -> EmptyResult { pub async fn send_register_verify_email(email: &str, token: &str) -> EmptyResult { let mut query = url::Url::parse("https://query.builder").unwrap(); - query.query_pairs_mut().append_pair("email", email).append_pair("token", token).append_pair("fromEmail", "true"); - let Some(query_string) = query.query() else { - err!("Failed to build verify URL query parameters") + query.query_pairs_mut().append_pair("email", email).append_pair("token", token); + let query_string = match query.query() { + None => err!("Failed to build verify URL query parameters"), + Some(query) => query, }; let (subject, body_html, body_text) = get_text( @@ -237,7 +235,7 @@ pub async fn send_welcome(address: &str) -> EmptyResult { } pub async fn send_welcome_must_verify(address: &str, user_id: &UserId) -> EmptyResult { - let claims = generate_verify_email_claims(user_id)?; + let claims = generate_verify_email_claims(user_id); let verify_email_token = encode_jwt(&claims); let (subject, body_html, body_text) = get_text( @@ -292,7 +290,7 @@ pub async fn send_invite( org_id.clone(), member_id.clone(), invited_by_email, - )?; + ); let invite_token = encode_jwt(&claims); let mut query = url::Url::parse("https://query.builder").unwrap(); { @@ -307,18 +305,9 @@ pub async fn send_invite( if CONFIG.sso_enabled() && CONFIG.sso_only() { query_params.append_pair("orgSsoIdentifier", &org_id); } - - // The web vault requires both of these parameters to be present. - // If either is missing it rejects the invite client-side, before any - // request reaches the server, showing only "Unable to accept invitation". - query_params.append_pair("initOrganization", "false"); - - let org_user_has_existing_user = if user.private_key.is_some() { - "true" - } else { - "false" - }; - query_params.append_pair("orgUserHasExistingUser", org_user_has_existing_user); + if user.private_key.is_some() { + query_params.append_pair("orgUserHasExistingUser", "true"); + } } let Some(query_string) = query.query() else { @@ -351,7 +340,7 @@ pub async fn send_emergency_access_invite( emer_id.clone(), String::from(grantor_name), String::from(grantor_email), - )?; + ); // Build the query here to ensure proper escaping let mut query = url::Url::parse("https://query.builder").unwrap(); @@ -515,6 +504,8 @@ pub async fn send_invite_confirmed(address: &str, org_name: &str) -> EmptyResult } pub async fn send_new_device_logged_in(address: &str, ip: &str, dt: &NaiveDateTime, device: &Device) -> EmptyResult { + use crate::util::upcase_first; + let fmt = "%A, %B %_d, %Y at %r %Z"; let (subject, body_html, body_text) = get_text( "email/new_device_logged_in", @@ -531,21 +522,6 @@ pub async fn send_new_device_logged_in(address: &str, ip: &str, dt: &NaiveDateTi send_email(address, &subject, body_html, body_text).await } -pub async fn send_recover_twofactor(address: &str, ip: &str, dt: &NaiveDateTime) -> EmptyResult { - let fmt = "%A, %B %_d, %Y at %r %Z"; - let (subject, body_html, body_text) = get_text( - "email/recover_twofactor", - json!({ - "url": CONFIG.domain(), - "img_src": CONFIG._smtp_img_src(), - "ip": ip, - "datetime": crate::util::format_naive_datetime_local(dt, fmt), - }), - )?; - - send_email(address, &subject, body_html, body_text).await -} - pub async fn send_incomplete_2fa_login( address: &str, ip: &str, @@ -553,6 +529,8 @@ pub async fn send_incomplete_2fa_login( device_name: &str, device_type: &str, ) -> EmptyResult { + use crate::util::upcase_first; + let fmt = "%A, %B %_d, %Y at %r %Z"; let (subject, body_html, body_text) = get_text( "email/incomplete_2fa_login", @@ -648,24 +626,14 @@ pub async fn send_test(address: &str) -> EmptyResult { send_email(address, &subject, body_html, body_text).await } -pub async fn send_admin_account_recovery( - address: &str, - user_name: &str, - org_name: &str, - reset_password: bool, - reset_2fa: bool, - fallback_2fa_email: bool, -) -> EmptyResult { +pub async fn send_admin_reset_password(address: &str, user_name: &str, org_name: &str) -> EmptyResult { let (subject, body_html, body_text) = get_text( - "email/admin_account_recovery", + "email/admin_reset_password", json!({ "url": CONFIG.domain(), "img_src": CONFIG._smtp_img_src(), "user_name": user_name, "org_name": org_name, - "reset_password": reset_password, - "reset_2fa": reset_2fa, - "fallback_2fa_email": fallback_2fa_email, }), )?; send_email(address, &subject, body_html, body_text).await @@ -687,7 +655,7 @@ pub async fn send_protected_action_token(address: &str, token: &str) -> EmptyRes async fn send_with_selected_transport(email: Message) -> EmptyResult { if CONFIG.use_sendmail() { match sendmail_transport().send(email).await { - Ok(()) => Ok(()), + Ok(_) => Ok(()), // Match some common errors and make them more user friendly Err(e) => { if e.is_client() { @@ -696,9 +664,10 @@ async fn send_with_selected_transport(email: Message) -> EmptyResult { } else if e.is_response() { debug!("Sendmail response error: {e:?}"); err!(format!("Sendmail response error: {e}")); + } else { + debug!("Sendmail error: {e:?}"); + err!(format!("Sendmail error: {e}")); } - debug!("Sendmail error: {e:?}"); - err!(format!("Sendmail error: {e}")); } } } else { @@ -726,9 +695,10 @@ async fn send_with_selected_transport(email: Message) -> EmptyResult { } else if e.is_tls() { debug!("SMTP encryption error: {e:#?}"); err!(format!("SMTP encryption error: {e}")); + } else { + debug!("SMTP error: {e:#?}"); + err!(format!("SMTP error: {e}")); } - debug!("SMTP error: {e:#?}"); - err!(format!("SMTP error: {e}")); } } } @@ -738,8 +708,8 @@ async fn send_email(address: &str, subject: &str, body_html: String, body_text: let smtp_from = Address::from_str(&CONFIG.smtp_from())?; let body = if CONFIG.smtp_embed_images() { - let logo_gray_body = Body::new(crate::api::STATIC_FILES["logo-gray.png"].bytes.to_vec()); - let mail_github_body = Body::new(crate::api::STATIC_FILES["mail-github.png"].bytes.to_vec()); + let logo_gray_body = Body::new(crate::api::static_files("logo-gray.png").unwrap().1.to_vec()); + let mail_github_body = Body::new(crate::api::static_files("mail-github.png").unwrap().1.to_vec()); MultiPart::alternative().singlepart(SinglePart::plain(body_text)).multipart( MultiPart::related() .singlepart(SinglePart::html(body_html)) diff --git a/src/main.rs b/src/main.rs index 76c561f2..60c5a593 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,7 @@ // The recursion_limit is mainly triggered by the json!() macro. // The more key/value pairs there are the more recursion occurs. // We want to keep this as low as possible! -#![recursion_limit = "192"] +#![recursion_limit = "165"] // When enabled use MiMalloc as malloc instead of the default malloc #[cfg(feature = "enable_mimalloc")] @@ -33,7 +33,6 @@ use std::{ path::Path, process::exit, str::FromStr, - sync::{Arc, atomic::Ordering}, thread, }; @@ -45,8 +44,6 @@ use tokio::{ #[cfg(unix)] use tokio::signal::unix::SignalKind; -use rocket::data::{Limits, ToByteUnit}; - #[macro_use] mod error; mod api; @@ -60,19 +57,19 @@ mod mail; mod ratelimit; mod sso; mod sso_client; -mod storage; mod util; -use crate::api::{ - WS_ANONYMOUS_SUBSCRIPTIONS, WS_USERS, core::two_factor::duo_oidc::purge_duo_contexts, purge_auth_requests, -}; -pub use config::{CONFIG, PathType}; +use crate::api::core::two_factor::duo_oidc::purge_duo_contexts; +use crate::api::purge_auth_requests; +use crate::api::{WS_ANONYMOUS_SUBSCRIPTIONS, WS_USERS}; +pub use config::{PathType, CONFIG}; pub use error::{Error, MapResult}; +use rocket::data::{Limits, ToByteUnit}; +use std::sync::{atomic::Ordering, Arc}; pub use util::is_running_in_container; #[rocket::main] async fn main() -> Result<(), Error> { - install_rustls_crypto_provider(); parse_args(); launch_info(); @@ -137,22 +134,27 @@ fn parse_args() { if let Some(command) = pargs.subcommand().unwrap_or_default() { if command == "hash" { - use argon2::{Algorithm::Argon2id, Argon2, ParamsBuilder, PasswordHasher, Version::V0x13}; + use argon2::{ + password_hash::SaltString, Algorithm::Argon2id, Argon2, ParamsBuilder, PasswordHasher, Version::V0x13, + }; let mut argon2_params = ParamsBuilder::new(); let preset: Option = pargs.opt_value_from_str(["-p", "--preset"]).unwrap_or_default(); let selected_preset; - if preset.as_deref() == Some("owasp") { - selected_preset = "owasp"; - argon2_params.m_cost(19456); - argon2_params.t_cost(2); - argon2_params.p_cost(1); - } else { - // Bitwarden preset is the default - selected_preset = "bitwarden"; - argon2_params.m_cost(65540); - argon2_params.t_cost(3); - argon2_params.p_cost(4); + match preset.as_deref() { + Some("owasp") => { + selected_preset = "owasp"; + argon2_params.m_cost(19456); + argon2_params.t_cost(2); + argon2_params.p_cost(1); + } + _ => { + // Bitwarden preset is the default + selected_preset = "bitwarden"; + argon2_params.m_cost(65540); + argon2_params.t_cost(3); + argon2_params.p_cost(4); + } } println!("Generate an Argon2id PHC string using the '{selected_preset}' preset:\n"); @@ -170,10 +172,10 @@ fn parse_args() { } let argon2 = Argon2::new(Argon2id, V0x13, argon2_params.build().unwrap()); - let salt = crypto::get_random_bytes::<32>(); + let salt = SaltString::encode_b64(&crypto::get_random_bytes::<32>()).unwrap(); let argon2_timer = tokio::time::Instant::now(); - if let Ok(password_hash) = argon2.hash_password_with_salt(password.as_bytes(), &salt) { + if let Ok(password_hash) = argon2.hash_password(password.as_bytes(), &salt) { println!( "\n\ ADMIN_TOKEN='{password_hash}'\n\n\ @@ -200,14 +202,6 @@ fn parse_args() { } } -fn install_rustls_crypto_provider() { - if rustls::crypto::CryptoProvider::get_default().is_none() { - rustls::crypto::ring::default_provider() - .install_default() - .expect("failed to install rustls ring crypto provider"); - } -} - fn launch_info() { println!( "\ @@ -243,7 +237,7 @@ fn init_logging() -> Result { let level = caps .get(1) .and_then(|m| log::LevelFilter::from_str(m.as_str()).ok()) - .ok_or(Error::new("Failed to parse global log level".to_owned(), ""))?; + .ok_or(Error::new("Failed to parse global log level".to_string(), ""))?; let levels_override: Vec<(&str, log::LevelFilter)> = caps .get(2) @@ -252,13 +246,13 @@ fn init_logging() -> Result { .split(',') .collect::>() .into_iter() - .filter_map(|s| match s.split_once('=') { + .flat_map(|s| match s.split_once('=') { Some((log, lvl_str)) => log::LevelFilter::from_str(lvl_str).ok().map(|lvl| (log, lvl)), _ => None, }) .collect() }) - .ok_or(Error::new("Failed to parse overrides".to_owned(), ""))?; + .ok_or(Error::new("Failed to parse overrides".to_string(), ""))?; (level, levels_override) } else { @@ -334,7 +328,7 @@ fn init_logging() -> Result { ("vaultwarden::db::query_logger", log::LevelFilter::Off), ]); - for (path, level) in levels_override { + for (path, level) in levels_override.into_iter() { let _ = default_levels.insert(path, level); } @@ -348,7 +342,7 @@ fn init_logging() -> Result { let mut logger = fern::Dispatch::new().level(level).chain(std::io::stdout()); for (path, level) in default_levels { - logger = logger.level_for(path.to_owned(), level); + logger = logger.level_for(path.to_string(), level); } if CONFIG.extended_logging() { @@ -359,7 +353,7 @@ fn init_logging() -> Result { record.target(), record.level(), message - )); + )) }); } else { logger = logger.format(|out, message, _| out.finish(format_args!("{message}"))); @@ -595,7 +589,7 @@ async fn launch_rocket(pool: db::DbPool, extra_debug: bool) -> Result<(), Error> .manage(Arc::clone(&WS_ANONYMOUS_SUBSCRIPTIONS)) .attach(util::AppHeaders()) .attach(util::Cors()) - .attach(util::BetterLogging::new(extra_debug, basepath)) + .attach(util::BetterLogging(extra_debug)) .ignite() .await?; @@ -605,7 +599,9 @@ async fn launch_rocket(pool: db::DbPool, extra_debug: bool) -> Result<(), Error> #[cfg(all(unix, sqlite))] { - if db::ACTIVE_DB_TYPE.get() == Some(&db::DbConnType::Sqlite) { + if db::ACTIVE_DB_TYPE.get() != Some(&db::DbConnType::Sqlite) { + debug!("PostgreSQL and MySQL/MariaDB do not support this backup feature, skip adding USR1 signal."); + } else { tokio::spawn(async move { let mut signal_user1 = tokio::signal::unix::signal(SignalKind::user_defined1()).unwrap(); loop { @@ -618,8 +614,6 @@ async fn launch_rocket(pool: db::DbPool, extra_debug: bool) -> Result<(), Error> } } }); - } else { - debug!("PostgreSQL and MySQL/MariaDB do not support this backup feature, skip adding USR1 signal."); } } @@ -667,7 +661,7 @@ fn schedule_jobs(pool: db::DbPool) { let runtime = tokio::runtime::Runtime::new().unwrap(); thread::Builder::new() - .name("job-scheduler".to_owned()) + .name("job-scheduler".to_string()) .spawn(move || { use job_scheduler_ng::{Job, JobScheduler}; let _runtime_guard = runtime.enter(); @@ -720,7 +714,7 @@ fn schedule_jobs(pool: db::DbPool) { } // Clean unused, expired Duo authentication contexts. - if !CONFIG.duo_context_purge_schedule().is_empty() && CONFIG._enable_duo() { + if !CONFIG.duo_context_purge_schedule().is_empty() && CONFIG._enable_duo() && !CONFIG.duo_use_iframe() { sched.add(Job::new(CONFIG.duo_context_purge_schedule().parse().unwrap(), || { runtime.spawn(purge_duo_contexts(pool.clone())); })); @@ -729,7 +723,7 @@ fn schedule_jobs(pool: db::DbPool) { // Cleanup the event table of records x days old. if CONFIG.org_events_enabled() && !CONFIG.event_cleanup_schedule().is_empty() - && CONFIG.events_days_retain().is_some_and(|d| d != 0) + && CONFIG.events_days_retain().is_some() { sched.add(Job::new(CONFIG.event_cleanup_schedule().parse().unwrap(), || { runtime.spawn(api::event_cleanup_job(pool.clone())); @@ -754,9 +748,7 @@ fn schedule_jobs(pool: db::DbPool) { // tick, the one that was added earlier will run first. loop { sched.tick(); - runtime.block_on(tokio::time::sleep(tokio::time::Duration::from_millis(u64::from( - CONFIG.job_poll_interval_ms(), - )))); + runtime.block_on(tokio::time::sleep(tokio::time::Duration::from_millis(CONFIG.job_poll_interval_ms()))); } }) .expect("Error spawning job scheduler thread"); diff --git a/src/ratelimit.rs b/src/ratelimit.rs index 50d1f99b..854bcc53 100644 --- a/src/ratelimit.rs +++ b/src/ratelimit.rs @@ -1,44 +1,26 @@ use std::{net::IpAddr, num::NonZeroU32, sync::LazyLock, time::Duration}; -use governor::{Quota, RateLimiter, clock::DefaultClock, state::keyed::DashMapStateStore}; +use governor::{clock::DefaultClock, state::keyed::DashMapStateStore, Quota, RateLimiter}; -use crate::{CONFIG, Error}; +use crate::{Error, CONFIG}; type Limiter = RateLimiter, DefaultClock>; static LIMITER_LOGIN: LazyLock = LazyLock::new(|| { - let seconds = Duration::from_secs(u64::from(CONFIG.login_ratelimit_seconds())); - let burst = NonZeroU32::new(u32::from(CONFIG.login_ratelimit_max_burst())).expect("Non-zero login ratelimit burst"); + let seconds = Duration::from_secs(CONFIG.login_ratelimit_seconds()); + let burst = NonZeroU32::new(CONFIG.login_ratelimit_max_burst()).expect("Non-zero login ratelimit burst"); RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero login ratelimit seconds").allow_burst(burst)) }); static LIMITER_ADMIN: LazyLock = LazyLock::new(|| { - let seconds = Duration::from_secs(u64::from(CONFIG.admin_ratelimit_seconds())); - let burst = NonZeroU32::new(u32::from(CONFIG.admin_ratelimit_max_burst())).expect("Non-zero admin ratelimit burst"); + let seconds = Duration::from_secs(CONFIG.admin_ratelimit_seconds()); + let burst = NonZeroU32::new(CONFIG.admin_ratelimit_max_burst()).expect("Non-zero admin ratelimit burst"); RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero admin ratelimit seconds").allow_burst(burst)) }); -static LIMITER_UNAUTHENTICATED: LazyLock = LazyLock::new(|| { - let seconds = Duration::from_secs(u64::from(CONFIG.unauthenticated_ratelimit_seconds())); - let burst = NonZeroU32::new(u32::from(CONFIG.unauthenticated_ratelimit_max_burst())) - .expect("Non-zero unauthenticated ratelimit burst"); - RateLimiter::keyed( - Quota::with_period(seconds).expect("Non-zero unauthenticated ratelimit seconds").allow_burst(burst), - ) -}); - -pub fn check_limit_unauthenticated(ip: &IpAddr) -> Result<(), Error> { - match LIMITER_UNAUTHENTICATED.check_key(ip) { - Ok(()) => Ok(()), - Err(_e) => { - err_code!("Too many requests", 429); - } - } -} - pub fn check_limit_login(ip: &IpAddr) -> Result<(), Error> { match LIMITER_LOGIN.check_key(ip) { - Ok(()) => Ok(()), + Ok(_) => Ok(()), Err(_e) => { err_code!("Too many login requests", 429); } @@ -47,7 +29,7 @@ pub fn check_limit_login(ip: &IpAddr) -> Result<(), Error> { pub fn check_limit_admin(ip: &IpAddr) -> Result<(), Error> { match LIMITER_ADMIN.check_key(ip) { - Ok(()) => Ok(()), + Ok(_) => Ok(()), Err(_e) => { err_code!("Too many admin requests", 429); } diff --git a/src/sso.rs b/src/sso.rs index baa17492..ee6d707a 100644 --- a/src/sso.rs +++ b/src/sso.rs @@ -2,21 +2,22 @@ use std::{sync::LazyLock, time::Duration}; use chrono::Utc; use derive_more::{AsRef, Deref, Display, From, Into}; +use regex::Regex; use url::Url; use crate::{ - CONFIG, api::ApiResult, auth, - auth::{AuthMethod, AuthTokens, BW_EXPIRATION, DEFAULT_REFRESH_VALIDITY, TokenWrapper}, + auth::{AuthMethod, AuthTokens, TokenWrapper, BW_EXPIRATION, DEFAULT_REFRESH_VALIDITY}, db::{ + models::{Device, OIDCAuthenticatedUser, OIDCCodeWrapper, SsoAuth, SsoUser, User}, DbConn, - models::{Device, OIDCAuthenticatedUser, SsoAuth, SsoUser, User}, }, sso_client::Client, + CONFIG, }; -pub static FAKE_SSO_IDENTIFIER: &str = "00000000-01DC-01DC-01DC-000000000000"; +pub static FAKE_IDENTIFIER: &str = "VW_DUMMY_IDENTIFIER_FOR_OIDC"; static SSO_JWT_ISSUER: LazyLock = LazyLock::new(|| format!("{}|sso", CONFIG.domain_origin())); @@ -122,7 +123,7 @@ pub fn encode_ssotoken_claims() -> String { nbf: time_now.timestamp(), exp: (time_now + chrono::TimeDelta::try_minutes(2).unwrap()).timestamp(), iss: SSO_JWT_ISSUER.to_string(), - sub: "vaultwarden".to_owned(), + sub: "vaultwarden".to_string(), }; auth::encode_jwt(&claims) @@ -170,46 +171,39 @@ fn decode_token_claims(token_name: &str, token: &str) -> ApiResult ApiResult { - let state = if let Ok(vec) = data_encoding::BASE64.decode(base64_state.as_bytes()) { - if let Ok(valid) = String::from_utf8(vec) { - OIDCState(valid) - } else { - err!(format!("Invalid utf8 chars in {} after base64 decoding", base64_state.escape_debug())) - } - } else { - err!(format!("Failed to decode {} using base64", base64_state.escape_debug())) + let state = match data_encoding::BASE64.decode(base64_state.as_bytes()) { + Ok(vec) => match String::from_utf8(vec) { + Ok(valid) => OIDCState(valid), + Err(_) => err!(format!("Invalid utf8 chars in {base64_state} after base64 decoding")), + }, + Err(_) => err!(format!("Failed to decode {base64_state} using base64")), }; Ok(state) } -// redirect_uri from: https://github.com/bitwarden/server/blob/main/src/Identity/IdentityServer/StaticClientStore.cs +// redirect_uri from: https://github.com/bitwarden/server/blob/main/src/Identity/IdentityServer/ApiClient.cs pub async fn authorize_url( state: OIDCState, client_challenge: OIDCCodeChallenge, client_id: &str, raw_redirect_uri: &str, - binding_hash: Option, conn: DbConn, ) -> ApiResult { - // The desktop app uses the localhost callback when it can't register the custom scheme (AppImage, dev builds) - let localhost_redirect_uri = - (8065..=8070).map(|port| format!("http://localhost:{port}")).find(|uri| uri == raw_redirect_uri); - let redirect_uri = match client_id { "web" | "browser" => format!("{}/sso-connector.html", CONFIG.domain()), - "desktop" => localhost_redirect_uri.unwrap_or_else(|| "bitwarden://sso-callback".to_owned()), - "mobile" => "bitwarden://sso-callback".to_owned(), + "desktop" | "mobile" => "bitwarden://sso-callback".to_string(), "cli" => { - let Some(uri) = localhost_redirect_uri else { - err!(format!("Unsupported redirect uri {}", raw_redirect_uri.escape_debug())) - }; - uri + let port_regex = Regex::new(r"^http://localhost:([0-9]{4})$").unwrap(); + match port_regex.captures(raw_redirect_uri).and_then(|captures| captures.get(1).map(|c| c.as_str())) { + Some(port) => format!("http://localhost:{port}"), + None => err!("Failed to extract port number"), + } } - _ => err!(format!("Unsupported client {}", client_id.escape_debug())), + _ => err!(format!("Unsupported client {client_id}")), }; - 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).await?; sso_auth.save(&conn).await?; Ok(auth_url) } @@ -242,37 +236,36 @@ impl OIDCIdentifier { // During the 2FA flow we will // - retrieve the user information and then only discover he needs 2FA. -// - second time we will rely on `SsoAuth.auth_response` since the `code` has already been exchanged, -// after checking the PKCE verifier again like upstream does on every redemption. +// - second time we will rely on `SsoAuth.auth_response` since the `code` has already been exchanged. // The `SsoAuth` will ensure that the user is authorized only once. pub async fn exchange_code( - code: &OIDCCode, + state: &OIDCState, client_verifier: OIDCCodeVerifier, conn: &DbConn, ) -> ApiResult<(SsoAuth, OIDCAuthenticatedUser)> { use openidconnect::OAuth2TokenResponse; - let Some(mut sso_auth) = SsoAuth::find_by_code(code, conn).await else { - err!("Invalid code cannot retrieve sso auth") + let mut sso_auth = match SsoAuth::find(state, conn).await { + None => err!(format!("Invalid state cannot retrieve sso auth")), + Some(sso_auth) => sso_auth, }; if let Some(authenticated_user) = sso_auth.auth_response.clone() { - Client::check_client_verifier(&client_verifier, &sso_auth)?; return Ok((sso_auth, authenticated_user)); } - let code = match (sso_auth.code_response.clone(), sso_auth.code_response_error.as_ref()) { - (Some(code), None) => code, - (_, Some(re)) => { - let error_msg = format!( - "SSO authorization failed: {}, {}", - re.error.escape_debug(), - re.error_description.as_deref().unwrap_or_default().escape_debug() - ); + let code = match sso_auth.code_response.clone() { + Some(OIDCCodeWrapper::Ok { + code, + }) => code.clone(), + Some(OIDCCodeWrapper::Error { + error, + error_description, + }) => { sso_auth.delete(conn).await?; - err!(error_msg); + err!(format!("SSO authorization failed: {error}, {}", error_description.as_ref().unwrap_or(&String::new()))) } - (None, _) => { + None => { sso_auth.delete(conn).await?; err!("Missing authorization provider return"); } @@ -290,10 +283,10 @@ pub async fn exchange_code( let email_verified = id_claims.email_verified().or(user_info.email_verified()); - let user_name = id_claims.preferred_username().or(user_info.preferred_username()).map(|un| un.to_string()); + let user_name = id_claims.preferred_username().map(|un| un.to_string()); - let refresh_token = token_response.refresh_token().map(openidconnect::RefreshToken::secret); - if refresh_token.is_none() && CONFIG.sso_scopes_vec().contains(&"offline_access".to_owned()) { + let refresh_token = token_response.refresh_token().map(|t| t.secret()); + if refresh_token.is_none() && CONFIG.sso_scopes_vec().contains(&"offline_access".to_string()) { error!("Scope offline_access is present but response contain no refresh_token"); } @@ -309,11 +302,7 @@ pub async fn exchange_code( user_name: user_name.clone(), }; - if CONFIG.sso_debug_tokens() { - debug!("Authenticated user {authenticated_user:?}"); - } else { - debug!("Authenticated user {identifier}"); - } + debug!("Authenticated user {authenticated_user:?}"); sso_auth.auth_response = Some(authenticated_user.clone()); sso_auth.updated_at = Utc::now().naive_utc(); sso_auth.save(conn).await?; @@ -341,9 +330,7 @@ pub async fn redeem( user_sso.save(conn).await?; } - if CONFIG.sso_auth_only_not_session() { - Ok(AuthTokens::new(device, user, AuthMethod::Sso, client_id)) - } else { + if !CONFIG.sso_auth_only_not_session() { let now = Utc::now(); let (ap_nbf, ap_exp) = @@ -356,7 +343,9 @@ pub async fn redeem( let access_claims = auth::LoginJwtClaims::new(device, user, ap_nbf, ap_exp, AuthMethod::Sso.scope_vec(), client_id, now); - create_auth_tokens_impl(device, auth_user.refresh_token, access_claims, auth_user.access_token) + _create_auth_tokens(device, auth_user.refresh_token, access_claims, auth_user.access_token) + } else { + Ok(AuthTokens::new(device, user, AuthMethod::Sso, client_id)) } } @@ -370,9 +359,7 @@ pub fn create_auth_tokens( access_token: String, expires_in: Option, ) -> ApiResult { - if CONFIG.sso_auth_only_not_session() { - Ok(AuthTokens::new(device, user, AuthMethod::Sso, client_id)) - } else { + if !CONFIG.sso_auth_only_not_session() { let now = Utc::now(); let (ap_nbf, ap_exp) = match (decode_token_claims("access_token", &access_token), expires_in) { @@ -384,11 +371,13 @@ pub fn create_auth_tokens( let access_claims = auth::LoginJwtClaims::new(device, user, ap_nbf, ap_exp, AuthMethod::Sso.scope_vec(), client_id, now); - create_auth_tokens_impl(device, refresh_token, access_claims, access_token) + _create_auth_tokens(device, refresh_token, access_claims, access_token) + } else { + Ok(AuthTokens::new(device, user, AuthMethod::Sso, client_id)) } } -fn create_auth_tokens_impl( +fn _create_auth_tokens( device: &Device, refresh_token: Option, access_claims: auth::LoginJwtClaims, @@ -472,7 +461,7 @@ pub async fn exchange_refresh_token( now, ); - create_auth_tokens_impl(device, None, access_claims, access_token) + _create_auth_tokens(device, None, access_claims, access_token) } None => err!("No token present while in SSO"), } diff --git a/src/sso_client.rs b/src/sso_client.rs index 246e5678..6204ab48 100644 --- a/src/sso_client.rs +++ b/src/sso_client.rs @@ -1,35 +1,21 @@ -use std::{borrow::Cow, collections::HashSet, future::Future, pin::Pin, sync::LazyLock, time::Duration}; +use std::{borrow::Cow, sync::LazyLock, time::Duration}; -use openidconnect::{ - AccessToken, AsyncHttpClient, AuthDisplay, AuthPrompt, AuthType, AuthenticationFlow, AuthorizationCode, - AuthorizationRequest, ClientId, ClientSecret, CsrfToken, EmptyAdditionalClaims, EmptyExtraTokenFields, - EndpointNotSet, EndpointSet, HttpClientError, HttpRequest, HttpResponse, IdTokenClaims, IdTokenFields, Nonce, - OAuth2TokenResponse, PkceCodeChallenge, PkceCodeVerifier, RefreshToken, ResponseType, Scope, StandardErrorResponse, - StandardTokenResponse, - core::{ - CoreAuthDisplay, CoreAuthPrompt, CoreClient, CoreClientAuthMethod, CoreErrorResponseType, CoreGenderClaim, - CoreIdTokenVerifier, CoreJsonWebKey, CoreJweContentEncryptionAlgorithm, CoreJwsSigningAlgorithm, - CoreProviderMetadata, CoreResponseType, CoreRevocableToken, CoreRevocationErrorResponse, - CoreTokenIntrospectionResponse, CoreTokenResponse, CoreTokenType, CoreUserInfoClaims, - }, - http, url, -}; +use openidconnect::{core::*, reqwest, *}; use regex::Regex; use url::Url; use crate::{ - CONFIG, api::{ApiResult, EmptyResult}, db::models::SsoAuth, - http_client::get_reqwest_client_builder, sso::{OIDCCode, OIDCCodeChallenge, OIDCCodeVerifier, OIDCState}, + CONFIG, }; -static CLIENT_CACHE_KEY: LazyLock = LazyLock::new(|| "sso-client".to_owned()); +static CLIENT_CACHE_KEY: LazyLock = LazyLock::new(|| "sso-client".to_string()); static CLIENT_CACHE: LazyLock> = LazyLock::new(|| { moka::sync::Cache::builder() .max_capacity(1) - .time_to_live(Duration::from_secs(u64::from(CONFIG.sso_client_cache_expiration()))) + .time_to_live(Duration::from_secs(CONFIG.sso_client_cache_expiration())) .build() }); static REFRESH_CACHE: LazyLock>> = @@ -60,58 +46,19 @@ pub type RefreshTokenResponse = (Option, String, Option); #[derive(Clone)] pub struct Client { - pub http_client: OidcHttpClient, + pub http_client: reqwest::Client, pub core_client: CustomClient, } -#[derive(Clone)] -pub struct OidcHttpClient { - client: reqwest::Client, -} - -impl OidcHttpClient { - fn new() -> Result { - get_reqwest_client_builder(false).redirect(reqwest::redirect::Policy::none()).build().map(|client| Self { - client, - }) - } -} - -impl<'c> AsyncHttpClient<'c> for OidcHttpClient { - type Error = HttpClientError; - type Future = Pin> + Send + Sync + 'c>>; - - fn call(&'c self, request: HttpRequest) -> Self::Future { - Box::pin(async move { - let response = self.client.execute(request.try_into().map_err(Box::new)?).await.map_err(|e| { - debug!("Request failed {e:?}"); - Box::new(e) - })?; - - let mut builder = http::Response::builder().status(response.status()).version(response.version()); - - for (name, value) in response.headers() { - builder = builder.header(name, value); - } - - let body = response.bytes().await.map_err(Box::new)?; - if CONFIG.sso_debug_tokens() { - debug!("Response body {}", String::from_utf8_lossy(&body)); - } - builder.body(body.to_vec()).map_err(HttpClientError::Http) - }) - } -} - impl Client { // Call the OpenId discovery endpoint to retrieve configuration - async fn get_client() -> ApiResult { + async fn _get_client() -> ApiResult { let client_id = ClientId::new(CONFIG.sso_client_id()); let client_secret = ClientSecret::new(CONFIG.sso_client_secret()); let issuer_url = CONFIG.sso_issuer_url()?; - let http_client = match OidcHttpClient::new() { + let http_client = match reqwest::ClientBuilder::new().redirect(reqwest::redirect::Policy::none()).build() { Err(err) => err!(format!("Failed to build http client: {err}")), Ok(client) => client, }; @@ -121,32 +68,16 @@ impl Client { Ok(metadata) => metadata, }; - let auth_methods: Option> = provider_metadata - .token_endpoint_auth_methods_supported() - .map(|v| v.iter().map(ToOwned::to_owned).collect()); + let base_client = CoreClient::from_provider_metadata(provider_metadata, client_id, Some(client_secret)); - let mut base_client = CoreClient::from_provider_metadata(provider_metadata, client_id, Some(client_secret)); - - if let Some(am) = auth_methods { - if am.contains(&CoreClientAuthMethod::ClientSecretBasic) { - base_client = base_client.set_auth_type(AuthType::BasicAuth); // Default - } else if am.contains(&CoreClientAuthMethod::ClientSecretPost) { - base_client = base_client.set_auth_type(AuthType::RequestBody); - } else { - err!(format!("No supported auth_methods (only basic or request body), advertised: {am:?}")); - } - } - - let token_uri = if let Some(uri) = base_client.token_uri() { - uri.clone() - } else { - err!("Failed to discover token_url, cannot proceed") + let token_uri = match base_client.token_uri() { + Some(uri) => uri.clone(), + None => err!("Failed to discover token_url, cannot proceed"), }; - let user_info_url = if let Some(url) = base_client.user_info_url() { - url.clone() - } else { - err!("Failed to discover user_info url, cannot proceed") + let user_info_url = match base_client.user_info_url() { + Some(url) => url.clone(), + None => err!("Failed to discover user_info url, cannot proceed"), }; let core_client = base_client @@ -165,13 +96,13 @@ impl Client { if CONFIG.sso_client_cache_expiration() > 0 { match CLIENT_CACHE.get(&*CLIENT_CACHE_KEY) { Some(client) => Ok(client), - None => Self::get_client().await.inspect(|client| { + None => Self::_get_client().await.inspect(|client| { debug!("Inserting new client in cache"); CLIENT_CACHE.insert(CLIENT_CACHE_KEY.clone(), client.clone()); }), } } else { - Self::get_client().await + Self::_get_client().await } } @@ -186,7 +117,6 @@ impl Client { state: OIDCState, client_challenge: OIDCCodeChallenge, redirect_uri: String, - binding_hash: Option, ) -> ApiResult<(Url, SsoAuth)> { let scopes = CONFIG.sso_scopes_vec().into_iter().map(Scope::new); let base64_state = data_encoding::BASE64.encode(state.to_string().as_bytes()); @@ -209,17 +139,7 @@ impl Client { } let (auth_url, _, nonce) = auth_req.url(); - 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(()) + Ok((auth_url, SsoAuth::new(state, client_challenge, nonce.secret().clone(), redirect_uri))) } pub async fn exchange_code( @@ -244,10 +164,15 @@ impl Client { let mut exchange = self.core_client.exchange_code(oidc_code); + let verifier = PkceCodeVerifier::new(client_verifier.into()); if CONFIG.sso_pkce() { - exchange = exchange.set_pkce_verifier(PkceCodeVerifier::new(client_verifier.into())); + exchange = exchange.set_pkce_verifier(verifier); } else { - Self::check_client_verifier(&client_verifier, sso_auth)?; + let challenge = PkceCodeChallenge::from_code_verifier_sha256(&verifier); + if challenge.as_str() != String::from(sso_auth.client_challenge.clone()) { + err!(format!("PKCE client challenge failed")) + // Might need to notify admin ? how ? + } } match exchange.request_async(&self.http_client).await { @@ -255,14 +180,15 @@ impl Client { Ok(token_response) => { let oidc_nonce = Nonce::new(sso_auth.nonce.clone()); - let Some(id_token) = token_response.extra_fields().id_token() else { - err!("Token response did not contain an id_token") + let id_token = match token_response.extra_fields().id_token() { + None => err!("Token response did not contain an id_token"), + Some(token) => token, }; if CONFIG.sso_debug_tokens() { - debug!("Id token: {id_token}"); + debug!("Id token: {}", id_token.to_string()); debug!("Access token: {}", token_response.access_token().secret()); - debug!("Refresh token: {:?}", token_response.refresh_token().map(RefreshToken::secret)); + debug!("Refresh token: {:?}", token_response.refresh_token().map(|t| t.secret())); debug!("Expiration time: {:?}", token_response.expires_in()); } @@ -315,12 +241,12 @@ impl Client { let client = Client::cached().await?; REFRESH_CACHE - .get_with(refresh_token.clone(), async move { client.exchange_refresh_token_impl(refresh_token).await }) + .get_with(refresh_token.clone(), async move { client._exchange_refresh_token(refresh_token).await }) .await .map_err(Into::into) } - async fn exchange_refresh_token_impl(&self, refresh_token: String) -> Result { + async fn _exchange_refresh_token(&self, refresh_token: String) -> Result { let rt = RefreshToken::new(refresh_token); match self.core_client.exchange_refresh_token(&rt).request_async(&self.http_client).await { diff --git a/src/static/scripts/admin.css b/src/static/scripts/admin.css index c7c6f443..0df56771 100644 --- a/src/static/scripts/admin.css +++ b/src/static/scripts/admin.css @@ -1,17 +1,6 @@ body { padding-top: 75px; } -/* Some extra width's for the main layout */ -@media (min-width: 1600px) { - .container-xxl { - max-width: 1520px; - } -} -@media (min-width: 1800px) { - .container-xxl { - max-width: 1720px; - } -} img { width: 48px; height: 48px; @@ -49,8 +38,8 @@ img { max-width: 130px; } #users-table .vw-actions, #orgs-table .vw-actions { - min-width: 170px; - max-width: 180px; + min-width: 155px; + max-width: 160px; } #users-table .vw-org-cell { max-height: 120px; diff --git a/src/static/scripts/admin.js b/src/static/scripts/admin.js index fa949a40..3f6bb1df 100644 --- a/src/static/scripts/admin.js +++ b/src/static/scripts/admin.js @@ -1,5 +1,6 @@ "use strict"; -/* exported BASE_URL, _post, _delete */ +/* eslint-env es2017, browser */ +/* exported BASE_URL, _post _delete */ function getBaseUrl() { // If the base URL is `https://vaultwarden.example.com/base/path/admin/`, diff --git a/src/static/scripts/admin_diagnostics.js b/src/static/scripts/admin_diagnostics.js index 1d2f6605..2cff4410 100644 --- a/src/static/scripts/admin_diagnostics.js +++ b/src/static/scripts/admin_diagnostics.js @@ -1,4 +1,5 @@ "use strict"; +/* eslint-env es2017, browser */ /* global BASE_URL:readable, bootstrap:readable */ var dnsCheck = false; @@ -79,46 +80,37 @@ async function generateSupportString(event, dj) { event.preventDefault(); event.stopPropagation(); - // Health check Markdown emoji, if something is a failure or not - const chk = v => v ? "true :white_check_mark:" : "false :x:"; - // Yes/No Markdown emoji, if something is not a failure, but just yes or no - const yn = v => v ? "yes :heavy_plus_sign:" : "no :heavy_minus_sign:"; - - const template_overrides = dj.template_overrides !== "" ? ` (${dj.template_overrides})` : ""; let supportString = "### Your environment (Generated via diagnostics page)\n\n"; supportString += `* Vaultwarden version: v${dj.current_release}\n`; supportString += `* Web-vault version: v${dj.active_web_release}\n`; supportString += `* OS/Arch: ${dj.host_os}/${dj.host_arch}\n`; - supportString += `* Running within a container: ${yn(dj.running_within_container)} (Base: ${dj.container_base_image})\n`; + supportString += `* Running within a container: ${dj.running_within_container} (Base: ${dj.container_base_image})\n`; supportString += `* Database type: ${dj.db_type}\n`; supportString += `* Database version: ${dj.db_version}\n`; - supportString += `* Uses config.json: ${yn(dj.config_file_status !== "NotFound")}`; - supportString += dj.config_file_status !== "NotFound" ? ` (Valid: ${chk(dj.config_file_status === "Loaded")})\n` : "\n"; - supportString += `* Environment variables are valid: ${chk(dj.invalid_env_vars == "")}\n`; - supportString += `* Uses custom templates: ${yn(dj.template_overrides !== "")}${template_overrides}\n`; - supportString += `* Uses a reverse proxy: ${yn(dj.ip_header_exists)}\n`; + supportString += `* Uses config.json: ${dj.overrides !== ""}\n`; + supportString += `* Uses a reverse proxy: ${dj.ip_header_exists}\n`; if (dj.ip_header_exists) { - supportString += `* IP Header check: ${chk(dj.ip_header_match)} (${dj.ip_header_name})\n`; + supportString += `* IP Header check: ${dj.ip_header_match} (${dj.ip_header_name})\n`; } - supportString += `* Internet access: ${chk(dj.has_http_access)}\n`; - supportString += `* Internet access via a proxy: ${yn(dj.uses_proxy)}\n`; - supportString += `* DNS Check: ${chk(dnsCheck)}\n`; + supportString += `* Internet access: ${dj.has_http_access}\n`; + supportString += `* Internet access via a proxy: ${dj.uses_proxy}\n`; + supportString += `* DNS Check: ${dnsCheck}\n`; if (dj.tz_env !== "") { supportString += `* TZ environment: ${dj.tz_env}\n`; } - supportString += `* Browser/Server Time Check: ${chk(timeCheck)}\n`; - supportString += `* Server/NTP Time Check: ${chk(ntpTimeCheck)}\n`; - supportString += `* Domain Configuration Check: ${chk(domainCheck)}\n`; - supportString += `* HTTPS Check: ${chk(httpsCheck)}\n`; + supportString += `* Browser/Server Time Check: ${timeCheck}\n`; + supportString += `* Server/NTP Time Check: ${ntpTimeCheck}\n`; + supportString += `* Domain Configuration Check: ${domainCheck}\n`; + supportString += `* HTTPS Check: ${httpsCheck}\n`; if (dj.enable_websocket) { - supportString += `* Websocket Check: ${chk(websocketCheck)}\n`; + supportString += `* Websocket Check: ${websocketCheck}\n`; } else { supportString += "* Websocket Check: disabled\n"; } - supportString += `* HTTP Response Checks: ${chk(httpResponseCheck)}\n`; + supportString += `* HTTP Response Checks: ${httpResponseCheck}\n`; if (dj.invalid_feature_flags != "") { - supportString += "* Invalid feature flags: true :x:\n"; + supportString += `* Invalid feature flags: true\n`; } const jsonResponse = await fetch(`${BASE_URL}/admin/diagnostics/config`, { @@ -134,11 +126,6 @@ async function generateSupportString(event, dj) { supportString += "\n### Config & Details (Generated via diagnostics page)\n\n"; supportString += "
Show Config & Details\n"; - // Show invalid environment variables if they exists - if (dj.invalid_env_vars != "") { - supportString += `\n**Invalid environment variables:** ${dj.invalid_env_vars}\n`; - } - // Add overrides if they exists if (dj.overrides != "") { supportString += `\n**Environment settings which are overridden:** ${dj.overrides}\n`; diff --git a/src/static/scripts/admin_organizations.js b/src/static/scripts/admin_organizations.js index 0aa57dcd..c885344e 100644 --- a/src/static/scripts/admin_organizations.js +++ b/src/static/scripts/admin_organizations.js @@ -1,5 +1,6 @@ "use strict"; -/* global DataTable, _post:readable, BASE_URL:readable, reload:readable, jdenticon:readable */ +/* eslint-env es2017, browser, jquery */ +/* global _post:readable, BASE_URL:readable, reload:readable, jdenticon:readable */ function deleteOrganization(event) { event.preventDefault(); @@ -41,9 +42,8 @@ function initActions() { // onLoad events document.addEventListener("DOMContentLoaded", (/*event*/) => { - const columnCount = document.getElementById("orgs-table").querySelectorAll("thead th").length; - new DataTable("#orgs-table", { - "drawCallback": function () { + jQuery("#orgs-table").DataTable({ + "drawCallback": function() { initActions(); }, "stateSave": true, @@ -54,7 +54,7 @@ document.addEventListener("DOMContentLoaded", (/*event*/) => { ], "pageLength": -1, // Default show all "columnDefs": [{ - "targets": [columnCount - 2, columnCount - 1], // Do not include the last two columns into the search/order features + "targets": [4,5], "searchable": false, "orderable": false }] @@ -67,4 +67,4 @@ document.addEventListener("DOMContentLoaded", (/*event*/) => { if (btnReload) { btnReload.addEventListener("click", reload); } -}); +}); \ No newline at end of file diff --git a/src/static/scripts/admin_settings.js b/src/static/scripts/admin_settings.js index 9061719e..3d61a508 100644 --- a/src/static/scripts/admin_settings.js +++ b/src/static/scripts/admin_settings.js @@ -1,4 +1,5 @@ "use strict"; +/* eslint-env es2017, browser */ /* global _post:readable, BASE_URL:readable */ function smtpTest(event) { diff --git a/src/static/scripts/admin_users.js b/src/static/scripts/admin_users.js index 63128627..99e39aab 100644 --- a/src/static/scripts/admin_users.js +++ b/src/static/scripts/admin_users.js @@ -1,5 +1,6 @@ "use strict"; -/* global DataTable, _post:readable, _delete:readable, BASE_URL:readable, reload:readable, jdenticon:readable */ +/* eslint-env es2017, browser, jquery */ +/* global _post:readable, _delete:readable BASE_URL:readable, reload:readable, jdenticon:readable */ function deleteUser(event) { event.preventDefault(); @@ -141,7 +142,7 @@ function inviteUser(event) { ); } -function resendUserInvite(event) { +function resendUserInvite (event) { event.preventDefault(); event.stopPropagation(); const id = event.target.parentNode.dataset.vwUserUuid; @@ -173,41 +174,61 @@ const ORG_TYPES = { "name": "User", "bg": "blue" }, - "3": { + "4": { "name": "Manager", "bg": "green" }, - "4": { - "name": "Custom", - "bg": "teal" - }, }; +// Special sort function to sort dates in ISO format +jQuery.extend(jQuery.fn.dataTableExt.oSort, { + "date-iso-pre": function(a) { + let x; + const sortDate = a.replace(/(<([^>]+)>)/gi, "").trim(); + if (sortDate !== "") { + const dtParts = sortDate.split(" "); + const timeParts = (undefined != dtParts[1]) ? dtParts[1].split(":") : ["00", "00", "00"]; + const dateParts = dtParts[0].split("-"); + x = (dateParts[0] + dateParts[1] + dateParts[2] + timeParts[0] + timeParts[1] + ((undefined != timeParts[2]) ? timeParts[2] : 0)) * 1; + if (isNaN(x)) { + x = 0; + } + } else { + x = Infinity; + } + return x; + }, + + "date-iso-asc": function(a, b) { + return a - b; + }, + + "date-iso-desc": function(a, b) { + return b - a; + } +}); + const userOrgTypeDialog = document.getElementById("userOrgTypeDialog"); // Fill the form and title -userOrgTypeDialog.addEventListener("show.bs.modal", function (event) { +userOrgTypeDialog.addEventListener("show.bs.modal", function(event) { // Get shared values const userEmail = event.relatedTarget.parentNode.dataset.vwUserEmail; const userUuid = event.relatedTarget.parentNode.dataset.vwUserUuid; // Get org specific values const userOrgType = event.relatedTarget.dataset.vwOrgType; + const userOrgTypeName = ORG_TYPES[userOrgType]["name"]; const orgName = event.relatedTarget.dataset.vwOrgName; const orgUuid = event.relatedTarget.dataset.vwOrgUuid; - document.getElementById("userOrgTypeForm").reset(); document.getElementById("userOrgTypeDialogOrgName").textContent = orgName; document.getElementById("userOrgTypeDialogUserEmail").textContent = userEmail; document.getElementById("userOrgTypeUserUuid").value = userUuid; document.getElementById("userOrgTypeOrgUuid").value = orgUuid; - // The Custom role can only be selected while it is enabled - const userOrgTypeRadio = document.getElementById(`userOrgType${ORG_TYPES[userOrgType]?.name}`); - if (userOrgTypeRadio) { - userOrgTypeRadio.checked = true; - } + document.getElementById(`userOrgType${userOrgTypeName}`).checked = true; }, false); // Prevent accidental submission of the form with valid elements after the modal has been hidden. -userOrgTypeDialog.addEventListener("hide.bs.modal", function () { +userOrgTypeDialog.addEventListener("hide.bs.modal", function() { document.getElementById("userOrgTypeDialogOrgName").textContent = ""; document.getElementById("userOrgTypeDialogUserEmail").textContent = ""; document.getElementById("userOrgTypeUserUuid").value = ""; @@ -229,8 +250,8 @@ function updateUserOrgType(event) { function initUserTable() { // Color all the org buttons per type - document.querySelectorAll("button[data-vw-org-type]").forEach(function (e) { - const orgType = ORG_TYPES[e.dataset.vwOrgType] ?? { "name": "Unknown", "bg": "gray" }; + document.querySelectorAll("button[data-vw-org-type]").forEach(function(e) { + const orgType = ORG_TYPES[e.dataset.vwOrgType]; e.style.backgroundColor = orgType.bg; if (orgType.font !== undefined) { e.style.color = orgType.font; @@ -265,37 +286,12 @@ function initUserTable() { } } -// Special sort function to sort dates in ISO format and have anything else as 0 -DataTable.ext.type.order["date-iso-pre"] = function (a) { - let x; - const sortDate = a.replace(/(<([^>]+)>)/gi, "").trim(); - if (sortDate !== "") { - const dtParts = sortDate.split(" "); - const timeParts = (undefined != dtParts[1]) ? dtParts[1].split(":") : ["00", "00", "00"]; - const dateParts = dtParts[0].split("-"); - x = (dateParts[0] + dateParts[1] + dateParts[2] + timeParts[0] + timeParts[1] + ((undefined != timeParts[2]) ? timeParts[2] : 0)) * 1; - if (isNaN(x)) { - x = 0; - } - } else { - x = Infinity; - } - return x; -}; - // onLoad events document.addEventListener("DOMContentLoaded", (/*event*/) => { - DataTable.ext.type.detect.unshift(function (data) { - if (typeof data !== "string") { return null; } - return data.indexOf("data-sort-type=\"date-iso\"") !== -1 - ? "date-iso" - : null; - }); - - const columnCount = document.getElementById("users-table").querySelectorAll("thead th").length; - new DataTable("#users-table", { - "typeDetect": true, - "drawCallback": function () { + const size = jQuery("#users-table > thead th").length; + const ssoOffset = size-7; + jQuery("#users-table").DataTable({ + "drawCallback": function() { initUserTable(); }, "stateSave": true, @@ -306,7 +302,10 @@ document.addEventListener("DOMContentLoaded", (/*event*/) => { ], "pageLength": -1, // Default show all "columnDefs": [{ - "targets": columnCount - 1, // Do not include the last column into the search/order features + "targets": [1 + ssoOffset, 2 + ssoOffset], + "type": "date-iso" + }, { + "targets": size-1, "searchable": false, "orderable": false }] diff --git a/src/static/scripts/datatables.css b/src/static/scripts/datatables.css index 81827eed..d91ea601 100644 --- a/src/static/scripts/datatables.css +++ b/src/static/scripts/datatables.css @@ -4,32 +4,25 @@ * * To rebuild or modify this file with the latest versions of the included * software please visit: - * https://datatables.net/download/#bs5/dt-3.1.4 + * https://datatables.net/download/#bs5/dt-2.3.7 * * Included libraries: - * DataTables 3.1.4 + * DataTables 2.3.7 */ -/*! DataTables Bootstrap 5 integration - * © SpryMedia Ltd - datatables.net/license - */ :root { - --dt_background-selected: 13, 110, 253; - --dt_color-selected: 255, 255, 255; - --dt_link_color-selected: 228, 228, 228; - --dt-row_background: transparent; - --dt-row_background-selected: var(--dt_background-selected); - --dt-row-text_color-selected: var(--dt_color-selected); - --dt-row-link_color-selected: var(--dt_link_color-selected); - --dt-row_background-stripe: 0, 0, 0; - --dt-row_background-hover: 0, 0, 0; - --dt-column-ordering_background: 0, 0, 0; - --dt-header-cell_align-items: center; - --dt-header-cell_vertical-align: middle; - --dt-html_background: white; + --dt-row-selected: 13, 110, 253; + --dt-row-selected-text: 255, 255, 255; + --dt-row-selected-link: 228, 228, 228; + --dt-row-stripe: 0, 0, 0; + --dt-row-hover: 0, 0, 0; + --dt-column-ordering: 0, 0, 0; + --dt-header-align-items: center; + --dt-header-vertical-align: middle; + --dt-html-background: white; } :root.dark { - --dt-html_background: rgb(33, 37, 41); + --dt-html-background: rgb(33, 37, 41); } table.dataTable tbody td.dt-control { @@ -51,13 +44,16 @@ table.dataTable tbody tr.dt-hasChild td.dt-control:before { border-bottom: 0px solid transparent; border-right: 5px solid transparent; } +table.dataTable tfoot:empty { + display: none; +} -:root.dark table.dataTable td.dt-control:before, +html.dark table.dataTable td.dt-control:before, :root[data-bs-theme=dark] table.dataTable td.dt-control:before, :root[data-theme=dark] table.dataTable td.dt-control:before { border-left-color: rgba(255, 255, 255, 0.5); } -:root.dark table.dataTable tr.dt-hasChild td.dt-control:before, +html.dark table.dataTable tr.dt-hasChild td.dt-control:before, :root[data-bs-theme=dark] table.dataTable tr.dt-hasChild td.dt-control:before, :root[data-theme=dark] table.dataTable tr.dt-hasChild td.dt-control:before { border-top-color: rgba(255, 255, 255, 0.5); @@ -88,25 +84,6 @@ div.dt-scroll-body tfoot tr td div.dt-scroll-sizing { overflow: hidden !important; } -/*! DataTables Bootstrap 5 integration - * © SpryMedia Ltd - datatables.net/license - */ -:root { - --dt-order-arrow_color: rgb(51, 51, 51); - --dt-order-arrow_color-current: rgb(51, 51, 51); - --dt-order-arrow-height: 7px; - --dt-order-arrow_opacity: 0.125; - --dt-order-arrow_opacity-current: 0.65; - --dt-order-arrow-width: 8px; - --dt-order-arrow-gap: 1px; - --dt-order-header_outline-hover: 2px solid rgba(0, 0, 0, 0.05); -} -:root.dark, :root[data-bs-theme=dark], :root[data-theme=dark] { - --dt-order-arrow_color: rgb(229, 233, 238); - --dt-order-arrow_color-current: rgb(229, 233, 238); - --dt-order-header_outline-hover: 2px solid rgba(255, 255, 255, 0.05); -} - table.dataTable thead > tr > th:active, table.dataTable thead > tr > td:active { outline: none; @@ -114,18 +91,20 @@ table.dataTable thead > tr > td:active { table.dataTable thead > tr > th.dt-orderable-asc .dt-column-order:before, table.dataTable thead > tr > th.dt-ordering-asc .dt-column-order:before, table.dataTable thead > tr > td.dt-orderable-asc .dt-column-order:before, table.dataTable thead > tr > td.dt-ordering-asc .dt-column-order:before { - bottom: calc(50% + var(--dt-order-arrow-gap)); - border-bottom: var(--dt-order-arrow-height) solid var(--dt-order-arrow_color); - border-left: calc(var(--dt-order-arrow-width) / 2) solid transparent; - border-right: calc(var(--dt-order-arrow-width) / 2) solid transparent; + position: absolute; + display: block; + bottom: 50%; + content: "\25B2"; + content: "\25B2"/""; } table.dataTable thead > tr > th.dt-orderable-desc .dt-column-order:after, table.dataTable thead > tr > th.dt-ordering-desc .dt-column-order:after, table.dataTable thead > tr > td.dt-orderable-desc .dt-column-order:after, table.dataTable thead > tr > td.dt-ordering-desc .dt-column-order:after { - top: calc(50% + 1px); - border-top: var(--dt-order-arrow-height) solid var(--dt-order-arrow_color); - border-left: calc(var(--dt-order-arrow-width) / 2) solid transparent; - border-right: calc(var(--dt-order-arrow-width) / 2) solid transparent; + position: absolute; + display: block; + top: 50%; + content: "\25BC"; + content: "\25BC"/""; } table.dataTable thead > tr > th.dt-orderable-asc .dt-column-order, table.dataTable thead > tr > th.dt-orderable-desc .dt-column-order, table.dataTable thead > tr > th.dt-ordering-asc .dt-column-order, table.dataTable thead > tr > th.dt-ordering-desc .dt-column-order, table.dataTable thead > tr > td.dt-orderable-asc .dt-column-order, @@ -133,8 +112,8 @@ table.dataTable thead > tr > td.dt-orderable-desc .dt-column-order, table.dataTable thead > tr > td.dt-ordering-asc .dt-column-order, table.dataTable thead > tr > td.dt-ordering-desc .dt-column-order { position: relative; - width: var(--dt-order-arrow-width); - align-self: stretch; + width: 12px; + height: 20px; } table.dataTable thead > tr > th.dt-orderable-asc .dt-column-order:before, table.dataTable thead > tr > th.dt-orderable-asc .dt-column-order:after, table.dataTable thead > tr > th.dt-orderable-desc .dt-column-order:before, table.dataTable thead > tr > th.dt-orderable-desc .dt-column-order:after, table.dataTable thead > tr > th.dt-ordering-asc .dt-column-order:before, table.dataTable thead > tr > th.dt-ordering-asc .dt-column-order:after, table.dataTable thead > tr > th.dt-ordering-desc .dt-column-order:before, table.dataTable thead > tr > th.dt-ordering-desc .dt-column-order:after, table.dataTable thead > tr > td.dt-orderable-asc .dt-column-order:before, @@ -145,14 +124,10 @@ table.dataTable thead > tr > td.dt-ordering-asc .dt-column-order:before, table.dataTable thead > tr > td.dt-ordering-asc .dt-column-order:after, table.dataTable thead > tr > td.dt-ordering-desc .dt-column-order:before, table.dataTable thead > tr > td.dt-ordering-desc .dt-column-order:after { - position: absolute; - display: block; - content: " "; - height: 0; - width: 0; left: 0; - color: var(--dt-order-arrow_color); - opacity: var(--dt-order-arrow_opacity); + opacity: 0.125; + line-height: 9px; + font-size: 0.8em; } table.dataTable thead > tr > th.dt-orderable-asc, table.dataTable thead > tr > th.dt-orderable-desc, table.dataTable thead > tr > td.dt-orderable-asc, @@ -162,18 +137,13 @@ table.dataTable thead > tr > td.dt-orderable-desc { table.dataTable thead > tr > th.dt-orderable-asc:hover, table.dataTable thead > tr > th.dt-orderable-desc:hover, table.dataTable thead > tr > td.dt-orderable-asc:hover, table.dataTable thead > tr > td.dt-orderable-desc:hover { - outline: var(--dt-order-header_outline-hover); + outline: 2px solid rgba(0, 0, 0, 0.05); outline-offset: -2px; } -table.dataTable thead > tr > th.dt-ordering-asc .dt-column-order:before, -table.dataTable thead > tr > td.dt-ordering-asc .dt-column-order:before { - border-bottom-color: var(--dt-order-arrow_color-current); - opacity: var(--dt-order-arrow_opacity-current); -} -table.dataTable thead > tr > th.dt-ordering-desc .dt-column-order:after, +table.dataTable thead > tr > th.dt-ordering-asc .dt-column-order:before, table.dataTable thead > tr > th.dt-ordering-desc .dt-column-order:after, +table.dataTable thead > tr > td.dt-ordering-asc .dt-column-order:before, table.dataTable thead > tr > td.dt-ordering-desc .dt-column-order:after { - border-top-color: var(--dt-order-arrow_color-current); - opacity: var(--dt-order-arrow_opacity-current); + opacity: 0.6; } table.dataTable thead > tr > th.dt-orderable-none:not(.dt-ordering-asc, .dt-ordering-desc) .dt-column-order:empty, table.dataTable thead > tr > th.sorting_desc_disabled .dt-column-order:after, table.dataTable thead > tr > th.sorting_asc_disabled .dt-column-order:before, table.dataTable thead > tr > td.dt-orderable-none:not(.dt-ordering-asc, .dt-ordering-desc) .dt-column-order:empty, @@ -196,7 +166,7 @@ table.dataTable tfoot > tr > td div.dt-column-header, table.dataTable tfoot > tr > td div.dt-column-footer { display: flex; justify-content: space-between; - align-items: var(--dt-header-cell_align-items); + align-items: var(--dt-header-align-items); gap: 4px; } table.dataTable thead > tr > th div.dt-column-header .dt-column-title, @@ -232,14 +202,7 @@ div.dt-scroll-body > table.dataTable > thead > tr > td { :root[data-bs-theme=dark] table.dataTable thead > tr > th.dt-orderable-desc:hover, :root[data-bs-theme=dark] table.dataTable thead > tr > td.dt-orderable-asc:hover, :root[data-bs-theme=dark] table.dataTable thead > tr > td.dt-orderable-desc:hover { - outline: var(--dt-order-header_outline-hover); -} - -/*! DataTables Bootstrap 5 integration - * © SpryMedia Ltd - datatables.net/license - */ -:root { - --dt-processing-circle_background: var(--dt_background-selected); + outline: 2px solid rgba(255, 255, 255, 0.05); } div.dt-processing { @@ -265,7 +228,8 @@ div.dt-processing > div:last-child > div { width: 13px; height: 13px; border-radius: 50%; - background: rgb(var(--dt-processing-circle_background)); + background: rgb(13, 110, 253); + background: rgb(var(--dt-row-selected)); animation-timing-function: cubic-bezier(0, 1, 1, 0); } div.dt-processing > div:last-child > div:nth-child(1) { @@ -378,7 +342,7 @@ table.dataTable thead td, table.dataTable tfoot th, table.dataTable tfoot td { text-align: left; - vertical-align: var(--dt-header-cell_vertical-align); + vertical-align: var(--dt-header-vertical-align); } table.dataTable thead th.dt-head-left, table.dataTable thead td.dt-head-left, @@ -461,16 +425,11 @@ table.dataTable tbody td.dt-body-nowrap { white-space: nowrap; } -:root { - --dt_background-selected: 13, 110, 253; -} - -:root[data-bs-theme=dark] { - --dt-row_background-hover: 255, 255, 255; - --dt-row_background-stripe: 255, 255, 255; - --dt-column-ordering_background: 255, 255, 255; -} - +/*! Bootstrap 5 integration for DataTables + * + * ©2020 SpryMedia Ltd, all rights reserved. + * License: MIT datatables.net/license/mit + */ table.table.dataTable { clear: both; margin-bottom: 0; @@ -484,26 +443,31 @@ table.table.dataTable > :not(caption) > * > * { background-color: var(--bs-table-bg); } table.table.dataTable > tbody > tr { - background-color: var(--dt-row_background); + background-color: transparent; } table.table.dataTable > tbody > tr.selected > * { - box-shadow: inset 0 0 0 9999px rgb(var(--dt-row_background-selected)); - color: rgb(var(--dt-row-text_color-selected)); + box-shadow: inset 0 0 0 9999px rgb(13, 110, 253); + box-shadow: inset 0 0 0 9999px rgb(var(--dt-row-selected)); + color: rgb(255, 255, 255); + color: rgb(var(--dt-row-selected-text)); } table.table.dataTable > tbody > tr.selected a { - color: rgb(var(--dt-row-link_color-selected)); + color: rgb(228, 228, 228); + color: rgb(var(--dt-row-selected-link)); } table.table.dataTable.table-striped > tbody > tr:nth-of-type(2n+1) > * { - box-shadow: inset 0 0 0 9999px rgba(var(--dt-row_background-stripe), 0.05); + box-shadow: inset 0 0 0 9999px rgba(var(--dt-row-stripe), 0.05); } table.table.dataTable.table-striped > tbody > tr:nth-of-type(2n+1).selected > * { - box-shadow: inset 0 0 0 9999px rgba(var(--dt-row_background-selected), 0.95); + box-shadow: inset 0 0 0 9999px rgba(13, 110, 253, 0.95); + box-shadow: inset 0 0 0 9999px rgba(var(--dt-row-selected), 0.95); } table.table.dataTable.table-hover > tbody > tr:hover > * { - box-shadow: inset 0 0 0 9999px rgba(var(--dt-row_background-hover), 0.075); + box-shadow: inset 0 0 0 9999px rgba(var(--dt-row-hover), 0.075); } table.table.dataTable.table-hover > tbody > tr.selected:hover > * { - box-shadow: inset 0 0 0 9999px rgba(var(--dt-row_background-selected), 0.975); + box-shadow: inset 0 0 0 9999px rgba(13, 110, 253, 0.975); + box-shadow: inset 0 0 0 9999px rgba(var(--dt-row-selected), 0.975); } div.dt-container div.dt-layout-start > *:not(:last-child) { @@ -636,18 +600,6 @@ table.dataTable.table-sm > thead > tr td.dt-type-date .dt-column-order, table.dataTable.table-sm > thead > tr td.dt-type-numeric .dt-column-order { left: 0.25rem; } -table.dataTable.table-sm > thead > tr th.dt-left .dt-column-order, table.dataTable.table-sm > thead > tr th.dt-head-left .dt-column-order, -table.dataTable.table-sm > thead > tr td.dt-left .dt-column-order, -table.dataTable.table-sm > thead > tr td.dt-head-left .dt-column-order { - left: auto; - right: 0.25rem; -} -table.dataTable.table-sm > thead > tr th.dt-right .dt-column-order, table.dataTable.table-sm > thead > tr th.dt-head-right .dt-column-order, -table.dataTable.table-sm > thead > tr td.dt-right .dt-column-order, -table.dataTable.table-sm > thead > tr td.dt-head-right .dt-column-order { - left: 0.25rem; - right: auto; -} div.dt-scroll-head table.table-bordered { border-bottom-width: 0; @@ -664,4 +616,10 @@ div.table-responsive > div.dt-container > div.row > div[class^=col-]:last-child padding-right: 0; } +:root[data-bs-theme=dark] { + --dt-row-hover: 255, 255, 255; + --dt-row-stripe: 255, 255, 255; + --dt-column-ordering: 255, 255, 255; +} + diff --git a/src/static/scripts/datatables.js b/src/static/scripts/datatables.js index 8dc93bd6..9c7fa042 100644 --- a/src/static/scripts/datatables.js +++ b/src/static/scripts/datatables.js @@ -4,12950 +4,14166 @@ * * To rebuild or modify this file with the latest versions of the included * software please visit: - * https://datatables.net/download/#bs5/dt-3.1.4 + * https://datatables.net/download/#bs5/dt-2.3.7 * * Included libraries: - * DataTables 3.1.4 + * DataTables 2.3.7 */ -/*! DataTables 3.1.4 - * Copyright (c) SpryMedia Ltd - datatables.net/license +/*! DataTables 2.3.7 + * © SpryMedia Ltd - datatables.net/license */ -(function(factory){ - if (typeof define === 'function' && define.amd) { +(function( factory ) { + "use strict"; + + if ( typeof define === 'function' && define.amd ) { // AMD - define([], function () { - return factory(window, document); - }); + define( ['jquery'], function ( $ ) { + return factory( $, window, document ); + } ); } - else if (typeof exports === 'object') { + else if ( typeof exports === 'object' ) { // CommonJS - var cjsRequires = function (root) { }; + // jQuery's factory checks for a global window - if it isn't present then it + // returns a factory function that expects the window object + var jq = require('jquery'); if (typeof window === 'undefined') { - module.exports = function (root) { - if (! root) { + module.exports = function (root, $) { + if ( ! root ) { // CommonJS environments without a window global must pass a // root. This will give an error otherwise root = window; } - cjsRequires(root); - return factory(root, root.document); + if ( ! $ ) { + $ = jq( root ); + } + + return factory( $, root, root.document ); }; } else { - cjsRequires(window); - module.exports = factory(window, window.document); + module.exports = factory( jq, window, window.document ); } } else { // Browser - window.DataTable = factory(window, document); + window.DataTable = factory( jQuery, window, document ); } -}(function(window, document) { -'use strict'; +}(function( $, window, document ) { + "use strict"; + + var DataTable = function ( selector, options ) + { + // Check if called with a window or jQuery object for DOM less applications + // This is for backwards compatibility + if (DataTable.factory(selector, options)) { + return DataTable; + } + + // When creating with `new`, create a new DataTable, returning the API instance + if (this instanceof DataTable) { + return $(selector).DataTable(options); + } + else { + // Argument switching + options = selector; + } + + var _that = this; + var emptyInit = options === undefined; + var len = this.length; + + if ( emptyInit ) { + options = {}; + } + + // Method to get DT API instance from jQuery object + this.api = function () + { + return new _Api( this ); + }; + + this.each(function() { + // For each initialisation we want to give it a clean initialisation + // object that can be bashed around + var o = {}; + var oInit = len > 1 ? // optimisation for single table case + _fnExtend( o, options, true ) : + options; + + + var i=0, iLen; + var sId = this.getAttribute( 'id' ); + var defaults = DataTable.defaults; + var $this = $(this); + + // Sanity check + if ( this.nodeName.toLowerCase() != 'table' ) + { + _fnLog( null, 0, 'Non-table node initialisation ('+this.nodeName+')', 2 ); + return; + } + + // Special case for options + if (oInit.on && oInit.on.options) { + _fnListener($this, 'options', oInit.on.options); + } + + $this.trigger( 'options.dt', oInit ); + + /* Backwards compatibility for the defaults */ + _fnCompatOpts( defaults ); + _fnCompatCols( defaults.column ); + + /* Convert the camel-case defaults to Hungarian */ + _fnCamelToHungarian( defaults, defaults, true ); + _fnCamelToHungarian( defaults.column, defaults.column, true ); + + /* Setting up the initialisation object */ + _fnCamelToHungarian( defaults, $.extend( oInit, _fnEscapeObject($this.data()) ), true ); + + + + /* Check to see if we are re-initialising a table */ + var allSettings = DataTable.settings; + for ( i=0, iLen=allSettings.length ; i'), + fastData: function (row, column, type) { + return _fnGetCellData(oSettings, row, column, type); + } + } ); + oSettings.nTable = this; + oSettings.oInit = oInit; + + allSettings.push( oSettings ); + + // Make a single API instance available for internal handling + oSettings.api = new _Api( oSettings ); + + // Need to add the instance after the instance after the settings object has been added + // to the settings array, so we can self reference the table instance if more than one + oSettings.oInstance = (_that.length===1) ? _that : $this.dataTable(); + + // Backwards compatibility, before we apply all the defaults + _fnCompatOpts( oInit ); + + // If the length menu is given, but the init display length is not, use the length menu + if ( oInit.aLengthMenu && ! oInit.iDisplayLength ) + { + oInit.iDisplayLength = Array.isArray(oInit.aLengthMenu[0]) + ? oInit.aLengthMenu[0][0] + : $.isPlainObject( oInit.aLengthMenu[0] ) + ? oInit.aLengthMenu[0].value + : oInit.aLengthMenu[0]; + } + + // Apply the defaults and init options to make a single init object will all + // options defined from defaults and instance options. + oInit = _fnExtend( $.extend( true, {}, defaults ), oInit ); + + + // Map the initialisation options onto the settings object + _fnMap( oSettings.oFeatures, oInit, [ + "bPaginate", + "bLengthChange", + "bFilter", + "bSort", + "bSortMulti", + "bInfo", + "bProcessing", + "bAutoWidth", + "bSortClasses", + "bServerSide", + "bDeferRender" + ] ); + _fnMap( oSettings, oInit, [ + "ajax", + "fnFormatNumber", + "sServerMethod", + "aaSorting", + "aaSortingFixed", + "aLengthMenu", + "sPaginationType", + "iStateDuration", + "bSortCellsTop", + "iTabIndex", + "sDom", + "fnStateLoadCallback", + "fnStateSaveCallback", + "renderer", + "searchDelay", + "rowId", + "caption", + "layout", + "orderDescReverse", + "orderIndicators", + "orderHandler", + "titleRow", + "typeDetect", + "columnTitleTag", + [ "iCookieDuration", "iStateDuration" ], // backwards compat + [ "oSearch", "oPreviousSearch" ], + [ "aoSearchCols", "aoPreSearchCols" ], + [ "iDisplayLength", "_iDisplayLength" ] + ] ); + _fnMap( oSettings.oScroll, oInit, [ + [ "sScrollX", "sX" ], + [ "sScrollXInner", "sXInner" ], + [ "sScrollY", "sY" ], + [ "bScrollCollapse", "bCollapse" ] + ] ); + _fnMap( oSettings.oLanguage, oInit, "fnInfoCallback" ); + + /* Callback functions which are array driven */ + _fnCallbackReg( oSettings, 'aoDrawCallback', oInit.fnDrawCallback ); + _fnCallbackReg( oSettings, 'aoStateSaveParams', oInit.fnStateSaveParams ); + _fnCallbackReg( oSettings, 'aoStateLoadParams', oInit.fnStateLoadParams ); + _fnCallbackReg( oSettings, 'aoStateLoaded', oInit.fnStateLoaded ); + _fnCallbackReg( oSettings, 'aoRowCallback', oInit.fnRowCallback ); + _fnCallbackReg( oSettings, 'aoRowCreatedCallback', oInit.fnCreatedRow ); + _fnCallbackReg( oSettings, 'aoHeaderCallback', oInit.fnHeaderCallback ); + _fnCallbackReg( oSettings, 'aoFooterCallback', oInit.fnFooterCallback ); + _fnCallbackReg( oSettings, 'aoInitComplete', oInit.fnInitComplete ); + _fnCallbackReg( oSettings, 'aoPreDrawCallback', oInit.fnPreDrawCallback ); + + oSettings.rowIdFn = _fnGetObjectDataFn( oInit.rowId ); + + // Add event listeners + if (oInit.on) { + Object.keys(oInit.on).forEach(function (key) { + _fnListener($this, key, oInit.on[key]); + }); + } + + /* Browser support detection */ + _fnBrowserDetect( oSettings ); + + var oClasses = oSettings.oClasses; + + $.extend( oClasses, DataTable.ext.classes, oInit.oClasses ); + $this.addClass( oClasses.table ); + + if (! oSettings.oFeatures.bPaginate) { + oInit.iDisplayStart = 0; + } + + if ( oSettings.iInitDisplayStart === undefined ) + { + /* Display start point, taking into account the save saving */ + oSettings.iInitDisplayStart = oInit.iDisplayStart; + oSettings._iDisplayStart = oInit.iDisplayStart; + } + + var defer = oInit.iDeferLoading; + if ( defer !== null ) + { + oSettings.deferLoading = true; + + var tmp = Array.isArray(defer); + oSettings._iRecordsDisplay = tmp ? defer[0] : defer; + oSettings._iRecordsTotal = tmp ? defer[1] : defer; + } + + /* + * Columns + * See if we should load columns automatically or use defined ones + */ + var columnsInit = []; + var thead = this.getElementsByTagName('thead'); + var initHeaderLayout = _fnDetectHeader( oSettings, thead[0] ); + + // If we don't have a columns array, then generate one with nulls + if ( oInit.aoColumns ) { + columnsInit = oInit.aoColumns; + } + else if ( initHeaderLayout.length ) { + for ( i=0, iLen=initHeaderLayout[0].length ; i').prependTo( $this ); + } + + caption.html( oSettings.caption ); + } + + // Store the caption side, so we can remove the element from the document + // when creating the element + if (caption.length) { + caption[0]._captionSide = caption.css('caption-side'); + oSettings.captionNode = caption[0]; + } + + // Place the colgroup element in the correct location for the HTML structure + if (caption.length) { + oSettings.colgroup.insertAfter(caption); + } + else { + oSettings.colgroup.prependTo(oSettings.nTable); + } + + if ( thead.length === 0 ) { + thead = $('').appendTo($this); + } + oSettings.nTHead = thead[0]; + + var tbody = $this.children('tbody'); + if ( tbody.length === 0 ) { + tbody = $('').insertAfter(thead); + } + oSettings.nTBody = tbody[0]; + + var tfoot = $this.children('tfoot'); + if ( tfoot.length === 0 ) { + // If we are a scrolling table, and no footer has been given, then we need to create + // a tfoot element for the caption element to be appended to + tfoot = $('').appendTo($this); + } + oSettings.nTFoot = tfoot[0]; + + // Copy the data index array + oSettings.aiDisplay = oSettings.aiDisplayMaster.slice(); + + // Initialisation complete - table can be drawn + oSettings.bInitialised = true; + + // Language definitions + var oLanguage = oSettings.oLanguage; + $.extend( true, oLanguage, oInit.oLanguage ); + + if ( oLanguage.sUrl ) { + // Get the language definitions from a file + $.ajax( { + dataType: 'json', + url: oLanguage.sUrl, + success: function ( json ) { + _fnCamelToHungarian( defaults.oLanguage, json ); + $.extend( true, oLanguage, json, oSettings.oInit.oLanguage ); + + _fnCallbackFire( oSettings, null, 'i18n', [oSettings], true); + _fnInitialise( oSettings ); + }, + error: function () { + // Error occurred loading language file + _fnLog( oSettings, 0, 'i18n file loading error', 21 ); + + // Continue on as best we can + _fnInitialise( oSettings ); + } + } ); + } + else { + _fnCallbackFire( oSettings, null, 'i18n', [oSettings], true); + _fnInitialise( oSettings ); + } + } ); + _that = null; + return this; + }; + + + + /** + * DataTables extensions + * + * This namespace acts as a collection area for plug-ins that can be used to + * extend DataTables capabilities. Indeed many of the build in methods + * use this method to provide their own capabilities (sorting methods for + * example). + * + * Note that this namespace is aliased to `jQuery.fn.dataTableExt` for legacy + * reasons + * + * @namespace + */ + DataTable.ext = _ext = { + /** + * DataTables build type (expanded by the download builder) + * + * @type string + */ + builder: "bs5/dt-2.3.7", + + /** + * Buttons. For use with the Buttons extension for DataTables. This is + * defined here so other extensions can define buttons regardless of load + * order. It is _not_ used by DataTables core. + * + * @type object + * @default {} + */ + buttons: {}, + + + /** + * ColumnControl buttons and content + * + * @type object + */ + ccContent: {}, + + + /** + * Element class names + * + * @type object + * @default {} + */ + classes: {}, + + + /** + * Error reporting. + * + * How should DataTables report an error. Can take the value 'alert', + * 'throw', 'none' or a function. + * + * @type string|function + * @default alert + */ + errMode: "alert", + + /** HTML entity escaping */ + escape: { + /** When reading data-* attributes for initialisation options */ + attributes: false + }, + + /** + * Legacy so v1 plug-ins don't throw js errors on load + */ + feature: [], + + /** + * Feature plug-ins. + * + * This is an object of callbacks which provide the features for DataTables + * to be initialised via the `layout` option. + */ + features: {}, + + + /** + * Row searching. + * + * This method of searching is complimentary to the default type based + * searching, and a lot more comprehensive as it allows you complete control + * over the searching logic. Each element in this array is a function + * (parameters described below) that is called for every row in the table, + * and your logic decides if it should be included in the searching data set + * or not. + * + * Searching functions have the following input parameters: + * + * 1. `{object}` DataTables settings object: see + * {@link DataTable.models.oSettings} + * 2. `{array|object}` Data for the row to be processed (same as the + * original format that was passed in as the data source, or an array + * from a DOM data source + * 3. `{int}` Row index ({@link DataTable.models.oSettings.aoData}), which + * can be useful to retrieve the `TR` element if you need DOM interaction. + * + * And the following return is expected: + * + * * {boolean} Include the row in the searched result set (true) or not + * (false) + * + * Note that as with the main search ability in DataTables, technically this + * is "filtering", since it is subtractive. However, for consistency in + * naming we call it searching here. + * + * @type array + * @default [] + * + * @example + * // The following example shows custom search being applied to the + * // fourth column (i.e. the data[3] index) based on two input values + * // from the end-user, matching the data in a certain range. + * $.fn.dataTable.ext.search.push( + * function( settings, data, dataIndex ) { + * var min = document.getElementById('min').value * 1; + * var max = document.getElementById('max').value * 1; + * var version = data[3] == "-" ? 0 : data[3]*1; + * + * if ( min == "" && max == "" ) { + * return true; + * } + * else if ( min == "" && version < max ) { + * return true; + * } + * else if ( min < version && "" == max ) { + * return true; + * } + * else if ( min < version && version < max ) { + * return true; + * } + * return false; + * } + * ); + */ + search: [], + + + /** + * Selector extensions + * + * The `selector` option can be used to extend the options available for the + * selector modifier options (`selector-modifier` object data type) that + * each of the three built in selector types offer (row, column and cell + + * their plural counterparts). For example the Select extension uses this + * mechanism to provide an option to select only rows, columns and cells + * that have been marked as selected by the end user (`{selected: true}`), + * which can be used in conjunction with the existing built in selector + * options. + * + * Each property is an array to which functions can be pushed. The functions + * take three attributes: + * + * * Settings object for the host table + * * Options object (`selector-modifier` object type) + * * Array of selected item indexes + * + * The return is an array of the resulting item indexes after the custom + * selector has been applied. + * + * @type object + */ + selector: { + cell: [], + column: [], + row: [] + }, + + + /** + * Legacy configuration options. Enable and disable legacy options that + * are available in DataTables. + * + * @type object + */ + legacy: { + /** + * Enable / disable DataTables 1.9 compatible server-side processing + * requests + * + * @type boolean + * @default null + */ + ajax: null + }, + + + /** + * Pagination plug-in methods. + * + * Each entry in this object is a function and defines which buttons should + * be shown by the pagination rendering method that is used for the table: + * {@link DataTable.ext.renderer.pageButton}. The renderer addresses how the + * buttons are displayed in the document, while the functions here tell it + * what buttons to display. This is done by returning an array of button + * descriptions (what each button will do). + * + * Pagination types (the four built in options and any additional plug-in + * options defined here) can be used through the `paginationType` + * initialisation parameter. + * + * The functions defined take two parameters: + * + * 1. `{int} page` The current page index + * 2. `{int} pages` The number of pages in the table + * + * Each function is expected to return an array where each element of the + * array can be one of: + * + * * `first` - Jump to first page when activated + * * `last` - Jump to last page when activated + * * `previous` - Show previous page when activated + * * `next` - Show next page when activated + * * `{int}` - Show page of the index given + * * `{array}` - A nested array containing the above elements to add a + * containing 'DIV' element (might be useful for styling). + * + * Note that DataTables v1.9- used this object slightly differently whereby + * an object with two functions would be defined for each plug-in. That + * ability is still supported by DataTables 1.10+ to provide backwards + * compatibility, but this option of use is now decremented and no longer + * documented in DataTables 1.10+. + * + * @type object + * @default {} + * + * @example + * // Show previous, next and current page buttons only + * $.fn.dataTableExt.oPagination.current = function ( page, pages ) { + * return [ 'previous', page, 'next' ]; + * }; + */ + pager: {}, + + + renderer: { + pageButton: {}, + header: {} + }, + + + /** + * Ordering plug-ins - custom data source + * + * The extension options for ordering of data available here is complimentary + * to the default type based ordering that DataTables typically uses. It + * allows much greater control over the data that is being used to + * order a column, but is necessarily therefore more complex. + * + * This type of ordering is useful if you want to do ordering based on data + * live from the DOM (for example the contents of an 'input' element) rather + * than just the static string that DataTables knows of. + * + * The way these plug-ins work is that you create an array of the values you + * wish to be ordering for the column in question and then return that + * array. The data in the array much be in the index order of the rows in + * the table (not the currently ordering order!). Which order data gathering + * function is run here depends on the `dt-init columns.orderDataType` + * parameter that is used for the column (if any). + * + * The functions defined take two parameters: + * + * 1. `{object}` DataTables settings object: see + * {@link DataTable.models.oSettings} + * 2. `{int}` Target column index + * + * Each function is expected to return an array: + * + * * `{array}` Data for the column to be ordering upon + * + * @type array + * + * @example + * // Ordering using `input` node values + * $.fn.dataTable.ext.order['dom-text'] = function ( settings, col ) + * { + * return this.api().column( col, {order:'index'} ).nodes().map( function ( td, i ) { + * return $('input', td).val(); + * } ); + * } + */ + order: {}, + + + /** + * Type based plug-ins. + * + * Each column in DataTables has a type assigned to it, either by automatic + * detection or by direct assignment using the `type` option for the column. + * The type of a column will effect how it is ordering and search (plug-ins + * can also make use of the column type if required). + * + * @namespace + */ + type: { + /** + * Automatic column class assignment + */ + className: {}, + + /** + * Type detection functions. + * + * The functions defined in this object are used to automatically detect + * a column's type, making initialisation of DataTables super easy, even + * when complex data is in the table. + * + * The functions defined take two parameters: + * + * 1. `{*}` Data from the column cell to be analysed + * 2. `{settings}` DataTables settings object. This can be used to + * perform context specific type detection - for example detection + * based on language settings such as using a comma for a decimal + * place. Generally speaking the options from the settings will not + * be required + * + * Each function is expected to return: + * + * * `{string|null}` Data type detected, or null if unknown (and thus + * pass it on to the other type detection functions. + * + * @type array + * + * @example + * // Currency type detection plug-in: + * $.fn.dataTable.ext.type.detect.push( + * function ( data, settings ) { + * // Check the numeric part + * if ( ! data.substring(1).match(/[0-9]/) ) { + * return null; + * } + * + * // Check prefixed by currency + * if ( data.charAt(0) == '$' || data.charAt(0) == '£' ) { + * return 'currency'; + * } + * return null; + * } + * ); + */ + detect: [], + + /** + * Automatic renderer assignment + */ + render: {}, + + + /** + * Type based search formatting. + * + * The type based searching functions can be used to pre-format the + * data to be search on. For example, it can be used to strip HTML + * tags or to de-format telephone numbers for numeric only searching. + * + * Note that is a search is not defined for a column of a given type, + * no search formatting will be performed. + * + * Pre-processing of searching data plug-ins - When you assign the sType + * for a column (or have it automatically detected for you by DataTables + * or a type detection plug-in), you will typically be using this for + * custom sorting, but it can also be used to provide custom searching + * by allowing you to pre-processing the data and returning the data in + * the format that should be searched upon. This is done by adding + * functions this object with a parameter name which matches the sType + * for that target column. This is the corollary of afnSortData + * for searching data. + * + * The functions defined take a single parameter: + * + * 1. `{*}` Data from the column cell to be prepared for searching + * + * Each function is expected to return: + * + * * `{string|null}` Formatted string that will be used for the searching. + * + * @type object + * @default {} + * + * @example + * $.fn.dataTable.ext.type.search['title-numeric'] = function ( d ) { + * return d.replace(/\n/g," ").replace( /<.*?>/g, "" ); + * } + */ + search: {}, + + + /** + * Type based ordering. + * + * The column type tells DataTables what ordering to apply to the table + * when a column is sorted upon. The order for each type that is defined, + * is defined by the functions available in this object. + * + * Each ordering option can be described by three properties added to + * this object: + * + * * `{type}-pre` - Pre-formatting function + * * `{type}-asc` - Ascending order function + * * `{type}-desc` - Descending order function + * + * All three can be used together, only `{type}-pre` or only + * `{type}-asc` and `{type}-desc` together. It is generally recommended + * that only `{type}-pre` is used, as this provides the optimal + * implementation in terms of speed, although the others are provided + * for compatibility with existing JavaScript sort functions. + * + * `{type}-pre`: Functions defined take a single parameter: + * + * 1. `{*}` Data from the column cell to be prepared for ordering + * + * And return: + * + * * `{*}` Data to be sorted upon + * + * `{type}-asc` and `{type}-desc`: Functions are typical JavaScript sort + * functions, taking two parameters: + * + * 1. `{*}` Data to compare to the second parameter + * 2. `{*}` Data to compare to the first parameter + * + * And returning: + * + * * `{*}` Ordering match: <0 if first parameter should be sorted lower + * than the second parameter, ===0 if the two parameters are equal and + * >0 if the first parameter should be sorted height than the second + * parameter. + * + * @type object + * @default {} + * + * @example + * // Numeric ordering of formatted numbers with a pre-formatter + * $.extend( $.fn.dataTable.ext.type.order, { + * "string-pre": function(x) { + * a = (a === "-" || a === "") ? 0 : a.replace( /[^\d\-\.]/g, "" ); + * return parseFloat( a ); + * } + * } ); + * + * @example + * // Case-sensitive string ordering, with no pre-formatting method + * $.extend( $.fn.dataTable.ext.order, { + * "string-case-asc": function(x,y) { + * return ((x < y) ? -1 : ((x > y) ? 1 : 0)); + * }, + * "string-case-desc": function(x,y) { + * return ((x < y) ? 1 : ((x > y) ? -1 : 0)); + * } + * } ); + */ + order: {} + }, + + /** + * Unique DataTables instance counter + * + * @type int + * @private + */ + _unique: 0, + + + // + // Depreciated + // The following properties are retained for backwards compatibility only. + // The should not be used in new projects and will be removed in a future + // version + // + + /** + * Version check function. + * @type function + * @depreciated Since 1.10 + */ + fnVersionCheck: DataTable.fnVersionCheck, + + + /** + * Index for what 'this' index API functions should use + * @type int + * @deprecated Since v1.10 + */ + iApiIndex: 0, + + + /** + * Software version + * @type string + * @deprecated Since v1.10 + */ + sVersion: DataTable.version + }; + + + // + // Backwards compatibility. Alias to pre 1.10 Hungarian notation counter parts + // + $.extend( _ext, { + afnFiltering: _ext.search, + aTypes: _ext.type.detect, + ofnSearch: _ext.type.search, + oSort: _ext.type.order, + afnSortData: _ext.order, + aoFeatures: _ext.feature, + oStdClasses: _ext.classes, + oPagination: _ext.pager + } ); + + + $.extend( DataTable.ext.classes, { + container: 'dt-container', + empty: { + row: 'dt-empty' + }, + info: { + container: 'dt-info' + }, + layout: { + row: 'dt-layout-row', + cell: 'dt-layout-cell', + tableRow: 'dt-layout-table', + tableCell: '', + start: 'dt-layout-start', + end: 'dt-layout-end', + full: 'dt-layout-full' + }, + length: { + container: 'dt-length', + select: 'dt-input' + }, + order: { + canAsc: 'dt-orderable-asc', + canDesc: 'dt-orderable-desc', + isAsc: 'dt-ordering-asc', + isDesc: 'dt-ordering-desc', + none: 'dt-orderable-none', + position: 'sorting_' + }, + processing: { + container: 'dt-processing' + }, + scrolling: { + body: 'dt-scroll-body', + container: 'dt-scroll', + footer: { + self: 'dt-scroll-foot', + inner: 'dt-scroll-footInner' + }, + header: { + self: 'dt-scroll-head', + inner: 'dt-scroll-headInner' + } + }, + search: { + container: 'dt-search', + input: 'dt-input' + }, + table: 'dataTable', + tbody: { + cell: '', + row: '' + }, + thead: { + cell: '', + row: '' + }, + tfoot: { + cell: '', + row: '' + }, + paging: { + active: 'current', + button: 'dt-paging-button', + container: 'dt-paging', + disabled: 'disabled', + nav: '' + } + } ); + + + /* + * It is useful to have variables which are scoped locally so only the + * DataTables functions can access them and they don't leak into global space. + * At the same time these functions are often useful over multiple files in the + * core and API, so we list, or at least document, all variables which are used + * by DataTables as private variables here. This also ensures that there is no + * clashing of variable names and that they can easily referenced for reuse. + */ + + + // Defined else where + // _selector_run + // _selector_opts + // _selector_row_indexes + + var _ext; // DataTable.ext + var _Api; // DataTable.Api + var _api_register; // DataTable.Api.register + var _api_registerPlural; // DataTable.Api.registerPlural + + var _re_dic = {}; + var _re_new_lines = /[\r\n\u2028]/g; + var _re_html = /<([^>]*>)/g; + var _max_str_len = Math.pow(2, 28); + + // This is not strict ISO8601 - Date.parse() is quite lax, although + // implementations differ between browsers. + var _re_date = /^\d{2,4}[./-]\d{1,2}[./-]\d{1,2}([T ]{1}\d{1,2}[:.]\d{2}([.:]\d{2})?)?$/; + + // Escape regular expression special characters + var _re_escape_regex = new RegExp( '(\\' + [ '/', '.', '*', '+', '?', '|', '(', ')', '[', ']', '{', '}', '\\', '$', '^', '-' ].join('|\\') + ')', 'g' ); + + // https://en.wikipedia.org/wiki/Foreign_exchange_market + // - \u20BD - Russian ruble. + // - \u20a9 - South Korean Won + // - \u20BA - Turkish Lira + // - \u20B9 - Indian Rupee + // - R - Brazil (R$) and South Africa + // - fr - Swiss Franc + // - kr - Swedish krona, Norwegian krone and Danish krone + // - \u2009 is thin space and \u202F is narrow no-break space, both used in many + // - Ƀ - Bitcoin + // - Ξ - Ethereum + // standards as thousands separators. + var _re_formatted_numeric = /['\u00A0,$£€¥%\u2009\u202F\u20BD\u20a9\u20BArfkɃΞ]/gi; + + + var _empty = function ( d ) { + return !d || d === true || d === '-' ? true : false; + }; + + + var _intVal = function ( s ) { + var integer = parseInt( s, 10 ); + return !isNaN(integer) && isFinite(s) ? integer : null; + }; + + // Convert from a formatted number with characters other than `.` as the + // decimal place, to a JavaScript number + var _numToDecimal = function ( num, decimalPoint ) { + // Cache created regular expressions for speed as this function is called often + if ( ! _re_dic[ decimalPoint ] ) { + _re_dic[ decimalPoint ] = new RegExp( _fnEscapeRegex( decimalPoint ), 'g' ); + } + return typeof num === 'string' && decimalPoint !== '.' ? + num.replace( /\./g, '' ).replace( _re_dic[ decimalPoint ], '.' ) : + num; + }; + + + var _isNumber = function ( d, decimalPoint, formatted, allowEmpty ) { + var type = typeof d; + var strType = type === 'string'; + + if ( type === 'number' || type === 'bigint') { + return true; + } + + // If empty return immediately so there must be a number if it is a + // formatted string (this stops the string "k", or "kr", etc being detected + // as a formatted number for currency + if ( allowEmpty && _empty( d ) ) { + return true; + } + + if ( decimalPoint && strType ) { + d = _numToDecimal( d, decimalPoint ); + } + + if ( formatted && strType ) { + d = d.replace( _re_formatted_numeric, '' ); + } + + return !isNaN( parseFloat(d) ) && isFinite( d ); + }; + + + // A string without HTML in it can be considered to be HTML still + var _isHtml = function ( d ) { + return _empty( d ) || typeof d === 'string'; + }; + + // Is a string a number surrounded by HTML? + var _htmlNumeric = function ( d, decimalPoint, formatted, allowEmpty ) { + if ( allowEmpty && _empty( d ) ) { + return true; + } + + // input and select strings mean that this isn't just a number + if (typeof d === 'string' && d.match(/<(input|select)/i)) { + return null; + } + + var html = _isHtml( d ); + return ! html ? + null : + _isNumber( _stripHtml( d ), decimalPoint, formatted, allowEmpty ) ? + true : + null; + }; + + + var _pluck = function ( a, prop, prop2 ) { + var out = []; + var i=0, iLen=a.length; + + // Could have the test in the loop for slightly smaller code, but speed + // is essential here + if ( prop2 !== undefined ) { + for ( ; i _max_str_len) { + throw new Error('Exceeded max str len'); + } + + var previous; + + input = input.replace(_re_html, replacement || ''); // Complete tags + + // Safety for incomplete script tag - use do / while to ensure that + // we get all instances + do { + previous = input; + input = input.replace(/ diff --git a/src/static/templates/admin/settings.hbs b/src/static/templates/admin/settings.hbs index cd5e3ca2..fb066cb4 100644 --- a/src/static/templates/admin/settings.hbs +++ b/src/static/templates/admin/settings.hbs @@ -1,4 +1,4 @@ -
+
You are using a plain text `ADMIN_TOKEN` which is insecure.
diff --git a/src/static/templates/admin/users.hbs b/src/static/templates/admin/users.hbs index 22d8205c..73f0cbc8 100644 --- a/src/static/templates/admin/users.hbs +++ b/src/static/templates/admin/users.hbs @@ -1,4 +1,4 @@ -
+
Registered Users
@@ -14,7 +14,7 @@ Entries Attachments Organizations - Actions + Actions @@ -43,14 +43,14 @@ {{#if ../sso_enabled}} - {{sso_identifier}} + {{sso_identifier}} {{/if}} - {{created_at}} + {{created_at}} - {{last_active}} + {{last_active}} {{cipher_count}} @@ -135,11 +135,6 @@
- {{#if custom_roles_enabled}} -
- -
- {{/if}}
@@ -158,6 +153,7 @@
+ diff --git a/src/static/templates/email/admin_account_recovery.hbs b/src/static/templates/email/admin_account_recovery.hbs deleted file mode 100644 index a35a1d05..00000000 --- a/src/static/templates/email/admin_account_recovery.hbs +++ /dev/null @@ -1,12 +0,0 @@ -Admin account recovery from {{org_name}} organization - -{{#if reset_password}} -The master password for {{user_name}} has been changed. -{{/if}} -{{#if reset_2fa}} -Your two-step verification providers have been reset.{{#if fallback_2fa_email}} Email two factor has been activated as a fallback.{{/if}} -{{/if}} - -If you did not initiate this request, please reach out to your administrator immediately. - -{{> email/email_footer_text }} diff --git a/src/static/templates/email/admin_reset_password.hbs b/src/static/templates/email/admin_reset_password.hbs new file mode 100644 index 00000000..f70423f1 --- /dev/null +++ b/src/static/templates/email/admin_reset_password.hbs @@ -0,0 +1,4 @@ +Master Password Has Been Changed + +The master password for {{user_name}} has been changed by an administrator in your {{org_name}} organization. If you did not initiate this request, please reach out to your administrator immediately. +{{> email/email_footer_text }} diff --git a/src/static/templates/email/admin_account_recovery.html.hbs b/src/static/templates/email/admin_reset_password.html.hbs similarity index 55% rename from src/static/templates/email/admin_account_recovery.html.hbs rename to src/static/templates/email/admin_reset_password.html.hbs index cf8eebed..d9749d22 100644 --- a/src/static/templates/email/admin_account_recovery.html.hbs +++ b/src/static/templates/email/admin_reset_password.html.hbs @@ -1,17 +1,10 @@ -Admin account recovery from {{org_name}} organization +Master Password Has Been Changed {{> email/email_header }}
- {{#if reset_password}} - The master password for {{user_name}} has been changed. - {{/if}} - {{#if reset_2fa}} - Your two-step verification providers have been reset.{{#if fallback_2fa_email}} Email two factor has been activated as a fallback.{{/if}} - {{/if}} -
- If you did not initiate this request, please reach out to your administrator immediately. + The master password for {{user_name}} has been changed by an administrator in your {{org_name}} organization. If you did not initiate this request, please reach out to your administrator immediately.
diff --git a/src/static/templates/email/recover_twofactor.hbs b/src/static/templates/email/recover_twofactor.hbs deleted file mode 100644 index 92e4330a..00000000 --- a/src/static/templates/email/recover_twofactor.hbs +++ /dev/null @@ -1,9 +0,0 @@ -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 }} diff --git a/src/static/templates/email/recover_twofactor.html.hbs b/src/static/templates/email/recover_twofactor.html.hbs deleted file mode 100644 index 847cbae0..00000000 --- a/src/static/templates/email/recover_twofactor.html.hbs +++ /dev/null @@ -1,26 +0,0 @@ -Two-Step Login Recovered From {{ip}} - -{{> email/email_header }} - - - - - - - - - - - - - -
- 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 }} diff --git a/src/static/templates/email/send_emergency_access_invite.html.hbs b/src/static/templates/email/send_emergency_access_invite.html.hbs index b5ab3a07..5318378c 100644 --- a/src/static/templates/email/send_emergency_access_invite.html.hbs +++ b/src/static/templates/email/send_emergency_access_invite.html.hbs @@ -9,7 +9,7 @@ Emergency access for {{{grantor_name}}} - Become emergency contact diff --git a/src/static/templates/scss/vaultwarden.scss.hbs b/src/static/templates/scss/vaultwarden.scss.hbs index fc6d22c6..230ac2e7 100644 --- a/src/static/templates/scss/vaultwarden.scss.hbs +++ b/src/static/templates/scss/vaultwarden.scss.hbs @@ -42,27 +42,45 @@ a[href$="/settings/sponsored-families"] { {{/if}} /* Hide the `Enterprise Single Sign-On` button on the login page */ +{{#if (webver ">=2025.5.1")}} {{#if (not sso_enabled)}} .vw-sso-login { @extend %vw-hide; } {{/if}} +{{else}} +app-root ng-component > form > div:nth-child(1) > div > button[buttontype="secondary"].\!tw-text-primary-600:nth-child(4) { + @extend %vw-hide; +} +{{/if}} /* Hide the `Log in with passkey` settings */ app-user-layout app-password-settings app-webauthn-login-settings { @extend %vw-hide; } /* Hide Log in with passkey on the login page */ +{{#if (webver ">=2025.5.1")}} .vw-passkey-login { @extend %vw-hide; } +{{else}} +app-root ng-component > form > div:nth-child(1) > div > button[buttontype="secondary"].\!tw-text-primary-600:nth-child(3) { + @extend %vw-hide; +} +{{/if}} /* Hide the or text followed by the two buttons hidden above */ +{{#if (webver ">=2025.5.1")}} {{#if (or (not sso_enabled) sso_only)}} .vw-or-text { @extend %vw-hide; } {{/if}} +{{else}} +app-root ng-component > form > div:nth-child(1) > div:nth-child(3) > div:nth-child(2) { + @extend %vw-hide; +} +{{/if}} /* Hide the `Other` button on the login page */ {{#if (or (not sso_enabled) sso_only)}} @@ -98,17 +116,10 @@ app-security > app-two-factor-setup > form { } /* Hide unsupported Custom Role options */ -{{#if custom_roles_enabled}} -:is(bit-dialog, [bit-dialog]) div.tw-ml-4:has(bit-form-control input), -:is(bit-dialog, [bit-dialog]) div.tw-col-span-4:has(input[formcontrolname*="access"], input[formcontrolname*="manage"]) { +bit-dialog div.tw-ml-4:has(bit-form-control input), +bit-dialog div.tw-col-span-4:has(input[formcontrolname*="access"], input[formcontrolname*="manage"]) { @extend %vw-hide; } -{{else}} -:is(bit-dialog, [bit-dialog]) div.tw-ml-4:has(bit-form-control input), -:is(bit-dialog, [bit-dialog]) div.tw-col-span-4:has(input[formcontrolname*="access"], input[formcontrolname*="manage"]) { - @extend %vw-hide; -} -{{/if}} /* Change collapsed menu icon to Vaultwarden */ bit-nav-logo bit-nav-item a:before { @@ -126,14 +137,6 @@ bit-nav-logo bit-nav-item .bwi-shield { app-user-layout app-danger-zone button:nth-child(1) { @extend %vw-hide; } - -/* Hide unsupported Forwarding email alias options */ -ng-dropdown-panel div.ng-dropdown-panel-items div:has(> [title="Firefox Relay"]) { - @extend %vw-hide; -} -ng-dropdown-panel div.ng-dropdown-panel-items div:has(> [title="DuckDuckGo"]) { - @extend %vw-hide; -} /**** END Static Vaultwarden Changes ****/ /**** START Dynamic Vaultwarden Changes ****/ {{#if signup_disabled}} @@ -143,10 +146,17 @@ ng-dropdown-panel div.ng-dropdown-panel-items div:has(> [title="DuckDuckGo"]) { take a while for the change to take effect. To avoid the button appearing when it shouldn't, we'll keep this style in place for a couple of versions */ /* Hide the register link on the login screen */ +{{#if (webver "<2025.3.0")}} +app-login form div + div + div + div + hr, +app-login form div + div + div + div + hr + p { + @extend %vw-hide; +} +{{else}} app-root a[routerlink="/signup"] { @extend %vw-hide; } {{/if}} +{{/if}} {{#if remember_2fa_disabled}} /* Hide checkbox to remember 2FA token for 30 days */ @@ -202,13 +212,6 @@ auth-input-password > form > bit-form-field:nth-child(4) { @extend %vw-hide; } {{/unless}} - -{{#unless email_change_allowed}} -/* Hide the whole `Change email` section, including its heading, if not allowed */ -div:has(> app-change-email) { - @extend %vw-hide; -} -{{/unless}} /**** End Dynamic Vaultwarden Changes ****/ /**** Include a special user stylesheet for custom changes ****/ {{#if load_user_scss}} diff --git a/src/storage.rs b/src/storage.rs deleted file mode 100644 index 32562a0d..00000000 --- a/src/storage.rs +++ /dev/null @@ -1,302 +0,0 @@ -use std::sync::LazyLock; - -pub(crate) fn join_path(base: &str, child: &str) -> String { - #[cfg(s3)] - if s3::is_uri(base) { - return s3::join_path(base, child); - } - - let base = base.trim_end_matches('/'); - let child = child.trim_start_matches('/'); - if base.is_empty() { - child.to_owned() - } else if child.is_empty() { - base.to_owned() - } else { - format!("{base}/{child}") - } -} - -pub(crate) fn with_extension(path: &str, extension: &str) -> String { - let extension = extension.trim_start_matches('.'); - - #[cfg(s3)] - if s3::is_uri(path) { - return s3::with_extension(path, extension); - } - - format!("{path}.{extension}") -} - -pub(crate) fn parent(path: &str) -> Option { - #[cfg(s3)] - if s3::is_uri(path) { - return s3::parent(path); - } - - std::path::Path::new(path).parent()?.to_str().map(str::to_owned) -} - -pub(crate) fn file_name(path: &str) -> Option { - #[cfg(s3)] - if s3::is_uri(path) { - return s3::file_name(path); - } - - std::path::Path::new(path).file_name()?.to_str().map(str::to_owned) -} - -pub(crate) fn is_fs_operator(operator: &opendal::Operator) -> bool { - operator.info().scheme() == opendal::services::FS_SCHEME -} - -pub(crate) fn operator_for_path(path: &str) -> Result { - // Cache of previously built operators by path - static OPERATORS_BY_PATH: LazyLock> = - LazyLock::new(dashmap::DashMap::new); - - if let Some(operator) = OPERATORS_BY_PATH.get(path) { - return Ok(operator.clone()); - } - - let operator = if path.starts_with("s3://") { - #[cfg(not(s3))] - return Err(opendal::Error::new(opendal::ErrorKind::ConfigInvalid, "S3 support is not enabled").into()); - - #[cfg(s3)] - s3::operator_for_path(path)? - } else { - let builder = opendal::services::Fs::default().root(path); - opendal::Operator::new(builder)? - }; - - OPERATORS_BY_PATH.insert(path.to_owned(), operator.clone()); - - Ok(operator) -} - -#[cfg(s3)] -mod s3 { - use std::sync::LazyLock; - - use opendal_http_transport_reqwest::ReqwestTransport; - use reqwest::Url; - - use crate::error::Error; - - static HTTP_CLIENT: LazyLock = LazyLock::new(|| { - // Storage endpoints are administrator-configured and may be private. - crate::http_client::get_reqwest_client_builder(false).build().expect("Failed to build OpenDAL HTTP client") - }); - - pub(super) fn is_uri(path: &str) -> bool { - path.starts_with("s3://") - } - - pub(super) fn join_path(base: &str, child: &str) -> String { - if let Ok(mut url) = Url::parse(base) { - let mut segments = path_segments(&url); - segments.extend(child.split('/').filter(|segment| !segment.is_empty()).map(str::to_owned)); - set_path_segments(&mut url, &segments); - return url.to_string(); - } - - let base = base.trim_end_matches('/'); - let child = child.trim_start_matches('/'); - if base.is_empty() { - child.to_owned() - } else if child.is_empty() { - base.to_owned() - } else { - format!("{base}/{child}") - } - } - - pub(super) fn with_extension(path: &str, extension: &str) -> String { - if let Ok(mut url) = Url::parse(path) { - let mut segments = path_segments(&url); - if let Some(file_name) = segments.last_mut() { - file_name.push('.'); - file_name.push_str(extension); - set_path_segments(&mut url, &segments); - return url.to_string(); - } - } - - format!("{path}.{extension}") - } - - pub(super) fn parent(path: &str) -> Option { - if let Ok(mut url) = Url::parse(path) { - let mut segments = path_segments(&url); - segments.pop()?; - set_path_segments(&mut url, &segments); - return Some(url.to_string()); - } - - std::path::Path::new(path).parent()?.to_str().map(str::to_owned) - } - - pub(super) fn file_name(path: &str) -> Option { - if let Ok(url) = Url::parse(path) { - return path_segments(&url).pop(); - } - - std::path::Path::new(path).file_name()?.to_str().map(str::to_owned) - } - - fn path_segments(url: &Url) -> Vec { - url.path_segments() - .map(|segments| segments.filter(|segment| !segment.is_empty()).map(str::to_owned).collect()) - .unwrap_or_default() - } - - fn set_path_segments(url: &mut Url, segments: &[String]) { - if segments.is_empty() { - url.set_path(""); - } else { - url.set_path(&format!("/{}", segments.join("/"))); - } - } - - pub(super) fn operator_for_path(path: &str) -> Result { - use crate::http_client::aws::AwsReqwestConnector; - use aws_config::{default_provider::credentials::DefaultCredentialsChain, provider_config::ProviderConfig}; - use opendal::Configurator; - use reqsign_aws_v4::Credential; - use reqsign_core::{Context, ProvideCredential, ProvideCredentialChain}; - - // This is a custom AWS credential loader that uses the official AWS Rust - // SDK config crate to load credentials. This ensures maximum compatibility - // with AWS credential configurations. For example, OpenDAL doesn't support - // AWS SSO temporary credentials yet. - #[derive(Debug)] - struct OpenDALS3CredentialProvider; - - impl ProvideCredential for OpenDALS3CredentialProvider { - type Credential = Credential; - - async fn provide_credential(&self, _ctx: &Context) -> reqsign_core::Result> { - use aws_credential_types::provider::ProvideCredentials as _; - use reqsign_core::time::Timestamp; - use tokio::sync::OnceCell; - - static DEFAULT_CREDENTIAL_CHAIN: OnceCell = OnceCell::const_new(); - - let chain = DEFAULT_CREDENTIAL_CHAIN - .get_or_init(|| { - let conf = ProviderConfig::default().with_http_client(AwsReqwestConnector::new()); - - DefaultCredentialsChain::builder().configure(conf).build() - }) - .await; - - let creds = chain.provide_credentials().await.map_err(|e| { - reqsign_core::Error::unexpected("failed to load AWS credentials via AWS SDK").with_source(e) - })?; - - let expires_in = if let Some(expiration) = creds.expiry() { - let duration = expiration.duration_since(std::time::UNIX_EPOCH).map_err(|e| { - reqsign_core::Error::unexpected("AWS credential expiration is before the Unix epoch") - .with_source(e) - })?; - let seconds = i64::try_from(duration.as_secs()).map_err(|e| { - reqsign_core::Error::unexpected("AWS credential expiration is too large").with_source(e) - })?; - Some(Timestamp::from_second(seconds)?) - } else { - None - }; - - Ok(Some(Credential { - access_key_id: creds.access_key_id().to_owned(), - secret_access_key: creds.secret_access_key().to_owned(), - session_token: creds.session_token().map(ToOwned::to_owned), - expires_in, - })) - } - } - - let uri = opendal::OperatorUri::new(path, std::iter::empty::<(String, String)>())?; - let mut config = opendal::services::S3Config::from_uri(&uri)?; - - if !uri_has_option(&uri, &["default_storage_class"]) { - config.default_storage_class = Some("INTELLIGENT_TIERING".to_owned()); - } - - if !uri_has_option( - &uri, - &["enable_virtual_host_style", "aws_virtual_hosted_style_request", "virtual_hosted_style_request"], - ) { - config.enable_virtual_host_style = true; - } - - let use_aws_sdk_credentials = !uri_has_credential_options(&uri, &config); - let mut builder = config.into_builder(); - - if use_aws_sdk_credentials { - builder = - builder.credential_provider_chain(ProvideCredentialChain::new().push(OpenDALS3CredentialProvider)); - } - - let http_transport = opendal::HttpTransporter::new(ReqwestTransport::new(HTTP_CLIENT.clone())); - let context = opendal::OperationContext::new().with_http_transport(http_transport); - Ok(opendal::Operator::new(builder)?.with_context(context)) - } - - fn uri_has_option(uri: &opendal::OperatorUri, names: &[&str]) -> bool { - names.iter().any(|name| uri.options().contains_key(*name)) - } - - fn uri_has_credential_options(uri: &opendal::OperatorUri, config: &opendal::services::S3Config) -> bool { - config.access_key_id.is_some() - || config.secret_access_key.is_some() - || config.session_token.is_some() - || config.role_arn.is_some() - || config.external_id.is_some() - || config.role_session_name.is_some() - || uri_has_option(uri, &["allow_anonymous", "disable_config_load", "disable_ec2_metadata"]) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn handles_local_paths() { - assert_eq!(join_path("data", "attachments"), "data/attachments"); - assert_eq!(with_extension("data/rsa_key", "pem"), "data/rsa_key.pem"); - assert_eq!(parent("data/rsa_key.pem").as_deref(), Some("data")); - assert_eq!(file_name("data/rsa_key.pem").as_deref(), Some("rsa_key.pem")); - } -} - -#[cfg(all(test, s3))] -mod s3_tests { - use super::*; - - #[test] - fn joins_s3_path_before_query_string() { - assert_eq!( - join_path("s3://bucket/base?region=us-west-2", "attachments"), - "s3://bucket/base/attachments?region=us-west-2" - ); - } - - #[test] - fn appends_extension_before_s3_query_string() { - assert_eq!( - with_extension("s3://bucket/base/rsa_key?region=us-west-2", "pem"), - "s3://bucket/base/rsa_key.pem?region=us-west-2" - ); - } - - #[test] - fn splits_s3_parent_and_file_name_without_query_string() { - let path = "s3://bucket/base/config.json?region=us-west-2"; - - assert_eq!(parent(path).as_deref(), Some("s3://bucket/base?region=us-west-2")); - assert_eq!(file_name(path).as_deref(), Some("config.json")); - } -} diff --git a/src/util.rs b/src/util.rs index 696c3360..182b7b3b 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,28 +1,24 @@ // // Web Headers and caching // -use std::{collections::HashMap, env, fmt, io::Cursor, path::Path, str::FromStr}; +use std::{collections::HashMap, io::Cursor, path::Path}; -use chrono::{DateTime, Local, NaiveDateTime, TimeZone}; use num_traits::ToPrimitive; -use tokio::{ - runtime::Handle, - time::{Duration, sleep}, -}; - -use serde::de::{self, DeserializeOwned, Deserializer, MapAccess, SeqAccess, Visitor}; -use serde_json::Value; - use rocket::{ - Data, Orbit, Request, Response, Rocket, fairing::{Fairing, Info, Kind}, http::{ContentType, Header, HeaderMap, Method, Status}, response::{self, Responder}, + Data, Orbit, Request, Response, Rocket, +}; + +use tokio::{ + runtime::Handle, + time::{sleep, Duration}, }; use crate::{ - CONFIG, config::{PathType, SUPPORTED_FEATURE_FLAGS}, + CONFIG, }; pub struct AppHeaders(); @@ -79,16 +75,11 @@ impl Fairing for AppHeaders { // Do not send the Content-Security-Policy (CSP) Header and X-Frame-Options for the *-connector.html files. // This can cause issues when some MFA requests needs to open a popup or page within the clients like WebAuthn, or Duo. // This is the same behavior as upstream Bitwarden. - if req_uri_path.ends_with("connector.html") { - // It looks like this header get's set somewhere else also, make sure this is not sent for these files, it will cause MFA issues. - res.remove_header("X-Frame-Options"); - } else { + if !req_uri_path.ends_with("connector.html") { let csp = if is_image { // Prevent scripts, frames, objects, etc., from loading with images, mainly for SVG images, since these could contain JavaScript and other unsafe items. // Even though we sanitize SVG images before storing and viewing them, it's better to prevent allowing these elements. - String::from( - "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; script-src 'none'; frame-src 'none'; object-src 'none", - ) + String::from("default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; script-src 'none'; frame-src 'none'; object-src 'none") } else { // # Frame Ancestors: // Chrome Web Store: https://chrome.google.com/webstore/detail/bitwarden-free-password-m/nngceckbapebfimnlniiiahkandclblb @@ -138,6 +129,9 @@ impl Fairing for AppHeaders { res.set_raw_header("Content-Security-Policy", csp); res.set_raw_header("X-Frame-Options", "SAMEORIGIN"); + } else { + // It looks like this header get's set somewhere else also, make sure this is not sent for these files, it will cause MFA issues. + res.remove_header("X-Frame-Options"); } // Disable cache unless otherwise specified @@ -152,7 +146,7 @@ pub struct Cors(); impl Cors { fn get_header(headers: &HeaderMap<'_>, name: &str) -> String { match headers.get_one(name) { - Some(h) => h.to_owned(), + Some(h) => h.to_string(), _ => String::new(), } } @@ -210,7 +204,7 @@ impl Fairing for Cors { pub struct Cached { response: R, is_immutable: bool, - ttl: u32, + ttl: u64, } impl Cached { @@ -218,7 +212,7 @@ impl Cached { Self { response, is_immutable, - ttl: 604_800, // 7 days + ttl: 604800, // 7 days } } @@ -230,7 +224,7 @@ impl Cached { } } - pub fn ttl(response: R, ttl: u32, is_immutable: bool) -> Cached { + pub fn ttl(response: R, ttl: u64, is_immutable: bool) -> Cached { Self { response, is_immutable, @@ -251,120 +245,29 @@ impl<'r, R: 'r + Responder<'r, 'static> + Send> Responder<'r, 'static> for Cache res.set_raw_header("Cache-Control", cache_control_header); let time_now = Local::now(); - let expiry_time = time_now + chrono::TimeDelta::try_seconds(self.ttl.into()).unwrap(); + let expiry_time = time_now + chrono::TimeDelta::try_seconds(self.ttl.try_into().unwrap()).unwrap(); res.set_raw_header("Expires", format_datetime_http(&expiry_time)); Ok(res) } } -pub struct EtagCached { - response: R, - content_type: ContentType, - etag: String, -} - -impl EtagCached { - /// An `etag` response should always be quoted - pub fn new(response: R, content_type: ContentType, etag: &str) -> Self { - Self { - response, - content_type, - etag: format!("\"{etag}\""), - } - } -} - -impl<'r, R: 'r + Responder<'r, 'static> + Send> Responder<'r, 'static> for EtagCached { - fn respond_to(self, request: &'r Request<'_>) -> response::Result<'static> { - // Check and validate a `If-None-Match` ETag header - // Multiple tags could be returned for the same URI if the browser has multiple versions cached - // Also, weak tags are prefixed with `W/`, but ETags are always weak, so just strip it too before comparing - let etag_matches = request - .headers() - .get_one("If-None-Match") - .is_some_and(|v| v.split(',').any(|t| t.trim().trim_start_matches("W/") == self.etag)); - - let mut res = if etag_matches { - Response::build().status(Status::NotModified).ok()? - } else { - self.response.respond_to(request)? - }; - - // Both 200 (OK) and 304 (Not Modified) need to return the Content-Type Etag and Cache-Control - res.set_header(self.content_type); - res.set_raw_header("Etag", self.etag); - res.set_raw_header("Cache-Control", "public, no-cache"); - Ok(res) - } -} - // Log all the routes from the main paths list, and the attachments endpoint // Effectively ignores, any static file route, and the alive endpoint const LOGGED_ROUTES: [&str; 7] = ["/api", "/admin", "/identity", "/icons", "/attachments", "/events", "/notifications"]; -const MAX_LOGGED_QUERY_BYTES: usize = 100; - -// Query values can hold secrets, like the access code of a login request, so we don't log them -fn redact_query_values(query: &str) -> String { - query - .split('&') - .map(|param| match param.split_once('=') { - Some((name, _)) => format!("{name}=…"), - None => param.to_owned(), - }) - .collect::>() - .join("&") -} - -// When extra is true, we ignore the whitelist above and also print the mounts -pub struct BetterLogging { - extra: bool, - log_requests: bool, - log_response: bool, - domain_path: String, -} - -impl BetterLogging { - pub fn new(extra: bool, domain_path: &str) -> Self { - Self { - extra, - log_requests: log_enabled!(target: "request", log::Level::Info), - log_response: log_enabled!(target: "response", log::Level::Info), - domain_path: rocket::http::RawStr::new(&domain_path).percent_decode_lossy().into_owned(), - } - } - - fn log_path<'p>(&self, method: Method, path: &'p rocket::http::RawStr) -> Option> { - if !self.extra && method == Method::Options { - return None; - } - let path = path.percent_decode_lossy(); - if self.extra { - return Some(path); - } - let subpath = path.strip_prefix(&self.domain_path).unwrap_or(&path); - LOGGED_ROUTES.iter().any(|r| subpath.starts_with(r)).then_some(path) - } -} - +// Boolean is extra debug, when true, we ignore the whitelist above and also print the mounts +pub struct BetterLogging(pub bool); #[rocket::async_trait] impl Fairing for BetterLogging { fn info(&self) -> Info { - let mut kind = Kind::Liftoff; - if self.log_requests { - kind = kind | Kind::Request; - } - if self.log_response { - kind = kind | Kind::Response; - } Info { name: "Better Logging", - kind, + kind: Kind::Liftoff | Kind::Request | Kind::Response, } } async fn on_liftoff(&self, rocket: &Rocket) { - if self.extra { + if self.0 { info!(target: "routes", "Routes loaded:"); let mut routes: Vec<_> = rocket.routes().collect(); routes.sort_by_key(|r| r.uri.path()); @@ -383,34 +286,41 @@ impl Fairing for BetterLogging { } else { "http" }; - let addr = format!("{scheme}://{}:{}", config.address, config.port); + let addr = format!("{scheme}://{}:{}", &config.address, &config.port); info!(target: "start", "Rocket has launched from {addr}"); } async fn on_request(&self, request: &mut Request<'_>, _data: &mut Data<'_>) { - let uri = request.uri(); let method = request.method(); - let Some(path) = self.log_path(method, uri.path().raw()) else { + if !self.0 && method == Method::Options { return; - }; - match uri.query() { - Some(q) => { - let query = redact_query_values(q.as_str()); - let query = &query[..query.floor_char_boundary(MAX_LOGGED_QUERY_BYTES)]; - info!(target: "request", "{method} {}?{}", path.escape_debug(), query.escape_debug()); - } - None => info!(target: "request", "{method} {}", path.escape_debug()), + } + let uri = request.uri(); + let uri_path = uri.path(); + let uri_path_str = uri_path.url_decode_lossy(); + let uri_subpath = uri_path_str.strip_prefix(&CONFIG.domain_path()).unwrap_or(&uri_path_str); + if self.0 || LOGGED_ROUTES.iter().any(|r| uri_subpath.starts_with(r)) { + match uri.query() { + Some(q) => info!(target: "request", "{method} {uri_path_str}?{}", &q[..q.len().min(30)]), + None => info!(target: "request", "{method} {uri_path_str}"), + }; } } async fn on_response<'r>(&self, request: &'r Request<'_>, response: &mut Response<'r>) { - if self.log_path(request.method(), request.uri().path().raw()).is_none() { + if !self.0 && request.method() == Method::Options { return; } - let status = response.status(); - match request.route() { - Some(r) => info!(target: "response", "{r} => {status}"), - None => info!(target: "response", "{status}"), + let uri_path = request.uri().path(); + let uri_path_str = uri_path.url_decode_lossy(); + let uri_subpath = uri_path_str.strip_prefix(&CONFIG.domain_path()).unwrap_or(&uri_path_str); + if self.0 || LOGGED_ROUTES.iter().any(|r| uri_subpath.starts_with(r)) { + let status = response.status(); + if let Some(ref route) = request.route() { + info!(target: "response", "{route} => {status}") + } else { + info!(target: "response", "{status}") + } } } } @@ -426,7 +336,7 @@ pub fn get_display_size(size: i64) -> String { let mut unit_counter = 0; loop { - if size > 1024. && unit_counter < UNITS.len() - 1 { + if size > 1024. { size /= 1024.; unit_counter += 1; } else { @@ -444,6 +354,9 @@ pub fn get_uuid() -> String { // // String util methods // + +use std::str::FromStr; + #[inline] pub fn upcase_first(s: &str) -> String { let mut c = s.chars(); @@ -477,6 +390,9 @@ where // // Env methods // + +use std::env; + pub fn get_env_str_value(key: &str) -> Option { let key_file = format!("{key}_FILE"); let value_from_env = env::var(key); @@ -486,41 +402,27 @@ pub fn get_env_str_value(key: &str) -> Option { (Ok(_), Ok(_)) => panic!("You should not define both {key} and {key_file}!"), (Ok(v_env), Err(_)) => Some(v_env), (Err(_), Ok(v_file)) => match std::fs::read_to_string(v_file) { - Ok(content) => Some(content.trim().to_owned()), + Ok(content) => Some(content.trim().to_string()), Err(e) => panic!("Failed to load {key}: {e:?}"), }, _ => None, } } -pub static INVALID_ENV_VARS: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); - -pub fn get_env(key: &'static str) -> Option +pub fn get_env(key: &str) -> Option where V: FromStr, { - let value = get_env_str_value(key); - let value_is_some = value.is_some(); - let parsed: Option = try_parse_string(value); - if parsed.is_none() && value_is_some { - println!("[ERROR] `{key}` contains an invalid value for its type and is ignored, the default is used instead."); - INVALID_ENV_VARS.lock().unwrap().push(key); - } - parsed + try_parse_string(get_env_str_value(key)) } -pub fn get_env_bool(key: &'static str) -> Option { +pub fn get_env_bool(key: &str) -> Option { const TRUE_VALUES: &[&str] = &["true", "t", "yes", "y", "1"]; const FALSE_VALUES: &[&str] = &["false", "f", "no", "n", "0"]; match get_env_str_value(key) { Some(val) if TRUE_VALUES.contains(&val.to_lowercase().as_ref()) => Some(true), Some(val) if FALSE_VALUES.contains(&val.to_lowercase().as_ref()) => Some(false), - Some(_) => { - println!("[ERROR] `{key}` contains an invalid boolean value and is ignored, the default is used instead."); - INVALID_ENV_VARS.lock().unwrap().push(key); - None - } _ => None, } } @@ -529,6 +431,8 @@ pub fn get_env_bool(key: &'static str) -> Option { // Date util methods // +use chrono::{DateTime, Local, NaiveDateTime, TimeZone}; + /// Formats a UTC-offset `NaiveDateTime` in the format used by Bitwarden API /// responses with "date" fields (`CreationDate`, `RevisionDate`, etc.). pub fn format_date(dt: &NaiveDateTime) -> String { @@ -553,10 +457,10 @@ pub fn validate_and_format_date(dt: &str) -> String { pub fn format_datetime_local(dt: &DateTime, fmt: &str) -> String { // Try parsing the `TZ` environment variable to enable formatting `%Z` as // a time zone abbreviation. - if let Ok(tz) = env::var("TZ") - && let Ok(tz) = tz.parse::() - { - return dt.with_timezone(&tz).format(fmt).to_string(); + if let Ok(tz) = env::var("TZ") { + if let Ok(tz) = tz.parse::() { + return dt.with_timezone(&tz).format(fmt).to_string(); + } } // Otherwise, fall back to formatting `%Z` as a UTC offset. @@ -582,8 +486,8 @@ pub fn format_datetime_http(dt: &DateTime) -> String { expiry_time.to_rfc2822().replace("+0000", "GMT") } -pub fn parse_date(date: &str) -> Option { - DateTime::parse_from_rfc3339(date).ok().map(|dt| dt.naive_utc()) +pub fn parse_date(date: &str) -> NaiveDateTime { + DateTime::parse_from_rfc3339(date).unwrap().naive_utc() } /// Returns true or false if an email address is valid or not @@ -597,7 +501,10 @@ pub fn is_valid_email(email: &str) -> bool { let Ok(email_url) = url::Url::parse(&format!("https://{}", email.domain())) else { return false; }; - email_url.domain().is_some() && email_url.path() == "/" && email_url.query().is_none() + if email_url.path().ne("/") || email_url.domain().is_none() || email_url.query().is_some() { + return false; + } + true } // @@ -605,7 +512,6 @@ pub fn is_valid_email(email: &str) -> bool { // /// Returns true if the program is running in Docker, Podman or Kubernetes. -#[must_use] pub fn is_running_in_container() -> bool { Path::new("/.dockerenv").exists() || Path::new("/run/.containerenv").exists() @@ -637,10 +543,10 @@ pub fn get_active_web_release() -> String { ]; for version_file in version_files { - if let Ok(version_str) = std::fs::read_to_string(&version_file) - && let Ok(version) = serde_json::from_str::(&version_str) - { - return String::from(version.version.trim_start_matches('v')); + if let Ok(version_str) = std::fs::read_to_string(&version_file) { + if let Ok(version) = serde_json::from_str::(&version_str) { + return String::from(version.version.trim_start_matches('v')); + } } } @@ -650,6 +556,12 @@ pub fn get_active_web_release() -> String { // // Deserialization methods // + +use std::fmt; + +use serde::de::{self, DeserializeOwned, Deserializer, MapAccess, SeqAccess, Visitor}; +use serde_json::Value; + pub type JsonMap = serde_json::Map; #[derive(Serialize, Deserialize)] @@ -693,7 +605,7 @@ impl<'de> Visitor<'de> for LowerCaseVisitor { let mut result_map = JsonMap::new(); while let Some((key, value)) = map.next_entry()? { - result_map.insert(process_json_key(key), convert_json_key_lcase_first(value)); + result_map.insert(_process_key(key), convert_json_key_lcase_first(value)); } Ok(Value::Object(result_map)) @@ -715,7 +627,7 @@ impl<'de> Visitor<'de> for LowerCaseVisitor { // Inner function to handle a special case for the 'ssn' key. // This key is part of the Identity Cipher (Social Security Number) -fn process_json_key(key: &str) -> String { +fn _process_key(key: &str) -> String { match key.to_lowercase().as_ref() { "ssn" => "ssn".into(), _ => lcase_first(key), @@ -752,24 +664,21 @@ impl NumberOrString { } } - #[expect(clippy::wrong_self_convention)] + #[allow(clippy::wrong_self_convention)] pub fn into_i32(&self) -> Result { use std::num::ParseIntError as PIE; match self { - NumberOrString::Number(n) => { - if let Some(n) = n.to_i32() { - Ok(n) - } else { - err!("Number does not fit in i32") - } - } + NumberOrString::Number(n) => match n.to_i32() { + Some(n) => Ok(n), + None => err!("Number does not fit in i32"), + }, NumberOrString::String(s) => { s.parse().map_err(|e: PIE| crate::Error::new("Can't convert to number", e.to_string())) } } } - #[expect(clippy::wrong_self_convention)] + #[allow(clippy::wrong_self_convention)] pub fn into_i64(&self) -> Result { use std::num::ParseIntError as PIE; match self { @@ -825,7 +734,7 @@ where warn!("Can't connect to database, retrying: {e:?}"); - sleep(Duration::from_secs(1)).await; + sleep(Duration::from_millis(1_000)).await; } } } @@ -844,11 +753,11 @@ pub fn convert_json_key_lcase_first(src_json: Value) -> Value { Value::Object(obj) => { let mut json_map = JsonMap::new(); - for (key, value) in obj { + for (key, value) in obj.into_iter() { match (key, value) { (key, Value::Object(elm)) => { let inner_value = convert_json_key_lcase_first(Value::Object(elm)); - json_map.insert(process_json_key(&key), inner_value); + json_map.insert(_process_key(&key), inner_value); } (key, Value::Array(elm)) => { @@ -858,11 +767,11 @@ pub fn convert_json_key_lcase_first(src_json: Value) -> Value { inner_array.push(convert_json_key_lcase_first(inner_obj)); } - json_map.insert(process_json_key(&key), Value::Array(inner_array)); + json_map.insert(_process_key(&key), Value::Array(inner_array)); } (key, value) => { - json_map.insert(process_json_key(&key), value); + json_map.insert(_process_key(&key), value); } } } @@ -884,7 +793,7 @@ pub enum FeatureFlagFilter { /// Parses the experimental client feature flags string into a HashMap. pub fn parse_experimental_client_feature_flags( experimental_client_feature_flags: &str, - filter_mode: &FeatureFlagFilter, + filter_mode: FeatureFlagFilter, ) -> HashMap { experimental_client_feature_flags .split(',') @@ -899,35 +808,24 @@ pub fn parse_experimental_client_feature_flags( .collect() } -/// Whether the admin enabled this supported client feature flag -pub fn is_client_feature_flag_enabled(flag: &str) -> bool { - parse_experimental_client_feature_flags(&CONFIG.experimental_client_feature_flags(), &FeatureFlagFilter::ValidOnly) - .contains_key(flag) -} - /// TODO: This is extracted from IpAddr::is_global, which is unstable: /// https://doc.rust-lang.org/nightly/std/net/enum.IpAddr.html#method.is_global /// Remove once https://github.com/rust-lang/rust/issues/27709 is merged -// #[expect(clippy::nonminimal_bool, reason = "Mostly copy/paste from std, keep as-is")] -#[expect(clippy::decimal_bitwise_operands, reason = "Mostly copy/paste from std, keep as-is")] +#[allow(clippy::nonminimal_bool)] #[cfg(any(not(feature = "unstable"), test))] pub fn is_global_hardcoded(ip: std::net::IpAddr) -> bool { match ip { std::net::IpAddr::V4(ip) => { !(ip.octets()[0] == 0 // "This network" || ip.is_private() - || (ip.octets()[0] == 100 && (ip.octets()[1] & 0b1100_0000 == 0b0100_0000)) // ip.is_shared() + || (ip.octets()[0] == 100 && (ip.octets()[1] & 0b1100_0000 == 0b0100_0000)) //ip.is_shared() || ip.is_loopback() || ip.is_link_local() // addresses reserved for future protocols (`192.0.0.0/24`) - // .9 and .10 are documented as globally reachable so they're excluded - || ( - ip.octets()[0] == 192 && ip.octets()[1] == 0 && ip.octets()[2] == 0 - && ip.octets()[3] != 9 && ip.octets()[3] != 10 - ) + ||(ip.octets()[0] == 192 && ip.octets()[1] == 0 && ip.octets()[2] == 0) || ip.is_documentation() || (ip.octets()[0] == 198 && (ip.octets()[1] & 0xfe) == 18) // ip.is_benchmarking() - || (ip.octets()[0] & 240 == 240 && !ip.is_broadcast()) // ip.is_reserved() + || (ip.octets()[0] & 240 == 240 && !ip.is_broadcast()) //ip.is_reserved() || ip.is_broadcast()) } std::net::IpAddr::V6(ip) => { @@ -951,17 +849,11 @@ pub fn is_global_hardcoded(ip: std::net::IpAddr) -> bool { // AS112-v6 (`2001:4:112::/48`) || matches!(ip.segments(), [0x2001, 4, 0x112, _, _, _, _, _]) // ORCHIDv2 (`2001:20::/28`) - // Drone Remote ID Protocol Entity Tags (DETs) Prefix (`2001:30::/28`)` - || matches!(ip.segments(), [0x2001, b, _, _, _, _, _, _] if (0x20..=0x3F).contains(&b)) + || matches!(ip.segments(), [0x2001, b, _, _, _, _, _, _] if (0x20..=0x2F).contains(&b)) )) - // 6to4 (`2002::/16`) – it's not explicitly documented as globally reachable, - // IANA says N/A. - || matches!(ip.segments(), [0x2002, _, _, _, _, _, _, _]) - || matches!(ip.segments(), [0x2001, 0xdb8, ..] | [0x3fff, 0..=0x0fff, ..]) // ip.is_documentation() - // Segment Routing (SRv6) SIDs (`5f00::/16`) - || matches!(ip.segments(), [0x5f00, ..]) - || ip.is_unique_local() - || ip.is_unicast_link_local()) + || ((ip.segments()[0] == 0x2001) && (ip.segments()[1] == 0xdb8)) // ip.is_documentation() + || ((ip.segments()[0] & 0xfe00) == 0xfc00) //ip.is_unique_local() + || ((ip.segments()[0] & 0xffc0) == 0xfe80)) //ip.is_unicast_link_local() } } } @@ -1009,15 +901,15 @@ mod tests { use std::net::IpAddr; #[test] - #[ignore = "exhaustive IPv4 check is too slow for the regular test suite"] + #[ignore] fn test_ipv4_global() { for a in 0..u8::MAX { - println!("Iter: {a}/255"); + println!("Iter: {}/255", a); for b in 0..u8::MAX { for c in 0..u8::MAX { for d in 0..u8::MAX { let ip = IpAddr::V4(std::net::Ipv4Addr::new(a, b, c, d)); - assert_eq!(ip.is_global(), is_global_hardcoded(ip), "IP mismatch: {ip}"); + assert_eq!(ip.is_global(), is_global_hardcoded(ip), "IP mismatch: {}", ip) } } } @@ -1025,15 +917,15 @@ mod tests { } #[test] - #[ignore = "random IPv6 check is too slow for the regular test suite"] + #[ignore] fn test_ipv6_global() { - use rand::RngExt; + use rand::Rng; std::thread::scope(|s| { for t in 0..16 { - let _handle = s.spawn(move || { + let handle = s.spawn(move || { let mut v = [0u8; 16]; - let mut rng = rand::rng(); + let mut rng = rand::thread_rng(); for i in 0..20 { println!("Thread {t} Iter: {i}/50"); @@ -1048,28 +940,3 @@ mod tests { }); } } - -#[cfg(test)] -mod query_log_tests { - use super::{MAX_LOGGED_QUERY_BYTES, redact_query_values}; - - #[test] - fn test_redact_query_values() { - assert_eq!(redact_query_values("code=SECRET"), "code=…"); - assert_eq!(redact_query_values("code=SECRET&other=SECRET"), "code=…&other=…"); - assert_eq!(redact_query_values("a=1&b=2&flag"), "a=…&b=…&flag"); - assert_eq!(redact_query_values("a=b=SECRET"), "a=…"); - assert_eq!(redact_query_values("a=1&&=SECRET"), "a=…&&=…"); - assert_eq!(redact_query_values("a=x%26b%3DSECRET"), "a=…"); - assert_eq!(redact_query_values("a=1;b=SECRET"), "a=…"); - assert_eq!(redact_query_values("a=1&a=SECRET"), "a=…&a=…"); - assert_eq!(redact_query_values(""), ""); - } - - #[test] - fn test_redacted_query_truncates_on_a_char_boundary() { - let name = "a".repeat(MAX_LOGGED_QUERY_BYTES - 1); - let query = redact_query_values(&format!("{name}=SECRET")); - assert_eq!(&query[..query.floor_char_boundary(MAX_LOGGED_QUERY_BYTES)], format!("{name}=")); - } -}