Certificates & PKI

Two units cover opposite directions of the same X.509 format: sgcCrypto_X509 reads and verifies a certificate someone else gave you, sgcCrypto_X509_Gen writes a certificate or request of your own. A third unit, sgcCrypto_ASN1, provides the DER/PEM plumbing both of them sit on.

Reading and verifying a certificate

sgcCrypto_X509 parses a DER encoded certificate into a plain record, and checks a chain or a revocation list against it. Use this side of the API when your application receives a certificate over the wire and has to decide whether to trust it.


function sgcX509_Parse(const aDER: TBytes; out aCert: TsgcX509Cert): Boolean;
function sgcX509_VerifySignedBy(const aCert, aIssuer: TsgcX509Cert): Boolean;
function sgcX509_VerifyChain(const aChainDER: array of TBytes;
  const aTrustRootsDER: array of TBytes; aNowUnix: Int64): Boolean;
function sgcX509_SubjectOneLine(const aCert: TsgcX509Cert): string;
function sgcX509_IssuerRFC2253(const aCert: TsgcX509Cert): string;
function sgcX509_CRL_Parse(const aDER: TBytes; out aCRL: TsgcX509CRL): Boolean;
function sgcX509_IsRevoked(const aCRL: TsgcX509CRL; const aSerial: TBytes): Boolean;
function sgcX509_CSR_Parse(const aDER: TBytes; out aCSR: TsgcX509CSR): Boolean;
function sgcX509_CSR_Verify(const aCSR: TsgcX509CSR): Boolean;

sgcX509_VerifyChain takes the chain and the trust anchors as DER blobs, not as parsed records, and a Unix timestamp to test the validity window against. Element 0 of the chain is the leaf and each certificate must be signed by the next one; the top must be signed by, or be equal to, one of the trust roots. The path is rejected when a certificate is outside its validity window at aNowUnix, when an issuer is not a CA, when it breaks basicConstraints pathLenConstraint (self-issued certificates do not count against the limit, RFC 5280 section 6.1.4), or when it breaks a nameConstraints extension on any certificate in the path, the trust anchor included. The constraint choices checked are dNSName, iPAddress, rfc822Name, uniformResourceIdentifier and directoryName; the DNS subtrees also apply to a leaf common name that reads as a host name when the leaf carries no DNS name of its own, and a critical nameConstraints naming any other choice rejects the path. Certificate policies are not checked.

TsgcX509Cert exposes the fields a caller actually needs after parsing: Version, SerialNumber, the raw Issuer/Subject RDN sequences, NotBeforeUnix/NotAfterUnix, the public key (whichever PubKeyType says it is, x509pkRSA, x509pkEC, x509pkEd25519, x509pkMLDSA, x509pkSLHDSA or x509pkMLKEM) and IsCA. A post-quantum key arrives in PQCPublicKey, and the parameter set its object identifier names is in whichever of MLDSAParams, SLHDSAParams or MLKEMParams matches the type. TsgcX509CSR mirrors the same fields for a PKCS#10 request.

Generating a certificate or a request

sgcCrypto_X509_Gen builds a self-signed certificate, or a PKCS#10 certificate signing request to send to a CA, from an RSA key pair (see Public Key Cryptography). Both take the same options record.


function sgcX509_CreateSelfSigned(const aKey: TsgcRSAPrivateKey; const aOptions: TsgcX509Options): TBytes;
function sgcX509_CreateCSR(const aKey: TsgcRSAPrivateKey; const aOptions: TsgcX509Options): TBytes;
function sgcX509_ToPEM(const aDER: TBytes; const aLabel: string): string;

var
  oKey: TsgcRSAPrivateKey;
  oOptions: TsgcX509Options;
  oDER: TBytes;
