Go使用TLS实现HTTPS服务器
本文介绍Go使用TLS运行HTTPS服务器和客户端。这里假设你对公钥加密有一定的了解。如果不了解可以查阅RSA和Diffie-Hellman密匙交换。TLS使用Diffie-Hellman的椭圆曲线加密方式。 在这里不详细介绍协议本身是如何工作的,但如果你感兴趣,建议你仔细阅读这方面的资料。
本文的所有代码都可以在这个仓库中找到。
简要介绍TLS
TLS(传输层安全)是一种协议,用于在Internet上实现客户端-服务器安全通信,防止窃听、篡改和消息伪造。在RFC 8446中有描述。
TLS依赖于最先进的加密技术;这也是为什么建议使用TLS的最新版本,即1.3版本(2021年初)。TLS协议的修订版清除了潜在的不安全的问题,删除了薄弱的加密算法,使协议更安全。
当客户端使用普通的HTTP协议连接到服务器时,完成标准的TCP握手(SYN -> SYN-ACK -> ACK)之后,客户端就开始发送封装在TCP数据包中的明文数据。使用TLS,情况有点复杂:
完成TCP握手之后,服务端和客户端执行TLS握手,协商一个他们(以及这个特定会话)独有的公匙。然后使用这个公匙对它们之间交换的所有数据安全地加密。虽然这里有很多事情要做,但这是TLS层实现的。我们只需要正确设置TLS服务器(或客户端);在Go中,HTTP和HTTPS服务器之间的实际差异是最小的。
TLS证书
在进入如何使用TLS在Go中设置HTTPS服务器的代码之前,让我们先讨论一下证书。在上面的图中,可以看到服务器将证书作为其第一个ServerHello消息的一部分发送给客户端。在形式上这些证书称为X.509证书,在RFC 5280中有描述。
公钥加密在TLS中起着重要的作用。证书包含服务器公钥、其身份和受信机构(通常是证书机构)的签名。假设你想和https://bigbank.com网站连接;你怎么知道访问的BigBank服务端是正确的?如果有人监听你的连接,拦截了所有的流量,假装是BigBank服务端与你通信(典型的MITM -中间人攻击)。
证书处理就解决了中间人攻击情况。当您的客户端底层实现了TLS,在访问https://bigbank.com时,它期望BigBank的证书包含可信机构签署的公匙。证书签名可以形成一棵树(可以由a签名,B签名,C签名,等等),但不管怎样,必须有某个受客户端信任的证书授权机构签名。浏览器有一个内置的预信任CA列表(以及它们的证书)。既然你的连接包含了无法复制的可信证书签名,在这里别人就不能冒充Big Bank服务端。
在Go中生成自签名证书
对于本地测试来说,使用自签名证书是非常有用的。自签名证书是为某实体生成包含公钥的证书,但该密钥不是由已知的证书颁发机构签署的,而是由服务端自己签署的。虽然自签名证书还有其他一些合法用途,但我们在这里将重点讨论它们在测试中的用途。
Go标准库对所有与加密、TLS和证书相关的东西都有很好的支持。让我们看看如何在Go中生成自签名证书:
第一步生成私钥:
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
log.Fatalf("Failed to generate private key: %v", err)
}
这段代码使用crypto/ecdsa、crypto/elliptic和crypto/rand包生成一个新的密钥对,使用的是P-256椭圆曲线,这是TLS 1.3中允许的曲线之一。
接下来,创建证书模版:
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil {
log.Fatalf("Failed to generate serial number: %v", err)
}
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
Organization: []string{"My Corp"},
},
DNSNames: []string{"localhost"},
NotBefore: time.Now(),
NotAfter: time.Now().Add(3 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
每个证书都需要一个唯一的序列号;通常,证书颁发机构会将这些数据存储在某个数据库中,但对于我们的本地测试,使用一个随机的128位数字就可以了。这就是上面代码前几行所做的事情。
接下来是x509.Certificate模版,有关字段含义的更多信息,请参阅crypto/x509包文档以及RFC 5280。我们只需注意证书在3小时内有效,并且只对localhost域名有效。
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
if err != nil {
log.Fatalf("Failed to create certificate: %v", err)
}
证书是从模板创建的,并使用我们前面生成的私钥签名。注意,&template是作为CreateCertificate的模板和参数传入的。后者使得这个证书是自签名的。
就这样,我们有服务器的私钥和它的证书(其中包含公钥和其他信息)。现在剩下的就是将它们序列化成文件。首先,序列化证书文件:
pemCert := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
if pemCert == nil {
log.Fatal("Failed to encode certificate to PEM")
}
if err := os.WriteFile("cert.pem", pemCert, 0644); err != nil {
log.Fatal(err)
}
log.Print("wrote cert.pem\n")
然后,私匙文件:
privBytes, err := x509.MarshalPKCS8PrivateKey(privateKey)
if err != nil {
log.Fatalf("Unable to marshal private key: %v", err)
}
pemKey := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privBytes})
if pemKey == nil {
log.Fatal("Failed to encode key to PEM")
}
if err := os.WriteFile("key.pem", pemKey, 0600); err != nil {
log.Fatal(err)
}
log.Print("wrote key.pem\n")
我们将证书和密钥序列化到PEM文件中,证书格式如下所示:
-----BEGIN CERTIFICATE-----
MIIBbjCCARSgAwIBAgIRALBCBgLhD1I/4S0fRZv6yfcwCgYIKoZIzj0EAwIwEjEQ
MA4GA1UEChMHTXkgQ29ycDAeFw0yMTAzMjcxNDI1NDlaFw0yMTAzMjcxNzI1NDla
MBIxEDAOBgNVBAoTB015IENvcnAwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAASf
wNSifB2LWDeb6xUAWbwnBQ2raSQTqqpaR1C1eEiy6cgqUiiOlr4jUDDiFCly+AS9
pNNe8o63/Gab/98dwFNQo0swSTAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYI
KwYBBQUHAwEwDAYDVR0TAQH/BAIwADAUBgNVHREEDTALgglsb2NhbGhvc3QwCgYI
KoZIzj0EAwIDSAAwRQIgYlJYGIwSvA+AmsHe8P34B5+hlfWEK4+kBmydJ65XJZMC
IQCzg5aihUXh7Rm0L1K3JrG7eRuTuFSkHoAhzk4cy6FqfQ==
-----END CERTIFICATE-----
如果您曾经设置过SSH密钥,那么对它的格式应该很熟悉。我们可以使用openssl命令行工具解析它的内容:
$ openssl x509 -in cert.pem -text
Certificate:
Data:
Version: 3 (0x2)
Serial Number:
b0:42:06:02:e1:0f:52:3f:e1:2d:1f:45:9b:fa:c9:f7
Signature Algorithm: ecdsa-with-SHA256
Issuer: O = My Corp
Validity
Not Before: Mar 27 14:25:49 2021 GMT
Not After : Mar 27 17:25:49 2021 GMT
Subject: O = My Corp
Subject Public Key Info:
Public Key Algorithm: id-ecPublicKey
Public-Key: (256 bit)
pub:
04:9f:c0:d4:a2:7c:1d:8b:58:37:9b:eb:15:00:59:
bc:27:05:0d:ab:69:24:13:aa:aa:5a:47:50:b5:78:
48:b2:e9:c8:2a:52:28:8e:96:be:23:50:30:e2:14:
29:72:f8:04:bd:a4:d3:5e:f2:8e:b7:fc:66:9b:ff:
df:1d:c0:53:50
ASN1 OID: prime256v1
NIST CURVE: P-256
X509v3 extensions:
X509v3 Key Usage: critical
Digital Signature
X509v3 Extended Key Usage:
TLS Web Server Authentication
X509v3 Basic Constraints: critical
CA:FALSE
X509v3 Subject Alternative Name:
DNS:localhost
Signature Algorithm: ecdsa-with-SHA256
30:45:02:20:62:52:58:18:8c:12:bc:0f:80:9a:c1:de:f0:fd:
f8:07:9f:a1:95:f5:84:2b:8f:a4:06:6c:9d:27:ae:57:25:93:
02:21:00:b3:83:96:a2:85:45:e1:ed:19:b4:2f:52:b7:26:b1:
bb:79:1b:93:b8:54:a4:1e:80:21:ce:4e:1c:cb:a1:6a:7d
Go HTTPS服务器
现在我们有了证书和私钥,就可以运行HTTPS服务器了!尽管安全性是一个非常棘手的问题,但使用标准库非常容易。在将服务器开放到Internet之前,请考虑咨询安全工程师,了解最佳实践以及需要注意哪些配置选项。
以下是Go实现的一个简单HTTPS服务器:
func main() {
addr := flag.String("addr", ":4000", "HTTPS network address")
certFile := flag.String("certfile", "cert.pem", "certificate PEM file")
keyFile := flag.String("keyfile", "key.pem", "key PEM file")
flag.Parse()
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
if req.URL.Path != "/" {
http.NotFound(w, req)
return
}
fmt.Fprintf(w, "Proudly served with Go and HTTPS!")
})
srv := &http.Server{
Addr: *addr,
Handler: mux,
TLSConfig: &tls.Config{
MinVersion: tls.VersionTLS13,
PreferServerCipherSuites: true,
},
}
log.Printf("Starting server on %s", *addr)
err := srv.ListenAndServeTLS(*certFile, *keyFile)
log.Fatal(err)
}
```它在根路由上提供了一个处理程序。有趣的部分是TLS配置,以及ListenAndServeTLS调用,获取证书文件和私钥文件的路径(PEM格式,就像我们前面生成的)。TLS配置有许多可能的字段;在这里,我选择了一个相对严格的协议,至少TLS1.3。TLS1.3具有强大开箱即用的安全性,所以如果你能确保你的所有客户端能解析这个版本,是一个不错的选择。
与普通HTTP服务器的区别少于10行代码!服务器的大部分代码(特定路由的处理程序)与底层协议完全无关,不会改变。
这个服务器在本地运行(并且默认监听在4000端口上),Chrome在访问它时最初会阻止:
![](https://img.haomeiwen.com/i21436181/337124108dffb47b.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
这是因为web浏览器在默认情况下不接受自签名证书。如上所述,浏览器自带一个它信任的硬编码CA列表,而我们的自签名证书显然不在其中。我们仍然可以通过点击高级进入服务器,然后允许Chrome继续运行,明确地接受风险。然后它会向我们展示网站(地址栏上有一个红色的“不安全”标志)。
如果我们尝试curl命令访问服务器,也会得到一个错误[4]:
```shell
$ curl -Lv https://localhost:4000
* Trying 127.0.0.1:4000...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 4000 (#0)
* ALPN, offering h2
* ALPN, offering http/1.1
* successfully set certificate verify locations:
* CAfile: /etc/ssl/certs/ca-certificates.crt
CApath: /etc/ssl/certs
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (OUT), TLS alert, unknown CA (560):
* SSL certificate problem: unable to get local issuer certificate
* Closing connection 0
curl: (60) SSL certificate problem: unable to get local issuer certificate
More details here: https://curl.haxx.se/docs/sslcerts.html
curl failed to verify the legitimacy of the server and therefore could not
establish a secure connection to it. To learn more about this situation and
how to fix it, please visit the web page mentioned above.
通过阅读文档,我们可以通过--cacert命令行参数提供服务器证书,使curl信任我们的服务器。如果我们这样做:
$ curl -Lv --cacert <path/to/cert.pem> https://localhost:4000
* Trying 127.0.0.1:4000...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 4000 (#0)
* ALPN, offering h2
* ALPN, offering http/1.1
* successfully set certificate verify locations:
* CAfile: /home/eliben/eli/private-code-for-blog/2021/tls/cert.pem
CApath: /etc/ssl/certs
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* TLSv1.3 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_128_GCM_SHA256
* ALPN, server accepted to use h2
* Server certificate:
* subject: O=My Corp
* start date: Mar 29 13:30:25 2021 GMT
* expire date: Mar 29 16:30:25 2021 GMT
* subjectAltName: host "localhost" matched cert's "localhost"
* issuer: O=My Corp
* SSL certificate verify ok.
* Using HTTP2, server supports multi-use
* Connection state changed (HTTP/2 confirmed)
* Copying HTTP/2 data in stream buffer to connection buffer after upgrade: len=0
* Using Stream ID: 1 (easy handle 0x557103006e10)
> GET / HTTP/2
> Host: localhost:4000
> user-agent: curl/7.68.0
> accept: */*
>
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
* Connection state changed (MAX_CONCURRENT_STREAMS == 250)!
< HTTP/2 200
< content-type: text/plain; charset=utf-8
< content-length: 33
< date: Mon, 29 Mar 2021 13:31:34 GMT
<
* Connection #0 to host localhost left intact
Proudly served with Go and HTTPS!
成功!
我们还可以使用Go编写的自定义HTTPS客户端与服务器通信。代码如下:
func main() {
addr := flag.String("addr", "localhost:4000", "HTTPS server address")
certFile := flag.String("certfile", "cert.pem", "trusted CA certificate")
flag.Parse()
cert, err := os.ReadFile(*certFile)
if err != nil {
log.Fatal(err)
}
certPool := x509.NewCertPool()
if ok := certPool.AppendCertsFromPEM(cert); !ok {
log.Fatalf("unable to parse cert from %s", *certFile)
}
client := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
RootCAs: certPool,
},
},
}
r, err := client.Get("https://" + *addr)
if err != nil {
log.Fatal(err)
}
defer r.Body.Close()
html, err := io.ReadAll(r.Body)
if err != nil {
log.Fatal(err)
}
fmt.Printf("%v\n", r.Status)
fmt.Printf(string(html))
}
唯一不同于标准HTTP客户端的是TLS设置。重要的是设置TLS的RootCAs字段。配置结构。这告诉Go客户端可以信任哪些证书。
客户端认证(mTLS)
到目前为止,我们看到的示例是服务器向客户端提供其(CA签名的)证书,以证明服务器是其所声称的合法身份(例如,银行网站,在您同意提供密码之前需要验证服务端身份)。
这个想法很容易扩展到相互身份验证,其中客户端也有一个签名的证书来证明其身份。在TLS的世界中,这被称为mTLS(相互TLS),在内部服务必须安全地相互通信中可能非常有用。
下面是一个简单的带有客户端身份验证的HTTPS服务器:
func main() {
addr := flag.String("addr", ":4000", "HTTPS network address")
certFile := flag.String("certfile", "cert.pem", "certificate PEM file")
keyFile := flag.String("keyfile", "key.pem", "key PEM file")
clientCertFile := flag.String("clientcert", "clientcert.pem", "certificate PEM for client authentication")
flag.Parse()
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
if req.URL.Path != "/" {
http.NotFound(w, req)
return
}
fmt.Fprintf(w, "Proudly served with Go and HTTPS!")
})
// Trusted client certificate.
clientCert, err := os.ReadFile(*clientCertFile)
if err != nil {
log.Fatal(err)
}
clientCertPool := x509.NewCertPool()
clientCertPool.AppendCertsFromPEM(clientCert)
srv := &http.Server{
Addr: *addr,
Handler: mux,
TLSConfig: &tls.Config{
MinVersion: tls.VersionTLS13,
PreferServerCipherSuites: true,
ClientCAs: clientCertPool,
ClientAuth: tls.RequireAndVerifyClientCert,
},
}
log.Printf("Starting server on %s", *addr)
err = srv.ListenAndServeTLS(*certFile, *keyFile)
log.Fatal(err)
}
这些变化和预期的差不多;除了设置自己的证书、密钥和TLS配置外,服务器还加载客户端证书并将TLSConfig设置为信任它。当然,这也可以是签署客户端证书的本地可信CA的证书。
下面是一个HTTPS客户端它在连接到服务器时验证自己:
func main() {
addr := flag.String("addr", "localhost:4000", "HTTPS server address")
certFile := flag.String("certfile", "cert.pem", "trusted CA certificate")
clientCertFile := flag.String("clientcert", "clientcert.pem", "certificate PEM for client")
clientKeyFile := flag.String("clientkey", "clientkey.pem", "key PEM for client")
flag.Parse()
// Load our client certificate and key.
clientCert, err := tls.LoadX509KeyPair(*clientCertFile, *clientKeyFile)
if err != nil {
log.Fatal(err)
}
// Trusted server certificate.
cert, err := os.ReadFile(*certFile)
if err != nil {
log.Fatal(err)
}
certPool := x509.NewCertPool()
if ok := certPool.AppendCertsFromPEM(cert); !ok {
log.Fatalf("unable to parse cert from %s", *certFile)
}
client := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
RootCAs: certPool,
Certificates: []tls.Certificate{clientCert},
},
},
}
r, err := client.Get("https://" + *addr)
if err != nil {
log.Fatal(err)
}
defer r.Body.Close()
html, err := io.ReadAll(r.Body)
if err != nil {
log.Fatal(err)
}
fmt.Printf("%v\n", r.Status)
fmt.Printf(string(html))
}
在测试之前,我们需要更改证书生成脚本,以生成适合客户端的证书。修改以下这一行:
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
``
改为:
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
现在我们来试一下。首先为客户端和服务器生成单独的证书/密钥:
```shell
# 客户端证书
$ go run tls-self-signed-cert.go
2021/04/03 05:51:25 wrote cert.pem
2021/04/03 05:51:25 wrote key.pem
$ mv cert.pem clientcert.pem
$ mv key.pem clientkey.pem
# 服务端证书
$ go run tls-self-signed-cert.go
2021/04/03 05:51:42 wrote cert.pem
2021/04/03 05:51:42 wrote key.pem
运行mTLS服务器,它应该根据默认配制参数选择正确的文件:
$ go run https-server-mtls.go
2021/11/03 05:54:51 Starting server on :4000
在另一个终端中,如果我们运行旧的(非mtls)客户端,会得到一个错误:
$ go run https-client.go
2021/04/03 05:55:24 Get "https://localhost:4000": remote error: tls: bad certificate
exit status 1
服务器日志显示“客户端没有提供证书”。然而,如果我们运行新的mTLS客户端,工作正常:
$ go run https-client-mtls.go
200 OK
Proudly served with Go and HTTPS!
虽然这演示了运行mTLS服务器和客户端的机制,但实际上还有很多工作要做,特别是管理证书、证书续签和撤销以及受信任的CA。这就是所谓的公钥基础设施(PKI),是一个很大的话题,超出了本文的范围。