Source code for aeat.application.modelo._review_package_signing

"""Ed25519 signing and signature verification for review packages.

This module implements the signing slice deferred by
:mod:`~application.modelo._review_package`: that module's
:func:`~application.modelo.verify_review_package` is an INTEGRITY check
only (did every archived member arrive byte-for-byte as built); it makes no
claim about WHO built the package. This module adds the AUTHENTICITY layer on
top of that integrity check by signing the package's self-attesting
:attr:`~core.corpus_manifest.CorpusManifest.manifest_sha256` digest with a
per-profile Ed25519 keypair.

Signing the manifest digest (not the archive bytes or a re-derived hash) means
the signature transitively covers every archived member: the digest is a
SHA-256 over the canonical JSON of the manifest's per-file entries, and a
tampered member is already caught by
:func:`~core.corpus_manifest.verify_corpus_bundle` before signature
verification is even attempted (see :func:`verify_review_package_signature`).

Key custody (``sensitive-financial-data-secure-storage-only`` /
``no-legacy-compatibility``): the private key is generated once per profile
bucket and persisted ONLY as ciphertext through
:class:`~adapters.persistence.storage.SecureObjectRepository` at
:attr:`~adapters.persistence.storage.SensitivityClass.SECRET` sensitivity
(:data:`~adapters.persistence.storage.MODELO_REVIEW_PACKAGE_SIGNING_KEY_NAMESPACE`).
It is never logged, never written to a plaintext file, and never leaves this
module as raw bytes except transiently in process memory to sign. The public
key is, by construction, safe to export and hand to a receiving accountant for
signature verification -- it carries no secrecy requirement.

Counter-signed accountant feedback-package round trips remain OUT OF SCOPE for
this module; it exposes only the primitive: mint/load a per-profile keypair,
sign a package's manifest digest, verify a signature against a public key.

See Also:
    :mod:`~application.modelo._review_package`
        Builds and integrity-verifies the review package this module signs.
    :class:`~adapters.persistence.storage.SecureObjectRepository`
        Encrypted substrate the private key is persisted through.
    :class:`~adapters.persistence.storage.SensitivityClass`
        Storage classification policy used for the persisted signing keypair.
"""

from __future__ import annotations

from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING

from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
    Ed25519PrivateKey,
    Ed25519PublicKey,
)
from cryptography.hazmat.primitives.serialization import (
    Encoding,
    NoEncryption,
    PrivateFormat,
    PublicFormat,
)
from pydantic import BaseModel, Field, field_validator

from ...adapters.persistence.storage import MODELO_REVIEW_PACKAGE_SIGNING_KEY_NAMESPACE as _NAMESPACE
from ...adapters.persistence.storage import SensitivityClass
from ...core import STRICT_FROZEN_CONFIG as _STRICT_FROZEN
from ...core.corpus_manifest import CorpusBundleError, CorpusManifestTamperError, verify_corpus_bundle
from ...core.errors import AeatError
from ...core.external_constants import UTF_8_ENCODING
from ...core.time import now as _utc_now
from ._review_package import assert_review_package_verifies

if TYPE_CHECKING:
    from ...adapters.persistence.storage import SecureObjectRepository

#: Wire-format version of the signature envelope. Bumped when the envelope
#: schema changes shape (e.g. a future multi-signer / counter-sign extension).
_SIGNATURE_ENVELOPE_VERSION = 1

#: Raw Ed25519 signature size, per RFC 8032. Used to validate hex-encoded
#: signature material at the pydantic boundary rather than trusting the caller.
_ED25519_SIGNATURE_BYTES = 64

_HEX_PATTERN_64 = r"^[0-9a-f]{64}$"
_HEX_PATTERN_128 = r"^[0-9a-f]{128}$"