begin
  oKey := sgcRSA_GenerateKey(2048);
  oOptions := sgcX509_DefaultOptions;
  oOptions.Subject.CommonName := 'example.com';
  oOptions.NotBefore := Now;
  oOptions.NotAfter := Now + 365;
  SetLength(oOptions.DNSNames, 2);
  oOptions.DNSNames[0] := 'example.com';
  oOptions.DNSNames[1] := 'www.example.com';
  oOptions.KeyUsage := [kuDigitalSignature, kuKeyEncipherment];
  SetLength(oOptions.ExtKeyUsage, 1);
  oOptions.ExtKeyUsage[0] := '1.3.6.1.5.5.7.3.1'; { TLS server authentication }

  oDER := sgcX509_CreateSelfSigned(oKey, oOptions);
  Memo1.Text := sgcX509_ToPEM(oDER, 'CERTIFICATE');
end;

Build the validity window from UTC.

Certificate validity is encoded as UTCTime with a Z suffix, meaning Greenwich time, so filling NotBefore and NotAfter from a local Now dates the certificate wrong by your offset from UTC. East of Greenwich a freshly issued certificate is then not yet valid until that offset elapses, west of it the certificate expires early. sgcX509_UTCNow returns the right value, and sgcX509_DefaultOptions already uses it with a five minute backdate for clock skew, the same margin real CAs apply.


function sgcX509_UTCNow: TDateTime;

var
  oOptions: TsgcX509Options;
begin
  oOptions := sgcX509_DefaultOptions;
  oOptions.NotBefore := sgcX509_UTCNow;
  oOptions.NotAfter := sgcX509_UTCNow + 365;
end;

Fill in the subject alternative names.

Modern browsers and TLS libraries ignore the common name entirely and look only at the DNSNames extension; a certificate with a perfect common name and no DNS name in SubjectAltName will be rejected however correct the rest of it is.

Alongside DNSNames, IPAddresses and Emails, TsgcX509Options carries a URIs array, for subject alternative names of type uniformResourceIdentifier. A service identity that is a URI rather than a host name goes here, the concrete case being a SPIFFE ID, which a workload mesh matches literally and which no DNS name can express. The value goes in verbatim as IA5String content, so pass a full URI.


SetLength(oOptions.URIs, 1);
oOptions.URIs[0] := 'spiffe://example.com/ns/default/sa/payments';

Set IsCA := True and a non-negative PathLength to produce a CA certificate rather than a leaf certificate; leave PathLength at -1 to omit the constraint on a leaf. A CSR carries the same requested extensions in its extensionRequest attribute, which is where a CA looks for them when issuing the final certificate.

IPv6 subject alternative names.

IPAddresses takes IPv4 in dotted form and IPv6 in any of the text forms of RFC 4291: the full eight group form, a form compressed with ::, and a form with an embedded IPv4 address in the last 32 bits, in any letter case. Do not add brackets and do not add a zone index. An IPv4 entry is written as 4 octets and an IPv6 entry as 16, which is what RFC 5280 requires and what a TLS client compares the peer address against.


SetLength(oOptions.IPAddresses, 3);
oOptions.IPAddresses[0] := '192.0.2.10';
oOptions.IPAddresses[1] := '2001:db8::1';
oOptions.IPAddresses[2] := '::ffff:192.0.2.10';

Name constraints on a CA certificate.

A CA certificate can limit what the certificates below it are allowed to name, RFC 5280 section 4.2.1.10. Fill any of PermittedDNSNames, ExcludedDNSNames, PermittedIPRanges or ExcludedIPRanges and the extension is written, marked critical. A DNS entry is a subtree, so example.com covers that name and every name below it. An IP entry is an address with a prefix length. sgcX509_VerifyChain enforces what you write here.


oOptions.IsCA := True;
oOptions.PathLength := 0;
oOptions.KeyUsage := [kuKeyCertSign, kuCRLSign];
SetLength(oOptions.PermittedDNSNames, 1);
oOptions.PermittedDNSNames[0] := 'internal.example.com';
SetLength(oOptions.PermittedIPRanges, 2);
oOptions.PermittedIPRanges[0] := '192.0.2.0/24';
oOptions.PermittedIPRanges[1] := '2001:db8::/32';

