Source code for aeat.application.modelo._review_package_counter_sign

"""Counter-signed accountant receipt round trip for signed review packages.

This module implements the counter-sign slice deferred by
:mod:`~application.modelo._review_package_signing`: that module adds an
AUTHENTICITY layer over a review package's checksum manifest by having the
operator sign the manifest digest with their own Ed25519 keypair
(:func:`~application.modelo.sign_review_package`). It makes no claim
about what the RECEIVING accountant did with the package once it arrived.

A :class:`CounterSignedReceipt` closes that loop: the accountant signs a
second, independent Ed25519 signature over the operator's ORIGINAL signature
bytes plus a short free-text note (e.g. a verdict such as "reviewed, no
changes" or "see attached corrections"). Verifying the receipt
(:func:`verify_counter_signed_receipt`) re-checks BOTH layers -- the
operator's original signature against the operator's public key (delegating
to :func:`~application.modelo.verify_review_package_signature`, so the
checksum-manifest integrity re-check happens first, exactly as it does for a
bare :class:`~application.modelo.SignedReviewPackage`), and the
accountant's counter-signature against the accountant's public key -- so a
receipt only verifies clean when neither party's signature nor the note text
has been tampered with.

Signing the ORIGINAL SIGNATURE BYTES (not the manifest digest a second time,
and not a re-derived hash) means the counter-signature transitively commits
the accountant to the specific operator signature they received: swapping in
a different (even validly-signed) operator signature for the same package
invalidates the counter-signature, because the bytes it covers changed.

Key custody (``sensitive-financial-data-secure-storage-only`` /
``no-legacy-compatibility``): the counter-signer's (accountant's) keypair is
minted and persisted through the exact same
:func:`~application.modelo.ensure_review_package_signing_keypair`
primitive the operator uses, scoped to whatever ``bucket_id`` the caller
supplies for the counter-signer's identity -- there is no separate key-custody
mechanism to introduce. The private key never leaves that primitive as raw
bytes except transiently in process memory to sign.

See Also:
    :mod:`~application.modelo._review_package_signing`
        The operator-side signing primitive this module counter-signs on top of.
    :mod:`~application.modelo._review_package`
        Builds and integrity-verifies the review package that is signed.
"""

from __future__ import annotations

from datetime import datetime
from pathlib import Path

from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from pydantic import BaseModel, Field

from ...core import STRICT_FROZEN_CONFIG as _STRICT_FROZEN
from ...core.errors import AeatError
from ...core.external_constants import UTF_8_ENCODING
from ...core.time import now as _utc_now
from ._review_package_signing import (
    ReviewPackageSigningKeypair,
    SignedReviewPackage,
    verify_review_package_signature,
)

#: Wire-format version of the counter-signature envelope. Bumped when the
#: envelope schema changes shape.
_COUNTER_SIGNATURE_ENVELOPE_VERSION = 1

#: Raw Ed25519 signature size, per RFC 8032.
_ED25519_SIGNATURE_BYTES = 64

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


