☰
C语言从X.509证书提取公钥:OpenSSL与手写ASN.1解析实战
2026/10/16 16:11:05 网站建设 项目流程

简介:这份资源面向具备一定C语言与网络安全基础的开发者,聚焦于从X.509证书中提取公钥这一底层任务。X.509证书广泛用于SSL/TLS身份验证,其公钥封装在Subject Public Key Info结构中,涉及ASN.1编码、算法标识符与公钥数据解析等知识点。资源包共20个文件,约704KB,包含cpp源码、cer与pfx证书样本、exe可执行文件,以及dsp、dsw等VC6工程文件,另有pdb、obj、ilk等编译调试产物,便于直接运行与调试。已有1649人学习下载,说明该主题在证书解析与PKI开发中具有实际参考价值。读者可借助源码理解如何读取PEM或DER编码证书、解码ASN.1结构、定位公钥字段,并结合OpenSSL等库完成RSA或ECC公钥的提取与异常处理,适合作为证书解析入门与排错思路的实践参考。

1. 用 C 语言从 X.509 证书里取出公钥:为什么这件事比想象中更容易翻车

很多人第一次接触 X.509 证书解析,是在做设备端签名校验、固件升级验签或者 TLS 双向认证的时候。需求听起来很朴素:手里有一张 DER 或 PEM 格式的证书,想用 C 语言把里面的公钥抠出来,拿到 RSA 的模数、指数,或者 EC 的曲线点和曲线参数,然后交给底层密码库去做验签。真动手才发现,证书不是一段裸公钥,它是一层套一层的 ASN.1 结构,外面裹着 TBSCertificate、签名算法、签名值,公钥只是 SubjectPublicKeyInfo 里的一个 BIT STRING。用错 API、漏掉一层解包、把 PEM 当 DER 直接喂进去,都会让你在调试器里对着一段乱码发呆。

这篇笔记面向的是要在 C 工程里落地证书公钥提取的开发者,不管你是做嵌入式、桌面端还是服务端。核心思路有两条:一条是借 OpenSSL 这类成熟库走捷径,另一条是手写最小 ASN.1 解析器只取需要的字段。两条路各有适用边界,选错了不是性能问题,而是安全边界问题。下面按“先立住概念、再动手复现、最后看坑”的顺序讲清楚。

2. X.509 公钥到底藏在哪一层:结构拆解与选型判断

2.1 从 Certificate 到 SubjectPublicKeyInfo 的嵌套关系

一张 X.509 v3 证书的顶层结构是Certificate,它由三个字段组成:tbsCertificate、signatureAlgorithm、signatureValue。公钥不在顶层,而在tbsCertificate里面。TBSCertificate又包含版本、序列号、签名算法、颁发者、有效期、主体、subjectPublicKeyInfo、扩展等字段。真正装公钥的是SubjectPublicKeyInfo,它的定义大致是:

SubjectPublicKeyInfo ::= SEQUENCE { algorithm AlgorithmIdentifier, subjectPublicKey BIT STRING }

algorithm告诉你这是 RSA、EC 还是 Ed25519,subjectPublicKey是一段 BIT STRING,里面的内容随算法不同而不同。RSA 的话,BIT STRING 里再套一个RSAPublicKey ::= SEQUENCE { modulus INTEGER, publicExponent INTEGER };EC 的话,BIT STRING 里是一个ECPoint,通常是非压缩格式04 || X || Y。理解这层嵌套是后面所有代码的基础,因为绝大多数“取不到公钥”的问题,都是少解了一层或者把 BIT STRING 的内容当成了原始字节。

还有一个容易忽略的点:BIT STRING 的第一个字节是“未使用位数”,通常是 0。如果你直接把 BIT STRING 的 value 指针当成公钥数据起始位置,就会多读一个字节,导致 RSA 模数整体偏移,验签必然失败。这个坑在纯手写解析时几乎人人都会踩一次。

2.2 OpenSSL 路线与手写 ASN.1 路线的取舍

选型上先问自己三个问题:工程里是否已经依赖 OpenSSL 或同类密码库?目标平台是否允许引入这个依赖?你需要的只是公钥,还是后续还要做验签、加密?

