BitwardenWrapper.psm1

using module '.\classes\BitwardenEnums.psm1'
using module '.\classes\BitwardenPasswordHistory.psm1'

[version]$SupportedVersion = '1.16'

# check if we should use a specific bw.exe
if ( $env:BITWARDEN_CLI_PATH ) {

    $BitwardenCLI = Get-Command $env:BITWARDEN_CLI_PATH -CommandType Application -ErrorAction SilentlyContinue

} else {

    $BitwardenCLI = Get-Command -Name bw.exe -CommandType Application -ErrorAction SilentlyContinue

}

if ( -not $BitwardenCLI ) {

    Write-Warning 'No Bitwarden CLI found in your path, either specify $env:BITWARDEN_CLI_PATH or put bw.exe in your path. You can use Install-BitwardenCLI to install to C:\Windows\System32'

}

if ( $BitwardenCLI -and $BitwardenCLI.Version -lt $SupportedVersion ) {

    Write-Warning "Your Bitwarden CLI is version $($BitwardenCLI.Version) and out of date, please upgrade to at least version $SupportedVersion."

}


$__Commands = @{
    login          = '--raw --method --code --sso --check --help'
    logout         = '--help'
    lock           = '--help'
    unlock         = '--check --raw --help'
    sync           = '--force --last --help'
    list           = '--search --url --folderid --collectionid --organizationid --trash --help'
    get            = '--itemid --output --organizationid --help'
    create         = '--file --itemid --organizationid --help'
    edit           = '--organizationid --help'
    delete         = '--itemid --organizationid --permanent --help'
    restore        = '--help'
    share          = '--help'
    confirm        = '--organizationid --help'
    import         = '--formats --help'
    export         = '--output --format --organizationid --help'
    generate       = '--uppercase --lowercase --number --special --passphrase --length --words --separator --help'
    encode         = '--help'
    config         = '--web-vault --api --identity --icons --notifications --events --help'
    update         = '--raw --help'
    completion     = '--shell --help'
    status         = '--help'
    send           = '--file --deleteInDays --hidden --name --notes --fullObject --help'
}

$__CommandAutoComplete = @{
    list           = 'items folders collections organizations org-collections org-members'
    get            = 'item username password uri totp exposed attachment folder collection org-collection organization template fingerprint send'
    create         = 'item attachment folder org-collection'
    edit           = 'item item-collections folder org-collection'
    delete         = 'item attachment folder org-collection'
    restore        = 'item'
    confirm        = 'org-member'
    import         = '1password1pif 1passwordwincsv ascendocsv avastcsv avastjson aviracsv bitwardencsv bitwardenjson blackberrycsv blurcsv buttercupcsv chromecsv clipperzhtml codebookcsv dashlanejson encryptrcsv enpasscsv enpassjson firefoxcsv fsecurefsk gnomejson kasperskytxt keepass2xml keepassxcsv keepercsv lastpasscsv logmeoncecsv meldiumcsv msecurecsv mykicsv operacsv padlockcsv passboltcsv passkeepcsv passmanjson passpackcsv passwordagentcsv passwordbossjson passworddragonxml passwordwallettxt pwsafexml remembearcsv roboformcsv safeincloudxml saferpasscsv securesafecsv splashidcsv stickypasswordxml truekeycsv upmcsv vivaldicsv yoticsv zohovaultcsv'
    config         = 'server'
    template       = 'item item.field item.login item.login.uri item.card item.identity item.securenote folder collection item-collections org-collection'
    send           = 'list template get receive create edit remove-password delete'
    '--method'     = '0 1 3'
    '--format'     = 'csv json'
    '--shell'      = 'zsh'
}

$__CommonParams    = '--pretty --raw --response --quiet --nointeraction --session --version --help'

$__HasCompleter    = 'list get create edit delete restore confirm import config send ' +     # commands with auto-complete
                     'template ' +                                                      # template options
                     '--session ' +                                                     # provide session variable
                     '--method --code ' +                                               # login
                     '--search --url --folderid --collectionid --organizationid ' +     # list
                     '--itemid --output ' +                                             # get
                     '--format ' +                                                      # export
                     '--length --words --separator ' +                                  # generate
                     '--web-vault --api --identity --icons --notifications --events ' + # config
                     '--shell ' +                                                       # completion
                     '--file --deleteInDays --name --notes'                             # send


