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):

  1. A fresh EPHEMERAL X25519 keypair is generated for this one message (cryptography.hazmat.primitives.asymmetric.x25519.X25519PrivateKey.generate()).

  2. ECDH is performed between the ephemeral private key and the recipient’s long-term public key (RecipientFingerprintRecord), producing a 32-byte shared secret.

  3. The shared secret is NEVER used directly as an AEAD key. It is run through derive_key() (HKDF-SHA256), with the HKDF salt set to the ephemeral public key and the context (HKDF info) 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.

  4. 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.

  5. The returned RecipientEncryptedPackage envelope 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-supplied now (never the wall clock read directly by this module, so the check is deterministic and testable): a package presented after its valid_until deadline is refused before AEAD decryption is even attempted. A valid_until=None envelope never expires.

  • Replay defence is a TWO-PARTY contract this module only half-owns: the envelope’s envelope_nonce_hex is the token a caller checks against RecipientReplayGuardRepository (a persisted, bucket-scoped consumed-nonce ledger) before or after calling decrypt_review_package_for_recipient() – this module mints and carries the nonce but performs no persistence itself (this is the composition-service-no-parallel-write-path boundary: 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 typed RecipientDecryptedPackage (bytes plus the review_only flag) rather than bare bytes, 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_registry

Where a recipient’s trusted public key is registered and looked up before calling this module.

_review_package_recipient_replay_guard

The consumed-nonce ledger a caller composes around decrypt_review_package_for_recipient() for replay defence.

_review_package

Builds 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: AeatError

Base error for review-package recipient-encryption failures.

Parameters:
  • message (str | None)

  • context (Mapping[str, object] | None)

  • suggestion (str | None)

  • translated_message (str | None)

Return type:

None

code: ClassVar[ErrorCode]
exception RecipientDecryptionError(message=None, *, context=None, suggestion=None, translated_message=None)[source]

Bases: RecipientEncryptionError

Raised 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_until deadline – never distinguished further, so an attacker cannot use error content to learn which check failed.

Parameters:
  • message (str | None)

  • context (Mapping[str, object] | None)

  • suggestion (str | None)

  • translated_message (str | None)

Return type:

None

code: ClassVar[ErrorCode]
exception RecipientEncryptionKeyNotFoundError(message=None, *, context=None, suggestion=None, translated_message=None)[source]

Bases: RecipientEncryptionError

Raised 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:
  • message (str | None)

  • context (Mapping[str, object] | None)

  • suggestion (str | None)

  • translated_message (str | None)

Return type:

None

code: ClassVar[ErrorCode]
class RecipientEncryptionKeypair(**data)[source]

Bases: BaseModel

A 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 live cryptography key objects from the stored raw hex bytes. The caller (ensure_recipient_encryption_keypair()) is responsible for persisting it only through SecureObjectRepository, mirroring ReviewPackageSigningKeypair exactly – a distinct keypair, for a distinct purpose (encryption, never signing).

Parameters:
bucket_id: str
private_key_hex: str
public_key_hex: str
created_at: datetime
private_key()[source]

Reconstruct the live X25519PrivateKey from stored raw bytes.

Return type:

X25519PrivateKey

public_key()[source]

Reconstruct the live X25519PublicKey from stored raw bytes.

Return type:

X25519PublicKey

class RecipientEncryptionPublicKey(**data)[source]

Bases: BaseModel

The 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 – unlike RecipientEncryptionKeypair, this model is fine to print, write to a plaintext file, or read aloud for out-of-band fingerprint verification.

Parameters:
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 from MODELO_REVIEW_PACKAGE_RECIPIENT_ENCRYPTION_KEY_NAMESPACE when present; otherwise generates a fresh keypair via X25519PrivateKey.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’s SecureObjectRepository.

  • generated_at (datetime | None) – Optional override for the keypair’s created_at timestamp (tests only); defaults to the current UTC time.

Return type:

RecipientEncryptionKeypair

load_recipient_encryption_keypair(*, bucket_id, repository)[source]

Load the bucket’s existing X25519 encryption keypair.

Parameters:
Raises:

RecipientEncryptionKeyNotFoundError – If no keypair has been minted yet for bucket_id. Call ensure_recipient_encryption_keypair() first.

Return type:

RecipientEncryptionKeypair

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:

RecipientEncryptionPublicKey

Parameters:

keypair (RecipientEncryptionKeypair)

class RecipientEncryptedPackage(**data)[source]

Bases: BaseModel

Wire envelope for a review package encrypted for one recipient.

ephemeral_public_key_hex and recipient_public_key_hex are both raw 32-byte X25519 public keys, hex-encoded. ciphertext is the AEAD wire form (nonce || ciphertext_with_tag) produced by encrypt_record(), held as raw bytes on 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 for bytes assumes valid UTF-8, which arbitrary AEAD ciphertext is not, so a bare bytes field would raise PydanticSerializationError the first time a caller (e.g. the CLI encrypt-for-recipient verb) writes the envelope to disk as JSON. model_validate_json() accepts the hex form it produced; the plain-Python constructor still accepts raw bytes directly.

envelope_nonce_hex is a replay-detection token, independent of the AEAD nonce embedded in ciphertext: a caller checks it against RecipientReplayGuardRepository to refuse a package presented more than once. issued_at / valid_until bound the envelope’s validity window (valid_until of None means the envelope never expires); the deadline is checked inside decrypt_review_package_for_recipient() against an explicit caller-supplied now, never the wall clock read by this module. review_only asserts 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)

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_bytes so only recipient_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_bytes or 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 (see RecipientFingerprintRecord).

  • review_only (bool) – When True, marks the sealed package as carrying no filing authority – see the module docstring. Defaults to False (a normal filing-grade handoff).

  • valid_for (timedelta | None) – Optional validity window measured from issued_at. When supplied, the envelope’s valid_until is issued_at + valid_for and decrypt_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’s issued_at timestamp (tests only); defaults to the current UTC time.

