BitwardenWrapper.psm1

enum BitwardenMfaMethod {
    Authenticator   = 0
    Email           = 1
    Yubikey         = 2
}

enum BitwardenItemType {
    Undefined       = 0
    Login           = 1
    SecureNote      = 2
    Card            = 3
    Identity        = 4
}

enum BitwardenUriMatchType {
    Domain          = 0
    Host            = 1
    StartsWith      = 2
    Exact           = 3
    Regex           = 4
    Never           = 5
}

enum BitwardenFieldType {
    Text            = 0
    Hidden          = 1
    Boolean         = 2
}

enum BitwardenOrganizationUserType {
    Owner           = 0
    Admin           = 1
    User            = 2
    Manager         = 3
}

enum BitwardenOrganizationUserStatus {
    Invited         = 0
    Accepted        = 1
    Confirmed       = 2
}

# the bw-cli version the module was built against
# NOTE: The following line is updated programatically, do not change spacing
[version] $BitwardenCLIVersion = '2024.3.1'

# NOTE: Cannot use Join-Path with more than two arguments in PowerShell < 6.0
$ModuleCacheFolder = [IO.Path]::Combine( '~', '.config', 'BitwardenWrapper' )
if ( -not( Test-Path -Path $ModuleCacheFolder ) ) {
    New-Item -Path $ModuleCacheFolder -ItemType Directory -ErrorAction Stop > $null
}

if ( -not $env:BITWARDENCLI_APPDATA_DIR ) {

    # tell bitwarden where to store data
    $env:BITWARDENCLI_APPDATA_DIR = [IO.Path]::Combine( ( $ModuleCacheFolder | Resolve-Path | Convert-Path ), 'appdata' )

}

# file indication that bitwarden is unlocked
# allows sharing lock status across sessions
$UnlockedFile = [IO.Path]::Combine( $env:BITWARDENCLI_APPDATA_DIR, '.unlocked' )

# file storing session key
$SessionXmlPath = [IO.Path]::Combine( $env:BITWARDENCLI_APPDATA_DIR, 'session.xml' )
if ( -not( Test-Path Env:\BW_SESSION ) -and ( Test-Path $SessionXmlPath -PathType Leaf ) ) {
    $env:BW_SESSION = (Import-Clixml -Path $SessionXmlPath).GetNetworkCredential().Password
}

