Cookbook · digital signatures

Sign PDFs with CSC v2 — PAdES-B-B through B-LTA.

Integrate CodeB's Remote Signature Service into your product. Every authenticated user gets an EC P-256 signing credential whose Subject DN is enriched from the OIDC profile. Sign a hash via signHash, wrap it into a PAdES envelope client-side (sign.html) or let the server do it via signDoc, add an RFC 3161 timestamp, embed RFC 6960 OCSP revocation data, append a document timestamp — PAdES all the way up to B-LTA. Ready for European Digital Identity Wallet integrations.

Scope reminder — Advanced Electronic Signature. This produces AdES per Regulation (EU) 910/2014 Article 3(11). The signing key is software-backed and the certificate is self-signed by an in-tenant CA — not a Qualified Signature Creation Device (QSCD), not a Qualified Electronic Signature (QES). The ICryptoModule boundary is HSM-ready; Azure Key Vault and PKCS#11 back-ends are stubbed and return HTTP 501 until wired.

Try it live → Full API reference

The four PAdES conformance levels

Pick the lowest level that satisfies your evidentiary need. Higher levels add durability (survive certificate expiry) at the cost of larger envelopes and extra network round-trips.

PAdES-B-B

Basic — signed attributes only. Verifiable while the signer's certificate is still valid.

  • CMS SignerInfo with signingCertificateV2 (RFC 5035)
  • id-aa-CMSAlgorithmProtection (RFC 6211)

PAdES-B-T

Time — adds an RFC 3161 TSA token proving the signature existed at a moment in time.

  • B-B + id-aa-signatureTimeStampToken
  • ~5 KiB extra per signature

PAdES-B-LT

Long-Term — embeds OCSP responses + cert chain so verification works offline after the cert expires.

  • B-T + id-aa-ets-revocationValues + id-aa-ets-certValues
  • ~10-15 KiB extra per signature

PAdES-B-LTA

Long-Term with Archive — appends a /DocTimeStamp over the whole B-LT PDF. Repeat before the archive TSA expires to extend validity indefinitely.

  • B-LT + incremental /DocTimeStamp signature
  • ISO 32000-2 §12.8.5

1 OIDC sign-in — get an access token

CSC v2 rides on top of the tenant's OIDC provider. Use standard OAuth 2.0 Authorization Code + PKCE to obtain a Bearer token. See the OIDC sign-in cookbook for the flow. Once you have the token, everything below is Authorization: Bearer <token>.

// After OIDC round-trip:
const accessToken = tokenResp.access_token;
const authHeaders = {
  'Authorization': 'Bearer ' + accessToken,
  'Content-Type':  'application/json'
};

2 List the user's credentials

Under Phase 1b every user has exactly one per-user EC P-256 credential. Set Csc:PerUserCerts=false if you want all users to share a tenant-wide fallback.

const listResp = await fetch('/csc/v2/credentials/list', {
  method: 'POST', headers: authHeaders, body: '{}'
}).then(r => r.json());
const credentialID = listResp.credentialIDs[0];   // e.g. "codeb-user-a1b2c3d4e5f60718"

const info = await fetch('/csc/v2/credentials/info', {
  method: 'POST', headers: authHeaders,
  body: JSON.stringify({ credentialID })
}).then(r => r.json());
console.log(info.cert.subjectDN);   // "CN=Ada Lovelace, GN=Ada, SN=Lovelace, E=..."
console.log(info.cert.certificates[0]);   // base64 DER leaf

3 Compute the signed-attributes hash, then request a SAD

The Signature Activation Data (SAD) is a short-lived JWT that binds the signer, the credential and the exact hash list. Compute the SHA-256 of the PDF's CMS signedAttributes DER first (see step 5 for how signedAttrs are assembled), then:

const authorize = await fetch('/csc/v2/credentials/authorize', {
  method: 'POST', headers: authHeaders,
  body: JSON.stringify({
    credentialID, numSignatures: 1,
    hash: [ hashB64 ],
    hashAlgo: '2.16.840.1.101.3.4.2.1'   // SHA-256
  })
}).then(r => r.json());

// SCAL1 -> immediate SAD in authorize.SAD
// SCAL2 -> authorize.pending=true; prompt user for PIN + confirm:
//   POST /csc/v2/credentials/authorize/confirm { authorization_id, pin }
Bind the SAD to the exact hash. The server validates SAD.hash === request.hash on signHash. Never reuse a SAD for a different hash — that would let a compromised hash swap the signed content.

