证书签发与 SubjectAltName 扩展项

证书签发与 SubjectAltName 扩展项

subjectAltName 在 RFC 5280 4.2.1.6. 中提供了详细的说明,subjectAltName 是 X.509 version 3 的一个扩展项,该扩展项用于标记和界定证书持有者的身份。

在 X.509 格式的证书中,一般使用 Issuer 项标记证书的颁发者信息,该项必须是一个非空的 Distinguished Name 名称。除此之外还可以使用扩展项 issuerAltName 来标记颁发者的其他名称,这是一个非关键的扩展项。

对于证书持有者,一般使用 Subject 项标记,并使用 subjectAltName 扩展项提供更详细的持有者身份信息。 subjectAltName 全称为 Subject Alternative Name,缩写为 SAN。它可以包括一个或者多个的电子邮件地址,域名,IP地址和 URI 等,详细定义如下:

SubjectAltName ::= GeneralNames
   GeneralNames ::= SEQUENCE SIZE (1..MAX) OF GeneralName

   GeneralName ::= CHOICE {
        otherName                       [0]     OtherName,
        rfc822Name                      [1]     IA5String,
        dNSName                         [2]     IA5String,
        x400Address                     [3]     ORAddress,
        directoryName                   [4]     Name,
        ediPartyName                    [5]     EDIPartyName,
        uniformResourceIdentifier       [6]     IA5String,
        iPAddress                       [7]     OCTET STRING,
        registeredID                    [8]     OBJECT IDENTIFIER 
    }

当颁发的证书不存在 Subject 项的时候,证书必须包含扩展项 subjectAltName,并且标记为关键(critical)的。当颁发的证书存在 Subject 项的时候,必须将扩展项 subjectAltName 标记为非关键(no-critical)的。注意:用于颁发证书的 CA 证书是必须包含 Subject 项的。

根据 RFC 6125 中的规定,当一个网站使用证书标记自己的身份时,如果证书中包含 subjectAltName,在识别证书持有者时会忽略 Subject 子项,而是通过 subjectAltName 来识别证书持有者。在早期颁发的证书中一般通过 Subject 的 CommonName 来识别持有者的身份,不包含 subjectAltName 扩展项。这会导致最新版本的浏览器Chrome、Firefox 等在通过 HTTPS 访问 web 网站时,触发 NET::ERR_CERT_COMMON_NAME_INVALID 错误。

Java TLS 中的检查过程

Java 在 TLS 建立的过程中,默认通过 sun.security.util.HostnameChecker 进行证书持有者身份检查。检查流程如下:

  • 从 SSLSession 中获取到服务端的地址信息 SSLSession#getPeerHost(),这个地址实际上是 Socket 建立连接的时候指定的 IP 地址或域名地址。
  • 判断该地址是 IP 地址还是域名地址。
        public void match(String host, X509Certificate serverCert) throws CertificateException {
            if (isIpAddress(host)) {
                matchIP(host, serverCert);
            } else {
                this.matchDNS(host, serverCert);
            }
        }
  • 如果是 IP 地址,则在 subjectAltName 中寻找 IP 进行匹配,根据 RFC 5280 4.2.1.6. 中对 GeneralName 的定义 IP 为类型 7。
    private static void matchIP(String host, X509Certificate certificate) throws CertificateException {
        Collection san = certificate.getSubjectAlternativeNames();
        if (san == null) {
            throw new CertificateException("No subject alternative names present");
        } else {
            Iterator generalNames = san.iterator();
            while(generalNames.hasNext()) {
                List var4 = (List)generalNames.next();
                if ((Integer)var4.get(0) == 7) {
                    String generalName = (String)var4.get(1);
                    if (host.equalsIgnoreCase(generalName)) {
                        return;
                    }
                    try {
                        if (InetAddress.getByName(host).equals(InetAddress.getByName(generalName))) {
                            return;
                        }
                    } catch (UnknownHostException var7) {
                    } catch (SecurityException var8) {
                    }
                }
            }
            throw new CertificateException("No subject alternative names matching IP address " + host + " found");
        }
    } 
  • 如果是域名地址,则在 subjectAltName 中寻找域名进行匹配,根据 RFC 5280 4.2.1.6. 中对 GeneralName 的定义域名为类型 2。注意:当使用域名的时候,除了检查 subjectAltName 中是否存在匹配的域名之外,还会检查 Subject 中的 commonName 是否和域名匹配。
    private void matchDNS(String host, X509Certificate certificate) throws CertificateException {
        try {
            new SNIHostName(host);
        } catch (IllegalArgumentException e) {
            throw new CertificateException("Illegal given domain name: " + host, e);
        }
    
        Collection san = certificate.getSubjectAlternativeNames();
        if (san != null) {
            boolean unMatched = false;
            Iterator generalNames = san.iterator();
    
            while(generalNames.hasNext()) {
                List generalName = (List)generalNames.next();
                if ((Integer)generalName.get(0) == 2) {
                    unMatched = true;
                    String var7 = (String)generalName.get(1);
                    if (this.isMatched(host, var7)) {
                        return;
                    }
                }
            }
    
            if (unMatched) {
                throw new CertificateException("No subject alternative DNS name matching " + host + " found.");
            }
        }
    
        X500Name DN = getSubjectX500Name(certificate);
        DerValue commonName = DN.findMostSpecificAttribute(X500Name.commonName_oid);
        if (var11 != null) {
            try {
                if (this.isMatched(host, commonName.getAsString())) {
                    return;
                }
            } catch (IOException e) {
            }
        }
    
        String err = "No name matching " + host + " found";
        throw new CertificateException(err);
    }

