Go HTTPS 服务器

Apr 13, 2021 23:15 · 3013 words · 7 minute read Golang Network

本文将介绍使用 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/ecdsacrypto/ellipticcrypto/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 函数的 templateparent 都传了 &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 通讯。