"""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",
]