Firma PAdES¶
El modulo app/pades_signer.py implementa la firma digital PAdES-B-T (Basic with Time)
usando la libreria pyHanko.
Que es PAdES¶
PAdES (PDF Advanced Electronic Signatures) es un estandar de ETSI para firmas electronicas avanzadas en documentos PDF.
PAdES-B-T incluye:
- Firma criptografica embebida en el PDF
- Timestamp de un servidor TSA (Time Stamping Authority)
- Hash SHA-256
- Compatible con Adobe Acrobat Reader (verificacion con clic)
Flujo de firma PAdES¶
flowchart TD
A[PDF + Certificado .p12] --> B[Cargar certificado PKCS#12]
B --> C[Crear SimpleSigner pyHanko]
C --> D[Configurar IncrementalPdfFileWriter]
D --> E[Configurar metadata firma]
E --> F[Configurar campo visual]
F --> G{TSA configurado?}
G -->|Si| H[Solicitar timestamp]
G -->|No| I[Firma sin timestamp]
H --> J[Firmar con async_sign_pdf]
I --> J
J --> K[PDF firmado PAdES-B-T]
Funcion principal: sign_pdf_combined¶
Esta es la funcion usada en produccion. Combina firma criptografica PAdES con representacion visual profesional.
async def sign_pdf_combined(
pdf_content: bytes,
cert: LoadedCertificate,
signature_params: dict, # name, seal, department, entity
x: float, # Coordenada X (de layout.py)
y: float, # Coordenada Y (de layout.py)
existing_signature_count: int = 0,
) -> bytes:
Configuracion del firmante¶
# Crear firmante desde archivo .p12 (metodo nativo de pyHanko)
signer = signers.SimpleSigner.load_pkcs12(
pfx_file=p12_path,
passphrase=password.encode('utf-8'),
)
Campo de firma visual¶
El campo se posiciona en la ultima pagina usando las coordenadas calculadas por layout.py:
sig_field_spec = fields.SigFieldSpec(
sig_field_name=f"GDI_Signature_{pades_count + 1}",
on_page=last_page,
box=(sig_x, sig_y, sig_x + SIGNATURE_WIDTH, sig_y + SIGNATURE_HEIGHT),
)
Estilo del stamp visual¶
stamp_style = TextStampStyle(
stamp_text=(
"%(signer_upper)s\n"
"%(seal)s\n"
"%(department)s\n"
"%(entity)s"
),
border_width=0, # Sin borde
background_opacity=0.0, # Sin fondo
)
appearance_text_params = {
'signer_upper': name.upper(), # Nombre en MAYUSCULAS
'seal': seal,
'department': department,
'entity': entity,
}
Resultado visual¶
- Nombre en MAYUSCULAS para destacar
- Sin borde, sin fondo
- Sin fecha visible (la fecha esta en los metadatos de la firma)
Metadata de la firma¶
signature_meta = signers.PdfSignatureMetadata(
field_name=sig_field_name,
name=name,
reason=f"{seal} - {department}",
location=entity,
subfilter=fields.SigSeedSubFilter.PADES,
md_algorithm='sha256',
)
La metadata es visible al hacer clic en la firma en Adobe Reader:
- Name: Nombre del firmante
- Reason: Cargo - Departamento
- Location: Entidad
Timestamp (TSA) con reintentos y circuit breaker¶
El cliente TSA usa RetryingTimestamper (wrapper sobre HTTPTimeStamper) con backoff
exponencial y circuit breaker. La funcion get_timestamp_client() nunca devuelve None:
si TSA_URL no esta configurado, lanza PAdESTimestampError.
# Config
TSA_URL = os.getenv("TSA_URL", "http://timestamp.digicert.com")
TSA_TIMEOUT = int(os.getenv("TSA_TIMEOUT", "3")) # segundos por intento
TSA_RETRIES = int(os.getenv("TSA_RETRIES", "2")) # reintentos (total = retries + 1)
Logica de reintentos¶
Con los valores default: 3 intentos x 3s + backoffs (0.2s + 0.6s) = ~9.8s maximo por firma.
| Intento | Espera antes |
|---|---|
| 1 | - |
| 2 | 0.2s |
| 3 | 0.6s |
Circuit breaker¶
Protege contra TSA caido de forma sostenida. Implementado en TsaCircuitBreaker:
| Estado | Comportamiento |
|---|---|
CLOSED |
Normal, todas las llamadas pasan |
OPEN |
Fail-fast: lanza PAdESTsaUnavailableError sin llamar al TSA |
HALF_OPEN |
Deja pasar 1 request de prueba; exito → CLOSED, fallo → OPEN |
- Umbral de apertura: 5 fallos consecutivos
- Cooldown: 60 segundos en
OPENantes de pasar aHALF_OPEN - Alcance: por proceso Gunicorn (no compartido entre procesos)
Cuando el circuit breaker esta OPEN, Notary devuelve HTTP 503 TSA_UNAVAILABLE
independientemente de FALLBACK_TO_VISUAL, para que el Backend pueda distinguir
"TSA caido" de "sin certificado".
Servidores TSA publicos soportados:
| Servidor | URL |
|---|---|
| DigiCert | http://timestamp.digicert.com |
| Sectigo | http://timestamp.sectigo.com |
| GlobalSign | http://timestamp.globalsign.com/tsa/r6advanced1 |
Otras funciones¶
count_pades_signatures¶
Cuenta las firmas PAdES existentes en un PDF:
def count_pades_signatures(pdf_content: bytes) -> int:
reader = PdfFileReader(io.BytesIO(pdf_content))
return len(list(reader.embedded_signatures))
verify_pades_signature¶
Verifica firmas PAdES existentes:
def verify_pades_signature(pdf_content: bytes) -> dict:
# Retorna: signature_count, signatures[{field_name, valid, intact, trusted}]
Excepciones¶
| Excepcion | HTTP | Causa |
|---|---|---|
PAdESSigningError |
500 | Error general de firma |
PAdESTimestampError |
500 | Todos los reintentos al TSA fallaron |
PAdESTsaUnavailableError |
503 | Circuit breaker abierto (subclase de PAdESTimestampError) |
PAdESCertificateError |
400 | Certificado invalido o expirado |