mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-04 20:07:20 +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
@@ -13,25 +13,68 @@ namespace AIStudio.Tools;
|
||||
/// is designed to prevent casual exposure of API keys in configuration files. It
|
||||
/// also protects against accidental leaks while sharing configuration snippets,
|
||||
/// as the encrypted values cannot be decrypted without the secret key.
|
||||
/// <br/><br/>
|
||||
/// Both formats use the same secret. "ENC:v2:" is authenticated: AES-256-GCM refuses
|
||||
/// every value which was encrypted with another secret or changed afterward. "ENC:v1:"
|
||||
/// uses AES-256-CBC without authentication, so a wrong secret passes its padding check
|
||||
/// in about one of 256 cases; decoding the result strictly as UTF-8 catches nearly all
|
||||
/// of those. Versions before v26.10.1 read only v1, which is why AI Studio still writes
|
||||
/// it when asked to.
|
||||
/// </remarks>
|
||||
public sealed class EnterpriseEncryption
|
||||
{
|
||||
/// <summary>
|
||||
/// The number of iterations to derive the key and IV from the password.
|
||||
/// The number of iterations to derive the key and IV of a v1 value from the password.
|
||||
/// We use a higher iteration count here because the secret is static
|
||||
/// (not regenerated each startup like the IPC encryption).
|
||||
/// </summary>
|
||||
private const int ITERATIONS = 10_000;
|
||||
private const int V1_ITERATIONS = 10_000;
|
||||
|
||||
/// <summary>
|
||||
/// The length of the salt in bytes.
|
||||
/// The length of the salt in bytes, in both formats.
|
||||
/// </summary>
|
||||
private const int SALT_LENGTH = 16;
|
||||
|
||||
/// <summary>
|
||||
/// The prefix for encrypted values.
|
||||
/// The length of the AES-GCM nonce of a v2 value in bytes.
|
||||
/// </summary>
|
||||
private const string PREFIX = "ENC:v1:";
|
||||
private const int V2_NONCE_LENGTH = 12;
|
||||
|
||||
/// <summary>
|
||||
/// The length of the AES-GCM authentication tag of a v2 value in bytes.
|
||||
/// </summary>
|
||||
private const int V2_TAG_LENGTH = 16;
|
||||
|
||||
/// <summary>
|
||||
/// The prefix all encrypted values share, whatever their format.
|
||||
/// </summary>
|
||||
private const string ENCRYPTED_PREFIX = "ENC:";
|
||||
|
||||
/// <summary>
|
||||
/// The prefix of v1 values.
|
||||
/// </summary>
|
||||
private const string PREFIX_V1 = "ENC:v1:";
|
||||
|
||||
/// <summary>
|
||||
/// The prefix of v2 values.
|
||||
/// </summary>
|
||||
private const string PREFIX_V2 = "ENC:v2:";
|
||||
|
||||
/// <summary>
|
||||
/// What HKDF binds the key of a v2 value to, so that the key serves no other purpose.
|
||||
/// </summary>
|
||||
private static readonly byte[] V2_KEY_INFO = "MindWork AI Studio ENC:v2"u8.ToArray();
|
||||
|
||||
/// <summary>
|
||||
/// The prefix is authenticated together with the ciphertext, so a v2 payload counts under
|
||||
/// this prefix only.
|
||||
/// </summary>
|
||||
private static readonly byte[] V2_ASSOCIATED_DATA = Encoding.UTF8.GetBytes(PREFIX_V2);
|
||||
|
||||
/// <summary>
|
||||
/// Decodes UTF-8 and throws on invalid bytes instead of replacing them.
|
||||
/// </summary>
|
||||
private static readonly UTF8Encoding STRICT_UTF8 = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);
|
||||
|
||||
private readonly ILogger<EnterpriseEncryption> logger;
|
||||
private readonly byte[]? secretKey;
|
||||
@@ -79,16 +122,21 @@ public sealed class EnterpriseEncryption
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the given value is encrypted (has the encryption prefix).
|
||||
/// Checks if the given value is encrypted, in whatever format.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A format which only a newer version of AI Studio knows counts as encrypted, too. Such a
|
||||
/// value cannot be decrypted here, and that is what the user has to learn, rather than that
|
||||
/// it was stored in plaintext.
|
||||
/// </remarks>
|
||||
/// <param name="value">The value to check.</param>
|
||||
/// <returns>True if the value starts with the encryption prefix; otherwise, false.</returns>
|
||||
public static bool IsEncrypted(string? value) => value?.StartsWith(PREFIX, StringComparison.Ordinal) ?? false;
|
||||
public static bool IsEncrypted(string? value) => value?.StartsWith(ENCRYPTED_PREFIX, StringComparison.Ordinal) ?? false;
|
||||
|
||||
/// <summary>
|
||||
/// Tries to decrypt an encrypted value.
|
||||
/// </summary>
|
||||
/// <param name="encryptedValue">The encrypted value (with ENC:v1: prefix).</param>
|
||||
/// <param name="encryptedValue">The encrypted value, with the ENC:v1: or ENC:v2: prefix.</param>
|
||||
/// <param name="decryptedValue">When successful, contains the decrypted plaintext.</param>
|
||||
/// <returns>True if decryption was successful; otherwise, false.</returns>
|
||||
public bool TryDecrypt(string encryptedValue, out string decryptedValue)
|
||||
@@ -108,35 +156,26 @@ public sealed class EnterpriseEncryption
|
||||
|
||||
try
|
||||
{
|
||||
// Extract the base64-encoded data after the prefix:
|
||||
var base64Data = encryptedValue[PREFIX.Length..];
|
||||
var encryptedBytes = Convert.FromBase64String(base64Data);
|
||||
if (encryptedBytes.Length < SALT_LENGTH + 1)
|
||||
byte[]? decryptedBytes;
|
||||
if (encryptedValue.StartsWith(PREFIX_V2, StringComparison.Ordinal))
|
||||
decryptedBytes = this.DecryptV2(Convert.FromBase64String(encryptedValue[PREFIX_V2.Length..]));
|
||||
else if (encryptedValue.StartsWith(PREFIX_V1, StringComparison.Ordinal))
|
||||
decryptedBytes = this.DecryptV1(Convert.FromBase64String(encryptedValue[PREFIX_V1.Length..]));
|
||||
else
|
||||
{
|
||||
this.logger.LogWarning("Cannot decrypt: Encrypted data is too short.");
|
||||
this.logger.LogWarning("Cannot decrypt: The value uses an encryption format which this version of AI Studio does not know. A newer version may have encrypted it.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract salt and encrypted content:
|
||||
var salt = encryptedBytes[..SALT_LENGTH];
|
||||
var cipherText = encryptedBytes[SALT_LENGTH..];
|
||||
|
||||
// Derive key and IV using PBKDF2:
|
||||
using var keyDerivation = new Rfc2898DeriveBytes(this.secretKey!, salt, ITERATIONS, HashAlgorithmName.SHA512);
|
||||
var key = keyDerivation.GetBytes(32); // AES-256
|
||||
var iv = keyDerivation.GetBytes(16); // AES block size
|
||||
|
||||
// Decrypt using AES-256-CBC:
|
||||
using var aes = Aes.Create();
|
||||
aes.Key = key;
|
||||
aes.IV = iv;
|
||||
aes.Mode = CipherMode.CBC;
|
||||
aes.Padding = PaddingMode.PKCS7;
|
||||
|
||||
using var decryptor = aes.CreateDecryptor();
|
||||
var decryptedBytes = decryptor.TransformFinalBlock(cipherText, 0, cipherText.Length);
|
||||
decryptedValue = Encoding.UTF8.GetString(decryptedBytes);
|
||||
if (decryptedBytes is null)
|
||||
return false;
|
||||
|
||||
//
|
||||
// AI Studio always encrypted valid UTF-8, so decoding strictly never refuses a genuine
|
||||
// value. It refuses the random bytes, though, which a wrong secret yields for a v1 value
|
||||
// whenever the padding happens to check out:
|
||||
//
|
||||
decryptedValue = STRICT_UTF8.GetString(decryptedBytes);
|
||||
return true;
|
||||
}
|
||||
catch (FormatException ex)
|
||||
@@ -149,15 +188,21 @@ public sealed class EnterpriseEncryption
|
||||
this.logger.LogWarning(ex, "Failed to decrypt value. The encryption secret may be incorrect.");
|
||||
return false;
|
||||
}
|
||||
catch (DecoderFallbackException ex)
|
||||
{
|
||||
this.logger.LogWarning(ex, "Failed to decrypt value, because the result is no text. The encryption secret may be incorrect.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encrypts a plaintext value.
|
||||
/// </summary>
|
||||
/// <param name="plaintext">The plaintext to encrypt.</param>
|
||||
/// <param name="format">The format to write. A v2 value is authenticated, but only AI Studio v26.10.1 and later can read it.</param>
|
||||
/// <param name="encryptedValue">When successful, contains the encrypted value with prefix.</param>
|
||||
/// <returns>True if encryption was successful; otherwise, false.</returns>
|
||||
public bool TryEncrypt(string plaintext, out string encryptedValue)
|
||||
public bool TryEncrypt(string plaintext, EnterpriseEncryptionFormat format, out string encryptedValue)
|
||||
{
|
||||
encryptedValue = string.Empty;
|
||||
if (!this.IsAvailable)
|
||||
@@ -168,33 +213,21 @@ public sealed class EnterpriseEncryption
|
||||
|
||||
try
|
||||
{
|
||||
// Generate a random salt:
|
||||
var salt = RandomNumberGenerator.GetBytes(SALT_LENGTH);
|
||||
|
||||
// Derive key and IV using PBKDF2:
|
||||
using var keyDerivation = new Rfc2898DeriveBytes(this.secretKey!, salt, ITERATIONS, HashAlgorithmName.SHA512);
|
||||
var key = keyDerivation.GetBytes(32); // AES-256
|
||||
var iv = keyDerivation.GetBytes(16); // AES block size
|
||||
|
||||
// Encrypt using AES-256-CBC:
|
||||
using var aes = Aes.Create();
|
||||
aes.Key = key;
|
||||
aes.IV = iv;
|
||||
aes.Mode = CipherMode.CBC;
|
||||
aes.Padding = PaddingMode.PKCS7;
|
||||
|
||||
using var encryptor = aes.CreateEncryptor();
|
||||
var plaintextBytes = Encoding.UTF8.GetBytes(plaintext);
|
||||
var cipherText = encryptor.TransformFinalBlock(plaintextBytes, 0, plaintextBytes.Length);
|
||||
switch (format)
|
||||
{
|
||||
case EnterpriseEncryptionFormat.V1:
|
||||
encryptedValue = this.EncryptV1(plaintextBytes);
|
||||
return true;
|
||||
|
||||
// Combine salt and ciphertext
|
||||
var combined = new byte[SALT_LENGTH + cipherText.Length];
|
||||
Array.Copy(salt, 0, combined, 0, SALT_LENGTH);
|
||||
Array.Copy(cipherText, 0, combined, SALT_LENGTH, cipherText.Length);
|
||||
case EnterpriseEncryptionFormat.V2:
|
||||
encryptedValue = this.EncryptV2(plaintextBytes);
|
||||
return true;
|
||||
|
||||
// Encode to base64 and add the prefix:
|
||||
encryptedValue = PREFIX + Convert.ToBase64String(combined);
|
||||
return true;
|
||||
default:
|
||||
this.logger.LogWarning($"Cannot encrypt: The encryption format '{format}' is unknown.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (CryptographicException ex)
|
||||
{
|
||||
@@ -208,4 +241,118 @@ public sealed class EnterpriseEncryption
|
||||
/// </summary>
|
||||
/// <returns>A base64-encoded 32-byte secret key.</returns>
|
||||
public static string GenerateSecret() => Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||
|
||||
/// <summary>
|
||||
/// Encrypts into a v1 value: the salt, followed by the AES-256-CBC ciphertext.
|
||||
/// </summary>
|
||||
private string EncryptV1(byte[] plaintextBytes)
|
||||
{
|
||||
// Generate a random salt:
|
||||
var salt = RandomNumberGenerator.GetBytes(SALT_LENGTH);
|
||||
|
||||
// Encrypt using AES-256-CBC:
|
||||
using var aes = this.CreateAesV1(salt);
|
||||
using var encryptor = aes.CreateEncryptor();
|
||||
var cipherText = encryptor.TransformFinalBlock(plaintextBytes, 0, plaintextBytes.Length);
|
||||
|
||||
// Combine salt and ciphertext, encode to base64, and add the prefix:
|
||||
byte[] combined = [..salt, ..cipherText];
|
||||
return PREFIX_V1 + Convert.ToBase64String(combined);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decrypts the payload of a v1 value: the salt, followed by the AES-256-CBC ciphertext.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Organizations deployed configuration plugins in this format, so AI Studio reads it for
|
||||
/// good, as decided on 2026-10-17. This is no compatibility shim with a removal date.
|
||||
/// </remarks>
|
||||
/// <returns>The decrypted bytes, or null when the payload is too short.</returns>
|
||||
private byte[]? DecryptV1(byte[] encryptedBytes)
|
||||
{
|
||||
if (encryptedBytes.Length < SALT_LENGTH + 1)
|
||||
{
|
||||
this.logger.LogWarning("Cannot decrypt: Encrypted data is too short.");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract salt and encrypted content:
|
||||
var salt = encryptedBytes[..SALT_LENGTH];
|
||||
var cipherText = encryptedBytes[SALT_LENGTH..];
|
||||
|
||||
// Decrypt using AES-256-CBC:
|
||||
using var aes = this.CreateAesV1(salt);
|
||||
using var decryptor = aes.CreateDecryptor();
|
||||
return decryptor.TransformFinalBlock(cipherText, 0, cipherText.Length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the AES-256-CBC instance for a v1 value, with key and IV derived from the secret
|
||||
/// and the salt of that value.
|
||||
/// </summary>
|
||||
private Aes CreateAesV1(byte[] salt)
|
||||
{
|
||||
// Derive key and IV using PBKDF2:
|
||||
using var keyDerivation = new Rfc2898DeriveBytes(this.secretKey!, salt, V1_ITERATIONS, HashAlgorithmName.SHA512);
|
||||
var aes = Aes.Create();
|
||||
aes.Key = keyDerivation.GetBytes(32); // AES-256
|
||||
aes.IV = keyDerivation.GetBytes(16); // AES block size
|
||||
aes.Mode = CipherMode.CBC;
|
||||
aes.Padding = PaddingMode.PKCS7;
|
||||
return aes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encrypts into a v2 value: the salt, the nonce, the AES-256-GCM ciphertext, and its tag.
|
||||
/// </summary>
|
||||
private string EncryptV2(byte[] plaintextBytes)
|
||||
{
|
||||
// The random salt gives every value a key of its own; a random nonce on top costs nothing:
|
||||
var salt = RandomNumberGenerator.GetBytes(SALT_LENGTH);
|
||||
var nonce = RandomNumberGenerator.GetBytes(V2_NONCE_LENGTH);
|
||||
var cipherText = new byte[plaintextBytes.Length];
|
||||
var tag = new byte[V2_TAG_LENGTH];
|
||||
|
||||
using var aes = new AesGcm(this.DeriveKeyV2(salt), V2_TAG_LENGTH);
|
||||
aes.Encrypt(nonce, plaintextBytes, cipherText, tag, V2_ASSOCIATED_DATA);
|
||||
|
||||
byte[] combined = [..salt, ..nonce, ..cipherText, ..tag];
|
||||
return PREFIX_V2 + Convert.ToBase64String(combined);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decrypts the payload of a v2 value: the salt, the nonce, the AES-256-GCM ciphertext, and its tag.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Throws an AuthenticationTagMismatchException, which is a CryptographicException, when the
|
||||
/// tag does not match: for a wrong secret as well as for a changed value.
|
||||
/// </remarks>
|
||||
/// <returns>The decrypted bytes, or null when the payload is too short.</returns>
|
||||
private byte[]? DecryptV2(byte[] encryptedBytes)
|
||||
{
|
||||
if (encryptedBytes.Length < SALT_LENGTH + V2_NONCE_LENGTH + V2_TAG_LENGTH)
|
||||
{
|
||||
this.logger.LogWarning("Cannot decrypt: Encrypted data is too short.");
|
||||
return null;
|
||||
}
|
||||
|
||||
var salt = encryptedBytes[..SALT_LENGTH];
|
||||
var nonce = encryptedBytes[SALT_LENGTH..(SALT_LENGTH + V2_NONCE_LENGTH)];
|
||||
var cipherText = encryptedBytes[(SALT_LENGTH + V2_NONCE_LENGTH)..^V2_TAG_LENGTH];
|
||||
var tag = encryptedBytes[^V2_TAG_LENGTH..];
|
||||
|
||||
using var aes = new AesGcm(this.DeriveKeyV2(salt), V2_TAG_LENGTH);
|
||||
var decryptedBytes = new byte[cipherText.Length];
|
||||
aes.Decrypt(nonce, cipherText, tag, decryptedBytes, V2_ASSOCIATED_DATA);
|
||||
return decryptedBytes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Derives the AES-256 key of a v2 value from the secret and the salt of that value.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Unlike a password, the secret is meant to be 256 random bits, as GenerateSecret makes it.
|
||||
/// Stretching it, as PBKDF2 does for v1 values, adds nothing, so HKDF suffices.
|
||||
/// </remarks>
|
||||
private byte[] DeriveKeyV2(byte[] salt) => HKDF.DeriveKey(HashAlgorithmName.SHA256, this.secretKey!, 32, salt, V2_KEY_INFO);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// The formats in which AI Studio encrypts the secrets of enterprise configuration plugins.
|
||||
/// </summary>
|
||||
public enum EnterpriseEncryptionFormat
|
||||
{
|
||||
/// <summary>
|
||||
/// "ENC:v1:", which every version of AI Studio reads. It is not authenticated, so a wrong
|
||||
/// encryption secret is not always detected.
|
||||
/// </summary>
|
||||
V1,
|
||||
|
||||
/// <summary>
|
||||
/// "ENC:v2:", which is authenticated: AI Studio refuses every value encrypted with another
|
||||
/// secret or changed afterward. Versions before v26.10.1 cannot read it.
|
||||
/// </summary>
|
||||
V2,
|
||||
}
|
||||
@@ -305,6 +305,9 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
|
||||
// Config: show administration settings?
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.ShowAdminSettings, this.Id, settingsTable, dryRun);
|
||||
|
||||
// Config: in which format do exports encrypt secrets?
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.EnterpriseEncryptionExportFormat, this.Id, settingsTable, dryRun);
|
||||
|
||||
// Config: add a disclosure to copied and exported AI-generated content?
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AddAIGeneratedContentDisclosure, this.Id, settingsTable, dryRun);
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ public sealed partial class ToolSettingsService
|
||||
continue;
|
||||
|
||||
var values = await this.GetSettingsAsync(tool.Definition);
|
||||
var issue = CollectSettings(tool.Definition, areasOfTool, values, options, PluginFactory.EnterpriseEncryption, lockedValues, defaultValues);
|
||||
var issue = CollectSettings(tool.Definition, areasOfTool, values, options, PluginFactory.EnterpriseEncryption, settingsManager.ConfigurationData.App.EnterpriseEncryptionExportFormat, lockedValues, defaultValues);
|
||||
if (issue is not null)
|
||||
return new(ErrorMessage: issue);
|
||||
}
|
||||
@@ -98,7 +98,7 @@ public sealed partial class ToolSettingsService
|
||||
/// Adds the selected settings of one tool to the values to export, by their managed key.
|
||||
/// </summary>
|
||||
/// <returns>Why the export failed, or null when it may go on.</returns>
|
||||
private static string? CollectSettings(ToolDefinition definition, IEnumerable<ExportableSettings> selectedAreas, IReadOnlyDictionary<string, string> values, ToolSettingsExportOptions options, EnterpriseEncryption? encryption, Dictionary<string, string> lockedValues, Dictionary<string, string> defaultValues)
|
||||
private static string? CollectSettings(ToolDefinition definition, IEnumerable<ExportableSettings> selectedAreas, IReadOnlyDictionary<string, string> values, ToolSettingsExportOptions options, EnterpriseEncryption? encryption, EnterpriseEncryptionFormat encryptionFormat, Dictionary<string, string> lockedValues, Dictionary<string, string> defaultValues)
|
||||
{
|
||||
foreach (var fieldName in GetSelectedFieldNames(definition, selectedAreas))
|
||||
{
|
||||
@@ -114,7 +114,7 @@ public sealed partial class ToolSettingsService
|
||||
if (encryption?.IsAvailable is not true)
|
||||
return TB("Cannot export encrypted tool secrets: No enterprise encryption secret is configured.");
|
||||
|
||||
if (!encryption.TryEncrypt(value, out var encrypted))
|
||||
if (!encryption.TryEncrypt(value, encryptionFormat, out var encrypted))
|
||||
return TB("The tool secrets could not be encrypted. Nothing was exported.");
|
||||
|
||||
lockedValues[key] = encrypted;
|
||||
|
||||
@@ -182,7 +182,7 @@ public sealed partial class ToolSettingsService(SettingsManager settingsManager,
|
||||
|
||||
if (!EnterpriseEncryption.IsEncrypted(encryptedSecret))
|
||||
{
|
||||
logger.LogWarning("The managed tool setting '{ManagedKey}' holds a plaintext secret. Only encrypted secrets, starting with 'ENC:v1:', are supported.", managedKey);
|
||||
logger.LogWarning("The managed tool setting '{ManagedKey}' holds a plaintext secret. Only encrypted secrets, starting with 'ENC:', are supported.", managedKey);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user