aeat.application.modelo._review_package_recipient_encryption module¶
Encrypt-for-recipient transport for review packages (X25519 ECIES).
This module adds a CONFIDENTIALITY layer on top of the review-package
checksum-integrity (_review_package) and
authenticity (_review_package_signing,
_review_package_counter_sign) layers: a
review package sealed with encrypt_review_package_for_recipient()
can be opened only by the holder of the matching X25519 private key –
unlike sign/counter-sign, which leave the archive itself in
plaintext ZIP form.
Construction (ECIES over the primitives already vetted and already
shipped by this project – no new dependency, see
2026-07-04-recipient-encryption-adr):
A fresh EPHEMERAL X25519 keypair is generated for this one message (
cryptography.hazmat.primitives.asymmetric.x25519.X25519PrivateKey.generate()).ECDH is performed between the ephemeral private key and the recipient’s long-term public key (
RecipientFingerprintRecord), producing a 32-byte shared secret.The shared secret is NEVER used directly as an AEAD key. It is run through
derive_key()(HKDF-SHA256), with the HKDFsaltset to the ephemeral public key and thecontext(HKDFinfo) bound to a fixed domain-separation string PLUS the recipient’s public key – so a derived key can never be reused across a different ephemeral sender key or a different recipient.The package bytes are AEAD-encrypted via
encrypt_record()(AES-256-GCM), with the recipient’s public key bound into the associated data – so a ciphertext cannot be silently re-targeted at a different recipient’s key without the AEAD tag failing to verify.The returned
RecipientEncryptedPackageenvelope carries the ephemeral public key, the recipient’s public key, and the AEAD wire bytes – everything the recipient needs to reverse the ECDH and decrypt, and nothing that identifies the sender (no long-term sender keypair is required or persisted for this direction).
Key custody (sensitive-financial-data-secure-storage-only): the
review-package bytes are read into memory, encrypted in memory, and the
CIPHERTEXT envelope is the only artefact this module returns to the
caller; the caller is responsible for writing the envelope bytes to its
requested output path. Nothing is staged to a temp file. The recipient’s
public key carries no secrecy requirement (it is looked up from
RecipientFingerprintRegistryRepository);
the ephemeral sender private key exists only for the duration of one
call and is never persisted.
Expiry and replay defence (2026-07-04-recipient-encryption-adr, the
review-only/expiry/replay follow-up slice): every envelope carries an
issued_at timestamp, an optional valid_until deadline, a random
envelope_nonce_hex (independent of the AEAD nonce embedded in
ciphertext, minted purely as a replay-detection token), and a
review_only flag.
Expiry is checked entirely inside
decrypt_review_package_for_recipient()against an explicit, caller-suppliednow(never the wall clock read directly by this module, so the check is deterministic and testable): a package presented after itsvalid_untildeadline is refused before AEAD decryption is even attempted. Avalid_until=Noneenvelope never expires.Replay defence is a TWO-PARTY contract this module only half-owns: the envelope’s
envelope_nonce_hexis the token a caller checks againstRecipientReplayGuardRepository(a persisted, bucket-scoped consumed-nonce ledger) before or after callingdecrypt_review_package_for_recipient()– this module mints and carries the nonce but performs no persistence itself (this is thecomposition-service-no-parallel-write-pathboundary: encryption and decryption stay pure in-memory primitives, and the CLI decrypt-side composition owns the ledger check).Review-only mode (
review_only=True) asserts the sealed package carries no filing authority: the recipient may read and verify it, but it is NOT evidence that the underlying revision has been (or will be) filed with AEAT.decrypt_review_package_for_recipient()returns a typedRecipientDecryptedPackage(bytes plus thereview_onlyflag) rather than barebytes, so a downstream consumer cannot lose the flag and mistake a review-only handoff for a filing artefact.
Recipient’s own keypair (mint-or-load, symmetric to
ensure_review_package_signing_keypair()): a
recipient (the accountant running decrypt_review_package_for_recipient()
against a package sealed for them) needs their OWN X25519 private key, matching
the public key a taxpayer registered via
RecipientFingerprintRegistryRepository.
ensure_recipient_encryption_keypair() mints one on first use and persists
it – private key included – ONLY as ciphertext through a
SecureObjectRepository, at
SensitivityClass SECRET
(MODELO_REVIEW_PACKAGE_RECIPIENT_ENCRYPTION_KEY_NAMESPACE),
exactly as the Ed25519 signing keypair is minted and stored. It is never
logged, never written to a plaintext file, and never leaves this module as raw
bytes except transiently in process memory to decrypt. The exportable public
half (recipient_encryption_public_key()) is what a taxpayer registers via
the fingerprint registry – never the private key.
See also
_review_package_recipient_registryWhere a recipient’s trusted public key is registered and looked up before calling this module.
_review_package_recipient_replay_guardThe consumed-nonce ledger a caller composes around
decrypt_review_package_for_recipient()for replay defence._review_packageBuilds and integrity-verifies the review package this module encrypts.
ensure_review_package_signing_keypair()The Ed25519 signing-keypair primitive this module’s
ensure_recipient_encryption_keypair()mirrors exactly (mint-once, persist-as-ciphertext, idempotent-reuse), for a distinct purpose (encryption, never signing – see the ADR’s rejection of key reuse across purposes).
- exception RecipientEncryptionError(message=None, *, context=None, suggestion=None, translated_message=None)[source]¶
Bases:
AeatErrorBase error for review-package recipient-encryption failures.
- Parameters:
- Return type:
None
- code: ClassVar[ErrorCode]¶
- exception RecipientDecryptionError(message=None, *, context=None, suggestion=None, translated_message=None)[source]¶
Bases:
RecipientEncryptionErrorRaised when a recipient-encrypted package fails to decrypt.
Covers cryptographic AEAD-tag failure (tampered ciphertext or wrong private key), a mismatched recipient public key (the caller’s private key does not correspond to the envelope’s declared recipient public key), and an expired
valid_untildeadline – never distinguished further, so an attacker cannot use error content to learn which check failed.- Parameters:
- Return type:
None
- code: ClassVar[ErrorCode]¶
- exception RecipientEncryptionKeyNotFoundError(message=None, *, context=None, suggestion=None, translated_message=None)[source]¶
Bases:
RecipientEncryptionErrorRaised when no encryption keypair has been minted for a bucket yet.
Callers should mint one via
ensure_recipient_encryption_keypair()before loading it explicitly.- Parameters:
- Return type:
None
- code: ClassVar[ErrorCode]¶
- class RecipientEncryptionKeypair(**data)[source]¶
Bases:
BaseModelA bucket’s X25519 encryption keypair, private key included.
This model is the PLAINTEXT in-memory shape used only transiently around generation, persistence, and decryption;
private_key()/public_key()reconstruct livecryptographykey objects from the stored raw hex bytes. The caller (ensure_recipient_encryption_keypair()) is responsible for persisting it only throughSecureObjectRepository, mirroringReviewPackageSigningKeypairexactly – a distinct keypair, for a distinct purpose (encryption, never signing).- bucket_id: str¶
- private_key_hex: str¶
- public_key_hex: str¶
- created_at: datetime¶
- class RecipientEncryptionPublicKey(**data)[source]¶
Bases:
BaseModelThe exportable, non-secret half of a bucket’s encryption keypair.
Safe to hand to a taxpayer so they can register it via
RecipientFingerprintRegistryRepository. Carries no secrecy requirement – unlikeRecipientEncryptionKeypair, this model is fine to print, write to a plaintext file, or read aloud for out-of-band fingerprint verification.- bucket_id: str¶
- public_key_hex: str¶
- created_at: datetime¶
- ensure_recipient_encryption_keypair(*, bucket_id, repository, generated_at=None)[source]¶
Return the bucket’s X25519 encryption keypair, minting one on first use.
Mirrors
ensure_review_package_signing_keypair()exactly: loads the existing keypair fromMODELO_REVIEW_PACKAGE_RECIPIENT_ENCRYPTION_KEY_NAMESPACEwhen present; otherwise generates a fresh keypair viaX25519PrivateKey.generate(), persists it (private key included) as ciphertext, and returns it. Idempotent: a second call against the same bucket returns the SAME keypair rather than rotating it, so a package sealed for the recipient’s public key today still decrypts next week.- Parameters:
bucket_id (
str) – The bucket this keypair is scoped to (the recipient’s own profile bucket, resolved the same way the signing keypair is).repository (
SecureObjectRepository) – The bucket’sSecureObjectRepository.generated_at (
datetime|None) – Optional override for the keypair’screated_attimestamp (tests only); defaults to the current UTC time.
- Return type:
- load_recipient_encryption_keypair(*, bucket_id, repository)[source]¶
Load the bucket’s existing X25519 encryption keypair.
- Parameters:
bucket_id (
str) – The bucket this keypair is scoped to.repository (
SecureObjectRepository) – The bucket’sSecureObjectRepository.
- Raises:
RecipientEncryptionKeyNotFoundError – If no keypair has been minted yet for
bucket_id. Callensure_recipient_encryption_keypair()first.- Return type:
- recipient_encryption_public_key(keypair)[source]¶
Project the exportable public half out of a full keypair.
The projection never touches
private_key_hex; the returned model is safe to hand to a taxpayer to register via the fingerprint registry.- Return type:
- Parameters:
keypair (RecipientEncryptionKeypair)
- class RecipientEncryptedPackage(**data)[source]¶
Bases:
BaseModelWire envelope for a review package encrypted for one recipient.
ephemeral_public_key_hexandrecipient_public_key_hexare both raw 32-byte X25519 public keys, hex-encoded.ciphertextis the AEAD wire form (nonce || ciphertext_with_tag) produced byencrypt_record(), held as rawbyteson the Python object (matching every in-process caller in this module) but hex-encoded on the JSON boundary (model_dump_json()/model_dump(mode="json")) – pydantic’s default JSON encoding forbytesassumes valid UTF-8, which arbitrary AEAD ciphertext is not, so a barebytesfield would raisePydanticSerializationErrorthe first time a caller (e.g. the CLIencrypt-for-recipientverb) writes the envelope to disk as JSON.model_validate_json()accepts the hex form it produced; the plain-Python constructor still accepts rawbytesdirectly.envelope_nonce_hexis a replay-detection token, independent of the AEAD nonce embedded inciphertext: a caller checks it againstRecipientReplayGuardRepositoryto refuse a package presented more than once.issued_at/valid_untilbound the envelope’s validity window (valid_untilofNonemeans the envelope never expires); the deadline is checked insidedecrypt_review_package_for_recipient()against an explicit caller-suppliednow, never the wall clock read by this module.review_onlyasserts the sealed package carries no filing authority – see the module docstring.- Parameters:
- envelope_version: int¶
- ephemeral_public_key_hex: str¶
- recipient_public_key_hex: str¶
- ciphertext: bytes¶
- envelope_nonce_hex: str¶
- issued_at: datetime¶
- valid_until: datetime | None¶
- review_only: bool¶
- encrypt_review_package_for_recipient(package_bytes, *, recipient_public_key_hex, review_only=False, valid_for=None, issued_at=None)[source]¶
Seal
package_bytesso onlyrecipient_public_key_hex’s holder can open it.Generates a fresh ephemeral X25519 keypair, performs ECDH against the recipient’s public key, derives a per-message AES-256-GCM key via HKDF-SHA256, and encrypts. See the module docstring for the full construction. Never writes
package_bytesor the derived key to disk; the caller is responsible for persisting the returned envelope’s bytes.- Parameters:
package_bytes (
bytes) – The plaintext review-package archive bytes to seal (read into memory by the caller; this function performs no filesystem I/O).recipient_public_key_hex (
str) – The recipient’s raw 32-byte X25519 public key, hex-encoded (seeRecipientFingerprintRecord).review_only (
bool) – WhenTrue, marks the sealed package as carrying no filing authority – see the module docstring. Defaults toFalse(a normal filing-grade handoff).valid_for (
timedelta|None) – Optional validity window measured fromissued_at. When supplied, the envelope’svalid_untilisissued_at + valid_foranddecrypt_review_package_for_recipient()refuses the package once that deadline has passed.None(the default) produces an envelope that never expires.issued_at (
datetime|None) – Optional override for the envelope’sissued_attimestamp (tests only); defaults to the current UTC time.
- Raises:
RecipientEncryptionError – If
recipient_public_key_hexis not a well-formed X25519 public key, or ifvalid_foris not a strictly positive duration.- Return type:
- exception RecipientPackageExpiredError(message=None, *, context=None, suggestion=None, translated_message=None)[source]¶
Bases:
RecipientDecryptionErrorRaised when a recipient-encrypted package is presented past its
valid_untildeadline.A subclass of
RecipientDecryptionError(rather than a sibling) so an existingexcept RecipientDecryptionErrorcatch-all keeps working verbatim; callers that need to distinguish expiry from a cryptographic failure may catch this subclass specifically, though the ADR’s undifferentiated-failure posture means the rendered message is identical either way.- Parameters:
- Return type:
None
- code: ClassVar[ErrorCode]¶
- class RecipientDecryptedPackage(**data)[source]¶
Bases:
BaseModelRecovered plaintext bytes plus the envelope’s carried disposition flags.
Returned by
decrypt_review_package_for_recipient()instead of barebytesso a downstream consumer cannot lose thereview_onlyflag and mistake a review-only handoff for a filing-grade artefact.- package_bytes: bytes¶
- review_only: bool¶
- decrypt_review_package_for_recipient(envelope, *, recipient_private_key, now=None)[source]¶
Reverse
encrypt_review_package_for_recipient()and return the package bytes.Reconstructs the same derived AEAD key by performing ECDH between
recipient_private_keyand the envelope’s ephemeral public key, then decrypts and authenticates. A wrongrecipient_private_key(or any tampering ofenvelope.ciphertextor the declared public keys) fails AEAD authentication. Before any cryptographic work, the envelope’svalid_untildeadline (when set) is checked againstnow; an expired envelope is refused without attempting decryption.Replay defence is NOT performed here: this function is a pure encrypt/decrypt primitive with no persistence dependency (
composition-service-no-parallel-write-path). A caller that needs replay defence composesRecipientReplayGuardRepositoryaround this call, keyed onenvelope.envelope_nonce_hex.- Parameters:
envelope (
RecipientEncryptedPackage) – TheRecipientEncryptedPackageproduced byencrypt_review_package_for_recipient().recipient_private_key (
X25519PrivateKey) – The recipient’s own X25519 private key.now (
datetime|None) – The instant to evaluateenvelope.valid_untilagainst. Defaults to the current UTC time; tests inject an explicit value rather than relying on the wall clock.
- Return type:
- Returns:
A
RecipientDecryptedPackagecarrying the original plaintext review-package archive bytes and the envelope’sreview_onlydisposition.- Raises:
RecipientPackageExpiredError – If
envelope.valid_untilis set andnowis at or past that deadline.RecipientDecryptionError – If
recipient_private_keydoes not match the envelope’s declared recipient public key, or the ciphertext fails AEAD authentication for any reason (tampering, corruption, wrong key).