Signing a PDF
This page puts the whole flow together: a PAdES signature of a PDF with a cloud certificate, in C#, PHP, Python, Node.js and Java. CloudHub provides the certificate and the signature; a signature library builds the signed PDF. Each example follows the matching sample in PKI Suite Samples.
| Language | Signature library | Web framework |
|---|---|---|
| C# | PKI SDK | ASP.NET Core MVC |
| PHP | Rest PKI | plain PHP |
| Python | PKI Express | Flask |
| Node.js | Rest PKI | Express |
| Java | Rest PKI | Spring MVC |
Each signature library needs its own setup first: a license for PKI SDK, an access token for Rest
PKI, PKI Express installed on the server. The examples read the Rest PKI token from
RESTPKI_ACCESS_TOKEN and the CloudHub key from CLOUDHUB_API_KEY.
How the pieces fit
Every example has two handlers:
- discover receives the signer's CPF, creates a CloudHub session and lists the providers, each
linked to its
authUrl. It also stores the CPF in the user's web session, to check the certificate later. - complete is the redirect URI. It runs four steps:
- Read the signer's certificate from CloudHub.
- Give the certificate to the signature library, which starts the signature and returns the hash to sign, its digest algorithm, and what it needs to finish (transfer data, a token or a transfer file).
- Send the hash to CloudHub's
sign-hash. The provider signs it with the user's key. - Give the signature to the signature library, which finishes the signed PDF. Then check that the signer's CPF is the one you stored.
The signature libraries return the hash in different encodings. CloudHub takes it base64-encoded on the wire, and the client libraries convert from the type they accept:
| Example | The hash from the signature library | What the CloudHub client takes |
|---|---|---|
| C#, PKI SDK | bytes, from DigestAlgorithm.ComputeHash | bytes |
| PHP, Rest PKI | raw bytes in $start->toSignHash | base64: wrap it in base64_encode |
| Python, PKI Express | base64 in start['toSignHash'] | base64 |
| Node.js, Rest PKI | base64 in start.toSignHash | base64 |
| Java, Rest PKI | bytes, from getToSignHashRaw() | bytes |
The example
- C#
- PHP
- Python
- Node.js
- Java
using System.Text.RegularExpressions;
using Lacuna.Cloudhub.Api;
using Lacuna.Cloudhub.Client;
using Lacuna.Pki;
using Lacuna.Pki.Pades;
using Microsoft.AspNetCore.Mvc;
public class CloudSignatureController : Controller {
private static readonly CloudhubClient cloudhub = new CloudhubClient(
"https://cloudhub.lacunasoftware.com/",
Environment.GetEnvironmentVariable("CLOUDHUB_API_KEY"));
// POST /CloudSignature/Discover: the form with the signer's CPF
[HttpPost]
public async Task<IActionResult> Discover(string fileId, string cpf) {
cpf = DigitsOnly(cpf);
// Remember whose signature this is, to check the certificate later
TempData["cloudhubCpf"] = cpf;
var session = await cloudhub.CreateSessionAsync(new SessionCreateRequest {
Identifier = cpf,
RedirectUri = Url.Action(nameof(Complete), "CloudSignature", new { fileId }, Request.Scheme),
Type = TrustServiceSessionTypes.SingleSignature,
});
// The view links each entry of session.Services to its AuthUrl
return View(session.Services);
}
// GET /CloudSignature/Complete?fileId=…&session=…: the redirect URI
public async Task<IActionResult> Complete(string fileId, string session) {
if (string.IsNullOrEmpty(session)) {
return RedirectToAction("Index", new { fileId }); // not authorized
}
// 1. The signer's certificate, from CloudHub
var certificate = PKCertificate.Decode(await cloudhub.GetCertificateAsync(session));
if (DigitsOnly(certificate.PkiBrazil.CPF) != (string)TempData["cloudhubCpf"]) {
return StatusCode(403); // someone else signed in at the provider
}
// 2. PKI SDK starts the signature. It throws a ValidationException
// when the certificate is not trusted.
var signer = new PadesSigner();
signer.SetPolicy(PadesPoliciesForGeneration.GetPadesBasic(TrustArbitrators.PkiBrazil));
signer.SetPdfToSign(GetPdfPath(fileId));
signer.SetSigningCertificate(certificate);
var toSignBytes = signer.GetToSignBytes(out var signatureAlgorithm, out var transferData);
// 3. CloudHub signs the hash with the user's key
var signature = await cloudhub.SignHashAsync(new SignHashRequest {
Session = session,
Hash = signatureAlgorithm.DigestAlgorithm.ComputeHash(toSignBytes),
DigestAlgorithmOid = signatureAlgorithm.DigestAlgorithm.Oid,
});
// 4. PKI SDK finishes the signed PDF
signer.SetPreComputedSignature(signature, transferData);
var signedPdf = signer.GetPadesSignature();
return File(signedPdf, "application/pdf", "signed.pdf");
}
private static string DigitsOnly(string value) => Regex.Replace(value ?? "", @"\D", "");
}
With PKI SDK, the certificate is known before anything is signed, so the CPF check comes first.
GetPdfPath stands for your own storage.
<?php
require __DIR__ . '/../vendor/autoload.php';
use Lacuna\Cloudhub\CloudhubClient;
use Lacuna\Cloudhub\SessionCreateRequest;
use Lacuna\Cloudhub\TrustServiceSessionTypes;
session_start();
$cloudhub = new CloudhubClient('https://cloudhub.lacunasoftware.com/', getenv('CLOUDHUB_API_KEY'));
$fileId = $_GET['fileId'];
$cpf = preg_replace('/\D/', '', $_POST['cpf']);
// Remember whose signature this is, to check the certificate later
$_SESSION['cloudhubCpf'] = $cpf;
$session = $cloudhub->createSessionAsync(new SessionCreateRequest(
$cpf,
'https://app.example.com/cloud-signature/complete.php?fileId=' . urlencode($fileId),
TrustServiceSessionTypes::SingleSignature
));
?>
<?php if (empty($session->services)) { ?>
<p>No provider holds a certificate for this CPF.</p>
<?php } ?>
<?php foreach ($session->services as $service) { ?>
<a href="<?= htmlspecialchars($service->authUrl) ?>">
Sign with <?= htmlspecialchars($service->serviceInfo->provider) ?>
</a>
<?php } ?>
<?php
require __DIR__ . '/../vendor/autoload.php';
use Lacuna\Cloudhub\CloudhubClient;
use Lacuna\Cloudhub\SignHashRequest;
use Lacuna\RestPki\PadesSignatureFinisher2;
use Lacuna\RestPki\PadesSignatureStarter;
use Lacuna\RestPki\RestPkiClient;
use Lacuna\RestPki\StandardSecurityContexts;
use Lacuna\RestPki\StandardSignaturePolicies;
session_start();
$cloudhub = new CloudhubClient('https://cloudhub.lacunasoftware.com/', getenv('CLOUDHUB_API_KEY'));
$restPki = new RestPkiClient('https://pki.rest/', getenv('RESTPKI_ACCESS_TOKEN'));
$fileId = $_GET['fileId'];
$session = $_GET['session'] ?? null;
if (empty($session)) {
header('Location: /cloud-signature/index.php?fileId=' . urlencode($fileId)); // not authorized
exit;
}
// 1. The signer's certificate, from CloudHub (base64)
$certificate = $cloudhub->getCertificateAsync($session);
// 2. Rest PKI starts the signature
$starter = new PadesSignatureStarter($restPki);
$starter->setSignerCertificateBase64($certificate);
$starter->setPdfToSignFromPath(getPdfPath($fileId));
$starter->signaturePolicy = StandardSignaturePolicies::PADES_BASIC;
$starter->securityContext = StandardSecurityContexts::PKI_BRAZIL;
$start = $starter->start();
// 3. CloudHub signs the hash with the user's key. Rest PKI's PHP client
// returns the hash as raw bytes, and CloudHub takes it base64-encoded.
$signature = $cloudhub->signHashAsync(new SignHashRequest(
$session,
base64_encode($start->toSignHash),
null,
$start->digestAlgorithmOid
));
// 4. Rest PKI finishes the signed PDF
$finisher = new PadesSignatureFinisher2($restPki);
$finisher->token = $start->token;
$finisher->signatureBase64 = $signature;
$result = $finisher->finish();
if ($result->certificate->pkiBrazil->cpf !== $_SESSION['cloudhubCpf']) {
http_response_code(403); // someone else signed in at the provider: discard the result
exit;
}
$result->writeToFile(getSignedPdfPath($fileId));
getPdfPath and getSignedPdfPath stand for your own storage.
import os
from flask import Blueprint, abort, redirect, render_template, request, url_for
from flask import session as web_session
from pkiexpress import PadesSignatureStarter, SignatureFinisher, standard_signature_policies
from cloudhub import call_cloudhub # the helper from "Get started"
blueprint = Blueprint('cloud_signature', __name__, url_prefix='/cloud-signature')
@blueprint.route('/discover/<file_id>', methods=['POST'])
def discover(file_id):
cpf = ''.join(c for c in request.form['cpf'] if c.isdigit())
# Remember whose signature this is, to check the certificate later
web_session['cloudhub_cpf'] = cpf
session = call_cloudhub('POST', '/api/sessions', json={
'identifier': cpf,
'redirectUri': url_for('.complete', file_id=file_id, _external=True),
'type': 'SingleSignature',
})
# The template links each entry of services to its authUrl
return render_template('cloud_signature/discover.html', services=session['services'])
@blueprint.route('/complete/<file_id>')
def complete(file_id):
cloudhub_session = request.args.get('session')
if not cloudhub_session:
return redirect(url_for('.index', file_id=file_id)) # not authorized
# 1. The signer's certificate, from CloudHub (base64)
certificate = call_cloudhub('GET', '/api/sessions/certificate',
params={'session': cloudhub_session})
# 2. PKI Express starts the signature
pdf_path = get_pdf_path(file_id)
starter = PadesSignatureStarter()
starter.signature_policy = standard_signature_policies.PADES_BASIC
starter.set_pdf_to_sign_from_path(pdf_path)
starter.set_certificate_from_base64(certificate)
start = starter.start()
# 3. CloudHub signs the hash with the user's key
signature = call_cloudhub('POST', '/api/sessions/sign-hash', json={
'session': cloudhub_session,
'hash': start['toSignHash'],
'digestAlgorithmOid': start['digestAlgorithmOid'],
})
# 4. PKI Express finishes the signed PDF
finisher = SignatureFinisher()
finisher.set_file_to_sign_from_path(pdf_path)
finisher.set_transfer_file_from_path(start['transferFile'])
finisher.signature = signature
finisher.output_file = get_signed_pdf_path(file_id)
signer_cert = finisher.complete(get_cert=True)
if signer_cert.pki_brazil.cpf != web_session.get('cloudhub_cpf'):
os.remove(finisher.output_file) # someone else signed in at the provider
abort(403)
return render_template('cloud_signature/complete.html', signer_cert=signer_cert)
The CloudHub value is named cloudhub_session so that it doesn't shadow Flask's session.
get_pdf_path and get_signed_pdf_path stand for your own storage.
const express = require('express');
const { CloudHubClient, TrustServiceSessionTypes } = require('cloudhub-client');
const {
RestPkiClient,
PadesSignatureStarter,
PadesSignatureFinisher,
StandardSecurityContexts,
StandardSignaturePolicies,
} = require('restpki-client');
const cloudhub = new CloudHubClient('https://cloudhub.lacunasoftware.com/', process.env.CLOUDHUB_API_KEY);
const restPki = new RestPkiClient('https://pki.rest/', process.env.RESTPKI_ACCESS_TOKEN);
const router = express.Router();
// POST /cloud-signature/discover?fileId=…: the form with the signer's CPF
router.post('/discover', async (req, res, next) => {
try {
const { fileId } = req.query;
const cpf = req.body.cpf.replace(/\D/g, '');
// Remember whose signature this is, to check the certificate later (express-session)
req.session.cloudhubCpf = cpf;
const session = await cloudhub.createSessionAsync({
identifier: cpf,
redirectUri: `https://app.example.com/cloud-signature/complete?fileId=${encodeURIComponent(fileId)}`,
type: TrustServiceSessionTypes.SingleSignature,
});
// The view links each entry of services to its authUrl
res.render('cloud-signature/discover', { services: session.services || [] });
} catch (err) {
next(err);
}
});
// GET /cloud-signature/complete?fileId=…&session=…: the redirect URI
router.get('/complete', async (req, res, next) => {
try {
const { fileId, session } = req.query;
if (!session) {
return res.redirect(`/cloud-signature?fileId=${encodeURIComponent(fileId)}`); // not authorized
}
// 1. The signer's certificate, from CloudHub (base64)
const certificate = await cloudhub.getCertificateAsync(session);
// 2. Rest PKI starts the signature. It base64-encodes the certificate
// itself, so give it the decoded bytes.
const starter = new PadesSignatureStarter(restPki);
starter.setPdfToSignFromPath(getPdfPath(fileId));
starter.signerCertificate = Buffer.from(certificate, 'base64');
starter.signaturePolicy = StandardSignaturePolicies.PADES_BASIC;
starter.securityContext = StandardSecurityContexts.PKI_BRAZIL;
const start = await starter.start();
// 3. CloudHub signs the hash with the user's key
const signature = await cloudhub.signHashAsync({
session,
hash: start.toSignHash,
digestAlgorithmOid: start.digestAlgorithmOid,
});
// 4. Rest PKI finishes the signed PDF
const finisher = new PadesSignatureFinisher(restPki);
finisher.token = start.token;
finisher.signature = signature;
const result = await finisher.finish();
if (result.certificate.pkiBrazil.cpf !== req.session.cloudhubCpf) {
return res.sendStatus(403); // someone else signed in at the provider: discard the result
}
await result.writeToFile(getSignedPdfPath(fileId));
res.render('cloud-signature/complete', { signerCert: result.certificate });
} catch (err) {
next(err);
}
});
module.exports = router;
getPdfPath and getSignedPdfPath stand for your own storage.
import cloudhub.SessionsApi;
import cloudhub.client.CloudhubClient;
import cloudhub.client.model.SessionCreateRequest;
import cloudhub.client.model.SessionModel;
import cloudhub.client.model.SignHashRequest;
import cloudhub.client.model.TrustServiceSessionTypes;
import com.lacunasoftware.restpki.PKCertificate;
import com.lacunasoftware.restpki.PadesSignatureFinisher2;
import com.lacunasoftware.restpki.PadesSignatureStarter2;
import com.lacunasoftware.restpki.RestPkiClient;
import com.lacunasoftware.restpki.SecurityContext;
import com.lacunasoftware.restpki.SignaturePolicy;
import com.lacunasoftware.restpki.SignatureResult;
import com.lacunasoftware.restpki.SignatureStartResult;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import javax.servlet.http.HttpSession; // jakarta.servlet on Spring Boot 3
import java.io.InputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.util.Base64;
import java.util.Objects;
@Controller
@RequestMapping("/cloud-signature")
public class CloudSignatureController {
private final SessionsApi cloudhub = new SessionsApi(
new CloudhubClient("https://cloudhub.lacunasoftware.com", System.getenv("CLOUDHUB_API_KEY")));
private final RestPkiClient restPki = new RestPkiClient(
"https://pki.rest/", System.getenv("RESTPKI_ACCESS_TOKEN"));
// POST /cloud-signature/discover: the form with the signer's CPF
@PostMapping("/discover")
public String discover(@RequestParam String fileId, @RequestParam String cpf,
HttpSession httpSession, Model model) throws Exception {
String plainCpf = cpf.replaceAll("\\D", "");
// Remember whose signature this is, to check the certificate later
httpSession.setAttribute("cloudhubCpf", plainCpf);
SessionCreateRequest request = new SessionCreateRequest();
request.setIdentifier(plainCpf);
request.setRedirectUri("https://app.example.com/cloud-signature/complete?fileId="
+ URLEncoder.encode(fileId, "UTF-8"));
request.setType(TrustServiceSessionTypes.SINGLE_SIGNATURE);
SessionModel session = cloudhub.apiSessionsPost(request);
// The template links each entry of services to its authUrl
model.addAttribute("services", session.getServices());
return "cloud-signature/discover";
}
// GET /cloud-signature/complete?fileId=…&session=…: the redirect URI
@GetMapping("/complete")
public String complete(@RequestParam String fileId,
@RequestParam(required = false) String session,
HttpSession httpSession, Model model) throws Exception {
if (session == null || session.isEmpty()) {
return "redirect:/cloud-signature?fileId=" + fileId; // not authorized
}
// 1. The signer's certificate, from CloudHub (base64)
String certificate = unquote(cloudhub.apiSessionsCertificateGet(session));
// 2. Rest PKI starts the signature
PadesSignatureStarter2 starter = new PadesSignatureStarter2(restPki);
starter.setPdfToSign(getPdfPath(fileId));
starter.setSignerCertificateBase64(certificate);
starter.setSignaturePolicy(SignaturePolicy.PadesBasic);
starter.setSecurityContext(SecurityContext.pkiBrazil);
SignatureStartResult start = starter.start();
// 3. CloudHub signs the hash with the user's key
SignHashRequest signRequest = new SignHashRequest();
signRequest.setSession(session);
signRequest.setHash(start.getToSignHashRaw());
signRequest.setDigestAlgorithmOid(start.getDigestAlgorithmOid());
byte[] signature = Base64.getDecoder().decode(unquote(cloudhub.apiSessionsSignHashPost(signRequest)));
// 4. Rest PKI finishes the signed PDF
PadesSignatureFinisher2 finisher = new PadesSignatureFinisher2(restPki);
finisher.setToken(start.getToken());
finisher.setSignature(signature);
SignatureResult result = finisher.finish();
PKCertificate signerCert = result.getCertificate();
if (!Objects.equals(signerCert.getPkiBrazil().getCpf(), httpSession.getAttribute("cloudhubCpf"))) {
// Someone else signed in at the provider: discard the result
throw new ResponseStatusException(HttpStatus.FORBIDDEN);
}
try (InputStream signedPdf = result.openRead()) {
Files.copy(signedPdf, getSignedPdfPath(fileId), StandardCopyOption.REPLACE_EXISTING);
}
model.addAttribute("signerCert", signerCert);
return "cloud-signature/complete";
}
// The certificate and sign-hash operations answer with a JSON string ("MIIG..."),
// and the client hands back its raw bytes, quotes included
private static String unquote(byte[] body) {
return new String(body, StandardCharsets.UTF_8).replace("\"", "");
}
}
getPdfPath and getSignedPdfPath stand for your own storage and return a java.nio.file.Path.
The examples trust ICP-Brasil certificates only (TrustArbitrators.PkiBrazil,
PKI_BRAZIL, SecurityContext.pkiBrazil). To test with a provider's test certificates, trust their
root in development only; the samples show how for each library.
Variations
- A visible signature stamp. Add a visual representation through your signature library, exactly as with any other certificate. The samples linked above all add one.
- Several documents with one authorization. Create the session with
TrustServiceSessionTypes.SignatureSessionand alifetimeInSecondslong enough for the batch, then repeat steps 2 to 4 for each document with the same session. See Session types. - CAdES and XAdES. The flow is the same with your signature library's CAdES or XML starter. The PHP samples include an XML signature with CloudHub.