diff --git a/doc/manual/rl-next/secretspec-access-tokens.md b/doc/manual/rl-next/secretspec-access-tokens.md new file mode 100644 index 000000000000..41f38816c2ac --- /dev/null +++ b/doc/manual/rl-next/secretspec-access-tokens.md @@ -0,0 +1,58 @@ +--- +synopsis: Resolve Nix credentials through SecretSpec +--- + +The new +[`secretspec-access-tokens`](@docroot@/command-ref/conf-file.md#conf-secretspec-access-tokens) +setting maps Git forge hosts and path prefixes to secret names declared in a +`secretspec.toml`. +Nix resolves those names lazily through `secretspec-ffi`, so access-token values +no longer need to be stored in `nix.conf` or exposed by `nix config show`. + +[`secretspec-netrc-file`](@docroot@/command-ref/conf-file.md#conf-secretspec-netrc-file) +selects a complete `netrc` secret declared with `as_path = true`, while +[`secretspec-impure-env`](@docroot@/command-ref/conf-file.md#conf-secretspec-impure-env) +maps environment-variable names to inline SecretSpec secrets for fixed-output +derivations. SecretSpec values are resolved only when the corresponding +credential is used. + +Nix installs and selects a bundled `secretspec.toml` by default. It declares +optional `GITHUB_TOKEN`, `GITLAB_TOKEN`, `SOURCEHUT_TOKEN`, `NIX_NETRC`, and +`BUILD_TOKEN` secrets, plus a `nix` scope containing all of them. Set +[`secretspec-file`](@docroot@/command-ref/conf-file.md#conf-secretspec-file) to +use a custom manifest with different declarations. + +For example: + +```ini +secretspec-access-tokens = github.com=GITHUB_TOKEN +secretspec-netrc-file = NIX_NETRC +secretspec-impure-env = PRIVATE_TOKEN=BUILD_TOKEN +secretspec-scope = nix +``` + +`secretspec-netrc-file` takes precedence over `netrc-file`. Literal +`access-tokens` and `impure-env` entries take precedence over equally specific +or equally named SecretSpec mappings. Resolved values never become part of the +Nix configuration; `nix config show` displays only their SecretSpec names. + +On a multi-user daemon, the selected `netrc` is a daemon-wide credential source, +not a per-user one. Users allowed to request builds can cause matching entries +to be used by HTTP(S) transfers, including the `builtin:fetchurl` builder. +Only include credentials intended to be shared across that trust domain. + +The [`secretspec-file`](@docroot@/command-ref/conf-file.md#conf-secretspec-file), +[`secretspec-provider`](@docroot@/command-ref/conf-file.md#conf-secretspec-provider), +[`secretspec-profile`](@docroot@/command-ref/conf-file.md#conf-secretspec-profile), +and +[`secretspec-scope`](@docroot@/command-ref/conf-file.md#conf-secretspec-scope) +settings select the SecretSpec resolution context. +Nix links to the `secretspec-ffi` C ABI through its pkg-config metadata and +removes materialized `as_path` files when their resolution context is destroyed. +Support is a build time option (`-Dsecretspec=`, enabled automatically when +`secretspec-ffi` is available); without it the `secretspec-*` settings still +exist but report that Nix was built without SecretSpec support. + +Credential-related settings, including `access-tokens`, `impure-env`, +`netrc-file`, and all `secretspec-*` settings, cannot be set from a flake's +`nixConfig`, even when `accept-flake-config` is enabled. diff --git a/packaging/dependencies.nix b/packaging/dependencies.nix index f8f64abf3968..0a3c2629d85e 100644 --- a/packaging/dependencies.nix +++ b/packaging/dependencies.nix @@ -16,6 +16,9 @@ in scope: { inherit stdenv; + # TODO: Use pkgs.secretspec-ffi after the pinned nixpkgs is bumped to include it. + secretspec-ffi = scope.callPackage ./secretspec-ffi.nix { }; + mimalloc = if lib.versionAtLeast pkgs.mimalloc.version "3.3.2" then pkgs.mimalloc diff --git a/packaging/secretspec-ffi.nix b/packaging/secretspec-ffi.nix new file mode 100644 index 000000000000..7825c5f22438 --- /dev/null +++ b/packaging/secretspec-ffi.nix @@ -0,0 +1,68 @@ +{ + lib, + stdenv, + buildPackages, + rustPlatform, + fetchFromGitHub, + cargo-c, + nix-update-script, + testers, +}: + +rustPlatform.buildRustPackage (finalAttrs: { + pname = "secretspec-ffi"; + version = "0.19.0"; + + src = fetchFromGitHub { + owner = "cachix"; + repo = "secretspec"; + tag = "v${finalAttrs.version}"; + hash = "sha256-u6zfPsyLoktLQTE8OEDhK0GtiogOw/3ML4zpDVhSrX0="; + }; + + cargoHash = "sha256-ogeNTp94FJv7p+eZgrLUK1i63VCHiqHd7BsP+jDMHVc="; + + nativeBuildInputs = [ cargo-c ]; + + buildPhase = '' + runHook preBuild + ${buildPackages.rust.envVars.setEnv} cargo cbuild -p secretspec-ffi -j $NIX_BUILD_CORES \ + --release --frozen --prefix=${placeholder "out"} \ + --target ${stdenv.hostPlatform.rust.rustcTarget} + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + ${buildPackages.rust.envVars.setEnv} cargo cinstall -p secretspec-ffi -j $NIX_BUILD_CORES \ + --release --frozen --prefix=${placeholder "out"} \ + --target ${stdenv.hostPlatform.rust.rustcTarget} + runHook postInstall + ''; + + checkPhase = '' + runHook preCheck + ${buildPackages.rust.envVars.setEnv} cargo ctest -p secretspec-ffi -j $NIX_BUILD_CORES \ + --release --frozen --prefix=${placeholder "out"} \ + --target ${stdenv.hostPlatform.rust.rustcTarget} + runHook postCheck + ''; + + passthru = { + tests.pkg-config = testers.hasPkgConfigModules { + package = finalAttrs.finalPackage; + }; + updateScript = nix-update-script { }; + }; + + meta = { + description = "C ABI for resolving secrets through SecretSpec"; + homepage = "https://secretspec.dev"; + license = lib.licenses.asl20; + maintainers = with lib.maintainers; [ + domenkozar + sandydoo + ]; + pkgConfigModules = [ "secretspec_ffi" ]; + }; +}) diff --git a/src/libfetchers-tests/access-tokens.cc b/src/libfetchers-tests/access-tokens.cc index 0614873fd281..4594eaaf8f75 100644 --- a/src/libfetchers-tests/access-tokens.cc +++ b/src/libfetchers-tests/access-tokens.cc @@ -1,8 +1,12 @@ #include #include +#include + #include "nix/fetchers/fetchers.hh" #include "nix/fetchers/fetch-settings.hh" +#include "nix/store/tests/secretspec.hh" +#include "nix/util/file-system.hh" namespace nix::fetchers { @@ -69,6 +73,27 @@ TEST_F(AccessKeysTest, repoGitHub) ASSERT_EQ(token, "yet_another_token"); } +TEST_F(AccessKeysTest, emptyPathSpecificTokenFallsBackToHost) +{ + fetchers::Settings fetchSettings = fetchers::Settings{}; + fetchSettings.accessTokens.get().insert({"github.com", "host-token"}); + fetchSettings.accessTokens.get().insert({"github.com/a", ""}); + auto i = Input::fromURL("github:a/b"); + + auto token = i.scheme->getAccessToken(fetchSettings, "github.com", "github.com/a/b"); + ASSERT_EQ(token, "host-token"); +} + +TEST_F(AccessKeysTest, emptyHostTokenIsNoToken) +{ + fetchers::Settings fetchSettings = fetchers::Settings{}; + fetchSettings.accessTokens.get().insert({"github.com", ""}); + auto i = Input::fromURL("github:a/b"); + + auto token = i.scheme->getAccessToken(fetchSettings, "github.com", "github.com/a/b"); + ASSERT_EQ(token, std::nullopt); +} + TEST_F(AccessKeysTest, multipleGitLab) { fetchers::Settings fetchSettings = fetchers::Settings{}; @@ -97,4 +122,165 @@ TEST_F(AccessKeysTest, multipleSourceHut) ASSERT_EQ(token, "token"); } +TEST_F(AccessKeysTest, literalTokenWinsEquallySpecificSecretSpecMatch) +{ + fetchers::Settings fetchSettings; + fetchSettings.accessTokens.get().insert({"github.com", "literal-token"}); + fetchSettings.secretSpecAccessTokens.get().insert({"github.com", "GITHUB_TOKEN"}); + auto i = Input::fromURL("github:a/b"); + + auto token = i.scheme->getAccessToken(fetchSettings, "github.com", "github.com/a/b"); + ASSERT_EQ(token, "literal-token"); +} + +/* The remaining tests resolve secrets for real through secretspec-ffi. */ +#if NIX_WITH_SECRETSPEC + +using nix::testing::SecretSpecFixture; + +static constexpr std::string_view accessTokenManifest = R"( +[project] +name = "nix-fetchers-test" +revision = "1.0" + +[profiles.nix] +GITHUB_TOKEN = { description = "GitHub token", required = false } +GITHUB_ORG_TOKEN = { description = "GitHub organization token", required = false } +GITLAB_TOKEN = { description = "GitLab token", required = false } + +[scopes.fetchers] +secrets = ["GITHUB_TOKEN", "GITHUB_ORG_TOKEN", "GITLAB_TOKEN"] +)"; + +TEST_F(AccessKeysTest, secretSpecToken) +{ + SecretSpecFixture fixture{ + accessTokenManifest, + "GITHUB_TOKEN=ffi-token\nGITHUB_ORG_TOKEN=ffi-org-token\nGITLAB_TOKEN=PAT:ffi-gitlab-token\n"}; + SecretSpecSettings secretSettings; + fixture.configure(secretSettings, "fetchers"); + fetchers::Settings fetchSettings{secretSettings}; + fetchSettings.secretSpecAccessTokens.get().insert({"github.com", "GITHUB_TOKEN"}); + auto i = Input::fromURL("github:a/b"); + + auto token = i.scheme->getAccessToken(fetchSettings, "github.com", "github.com/a/b"); + ASSERT_EQ(token, "ffi-token"); +} + +TEST_F(AccessKeysTest, secretSpecAndLiteralTokensUseMostSpecificMatch) +{ + SecretSpecFixture fixture{accessTokenManifest, "GITHUB_ORG_TOKEN=ffi-org-token\n"}; + SecretSpecSettings secretSettings; + fixture.configure(secretSettings, "fetchers"); + fetchers::Settings fetchSettings{secretSettings}; + fetchSettings.accessTokens.get().insert({"github.com", "literal-token"}); + fetchSettings.secretSpecAccessTokens.get().insert({"github.com/a", "GITHUB_ORG_TOKEN"}); + auto i = Input::fromURL("github:a/b"); + + auto token = i.scheme->getAccessToken(fetchSettings, "github.com", "github.com/a/b"); + ASSERT_EQ(token, "ffi-org-token"); +} + +TEST_F(AccessKeysTest, secretSpecResolutionFailureDoesNotExposeAValue) +{ + SecretSpecFixture fixture{"not valid TOML", ""}; + SecretSpecSettings secretSettings; + fixture.configure(secretSettings); + fetchers::Settings fetchSettings{secretSettings}; + fetchSettings.secretSpecAccessTokens.get().insert({"github.com", "GITHUB_TOKEN"}); + auto i = Input::fromURL("github:a/b"); + + EXPECT_THROW(i.scheme->getAccessToken(fetchSettings, "github.com", "github.com/a/b"), Error); +} + +TEST_F(AccessKeysTest, secretSpecMissingRequiredSecretFails) +{ + SecretSpecFixture fixture{ + R"( +[project] +name = "nix-fetchers-test" +revision = "1.0" + +[profiles.nix] +GITHUB_TOKEN = { description = "GitHub token", required = true } + +[scopes.fetchers] +secrets = ["GITHUB_TOKEN"] +)", + ""}; + SecretSpecSettings secretSettings; + fixture.configure(secretSettings, "fetchers"); + fetchers::Settings fetchSettings{secretSettings}; + fetchSettings.secretSpecAccessTokens.get().insert({"github.com", "GITHUB_TOKEN"}); + auto i = Input::fromURL("github:a/b"); + + EXPECT_THROW(i.scheme->getAccessToken(fetchSettings, "github.com", "github.com/a/b"), Error); +} + +TEST_F(AccessKeysTest, secretSpecMissingUnrelatedOptionalSecretSucceeds) +{ + SecretSpecFixture fixture{accessTokenManifest, "GITHUB_TOKEN=ffi-token\n"}; + SecretSpecSettings secretSettings; + fixture.configure(secretSettings, "fetchers"); + fetchers::Settings fetchSettings{secretSettings}; + fetchSettings.secretSpecAccessTokens.get().insert({"github.com", "GITHUB_TOKEN"}); + fetchSettings.secretSpecAccessTokens.get().insert({"gitlab.com", "GITLAB_TOKEN"}); + auto i = Input::fromURL("github:a/b"); + + auto token = i.scheme->getAccessToken(fetchSettings, "github.com", "github.com/a/b"); + ASSERT_EQ(token, "ffi-token"); +} + +TEST_F(AccessKeysTest, secretSpecAccessTokenRejectsAsPath) +{ + SecretSpecFixture fixture{ + R"( +[project] +name = "nix-fetchers-test" +revision = "1.0" + +[profiles.nix] +GITHUB_TOKEN = { description = "GitHub token", required = false, as_path = true } + +[scopes.fetchers] +secrets = ["GITHUB_TOKEN"] +)", + "GITHUB_TOKEN=ffi-token\n"}; + SecretSpecSettings secretSettings; + fixture.configure(secretSettings, "fetchers"); + fetchers::Settings fetchSettings{secretSettings}; + fetchSettings.secretSpecAccessTokens.get().insert({"github.com", "GITHUB_TOKEN"}); + auto i = Input::fromURL("github:a/b"); + + EXPECT_THROW(i.scheme->getAccessToken(fetchSettings, "github.com", "github.com/a/b"), Error); +} + +TEST_F(AccessKeysTest, emptyHostTokenFallsBackToSecretSpec) +{ + SecretSpecFixture fixture{accessTokenManifest, "GITHUB_TOKEN=ffi-token\n"}; + SecretSpecSettings secretSettings; + fixture.configure(secretSettings, "fetchers"); + fetchers::Settings fetchSettings{secretSettings}; + fetchSettings.accessTokens.get().insert({"github.com", ""}); + fetchSettings.secretSpecAccessTokens.get().insert({"github.com", "GITHUB_TOKEN"}); + auto i = Input::fromURL("github:a/b"); + + auto token = i.scheme->getAccessToken(fetchSettings, "github.com", "github.com/a/b"); + ASSERT_EQ(token, "ffi-token"); +} + +TEST_F(AccessKeysTest, secretSpecAccessTokenRejectsEmptySecret) +{ + SecretSpecFixture fixture{accessTokenManifest, "GITHUB_TOKEN=\n"}; + SecretSpecSettings secretSettings; + fixture.configure(secretSettings, "fetchers"); + fetchers::Settings fetchSettings{secretSettings}; + fetchSettings.secretSpecAccessTokens.get().insert({"github.com", "GITHUB_TOKEN"}); + auto i = Input::fromURL("github:a/b"); + + EXPECT_THROW(i.scheme->getAccessToken(fetchSettings, "github.com", "github.com/a/b"), Error); +} + +#endif + } // namespace nix::fetchers diff --git a/src/libfetchers/fetch-settings.cc b/src/libfetchers/fetch-settings.cc index 8839a0fde4f0..bada47702a14 100644 --- a/src/libfetchers/fetch-settings.cc +++ b/src/libfetchers/fetch-settings.cc @@ -2,8 +2,16 @@ namespace nix::fetchers { -Settings::Settings() {} +Settings::Settings(SecretSpecSettings & secretSpecSettings) + : secretSpecSettings(secretSpecSettings) +{ +} void Settings::anchor() {} +std::string Settings::getSecretSpecAccessToken(const std::string & name) const +{ + return secretSpecSettings.getInlineSecret(name); +} + } // namespace nix::fetchers diff --git a/src/libfetchers/github.cc b/src/libfetchers/github.cc index ac608da37a31..542ef1452ce2 100644 --- a/src/libfetchers/github.cc +++ b/src/libfetchers/github.cc @@ -193,28 +193,45 @@ struct GitArchiveInputScheme : InputScheme return input; } - // Search for the longest possible match starting from the beginning and ending at either the end or a path segment. - std::optional getAccessToken( - const fetchers::Settings & settings, const std::string & host, const std::string & url) const override + struct AccessTokenMatch + { + std::string value; + size_t prefixLength; + }; + + /** + * Search for the longest match ending at either the URL end or a path + * boundary. An empty value means "no token", so that a more specific entry + * can cancel a less specific one. + */ + static std::optional + findAccessTokenMatch(const StringMap & tokens, const std::string & host, const std::string & url) { - auto tokens = settings.accessTokens.get(); - std::string answer; - size_t answer_match_len = 0; + std::optional answer; if (!url.empty()) { - for (auto & token : tokens) { - auto first = url.find(token.first); - if (first != std::string::npos && token.first.length() > answer_match_len && first == 0 - && url.substr(0, token.first.length()) == token.first - && (url.length() == token.first.length() || url[token.first.length()] == '/')) { - answer = token.second; - answer_match_len = token.first.length(); - } + for (const auto & [prefix, value] : tokens) { + if ((!answer || prefix.length() > answer->prefixLength) && url.starts_with(prefix) + && (url.length() == prefix.length() || url[prefix.length()] == '/') && !value.empty()) + answer = AccessTokenMatch{value, prefix.length()}; } - if (!answer.empty()) - return answer; } - if (auto token = get(tokens, host)) - return *token; + if (!answer) + if (auto token = get(tokens, host); token && !token->empty()) + answer = AccessTokenMatch{*token, host.length()}; + return answer; + } + + std::optional getAccessToken( + const fetchers::Settings & settings, const std::string & host, const std::string & url) const override + { + auto literal = findAccessTokenMatch(settings.accessTokens.get(), host, url); + auto secret = findAccessTokenMatch(settings.secretSpecAccessTokens.get(), host, url); + + /* Preserve literal-token compatibility on an equally specific match. */ + if (literal && (!secret || literal->prefixLength >= secret->prefixLength)) + return literal->value; + if (secret) + return settings.getSecretSpecAccessToken(secret->value); return {}; } diff --git a/src/libfetchers/include/nix/fetchers/fetch-settings.hh b/src/libfetchers/include/nix/fetchers/fetch-settings.hh index bb9a67d068d4..0f59893797b6 100644 --- a/src/libfetchers/include/nix/fetchers/fetch-settings.hh +++ b/src/libfetchers/include/nix/fetchers/fetch-settings.hh @@ -5,6 +5,7 @@ #include "nix/util/configuration.hh" #include "nix/util/ref.hh" #include "nix/util/sync.hh" +#include "nix/store/secretspec-settings.hh" #include #include @@ -24,7 +25,7 @@ struct Cache; struct Settings : public Config { - Settings(); + explicit Settings(SecretSpecSettings & secretSpecSettings = nix::secretSpecSettings); Setting accessTokens{ this, @@ -78,7 +79,46 @@ struct Settings : public Config The `input.foo` uses the "gitlab" fetcher, which might requires specifying the token type along with the token value. - )"}; + )", + {}, + true, + std::nullopt, + FlakeConfigSetting::Forbidden}; + + Setting secretSpecAccessTokens{ + this, + {}, + "secretspec-access-tokens", + R"( + Access tokens resolved through [SecretSpec](https://secretspec.dev/). + + The value is a space-separated list of `host=secret-name` entries. Host + and path-prefix matching follows [`access-tokens`](#conf-access-tokens). + When both settings match the same prefix, the literal `access-tokens` + entry takes precedence; a more-specific entry in either setting takes + precedence over a less-specific one. + + Secret values are resolved lazily through the `secretspec-ffi` C ABI + and cached in memory by the shared SecretSpec settings. They are never + added to the Nix configuration, so `nix config show` displays only the + declared secret names. The resolved secrets must contain inline + values, not `as_path` values. + + Example `~/.config/nix/nix.conf`: + + ``` + secretspec-access-tokens = github.com=GITHUB_TOKEN gitlab.com=GITLAB_TOKEN + secretspec-scope = nix + ``` + + These conventional secret names, along with `SOURCEHUT_TOKEN`, are + declared by Nix's bundled SecretSpec manifest. Set + [`secretspec-file`](#conf-secretspec-file) to use a custom manifest. + )", + {}, + true, + std::nullopt, + FlakeConfigSetting::Forbidden}; Setting allowDirty{this, true, "allow-dirty", "Whether to allow dirty Git/Mercurial trees."}; @@ -161,10 +201,13 @@ struct Settings : public Config const ref srcToStore = createSrcToStore(); + /** Resolve and cache one access-token secret through secretspec-ffi. */ + std::string getSecretSpecAccessToken(const std::string & name) const; private: void anchor() override; + SecretSpecSettings & secretSpecSettings; mutable Sync> _cache; }; diff --git a/src/libflake/config.cc b/src/libflake/config.cc index 6367d7b4ec42..6c7e12286445 100644 --- a/src/libflake/config.cc +++ b/src/libflake/config.cc @@ -77,6 +77,11 @@ void ConfigFile::apply(const Settings & flakeSettings) else assert(false); + if (globalConfig.getFlakeConfigSetting(baseName) == FlakeConfigSetting::Forbidden) { + warn("ignoring flake configuration setting '%s' because it is not allowed to be set by flakes", name); + continue; + } + if (!whitelist.count(baseName) && !flakeSettings.acceptFlakeConfig) { bool trusted = false; auto trustedList = readTrustedList(); diff --git a/src/libstore-test-support/include/nix/store/tests/meson.build b/src/libstore-test-support/include/nix/store/tests/meson.build index 8d844d24e7d8..d491815087a1 100644 --- a/src/libstore-test-support/include/nix/store/tests/meson.build +++ b/src/libstore-test-support/include/nix/store/tests/meson.build @@ -11,5 +11,6 @@ headers = files( 'outputs-spec.hh', 'path.hh', 'protocol.hh', + 'secretspec.hh', 'test-main.hh', ) diff --git a/src/libstore-test-support/include/nix/store/tests/secretspec.hh b/src/libstore-test-support/include/nix/store/tests/secretspec.hh new file mode 100644 index 000000000000..9441a9b8063f --- /dev/null +++ b/src/libstore-test-support/include/nix/store/tests/secretspec.hh @@ -0,0 +1,39 @@ +#pragma once +///@file + +#include +#include + +#include "nix/store/secretspec-settings.hh" +#include "nix/util/file-system.hh" + +namespace nix::testing { + +/** + * A throwaway `secretspec.toml` plus a `dotenv://` provider backing it, for + * tests that need real secret resolution without a real secret store. + */ +struct SecretSpecFixture +{ + std::filesystem::path dir = createTempDir(); + AutoDelete cleanup{dir}; + std::filesystem::path manifest = dir / "secretspec.toml"; + std::filesystem::path dotenv = dir / ".env"; + + SecretSpecFixture(std::string_view manifestContents, std::string_view dotenvContents) + { + writeFile(manifest, manifestContents); + writeFile(dotenv, dotenvContents); + } + + void configure(SecretSpecSettings & settings, std::optional scope = std::nullopt) const + { + settings.file = manifest.string(); + settings.provider = "dotenv://" + dotenv.string(); + settings.profile = "nix"; + if (scope) + settings.scope = *scope; + } +}; + +} // namespace nix::testing diff --git a/src/libstore-tests/filetransfer-request.cc b/src/libstore-tests/filetransfer-request.cc index b89bb7ed0bc1..eea6cd207060 100644 --- a/src/libstore-tests/filetransfer-request.cc +++ b/src/libstore-tests/filetransfer-request.cc @@ -1,6 +1,8 @@ #include #include "nix/store/filetransfer.hh" +#include "nix/store/tests/secretspec.hh" +#include "nix/util/file-system.hh" namespace nix { @@ -16,4 +18,76 @@ TEST(FileTransferRequest, displayUriStripsUserinfo) EXPECT_EQ(plain.displayUri(), "https://example.org/file"); } +TEST(FileTransferSettings, doesNotResolveSecretSpecNetrcForFileUrls) +{ + auto dir = createTempDir(); + AutoDelete cleanup{dir}; + auto source = dir / "source"; + writeFile(source, "local data"); + + SecretSpecSettings secretSettings; + FileTransferSettings transferSettings{secretSettings}; + transferSettings.secretSpecNetrcFile = "UNUSED_NETRC_SECRET"; + + auto result = + makeFileTransfer(transferSettings)->download(FileTransferRequest{VerbatimURL{"file://" + source.string()}}); + EXPECT_EQ(result.data, "local data"); +} + +#if NIX_WITH_SECRETSPEC + +using nix::testing::SecretSpecFixture; + +static constexpr std::string_view netrcManifest = R"( +[project] +name = "nix-filetransfer-test" +revision = "1.0" + +[profiles.nix] +NIX_NETRC = { description = "netrc file", required = true, as_path = true } +)"; + +TEST(FileTransferSettings, resolvesSecretSpecNetrcFile) +{ + SecretSpecFixture fixture{netrcManifest, "NIX_NETRC=machine-example\n"}; + + std::filesystem::path resolvedPath; + { + SecretSpecSettings secretSettings; + fixture.configure(secretSettings); + + FileTransferSettings transferSettings{secretSettings}; + transferSettings.secretSpecNetrcFile = "NIX_NETRC"; + resolvedPath = transferSettings.getNetrcFile(); + + EXPECT_EQ(readFile(resolvedPath), "machine-example"); + EXPECT_TRUE(pathExists(resolvedPath)); + } + + EXPECT_FALSE(pathExists(resolvedPath)); +} + +TEST(FileTransferSettings, rejectsInlineSecretSpecNetrcFile) +{ + SecretSpecFixture fixture{ + R"( +[project] +name = "nix-filetransfer-test" +revision = "1.0" + +[profiles.nix] +NIX_NETRC = { description = "netrc file", required = true } +)", + "NIX_NETRC=machine-example\n"}; + + SecretSpecSettings secretSettings; + fixture.configure(secretSettings); + + FileTransferSettings transferSettings{secretSettings}; + transferSettings.secretSpecNetrcFile = "NIX_NETRC"; + EXPECT_THROW(transferSettings.getNetrcFile(), Error); +} + +#endif + } // namespace nix diff --git a/src/libstore/build/derivation-building-goal.cc b/src/libstore/build/derivation-building-goal.cc index 621e28eb553b..cc7b0a2595ef 100644 --- a/src/libstore/build/derivation-building-goal.cc +++ b/src/libstore/build/derivation-building-goal.cc @@ -911,7 +911,7 @@ Goal::Co DerivationBuildingGoal::buildLocally( * daemon thread. Ideally we should reuse the same * Worker to share scheduling state. */ - Worker freshWorker{goal.worker.store, goal.worker.evalStore}; + Worker freshWorker{goal.worker.store, goal.worker.evalStore, NotTrusted}; auto builder = makeRestrictedBuilder(freshWorker, context); daemon::processConnection( store, std::move(from), std::move(to), NotTrusted, recursiveFlag, builder.get_ptr()); @@ -954,6 +954,7 @@ Goal::Co DerivationBuildingGoal::buildLocally( .inputPaths = inputPaths, .initialOutputs = initialOutputs, .buildMode = buildMode, + .requestTrusted = worker.requestTrusted, .defaultPathsInChroot = std::move(defaultPathsInChroot), .systemFeatures = worker.store.config.systemFeatures.get(), .desugaredEnv = std::move(desugaredEnv), @@ -1174,7 +1175,9 @@ HookReply DerivationBuildingGoal::tryBuildHook(const DerivationOptions( - worker.settings.buildHook, std::chrono::milliseconds(worker.settings.buildHookKillTimeout)); + worker.settings.buildHook, + std::chrono::milliseconds(worker.settings.buildHookKillTimeout), + worker.requestTrusted); try { diff --git a/src/libstore/build/worker.cc b/src/libstore/build/worker.cc index 6ff1ef12f745..d1e1c24fa8e7 100644 --- a/src/libstore/build/worker.cc +++ b/src/libstore/build/worker.cc @@ -16,7 +16,7 @@ namespace nix { -Worker::Worker(Store & store, Store & evalStore) +Worker::Worker(Store & store, Store & evalStore, TrustedFlag requestTrusted) /* Can't use make_ref, because the constructor is private. */ : wakerState(ref(new Waker{})) , act(*logger, actRealise) @@ -27,6 +27,7 @@ Worker::Worker(Store & store, Store & evalStore) #endif , store(store) , evalStore(evalStore) + , requestTrusted(requestTrusted) , settings(nix::settings.getWorkerSettings()) , getSubstituters{[] { return nix::settings.getWorkerSettings().useSubstitutes ? getDefaultSubstituters() : std::list>{}; diff --git a/src/libstore/builtins/fetchurl.cc b/src/libstore/builtins/fetchurl.cc index 30d1c9a6d95e..6fe5368cddc4 100644 --- a/src/libstore/builtins/fetchurl.cc +++ b/src/libstore/builtins/fetchurl.cc @@ -12,9 +12,10 @@ static void builtinFetchurl(const BuiltinBuilderContext & ctx) /* Make the host's netrc data available. Too bad curl requires this to be stored in a file. It would be nice if we could just pass a pointer to the data. */ - if (ctx.netrcData != "") { + if (ctx.netrcData) { + fileTransferSettings.secretSpecNetrcFile = ""; fileTransferSettings.netrcFile = ctx.tmpDirInSandbox / "netrc"; - writeFile(fileTransferSettings.netrcFile.get(), ctx.netrcData, 0600); + writeFile(fileTransferSettings.netrcFile.get(), *ctx.netrcData, 0600); } auto caFilePath = ctx.tmpDirInSandbox / "ca-certificates.crt"; diff --git a/src/libstore/daemon.cc b/src/libstore/daemon.cc index 065146876aa6..ceb10323f07a 100644 --- a/src/libstore/daemon.cc +++ b/src/libstore/daemon.cc @@ -298,6 +298,7 @@ struct ClientSettings || (name == "builders" && value == "")) { settings.set(name, value); fileTransferSettings.set(name, value); + secretSpecSettings.set(name, value); } else if (setSubstituters(settings.getWorkerSettings().substituters)) ; else @@ -1133,7 +1134,7 @@ void processConnection( #endif if (!builder) - builder = store->getBuilder(); + builder = store->getBuilder(nullptr, trusted); /* Exchange the greeting. */ WorkerProto::Version localVersion; diff --git a/src/libstore/filetransfer.cc b/src/libstore/filetransfer.cc index 374a39996516..36c15ae3e5ec 100644 --- a/src/libstore/filetransfer.cc +++ b/src/libstore/filetransfer.cc @@ -99,7 +99,8 @@ std::optional FileTransferSettings::getDefaultSSLCertFile void FileTransferSettings::anchor() {} -FileTransferSettings::FileTransferSettings() +FileTransferSettings::FileTransferSettings(SecretSpecSettings & secretSpecSettings) + : secretSpecSettings(secretSpecSettings) { std::optional sslOverride = getEnvOs(OS_STR("NIX_SSL_CERT_FILE")) @@ -112,6 +113,13 @@ FileTransferSettings::FileTransferSettings() caFile = *sslOverride; } +AbsolutePath FileTransferSettings::getNetrcFile() const +{ + if (!secretSpecNetrcFile.get().empty()) + return secretSpecSettings.getPathSecret(secretSpecNetrcFile.get()); + return netrcFile.get(); +} + FileTransferSettings fileTransferSettings; static GlobalConfig::Register rFileTransferSettings(&fileTransferSettings); @@ -213,6 +221,11 @@ struct curlFileTransfer : public FileTransfer curl_off_t writtenToSink = 0; + /* Resolved once during construction rather than in init(), which runs + on the download thread where a failure to resolve a SecretSpec-backed + netrc file would take down every other transfer as well. */ + std::optional netrcFile; + std::chrono::steady_clock::time_point startTime = std::chrono::steady_clock::now(); inline static const std::set successfulStatuses{ @@ -276,6 +289,9 @@ struct curlFileTransfer : public FileTransfer { result.urls.push_back(request.uri.to_string()); + if (request.uri.scheme() == "http" || request.uri.scheme() == "https") + netrcFile = fileTransfer.settings.getNetrcFile(); + if (!request.expectedETag.empty()) appendHeaders("If-None-Match: " + request.expectedETag); if (!request.mimeType.empty()) @@ -675,10 +691,12 @@ struct curlFileTransfer : public FileTransfer curl_easy_setopt(req, CURLOPT_LOW_SPEED_LIMIT, 1L); curl_easy_setopt(req, CURLOPT_LOW_SPEED_TIME, fileTransfer.settings.stalledDownloadTimeout.get()); - /* If no file exist in the specified path, curl continues to work - anyway as if netrc support was disabled. */ - curl_easy_setopt(req, CURLOPT_NETRC_FILE, fileTransfer.settings.netrcFile.get().string().c_str()); - curl_easy_setopt(req, CURLOPT_NETRC, CURL_NETRC_OPTIONAL); + if (netrcFile) { + /* If no file exists at the specified path, curl continues to + work as if netrc support was disabled. */ + curl_easy_setopt(req, CURLOPT_NETRC_FILE, netrcFile->string().c_str()); + curl_easy_setopt(req, CURLOPT_NETRC, CURL_NETRC_OPTIONAL); + } if (writtenToSink) curl_easy_setopt(req, CURLOPT_RESUME_FROM_LARGE, writtenToSink); diff --git a/src/libstore/freebsd/build/freebsd-derivation-builder.cc b/src/libstore/freebsd/build/freebsd-derivation-builder.cc index f46ebf55ed91..2e9f2a3c26b2 100644 --- a/src/libstore/freebsd/build/freebsd-derivation-builder.cc +++ b/src/libstore/freebsd/build/freebsd-derivation-builder.cc @@ -364,6 +364,7 @@ void ChrootFreeBSDDerivationBuilder::startChild() int jid; RunChildArgs args{ + .netrcData = preResolveNetrcData(), #if NIX_WITH_AWS_AUTH .awsCredentials = preResolveAwsCredentials(), #endif diff --git a/src/libstore/include/nix/store/build/derivation-builder.hh b/src/libstore/include/nix/store/build/derivation-builder.hh index 088644a3eb32..838e4f0c6d32 100644 --- a/src/libstore/include/nix/store/build/derivation-builder.hh +++ b/src/libstore/include/nix/store/build/derivation-builder.hh @@ -103,6 +103,9 @@ struct DerivationBuilderParams const BuildMode & buildMode; + /** Trust level of the client that requested this build. */ + const TrustedFlag requestTrusted; + /** * Extra paths we want to be in the chroot, regardless of the * derivation we are building. diff --git a/src/libstore/include/nix/store/build/worker.hh b/src/libstore/include/nix/store/build/worker.hh index f30a3146be4e..c198178e9a13 100644 --- a/src/libstore/include/nix/store/build/worker.hh +++ b/src/libstore/include/nix/store/build/worker.hh @@ -75,9 +75,10 @@ struct HookInstance; class LocalBuilder : public Builder { public: - LocalBuilder(ref store, ref evalStore) + LocalBuilder(ref store, ref evalStore, TrustedFlag requestTrusted) : store(store) - , evalStore(evalStore) {}; + , evalStore(evalStore) + , requestTrusted(requestTrusted) {}; /* Builder interface — see `Builder` for documentation. */ @@ -95,11 +96,12 @@ private: */ inline std::shared_ptr getWorker() { - return std::make_shared(*store, *evalStore); + return std::make_shared(*store, *evalStore, requestTrusted); } ref store; ref evalStore; + TrustedFlag requestTrusted; }; /** @@ -235,6 +237,9 @@ public: Store & store; Store & evalStore; + /** Trust level of the client that requested this build. */ + const TrustedFlag requestTrusted; + const WorkerSettings & settings; /** @@ -269,7 +274,7 @@ public: */ bool tryBuildHook = true; - Worker(Store & store, Store & evalStore); + Worker(Store & store, Store & evalStore, TrustedFlag requestTrusted = Trusted); ~Worker(); /** diff --git a/src/libstore/include/nix/store/builtins.hh b/src/libstore/include/nix/store/builtins.hh index e2caba3f1839..d016cbc3dd3a 100644 --- a/src/libstore/include/nix/store/builtins.hh +++ b/src/libstore/include/nix/store/builtins.hh @@ -4,6 +4,8 @@ #include "nix/store/derivations.hh" #include "nix/store/config.hh" +#include + #if NIX_WITH_AWS_AUTH # include "nix/store/aws-creds.hh" #endif @@ -14,7 +16,7 @@ struct BuiltinBuilderContext { const BasicDerivation & drv; std::map outputs; - std::string netrcData; + std::optional netrcData; std::string caFileData; Strings hashedMirrors; std::filesystem::path tmpDirInSandbox; diff --git a/src/libstore/include/nix/store/filetransfer.hh b/src/libstore/include/nix/store/filetransfer.hh index 774a4e1403b3..a63a23c4fdc8 100644 --- a/src/libstore/include/nix/store/filetransfer.hh +++ b/src/libstore/include/nix/store/filetransfer.hh @@ -14,6 +14,7 @@ #include "nix/util/url.hh" #include "nix/store/config.hh" +#include "nix/store/secretspec-settings.hh" #if NIX_WITH_AWS_AUTH # include "nix/store/aws-creds.hh" #endif @@ -29,8 +30,10 @@ class FileTransferSettings : public Config void anchor() override; + SecretSpecSettings & secretSpecSettings; + public: - FileTransferSettings(); + explicit FileTransferSettings(SecretSpecSettings & secretSpecSettings = nix::secretSpecSettings); Setting enableHttp2{this, true, "http2", "Whether to enable HTTP/2 support."}; @@ -190,7 +193,40 @@ public: > This must be an absolute path, and `~` is not resolved. For > example, `~/.netrc` won't resolve to your home directory's > `.netrc`. - )"}; + )", + {}, + true, + std::nullopt, + FlakeConfigSetting::Forbidden}; + + Setting secretSpecNetrcFile{ + this, + "", + "secretspec-netrc-file", + R"( + Name of a SecretSpec secret containing a complete `netrc` file. + + The secret must be declared with `as_path = true`. When set, this + takes precedence over [`netrc-file`](#conf-netrc-file), and Nix keeps + the materialized file alive for the lifetime of the process. Nix's + bundled SecretSpec manifest provides the conventional `NIX_NETRC` + declaration. + + Only the secret name is stored in the Nix configuration and displayed + by `nix config show`. + + When configured on a multi-user daemon, this is a daemon-wide + credential source rather than a per-user one. Users allowed to request + builds can cause matching entries to be used by HTTP(S) transfers, + including the `builtin:fetchurl` builder. Only include credentials + intended to be shared across that trust domain. + )", + {}, + true, + std::nullopt, + FlakeConfigSetting::Forbidden}; + + AbsolutePath getNetrcFile() const; Setting> caFile{ this, diff --git a/src/libstore/include/nix/store/legacy-ssh-store.hh b/src/libstore/include/nix/store/legacy-ssh-store.hh index bf1532b8ba25..52d345c5d5bb 100644 --- a/src/libstore/include/nix/store/legacy-ssh-store.hh +++ b/src/libstore/include/nix/store/legacy-ssh-store.hh @@ -139,7 +139,7 @@ public: public: - ref getBuilder(std::shared_ptr evalStore) override; + ref getBuilder(std::shared_ptr evalStore, TrustedFlag requestTrusted) override; ref getFSAccessor(bool requireValidPath) override { diff --git a/src/libstore/include/nix/store/local-settings.hh b/src/libstore/include/nix/store/local-settings.hh index 46186fd6ae1b..a1524c20a4da 100644 --- a/src/libstore/include/nix/store/local-settings.hh +++ b/src/libstore/include/nix/store/local-settings.hh @@ -636,7 +636,38 @@ public: )", {}, // aliases true, // document default - Xp::ConfigurableImpureEnv}; + Xp::ConfigurableImpureEnv, + FlakeConfigSetting::Forbidden}; + + Setting secretSpecImpureEnv{ + this, + {}, + "secretspec-impure-env", + R"( + A list of `environment-variable=secret-name` mappings for fixed-output + derivations using `impureEnvVars`. + + Nix resolves a mapped SecretSpec value lazily, only when a derivation + requests that environment variable. Literal values in + [`impure-env`](#conf-impure-env) take precedence over an equally named + SecretSpec mapping. + + The resolved secrets must contain inline values, not `as_path` + values. Only environment-variable and secret names are stored in the + Nix configuration and displayed by `nix config show`. + + Resolution happens in the process that runs the build, which for a + daemon-backed store is the daemon. See + [`secretspec-file`](#conf-secretspec-file) for what that implies for + manifest selection and provider choice. The bundled manifest declares + `BUILD_TOKEN` for the conventional + `environment-variable=BUILD_TOKEN` case; other secret names require a + custom manifest. + )", + {}, + true, + Xp::ConfigurableImpureEnv, + FlakeConfigSetting::Forbidden}; Setting hashedMirrors{ this, diff --git a/src/libstore/include/nix/store/meson.build b/src/libstore/include/nix/store/meson.build index 1b96b50accf8..822424f5fe6a 100644 --- a/src/libstore/include/nix/store/meson.build +++ b/src/libstore/include/nix/store/meson.build @@ -85,6 +85,7 @@ headers = [ config_pub_h ] + files( 'restricted-store.hh', 's3-binary-cache-store.hh', 's3-url.hh', + 'secretspec-settings.hh', 'serve-protocol-connection.hh', 'serve-protocol-impl.hh', 'serve-protocol.hh', diff --git a/src/libstore/include/nix/store/remote-store.hh b/src/libstore/include/nix/store/remote-store.hh index b357b78f9421..9937cd72338b 100644 --- a/src/libstore/include/nix/store/remote-store.hh +++ b/src/libstore/include/nix/store/remote-store.hh @@ -126,7 +126,7 @@ public: void queryRealisationUncached( const DrvOutput &, Callback> callback) noexcept override; - ref getBuilder(std::shared_ptr evalStore) override; + ref getBuilder(std::shared_ptr evalStore, TrustedFlag requestTrusted) override; void addTempRoot(const StorePath & path) override; @@ -179,7 +179,7 @@ protected: ref> connections; - virtual void setOptions(Connection & conn); + virtual void setOptions(Connection & conn, TrustedFlag requestTrusted = Trusted); void setOptions() override; @@ -188,6 +188,7 @@ protected: ConnectionHandle getConnection(); friend struct ConnectionHandle; + friend struct RemoteBuilder; virtual ref getFSAccessor(bool requireValidPath = true) override; diff --git a/src/libstore/include/nix/store/secretspec-settings.hh b/src/libstore/include/nix/store/secretspec-settings.hh new file mode 100644 index 000000000000..df43ace8e251 --- /dev/null +++ b/src/libstore/include/nix/store/secretspec-settings.hh @@ -0,0 +1,136 @@ +#pragma once +///@file + +#include "nix/store/config.hh" +#include "nix/util/configuration.hh" +#include "nix/util/sync.hh" + +#include +#include +#include +#include + +namespace nix { + +struct SecretSpecCache; + +/** The settings that together select one SecretSpec resolution. */ +struct SecretSpecRequest +{ + std::string file, provider, profile, scope; + + auto operator<=>(const SecretSpecRequest &) const = default; +}; + +/** Shared SecretSpec resolution context for Nix credential consumers. */ +struct SecretSpecSettings : public virtual Config +{ +private: + static std::string defaultFile(); + +public: + SecretSpecSettings(); + ~SecretSpecSettings(); + + Setting file{ + this, + defaultFile(), + "secretspec-file", + R"( + Path to the `secretspec.toml` used by SecretSpec-backed Nix + credential settings. + + The default is a manifest bundled with Nix. It declares the optional + `GITHUB_TOKEN`, `GITLAB_TOKEN`, `SOURCEHUT_TOKEN`, `NIX_NETRC`, and + `BUILD_TOKEN` secrets used by the examples for the SecretSpec-backed + settings. Set this option to a custom manifest to use other secret + names or declarations. Set it to an empty value to make SecretSpec + search for a manifest from the current working directory in the same + way as its CLI and SDKs. + + Credentials that a build needs, such as + [`secretspec-impure-env`](#conf-secretspec-impure-env), are resolved + by whichever process runs the build. When that is the Nix daemon, all + `secretspec-*` settings are forwarded to it and resolution happens in + the daemon's context. If this setting is empty, manifest discovery + starts from the daemon's working directory rather than yours, and + providers that require a user session, such as the OS keyring, are not + reachable. When overriding the bundled manifest for a daemon, set an + absolute path here and choose a provider the daemon can read. + )", + {}, + true, + std::nullopt, + FlakeConfigSetting::Forbidden}; + + Setting provider{ + this, + "", + "secretspec-provider", + R"( + Optional SecretSpec provider name or URI used by SecretSpec-backed + Nix credential settings. + + If unset, SecretSpec uses `SECRETSPEC_PROVIDER`, its global + configuration, and the manifest's provider configuration in their + normal precedence order. + )", + {}, + true, + std::nullopt, + FlakeConfigSetting::Forbidden}; + + Setting profile{ + this, + "", + "secretspec-profile", + R"( + Optional SecretSpec profile used by SecretSpec-backed Nix credential + settings. + + If unset, SecretSpec uses `SECRETSPEC_PROFILE`, its global default, + or the `default` profile. + )", + {}, + true, + std::nullopt, + FlakeConfigSetting::Forbidden}; + + Setting scope{ + this, + "", + "secretspec-scope", + R"( + Optional SecretSpec scope used by SecretSpec-backed Nix credential + settings. + + A dedicated scope containing only credentials used by Nix limits the + secrets returned across the FFI boundary and prevents unrelated + required secrets from blocking credential resolution. + )", + {}, + true, + std::nullopt, + FlakeConfigSetting::Forbidden}; + + /** Resolve an inline secret, rejecting secrets declared with `as_path`. */ + std::string getInlineSecret(const std::string & name) const; + + /** Resolve an `as_path` secret and retain ownership of its temporary file. */ + AbsolutePath getPathSecret(const std::string & name) const; + +private: + std::shared_ptr getResolvedSecrets() const; + + /* Keep every resolved context alive so an in-flight user never observes an + `as_path` file being deleted after a configuration change. */ + mutable Sync>> _caches; + + /* Serializes resolution so that a request is resolved only once, without + blocking cache lookups for the duration of the resolution. */ + mutable std::mutex _resolveMutex; +}; + +extern SecretSpecSettings secretSpecSettings; + +} // namespace nix diff --git a/src/libstore/include/nix/store/store-api.hh b/src/libstore/include/nix/store/store-api.hh index 06122cc746cd..cf4e3ee505a7 100644 --- a/src/libstore/include/nix/store/store-api.hh +++ b/src/libstore/include/nix/store/store-api.hh @@ -465,13 +465,17 @@ public: virtual ~Store() {} /** - * Get a `Builder` for this store. + * Get a `Builder` for this store and request trust level. * * @param evalStore If provided and different from this store, * derivation files will be copied from the eval store to this * store before building. + * + * Direct in-process callers are trusted by default. Daemon connections + * must pass the trust level established for the client so credentials are + * not made available to builds requested by untrusted users. */ - virtual ref getBuilder(std::shared_ptr evalStore = nullptr); + virtual ref getBuilder(std::shared_ptr evalStore = nullptr, TrustedFlag requestTrusted = Trusted); /** * Follow symlinks until we end up with a path in the Nix store. diff --git a/src/libstore/legacy-ssh-store.cc b/src/libstore/legacy-ssh-store.cc index 1765c5af747a..8a4472f137eb 100644 --- a/src/libstore/legacy-ssh-store.cc +++ b/src/libstore/legacy-ssh-store.cc @@ -387,8 +387,9 @@ LegacySSHBuilder::buildPathsWithResults(const std::vector & reqs, B return results; } -ref LegacySSHStore::getBuilder(std::shared_ptr evalStore) +ref LegacySSHStore::getBuilder(std::shared_ptr evalStore, TrustedFlag requestTrusted) { + (void) requestTrusted; if (evalStore && evalStore.get() != this) throw Error("building on an SSH store is incompatible with '--eval-store'"); return make_ref( diff --git a/src/libstore/linux/build/linux-derivation-builder.cc b/src/libstore/linux/build/linux-derivation-builder.cc index 525b3ffa1632..3e3ffdf10935 100644 --- a/src/libstore/linux/build/linux-derivation-builder.cc +++ b/src/libstore/linux/build/linux-derivation-builder.cc @@ -506,6 +506,7 @@ void ChrootLinuxDerivationBuilder::prepareSandbox() void ChrootLinuxDerivationBuilder::startChild() { RunChildArgs args{ + .netrcData = preResolveNetrcData(), #if NIX_WITH_AWS_AUTH .awsCredentials = preResolveAwsCredentials(), #endif diff --git a/src/libstore/meson.build b/src/libstore/meson.build index 5b3afcb45555..b66033629853 100644 --- a/src/libstore/meson.build +++ b/src/libstore/meson.build @@ -130,6 +130,19 @@ curl = dependency('libcurl', 'curl', version : '>= 8.17.0') deps_private += curl +secretspec_ffi = dependency( + 'secretspec_ffi', + version : '>= 0.19.0', + required : get_option('secretspec'), +) +if secretspec_ffi.found() + deps_private += secretspec_ffi + # Checked against secretspec_abi_version() at run time, so that a library + # swapped underneath us is diagnosed instead of silently misinterpreted. + configdata_priv.set_quoted('SECRETSPEC_FFI_VERSION', secretspec_ffi.version()) +endif +configdata_pub.set('NIX_WITH_SECRETSPEC', secretspec_ffi.found().to_int()) + # seccomp only makes sense on Linux is_linux = host_machine.system() == 'linux' seccomp_required = get_option('seccomp-sandboxing') @@ -194,6 +207,7 @@ prefix = get_option('prefix') # it is already an absolute path (which is the default for store-dir, localstatedir, and log-dir). path_opts = [ # Meson built-ins. + 'datadir', 'mandir', 'libdir', 'includedir', @@ -231,6 +245,10 @@ endif # by joining it with prefix, unless it was already an absolute path # (which is the default for store-dir, localstatedir, and log-dir). configdata_priv.set_quoted('NIX_STORE_DIR', store_dir) +configdata_priv.set_quoted( + 'NIX_SECRETSPEC_FILE', + datadir / 'nix/secretspec.toml', +) # On Windows, NIX_STATE_DIR, NIX_LOG_DIR, and NIX_CONF_DIR are determined at # runtime using the Windows known folders API (FOLDERID_ProgramData), so we # don't define them at compile time. @@ -327,6 +345,7 @@ sources = files( 'restricted-store.cc', 's3-binary-cache-store.cc', 's3-url.cc', + 'secretspec-settings.cc', 'serve-protocol-connection.cc', 'serve-protocol.cc', 'sqlite.cc', @@ -384,12 +403,14 @@ this_library = library( ) install_headers(headers, subdir : 'nix/store', preserve_path : true) +install_data('secretspec.toml', install_dir : get_option('datadir') / 'nix') libraries_private = [] extra_pkg_config_variables = { 'storedir' : get_option('store-dir'), 'localstatedir' : get_option('localstatedir'), + 'with_secretspec' : secretspec_ffi.found() ? 'true' : 'false', } subdir('nix-meson-build-support/export') diff --git a/src/libstore/meson.options b/src/libstore/meson.options index c822133df46e..06de2153e2dc 100644 --- a/src/libstore/meson.options +++ b/src/libstore/meson.options @@ -39,3 +39,9 @@ option( type : 'feature', description : 'build support for AWS authentication with S3', ) + +option( + 'secretspec', + type : 'feature', + description : 'build support for resolving credentials through SecretSpec', +) diff --git a/src/libstore/package.nix b/src/libstore/package.nix index 69f79153ee15..28592415ab50 100644 --- a/src/libstore/package.nix +++ b/src/libstore/package.nix @@ -15,6 +15,7 @@ libseccomp, nlohmann_json, sqlite, + secretspec-ffi, cmake, # for resolving aws-crt-cpp dep busybox-sandbox-shell ? null, @@ -38,6 +39,10 @@ withAWS ? # Default is this way because there have been issues building this dependency (lib.meta.availableOn stdenv.hostPlatform aws-c-common), + + # secretspec-ffi is a Rust library that is not built for every platform Nix + # supports, so credential resolution through SecretSpec is optional. + withSecretSpec ? stdenv.hostPlatform.isUnix, }: let @@ -56,6 +61,7 @@ mkMesonLibrary (finalAttrs: { ./.version ./meson.build ./meson.options + ./secretspec.toml ./include/nix/store/meson.build ./linux/meson.build ./linux/include/nix/store/meson.build @@ -81,7 +87,8 @@ mkMesonLibrary (finalAttrs: { ] ++ lib.optional stdenv.hostPlatform.isLinux libseccomp ++ lib.optional stdenv.hostPlatform.isFreeBSD freebsd.libjail - ++ lib.optional withAWS aws-crt-cpp; + ++ lib.optional withAWS aws-crt-cpp + ++ lib.optional withSecretSpec secretspec-ffi; propagatedBuildInputs = [ nix-util @@ -92,6 +99,7 @@ mkMesonLibrary (finalAttrs: { (lib.mesonEnable "seccomp-sandboxing" stdenv.hostPlatform.isLinux) (lib.mesonBool "embedded-sandbox-shell" embeddedSandboxShell) (lib.mesonEnable "s3-aws-auth" withAWS) + (lib.mesonEnable "secretspec" withSecretSpec) ] ++ lib.optionals withSandboxShell [ (lib.mesonOption "sandbox-shell" sandboxShell) diff --git a/src/libstore/remote-store.cc b/src/libstore/remote-store.cc index 92a0dd31d827..9b541d3c0b6b 100644 --- a/src/libstore/remote-store.cc +++ b/src/libstore/remote-store.cc @@ -129,7 +129,7 @@ void RemoteStore::initConnection(Connection & conn) setOptions(conn); } -void RemoteStore::setOptions(Connection & conn) +void RemoteStore::setOptions(Connection & conn, TrustedFlag requestTrusted) { conn.to << WorkerProto::Op::SetOptions << settings.keepFailed << settings.getWorkerSettings().keepGoing << settings.getWorkerSettings().tryFallback << verbosity << settings.getWorkerSettings().maxBuildJobs @@ -141,6 +141,19 @@ void RemoteStore::setOptions(Connection & conn) std::map overrides; settings.getSettings(overrides, true); // libstore settings fileTransferSettings.getSettings(overrides, true); + secretSpecSettings.getSettings(overrides, true); + if (!requestTrusted) { + /* A trusted connection to another daemon must not turn an untrusted + originating request into permission to consume that daemon's + SecretSpec-backed impure environment. */ + overrides.insert_or_assign( + settings.getLocalSettings().secretSpecImpureEnv.name, + Config::SettingInfo{ + .value = "", + .description = settings.getLocalSettings().secretSpecImpureEnv.description, + .flakeConfigSetting = FlakeConfigSetting::Forbidden, + }); + } overrides.erase(settings.keepFailed.name); overrides.erase(settings.getWorkerSettings().keepGoing.name); overrides.erase(settings.getWorkerSettings().tryFallback.name); @@ -579,10 +592,18 @@ struct RemoteBuilder : Builder { ref store; std::shared_ptr evalStore; + TrustedFlag requestTrusted; + + void setOptions(RemoteStore::Connection & conn) + { + if (!conn.protoVersion.features.contains(WorkerProto::featureDisableSetOptions)) + store->setOptions(conn, requestTrusted); + } - RemoteBuilder(ref store, std::shared_ptr evalStore) + RemoteBuilder(ref store, std::shared_ptr evalStore, TrustedFlag requestTrusted) : store(store) , evalStore(std::move(evalStore)) + , requestTrusted(requestTrusted) { } @@ -636,6 +657,7 @@ void RemoteBuilder::buildPaths(const std::vector & drvPaths, BuildM { copyDrvsFromEvalStore(drvPaths); auto conn(store->getConnection()); + setOptions(*conn); conn->to << WorkerProto::Op::BuildPaths; WorkerProto::write(*store, *conn, drvPaths); conn->to << buildMode; @@ -650,6 +672,7 @@ RemoteBuilder::buildPathsWithResults(const std::vector & paths, Bui std::optional conn_(store->getConnection()); auto & conn = *conn_; + setOptions(*conn); if (conn->protoVersion >= WorkerProto::Version{.number = {1, 34}}) { conn->to << WorkerProto::Op::BuildPathsWithResults; @@ -719,6 +742,7 @@ RemoteBuilder::buildPathsWithResults(const std::vector & paths, Bui BuildResult RemoteBuilder::buildDerivation(const StorePath & drvPath, const BasicDerivation & drv, BuildMode buildMode) { auto conn(store->getConnection()); + setOptions(*conn); conn->putBuildDerivationRequest(*store, &conn.daemonException, drvPath, drv, buildMode); conn.processStderr(); return WorkerProto::Serialise::read(*store, *conn); @@ -738,10 +762,12 @@ void RemoteBuilder::repairPath(const StorePath & path) throw Unsupported("operation 'repairPath' is not supported by store '%s'", store->config.getHumanReadableURI()); } -ref RemoteStore::getBuilder(std::shared_ptr evalStore) +ref RemoteStore::getBuilder(std::shared_ptr evalStore, TrustedFlag requestTrusted) { return make_ref( - ref(std::dynamic_pointer_cast(shared_from_this())), std::move(evalStore)); + ref(std::dynamic_pointer_cast(shared_from_this())), + std::move(evalStore), + requestTrusted); } void RemoteStore::addTempRoot(const StorePath & path) diff --git a/src/libstore/restricted-store.cc b/src/libstore/restricted-store.cc index 68d38525eb17..7c324de5cdd3 100644 --- a/src/libstore/restricted-store.cc +++ b/src/libstore/restricted-store.cc @@ -162,7 +162,7 @@ struct RestrictedStore : public virtual IndirectRootStore, public virtual GcStor return NotTrusted; } - ref getBuilder(std::shared_ptr evalStore) override + ref getBuilder(std::shared_ptr evalStore, TrustedFlag requestTrusted) override { unreachable(); } diff --git a/src/libstore/secretspec-settings.cc b/src/libstore/secretspec-settings.cc new file mode 100644 index 000000000000..9d903fa83ce0 --- /dev/null +++ b/src/libstore/secretspec-settings.cc @@ -0,0 +1,287 @@ +#include "nix/store/secretspec-settings.hh" + +#include "nix/util/config-global.hh" +#include "nix/util/error.hh" +#include "nix/util/file-system.hh" +#include "nix/util/strings.hh" + +#include +#include + +#include + +#include "store-config-private.hh" + +#if NIX_WITH_SECRETSPEC +# include +#endif + +namespace nix { + +std::string SecretSpecSettings::defaultFile() +{ + return NIX_SECRETSPEC_FILE; +} + +/* A resolve response carries secret values, so this file deliberately does not + use the accessors from `json-utils.hh`: those quote the offending JSON in + their error messages, which would put secrets into diagnostics and logs. */ + +namespace { + +struct ResolvedSecret +{ + bool asPath; + std::string value; +}; + +#if !NIX_WITH_SECRETSPEC + +std::string resolveWithSecretSpec(const std::string &) +{ + throw Error("Nix was built without SecretSpec support, so it cannot resolve SecretSpec credentials"); +} + +#else + +/** Parse the `major.minor` prefix of a semver ABI version. */ +std::pair parseAbiVersion(const std::string & version) +{ + auto components = tokenizeString>(version, "."); + if (components.size() < 2) + throw Error("cannot parse secretspec-ffi ABI version '%s'", version); + try { + return {std::stoul(components[0]), std::stoul(components[1])}; + } catch (std::exception &) { + throw Error("cannot parse secretspec-ffi ABI version '%s'", version); + } +} + +void checkAbiVersion() +{ + /* The returned pointer is owned by the library and must not be freed. */ + auto version = secretspec_abi_version(); + if (!version || version[0] == '\0') + throw Error("secretspec-ffi returned an empty ABI version"); + + auto [runtimeMajor, runtimeMinor] = parseAbiVersion(version); + auto [buildMajor, buildMinor] = parseAbiVersion(SECRETSPEC_FFI_VERSION); + + /* Semver: before 1.0 every minor release may break compatibility, after it + only majors do. */ + bool compatible = + runtimeMajor == buildMajor && (buildMajor == 0 ? runtimeMinor == buildMinor : runtimeMinor >= buildMinor); + + if (!compatible) + throw Error( + "secretspec-ffi reports ABI version '%s', which is incompatible with version '%s' that Nix was built against", + version, + SECRETSPEC_FFI_VERSION); +} + +std::string resolveWithSecretSpec(const std::string & request) +{ + static const bool checkedAbiVersion = [] { + checkAbiVersion(); + return true; + }(); + (void) checkedAbiVersion; + + auto response = secretspec_resolve(request.c_str()); + if (!response) + throw Error("secretspec-ffi returned a null response while resolving Nix credentials"); + std::unique_ptr ownedResponse(response, &secretspec_free); + return ownedResponse.get(); +} + +#endif + +std::string getString(const nlohmann::json & object, const char * field, const std::string & fallback = "") +{ + if (auto i = object.find(field); i != object.end() && i->is_string()) + return i->get(); + return fallback; +} + +} // namespace + +struct SecretSpecCache +{ + std::map secrets; + /** Per-secret validation failures, reported only when that secret is requested. */ + std::map errors; + std::vector pathOwners; +}; + +SecretSpecSettings secretSpecSettings; + +static GlobalConfig::Register rSecretSpecSettings(&secretSpecSettings); + +SecretSpecSettings::SecretSpecSettings() {} + +SecretSpecSettings::~SecretSpecSettings() = default; + +static std::shared_ptr resolveSecrets(const SecretSpecRequest & request) +{ + nlohmann::json requestJson{ + {"mode", "resolve"}, + {"no_values", false}, + {"reason", "Nix credential resolution"}, + }; + if (!request.file.empty()) + requestJson["path"] = request.file; + if (!request.provider.empty()) + requestJson["provider"] = request.provider; + if (!request.profile.empty()) + requestJson["profile"] = request.profile; + if (!request.scope.empty()) + requestJson["scope"] = request.scope; + + auto response = resolveWithSecretSpec(requestJson.dump()); + nlohmann::json envelope; + try { + envelope = nlohmann::json::parse(response); + } catch (const nlohmann::json::exception &) { + throw Error("secretspec-ffi returned invalid JSON while resolving Nix credentials"); + } + + if (!envelope.is_object() || !envelope.contains("ok") || !envelope["ok"].is_boolean()) + throw Error("secretspec-ffi returned an invalid response envelope while resolving Nix credentials"); + + if (!envelope["ok"].get()) { + auto error = envelope.find("error"); + auto kind = error != envelope.end() && error->is_object() ? getString(*error, "kind", "unknown") : "unknown"; + auto message = error != envelope.end() && error->is_object() ? getString(*error, "message", "unknown error") + : "unknown error"; + throw Error("SecretSpec failed to resolve Nix credentials (kind: %s): %s", kind, message); + } + + auto responseObject = envelope.find("response"); + if (responseObject == envelope.end() || !responseObject->is_object()) + throw Error("secretspec-ffi returned no resolve response while resolving Nix credentials"); + + auto schemaVersion = responseObject->find("schema_version"); + if (schemaVersion == responseObject->end() || !schemaVersion->is_number_unsigned() + || schemaVersion->get() != 2) + throw Error("secretspec-ffi returned an unsupported resolve response schema"); + + auto missingRequired = responseObject->find("missing_required"); + if (missingRequired == responseObject->end() || !missingRequired->is_array()) + throw Error("secretspec-ffi returned an invalid missing-required list while resolving Nix credentials"); + if (!missingRequired->empty()) { + StringSet names; + for (const auto & item : *missingRequired) { + if (!item.is_string()) + throw Error("secretspec-ffi returned an invalid missing-required list while resolving Nix credentials"); + names.insert(item.get()); + } + throw Error( + "SecretSpec is missing required secrets while resolving Nix credentials: %s", + concatStringsSep(", ", names)); + } + + auto secrets = responseObject->find("secrets"); + if (secrets == responseObject->end() || !secrets->is_object()) + throw Error("secretspec-ffi returned no secrets object while resolving Nix credentials"); + + auto result = std::make_shared(); + + /* A response covers every secret in scope, most of which the caller did not + ask for, so a malformed entry is recorded rather than thrown: it must not + take down the credentials that did resolve. */ + for (const auto & [name, secret] : secrets->items()) { + try { + if (!secret.is_object()) + throw Error("secretspec-ffi returned an invalid value for secret '%s'", name); + + auto asPath = secret.find("as_path"); + if (asPath == secret.end() || !asPath->is_boolean()) + throw Error("secretspec-ffi returned an invalid value for secret '%s'", name); + + if (asPath->get()) { + auto path = secret.find("path"); + if (path == secret.end() || !path->is_string() || path->get_ref().empty()) + throw Error("SecretSpec path secret '%s' did not contain a path", name); + AbsolutePath absolutePath{path->get()}; + result->secrets.emplace(name, ResolvedSecret{.asPath = true, .value = absolutePath.string()}); + result->pathOwners.emplace_back(absolutePath, /* recursive = */ false); + } else { + auto value = secret.find("value"); + if (value == secret.end() || !value->is_string()) + throw Error("SecretSpec inline secret '%s' did not contain a value", name); + result->secrets.emplace(name, ResolvedSecret{.asPath = false, .value = value->get()}); + } + } catch (Error &) { + result->errors.emplace(name, std::current_exception()); + } + } + + return result; +} + +std::shared_ptr SecretSpecSettings::getResolvedSecrets() const +{ + SecretSpecRequest request{ + .file = file.get(), + .provider = provider.get(), + .profile = profile.get(), + .scope = scope.get(), + }; + + auto lookup = [&]() -> std::shared_ptr { + auto caches(_caches.lock()); + if (auto cached = caches->find(request); cached != caches->end()) + return cached->second; + return nullptr; + }; + + if (auto cached = lookup()) + return cached; + + /* `secretspec_resolve()` blocks for as long as the provider takes, which + for an interactive provider means until the user answers a prompt. Hold + a separate lock across it so that cache hits for already-resolved + requests are never blocked by an in-flight resolution, while still + resolving each request only once. */ + std::lock_guard resolveLock(_resolveMutex); + + /* Another thread may have resolved this request while we waited. */ + if (auto cached = lookup()) + return cached; + + auto resolved = resolveSecrets(request); + _caches.lock()->emplace(request, resolved); + return resolved; +} + +static const ResolvedSecret & getSecret(const SecretSpecCache & cache, const std::string & name) +{ + if (auto error = cache.errors.find(name); error != cache.errors.end()) + std::rethrow_exception(error->second); + auto secret = cache.secrets.find(name); + if (secret == cache.secrets.end()) + throw Error("SecretSpec did not resolve configured secret '%s'", name); + return secret->second; +} + +std::string SecretSpecSettings::getInlineSecret(const std::string & name) const +{ + auto resolved = getResolvedSecrets(); + auto & secret = getSecret(*resolved, name); + if (secret.asPath) + throw Error("SecretSpec secret '%s' must not use 'as_path'", name); + if (secret.value.empty()) + throw Error("SecretSpec secret '%s' was empty", name); + return secret.value; +} + +AbsolutePath SecretSpecSettings::getPathSecret(const std::string & name) const +{ + auto resolved = getResolvedSecrets(); + auto & secret = getSecret(*resolved, name); + if (!secret.asPath) + throw Error("SecretSpec secret '%s' must use 'as_path'", name); + return AbsolutePath{secret.value}; +} + +} // namespace nix diff --git a/src/libstore/secretspec.toml b/src/libstore/secretspec.toml new file mode 100644 index 000000000000..6ee9d95edcf6 --- /dev/null +++ b/src/libstore/secretspec.toml @@ -0,0 +1,13 @@ +[project] +name = "nix" +revision = "1.0" + +[profiles.default] +GITHUB_TOKEN = { description = "GitHub access token", required = false } +GITLAB_TOKEN = { description = "GitLab access token", required = false } +SOURCEHUT_TOKEN = { description = "SourceHut access token", required = false } +NIX_NETRC = { description = "Complete netrc file used by Nix", required = false, as_path = true } +BUILD_TOKEN = { description = "Token exposed to a fixed-output derivation", required = false } + +[scopes.nix] +secrets = ["GITHUB_TOKEN", "GITLAB_TOKEN", "SOURCEHUT_TOKEN", "NIX_NETRC", "BUILD_TOKEN"] diff --git a/src/libstore/ssh-store.cc b/src/libstore/ssh-store.cc index 4ecf2780e7b2..1f8ab7094875 100644 --- a/src/libstore/ssh-store.cc +++ b/src/libstore/ssh-store.cc @@ -89,7 +89,7 @@ struct alignas(8) /* Work around ASAN failures on i686-linux. */ SSHMaster master; - void setOptions(RemoteStore::Connection & conn) override { + void setOptions(RemoteStore::Connection & conn, TrustedFlag) override { /* TODO Add a way to explicitly ask for some options to be forwarded. One option: A way to query the daemon for its settings, and then a series of params to SSHStore like diff --git a/src/libstore/store-api.cc b/src/libstore/store-api.cc index 72f288ffa968..5e790a7ab6f2 100644 --- a/src/libstore/store-api.cc +++ b/src/libstore/store-api.cc @@ -149,11 +149,11 @@ std::pair StoreDirConfig::toStorePath(std::string_view pat return {parseStorePath(path.substr(0, slash)), CanonPath{path.substr(slash)}}; } -ref Store::getBuilder(std::shared_ptr evalStore) +ref Store::getBuilder(std::shared_ptr evalStore, TrustedFlag requestTrusted) { auto store = ref(shared_from_this()); auto evalStoreRef = evalStore ? ref(std::move(evalStore)) : store; - return make_ref(store, evalStoreRef); + return make_ref(store, evalStoreRef, requestTrusted); } std::filesystem::path Store::followLinksToStore(std::string_view _path) const diff --git a/src/libstore/unix/build/derivation-builder-impl.hh b/src/libstore/unix/build/derivation-builder-impl.hh index 353840b7673f..1977fec15dd2 100644 --- a/src/libstore/unix/build/derivation-builder-impl.hh +++ b/src/libstore/unix/build/derivation-builder-impl.hh @@ -368,11 +368,15 @@ protected: */ struct RunChildArgs { + std::optional netrcData; #if NIX_WITH_AWS_AUTH std::optional awsCredentials; #endif }; + /** Read builtin:fetchurl netrc data before forking the builder process. */ + std::optional preResolveNetrcData(); + /** * Run the builder's process. */ diff --git a/src/libstore/unix/build/derivation-builder.cc b/src/libstore/unix/build/derivation-builder.cc index 6d1c44742874..a4848a93ec78 100644 --- a/src/libstore/unix/build/derivation-builder.cc +++ b/src/libstore/unix/build/derivation-builder.cc @@ -20,6 +20,7 @@ #include "nix/store/build/derivation-env-desugar.hh" #include "nix/util/terminal.hh" #include "nix/store/filetransfer.hh" +#include "nix/store/secretspec-settings.hh" #include #include @@ -657,9 +658,45 @@ std::optional DerivationBuilderImpl::preResolveAwsCredentials() } #endif +std::optional DerivationBuilderImpl::preResolveNetrcData() +{ + if (drv.isBuiltin() && drv.builder == "builtin:fetchurl") { + auto netrcAppliesTo = [](std::string_view url) { + auto scheme = VerbatimURL{url}.scheme(); + return scheme == "http" || scheme == "https"; + }; + + bool hasNetrcCapableUrl = false; + if (auto url = drv.env.find("url"); url != drv.env.end()) + hasNetrcCapableUrl = netrcAppliesTo(url->second); + + auto out = get(drv.outputs, "out"); + auto fixed = out ? std::get_if(&out->raw) : nullptr; + if (!hasNetrcCapableUrl && fixed && fixed->ca.method.getFileIngestionMethod() == FileIngestionMethod::Flat) { + for (const auto & mirror : localSettings.hashedMirrors.get()) + if (netrcAppliesTo(mirror)) { + hasNetrcCapableUrl = true; + break; + } + } + + if (!hasNetrcCapableUrl) + return std::nullopt; + + if (!fileTransferSettings.secretSpecNetrcFile.get().empty()) + return readFile(fileTransferSettings.getNetrcFile()); + try { + return readFile(fileTransferSettings.netrcFile.get()); + } catch (SystemError &) { + } + } + return std::nullopt; +} + void DerivationBuilderImpl::startChild() { RunChildArgs args{ + .netrcData = preResolveNetrcData(), #if NIX_WITH_AWS_AUTH .awsCredentials = preResolveAwsCredentials(), #endif @@ -773,13 +810,17 @@ void DerivationBuilderImpl::initEnv() already know the cryptographic hash of the output). */ if (!derivationType.isSandboxed()) { auto & impureEnv = localSettings.impureEnv.get(); - if (!impureEnv.empty()) + auto & secretSpecImpureEnv = localSettings.secretSpecImpureEnv.get(); + if (!impureEnv.empty() || !secretSpecImpureEnv.empty()) experimentalFeatureSettings.require(Xp::ConfigurableImpureEnv); for (auto & i : drvOptions.impureEnvVars) { auto envVar = impureEnv.find(i); if (envVar != impureEnv.end()) { env[i] = envVar->second; + } else if (auto secret = secretSpecImpureEnv.find(i); + requestTrusted && secret != secretSpecImpureEnv.end()) { + env[i] = secretSpecSettings.getInlineSecret(secret->second); } else { env[i] = getEnv(i).value_or(""); } @@ -970,6 +1011,7 @@ void DerivationBuilderImpl::runChild(RunChildArgs args) different uid and/or in a sandbox). */ BuiltinBuilderContext ctx{ .drv = drv, + .netrcData = std::move(args.netrcData), .hashedMirrors = settings.getLocalSettings().hashedMirrors, .tmpDirInSandbox = tmpDirInSandbox(), #if NIX_WITH_AWS_AUTH @@ -978,11 +1020,6 @@ void DerivationBuilderImpl::runChild(RunChildArgs args) }; if (drv.isBuiltin() && drv.builder == "builtin:fetchurl") { - try { - ctx.netrcData = readFile(fileTransferSettings.netrcFile.get()); - } catch (SystemError &) { - } - if (auto & caFile = fileTransferSettings.caFile.get()) try { ctx.caFileData = readFile(*caFile); diff --git a/src/libstore/unix/build/hook-instance.cc b/src/libstore/unix/build/hook-instance.cc index ef47b6110893..76dc5e827ef7 100644 --- a/src/libstore/unix/build/hook-instance.cc +++ b/src/libstore/unix/build/hook-instance.cc @@ -1,4 +1,5 @@ #include "nix/util/config-global.hh" +#include "nix/store/globals.hh" #include "nix/store/build/hook-instance.hh" #include "nix/store/build/child.hh" #include "nix/util/strings.hh" @@ -7,7 +8,7 @@ namespace nix { -HookInstance::HookInstance(const Strings & _buildHook, std::chrono::milliseconds timeout) +HookInstance::HookInstance(const Strings & _buildHook, std::chrono::milliseconds timeout, TrustedFlag requestTrusted) { debug("starting build hook '%s'", concatStringsSep(" ", _buildHook)); @@ -84,6 +85,13 @@ HookInstance::HookInstance(const Strings & _buildHook, std::chrono::milliseconds sink = FdSink(toHook.writeSide.get()); std::map settings; globalConfig.getSettings(settings); + /* The standard build hook forwards these settings to remote stores, so + override their daemon-configured mapping for untrusted requests. */ + if (!requestTrusted) { + if (auto setting = settings.find(nix::settings.getLocalSettings().secretSpecImpureEnv.name); + setting != settings.end()) + setting->second.value.clear(); + } for (auto & setting : settings) sink << 1 << setting.first << setting.second.value; sink << 0; diff --git a/src/libstore/unix/include/nix/store/build/hook-instance.hh b/src/libstore/unix/include/nix/store/build/hook-instance.hh index 10d61b4d0b56..acbfc6fc3f5e 100644 --- a/src/libstore/unix/include/nix/store/build/hook-instance.hh +++ b/src/libstore/unix/include/nix/store/build/hook-instance.hh @@ -10,6 +10,8 @@ namespace nix { +enum TrustedFlag : bool; + /** * @note Sometimes this is owned by the `Worker`, and sometimes it is * owned by a `Goal`. This is for efficiency: rather than starting the @@ -50,7 +52,7 @@ struct HookInstance */ std::function onKillChild; - HookInstance(const Strings & buildHook, std::chrono::milliseconds timeout); + HookInstance(const Strings & buildHook, std::chrono::milliseconds timeout, TrustedFlag requestTrusted); ~HookInstance(); }; diff --git a/src/libutil-tests/config.cc b/src/libutil-tests/config.cc index d7b024ead170..72331ab43d13 100644 --- a/src/libutil-tests/config.cc +++ b/src/libutil-tests/config.cc @@ -35,6 +35,20 @@ TEST(Config, getDefinedSetting) ASSERT_NE(iter, settings.end()); ASSERT_EQ(iter->second.value, ""); ASSERT_EQ(iter->second.description, "description\n"); + ASSERT_EQ(iter->second.flakeConfigSetting, FlakeConfigSetting::Allowed); +} + +TEST(Config, getForbiddenFlakeSetting) +{ + Config config; + std::map settings; + Setting setting{ + &config, "", "credential-setting", "description", {}, true, std::nullopt, FlakeConfigSetting::Forbidden}; + + config.getSettings(settings); + ASSERT_EQ(settings.at("credential-setting").flakeConfigSetting, FlakeConfigSetting::Forbidden); + + ASSERT_EQ(config.getFlakeConfigSetting("credential-setting"), FlakeConfigSetting::Forbidden); } TEST(Config, getDefinedOverriddenSettingNotSet) diff --git a/src/libutil/config-global.cc b/src/libutil/config-global.cc index c60d0e4ebc64..673e5c7f700b 100644 --- a/src/libutil/config-global.cc +++ b/src/libutil/config-global.cc @@ -29,6 +29,14 @@ void GlobalConfig::getSettings(std::map & res, bool ov config->getSettings(res, overriddenOnly); } +std::optional GlobalConfig::getFlakeConfigSetting(const std::string & name) const +{ + for (auto & config : configRegistrations()) + if (auto setting = config->getFlakeConfigSetting(name)) + return setting; + return std::nullopt; +} + void GlobalConfig::resetOverridden() { for (auto & config : configRegistrations()) diff --git a/src/libutil/configuration.cc b/src/libutil/configuration.cc index b84867885098..340d3fd2564e 100644 --- a/src/libutil/configuration.cc +++ b/src/libutil/configuration.cc @@ -96,7 +96,21 @@ void Config::getSettings(std::map & res, bool overridd for (const auto & opt : _settings) if (!opt.second.isAlias && (!overriddenOnly || opt.second.setting->overridden) && experimentalFeatureSettings.isEnabled(opt.second.setting->experimentalFeature)) - res.emplace(opt.first, SettingInfo{opt.second.setting->to_string(), opt.second.setting->description}); + res.emplace( + opt.first, + SettingInfo{ + opt.second.setting->to_string(), + opt.second.setting->description, + opt.second.setting->flakeConfigSetting, + }); +} + +std::optional Config::getFlakeConfigSetting(const std::string & name) const +{ + auto setting = _settings.find(name); + if (setting == _settings.end()) + return std::nullopt; + return setting->second.setting->flakeConfigSetting; } /** @@ -233,11 +247,13 @@ AbstractSetting::AbstractSetting( const std::string & name, const std::string & description, const StringSet & aliases, - std::optional experimentalFeature) + std::optional experimentalFeature, + FlakeConfigSetting flakeConfigSetting) : name(name) , description(stripIndentation(description)) , aliases(aliases) , experimentalFeature(std::move(experimentalFeature)) + , flakeConfigSetting(flakeConfigSetting) { } diff --git a/src/libutil/include/nix/util/config-global.hh b/src/libutil/include/nix/util/config-global.hh index a0d662388620..9df643e98f00 100644 --- a/src/libutil/include/nix/util/config-global.hh +++ b/src/libutil/include/nix/util/config-global.hh @@ -18,6 +18,8 @@ public: void getSettings(std::map & res, bool overriddenOnly = false) const override; + std::optional getFlakeConfigSetting(const std::string & name) const override; + void resetOverridden() override; nlohmann::json toJSON() override; diff --git a/src/libutil/include/nix/util/configuration.hh b/src/libutil/include/nix/util/configuration.hh index dc6d8a811b39..ac9c4fe09b91 100644 --- a/src/libutil/include/nix/util/configuration.hh +++ b/src/libutil/include/nix/util/configuration.hh @@ -50,6 +50,11 @@ namespace nix { class Args; class AbstractSetting; +enum class FlakeConfigSetting { + Allowed, + Forbidden, +}; + class AbstractConfig { private: @@ -74,6 +79,7 @@ public: { std::string value; std::string description; + FlakeConfigSetting flakeConfigSetting; }; /** @@ -83,6 +89,9 @@ public: */ virtual void getSettings(std::map & res, bool overriddenOnly = false) const = 0; + /** Return flake policy for one known setting, including disabled experimental settings. */ + virtual std::optional getFlakeConfigSetting(const std::string & name) const = 0; + /** * Parses the configuration in `contents` and applies it * - contents: configuration contents to be parsed and applied @@ -172,6 +181,8 @@ public: void getSettings(std::map & res, bool overriddenOnly = false) const override; + std::optional getFlakeConfigSetting(const std::string & name) const override; + void resetOverridden() override; nlohmann::json toJSON() override; @@ -197,6 +208,8 @@ public: std::optional experimentalFeature; + const FlakeConfigSetting flakeConfigSetting; + bool isOverridden() const; protected: @@ -205,7 +218,8 @@ protected: const std::string & name, const std::string & description, const StringSet & aliases, - std::optional experimentalFeature = std::nullopt); + std::optional experimentalFeature = std::nullopt, + FlakeConfigSetting flakeConfigSetting = FlakeConfigSetting::Allowed); virtual ~AbstractSetting(); @@ -362,8 +376,9 @@ public: const std::string & name, const std::string & description, const StringSet & aliases = {}, - std::optional experimentalFeature = std::nullopt) - : AbstractSetting(name, description, aliases, experimentalFeature) + std::optional experimentalFeature = std::nullopt, + FlakeConfigSetting flakeConfigSetting = FlakeConfigSetting::Allowed) + : AbstractSetting(name, description, aliases, experimentalFeature, flakeConfigSetting) , value(def) , defaultValue(def) , documentDefault(documentDefault) @@ -476,8 +491,10 @@ public: const std::string & description, const StringSet & aliases = {}, const bool documentDefault = true, - std::optional experimentalFeature = std::nullopt) - : BaseSetting(def, documentDefault, name, description, aliases, std::move(experimentalFeature)) + std::optional experimentalFeature = std::nullopt, + FlakeConfigSetting flakeConfigSetting = FlakeConfigSetting::Allowed) + : BaseSetting( + def, documentDefault, name, description, aliases, std::move(experimentalFeature), flakeConfigSetting) { options->addSetting(this); } @@ -511,8 +528,10 @@ public: const std::string & description, const StringSet & aliases = {}, const bool documentDefault = true, - std::optional experimentalFeature = std::nullopt) - : BaseSetting(def, documentDefault, name, description, aliases, std::move(experimentalFeature)) + std::optional experimentalFeature = std::nullopt, + FlakeConfigSetting flakeConfigSetting = FlakeConfigSetting::Allowed) + : BaseSetting( + def, documentDefault, name, description, aliases, std::move(experimentalFeature), flakeConfigSetting) { options->addSetting(this); } diff --git a/tests/functional/common/subst-vars.sh.in b/tests/functional/common/subst-vars.sh.in index df140dec1b9e..831a76fa3c27 100644 --- a/tests/functional/common/subst-vars.sh.in +++ b/tests/functional/common/subst-vars.sh.in @@ -13,5 +13,6 @@ busybox="@sandbox_shell@" version=@PACKAGE_VERSION@ system=@system@ +with_secretspec=@with_secretspec@ fi diff --git a/tests/functional/common/vars.sh b/tests/functional/common/vars.sh index 06dca295b309..e109e2e9a1ff 100644 --- a/tests/functional/common/vars.sh +++ b/tests/functional/common/vars.sh @@ -15,8 +15,8 @@ commonDir="$(readlink -f "$(dirname "${BASH_SOURCE[0]-$0}")")" # shellcheck disable=SC1091 source "${_NIX_TEST_BUILD_DIR}/common/subst-vars.sh" # Make sure shellcheck knows all these will be defined by the above generated snippet -: "${bindir?} ${coreutils?} ${dot?} ${SHELL?} ${busybox?} ${version?} ${system?}" -export coreutils dot busybox version system +: "${bindir?} ${coreutils?} ${dot?} ${SHELL?} ${busybox?} ${version?} ${system?} ${with_secretspec?}" +export coreutils dot busybox version system with_secretspec export PAGER=cat diff --git a/tests/functional/fetchurl.sh b/tests/functional/fetchurl.sh index 452ac043a5b3..c25b92441bf4 100755 --- a/tests/functional/fetchurl.sh +++ b/tests/functional/fetchurl.sh @@ -7,7 +7,8 @@ TODO_NixOS # Test fetching a flat file. hash=$(nix-hash --flat --type sha256 ./fetchurl.sh) -outPath=$(nix-build -vvvvv --expr 'import ' --argstr url "file://$(pwd)/fetchurl.sh" --argstr sha256 "$hash" --no-out-link) +outPath=$(nix-build -vvvvv --expr 'import ' --argstr url "file://$(pwd)/fetchurl.sh" --argstr sha256 "$hash" --no-out-link \ + --option secretspec-netrc-file UNUSED_NETRC_SECRET) cmp "$outPath" fetchurl.sh diff --git a/tests/functional/flakes/config.sh b/tests/functional/flakes/config.sh index 87714b5db61a..48309cdf0279 100755 --- a/tests/functional/flakes/config.sh +++ b/tests/functional/flakes/config.sh @@ -54,3 +54,41 @@ clearStore nix build --accept-flake-config diff -q post-hook-ran previous-post-hook-run || \ fail "Both post hook runs should report the same filename" + +# Credential sources are never accepted from a flake, even when all other +# flake configuration is accepted without prompting. +cat < flake.nix +{ + nixConfig.access-tokens = "attacker.example=literal-secret"; + nixConfig.extra-access-tokens = "attacker.example=literal-secret"; + nixConfig.impure-env = "TOKEN=another-literal-secret"; + nixConfig.netrc-file = "/tmp/attacker-netrc"; + nixConfig.secretspec-access-tokens = "attacker.example=GITHUB_TOKEN"; + nixConfig.extra-secretspec-access-tokens = "attacker.example=GITHUB_TOKEN"; + nixConfig.secretspec-file = "/tmp/attacker-secretspec.toml"; + nixConfig.secretspec-provider = "attacker-provider"; + nixConfig.secretspec-profile = "attacker-profile"; + nixConfig.secretspec-scope = "attacker-scope"; + nixConfig.secretspec-impure-env = "TOKEN=GITHUB_TOKEN"; + nixConfig.secretspec-netrc-file = "NIX_NETRC"; + + outputs = a: { + packages.$system.default = import ./simple.nix; + }; +} +EOF + +nix build --accept-flake-config --no-link 2> forbidden-settings.err +grepQuiet "ignoring flake configuration setting 'access-tokens' because it is not allowed to be set by flakes" forbidden-settings.err +grepQuiet "ignoring flake configuration setting 'extra-access-tokens' because it is not allowed to be set by flakes" forbidden-settings.err +grepQuiet "ignoring flake configuration setting 'impure-env' because it is not allowed to be set by flakes" forbidden-settings.err +grepQuiet "ignoring flake configuration setting 'netrc-file' because it is not allowed to be set by flakes" forbidden-settings.err +grepQuiet "ignoring flake configuration setting 'secretspec-access-tokens' because it is not allowed to be set by flakes" forbidden-settings.err +grepQuiet "ignoring flake configuration setting 'extra-secretspec-access-tokens' because it is not allowed to be set by flakes" forbidden-settings.err +grepQuiet "ignoring flake configuration setting 'secretspec-file' because it is not allowed to be set by flakes" forbidden-settings.err +grepQuiet "ignoring flake configuration setting 'secretspec-provider' because it is not allowed to be set by flakes" forbidden-settings.err +grepQuiet "ignoring flake configuration setting 'secretspec-profile' because it is not allowed to be set by flakes" forbidden-settings.err +grepQuiet "ignoring flake configuration setting 'secretspec-scope' because it is not allowed to be set by flakes" forbidden-settings.err +grepQuiet "ignoring flake configuration setting 'secretspec-impure-env' because it is not allowed to be set by flakes" forbidden-settings.err +grepQuiet "ignoring flake configuration setting 'secretspec-netrc-file' because it is not allowed to be set by flakes" forbidden-settings.err +grepQuietInverse "literal-secret" forbidden-settings.err diff --git a/tests/functional/impure-env.sh b/tests/functional/impure-env.sh index 6199c26f8f65..9c605b000d72 100755 --- a/tests/functional/impure-env.sh +++ b/tests/functional/impure-env.sh @@ -27,10 +27,45 @@ set_in_config=daemon_value restartDaemon varTest set_in_config config_value varTest set_in_config client_value --impure-env set_in_config=client_value +if [[ $with_secretspec == true ]]; then + cat > "$TEST_ROOT/secretspec.toml" < "$TEST_ROOT/secrets.env" + + # A trusted client must forward both the mapping and the SecretSpec context to + # the daemon. The daemon intentionally has no SecretSpec context at this point. + varTest from_secret secret_value \ + --secretspec-file "$TEST_ROOT/secretspec.toml" \ + --secretspec-provider "dotenv://$TEST_ROOT/secrets.env" \ + --secretspec-profile nix \ + --secretspec-impure-env from_secret=IMPURE_VALUE + + cat >> "$test_nix_conf" < /home/alice/secretspec-test/secretspec.toml <<'EOF' + [project] + name = "nix-daemon-test" + revision = "1.0" + + [profiles.nix] + IMPURE_VALUE = { description = "impure environment test value", required = true } + EOF + echo 'IMPURE_VALUE=secret_value' > /home/alice/secretspec-test/secrets.env + cat > /home/alice/secretspec-test/default.nix <<'EOF' + let + bash = builtins.storePath "${pkgs.bash}"; + in derivation { + name = "secretspec-daemon-test"; + system = builtins.currentSystem; + builder = "''${bash}/bin/bash"; + args = [ "-c" "printf %s \\\"$FORWARDED_SECRET\\\" > \\\"$out\\\"" ]; + impureEnvVars = [ "FORWARDED_SECRET" ]; + outputHashAlgo = "sha256"; + outputHash = builtins.hashString "sha256" "secret_value"; + } + EOF + chown -R alice:users /home/alice/secretspec-test + chmod 0600 /home/alice/secretspec-test/* + + install -d -o bob -g users -m 0700 /home/bob/secretspec-test + cp /home/alice/secretspec-test/secretspec.toml /home/alice/secretspec-test/secrets.env \ + /home/bob/secretspec-test/ + cat > /home/bob/secretspec-test/default.nix <<'EOF' + let + bash = builtins.storePath "${pkgs.bash}"; + in derivation { + name = "secretspec-daemon-untrusted-test"; + system = builtins.currentSystem; + builder = "''${bash}/bin/bash"; + args = [ "-c" "printf %s \\\"$FORWARDED_SECRET\\\" >&2; printf safe > \\\"$out\\\"" ]; + impureEnvVars = [ "FORWARDED_SECRET" ]; + outputHashAlgo = "sha256"; + outputHash = builtins.hashString "sha256" "safe"; + } + EOF + chown -R bob:users /home/bob/secretspec-test + chmod 0600 /home/bob/secretspec-test/* + """) + + out = machine.succeed(""" + su --login alice -c ' + nix build --no-link --print-out-paths --impure \ + --file ~/secretspec-test/default.nix + ' + """).strip() + assert machine.succeed(f"cat {out}") == "secret_value" + + log = machine.succeed(""" + su --login bob -c ' + nix build --no-link --impure -L \ + --file ~/secretspec-test/default.nix + ' 2>&1 + """) + assert "secret_value" not in log + + log = machine.succeed(""" + su --login bob -c ' + nix build --no-link --impure -L \ + --file ~/secretspec-test/default.nix \ + --secretspec-file ~/secretspec-test/secretspec.toml \ + --secretspec-provider dotenv:///home/bob/secretspec-test/secrets.env \ + --secretspec-profile nix \ + --secretspec-impure-env FORWARDED_SECRET=IMPURE_VALUE + ' 2>&1 + """) + assert "secret_value" not in log + assert "restricted setting and you are not a trusted user" in log + ''; +}