AWS KMSでCSRを発行する

# 概要

AWSのKey Management Service(KMS)を使って署名を行うときに、その秘密鍵に対して証明書を発行してみたかったので試してみました。

そのためには証明書署名要求(Certificate Signing Request; CSR)を作成する必要があります。しかし、OpenSSLではローカルにある秘密鍵でしかCSRを発行できないので(たぶん)、Pythonモジュールであるpyasn1を使って発行してみました。

以下のように行います。

- pyasn1を使って署名するための情報を作成する
- その情報をKMSで署名する
- それらを組み合わせてCSRを出力する

これらの手順をPythonコードにまとめました。

ダミーの秘密鍵でOpenSSLなどでCSRを発行し、そのファイルをpyasn1でパースし、subjectPKInfoとsignatureを置換する方法(参考1)があり、実行可能でした(他の方法は見つけられなかったのでそもそも需要がないのかな…？)。ただ、ダミーで発行するというのも美しくないので、pyasn1ですべて構成する方法にしてみました。

:::note alert
セキュリティに関わるので、実際に使用する際は十分な検証を行って下さい。あくまでこのコードは自己署名CAで証明書を生成することのできるCSRの生成に成功した、程度のものです。
:::

# 手順

ここではすでにKMS上に秘密鍵を生成してあるものとします。
CentOS 7.9 + python 3.6 + pyasn1 0.5.0 + pyasn1_modules 0.3.0 + boto3を使用しました。
(pyasn1がpython 3.6で動いたので楽でした; CentOS7でスミマセン)

## Pythonによる実装

```python: generate_csr_with_kms.py
#!/usr/bin/env python3
# pyasn1とAWS KMSを使ったCSR生成
# 2023-10-23
import hashlib, textwrap
from base64 import b64encode
from pyasn1.type import univ, char
from pyasn1_modules.rfc2986 import CertificationRequest, CertificationRequestInfo
from pyasn1_modules.rfc2314 import SubjectPublicKeyInfo, SignatureAlgorithmIdentifier
from pyasn1_modules import rfc2459
from pyasn1.codec.der import decoder, encoder
import boto3

START_MARKER = "-----BEGIN CERTIFICATE REQUEST-----"
END_MARKER   = "-----END CERTIFICATE REQUEST-----"

SIGNING_ALGORITHM_OID = {
    "ECDSA_SHA_512": "1.2.840.10045.4.3.4",
    "ECDSA_SHA_384": "1.2.840.10045.4.3.3",
    "ECDSA_SHA_256": "1.2.840.10045.4.3.2",
    "ECDSA_SHA_224": "1.2.840.10045.4.3.1",
    "RSASSA_PKCS1_V1_5_SHA_512": "1.2.840.113549.1.1.13",
    "RSASSA_PKCS1_V1_5_SHA_384": "1.2.840.113549.1.1.12",
    "RSASSA_PKCS1_V1_5_SHA_256": "1.2.840.113549.1.1.11",
}

def create_subject(subject_str):
    # DNを作る
    # subject_str = "/C=JP/O=My Organization/CN=Happy World"
    def _create_rdn(attribute_type, value):
        # RDNを作る
        DNTYPE = {
            "C":    rfc2459.id_at_countryName,          # 国
            "ST":   rfc2459.id_at_stateOrProvinceName,  # 都道府県
            "L":    rfc2459.id_at_localityName,         # 市町村
            "O":    rfc2459.id_at_organizationName,     # 組織名
            "CN":   rfc2459.id_at_commonName,           # コモンネーム
        }

        attribute = rfc2459.AttributeTypeAndValue()
        attribute["type"] = DNTYPE[attribute_type]
        attribute["value"] = char.PrintableString(value)
        rdn = rfc2459.RelativeDistinguishedName()
        rdn.append(attribute)
        return rdn

    # DNを作る
    dn = rfc2459.Name()
    dn[0] = rfc2459.RDNSequence()

    subject_str = subject_str.lstrip("/")
    for rdn_str in subject_str.split("/"):
        rdn_id_str, rdn_value = [x.strip() for x in rdn_str.split("=")]
        dn[0].append(_create_rdn(rdn_id_str, rdn_value))

    return dn

def generate_csr(subject_str, key_id, signing_algorithm):
    # 内容を生成
    cri = CertificationRequestInfo()
    cri["version"] = 0
    cri["subject"] = create_subject(subject_str)

    # KMSから公開鍵を取得してsubjectPKInfoにセットする
    kms = boto3.client("kms")
    res = kms.get_public_key(KeyId=key_id)
    cri["subjectPKInfo"] = decoder.decode(res["PublicKey"], SubjectPublicKeyInfo())[0]

    # certificationRequestInfoをDERフォーマットに変換してKMSで署名する
    der_bytes = encoder.encode(cri)
    hash_algorithm = "".join(signing_algorithm.split("_")[-2:]).lower()
    digest = hashlib.new(hash_algorithm, der_bytes)
    res = kms.sign(
        KeyId=key_id,
        Message=digest.digest(),
        MessageType="DIGEST",
        SigningAlgorithm=signing_algorithm,
    )
    signature = res["Signature"]

    # CSRに各種情報を格納する
    csr = CertificationRequest()
    csr["certificationRequestInfo"] = cri
    sai = SignatureAlgorithmIdentifier()
    sai["algorithm"] = univ.ObjectIdentifier(SIGNING_ALGORITHM_OID[signing_algorithm])
    csr["signatureAlgorithm"] = sai
    csr["signature"] = univ.BitString.fromOctetString(signature)

    return csr

def write_csr(csr, filename):
    # ファイルに書き出す
    b64csr = b64encode(encoder.encode(csr)).decode()
    s = "\n".join([START_MARKER, "\n".join(textwrap.wrap(b64csr)), END_MARKER])
    open(filename, "w").write(s)

SUBJECT = "/C=JP/O=My Organization/CN=Happy Paradise"
KEY_ID  = "12345678-abcd-ef00-0000-012345678912"
SIGNING_ALGORITHM = "ECDSA_SHA_256"
OUTFILE = "test.csr"

csr = generate_csr(SUBJECT, KEY_ID, SIGNING_ALGORITHM)
write_csr(csr, OUTFILE)
```

