DSInternals.Passkeys.psm1

# Needed for [Microsoft.Graph.PowerShell.Models.MicrosoftGraphFido2AuthenticationMethod] type
Import-Module -Name Microsoft.Graph.Identity.SignIns -ErrorAction Stop

# Variables used for Okta connection lifecycle management
New-Variable -Name OktaToken -Value $null -Scope Script
New-Variable -Name OktaRevocationInfo -Value $null -Scope Script

function Get-EntraIDPasskeyRegistrationOptions
{
    [OutputType([DSInternals.Win32.WebAuthn.EntraID.MicrosoftGraphWebauthnCredentialCreationOptions])]
    param (
    [Parameter(Mandatory = $true)]
    [ValidateScript({
        return $_ -match "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$" -or $true -eq [guid]::TryParse($_, $([ref][guid]::Empty))
    })]
    [Alias('User')]
    [string] $UserId,

    [Parameter(Mandatory = $false)]
    [ValidateScript({
        if ($_ -is [TimeSpan]) {
            $min = New-TimeSpan -Minutes 5
            $max = New-TimeSpan -Minutes 43200
            return $_ -ge $min -and $_ -le $max
        }
        else {
            throw "Parameter must be a TimeSpan object."
        }
    })]
    [Alias('Timeout')]
    [TimeSpan] $ChallengeTimeout = (New-TimeSpan -Minutes 5)
    )
    try {
        Write-Debug "UserId ${UserId} TokenLifetimeSeconds ${ChallengeTimeout}"
        # Generate the user-specific URL, e.g., https://graph.microsoft.com/beta/users/af4cf208-16e0-429d-b574-2a09c5f30dea/authentication/fido2Methods/creationOptions
        [string] $credentialOptionsUrl = '/beta/users/{0}/authentication/fido2Methods/creationOptions' -f [uri]::EscapeDataString($UserId)

        Write-Debug ('Credential options url: ' + $credentialOptionsUrl)

        [string] $response = Invoke-MgGraphRequest -Method GET `
                                                -Uri $credentialOptionsUrl `
                                                -Body @{ challengeTimeoutInMinutes = $ChallengeTimeout.TotalMinutes } `
                                                -OutputType Json

        Write-Debug ('Credential options response: ' + $response)

        # Parse JSON response
        return [DSInternals.Win32.WebAuthn.EntraID.MicrosoftGraphWebauthnCredentialCreationOptions]::Create($response)
    }
    catch {
        throw $_
    }
}

function Invoke-OktaWebRequest
{
    param(
        $Path,
        $Query,
        $Method = [Microsoft.PowerShell.Commands.WebRequestMethod]::Post,
        $Body,
        $ContentType = "application/json"
    )

    Write-Debug "Path ${Path}"
    Write-Debug "Query ${Query}"
    Write-Debug "Method ${Method}"
    Write-Debug ('Body ' + ($Body | ConvertTo-Json))
    Write-Debug "Content type ${ContentType}"

    $tokenType = $Script:OktaToken.TokenType
    $token = $Script:OktaToken.AccessToken
    $headers = @{
        "Accept" = "application/json"
        "Authorization" = "${tokenType} ${token}"
    }

    $tenant = ([System.UriBuilder]($Script:OktaToken.AuthenticationResultMetadata.TokenEndpoint)).Host
    Write-Debug "Tenant ${Tenant}"

    $uriBuilder = New-Object System.UriBuilder
    $uriBuilder.Scheme = "https"
    $uriBuilder.Host = $tenant
    $uriBuilder.Path = $Path
    $uriBuilder.Query = $Query

    $uri = $uriBuilder.ToString()
    Write-Debug "Uri ${uri}"

    return Invoke-WebRequest -Uri $uri `
    -Method $Method `
    -Headers $headers `
    -ContentType $ContentType `
    -Body $Body
}

function Get-OktaPasskeyRegistrationOptions
{
    [OutputType([DSInternals.Win32.WebAuthn.Okta.OktaWebauthnCredentialCreationOptions])]
    param(
    [Parameter(Mandatory = $true)]
    [ValidatePattern("^[A-Za-z0-9_-]{20}$")]
    [Alias('User')]
    [string] $UserId,

    [Parameter(Mandatory = $false)]
    [ValidateScript({
        if ($_ -is [TimeSpan]) {
            $min = New-TimeSpan -Seconds 1
            $max = New-TimeSpan -Seconds 86400
            return $_ -ge $min -and $_ -le $max
        }
        else {
            throw "Parameter must be a TimeSpan object."
        }
    })]
    [Alias('Timeout')]
    [TimeSpan] $ChallengeTimeout = (New-TimeSpan -Minutes 5)
    )
    begin {
        if ($null -eq $Script:OktaToken)
        {
            throw 'Not connected to Okta, call Connnect-Okta to get started.'
        }
    }
    process {
        try
        {
            Write-Debug "In Get-OktaPasskeyRegistrationOptions with ${UserId} and ${ChallengeTimeout}"

            $TokenLifetimeSeconds = $ChallengeTimeout.TotalSeconds

            Write-Debug "TokenLifetimeSeconds ${TokenLifetimeSeconds}"

            [string] $credentialOptionsPath = "/api/v1/users/${UserId}/factors"
            [string] $credentialOptionsQuery = "tokenLifetimeSeconds=${TokenLifetimeSeconds}&activate=true"
            Write-Debug ('Credential options path: ' + $credentialOptionsPath)
            Write-Debug ('Credential options query: ' + $credentialOptionsQuery)

            $body = @{
                factorType = "webauthn"
                provider = "FIDO"
            } | ConvertTo-Json -Compress

            Write-Debug ('Credential options payload: ' + $body)

            [string] $response = Invoke-OktaWebRequest -Path $credentialOptionsPath `
                        -Query $credentialOptionsQuery `
                        -Body $body

            Write-Debug ('Credential options response: ' + $response)

            # Parse JSON response
            $options = [DSInternals.Win32.WebAuthn.Okta.OktaWebauthnCredentialCreationOptions]::Create($response)

            # Okta appears to omit relying party id in the options, but it is required for credential creation
            # So set default to the tenant we are talking to, which is probably what the user wants anyway
            if ($null -eq $options.Embedded.PublicKeyOptions.RelyingParty.Id)
            {
                Write-Debug ('Setting relying party id to ' + ([System.UriBuilder]($Script:OktaToken.AuthenticationResultMetadata.TokenEndpoint)).Host)
                $options.Embedded.PublicKeyOptions.RelyingParty.Id = ([System.UriBuilder]($Script:OktaToken.AuthenticationResultMetadata.TokenEndpoint)).Host
            }
            Write-Debug ('Credential options: ' + ($options | Out-String))
            return $options
        }
        catch
        {
            throw $_
        }
    }
}

<#
.SYNOPSIS
Retrieves creation options required to generate and register a Microsoft Entra ID or Okta compatible passkey.
 
.PARAMETER UserId
The unique identifier of user. For Entra ID, this is the object id (guid) or UPN. For Okta, this is the unique identifier of Okta user.
 
.PARAMETER ChallengeTimeout
Overrides the timeout of the server-generated challenge returned in the request. For Entra ID, the default value is 5 minutes, with the accepted range being between 5 minutes and 30 days. For Okta, the default value is 300 second, with the accepted range being between 1 second and 1 day.
 
.EXAMPLE
PS \> Connect-MgGraph -Scopes 'UserAuthenticationMethod.ReadWrite.All'
PS \> Get-PasskeyRegistrationOptions -UserId 'AdeleV@contoso.com'
 
.EXAMPLE
PS \> Connect-MgGraph -Scopes 'UserAuthenticationMethod.ReadWrite.All'
PS \> Get-PasskeyRegistrationOptions -UserId 'AdeleV@contoso.com' -ChallengeTimeout (New-TimeSpan -Minutes 10)
 
.EXAMPLE
PS \> Connect-Okta -Tenant example.okta.com -ClientId 0oakmj8hvxvtvCy3P5d7
PS \> Get-PasskeyRegistrationOptions -UserId 00eDuihq64pgP1gVD0x7
 
.EXAMPLE
PS \> Connect-Okta -Tenant example.okta.com -ClientId 0oakmj8hvxvtvCy3P5d7
PS \> Get-PasskeyRegistrationOptions -UserId 00eDuihq64pgP1gVD0x7 -ChallengeTimeout (New-TimeSpan -Seconds 60)
 
.NOTES
Self-service operations aren't supported for Entra ID.
More info about Entra ID at https://learn.microsoft.com/en-us/graph/api/fido2authenticationmethod-creationoptions
More info about Okta at https://developer.okta.com/docs/api/openapi/okta-management/management/tag/UserFactor/#tag/UserFactor/operation/enrollFactor
 
#>

function Get-PasskeyRegistrationOptions
{
    [OutputType([DSInternals.Win32.WebAuthn.WebauthnCredentialCreationOptions])]
    param(
        [Parameter(Mandatory = $true)]
        [Alias('User')]
        [ValidateScript({
            if ($_ -match "^[A-Za-z0-9_-]{20}$" -or $true -eq [guid]::TryParse($_, $([ref][guid]::Empty)) -or $_ -match "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$")
                {return $true}
                return $false
        })]
        [string] $UserId,

        [Parameter(Mandatory = $false)]
        [Alias('Timeout')]
        [timespan] $ChallengeTimeout = (New-TimeSpan -Minutes 5)
    )

    begin {
        Write-Debug "In Get-PasskeyRegistrationOptions with ${UserId} and ${ChallengeTimeout}"
        $IsEntraID = ($UserId -match "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$" -or $true -eq [guid]::TryParse($UserId, $([ref][guid]::Empty)))
        Write-Debug "IsEntraID: ${IsEntraId}"
        if ($IsEntraID)
        {
            $min = New-TimeSpan -Minutes 5
            $max = New-TimeSpan -Minutes 43200
            if ($ChallengeTimeout -gt $max -or $ChallengeTimeout -lt $min) {
                Write-Error "Cannot validate argument on parameter 'ChallengeTimeout' which must be a valid TimeSpan between 5 and 43200 minutes for $_." -ErrorAction Stop
            }
        }
        else
        {
            $min = New-TimeSpan -Seconds 1
            $max = New-TimeSpan -Seconds 86400
            if ($ChallengeTimeout -gt $max -or $ChallengeTimeout -lt $min) {
                Write-Error "Cannot validate argument on parameter 'ChallengeTimeout' which must be a valid TimeSpan between 1 and 86400 seconds for $_." -ErrorAction Stop
            }
            if ($UserId -notmatch "^[A-Za-z0-9_-]{20}$") {
                Write-Error "Cannot validate argument on parameter 'UserID' which must the unique idenitier for the user for Okta." -ErrorAction Stop
            }
            if ($null -eq $Script:OktaToken) {
                throw 'Not connected to Okta, call Connnect-Okta to get started.'
            }
        }
    }
    process {
        $Options = $null
        try {
            if ($IsEntraID) {
                Write-Debug "Calling Get-EntraIDPasskeyRegistrationOptions with ${UserId} and ${ChallengeTimeout}"
                $Options = Get-EntraIDPasskeyRegistrationOptions -UserId $UserId -ChallengeTimeout $ChallengeTimeout
            }
            else {
                Write-Debug "Calling Get-OktaPasskeyRegistrationOptions with ${UserId} and ${ChallengeTimeout}"
                $Options = Get-OktaPasskeyRegistrationOptions -UserId $UserId -ChallengeTimeout $ChallengeTimeout
            }
            return $Options
        }
        catch {
            $errorRecord = New-Object Management.Automation.ErrorRecord(
                $_.Exception,
                $_.Exception.Message,
                [Management.Automation.ErrorCategory]::InvalidArgument,
                $Options
            )

            $PSCmdlet.ThrowTerminatingError($errorRecord)
        }
    }
}

function Register-EntraIDPasskey
{
    [OutputType([Microsoft.Graph.PowerShell.Models.MicrosoftGraphFido2AuthenticationMethod])]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateScript({
            return $_ -match "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$" -or $true -eq [guid]::TryParse($_, $([ref][guid]::Empty))
        })]
        [Alias('User')]
        [string] $UserId,

        [ValidateScript({
            if ([string]::IsNullOrEmpty($_.DisplayName))
            {
                throw "Passkey 'DisplayName' field may not be null or empty."
            }
            return $true
        })]
        [DSInternals.Win32.WebAuthn.EntraID.MicrosoftGraphWebauthnAttestationResponse]
        $Passkey
    )
    try
    {
        # Generate the user-specific URL, e.g., https://graph.microsoft.com/beta/users/af4cf208-16e0-429d-b574-2a09c5f30dea/authentication/fido2Methods
        [string] $registrationUrl = '/beta/users/{0}/authentication/fido2Methods' -f [uri]::EscapeDataString($UserId)

        Write-Debug ('Registration URL: ' + $registrationUrl)

        [string] $response = Invoke-MgGraphRequest `
                                -Method POST `
                                -Uri $registrationUrl `
                                -OutputType Json `
                                -ContentType 'application/json' `
                                -Body $Passkey.ToString()

        Write-Debug ('Registration response: ' + $response)

        return [Microsoft.Graph.PowerShell.Models.MicrosoftGraphFido2AuthenticationMethod]::FromJsonString($response)
    }
    catch
    {
        throw $_
    }
}

