From 4d6393f4bfd1fee766697feebe8902472563b57f Mon Sep 17 00:00:00 2001 From: Bernardo Meurer Costa Date: Mon, 20 Oct 2025 20:58:28 +0000 Subject: [PATCH 1/4] feat(libstore): add RequiredSignatures field to binary cache protocol Binary caches can now advertise signature requirements via a new RequiredSignatures field in nix-cache-info. This field contains a whitespace-separated list of public keys. When uploading paths to such caches, Nix validates that each path has at least one valid signature from the required keys. This prevents accidental uploads of unsigned or incorrectly-signed paths due to configuration errors (e.g., typos like "secret-key-file" vs "secret-key"). Content-addressed paths are exempt from this check as they are self-validating. --- src/libstore/binary-cache-store.cc | 55 +++++++++++++++++++ .../include/nix/store/binary-cache-store.hh | 7 +++ 2 files changed, 62 insertions(+) diff --git a/src/libstore/binary-cache-store.cc b/src/libstore/binary-cache-store.cc index 3705f3d4ddd..790b727e65b 100644 --- a/src/libstore/binary-cache-store.cc +++ b/src/libstore/binary-cache-store.cc @@ -16,6 +16,7 @@ #include #include +#include #include #include #include @@ -66,6 +67,22 @@ void BinaryCacheStore::init() config.wantMassQuery.setDefault(value == "1"); } else if (name == "Priority") { config.priority.setDefault(std::stoi(value)); + } else if (name == "RequiredSignatures") { + // Parse whitespace-separated list of public keys + for (const auto & keyStr : tokenizeString(value, " \t\n\r")) { + if (!keyStr.empty()) { + try { + PublicKey key(keyStr); + requiredSignatures.emplace(key.name, key); + } catch (Error & e) { + e.addTrace( + {}, + "while parsing RequiredSignatures field in binary cache '%s'", + config.getHumanReadableURI()); + throw; + } + } + } } } } @@ -284,6 +301,44 @@ ref BinaryCacheStore::addToStoreCommon( narInfo->sign(*this, signers); + /* Check if this binary cache requires signatures. Content-addressed paths don't need signatures. */ + if (!requiredSignatures.empty() && !info.ca) { + // Check if path has at least one valid signature from required keys + auto validSigs = info.checkSignatures(*this, requiredSignatures); + if (validSigs == 0) { + // Build list of required key names for error message + auto keys = std::views::keys(requiredSignatures); + std::vector keyNames(keys.begin(), keys.end()); + std::string requiredKeyNames = concatStringsSep(", ", keyNames); + + if (info.sigs.empty()) { + // No signatures at all + throw Error( + "refusing to upload unsigned path '%s' to binary cache '%s'\n\n" + "The cache requires paths to be signed by one of these keys:\n %s\n\n" + "You have not configured any signing keys. To fix this:\n" + " - Use: %s?secret-key=/path/to/key (not 'secret-key-file'!)\n" + " - Or set: secret-key-files = /path/to/key in nix.conf\n", + printStorePath(info.path), + config.getHumanReadableURI(), + requiredKeyNames, + config.getHumanReadableURI()); + } else { + // Has signatures, but none from required keys + throw Error( + "refusing to upload path '%s' to binary cache '%s'\n\n" + "The cache requires paths to be signed by one of these keys:\n %s\n\n" + "Current signatures on path:\n %s\n\n" + "The path is signed, but not by any of the keys this cache requires.\n" + "Make sure you're using the correct signing key for this cache.", + printStorePath(info.path), + config.getHumanReadableURI(), + requiredKeyNames, + concatStringsSep("\n ", info.sigs)); + } + } + } + /* Atomically write the NAR info file.*/ writeNarInfo(narInfo); diff --git a/src/libstore/include/nix/store/binary-cache-store.hh b/src/libstore/include/nix/store/binary-cache-store.hh index 3f4de2bd46c..76c2df4a08e 100644 --- a/src/libstore/include/nix/store/binary-cache-store.hh +++ b/src/libstore/include/nix/store/binary-cache-store.hh @@ -136,6 +136,13 @@ private: std::string narMagic; + /** + * Public keys that are required to have signed any path uploaded + * to this cache. Parsed from the RequiredSignatures field in + * nix-cache-info. Empty if no signature requirements. + */ + PublicKeys requiredSignatures; + std::string narInfoFileFor(const StorePath & storePath); void writeNarInfo(ref narInfo); From 626e7c0da2f0ab0f56a6e4e4fec904b1c7135614 Mon Sep 17 00:00:00 2001 From: Bernardo Meurer Costa Date: Mon, 20 Oct 2025 22:10:54 +0000 Subject: [PATCH 2/4] feat(libstore): add RequireAllSignatures field for multi-party signing Adds a RequireAllSignatures field to nix-cache-info that, when set to 1, requires paths to be signed by ALL keys listed in RequiredSignatures rather than just one. This enables multi-party approval workflows where paths must be signed by multiple independent parties before being uploaded to a cache (e.g., dev team, QA team, and release team). Example nix-cache-info: RequiredSignatures: dev:key1... qa:key2... release:key3... RequireAllSignatures: 1 Error messages now indicate whether "at least ONE" or "ALL" keys are required and show the signature count (e.g., "1 out of 3 required"). --- src/libstore/binary-cache-store.cc | 28 ++++++++++++------- .../include/nix/store/binary-cache-store.hh | 7 +++++ 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/libstore/binary-cache-store.cc b/src/libstore/binary-cache-store.cc index 790b727e65b..9c12740e0e4 100644 --- a/src/libstore/binary-cache-store.cc +++ b/src/libstore/binary-cache-store.cc @@ -83,6 +83,8 @@ void BinaryCacheStore::init() } } } + } else if (name == "RequireAllSignatures") { + requireAllSignatures = (value == "1"); } } } @@ -303,38 +305,44 @@ ref BinaryCacheStore::addToStoreCommon( /* Check if this binary cache requires signatures. Content-addressed paths don't need signatures. */ if (!requiredSignatures.empty() && !info.ca) { - // Check if path has at least one valid signature from required keys auto validSigs = info.checkSignatures(*this, requiredSignatures); - if (validSigs == 0) { - // Build list of required key names for error message + size_t requiredSigs = requireAllSignatures ? requiredSignatures.size() : 1; + + if (validSigs < requiredSigs) { auto keys = std::views::keys(requiredSignatures); std::vector keyNames(keys.begin(), keys.end()); std::string requiredKeyNames = concatStringsSep(", ", keyNames); if (info.sigs.empty()) { - // No signatures at all throw Error( "refusing to upload unsigned path '%s' to binary cache '%s'\n\n" - "The cache requires paths to be signed by one of these keys:\n %s\n\n" + "The cache requires paths to be signed by %s:\n %s\n\n" "You have not configured any signing keys. To fix this:\n" " - Use: %s?secret-key=/path/to/key (not 'secret-key-file'!)\n" " - Or set: secret-key-files = /path/to/key in nix.conf\n", printStorePath(info.path), config.getHumanReadableURI(), + requireAllSignatures ? "ALL of these keys" : "at least ONE of these keys", requiredKeyNames, config.getHumanReadableURI()); } else { - // Has signatures, but none from required keys + // Has signatures, but insufficient or wrong keys throw Error( "refusing to upload path '%s' to binary cache '%s'\n\n" - "The cache requires paths to be signed by one of these keys:\n %s\n\n" + "The cache requires paths to be signed by %s:\n %s\n\n" + "Current valid signatures: %d out of %d required\n" "Current signatures on path:\n %s\n\n" - "The path is signed, but not by any of the keys this cache requires.\n" - "Make sure you're using the correct signing key for this cache.", + "The path %s.\n" + "Make sure you're using the correct signing key(s) for this cache.", printStorePath(info.path), config.getHumanReadableURI(), + requireAllSignatures ? "ALL of these keys" : "at least ONE of these keys", requiredKeyNames, - concatStringsSep("\n ", info.sigs)); + validSigs, + requiredSigs, + concatStringsSep("\n ", info.sigs), + validSigs == 0 ? "is signed, but not by any of the keys this cache requires" + : "is signed by some required keys, but not all of them"); } } } diff --git a/src/libstore/include/nix/store/binary-cache-store.hh b/src/libstore/include/nix/store/binary-cache-store.hh index 76c2df4a08e..7a80956a946 100644 --- a/src/libstore/include/nix/store/binary-cache-store.hh +++ b/src/libstore/include/nix/store/binary-cache-store.hh @@ -143,6 +143,13 @@ private: */ PublicKeys requiredSignatures; + /** + * If true, paths must be signed by ALL keys in requiredSignatures. + * If false, paths need only be signed by at least ONE key. + * Parsed from the RequireAllSignatures field in nix-cache-info. + */ + bool requireAllSignatures = false; + std::string narInfoFileFor(const StorePath & storePath); void writeNarInfo(ref narInfo); From 786871ef59c9d9c2b4cc1d070c9a419fb0417674 Mon Sep 17 00:00:00 2001 From: Bernardo Meurer Costa Date: Mon, 20 Oct 2025 22:20:50 +0000 Subject: [PATCH 3/4] test(tests/nixos/s3-binary-cache-store): add RequiredSignatures tests Add tests for the RequiredSignatures and RequireAllSignatures fields in nix-cache-info: - Test unsigned path rejection - Test correctly signed path acceptance - Test wrong key signature rejection - Test content-addressed paths bypass signature requirements - Test multiple required keys (any one sufficient) - Test RequireAllSignatures enforcement (all keys required) Added test packages D-G to avoid signature state pollution across tests. --- tests/nixos/s3-binary-cache-store.nix | 133 ++++++++++++++++++++++---- 1 file changed, 114 insertions(+), 19 deletions(-) diff --git a/tests/nixos/s3-binary-cache-store.nix b/tests/nixos/s3-binary-cache-store.nix index 981fab8686e..ae610534484 100644 --- a/tests/nixos/s3-binary-cache-store.nix +++ b/tests/nixos/s3-binary-cache-store.nix @@ -8,15 +8,19 @@ let pkgs = config.nodes.client.nixpkgs.pkgs; - # Test packages - minimal packages for fast copying - pkgA = pkgs.writeText "test-package-a" "test package a"; - pkgB = pkgs.writeText "test-package-b" "test package b"; - pkgC = pkgs.writeText "test-package-c" "test package c"; + testPkgs = lib.genAttrs [ + "A" + "B" + "C" + "D" + "E" + "F" + "G" + ] (n: pkgs.writeText "test-package-${n}" "test package ${n}"); # S3 configuration accessKey = "BKIKJAA5BMMU2RHO6IBB"; secretKey = "V7f1CwQqAcwo80UEIJEjc5gVQUSSx5ohQ9GSrr12"; - in { name = "curl-s3-binary-cache-store"; @@ -32,11 +36,7 @@ in { virtualisation.writableStore = true; virtualisation.cores = 2; - virtualisation.additionalPaths = [ - pkgA - pkgB - pkgC - ]; + virtualisation.additionalPaths = lib.attrValues testPkgs; environment.systemPackages = [ pkgs.minio-client ]; nix.extraOptions = '' experimental-features = nix-command @@ -83,11 +83,7 @@ in ENDPOINT = 'http://server:9000' REGION = 'eu-west-1' - PKGS = { - 'A': '${pkgA}', - 'B': '${pkgB}', - 'C': '${pkgC}', - } + PKGS = json.loads('${builtins.toJSON testPkgs}') ENV_WITH_CREDS = f"AWS_ACCESS_KEY_ID={ACCESS_KEY} AWS_SECRET_ACCESS_KEY={SECRET_KEY}" @@ -147,7 +143,7 @@ in else: machine.fail(f"nix path-info {pkg}") - def setup_s3(populate_bucket=[], public=False): + def setup_s3(populate_bucket=[], public=False, required_signatures=[], require_all_signatures=False): """ Decorator that creates/destroys a unique bucket for each test. Optionally pre-populates bucket with specified packages. @@ -156,6 +152,8 @@ in Args: populate_bucket: List of packages to upload before test runs public: If True, make the bucket publicly accessible + required_signatures: List of public keys to require in nix-cache-info + require_all_signatures: If True, require ALL signatures (not just one) """ def decorator(test_func): def wrapper(): @@ -163,6 +161,16 @@ in server.succeed(f"mc mb minio/{bucket}") if public: server.succeed(f"mc anonymous set download minio/{bucket}") + + # Upload nix-cache-info with optional RequiredSignatures + cache_info = "StoreDir: /nix/store" + if required_signatures: + sigs = " ".join(required_signatures) + cache_info += f"\\nRequiredSignatures: {sigs}" + if require_all_signatures: + cache_info += "\\nRequireAllSignatures: 1" + server.succeed(f"echo -e '{cache_info}' | mc pipe minio/{bucket}/nix-cache-info") + try: if populate_bucket: store_url = make_s3_url(bucket) @@ -171,9 +179,8 @@ in test_func(bucket) finally: server.succeed(f"mc rb --force minio/{bucket}") - # Clean up client store - only delete if path exists - for pkg in PKGS.values(): - client.succeed(f"[ ! -e {pkg} ] || nix store delete --ignore-liveness {pkg}") + # Surprisingly, nix store delete doesn't care if a path does not exist at all + client.succeed(f'nix store delete --ignore-liveness {" ".join(PKGS.values())}') return wrapper return decorator @@ -597,6 +604,93 @@ in print(" ✓ File content verified correct (hash matches)") + def test_required_signatures(): + """Test RequiredSignatures field enforcement""" + print("\n=== Testing RequiredSignatures ===") + + # Generate signing keys + server.succeed("nix key generate-secret --key-name cache.example.org > /tmp/sk1") + server.succeed("nix key generate-secret --key-name other.example.org > /tmp/sk2") + + pk1 = server.succeed("nix key convert-secret-to-public < /tmp/sk1").strip() + pk2 = server.succeed("nix key convert-secret-to-public < /tmp/sk2").strip() + + # Test 1: Unsigned paths are rejected + @setup_s3(required_signatures=[pk1]) + def test_unsigned_rejected(bucket): + store_url = make_s3_url(bucket) + error = server.fail(f"{ENV_WITH_CREDS} nix copy --to '{store_url}' {PKGS['D']} 2>&1") + if "refusing to upload unsigned path" not in error: + raise Exception("Expected error about unsigned path") + print(" ✓ Unsigned paths rejected") + + # Test 2: Correctly signed paths are accepted + @setup_s3(required_signatures=[pk1]) + def test_signed_accepted(bucket): + server.succeed(f"nix store sign --key-file /tmp/sk1 {PKGS['E']}") + store_url = make_s3_url(bucket) + server.succeed(f"{ENV_WITH_CREDS} nix copy --to '{store_url}' {PKGS['E']}") + print(" ✓ Correctly signed paths accepted") + + # Test 3: Wrong key signatures are rejected + @setup_s3(required_signatures=[pk1]) + def test_wrong_key_rejected(bucket): + server.succeed(f"nix store sign --key-file /tmp/sk2 {PKGS['F']}") + store_url = make_s3_url(bucket) + error = server.fail(f"{ENV_WITH_CREDS} nix copy --to '{store_url}' {PKGS['F']} 2>&1") + if "not by any of the keys this cache requires" not in error: + raise Exception("Expected error about wrong key") + print(" ✓ Wrong key signatures rejected") + + # Test 4: Content-addressed paths don't need signatures + @setup_s3(required_signatures=[pk1]) + def test_ca_paths(bucket): + # Convert an existing package to content-addressed + ca_output = server.succeed(f"nix store make-content-addressed --json {PKGS['A']}") + ca_info = json.loads(ca_output) + ca_path = ca_info["rewrites"][PKGS['A']] + + store_url = make_s3_url(bucket) + server.succeed(f"{ENV_WITH_CREDS} nix copy --to '{store_url}' {ca_path}") + print(" ✓ Content-addressed paths work without signatures") + + # Test 5: Multiple required keys (any one is sufficient) + @setup_s3(required_signatures=[pk1, pk2]) + def test_multiple_keys(bucket): + # Path signed with sk1 should succeed + server.succeed(f"nix store sign --key-file /tmp/sk1 {PKGS['B']}") + store_url = make_s3_url(bucket) + server.succeed(f"{ENV_WITH_CREDS} nix copy --to '{store_url}' {PKGS['B']}") + + # Path signed with sk2 should also succeed + server.succeed(f"nix store sign --key-file /tmp/sk2 {PKGS['C']}") + server.succeed(f"{ENV_WITH_CREDS} nix copy --to '{store_url}' {PKGS['C']}") + print(" ✓ Multiple required keys work (any one is sufficient)") + + # Test 6: RequireAllSignatures enforcement + @setup_s3(required_signatures=[pk1, pk2], require_all_signatures=True) + def test_require_all_signatures(bucket): + store_url = make_s3_url(bucket) + + # Path signed with only pk1 should fail + server.succeed(f"nix store sign --key-file /tmp/sk1 {PKGS['G']}") + error = server.fail(f"{ENV_WITH_CREDS} nix copy --to '{store_url}' {PKGS['G']} 2>&1") + if "ALL of these keys" not in error or "1 out of 2 required" not in error: + raise Exception(f"Expected error about ALL keys and signature count. Got: {error}") + + # Path signed with both pk1 and pk2 should succeed + server.succeed(f"nix store sign --key-file /tmp/sk2 {PKGS['G']}") + server.succeed(f"{ENV_WITH_CREDS} nix copy --to '{store_url}' {PKGS['G']}") + print(" ✓ RequireAllSignatures enforces all keys must sign") + + # Run all sub-tests + test_unsigned_rejected() + test_signed_accepted() + test_wrong_key_rejected() + test_ca_paths() + test_multiple_keys() + test_require_all_signatures() + # ============================================================================ # Main Test Execution # ============================================================================ @@ -626,6 +720,7 @@ in test_compression_mixed() test_compression_disabled() test_nix_prefetch_url() + test_required_signatures() print("\n" + "="*80) print("✓ All S3 Binary Cache Store Tests Passed!") From 2bb83345b52bb25b1d2a62a3893d84404a6f08f6 Mon Sep 17 00:00:00 2001 From: Bernardo Meurer Costa Date: Tue, 21 Oct 2025 00:07:37 +0000 Subject: [PATCH 4/4] docs: add release notes for RequiredSignatures feature --- .../binary-cache-required-signatures.md | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 doc/manual/rl-next/binary-cache-required-signatures.md diff --git a/doc/manual/rl-next/binary-cache-required-signatures.md b/doc/manual/rl-next/binary-cache-required-signatures.md new file mode 100644 index 00000000000..872a139d9c1 --- /dev/null +++ b/doc/manual/rl-next/binary-cache-required-signatures.md @@ -0,0 +1,34 @@ +--- +synopsis: "Binary caches can now soft-enforce signature requirements" +issues: [12491] +--- + +Binary caches can now advertise signature requirements through their +`nix-cache-info` file, preventing accidental uploads of unsigned or +incorrectly-signed store paths. + +Cache operators can add a `RequiredSignatures` field containing a +whitespace-separated list of public keys. When uploading paths to such caches, +Nix validates that each path has at least one valid signature from the required +keys: + +``` +StoreDir: /nix/store +RequiredSignatures: cache.example.org-1:abc123... cache.example.org-2:def456... +``` + +This helps catch common configuration errors, such as typos in store URLs +(`secret-key-file` vs `secret-key`), by failing fast with clear error messages +rather than silently uploading unsigned paths. + +For multi-party approval workflows, the optional `RequireAllSignatures: 1` field +requires paths to be signed by *all* listed keys rather than just one: + +``` +StoreDir: /nix/store +RequiredSignatures: dev:key1... qa:key2... release:key3... +RequireAllSignatures: 1 +``` + +Content-addressed paths are automatically exempt from signature requirements as +they are self-validating.