Compare commits

...

2 commits

Author SHA1 Message Date
a78847dbc9 allow again the push 2026-07-05 21:44:49 +01:00
38bb7e40ef API key protection 2026-07-05 21:18:37 +01:00
8 changed files with 231 additions and 20 deletions

View file

@ -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

View file

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

View file

@ -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;
}
}
}

View file

@ -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,

View file

@ -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<string>();
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)
{

View file

@ -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);
}
}
}

View file

@ -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<string, SourceSettings>
{
[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()
{

View file

@ -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;
}
}
}