Skip to content

Signatures with digest signing

One kind of operation, DigestSigning, covers most of what a product signs: ESP32 and other firmware, kernel modules, tokens, attestations and file signatures. The key stays in the HSM, and each result is checked with the verifier the device or service uses.

The Cyber Resilience Act's Annex I requires that a product protects the integrity of its software and that unauthorised changes are detected. Much of what a device checks is not a format the platform builds itself, and for all of it the pattern is the same: the build hashes what it ships, the platform signs the hash under a recorded, group-restricted operation, and the device's own verifier checks it.

How to read this

The digest signing page is the reference: every option, preset and error message. This page starts from the use case instead. Each one says what the device checks, gives the signing command, and shows how to verify the result the way the device will. Where signing-tool has an envelope for a format (kernel modules, JWS, DSSE, IMA), the page uses it; elsewhere it sends the digest with imagesigning add and the format's own tool assembles the result. SWUpdate and Mender have pages of their own. Building a device this way does not by itself make a product CRA compliant; the Annex I mapping sets out which requirements the platform supports and which remain yours.

Client version

The commands on this page need signing-tool 3.11.0 or later. The config file test.ini is set up once, as described in providing the token.

Terms used here. A product holds the keys for one device line. Each operation in it is one key: its type says what the key may do (here mostly DigestSigning, which signs a digest you send), and its approval rule says who may ask for a signature and who approves. A request is one signing call; signing-tool submits it and downloads the result. See the product guide.

Find your use case

Use case The device or verifier checks What the platform returns Certificate needed
ESP32 Secure Boot V2 The ROM bootloader checks the bootloader, the bootloader checks the app, against a key digest in eFuse A raw signature; espsecure attaches it No
Firmware for your own bootloader A signature in the image header against a key in fuses or flash A raw signature No
Linux kernel modules The kernel checks a module's appended signature against its trusted keys The signed module Yes
File integrity at run time Linux IMA checks each file's signature before it runs or is read One signature per file Yes
UEFI Secure Boot variables The firmware checks a db update against the KEK A PKCS #7 for db.auth Yes
Signed tokens and configuration A device or service checks a signed file against your public key A JWS No
Supply-chain attestations A customer or auditor checks an in-toto statement A DSSE envelope No
Post-quantum signatures An ML-DSA signature, raw or in a CMS A raw signature or a CMS For kernel modules
Container images cosign or skopeo against the operation's public key See OCI signing —
Over-the-air updates SWUpdate or Mender checks the update before it installs See SWUpdate and Mender —

Formats with an operation of their own are signed there: RAUC bundles, MCUboot images, NXP HAB and AHAB.

The LAAVAT product

Use one DigestSigning operation per key, and separate operations for keys that guard different things, so each can have its own approval rule: an image key every device trusts should need an approval for every request, while a key for build attestations can sign unattended.

