diff --git a/Tests/ConvertFrom-Base64UrlString.Tests.ps1 b/Tests/ConvertFrom-Base64UrlString.Tests.ps1 new file mode 100644 index 00000000..4edf91e4 --- /dev/null +++ b/Tests/ConvertFrom-Base64UrlString.Tests.ps1 @@ -0,0 +1,94 @@ +Describe $($PSCommandPath -Replace '.Tests.ps1') { + + BeforeAll { + #Get Current Directory + $Here = Split-Path -Parent $PSCommandPath + + #Assume ModuleName from Repository Root folder + $ModuleName = Split-Path (Split-Path $Here -Parent) -Leaf + + #Resolve Path to Module Directory + $ModulePath = Resolve-Path "$Here\..\$ModuleName" + + #Define Path to Module Manifest + $ManifestPath = Join-Path "$ModulePath" "$ModuleName.psd1" + + if ( -not (Get-Module -Name $ModuleName -All)) { + + Import-Module -Name "$ManifestPath" -ArgumentList $true -Force -ErrorAction Stop + + } + + $Script:RequestBody = $null + $psPASSession = [ordered]@{ + BaseURI = 'https://SomeURL/SomeApp' + User = $null + ExternalVersion = [System.Version]'0.0' + WebSession = New-Object Microsoft.PowerShell.Commands.WebRequestSession + StartTime = $null + ElapsedTime = $null + LastCommand = $null + LastCommandTime = $null + LastCommandResults = $null + } + + New-Variable -Name psPASSession -Value $psPASSession -Scope Script -Force + + } + + + AfterAll { + + $Script:RequestBody = $null + + } + + InModuleScope $(Split-Path (Split-Path (Split-Path -Parent $PSCommandPath) -Parent) -Leaf ) { + + Context 'Mandatory Parameters' { + + $Parameters = @{Parameter = 'InputString' } + + It 'specifies parameter as mandatory' -TestCases $Parameters { + + param($Parameter) + + (Get-Command ConvertFrom-Base64UrlString).Parameters["$Parameter"].Attributes.Mandatory | Should -Be $true + + } + + } + + Context 'Base64Url Decoding' { + + It 'decodes Base64Url string without padding' { + $base64Url = 'SGVsbG8gV29ybGQ' + $result = ConvertFrom-Base64UrlString -InputString $base64Url + $resultString = [System.Text.Encoding]::UTF8.GetString($result) + $resultString | Should -Be 'Hello World' + } + + It 'decodes Base64Url with URL-safe characters (dash and underscore)' { + $base64Url = 'PDw_Pz8-Pg' + $result = ConvertFrom-Base64UrlString -InputString $base64Url + $result | Should -Not -BeNullOrEmpty + } + + It 'handles padding correctly' { + $base64Url = 'YWJj' + $result = ConvertFrom-Base64UrlString -InputString $base64Url + $resultString = [System.Text.Encoding]::UTF8.GetString($result) + $resultString | Should -Be 'abc' + } + + It 'converts Base64Url to byte array' { + $base64Url = 'VGVzdA' + $result = ConvertFrom-Base64UrlString -InputString $base64Url + $result.GetType().BaseType.Name | Should -Be 'Array' + } + + } + + } + +} diff --git a/Tests/Invoke-FIDO2Authentication.Tests.ps1 b/Tests/Invoke-FIDO2Authentication.Tests.ps1 new file mode 100644 index 00000000..db053025 --- /dev/null +++ b/Tests/Invoke-FIDO2Authentication.Tests.ps1 @@ -0,0 +1,128 @@ +Describe $($PSCommandPath -Replace '.Tests.ps1') { + + BeforeAll { + #Get Current Directory + $Here = Split-Path -Parent $PSCommandPath + + #Assume ModuleName from Repository Root folder + $ModuleName = Split-Path (Split-Path $Here -Parent) -Leaf + + #Resolve Path to Module Directory + $ModulePath = Resolve-Path "$Here\..\$ModuleName" + + #Define Path to Module Manifest + $ManifestPath = Join-Path "$ModulePath" "$ModuleName.psd1" + + if ( -not (Get-Module -Name $ModuleName -All)) { + + Import-Module -Name "$ManifestPath" -ArgumentList $true -Force -ErrorAction Stop + + } + + $Script:RequestBody = $null + $psPASSession = [ordered]@{ + BaseURI = 'https://SomeURL/SomeApp' + User = $null + ExternalVersion = [System.Version]'0.0' + WebSession = New-Object Microsoft.PowerShell.Commands.WebRequestSession + StartTime = $null + ElapsedTime = $null + LastCommand = $null + LastCommandTime = $null + LastCommandResults = $null + } + + New-Variable -Name psPASSession -Value $psPASSession -Scope Script -Force + + } + + + AfterAll { + + $Script:RequestBody = $null + + } + + InModuleScope $(Split-Path (Split-Path (Split-Path -Parent $PSCommandPath) -Parent) -Leaf ) { + + Context 'Mandatory Parameters' { + + $Parameters = @( + @{Parameter = 'BaseURI' }, + @{Parameter = 'UserName' } + ) + + It 'specifies parameter as mandatory' -TestCases $Parameters { + + param($Parameter) + + (Get-Command Invoke-FIDO2Authentication).Parameters["$Parameter"].Attributes.Mandatory | Should -Be $true + + } + + It 'specifies parameter LogonRequest as optional' { + + (Get-Command Invoke-FIDO2Authentication).Parameters["LogonRequest"].Attributes.Mandatory | Should -Be $false + + } + + } + + Context 'Platform Requirements' { + + BeforeEach { + $IsWindowsPlatform = (-not (Test-IsCoreCLR)) -or $IsWindows + } + + It 'requires Windows platform' { + if (-not $IsWindowsPlatform) { + { Invoke-FIDO2Authentication -BaseURI 'https://pvwa.example.com' -UserName 'testuser' -LogonRequest @{} } | Should -Throw '*Windows*' + } + } + + } + + Context 'Input Validation' { + + It 'accepts BaseURI parameter' { + $params = (Get-Command Invoke-FIDO2Authentication).Parameters['BaseURI'] + $params | Should -Not -BeNullOrEmpty + $params.ParameterType.Name | Should -Be 'String' + } + + It 'accepts UserName parameter' { + $params = (Get-Command Invoke-FIDO2Authentication).Parameters['UserName'] + $params | Should -Not -BeNullOrEmpty + $params.ParameterType.Name | Should -Be 'String' + } + + It 'accepts LogonRequest parameter' { + $params = (Get-Command Invoke-FIDO2Authentication).Parameters['LogonRequest'] + $params | Should -Not -BeNullOrEmpty + $params.ParameterType.Name | Should -Be 'Hashtable' + } + + } + + Context 'Help Content' { + + It 'has a synopsis' { + $help = Get-Help Invoke-FIDO2Authentication + $help.Synopsis | Should -Not -BeNullOrEmpty + } + + It 'has a description' { + $help = Get-Help Invoke-FIDO2Authentication + $help.Description | Should -Not -BeNullOrEmpty + } + + It 'has examples' { + $help = Get-Help Invoke-FIDO2Authentication + $help.Examples | Should -Not -BeNullOrEmpty + } + + } + + } + +} diff --git a/Tests/New-PASSession.Tests.ps1 b/Tests/New-PASSession.Tests.ps1 index 96a49186..d9b99a62 100644 --- a/Tests/New-PASSession.Tests.ps1 +++ b/Tests/New-PASSession.Tests.ps1 @@ -1237,6 +1237,69 @@ Describe $($PSCommandPath -Replace '.Tests.ps1') { } + Context 'Gen2 with FIDO2' { + + BeforeEach { + + Mock Assert-VersionRequirement -MockWith {} + + Mock Invoke-FIDO2Authentication -MockWith { + [PSCustomObject]@{ + 'CyberArkLogonResult' = 'AAAAAAA\\\REEEAAAAALLLLYYYYY\\\\LOOOOONNNNGGGGG\\\ACCCCCEEEEEEEESSSSSSS\\\\\\TTTTTOOOOOKKKKKEEEEEN' + } + } + + Mock Get-PASServer -MockWith { + [PSCustomObject]@{ + ExternalVersion = '14.6' + } + } + + Mock Get-PASLoggedOnUser -MockWith { + @{'UserName' = 'TestUser' } + } + + $psPASSession.ExternalVersion = '14.6' + $psPASSession.WebSession = New-Object Microsoft.PowerShell.Commands.WebRequestSession + + } + + It 'sends request with UserName parameter' { + New-PASSession -BaseURI 'https://pvwa.cyberark.com' -type FIDO2 -UserName 'TestUser' + Assert-MockCalled Invoke-FIDO2Authentication -Times 1 -Exactly -Scope It + + } + + It 'sends request with expected parameters' { + New-PASSession -BaseURI 'https://pvwa.cyberark.com' -type FIDO2 -UserName 'TestUser' + Assert-MockCalled Invoke-FIDO2Authentication -ParameterFilter { + + $BaseURI -eq 'https://pvwa.cyberark.com/PasswordVault' -and $UserName -eq 'TestUser' + + } -Times 1 -Exactly -Scope It + + } + + It 'throws error when UserName is not provided' { + { New-PASSession -BaseURI 'https://pvwa.cyberark.com' -type FIDO2 } | Should -Throw 'Username is required for FIDO2 authentication. Use -UserName parameter.' + } + + It 'sets expected BaseURI' { + + New-PASSession -BaseURI 'https://pvwa.cyberark.com' -type FIDO2 -UserName 'TestUser' + $Script:psPASSession.BaseURI | Should -Be 'https://pvwa.cyberark.com/PasswordVault' + + } + + It 'sets expected authorization header' { + + New-PASSession -BaseURI 'https://pvwa.cyberark.com' -type FIDO2 -UserName 'TestUser' + $psPASSession.WebSession.Headers['Authorization'] | Should -Be 'AAAAAAA\\\REEEAAAAALLLLYYYYY\\\\LOOOOONNNNGGGGG\\\ACCCCCEEEEEEEESSSSSSS\\\\\\TTTTTOOOOOKKKKKEEEEEN' + + } + + } + } } diff --git a/Tests/Register-PASFIDO2Device.Tests.ps1 b/Tests/Register-PASFIDO2Device.Tests.ps1 new file mode 100644 index 00000000..b0d81ae9 --- /dev/null +++ b/Tests/Register-PASFIDO2Device.Tests.ps1 @@ -0,0 +1,302 @@ +Describe $($PSCommandPath -Replace '.Tests.ps1') { + + BeforeAll { + #Get Current Directory + $Here = Split-Path -Parent $PSCommandPath + + #Assume ModuleName from Repository Root folder + $ModuleName = Split-Path (Split-Path $Here -Parent) -Leaf + + #Resolve Path to Module Directory + $ModulePath = Resolve-Path "$Here\..\$ModuleName" + + #Define Path to Module Manifest + $ManifestPath = Join-Path "$ModulePath" "$ModuleName.psd1" + + if ( -not (Get-Module -Name $ModuleName -All)) { + + Import-Module -Name "$ManifestPath" -ArgumentList $true -Force -ErrorAction Stop + + } + + $Script:RequestBody = $null + $psPASSession = [ordered]@{ + BaseURI = 'https://SomeURL/SomeApp' + User = $null + ExternalVersion = [System.Version]'0.0' + WebSession = New-Object Microsoft.PowerShell.Commands.WebRequestSession + StartTime = $null + ElapsedTime = $null + LastCommand = $null + LastCommandTime = $null + LastCommandResults = $null + } + + New-Variable -Name psPASSession -Value $psPASSession -Scope Script -Force + + } + + + AfterAll { + + $Script:RequestBody = $null + + } + + InModuleScope $(Split-Path (Split-Path (Split-Path -Parent $PSCommandPath) -Parent) -Leaf ) { + + Context 'Parameter Sets' { + + It 'has expected parameter sets' { + + $ParameterSets = (Get-Command Register-PASFIDO2Device).ParameterSets + + $ParameterSets.Name | Should -Contain 'Default' + $ParameterSets.Name | Should -Contain 'OwnDevice' + + } + + It 'declares Default as the default parameter set' { + + (Get-Command Register-PASFIDO2Device).DefaultParameterSet | Should -Be 'Default' + + } + + It 'specifies OwnDevice as a mandatory switch in OwnDevice set' { + + $param = (Get-Command Register-PASFIDO2Device).Parameters['OwnDevice'] + $attr = $param.Attributes | Where-Object { $_.ParameterSetName -eq 'OwnDevice' } + $attr.Mandatory | Should -Be $true + + } + + } + + Context 'Input - Admin Registration with UserId' { + + BeforeEach { + $psPASSession.ExternalVersion = '14.6' + $Script:CapturedRequests = New-Object System.Collections.Generic.List[hashtable] + + Mock Invoke-PASRestMethod -MockWith { + $Script:CapturedRequests.Add(@{ URI = $URI; Method = $Method; Body = $Body }) + return [pscustomobject]@{ + createCredentialOptions = [pscustomobject]@{ + rp = [pscustomobject]@{ id = 'pvwa.example.com'; name = 'PVWA' } + user = [pscustomobject]@{ id = 'abc'; name = 'u'; displayName = 'U' } + challenge = 'chal' + } + } + } + + Mock Invoke-FIDO2MakeCredential -MockWith { + return @{ + CredentialId = 'cred-id-b64' + AttestationObject = 'att-obj-b64' + ClientDataJson = 'cdj-b64' + } + } + + Register-PASFIDO2Device -UserId 57 + + } + + It 'sends two REST calls (options + register)' { + + Assert-MockCalled Invoke-PASRestMethod -Times 2 -Exactly -Scope It + + } + + It 'sends options request to expected endpoint' { + + $optionsCall = $Script:CapturedRequests[0] + $optionsCall.URI | Should -Be "$($Script:psPASSession.BaseURI)/api/fido2/registrationOptions" + + } + + It 'sends options request with UserId in body' { + + $optionsCall = $Script:CapturedRequests[0] + ($optionsCall.Body | ConvertFrom-Json).UserId | Should -Be 57 + + } + + It 'sends register request to expected endpoint' { + + $registerCall = $Script:CapturedRequests[1] + $registerCall.URI | Should -Be "$($Script:psPASSession.BaseURI)/api/fido2/registration" + + } + + It 'sends register request using POST' { + + $registerCall = $Script:CapturedRequests[1] + $registerCall.Method | Should -Be 'POST' + + } + + It 'sends register body matching documented attestation shape' { + + $parsed = $Script:CapturedRequests[1].Body | ConvertFrom-Json + $parsed.Attestation.Id | Should -Be 'cred-id-b64' + $parsed.Attestation.Type | Should -Be 'public-key' + $parsed.Attestation.Response.AttestationObject | Should -Be 'att-obj-b64' + $parsed.Attestation.Response.ClientDataJson | Should -Be 'cdj-b64' + $parsed.UserId | Should -Be 57 + + } + + It 'invokes the WebAuthn MakeCredential ceremony once' { + + Assert-MockCalled Invoke-FIDO2MakeCredential -Times 1 -Exactly -Scope It + + } + + It 'throws error if version requirement not met' { + $psPASSession.ExternalVersion = '14.5' + { Register-PASFIDO2Device -UserId 57 } | Should -Throw + $psPASSession.ExternalVersion = '14.6' + } + + } + + Context 'Input - Admin Registration without UserId' { + + BeforeEach { + $psPASSession.ExternalVersion = '14.6' + $Script:CapturedRequests = New-Object System.Collections.Generic.List[hashtable] + + Mock Invoke-PASRestMethod -MockWith { + $Script:CapturedRequests.Add(@{ URI = $URI; Method = $Method; Body = $Body }) + return [pscustomobject]@{ + createCredentialOptions = [pscustomobject]@{ + rp = [pscustomobject]@{ id = 'pvwa.example.com'; name = 'PVWA' } + } + } + } + + Mock Invoke-FIDO2MakeCredential -MockWith { + return @{ + CredentialId = 'cred-id-b64' + AttestationObject = 'att-obj-b64' + ClientDataJson = 'cdj-b64' + } + } + + Register-PASFIDO2Device + + } + + It 'sends options request with empty body when no UserId supplied' { + + $optionsCall = $Script:CapturedRequests[0] + $optionsCall.URI | Should -Match 'registrationOptions$' + $optionsCall.Body | Should -Be '{}' + + } + + It 'omits UserId from register body when not supplied' { + + $parsed = $Script:CapturedRequests[1].Body | ConvertFrom-Json + $parsed.PSObject.Properties.Name | Should -Not -Contain 'UserId' + + } + + } + + Context 'Input - Self Registration (-OwnDevice)' { + + BeforeEach { + $psPASSession.ExternalVersion = '14.6' + $Script:CapturedRequests = New-Object System.Collections.Generic.List[hashtable] + + Mock Invoke-PASRestMethod -MockWith { + $Script:CapturedRequests.Add(@{ URI = $URI; Method = $Method; Body = $Body }) + return [pscustomobject]@{ + createCredentialOptions = [pscustomobject]@{ + rp = [pscustomobject]@{ id = 'pvwa.example.com'; name = 'PVWA' } + } + } + } + + Mock Invoke-FIDO2MakeCredential -MockWith { + return @{ + CredentialId = 'self-cred-id' + AttestationObject = 'self-att-obj' + ClientDataJson = 'self-cdj' + } + } + + Register-PASFIDO2Device -OwnDevice + + } + + It 'sends self-options request to selfRegistrationOptions endpoint' { + + $optionsCall = $Script:CapturedRequests[0] + $optionsCall.URI | Should -Be "$($Script:psPASSession.BaseURI)/api/fido2/selfRegistrationOptions" + + } + + It 'sends self-options request with empty JSON object body' { + + $optionsCall = $Script:CapturedRequests[0] + $optionsCall.Body | Should -Be '{}' + + } + + It 'sends self-register request to selfRegistration endpoint' { + + $registerCall = $Script:CapturedRequests[1] + $registerCall.URI | Should -Be "$($Script:psPASSession.BaseURI)/api/fido2/selfRegistration" + + } + + It 'self-register body does not include UserId' { + + $parsed = $Script:CapturedRequests[1].Body | ConvertFrom-Json + $parsed.PSObject.Properties.Name | Should -Not -Contain 'UserId' + + } + + It 'self-register body still includes attestation in documented shape' { + + $parsed = $Script:CapturedRequests[1].Body | ConvertFrom-Json + $parsed.Attestation.Id | Should -Be 'self-cred-id' + $parsed.Attestation.Response.AttestationObject | Should -Be 'self-att-obj' + $parsed.Attestation.Response.ClientDataJson | Should -Be 'self-cdj' + + } + + } + + Context 'Error Handling' { + + BeforeEach { + $psPASSession.ExternalVersion = '14.6' + } + + It 'throws if registrationOptions response is missing createCredentialOptions' { + + Mock Invoke-PASRestMethod -MockWith { return [pscustomobject]@{ } } + Mock Invoke-FIDO2MakeCredential -MockWith { @{ } } + + { Register-PASFIDO2Device -UserId 57 } | Should -Throw + + } + + It 'does not call MakeCredential when options request returns nothing useful' { + + Mock Invoke-PASRestMethod -MockWith { return [pscustomobject]@{ } } + Mock Invoke-FIDO2MakeCredential -MockWith { @{ } } + + { Register-PASFIDO2Device -UserId 57 } | Should -Throw + Assert-MockCalled Invoke-FIDO2MakeCredential -Times 0 -Exactly -Scope It + + } + + } + + } + +} diff --git a/docs/collections/_commands/New-PASSession.md b/docs/collections/_commands/New-PASSession.md index f25a33c2..7f8ec617 100644 --- a/docs/collections/_commands/New-PASSession.md +++ b/docs/collections/_commands/New-PASSession.md @@ -16,16 +16,18 @@ Authenticates a user to CyberArk Vault/API. ### Gen2 (Default) ``` -New-PASSession [-Credential ] -BaseURI [-newPassword ] [-type ] - [-concurrentSession ] [-PVWAAppName ] [-SkipVersionCheck] [-Certificate ] - [-CertificateThumbprint ] [-SkipCertificateCheck] [-WhatIf] [-Confirm] [] +New-PASSession [-Credential ] -BaseURI [-UserName ] + [-newPassword ] [-type ] [-concurrentSession ] [-PVWAAppName ] + [-SkipVersionCheck] [-Certificate ] [-CertificateThumbprint ] [-SkipCertificateCheck] + [-WhatIf] [-Confirm] [] ``` ### ISPSS-URL-ServiceUser ``` New-PASSession -Credential -IdentityTenantURL -PrivilegeCloudURL [-ServiceUser] [-PVWAAppName ] [-SkipVersionCheck] [-Certificate ] - [-CertificateThumbprint ] [-SkipCertificateCheck] [-WhatIf] [-Confirm] [] + [-CertificateThumbprint ] [-SkipCertificateCheck] [-WhatIf] + [-Confirm] [] ``` ### ISPSS-Subdomain-ServiceUser @@ -39,7 +41,8 @@ New-PASSession -Credential -TenantSubdomain [-ServiceUse ``` New-PASSession -Credential -IdentityTenantURL -PrivilegeCloudURL [-IdentityUser] [-PVWAAppName ] [-SkipVersionCheck] [-Certificate ] - [-CertificateThumbprint ] [-SkipCertificateCheck] [-WhatIf] [-Confirm] [] + [-CertificateThumbprint ] [-SkipCertificateCheck] [-WhatIf] + [-Confirm] [] ``` ### ISPSS-Subdomain-IdentityUser @@ -54,14 +57,16 @@ New-PASSession -Credential -TenantSubdomain [-IdentityUs New-PASSession -Credential -BaseURI [-UseGen1API] -useRadiusAuthentication [-OTP ] [-OTPMode ] [-OTPDelimiter ] [-RadiusChallenge ] [-connectionNumber ] [-PVWAAppName ] [-SkipVersionCheck] [-Certificate ] - [-CertificateThumbprint ] [-SkipCertificateCheck] [-WhatIf] [-Confirm] [] + [-CertificateThumbprint ] [-SkipCertificateCheck] [-WhatIf] + [-Confirm] [] ``` ### Gen1 ``` New-PASSession -Credential -BaseURI [-UseGen1API] [-newPassword ] [-connectionNumber ] [-PVWAAppName ] [-SkipVersionCheck] [-Certificate ] - [-CertificateThumbprint ] [-SkipCertificateCheck] [-WhatIf] [-Confirm] [] + [-CertificateThumbprint ] [-SkipCertificateCheck] [-WhatIf] + [-Confirm] [] ``` ### Gen2Radius @@ -82,8 +87,8 @@ New-PASSession -BaseURI [-UseDefaultCredentials] [-concurrentSession [-UseSharedAuthentication] [-PVWAAppName ] [-SkipVersionCheck] - [-Certificate ] [-CertificateThumbprint ] [-SkipCertificateCheck] [-WhatIf] - [-Confirm] [] + [-Certificate ] [-CertificateThumbprint ] [-SkipCertificateCheck] + [-WhatIf] [-Confirm] [] ``` ### Gen2SAML @@ -386,6 +391,13 @@ Requires IdentityCommand module to be installed for authentication flow to compl See: Get-Help IdentityCommand +### EXAMPLE 31 +``` +New-PASSession -BaseURI https://pvwa.company.com -type FIDO2 -UserName administrator +``` + +Authenticates to CyberArk using FIDO2/WebAuthn hardware security key authentication. + ## PARAMETERS ### -Credential @@ -521,13 +533,14 @@ Accept wildcard characters: False ### -type When using the Gen2 API, specify the type of authentication to use. -Valid values are: - CyberArk - +Valid values are: +- CyberArk - LDAP -- Windows -- Minimum version required 10.4 - RADIUS +- Windows (Minimum version required 10.4) +- RADIUS - PKI - PKIPN +- FIDO2 (Minimum version required 14.4) ```yaml Type: String @@ -907,6 +920,23 @@ Accept pipeline input: True (ByPropertyName) Accept wildcard characters: False ``` +### -UserName +The username for FIDO2 authentication. +When using `-type FIDO2`, specify the username with this parameter (required). +The username identifies the user and their registered security keys. + +```yaml +Type: String +Parameter Sets: Gen2 +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: True (ByPropertyName) +Accept wildcard characters: False +``` + ### CommonParameters This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). diff --git a/docs/collections/_commands/Register-PASFIDO2Device.md b/docs/collections/_commands/Register-PASFIDO2Device.md new file mode 100644 index 00000000..3fce37a5 --- /dev/null +++ b/docs/collections/_commands/Register-PASFIDO2Device.md @@ -0,0 +1,158 @@ +--- +category: PSPAS +external help file: psPAS-help.xml +Module Name: psPAS +online version: https://pspas.pspete.dev/commands/Register-PASFIDO2Device +schema: 2.0.0 +title: Register-PASFIDO2Device +--- + +# Register-PASFIDO2Device + +## SYNOPSIS +Registers a new FIDO2 device for a user. + +## SYNTAX + +### Default (Default) +``` +Register-PASFIDO2Device [-UserId ] [-WhatIf] [-Confirm] [] +``` + +### OwnDevice +``` +Register-PASFIDO2Device [-OwnDevice] [-WhatIf] [-Confirm] [] +``` + +## DESCRIPTION +Registers a new FIDO2 device, either on behalf of another user (admin flow) or for the +currently logged-in user (self-service flow). + +The cmdlet performs the full WebAuthn registration ceremony: + +1. Requests `createCredentialOptions` from the CyberArk API. +2. Invokes the Windows WebAuthn API (`webauthn.dll`) to prompt the user to interact with + their FIDO2 authenticator and produce an attestation. +3. Submits the attestation back to the CyberArk API to complete registration. + +Requires Windows 10 1903 or later for the WebAuthn ceremony. + +Requires CyberArk version 14.6 or later. + +When called without `-OwnDevice`, any user who is a member of the Vault Admins group +can run this web service. + +## EXAMPLES + +### Example 1 +```powershell +PS C:\> Register-PASFIDO2Device -UserId 57 +``` + +Registers a new FIDO2 device for the user whose ID is `57`. +The user running the cmdlet must have the necessary privileges to register devices on +behalf of other users. + +### Example 2 +```powershell +PS C:\> Register-PASFIDO2Device +``` + +Registers a new FIDO2 device for the user implied by the current session, using the admin +registration endpoint. + +### Example 3 +```powershell +PS C:\> Register-PASFIDO2Device -OwnDevice +``` + +Registers a new FIDO2 device for the user that is currently logged in, using the self-service +registration endpoint. + +## PARAMETERS + +### -UserId +The ID of the user to register the FIDO2 device for. + +If omitted, the device is registered against the user implied by the current session +(admin endpoint). + +```yaml +Type: Int32 +Parameter Sets: Default +Aliases: + +Required: False +Position: Named +Default value: 0 +Accept pipeline input: True (ByPropertyName) +Accept wildcard characters: False +``` + +### -OwnDevice +When specified, registers the FIDO2 device against the current user via the self-service +endpoint. + +```yaml +Type: SwitchParameter +Parameter Sets: OwnDevice +Aliases: + +Required: True +Position: Named +Default value: False +Accept pipeline input: True (ByPropertyName) +Accept wildcard characters: False +``` + +### -WhatIf +Shows what would happen if the cmdlet runs. +The cmdlet is not run. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: wi + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Confirm +Prompts you for confirmation before running the cmdlet. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: cf + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### CommonParameters +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +## OUTPUTS + +## NOTES + +## RELATED LINKS + +[https://pspas.pspete.dev/commands/Register-PASFIDO2Device](https://pspas.pspete.dev/commands/Register-PASFIDO2Device) + +[https://docs.cyberark.com/pam-self-hosted/latest/en/content/webservices/fido2-start-registration.htm](https://docs.cyberark.com/pam-self-hosted/latest/en/content/webservices/fido2-start-registration.htm) + +[https://docs.cyberark.com/pam-self-hosted/latest/en/content/webservices/fido2-register.htm](https://docs.cyberark.com/pam-self-hosted/latest/en/content/webservices/fido2-register.htm) + +[https://docs.cyberark.com/pam-self-hosted/latest/en/content/webservices/fido2-self-start-registration.htm](https://docs.cyberark.com/pam-self-hosted/latest/en/content/webservices/fido2-self-start-registration.htm) + +[https://docs.cyberark.com/pam-self-hosted/latest/en/content/webservices/fido2-selfregister.htm](https://docs.cyberark.com/pam-self-hosted/latest/en/content/webservices/fido2-selfregister.htm) diff --git a/psPAS/Functions/Authentication/New-PASSession.ps1 b/psPAS/Functions/Authentication/New-PASSession.ps1 index 483283e9..bdd0859c 100644 --- a/psPAS/Functions/Authentication/New-PASSession.ps1 +++ b/psPAS/Functions/Authentication/New-PASSession.ps1 @@ -189,6 +189,15 @@ function New-PASSession { [Alias('UseClassicAPI')] [switch]$UseGen1API, + [Parameter( + Mandatory = $false, + ValueFromPipeline = $false, + ValueFromPipelinebyPropertyName = $true, + ParameterSetName = 'Gen2' + )] + [ValidateNotNullOrEmpty()] + [string]$UserName, + [Parameter( Mandatory = $false, ValueFromPipeline = $false, @@ -254,7 +263,7 @@ function New-PASSession { ValueFromPipelinebyPropertyName = $true, ParameterSetName = 'Gen2Radius' )] - [ValidateSet('CyberArk', 'LDAP', 'Windows', 'RADIUS', 'PKI', 'PKIPN')] + [ValidateSet('CyberArk', 'LDAP', 'Windows', 'RADIUS', 'PKI', 'PKIPN', 'FIDO2')] [string]$type = 'CyberArk', [Parameter( @@ -555,7 +564,7 @@ function New-PASSession { #Get request parameters $boundParameters = $PSBoundParameters | Get-PASParameter -ParametersToRemove Credential, SkipVersionCheck, SkipCertificateCheck, - UseDefaultCredentials, CertificateThumbprint, BaseURI, PVWAAppName, OTP, type, OTPMode, OTPDelimiter, RadiusChallenge, Certificate + UseDefaultCredentials, CertificateThumbprint, BaseURI, PVWAAppName, OTP, type, OTPMode, OTPDelimiter, RadiusChallenge, Certificate, UserName #deal with newPassword SecureString if ($PSBoundParameters.ContainsKey('newPassword')) { @@ -565,7 +574,15 @@ function New-PASSession { } - if ($type -ne 'PKIPN') { + if ($type -eq 'FIDO2') { + #FIDO2 authentication requires username but no password + Assert-VersionRequirement -SelfHosted + + if (-not $PSBoundParameters.ContainsKey('UserName')) { + throw 'Username is required for FIDO2 authentication. Use -UserName parameter.' + } + + } elseif ($type -ne 'PKIPN') { if ($PSBoundParameters.Keys.Contains('Credential')) { #Add user name from credential object @@ -649,6 +666,11 @@ function New-PASSession { $PASSession = New-IDPlatformToken -tenant_url $LogonRequest['Uri'] -Credential $LogonRequest['Credential'] break } + ( { $type -eq 'FIDO2' } ) { + #Perform FIDO2 Authentication + $PASSession = Invoke-FIDO2Authentication -BaseURI $Uri -UserName $UserName -LogonRequest $LogonRequest + break + } default { #Send Logon Request $PASSession = Invoke-PASRestMethod @LogonRequest diff --git a/psPAS/Functions/Authentication/Register-PASFIDO2Device.ps1 b/psPAS/Functions/Authentication/Register-PASFIDO2Device.ps1 new file mode 100644 index 00000000..26835f72 --- /dev/null +++ b/psPAS/Functions/Authentication/Register-PASFIDO2Device.ps1 @@ -0,0 +1,109 @@ +# .ExternalHelp psPAS-help.xml +function Register-PASFIDO2Device { + + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'OwnDevice', Justification = 'False Positive')] + [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'Default')] + param( + [parameter( + Mandatory = $false, + ValueFromPipelinebyPropertyName = $true, + ParameterSetName = 'Default' + )] + [int]$UserId, + + [parameter( + Mandatory = $true, + ValueFromPipelinebyPropertyName = $true, + ParameterSetName = 'OwnDevice' + )] + [switch]$OwnDevice + ) + + begin { + + Assert-VersionRequirement -SelfHosted + Assert-VersionRequirement -RequiredVersion 14.6 + + }#begin + + process { + + switch ($PSCmdlet.ParameterSetName) { + + 'OwnDevice' { + + #URL for current user's own registration + #Note: docs show /api/fido2/registrationOptions for self-start, but that endpoint + #validates UserId >= 1. The actual self-start endpoint mirrors the selfX naming + #convention used elsewhere (selfKeys, selfRegistration). + $optionsURI = "$($psPASSession.BaseURI)/api/fido2/selfRegistrationOptions" + $registerURI = "$($psPASSession.BaseURI)/api/fido2/selfRegistration" + + #Self-start endpoint takes no documented body fields, but the server still + #expects a JSON object payload (else: PASWS011E Missing mandatory parameter [request]) + $optionsBody = '{}' + $shouldProcessTarget = 'Current User' + $shouldProcessMessage = 'Register Own FIDO2 Device' + + break + + } + + default { + + #URL for admin-initiated registration on behalf of another user + $optionsURI = "$($psPASSession.BaseURI)/api/fido2/registrationOptions" + $registerURI = "$($psPASSession.BaseURI)/api/fido2/registration" + + $optionsBody = @{} + if ($PSBoundParameters.ContainsKey('UserId')) { + $optionsBody['UserId'] = $UserId + } + $optionsBody = $optionsBody | ConvertTo-Json + + $shouldProcessTarget = if ($PSBoundParameters.ContainsKey('UserId')) { "UserId $UserId" } else { 'Current User' } + $shouldProcessMessage = 'Register FIDO2 Device' + + } + + } + + #1. Request registration options + $optionsResponse = Invoke-PASRestMethod -Uri $optionsURI -Method POST -Body $optionsBody + + if ($null -eq $optionsResponse.createCredentialOptions) { + throw 'No createCredentialOptions returned from /api/fido2/registrationOptions' + } + + #2. Run WebAuthn MakeCredential ceremony locally against the FIDO2 authenticator + $attestation = Invoke-FIDO2MakeCredential -Options $optionsResponse.createCredentialOptions + + #3. Submit attestation to register the new device + $registerBody = [ordered]@{ + Attestation = [ordered]@{ + Id = $attestation.CredentialId + Type = 'public-key' + Response = [ordered]@{ + AttestationObject = $attestation.AttestationObject + ClientDataJson = $attestation.ClientDataJson + } + Extensions = @{} + } + } + + #UserId only applies to the admin (Default) endpoint + if ($PSCmdlet.ParameterSetName -eq 'Default' -and $PSBoundParameters.ContainsKey('UserId')) { + $registerBody['UserId'] = $UserId + } + + if ($PSCmdlet.ShouldProcess($shouldProcessTarget, $shouldProcessMessage)) { + + Invoke-PASRestMethod -Uri $registerURI -Method POST -Body ($registerBody | ConvertTo-Json -Depth 5) + + } + + }#process + + end { }#end + +} diff --git a/psPAS/Private/ConvertFrom-Base64UrlString.ps1 b/psPAS/Private/ConvertFrom-Base64UrlString.ps1 new file mode 100644 index 00000000..4d0717ee --- /dev/null +++ b/psPAS/Private/ConvertFrom-Base64UrlString.ps1 @@ -0,0 +1,45 @@ +function ConvertFrom-Base64UrlString { + <# + .SYNOPSIS + Converts a Base64Url-encoded string to bytes + + .DESCRIPTION + Converts a Base64Url-encoded string (URL-safe Base64 without padding) to a byte array. + This format is used in FIDO2/WebAuthn specifications. + + .PARAMETER InputString + The Base64Url-encoded string to convert + + .EXAMPLE + ConvertFrom-Base64UrlString -InputString 'SGVsbG8gV29ybGQ' + + Converts the Base64Url string to bytes + + .NOTES + Base64Url encoding uses - and _ instead of + and / and removes padding (=) + #> + [CmdletBinding()] + [OutputType([byte[]])] + param( + [Parameter( + Mandatory = $true, + ValueFromPipeline = $true, + ValueFromPipelinebyPropertyName = $true + )] + [string]$InputString + ) + + process { + # Convert Base64Url to standard Base64 + $base64 = $InputString.Replace('-', '+').Replace('_', '/') + + # Add padding if necessary + $padding = 4 - ($base64.Length % 4) + if ($padding -ne 4) { + $base64 += '=' * $padding + } + + # Convert to bytes + [System.Convert]::FromBase64String($base64) + } +} diff --git a/psPAS/Private/Invoke-FIDO2Authentication.ps1 b/psPAS/Private/Invoke-FIDO2Authentication.ps1 new file mode 100644 index 00000000..fa984fb3 --- /dev/null +++ b/psPAS/Private/Invoke-FIDO2Authentication.ps1 @@ -0,0 +1,278 @@ +function Invoke-FIDO2Authentication { + <# + .SYNOPSIS + Performs FIDO2 authentication via the Windows WebAuthn API + + .DESCRIPTION + Handles the FIDO2 authentication flow: + 1. Requests assertion options from the CyberArk API. + 2. Uses a FIDO2 device (via webauthn.dll) to generate an assertion. + 3. Submits the assertion back to the CyberArk API. + + .PARAMETER BaseURI + The base URI for the CyberArk PVWA + + .PARAMETER UserName + The username for FIDO2 authentication + + .PARAMETER LogonRequest + Hashtable containing the logon request parameters + + .EXAMPLE + Invoke-FIDO2Authentication -BaseURI 'https://pvwa.example.com/PasswordVault' -UserName 'myuser' -LogonRequest $request + + .NOTES + Requires Windows 10 1903+ (ships webauthn.dll). No third-party assemblies required. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$BaseURI, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$UserName, + + [Parameter(Mandatory = $false)] + [hashtable]$LogonRequest + ) + + begin { + + if ((Test-IsCoreCLR) -and -not $IsWindows) { + throw 'FIDO2 authentication is only supported on Windows platforms' + } + + #Compile P/Invoke wrapper for Windows webauthn.dll on first use + if (-not ('psPAS.WebAuthn.Native' -as [type])) { + + Add-Type -ErrorAction Stop -TypeDefinition @' +using System; +using System.Runtime.InteropServices; + +namespace psPAS.WebAuthn { + + [StructLayout(LayoutKind.Sequential)] + public struct WEBAUTHN_CLIENT_DATA { + public uint dwVersion; + public uint cbClientDataJSON; + public IntPtr pbClientDataJSON; + [MarshalAs(UnmanagedType.LPWStr)] public string pwszHashAlgId; + } + + [StructLayout(LayoutKind.Sequential)] + public struct WEBAUTHN_CREDENTIAL { + public uint dwVersion; + public uint cbId; + public IntPtr pbId; + [MarshalAs(UnmanagedType.LPWStr)] public string pwszCredentialType; + } + + [StructLayout(LayoutKind.Sequential)] + public struct WEBAUTHN_CREDENTIALS { + public uint cCredentials; + public IntPtr pCredentials; + } + + [StructLayout(LayoutKind.Sequential)] + public struct WEBAUTHN_EXTENSIONS { + public uint cExtensions; + public IntPtr pExtensions; + } + + [StructLayout(LayoutKind.Sequential)] + public struct WEBAUTHN_AUTHENTICATOR_GET_ASSERTION_OPTIONS { + public uint dwVersion; + public uint dwTimeoutMilliseconds; + public WEBAUTHN_CREDENTIALS CredentialList; + public WEBAUTHN_EXTENSIONS Extensions; + public uint dwAuthenticatorAttachment; + public uint dwUserVerificationRequirement; + public uint dwFlags; + } + + [StructLayout(LayoutKind.Sequential)] + public struct WEBAUTHN_ASSERTION { + public uint dwVersion; + public uint cbAuthenticatorData; + public IntPtr pbAuthenticatorData; + public uint cbSignature; + public IntPtr pbSignature; + public WEBAUTHN_CREDENTIAL Credential; + public uint cbUserId; + public IntPtr pbUserId; + } + + public static class Native { + + [DllImport("webauthn.dll", CharSet = CharSet.Unicode)] + public static extern int WebAuthNAuthenticatorGetAssertion( + IntPtr hWnd, + [MarshalAs(UnmanagedType.LPWStr)] string pwszRpId, + ref WEBAUTHN_CLIENT_DATA pWebAuthNClientData, + ref WEBAUTHN_AUTHENTICATOR_GET_ASSERTION_OPTIONS pWebAuthNGetAssertionOptions, + out IntPtr ppWebAuthNAssertion); + + [DllImport("webauthn.dll")] + public static extern void WebAuthNFreeAssertion(IntPtr pWebAuthNAssertion); + + [DllImport("webauthn.dll", CharSet = CharSet.Unicode)] + public static extern IntPtr WebAuthNGetErrorName(int hr); + + [DllImport("user32.dll")] + public static extern IntPtr GetForegroundWindow(); + } +} +'@ + + } + + #Base64Url encode a byte array + $toB64Url = { param([byte[]]$Bytes) [Convert]::ToBase64String($Bytes).Replace('+', '-').Replace('/', '_').TrimEnd('=') } + + } + + process { + + #Passthrough parameters shared with each API call + $commonParams = @{} + foreach ($key in 'UseDefaultCredentials', 'SkipCertificateCheck', 'Certificate', 'CertificateThumbprint') { + if ($LogonRequest -and $LogonRequest.ContainsKey($key)) { $commonParams[$key] = $LogonRequest[$key] } + } + + #Request assertion options + $assertionOptions = (Invoke-PASRestMethod @commonParams ` + -Uri "$BaseURI/api/auth/fido/logon" -Method POST ` + -Body (@{ username = $UserName; type = 'fido' } | ConvertTo-Json)).assertionOptions + + #Build clientDataJSON (same structure the server verifies against) + $clientDataJson = '{"type":"webauthn.get","challenge":"' + $assertionOptions.challenge + + '","origin":"https://' + $assertionOptions.rpId + '","crossOrigin":false}' + $clientDataBytes = [System.Text.Encoding]::UTF8.GetBytes($clientDataJson) + + $allocations = New-Object System.Collections.Generic.List[IntPtr] + $pAssertion = [IntPtr]::Zero + + try { + + #Marshal clientData + $pClientDataJson = [Runtime.InteropServices.Marshal]::AllocHGlobal($clientDataBytes.Length) + $allocations.Add($pClientDataJson) + [Runtime.InteropServices.Marshal]::Copy($clientDataBytes, 0, $pClientDataJson, $clientDataBytes.Length) + + $clientData = New-Object psPAS.WebAuthn.WEBAUTHN_CLIENT_DATA + $clientData.dwVersion = 1 + $clientData.cbClientDataJSON = $clientDataBytes.Length + $clientData.pbClientDataJSON = $pClientDataJson + $clientData.pwszHashAlgId = 'SHA-256' + + #Marshal allowed credentials list + $credStructs = @() + foreach ($cred in $assertionOptions.allowCredentials) { + $credIdBytes = ConvertFrom-Base64UrlString -InputString $cred.id + $pCredId = [Runtime.InteropServices.Marshal]::AllocHGlobal($credIdBytes.Length) + $allocations.Add($pCredId) + [Runtime.InteropServices.Marshal]::Copy($credIdBytes, 0, $pCredId, $credIdBytes.Length) + + $wCred = New-Object psPAS.WebAuthn.WEBAUTHN_CREDENTIAL + $wCred.dwVersion = 1 + $wCred.cbId = $credIdBytes.Length + $wCred.pbId = $pCredId + $wCred.pwszCredentialType = 'public-key' + $credStructs += $wCred + } + + $pCredArray = [IntPtr]::Zero + if ($credStructs.Count -gt 0) { + $credSize = [Runtime.InteropServices.Marshal]::SizeOf([type][psPAS.WebAuthn.WEBAUTHN_CREDENTIAL]) + $pCredArray = [Runtime.InteropServices.Marshal]::AllocHGlobal($credSize * $credStructs.Count) + $allocations.Add($pCredArray) + for ($i = 0; $i -lt $credStructs.Count; $i++) { + $target = [IntPtr]::new($pCredArray.ToInt64() + ($i * $credSize)) + [Runtime.InteropServices.Marshal]::StructureToPtr($credStructs[$i], $target, $false) + } + } + + #Nested value-type fields must be assigned whole + #(PowerShell mutates a COPY when setting a nested struct's fields) + $credList = New-Object psPAS.WebAuthn.WEBAUTHN_CREDENTIALS + $credList.cCredentials = $credStructs.Count + $credList.pCredentials = $pCredArray + + $options = New-Object psPAS.WebAuthn.WEBAUTHN_AUTHENTICATOR_GET_ASSERTION_OPTIONS + $options.dwVersion = 1 + $options.dwTimeoutMilliseconds = 60000 + $options.CredentialList = $credList + $options.dwUserVerificationRequirement = 1 #Required + + #Invoke Windows WebAuthn API + $hr = [psPAS.WebAuthn.Native]::WebAuthNAuthenticatorGetAssertion( + [psPAS.WebAuthn.Native]::GetForegroundWindow(), + $assertionOptions.rpId, + [ref]$clientData, + [ref]$options, + [ref]$pAssertion + ) + + if ($hr -ne 0) { + $errorNamePtr = [psPAS.WebAuthn.Native]::WebAuthNGetErrorName($hr) + $errorName = if ($errorNamePtr -ne [IntPtr]::Zero) { [Runtime.InteropServices.Marshal]::PtrToStringUni($errorNamePtr) } else { "Unknown error (0x{0:X})" -f $hr } + throw "WebAuthNAuthenticatorGetAssertion failed: $errorName" + } + + $assertion = [Runtime.InteropServices.Marshal]::PtrToStructure( + $pAssertion, [type][psPAS.WebAuthn.WEBAUTHN_ASSERTION] + ) + + $authenticatorData = New-Object byte[] $assertion.cbAuthenticatorData + [Runtime.InteropServices.Marshal]::Copy($assertion.pbAuthenticatorData, $authenticatorData, 0, $assertion.cbAuthenticatorData) + + $signature = New-Object byte[] $assertion.cbSignature + [Runtime.InteropServices.Marshal]::Copy($assertion.pbSignature, $signature, 0, $assertion.cbSignature) + + $credentialId = New-Object byte[] $assertion.Credential.cbId + [Runtime.InteropServices.Marshal]::Copy($assertion.Credential.pbId, $credentialId, 0, $assertion.Credential.cbId) + + $userHandle = $null + if ($assertion.cbUserId -gt 0) { + $userHandle = New-Object byte[] $assertion.cbUserId + [Runtime.InteropServices.Marshal]::Copy($assertion.pbUserId, $userHandle, 0, $assertion.cbUserId) + } + + } finally { + + if ($pAssertion -ne [IntPtr]::Zero) { + [psPAS.WebAuthn.Native]::WebAuthNFreeAssertion($pAssertion) + } + foreach ($ptr in $allocations) { + [Runtime.InteropServices.Marshal]::FreeHGlobal($ptr) + } + + } + + #Build response payload + $credIdB64 = & $toB64Url $credentialId + $assertionResponse = [ordered]@{ + Id = $credIdB64 + RawId = $credIdB64 + Type = 'public-key' + Extensions = @{} + Response = [ordered]@{ + AuthenticatorData = & $toB64Url $authenticatorData + ClientDataJson = & $toB64Url $clientDataBytes + Signature = & $toB64Url $signature + UserHandle = if ($userHandle) { & $toB64Url $userHandle } else { $null } + } + } + + $additionalInfo = & $toB64Url ([System.Text.Encoding]::UTF8.GetBytes(($assertionResponse | ConvertTo-Json -Depth 10 -Compress))) + + #Submit assertion + Invoke-PASRestMethod @commonParams ` + -Uri "$BaseURI/api/auth/fido/logon" -Method POST ` + -Body (@{ userName = $UserName; AdditionalInfo = $additionalInfo } | ConvertTo-Json) ` + -SessionVariable 'FIDOSession' + + } + +} diff --git a/psPAS/Private/Invoke-FIDO2MakeCredential.ps1 b/psPAS/Private/Invoke-FIDO2MakeCredential.ps1 new file mode 100644 index 00000000..cbeb6539 --- /dev/null +++ b/psPAS/Private/Invoke-FIDO2MakeCredential.ps1 @@ -0,0 +1,348 @@ +function Invoke-FIDO2MakeCredential { + <# + .SYNOPSIS + Performs a FIDO2 MakeCredential ceremony via the Windows WebAuthn API. + + .DESCRIPTION + Wraps the webauthn.dll WebAuthNAuthenticatorMakeCredential P/Invoke call. + Given the createCredentialOptions returned by the CyberArk + "Start FIDO2 registration" endpoint, prompts the user to interact with their + FIDO2 authenticator and returns the resulting attestation as base64url-encoded + values ready to submit to the "Register FIDO2 device" endpoint. + + .PARAMETER Options + The createCredentialOptions object returned by the + /api/fido2/registrationOptions endpoint. + + .NOTES + Requires Windows 10 1903+ (ships webauthn.dll). + #> + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', 'CreateCredentialOptions', Justification = 'Parameter does not hold a password or credential')] + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory = $true)] + [ValidateNotNull()] + [object]$Options + ) + + begin { + + if ((Test-IsCoreCLR) -and -not $IsWindows) { + throw 'FIDO2 device registration is only supported on Windows platforms' + } + + #Compile P/Invoke wrapper for Windows webauthn.dll on first use. + #A separate type is used for registration so that this helper is self-contained. + if (-not ('psPAS.WebAuthnReg.Native' -as [type])) { + + Add-Type -ErrorAction Stop -TypeDefinition @' +using System; +using System.Runtime.InteropServices; + +namespace psPAS.WebAuthnReg { + + [StructLayout(LayoutKind.Sequential)] + public struct WEBAUTHN_RP_ENTITY_INFORMATION { + public uint dwVersion; + [MarshalAs(UnmanagedType.LPWStr)] public string pwszId; + [MarshalAs(UnmanagedType.LPWStr)] public string pwszName; + [MarshalAs(UnmanagedType.LPWStr)] public string pwszIcon; + } + + [StructLayout(LayoutKind.Sequential)] + public struct WEBAUTHN_USER_ENTITY_INFORMATION { + public uint dwVersion; + public uint cbId; + public IntPtr pbId; + [MarshalAs(UnmanagedType.LPWStr)] public string pwszName; + [MarshalAs(UnmanagedType.LPWStr)] public string pwszIcon; + [MarshalAs(UnmanagedType.LPWStr)] public string pwszDisplayName; + } + + [StructLayout(LayoutKind.Sequential)] + public struct WEBAUTHN_COSE_CREDENTIAL_PARAMETER { + public uint dwVersion; + [MarshalAs(UnmanagedType.LPWStr)] public string pwszCredentialType; + public int lAlg; + } + + [StructLayout(LayoutKind.Sequential)] + public struct WEBAUTHN_COSE_CREDENTIAL_PARAMETERS { + public uint cCredentialParameters; + public IntPtr pCredentialParameters; + } + + [StructLayout(LayoutKind.Sequential)] + public struct WEBAUTHN_CLIENT_DATA { + public uint dwVersion; + public uint cbClientDataJSON; + public IntPtr pbClientDataJSON; + [MarshalAs(UnmanagedType.LPWStr)] public string pwszHashAlgId; + } + + [StructLayout(LayoutKind.Sequential)] + public struct WEBAUTHN_CREDENTIAL { + public uint dwVersion; + public uint cbId; + public IntPtr pbId; + [MarshalAs(UnmanagedType.LPWStr)] public string pwszCredentialType; + } + + [StructLayout(LayoutKind.Sequential)] + public struct WEBAUTHN_CREDENTIALS { + public uint cCredentials; + public IntPtr pCredentials; + } + + [StructLayout(LayoutKind.Sequential)] + public struct WEBAUTHN_EXTENSIONS { + public uint cExtensions; + public IntPtr pExtensions; + } + + [StructLayout(LayoutKind.Sequential)] + public struct WEBAUTHN_AUTHENTICATOR_MAKE_CREDENTIAL_OPTIONS { + public uint dwVersion; + public uint dwTimeoutMilliseconds; + public WEBAUTHN_CREDENTIALS CredentialList; + public WEBAUTHN_EXTENSIONS Extensions; + public uint dwAuthenticatorAttachment; + [MarshalAs(UnmanagedType.Bool)] public bool bRequireResidentKey; + public uint dwUserVerificationRequirement; + public uint dwAttestationConveyancePreference; + public uint dwFlags; + } + + [StructLayout(LayoutKind.Sequential)] + public struct WEBAUTHN_CREDENTIAL_ATTESTATION { + public uint dwVersion; + [MarshalAs(UnmanagedType.LPWStr)] public string pwszFormatType; + public uint cbAuthenticatorData; + public IntPtr pbAuthenticatorData; + public uint cbAttestation; + public IntPtr pbAttestation; + public uint dwAttestationDecodeType; + public IntPtr pvAttestationDecode; + public uint cbAttestationObject; + public IntPtr pbAttestationObject; + public uint cbCredentialId; + public IntPtr pbCredentialId; + } + + public static class Native { + + [DllImport("webauthn.dll", CharSet = CharSet.Unicode)] + public static extern int WebAuthNAuthenticatorMakeCredential( + IntPtr hWnd, + ref WEBAUTHN_RP_ENTITY_INFORMATION pRpInformation, + ref WEBAUTHN_USER_ENTITY_INFORMATION pUserInformation, + ref WEBAUTHN_COSE_CREDENTIAL_PARAMETERS pPubKeyCredParams, + ref WEBAUTHN_CLIENT_DATA pWebAuthNClientData, + ref WEBAUTHN_AUTHENTICATOR_MAKE_CREDENTIAL_OPTIONS pWebAuthNMakeCredentialOptions, + out IntPtr ppWebAuthNCredentialAttestation); + + [DllImport("webauthn.dll")] + public static extern void WebAuthNFreeCredentialAttestation(IntPtr pWebAuthNCredentialAttestation); + + [DllImport("webauthn.dll", CharSet = CharSet.Unicode)] + public static extern IntPtr WebAuthNGetErrorName(int hr); + + [DllImport("user32.dll")] + public static extern IntPtr GetForegroundWindow(); + } +} +'@ + + } + + #Base64Url encode a byte array + $toB64Url = { param([byte[]]$Bytes) [Convert]::ToBase64String($Bytes).Replace('+', '-').Replace('/', '_').TrimEnd('=') } + + } + + process { + + #Build clientDataJSON for the create ceremony + $clientDataJson = '{"type":"webauthn.create","challenge":"' + $Options.challenge + + '","origin":"https://' + $Options.rp.id + '","crossOrigin":false}' + $clientDataBytes = [System.Text.Encoding]::UTF8.GetBytes($clientDataJson) + + #User handle is supplied as a base64url string in the registration options + $userIdBytes = ConvertFrom-Base64UrlString -InputString $Options.user.id + + $allocations = New-Object System.Collections.Generic.List[IntPtr] + $pAttestation = [IntPtr]::Zero + + try { + + #Marshal clientData + $pClientDataJson = [Runtime.InteropServices.Marshal]::AllocHGlobal($clientDataBytes.Length) + $allocations.Add($pClientDataJson) + [Runtime.InteropServices.Marshal]::Copy($clientDataBytes, 0, $pClientDataJson, $clientDataBytes.Length) + + $clientData = New-Object psPAS.WebAuthnReg.WEBAUTHN_CLIENT_DATA + $clientData.dwVersion = 1 + $clientData.cbClientDataJSON = $clientDataBytes.Length + $clientData.pbClientDataJSON = $pClientDataJson + $clientData.pwszHashAlgId = 'SHA-256' + + #Marshal user id + $pUserId = [Runtime.InteropServices.Marshal]::AllocHGlobal($userIdBytes.Length) + $allocations.Add($pUserId) + [Runtime.InteropServices.Marshal]::Copy($userIdBytes, 0, $pUserId, $userIdBytes.Length) + + $rp = New-Object psPAS.WebAuthnReg.WEBAUTHN_RP_ENTITY_INFORMATION + $rp.dwVersion = 1 + $rp.pwszId = $Options.rp.id + $rp.pwszName = $Options.rp.name + + $user = New-Object psPAS.WebAuthnReg.WEBAUTHN_USER_ENTITY_INFORMATION + $user.dwVersion = 1 + $user.cbId = $userIdBytes.Length + $user.pbId = $pUserId + $user.pwszName = $Options.user.name + $user.pwszDisplayName = $Options.user.displayName + + #Marshal pubKeyCredParams array + $paramStructs = @() + foreach ($p in $Options.pubKeyCredParams) { + $cp = New-Object psPAS.WebAuthnReg.WEBAUTHN_COSE_CREDENTIAL_PARAMETER + $cp.dwVersion = 1 + $cp.pwszCredentialType = $p.type + $cp.lAlg = [int]$p.alg + $paramStructs += $cp + } + + $pParamArray = [IntPtr]::Zero + if ($paramStructs.Count -gt 0) { + $paramSize = [Runtime.InteropServices.Marshal]::SizeOf([type][psPAS.WebAuthnReg.WEBAUTHN_COSE_CREDENTIAL_PARAMETER]) + $pParamArray = [Runtime.InteropServices.Marshal]::AllocHGlobal($paramSize * $paramStructs.Count) + $allocations.Add($pParamArray) + for ($i = 0; $i -lt $paramStructs.Count; $i++) { + $target = [IntPtr]::new($pParamArray.ToInt64() + ($i * $paramSize)) + [Runtime.InteropServices.Marshal]::StructureToPtr($paramStructs[$i], $target, $false) + } + } + + $pubKeyCredParams = New-Object psPAS.WebAuthnReg.WEBAUTHN_COSE_CREDENTIAL_PARAMETERS + $pubKeyCredParams.cCredentialParameters = $paramStructs.Count + $pubKeyCredParams.pCredentialParameters = $pParamArray + + #Marshal excludeCredentials + $excludeStructs = @() + if ($Options.excludeCredentials) { + foreach ($cred in $Options.excludeCredentials) { + $credIdBytes = ConvertFrom-Base64UrlString -InputString $cred.id + $pCredId = [Runtime.InteropServices.Marshal]::AllocHGlobal($credIdBytes.Length) + $allocations.Add($pCredId) + [Runtime.InteropServices.Marshal]::Copy($credIdBytes, 0, $pCredId, $credIdBytes.Length) + + $wCred = New-Object psPAS.WebAuthnReg.WEBAUTHN_CREDENTIAL + $wCred.dwVersion = 1 + $wCred.cbId = $credIdBytes.Length + $wCred.pbId = $pCredId + $wCred.pwszCredentialType = 'public-key' + $excludeStructs += $wCred + } + } + + $pExcludeArray = [IntPtr]::Zero + if ($excludeStructs.Count -gt 0) { + $credSize = [Runtime.InteropServices.Marshal]::SizeOf([type][psPAS.WebAuthnReg.WEBAUTHN_CREDENTIAL]) + $pExcludeArray = [Runtime.InteropServices.Marshal]::AllocHGlobal($credSize * $excludeStructs.Count) + $allocations.Add($pExcludeArray) + for ($i = 0; $i -lt $excludeStructs.Count; $i++) { + $target = [IntPtr]::new($pExcludeArray.ToInt64() + ($i * $credSize)) + [Runtime.InteropServices.Marshal]::StructureToPtr($excludeStructs[$i], $target, $false) + } + } + + $excludeList = New-Object psPAS.WebAuthnReg.WEBAUTHN_CREDENTIALS + $excludeList.cCredentials = $excludeStructs.Count + $excludeList.pCredentials = $pExcludeArray + + #Map authenticatorSelection / attestation values + $uvRequirement = 0 + switch ($Options.authenticatorSelection.userVerification) { + 'required' { $uvRequirement = 1 } + 'preferred' { $uvRequirement = 2 } + 'discouraged' { $uvRequirement = 3 } + } + + $attachment = 0 + switch ($Options.authenticatorSelection.authenticatorAttachment) { + 'platform' { $attachment = 1 } + 'cross-platform' { $attachment = 2 } + } + + $attestationPref = 0 + switch ($Options.attestation) { + 'none' { $attestationPref = 1 } + 'indirect' { $attestationPref = 2 } + 'direct' { $attestationPref = 3 } + 'enterprise' { $attestationPref = 4 } + } + + $options = New-Object psPAS.WebAuthnReg.WEBAUTHN_AUTHENTICATOR_MAKE_CREDENTIAL_OPTIONS + #dwVersion=1: struct layout matches the fields declared above (up to dwFlags). + #Higher versions (2+) require additional fields (pCancellationId, pExcludeCredentialList, ...); + #using a higher version with a smaller struct causes webauthn.dll to read past the + #end of the struct and access-violate. + $options.dwVersion = 1 + $options.dwTimeoutMilliseconds = if ($Options.timeout) { [uint32]$Options.timeout } else { 60000 } + #At V1, CredentialList is the (deprecated) exclude credentials list + $options.CredentialList = $excludeList + $options.dwAuthenticatorAttachment = $attachment + $options.bRequireResidentKey = [bool]$Options.authenticatorSelection.requireResidentKey + $options.dwUserVerificationRequirement = $uvRequirement + $options.dwAttestationConveyancePreference = $attestationPref + + #Invoke Windows WebAuthn API + $hr = [psPAS.WebAuthnReg.Native]::WebAuthNAuthenticatorMakeCredential( + [psPAS.WebAuthnReg.Native]::GetForegroundWindow(), + [ref]$rp, + [ref]$user, + [ref]$pubKeyCredParams, + [ref]$clientData, + [ref]$options, + [ref]$pAttestation + ) + + if ($hr -ne 0) { + $errorNamePtr = [psPAS.WebAuthnReg.Native]::WebAuthNGetErrorName($hr) + $errorName = if ($errorNamePtr -ne [IntPtr]::Zero) { [Runtime.InteropServices.Marshal]::PtrToStringUni($errorNamePtr) } else { "Unknown error (0x{0:X})" -f $hr } + throw "WebAuthNAuthenticatorMakeCredential failed: $errorName" + } + + $attestation = [Runtime.InteropServices.Marshal]::PtrToStructure( + $pAttestation, [type][psPAS.WebAuthnReg.WEBAUTHN_CREDENTIAL_ATTESTATION] + ) + + $credentialId = New-Object byte[] $attestation.cbCredentialId + [Runtime.InteropServices.Marshal]::Copy($attestation.pbCredentialId, $credentialId, 0, $attestation.cbCredentialId) + + $attestationObject = New-Object byte[] $attestation.cbAttestationObject + [Runtime.InteropServices.Marshal]::Copy($attestation.pbAttestationObject, $attestationObject, 0, $attestation.cbAttestationObject) + + } finally { + + if ($pAttestation -ne [IntPtr]::Zero) { + [psPAS.WebAuthnReg.Native]::WebAuthNFreeCredentialAttestation($pAttestation) + } + foreach ($ptr in $allocations) { + [Runtime.InteropServices.Marshal]::FreeHGlobal($ptr) + } + + } + + #Return base64url-encoded values for the registration submission + @{ + CredentialId = & $toB64Url $credentialId + AttestationObject = & $toB64Url $attestationObject + ClientDataJson = & $toB64Url $clientDataBytes + } + + } + +} diff --git a/psPAS/en-US/psPAS-help.xml b/psPAS/en-US/psPAS-help.xml index 1f7927cd..803e8b11 100644 --- a/psPAS/en-US/psPAS-help.xml +++ b/psPAS/en-US/psPAS-help.xml @@ -29332,10 +29332,11 @@ PS C:\> New-PASReportSchedule -version 1 -type 'Report' -subType 'CyberArk.Re When using the Gen2 API, specify the type of authentication to use. Valid values are: - CyberArk - LDAP - - Windows - - Minimum version required 10.4 - RADIUS + - Windows (Minimum version required 10.4) + - RADIUS - PKI - PKIPN + - FIDO2 (Minimum version required 14.4) String @@ -29459,6 +29460,18 @@ PS C:\> New-PASReportSchedule -version 1 -type 'Report' -subType 'CyberArk.Re False + + UserName + + The username for FIDO2 authentication. When using `-type FIDO2`, specify the username with this parameter (required). The username identifies the user and their registered security keys. + + String + + String + + + None + New-PASSession @@ -30024,10 +30037,11 @@ PS C:\> New-PASReportSchedule -version 1 -type 'Report' -subType 'CyberArk.Re When using the Gen2 API, specify the type of authentication to use. Valid values are: - CyberArk - LDAP - - Windows - - Minimum version required 10.4 - RADIUS + - Windows (Minimum version required 10.4) + - RADIUS - PKI - PKIPN + - FIDO2 (Minimum version required 14.4) String @@ -31163,10 +31177,11 @@ PS C:\> New-PASReportSchedule -version 1 -type 'Report' -subType 'CyberArk.Re When using the Gen2 API, specify the type of authentication to use. Valid values are: - CyberArk - LDAP - - Windows - - Minimum version required 10.4 - RADIUS + - Windows (Minimum version required 10.4) + - RADIUS - PKI - PKIPN + - FIDO2 (Minimum version required 14.4) String @@ -31452,6 +31467,18 @@ PS C:\> New-PASReportSchedule -version 1 -type 'Report' -subType 'CyberArk.Re False + + UserName + + The username for FIDO2 authentication. When using `-type FIDO2`, specify the username with this parameter (required). The username identifies the user and their registered security keys. + + String + + String + + + None + @@ -31713,6 +31740,13 @@ New-PASSession -BaseURI $url -type PKIPN -Certificate $Cert See: Get-Help IdentityCommand + + -------------------------- EXAMPLE 31 -------------------------- + New-PASSession -BaseURI https://pvwa.company.com -type FIDO2 -UserName administrator + + Authenticates to CyberArk using FIDO2/WebAuthn hardware security key authentication. + + @@ -35136,6 +35170,203 @@ Publish-PASDiscoveredAccount -id 66_6 -PlatformID WinDomain -safeName SomeSafe - + + + Register-PASFIDO2Device + Register + PASFIDO2Device + + Registers a new FIDO2 device for a user. + + + + Registers a new FIDO2 device, either on behalf of another user (admin flow) or for the currently logged-in user (self-service flow). + The cmdlet performs the full WebAuthn registration ceremony: + 1. Requests `createCredentialOptions` from the CyberArk API. 2. Invokes the Windows WebAuthn API (`webauthn.dll`) to prompt the user to interact with their FIDO2 authenticator and produce an attestation. 3. Submits the attestation back to the CyberArk API to complete registration. + Requires Windows 10 1903 or later for the WebAuthn ceremony. + Requires CyberArk version 14.6 or later. + When called without `-OwnDevice`, any user who is a member of the Vault Admins group can run this web service. + + + + Register-PASFIDO2Device + + UserId + + The ID of the user to register the FIDO2 device for. + If omitted, the device is registered against the user implied by the current session (admin endpoint). + + Int32 + + Int32 + + + 0 + + + WhatIf + + Shows what would happen if the cmdlet runs. The cmdlet is not run. + + + SwitchParameter + + + False + + + Confirm + + Prompts you for confirmation before running the cmdlet. + + + SwitchParameter + + + False + + + + Register-PASFIDO2Device + + OwnDevice + + When specified, registers the FIDO2 device against the current user via the self-service endpoint. + + + SwitchParameter + + + False + + + WhatIf + + Shows what would happen if the cmdlet runs. The cmdlet is not run. + + + SwitchParameter + + + False + + + Confirm + + Prompts you for confirmation before running the cmdlet. + + + SwitchParameter + + + False + + + + + + UserId + + The ID of the user to register the FIDO2 device for. + If omitted, the device is registered against the user implied by the current session (admin endpoint). + + Int32 + + Int32 + + + 0 + + + OwnDevice + + When specified, registers the FIDO2 device against the current user via the self-service endpoint. + + SwitchParameter + + SwitchParameter + + + False + + + WhatIf + + Shows what would happen if the cmdlet runs. The cmdlet is not run. + + SwitchParameter + + SwitchParameter + + + False + + + Confirm + + Prompts you for confirmation before running the cmdlet. + + SwitchParameter + + SwitchParameter + + + False + + + + + + + + + + + + -------------------------- Example 1 -------------------------- + PS C:\> Register-PASFIDO2Device -UserId 57 + + Registers a new FIDO2 device for the user whose ID is `57`. The user running the cmdlet must have the necessary privileges to register devices on behalf of other users. + + + + -------------------------- Example 2 -------------------------- + PS C:\> Register-PASFIDO2Device + + Registers a new FIDO2 device for the user implied by the current session, using the admin registration endpoint. + + + + -------------------------- Example 3 -------------------------- + PS C:\> Register-PASFIDO2Device -OwnDevice + + Registers a new FIDO2 device for the user that is currently logged in, using the self-service registration endpoint. + + + + + + https://pspas.pspete.dev/commands/Register-PASFIDO2Device + https://pspas.pspete.dev/commands/Register-PASFIDO2Device + + + https://docs.cyberark.com/pam-self-hosted/latest/en/content/webservices/fido2-start-registration.htm + https://docs.cyberark.com/pam-self-hosted/latest/en/content/webservices/fido2-start-registration.htm + + + https://docs.cyberark.com/pam-self-hosted/latest/en/content/webservices/fido2-register.htm + https://docs.cyberark.com/pam-self-hosted/latest/en/content/webservices/fido2-register.htm + + + https://docs.cyberark.com/pam-self-hosted/latest/en/content/webservices/fido2-self-start-registration.htm + https://docs.cyberark.com/pam-self-hosted/latest/en/content/webservices/fido2-self-start-registration.htm + + + https://docs.cyberark.com/pam-self-hosted/latest/en/content/webservices/fido2-selfregister.htm + https://docs.cyberark.com/pam-self-hosted/latest/en/content/webservices/fido2-selfregister.htm + + + Remove-PASAccount diff --git a/psPAS/psPAS.psd1 b/psPAS/psPAS.psd1 index 7fad6738..a57ec697 100644 --- a/psPAS/psPAS.psd1 +++ b/psPAS/psPAS.psd1 @@ -271,6 +271,7 @@ 'Remove-PASDependentAccount', 'Resume-PASDependentAccount', 'Remove-PASFIDO2Device', + 'Register-PASFIDO2Device', 'Get-PASMasterPolicy', 'Set-PASMasterPolicy', 'Get-PASDependentAccount',