Issuing from a CA

A self-signed certificate asserts nothing a third party can check. sgcX509_CreateSigned issues a certificate from a CA key and the CA's own certificate, and sgcX509_CreateSignedFromCSR does the same starting from a PKCS#10 request.


function sgcX509_CreateSigned(const aIssuerKey: TsgcX509SignKey;
  const aIssuerCertDER, aSubjectPublicKeyInfo: TBytes; const aOptions: TsgcX509Options): TBytes;
function sgcX509_CreateSignedFromCSR(const aIssuerKey: TsgcX509SignKey;
  const aIssuerCertDER, aCSRDER: TBytes; const aOptions: TsgcX509Options): TBytes;

The issuer name is copied byte for byte from the subject of the issuer certificate, so the chain links exactly, and the authorityKeyIdentifier is that certificate's subjectKeyIdentifier. TsgcX509Options.Issuer is never read by any builder. Both functions raise before signing anything when the issuer certificate is malformed, is not a CA, or carries a key usage without keyCertSign, when the key you passed is not the key of that certificate, and when the subject key is malformed or of an algorithm the unit does not know.

When issuing from a request, the signature on the request is verified first and a request that fails raises. The subject name and the public key come from the request, so aOptions.Subject is ignored, and the extensions come from aOptions alone. What the request asked for is not copied: the CA decides what the certificate asserts.

Signing with an EC key

sgcX509_CreateSelfSigned and sgcX509_CreateCSR sign with RSA. The Ex pair takes a TsgcX509SignKey instead, which wraps either kind of key, so the same options record produces an RSA or an EC certificate depending on what you hand it. Build the wrapper with sgcX509_RSAKey or sgcX509_ECKey rather than filling the record by hand, the unused half is left undefined on purpose. The two original functions are unchanged and now simply wrap sgcX509_RSAKey internally.

A P-256 certificate is roughly a third the size of a 2048 bit RSA one and verifies far faster, which is why modern deployments default to EC. TsgcX509Options.Hash still only names the SHA; the signature algorithm follows the key kind, so rhSHA256 with an EC key means ecdsa-with-SHA256, and with an RSA key it means sha256WithRSAEncryption.


type
  TsgcX509KeyKind = (xkRSA, xkEC, xkMLDSA, xkSLHDSA);

function sgcX509_RSAKey(const aKey: TsgcRSAPrivateKey): TsgcX509SignKey;
function sgcX509_ECKey(aCurve: TsgcECCurve; const aPrivateKey: TBytes): TsgcX509SignKey;
function sgcX509_CreateSelfSignedEx(const aKey: TsgcX509SignKey; const aOptions: TsgcX509Options): TBytes;
function sgcX509_CreateCSREx(const aKey: TsgcX509SignKey; const aOptions: TsgcX509Options): TBytes;

var
  oPrivateKey, oPublicKey, oDER: TBytes;
  oOptions: TsgcX509Options;
begin
  sgcEC_GenerateKeyPair(eccP256, oPrivateKey, oPublicKey);
  oOptions := sgcX509_DefaultOptions;
  oOptions.Subject.CommonName := 'example.com';
  SetLength(oOptions.DNSNames, 1);
  oOptions.DNSNames[0] := 'example.com';

  oDER := sgcX509_CreateSelfSignedEx(sgcX509_ECKey(eccP256, oPrivateKey), oOptions);
  Memo1.Text := sgcX509_ToPEM(oDER, 'CERTIFICATE');
end;

sgcX509_ECKey derives the public point from the scalar, so you never have to pass it. The curve comes from sgcCrypto_ECCurves and can be any of the seven it implements, though a public CA will only accept P-256 or P-384.

Post-quantum certificates

