Skip to content
3 changes: 3 additions & 0 deletions .build/cspell-words.txt
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Dsamain
DTLS
dumptidset
DWORD
ecdsa
eems
EFORMS
EICAR
Expand Down Expand Up @@ -102,6 +103,7 @@ NDIS
Nego
Netlogon
netsh
nist
nmap
noderunner
notcontains
Expand All @@ -114,6 +116,7 @@ NTFS
NUMA
nupkg
odata
oids
onmicrosoft
onprem
OutlookiOS
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
. $PSScriptRoot\..\DataCollection\Get-ExchangeServerCertificate.ps1
. $PSScriptRoot\..\..\..\Shared\ActiveDirectoryFunctions\Get-InternalTransportCertificateFromServer.ps1
. $PSScriptRoot\..\..\..\Shared\CertificateFunctions\Import-ExchangeCertificateFromRawData.ps1
. $PSScriptRoot\..\..\..\Shared\CertificateFunctions\New-ExchangeSelfSignedCertificate.ps1
. $PSScriptRoot\..\..\..\Shared\Invoke-CatchActionError.ps1

function New-ExchangeAuthCertificate {
Expand All @@ -19,6 +20,11 @@ function New-ExchangeAuthCertificate {
[Parameter(Mandatory = $true, ParameterSetName = "NewNextAuthCert")]
[int]$CurrentAuthCertificateLifetimeInDays,

[Parameter(Mandatory = $false, ParameterSetName = "NewPrimaryAuthCert")]
[Parameter(Mandatory = $false, ParameterSetName = "NewNextAuthCert")]
[ValidateScript({ $_ -ge 0 })]
[int]$NewAuthCertificateLifetimeInDays,
Copy link

Copilot AI Nov 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The $NewAuthCertificateLifetimeInDays parameter lacks a default value. When not explicitly provided by the caller, it will be $null or 0 (depending on PowerShell's type coercion). This could lead to unexpected behavior. The parameter should have an explicit default value (e.g., = 0) to make the API contract clear, especially since line 206 checks if ($NewAuthCertificateLifetimeInDays -gt 0) which suggests 0 or less means "use default".

Suggested change
[int]$NewAuthCertificateLifetimeInDays,
[int]$NewAuthCertificateLifetimeInDays = 0,

Copilot uses AI. Check for mistakes.

[Parameter(Mandatory = $false, ParameterSetName = "NewPrimaryAuthCert")]
[Parameter(Mandatory = $false, ParameterSetName = "NewNextAuthCert")]
[ScriptBlock]$CatchActionFunction
Expand Down Expand Up @@ -100,6 +106,16 @@ function New-ExchangeAuthCertificate {
ErrorAction = "Stop"
}

$newCustomAuthCertificateParams = @{
AlgorithmType = "RSA"
UseRSACryptoServiceProvider = $true # Make sure to set this to true as the certificate can't be used as Auth Certificate otherwise
KeySize = 2048
LifetimeInDays = $NewAuthCertificateLifetimeInDays
SubjectName = "Microsoft Exchange Server Auth Certificate"
FriendlyName = $authCertificateFriendlyName
DomainName = @()
}

if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, $confirmationMessage, "Unattended Exchange certificate generation")) {
Write-Verbose ("Internal transport certificate will be overwritten for a short time and then reset to the previous one")
$internalTransportCertificate = Get-InternalTransportCertificateFromServer $env:COMPUTERNAME
Expand Down Expand Up @@ -187,7 +203,17 @@ function New-ExchangeAuthCertificate {
Write-Verbose ("Starting Auth Certificate creation process")
try {
if ($PSCmdlet.ShouldProcess("New-ExchangeCertificate", "Generate new Auth Certificate")) {
$newAuthCertificate = New-ExchangeCertificate @newAuthCertificateParams
if ($NewAuthCertificateLifetimeInDays -gt 0) {
Write-Verbose "Creating a custom self-signed certificate with a lifetime of $NewAuthCertificateLifetimeInDays days"
$newAuthCertificate = New-ExchangeSelfSignedCertificate @newCustomAuthCertificateParams
Copy link

Copilot AI Nov 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When New-ExchangeSelfSignedCertificate is called with -WhatIf, it may return a result with a mock thumbprint, but it's not guaranteed to complete successfully due to the WhatIf handling issues in that function. The code should add a check after line 208 to verify that $newAuthCertificate was successfully created before proceeding, similar to the existing null check pattern at line 241.

Suggested change
$newAuthCertificate = New-ExchangeSelfSignedCertificate @newCustomAuthCertificateParams
$newAuthCertificate = New-ExchangeSelfSignedCertificate @newCustomAuthCertificateParams
if ($null -eq $newAuthCertificate) {
throw "Failed to create a new Auth Certificate. The certificate object is null."
}

Copilot uses AI. Check for mistakes.
} else {
Write-Verbose "Creating a default self-signed certificate with a lifetime of 5 years"
$certObject = New-ExchangeCertificate @newAuthCertificateParams
$newAuthCertificate = [PSCustomObject]@{
Thumbprint = $certObject.Thumbprint
Subject = $certObject.Subject
}
}
Start-Sleep -Seconds 5
} else {
$newAuthCertificateParams.GetEnumerator() | ForEach-Object {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ function Get-ExchangeAuthCertificateStatus {
[OutputType([System.Object])]
param(
[bool]$IgnoreUnreachableServers = $false,

[bool]$IgnoreHybridSetup = $false,

[bool]$EnforceNewNextAuthCertificateCreation = $false,

[ScriptBlock]$CatchActionFunction
)

Expand Down Expand Up @@ -158,13 +162,15 @@ function Get-ExchangeAuthCertificateStatus {
($nextAuthCertificateValidInDays -lt 0)) {
# Scenario 1: Current Auth Certificate has expired and no next Auth Certificate defined or the next Auth Certificate has expired
$replaceRequired = $true
} elseif ((($currentAuthCertificateValidInDays -ge 0) -and
($currentAuthCertificateValidInDays -le 60)) -and
} elseif (((($currentAuthCertificateValidInDays -ge 0) -and
($currentAuthCertificateValidInDays -le 60)) -and
(($nextAuthCertificateValidInDays -le 0) -or
($nextAuthCertificateValidInDays -le 120)) -and
($currentAuthCertificateMissingOnServersList.Count -eq 0) -and
($nextAuthCertificateMissingOnServersList.Count -eq 0)) {
($nextAuthCertificateMissingOnServersList.Count -eq 0)) -or
$EnforceNewNextAuthCertificateCreation) {
# Scenario 2: Current Auth Certificate is valid but no next Auth Certificate defined or next Auth Certificate will expire in < 120 days
# or EnforceNewNextAuthCertificateCreation was explicitly set to true
$configureNextAuthRequired = $true
} elseif (($currentAuthCertificateValidInDays -le 0) -and
($nextAuthCertificateValidInDays -ge 0)) {
Expand All @@ -186,7 +192,7 @@ function Get-ExchangeAuthCertificateStatus {

Write-Verbose ("Replace of the primary Auth Certificate required? $($replaceRequired)")
Write-Verbose ("Import of the primary Auth Certificate required? $($importCurrentAuthCertificateRequired)")
Write-Verbose ("Replace of the next Auth Certificate required? $($configureNextAuthRequired)")
Write-Verbose ("Replace of the next Auth Certificate required or explicitly desired? $($configureNextAuthRequired)")
Write-Verbose ("Import of the next Auth Certificate required? $($importNextAuthCertificateRequired)")
Write-Verbose ("Hybrid Configuration detected? $($null -ne $hybridConfiguration)")
Write-Verbose ("Stop processing due to hybrid? $($stopProcessingDueToHybrid)")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@
.PARAMETER ValidateAndRenewAuthCertificate
You can use this parameter to let the script perform the required Auth Certificate renewal actions.
If the script runs with this parameter set to $false, no action will be made to the current Auth Configuration.
.PARAMETER EnforceNewAuthCertificateCreation
You can use this switch parameter to let the script stage a new next Auth Certificate which will become automatically active within 24 hours.
.PARAMETER CustomCertificateLifetimeInDays
You can use this parameter to specify a custom lifetime for the newly created Auth certificate.
By default, the self-signed certificate is created with a lifetime of 5 years.
Copy link

Copilot AI Nov 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation states "By default, the self-signed certificate is created with a lifetime of 5 years," but it doesn't clarify what happens when CustomCertificateLifetimeInDays is set to 0. The parameter accepts 0 as valid, but it's unclear whether this means "use the default 5-year lifetime" or if it has a different meaning.

Consider updating the documentation to explicitly state: "By default (or when set to 0), the self-signed certificate is created with a lifetime of 5 years."

Suggested change
By default, the self-signed certificate is created with a lifetime of 5 years.
By default (or when set to 0), the self-signed certificate is created with a lifetime of 5 years.

Copilot uses AI. Check for mistakes.
.PARAMETER IgnoreUnreachableServers
This optional parameter can be used to ignore if some of the Exchange servers within the organization cannot be reached.
If this parameter is used, the script only validates the servers that can be reached and will perform Auth Certificate
Expand Down Expand Up @@ -67,6 +72,17 @@
.\MonitorExchangeAuthCertificate.ps1 -ValidateAndRenewAuthCertificate $true -Confirm:$false
Runs the script in renewal mode without user interaction. The Auth Certificate renewal action will be performed (if required).
In unattended mode the internal SMTP certificate will be replaced with the new Auth Certificate and is then set back to the previous one.
The new Auth Certificate, which is eventually created, will have a lifetime of 5 years.
.EXAMPLE
.\MonitorExchangeAuthCertificate.ps1 -ValidateAndRenewAuthCertificate $true -CustomCertificateLifetimeInDays 365 -Confirm:$false
Runs the script in renewal mode without user interaction. The Auth Certificate renewal action will be performed (if required).
In unattended mode the internal SMTP certificate will be replaced with the new Auth Certificate and is then set back to the previous one.
The new Auth Certificate, which is eventually created, will be created with a lifetime of 365 days (1 year).
.EXAMPLE
.\MonitorExchangeAuthCertificate.ps1 -EnforceNewAuthCertificateCreation -CustomCertificateLifetimeInDays 365 -Confirm:$false
Runs the script in Auth Certificate enforcement mode. A new Auth Certificate is created and staged as new next Auth Certificate.
The Exchange AuthAdmin servicelet will publish the newly created Auth Certificate as soon as it processes it the next time (usually in a 12 hour time frame).
The new Auth Certificate, which is created, will be created with a lifetime of 365 days (1 year).
.EXAMPLE
.\MonitorExchangeAuthCertificate.ps1 -ValidateAndRenewAuthCertificate $true -IgnoreUnreachableServers $true -Confirm:$false
Runs the script in renewal mode without user interaction. We only take the Exchange server into account which are reachable and will perform
Expand All @@ -87,12 +103,23 @@ param(
[Parameter(Mandatory = $false, ParameterSetName = "MonitorExchangeAuthCertificateManually")]
[bool]$ValidateAndRenewAuthCertificate = $false,

[Parameter(Mandatory = $false, ParameterSetName = "EnforceNewNextAuthCertificateConfiguration")]
[switch]$EnforceNewAuthCertificateCreation,

[Parameter(Mandatory = $false, ParameterSetName = "MonitorExchangeAuthCertificateManually")]
[Parameter(Mandatory = $false, ParameterSetName = "ConfigureAutomaticExecutionViaScheduledTask")]
[Parameter(Mandatory = $false, ParameterSetName = "EnforceNewNextAuthCertificateConfiguration")]
[ValidateScript({ $_ -ge 0 })]
Copy link

Copilot AI Nov 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The validation { $_ -ge 0 } allows 0 as a valid value for CustomCertificateLifetimeInDays. However, when this value is 0, the code at line 206 checks if ($NewAuthCertificateLifetimeInDays -gt 0), meaning a value of 0 will use the default certificate generation path. This creates ambiguity: should 0 mean "use default" or should it be rejected as invalid?

Consider either:

  1. Changing the validation to { $_ -gt 0 } if 0 is not a valid lifetime, or
  2. Adding clearer documentation that 0 means "use default 5-year lifetime".
Suggested change
[ValidateScript({ $_ -ge 0 })]
[ValidateScript({ $_ -gt 0 })]

Copilot uses AI. Check for mistakes.
[int]$CustomCertificateLifetimeInDays = 0,

[Parameter(Mandatory = $false, ParameterSetName = "MonitorExchangeAuthCertificateManually")]
[Parameter(Mandatory = $false, ParameterSetName = "ConfigureAutomaticExecutionViaScheduledTask")]
[Parameter(Mandatory = $false, ParameterSetName = "EnforceNewNextAuthCertificateConfiguration")]
[bool]$IgnoreUnreachableServers = $false,

[Parameter(Mandatory = $false, ParameterSetName = "MonitorExchangeAuthCertificateManually")]
[Parameter(Mandatory = $false, ParameterSetName = "ConfigureAutomaticExecutionViaScheduledTask")]
[Parameter(Mandatory = $false, ParameterSetName = "EnforceNewNextAuthCertificateConfiguration")]
[bool]$IgnoreHybridConfig = $false,

[Parameter(Mandatory = $false, ParameterSetName = "SetupAutomaticExecutionADRequirements")]
Expand Down Expand Up @@ -134,6 +161,7 @@ param(

[Parameter(Mandatory = $false, ParameterSetName = "MonitorExchangeAuthCertificateManually")]
[Parameter(Mandatory = $false, ParameterSetName = "ConfigureAutomaticExecutionViaScheduledTask")]
[Parameter(Mandatory = $false, ParameterSetName = "EnforceNewNextAuthCertificateConfiguration")]
[Parameter(Mandatory = $false, ParameterSetName = "SetupAutomaticExecutionADRequirements")]
[switch]$SkipVersionCheck
)
Expand Down Expand Up @@ -169,7 +197,7 @@ function Main {
param()

if (-not(Confirm-Administrator)) {
Write-Warning ("The script needs to be executed in elevated mode. Start the Exchange Management Shell as an Administrator.")
Write-Warning ("The script must be executed in elevated mode. Start the Exchange Management Shell as an administrator.")
$Error.Clear()
Start-Sleep -Seconds 2
exit
Expand Down Expand Up @@ -399,6 +427,8 @@ function Main {

if ($ValidateAndRenewAuthCertificate) {
Write-Host ("Mode: Testing and replacing or importing the Auth Certificate (if required)")
} elseif ($EnforceNewAuthCertificateCreation) {
Write-Host ("Mode: Enforce new next Auth Certificate creation")
} else {
Write-Host ("The script was run without parameter therefore, only a check of the Auth Certificate configuration is performed and no change will be made")
}
Expand All @@ -423,9 +453,10 @@ function Main {
}

$authCertificateStatusParams = @{
IgnoreUnreachableServers = $IgnoreUnreachableServers
IgnoreHybridSetup = $IgnoreHybridConfig
CatchActionFunction = ${Function:Invoke-CatchActions}
IgnoreUnreachableServers = $IgnoreUnreachableServers
IgnoreHybridSetup = $IgnoreHybridConfig
EnforceNewNextAuthCertificateCreation = $EnforceNewAuthCertificateCreation
CatchActionFunction = ${Function:Invoke-CatchActions}
}
$authCertStatus = Get-ExchangeAuthCertificateStatus @authCertificateStatusParams

Expand All @@ -439,7 +470,7 @@ function Main {
if ($authCertStatus.ReplaceRequired) {
$renewalActionWording = "The Auth Certificate in use must be replaced by a new one."
} elseif ($authCertStatus.ConfigureNextAuthRequired) {
$renewalActionWording = "The Auth Certificate configured as next Auth Certificate must be configured or replaced by a new one."
$renewalActionWording = "The Auth Certificate configured as next Auth Certificate must be configured or replaced by a new one or is created on express request."
} elseif (($authCertStatus.CurrentAuthCertificateImportRequired) -or
($authCertStatus.NextAuthCertificateImportRequired)) {
$renewalActionWording = "The current or next Auth Certificate is missing on some servers and must be imported."
Expand All @@ -455,23 +486,28 @@ function Main {
Write-Host ("Please rerun the script using the '-IgnoreHybridConfig `$true' parameter to perform the renewal action.") -ForegroundColor Yellow
Write-Host ("It's also required to run the Hybrid Configuration Wizard (HCW) after the primary Auth Certificate was replaced.") -ForegroundColor Yellow
} else {
if (($ValidateAndRenewAuthCertificate) -and
if (($ValidateAndRenewAuthCertificate -or
$EnforceNewAuthCertificateCreation) -and
($renewalActionRequired)) {
Write-Host ("Renewal scenario: $($renewalActionWording)")
if ($authCertStatus.ReplaceRequired) {
$replaceExpiredAuthCertificateParams = @{
ReplaceExpiredAuthCertificate = $true
CatchActionFunction = ${Function:Invoke-CatchActions}
WhatIf = $WhatIfPreference
ReplaceExpiredAuthCertificate = $true
NewAuthCertificateLifetimeInDays = $CustomCertificateLifetimeInDays
CatchActionFunction = ${Function:Invoke-CatchActions}
WhatIf = $WhatIfPreference
}
$renewalActionResult = New-ExchangeAuthCertificate @replaceExpiredAuthCertificateParams

$emailBodyRenewalScenario = "The Auth Certificate in use was invalid (expired) or not available on all Exchange Servers within your organization.<BR>" +
"It was immediately replaced by a new one which is already active.<BR><BR>"
} elseif ($authCertStatus.ConfigureNextAuthRequired) {
# Set CurrentAuthCertificateLifetimeInDays to 2 in case that EnforceNewAuthCertificateCreation was used
# We do that to ensure that the new Auth Certificate will become active next time the AuthAdmin servicelet processes it
$configureNextAuthCertificateParams = @{
ConfigureNextAuthCertificate = $true
CurrentAuthCertificateLifetimeInDays = $authCertStatus.CurrentAuthCertificateLifetimeInDays
NewAuthCertificateLifetimeInDays = $CustomCertificateLifetimeInDays
CurrentAuthCertificateLifetimeInDays = if ($EnforceNewAuthCertificateCreation) { 2 } else { $authCertStatus.CurrentAuthCertificateLifetimeInDays }
CatchActionFunction = ${Function:Invoke-CatchActions}
WhatIf = $WhatIfPreference
}
Expand Down
Loading