aeat.adapters.outbound.aeat.auth.certificate module

PKCS#12 client-certificate records and checks for AEAT Sede Electrónica.

This module is the public surface for certificate-based authentication against the Spanish tax authority’s Sede Electrónica. Callers import exclusively from adapters.outbound.aeat.auth; the backend implementations live in the private adapters.outbound.aeat.auth._certificate_backends package.

adapters.outbound.aeat.auth.AeatAuthenticator consumes this surface by loading a CertificateBundle into a LoadedCertificate, recording CertificateHealth, deriving the taxpayer NIF/NIE through extract_nif_from_subject(), and storing HandshakeResult evidence in certificate-backed sessions.

Design constraints:

  • All boundary records are pydantic v2 BaseModel with model_config set to the shared strict, frozen project config.

  • Cert passphrases are pydantic.SecretStr. The secret value is materialised only at the exact TLS-handshake boundary and is never logged, persisted, or serialised by model_dump.

  • Parsed private-key material and the raw PKCS#12 bytes live in pydantic.PrivateAttr fields on LoadedCertificate, so they can never be leaked via model_dump or repr.

  • All errors inherit from core.errors.AeatError via CertificateError.

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

Bases: AuthError

Base class for every certificate-auth domain error.

Subclasses remain catchable through the shared AuthError branch while preserving certificate-specific causes for loading, password, health, handshake, and subject-identity failures.

Parameters:
  • message (str | None)

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

  • suggestion (str | None)

  • translated_message (str | None)

Return type:

None

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

Bases: CertificateError

Raised when load_certificate() cannot parse PKCS#12 bytes.

Parameters:
  • message (str | None)

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

  • suggestion (str | None)

  • translated_message (str | None)

Return type:

None

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

Bases: CertificateError

Raised when CertificateBundle.password is empty or wrong.

Parameters:
  • message (str | None)

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

  • suggestion (str | None)

  • translated_message (str | None)

Return type:

None

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

Bases: CertificateError

Raised when load_certificate() sees an elapsed not_after.

Parameters:
  • message (str | None)

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

  • suggestion (str | None)

  • translated_message (str | None)

Return type:

None

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

Bases: CertificateError

Raised when a certificate is within the pre-expiry danger window.

Distinct from CertificateExpiredError (which fires after not_after has elapsed): this error is raised proactively by the workflow gate and CLI surfaces when a loaded certificate’s days_until_expiry has fallen below the configured critical threshold, before the bundle becomes technically unusable. Callers may suppress it via an explicit override flag on the narrow programmatic surfaces that still support certificate probes.

Parameters:
  • message (str | None)

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

  • suggestion (str | None)

  • translated_message (str | None)

Return type:

None

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

Bases: CertificateError

Raised when handshake input is structurally invalid.

TLS failures encountered during verify_handshake() are returned as HandshakeResult(success=False, ...) rather than raised; this exception is reserved for cases where the caller passed nonsense (e.g. an empty URL).

Parameters:
  • message (str | None)

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

  • suggestion (str | None)

  • translated_message (str | None)

Return type:

None

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

Bases: CertificateError

Raised when no NIF / NIE can be parsed from a certificate subject.

The project’s authenticator derives the taxpayer NIF from the FNMT certificate subject (canonical source: the serialNumber RDN, OID 2.5.4.5). Certificates that carry no such attribute, that use a CIF (legal-entity) shape, or whose CN/serialNumber lacks a recognisable DNI ([0-9]{7,8}[A-Z]) or NIE ([XYZ][0-9]{7}[A-Z]) identifier produce this error. Callers MUST propagate it rather than guess the identifier from other fields.

Parameters:
  • message (str | None)

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

  • suggestion (str | None)

  • translated_message (str | None)

Return type:

None

code: ClassVar[ErrorCode]
class CertificateHealthSeverity(*values)[source]

Bases: StrEnum

Closed catalogue of certificate health verdicts.

Mapping from days_until_expiry to severity is driven by the warn_threshold_days / critical_threshold_days fields on the CertificateHealth record and the sourced values in core.config.Settings.

Variables:
  • OK – Certificate has more than warn_threshold_days remaining.

  • WARN – Within the warning window but outside the critical one.

  • CRITICAL – Inside the critical window but not yet expired.

  • EXPIREDnot_after has already elapsed.

OK
WARN
CRITICAL
EXPIRED
class CertificateBundle(**data)[source]

Bases: BaseModel

Operator-supplied pointer at a PKCS#12 bundle on disk.

load_certificate() turns this pointer into a LoadedCertificate. The selected CertificateBackend determines which private backend later consumes the loaded certificate.

