通过一个例子说明客户端如何验证HTTPS服务端的证书信息。
类型浏览器如何验证WEB服务器的证书信息。
生成服务器端证书,以及CA证书
# generate ca certificate
$ openssl genrsa -out ca-key.pem 2048
$ openssl req -new -x509 -days 365 -key ca-key.pem -out ca-cert.pem -subj "/CN=ca"
# generate server certificate
$ openssl genrsa -out server-key.pem 2048
$ openssl req -new -key server-key.pem -out server-csr.pem -subj "/CN=localhost"
$ openssl x509 -req -days 3650 -in server-csr.pem -CA ca-cert.pem -CAkey ca-key.pem -CAcreateserial -out server-cert.pem
生成服务端证书server-cert.pem,(注意证书的common name是localhost),这个证书是通过CA证书ca-cert.pem签名的。
服务器端代码
$ cat server.go
package main
import (
"fmt"
"log"
"flag"
"net/http"
"crypto/tls"
"encoding/json"
"github.com/gorilla/mux"
)
var (
port int
hostname string
keyfile string
signcert string
)
func init() {
flag.IntVar(&port, "port", 8080, "The host port on which the REST server will listen")
flag.StringVar(&hostname, "hostname", "0.0.0.0", "The host name on which the REST server will listen")
flag.StringVar(&keyfile, "key", "", "Path to file containing PEM-encoded key file for service")
flag.StringVar(&signcert, "signcert", "", "Path to file containing PEM-encoded sign certificate for service")
}
func startHTTPSServer(address string, keyfile string, signcert string, router *mux.Router) {
s := &http.Server{
Addr: address,
Handler: router,
TLSConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
},
}
err := s.ListenAndServeTLS(signcert, keyfile)
if err != nil {
log.Fatalln("ListenAndServeTLS err:", err)
}
}
func SayHello(w http.ResponseWriter, r *http.Request) {
log.Println("Entry SayHello")
res := map[string]string {"hello": "world"}
b, err := json.Marshal(res)
if err == nil {
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
w.Write(b)
}
log.Println("Exit SayHello")
}
func main() {
flag.Parse()
router := mux.NewRouter().StrictSlash(true)
router.HandleFunc("/service/hello", SayHello).Methods("GET")
var address = fmt.Sprintf("%s:%d", hostname, port)
fmt.Println("Server listen on", address)
startHTTPSServer(address, keyfile, signcert, router)
fmt.Println("Exit main")
}
编译运行
$ go build
$ ./server -port 8080 -signcert ./server-cert.pem -key ./server-key.pem
运行服务器在端口8080,这个服务器提供验证的证书是server-cert.pem,客户端(浏览器将验证这个证书的有效性)。
客户端代码
$ cat client.js
fs = require('fs');
https = require('https');
options = {
hostname: 'localhost',
port : 8080,
path : '/service/hello',
method : 'GET',
ca : fs.readFileSync('ca-cert.pem')
};
req = https.request(options, (res) => {
console.log('statusCode:', res.statusCode);
console.log('headers:', res.headers);
res.on('data', (d) => {
process.stdout.write(d);
});
});
req.on('error', (e) => {
console.error(e);
});
req.end();
运行
$ node client.js
{"hello":"world"}
完整的访问地址格式就是 https://localhost:8080/service/hello
这里客户端必须提供CA证书ca-cert.pem,用他来验证服务端的证书server-cert.pem是有效的。
另外这里客户端访问的地址是localhost,这个值和服务器证书server-cert.pem的common name域是一样的,否则验证就会失败。
例如,我们修改客户端请求中hostname值真实到机器名(假定机器名为saturn)。
options = {
hostname: 'saturn',
port : 8080,
path : '/service/hello',
method : 'GET',
ca : fs.readFileSync('ca-cert.pem')
};
再运行(https://saturn:8080/service/hello),会得到如下错误:
$ node client.js
{ Error: Hostname/IP doesn't match certificate's altnames: "Host: saturn. is not cert's CN: localhost"
at Object.checkServerIdentity (tls.js:199:17)
at TLSSocket.<anonymous> (_tls_wrap.js:1098:29)
at emitNone (events.js:86:13)
at TLSSocket.emit (events.js:185:7)
at TLSSocket._finishInit (_tls_wrap.js:610:8)
at TLSWrap.ssl.onhandshakedone (_tls_wrap.js:440:38)
这说明client使用URL的主机名和证书的Common Name域进行比较,作为证书是否有效的一个证据。
另外Common Name可以是一个短名(saturn),也可以长域名(saturn.yourcomp.com.cn),但不管短名还是长名都必须是URL请求中的主机名一样。即
- 如果证书是saturn,则必须用https://saturn:8080/service/hello访问
- 如果证书是saturn.yourcomp.com.cn,则必须用https://saturn.yourcomp.com.cn:8080/service/hello访问
SAN证书
SAN是另一种解决证书和URL主机名匹配的办法。
SAN = subjectAltName = Subject Alternative Name
具体用法步骤:
修改openssl.cnf
[ req ]
req_extensions = v3_req
[ v3_req ]
# Extensions to add to a certificate request
basicConstraints = CA:FALSE
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
subjectAltName = @alt_names
[alt_names]
DNS.1 = saturn
DNS.2 = saturn.yourcomp.com.cn
生成证书
# generate ca certificate
openssl genrsa -out ca-key.pem 2048
openssl req -new -x509 -days 365 -key ca-key.pem -out ca-cert.pem -subj "/CN=ca"
# generate server certificate
openssl genrsa -out server-key.pem 2048
openssl req -new -key server-key.pem -out server-csr.pem -subj "/CN=localhost"
openssl x509 -req -days 3650 -in server-csr.pem -CA ca-cert.pem -CAkey ca-key.pem -CAcreateserial -out server-cert.pem -extensions v3_req -extfile openssl.cnf
对比和前面不使用SAN的差别。其实就是在使用CA根证书对服务器证书签名的时候,指定extensions属性。
运行客户端
options = {
hostname: 'localhost',
port : 8080,
path : '/service/hello',
method : 'GET',
ca : fs.readFileSync('ca-cert.pem')
};
$ node client.js
{ Error: Hostname/IP doesn't match certificate's altnames: "Host: localhost. is not in the cert's altnames: DNS:saturn"
看到了吗?localhost已经验证不过了,尽管他在common name的值没有变化,但是由于使用了SAN,证书验证的时候就使用SAN的值,而忽略common name的值。
使用客户端saturn
options = {
hostname: 'saturn',
port : 8080,
path : '/service/hello',
method : 'GET',
ca : fs.readFileSync('ca-cert.pem')
};
$ node client.js
{"hello":"world"}
使用saturn就通过了。当然使用saturn.yourcomp.com.cn也能通过。
总结
客户端验证服务端证书分两种情况:
- 不使用SAN,那么验证证书的common name和URL的主机名一致。
主机名是否是common name中的一个。 - 使用SAN,那么验证证书的SAN值和URL的主机名一致。
主机名是否就是SAN值中的一个。