ADR-0016: Kernel Modules Are Signed After the Build, by a Recorded Step¶
Date: 2026-10-17
Status¶
Accepted (2026-10-18)
Supersedes the module-signing half of the first bullet under "Negative" in
ADR-0009 ("CONFIG_MODULE_SIG_ALL signs the
modules with a key generated for each build ..."). The debian/changelog half of that bullet was
closed by a date wrapper on the bindeb-pkg path (#171). The rest of ADR-0009 stands.
Context¶
Issue #77 asks for packages that two builds of one tag write bit for bit alike. Two
build-matrix.yml runs of mainstream/x86_64 after #171 (runs 38991220451 and 38986603555)
were compared member by member:
- the headers package, the
linux-libc-devpackage and the resolved configuration were identical; - the image package and
vmlinuzdiffered in three places only: the DER certificate "Build time autogenerated kernel key" embedded in the kernel, the kernel'sNT_GNU_BUILD_ID, which follows from it, and the appended signature of each of the 280 modules. With the signatures cut off, no module differed.
Every leg resolved CONFIG_MODULE_SIG_ALL=y and CONFIG_MODULE_SIG_KEY="certs/signing_key.pem"
from its defconfig; no fragment asked for either. With that default, certs/Makefile runs
openssl req -new -nodes -x509 ... -config x509.genkey during the build, so each build trusts and
signs with a key of its own, which CI then discards. A build that generates its key cannot be
reproduced, and a key that is not kept cannot sign anything later.
The kernel documents the way out in Documentation/kbuild/reproducible-builds.rst, section
"Module signing" (Linux 7.2.10): build with CONFIG_MODULE_SIG_ALL off and the certificate given
through CONFIG_SYSTEM_TRUSTED_KEYS, then sign the modules afterwards. Three upstream facts make
this work for this forge:
certs/Makefilegenerates a key only whileCONFIG_MODULE_SIG_KEYiscerts/signing_key.pem; withCONFIG_MODULE_SIG_KEY="",extract-certwrites an emptysigning_key.x509(certs/extract-cert.c), and the kernel trusts only the certificates inCONFIG_SYSTEM_TRUSTED_KEYS;scripts/Makefile.modinstsigns duringmodules_installonly whenCONFIG_MODULE_SIG_ALLis set, sobindeb-pkgpackages unsigned modules;scripts/sign-file.csigns withCMS_NOATTRfor RSA keys, so the same key over the same module writes the same signature.
Decision¶
-
The configuration trusts a certificate and generates no key.
kconfig/security-hardened.configsetsCONFIG_MODULE_SIG=y,# CONFIG_MODULE_SIG_ALL is not set,CONFIG_MODULE_SIG_SHA512=y,CONFIG_MODULE_SIG_KEY=""andCONFIG_SYSTEM_TRUSTED_KEYS="certs/nucleus-module-signing.pem", a path in the build directory.CONFIG_MODULE_SIG_FORCEstays off, as before. -
build_kernel.shsupplies the key and its certificate.--module-key=<pem>names a PEM file with the private key and the certificate. Without it, the build generates an RSA key from the kernel's owncerts/default_x509.genkey, as the kernel did before, uses it for this build and deletes it after signing, whether signing succeeded or not. Either way the certificate is written tocerts/nucleus-module-signing.pemin the build directory beforemake bindeb-pkg. -
A recorded step signs after packaging.
scripts/sign_kernel_modules.pyunpacks the image package, refuses unless the key's DER certificate is insidevmlinux, the package carries at least one module, every module is an uncompressed.koand none is signed yet; signs every module with the build's ownscripts/sign-file; verifies each signature withopenssl cms -verifyagainst the certificate; rewrites the modules' lines ofDEBIAN/md5sums; and rebuilds the package withdpkg-deb --root-owner-groupand the data member's compressor. It requiresSOURCE_DATE_EPOCH, which clamps the times of the files it touched. The unsigned package is kept in<work-dir>/<stream>-<arch>/unsigned/, andbuild.jsonrecords the step undermodule_signing: the key's origin (suppliedorephemeral), the certificate's SHA-256, the hash, the number of modules, and the SHA-256 of the unsigned and the signed package. -
The artifact gate refuses an unsigned module.
scripts/check_kernel_artifacts.pyrefuses an image package that carries no module, a compressed module, or a.kothat does not end in an appended PKCS#7 module signature. -
A stable key is the operator's act. Two builds are byte-identical only when they sign with one key. A long-lived signing key for releases is a credential of the operator's and is not created here; until one exists, every build signs with its own ephemeral key, as before, and a comparison of two builds compares the unsigned packages or passes both builds one key.
Consequences¶
Positive¶
- The build itself is now deterministic in its module handling: the unsigned packages of two builds of one tag can be compared, and two builds given one key can be compared signed.
- Signing is a separate step with its own record; the packages before and after it are both kept.
- The gate proves every published module carries a signature; before, nothing checked.
Negative¶
- Without
--module-key, released packages still differ per build, by the key, as before. - The kernel's build ID still follows the trusted certificate, so two builds with different keys
produce different
vmlinuzfiles. - An external module (OpenZFS, DKMS) is not signed by this step; it loads by tainting the kernel,
as before, since
CONFIG_MODULE_SIG_FORCEis off.
Neutral¶
- The hash stays SHA-512 and the key type RSA, the values every leg resolved before.
Release key and revisit trigger¶
publish-release.yml passes no --module-key, so every release signs its modules with a key
generated for that build and deleted after it. This stays so: a stable release key is a
credential the operator would create and hold, and a leaked one would let anyone sign modules
the released kernels trust. Nothing needs one today.
Revisit when either holds, and bring the stable key to the operator as a decision:
- a consumer (imago or another) needs out-of-tree or DKMS modules signed for a released kernel;
- a byte-identical rebuild of a released tag has to be shown, not only of two builds with one key.
Compliance¶
tests/test_sign_kernel_modules.py: signs with a stand-insign-fileand realopensslanddpkg-deb; two runs with one key write identical packages; refuses a signatureopenssldoes not verify, an already signed module, a compressed module, a package without modules, a certificate the kernel does not carry, a hash name that is not one, and a missingSOURCE_DATE_EPOCH, each leaving the package unchanged.tests/test_check_kernel_artifacts.py: the gate refuses an image without modules, with a compressed module or with an unsigned module.tests/test_build_kernel.py: the dry run states the key and the signing step; a--module-keythat is not a file is refused before anything is written;build.jsonrecordsmodule_signing..github/workflows/build-matrix.yml, jobreproducibility: buildsmainstream/x86_64 twice with one generated--module-keyand fails unlessscripts/verify-reproducibility.shfinds the two output directories byte-identical.scripts/kconfig_survival.py, run for all twelve legs byverify-requirements.ymlandbuild-matrix.yml, refuses a leg on which the signing values do not surviveolddefconfig.
References¶
- Issue #77 (reproducible Debian packages); #171 (
debian/changelogdate). - Linux 7.2.10:
Documentation/kbuild/reproducible-builds.rst,certs/Makefile,certs/extract-cert.c,scripts/Makefile.modinst,scripts/sign-file.c,certs/default_x509.genkey. - ADR-0009.