From 38bb7e40ef355475271a6930aaf1b62784e3d0ca Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sun, 5 Jul 2026 21:18:37 +0100 Subject: [PATCH] API key protection --- contrib/upgrade-isn-isnd.sh | 11 ++-- src/isn/Program.cs | 15 ++--- src/isn/commands/set-api-key.cs | 2 +- .../Packages/PackagesController.Put.cs | 4 +- src/isnd/Helpers/ApiKeyProtector.cs | 50 ++++++++++++++++ test/isn.tests/PushTest.cs | 57 +++++++++++++++++-- test/isnd.tests/UnitTestWebHost.cs | 23 ++++++++ 7 files changed, 143 insertions(+), 19 deletions(-) create mode 100644 src/isnd/Helpers/ApiKeyProtector.cs diff --git a/contrib/upgrade-isn-isnd.sh b/contrib/upgrade-isn-isnd.sh index 8a93152..457348b 100755 --- a/contrib/upgrade-isn-isnd.sh +++ b/contrib/upgrade-isn-isnd.sh @@ -3,11 +3,14 @@ set -e # compiler tout dotnet build -c Release -dotnet publish -c Release -f netcoreapp2.1 src/isnd +dotnet publish -c Release src/isnd +dotnet publish -c Release src/isn # MAJ du serveur sudo systemctl stop isnd -sudo cp -a src/isnd/bin/Release/netcoreapp2.1/publish/* /srv/www/isnd +sudo cp -a src/isnd/bin/Release/net10.0/publish/* /srv/www/isnd sudo systemctl start isnd # MAJ du client -sudo cp -a src/isn/bin/Release/netcoreapp2.1/* /usr/local/lib/isn -sudo chown -R root.root /usr/local/lib/isn +sudo cp -a src/isn/bin/Release/net10.0/* /usr/local/lib/isn +sudo chown -R root:root /usr/local/lib/isn + + diff --git a/src/isn/Program.cs b/src/isn/Program.cs index 6f1752d..5abbbb3 100644 --- a/src/isn/Program.cs +++ b/src/isn/Program.cs @@ -12,7 +12,7 @@ namespace Isn public static void LoadConfig() { - FileInfo cfgSettingIf = new FileInfo(_configFileName); + FileInfo cfgSettingIf = new FileInfo(ConfigFileName); if (cfgSettingIf.Exists) { var json = File.ReadAllText(cfgSettingIf.FullName); @@ -29,12 +29,13 @@ namespace Isn { "h|help", "show this message and exit", h => shouldShowPushHelp = h != null }, }; - private static readonly string _configFileName = - Path.Combine( - Path.Combine(Environment.GetFolderPath( - Environment.SpecialFolder.UserProfile), ".isn"), - "config.json") - ; + private static string ConfigDirectory => + Environment.GetEnvironmentVariable("ISN_CONFIG_DIR") + ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".isn"); + + private static string ConfigFileName => Path.Combine(ConfigDirectory, "config.json"); public const string push = "push"; static readonly OptionSet options = new OptionSet { diff --git a/src/isn/commands/set-api-key.cs b/src/isn/commands/set-api-key.cs index 0c4c2a5..7da6021 100644 --- a/src/isn/commands/set-api-key.cs +++ b/src/isn/commands/set-api-key.cs @@ -61,7 +61,7 @@ namespace Isn } public static void SaveConfig() { - FileInfo cfgSettingIf = new FileInfo(_configFileName); + FileInfo cfgSettingIf = new FileInfo(ConfigFileName); if (!cfgSettingIf.Directory.Exists) cfgSettingIf.Directory.Create(); File.WriteAllText( cfgSettingIf.FullName, diff --git a/src/isnd/Controllers/Packages/PackagesController.Put.cs b/src/isnd/Controllers/Packages/PackagesController.Put.cs index 3f82924..18c3633 100644 --- a/src/isnd/Controllers/Packages/PackagesController.Put.cs +++ b/src/isnd/Controllers/Packages/PackagesController.Put.cs @@ -31,12 +31,12 @@ namespace isnd.Controllers try { var clientVersionId = Request.Headers["X-NuGet-Client-Version"]; - string apiKey = Request.Headers["X-NuGet-ApiKey"][0]; + string apiKey = Request.Headers["X-NuGet-ApiKey"].FirstOrDefault() ?? string.Empty; ViewData["versionId"] = typeof(PackagesController).Assembly.FullName; var files = new List(); ViewData["files"] = files; - var clearkey = protector.Unprotect(apiKey); + var clearkey = ApiKeyProtector.UnprotectWithFallback(protector, apiKey); var apikey = dbContext.ApiKeys.SingleOrDefault(k => k.Id == clearkey); if (apikey == null) { diff --git a/src/isnd/Helpers/ApiKeyProtector.cs b/src/isnd/Helpers/ApiKeyProtector.cs new file mode 100644 index 0000000..4a8062f --- /dev/null +++ b/src/isnd/Helpers/ApiKeyProtector.cs @@ -0,0 +1,50 @@ +using System; +using System.Security.Cryptography; +using System.Text; +using Microsoft.AspNetCore.DataProtection; + +namespace isnd.Helpers +{ + public static class ApiKeyProtector + { + private const byte LegacyDelta = 145; + + public static string UnprotectWithFallback(IDataProtector protector, string protectedValue) + { + if (string.IsNullOrWhiteSpace(protectedValue)) + { + return protectedValue; + } + + try + { + return protector.Unprotect(protectedValue); + } + catch (CryptographicException) + { + return UnprotectLegacy(protectedValue); + } + catch (FormatException) + { + return UnprotectLegacy(protectedValue); + } + } + + public static string UnprotectLegacy(string protectedValue) + { + if (string.IsNullOrWhiteSpace(protectedValue)) + { + return protectedValue; + } + + var bytes = Convert.FromBase64String(protectedValue); + var unprotectedBytes = new byte[bytes.Length]; + for (var index = 0; index < bytes.Length; index++) + { + unprotectedBytes[index] = (byte)(bytes[index] ^ LegacyDelta); + } + + return Encoding.UTF8.GetString(unprotectedBytes); + } + } +} diff --git a/test/isn.tests/PushTest.cs b/test/isn.tests/PushTest.cs index b4223fc..2cc717f 100644 --- a/test/isn.tests/PushTest.cs +++ b/test/isn.tests/PushTest.cs @@ -18,6 +18,8 @@ namespace Isn.tests { private readonly HttpListener listener; private readonly Task listenerTask; + private readonly string configDirectory; + private readonly string originalConfigDirectory; public LocalSourceFixture() { @@ -64,6 +66,11 @@ namespace Isn.tests } }); + configDirectory = Path.Combine(Path.GetTempPath(), $"isn-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(configDirectory); + originalConfigDirectory = Environment.GetEnvironmentVariable("ISN_CONFIG_DIR"); + Environment.SetEnvironmentVariable("ISN_CONFIG_DIR", configDirectory); + SourceUrl = prefix + "index.json"; ConfigureIsnSettings(SourceUrl); Program.LoadConfig(); @@ -84,6 +91,12 @@ namespace Isn.tests { } } + + Environment.SetEnvironmentVariable("ISN_CONFIG_DIR", originalConfigDirectory); + if (Directory.Exists(configDirectory)) + { + Directory.Delete(configDirectory, recursive: true); + } } private static int GetFreePort() @@ -95,12 +108,8 @@ namespace Isn.tests return port; } - private static void ConfigureIsnSettings(string sourceUrl) + private void ConfigureIsnSettings(string sourceUrl) { - var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - var configDirectory = Path.Combine(home, ".isn"); - Directory.CreateDirectory(configDirectory); - var configPath = Path.Combine(configDirectory, "config.json"); var settings = new Settings { @@ -125,6 +134,44 @@ namespace Isn.tests this.fixture = fixture; } + [Fact] + public void SupportsIsolatedConfigDirectoryOverride() + { + var tempDir = Path.Combine(Path.GetTempPath(), $"isn-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + var originalConfigDir = Environment.GetEnvironmentVariable("ISN_CONFIG_DIR"); + var configFile = Path.Combine(tempDir, "config.json"); + var expectedSource = "https://example.test/index.json"; + var settings = new Settings + { + DataProtectionTitle = "isn", + Sources = new Dictionary + { + [expectedSource] = new SourceSettings { Alias = "override" } + }, + DefaultSourceKey = expectedSource + }; + + try + { + Environment.SetEnvironmentVariable("ISN_CONFIG_DIR", tempDir); + File.WriteAllText(configFile, JsonConvert.SerializeObject(settings, Formatting.Indented)); + + Program.LoadConfig(); + + Assert.Equal(expectedSource, Program.Settings.DefaultSourceKey); + Assert.True(Program.Settings.Sources.ContainsKey(expectedSource)); + } + finally + { + Environment.SetEnvironmentVariable("ISN_CONFIG_DIR", originalConfigDir); + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, recursive: true); + } + } + } + [Fact] public void HaveADefaultDataProtector() { diff --git a/test/isnd.tests/UnitTestWebHost.cs b/test/isnd.tests/UnitTestWebHost.cs index 3ca896f..183e217 100644 --- a/test/isnd.tests/UnitTestWebHost.cs +++ b/test/isnd.tests/UnitTestWebHost.cs @@ -19,6 +19,9 @@ using System.Threading.Tasks; using NuGet.Protocol.Core.Types; using NuGet.Common; using Isn.Abstract; +using isnd.Helpers; +using Microsoft.AspNetCore.DataProtection; +using System.Security.Cryptography; namespace isnd.host.tests { @@ -49,6 +52,17 @@ namespace isnd.host.tests } } + [Fact] + public void LegacyApiKeyProtectorCanUnprotectValuesFromOlderClients() + { + const string clearValue = "legacy-api-key"; + var legacyProtectedValue = new Isn.DefaultDataProtector().Protect(clearValue); + + var unprotected = ApiKeyProtector.UnprotectWithFallback(new ThrowingProtector(), legacyProtectedValue); + + Assert.Equal(clearValue, unprotected); + } + [Fact] void TestDropUser() { @@ -117,5 +131,14 @@ namespace isnd.host.tests { throw new NotImplementedException(); } + + private sealed class ThrowingProtector : IDataProtector + { + public byte[] Protect(byte[] plaintext) => throw new CryptographicException("unexpected protect call"); + + public byte[] Unprotect(byte[] protectedData) => throw new CryptographicException("unexpected unprotect call"); + + public IDataProtector CreateProtector(string purpose) => this; + } } }