diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index 76aeb39..66f1a02 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -31,3 +31,15 @@
- Whenever possible, prefer OCORE_web_pdf / OCORE PDF functions for PDF-related tasks over rewriting.
- Do not use OCMS or OCMS_sharp; use only OCORE or OCORE_web.
+## Azure Key Vault — Secret Naming
+- Secret names must satisfy the pattern `^[0-9a-zA-Z-]+$` (alphanumerics and hyphens only; no underscores, dots, or spaces).
+- Hierarchy levels are separated by `--` (double hyphen), which maps to `:` in `IConfiguration`.
+- Underscores within a name segment are encoded as a single `-` in Key Vault and decoded back to `_` when the key is reconstructed.
+- The app prefix `fuchs` is prepended to every secret name.
+- Format: `{appname}--{Section}--{key-with-hyphens-for-underscores}`
+- Examples:
+ - `fuchs--ConnectionStrings--ocms-ConnectionString` → `ConnectionStrings:ocms_ConnectionString`
+ - `fuchs--Fuchs--SMS-APIKey` → `Fuchs:SMS_APIKey`
+ - `fuchs--Fuchs--Email--Main--password` → `Fuchs:Email:Main:password`
+- When adding new secrets: replace every `_` in the original config key with `-` for the Key Vault name, and add the entry to `ManagedSecretKeys` in `appsettings.json` (using the same hyphenated form without the `fuchs--` prefix).
+
diff --git a/.gitignore b/.gitignore
index ad90e1b..a510a17 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,4 +24,10 @@ dist/
/playwright-report/
/blob-report/
/playwright/.cache/
-/playwright/.auth/
\ No newline at end of file
+/playwright/.auth/
+# Secret Management DPAPI cache
+secrets.cache
+**/secrets.cache
+
+# Secret values file (never commit)
+Scripts/secrets.json
diff --git a/Fuchs/Docs/keyvault-dpapi-ocore-implementation.md b/Fuchs/Docs/keyvault-dpapi-ocore-implementation.md
new file mode 100644
index 0000000..b087e9a
--- /dev/null
+++ b/Fuchs/Docs/keyvault-dpapi-ocore-implementation.md
@@ -0,0 +1,933 @@
+# Agent Instructions: Key Vault + DPAPI — Multi-App Architektur (15 Projekte, 1 Server)
+
+## Ausgangslage
+
+- 15 Anwendungen (Web Apps, Console Apps, Windows Services, Worker Services)
+- Alle laufen auf demselben Windows-Server
+- Ein gemeinsamer Azure Key Vault
+- Secrets sind aktuell in `appsettings.json`, `web.config` oder Umgebungsvariablen
+- Zwei bestehende Shared Projects: **OCORE** und **OCORE_web**
+
+---
+
+## Aufteilung zwischen OCORE und OCORE_web
+
+```
+OCORE OCORE_web
+────────────────────────────── ──────────────────────────────
+Alles was NICHT Web-spezifisch Nur was WebApplicationBuilder
+ist. Funktioniert für: benötigt. Funktioniert für:
+
+ - Console Apps - ASP.NET Core Web Apps
+ - Windows Services - ASP.NET Core Web APIs
+ - Worker Services - Razor Pages / MVC
+ - WinForms / WPF
+ - Web Apps (via OCORE_web)
+
+Enthält: Enthält:
+ AppSecretsOptions SecretManagementWebExtensions
+ DpapiSecretsCache (eine einzige Datei)
+ DpapiCacheConfigurationSource
+ KeyVaultSyncService
+ PrefixKeyVaultSecretManager
+ SecretManagementExtensions
+ (IHostBuilder)
+```
+
+> OCORE_web referenziert OCORE — d.h. Web Apps brauchen nur OCORE_web
+> zu referenzieren, OCORE kommt transitiv mit.
+
+---
+
+## Architektur-Übersicht
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ Azure Key Vault │
+│ │
+│ crm--ConnectionStrings--Database → CRM Web App │
+│ crm--ExternalApi--ApiKey → CRM Web App │
+│ erp--ConnectionStrings--Database → ERP Web App │
+│ erp--Smtp--Password → ERP Web App │
+│ importer--ConnectionStrings--Db → Importer Console App │
+│ scheduler--ConnectionStrings--Db → Scheduler Windows Svc │
+│ ... │
+└──────────────────────────┬──────────────────────────────────────┘
+ │ Managed Identity (Server-Level)
+┌──────────────────────────▼──────────────────────────────────────┐
+│ Windows Server │
+│ │
+│ C:\inetpub\crm\ secrets.cache (DPAPI, crm-Entropy) │
+│ C:\inetpub\erp\ secrets.cache (DPAPI, erp-Entropy) │
+│ C:\services\importer\ secrets.cache (DPAPI, importer-Entropy) │
+│ C:\services\scheduler\secrets.cache (DPAPI, scheduler-Entropy)│
+│ ... │
+│ │
+│ OCORE.dll ← alle 15 Apps referenzieren dies │
+│ OCORE_web.dll ← nur Web Apps referenzieren dies zusätzlich │
+└─────────────────────────────────────────────────────────────────┘
+```
+
+### Wichtige Designentscheidungen
+
+| Thema | Entscheidung | Begründung |
+|---|---|---|
+| Key Vault | Ein gemeinsamer | Einfacher zu verwalten, RBAC auf Secret-Ebene möglich |
+| Namespacing | `{appname}--{key}` Präfix | Verhindert Kollisionen zwischen Apps |
+| DPAPI Scope | `LocalMachine` + App-spezifische Entropy | Isolation: App A kann Cache von App B nicht lesen |
+| Basiscode | OCORE | Kein Web-Framework nötig, funktioniert überall |
+| Web-Extension | OCORE_web | Nur eine Datei, dünne Schicht über OCORE |
+| App-Identity | Eine Server-Managed-Identity | Alle Apps teilen dieselbe Identity, RBAC per Secret |
+
+---
+
+## Teil 1: OCORE — NuGet-Pakete hinzufügen
+
+```bash
+cd OCORE
+dotnet add package Azure.Extensions.AspNetCore.Configuration.Secrets
+dotnet add package Azure.Identity
+dotnet add package Azure.Security.KeyVault.Secrets
+dotnet add package Microsoft.Extensions.Hosting.Abstractions
+dotnet add package Microsoft.Extensions.Configuration.Abstractions
+dotnet add package Microsoft.Extensions.Logging.Abstractions
+```
+
+---
+
+## Teil 2: OCORE — Dateien anlegen
+
+Neue Ordnerstruktur **innerhalb von OCORE** (bestehende Struktur bleibt unverändert):
+
+```
+OCORE/
+ ... (bestehende Dateien)
+ Secrets/
+ Models/
+ AppSecretsOptions.cs
+ Cache/
+ DpapiSecretsCache.cs
+ DpapiCacheConfigurationSource.cs
+ Sync/
+ KeyVaultSyncService.cs
+ Extensions/
+ SecretManagementExtensions.cs ← IHostBuilder Extension + gemeinsame Logik
+ PrefixKeyVaultSecretManager.cs
+```
+
+---
+
+### 2.1 OCORE/Secrets/Models/AppSecretsOptions.cs
+
+```csharp
+namespace OCORE.Secrets.Models;
+
+public class AppSecretsOptions
+{
+ public const string SectionName = "SecretManagement";
+
+ ///
+ /// Vollständige URI des Azure Key Vault.
+ /// Beispiel: https://company-vault.vault.azure.net/
+ ///
+ public string VaultUri { get; set; } = string.Empty;
+
+ ///
+ /// App-Name als Präfix für alle Secrets in Key Vault.
+ /// Beispiel: "crm" → Secret "crm--ConnectionStrings--Database"
+ /// Nur Kleinbuchstaben und Bindestriche (Key Vault Konvention).
+ ///
+ public string AppName { get; set; } = string.Empty;
+
+ ///
+ /// Pfad zur DPAPI-Cache-Datei (relativ zum App-Verzeichnis).
+ ///
+ public string CacheFilePath { get; set; } = "secrets.cache";
+
+ ///
+ /// Sync-Intervall in Stunden.
+ ///
+ public int SyncIntervalHours { get; set; } = 6;
+
+ ///
+ /// Secret-Namen ohne App-Präfix.
+ /// Beispiel: ["ConnectionStrings--Database", "ExternalApi--ApiKey"]
+ ///
+ public List ManagedSecretKeys { get; set; } = [];
+}
+```
+
+---
+
+### 2.2 OCORE/Secrets/Cache/DpapiSecretsCache.cs
+
+```csharp
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using Microsoft.Extensions.Logging;
+
+namespace OCORE.Secrets.Cache;
+
+public interface IDpapiSecretsCache
+{
+ Task WriteAsync(Dictionary secrets);
+ Task> ReadAsync();
+ bool Exists();
+}
+
+public class DpapiSecretsCache : IDpapiSecretsCache
+{
+ private readonly string _filePath;
+ private readonly byte[] _entropy;
+ private readonly ILogger _logger;
+
+ ///
+ /// Entropy wird aus AppName abgeleitet — verhindert dass eine andere App
+ /// auf demselben Server den Cache dieser App lesen kann.
+ ///
+ public DpapiSecretsCache(string filePath, string appName, ILogger logger)
+ {
+ _filePath = filePath;
+ _entropy = Encoding.UTF8.GetBytes($"dpapi-entropy-{appName}");
+ _logger = logger;
+ }
+
+ public async Task WriteAsync(Dictionary secrets)
+ {
+ try
+ {
+ var json = JsonSerializer.SerializeToUtf8Bytes(secrets);
+ var encrypted = ProtectedData.Protect(json, _entropy, DataProtectionScope.LocalMachine);
+ await File.WriteAllBytesAsync(_filePath, encrypted);
+ _logger.LogInformation("[OCORE.Secrets] DPAPI Cache geschrieben: {Count} Secrets → {Path}",
+ secrets.Count, _filePath);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "[OCORE.Secrets] DPAPI Cache konnte nicht geschrieben werden: {Path}", _filePath);
+ }
+ }
+
+ public async Task> ReadAsync()
+ {
+ if (!Exists()) return [];
+
+ try
+ {
+ var encrypted = await File.ReadAllBytesAsync(_filePath);
+ var decrypted = ProtectedData.Unprotect(encrypted, _entropy, DataProtectionScope.LocalMachine);
+ return JsonSerializer.Deserialize>(decrypted) ?? [];
+ }
+ catch (CryptographicException ex)
+ {
+ _logger.LogError(ex,
+ "[OCORE.Secrets] DPAPI Entschlüsselung fehlgeschlagen — AppName korrekt? {Path}", _filePath);
+ return [];
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "[OCORE.Secrets] DPAPI Cache Lesefehler: {Path}", _filePath);
+ return [];
+ }
+ }
+
+ public bool Exists() => File.Exists(_filePath);
+}
+```
+
+---
+
+### 2.3 OCORE/Secrets/Cache/DpapiCacheConfigurationSource.cs
+
+```csharp
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging;
+
+namespace OCORE.Secrets.Cache;
+
+public class DpapiCacheConfigurationSource : IConfigurationSource
+{
+ private readonly string _filePath;
+ private readonly string _appName;
+
+ public DpapiCacheConfigurationSource(string filePath, string appName)
+ {
+ _filePath = filePath;
+ _appName = appName;
+ }
+
+ public IConfigurationProvider Build(IConfigurationBuilder builder)
+ => new DpapiCacheConfigurationProvider(_filePath, _appName);
+}
+
+public class DpapiCacheConfigurationProvider : ConfigurationProvider
+{
+ private readonly string _filePath;
+ private readonly string _appName;
+
+ public DpapiCacheConfigurationProvider(string filePath, string appName)
+ {
+ _filePath = filePath;
+ _appName = appName;
+ }
+
+ public override void Load()
+ {
+ if (!File.Exists(_filePath)) return;
+
+ try
+ {
+ using var loggerFactory = LoggerFactory.Create(b => b.AddConsole());
+ var logger = loggerFactory.CreateLogger();
+ var cache = new DpapiSecretsCache(_filePath, _appName, logger);
+ var secrets = cache.ReadAsync().GetAwaiter().GetResult();
+
+ // "crm--ConnectionStrings--Database" → "ConnectionStrings:Database"
+ var prefix = $"{_appName}--";
+ Data = secrets
+ .Where(kvp => kvp.Key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
+ .ToDictionary(
+ kvp => kvp.Key[prefix.Length..].Replace("--", ":"),
+ kvp => (string?)kvp.Value);
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"[OCORE.Secrets] DPAPI Cache Ladefehler: {ex.Message}");
+ Data = [];
+ }
+ }
+}
+```
+
+---
+
+### 2.4 OCORE/Secrets/Sync/KeyVaultSyncService.cs
+
+```csharp
+using Azure.Security.KeyVault.Secrets;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using OCORE.Secrets.Cache;
+using OCORE.Secrets.Models;
+
+namespace OCORE.Secrets.Sync;
+
+///
+/// Läuft als BackgroundService in jedem Host-Typ:
+/// Web Apps, Console Apps, Windows Services, Worker Services.
+/// Synct Secrets aus Key Vault → DPAPI-Cache, periodisch.
+///
+public class KeyVaultSyncService : BackgroundService
+{
+ private readonly SecretClient _secretClient;
+ private readonly IDpapiSecretsCache _cache;
+ private readonly IConfigurationRoot _configRoot;
+ private readonly AppSecretsOptions _options;
+ private readonly ILogger _logger;
+
+ public KeyVaultSyncService(
+ SecretClient secretClient,
+ IDpapiSecretsCache cache,
+ IConfiguration configuration,
+ IOptions options,
+ ILogger logger)
+ {
+ _secretClient = secretClient;
+ _cache = cache;
+ _configRoot = (IConfigurationRoot)configuration;
+ _options = options.Value;
+ _logger = logger;
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ await SyncAsync(stoppingToken);
+
+ using var timer = new PeriodicTimer(TimeSpan.FromHours(_options.SyncIntervalHours));
+ while (await timer.WaitForNextTickAsync(stoppingToken))
+ {
+ await SyncAsync(stoppingToken);
+ }
+ }
+
+ private async Task SyncAsync(CancellationToken cancellationToken)
+ {
+ _logger.LogInformation("[OCORE.Secrets:{App}] Key Vault Sync gestartet", _options.AppName);
+ var synced = new Dictionary();
+
+ foreach (var key in _options.ManagedSecretKeys)
+ {
+ var fullName = $"{_options.AppName}--{key}";
+ try
+ {
+ var secret = await _secretClient.GetSecretAsync(fullName, version: null, cancellationToken);
+ synced[fullName] = secret.Value.Value;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning("[OCORE.Secrets:{App}] Secret '{Name}' nicht geladen: {Error}",
+ _options.AppName, fullName, ex.Message);
+ }
+ }
+
+ if (synced.Count > 0)
+ {
+ await _cache.WriteAsync(synced);
+ _configRoot.Reload();
+ _logger.LogInformation("[OCORE.Secrets:{App}] Sync OK: {Count}/{Total}",
+ _options.AppName, synced.Count, _options.ManagedSecretKeys.Count);
+ }
+ else
+ {
+ _logger.LogWarning("[OCORE.Secrets:{App}] Sync: Keine Secrets geladen — Cache unverändert",
+ _options.AppName);
+ }
+ }
+}
+```
+
+---
+
+### 2.5 OCORE/Secrets/Extensions/PrefixKeyVaultSecretManager.cs
+
+```csharp
+using Azure.Extensions.AspNetCore.Configuration.Secrets;
+using Azure.Security.KeyVault.Secrets;
+
+namespace OCORE.Secrets.Extensions;
+
+///
+/// Filtert beim Key Vault Laden nur Secrets mit dem App-Präfix
+/// und konvertiert den Namen in einen IConfiguration-Key.
+/// "crm--ConnectionStrings--Database" → "ConnectionStrings:Database"
+///
+public class PrefixKeyVaultSecretManager : KeyVaultSecretManager
+{
+ private readonly string _prefix;
+
+ public PrefixKeyVaultSecretManager(string appName)
+ => _prefix = $"{appName}--";
+
+ public override bool Load(SecretProperties secret)
+ => secret.Name.StartsWith(_prefix, StringComparison.OrdinalIgnoreCase);
+
+ public override string GetKey(KeyVaultSecret secret)
+ => secret.Name[_prefix.Length..].Replace("--", ":");
+}
+```
+
+---
+
+### 2.6 OCORE/Secrets/Extensions/SecretManagementExtensions.cs
+
+```csharp
+using Azure.Identity;
+using Azure.Security.KeyVault.Secrets;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using OCORE.Secrets.Cache;
+using OCORE.Secrets.Models;
+using OCORE.Secrets.Sync;
+
+namespace OCORE.Secrets.Extensions;
+
+public static class SecretManagementExtensions
+{
+ ///
+ /// Für Console Apps, Worker Services, Windows Services, WinForms/WPF.
+ /// Verwendung: Host.CreateDefaultBuilder(args).AddSecretManagement()
+ ///
+ public static IHostBuilder AddSecretManagement(this IHostBuilder hostBuilder)
+ {
+ hostBuilder.ConfigureAppConfiguration((context, config) =>
+ {
+ var built = config.Build();
+ var options = built.GetSection(AppSecretsOptions.SectionName).Get()
+ ?? new AppSecretsOptions();
+
+ if (!IsConfigured(options)) return;
+
+ var cacheFilePath = ResolveCachePath(
+ context.HostingEnvironment.ContentRootPath, options.CacheFilePath);
+
+ // 1. DPAPI-Cache (Fallback)
+ config.Add(new DpapiCacheConfigurationSource(cacheFilePath, options.AppName));
+
+ // 2. Key Vault (primär — überschreibt Cache wenn erreichbar)
+ TryAddKeyVault(config, options);
+ });
+
+ hostBuilder.ConfigureServices((context, services) =>
+ {
+ RegisterCoreServices(services, context.Configuration);
+ });
+
+ return hostBuilder;
+ }
+
+ ///
+ /// Gemeinsame Service-Registrierung — wird von OCORE und OCORE_web verwendet.
+ ///
+ public static void RegisterCoreServices(IServiceCollection services, IConfiguration configuration)
+ {
+ var options = configuration
+ .GetSection(AppSecretsOptions.SectionName)
+ .Get() ?? new AppSecretsOptions();
+
+ if (!IsConfigured(options)) return;
+
+ services.Configure(
+ configuration.GetSection(AppSecretsOptions.SectionName));
+
+ services.AddSingleton(new SecretClient(
+ new Uri(options.VaultUri),
+ new DefaultAzureCredential()));
+
+ services.AddSingleton(sp =>
+ new DpapiSecretsCache(
+ ResolveCachePath(AppContext.BaseDirectory, options.CacheFilePath),
+ options.AppName,
+ sp.GetRequiredService>()));
+
+ services.AddHostedService();
+ }
+
+ public static void TryAddKeyVault(IConfigurationBuilder config, AppSecretsOptions options)
+ {
+ try
+ {
+ config.AddAzureKeyVault(
+ new Uri(options.VaultUri),
+ new DefaultAzureCredential(),
+ new PrefixKeyVaultSecretManager(options.AppName));
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"[OCORE.Secrets] Key Vault nicht erreichbar: {ex.Message}");
+ Console.WriteLine("[OCORE.Secrets] Starte mit DPAPI-Cache");
+ }
+ }
+
+ public static bool IsConfigured(AppSecretsOptions options)
+ => !string.IsNullOrEmpty(options.VaultUri) && !string.IsNullOrEmpty(options.AppName);
+
+ public static string ResolveCachePath(string basePath, string cacheFilePath)
+ => Path.IsPathRooted(cacheFilePath)
+ ? cacheFilePath
+ : Path.Combine(basePath, cacheFilePath);
+}
+```
+
+---
+
+## Teil 3: OCORE_web — Einzige neue Datei
+
+OCORE_web referenziert OCORE bereits. Nur eine neue Datei hinzufügen:
+
+```
+OCORE_web/
+ ... (bestehende Dateien)
+ Secrets/
+ SecretManagementWebExtensions.cs ← einzige neue Datei in OCORE_web
+```
+
+### 3.1 OCORE_web/Secrets/SecretManagementWebExtensions.cs
+
+```csharp
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.Configuration;
+using OCORE.Secrets.Extensions;
+using OCORE.Secrets.Models;
+
+namespace OCORE_web.Secrets;
+
+public static class SecretManagementWebExtensions
+{
+ ///
+ /// Für ASP.NET Core Web Apps und Web APIs.
+ /// Verwendung: builder.AddSecretManagement()
+ ///
+ public static WebApplicationBuilder AddSecretManagement(
+ this WebApplicationBuilder builder)
+ {
+ var options = builder.Configuration
+ .GetSection(AppSecretsOptions.SectionName)
+ .Get() ?? new AppSecretsOptions();
+
+ if (!SecretManagementExtensions.IsConfigured(options)) return builder;
+
+ var cacheFilePath = SecretManagementExtensions.ResolveCachePath(
+ builder.Environment.ContentRootPath, options.CacheFilePath);
+
+ // 1. DPAPI-Cache (Fallback)
+ builder.Configuration.Add(
+ new OCORE.Secrets.Cache.DpapiCacheConfigurationSource(cacheFilePath, options.AppName));
+
+ // 2. Key Vault (primär)
+ SecretManagementExtensions.TryAddKeyVault(builder.Configuration, options);
+
+ // 3. Services — aus OCORE (kein duplizierter Code)
+ SecretManagementExtensions.RegisterCoreServices(builder.Services, builder.Configuration);
+
+ return builder;
+ }
+}
+```
+
+---
+
+## Teil 4: Verwendung je App-Typ
+
+### Web App (referenziert OCORE_web)
+
+```csharp
+// Program.cs
+using OCORE_web.Secrets;
+
+var builder = WebApplication.CreateBuilder(args);
+builder.AddSecretManagement(); // ← aus OCORE_web
+
+var app = builder.Build();
+app.Run();
+```
+
+### Console App / Worker Service / Windows Service (referenziert OCORE)
+
+```csharp
+// Program.cs
+using OCORE.Secrets.Extensions;
+
+// Console / Worker
+var host = Host.CreateDefaultBuilder(args)
+ .AddSecretManagement() // ← aus OCORE
+ .ConfigureServices(services =>
+ {
+ services.AddHostedService();
+ })
+ .Build();
+
+await host.RunAsync();
+```
+
+```csharp
+// Windows Service — identisch, nur UseWindowsService() zusätzlich
+var host = Host.CreateDefaultBuilder(args)
+ .UseWindowsService()
+ .AddSecretManagement() // ← aus OCORE
+ .ConfigureServices(services =>
+ {
+ services.AddHostedService();
+ })
+ .Build();
+
+await host.RunAsync();
+```
+
+### WinForms / WPF (referenziert OCORE)
+
+```csharp
+// Program.cs — Generic Host nachrüsten
+using OCORE.Secrets.Extensions;
+
+var host = Host.CreateDefaultBuilder()
+ .AddSecretManagement() // ← aus OCORE
+ .ConfigureServices(services =>
+ {
+ services.AddSingleton();
+ })
+ .Build();
+
+Application.Run(host.Services.GetRequiredService());
+```
+
+---
+
+## Teil 5: appsettings.json (identisch für alle App-Typen)
+
+```json
+{
+ "SecretManagement": {
+ "VaultUri": "https://company-vault.vault.azure.net/",
+ "AppName": "crm",
+ "CacheFilePath": "secrets.cache",
+ "SyncIntervalHours": 6,
+ "ManagedSecretKeys": [
+ "ConnectionStrings--Database",
+ "ExternalApi--ApiKey"
+ ]
+ },
+ "ConnectionStrings": {
+ "Database": "MANAGED_BY_KEYVAULT"
+ }
+}
+```
+
+> `AppName` ist der einzige Wert der sich zwischen den 15 Projekten unterscheidet.
+
+---
+
+## Teil 6: Key Vault Naming-Konvention
+
+```
+Format: {appname}--{ConfigSection}--{ConfigKey}
+
+Web Apps:
+ crm--ConnectionStrings--Database
+ crm--ExternalApi--ApiKey
+ erp--ConnectionStrings--Database
+ erp--Smtp--Password
+ portal--Auth--JwtSecret
+
+Non-Web Apps:
+ importer--ConnectionStrings--Database
+ scheduler--ConnectionStrings--Database
+ scheduler--Smtp--Password
+```
+
+### Naming-Regeln
+
+- Nur alphanumerische Zeichen und einfache Bindestriche `-`
+- Hierarchie-Trenner: `--` (doppelter Bindestrich)
+- Kein Unterstrich, kein Punkt, kein Leerzeichen
+- Maximal 127 Zeichen
+
+---
+
+## Teil 7: Einmalige Migration — Alle Apps
+
+### 7.1 Inventur-Script
+
+```powershell
+# inventarisierung.ps1
+param([string]$SolutionRoot = "C:\Projects\MySolution")
+
+$patterns = @(
+ 'password\s*=\s*["\'][^"\']+["\']',
+ 'connectionstring\s*=\s*["\'][^"\']+["\']',
+ 'apikey\s*=\s*["\'][^"\']+["\']',
+ 'secret\s*=\s*["\'][^"\']+["\']',
+ 'pwd\s*=\s*[^;]+'
+)
+
+Get-ChildItem -Path $SolutionRoot -Recurse -Include "*.json","*.config","*.cs" |
+ Where-Object { $_.FullName -notmatch '\\(bin|obj|\.git)\\' } |
+ ForEach-Object {
+ $file = $_
+ $content = Get-Content $file.FullName -Raw -ErrorAction SilentlyContinue
+ foreach ($pattern in $patterns) {
+ if ($content -match $pattern) {
+ Write-Host "⚠️ $($file.FullName)" -ForegroundColor Yellow
+ break
+ }
+ }
+ }
+```
+
+### 7.2 Migration Script
+
+```powershell
+# migrate-all-to-keyvault.ps1
+param([Parameter(Mandatory)][string]$VaultName)
+
+Connect-AzAccount
+
+$allSecrets = @{
+ # Web Apps
+ "crm--ConnectionStrings--Database" = "Server=PROD-SQL;Database=CrmDb;..."
+ "crm--ExternalApi--ApiKey" = "crm-api-key"
+ "erp--ConnectionStrings--Database" = "Server=PROD-SQL;Database=ErpDb;..."
+ "erp--Smtp--Password" = "smtp-passwort"
+ "portal--ConnectionStrings--Database" = "Server=PROD-SQL;Database=PortalDb;..."
+ "portal--Auth--JwtSecret" = "jwt-secret"
+
+ # Non-Web Apps (OCORE)
+ "importer--ConnectionStrings--Database" = "Server=PROD-SQL;Database=ImportDb;..."
+ "scheduler--ConnectionStrings--Database" = "Server=PROD-SQL;Database=SchedDb;..."
+ "scheduler--Smtp--Password" = "smtp-passwort"
+
+ # Weitere Apps ergänzen...
+}
+
+$total = $allSecrets.Count
+$done = 0
+
+foreach ($name in $allSecrets.Keys) {
+ $value = ConvertTo-SecureString $allSecrets[$name] -AsPlainText -Force
+ Set-AzKeyVaultSecret -VaultName $VaultName -Name $name -SecretValue $value
+ $done++
+ Write-Progress -Activity "Migriere Secrets" -Status "$name" -PercentComplete (($done/$total)*100)
+ Write-Host "✅ [$done/$total] $name"
+}
+
+Write-Host "`nMigration abgeschlossen." -ForegroundColor Green
+```
+
+### 7.3 Bereinigung
+
+```powershell
+# bereinigung.ps1
+$projektOrdner = @(
+ "C:\Projects\CRM",
+ "C:\Projects\ERP",
+ "C:\Projects\Portal",
+ "C:\Projects\Importer",
+ "C:\Projects\Scheduler"
+ # Alle Pfade eintragen
+)
+
+foreach ($ordner in $projektOrdner) {
+ $file = Join-Path $ordner "appsettings.json"
+ if (Test-Path $file) {
+ $json = Get-Content $file -Raw | ConvertFrom-Json
+ if ($json.ConnectionStrings) {
+ $json.ConnectionStrings.PSObject.Properties |
+ ForEach-Object { $_.Value = "MANAGED_BY_KEYVAULT" }
+ }
+ $json | ConvertTo-Json -Depth 10 | Set-Content $file
+ Write-Host "✅ Bereinigt: $file"
+ }
+}
+```
+
+---
+
+## Teil 8: Server-Setup (einmalig)
+
+### 8.1 Managed Identity + Key Vault Zugriff
+
+```powershell
+# server-setup.ps1 — einmalig als Admin
+
+# Managed Identity der VM abrufen
+$identity = Get-AzVM -ResourceGroupName "MyRG" -Name "PROD-SERVER-01"
+$objectId = $identity.Identity.PrincipalId
+
+# Key Vault: Server darf lesen, nicht schreiben
+Set-AzKeyVaultAccessPolicy `
+ -VaultName "company-vault" `
+ -ObjectId $objectId `
+ -PermissionsToSecrets Get, List
+
+Write-Host "✅ Key Vault Zugriff konfiguriert für Object ID: $objectId"
+```
+
+### 8.2 Schreibrechte für Cache-Dateien
+
+```powershell
+# Für IIS Web Apps (App Pool User)
+$webApps = @("CRM", "ERP", "Portal")
+foreach ($pool in $webApps) {
+ $appPath = "C:\inetpub\$pool"
+ $user = "IIS AppPool\$pool"
+ $acl = Get-Acl $appPath
+ $rule = New-Object System.Security.AccessControl.FileSystemAccessRule(
+ $user, "Modify", "ContainerInherit,ObjectInherit", "None", "Allow")
+ $acl.SetAccessRule($rule)
+ Set-Acl -Path $appPath -AclObject $acl
+ Write-Host "✅ Web App Schreibrecht: $user → $appPath"
+}
+
+# Für Windows Services / Console Apps (LocalSystem oder dedizierter Service-User)
+$serviceApps = @("Importer", "Scheduler")
+foreach ($svc in $serviceApps) {
+ $appPath = "C:\services\$svc"
+ # LocalSystem hat standardmäßig Schreibrecht — nur prüfen ob Ordner existiert
+ if (-not (Test-Path $appPath)) {
+ New-Item -ItemType Directory -Path $appPath
+ Write-Host "✅ Service-Ordner erstellt: $appPath"
+ }
+}
+```
+
+---
+
+## Teil 9: .gitignore (Solution-Level)
+
+```gitignore
+# Secret Management
+secrets.cache
+**/secrets.cache
+
+# Konfigurationsdateien mit echten Werten
+appsettings.Production.json
+appsettings.Staging.json
+
+# Zertifikate
+*.pfx
+*.p12
+*.key
+```
+
+---
+
+## Teil 10: Pro-Projekt Checkliste
+
+### Web Apps (OCORE_web)
+
+```
+[ ] 1. AppName festlegen (kurz, eindeutig, Kleinbuchstaben)
+[ ] 2. OCORE_web bereits referenziert (transitiv: OCORE kommt mit)
+[ ] 3. Program.cs:
+ using OCORE_web.Secrets;
+ builder.AddSecretManagement();
+[ ] 4. appsettings.json: SecretManagement-Block mit korrektem AppName
+[ ] 5. ManagedSecretKeys vollständig auflisten
+[ ] 6. Secrets in Key Vault mit {appname}-- Präfix vorhanden
+[ ] 7. Klartext-Secrets aus appsettings.json / web.config entfernt
+[ ] 8. secrets.cache in .gitignore
+[ ] 9. Logs nach Deploy prüfen:
+ "[OCORE.Secrets:crm] Sync OK" → ✅
+ "[OCORE.Secrets:crm] Key Vault nicht erreichbar" → ❌
+```
+
+### Non-Web Apps (OCORE)
+
+```
+[ ] 1. AppName festlegen (kurz, eindeutig, Kleinbuchstaben)
+[ ] 2. OCORE bereits referenziert (vorhanden)
+[ ] 3. Program.cs:
+ using OCORE.Secrets.Extensions;
+ Host.CreateDefaultBuilder(args).AddSecretManagement()
+ (Windows Service: zusätzlich .UseWindowsService())
+[ ] 4. appsettings.json: SecretManagement-Block mit korrektem AppName
+[ ] 5. ManagedSecretKeys vollständig auflisten
+[ ] 6. Secrets in Key Vault mit {appname}-- Präfix vorhanden
+[ ] 7. Klartext-Secrets aus Konfigurationsdateien entfernt
+[ ] 8. secrets.cache in .gitignore
+[ ] 9. Logs prüfen nach erstem Start
+```
+
+---
+
+## Zusammenfassung der Artefakte
+
+```
+OCORE/Secrets/ ← Einmalig anlegen
+ Models/AppSecretsOptions.cs
+ Cache/DpapiSecretsCache.cs
+ Cache/DpapiCacheConfigurationSource.cs
+ Sync/KeyVaultSyncService.cs
+ Extensions/PrefixKeyVaultSecretManager.cs
+ Extensions/SecretManagementExtensions.cs ← IHostBuilder + shared helpers
+
+OCORE_web/Secrets/ ← Einmalig anlegen (1 Datei)
+ SecretManagementWebExtensions.cs ← WebApplicationBuilder Extension
+
+Pro Web-Projekt (15x oder subset):
+ Program.cs → +2 Zeilen (using + builder.AddSecretManagement())
+ appsettings.json → SecretManagement-Block, keine Secrets mehr
+
+Pro Non-Web-Projekt:
+ Program.cs → +2 Zeilen (using + .AddSecretManagement())
+ appsettings.json → SecretManagement-Block, keine Secrets mehr
+
+Scripts/
+ inventarisierung.ps1 ← Wo liegen aktuell Secrets?
+ migrate-all-to-keyvault.ps1 ← Einmalige Migration aller Apps
+ bereinigung.ps1 ← Platzhalter setzen nach Migration
+ server-setup.ps1 ← Managed Identity + ACLs (einmalig)
+```
diff --git a/Fuchs/Program.cs b/Fuchs/Program.cs
index e3a607e..5899e61 100644
--- a/Fuchs/Program.cs
+++ b/Fuchs/Program.cs
@@ -1,5 +1,6 @@
using Fuchs.intranet;
using Fuchs.Logging;
+using OCORE_web.Secrets;
using Fuchs.Services;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.StaticFiles;
@@ -28,6 +29,9 @@ public class Program
.SetMinimumLevel(LogLevel.Debug)
.AddFuchsLogging();
+ // Key Vault + DPAPI secret management (must run before FuchsOcmsIntranet.Initialize)
+ builder.AddSecretManagement();
+
// Initialize the Fuchs intranet singleton with configuration
FuchsOcmsIntranet.Initialize(builder.Configuration);
diff --git a/Fuchs/appsettings.json b/Fuchs/appsettings.json
index 4a2ce3c..0a21eaf 100644
--- a/Fuchs/appsettings.json
+++ b/Fuchs/appsettings.json
@@ -1,4 +1,20 @@
{
+ "SecretManagement": {
+ "VaultUri": "https://pcwkeys.vault.azure.net/",
+ "AppName": "fuchs",
+ "CacheFilePath": "secrets.cache",
+ "SyncIntervalHours": 6,
+ "ManagedSecretKeys": [
+ "ConnectionStrings--ocms-ConnectionString",
+ "ConnectionStrings--fuchs-fds-ConnectionString",
+ "Fuchs--SMS-APIKey",
+ "Fuchs--Email--Main--password",
+ "Fuchs--Email--Fds--password",
+ "Fuchs--Email--Service--password",
+ "Fuchs--fuchs-captcha-TOTP",
+ "Fuchs--fuchs-intranet-TOTP"
+ ]
+ },
"Logging": {
"LogLevel": {
"Default": "Information",
@@ -7,8 +23,8 @@
},
"AllowedHosts": "*",
"ConnectionStrings": {
- "ocms_ConnectionString": "Data Source=MSSQL4.NBG4.DOMAINXYZ.DE,10439;Initial Catalog=site_fuchs_dev;Persist Security Info=False;TrustServerCertificate=true;Encrypt=true;User ID=fuchs_web;password='Bt5pL/cJg9oxb5';Connect Timeout=60;Load Balance Timeout=240;Max Pool Size=500;",
- "fuchs_fds_ConnectionString": "Data Source=MSSQL4.NBG4.DOMAINXYZ.DE,10439;Initial Catalog=site_fuchs_dev;Persist Security Info=False;TrustServerCertificate=true;Encrypt=true;User ID=fuchs_dev;password='!Po@cGZ5bUn37khO';Connect Timeout=60;Load Balance Timeout=240;Max Pool Size=500;"
+ "ocms_ConnectionString": "MANAGED_BY_KEYVAULT",
+ "fuchs_fds_ConnectionString": "MANAGED_BY_KEYVAULT"
},
"Fuchs": {
"ocms_guid": "00094b8f-a822-4e9c-b627-87802f93fca8",
@@ -16,9 +32,9 @@
"ocms_default_locale": "de",
"fuchs_guid": "cbfc57b3-6b85-4bbc-ab68-3b2c7408af5e",
"fuchs_intranet_guid": "cbfc57b3-6b85-4bbc-ab68-3b2c7408af5e",
- "fuchs_captcha_TOTP": "4OXKGB3KS3VZNIUTTQLHECRUVN7ZDEFGSXYVU56D7UCKQZK7VHK7ZN",
- "fuchs_intranet_TOTP": "ZNQIUF4KC5XSL2ZXK6VQIZYG74SAMW7FDAGT7ZOVYFJCXBJ47RQW3O",
- "SMS_APIKey": "VLbm04ILlDby4EHjqolI9L95bAnfsipJcli0uvppMBHVq0BI1YR2gvpbKJRWDINu",
+ "fuchs_captcha_TOTP": "MANAGED_BY_KEYVAULT",
+ "fuchs_intranet_TOTP": "MANAGED_BY_KEYVAULT",
+ "SMS_APIKey": "MANAGED_BY_KEYVAULT",
"Email": {
"Main": {
"alias": "Sebastian Fuchs - Bad und Heizung",
@@ -29,7 +45,7 @@
"port": 587,
"security": "StartTls",
"username": "anfrage@sanitaerfuchs.de",
- "password": "DsCG8wxc4!Cu9"
+ "password": "MANAGED_BY_KEYVAULT"
},
"Fds": {
"alias": "Sebastian Fuchs - Bad und Heizung",
@@ -40,7 +56,7 @@
"port": 587,
"security": "StartTls",
"username": "rechnungen@sanitaerfuchs.de",
- "password": "8M9#s7TVg6b"
+ "password": "MANAGED_BY_KEYVAULT"
},
"Service": {
"alias": "ProcessWeb Service",
@@ -51,7 +67,7 @@
"port": 587,
"security": "StartTls",
"username": "service@emails.processweb.de",
- "password": "Uk84za4Qzba4ij"
+ "password": "MANAGED_BY_KEYVAULT"
},
"TestAddresses": "st.ott@web.de,info@processweb.de"
}
diff --git a/Scripts/migrate-fuchs-secrets-to-keyvault.ps1 b/Scripts/migrate-fuchs-secrets-to-keyvault.ps1
new file mode 100644
index 0000000..b2a4efb
--- /dev/null
+++ b/Scripts/migrate-fuchs-secrets-to-keyvault.ps1
@@ -0,0 +1,136 @@
+<#
+.SYNOPSIS
+ Migrates Fuchs Intranet secrets to Azure Key Vault "pcwkeys".
+
+.DESCRIPTION
+ Reads secret values from a JSON file and uploads them to Key Vault
+ under the "fuchs--" prefix convention used by the OCORE SecretManagement library.
+
+ Secret naming: fuchs--{ConfigSection}--{ConfigKey}
+ e.g. "fuchs--ConnectionStrings--ocms_ConnectionString"
+
+ The JSON file must have the format:
+ {
+ "fuchs--ConnectionStrings--ocms_ConnectionString": "actual-value",
+ ...
+ }
+
+.NOTES
+ Run once as part of the initial Key Vault migration.
+ NEVER commit the secrets JSON file - it is listed in .gitignore.
+ Requires: Az PowerShell module (Install-Module Az -Scope CurrentUser)
+ Requires: Key Vault Secrets Officer role (or legacy Access Policy: Set + Get + List)
+
+.EXAMPLE
+ .\migrate-fuchs-secrets-to-keyvault.ps1 -SecretsFile .\Scripts\secrets.json
+ .\migrate-fuchs-secrets-to-keyvault.ps1 -SecretsFile .\Scripts\secrets.json -WhatIf
+#>
+
+[CmdletBinding(SupportsShouldProcess)]
+param(
+ [Parameter(Mandatory)]
+ [string] $SecretsFile,
+
+ [string] $VaultName = "pcwkeys"
+)
+
+# ---------------------------------------------------------------------------
+# 1. Login check
+# ---------------------------------------------------------------------------
+$ctx = Get-AzContext -ErrorAction SilentlyContinue
+if (-not $ctx) {
+ Write-Host "Not logged in - running Connect-AzAccount..." -ForegroundColor Yellow
+ Connect-AzAccount
+}
+else {
+ Write-Host "Using Azure account: $($ctx.Account.Id)" -ForegroundColor Cyan
+}
+
+# ---------------------------------------------------------------------------
+# 2. Load secrets from JSON file
+# ---------------------------------------------------------------------------
+if (-not (Test-Path $SecretsFile)) {
+ Write-Host ""
+ Write-Host "ERROR: Secrets file not found: $SecretsFile" -ForegroundColor Red
+ Write-Host "Create it from the template: Scripts\secrets.json" -ForegroundColor Yellow
+ exit 1
+}
+
+try {
+ $json = Get-Content $SecretsFile -Raw -Encoding UTF8 -ErrorAction Stop
+ $parsed = $json | ConvertFrom-Json -ErrorAction Stop
+ $secrets = [ordered]@{}
+ $parsed.PSObject.Properties | ForEach-Object { $secrets[$_.Name] = $_.Value }
+}
+catch {
+ Write-Host ""
+ Write-Host "ERROR: Failed to parse $SecretsFile - $($_.Exception.Message)" -ForegroundColor Red
+ exit 1
+}
+
+Write-Host "Loaded $($secrets.Count) secrets from: $SecretsFile" -ForegroundColor Cyan
+
+# ---------------------------------------------------------------------------
+# 3. Validate - abort if any value is still empty
+# ---------------------------------------------------------------------------
+$empty = $secrets.Keys | Where-Object { [string]::IsNullOrWhiteSpace($secrets[$_]) }
+if ($empty) {
+ Write-Host ""
+ Write-Host "ERROR: The following secrets have no value set:" -ForegroundColor Red
+ $empty | ForEach-Object { Write-Host " $_" -ForegroundColor Red }
+ Write-Host ""
+ Write-Host "Fill in the values in secrets.json, then run again." -ForegroundColor Yellow
+ exit 1
+}
+
+# ---------------------------------------------------------------------------
+# 4. Write secrets to Key Vault
+# ---------------------------------------------------------------------------
+$total = $secrets.Count
+$done = 0
+$failed = @()
+
+Write-Host ""
+Write-Host "Migrating $total secrets to Key Vault '$VaultName'..." -ForegroundColor Cyan
+Write-Host ""
+
+foreach ($name in $secrets.Keys) {
+ $done++
+ $pct = [int](($done / $total) * 100)
+ Write-Progress -Activity "Uploading secrets to $VaultName" -Status "$name" -PercentComplete $pct
+
+ if ($PSCmdlet.ShouldProcess($VaultName, "Set secret '$name'")) {
+ try {
+ $secureValue = ConvertTo-SecureString $secrets[$name] -AsPlainText -Force
+ $null = Set-AzKeyVaultSecret -VaultName $VaultName -Name $name -SecretValue $secureValue
+ Write-Host " [OK] [$done/$total] $name" -ForegroundColor Green
+ }
+ catch {
+ Write-Host " [FAIL] [$done/$total] $name - $($_.Exception.Message)" -ForegroundColor Red
+ $failed += $name
+ }
+ }
+ else {
+ Write-Host " [WhatIf] Would set: $name" -ForegroundColor DarkCyan
+ }
+}
+
+Write-Progress -Activity "Uploading secrets" -Completed
+
+# ---------------------------------------------------------------------------
+# 5. Summary
+# ---------------------------------------------------------------------------
+Write-Host ""
+if ($failed.Count -eq 0) {
+ Write-Host "Migration complete - $done/$total secrets written." -ForegroundColor Green
+}
+else {
+ Write-Host "Migration finished with $($failed.Count) error(s):" -ForegroundColor Yellow
+ $failed | ForEach-Object { Write-Host " $_" -ForegroundColor Red }
+}
+
+Write-Host ""
+Write-Host "Next steps:" -ForegroundColor Cyan
+Write-Host " 1. Verify appsettings.json has VaultUri set to https://pcwkeys.vault.azure.net/"
+Write-Host " 2. Start the app and confirm '[OCORE.Secrets:fuchs] Sync OK' appears in logs"
+Write-Host " 3. Clear the secret values from secrets.json after a successful run"