安全启动这套机制刚在企业内网铺开那阵子,我手头有一批自研的采集卡驱动要上线,编译、加载、功能测试一路绿灯,结果换到开了安全启动的机器上,insmod直接甩回来一句Required key not available。当时第一反应是权限问题,sudo加了好几遍,dmesg翻来覆去看,最后才反应过来——内核根本不认这个模块,因为它没签名。这件事让我意识到,Linux Secure Boot 下为内核模块签名不是可选项,而是任何要在受控环境里加载第三方驱动的团队绕不过去的一道坎。这篇内容就是把我从踩坑到跑通、再到批量落地的完整过程摊开讲清楚,适合正在做驱动开发、系统集成、或者维护一批自编译内核模块的同行参考,哪怕你之前完全没碰过密钥体系,跟着思路走也能落地。
1. 先搞清楚安全启动到底在拦什么
很多人一上来就去搜“怎么签名”,结果命令敲了一堆还是加载失败,根子在于没弄明白拦截发生在哪一层。安全启动的信任链是从固件开始的:主板 UEFI 固件里烧了一组受信任的证书(平台密钥 PK、密钥交换密钥 KEK、签名数据库 db),固件只愿意把控制权交给被 db 里证书签过名的引导程序。引导程序(比如 shim 或 GRUB)再往下校验内核,内核再校验模块。每一环都是“上一环信任下一环”的链式结构,任何一环签名对不上,链条就断在那里。
所以模块加载失败,可能断在三个不同的位置,排查方向完全不同:
| 失败位置 | 典型现象 | 根因方向 |
|---|---|---|
| 固件层 | 系统根本起不来,直接进 BIOS 报错 | 引导程序没被 db 信任 |
| 引导层 | 内核 panic 或提示 verification failed | 内核镜像未签名或密钥不匹配 |
| 模块层 | insmod报Required key not available或Key was rejected by service | 模块未签名,或签名密钥不在内核密钥环里 |
我遇到的就是第三层。这里有个容易被忽略的点:模块签名验证用的是内核自己的密钥环(kernel keyring),不是固件里的 db。也就是说,就算你的模块用某个密钥签了名,只要这个密钥没被导入到内核的.platform或.secondary密钥环,内核照样拒绝。这一点是后面所有操作的核心逻辑,先记住。
还有一个常见误解:以为签名是“加密模块内容”。其实不是,签名只是对模块内容算一个哈希再用私钥签一下,内核用公钥验签,验证的是完整性和来源,模块本身还是明文。所以签名不会影响模块的运行性能,也不会改变它的加载方式,纯粹是过一道身份核验。
1.1 内核密钥环里到底该放什么
内核在启动时会加载几类密钥:内建的(编译进内核的)、从固件拿的(如果有)、以及通过keyctl或配置文件导入的。模块签名验证默认会去.platform密钥环找可信公钥。你可以用下面这条命令看当前系统里都有哪些密钥:
cat /proc/keys输出里会列出每个密钥的 ID、类型、权限和描述。如果这里看不到你签名用的公钥,那模块必然加载失败。另一个更直观的方式是看内核日志里关于Loading compiled-in X.509 certificates的记录,能确认哪些证书被内核接受了。
这里有个实操细节:不同发行版对密钥环的初始化策略不一样。有的发行版会把/etc/ssl/certs下的某些证书自动导入,有的则完全依赖CONFIG_SYSTEM_TRUSTED_KEYS编译时内建。你不能假设“我签了名系统就该认”,必须显式确认公钥进了密钥环。我一般会在签名前先跑一遍cat /proc/keys | grep <我的密钥描述>,确认基线状态,签完再跑一遍对比,这样出问题能快速定位是签名环节还是导入环节。
1.2 为什么自签名密钥比想象中更麻烦
用发行版自带的签名服务(比如某些发行版提供的 MOK 机制)确实省事,但企业环境里往往不允许——你的模块可能涉及内部逻辑,不想走外部签名流程,或者根本没有可用的签名服务。这时候就得自己生成一对密钥,自己签,自己导入。麻烦点在于:自签名意味着你要同时管好私钥的安全和公钥的分发,私钥泄露等于任何人都能签出被你的机器信任的模块,这在安全上是致命的。
所以我的做法是把签名私钥放在一台离线的签名机上,模块编译完成后拷过去签名,再把签好的.ko拷回目标机。听起来繁琐,但这是唯一能兼顾安全和可维护的方式。如果团队规模大,可以搭一个内部的签名服务,把私钥锁在服务里,开发人员只提交模块、拿回签名结果,不接触私钥本身。
2. 生成密钥对:别随手 openssl 一把梭
网上很多教程直接甩一句openssl req -new -x509 ...就完事,但内核模块签名对密钥格式有具体要求,随手生成的密钥很可能在导入或验签阶段出问题。我踩过的坑是:用默认参数生成的密钥,sign-file工具能签,但内核导入时报invalid certificate,折腾半天才发现是密钥长度和扩展字段的问题。
内核模块签名推荐用RSA 2048 位或以上,证书要带正确的 Key Usage 扩展。下面是我实际在用的一套生成命令,分两步:先出私钥,再出自签名证书。
# 生成 2048 位 RSA 私钥 openssl genrsa -out module_signing.key 2048 # 用私钥生成自签名证书,有效期 10 年 openssl req -new -x509 -nodes -days 3650 \ -key module_signing.key \ -out module_signing.crt \ -subj "/CN=Internal Module Signing/" \ -addext "keyUsage=digitalSignature" \ -addext "extendedKeyUsage=codeSigning"几个参数值得说清楚为什么这么设:
-days 3650:证书有效期。设太短会导致到期后新签的模块突然加载不了,排查起来很隐蔽。十年是个折中,既不会短到频繁换证,也不至于长到失控。-addext "keyUsage=digitalSignature":明确这个密钥只用于数字签名。内核验签时会检查 Key Usage,缺了这项可能被拒。-addext "extendedKeyUsage=codeSigning":标记为代码签名用途,语义上更规范,部分内核版本会校验。-subj "/CN=...":主题名。这里填一个能标识团队或用途的名字即可,不要填真实公司名或个人信息,用内部代号就行。
生成完你会得到两个文件:module_signing.key(私钥,绝不能外泄)和module_signing.crt(证书,要分发到目标机)。我习惯再导出一份 DER 格式的公钥,因为有些导入工具只认 DER:
openssl x509 -in module_signing.crt -outform DER -out module_signing.der提示:私钥文件的权限一定要收紧,
chmod 600 module_signing.key,并且放在加密存储或离线介质里。我见过把私钥随手放在代码仓库里的,等于把整个信任体系拱手让人。
2.1 密钥长度和算法选择的取舍
有人会问,能不能用 ECC 密钥,体积小、签名快。技术上内核支持 ECDSA,但兼容性不如 RSA 稳,尤其是老一些的内核版本或某些发行版的验签实现,对 ECC 的支持参差不齐。我在一个混合内核版本的环境里试过 ECC,结果一半机器认、一半不认,最后统一退回 RSA 2048。如果你的环境内核版本统一且较新,可以评估 ECC;否则 RSA 是更保险的选择。
至于 4096 位,安全性更高但签名和验签都更慢,对于模块加载这种低频操作,性能差异可以忽略,但密钥文件更大、部分嵌入式环境的存储吃紧。我的建议是:通用环境用 2048,高安全要求场景用 4096,别用 1024(已经不安全了)。
2.2 多密钥并存的规划
一个容易被低估的问题是:随着时间推移,你可能会换密钥(比如旧密钥到期、人员变动)。如果只维护一把密钥,换的时候所有机器都要同步更新,风险集中。我的做法是按用途或按团队分多把密钥,比如“采集类驱动一把、存储类驱动一把”,每把密钥的证书都导入目标机。这样某一把出问题或需要轮换时,影响面可控。
代价是密钥环里证书变多,管理成本上升。所以我会维护一张表,记录每把密钥的用途、生成日期、有效期、对应哪些模块,放在内部文档里。这张表在排查“某个模块为什么突然加载不了”时特别有用——往往就是某把密钥到期了。
3. 签名操作:sign-file 的用法和那些没写进手册的细节
密钥准备好,接下来就是给模块签名。内核源码树里自带一个scripts/sign-file工具,这是最标准的方式。它的调用格式是:
scripts/sign-file <hash算法> <私钥> <证书> <待签名模块>实际用起来大概是这样:
/usr/src/linux-headers-$(uname -r)/scripts/sign-file \ sha256 \ module_signing.key \ module_signing.crt \ my_driver.ko执行完,my_driver.ko会被就地修改,末尾追加签名数据。你可以用modinfo验证签名信息是否写进去了:
modinfo my_driver.ko | grep -i signer正常会输出signer: Internal Module Signing之类的信息。如果什么都没有,说明签名没成功,或者工具路径不对。
这里有几个手册里不会强调、但实际会咬人的细节:
第一,sign-file的路径因发行版而异。有的在/usr/src/linux-headers-*/scripts/,有的在/lib/modules/$(uname -r)/build/scripts/,还有的发行版根本没装内核头文件包,得先装linux-headers。我一般先用find / -name sign-file 2>/dev/null定位,确认存在再写脚本。
第二,签名是就地覆盖,没有备份。签坏了原文件就废了。所以我的脚本里永远先cp my_driver.ko my_driver.ko.bak,签完验证通过再删备份。这个习惯救过我好几次——有次密钥路径写错,sign-file报错但文件已经被截断,没备份就得重新编译。
第三,哈希算法要和内核配置匹配。大多数现代内核用sha256,但极少数老配置可能只支持sha1或要求sha512。如果签名后加载报invalid signature,先确认内核的CONFIG_MODULE_SIG_HASH配置值,用grep CONFIG_MODULE_SIG_HASH /boot/config-$(uname -r)查一下,签名时用同样的算法。
3.1 批量签名脚本的写法
单个模块手动签还行,一批模块就得脚本化。我用的脚本大致长这样,核心是把路径、密钥、模块列表参数化:
#!/bin/bash set -e SIGN_TOOL="/usr/src/linux-headers-$(uname -r)/scripts/sign-file" KEY="/secure/signing/module_signing.key" CRT="/secure/signing/module_signing.crt" HASH="sha256" for ko in "$@"; do echo "Signing $ko ..." cp "$ko" "$ko.bak" "$SIGN_TOOL" "$HASH" "$KEY" "$CRT" "$ko" if modinfo "$ko" | grep -q "signer:"; then echo " OK" rm "$ko.bak" else echo " FAILED, restoring backup" mv "$ko.bak" "$ko" exit 1 fi doneset -e保证任何一步失败就停,避免半签状态扩散。每个模块签完立即用modinfo验证,失败就回滚。这套逻辑看着朴素,但在 CI 里跑很稳,出问题能立刻定位到具体哪个模块。
3.2 签名后模块大小和加载行为的变化
签名会在模块末尾追加一段数据,大小通常是几百字节到一两 KB,取决于密钥长度和哈希算法。这个变化不影响加载,但如果你有校验模块哈希的流程(比如某些完整性检查),要注意签名后的哈希和签名前不一样,校验基准得用签名后的文件。
另外,签名后的模块在未开启安全启动的机器上照样能加载,内核会忽略签名验证。所以同一批签名模块可以同时用于安全启动和非安全启动环境,不需要维护两套,这点在混合环境里省了不少事。
4. 把公钥送进内核:导入环节才是真正的分水岭
签好名的模块拷到目标机,insmod还是可能失败,因为公钥没进内核密钥环。这一步是整个流程里最容易卡住的地方,也是不同发行版差异最大的地方。导入方式主要有三种,我按适用场景分别说。
4.1 编译时内建:一劳永逸但不够灵活
如果你们是自己编译内核,最干净的方式是把证书路径写进内核配置:
CONFIG_SYSTEM_TRUSTED_KEYS="module_signing.crt"编译时内核会把这张证书直接编进.platform密钥环,开机即信任,不需要任何运行时操作。缺点是换密钥就得重新编译内核,对于频繁迭代的团队不现实。我一般只在做定制固件、内核版本长期固定的场景用这种方式。
4.2 运行时通过 keyctl 导入:灵活但要注意持久性
keyctl可以在运行时把证书加进内核密钥环:
keyctl padd asymmetric "internal_signing" %:.platform < module_signing.der这条命令把 DER 格式的证书加进.platform密钥环,描述设为internal_signing。加完再cat /proc/keys就能看到。但要注意,这种导入是临时的,重启就没了。所以它适合调试阶段快速验证,不适合生产。生产环境要么配合开机脚本重新导入,要么用下面的 MOK 方式。
4.3 MOK 机制:需要人工确认但能持久化
很多发行版支持 MOK(Machine Owner Key)机制,通过mokutil工具导入证书,重启时在固件的 MOK 管理界面确认一次,之后证书就持久化在机器的 NVRAM 里。流程是:
# 导入证书(会要求设置一个一次性密码) sudo mokutil --import module_signing.der # 重启,在 MOK 管理界面选择 Enroll MOK,输入刚才的密码 sudo reboot重启后进 MOK 界面这一步是人工操作,批量部署时比较烦,但它是唯一不需要重新编译内核、又能持久化的标准方式。我在小规模环境用这个,大规模环境会评估是否有自动化确认的手段,或者干脆走编译内建。
三种方式对比如下:
| 导入方式 | 持久性 | 是否需重编译 | 批量友好度 | 适用场景 |
|---|---|---|---|---|
| 编译内建 | 永久 | 是 | 中 | 定制固件、内核版本固定 |
| keyctl 运行时 | 重启失效 | 否 | 高 | 调试、临时验证 |
| MOK | 持久 | 否 | 低(需人工确认) | 小规模生产、单机部署 |
4.4 导入后验证:别跳过这一步
不管用哪种方式导入,导入完一定要验证。最直接的是看/proc/keys里有没有对应条目,再进一步是实际加载一个签名模块试试。我习惯用一个“探针模块”——一个功能极简、专门用来验证签名链路的小模块,每次环境变更后先加载它,通了再上业务模块。这样能把“签名体系问题”和“业务模块自身问题”隔离开,排查效率高很多。
5. 排查链路:加载失败时按这个顺序查
模块加载失败的现象就那么几种,但原因可能分布在签名、导入、内核配置、模块本身四个层面。我整理了一套固定的排查顺序,每次按这个走,基本能在十分钟内定位。
5.1 第一步:确认错误码的确切含义
insmod的报错信息很关键,不同错误码指向不同方向:
Required key not available:模块没签名,或者签名了但公钥不在密钥环。Key was rejected by service:公钥在密钥环里,但验签失败(签名损坏、密钥不匹配、哈希算法不对)。Invalid module format:模块和当前内核版本不匹配,跟签名无关。Operation not permitted:可能是安全启动策略或权限问题。
先看清楚是哪一种,再往下查,别一上来就重新签名,方向错了白费功夫。
5.2 第二步:确认模块确实带了签名
modinfo my_driver.ko | grep -i "sig"如果没有任何sig相关输出,说明签名根本没写进去,回到第 3 节检查sign-file是否执行成功。如果有sig_id但加载仍失败,进入下一步。
5.3 第三步:确认公钥在密钥环里
cat /proc/keys | grep -i "internal_signing"没有的话,说明导入没生效或重启后丢了,回到第 4 节重新导入。有的话,对比一下密钥环里证书的指纹和签名用的证书指纹是否一致:
# 查看证书指纹 openssl x509 -in module_signing.crt -noout -fingerprint -sha256指纹对不上,说明导入的是旧证书,或者签名用的是另一把密钥,这是多密钥环境下最常见的坑。
5.4 第四步:检查内核配置
grep -E "CONFIG_MODULE_SIG|CONFIG_SYSTEM_TRUSTED" /boot/config-$(uname -r)重点看CONFIG_MODULE_SIG_FORCE是否为y。如果是y,内核会强制所有模块必须签名,连未签名的内建模块都可能受影响;如果是n,未签名模块在非安全启动下还能加载,但安全启动下依然会被拦。这个配置决定了你的容错空间。
5.5 第五步:用 dmesg 看内核侧的真实原因
用户态报错往往很笼统,dmesg里内核打印的信息更具体:
dmesg | tail -30常见的有Loading of unsigned module is rejected、PKCS#7 signature not signed with a trusted key等,直接指向根因。我排查时习惯先dmesg -C清空,再复现一次加载,这样日志干净,一眼能看到相关条目。
6. 几个让我印象深刻的坑和对应的解法
光讲流程不够,实际落地时有些坑是文档里绝对看不到的,这里挑几个典型的说说。
6.1 内核升级后签名突然失效
有次一批机器内核小版本升级,升级前好好的模块突然加载不了。查了半天发现:升级后的内核重新编译过,CONFIG_SYSTEM_TRUSTED_KEYS指向的证书路径变了,或者干脆没带我们的证书。内核升级会重置密钥环的内建部分,运行时导入的(keyctl 方式)也会因为重启丢失。解法是把证书导入做成开机自启的服务,或者升级流程里强制包含 MOK 重新确认步骤。这个坑的教训是:签名体系要和内核生命周期绑定管理,不能签完就不管了。
6.2 同一模块在不同机器上表现不一致
同样的模块、同样的签名,A 机器能加载,B 机器不行。最后发现 B 机器的密钥环里有一把同名但不同内容的旧证书,内核按描述匹配时命中了旧的,验签自然失败。这提醒我:密钥描述要带版本号或日期,比如internal_signing_2024,避免同名冲突。多密钥环境下这个细节能省掉大量排查时间。
6.3 签名工具版本和内核版本不匹配
sign-file是内核源码的一部分,不同内核版本的sign-file生成的签名格式可能有细微差异。我用过一次系统自带的旧版sign-file去签给新内核用的模块,结果验签失败。后来统一规定:签名工具必须和目标内核版本对应,从目标内核的头文件包里取sign-file,别图省事用系统里随便找到的那个。
6.4 模块依赖链上的签名遗漏
一个模块可能依赖其他模块,如果依赖的模块没签名,加载主模块时也会失败,报错却不直接指向依赖。排查时用modprobe --show-depends my_driver看依赖树,确保链上每个模块都签了名。这个坑在复杂驱动里特别常见,因为依赖往往是间接的。
7. 把流程固化下来:脚本、文档和检查清单
单次跑通不算本事,能稳定复现、团队里谁都能操作才算落地。我最后做的是三件事:一个签名脚本、一份操作文档、一张检查清单。
签名脚本前面给过框架,实际用的时候我会加两个增强:一是从环境变量读密钥路径,避免硬编码;二是签名前校验密钥文件权限,权限过宽直接拒绝执行。文档则把“生成密钥、签名、导入、验证”四步写成可复制的命令块,每步附上预期输出,新人照着做不会迷路。检查清单是给上线前用的,列了“密钥有效期是否充足、目标机密钥环是否含正确证书、依赖模块是否全部签名、内核版本是否匹配”这几项,逐项打勾。
这套东西的价值在出问题时体现得最明显。有次线上反馈某模块加载失败,值班同事按清单走,五分钟就定位到是某把密钥到期,而不是像以前那样从头排查一遍。签名这件事,流程化程度决定了它的可靠上限。
最后分享一个我个人的习惯:每次更换或新增密钥,我都会在一台隔离的测试机上完整走一遍“生成—签名—导入—加载—重启后再加载”的全流程,确认重启后依然有效才推到生产。这个习惯源于一次教训——某次只验证了当次加载成功,没测重启,结果生产机重启后集体失效,因为用的是 keyctl 临时导入。多花十分钟测重启,能省掉一次半夜救火。