# if a bw application exists in the Cache folder we'll use
# that, otherwise use the version that matches this module
$BitwardenCLI = Get-Command ([IO.Path]::Combine( $ModuleCacheFolder, 'bw' )) -CommandType Application -ErrorAction SilentlyContinue
if ( -not $BitwardenCLI ) {

    $Platform = 'Unsupported'
    if ( $PSVersionTable.PSVersion -lt '6.0' ) {
        # NOTE: only set on Windows PowerShell where this variable otherwise doesn't exist
        [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingCmdletAliases', '', Scope='Function')]
        $IsWindows = $true
        $Platform = 'Windows'
    }
    if ( $IsMacOS   ) { $Platform = 'MacOS'   }
    if ( $IsLinux   ) { $Platform = 'Linux'   }
    if ( $IsWindows ) { $Platform = 'Windows' }
    if ( $Platform -eq 'Unsupported' ) {
        Write-Error "You appear to be using an unsupported platform. Please manually install a bw-cli binary into $ModuleCacheFolder." -ErrorAction Stop
    }

    $DefaultPath = [IO.Path]::Combine( $ModuleCacheFolder, ( 'bw' + ('','.exe')[$IsWindows] ) )
    $TargetPath  = [IO.Path]::Combine( $ModuleCacheFolder, ( 'bw-v' + $BitwardenCLIVersion + ('','.exe')[$IsWindows] ) )

    if ( Test-Path -Path $TargetPath ) {

        $BitwardenCLI = Get-Command $TargetPath -CommandType Application -ErrorAction Stop

    } else {

        Write-Warning "Downloading Bitwarden CLI v$BitwardenCLIVersion..."

        $DownloadUri = "https://github.com/bitwarden/clients/releases/download/cli-v{0}/bw-{1}-{0}.zip" -f $BitwardenCLIVersion, $Platform.ToLower()
        $DownloadPath = [IO.Path]::Combine( $ModuleCacheFolder, 'bw-cli.zip' )
    
        Invoke-WebRequest -UseBasicParsing -Uri $DownloadUri -OutFile $DownloadPath

        Expand-Archive -Path $DownloadPath -DestinationPath $ModuleCacheFolder -Force
        Remove-Item -Path $DownloadPath -Force -Confirm:$false -ErrorAction SilentlyContinue
        Get-Item -Path $DefaultPath | Move-Item -Destination $TargetPath
        if ( $Platform -ne 'Windows' ) {
            chmod +x $TargetPath
        }
    
        $BitwardenCLI = Get-Command $TargetPath -CommandType Application -ErrorAction Stop

    }

}

New-Alias -Name 'bw-cli' -Value $BitwardenCLI.Path

[version] $BitwardenCLIVersionAvailable = & $BitwardenCLI --version
if ( $BitwardenCLIVersionAvailable -lt $BitwardenCLIVersion ) {
    Write-Warning ( 'The version of bw-cli is lower than the version tested with this module. Please update ''{0}'' to version {1}.' -f $BitwardenCLI.Path, $BitwardenCLIVersion )
}

function bw {
    <#
    .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
    #>

    process {

        [System.Collections.Generic.List[string]]$ArgumentsList = $args

        # run bw and capture result
        if ( $_ ) {
            [string[]] $Result = $_ | & $Script:BitwardenCLI @ArgumentsList
        } else {
            [string[]] $Result = & $Script:BitwardenCLI @ArgumentsList
        }

        # remove the .unlocked file if the command is lock
        # unlocked file just used to allow easy detection of
        # lock status for prompt decoration and auto locking
        # scripts
        if ( $ArgumentsList.IndexOf('lock') -ge 0 ) {
            Remove-Item $Script:UnlockedFile -ErrorAction SilentlyContinue
        }
        
        # if --raw is specified we just return the result
        # without doing any post processing
        if ( $ArgumentsList.IndexOf('--raw') -ge 0 ) { return $Result }
        
        # try to parse the result as JSON
        try {

            $DepthSplat = @{}
            if ( (Get-Command ConvertFrom-Json).Parameters.ContainsKey('Depth') ) {
                $DepthSplat.Depth = 99
            }
        
            [object[]]$JsonResult = $Result | ConvertFrom-Json @DepthSplat -ErrorAction SilentlyContinue
            
        } catch {
        
            Write-Verbose 'JSON Parse Message:'
            Write-Verbose $_.Exception.Message
        
        }

        # if parsing is successful we do some post processing
        if ( $JsonResult ) {

            Write-Verbose 'Processing JSON output...'

            $JsonResult.ForEach({

                if ( $_.type ) {
                
                    if ( $_.object -eq 'item' ) {
                        
                        [BitwardenItemType]$_.type = [int]$_.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()

                    }

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

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

                if ( $_.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

                        }
                    
                    })

                }

                if ( $_.status ) {
                    if ( $_.status -eq 'unlocked' ) {
                        New-Item $Script:UnlockedFile -Force > $null
                    } else {
                        Remove-Item $Script:UnlockedFile -ErrorAction SilentlyContinue
                    }
                }

                $_

            })

        }
        
        else {

            Write-Verbose 'Processing text output...'

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

                Write-Verbose 'Found session key, unlocking...'

                New-Item $Script:UnlockedFile -Force > $null

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

                # export session key into cache
                [pscredential]::new( 'SessionKey', ( $env:BW_SESSION | ConvertTo-SecureString -AsPlainText -Force) ) | Export-Clixml -Path $Script:SessionXmlPath

                $Result[0]

            } else {

                $Result

            }

        }

    }

}

if ( $IsWindows ) {
    New-Alias -Name 'bw.exe' -Value 'bw'
}


