SACRED.Rotate.Azure.ServicePrincipal.psm1

<#
Copyright (c) 2023 Chris Clohosy
 
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
 
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
 
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
#>


using module SACRED.Log

Function Invoke-SACREDEntraServicePrincipalSecretRotation (
    [Parameter(Mandatory=$true)]    
    [string] $ServicePrincipalDisplayName,

    [Parameter(Mandatory=$false)]
    [int] $SecretValidityInDays = 365,

    [Parameter(Mandatory=$false)]
    [int] $SecretValidityInHours = 0
)
{
    <#
        .SYNOPSIS
        Rotates an Entra Service Principal secret.
 
        .DESCRIPTION
        Rotates an Entra Service Principal secret.
 
        .PARAMETER ServicePrincipalDisplayName
        The name of the Entra Service Principal.
 
        .PARAMETER SecretValidityInDays
        How long the secret should be valid for, in days.
 
        .PARAMETER SecretValidityInHours
        How long the secret should be valid for, in hours.
 
        .INPUTS
        None
 
        .OUTPUTS
        None
    #>


    Connect-SACREDToMicrosoftGraph

    return Invoke-SACREDEntraServicePrincipalSecretRegeneration -ServicePrincipalDisplayName $ServicePrincipalDisplayName -SecretValidityInDays $SecretValidityInDays -SecretValidityInHours $SecretValidityInHours
}

Function Invoke-SACREDEntraServicePrincipalSecretRegeneration (
    [Parameter(Mandatory=$true)]    
    [string] $ServicePrincipalDisplayName,

    [Parameter(Mandatory=$false)]
    [int] $SecretValidityInDays = 365,

    [Parameter(Mandatory=$false)]
    [int] $SecretValidityInHours = 0
)
{
    <#
        .SYNOPSIS
        Regenerates an Entra Service Principal secret.
 
        .DESCRIPTION
        Regenerates an Entra Service Principal secret.
 
        .PARAMETER ServicePrincipalDisplayName
        The name of the Entra Service Principal.
 
        .PARAMETER SecretValidityInDays
        How long the secret should be valid for, in days.
 
        .PARAMETER SecretValidityInHours
        How long the secret should be valid for, in hours.
 
        .INPUTS
        None
 
        .OUTPUTS
        None
    #>


    $global:SACREDLogger.Info("Regenerating the secret for service principal $ServicePrincipalDisplayName.")
    $servicePrincipal = Get-MgServicePrincipal -Filter "DisplayName eq '$ServicePrincipalDisplayName'"
    $secretStartDate = Get-Date
    if($SecretValidityInHours -gt 0)
    {
        $secretEndDate = $secretStartDate.AddHours($SecretValidityInHours)
    }
    else
    {
        $secretEndDate = $secretStartDate.AddDays($SecretValidityInDays)
    }
    $global:SACREDLogger.Info("New secret will be valid from $secretStartDate to $secretEndDate.")
    $secretCredential = @{
        'EndDateTime'=$secretEndDate
        'StartDateTime'=$secretStartDate
        'DisplayName'='SACRED generated secret'
    }
    $secret = Add-MgServicePrincipalPassword -ServicePrincipalId $servicePrincipal.Id -PasswordCredential $secretCredential

    $credentialInfo = @{'ServicePrincipalSecret'=$secret.SecretText; 'ServicePrincipalSecretValidFrom'=$secretStartDate; 'ServicePrincipalSecretValidTo'=$secretEndDate; 'ServicePrincipalSecretKeyId'=$secret.KeyId}
    return $credentialInfo
}

Function Remove-SACREDOldEntraServicePrincipalSecrets (
    [Parameter(Mandatory=$true)]    
    [string] $ServicePrincipalDisplayName,

    [Parameter(Mandatory=$false)]
    [int] $MostRecentSecretsToRetain = 2
)
{
    <#
        .SYNOPSIS
        Removes any old Entra Service Principal secrets.
 
        .DESCRIPTION
        Removes any old Entra Service Principal secrets.
 
        .PARAMETER ServicePrincipalDisplayName
        The name of the Entra Service Principal.
 
        .PARAMETER MostRecentSecretsToRetain
        How many of the most recent secrets (measured by start time) to retain.
 
        .INPUTS
        None
 
        .OUTPUTS
        None
    #>


    $global:SACREDLogger.Info("Removing every SACRED generated secret bar the $MostRecentSecretsToRetain most recent ones, for service principal $ServicePrincipalDisplayName.")
    $servicePrincipal = Get-MgServicePrincipal -Filter "DisplayName eq '$ServicePrincipalDisplayName'"
    $existingSecrets = ($servicePrincipal.PasswordCredentials | Sort-Object StartDateTime -Descending)
    if($existingSecrets.Count -gt $MostRecentSecretsToRetain)
    {
        for($i=$MostRecentSecretsToRetain; $i -lt $existingSecrets.Count; $i++)
        {
            $secretToDelete = $existingSecrets[$i]
            $global:SACREDLogger.Info("Deleting secret $($secretToDelete.KeyId) from service principal.")
            Remove-MgServicePrincipalPassword -ServicePrincipalId $servicePrincipal.Id -KeyId $secretToDelete.KeyId
        }
    }
}