## コード内容

### create_subject(subject_str)

- subject_str -- DN文字列(/C=JP/ST=State/L=Locality/O=Organization/CN=Common Name)

`subject_str`にDNを指定してsubjectに与えるオブジェクトを生成します。例えば、`/C=JP/O=My Organization/CN=Happy Paradise`のように指定します。

### generate_csr(subject_str, key_id, signing_algorithm)

- subject_str -- DN文字列
- key_id -- AWS KSMのKey ID
- signing_algorithm -- KMSのSigningAlgorithmパラメータ(ECDSA_SHA_256など)

DN文字列とAWS KMSを使用してCSRを生成します。コード中のコメント通り、以下のことを行います。

- Certification Request Info構造に必要なパラメータをセットする(version, subject, subjectPKInfo)
- Certification Request InfoをDER形式にエンコード後、ハッシュ化しKMSで署名する
- Certification Requestオブジェクトにパラメータをセットする

ここでは`pyasn1_modules.rfc2986.CertificationRequest`オブジェクトを返します。

### write_csr(csr, filename)

- csr -- CertificationRequestオブジェクト
- filename -- 出力ファイル

CertificationRequestオブジェクトをPEM形式にエンコードし、整形してファイルに書き出します。

以上で、KMSで署名したCSRができあがります。確認は`OpenSSL 3.0.9 30 May 2023`です。

```bash
$ ./generate_csr_with_kms.py
$ openssl req -in test.csr -text
Certificate Request:
    Data:
        Version: 1 (0x0)
        Subject: C = JP, O = My Organization, CN = Happy Paradise
        Subject Public Key Info:
            Public Key Algorithm: id-ecPublicKey
                Public-Key: (256 bit)
                pub:
                    04:...
                    4a:d6:89:69:d5
                ASN1 OID: prime256v1
                NIST CURVE: P-256
        Attributes:
            (none)
            Requested Extensions:
    Signature Algorithm: ecdsa-with-SHA256
    Signature Value:
        30:...
-----BEGIN CERTIFICATE REQUEST-----
MI...
-----END CERTIFICATE REQUEST-----
```

得られたCSRをCAで署名すれば証明書が作成可能です。

# 余談

pyasn1の使い方のページがほぼなくて、「参考」に挙げたGitHubをもとにいろいろ試行錯誤しました…。ASN.1をちゃんと理解していればいいのかもしれませんが。
Web上に資料が少ないせいか、ChatGPTによるコード生成でも、ビミョーに動かないコードが生成されて、あまり参考になりませんでした…

cryptographyを使ったコードはいくらでもあるのですが、最近のcryptographyはbackendがrustになったらしく(斜め読み)、backendの実装だけをいじるという手段ができなさそうだったので、pyasn1で構成しました。

このコードが参考になれば幸いです。

# 参考

1. 発行済みCSRの署名を置き換える方法
    - GitHub g-a-d/aws-kms-sign-csr -- https://github.com/g-a-d/aws-kms-sign-csr