如果工程里已经有 OpenSSL,那直接用它的高层 API 是最稳的。d2i_X509把 DER 解析成X509*,X509_get_pubkey拿到EVP_PKEY*,再用EVP_PKEY_get1_RSA或EVP_PKEY_get1_EC_KEY取具体参数。这条路的优点是边界情况处理得全,PEM/DER 都支持,错误码清晰。缺点是引入库体积,嵌入式环境可能吃不消。

如果目标是一个资源受限的 MCU,或者你只想做“取公钥”这一件事,不想拖进整个密码库,那就手写一个最小 ASN.1 解析器。只需要支持 SEQUENCE、INTEGER、BIT STRING、OBJECT IDENTIFIER、NULL 这几种类型,代码量可以控制在两三百行。代价是你要自己处理长度编码(短格式、长格式、不定长)、边界检查、以及不同算法的分支。手写解析器最大的风险不是功能,而是安全:长度字段没校验、递归深度没限制,都可能被恶意证书打穿。所以手写路线必须配一套严格的边界检查。

我的建议是:能上 OpenSSL 就上 OpenSSL,除非有明确的体积或平台限制。手写解析适合作为学习手段,或者用在证书来源完全可信、格式固定的场景。

2.3 用 OpenSSL 取公钥的最小可编译示例

先看 OpenSSL 路线。下面这段代码从 DER 文件读入证书,取出公钥,并打印 RSA 模数的前几个字节,用来确认取到的是真东西。

#include <stdio.h> #include <stdlib.h> #include <openssl/x509.h> #include <openssl/pem.h> #include <openssl/evp.h> #include <openssl/rsa.h> /* 从 DER 文件加载证书并提取公钥 * 返回 EVP_PKEY*,失败返回 NULL */ EVP_PKEY *load_pubkey_from_der(const char *path) { FILE *fp = fopen(path, "rb"); if (!fp) return NULL; /* 先取文件长度 */ fseek(fp, 0, SEEK_END); long len = ftell(fp); fseek(fp, 0, SEEK_SET); if (len <= 0) { fclose(fp); return NULL; } unsigned char *buf = malloc(len); if (!buf) { fclose(fp); return NULL; } if (fread(buf, 1, len, fp) != (size_t)len) { free(buf); fclose(fp); return NULL; } fclose(fp); /* d2i_X509 会把 DER 解析成 X509 结构,第二个参数传 NULL 表示内部申请 */ const unsigned char *p = buf; X509 *cert = d2i_X509(NULL, &p, len); free(buf); if (!cert) return NULL; /* X509_get_pubkey 返回的 EVP_PKEY 需要调用方释放 */ EVP_PKEY *pkey = X509_get_pubkey(cert); X509_free(cert); return pkey; } int main(void) { EVP_PKEY *pkey = load_pubkey_from_der("cert.der"); if (!pkey) { fprintf(stderr, "load cert failed\n"); return 1; } /* 判断算法类型,再取具体参数 */ int type = EVP_PKEY_base_id(pkey); if (type == EVP_PKEY_RSA) { RSA *rsa = EVP_PKEY_get1_RSA(pkey); const BIGNUM *n = NULL, *e = NULL; RSA_get0_key(rsa, &n, &e, NULL); printf("RSA modulus bits: %d\n", BN_num_bits(n)); printf("RSA exponent: %lu\n", BN_get_word(e)); RSA_free(rsa); } else if (type == EVP_PKEY_EC) { EC_KEY *ec = EVP_PKEY_get1_EC_KEY(pkey); const EC_GROUP *group = EC_KEY_get0_group(ec); printf("EC curve nid: %d\n", EC_GROUP_get_curve_name(group)); EC_KEY_free(ec); } else { printf("other key type: %d\n", type); } EVP_PKEY_free(pkey); return 0; }

编译命令要显式链接 crypto:

gcc -o extract extract.c -lcrypto

这段代码里几个关键点值得说明。d2i_X509的第二个参数是二级指针,解析成功后它会指向 DER 缓冲区中证书结束的位置,方便你处理“一个文件里多张证书”的情况。X509_get_pubkey返回的EVP_PKEY是引用计数对象,必须用EVP_PKEY_free释放,否则内存泄漏。EVP_PKEY_get1_RSA里的1表示返回的RSA*带一个引用,用完要RSA_free。这些引用计数规则是 OpenSSL 1.1.0 之后统一起来的,老代码里直接访问pkey->pkey.rsa的写法已经不可移植。