Function Invoke-SACREDEntraServicePrincipalSelfSignedCertificateRotation (
    [Parameter(Mandatory=$true)]    
    [string] $ServicePrincipalDisplayName,

    [Parameter(Mandatory=$false)]
    [int] $CertificateValidityInDays = 365,

    [Parameter(Mandatory=$false)]
    [int] $CertificateValidityInHours = 0
)
{
    <#
        .SYNOPSIS
        Rotates an Entra Service Principal certificate.
 
        .DESCRIPTION
        Rotates an Entra Service Principal certificate. These certificates are self-signed.
 
        .PARAMETER ServicePrincipalDisplayName
        The name of the Entra Service Principal.
 
        .PARAMETER CertificateValidityInDays
        How long the certificate should be valid for, in days.
 
        .PARAMETER CertificateValidityInHours
        How long the certificate should be valid for, in hours.
 
        .INPUTS
        None
 
        .OUTPUTS
        None
    #>


    Connect-SACREDToMicrosoftGraph

    return Invoke-SACREDEntraServicePrincipalSelfSignedCertificateRegeneration -ServicePrincipalDisplayName $ServicePrincipalDisplayName -CertificateValidityInDays $CertificateValidityInDays -CertificateValidityInHours $CertificateValidityInHours
}

Function Invoke-SACREDEntraServicePrincipalSelfSignedCertificateRegeneration (
    [Parameter(Mandatory=$true)]    
    [string] $ServicePrincipalDisplayName,

    [Parameter(Mandatory=$false)]
    [int] $CertificateValidityInDays = 365,

    [Parameter(Mandatory=$false)]
    [int] $CertificateValidityInHours = 0
)
{
    <#
        .SYNOPSIS
        Regenerates an Entra Service Principal certificate.
 
        .DESCRIPTION
        Regenerates an Entra Service Principal certificate. These certificates are self-signed.
 
        .PARAMETER ServicePrincipalDisplayName
        The name of the Entra Service Principal.
 
        .PARAMETER CertificateValidityInDays
        How long the certificate should be valid for, in days.
 
        .PARAMETER CertificateValidityInHours
        How long the certificate should be valid for, in hours.
 
        .INPUTS
        None
 
        .OUTPUTS
        None
    #>


    $global:SACREDLogger.Info("Regenerating the self-signed certificate for service principal $ServicePrincipalDisplayName.")
    $certificateStartDate = Get-Date
    if($CertificateValidityInHours -gt 0)
    {
        $certificateEndDate = $certificateStartDate.AddHours($CertificateValidityInHours)
    }
    else
    {
        $certificateEndDate = $certificateStartDate.AddDays($CertificateValidityInDays)
    }
    $global:SACREDLogger.Info("New certificate will be valid from $certificateStartDate to $certificateEndDate.")
    $servicePrincipal = Get-MgServicePrincipal -Filter "DisplayName eq '$ServicePrincipalDisplayName'"
    $certificate = New-SelfSignedCertificate -Subject "CN=$ServicePrincipalDisplayName" -CertStoreLocation "Cert:\CurrentUser\My" -KeyExportPolicy Exportable -KeySpec Signature -KeyLength 2048 -KeyAlgorithm RSA -HashAlgorithm SHA256 -NotAfter $certificateEndDate

    $publicCertificateData = $certificate.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert)
    $certificateThumbprint = $certificate.Thumbprint
    $certificateEndDateTime = Get-Date $certificate.NotAfter -Format 'o'
    
    $global:SACREDLogger.Info("New certificate thumbprint is $certificateThumbprint.")
    $keyCredential = @{
        'CustomKeyIdentifier'=[System.Text.Encoding]::UTF8.GetBytes($certificateThumbprint.Substring(0, 32))
        'EndDateTime'=$certificateEndDateTime
        'Key'=$publicCertificateData
        'Type'='AsymmetricX509Cert'
        'Usage'='Verify'
        'DisplayName'="CN=$ServicePrincipalDisplayName"
    }
    $keyCredentials = New-Object System.Collections.ArrayList
    $keyCredentials.Add($keyCredential) | Out-Null
    if($servicePrincipal.KeyCredentials.Count -gt 0)
    {
        foreach($keyCred in $servicePrincipal.KeyCredentials)
        {
            $keyCredentials.Add($keyCred) | Out-Null
        }
    }
    $global:SACREDLogger.Info("Updating key credentials on service principal.")
    Update-MgServicePrincipal -ServicePrincipalId $servicePrincipal.Id -KeyCredentials $keyCredentials

    $privateCertificatePassword = New-Guid
    $securePrivateCertificatePassword = ConvertTo-SecureString -AsPlainText $privateCertificatePassword -Force
    $privateCertificateData = $certificate.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Pfx, $securePrivateCertificatePassword)

    Remove-Item $certificate.PSPath

    $credentialInfo = @{'ServicePrincipalPublicCertificate'=$publicCertificateData; 'ServicePrincipalPrivateCertificate'=$privateCertificateData; 'ServicePrincipalPrivateCertificatePassword'=$privateCertificatePassword; 'ServicePrincipalCertificateThumbprint'=$certificateThumbprint; 'ServicePrincipalCertificateValidFrom'=$certificateStartDate; 'ServicePrincipalCertificateValidTo'=$certificateEndDate}
    return $credentialInfo
}

