Combining GnuPG Kyber and RFC 9980 ML-KEM in one compatible OpenPGP certificate
Posted: 2026-09-05 · Last updated: 2026-09-05 · Permalink
I wanted to retain an existing version 4 Ed25519 OpenPGP identity while offering encryption subkeys understood by both modern GnuPG and Sequoia. This works. The resulting certificate has this shape:
v4 Ed25519 primary
|
+-- v4 Cv25519
+-- v5 GnuPG Kyber-1024 + X448 ← LibrePGP algorithm 8
+-- v4 ML-KEM-768 + X25519 ← RFC 9980 algorithm 35 The result after following the instructions below may be found here. Feel free to try encrypting many a plaintext to these variegated subkeys!
The two post-quantum subkeys are deliberately different encodings. GnuPG's ky1024_cv448 is its LibrePGP-style algorithm 8, not RFC 9980 algorithm 36, despite both combining ML-KEM-1024 with X448. Sequoia treats the GnuPG packet as opaque and does not select it for encryption; GnuPG retains the RFC 9980 packet but does not select it. Each implementation can therefore use its own post-quantum subkey without losing the other one.
I tested this with GnuPG 2.5.21 and Sequoia OpenPGP 2.4.1. The commands below assume that recent gpg and sq executables are in PATH. Check first that gpg --version, sq version, gpg --with-colons --list-config curve, and sq key generate --help show the required algorithms. Sequoia must be built with a cryptographic backend that supports ML-KEM. The default Nettle backend does not: sq key generate --encryption-algorithm=mlkem768-x25519 will list the algorithm in --help but fail at runtime with "Unsupported public key algorithm". Build sq with the crypto-rust backend instead:
cargo build --release -p sequoia-sq \
--no-default-features \
--features crypto-rust,sequoia-openpgp/allow-experimental-crypto,sequoia-openpgp/allow-variable-time-crypto Verify the backend with sq version, which should report with cryptographic backend RustCrypto.
Note: Beyond Sequoia, you will almost certainly need to compile GnuPG yourself. It is quite scandalous, in my opinion, that major Linux distributions are currently boycotting new versions of GnuPG.
Why the following uses algorithm 35, not algorithm 36
RFC 9980 makes ML-KEM-768+X25519 (algorithm 35) mandatory to implement and ML-KEM-1024+X448 (algorithm 36) recommended. Its key-version rule, however, grants only algorithm 35 an exception for version 4 encryption subkeys; algorithm 36 requires version 6. Therefore, to continue using my existing version 4 OpenPGP key I had to use algorithm 35. The guide below already takes that into account. Moreover, software support for algorithm 35 seems better (example).
The history points to a migration-policy decision. Draft 05 mandated version 6 for the ML-KEM composites. On 15 April 2025, draft 08 added the algorithm-35 exception. When an Area Director later asked for its justification, the recorded answer was that it provides a “pq-upgrade path without rotating the primary key”. I found no published cryptographic reason for withholding the same path from algorithm 36.
There is no relevant size barrier in the version 4 packet layout: it consists of a version, creation time, algorithm identifier, and algorithm-specific key material. The LibrePGP draft calls version 4 its preferred key version, defines a 1,568-byte ML-KEM-1024 public key, and lists X448+ML-KEM-1024 as a recommended combination. GnuPG's working hybrid is practical evidence that this component size and pairing can coexist with a version 4 primary identity.
Note: GnuPG emits this particular hybrid as a LibrePGP version 5, algorithm 8 subkey. It is not an RFC 9980 version 4, algorithm 36 subkey, and the mixed certificate therefore straddles the two formats. It demonstrates that the obstacle is a standards rule, not that GnuPG has already implemented the hypothetical RFC 9980 encoding.
Although I like and support GnuPG, version 6 certificates seem somewhat better from a technical point of view. A version 4 fingerprint is a 160-bit SHA-1 digest, whereas a version 6 fingerprint is a 256-bit SHA-256 digest; RFC 9580 documents both and retains SHA-1 because version 4 fingerprints require it. That explains a broad preference for version 6. It does not explain the 768/1024 split: once a version 4 migration exception exists, the larger parameter set does not place additional stress on SHA-1. Indeed, FIPS 203 assigns ML-KEM-768 to security category 3 and ML-KEM-1024 to category 5, while RFC 7748 places X25519 and X448 around the 128-bit and 224-bit classical security levels respectively.
The designers were aware of certificates carrying several generations of encryption keys: an OpenPGP mailing-list discussion explicitly used a six-key example ending in both ML-KEM-768+X25519 and ML-KEM-1024+X448. The narrow exception therefore looks like a compromise: preserve old version 4 identities for the mandatory baseline, while continuing to push the optional high-security variant toward version 6. A future revision could allow algorithm 36 in version 4 encryption subkeys just as it allows algorithm 35.
Generate the GnuPG key, if necessary
If the Ed25519 primary and both GnuPG encryption subkeys already exist, skip this section.
Here are highly abbreviated steps on how to generate a new GnuPG key:
gpg --quick-generate-key \
'Alice Example <alice@example.org>' ed25519 cert,sign never
gpg --fingerprint 'Alice Example <alice@example.org>'
FPR=PUT_THE_FULL_PRIMARY_FINGERPRINT_HERE
gpg --quick-add-key "$FPR" cv25519 encr never
gpg --quick-add-key "$FPR" kyber1024_cv448 encr never
gpg --with-colons --with-subkey-fingerprint --list-secret-keys "$FPR" These are the documented --quick-generate-key and --quick-add-key interfaces. GnuPG accepts kyber1024_cv448 when creating the subkey and displays it as ky1024_cv448. Replace never with the expiry policy you actually want.
Add the Sequoia ML-KEM subkey
At present, sq key generate accepts --encryption-algorithm=mlkem768-x25519, but sq key subkey add does not expose that algorithm. This is tracked as Sequoia issue 666. Until that is fixed, generate a disposable donor certificate and use sq key subkey bind to bind its ML-KEM subkey to the real certificate.
Work in a private temporary directory so that intermediate secret files never reach persistent storage. Two exports are needed: the full public certificate (all subkeys, no secrets) and the Ed25519 primary secret key alone. The '!' suffix after the fingerprint tells GnuPG to select exactly that key and nothing else, so the Kyber subkey secret — which Sequoia cannot parse — is excluded from the secret export:
cd "$(mktemp -d)"
umask 077
FPR=PUT_THE_FULL_PRIMARY_FINGERPRINT_HERE
gpg --export "$FPR" > original-public.pgp
gpg --export-secret-keys "$FPR"'!' > primary-secret.pgp
sq key import primary-secret.pgp (You can find out the primary fingerprint set in the FPR= line by running gpg -K and inspecting the line after sec.)
Now generate a temporary version 4 donor containing one RFC 9980 ML-KEM-768+X25519 encryption subkey. Import the donor's secret key into Sequoia's key store so that Sequoia can later decrypt messages encrypted to its ML-KEM subkey:
sq key generate \
--own-key \
--without-password \
--profile=rfc4880 \
--no-userids \
--cannot-sign \
--cannot-authenticate \
--can-encrypt=universal \
--signing-algorithm=ed25519 \
--encryption-algorithm=mlkem768-x25519 \
--expiration=never \
--output=donor-secret.pgp \
--rev-cert=donor.rev
sq key import donor-secret.pgp
sq inspect donor-secret.pgp Copy the donor's ML-KEM-768+X25519 subkey fingerprint from the last command, not the donor primary fingerprint. Bind that subkey to the original certificate:
MLKEM_FPR=PUT_THE_DONOR_MLKEM_SUBKEY_FINGERPRINT_HERE
sq --keyring=donor-secret.pgp key subkey bind \
--cert-file=original-public.pgp \
--key="$MLKEM_FPR" \
--can-encrypt=universal \
--expiration=never \
--output=mixed-sequoia-secret.pgp
sq cert import mixed-sequoia-secret.pgp sq uses the imported Ed25519 primary secret to make a new subkey-binding signature over the ML-KEM subkey. The output retains the existing public Cv25519 and GnuPG Kyber packets and contains the new ML-KEM secret. Use sq cert import here, not sq key import: the mixed output contains only the ML-KEM subkey secret, not the primary secret, so sq key import will reject it with "does not contain any secret key material". The certificate update (the new subkey and its binding signature) is what matters at this step. Convert it to a public certificate and import that public update into GnuPG:
sq keyring filter --experimental --to-cert \
mixed-sequoia-secret.pgp --output=mixed-public.pgp
sq inspect mixed-public.pgp
gpg --import mixed-public.pgp The key manipulation is now complete. Password-protect the secret material in Sequoia's key store. The primary secret had to remain unencrypted for sq key subkey bind to sign the binding, but nothing else needs it unlocked. This single command encrypts both the Ed25519 primary secret and the ML-KEM subkey secret:
sq key password --cert="$FPR" mixed-public.pgp into GnuPG, not mixed-sequoia-secret.pgp. In my test, importing the mixed secret form made GnuPG report a bad signature and discard the ML-KEM update. The public import is the interoperable path. The files primary-secret.pgp, donor-secret.pgp, and mixed-sequoia-secret.pgp contain secret material. Working in /tmp (or a tmpfs mount) avoids writing them to persistent storage. When finished, shred -u them rather than plain rm: shred -u primary-secret.pgp donor-secret.pgp mixed-sequoia-secret.pgp donor.rev Note that sq key import and sq cert import also write into Sequoia's persistent stores under $HOME/.local/share/sequoia/ (or the path set by --home): the certificate store (pgp.cert.d/) and the key store (keystore/softkeys/). Sequoia's softkey store has no agent daemon guarding access; without a password the secret material sits as plaintext files readable by anything running as your user. The ML-KEM subkey secret lives in the donor's softkey entry; do not delete the donor key from the key store until Sequoia provides a way to migrate a subkey secret between certificates.
Verify the result
sq inspect mixed-public.pgp
sq packet dump mixed-public.pgp
gpg --check-signatures "$FPR"
gpg --with-colons --with-subkey-fingerprint --list-keys "$FPR" The GnuPG listing should still show its usable ky1024_cv448 subkey. Its entry for the Sequoia key will resemble sub:i:0:35:: algorithm 35 is retained, but GnuPG does not regard it as a usable encryption key. Sequoia should list ML-KEM-768+X25519 as usable and the GnuPG version 5 Kyber packet as unknown or opaque.
Here, “ignore” means “exclude from usable encryption keys”, not “delete”. I re-exported the combined public certificate from each implementation. GnuPG's re-export preserved the ML-KEM subkey it does not use. Sequoia's behavior is split: sq keyring filter (which operates on a file without touching the certificate store) preserved the Kyber version 5 subkey, but Sequoia's certificate store dropped it. A certificate that passes through sq cert import and sq cert export loses the Kyber subkey and its binding signature. For distribution, re-export from GnuPG or use sq keyring filter on the file directly.
| Subkey | Sequoia | GnuPG |
|---|---|---|
| v4 Cv25519 | usable | usable |
| v5 LibrePGP algorithm 8, Kyber-1024+X448 | opaque, not selected; dropped by cert store, preserved by keyring filter | usable |
| v4 RFC 9980 algorithm 35, ML-KEM-768+X25519 | usable | preserved, not selected |
Encrypt and decrypt
Sequoia encrypts to both the Cv25519 and ML-KEM subkeys. GnuPG defaults to the Cv25519 subkey unless --require-pqc-encryption is passed, in which case it selects ky1024_cv448:
echo 'Hello, post-quantum world!' > plaintext.txt
sq encrypt --for="$FPR" --without-signature \
--output=plaintext-sq.pgp plaintext.txt
sq packet dump plaintext-sq.pgp | head -12
gpg --require-pqc-encryption -e -r "$FPR" plaintext.txt
gpg --list-packets plaintext.txt.gpg | head -6 Sequoia can decrypt its own message via the ML-KEM subkey, and GnuPG can decrypt it via the Cv25519 PKESK that Sequoia includes for backward compatibility:
sq decrypt --output=- plaintext-sq.pgp
gpg -d plaintext-sq.pgp GnuPG can decrypt its own Kyber-encrypted message. Sequoia cannot decrypt GnuPG's output because GnuPG 2.5 uses AEAD Encrypted Data Packet version 0 (the LibrePGP form), which Sequoia's policy rejects as insecure. This is a broader format disagreement between the two implementations, not specific to the post-quantum subkey.
Is it properly self-signed?
Yes. In the test certificate, GnuPG made the User ID's positive certification self-signature (type 0x13) and a subkey-binding self-signature (type 0x18) for each of its encryption subkeys. sq key subkey bind made another type-0x18 signature with the same Ed25519 primary key for ML-KEM. GnuPG's --check-signatures verified all four signatures.
This is the structure OpenPGP requires: the primary key's 0x18 signature states that it owns the subkey. An embedded 0x19 “back signature” is required for a signing subkey; these are encryption-only subkeys, so its absence is correct.