Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions doc/manual/rl-next/binary-cache-required-signatures.md
Original file line number Diff line number Diff line change
@@ -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.
63 changes: 63 additions & 0 deletions src/libstore/binary-cache-store.cc
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

#include <chrono>
#include <future>
#include <ranges>
#include <regex>
#include <fstream>
#include <sstream>
Expand Down Expand Up @@ -66,6 +67,24 @@ void BinaryCacheStore::init()
config.wantMassQuery.setDefault(value == "1");
} else if (name == "Priority") {
config.priority.setDefault(std::stoi(value));
} else if (name == "RequiredSignatures") {
Copy link
Member

Choose a reason for hiding this comment

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

This should probably be named RequiredSigners (it's a list of public keys, not a list of signatures).

Copy link
Member

Choose a reason for hiding this comment

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

And actually the name RequiredSigners is misleading if RequireAllSignatures is false. So it should probably be Signers or AllowedSigners.

// Parse whitespace-separated list of public keys
for (const auto & keyStr : tokenizeString<Strings>(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;
}
}
}
} else if (name == "RequireAllSignatures") {
Copy link
Member

Choose a reason for hiding this comment

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

This could also be NrSignaturesNeeded, taking an arbitrary integer, similar to nix verify's --sigs-needed flag. It might be useful to require e.g. at least 2 valid signatures.

requireAllSignatures = (value == "1");
}
}
}
Expand Down Expand Up @@ -284,6 +303,50 @@ ref<const ValidPathInfo> 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) {
auto validSigs = info.checkSignatures(*this, requiredSignatures);
size_t requiredSigs = requireAllSignatures ? requiredSignatures.size() : 1;

if (validSigs < requiredSigs) {
auto keys = std::views::keys(requiredSignatures);
std::vector<std::string> keyNames(keys.begin(), keys.end());
std::string requiredKeyNames = concatStringsSep(", ", keyNames);

if (info.sigs.empty()) {
throw Error(
"refusing to upload unsigned path '%s' to binary cache '%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 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 %s:\n %s\n\n"
"Current valid signatures: %d out of %d required\n"
"Current signatures on path:\n %s\n\n"
"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,
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");
}
}
}

/* Atomically write the NAR info file.*/
writeNarInfo(narInfo);

Expand Down
14 changes: 14 additions & 0 deletions src/libstore/include/nix/store/binary-cache-store.hh
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,20 @@ 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;

/**
* 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> narInfo);
Expand Down
133 changes: 114 additions & 19 deletions tests/nixos/s3-binary-cache-store.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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
Expand Down Expand Up @@ -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}"

Expand Down Expand Up @@ -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.
Expand All @@ -156,13 +152,25 @@ 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():
bucket = str(uuid.uuid4())
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)
Expand All @@ -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

Expand Down Expand Up @@ -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
# ============================================================================
Expand Down Expand Up @@ -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!")
Expand Down
Loading