Port certificate lifecycle (parse/encrypt/upload/deactivate) and FiscalSeries CRUD from the auto, adapted to (product_id, tenant_ref, branch_ref) tenancy. Closes the "guard retroativo" PATCH /v1/series next_number regression check that Task 3 deferred to this task. Routes: POST/GET/DELETE /v1/certificados, POST/GET/PATCH /v1/series.
159 lines
6.6 KiB
Python
159 lines
6.6 KiB
Python
"""A1 certificate (.pfx) lifecycle: parse + validate metadata, encrypt/
|
|
decrypt the binary for storage (Fernet), and decrypt-in-memory for signing
|
|
(consumed by `emission.service`, Task 5). Ported VERBATIM (mechanism +
|
|
docstrings) from the auto's `app/modules/fiscal/certificate.py` -- no
|
|
tenancy-shaped adaptation needed, this module never touches
|
|
product_id/tenant_ref/branch_ref, only the PFX bytes themselves.
|
|
|
|
`FISCAL_CERT_ENCRYPTION_KEY` is this SERVICE's OWN env var (Global
|
|
Constraints/porte table: "secrets never live in Settings", same convention
|
|
as the auto) -- deliberately a DIFFERENT key/secret than the auto's own
|
|
`FISCAL_CERT_ENCRYPTION_KEY` (they are two different Kubernetes Secrets in
|
|
two different namespaces/deployments; the NAME collides on purpose, mirroring
|
|
the auto's env var name 1:1, but the VALUE never does -- ties the blast
|
|
radius of a compromised key to exactly one service's certificates)."""
|
|
import os
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from functools import lru_cache
|
|
|
|
from cryptography.fernet import Fernet
|
|
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
|
|
from cryptography.hazmat.primitives.serialization import pkcs12
|
|
from cryptography.x509 import Certificate
|
|
from cryptography.x509.oid import NameOID
|
|
|
|
CURRENT_ENCRYPTION_KEY_ID = "fiscal-svc-cert-fernet-v1-env"
|
|
|
|
|
|
class FiscalCertEncryptionKeyNotConfiguredError(RuntimeError):
|
|
pass
|
|
|
|
|
|
class InvalidPfxError(Exception):
|
|
"""O arquivo não é um PKCS#12 (.pfx/.p12) válido -- corrompido ou de
|
|
outro formato inteiro. Distinto de `WrongPasswordError` (ver a
|
|
docstring de `parse_pfx` para como -- e o quão bem -- essa distinção é
|
|
feita)."""
|
|
|
|
|
|
class WrongPasswordError(Exception):
|
|
"""A senha informada não abre o PFX. `cryptography` não distingue com
|
|
perfeição "senha errada" de "PFX corrompido de um jeito que só se
|
|
manifesta na fase de decriptação" -- ver a docstring de `parse_pfx`."""
|
|
|
|
|
|
class CertificateExpiredError(Exception):
|
|
"""O certificado já passou do `not_valid_after` -- fail-closed: emitir
|
|
com um A1 vencido é rejeitado pela própria SEFAZ, então isto é
|
|
detectado no UPLOAD, não só na emissão."""
|
|
|
|
def __init__(self, not_valid_after: datetime):
|
|
self.not_valid_after = not_valid_after
|
|
super().__init__(f"Certificado vencido em {not_valid_after.isoformat()}")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CertInfo:
|
|
subject_cn: str
|
|
cnpj: str
|
|
not_valid_before: datetime
|
|
not_valid_after: datetime
|
|
|
|
|
|
@lru_cache(maxsize=1)
|
|
def _get_fernet() -> Fernet:
|
|
"""A ausência de `FISCAL_CERT_ENCRYPTION_KEY` é um erro de DEPLOY (o
|
|
Secret do k8s não foi provisionado/montado), detectado aqui no PRIMEIRO
|
|
uso -- o primeiro upload ou emissão que precisar cifrar/decifrar um
|
|
certificado, não antes (mesma convenção do módulo que este porta)."""
|
|
key = os.environ.get("FISCAL_CERT_ENCRYPTION_KEY")
|
|
if not key:
|
|
raise FiscalCertEncryptionKeyNotConfiguredError(
|
|
"FISCAL_CERT_ENCRYPTION_KEY não configurada. Em produção vem de um "
|
|
"secrets manager (Vault) injetada como variável de ambiente -- nunca "
|
|
"de um arquivo .env versionado."
|
|
)
|
|
return Fernet(key.encode("utf-8"))
|
|
|
|
|
|
def encrypt_bytes(raw: bytes) -> bytes:
|
|
return _get_fernet().encrypt(raw)
|
|
|
|
|
|
def decrypt_bytes(token: bytes) -> bytes:
|
|
return _get_fernet().decrypt(token)
|
|
|
|
|
|
def _extract_cnpj(certificate: Certificate) -> str:
|
|
"""CNPJ do certificado A1 e-CNPJ (ICP-Brasil, DOC-ICP-04): o padrão de
|
|
mercado é `CN=RAZAO SOCIAL:CNPJ` e um atributo `SERIALNUMBER` (OID
|
|
2.5.4.5) carregando o CNPJ puro. Tenta o `SERIALNUMBER` primeiro; cai
|
|
para o sufixo do `CN` depois de `:` quando o `SERIALNUMBER` não vem ou
|
|
não tem 14 dígitos (algumas ACs variam)."""
|
|
serial_attrs = certificate.subject.get_attributes_for_oid(NameOID.SERIAL_NUMBER)
|
|
if serial_attrs:
|
|
digits = "".join(c for c in serial_attrs[0].value if c.isdigit())
|
|
if len(digits) == 14:
|
|
return digits
|
|
|
|
cn_attrs = certificate.subject.get_attributes_for_oid(NameOID.COMMON_NAME)
|
|
if cn_attrs and ":" in cn_attrs[0].value:
|
|
tail = cn_attrs[0].value.rsplit(":", 1)[-1]
|
|
digits = "".join(c for c in tail if c.isdigit())
|
|
if len(digits) == 14:
|
|
return digits
|
|
|
|
raise InvalidPfxError(
|
|
"Não foi possível extrair um CNPJ (14 dígitos) do subject do certificado"
|
|
)
|
|
|
|
|
|
def parse_pfx(pfx_bytes: bytes, password: str) -> CertInfo:
|
|
"""Abre o .pfx com a senha informada e extrai os metadados.
|
|
|
|
`cryptography.hazmat.primitives.serialization.pkcs12.
|
|
load_key_and_certificates` levanta `ValueError` tanto para "não é um
|
|
PKCS#12 válido" quanto para "senha errada" -- mensagens diferentes,
|
|
confirmadas empiricamente (heurística, não contrato): dado malformado
|
|
-> "Could not deserialize PKCS12 data"; senha errada sobre um PKCS#12
|
|
estruturalmente válido -> "Invalid password or PKCS12 data". Ambas
|
|
viram 422 no router de qualquer forma -- a distinção importa só para a
|
|
MENSAGEM ao usuário."""
|
|
try:
|
|
private_key, certificate, _ca_certs = pkcs12.load_key_and_certificates(
|
|
pfx_bytes, password.encode("utf-8")
|
|
)
|
|
except ValueError as exc:
|
|
if "deserialize" in str(exc).lower():
|
|
raise InvalidPfxError(f"Arquivo não é um PKCS#12 (.pfx) válido: {exc}") from exc
|
|
raise WrongPasswordError("Senha do certificado incorreta") from exc
|
|
|
|
if certificate is None or private_key is None:
|
|
raise InvalidPfxError("PFX não contém certificado e/ou chave privada")
|
|
|
|
cn_attrs = certificate.subject.get_attributes_for_oid(NameOID.COMMON_NAME)
|
|
subject_cn = cn_attrs[0].value if cn_attrs else certificate.subject.rfc4514_string()
|
|
cnpj = _extract_cnpj(certificate)
|
|
|
|
return CertInfo(
|
|
subject_cn=subject_cn,
|
|
cnpj=cnpj,
|
|
not_valid_before=certificate.not_valid_before_utc,
|
|
not_valid_after=certificate.not_valid_after_utc,
|
|
)
|
|
|
|
|
|
def load_private_key_and_cert(certificate) -> tuple[RSAPrivateKey, Certificate]:
|
|
"""Decifra o PFX/senha de um `FiscalCertificate` row EM MEMÓRIA e
|
|
devolve `(private_key, certificate)` prontos para assinatura
|
|
(`erpbrasil.assinatura`, consumido por `emission.service`, Task 5).
|
|
Nunca grava nada em disco/tmp; o retorno vive só na pilha do
|
|
chamador."""
|
|
pfx_bytes = decrypt_bytes(certificate.pfx_encrypted)
|
|
password = decrypt_bytes(certificate.password_encrypted).decode("utf-8")
|
|
private_key, cert, _ca_certs = pkcs12.load_key_and_certificates(
|
|
pfx_bytes, password.encode("utf-8")
|
|
)
|
|
return private_key, cert
|