Skip to content

Add support for signing store paths using CNSA algorithms#449

Merged
edolstra merged 23 commits into
mainfrom
eelcodolstra/nix-373
May 20, 2026
Merged

Add support for signing store paths using CNSA algorithms#449
edolstra merged 23 commits into
mainfrom
eelcodolstra/nix-373

Conversation

@edolstra
Copy link
Copy Markdown
Collaborator

@edolstra edolstra commented May 6, 2026

Motivation

This adds support for some CNSA 1.0 and 2.0 algorithms: ecdsa-p384, ml-dsa-44, ml-dsa-65 and ml-dsa-87. ML-DSA is a post-quantum cryptography signature scheme.

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

It also adds commands nix keys convert-{public,secret}-to-pem which may be useful if you want to use the keys with the openssl CLI.

Context

Summary by CodeRabbit

  • New Features

    • Added ML-DSA (ml-dsa-44/65/87) and ECDSA P-384 signing support alongside Ed25519
    • nix key generate-secret gains --key-type to select algorithm
    • New CLI: nix key convert-secret-to-pem and nix key convert-public-to-pem
    • PEM conversion: Ed25519 unsupported; ML-DSA/ECDSA produce PEM output
  • Documentation

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

    • Signing tests extended to cover ed25519, ml-dsa variants, and ecdsa-p384 with PEM checks

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

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds KeyType and polymorphic SecretKey/PublicKey implementations (Ed25519 and OpenSSL ML-DSA/ECDSA), moves key ownership to unique_ptr, adds CLI key-type selection and PEM conversion commands, updates call sites to use parse/generate factories, and expands tests and docs to exercise multiple 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
Introduces KeyType, parseKeyType, getKeyTypes; Key uses const name/key and a (name,key) ctor. SecretKey/PublicKey expose parse() factories returning std::unique_ptr, add virtual dtor and virtual sign/verify/toPEM APIs, and PublicKeys now owns std::unique_ptr<PublicKey>.
OpenSSL integration and polymorphic implementations
src/libutil/signature/local-keys.cc
Adds OpenSSL includes, RAII helpers, DER/PKCS#8/SPKI parsing and KeyType mapping. Implements Ed25519 and OpenSSL-backed SecretKey/PublicKey subclasses, ML-DSA/ECDSA generation and deterministic signing parameters, and PEM serialization. SecretKey::parse/generate and PublicKey::parse dispatch to appropriate subclass.
LocalSigner ownership refactor
src/libutil/include/nix/util/signature/signer.hh, src/libutil/signature/signer.cc
LocalSigner accepts std::unique_ptr<SecretKey>&&, stores privateKey and cached publicKey as std::unique_ptr, and forwards sign/getPublic via pointer dereference.
Call-site integration and CLI PEM/key-type features
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, src/nix/nix-store/nix-store.cc
Replaces direct value construction with SecretKey::parse/PublicKey::parse at signing and store call sites; CmdKeyGenerateSecret adds --key-type and passes parsed KeyType to SecretKey::generate; adds convert-secret-to-pem and convert-public-to-pem commands that emit PEM via toPEM(); opGenerateBinaryCacheKey explicitly requests Ed25519.
Parameterized functional and unit tests
tests/functional/signing.sh, src/libutil-tests/local-keys.cc, src/libutil-tests/meson.build, src/libutil-tests/data/local-keys/*
Adds runTests(keyType) to run signing/verification flows per key type; generates secrets with nix key generate-secret, derives publics, and asserts PEM conversion behavior per type; adds gtests covering Ed25519, ML-DSA variants, and ECDSA P-384 vectors; ensures tests and fixtures are included in test build.
Documentation for key-type 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
Updates key-generate-secret docs for --key-type and multi-type keys; adds usage and OpenSSL decode examples for convert-secret-to-pem and convert-public-to-pem.
Experimental feature: CNSA
src/libutil/experimental-features.cc, src/libutil/include/nix/util/experimental-features.hh
Adds CNSA experimental enumerator and xpFeatureDetails entry describing CNSA (ECDSA P-384 and ML-DSA) support.
WASM argument passing fix
src/libexpr/primops/wasm.cc
Fix NixWasmInstance::make_app to pass arg directly to mkApp.

🎯 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 and libsodium too! 🔐✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 8.96% 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
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.
Title check ✅ Passed The title clearly describes the main change: adding support for CNSA algorithms (ML-DSA and ECDSA P-384) for signing store paths.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

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

✨ Finishing Touches
🧪 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 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

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
Comment thread src/libutil/signature/local-keys.cc Outdated
Comment thread src/libutil/experimental-features.cc Outdated
},
{
.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.

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've renamed the feature flag to cnsa.

Comment thread src/libutil/signature/local-keys.cc Outdated
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 (bare minimum, literally just the pkcs11: prefix) Nix knowledge of PKCS#11 URLs vs. Nix knowledge of PEM but I think it's a better trade because the format is less awkward for our existing private key format and makes it easier for nixPKCS.

Edit: The PEM equivalent would be something like -----BEGIN PKCS#11 PROVIDER URI-----\nMII(asn1 here...) - see this issue and these docs - the recommended path seems to be using OSSL_STORE for newer applications to avoid using the PEM hack anyway.

@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.

edolstra added 10 commits May 19, 2026 14:59
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 bf1a60c to 9eced63 Compare May 19, 2026 14:52
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/experimental-features.cc`:
- Around line 308-317: Update the .description text for the Xp::CNSA
experimental feature entry (the initializer with .tag = Xp::CNSA) so it
explicitly names the supported ML-DSA variants; replace the generic "ML-DSA"
with "ML-DSA-44, ML-DSA-65, and ML-DSA-87" (or "ML-DSA-44/65/87") alongside the
existing "ECDSA P-384" wording to make the supported algorithms explicit.

In `@tests/functional/signing.sh`:
- Around line 36-40: The current check only ensures the openssl binary exists;
change the probe so it actually verifies openssl pkey can parse the test keys
(TEST_ROOT/sk1.pem and TEST_ROOT/pk1.pem) before emitting text output.
Specifically, after detecting openssl (type -p openssl), run a non-verbose parse
check using openssl pkey against both "$TEST_ROOT"/sk1.pem and
"$TEST_ROOT"/pk1.pem and only call openssl pkey -text -noout for the keys if
those parse checks succeed (i.e., gate the ml-dsa-* and ecdsa-p384 text output
on successful parse of both keys). Ensure error branches skip the text output
when parsing fails so builders without PKCS#8/ML-DSA support do not run the
failing flows.
🪄 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: c69dcb42-69e3-424a-ac8d-599799f91f36

📥 Commits

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

📒 Files selected for processing (17)
  • src/libexpr/primops/wasm.cc
  • src/libstore/binary-cache-store.cc
  • src/libstore/keys.cc
  • src/libstore/store-api.cc
  • src/libutil/experimental-features.cc
  • src/libutil/include/nix/util/experimental-features.hh
  • 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
✅ Files skipped from review due to trivial changes (3)
  • src/nix/key-convert-secret-to-pem.md
  • src/libexpr/primops/wasm.cc
  • src/nix/key-convert-public-to-pem.md
🚧 Files skipped from review as they are similar to previous changes (9)
  • src/libstore/binary-cache-store.cc
  • src/perl/lib/Nix/Store.xs
  • src/nix/key-generate-secret.md
  • src/libutil/include/nix/util/signature/signer.hh
  • src/libutil/include/nix/util/signature/local-keys.hh
  • src/nix/sigs.cc
  • src/libstore/keys.cc
  • src/libutil/signature/signer.cc
  • src/libutil/signature/local-keys.cc

Comment thread src/libutil/experimental-features.cc
Comment thread tests/functional/signing.sh
@github-actions github-actions Bot temporarily deployed to pull request May 19, 2026 15:10 Inactive
@github-actions github-actions Bot temporarily deployed to pull request May 20, 2026 10:29 Inactive
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.

🧹 Nitpick comments (1)
src/libutil-tests/local-keys.cc (1)

11-11: ⚡ Quick win

Avoid leaking experimental feature state between tests.

Line 11, Line 71, and Line 115 mutate a global feature set but never restore it. That can make test behavior order-dependent. Please snapshot/restore in each test (or use a fixture with setup/teardown).

Also applies to: 71-71, 115-115

🤖 Prompt for 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.

In `@src/libutil-tests/local-keys.cc` at line 11, The test mutates the global
experimental features via
experimentalFeatureSettings.experimentalFeatures.get().insert(Xp::CNSA) (also at
the other two sites) and never restores state; change each test to snapshot the
current feature set before mutating and restore it after (or wrap the test in a
fixture with setup/teardown) so the global
experimentalFeatureSettings.experimentalFeatures is returned to its prior value
when the test finishes. Use the existing getter
experimentalFeatureSettings.experimentalFeatures.get() to capture the current
set, perform the insert for the test, and restore the captured set in the test
teardown (or fixture destructor) to avoid order-dependent failures.
🤖 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.

Nitpick comments:
In `@src/libutil-tests/local-keys.cc`:
- Line 11: The test mutates the global experimental features via
experimentalFeatureSettings.experimentalFeatures.get().insert(Xp::CNSA) (also at
the other two sites) and never restores state; change each test to snapshot the
current feature set before mutating and restore it after (or wrap the test in a
fixture with setup/teardown) so the global
experimentalFeatureSettings.experimentalFeatures is returned to its prior value
when the test finishes. Use the existing getter
experimentalFeatureSettings.experimentalFeatures.get() to capture the current
set, perform the insert for the test, and restore the captured set in the test
teardown (or fixture destructor) to avoid order-dependent failures.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 660b097f-53cc-445d-b54f-4b6d1df0e952

📥 Commits

Reviewing files that changed from the base of the PR and between 9eced63 and 5e70ead.

📒 Files selected for processing (2)
  • src/libutil-tests/local-keys.cc
  • src/libutil-tests/meson.build

edolstra and others added 3 commits May 20, 2026 20:47
This uses test vectors from the NIST ACVP ML-DSA-sigGen test set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@edolstra edolstra force-pushed the eelcodolstra/nix-373 branch from a728235 to c8a2a35 Compare May 20, 2026 18:48
@edolstra edolstra changed the title Add support for signing store paths using ML-DSA Add support for signing store paths using CNSA algorithms May 20, 2026
@github-actions github-actions Bot temporarily deployed to pull request May 20, 2026 18:56 Inactive
Comment thread src/libutil-tests/local-keys.cc Outdated
cole-h
cole-h previously approved these changes May 20, 2026
@edolstra edolstra enabled auto-merge May 20, 2026 19:38
@github-actions github-actions Bot temporarily deployed to pull request May 20, 2026 19:40 Inactive
@github-actions github-actions Bot temporarily deployed to pull request May 20, 2026 19:51 Inactive
@edolstra edolstra added this pull request to the merge queue May 20, 2026
@numinit
Copy link
Copy Markdown

numinit commented May 20, 2026

Thanks for this. I feel like CNSA is the correct framing and P-384 is an appropriate option that doesn't rule out compatibility with (e.g.) TPMs.

Merged via the queue into main with commit 6b78b5d May 20, 2026
29 checks passed
@edolstra edolstra deleted the eelcodolstra/nix-373 branch May 20, 2026 20:19
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