Source code for cadrumo.application.user_profile.bundle_encryption
"""Passphrase encryption for portable profile-bundle exports."""from__future__importannotationsimportbase64importbinasciifromtypingimportFinalfrompydanticimportBaseModel,Field,field_validator,model_validatorfrom...core.errors.hierarchyimportpydantic_validation_boundaryfrom...core.external_constantsimportUTF_8_ENCODINGfrom...core.modelsimportSTRICT_FROZEN_CONFIGas_STRICT_FROZENfrom...domain.user_profile.portable_exportimportUserProfilePortableExportfrom.custody_portsimport(default_profile_record_crypto_port,)_ENCRYPTED_BUNDLE_ENVELOPE_SCHEMA_VERSION:Final[int]=1_ENCRYPTED_BUNDLE_PAYLOAD_MODEL:Final[str]="UserProfilePortableExport"_ENCRYPTED_BUNDLE_KDF:Final[str]="argon2id"_ENCRYPTED_BUNDLE_AAD=b"cadrumo.user-profile.portable-export.v1"
[docs]classEncryptedProfileBundleExport(BaseModel):"""Encrypted transport envelope for a serialized profile-bundle payload. The ciphertext wraps the exact ``UserProfilePortableExport`` JSON bytes. The envelope schema is transport metadata only; after decryption, callers still validate the original bundle model and its ``bundle_schema_version``. Every marker that routes how the ciphertext is interpreted -- ``encrypted_bundle_schema_version``, ``payload_model``, ``kdf`` and ``kdf_version`` -- is required and compared for equality on the read path. The first three carry no default, and a default equal to the accepted value makes that comparison blind to the one payload it exists to catch: a stored envelope omitting the key hydrates AS the accepted value and passes a check the writer never actually made. Every field on this record is stamped explicitly by :func:`encrypt_profile_bundle_for_passphrase`. The three Argon2 cost axes and the salt length carry the same window as the composed persistence KDF policy that the writer stamps them from. They were bare integers and a bare string, which made this the one storage KDF record that could describe an arbitrarily weak derivation: eight kibibytes, one iteration, a one-byte salt. The writer never emitted such a set, so nothing this build wrote was weak -- what the looseness cost was that a re-exported envelope could circulate as an application-grade encrypted bundle while being brute-forceable, and that a structurally invalid salt was indistinguishable at the boundary from a wrong passphrase, sending an operator to recover a passphrase that was never wrong. Reading the window through the application crypto port rather than restating it is what keeps the bound this record validates against from drifting away from the parameters the writer stamps. """model_config=_STRICT_FROZENencrypted_bundle_schema_version:int=Field(ge=1)payload_model:str=Field(min_length=1)payload_schema_version:intkdf:str=Field(min_length=1)kdf_version:intmemory_cost:inttime_cost:intparallelism:intsalt_b64:strciphertext_b64:str@field_validator("salt_b64")@classmethod@pydantic_validation_boundarydef_check_salt_b64(cls,value:str)->str:"""Refuse a salt that is not canonical base64."""try:base64.b64decode(value.encode("ascii"),validate=True)except(UnicodeEncodeError,binascii.Error)asexc:raiseValueError("salt_b64 must be canonical base64")fromexcreturnvalue@model_validator(mode="after")@pydantic_validation_boundarydef_check_kdf_window(self)->EncryptedProfileBundleExport:"""Refuse KDF costs and salt lengths outside the composed policy."""salt=base64.b64decode(self.salt_b64.encode("ascii"),validate=True)crypto=default_profile_record_crypto_port()policy=crypto.passphrase_kdf_policy()iflen(salt)!=policy.salt_bytes:raiseValueError(f"salt_b64 must encode exactly {policy.salt_bytes} bytes")ifnotcrypto.passphrase_kdf_window_accepts(memory_cost=self.memory_cost,time_cost=self.time_cost,parallelism=self.parallelism,salt=salt,):raiseValueError("passphrase KDF parameters are outside the supported window")returnself
[docs]defencrypt_profile_bundle_for_passphrase(bundle:UserProfilePortableExport,*,passphrase:str,)->EncryptedProfileBundleExport:"""Encrypt ``bundle`` under ``passphrase`` and return a transport envelope."""payload=bundle.model_dump_json().encode(UTF_8_ENCODING)encrypted=default_profile_record_crypto_port().seal_with_passphrase(payload,passphrase=passphrase.encode(UTF_8_ENCODING),associated_data=_ENCRYPTED_BUNDLE_AAD,)params=encrypted.parametersreturnEncryptedProfileBundleExport(encrypted_bundle_schema_version=_ENCRYPTED_BUNDLE_ENVELOPE_SCHEMA_VERSION,payload_model=_ENCRYPTED_BUNDLE_PAYLOAD_MODEL,payload_schema_version=bundle.bundle_schema_version,kdf=_ENCRYPTED_BUNDLE_KDF,kdf_version=params.version,memory_cost=params.memory_cost,time_cost=params.time_cost,parallelism=params.parallelism,salt_b64=base64.b64encode(params.salt).decode("ascii"),ciphertext_b64=base64.b64encode(encrypted.blob.to_wire()).decode("ascii"),)