Raises:

RecipientEncryptionError – If recipient_public_key_hex is not a well-formed X25519 public key, or if valid_for is not a strictly positive duration.

Return type:

RecipientEncryptedPackage

exception RecipientPackageExpiredError(message=None, *, context=None, suggestion=None, translated_message=None)[source]

Bases: RecipientDecryptionError

Raised when a recipient-encrypted package is presented past its valid_until deadline.

A subclass of RecipientDecryptionError (rather than a sibling) so an existing except RecipientDecryptionError catch-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:
  • message (str | None)

  • context (Mapping[str, object] | None)

  • suggestion (str | None)

  • translated_message (str | None)

Return type:

None

code: ClassVar[ErrorCode]
class RecipientDecryptedPackage(**data)[source]

Bases: BaseModel

Recovered plaintext bytes plus the envelope’s carried disposition flags.

Returned by decrypt_review_package_for_recipient() instead of bare bytes so a downstream consumer cannot lose the review_only flag and mistake a review-only handoff for a filing-grade artefact.

Parameters:
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_key and the envelope’s ephemeral public key, then decrypts and authenticates. A wrong recipient_private_key (or any tampering of envelope.ciphertext or the declared public keys) fails AEAD authentication. Before any cryptographic work, the envelope’s valid_until deadline (when set) is checked against now; 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 composes RecipientReplayGuardRepository around this call, keyed on envelope.envelope_nonce_hex.

Parameters:
Return type:

RecipientDecryptedPackage

Returns:

A RecipientDecryptedPackage carrying the original plaintext review-package archive bytes and the envelope’s review_only disposition.

Raises:
  • RecipientPackageExpiredError – If envelope.valid_until is set and now is at or past that deadline.

  • RecipientDecryptionError – If recipient_private_key does not match the envelope’s declared recipient public key, or the ciphertext fails AEAD authentication for any reason (tampering, corruption, wrong key).