mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-03 07:07:21 +00:00
Authenticated encryption for enterprise secrets (#1078)
This commit is contained in:
1 parent
031874cebf
commit
274d718314
26 files changed
+740
-89
No files matched your search
@@ -0,0 +1,135 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Settings.DataModel;
|
||||
using AIStudio.Tools;
|
||||
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace AIStudio.Tests.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// Checks in which format an installation exports encrypted secrets, depending on whether it
|
||||
/// existed before the format became a choice.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Large organizations package updates themselves, so their administrators run a new version weeks
|
||||
/// before everybody else. Exports in the authenticated format v2 would be unreadable for the rest
|
||||
/// of such an organization meanwhile. An installation which existed before v26.10.1 therefore keeps
|
||||
/// v1, and only a new one starts with v2. Keeping v1 is a compatibility shim, see
|
||||
/// documentation/compatibility-shims/2026-10-enterprise-encryption-export-format.md.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
[NonParallelizable]
|
||||
public sealed class EnterpriseEncryptionExportFormatTests
|
||||
{
|
||||
private const string SETTINGS_FILENAME = "settings.json";
|
||||
|
||||
private string? previousConfigDirectory;
|
||||
private string? previousDataDirectory;
|
||||
private string testDirectory = string.Empty;
|
||||
|
||||
private string SettingsPath => Path.Combine(this.testDirectory, SETTINGS_FILENAME);
|
||||
|
||||
[SetUp]
|
||||
public void PrepareTestDirectory()
|
||||
{
|
||||
// Both directories are static state of the whole application, which is why this fixture
|
||||
// does not run alongside others:
|
||||
this.previousConfigDirectory = SettingsManager.ConfigDirectory;
|
||||
this.previousDataDirectory = SettingsManager.DataDirectory;
|
||||
|
||||
this.testDirectory = Path.Combine(Path.GetTempPath(), $"ai-studio-encryption-format-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(this.testDirectory);
|
||||
|
||||
SettingsManager.ConfigDirectory = this.testDirectory;
|
||||
SettingsManager.DataDirectory = this.testDirectory;
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void RemoveTestDirectory()
|
||||
{
|
||||
SettingsManager.ConfigDirectory = this.previousConfigDirectory;
|
||||
SettingsManager.DataDirectory = this.previousDataDirectory;
|
||||
|
||||
try
|
||||
{
|
||||
Directory.Delete(this.testDirectory, true);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// A temporary directory we could not remove says nothing about the code under test.
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ANewInstallationStartsWithTheAuthenticatedFormat()
|
||||
{
|
||||
var settingsManager = CreateSettingsManager();
|
||||
await settingsManager.LoadSettings();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(File.Exists(this.SettingsPath), Is.False, "A new installation has no settings file yet.");
|
||||
Assert.That(settingsManager.ConfigurationData.App.EnterpriseEncryptionExportFormat, Is.EqualTo(EnterpriseEncryptionFormat.V2));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task SettingsFromBeforeTheChoiceKeepTheLegacyFormat()
|
||||
{
|
||||
var settings = SerializeToNode(new Data());
|
||||
settings["App"]!.AsObject().Remove(nameof(DataApp.EnterpriseEncryptionExportFormat));
|
||||
await File.WriteAllTextAsync(this.SettingsPath, settings.ToJsonString(SettingsManager.JSON_OPTIONS));
|
||||
|
||||
var settingsManager = CreateSettingsManager();
|
||||
await settingsManager.LoadSettings();
|
||||
await settingsManager.StoreSettings();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(settingsManager.ConfigurationData.App.EnterpriseEncryptionExportFormat, Is.EqualTo(EnterpriseEncryptionFormat.V1));
|
||||
Assert.That(this.ReadStoredFormat(), Is.EqualTo(nameof(EnterpriseEncryptionFormat.V1)), "Once stored, the settings hold the format, and the shim has nothing left to do.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task SettingsOfAnOlderVersionKeepTheLegacyFormat()
|
||||
{
|
||||
await File.WriteAllTextAsync(this.SettingsPath, """{"Version": "V5"}""");
|
||||
|
||||
var settingsManager = CreateSettingsManager();
|
||||
await settingsManager.LoadSettings();
|
||||
|
||||
Assert.That(settingsManager.ConfigurationData.App.EnterpriseEncryptionExportFormat, Is.EqualTo(EnterpriseEncryptionFormat.V1));
|
||||
}
|
||||
|
||||
[TestCase(EnterpriseEncryptionFormat.V1)]
|
||||
[TestCase(EnterpriseEncryptionFormat.V2)]
|
||||
public async Task AChosenFormatStays(EnterpriseEncryptionFormat format)
|
||||
{
|
||||
var data = new Data();
|
||||
data.App.EnterpriseEncryptionExportFormat = format;
|
||||
await File.WriteAllTextAsync(this.SettingsPath, SerializeToNode(data).ToJsonString(SettingsManager.JSON_OPTIONS));
|
||||
|
||||
var settingsManager = CreateSettingsManager();
|
||||
await settingsManager.LoadSettings();
|
||||
|
||||
Assert.That(settingsManager.ConfigurationData.App.EnterpriseEncryptionExportFormat, Is.EqualTo(format));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The rust service is handed in as null on purpose: neither storing nor reading settings ever
|
||||
/// asks it anything.
|
||||
/// </remarks>
|
||||
private static SettingsManager CreateSettingsManager() => new(NullLogger<SettingsManager>.Instance, null!);
|
||||
|
||||
private static JsonObject SerializeToNode(Data data) => JsonSerializer.SerializeToNode(data, SettingsManager.JSON_OPTIONS)!.AsObject();
|
||||
|
||||
private string? ReadStoredFormat()
|
||||
{
|
||||
using var settingsDocument = JsonDocument.Parse(File.ReadAllText(this.SettingsPath));
|
||||
return settingsDocument.RootElement.GetProperty("App").GetProperty(nameof(DataApp.EnterpriseEncryptionExportFormat)).GetString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
using AIStudio.Tools;
|
||||
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace AIStudio.Tests.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// Checks the encryption of the API keys, tokens, and passwords which organizations deploy with
|
||||
/// their configuration plugins.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Organizations deployed configuration plugins with values in the "ENC:v1:" format, and AI Studio
|
||||
/// keeps reading them. The legacy values here were therefore built without AI Studio: PBKDF2 with
|
||||
/// SHA-512 and 10,000 iterations derives 48 bytes from the secret and the salt, the first 32 being
|
||||
/// the AES-256 key and the last 16 the IV, and openssl encrypted the text with AES-256-CBC. A change
|
||||
/// which breaks one of these values breaks every plugin deployed with that format.
|
||||
/// <br/><br/>
|
||||
/// The authenticated "ENC:v2:" format has to refuse every wrong secret and every change. Its tests
|
||||
/// try thousands of secrets, made up from a fixed seed, so that a failure can be repeated.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class EnterpriseEncryptionTests
|
||||
{
|
||||
private const string API_KEY = "sk-imported-key";
|
||||
|
||||
/// <summary>
|
||||
/// The seed of the secrets which the tests make up.
|
||||
/// </summary>
|
||||
private const int SEED = 20_261_017;
|
||||
|
||||
private static readonly string[] PLAINTEXTS =
|
||||
[
|
||||
string.Empty,
|
||||
API_KEY,
|
||||
"Schlüssel für Größe 🔑",
|
||||
new('x', 1_000),
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// The encryption secret of the legacy values below, as an organization deploys it.
|
||||
/// </summary>
|
||||
private const string LEGACY_SECRET = "/af/yJrG1dl9xGt26GcAlXMZ2+drG5I1w5KvIJgJnUk=";
|
||||
|
||||
/// <summary>
|
||||
/// A wrong secret for the legacy value of "sk-imported-key", found by searching. With it, the
|
||||
/// padding of that value checks out, as it does for about one wrong secret in 256.
|
||||
/// </summary>
|
||||
private const string WRONG_LEGACY_SECRET = "YXSD2dZLmSU6fEGDpkx9XWEu2+IJi8vG4x2oh49k81E=";
|
||||
|
||||
[TestCase("ENC:v1:nj0LJPcEvqzGau4SRDw4W94R2hLJBiB+FJ8gySll+vA=", "", TestName = "An empty legacy value")]
|
||||
[TestCase("ENC:v1:xc6EIl2jhKUODkp1Nr83kYmE86Vcti23dzSStSpbMts=", "sk-imported-key", TestName = "A legacy value within one block")]
|
||||
[TestCase("ENC:v1:K5fUwDVwDkNDTm3NZHuY53uhKvzQ6AZJqWAX/0dn1vvXGfz6cjbIOLhATWSoAg+U", "Schlüssel für Größe 🔑", TestName = "A legacy value beyond ASCII")]
|
||||
[TestCase("ENC:v1:LlgaFe4LWq9tuULA8Ysx3GVjmTxrNWPOggY5mKOGj+H2M14BBC+Y37U/nvSnOMqdiuUFAO6zJzwjNenZL0nb5kZX2co4u6Q+VAqrJ9u/VYM/GR4MNYYXJqelNbGGj71n", "eri-token-0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", TestName = "A legacy value over several blocks")]
|
||||
public void LegacyValuesStayReadable(string encryptedValue, string expectedValue)
|
||||
{
|
||||
var encryption = new EnterpriseEncryption(NullLogger<EnterpriseEncryption>.Instance, LEGACY_SECRET);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(EnterpriseEncryption.IsEncrypted(encryptedValue), Is.True);
|
||||
Assert.That(encryption.TryDecrypt(encryptedValue, out var decryptedValue), Is.True);
|
||||
Assert.That(decryptedValue, Is.EqualTo(expectedValue));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ALegacyValueWithAWrongSecretIsRefusedWhenItIsNoText()
|
||||
{
|
||||
// The legacy format has no authentication, so this wrong secret passes its only check. What
|
||||
// it yields are random bytes, though, and AI Studio always encrypted valid UTF-8:
|
||||
var encryption = new EnterpriseEncryption(NullLogger<EnterpriseEncryption>.Instance, WRONG_LEGACY_SECRET);
|
||||
|
||||
Assert.That(encryption.TryDecrypt("ENC:v1:xc6EIl2jhKUODkp1Nr83kYmE86Vcti23dzSStSpbMts=", out _), Is.False, "Random bytes must never become an API key.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EachFormatSurvivesTheRoundTrip([Values] EnterpriseEncryptionFormat format, [ValueSource(nameof(PLAINTEXTS))] string plaintext)
|
||||
{
|
||||
var encryption = Create(NextSecret(new Random(SEED)));
|
||||
Assert.That(encryption.TryEncrypt(plaintext, format, out var encryptedValue), Is.True);
|
||||
Assert.That(encryption.TryEncrypt(plaintext, format, out var encryptedAgain), Is.True);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(encryptedValue, Does.StartWith(PrefixOf(format)));
|
||||
Assert.That(EnterpriseEncryption.IsEncrypted(encryptedValue), Is.True);
|
||||
Assert.That(encryptedAgain, Is.Not.EqualTo(encryptedValue), "Every value gets a salt of its own, so equal secrets do not show as equal values.");
|
||||
Assert.That(encryption.TryDecrypt(encryptedValue, out var decryptedValue), Is.True);
|
||||
Assert.That(decryptedValue, Is.EqualTo(plaintext));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void NoWrongSecretDecryptsAnAuthenticatedValue()
|
||||
{
|
||||
var random = new Random(SEED);
|
||||
var rightSecret = NextSecret(random);
|
||||
var encryptedValue = EncryptAuthenticated(rightSecret, API_KEY);
|
||||
|
||||
// 2,000 secrets at random, plus the 256 which differ from the right one in a single bit:
|
||||
var wrongSecrets = Enumerable.Range(0, 2_000).Select(_ => NextSecret(random)).Concat(BitFlipsOf(rightSecret)).ToList();
|
||||
var acceptingSecrets = wrongSecrets.Where(wrongSecret => Create(wrongSecret).TryDecrypt(encryptedValue, out _)).ToList();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(wrongSecrets, Has.Count.EqualTo(2_256));
|
||||
Assert.That(Create(rightSecret).TryDecrypt(encryptedValue, out var decryptedValue), Is.True, "Otherwise, this test would pass for nothing.");
|
||||
Assert.That(decryptedValue, Is.EqualTo(API_KEY));
|
||||
Assert.That(acceptingSecrets, Is.Empty, "In the legacy format, about nine of these secrets would pass the padding check.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ATamperedValueIsRefused()
|
||||
{
|
||||
var secret = NextSecret(new Random(SEED));
|
||||
var encryption = Create(secret);
|
||||
var payload = Convert.FromBase64String(EncryptAuthenticated(secret, API_KEY)["ENC:v2:".Length..]);
|
||||
|
||||
// Salt, nonce, ciphertext, and tag: a change anywhere must be noticed.
|
||||
var acceptedPositions = Enumerable.Range(0, payload.Length).Where(position =>
|
||||
{
|
||||
var tampered = (byte[])payload.Clone();
|
||||
tampered[position] ^= 0x01;
|
||||
return encryption.TryDecrypt($"ENC:v2:{Convert.ToBase64String(tampered)}", out _);
|
||||
}).ToList();
|
||||
|
||||
Assert.That(acceptedPositions, Is.Empty);
|
||||
}
|
||||
|
||||
[TestCase("ENC:v2:", TestName = "An authenticated value without payload")]
|
||||
[TestCase("ENC:v2:AAAA", TestName = "An authenticated value too short for salt, nonce, and tag")]
|
||||
[TestCase("ENC:v1:AAAA", TestName = "A legacy value too short for its salt")]
|
||||
[TestCase("ENC:v2:not base64!", TestName = "A value which is no base64")]
|
||||
[TestCase("ENC:v2:LlgaFe4LWq9tuULA8Ysx3GVjmTxrNWPOggY5mKOGj+H2M14BBC+Y37U/nvSnOMqdiuUFAO6zJzwjNenZL0nb5kZX2co4u6Q+VAqrJ9u/VYM/GR4MNYYXJqelNbGGj71n", TestName = "A legacy payload passed off as authenticated")]
|
||||
[TestCase("ENC:v3:AAAA", TestName = "A format of a newer version")]
|
||||
public void AMalformedValueIsRefused(string encryptedValue)
|
||||
{
|
||||
var encryption = new EnterpriseEncryption(NullLogger<EnterpriseEncryption>.Instance, LEGACY_SECRET);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(EnterpriseEncryption.IsEncrypted(encryptedValue), Is.True, "Whatever starts like an encrypted value must not count as plaintext.");
|
||||
Assert.That(encryption.TryDecrypt(encryptedValue, out var decryptedValue), Is.False);
|
||||
Assert.That(decryptedValue, Is.Empty);
|
||||
});
|
||||
}
|
||||
|
||||
private static EnterpriseEncryption Create(string secret) => new(NullLogger<EnterpriseEncryption>.Instance, secret);
|
||||
|
||||
private static string EncryptAuthenticated(string secret, string plaintext)
|
||||
{
|
||||
Assert.That(Create(secret).TryEncrypt(plaintext, EnterpriseEncryptionFormat.V2, out var encryptedValue), Is.True);
|
||||
return encryptedValue;
|
||||
}
|
||||
|
||||
private static string NextSecret(Random random)
|
||||
{
|
||||
var secret = new byte[32];
|
||||
random.NextBytes(secret);
|
||||
return Convert.ToBase64String(secret);
|
||||
}
|
||||
|
||||
private static IEnumerable<string> BitFlipsOf(string secret)
|
||||
{
|
||||
var bytes = Convert.FromBase64String(secret);
|
||||
for (var bit = 0; bit < bytes.Length * 8; bit++)
|
||||
{
|
||||
var flipped = (byte[])bytes.Clone();
|
||||
flipped[bit / 8] ^= (byte)(1 << (bit % 8));
|
||||
yield return Convert.ToBase64String(flipped);
|
||||
}
|
||||
}
|
||||
|
||||
private static string PrefixOf(EnterpriseEncryptionFormat format) => format switch
|
||||
{
|
||||
EnterpriseEncryptionFormat.V1 => "ENC:v1:",
|
||||
EnterpriseEncryptionFormat.V2 => "ENC:v2:",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(format), format, "This test does not know the format yet."),
|
||||
};
|
||||
}
|
||||
@@ -185,7 +185,7 @@ public sealed class ConfigurationSnippetImportTests : ToolRegistryTestBase
|
||||
public void AnAPIKeyOfAnotherOrganizationLeavesAHint(PluginConfigurationObjectType type)
|
||||
{
|
||||
var otherOrganization = new EnterpriseEncryption(NullLogger<EnterpriseEncryption>.Instance, EnterpriseEncryption.GenerateSecret());
|
||||
Assert.That(otherOrganization.TryEncrypt(API_KEY, out var foreignApiKey), Is.True);
|
||||
Assert.That(otherOrganization.TryEncrypt(API_KEY, EnterpriseEncryptionFormat.V2, out var foreignApiKey), Is.True);
|
||||
|
||||
AssertAPIKeyLeftOut(Import(ExportProvider(type, foreignApiKey).Snippet, type));
|
||||
}
|
||||
@@ -481,9 +481,13 @@ public sealed class ConfigurationSnippetImportTests : ToolRegistryTestBase
|
||||
/// </summary>
|
||||
private static void RemoveEncryptionSecret() => PluginFactory.InitializeEnterpriseEncryption(encryptionSecret: null);
|
||||
|
||||
/// <summary>
|
||||
/// Encrypts in the authenticated format. In the legacy format, a wrong secret passes in rare
|
||||
/// cases, so the tests with the secret of another organization would fail now and then.
|
||||
/// </summary>
|
||||
private static string Encrypt(string secret)
|
||||
{
|
||||
Assert.That(PluginFactory.EnterpriseEncryption!.TryEncrypt(secret, out var encryptedSecret), Is.True);
|
||||
Assert.That(PluginFactory.EnterpriseEncryption!.TryEncrypt(secret, EnterpriseEncryptionFormat.V2, out var encryptedSecret), Is.True);
|
||||
return encryptedSecret;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user