Go HTTPS 服务器
Apr 13, 2021 23:15 · 3013 words · 7 minute read
本文将介绍使用 Golang 快速编写 HTTPS 服务器和客户端。
TLS
TLS (Transport Layer Security) 是一种防止客户端与服务器之间的网络通信被窃听、篡改和伪造的协议,请参考 RFC 8446。TLS 基于最先进的加密技术,使用 Diffie-Hellman 密钥交换算法的椭圆曲线版本,这也是建议使用最新版本 TLS 即 1.3 的原因。这版修订清除了潜在的威胁,干掉了弱加密算法,更安全。
当客户端与服务器通过 HTTP 交互,在 TCP 握手完成后(SYN -> SYN-ACK -> ACK),就开始发送包裹在 TCP 数据包中的纯文本数据。使用 TLS 的话,就比较复杂了:
在完成 TCP 握手之后,服务器和客户端还会进行一次 TLS 握手来协商一个只属于它们的密钥。这个共享的密钥被用于加密它们之间来往的所有数据。这些操作都是由 TLS 层实现的,我们只要正确地设置 TLS 服务器(或客户端),在 Golang 中 HTTP 和 HTTPS 服务器的差别并不大。
TLS 证书
在我们写代码前,先聊一下证书。在上图中,你有没有注意到服务器给客户端发送了一个证书,在它第一个 Hello 消息中携带。这些证书被称为 X.509 证书,请参考 RFC 5280
公钥密码学在 TLS 中起了重要作用。证书是一种用来包装服务器公钥以及它的身份和授信机构(通常是 Certificate Authority)的签名的标准方式。假设你想和 https://bigbank.com 通信,你怎么知道是真的银行在问你密码呢?如果有人在网路中间,拦截所有的流量并假装成银行(经典的 MITM ——中间人攻击)
证书就被设计出来用于防止这种情况发生。当你客户端的底层访问 https://bigbank.com,它希望得到银行带有公钥的证书,这个证书由授信的 CA 签发。证书签名是递归的(银行的密钥由 A 签名,A 由 B 签名,B 由 C 签名),但在链条的末端,必须有客户端信任的 CA。现代浏览器内置了一个可信 CA 的列表。由于中间人无法伪造可信证书的签名,也就没法冒充银行了。证书上带有银行的合法公钥,当你用它来生成那个共享的密钥时,只有银行才能解密。
Go 自签证书
本地测试时,自签证书往往很有用。自签证书是指某个实体的公钥的证书,但是这个公钥不是由知名的 CA 签发,而是由公钥本身签署。虽然自签证书也有实际应用场景,但是一般只在测试中使用。
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 所推荐的。
下面我们将创建一个证书模板:
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,
}
每张证书都需要一个唯一的序列号;通常 CA 会将它们存在数据库中,但我们本地的话搞一个随机的 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)
}
证书是从模板创建的,并且用之前生成的私钥签名。CreateCertificate
函数的 template
和 parent
都传了 &template
。后者就是这个证书自签的原因。
现在我们有了服务器的私钥和它的证书(其中包含公钥等信息),剩下的就是把它们序列化到文件中。先是证书:
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 服务器了!又要说到标准库了,让这件事变得易如反掌。不过要提醒的是,安全是很棘手的问题,在将你的服务暴露到公网前最好想清楚。
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 配置其实有很多字段的,这里我选择了一个相对严格的协议,强制使用 TLS 1.3 以上。TLS 1.3 开箱就有很强的安全性,如果能确保所有客户都能匹配上这个版本(2021 年了,这是应该做的事!),那就美滋滋了。
和普通的 HTTP 服务器差了也就十来行代码。底层协议对大部分代码都是透明的。
在本地运行这个服务器(跑在 4000 端口),Chrome 首次访问它是打不开的:
这是因为浏览器默认不接受自签证书,它们自带了一个写死的可信 CA 列表,我们的自签证书显然不在其中。我们还是可以点击“高级”,明确接受风险,才可以访问 web 服务,有点勉强(地址栏有个醒目的“不安全”标记)。
我们试着去 curl
它,也会报错:
$ 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!
我们还可以用 Golang 编写的 HTTPS 客户端来与服务器通讯:
func main() {
addr := flag.String("addr", "localhost:4000", "HTTPS server address")
certFile := flag.String("certfile", "cert.pem", "server 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.Config
结构的 RootCAs
字段,让 Go 信任我们的证书。
生成证书的其他选项
你可能不知道标准安装的 Go 还自带了一个生成自签名 TLS 证书的工具。如果你的 Go 安装在 /usr/local/go
(macOS)路径,就可以运行这个工具:
$ go run /usr/local/go/src/crypto/tls/generate_cert.go -help
总的来说,它和本文的第一个代码片段实现了同样的效果,后者由代码控制生成什么,而 generate_cert
则是通过 flag 来配置,支持好几个选项。
正如我们所看到的,虽然自签名证书可以用于测试,但它们并不是所有场景的理想选择。比如,很难让浏览器信任它们,用户体验也大打折扣。
另一个选择是 mkcert 工具,它会创建一个本地 CA,并将其添加到你的操作系统的可信 CA 列表中。然后,它生成由这个 CA 签署的证书,对于浏览器来说它们是完全可信的。
如果我们使用 mkcert 生成的证书/密钥来运行本地 HTTPS 服务器,Chrome 会欣然接受而不是警告;我们还可以在开发者工具的安全选项卡中看到详细信息:
curl
也一样,不用带上 cacert
选项了,它会先检查操作系统信任的 CA。
如果你想用真的证书,Let’s Encrypt 是不二之选。在 Go 中,像 certmagic 这样的三方库可以自动为服务器与 Let’s Encrypt 通讯。