{
  "name": "Gateway release signing",
  "description": "Digest signing: firmware, Linux, tokens, attestations, UEFI and post-quantum keys",
  "productType": "Production",
  "enabled": true,
  "caInfo": [], "rndKeys": [],
  "productOperations": [
    { "name": "Firmware RSA", "description": "ESP32 RSA chips, RSA bootloaders", "operationType": "DigestSigning",
      "token": { "name": "firmware RSA key", "description": "RSA-3072", "keyType": "RSA3072" },
      "approvalRule": { "name": "Release", "description": "Release group requests, approver group approves",
                        "allowedGroups": ["<release-group-uuid>"], "approvalGroups": ["<approver-group-uuid>"], "blanketGroups": [] } },
    { "name": "Firmware EC", "description": "ECDSA-only ESP32 chips, raw r and s bootloaders", "operationType": "DigestSigning",
      "token": { "name": "firmware EC key", "description": "EC P-256", "keyType": "ECDSAP256" },
      "approvalRule": { "name": "Release", "description": "Release group requests, approver group approves",
                        "allowedGroups": ["<release-group-uuid>"], "approvalGroups": ["<approver-group-uuid>"], "blanketGroups": [] } },
    { "name": "Linux runtime", "description": "Kernel modules and IMA", "operationType": "DigestSigning",
      "token": { "name": "Linux key", "description": "RSA-3072", "keyType": "RSA3072" },
      "approvalRule": { "name": "Release", "description": "Release group requests, approver group approves",
                        "allowedGroups": ["<release-group-uuid>"], "approvalGroups": ["<approver-group-uuid>"], "blanketGroups": [] } },
    { "name": "Tokens and configuration", "description": "Licences and configuration devices accept", "operationType": "DigestSigning",
      "token": { "name": "token key", "description": "EC P-256", "keyType": "ECDSAP256" },
      "approvalRule": { "name": "Release", "description": "Release group requests, approver group approves",
                        "allowedGroups": ["<release-group-uuid>"], "approvalGroups": ["<approver-group-uuid>"], "blanketGroups": [] } },
    { "name": "Build attestations", "description": "in-toto statements, signed unattended", "operationType": "DigestSigning",
      "token": { "name": "attestation key", "description": "EC P-256", "keyType": "ECDSAP256" },
      "approvalRule": { "name": "Pipeline", "description": "The build pipeline signs unattended",
                        "allowedGroups": ["<pipeline-group-uuid>"], "approvalGroups": ["<pipeline-group-uuid>"], "blanketGroups": ["<pipeline-group-uuid>"] } },
    { "name": "UEFI KEK", "description": "RSA-2048, the common KEK size; signs db updates", "operationType": "DigestSigning",
      "token": { "name": "KEK", "description": "RSA-2048", "keyType": "RSA2048" },
      "approvalRule": { "name": "Platform keys", "description": "Platform security group requests, approver group approves",
                        "allowedGroups": ["<platform-security-group-uuid>"], "approvalGroups": ["<approver-group-uuid>"], "blanketGroups": [] } },
    { "name": "Post-quantum", "description": "ML-DSA-65", "operationType": "DigestSigning",
      "token": { "name": "ML-DSA key", "description": "ML-DSA-65", "keyType": "MLDSA65" },
      "approvalRule": { "name": "Release", "description": "Release group requests, approver group approves",
                        "allowedGroups": ["<release-group-uuid>"], "approvalGroups": ["<approver-group-uuid>"], "blanketGroups": [] } }
  ]
}
signing-tool -n test.ini product add -T release-signing.json
# the new product is approved in the LAAVAT console before use

Approval rules. Each operation's rule decides who may sign with its key. A member of allowedGroups submits the signing request; it then waits until a member of approvalGroups approves it, in the LAAVAT console or with signing-tool, and only then does the HSM sign. If the requester is also a member of one of the blanketGroups, no approval is needed and the request is signed straight away. In the example, every operation needs an approval for each request except build attestations, which list the pipeline group as a blanket group so the build, such as a Jenkins job, can sign unattended; keep such groups narrow, since they skip the human check. The group ids in the rule are the object ids of groups in your identity provider (Microsoft Entra ID or Google Cloud Identity). See approval rules and configuration groups.

Before you sign

The operation's public key

Every Verify step, and espsecure for ESP32, needs the public key of the operation that signed. Export it once per operation; on this page it is called op.pub:

signing-tool -n test.ini product getpubkey -P <product-uuid> --operid <operation-uuid> -O op.pub

What a request sends

In every request, -p "$(openssl dgst -sha256 -binary FILE | base64 -w0)" is the SHA-256 of what you sign, base64-encoded on one line; -H names the hash. That digest is all that reaches LAAVAT; the file stays on the build host. The envelopes (imagesigning envelope …) compute the digests themselves. The one exception is raw ML-DSA, which signs the message itself (see Post-quantum signatures).

A certificate, for kernel modules, IMA and UEFI

These verifiers trust a certificate for the signing key, not the bare key. On this page signer.pem is that certificate, issued by your CA for the operation's public key op.pub, and ca-chain.pem holds the CA certificates above it: the issuing CA and the root.

