From 14acc7f2d64bc8932a4f1229c05a60dd053bc34e Mon Sep 17 00:00:00 2001 From: Andrew Yager Date: Thu, 30 Apr 2026 10:59:30 +1000 Subject: [PATCH 1/4] Add Repair-AcmeDnsCredential recovery tool for DPAPI scope mismatch Register-AcmeDns.ps1 encrypts the acme-dns password using DPAPI in CurrentUser scope, but Install-Prerequisites.ps1 creates the renewal scheduled task running as SYSTEM. SYSTEM cannot decrypt CurrentUser DPAPI blobs, so every automatic renewal fails until the certificate expires. This recovery tool re-encrypts an existing credential file under DataProtectionScope.LocalMachine so any account on the host (including SYSTEM) can decrypt it. The script auto-detects the toolkit, patches the local Get-AcmeDnsCredential.ps1 to recognise the new "DPAPI-LocalMachine" StorageMethod, backs up both files, and verifies the round-trip end-to-end. Verified end-to-end on a Domain Controller affected by this bug: the repair tool was applied, then the win-acme renewal scheduled task running as SYSTEM successfully renewed the certificate. A toolkit-level fix making LocalMachine scope the default for new registrations will follow. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/Recovery/Repair-AcmeDnsCredential.ps1 | 378 ++++++++++++++++++ 1 file changed, 378 insertions(+) create mode 100644 scripts/Recovery/Repair-AcmeDnsCredential.ps1 diff --git a/scripts/Recovery/Repair-AcmeDnsCredential.ps1 b/scripts/Recovery/Repair-AcmeDnsCredential.ps1 new file mode 100644 index 0000000..b9fa016 --- /dev/null +++ b/scripts/Recovery/Repair-AcmeDnsCredential.ps1 @@ -0,0 +1,378 @@ +#Requires -Version 5.1 +#Requires -RunAsAdministrator +<# +.SYNOPSIS + Re-encrypts an existing acme-dns credential file under machine-scoped + DPAPI so the SYSTEM-context win-acme renewal task can decrypt it. + +.DESCRIPTION + When acme-dns credentials are registered via Register-AcmeDns.ps1 from an + interactive operator session, the password is encrypted with DPAPI in + CurrentUser scope. The win-acme renewal scheduled task runs as SYSTEM, + which cannot decrypt CurrentUser-scoped DPAPI blobs. Result: every + automatic renewal fails until the certificate expires. + + This recovery tool: + 1. Reads the existing credential file for the supplied -Domain. + 2. Prompts for (or accepts) the original acme-dns password. + 3. Re-encrypts the password with DataProtectionScope.LocalMachine, so + any account on the machine (including SYSTEM) can decrypt it. + 4. Writes the credential file back with StorageMethod set to + "DPAPI-LocalMachine", preserving all other fields. + 5. Patches the local toolkit's Get-AcmeDnsCredential.ps1 to recognise + the new StorageMethod (idempotent; backs up first). + 6. Verifies the round-trip by invoking the patched + Get-AcmeDnsCredential.ps1 and confirming it returns the password. + + The script is self-contained; copy it to any affected host and run as + Administrator. It does not depend on Common.ps1 or any other toolkit + helper. + +.PARAMETER Domain + The domain whose credential needs repairing + (e.g., "dc01.internal.example.com"). + +.PARAMETER Password + SecureString containing the acme-dns password. If omitted, the script + prompts. The password is the "password" field returned by the acme-dns + /register endpoint, NOT the registration API key. + +.PARAMETER ToolkitPath + Path to the deployed WinCertManager toolkit directory (the one + containing scripts\AcmeDns\Get-AcmeDnsCredential.ps1). Auto-detected + by searching C:\Tools, C:\Program Files, and C:\Program Files (x86) + if omitted. + +.PARAMETER CredentialPath + Override the credential storage directory. Defaults to + "$env:ProgramData\WinCertManager\Config\acme-dns". + +.PARAMETER SkipToolkitPatch + Skip updating Get-AcmeDnsCredential.ps1. Use only when a version that + already understands DPAPI-LocalMachine is deployed. + +.PARAMETER SkipVerify + Skip post-repair verification (decrypting via the patched + Get-AcmeDnsCredential.ps1). + +.EXAMPLE + .\Repair-AcmeDnsCredential.ps1 -Domain "syd03-ad01.internal.thecore.net.au" + +.EXAMPLE + $pw = Read-Host -AsSecureString + .\Repair-AcmeDnsCredential.ps1 -Domain "dc01.example.com" -Password $pw -WhatIf + +.NOTES + Author: Real World Technology Solutions + Version: 1.0.0 + + After repair, force a renewal to confirm the fix: + C:\Tools\win-acme\wacs.exe --renew --force --verbose +#> +[CmdletBinding(SupportsShouldProcess)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', 'CredentialPath', + Justification = 'CredentialPath is a filesystem path to the credential storage directory, not a password.')] +param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$Domain, + + [Parameter()] + [SecureString]$Password, + + [Parameter()] + [string]$ToolkitPath, + + [Parameter()] + [string]$CredentialPath, + + [Parameter()] + [switch]$SkipToolkitPatch, + + [Parameter()] + [switch]$SkipVerify +) + +$ErrorActionPreference = 'Stop' + +Add-Type -AssemblyName System.Security + +# ----------------------------------------------------------------------------- +# Helpers +# ----------------------------------------------------------------------------- + +function Find-ToolkitPath { + [CmdletBinding()] + param() + + $roots = @('C:\Tools', "$env:ProgramFiles", "${env:ProgramFiles(x86)}") | + Where-Object { $_ -and (Test-Path -LiteralPath $_) } + + $candidates = foreach ($root in $roots) { + Get-ChildItem -LiteralPath $root -Directory -Filter 'wincertmanager*' -ErrorAction SilentlyContinue + } + + foreach ($candidate in $candidates | Sort-Object LastWriteTime -Descending) { + $script = Join-Path $candidate.FullName 'scripts\AcmeDns\Get-AcmeDnsCredential.ps1' + if (Test-Path -LiteralPath $script -PathType Leaf) { + return $candidate.FullName + } + } + return $null +} + +function Invoke-DpapiRoundTrip { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)] + [SecureString]$SecurePassword + ) + + $bstr = [IntPtr]::Zero + $plainBytes = $null + $verifyBytes = $null + try { + $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecurePassword) + $plainText = [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) + $plainBytes = [System.Text.Encoding]::UTF8.GetBytes($plainText) + + $protected = [System.Security.Cryptography.ProtectedData]::Protect( + $plainBytes, $null, + [System.Security.Cryptography.DataProtectionScope]::LocalMachine + ) + $encryptedB64 = [Convert]::ToBase64String($protected) + + # Round-trip verify: decrypt and compare to original plaintext. + $verifyBytes = [System.Security.Cryptography.ProtectedData]::Unprotect( + $protected, $null, + [System.Security.Cryptography.DataProtectionScope]::LocalMachine + ) + $verifyText = [System.Text.Encoding]::UTF8.GetString($verifyBytes) + if ($verifyText -ne $plainText) { + throw 'DPAPI round-trip verification failed: decrypted value does not match input.' + } + + return $encryptedB64 + } + finally { + if ($null -ne $plainBytes) { [Array]::Clear($plainBytes, 0, $plainBytes.Length) } + if ($null -ne $verifyBytes) { [Array]::Clear($verifyBytes, 0, $verifyBytes.Length) } + if ($bstr -ne [IntPtr]::Zero) { + [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) + } + } +} + +function Update-GetAcmeDnsCredentialScript { + [CmdletBinding(SupportsShouldProcess)] + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)] + [string]$ScriptPath + ) + + $scriptText = Get-Content -LiteralPath $ScriptPath -Raw + + if ($scriptText -match 'DPAPI-LocalMachine') { + Write-Host ' Already patched (DPAPI-LocalMachine case present).' -ForegroundColor Yellow + return $false + } + + # Locate the existing 'DPAPI' switch case. The marker matches the file as + # shipped in the 1.0.x toolkit. If a future version reformats the switch, + # the patch will be skipped and the operator must apply manually. + $marker = " 'DPAPI' {" + $markerIdx = $scriptText.IndexOf($marker) + if ($markerIdx -lt 0) { + throw "Could not locate the 'DPAPI' switch case in $ScriptPath. Apply the patch manually or re-deploy the toolkit." + } + + $newCase = @" + 'DPAPI-LocalMachine' { + # Decrypt with machine-scoped DPAPI (any user on this host, including SYSTEM). + try { + Add-Type -AssemblyName System.Security -ErrorAction SilentlyContinue + `$protectedBytes = [Convert]::FromBase64String(`$storedData.EncryptedPassword) + `$plainBytes = [System.Security.Cryptography.ProtectedData]::Unprotect( + `$protectedBytes, `$null, + [System.Security.Cryptography.DataProtectionScope]::LocalMachine) + `$plainText = [System.Text.Encoding]::UTF8.GetString(`$plainBytes) + [Array]::Clear(`$plainBytes, 0, `$plainBytes.Length) + if (`$AsPlainText) { + `$password = `$plainText + } + else { + `$password = ConvertTo-SecureString -String `$plainText -AsPlainText -Force + } + } + catch { + Write-Error "Failed to decrypt password (LocalMachine DPAPI): `$(`$_.Exception.Message)" + return `$null + } + } + + +"@ + + $patched = $scriptText.Substring(0, $markerIdx) + $newCase + $scriptText.Substring($markerIdx) + + if ($PSCmdlet.ShouldProcess($ScriptPath, 'Patch to support DPAPI-LocalMachine')) { + $backup = "$ScriptPath.bak.$((Get-Date).ToString('yyyyMMddHHmmss'))" + Copy-Item -LiteralPath $ScriptPath -Destination $backup -Force + Set-Content -LiteralPath $ScriptPath -Value $patched -Force + Write-Host " Patched. Backup: $backup" -ForegroundColor Green + return $true + } + + return $false +} + +# ----------------------------------------------------------------------------- +# Locate toolkit and credential file +# ----------------------------------------------------------------------------- + +if (-not $ToolkitPath) { + $ToolkitPath = Find-ToolkitPath +} + +$getCredScript = $null +if ($ToolkitPath) { + $getCredScript = Join-Path $ToolkitPath 'scripts\AcmeDns\Get-AcmeDnsCredential.ps1' + if (-not (Test-Path -LiteralPath $getCredScript -PathType Leaf)) { + throw "Get-AcmeDnsCredential.ps1 not found at: $getCredScript" + } +} +elseif (-not $SkipToolkitPatch) { + throw 'Could not auto-detect a WinCertManager toolkit installation. Use -ToolkitPath, or pass -SkipToolkitPatch if the toolkit is already up to date.' +} + +if (-not $CredentialPath) { + $CredentialPath = Join-Path $env:ProgramData 'WinCertManager\Config\acme-dns' +} + +$normalizedDomain = $Domain.ToLower().Trim() +$credentialFile = Join-Path $CredentialPath ("$normalizedDomain.json") + +Write-Host '' +Write-Host '=== WinCertManager acme-dns Credential Repair ===' -ForegroundColor Cyan +Write-Host "Toolkit: $(if ($ToolkitPath) { $ToolkitPath } else { '(skipped)' })" -ForegroundColor White +Write-Host "Credential file: $credentialFile" -ForegroundColor White +Write-Host '' + +if (-not (Test-Path -LiteralPath $credentialFile -PathType Leaf)) { + throw "Credential file not found: $credentialFile" +} + +# ----------------------------------------------------------------------------- +# Load and validate credential file +# ----------------------------------------------------------------------------- + +try { + $storedData = Get-Content -LiteralPath $credentialFile -Raw | ConvertFrom-Json +} +catch { + throw "Failed to parse credential file '$credentialFile': $($_.Exception.Message)" +} + +$requiredFields = @('Domain', 'AcmeDnsServer', 'Subdomain', 'FullDomain', 'Username') +foreach ($field in $requiredFields) { + if ($storedData.PSObject.Properties.Name -notcontains $field) { + throw "Credential file is missing required field '$field'." + } +} + +if ($storedData.StorageMethod -eq 'CredentialManager') { + throw "StorageMethod is 'CredentialManager'. This script repairs DPAPI-stored credentials only. Re-register the domain or restore manually." +} +elseif ($storedData.StorageMethod -eq 'DPAPI-LocalMachine') { + Write-Warning "StorageMethod is already 'DPAPI-LocalMachine'. Continuing will overwrite with the supplied password." +} +elseif ($storedData.StorageMethod -ne 'DPAPI') { + Write-Warning "Unexpected StorageMethod '$($storedData.StorageMethod)'. Continuing anyway." +} + +Write-Host "Domain: $($storedData.Domain)" -ForegroundColor White +Write-Host "Subdomain: $($storedData.Subdomain)" -ForegroundColor White +Write-Host "Username: $($storedData.Username)" -ForegroundColor White +Write-Host "Storage method: $($storedData.StorageMethod)" -ForegroundColor White +Write-Host '' + +# ----------------------------------------------------------------------------- +# Prompt for password if not supplied +# ----------------------------------------------------------------------------- + +if (-not $Password) { + Write-Host "Enter the acme-dns password (the 'password' field from the original /register response):" -ForegroundColor Yellow + $Password = Read-Host -AsSecureString +} +if (-not $Password -or $Password.Length -eq 0) { + throw 'Password cannot be empty.' +} + +# ----------------------------------------------------------------------------- +# Re-encrypt under LocalMachine DPAPI +# ----------------------------------------------------------------------------- + +Write-Host 'Encrypting password under DPAPI LocalMachine scope...' -ForegroundColor Cyan +$newEncrypted = Invoke-DpapiRoundTrip -SecurePassword $Password +Write-Host 'Round-trip verification: OK' -ForegroundColor Green + +# ----------------------------------------------------------------------------- +# Patch toolkit Get-AcmeDnsCredential.ps1 +# ----------------------------------------------------------------------------- + +if (-not $SkipToolkitPatch -and $getCredScript) { + Write-Host '' + Write-Host "Patching $getCredScript ..." -ForegroundColor Cyan + $null = Update-GetAcmeDnsCredentialScript -ScriptPath $getCredScript +} + +# ----------------------------------------------------------------------------- +# Write updated credential file +# ----------------------------------------------------------------------------- + +$newData = [ordered]@{} +foreach ($prop in $storedData.PSObject.Properties) { + $newData[$prop.Name] = $prop.Value +} +$newData['EncryptedPassword'] = $newEncrypted +$newData['StorageMethod'] = 'DPAPI-LocalMachine' +$newData['RepairedAt'] = (Get-Date).ToString('o') + +$credBackup = "$credentialFile.bak.$((Get-Date).ToString('yyyyMMddHHmmss'))" +if ($PSCmdlet.ShouldProcess($credentialFile, 'Re-encrypt acme-dns credential under LocalMachine DPAPI')) { + Copy-Item -LiteralPath $credentialFile -Destination $credBackup -Force + Write-Host '' + Write-Host "Backup written: $credBackup" -ForegroundColor Yellow + + $newJson = ([PSCustomObject]$newData) | ConvertTo-Json -Depth 5 + Set-Content -LiteralPath $credentialFile -Value $newJson -Force + Write-Host "Credential repaired: $credentialFile" -ForegroundColor Green +} + +# ----------------------------------------------------------------------------- +# Verify via patched Get-AcmeDnsCredential.ps1 +# ----------------------------------------------------------------------------- + +if (-not $SkipVerify -and -not $WhatIfPreference -and $getCredScript) { + Write-Host '' + Write-Host 'Verifying via Get-AcmeDnsCredential.ps1...' -ForegroundColor Cyan + $verifyResult = & $getCredScript -Domain $normalizedDomain -AsPlainText 6>$null 5>$null 4>$null 3>$null + if (-not $verifyResult -or -not $verifyResult.Password) { + throw 'Verification failed: Get-AcmeDnsCredential.ps1 did not return a password. Inspect manually.' + } + Write-Host 'Verification OK: credential decrypts via Get-AcmeDnsCredential.ps1.' -ForegroundColor Green +} + +# ----------------------------------------------------------------------------- +# Next steps +# ----------------------------------------------------------------------------- + +Write-Host '' +Write-Host 'Next steps:' -ForegroundColor Cyan +Write-Host ' 1. Force a renewal:' +Write-Host ' C:\Tools\win-acme\wacs.exe --renew --force --verbose' -ForegroundColor White +Write-Host ' 2. Confirm LDAPS is presenting the new certificate:' +Write-Host ' Test-NetConnection localhost -Port 636' -ForegroundColor White +Write-Host '' From 2d9add02713b6073164f5730a4763f194b9282cc Mon Sep 17 00:00:00 2001 From: Andrew Yager Date: Thu, 30 Apr 2026 10:54:31 +1000 Subject: [PATCH 2/4] Document Repair-AcmeDnsCredential in README troubleshooting Adds a 'Renewals Silently Failing (Legacy DPAPI Scope)' subsection to the README troubleshooting block describing the symptom (renewal task exit code 0xFFFFFFFF, decrypt failure in win-acme logs, StorageMethod "DPAPI" in the credential JSON) and the recovery procedure. The procedure downloads the script from raw.githubusercontent.com, explicitly opens it for review before execution, and prompts for the original acme-dns password. Also points operators at the signed release ZIP for verified deployment. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/README.md b/README.md index 44aef5e..b862085 100644 --- a/README.md +++ b/README.md @@ -248,6 +248,37 @@ When running on a Domain Controller or server using internal DNS, win-acme's pre This tells win-acme to use Google/Cloudflare DNS for checking TXT records instead of the system's DNS. +### Renewals Silently Failing (Legacy DPAPI Scope) + +Toolkit versions ≤ v1.0.1 stored acme-dns credentials using DPAPI in `CurrentUser` scope, but the win-acme renewal scheduled task runs as `SYSTEM`. SYSTEM cannot decrypt CurrentUser-scoped DPAPI blobs, so every automatic renewal fails inside `Get-AcmeDnsCredential.ps1` and the certificate eventually expires without anyone being paged. + +**Symptoms:** +- `Get-ScheduledTask -TaskName 'win-acme*' | Get-ScheduledTaskInfo` shows `LastTaskResult` of `4294967295` (`0xFFFFFFFF`) +- `%ProgramData%\win-acme\acme-v02.api.letsencrypt.org\Log\log-*.txt` contains `ConvertTo-SecureString : ... CryptographicException` and `Failed to decrypt password. This usually means the credential was stored by a different user or on a different machine.` +- The credential JSON in `%ProgramData%\WinCertManager\Config\acme-dns\` reports `"StorageMethod": "DPAPI"` + +**Fix:** `scripts/Recovery/Repair-AcmeDnsCredential.ps1` re-encrypts the credential under DPAPI `LocalMachine` scope (so SYSTEM can decrypt it) without changing the acme-dns subdomain registration — no DNS changes required. Run it once per affected host. Toolkit ≥ v1.0.2 stores new credentials in `LocalMachine` scope by default, so fresh installs are unaffected. + +> **Verify the script before running it.** Open it in a text editor or run `Get-AuthenticodeSignature` against a copy from a signed release ZIP. The script will prompt for the original acme-dns password (the `password` field returned by `/register`, retrievable from your password manager). + +```powershell +# On the affected host, in an elevated PowerShell session: +$url = 'https://raw.githubusercontent.com/realworldtech/wincertmanager/main/scripts/Recovery/Repair-AcmeDnsCredential.ps1' +$dest = Join-Path $env:TEMP 'Repair-AcmeDnsCredential.ps1' +Invoke-WebRequest -Uri $url -OutFile $dest -UseBasicParsing + +# REVIEW the file before executing: +notepad $dest + +# Then run for the affected domain (will prompt for the acme-dns password): +& $dest -Domain 'dc01.internal.example.com' + +# After repair, force a renewal to confirm: +& 'C:\Tools\win-acme\wacs.exe' --renew --force --verbose +``` + +If you prefer a signed copy, download the latest release ZIP from the [Releases page](https://github.com/realworldtech/wincertmanager/releases), verify the SHA256, and run the script from `scripts/Recovery/` inside the extracted directory. + See [docs/troubleshooting.md](docs/troubleshooting.md) for more common issues. ## Documentation From b38dff73d45f2b4b54ce07c8fbc6163c94f46b6f Mon Sep 17 00:00:00 2001 From: Andrew Yager Date: Thu, 30 Apr 2026 11:13:34 +1000 Subject: [PATCH 3/4] Auto-detect single-domain credential in Repair-AcmeDnsCredential Makes -Domain optional. When omitted, the script enumerates the credential store directory (default %ProgramData%\WinCertManager\Config\acme-dns) and uses the single credential file present. If zero or multiple files exist, the script errors with a clear message listing the candidates. Motivation: enables running the recovery as a single unattended job from an RMM platform without per-host customisation. The new Resolve-DomainFromCredentialStore helper excludes .meta.json files (Credential Manager metadata) so it interacts cleanly with both storage methods. Verified on a host where the credential is already migrated: auto-detect picks the correct file, the rest of the workflow proceeds unchanged, and -WhatIf prevents writes. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/Recovery/Repair-AcmeDnsCredential.ps1 | 50 +++++++++++++++++-- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/scripts/Recovery/Repair-AcmeDnsCredential.ps1 b/scripts/Recovery/Repair-AcmeDnsCredential.ps1 index b9fa016..e2324c5 100644 --- a/scripts/Recovery/Repair-AcmeDnsCredential.ps1 +++ b/scripts/Recovery/Repair-AcmeDnsCredential.ps1 @@ -30,7 +30,11 @@ .PARAMETER Domain The domain whose credential needs repairing - (e.g., "dc01.internal.example.com"). + (e.g., "dc01.internal.example.com"). Optional: when omitted and the + credential store contains exactly one registration, that domain is + auto-detected. If multiple registrations exist, -Domain is required. + Useful for unattended runs from RMM platforms on hosts that hold a + single acme-dns credential. .PARAMETER Password SecureString containing the acme-dns password. If omitted, the script @@ -56,12 +60,18 @@ Get-AcmeDnsCredential.ps1). .EXAMPLE - .\Repair-AcmeDnsCredential.ps1 -Domain "syd03-ad01.internal.thecore.net.au" + .\Repair-AcmeDnsCredential.ps1 -Domain "dc01.internal.example.com" .EXAMPLE $pw = Read-Host -AsSecureString .\Repair-AcmeDnsCredential.ps1 -Domain "dc01.example.com" -Password $pw -WhatIf +.EXAMPLE + # Unattended single-domain host (e.g., from an RMM platform): auto-detects + # the domain from the credential store and avoids confirmation prompts. + $pw = ConvertTo-SecureString $env:ACMEDNS_PASSWORD -AsPlainText -Force + .\Repair-AcmeDnsCredential.ps1 -Password $pw -Confirm:$false + .NOTES Author: Real World Technology Solutions Version: 1.0.0 @@ -73,8 +83,7 @@ [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', 'CredentialPath', Justification = 'CredentialPath is a filesystem path to the credential storage directory, not a password.')] param( - [Parameter(Mandatory = $true)] - [ValidateNotNullOrEmpty()] + [Parameter()] [string]$Domain, [Parameter()] @@ -101,6 +110,34 @@ Add-Type -AssemblyName System.Security # Helpers # ----------------------------------------------------------------------------- +function Resolve-DomainFromCredentialStore { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + if (-not (Test-Path -LiteralPath $Path -PathType Container)) { + throw "Credential directory not found: $Path. Pass -Domain explicitly or specify -CredentialPath." + } + + # Exclude .meta.json (Credential Manager metadata) and our own .bak.* backups. + $files = Get-ChildItem -LiteralPath $Path -Filter '*.json' -File -ErrorAction Stop | + Where-Object { $_.Name -notmatch '\.meta\.json$' } + + if ($files.Count -eq 0) { + throw "No acme-dns credential files found in '$Path'. Register a domain with Register-AcmeDns.ps1 first, or pass -Domain to point at a specific file." + } + + if ($files.Count -gt 1) { + $list = ($files | ForEach-Object { ' - ' + [System.IO.Path]::GetFileNameWithoutExtension($_.Name) }) -join "`n" + throw "Multiple acme-dns credentials found in '$Path'. Specify -Domain to choose one:`n$list" + } + + return [System.IO.Path]::GetFileNameWithoutExtension($files[0].Name) +} + function Find-ToolkitPath { [CmdletBinding()] param() @@ -251,6 +288,11 @@ if (-not $CredentialPath) { $CredentialPath = Join-Path $env:ProgramData 'WinCertManager\Config\acme-dns' } +if (-not $Domain) { + $Domain = Resolve-DomainFromCredentialStore -Path $CredentialPath + Write-Host "Auto-detected domain: $Domain" -ForegroundColor Cyan +} + $normalizedDomain = $Domain.ToLower().Trim() $credentialFile = Join-Path $CredentialPath ("$normalizedDomain.json") From 4c668d408f8d4090879b3425838a30a702bc53fa Mon Sep 17 00:00:00 2001 From: Andrew Yager Date: Thu, 30 Apr 2026 12:23:28 +1000 Subject: [PATCH 4/4] Update scripts/Recovery/Repair-AcmeDnsCredential.ps1 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- scripts/Recovery/Repair-AcmeDnsCredential.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/Recovery/Repair-AcmeDnsCredential.ps1 b/scripts/Recovery/Repair-AcmeDnsCredential.ps1 index e2324c5..28f8604 100644 --- a/scripts/Recovery/Repair-AcmeDnsCredential.ps1 +++ b/scripts/Recovery/Repair-AcmeDnsCredential.ps1 @@ -389,7 +389,7 @@ if ($PSCmdlet.ShouldProcess($credentialFile, 'Re-encrypt acme-dns credential und Write-Host "Backup written: $credBackup" -ForegroundColor Yellow $newJson = ([PSCustomObject]$newData) | ConvertTo-Json -Depth 5 - Set-Content -LiteralPath $credentialFile -Value $newJson -Force + Set-Content -LiteralPath $credentialFile -Value $newJson -Encoding UTF8 -Force Write-Host "Credential repaired: $credentialFile" -ForegroundColor Green }