diff --git a/README.md b/README.md index 7848224..3b959f1 100644 --- a/README.md +++ b/README.md @@ -95,3 +95,31 @@ Please note that this requires additional SCEPman configuration regarding the st - AppConfig:StaticValidation:AllowRenewals : true - AppConfig:StaticValidation:ReenrollmentAllowedCertificateTypes: Static,IntuneUser (Depending on the types intended for renewal) +## Search certificates +Use `Find-SCEPmanCertificate` to query certificates via the SCEPman management API. + +```powershell +Find-SCEPmanCertificate -Url 'https://scepman.contoso.com' -SearchText 'alice@contoso.com' -PageSize 50 -CertValidity Any -CertType Any +``` + +When paginating, pass the continuation token returned by the previous response: + +```powershell +Find-SCEPmanCertificate -Url 'https://scepman.contoso.com' -SearchText 'alice' -ContinuationToken '' +``` + +## Revoke certificates +Use `Revoke-SCEPmanCertificate` to revoke one or more certificates in SCEPman Enterprise. + +Revoke a single certificate with an explicit revoker identity: + +```powershell +Revoke-SCEPmanCertificate -Url 'https://scepman.contoso.com' -SerialNumber '1A2B3C4D' -RevocationReason KeyCompromise -Revoker 'admin@contoso.com' +``` + +Revoke multiple certificates at once: + +```powershell +Revoke-SCEPmanCertificate -Url 'https://scepman.contoso.com' -SerialNumber '1A2B3C4D','5E6F7A8B' -RevocationReason Superseded +``` + diff --git a/SCEPmanClient/Private/x509/constants.ps1 b/SCEPmanClient/Private/x509/constants.ps1 index b56223e..e7379ce 100644 --- a/SCEPmanClient/Private/x509/constants.ps1 +++ b/SCEPmanClient/Private/x509/constants.ps1 @@ -44,4 +44,33 @@ enum ValidityPeriod { Weeks Months Years +} + +enum RevocationReason { + # Good = -1 + Unspecified = 0 + KeyCompromise = 1 + CACompromise = 2 + AffiliationChanged = 3 + Superseded = 4 + CessationOfOperation = 5 + CertificateHold = 6 + RemoveFromCrl = 8 + PrivilegeWithdrawn = 9 + AACompromise = 10 +} + +enum CertValidityType { + Valid + Revoked + Expired + Any +} + +enum CertType { + Static + DC + User + Device + Any } \ No newline at end of file diff --git a/SCEPmanClient/Public/Find-SCEPmanCertificate.ps1 b/SCEPmanClient/Public/Find-SCEPmanCertificate.ps1 new file mode 100644 index 0000000..6118c74 --- /dev/null +++ b/SCEPmanClient/Public/Find-SCEPmanCertificate.ps1 @@ -0,0 +1,198 @@ +<# +.SYNOPSIS + Search certificates issued by SCEPman. + +.DESCRIPTION + This function searches certificates via the SCEPman management search API. + +.PARAMETER Url + The URL of the SCEPman App Service. + +.PARAMETER SearchText + Search text used by the SCEPman API (for example an email, subject, or serial fragment). + +.PARAMETER PageSize + Number of results to return per request. + +.PARAMETER CertValidity + Certificate validity filter value expected by your SCEPman API (for example 'Any' or a numeric enum value). + +.PARAMETER CertType + Certificate type filter value expected by your SCEPman API (for example 'Any' or a numeric enum value). + +.PARAMETER ContinuationToken + Continuation token from a previous search response. + +.PARAMETER ResourceUrl + The resource URL of the SCEPman service. If not provided, the function will try to find the Enterprise Application for the URL. + +.PARAMETER IgnoreExistingSession + Ignore existing Azure session. + +.PARAMETER DeviceCode + Use device code authentication. + +.PARAMETER Identity + Use the managed identity for authentication. + +.PARAMETER ClientId + The client ID for service principal authentication. + +.PARAMETER TenantId + The tenant ID for service principal authentication. + +.PARAMETER ClientSecret + The client secret for service principal authentication. + +.PARAMETER AccessToken + An access token for authentication. If not provided, the function will authenticate using Azure PowerShell + +.EXAMPLE + Find-SCEPmanCertificate -Url "https://scepman.contoso.com" -SearchText "alice@contoso.com" -PageSize 50 -CertValidity Any -CertType Any + +.EXAMPLE + Find-SCEPmanCertificate -Url "https://scepman.contoso.com" -SearchText "alice" -ContinuationToken "next-page-token" +#> + +Function Find-SCEPmanCertificate { + [CmdletBinding(DefaultParameterSetName='AzAuth')] + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingUsernameAndPasswordParams", "", Justification="Service principal authentication requires username and password.")] + Param( + [Parameter(Mandatory, Position=0)] + [Alias('AppServiceUrl')] + [String]$Url, + + [String]$SearchText, + + [ValidateRange(1, 500)] + [Int]$PageSize = 50, + + [CertValidityType]$CertValidity, + + [CertType]$CertType, + + [String]$ContinuationToken, + + [Parameter(ParameterSetName='AzAuth')] + [String]$ResourceUrl, + + [Parameter(ParameterSetName='AzAuth')] + [Switch]$IgnoreExistingSession, + [Parameter(ParameterSetName='AzAuth')] + [Switch]$DeviceCode, + [Parameter(ParameterSetName='AzAuth')] + [Switch]$Identity, + [Parameter(ParameterSetName='AzAuth')] + [String]$ClientId, + [Parameter(ParameterSetName='AzAuth')] + [String]$TenantId, + [Parameter(ParameterSetName='AzAuth')] + [String]$ClientSecret, + + [Parameter(ParameterSetName='DirectTokenAuth')] + [String]$AccessToken + ) + + Begin { + $ErrorActionPreference = 'Stop' + + If($PSCmdlet.ParameterSetName -eq 'DirectTokenAuth') { + Write-Verbose "$($MyInvocation.MyCommand): Using direct token authentication" + + If (-not $AccessToken) { + throw "$($MyInvocation.MyCommand): AccessToken is required for direct token authentication" + } + } Else { + Set-AzConfig -Scope Process -LoginExperienceV2 Off -DisplaySurveyMessage $false | Out-Null + + $Connect_Params = @{} + + If ($PSBoundParameters.ContainsKey('IgnoreExistingSession')) { $Connect_Params['IgnoreExistingSession'] = $true } + If ($PSBoundParameters.ContainsKey('DeviceCode')) { $Connect_Params['DeviceCode'] = $true } + If ($PSBoundParameters.ContainsKey('Identity')) { $Connect_Params['Identity'] = $true } + If ($PSBoundParameters.ContainsKey('ClientId')) { $Connect_Params['ClientId'] = $ClientId } + If ($PSBoundParameters.ContainsKey('TenantId')) { $Connect_Params['TenantId'] = $TenantId } + If ($PSBoundParameters.ContainsKey('ClientSecret')) { $Connect_Params['ClientSecret'] = $ClientSecret } + + Connect-SCEPmanAzAccount @Connect_Params + + If (-not $PSBoundParameters.ContainsKey('ResourceUrl')) { + Write-Verbose "$($MyInvocation.MyCommand): No resource URL provided. Trying to find Enterprise Application for URL: $Url" + $ResourceUrl = Get-SCEPmanResourceUrl -AppServiceUrl $Url + } + + $AccessToken = Get-SCEPmanAccessToken -ResourceUrl $ResourceUrl + } + } + + Process { + $BaseUrl = $Url.TrimEnd('/') + + $Headers = @{ + 'Authorization' = "Bearer $AccessToken" + } + + $Query = [ordered]@{ + SearchText = $SearchText + PageSize = $PageSize + CertValidity = $CertValidity + CertType = $CertType + ContinuationToken = $ContinuationToken + } + + $QueryString = ($Query.GetEnumerator() | + Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_.Value) } | + ForEach-Object { + '{0}={1}' -f [uri]::EscapeDataString($_.Key), [uri]::EscapeDataString([string]$_.Value) + }) -join '&' + + $RequestUrl = "$BaseUrl/api/manage/search" + If (-not [string]::IsNullOrWhiteSpace($QueryString)) { + $RequestUrl = "{0}?{1}" -f $RequestUrl, $QueryString + } + + Write-Verbose "$($MyInvocation.MyCommand): Sending search request to $RequestUrl" + + try { + $Response = Invoke-RestMethod -Uri $RequestUrl -Method Get -Headers $Headers + Write-Verbose "$($MyInvocation.MyCommand): Search request successful. Found $($Response.items.count) certificate$(if($Response.items.count -ne 1) { 's' })" + + Return $Response + + } catch { + $StatusCode = if ($_.Exception.Response) { [int]$_.Exception.Response.StatusCode } else { $null } + $RawErrorBody = $_.ErrorDetails.Message + + $ApiErrorCode = $null + $ApiErrorMessage = $null + + if ($RawErrorBody) { + try { + $ParsedError = $RawErrorBody | ConvertFrom-Json + $ApiErrorCode = $ParsedError.ErrorCode + $ApiErrorMessage = $ParsedError.ErrorMessage + } + catch { + $ApiErrorMessage = $RawErrorBody + } + } + + Write-Verbose "$($MyInvocation.MyCommand): Failed to search certificates. Status code: $StatusCode. ApiErrorCode: $ApiErrorCode. ApiErrorMessage: $ApiErrorMessage" + + switch ($ApiErrorCode) { + 4711 { throw "$($MyInvocation.MyCommand): SCEPman Enterprise is required for the manage search API." } + default { + switch ($StatusCode) { + 400 { throw "$($MyInvocation.MyCommand): Bad request. Check the request parameters for errors. $ApiErrorMessage" } + 401 { throw "$($MyInvocation.MyCommand): Unauthorized. Authentication failed." } + 403 { throw "$($MyInvocation.MyCommand): Forbidden. Access denied or license revoked." } + 404 { throw "$($MyInvocation.MyCommand): Endpoint not found. Verify the URL and that the manage API endpoint exists." } + 409 { throw "$($MyInvocation.MyCommand): Conflict. $(if ($ApiErrorMessage) { $ApiErrorMessage } else { 'Request could not be completed.' })" } + 500 { throw "$($MyInvocation.MyCommand): Server error while searching certificates. $ApiErrorMessage" } + default { throw $_ } + } + } + } + } + } +} diff --git a/SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 b/SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 new file mode 100644 index 0000000..01ec095 --- /dev/null +++ b/SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 @@ -0,0 +1,146 @@ +<# +.SYNOPSIS + Revoke a certificate issued by SCEPman. + +.DESCRIPTION + This function revokes a certificate issued by SCEPman by calling the SCEPman revocation API. + +.PARAMETER Url + The URL of the SCEPman App Service. + +.PARAMETER SerialNumber + One or more serial numbers of the certificates to revoke. + +.PARAMETER RevocationReason + The reason for revoking the certificate. + +.PARAMETER Revoker + The identity of the person or entity revoking the certificate (e.g. admin@contoso.com). + +.PARAMETER ResourceUrl + The resource URL of the SCEPman service. If not provided, the function will try to find the Enterprise Application for the URL. + +.PARAMETER IgnoreExistingSession + Ignore existing Azure session. + +.PARAMETER DeviceCode + Use device code authentication. + +.PARAMETER Identity + Use the managed identity for authentication. + +.PARAMETER ClientId + The client ID for service principal authentication. + +.PARAMETER TenantId + The tenant ID for service principal authentication. + +.PARAMETER ClientSecret + The client secret for service principal authentication. + +.PARAMETER AccessToken + An access token for authentication. If not provided, the function will authenticate using Azure PowerShell + +.EXAMPLE + Revoke-SCEPmanCertificate -Url "https://scepman.contoso.com" -SerialNumber "1A2B3C4D" -RevocationReason KeyCompromise -Revoker "admin@contoso.com" + +.EXAMPLE + Revoke-SCEPmanCertificate -Url "https://scepman.contoso.com" -SerialNumber "1A2B3C4D","5E6F7A8B" -RevocationReason Superseded +#> + +Function Revoke-SCEPmanCertificate { + [CmdletBinding(DefaultParameterSetName='AzAuth')] + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingUsernameAndPasswordParams", "", Justification="Service principal authentication requires username and password.")] + Param( + [Parameter(Mandatory, Position=0)] + [Alias('AppServiceUrl')] + [String]$Url, + + [Parameter(Mandatory, Position=1, ValueFromPipeline)] + [String[]]$SerialNumber, + + [Parameter(Mandatory)] + [RevocationReason]$RevocationReason, + + [String]$Revoker, + + [Parameter(ParameterSetName='AzAuth')] + [String]$ResourceUrl, + + [Parameter(ParameterSetName='AzAuth')] + [Switch]$IgnoreExistingSession, + [Parameter(ParameterSetName='AzAuth')] + [Switch]$DeviceCode, + [Parameter(ParameterSetName='AzAuth')] + [Switch]$Identity, + [Parameter(ParameterSetName='AzAuth')] + [String]$ClientId, + [Parameter(ParameterSetName='AzAuth')] + [String]$TenantId, + [Parameter(ParameterSetName='AzAuth')] + [String]$ClientSecret, + + [Parameter(ParameterSetName='DirectTokenAuth')] + [String]$AccessToken + ) + + Begin { + $ErrorActionPreference = 'Stop' + + If($PSCmdlet.ParameterSetName -eq 'DirectTokenAuth') { + Write-Verbose "$($MyInvocation.MyCommand): Using direct token authentication" + + If (-not $AccessToken) { + throw "$($MyInvocation.MyCommand): AccessToken is required for direct token authentication" + } + } Else { + Set-AzConfig -Scope Process -LoginExperienceV2 Off -DisplaySurveyMessage $false | Out-Null + + $Connect_Params = @{} + + If ($PSBoundParameters.ContainsKey('IgnoreExistingSession')) { $Connect_Params['IgnoreExistingSession'] = $true } + If ($PSBoundParameters.ContainsKey('DeviceCode')) { $Connect_Params['DeviceCode'] = $true } + If ($PSBoundParameters.ContainsKey('Identity')) { $Connect_Params['Identity'] = $true } + If ($PSBoundParameters.ContainsKey('ClientId')) { $Connect_Params['ClientId'] = $ClientId } + If ($PSBoundParameters.ContainsKey('TenantId')) { $Connect_Params['TenantId'] = $TenantId } + If ($PSBoundParameters.ContainsKey('ClientSecret')) { $Connect_Params['ClientSecret'] = $ClientSecret } + + Connect-SCEPmanAzAccount @Connect_Params + + If (-not $PSBoundParameters.ContainsKey('ResourceUrl')) { + Write-Verbose "$($MyInvocation.MyCommand): No resource URL provided. Trying to find Enterprise Application for URL: $Url" + $ResourceUrl = Get-SCEPmanResourceUrl -AppServiceUrl $Url + } + + $AccessToken = Get-SCEPmanAccessToken -ResourceUrl $ResourceUrl + } + + $BaseUrl = $Url.TrimEnd('/') + + $Headers = @{ + 'Authorization' = "Bearer $AccessToken" + 'Content-Type' = 'application/json' + } + + } + + Process { + foreach ($Serial in $SerialNumber) { + $RequestUrl = "$BaseUrl/api/manage/revoke/$Serial" + + $Body = @{ + revocationReason = [int]$RevocationReason + revoker = $Revoker + } | ConvertTo-Json + + Write-Verbose "$($MyInvocation.MyCommand): Sending revocation request to $RequestUrl" + + try { + $null = Invoke-RestMethod -Uri $RequestUrl -Method Patch -Headers $Headers -Body $Body + Write-Output "$($MyInvocation.MyCommand): Certificate $Serial revoked successfully." + } catch { + throw "$($MyInvocation.MyCommand): Failed to revoke certificate $Serial. Raw error: $($_)" + } + } + } +} diff --git a/SCEPmanClient/SCEPmanClient.psd1 b/SCEPmanClient/SCEPmanClient.psd1 index 85695ef..5bd5285 100644 --- a/SCEPmanClient/SCEPmanClient.psd1 +++ b/SCEPmanClient/SCEPmanClient.psd1 @@ -35,7 +35,9 @@ 'New-CSR', 'New-PrivateKey', 'New-SCEPmanCertificate', - 'New-SCEPManKeyVaultCertificate' + 'New-SCEPManKeyVaultCertificate', + 'Revoke-SCEPmanCertificate', + 'Find-SCEPmanCertificate' ) } diff --git a/Tests/Find-SCEPmanCertificate.Tests.ps1 b/Tests/Find-SCEPmanCertificate.Tests.ps1 new file mode 100644 index 0000000..e56bbe5 --- /dev/null +++ b/Tests/Find-SCEPmanCertificate.Tests.ps1 @@ -0,0 +1,55 @@ +BeforeAll { + $ModuleRoot = "$PSScriptRoot\..\SCEPmanClient\" + + Import-Module "$ModuleRoot\SCEPmanClient.psm1" +} + +Describe "Find-SCEPmanCertificate" { + BeforeEach { + $script:LastInvokeUri = $null + $script:LastInvokeMethod = $null + $script:LastInvokeAuth = $null + + Mock Set-AzConfig {} -ModuleName SCEPmanClient + Mock Connect-SCEPmanAzAccount {} -ModuleName SCEPmanClient + Mock Get-SCEPmanResourceUrl { 'api://resource-id' } -ModuleName SCEPmanClient + Mock Get-SCEPmanAccessToken { 'test-token' } -ModuleName SCEPmanClient + Mock Invoke-RestMethod { + param($Uri, $Method, $Headers) + $script:LastInvokeUri = $Uri + $script:LastInvokeMethod = $Method + $script:LastInvokeAuth = $Headers.Authorization + [pscustomobject]@{ ok = $true } + } -ModuleName SCEPmanClient + } + + It "builds the search query and calls the API using bearer auth" { + Find-SCEPmanCertificate -Url "https://scepman.contoso.com" -SearchText "alice@contoso.com" -PageSize 50 -CertValidity "Any" -CertType "Any" | Out-Null + + Should -Invoke Invoke-RestMethod -Times 1 -ModuleName SCEPmanClient + $script:LastInvokeMethod | Should -Be 'Get' + $script:LastInvokeAuth | Should -Be 'Bearer test-token' + $script:LastInvokeUri | Should -Match '^https://scepman\.contoso\.com/api/manage/search\?' + $script:LastInvokeUri | Should -Match 'SearchText=alice%40contoso\.com' + $script:LastInvokeUri | Should -Match 'PageSize=50' + $script:LastInvokeUri | Should -Match 'CertValidity=Any' + $script:LastInvokeUri | Should -Match 'CertType=Any' + } + + It "omits empty continuation token and resolves resource URL when not provided" { + Find-SCEPmanCertificate -Url "https://scepman.contoso.com" -SearchText "alice" -ContinuationToken "" | Out-Null + + Should -Invoke Get-SCEPmanResourceUrl -Times 1 -ModuleName SCEPmanClient + Should -Invoke Invoke-RestMethod -Times 1 -ModuleName SCEPmanClient + $script:LastInvokeUri | Should -Not -Match 'ContinuationToken=' + } + + It "does not resolve resource URL when ResourceUrl is provided" { + Find-SCEPmanCertificate -Url "https://scepman.contoso.com" -SearchText "alice" -ResourceUrl "api://given-resource" | Out-Null + + Should -Invoke Get-SCEPmanResourceUrl -Times 0 -ModuleName SCEPmanClient + Should -Invoke Get-SCEPmanAccessToken -Times 1 -ModuleName SCEPmanClient -ParameterFilter { + $ResourceUrl -eq 'api://given-resource' + } + } +} diff --git a/Tests/Revoke-SCEPmanCertificate.Tests.ps1 b/Tests/Revoke-SCEPmanCertificate.Tests.ps1 new file mode 100644 index 0000000..817e134 --- /dev/null +++ b/Tests/Revoke-SCEPmanCertificate.Tests.ps1 @@ -0,0 +1,59 @@ +BeforeAll { + $ModuleRoot = "$PSScriptRoot\..\SCEPmanClient\" + + Import-Module "$ModuleRoot\SCEPmanClient.psm1" +} + +Describe "Revoke-SCEPmanCertificate" { + BeforeEach { + $script:InvokeCalls = @() + + Mock Set-AzConfig {} -ModuleName SCEPmanClient + Mock Connect-SCEPmanAzAccount {} -ModuleName SCEPmanClient + Mock Get-SCEPmanResourceUrl { 'api://resource-id' } -ModuleName SCEPmanClient + Mock Get-SCEPmanAccessToken { 'test-token' } -ModuleName SCEPmanClient + Mock Get-AzContext { [pscustomobject]@{ Account = [pscustomobject]@{ Id = 'context-user@contoso.com' } } } -ModuleName SCEPmanClient + + Mock Invoke-RestMethod { + param($Uri, $Method, $Headers, $Body) + + $script:InvokeCalls += [pscustomobject]@{ + Uri = $Uri + Method = $Method + Auth = $Headers.Authorization + CType = $Headers.'Content-Type' + Body = $Body | ConvertFrom-Json + } + + [pscustomobject]@{ status = 'ok' } + } -ModuleName SCEPmanClient + } + + It "sends a PATCH request with revocation reason and explicit revoker" { + Revoke-SCEPmanCertificate -Url "https://scepman.contoso.com" -SerialNumber "1A2B3C4D" -RevocationReason KeyCompromise -Revoker "admin@contoso.com" -ResourceUrl "api://given-resource" | Out-Null + + Should -Invoke Get-SCEPmanResourceUrl -Times 0 -ModuleName SCEPmanClient + Should -Invoke Get-SCEPmanAccessToken -Times 1 -ModuleName SCEPmanClient -ParameterFilter { + $ResourceUrl -eq 'api://given-resource' + } + Should -Invoke Invoke-RestMethod -Times 1 -ModuleName SCEPmanClient + + $script:InvokeCalls[0].Method | Should -Be 'Patch' + $script:InvokeCalls[0].Auth | Should -Be 'Bearer test-token' + $script:InvokeCalls[0].CType | Should -Be 'application/json' + $script:InvokeCalls[0].Uri | Should -Be 'https://scepman.contoso.com/api/manage/revoke/1A2B3C4D' + $script:InvokeCalls[0].Body.revocationReason | Should -Be 1 + $script:InvokeCalls[0].Body.revoker | Should -Be 'admin@contoso.com' + } + + It "sends one request per serial number" { + Revoke-SCEPmanCertificate -Url "https://scepman.contoso.com" -SerialNumber "1111", "2222" -RevocationReason Unspecified -Revoker "admin@contoso.com" | Out-Null + + Should -Invoke Invoke-RestMethod -Times 2 -ModuleName SCEPmanClient + $script:InvokeCalls.Count | Should -Be 2 + $script:InvokeCalls[0].Uri | Should -Be 'https://scepman.contoso.com/api/manage/revoke/1111' + $script:InvokeCalls[1].Uri | Should -Be 'https://scepman.contoso.com/api/manage/revoke/2222' + $script:InvokeCalls[0].Body.revocationReason | Should -Be 0 + $script:InvokeCalls[1].Body.revocationReason | Should -Be 0 + } +}