同じ X.509 形式を、2つのユニットが逆方向にカバーしています。sgcCrypto_X509は他者から受け取った証明書を読み取り検証し、sgcCrypto_X509_Genは自分自身の証明書やリクエストを作成します。3つ目のユニットである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 のサブツリーはホスト名として読めるリーフのコモンネームにも適用され、他の選択肢を指定した critical な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 を基準に設定してください。
証明書の有効期間は、グリニッジ時を意味するZサフィックス付きの UTCTime としてエンコードされます。そのため、ローカルのNowからNotBeforeとNotAfterを設定すると、UTC からの時差の分だけ証明書の日付がずれてしまいます。グリニッジより東側では、発行したばかりの証明書がその時差の分だけ有効になるまで待たされ、西側では証明書が早く失効してしまいます。sgcX509_UTCNowは正しい値を返し、sgcX509_DefaultOptionsは既にこれを使用しており、実際の CA が適用するのと同じマージンである、クロックスキュー向けの5分の巻き戻しを行っています。
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はuniformResourceIdentifier型のサブジェクト代替名向けにURIs配列を保持しています。ホスト名ではなく URI であるサービス ID はここに設定します。具体的なケースが SPIFFE ID で、これはワークロードメッシュがそのまま照合するものであり、DNS 名では表現できません。値は IA5String の内容としてそのまま書き込まれるため、完全な URI を渡してください。
SetLength(oOptions.URIs, 1);
oOptions.URIs[0] := 'spiffe://example.com/ns/default/sa/payments';
IsCA := Trueと、0 以上のPathLengthを設定すると、リーフ証明書ではなく CA 証明書が生成されます。リーフでこの制約を省略するにはPathLengthを -1 のままにしてください。CSR は、要求する拡張を同じ形でextensionRequest属性に保持しており、CA は最終的な証明書を発行する際にそこを参照します。
IPv6 のサブジェクト代替名
IPAddressesは、IPv4 をドット区切り形式で、IPv6 を RFC 4291 のいずれのテキスト形式でも受け取ります。8グループをすべて書く形式、::で圧縮した形式、下位 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のいずれかを埋めると、拡張が critical として書き出されます。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で構築してください。使用しない側のフィールドは意図的に未定義のままにされています。元の2つの関数は変更されておらず、現在は内部で単純にsgcX509_RSAKeyをラップしています。
P-256 証明書は、2048 ビット RSA 証明書のおよそ3分の1のサイズで、検証もはるかに高速です。これが、現代のデプロイメントが 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が実装する7種類のいずれでも構いませんが、パブリック 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 の鍵はカプセル化を行うだけで署名は行わないため、証明書のサブジェクトにはなれますが、証明書を要求するリクエストに署名することはできません。RFC 9935 がアプリケーションに委ねているとおり、他の手段で所有を証明した上で、その SubjectPublicKeyInfo をsgcX509_CreateSignedに渡してください。そのような証明書で許可される鍵用途フラグはkuKeyEnciphermentだけです。同じ理由から、sgcX509_CSR_Verifyが ML-KEM のリクエストで成功することは決してなく、sgcX509_VerifySignedByが ML-KEM の発行者鍵で何かを検証することもありません。
読み戻し
sgcX509_ParseとsgcX509_CSR_Parseは、SubjectPublicKeyInfo や ML-DSA・SLH-DSA の署名アルゴリズムがパラメータを持っている場合、そして鍵の長さがオブジェクト識別子の示すパラメータセットと一致しない場合に、そのポスト量子証明書を不正な形式として扱います。ML-KEM の鍵はさらに FIPS 203 の法チェックに合格しなければなりません。sgcX509_VerifySignedByは ML-DSA または SLH-DSA の署名を、空のコンテキストを用いた純粋モードで検証し、署名アルゴリズムが示すパラメータセットが発行者鍵自身のものでない場合は False を返します。
DER、ASN.1、PEM の基盤処理
sgcCrypto_ASN1は DER を読み取り、PEM と Base64 をデコードし、RSA/EC の秘密鍵・公開鍵を DER から解析します。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;