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
119 changes: 106 additions & 13 deletions src/libstore/aws-creds.cc
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@

# include <aws/crt/Types.h>
# include "nix/store/s3-url.hh"
# include "nix/util/finally.hh"
# include "nix/util/logging.hh"
# include "nix/util/url.hh"
# include "nix/util/util.hh"

# include <aws/crt/Api.h>
# include <aws/crt/auth/Credentials.h>
# include <aws/crt/io/Bootstrap.h>

// C library headers for SSO provider support
# include <aws/auth/credentials.h>

# include <boost/unordered/concurrent_flat_map.hpp>

# include <chrono>
Expand All @@ -30,6 +30,48 @@ AwsAuthError::AwsAuthError(int errorCode)

namespace {

/**
* Helper function to wrap a C credentials provider in the C++ interface.
* This replicates the static s_CreateWrappedProvider from aws-crt-cpp.
*/
static std::shared_ptr<Aws::Crt::Auth::ICredentialsProvider> createWrappedProvider(
aws_credentials_provider * rawProvider, Aws::Crt::Allocator * allocator = Aws::Crt::ApiAllocator())
{
if (rawProvider == nullptr) {
return nullptr;
}

auto provider = Aws::Crt::MakeShared<Aws::Crt::Auth::CredentialsProvider>(allocator, rawProvider, allocator);
return std::static_pointer_cast<Aws::Crt::Auth::ICredentialsProvider>(provider);
}

/**
* Create an SSO credentials provider using the C library directly.
* The C++ wrapper doesn't expose SSO, so we call the C library and wrap the result.
* Returns nullptr if SSO provider creation fails (e.g., profile doesn't have SSO config).
*/
static std::shared_ptr<Aws::Crt::Auth::ICredentialsProvider> createSSOProvider(
const std::string & profileName,
Aws::Crt::Io::ClientBootstrap * bootstrap,
Aws::Crt::Io::TlsContext * tlsContext,
Aws::Crt::Allocator * allocator = Aws::Crt::ApiAllocator())
{
aws_credentials_provider_sso_options options;
AWS_ZERO_STRUCT(options);

options.bootstrap = bootstrap->GetUnderlyingHandle();
options.tls_ctx = tlsContext ? tlsContext->GetUnderlyingHandle() : nullptr;
options.profile_name_override = aws_byte_cursor_from_c_str(profileName.c_str());

// Create the SSO provider - will return nullptr if SSO isn't configured for this profile
auto * rawProvider = aws_credentials_provider_new_sso(allocator, &options);
if (!rawProvider) {
return nullptr;
}

return createWrappedProvider(rawProvider, allocator);
}

static AwsCredentials getCredentialsFromProvider(std::shared_ptr<Aws::Crt::Auth::ICredentialsProvider> provider)
{
if (!provider || !provider->IsValid()) {
Expand Down Expand Up @@ -91,6 +133,16 @@ class AwsCredentialProviderImpl : public AwsCredentialProvider
logLevel = Aws::Crt::LogLevel::Warn;
}
apiHandle.InitializeLogging(logLevel, stderr);

// Create a shared TLS context for SSO (required for HTTPS connections)
auto allocator = Aws::Crt::ApiAllocator();
auto tlsCtxOptions = Aws::Crt::Io::TlsContextOptions::InitDefaultClient(allocator);
tlsContext =
std::make_shared<Aws::Crt::Io::TlsContext>(tlsCtxOptions, Aws::Crt::Io::TlsMode::CLIENT, allocator);
if (!tlsContext || !*tlsContext) {
warn("failed to create TLS context for AWS SSO; SSO authentication will be unavailable");
tlsContext = nullptr;
}
}

AwsCredentials getCredentialsRaw(const std::string & profile);
Expand All @@ -111,6 +163,7 @@ class AwsCredentialProviderImpl : public AwsCredentialProvider

private:
Aws::Crt::ApiHandle apiHandle;
std::shared_ptr<Aws::Crt::Io::TlsContext> tlsContext;
boost::concurrent_flat_map<std::string, std::shared_ptr<Aws::Crt::Auth::ICredentialsProvider>>
credentialProviderCache;
};
Expand All @@ -123,18 +176,58 @@ AwsCredentialProviderImpl::createProviderForProfile(const std::string & profile)
getpid(),
profile.empty() ? "(default)" : profile.c_str());