If your CA issues certificates only from a CSR, kernel modules and UEFI can use a certificate the platform holds instead: see Certificate-based signing. IMA has no such route yet, because its envelope needs this operation's key.

For example, with OpenSSL:

printf '%s\n' 'basicConstraints=CA:FALSE' 'keyUsage=critical,digitalSignature' \
    'extendedKeyUsage=emailProtection' 'subjectKeyIdentifier=hash' 'authorityKeyIdentifier=keyid' > signer.ext
openssl x509 -new -subj "/CN=Release signing" -force_pubkey op.pub \
    -CA issuing-ca.pem -CAkey issuing-ca.key -days 730 -extfile signer.ext -out signer.pem
cat issuing-ca.pem root.pem > ca-chain.pem

What the commands do:

  • signer.ext lists what the certificate allows: it is a signing certificate, not a CA, its key may make signatures, and it carries the identifiers verifiers use to match it to its key and to its CA (subjectKeyIdentifier, authorityKeyIdentifier). The extendedKeyUsage line matters only for SWUpdate, which checks it; these verifiers do not.
  • openssl x509 -new -force_pubkey op.pub creates the certificate for the operation's public key (the private key stays in the HSM) and signs it with your issuing CA (-CA, -CAkey), valid for two years.
  • cat writes ca-chain.pem: the issuing CA and the root.

ESP32 Secure Boot V2

Use case: the ROM bootloader checks the second-stage bootloader, and the bootloader checks the application, each against a public key whose digest is burned into eFuse. An image without a valid signature block does not run.

The key type depends on the chip (see the table below). Two images are signed, the second-stage bootloader and the application, each in the same way and with the same operation:

  • Build them unsigned, with CONFIG_SECURE_BOOT_BUILD_SIGNED_BINARIES turned off. ESP-IDF then pads each image the way Secure Boot V2 requires, and that padded image is what gets hashed and signed.
  • Sign its hash with LAAVAT, and attach the signature with espsecure, as below.
Chip Secure Boot V2 accepts Operation key and settings
ESP32 (v3.0 and later), ESP32-S2, ESP32-S3, ESP32-C3 RSA-3072 RSA3072, -H SHA256 --padding PSS
ESP32-C6 RSA-3072 or ECDSA P-256 As above, or ECDSAP256, -H SHA256
ESP32-H2, ESP32-C5, ESP32-P4 RSA-3072. ECDSA is supported, but Espressif does not recommend it on these chips RSA3072, -H SHA256 --padding PSS
ESP32-H4 ECDSA P-256 or ECDSA P-384 ECDSAP256, -H SHA256, or ECDSAP384, -H SHA384
ESP32-C2, ESP32-C61 ECDSA P-256 ECDSAP256, -H SHA256

These are from Espressif's ESP-IDF documentation; select your chip at the top of that page and check its revision. On the ESP32-H2, C5 and P4, Espressif marks ECDSA Secure Boot V2 as "not functional for certain input vectors" and recommends RSA.

The commands below sign the application, app.bin. The bootloader, bootloader.bin, is signed the same way.

signing-tool -n test.ini imagesigning add DigestSigning -P <product-uuid> --operid <firmware-rsa-operation-uuid> \
    -p "$(openssl dgst -sha256 -binary app.bin | base64 -w0)" -H SHA256 --padding PSS \
    -N "app 1.4.0" -D "ESP32 application 1.4.0"
Image signing request sent. Request ID: <request-uuid> state: Created
signing-tool -n test.ini imagesigning get -I <request-uuid> --wait -O app.sig
espsecure sign-data --version 2 --pub-key op.pub --signature app.sig --output app-signed.bin app.bin

espsecure attaches the signature to the image, using op.pub (see Before you sign) to build the signature block. It checks the signature before it writes app-signed.bin, so a signature over other bytes fails here and not on the device. For ECDSA use the Firmware EC operation and leave out --padding PSS. For P-384 (ESP32-H4) add an operation with an ECDSAP384 key and use -H SHA384 and openssl dgst -sha384. These are the command names of esptool v5; esptool v4 spells them espsecure.py sign_data and verify_signature.

