From 15b0575b81f2c114a2952e55d6ba3d28f38ced53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Mon, 1 Jun 2026 08:36:26 +0200 Subject: [PATCH 01/35] tests - read from .env --- tests/e2e/test_bitwarden.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/e2e/test_bitwarden.py b/tests/e2e/test_bitwarden.py index 2a566a0..ce5f00b 100644 --- a/tests/e2e/test_bitwarden.py +++ b/tests/e2e/test_bitwarden.py @@ -1,9 +1,20 @@ import os import unittest +from pathlib import Path from vaultwarden.clients.bitwarden import BitwardenAPIClient from vaultwarden.models.bitwarden import get_organization + +env = Path("tests/.env").read_text() +for line in env.splitlines(): + k,v = line.strip().split(":", maxsplit=1) + v = v.strip().strip('"') + if os.environ.get(k) is None: + print(f"{k} = {v}") + os.environ[k] = v + + # Get Bitwarden credentials from environment variables url = os.environ.get("BITWARDEN_URL", None) email = os.environ.get("BITWARDEN_EMAIL", None) From 5425dd300a6af2dccd69048b54ae49884c3b11a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Sun, 7 Jun 2026 10:41:34 +0200 Subject: [PATCH 02/35] crypto - refactor --- src/vaultwarden/utils/crypto.py | 375 +++++++++++++++++++------------- 1 file changed, 227 insertions(+), 148 deletions(-) diff --git a/src/vaultwarden/utils/crypto.py b/src/vaultwarden/utils/crypto.py index 7b47841..4f64bcf 100644 --- a/src/vaultwarden/utils/crypto.py +++ b/src/vaultwarden/utils/crypto.py @@ -2,7 +2,6 @@ # -*- coding: utf-8 -*- # Original source: # https://github.com/corpusops/bitwardentools/blob/main/src/bitwardentools/crypto.py -from __future__ import absolute_import, division, print_function import base64 import hashlib @@ -19,26 +18,187 @@ from Crypto.Cipher import AES, PKCS1_OAEP from Crypto.PublicKey import RSA from hkdf import hkdf_expand +from typing_extensions import override if typing.TYPE_CHECKING: import vaultwarden.models.bitwarden class CIPHERS(IntEnum): + null = 0 sym = 2 asym = 4 CACHE = {} # type: ignore -ITERATIONS = 2000000 -ENCODED_CIPHER = { - CIPHERS.sym: "{typ}.{b64_iv}|{b64_ct}|{b64_digest}", - CIPHERS.asym: "{typ}.{b64_ct}", -} ENCRYPTED_STRING_RE = re.compile("^[0-9][.].*=.*", flags=re.I | re.M) SYM_ENCRYPTED_STRING_RE = re.compile( "^2[.][^=]+=+[|][^=]+=+[|][^=]+=+", flags=re.I | re.M ) +class _Cipher: + TYPE: int + ENCODING: str + @classmethod + def encrypt(cls, plainbytes:bytes, key:bytes) -> str: + raise NotImplementedError() + + def decrypt(self, data:bytes, key: bytes) -> bytes: + raise NotImplementedError() + +class AsymmetricCipher(_Cipher): + TYPE = CIPHERS.asym + ENCODING = "{typ}.{b64_ct}" + @classmethod + def parse(cls, ct:str) -> tuple[typing.Self, bytes]: + return cls(), b64decode(ct) + + @classmethod + def encrypt(cls, plainbytes: bytes, key: bytes): + assert isinstance(plainbytes, bytes) + assert isinstance(key, bytes) + cipher = PKCS1_OAEP.new(load_rsa_key(key)).encrypt(plainbytes) + b64_ct = b64encode(cipher).decode() + return cls.ENCODING.format(cipher=cipher, b64_ct=b64_ct) + + def decrypt(self, ct:bytes, key: bytes): + assert isinstance(ct, bytes) + assert isinstance(key, bytes) + return PKCS1_OAEP.new(load_rsa_key(key)).decrypt(ct) + + +class SymmetricCipher(_Cipher): + TYPE = CIPHERS.sym + ENCODING = "{typ}.{b64_iv}|{b64_ct}|{b64_digest}" + def __init__(self, iv:bytes, mac:bytes): + self._iv = iv + self._mac = mac + + @classmethod + def parse(cls, ct: str) -> tuple[typing.Self, bytes]: + iv, ct, mac = ct.split("|", 3) + return cls(b64decode(iv), b64decode(mac)[0:32]), b64decode(ct) + + @classmethod + def encrypt(cls, plainbytes: bytes, key: bytes) -> str: + assert isinstance(plainbytes, bytes) + assert isinstance(key, bytes) + return cls._encrypt_sym(plainbytes, key) + + + def decrypt(self, ct: bytes, key: bytes) -> bytes: + assert isinstance(ct, bytes) + assert isinstance(key, bytes) + return SymmetricCipher._decrypt_sym(dct=ct, key=key, div=self._iv, dmac=self._mac) + + + @staticmethod + def _get_enc_mac(key:bytes) -> tuple[bytes, bytes]: + assert isinstance(key, bytes) + # symmetric master_key of the user + if len(key) == 32: + enc = hkdf_expand(key, b"enc", 32, sha256) + mac = hkdf_expand(key, b"mac", 32, sha256) + # symmetric key of an organization + elif len(key) == 64: + enc = key[:32] + mac = key[32:] + return enc, mac + + @staticmethod + def _decrypt_sym(dct:bytes, key:bytes, div:bytes, dmac:bytes) -> bytes: + assert isinstance(dct, bytes) + assert isinstance(key, bytes) + assert isinstance(div, bytes) + assert isinstance(dmac, bytes) + + enc, mac = SymmetricCipher._get_enc_mac(key) + hdmac = hmac_new(mac, div + dct, sha256).digest() + if hdmac != dmac: + raise DecryptError( + f"Symmetric hmac verification failed {bytes(hdmac).hex()} / {bytes(dmac).hex()}. Check your password." + ) + c = AES.new(enc, AES.MODE_CBC, div) + plaintext = c.decrypt(dct) + pad_len = plaintext[-1] + padding = bytes([pad_len] * pad_len) + if plaintext[-pad_len:] == padding: + plaintext = plaintext[:-pad_len] + return plaintext + + @classmethod + def _encrypt_sym(cls, plaintext: bytes, key: bytes) -> str: + assert isinstance(plaintext, bytes) + assert isinstance(key, bytes) + # inspired from bitwarden/jslib:src/services/crypto.service.ts + typ = int(CIPHERS.sym) + (iv, ct, mac) = aes_encrypt(plaintext, key) + # jslib: encrypt() + b64_iv = b64encode(iv).decode() + b64_ct = b64encode(ct).decode() + b64_digest = "" + if mac: + b64_digest = b64encode(mac).decode() + return cls.ENCODING.format(typ=CIPHERS.sym, b64_iv=b64_iv, b64_ct=b64_ct,b64_digest=b64_digest) + + +class BinarySymmetricCipher: + ENCODING = b"%(typ)c%(iv)16b%(mac)32b%(ct)b" + + def __init__(self, iv:bytes, mac:bytes): + self._iv = iv + self._mac = mac + + @classmethod + def parse(cls, cipher_bytes: bytes) -> tuple[typing.Self, bytes]: + iv = cipher_bytes[1:17] + mac = cipher_bytes[17:49] + ct = cipher_bytes[49:] + return cls(iv, mac), ct + + + def decrypt(self, ct: bytes, key: bytes) -> bytes: + assert isinstance(ct, bytes) + assert isinstance(key, bytes) + return SymmetricCipher._decrypt_sym(dct=ct, key=key, div=self._iv, dmac=self._mac) + + + @classmethod + def encrypt(cls, plainbytes: bytes, key: bytes) -> bytes: + assert isinstance(plainbytes, bytes) + assert isinstance(key, bytes) + return cls._encrypt_sym_bytes(plainbytes, key) + + @classmethod + def _encrypt_sym_bytes(cls, plainbytes: bytes, key: bytes) -> bytes: + assert isinstance(plainbytes, bytes) + assert isinstance(key, bytes) + # inspired from bitwarden/jslib:src/services/crypto.service.ts + typ = int(CIPHERS.sym) + (iv, ct, mac) = aes_encrypt(plainbytes, key) + # jslib: encryptToBytes() + ret = chr(typ).encode() + ret += iv + if mac: + ret += mac + ret += ct + + assert cls.ENCODING % {"typ": typ, "iv": iv, "mac": mac, "ct": ct} == ret + return ret + + +class NullCipher(_Cipher): + TYPE = CIPHERS.null + def __init__(self, iv, ct): + self._iv = iv + self._ct = ct + + @classmethod + def parse(cls, ct): + iv, ct, mac = ct.split("|", 2) + iv = b64decode(iv) + ct = b64decode(ct) + return cls(iv), ct + class UnimplementedError(Exception): """.""" @@ -68,48 +228,27 @@ class DecryptError(ValueError): """.""" -def decode_cipher_string(cipher_string): +def decode_cipher_string(cipher_string: str) -> tuple[_Cipher, bytes]: """decode a cipher tring into it's parts""" - iv = None - mac = None - assert cipher_string is not None + assert isinstance(cipher_string, str) if not ENCRYPTED_STRING_RE.match(cipher_string): raise WrongFormatError(f"{cipher_string}") try: - typ = cipher_string[0:1] - typ = int(typ) + typ = CIPHERS(int(cipher_string[0:1])) assert typ < 9 except (AssertionError, ValueError): raise WrongTypeDecryptError(f"{typ} is not valid") - ct = cipher_string[2:] - if typ == CIPHERS.asym: - pass - else: - try: - if typ == 0: - iv, ct = ct.split("|", 2) - else: - iv, ct, mac = ct.split("|", 3) - except Exception: - raise MissingPartsDecryptError(f"{ct} is missing parts") - if iv: - try: - iv = b64decode(iv) - except Exception: - raise B64DecryptError(f"iv {iv} not valid") - if mac: - try: - mac = b64decode(mac)[0:32] - except Exception: - raise B64DecryptError(f"mac {mac} not valid") - try: - ct = b64decode(ct) - except Exception: - raise B64DecryptError(f"ct {ct} not valid") - return typ, iv, ct, mac + data = cipher_string[2:] + match typ: + case CIPHERS.asym: + return AsymmetricCipher.parse(data) + case CIPHERS.sym: + return SymmetricCipher.parse(data) + case CIPHERS.null: + return NullCipher.parse(data) -def is_encrypted(cipher_string): +def is_encrypted(cipher_string: str) -> bool: # FIXME unused try: decode_cipher_string(cipher_string) except DecodeEncKeyError: @@ -150,16 +289,16 @@ def make_master_key(password: str, salt: str, kdf: "vaultwarden.models.bitwarden ) return v -def hash_password(password, salt, iterations=ITERATIONS): +def hash_password(password: str, salt: str, kdf: "vaultwarden.models.bitwarden.Kdf"): # FIXME UNUSED """base64-encode a wrapped, stretched password+salt(email) for signup/login""" - if not hasattr(password, "decode"): - password = password.encode("utf-8") - master_key = make_master_key(password, salt, iterations) - hashpw = hashlib.pbkdf2_hmac("sha256", master_key, password, 1) + assert isinstance(password, str) + assert isinstance(salt, str) + master_key = make_master_key(password, salt, kdf) + hashpw = hashlib.pbkdf2_hmac("sha256", master_key, password.encode(), 1) return base64.b64encode(hashpw), master_key -def load_rsa_key(key): +def load_rsa_key(key: bytes) -> RSA.RsaKey: rsakeys = CACHE.setdefault("rsa", {}) if not isinstance(key, RSA.RsaKey): try: @@ -170,10 +309,12 @@ def load_rsa_key(key): return key -def aes_encrypt(plaintext, key, charset="utf-8"): - enc, mac = get_sym_enc_mac(key) - if not hasattr(plaintext, "decode"): - plaintext = plaintext.encode(charset) +def aes_encrypt(plaintext: bytes, key: bytes) -> tuple[bytes, bytes, bytes]: + assert isinstance(plaintext, bytes) + assert isinstance(key, bytes) + + enc, mac = SymmetricCipher._get_enc_mac(key) + pad_len = 16 - len(plaintext) % 16 padding = bytes([pad_len] * pad_len) content = plaintext + padding @@ -181,106 +322,47 @@ def aes_encrypt(plaintext, key, charset="utf-8"): c = AES.new(enc, AES.MODE_CBC, iv) ct = c.encrypt(content) cmac = hmac_new(mac, iv + ct, sha256) - return iv, ct, cmac - - -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) - if mac: - mac = mac.digest() - if to_bytes: - # jslib: encryptToBytes() - ret = chr(typ).encode() - ret += iv - if mac: - ret += mac - ret += ct - else: - # jslib: encrypt() - b64_iv = b64encode(iv).decode() - b64_ct = b64encode(ct).decode() - b64_digest = "" - if mac: - b64_digest = b64encode(mac).decode() - ret = ENCODED_CIPHER[typ].format(**locals()) - return ret + return iv, ct, cmac.digest() -def encrypt_sym_to_bytes(plaintext, key, *a, **kw): - kw["to_bytes"] = True - return encrypt_sym(plaintext, key, *a, **kw) +def encrypt_sym_to_bytes(plaintext: str, key: bytes): # FIXME migrated + assert isinstance(plaintext, str) + return BinarySymmetricCipher.encrypt(plaintext.encode("utf-8"), key) -def encrypt_asym(plaintext, key, *a, **kw): - cipher = PKCS1_OAEP.new(load_rsa_key(key)).encrypt(plaintext) - b64_ct = b64encode(cipher).decode() - typ = CIPHERS.asym - return ENCODED_CIPHER[typ].format(**locals()) +def encrypt(typ:CIPHERS|int, plaintext: str, key: bytes): + assert isinstance(typ, (CIPHERS, int)), typ + assert isinstance(plaintext, str) + assert isinstance(key, bytes) + plainbytes = plaintext.encode("utf-8") + match typ: + case AsymmetricCipher.TYPE: + return AsymmetricCipher.encrypt(plainbytes, key) + case SymmetricCipher.TYPE: + return SymmetricCipher.encrypt(plainbytes, key) + case _: + raise UnimplementedError(f"can not encrypt type:{typ}") -def encrypt(typ, plaintext, key, *a, **kw): - try: - enc = ENCRYPT[typ] - except KeyError: - raise UnimplementedError(f"can not encrypt type:{typ}") - return enc(plaintext=plaintext, key=key, *a, **kw) - - -def get_sym_enc_mac(key): - # symmetric master_key of the user - if len(key) == 32: - enc = hkdf_expand(key, b"enc", 32, sha256) - mac = hkdf_expand(key, b"mac", 32, sha256) - # symmetric key of an organization - elif len(key) == 64: - enc = key[:32] - mac = key[32:] - return enc, mac - - -def decrypt_sym(dct, key, div, dmac, *a, **kw): - enc, mac = get_sym_enc_mac(key) - hdmac = hmac_new(mac, div + dct, sha256).digest() - if hdmac != dmac: - raise DecryptError( - f"Symmetric hmac verification failed {bytes(hdmac).hex()} / {bytes(dmac).hex()}. Check your password." - ) - c = AES.new(enc, AES.MODE_CBC, div) - plaintext = c.decrypt(dct) - pad_len = plaintext[-1] - padding = bytes([pad_len] * pad_len) - if plaintext[-pad_len:] == padding: - plaintext = plaintext[:-pad_len] - return plaintext -def decrypt_asym(dct, key, *a, **kw): - return PKCS1_OAEP.new(load_rsa_key(key)).decrypt(dct) +def decrypt_bytes(cipher_bytes: bytes, key: bytes): # FIXME UNUSED + assert isinstance(cipher_bytes, bytes) + assert isinstance(key, bytes) + typ = cipher_bytes[0] + match typ: + case SymmetricCipher.TYPE: + cipher, ct = BinarySymmetricCipher.parse(cipher_bytes) + return cipher.decrypt(ct, key) + case _: + raise UnimplementedError(f"{typ} encType decryption is not implemented") +def decrypt(cipher_string: str, key:bytes) -> bytes: + assert isinstance(cipher_string, str) + cipher, ct = decode_cipher_string(cipher_string) + return cipher.decrypt(ct, key) -def decrypt_bytes(cipher_bytes, key, *a, **kw): - ret, typ = None, cipher_bytes[0] - if typ in [2]: - iv = cipher_bytes[1:17] - mac = cipher_bytes[17:49] - ct = cipher_bytes[49:] - ret = decrypt_sym(ct, key, iv, mac) - else: - raise UnimplementedError(f"{typ} encType decryption is not implemented") - return ret - - -def decrypt(cipher_string, key, *a, **kw): - typ, iv, ct, mac = decode_cipher_string(cipher_string) - try: - dec = DECRYPT[typ] - except KeyError: - raise UnimplementedError(f"can not decrypt type:{typ}") - return dec(div=iv, dct=ct, dmac=mac, key=key, *a, **kw) - - -def strech_key(key): +def strech_key(key: bytes) -> bytes: stretched_key = key if len(stretched_key) < 64: stretched_key = hkdf_expand(key, b"enc", 32, sha256) + hkdf_expand( @@ -288,24 +370,23 @@ def strech_key(key): ) return stretched_key - -def make_sym_key(master_key): +def make_sym_key(master_key: bytes) -> tuple[str, bytes]: # FIXME UNUSED stretched_key = strech_key(master_key) plaintext = token_bytes(64) - return encrypt_sym(plaintext, stretched_key), plaintext + return SymmetricCipher.encrypt(plaintext, stretched_key), plaintext -def make_asym_key(key, stretch=True): +def make_asym_key(key:bytes, stretch=True) -> tuple[str, bytes, bytes]: # FIXME UNUSED if stretch: key = strech_key(key) asym_key = RSA.generate(2048) public_key = asym_key.publickey().exportKey("DER") private_key = asym_key.exportKey("DER", pkcs=8) - return encrypt_sym(private_key, key), public_key, private_key + return SymmetricCipher.encrypt(private_key, key), public_key, private_key -def gen_password(length=32, alphabet=None): - alphabet = string.ascii_letters + string.digits +def gen_password(length=32, alphabet=None) -> str: # FIXME UNUSED + alphabet = alphabet or string.ascii_letters + string.digits while True: password = "".join(secrets.choice(alphabet) for i in range(length)) if ( @@ -317,6 +398,4 @@ def gen_password(length=32, alphabet=None): return password -DECRYPT = {CIPHERS.sym: decrypt_sym, CIPHERS.asym: decrypt_asym} -ENCRYPT = {CIPHERS.sym: encrypt_sym, CIPHERS.asym: encrypt_asym} # vim:set et sts=4 ts=4 tw=120: From 0bf584306521e55d7d5c2608295e96ab032ebb02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Sun, 7 Jun 2026 10:47:22 +0200 Subject: [PATCH 03/35] models - using a WrapSerializer to encrypt values --- src/vaultwarden/models/bitwarden.py | 178 +++++++++++++++++++++------- 1 file changed, 136 insertions(+), 42 deletions(-) diff --git a/src/vaultwarden/models/bitwarden.py b/src/vaultwarden/models/bitwarden.py index 8e81b68..d11521a 100644 --- a/src/vaultwarden/models/bitwarden.py +++ b/src/vaultwarden/models/bitwarden.py @@ -1,4 +1,4 @@ -import dataclasses +from contextvars import ContextVar import datetime import sys from typing import ( @@ -18,12 +18,16 @@ Field, ModelWrapValidatorHandler, TypeAdapter, + WrapSerializer, WrapValidator, field_validator, + model_serializer, model_validator, ) from pydantic_core.core_schema import ( FieldValidationInfo, + SerializationInfo, + SerializerFunctionWrapHandler, ValidationInfo, ValidatorFunctionWrapHandler, ) @@ -33,7 +37,7 @@ 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 +from vaultwarden.utils.crypto import SymmetricCipher, decrypt, encrypt if TYPE_CHECKING: import vaultwarden.clients.bitwarden @@ -48,6 +52,17 @@ T = TypeVar("T", bound="BitwardenBaseModel") +_init_context_var = ContextVar("_init_context_var", default=None) + + +# @contextmanager +# def init_context(value: dict[str, Any]) -> Generator[None]: +# token = _init_context_var.set(value) +# try: +# yield +# finally: +# _init_context_var.reset(token) + class ResplistBitwarden(PermissiveBaseModel, Generic[T]): Data: list[T] @@ -71,6 +86,15 @@ def api_client(self) -> BitwardenAPIClient: return self.bitwarden_client +# def _x_init__(self, /, **data: Any) -> None: +# # c.f. https://pydantic.dev/docs/validation/latest/concepts/serialization#serialization-context +# self.__pydantic_validator__.validate_python( +# data, +# self_instance=self, +# # context=_init_context_var.get(), +# ) + + def decode_bytes( value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo ) -> bytes: @@ -84,10 +108,38 @@ def decode_bytes( raise ValueError("No key found") +def encode_bytes( + value: Any, handler: SerializerFunctionWrapHandler, info: SerializationInfo +) -> bytes: + return encode_string(value, handler, info=info).encode("utf-8") + + def decode_string( value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo ) -> str: - return decode_bytes(value, handler, info=info).decode("utf-8") + 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).decode() + except Exception: + continue + raise ValueError("No key found") + + +def encode_string( + value: Any, handler: SerializerFunctionWrapHandler, info: SerializationInfo +) -> str: + context: dict = cast("dict", info.context) + keys: list[bytes] = cast("list[bytes]", context.get("cctx")) + if keys: + return encrypt(2, handler(value), keys[0]) + raise ValueError("No key found") + + +EncryptedString = Annotated[ + str, WrapValidator(decode_string), WrapSerializer(encode_string) +] class UriMatch(BitwardenBaseModel): @@ -95,8 +147,8 @@ class Config: extra = "forbid" match: int | None = None - uri: Annotated[str, WrapValidator(decode_string)] | None = None - uriChecksum: Annotated[str, WrapValidator(decode_string)] | None = None + uri: EncryptedString | None = None + uriChecksum: EncryptedString | None = None response: str | None = None @@ -104,10 +156,10 @@ class XField(BitwardenBaseModel): class Config: extra = "forbid" - name: Annotated[str, WrapValidator(decode_string)] | None = None - response: Annotated[str, WrapValidator(decode_string)] | None = None + name: EncryptedString | None = None + response: EncryptedString | None = None type: int - value: Annotated[str, WrapValidator(decode_string)] | None = None + value: EncryptedString | None = None linkedId: str | None = None @@ -115,15 +167,15 @@ class CipherLogin(BitwardenBaseModel): class Config: extra = "forbid" - name: Annotated[str, WrapValidator(decode_string)] | None = None + name: EncryptedString | None = None autofillOnPageLoad: bool | None = None - password: Annotated[str, WrapValidator(decode_string)] | None = None + password: EncryptedString | None = None passwordRevisionDate: datetime.datetime | None = None totp: str | None = None - uri: Annotated[str, WrapValidator(decode_string)] | None = None + uri: EncryptedString | None = None uris: list[UriMatch] | None = None - username: Annotated[str, WrapValidator(decode_string)] | None = None - notes: Annotated[str, WrapValidator(decode_string)] | None = None + username: EncryptedString | None = None + notes: EncryptedString | None = None class PasswordChange(BitwardenBaseModel): @@ -138,20 +190,20 @@ class Fido2Credential(BitwardenBaseModel): class Config: extra = "forbid" - counter: Annotated[str, WrapValidator(decode_string)] | None = None + counter: EncryptedString | None = None creationDate: datetime.datetime | 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 + credentialId: EncryptedString | None = None + discoverable: EncryptedString | None = None + keyAlgorithm: EncryptedString | None = None + keyCurve: EncryptedString | None = None + keyType: EncryptedString | None = None + keyValue: EncryptedString | None = None response: str | 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 + rpId: EncryptedString | None = None + rpName: EncryptedString | None = None + userDisplayName: EncryptedString | None = None + userHandle: EncryptedString | None = None + userName: EncryptedString | None = None class LoginData(CipherLogin): @@ -178,11 +230,11 @@ class SecureNoteProperty(BitwardenBaseModel): class Config: extra = "forbid" - name: Annotated[str, WrapValidator(decode_string)] | None = None - notes: Annotated[str, WrapValidator(decode_string)] | None = None + name: EncryptedString | None = None + notes: EncryptedString | None = None fields: list[XField] | None = None passwordHistory: list[PasswordChange] | None = None - response: Annotated[str, WrapValidator(decode_string)] | None = None + response: EncryptedString | None = None type: int @@ -190,7 +242,7 @@ class Attachment(BitwardenBaseModel): class Config: extra = "forbid" - fileName: Annotated[str, WrapValidator(decode_string)] | None = None + fileName: EncryptedString | None = None id: str key: str | None = ( None # Annotated[str, WrapValidator(decodeBytes)]|None = None @@ -208,7 +260,7 @@ class Config: Id: UUID | None = None OrganizationId: UUID | None = Field(None, validate_default=True) Type: CipherType - Name: Annotated[str, WrapValidator(decode_string)] + Name: EncryptedString CollectionIds: list[UUID] key: str | None = None @@ -217,7 +269,7 @@ class Config: deletedDate: datetime.datetime | None = None fields: list[XField] | None = None - notes: Annotated[str, WrapValidator(decode_string)] | None = None + notes: EncryptedString | None = None reprompt: int revisionDate: str sshKey: str | None @@ -225,9 +277,15 @@ class Config: object: str | None = None attachments: list[Attachment] | None = None + edit: bool | None = None + favorite: bool | None = None + folderId: UUID | None = None + permissions: Any | None = None + viewPassword: bool | None = None + @model_validator(mode="wrap") @classmethod - def set_key( + def val_set_key( cls, data: Any, handler: ModelWrapValidatorHandler[Self], @@ -246,6 +304,19 @@ def set_key( return v + @model_serializer(mode="wrap") + def ser_set_key( + self, handler: SerializerFunctionWrapHandler, info: SerializationInfo + ) -> Any: + if (key := self.key) is not None: + context = cast("dict", info.context) + cctx = cast("list[bytes]", context.get("cctx")) + cctx.append(key.encode()) + + v = handler(self) + + return v + @field_validator("OrganizationId") @classmethod def set_id(cls, v, info: FieldValidationInfo): @@ -291,7 +362,7 @@ def update_collection(self, collections: list[UUID]): class Login(_CipherBase): - Type: Literal[CipherType.Login] + Type: Literal[CipherType.Login] = CipherType.Login login: LoginData | None = None secureNote: None = None @@ -338,6 +409,8 @@ class Identity(_CipherBase): Union[Login, SecureNote, Card, Identity], Field(discriminator="Type") ] +CipherDetail: TypeAdapter[CipherDetails] = TypeAdapter(CipherDetails) + class CollectionAccess(BitwardenBaseModel): ReadOnly: bool = False @@ -760,7 +833,7 @@ def collections( def create_collection(self, name: str) -> OrganizationCollection: org_key = self.key() data = { - "name": encrypt(2, name, self.key()), + "name": SymmetricCipher.encrypt(name.encode("utf-8"), self.key()), "groups": [], "users": [], } @@ -853,9 +926,8 @@ def get_organization( ) -@dataclasses.dataclass -class Kdf: - Kdf: KdfType +class Kdf(PermissiveBaseModel): + Kdf: int KdfIterations: int | None = None KdfMemory: int | None = None KdfParallelism: int | None = None @@ -864,9 +936,31 @@ class Kdf: def from_connect_token( cls, token: "vaultwarden.clients.bitwarden.ConnectToken" ): - return cls( - token.Kdf, - token.KdfIterations, - token.KdfMemory, - token.KdfParallelism, + return cls.model_construct( + Kdf=token.Kdf, + KdfIterations=token.KdfIterations, + KdfMemory=token.KdfMemory, + KdfParallelism=token.KdfParallelism, ) + + +class KeysData(BitwardenBaseModel): + encryptedPrivateKey: str + publicKey: str + + +class RegisterData(BitwardenBaseModel): + email: str + name: str + Kdf: KdfType + key: str + + masterPasswordHash: str + + kdfIterations: int | None = None + kdfMemory: int | None = None + kdfParallelism: int | None = None + + keys: KeysData | None = None + + masterPasswordHint: str | None = None From c53cda11b09aacc4af7617f274bf5960a8a0a824 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Sun, 7 Jun 2026 10:52:48 +0200 Subject: [PATCH 04/35] tests - create user & login --- src/vaultwarden/clients/bitwarden.py | 91 ++++++++++++++++++++++++++++ tests/e2e/test_bitwarden.py | 61 ++++++++++++++++++- 2 files changed, 149 insertions(+), 3 deletions(-) diff --git a/src/vaultwarden/clients/bitwarden.py b/src/vaultwarden/clients/bitwarden.py index 3650b11..9ae5825 100644 --- a/src/vaultwarden/clients/bitwarden.py +++ b/src/vaultwarden/clients/bitwarden.py @@ -1,3 +1,4 @@ +import typing from typing import Literal from uuid import UUID @@ -8,6 +9,14 @@ from vaultwarden.utils.crypto import make_master_key from vaultwarden.utils.logger import log_raise_for_status +if typing.TYPE_CHECKING: + from vaultwarden.models.bitwarden import ( + CipherDetails, + Kdf, + Organization, + OrganizationCollection, + ) + class BitwardenAPIClient: def __init__( @@ -157,3 +166,85 @@ def sync(self, force_refresh: bool = False) -> SyncData: resp = self._api_request("GET", "api/sync") self._sync = SyncData.model_validate_json(resp.text) return self._sync + + # def create_organization(self, name, email=None) -> "Organization": + # pass + + # def get_organization(self, name) -> "Organization": + # pass + + def create_user( + self, + email: str, + password: str, + name, + kdf: "Kdf", + ): + from base64 import b64encode + import json + + from vaultwarden.models.bitwarden import KeysData, RegisterData + from vaultwarden.utils import crypto + + hashedpw, master_key = crypto.hash_password(password, email, kdf=kdf) + + ekey, key = crypto.make_sym_key(master_key) + easymk, pub_asymk, priv_asymk = crypto.make_asym_key(key) + bpub_asymk = b64encode(pub_asymk).decode() + + payload = RegisterData.model_validate( + { + "email": email, + **kdf.model_dump(exclude_unset=True, exclude_none=True), + "masterPasswordHint": "x", + "masterPasswordHash": hashedpw.decode(), + "name": name, + "key": ekey, + "keys": KeysData.model_validate( + {"encryptedPrivateKey": easymk, "publicKey": bpub_asymk} + ), + } + ) + data = payload.model_dump(exclude_none=True, exclude_unset=True) + print(json.dumps(data, indent=2)) + resp = self._api_request("POST", "api/accounts/register", json=data) + print(resp.text) + + def create_item( + self, + item: "CipherDetails", + organization: typing.Optional["Organization"], + collections: list["OrganizationCollection"] | None, + ) -> "CipherDetails": + if organization or collections: + assert ( + organization and collections is not None and len(collections) + ) + path = "api/ciphers/admin" + key = organization.key() + item.OrganizationId = organization.Id + data = { + "type": item.Type, + "cipher": item.model_dump( + by_alias=True, mode="json", context={"cctx": [key]} + ), + "collectionIds": [str(i.Id) for i in collections], + } + else: + path = "api/ciphers" + assert self.connect_token is not None + key = item.key or self.connect_token.user_key + + data = item.model_dump( + by_alias=True, mode="json", context={"cctx": [key]} + ) + + resp = self._api_request("POST", path, json=data) + + import json + + print(json.dumps(resp.json(), indent=2)) + + from vaultwarden.models.bitwarden import CipherDetail + + return CipherDetail.validate_json(resp.text, context={"cctx": [key]}) diff --git a/tests/e2e/test_bitwarden.py b/tests/e2e/test_bitwarden.py index ce5f00b..7380f90 100644 --- a/tests/e2e/test_bitwarden.py +++ b/tests/e2e/test_bitwarden.py @@ -1,14 +1,13 @@ import os -import unittest from pathlib import Path +import unittest from vaultwarden.clients.bitwarden import BitwardenAPIClient from vaultwarden.models.bitwarden import get_organization - env = Path("tests/.env").read_text() for line in env.splitlines(): - k,v = line.strip().split(":", maxsplit=1) + k, v = line.strip().split(":", maxsplit=1) v = v.strip().strip('"') if os.environ.get(k) is None: print(f"{k} = {v}") @@ -153,6 +152,62 @@ def test_deduplicate(self): # Todo build test fixtures and delete them at the end of the test return + def test_create_user(self): + from vaultwarden.models.bitwarden import Kdf, KdfType + + argon2id = Kdf.model_construct( + Kdf=KdfType.Argon2id, + KdfMemory=32, + KdfIterations=6, + KdfParallelism=4, + ) + bitwarden.create_user( + "test@examle.org", "test", "test user", kdf=argon2id + ) + + def test_create_org_login(self): + from secrets import token_bytes + + from vaultwarden.models.bitwarden import Login, LoginData + + for name, key in [("with key", token_bytes(32)), ("no key", None)]: + data = LoginData.model_construct( + name=name, + password="test123", + username="test", + key=key, + ) + item = Login.model_construct( + name=name, + login=data, + data=data, + ) + bitwarden.create_item( + item, self.organization, collections=self.test_colls_ids + ) + + def test_create_own_login(self): + from secrets import token_bytes + + from vaultwarden.models.bitwarden import Login, LoginData + + for name, key in [ + ("own with key", token_bytes(32)), + ("own no key", None), + ]: + data = LoginData.model_construct( + name=name, + password="test123", + username="test", + key=key, + ) + item = Login.model_construct( + name=name, + login=data, + data=data, + ) + bitwarden.create_item(item, None, collections=self.test_colls_ids) + class BitwardenWithEmailTests(unittest.TestCase, BitwardenBaseTests): def setUp(self): From 8d0332e57e48d815278e1c130a2bde885e11c1dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Mon, 8 Jun 2026 20:23:37 +0200 Subject: [PATCH 05/35] crypto - embedding into models --- src/vaultwarden/clients/bitwarden.py | 39 +++-- src/vaultwarden/models/bitwarden.py | 219 +++++++++++---------------- src/vaultwarden/models/crypto.py | 175 +++++++++++++++++++++ src/vaultwarden/models/sync.py | 65 ++++++-- src/vaultwarden/utils/crypto.py | 128 ++++++++-------- tests/e2e/test_bitwarden.py | 21 ++- 6 files changed, 428 insertions(+), 219 deletions(-) create mode 100644 src/vaultwarden/models/crypto.py diff --git a/src/vaultwarden/clients/bitwarden.py b/src/vaultwarden/clients/bitwarden.py index 9ae5825..b43fdee 100644 --- a/src/vaultwarden/clients/bitwarden.py +++ b/src/vaultwarden/clients/bitwarden.py @@ -77,7 +77,7 @@ def _refresh_connect_token(self): import vaultwarden.models.bitwarden - self._connect_token.master_key = make_master_key( + self._connect_token._master_key = make_master_key( password=self.password, salt=self.email, kdf=vaultwarden.models.bitwarden.Kdf.from_connect_token( @@ -102,6 +102,9 @@ def _set_connect_token(self): resp = self._http_client.post( "identity/connect/token", headers=headers, data=payload ) + self._connect_token = ConnectToken.model_validate_json( + resp.text, context={"client": self} + ) self._connect_token = ConnectToken.model_validate_json(resp.text) if self.email is None: @@ -117,7 +120,7 @@ def _set_connect_token(self): import vaultwarden.models.bitwarden - self._connect_token.master_key = make_master_key( + self._connect_token._master_key = make_master_key( password=self.password, salt=self.email, kdf=vaultwarden.models.bitwarden.Kdf.from_connect_token( @@ -163,8 +166,21 @@ def _api_request( def sync(self, force_refresh: bool = False) -> SyncData: if self._sync is None or force_refresh: + assert ( + self._connect_token + and self._connect_token.user_key + and self._connect_token._master_key + ) resp = self._api_request("GET", "api/sync") - self._sync = SyncData.model_validate_json(resp.text) + self._sync = SyncData.model_validate_json( + resp.text, + context={ + "cctx": [ + self._connect_token.orgs_key, + self._connect_token._master_key, + ] + }, + ) return self._sync # def create_organization(self, name, email=None) -> "Organization": @@ -201,9 +217,11 @@ def create_user( "name": name, "key": ekey, "keys": KeysData.model_validate( - {"encryptedPrivateKey": easymk, "publicKey": bpub_asymk} + {"encryptedPrivateKey": easymk, "publicKey": bpub_asymk}, + context={"client": self}, ), - } + }, + context={"client": self}, ) data = payload.model_dump(exclude_none=True, exclude_unset=True) print(json.dumps(data, indent=2)) @@ -216,10 +234,10 @@ def create_item( organization: typing.Optional["Organization"], collections: list["OrganizationCollection"] | None, ) -> "CipherDetails": - if organization or collections: - assert ( - organization and collections is not None and len(collections) - ) + if organization: + assert organization and ( + collections is not None and len(collections) + ), (organization, collections) path = "api/ciphers/admin" key = organization.key() item.OrganizationId = organization.Id @@ -233,8 +251,7 @@ def create_item( else: path = "api/ciphers" assert self.connect_token is not None - key = item.key or self.connect_token.user_key - + key = self.connect_token.user_key data = item.model_dump( by_alias=True, mode="json", context={"cctx": [key]} ) diff --git a/src/vaultwarden/models/bitwarden.py b/src/vaultwarden/models/bitwarden.py index d11521a..a46385b 100644 --- a/src/vaultwarden/models/bitwarden.py +++ b/src/vaultwarden/models/bitwarden.py @@ -1,4 +1,3 @@ -from contextvars import ContextVar import datetime import sys from typing import ( @@ -17,9 +16,8 @@ AliasChoices, Field, ModelWrapValidatorHandler, + PrivateAttr, TypeAdapter, - WrapSerializer, - WrapValidator, field_validator, model_serializer, model_validator, @@ -29,18 +27,18 @@ SerializationInfo, SerializerFunctionWrapHandler, ValidationInfo, - ValidatorFunctionWrapHandler, ) from typing_extensions import Self -from vaultwarden.clients.bitwarden import BitwardenAPIClient +from vaultwarden.models.crypto import SecretCipherKey, SecretString 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 SymmetricCipher, decrypt, encrypt +from vaultwarden.utils.crypto import SymmetricCipher if TYPE_CHECKING: import vaultwarden.clients.bitwarden + from vaultwarden.clients.bitwarden import BitwardenAPIClient if sys.version_info < (3, 12): from typing_extensions import Self @@ -52,94 +50,49 @@ T = TypeVar("T", bound="BitwardenBaseModel") -_init_context_var = ContextVar("_init_context_var", default=None) - - -# @contextmanager -# def init_context(value: dict[str, Any]) -> Generator[None]: -# token = _init_context_var.set(value) -# try: -# yield -# finally: -# _init_context_var.reset(token) - class ResplistBitwarden(PermissiveBaseModel, Generic[T]): Data: list[T] +# class BitwardenBaseModel(PermissiveBaseModel): +# bitwarden_client: "BitwardenAPIClient" | None = Field( +# default=None, validate_default=True, exclude=True +# ) +# +# @field_validator("bitwarden_client") +# @classmethod +# def set_client(cls, v, info: FieldValidationInfo): +# if v is None and info.context is not None: +# return info.context.get("client") +# return v +# +# @property +# def api_client(self) -> "BitwardenAPIClient": +# assert self.bitwarden_client is not None +# return self.bitwarden_client + + class BitwardenBaseModel(PermissiveBaseModel): - bitwarden_client: BitwardenAPIClient | None = Field( - default=None, validate_default=True, exclude=True - ) + _bitwarden_client: Any = PrivateAttr(default=None) - @field_validator("bitwarden_client") + @model_validator(mode="wrap") @classmethod - def set_client(cls, v, info: FieldValidationInfo): - if v is None and info.context is not None: - return info.context.get("client") + def val_set_client( + cls, + data: Any, + handler: ModelWrapValidatorHandler[Self], + info: ValidationInfo, + ) -> Self: + assert info.context + v = handler(data) + v._bitwarden_client = info.context.get("client") return v @property - def api_client(self) -> BitwardenAPIClient: - assert self.bitwarden_client is not None - return self.bitwarden_client - - -# def _x_init__(self, /, **data: Any) -> None: -# # c.f. https://pydantic.dev/docs/validation/latest/concepts/serialization#serialization-context -# self.__pydantic_validator__.validate_python( -# data, -# self_instance=self, -# # context=_init_context_var.get(), -# ) - - -def decode_bytes( - value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo -) -> 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("No key found") - - -def encode_bytes( - value: Any, handler: SerializerFunctionWrapHandler, info: SerializationInfo -) -> bytes: - return encode_string(value, handler, info=info).encode("utf-8") - - -def decode_string( - value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo -) -> str: - 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).decode() - except Exception: - continue - raise ValueError("No key found") - - -def encode_string( - value: Any, handler: SerializerFunctionWrapHandler, info: SerializationInfo -) -> str: - context: dict = cast("dict", info.context) - keys: list[bytes] = cast("list[bytes]", context.get("cctx")) - if keys: - return encrypt(2, handler(value), keys[0]) - raise ValueError("No key found") - - -EncryptedString = Annotated[ - str, WrapValidator(decode_string), WrapSerializer(encode_string) -] + def api_client(self) -> "BitwardenAPIClient": + assert self._bitwarden_client is not None + return self._bitwarden_client class UriMatch(BitwardenBaseModel): @@ -147,8 +100,8 @@ class Config: extra = "forbid" match: int | None = None - uri: EncryptedString | None = None - uriChecksum: EncryptedString | None = None + uri: SecretString | None = None + uriChecksum: SecretString | None = None response: str | None = None @@ -156,10 +109,10 @@ class XField(BitwardenBaseModel): class Config: extra = "forbid" - name: EncryptedString | None = None - response: EncryptedString | None = None + name: SecretString | None = None + response: SecretString | None = None type: int - value: EncryptedString | None = None + value: SecretString | None = None linkedId: str | None = None @@ -167,15 +120,15 @@ class CipherLogin(BitwardenBaseModel): class Config: extra = "forbid" - name: EncryptedString | None = None + name: SecretString | None = None autofillOnPageLoad: bool | None = None - password: EncryptedString | None = None + password: SecretString | None = None passwordRevisionDate: datetime.datetime | None = None totp: str | None = None - uri: EncryptedString | None = None + uri: SecretString | None = None uris: list[UriMatch] | None = None - username: EncryptedString | None = None - notes: EncryptedString | None = None + username: SecretString | None = None + notes: SecretString | None = None class PasswordChange(BitwardenBaseModel): @@ -190,20 +143,20 @@ class Fido2Credential(BitwardenBaseModel): class Config: extra = "forbid" - counter: EncryptedString | None = None + counter: SecretString | None = None creationDate: datetime.datetime | None = None - credentialId: EncryptedString | None = None - discoverable: EncryptedString | None = None - keyAlgorithm: EncryptedString | None = None - keyCurve: EncryptedString | None = None - keyType: EncryptedString | None = None - keyValue: EncryptedString | None = None + credentialId: SecretString | None = None + discoverable: SecretString | None = None + keyAlgorithm: SecretString | None = None + keyCurve: SecretString | None = None + keyType: SecretString | None = None + keyValue: SecretString | None = None response: str | None = None - rpId: EncryptedString | None = None - rpName: EncryptedString | None = None - userDisplayName: EncryptedString | None = None - userHandle: EncryptedString | None = None - userName: EncryptedString | None = None + rpId: SecretString | None = None + rpName: SecretString | None = None + userDisplayName: SecretString | None = None + userHandle: SecretString | None = None + userName: SecretString | None = None class LoginData(CipherLogin): @@ -230,11 +183,11 @@ class SecureNoteProperty(BitwardenBaseModel): class Config: extra = "forbid" - name: EncryptedString | None = None - notes: EncryptedString | None = None + name: SecretString | None = None + notes: SecretString | None = None fields: list[XField] | None = None passwordHistory: list[PasswordChange] | None = None - response: EncryptedString | None = None + response: SecretString | None = None type: int @@ -242,7 +195,7 @@ class Attachment(BitwardenBaseModel): class Config: extra = "forbid" - fileName: EncryptedString | None = None + fileName: SecretString | None = None id: str key: str | None = ( None # Annotated[str, WrapValidator(decodeBytes)]|None = None @@ -260,16 +213,16 @@ class Config: Id: UUID | None = None OrganizationId: UUID | None = Field(None, validate_default=True) Type: CipherType - Name: EncryptedString + Name: SecretString CollectionIds: list[UUID] - key: str | None = None + key: SecretCipherKey | None = None organizationUseTotp: bool | None = None creationDate: datetime.datetime | None = None deletedDate: datetime.datetime | None = None fields: list[XField] | None = None - notes: EncryptedString | None = None + notes: SecretString | None = None reprompt: int revisionDate: str sshKey: str | None @@ -291,11 +244,14 @@ def val_set_key( handler: ModelWrapValidatorHandler[Self], info: ValidationInfo, ) -> Self: + key: str + cctx: list[bytes] if (key := data.get("key")) is not None: context = cast("dict", info.context) cctx = cast("list[bytes]", context.get("cctx")) - cctx.append(decrypt(key, cctx[0])) + cipher, ct = SymmetricCipher.parse(key[1:]) + cctx.append(cipher.decrypt(ct, cctx[-1])) v = handler(data) @@ -308,13 +264,18 @@ def val_set_key( def ser_set_key( self, handler: SerializerFunctionWrapHandler, info: SerializationInfo ) -> Any: + key: bytes | None + cctx: list[bytes] if (key := self.key) is not None: context = cast("dict", info.context) cctx = cast("list[bytes]", context.get("cctx")) - cctx.append(key.encode()) + cctx.append(key) v = handler(self) + if key is not None: + cctx.pop() + return v @field_validator("OrganizationId") @@ -453,7 +414,7 @@ def set_id(cls, v, info: FieldValidationInfo): class OrganizationCollection(BitwardenBaseModel): Id: UUID | None = None OrganizationId: UUID | None = Field(None, validate_default=True) - Name: str + Name: SecretString ExternalId: str | None = None @field_validator("OrganizationId") @@ -543,14 +504,14 @@ def add_collections(self, collections: list[UUID]): for collection in collections: if collection in _current_collections: continue - user = UserCollection( + user = UserCollection.model_construct( CollectionId=collection, UserId=self.Id, ReadOnly=False, HidePasswords=False, Manage=False, ) - user.bitwarden_client = self.api_client + user._bitwarden_client = self.api_client self.Collections.append(user) pl = self.model_dump( include={ @@ -813,12 +774,12 @@ def _get_collections(self) -> list[OrganizationCollection]: ) res = ResplistBitwarden[OrganizationCollection].model_validate_json( resp.text, - context={"parent_id": self.Id, "client": self.api_client}, + context={ + "parent_id": self.Id, + "client": self.api_client, + "cctx": [self.key()], + }, ) - org_key = self.key() - # map each collection name to the decrypted name - for collection in res.Data: - collection.Name = decrypt(collection.Name, org_key).decode("utf-8") return res.Data def collections( @@ -833,7 +794,7 @@ def collections( def create_collection(self, name: str) -> OrganizationCollection: org_key = self.key() data = { - "name": SymmetricCipher.encrypt(name.encode("utf-8"), self.key()), + "name": SymmetricCipher.encrypt(name.encode("utf-8"), org_key), "groups": [], "users": [], } @@ -842,9 +803,12 @@ def create_collection(self, name: str) -> OrganizationCollection: ) res = OrganizationCollection.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], + }, ) - res.Name = decrypt(res.Name, org_key).decode("utf-8") if self._collections is not None: self._collections.append(res) else: @@ -904,14 +868,15 @@ def ciphers( ] return self._ciphers - def key(self): + def key(self) -> bytes: sync = self.api_client.sync() for org in sync.Profile.Organizations: if org.Id == self.Id: break else: raise BitwardenError(f"No Organizations `{self.Id}` found") - return decrypt(org.Key, self.api_client.connect_token.orgs_key) + assert org and org.Key + return org.Key def get_organization( diff --git a/src/vaultwarden/models/crypto.py b/src/vaultwarden/models/crypto.py new file mode 100644 index 0000000..df2bdb9 --- /dev/null +++ b/src/vaultwarden/models/crypto.py @@ -0,0 +1,175 @@ +from typing import Annotated, Any, cast + +from Crypto.PublicKey import RSA +from pydantic import ( + SerializationInfo, + SerializerFunctionWrapHandler, + ValidationInfo, + ValidatorFunctionWrapHandler, + WrapSerializer, + WrapValidator, +) + +from vaultwarden.utils.crypto import AsymmetricCipher, SymmetricCipher + + +def decode_org_key( + value: str, handler: ValidatorFunctionWrapHandler, info: ValidationInfo +) -> bytes: + context: dict = cast("dict", info.context) + keys: list[bytes] = cast("list[bytes]", context.get("cctx")) + for key in keys[::-1]: + if len(key) <= 64: + continue + try: + assert int(value[0]) == AsymmetricCipher.TYPE + cipher, ct = AsymmetricCipher.parse(value[1:]) + return handler(cipher.decrypt(ct, key)) + except Exception as e: + print(e) + continue + raise ValueError("No key found") + + +def encode_org_key( + value: bytes, + handler: SerializerFunctionWrapHandler, + info: SerializationInfo, +) -> str: + context: dict = cast("dict", info.context) + keys: list[bytes] = cast("list[bytes]", context.get("cctx")) + if keys: + return handler(AsymmetricCipher.encrypt(value, keys[-2])) + raise ValueError("No key found") + + +SecretOrganizationKey = Annotated[ + bytes, WrapValidator(decode_org_key), WrapSerializer(encode_org_key) +] + + +def decode_string( + value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo +) -> str: + context: dict = cast("dict", info.context) + keys: list[bytes] = cast("list[bytes]", context.get("cctx")) + for key in keys[::-1]: + try: + cipher, ct = SymmetricCipher.parse(handler(value)[1:]) + return handler(cipher.decrypt(ct, key)) + except Exception as e: + print(e) + continue + raise ValueError("No key found") + + +def encode_string( + value: str, handler: SerializerFunctionWrapHandler, info: SerializationInfo +) -> str: + context: dict = cast("dict", info.context) + keys: list[bytes] = cast("list[bytes]", context.get("cctx")) + if keys: + return handler(SymmetricCipher.encrypt(value.encode(), keys[-1])) + raise ValueError("No key found") + + +SecretString = Annotated[ + str, WrapValidator(decode_string), WrapSerializer(encode_string) +] + + +def decode_cipher_key( + value: str, handler: ValidatorFunctionWrapHandler, info: ValidationInfo +) -> bytes: + context: dict = cast("dict", info.context) + keys: list[bytes] = cast("list[bytes]", context.get("cctx")) + for key in keys[-2::-1]: # not last element - reverse + try: + assert int(value[0]) == SymmetricCipher.TYPE + cipher, ct = SymmetricCipher.parse(value[1:]) + return handler(cipher.decrypt(ct, key)) + except Exception as e: + print(e) + continue + raise ValueError("No key found") + + +def encode_cipher_key( + value: bytes, + handler: SerializerFunctionWrapHandler, + info: SerializationInfo, +) -> str: + context: dict = cast("dict", info.context) + keys: list[bytes] = cast("list[bytes]", context.get("cctx")) + if keys: + return handler(SymmetricCipher.encrypt(value, keys[-2])) + raise ValueError("No key found") + + +SecretCipherKey = Annotated[ + bytes, WrapValidator(decode_cipher_key), WrapSerializer(encode_cipher_key) +] + + +def decode_bytes( + value: str, handler: ValidatorFunctionWrapHandler, info: ValidationInfo +) -> bytes: + context: dict = cast("dict", info.context) + keys: list[bytes] = cast("list[bytes]", context.get("cctx")) + for key in keys[::-1]: + try: + cipher, ct = SymmetricCipher.parse(value[1:]) + return handler(cipher.decrypt(ct, key)) + except Exception as e: + print(e) + continue + raise ValueError("No key found") + + +def encode_bytes( + value: Any, handler: SerializerFunctionWrapHandler, info: SerializationInfo +) -> bytes: + context: dict = cast("dict", info.context) + keys: list[bytes] = cast("list[bytes]", context.get("cctx")) + if keys: + SymmetricCipher.encrypt(handler(value), keys[-1]) + raise ValueError("No key found") + + +SecretBytes = Annotated[ + bytes, WrapValidator(decode_bytes), WrapSerializer(encode_bytes) +] + + +def decode_rsa( + value: str, handler: ValidatorFunctionWrapHandler, info: ValidationInfo +) -> RSA.RsaKey: + context: dict = cast("dict", info.context) + keys: list[bytes] = cast("list[bytes]", context.get("cctx")) + for key in keys[::-1]: + try: + cipher, ct = SymmetricCipher.parse(value[1:]) + return handler(RSA.importKey(cipher.decrypt(ct, key))) + except Exception as e: + print(e) + continue + raise ValueError("No key found") + + +def encode_rsa( + value: RSA.RsaKey, + handler: SerializerFunctionWrapHandler, + info: SerializationInfo, +) -> bytes: + context: dict = cast("dict", info.context) + keys: list[bytes] = cast("list[bytes]", context.get("cctx")) + if keys: + SymmetricCipher.encrypt( + handler(value.exportKey("DER", pkcs=8)), keys[-1] + ) + raise ValueError("No key found") + + +SecretRSA = Annotated[ + RSA.RsaKey, WrapValidator(decode_rsa), WrapSerializer(encode_rsa) +] diff --git a/src/vaultwarden/models/sync.py b/src/vaultwarden/models/sync.py index 3044ce2..ce92ca9 100644 --- a/src/vaultwarden/models/sync.py +++ b/src/vaultwarden/models/sync.py @@ -1,11 +1,25 @@ import time +from typing import Any, Self, cast from uuid import UUID -from pydantic import AliasChoices, Field, field_validator - +from pydantic import ( + AliasChoices, + Field, + ModelWrapValidatorHandler, + PrivateAttr, + ValidationInfo, + field_validator, + model_validator, +) + +from vaultwarden.models.crypto import ( + SecretBytes, + SecretOrganizationKey, + SecretRSA, +) from vaultwarden.models.enum import KdfType, VaultwardenUserStatus from vaultwarden.models.permissive_model import PermissiveBaseModel -from vaultwarden.utils.crypto import decrypt +from vaultwarden.utils.crypto import SymmetricCipher class ConnectToken(PermissiveBaseModel): @@ -23,7 +37,7 @@ class ConnectToken(PermissiveBaseModel): unofficialServer: bool = False ResetMasterPassword: bool | None = None - master_key: bytes | None = None # pydantic.PrivateAttr(default=None) + _master_key: bytes | None = PrivateAttr(default=None) @field_validator("expires_in") @classmethod @@ -36,18 +50,24 @@ def is_expired(self, now=None): return (self.expires_in is not None) and (self.expires_in <= now) @property - def user_key(self): - return decrypt(self.Key, self.master_key) + def user_key(self) -> bytes: + assert self._master_key + cipher, ct = SymmetricCipher.parse(self.Key[1:]) + return cipher.decrypt(ct, self._master_key) @property - def orgs_key(self): - return decrypt(self.PrivateKey, self.user_key) + def orgs_key(self) -> bytes: + cipher, ct = SymmetricCipher.parse(self.PrivateKey[1:]) + return cipher.decrypt(ct, self.user_key) + + +# return self.PrivateKey class ProfileOrganization(PermissiveBaseModel): Id: UUID Name: str - Key: str | None = None + Key: SecretOrganizationKey | None = None ProviderId: str | None = None ProviderName: str | None = None ResetPasswordEnrolled: bool @@ -74,13 +94,13 @@ class UserProfile(PermissiveBaseModel): EmailVerified: bool ForcePasswordReset: bool Id: UUID - Key: str + Key: SecretBytes MasterPasswordHint: str | None = None Name: str | None Object: str | None Organizations: list[ProfileOrganization] Premium: bool - PrivateKey: str | None + PrivateKey: SecretRSA | None ProviderOrganizations: list Providers: list SecurityStamp: str @@ -91,6 +111,29 @@ class UserProfile(PermissiveBaseModel): validation_alias=AliasChoices("_status", "_Status"), ) + @model_validator(mode="wrap") + @classmethod + def val_set_key( + cls, + data: Any, + handler: ModelWrapValidatorHandler[Self], + info: ValidationInfo, + ) -> Self: + cctx: list[bytes] + key: str + if (key := data.get("key")) is not None: + context = cast("dict", info.context) + cctx = cast("list[bytes]", context.get("cctx")) + cipher, ct = SymmetricCipher.parse(key[1:]) + v = cipher.decrypt(ct, cctx[-1]) + cctx.append(v) + + r = handler(data) + if key: + cctx.pop(0) + + return r + class VaultwardenUser(UserProfile): UserEnabled: bool diff --git a/src/vaultwarden/utils/crypto.py b/src/vaultwarden/utils/crypto.py index 4f64bcf..19f87ca 100644 --- a/src/vaultwarden/utils/crypto.py +++ b/src/vaultwarden/utils/crypto.py @@ -228,33 +228,33 @@ class DecryptError(ValueError): """.""" -def decode_cipher_string(cipher_string: str) -> tuple[_Cipher, bytes]: - """decode a cipher tring into it's parts""" - assert isinstance(cipher_string, str) - if not ENCRYPTED_STRING_RE.match(cipher_string): - raise WrongFormatError(f"{cipher_string}") - try: - typ = CIPHERS(int(cipher_string[0:1])) - assert typ < 9 - except (AssertionError, ValueError): - raise WrongTypeDecryptError(f"{typ} is not valid") - data = cipher_string[2:] - match typ: - case CIPHERS.asym: - return AsymmetricCipher.parse(data) - case CIPHERS.sym: - return SymmetricCipher.parse(data) - case CIPHERS.null: - return NullCipher.parse(data) - - -def is_encrypted(cipher_string: str) -> bool: # FIXME unused - try: - decode_cipher_string(cipher_string) - except DecodeEncKeyError: - return False - else: - return True +# def decode_cipher_string(cipher_string: str) -> tuple[_Cipher, bytes]: +# """decode a cipher tring into it's parts""" +# assert isinstance(cipher_string, str) +# if not ENCRYPTED_STRING_RE.match(cipher_string): +# raise WrongFormatError(f"{cipher_string}") +# try: +# typ = CIPHERS(int(cipher_string[0:1])) +# assert typ < 9 +# except (AssertionError, ValueError): +# raise WrongTypeDecryptError(f"{typ} is not valid") +# data = cipher_string[2:] +# match typ: +# case CIPHERS.asym: +# return AsymmetricCipher.parse(data) +# case CIPHERS.sym: +# return SymmetricCipher.parse(data) +# case CIPHERS.null: +# return NullCipher.parse(data) + + +#def is_encrypted(cipher_string: str) -> bool: # FIXME unused +# try: +# decode_cipher_string(cipher_string) +# except DecodeEncKeyError: +# return False +# else: +# return True def make_master_key(password: str, salt: str, kdf: "vaultwarden.models.bitwarden.Kdf"): @@ -324,43 +324,43 @@ def aes_encrypt(plaintext: bytes, key: bytes) -> tuple[bytes, bytes, bytes]: cmac = hmac_new(mac, iv + ct, sha256) return iv, ct, cmac.digest() - -def encrypt_sym_to_bytes(plaintext: str, key: bytes): # FIXME migrated - assert isinstance(plaintext, str) - return BinarySymmetricCipher.encrypt(plaintext.encode("utf-8"), key) - - -def encrypt(typ:CIPHERS|int, plaintext: str, key: bytes): - assert isinstance(typ, (CIPHERS, int)), typ - assert isinstance(plaintext, str) - assert isinstance(key, bytes) - - plainbytes = plaintext.encode("utf-8") - match typ: - case AsymmetricCipher.TYPE: - return AsymmetricCipher.encrypt(plainbytes, key) - case SymmetricCipher.TYPE: - return SymmetricCipher.encrypt(plainbytes, key) - case _: - raise UnimplementedError(f"can not encrypt type:{typ}") - - - -def decrypt_bytes(cipher_bytes: bytes, key: bytes): # FIXME UNUSED - assert isinstance(cipher_bytes, bytes) - assert isinstance(key, bytes) - typ = cipher_bytes[0] - match typ: - case SymmetricCipher.TYPE: - cipher, ct = BinarySymmetricCipher.parse(cipher_bytes) - return cipher.decrypt(ct, key) - case _: - raise UnimplementedError(f"{typ} encType decryption is not implemented") - -def decrypt(cipher_string: str, key:bytes) -> bytes: - assert isinstance(cipher_string, str) - cipher, ct = decode_cipher_string(cipher_string) - return cipher.decrypt(ct, key) +# +# def encrypt_sym_to_bytes(plaintext: str, key: bytes): # FIXME migrated +# assert isinstance(plaintext, str) +# return BinarySymmetricCipher.encrypt(plaintext.encode("utf-8"), key) + + +# def encrypt(typ:CIPHERS|int, plaintext: str, key: bytes): +# assert isinstance(typ, (CIPHERS, int)), typ +# assert isinstance(plaintext, str) +# assert isinstance(key, bytes) +# +# plainbytes = plaintext.encode("utf-8") +# match typ: +# case AsymmetricCipher.TYPE: +# return AsymmetricCipher.encrypt(plainbytes, key) +# case SymmetricCipher.TYPE: +# return SymmetricCipher.encrypt(plainbytes, key) +# case _: +# raise UnimplementedError(f"can not encrypt type:{typ}") + + + +# def decrypt_bytes(cipher_bytes: bytes, key: bytes): # FIXME UNUSED +# assert isinstance(cipher_bytes, bytes) +# assert isinstance(key, bytes) +# typ = cipher_bytes[0] +# match typ: +# case SymmetricCipher.TYPE: +# cipher, ct = BinarySymmetricCipher.parse(cipher_bytes) +# return cipher.decrypt(ct, key) +# case _: +# raise UnimplementedError(f"{typ} encType decryption is not implemented") + +#def decrypt(cipher_string: str, key:bytes) -> bytes: +# assert isinstance(cipher_string, str) +# cipher, ct = decode_cipher_string(cipher_string) +# return cipher.decrypt(ct, key) def strech_key(key: bytes) -> bytes: stretched_key = key diff --git a/tests/e2e/test_bitwarden.py b/tests/e2e/test_bitwarden.py index 7380f90..f2a34d7 100644 --- a/tests/e2e/test_bitwarden.py +++ b/tests/e2e/test_bitwarden.py @@ -1,5 +1,6 @@ import os from pathlib import Path +import string import unittest from vaultwarden.clients.bitwarden import BitwardenAPIClient @@ -161,8 +162,13 @@ def test_create_user(self): KdfIterations=6, KdfParallelism=4, ) + import random + + rnd = "".join( + random.choices(string.ascii_letters + string.digits, k=10) + ) bitwarden.create_user( - "test@examle.org", "test", "test user", kdf=argon2id + f"test+{rnd}@examle.org", "test", "test user", kdf=argon2id ) def test_create_org_login(self): @@ -170,17 +176,20 @@ def test_create_org_login(self): from vaultwarden.models.bitwarden import Login, LoginData - for name, key in [("with key", token_bytes(32)), ("no key", None)]: + for name, key in [ + ("org - with key", token_bytes(64)), + ("org - no key", None), + ]: data = LoginData.model_construct( name=name, password="test123", username="test", - key=key, ) item = Login.model_construct( name=name, login=data, data=data, + key=key, ) bitwarden.create_item( item, self.organization, collections=self.test_colls_ids @@ -192,19 +201,19 @@ def test_create_own_login(self): from vaultwarden.models.bitwarden import Login, LoginData for name, key in [ - ("own with key", token_bytes(32)), - ("own no key", None), + ("own - with key", token_bytes(64)), + ("own - no key", None), ]: data = LoginData.model_construct( name=name, password="test123", username="test", - key=key, ) item = Login.model_construct( name=name, login=data, data=data, + key=key, ) bitwarden.create_item(item, None, collections=self.test_colls_ids) From 5db4e353b95ec85ea84e1c1939d67b0ca490a353 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Mon, 8 Jun 2026 21:45:17 +0200 Subject: [PATCH 06/35] crypto - move to models --- src/vaultwarden/clients/bitwarden.py | 34 ++++++------------ src/vaultwarden/models/crypto.py | 2 +- src/vaultwarden/models/sync.py | 53 +++++++++++++++++++++------- src/vaultwarden/utils/crypto.py | 30 ++++++++-------- 4 files changed, 66 insertions(+), 53 deletions(-) diff --git a/src/vaultwarden/clients/bitwarden.py b/src/vaultwarden/clients/bitwarden.py index b43fdee..b4d4faf 100644 --- a/src/vaultwarden/clients/bitwarden.py +++ b/src/vaultwarden/clients/bitwarden.py @@ -6,7 +6,6 @@ from vaultwarden.models.exception_models import BitwardenError from vaultwarden.models.sync import ConnectToken, SyncData -from vaultwarden.utils.crypto import make_master_key from vaultwarden.utils.logger import log_raise_for_status if typing.TYPE_CHECKING: @@ -73,16 +72,8 @@ def _refresh_connect_token(self): resp = self._http_client.post( "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, - kdf=vaultwarden.models.bitwarden.Kdf.from_connect_token( - self._connect_token - ), + self._connect_token = ConnectToken.model_validate_json( + resp.text, context={"client": self, "cctx": []} ) def _set_connect_token(self): @@ -103,9 +94,8 @@ def _set_connect_token(self): "identity/connect/token", headers=headers, data=payload ) self._connect_token = ConnectToken.model_validate_json( - resp.text, context={"client": self} + resp.text, context={"client": self, "cctx": []} ) - self._connect_token = ConnectToken.model_validate_json(resp.text) if self.email is None: headers = { @@ -118,15 +108,11 @@ def _set_connect_token(self): ) self.email = resp.json()["email"] - import vaultwarden.models.bitwarden - - self._connect_token._master_key = make_master_key( - password=self.password, - salt=self.email, - kdf=vaultwarden.models.bitwarden.Kdf.from_connect_token( - self._connect_token - ), + self._connect_token = ConnectToken.model_validate_json( + resp.text, context={"client": self, "cctx": []} ) + + return # login to api @@ -168,7 +154,7 @@ def sync(self, force_refresh: bool = False) -> SyncData: if self._sync is None or force_refresh: assert ( self._connect_token - and self._connect_token.user_key + and self._connect_token.PrivateKey and self._connect_token._master_key ) resp = self._api_request("GET", "api/sync") @@ -176,7 +162,7 @@ def sync(self, force_refresh: bool = False) -> SyncData: resp.text, context={ "cctx": [ - self._connect_token.orgs_key, + self._connect_token.PrivateKey, self._connect_token._master_key, ] }, @@ -251,7 +237,7 @@ def create_item( else: path = "api/ciphers" assert self.connect_token is not None - key = self.connect_token.user_key + key = self.connect_token.Key data = item.model_dump( by_alias=True, mode="json", context={"cctx": [key]} ) diff --git a/src/vaultwarden/models/crypto.py b/src/vaultwarden/models/crypto.py index df2bdb9..dd47ed8 100644 --- a/src/vaultwarden/models/crypto.py +++ b/src/vaultwarden/models/crypto.py @@ -19,7 +19,7 @@ def decode_org_key( context: dict = cast("dict", info.context) keys: list[bytes] = cast("list[bytes]", context.get("cctx")) for key in keys[::-1]: - if len(key) <= 64: + if not isinstance(key, RSA.RsaKey): continue try: assert int(value[0]) == AsymmetricCipher.TYPE diff --git a/src/vaultwarden/models/sync.py b/src/vaultwarden/models/sync.py index ce92ca9..a6df594 100644 --- a/src/vaultwarden/models/sync.py +++ b/src/vaultwarden/models/sync.py @@ -27,8 +27,8 @@ class ConnectToken(PermissiveBaseModel): KdfIterations: int = 0 KdfMemory: int | None = None KdfParallelism: int | None = None - Key: str - PrivateKey: str + Key: SecretBytes + PrivateKey: SecretRSA access_token: str refresh_token: str | None = None expires_in: int @@ -49,19 +49,46 @@ def is_expired(self, now=None): now = time.time() return (self.expires_in is not None) and (self.expires_in <= now) - @property - def user_key(self) -> bytes: - assert self._master_key - cipher, ct = SymmetricCipher.parse(self.Key[1:]) - return cipher.decrypt(ct, self._master_key) - - @property - def orgs_key(self) -> bytes: - cipher, ct = SymmetricCipher.parse(self.PrivateKey[1:]) - return cipher.decrypt(ct, self.user_key) + @field_validator("Key", mode="wrap") + @classmethod + def val_field_key(cls, v: str, handler: Any, info: ValidationInfo) -> str: + assert info and info.context + r = handler(v) + cctx = cast("list[bytes]", info.context["cctx"]) + cctx.append(r) + return r -# return self.PrivateKey + @model_validator(mode="wrap") + @classmethod + def val_set_key( + cls, + data: Any, + handler: ModelWrapValidatorHandler[Self], + info: ValidationInfo, + ) -> Self: + from vaultwarden.clients.bitwarden import BitwardenAPIClient + from vaultwarden.models.bitwarden import Kdf + from vaultwarden.utils.crypto import make_master_key + + assert info and info.context + + client: BitwardenAPIClient = cast( + BitwardenAPIClient, info.context["client"] + ) + cctx: list[bytes] = cast("list[bytes]", info.context["cctx"]) + + master_key = make_master_key( + password=client.password, + salt=client.email, + kdf=Kdf.model_validate(data), + ) + cctx.append(master_key) + v = handler(data) + cctx.pop() # Key + cctx.pop() # master_key + v._master_key = master_key + return v class ProfileOrganization(PermissiveBaseModel): diff --git a/src/vaultwarden/utils/crypto.py b/src/vaultwarden/utils/crypto.py index 19f87ca..e9b8730 100644 --- a/src/vaultwarden/utils/crypto.py +++ b/src/vaultwarden/utils/crypto.py @@ -53,17 +53,17 @@ def parse(cls, ct:str) -> tuple[typing.Self, bytes]: return cls(), b64decode(ct) @classmethod - def encrypt(cls, plainbytes: bytes, key: bytes): + def encrypt(cls, plainbytes: bytes, key: RSA.RsaKey): assert isinstance(plainbytes, bytes) - assert isinstance(key, bytes) - cipher = PKCS1_OAEP.new(load_rsa_key(key)).encrypt(plainbytes) + assert isinstance(key, RSA.RsaKey) + cipher = PKCS1_OAEP.new(key).encrypt(plainbytes) b64_ct = b64encode(cipher).decode() return cls.ENCODING.format(cipher=cipher, b64_ct=b64_ct) - def decrypt(self, ct:bytes, key: bytes): + def decrypt(self, ct:bytes, key: RSA.RsaKey): assert isinstance(ct, bytes) - assert isinstance(key, bytes) - return PKCS1_OAEP.new(load_rsa_key(key)).decrypt(ct) + assert isinstance(key, RSA.RsaKey) + return PKCS1_OAEP.new(key).decrypt(ct) class SymmetricCipher(_Cipher): @@ -298,15 +298,15 @@ def hash_password(password: str, salt: str, kdf: "vaultwarden.models.bitwarden.K return base64.b64encode(hashpw), master_key -def load_rsa_key(key: bytes) -> RSA.RsaKey: - rsakeys = CACHE.setdefault("rsa", {}) - if not isinstance(key, RSA.RsaKey): - try: - key = rsakeys[key] - except KeyError: - rsakeys[key] = RSA.importKey(key) - key = rsakeys[key] - return key +# def load_rsa_key(key: bytes) -> RSA.RsaKey: +# rsakeys = CACHE.setdefault("rsa", {}) +# if not isinstance(key, RSA.RsaKey): +# try: +# key = rsakeys[key] +# except KeyError: +# rsakeys[key] = RSA.importKey(key) +# key = rsakeys[key] +# return key def aes_encrypt(plaintext: bytes, key: bytes) -> tuple[bytes, bytes, bytes]: From 751217d8d1281835c618e3ca0525a45b710e7ddf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Mon, 8 Jun 2026 22:35:33 +0200 Subject: [PATCH 07/35] crypto - cleanup --- src/vaultwarden/models/bitwarden.py | 29 +--- src/vaultwarden/models/crypto.py | 39 +++-- src/vaultwarden/models/sync.py | 9 +- src/vaultwarden/utils/crypto.py | 216 ++++++++++------------------ 4 files changed, 104 insertions(+), 189 deletions(-) diff --git a/src/vaultwarden/models/bitwarden.py b/src/vaultwarden/models/bitwarden.py index a46385b..16f4dfd 100644 --- a/src/vaultwarden/models/bitwarden.py +++ b/src/vaultwarden/models/bitwarden.py @@ -55,24 +55,6 @@ class ResplistBitwarden(PermissiveBaseModel, Generic[T]): Data: list[T] -# class BitwardenBaseModel(PermissiveBaseModel): -# bitwarden_client: "BitwardenAPIClient" | None = Field( -# default=None, validate_default=True, exclude=True -# ) -# -# @field_validator("bitwarden_client") -# @classmethod -# def set_client(cls, v, info: FieldValidationInfo): -# if v is None and info.context is not None: -# return info.context.get("client") -# return v -# -# @property -# def api_client(self) -> "BitwardenAPIClient": -# assert self.bitwarden_client is not None -# return self.bitwarden_client - - class BitwardenBaseModel(PermissiveBaseModel): _bitwarden_client: Any = PrivateAttr(default=None) @@ -249,16 +231,15 @@ def val_set_key( if (key := data.get("key")) is not None: context = cast("dict", info.context) cctx = cast("list[bytes]", context.get("cctx")) + v = SymmetricCipher.decode(key, cctx[-1]) + cctx.append(v) - cipher, ct = SymmetricCipher.parse(key[1:]) - cctx.append(cipher.decrypt(ct, cctx[-1])) - - v = handler(data) + r = handler(data) if key is not None: cctx.pop() - return v + return r @model_serializer(mode="wrap") def ser_set_key( @@ -794,7 +775,7 @@ def collections( def create_collection(self, name: str) -> OrganizationCollection: org_key = self.key() data = { - "name": SymmetricCipher.encrypt(name.encode("utf-8"), org_key), + "name": SymmetricCipher.encode(name.encode("utf-8"), org_key), "groups": [], "users": [], } diff --git a/src/vaultwarden/models/crypto.py b/src/vaultwarden/models/crypto.py index dd47ed8..19d73c7 100644 --- a/src/vaultwarden/models/crypto.py +++ b/src/vaultwarden/models/crypto.py @@ -22,9 +22,7 @@ def decode_org_key( if not isinstance(key, RSA.RsaKey): continue try: - assert int(value[0]) == AsymmetricCipher.TYPE - cipher, ct = AsymmetricCipher.parse(value[1:]) - return handler(cipher.decrypt(ct, key)) + return handler(AsymmetricCipher.decode(value, key)) except Exception as e: print(e) continue @@ -39,7 +37,7 @@ def encode_org_key( context: dict = cast("dict", info.context) keys: list[bytes] = cast("list[bytes]", context.get("cctx")) if keys: - return handler(AsymmetricCipher.encrypt(value, keys[-2])) + return handler(AsymmetricCipher.encode(value, keys[-2])) raise ValueError("No key found") @@ -55,8 +53,7 @@ def decode_string( keys: list[bytes] = cast("list[bytes]", context.get("cctx")) for key in keys[::-1]: try: - cipher, ct = SymmetricCipher.parse(handler(value)[1:]) - return handler(cipher.decrypt(ct, key)) + return handler(SymmetricCipher.decode(value, key)) except Exception as e: print(e) continue @@ -69,7 +66,7 @@ def encode_string( context: dict = cast("dict", info.context) keys: list[bytes] = cast("list[bytes]", context.get("cctx")) if keys: - return handler(SymmetricCipher.encrypt(value.encode(), keys[-1])) + return handler(SymmetricCipher.encode(value.encode(), keys[-1])) raise ValueError("No key found") @@ -85,9 +82,7 @@ def decode_cipher_key( keys: list[bytes] = cast("list[bytes]", context.get("cctx")) for key in keys[-2::-1]: # not last element - reverse try: - assert int(value[0]) == SymmetricCipher.TYPE - cipher, ct = SymmetricCipher.parse(value[1:]) - return handler(cipher.decrypt(ct, key)) + return handler(SymmetricCipher.decode(value, key)) except Exception as e: print(e) continue @@ -102,7 +97,7 @@ def encode_cipher_key( context: dict = cast("dict", info.context) keys: list[bytes] = cast("list[bytes]", context.get("cctx")) if keys: - return handler(SymmetricCipher.encrypt(value, keys[-2])) + return handler(SymmetricCipher.encode(value, keys[-2])) raise ValueError("No key found") @@ -111,33 +106,32 @@ def encode_cipher_key( ] -def decode_bytes( +def decode_key( value: str, handler: ValidatorFunctionWrapHandler, info: ValidationInfo ) -> bytes: context: dict = cast("dict", info.context) keys: list[bytes] = cast("list[bytes]", context.get("cctx")) for key in keys[::-1]: try: - cipher, ct = SymmetricCipher.parse(value[1:]) - return handler(cipher.decrypt(ct, key)) + return handler(SymmetricCipher.decode(value, key)) except Exception as e: print(e) continue raise ValueError("No key found") -def encode_bytes( +def encode_key( value: Any, handler: SerializerFunctionWrapHandler, info: SerializationInfo ) -> bytes: context: dict = cast("dict", info.context) keys: list[bytes] = cast("list[bytes]", context.get("cctx")) if keys: - SymmetricCipher.encrypt(handler(value), keys[-1]) + SymmetricCipher.encode(handler(value), keys[-1]) raise ValueError("No key found") -SecretBytes = Annotated[ - bytes, WrapValidator(decode_bytes), WrapSerializer(encode_bytes) +SecretKey = Annotated[ + bytes, WrapValidator(decode_key), WrapSerializer(encode_key) ] @@ -148,8 +142,7 @@ def decode_rsa( keys: list[bytes] = cast("list[bytes]", context.get("cctx")) for key in keys[::-1]: try: - cipher, ct = SymmetricCipher.parse(value[1:]) - return handler(RSA.importKey(cipher.decrypt(ct, key))) + return handler(RSA.importKey(SymmetricCipher.decode(value, key))) except Exception as e: print(e) continue @@ -164,8 +157,10 @@ def encode_rsa( context: dict = cast("dict", info.context) keys: list[bytes] = cast("list[bytes]", context.get("cctx")) if keys: - SymmetricCipher.encrypt( - handler(value.exportKey("DER", pkcs=8)), keys[-1] + return handler( + SymmetricCipher.encode( + handler(value.exportKey("DER", pkcs=8)), keys[-1] + ) ) raise ValueError("No key found") diff --git a/src/vaultwarden/models/sync.py b/src/vaultwarden/models/sync.py index a6df594..23e8c30 100644 --- a/src/vaultwarden/models/sync.py +++ b/src/vaultwarden/models/sync.py @@ -13,7 +13,7 @@ ) from vaultwarden.models.crypto import ( - SecretBytes, + SecretKey, SecretOrganizationKey, SecretRSA, ) @@ -27,7 +27,7 @@ class ConnectToken(PermissiveBaseModel): KdfIterations: int = 0 KdfMemory: int | None = None KdfParallelism: int | None = None - Key: SecretBytes + Key: SecretKey PrivateKey: SecretRSA access_token: str refresh_token: str | None = None @@ -121,7 +121,7 @@ class UserProfile(PermissiveBaseModel): EmailVerified: bool ForcePasswordReset: bool Id: UUID - Key: SecretBytes + Key: SecretKey MasterPasswordHint: str | None = None Name: str | None Object: str | None @@ -151,8 +151,7 @@ def val_set_key( if (key := data.get("key")) is not None: context = cast("dict", info.context) cctx = cast("list[bytes]", context.get("cctx")) - cipher, ct = SymmetricCipher.parse(key[1:]) - v = cipher.decrypt(ct, cctx[-1]) + v = SymmetricCipher.decode(key, cctx[-1]) cctx.append(v) r = handler(data) diff --git a/src/vaultwarden/utils/crypto.py b/src/vaultwarden/utils/crypto.py index e9b8730..97a154d 100644 --- a/src/vaultwarden/utils/crypto.py +++ b/src/vaultwarden/utils/crypto.py @@ -39,32 +39,41 @@ class _Cipher: TYPE: int ENCODING: str @classmethod - def encrypt(cls, plainbytes:bytes, key:bytes) -> str: + def encode(cls, plainbytes:bytes, key:bytes) -> str: raise NotImplementedError() - def decrypt(self, data:bytes, key: bytes) -> bytes: + @classmethod + def decode(cls, data, key) -> bytes: + raise NotImplementedError() + + def _decrypt(self, data:bytes, key: bytes) -> bytes: raise NotImplementedError() class AsymmetricCipher(_Cipher): TYPE = CIPHERS.asym ENCODING = "{typ}.{b64_ct}" @classmethod - def parse(cls, ct:str) -> tuple[typing.Self, bytes]: + def _parse(cls, ct:str) -> tuple[typing.Self, bytes]: return cls(), b64decode(ct) + def _decrypt(self, ct:bytes, key: RSA.RsaKey) -> bytes: + assert isinstance(ct, bytes) + assert isinstance(key, RSA.RsaKey) + return PKCS1_OAEP.new(key).decrypt(ct) + @classmethod - def encrypt(cls, plainbytes: bytes, key: RSA.RsaKey): + def encode(cls, plainbytes: bytes, key: RSA.RsaKey): assert isinstance(plainbytes, bytes) assert isinstance(key, RSA.RsaKey) cipher = PKCS1_OAEP.new(key).encrypt(plainbytes) b64_ct = b64encode(cipher).decode() return cls.ENCODING.format(cipher=cipher, b64_ct=b64_ct) - def decrypt(self, ct:bytes, key: RSA.RsaKey): - assert isinstance(ct, bytes) - assert isinstance(key, RSA.RsaKey) - return PKCS1_OAEP.new(key).decrypt(ct) - + @classmethod + def decode(cls, data: str, key: RSA.RsaKey) -> bytes: + assert int(data[0]) == AsymmetricCipher.TYPE + cipher, ct = cls._parse(data[1:]) + return cipher._decrypt(ct, key) class SymmetricCipher(_Cipher): TYPE = CIPHERS.sym @@ -74,71 +83,66 @@ def __init__(self, iv:bytes, mac:bytes): self._mac = mac @classmethod - def parse(cls, ct: str) -> tuple[typing.Self, bytes]: + def _parse(cls, ct: str) -> tuple[typing.Self, bytes]: iv, ct, mac = ct.split("|", 3) return cls(b64decode(iv), b64decode(mac)[0:32]), b64decode(ct) - @classmethod - def encrypt(cls, plainbytes: bytes, key: bytes) -> str: - assert isinstance(plainbytes, bytes) - assert isinstance(key, bytes) - return cls._encrypt_sym(plainbytes, key) - - - def decrypt(self, ct: bytes, key: bytes) -> bytes: + def _decrypt(self, ct: bytes, key: bytes) -> bytes: assert isinstance(ct, bytes) assert isinstance(key, bytes) - return SymmetricCipher._decrypt_sym(dct=ct, key=key, div=self._iv, dmac=self._mac) - - - @staticmethod - def _get_enc_mac(key:bytes) -> tuple[bytes, bytes]: - assert isinstance(key, bytes) - # symmetric master_key of the user - if len(key) == 32: - enc = hkdf_expand(key, b"enc", 32, sha256) - mac = hkdf_expand(key, b"mac", 32, sha256) - # symmetric key of an organization - elif len(key) == 64: - enc = key[:32] - mac = key[32:] - return enc, mac - - @staticmethod - def _decrypt_sym(dct:bytes, key:bytes, div:bytes, dmac:bytes) -> bytes: - assert isinstance(dct, bytes) - assert isinstance(key, bytes) - assert isinstance(div, bytes) - assert isinstance(dmac, bytes) - enc, mac = SymmetricCipher._get_enc_mac(key) - hdmac = hmac_new(mac, div + dct, sha256).digest() - if hdmac != dmac: + hdmac = hmac_new(mac, self._iv + ct, sha256).digest() + if hdmac != self._mac: raise DecryptError( - f"Symmetric hmac verification failed {bytes(hdmac).hex()} / {bytes(dmac).hex()}. Check your password." + f"Symmetric hmac verification failed {bytes(hdmac).hex()} / {bytes(self._mac).hex()}. Check your password." ) - c = AES.new(enc, AES.MODE_CBC, div) - plaintext = c.decrypt(dct) + c = AES.new(enc, AES.MODE_CBC, self._iv) + plaintext = c.decrypt(ct) pad_len = plaintext[-1] padding = bytes([pad_len] * pad_len) if plaintext[-pad_len:] == padding: plaintext = plaintext[:-pad_len] return plaintext + @classmethod - def _encrypt_sym(cls, plaintext: bytes, key: bytes) -> str: - assert isinstance(plaintext, bytes) + def encode(cls, plainbytes: bytes, key: bytes) -> str: + assert isinstance(plainbytes, bytes) assert isinstance(key, bytes) # inspired from bitwarden/jslib:src/services/crypto.service.ts typ = int(CIPHERS.sym) - (iv, ct, mac) = aes_encrypt(plaintext, key) + (iv, ct, mac) = aes_encrypt(plainbytes, key) # jslib: encrypt() b64_iv = b64encode(iv).decode() b64_ct = b64encode(ct).decode() b64_digest = "" if mac: b64_digest = b64encode(mac).decode() - return cls.ENCODING.format(typ=CIPHERS.sym, b64_iv=b64_iv, b64_ct=b64_ct,b64_digest=b64_digest) + return cls.ENCODING.format(typ=CIPHERS.sym, b64_iv=b64_iv, b64_ct=b64_ct, b64_digest=b64_digest) + + @classmethod + def decode(cls, data: str, key: bytes) -> bytes: + assert int(data[0]) == SymmetricCipher.TYPE + cipher, ct = cls._parse(data[1:]) + return cipher._decrypt(ct, key) + + + @staticmethod + def _get_enc_mac(key:bytes) -> tuple[bytes, bytes]: + assert isinstance(key, bytes) + # + match len(key): + case 32: + """symmetric master_key of the user""" + enc = hkdf_expand(key, b"enc", 32, sha256) + mac = hkdf_expand(key, b"mac", 32, sha256) + case 64: + """symmetric key of an organization""" + enc = key[:32] + mac = key[32:] + case _: + raise ValueError(f"Invalid key type {key!r}") + return enc, mac class BinarySymmetricCipher: @@ -149,27 +153,40 @@ def __init__(self, iv:bytes, mac:bytes): self._mac = mac @classmethod - def parse(cls, cipher_bytes: bytes) -> tuple[typing.Self, bytes]: + def _parse(cls, cipher_bytes: bytes) -> tuple[typing.Self, bytes]: iv = cipher_bytes[1:17] mac = cipher_bytes[17:49] ct = cipher_bytes[49:] return cls(iv, mac), ct - - def decrypt(self, ct: bytes, key: bytes) -> bytes: + def _decrypt(self, ct: bytes, key: bytes) -> bytes: assert isinstance(ct, bytes) assert isinstance(key, bytes) - return SymmetricCipher._decrypt_sym(dct=ct, key=key, div=self._iv, dmac=self._mac) + enc, mac = SymmetricCipher._get_enc_mac(key) + hdmac = hmac_new(mac, self._iv + ct, sha256).digest() + if hdmac != self._mac: + raise DecryptError( + f"Symmetric hmac verification failed {bytes(hdmac).hex()} / {bytes(self._mac).hex()}. Check your password." + ) + c = AES.new(enc, AES.MODE_CBC, self._iv) + plaintext = c.decrypt(ct) + pad_len = plaintext[-1] + padding = bytes([pad_len] * pad_len) + if plaintext[-pad_len:] == padding: + plaintext = plaintext[:-pad_len] + return plaintext - @classmethod - def encrypt(cls, plainbytes: bytes, key: bytes) -> bytes: - assert isinstance(plainbytes, bytes) + def decode(cls, data: bytes, key: bytes) -> bytes: + assert isinstance(data, bytes) assert isinstance(key, bytes) - return cls._encrypt_sym_bytes(plainbytes, key) + assert int(data[0]) == SymmetricCipher.TYPE + cipher, ct = cls._parse(data[1:]) + return cipher._decrypt(ct, key) + @classmethod - def _encrypt_sym_bytes(cls, plainbytes: bytes, key: bytes) -> bytes: + def encode(cls, plainbytes: bytes, key: bytes) -> bytes: assert isinstance(plainbytes, bytes) assert isinstance(key, bytes) # inspired from bitwarden/jslib:src/services/crypto.service.ts @@ -228,35 +245,6 @@ class DecryptError(ValueError): """.""" -# def decode_cipher_string(cipher_string: str) -> tuple[_Cipher, bytes]: -# """decode a cipher tring into it's parts""" -# assert isinstance(cipher_string, str) -# if not ENCRYPTED_STRING_RE.match(cipher_string): -# raise WrongFormatError(f"{cipher_string}") -# try: -# typ = CIPHERS(int(cipher_string[0:1])) -# assert typ < 9 -# except (AssertionError, ValueError): -# raise WrongTypeDecryptError(f"{typ} is not valid") -# data = cipher_string[2:] -# match typ: -# case CIPHERS.asym: -# return AsymmetricCipher.parse(data) -# case CIPHERS.sym: -# return SymmetricCipher.parse(data) -# case CIPHERS.null: -# return NullCipher.parse(data) - - -#def is_encrypted(cipher_string: str) -> bool: # FIXME unused -# try: -# decode_cipher_string(cipher_string) -# except DecodeEncKeyError: -# return False -# else: -# return True - - def make_master_key(password: str, salt: str, kdf: "vaultwarden.models.bitwarden.Kdf"): import vaultwarden.models.bitwarden @@ -298,17 +286,6 @@ def hash_password(password: str, salt: str, kdf: "vaultwarden.models.bitwarden.K return base64.b64encode(hashpw), master_key -# def load_rsa_key(key: bytes) -> RSA.RsaKey: -# rsakeys = CACHE.setdefault("rsa", {}) -# if not isinstance(key, RSA.RsaKey): -# try: -# key = rsakeys[key] -# except KeyError: -# rsakeys[key] = RSA.importKey(key) -# key = rsakeys[key] -# return key - - def aes_encrypt(plaintext: bytes, key: bytes) -> tuple[bytes, bytes, bytes]: assert isinstance(plaintext, bytes) assert isinstance(key, bytes) @@ -324,43 +301,6 @@ def aes_encrypt(plaintext: bytes, key: bytes) -> tuple[bytes, bytes, bytes]: cmac = hmac_new(mac, iv + ct, sha256) return iv, ct, cmac.digest() -# -# def encrypt_sym_to_bytes(plaintext: str, key: bytes): # FIXME migrated -# assert isinstance(plaintext, str) -# return BinarySymmetricCipher.encrypt(plaintext.encode("utf-8"), key) - - -# def encrypt(typ:CIPHERS|int, plaintext: str, key: bytes): -# assert isinstance(typ, (CIPHERS, int)), typ -# assert isinstance(plaintext, str) -# assert isinstance(key, bytes) -# -# plainbytes = plaintext.encode("utf-8") -# match typ: -# case AsymmetricCipher.TYPE: -# return AsymmetricCipher.encrypt(plainbytes, key) -# case SymmetricCipher.TYPE: -# return SymmetricCipher.encrypt(plainbytes, key) -# case _: -# raise UnimplementedError(f"can not encrypt type:{typ}") - - - -# def decrypt_bytes(cipher_bytes: bytes, key: bytes): # FIXME UNUSED -# assert isinstance(cipher_bytes, bytes) -# assert isinstance(key, bytes) -# typ = cipher_bytes[0] -# match typ: -# case SymmetricCipher.TYPE: -# cipher, ct = BinarySymmetricCipher.parse(cipher_bytes) -# return cipher.decrypt(ct, key) -# case _: -# raise UnimplementedError(f"{typ} encType decryption is not implemented") - -#def decrypt(cipher_string: str, key:bytes) -> bytes: -# assert isinstance(cipher_string, str) -# cipher, ct = decode_cipher_string(cipher_string) -# return cipher.decrypt(ct, key) def strech_key(key: bytes) -> bytes: stretched_key = key @@ -373,7 +313,7 @@ def strech_key(key: bytes) -> bytes: def make_sym_key(master_key: bytes) -> tuple[str, bytes]: # FIXME UNUSED stretched_key = strech_key(master_key) plaintext = token_bytes(64) - return SymmetricCipher.encrypt(plaintext, stretched_key), plaintext + return SymmetricCipher.encode(plaintext, stretched_key), plaintext def make_asym_key(key:bytes, stretch=True) -> tuple[str, bytes, bytes]: # FIXME UNUSED @@ -382,7 +322,7 @@ def make_asym_key(key:bytes, stretch=True) -> tuple[str, bytes, bytes]: # FIXME asym_key = RSA.generate(2048) public_key = asym_key.publickey().exportKey("DER") private_key = asym_key.exportKey("DER", pkcs=8) - return SymmetricCipher.encrypt(private_key, key), public_key, private_key + return SymmetricCipher.encode(private_key, key), public_key, private_key def gen_password(length=32, alphabet=None) -> str: # FIXME UNUSED From 46f39c86d5caad6188f9ae783cc8873a67cc4236 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Thu, 11 Jun 2026 06:36:19 +0200 Subject: [PATCH 08/35] crypto - cleanup user registration --- src/vaultwarden/clients/bitwarden.py | 55 +++----- src/vaultwarden/models/bitwarden.py | 193 ++++++++++++++++++++------- src/vaultwarden/utils/crypto.py | 28 +--- tests/e2e/test_bitwarden.py | 18 ++- 4 files changed, 177 insertions(+), 117 deletions(-) diff --git a/src/vaultwarden/clients/bitwarden.py b/src/vaultwarden/clients/bitwarden.py index b4d4faf..158a953 100644 --- a/src/vaultwarden/clients/bitwarden.py +++ b/src/vaultwarden/clients/bitwarden.py @@ -4,6 +4,7 @@ from httpx import Client, Response +from vaultwarden.models.bitwarden import CipherDetail, RegisterData from vaultwarden.models.exception_models import BitwardenError from vaultwarden.models.sync import ConnectToken, SyncData from vaultwarden.utils.logger import log_raise_for_status @@ -143,9 +144,12 @@ def _api_request( raise BitwardenError("Fail to connect") headers = { "Authorization": f"Bearer {self.connect_token.access_token}", - "content-type": "application/json; charset=utf-8", "Accept": "*/*", } + + if kwargs.get("json") is not None: + headers["content-type"] = "application/json; charset=utf-8" + return self._http_client.request( method, path, headers=headers, **kwargs ) @@ -182,37 +186,23 @@ def create_user( name, kdf: "Kdf", ): - from base64 import b64encode - import json - - from vaultwarden.models.bitwarden import KeysData, RegisterData - from vaultwarden.utils import crypto - - hashedpw, master_key = crypto.hash_password(password, email, kdf=kdf) - - ekey, key = crypto.make_sym_key(master_key) - easymk, pub_asymk, priv_asymk = crypto.make_asym_key(key) - bpub_asymk = b64encode(pub_asymk).decode() - - payload = RegisterData.model_validate( - { - "email": email, - **kdf.model_dump(exclude_unset=True, exclude_none=True), - "masterPasswordHint": "x", - "masterPasswordHash": hashedpw.decode(), - "name": name, - "key": ekey, - "keys": KeysData.model_validate( - {"encryptedPrivateKey": easymk, "publicKey": bpub_asymk}, - context={"client": self}, - ), - }, + assert email == email.lower(), "email is not lowercase" + assert len(password) >= 8, "password is too short (< 8 characters)" + + rd = RegisterData.model_construct( + email=email, + password=password, + name=name, + **kdf.model_dump(by_alias=True), + ) + data = rd.model_dump( + by_alias=True, + exclude_none=True, + exclude_unset=True, context={"client": self}, ) - data = payload.model_dump(exclude_none=True, exclude_unset=True) - print(json.dumps(data, indent=2)) resp = self._api_request("POST", "api/accounts/register", json=data) - print(resp.text) + return resp.json() def create_item( self, @@ -243,11 +233,4 @@ def create_item( ) resp = self._api_request("POST", path, json=data) - - import json - - print(json.dumps(resp.json(), indent=2)) - - from vaultwarden.models.bitwarden import CipherDetail - return CipherDetail.validate_json(resp.text, context={"cctx": [key]}) diff --git a/src/vaultwarden/models/bitwarden.py b/src/vaultwarden/models/bitwarden.py index 16f4dfd..452df62 100644 --- a/src/vaultwarden/models/bitwarden.py +++ b/src/vaultwarden/models/bitwarden.py @@ -1,4 +1,8 @@ +import base64 import datetime +from functools import cached_property +import hashlib +from secrets import token_bytes import sys from typing import ( TYPE_CHECKING, @@ -12,12 +16,14 @@ ) from uuid import UUID +from Crypto.PublicKey import RSA from pydantic import ( AliasChoices, Field, ModelWrapValidatorHandler, PrivateAttr, TypeAdapter, + computed_field, field_validator, model_serializer, model_validator, @@ -34,10 +40,14 @@ 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 SymmetricCipher +from vaultwarden.utils.crypto import ( + BinarySymmetricCipher, + SymmetricCipher, + make_master_key, + stretch_key, +) if TYPE_CHECKING: - import vaultwarden.clients.bitwarden from vaultwarden.clients.bitwarden import BitwardenAPIClient if sys.version_info < (3, 12): @@ -51,6 +61,46 @@ T = TypeVar("T", bound="BitwardenBaseModel") +def val_set_key( + cls, + data: Any, + handler: ModelWrapValidatorHandler[Any], + info: ValidationInfo, +) -> Any: + key: str + cctx: list[bytes] + if (key := data.get("key")) is not None: + context = cast("dict", info.context) + cctx = cast("list[bytes]", context.get("cctx")) + v = SymmetricCipher.decode(key, cctx[-1]) + cctx.append(v) + + r = handler(data) + + if key is not None: + cctx.pop() + + return r + + +def ser_set_key( + slf: Any, handler: SerializerFunctionWrapHandler, info: SerializationInfo +) -> Any: + key: bytes | None + cctx: list[bytes] + if (key := slf.key) is not None: + context = cast("dict", info.context) + cctx = cast("list[bytes]", context.get("cctx")) + cctx.append(key) + + v = handler(slf) + + if key is not None: + cctx.pop() + + return v + + class ResplistBitwarden(PermissiveBaseModel, Generic[T]): Data: list[T] @@ -179,14 +229,32 @@ class Config: fileName: SecretString | None = None id: str - key: str | None = ( - None # Annotated[str, WrapValidator(decodeBytes)]|None = None - ) + key: SecretCipherKey | None = None object: str size: int sizeName: str url: str + @model_validator(mode="wrap") + @classmethod + def val_set_key( + cls, + data: Any, + handler: ModelWrapValidatorHandler[Self], + info: ValidationInfo, + ) -> Self: + return val_set_key(cls, data, handler, info) + + @model_serializer(mode="wrap") + def ser_set_key( + self, handler: SerializerFunctionWrapHandler, info: SerializationInfo + ) -> Any: + return ser_set_key(self, handler, info) + + def download(self): + v = self._bitwarden_client._http_client.get(self.url) + return BinarySymmetricCipher.decode(v.content, self.key) + class _CipherBase(BitwardenBaseModel): class Config: @@ -226,38 +294,13 @@ def val_set_key( handler: ModelWrapValidatorHandler[Self], info: ValidationInfo, ) -> Self: - key: str - cctx: list[bytes] - if (key := data.get("key")) is not None: - context = cast("dict", info.context) - cctx = cast("list[bytes]", context.get("cctx")) - v = SymmetricCipher.decode(key, cctx[-1]) - cctx.append(v) - - r = handler(data) - - if key is not None: - cctx.pop() - - return r + return val_set_key(cls, data, handler, info) @model_serializer(mode="wrap") def ser_set_key( self, handler: SerializerFunctionWrapHandler, info: SerializationInfo ) -> Any: - key: bytes | None - cctx: list[bytes] - if (key := self.key) is not None: - context = cast("dict", info.context) - cctx = cast("list[bytes]", context.get("cctx")) - cctx.append(key) - - v = handler(self) - - if key is not None: - cctx.pop() - - return v + return ser_set_key(self, handler, info) @field_validator("OrganizationId") @classmethod @@ -879,14 +922,12 @@ class Kdf(PermissiveBaseModel): KdfParallelism: int | None = None @classmethod - def from_connect_token( - cls, token: "vaultwarden.clients.bitwarden.ConnectToken" - ): + def argon2id(cls): return cls.model_construct( - Kdf=token.Kdf, - KdfIterations=token.KdfIterations, - KdfMemory=token.KdfMemory, - KdfParallelism=token.KdfParallelism, + Kdf=KdfType.Argon2id, + KdfMemory=32, + KdfIterations=6, + KdfParallelism=4, ) @@ -896,17 +937,75 @@ class KeysData(BitwardenBaseModel): class RegisterData(BitwardenBaseModel): + """ + c.f. https://bitwarden.com/help/bitwarden-security-white-paper/ + """ + + class Config: + extra = "forbid" + arbitrary_types_allowed = True + email: str - name: str - Kdf: KdfType - key: str + password: str = Field(exclude=True) - masterPasswordHash: str + name: str + Kdf: int + # key: str - kdfIterations: int | None = None - kdfMemory: int | None = None - kdfParallelism: int | None = None + KdfIterations: int | None = None + KdfMemory: int | None = None + KdfParallelism: int | None = None - keys: KeysData | None = None + # keys: KeysData | None = None masterPasswordHint: str | None = None + + @computed_field # type: ignore[prop-decorator] + @property + def masterPasswordHash(self) -> str: # noqa: N802 + v = hashlib.pbkdf2_hmac( + "sha256", self._masterKey, self.password.encode(), 1 + ) + return base64.b64encode(v).decode() + + @computed_field # type: ignore[prop-decorator] + @property + def key(self) -> str: + return SymmetricCipher.encode(self._rawKey, self._masterKey) + + @computed_field # type: ignore[prop-decorator] + @property + def keys(self) -> KeysData: + return KeysData.model_construct( + encryptedPrivateKey=SymmetricCipher.encode( + self._rawKeys.exportKey("DER", pkcs=8), self._rawKey + ), + publicKey=base64.b64encode( + self._rawKeys.publickey().exportKey("DER") + ).decode(), + ) + + @cached_property + def _masterKey(self) -> bytes: # noqa: N802 + return make_master_key( + self.password, + self.email, + Kdf.model_construct( + Kdf=self.Kdf, + KdfIterations=self.KdfIterations, + KdfMemory=self.KdfMemory, + KdfParallelism=self.KdfParallelism, + ), + ) + + @cached_property + def _stretchedKey(self) -> bytes: # noqa: N802 + return stretch_key(self._masterKey) + + @cached_property + def _rawKey(self) -> bytes: # noqa: N802 + return token_bytes(64) + + @cached_property + def _rawKeys(self) -> RSA.RsaKey: # noqa: N802 + return RSA.generate(2048) diff --git a/src/vaultwarden/utils/crypto.py b/src/vaultwarden/utils/crypto.py index 97a154d..2bd0322 100644 --- a/src/vaultwarden/utils/crypto.py +++ b/src/vaultwarden/utils/crypto.py @@ -245,7 +245,7 @@ class DecryptError(ValueError): """.""" -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") -> bytes: import vaultwarden.models.bitwarden assert isinstance(salt, str) @@ -276,14 +276,8 @@ def make_master_key(password: str, salt: str, kdf: "vaultwarden.models.bitwarden type=argon2.Type.ID, ) return v - -def hash_password(password: str, salt: str, kdf: "vaultwarden.models.bitwarden.Kdf"): # FIXME UNUSED - """base64-encode a wrapped, stretched password+salt(email) for signup/login""" - assert isinstance(password, str) - assert isinstance(salt, str) - master_key = make_master_key(password, salt, kdf) - hashpw = hashlib.pbkdf2_hmac("sha256", master_key, password.encode(), 1) - return base64.b64encode(hashpw), master_key + case _: + raise ValueError(f"unsupported kdf {kdf}") def aes_encrypt(plaintext: bytes, key: bytes) -> tuple[bytes, bytes, bytes]: @@ -302,7 +296,7 @@ def aes_encrypt(plaintext: bytes, key: bytes) -> tuple[bytes, bytes, bytes]: return iv, ct, cmac.digest() -def strech_key(key: bytes) -> bytes: +def stretch_key(key: bytes) -> bytes: stretched_key = key if len(stretched_key) < 64: stretched_key = hkdf_expand(key, b"enc", 32, sha256) + hkdf_expand( @@ -310,20 +304,6 @@ def strech_key(key: bytes) -> bytes: ) return stretched_key -def make_sym_key(master_key: bytes) -> tuple[str, bytes]: # FIXME UNUSED - stretched_key = strech_key(master_key) - plaintext = token_bytes(64) - return SymmetricCipher.encode(plaintext, stretched_key), plaintext - - -def make_asym_key(key:bytes, stretch=True) -> tuple[str, bytes, bytes]: # FIXME UNUSED - if stretch: - key = strech_key(key) - asym_key = RSA.generate(2048) - public_key = asym_key.publickey().exportKey("DER") - private_key = asym_key.exportKey("DER", pkcs=8) - return SymmetricCipher.encode(private_key, key), public_key, private_key - def gen_password(length=32, alphabet=None) -> str: # FIXME UNUSED alphabet = alphabet or string.ascii_letters + string.digits diff --git a/tests/e2e/test_bitwarden.py b/tests/e2e/test_bitwarden.py index f2a34d7..e310399 100644 --- a/tests/e2e/test_bitwarden.py +++ b/tests/e2e/test_bitwarden.py @@ -154,21 +154,19 @@ def test_deduplicate(self): return def test_create_user(self): - from vaultwarden.models.bitwarden import Kdf, KdfType - - argon2id = Kdf.model_construct( - Kdf=KdfType.Argon2id, - KdfMemory=32, - KdfIterations=6, - KdfParallelism=4, - ) import random + from vaultwarden.models.bitwarden import Kdf + from vaultwarden.utils.crypto import gen_password + rnd = "".join( random.choices(string.ascii_letters + string.digits, k=10) - ) + ).lower() bitwarden.create_user( - f"test+{rnd}@examle.org", "test", "test user", kdf=argon2id + f"test+{rnd}@examle.org", + gen_password(), + "test user", + kdf=Kdf.argon2id(), ) def test_create_org_login(self): From dfc2b3b49ca10a10831dd613577b5453e2c10144 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Thu, 11 Jun 2026 06:49:45 +0200 Subject: [PATCH 09/35] crypto - fix BinarySymmetricCipher used for attachments --- src/vaultwarden/models/bitwarden.py | 65 +++++++++++++++++++++++++++++ src/vaultwarden/utils/crypto.py | 10 ++--- tests/e2e/test_bitwarden.py | 10 +++++ 3 files changed, 80 insertions(+), 5 deletions(-) diff --git a/src/vaultwarden/models/bitwarden.py b/src/vaultwarden/models/bitwarden.py index 452df62..45eb2ac 100644 --- a/src/vaultwarden/models/bitwarden.py +++ b/src/vaultwarden/models/bitwarden.py @@ -2,6 +2,8 @@ import datetime from functools import cached_property import hashlib +import io +from pathlib import Path from secrets import token_bytes import sys from typing import ( @@ -223,6 +225,32 @@ class Config: type: int +class AttachmentRequest(BitwardenBaseModel): + class Config: + extra = "forbid" + + key: SecretCipherKey + fileName: SecretString + fileSize: int + adminRequest: bool | None = None + + @model_validator(mode="wrap") + @classmethod + def val_set_key( + cls, + data: Any, + handler: ModelWrapValidatorHandler[Self], + info: ValidationInfo, + ) -> Self: + return val_set_key(cls, data, handler, info) + + @model_serializer(mode="wrap") + def ser_set_key( + self, handler: SerializerFunctionWrapHandler, info: SerializationInfo + ) -> Any: + return ser_set_key(self, handler, info) + + class Attachment(BitwardenBaseModel): class Config: extra = "forbid" @@ -345,6 +373,43 @@ def update_collection(self, collections: list[UUID]): json={"collectionIds": dump}, ) + def attach(self, path: Path): + with path.open("rb") as f: + self._attach(path.name, f) + + def _attach(self, name: str, file: io.IOBase): + "/api/ciphers/fc246fe5-9177-455b-b318-c00fab407dc8/attachment/v2" + key = token_bytes(64) + ed = BinarySymmetricCipher.encode(file.read(), key) + ar = AttachmentRequest.model_construct( + key=key, fileName=name, fileSize=len(ed), adminRequest=True + ) + if self.OrganizationId: + cctx = [ + get_organization( + self._bitwarden_client, self.OrganizationId + ).key() + ] + else: + cctx = [self._bitwarden_client._connect_token._masterKey] + ard = ar.model_dump( + context={"client": self._bitwarden_client, "cctx": cctx} + ) + v = self._bitwarden_client._api_request( + "POST", f"api/ciphers/{self.Id}/attachment/v2", json=ard + ).json() + self._bitwarden_client._api_request( + "POST", + "api" + v["url"], + files={ + "data": ( + ard["fileName"], + io.BytesIO(ed), + "application/octet-stream", + ) + }, + ) + class Login(_CipherBase): Type: Literal[CipherType.Login] = CipherType.Login diff --git a/src/vaultwarden/utils/crypto.py b/src/vaultwarden/utils/crypto.py index 2bd0322..de465fb 100644 --- a/src/vaultwarden/utils/crypto.py +++ b/src/vaultwarden/utils/crypto.py @@ -154,9 +154,9 @@ def __init__(self, iv:bytes, mac:bytes): @classmethod def _parse(cls, cipher_bytes: bytes) -> tuple[typing.Self, bytes]: - iv = cipher_bytes[1:17] - mac = cipher_bytes[17:49] - ct = cipher_bytes[49:] + iv = cipher_bytes[0:16] + mac = cipher_bytes[16:48] + ct = cipher_bytes[48:] return cls(iv, mac), ct def _decrypt(self, ct: bytes, key: bytes) -> bytes: @@ -176,7 +176,7 @@ def _decrypt(self, ct: bytes, key: bytes) -> bytes: plaintext = plaintext[:-pad_len] return plaintext - + @classmethod def decode(cls, data: bytes, key: bytes) -> bytes: assert isinstance(data, bytes) assert isinstance(key, bytes) @@ -199,7 +199,7 @@ def encode(cls, plainbytes: bytes, key: bytes) -> bytes: ret += mac ret += ct - assert cls.ENCODING % {"typ": typ, "iv": iv, "mac": mac, "ct": ct} == ret + assert cls.ENCODING % {b"typ": typ, b"iv": iv, b"mac": mac, b"ct": ct} == ret return ret diff --git a/tests/e2e/test_bitwarden.py b/tests/e2e/test_bitwarden.py index e310399..a8c585f 100644 --- a/tests/e2e/test_bitwarden.py +++ b/tests/e2e/test_bitwarden.py @@ -215,6 +215,16 @@ def test_create_own_login(self): ) bitwarden.create_item(item, None, collections=self.test_colls_ids) + def test_create_attachment(self): + from pathlib import Path + + from vaultwarden.models.bitwarden import Login + + login: Login = next( + filter(lambda x: x.attachments, self.test_org_ciphers) + ) + login.attach(Path("/etc/modules")) + class BitwardenWithEmailTests(unittest.TestCase, BitwardenBaseTests): def setUp(self): From a81847ed2cad575d1da3ff3888762375aa1851bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Thu, 11 Jun 2026 14:31:25 +0200 Subject: [PATCH 10/35] crypto - fold cctx management into models --- src/vaultwarden/clients/bitwarden.py | 63 +++++------ src/vaultwarden/models/bitwarden.py | 100 +++++++++-------- src/vaultwarden/models/crypto.py | 161 ++++++++++++--------------- src/vaultwarden/models/sync.py | 76 ++++++++----- src/vaultwarden/utils/crypto.py | 24 ---- tests/e2e/test_bitwarden.py | 1 + 6 files changed, 201 insertions(+), 224 deletions(-) diff --git a/src/vaultwarden/clients/bitwarden.py b/src/vaultwarden/clients/bitwarden.py index 158a953..e29fe77 100644 --- a/src/vaultwarden/clients/bitwarden.py +++ b/src/vaultwarden/clients/bitwarden.py @@ -62,26 +62,15 @@ def _refresh_connect_token(self): or self.connect_token.refresh_token is None ): self._set_connect_token() - return - headers = { - "content-type": "application/x-www-form-urlencoded; charset=utf-8", - } - payload = { - "grant_type": "refresh_token", - "refresh_token": self.connect_token.refresh_token, - } - resp = self._http_client.post( - "identity/connect/token", headers=headers, data=payload - ) - self._connect_token = ConnectToken.model_validate_json( - resp.text, context={"client": self, "cctx": []} - ) + else: + payload = { + "grant_type": "refresh_token", + "refresh_token": self.connect_token.refresh_token, + } + self._set_connect_token(payload) - def _set_connect_token(self): - headers = { - "content-type": "application/x-www-form-urlencoded; charset=utf-8", - } - payload = { + def _set_connect_token(self, refresh: dict | None = None): + payload = refresh or { "grant_type": "client_credentials", "client_secret": f"{self.client_secret}", "client_id": f"{self.client_id}", @@ -91,6 +80,9 @@ def _set_connect_token(self): "deviceIdentifier": f"{self.device_id}", "deviceName": "python-vaultwarden", } + headers = { + "content-type": "application/x-www-form-urlencoded; charset=utf-8", + } resp = self._http_client.post( "identity/connect/token", headers=headers, data=payload ) @@ -156,20 +148,23 @@ def _api_request( def sync(self, force_refresh: bool = False) -> SyncData: if self._sync is None or force_refresh: - assert ( - self._connect_token - and self._connect_token.PrivateKey - and self._connect_token._master_key - ) resp = self._api_request("GET", "api/sync") - self._sync = SyncData.model_validate_json( - resp.text, - context={ - "cctx": [ - self._connect_token.PrivateKey, - self._connect_token._master_key, - ] - }, + data = resp.json() + v = { + "profile": data["profile"], + "ciphers": [], + "collections": [], + "folders": [], + "policies": [], + "sends": [], + "domains": {}, + } + # populate self._sync.Profile + self._sync = SyncData.model_validate(v, context={"client": self}) + # uses self._sync.Profile + self._sync = SyncData.model_validate( + data, + context={"client": self}, ) return self._sync @@ -233,4 +228,6 @@ def create_item( ) resp = self._api_request("POST", path, json=data) - return CipherDetail.validate_json(resp.text, context={"cctx": [key]}) + return CipherDetail.validate_json( + resp.text, context={"client": self, "cctx": []} + ) diff --git a/src/vaultwarden/models/bitwarden.py b/src/vaultwarden/models/bitwarden.py index 45eb2ac..beedf40 100644 --- a/src/vaultwarden/models/bitwarden.py +++ b/src/vaultwarden/models/bitwarden.py @@ -6,6 +6,7 @@ from pathlib import Path from secrets import token_bytes import sys +import typing from typing import ( TYPE_CHECKING, Annotated, @@ -38,7 +39,7 @@ ) from typing_extensions import Self -from vaultwarden.models.crypto import SecretCipherKey, SecretString +from vaultwarden.models.crypto import SecretBytes, SecretKey, SecretString from vaultwarden.models.enum import CipherType, KdfType, OrganizationUserType from vaultwarden.models.exception_models import BitwardenError from vaultwarden.models.permissive_model import PermissiveBaseModel @@ -51,6 +52,7 @@ if TYPE_CHECKING: from vaultwarden.clients.bitwarden import BitwardenAPIClient + from vaultwarden.models.sync import ProfileOrganization if sys.version_info < (3, 12): from typing_extensions import Self @@ -71,7 +73,7 @@ def val_set_key( ) -> Any: key: str cctx: list[bytes] - if (key := data.get("key")) is not None: + if (key := (data.get("key") or data.get("Key"))) is not None: context = cast("dict", info.context) cctx = cast("list[bytes]", context.get("cctx")) v = SymmetricCipher.decode(key, cctx[-1]) @@ -229,56 +231,24 @@ class AttachmentRequest(BitwardenBaseModel): class Config: extra = "forbid" - key: SecretCipherKey + key: SecretBytes fileName: SecretString fileSize: int adminRequest: bool | None = None - @model_validator(mode="wrap") - @classmethod - def val_set_key( - cls, - data: Any, - handler: ModelWrapValidatorHandler[Self], - info: ValidationInfo, - ) -> Self: - return val_set_key(cls, data, handler, info) - - @model_serializer(mode="wrap") - def ser_set_key( - self, handler: SerializerFunctionWrapHandler, info: SerializationInfo - ) -> Any: - return ser_set_key(self, handler, info) - class Attachment(BitwardenBaseModel): class Config: extra = "forbid" + key: SecretBytes fileName: SecretString | None = None id: str - key: SecretCipherKey | None = None object: str size: int sizeName: str url: str - @model_validator(mode="wrap") - @classmethod - def val_set_key( - cls, - data: Any, - handler: ModelWrapValidatorHandler[Self], - info: ValidationInfo, - ) -> Self: - return val_set_key(cls, data, handler, info) - - @model_serializer(mode="wrap") - def ser_set_key( - self, handler: SerializerFunctionWrapHandler, info: SerializationInfo - ) -> Any: - return ser_set_key(self, handler, info) - def download(self): v = self._bitwarden_client._http_client.get(self.url) return BinarySymmetricCipher.decode(v.content, self.key) @@ -293,7 +263,7 @@ class Config: Type: CipherType Name: SecretString CollectionIds: list[UUID] - key: SecretCipherKey | None = None + key: SecretKey | None = None organizationUseTotp: bool | None = None creationDate: datetime.datetime | None = None @@ -322,7 +292,38 @@ def val_set_key( handler: ModelWrapValidatorHandler[Self], info: ValidationInfo, ) -> Self: - return val_set_key(cls, data, handler, info) + assert isinstance(info.context, dict) + + cctx: list[bytes] + + if (v := info.context.get("cctx", None)) is None: + cctx = info.context["cctx"] = [] + else: + cctx = cast(list[bytes], v) + + client: "BitwardenAPIClient" = cast( + "BitwardenAPIClient", info.context.get("client") + ) + assert client._sync and client._sync.Profile + + if (o := data.get("organizationId")) is not None: + oid = UUID(o) + org: typing.Optional["ProfileOrganization"] = None + for org in client._sync.Profile.Organizations: + if oid == org.Id: + assert org.Key + cctx.append(org.Key) + break + else: + raise ValueError(f"No organization found {oid}") + else: + assert client._connect_token + cctx.append(client._connect_token.Key) + r = val_set_key(cls, data, handler, info) + + cctx.pop() + + return r @model_serializer(mode="wrap") def ser_set_key( @@ -927,14 +928,9 @@ 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, - "cctx": [org_key], # crypto context - }, + context={"parent_id": self.Id, "client": self.api_client}, ) return res.Data @@ -969,8 +965,22 @@ def key(self) -> bytes: def get_organization( - bitwarden_client, organisation_id: UUID | str + bitwarden_client: "BitwardenAPIClient", organisation_id: UUID | str ) -> Organization: + if bitwarden_client._sync is not None: + oid = ( + UUID(organisation_id) + if isinstance(organisation_id, str) + else organisation_id + ) + for org in bitwarden_client._sync.Profile.Organizations: + if org.Id == oid: + r = Organization.model_construct( + Id=org.Id, Name=org.Name, BillingEmail="", Object="" + ) + r._bitwarden_client = bitwarden_client + return r + resp = bitwarden_client.api_request( "GET", f"api/organizations/{organisation_id}" ) diff --git a/src/vaultwarden/models/crypto.py b/src/vaultwarden/models/crypto.py index 19d73c7..e0ec71b 100644 --- a/src/vaultwarden/models/crypto.py +++ b/src/vaultwarden/models/crypto.py @@ -13,158 +13,135 @@ from vaultwarden.utils.crypto import AsymmetricCipher, SymmetricCipher -def decode_org_key( - value: str, handler: ValidatorFunctionWrapHandler, info: ValidationInfo -) -> bytes: +def decode_string( + value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo +) -> str: context: dict = cast("dict", info.context) keys: list[bytes] = cast("list[bytes]", context.get("cctx")) - for key in keys[::-1]: - if not isinstance(key, RSA.RsaKey): - continue - try: - return handler(AsymmetricCipher.decode(value, key)) - except Exception as e: - print(e) - continue - raise ValueError("No key found") + return handler(SymmetricCipher.decode(value, keys[-1])) -def encode_org_key( - value: bytes, - handler: SerializerFunctionWrapHandler, - info: SerializationInfo, +def encode_string( + value: str, handler: SerializerFunctionWrapHandler, info: SerializationInfo ) -> str: context: dict = cast("dict", info.context) keys: list[bytes] = cast("list[bytes]", context.get("cctx")) if keys: - return handler(AsymmetricCipher.encode(value, keys[-2])) + return handler(SymmetricCipher.encode(value.encode(), keys[-1])) raise ValueError("No key found") -SecretOrganizationKey = Annotated[ - bytes, WrapValidator(decode_org_key), WrapSerializer(encode_org_key) +SecretString = Annotated[ + str, WrapValidator(decode_string), WrapSerializer(encode_string) ] +""" +Symmetric encoded string value +""" -def decode_string( - value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo -) -> str: +def decode_bytes( + value: str, handler: ValidatorFunctionWrapHandler, info: ValidationInfo +) -> bytes: context: dict = cast("dict", info.context) keys: list[bytes] = cast("list[bytes]", context.get("cctx")) - for key in keys[::-1]: - try: - return handler(SymmetricCipher.decode(value, key)) - except Exception as e: - print(e) - continue - raise ValueError("No key found") + return handler(SymmetricCipher.decode(value, keys[-1])) -def encode_string( - value: str, handler: SerializerFunctionWrapHandler, info: SerializationInfo -) -> str: +def encode_bytes( + value: Any, handler: SerializerFunctionWrapHandler, info: SerializationInfo +) -> bytes: context: dict = cast("dict", info.context) keys: list[bytes] = cast("list[bytes]", context.get("cctx")) - if keys: - return handler(SymmetricCipher.encode(value.encode(), keys[-1])) - raise ValueError("No key found") + return handler(SymmetricCipher.encode(value, keys[-1])) -SecretString = Annotated[ - str, WrapValidator(decode_string), WrapSerializer(encode_string) +SecretBytes = Annotated[ + bytes, WrapValidator(decode_bytes), WrapSerializer(encode_bytes) ] +""" +Symmetric encoded bytes value +""" -def decode_cipher_key( +def decode_rsa( value: str, handler: ValidatorFunctionWrapHandler, info: ValidationInfo -) -> bytes: +) -> RSA.RsaKey: context: dict = cast("dict", info.context) keys: list[bytes] = cast("list[bytes]", context.get("cctx")) - for key in keys[-2::-1]: # not last element - reverse - try: - return handler(SymmetricCipher.decode(value, key)) - except Exception as e: - print(e) - continue - raise ValueError("No key found") + return handler(RSA.importKey(SymmetricCipher.decode(value, keys[-1]))) -def encode_cipher_key( - value: bytes, +def encode_rsa( + value: RSA.RsaKey, handler: SerializerFunctionWrapHandler, info: SerializationInfo, -) -> str: +) -> bytes: context: dict = cast("dict", info.context) keys: list[bytes] = cast("list[bytes]", context.get("cctx")) - if keys: - return handler(SymmetricCipher.encode(value, keys[-2])) - raise ValueError("No key found") + return handler( + SymmetricCipher.encode(value.exportKey("DER", pkcs=8), keys[-1]) + ) -SecretCipherKey = Annotated[ - bytes, WrapValidator(decode_cipher_key), WrapSerializer(encode_cipher_key) +SecretRSA = Annotated[ + RSA.RsaKey, WrapValidator(decode_rsa), WrapSerializer(encode_rsa) ] +""" +Symmetric encoded RSA key +""" -def decode_key( +def decode_org_key( value: str, handler: ValidatorFunctionWrapHandler, info: ValidationInfo ) -> bytes: context: dict = cast("dict", info.context) keys: list[bytes] = cast("list[bytes]", context.get("cctx")) - for key in keys[::-1]: - try: - return handler(SymmetricCipher.decode(value, key)) - except Exception as e: - print(e) - continue - raise ValueError("No key found") + return handler(AsymmetricCipher.decode(value, keys[-1])) -def encode_key( - value: Any, handler: SerializerFunctionWrapHandler, info: SerializationInfo -) -> bytes: +def encode_org_key( + value: bytes, + handler: SerializerFunctionWrapHandler, + info: SerializationInfo, +) -> str: context: dict = cast("dict", info.context) keys: list[bytes] = cast("list[bytes]", context.get("cctx")) - if keys: - SymmetricCipher.encode(handler(value), keys[-1]) - raise ValueError("No key found") + return handler(AsymmetricCipher.encode(value, keys[-1])) -SecretKey = Annotated[ - bytes, WrapValidator(decode_key), WrapSerializer(encode_key) +SecretOrganizationKey = Annotated[ + bytes, WrapValidator(decode_org_key), WrapSerializer(encode_org_key) ] +""" +Asymmetric encoded Key +* key is not added to cctx +* encoding uses the seconds last key in cctx +""" -def decode_rsa( + +def decode_key( value: str, handler: ValidatorFunctionWrapHandler, info: ValidationInfo -) -> RSA.RsaKey: +) -> bytes: context: dict = cast("dict", info.context) keys: list[bytes] = cast("list[bytes]", context.get("cctx")) - for key in keys[::-1]: - try: - return handler(RSA.importKey(SymmetricCipher.decode(value, key))) - except Exception as e: - print(e) - continue - raise ValueError("No key found") + return handler(SymmetricCipher.decode(value, keys[-2])) -def encode_rsa( - value: RSA.RsaKey, - handler: SerializerFunctionWrapHandler, - info: SerializationInfo, +def encode_key( + value: Any, handler: SerializerFunctionWrapHandler, info: SerializationInfo ) -> bytes: context: dict = cast("dict", info.context) keys: list[bytes] = cast("list[bytes]", context.get("cctx")) - if keys: - return handler( - SymmetricCipher.encode( - handler(value.exportKey("DER", pkcs=8)), keys[-1] - ) - ) - raise ValueError("No key found") + return handler(SymmetricCipher.encode(value, keys[-2])) -SecretRSA = Annotated[ - RSA.RsaKey, WrapValidator(decode_rsa), WrapSerializer(encode_rsa) +SecretKey = Annotated[ + bytes, WrapValidator(decode_key), WrapSerializer(encode_key) ] +""" +Symmetric encoded Key + +* the Key is added to cctx by ser_set_key / val_set_key of the model +* en/decoding uses the [-2] key in cctx +""" diff --git a/src/vaultwarden/models/sync.py b/src/vaultwarden/models/sync.py index 23e8c30..706f2a8 100644 --- a/src/vaultwarden/models/sync.py +++ b/src/vaultwarden/models/sync.py @@ -1,4 +1,5 @@ import time +import typing from typing import Any, Self, cast from uuid import UUID @@ -12,6 +13,7 @@ model_validator, ) +from vaultwarden.models.bitwarden import Login, val_set_key from vaultwarden.models.crypto import ( SecretKey, SecretOrganizationKey, @@ -19,7 +21,9 @@ ) from vaultwarden.models.enum import KdfType, VaultwardenUserStatus from vaultwarden.models.permissive_model import PermissiveBaseModel -from vaultwarden.utils.crypto import SymmetricCipher + +if typing.TYPE_CHECKING: + from vaultwarden.clients.bitwarden import BitwardenAPIClient class ConnectToken(PermissiveBaseModel): @@ -49,16 +53,6 @@ def is_expired(self, now=None): now = time.time() return (self.expires_in is not None) and (self.expires_in <= now) - @field_validator("Key", mode="wrap") - @classmethod - def val_field_key(cls, v: str, handler: Any, info: ValidationInfo) -> str: - assert info and info.context - r = handler(v) - - cctx = cast("list[bytes]", info.context["cctx"]) - cctx.append(r) - return r - @model_validator(mode="wrap") @classmethod def val_set_key( @@ -84,8 +78,7 @@ def val_set_key( kdf=Kdf.model_validate(data), ) cctx.append(master_key) - v = handler(data) - cctx.pop() # Key + v = val_set_key(cls, data, handler, info) cctx.pop() # master_key v._master_key = master_key return v @@ -125,9 +118,9 @@ class UserProfile(PermissiveBaseModel): MasterPasswordHint: str | None = None Name: str | None Object: str | None + PrivateKey: SecretRSA | None Organizations: list[ProfileOrganization] Premium: bool - PrivateKey: SecretRSA | None ProviderOrganizations: list Providers: list SecurityStamp: str @@ -138,6 +131,21 @@ class UserProfile(PermissiveBaseModel): validation_alias=AliasChoices("_status", "_Status"), ) + @field_validator("Organizations", mode="wrap") + @classmethod + def val_field_Organizations( # noqa: N802 + cls, + v: str, + handler: ModelWrapValidatorHandler[Self], + info: ValidationInfo, + ) -> Self: + assert info.context and isinstance(info.context, dict) + cctx: list[bytes] = cast(list["bytes"], info.context["cctx"]) + cctx.append(info.data["PrivateKey"]) + r = handler(v) + cctx.pop() + return r + @model_validator(mode="wrap") @classmethod def val_set_key( @@ -146,19 +154,7 @@ def val_set_key( handler: ModelWrapValidatorHandler[Self], info: ValidationInfo, ) -> Self: - cctx: list[bytes] - key: str - if (key := data.get("key")) is not None: - context = cast("dict", info.context) - cctx = cast("list[bytes]", context.get("cctx")) - v = SymmetricCipher.decode(key, cctx[-1]) - cctx.append(v) - - r = handler(data) - if key: - cctx.pop(0) - - return r + return val_set_key(cls, data, handler, info) class VaultwardenUser(UserProfile): @@ -167,12 +163,32 @@ class VaultwardenUser(UserProfile): LastActive: str | None = None -# TODO: add definition of attribute's types class SyncData(PermissiveBaseModel): - Ciphers: list[dict] + Profile: UserProfile + Ciphers: list[Login] Collections: list[dict] Domains: dict | None Folders: list[dict] Policies: list[dict] - Profile: UserProfile Sends: list[dict] + + @model_validator(mode="wrap") + @classmethod + def val_set_key( + cls, + data: Any, + handler: ModelWrapValidatorHandler[Self], + info: ValidationInfo, + ) -> Self: + assert info.context and isinstance(info.context, dict) + cctx: list[bytes] + if (v := info.context.get("cctx")) is None: + cctx = info.context["cctx"] = [] + else: + cctx = cast(list[bytes], v) + client: "BitwardenAPIClient" = info.context.get("client") + assert client._connect_token and client._connect_token._master_key + cctx.append(client._connect_token._master_key) + r = handler(data) + cctx.pop() + return r diff --git a/src/vaultwarden/utils/crypto.py b/src/vaultwarden/utils/crypto.py index de465fb..10f0466 100644 --- a/src/vaultwarden/utils/crypto.py +++ b/src/vaultwarden/utils/crypto.py @@ -217,30 +217,6 @@ def parse(cls, ct): return cls(iv), ct -class UnimplementedError(Exception): - """.""" - - -class DecodeEncKeyError(ValueError): - """.""" - - -class WrongFormatError(DecodeEncKeyError): - """.""" - - -class WrongTypeDecryptError(DecodeEncKeyError): - """.""" - - -class MissingPartsDecryptError(DecodeEncKeyError): - """.""" - - -class B64DecryptError(DecodeEncKeyError): - """.""" - - class DecryptError(ValueError): """.""" diff --git a/tests/e2e/test_bitwarden.py b/tests/e2e/test_bitwarden.py index a8c585f..e02a987 100644 --- a/tests/e2e/test_bitwarden.py +++ b/tests/e2e/test_bitwarden.py @@ -23,6 +23,7 @@ client_secret = os.environ.get("BITWARDEN_CLIENT_SECRET", None) device_id = os.environ.get("BITWARDEN_DEVICE_ID", None) + # Get test organization id from environment variables test_organization = os.environ.get("BITWARDEN_TEST_ORGANIZATION", None) From 4b295222bae60607f412e49bb8a82010f3540a81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Thu, 11 Jun 2026 14:39:31 +0200 Subject: [PATCH 11/35] tests - set env from ci.yml for local use --- tests/e2e/test_bitwarden.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/tests/e2e/test_bitwarden.py b/tests/e2e/test_bitwarden.py index e02a987..3f7581e 100644 --- a/tests/e2e/test_bitwarden.py +++ b/tests/e2e/test_bitwarden.py @@ -4,16 +4,18 @@ import unittest from vaultwarden.clients.bitwarden import BitwardenAPIClient -from vaultwarden.models.bitwarden import get_organization - -env = Path("tests/.env").read_text() -for line in env.splitlines(): - k, v = line.strip().split(":", maxsplit=1) - v = v.strip().strip('"') - if os.environ.get(k) is None: - print(f"{k} = {v}") - os.environ[k] = v +from vaultwarden.models.bitwarden import ( + get_organization, +) + +if os.environ.get("BITWARDEN_URL", None) is None: + from pathlib import Path + import yaml + + obj = yaml.safe_load(Path(".github/workflows/ci.yml").read_text()) + for k, v in obj["jobs"]["test"]["steps"][-1]["env"].items(): + os.environ[k] = v # Get Bitwarden credentials from environment variables url = os.environ.get("BITWARDEN_URL", None) From 6cc9d54d2af758b30cf3ec1c1345134627275190 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Thu, 11 Jun 2026 14:42:39 +0200 Subject: [PATCH 12/35] tests - disable db changing tests --- tests/e2e/test_bitwarden.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/e2e/test_bitwarden.py b/tests/e2e/test_bitwarden.py index 3f7581e..21fd69e 100644 --- a/tests/e2e/test_bitwarden.py +++ b/tests/e2e/test_bitwarden.py @@ -156,7 +156,7 @@ def test_deduplicate(self): # Todo build test fixtures and delete them at the end of the test return - def test_create_user(self): + def _test_create_user(self): import random from vaultwarden.models.bitwarden import Kdf @@ -172,7 +172,7 @@ def test_create_user(self): kdf=Kdf.argon2id(), ) - def test_create_org_login(self): + def _test_create_org_login(self): from secrets import token_bytes from vaultwarden.models.bitwarden import Login, LoginData @@ -196,7 +196,7 @@ def test_create_org_login(self): item, self.organization, collections=self.test_colls_ids ) - def test_create_own_login(self): + def _test_create_own_login(self): from secrets import token_bytes from vaultwarden.models.bitwarden import Login, LoginData @@ -218,7 +218,7 @@ def test_create_own_login(self): ) bitwarden.create_item(item, None, collections=self.test_colls_ids) - def test_create_attachment(self): + def _test_create_attachment(self): from pathlib import Path from vaultwarden.models.bitwarden import Login @@ -228,6 +228,10 @@ def test_create_attachment(self): ) login.attach(Path("/etc/modules")) + def _test_sync(self): + for v in bitwarden._sync.Ciphers: + print(f"{v.Name} - {v.OrganizationId}") + class BitwardenWithEmailTests(unittest.TestCase, BitwardenBaseTests): def setUp(self): From 42c7e8236f32d7821e8e5a818997ddab94085519 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Thu, 11 Jun 2026 14:45:19 +0200 Subject: [PATCH 13/35] tests - disable rename org --- tests/e2e/test_bitwarden.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/test_bitwarden.py b/tests/e2e/test_bitwarden.py index 21fd69e..a63e5bb 100644 --- a/tests/e2e/test_bitwarden.py +++ b/tests/e2e/test_bitwarden.py @@ -134,7 +134,7 @@ def test_invite_user_than_remove(self): self.assertIsNotNone(user) user.delete() - def test_rename_organization(self): + def _test_rename_organization(self): old_name = self.organization.Name new_name = "new_test_organization" self.organization.rename(new_name) From 8c925acc5e3d46ceaaab3e47779640793b975130 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Thu, 11 Jun 2026 16:19:12 +0200 Subject: [PATCH 14/35] tests - disable failing tests --- tests/models/validation/test_bitwarden_models.py | 4 ++-- tests/models/validation/test_pascal_camel_cases.py | 8 ++++---- tests/models/validation/test_sync_models.py | 2 +- tests/models/validation/test_vaultwarden_models.py | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/models/validation/test_bitwarden_models.py b/tests/models/validation/test_bitwarden_models.py index 657296e..170fd99 100644 --- a/tests/models/validation/test_bitwarden_models.py +++ b/tests/models/validation/test_bitwarden_models.py @@ -15,14 +15,14 @@ def read_json_payload(file_path): with open(file_path, "r") as file: return file.read() - def test_organization(self): + def _test_organization(self): payload = self.read_json_payload( "tests/fixtures/test-organization/organization_camel.json" ) data = Organization.model_validate_json(payload) assert data.Name == "Test Organization" - def test_organization_users(self): + def _test_organization_users(self): payload = self.read_json_payload( "tests/fixtures/test-organization/users_camel.json" ) diff --git a/tests/models/validation/test_pascal_camel_cases.py b/tests/models/validation/test_pascal_camel_cases.py index e82ebd1..279ba55 100644 --- a/tests/models/validation/test_pascal_camel_cases.py +++ b/tests/models/validation/test_pascal_camel_cases.py @@ -15,7 +15,7 @@ def read_json_payload(file_path): with open(file_path, "r") as file: return file.read() - def test_organization(self): + def _test_organization(self): pascal_case_payload = self.read_json_payload( "tests/fixtures/test-organization/organization_pascal.json" ) @@ -26,7 +26,7 @@ def test_organization(self): camel = Organization.model_validate_json(camel_case_payload) self.assertEqual(pascal.Name, camel.Name) - def test_collections(self): + def _test_collections(self): pascal_case_payload = self.read_json_payload( "tests/fixtures/test-organization/collections/collections_pascal.json" ) @@ -47,7 +47,7 @@ def test_collections(self): self.assertEqual(pascal_collections[0].Name, camel_collections[0].Name) self.assertEqual(pascal_collections[1].Name, camel_collections[1].Name) - def test_sync_data(self): + def _test_sync_data(self): pascal_case_payload = self.read_json_payload( "tests/fixtures/test-account/sync_pascal.json" ) @@ -65,7 +65,7 @@ def test_sync_data(self): pascal.Collections[1].get("Name"), camel.Collections[1].get("name") ) - def test_admin_users(self): + def _test_admin_users(self): pascal_case_payload = self.read_json_payload( "tests/fixtures/admin/users_pascal.json" ) diff --git a/tests/models/validation/test_sync_models.py b/tests/models/validation/test_sync_models.py index f438f54..bb38d0d 100644 --- a/tests/models/validation/test_sync_models.py +++ b/tests/models/validation/test_sync_models.py @@ -9,7 +9,7 @@ def read_json_payload(file_path): with open(file_path, "r") as file: return file.read() - def test_syncdata(self): + def _test_syncdata(self): payload = self.read_json_payload( "tests/fixtures/test-account/sync_camel.json" ) diff --git a/tests/models/validation/test_vaultwarden_models.py b/tests/models/validation/test_vaultwarden_models.py index 4cb475c..97eed77 100644 --- a/tests/models/validation/test_vaultwarden_models.py +++ b/tests/models/validation/test_vaultwarden_models.py @@ -10,7 +10,7 @@ def read_json_payload(file_path): with open(file_path, "r") as file: return file.read() - def test_users(self): + def _test_users(self): payload = self.read_json_payload( "tests/fixtures/admin/users_camel.json" ) From bcdda771cd6ecc1d9cc9e83c35030e3636185625 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Thu, 11 Jun 2026 16:23:07 +0200 Subject: [PATCH 15/35] typing - Self f. py3.10 --- src/vaultwarden/models/bitwarden.py | 3 +-- src/vaultwarden/models/sync.py | 9 ++++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/vaultwarden/models/bitwarden.py b/src/vaultwarden/models/bitwarden.py index beedf40..4735769 100644 --- a/src/vaultwarden/models/bitwarden.py +++ b/src/vaultwarden/models/bitwarden.py @@ -37,7 +37,6 @@ SerializerFunctionWrapHandler, ValidationInfo, ) -from typing_extensions import Self from vaultwarden.models.crypto import SecretBytes, SecretKey, SecretString from vaultwarden.models.enum import CipherType, KdfType, OrganizationUserType @@ -54,7 +53,7 @@ from vaultwarden.clients.bitwarden import BitwardenAPIClient from vaultwarden.models.sync import ProfileOrganization -if sys.version_info < (3, 12): +if sys.version_info < (3, 11): from typing_extensions import Self else: from typing import Self diff --git a/src/vaultwarden/models/sync.py b/src/vaultwarden/models/sync.py index 706f2a8..9451f89 100644 --- a/src/vaultwarden/models/sync.py +++ b/src/vaultwarden/models/sync.py @@ -1,6 +1,7 @@ +import sys import time import typing -from typing import Any, Self, cast +from typing import Any, cast from uuid import UUID from pydantic import ( @@ -22,6 +23,12 @@ from vaultwarden.models.enum import KdfType, VaultwardenUserStatus from vaultwarden.models.permissive_model import PermissiveBaseModel +if sys.version_info < (3, 11): + from typing_extensions import Self +else: + from typing import Self + + if typing.TYPE_CHECKING: from vaultwarden.clients.bitwarden import BitwardenAPIClient From 47d615b242d6eab45d1286c4e7f4f38e32299cd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Thu, 11 Jun 2026 17:13:26 +0200 Subject: [PATCH 16/35] crypto - use CryptoContext instead of dict for ser/des affects {SerializationInfo,ValidationInfo,FieldValidationInfo}.context --- src/vaultwarden/clients/bitwarden.py | 21 ++-- src/vaultwarden/models/bitwarden.py | 109 +++++++++--------- src/vaultwarden/models/crypto.py | 76 ++++++------ src/vaultwarden/models/sync.py | 46 +++----- .../validation/test_bitwarden_models.py | 17 ++- 5 files changed, 140 insertions(+), 129 deletions(-) diff --git a/src/vaultwarden/clients/bitwarden.py b/src/vaultwarden/clients/bitwarden.py index e29fe77..59f5228 100644 --- a/src/vaultwarden/clients/bitwarden.py +++ b/src/vaultwarden/clients/bitwarden.py @@ -5,6 +5,7 @@ from httpx import Client, Response from vaultwarden.models.bitwarden import CipherDetail, RegisterData +from vaultwarden.models.crypto import CryptoContext from vaultwarden.models.exception_models import BitwardenError from vaultwarden.models.sync import ConnectToken, SyncData from vaultwarden.utils.logger import log_raise_for_status @@ -87,7 +88,7 @@ def _set_connect_token(self, refresh: dict | None = None): "identity/connect/token", headers=headers, data=payload ) self._connect_token = ConnectToken.model_validate_json( - resp.text, context={"client": self, "cctx": []} + resp.text, context=CryptoContext(client=self) ) if self.email is None: @@ -160,11 +161,13 @@ def sync(self, force_refresh: bool = False) -> SyncData: "domains": {}, } # populate self._sync.Profile - self._sync = SyncData.model_validate(v, context={"client": self}) + self._sync = SyncData.model_validate( + v, context=CryptoContext(client=self) + ) # uses self._sync.Profile self._sync = SyncData.model_validate( data, - context={"client": self}, + context=CryptoContext(client=self), ) return self._sync @@ -194,7 +197,7 @@ def create_user( by_alias=True, exclude_none=True, exclude_unset=True, - context={"client": self}, + context=CryptoContext(client=self), ) resp = self._api_request("POST", "api/accounts/register", json=data) return resp.json() @@ -215,7 +218,9 @@ def create_item( data = { "type": item.Type, "cipher": item.model_dump( - by_alias=True, mode="json", context={"cctx": [key]} + by_alias=True, + mode="json", + context=CryptoContext(client=self, stack=[key]), ), "collectionIds": [str(i.Id) for i in collections], } @@ -224,10 +229,12 @@ def create_item( assert self.connect_token is not None key = self.connect_token.Key data = item.model_dump( - by_alias=True, mode="json", context={"cctx": [key]} + by_alias=True, + mode="json", + context=CryptoContext(client=self, stack=[key]), ) resp = self._api_request("POST", path, json=data) return CipherDetail.validate_json( - resp.text, context={"client": self, "cctx": []} + resp.text, context=CryptoContext(client=self) ) diff --git a/src/vaultwarden/models/bitwarden.py b/src/vaultwarden/models/bitwarden.py index 4735769..df32f33 100644 --- a/src/vaultwarden/models/bitwarden.py +++ b/src/vaultwarden/models/bitwarden.py @@ -38,7 +38,12 @@ ValidationInfo, ) -from vaultwarden.models.crypto import SecretBytes, SecretKey, SecretString +from vaultwarden.models.crypto import ( + CryptoContext, + SecretBytes, + SecretKey, + SecretString, +) from vaultwarden.models.enum import CipherType, KdfType, OrganizationUserType from vaultwarden.models.exception_models import BitwardenError from vaultwarden.models.permissive_model import PermissiveBaseModel @@ -71,17 +76,16 @@ def val_set_key( info: ValidationInfo, ) -> Any: key: str - cctx: list[bytes] + ctx: CryptoContext = cast(CryptoContext, info.context) if (key := (data.get("key") or data.get("Key"))) is not None: - context = cast("dict", info.context) - cctx = cast("list[bytes]", context.get("cctx")) - v = SymmetricCipher.decode(key, cctx[-1]) - cctx.append(v) + assert isinstance(ctx.stack[-1], bytes) + v = SymmetricCipher.decode(key, ctx.stack[-1]) + ctx.push(v) r = handler(data) if key is not None: - cctx.pop() + ctx.pop() return r @@ -90,16 +94,14 @@ def ser_set_key( slf: Any, handler: SerializerFunctionWrapHandler, info: SerializationInfo ) -> Any: key: bytes | None - cctx: list[bytes] if (key := slf.key) is not None: - context = cast("dict", info.context) - cctx = cast("list[bytes]", context.get("cctx")) - cctx.append(key) + ctx: CryptoContext = cast(CryptoContext, info.context) + ctx.push(key) v = handler(slf) if key is not None: - cctx.pop() + ctx.pop() return v @@ -119,9 +121,9 @@ def val_set_client( handler: ModelWrapValidatorHandler[Self], info: ValidationInfo, ) -> Self: - assert info.context + ctx: CryptoContext = cast(CryptoContext, info.context) v = handler(data) - v._bitwarden_client = info.context.get("client") + v._bitwarden_client = ctx.client return v @property @@ -291,36 +293,28 @@ def val_set_key( handler: ModelWrapValidatorHandler[Self], info: ValidationInfo, ) -> Self: - assert isinstance(info.context, dict) + assert isinstance(info.context, CryptoContext) - cctx: list[bytes] + ctx: CryptoContext = cast(CryptoContext, info.context) - if (v := info.context.get("cctx", None)) is None: - cctx = info.context["cctx"] = [] - else: - cctx = cast(list[bytes], v) - - client: "BitwardenAPIClient" = cast( - "BitwardenAPIClient", info.context.get("client") - ) - assert client._sync and client._sync.Profile + assert ctx.client._sync and ctx.client._sync.Profile if (o := data.get("organizationId")) is not None: oid = UUID(o) org: typing.Optional["ProfileOrganization"] = None - for org in client._sync.Profile.Organizations: + for org in ctx.client._sync.Profile.Organizations: if oid == org.Id: assert org.Key - cctx.append(org.Key) + ctx.push(org.Key) break else: raise ValueError(f"No organization found {oid}") else: - assert client._connect_token - cctx.append(client._connect_token.Key) + assert ctx.client._connect_token + ctx.push(ctx.client._connect_token.Key) r = val_set_key(cls, data, handler, info) - cctx.pop() + ctx.pop() return r @@ -334,7 +328,8 @@ def ser_set_key( @classmethod def set_id(cls, v, info: FieldValidationInfo): if v is None and info.context is not None: - return info.context.get("parent_id") + ctx: CryptoContext = cast(CryptoContext, info.context) + return ctx.parent_id return v def add_collections(self, collections: list[UUID]): @@ -385,15 +380,15 @@ def _attach(self, name: str, file: io.IOBase): key=key, fileName=name, fileSize=len(ed), adminRequest=True ) if self.OrganizationId: - cctx = [ + stack = [ get_organization( self._bitwarden_client, self.OrganizationId ).key() ] else: - cctx = [self._bitwarden_client._connect_token._masterKey] + stack = [self._bitwarden_client._connect_token._masterKey] ard = ar.model_dump( - context={"client": self._bitwarden_client, "cctx": cctx} + context=CryptoContext(client=self._bitwarden_client, stack=stack) ) v = self._bitwarden_client._api_request( "POST", f"api/ciphers/{self.Id}/attachment/v2", json=ard @@ -480,7 +475,8 @@ class CollectionUser(CollectionAccess): @classmethod def set_id(cls, v, info: FieldValidationInfo): if v is None and info.context is not None: - return info.context.get("parent_id") + ctx: CryptoContext = cast(CryptoContext, info.context) + return ctx.parent_id return v @@ -496,7 +492,8 @@ class UserCollection(CollectionAccess): @classmethod def set_id(cls, v, info: FieldValidationInfo): if v is None and info.context is not None: - return info.context.get("parent_id") + ctx: CryptoContext = cast(CryptoContext, info.context) + return ctx.parent_id return v @@ -510,7 +507,8 @@ class OrganizationCollection(BitwardenBaseModel): @classmethod def set_id(cls, v, info: FieldValidationInfo): if v is None and info.context is not None: - return info.context.get("parent_id") + ctx: CryptoContext = cast(CryptoContext, info.context) + return ctx.parent_id return v def users(self) -> list[CollectionUser]: @@ -521,7 +519,7 @@ def users(self) -> list[CollectionUser]: ) return TypeAdapter(list[CollectionUser]).validate_json( resp.text, - context={"parent_id": self.Id, "client": self.api_client}, + context=CryptoContext(client=self.api_client, parent_id=self.Id), ) def set_users( @@ -585,7 +583,8 @@ class OrganizationUserDetails(BitwardenBaseModel): @classmethod def set_id(cls, v, info: FieldValidationInfo): if v is None and info.context is not None: - return info.context.get("parent_id") + ctx: CryptoContext = cast(CryptoContext, info.context) + return ctx.parent_id return v def add_collections(self, collections: list[UUID]): @@ -721,7 +720,8 @@ class Organization(BitwardenBaseModel): @classmethod def set_id(cls, v, info: FieldValidationInfo): if v is None and info.context is not None: - return info.context.get("parent_id") + ctx: CryptoContext = cast(CryptoContext, info.context) + return ctx.parent_id return v def rename(self, new_name: str): @@ -807,10 +807,9 @@ def _get_users(self) -> list[OrganizationUserDetails]: ResplistBitwarden[OrganizationUserDetails] .model_validate_json( resp.text, - context={ - "parent_id": self.Id, - "client": self.api_client, - }, + context=CryptoContext( + client=self.api_client, parent_id=self.Id + ), ) .Data ) @@ -843,7 +842,7 @@ def user(self, user_id: UUID) -> OrganizationUserDetails: ) return OrganizationUserDetails.model_validate_json( resp.text, - context={"parent_id": self.Id, "client": self.api_client}, + context=CryptoContext(client=self.api_client, parent_id=self.Id), ) def user_search( @@ -863,11 +862,9 @@ def _get_collections(self) -> list[OrganizationCollection]: ) res = ResplistBitwarden[OrganizationCollection].model_validate_json( resp.text, - context={ - "parent_id": self.Id, - "client": self.api_client, - "cctx": [self.key()], - }, + context=CryptoContext( + client=self.api_client, parent_id=self.Id, stack=[self.key()] + ), ) return res.Data @@ -892,11 +889,9 @@ def create_collection(self, name: str) -> OrganizationCollection: ) res = OrganizationCollection.model_validate_json( resp.text, - context={ - "parent_id": self.Id, - "client": self.api_client, - "cctx": [org_key], - }, + context=CryptoContext( + client=self.api_client, parent_id=self.Id, stack=[org_key] + ), ) if self._collections is not None: self._collections.append(res) @@ -929,7 +924,7 @@ def _get_ciphers(self) -> list[CipherDetails]: ) res = ResplistBitwarden[CipherDetails].model_validate_json( resp.text, - context={"parent_id": self.Id, "client": self.api_client}, + context=CryptoContext(client=self.api_client, parent_id=self.Id), ) return res.Data @@ -985,7 +980,7 @@ def get_organization( ) return Organization.model_validate_json( resp.text, - context={"client": bitwarden_client, "parent_id": organisation_id}, + context=CryptoContext(client=bitwarden_client, parent_id=oid), ) diff --git a/src/vaultwarden/models/crypto.py b/src/vaultwarden/models/crypto.py index e0ec71b..0a8d033 100644 --- a/src/vaultwarden/models/crypto.py +++ b/src/vaultwarden/models/crypto.py @@ -1,4 +1,7 @@ -from typing import Annotated, Any, cast +import dataclasses +import typing +from typing import Any, TypeAlias, cast +from uuid import UUID from Crypto.PublicKey import RSA from pydantic import ( @@ -9,26 +12,26 @@ WrapSerializer, WrapValidator, ) +from typing_extensions import Annotated from vaultwarden.utils.crypto import AsymmetricCipher, SymmetricCipher +if typing.TYPE_CHECKING: + from vaultwarden.clients.bitwarden import BitwardenAPIClient + def decode_string( value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo ) -> str: - context: dict = cast("dict", info.context) - keys: list[bytes] = cast("list[bytes]", context.get("cctx")) - return handler(SymmetricCipher.decode(value, keys[-1])) + ctx = cast(CryptoContext, info.context) + return handler(SymmetricCipher.decode(value, ctx.stack[-1])) def encode_string( value: str, handler: SerializerFunctionWrapHandler, info: SerializationInfo ) -> str: - context: dict = cast("dict", info.context) - keys: list[bytes] = cast("list[bytes]", context.get("cctx")) - if keys: - return handler(SymmetricCipher.encode(value.encode(), keys[-1])) - raise ValueError("No key found") + ctx = cast(CryptoContext, info.context) + return handler(SymmetricCipher.encode(value.encode(), ctx.stack[-1])) SecretString = Annotated[ @@ -42,17 +45,15 @@ def encode_string( def decode_bytes( value: str, handler: ValidatorFunctionWrapHandler, info: ValidationInfo ) -> bytes: - context: dict = cast("dict", info.context) - keys: list[bytes] = cast("list[bytes]", context.get("cctx")) - return handler(SymmetricCipher.decode(value, keys[-1])) + ctx = cast(CryptoContext, info.context) + return handler(SymmetricCipher.decode(value, ctx.stack[-1])) def encode_bytes( value: Any, handler: SerializerFunctionWrapHandler, info: SerializationInfo ) -> bytes: - context: dict = cast("dict", info.context) - keys: list[bytes] = cast("list[bytes]", context.get("cctx")) - return handler(SymmetricCipher.encode(value, keys[-1])) + ctx = cast(CryptoContext, info.context) + return handler(SymmetricCipher.encode(value, ctx.stack[-1])) SecretBytes = Annotated[ @@ -66,9 +67,8 @@ def encode_bytes( def decode_rsa( value: str, handler: ValidatorFunctionWrapHandler, info: ValidationInfo ) -> RSA.RsaKey: - context: dict = cast("dict", info.context) - keys: list[bytes] = cast("list[bytes]", context.get("cctx")) - return handler(RSA.importKey(SymmetricCipher.decode(value, keys[-1]))) + ctx = cast(CryptoContext, info.context) + return handler(RSA.importKey(SymmetricCipher.decode(value, ctx.stack[-1]))) def encode_rsa( @@ -76,10 +76,9 @@ def encode_rsa( handler: SerializerFunctionWrapHandler, info: SerializationInfo, ) -> bytes: - context: dict = cast("dict", info.context) - keys: list[bytes] = cast("list[bytes]", context.get("cctx")) + ctx = cast(CryptoContext, info.context) return handler( - SymmetricCipher.encode(value.exportKey("DER", pkcs=8), keys[-1]) + SymmetricCipher.encode(value.exportKey("DER", pkcs=8), ctx.stack[-1]) ) @@ -94,9 +93,8 @@ def encode_rsa( def decode_org_key( value: str, handler: ValidatorFunctionWrapHandler, info: ValidationInfo ) -> bytes: - context: dict = cast("dict", info.context) - keys: list[bytes] = cast("list[bytes]", context.get("cctx")) - return handler(AsymmetricCipher.decode(value, keys[-1])) + ctx = cast(CryptoContext, info.context) + return handler(AsymmetricCipher.decode(value, ctx.stack[-1])) def encode_org_key( @@ -104,9 +102,8 @@ def encode_org_key( handler: SerializerFunctionWrapHandler, info: SerializationInfo, ) -> str: - context: dict = cast("dict", info.context) - keys: list[bytes] = cast("list[bytes]", context.get("cctx")) - return handler(AsymmetricCipher.encode(value, keys[-1])) + ctx = cast(CryptoContext, info.context) + return handler(AsymmetricCipher.encode(value, ctx.stack[-1])) SecretOrganizationKey = Annotated[ @@ -123,17 +120,15 @@ def encode_org_key( def decode_key( value: str, handler: ValidatorFunctionWrapHandler, info: ValidationInfo ) -> bytes: - context: dict = cast("dict", info.context) - keys: list[bytes] = cast("list[bytes]", context.get("cctx")) - return handler(SymmetricCipher.decode(value, keys[-2])) + ctx = cast(CryptoContext, info.context) + return handler(SymmetricCipher.decode(value, ctx.stack[-2])) def encode_key( value: Any, handler: SerializerFunctionWrapHandler, info: SerializationInfo ) -> bytes: - context: dict = cast("dict", info.context) - keys: list[bytes] = cast("list[bytes]", context.get("cctx")) - return handler(SymmetricCipher.encode(value, keys[-2])) + ctx = cast(CryptoContext, info.context) + return handler(SymmetricCipher.encode(value, ctx.stack[-2])) SecretKey = Annotated[ @@ -145,3 +140,18 @@ def encode_key( * the Key is added to cctx by ser_set_key / val_set_key of the model * en/decoding uses the [-2] key in cctx """ + +CryptoKey: TypeAlias = RSA.RsaKey | bytes + + +@dataclasses.dataclass(frozen=True) +class CryptoContext: + client: "BitwardenAPIClient" + parent_id: UUID | None = None + stack: list[CryptoKey] = dataclasses.field(default_factory=list) + + def push(self, v: CryptoKey) -> None: + return self.stack.append(v) + + def pop(self) -> CryptoKey: + return self.stack.pop() diff --git a/src/vaultwarden/models/sync.py b/src/vaultwarden/models/sync.py index 9451f89..ff3da70 100644 --- a/src/vaultwarden/models/sync.py +++ b/src/vaultwarden/models/sync.py @@ -1,6 +1,5 @@ import sys import time -import typing from typing import Any, cast from uuid import UUID @@ -16,6 +15,7 @@ from vaultwarden.models.bitwarden import Login, val_set_key from vaultwarden.models.crypto import ( + CryptoContext, SecretKey, SecretOrganizationKey, SecretRSA, @@ -29,10 +29,6 @@ from typing import Self -if typing.TYPE_CHECKING: - from vaultwarden.clients.bitwarden import BitwardenAPIClient - - class ConnectToken(PermissiveBaseModel): Kdf: KdfType = KdfType.Pbkdf2 KdfIterations: int = 0 @@ -68,25 +64,21 @@ def val_set_key( handler: ModelWrapValidatorHandler[Self], info: ValidationInfo, ) -> Self: - from vaultwarden.clients.bitwarden import BitwardenAPIClient from vaultwarden.models.bitwarden import Kdf + from vaultwarden.models.crypto import CryptoContext from vaultwarden.utils.crypto import make_master_key assert info and info.context - - client: BitwardenAPIClient = cast( - BitwardenAPIClient, info.context["client"] - ) - cctx: list[bytes] = cast("list[bytes]", info.context["cctx"]) + ctx = cast(CryptoContext, info.context) master_key = make_master_key( - password=client.password, - salt=client.email, + password=ctx.client.password, + salt=ctx.client.email, kdf=Kdf.model_validate(data), ) - cctx.append(master_key) + ctx.push(master_key) v = val_set_key(cls, data, handler, info) - cctx.pop() # master_key + ctx.pop() # master_key v._master_key = master_key return v @@ -146,11 +138,10 @@ def val_field_Organizations( # noqa: N802 handler: ModelWrapValidatorHandler[Self], info: ValidationInfo, ) -> Self: - assert info.context and isinstance(info.context, dict) - cctx: list[bytes] = cast(list["bytes"], info.context["cctx"]) - cctx.append(info.data["PrivateKey"]) + ctx: CryptoContext = cast(CryptoContext, info.context) + ctx.push(info.data["PrivateKey"]) r = handler(v) - cctx.pop() + ctx.pop() return r @model_validator(mode="wrap") @@ -187,15 +178,12 @@ def val_set_key( handler: ModelWrapValidatorHandler[Self], info: ValidationInfo, ) -> Self: - assert info.context and isinstance(info.context, dict) - cctx: list[bytes] - if (v := info.context.get("cctx")) is None: - cctx = info.context["cctx"] = [] - else: - cctx = cast(list[bytes], v) - client: "BitwardenAPIClient" = info.context.get("client") - assert client._connect_token and client._connect_token._master_key - cctx.append(client._connect_token._master_key) + ctx: CryptoContext = cast(CryptoContext, info.context) + + assert ( + ctx.client._connect_token and ctx.client._connect_token._master_key + ) + ctx.push(ctx.client._connect_token._master_key) r = handler(data) - cctx.pop() + ctx.pop() return r diff --git a/tests/models/validation/test_bitwarden_models.py b/tests/models/validation/test_bitwarden_models.py index 170fd99..2d3406a 100644 --- a/tests/models/validation/test_bitwarden_models.py +++ b/tests/models/validation/test_bitwarden_models.py @@ -1,4 +1,5 @@ import unittest +from uuid import UUID from pydantic import TypeAdapter from vaultwarden.models.bitwarden import ( @@ -7,6 +8,7 @@ OrganizationUserDetails, ResplistBitwarden, ) +from vaultwarden.models.crypto import CryptoContext class TestBitwardenModels(unittest.TestCase): @@ -30,7 +32,10 @@ def _test_organization_users(self): ResplistBitwarden[OrganizationUserDetails] .model_validate_json( payload, - context={"parent_id": "cda840d2-1de0-4f31-bd49-b30dacd7e8b0"}, + context=CryptoContext( + client=None, + parent_id=UUID("cda840d2-1de0-4f31-bd49-b30dacd7e8b0"), + ), ) .model_validate_json(payload) ) @@ -47,11 +52,17 @@ def test_organization_collections(self): ) collection1 = TypeAdapter(list[CollectionUser]).validate_json( payload1, - context={"parent_id": "9ed17918-31f6-4ac5-ac82-c11541cd8a7c"}, + context=CryptoContext( + client=None, + parent_id=UUID("9ed17918-31f6-4ac5-ac82-c11541cd8a7c"), + ), ) collection2 = TypeAdapter(list[CollectionUser]).validate_json( payload2, - context={"parent_id": "3c73f14f-5a01-4016-98bb-9605146a1a49"}, + context=CryptoContext( + client=None, + parent_id=UUID("3c73f14f-5a01-4016-98bb-9605146a1a49"), + ), ) assert len(collection1) == 0 From 1fa747a3416618639adcd730d6a5e2f2e11e5a01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Thu, 11 Jun 2026 17:41:29 +0200 Subject: [PATCH 17/35] rebase - fixes for no-mail authentication --- src/vaultwarden/clients/bitwarden.py | 18 +++++++----------- src/vaultwarden/models/bitwarden.py | 11 ++++++----- src/vaultwarden/models/sync.py | 2 ++ 3 files changed, 15 insertions(+), 16 deletions(-) diff --git a/src/vaultwarden/clients/bitwarden.py b/src/vaultwarden/clients/bitwarden.py index 59f5228..1830750 100644 --- a/src/vaultwarden/clients/bitwarden.py +++ b/src/vaultwarden/clients/bitwarden.py @@ -23,7 +23,7 @@ class BitwardenAPIClient: def __init__( self, url: str, - email: str, + email: str | None, password: str, client_id: str, client_secret: str, @@ -33,7 +33,7 @@ def __init__( # if one of the parameters is None, raise an exception if not all([url, password, client_id, client_secret, device_id]): raise BitwardenError("All parameters are required") - self.email = email + self.email: str | None = email self.password = password self.client_id = client_id self.client_secret = client_secret @@ -87,26 +87,22 @@ def _set_connect_token(self, refresh: dict | None = None): resp = self._http_client.post( "identity/connect/token", headers=headers, data=payload ) - self._connect_token = ConnectToken.model_validate_json( - resp.text, context=CryptoContext(client=self) - ) - if self.email is None: + access_token = resp.json()["access_token"] headers = { - "Authorization": f"Bearer {self._connect_token.access_token}", + "Authorization": f"Bearer {access_token}", "content-type": "application/json; charset=utf-8", "Accept": "*/*", } - resp = self._http_client.get( + mresp = self._http_client.get( "api/accounts/profile", headers=headers ) - self.email = resp.json()["email"] + self.email = mresp.json()["email"] self._connect_token = ConnectToken.model_validate_json( - resp.text, context={"client": self, "cctx": []} + resp.text, context=CryptoContext(client=self) ) - return # login to api diff --git a/src/vaultwarden/models/bitwarden.py b/src/vaultwarden/models/bitwarden.py index df32f33..1ef7a08 100644 --- a/src/vaultwarden/models/bitwarden.py +++ b/src/vaultwarden/models/bitwarden.py @@ -961,12 +961,13 @@ def key(self) -> bytes: def get_organization( bitwarden_client: "BitwardenAPIClient", organisation_id: UUID | str ) -> Organization: + oid = ( + UUID(organisation_id) + if isinstance(organisation_id, str) + else organisation_id + ) + if bitwarden_client._sync is not None: - oid = ( - UUID(organisation_id) - if isinstance(organisation_id, str) - else organisation_id - ) for org in bitwarden_client._sync.Profile.Organizations: if org.Id == oid: r = Organization.model_construct( diff --git a/src/vaultwarden/models/sync.py b/src/vaultwarden/models/sync.py index ff3da70..c03a028 100644 --- a/src/vaultwarden/models/sync.py +++ b/src/vaultwarden/models/sync.py @@ -69,7 +69,9 @@ def val_set_key( from vaultwarden.utils.crypto import make_master_key assert info and info.context + ctx = cast(CryptoContext, info.context) + assert ctx.client.email is not None master_key = make_master_key( password=ctx.client.password, From fe7f343d38b4cd071a230e27a0726654372068cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Thu, 11 Jun 2026 18:14:53 +0200 Subject: [PATCH 18/35] fix - Self on py3.10 --- src/vaultwarden/utils/crypto.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/vaultwarden/utils/crypto.py b/src/vaultwarden/utils/crypto.py index 10f0466..04b0fdc 100644 --- a/src/vaultwarden/utils/crypto.py +++ b/src/vaultwarden/utils/crypto.py @@ -8,6 +8,7 @@ import re import secrets import string +import sys from base64 import b64decode, b64encode from enum import IntEnum from hashlib import pbkdf2_hmac, sha256 @@ -20,9 +21,17 @@ from hkdf import hkdf_expand from typing_extensions import override +if sys.version_info < (3, 11): + from typing_extensions import Self +else: + from typing import Self + + if typing.TYPE_CHECKING: import vaultwarden.models.bitwarden + + class CIPHERS(IntEnum): null = 0 sym = 2 @@ -53,7 +62,7 @@ class AsymmetricCipher(_Cipher): TYPE = CIPHERS.asym ENCODING = "{typ}.{b64_ct}" @classmethod - def _parse(cls, ct:str) -> tuple[typing.Self, bytes]: + def _parse(cls, ct:str) -> tuple[Self, bytes]: return cls(), b64decode(ct) def _decrypt(self, ct:bytes, key: RSA.RsaKey) -> bytes: @@ -83,7 +92,7 @@ def __init__(self, iv:bytes, mac:bytes): self._mac = mac @classmethod - def _parse(cls, ct: str) -> tuple[typing.Self, bytes]: + def _parse(cls, ct: str) -> tuple[Self, bytes]: iv, ct, mac = ct.split("|", 3) return cls(b64decode(iv), b64decode(mac)[0:32]), b64decode(ct) @@ -153,7 +162,7 @@ def __init__(self, iv:bytes, mac:bytes): self._mac = mac @classmethod - def _parse(cls, cipher_bytes: bytes) -> tuple[typing.Self, bytes]: + def _parse(cls, cipher_bytes: bytes) -> tuple[Self, bytes]: iv = cipher_bytes[0:16] mac = cipher_bytes[16:48] ct = cipher_bytes[48:] From 60e973853095dd9c7c29eaeee13a0df58fea469d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Thu, 11 Jun 2026 18:19:53 +0200 Subject: [PATCH 19/35] tests - fix bitwarden ref --- tests/e2e/test_bitwarden.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/e2e/test_bitwarden.py b/tests/e2e/test_bitwarden.py index a63e5bb..c112189 100644 --- a/tests/e2e/test_bitwarden.py +++ b/tests/e2e/test_bitwarden.py @@ -165,7 +165,7 @@ def _test_create_user(self): rnd = "".join( random.choices(string.ascii_letters + string.digits, k=10) ).lower() - bitwarden.create_user( + self.bitwarden.create_user( f"test+{rnd}@examle.org", gen_password(), "test user", @@ -192,7 +192,7 @@ def _test_create_org_login(self): data=data, key=key, ) - bitwarden.create_item( + self.bitwarden.create_item( item, self.organization, collections=self.test_colls_ids ) @@ -216,7 +216,9 @@ def _test_create_own_login(self): data=data, key=key, ) - bitwarden.create_item(item, None, collections=self.test_colls_ids) + self.bitwarden.create_item( + item, None, collections=self.test_colls_ids + ) def _test_create_attachment(self): from pathlib import Path @@ -226,10 +228,10 @@ def _test_create_attachment(self): login: Login = next( filter(lambda x: x.attachments, self.test_org_ciphers) ) - login.attach(Path("/etc/modules")) + login.attach(Path(__file__)) - def _test_sync(self): - for v in bitwarden._sync.Ciphers: + def test_sync(self): + for v in self.bitwarden._sync.Ciphers: print(f"{v.Name} - {v.OrganizationId}") From ed4f86b57aee8f538e5fa4e760edc90b714c9c68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Sun, 14 Jun 2026 08:11:14 +0200 Subject: [PATCH 20/35] models - PascalCase some attributes --- src/vaultwarden/models/bitwarden.py | 102 ++++++++++++++-------------- 1 file changed, 51 insertions(+), 51 deletions(-) diff --git a/src/vaultwarden/models/bitwarden.py b/src/vaultwarden/models/bitwarden.py index 1ef7a08..418f85b 100644 --- a/src/vaultwarden/models/bitwarden.py +++ b/src/vaultwarden/models/bitwarden.py @@ -94,7 +94,7 @@ def ser_set_key( slf: Any, handler: SerializerFunctionWrapHandler, info: SerializationInfo ) -> Any: key: bytes | None - if (key := slf.key) is not None: + if (key := slf.Key) is not None: ctx: CryptoContext = cast(CryptoContext, info.context) ctx.push(key) @@ -157,15 +157,15 @@ class CipherLogin(BitwardenBaseModel): class Config: extra = "forbid" - name: SecretString | None = None + Name: SecretString | None = None autofillOnPageLoad: bool | None = None password: SecretString | None = None passwordRevisionDate: datetime.datetime | None = None totp: str | None = None - uri: SecretString | None = None - uris: list[UriMatch] | None = None + Uri: SecretString | None = None + Uris: list[UriMatch] | None = None username: SecretString | None = None - notes: SecretString | None = None + Notes: SecretString | None = None class PasswordChange(BitwardenBaseModel): @@ -200,8 +200,8 @@ class LoginData(CipherLogin): class Config: extra = "forbid" - fields: list[XField] | None = None - passwordHistory: list[PasswordChange] | None = None + Fields: list[XField] | None = None + PasswordHistory: list[PasswordChange] | None = None response: str | None = None fido2Credentials: list[Fido2Credential] | None = None @@ -232,7 +232,7 @@ class AttachmentRequest(BitwardenBaseModel): class Config: extra = "forbid" - key: SecretBytes + Key: SecretBytes fileName: SecretString fileSize: int adminRequest: bool | None = None @@ -242,10 +242,10 @@ class Attachment(BitwardenBaseModel): class Config: extra = "forbid" - key: SecretBytes + Key: SecretBytes fileName: SecretString | None = None id: str - object: str + Object: str size: int sizeName: str url: str @@ -264,26 +264,26 @@ class Config: Type: CipherType Name: SecretString CollectionIds: list[UUID] - key: SecretKey | None = None - - organizationUseTotp: bool | None = None - creationDate: datetime.datetime | None = None - deletedDate: datetime.datetime | None = None - fields: list[XField] | None = None - - notes: SecretString | None = None - reprompt: int - revisionDate: str - sshKey: str | None - passwordHistory: list[PasswordChange] - object: str | None = None - attachments: list[Attachment] | None = None - - edit: bool | None = None - favorite: bool | None = None - folderId: UUID | None = None - permissions: Any | None = None - viewPassword: bool | None = None + Key: SecretKey | None = None + + OrganizationUseTotp: bool | None = None + CreationDate: datetime.datetime | None = None + DeletedDate: datetime.datetime | None = None + Fields: list[XField] | None = None + + Notes: SecretString | None = None + Reprompt: int | None = None + RevisionDate: str | None = None + sshKey: str | None = None + PasswordHistory: list[PasswordChange] | None = None + Object: str | None = None + Attachments: list[Attachment] | None = None + + Edit: bool | None = None + Favorite: bool | None = None + FolderId: UUID | None = None + Permissions: Any | None = None + ViewPassword: bool | None = None @model_validator(mode="wrap") @classmethod @@ -377,7 +377,7 @@ def _attach(self, name: str, file: io.IOBase): key = token_bytes(64) ed = BinarySymmetricCipher.encode(file.read(), key) ar = AttachmentRequest.model_construct( - key=key, fileName=name, fileSize=len(ed), adminRequest=True + Key=key, fileName=name, fileSize=len(ed), adminRequest=True ) if self.OrganizationId: stack = [ @@ -409,45 +409,45 @@ def _attach(self, name: str, file: io.IOBase): class Login(_CipherBase): Type: Literal[CipherType.Login] = CipherType.Login - login: LoginData | None = None - secureNote: None = None - card: None = None - identity: None = None + Login: LoginData | None = None + SecureNote: None = None + Card: None = None + Identity: None = None - data: LoginData | None = None + Data: LoginData | None = None class SecureNote(_CipherBase): Type: Literal[CipherType.SecureNote] - login: None = None - secureNote: SecureNoteProperty | None = None - card: None = None - identity: None = None + Login: None = None + SecureNote: SecureNoteProperty | None = None + Card: None = None + Identity: None = None - data: SecureNoteData | None = None + Data: SecureNoteData | None = None class Card(_CipherBase): Type: Literal[CipherType.Card] - login: None = None - card: None = None - secureNote: None = None - identity: None = None + Login: None = None + Card: None = None + SecureNote: None = None + Identity: None = None - data: None = None + Data: None = None class Identity(_CipherBase): Type: Literal[CipherType.Identity] - login: None = None - secureNote: None = None - card: None = None - identity: None = None + Login: None = None + SecureNote: None = None + Card: None = None + Identity: None = None - data: None = None + Data: None = None CipherDetails = Annotated[ From 36deab5cba418f6a36f2c84f5b406844c5a66b2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Sun, 14 Jun 2026 08:33:36 +0200 Subject: [PATCH 21/35] tests - re-enable --- src/vaultwarden/clients/bitwarden.py | 41 ++++++++++--------- src/vaultwarden/models/bitwarden.py | 4 +- src/vaultwarden/models/crypto.py | 2 +- src/vaultwarden/models/sync.py | 24 +++++++++-- tests/e2e/__init__.py | 12 ++++++ tests/e2e/test_bitwarden.py | 13 ++---- tests/e2e/test_vaultwarden.py | 10 ++++- tests/models/validation/__init__.py | 41 +++++++++++++++++++ .../validation/test_bitwarden_models.py | 19 +++++---- .../validation/test_pascal_camel_cases.py | 31 +++++++++----- tests/models/validation/test_sync_models.py | 8 +++- .../validation/test_vaultwarden_models.py | 2 +- 12 files changed, 150 insertions(+), 57 deletions(-) diff --git a/src/vaultwarden/clients/bitwarden.py b/src/vaultwarden/clients/bitwarden.py index 1830750..46409f0 100644 --- a/src/vaultwarden/clients/bitwarden.py +++ b/src/vaultwarden/clients/bitwarden.py @@ -146,25 +146,28 @@ def _api_request( def sync(self, force_refresh: bool = False) -> SyncData: if self._sync is None or force_refresh: resp = self._api_request("GET", "api/sync") - data = resp.json() - v = { - "profile": data["profile"], - "ciphers": [], - "collections": [], - "folders": [], - "policies": [], - "sends": [], - "domains": {}, - } - # populate self._sync.Profile - self._sync = SyncData.model_validate( - v, context=CryptoContext(client=self) - ) - # uses self._sync.Profile - self._sync = SyncData.model_validate( - data, - context=CryptoContext(client=self), - ) + return self._sync_step(resp.json()) + return self._sync + + def _sync_step(self, data: dict) -> SyncData: + v: dict[str, typing.Any] = { + "profile": data.get("profile") or data.get("Profile"), + "ciphers": [], + "collections": [], + "folders": [], + "policies": [], + "sends": [], + "domains": {}, + } + # populate self._sync.Profile + self._sync = SyncData.model_validate( + v, context=CryptoContext(client=self) + ) + # uses self._sync.Profile + self._sync = SyncData.model_validate( + data, + context=CryptoContext(client=self), + ) return self._sync # def create_organization(self, name, email=None) -> "Organization": diff --git a/src/vaultwarden/models/bitwarden.py b/src/vaultwarden/models/bitwarden.py index 418f85b..97fdaff 100644 --- a/src/vaultwarden/models/bitwarden.py +++ b/src/vaultwarden/models/bitwarden.py @@ -299,7 +299,9 @@ def val_set_key( assert ctx.client._sync and ctx.client._sync.Profile - if (o := data.get("organizationId")) is not None: + if ( + o := data.get("organizationId") or data.get("OrganizationId") + ) is not None: oid = UUID(o) org: typing.Optional["ProfileOrganization"] = None for org in ctx.client._sync.Profile.Organizations: diff --git a/src/vaultwarden/models/crypto.py b/src/vaultwarden/models/crypto.py index 0a8d033..a4803b7 100644 --- a/src/vaultwarden/models/crypto.py +++ b/src/vaultwarden/models/crypto.py @@ -144,7 +144,7 @@ def encode_key( CryptoKey: TypeAlias = RSA.RsaKey | bytes -@dataclasses.dataclass(frozen=True) +@dataclasses.dataclass class CryptoContext: client: "BitwardenAPIClient" parent_id: UUID | None = None diff --git a/src/vaultwarden/models/sync.py b/src/vaultwarden/models/sync.py index c03a028..551782a 100644 --- a/src/vaultwarden/models/sync.py +++ b/src/vaultwarden/models/sync.py @@ -108,7 +108,7 @@ class ProfileOrganization(PermissiveBaseModel): UseTotp: bool -class UserProfile(PermissiveBaseModel): +class _UserProfile(PermissiveBaseModel): AvatarColor: str | None Culture: str Email: str @@ -132,6 +132,8 @@ class UserProfile(PermissiveBaseModel): validation_alias=AliasChoices("_status", "_Status"), ) + +class UserProfile(_UserProfile): @field_validator("Organizations", mode="wrap") @classmethod def val_field_Organizations( # noqa: N802 @@ -141,9 +143,13 @@ def val_field_Organizations( # noqa: N802 info: ValidationInfo, ) -> Self: ctx: CryptoContext = cast(CryptoContext, info.context) - ctx.push(info.data["PrivateKey"]) + if ( + key := info.data.get("PrivateKey") or info.data.get("privateKey") + ) is not None: + ctx.push(key) r = handler(v) - ctx.pop() + if key: + ctx.pop() return r @model_validator(mode="wrap") @@ -157,11 +163,21 @@ def val_set_key( return val_set_key(cls, data, handler, info) -class VaultwardenUser(UserProfile): +class VaultwardenOrganization(ProfileOrganization): + # overwrite + Key: str # type: ignore + + +class VaultwardenUser(_UserProfile): UserEnabled: bool CreatedAt: str LastActive: str | None = None + # overwrite + Key: str # type: ignore + PrivateKey: str | None # type: ignore + Organizations: list[VaultwardenOrganization] # type: ignore + class SyncData(PermissiveBaseModel): Profile: UserProfile diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py index e69de29..6aed553 100644 --- a/tests/e2e/__init__.py +++ b/tests/e2e/__init__.py @@ -0,0 +1,12 @@ +def env_from_ci(): + import os + from pathlib import Path + + import yaml + + if os.environ.get("BITWARDEN_URL", None) is not None: + return + + obj = yaml.safe_load(Path(".github/workflows/ci.yml").read_text()) + for k, v in obj["jobs"]["test"]["steps"][-1]["env"].items(): + os.environ[k] = v diff --git a/tests/e2e/test_bitwarden.py b/tests/e2e/test_bitwarden.py index c112189..6151be9 100644 --- a/tests/e2e/test_bitwarden.py +++ b/tests/e2e/test_bitwarden.py @@ -8,14 +8,9 @@ get_organization, ) -if os.environ.get("BITWARDEN_URL", None) is None: - from pathlib import Path +from . import env_from_ci - import yaml - - obj = yaml.safe_load(Path(".github/workflows/ci.yml").read_text()) - for k, v in obj["jobs"]["test"]["steps"][-1]["env"].items(): - os.environ[k] = v +env_from_ci() # Get Bitwarden credentials from environment variables url = os.environ.get("BITWARDEN_URL", None) @@ -49,6 +44,8 @@ class BitwardenBaseTests: + bitwarden: BitwardenAPIClient + def setup_base(self): self.organization = get_organization(self.bitwarden, test_organization) self.test_colls_names = self.organization.collections(as_dict=True) @@ -221,8 +218,6 @@ def _test_create_own_login(self): ) def _test_create_attachment(self): - from pathlib import Path - from vaultwarden.models.bitwarden import Login login: Login = next( diff --git a/tests/e2e/test_vaultwarden.py b/tests/e2e/test_vaultwarden.py index ed6981d..c015bb2 100644 --- a/tests/e2e/test_vaultwarden.py +++ b/tests/e2e/test_vaultwarden.py @@ -4,14 +4,20 @@ from vaultwarden.clients.vaultwarden import VaultwardenAdminClient +from . import env_from_ci + +env_from_ci() + # Get Vaultwarden Admin credentials from environment variables url = os.environ.get("VAULTWARDEN_URL", None) admin_token = os.environ.get("VAULTWARDEN_ADMIN_TOKEN", None) -# TODO Add tests for VaultwardenAdminClient class VaultwardenAdminClientBasic(unittest.TestCase): def setUp(self) -> None: self.vaultwarden = VaultwardenAdminClient( - url=url, admin_secret_token=admin_token + url=url, admin_secret_token=admin_token, preload_users=False ) + + def test_users(self): + self.vaultwarden.users() diff --git a/tests/models/validation/__init__.py b/tests/models/validation/__init__.py index e69de29..251f548 100644 --- a/tests/models/validation/__init__.py +++ b/tests/models/validation/__init__.py @@ -0,0 +1,41 @@ +from typing import Any + +from vaultwarden.models.crypto import CryptoContext + + +def default_ctx(account: str = "test-account") -> Any: + import json + from pathlib import Path + + from vaultwarden.clients.bitwarden import BitwardenAPIClient + from vaultwarden.models.sync import ConnectToken + + client = BitwardenAPIClient( + url=".", + email=f"{account}@example.com", + password=account, + client_id=".", + device_id=".", + client_secret=".", + ) + ctx = CryptoContext(client) + + payload = json.loads( + Path(f"tests/fixtures/{account}/sync_camel.json").read_text() + ) + + ct = { + "Kdf": 0, + "KdfIterations": 600000, + "Key": payload["profile"]["key"], + "PrivateKey": payload["profile"]["privateKey"], + "access_token": "", + "expires_in": 3600, + "token_type": "", + "scope": "", + } + + client._connect_token = ConnectToken.model_validate(ct, context=ctx) + + client._sync_step(payload) + return ctx diff --git a/tests/models/validation/test_bitwarden_models.py b/tests/models/validation/test_bitwarden_models.py index 2d3406a..6239213 100644 --- a/tests/models/validation/test_bitwarden_models.py +++ b/tests/models/validation/test_bitwarden_models.py @@ -10,6 +10,8 @@ ) from vaultwarden.models.crypto import CryptoContext +from . import default_ctx + class TestBitwardenModels(unittest.TestCase): @staticmethod @@ -17,27 +19,28 @@ def read_json_payload(file_path): with open(file_path, "r") as file: return file.read() - def _test_organization(self): + def test_organization(self): payload = self.read_json_payload( "tests/fixtures/test-organization/organization_camel.json" ) - data = Organization.model_validate_json(payload) + data = Organization.model_validate_json( + payload, context=CryptoContext(client=None) + ) assert data.Name == "Test Organization" - def _test_organization_users(self): + def test_organization_users(self): payload = self.read_json_payload( "tests/fixtures/test-organization/users_camel.json" ) + ctx = default_ctx() + ctx.parent_id = UUID("cda840d2-1de0-4f31-bd49-b30dacd7e8b0") users = ( ResplistBitwarden[OrganizationUserDetails] .model_validate_json( payload, - context=CryptoContext( - client=None, - parent_id=UUID("cda840d2-1de0-4f31-bd49-b30dacd7e8b0"), - ), + context=ctx, ) - .model_validate_json(payload) + .model_validate_json(payload, context=ctx) ) assert len(users.Data) == 2 assert users.Data[0].Email == "test-account@example.com" diff --git a/tests/models/validation/test_pascal_camel_cases.py b/tests/models/validation/test_pascal_camel_cases.py index 279ba55..c7a8620 100644 --- a/tests/models/validation/test_pascal_camel_cases.py +++ b/tests/models/validation/test_pascal_camel_cases.py @@ -8,6 +8,8 @@ ) from vaultwarden.models.sync import SyncData, VaultwardenUser +from . import default_ctx + class TestModelCases(unittest.TestCase): @staticmethod @@ -15,24 +17,32 @@ def read_json_payload(file_path): with open(file_path, "r") as file: return file.read() - def _test_organization(self): + def test_organization(self): pascal_case_payload = self.read_json_payload( "tests/fixtures/test-organization/organization_pascal.json" ) camel_case_payload = self.read_json_payload( "tests/fixtures/test-organization/organization_camel.json" ) - pascal = Organization.model_validate_json(pascal_case_payload) - camel = Organization.model_validate_json(camel_case_payload) + ctx = default_ctx() + pascal = Organization.model_validate_json( + pascal_case_payload, context=ctx + ) + camel = Organization.model_validate_json( + camel_case_payload, context=ctx + ) self.assertEqual(pascal.Name, camel.Name) - def _test_collections(self): + def test_collections(self): + ctx = default_ctx() + ctx.push(ctx.client._sync.Profile.Organizations[0].Key) + pascal_case_payload = self.read_json_payload( "tests/fixtures/test-organization/collections/collections_pascal.json" ) pascal_collections = ( ResplistBitwarden[OrganizationCollection] - .model_validate_json(pascal_case_payload) + .model_validate_json(pascal_case_payload, context=ctx) .Data ) camel_case_payload = self.read_json_payload( @@ -40,22 +50,23 @@ def _test_collections(self): ) camel_collections = ( ResplistBitwarden[OrganizationCollection] - .model_validate_json(camel_case_payload) + .model_validate_json(camel_case_payload, context=ctx) .Data ) self.assertEqual(len(pascal_collections), len(camel_collections)) self.assertEqual(pascal_collections[0].Name, camel_collections[0].Name) self.assertEqual(pascal_collections[1].Name, camel_collections[1].Name) - def _test_sync_data(self): + def test_sync_data(self): + ctx = default_ctx() pascal_case_payload = self.read_json_payload( "tests/fixtures/test-account/sync_pascal.json" ) camel_case_payload = self.read_json_payload( "tests/fixtures/test-account/sync_camel.json" ) - pascal = SyncData.model_validate_json(pascal_case_payload) - camel = SyncData.model_validate_json(camel_case_payload) + pascal = SyncData.model_validate_json(pascal_case_payload, context=ctx) + camel = SyncData.model_validate_json(camel_case_payload, context=ctx) self.assertEqual(len(pascal.Ciphers), len(camel.Ciphers)) self.assertEqual(len(pascal.Collections), len(camel.Collections)) self.assertEqual( @@ -65,7 +76,7 @@ def _test_sync_data(self): pascal.Collections[1].get("Name"), camel.Collections[1].get("name") ) - def _test_admin_users(self): + def test_admin_users(self): pascal_case_payload = self.read_json_payload( "tests/fixtures/admin/users_pascal.json" ) diff --git a/tests/models/validation/test_sync_models.py b/tests/models/validation/test_sync_models.py index bb38d0d..063c849 100644 --- a/tests/models/validation/test_sync_models.py +++ b/tests/models/validation/test_sync_models.py @@ -2,6 +2,8 @@ from vaultwarden.models.sync import SyncData +from . import default_ctx + class TestSyncModels(unittest.TestCase): @staticmethod @@ -9,11 +11,13 @@ def read_json_payload(file_path): with open(file_path, "r") as file: return file.read() - def _test_syncdata(self): + def test_syncdata(self): payload = self.read_json_payload( "tests/fixtures/test-account/sync_camel.json" ) - data = SyncData.model_validate_json(payload) + ctx = default_ctx() + + data = SyncData.model_validate_json(payload, context=ctx) assert len(data.Ciphers) == 2 assert len(data.Collections) == 3 assert len(data.Profile.Organizations) == 1 diff --git a/tests/models/validation/test_vaultwarden_models.py b/tests/models/validation/test_vaultwarden_models.py index 97eed77..4cb475c 100644 --- a/tests/models/validation/test_vaultwarden_models.py +++ b/tests/models/validation/test_vaultwarden_models.py @@ -10,7 +10,7 @@ def read_json_payload(file_path): with open(file_path, "r") as file: return file.read() - def _test_users(self): + def test_users(self): payload = self.read_json_payload( "tests/fixtures/admin/users_camel.json" ) From eb03377001593dcfdceff03c23fa3ae34bf576c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Sun, 14 Jun 2026 10:44:06 +0200 Subject: [PATCH 22/35] ciphers - implement matching --- src/vaultwarden/clients/bitwarden.py | 5 ++ src/vaultwarden/models/bitwarden.py | 77 +++++++++++++++++++++++----- tests/e2e/__init__.py | 4 +- 3 files changed, 70 insertions(+), 16 deletions(-) diff --git a/src/vaultwarden/clients/bitwarden.py b/src/vaultwarden/clients/bitwarden.py index 46409f0..8929702 100644 --- a/src/vaultwarden/clients/bitwarden.py +++ b/src/vaultwarden/clients/bitwarden.py @@ -201,6 +201,11 @@ def create_user( resp = self._api_request("POST", "api/accounts/register", json=data) return resp.json() + def search_item(self, name): + for i in self._sync.Ciphers: + if i.uri_match(name): + yield i + def create_item( self, item: "CipherDetails", diff --git a/src/vaultwarden/models/bitwarden.py b/src/vaultwarden/models/bitwarden.py index 97fdaff..a3de716 100644 --- a/src/vaultwarden/models/bitwarden.py +++ b/src/vaultwarden/models/bitwarden.py @@ -1,5 +1,6 @@ import base64 import datetime +from enum import IntEnum from functools import cached_property import hashlib import io @@ -132,15 +133,52 @@ def api_client(self) -> "BitwardenAPIClient": return self._bitwarden_client +class UriMatchDetection(IntEnum): + BASEDOMAIN = 0 + HOST = 1 + STARTSWITH = 2 + EXACT = 3 + RE = 4 + NEVER = 5 + + class UriMatch(BitwardenBaseModel): class Config: extra = "forbid" - match: int | None = None + match: UriMatchDetection | None = None uri: SecretString | None = None uriChecksum: SecretString | None = None response: str | None = None + def uri_match(self, name: str) -> bool: + import re + import urllib.parse + + if self.uri is None: + return False + m = self.match if self.match is not None else UriMatchDetection.HOST + match m: + case UriMatchDetection.BASEDOMAIN: + url = urllib.parse.urlparse(name) + if url.hostname is None: + return False + basename = ".".join(url.hostname.split(".")[1:]) + hostname = urllib.parse.urlparse(self.uri).hostname + return hostname == basename + case UriMatchDetection.HOST: + url = urllib.parse.urlparse(self.uri) + hostname = urllib.parse.urlparse(name).hostname + return hostname == url.hostname + case UriMatchDetection.STARTSWITH: + return name.startswith(self.uri) + case UriMatchDetection.EXACT: + return name == self.uri + case UriMatchDetection.RE: + return re.match(self.uri, name) is not None + case UriMatchDetection.NEVER: + return False + class XField(BitwardenBaseModel): class Config: @@ -153,6 +191,14 @@ class Config: linkedId: str | None = None +class PasswordChange(BitwardenBaseModel): + class Config: + extra = "forbid" + + lastUsedDate: datetime.datetime + password: SecretString + + class CipherLogin(BitwardenBaseModel): class Config: extra = "forbid" @@ -167,13 +213,18 @@ class Config: username: SecretString | None = None Notes: SecretString | None = None + Fields: list[XField] | None = None + PasswordHistory: list[PasswordChange] | None = None -class PasswordChange(BitwardenBaseModel): - class Config: - extra = "forbid" + def uri_match(self, name: str) -> bool: + if self.Uri and self.Uri == name: + return True - lastUsedDate: datetime.datetime - password: str + if self.Uris: + for um in self.Uris: + if um.uri_match(name): + return True + return False class Fido2Credential(BitwardenBaseModel): @@ -200,8 +251,6 @@ 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 @@ -210,8 +259,6 @@ class SecureNoteData(CipherLogin): class Config: extra = "forbid" - fields: list[XField] - passwordHistory: list[PasswordChange] response: str | None = None type: int | None = None @@ -220,10 +267,6 @@ class SecureNoteProperty(BitwardenBaseModel): class Config: extra = "forbid" - name: SecretString | None = None - notes: SecretString | None = None - fields: list[XField] | None = None - passwordHistory: list[PasswordChange] | None = None response: SecretString | None = None type: int @@ -285,6 +328,8 @@ class Config: Permissions: Any | None = None ViewPassword: bool | None = None + Data: CipherLogin | None = None + @model_validator(mode="wrap") @classmethod def val_set_key( @@ -407,6 +452,10 @@ def _attach(self, name: str, file: io.IOBase): }, ) + def uri_match(self, name: str) -> bool: + assert self.Data + return self.Data.uri_match(name) + class Login(_CipherBase): Type: Literal[CipherType.Login] = CipherType.Login diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py index 6aed553..6cd987f 100644 --- a/tests/e2e/__init__.py +++ b/tests/e2e/__init__.py @@ -2,11 +2,11 @@ def env_from_ci(): import os from pathlib import Path - import yaml - if os.environ.get("BITWARDEN_URL", None) is not None: return + import yaml + obj = yaml.safe_load(Path(".github/workflows/ci.yml").read_text()) for k, v in obj["jobs"]["test"]["steps"][-1]["env"].items(): os.environ[k] = v From 89bf1344f101ba4b259c6d93c72b5ec742a8984e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Tue, 16 Jun 2026 17:34:31 +0200 Subject: [PATCH 23/35] tests - writing user & items --- README.md | 44 ++++ src/vaultwarden/clients/bitwarden.py | 115 ++++++++- src/vaultwarden/clients/vaultwarden.py | 15 ++ src/vaultwarden/models/bitwarden.py | 320 ++++++++++++++++++------- src/vaultwarden/models/crypto.py | 32 ++- src/vaultwarden/models/enum.py | 1 + src/vaultwarden/models/sync.py | 20 +- src/vaultwarden/utils/crypto.py | 7 +- tests/e2e/test_write.py | 252 +++++++++++++++++++ 9 files changed, 690 insertions(+), 116 deletions(-) create mode 100644 tests/e2e/test_write.py diff --git a/README.md b/README.md index 22f648a..cf61df8 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,49 @@ client.set_user_enabled(user.Id, enabled=True) ### Bitwarden client +#### Login/… creation & lookup +```python +import urllib.parse +import secrets +from vaultwarden.models.bitwarden import Login, LoginData, UriMatch, UriMatchDetection +from vaultwarden.clients.bitwarden import BitwardenAPIClient + +bitwarden_client = BitwardenAPIClient(url="http://127.0.0.1", + email="test-account@example.com", + password="test-account", + client_id="user.a8be340c-856b-481f-8183-2b7712995da2", + client_secret="ag66paVUq4h7tBLbCbJOY5tJkQvUuT", + device_id="e54ba5f5-7d58-4830-8f2b-99194c70c14f") +bitwarden_client.sync() + +# create +uri = urllib.parse.urlparse(url:="http://username:password@login.example.org") +key = secrets.token_bytes(64) + +data = LoginData.model_construct( + name=uri.hostname, + password=uri.username, + username=uri.password, + uris = [UriMatch.model_construct(match = UriMatchDetection.HOST, uri=url)] +) +item = Login.model_construct( + name=f"{uri.username}@{uri.hostname}", + login=data, + data=data, + key=key, +) + +bitwarden_client.create_item(item, None, None) + +# refresh cache +bitwarden_client.sync(force_refresh=True) + +# lookup +print(list(bitwarden_client.search_items(name="login.example."))) +print(list(bitwarden_client.search_items(uri="http://login.example.org"))) +``` + +#### User / Org / Collection Management ```python from vaultwarden.clients.bitwarden import BitwardenAPIClient from vaultwarden.models.bitwarden import Organization, OrganizationCollection, get_organization @@ -89,6 +132,7 @@ if my_user: ``` + ## Compatibility This library is compatible with vaultwarden 1.32.0 and above. diff --git a/src/vaultwarden/clients/bitwarden.py b/src/vaultwarden/clients/bitwarden.py index 8929702..ddbb0f0 100644 --- a/src/vaultwarden/clients/bitwarden.py +++ b/src/vaultwarden/clients/bitwarden.py @@ -4,18 +4,23 @@ from httpx import Client, Response -from vaultwarden.models.bitwarden import CipherDetail, RegisterData +from vaultwarden.models.bitwarden import ( + CipherDetail, + CipherDetails, + Organization, + OrganizationCollection, + OrgData, + RegisterData, +) from vaultwarden.models.crypto import CryptoContext from vaultwarden.models.exception_models import BitwardenError from vaultwarden.models.sync import ConnectToken, SyncData +from vaultwarden.utils.crypto import masterPasswordHash from vaultwarden.utils.logger import log_raise_for_status if typing.TYPE_CHECKING: from vaultwarden.models.bitwarden import ( - CipherDetails, Kdf, - Organization, - OrganizationCollection, ) @@ -56,6 +61,12 @@ def connect_token(self) -> ConnectToken | None: def connect_token(self, value: ConnectToken): self._connect_token = value + @property + def masterPasswordHash(self): # noqa: N802 + return masterPasswordHash( + self._connect_token._master_key, self.password + ) + # refresh connect token if expired def _refresh_connect_token(self): if ( @@ -170,8 +181,34 @@ def _sync_step(self, data: dict) -> SyncData: ) return self._sync - # def create_organization(self, name, email=None) -> "Organization": - # pass + def create_organization( + self, + name: str, + email: str, + default_collection_name: str = "DefaultCollection", + ) -> Organization: + if not self.connect_token: + raise BitwardenError("Not connected") + assert self._connect_token + + from secrets import token_bytes + + req = OrgData.model_construct( + Name=name, + BillingEmail=email, + CollectionName=default_collection_name, + PlanType=0, + Key=token_bytes(64), + ) + ctx = CryptoContext(client=self) + ctx.push(self._connect_token.PrivateKey) + data = req.model_dump( + by_alias=True, exclude_none=True, exclude_unset=True, context=ctx + ) + v = self.api_request("POST", "api/organizations", json=data) + return Organization.model_validate( + v.json(), context=CryptoContext(client=self) + ) # def get_organization(self, name) -> "Organization": # pass @@ -199,11 +236,71 @@ def create_user( context=CryptoContext(client=self), ) resp = self._api_request("POST", "api/accounts/register", json=data) - return resp.json() + # user = self._api_request("GET", f"api/users/{email}") + return resp + + def search_items( + self, + uri: str | None = None, + name: str | None = None, + organisations: list[Organization] | None = None, + collections: list[OrganizationCollection] | None = None, + types: list[type[CipherDetails]] | None = None, + ) -> typing.Generator["CipherDetails", None, None]: + selectors: list[typing.Callable[["CipherDetails"], bool]] = list() + + if uri is not None: + + def by_uri(item: CipherDetails) -> bool: + return item.uri_match(uri) + + selectors.append(by_uri) + + if name is not None: + + def by_name(item: CipherDetails) -> bool: + return name in item.Name + + selectors.append(by_name) + + if organisations is not None: + + def by_organisation(item: CipherDetails) -> bool: + return item.OrganizationId in [o.Id for o in organisations] + + selectors.append(by_organisation) + + if collections is not None: + + def by_collection(item: CipherDetails) -> bool: + return ( + len( + set(item.CollectionIds) + & set([o.Id for o in collections]) + ) + > 0 + ) + + selectors.append(by_collection) + + if types is not None: + + def by_type(item: CipherDetails) -> bool: + return isinstance(item, tuple(types)) + + selectors.append(by_type) + + def select_func(item: CipherDetails) -> bool: + return all([selector(item) for selector in selectors]) + + return self.select_items(select_func) - def search_item(self, name): + def select_items( + self, select_func: typing.Callable[["CipherDetails"], bool] + ) -> typing.Generator["CipherDetails", None, None]: + assert self._sync for i in self._sync.Ciphers: - if i.uri_match(name): + if select_func(i): yield i def create_item( diff --git a/src/vaultwarden/clients/vaultwarden.py b/src/vaultwarden/clients/vaultwarden.py index c5d580c..192c3f3 100644 --- a/src/vaultwarden/clients/vaultwarden.py +++ b/src/vaultwarden/clients/vaultwarden.py @@ -298,3 +298,18 @@ def transfer_account_rights( permissions=user_details.Permissions, ) self.set_user_enabled(str(user.Id), enabled=False) + + # org management + def delete_organization(self, identifier: str | UUID) -> bool: + logger.info(f"Deleting {identifier} organization") + try: + self._admin_request( + "POST", + f"organizations/{identifier}/delete", + headers={"Content-Type": "application/json"}, + ) + except HTTPStatusError as e: + logger.warning(f"Failed to delete {identifier} {e}") + return False + logger.info(f"Successfully deleted org: {identifier}") + return True diff --git a/src/vaultwarden/models/bitwarden.py b/src/vaultwarden/models/bitwarden.py index a3de716..75cdc3c 100644 --- a/src/vaultwarden/models/bitwarden.py +++ b/src/vaultwarden/models/bitwarden.py @@ -2,7 +2,6 @@ import datetime from enum import IntEnum from functools import cached_property -import hashlib import io from pathlib import Path from secrets import token_bytes @@ -41,17 +40,21 @@ from vaultwarden.models.crypto import ( CryptoContext, + RSAPublicKey, SecretBytes, SecretKey, + SecretOrganizationKey, SecretString, ) 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 ( + AsymmetricCipher, BinarySymmetricCipher, SymmetricCipher, make_master_key, + masterPasswordHash, stretch_key, ) @@ -79,8 +82,13 @@ def val_set_key( key: str ctx: CryptoContext = cast(CryptoContext, info.context) if (key := (data.get("key") or data.get("Key"))) is not None: - assert isinstance(ctx.stack[-1], bytes) - v = SymmetricCipher.decode(key, ctx.stack[-1]) + match int(key[0]): + case SymmetricCipher.TYPE: + assert isinstance(ctx.stack[-1], bytes) + v = SymmetricCipher.decode(key, ctx.stack[-1]) + case AsymmetricCipher.TYPE: + assert isinstance(ctx.stack[-1], RSA.RsaKey) + v = AsymmetricCipher.decode(key, ctx.stack[-1]) ctx.push(v) r = handler(data) @@ -199,34 +207,6 @@ class Config: password: SecretString -class CipherLogin(BitwardenBaseModel): - class Config: - extra = "forbid" - - Name: SecretString | None = None - autofillOnPageLoad: bool | None = None - password: SecretString | None = None - passwordRevisionDate: datetime.datetime | None = None - totp: str | None = None - Uri: SecretString | None = None - Uris: list[UriMatch] | None = None - username: SecretString | None = None - Notes: SecretString | None = None - - Fields: list[XField] | None = None - PasswordHistory: list[PasswordChange] | None = None - - def uri_match(self, name: str) -> bool: - if self.Uri and self.Uri == name: - return True - - if self.Uris: - for um in self.Uris: - if um.uri_match(name): - return True - return False - - class Fido2Credential(BitwardenBaseModel): class Config: extra = "forbid" @@ -247,30 +227,6 @@ class Config: userName: SecretString | None = None -class LoginData(CipherLogin): - class Config: - extra = "forbid" - - response: str | None = None - fido2Credentials: list[Fido2Credential] | None = None - - -class SecureNoteData(CipherLogin): - class Config: - extra = "forbid" - - response: str | None = None - type: int | None = None - - -class SecureNoteProperty(BitwardenBaseModel): - class Config: - extra = "forbid" - - response: SecretString | None = None - type: int - - class AttachmentRequest(BitwardenBaseModel): class Config: extra = "forbid" @@ -316,9 +272,9 @@ class Config: Notes: SecretString | None = None Reprompt: int | None = None + ArchivedDate: str | None = None RevisionDate: str | None = None sshKey: str | None = None - PasswordHistory: list[PasswordChange] | None = None Object: str | None = None Attachments: list[Attachment] | None = None @@ -326,9 +282,15 @@ class Config: Favorite: bool | None = None FolderId: UUID | None = None Permissions: Any | None = None + PasswordHistory: list[PasswordChange] | None = None ViewPassword: bool | None = None - Data: CipherLogin | None = None + Login: None = None + SecureNote: None = None + Card: None = None + Identity: None = None + + Data: Any | None = None @model_validator(mode="wrap") @classmethod @@ -453,56 +415,118 @@ def _attach(self, name: str, file: io.IOBase): ) def uri_match(self, name: str) -> bool: - assert self.Data - return self.Data.uri_match(name) + return False + + +class LoginData(BitwardenBaseModel): + username: SecretString | None = None + password: SecretString | None = None + passwordRevisionDate: datetime.datetime | None = None + Uri: SecretString | None = None + Uris: list[UriMatch] | None = None + PasswordHistory: list[PasswordChange] | None = None + response: str | None = None + fido2Credentials: list[Fido2Credential] | None = None + + autofillOnPageLoad: bool | None = None + totp: SecretString | None = None + + def uri_match(self, name: str) -> bool: + if self.Uri and self.Uri == name: + return True + + if self.Uris: + for um in self.Uris: + if um.uri_match(name): + return True + return False class Login(_CipherBase): Type: Literal[CipherType.Login] = CipherType.Login - Login: LoginData | None = None - SecureNote: None = None - Card: None = None - Identity: None = None + Login: LoginData | None = None # type: ignore - Data: LoginData | None = None + def uri_match(self, name: str) -> bool: + if self.Login: + return self.Login.uri_match(name) + return False + + +class SecureNoteData(BitwardenBaseModel): + Fields: list[XField] | None = None + + Notes: SecretString | None = None + + response: str | None = None + type: int | None = None class SecureNote(_CipherBase): - Type: Literal[CipherType.SecureNote] + Type: Literal[CipherType.SecureNote] = CipherType.SecureNote + SecureNote: SecureNoteData | None = None # type: ignore - Login: None = None - SecureNote: SecureNoteProperty | None = None - Card: None = None - Identity: None = None - Data: SecureNoteData | None = None +class CardData(BitwardenBaseModel): + Fields: list[XField] | None = None + + cardholderName: SecretString | None = None + brand: SecretString | None = None + code: SecretString | None = None + expMonth: SecretString | None = None + expYear: SecretString | None = None + number: SecretString | None = None class Card(_CipherBase): - Type: Literal[CipherType.Card] + Type: Literal[CipherType.Card] = CipherType.Card + Card: CardData | None = None # type: ignore - Login: None = None - Card: None = None - SecureNote: None = None - Identity: None = None - Data: None = None +class IdentityData(BitwardenBaseModel): + Fields: list[XField] | None = None + + title: SecretString | None = None + firstName: SecretString | None = None + middleName: SecretString | None = None + lastName: SecretString | None = None + username: SecretString | None = None + company: SecretString | None = None + + ssn: SecretString | None = None + passportNumber: SecretString | None = None + licenseNumber: SecretString | None = None + + email: SecretString | None = None + phone: SecretString | None = None + address1: SecretString | None = None + address2: SecretString | None = None + address3: SecretString | None = None + city: SecretString | None = None + state: SecretString | None = None + postalCode: SecretString | None = None + country: SecretString | None = None class Identity(_CipherBase): - Type: Literal[CipherType.Identity] + Type: Literal[CipherType.Identity] = CipherType.Identity + Identity: IdentityData = None # type: ignore - Login: None = None - SecureNote: None = None - Card: None = None - Identity: None = None - Data: None = None +class SSHKeyData(BitwardenBaseModel): + keyFingerprint: SecretString | None = None + privateKey: SecretString | None = None + publicKey: SecretString | None = None + + +class SSHKey(_CipherBase): + Type: Literal[CipherType.SSHKey] = CipherType.SSHKey + sshKey: SSHKeyData = None # type: ignore CipherDetails = Annotated[ - Union[Login, SecureNote, Card, Identity], Field(discriminator="Type") + Union[Login, SecureNote, Card, Identity, SSHKey], + Field(discriminator="Type"), ] CipherDetail: TypeAdapter[CipherDetails] = TypeAdapter(CipherDetails) @@ -615,6 +639,38 @@ def delete(self): ) +class UserPublicKey(BitwardenBaseModel): + """ + c.f. https://github.com/dani-garcia/vaultwarden/blob/d6a3d539ed13352085ca7dfa63c49017d86c419b/src/api/core/accounts.rs#L471 + + """ + + userId: UUID + publicKey: RSAPublicKey + object: Literal["userKey"] + + +class ConfirmData(BitwardenBaseModel): + Id: UUID | None = None + Key: SecretOrganizationKey | None + + @model_validator(mode="wrap") + @classmethod + def val_set_key( + cls, + data: Any, + handler: ModelWrapValidatorHandler[Self], + info: ValidationInfo, + ) -> Self: + return val_set_key(cls, data, handler, info) + + @model_serializer(mode="wrap") + def ser_set_key( + self, handler: SerializerFunctionWrapHandler, info: SerializationInfo + ) -> Any: + return ser_set_key(self, handler, info) + + class OrganizationUserDetails(BitwardenBaseModel): Id: UUID | None = None Email: str @@ -746,6 +802,18 @@ def update_collection(self, collections: list[UUID]): ), ) + def publicKey(self) -> RSA.RsaKey: # noqa: N802 + """ + c.f. https://github.com/dani-garcia/vaultwarden/blob/d6a3d539ed13352085ca7dfa63c49017d86c419b/src/api/core/accounts.rs#L471 + :return: + """ + resp = self.api_client.api_request( + "GET", f"api/users/{self.UserId}/public-key" + ) + return UserPublicKey.model_validate_json( + resp.text, context=CryptoContext(client=self.api_client) + ).publicKey + def delete(self): return self.api_client.api_request( "DELETE", @@ -848,6 +916,27 @@ def invite( self._users = self._get_users() return resp + def confirm(self, user: OrganizationUserDetails): + """ + c.f. https://github.com/dani-garcia/vaultwarden/blob/d6a3d539ed13352085ca7dfa63c49017d86c419b/src/api/core/organizations.rs#L1382 + :param new_user: + :return: + """ + + publicKey = user.publicKey() # noqa: N806 + + confirm = ConfirmData.model_construct(Key=self.key()) + payload = confirm.model_dump( + by_alias=True, + context=CryptoContext(client=self.api_client, stack=[publicKey]), + ) + resp = self.api_client.api_request( + "POST", + f"api/organizations/{self.Id}/users/{user.Id}/confirm", + json=payload, + ) + return resp + def _get_users(self) -> list[OrganizationUserDetails]: resp = self.api_client.api_request( "GET", @@ -999,14 +1088,23 @@ def ciphers( return self._ciphers def key(self) -> bytes: - sync = self.api_client.sync() - for org in sync.Profile.Organizations: - if org.Id == self.Id: - break + for force_refresh in [False, True]: + sync = self.api_client.sync(force_refresh=force_refresh) + for org in sync.Profile.Organizations: + if org.Id == self.Id: + assert org and org.Key + return org.Key else: raise BitwardenError(f"No Organizations `{self.Id}` found") - assert org and org.Key - return org.Key + + def delete(self) -> None: + self.api_client.api_request( + "DELETE", + f"api/organizations/{self.Id}", + json=dict( + masterPasswordHash=self._bitwarden_client.masterPasswordHash + ), + ) def get_organization( @@ -1084,10 +1182,7 @@ class Config: @computed_field # type: ignore[prop-decorator] @property def masterPasswordHash(self) -> str: # noqa: N802 - v = hashlib.pbkdf2_hmac( - "sha256", self._masterKey, self.password.encode(), 1 - ) - return base64.b64encode(v).decode() + return masterPasswordHash(self._masterKey, self.password) @computed_field # type: ignore[prop-decorator] @property @@ -1130,3 +1225,42 @@ def _rawKey(self) -> bytes: # noqa: N802 @cached_property def _rawKeys(self) -> RSA.RsaKey: # noqa: N802 return RSA.generate(2048) + + +class OrgData(BitwardenBaseModel): + """ + c.f. https://github.com/dani-garcia/vaultwarden/blob/d6a3d539ed13352085ca7dfa63c49017d86c419b/src/api/core/organizations.rs#L109-L119 + """ + + class Config: + extra = "forbid" + arbitrary_types_allowed = True + + BillingEmail: str + CollectionName: SecretString + Key: SecretOrganizationKey + Name: str + # Keys: KeysData + PlanType: int | str + + @computed_field(alias="keys") # type: ignore[prop-decorator] + @property + def Keys(self) -> KeysData: # noqa: N802 + return KeysData.model_construct( + encryptedPrivateKey=SymmetricCipher.encode( + self._rawKeys.exportKey("DER", pkcs=8), self.Key + ), + publicKey=base64.b64encode( + self._rawKeys.publickey().exportKey("DER") + ).decode(), + ) + + @cached_property + def _rawKeys(self) -> RSA.RsaKey: # noqa: N802 + return RSA.generate(2048) + + @model_serializer(mode="wrap") + def ser_set_key( + self, handler: SerializerFunctionWrapHandler, info: SerializationInfo + ) -> Any: + return ser_set_key(self, handler, info) diff --git a/src/vaultwarden/models/crypto.py b/src/vaultwarden/models/crypto.py index a4803b7..2966ea1 100644 --- a/src/vaultwarden/models/crypto.py +++ b/src/vaultwarden/models/crypto.py @@ -1,6 +1,7 @@ +from base64 import b64decode import dataclasses import typing -from typing import Any, TypeAlias, cast +from typing import TypeAlias, cast from uuid import UUID from Crypto.PublicKey import RSA @@ -21,7 +22,7 @@ def decode_string( - value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo + value: str, handler: ValidatorFunctionWrapHandler, info: ValidationInfo ) -> str: ctx = cast(CryptoContext, info.context) return handler(SymmetricCipher.decode(value, ctx.stack[-1])) @@ -50,8 +51,10 @@ def decode_bytes( def encode_bytes( - value: Any, handler: SerializerFunctionWrapHandler, info: SerializationInfo -) -> bytes: + value: bytes, + handler: SerializerFunctionWrapHandler, + info: SerializationInfo, +) -> str: ctx = cast(CryptoContext, info.context) return handler(SymmetricCipher.encode(value, ctx.stack[-1])) @@ -75,7 +78,7 @@ def encode_rsa( value: RSA.RsaKey, handler: SerializerFunctionWrapHandler, info: SerializationInfo, -) -> bytes: +) -> str: ctx = cast(CryptoContext, info.context) return handler( SymmetricCipher.encode(value.exportKey("DER", pkcs=8), ctx.stack[-1]) @@ -94,7 +97,7 @@ def decode_org_key( value: str, handler: ValidatorFunctionWrapHandler, info: ValidationInfo ) -> bytes: ctx = cast(CryptoContext, info.context) - return handler(AsymmetricCipher.decode(value, ctx.stack[-1])) + return handler(AsymmetricCipher.decode(value, ctx.stack[-2])) def encode_org_key( @@ -103,7 +106,7 @@ def encode_org_key( info: SerializationInfo, ) -> str: ctx = cast(CryptoContext, info.context) - return handler(AsymmetricCipher.encode(value, ctx.stack[-1])) + return handler(AsymmetricCipher.encode(value, ctx.stack[-2])) SecretOrganizationKey = Annotated[ @@ -125,8 +128,10 @@ def decode_key( def encode_key( - value: Any, handler: SerializerFunctionWrapHandler, info: SerializationInfo -) -> bytes: + value: bytes, + handler: SerializerFunctionWrapHandler, + info: SerializationInfo, +) -> str: ctx = cast(CryptoContext, info.context) return handler(SymmetricCipher.encode(value, ctx.stack[-2])) @@ -144,6 +149,15 @@ def encode_key( CryptoKey: TypeAlias = RSA.RsaKey | bytes +def decode_rsa_public_key( + value: str, handler: ValidatorFunctionWrapHandler, info: ValidationInfo +) -> RSA.RsaKey: + return handler(RSA.importKey(b64decode(value))) + + +RSAPublicKey = Annotated[RSA.RsaKey, WrapValidator(decode_rsa_public_key)] + + @dataclasses.dataclass class CryptoContext: client: "BitwardenAPIClient" diff --git a/src/vaultwarden/models/enum.py b/src/vaultwarden/models/enum.py index ece782e..e60bfa8 100644 --- a/src/vaultwarden/models/enum.py +++ b/src/vaultwarden/models/enum.py @@ -21,6 +21,7 @@ class CipherType(IntEnum): SecureNote = 2 Card = 3 Identity = 4 + SSHKey = 5 class VaultwardenUserStatus(IntEnum): diff --git a/src/vaultwarden/models/sync.py b/src/vaultwarden/models/sync.py index 551782a..9b35fb8 100644 --- a/src/vaultwarden/models/sync.py +++ b/src/vaultwarden/models/sync.py @@ -13,7 +13,7 @@ model_validator, ) -from vaultwarden.models.bitwarden import Login, val_set_key +from vaultwarden.models.bitwarden import CipherDetails, val_set_key from vaultwarden.models.crypto import ( CryptoContext, SecretKey, @@ -85,7 +85,7 @@ def val_set_key( return v -class ProfileOrganization(PermissiveBaseModel): +class _ProfileOrganization(PermissiveBaseModel): Id: UUID Name: str Key: SecretOrganizationKey | None = None @@ -108,6 +108,18 @@ class ProfileOrganization(PermissiveBaseModel): UseTotp: bool +class ProfileOrganization(_ProfileOrganization): + @model_validator(mode="wrap") + @classmethod + def val_set_key( + cls, + data: Any, + handler: ModelWrapValidatorHandler[Self], + info: ValidationInfo, + ) -> Self: + return val_set_key(cls, data, handler, info) + + class _UserProfile(PermissiveBaseModel): AvatarColor: str | None Culture: str @@ -163,7 +175,7 @@ def val_set_key( return val_set_key(cls, data, handler, info) -class VaultwardenOrganization(ProfileOrganization): +class VaultwardenOrganization(_ProfileOrganization): # overwrite Key: str # type: ignore @@ -181,7 +193,7 @@ class VaultwardenUser(_UserProfile): class SyncData(PermissiveBaseModel): Profile: UserProfile - Ciphers: list[Login] + Ciphers: list[CipherDetails] Collections: list[dict] Domains: dict | None Folders: list[dict] diff --git a/src/vaultwarden/utils/crypto.py b/src/vaultwarden/utils/crypto.py index 04b0fdc..c168cb3 100644 --- a/src/vaultwarden/utils/crypto.py +++ b/src/vaultwarden/utils/crypto.py @@ -76,7 +76,7 @@ def encode(cls, plainbytes: bytes, key: RSA.RsaKey): assert isinstance(key, RSA.RsaKey) cipher = PKCS1_OAEP.new(key).encrypt(plainbytes) b64_ct = b64encode(cipher).decode() - return cls.ENCODING.format(cipher=cipher, b64_ct=b64_ct) + return cls.ENCODING.format(typ=cls.TYPE, b64_ct=b64_ct) @classmethod def decode(cls, data: str, key: RSA.RsaKey) -> bytes: @@ -289,6 +289,11 @@ def stretch_key(key: bytes) -> bytes: ) return stretched_key +def masterPasswordHash(masterKey: bytes, password: str) -> str: + v = hashlib.pbkdf2_hmac( + "sha256", masterKey, password.encode(), 1 + ) + return base64.b64encode(v).decode() def gen_password(length=32, alphabet=None) -> str: # FIXME UNUSED alphabet = alphabet or string.ascii_letters + string.digits diff --git a/tests/e2e/test_write.py b/tests/e2e/test_write.py new file mode 100644 index 0000000..672f043 --- /dev/null +++ b/tests/e2e/test_write.py @@ -0,0 +1,252 @@ +import os +import random +import secrets +import string +import urllib.parse + +import pytest +from vaultwarden.clients.bitwarden import BitwardenAPIClient +from vaultwarden.clients.vaultwarden import VaultwardenAdminClient +from vaultwarden.models.bitwarden import ( + Card, + CardData, + CipherDetails, + Identity, + IdentityData, + Kdf, + Login, + LoginData, + Organization, + OrganizationCollection, + SecureNote, + SecureNoteData, + SSHKeyData, + UriMatch, + UriMatchDetection, +) + + +@pytest.fixture +def test_account() -> BitwardenAPIClient: + from . import env_from_ci + + env_from_ci() + + # Get Bitwarden credentials from environment variables + url = os.environ.get("BITWARDEN_URL", None) + email = os.environ.get("BITWARDEN_EMAIL", None) + password = os.environ.get("BITWARDEN_PASSWORD", None) + client_id = os.environ.get("BITWARDEN_CLIENT_ID", None) + client_secret = os.environ.get("BITWARDEN_CLIENT_SECRET", None) + device_id = os.environ.get("BITWARDEN_DEVICE_ID", None) + + # Get test organization id from environment variables + # test_organization = os.environ.get("BITWARDEN_TEST_ORGANIZATION", None) + + c = BitwardenAPIClient( + url, + email, + password, + client_id, + client_secret, + device_id, + ) + + c.sync() + return c + + +@pytest.fixture +def admin(test_account): + admin_secret_token = os.environ.get("VAULTWARDEN_ADMIN_TOKEN", None) + c = VaultwardenAdminClient( + test_account.url, admin_secret_token, preload_users=False + ) + return c + + +@pytest.fixture +def user() -> dict: + u = "".join(random.choices(string.ascii_lowercase + string.digits, k=10)) + email = f"{u}@example.org" + return dict(email=email, password=u, name=u, kdf=Kdf.argon2id()) + + +@pytest.fixture +def organization(test_account: BitwardenAPIClient, user: dict) -> Organization: + return test_account.create_organization( + name=f'Org {user["email"].partition("@")[0]}', email=user["email"] + ) + + +@pytest.fixture +def login() -> "Login": + uri = urllib.parse.urlparse( + url := "http://username:password@login.example.org" + ) + key = secrets.token_bytes(64) + + data = LoginData.model_construct( + name=uri.hostname, + password=uri.username, + username=uri.password, + uris=[UriMatch.model_construct(match=UriMatchDetection.HOST, uri=url)], + ) + item = Login.model_construct( + name=f"{uri.username}@{uri.hostname}", + login=data, + data=data, + key=key, + ) + return item + + +@pytest.fixture +def securenote() -> "SecureNote": + uri = urllib.parse.urlparse( + url := "http://username:password@securenote.example.org" + ) + key = secrets.token_bytes(64) + + data = SecureNoteData.model_construct( + Notes="".join(random.choices(string.ascii_letters, k=10)), + uris=[UriMatch.model_construct(match=UriMatchDetection.HOST, uri=url)], + ) + item = SecureNote.model_construct( + Name=f"{uri.username}@{uri.hostname}", + SecureNote=data, + Data=data, + Key=key, + ) + return item + + +@pytest.fixture +def card(): + key = secrets.token_bytes(64) + data = CardData.model_construct( + cardholderName="user", + brand="VISA", + code="123", + expMonth="11", + expYear="2020", + number="1204391293", + ) + item = Card.model_construct( + Name="user@VISA", + Card=data, + Data=data, + Key=key, + ) + return item + + +@pytest.fixture +def identity(): + key = secrets.token_bytes(64) + data = IdentityData.model_construct( + title="Mrs.", + firstName="A", + middleName="B", + lastName="C", + username="abc", + company="Z", + ssn="1", + passportNumber="2", + licenseNumber="3", + email="abc@Z.org", + phone="112", + address1="a1", + address2="a2", + address3="a3", + city="City", + state="State", + postalCode="1", + country="X", + ) + item = Identity.model_construct( + Name="user@Z", + Identity=data, + Data=data, + Key=key, + ) + return item + + +@pytest.fixture +def sshkey(): + fp = "SHA256:0uYSZPry8sa7UC/sfjLZCgjggJ12KhHHeD+BP0hew50" + priv = """-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBMlizY/9+h3pZlH9ADEGOaL/aRnBA0XveKurHXW66oAwAAAIgdq/EQHavx +EAAAAAtzc2gtZWQyNTUxOQAAACBMlizY/9+h3pZlH9ADEGOaL/aRnBA0XveKurHXW66oAw +AAAEAjVrd/TKd20aXb5qdh15Jjqw3GNEhQ+dLBx0nfV7X29UyWLNj/36HelmUf0AMQY5ov +9pGcEDRe94q6sddbrqgDAAAAAAECAwQF +-----END OPENSSH PRIVATE KEY----- + """ + pub = ( + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEyWLNj" + "/36HelmUf0AMQY5ov9pGcEDRe94q6sddbrqgD" + ) + + data = SSHKeyData.model_construct( + keyFingerprint=fp, privateKey=priv, publicKey=pub + ) + item = SSHKeyData.model_construct(sshKey=data) + return item + + +@pytest.fixture +def collection(organization: Organization) -> OrganizationCollection: + return organization.create_collection("Test Collection") + + +@pytest.fixture +def ciphers(login, securenote, card, identity) -> list[CipherDetails]: + return [login, securenote, card, identity] + + +def test_user( + test_account: BitwardenAPIClient, + admin: VaultwardenAdminClient, + user: dict, + organization: Organization, + collection: OrganizationCollection, + ciphers, +): + # create + test_account.create_user(**user) + + # invite + organization.invite(user["email"]) + + # confirm + users = organization.users(force_refresh=True, search=user["email"]) + assert len(users) == 1 + u = users[0] + organization.confirm(u) + + # add to collection + u.add_collections([collection.Id]) + + # cleanup + organization.delete() + admin.delete(u.Id) + + +def test_ciphers( + test_account: BitwardenAPIClient, + organization: Organization, + collection: OrganizationCollection, + ciphers, +): + for c in ciphers: + test_account.create_item(c, organization, [collection]) + + organization.delete() + + +def test_cleanup_users(admin: VaultwardenAdminClient): + for i in admin.users(): + if i.Email.endswith("@example.org"): + admin.delete(i.Id) From 352fed304560505c82d839a9b63a111a4411f189 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Tue, 16 Jun 2026 17:37:34 +0200 Subject: [PATCH 24/35] pyproject - include pytest --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index b548d51..5248728 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,6 +68,7 @@ packages = [ [tool.hatch.envs.test] dependencies = [ "coverage", + "pytest" ] [tool.hatch.envs.test.scripts] From 40383e824ad8a313396fc9dd90bc623c692eb944 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Fri, 19 Jun 2026 13:09:40 +0200 Subject: [PATCH 25/35] pyproject/pytest - warnings as errors --- pyproject.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 5248728..ef0fb14 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,11 @@ test = [ "pytest~=8.3", ] +[tool.pytest.ini_options] +filterwarnings = [ + "error" +] + [tool.hatch.version] path = "src/vaultwarden/__version__.py" From 92fae242a5112eb027461bd94e643a4ebe7092aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Fri, 19 Jun 2026 13:14:17 +0200 Subject: [PATCH 26/35] models - migrate to model_config class based configuration is deprecated --- src/vaultwarden/models/bitwarden.py | 30 ++++++++++------------------- 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/src/vaultwarden/models/bitwarden.py b/src/vaultwarden/models/bitwarden.py index 75cdc3c..b73c7c4 100644 --- a/src/vaultwarden/models/bitwarden.py +++ b/src/vaultwarden/models/bitwarden.py @@ -22,6 +22,7 @@ from Crypto.PublicKey import RSA from pydantic import ( AliasChoices, + ConfigDict, Field, ModelWrapValidatorHandler, PrivateAttr, @@ -151,8 +152,7 @@ class UriMatchDetection(IntEnum): class UriMatch(BitwardenBaseModel): - class Config: - extra = "forbid" + model_config = ConfigDict(extra="forbid") match: UriMatchDetection | None = None uri: SecretString | None = None @@ -189,8 +189,7 @@ def uri_match(self, name: str) -> bool: class XField(BitwardenBaseModel): - class Config: - extra = "forbid" + model_config = ConfigDict(extra="forbid") name: SecretString | None = None response: SecretString | None = None @@ -200,16 +199,14 @@ class Config: class PasswordChange(BitwardenBaseModel): - class Config: - extra = "forbid" + model_config = ConfigDict(extra="forbid") lastUsedDate: datetime.datetime password: SecretString class Fido2Credential(BitwardenBaseModel): - class Config: - extra = "forbid" + model_config = ConfigDict(extra="forbid") counter: SecretString | None = None creationDate: datetime.datetime | None = None @@ -228,8 +225,7 @@ class Config: class AttachmentRequest(BitwardenBaseModel): - class Config: - extra = "forbid" + model_config = ConfigDict(extra="forbid") Key: SecretBytes fileName: SecretString @@ -238,8 +234,7 @@ class Config: class Attachment(BitwardenBaseModel): - class Config: - extra = "forbid" + model_config = ConfigDict(extra="forbid") Key: SecretBytes fileName: SecretString | None = None @@ -255,8 +250,7 @@ def download(self): class _CipherBase(BitwardenBaseModel): - class Config: - extra = "forbid" + model_config = ConfigDict(extra="forbid") Id: UUID | None = None OrganizationId: UUID | None = Field(None, validate_default=True) @@ -1160,9 +1154,7 @@ class RegisterData(BitwardenBaseModel): c.f. https://bitwarden.com/help/bitwarden-security-white-paper/ """ - class Config: - extra = "forbid" - arbitrary_types_allowed = True + model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True) email: str password: str = Field(exclude=True) @@ -1232,9 +1224,7 @@ class OrgData(BitwardenBaseModel): c.f. https://github.com/dani-garcia/vaultwarden/blob/d6a3d539ed13352085ca7dfa63c49017d86c419b/src/api/core/organizations.rs#L109-L119 """ - class Config: - extra = "forbid" - arbitrary_types_allowed = True + model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True) BillingEmail: str CollectionName: SecretString From 317f9f1c2408cfc7b605964aaceb7e10650a2ab8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Fri, 19 Jun 2026 13:15:17 +0200 Subject: [PATCH 27/35] models - use ValidationInfo FieldValidationInfo is deprecated --- src/vaultwarden/models/bitwarden.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/vaultwarden/models/bitwarden.py b/src/vaultwarden/models/bitwarden.py index b73c7c4..b12efc9 100644 --- a/src/vaultwarden/models/bitwarden.py +++ b/src/vaultwarden/models/bitwarden.py @@ -33,7 +33,6 @@ model_validator, ) from pydantic_core.core_schema import ( - FieldValidationInfo, SerializationInfo, SerializerFunctionWrapHandler, ValidationInfo, @@ -329,7 +328,7 @@ def ser_set_key( @field_validator("OrganizationId") @classmethod - def set_id(cls, v, info: FieldValidationInfo): + def set_id(cls, v, info: ValidationInfo): if v is None and info.context is not None: ctx: CryptoContext = cast(CryptoContext, info.context) return ctx.parent_id @@ -542,7 +541,7 @@ class CollectionUser(CollectionAccess): @field_validator("CollectionId") @classmethod - def set_id(cls, v, info: FieldValidationInfo): + def set_id(cls, v, info: ValidationInfo): if v is None and info.context is not None: ctx: CryptoContext = cast(CryptoContext, info.context) return ctx.parent_id @@ -559,7 +558,7 @@ class UserCollection(CollectionAccess): @field_validator("UserId") @classmethod - def set_id(cls, v, info: FieldValidationInfo): + def set_id(cls, v, info: ValidationInfo): if v is None and info.context is not None: ctx: CryptoContext = cast(CryptoContext, info.context) return ctx.parent_id @@ -574,7 +573,7 @@ class OrganizationCollection(BitwardenBaseModel): @field_validator("OrganizationId") @classmethod - def set_id(cls, v, info: FieldValidationInfo): + def set_id(cls, v, info: ValidationInfo): if v is None and info.context is not None: ctx: CryptoContext = cast(CryptoContext, info.context) return ctx.parent_id @@ -682,7 +681,7 @@ class OrganizationUserDetails(BitwardenBaseModel): @field_validator("OrganizationId") @classmethod - def set_id(cls, v, info: FieldValidationInfo): + def set_id(cls, v, info: ValidationInfo): if v is None and info.context is not None: ctx: CryptoContext = cast(CryptoContext, info.context) return ctx.parent_id @@ -831,7 +830,7 @@ class Organization(BitwardenBaseModel): @field_validator("Id") @classmethod - def set_id(cls, v, info: FieldValidationInfo): + def set_id(cls, v, info: ValidationInfo): if v is None and info.context is not None: ctx: CryptoContext = cast(CryptoContext, info.context) return ctx.parent_id From 9c27022e053cd820495f9e5794f06930b41bbd4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Fri, 19 Jun 2026 13:16:09 +0200 Subject: [PATCH 28/35] tests - close client connections --- src/vaultwarden/clients/bitwarden.py | 3 +++ src/vaultwarden/clients/vaultwarden.py | 3 +++ tests/e2e/test_write.py | 8 +++++--- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/vaultwarden/clients/bitwarden.py b/src/vaultwarden/clients/bitwarden.py index ddbb0f0..42420a0 100644 --- a/src/vaultwarden/clients/bitwarden.py +++ b/src/vaultwarden/clients/bitwarden.py @@ -53,6 +53,9 @@ def __init__( self._connect_token: ConnectToken | None = None self._sync: SyncData | None = None + def close(self): + self._http_client.close() + @property def connect_token(self) -> ConnectToken | None: return self._connect_token diff --git a/src/vaultwarden/clients/vaultwarden.py b/src/vaultwarden/clients/vaultwarden.py index 192c3f3..85e17c9 100644 --- a/src/vaultwarden/clients/vaultwarden.py +++ b/src/vaultwarden/clients/vaultwarden.py @@ -43,6 +43,9 @@ def __init__( if preload_users: self._load_users() + def close(self): + self._http_client.close() + def _get_admin_cookie(self) -> Cookie | None: """Get the session cookie, required to authenticate requests""" bw_cookies = ( diff --git a/tests/e2e/test_write.py b/tests/e2e/test_write.py index 672f043..a2260f5 100644 --- a/tests/e2e/test_write.py +++ b/tests/e2e/test_write.py @@ -27,7 +27,7 @@ @pytest.fixture -def test_account() -> BitwardenAPIClient: +def test_account(): from . import env_from_ci env_from_ci() @@ -53,7 +53,8 @@ def test_account() -> BitwardenAPIClient: ) c.sync() - return c + yield c + c.close() @pytest.fixture @@ -62,7 +63,8 @@ def admin(test_account): c = VaultwardenAdminClient( test_account.url, admin_secret_token, preload_users=False ) - return c + yield c + c.close() @pytest.fixture From fc98f27c1244ca61378837e4a85a50e151a60dab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Fri, 19 Jun 2026 13:21:38 +0200 Subject: [PATCH 29/35] crypto - encode to bytes by default --- src/vaultwarden/clients/bitwarden.py | 11 +++++-- src/vaultwarden/models/bitwarden.py | 14 ++++++--- src/vaultwarden/models/crypto.py | 9 +++++- src/vaultwarden/utils/crypto.py | 46 ++++++++-------------------- 4 files changed, 37 insertions(+), 43 deletions(-) diff --git a/src/vaultwarden/clients/bitwarden.py b/src/vaultwarden/clients/bitwarden.py index 42420a0..5fde44e 100644 --- a/src/vaultwarden/clients/bitwarden.py +++ b/src/vaultwarden/clients/bitwarden.py @@ -206,7 +206,11 @@ def create_organization( ctx = CryptoContext(client=self) ctx.push(self._connect_token.PrivateKey) data = req.model_dump( - by_alias=True, exclude_none=True, exclude_unset=True, context=ctx + mode="json", + by_alias=True, + exclude_none=True, + exclude_unset=True, + context=ctx, ) v = self.api_request("POST", "api/organizations", json=data) return Organization.model_validate( @@ -230,9 +234,10 @@ def create_user( email=email, password=password, name=name, - **kdf.model_dump(by_alias=True), + **kdf.model_dump(mode="json", by_alias=True), ) data = rd.model_dump( + mode="json", by_alias=True, exclude_none=True, exclude_unset=True, @@ -322,8 +327,8 @@ def create_item( data = { "type": item.Type, "cipher": item.model_dump( - by_alias=True, mode="json", + by_alias=True, context=CryptoContext(client=self, stack=[key]), ), "collectionIds": [str(i.Id) for i in collections], diff --git a/src/vaultwarden/models/bitwarden.py b/src/vaultwarden/models/bitwarden.py index b12efc9..69448a9 100644 --- a/src/vaultwarden/models/bitwarden.py +++ b/src/vaultwarden/models/bitwarden.py @@ -390,7 +390,8 @@ def _attach(self, name: str, file: io.IOBase): else: stack = [self._bitwarden_client._connect_token._masterKey] ard = ar.model_dump( - context=CryptoContext(client=self._bitwarden_client, stack=stack) + mode="json", + context=CryptoContext(client=self._bitwarden_client, stack=stack), ) v = self._bitwarden_client._api_request( "POST", f"api/ciphers/{self.Id}/attachment/v2", json=ard @@ -920,6 +921,7 @@ def confirm(self, user: OrganizationUserDetails): confirm = ConfirmData.model_construct(Key=self.key()) payload = confirm.model_dump( + mode="json", by_alias=True, context=CryptoContext(client=self.api_client, stack=[publicKey]), ) @@ -1013,7 +1015,9 @@ def collections( def create_collection(self, name: str) -> OrganizationCollection: org_key = self.key() data = { - "name": SymmetricCipher.encode(name.encode("utf-8"), org_key), + "name": SymmetricCipher.encode( + name.encode("utf-8"), org_key + ).decode("utf-8"), "groups": [], "users": [], } @@ -1178,7 +1182,7 @@ def masterPasswordHash(self) -> str: # noqa: N802 @computed_field # type: ignore[prop-decorator] @property def key(self) -> str: - return SymmetricCipher.encode(self._rawKey, self._masterKey) + return SymmetricCipher.encode(self._rawKey, self._masterKey).decode() @computed_field # type: ignore[prop-decorator] @property @@ -1186,7 +1190,7 @@ def keys(self) -> KeysData: return KeysData.model_construct( encryptedPrivateKey=SymmetricCipher.encode( self._rawKeys.exportKey("DER", pkcs=8), self._rawKey - ), + ).decode(), publicKey=base64.b64encode( self._rawKeys.publickey().exportKey("DER") ).decode(), @@ -1238,7 +1242,7 @@ def Keys(self) -> KeysData: # noqa: N802 return KeysData.model_construct( encryptedPrivateKey=SymmetricCipher.encode( self._rawKeys.exportKey("DER", pkcs=8), self.Key - ), + ).decode(), publicKey=base64.b64encode( self._rawKeys.publickey().exportKey("DER") ).decode(), diff --git a/src/vaultwarden/models/crypto.py b/src/vaultwarden/models/crypto.py index 2966ea1..ea797de 100644 --- a/src/vaultwarden/models/crypto.py +++ b/src/vaultwarden/models/crypto.py @@ -31,8 +31,11 @@ def decode_string( def encode_string( value: str, handler: SerializerFunctionWrapHandler, info: SerializationInfo ) -> str: + assert info.mode == "json" ctx = cast(CryptoContext, info.context) - return handler(SymmetricCipher.encode(value.encode(), ctx.stack[-1])) + return handler( + SymmetricCipher.encode(value.encode(), ctx.stack[-1]).decode() + ) SecretString = Annotated[ @@ -55,6 +58,7 @@ def encode_bytes( handler: SerializerFunctionWrapHandler, info: SerializationInfo, ) -> str: + assert info.mode == "json" ctx = cast(CryptoContext, info.context) return handler(SymmetricCipher.encode(value, ctx.stack[-1])) @@ -79,6 +83,7 @@ def encode_rsa( handler: SerializerFunctionWrapHandler, info: SerializationInfo, ) -> str: + assert info.mode == "json" ctx = cast(CryptoContext, info.context) return handler( SymmetricCipher.encode(value.exportKey("DER", pkcs=8), ctx.stack[-1]) @@ -105,6 +110,7 @@ def encode_org_key( handler: SerializerFunctionWrapHandler, info: SerializationInfo, ) -> str: + assert info.mode == "json" ctx = cast(CryptoContext, info.context) return handler(AsymmetricCipher.encode(value, ctx.stack[-2])) @@ -132,6 +138,7 @@ def encode_key( handler: SerializerFunctionWrapHandler, info: SerializationInfo, ) -> str: + assert info.mode == "json" ctx = cast(CryptoContext, info.context) return handler(SymmetricCipher.encode(value, ctx.stack[-2])) diff --git a/src/vaultwarden/utils/crypto.py b/src/vaultwarden/utils/crypto.py index c168cb3..922eaae 100644 --- a/src/vaultwarden/utils/crypto.py +++ b/src/vaultwarden/utils/crypto.py @@ -19,7 +19,7 @@ from Crypto.Cipher import AES, PKCS1_OAEP from Crypto.PublicKey import RSA from hkdf import hkdf_expand -from typing_extensions import override + if sys.version_info < (3, 11): from typing_extensions import Self @@ -37,18 +37,11 @@ class CIPHERS(IntEnum): sym = 2 asym = 4 - -CACHE = {} # type: ignore -ENCRYPTED_STRING_RE = re.compile("^[0-9][.].*=.*", flags=re.I | re.M) -SYM_ENCRYPTED_STRING_RE = re.compile( - "^2[.][^=]+=+[|][^=]+=+[|][^=]+=+", flags=re.I | re.M -) - class _Cipher: TYPE: int - ENCODING: str + ENCODING: bytes @classmethod - def encode(cls, plainbytes:bytes, key:bytes) -> str: + def encode(cls, plainbytes:bytes, key:bytes) -> bytes: raise NotImplementedError() @classmethod @@ -60,7 +53,7 @@ def _decrypt(self, data:bytes, key: bytes) -> bytes: class AsymmetricCipher(_Cipher): TYPE = CIPHERS.asym - ENCODING = "{typ}.{b64_ct}" + ENCODING = b"%(typ)i.%(ct)b" @classmethod def _parse(cls, ct:str) -> tuple[Self, bytes]: return cls(), b64decode(ct) @@ -71,12 +64,11 @@ def _decrypt(self, ct:bytes, key: RSA.RsaKey) -> bytes: return PKCS1_OAEP.new(key).decrypt(ct) @classmethod - def encode(cls, plainbytes: bytes, key: RSA.RsaKey): + def encode(cls, plainbytes: bytes, key: RSA.RsaKey) -> bytes: assert isinstance(plainbytes, bytes) assert isinstance(key, RSA.RsaKey) cipher = PKCS1_OAEP.new(key).encrypt(plainbytes) - b64_ct = b64encode(cipher).decode() - return cls.ENCODING.format(typ=cls.TYPE, b64_ct=b64_ct) + return cls.ENCODING % {b"typ":cls.TYPE, b"ct":b64encode(cipher)} @classmethod def decode(cls, data: str, key: RSA.RsaKey) -> bytes: @@ -86,7 +78,7 @@ def decode(cls, data: str, key: RSA.RsaKey) -> bytes: class SymmetricCipher(_Cipher): TYPE = CIPHERS.sym - ENCODING = "{typ}.{b64_iv}|{b64_ct}|{b64_digest}" + ENCODING = b"%(typ)i.%(iv)b|%(ct)b|%(digest)b" def __init__(self, iv:bytes, mac:bytes): self._iv = iv self._mac = mac @@ -115,19 +107,12 @@ def _decrypt(self, ct: bytes, key: bytes) -> bytes: @classmethod - def encode(cls, plainbytes: bytes, key: bytes) -> str: + def encode(cls, plainbytes: bytes, key: bytes) -> bytes: assert isinstance(plainbytes, bytes) assert isinstance(key, bytes) # inspired from bitwarden/jslib:src/services/crypto.service.ts - typ = int(CIPHERS.sym) (iv, ct, mac) = aes_encrypt(plainbytes, key) - # jslib: encrypt() - b64_iv = b64encode(iv).decode() - b64_ct = b64encode(ct).decode() - b64_digest = "" - if mac: - b64_digest = b64encode(mac).decode() - return cls.ENCODING.format(typ=CIPHERS.sym, b64_iv=b64_iv, b64_ct=b64_ct, b64_digest=b64_digest) + return cls.ENCODING % {b"typ":cls.TYPE, b"iv":b64encode(iv), b"ct":b64encode(ct), b"digest":b64encode(mac)} @classmethod def decode(cls, data: str, key: bytes) -> bytes: @@ -155,6 +140,7 @@ def _get_enc_mac(key:bytes) -> tuple[bytes, bytes]: class BinarySymmetricCipher: + TYPE = CIPHERS.sym ENCODING = b"%(typ)c%(iv)16b%(mac)32b%(ct)b" def __init__(self, iv:bytes, mac:bytes): @@ -199,17 +185,9 @@ def encode(cls, plainbytes: bytes, key: bytes) -> bytes: assert isinstance(plainbytes, bytes) assert isinstance(key, bytes) # inspired from bitwarden/jslib:src/services/crypto.service.ts - typ = int(CIPHERS.sym) (iv, ct, mac) = aes_encrypt(plainbytes, key) - # jslib: encryptToBytes() - ret = chr(typ).encode() - ret += iv - if mac: - ret += mac - ret += ct - - assert cls.ENCODING % {b"typ": typ, b"iv": iv, b"mac": mac, b"ct": ct} == ret - return ret + return cls.ENCODING % {b"typ": cls.TYPE, b"iv": iv, b"mac": mac, b"ct": ct} + class NullCipher(_Cipher): From d5cfb3cb3c21cd771ce699d15e23853f5961a672 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Fri, 19 Jun 2026 19:57:12 +0200 Subject: [PATCH 30/35] tests - search/edit items --- src/vaultwarden/clients/bitwarden.py | 28 +++++++-- src/vaultwarden/models/bitwarden.py | 19 ++++++ tests/e2e/test_write.py | 90 ++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 4 deletions(-) diff --git a/src/vaultwarden/clients/bitwarden.py b/src/vaultwarden/clients/bitwarden.py index 5fde44e..8ce583b 100644 --- a/src/vaultwarden/clients/bitwarden.py +++ b/src/vaultwarden/clients/bitwarden.py @@ -11,6 +11,7 @@ OrganizationCollection, OrgData, RegisterData, + get_organization, ) from vaultwarden.models.crypto import CryptoContext from vaultwarden.models.exception_models import BitwardenError @@ -313,9 +314,9 @@ def select_items( def create_item( self, - item: "CipherDetails", - organization: typing.Optional["Organization"], - collections: list["OrganizationCollection"] | None, + item: CipherDetails, + organization: Organization, + collections: list[OrganizationCollection], ) -> "CipherDetails": if organization: assert organization and ( @@ -330,6 +331,7 @@ def create_item( mode="json", by_alias=True, context=CryptoContext(client=self, stack=[key]), + exclude_none=True, ), "collectionIds": [str(i.Id) for i in collections], } @@ -338,8 +340,8 @@ def create_item( assert self.connect_token is not None key = self.connect_token.Key data = item.model_dump( - by_alias=True, mode="json", + by_alias=True, context=CryptoContext(client=self, stack=[key]), ) @@ -347,3 +349,21 @@ def create_item( return CipherDetail.validate_json( resp.text, context=CryptoContext(client=self) ) + + def edit_item(self, item: CipherDetails) -> "CipherDetails": + assert self.connect_token is not None + path = f"/api/ciphers/{item.Id}" + key = ( + self.connect_token.Key + if item.OrganizationId is None + else get_organization(self, item.OrganizationId).key() + ) + data = item.model_dump( + mode="json", + by_alias=True, + context=CryptoContext(client=self, stack=[key]), + ) + resp = self._api_request("PUT", path, json=data) + return CipherDetail.validate_json( + resp.text, context=CryptoContext(client=self) + ) diff --git a/src/vaultwarden/models/bitwarden.py b/src/vaultwarden/models/bitwarden.py index 69448a9..1312069 100644 --- a/src/vaultwarden/models/bitwarden.py +++ b/src/vaultwarden/models/bitwarden.py @@ -358,6 +358,22 @@ def remove_collections(self, collections: list[UUID]): json={"collectionIds": dump}, ) + def collections(self): + org: Organization | None = ( + get_organization(self._bitwarden_client, self.OrganizationId) + if self.OrganizationId + else None + ) + if org is None: + return [] + cd: dict[UUID, OrganizationCollection] = { + o.Id: o for o in org.collections() + } + colls: list[OrganizationCollection] = [ + cd[i] for i in self.CollectionIds + ] + return colls + def delete(self): return self.api_client.api_request("DELETE", f"api/ciphers/{self.Id}") @@ -411,6 +427,9 @@ def _attach(self, name: str, file: io.IOBase): def uri_match(self, name: str) -> bool: return False + def save(self): + self._bitwarden_client.edit_item(self) + class LoginData(BitwardenBaseModel): username: SecretString | None = None diff --git a/tests/e2e/test_write.py b/tests/e2e/test_write.py index a2260f5..d517d52 100644 --- a/tests/e2e/test_write.py +++ b/tests/e2e/test_write.py @@ -252,3 +252,93 @@ def test_cleanup_users(admin: VaultwardenAdminClient): for i in admin.users(): if i.Email.endswith("@example.org"): admin.delete(i.Id) + + +SEARCH_ITEMS = [ + # ("http://default.com", "http://default.com", None), + ( + "http://sub.basedomain.com", + "http://basedomain.com", + UriMatchDetection.BASEDOMAIN, + ), + ("http://host.com/a", "http://host.com", UriMatchDetection.HOST), + ( + "http://startswith.com/a/b", + "http://startswith.com/a", + UriMatchDetection.STARTSWITH, + ), + ("http://re.com", r"^http://re\.c.m", UriMatchDetection.RE), + ("http://exact.com", "http://exact.com", UriMatchDetection.EXACT), +] + + +@pytest.fixture( + params=SEARCH_ITEMS, + ids=[urllib.parse.urlparse(url).hostname for url, *_ in SEARCH_ITEMS], +) +def logins(request, test_account, organization, collection): + url, uri, match = request.param + name = urllib.parse.urlparse(url).hostname + data = LoginData.model_construct( + name=name, + password="test123", + username="test", + Uris=[UriMatch.model_construct(match=match, uri=uri)], + ) + item = Login.model_construct( + name=name, + login=data, + data=data, + key=secrets.token_bytes(64), + ) + test_account.create_item(item, organization, [collection]) + return url, uri, match + + +def test_search( + test_account: BitwardenAPIClient, + organization: Organization, + collection: OrganizationCollection, + logins, +): + test_account.sync(force_refresh=True) + url, uri, match = logins + + r = list( + test_account.search_items( + url, organisations=[organization], collections=[collection] + ) + ) + assert len(r) == 1, url + assert r[0].Name == urllib.parse.urlparse(url).hostname + + +def test_edit( + test_account: BitwardenAPIClient, + organization: Organization, + collection: OrganizationCollection, + logins, +): + test_account.sync(force_refresh=True) + url, uri, match = logins + + r = list( + test_account.search_items( + url, organisations=[organization], collections=[collection] + ) + ) + assert len(r) == 1, url + assert r[0].Name == urllib.parse.urlparse(url).hostname + lo: Login = r[0] + assert lo.Login.username == "test" + lo.Login.username = lo.Login.password = "edit" + lo.save() + test_account.sync(force_refresh=True) + r = list( + test_account.search_items( + url, organisations=[organization], collections=[collection] + ) + ) + assert len(r) == 1, url + lo: Login = r[0] + assert lo.Login.username == lo.Login.password == "edit" From 971f053abc5c5f78c810edb2a6ea50008c1bfdb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Tue, 23 Jun 2026 23:26:40 +0200 Subject: [PATCH 31/35] tests/attachments - fixes & tests --- src/vaultwarden/models/bitwarden.py | 4 ++- tests/e2e/test_write.py | 52 +++++++++++++++++++++++++++-- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src/vaultwarden/models/bitwarden.py b/src/vaultwarden/models/bitwarden.py index 1312069..ed1fe4a 100644 --- a/src/vaultwarden/models/bitwarden.py +++ b/src/vaultwarden/models/bitwarden.py @@ -397,7 +397,9 @@ def _attach(self, name: str, file: io.IOBase): ar = AttachmentRequest.model_construct( Key=key, fileName=name, fileSize=len(ed), adminRequest=True ) - if self.OrganizationId: + if self.Key: + stack = [self.Key] + elif self.OrganizationId: stack = [ get_organization( self._bitwarden_client, self.OrganizationId diff --git a/tests/e2e/test_write.py b/tests/e2e/test_write.py index d517d52..28fbf4a 100644 --- a/tests/e2e/test_write.py +++ b/tests/e2e/test_write.py @@ -1,6 +1,8 @@ import os +from pathlib import Path import random import secrets +from secrets import token_bytes import string import urllib.parse @@ -236,11 +238,11 @@ def test_user( admin.delete(u.Id) -def test_ciphers( +def test_org_create_ciphers( test_account: BitwardenAPIClient, organization: Organization, collection: OrganizationCollection, - ciphers, + ciphers: list[CipherDetails], ): for c in ciphers: test_account.create_item(c, organization, [collection]) @@ -248,6 +250,34 @@ def test_ciphers( organization.delete() +@pytest.fixture( + params=[token_bytes(64), None], + ids=["with key", "without key"], +) +def cipher_key(request): + return request.param + + +def test_user_create_cipher( + test_account: BitwardenAPIClient, login, cipher_key +): + login.Key = cipher_key + v = test_account.create_item(login, None, None) + v.delete() + + +def test_org_create_cipher( + test_account: BitwardenAPIClient, + login, + cipher_key, + organization, + collection, +): + login.Key = cipher_key + v = test_account.create_item(login, organization, [collection]) + v.delete() + + def test_cleanup_users(admin: VaultwardenAdminClient): for i in admin.users(): if i.Email.endswith("@example.org"): @@ -342,3 +372,21 @@ def test_edit( assert len(r) == 1, url lo: Login = r[0] assert lo.Login.username == lo.Login.password == "edit" + + +def test_attach( + test_account: BitwardenAPIClient, + organization: Organization, + collection: OrganizationCollection, + login: Login, +): + test_account.sync(force_refresh=True) + v = test_account.create_item(login, organization, [collection]) + v.attach(Path(__file__)) + + +def test_org_clean(admin, test_account): + for i in test_account._sync.Profile.Organizations: + if i.Name.startswith("Test "): + continue + admin.delete_organization(i.Id) From 0627c10b18bbfd12b77d662bfb2191f6f1d6affc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Tue, 23 Jun 2026 23:30:39 +0200 Subject: [PATCH 32/35] models - use serialize_by_alias --- src/vaultwarden/models/bitwarden.py | 11 ++--------- src/vaultwarden/models/permissive_model.py | 1 + 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/src/vaultwarden/models/bitwarden.py b/src/vaultwarden/models/bitwarden.py index ed1fe4a..1b45cd1 100644 --- a/src/vaultwarden/models/bitwarden.py +++ b/src/vaultwarden/models/bitwarden.py @@ -245,7 +245,7 @@ class Attachment(BitwardenBaseModel): def download(self): v = self._bitwarden_client._http_client.get(self.url) - return BinarySymmetricCipher.decode(v.content, self.key) + return BinarySymmetricCipher.decode(v.content, self.Key) class _CipherBase(BitwardenBaseModel): @@ -624,9 +624,7 @@ def set_users( if isinstance(users[0], CollectionUser): users = cast("list[CollectionUser]", users) users_payload = [ - user.model_dump( - exclude={"CollectionId"}, by_alias=True, mode="json" - ) + user.model_dump(exclude={"CollectionId"}, mode="json") for user in users ] else: @@ -739,7 +737,6 @@ def add_collections(self, collections: list[UUID]): exclude={ "Permissions": self.Permissions is None, }, - by_alias=True, mode="json", ) return ( @@ -774,7 +771,6 @@ def remove_collections(self, collections: list[UUID]): exclude={ "Permissions": self.Permissions is None, }, - by_alias=True, mode="json", ) return self.api_client.api_request( @@ -812,7 +808,6 @@ def update_collection(self, collections: list[UUID]): exclude={ "Permissions": self.Permissions is None, }, - by_alias=True, mode="json", ), ) @@ -895,7 +890,6 @@ def invite( ex: dict[str, Literal[True]] = {"UserId": True} collections_payload.append( coll.model_dump( - by_alias=True, mode="json", exclude=ex, ) @@ -943,7 +937,6 @@ def confirm(self, user: OrganizationUserDetails): confirm = ConfirmData.model_construct(Key=self.key()) payload = confirm.model_dump( mode="json", - by_alias=True, context=CryptoContext(client=self.api_client, stack=[publicKey]), ) resp = self.api_client.api_request( diff --git a/src/vaultwarden/models/permissive_model.py b/src/vaultwarden/models/permissive_model.py index 9839a0c..642ba6b 100644 --- a/src/vaultwarden/models/permissive_model.py +++ b/src/vaultwarden/models/permissive_model.py @@ -9,5 +9,6 @@ class PermissiveBaseModel( alias_generator=pascal_case_to_camel_case, populate_by_name=True, arbitrary_types_allowed=True, + serialize_by_alias=True, ): pass From 7ff2acd088fecbc2aea0163566d5b8914b6cee40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Wed, 24 Jun 2026 21:52:53 +0200 Subject: [PATCH 33/35] tests - item create & revision date and password history --- src/vaultwarden/clients/bitwarden.py | 19 ++++++++++-- src/vaultwarden/models/bitwarden.py | 44 +++++++++++++++++++++++----- tests/e2e/test_write.py | 44 ++++++++++++++++++++++++++-- 3 files changed, 95 insertions(+), 12 deletions(-) diff --git a/src/vaultwarden/clients/bitwarden.py b/src/vaultwarden/clients/bitwarden.py index 8ce583b..a05e85d 100644 --- a/src/vaultwarden/clients/bitwarden.py +++ b/src/vaultwarden/clients/bitwarden.py @@ -1,3 +1,4 @@ +from datetime import datetime import typing from typing import Literal from uuid import UUID @@ -315,9 +316,19 @@ def select_items( def create_item( self, item: CipherDetails, - organization: Organization, - collections: list[OrganizationCollection], + organization: Organization | None = None, + collections: list[OrganizationCollection] | None = None, ) -> "CipherDetails": + item.RevisionDate = item.CreationDate = datetime.now() + return self._create_item(item, organization, collections) + + def _create_item( + self, + item: CipherDetails, + organization: Organization | None = None, + collections: list[OrganizationCollection] | None = None, + ) -> "CipherDetails": + # item.Key = None if organization: assert organization and ( collections is not None and len(collections) @@ -351,6 +362,10 @@ def create_item( ) def edit_item(self, item: CipherDetails) -> "CipherDetails": + item.RevisionDate = datetime.now() + return self._edit_item(item) + + def _edit_item(self, item: CipherDetails) -> "CipherDetails": assert self.connect_token is not None path = f"/api/ciphers/{item.Id}" key = ( diff --git a/src/vaultwarden/models/bitwarden.py b/src/vaultwarden/models/bitwarden.py index 1b45cd1..1b11a55 100644 --- a/src/vaultwarden/models/bitwarden.py +++ b/src/vaultwarden/models/bitwarden.py @@ -265,8 +265,8 @@ class _CipherBase(BitwardenBaseModel): Notes: SecretString | None = None Reprompt: int | None = None - ArchivedDate: str | None = None - RevisionDate: str | None = None + ArchivedDate: datetime.datetime | None = None + RevisionDate: datetime.datetime | None = None sshKey: str | None = None Object: str | None = None Attachments: list[Attachment] | None = None @@ -358,7 +358,7 @@ def remove_collections(self, collections: list[UUID]): json={"collectionIds": dump}, ) - def collections(self): + def collections(self) -> list["OrganizationCollection"]: org: Organization | None = ( get_organization(self._bitwarden_client, self.OrganizationId) if self.OrganizationId @@ -374,7 +374,7 @@ def collections(self): ] return colls - def delete(self): + def delete(self) -> Any: return self.api_client.api_request("DELETE", f"api/ciphers/{self.Id}") def update_collection(self, collections: list[UUID]): @@ -386,7 +386,7 @@ def update_collection(self, collections: list[UUID]): json={"collectionIds": dump}, ) - def attach(self, path: Path): + def attach(self, path: Path) -> None: with path.open("rb") as f: self._attach(path.name, f) @@ -467,6 +467,35 @@ def uri_match(self, name: str) -> bool: return self.Login.uri_match(name) return False + @property + def username(self) -> str | None: + assert self.Login + return self.Login.username + + @username.setter + def username(self, value: str): + assert self.Login + self.Login.username = value + + @property + def password(self) -> str | None: + assert self.Login and self.Login.password + return self.Login.password + + @password.setter + def password(self, value: str): + assert self.Login and self.Login.password + hist = PasswordChange.model_construct( + lastUsedDate=datetime.datetime.now(), password=self.Login.password + ) + + if self.Login.PasswordHistory is None: + self.Login.PasswordHistory = [hist] + else: + self.Login.PasswordHistory.append(hist) + self.Login.passwordRevisionDate = datetime.datetime.now() + self.Login.password = value + class SecureNoteData(BitwardenBaseModel): Fields: list[XField] | None = None @@ -1017,9 +1046,8 @@ def _get_collections(self) -> list[OrganizationCollection]: ) return res.Data - def collections( - self, force_refresh: bool = False, as_dict: bool = False - ) -> list[OrganizationCollection] | dict[str, OrganizationCollection]: + # FIXME typing + def collections(self, force_refresh: bool = False, as_dict: bool = False): if self._collections is None or force_refresh: self._collections = self._get_collections() if as_dict: diff --git a/tests/e2e/test_write.py b/tests/e2e/test_write.py index 28fbf4a..62f13f6 100644 --- a/tests/e2e/test_write.py +++ b/tests/e2e/test_write.py @@ -306,7 +306,7 @@ def test_cleanup_users(admin: VaultwardenAdminClient): params=SEARCH_ITEMS, ids=[urllib.parse.urlparse(url).hostname for url, *_ in SEARCH_ITEMS], ) -def logins(request, test_account, organization, collection): +def logins(request, cipher_key, test_account, organization, collection): url, uri, match = request.param name = urllib.parse.urlparse(url).hostname data = LoginData.model_construct( @@ -319,7 +319,7 @@ def logins(request, test_account, organization, collection): name=name, login=data, data=data, - key=secrets.token_bytes(64), + key=cipher_key, ) test_account.create_item(item, organization, [collection]) return url, uri, match @@ -374,13 +374,53 @@ def test_edit( assert lo.Login.username == lo.Login.password == "edit" +def test_edit_history( + test_account: BitwardenAPIClient, + organization: Organization, + collection: OrganizationCollection, + logins, +): + test_account.sync(force_refresh=True) + url, uri, match = logins + + r = list( + test_account.search_items( + url, organisations=[organization], collections=[collection] + ) + ) + assert len(r) == 1, url + assert r[0].Name == urllib.parse.urlparse(url).hostname + lo: Login = r[0] + + assert lo.Login.PasswordHistory is None + assert lo.Login.passwordRevisionDate is None + assert lo.RevisionDate is not None + + lo.username = lo.password = "edit" + lo.save() + test_account.sync(force_refresh=True) + r = list( + test_account.search_items( + url, organisations=[organization], collections=[collection] + ) + ) + assert len(r) == 1, url + lo: Login = r[0] + assert len(lo.Login.PasswordHistory) == 1 + assert lo.Login.passwordRevisionDate is not None + assert lo.RevisionDate != lo.CreationDate + assert lo.username == "edit" + + def test_attach( test_account: BitwardenAPIClient, organization: Organization, collection: OrganizationCollection, login: Login, + cipher_key: bytes | None, ): test_account.sync(force_refresh=True) + login.Key = cipher_key v = test_account.create_item(login, organization, [collection]) v.attach(Path(__file__)) From 488545dd50cfd1d00a212655bdda8796c8c3a86c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Wed, 24 Jun 2026 22:01:49 +0200 Subject: [PATCH 34/35] sync - decode Collections & Folders --- src/vaultwarden/clients/bitwarden.py | 15 +++++- src/vaultwarden/models/sync.py | 46 +++++++++++++++++-- .../validation/test_pascal_camel_cases.py | 8 +--- 3 files changed, 58 insertions(+), 11 deletions(-) diff --git a/src/vaultwarden/clients/bitwarden.py b/src/vaultwarden/clients/bitwarden.py index a05e85d..7473f2f 100644 --- a/src/vaultwarden/clients/bitwarden.py +++ b/src/vaultwarden/clients/bitwarden.py @@ -1,6 +1,7 @@ from datetime import datetime import typing from typing import Literal +import uuid from uuid import UUID from httpx import Client, Response @@ -24,6 +25,7 @@ from vaultwarden.models.bitwarden import ( Kdf, ) + from vaultwarden.models.sync import ProfileOrganization class BitwardenAPIClient: @@ -219,8 +221,17 @@ def create_organization( v.json(), context=CryptoContext(client=self) ) - # def get_organization(self, name) -> "Organization": - # pass + def select_organizations( + self, + Id: str | uuid.UUID | None = None, # noqa: N803 + ) -> typing.Generator["ProfileOrganization", None, None]: + assert self._sync and self._sync.Profile + if Id is not None: + id_ = uuid.UUID(Id) if isinstance(Id, str) else Id + for org in self._sync.Profile.Organizations: + if org.Id == id_: + yield org + raise StopIteration def create_user( self, diff --git a/src/vaultwarden/models/sync.py b/src/vaultwarden/models/sync.py index 9b35fb8..3405316 100644 --- a/src/vaultwarden/models/sync.py +++ b/src/vaultwarden/models/sync.py @@ -1,6 +1,7 @@ +import datetime import sys import time -from typing import Any, cast +from typing import Any, Literal, cast from uuid import UUID from pydantic import ( @@ -19,6 +20,7 @@ SecretKey, SecretOrganizationKey, SecretRSA, + SecretString, ) from vaultwarden.models.enum import KdfType, VaultwardenUserStatus from vaultwarden.models.permissive_model import PermissiveBaseModel @@ -191,12 +193,50 @@ class VaultwardenUser(_UserProfile): Organizations: list[VaultwardenOrganization] # type: ignore +class CollectionDetail(PermissiveBaseModel): + Id: UUID | None = None + ExternalId: str | None = None + OrganizationId: UUID | None = None + Name: SecretString + Manage: bool | None = None + Object: Literal["collectionDetails"] + ReadOnly: bool | None = None + + @model_validator(mode="wrap") + @classmethod + def val_set_key( + cls, + data: Any, + handler: ModelWrapValidatorHandler[Self], + info: ValidationInfo, + ) -> Self: + ctx: CryptoContext = cast(CryptoContext, info.context) + + org = next( + ctx.client.select_organizations( + Id=data.get("organizationId") or data.get("OrganizationId") + ) + ) + assert org.Key + ctx.push(org.Key) + r = handler(data) + ctx.pop() + return r + + +class Folder(PermissiveBaseModel): + Id: UUID | None = None + Name: str | None = None + object: Literal["folder"] + revisionDate: datetime.datetime | None = None + + class SyncData(PermissiveBaseModel): Profile: UserProfile Ciphers: list[CipherDetails] - Collections: list[dict] + Collections: list[CollectionDetail] Domains: dict | None - Folders: list[dict] + Folders: list[Folder] Policies: list[dict] Sends: list[dict] diff --git a/tests/models/validation/test_pascal_camel_cases.py b/tests/models/validation/test_pascal_camel_cases.py index c7a8620..53fdc89 100644 --- a/tests/models/validation/test_pascal_camel_cases.py +++ b/tests/models/validation/test_pascal_camel_cases.py @@ -69,12 +69,8 @@ def test_sync_data(self): camel = SyncData.model_validate_json(camel_case_payload, context=ctx) self.assertEqual(len(pascal.Ciphers), len(camel.Ciphers)) self.assertEqual(len(pascal.Collections), len(camel.Collections)) - self.assertEqual( - pascal.Collections[0].get("Name"), camel.Collections[0].get("name") - ) - self.assertEqual( - pascal.Collections[1].get("Name"), camel.Collections[1].get("name") - ) + self.assertEqual(pascal.Collections[0].Name, camel.Collections[0].Name) + self.assertEqual(pascal.Collections[1].Name, camel.Collections[1].Name) def test_admin_users(self): pascal_case_payload = self.read_json_payload( From 204de2f8b7d7b071785021de9c202c7af051607c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Thu, 25 Jun 2026 06:24:21 +0200 Subject: [PATCH 35/35] sync - Folder uses Profile.Key --- src/vaultwarden/models/sync.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/vaultwarden/models/sync.py b/src/vaultwarden/models/sync.py index 3405316..458160c 100644 --- a/src/vaultwarden/models/sync.py +++ b/src/vaultwarden/models/sync.py @@ -226,7 +226,7 @@ def val_set_key( class Folder(PermissiveBaseModel): Id: UUID | None = None - Name: str | None = None + Name: SecretString | None = None object: Literal["folder"] revisionDate: datetime.datetime | None = None @@ -257,3 +257,12 @@ def val_set_key( r = handler(data) ctx.pop() return r + + @field_validator("Profile", mode="after") + @classmethod + def val_set_Profile_Key( # noqa: N802 + cls, data: UserProfile, info: ValidationInfo[Self] + ): + ctx: CryptoContext = cast(CryptoContext, info.context) + ctx.push(data.Key) + return data