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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions doc/manual/rl-next/secretspec-access-tokens.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions packaging/dependencies.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 68 additions & 0 deletions packaging/secretspec-ffi.nix
Original file line number Diff line number Diff line change
@@ -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" ];
};
})
186 changes: 186 additions & 0 deletions src/libfetchers-tests/access-tokens.cc
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
#include <nlohmann/json.hpp>
#include <gtest/gtest.h>

#include <string_view>

#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 {

Expand Down Expand Up @@ -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{};
Expand Down Expand Up @@ -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
10 changes: 9 additions & 1 deletion src/libfetchers/fetch-settings.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading