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

@@ -5152,6 +5152,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T436881267"]
-- AI Studio cannot install updates into this installation. Contact the person or organization that installed it for new versions.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T476576809"] = "AI Studio cannot install updates into this installation. Contact the person or organization that installed it for new versions."
-- Applies when you export providers, data sources, or tool settings with their API keys, tokens, or passwords. The protected format makes AI Studio refuse every value which was encrypted with another secret, but versions before v26.10.1 cannot read it. Keep the compatible format until every computer in your organization runs v26.10.1 or newer. Both formats use the same encryption secret.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T602260056"] = "Applies when you export providers, data sources, or tool settings with their API keys, tokens, or passwords. The protected format makes AI Studio refuse every value which was encrypted with another secret, but versions before v26.10.1 cannot read it. Keep the compatible format until every computer in your organization runs v26.10.1 or newer. Both formats use the same encryption secret."
-- Navigation bar behavior
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T602293588"] = "Navigation bar behavior"
@@ -5185,6 +5188,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T922066419"]
-- Administration settings are not visible
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T929143445"] = "Administration settings are not visible"
-- Format for encrypted secrets in exports
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T938676916"] = "Format for encrypted secrets in exports"
-- Show provider's confidence level?
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELCONFIDENCE::T1052533048"] = "Show provider's confidence level?"
@@ -11551,6 +11557,9 @@ UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T3137986690
-- Delete disappearing chats older than 180 days
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T3491430707"] = "Delete disappearing chats older than 180 days"
-- Compatible with all versions of AI Studio
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T3559426588"] = "Compatible with all versions of AI Studio"
-- Install updates automatically
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T3569059463"] = "Install updates automatically"
@@ -11563,6 +11572,9 @@ UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T3612390107
-- Toggle the sidebar: show the workspaces next to the chat when desired
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T3711207137"] = "Toggle the sidebar: show the workspaces next to the chat when desired"
-- Protected against wrong encryption secrets, needs AI Studio v26.10.1 or newer
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T3831574746"] = "Protected against wrong encryption secrets, needs AI Studio v26.10.1 or newer"
-- Updates disabled by your organization
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T4048970098"] = "Updates disabled by your organization"
@@ -366,7 +366,7 @@ public partial class DataSourceManagement : MSGComponentBase
}
var decryptedSecret = await secretResponse.Secret.Decrypt(Program.ENCRYPTION);
if (!encryption.TryEncrypt(decryptedSecret, out var encryptedSecret))
if (!encryption.TryEncrypt(decryptedSecret, this.SettingsManager.ConfigurationData.App.EnterpriseEncryptionExportFormat, out var encryptedSecret))
{
await this.DialogService.ShowMessageBox(
T("Export ERI Data Source"),
@@ -71,10 +71,13 @@
<MudButton StartIcon="@Icons.Material.Filled.Key"
Variant="Variant.Filled"
Color="Color.Primary"
Class="mb-6"
OnClick="@this.GenerateEncryptionSecret">
@T("Generate an encryption secret and copy it to the clipboard")
</MudButton>
<ConfigurationSelect OptionDescription="@T("Format for encrypted secrets in exports")" SelectedValue="@(() => this.SettingsManager.ConfigurationData.App.EnterpriseEncryptionExportFormat)" Data="@ConfigurationSelectDataFactory.GetEnterpriseEncryptionFormatData()" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.App.EnterpriseEncryptionExportFormat = selectedValue)" OptionHelp="@T("Applies when you export providers, data sources, or tool settings with their API keys, tokens, or passwords. The protected format makes AI Studio refuse every value which was encrypted with another secret, but versions before v26.10.1 cannot read it. Keep the compatible format until every computer in your organization runs v26.10.1 or newer. Both formats use the same encryption secret.")" IsLocked="() => ManagedConfiguration.TryGet(x => x.App, x => x.EnterpriseEncryptionExportFormat, out var meta) && meta.IsLocked"/>
<MudText Typo="Typo.h6" Class="mt-6 mb-3">
@T("External HTTPS certificates")
</MudText>
@@ -41,7 +41,7 @@ public abstract class SettingsPanelProviderBase : SettingsPanelBase
if (encryption?.IsAvailable == true)
{
var decryptedApiKey = await apiKeyResponse.Secret.Decrypt(Program.ENCRYPTION);
if (encryption.TryEncrypt(decryptedApiKey, out var encrypted))
if (encryption.TryEncrypt(decryptedApiKey, this.SettingsManager.ConfigurationData.App.EnterpriseEncryptionExportFormat, out var encrypted))
encryptedApiKey = encrypted;
}
else
@@ -154,12 +154,15 @@ CONFIG["LLM_PROVIDERS"] = {}
--
-- -- Optional: Encrypted API key for cloud providers or secured on-premise models.
-- -- The API key must be encrypted using the enterprise encryption secret.
-- -- Format: "ENC:v1:<base64-encoded encrypted data>"
-- -- Format: "ENC:v2:<base64-encoded encrypted data>", which AI Studio v26.10.1 or newer reads,
-- -- or "ENC:v1:<base64-encoded encrypted data>", which every version of AI Studio reads. Both
-- -- formats use the same secret. DataApp.EnterpriseEncryptionExportFormat below decides which
-- -- one your exports write.
-- -- The encryption secret must be configured via:
-- -- Windows Registry: HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT\config_encryption_secret
-- -- Environment variable: MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ENCRYPTION_SECRET
-- -- You can export an encrypted API key from an existing provider using the export button in the settings.
-- -- ["APIKey"] = "ENC:v1:<base64-encoded encrypted data>",
-- -- ["APIKey"] = "ENC:v2:<base64-encoded encrypted data>",
--
-- -- Optional: let each user set their own API key for this otherwise locked provider,
-- -- instead of (or in addition to not) embedding one centrally. Host, model, instance
@@ -193,7 +196,7 @@ CONFIG["TRANSCRIPTION_PROVIDERS"] = {}
-- -- ["IconPath"] = "assets/project-icon.svg",
--
-- -- Optional: Encrypted API key (see LLM_PROVIDERS example for details)
-- -- ["APIKey"] = "ENC:v1:<base64-encoded encrypted data>",
-- -- ["APIKey"] = "ENC:v2:<base64-encoded encrypted data>",
--
-- -- Optional: let each user set their own API key for this otherwise locked transcription
-- -- provider (see LLM_PROVIDERS example for details). Mutually exclusive with "APIKey"
@@ -230,7 +233,7 @@ CONFIG["EMBEDDING_PROVIDERS"] = {}
-- -- ["IconPath"] = "assets/project-icon.svg",
--
-- -- Optional: Encrypted API key (see LLM_PROVIDERS example for details)
-- -- ["APIKey"] = "ENC:v1:<base64-encoded encrypted data>",
-- -- ["APIKey"] = "ENC:v2:<base64-encoded encrypted data>",
--
-- -- Optional: tokenizer path for this provider relative to the plugin directory.
-- -- ["TokenizerPath"] = "",
@@ -269,7 +272,7 @@ CONFIG["DATA_SOURCES"] = {}
-- ["Hostname"] = "<https address of the ERI server>",
-- ["Port"] = 443,
-- ["AuthMethod"] = "TOKEN",
-- ["Token"] = "ENC:v1:<base64-encoded encrypted token>",
-- ["Token"] = "ENC:v2:<base64-encoded encrypted token>",
-- ["SecurityPolicy"] = "SELF_HOSTED",
-- ["SelectedRetrievalId"] = "<retrieval process ID from the ERI server>",
-- ["MaxMatches"] = 10,
@@ -285,7 +288,7 @@ CONFIG["DATA_SOURCES"] = {}
-- ["AuthMethod"] = "USERNAME_PASSWORD",
-- ["UsernamePasswordMode"] = "SHARED_USERNAME_AND_PASSWORD",
-- ["Username"] = "<shared username>",
-- ["Password"] = "ENC:v1:<base64-encoded encrypted password>",
-- ["Password"] = "ENC:v2:<base64-encoded encrypted password>",
-- ["SecurityPolicy"] = "SELF_HOSTED",
-- ["SelectedRetrievalId"] = "<retrieval process ID from the ERI server>",
-- ["MaxMatches"] = 10,
@@ -300,7 +303,7 @@ CONFIG["DATA_SOURCES"] = {}
-- ["Port"] = 443,
-- ["AuthMethod"] = "USERNAME_PASSWORD",
-- ["UsernamePasswordMode"] = "OS_USERNAME_SHARED_PASSWORD",
-- ["Password"] = "ENC:v1:<base64-encoded encrypted password>",
-- ["Password"] = "ENC:v2:<base64-encoded encrypted password>",
-- ["SecurityPolicy"] = "SELF_HOSTED",
-- ["SelectedRetrievalId"] = "<retrieval process ID from the ERI server>",
-- ["MaxMatches"] = 10,
@@ -482,6 +485,22 @@ CONFIG["SETTINGS"] = {}
-- Configure whether administration settings are visible in the UI:
-- CONFIG["SETTINGS"]["DataApp.ShowAdminSettings"] = true
-- Configure the format in which exports encrypt API keys, tokens, and passwords with the
-- enterprise encryption secret. Both formats use the same secret.
-- Allowed values are: V1, V2
-- V2 is protected: AI Studio refuses every value which was encrypted with another secret. It
-- needs AI Studio v26.10.1 or newer on every computer which reads it. V1 is read by every
-- version of AI Studio, and AI Studio keeps reading it for good. New installations start with
-- V2, installations which existed before v26.10.1 keep V1. Keep V1 until every computer in
-- your organization runs v26.10.1 or newer, then switch to V2.
-- CONFIG["SETTINGS"]["DataApp.EnterpriseEncryptionExportFormat"] = "V1"
--
-- Allow users to change the export format locally.
-- Allowed values are: true, false
-- When set to true, the configured format becomes the organization default,
-- but users can still choose another format in the enterprise administration settings.
-- CONFIG["SETTINGS"]["DataApp.EnterpriseEncryptionExportFormat.AllowUserOverride"] = true
-- Configure whether AI answers which users copy or export end with a notice that they were
-- generated using artificial intelligence (AI). The notice is written in the language of the app.
-- It also ends the files of the batch processing. Code blocks, single files or tables taken from
@@ -896,10 +915,10 @@ CONFIG["SETTINGS"] = {}
-- DefaultToolSettings - pre-fills the setting; a value the user saves afterwards wins.
--
-- A tool field marked as secret, such as an API key, can be rolled out as well — but only
-- encrypted with the enterprise encryption secret, in the same "ENC:v1:<base64>" form the
-- providers above use, and only through LockedToolSettings. A locked secret leaves whatever
-- the user entered untouched, so their own key returns when you stop deploying yours. A
-- plaintext secret is refused with a warning in the log rather than used.
-- encrypted with the enterprise encryption secret, in the same "ENC:v2:<base64>" or
-- "ENC:v1:<base64>" form the providers above use, and only through LockedToolSettings. A locked
-- secret leaves whatever the user entered untouched, so their own key returns when you stop
-- deploying yours. A plaintext secret is refused with a warning in the log rather than used.
--
-- Field names of the Web Search tool. At least one of its search services has to be configured
-- before the tool can be used; which one you pick is up to you, since all three can be rolled
@@ -979,7 +998,7 @@ CONFIG["SETTINGS"] = {}
-- ["web_search.searxng.baseUrl"] = "https://searxng.example.org/",
-- ["web_search.defaultLanguage"] = "de-DE",
-- ["web_search.backendStrategy"] = "FAILOVER",
-- ["web_search.tavily.apiKey"] = "ENC:v1:<base64-encoded encrypted data>",
-- ["web_search.tavily.apiKey"] = "ENC:v2:<base64-encoded encrypted data>",
-- ["read_web_page.freeAddressChoice"] = "OFF",
-- ["read_web_page.allowedPrivateHosts"] = "example.org, *.example.org",
-- ["search_confluence.baseUrl"] = "https://wiki.example.org/confluence/"
@@ -5154,6 +5154,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T436881267"]
-- AI Studio cannot install updates into this installation. Contact the person or organization that installed it for new versions.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T476576809"] = "AI Studio kann in dieser Installation keine Updates installieren. Wenden Sie sich für neue Versionen an die Person oder Organisation, die die Installation vorgenommen hat."
-- Applies when you export providers, data sources, or tool settings with their API keys, tokens, or passwords. The protected format makes AI Studio refuse every value which was encrypted with another secret, but versions before v26.10.1 cannot read it. Keep the compatible format until every computer in your organization runs v26.10.1 or newer. Both formats use the same encryption secret.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T602260056"] = "Gilt, wenn Sie Anbieter, Datenquellen oder Werkzeugeinstellungen zusammen mit den zugehörigen API-Schlüsseln, Token oder Passwörtern exportieren. Im geschützten Format lehnt AI Studio alle Werte ab, die mit einem anderen Geheimnis verschlüsselt wurden. Versionen vor v26.10.1 können dieses Format jedoch nicht lesen. Verwenden Sie weiterhin das kompatible Format, bis auf allen Rechnern Ihrer Organisation v26.10.1 oder eine neuere Version installiert ist. Beide Formate verwenden dasselbe Geheimnis für die Verschlüsselung."
-- Navigation bar behavior
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T602293588"] = "Verhalten der Navigationsleiste"
@@ -5187,6 +5190,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T922066419"]
-- Administration settings are not visible
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T929143445"] = "Die Optionen für die Administration sind nicht sichtbar."
-- Format for encrypted secrets in exports
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T938676916"] = "Format für verschlüsselte Geheimnisse in Exporten"
-- Show provider's confidence level?
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELCONFIDENCE::T1052533048"] = "Vertrauensniveau des Anbieters anzeigen?"
@@ -11553,6 +11559,9 @@ UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T3137986690
-- Delete disappearing chats older than 180 days
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T3491430707"] = "Selbstlöschende Chats: lösche Chats die älter als 180 Tage sind"
-- Compatible with all versions of AI Studio
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T3559426588"] = "Kompatibel mit allen Versionen von AI Studio"
-- Install updates automatically
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T3569059463"] = "Updates automatisch installieren"
@@ -11565,6 +11574,9 @@ UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T3612390107
-- Toggle the sidebar: show the workspaces next to the chat when desired
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T3711207137"] = "Seitenleiste umschalten: Arbeitsbereiche bei Bedarf neben dem Chat anzeigen"
-- Protected against wrong encryption secrets, needs AI Studio v26.10.1 or newer
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T3831574746"] = "Geschützt vor falschen Geheimnissen für die Verschlüsselung, erfordert AI Studio v26.10.1 oder neuer"
-- Updates disabled by your organization
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T4048970098"] = "Updates wurden von Ihrer Organisation deaktiviert"
@@ -5154,6 +5154,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T436881267"]
-- AI Studio cannot install updates into this installation. Contact the person or organization that installed it for new versions.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T476576809"] = "AI Studio cannot install updates into this installation. Contact the person or organization that installed it for new versions."
-- Applies when you export providers, data sources, or tool settings with their API keys, tokens, or passwords. The protected format makes AI Studio refuse every value which was encrypted with another secret, but versions before v26.10.1 cannot read it. Keep the compatible format until every computer in your organization runs v26.10.1 or newer. Both formats use the same encryption secret.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T602260056"] = "Applies when you export providers, data sources, or tool settings with their API keys, tokens, or passwords. The protected format makes AI Studio refuse every value which was encrypted with another secret, but versions before v26.10.1 cannot read it. Keep the compatible format until every computer in your organization runs v26.10.1 or newer. Both formats use the same encryption secret."
-- Navigation bar behavior
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T602293588"] = "Navigation bar behavior"
@@ -5187,6 +5190,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T922066419"]
-- Administration settings are not visible
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T929143445"] = "Administration settings are not visible"
-- Format for encrypted secrets in exports
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T938676916"] = "Format for encrypted secrets in exports"
-- Show provider's confidence level?
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELCONFIDENCE::T1052533048"] = "Show provider's confidence level?"
@@ -11553,6 +11559,9 @@ UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T3137986690
-- Delete disappearing chats older than 180 days
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T3491430707"] = "Disappearing chats: delete chats older than 180 days"
-- Compatible with all versions of AI Studio
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T3559426588"] = "Compatible with all versions of AI Studio"
-- Install updates automatically
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T3569059463"] = "Install updates automatically"
@@ -11565,6 +11574,9 @@ UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T3612390107
-- Toggle the sidebar: show the workspaces next to the chat when desired
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T3711207137"] = "Toggle the sidebar: show the workspaces on demand next to the chat"
-- Protected against wrong encryption secrets, needs AI Studio v26.10.1 or newer
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T3831574746"] = "Protected against wrong encryption secrets, needs AI Studio v26.10.1 or newer"
-- Updates disabled by your organization
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T4048970098"] = "Updates disabled by your organization"
@@ -370,4 +370,10 @@ public static class ConfigurationSelectDataFactory
yield return new(bitrate.GetName(), bitrate);
}
}
public static IEnumerable<ConfigurationSelectData<EnterpriseEncryptionFormat>> GetEnterpriseEncryptionFormatData()
{
yield return new(TB("Compatible with all versions of AI Studio"), EnterpriseEncryptionFormat.V1);
yield return new(TB("Protected against wrong encryption secrets, needs AI Studio v26.10.1 or newer"), EnterpriseEncryptionFormat.V2);
}
}
@@ -315,6 +315,17 @@ public sealed class DataApp(Expression<Func<Data, DataApp>>? configSelection = n
/// </summary>
public bool ShowAdminSettings { get; set; } = ManagedConfiguration.Register(configSelection, n => n.ShowAdminSettings, false);
/// <summary>
/// In which format do exports encrypt API keys, tokens, and passwords with the enterprise encryption secret?
/// </summary>
/// <remarks>
/// New installations write the authenticated format v2. Installations which existed before
/// v26.10.1 keep writing v1, which older versions read as well: large organizations package
/// updates themselves, so their administrators run a new version weeks before everybody else.
/// SettingsManager tells the two kinds of installations apart.
/// </remarks>
public EnterpriseEncryptionFormat EnterpriseEncryptionExportFormat { get; set; } = ManagedConfiguration.Register(configSelection, n => n.EnterpriseEncryptionExportFormat, EnterpriseEncryptionFormat.V2);
/// <summary>
/// Should AI answers which are copied or exported end with a notice that AI generated them?
/// </summary>
@@ -391,7 +391,7 @@ public readonly record struct DataSourceERI_V1 : IERIDataSource
if (!EnterpriseEncryption.IsEncrypted(encryptedSecret))
{
LOGGER.LogWarning($"The configured data source {idx} contains a plaintext {secretFieldName}. Only encrypted secrets (starting with 'ENC:v1:') are supported. (Plugin ID: {configPluginId})");
LOGGER.LogWarning($"The configured data source {idx} contains a plaintext {secretFieldName}. Only encrypted secrets (starting with 'ENC:') are supported. (Plugin ID: {configPluginId})");
return false;
}
@@ -61,10 +61,10 @@ public sealed class DataTools(Expression<Func<Data, DataTools>>? configSelection
/// known at compile time, yet an organization has to be able to configure them the same way.
/// <br/><br/>
/// A secret field travels here too, but only encrypted with the enterprise secret, in the
/// same "ENC:v1:" form the providers use for their API keys. What is stored is therefore
/// ciphertext, worthless without a secret that lives outside every deployed file. A plaintext
/// secret is refused rather than used, and a secret is never accepted as a pre-filled default
/// — see the tool settings service for both rules.
/// same "ENC:v2:" or "ENC:v1:" form the providers use for their API keys. What is stored is
/// therefore ciphertext, worthless without a secret that lives outside every deployed file. A
/// plaintext secret is refused rather than used, and a secret is never accepted as a
/// pre-filled default — see the tool settings service for both rules.
/// </remarks>
public Dictionary<string, string> LockedToolSettings { get; set; } = ManagedConfiguration.Register(
configSelection,
@@ -182,7 +182,7 @@ public sealed record EmbeddingProvider(
else if (table.TryGetValue("APIKey", out var apiKeyValue) && apiKeyValue.TryRead<string>(out var apiKeyText) && !string.IsNullOrWhiteSpace(apiKeyText))
{
if (!EnterpriseEncryption.IsEncrypted(apiKeyText))
LOGGER.LogWarning($"The configured embedding provider {idx} contains a plaintext API key. Only encrypted API keys (starting with 'ENC:v1:') are supported. (Plugin ID: {configPluginId})");
LOGGER.LogWarning($"The configured embedding provider {idx} contains a plaintext API key. Only encrypted API keys (starting with 'ENC:') are supported. (Plugin ID: {configPluginId})");
else
{
var encryption = PluginFactory.EnterpriseEncryption;
+1 -1
View File
@@ -203,7 +203,7 @@ public sealed record Provider(
else if (table.TryGetValue("APIKey", out var apiKeyValue) && apiKeyValue.TryRead<string>(out var apiKeyText) && !string.IsNullOrWhiteSpace(apiKeyText))
{
if (!EnterpriseEncryption.IsEncrypted(apiKeyText))
LOGGER.LogWarning($"The configured provider {idx} contains a plaintext API key. Only encrypted API keys (starting with 'ENC:v1:') are supported. (Plugin ID: {configPluginId})");
LOGGER.LogWarning($"The configured provider {idx} contains a plaintext API key. Only encrypted API keys (starting with 'ENC:') are supported. (Plugin ID: {configPluginId})");
else
{
var encryption = PluginFactory.EnterpriseEncryption;
@@ -186,6 +186,11 @@ public sealed class SettingsManager
this.logger.LogInformation("No valid current-version settings backup was found. Migrating the settings file.");
settingsData = SettingsMigrations.Migrate(this.logger, settingsVersion.Version, await File.ReadAllTextAsync(settingsPath), JSON_OPTIONS);
// Compatibility shim, remove after 2027-10-31, see KeepTheLegacyEncryptionExportFormat.
// Settings of an older version always come from before the export format existed:
settingsData.App.EnterpriseEncryptionExportFormat = EnterpriseEncryptionFormat.V1;
this.PrepareLoadedSettings(settingsData);
await this.StoreSettingsSnapshot(settingsData, settingsPath);
await this.StoreCurrentVersionBackup(settingsData);
@@ -291,7 +296,8 @@ public sealed class SettingsManager
{
try
{
var settingsData = JsonSerializer.Deserialize<Data>(await File.ReadAllTextAsync(settingsPath), JSON_OPTIONS);
var settingsJson = await File.ReadAllTextAsync(settingsPath);
var settingsData = JsonSerializer.Deserialize<Data>(settingsJson, JSON_OPTIONS);
if(settingsData is null)
{
this.logger.LogError($"Failed to parse the {sourceDescription} '{settingsPath}'.");
@@ -304,6 +310,7 @@ public sealed class SettingsManager
return new(null, SettingsWriteBlockReason.CURRENT_VERSION_INVALID);
}
KeepTheLegacyEncryptionExportFormat(settingsData, settingsJson);
return new(settingsData, SettingsWriteBlockReason.NONE);
}
catch(Exception e)
@@ -313,6 +320,28 @@ public sealed class SettingsManager
}
}
/// <summary>
/// Lets an installation which existed before v26.10.1 keep exporting encrypted secrets in the
/// format v1, which every version of AI Studio reads.
/// </summary>
/// <remarks>
/// Compatibility shim, remove after 2027-10-31, see
/// documentation/compatibility-shims/2026-10-enterprise-encryption-export-format.md.<br/><br/>
/// New installations export in the authenticated format v2. An existing installation, however,
/// may belong to an administrator whose organization still runs an older version, which reads
/// v1 only. Settings written before v26.10.1 lack the export format, and nothing else tells
/// such an installation apart. Once the settings are stored, they hold the format, and this
/// changes nothing anymore.
/// </remarks>
private static void KeepTheLegacyEncryptionExportFormat(Data settingsData, string settingsJson)
{
using var settingsDocument = JsonDocument.Parse(settingsJson);
if(settingsDocument.RootElement.TryGetProperty(nameof(Data.App), out var appElement) && appElement.ValueKind is JsonValueKind.Object && appElement.TryGetProperty(nameof(DataApp.EnterpriseEncryptionExportFormat), out _))
return;
settingsData.App.EnterpriseEncryptionExportFormat = EnterpriseEncryptionFormat.V1;
}
private void BlockSettingsWrites(SettingsWriteBlockReason reason, string message)
{
this.SettingsWriteBlockReason = reason;
@@ -152,7 +152,7 @@ public sealed record TranscriptionProvider(
else if (table.TryGetValue("APIKey", out var apiKeyValue) && apiKeyValue.TryRead<string>(out var apiKeyText) && !string.IsNullOrWhiteSpace(apiKeyText))
{
if (!EnterpriseEncryption.IsEncrypted(apiKeyText))
LOGGER.LogWarning($"The configured transcription provider {idx} contains a plaintext API key. Only encrypted API keys (starting with 'ENC:v1:') are supported. (Plugin ID: {configPluginId})");
LOGGER.LogWarning($"The configured transcription provider {idx} contains a plaintext API key. Only encrypted API keys (starting with 'ENC:') are supported. (Plugin ID: {configPluginId})");
else
{
var encryption = PluginFactory.EnterpriseEncryption;
@@ -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;
}
@@ -19,6 +19,8 @@
- Added organization-wide management for importing configurations which somebody exported from AI Studio. IT departments can allow or prevent such imports, either altogether or for each kind, such as providers or chat templates. A locked Import button stays visible.
- Added an optional notice for AI-generated content. When you switch it on in the app settings, the AI answers you copy or export end with a note that AI generated them, in the language of the app, for example, when rules on transparency such as the EU AI Act ask for one. Thanks, Sabrina (`Sabrina-devops`), for this contribution.
- Added organization-wide management for the notice on AI-generated content. IT departments can switch it on for everybody, either fixed or as a default which users may change.
- Added a choice of how AI Studio encrypts API keys, tokens, and passwords when you export providers, data sources, or tool settings for your organization. The new protected format needs AI Studio v26.10.1 or newer on every computer that reads it, and your encryption secret stays the same. If you used AI Studio before, your exports keep the previous format until you switch, so your organization can update all computers first. You find the choice in the app settings, under Enterprise Administration.
- Added organization-wide management for the format in which exports encrypt API keys, tokens, and passwords. IT departments can set it for all their administrators, either fixed or as a default which users may change.
- Improved the Read Web Page tool. When you have not allowed the AI to choose web addresses freely, the tool now refuses every address that does not appear in your chat or in what the tools returned, instead of only asking the AI not to read it.
- Improved the installation on macOS: AI Studio is now signed and checked by Apple, so it opens like any other app. You no longer need the Terminal.
- Improved the installation on Windows: AI Studio is now signed, so Windows shows who published it. For a while, Windows may still ask you to confirm the installation until it knows the app better.
@@ -79,6 +81,7 @@
- Fixed building AI Studio from source on Windows, where the checks that run before every build failed because two of them expected the line breaks and file paths of macOS and Linux. Thanks, Paul Koudelka (`PaulKoudelka`), for this contribution.
- Fixed a problem on Windows where AI Studio could fail to read the configuration of your organization when the name of an environment variable on your computer contained umlauts or other special characters.
- Fixed the localization assistant writing the translation instead of the English text into the comment above an entry of a language plugin. This happened when the English language plugin did not contain that text yet.
- Fixed AI Studio occasionally accepting an API key, token, or password which was encrypted with the secret of another organization, instead of refusing it. AI Studio then used a wrong key, so the provider, data source, or tool failed later with a misleading error. Such a value is now always refused in the new protected format, and almost always in the previous one.
- Updated the code contributions on the supporters page, which now thank everyone who has contributed code to AI Studio so far.
- Upgraded several libraries to improve security.
- Upgraded to Rust v1.99.0
@@ -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;
}
+26 -3
View File
@@ -676,7 +676,7 @@ You can include encrypted API keys in your configuration plugins for cloud provi
You must also deploy the same secret on the machine where you will export the encrypted API keys (step 3).
3. **Export encrypted API keys from AI Studio:**
Once the encryption secret is deployed on your machine, configure the provider with its API key and [export it](#exporting-configurations-from-the-app). AI Studio asks whether to include the key; the exported Lua code then contains it in the format `ENC:v1:<base64-encoded data>`.
Once the encryption secret is deployed on your machine, configure the provider with its API key and [export it](#exporting-configurations-from-the-app). AI Studio asks whether to include the key; the exported Lua code then contains it in the format `ENC:v2:<base64-encoded data>` or `ENC:v1:<base64-encoded data>`, see [Formats of encrypted values](#formats-of-encrypted-values).
4. **Add encrypted keys to your configuration:**
Copy the exported configuration (including the encrypted API key) into your configuration plugin.
@@ -690,7 +690,7 @@ CONFIG["LLM_PROVIDERS"][#CONFIG["LLM_PROVIDERS"]+1] = {
["UsedLLMProvider"] = "OPEN_AI",
["Host"] = "NONE",
["Hostname"] = "",
["APIKey"] = "ENC:v1:MTIzNDU2Nzg5MDEyMzQ1NkFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFla...",
["APIKey"] = "ENC:v2:MTIzNDU2Nzg5MDEyMzQ1NkFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFla...",
["AdditionalJsonApiParameters"] = "",
["Model"] = {
["Id"] = "gpt-4",
@@ -701,6 +701,29 @@ CONFIG["LLM_PROVIDERS"][#CONFIG["LLM_PROVIDERS"]+1] = {
The API key will be automatically decrypted when the configuration is loaded and stored securely in the operating system's credential store (Windows Credential Manager / macOS Keychain).
### Formats of encrypted values
AI Studio encrypts API keys, tokens, and passwords in one of two formats. Both use the same encryption secret, so you neither generate nor deploy a new one.
| Format | Starts with | Read by | A value encrypted with another secret |
|---|---|---|---|
| Protected | `ENC:v2:` | AI Studio v26.10.1 or newer | is always refused |
| Compatible | `ENC:v1:` | every version of AI Studio | is usually refused, but in rare cases decrypted into a wrong key |
AI Studio reads both formats, and it keeps reading `ENC:v1:` for good: the configuration plugins you have already deployed keep working.
**Which format your exports write** is set by **Format for encrypted secrets in exports** in the **Enterprise Administration** section of the app settings. New installations start with the protected format. Installations which existed before v26.10.1 keep the compatible format until you switch, because a version of AI Studio before v26.10.1 takes an `ENC:v2:` value for plaintext and leaves the key out.
**When you roll out updates yourself**, your administrators run a new version weeks before everybody else. Keep the compatible format until every computer in your organization runs v26.10.1 or newer, then switch to the protected one. You can also set the format centrally:
```lua
CONFIG["SETTINGS"]["DataApp.EnterpriseEncryptionExportFormat"] = "V1"
```
Change the value to `"V2"` once the update has arrived everywhere. While a configuration sets the format, the choice in the app settings is locked; with `DataApp.EnterpriseEncryptionExportFormat.AllowUserOverride` set to `true`, the configured format becomes a default instead.
Switching the format leaves the values in your configuration plugins as they are. To protect a value you have already deployed, export it again and replace it in your plugin.
## Exporting tool configurations
A tool export is the one that asks the most before it writes anything. It assumes `CONFIG` and `CONFIG["SETTINGS"]` already exist in your plugin.
@@ -738,7 +761,7 @@ For **Web Search**, select only Tavily, choose **Editable defaults**, enable enc
```lua
CONFIG["SETTINGS"]["DataTools.LockedToolSettings"] = CONFIG["SETTINGS"]["DataTools.LockedToolSettings"] or {}
CONFIG["SETTINGS"]["DataTools.LockedToolSettings"]["web_search.tavily.apiKey"] = "ENC:v1:<base64-encoded encrypted data>"
CONFIG["SETTINGS"]["DataTools.LockedToolSettings"]["web_search.tavily.apiKey"] = "ENC:v2:<base64-encoded encrypted data>"
CONFIG["SETTINGS"]["DataTools.DefaultToolSettings"] = CONFIG["SETTINGS"]["DataTools.DefaultToolSettings"] or {}
CONFIG["SETTINGS"]["DataTools.DefaultToolSettings"]["web_search.tavily.searchDepth"] = "basic"
@@ -0,0 +1,32 @@
# Enterprise Encryption Export Format
- Status: Active
- Introduced: 2026-10-17
- Remove after: 2027-10-31
- Code references:
- `app/MindWork AI Studio/Settings/SettingsManager.cs` (`KeepTheLegacyEncryptionExportFormat` and the migration path in `ReadSettingsSnapshot`)
- `app/MindWork AI Studio/Settings/DataModel/DataApp.cs` (`EnterpriseEncryptionExportFormat`)
- `app/Tests/Settings/EnterpriseEncryptionExportFormatTests.cs`
## User Impact
From v26.10.1 on, exports can encrypt API keys, tokens, and passwords in the authenticated format `ENC:v2:`. Versions up to v26.9.1 read only `ENC:v1:`: they take a v2 value for plaintext and leave the key out.
Large organizations package updates themselves, and it takes them weeks to roll one out. Their administrators run the new version first, because they test and approve it. When such an administrator exported a provider in v2, nobody else in the organization could use its key until the update arrived everywhere.
The setting `DataApp.EnterpriseEncryptionExportFormat` decides the format, and new installations start with v2. This shim makes installations which existed before v26.10.1 start with v1 instead, so that their exports keep working for everybody until an administrator switches.
## Compatibility Behavior
Settings written before v26.10.1 lack the property `App.EnterpriseEncryptionExportFormat`. When `SettingsManager` reads such settings, from the settings file or from its backup, it sets the format to v1. Settings of an older schema version always get v1 while they are migrated. Once the settings are stored, they hold the format, and the shim changes nothing anymore.
Reading `ENC:v1:` values is no part of this shim: AI Studio keeps reading them for good, because organizations deployed configuration plugins with them.
## Removal Checklist
- Remove `KeepTheLegacyEncryptionExportFormat` and its call in `SettingsManager.TryDeserializeCurrentSettings`.
- Remove the line which sets v1 after `SettingsMigrations.Migrate` in `SettingsManager.ReadSettingsSnapshot`.
- Remove the tests of `app/Tests/Settings/EnterpriseEncryptionExportFormatTests.cs` which expect v1 for settings without the property, and keep the others.
- Keep the setting `DataApp.EnterpriseEncryptionExportFormat` and the v1 format: both stay.
- Update this document's status to `Removed`.
- No changelog entry is needed: an installation which skipped every version from v26.10.1 until then starts with v2, like a new one.