Skip to content

Add support for signing store paths using ML-DSA#449

Open
edolstra wants to merge 14 commits into
mainfrom
eelcodolstra/nix-373
Open

Add support for signing store paths using ML-DSA#449
edolstra wants to merge 14 commits into
mainfrom
eelcodolstra/nix-373

Conversation

@edolstra
Copy link
Copy Markdown
Collaborator

@edolstra edolstra commented May 6, 2026

Motivation

ML-DSA-* is a post-quantum cryptography signature scheme.

To use, just call nix key generate-secret with --key-type ml-dsa-{44,65,87}, otherwise it works the same as ed25519 (libsodium) signatures except that it produces much bigger keys/signatures.

Context

Summary by CodeRabbit

  • New Features

    • Added ML-DSA key support (ml-dsa-44/65/87) alongside Ed25519 for signing
    • nix key generate-secret gains --key-type to select key format
    • New commands: nix key convert-secret-to-pem and nix key convert-public-to-pem
    • Experimental feature flag for ML-DSA added
  • Documentation

    • Updated nix key generate-secret docs; added docs for PEM conversion commands
  • Tests

    • Signing tests extended to exercise ed25519 and ml-dsa variants, including PEM expectations

Review Change Stack

@edolstra edolstra marked this pull request as draft May 6, 2026 14:42
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 6, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8aef909c-82b7-4b23-a0f3-2735b4196d2c

📥 Commits

Reviewing files that changed from the base of the PR and between d4186f4 and bf1a60c.

📒 Files selected for processing (6)
  • src/libutil/experimental-features.cc
  • src/libutil/include/nix/util/experimental-features.hh
  • src/libutil/include/nix/util/signature/local-keys.hh
  • src/libutil/signature/local-keys.cc
  • src/nix/sigs.cc
  • tests/functional/signing.sh
✅ Files skipped from review due to trivial changes (1)
  • src/libutil/include/nix/util/experimental-features.hh
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/libutil/include/nix/util/signature/local-keys.hh
  • src/nix/sigs.cc
  • src/libutil/signature/local-keys.cc

📝 Walkthrough

Walkthrough

Adds ML-DSA-44/65/87 support alongside Ed25519 via polymorphic SecretKey/PublicKey types, OpenSSL-backed ML-DSA implementations, unique_ptr ownership for keys, CLI key-type selection and PEM conversion, and tests exercising all key types.

Changes

ML-DSA and polymorphic key system

Layer / File(s) Summary
Key type contract and virtual architecture
src/libutil/include/nix/util/signature/local-keys.hh
KeyType enum now includes MLDSA44, MLDSA65, MLDSA87. Key stores name and key as const members with a protected (name, key) constructor. SecretKey and PublicKey inherit base constructors directly, expose parse() static entrypoints, and mark signing/verification/export methods as virtual. PublicKeys changes from std::map<std::string, PublicKey> to std::map<std::string, std::unique_ptr<PublicKey>>.
OpenSSL integration and polymorphic implementations
src/libutil/signature/local-keys.cc
Adds OpenSSL headers and RAII type aliases. Implements parseKeyType() mapping string names to KeyType. Creates polymorphic Ed25519SecretKey/Ed25519PublicKey using libsodium for signing/verification, and OpenSSLSecretKey/OpenSSLPublicKey using OpenSSL EVP_* APIs for ML-DSA. SecretKey::parse detects key type by size or DER structure. SecretKey::generate creates appropriate subclass instances with DER encoding for ML-DSA. ML-DSA signing uses EVP_DigestSign with OSSL_SIGNATURE_PARAM_DETERMINISTIC. verifyDetached calls through the stored key pointer.
LocalSigner ownership refactoring via unique_ptr
src/libutil/include/nix/util/signature/signer.hh, src/libutil/signature/signer.cc
LocalSigner now owns SecretKey and PublicKey via std::unique_ptr instead of value members. Constructor accepts rvalue std::unique_ptr<SecretKey>&&, derives public key from the pointed secret key, and stores moved ownership. signDetached and getPublicKey access through pointer dereference.
Integration: SecretKey::parse() at call sites
src/libstore/binary-cache-store.cc, src/libstore/keys.cc, src/libstore/store-api.cc, src/nix/sigs.cc, src/perl/lib/Nix/Store.xs
Updated all key instantiation paths to use SecretKey::parse(readFile(...)) and PublicKey::parse(...) instead of direct construction, enabling polymorphic type detection and key-type-specific behavior across signing, caching, and API layers.
CLI key type selection and PEM conversion subcommands
src/nix/sigs.cc
CmdKeyGenerateSecret adds keyType member (default ed25519) with --key-type flag supporting ed25519, ml-dsa-44, ml-dsa-65, ml-dsa-87. Generation calls SecretKey::generate(name, parseKeyType(keyType)). New subcommands convert-secret-to-pem and convert-public-to-pem output PEM-formatted PKCS#8/SubjectPublicKeyInfo via toPEM().
Parameterized test coverage for all key types
tests/functional/signing.sh
Refactored signing test setup into runTests(keyType) function. Replaced nix-store --generate-binary-cache-key with nix key generate-secret + nix key convert-secret-to-public. PEM conversion expectations vary by type: ed25519 fails, ml-dsa-\* succeeds. Test suite runs for ed25519, ml-dsa-44, ml-dsa-65, and ml-dsa-87.
Documentation for key type support and PEM commands
src/nix/key-generate-secret.md, src/nix/key-convert-secret-to-pem.md, src/nix/key-convert-public-to-pem.md
Updated key-generate-secret documentation to describe multi-type key generation and --key-type flag. Added new documentation for convert-secret-to-pem and convert-public-to-pem commands with OpenSSL decoding examples.
Explicit key type in binary cache generation
src/nix/nix-store/nix-store.cc
opGenerateBinaryCacheKey now explicitly passes KeyType::Ed25519 to SecretKey::generate().
Experimental feature flag
src/libutil/experimental-features.cc, src/libutil/include/nix/util/experimental-features.hh
Adds Xp::MLDSA experimental feature enumerator and xpFeatureDetails entry for "ml-dsa".
WASM function argument passing fix
src/libexpr/primops/wasm.cc
NixWasmInstance::make_app passes arg directly instead of wrapped in braced initializer.

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested reviewers:

  • cole-h