<#
.SYNOPSIS
 Helper function to install bw.exe to $env:windir\system32
 
.DESCRIPTION
 Helper function to install bw.exe to $env:windir\system32
 
.PARAMETER Force
 Install even if bw.exe is present
#>

function Install-BitwardenCLI {

    param( [switch]$Force )

    $ErrorActionPreference = 'Stop'

    if ( -not [environment]::Is64BitOperatingSystem ) {

        Write-Error "Cannot install on 32-bit OS"
        return

    }

    if ( -not $Force -and ( $bw = Get-Command -Name bw.exe -CommandType Application -ErrorAction SilentlyContinue ) ) {

        Write-Warning "Bitwarden CLI already installed to $($bw.Path), use -Force to install anyway"
        return

    }

    $TempPath = New-TemporaryFile | ForEach-Object { Rename-Item -Path $_.FullName -NewName $_.Name.Replace( $_.Extension, '.zip' ) -PassThru }

    Invoke-WebRequest -UseBasicParsing -Uri 'https://vault.bitwarden.com/download/?app=cli&platform=windows' -OutFile $TempPath.FullName

    Expand-Archive -Path $TempPath -DestinationPath $env:TEMP -Force

    Start-Process -FilePath powershell.exe -ArgumentList "-NoProfile -NonInteractive -NoExit -Command ""Move-Item -Path '$env:TEMP\bw.exe' -Destination '$env:windir\System32\bw.exe' -Confirm:`$false -Force""" -Verb RunAs -Wait

    $Script:BitwardenCLI = Get-Command "$env:windir\System32\bw.exe" -CommandType Application -ErrorAction Stop

}

<#
.SYNOPSIS
 The Bitwarden command-line interface (CLI) is a powerful, fully-featured tool for accessing and managing your Vault.
 
.DESCRIPTION
 The Bitwarden command-line interface (CLI) is a powerful, fully-featured tool for accessing and managing your Vault.
 Most features that you find in other Bitwarden client applications (Desktop, Browser Extension, etc.) are available
 from the CLI. The Bitwarden CLI is self-documented. From the command line, learn about the available commands using:
 bw --help
 
#>