4 Sign the hash

const sig = await fetch('/csc/v2/signatures/signHash', {
  method: 'POST', headers: authHeaders,
  body: JSON.stringify({
    credentialID, SAD: authorize.SAD,
    hash: [ hashB64 ], hashAlgo: '2.16.840.1.101.3.4.2.1',
    signAlgo: '1.2.840.10045.4.3.2'   // ecdsa-with-SHA256
  })
}).then(r => r.json());

const rawSig = base64Decode(sig.signatures[0]);   // 64-byte r||s
The response is the raw ECDSA r||s concatenation (64 bytes for P-256). Wrap it in an ASN.1 SEQUENCE { INTEGER r, INTEGER s } before splicing into the CMS SignerInfo.signature OCTET STRING.

5 Assemble the PAdES envelope

This is the fiddly part. If you don't want to touch bytes, either use the browser signer (which does all of this client-side using node-forge and PDF.js) or the server-side signDoc. If you want to build it yourself:

5.1 · Reserve the /Contents placeholder in the PDF

Assemble the PDF incremental-update trailer with the signature dictionary. The /Contents<...> hex string is placeholder-padded to your target size (64 KiB is a comfortable ceiling for B-LTA). Record the byte offsets of the < and > brackets — they define the ByteRange hole.

<<
  /Type    /Sig
  /Filter  /Adobe.PPKLite
  /SubFilter /adbe.pkcs7.detached
  /ByteRange [ 0 <sigDictStart> <sigDictEnd> <afterSig> ]
  /Contents <0000000000...0000>    /* placeholder */
  /M         (D:YYYYMMDDHHMMSSZ)
  /Reason    (...)
  /Location  (...)
  /ContactInfo (...)
>>

Bracket rule (ISO 32000-1 §12.8.1.1): the < and > brackets sit inside the ByteRange hole, not the signed range.

5.2 · Compute the signedAttributes DER

Five mandatory signed attributes, DER-sorted:

  • contentType (OID 1.2.840.113549.1.9.3) = id-data
  • messageDigest (OID 1.2.840.113549.1.9.4) = SHA-256 of the PDF bytes covered by ByteRange
  • signingTime (OID 1.2.840.113549.1.9.5) = current UTC
  • signingCertificateV2 (OID 1.2.840.113549.1.9.16.2.47, RFC 5035) — ESSCertIDv2 with issuerSerial
  • id-aa-CMSAlgorithmProtection (OID 1.2.840.113549.1.9.52, RFC 6211) — digestAlgorithm + signatureAlgorithm

SHA-256 the DER-encoded SET. That's the hash you send to signHash in step 4.

5.3 · Splice the CMS into the placeholder

Build the CMS SignedData with the signer's certificate included, then hex-encode. Left-align inside the /Contents<...> window; pad the remainder with 0. Fix up the ByteRange integers so the four numbers all fit inside the space you reserved for them.

6 B-T — add an RFC 3161 timestamp

SHA-256 the CMS SignerInfo.signature OCTET STRING contents, then post to the timestamp endpoint:

const tsaHash = sha256(rawSigDerBytes);
const tsResp = await fetch('/csc/v2/signatures/timestamp', {
  method: 'POST', headers: authHeaders,
  body: JSON.stringify({
    hash: base64(tsaHash),
    hashAlgo: '2.16.840.1.101.3.4.2.1'
  })
}).then(r => r.json());

// tsResp.token is a base64 DER RFC 3161 TimeStampToken.
// Splice it into CMS SignerInfo.unsignedAttrs as
//   id-aa-signatureTimeStampToken  (OID 1.2.840.113549.1.9.16.2.14)
Two TSA endpoints ship with every tenant. signatures/timestamp proxies to whatever URL is set in HKLM\SOFTWARE\CodeB\TSAURL (defaulting to Sectigo). signatures/tsa is an in-tenant issuer with an auto-generated responder cert — use it for air-gapped or sovereign deployments (see the TSA server note).

7 B-LT — embed OCSP responses + cert chain

Long-term validation means a relying party can verify the signature after the signer's certificate has expired. You bundle the revocation evidence and the cert chain inside the signature so a future verifier does not need to reach out to any online service.

