X.509 certificates
mdoc issuers, SD-JWT VC issuers and OpenID4VP verifiers often sign with a key that's bound to an X.509 certificate instead of a DID. agent.x509 creates certificates and certificate signing requests (CSRs), validates certificate chains and creates certificate revocation lists (CRLs). How a verifier decides which certificates to trust is on Trusted issuers.
In the examples, one agent plays the certificate authority (CA) and a second agent is a verifier. They use these imports:
import {
Agent,
CredoWebCrypto,
X509Certificate,
X509CertificateRevocationListEntryReason,
X509ExtendedKeyUsage,
X509KeyUsage,
X509Module,
X509RevocationCheckMode,
X509ValidationError,
} from '@credo-ts/core'
import { PublicJwk } from '@credo-ts/core/kms'
import { agentDependencies } from '@credo-ts/node'
Create a CA certificate
A CA signs the certificates of issuers or verifiers, and whoever checks those certificates trusts the root certificate of the CA. For mdoc issuers, this root is the issuing authority CA (IACA). Create a key in the KMS and a self-signed certificate for it:
const rootKey = await agent.kms.createKey({ type: { kty: 'EC', crv: 'P-256' } })
const rootPublicJwk = PublicJwk.fromPublicJwk(rootKey.publicJwk)
// Without a subjectPublicKey the certificate is self-signed
const rootCertificate = await agent.x509.createCertificate({
authorityKey: rootPublicJwk,
issuer: { commonName: 'Example Root CA', countryName: 'NL' },
validity: { notBefore: new Date(), notAfter: new Date(Date.now() + 5 * oneYear) },
extensions: {
subjectKeyIdentifier: { include: true },
keyUsage: { usages: [X509KeyUsage.KeyCertSign, X509KeyUsage.CrlSign], markAsCritical: true },
basicConstraints: { ca: true, pathLenConstraint: 0, markAsCritical: true },
},
})
const rootCertificatePem = rootCertificate.toString('pem')
Without subjectPublicKey, the authorityKey signs its own certificate and issuer is also the subject. Passing subject for a self-signed certificate throws. pathLenConstraint: 0 lets this CA sign end-entity certificates, but no intermediate CAs.
issuer and subject take commonName, countryName, stateOrProvinceName and organizationalUnit. For other name attributes, pass a distinguished name string such as 'CN=Example Root CA, O=Example, C=NL'.
Issue a certificate
Sign a certificate for another key with the CA key. This one is an issuer certificate:
const issuerKey = await agent.kms.createKey({ type: { kty: 'EC', crv: 'P-256' } })
const issuerCertificate = await agent.x509.createCertificate({
authorityKey: rootPublicJwk,
subjectPublicKey: PublicJwk.fromPublicJwk(issuerKey.publicJwk),
issuer: rootCertificate.subject,
subject: { commonName: 'Example Issuer', countryName: 'NL' },
validity: { notBefore: new Date(), notAfter: new Date(Date.now() + oneYear) },
extensions: {
authorityKeyIdentifier: { include: true },
subjectKeyIdentifier: { include: true },
keyUsage: { usages: [X509KeyUsage.DigitalSignature], markAsCritical: true },
// Document signer usage, required for mdoc issuer certificates
extendedKeyUsage: { usages: [X509ExtendedKeyUsage.MdlDs], markAsCritical: true },
// An SD-JWT VC `iss` must match a SAN URI, or its host a SAN DNS name
subjectAlternativeName: { name: [{ type: 'dns', value: 'issuer.example.com' }] },
},
})
// The certificate keeps the KMS key id of the subject key, so Credo can sign with it
console.log(issuerCertificate.keyId === issuerKey.keyId)
Credo signs with authorityKey, so it must be a key in the agent's KMS. The certificate gets the key id of subjectPublicKey, which lets the agent that holds that key sign with the certificate.
The extensions and subject alternative names (SANs) depend on what the certificate is for:
| Certificate | What it needs |
|---|---|
| CA, or mdoc IACA | basicConstraints with ca: true, and a key usage with KeyCertSign, plus CrlSign if the CA signs CRLs. Credo rejects a CA certificate without basicConstraints, or with a key usage that lacks these. |
| mdoc issuer (document signer) | A country (countryName) in the subject, DigitalSignature key usage, and the MdlDs extended key usage. Credo checks the country but not the extended key usage, which ISO 18013-5 requires. See mdoc. |
| SD-JWT VC issuer | When the credential has an iss, a SAN URI equal to it, or a SAN DNS name equal to its host. Without iss, no SAN is needed. See SD-JWT VC. |
| OpenID4VP verifier (access certificate) | With the x509_san_dns client id prefix (the default), the host of the verifier's baseUrl as a SAN DNS name. With x509_hash, no SAN is needed. See Verifying credentials. |
| mdoc reader (DC API reader authentication) | No specific extensions: the wallet checks that the chain leads to a reader certificate it trusts. See Digital Credentials API. |
Configure the X.509 module
X509Module is registered by default under the key x509. Register it yourself to pass options:
const verifierAgent = new Agent({
dependencies: agentDependencies,
modules: {
...storage, // Storage and key management, see Agent setup
x509: new X509Module({
trustedCertificates: [rootCertificatePem],
revocationCheck: { mode: X509RevocationCheckMode.SoftFail },
}),
},
})
| Option | Description |
|---|---|
trustedCertificates | PEM or base64 DER strings, or X509Certificate instances. Credo uses them as trust anchors when nothing else decides trust, see Trusted issuers. |
getTrustedCertificatesForVerification | Deprecated. Use the agent's getTrustedIssuersForVerification callback instead. |
revocationCheck | Enables CRL checking during chain validation. See Revocation. |
parseOptions | Limits for parsing certificates. See Parse limits. |
To change the trusted certificates at runtime, use agent.x509.config.addTrustedCertificate(certificate) or agent.x509.config.setTrustedCertificates(certificates).
Certificate signing requests
When the key holder and the CA are different parties, the key holder sends a CSR. Here the verifier requests an access certificate:
// Verifier: create a key and a certificate signing request (CSR) for it
const verifierKey = await verifierAgent.kms.createKey({ type: { kty: 'EC', crv: 'P-256' } })
const csr = await verifierAgent.x509.createCertificateSigningRequest({
subjectPublicKey: PublicJwk.fromPublicJwk(verifierKey.publicJwk),
subject: { commonName: 'Example Verifier', countryName: 'NL' },
extensions: {
// With the x509_san_dns client id prefix, the verifier's host must be a SAN DNS name
subjectAlternativeName: { name: [{ type: 'dns', value: 'verifier.example.com' }] },
},
})
const encodedCsr = csr.toString('pem')
// CA: parse the CSR and check its signature, which proves possession of the key
const receivedCsr = agent.x509.parseCertificateSigningRequest({ encodedCertificateSigningRequest: encodedCsr })
await receivedCsr.verify(new CredoWebCrypto(agent.context))
const accessCertificate = await agent.x509.createCertificate({
authorityKey: rootPublicJwk,
subjectPublicKey: receivedCsr.publicJwk,
issuer: rootCertificate.subject,
subject: receivedCsr.subject,
validity: { notBefore: new Date(), notAfter: new Date(Date.now() + oneYear) },
extensions: {
authorityKeyIdentifier: { include: true },
keyUsage: { usages: [X509KeyUsage.DigitalSignature], markAsCritical: true },
subjectAlternativeName: { name: receivedCsr.sanDnsNames.map((value) => ({ type: 'dns', value })) },
},
})
const accessCertificatePem = accessCertificate.toString('pem')
parseCertificateSigningRequest doesn't check the CSR signature. Call verify to make sure the requester holds the private key. The CA decides which extensions go into the certificate: copy only the values from the CSR that you've checked.
Parse a certificate
X509Certificate.fromEncodedCertificate parses a PEM or base64 DER certificate. A parsed certificate has no key id, so set keyId to the KMS key id before you sign with it:
// Verifier: parse the certificate returned by the CA and link it to the KMS key
const verifierCertificate = X509Certificate.fromEncodedCertificate(accessCertificatePem)
verifierCertificate.keyId = verifierKey.keyId
console.log(verifierCertificate.subject, verifierCertificate.sanDnsNames)
Getters include subject, issuer, sanDnsNames, sanUriNames, keyUsage, extendedKeyUsage, isCertificateAuthority, crlDistributionPoints and publicJwk. Encode a certificate with toString('pem') or toString('base64'), and compare two with equal.
Validate a certificate chain
const validatedChain = await verifierAgent.x509.validateCertificateChain({
// Leaf first, as in an x5c header. The root may be left out when it is a trusted certificate.
certificateChain: [issuerCertificate.toString('base64')],
trustedCertificates: [rootCertificatePem],
})
// The chain starts at the trust anchor and ends with the leaf
console.log(
validatedChain[0].equal(rootCertificate),
validatedChain[validatedChain.length - 1].equal(issuerCertificate)
)
Pass the chain leaf first, as in an x5c header. The chain may leave out the root if it's in trustedCertificates. On success Credo returns the chain from the trust anchor to the leaf. On failure it throws an X509ValidationError, whose validationResult.validations shows which check failed.
Credo checks the signatures and validity periods, basic constraints, the keyCertSign key usage of CA certificates, path length constraints, critical extensions, and revocation when enabled. It doesn't check OCSP, name constraints or certificate policies, and it doesn't enforce extended key usage.
Without trustedCertificates, Credo validates the chain but doesn't check that it ends at a certificate you trust. The module's trustedCertificates aren't used here.
allowNonRootTrustedCertificate defaults to true: a trusted intermediate or leaf certificate acts as the trust anchor, and Credo doesn't verify its signature. ISO 18013-5 needs this, because mdoc issuers don't include the root in the chain. Set it to false to require that trusted certificates are verified up to a self-signed root. The default may change in a future version.
Revocation
Credo checks revocation with CRLs, from the URLs in a certificate's crlDistributionPoints. Revocation checking is off unless you set revocationCheck on the module:
| Mode | Behaviour |
|---|---|
X509RevocationCheckMode.SoftFail | Default when revocationCheck is set. Rejects revoked certificates and invalid CRLs, and skips the check when the CRL can't be downloaded. |
X509RevocationCheckMode.Require | Also fails when the CRL can't be downloaded. |
X509RevocationCheckMode.Disabled | No revocation checks. |
Certificates without a CRL distribution point pass the check. Credo checks every certificate in the chain except the root (checkFullChain), and caches a CRL for an hour (crlCacheExpirySeconds), but never past its nextUpdate.
To revoke certificates, create a CRL signed by the CA key and publish it at the distribution point URL:
const revokedKey = await agent.kms.createKey({ type: { kty: 'EC', crv: 'P-256' } })
const revokedCertificate = await agent.x509.createCertificate({
authorityKey: rootPublicJwk,
subjectPublicKey: PublicJwk.fromPublicJwk(revokedKey.publicJwk),
issuer: rootCertificate.subject,
subject: { commonName: 'Revoked Issuer', countryName: 'NL' },
validity: { notBefore: new Date(), notAfter: new Date(Date.now() + oneYear) },
extensions: {
authorityKeyIdentifier: { include: true },
keyUsage: { usages: [X509KeyUsage.DigitalSignature], markAsCritical: true },
crlDistributionPoints: { urls: [crlUrl] },
},
})
const crl = await agent.x509.createCertificateRevocationList({
authorityKey: rootPublicJwk,
issuer: rootCertificate.subject,
validity: { nextUpdate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) },
entries: [{ certificate: revokedCertificate, reason: X509CertificateRevocationListEntryReason.KeyCompromise }],
extensions: { crlNumber: { value: 1 }, authorityKeyIdentifier: { include: true } },
})
// Serve the DER encoded CRL at the URL in the certificate's crlDistributionPoints
const crlBytes = crl.rawCertificateRevocationList
Validating the revoked certificate now fails on the revocation check:
try {
await verifierAgent.x509.validateCertificateChain({
certificateChain: [revokedCertificate.toString('base64')],
trustedCertificates: [rootCertificatePem],
})
} catch (error) {
if (!(error instanceof X509ValidationError)) throw error
// The validations show which check failed, here revocationStatus
console.log('Rejected:', error.message, error.validationResult.validations.revocationStatus?.isValid)
}
Parse limits
Credo limits how much work parsing a certificate or CRL may take, as these often come from untrusted input. X509ParseOptions has three limits:
| Option | Default | Description |
|---|---|---|
maxDepth | 100 | Maximum ASN.1 nesting depth. |
maxNodes | 1,500,000 | Maximum number of ASN.1 nodes. The default fits a CRL of 10 MB, the default download limit. |
maxContentLength | 16 MB | Maximum size of a single ASN.1 value. |
Set them on the module with parseOptions, per call on validateCertificateChain, parseCertificateSigningRequest, checkCertificateRevocation and fetchCertificateRevocationList, or as the second argument of X509Certificate.fromEncodedCertificate. Options replace the defaults as a whole: a limit you leave out falls back to the parser's own default, not to Credo's. For maxNodes that's 10,000, which rejects CRLs with more than about 2,000 entries. Spread defaultX509ParseOptions to change one limit:
import { defaultX509ParseOptions, X509Module } from '@credo-ts/core'
new X509Module({ parseOptions: { ...defaultX509ParseOptions, maxDepth: 50 } })
The module's parseOptions only apply to parsing done by the X.509 module. Certificates in the x5c of an SD-JWT VC or the issuer data of an mdoc are parsed with defaultX509ParseOptions.