Function Remove-SACREDOldEntraServicePrincipalSelfSignedCertificates (
    [Parameter(Mandatory=$true)]    
    [string] $ServicePrincipalDisplayName,

    [Parameter(Mandatory=$false)]
    [int] $MostRecentCertificatesToRetain = 2
)
{
    <#
        .SYNOPSIS
        Removes any old Entra Service Principal certificates.
 
        .DESCRIPTION
        Removes any old Entra Service Principal certificates.
 
        .PARAMETER ServicePrincipalDisplayName
        The name of the Entra Service Principal.
 
        .PARAMETER MostRecentCertificatesToRetain
        How many of the most recent certificates (measured by start time) to retain.
 
        .INPUTS
        None
 
        .OUTPUTS
        None
    #>


    $global:SACREDLogger.Info("Removing every SACRED generated certificate bar the $MostRecentCertificatesToRetain most recent ones, for service principal $ServicePrincipalDisplayName.")
    $servicePrincipal = Get-MgServicePrincipal -Filter "DisplayName eq '$ServicePrincipalDisplayName'"

    $existingCredentials = $servicePrincipal.KeyCredentials
    $certificateList = New-Object System.Collections.ArrayList
    $keyCredentials = New-Object System.Collections.ArrayList
    foreach($existingCredential in $existingCredentials)
    {
        if($existingCredential.Type -eq "AsymmetricX509Cert") 
        {
            $certificateList.Add($existingCredential) | Out-Null
        }
        $keyCredentials.Add($existingCredential) | Out-Null
    }

    $certificateList = ($certificateList | Sort-Object StartDateTime -Descending)
    if ($certificateList.Count -gt $MostRecentCertificatesToRetain)
    {
        for($i=$MostRecentCertificatesToRetain; $i -lt $certificateList.Count; $i++)
        {
            $credentialToDelete = $certificateList[$i]
            $customKeyIdentifier = $credentialToDelete.CustomKeyIdentifier
            if($customKeyIdentifier)
            {
                $certificateThumbprint = [System.Text.Encoding]::UTF8.GetString($customKeyIdentifier)
            }
            else
            {
                $certificateThumbprint = 'UNKNOWN'
            }

            $global:SACREDLogger.Info("Deleting certificate $($credentialToDelete.KeyId) with thumbprint prefix $certificateThumbprint from service principal.")
            $keyCredentials.Remove($credentialToDelete)
        }

        Update-MgServicePrincipal -ServicePrincipalId $servicePrincipal.Id -KeyCredentials $keyCredentials
    }
}

Function Build-SACREDEntraServicePrincipalRotationJobName (
    [Parameter(Mandatory=$true)]
    [SACREDRotationJobDefinition] $RotationJobDefinition
)
{
    <#
        .SYNOPSIS
        Builds the name of a rotation job for an Entra Service Principal secret/certificate.
 
        .DESCRIPTION
        Builds the name of a rotation job for an Entra Service Principal secret/certificate.
 
        .PARAMETER RotationJobDefinition
        The definition of the rotation job.
 
        .INPUTS
        None
 
        .OUTPUTS
        None
    #>


    if($RotationJobDefinition.entraServicePrincipal.credentialType -eq 'secret')
    {
        $rotationJobName = "EntraServicePrincipal_$($RotationJobDefinition.entraServicePrincipal.displayName)_Secret"
    }
    elseif($RotationJobDefinition.entraServicePrincipal.credentialType -eq 'selfsignedcertificate')
    {
        $rotationJobName = "EntraServicePrincipal_$($RotationJobDefinition.entraServicePrincipal.displayName)_SelfSignedCertificate"
    }

    return $rotationJobName
}