4. 证书存储与读取:PEM与DER格式转换、从文件/内存加载证书、提取证书过期时间
证书轮换脚本的核心,说白了就是跟证书文件打交道。你拿到一个证书,可能是 PEM 格式,也可能是 DER 格式;可能从文件读,也可能从内存加载。不管哪种情况,最终你都得知道它什么时候过期。
这一节,我们就来把这些基础操作捋清楚。我个人习惯把证书操作分成三块:格式识别与转换、加载方式、过期时间提取。这三块搞定了,后面写轮换逻辑就顺了。
核心要点:证书轮换脚本里,90% 的坑都出在格式不匹配和路径错误上。先把读写基础打牢,后面才能稳。
4.1 PEM 与 DER:两种格式,一个本质
先说说这两种格式的区别。PEM 是文本格式,用 Base64 编码,带明显的头尾标记。DER 是二进制格式,直接就是 ASN.1 编码的原始数据。
| 特性 | PEM | DER |
|---|---|---|
| 编码方式 | Base64 文本 | 二进制 ASN.1 |
| 可读性 | 可读,有头尾标记 | 不可读,需工具解析 |
| 文件大小 | 比 DER 大约 30% | 更紧凑 |
| 常见场景 | Web 服务器、OpenSSL 默认 | Java 密钥库、Windows 系统 |
| 头尾标记 | -----BEGIN CERTIFICATE----- |
无 |
我记得有一次排查线上问题,发现 Nginx 死活加载不了证书。折腾了半天,最后发现是运维把 DER 格式的证书直接扔给了 Nginx。Nginx 只认 PEM,就这么简单。所以,搞清楚你的目标系统要什么格式,这是第一步。
4.1.1 格式转换:OpenSSL 一把梭
格式转换用 OpenSSL 最方便。我一般记这几个命令,够用了:
# PEM 转 DER
openssl x509 -in cert.pem -outform DER -out cert.der
# DER 转 PEM
openssl x509 -in cert.der -inform DER -out cert.pem
# 查看证书信息(不管什么格式)
openssl x509 -in cert.pem -text -noout
openssl x509 -in cert.der -inform DER -text -noout
你想想看,这些命令其实就是指定输入格式和输出格式。PEM 是默认格式,所以转 DER 时要加 -outform DER;DER 转回来时要加 -inform DER。逻辑很直白。
小技巧:如果你不确定一个文件是 PEM 还是 DER,用 file 命令看一眼。PEM 会显示 "ASCII text",DER 会显示 "data"。或者直接 head -1 看第一行有没有 "-----BEGIN"。
4.2 从文件加载证书:两种语言的实现
脚本里加载证书,我主要用 Python 和 Go。Python 灵活,Go 编译后部署方便。下面分别给出代码示例。
4.2.1 Python 加载 PEM 和 DER
Python 的 cryptography 库是首选。它比 pyOpenSSL 更现代,API 也更清晰。
from cryptography import x509
from cryptography.hazmat.backends import default_backend
# 加载 PEM 证书
with open("cert.pem", "rb") as f:
pem_data = f.read()
cert = x509.load_pem_x509_certificate(pem_data, default_backend())
# 加载 DER 证书
with open("cert.der", "rb") as f:
der_data = f.read()
cert = x509.load_der_x509_certificate(der_data, default_backend())
print(f"证书主题: {cert.subject}")
print(f"签发者: {cert.issuer}")
这里要注意,open 一定要用 "rb" 模式。我见过有人用 "r" 读 PEM,结果 Base64 换行符被自动转换,导致解析失败。嗯,这种坑踩过一次就记住了。
4.2.2 Go 加载 PEM 和 DER
Go 的标准库 crypto/x509 已经够用,不需要第三方依赖。
package main
import (
"crypto/x509"
"encoding/pem"
"fmt"
"io/ioutil"
)
func main() {
// 加载 PEM 证书
pemBytes, _ := ioutil.ReadFile("cert.pem")
block, _ := pem.Decode(pemBytes)
if block == nil || block.Type != "CERTIFICATE" {
panic("PEM 解析失败")
}
cert, _ := x509.ParseCertificate(block.Bytes)
fmt.Printf("主题: %s\n", cert.Subject)
// 加载 DER 证书
derBytes, _ := ioutil.ReadFile("cert.der")
cert, _ = x509.ParseCertificate(derBytes)
fmt.Printf("主题: %s\n", cert.Subject)
}
Go 的 PEM 解析多了一步 pem.Decode,因为 PEM 可能包含多个块(比如证书链)。DER 就直接 ParseCertificate,简单粗暴。
注意:Go 的 ioutil.ReadFile 在 1.16+ 已弃用,建议用 os.ReadFile。另外,生产代码里一定要处理错误,我这里为了简洁省略了。
4.3 从内存加载证书:字符串与字节流
有时候证书不在文件里,而是从 API 响应、数据库或环境变量中读取。这时候就要从内存加载。
说白了,内存加载就是把文件读取那一步换成从字符串或字节数组读取。其他流程完全一样。
# Python:从字符串加载 PEM
pem_str = """-----BEGIN CERTIFICATE-----
MIIDazCCAlM...
-----END CERTIFICATE-----"""
cert = x509.load_pem_x509_certificate(pem_str.encode(), default_backend())
# Python:从字节数组加载 DER
der_bytes = b'\x30\x82\x03...' # 假设从某处获取
cert = x509.load_der_x509_certificate(der_bytes, default_backend())
// Go:从字符串加载 PEM
pemStr := `-----BEGIN CERTIFICATE-----
MIIDazCCAlM...
-----END CERTIFICATE-----`
block, _ := pem.Decode([]byte(pemStr))
cert, _ := x509.ParseCertificate(block.Bytes)
// Go:从字节数组加载 DER
derBytes := []byte{0x30, 0x82, 0x03, ...}
cert, _ := x509.ParseCertificate(derBytes)
这里有个细节:Python 的 load_pem_x509_certificate 要求传入 bytes,所以字符串要 .encode()。Go 的 pem.Decode 也要求 []byte,直接强转就行。
4.4 提取证书过期时间
证书轮换的核心判断依据就是过期时间。你需要在脚本里定期检查,如果证书快过期了,就触发轮换。
4.4.1 Python 提取过期时间
from cryptography import x509
from cryptography.hazmat.backends import default_backend
from datetime import datetime, timezone
with open("cert.pem", "rb") as f:
cert = x509.load_pem_x509_certificate(f.read(), default_backend())
# 获取过期时间
expiry = cert.not_valid_after_utc
print(f"过期时间: {expiry}")
# 计算剩余天数
now = datetime.now(timezone.utc)
remaining = (expiry - now).days
print(f"剩余 {remaining} 天")
# 判断是否需要轮换(提前 30 天触发)
if remaining < 30:
print("证书即将过期,需要轮换!")
else:
print("证书状态正常")
注意,not_valid_after_utc 返回的是带 UTC 时区的时间。如果你用 not_valid_after,它返回的是不带时区的 naive datetime,比较时容易出问题。我建议统一用 UTC 时间比较,避免时区坑。
4.4.2 Go 提取过期时间
package main
import (
"crypto/x509"
"encoding/pem"
"fmt"
"io/ioutil"
"time"
)
func main() {
pemBytes, _ := ioutil.ReadFile("cert.pem")
block, _ := pem.Decode(pemBytes)
cert, _ := x509.ParseCertificate(block.Bytes)
// 获取过期时间
expiry := cert.NotAfter
fmt.Printf("过期时间: %s\n", expiry)
// 计算剩余天数
remaining := time.Until(expiry)
days := int(remaining.Hours() / 24)
fmt.Printf("剩余 %d 天\n", days)
// 判断是否需要轮换
if days < 30 {
fmt.Println("证书即将过期,需要轮换!")
} else {
fmt.Println("证书状态正常")
}
}
Go 的 cert.NotAfter 返回的是 time.Time,自带时区信息。用 time.Until 计算差值,干净利落。
最佳实践:我建议在脚本里设置两个阈值:警告阈值(比如 30 天)和紧急阈值(比如 7 天)。警告阈值发通知,紧急阈值直接触发自动轮换。这样既不会频繁操作,也不会错过截止时间。
4.5 知识体系总览
下面这张图把本章的知识点串起来了。你可以看到,从证书文件到过期时间判断,中间经历了格式识别、加载、解析三个步骤。
4.6 避坑指南
最后,分享几个我实际踩过的坑:
- PEM 头尾标记缺失:我曾经从某个内部系统导出的证书没有
-----END CERTIFICATE-----换行,导致解析失败。后来我养成了习惯,拿到证书先用openssl x509 -in cert.pem -text -noout验证一下。 - DER 文件被误认为 PEM:有人把 DER 文件重命名为
.pem,然后脚本报错。我的建议是:不要相信文件扩展名,用file命令或代码里做格式探测。 - 时区问题:Python 的
not_valid_after返回 naive datetime,跟datetime.now()比较时差 8 小时。我后来统一用not_valid_after_utc和datetime.now(timezone.utc),再也没出过问题。 - 证书链加载:如果文件里包含多个 PEM 块(证书链),
load_pem_x509_certificate只加载第一个。你需要自己遍历所有块,或者用load_pem_x509_certificates(Python cryptography 支持)。
我的习惯:在脚本开头加一个 verify_cert_format() 函数,自动检测输入是 PEM 还是 DER,然后走对应的加载路径。这样不管用户给什么格式,脚本都能处理。代码也就多 10 行,但省心很多。
好了,证书的存储与读取就讲到这里。这些操作是证书轮换脚本的基石,你可以在后面的章节里看到它们如何被组合进完整的轮换流程中。