如果证书是 PEM 格式,把d2i_X509换成PEM_read_X509即可,它接受FILE*或BIO*。不要试图把 PEM 文本直接喂给d2i_X509,它只认 DER 二进制。

2.4 手写 ASN.1 解析器取 RSA 模数与指数

再看手写路线。下面是一个最小实现,只处理 DER 编码,只取 RSA 公钥的模数和指数。它的价值在于让你看清每一层字节是怎么走的。

#include <stdio.h> #include <stdint.h> #include <string.h> /* 读取一个 ASN.1 TLV,返回 value 起始指针和长度 * 成功返回 0,失败返回 -1 */ static int read_tlv(const uint8_t *buf, size_t buf_len, uint8_t *tag, const uint8_t **val, size_t *val_len) { if (buf_len < 2) return -1; *tag = buf[0]; size_t idx = 1; uint8_t len_byte = buf[idx++]; size_t len = 0; if (len_byte & 0x80) { /* 长格式:低 7 位表示后续长度字节数 */ int n = len_byte & 0x7F; if (n == 0 || n > 4) return -1; /* 不支持不定长和超长 */ if (idx + n > buf_len) return -1; for (int i = 0; i < n; i++) { len = (len << 8) | buf[idx++]; } } else { len = len_byte; } if (idx + len > buf_len) return -1; /* 边界检查,防止越界 */ *val = buf + idx; *val_len = len; return 0; } /* 跳过一层 TLV,返回下一层的偏移,失败返回 -1 */ static long skip_tlv(const uint8_t *buf, size_t buf_len) { uint8_t tag; const uint8_t *val; size_t val_len; if (read_tlv(buf, buf_len, &tag, &val, &val_len) != 0) return -1; return (long)(val - buf) + (long)val_len; } /* 从证书 DER 中提取 RSA 模数和指数 * 成功返回 0,失败返回 -1 */ int extract_rsa_pubkey(const uint8_t *der, size_t der_len, const uint8_t **mod, size_t *mod_len, const uint8_t **exp, size_t *exp_len) { uint8_t tag; const uint8_t *val; size_t val_len; /* 第 1 层:Certificate SEQUENCE */ if (read_tlv(der, der_len, &tag, &val, &val_len) != 0) return -1; if (tag != 0x30) return -1; /* 第 2 层:tbsCertificate SEQUENCE,它是 Certificate 的第一个元素 */ if (read_tlv(val, val_len, &tag, &val, &val_len) != 0) return -1; if (tag != 0x30) return -1; /* 第 3 层:在 tbsCertificate 里逐个跳过字段,找到 subjectPublicKeyInfo * 字段顺序:version(可选) serial signature issuer validity subject spki ... * 这里用简化策略:跳过前 6 个字段,第 7 个就是 spki * 注意 version 是 [0] 显式标签,存在时占一个字段 */ const uint8_t *p = val; size_t remain = val_len; int field = 0; const uint8_t *spki = NULL; size_t spki_len = 0; while (remain > 0) { uint8_t t; const uint8_t *v; size_t l; if (read_tlv(p, remain, &t, &v, &l) != 0) return -1; long consumed = (long)(v - p) + (long)l; if (field == 6) { /* 第 7 个字段 */ spki = p; spki_len = (size_t)consumed; break; } p += consumed; remain -= (size_t)consumed; field++; } if (!spki) return -1; /* 第 4 层:SubjectPublicKeyInfo SEQUENCE */ if (read_tlv(spki, spki_len, &tag, &val, &val_len) != 0) return -1; if (tag != 0x30) return -1; /* 第 5 层:algorithm 跳过,取第二个元素 BIT STRING */ long alg_len = skip_tlv(val, val_len); if (alg_len < 0) return -1; const uint8_t *bitstr = val + alg_len; size_t bitstr_remain = val_len - (size_t)alg_len; if (read_tlv(bitstr, bitstr_remain, &tag, &val, &val_len) != 0) return -1; if (tag != 0x03) return -1; /* BIT STRING */ if (val_len < 1) return -1; /* BIT STRING 第一个字节是未使用位数,跳过 */ const uint8_t *inner = val + 1; size_t inner_len = val_len - 1; /* 第 6 层:RSAPublicKey SEQUENCE */ if (read_tlv(inner, inner_len, &tag, &val, &val_len) != 0) return -1; if (tag != 0x30) return -1; /* 第 7 层:modulus INTEGER */ if (read_tlv(val, val_len, &tag, &val, &val_len) != 0) return -1; if (tag != 0x02) return -1; *mod = val; *mod_len = val_len; /* 第 8 层:publicExponent INTEGER */ long mod_consumed = (long)(val - (inner + 2)) + (long)val_len; const uint8_t *rest = inner + 2 + mod_consumed; size_t rest_len = inner_len - 2 - (size_t)mod_consumed; if (read_tlv(rest, rest_len, &tag, &val, &val_len) != 0) return -1; if (tag != 0x02) return -1; *exp = val; *exp_len = val_len; return 0; }

