C语言从X.509证书提取公钥:OpenSSL与手写ASN.1解析实战
简介这份资源面向具备一定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 还是 Ed25519subjectPublicKey是一段 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; }编译命令要显式链接 cryptogcc -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 /CNtest # 从证书提取公钥 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_keyEC 用EC_KEY_get0_group和EC_KEY_get0_public_keyDSA 用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、只处理短格式长度。上线后遇到一张用长格式长度编码的证书解析直接失败设备无法升级。后来补上长格式支持代码多了几十行但再没出过类似问题。证书是别人给的格式的边界比你想象的多能交给成熟库就别自己扛。希望帮到你。本文还有配套的精品资源点击获取