配置和脚本

自签发 CA 证书配置文件ca-openssl.cnf

[req]
distinguished_name  = req_distinguished_name
req_extensions = v3_req

[req_distinguished_name]
countryName           = Country Name (2 letter code)
countryName_default = CN
stateOrProvinceName   = State or Province Name (full name)
stateOrProvinceName_default = Some-State
organizationName          = Organization Name (eg, company)
organizationName_default = Internet Widgits Pty Ltd
commonName            = Common Name (eg, YOUR name)
commonName_default = testca

[v3_req]
basicConstraints = CA:true
keyUsage = critical, keyCertSign

服务端证书配置文件 server-openssl.cnf

[req]
distinguished_name  = req_distinguished_name
req_extensions     = v3_req

[req_distinguished_name]
countryName           = Country Name (2 letter code)
countryName_default   = CN
stateOrProvinceName   = State or Province Name (full name)
stateOrProvinceName_default = Beijing
localityName          = Locality Name (eg, city)
localityName_default  = Beijing
organizationName          = Organization Name (eg, company)
organizationName_default  = Example, Co.
commonName            = Common Name (eg, YOUR name)
commonName_max        = 64

####################################################################
[ ca ]
default_ca  = CA_default        # The default ca section

####################################################################
[ CA_default ]

dir     = . # Where everything is kept
certs       = $dir # Where the issued certs are kept
crl_dir     = $dir      # Where the issued crl are kept
database    = $dir/index.txt    # database index file.
#unique_subject = no            # Set to 'no' to allow creation of
                    # several ctificates with same subject.
new_certs_dir   = $dir      # default place for new certs.

certificate = $dir/ca.pem   # The CA certificate
serial      = $dir/serial       # The current serial number
crlnumber   = $dir/crlnumber    # the current crl number
                    # must be commented out to leave a V1 CRL
crl     = $dir/crl.pem      # The current CRL
private_key = $dir/private/cakey.pem# The private key
RANDFILE    = $dir/private/.rand    # private random number file

x509_extensions = usr_cert      # The extentions to add to the cert

# Comment out the following two lines for the "traditional"
# (and highly broken) format.
name_opt    = ca_default        # Subject Name options
cert_opt    = ca_default        # Certificate field options

# Extension copying option: use with caution.
# copy_extensions = copy

# Extensions to add to a CRL. Note: Netscape communicator chokes on V2 CRLs
# so this is commented out by default to leave a V1 CRL.
# crlnumber must also be commented out to leave a V1 CRL.
# crl_extensions    = crl_ext