Fetch a BasicOCSPResponse for each cert whose revocation status you want to freeze:

// Build RFC 6960 OCSPRequest DER for the signer cert (self-issued
// -> issuer == subject; SHA-1 CertID per RFC 5019).
const ocspReq = buildOcspRequestDer(signerCertDer);

const ocspBytes = await fetch('/csc/v2/ocsp', {
  method: 'POST',
  headers: { 'Content-Type': 'application/ocsp-request',
             'Accept':       'application/ocsp-response' },
  body: ocspReq
}).then(r => r.arrayBuffer());

// Extract the BasicOCSPResponse (unwrap OCSPResponse.responseBytes.response).
// Repeat for the TSA cert. Bundle both into CMS unsignedAttrs as:
//   id-aa-ets-revocationValues (OID 1.2.840.113549.1.9.16.2.24)
//   id-aa-ets-certValues       (OID 1.2.840.113549.1.9.16.2.23)

Fall-back policy the browser signer uses (recommended pattern):

  • Some OCSPs fail: log it, continue as B-LT with whatever revocationValues you got.
  • All OCSPs fail: log it, degrade to B-T.

See the OCSP responder page for the full request / response wire format and error taxonomy.

8 B-LTA — append a DocTimeStamp

PDF-level /DocTimeStamp is a signature dict variant with /SubFilter /ETSI.RFC3161 and /Contents = raw RFC 3161 TimeStampToken DER (not CMS SignedData). ByteRange covers the entire prior PDF minus the new /Contents<...> gap.

// 1) Compute SHA-256 of the ENTIRE B-LT PDF as-is (all prior bytes).
// 2) Post to /csc/v2/signatures/timestamp OR /csc/v2/signatures/tsa
//    (in-tenant issuer) to get a TimeStampToken.
// 3) Assemble a NEW incremental update:
//    - New /Sig dict with /SubFilter /ETSI.RFC3161
//    - Splice raw token DER (hex) into /Contents
//    - Fix up the widget /Annots on the first page
//    - Extend AcroForm.Fields
//    - Append a fresh xref subsection + trailer with /Prev pointing at the
//      B-LT trailer's startxref
// 4) Fall-back: if the archive-TSA fetch fails, deliver B-LT instead
//    (log clearly, do not error out).

Re-run this step every few years before the archive TSA cert expires. Each fresh /DocTimeStamp extends the verifiability window.

Server-side alternative — signDoc

If you'd rather ship your PDF, a page-hint and a level flag and let the server assemble everything (including the incremental save), use POST /csc/v2/signatures/signDoc:

const signed = await fetch('/csc/v2/signatures/signDoc', {
  method: 'POST', headers: authHeaders,
  body: JSON.stringify({
    credentialID, SAD,
    documents: [{
      document:                base64OfPdfBytes,
      signature_format:        'P',      /* PAdES */
      conformance_level:       'AdES-B-LT',   /* or -B-B / -B-T / -B-LTA */
      signed_envelope_property:'ENVELOPED',
      parameters: {
        signing_reason:   'Contract acceptance',
        signing_location: 'Valletta',
        contact_info:     'legal@example.com',
        signing_time:     '20260727120000Z'  /* optional; else DateTime.UtcNow */
      }
    }]
  })
}).then(r => r.json());

const signedPdf = base64Decode(signed.documentWithSignature[0]);
Body cap for signDoc is 10 MiB (vs. 32 KiB elsewhere). The server does the SAD binding + hash computation + PAdES assembly for you and returns the finished PDF ready to download.

Troubleshooting

  • Adobe Reader shows "signature signature not applied" — usually a ByteRange bug (brackets outside the hole) or a missing widget /P. Attach the widget to the page /Annots AND set its /P to the page indirect ref.
  • Adobe shows an empty signature panel — the AcroForm dictionary is missing or does not list the widget. Update /AcroForm << /Fields [ ... ] /SigFlags 3 >>.
  • CMS parser rejects the envelope — signedAttrs must be DER-sorted by tag (SET OF requires canonical ordering).
  • PAdES-B-T verifies but LT/LTA doesn't — check that OCSP responses are BasicOCSPResponse structures (unwrapped from the outer OCSPResponse).
  • Timestamp fetch fails — the client should degrade to PAdES-B-B and log clearly. Do not fail the whole signature.

Standards + wire references