Authenticated encryption for enterprise secrets (#1078)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-10-17 20:36:34 +02:00
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;
}