Signum.Pdf.NetFramework 1.3.0

dotnet add package Signum.Pdf.NetFramework --version 1.3.0
                    
NuGet\Install-Package Signum.Pdf.NetFramework -Version 1.3.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Signum.Pdf.NetFramework" Version="1.3.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Signum.Pdf.NetFramework" Version="1.3.0" />
                    
Directory.Packages.props
<PackageReference Include="Signum.Pdf.NetFramework" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Signum.Pdf.NetFramework --version 1.3.0
                    
#r "nuget: Signum.Pdf.NetFramework, 1.3.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Signum.Pdf.NetFramework@1.3.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Signum.Pdf.NetFramework&version=1.3.0
                    
Install as a Cake Addin
#tool nuget:?package=Signum.Pdf.NetFramework&version=1.3.0
                    
Install as a Cake Tool

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 AddSignum lo 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.

ConCadena acepta la colección tal como la tengásList, IList, arreglo, LINQ, certificados sueltos o X509Certificate2 (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 ConCertificado con la cadena? Porque son contratos distintos: ConCertificado promete "la llave privada es alcanzable desde este proceso" (y firma en un paso); ConCadena promete 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 IMemoryCache pierde 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 HybridCache son 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.
  • FirmaEnCurso es 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 GetOrSetAsync acá. 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 va GetOrDefaultAsync, que devuelve null cuando 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.
  • MostrarFecha usa 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.
  • Integra verifica 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 nextUpdate real — 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 /DSS con 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:

  1. 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.
  2. YaFirmo asume una identificación numérica. Normaliza a dígitos para tolerar los formatos de la cédula de Costa Rica (CPF-05-0398-0891503980891), así que con un documento alfanumérico —un NIE español, por ejemplo— descarta las letras. YaFirmoPorNombre y BuscarFirmas(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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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