default_days    = 365           # how long to certify for
default_crl_days= 30            # how long before next CRL
default_md  = default       # use public key default MD
preserve    = no            # keep passed DN ordering

# A few difference way of specifying how similar the request should look
# For type CA, the listed attributes must be the same, and the optional
# and supplied fields are just that :-)
policy      = policy_anything
[ policy_anything ]
countryName     = optional
stateOrProvinceName = optional
localityName        = optional
organizationName    = optional
organizationalUnitName  = optional
commonName      = supplied
emailAddress        = optional

[v3_req]
basicConstraints = CA:FALSE
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
subjectAltName = @alt_names

[alt_names]
IP.1 = 10.0.2.70

这里的 [alt_names] 域中即为我们需要指定的 subjectAltName,可以配置多个 IP,DNS 或其他值。

下面是一个用于证书签发,证书文件转换的脚本 certificate-gen.sh。使用该脚本前需要确认有 Java 的 keytool 命令和 openssl 命令。该脚本会生成一张 CA 自签发证书,然后使用该证书签发一张客户端证书和服务端证书。该脚本运行时会要求输入一个服务端 IP 的地址,这个地址会替换 [alt_names] 域的 IP.1 的值。下面是脚本内容:

#! /bin/bash
echo "INFO: 清理环境"
rm *.rsa
rm *.jks
rm *.p12
rm *.key
rm *.csr
rm *.srl

echo "INFO: 生成自签发证书"
openssl req -x509 -new -newkey rsa:2048 -nodes -keyout ca.key -out ca.pem -config ca-openssl.cnf -days 3650 -extensions v3_req

echo "INFO: 将 ca.pem 转换为 ca.jks, KeyStore 密码为 123456"
keytool -importcert -trustcacerts -file ca.pem -keystore ca.jks -storepass 123456

echo "INFO: 签发客户端证书"
openssl genrsa -out client.key.rsa 2048
openssl pkcs8 -topk8 -in client.key.rsa -out client.key -nocrypt
openssl req -new -key client.key -out client.csr
openssl x509 -req -CA ca.pem -CAkey ca.key -CAcreateserial -in client.csr -out client.pem -days 3650

echo "INFO: 将私钥和对应的证书链合成 PKCS#12 格式,KeyStore 密码和私钥密码均为 123456"
openssl pkcs12 -export -CAfile ca.pem -in client.pem  -inkey client.key -out client.p12 -passout pass:123456

echo "INFO: 签发服务端证书"
echo "INFO: 填写主机名"
read -p "请输入服务器域名或者主机名:" server
echo "INFO: set alt_names $server"
old_server=$(grep "IP.1 = " server-openssl.cnf|awk -F " " '{print $3}')
echo "INFO: 将 alt_names 从 $old_server 修改为 $server"
sed -i "s/$old_server/$server/g" server-openssl.cnf
openssl genrsa -out server.key.rsa 2048
openssl pkcs8 -topk8 -in server.key.rsa -out server.key -nocrypt
openssl req -new -key server.key -out server.csr -config server-openssl.cnf
openssl x509 -req -CA ca.pem -CAkey ca.key -CAcreateserial -in server.csr -out server.pem -extensions v3_req -extfile server-openssl.cnf -days 3650

echo "INFO: 将私钥和对应的证书链合成 PKCS#12 格式,KeyStore 密码和私钥密码均为 123456"
openssl pkcs12 -export -CAfile ca.pem -in server.pem  -inkey server.key -out server.p12 -passout pass:123456

echo "INFO:清理无用的文件"
rm *.rsa
rm *.csr
rm ca.srl

运行脚本前的目录结构:

certificates
├── ca-openssl.cnf
├── certificate-gen.sh
└── server-openssl.cnf

运行脚本后的目录结构:

certificates
├── ca-openssl.cnf
├── ca.jks
├── ca.key
├── ca.pem
├── certificate-gen.sh
├── client.key
├── client.p12
├── client.pem
├── server-openssl.cnf
├── server.key
├── server.p12
└── server.pem

0 directories, 12 files

发布于 2020-07-08 23:08