function Register-OktaPasskey
{
    [CmdletBinding(DefaultParameterSetName = 'New')]
    [OutputType([DSInternals.Win32.WebAuthn.Okta.OktaFido2AuthenticationMethod])]
    param (
        [Parameter(Mandatory = $true, ParameterSetName = 'Existing')]
        [Parameter(Mandatory = $true, ParameterSetName = 'New')]
        [Alias('User')]
        [string] $UserId,

        [Parameter(Mandatory = $true, ParameterSetName = 'Existing', ValueFromPipeline = $true)]
        [DSInternals.Win32.WebAuthn.Okta.OktaWebauthnAttestationResponse]
        $Passkey,

        [Parameter(Mandatory = $false, ParameterSetName = 'New')]
        [ValidateScript({
            if ($_ -is [TimeSpan]) {
                $min = New-TimeSpan -Seconds 1
                $max = New-TimeSpan -Seconds 86400
                return $_ -ge $min -and $_ -le $max
            }
            else {
                throw "Parameter must be a TimeSpan object."
            }
        })]
        [Alias('Timeout')]
        [timespan] $ChallengeTimeout = (New-TimeSpan -Minutes 5)
    )
    try
    {
        if ($null -eq $Script:OktaToken)
        {
            throw 'Not connected to Okta, call Connnect-Okta to get started.'
        }

        $userId = $Passkey.UserId
        $factorId = $Passkey.FactorId
        [string] $registrationPath = "/api/v1/users/${userId}/factors/${factorId}/lifecycle/activate"

        Write-Debug ('Registration path: ' + $registrationPath)

        [string] $response = Invoke-OktaWebRequest -Path $registrationPath `
                    -Body $Passkey.ToString()

        Write-Debug ('Registration response: ' + $response)

        return [DSInternals.Win32.WebAuthn.Okta.OktaFido2AuthenticationMethod]::FromJsonString($response)
    }
    catch
    {
        throw $_
    }
}

<#
.SYNOPSIS
Registers a new passkey in Microsoft Entra ID, or Okta.
 
.PARAMETER UserId
The unique identifier of user. For Entra ID, this is the object id (guid) or UPN. For Okta, this is the unique identifier of Okta user.
 
.PARAMETER ChallengeTimeout
Overrides the timeout of the server-generated challenge returned in the request. For Entra ID, the default value is 5 minutes, with the accepted range being between 5 minutes and 30 days. For Okta, the default value is 300 second, with the accepted range being between 1 second and 1 day.
 
.PARAMETER Passkey
The passkey to be registered.
 
.PARAMETER DisplayName
Custom name given to the Entra ID registered passkey.
 
.EXAMPLE
PS \> Connect-MgGraph -Scopes 'UserAuthenticationMethod.ReadWrite.All'
PS \> Register-Passkey -UserId 'AdeleV@contoso.com' -DisplayName 'YubiKey 5 Nano'
 
.EXAMPLE
PS \> Connect-MgGraph -Scopes 'UserAuthenticationMethod.ReadWrite.All'
PS \> Register-Passkey -UserId 'AdeleV@contoso.com' -DisplayName 'YubiKey 5 Nano' -ChallengeTimeout (New-TimeSpan -Minutes 10)
 
.EXAMPLE
PS \> Connect-MgGraph -Scopes 'UserAuthenticationMethod.ReadWrite.All'
PS \> Get-PasskeyRegistrationOptions -UserId 'AdeleV@contoso.com' | New-Passkey -DisplayName 'YubiKey 5 Nano' | Register-Passkey -UserId 'AdeleV@contoso.com'
 
.EXAMPLE
PS \> Connect-Okta -Tenant example.okta.com -ClientId 0oakmj8hvxvtvCy3P5d7
PS \> Register-Passkey -UserId 00eDuihq64pgP1gVD0x7
 
.EXAMPLE
PS \> Connect-Okta -Tenant example.okta.com -ClientId 0oakmj8hvxvtvCy3P5d7
PS \> Get-PasskeyRegistrationOptions -UserId 00eDuihq64pgP1gVD0x7 | New-Passkey | Register-Passkey
 
.NOTES
More info for Entra ID at https://learn.microsoft.com/en-us/graph/api/authentication-post-fido2methods
More info for Okta at https://developer.okta.com/docs/api/openapi/okta-management/management/tag/UserFactor/#tag/UserFactor/operation/activateFactor
 
#>

function Register-Passkey
{
    [OutputType([DSInternals.Win32.WebAuthn.Okta.OktaFido2AuthenticationMethod], ParameterSetName = 'OktaNew')]
    [OutputType([Microsoft.Graph.PowerShell.Models.MicrosoftGraphFido2AuthenticationMethod], ParameterSetName = 'EntraIDNew')]
    param(
        [Parameter(Mandatory = $true, ParameterSetName = 'EntraIDNew')]
        [Parameter(Mandatory = $true, ParameterSetName = 'Existing')]
        [Parameter(Mandatory = $true, ParameterSetName = 'OktaNew')]
        [Alias('User')]
        [string] $UserId,

        [Parameter(Mandatory = $true, ParameterSetName = 'Existing', ValueFromPipeline = $true)]
        [DSInternals.Win32.WebAuthn.WebauthnAttestationResponse]
        $Passkey,

        [Parameter(Mandatory = $true, ParameterSetName = 'EntraIDNew')]
        [string] $DisplayName,

        [Parameter(Mandatory = $false, ParameterSetName = 'EntraIDNew')]
        [Parameter(Mandatory = $false, ParameterSetName = 'OktaNew')]
        [Alias('Timeout')]
        [timespan] $ChallengeTimeout = (New-TimeSpan -Minutes 5)
    )

    begin {
        if ($null -ne $Passkey -and $Passkey.GetType() -eq ([DSInternals.Win32.WebAuthn.EntraID.MicrosoftGraphWebauthnAttestationResponse]) -and [string]::IsNullOrEmpty($Passkey.displayName)) {
            throw "Parameter 'DisplayName' may not be null or empty."
        }
    }

    process {
        switch ($PSCmdlet.ParameterSetName) {
            'Existing' {
                switch ($Passkey.GetType())
                {
                    ([DSInternals.Win32.WebAuthn.EntraID.MicrosoftGraphWebauthnAttestationResponse])
                    {
                        return Register-EntraIDPasskey -UserId $UserId -Passkey $Passkey
                    }

                    ([DSInternals.Win32.WebAuthn.Okta.OktaWebauthnAttestationResponse])
                    {
                        if ($null -eq $Script:OktaToken) {
                            throw 'Not connected to Okta, call Connnect-Okta to get started.'
                        }
                        return Register-OktaPasskey -UserId $UserId -Passkey $Passkey
                    }
                }
            }
            default {
                [DSInternals.Win32.WebAuthn.WebauthnCredentialCreationOptions] $registrationOptions =
                Get-PasskeyRegistrationOptions -UserId $UserId -ChallengeTimeout $ChallengeTimeout

                [DSInternals.Win32.WebAuthn.WebauthnAttestationResponse] $passkey =
                New-Passkey -Options $registrationOptions -DisplayName $DisplayName

                # Recursive call with the 'Existing' parameter set
                return Register-Passkey -UserId $UserId -Passkey $passkey
            }
        }
    }
}

<#
.SYNOPSIS
Creates a new Microsoft Entra ID or Okta compatible passkey.
 
.PARAMETER Options
Options required to generate a Microsoft Entra ID or Okta compatible passkey.
 
.PARAMETER DisplayName
Custom name given to the Entra ID registered passkey.
 
.EXAMPLE
PS \> Connect-MgGraph -Scopes 'UserAuthenticationMethod.ReadWrite.All'
PS \> Get-PasskeyRegistrationOptions -UserId 'AdeleV@contoso.com' | New-Passkey -DisplayName 'YubiKey 5 Nano' | Register-Passkey -UserId 'AdeleV@contoso.com'
 
.EXAMPLE
PS \> New-Passkey -Options $options
 
.EXAMPLE
PS \> Connect-Okta -Tenant example.okta.com -ClientId 0oakmj8hvxvtvCy3P5d7
PS \> Get-PasskeyRegistrationOptions -UserId 00eDuihq64pgP1gVD0x7 | New-Passkey
 
#>

function New-Passkey
{
    [OutputType([DSInternals.Win32.WebAuthn.WebauthnAttestationResponse])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [DSInternals.Win32.WebAuthn.WebauthnCredentialCreationOptions]
        $Options,

        [Parameter(Mandatory = $false)]
        [ValidateLength(1, 30)]
        [string] $DisplayName
    )

    try {
        [DSInternals.Win32.WebAuthn.WebAuthnApi] $api = [DSInternals.Win32.WebAuthn.WebAuthnApi]::new()
        [DSInternals.Win32.WebAuthn.PublicKeyCredential] $credential = $api.AuthenticatorMakeCredential($Options.PublicKeyOptions)

        switch ($Options.GetType()) {
            ([DSInternals.Win32.WebAuthn.EntraID.MicrosoftGraphWebauthnCredentialCreationOptions]) {
                if ([string]::IsNullOrEmpty($DisplayName)) {
                    throw "Parameter 'DisplayName' may not be null or empty."
                }
                return [DSInternals.Win32.WebAuthn.EntraID.MicrosoftGraphWebauthnAttestationResponse]::new($credential, $DisplayName)
            }
            ([DSInternals.Win32.WebAuthn.Okta.OktaWebauthnCredentialCreationOptions]) {
                return [DSInternals.Win32.WebAuthn.Okta.OktaWebauthnAttestationResponse]::new($credential, $Options.PublicKeyOptions.User.Id, $Options.Id)
            }
        }
    }
    catch {
        [System.Management.Automation.ErrorRecord] $errorRecord = [System.Management.Automation.ErrorRecord]::new(
            $_,
            $_.Message,
            [System.Management.Automation.ErrorCategory]::InvalidArgument,
                $Options
            )
            $PSCmdlet.ThrowTerminatingError($errorRecord)
        }
}

<#
.SYNOPSIS
Tests a passkey by performing an authentication assertion.
 
.DESCRIPTION
Performs a WebAuthn authentication assertion to test a passkey credential. This triggers the authenticator to sign a challenge,
verifying that the passkey is working correctly.
 
.PARAMETER RelyingPartyId
The relying party identifier (e.g., 'login.microsoft.com').
 
.PARAMETER Challenge
The challenge bytes to be signed. Accepts either a byte array or a Base64Url encoded string.
If not provided, a random challenge will be generated.
 
.PARAMETER UserVerification
Specifies the user verification requirement.
 
.PARAMETER AuthenticatorAttachment
Specifies the authenticator attachment type.
 
.PARAMETER Timeout
The timeout for the operation.
 
.PARAMETER CredentialId
An optional credential ID to test a specific credential. Accepts either a byte array or a Base64Url encoded string.
 
.PARAMETER Hint
An optional hint to the client about which credential source to use (e.g., SecurityKey, ClientDevice, Hybrid).
 
.EXAMPLE
PS \> Test-Passkey -RelyingPartyId 'login.microsoft.com'
 
Tests any passkey registered for login.microsoft.com with a random challenge.
 
.EXAMPLE
PS \> $challenge = Get-PasskeyRandomChallenge -Length 32
PS \> Test-Passkey -RelyingPartyId 'login.microsoft.com' -Challenge $challenge
 
Tests any passkey registered for login.microsoft.com with a specific challenge.
 
.EXAMPLE
PS \> $credential = Get-PasskeyWindowsHello | Select-Object -First 1
PS \> Test-Passkey -RelyingPartyId $credential.RelyingPartyInformation.Id -CredentialId $credential.CredentialId
 
Tests a specific platform credential.
 
.EXAMPLE
PS \> Test-Passkey -RelyingPartyId 'login.microsoft.com' -Hint SecurityKey
 
Tests a passkey with a hint that a security key should be used.
 
#>

function Test-Passkey
{
    [OutputType([DSInternals.Win32.WebAuthn.PublicKeyCredential])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [Alias('RelyingParty')]
        [Alias('RpId')]
        [string] $RelyingPartyId,

        [Parameter(Mandatory = $false)]
        [object] $Challenge = (New-PasskeyRandomChallenge -Length 32),

        [Parameter(Mandatory = $false)]
        [DSInternals.Win32.WebAuthn.UserVerificationRequirement] $UserVerification = [DSInternals.Win32.WebAuthn.UserVerificationRequirement]::Preferred,

        [Parameter(Mandatory = $false)]
        [DSInternals.Win32.WebAuthn.AuthenticatorAttachment] $AuthenticatorAttachment = [DSInternals.Win32.WebAuthn.AuthenticatorAttachment]::Any,

        [Parameter(Mandatory = $false)]
        [timespan] $Timeout = (New-TimeSpan -Minutes 2),

        [Parameter(Mandatory = $false)]
        [object] $CredentialId,

        [Parameter(Mandatory = $false)]
        [Alias("AuthenticatorType", "CredentialHint", "PublicKeyCredentialHint")]
        [DSInternals.Win32.WebAuthn.PublicKeyCredentialHint] $Hint
    )

    try {
        # Convert Challenge parameter (accepts byte[] or Base64Url string)
        [byte[]] $challengeBytes = ConvertFrom-Base64UrlParameter -InputObject $Challenge

        # Convert CredentialId parameter (accepts byte[] or Base64Url string)
        [byte[]] $credentialIdBytes = ConvertFrom-Base64UrlParameter -InputObject $CredentialId

        # Build the AllowCredentials list if a specific CredentialId was provided
        [DSInternals.Win32.WebAuthn.PublicKeyCredentialDescriptor[]] $allowCredentials = @()

        if ($null -ne $credentialIdBytes -and $credentialIdBytes.Length -gt 0) {
            $allowCredentials += [DSInternals.Win32.WebAuthn.PublicKeyCredentialDescriptor]::new($credentialIdBytes)
        }

        # Convert TimeSpan to milliseconds, while capping to [1, 10 minutes]
        [int] $timeoutMilliseconds = [Math]::Max(1, [Math]::Min(10 * 60 * 1000, $Timeout.TotalMilliseconds))

        [DSInternals.Win32.WebAuthn.WebAuthnApi] $api = [DSInternals.Win32.WebAuthn.WebAuthnApi]::new()

        $response = $api.AuthenticatorGetAssertion(
            $RelyingPartyId,
            $challengeBytes,
            $UserVerification,
            $AuthenticatorAttachment,
            $timeoutMilliseconds,
            $allowCredentials,
            $null,  # extensions
            [DSInternals.Win32.WebAuthn.CredentialLargeBlobOperation]::None,
            $null,  # largeBlob
            $false, # browserInPrivateMode
            $null,  # linkedDevice
            $false, # autoFill
            $Hint,  # credentialHints
            $null,  # remoteWebOrigin
            $null,  # authenticatorId
            $null,  # publicKeyCredentialRequestOptionsJson
            [DSInternals.Win32.WebAuthn.WindowHandle]::ForegroundWindow
        )

        return $response
    }
    catch {
        [System.Management.Automation.ErrorRecord] $errorRecord = [System.Management.Automation.ErrorRecord]::new(
            $_.Exception,
            $_.Exception.Message,
            [System.Management.Automation.ErrorCategory]::InvalidOperation,
            $RelyingPartyId
        )

        $PSCmdlet.ThrowTerminatingError($errorRecord)
    }
}

<#
.SYNOPSIS
Gets the list of registered authenticator plugins from the Windows registry.
 
.DESCRIPTION
Retrieves information about third-party passkey providers (such as 1Password, Bitwarden, etc.) that are registered
as authenticator plugins in Windows. These plugins are registered under HKLM\SOFTWARE\Microsoft\FIDO.
 
.EXAMPLE
PS \> Get-PasskeyAuthenticatorPlugin
 
Lists all registered authenticator plugins.
 
.EXAMPLE
PS \> Get-PasskeyAuthenticatorPlugin | Where-Object Enabled -eq $true
 
Lists only enabled authenticator plugins.
 
.EXAMPLE
PS \> Get-PasskeyAuthenticatorPlugin | Select-Object -Property Name,PackageFamilyName,Enabled
 
Lists authenticator plugins with selected properties.
 
#>

function Get-PasskeyAuthenticatorPlugin
{
    [OutputType([DSInternals.Win32.WebAuthn.AuthenticatorPluginInformation])]
    [CmdletBinding()]
    param()

    try {
        [DSInternals.Win32.WebAuthn.AuthenticatorPluginInformation[]] $plugins = [DSInternals.Win32.WebAuthn.WebAuthnApi]::GetPluginAuthenticators()

        if ($null -eq $plugins -or $plugins.Count -eq 0) {
            Write-Verbose 'No authenticator plugins found.'
            return
        }

        foreach ($plugin in $plugins) {
            Write-Output $plugin
        }
    }
    catch {
        [System.Management.Automation.ErrorRecord] $errorRecord = [System.Management.Automation.ErrorRecord]::new(
            $_.Exception,
            $_.Exception.Message,
            [System.Management.Automation.ErrorCategory]::ReadError,
            $null
        )

        $PSCmdlet.ThrowTerminatingError($errorRecord)
    }
}

<#
.SYNOPSIS
Gets the list of available authenticators from the WebAuthn API.
 
.DESCRIPTION
Retrieves a list of authenticators available on the system using the Windows WebAuthn API.
This includes information about authenticator IDs, names, logos, and lock status.
 
.EXAMPLE
PS \> Get-PasskeyAuthenticator
 
Lists all available authenticators.
 
.EXAMPLE
PS \> Get-PasskeyAuthenticator | Where-Object { -not $PSItem.Locked }
 
Lists only unlocked authenticators.
 
.EXAMPLE
PS \> Get-PasskeyAuthenticator | Format-Table -Prperty AuthenticatorName,Locked
 
Lists authenticator names and lock status in a table.
 
#>

function Get-PasskeyAuthenticator
{
    [OutputType([DSInternals.Win32.WebAuthn.AuthenticatorDetails])]
    [CmdletBinding()]
    param()

    try {
        [DSInternals.Win32.WebAuthn.AuthenticatorDetails[]] $authenticators = [DSInternals.Win32.WebAuthn.WebAuthnApi]::GetAuthenticatorList()

        if ($null -eq $authenticators -or $authenticators.Count -eq 0) {
            Write-Verbose 'No authenticators found.'
            return
        }

        foreach ($authenticator in $authenticators) {
            Write-Output $authenticator
        }
    }
    catch {
        [System.Management.Automation.ErrorRecord] $errorRecord = [System.Management.Automation.ErrorRecord]::new(
            $_.Exception,
            $_.Exception.Message,
            [System.Management.Automation.ErrorCategory]::ReadError,
            $null
        )

        $PSCmdlet.ThrowTerminatingError($errorRecord)
    }
}

<#
.SYNOPSIS
Gets the list of platform credentials (passkeys) stored on the system.
 
.DESCRIPTION
Retrieves the list of credentials stored on platform authenticators (such as Windows Hello).
This includes information about credential IDs, relying party information, user information,
and whether credentials are removable or backed up.
 
.PARAMETER RelyingPartyId
Optional relying party ID to filter credentials. If not specified, all credentials are returned.
 
.EXAMPLE
PS \> Get-PasskeyWindowsHello
 
Lists all platform credentials.
 
.EXAMPLE
PS \> Get-PasskeyWindowsHello -RelyingPartyId 'login.microsoft.com'
 
Lists credentials for a specific relying party.
 
#>

function Get-PasskeyWindowsHello
{
    [OutputType([DSInternals.Win32.WebAuthn.CredentialDetails])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $false)]
        [Alias('RpId')]
        [string] $RelyingPartyId
    )

    try {
        [DSInternals.Win32.WebAuthn.CredentialDetails[]] $credentials = [DSInternals.Win32.WebAuthn.WebAuthnApi]::GetPlatformCredentialList($RelyingPartyId)

        if ($null -eq $credentials -or $credentials.Count -eq 0) {
            Write-Verbose 'No platform credentials found.'
            return
        }

        foreach ($credential in $credentials) {
            Write-Output $credential
        }
    }
    catch {
        [System.Management.Automation.ErrorRecord] $errorRecord = [System.Management.Automation.ErrorRecord]::new(
            $_.Exception,
            $_.Exception.Message,
            [System.Management.Automation.ErrorCategory]::ReadError,
            $RelyingPartyId
        )

        $PSCmdlet.ThrowTerminatingError($errorRecord)
    }
}

<#
.SYNOPSIS
Removes a platform credential (passkey) from the system.
 
.DESCRIPTION
Removes a Public Key Credential stored on a platform authenticator (such as Windows Hello).
This operation is irreversible - once deleted, the credential cannot be recovered.
 
.PARAMETER CredentialId
The ID of the credential to be removed. This can be obtained from Get-PasskeyWindowsHello.
Accepts either a byte array or a Base64Url encoded string.
 
.PARAMETER Credential
A CredentialDetails object obtained from Get-PasskeyWindowsHello.
 
.EXAMPLE
PS \> $cred = Get-PasskeyWindowsHello | Select-Object -First 1
PS \> Remove-PasskeyWindowsHello -CredentialId $cred.CredentialId
 
Removes a specific platform credential by ID.
 
.EXAMPLE
PS \> Get-PasskeyWindowsHello | Where-Object { $_.RelyingPartyInformation.Id -eq 'example.com' } | Remove-PasskeyWindowsHello
 
Removes all credentials for a specific relying party using pipeline input.
 
.EXAMPLE
PS \> Remove-PasskeyWindowsHello -CredentialId 'dGVzdC1jcmVkZW50aWFsLWlk'
 
Removes a credential using a Base64Url encoded credential ID.
 
.NOTES
Requires Windows with WebAuthn API version 4 or later (Windows 10 2004+).
This operation requires appropriate permissions and may trigger a Windows Security prompt.
 
#>

function Remove-PasskeyWindowsHello
{
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High', DefaultParameterSetName = 'ByCredentialId')]
    param(
        [Parameter(Mandatory = $true, ParameterSetName = 'ByCredentialId', Position = 0)]
        [ValidateNotNull()]
        [object] $CredentialId,

        [Parameter(Mandatory = $true, ParameterSetName = 'ByCredential', ValueFromPipeline = $true)]
        [ValidateNotNull()]
        [DSInternals.Win32.WebAuthn.CredentialDetails] $InputObject
    )
    try {
        # Get the credential ID from the appropriate parameter
        [byte[]] $credId = $null

        if ($PSCmdlet.ParameterSetName -eq 'ByCredential') {
            $credId = $InputObject.CredentialId
        }
        else {
            # Convert CredentialId from string (Base64Url) to byte[] if necessary
            $credId = ConvertFrom-Base64UrlParameter -InputObject $CredentialId
        }

        # Confirm the operation
        [string] $credentialIdString = [System.Buffers.Text.Base64Url]::EncodeToString($credId)
        if ($PSCmdlet.ShouldProcess($credentialIdString, 'Remove platform credential')) {
            [DSInternals.Win32.WebAuthn.WebAuthnApi]::DeletePlatformCredential($credId)
            Write-Verbose "Successfully removed credential $credentialIdString"
        }
    }
    catch {
        Write-Error -ErrorRecord ([System.Management.Automation.ErrorRecord]::new(
            $_.Exception,
            $_.Exception.Message,
            [System.Management.Automation.ErrorCategory]::WriteError,
            $credId
        ))
    }
}

<#
.SYNOPSIS
Retrieves the Microsoft Graph endpoint URL.
 
.NOTES
Dynamic URL retrieval is used to support Azure environments, like Azure Public, Azure Government, or Azure China.
 
#>

function Get-MgGraphEndpoint
{
    [CmdletBinding()]
    [OutputType([string])]
    param()

    try {
        [Microsoft.Graph.PowerShell.Authentication.AuthContext] $context = Get-MgContext -ErrorAction Stop

        if($null -ne $context) {
            return (Get-MgEnvironment -Name $context.Environment -ErrorAction Stop).GraphEndpoint
        }
    }
    catch {
        $errorRecord = New-Object Management.Automation.ErrorRecord(
            (New-Object Exception('Not connected to Microsoft Graph.')),
            'Not connected to Microsoft Graph.',
            [Management.Automation.ErrorCategory]::ConnectionError,
            $context
        )

        $PSCmdlet.ThrowTerminatingError($errorRecord)
    }
}

<#
.SYNOPSIS
Retrieves an access token to interact with Okta APIs.
 
.PARAMETER Tenant
The unique identifier of Okta tenant, like 'example.okta.com'.
 
.PARAMETER ClientId
The client id of the Okta application used to obtain an access token.
 
.PARAMETER Scopes
Scopes to request for the access token. Defaults to 'okta.users.manage'.
 
.PARAMETER JsonWebKey
The JSON Web Key used to authenticate to the Okta application, in order to obtain access token using the client credentials OAuth flow.
 
.EXAMPLE
PS \> Connect-Okta -Tenant example.okta.com -ClientId 0oakmj8hvxvtvCy3P5d7
 
.EXAMPLE
PS \> Connect-Okta -Tenant example.okta.com -ClientId 0oakmj8hvxvtvCy3P5d7 -Scopes @('okta.users.manage','okta.something.else')
 
.EXAMPLE
PS \> $jwk = '{"kty":"RSA","kid":"EE3QB0WvhuOwR9DuR6717OERKbDrBemrDKOK4Xvbf8c","d":"TmljZSB0cnkhICBCdXQgdGhpcyBpc...'
PS \> Connect-Okta -Tenant example.okta.com -ClientId 0oakmj8hvxvtvCy3P5d7 -Scopes @('okta.users.manage','okta.something.else') -JsonWebKey $jwk
 
#>


function Connect-Okta
{
    param(
        [Parameter(Mandatory = $true, ParameterSetName = 'ClientCredentials')]
        [Parameter(Mandatory = $true, ParameterSetName = 'AuthorizationCode')]
        [ValidatePattern('^[a-zA-Z0-9-]+\.okta(?:-emea|preview|\.mil)?\.com$')]
        [string] $Tenant,

        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ParameterSetName = 'ClientCredentials')]
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ParameterSetName = 'AuthorizationCode')]
        [ValidatePattern('^[A-Za-z0-9_-]{20}$')]
        [string]
        $ClientId,

        [Parameter(Mandatory = $false, ParameterSetName = 'ClientCredentials')]
        [Parameter(Mandatory = $false, ParameterSetName = 'AuthorizationCode')]
        [string[]] $Scopes = @('okta.users.manage'),

        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ParameterSetName = 'ClientCredentials')]
        [Alias('jwk')]
        [string]
        $JsonWebKey
    )

    try {
        $Script:OktaRevocationInfo = [PSCustomObject] @{
            ClientId = $ClientId
            RevocationToken = $null
        }
        switch ($PSCmdlet.ParameterSetName){
            'AuthorizationCode'
            {
                Write-Debug "No JWK found, assuming public client intended"
                $publicClientApp = [Microsoft.Identity.Client.PublicClientApplicationBuilder]::Create($ClientId).
                    WithExperimentalFeatures().
                    WithOidcAuthority("https://${tenant}/").
                    WithRedirectUri("http://localhost:8080/login/callback").
                    Build()

                $Script:OktaToken = $publicClientApp.AcquireTokenInteractive($Scopes).ExecuteAsync().GetAwaiter().GetResult()
                if ($null -ne $Script:OktaToken)
                {
                    Write-Host 'Okta access token successfully retrieved.'
                }
            }
            'ClientCredentials'
            {
                Write-Debug "JWK found, assuming confidential client intended"
                $jwk = [Microsoft.IdentityModel.Tokens.JsonWebKey]::new($JsonWebKey)
                $signingCredentials = [Microsoft.IdentityModel.Tokens.SigningCredentials]::new($jwk,'RS256')
                $issuer = $ClientId
                $audience = "https://${tenant}/oauth2/v1/token"
                $subject = [System.Security.Claims.ClaimsIdentity]::new()
                $subject.Claims.Add([System.Security.Claims.Claim]::new('sub',$ClientId))
                $notBefore = (Get-Date)
                $expires = (Get-Date).AddMinutes(60)
                $issuedAt = $notBefore

                $tokenHandler = [System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler]::new()
                $securityToken = $tokenHandler.CreateJwtSecurityToken($issuer, $audience, $subject, $notBefore, $expires, $issuedAt, $signingCredentials)
                $revocationToken = $tokenHandler.CreateJwtSecurityToken($issuer, "https://${tenant}/oauth2/v1/revoke", $subject, $notBefore, $expires, $issuedAt, $signingCredentials)
                $assertion = $tokenHandler.WriteToken($securityToken)
                $Script:OktaRevocationInfo.RevocationToken = $tokenHandler.WriteToken($revocationToken)
                $confidentialClientApp = [Microsoft.Identity.Client.ConfidentialClientApplicationBuilder]::Create($ClientId).
                    WithClientAssertion($assertion).
                    WithOidcAuthority("https://${tenant}").
                    Build()

                $Script:OktaToken = $confidentialClientApp.AcquireTokenForClient($Scopes).ExecuteAsync().GetAwaiter().GetResult()
                if ($null -ne $Script:OktaToken -and $null -ne $Script:OktaRevocationInfo.RevocationToken)
                {
                    Write-Host 'Okta access and revocation tokens successfully retrieved.'
                }
            }
        }
    }
    catch {
        throw
     }
}

<#
.SYNOPSIS
Revokes Okta access token.
 
.EXAMPLE
PS \> Disconnect-Okta
 
.DESCRIPTION
Revokes the Okta access token cached from the call to `Connect-Okta`.
 
.LINK
https://developer.okta.com/docs/guides/revoke-tokens/main/
 
#>

function Disconnect-Okta
{
    if ($null -ne $Script:OktaToken)
    {
        [string] $revocationPath = "/oauth2/v1/revoke"

        Write-Debug ('Revocation path: ' + $revocationPath)

        $body = @{
            client_id = $Script:OktaRevocationInfo.ClientId
            token = $Script:OktaToken.AccessToken
            token_type_hint = "access_token"
        }

        if ($null -ne $Script:OktaRevocationInfo.RevocationToken)
        {
            $body.Add('client_assertion_type',"urn:ietf:params:oauth:client-assertion-type:jwt-bearer")
            $body.Add('client_assertion',$Script:OktaRevocationInfo.RevocationToken)
        }

        Write-Debug ('Revocation payload: ' + ($body | ConvertTo-Json))

        [string] $response = Invoke-OktaWebRequest -Uri $revocationPath `
                    -ContentType "application/x-www-form-urlencoded" `
                    -Body $body

        $Script:OktaToken = $null
        $Script:OktaRevocationInfo.RevocationToken = $null

        if ($response.Length -eq 0 -and $null -eq $Script:OktaToken)
        {
            Write-Host 'Okta access token successfully revoked.'
        }
    }
}

<#
.SYNOPSIS
Generates a random challenge to be used by WebAuthn.
 
.PARAMETER Length
The length of the challenge in bytes.
 
.EXAMPLE
PS \> New-PasskeyRandomChallenge -Length 32
Generates a random 32-byte challenge.
 
#>

function New-PasskeyRandomChallenge
{
    [CmdletBinding()]
    [OutputType([byte[]])]
    param(
        [Parameter(Mandatory = $false)]
        [ValidateRange(16, 64)]
        [int] $Length = 32
    )

    [byte[]] $challenge = [byte[]]::new($Length)
    $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create()
    try {
        $rng.GetBytes($challenge)
        return $challenge
    }
    finally {
        $rng.Dispose()
    }
}

<#
.SYNOPSIS
Converts a Base64Url encoded string or byte array to a byte array.
 
.PARAMETER InputObject
The input object to convert. Can be a Base64Url encoded string or a byte array.
 
.NOTES
This is a helper function used internally for parameter conversion.
#>

function ConvertFrom-Base64UrlParameter
{
    [CmdletBinding()]
    [OutputType([byte[]])]
    param(
        [Parameter(Mandatory = $false, Position = 0)]
        [object] $InputObject
    )

    if ($null -eq $InputObject) {
        return $null
    } elseif ($InputObject -is [string]) {
        # Convert from Base64Url string to byte array
        return [DSInternals.Win32.WebAuthn.Base64UrlConverter]::FromBase64UrlString($InputObject)
    } elseif ($null -ne ($InputObject -as [byte[]])) {
        # Nothing to convert
        return $InputObject
    } else {
        throw [System.ArgumentException]::new("The value must be a byte array or a Base64Url encoded string.")
    }
}

New-Alias -Name Register-MgUserAuthenticationFido2Method -Value Register-Passkey

Export-ModuleMember -Function 'Get-PasskeyRegistrationOptions','New-Passkey','Register-Passkey','Test-Passkey','Connect-Okta','Disconnect-Okta','Invoke-OktaWebRequest','Get-PasskeyAuthenticatorPlugin','Get-PasskeyAuthenticator','Get-PasskeyWindowsHello','Remove-PasskeyWindowsHello', 'New-PasskeyRandomChallenge' `
                    -Alias 'Register-MgUserAuthenticationFido2Method'

# SIG # Begin signature block
# MIIvkwYJKoZIhvcNAQcCoIIvhDCCL4ACAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCAWVWrhVmQjxoZ3
# bcx/Tiqo9RHpF3I+qii/3zoHVvC98qCCE6UwggWQMIIDeKADAgECAhAFmxtXno4h
# MuI5B72nd3VcMA0GCSqGSIb3DQEBDAUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQK
# EwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNV
# BAMTGERpZ2lDZXJ0IFRydXN0ZWQgUm9vdCBHNDAeFw0xMzA4MDExMjAwMDBaFw0z
# ODAxMTUxMjAwMDBaMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJ
# bmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0
# IFRydXN0ZWQgUm9vdCBHNDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIB
# AL/mkHNo3rvkXUo8MCIwaTPswqclLskhPfKK2FnC4SmnPVirdprNrnsbhA3EMB/z
# G6Q4FutWxpdtHauyefLKEdLkX9YFPFIPUh/GnhWlfr6fqVcWWVVyr2iTcMKyunWZ
# anMylNEQRBAu34LzB4TmdDttceItDBvuINXJIB1jKS3O7F5OyJP4IWGbNOsFxl7s
# Wxq868nPzaw0QF+xembud8hIqGZXV59UWI4MK7dPpzDZVu7Ke13jrclPXuU15zHL
# 2pNe3I6PgNq2kZhAkHnDeMe2scS1ahg4AxCN2NQ3pC4FfYj1gj4QkXCrVYJBMtfb
# BHMqbpEBfCFM1LyuGwN1XXhm2ToxRJozQL8I11pJpMLmqaBn3aQnvKFPObURWBf3
# JFxGj2T3wWmIdph2PVldQnaHiZdpekjw4KISG2aadMreSx7nDmOu5tTvkpI6nj3c
# AORFJYm2mkQZK37AlLTSYW3rM9nF30sEAMx9HJXDj/chsrIRt7t/8tWMcCxBYKqx
# YxhElRp2Yn72gLD76GSmM9GJB+G9t+ZDpBi4pncB4Q+UDCEdslQpJYls5Q5SUUd0
# viastkF13nqsX40/ybzTQRESW+UQUOsxxcpyFiIJ33xMdT9j7CFfxCBRa2+xq4aL
# T8LWRV+dIPyhHsXAj6KxfgommfXkaS+YHS312amyHeUbAgMBAAGjQjBAMA8GA1Ud
# EwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBTs1+OC0nFdZEzf
# Lmc/57qYrhwPTzANBgkqhkiG9w0BAQwFAAOCAgEAu2HZfalsvhfEkRvDoaIAjeNk
# aA9Wz3eucPn9mkqZucl4XAwMX+TmFClWCzZJXURj4K2clhhmGyMNPXnpbWvWVPjS
# PMFDQK4dUPVS/JA7u5iZaWvHwaeoaKQn3J35J64whbn2Z006Po9ZOSJTROvIXQPK
# 7VB6fWIhCoDIc2bRoAVgX+iltKevqPdtNZx8WorWojiZ83iL9E3SIAveBO6Mm0eB
# cg3AFDLvMFkuruBx8lbkapdvklBtlo1oepqyNhR6BvIkuQkRUNcIsbiJeoQjYUIp
# 5aPNoiBB19GcZNnqJqGLFNdMGbJQQXE9P01wI4YMStyB0swylIQNCAmXHE/A7msg
# dDDS4Dk0EIUhFQEI6FUy3nFJ2SgXUE3mvk3RdazQyvtBuEOlqtPDBURPLDab4vri
# RbgjU2wGb2dVf0a1TD9uKFp5JtKkqGKX0h7i7UqLvBv9R0oN32dmfrJbQdA75PQ7
# 9ARj6e/CVABRoIoqyc54zNXqhwQYs86vSYiv85KZtrPmYQ/ShQDnUBrkG5WdGaG5
# nLGbsQAe79APT0JsyQq87kP6OnGlyE0mpTX9iV28hWIdMtKgK1TtmlfB2/oQzxm3
# i0objwG2J5VT6LaJbVu8aNQj6ItRolb58KaAoNYes7wPD1N1KarqE3fk3oyBIa0H
# EEcRrYc9B9F1vM/zZn4wggawMIIEmKADAgECAhAIrUCyYNKcTJ9ezam9k67ZMA0G
# CSqGSIb3DQEBDAUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJ
# bmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0
# IFRydXN0ZWQgUm9vdCBHNDAeFw0yMTA0MjkwMDAwMDBaFw0zNjA0MjgyMzU5NTla
# MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UE
# AxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBDb2RlIFNpZ25pbmcgUlNBNDA5NiBTSEEz
# ODQgMjAyMSBDQTEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDVtC9C
# 0CiteLdd1TlZG7GIQvUzjOs9gZdwxbvEhSYwn6SOaNhc9es0JAfhS0/TeEP0F9ce
# 2vnS1WcaUk8OoVf8iJnBkcyBAz5NcCRks43iCH00fUyAVxJrQ5qZ8sU7H/Lvy0da
# E6ZMswEgJfMQ04uy+wjwiuCdCcBlp/qYgEk1hz1RGeiQIXhFLqGfLOEYwhrMxe6T
# SXBCMo/7xuoc82VokaJNTIIRSFJo3hC9FFdd6BgTZcV/sk+FLEikVoQ11vkunKoA
# FdE3/hoGlMJ8yOobMubKwvSnowMOdKWvObarYBLj6Na59zHh3K3kGKDYwSNHR7Oh
# D26jq22YBoMbt2pnLdK9RBqSEIGPsDsJ18ebMlrC/2pgVItJwZPt4bRc4G/rJvmM
# 1bL5OBDm6s6R9b7T+2+TYTRcvJNFKIM2KmYoX7BzzosmJQayg9Rc9hUZTO1i4F4z
# 8ujo7AqnsAMrkbI2eb73rQgedaZlzLvjSFDzd5Ea/ttQokbIYViY9XwCFjyDKK05
# huzUtw1T0PhH5nUwjewwk3YUpltLXXRhTT8SkXbev1jLchApQfDVxW0mdmgRQRNY
# mtwmKwH0iU1Z23jPgUo+QEdfyYFQc4UQIyFZYIpkVMHMIRroOBl8ZhzNeDhFMJlP
# /2NPTLuqDQhTQXxYPUez+rbsjDIJAsxsPAxWEQIDAQABo4IBWTCCAVUwEgYDVR0T
# AQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUaDfg67Y7+F8Rhvv+YXsIiGX0TkIwHwYD
# VR0jBBgwFoAU7NfjgtJxXWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMG
# A1UdJQQMMAoGCCsGAQUFBwMDMHcGCCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYY
# aHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2Fj
# ZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNV
# HR8EPDA6MDigNqA0hjJodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRU
# cnVzdGVkUm9vdEc0LmNybDAcBgNVHSAEFTATMAcGBWeBDAEDMAgGBmeBDAEEATAN
# BgkqhkiG9w0BAQwFAAOCAgEAOiNEPY0Idu6PvDqZ01bgAhql+Eg08yy25nRm95Ry
# sQDKr2wwJxMSnpBEn0v9nqN8JtU3vDpdSG2V1T9J9Ce7FoFFUP2cvbaF4HZ+N3HL
# IvdaqpDP9ZNq4+sg0dVQeYiaiorBtr2hSBh+3NiAGhEZGM1hmYFW9snjdufE5Btf
# Q/g+lP92OT2e1JnPSt0o618moZVYSNUa/tcnP/2Q0XaG3RywYFzzDaju4ImhvTnh
# OE7abrs2nfvlIVNaw8rpavGiPttDuDPITzgUkpn13c5UbdldAhQfQDN8A+KVssIh
# dXNSy0bYxDQcoqVLjc1vdjcshT8azibpGL6QB7BDf5WIIIJw8MzK7/0pNVwfiThV
# 9zeKiwmhywvpMRr/LhlcOXHhvpynCgbWJme3kuZOX956rEnPLqR0kq3bPKSchh/j
# wVYbKyP/j7XqiHtwa+aguv06P0WmxOgWkVKLQcBIhEuWTatEQOON8BUozu3xGFYH
# Ki8QxAwIZDwzj64ojDzLj4gLDb879M4ee47vtevLt/B3E+bnKD+sEq6lLyJsQfmC
# XBVmzGwOysWGw/YmMwwHS6DTBwJqakAwSEs0qFEgu60bhQjiWQ1tygVQK+pKHJ6l
# /aCnHwZ05/LWUpD9r4VIIflXO7ScA+2GRfS0YW6/aOImYIbqyK+p/pQd52MbOoZW
# eE4wggdZMIIFQaADAgECAhANqK80cCX+jsAYDGB/BSyeMA0GCSqGSIb3DQEBCwUA
# MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UE
# AxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBDb2RlIFNpZ25pbmcgUlNBNDA5NiBTSEEz
# ODQgMjAyMSBDQTEwHhcNMjYwMTA1MDAwMDAwWhcNMjkwMTA0MjM1OTU5WjBhMQsw
# CQYDVQQGEwJDWjEOMAwGA1UEBxMFUHJhaGExIDAeBgNVBAoTF01nci4gTWljaGFl
# bCBHcmFmbmV0dGVyMSAwHgYDVQQDExdNZ3IuIE1pY2hhZWwgR3JhZm5ldHRlcjCC
# AiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAJrpm0HI6ak/Uj3iqs64nuUT
# WZAkxHVeJKwnd3XR0Ge/oE1STGkmydGCZ8VR9jMcIHK+kYteESboRguDVk7BTxEv
# SKTEDG0lt1KLr0KUuWI2EUykxtcpR7wOjZ/VBMPeiwPQqccY/ZnwF0H6P43MJSIf
# WYVhTLb5R8ueHkjxRGkMPgIcuV4W3TqIEQKvO9NEMC0Tv2nPXPab5u20QdixQaep
# 05DEBL1cd9L0NYvbgUi8JQ63I/P7fqrlC6zXZb61wDDTNxwtdFIlR7jvAFqhY1bX
# Qfb8bmL4KXH9Sv3hHIDUUfitghKh4RoQWitVTpf+uzLPG0Dr1UH8RbWIYQRXCZhr
# 4RJzmt3+i0f+IZSJkRlXBVhn9GeQTk3yaUwLFyz5evTYh5IBaMNA+1BChpswlB32
# PoOjg4eJr3/nArLKN3UZCy9PQ0F+y0J+T/UkGCyv0Ws+zxiZlR2A6ekjGnP5x+8g
# n2S3Hf0rMCfCgudgT12S8tXPSdI88TzsihLj8iJT9ljgS0bJSjNykYaBK8BXYYJ3
# PvBn7px9G7b7WOPieyp7rTDkmyWBG5+vVIOFjJUgMIMJChsc5f11iHNcdo7FsVwS
# H8MftQ0rUSktj1xK4/p9zwhQpI+eXE0l9YIP08JmVOLtgu2PzXcCT/El+/8+XUw/
# X63cEIgp49URPwtmU0UhAgMBAAGjggIDMIIB/zAfBgNVHSMEGDAWgBRoN+Drtjv4
# XxGG+/5hewiIZfROQjAdBgNVHQ4EFgQUUAbobwgAP24SnIkacVlc4i61qWgwPgYD
# VR0gBDcwNTAzBgZngQwBBAEwKTAnBggrBgEFBQcCARYbaHR0cDovL3d3dy5kaWdp
# Y2VydC5jb20vQ1BTMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcD
# AzCBtQYDVR0fBIGtMIGqMFOgUaBPhk1odHRwOi8vY3JsMy5kaWdpY2VydC5jb20v
# RGlnaUNlcnRUcnVzdGVkRzRDb2RlU2lnbmluZ1JTQTQwOTZTSEEzODQyMDIxQ0Ex
# LmNybDBToFGgT4ZNaHR0cDovL2NybDQuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1
# c3RlZEc0Q29kZVNpZ25pbmdSU0E0MDk2U0hBMzg0MjAyMUNBMS5jcmwwgZQGCCsG
# AQUFBwEBBIGHMIGEMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5j
# b20wXAYIKwYBBQUHMAKGUGh0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdp
# Q2VydFRydXN0ZWRHNENvZGVTaWduaW5nUlNBNDA5NlNIQTM4NDIwMjFDQTEuY3J0
# MAkGA1UdEwQCMAAwDQYJKoZIhvcNAQELBQADggIBAI/LhB69MYV+zYchS+lShCm1
# H7P/sHkhIctl5ClI6fFpsqc7uAZlH2JjUNYDkkIUvoGk5teyJdLXrycGQIVwH26i
# DJm6VDoOOalOjOHh1Om/dP3Cl3XPUM1KRyj39DNnLvPB/5VxIbGoz9yoaTvHAQnu
# B5THrdo0nWyPtDgTF2ItJFVky4Uh9cE/ggCifsDUFMwAVKDZ1YvwNspO/ajwdLTM
# l121TKX3x5eA8KL4bO3LVvE5GQIqQx3PVqTn4jwFlxjDaBh/RE6yo2UwPwIIfTfj
# XTHuziTtFgvVhHKFS55Cxt4h3nCrPEnCfSdG/oNOF8TgLWA873V4T1Qqkdi8aXuA
# BWu5GhemjorxFxqeSLrskfTnGO2a5WQ6hmqmz8jU/Ulau8MvwRN8ZSyDJBYc8iuF
# VL1+abP12QfM/O8VkqYHbeslkC0qndAbJlT6icTd4CPeBXTwXtGY0hixRxcAkq3y
# gKSs7EeGGY6Ytc1pANGDX6PBjVxaf0XfwhkwG35iAA3Ix1muNoB4Nt2HySbkVGDh
# ZT5seWy/D2Z44NTAMcGh7WcBGp0RJ/372LGP58ZgSPp/0EtlhaCX1UoQo7Nv6XsY
# 1qu0RC+G/lOTl1C9o3eya4bf5y0GZlrNgW3U4nxi4UcRwlpmRaH1hbuz5BQoImaz
# mn6pOiSVBvJE8R1OA+9UMYIbRDCCG0ACAQEwfTBpMQswCQYDVQQGEwJVUzEXMBUG
# A1UEChMORGlnaUNlcnQsIEluYy4xQTA/BgNVBAMTOERpZ2lDZXJ0IFRydXN0ZWQg
# RzQgQ29kZSBTaWduaW5nIFJTQTQwOTYgU0hBMzg0IDIwMjEgQ0ExAhANqK80cCX+
# jsAYDGB/BSyeMA0GCWCGSAFlAwQCAQUAoIGEMBgGCisGAQQBgjcCAQwxCjAIoAKA
# AKECgAAwGQYJKoZIhvcNAQkDMQwGCisGAQQBgjcCAQQwHAYKKwYBBAGCNwIBCzEO
# MAwGCisGAQQBgjcCARUwLwYJKoZIhvcNAQkEMSIEIPhcObz3bAe07u76x/pDjJzC
# LuvYk5Lpaw3SnnEyrqoBMA0GCSqGSIb3DQEBAQUABIICAGtwvX1458I02e6itt54
# pfqft8q+pibXG/cLXe59YWFLyEnOvJRfOO0IXSUGZ20p8UcM0pa5kEuzcdJg21qS
# Cm26n9eGlsoy6l+FJJd+9ybUYObSY6Do8Gc6U3Pa0hwAw+Z+2DnlSdB7Wg7lpQ3M
# VlJDoAh5tvI8boVex+D2lXvKaZGtbBZ1QZ12/NVNLlNhkP9oAN4RcRHxTwi5Rpzv
# rcUrm3RXFPZFomXfZtMqATlhRt/eSkDVFyisaQbdEcvkq+GrtQ+OG0AEl51BZQoC
# bVoOJFwXqAd65wbxCvEbiHDTKGsjAG2v2vV/5N8+6fIcKLO4QfkWv5qS0lmzmHGv
# Ht6hlgZGZB2UVLRm+2jkh0BOcZpDw4eeS5Ev3FD99u55nXZCA63UVeN6TCEyPbJ7
# ugO2W1dj7PqzH3UKQCdfT0nb97V5XibtCC1e993X9PhgPvTTnpTEgevo4w2WgI96
# LVsONtMl9wX6yoyhziflYCLHn1idD4aOUNln7ruRlA227J3EE6YIEwy70idQZ1yT
# xgeqgRTq1mHkqLfTfVsAmxfDkeBaP/AAfP659cRz7Sr/mbhHU5tkC3E1QwTAxM1T
# FFrevcCrUchJY+5wKnqnMzRzDRsXq2yFHz4W0kmQ+HNpX1GvLMrYeq5w/pl1O8uI
# XTmvkdEcb4Y/xobjysG6WVSEoYIYETCCGA0GCisGAQQBgjcDAwExghf9MIIX+QYJ
# KoZIhvcNAQcCoIIX6jCCF+YCAQMxDzANBglghkgBZQMEAgEFADCCAWIGCyqGSIb3
# DQEJEAEEoIIBUQSCAU0wggFJAgEBBgorBgEEAYRZCgMBMDEwDQYJYIZIAWUDBAIB
# BQAEIE9/RtEFwZJYZ9MvcHhm9jb6LCcnYvRCmPBpyZImn7PGAgZpwmaWceEYEzIw
# MjYwNDA1MTUyMzE4LjQ3OVowBIACAfSggeGkgd4wgdsxCzAJBgNVBAYTAlVTMRMw
# EQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVN
# aWNyb3NvZnQgQ29ycG9yYXRpb24xJTAjBgNVBAsTHE1pY3Jvc29mdCBBbWVyaWNh
# IE9wZXJhdGlvbnMxJzAlBgNVBAsTHm5TaGllbGQgVFNTIEVTTjpBNTAwLTA1RTAt
# RDk0NzE1MDMGA1UEAxMsTWljcm9zb2Z0IFB1YmxpYyBSU0EgVGltZSBTdGFtcGlu
# ZyBBdXRob3JpdHmggg8hMIIHgjCCBWqgAwIBAgITMwAAAAXlzw//Zi7JhwAAAAAA
# BTANBgkqhkiG9w0BAQwFADB3MQswCQYDVQQGEwJVUzEeMBwGA1UEChMVTWljcm9z
# b2Z0IENvcnBvcmF0aW9uMUgwRgYDVQQDEz9NaWNyb3NvZnQgSWRlbnRpdHkgVmVy
# aWZpY2F0aW9uIFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5IDIwMjAwHhcNMjAx
# MTE5MjAzMjMxWhcNMzUxMTE5MjA0MjMxWjBhMQswCQYDVQQGEwJVUzEeMBwGA1UE
# ChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMTIwMAYDVQQDEylNaWNyb3NvZnQgUHVi
# bGljIFJTQSBUaW1lc3RhbXBpbmcgQ0EgMjAyMDCCAiIwDQYJKoZIhvcNAQEBBQAD
# ggIPADCCAgoCggIBAJ5851Jj/eDFnwV9Y7UGIqMcHtfnlzPREwW9ZUZHd5HBXXBv
# f7KrQ5cMSqFSHGqg2/qJhYqOQxwuEQXG8kB41wsDJP5d0zmLYKAY8Zxv3lYkuLDs
# fMuIEqvGYOPURAH+Ybl4SJEESnt0MbPEoKdNihwM5xGv0rGofJ1qOYSTNcc55EbB
# T7uq3wx3mXhtVmtcCEr5ZKTkKKE1CxZvNPWdGWJUPC6e4uRfWHIhZcgCsJ+sozf5
# EeH5KrlFnxpjKKTavwfFP6XaGZGWUG8TZaiTogRoAlqcevbiqioUz1Yt4FRK53P6
# ovnUfANjIgM9JDdJ4e0qiDRm5sOTiEQtBLGd9Vhd1MadxoGcHrRCsS5rO9yhv2fj
# JHrmlQ0EIXmp4DhDBieKUGR+eZ4CNE3ctW4uvSDQVeSp9h1SaPV8UWEfyTxgGjOs
# RpeexIveR1MPTVf7gt8hY64XNPO6iyUGsEgt8c2PxF87E+CO7A28TpjNq5eLiiun
# hKbq0XbjkNoU5JhtYUrlmAbpxRjb9tSreDdtACpm3rkpxp7AQndnI0Shu/fk1/rE
# 3oWsDqMX3jjv40e8KN5YsJBnczyWB4JyeeFMW3JBfdeAKhzohFe8U5w9WuvcP1E8
# cIxLoKSDzCCBOu0hWdjzKNu8Y5SwB1lt5dQhABYyzR3dxEO/T1K/BVF3rV69AgMB
# AAGjggIbMIICFzAOBgNVHQ8BAf8EBAMCAYYwEAYJKwYBBAGCNxUBBAMCAQAwHQYD
# VR0OBBYEFGtpKDo1L0hjQM972K9J6T7ZPdshMFQGA1UdIARNMEswSQYEVR0gADBB
# MD8GCCsGAQUFBwIBFjNodHRwOi8vd3d3Lm1pY3Jvc29mdC5jb20vcGtpb3BzL0Rv
# Y3MvUmVwb3NpdG9yeS5odG0wEwYDVR0lBAwwCgYIKwYBBQUHAwgwGQYJKwYBBAGC
# NxQCBAweCgBTAHUAYgBDAEEwDwYDVR0TAQH/BAUwAwEB/zAfBgNVHSMEGDAWgBTI
# ftJqhSobyhmYBAcnz1AQT2ioojCBhAYDVR0fBH0wezB5oHegdYZzaHR0cDovL3d3
# dy5taWNyb3NvZnQuY29tL3BraW9wcy9jcmwvTWljcm9zb2Z0JTIwSWRlbnRpdHkl
# MjBWZXJpZmljYXRpb24lMjBSb290JTIwQ2VydGlmaWNhdGUlMjBBdXRob3JpdHkl
# MjAyMDIwLmNybDCBlAYIKwYBBQUHAQEEgYcwgYQwgYEGCCsGAQUFBzAChnVodHRw
# Oi8vd3d3Lm1pY3Jvc29mdC5jb20vcGtpb3BzL2NlcnRzL01pY3Jvc29mdCUyMElk
# ZW50aXR5JTIwVmVyaWZpY2F0aW9uJTIwUm9vdCUyMENlcnRpZmljYXRlJTIwQXV0
# aG9yaXR5JTIwMjAyMC5jcnQwDQYJKoZIhvcNAQEMBQADggIBAF+Idsd+bbVaFXXn
# THho+k7h2ESZJRWluLE0Oa/pO+4ge/XEizXvhs0Y7+KVYyb4nHlugBesnFqBGEdC
# 2IWmtKMyS1OWIviwpnK3aL5JedwzbeBF7POyg6IGG/XhhJ3UqWeWTO+Czb1c2NP5
# zyEh89F72u9UIw+IfvM9lzDmc2O2END7MPnrcjWdQnrLn1Ntday7JSyrDvBdmgbN
# nCKNZPmhzoa8PccOiQljjTW6GePe5sGFuRHzdFt8y+bN2neF7Zu8hTO1I64XNGqs
# t8S+w+RUdie8fXC1jKu3m9KGIqF4aldrYBamyh3g4nJPj/LR2CBaLyD+2BuGZCVm
# oNR/dSpRCxlot0i79dKOChmoONqbMI8m04uLaEHAv4qwKHQ1vBzbV/nG89LDKbRS
# SvijmwJwxRxLLpMQ/u4xXxFfR4f/gksSkbJp7oqLwliDm/h+w0aJ/U5ccnYhYb7v
# PKNMN+SZDWycU5ODIRfyoGl59BsXR/HpRGtiJquOYGmvA/pk5vC1lcnbeMrcWD/2
# 6ozePQ/TWfNXKBOmkFpvPE8CH+EeGGWzqTCjdAsno2jzTeNSxlx3glDGJgcdz5D/
# AAxw9Sdgq/+rY7jjgs7X6fqPTXPmaCAJKVHAP19oEjJIBwD1LyHbaEgBxFCogYSO
# iUIr0Xqcr1nJfiWG2GwYe6ZoAF1bMIIHlzCCBX+gAwIBAgITMwAAAFZ+j51YCI7p
# YAAAAAAAVjANBgkqhkiG9w0BAQwFADBhMQswCQYDVQQGEwJVUzEeMBwGA1UEChMV
# TWljcm9zb2Z0IENvcnBvcmF0aW9uMTIwMAYDVQQDEylNaWNyb3NvZnQgUHVibGlj
# IFJTQSBUaW1lc3RhbXBpbmcgQ0EgMjAyMDAeFw0yNTEwMjMyMDQ2NTFaFw0yNjEw
# MjIyMDQ2NTFaMIHbMQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQ
# MA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9u
# MSUwIwYDVQQLExxNaWNyb3NvZnQgQW1lcmljYSBPcGVyYXRpb25zMScwJQYDVQQL
# Ex5uU2hpZWxkIFRTUyBFU046QTUwMC0wNUUwLUQ5NDcxNTAzBgNVBAMTLE1pY3Jv
# c29mdCBQdWJsaWMgUlNBIFRpbWUgU3RhbXBpbmcgQXV0aG9yaXR5MIICIjANBgkq
# hkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAtKWfm/ul027/d8Rlb8Mn/g0QUvvLqY2V
# sy3tI8U2tFSspTZomZOD3BHT8LkR+RrhMJgb1VjAKFNysaK9cLSXifPGSIBrPCgs
# 9P4y24lrJEmrV6Q5z4BmqMhIPrZhEvZnWpCS4HO7jYSei/nxmC7/1Er+l5Lg3PmS
# xb8d2IVcARxSw1B4mxB6XI0nkel9wa1dYb2wfGpofraFmxZOxT9eNht4LH0RBSVu
# eba6ZNpjS/0gtfm7qiIiyP6p6PRzTTbMnVqsHnV/d/rW0zHx+Q+QNZ5wUqKmTZJB
# 9hU853+2pX5rDfK32uNY9/WBOAmzbqgpEdQkbiMavUMyUDShmycIvgHdQnS207sT
# j8M+kJL3tOdahPuPqMwsaCCgdfwwQx0O9TKe7FSvbAEYs1AnldCl/KHGZCOVvUNq
# jyL10JLe0/+GD9/ynqXGWFpXOjaunvZ/cKROhjN4M5e6xx0b2miqcPii4/ii2Zhe
# KallJET7CKlpFShs3wyg6F/fojQxQvPnbWD4Nyx6lhjWjwmoLcx6w1FSCtavLCly
# 33BLRSlTU4qKUxaa8d7YN7Eqpn9XO0SY0umOvKFXrWH7rxl+9iaicitdnTTksAnR
# jvekdKT3lg7lRMfmfZU8vXNiN0UYJzT9EjqjRm0uN/h0oXxPhNfPYqeFbyPXGGxz
# aYUz6zx3qTcCAwEAAaOCAcswggHHMB0GA1UdDgQWBBS+tjPyu6tZ/h5GsyLvyz1H
# +FNIWjAfBgNVHSMEGDAWgBRraSg6NS9IY0DPe9ivSek+2T3bITBsBgNVHR8EZTBj
# MGGgX6BdhltodHRwOi8vd3d3Lm1pY3Jvc29mdC5jb20vcGtpb3BzL2NybC9NaWNy
# b3NvZnQlMjBQdWJsaWMlMjBSU0ElMjBUaW1lc3RhbXBpbmclMjBDQSUyMDIwMjAu
# Y3JsMHkGCCsGAQUFBwEBBG0wazBpBggrBgEFBQcwAoZdaHR0cDovL3d3dy5taWNy
# b3NvZnQuY29tL3BraW9wcy9jZXJ0cy9NaWNyb3NvZnQlMjBQdWJsaWMlMjBSU0El
# MjBUaW1lc3RhbXBpbmclMjBDQSUyMDIwMjAuY3J0MAwGA1UdEwEB/wQCMAAwFgYD
# VR0lAQH/BAwwCgYIKwYBBQUHAwgwDgYDVR0PAQH/BAQDAgeAMGYGA1UdIARfMF0w
# UQYMKwYBBAGCN0yDfQEBMEEwPwYIKwYBBQUHAgEWM2h0dHA6Ly93d3cubWljcm9z
# b2Z0LmNvbS9wa2lvcHMvRG9jcy9SZXBvc2l0b3J5Lmh0bTAIBgZngQwBBAIwDQYJ
# KoZIhvcNAQEMBQADggIBAA4DqAXEsO26j/La7Fgn/Qifit8xuZekqZ57+Ye+sH/h
# RTbEEjGYrZgsqwR/lUUfKCFpbZF8msaZPQJOR4YYUEU8XyjLrn8Y1jCSmoxh9l7t
# WiSoc/JFBw356JAmzGGxeBA2EWSxRuTr1AuZe6nYaN8/wtFkiHcs8gMadxXBs6Dx
# Vhyu5YnhLPQkfumKm3lFftwE7pieV7f1lskmlgsC6AeSGCzGPZUgCvcH5Tv/Qe9z
# 7bIImSD3SuzhOIwaP+eKQTYf67TifyJKkWQSdGfTA6Kcu41k8LB6oPK+MLk1jbxx
# K5wPqLSL62xjK04SBXHEJSEnsFt0zxWkxP/lgej1DxqUnmrYEdkxvzKSHIAqFWSZ
# ul/5hI+vJxvFPhsNQBEk4cSulDkJQpcdVi/gmf/mHFOYhDBjsa15s4L+2sBil3XV
# /T8RiR66Q8xYvTLRWxd2dVsrOoCwnsU4WIeiC0JinCv1WLHEh7Qyzr9RSr4kKJLW
# dpNYLhgjkojTmEkAjFO774t3xB7enbvIF0GOsV19xnCUzq9EGKyt0gMuaphKlNjJ
# +aTpjWMZDGo+GOKsnp93Hmftml0Syp3F9+M3y+y6WJGUZoIZJq227jDjjEndtpUr
# h9BdPdVIfVJD/Au81Rzh05UHAivorQ3Os8PELHIgiOd9TWzbdgmGzcILt/ddVQER
# MYIHQzCCBz8CAQEweDBhMQswCQYDVQQGEwJVUzEeMBwGA1UEChMVTWljcm9zb2Z0
# IENvcnBvcmF0aW9uMTIwMAYDVQQDEylNaWNyb3NvZnQgUHVibGljIFJTQSBUaW1l
# c3RhbXBpbmcgQ0EgMjAyMAITMwAAAFZ+j51YCI7pYAAAAAAAVjANBglghkgBZQME
# AgEFAKCCBJwwEQYLKoZIhvcNAQkQAg8xAgUAMBoGCSqGSIb3DQEJAzENBgsqhkiG
# 9w0BCRABBDAcBgkqhkiG9w0BCQUxDxcNMjYwNDA1MTUyMzE4WjAvBgkqhkiG9w0B
# CQQxIgQgDQVLNtCFuFhKDetWwZ7S3QTAlZXzso2TE4Wi6VOg0gEwgbkGCyqGSIb3
# DQEJEAIvMYGpMIGmMIGjMIGgBCC2DDMlTaTj8JV3iTg5Xnpe4CSH60143Z+X9o5N
# BgMMqDB8MGWkYzBhMQswCQYDVQQGEwJVUzEeMBwGA1UEChMVTWljcm9zb2Z0IENv
# cnBvcmF0aW9uMTIwMAYDVQQDEylNaWNyb3NvZnQgUHVibGljIFJTQSBUaW1lc3Rh
# bXBpbmcgQ0EgMjAyMAITMwAAAFZ+j51YCI7pYAAAAAAAVjCCA14GCyqGSIb3DQEJ
# EAISMYIDTTCCA0mhggNFMIIDQTCCAikCAQEwggEJoYHhpIHeMIHbMQswCQYDVQQG
# EwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwG
# A1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMSUwIwYDVQQLExxNaWNyb3NvZnQg
# QW1lcmljYSBPcGVyYXRpb25zMScwJQYDVQQLEx5uU2hpZWxkIFRTUyBFU046QTUw
# MC0wNUUwLUQ5NDcxNTAzBgNVBAMTLE1pY3Jvc29mdCBQdWJsaWMgUlNBIFRpbWUg
# U3RhbXBpbmcgQXV0aG9yaXR5oiMKAQEwBwYFKw4DAhoDFQD/c/cpFSqQWYBeXggy
# RJ2ZbvYEEaBnMGWkYzBhMQswCQYDVQQGEwJVUzEeMBwGA1UEChMVTWljcm9zb2Z0
# IENvcnBvcmF0aW9uMTIwMAYDVQQDEylNaWNyb3NvZnQgUHVibGljIFJTQSBUaW1l
# c3RhbXBpbmcgQ0EgMjAyMDANBgkqhkiG9w0BAQsFAAIFAO18tu8wIhgPMjAyNjA0
# MDUxMDI0NDdaGA8yMDI2MDQwNjEwMjQ0N1owdDA6BgorBgEEAYRZCgQBMSwwKjAK
# AgUA7Xy27wIBADAHAgEAAgIkAjAHAgEAAgISXTAKAgUA7X4IbwIBADA2BgorBgEE
# AYRZCgQCMSgwJjAMBgorBgEEAYRZCgMCoAowCAIBAAIDB6EgoQowCAIBAAIDAYag
# MA0GCSqGSIb3DQEBCwUAA4IBAQAggD4/bR6WgBGv4Ubhusl7gtfnsakpJmcxZg7Q
# LBzG5axESnQp24WQ3g5LGsL7kC0n9VqMXD14lakstjEiDceNce5Y59ScFbIlOkcJ
# 9CVKmtnmz6NeDyMrFBbQB7lLo56fcJoHbEqhhvMYihXOl9xzEHU2TfFHt77gOogl
# Mf4o6QauMv3osWwcDeFfNZyseAzGVNQHYvPWrf8Kybe4YGnnOG3A7+tZB7tjOWtZ
# pYDkBY/yXxapAUztVStG695ELqjpToL1QCj+H7KAUDA51Ecy6ONASlZJJH2+Brbt
# Khd5ySrIXvFpFEVDyyJeYC+cr+Tti/RO8sNlAgKfTkEblJyrMA0GCSqGSIb3DQEB
# AQUABIICAF6Kkag7B4gnORyKuxNCg9y61eKOBi5Fws/tHHZ3Nflwvi8uHL094XI9
# 73T9efzwL+GmDVjTjLLCALpfzDH79D58IS2Z2ONcB5aHAB/rnPVElC09oFN0/4mc
# iI8UDsldt8D/zO5ztDtbjuedDoAohBzIjZ1hBRpgvZcfxxsoWnZ1hf/HtSrFBxy7
# h7OuDcI+aW+/yVMnFKGT3tQxft6ukD4VKuoc6umx2JfUw90PEaoM2HLN3weZldRL
# +hksvEjUNDSVEKBCxjTZIzBVzBoJ0MEi4Pl4CKSDPwUj7z9PBOIAmb9/nxzP6U7K
# q9FsMUAUgzHhMI2G/Ijs4BJSZeHJTOpzOZtNBnrC7lCqgHImZs3G60IdOC2EF6/K
# 2kjTBKg9hwPTFeP252phHBVrSS9bxwcVs2sJ1xIU+5TVAJ7CaPI9tq5Mzz4uEfhP
# 6sBZLPSIzZfZjqWBTqIZZigDGQa3pmus0S8lk7OgZAuNrYr7/pyCkOWw8xBWasGL
# oJ2JtmlrDqo9AY7mtaFaD8Pf9RxcczfE0Yb7huXs2x/smEmGq7RnS90aBguBxfeZ
# BopSELA97y3rEyiHZNpa946Nl9JcfllX1TUf8tBQ55pOWzbg93ZJo9cfDM32rSp6
# QXxap1+XhPkQpEfODo6M35W7fQ35TsSnd79sear4sjKyeZZdB4hS
# SIG # End signature block