🐰 A rabbit's ode to cryptographic choice:
Ed25519 had its day of fame,
Now ML-DSA joins the game—
Polymorphic keys, both strong and true,
With OpenSSL's strength shining through! 🔐✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 6.56% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: adding ML-DSA support for signing store paths, which aligns with the comprehensive refactoring of the key system to support multiple key types.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch eelcodolstra/nix-373

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 6, 2026

@github-actions github-actions Bot temporarily deployed to pull request May 6, 2026 14:49 Inactive
edolstra added 4 commits May 13, 2026 10:46
ML-DSA-65 is a post-quantum cryptography signaturew scheme/

To use, just call `nix key generate-secret` with `--key-type
ml-dsa-65`, otherwise it works the same as ed25519 (libsodium)
signatures except that it produces much bigger keys/signatures
@edolstra edolstra force-pushed the eelcodolstra/nix-373 branch from ecebf02 to a6c429b Compare May 13, 2026 10:19
@github-actions github-actions Bot temporarily deployed to pull request May 13, 2026 12:44 Inactive

using AutoEVP_PKEY = std::unique_ptr<EVP_PKEY, Deleter<EVP_PKEY_free>>;
using AutoEVP_PKEY_CTX = std::unique_ptr<EVP_PKEY_CTX, Deleter<EVP_PKEY_CTX_free>>;
using AutoEVP_MD_CTX = std::unique_ptr<EVP_MD_CTX, Deleter<EVP_MD_CTX_free>>;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like these would be clearer to work with if there were a class with proper constructor and destructor

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's basically what std::unique_ptr with Deleter is. It ensures the C pointer is always destroyed with the right function when it goes out of scope.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean putting the initialization logic into the class constructor too. As is you initialize an AutoEVP_PKEY with any arbitrary pointer to an EVP_PKEY.

Comment thread src/libutil/include/nix/util/signature/local-keys.hh Outdated
Comment thread src/libutil/signature/local-keys.cc Outdated
ctx.get(),
(const unsigned char *) sig.sig.data(),
sig.sig.size(),
(const unsigned char *) data.data(),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we stream this in? Or do we just sign the narinfo?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it's just the narinfo in memory.


