木匣子

Web/Game/Programming/Life etc.

创建自签名 SSL 证书

疫情仍然在继续,但是维州已经开始恢复生机。我大概又歇了小半年,除了工作时间,下班之余几乎没有碰与技术相关的东西。同时由于近来关注了太多社会新闻,反而让自己陷入了政治性抑郁。刷推的时候刷到一个无聊的放置类手游竟随手下载来玩了一个星期,也算放空一下自己,转移注意力,好重振旗鼓。 回顾了一下半年多以前开发的 Axidraw Web,有不少可以分享的点东西,准备挑几个记录一下。

AxiDraw 是一个双电机的二维绘图仪。可以将 SVG 绘制到纸面上。在研究完它的底层串口协议后,我尝试用 javascript 实现了一个能在网页上运行的 Web 小程序,能通过 WebUSB API 与 AxiDraw 单片机直接进行通讯,同时实现了 SVG 的绘制功能。

WebUSB

在开发 Axidraw Web 的时候,由于 WebUSB API 需要在安全的浏览器环境下(Secure Contexts)才能使用。在较早的 Chrome 浏览器上(version 83 之前),localhost 是否属于 Secure Contexts 尚未确定。而这类 API 默认只对 https://localhost 开放。在我写这篇文章的时候,文档中已经明确将 localhost 定义为安全环境。如果在开发环境中使用了较老的浏览器或者其它的自定域名,还是需要自行提供 TLS 支持。主要工作就是提供一个自签名证书,然后在 web server 中使用:

import express from 'express';
import https from 'https';
import fs from 'fs';

const app = express();
app.use(express.static('dist'));

const options = {};

try {
  options.key = fs.readFileSync('server/cert/dev.key');
  options.cert = fs.readFileSync('server/cert/dev.crt');
} catch (e) {
  console.error('Please create and install the SSL cert first.');
  process.exit(1);
}

const server = https.createServer(options, app).listen(8443);

Webpack-dev-server

AxiDraw Web 项目使用 Webpack 打包,很自然的想到 Webpack-dev-server 的 https 配置项。可以简单将其设置为 true 即可启用 https 协议。

module.exports = {
  //...
  devServer: {
    https: true,
  },
};

运行后 Webpack-dev-server 会自动生成一个有效期 30 天的临时根证书。储存在 node_modules/webpack-dev-server/ssl/server.pem 手动将该文件导入 Keychain 并认 SSL 项目即可获得浏览器绿锁。

trust-ssl-in-keychain

不过这个方法几个缺点:该根证书的有效期只有 30 天,过期后要重新信任证书;证书内只签发了 localhost、127.0.0.1 以及一个奇怪的内部域名 lvh.me,无法满足一些 LAN 跨设备调试的需要。

OpenSSL

于是我又想,能不能根据自己的需求手动创建自签名证书来使用。经过一番研究,最终搞定了基于 OpenSSL 的证书生成过程。

首先我们需要定义如下文件:

  • ca.cnf: Certificate Authority 的配置文件
  • ca.key: Certificate Authority 的密钥,用于签名根证书
  • ca.cert: Certificate Authority 的根证书
  • localhost.cnf: localhost 的配置文件
  • localhost.key: localhost 的密钥,用于签名项目书
  • localhost.csr: localhost 证书请求文件,用于向 CA 申请 SSL 证书
  • localhost.ext: localhost 证书的扩展配置,用来描述证书涵盖的 DNS
  • localhost.crt: localhost 的 SSL 证书文件

在 shell 脚本中定义好变量名,之后使用:

FILENAME_CA_CNF=ca.cnf
FILENAME_CA_KEY=ca.key
FILENAME_CA_CERT=ca.pem
FILENAME_CERT_CNF=localhost.cnf
FILENAME_CERT_KEY=localhost.key
FILENAME_CERT_CSR=localhost.csr
FILENAME_CERT_EXT=localhost.ext
FILENAME_CERT_CERT=localhost.crt

