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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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