diff --git a/src/vaultwarden/clients/bitwarden.py b/src/vaultwarden/clients/bitwarden.py index 9e48b09..8c8858e 100644 --- a/src/vaultwarden/clients/bitwarden.py +++ b/src/vaultwarden/clients/bitwarden.py @@ -67,6 +67,12 @@ def _refresh_connect_token(self): ) self._connect_token = ConnectToken.model_validate_json(resp.text) + self._connect_token.master_key = make_master_key( + password=self.password, + salt=self.email, + iterations=self._connect_token.KdfIterations, + ) + def _set_connect_token(self): headers = { "content-type": "application/x-www-form-urlencoded; charset=utf-8", diff --git a/src/vaultwarden/clients/vaultwarden.py b/src/vaultwarden/clients/vaultwarden.py index 78e95c8..f38260f 100644 --- a/src/vaultwarden/clients/vaultwarden.py +++ b/src/vaultwarden/clients/vaultwarden.py @@ -1,6 +1,6 @@ import http from http.cookiejar import Cookie -from typing import Any, Literal, Optional +from typing import Any, Literal from uuid import UUID from httpx import Client, HTTPStatusError, Response @@ -43,7 +43,7 @@ def __init__( if preload_users: self._load_users() - def _get_admin_cookie(self) -> Optional[Cookie]: + def _get_admin_cookie(self) -> Cookie | None: """Get the session cookie, required to authenticate requests""" bw_cookies = ( c for c in self._http_client.cookies.jar if c.name == "VW_ADMIN" diff --git a/src/vaultwarden/models/bitwarden.py b/src/vaultwarden/models/bitwarden.py index 19f755f..df7ec7d 100644 --- a/src/vaultwarden/models/bitwarden.py +++ b/src/vaultwarden/models/bitwarden.py @@ -1,12 +1,13 @@ from typing import Generic, TypeVar from uuid import UUID -from pydantic import BaseModel, Field, TypeAdapter, field_validator +from pydantic import AliasChoices, Field, TypeAdapter, field_validator from pydantic_core.core_schema import FieldValidationInfo from vaultwarden.clients.bitwarden import BitwardenAPIClient from vaultwarden.models.enum import CipherType, OrganizationUserType from vaultwarden.models.exception_models import BitwardenError +from vaultwarden.models.permissive_model import PermissiveBaseModel from vaultwarden.utils.crypto import decrypt, encrypt # Pydantic models for Bitwarden data structures @@ -14,13 +15,11 @@ T = TypeVar("T", bound="BitwardenBaseModel") -class ResplistBitwarden(BaseModel, Generic[T]): +class ResplistBitwarden(PermissiveBaseModel, Generic[T]): Data: list[T] -class BitwardenBaseModel( - BaseModel, extra="allow", arbitrary_types_allowed=True -): +class BitwardenBaseModel(PermissiveBaseModel): bitwarden_client: BitwardenAPIClient | None = Field( default=None, validate_default=True, exclude=True ) @@ -105,7 +104,11 @@ class CollectionAccess(BitwardenBaseModel): class CollectionUser(CollectionAccess): CollectionId: UUID | None = Field(None, validate_default=True) - UserId: UUID | None = Field(None, alias="Id", serialization_alias="Id") + UserId: UUID | None = Field( + None, + validation_alias=AliasChoices("id", "Id"), + serialization_alias="id", + ) @field_validator("CollectionId") @classmethod @@ -117,7 +120,9 @@ def set_id(cls, v, info: FieldValidationInfo): class UserCollection(CollectionAccess): CollectionId: UUID | None = Field( - None, alias="Id", serialization_alias="Id" + None, + validation_alias=AliasChoices("id", "Id"), + serialization_alias="id", ) UserId: UUID | None = Field(None, validate_default=True) @@ -133,7 +138,7 @@ class OrganizationCollection(BitwardenBaseModel): Id: UUID | None = None OrganizationId: UUID | None = Field(None, validate_default=True) Name: str - ExternalId: str | None + ExternalId: str | None = None @field_validator("OrganizationId") @classmethod @@ -219,7 +224,7 @@ def add_collections(self, collections: list[UUID]): if collection in _current_collections: continue user = UserCollection( - Id=collection, + CollectionId=collection, UserId=self.Id, ReadOnly=False, HidePasswords=False, @@ -284,7 +289,7 @@ def update_collection(self, collections: list[UUID]): self.Collections = [ UserCollection( UserId=self.Id, - Id=coll, + CollectionId=coll, ReadOnly=False, HidePasswords=False, ) @@ -484,9 +489,9 @@ def collections( def create_collection(self, name: str) -> OrganizationCollection: org_key = self.key() data = { - "Name": encrypt(2, name, self.key()), - "Groups": [], - "Users": [], + "name": encrypt(2, name, self.key()), + "groups": [], + "users": [], } resp = self.api_client.api_request( "POST", f"api/organizations/{self.Id}/collections", json=data diff --git a/src/vaultwarden/models/permissive_model.py b/src/vaultwarden/models/permissive_model.py new file mode 100644 index 0000000..9839a0c --- /dev/null +++ b/src/vaultwarden/models/permissive_model.py @@ -0,0 +1,13 @@ +from pydantic import BaseModel + +from vaultwarden.utils.string_cases import pascal_case_to_camel_case + + +class PermissiveBaseModel( + BaseModel, + extra="allow", + alias_generator=pascal_case_to_camel_case, + populate_by_name=True, + arbitrary_types_allowed=True, +): + pass diff --git a/src/vaultwarden/models/sync.py b/src/vaultwarden/models/sync.py index af2c088..4978eda 100644 --- a/src/vaultwarden/models/sync.py +++ b/src/vaultwarden/models/sync.py @@ -1,13 +1,14 @@ import time from uuid import UUID -from pydantic import BaseModel, Field, field_validator +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 -class ConnectToken(BaseModel, extra="allow"): +class ConnectToken(PermissiveBaseModel): Kdf: int = 0 KdfIterations: int = 0 KdfMemory: int | None = None @@ -31,9 +32,7 @@ def expires_in_to_time(cls, v): def is_expired(self, now=None): if now is None: now = time.time() - if (self.expires_in is not None) and (self.expires_in <= now): - return True - return False + return (self.expires_in is not None) and (self.expires_in <= now) @property def user_key(self): @@ -44,7 +43,7 @@ def orgs_key(self): return decrypt(self.PrivateKey, self.user_key) -class ProfileOrganization(BaseModel, extra="allow"): +class ProfileOrganization(PermissiveBaseModel): Id: UUID Name: str Key: str | None = None @@ -67,7 +66,7 @@ class ProfileOrganization(BaseModel, extra="allow"): UseTotp: bool -class UserProfile(BaseModel, extra="allow"): +class UserProfile(PermissiveBaseModel): AvatarColor: str | None Culture: str Email: str @@ -78,14 +77,16 @@ class UserProfile(BaseModel, extra="allow"): MasterPasswordHint: str | None Name: str Object: str | None - Organizations: list[ProfileOrganization] = [] + Organizations: list[ProfileOrganization] Premium: bool PrivateKey: str | None - ProviderOrganizations: list = [] - Providers: list = [] + ProviderOrganizations: list + Providers: list SecurityStamp: str TwoFactorEnabled: bool - status: VaultwardenUserStatus = Field(alias="_Status") + status: VaultwardenUserStatus = Field( + validation_alias=AliasChoices("_status", "_Status") + ) class VaultwardenUser(UserProfile): @@ -95,11 +96,11 @@ class VaultwardenUser(UserProfile): # TODO: add definition of attribute's types -class SyncData(BaseModel, extra="allow"): - Ciphers: list[dict] = [] - Collections: list[dict] = [] - Domains: dict = {} - Folders: list[dict] = [] - Policies: list[dict] = [] +class SyncData(PermissiveBaseModel): + Ciphers: list[dict] + Collections: list[dict] + Domains: dict | None + Folders: list[dict] + Policies: list[dict] Profile: UserProfile - Sends: list[dict] = [] + Sends: list[dict] diff --git a/src/vaultwarden/utils/string_cases.py b/src/vaultwarden/utils/string_cases.py new file mode 100644 index 0000000..a23fc65 --- /dev/null +++ b/src/vaultwarden/utils/string_cases.py @@ -0,0 +1,10 @@ +def pascal_case_to_camel_case(pascal: str) -> str: + """Convert a PascalCase string to camelCase. + + Args: + pascal: The string to convert. + + Returns: + The converted camelCase string. + """ + return pascal[0].lower() + pascal[1:]