这段代码里最需要盯住的是边界检查。read_tlv在读取长度字节和 value 之前都做了idx + n > buf_len和idx + len > buf_len的判断,这是防止恶意证书导致越界读的第一道闸。skip_tlv用 value 指针减去起始指针再加长度来算消耗字节数,避免了重复解析长度。第 3 层里“跳过前 6 个字段”是一种简化,真实证书里 version 字段是可选的,如果证书是 v1 格式,字段数会少一个,这个策略就会错位。生产代码里应该按 tag 判断:遇到[0]就说明有 version,否则从 serial 开始数。这里为了篇幅做了简化,但你要知道它的边界在哪。

模数和指数返回的是指向 DER 缓冲区内部的指针,不是拷贝。这意味着调用方必须保证 DER 缓冲区在公钥使用期间一直有效。如果你需要跨函数传递,要么拷贝出来,要么把整个证书缓冲区一起管理。这个生命周期问题在手写解析里非常常见,忘了就会拿到悬空指针。

3. 把 DER、PEM、算法分支都跑通:完整落地步骤

3.1 PEM 与 DER 的识别和转换

PEM 本质是 Base64 编码的 DER,外面包了-----BEGIN CERTIFICATE-----和-----END CERTIFICATE-----。如果你拿到的是 PEM,第一步是解码。OpenSSL 命令行可以快速转换:

# PEM 转 DER openssl x509 -in cert.pem -outform DER -out cert.der # 查看证书里的公钥信息,确认算法和位数 openssl x509 -in cert.pem -noout -text | grep -A 5 "Public Key"

在 C 代码里,如果不想依赖 OpenSSL,可以自己实现 Base64 解码。但更稳的做法是判断文件头:以0x30开头的基本是 DER,以-----BEGIN开头的是 PEM。不要用文件扩展名判断,.crt既可能是 PEM 也可能是 DER,这是血泪经验。

3.2 用 OpenSSL 同时支持 RSA 和 EC 的取参写法

上一节的示例只打印了 EC 的曲线 NID,实际工程里往往需要拿到 EC 公钥的坐标点。下面这段补全 EC 分支,把 X、Y 坐标以十六进制打印出来。

#include <openssl/ec.h> #include <openssl/bn.h> void print_ec_pubkey(EVP_PKEY *pkey) { EC_KEY *ec = EVP_PKEY_get1_EC_KEY(pkey); if (!ec) return; const EC_GROUP *group = EC_KEY_get0_group(ec); const EC_POINT *point = EC_KEY_get0_public_key(ec); if (!group || !point) { EC_KEY_free(ec); return; } BIGNUM *x = BN_new(); BIGNUM *y = BN_new(); if (EC_POINT_get_affine_coordinates(group, point, x, y, NULL) != 1) { BN_free(x); BN_free(y); EC_KEY_free(ec); return; } char *x_hex = BN_bn2hex(x); char *y_hex = BN_bn2hex(y); printf("EC X: %s\n", x_hex); printf("EC Y: %s\n", y_hex); OPENSSL_free(x_hex); OPENSSL_free(y_hex); BN_free(x); BN_free(y); EC_KEY_free(ec); }

