证书与 PKI

有两个单元分别覆盖同一个 X.509 格式的两个相反方向:sgcCrypto_X509 读取并验证他人交给您的证书,sgcCrypto_X509_Gen 生成您自己的证书或请求。第三个单元 sgcCrypto_ASN1 提供了二者所依赖的 DER/PEM 基础设施。

 

读取并验证证书

 

sgcCrypto_X509 把一个 DER 编码的证书解析成一个普通记录,并针对它检查证书链或吊销列表。 当您的应用程序通过网络接收到一个证书、需要判断是否信任它时,应使用 API 的这一侧。

 


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 接受的证书链和信任锚都是 DER 数据块,而不是已解析的记录,另外还接受一个用于检验有效期窗口的 Unix 时间戳。链中第 0 个元素是叶子证书,每个证书都必须由它的下一个证书签名; 最顶端的证书必须由某个信任根签名,或者本身就等于某个信任根。出现以下情况时该路径会被拒绝: 某个证书在 aNowUnix 时刻不在其有效期窗口内、某个签发者不是 CA、违反了 basicConstraints 的 pathLenConstraint (自签发证书不计入该限制,见 RFC 5280 第 6.1.4 节),或者违反了路径中任一证书(包括信任锚) 上的 nameConstraints 扩展。 会被检查的约束类型有 dNSName、 iPAddress、 rfc822Name、 uniformResourceIdentifier 和 directoryName; 当叶子证书本身不带任何 DNS 名称,而它的通用名称看起来像一个主机名时,DNS 子树约束同样适用于该通用名称, 而一个标记为关键、却指定了其他任何类型的 nameConstraints 会导致该路径被拒绝。证书策略不在检查之列。

 

TsgcX509Cert 暴露了调用方在解析后实际需要用到的字段:Version、 SerialNumber、 原始的 Issuer/Subject RDN 序列、NotBeforeUnix/NotAfterUnix、 公钥(具体类型取决于 PubKeyType 的值,可能是 x509pkRSA、 x509pkEC、 x509pkEd25519、 x509pkMLDSA、 x509pkSLHDSA 或 x509pkMLKEM) 以及 IsCA。 后量子密钥会放在 PQCPublicKey 中, 而其对象标识符所指明的参数集,则位于 MLDSAParams、 SLHDSAParams 或 MLKEMParams 中与该类型相符的那一个。 TsgcX509CSR 为 PKCS#10 请求提供同样的这些字段。

 

生成证书或请求

 

sgcCrypto_X509_Gen 可以根据一对 RSA 密钥(参见公钥密码学)构建一个自签名证书, 或者构建一个要发送给 CA 的 PKCS#10 证书签名请求。两者使用同一个选项记录。

 


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;

 

依据 UTC 构建有效期窗口。

证书的有效期以 UTCTime 编码,带有 Z 后缀,表示格林尼治时间,因此用本地的 Now 填写 NotBefore 和 NotAfter,会让证书的日期偏离您与 UTC 之间的时差。在格林尼治以东,新签发的证书会因此要等这段时差过去才生效;在格林尼治以西, 证书则会提前过期。sgcX509_UTCNow 会返回正确的值,而 sgcX509_DefaultOptions 已经在使用它,并为应对时钟偏差回退了五分钟,这与真实 CA 所采用的余量相同。

 


function sgcX509_UTCNow: TDateTime;

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

 

务必填写主题备用名称。

现代浏览器和 TLS 库完全忽略通用名称,只会查看 DNSNames 扩展;一个通用名称完美无缺、但 SubjectAltName 中没有任何 DNS 名称的证书,无论其余部分多么正确,都会被拒绝。

 

除 DNSNames、 IPAddresses 和 Emails 之外, TsgcX509Options 还携带一个 URIs 数组,用于类型为 uniformResourceIdentifier 的主题备用名称。当服务身份是一个 URI 而非主机名时,就应使用这里,典型场景是 SPIFFE ID, 工作负载网格会对其进行字面匹配,而任何 DNS 名称都无法表达这种身份。该值会按原样作为 IA5String 内容写入,因此请传入完整的 URI。

 


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

 

将 IsCA := True 并设置一个非负的 PathLength, 即可生成 CA 证书而不是叶子证书;若要在叶子证书上省略该约束,请将 PathLength 保持为 -1。CSR 会在其 extensionRequest 属性中携带相同的请求扩展,这也是 CA 在签发最终证书时查找它们的地方。

 

IPv6 主题备用名称。

IPAddresses 接受点分形式的 IPv4,以及 RFC 4291 所定义的任意一种 IPv6 文本形式:完整的八组形式、 用 :: 压缩过的形式, 以及在最后 32 位中内嵌 IPv4 地址的形式,字母大小写不限。请不要加方括号,也不要加区域索引。 IPv4 条目会写成 4 个八位组,IPv6 条目写成 16 个,这正是 RFC 5280 的要求, 也是 TLS 客户端用来与对端地址进行比较的形式。

 


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';

 

CA 证书上的名称约束。

CA 证书可以限制它下面的证书允许使用哪些名称,见 RFC 5280 第 4.2.1.10 节。只要填写了 PermittedDNSNames、 ExcludedDNSNames、 PermittedIPRanges 或 ExcludedIPRanges 中的任意一项, 就会写入该扩展,并把它标记为关键。DNS 条目代表一棵子树,因此 example.com 既涵盖该名称本身,也涵盖它之下的所有名称。IP 条目则是一个地址加上前缀长度。 sgcX509_VerifyChain 会强制执行您在这里写下的内容。

 


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';

 

由 CA 签发

 