The same Ex builders sign with a post-quantum key. sgcX509_MLDSAKey wraps an ML-DSA key pair and sgcX509_SLHDSAKey an SLH-DSA private key, and from there nothing else changes: the same options record produces a self-signed certificate or a certificate signing request signed with ML-DSA or SLH-DSA. See Post-Quantum Cryptography for the schemes themselves and for the key containers the certificate carries.


function sgcX509_MLDSAKey(aParams: TsgcMLDSAParams; const aPrivateKey, aPublicKey: TBytes): TsgcX509SignKey;
function sgcX509_SLHDSAKey(aParams: TsgcSLHDSAParams; const aPrivateKey: TBytes): TsgcX509SignKey;

var
  oSeed, oPub, oPriv, oDER: TBytes;
  oOptions: TsgcX509Options;
begin
  sgcMLDSA_GenerateKeyPairAndSeed(mldsa65, oSeed, oPub, oPriv);

  oOptions := sgcX509_DefaultOptions;
  oOptions.Subject.CommonName := 'example.com';
  SetLength(oOptions.DNSNames, 1);
  oOptions.DNSNames[0] := 'example.com';
  { the default options ask for kuKeyEncipherment, which ML-DSA does not allow }
  oOptions.KeyUsage := [kuDigitalSignature];

  oDER := sgcX509_CreateSelfSignedEx(sgcX509_MLDSAKey(mldsa65, oPriv, oPub), oOptions);
  Memo1.Text := sgcX509_ToPEM(oDER, 'CERTIFICATE');
end;

Both schemes sign in the pure mode with an empty context string, and the signature algorithm is the key's own object identifier with the parameters absent, following RFC 9881 for ML-DSA and RFC 9909 for SLH-DSA. TsgcX509Options.Hash is ignored for these keys, since neither hashes the body separately. sgcX509_MLDSAKey checks both key lengths and makes the pair sign and verify a fixed message, so a private key handed the wrong public key raises there instead of producing a certificate nothing can verify.

Key usage is restricted.

Only kuDigitalSignature, kuNonRepudiation, kuKeyCertSign and kuCRLSign are accepted with an ML-DSA or SLH-DSA key, and the rule is enforced before anything is signed. sgcX509_DefaultOptions asks for kuKeyEncipherment, so it raises with these keys until you change KeyUsage. An empty KeyUsage leaves the extension out, which both RFCs allow.

Certifying an ML-KEM key.

An ML-KEM key encapsulates, it never signs, so it can be the subject of a certificate but can never sign a request to ask for one. Pass its SubjectPublicKeyInfo to sgcX509_CreateSigned after proving possession by other means, which is what RFC 9935 leaves to the application. kuKeyEncipherment is the only key usage flag allowed on such a certificate. For the same reason sgcX509_CSR_Verify can never pass on an ML-KEM request, and sgcX509_VerifySignedBy never verifies anything with an ML-KEM issuer key.

Reading one back.

sgcX509_Parse and sgcX509_CSR_Parse treat a post-quantum certificate as malformed when its SubjectPublicKeyInfo or its ML-DSA or SLH-DSA signature algorithm carries parameters, and when the key length does not match the parameter set the object identifier names. An ML-KEM key must additionally pass the FIPS 203 modulus check. sgcX509_VerifySignedBy verifies an ML-DSA or SLH-DSA signature in the pure mode with an empty context, and returns False when the parameter set named by the signature algorithm is not the issuer key's own.

DER, ASN.1 and PEM infrastructure

sgcCrypto_ASN1 reads DER, decodes PEM and Base64, and parses RSA/EC private and public keys out of DER. sgcCrypto_DER is the writer, used internally by sgcCrypto_X509_Gen and by the RSA key export functions on the Public Key Cryptography page; most applications will not call it directly.


function sgcPEM_Decode(const aPEM: string; out aLabel: string): TBytes;
function sgcPEM_Encode(const aDER: TBytes; const aLabel: string): string;
function sgcBase64Encode(const aData: TBytes): string;
function sgcBase64Decode(const aText: string): TBytes;