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 知识体系总览

下面这张图把本章的知识点串起来了。你可以看到,从证书文件到过期时间判断,中间经历了格式识别、加载、解析三个步骤。

证书存储与读取知识体系 证书文件 内存数据 格式识别 PEM vs DER 格式转换 OpenSSL 命令 加载解析 Python / Go 代码 过期时间提取 not_valid_after / NotAfter 轮换决策 剩余天数 < 阈值 常见问题 格式不匹配导致加载失败 时区问题导致过期判断错误

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 行,但省心很多。

好了,证书的存储与读取就讲到这里。这些操作是证书轮换脚本的基石,你可以在后面的章节里看到它们如何被组合进完整的轮换流程中。