EC_POINT_get_affine_coordinates是 OpenSSL 1.1.0 之后的接口,老版本用EC_POINT_get_affine_coordinates_GFp。BN_bn2hex返回的字符串要用OPENSSL_free释放,不能用free,因为 OpenSSL 可能用了自己的内存分配器。这些细节在跨版本移植时经常翻车。

3.3 手写解析器如何扩展到 EC 公钥

EC 公钥在 BIT STRING 里不是 SEQUENCE,而是一个ECPoint,通常以0x04开头表示非压缩格式,后面跟 X 和 Y 各占曲线字节长度。曲线长度由 algorithm 里的 OID 决定,比如 prime256v1 是 32 字节,secp384r1 是 48 字节。手写解析时,你需要先解析 algorithm 里的 OID,映射到曲线长度,再按长度切分 BIT STRING 的内容。

/* 假设已经拿到 BIT STRING 的 value(跳过未使用位数字节) * 返回 0 表示成功,point 指向 04||X||Y */ int parse_ec_point(const uint8_t *val, size_t val_len, const uint8_t **point, size_t *point_len) { if (val_len < 1) return -1; if (val[0] != 0x04) return -1; /* 只处理非压缩格式 */ *point = val; *point_len = val_len; return 0; }

OID 到曲线长度的映射需要一张表,常见的有:1.2.840.10045.3.1.7对应 32 字节,1.3.132.0.34对应 48 字节,1.3.132.0.35对应 66 字节。这张表要写死在代码里,遇到不认识的 OID 直接拒绝,不要猜。手写解析 EC 的复杂度比 RSA 高不少,如果工程里 EC 是刚需,还是建议用 OpenSSL。

3.4 验证取到的公钥是否正确

取到公钥后怎么确认它是对的?最直接的办法是用它验一个已知签名。如果没有现成签名,可以做一个自检:用私钥签一段固定数据,再用取出的公钥验签。OpenSSL 命令行可以快速做这件事:

# 生成私钥和自签名证书 openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 1 -nodes -subj "/CN=test" # 从证书提取公钥 openssl x509 -in cert.pem -pubkey -noout > pub.pem # 用私钥签名 echo "hello" > data.txt openssl dgst -sha256 -sign key.pem -out sig.bin data.txt # 用提取出的公钥验签 openssl dgst -sha256 -verify pub.pem -signature sig.bin data.txt

如果最后一步输出Verified OK,说明你提取公钥的路径是通的。这个自检流程在集成到 C 工程之前跑一遍,能省掉大量调试时间。注意-pubkey输出的是 PEM 格式的 SubjectPublicKeyInfo,和你从证书里取出的公钥结构一致。

4. 取公钥时最容易踩的五个坑:现象、原因与解决

4.1 现象:d2i_X509 返回 NULL,错误码是 ASN1_R_HEADER_TOO_LONG

原因通常是把 PEM 文件直接喂给了d2i_X509。PEM 的第一个字符是-,不是合法的 DER 头。另一个可能是文件里有多张证书,DER 解析器只认第一张,如果第一张不完整也会失败。

解决:先判断文件头。以0x30开头走d2i_X509,以-----BEGIN开头走PEM_read_X509。多证书场景用PEM_read_X509循环读取,或者用d2i_X509的二级指针推进。

4.2 现象:RSA 模数开头多了一个 0x00,或者整体偏移一个字节

原因是 BIT STRING 的第一个字节是未使用位数,通常是 0,但很多人直接把它当成了数据。另一个可能是 INTEGER 的前导零:ASN.1 的 INTEGER 是有符号的,如果最高位是 1,编码时会在前面补一个 0x00 表示正数。这个 0x00 是编码的一部分,不是数据错误。

解决:解析 BIT STRING 时跳过第一个字节。解析 INTEGER 时,如果第一个字节是 0x00 且长度大于 1,可以跳过它再使用,但要注意记录原始长度。OpenSSL 的BN_bin2bn能正确处理带前导零的缓冲区,手写代码里如果自己做字节比较,要留意这一点。

4.3 现象:EC 公钥取出来坐标长度不对,或者验签失败

原因是把压缩格式的 EC 点当成了非压缩格式。压缩格式以0x02或0x03开头,只有一个坐标加一个标志位,长度是非压缩格式的一半加一。很多证书默认用非压缩格式,但标准允许压缩格式。