[docs] class ReviewPackageSigningError(AeatError): """Base error for review-package signing/verification failures."""
[docs] class ReviewPackageSigningKeyNotFoundError(ReviewPackageSigningError): """Raised when no signing keypair has been minted for a profile bucket yet. Callers should mint one via :func:`ensure_review_package_signing_keypair` before signing a package for the first time in a given profile. """
[docs] class ReviewPackageSigningKeypair(BaseModel): """A profile's Ed25519 signing keypair, private key included. This model is the PLAINTEXT in-memory shape used only transiently around generation, persistence, and signing; :meth:`private_key` / :meth:`public_key` reconstruct live ``cryptography`` key objects from the stored raw hex bytes. Nothing about this model changes the secure-storage contract: the caller (:func:`ensure_review_package_signing_keypair`) is responsible for persisting it only through :class:`~adapters.persistence.storage.SecureObjectRepository`. """ model_config = _STRICT_FROZEN bucket_id: str = Field(min_length=1) private_key_hex: str = Field(pattern=_HEX_PATTERN_64) public_key_hex: str = Field(pattern=_HEX_PATTERN_64) created_at: datetime
[docs] def private_key(self) -> Ed25519PrivateKey: """Reconstruct the live :class:`Ed25519PrivateKey` from stored raw bytes.""" return Ed25519PrivateKey.from_private_bytes(bytes.fromhex(self.private_key_hex))
[docs] def public_key(self) -> Ed25519PublicKey: """Reconstruct the live :class:`Ed25519PublicKey` from stored raw bytes.""" return Ed25519PublicKey.from_public_bytes(bytes.fromhex(self.public_key_hex))
[docs] class ReviewPackageSigningPublicKey(BaseModel): """The exportable, non-secret half of a profile's signing keypair. Safe to hand to a receiving accountant so they can verify a package's signature independently. Carries no secrecy requirement -- unlike :class:`ReviewPackageSigningKeypair`, this model is fine to write to a plaintext file, print, or transmit. """ model_config = _STRICT_FROZEN bucket_id: str = Field(min_length=1) public_key_hex: str = Field(pattern=_HEX_PATTERN_64) created_at: datetime
[docs] class SignedReviewPackage(BaseModel): """Signature envelope binding a review package's manifest digest to a signer. ``manifest_sha256`` is the review package's own self-attesting :attr:`~core.corpus_manifest.CorpusManifest.manifest_sha256` (recovered via :func:`~application.modelo.verify_review_package`), NOT a re-derived hash of the archive bytes: signing the manifest digest transitively covers every archived member because the manifest digest already covers every per-file checksum record. """ model_config = _STRICT_FROZEN envelope_version: int = Field(default=_SIGNATURE_ENVELOPE_VERSION, ge=1) bucket_id: str = Field(min_length=1) calculation_revision_id: str = Field(min_length=1) manifest_sha256: str = Field(pattern=_HEX_PATTERN_64) signature_hex: str = Field(pattern=_HEX_PATTERN_128) public_key_hex: str = Field(pattern=_HEX_PATTERN_64) signed_at: datetime @field_validator("signature_hex") @classmethod def _signature_is_ed25519_length(cls, value: str) -> str: if len(bytes.fromhex(value)) != _ED25519_SIGNATURE_BYTES: raise ValueError(f"Ed25519 signature must be {_ED25519_SIGNATURE_BYTES} bytes") return value
def _signing_key_object_key(bucket_id: str) -> str: """Return the natural :class:`~adapters.persistence.storage.SecureObjectRepository` key for ``bucket_id``'s keypair. Matches the namespace's declared ``object_key_grammar="review-package-signing-key:{bucket_id}"``. """ return f"review-package-signing-key:{bucket_id.strip()}" def _keypair_from_repository_payload(payload: bytes, *, bucket_id: str) -> ReviewPackageSigningKeypair: return ReviewPackageSigningKeypair.model_validate_json(payload)
[docs] def ensure_review_package_signing_keypair( *, bucket_id: str, repository: SecureObjectRepository, generated_at: datetime | None = None, ) -> ReviewPackageSigningKeypair: """Return the profile's Ed25519 signing keypair, minting one on first use. Loads the existing keypair from :data:`~adapters.persistence.storage.MODELO_REVIEW_PACKAGE_SIGNING_KEY_NAMESPACE` when present; otherwise generates a fresh keypair via ``Ed25519PrivateKey.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 signed today verifies against a keypair fetched next week. Args: bucket_id: The active profile bucket id this keypair is scoped to. repository: The bucket's :class:`~adapters.persistence.storage.SecureObjectRepository`, e.g. obtained via :func:`~adapters.persistence.storage.secure_object_repository_for_active_bucket`. generated_at: Optional override for the keypair's ``created_at`` timestamp (tests only); defaults to the current UTC time. """ object_key = _signing_key_object_key(bucket_id) existing = repository.load( _NAMESPACE.namespace, object_key, expected_class=SensitivityClass.SECRET, max_supported_version=_NAMESPACE.schema_version, ) if existing is not None: return _keypair_from_repository_payload(existing.payload, bucket_id=bucket_id) private_key = Ed25519PrivateKey.generate() public_key = private_key.public_key() keypair = ReviewPackageSigningKeypair( bucket_id=bucket_id, private_key_hex=private_key.private_bytes( encoding=Encoding.Raw, format=PrivateFormat.Raw, encryption_algorithm=NoEncryption(), ).hex(), public_key_hex=public_key.public_bytes( encoding=Encoding.Raw, format=PublicFormat.Raw, ).hex(), created_at=generated_at or _utc_now(), ) repository.save( namespace=_NAMESPACE.namespace, object_key=object_key, classification=SensitivityClass.SECRET, schema_version=_NAMESPACE.schema_version, written_at=keypair.created_at, payload=keypair.model_dump_json().encode(UTF_8_ENCODING), write_provenance="application.modelo.review_package_signing.ensure_keypair", ) return keypair
[docs] def load_review_package_signing_keypair( *, bucket_id: str, repository: SecureObjectRepository, ) -> ReviewPackageSigningKeypair: """Load the profile's existing Ed25519 signing keypair. Args: bucket_id: The profile bucket whose signing keypair is loaded. repository: The bucket's :class:`~adapters.persistence.storage.SecureObjectRepository`. Raises: ReviewPackageSigningKeyNotFoundError: If no keypair has been minted yet for ``bucket_id``. Call :func:`ensure_review_package_signing_keypair` first. """ object_key = _signing_key_object_key(bucket_id) record = repository.load( _NAMESPACE.namespace, object_key, expected_class=SensitivityClass.SECRET, max_supported_version=_NAMESPACE.schema_version, ) if record is None: raise ReviewPackageSigningKeyNotFoundError( translated_message="application.modelo.errors.review_package_generic", context={"bucket_id": bucket_id}, ) return _keypair_from_repository_payload(record.payload, bucket_id=bucket_id)
[docs] def review_package_signing_public_key( keypair: ReviewPackageSigningKeypair, ) -> ReviewPackageSigningPublicKey: """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 receiving accountant. """ return ReviewPackageSigningPublicKey( bucket_id=keypair.bucket_id, public_key_hex=keypair.public_key_hex, created_at=keypair.created_at, )
[docs] def sign_review_package( package_path: Path, *, keypair: ReviewPackageSigningKeypair, signed_at: datetime | None = None, ) -> SignedReviewPackage: """Verify ``package_path``'s checksum manifest, then sign its digest. Delegates the integrity check entirely to :func:`~application.modelo.assert_review_package_verifies` (no hashing logic is re-derived here): a package that is not checksum-clean raises before any signature is produced, so a signature can never be minted over a package this module itself cannot vouch is intact. Args: package_path: Path to the review-package ZIP to sign. keypair: The signer's :class:`ReviewPackageSigningKeypair` (see :func:`ensure_review_package_signing_keypair`). signed_at: Optional override for the envelope's ``signed_at`` timestamp (tests only); defaults to the current UTC time. Raises: FileNotFoundError: If ``package_path`` does not exist. ReviewPackageIntegrityError: If the package fails checksum-manifest verification (propagated from :func:`~application.modelo.assert_review_package_verifies`). """ manifest = assert_review_package_verifies(package_path) manifest_sha256 = _package_manifest_sha256(package_path) signature = keypair.private_key().sign(bytes.fromhex(manifest_sha256)) return SignedReviewPackage( bucket_id=manifest.bucket_id, calculation_revision_id=manifest.calculation_revision_id, manifest_sha256=manifest_sha256, signature_hex=signature.hex(), public_key_hex=keypair.public_key_hex, signed_at=signed_at or _utc_now(), )
[docs] def verify_review_package_signature( package_path: Path, signed_package: SignedReviewPackage, *, public_key_hex: str, ) -> bool: """Verify ``signed_package``'s signature against ``public_key_hex``. Re-runs the checksum-manifest integrity check (a fresh :func:`~core.corpus_manifest.verify_corpus_bundle` call, not a trust of ``signed_package.manifest_sha256``) first. This matters because ``manifest_sha256`` is a digest over the embedded manifest's OWN recorded per-file hashes -- it does NOT change if an archived member's bytes are swapped without touching the manifest metadata; only the ``mismatched`` check (re-hashing every archived file against its manifest record) catches that tamper. So a package whose CURRENT bytes are not checksum-clean, or whose current manifest digest no longer matches the signed digest, fails here even before the Ed25519 check runs. Args: package_path: Path to the review-package ZIP to verify. signed_package: The :class:`SignedReviewPackage` envelope produced by :func:`sign_review_package`. public_key_hex: The signer's raw public key, as 64 lowercase hex characters (see :attr:`~ReviewPackageSigningPublicKey.public_key_hex`). Passed explicitly (never read off ``signed_package``) so a verifier must supply the key it actually trusts, rather than trusting whatever key the envelope claims. Returns: ``True`` iff the package is currently checksum-clean, its manifest digest matches the signed digest, AND the Ed25519 signature verifies against ``public_key_hex``. Returns ``False`` (never raises) on any mismatch or invalid-signature outcome -- signature verification is an authenticity *check*, not an assertion; callers that want a raising assertion should call :func:`sign_review_package`'s sibling :func:`~application.modelo.assert_review_package_verifies` first and test this function's boolean themselves. """ try: result = verify_corpus_bundle(package_path) except (CorpusBundleError, CorpusManifestTamperError): return False if not result.is_clean: return False if result.manifest.manifest_sha256 != signed_package.manifest_sha256: return False public_key = Ed25519PublicKey.from_public_bytes(bytes.fromhex(public_key_hex)) try: public_key.verify( bytes.fromhex(signed_package.signature_hex), bytes.fromhex(signed_package.manifest_sha256), ) except InvalidSignature: return False return True
def _package_manifest_sha256(package_path: Path) -> str: """Return the package's current self-attesting corpus manifest digest. Re-runs :func:`~core.corpus_manifest.verify_corpus_bundle` directly (the same checksum layer :func:`~application.modelo.verify_review_package` delegates to) so the digest reflects the archive's CURRENT bytes, never a cached value, and is available even when the caller only needs the digest rather than the full review-specific verification result. """ return verify_corpus_bundle(package_path).manifest.manifest_sha256 __all__ = [ "ReviewPackageSigningError", "ReviewPackageSigningKeyNotFoundError", "ReviewPackageSigningKeypair", "ReviewPackageSigningPublicKey", "SignedReviewPackage", "ensure_review_package_signing_keypair", "load_review_package_signing_keypair", "review_package_signing_public_key", "sign_review_package", "verify_review_package_signature", ]