if (profile.empty()) {
Aws::Crt::Auth::CredentialsProviderChainDefaultConfig config;
config.Bootstrap = Aws::Crt::ApiHandle::GetOrCreateStaticDefaultClientBootstrap();
return Aws::Crt::Auth::CredentialsProvider::CreateCredentialsProviderChainDefault(config);
auto bootstrap = Aws::Crt::ApiHandle::GetOrCreateStaticDefaultClientBootstrap();
if (!bootstrap) {
throw AwsAuthError("failed to create AWS client bootstrap");
}

Aws::Crt::Auth::CredentialsProviderProfileConfig config;
config.Bootstrap = Aws::Crt::ApiHandle::GetOrCreateStaticDefaultClientBootstrap();
// This is safe because the underlying C library will copy this string
// c.f. https://github.com/awslabs/aws-c-auth/blob/main/source/credentials_provider_profile.c#L220
config.ProfileNameOverride = Aws::Crt::ByteCursorFromCString(profile.c_str());
return Aws::Crt::Auth::CredentialsProvider::CreateCredentialsProviderProfile(config);
// Build a custom credential chain: Environment → SSO → Profile → IMDS
// This works for both default and named profiles, ensuring consistent behavior
// including SSO support and proper TLS context for STS-based role assumption.
Aws::Crt::Auth::CredentialsProviderChainConfig chainConfig;
auto allocator = Aws::Crt::ApiAllocator();
const char * profileName = profile.empty() ? "(default)" : profile.c_str();

auto addProviderToChain = [&](std::string_view name, auto createProvider) {
if (auto provider = createProvider()) {
chainConfig.Providers.push_back(provider);
debug("Added AWS %s Credential Provider to chain for profile '%s'", name, profileName);
} else {
debug("Skipped AWS %s Credential Provider for profile '%s'", name, profileName);
}
};

// 1. Environment variables (highest priority)
addProviderToChain("Environment", [&]() {
return Aws::Crt::Auth::CredentialsProvider::CreateCredentialsProviderEnvironment(allocator);
});

// 2. SSO provider (try it, will fail gracefully if not configured)
if (tlsContext) {
addProviderToChain("SSO", [&]() { return createSSOProvider(profile, bootstrap, tlsContext.get(), allocator); });
} else {
debug("Skipped AWS SSO Credential Provider for profile '%s': TLS context unavailable", profileName);
}

// 3. Profile provider (for static credentials and role_arn/source_profile with STS)
addProviderToChain("Profile", [&]() {
Aws::Crt::Auth::CredentialsProviderProfileConfig profileConfig;
profileConfig.Bootstrap = bootstrap;
profileConfig.TlsContext = tlsContext.get();
if (!profile.empty()) {
profileConfig.ProfileNameOverride = Aws::Crt::ByteCursorFromCString(profile.c_str());
}
return Aws::Crt::Auth::CredentialsProvider::CreateCredentialsProviderProfile(profileConfig, allocator);
});

// 4. IMDS provider (for EC2 instances, lowest priority)
addProviderToChain("IMDS", [&]() {
Aws::Crt::Auth::CredentialsProviderImdsConfig imdsConfig;
imdsConfig.Bootstrap = bootstrap;
return Aws::Crt::Auth::CredentialsProvider::CreateCredentialsProviderImds(imdsConfig, allocator);
});

return Aws::Crt::Auth::CredentialsProvider::CreateCredentialsProviderChain(chainConfig, allocator);
}