# Scriptblock for Test-BWAutoComplete and the argument completer
$AutoCompleteScript = {
    param( $WordToComplete, $CommandAst, $CursorPosition )

    # Instantiate the AutoComplete configuration
    # NOTE: The following line is updated programatically, do not change spacing
    $AutoCompleteJson = '{"Version":"2024.3.1","Switches":[{"Name":"--pretty","Values":null},{"Name":"--raw","Values":null},{"Name":"--response","Values":null},{"Name":"--cleanexit","Values":null},{"Name":"--quiet","Values":null},{"Name":"--nointeraction","Values":null},{"Name":"--session","Values":[""]},{"Name":"--version","Values":null},{"Name":"--help","Values":null}],"Commands":[{"Name":"login","Switches":[{"Name":"--method","Values":[0,1,3]},{"Name":"--code","Values":[""]},{"Name":"--sso","Values":null},{"Name":"--apikey","Values":null},{"Name":"--passwordenv","Values":[""]},{"Name":"--passwordfile","Values":[""]},{"Name":"--check","Values":null},{"Name":"--help","Values":null}],"Params":[{"Name":"email","Values":[""]},{"Name":"password","Values":[""]}]},{"Name":"logout","Switches":[{"Name":"--help","Values":null}],"Params":[]},{"Name":"lock","Switches":[{"Name":"--help","Values":null}],"Params":[]},{"Name":"unlock","Switches":[{"Name":"--check","Values":null},{"Name":"--passwordenv","Values":[""]},{"Name":"--passwordfile","Values":[""]},{"Name":"--help","Values":null}],"Params":[{"Name":"password","Values":[""]}]},{"Name":"sync","Switches":[{"Name":"--force","Values":null},{"Name":"--last","Values":null},{"Name":"--help","Values":null}],"Params":[]},{"Name":"generate","Switches":[{"Name":"--uppercase","Values":null},{"Name":"--lowercase","Values":null},{"Name":"--number","Values":null},{"Name":"--special","Values":null},{"Name":"--passphrase","Values":null},{"Name":"--length","Values":[""]},{"Name":"--words","Values":[""]},{"Name":"--minNumber","Values":[""]},{"Name":"--minSpecial","Values":[""]},{"Name":"--separator","Values":[""]},{"Name":"--capitalize","Values":null},{"Name":"--includeNumber","Values":null},{"Name":"--ambiguous","Values":null},{"Name":"--help","Values":null}],"Params":[]},{"Name":"encode","Switches":[{"Name":"--help","Values":null}],"Params":[]},{"Name":"config","Switches":[{"Name":"--api","Values":[""]},{"Name":"--identity","Values":[""]},{"Name":"--icons","Values":[""]},{"Name":"--notifications","Values":[""]},{"Name":"--events","Values":[""]},{"Name":"--help","Values":null}],"Params":[{"Name":"setting","Values":[""]},{"Name":"value","Values":[""]}]},{"Name":"update","Switches":[{"Name":"--help","Values":null}],"Params":[]},{"Name":"completion","Switches":[{"Name":"--shell","Values":"zsh"},{"Name":"--help","Values":null}],"Params":[]},{"Name":"status","Switches":[{"Name":"--help","Values":null}],"Params":[]},{"Name":"serve","Switches":[{"Name":"--hostname","Values":[""]},{"Name":"--port","Values":[""]},{"Name":"--help","Values":null}],"Params":[]},{"Name":"list","Switches":[{"Name":"--search","Values":[""]},{"Name":"--url","Values":[""]},{"Name":"--folderid","Values":[""]},{"Name":"--collectionid","Values":[""]},{"Name":"--organizationid","Values":[""]},{"Name":"--trash","Values":null},{"Name":"--help","Values":null}],"Params":[{"Name":"object","Values":["items","folders","collections","org-collections","org-members","organizations"]}]},{"Name":"get","Switches":[{"Name":"--itemid","Values":[""]},{"Name":"--output","Values":[""]},{"Name":"--organizationid","Values":[""]},{"Name":"--help","Values":null}],"Params":[{"Name":"object","Values":["item","username","password","uri","totp","notes","exposed","attachment","folder","collection","org-collection","organization","template","fingerprint","send"]},{"Name":"id","Values":[""]}]},{"Name":"create","Switches":[{"Name":"--file","Values":[""]},{"Name":"--itemid","Values":[""]},{"Name":"--organizationid","Values":[""]},{"Name":"--help","Values":null}],"Params":[{"Name":"object","Values":["item","attachment","folder","org-collection"]},{"Name":"encodedJson","Values":[""]}]},{"Name":"edit","Switches":[{"Name":"--organizationid","Values":[""]},{"Name":"--help","Values":null}],"Params":[{"Name":"object","Values":["item","item-collections","folder","org-collection"]},{"Name":"id","Values":[""]},{"Name":"encodedJson","Values":[""]}]},{"Name":"delete","Switches":[{"Name":"--itemid","Values":[""]},{"Name":"--organizationid","Values":[""]},{"Name":"--permanent","Values":null},{"Name":"--help","Values":null}],"Params":[{"Name":"object","Values":["item","attachment","folder","org-collection"]},{"Name":"id","Values":[""]}]},{"Name":"restore","Switches":[{"Name":"--help","Values":null}],"Params":[{"Name":"object","Values":["item"]},{"Name":"id","Values":[""]}]},{"Name":"move","Switches":[{"Name":"--help","Values":null}],"Params":[{"Name":"id","Values":[""]},{"Name":"organizationId","Values":[""]},{"Name":"encodedJson","Values":[""]}]},{"Name":"confirm","Switches":[{"Name":"--organizationid","Values":[""]},{"Name":"--help","Values":null}],"Params":[{"Name":"object","Values":["org-member"]},{"Name":"id","Values":[""]}]},{"Name":"import","Switches":[{"Name":"--formats","Values":null},{"Name":"--organizationid","Values":[""]},{"Name":"--help","Values":null}],"Params":[{"Name":"format","Values":[""]},{"Name":"input","Values":[""]}]},{"Name":"export","Switches":[{"Name":"--output","Values":[""]},{"Name":"--format","Values":["csv","json"]},{"Name":"--password","Values":[""]},{"Name":"--organizationid","Values":[""]},{"Name":"--help","Values":null}],"Params":[]},{"Name":"share","Switches":[{"Name":"--help","Values":null}],"Params":[{"Name":"id","Values":[""]},{"Name":"organizationId","Values":[""]},{"Name":"encodedJson","Values":[""]}]},{"Name":"send","Switches":[{"Name":"--file","Values":null},{"Name":"--deleteInDays","Values":[""]},{"Name":"--maxAccessCount","Values":[""]},{"Name":"--hidden","Values":null},{"Name":"--name","Values":[""]},{"Name":"--notes","Values":[""]},{"Name":"--fullObject","Values":null},{"Name":"--help","Values":null}],"Params":[{"Name":"command","Values":[""]},{"Name":"data","Values":[""]}]},{"Name":"receive","Switches":[{"Name":"--password","Values":[""]},{"Name":"--passwordenv","Values":[""]},{"Name":"--passwordfile","Values":[""]},{"Name":"--obj","Values":null},{"Name":"--output","Values":[""]},{"Name":"--help","Values":null}],"Params":[{"Name":"url","Values":[""]}]},{"Name":"help","Switches":[{"Name":"--pretty","Values":null},{"Name":"--raw","Values":null},{"Name":"--response","Values":null},{"Name":"--cleanexit","Values":null},{"Name":"--quiet","Values":null},{"Name":"--nointeraction","Values":null},{"Name":"--session","Values":[""]},{"Name":"--version","Values":null},{"Name":"--help","Values":null}],"Params":[]}]}'
    $AutoCompleteConfiguration = $AutoCompleteJson | ConvertFrom-Json -Depth 99

    # 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 = Invoke-Expression "&{`$args} $ArgumentsList"

    # filter for returned values
    $MatchFilter = { $_ -notin $ArgumentsArray -and $_ -like "$WordToComplete*" }

    # check for the current command, returns first command that appears in the
    # $ArgumentsArray ignoring parameters any other strings
    $CurrentCommand = $AutoCompleteConfiguration.Commands | Where-Object { $ArgumentsArray -contains $_.Name }
    $CurrentSwitch = $null

    # if there are elements in the ArgumentsArray get the current switch argument
    # if it requires a value be provided
    if ( $ArgumentsArray.Count -ne 0 ) {
        
        if ( $CurrentCommand ) {
            $CurrentSwitch = $CurrentCommand.Switches | Where-Object { $_.Name -eq $ArgumentsArray[-1] -and $null -ne $_.Values } | Select-Object -First 1
        } else {
            $CurrentSwitch = $AutoCompleteConfiguration.Switches | Where-Object { $_.Name -eq $ArgumentsArray[-1] -and $null -ne $_.Values } | Select-Object -First 1
        }

        # if the last argument is a switch with a completer then we do that
        if ( $CurrentSwitch ) {
            if ( $CurrentSwitch.Values ) {
                return $CurrentSwitch.Values |
                    Where-Object $MatchFilter
            }
            return $WordToComplete
        }

    }

    # 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 $AutoCompleteConfiguration.Commands.Name + $AutoCompleteConfiguration.Switches.Name |
            Where-Object $MatchFilter

    }

    # if the last complete argument is the command name and there are params for this
    # command then we want to force parameter configuration
    [string[]] $NonSwitchParamsAfterCommand = $ArgumentsArray | Select-Object -Skip ( $ArgumentsArray.IndexOf($CurrentCommand.Name) + 1 ) | Where-Object { $_ -notlike '--*' }
    if ( $WordToComplete -notlike '-*' -and $NonSwitchParamsAfterCommand.Count -lt $CurrentCommand.Params.Count ) {
        $CurrentParam = $CurrentCommand.Params | Select-Object -Skip $NonSwitchParamsAfterCommand.Count -First 1
        if ( $CurrentParam.Values ) {
            return $CurrentParam.Values |
                Where-Object $MatchFilter
        }
        return $WordToComplete
    }

    # if we get here then all we have left is switches for the current command
    return $CurrentCommand.Switches.Name |
        Where-Object $MatchFilter

}

