Signum.Pdf.NetFramework
1.2.1
See the version list below for details.
dotnet add package Signum.Pdf.NetFramework --version 1.2.1
NuGet\Install-Package Signum.Pdf.NetFramework -Version 1.2.1
<PackageReference Include="Signum.Pdf.NetFramework" Version="1.2.1" />
<PackageVersion Include="Signum.Pdf.NetFramework" Version="1.2.1" />
<PackageReference Include="Signum.Pdf.NetFramework" />
paket add Signum.Pdf.NetFramework --version 1.2.1
#r "nuget: Signum.Pdf.NetFramework, 1.2.1"
#:package Signum.Pdf.NetFramework@1.2.1
#addin nuget:?package=Signum.Pdf.NetFramework&version=1.2.1
#tool nuget:?package=Signum.Pdf.NetFramework&version=1.2.1
Signum.Pdf
Firma digital PAdES para .NET, sin ataduras. Firma PDFs en PAdES-B-LTA —firma + sello de tiempo + validación a largo plazo— usando solo dependencias MIT. PAdES es un estándar ETSI de alcance internacional, así que la librería sirve para cualquier PKI; está probada contra la infraestructura de firma digital de Costa Rica (tarjetas SINPE/BCCR, TSA de SINPE).
Validado contra el validador nacional de Costa Rica. Un documento firmado con Signum pasa los cuatro checks en verde: integridad y autenticidad, validez en el tiempo, revocación contenida en el documento y fecha oficial de la firma sin advertencias. Probado en producción con tarjetas SINPE y la TSA del BCCR.
Límite conocido: las tablas de referencias cruzadas comprimidas (xref streams) se leen, pero las revisiones que agrega Signum se escriben siempre con tabla clásica.
- ✅ Los cuatro checks del validador nacional en verde
- ✅ Firma diferida (deferred signing): el PDF nunca sale del servidor; la tarjeta firma solo un hash de 32 bytes
- ✅ Firma directa con certificado del almacén/PFX (sellos institucionales)
- ✅ PAdES-B-LTA completo: CAdES + sello de tiempo de la firma (CAdES-T) + DSS con OCSP/CRL embebidos + Document Timestamp
- ✅ Estampa visible configurable estilo Adobe (o firma invisible)
- ✅ Lotes con id/guid propio y aislamiento de errores por documento
- ✅ Lectura de firmas existentes: ¿quién firmó? ¿ya firmó este usuario?
- ✅ Licencia MIT — sin AGPL, sin regalías, sin sorpresas
- Dependencias: PDFsharp (MIT) + BouncyCastle.Cryptography (MIT)
Instalación
dotnet add package Signum.Pdf
Requiere .NET 8, 9 o 10.
Activación y registro
Signum se usa por inyección de dependencias y requiere una clave de activación:
// Program.cs
builder.Services.AddSignum(builder.Configuration["Signum:ClaveActivacion"]!, o =>
{
o.TsaUrl = "http://tsa.sinpe.fi.cr/tsahttp/"; // autoridad de sellado por defecto
o.Traza = m => logger.LogInformation("Signum: {M}", m); // diagnóstico opcional
});
// Uso: se inyecta ISignum — la única superficie pública del paquete
public sealed class MiServicio(ISignum signum) { /* … */ }
Sin una clave válida, AddSignum lanza SignumActivacionException en el arranque y no registra
nada. Si la firma es una función opcional de tu app, usá TryAddSignum, que devuelve false en
vez de lanzar.
Para solicitar una clave de activación, escribí a andreyclemen@gmail.com.
Las opciones (SignumOptions)
Todo se configura una vez, al registrar. Ninguna es obligatoria: todas traen un valor por
defecto sensato y AddSignum(clave) a secas ya firma. Lo único que conviene poner es TsaUrl,
para no repetir la URL en cada llamada.
Los valores se validan al arrancar: un TamanoReservaFirma en 0 o un timeout en cero fallan
al registrar, con el nombre de la opción — no media hora después, en la primera firma.
builder.Services.AddSignum(clave, o =>
{
o.TsaUrl = "http://tsa.sinpe.fi.cr/tsahttp/";
o.Traza = m => logger.LogInformation("Signum: {M}", m);
});
| Opción | Por defecto | Qué controla |
|---|---|---|
TsaUrl |
null |
Autoridad de sellado de tiempo (RFC 3161). Con esto configurado, ConSelloDeTiempo() y ConLtv() se llaman sin parámetro; la URL explícita queda para casos donde una firma concreta use otra TSA. En Costa Rica: http://tsa.sinpe.fi.cr/tsahttp/. |
TamanoReservaFirma |
32768 (32 KB) |
Ver abajo. |
TimeoutOcsp |
3 s | Tiempo máximo por consulta OCSP durante el LTV. |
TimeoutCrl |
10 s | Tiempo máximo por descarga de CRL durante el LTV (las CRL pueden pesar varios MB). |
TimeoutTsa |
5 s | Tiempo máximo por solicitud de sello de tiempo. |
Traza |
null |
Receptor de mensajes de diagnóstico: qué material de revocación se obtuvo, cuánto tardó cada paso del LTV. Conectalo a tu logger; no imprime nada por sí solo. |
TamanoReservaFirma, explicada
La firma se escribe dentro del PDF, en un hueco que se reserva ANTES de firmar — así los bytes
del documento no se mueven al inyectarla (es lo que exige el formato: el /ByteRange describe
offsets absolutos). Ese hueco tiene que ser lo bastante grande para el CMS completo:
firma RSA (256 B) + cadena de certificados (~3–6 KB) + sello de tiempo CAdES-T (~5 KB) + ASN.1
- El valor por defecto (32 KB) sobra para la firma digital de Costa Rica — cadena SINPE de 4 certificados + sello del TSA del BCCR usan ~12 KB. El resto es margen.
- Cuándo subirlo: si aparece
«La firma no cabe en el espacio reservado: necesita X bytes…»— pasa con cadenas corporativas muy largas o TSAs que devuelven tokens grandes. - Cuándo bajarlo: casi nunca. El hueco sin usar se rellena con ceros y pesa en el archivo
final; si firmás miles de documentos por día y cada KB cuenta, 16 KB sigue siendo seguro para
la jerarquía nacional. Por debajo de eso, medí primero. El mínimo aceptado es 2 KB — menos
que eso no alcanza ni para la firma más chica con su cadena, y
AddSignumlo rechaza. - Cada firma del documento lleva SU hueco: un PDF con 3 firmas carga 3 reservas.
Inicio rápido
1. Firma diferida (tarjeta inteligente) — el caso típico
El flujo es en dos fases porque la llave privada vive en la tarjeta del usuario (en otra máquina) y solo puede firmar un hash:
// ───── FASE 1 (servidor): preparar ─────
var prep = signum.Firmar(pdfBytes)
.ConCadena(cadenaDer) // la CADENA de certificados de la tarjeta (ver nota abajo)
.ConEstampa(e => e
.EnPagina(1)
.Recuadro(x: 90, y: 350, ancho: 300, alto: 58)) // puntos PDF, origen abajo-izquierda
// el nombre NO se pasa: sale solo del certificado (CN). Look default = nombre grande |
// "Firmado digitalmente por … / Fecha: …"
.Preparar();
// prep.Digest -> LO QUE FIRMA LA TARJETA: la huella (32 bytes) del documento + sus atributos.
// Es lo ÚNICO que viaja al agente/tarjeta; el PDF nunca sale del servidor.
// prep.FirmaEnCurso -> LA FIRMA A MEDIO HACER: el PDF con el hueco, los atributos CAdES ya
// armados y la cadena. Es opaco: guardalo donde quieras (memoria, BD, Redis)
// mientras el usuario digita el PIN, y devolvéselo a Completar en la fase 2.
// ───── (el agente local le pide el PIN a la tarjeta y firma prep.Digest) ─────
// ───── FASE 2 (servidor): completar ─────
byte[] pdfFirmado = signum.Completar(prep.FirmaEnCurso, firmaDeLaTarjeta)
.ConSelloDeTiempo() // CAdES-T: la "fecha oficial". Usa la TSA de SignumOptions.TsaUrl
.ConLtv() // DSS + DocTimeStamp = B-LTA. También toma la TSA de las opciones
.Emitir();
// ¿Otra TSA solo para esta firma? Ambos aceptan la URL explícita: ConSelloDeTiempo("http://…").
¿Qué es
ConCadena(cadenaDer)? En la firma diferida el certificado NO está en el servidor: vive en la tarjeta del usuario. El agente local lo lee (parte pública, sin PIN) y manda la cadena completa: certificado del firmante + CAs intermedias + raíz, cada uno en DER (byte[]). Signum la necesita para: (1) meter los certificados dentro de la firma (el check "jerarquía de confianza"), (2) sacar el nombre/cédula del firmante, y (3) el LTV (a quién pedirle OCSP/CRL). En la firma directa no existe:ConCertificado(x509)ya trae todo.
ConCadenaacepta la colección tal como la tengás —List,IList, arreglo, LINQ, certificados sueltos oX509Certificate2(usa solo su parte pública):.ConCadena(cadenaDer) // cualquier IEnumerable<byte[]> .ConCadena(hoja, intermedia, raiz) // params: certificados sueltos .ConCadena(coleccionX509) // IEnumerable<X509Certificate2>¿Y por qué no
ConCertificadocon la cadena? Porque son contratos distintos:ConCertificadopromete "la llave privada es alcanzable desde este proceso" (y firma en un paso);ConCadenapromete lo contrario — "solo tengo la parte pública, la llave está en la tarjeta del usuario" — y por eso el flujo es en dos fases. Mezclarlos en un solo nombre escondería la diferencia más importante de toda la librería.
2. Firma directa (sello institucional, PFX/almacén)
byte[] sellado = signum.Firmar(pdfBytes)
.ConCertificado(certificadoConLlave) // X509Certificate2 (almacén de Windows o PFX)
.ConEstampa(e => e
.EnPagina(1).Recuadro(246, 76, 300, 48)
.SinGrafico()
.Firmante("TU INSTITUCION (SELLO ELECTRONICO)")
.MostrarMotivo("Sello electrónico institucional"))
.Emitir() // cierra la firma (acá se firma con la llave del certificado)
.ConSelloDeTiempo() // sello de tiempo con la TSA de las opciones
.ConLtv() // nivel B-LTA
.Emitir(); // produce el PDF
Los dos Emitir() son deliberados: el primero firma (con eso ya hay una firma válida); el
segundo cierra el archivo después de los agregados opcionales. Si no querés sello ni LTV, es
….Emitir().Emitir() y listo.
3. Firma invisible
No llames ConEstampa — la firma queda en el PDF sin marca visual:
var prep = signum.Firmar(pdfBytes).ConCadena(cadenaDer).Preparar();
Dónde guardar FirmaEnCurso
Entre Preparar() y Completar() pasan segundos o minutos: el usuario está digitando el PIN. En
el medio hay que guardar dos cosas por operación: prep.FirmaEnCurso (un byte[] opaco) y a
qué documento pertenece.
Lo que hace fácil esta parte: el motor no tiene estado. Completar solo necesita ese byte[]
de vuelta — no le importa qué instancia lo produjo ni cuánto tiempo pasó. La instancia que
completa no tiene que ser la que preparó, así que escalar es únicamente decidir dónde se
guarda.
Cuánto pesa: FirmaEnCurso contiene el PDF preparado, o sea
tamaño del PDF + TamanoReservaFirma + unos KB. Para un PDF de 500 KB, ~540 KB por operación en
curso. Con 50 firmas simultáneas son ~27 MB. Es un dato que importa al elegir dónde ponerlo.
Cuál usar
| Tu despliegue | Qué usar | Por qué |
|---|---|---|
| Una instancia fija (IIS, servicio Windows, VM) | IMemoryCache |
Cero infraestructura y el proceso siempre es el mismo |
| Contenedores que escalan 0→N, Kubernetes, App Service con varias instancias | IDistributedCache (Redis) |
La instancia que completa puede no ser la que preparó |
| Ya usás FusionCache o HybridCache en la app | Ese mismo, con L1 desactivado | Ver la nota más abajo — importa |
| La operación debe sobrevivir horas, o hay que auditarla | Tabla en base de datos | Sobrevive reinicios y queda registro |
1. Una instancia: IMemoryCache
public sealed class OperacionesDeFirma(IMemoryCache cache)
{
private static readonly TimeSpan Ttl = TimeSpan.FromMinutes(10);
public Guid Guardar(byte[] firmaEnCurso)
{
var id = Guid.NewGuid();
cache.Set($"firma:{id}", firmaEnCurso, Ttl);
return id;
}
public byte[]? Recuperar(Guid id)
=> cache.TryGetValue($"firma:{id}", out byte[]? valor) ? valor : null;
public void Terminar(Guid id) => cache.Remove($"firma:{id}");
}
El TTL no es opcional: si el usuario cierra la pestaña sin firmar, la operación expira sola en vez de quedar ocupando memoria para siempre.
2. Varias instancias / AppContainer: IDistributedCache
Es la opción correcta para contenedores que escalan de 0 a N. El mismo código sirve para Redis,
SQL Server o cualquier proveedor de IDistributedCache; solo cambia el registro:
// Program.cs
builder.Services.AddStackExchangeRedisCache(o =>
o.Configuration = builder.Configuration.GetConnectionString("Redis"));
public sealed class OperacionesDeFirma(IDistributedCache cache)
{
private static readonly DistributedCacheEntryOptions Ttl = new()
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10),
};
public async Task<Guid> Guardar(byte[] firmaEnCurso)
{
var id = Guid.NewGuid();
await cache.SetAsync($"firma:{id}", firmaEnCurso, Ttl);
return id;
}
public Task<byte[]?> Recuperar(Guid id) => cache.GetAsync($"firma:{id}");
public Task Terminar(Guid id) => cache.RemoveAsync($"firma:{id}");
}
Con esto: la instancia A prepara, el pod se muere o el autoescalado lo reemplaza, y la instancia B completa sin enterarse. Es todo lo que hace falta para escalar.
Ojo con las apps que escalan a cero. Si el contenedor puede apagarse entre las dos fases (Container Apps, Cloud Run, Functions), el estado NO puede vivir en el proceso — sería exactamente el caso donde
IMemoryCachepierde la operación y el usuario ve "la operación expiró" después de haber digitado el PIN. Redis o base de datos, sin excepción.
3. Si ya usás FusionCache (o HybridCache)
Funciona, pero desactivá el nivel de memoria (L1) para estas entradas. La razón es concreta:
- FusionCache y
HybridCacheson cachés híbridos: L1 en la memoria de cada instancia + L2 distribuido. Están pensados para datos que se leen muchas veces desde la misma instancia. FirmaEnCursoes lo contrario: se escribe una vez, se lee una vez y —justamente— casi nunca en la misma instancia que lo escribió. El L1 no ahorra ni una lectura.- Y cuesta: son ~540 KB por operación replicados en la memoria de cada instancia que los toque, más el riesgo de que un L1 sirva una entrada ya consumida si el backplane se atrasa.
// Program.cs
builder.Services.AddFusionCache()
// OJO con el serializador: el de JSON convierte el byte[] a Base64 y lo infla ~33 %
// (540 KB → 720 KB por operación). Para datos binarios conviene uno binario.
.WithSerializer(new FusionCacheNeueccMessagePackSerializer())
.WithDistributedCache(new RedisCache(new RedisCacheOptions
{
Configuration = builder.Configuration.GetConnectionString("Redis"),
}));
(IDistributedCache pelado no tiene este problema: guarda el byte[] tal cual, sin serializar.)
public sealed class OperacionesDeFirma(IFusionCache cache)
{
// La clave del asunto: SkipMemoryCache. La entrada va y viene SOLO por el L2 (Redis).
private static readonly FusionCacheEntryOptions Opciones = new()
{
Duration = TimeSpan.FromMinutes(10),
SkipMemoryCache = true,
};
public async Task<Guid> Guardar(byte[] firmaEnCurso)
{
var id = Guid.NewGuid();
await cache.SetAsync($"firma:{id}", firmaEnCurso, Opciones);
return id;
}
public ValueTask<byte[]?> Recuperar(Guid id)
=> cache.GetOrDefaultAsync<byte[]?>($"firma:{id}", null, Opciones);
public ValueTask Terminar(Guid id) => cache.RemoveAsync($"firma:{id}", Opciones);
}
No uses
GetOrSetAsyncacá. Ese método es para "si no está, calculalo" — y una firma en curso no se puede recalcular: o existe, o la operación expiró y hay que empezar de nuevo. Por eso vaGetOrDefaultAsync, que devuelvenullcuando no está.
Con HybridCache (Microsoft.Extensions.Caching.Hybrid) el equivalente es
HybridCacheEntryFlags.DisableLocalCache en las HybridCacheEntryOptions.
Si no usás ninguno de los dos, no los agregues por esto: IDistributedCache pelado es
suficiente y es una dependencia menos.
4. Base de datos (sobrevive reinicios y queda auditable)
CREATE TABLE FirmasEnCurso (
Id UNIQUEIDENTIFIER NOT NULL PRIMARY KEY,
IdDocumento INT NOT NULL,
Usuario NVARCHAR(100) NOT NULL,
Datos VARBINARY(MAX) NOT NULL, -- prep.FirmaEnCurso, tal cual
CreadaUtc DATETIME2 NOT NULL,
ExpiraUtc DATETIME2 NOT NULL
);
CREATE INDEX IX_FirmasEnCurso_Expira ON FirmasEnCurso (ExpiraUtc);
public sealed class OperacionesDeFirma(IDbConnection db)
{
public async Task<Guid> Guardar(byte[] firmaEnCurso, int idDocumento, string usuario)
{
var id = Guid.NewGuid();
await db.ExecuteAsync(
"""
INSERT INTO FirmasEnCurso (Id, IdDocumento, Usuario, Datos, CreadaUtc, ExpiraUtc)
VALUES (@id, @idDocumento, @usuario, @datos, SYSUTCDATETIME(),
DATEADD(MINUTE, 10, SYSUTCDATETIME()))
""",
new { id, idDocumento, usuario, datos = firmaEnCurso });
return id;
}
/// <summary>Devuelve la operación solo si NO expiró; si expiró, es como si no existiera.</summary>
public Task<byte[]?> Recuperar(Guid id) => db.QueryFirstOrDefaultAsync<byte[]?>(
"SELECT Datos FROM FirmasEnCurso WHERE Id = @id AND ExpiraUtc > SYSUTCDATETIME()",
new { id });
public Task Terminar(Guid id)
=> db.ExecuteAsync("DELETE FROM FirmasEnCurso WHERE Id = @id", new { id });
/// <summary>Barrido de las abandonadas (el usuario cerró la pestaña). Un job cada hora.</summary>
public Task PurgarExpiradas()
=> db.ExecuteAsync("DELETE FROM FirmasEnCurso WHERE ExpiraUtc <= SYSUTCDATETIME()");
}
El byte[] se guarda tal cual: no hay que serializarlo, comprimirlo ni interpretarlo.
Concurrencia y reintentos
Completar es puro. Con el mismo FirmaEnCurso y la misma firma produce el mismo PDF. Un
reintento —timeout de red del agente, doble clic del usuario— es inofensivo mientras la operación
siga existiendo. De ahí la regla de orden:
var pdfFirmado = signum.Completar(firmaEnCurso, firma).ConSelloDeTiempo().ConLtv().Emitir();
await documentos.GuardarFirmado(idDocumento, pdfFirmado); // 1. guardar
await operaciones.Terminar(idOperacion); // 2. recién ahora borrar
Al revés, un fallo al guardar dejaría la operación borrada y al usuario firmando de nuevo — con otro PIN.
Documentos distintos en paralelo: no comparten nada, cada operación es independiente. No hay nada que sincronizar.
El MISMO documento firmado por dos personas a la vez: acá sí hay que ordenar. Las dos
prepararon sobre el mismo original, así que la segunda Completar produce un PDF que no incluye
la firma de la primera — la última en guardar pisa a la otra.
La solución no es un lock global sino preparar en serie por documento: la segunda firma se prepara sobre el PDF que dejó la primera (Signum soporta multi-firma, cada una se anexa como revisión incremental).
// Estado en la BD del documento: Disponible | Firmando
var tomado = await db.ExecuteAsync(
"""
UPDATE Documentos SET Estado = 'Firmando', TomadoUtc = SYSUTCDATETIME()
WHERE Id = @id AND (Estado = 'Disponible'
OR TomadoUtc < DATEADD(MINUTE, -10, SYSUTCDATETIME()))
""", new { id = idDocumento });
if (tomado == 0)
return Conflict("Otra persona está firmando este documento en este momento.");
Un UPDATE condicional alcanza: es atómico y funciona igual con N instancias, sin lock
distribuido. La condición del TomadoUtc libera el documento si alguien lo tomó y nunca terminó.
Se pasa a Disponible al completar o al expirar.
Para que las dos firmas queden en el documento, la clave está en la fase 1: Preparar sobre el
PDF más reciente, no sobre el original.
var pdf = await documentos.Leer(idDocumento); // ← el que ya tiene la firma anterior
var prep = signum.Firmar(pdf).ConCadena(cadena).ConEstampa(...).Preparar();
El agente local: la pieza que levanta el PIN
En la firma diferida hay un tercer actor que no viene en este paquete: un programa chiquito
instalado en la PC del usuario. Existe por una razón física: la llave privada vive dentro del chip
de la tarjeta y no puede salir — y el navegador no tiene ninguna API para hablar con tarjetas
(WebCrypto no accede al almacén del sistema; <keygen>, applets y ActiveX murieron hace años).
Alguien con acceso al middleware de la tarjeta tiene que pedir la firma, y ese alguien es el
agente. Es el mismo modelo de Adobe, del firmador del BCCR y de AutoFirma en España.
NAVEGADOR AGENTE (127.0.0.1) SERVIDOR
───────── ────────────────── ────────
"Firmar" ────────────────────► GET /certificados
(lee el almacén, SIN PIN)
◄────────────────────────── [cadenas de certificados]
elige certificado
│ cadena elegida ─────────────────────────────────────────► Preparar(pdf, cadena)
│ guarda FirmaEnCurso
◄────────────────────────────────────────────────────────── { idOperacion, digestBase64 }
│ digestBase64 ──────────► POST /firmar
GetRSAPrivateKey().SignHash(...)
┌──────────────────────────┐
│ Windows muestra el │ ← el PIN lo pide el MIDDLEWARE
│ diálogo de PIN │ de la tarjeta, no tu código
└──────────────────────────┘
◄────────────────────────── { firmaBase64 }
│ idOperacion + firma ────────────────────────────────────► Completar(...) + sello + LTV
◄────────────────────────────────────────────────────────── documento firmado ✓
Qué hace y qué no:
- Escucha solo en loopback (
127.0.0.1, un puerto local): nada entra desde la red. - Dos operaciones: listar certificados (parte pública, sin PIN) y firmar un digest.
- El PIN no lo pide tu código: al llamar
SignHash, el middleware de la tarjeta (el que instala la CA) muestra el diálogo de Windows. El agente ni ve el PIN ni lo puede ver. - Nunca ve el PDF: recibe 32 bytes y devuelve ~256. El documento no sale del servidor.
El corazón del agente son estas líneas — el resto es un servidor HTTP mínimo y la enumeración de certificados:
// POST /firmar { thumbprint, digestBase64 }
using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser);
store.Open(OpenFlags.ReadOnly);
var cert = store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, false)[0];
using var rsa = cert.GetRSAPrivateKey()!; // acá el middleware levanta el PIN
var firma = rsa.SignHash(Convert.FromBase64String(digestBase64),
HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
return Convert.ToBase64String(firma); // esto es lo que recibe Completar(...)
¿De verdad no hay forma sin agente? Hoy, para la tarjeta física en un navegador de Windows, no. Lo más cercano que existe: la Web Smart Card API (W3C/WICG) ya funciona — pero solo en ChromeOS y solo para Isolated Web Apps (aplicaciones instaladas y empaquetadas, no sitios web). Y el camino sin ningún software local es la firma remota (estándar CSC, modelo eIDAS): la llave vive en el HSM de un proveedor de confianza y se autoriza desde el navegador — pero eso requiere que la autoridad certificadora ofrezca ese servicio, y deja de ser la tarjeta del ciudadano.
La estampa visible
Se configura igual que el diálogo "Configurar aspecto de la firma" de Adobe Acrobat:
.ConEstampa(e => e
.EnPagina(1)
.Recuadro(90, 350, 300, 58)
.ConLogotipo(pngLogoDeTuEmpresa, opacidad: 0.12f)) // marca de agua tenue (opcional)
// Con SOLO eso, la estampa sale con el look por defecto (dos columnas estilo Adobe):
//
// JOSE ANDREY SALAZAR ⟨logo⟩ Firmado digitalmente por JOSE
// GUZMAN (FIRMA) tenue ANDREY SALAZAR GUZMAN (FIRMA)
// Fecha: 2026.07.27 11:20:13 -06'00'
//
// El nombre sale del CERTIFICADO (CN) — no hay que pasarlo. Texto auto-ajustado al recuadro.
// ── Opciones (todas opcionales, estilo diálogo de Adobe) ──
// GRÁFICO (columna izquierda) — elegí UNO:
// .GraficoNombre() // default: el nombre en grande
// .GraficoImagen(pngFirmaManuscrita) // tu firma escaneada (PNG con transparencia)
// .SinGrafico() // solo el bloque de texto
// TEXTO (columna derecha):
// .Firmante("OTRO NOMBRE") // override (default: CN del certificado)
// .MostrarFecha() // ON por default (reloj del servidor; informativa)
// .MostrarUbicacion("San José, Costa Rica")
// .MostrarMotivo("Aprobación del documento")
// .MostrarNombreReconocimiento() // el DN completo del certificado
// .MostrarVersion() // "Signum x.y"
// .ConEtiquetas(false) // quita los prefijos "Fecha:", "Motivo:"…
Notas:
- El texto se auto-ajusta al recuadro: dibujes el rectángulo grande o chico, el nombre y la fecha SIEMPRE salen completos.
MostrarFechausa el reloj del servidor al preparar — es informativa, igual que en Adobe. La fecha oficial (la que reporta el validador) es la del sello de tiempo del TSA (ConSelloDeTiempo), que es criptográfica y no depende de ningún reloj local.- Signum no trae ningún logo embebido: el logotipo y la imagen de firma los pasás vos (PNG).
Leer firmas existentes — "¿ya firmó?"
// Listar todo
foreach (var f in signum.LeerFirmas(pdfBytes))
Console.WriteLine($"{f.Firmante} ({f.Cedula}) — oficial: {f.FechaOficial} — íntegra: {f.Integra}");
// ¿Ya firmó este usuario? — tolerante al formato de la cédula (todas equivalen):
signum.YaFirmo(pdf, "CPF-05-0398-0891");
signum.YaFirmo(pdf, "05-0398-0891");
signum.YaFirmo(pdf, "0503980891");
signum.YaFirmo(pdf, "503980891");
// Por nombre, estilo LIKE '%…%' (sin importar mayúsculas ni tildes):
signum.YaFirmoPorNombre(pdf, "salazar guzmán");
// Búsqueda que devuelve LAS firmas que matchean:
var mias = signum.BuscarFirmas(pdf, cedula: "503980891");
- La comparación es contra el certificado (CN y SERIALNUMBER del subject), nunca contra el texto dibujado de la estampa.
Integraverifica la criptografía (firma RSA + digest del documento). La confianza de cadena y la revocación las evalúa el validador — están fuera del alcance de esta API.- Los sellos de tiempo de documento (
EsDocTimeStamp == true) no cuentan como firmantes.
Ejemplo: bloquear el doble firmado
if (signum.YaFirmo(pdfBytes, cedulaDelUsuario))
{
ToastWarning("Ya firmaste este documento.");
return;
}
// ... abrir el flujo de firma
LTV y niveles PAdES
| Llamada | Nivel | Qué agrega | Validador CR |
|---|---|---|---|
solo Emitir() |
B-B | firma CAdES | integridad ✅, fecha ❌, validez en el tiempo ❌ |
+ ConSelloDeTiempo() |
B-T | sello de tiempo DE LA FIRMA (CAdES-T) | + fecha oficial ✅ |
+ ConLtv() |
B-LTA | DSS (cadena + OCSP/CRL embebidos) + Document Timestamp + DSS del TSA | todo verde ✅ |
ConLtv también sirve standalone sobre un PDF ya firmado:
byte[] conLtv = signum.AgregarLtv(pdfYaFirmado).Emitir(); // TSA: la de las opciones
// …o con una TSA específica: signum.AgregarLtv(pdf).ConTsa("http://…").Emitir();
Comportamiento ante fallos de red (importante)
El LTV nunca pierde tu firma:
- Si la TSA no responde al agregar el Document Timestamp → se lanza
SignumLtvTimestampException, que trae el PDF con el DSS ya aplicado (PdfConDss) para que decidas usarlo. - Si falla la recolección de revocación completa → recibís la firma sin LTV (B-T), con el error disponible.
- Los fetch de OCSP/CRL corren en paralelo con timeouts cortos (OCSP 3s, CRL 10s, TSA 5s) y
caché por certificado con expiración según el
nextUpdatereal — en lotes, del segundo documento en adelante el LTV no toca la red (~90 ms).
Rendimiento
Números de referencia (medidos contra la TSA de SINPE y las CAs de CR):
| Operación | Frío | Con caché |
|---|---|---|
| Completar entero (CMS + CAdES-T + LTV) | ~320 ms | ~150 ms |
| LTV solo (DSS + DocTimeStamp + DSS del sello) | ~150 ms | ~75 ms |
| Lote de 3 documentos, completo | — | ~430 ms |
Consejo: llamá signum.PrecalentarRevocacion(cadenaDer) (fire-and-forget) apenas conozcás la
cadena del firmante — p. ej. mientras el usuario digita el PIN — y el LTV encontrará todo en caché.
Firmar varios PDFs (lote)
API de lote de primera clase: pasás un arreglo con tu id (string o guid), el PDF y la estampa opcional de cada uno — y recibís el mismo arreglo con el PDF firmado, correlacionado por id. Aislamiento por documento (uno que falla no tumba el resto) y cachés compartidos: el primer documento paga la red; del segundo en adelante el LTV tarda ~90 ms.
var docs = new[]
{
new DocumentoParaFirmar
{
Id = idSolicitud1.ToString(), // tu id o guid — vuelve tal cual
Pdf = pdf1,
Estampa = e => e.EnPagina(1).Recuadro(90, 350, 300, 58),
},
new DocumentoParaFirmar { Id = "doc-2", Pdf = pdf2 }, // sin Estampa -> firma invisible
new DocumentoParaFirmar { Id = "doc-3", Pdf = pdf3, Firmante = "NOMBRE OVERRIDE" },
};
Lote con tarjeta (diferida, 2 fases)
// FASE 1: preparar TODO el lote (prefetch de revocación automático)
var lote = signum.PrepararLote(docs, cadenaDer);
// lote.Pendientes -> [(Id, Digest)] — la tarjeta firma cada Digest
// lote.LoteEnCurso -> byte[] opaco con el lote a medio firmar
// (el agente firma los digests; hoy pide PIN por firma — ver nota)
// FASE 2: completar TODO, correlacionado por Id
var resultados = signum.CompletarLote(lote.LoteEnCurso, firmas, tsaUrl); // firmas: [(Id, FirmaPkcs1)]
foreach (var r in resultados)
if (r.Exito) Guardar(r.Id, r.Pdf);
else Registrar(r.Id, r.Error); // este doc falló; los demás siguieron
Lote directo (sello institucional, 1 llamada)
var resultados = signum.FirmarLote(docs, certificadoConLlave, tsaUrl);
// mismo retorno: [(Id, Pdf firmado | Error)]
Sobre el PIN (diferida): la tarjeta puede firmar varios digests con una sola sesión de PIN, pero si tu agente de firma pide el PIN una vez por documento —una decisión de seguridad razonable— el lote se lo pedirá N veces. Eso se decide en el agente, no acá: Signum ya recibe las N firmas juntas en
CompletarLote.
Fuentes
No hay nada que configurar. Signum trae embebida Liberation Sans (SIL OFL 1.1), métricamente compatible con Helvetica y Arial —las tipografías de siempre en los visores de PDF—, y la registra sola la primera vez que dibuja una estampa. El resultado no depende de las fuentes que tenga el servidor, lo que importa porque un contenedor mínimo no trae ninguna.
Si tu aplicación ya registró un IFontResolver de PDFsharp (por ejemplo para usar tipografías
corporativas), Signum respeta el tuyo y no lo sobrescribe.
Alcance y límites
- Entrada: PDFs sin cifrar (PDF 1.4–2.0, xref clásico o comprimido). PDFs cifrados: no soportados.
- Multi-firma: se puede firmar un documento que ya está firmado — pueden firmar todas las
personas que haga falta. La firma nueva se agrega como revisión incremental (los bytes ya
firmados quedan intactos), y el LTV re-emite el
/DSScon el material de TODAS las firmas. - El digest es SHA-256 y la firma RSA PKCS#1 v1.5 (lo que producen las tarjetas SINPE). Otros algoritmos: fuera del alcance inicial.
- Verificación (
LeerFirmas): integridad criptográfica, no evaluación de confianza/revocación.
Conformidad: qué estándar cumple, y en qué países sirve
PAdES no es un formato costarricense. Es un estándar de ETSI, el organismo europeo de
normalización, publicado como EN 319 142. Costa Rica no definió un formato propio: lo adoptó.
La Política de Formatos Oficiales de los Documentos Electrónicos Firmados
Digitalmente
(MICITT/DCFD, publicada en La Gaceta N.º 95 del 20 de mayo de 2013; vigente la v4 de junio de 2025)
establece PAdES para los PDF, exige LTV —validación a largo plazo— y recomienda el perfil
Baseline. Eso es exactamente lo que produce ConSelloDeTiempo + ConLtv: PAdES Baseline
B-LTA, el nivel más alto del perfil.
Como el estándar es internacional, el mismo PDF que Signum firma para Costa Rica vale en cualquier país que reconozca PAdES —toda la Unión Europea por eIDAS, y buena parte de Latinoamérica—. Lo que cambia de un país a otro es configuración, no código:
| Qué cambia | Cómo se resuelve |
|---|---|
| Autoridad de sellado de tiempo | SignumOptions.TsaUrl (o por llamada: ConSelloDeTiempo("https://tsa-de-tu-pais/…")). Sirve cualquier TSA RFC 3161 |
| Cadena de certificados | La aporta la tarjeta o el PFX, sea del país que sea |
| A quién pedirle OCSP/CRL | Automático: sale de las extensiones AIA y CRL Distribution Points del propio certificado |
| Cómo se ve la estampa | Configurable, y sin ningún logo embebido |
Dos límites reales, que conviene conocer antes de adoptarla fuera de Costa Rica:
- Solo RSA PKCS#1 v1.5 sobre SHA-256, que es lo que emiten las tarjetas costarricenses. Una PKI que firme con ECDSA o RSA-PSS todavía no está soportada.
YaFirmoasume una identificación numérica. Normaliza a dígitos para tolerar los formatos de la cédula de Costa Rica (CPF-05-0398-0891≡503980891), así que con un documento alfanumérico —un NIE español, por ejemplo— descarta las letras.YaFirmoPorNombreyBuscarFirmas(nombre:)no tienen esa limitación.
Todo lo demás —el CMS, el sello de tiempo, el DSS, la revisión incremental— es agnóstico del país.
Estándares implementados
ISO 32000-2 (PDF 2.0, §12.8 firmas, §7.5.6 incremental updates) · ETSI EN 319 142-1 (PAdES baseline B/T/LT/LTA) · ETSI TS 102 778-4 (LTV: DSS y Document Timestamp) · RFC 5652 (CMS) · RFC 5035 (ESS signingCertificateV2) · RFC 3161 (sello de tiempo) · RFC 6960 (OCSP).
Cada clase cita en su documentación la sección de la especificación que implementa. Signum es una implementación limpia desde los estándares públicos; no deriva de ninguna librería con licencia restrictiva.
Ejemplo completo de punta a punta
Todo junto: una API con dos endpoints, almacenamiento apto para varias instancias y el flujo del navegador contra el agente. Es el esqueleto real de una aplicación de firma con tarjeta.
appsettings.json
{
"Signum": {
"ClaveActivacion": "…",
"TsaUrl": "http://tsa.sinpe.fi.cr/tsahttp/"
},
"ConnectionStrings": {
"Redis": "localhost:6379"
}
}
Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSignum(
builder.Configuration["Signum:ClaveActivacion"]!,
o => o.TsaUrl = builder.Configuration["Signum:TsaUrl"]);
// IDistributedCache: memoria en dev, Redis en producción — mismo código en el controlador.
if (builder.Environment.IsDevelopment())
builder.Services.AddDistributedMemoryCache();
else
builder.Services.AddStackExchangeRedisCache(o =>
o.Configuration = builder.Configuration.GetConnectionString("Redis"));
builder.Services.AddControllers();
var app = builder.Build();
app.MapControllers();
app.Run();
FirmaController.cs
[ApiController]
[Route("api/firma")]
public sealed class FirmaController(
ISignum signum, IDistributedCache operaciones, IRepositorioDocumentos documentos) : ControllerBase
{
private static readonly DistributedCacheEntryOptions Ttl = new()
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10),
};
/// <summary>FASE 1: el navegador manda el documento a firmar y la cadena que eligió del agente.</summary>
[HttpPost("preparar")]
public async Task<ActionResult<PrepararRespuesta>> Preparar(PrepararSolicitud solicitud)
{
var pdf = await documentos.Leer(solicitud.IdDocumento);
var prep = signum.Firmar(pdf)
.ConCadena(solicitud.CadenaBase64.Select(Convert.FromBase64String))
.ConEstampa(e => e.EnPagina(1).Recuadro(72, 72, 260, 52))
.ConRazon("Firma digital")
.ConLugar("Costa Rica")
.Preparar();
// Adelanta las consultas OCSP/CRL mientras el usuario digita el PIN: el LTV de la
// fase 2 sale del caché en vez de tocar la red.
signum.PrecalentarRevocacion(solicitud.CadenaBase64.Select(Convert.FromBase64String).ToList());
var id = Guid.NewGuid();
await operaciones.SetAsync($"firma:{id}", prep.FirmaEnCurso, Ttl);
return new PrepararRespuesta(id, Convert.ToBase64String(prep.Digest));
}
/// <summary>FASE 2: vuelve la firma que produjo la tarjeta.</summary>
[HttpPost("completar")]
public async Task<ActionResult> Completar(CompletarSolicitud solicitud)
{
var firmaEnCurso = await operaciones.GetAsync($"firma:{solicitud.IdOperacion}");
if (firmaEnCurso is null)
return Conflict("La operación expiró: volvé a iniciar la firma.");
byte[] pdfFirmado;
try
{
pdfFirmado = signum.Completar(firmaEnCurso, Convert.FromBase64String(solicitud.FirmaBase64))
.ConSelloDeTiempo()
.ConLtv()
.Emitir();
}
catch (SignumLtvTimestampException ex)
{
// El LTV nunca cuesta la firma: si la TSA falló, el PDF trae firma + revocación.
pdfFirmado = ex.PdfConDss;
}
await documentos.GuardarFirmado(solicitud.IdDocumento, pdfFirmado);
await operaciones.RemoveAsync($"firma:{solicitud.IdOperacion}"); // borrar DESPUÉS de guardar
return Ok();
}
}
public sealed record PrepararSolicitud(int IdDocumento, string[] CadenaBase64);
public sealed record PrepararRespuesta(Guid IdOperacion, string DigestBase64);
public sealed record CompletarSolicitud(int IdDocumento, Guid IdOperacion, string FirmaBase64);
El navegador (contra el agente en loopback y contra la API):
// 1. certificados disponibles (el agente los lee del almacén, sin PIN)
const { certificados } = await (await fetch("http://127.0.0.1:19700/certificados")).json();
const elegido = await elegirEnModal(certificados); // tu UI
// 2. FASE 1 en el servidor
const prep = await api.post("api/firma/preparar", {
idDocumento, cadenaBase64: elegido.cadena });
// 3. el agente firma el digest — ACÁ Windows muestra el diálogo de PIN
const { firmaBase64 } = await (await fetch("http://127.0.0.1:19700/firmar", {
method: "POST",
body: JSON.stringify({ thumbprint: elegido.thumbprint, digestBase64: prep.digestBase64 }),
})).json();
// 4. FASE 2 en el servidor
await api.post("api/firma/completar", {
idDocumento, idOperacion: prep.idOperacion, firmaBase64 });
El lado del agente es el fragmento de la sección anterior: GetRSAPrivateKey().SignHash(...)
detrás de un HTTP mínimo en 127.0.0.1.
Con esto el resultado es PAdES-B-LTA — los cuatro checks del validador nacional en verde — y la aplicación escala a N instancias sin tocar nada más, porque la operación vive en el caché distribuido y no en la memoria del proceso.
Licencia
MIT.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net5.0 was computed. net5.0-windows was computed. net6.0 was computed. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 was computed. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 was computed. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
| .NET Core | netcoreapp2.0 was computed. netcoreapp2.1 was computed. netcoreapp2.2 was computed. netcoreapp3.0 was computed. netcoreapp3.1 was computed. |
| .NET Standard | netstandard2.0 is compatible. netstandard2.1 was computed. |
| .NET Framework | net461 was computed. net462 was computed. net463 was computed. net47 was computed. net471 was computed. net472 was computed. net48 was computed. net481 was computed. |
| MonoAndroid | monoandroid was computed. |
| MonoMac | monomac was computed. |
| MonoTouch | monotouch was computed. |
| Tizen | tizen40 was computed. tizen60 was computed. |
| Xamarin.iOS | xamarinios was computed. |
| Xamarin.Mac | xamarinmac was computed. |
| Xamarin.TVOS | xamarintvos was computed. |
| Xamarin.WatchOS | xamarinwatchos was computed. |
-
.NETStandard 2.0
- BouncyCastle.Cryptography (>= 2.6.2)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 9.0.0)
- Microsoft.Extensions.Options (>= 9.0.0)
- PDFsharp (>= 6.2.4)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
Ver CHANGELOG.md