The PKCS#12 passphrase is carried directly as a pydantic.SecretStr so callers no longer have to round-trip the secret through os.environ. The secret is materialised only at the exact PKCS#12-decode boundary and is never logged, persisted, or serialised by model_dump.

Variables:
  • path – Filesystem path to the .p12 / .pfx bundle.

  • password – PKCS#12 passphrase as a SecretStr.

  • friendly_name – Optional human-readable label for logs.

  • backend – Which backend should consume this bundle.

Parameters:
path: Path
password: SecretStr
friendly_name: str | None
backend: CertificateBackend
class LoadedCertificate(**data)[source]

Bases: BaseModel

A parsed, validated, in-memory PKCS#12 certificate.

adapters.outbound.aeat.auth.AeatAuthenticator uses this record for NIF/NIE extraction, CertificateHealth evaluation, HandshakeResult creation, and browser-context provisioning.

Public fields are safe to log and serialise. Secret material (raw PKCS#12 bytes, parsed private key, passphrase) lives in pydantic.PrivateAttr fields and is therefore invisible to model_dump, model_dump_json, and the overridden __repr__().

Variables:
  • subject – X.509 subject distinguished name.

  • issuer – X.509 issuer distinguished name.

  • not_before – Validity start (timezone-aware UTC).

  • not_after – Validity end (timezone-aware UTC).

  • serial_number – Hex-encoded serial number.

  • sha256_thumbprint – Hex-encoded SHA-256 fingerprint of the DER encoding.

  • source_path – Path the bundle was loaded from.

  • friendly_name – Optional label propagated from the bundle.

  • backend – Backend this cert should be handed to.

Parameters:
subject: str
issuer: str
not_before: datetime
not_after: datetime
serial_number: str
sha256_thumbprint: str
source_path: Path
friendly_name: str | None
backend: CertificateBackend
is_expired(now=None)[source]

Return True if the certificate’s validity has elapsed.

Parameters:

now (datetime | None) – Timezone-aware reference time. Defaults to datetime.now() in UTC.

Return type:

bool

Returns:

True when the certificate has expired relative to now.

class CertificateHealth(**data)[source]

Bases: BaseModel

Structured health verdict for a PKCS#12 certificate bundle.

Computed from a loaded certificate’s not_after against a reference evaluated_at timestamp and a pair of warning / critical thresholds sourced from core.config.Settings. The record never carries any secret material; it is safe to log, persist, or surface to the CLI. evaluate_loaded_certificate_health() computes this from an existing LoadedCertificate; health() computes it from a bundle path while preserving the expired-certificate reporting path.

Variables:
  • subject – RFC-4514 subject DN.

  • issuer – RFC-4514 issuer DN.

  • serial_number – Hex-encoded serial number.

  • not_before – Timezone-aware validity start.

  • not_after – Timezone-aware validity end.

  • days_until_expiry – Whole days between evaluated_at and not_after. Negative when the certificate is expired.

  • severityCertificateHealthSeverity bucket.

  • warn_threshold_days – The WARN cut-off that produced this verdict.

  • critical_threshold_days – The CRITICAL cut-off that produced this verdict.

  • evaluated_at – Timezone-aware reference timestamp.

Parameters:
subject: str
issuer: str
serial_number: str
not_before: datetime
not_after: datetime
days_until_expiry: int
severity: CertificateHealthSeverity
warn_threshold_days: int
critical_threshold_days: int
evaluated_at: datetime
class HandshakeResult(**data)[source]

Bases: BaseModel

Structured outcome of a verify_handshake() attempt.

Successful certificate sessions persist this result inside adapters.outbound.aeat.auth._authenticator_persistence.PersistedSessionMetadata so resumed sessions can preserve the original mTLS probe evidence.

Variables:
  • success – Whether the TLS handshake completed successfully.

  • status_code – HTTP status returned by the verify URL (0 if the handshake failed before any HTTP response was observed).

  • server_cert_chain – Tuple of subject DNs from the server-presented chain, outermost leaf first. Empty on failure.

  • elapsed_ms – Wall-clock elapsed time in milliseconds.

  • attempted_at – Timezone-aware UTC timestamp of the attempt.

  • error_message – Human-readable failure reason when success=False.

Parameters:
success: bool
status_code: int
server_cert_chain: tuple[str, ...]
elapsed_ms: int
attempted_at: datetime
error_message: str | None
load_certificate(bundle)[source]

Load and validate a PKCS#12 bundle from disk.

This is the canonical decode path for certificate auth. It feeds adapters.outbound.aeat.auth.AeatAuthenticator, operator probes, and backend provisioning surfaces with the same LoadedCertificate contract.

The passphrase is unwrapped from bundle.password at the PKCS#12-decode boundary only. An empty SecretStr raises CertificatePasswordError before any file I/O. On a successful load the returned LoadedCertificate carries the raw PKCS#12 bytes and a parsed private-key handle in PrivateAttr fields so the backends can consume them without a second on-disk round-trip.

Parameters:

bundle (CertificateBundle) – Operator-supplied CertificateBundle.

Return type:

LoadedCertificate

Returns:

A frozen LoadedCertificate. Its public fields are safe to log; secret material is never serialised.

Raises:
evaluate_loaded_certificate_health(cert, *, warn_days, critical_days, now=None)[source]

Compute a CertificateHealth from an already-loaded cert.

The helper exists so callers that have already paid the PKCS#12 decode cost, such as adapters.outbound.aeat.auth.AeatAuthenticator or operator probes, can reuse the parsed record rather than re-reading the bundle from disk.

Parameters:
Return type:

CertificateHealth

Returns:

A frozen CertificateHealth record.

Raises:

AuthValidationError – If critical_days <= 0 or warn_days <= critical_days.

health(path, *, password, warn_days, critical_days, backend=CertificateBackend.PLAYWRIGHT_CONTEXT, friendly_name=None, now=None)[source]

Load path and return its CertificateHealth.

Unlike load_certificate(), this function never raises on an expired certificate — it returns a CertificateHealth record with severity CertificateHealthSeverity.EXPIRED instead. Genuine load failures (empty passphrase, corrupt bytes, I/O) still raise the matching CertificateError subclass, because those are not pre-expiry conditions.

Parameters:
  • path (Path) – Filesystem path to the PKCS#12 bundle.

  • password (SecretStr) – PKCS#12 passphrase as a SecretStr.

  • warn_days (int) – Warning threshold in days (see evaluate_loaded_certificate_health()).

  • critical_days (int) – Critical threshold in days.

  • backend (CertificateBackend) – Backend the bundle belongs to (default PLAYWRIGHT_CONTEXT).

  • friendly_name (str | None) – Optional label propagated to the bundle.

  • now (datetime | None) – Optional reference time, for deterministic tests.

Return type:

CertificateHealth

Returns:

A frozen CertificateHealth record.

Raises:
  • CertificateExpiredError – When the certificate has expired and the raw bytes cannot be re-decoded for the health report.

  • CertificateLoadError – When the PKCS#12 bytes cannot be re-decoded for an expired-cert health report.

extract_nif_from_subject(cert)[source]

Return the FNMT taxpayer identifier encoded in cert’s subject.

FNMT persona física certificates carry the subject’s NIF or NIE in the serialNumber RDN (OID 2.5.4.5), optionally prefixed with IDCES-. Some older bundles repeat it in the common name with the format NAME SURNAME - NNNNNNNNL.

Uses cryptography.x509.Name.from_rfc4514_string() to parse the subject so that RFC 4514 escape sequences (\\,, \\+, etc.) and multi-valued RDNs are handled correctly.

Parameters:

cert (LoadedCertificate) – The loaded PKCS#12 certificate.

Return type:

str

Returns:

The uppercase normalised NIF/NIE (e.g. "12345678Z" or "X1234567L").

Raises:

CertificateNifParseError – When the subject contains no recognisable DNI / NIE identifier, or when the value present is a CIF (legal-entity). Certificate auth here accepts individual taxpayer certificates and rejects organization certificates rather than guessing an identity.

preload_into_browser_context(cert, context)[source]

Validate that context was constructed with cert.

Per Playwright’s API, per-context client certificates must be supplied at playwright.async_api.Browser.new_context() time; there is no post-hoc injection hook. This function therefore validates the contract rather than mutating context. It is the integration hook used by adapters.outbound.aeat.auth.CertificateContextProvisioner after it passes a CertificateBundle-derived certificate through to new_context.

Parameters:
Return type:

None

verify_handshake(cert, url)[source]

Perform an opt-in TLS handshake smoke test.

adapters.outbound.aeat.auth.AeatAuthenticator calls this before constructing a certificate-backed AeatSession. Backend implementations own the actual transport behavior.

Dispatches to the backend selected by cert.backend. TLS failures are returned as HandshakeResult with success=False so callers can record them in health-check reports without catching exceptions. Only structurally invalid input raises CertificateHandshakeError.

Parameters:
  • cert (LoadedCertificate) – The loaded certificate to present.

  • url (str) – Fully-qualified target URL (must include scheme + host).

Return type:

HandshakeResult

Returns:

A frozen HandshakeResult.

Raises:

CertificateHandshakeError – When url is empty or malformed.