#Requires -Version 5.1 #Requires -RunAsAdministrator <# .SYNOPSIS Sichere Zertifikatsrotation für SMTPGraphRelay. .DESCRIPTION - Muss mit Windows PowerShell 5.1 ausgeführt werden. - Liest TenantId, ClientId und aktuellen Thumbprint aus config.json. - Erzeugt ein neues nicht exportierbares RSA-Zertifikat in LocalMachine\My. - Meldet einen Entra-Administrator interaktiv an. - Fügt das neue öffentliche Zertifikat zusätzlich zur bestehenden App Registration hinzu. - Testet App-only Authentication mit dem neuen Zertifikat. - Aktualisiert erst danach config.json. - Startet den SMTPGraphRelay Scheduled Task neu. - Entfernt das alte App-Zertifikat / lokale Zertifikat nur auf Wunsch. Exchange Application RBAC bleibt unverändert, da ClientId/App Registration gleich bleibt. #> [CmdletBinding()] param( [string]$ConfigPath = "$PSScriptRoot\config.json", [int]$ValidityYears = 2 ) $ErrorActionPreference = "Stop" [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 if ($PSVersionTable.PSEdition -ne "Desktop" -or $PSVersionTable.PSVersion.Major -ne 5) { Write-Host "" Write-Host "Dieses Skript muss mit Windows PowerShell 5.1 ausgeführt werden." -ForegroundColor Red Write-Host "Bitte starten:" -ForegroundColor Yellow Write-Host " C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe" Write-Host "" Read-Host "Enter drücken zum Beenden" exit 1 } if (-not (Test-Path -LiteralPath $ConfigPath)) { throw "config.json nicht gefunden: $ConfigPath" } if ($ValidityYears -lt 1 -or $ValidityYears -gt 5) { throw "ValidityYears muss zwischen 1 und 5 liegen." } $config = Get-Content -LiteralPath $ConfigPath -Raw -Encoding UTF8 | ConvertFrom-Json $TenantId = [string]$config.Graph.TenantId $ClientId = [string]$config.Graph.ClientId $OldThumbprint = [string]$config.Graph.CertificateThumbprint if ([string]::IsNullOrWhiteSpace($TenantId) -or [string]::IsNullOrWhiteSpace($ClientId) -or [string]::IsNullOrWhiteSpace($OldThumbprint)) { throw "TenantId, ClientId oder CertificateThumbprint fehlen in config.json." } Write-Host "==========================================================" -ForegroundColor Cyan Write-Host " SMTPGraphRelay - Zertifikatsrotation" -ForegroundColor Cyan Write-Host "==========================================================" -ForegroundColor Cyan Write-Host "" Write-Host "Tenant ID: $TenantId" Write-Host "Client ID: $ClientId" Write-Host "Alter Thumbprint:$OldThumbprint" Write-Host "" $modules = @( "Microsoft.Graph.Authentication", "Microsoft.Graph.Applications" ) foreach ($module in $modules) { if (-not (Get-Module -ListAvailable -Name $module)) { Write-Host "-> $module fehlt. Wird für alle Benutzer installiert..." -ForegroundColor Yellow Install-Module $module -Scope AllUsers -Repository PSGallery -Force -AllowClobber } } Import-Module Microsoft.Graph.Authentication -Force -ErrorAction Stop Import-Module Microsoft.Graph.Applications -Force -ErrorAction Stop $oldCert = Get-Item -LiteralPath "Cert:\LocalMachine\My\$OldThumbprint" -ErrorAction Stop Write-Host "Altes Zertifikat gültig bis: $($oldCert.NotAfter)" -ForegroundColor DarkGray Write-Host "" Write-Host "Schritt 1: Neues Zertifikat erzeugen..." -ForegroundColor Cyan $certSubject = "CN=SMTPGraphRelay-$env:COMPUTERNAME" $newCert = New-SelfSignedCertificate ` -Subject $certSubject ` -CertStoreLocation "Cert:\LocalMachine\My" ` -KeyAlgorithm RSA ` -KeyLength 2048 ` -HashAlgorithm SHA256 ` -KeyExportPolicy NonExportable ` -KeySpec Signature ` -NotAfter (Get-Date).AddYears($ValidityYears) Write-Host "-> Neues Zertifikat: $($newCert.Thumbprint)" -ForegroundColor Green Write-Host "-> Gültig bis: $($newCert.NotAfter)" -ForegroundColor Green Write-Host "" try { Write-Host "Schritt 2: Entra-Administrator anmelden..." -ForegroundColor Cyan Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null Connect-MgGraph ` -TenantId $TenantId ` -Scopes "Application.ReadWrite.All" ` -NoWelcome $ctx = Get-MgContext if (-not $ctx -or $ctx.TenantId -ne $TenantId) { throw "Graph-Anmeldung am erwarteten Tenant fehlgeschlagen." } Write-Host "-> Angemeldet an Tenant $($ctx.TenantId)." -ForegroundColor Green Write-Host "" Write-Host "Schritt 3: Bestehende App Registration laden..." -ForegroundColor Cyan $app = Get-MgApplication -Filter "appId eq '$ClientId'" -Property "id,appId,displayName,keyCredentials" | Select-Object -First 1 if (-not $app) { throw "App Registration mit ClientId '$ClientId' wurde nicht gefunden." } Write-Host "-> App gefunden: $($app.DisplayName)" -ForegroundColor Green $alreadyPresent = $false foreach ($key in @($app.KeyCredentials)) { if ($key.CustomKeyIdentifier) { $keyThumb = ([BitConverter]::ToString($key.CustomKeyIdentifier)).Replace("-","") if ($keyThumb -eq $newCert.Thumbprint) { $alreadyPresent = $true break } } } if (-not $alreadyPresent) { Write-Host "Schritt 4: Neues Zertifikat zusätzlich zur App hinzufügen..." -ForegroundColor Cyan $existingKeys = @() foreach ($key in @($app.KeyCredentials)) { $existingKeys += @{ CustomKeyIdentifier = $key.CustomKeyIdentifier DisplayName = $key.DisplayName EndDateTime = $key.EndDateTime KeyId = $key.KeyId StartDateTime = $key.StartDateTime Type = $key.Type Usage = $key.Usage } } $newKey = @{ Type = "AsymmetricX509Cert" Usage = "Verify" Key = $newCert.GetRawCertData() DisplayName = "SMTPGraphRelay Certificate $($newCert.NotAfter.ToString('yyyy-MM-dd'))" StartDateTime = $newCert.NotBefore.ToUniversalTime() EndDateTime = $newCert.NotAfter.ToUniversalTime() } $allKeys = @($existingKeys) + @($newKey) Update-MgApplication ` -ApplicationId $app.Id ` -KeyCredentials $allKeys Write-Host "-> Neues Zertifikat wurde zusätzlich registriert." -ForegroundColor Green } else { Write-Host "-> Neues Zertifikat ist bereits in der App registriert." -ForegroundColor Yellow } Write-Host "" Write-Host "Schritt 5: App-only Anmeldung mit NEUEM Zertifikat testen..." -ForegroundColor Cyan Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null $authSucceeded = $false $lastAuthError = $null # Entra kann kurz brauchen, bis das neue Credential überall verfügbar ist. for ($attempt = 1; $attempt -le 6; $attempt++) { try { Connect-MgGraph ` -TenantId $TenantId ` -ClientId $ClientId ` -Certificate $newCert ` -NoWelcome | Out-Null $appCtx = Get-MgContext if ($appCtx -and $appCtx.AuthType -eq "AppOnly" -and $appCtx.ClientId -eq $ClientId) { $authSucceeded = $true break } throw "Graph-Kontext ist nicht AppOnly oder hat eine unerwartete ClientId." } catch { $lastAuthError = $_.Exception.Message Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null if ($attempt -lt 6) { Write-Host "-> Noch nicht verfügbar (Versuch $attempt/6), neuer Versuch..." -ForegroundColor Yellow Start-Sleep -Seconds 10 } } } if (-not $authSucceeded) { throw "Neues Zertifikat konnte nicht zur App-only Anmeldung verwendet werden: $lastAuthError" } Write-Host "-> App-only Anmeldung mit neuem Zertifikat erfolgreich." -ForegroundColor Green Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null Write-Host "" Write-Host "Schritt 6: config.json atomar auf neuen Thumbprint umstellen..." -ForegroundColor Cyan $backupPath = "$ConfigPath.bak-$(Get-Date -Format 'yyyyMMdd-HHmmss')" Copy-Item -LiteralPath $ConfigPath -Destination $backupPath -Force $config.Graph.CertificateThumbprint = $newCert.Thumbprint $tmpConfig = "$ConfigPath.tmp" $config | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $tmpConfig -Encoding UTF8 Move-Item -LiteralPath $tmpConfig -Destination $ConfigPath -Force Write-Host "-> config.json aktualisiert." -ForegroundColor Green Write-Host "-> Backup: $backupPath" -ForegroundColor DarkGray Write-Host "" Write-Host "Schritt 7: SMTPGraphRelay neu starten..." -ForegroundColor Cyan $task = Get-ScheduledTask -TaskName "SMTPGraphRelay" -ErrorAction SilentlyContinue if ($task) { Stop-ScheduledTask -TaskName "SMTPGraphRelay" -ErrorAction SilentlyContinue Start-Sleep -Seconds 1 Start-ScheduledTask -TaskName "SMTPGraphRelay" Start-Sleep -Seconds 2 Write-Host "-> Scheduled Task neu gestartet." -ForegroundColor Green } else { Write-Host "-> Scheduled Task 'SMTPGraphRelay' nicht gefunden. Bitte Relay manuell neu starten." -ForegroundColor Yellow } Write-Host "" Write-Host "==========================================================" -ForegroundColor Green Write-Host " ZERTIFIKATSROTATION ERFOLGREICH" -ForegroundColor Green Write-Host "==========================================================" -ForegroundColor Green Write-Host "" Write-Host "Alter Thumbprint: $OldThumbprint" Write-Host "Neuer Thumbprint: $($newCert.Thumbprint)" -ForegroundColor Yellow Write-Host "" Write-Host "Sicherheitsreserve:" -ForegroundColor Cyan Write-Host "Das alte Zertifikat bleibt zunächst parallel registriert." Write-Host "Damit ist ein Rollback über das config.json-Backup möglich." Write-Host "" $removeOld = Read-Host "Altes Zertifikat jetzt aus Entra UND LocalMachine entfernen? [j/N]" if ($removeOld -match '^(?i)j|ja|y|yes$') { Write-Host "" Write-Host "Schritt 8: Altes Zertifikat entfernen..." -ForegroundColor Cyan # Für die Änderung wieder Admin-Kontext herstellen. Connect-MgGraph ` -TenantId $TenantId ` -Scopes "Application.ReadWrite.All" ` -NoWelcome $appFresh = Get-MgApplication -ApplicationId $app.Id -Property "id,keyCredentials" $remainingKeys = @() $oldFound = $false foreach ($key in @($appFresh.KeyCredentials)) { $isOld = $false if ($key.CustomKeyIdentifier) { $keyThumb = ([BitConverter]::ToString($key.CustomKeyIdentifier)).Replace("-","") if ($keyThumb -eq $OldThumbprint) { $isOld = $true $oldFound = $true } } if (-not $isOld) { $remainingKeys += @{ CustomKeyIdentifier = $key.CustomKeyIdentifier DisplayName = $key.DisplayName EndDateTime = $key.EndDateTime KeyId = $key.KeyId StartDateTime = $key.StartDateTime Type = $key.Type Usage = $key.Usage } } } if ($oldFound) { Update-MgApplication ` -ApplicationId $app.Id ` -KeyCredentials $remainingKeys Write-Host "-> Altes Zertifikat aus Entra entfernt." -ForegroundColor Green } else { Write-Host "-> Alter Thumbprint war in Entra nicht mehr vorhanden." -ForegroundColor Yellow } Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null Remove-Item -LiteralPath "Cert:\LocalMachine\My\$OldThumbprint" -Force -ErrorAction Stop Write-Host "-> Altes lokales Zertifikat entfernt." -ForegroundColor Green } else { Write-Host "-> Altes Zertifikat bleibt als Rollback-Reserve bestehen." -ForegroundColor Yellow } } catch { Write-Host "" Write-Host "FEHLER BEI DER ZERTIFIKATSROTATION:" -ForegroundColor Red Write-Host $_.Exception.Message -ForegroundColor Red Write-Host "" Write-Host "Die bestehende config.json wurde nur nach erfolgreichem Auth-Test geändert." -ForegroundColor Yellow Write-Host "Falls das neue Zertifikat bereits erzeugt/hochgeladen wurde, kann es später manuell bereinigt werden." -ForegroundColor Yellow throw } finally { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } Write-Host "" Read-Host "Enter drücken zum Beenden"