AwsCredentials AwsCredentialProviderImpl::getCredentialsRaw(const std::string & profile)
Expand Down
2 changes: 2 additions & 0 deletions src/libstore/meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,8 @@ if s3_aws_auth.enabled()
deps_other += aws_crt_cpp
aws_c_common = cxx.find_library('aws-c-common', required : true)
deps_other += aws_c_common
aws_c_auth = cxx.find_library('aws-c-auth', required : true)
deps_other += aws_c_auth
endif

configdata_pub.set('NIX_WITH_AWS_AUTH', s3_aws_auth.enabled().to_int())
Expand Down
132 changes: 131 additions & 1 deletion tests/nixos/s3-binary-cache-store.nix
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ in
else:
machine.fail(f"nix path-info {pkg}")

def setup_s3(populate_bucket=[], public=False, versioned=False):
def setup_s3(populate_bucket=[], public=False, versioned=False, profiles=None):
"""
Decorator that creates/destroys a unique bucket for each test.
Optionally pre-populates bucket with specified packages.
Expand All @@ -157,23 +157,48 @@ in
populate_bucket: List of packages to upload before test runs
public: If True, make the bucket publicly accessible
versioned: If True, enable versioning on the bucket before populating
profiles: Dict of AWS profiles to create, e.g.:
{"valid": {"access_key": "...", "secret_key": "..."},
"invalid": {"access_key": "WRONG", "secret_key": "WRONG"}}
Profiles are created on the client machine at /root/.aws/credentials
"""
def decorator(test_func):
def wrapper():
# Restart nix-daemon on both machines to clear the credential provider cache.
# The AwsCredentialProviderImpl singleton persists in the daemon process,
# and its cache can cause credentials from previous tests to be reused.
# We reset-failed first to avoid systemd's start rate limiting.
server.succeed("systemctl reset-failed nix-daemon.service nix-daemon.socket")
server.succeed("systemctl restart nix-daemon")
client.succeed("systemctl reset-failed nix-daemon.service nix-daemon.socket")
client.succeed("systemctl restart nix-daemon")

bucket = str(uuid.uuid4())
server.succeed(f"mc mb minio/{bucket}")
try:
if public:
server.succeed(f"mc anonymous set download minio/{bucket}")
if versioned:
server.succeed(f"mc version enable minio/{bucket}")
if profiles:
# Build credentials file content
creds_content = ""
for name, creds in profiles.items():
creds_content += f"[{name}]\n"
creds_content += f"aws_access_key_id = {creds['access_key']}\n"
creds_content += f"aws_secret_access_key = {creds['secret_key']}\n\n"
client.succeed("mkdir -p /root/.aws")
client.succeed(f"cat > /root/.aws/credentials << 'AWSCREDS'\n{creds_content}AWSCREDS")
if populate_bucket:
store_url = make_s3_url(bucket)
for pkg in populate_bucket:
server.succeed(f"{ENV_WITH_CREDS} nix copy --to '{store_url}' {pkg}")
test_func(bucket)
finally:
server.succeed(f"mc rb --force minio/{bucket}")
# Clean up AWS profiles if created
if profiles:
client.succeed("rm -rf /root/.aws")
# 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}")
Expand Down Expand Up @@ -764,6 +789,108 @@ in

print(" ✓ Compressed log uploaded with multipart")

@setup_s3(
populate_bucket=[PKGS['A']],
profiles={
"valid": {"access_key": ACCESS_KEY, "secret_key": SECRET_KEY},
"invalid": {"access_key": "INVALIDKEY", "secret_key": "INVALIDSECRET"},
}
)
def test_profile_credentials(bucket):
"""Test that profile-based credentials work without environment variables"""
print("\n=== Testing Profile-Based Credentials ===")

store_url = make_s3_url(bucket, profile="valid")

# Verify store info works with profile credentials (no env vars)
client.succeed(f"HOME=/root nix store info --store '{store_url}' >&2")
print(" ✓ nix store info works with profile credentials")

