From 7885a5fc3078ef3807040058c0c677ab9f635814 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Fri, 4 Jul 2025 09:03:31 +0200 Subject: [PATCH 1/7] decrypt - using pydantic WrapValidator CipherDetails may have a dedicated key to use for decryption, use if set --- src/vaultwarden/clients/bitwarden.py | 13 +- src/vaultwarden/models/bitwarden.py | 286 +++++++++++++++++++++++++-- src/vaultwarden/models/enum.py | 5 + src/vaultwarden/models/sync.py | 8 +- src/vaultwarden/utils/crypto.py | 27 ++- tests/fixtures/server/db.sqlite3 | 4 +- 6 files changed, 313 insertions(+), 30 deletions(-) diff --git a/src/vaultwarden/clients/bitwarden.py b/src/vaultwarden/clients/bitwarden.py index 82eae74..59cb368 100644 --- a/src/vaultwarden/clients/bitwarden.py +++ b/src/vaultwarden/clients/bitwarden.py @@ -68,10 +68,14 @@ def _refresh_connect_token(self): ) self._connect_token = ConnectToken.model_validate_json(resp.text) + import vaultwarden.models.bitwarden + self._connect_token.master_key = make_master_key( password=self.password, salt=self.email, - iterations=self._connect_token.KdfIterations, + kdf=vaultwarden.models.bitwarden.Kdf.from_ConnectToken( + self._connect_token + ), ) def _set_connect_token(self): @@ -92,11 +96,16 @@ def _set_connect_token(self): "identity/connect/token", headers=headers, data=payload ) self._connect_token = ConnectToken.model_validate_json(resp.text) + import vaultwarden.models.bitwarden + self._connect_token.master_key = make_master_key( password=self.password, salt=self.email, - iterations=self._connect_token.KdfIterations, + kdf=vaultwarden.models.bitwarden.Kdf.from_ConnectToken( + self._connect_token + ), ) + return # login to api def _api_login(self) -> None: diff --git a/src/vaultwarden/models/bitwarden.py b/src/vaultwarden/models/bitwarden.py index 8a092c6..96e6e6e 100644 --- a/src/vaultwarden/models/bitwarden.py +++ b/src/vaultwarden/models/bitwarden.py @@ -1,8 +1,25 @@ -from typing import Generic, Literal, TypeVar, cast +from typing import Generic, Literal, TypeVar, cast, Any, Self, Annotated, Union from uuid import UUID - -from pydantic import AliasChoices, Field, TypeAdapter, field_validator -from pydantic_core.core_schema import FieldValidationInfo +import datetime + +from pydantic import ( + AliasChoices, + Field, + TypeAdapter, + field_validator, + RootModel, + PrivateAttr, + model_validator, + ValidationError, + ModelWrapValidatorHandler, + WrapValidator, + AfterValidator, +) +from pydantic_core.core_schema import ( + FieldValidationInfo, + ValidationInfo, + ValidatorFunctionWrapHandler, +) from vaultwarden.clients.bitwarden import BitwardenAPIClient from vaultwarden.models.enum import CipherType, OrganizationUserType @@ -10,6 +27,8 @@ from vaultwarden.models.permissive_model import PermissiveBaseModel from vaultwarden.utils.crypto import decrypt, encrypt +from src.vaultwarden.models.enum import KdfType + # Pydantic models for Bitwarden data structures T = TypeVar("T", bound="BitwardenBaseModel") @@ -37,12 +56,177 @@ def api_client(self) -> BitwardenAPIClient: return self.bitwarden_client -class CipherDetails(BitwardenBaseModel): +def decodeBytes( + value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo +) -> str: + for key in info.context["cctx"][::-1]: + try: + return decrypt(handler(value), key) + except Exception as e: + continue + raise e + + +def decodeString( + value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo +) -> str: + return decodeBytes(value, handler, info=info).decode("utf-8") + + +class UriMatch(BitwardenBaseModel): + class Config: + extra = "forbid" + + match: int | None = None + uri: Annotated[str, WrapValidator(decodeString)] | None = None + uriChecksum: Annotated[str, WrapValidator(decodeString)] | None = None + response: str | None = None + + +class XField(BitwardenBaseModel): + class Config: + extra = "forbid" + + name: Annotated[str, WrapValidator(decodeString)] | None = None + response: Annotated[str, WrapValidator(decodeString)] | None = None + type: int + value: Annotated[str, WrapValidator(decodeString)] | None = None + linkedId: str | None = None + + +class CipherLogin(BitwardenBaseModel): + class Config: + extra = "forbid" + + name: Annotated[str, WrapValidator(decodeString)] | None = None + autofillOnPageLoad: bool | None = None + password: Annotated[str, WrapValidator(decodeString)] | None = None + passwordRevisionDate: datetime.datetime | None = None + totp: str | None = None + uri: Annotated[str, WrapValidator(decodeString)] | None = None + uris: list[UriMatch] | None = None + username: Annotated[str, WrapValidator(decodeString)] | None = None + notes: Annotated[str, WrapValidator(decodeString)] | None = None + + +class PasswordChange(BitwardenBaseModel): + class Config: + extra = "forbid" + + lastUsedDate: datetime.datetime + password: str + + +class fido2Credential(BitwardenBaseModel): + class Config: + extra = "forbid" + + counter: Annotated[str, WrapValidator(decodeString)] | None = None + creationDate: datetime.datetime | None = None + credentialId: Annotated[str, WrapValidator(decodeString)] | None = None + discoverable: Annotated[str, WrapValidator(decodeString)] | None = None + keyAlgorithm: Annotated[str, WrapValidator(decodeString)] | None = None + keyCurve: Annotated[str, WrapValidator(decodeString)] | None = None + keyType: Annotated[str, WrapValidator(decodeString)] | None = None + keyValue: Annotated[str, WrapValidator(decodeString)] | None = None + response: str | None = None + rpId: Annotated[str, WrapValidator(decodeString)] | None = None + rpName: Annotated[str, WrapValidator(decodeString)] | None = None + userDisplayName: Annotated[str, WrapValidator(decodeString)] | None = None + userHandle: Annotated[str, WrapValidator(decodeString)] | None = None + userName: Annotated[str, WrapValidator(decodeString)] | None = None + + +class LoginData(CipherLogin): + class Config: + extra = "forbid" + + fields: list[XField] | None = None + passwordHistory: list[PasswordChange] | None = None + response: str | None = None + fido2Credentials: list[fido2Credential] | None = None + + +class SecureNoteData(CipherLogin): + class Config: + extra = "forbid" + + fields: list[XField] + passwordHistory: list[PasswordChange] + response: str | None = None + type: int | None = None + + +class SecureNoteProperty(BitwardenBaseModel): + class Config: + extra = "forbid" + + name: Annotated[str, WrapValidator(decodeString)] | None = None + notes: Annotated[str, WrapValidator(decodeString)] | None = None + fields: list[XField] | None = None + passwordHistory: list[PasswordChange] | None = None + response: Annotated[str, WrapValidator(decodeString)] | None = None + type: int + + +class Attachment(BitwardenBaseModel): + class Config: + extra = "forbid" + + fileName: Annotated[str, WrapValidator(decodeString)] | None = None + id: str + key: str | None = ( + None # Annotated[str, WrapValidator(decodeBytes)]|None = None + ) + object: str + size: int + sizeName: str + url: str + + +class _CipherBase(BitwardenBaseModel): + class Config: + extra = "forbid" + Id: UUID | None = None OrganizationId: UUID | None = Field(None, validate_default=True) Type: CipherType - Name: str + Name: Annotated[str, WrapValidator(decodeString)] CollectionIds: list[UUID] + key: str | None = None + + organizationUseTotp: bool | None = None + creationDate: datetime.datetime | None = None + deletedDate: datetime.datetime | None = None + fields: list[XField] | None = None + + notes: Annotated[str, WrapValidator(decodeString)] | None = None + reprompt: int + revisionDate: str + sshKey: str | None + passwordHistory: list[PasswordChange] + object: str | None = None + attachments: list[Attachment] | None = None + + @model_validator(mode="wrap") + @classmethod + def set_key( + cls, + data: Any, + handler: ModelWrapValidatorHandler[Self], + info: ValidationInfo, + ) -> Self: + if data.get("key") is not None: + info.context["cctx"].append( + decrypt(data["key"], info.context["cctx"][0]) + ) + + v = handler(data) + + if data.get("key") is not None: + info.context["cctx"].pop() + + return v @field_validator("OrganizationId") @classmethod @@ -88,6 +272,55 @@ def update_collection(self, collections: list[UUID]): ) +class Login(_CipherBase): + Type: Literal[1] + + login: LoginData | None = None + secureNote: None = None + card: None = None + identity: None = None + + data: LoginData | None = None + + +class SecureNote(_CipherBase): + Type: Literal[2] + + login: None = None + secureNote: SecureNoteProperty | None = None + card: None = None + identity: None = None + + data: SecureNoteData | None = None + + +class Card(_CipherBase): + Type: Literal[3] + + login: None = None + card: None = None + secureNote: None = None + identity: None = None + + data: None = None + + +class Identity(_CipherBase): + Type: Literal[4] + + login: None = None + secureNote: None = None + card: None = None + identity: None = None + + data: None = None + + +CipherDetails = Annotated[ + Union[Login, SecureNote, Card, Identity], Field(discriminator="Type") +] + + class CollectionAccess(BitwardenBaseModel): ReadOnly: bool = False HidePasswords: bool = False @@ -550,14 +783,15 @@ def _get_ciphers(self) -> list[CipherDetails]: "api/ciphers/organization-details", params={"organizationId": self.Id}, ) + org_key = self.key() res = ResplistBitwarden[CipherDetails].model_validate_json( resp.text, - context={"parent_id": self.Id, "client": self.api_client}, + context={ + "parent_id": self.Id, + "client": self.api_client, + "cctx": [org_key], + }, ) - org_key = self.key() - # map each cipher name to the decrypted name - for cipher in res.Data: - cipher.Name = decrypt(cipher.Name, org_key).decode("utf-8") return res.Data def ciphers( @@ -581,14 +815,12 @@ def ciphers( def key(self): sync = self.api_client.sync() - raw_key = None for org in sync.Profile.Organizations: if org.Id == self.Id: - raw_key = org.Key break - if raw_key is not None: - return decrypt(raw_key, self.api_client.connect_token.orgs_key) - raise BitwardenError(f"No Organizations `{self.Id}` found") + else: + raise BitwardenError(f"No Organizations `{self.Id}` found") + return decrypt(org.Key, self.api_client.connect_token.orgs_key) def get_organization( @@ -601,3 +833,25 @@ def get_organization( resp.text, context={"client": bitwarden_client, "parent_id": organisation_id}, ) + + +import dataclasses + + +@dataclasses.dataclass +class Kdf: + Kdf: KdfType + KdfIterations: int | None = None + KdfMemory: int | None = None + KdfParallelism: int | None = None + + @classmethod + def from_ConnectToken( + cls, token: "vaultwarden.clients.bitwarden.ConnectToken" + ): + return cls( + token.Kdf, + token.KdfIterations, + token.KdfMemory, + token.KdfParallelism, + ) diff --git a/src/vaultwarden/models/enum.py b/src/vaultwarden/models/enum.py index 2b0b57c..ece782e 100644 --- a/src/vaultwarden/models/enum.py +++ b/src/vaultwarden/models/enum.py @@ -27,3 +27,8 @@ class VaultwardenUserStatus(IntEnum): Enabled = 0 Invited = 1 Disabled = 2 + + +class KdfType(IntEnum): + Pbkdf2 = 0 + Argon2id = 1 diff --git a/src/vaultwarden/models/sync.py b/src/vaultwarden/models/sync.py index 2e50448..95ba75a 100644 --- a/src/vaultwarden/models/sync.py +++ b/src/vaultwarden/models/sync.py @@ -1,15 +1,18 @@ import time from uuid import UUID +import pydantic from pydantic import AliasChoices, Field, field_validator from vaultwarden.models.enum import VaultwardenUserStatus from vaultwarden.models.permissive_model import PermissiveBaseModel from vaultwarden.utils.crypto import decrypt +from src.vaultwarden.models.enum import KdfType + class ConnectToken(PermissiveBaseModel): - Kdf: int = 0 + Kdf: KdfType = KdfType.Pbkdf2 KdfIterations: int = 0 KdfMemory: int | None = None KdfParallelism: int | None = None @@ -22,7 +25,8 @@ class ConnectToken(PermissiveBaseModel): scope: str unofficialServer: bool = False ResetMasterPassword: bool | None = None - master_key: bytes | None = None + + master_key: bytes | None = None # pydantic.PrivateAttr(default=None) @field_validator("expires_in") @classmethod diff --git a/src/vaultwarden/utils/crypto.py b/src/vaultwarden/utils/crypto.py index fa77b8e..3453225 100644 --- a/src/vaultwarden/utils/crypto.py +++ b/src/vaultwarden/utils/crypto.py @@ -19,7 +19,6 @@ from Crypto.PublicKey import RSA from hkdf import hkdf_expand - class CIPHERS(IntEnum): sym = 2 asym = 4 @@ -69,6 +68,7 @@ def decode_cipher_string(cipher_string): """decode a cipher tring into it's parts""" iv = None mac = None + assert cipher_string is not None if not ENCRYPTED_STRING_RE.match(cipher_string): raise WrongFormatError(f"{cipher_string}") try: @@ -114,14 +114,25 @@ def is_encrypted(cipher_string): return True -def make_master_key(password, salt, iterations=ITERATIONS): - salt = salt.lower() - if not hasattr(password, "decode"): - password = password.encode("utf-8") - if not hasattr(salt, "decode"): - salt = salt.encode("utf-8") - return pbkdf2_hmac("sha256", password, salt, iterations) +def make_master_key( + password: str, salt: str, kdf: "vaultwarden.models.bitwarden.Kdf" +): + import vaultwarden.models.bitwarden + + assert isinstance(salt, str) + assert isinstance(password, str) + salt = salt.lower() + password = password.encode("utf-8") + salt = salt.encode("utf-8") + + match kdf.Kdf: + case vaultwarden.models.bitwarden.KdfType.Pbkdf2: + return pbkdf2_hmac("sha256", password, salt, kdf.KdfIterations) + case vaultwarden.models.bitwarden.KdfType.Argon2: + raise NotImplementedError("x") + case _: + return None def hash_password(password, salt, iterations=ITERATIONS): """base64-encode a wrapped, stretched password+salt(email) for signup/login""" diff --git a/tests/fixtures/server/db.sqlite3 b/tests/fixtures/server/db.sqlite3 index bf27515..93b7dcd 100644 --- a/tests/fixtures/server/db.sqlite3 +++ b/tests/fixtures/server/db.sqlite3 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c8c11a331ba097f1644b297885cd09b4ac5fc975ed5605176c880bc5cf08a813 -size 245760 +oid sha256:45f2e69202615d295d7687fa4752e189ad29cae7de8852101f93f0b679cbcab6 +size 262144 From d9f166ba1b61ca438c4482650396777a31c8f920 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Tue, 18 Nov 2025 15:08:02 +0100 Subject: [PATCH 2/7] argon2id - implement using argon2-cffi --- pyproject.toml | 1 + src/vaultwarden/utils/crypto.py | 20 ++++++++++++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4218963..55114db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ classifiers = [ requires-python = ">=3.10" dependencies = [ "hkdf >=0.0.3", + "argon2-cffi >= 25.1.0", "pycryptodome >=3.17.0", "pydantic >=2.5.0", "httpx >=0.24.1", diff --git a/src/vaultwarden/utils/crypto.py b/src/vaultwarden/utils/crypto.py index 3453225..3640a14 100644 --- a/src/vaultwarden/utils/crypto.py +++ b/src/vaultwarden/utils/crypto.py @@ -19,6 +19,7 @@ from Crypto.PublicKey import RSA from hkdf import hkdf_expand + class CIPHERS(IntEnum): sym = 2 asym = 4 @@ -129,11 +130,26 @@ def make_master_key( match kdf.Kdf: case vaultwarden.models.bitwarden.KdfType.Pbkdf2: return pbkdf2_hmac("sha256", password, salt, kdf.KdfIterations) - case vaultwarden.models.bitwarden.KdfType.Argon2: - raise NotImplementedError("x") + case vaultwarden.models.bitwarden.KdfType.Argon2id: + # c.f. + # https://github.com/vaultwarden/vw_web_builds/blob/355bddc6c9d5c110e55fe74c5fcfa86ddd85572c/libs/common/src/platform/services/key-generation.service.ts#L55-L75 + import argon2 + + hsalt = hashlib.new("sha256", salt).digest() + v = argon2.low_level.hash_secret_raw( + password, + hsalt, + time_cost=kdf.KdfIterations, + memory_cost=kdf.KdfMemory * 1024, + parallelism=kdf.KdfParallelism, + hash_len=32, + type=argon2.Type.ID, + ) + return v case _: return None + def hash_password(password, salt, iterations=ITERATIONS): """base64-encode a wrapped, stretched password+salt(email) for signup/login""" if not hasattr(password, "decode"): From 99468731358540fc7d9ba15316e37f54636a0712 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Tue, 13 Jan 2026 09:45:44 +0100 Subject: [PATCH 3/7] linting --- src/vaultwarden/clients/bitwarden.py | 4 +- src/vaultwarden/models/bitwarden.py | 101 ++++++++++++++------------- src/vaultwarden/models/sync.py | 4 +- src/vaultwarden/utils/crypto.py | 12 +--- 4 files changed, 59 insertions(+), 62 deletions(-) diff --git a/src/vaultwarden/clients/bitwarden.py b/src/vaultwarden/clients/bitwarden.py index 59cb368..d6b8c99 100644 --- a/src/vaultwarden/clients/bitwarden.py +++ b/src/vaultwarden/clients/bitwarden.py @@ -73,7 +73,7 @@ def _refresh_connect_token(self): self._connect_token.master_key = make_master_key( password=self.password, salt=self.email, - kdf=vaultwarden.models.bitwarden.Kdf.from_ConnectToken( + kdf=vaultwarden.models.bitwarden.Kdf.from_connect_token( self._connect_token ), ) @@ -101,7 +101,7 @@ def _set_connect_token(self): self._connect_token.master_key = make_master_key( password=self.password, salt=self.email, - kdf=vaultwarden.models.bitwarden.Kdf.from_ConnectToken( + kdf=vaultwarden.models.bitwarden.Kdf.from_connect_token( self._connect_token ), ) diff --git a/src/vaultwarden/models/bitwarden.py b/src/vaultwarden/models/bitwarden.py index 96e6e6e..86d3b92 100644 --- a/src/vaultwarden/models/bitwarden.py +++ b/src/vaultwarden/models/bitwarden.py @@ -1,25 +1,33 @@ -from typing import Generic, Literal, TypeVar, cast, Any, Self, Annotated, Union -from uuid import UUID +import dataclasses import datetime +from typing import ( + TYPE_CHECKING, + Annotated, + Any, + Generic, + Literal, + Self, + TypeVar, + Union, + cast, +) +from uuid import UUID from pydantic import ( AliasChoices, Field, + ModelWrapValidatorHandler, TypeAdapter, + WrapValidator, field_validator, - RootModel, - PrivateAttr, model_validator, - ValidationError, - ModelWrapValidatorHandler, - WrapValidator, - AfterValidator, ) from pydantic_core.core_schema import ( FieldValidationInfo, ValidationInfo, ValidatorFunctionWrapHandler, ) +from src.vaultwarden.models.enum import KdfType from vaultwarden.clients.bitwarden import BitwardenAPIClient from vaultwarden.models.enum import CipherType, OrganizationUserType @@ -27,7 +35,8 @@ from vaultwarden.models.permissive_model import PermissiveBaseModel from vaultwarden.utils.crypto import decrypt, encrypt -from src.vaultwarden.models.enum import KdfType +if TYPE_CHECKING: + import vaultwarden.clients.bitwarden # Pydantic models for Bitwarden data structures @@ -56,21 +65,20 @@ def api_client(self) -> BitwardenAPIClient: return self.bitwarden_client -def decodeBytes( +def decode_bytes( value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo ) -> str: for key in info.context["cctx"][::-1]: try: return decrypt(handler(value), key) - except Exception as e: + except Exception: continue - raise e -def decodeString( +def decode_string( value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo ) -> str: - return decodeBytes(value, handler, info=info).decode("utf-8") + return decode_bytes(value, handler, info=info).decode("utf-8") class UriMatch(BitwardenBaseModel): @@ -78,8 +86,8 @@ class Config: extra = "forbid" match: int | None = None - uri: Annotated[str, WrapValidator(decodeString)] | None = None - uriChecksum: Annotated[str, WrapValidator(decodeString)] | None = None + uri: Annotated[str, WrapValidator(decode_string)] | None = None + uriChecksum: Annotated[str, WrapValidator(decode_string)] | None = None response: str | None = None @@ -87,10 +95,10 @@ class XField(BitwardenBaseModel): class Config: extra = "forbid" - name: Annotated[str, WrapValidator(decodeString)] | None = None - response: Annotated[str, WrapValidator(decodeString)] | None = None + name: Annotated[str, WrapValidator(decode_string)] | None = None + response: Annotated[str, WrapValidator(decode_string)] | None = None type: int - value: Annotated[str, WrapValidator(decodeString)] | None = None + value: Annotated[str, WrapValidator(decode_string)] | None = None linkedId: str | None = None @@ -98,15 +106,15 @@ class CipherLogin(BitwardenBaseModel): class Config: extra = "forbid" - name: Annotated[str, WrapValidator(decodeString)] | None = None + name: Annotated[str, WrapValidator(decode_string)] | None = None autofillOnPageLoad: bool | None = None - password: Annotated[str, WrapValidator(decodeString)] | None = None + password: Annotated[str, WrapValidator(decode_string)] | None = None passwordRevisionDate: datetime.datetime | None = None totp: str | None = None - uri: Annotated[str, WrapValidator(decodeString)] | None = None + uri: Annotated[str, WrapValidator(decode_string)] | None = None uris: list[UriMatch] | None = None - username: Annotated[str, WrapValidator(decodeString)] | None = None - notes: Annotated[str, WrapValidator(decodeString)] | None = None + username: Annotated[str, WrapValidator(decode_string)] | None = None + notes: Annotated[str, WrapValidator(decode_string)] | None = None class PasswordChange(BitwardenBaseModel): @@ -117,24 +125,24 @@ class Config: password: str -class fido2Credential(BitwardenBaseModel): +class Fido2Credential(BitwardenBaseModel): class Config: extra = "forbid" - counter: Annotated[str, WrapValidator(decodeString)] | None = None + counter: Annotated[str, WrapValidator(decode_string)] | None = None creationDate: datetime.datetime | None = None - credentialId: Annotated[str, WrapValidator(decodeString)] | None = None - discoverable: Annotated[str, WrapValidator(decodeString)] | None = None - keyAlgorithm: Annotated[str, WrapValidator(decodeString)] | None = None - keyCurve: Annotated[str, WrapValidator(decodeString)] | None = None - keyType: Annotated[str, WrapValidator(decodeString)] | None = None - keyValue: Annotated[str, WrapValidator(decodeString)] | None = None + credentialId: Annotated[str, WrapValidator(decode_string)] | None = None + discoverable: Annotated[str, WrapValidator(decode_string)] | None = None + keyAlgorithm: Annotated[str, WrapValidator(decode_string)] | None = None + keyCurve: Annotated[str, WrapValidator(decode_string)] | None = None + keyType: Annotated[str, WrapValidator(decode_string)] | None = None + keyValue: Annotated[str, WrapValidator(decode_string)] | None = None response: str | None = None - rpId: Annotated[str, WrapValidator(decodeString)] | None = None - rpName: Annotated[str, WrapValidator(decodeString)] | None = None - userDisplayName: Annotated[str, WrapValidator(decodeString)] | None = None - userHandle: Annotated[str, WrapValidator(decodeString)] | None = None - userName: Annotated[str, WrapValidator(decodeString)] | None = None + rpId: Annotated[str, WrapValidator(decode_string)] | None = None + rpName: Annotated[str, WrapValidator(decode_string)] | None = None + userDisplayName: Annotated[str, WrapValidator(decode_string)] | None = None + userHandle: Annotated[str, WrapValidator(decode_string)] | None = None + userName: Annotated[str, WrapValidator(decode_string)] | None = None class LoginData(CipherLogin): @@ -144,7 +152,7 @@ class Config: fields: list[XField] | None = None passwordHistory: list[PasswordChange] | None = None response: str | None = None - fido2Credentials: list[fido2Credential] | None = None + fido2Credentials: list[Fido2Credential] | None = None class SecureNoteData(CipherLogin): @@ -161,11 +169,11 @@ class SecureNoteProperty(BitwardenBaseModel): class Config: extra = "forbid" - name: Annotated[str, WrapValidator(decodeString)] | None = None - notes: Annotated[str, WrapValidator(decodeString)] | None = None + name: Annotated[str, WrapValidator(decode_string)] | None = None + notes: Annotated[str, WrapValidator(decode_string)] | None = None fields: list[XField] | None = None passwordHistory: list[PasswordChange] | None = None - response: Annotated[str, WrapValidator(decodeString)] | None = None + response: Annotated[str, WrapValidator(decode_string)] | None = None type: int @@ -173,7 +181,7 @@ class Attachment(BitwardenBaseModel): class Config: extra = "forbid" - fileName: Annotated[str, WrapValidator(decodeString)] | None = None + fileName: Annotated[str, WrapValidator(decode_string)] | None = None id: str key: str | None = ( None # Annotated[str, WrapValidator(decodeBytes)]|None = None @@ -191,7 +199,7 @@ class Config: Id: UUID | None = None OrganizationId: UUID | None = Field(None, validate_default=True) Type: CipherType - Name: Annotated[str, WrapValidator(decodeString)] + Name: Annotated[str, WrapValidator(decode_string)] CollectionIds: list[UUID] key: str | None = None @@ -200,7 +208,7 @@ class Config: deletedDate: datetime.datetime | None = None fields: list[XField] | None = None - notes: Annotated[str, WrapValidator(decodeString)] | None = None + notes: Annotated[str, WrapValidator(decode_string)] | None = None reprompt: int revisionDate: str sshKey: str | None @@ -835,9 +843,6 @@ def get_organization( ) -import dataclasses - - @dataclasses.dataclass class Kdf: Kdf: KdfType @@ -846,7 +851,7 @@ class Kdf: KdfParallelism: int | None = None @classmethod - def from_ConnectToken( + def from_connect_token( cls, token: "vaultwarden.clients.bitwarden.ConnectToken" ): return cls( diff --git a/src/vaultwarden/models/sync.py b/src/vaultwarden/models/sync.py index 95ba75a..01a0033 100644 --- a/src/vaultwarden/models/sync.py +++ b/src/vaultwarden/models/sync.py @@ -1,15 +1,13 @@ import time from uuid import UUID -import pydantic from pydantic import AliasChoices, Field, field_validator +from src.vaultwarden.models.enum import KdfType from vaultwarden.models.enum import VaultwardenUserStatus from vaultwarden.models.permissive_model import PermissiveBaseModel from vaultwarden.utils.crypto import decrypt -from src.vaultwarden.models.enum import KdfType - class ConnectToken(PermissiveBaseModel): Kdf: KdfType = KdfType.Pbkdf2 diff --git a/src/vaultwarden/utils/crypto.py b/src/vaultwarden/utils/crypto.py index 3640a14..22c14fb 100644 --- a/src/vaultwarden/utils/crypto.py +++ b/src/vaultwarden/utils/crypto.py @@ -115,9 +115,7 @@ def is_encrypted(cipher_string): return True -def make_master_key( - password: str, salt: str, kdf: "vaultwarden.models.bitwarden.Kdf" -): +def make_master_key(password: str, salt: str, kdf: "vaultwarden.models.bitwarden.Kdf"): import vaultwarden.models.bitwarden assert isinstance(salt, str) @@ -186,9 +184,7 @@ def aes_encrypt(plaintext, key, charset="utf-8"): def encrypt_sym(plaintext, key, to_bytes=False, *a, **kw): # inspired from bitwarden/jslib:src/services/crypto.service.ts - typ, (iv, ct, mac) = int(CIPHERS.sym), aes_encrypt( - plaintext, key, *a, **kw - ) + typ, (iv, ct, mac) = int(CIPHERS.sym), aes_encrypt(plaintext, key, *a, **kw) if mac: mac = mac.digest() if to_bytes: @@ -269,9 +265,7 @@ def decrypt_bytes(cipher_bytes, key, *a, **kw): ct = cipher_bytes[49:] ret = decrypt_sym(ct, key, iv, mac) else: - raise UnimplementedError( - f"{typ} encType decryption is not implemented" - ) + raise UnimplementedError(f"{typ} encType decryption is not implemented") return ret From 640967ac9ef68f0de68304e769bef13f3d41bbc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Tue, 13 Jan 2026 10:23:45 +0100 Subject: [PATCH 4/7] linting --- src/vaultwarden/models/bitwarden.py | 3 +-- src/vaultwarden/models/sync.py | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/vaultwarden/models/bitwarden.py b/src/vaultwarden/models/bitwarden.py index 86d3b92..f028d0b 100644 --- a/src/vaultwarden/models/bitwarden.py +++ b/src/vaultwarden/models/bitwarden.py @@ -27,10 +27,9 @@ ValidationInfo, ValidatorFunctionWrapHandler, ) -from src.vaultwarden.models.enum import KdfType from vaultwarden.clients.bitwarden import BitwardenAPIClient -from vaultwarden.models.enum import CipherType, OrganizationUserType +from vaultwarden.models.enum import CipherType, OrganizationUserType, KdfType from vaultwarden.models.exception_models import BitwardenError from vaultwarden.models.permissive_model import PermissiveBaseModel from vaultwarden.utils.crypto import decrypt, encrypt diff --git a/src/vaultwarden/models/sync.py b/src/vaultwarden/models/sync.py index 01a0033..3044ce2 100644 --- a/src/vaultwarden/models/sync.py +++ b/src/vaultwarden/models/sync.py @@ -2,9 +2,8 @@ from uuid import UUID from pydantic import AliasChoices, Field, field_validator -from src.vaultwarden.models.enum import KdfType -from vaultwarden.models.enum import VaultwardenUserStatus +from vaultwarden.models.enum import KdfType, VaultwardenUserStatus from vaultwarden.models.permissive_model import PermissiveBaseModel from vaultwarden.utils.crypto import decrypt From 79840cc8801763247d14d3ce8f47221b85067bca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Wed, 14 Jan 2026 13:10:03 +0100 Subject: [PATCH 5/7] re-use lookup --- src/vaultwarden/models/bitwarden.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/vaultwarden/models/bitwarden.py b/src/vaultwarden/models/bitwarden.py index f028d0b..1867d23 100644 --- a/src/vaultwarden/models/bitwarden.py +++ b/src/vaultwarden/models/bitwarden.py @@ -223,14 +223,14 @@ def set_key( handler: ModelWrapValidatorHandler[Self], info: ValidationInfo, ) -> Self: - if data.get("key") is not None: + if (key := data.get("key")) is not None: info.context["cctx"].append( - decrypt(data["key"], info.context["cctx"][0]) + decrypt(key, info.context["cctx"][0]) ) v = handler(data) - if data.get("key") is not None: + if key is not None: info.context["cctx"].pop() return v @@ -796,7 +796,7 @@ def _get_ciphers(self) -> list[CipherDetails]: context={ "parent_id": self.Id, "client": self.api_client, - "cctx": [org_key], + "cctx": [org_key], # crypto context }, ) return res.Data From 80d0eea77ed88057933b36e3d8af9fc6c130619f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Thu, 15 Jan 2026 16:26:40 +0100 Subject: [PATCH 6/7] hatch run types:check --- src/vaultwarden/models/bitwarden.py | 25 +++++++++++++++---------- src/vaultwarden/utils/crypto.py | 22 ++++++++++++---------- 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/src/vaultwarden/models/bitwarden.py b/src/vaultwarden/models/bitwarden.py index 1867d23..af19483 100644 --- a/src/vaultwarden/models/bitwarden.py +++ b/src/vaultwarden/models/bitwarden.py @@ -66,13 +66,15 @@ def api_client(self) -> BitwardenAPIClient: def decode_bytes( value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo -) -> str: - for key in info.context["cctx"][::-1]: +) -> bytes: + context: dict = cast(dict, info.context) + keys: list[bytes] = cast(list[bytes], context.get("cctx")) + for key in keys[::-1]: try: return decrypt(handler(value), key) except Exception: continue - + raise ValueError(f"No key found") def decode_string( value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo @@ -224,14 +226,17 @@ def set_key( info: ValidationInfo, ) -> Self: if (key := data.get("key")) is not None: - info.context["cctx"].append( - decrypt(key, info.context["cctx"][0]) + context = cast(dict, info.context) + cctx = cast(list[bytes], context.get("cctx")) + + cctx.append( + decrypt(key, cctx[0]) ) v = handler(data) if key is not None: - info.context["cctx"].pop() + cctx.pop() return v @@ -280,7 +285,7 @@ def update_collection(self, collections: list[UUID]): class Login(_CipherBase): - Type: Literal[1] + Type: Literal[CipherType.Login] login: LoginData | None = None secureNote: None = None @@ -291,7 +296,7 @@ class Login(_CipherBase): class SecureNote(_CipherBase): - Type: Literal[2] + Type: Literal[CipherType.SecureNote] login: None = None secureNote: SecureNoteProperty | None = None @@ -302,7 +307,7 @@ class SecureNote(_CipherBase): class Card(_CipherBase): - Type: Literal[3] + Type: Literal[CipherType.Card] login: None = None card: None = None @@ -313,7 +318,7 @@ class Card(_CipherBase): class Identity(_CipherBase): - Type: Literal[4] + Type: Literal[CipherType.Identity] login: None = None secureNote: None = None diff --git a/src/vaultwarden/utils/crypto.py b/src/vaultwarden/utils/crypto.py index 22c14fb..9353546 100644 --- a/src/vaultwarden/utils/crypto.py +++ b/src/vaultwarden/utils/crypto.py @@ -14,11 +14,14 @@ from hashlib import pbkdf2_hmac, sha256 from hmac import new as hmac_new from secrets import token_bytes +import typing from Crypto.Cipher import AES, PKCS1_OAEP from Crypto.PublicKey import RSA from hkdf import hkdf_expand +if typing.TYPE_CHECKING: + import vaultwarden.models.bitwarden class CIPHERS(IntEnum): sym = 2 @@ -115,24 +118,26 @@ def is_encrypted(cipher_string): return True -def make_master_key(password: str, salt: str, kdf: "vaultwarden.models.bitwarden.Kdf"): +def make_master_key(password_: str, salt_: str, kdf: "vaultwarden.models.bitwarden.Kdf"): import vaultwarden.models.bitwarden - assert isinstance(salt, str) - assert isinstance(password, str) + assert isinstance(salt_, str) + assert isinstance(password_, str) - salt = salt.lower() - password = password.encode("utf-8") - salt = salt.encode("utf-8") + password = password_.encode("utf-8") + salt = salt_.lower().encode("utf-8") match kdf.Kdf: case vaultwarden.models.bitwarden.KdfType.Pbkdf2: + assert kdf.KdfIterations is not None return pbkdf2_hmac("sha256", password, salt, kdf.KdfIterations) case vaultwarden.models.bitwarden.KdfType.Argon2id: # c.f. # https://github.com/vaultwarden/vw_web_builds/blob/355bddc6c9d5c110e55fe74c5fcfa86ddd85572c/libs/common/src/platform/services/key-generation.service.ts#L55-L75 import argon2 - + assert kdf.KdfIterations is not None + assert kdf.KdfMemory is not None + assert kdf.KdfParallelism is not None hsalt = hashlib.new("sha256", salt).digest() v = argon2.low_level.hash_secret_raw( password, @@ -144,9 +149,6 @@ def make_master_key(password: str, salt: str, kdf: "vaultwarden.models.bitwarden type=argon2.Type.ID, ) return v - case _: - return None - def hash_password(password, salt, iterations=ITERATIONS): """base64-encode a wrapped, stretched password+salt(email) for signup/login""" From 63bcf866602596dcf604b8e8a6c8e1a54452d6c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Thu, 29 Jan 2026 07:09:08 +0100 Subject: [PATCH 7/7] linting --- src/vaultwarden/models/bitwarden.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/vaultwarden/models/bitwarden.py b/src/vaultwarden/models/bitwarden.py index af19483..17de6c0 100644 --- a/src/vaultwarden/models/bitwarden.py +++ b/src/vaultwarden/models/bitwarden.py @@ -29,7 +29,7 @@ ) from vaultwarden.clients.bitwarden import BitwardenAPIClient -from vaultwarden.models.enum import CipherType, OrganizationUserType, KdfType +from vaultwarden.models.enum import CipherType, KdfType, OrganizationUserType from vaultwarden.models.exception_models import BitwardenError from vaultwarden.models.permissive_model import PermissiveBaseModel from vaultwarden.utils.crypto import decrypt, encrypt @@ -74,7 +74,8 @@ def decode_bytes( return decrypt(handler(value), key) except Exception: continue - raise ValueError(f"No key found") + raise ValueError("No key found") + def decode_string( value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo @@ -229,9 +230,7 @@ def set_key( context = cast(dict, info.context) cctx = cast(list[bytes], context.get("cctx")) - cctx.append( - decrypt(key, cctx[0]) - ) + cctx.append(decrypt(key, cctx[0])) v = handler(data) @@ -801,7 +800,7 @@ def _get_ciphers(self) -> list[CipherDetails]: context={ "parent_id": self.Id, "client": self.api_client, - "cctx": [org_key], # crypto context + "cctx": [org_key], # crypto context }, ) return res.Data