asn1cryptoとPyCryptodomeで証明書を検証する

# 概要

デジタル署名者の証明書をPythonで検証する手順を学習するために、asn1cryptoとPyCryptodomeを使用して実装してみました。簡単な実装ですので、あくまで実装検証です。

# 手順
## 証明書の検証

証明書はRFC5280で定義されており、以下のような構造になっています。

```
   Certificate  ::=  SEQUENCE  {
        tbsCertificate       TBSCertificate,
        signatureAlgorithm   AlgorithmIdentifier,
        signatureValue       BIT STRING  }

   TBSCertificate  ::=  SEQUENCE  {
        version         [0]  EXPLICIT Version DEFAULT v1,
        serialNumber         CertificateSerialNumber,
        signature            AlgorithmIdentifier,
        issuer               Name,
        validity             Validity,
        subject              Name,
        subjectPublicKeyInfo SubjectPublicKeyInfo,
        issuerUniqueID  [1]  IMPLICIT UniqueIdentifier OPTIONAL,
                             -- If present, version MUST be v2 or v3
        subjectUniqueID [2] IMPLICIT UniqueIdentifier OPTIONAL, 
                             -- If present, version MUST be v2 or v3 
        extensions [3] EXPLICIT Extensions OPTIONAL
                             -- If present, version MUST be v3 }
```

TBSCertificateは`To Be Signed` Certificateであり、証明される内容です。
このTBSCertificateのDERフォーマットに対するCAの署名がsignatureValueです。つまり、以下の情報を検証すれば、この証明書が上位のCAから署名を受けているかがわかります。

- Subject (検証したい証明書)
    - tbsCertificate -- これを検証したい
    - signatureAlgorithm -- 署名スキーム
    - signatureValue -- CAによる署名

この内容を検証するにはCAの公開鍵を入手する必要があります。証明書チェーンやOSバンドルの証明書から得ることができます。tbsCertificate.extensionsのauthorityKeyIdentifierからCA証明書の情報を取得することができます。

証明書の公開鍵はtbsCertificateに含まれています。

- Issuer (CA証明書)
    - tbsCertificate
        - subjectPublicKeyInfo -- 公開鍵

これにより必要な情報が揃いました。

- Subject.tbsCertificate -- 検証したい内容
- Subject.signatureValue -- 署名
- Issuer.tbsCertificate.subjectPublicKeyInfo -- CAの公開鍵

これらを検証すれば内容が正しいかどうかがわかります。この検証を実装したのが以下です。

# 実装
## 環境

- CentOS 7.9 + Python 3.6
- asn1crypto 1.5.1
- PyCryptodome 3.19.0

## コード

このコードでは上位証明書の有効期限、basicConstraints、keyUsageを確認しています。それ以外にもっと厳しく検証が必要な場合は追加などするとよさそうです。

```python
from importlib import import_module
from datetime import datetime, timezone

class CertificateVerifier:
    # 証明書の検証
    SIGNATURE_SCHEME = {
        "rsassa_pkcs1v15":  (".RSA", ".pkcs1_15", {}),
        "ecdsa":            (".ECC", ".DSS", {"mode": "fips-186-3", "encoding": "der"}),
        "ed25519":          (".ECC", ".eddsa", {"mode": "rfc8032"}),
        "ed448":            (".ECC", ".eddsa", {"mode": "rfc8032"}),
    }

    def validate_certificate(self, cert):
        # 証明書自身を検証
        # 有効期限
        if not cert.not_valid_before <= datetime.now(timezone.utc) <= cert.not_valid_after:
            raise ValueError("Invalid validity.")

        # basicConstraintsがCA:TRUE
        if not cert.ca: raise ValueError("This cert is not for CA.")

        # keyUsageにkeyCertSignが含まれる
        if cert.key_usage_value and "key_cert_sign" not in cert.key_usage_value.native:
            raise ValueError("This cert is not for 'key_cert_sign'.")

    def verify(self, subject, issuer):
        # 証明書を検証する
        print("-", subject.subject.native["common_name"], "<=", issuer.subject.native["common_name"])

        # issuerのプロパティを検証
        self.validate_certificate(issuer)

        # 署名を確認
        sig_scheme = self.SIGNATURE_SCHEME[subject.signature_algo]  # ex. rsassa_pkcs1v15
        pk_algo = import_module(sig_scheme[0], "Crypto.PublicKey")  # 公開鍵暗号方式
        scheme = import_module(sig_scheme[1], "Crypto.Signature")   # 署名スキーム
        hash_algo = import_module(f".{subject.hash_algo.upper()}", "Crypto.Hash")  # ex. sha384

        # EdDSAではverify()に渡すメッセージはハッシュ化しない
        msg = subject["tbs_certificate"].dump()     # TBSCertificateをDERにする
        if subject.signature_algo not in ("ed25519", "ed448"): msg = hash_algo.new(msg)
        pubkey = pk_algo.import_key(issuer.public_key.dump())
        verifier = scheme.new(pubkey, **sig_scheme[2])
        verifier.verify(msg, subject.signature)

        print("  - Verify OK")

if __name__ == "__main__":
    # test.crtが検証したい証明書、ca.crtがCA証明書(PEM)
    from asn1crypto.pem import unarmor
    from asn1crypto.x509 import Certificate
    subject = Certificate.load(unarmor(open("test.crt", "rb").read())[2])
    issuer = Certificate.load(unarmor(open("ca.crt", "rb").read())[2])
    v = CertificateVerifier()
    v.verify(subject, issuer)
```

動作は、OpenSSL 3.0.9で生成した証明書と手元にあったタイムスタンプトークンで行いました。
以下の方式では多分動きます。

- RSASSA-PKCS1v1.5
- ECDSA(prime256v1)
- Ed25519
- Ed448

# まとめ

はじめはpyasn1を使おうと思ったのですが、読み込みなどに癖が強く、イマイチ使いこなせなかったのでasn1cryptoを使いました。asn1cryptoは、pyasn1が不満で(?)作ったというようなことが書かれているだけあって、慣れてしまえば非常に使いやすかったです。

asn1cryptoのモジュールの使い方などはasn1cryptoのソースを読む必要があるので、ちょっと面倒かも。Certificateクラスの便利プロパティなどはソースを見て探しました。

# 参考

- PyPI - asn1crypto -- https://pypi.org/project/asn1crypto/
- PyCryptodome -- https://www.pycryptodome.org/
- RFC5280 -- https://www.rfc-editor.org/rfc/rfc5280.html