《OpenResty HTTPS 证书与 OCSP、加密实践》

《OpenResty HTTPS 证书与 OCSP、加密实践》

本文基于 OpenResty 汇总 HTTPS 站点搭建、证书生成、动态加载证书、OCSP 在线状态校验以及 Lua 中的 AES/RSA 加解密实践。

1 使用 openssl 生成 CA/服务端/客户端证书

以下操作在 Debian 系上完成。

1.1 准备服务器端、客户端的公钥私钥

# 服务器端 openssl genrsa -out server.key 1024 openssl rsa -in server.key -pubout -out server.pem # 客户端 openssl genrsa -out client.key 1024 openssl rsa -in client.key -pubout -out client.pem

1.2 生成 CA 证书

# 生成 CA 私钥 openssl genrsa -out ca.key 1024 # 生成证书签名请求文件(会交互输入国家/省份/公司等信息) openssl req -new -key ca.key -out ca.csr # 用 CA 私钥对 csr 自签名生成证书 openssl x509 -days 3650 -req -in ca.csr -signkey ca.key -out ca.crt

openssl req 交互时会提示填写:国家(Country Name)、省份、城市、组织名称、公司通用名(Common Name)、邮箱以及 challenge password 等。

1.3 生成服务器端、客户端证书

服务器端向 CA 申请签名证书:

# 创建服务器 CSR openssl req -days 3650 -new -key server.key -out server.csr # 用 CA 的证书和私钥给 server.csr 签名,颁发带 CA 签名的证书 openssl x509 -days 3650 -req -CA ca.crt -CAkey ca.key -CAcreateserial -in server.csr -out server.crt

客户端同理:

openssl req -new -key client.key -out client.csr openssl x509 -days 3650 -req -CA ca.crt -CAkey ca.key -CAcreateserial -in client.csr -out client.crt

说明:CSR 文件其实就包含了用户公钥和个人信息,用户把它发送给 CA,CA 根据 CSR 内容签发数字证书。

1.4 server 配置 ssl

将 server.crt 和 server.key 放到配置目录:

