feat: certificates + series API v1 (Task 4)
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.
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user