Register-ArgumentCompleter -CommandName bw -ScriptBlock $AutoCompleteScript
Register-ArgumentCompleter -CommandName bw-cli -ScriptBlock $AutoCompleteScript

# testing script, not exported by default
New-Item -Path Function:\Test-BWAutoComplete -Value $AutoCompleteScript

function Get-BWCredential {
    
    param(

        [Parameter( Position = 1)]
        [Alias( 'UserName', 'Email' )]
        [string]
        $Search,

        [string]
        $Url,

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

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

    if ( $Search ) {
        $SearchParams.Add( '--search' )
        $SearchParams.Add( $Search )
    }

    if ( $Url ) {
        $SearchParams.Add( '--url' )
        $SearchParams.Add( $Url )
    }

    [object[]]$Result = bw @SearchParams | Where-Object { $_.login.credential }

    if ( $Result.Count -eq 0 ) {
        Write-Error 'No results returned'
        return
    }

    if ( $Result.Count -gt 1 -and $MultipleAction -eq 'Error' ) {
        Write-Error 'Multiple entries returned'
        return
    }
    
    $Result | Select-BWCredential

}

function Select-BWCredential {
    <#
    .SYNOPSIS
    Select a credential from those returned from the Bitwarden CLI
     
    .DESCRIPTION
    Select a credential from those returned from the Bitwarden CLI
    #>

    param(

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

    )

    begin {

        $ChooserProperties = @(
            @{ Name = ' '     ; Expression = { '{0,3})' -f $Result.IndexOf($_)            } }
            @{ Name = 'Name'     ; Expression = { $_.name                                    } }
            @{ Name = 'UserName' ; Expression = { $_.login.username                          } }
            @{ Name = 'Uri'      ; Expression = { $_.login.uris.uri | Select-Object -First 1 } }
        )

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

    }

    process {

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

    }

    end {

        if ( $Result.Count -eq 0 ) {
            Write-Error 'No results returned'
            return
        }
        
        if ( $Result.Count -gt 1 ) {
    
            $Result | Select-Object $ChooserProperties | Format-Table | Out-Host
            
            $Selection = ( Read-Host 'Selection' ) -as [int]
    
        }
        else {

            $Selection = 0
        
        }
    
        if ( $null -eq $Result[$Selection] ) { return }

        $Credential = $Result[$Selection].login.credential

        if ( -not [string]::IsNullOrEmpty( $Result[$Selection].login.totp ) ) {
            $Credential | Add-Member -MemberType ScriptProperty -Name TOTP -Value ([scriptblock]::Create("bw get totp $($Result[$Selection].id) --raw"))
        }
    
        return $Result[$Selection].login.credential

    }

}