function Invoke-BitwardenCLI {

    begin {

        if ( -not $BitwardenCLI ) {

            Write-Error "Bitwarden CLI is not installed!"
            return

        }

    }

    process {

        [System.Collections.Generic.List[string]]$ArgumentsList = $args | ForEach-Object {

            Write-Verbose "Argument: $_"

            return $_
        
        }

        if ( ( $ArgumentsList.Contains('unlock') -or $ArgumentsList.Contains('login') ) -and $ArgumentsList.Contains('--raw') ) {

            $ArgumentsList.RemoveAt( $ArgumentsList.IndexOf('--raw') )

        }

        if ( $input.Count -gt 0 ) {

            Write-Verbose "Pipleine input detected"

            $EncodedInput = ConvertTo-BWEncoding -InputObject $input

            if ( $ArgumentsList.Contains('encode') ) {

                return $EncodedInput

            } else {

                $ArgumentsList.Add( $EncodedInput )

            }

        }

        [string[]]$Result = & $BitwardenCLI @ArgumentsList

        if ( $ArgumentsList.IndexOf('--raw') -gt 0 ) { return $Result }

        try {
        
            [object[]]$JsonResult = $Result | ConvertFrom-Json -ErrorAction SilentlyContinue
            
        } catch {
        
            Write-Verbose "JSON Parse Message:"
            Write-Verbose $_.Exception.Message
        
        }

        if ( $JsonResult -is [array] ) {

            $JsonResult.ForEach({

                if ( $_.type ) {
                
                    if ( $_.object -eq 'item' ) {
                        
                        [BitwardenItemType]$_.type = [int]$_.type

                        $_.PSObject.TypeNames.Insert( 0, 'Bitwarden.' + $_.type )
                    
                    } elseif ( $_.object -eq 'org-member' ) {
                        
                        [BitwardenOrganizationUserType]$_.type = [int]$_.type
                        [BitwardenOrganizationUserStatus]$_.status = [int]$_.status

                    }

                }

                if ( $_.login ) {

                    if ( $null -ne $_.login.password ) {

                        $_.login.password = ConvertTo-SecureString -String $_.login.password -AsPlainText -Force

                    } else {

                        $_.login.password = [System.Security.SecureString]::new()

                    }

                    if ( $_.login.username -and $_.login.password ) {

                        $_.login | Add-Member -MemberType NoteProperty -Name credential -Value ([pscredential]::new( $_.login.username, $_.login.password ))

                    }

                    $_.login.uris.ForEach({ [BitwardenUriMatchType]$_.match = [int]$_.match })
                
                }

                if ( $_.passwordHistory ) {

                    [BitwardenPasswordHistory[]]$_.passwordHistory = $_.passwordHistory

                    <#$_.passwordHistory.ForEach({
             
                        $_.password = ConvertTo-SecureString -String $_.password -AsPlainText -Force
                         
                    })#>


                }

                if ( $_.identity.ssn ) {

                    $_.identity.ssn = ConvertTo-SecureString -String $_.identity.ssn -AsPlainText -Force
                    
                }

                if ( $_.fields ) {

                    $_.fields.ForEach({

                        [BitwardenFieldType]$_.type = [int]$_.type

                        if ( $_.type -eq [BitwardenFieldType]::Hidden ) {

                            $_.value = ConvertTo-SecureString -String $_.value -AsPlainText -Force

                        }
                    
                    })

                }

                $_

            })

        } else {

            # look for session key
            if ( $Result -and $Result[-1] -like '*--session*' ) {

                $env:BW_SESSION = $Result[-1].Trim().Split(' ')[-1]
                return $Result[0]

            } else {

                return $Result

            }

        }

    }

}

New-Alias -Name 'bw.exe' -Value 'Invoke-BitwardenCLI'
New-Alias -Name 'bw' -Value 'Invoke-BitwardenCLI'

$BitwardenCLIArgumentCompleter = {

    param(
        $WordToComplete,
        $CommandAst,
        $CursorPosition
    )

    function ConvertTo-ArgumentsArray {

        function __args { $args }

        Invoke-Expression "__args $args"

    }

    $InformationPreference = 'Continue'

    # trim off the command name and the $WordToComplete
    $ArgumentsList = $CommandAst -replace '^bw(.exe)?\s+' -replace "\s+$WordToComplete$"

    # split the $ArgumentsList into an array
    [string[]]$ArgumentsArray = ConvertTo-ArgumentsArray $ArgumentsList

    # check for the current command, returns first command that appears in the
    # $ArgumentsArray ignoring parameters any other strings
    $CurrentCommand = $ArgumentsArray |
        Where-Object { $_ -in $__Commands.Keys } |
        Select-Object -First 1

    # if the $ArgumentsArray is empty OR there is no $CurrentCommand then we
    # output all of the commands and common parameters that match the last
    # $WordToComplete
    if ( $ArgumentsArray.Count -eq 0 -or -not $CurrentCommand ) {

        return $__Commands.Keys + $__CommonParams.Split(' ') |
            Where-Object { $_ -notin $ArgumentsArray } |
            Where-Object { $_ -like "$WordToComplete*" }
    
    }

    # if the last complete argument has auto-complete options then we output
    # the auto-complete option that matches the $LastChunk
    if ( $ArgumentsArray[-1] -in $__HasCompleter.Split(' ') ) {

        # if the last complete argument exists in the $__CommandAutoComplete
        # hashtable keys then we return the options
        if ( $ArgumentsArray[-1] -in $__CommandAutoComplete.Keys ) {

            return $__CommandAutoComplete[ $ArgumentsArray[-1] ].Split(' ') |
                Where-Object { $_ -like "$WordToComplete*" }

        }
    
        # if it doesn't have a key then we just want to pause for user input
        # so we return an empty string. this pauses auto-complete until the
        # user provides input.
        else {
    
            return @( '' )

        }

    }

    # finally if $CurrentCommand is set and the current option doesn't have
    # it's own auto-complete we return the remaining options in the current
    # command's auto-complete list
    return $__Commands[ $CurrentCommand ].Split(' ') |
        Where-Object { $_ -notin $ArgumentsArray } |
        Where-Object { $_ -like "$WordToComplete*" }

}