创建一个 CA 配置文件,描述 CA 的所有权,其中字段描述如下:

  • C: Country 国家
  • ST: State 州/省
  • L: Locality 地区
  • O: Organization 企业/公司名
  • OU: Organization Unit 项目名
  • CN: Common Name 证书的唯一标识名
cat >> $FILENAME_CA_CNF <<EOF
[req]
prompt = no
distinguished_name = req_distinguished_name

[req_distinguished_name]
C=AU
ST=Victoria
L=Melbourne
O=mutoo.im
OU=ca
CN=ca.mutoo.im
EOF

然后使用 OpenSSL 生成一个 CA 用的钥匙,并创建根证书:

# generate ca key
openssl genrsa -out $FILENAME_CA_KEY 2048
# create ca root cert
openssl req -x509 \
  -config $FILENAME_CA_CNF \
  -new \
  -nodes \
  -key $FILENAME_CA_KEY \
  -sha256 \
  -days 365 \
  -out $FILENAME_CA_CERT

接下来创建一个项目用的证书配置文件,字段描述同上,稍微调整一下项目名即可:

cat >> $FILENAME_CERT_CNF <<EOF
[req]
prompt = no
distinguished_name = req_distinguished_name

[req_distinguished_name]
C=AU
ST=Victoria
L=Melbourne
O=mutoo.im
OU=axidraw-web
CN=axidraw-web.mutoo.im
EOF

然后我们需要生成一个「证书签名请求文件」用来向 CA 申请项目证书:

openssl req \
  -config $FILENAME_CERT_CNF \
  -new \
  -nodes \
  -key $FILENAME_CERT_KEY \
  -out $FILENAME_CERT_CSR

请求文件的主体只有项目标识名,我们还需要一个扩展文件来描述证书相关的 DNS 信息,其中 alt-names 段可以放上几个你需要绑定的域名:

# create ext config
cat >> $FILENAME_CERT_EXT <<EOF
authorityKeyIdentifier=keyid,issuer
basicConstraints=CA:FALSE
keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment
subjectAltName = @alt_names
[alt_names] 
DNS.1 = localhost
DNS.2 = $(hostname)
DNS.3 = $(ipconfig getifaddr en0).nip.io
EOF

在这里我除了绑定 localhost,还绑定了 hostname 主机名(在 macOS 下形如 macbook-pro.local 可经由 mDNS 可以在 LAN 中直接访问而无须使用内网 IP 访问),以及一个泛 DNS 解析的域名,方便当 mDNS 被禁用时使用。如果需要,这里还可以使用通配符,例如:DNS.4 = *.mutoo.im

有了证书签名请求文件和扩展信息,我们就可以用 CA 的名义签发项目证书:

# signed cert
openssl x509 -req \
  -in $FILENAME_CERT_CSR \
  -CA $FILENAME_CA_CERT \
  -CAkey $FILENAME_CA_KEY \
  -CAcreateserial \
  -out $FILENAME_CERT_CERT \
  -days 30 \
  -sha256 \
  -extfile $FILENAME_CERT_EXT

通过以上脚本,我们生成了一个有效期 365 天的根证书和一个有效期 30 天的项目证书。最后只需要将 ca.cert 导入系统的证书管理器,并信任 SSL 项目,然后在项目的 Web Server 中使用 localhost.crtlocalhost.key 即可。

如果有多个项目,可以重复使用 CA 根证书来签发不同的项目证书。由于根证书已经被系统信认,在有效期内,所以的项目证书都可以通过系统检测,十分简单。

如果需要在局域网中的设备访问开发中的网站,可以将证书文件放在静态资源中供下载,然后添加到信任列表:

// provide CA cert for client to download
app.use('/get-ca', express.static('server/cert/ca.pem'));

最后要注意保管好 CA 和项目证书的密钥,不要泄漏到 git 和 web server 上。