if (EVP_DigestSignInit(ctx.get(), nullptr, nullptr, nullptr, pkey.get()) <= 0)
throw Error("EVP_DigestSignInit failed");
/* Generate a deterministic signature (i.e. only depending on the key and the data) since Ed25519 is also
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not following why this implies we should be deterministic here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't have to be, but it does provide the nice property that repeated signing with the same key is idempotent (and currently the tests assume that's the case, though we could obviously change that).

@edolstra edolstra changed the title Add support for signing store paths using ML-DSA-65 Add support for signing store paths using ML-DSA May 13, 2026
@edolstra edolstra marked this pull request as ready for review May 13, 2026 20:44
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/libutil/signature/signer.cc`:
- Around line 8-11: The LocalSigner constructor currently dereferences
privateKey in the initializer list when calling privateKey->toPublicKey(), which
will crash if the passed std::unique_ptr<SecretKey> is null; modify the
constructor to validate the incoming _privateKey before calling toPublicKey
(e.g., check _privateKey != nullptr and throw std::invalid_argument or handle
the null case), assign privateKey = std::move(_privateKey) and then initialize
publicKey by calling privateKey->toPublicKey() inside the constructor body after
the null check; refer to the LocalSigner constructor, the privateKey member,
publicKey member and SecretKey::toPublicKey() when making the change.

In `@src/nix/sigs.cc`:
- Around line 164-166: Update the CLI help for the "key-type" option so it lists
all supported ML-DSA variants instead of only `ml-dsa-65`; modify the
.description associated with .longName = "key-type" in src/nix/sigs.cc to
mention `ed25519` and all three ML-DSA variants (`ml-dsa-44`, `ml-dsa-65`,
`ml-dsa-87`) and ensure the wording matches other flag descriptions (keep
`.labels = {"type"}` unchanged).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 36af1028-43af-4706-b61c-1527b39ea5d7

📥 Commits

Reviewing files that changed from the base of the PR and between e4fb0ab and d4186f4.

📒 Files selected for processing (15)
  • src/libexpr/primops/wasm.cc
  • src/libstore/binary-cache-store.cc
  • src/libstore/keys.cc
  • src/libstore/store-api.cc
  • src/libutil/include/nix/util/signature/local-keys.hh
  • src/libutil/include/nix/util/signature/signer.hh
  • src/libutil/signature/local-keys.cc
  • src/libutil/signature/signer.cc
  • src/nix/key-convert-public-to-pem.md
  • src/nix/key-convert-secret-to-pem.md
  • src/nix/key-generate-secret.md
  • src/nix/nix-store/nix-store.cc
  • src/nix/sigs.cc
  • src/perl/lib/Nix/Store.xs
  • tests/functional/signing.sh

Comment thread src/libutil/signature/signer.cc
Comment thread src/nix/sigs.cc
@github-actions github-actions Bot temporarily deployed to pull request May 13, 2026 20:52 Inactive
@github-actions github-actions Bot temporarily deployed to pull request May 14, 2026 14:29 Inactive

} catch (Error & e) {
// Don't show the entire key for security.
e.addTrace({}, "while decoding key '%s…'", s.substr(0, 32));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably ought not to show any of it.

},
{
.tag = Xp::MLDSA,
.name = "ml-dsa",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we were to add RSA and ECDSA support too, would those be separate features? I may implement that next and am wondering, since some people may simply want the new quantum resistant algorithms without P-{256,384,521} or RSA, so should we have a prefix indicating that we have a family of options for signature algorithms here? Maybe sig-ml-dsa so we can also have a sig-ecdsa and sig-rsa?

FWIW I'd also like to add an OpenSSL implementation of Ed25519 for related reasons, there are cases where OpenSSL may be more suitable than libsodium but where the keys and signatures would be compatible.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it can use the same experimental feature flag.

I wouldn't mind switching to OpenSSL for Ed25519 if it generates compatible signatures, since it would allow us to drop the libsodium dependency.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe a more generic feature flag for enabled OpenSSL algorithms, then?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI, any algorithm OpenSSL supports can also be pkcs11-ified using pkcs11-provider if you have a URL to the key. The wisdom I'd share there is that you just can't assume you can export the key as a pkcs8 if it's backed by a hardware keystore, but it's mostly the same underlying OpenSSL objects as of OpenSSL 3.

} else
throw Error("public key is not valid");
} catch (Error & e) {
// Don't show the entire key for security.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably does not apply to the following line

cole-h
cole-h previously approved these changes May 14, 2026
@cole-h cole-h dismissed their stale review May 14, 2026 22:04

didn't see numinit's comments; makes sense to look at them before this merges

@numinit
Copy link
Copy Markdown

numinit commented May 14, 2026

no worries. The next step I'm going to take after this merges is doing a PR to add a couple other algorithms and PKCS#11 support with https://github.com/numinit/nixpkcs, because I would very much like to use Nix with the hardware keystores I have access to.

cc @mschwaig

@numinit
Copy link
Copy Markdown

numinit commented May 14, 2026

A refactor like this PR was blocking me and will be a massive help, so thank you a ton @edolstra

@edolstra
Copy link
Copy Markdown
Collaborator Author

@numinit Yeah, getting HSM support would be great.

@numinit
Copy link
Copy Markdown

numinit commented May 14, 2026

@edolstra FYI I was simply going to add keyname:pkcs11:url-hereto the private key format. You are doing PKCS#8 with non-PEM which matches the previous format but rules out using OpenSSL pkcs11-provider's special PEM headers to indicate that a private key is actually PKCS#11.

The trade is going to be Nix knowledge of PKCS#11 vs. Nix knowledge of PEM but I think it's a better trade and makes it easier for nixPKCS.

@numinit
Copy link
Copy Markdown

numinit commented May 14, 2026

Overall, this is mostly how I would have done it from a format standpoint (but was mostly unsure about how to implement because of unfamiliarity with how to change the classes around) so looks good generally for me. SPKI is certainly the correct format for public keys with special cases for sodium keys like you have.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants