public/New-AxisCertificateRequest.ps1

<#
.SYNOPSIS
Generates a key pair on an Axis device and returns a certificate signing request (CSR).

.DESCRIPTION
The New-AxisCertificateRequest function creates (or reuses) a private key on the device and returns a PKCS#10 CSR in
PEM format. The private key never leaves the camera.
AXIS OS 11.6+: POST /config/rest/cert/v1/create_certificate (self-signed placeholder valid one year, key in the chosen
keystore) followed by POST /certificates/<alias>/get_csr with subject and SAN. If the alias already exists the existing
key is reused unless -Force is given (then the entry is deleted and recreated).
Older firmware (SOAP fallback): acertificates:CreateCertificate2 followed by the ONVIF tds:GetPkcs10Request. SAN is not
supported by that API and the Pkcs10 operation is not documented by Axis, so a warning is written.
Sources: https://developer.axis.com/vapix/device-configuration/certificate-management/
         https://developer.axis.com/vapix/network-video/certificate-management-api/

.PARAMETER Device
Specifies the IP address or hostname of the Axis device.

.PARAMETER Alias
Certificate alias / ID on the device. Default $Config.Certificate.Alias ("AxisPowershell").

.PARAMETER Subject
Subject DN (RFC 4514), e.g. "CN=cam01.example.com,O=Example". Default: resolved with Get-AxisCertificateSubject.

.PARAMETER SubjectAltNames
SAN entries, each prefixed with DNS: or IP:. Default: resolved with Get-AxisCertificateSubject.

.PARAMETER KeyType
Auto (default, strongest supported) or an explicit key type such as NIST-P256 or RSA-2048.

.PARAMETER Keystore
Auto (default, most secure available) or an explicit keystore such as TPM0, SE0, SOFTWARE0.

.PARAMETER AllowedKeyTypes
Restrict automatic key selection, e.g. -AllowedKeyTypes RSA-4096,RSA-2048 for an RSA-only CA template.

.PARAMETER Force
Replace an existing certificate/key with the same alias instead of reusing its key.

.EXAMPLE
$Request = New-AxisCertificateRequest -Device "192.168.1.100"
$Request.Csr | Set-Content cam.csr
Creates a key on the device and saves the CSR to a file.

.EXAMPLE
New-AxisCertificateRequest -Device "192.168.1.100" -Alias "Web" -Subject "CN=cam01.example.com" -SubjectAltNames "DNS:cam01.example.com","DNS:cam01" -KeyType RSA-2048 -Force
#>

function New-AxisCertificateRequest {
    [cmdletbinding()]
    Param(
        [Parameter(Mandatory)]
        [String]$Device,

        [Parameter()]
        [String]$Alias,

        [Parameter()]
        [String]$Subject,

        [Parameter()]
        [String[]]$SubjectAltNames,

        [Parameter()]
        [String]$KeyType="Auto",

        [Parameter()]
        [String]$Keystore="Auto",

        [Parameter()]
        [String[]]$AllowedKeyTypes,

        [Parameter()]
        [Switch]$Force
    )

    if(!$Alias) {
        $Alias = $Config.Certificate.Alias
        if(!$Alias) { $Alias = "AxisPowershell" }
    }
    if($KeyType -eq "Auto" -and $Config.Certificate.KeyType) { $KeyType = $Config.Certificate.KeyType }
    if($Keystore -eq "Auto" -and $Config.Certificate.Keystore) { $Keystore = $Config.Certificate.Keystore }

    #Subject / SAN from templates when not supplied
    if(!$Subject -or !$PSBoundParameters.ContainsKey('SubjectAltNames')) {
        $Resolved = Get-AxisCertificateSubject -Device $Device
        if(!$Subject) { $Subject = $Resolved.Subject }
        if(!$PSBoundParameters.ContainsKey('SubjectAltNames')) { $SubjectAltNames = @($Resolved.SubjectAltNames) }
    }
    if(!$SubjectAltNames) { $SubjectAltNames = @() }

    #Does the alias already exist, and which API do we have?
    $Source = "REST"
    $Existing = $null
    Try {
        $Existing = @(Invoke-AxisCertApi -Device $Device -Path "/certificates") | Where-Object { $_.alias -eq $Alias }
    }
    Catch {
        if($_.Exception.Message -notlike "*Command not supported*") {
            Throw "Unable to query certificates on $($Device): $_"
        }
        $Source = "SOAP"
    }

    $Selection = $null
    if($Source -eq "REST") {
        if($Existing -and !$Force) {
            Write-Verbose "Alias '$Alias' exists on $Device; reusing its key (use -Force to generate a new one)"
            $ExistingKeystore = [String]($Existing | Select-Object -First 1).keystore
            $Selection = [pscustomobject]@{ KeyType = $null; Keystore = $ExistingKeystore }
            Try {
                $Info = Get-AxisCertificate -Device $Device -Alias $Alias | Select-Object -First 1
                if($Info) { $Selection.KeyType = $Info.KeyType }
            } Catch {}
        }
        else {
            if($Existing) {
                Write-Verbose "Alias '$Alias' exists on $Device; removing it (-Force)"
                Try {
                    $null = Invoke-AxisCertApi -Device $Device -Path "/certificates/$([uri]::EscapeDataString($Alias))" -Method DELETE
                }
                Catch {
                    Throw "Unable to remove existing certificate '$Alias' on $($Device): $_"
                }
            }

            $Selection = Get-AxisCertificateKeyType -Device $Device -KeyType $KeyType -Keystore $Keystore -AllowedKeyTypes $AllowedKeyTypes

            #Self-signed placeholder valid for one year; it gets replaced by Install-AxisCertificate
            $Now = [DateTimeOffset]::UtcNow
            $Data = @{
                alias = $Alias
                subject = $Subject
                subject_alt_names = @($SubjectAltNames)
                valid_from = $Now.ToUnixTimeSeconds()
                valid_to = $Now.AddYears(1).ToUnixTimeSeconds()
            }
            if($Selection.KeyType) { $Data.Add('key_type', $Selection.KeyType) }
            if($Selection.Keystore) { $Data.Add('keystore', $Selection.Keystore) }

            Write-Verbose "Generating $($Selection.KeyType) key in $($Selection.Keystore) on $Device as '$Alias'"
            Try {
                $null = Invoke-AxisCertApi -Device $Device -Path "/create_certificate" -Method POST -Data $Data
            }
            Catch {
                Throw "Unable to generate key/certificate '$Alias' on $($Device): $_"
            }
        }

        #CSR with the requested subject/SAN (fields given here replace those of the placeholder)
        $CsrData = @{
            subject = $Subject
            subject_alt_names = @($SubjectAltNames)
        }
        Try {
            $CsrResponse = Invoke-AxisCertApi -Device $Device -Path "/certificates/$([uri]::EscapeDataString($Alias))/get_csr" -Method POST -Data $CsrData
        }
        Catch {
            Throw "Unable to obtain CSR for '$Alias' from $($Device): $_"
        }
        #Documented response type is a string; an early doc example shows it under "alias". Accept both.
        $CsrText = [String]$CsrResponse
        if($CsrResponse -isnot [String]) {
            $CsrText = ($CsrResponse.PSObject.Properties | ForEach-Object { [String]$_.Value } | Where-Object { $_ -like "*CERTIFICATE REQUEST*" } | Select-Object -First 1)
        }
        if($CsrText -match '-----BEGIN (NEW )?CERTIFICATE REQUEST-----[\s\S]+?-----END (NEW )?CERTIFICATE REQUEST-----') {
            $Csr = $Matches[0]
        }
        else {
            Throw "Device $Device did not return a PEM certificate request"
        }
    }
    else {
        #SOAP fallback for firmware < 11.6
        #CreateCertificate2 is documented by Axis; tds:GetPkcs10Request is standard ONVIF (not on the Axis page) and was
        #VERIFIED working on M3045-V / AXIS OS 9.80.49 (2026-11-03): returns Pkcs10Request/tt:Data base64 DER, RSA-2048.
        Write-Warning "$Device has no certificate REST API (firmware < 11.6); using SOAP. Subject Alternative Names are not supported by this firmware."

        #VERIFIED on M3045-V / AXIS OS 9.80.49 (2026-11-03): ONVIF LoadCertificates refuses an existing CertificateID
        #("Unknown certificate reference or the certificate ID already exists") and creates a NEW entry matched to the
        #key by public key. So the key/CSR placeholder lives under "<Alias>-csr" and Install-AxisCertificate loads the
        #signed certificate as "<Alias>" and then deletes the placeholder.
        $KeyAlias = "$Alias-csr"
        $SoapExisting = @(Get-AxisCertificate -Device $Device | Where-Object { $_.Alias -eq $KeyAlias })
        if($Force) {
            ForEach($Id in @($Alias, $KeyAlias)) {
                if(@(Get-AxisCertificate -Device $Device | Where-Object { $_.Alias -eq $Id }).Count -gt 0) {
                    Write-Verbose "Alias '$Id' exists on $Device; removing it (-Force)"
                    $null = Invoke-AxisSoapRequest -Device $Device -Body "<tds:DeleteCertificates xmlns=`"http://www.onvif.org/ver10/device/wsdl`"><CertificateID>$([System.Security.SecurityElement]::Escape($Id))</CertificateID></tds:DeleteCertificates>"
                }
            }
            $SoapExisting = @()
        }

        if($SoapExisting.Count -eq 0) {
            #Split the DN into the fields CreateCertificate2 knows about
            $Parts = @{ C = ""; ST = ""; L = ""; O = ""; OU = ""; CN = "" }
            ForEach($Rdn in ($Subject -split '(?<!\\),')) {
                $Pair = $Rdn.Split('=',2)
                if($Pair.Count -ne 2) { continue }
                $Name = $Pair[0].Trim().ToUpper()
                if($Parts.ContainsKey($Name)) {
                    $Parts[$Name] = [System.Security.SecurityElement]::Escape($Pair[1].Trim().Replace('\,',','))
                }
                else {
                    Write-Verbose "Subject attribute '$Name' is not supported by CreateCertificate2 and was dropped"
                }
            }
            if(!$Parts.CN) { Throw "Subject '$Subject' has no CN" }

            $NotBefore = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss")
            $NotAfter = (Get-Date).ToUniversalTime().AddYears(1).ToString("yyyy-MM-ddTHH:mm:ss")
            $Body = @"
<acertificates:CreateCertificate2 xmlns="http://www.axis.com/vapix/ws/certificates">
            <acertificates:Id>$([System.Security.SecurityElement]::Escape($KeyAlias))</acertificates:Id>
            <acertificates:Subject>
                <acert:C>$($Parts.C)</acert:C>
                <acert:ST>$($Parts.ST)</acert:ST>
                <acert:L>$($Parts.L)</acert:L>
                <acert:O>$($Parts.O)</acert:O>
                <acert:OU>$($Parts.OU)</acert:OU>
                <acert:CN>$($Parts.CN)</acert:CN>
            </acertificates:Subject>
            <acertificates:ValidNotBefore>$NotBefore</acertificates:ValidNotBefore>
            <acertificates:ValidNotAfter>$NotAfter</acertificates:ValidNotAfter>
        </acertificates:CreateCertificate2>
"@

            Write-Verbose "Generating key/certificate '$KeyAlias' on $Device via CreateCertificate2"
            Try {
                $null = Invoke-AxisSoapRequest -Device $Device -Body $Body
            }
            Catch {
                Throw "Unable to generate key/certificate '$KeyAlias' on $($Device): $_"
            }
        }
        else {
            Write-Verbose "Alias '$KeyAlias' exists on $Device; reusing its key (use -Force to generate a new one)"
        }

        #VERIFIED on M3045-V / AXIS OS 9.80.49 (2026-11-03): the device uses tds:Subject as the CN only; C/ST/L/O/OU come from
        #the certificate created by CreateCertificate2. Passing the full DN here yields "CN=CN=host,O=..." in the CSR.
        $CnValue = (($Subject -split '(?<!\\),') | Where-Object { $_.Trim() -match '^CN=' } | Select-Object -First 1)
        $CnValue = $CnValue.Trim().Substring(3).Replace('\,',',')
        $Body = @"
<tds:GetPkcs10Request xmlns="http://www.onvif.org/ver10/device/wsdl">
            <tds:CertificateID>$([System.Security.SecurityElement]::Escape($KeyAlias))</tds:CertificateID>
            <tds:Subject>$([System.Security.SecurityElement]::Escape($CnValue))</tds:Subject>
        </tds:GetPkcs10Request>
"@

        Try {
            $Response = Invoke-AxisSoapRequest -Device $Device -Body $Body
        }
        Catch {
            Throw "Unable to obtain CSR for '$Alias' from $($Device): $_"
        }
        $DataNode = $Response.SelectSingleNode("//*[local-name()='Pkcs10Request']/*[local-name()='Data']")
        if(!$DataNode -or !$DataNode.InnerText) {
            Throw "Device $Device did not return a PKCS#10 request"
        }
        $Raw = $DataNode.InnerText.Trim()
        if($Raw -like "*-----BEGIN*") {
            $Csr = $Raw
        }
        else {
            $Csr = ConvertTo-PemString -Bytes ([Convert]::FromBase64String(($Raw -replace '\s',''))) -Label "CERTIFICATE REQUEST"
        }

        $Selection = Get-AxisCertificateKeyType -Device $Device -KeyType $KeyType -Keystore $Keystore
        $SubjectAltNames = @()
    }

    [pscustomobject]@{
        Device = $Device
        Alias = $Alias
        Csr = $Csr
        Subject = $Subject
        SubjectAltNames = @($SubjectAltNames)
        KeyType = $Selection.KeyType
        Keystore = $Selection.Keystore
        Source = $Source
    }
}