# Verify we can copy from the store using profile
verify_packages_in_store(client, PKGS['A'], should_exist=False)
client.succeed(f"HOME=/root nix copy --no-check-sigs --from '{store_url}' {PKGS['A']}")
verify_packages_in_store(client, PKGS['A'])
print(" ✓ nix copy works with profile credentials")

# Clean up the package we just copied so we can test invalid profile
client.succeed(f"nix store delete --ignore-liveness {PKGS['A']}")
verify_packages_in_store(client, PKGS['A'], should_exist=False)

# Verify invalid profile fails when trying to copy
invalid_url = make_s3_url(bucket, profile="invalid")
client.fail(f"HOME=/root nix copy --no-check-sigs --from '{invalid_url}' {PKGS['A']} 2>&1")
print(" ✓ Invalid profile credentials correctly rejected")

@setup_s3(
populate_bucket=[PKGS['A']],
profiles={
"wrong": {"access_key": "WRONGKEY", "secret_key": "WRONGSECRET"},
}
)
def test_env_vars_precedence(bucket):
"""Test that environment variables take precedence over profile credentials"""
print("\n=== Testing Environment Variables Precedence ===")

# Use profile with wrong credentials, but provide correct creds via env vars
store_url = make_s3_url(bucket, profile="wrong")

# Ensure package is not in client store
verify_packages_in_store(client, PKGS['A'], should_exist=False)

# This should succeed because env vars (correct) override profile (wrong)
output = client.succeed(
f"HOME=/root {ENV_WITH_CREDS} nix copy --no-check-sigs --debug --from '{store_url}' {PKGS['A']} 2>&1"
)
print(" ✓ nix copy succeeded with env vars overriding wrong profile")

# Verify the credential chain shows Environment provider was added
if "Added AWS Environment Credential Provider" not in output:
print("Debug output:")
print(output)
raise Exception("Expected Environment provider to be added to chain")
print(" ✓ Environment provider added to credential chain")

# Clean up the package so we can test again without env vars
client.succeed(f"nix store delete --ignore-liveness {PKGS['A']}")
verify_packages_in_store(client, PKGS['A'], should_exist=False)

# Without env vars, same URL should fail (proving profile creds are actually wrong)
client.fail(f"HOME=/root nix copy --no-check-sigs --from '{store_url}' {PKGS['A']} 2>&1")
print(" ✓ Without env vars, wrong profile credentials correctly fail")

@setup_s3(
populate_bucket=[PKGS['A']],
profiles={
"testprofile": {"access_key": ACCESS_KEY, "secret_key": SECRET_KEY},
}
)
def test_credential_provider_chain(bucket):
"""Test that debug logging shows which providers are added to the chain"""
print("\n=== Testing Credential Provider Chain Logging ===")

store_url = make_s3_url(bucket, profile="testprofile")

output = client.succeed(
f"HOME=/root nix store info --debug --store '{store_url}' 2>&1"
)

# For a named profile, we expect to see these providers in the chain
expected_providers = ["Environment", "Profile", "IMDS"]
for provider in expected_providers:
msg = f"Added AWS {provider} Credential Provider to chain for profile 'testprofile'"
if msg not in output:
print("Debug output:")
print(output)
raise Exception(f"Expected to find: {msg}")
print(f" ✓ {provider} provider added to chain")

# SSO should be skipped (no SSO config for this profile)
if "Skipped AWS SSO Credential Provider for profile 'testprofile'" not in output:
print("Debug output:")
print(output)
raise Exception("Expected SSO provider to be skipped")
print(" ✓ SSO provider correctly skipped (not configured)")

# ============================================================================
# Main Test Execution
# ============================================================================
Expand Down Expand Up @@ -797,6 +924,9 @@ in
test_multipart_upload_basic()
test_multipart_threshold()
test_multipart_with_log_compression()
test_profile_credentials()
test_env_vars_precedence()
test_credential_provider_chain()

print("\n" + "="*80)
print("✓ All S3 Binary Cache Store Tests Passed!")
Expand Down
Loading