[docs] class ReviewPackageCounterSigningError(AeatError): """Base error for review-package counter-signing/verification failures."""
[docs] class CounterSignedReceipt(BaseModel): """A review package's operator signature, counter-signed by an accountant. Wraps the operator's :class:`~application.modelo.SignedReviewPackage` verbatim (the ``original_signature`` field) alongside the accountant's own Ed25519 signature over ``original_signature.signature_hex`` plus ``note``. Binding the counter-signature to the ORIGINAL SIGNATURE BYTES (rather than the manifest digest) means the counter-signature can only ever attest to that one specific operator signature: a different signature over the same manifest (e.g. re-signed by a rotated operator key) has different ``signature_hex`` bytes and would need a fresh counter-signature. """ model_config = _STRICT_FROZEN envelope_version: int = Field(default=_COUNTER_SIGNATURE_ENVELOPE_VERSION, ge=1) original_signature: SignedReviewPackage note: str = Field(default="", max_length=2000) counter_signature_hex: str = Field(pattern=_HEX_PATTERN_128) counter_public_key_hex: str = Field(pattern=_HEX_PATTERN_64) counter_signed_at: datetime @property def counter_signed_message(self) -> bytes: """Return the exact byte string the counter-signature covers. Reconstructing this independently of :func:`counter_sign_review_package` lets :func:`verify_counter_signed_receipt` recompute the signed message from the receipt's own fields rather than trusting a cached value, so a receipt whose ``note`` was edited after counter-signing fails verification instead of silently re-approving different text. """ return _counter_signed_message( signature_hex=self.original_signature.signature_hex, note=self.note, )
def _counter_signed_message(*, signature_hex: str, note: str) -> bytes: """Return the canonical byte string a counter-signature is minted over. Binds the operator's original signature hex and the accountant's note with an explicit separator so the two fields cannot be concatenated ambiguously (e.g. ``sig="ab"`` + ``note="cd"`` colliding with ``sig="abc"`` + ``note="d"``). """ return signature_hex.encode("ascii") + b"\x00" + note.encode(UTF_8_ENCODING)
[docs] def counter_sign_review_package( signed_package: SignedReviewPackage, *, counter_signer_keypair: ReviewPackageSigningKeypair, note: str = "", counter_signed_at: datetime | None = None, ) -> CounterSignedReceipt: """Counter-sign an operator-signed review package on behalf of the accountant. Does NOT re-verify the operator's ``signed_package`` signature or the underlying archive's checksum manifest -- that is :func:`~application.modelo.verify_review_package_signature`'s job, and it is re-run unconditionally inside :func:`verify_counter_signed_receipt`. Counter-signing a signature that later turns out to be invalid is not itself an error: the receipt's verification is what asserts both layers are clean, and a caller that wants to guarantee the original signature is valid BEFORE counter-signing should verify it first. Args: signed_package: The operator's :class:`SignedReviewPackage` envelope (see :func:`~application.modelo.sign_review_package`). counter_signer_keypair: The accountant's :class:`~application.modelo.ReviewPackageSigningKeypair` (minted the same way as the operator's, via :func:`~application.modelo.ensure_review_package_signing_keypair` scoped to the accountant's own identity). note: Optional free-text counter-signer note or verdict (e.g. ``"reviewed, no changes"``). Bound into the signed message, so editing the note after counter-signing invalidates the receipt. counter_signed_at: Optional override for the envelope's ``counter_signed_at`` timestamp (tests only); defaults to the current UTC time. """ message = _counter_signed_message(signature_hex=signed_package.signature_hex, note=note) counter_signature = counter_signer_keypair.private_key().sign(message) return CounterSignedReceipt( original_signature=signed_package, note=note, counter_signature_hex=counter_signature.hex(), counter_public_key_hex=counter_signer_keypair.public_key_hex, counter_signed_at=counter_signed_at or _utc_now(), )
[docs] def verify_counter_signed_receipt( package_path: Path, receipt: CounterSignedReceipt, *, operator_public_key_hex: str, counter_signer_public_key_hex: str, ) -> bool: """Verify BOTH signature layers of a counter-signed review-package receipt. First re-verifies the operator's original signature via :func:`~application.modelo.verify_review_package_signature` -- which itself re-runs the checksum-manifest integrity check against the package's CURRENT bytes before touching any Ed25519 signature, so a tampered archive fails here regardless of either signature. Only once that layer passes does this function verify the accountant's counter-signature against the message it recomputes from the receipt's own ``original_signature.signature_hex`` and ``note`` fields (never a cached message), so an edited note invalidates the receipt. Args: package_path: Path to the review-package ZIP the receipt attests to. receipt: The :class:`CounterSignedReceipt` produced by :func:`counter_sign_review_package`. operator_public_key_hex: The operator's raw public key, as 64 lowercase hex characters. Passed explicitly (never read off the receipt) so a verifier must supply the key it actually trusts. counter_signer_public_key_hex: The accountant's raw public key, as 64 lowercase hex characters. Passed explicitly for the same reason; the receipt's own ``counter_public_key_hex`` is never trusted as the verification key. Returns: ``True`` iff the package is currently checksum-clean, the operator's original signature verifies against ``operator_public_key_hex``, AND the accountant's counter-signature verifies against ``counter_signer_public_key_hex``. Returns ``False`` (never raises) on any mismatch, tamper, or invalid-signature outcome. """ if not verify_review_package_signature( package_path, receipt.original_signature, public_key_hex=operator_public_key_hex, ): return False counter_public_key = Ed25519PublicKey.from_public_bytes(bytes.fromhex(counter_signer_public_key_hex)) message = _counter_signed_message(signature_hex=receipt.original_signature.signature_hex, note=receipt.note) try: counter_public_key.verify(bytes.fromhex(receipt.counter_signature_hex), message) except InvalidSignature: return False return True
__all__ = [ "CounterSignedReceipt", "ReviewPackageCounterSigningError", "counter_sign_review_package", "verify_counter_signed_receipt", ]