自签名证书并不能向第三方证明任何东西。 sgcX509_CreateSigned 会用 CA 的密钥和 CA 自己的证书签发一张证书,而 sgcX509_CreateSignedFromCSR 做的是同一件事,只是从一个 PKCS#10 请求出发。

 


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;

 

签发者名称是从签发者证书的主题逐字节复制过来的,因此证书链可以精确衔接,而 authorityKeyIdentifier 就是该证书的 subjectKeyIdentifier。 任何构建函数都不会读取 TsgcX509Options.Issuer。 在签名任何内容之前,出现以下情况时这两个函数都会引发异常:签发者证书畸形、它不是 CA、 它带有一个不包含 keyCertSign 的密钥用途,您传入的密钥并不是该证书对应的密钥,以及主题密钥畸形或者属于本单元不认识的算法。

 

从请求签发时,会先验证请求上的签名,验证失败的请求会引发异常。主题名称和公钥都来自该请求, 因此 aOptions.Subject 会被忽略, 而扩展则完全来自 aOptions。 请求中提出的诉求不会被复制过来:证书断言什么内容,由 CA 决定。

 

使用 EC 密钥签名

 

sgcX509_CreateSelfSigned 和 sgcX509_CreateCSR 使用 RSA 签名。 Ex 版本改为接受一个 TsgcX509SignKey,它可以封装任意一种密钥, 因此同一个选项记录可以根据传入的内容生成 RSA 证书或 EC 证书。请使用 sgcX509_RSAKey 或 sgcX509_ECKey 来构建这个包装类型, 而不要手动填写该记录,未使用的那一半会故意保持未定义状态。原来的两个函数保持不变, 现在内部只是简单地封装了 sgcX509_RSAKey。

 

P-256 证书的体积大约只有 2048 位 RSA 证书的三分之一,验证速度也快得多,这正是现代部署默认选用 EC 的原因。TsgcX509Options.Hash 仍然只指定 SHA 算法;签名算法则由密钥类型决定,因此 rhSHA256 配合 EC 密钥意味着 ecdsa-with-SHA256, 配合 RSA 密钥则意味着 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 会根据标量推导出公钥点, 因此您永远不需要自己传入它。曲线来自 sgcCrypto_ECCurves, 可以是它实现的七条曲线中的任意一条,不过公共 CA 只会接受 P-256 或 P-384。

 

后量子证书

 

同样是这些 Ex 构建函数,也可以用后量子密钥签名。sgcX509_MLDSAKey 封装一对 ML-DSA 密钥,sgcX509_SLHDSAKey 封装一个 SLH-DSA 私钥,除此之外别无变化:同一个选项记录既可以生成用 ML-DSA 或 SLH-DSA 签名的自签名证书,也可以生成同样签名的证书签名请求。关于这些方案本身, 以及证书所携带的密钥容器,请参阅后量子密码学。

 


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;

 

两种方案都在纯模式下、使用空的上下文字符串进行签名,签名算法就是该密钥自身的对象标识符, 且参数缺省,ML-DSA 遵循 RFC 9881,SLH-DSA 遵循 RFC 9909。对这些密钥而言, TsgcX509Options.Hash 会被忽略,因为它们都不会单独对证书主体做哈希。 sgcX509_MLDSAKey 会检查两个密钥的长度,并让这一对密钥对一条固定消息完成签名和验证, 因此当私钥配上了错误的公钥时,异常会在这里抛出,而不是生成一张谁也验证不了的证书。

 

密钥用途受到限制。

使用 ML-DSA 或 SLH-DSA 密钥时,只接受 kuDigitalSignature、 kuNonRepudiation、 kuKeyCertSign 和 kuCRLSign, 而且这条规则会在签名任何内容之前就被执行。 sgcX509_DefaultOptions 要求的是 kuKeyEncipherment, 因此在您修改 KeyUsage 之前, 它配合这些密钥会引发异常。把 KeyUsage 留空则会省略该扩展,两份 RFC 都允许这样做。

 

为 ML-KEM 密钥签发证书。

ML-KEM 密钥只做封装,从不签名,因此它可以作为证书的主题,却永远无法签署一个请求来申请证书。 请在通过其他方式证明了私钥持有权之后,把它的 SubjectPublicKeyInfo 传给 sgcX509_CreateSigned, RFC 9935 把这件事留给了应用程序自行处理。这类证书上唯一允许的密钥用途标志是 kuKeyEncipherment。 出于同样的原因,sgcX509_CSR_Verify 对 ML-KEM 请求永远不可能通过,而 sgcX509_VerifySignedBy 也永远不会用 ML-KEM 签发者密钥验证任何东西。

 

读取一张后量子证书。

当一张后量子证书的 SubjectPublicKeyInfo,或者它的 ML-DSA 或 SLH-DSA 签名算法带有参数时, 以及当密钥长度与其对象标识符所指明的参数集不匹配时, sgcX509_Parse 和 sgcX509_CSR_Parse 都会把它视为畸形。ML-KEM 密钥还必须额外通过 FIPS 203 的模数检查。 sgcX509_VerifySignedBy 在纯模式下、使用空上下文来验证 ML-DSA 或 SLH-DSA 签名, 当签名算法所指明的参数集不是签发者密钥自身的参数集时,它会返回 False。

 

DER、ASN.1 和 PEM 基础设施

 

sgcCrypto_ASN1 负责读取 DER、解码 PEM 和 Base64,并从 DER 中解析出 RSA/EC 私钥和公钥。 sgcCrypto_DER 是写入器, 由 sgcCrypto_X509_Gen 以及公钥密码学页面中的 RSA 密钥导出函数在内部使用; 大多数应用程序不需要直接调用它。

 


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;