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/PushCommand.cs b/src/isn/commands/PushCommand.cs index 0f6f303..6820814 100644 --- a/src/isn/commands/PushCommand.cs +++ b/src/isn/commands/PushCommand.cs @@ -18,6 +18,8 @@ namespace Isn source = Program.Settings.Sources.First(s=>s.Value.Alias==source).Key; if (source==null) throw new InvalidOperationException("source is invalid"); + var resolvedApiKey = string.IsNullOrWhiteSpace(apikey) ? ResolveSourceApiKey(source) : apikey; + var resources = SourceHelpers.GetServerResources(source); if (resources.Resources == null) throw new InvalidOperationException("source gave no resource"); @@ -38,7 +40,7 @@ namespace Isn try { Console.WriteLine("Connecting to "+ pubRes.Id); - return client.UploadFilesToServer(new Uri(pubRes.Id), fi, apikey); + return client.UploadFilesToServer(new Uri(pubRes.Id), fi, resolvedApiKey); } catch (HttpRequestException hrex) { @@ -64,5 +66,25 @@ namespace Isn return report; } } + + private static string ResolveSourceApiKey(string source) + { + if (string.IsNullOrWhiteSpace(source)) + { + return null; + } + + if (Program.Settings.Sources.Values.Any(s => s.Alias == source)) + { + source = Program.Settings.Sources.First(s => s.Value.Alias == source).Key; + } + + if (Program.Settings.Sources.TryGetValue(source, out var sourceSettings)) + { + return sourceSettings.GetClearApiKey(); + } + + return null; + } } } \ No newline at end of file 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..3084fa8 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.TryUnprotectKey(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..98f5e72 --- /dev/null +++ b/src/isnd/Helpers/ApiKeyProtector.cs @@ -0,0 +1,82 @@ +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 TryUnprotectKey(IDataProtector protector, string protectedValue) + { + if (string.IsNullOrWhiteSpace(protectedValue)) + { + return protectedValue; + } + + if (TryUnprotect(protector, protectedValue, out var unprotected)) + { + return unprotected; + } + + if (TryUnprotectLegacy(protectedValue, out unprotected)) + { + return unprotected; + } + + return protectedValue; + } + + private static bool TryUnprotect(IDataProtector protector, string protectedValue, out string unprotected) + { + try + { + unprotected = protector.Unprotect(protectedValue); + return true; + } + catch (CryptographicException) + { + unprotected = null; + return false; + } + catch (FormatException) + { + unprotected = null; + return false; + } + } + + private static bool TryUnprotectLegacy(string protectedValue, out string unprotected) + { + try + { + unprotected = UnprotectLegacy(protectedValue); + return true; + } + catch (FormatException) + { + unprotected = null; + return false; + } + } + + 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..1871ebf 100644 --- a/test/isn.tests/PushTest.cs +++ b/test/isn.tests/PushTest.cs @@ -18,6 +18,10 @@ namespace Isn.tests { private readonly HttpListener listener; private readonly Task listenerTask; + private readonly string configDirectory; + private readonly string originalConfigDirectory; + + public string LastApiKeyHeader { get; private set; } public LocalSourceFixture() { @@ -45,6 +49,11 @@ namespace Isn.tests break; } + if (context.Request.HttpMethod == "PUT") + { + LastApiKeyHeader = context.Request.Headers["X-NuGet-ApiKey"]; + } + var indexJson = JsonConvert.SerializeObject(new ApiIndexViewModel(prefix + "index.json") { Version = "3.0.0", @@ -64,6 +73,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 +98,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 +115,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 +141,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() { @@ -159,6 +213,22 @@ namespace Isn.tests Assert.Single(report); } + [Fact] + public void PushUsesStoredApiKeyFromConfigWhenNoExplicitApiKeyIsProvided() + { + Program.LoadConfig(); + var clearApiKey = "stored-api-key"; + Program.Settings.Sources[fixture.SourceUrl].SetApiKey(clearApiKey); + Program.SaveConfig(); + Program.LoadConfig(); + + var report = Program.PushPkg(new[] { Path.Combine(AppContext.BaseDirectory, "dummy.nupkg") }); + + Assert.NotNull(report); + Assert.Single(report); + Assert.Equal(clearApiKey, fixture.LastApiKeyHeader); + } + [Fact] public void GetServerResourcesUsingHttpClientAsyncTest() { diff --git a/test/isnd.tests/UnitTestWebHost.cs b/test/isnd.tests/UnitTestWebHost.cs index 3ca896f..b89caae 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,27 @@ namespace isnd.host.tests } } + [Fact] + public void LegacyApiKeyProtectorCanUnprotectValuesFromOlderClients() + { + const string clearValue = "legacy-api-key"; + var legacyProtectedValue = new Isn.DefaultDataProtector().Protect(clearValue); + + var unprotected = ApiKeyProtector.TryUnprotectKey(new ThrowingProtector(), legacyProtectedValue); + + Assert.Equal(clearValue, unprotected); + } + + [Fact] + public void ApiKeyProtectorReturnsRawValueWhenInputIsNotProtected() + { + const string rawApiKey = "CfDJ8AstXUEkxpJBrmoENwy__WNPxqcOwAuxyYgiU3Mebns5yxAT3VrC5vTuWTRSGZBFB7IGUyFkUYsp2nhRy64NqeUzLgOwEcU4FPOf-yaAtPeTMeStRd60bRh2ZYyQkQ3hrhW-lwpCLwYtNOnOyX2jayuzByF-4QfHIEhg6lbWenzf"; + + var result = ApiKeyProtector.TryUnprotectKey(new ThrowingProtector(), rawApiKey); + + Assert.Equal(rawApiKey, result); + } + [Fact] void TestDropUser() { @@ -117,5 +141,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; + } } }