The eFuse holds a digest of the public key, not the key. espsecure computes it from op.pub; ESP-IDF's documentation gives the espefuse command that burns it on each chip.

espsecure digest-sbv2-public-key --keyfile op.pub --output key-digest.bin

To verify:

espsecure verify-signature --version 2 --keyfile op.pub app-signed.bin
Signature block 0 verification successful using the supplied key (RSA).

This is the check the ROM bootloader and the bootloader make. A changed byte in the image gives Signature block image digest does not match the actual image digest.

Firmware for your own bootloader

Use case: a bootloader checks a signature in the firmware image header before it jumps to the firmware image. The public key is in fuses or in protected flash.

Your bootloader decides the signature format. Two common cases:

  • RSA, for a bootloader that checks RSA-PSS: the Firmware RSA operation, first command below.
  • ECDSA P-256 on a small microcontroller: many verifiers expect the signature as 64 bytes, the two 32-byte values r and s one after the other, instead of the DER encoding OpenSSL produces. Use the Firmware EC operation with --encoding RAW, or RAW_LE if your verifier expects little-endian.
signing-tool -n test.ini imagesigning add DigestSigning -P <product-uuid> --operid <firmware-rsa-operation-uuid> \
    -p "$(openssl dgst -sha256 -binary app.bin | base64 -w0)" -H SHA256 --padding PSS \
    -N "app 1.4.0" -D "controller application 1.4.0"
signing-tool -n test.ini imagesigning get -I <request-uuid> --wait -O app.sig
signing-tool -n test.ini imagesigning add DigestSigning -P <product-uuid> --operid <firmware-ec-operation-uuid> \
    -p "$(openssl dgst -sha256 -binary app.bin | base64 -w0)" -H SHA256 --encoding RAW \
    -N "app 1.4.0" -D "controller application 1.4.0"
signing-tool -n test.ini imagesigning get -I <request-uuid> --wait -O app.sig

To verify:

openssl dgst -sha256 -binary app.bin > app.dgst
openssl pkeyutl -verify -pubin -inkey op.pub -in app.dgst -sigfile app.sig \
    -pkeyopt digest:sha256 -pkeyopt rsa_padding_mode:pss -pkeyopt rsa_pss_saltlen:-1
Signature Verified Successfully

This is the check an RSA-PSS bootloader makes. For the raw ECDSA signature, turn r and s into DER first; OpenSSL then checks it the same way:

python3 -c 'from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature as e; b = open("app.sig", "rb").read();
open("app.der.sig", "wb").write(e(int.from_bytes(b[:32], "big"), int.from_bytes(b[32:], "big")))'
openssl pkeyutl -verify -pubin -inkey op.pub -in app.dgst -sigfile app.der.sig -pkeyopt digest:sha256
Signature Verified Successfully

Then put app.sig where your image format expects it.

Linux kernel modules

Use case: out-of-tree drivers, built outside the kernel tree. With module signature enforcement, the kernel loads a module only if its appended signature verifies against a key in its trusted keyring.

signing-tool -n test.ini imagesigning envelope kernel-module -P <product-uuid> --operid <linux-operation-uuid> \
    --state-dir .sign -F drivers/a.ko -F drivers/net/b.ko -O signed/ --cms-cert signer.pem

All modules go into one request with one approval. signing-tool hashes each module, appends the signature in the kernel's format and writes signed/a.ko and signed/net/b.ko; the module bodies are unchanged. signer.pem is the certificate from Before you sign; --cms-cert signer.pem tells signing-tool which certificate the kernel holds for this key: the signature names that certificate by issuer and serial number, which is how the kernel finds the key in its keyring. The certificate itself is not embedded. With several -F, -O names a directory, as here; with one, it names the output file.

To verify:

perl scripts/extract-module-sig.pl -0 signed/a.ko > a.unsigned.ko
perl scripts/extract-module-sig.pl -s signed/a.ko > a.p7s
openssl cms -verify -binary -inform DER -in a.p7s -content a.unsigned.ko \
    -certfile signer.pem -CAfile ca-chain.pem -purpose any -out /dev/null
CMS Verification successful

Both scripts come with the kernel source. -CAfile ca-chain.pem holds the issuing CA as well as the root, because OpenSSL 3.0 does not use -certfile to build the chain. On the target, the kernel trusts signer.pem when it is built in with CONFIG_SYSTEM_TRUSTED_KEYS or loaded into the secondary keyring.

File integrity at run time

Use case: refuse to run a program or library that was changed after release. Linux IMA (Integrity Measurement Architecture) appraisal does this: before the kernel executes or reads a file, it checks the file's signature, stored in the file's security.ima extended attribute, against a certificate in the kernel's .ima keyring.

signing-tool -n test.ini imagesigning envelope ima -P <product-uuid> --operid <linux-operation-uuid> \
    --state-dir .sign -F rootfs/usr/bin/app -F rootfs/usr/lib/libapp.so -O ima/

signing-tool signs every file given with -F in one request, with one approval, and writes into ima/ one signature file per input, plus ima/ima.xattrs. That file sets all the security.ima attributes at once with setfattr --restore while the root filesystem image is assembled, which needs root. The .ima keyring holds signer.pem, the certificate from Before you sign. The kernel accepts a certificate into .ima only if a key in its system trusted keyring signed it (CONFIG_INTEGRITY_TRUSTED_KEYRING, on by default), so your issuing CA's certificate is built into the kernel, for example with CONFIG_SYSTEM_TRUSTED_KEYS.

To verify:

cp ima/bin/app.sig rootfs/usr/bin/app.sig
openssl x509 -in signer.pem -outform DER -out signer.der
evmctl ima_verify --key signer.der --sigfile rootfs/usr/bin/app
rootfs/usr/bin/app: verification is OK

evmctl runs the check the kernel makes, against the same certificate.

UEFI Secure Boot variables

Use case: add a certificate to the UEFI db of machines in the field. db is the list of certificates the firmware accepts boot loaders from, and it accepts a change only if the change is signed with the KEK (key exchange key), whose certificate is enrolled in the firmware. Here the KEK is the UEFI KEK operation's key. KEK.pem is that operation's certificate, made the same way as signer.pem (see Before you sign), from that operation's public key.

cert-to-efi-sig-list -g <your-owner-guid> db.pem db.esl
sign-efi-sig-list -a -o -t "<YYYY-MM-DD HH:MM:SS>" db db.esl db.tbs
signing-tool -n test.ini imagesigning add DigestSigning -P <product-uuid> --operid <kek-operation-uuid> \
    -p "$(openssl dgst -sha256 -binary db.tbs | base64 -w0)" -H SHA256 \
    --format CMS --cms-preset uefi --cms-cert KEK.pem -N db -D "db update"
signing-tool -n test.ini imagesigning get -I <request-uuid> --wait -O db.p7
sign-efi-sig-list -a -i db.p7 -t "<YYYY-MM-DD HH:MM:SS>" db db.esl db.auth
  • cert-to-efi-sig-list wraps the new certificate db.pem in an EFI signature list, db.esl. The owner GUID is a fixed value you keep, which marks your entries in db.
  • sign-efi-sig-list -o writes db.tbs: the exact bytes the KEK must sign.
  • signing-tool signs them with the KEK operation, as a PKCS #7 (--cms-preset uefi).
  • sign-efi-sig-list -i puts the PKCS #7 and the list into db.auth, the file you write to the db variable, for example with efi-updatevar.

-a appends the certificate to db; without it the update replaces every entry in db, such as the Microsoft UEFI CA, which can leave machines unable to boot. It is part of the signed data, so both sign-efi-sig-list calls need it, with the same -t timestamp. -i takes the PKCS #7 as downloaded, in DER: the tool copies the file into db.auth byte for byte. Changing db on a fleet is rare and sensitive, which is why the KEK has its own operation with an approval group.

To verify:

openssl cms -verify -binary -inform DER -in db.p7 -content db.tbs \
    -certfile KEK.pem -CAfile ca-chain.pem -purpose any -out /dev/null
CMS Verification successful

The firmware itself checks db.auth against the KEK when the variable is written.

Signed tokens and configuration

Use case: a device or a cloud service should accept a file, such as a licence, a provisioning token or a configuration, only if you signed it. JSON Web Signature (JWS, RFC 7515) is the common format for that: one string holding a small header (algorithm and key name), the file's content and the signature.

signing-tool -n test.ini imagesigning envelope jws -P <product-uuid> --operid <tokens-operation-uuid> \
    --state-dir .sign -F config.json -O config.jws --alg ES256 --kid release-2026

signing-tool reads config.json, has the Tokens and configuration operation sign it, and writes config.jws: one line of three parts separated by dots, a header that names the algorithm and the key, the content of config.json, and the signature, each base64url-encoded.

The options:

  • --alg ES256: the signature algorithm, ECDSA P-256 with SHA-256, matching the operation's EC P-256 key. For an RSA key use RS256 (PKCS #1 v1.5) or PS256 (RSA-PSS).
  • --kid release-2026: a name for the key, written into the header. A verifier that trusts several keys uses it to pick the right one.
  • --omit-payload (optional): leaves the content out of the JWS, for when config.json travels separately; the verifier then supplies it.

To verify:

python3 -c 'from jwcrypto import jwk, jws; t = jws.JWS(); t.deserialize(open("config.jws").read());
t.verify(jwk.JWK.from_pem(open("op.pub", "rb").read()), alg="ES256"); print("JWS verified")'

Any JWS or JWT library does the same with op.pub. Pin the algorithm in the verifier (ES256 here), so a token cannot choose a weaker one.

Supply-chain attestations

Use case: an in-toto statement that says what was built from what, signed so that a customer or an auditor can check it came from your pipeline. DSSE is the envelope format in-toto uses for the signed statement.

signing-tool -n test.ini imagesigning envelope dsse -P <product-uuid> --operid <attestation-operation-uuid> \
    --state-dir .sign -F statement.json -O statement.dsse.json \
    --payload-type application/vnd.in-toto+json --keyid release-2026

On an RSA key, --padding PSS matches in-toto and the default PKCS #1 v1.5 matches sigstore.

To verify:

python3 -c 'import json; from cryptography.hazmat.primitives.serialization import load_pem_public_key
from securesystemslib.dsse import Envelope; from securesystemslib.signer import SSlibKey
key = SSlibKey.from_crypto(load_pem_public_key(open("op.pub", "rb").read()), keyid="release-2026")
Envelope.from_dict(json.load(open("statement.dsse.json"))).verify([key], 1); print("DSSE verified")'

Signing container images and verifying them with cosign is covered in OCI signing.

Post-quantum signatures with ML-DSA

ML-DSA is the post-quantum signature standard (NIST FIPS 204). The platform holds ML-DSA keys in all three parameter sets, so a product can start signing with them now, next to its RSA or EC keys, for verifiers that are ready.

Key type Signature Public key Strength
MLDSA44 2,420 bytes 1,312 bytes NIST category 2
MLDSA65 3,309 bytes 1,952 bytes NIST category 3
MLDSA87 4,627 bytes 2,592 bytes NIST category 5

Plan header and keyring space for them: an ML-DSA-65 signature is almost nine times an RSA-3072 one.

Two ways to sign, and what leaves the build host

Form The platform receives Use it for
Raw ML-DSA, -H NONE The message itself: ML-DSA signs the message, not a hash Short messages: a manifest, a token, a hash list your verifier reads
ML-DSA in a CMS, --format CMS -H SHA512 Only a SHA-512 digest; the key signs the CMS signed attributes Large artifacts, and kernel modules (envelope kernel-module)

