From 6b5e7dc3a16da11ada2f0ab8beed9c89e1c1e22a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Wed, 12 Aug 2026 19:55:15 -0400 Subject: [PATCH] Add SecretSpec credential support Nix credentials currently have to be stored as literal configuration values. This makes long-lived forge tokens, netrc contents, and build secrets visible to configuration introspection and harder to manage through external secret providers. Allow these credentials to be referenced by SecretSpec name and resolved only when used. Support GitHub, GitLab, and SourceHut access tokens with the existing host and path-prefix matching, complete netrc files, and impure environment variables requested by fixed-output derivations. Preserve compatibility by preferring literal values for equally specific access-token mappings and equally named impure environment variables. SecretSpec netrc files take precedence over netrc-file. Prevent flakes from configuring credential-bearing settings, even when flake configuration is accepted. Propagate the requesting client trust level through local and remote builds so untrusted daemon clients cannot consume daemon-configured SecretSpec impure environment mappings. Make SecretSpec support optional at build time so libstore remains available where secretspec-ffi cannot be built. Closes: #6536 Related: #8635 Related: #6942 Assisted-by: Claude Code (claude-opus-5) Assisted-by: Codex (GPT-5) --- .../rl-next/secretspec-access-tokens.md | 58 ++++ packaging/dependencies.nix | 3 + packaging/secretspec-ffi.nix | 68 +++++ src/libfetchers-tests/access-tokens.cc | 186 ++++++++++++ src/libfetchers/fetch-settings.cc | 10 +- src/libfetchers/github.cc | 53 ++-- .../include/nix/fetchers/fetch-settings.hh | 47 ++- src/libflake/config.cc | 5 + .../include/nix/store/tests/meson.build | 1 + .../include/nix/store/tests/secretspec.hh | 39 +++ src/libstore-tests/filetransfer-request.cc | 74 +++++ .../build/derivation-building-goal.cc | 7 +- src/libstore/build/worker.cc | 3 +- src/libstore/builtins/fetchurl.cc | 5 +- src/libstore/daemon.cc | 3 +- src/libstore/filetransfer.cc | 28 +- .../build/freebsd-derivation-builder.cc | 1 + .../nix/store/build/derivation-builder.hh | 3 + .../include/nix/store/build/worker.hh | 13 +- src/libstore/include/nix/store/builtins.hh | 4 +- .../include/nix/store/filetransfer.hh | 40 ++- .../include/nix/store/legacy-ssh-store.hh | 2 +- .../include/nix/store/local-settings.hh | 33 +- src/libstore/include/nix/store/meson.build | 1 + .../include/nix/store/remote-store.hh | 5 +- .../include/nix/store/secretspec-settings.hh | 136 +++++++++ src/libstore/include/nix/store/store-api.hh | 8 +- src/libstore/legacy-ssh-store.cc | 3 +- .../linux/build/linux-derivation-builder.cc | 1 + src/libstore/meson.build | 21 ++ src/libstore/meson.options | 6 + src/libstore/package.nix | 10 +- src/libstore/remote-store.cc | 34 ++- src/libstore/restricted-store.cc | 2 +- src/libstore/secretspec-settings.cc | 287 ++++++++++++++++++ src/libstore/secretspec.toml | 13 + src/libstore/ssh-store.cc | 2 +- src/libstore/store-api.cc | 4 +- .../unix/build/derivation-builder-impl.hh | 4 + src/libstore/unix/build/derivation-builder.cc | 49 ++- src/libstore/unix/build/hook-instance.cc | 10 +- .../include/nix/store/build/hook-instance.hh | 4 +- src/libutil-tests/config.cc | 14 + src/libutil/config-global.cc | 8 + src/libutil/configuration.cc | 20 +- src/libutil/include/nix/util/config-global.hh | 2 + src/libutil/include/nix/util/configuration.hh | 33 +- tests/functional/common/subst-vars.sh.in | 1 + tests/functional/common/vars.sh | 4 +- tests/functional/fetchurl.sh | 3 +- tests/functional/flakes/config.sh | 38 +++ tests/functional/impure-env.sh | 35 +++ tests/functional/meson.build | 6 +- tests/nixos/default.nix | 2 + tests/nixos/secretspec-daemon.nix | 110 +++++++ 55 files changed, 1486 insertions(+), 76 deletions(-) create mode 100644 doc/manual/rl-next/secretspec-access-tokens.md create mode 100644 packaging/secretspec-ffi.nix create mode 100644 src/libstore-test-support/include/nix/store/tests/secretspec.hh create mode 100644 src/libstore/include/nix/store/secretspec-settings.hh create mode 100644 src/libstore/secretspec-settings.cc create mode 100644 src/libstore/secretspec.toml create mode 100644 tests/nixos/secretspec-daemon.nix 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 + ''; +}