Register-ArgumentCompleter -CommandName 'Invoke-BitwardenCLI' -ScriptBlock $BitwardenCLIArgumentCompleter
Register-ArgumentCompleter -CommandName 'bw' -ScriptBlock $BitwardenCLIArgumentCompleter
Register-ArgumentCompleter -CommandName 'bw.exe' -ScriptBlock $BitwardenCLIArgumentCompleter

<#
.SYNOPSIS
 Retrieve a credential from the Bitwarden CLI
 
.DESCRIPTION
 Retrieve a credential from the Bitwarden CLI
#>

function Get-BWCredential {

    param(

        [Parameter( Position = 1)]
        [string]
        $UserName,

        [string]
        $Url,

        [ValidateSet( 'Choose', 'Error' )]
        [string]
        $MultipleAction = 'Error'

    )

    [System.Collections.Generic.List[string]]$SearchParams = 'list', 'items'

    if ( $UserName ) {

        $SearchParams.Add( '--search' )
        $SearchParams.Add( $UserName )

    }

    if ( $Url ) {

        $SearchParams.Add( '--url' )
        $SearchParams.Add( $Url )

    }

    $Result = Invoke-BitwardenCLI @SearchParams | Where-Object { $_.login.credential }

    if ( -not $Result ) {

        Write-Error 'No results returned'
        return

    }

    if ( $Result.Count -gt 1 -and $MultipleAction -eq 'Error' ) {

        Write-Error 'Multiple entries returned'
        return

    }

    if ( $Result.Count -gt 1 ) {

        return $Result | Select-BWCredential

    }

    return $Result.login.credential

}

<#
.SYNOPSIS
 Select a credential from those returned from the Bitwarden CLI
 
.DESCRIPTION
 Select a credential from those returned from the Bitwarden CLI
#>

function Select-BWCredential {

    param(

        [Parameter( Mandatory = $true, ValueFromPipeline = $true )]
        [pscustomobject[]]
        $BitwardenItems

    )

    begin {

        [System.Collections.ArrayList]$LoginItems = @()

    }

    process {

        $BitwardenItems.Where({ $_.login }) | ForEach-Object { $LoginItems.Add($_) > $null }

    }

    end {

        if ( $LoginItems.Count -eq 0 ) {

            Write-Warning 'No login found!'
            return

        }

        if ( $LoginItems.Count -eq 1 ) {

            return $LoginItems.login.Credential

        }

        $SelectedItem = $LoginItems |
            Select-Object Id, Name, @{N='UserName';E={$_.login.username}}, @{N='PrimaryURI';E={$_.login.uris[0].uri}} |
            Out-GridView -Title 'Choose Login' -OutputMode Single

        return $LoginItems.Where({ $_.Id -eq $SelectedItem.Id }).login.Credential

    }

}

<#
.SYNOPSIS
 Base64 encodes an object for Bitwarden CLI
 
.DESCRIPTION
 Base64 encodes an object for Bitwarden CLI
#>

function ConvertTo-BWEncoding {

    [CmdletBinding()]
    param(

        [Parameter( Mandatory, Position = 0, ValueFromPipeline )]
        [object]
        $InputObject

    )

    process {

        if ( $InputObject -isnot [string] ) {

            try {

                $InputObject | ConvertFrom-Json > $null
                Write-Verbose 'Object is already a JSON string'

            } catch {

                Write-Verbose 'Converting object to JSON'
                $InputObject = ConvertTo-Json -InputObject $InputObject -Compress

            }

        }

        try {

            [convert]::FromBase64String( $InputObject ) > $null

            Write-Verbose 'Object is already Base64 encoded'
            return $InputObject

        } catch {

            Write-Verbose 'Converting JSON to Base64 encoding'
            return [convert]::ToBase64String( [System.Text.Encoding]::UTF8.GetBytes( $InputObject ) )

        }
            
    }

}