解决:判断第一个字节。0x04是非压缩,0x02/0x03是压缩。压缩格式需要根据曲线方程恢复另一个坐标,手写实现较复杂,建议直接用 OpenSSL 的EC_POINT_oct2point,它能自动处理两种格式。

4.4 现象:程序在解析某些证书时崩溃,换一张就正常

原因是手写解析器缺少边界检查,遇到畸形长度字段时越界读。ASN.1 的长度字段可以声明一个比实际缓冲区大得多的值,如果解析器不校验idx + len <= buf_len,就会读到非法内存。

解决:每一层read_tlv都必须做边界检查,包括长度字节本身的读取和 value 的读取。递归深度也要限制,虽然 X.509 嵌套不深,但恶意构造的证书可能通过不定长编码制造循环。生产代码里建议用经过审计的库,手写解析只用于格式完全可控的场景。

4.5 现象:OpenSSL 1.0.2 和 1.1.1 之间代码编译不过

原因是 OpenSSL 1.1.0 把很多结构体设成了不透明类型,不能再直接访问rsa->n、ec->group这类成员。1.0.2 时代的代码在 1.1.1 上会报“dereferencing pointer to incomplete type”。

解决:统一用 getter 函数。RSA 用RSA_get0_key,EC 用EC_KEY_get0_group和EC_KEY_get0_public_key,DSA 用DSA_get0_pqg。如果必须兼容 1.0.2,用#if OPENSSL_VERSION_NUMBER < 0x10100000L做条件编译,但更好的做法是直接要求 1.1.0 以上,1.0.2 已经停止维护很久了。

5. 把公钥提取做成可复用模块:接口设计与自检习惯

走到这里,取公钥这件事本身已经能跑通了。但工程里真正有价值的是把它做成一个可复用、可测试的模块,而不是每次复制粘贴一段解析代码。我一般会定义一个统一的接口,把算法差异藏在内部:

typedef enum { PUBKEY_TYPE_RSA, PUBKEY_TYPE_EC, PUBKEY_TYPE_UNKNOWN } pubkey_type_t; typedef struct { pubkey_type_t type; union { struct { uint8_t *modulus; /* 大端字节序 */ size_t modulus_len; uint8_t *exponent; size_t exponent_len; } rsa; struct { uint8_t *point; /* 04||X||Y 或压缩格式 */ size_t point_len; int curve_nid; /* OpenSSL NID,手写实现可换成自定义枚举 */ } ec; } u; } pubkey_info_t; int pubkey_extract(const uint8_t *cert_der, size_t cert_len, pubkey_info_t *out); void pubkey_info_free(pubkey_info_t *info);

这个接口的关键点是:内部把公钥数据拷贝出来,调用方拿到的是独立内存,不依赖原始证书缓冲区。pubkey_info_free负责释放。这样调用方不用关心 DER 缓冲区的生命周期,模块边界清晰。代价是一次内存拷贝,但公钥通常只有几百字节,这点开销可以接受。

实现上,pubkey_extract内部可以先用 OpenSSL 的d2i_X509和X509_get_pubkey拿到EVP_PKEY,再根据类型填充pubkey_info_t。RSA 的模数和指数用BN_bn2bin导出成大端字节序,EC 的点用EC_POINT_point2oct导出。这样对外接口不暴露任何 OpenSSL 类型,将来换库或者手写实现都不影响调用方。

自检习惯上,我建议在模块里加一个pubkey_self_test函数,用一对固定的测试证书和签名数据跑一遍验签。每次改动解析逻辑后先跑自检,再跑集成测试。这个习惯帮我拦下过好几次“改了一行边界判断,结果把正常证书也拒了”的回归问题。证书解析这种代码,正常路径很容易写对,难的是异常路径不崩、不误判。把自检做成模块的一部分,比事后调试划算得多。

最后说一个我自己的教训:早期做设备端验签时,为了省空间手写了一个极简解析器,只处理 RSA、只处理非压缩 EC、只处理短格式长度。上线后遇到一张用长格式长度编码的证书,解析直接失败,设备无法升级。后来补上长格式支持,代码多了几十行,但再没出过类似问题。证书是别人给的,格式的边界比你想象的多,能交给成熟库就别自己扛。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询