server { server_name *.example.com; listen 443 ssl; # 证书都是 PEM 格式的 ssl_certificate server.crt; ssl_certificate_key server.key; ssl_session_cache shared:SSL:10m; ssl_session_timeout 5m; proxy_ssl_protocols TLSv1 TLSv1.1 TLSv1.2; proxy_ssl_ciphers HIGH:!aNULL:!MD5; ssl_prefer_server_ciphers on; location / { content_by_lua_block { ngx.say("ok! method="..ngx.var.request_method.." uri="..ngx.var.request_uri); ngx.say("this is origin ssl") } } }

1.5 客户端请求测试

自签 CA 不在系统信任路径,请求时需加 -k(不校验服务端证书),或使用 --cacert 显式指定 CA。注意:https 请求不能靠 Host header 指定域名,必须用 --resolve 将域名解析到本地:

curl https://www.example.com -Ik --resolve www.example.com:443:127.0.0.1 -v

此时会提示 SSL certificate verify result: unable to get local issuer certificate (20), continuing anyway.

把 CA 加入系统信任后即可校验通过:

cp ca.crt /usr/local/share/ca-certificates/ update-ca-certificates

update-ca-certificates 会在 /etc/ssl/certs 下创建软连接,之后请求显示 common name: *.example.com (matched) 与 SSL certificate verify ok.。

域名不匹配时(证书 CN 是 *.example.com,请求 www.example.comx)会报:

SSL: certificate subject name '*.example.com' does not match target host name 'www.example.comx' curl: (51) SSL: certificate subject name '*.example.com' does not match target host name 'www.example.comx'

1.6 openssl 常用命令

查看证书:

openssl x509 -in server.crt -text -noout

格式转换(openssl 生成的证书默认是 PEM,可转 DER):

# PEM 到 DER openssl x509 -in server.crt -outform der -out server.der # DER 到 PEM openssl x509 -in server.der -inform der -outform pem -out server.pem

1.7 配置 nginx 与 https 源站通信

反向代理访问 https 源站常用的 proxy_ssl 指令:

  • proxy_ssl_protocols:支持的 SSL 协议版本。
  • proxy_ssl_ciphers:支持的加密套件。
  • proxy_ssl_server_name on:是否传递 SNI 信息(代理为 https origin 转发时 origin 可能依赖 SNI 选证书)。
  • proxy_ssl_name $set_host:源站的 host 名字。
  • proxy_ssl_verify_depth:证书链验证最大深度。
  • proxy_ssl_verify:是否校验源站证书。
  • proxy_ssl_trusted_certificate:受信任的 CA 证书 bundle,启动时一次性加载到内存。

示例:

proxy_ssl_protocols TLSv1 TLSv1.1 TLSv1.2; proxy_ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:AES128-GCM-SHA256:AES128-SHA; proxy_ssl_server_name on; proxy_ssl_name $set_host; proxy_ssl_verify_depth 5; proxy_ssl_verify on; proxy_ssl_trusted_certificate /usr/local/nginx/conf/trusted-ca-bundle.crt;

注意:更新了 proxy_ssl_trusted_certificate 指定的文件后需 reload nginx 才生效;自签证书可把自己的 CA 追加到 bundle 文件末尾后再 reload。

2 动态加载证书、缓存会话与 OCSP

2.1 整体流程

client -> openresty -> origin

OpenResty 可在 Lua 中动态加载证书与客户端完成 SSL 握手(根据 SNI 域名选择证书),再代理到源站。这里侧重 client -> openresty 的 https 通信。

2.2 nginx 关键配置

http { lua_package_path '/usr/local/nginx/lua/?.lua;/usr/local/nginx/luajit/lib/?.lua;/usr/local/nginx/lualib/?.lua;;'; init_by_lua 'require("init.server").go()'; init_worker_by_lua 'require("init.worker").go()'; # ssl 会话管理:能从缓存命中会话就免去完整握手,节省通讯成本 ssl_session_tickets off; ssl_session_fetch_by_lua_block {require("ssl.session").fetch()} ssl_session_store_by_lua_block {require("ssl.session").store()} # https 代理服务 server { listen 8356 ssl backlog=16383 bind default_server reuseport; ssl_protocols TLSv1 TLSv1.1 TLSv1.2; server_tokens off; include proxy.conf; } }

SSL 相关配置 ssl_settings.conf(存放证书的占位符,真实证书通过 lua 动态加载):

set $visit_scheme 'https'; set $visit_server_addr $server_addr; set $visit_client_addr $remote_addr; # 占位符,真实的证书和私钥是从 redis 中读取出来的 ssl_certificate fallback.crt; ssl_certificate_key fallback.key; # 动态加载证书 ssl_certificate_by_lua_block {require("ssl.certificate").go()}

2.3 ssl/certificate.lua 动态加载证书

核心思路:在 ssl_certificate_by_lua 阶段用 ngx.ssl 提供的能力清空占位证书、按 SNI 域名从 redis 读取证书与私钥并设置。

local _M = {} local ssl = require("ngx.ssl") local json = require("cjson.safe") local dbcli = require("db.interface") local clear_certs = ssl.clear_certs local server_name = ssl.server_name local cert_pem_to_der = ssl.cert_pem_to_der local set_der_cert = ssl.set_der_cert local priv_key_pem_to_der = ssl.priv_key_pem_to_der local set_der_priv_key = ssl.set_der_priv_key local str_format = string.format local CERT_ID_FORMAT = "ssl::cert:id:%s" local CERT_DATA_FORMAT = "ssl::cert:data:%s" -- 从 redis 读取 SNI 域名对应的证书数据 local function load_cert() local name, err = server_name() if not name then ngx.log(ngx.ERR, "[ssl.certificate] failed to get server name. err: ", err) return ngx.exit(ngx.ERROR) end local id = dbcli.get(str_format(CERT_ID_FORMAT, name)) if not id then ngx.log(ngx.ERR, "[ssl.certificate] failed to get cert id") return ngx.exit(ngx.ERROR) end local crt_data = dbcli.get(str_format(CERT_DATA_FORMAT, id)) if not crt_data then ngx.log(ngx.ERR, "[ssl.certificate] failed to get cert data") return ngx.exit(ngx.ERROR) end local crt, err = json.decode(crt_data) if not crt then ngx.log(ngx.ERR, "[ssl.certificate] failed to parse cert data, err: ", err) return ngx.exit(ngx.ERROR) end return crt end function _M.go() -- 清空占位符指定的证书和私钥 local ok, err = clear_certs() if not ok then ngx.log(ngx.ERR, "[ssl.certificate] failed to clear fallback certificates, err: ", err) return ngx.exit(ngx.ERROR) end local cert_data = load_cert() -- PEM 转 DER 后设置证书链 local der_cert_chain, derr der_cert_chain, derr = cert_pem_to_der(cert_data.certificate) if not der_cert_chain then ngx.log(ngx.ERR, "[ssl.certificate] failed to convert certificate chain from PEM to DER, err: ", derr) return ngx.exit(ngx.ERROR) end ok, err = set_der_cert(der_cert_chain) if not ok then ngx.log(ngx.ERR, "failed to set DER cert: ", err) return ngx.exit(ngx.ERROR) end -- PEM 转 DER 后设置私钥 local der_pkey, perr der_pkey, perr = priv_key_pem_to_der(cert_data.privateKey) if not der_pkey then ngx.log(ngx.ERR, "failed to convert private key from PEM to DER: ", perr) return ngx.exit(ngx.ERROR) end ok, err = set_der_priv_key(der_pkey) if not ok then ngx.log(ngx.ERR, "failed to set DER private key: ", err) return ngx.exit(ngx.ERROR) end end return _M

2.4 ssl/ocsp.lua OCSP 在线状态校验

OCSP(Online Certificate Status Protocol)用来判断一个尚未过期的证书是否已被吊销。相比 CRL(客户端维护吊销列表),OCSP 更适合服务器端做 OCSP Stapling,节省客户端请求延时。

local _M = {} local ocsp = require("ngx.ocsp") local http_new = require("resty.http").new local parse_url = require("resty.url").parse local set_ocsp_status_resp = ocsp.set_ocsp_status_resp local create_ocsp_request = ocsp.create_ocsp_request local validate_ocsp_response = ocsp.validate_ocsp_response local get_ocsp_responder_from_der_chain = ocsp.get_ocsp_responder_from_der_chain local SECOND = 1000 local CONNECT_TIMEOUT = 1 * SECOND local SEND_TIMEOUT = 3 * SECOND local READ_TIMEOUT = 11 * SECOND -- 尝试连接 OCSP 服务器,最多尝试 #ips 次 local function try_connect(ips, port) for _, ip in ipairs(ips) do local httpc, err = http_new() if httpc then httpc:set_timeouts(CONNECT_TIMEOUT, SEND_TIMEOUT, READ_TIMEOUT) local ok ok, err = httpc:connect(ip, port, {pool = "ocsp"}) if ok then return httpc else ngx.log(ngx.ERR, "[ssl.ocsp] failed to connect ocsp server ", ip, ":", port, ". err: ", err) end end end return nil end -- 建议用 redis/lru/shared_dict 对证书检查结果做缓存,TTL 可设一天左右 function _M.check(der_cert_chain) local ocsp_url, err = get_ocsp_responder_from_der_chain(der_cert_chain) if not ocsp_url then ngx.log(ngx.ERR, "[ssl.ocsp] failed to get ocsp url, err: ", err) return false end local result, perr = parse_url(ocsp_url) if perr then ngx.log(ngx.ERR, "[ssl.ocsp] failed to parse ocsp url, err: ", perr) return false end local ocsp_req, cerr ocsp_req, cerr = create_ocsp_request(der_cert_chain) if not ocsp_req then ngx.log(ngx.ERR, "[ssl.ocsp] failed to create ocsp request, err: ", cerr) return false end -- 解析成功但 port 可能为 nil:https 默认 443,http 默认 80 local port = 80 if result.port then port = result.port else if result.scheme == "https" then port = 443 end end local httpc = try_connect(get_ips(result.host), port) if not httpc then ngx.log(ngx.ERR, "[ssl.ocsp] failed to connect ocsp server") return false end if result.scheme == "https" then local ok ok, err = httpc:ssl_handshake() if not ok then httpc:close() ngx.log(ngx.ERR, "[ssl.ocsp] failed to finish client ssl handshake, err: ", err) return false end end local res, rr res, rr = httpc:request({ path = result.path, headers = {Host = result.host, ["Content-Type"] = "application/ocsp-request"}, method = "POST", body = ocsp_req, }) if not res then ngx.log(ngx.ERR, "[ssl.ocsp] failed to send ocsp request, err: ", rr) return false end if res.status ~= 200 then ngx.log(ngx.ERR, "[ssl.ocsp] ocsp responder returned bad HTTP status code: ", res.status) return false end if res.body and #res.body > 0 then local ok ok, err = validate_ocsp_response(res.body, der_cert_chain) if not ok then ngx.log(ngx.ERR, "[ssl.ocsp] failed to validate ocsp response, err: ", err) return false end ok, err = set_ocsp_status_resp(res.body) if not ok then ngx.log(ngx.ERR, "[ssl.ocsp] failed to set ocsp stapling, err: ", err) return false end end return true end return _M

注意事项:

2.5 自建 OCSP 服务器验证

CA 签发的证书需要包含吊销信息与 OCSP 验证地址(AIA 扩展)才能被 OCSP 查询。先生成默认目录:

mkdir -p demoCA/newcerts touch demoCA/index.txt echo '01' > demoCA/serial

server.ext 扩展配置:

keyUsage = nonRepudiation, digitalSignature, keyEncipherment extendedKeyUsage = serverAuth, clientAuth subjectAltName=@SubjectAlternativeName # 证书吊销列表 CRL crlDistributionPoints=@crl_section # 在线证书状态协议 OCSP authorityInfoAccess=@ocsp_section [ SubjectAlternativeName ] IP.1 = 127.0.0.1 DNS.1 = local.example.com [ crl_section ] URI.0 = http://local.example.com/crt.crl [ ocsp_section ] # CA 证书 caIssuers;URI.0 = http://local.example.com/ca.crt # 验证地址 OCSP;URI.0 = http://local.example.com/ocsp

签发带扩展的证书(ca 的要求:server.csr 中的省份、城市、组织名称与 CA 保持一致):

openssl ca -cert ca.crt -keyfile ca.key -in server.csr -extfile server.ext -days 365 -out server-ocsp.crt

生成吊销证书列表:

touch ./demoCA/crlnumber echo 01 > ./demoCA/crlnumber openssl ca -cert ca.crt -keyfile ca.key -gencrl -out crt.crl

启动 OCSP responder(直接用 CA 的证书和私钥对响应签名):

openssl ocsp -index demoCA/index.txt -CA ca.crt -port 8636 -text -rsigner ca.crt -rkey ca.key -timeout 60 -ignore_err

用 nginx 反向代理 /ocsp 到 responder 端口:

server { listen 8241; server_name local.example.com; location ^~ /ocsp { proxy_pass http://127.0.0.1:8636; } }

启动后校验证书:

# -noverify 表示不对响应做签名校验 openssl ocsp -issuer ca.crt -cert server-ocsp.crt -url http://127.0.0.1:8241/ocsp -resp_text -noverify # 关键输出:Cert Status: good

吊销一个证书后(重新生成 revoked.crt 需重启 ocsp responder),查询会返回 Cert Status: revoked 以及吊销时间。

3 Lua 中的 AES/RSA 加解密

OpenResty 中常用 resty.aes(对称加密)与 resty.rsa(非对称加密)在 Lua 里做加解密。

3.1 AES 对称加密(lua-resty-string)

local aes = require "resty.aes" local key = "your-secret-key-material" local salt = "your-salt-material" local hash_rounds = 2 local aes_128_cbc_md5 = aes:new(key, salt, nil, nil, hash_rounds) local encrypted = ngx.encode_base64(aes_128_cbc_md5:encrypt(message)) local decrypted = aes_128_cbc_md5:decrypt(ngx.decode_base64(encrypted))

AES-128-CBC 加解密在 5 万次循环下耗时在亚秒级,性能远优于 RSA,适合大流量场景下的敏感字段加密。

3.2 RSA 非对称加密(lua-resty-rsa)

支持加密、解密、签名和验签。

local rsa = require "resty.rsa" local algorithm = "SHA256" local rsa_public_key, rsa_priv_key = rsa:generate_rsa_keys(2048) local pub, err = rsa:new({ public_key = rsa_public_key, padding = rsa.PADDING.RSA_PKCS1_PADDING, algorithm = algorithm, }) local priv, err = rsa:new({ private_key = rsa_priv_key, padding = rsa.PADDING.RSA_PKCS1_PADDING, algorithm = algorithm, }) -- 加密 local encrypted = ngx.encode_base64(pub:encrypt(message)) -- 解密 local decrypted = priv:decrypt(ngx.decode_base64(encrypted)) -- 签名 / 验签 local sig = priv:sign(message) local ok = pub:verify(message, sig)

经验结论:

  • 2048 位 RSA 加解密、签名验签的耗时都在数百毫秒到数秒级别(5 万次循环),远高于 AES,适合低频操作(如密钥分发、数字签名)。
  • 对称加密(AES)适合高频大数据量场景,非对称加密(RSA)适合低频小数据量场景;实际应用中常组合使用:用 RSA 加密 AES 密钥,再用 AES 加密业务数据。
阅读 — · 全站 —
🎸 我的歌单 0 首