The raw form sends the message itself, so it is not digest signing in the usual sense: what you sign leaves the build host. The HSM signs it in one call, and accepts at most 13,504 bytes. For anything larger, or anything that should stay on the build host, use the CMS form, or sign a small manifest that lists the image's hash.

In the first example below, the message is a small manifest.json that lists the image's SHA-512: the manifest leaves the build host, the image does not.

# raw ML-DSA over a manifest
signing-tool -n test.ini imagesigning add DigestSigning -P <product-uuid> --operid <mldsa-operation-uuid> \
    -p "$(base64 -w0 manifest.json)" -H NONE -N "manifest 1.4.0" -D "ML-DSA manifest"
signing-tool -n test.ini imagesigning get -I <request-uuid> --wait -O manifest.sig

# ML-DSA kernel module: only the module's SHA-512 is sent
signing-tool -n test.ini imagesigning envelope kernel-module -P <product-uuid> --operid <mldsa-operation-uuid> \
    --state-dir .sign -F my_module.ko -O my_module.signed.ko -N "my_module 1.4.0" -D "ML-DSA kernel module"

The signature names its key by subject key identifier, so the certificate the kernel holds for it needs subjectKeyIdentifier=hash, and --cms-cert is not needed. The kernel accepts ML-DSA module signatures from Linux 7.0, and only when built with CONFIG_PKCS7_WAIVE_AUTHATTRS_REJECTION_FOR_MLDSA=y, which is not set unless you enable it. The OpenSSL check below cannot show whether your kernel is built that way.

To verify:

# OpenSSL 3.5 or later knows ML-DSA
openssl pkeyutl -verify -pubin -inkey op.pub -rawin -in manifest.json -sigfile manifest.sig
perl scripts/extract-module-sig.pl -0 my_module.signed.ko > my_module.unsigned.ko
perl scripts/extract-module-sig.pl -s my_module.signed.ko > my_module.p7s
openssl cms -verify -binary -inform DER -in my_module.p7s -content my_module.unsigned.ko \
    -certfile signer-mldsa.pem -CAfile ca-chain.pem -purpose any -out /dev/null

signer-mldsa.pem is the certificate for the post-quantum operation's public key, issued like signer.pem (with OpenSSL, version 3.5 or later). The device-side verifier needs ML-DSA support of its own: check your bootloader, kernel or library before switching a fleet.

Where off-hardware verification stops

Use case Checked on the build host Not tested: needs the target
ESP32 RSA-3072 PSS, ECDSA P-256 and P-384 attached and verified with espsecure; a changed byte refused. A test image, not an ESP-IDF build The ROM bootloader and the bootloader, with the key digest in eFuse
Other firmware Every key type, hash and encoding with openssl pkeyutl Your bootloader reading it from the image header
Kernel modules Two modules in one request, extracted with the kernel's script, CMS verified The kernel loading the module; for ML-DSA, a Linux 7.0 kernel built with the option above
Tokens ES256, ES384, RS256, PS256 and a detached JWS with jwcrypto Nothing: the verifier is software
Attestations EC, RSA-PSS and RSA PKCS #1 v1.5 with securesystemslib and cosign Nothing: the verifier is software
IMA Two files with evmctl ima_verify setfattr --restore (needs root) and kernel appraisal
UEFI sign-efi-sig-list -a -i took the platform's DER PKCS #7 and wrote it into db.auth byte for byte; the PKCS #7 verifies over db.tbs The firmware writing the variable
Post-quantum ML-DSA-44, 65 and 87 over a message; ML-DSA CMS; an ML-DSA kernel module from envelope kernel-module, all verified with OpenSSL 3.6 A device-side ML-DSA verifier

Mapping to the CRA

Annex I requirement Covered by
Part I (2)(f): protect the integrity of programs, commands and configuration against manipulation or modification not authorised by the user Boot ROMs, bootloaders, the kernel, IMA and UEFI check every image, module, file and variable against a key you control
Supporting evidence, not an Annex I product requirement: custody of the signing keys The private keys stay in the HSM; one operation per key with its own approval rule; every request in the platform's audit trail

References