Lastpass-PS.psm1

# Lastpass Powershell Module
# Copyright (C) 2020 Steven Loudermilk
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.

Using Namespace System.Security.Cryptography

Param(
    [ValidateScript({
        $Schema = @{
            ExportWriteCmdlets = 'Boolean'
            Debug = 'Boolean'
        }
        $_.GetEnumerator() | ForEach {
            If($_.Key -notin $Schema.Keys){
                Throw "Unknown module parameter: $($_.Key)"
            }
            If($_.Value -isnot $Schema[$_.Key]){
                Throw "Parameter '$($_.Key)' should be of type: [$($Schema[$_.Key])]"
            }
        }
        Return $True
    })]
    [HashTable] $ModuleParameters = @{}
)

$Script:Interactive = [Environment]::UserInteractive -and
    !([Environment]::GetCommandLineArgs() -like '-NonI*')
$Script:Epoch = [DateTime] '1970-01-01 00:00:00'
$Script:Schema = @{

    Account = @{
        Fields = [Ordered] @{
            ID = 'String'
            Name = 'Encrypted'
            Folder = 'Encrypted'
            URL = 'Hex'
            Notes = 'Encrypted'
            Favorite = 'Boolean'
            SharedFromAID = 'String' #?
            Username = 'Encrypted'
            Password = 'Encrypted'
            PasswordProtect = 'Boolean'
            GeneratedPassword = 'Boolean' #?
            SecureNote = 'Boolean' #?
            LastAccessed = 'Date'
            AutoLogin = 'Boolean' #?
            NeverAutofill = 'Boolean' #?
            RealmData = 'String' #?
            FIID = 'Skip' #?
            CustomJS = 'Skip' #?
            SubmitID = 'Skip' #?
            CaptchaID = 'Skip' #?
            URID = 'Skip' #?
            BasicAuth = 'Boolean' #?
            Method = 'Skip' #?
            Action = 'Skip'
            GroupID = 'String' #?
            Deleted = 'Boolean' #?
            AttachmentKey = 'String'
            AttachmentPresent = 'Boolean'
            IndividualShare = 'Boolean' #?
            NoteType = 'String' #?
            NoAlert = 'String' #?
            LastModifiedGMT = 'Date' #?
            HasBeenShared = 'Boolean' #?
            LastPasswordChange = 'Date' #?
            DateCreated = 'Date' #?
            Vulnerable = 'String' # JSON of exposure info
        }
        DefaultFields = @(
            'Name'
            'Username'
            'Folder'
            'Favorite'
        )
    }
    SecureNote = @{
        Fields = @(
            'ID'
            'Name'
            'Folder'
            'NoteType'
            'Notes'
            'AttachmentPresent'
            'AttachmentKey'
            'PasswordProtect'
            'Favorite'
            'Deleted'
            'HasBeenShared'
            'FIID'
            'DateCreated'
            'LastAccessed'
            'LastModifiedGMT'
            'LastPasswordChange'
            'ShareID'
        )
        DefaultFields = @(
            'Name'
            'Folder'
            'Favorite'
        )
        Types = @{
            Address                = "Address"
            Bank                = "Bank Account"
            Credit                = "Credit Card"
            Database            = "Database"
            DriversLicense        = "Driver's License"
            Email                = "Email Account"
            Generic                = "Generic"
            HealthInsurance        = "Health Insurance"
            IM                    = "Instant Messenger"
            Insurance            = "Insurance"
            Membership            = "Membership"
            Passport            = "Passport"
            Server                = "Server"
            SSN                    = "Social Security"
            SoftwareLicense        = "Software License"
            SSHKey                = "SSH Key"
            Wifi                = "Wi-Fi Password"
            Custom                = "Custom"
        }
    }
    Folder = @{
        Fields = @(
            'ID'
            'Name'
            'FIID'
            'DateCreated'
            'LastAccessed'
            'LastModifiedGMT'
            'LastPasswordChange'
            'ShareID'
        )
        DefaultFields = @(
            'Name'
            'LastModifiedGMT'
            'LastPasswordChange'
        )
    }
    SharedFolder = @{
        Fields = [Ordered] @{
            ID = 'String'
            RSAEncryptedFolderKey = 'Hex'
            Name = 'String'
            ReadOnly = 'Boolean'
            Give = 'Boolean' #?
            AESFolderKey = 'String'
        }
        DefaultFields = @(
            'Name'
            'ReadOnly'
        )
    }
    FormField = @{
        Fields = [Ordered] @{
            Name = 'String'
            Type = 'String'
            Value = 'Other'
            Checked = 'Boolean'
        }
        DefaultFields = @(
            'Name'
            'Type'
            'Value'
            'Checked'
        )
    }
    Attachment = @{
        Fields = [Ordered] @{
            ID = 'String'
            Parent = 'String'
            MIMEType = 'String'
            StorageKey = 'String'
            Size = 'String'
            FileName = 'Encrypted'
        }
        DefaultFields = @(
            'FileName'
            'MIMEType'
            'Size'
        )
    }
}

$Schema.GetEnumerator() | ForEach {
    $Param = @{
        TypeName = "Lastpass.$($_.Key)"
        DefaultDisplayPropertySet = $_.Value.DefaultFields
        Force = $True
    }
    Update-TypeData @Param
}

$Script:Session
$Script:Blob
$Script:WebSession
[TimeSpan] $Script:PasswordTimeout = New-Timespan
$Script:PasswordPrompt

Function Connect-Lastpass {

    <#
    .SYNOPSIS
    Logs in to Lastpass
 
    .DESCRIPTION
    Creates an authenticated session with the Lastpass service.
    If app based multifactor authentication is setup for the account,
    prompts for the one time password if it is not passed as a parameter.
 
    .PARAMETER Credential
    The Lastpass account credential
 
    .PARAMETER OneTimePassword
    The one time password generated by an multifactor authentication
    app, such as Google authenticator.
    If the account does is not setup for app based MFA, this
    parameter is ignored.
 
    .PARAMETER SkipSync
    If specified, the sync of account data on successful login will be skipped.
 
    .EXAMPLE
    Connect-Lastpass -Credential (Get-Credential)
 
    Logs in to Lastpass, prompting for the username and password
 
    .EXAMPLE
    Connect-Lastpass -Credential $Credential -OneTimePassword 158320
 
    Logs in to Lastpass, with the credentials saved in the $Credential
    variable. Includes the one time password.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
        'PSAvoidUsingPlainTextForPassword',
        'OneTimePassword',
        Justification='One time password can be sent in cleartext'
    )]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
        'PSAvoidUsingWriteHost', '',
        Justification = 'Message is for user interaction,
            code checks whether there is an interactive prompt,
            and it is designed to use -noNewLine'

    )]
    [CmdletBinding()]
    Param(
        [Parameter(Mandatory)]
        [PSCredential] $Credential,

        [String] $OneTimePassword,
        [Switch] $SkipSync
    )

    $Param = @{
        URI = 'https://lastpass.com/iterations.php'
        Body = @{email = $Credential.Username.ToLower()}
    }
    "Iterations parameters:`n{0}" -f ($Param.Body | Out-String) | Write-Debug
    [Int] $Iterations = Invoke-RestMethod @Param
    Write-Debug "Iterations: $Iterations"

    $Key = New-Key -Credential $Credential -Iterations $Iterations
    $Hash = New-LoginHash -Key $Key -Credential $Credential -Iterations $Iterations

    $Param = @{
        URI = 'https://lastpass.com/login.php'
        Method = 'Post'
        Body = @{
            xml                        = '2'
            username                = $Credential.Username.ToLower()
            hash                    = $Hash
            iterations                = "$Iterations"
            includeprivatekeyenc    = '1'
            method                    = 'cli'
            outofbandsupported        = '1'
            #UUID = Get-Random # Gen random?
        }
        SessionVariable = 'WebSession'
    }
    "Login parameters:`n{0}" -f ($Param.Body | Out-String) | Write-Debug
    $Response = (Invoke-RestMethod @Param).Response

    #TODO: Change this to While($Response.Error)?
    If($Response.Error){
        "Error received:`n{0}" -f $Response.Error.OuterXML | Write-Debug
        Switch -Regex ($Response.Error.Cause){
            OutOfBandRequired {
                $Type = $Response.Error.OutOfBandName
                $Capabilities = $Response.Error.Capabilities -split ','
                If(!$Type -or !$Capabilities){ Throw 'Could not determine out-of-band type' }

                $Param.Body.outofbandrequest = 1
                $Prompt = "Complete multifactor authentication through $Type"
                If($Capabilities -contains 'Passcode' -and $Interactive -and !$OneTimePassword ){
                    $Prompt += ' or enter a one time passcode: '
                    Write-Host -NoNewLine $Prompt
                    Do {
                        $Response = (Invoke-RestMethod @Param).Response
                        If($Response.Error.Cause -eq 'OutOfBandRequired'){
                            $Param.Body.outofbandretry = 1
                            $Param.Body.outofbandretryid = $Response.Error.RetryID

                            While([Console]::KeyAvailable){
                                $NextInput = [Console]::ReadKey($True)
                                Write-Debug ("Key: {0} {1}" -f $NextInput.Key, ($NextInput.Key -eq 'Enter'))
                                If( $NextInput.Key -eq 'Enter' ){
                                    Write-Debug $OneTimePassword
                                    $Param2 = $Param.Clone()
                                    $Param2.Body.outofbandrequest = 0
                                    $Param2.Body.outofbandretry = 0
                                    $Param2.Body.outofbandretryid = ''
                                    $Param2.Body.otp = $OneTimePassword
                                    $Param2.Body | Out-String | Write-Debug
                                    $Response = (Invoke-RestMethod @Param2).Response
                                    $OneTimePassword = $Null
                                    Break
                                }
                                $OneTimePassword += $NextInput.KeyChar
                            }
                        }
                        ElseIf($Response.Error.Cause -eq 'MultiFactorResponseFailed'){
                            Throw $Response.Error.Message
                        }
                        Start-Sleep 1
                    }Until($Response.OK)
                }
                ElseIf($Capabilities -notcontains 'Passcode' -or !$Interactive){
                    Write-Host -NoNewLine $Prompt
                    Do {
                        $Response = (Invoke-RestMethod @Param).Response
                        If($Response.Error.Cause -eq 'OutOfBandRequired'){
                            $Param.Body.outofbandretry = 1
                            $Param.Body.outofbandretryid = $Response.Error.RetryID
                        }
                        ElseIf($Response.Error.Cause -eq 'MultiFactorResponseFailed'){
                            Throw $Response.Error.Message
                        }
                        Start-Sleep 1
                    }Until($Response.OK)

                }
            }
            {$_ -in 'GoogleAuthRequired', 'OTPRequired' -or ($_ -eq 'OutOfBandRequired' -and $OneTimePassword)} {
                If(!$OneTimePassword){
                    If(!$Interactive){
                        Throw ('Powershell is running in noninteractive mode. ' +
                            'Enter the one time password via the -OneTimePassword parameter.')
                    }
                    $OneTimePassword = Read-Host 'Enter multifactor authentication code'
                }
                $Param.Body.otp = $OneTimePassword
                $Response = (Invoke-RestMethod @Param).Response

                # TODO: Error checking
                #'multifactorresponsefailed'
            }
            #'verifydevice' -> Default: Throw message
            # Parse custombutton and customaction attributes
            Default { Throw $Response.Error.Message }
        }
    }
    $Response.OK | Out-String | Write-Debug
    If(!$Response.OK){ Throw 'Login unsuccessful' }

    $Script:Session = [PSCustomObject] @{
        UID            = $Response.OK.UID
        SessionID    = $Response.OK.SessionID
        Token        = $Response.OK.Token
        PrivateKey    = [RSAParameters]::New()
        Iterations    = $Response.OK.Iterations
        Username    = $Response.OK.LPUsername
        Key            = $Key
    }

    If($Response.OK.PrivateKeyEnc){
        If($Response.OK.PrivateKeyEnc[0] -eq '!'){
            Write-Debug 'Version 2 private key encoding'
            $DecryptedKey = [Convert]::FromBase64String($Response.OK.PrivateKeyEnc) -join '' |
                ConvertFrom-LPEncryptedData
        }
        Else{
            Write-Debug 'Version 1 private key encoding'
            $DecryptedKey = '!{0}{1}' -f @(
                ([char[]] $S.Session.Key -join ''),
                (([Char[]] ($Response.OK.PrivateKeyEnc | ConvertFrom-Hex)) -join '')
            ) | ConvertFrom-LPEncryptedData
        }

        If(!$DecryptedKey){
            Write-Warning 'Failed to decrypt private key'
        }
        ElseIf($DecryptedKey -notmatch '^.*ey<(.*)>LastPassPrivateKey$'){
            Write-Warning 'Failed to decode decrypted private key'
        }
        Else{
            $ASN1 = $Matches[1] | ConvertFrom-Hex
            Write-Debug "ASN1 Length: $($ASN1.Length)"
            # This is a ASN.1 encoding, do basic parsing
            $Sequence = (Read-ASN1Item -Blob $ASN1).Value
            Write-Debug "Sequence Parsed. Length: $($Sequence.Length)"
            $Index = 0
            1..2 | ForEach { Write-Debug "$_"; $Index = (Read-ASN1Item -Blob $Sequence -Index $Index).Index }
            Write-Debug "Sequence 2 Index: $Index"
            $Sequence2 = (Read-ASN1Item -Blob $Sequence -Index $Index).Value
            Write-Debug "Sequence 2 Parsed. Length: $($Sequence2.Length)"

            $Sequence3 = (Read-ASN1Item -Blob $Sequence2).Value
            Write-Debug "Sequence 3 Parsed. Length: $($Sequence3.Length)"

            $Index = (Read-ASN1Item -Blob $Sequence3).Index

            # RSAParameters is a struct, so have to create a populated
            # copy and then assign the entire struct at once.
            $Parameters = @{}

            'Modulus',
            'Exponent',
            'D',
            'P',
            'Q',
            'DP',
            'DQ',
            'InverseQ' | ForEach {
                Write-Debug $_
                $ASN1Item = Read-ASN1Item -Blob $Sequence3 -Index $Index
                $ASN1Item.Value -is [Array] | Write-Debug
                $Index = $ASN1Item.Index
                $ByteIndex = 0
                # This is hacky, but I can't get it to treat a single byte value as an array
                If($ASN1Item.Value -is [Array]){
                    While($ASN1Item.Value[$ByteIndex] -eq 0){
                        write-debug 'skipping 0';
                        $ByteIndex++
                    }
                    "Indices: {0}, {1}" -f $ByteIndex, ($ASN1Item.Value.Length -1) | Write-Debug
                    $Parameters[$_] = $ASN1Item.Value[$ByteIndex..($ASN1Item.Value.Length -1)]
                }
                Else{
                    $Parameters[$_] = $ASN1Item.Value
                }
            }

            $Parameters | Out-String | Write-Debug

            # New-Object seems to be required to set struct members at creation
            $Session.PrivateKey = New-Object RSAParameters -Property $Parameters
        }
    }

    $Cookie = [System.Net.Cookie]::New(
        'PHPSESSID',
        [System.Web.HttpUtility]::UrlEncode($Script:Session.SessionID),
        '/',
        'lastpass.com'
    )
    $Script:WebSession = [Microsoft.Powershell.Commands.WebRequestSession]::New()
    $Script:WebSession.Cookies.Add($Cookie)
    If(!$?){ Throw 'Unable to create session' }

    If(!$SkipSync){ Sync-Lastpass | Out-Null }

    If($PSBoundParameters.Debug){ Return $Response }

    [PSCustomObject] @{
        Email = $Credential.Username
        SessionID = $Script:Session.SessionID
    } | Write-Output

}



Function Disconnect-Lastpass {
    <#
    .SYNOPSIS
    Ends Lastpass session
 
    .DESCRIPTION
    Calls the logout API and clears the local session
    Does not currently support saving local copy of vault
 
    .EXAMPLE
    Disconnect-Lastpass
 
    Ends the current Lastpass session
 
    #>


    [CmdletBinding()]
    Param()

    $Param = @{
        Method = 'Post'
        URI = 'https://lastpass.com/logout.php'
        WebSession = $Script:WebSession
        Body = @{
            method = 'cli'
            noredirect = '1'
            token = $Session.Token
        }
    }
    Invoke-RestMethod @Param | Out-Null

    $Script:Session = $Null
    $Script:Blob = $Null
    $Script:WebSession = $Null
    $Script:PasswordTimeout = New-Timespan
    $Script:PasswordPrompt = $Null
}



Function Sync-Lastpass {

    <#
    .SYNOPSIS
    Downloads Lastpass accounts from the server
 
    .DESCRIPTION
    Updates (overwrites) the local cache of Lastpass items with the latest version on the server.
    Decrypts the names of the items for later retrieval.
 
    .EXAMPLE
    Sync-LastpassBlob
 
    Downloads the Lastpass accounts from the server
 
    #>


    [CmdletBinding()]
    Param()

    If(!$Script:Session){ Throw 'User session not found. Log in with Connect-Lastpass' }
    Write-Verbose 'Syncing Lastpass information'

    $Param = @{
        WebSession = $Script:WebSession
        URI = 'https://lastpass.com/getaccts.php'
        Body = @{
            requestsrc = 'cli'
            mobile = '1'
            hasplugin = '3.0.23'
        }
        ErrorAction = 'Stop'
    }
    "Sync parameters:`n{0}" -f ($Param.Body | Out-String) | Write-Debug
    $Response = Invoke-RestMethod @Param

    If($Response.Error){ Throw $Response.Error.Cause }
    If($PSBoundParameters.Debug){ Return $Response }
    #"Response:`n{0}" -f $Response | Write-Debug
    # Return ([char[]][Convert]::FromBase64String($Response)) -join ''
    $Response = [Byte[]][Char[]] $Response

    #TODO: Cleanup debug output.
    # Wrap parse in try/catch and provide info in catch error
    Write-Verbose 'Parsing data'
    $Index = 0
    $Script:Blob = @{
        Metadata        = @{}
        Accounts        = @()
        SecureNotes        = @()
        Folders            = @()
        SharedFolders    = @()
    }
    While($Index -lt ($Response.Length-8)){
        $Type = ([Char[]] $Response[$Index..($Index+=3)]) -join ''
        Write-Debug "Type: $Type"
        Write-Debug "Index: $Index"
        $Data = Read-Item -Blob $Response -Index ($Index+=1) -Debug:$False
        $Index += $Data.Length + 4
        Write-Debug "After index: $Index"

        If(!$Blob.Metadata[$Type]){ $Blob.Metadata[$Type] = 1}
        Else{ $Blob.Metadata[$Type] += 1 }

        If($Type -eq 'ENDM' -and (([Char[]] $Data) -join '') -eq 'OK'){ Break }

        $ItemIndex = 0
        $Param = @{}
        Switch($Type){
            LPAV { $Blob.Version = [Char[]] $Data -join '' }
            ACCT {
                Write-Debug "BEGIN ACCOUNT DECODE"
                $Account = @{ PSTypeName = 'Lastpass.Account' }
                If($Blob.SharedFolders[-1].Key){ $Param = @{ Key = $Blob.SharedFolders[-1].Key } }
                'Param: {0}' -f ($Param | Out-String) | Write-Debug
                $Schema.Account.Fields.Keys | ForEach {
                    Write-Debug "Field: $_"
                    $Field = $_
                    $Item = Read-Item -Blob $Data -Index $ItemIndex -Debug:$False
                    $ItemIndex += $Item.length + 4
                    #'Returned length: {0}' -f $Item.Length | Write-Debug
                    $Account[$Field] = Switch($Schema.Account.Fields[$Field]){
                        Encrypted {
                            # The name and group are sent encrypted, but are generally needed
                            # for organizing and finding the accounts, so they are decrypted here.
                            If($Field -in 'Name','Folder'){
                                [Char[]] $Item -join '' | ConvertFrom-LPEncryptedData @Param
                            }
                            Else{ ConvertTo-LPEncryptedString @Param -Bytes $Item }
                        }
                        #TODO: See if there are cleaner ways to do these conversions
                        Hex        { [Char[]] ([Char[]] $Item -join '' | ConvertFrom-Hex) -join '' }
                        Boolean    { !!([Int] ([Char[]] $Item -join '')) }
                        Date    { $Epoch.AddSeconds([Char[]] $Item -join '') }
                        Default    { If($Item){[Char[]] $Item -join ''} }
                    }
                    Write-Debug "End Field $_"
                }

                If($Account.Folder -in '(none)', ''){ $Account.Folder = $Null }

                If($Blob.SharedFolders[-1]){
                    If($Account.Folder){
                        $Account.Folder = '{0}\{1}'-f $Blob.SharedFolders[-1].Name, $Account.Folder
                    }
                    Else{ $Account.Folder = $Blob.SharedFolders[-1].Name }
                    $Account.ShareID = $Blob.SharedFolders[-1].ID
                }

                Switch($Account.URL){
                    'http://sn' {
                        Write-Debug 'Item is Secure note'
                        $Account.Keys.Where({$_ -notin $Schema.SecureNote.Fields }) |
                            ForEach { $Account.Remove($_) }
                        $Account.PSTypeName = 'Lastpass.SecureNote'
                        If($Account.AttachmentPresent){

                            $Account.AttachmentKey = ConvertTo-LPEncryptedString @Param -Bytes (
                                [Byte[]] [Char[]] $Account.AttachmentKey
                            )
                        }
                        $Blob.SecureNotes += $Account
                    }
                    'http://group' {
                        Write-Debug 'Item is folder'
                        $Account.Name = $Account.Folder
                        $Account.Keys.Where({$_ -notin $Schema.Folder.Fields}) |
                            ForEach { $Account.Remove($_) }
                        $Account.PSTypeName = 'Lastpass.Folder'
                        $Blob.Folders += $Account
                    }
                    Default {
                        $Blob.Accounts += $Account
                    }
                }

                Write-Debug "END ACCOUNT DECODE"
            }
            {$_ -in 'ACFL','ACOF'} {
                Write-Debug 'BEGIN FORMFIELD DECODE'
                If(!$Blob.Accounts[-1]){ Write-Error 'Parse failed. Unable to find account for form fields' }
                If(!$Blob.Accounts[-1].FormFields){ $Blob.Accounts[-1].FormFields = @() }
                $FormField = [Ordered] @{}

                $Schema.FormField.Fields.Keys | ForEach {
                    Write-Debug "Field: $_"
                    $Item = Read-Item -Blob $Data -Index $ItemIndex -Debug:$False
                    'Returned length: {0}' -f $Item.Length | Write-Debug
                    $ItemIndex += $Item.length + 4

                    $FormField[$_] = Switch($Schema.FormField.Fields[$_]){
                        Boolean { !!([Int] ([Char[]] $Item -join '')) }
                        String { If($Item){[Char[]] $Item -join ''} }
                        Default { $Item }
                    }
                    Write-Debug "End Field $_"
                }
                Switch -Regex ($FormField.Type){
                    'email|tel|text|password|textarea' {
                        $FormField.Value = ConvertTo-LPEncryptedString @Param -Bytes $FormField.Value
                    }
                    Default { $FormField.Value = [Char[]] $FormField.Value -join '' }
                }
                $Blob.Accounts[-1].FormFields += $FormField
                Write-Debug 'END FORMFIELD DECODE'
            }
            ATTA {
                Write-Debug 'BEGIN ATTACHMENT DECODE'

                $Attachment = @{ PSTypeName = 'Lastpass.Attachment' }
                $Schema.Attachment.Fields.Keys | ForEach {
                    Write-Debug "Field: $_"

                    $Item = Read-Item -Blob $Data -Index $ItemIndex -Debug:$False
                    'Returned length: {0}' -f $Item.Length | Write-Debug
                    $ItemIndex += $Item.length + 4
                    $Attachment[$_] = Switch($Schema.Attachment.Fields[$_]){
                        Encrypted { ConvertTo-LPEncryptedString @Param -Bytes $Item }
                        String { If($Item){[Char[]] $Item -join ''} }
                    }

                    Write-Debug "End Field: $_"
                }
                $SecureNote = $Blob.SecureNotes | Where ID -eq $Attachment.Parent
                If(!$SecureNote){
                    "Unable to find Secure Note for attachment {0}`n{1}" -f @(
                        $Attachment.ID
                        $Attachment | Out-String
                    ) | Write-Warning
                }
                If(!$SecureNote.Attachments){ $SecureNote.Attachments = @() }
                $SecureNote.Attachments += $Attachment

                Write-Debug 'END ATTACHMENT DECODE'
            }
            SHAR {
                Write-Debug "BEGIN SHARE DECODE"
                $Folder = @{ PSTypeName = 'Lastpass.SharedFolder' }
                $Schema.SharedFolder.Fields.Keys | ForEach {
                    Write-Debug "Field: $_"
                    $Item = Read-Item -Blob $Data -Index $ItemIndex -Debug:$False
                    'Returned length: {0}' -f $Item.Length | Write-Debug
                    $ItemIndex += $Item.length + 4
                    $Folder[$_] = Switch($Schema.SharedFolder.Fields[$_]){
                        String    { If($Item){[Char[]] $Item -join ''} }
                        Boolean    { !!([Int] ([Char[]] $Item -join '')) }
                        Int        { [Int] ([Char[]] $Item -join '') }
                        Hex        { [Char[]] $Item -join '' | ConvertFrom-Hex }
                        Default { $Item }
                    }
                    Write-Debug "End Field $_"
                }

                If(!$Folder.AESFolderKey -or !$Folder.RSAEncryptedFolderKey){
                    'Share key not found for ID: {0}' -f $Folder.ID | Write-Warning
                }

                If($Folder.AESFolderKey){
                    $Folder.Key = $Folder.AESFolderKey |
                        ConvertFrom-LPEncryptedData |
                        ConvertFrom-Hex
                }
                Else{
                    $RSA = [RSACryptoServiceProvider]::New()
                    $RSA.ImportParameters($Script:Session.PrivateKey)
                    $Folder.Key = $RSA.Decrypt($Folder.RSAEncryptedFolderKey, $True) -join ''
                }
                $Folder.Name = $Folder.Name | ConvertFrom-LPEncryptedData -Base64 -Key $Folder.Key

                $Blob.SharedFolders += [PSCustomObject] $Folder
                Write-Debug "END SHARE DECODE"
            }
            Default {
                If($Blob.ContainsKey($Type)){ $Blob[$Type] += $Data }
                Else{ $Blob[$Type] = @($Data) }
            }
        }
    }

    $Script:LastSyncTime = Get-Date
    $Script:Blob = [PSCustomObject] $Script:Blob
    If($PSBoundParameters.Debug){ Write-Output $Script:Blob }

}



Function Get-Account {
    <#
    .SYNOPSIS
    Returns one or more Lastpass accounts/sites
 
    .DESCRIPTION
    Long description
 
    .PARAMETER Name
    The name of the account to return
 
    .EXAMPLE
    Get-Account
 
    Returns a list of all account IDs and names
 
    .EXAMPLE
    Get-Account -Name 'Email'
 
    Returns all accounts named 'Email'
    #>


    [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
        'PSAvoidUsingConvertToSecureStringWithPlainText', '',
        Justification = 'Information has already been intentionally decrypted for output'
    )]
    [CmdletBinding()]
    Param(
        [Parameter(
            ValueFromPipeline,
            ValueFromPipelineByPropertyName
        )]
        [String[]] $Name
    )

    BEGIN {
        If(!$Script:Session){ Throw 'User session not found. Log in with Connect-Lastpass' }
        $IDs = @()
    }

    PROCESS {
        If(!$Name){ Return $Script:Blob.Accounts | Select ID, Name }
        $Name | ForEach {
            $Script:Blob.Accounts | Where Name -eq $_ | Where ID -NotIn $IDs | ForEach {
                If($_.PasswordProtect){ Confirm-Password }

                $Account = @{}
                $Param = @{}
                If($_.ShareID){
                    $Param.Key = $Blob.SharedFolders |
                        Where ID -eq $_.ShareID |
                        ForEach Key
                }

                $_.GetEnumerator() | ForEach {
                    If($_.Key -eq 'FormFields'){
                        $Account.FormFields = @()
                        $_.Value | ForEach {
                            $_ | Out-String | Write-Debug
                            Write-Debug "FormField: $($_.Name)"
                            $_.Value | Out-String | Write-Debug
                            $Field = @{
                                PSTypeName = 'Lastpass.FormField'
                                Name = $_.Name
                                Type = $_.Type
                                Value = $_.Value
                                Checked = $_.Checked
                            }
                            If($_.Value -is [SecureString]){
                                $Param.SecureString = $_.Value
                                $Field.Value = ConvertFrom-LPEncryptedData @Param
                            }
                            $Account.FormFields += [PSCustomObject] $Field
                        }
                    }
                    ElseIf($_.Value -is [SecureString]){
                        $Param.SecureString = $_.Value
                        $Account[$_.Key] = ConvertFrom-LPEncryptedData @Param
                    }
                    Else{ $Account[$_.Key] = $_.Value }
                }

                $Credential = @{ Username = $Account.Username }
                If($Account.Password){
                    [SecureString] $Credential.Password = $Account.Password |
                        ConvertTo-SecureString -AsPlainText -Force
                }
                Else{ $Credential.Password = [SecureString]::Empty }

                $Account.Credential = [PSCredential]::New([PSCustomObject] $Credential)

                $Account.LastAccessed = [DateTime]::Now
                [PSCustomObject] $Account | Write-Output
                $IDs += $Account.ID
            }
        }
    }
}



Function Set-Account {
    <#
    .SYNOPSIS
    Updates a Lastpass Account
 
    .DESCRIPTION
    Sets the properties of a Lastpass account.
    Does a full overwrite (ie. any parameters not included will be
    deleted if they existed as part of the account previously)
 
    .PARAMETER Account
    The Lastpass account to update
 
    .PARAMETER Name
    The name of the account
 
    .PARAMETER URL
    The URL of the account
 
    .PARAMETER Credential
    The account credentials
 
    .PARAMETER Notes
    The notes tied to the account
 
    .PARAMETER FormFields
    The account form fields
 
    .PARAMETER PasswordProtect
    Whether to require a password reprompt to access the account
 
    .PARAMETER Favorite
    Whether the account is marked as a favorite
 
    .PARAMETER AutoLogin
    If set, the Lastpass browser plugin will automatically
    fill and submit the login on the account's website
 
    .PARAMETER DisableAutofill
    If set, the Lastpass browser plugin will not autofill the account on the website
 
    .EXAMPLE
    Set-Account -ID 10248 -Name 'NewName'
 
    Sets the account with ID 10248 to have the name 'NewName'.
    Note that any username, password, notes, or other properties of the account will be overwritten.
 
    .EXAMPLE
    Get-Account 'Email' | Set-Account -PasswordProtect
 
    Gets the account named 'Email', and passes it to Set-Account to update the account to require
    a password to access. Passing in an account object will include all of the existing properties,
    so Set-Account will effectively perform an update, only overwriting the parameters explicitly
    passed in.
    #>


    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    Param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [PSTypeName('Lastpass.Account')] $Account,

        [Parameter(ValueFromPipelineByPropertyName)]
        [String] $Name,

        [Parameter(ValueFromPipelineByPropertyName)]
        [String] $URL,

        [Parameter(ValueFromPipelineByPropertyName)]
        [PSCredential] $Credential,

        [Parameter(ValueFromPipelineByPropertyName)]
        [String] $Notes,

        [Parameter(ValueFromPipelineByPropertyName)]
        [PSTypeName('Lastpass.FormField')] $FormFields,

        [Parameter(ValueFromPipelineByPropertyName)]
        [Switch] $PasswordProtect,

        [Parameter(ValueFromPipelineByPropertyName)]
        [Switch] $Favorite,

        [Parameter(ValueFromPipelineByPropertyName)]
        [Switch] $AutoLogin,

        [Parameter(ValueFromPipelineByPropertyName)]
        [Switch] $DisableAutofill
    )

    If(!$Script:Session){ Throw 'User session not found. Log in with Connect-Lastpass' }

    "Set-Account called with parameters:`n{0}" -f ($PSBoundParameters | Out-String) | Write-Debug
    If($FormFields){ Throw 'Updating accounts with form fields not supported currently' }

    $Param = @{
        ID                = $Account.ID
        Name            = $Name
        Folder            = $Account.Folder
        URL                = $URL
        Credential        = $Credential
        Notes            = $Notes
        FormFields        = $FormFields
        PasswordProtect    = $PasswordProtect
        Favorite        = $Favorite
        AutoLogin        = $AutoLogin
        DisableAutofill    = $DisableAutofill
    }
    If($Account.ShareID){ $Param.ShareID = $Account.ShareID }


    "Calling Set-Item with parameters:`n{0}" -f ($Param | Out-String) | Write-Debug
    Set-Item @Param
}



Function Get-Note {
    <#
    .SYNOPSIS
    Returns Lastpass Notes
 
    .DESCRIPTION
    Parses and decrypts Lastpass Notes.
    Returns a list of all notes if no name is specified, or specific notes if the name is specified.
    Supports password protection.
 
    .PARAMETER Name
    The name of the note(s) to retrieve. If no name is specified, all notes are returned.
 
    .EXAMPLE
    Get-Note
 
    Returns a list of all notes in the Lastpass account.
    The returned objects do not have decrypted content.
 
    .EXAMPLE
    Get-Note 'Bank PIN'
 
    Returns all notes called 'Bank PIN', prompting for the password if the note is password protected.
    #>


    [CmdletBinding()]
    Param(
        [Parameter(
            ValueFromPipeline,
            ValueFromPipelineByPropertyName
        )]
        [String[]] $Name
    )
    BEGIN {
        If(!$Script:Session){ Throw 'User session not found. Log in with Connect-Lastpass' }
        $IDs = @()
    }
    PROCESS {
        If(!$Name){ Return $Script:Blob.SecureNotes | Select ID, Name }
        $Name | ForEach {
            $Script:Blob.SecureNotes | Where Name -eq $_ | Where ID -NotIn $IDs | ForEach {
                If($_.PasswordProtect){ Confirm-Password }

                $Note = @{}
                $Param = @{}
                If($_.ShareID){
                    $Param.Key = $Blob.SharedFolders |
                        Where ID -eq $_.ShareID |
                        ForEach Key
                }
                If($_.AttachmentKey){
                    $AttachmentKey = $_.AttachmentKey |
                        ConvertFrom-LPEncryptedData @Param -Base64 |
                        ConvertFrom-Hex
                }
                $_.GetEnumerator() | Where Key -ne 'AttachmentKey' | ForEach {
                    'Key: {0}' -f $_.Key | Write-Debug
                    If($_.Key -eq 'Attachments'){
                        $Note.Attachments = $_.Value | ForEach {
                            [PSCustomObject] @{
                                PStypeName = 'Lastpass.Attachment'
                                ID = $_.ID
                                MIMEType = $_.MIMEType
                                StorageKey = $_.StorageKey
                                Size = $_.Size
                                FileName = $_.FileName |
                                    ConvertFrom-LPEncryptedData -Key $AttachmentKey -Base64
                            }
                        }
                    }
                    ElseIf($_.Value -is [SecureString]){
                        $Note[$_.Key] = $_.Value | ConvertFrom-LPEncryptedData @Param
                    }
                    Else{ $Note[$_.Key] = $_.Value }
                }

                If(
                    $Note.Notes -match ('^NoteType:(.*)') -and (
                        $Matches[1] -in $Schema.SecureNote.Types.Values -or
                        $Matches[1] -match '^Custom_(\d+)$'
                    )
                ){
                    'Custom Note: {0}' -f $Matches[1] | Write-Debug
                    $Notes = [Ordered] @{}
                    $Note.Notes -split "`n" | ForEach {
                        If(($Split = $_.IndexOf(':')) -ge 1){
                            $Key = $_.Substring(0,$Split)
                            $Notes[$Key] = $_.Substring(($Split+1))
                        }
                        Else{ $Notes[$Key] += "`n$_" }
                    }
                    $Note.Notes = $Notes
                }
                $Note.LastAccessed = [DateTime]::Now
                [PSCustomObject] $Note | Write-Output
                $IDs += $Note.ID
            }
        }
    }
}



Function Set-Note {
    <#
    .SYNOPSIS
    Updates a Lastpass Note
 
    .DESCRIPTION
    Sets the properties of a Lastpass note.
    Does a full overwrite (ie. any parameters not included will be
    deleted if they existed as part of the note previously)
 
    .PARAMETER Note
    The Lastpass secure note to update
 
    .PARAMETER Name
    The name of the note
 
    .PARAMETER Notes
    The content of the note
 
    .PARAMETER PasswordProtect
    Whether to require a password reprompt to access the note
 
    .PARAMETER Favorite
    Whether the note is marked as a favorite
 
    .EXAMPLE
    Set-Note -ID 10248 -Name 'NewName'
 
    Sets the note with ID 10248 to have the name 'NewName'.
    Note that any note content, folder, or other properties of the note will be overwritten.
 
    .EXAMPLE
    Get-Note 'SecretCrush' | Set-Note -PasswordProtect
 
    Gets the note named 'SecretCrush', and passes it to Set-Note to update the note to require
    a password to access. Passing in a note object will include all of the existing properties,
    so Set-Note will effectively perform an update, only overwriting the parameters explicitly
    passed in.
    #>


    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    Param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [PSTypeName('Lastpass.SecureNote')] $Note,

        [Parameter(
            Mandatory,
            ValueFromPipelineByPropertyName
        )]
        [String] $Name,

        [Parameter(ValueFromPipelineByPropertyName)]
        [Object] $Notes,

        [Parameter(ValueFromPipelineByPropertyName)]
        [Switch] $PasswordProtect,

        [Parameter(ValueFromPipelineByPropertyName)]
        [Switch] $Favorite
    )

    If(!$Script:Session){ Throw 'User session not found. Log in with Connect-Lastpass' }

    $Param = @{
        ID                = $Note.ID
        Name            = $Name
        Folder            = $Note.Folder
        Notes            = $Notes
        PasswordProtect    = $PasswordProtect
        Favorite        = $Favorite
    }

    If($Note.ShareID){ $Param.ShareID = $Note.ShareID }

    If($Notes -is [Collections.Specialized.OrderedDictionary]){
        $Param.Notes = ''
        $Notes.GetEnumerator() | ForEach {
            $Param.Notes += "{0}:{1}`n" -f $_.Key, $_.Value
        }
    }
    Set-Item @Param

}



Function Get-Attachment {
    <#
    .SYNOPSIS
    Gets a Secure Note attachment
 
    .DESCRIPTION
    Long description
 
    .PARAMETER Attachment
    The attachment metadata object
 
    .PARAMETER FilePath
    The path to save the attachment to.
    If the specified path is a directory, the attachment
    filename will be appended to the path automatically
 
    .PARAMETER Force
    If specified, the function will overwrite an existing file at the specified path
    By default if a file exists at the specified path, a confirmation prompt to overwrite is shown
 
    .EXAMPLE
    $Note = Get-Note 'AttachmentNote'
    Get-Attachment $Note.Attachments[0] $env:HOME/Downloads/secretfile.txt
 
    Downloads the first attachment of the 'AttachmentNote' secure note
    and saves it to the Downloads folder in the user's home directory with the name 'secretfile.txt'
    If a file already exists at that path, the function will prompt whether to overwrite
 
    .EXAMPLE
    New-Item -ItemType Directory Attachments -Force
    $Note = Get-Note 'AttachmentNote'
    $Note.Attachments | Get-Attachment -FilePath ./Attachments -Force
 
    Downloads all of the attachments of the 'AttachmentNote' secure note
    to the Attachments directory in the current directory.
    Each attachment is saved with it's respective original filename in Lastpass
    Any existing files are overwritten without confirmation
    #>


    [CmdletBinding()]
    Param(
        [Parameter(ValueFromPipeline)]
        [Alias('AttachmentMetadata')]
        [PSTypeName('Lastpass.Attachment')] $Attachment,

        [Parameter(Mandatory)]
        [ValidateScript({ Test-Path -IsValid $_ })]
        [String] $FilePath,

        [Switch] $Force
    )

    If(!$Script:Session){ Throw 'User session not found. Log in with Connect-Lastpass' }

    $NoteID = $Attachment.ID -split '-' | Select -First 1
    $Note = Get-Note | Where ID -eq $NoteID | Get-Note
    If(!$Note.AttachmentKey){ Throw 'Unable to find attachment key' }
    $Param = @{
        URI = 'https://lastpass.com/getattach.php'
        WebSession = $WebSession
        Method = 'Post'
        Body = @{
            token = $Session.Token
            getattach = $Attachment.StorageKey
        }
    }

    If($Note.ShareID){ $Param.Body.sharedfolderid = $Note.ShareID }

    Try{ $Content = Invoke-RestMethod @Param }
    Catch{ Throw "Failed to download attachment from Lastpass server`n{0}" -f $_ }

    # May need to unescape backslashes in response
    Try{
        $Content = $Content | ConvertFrom-LPEncryptedData -Base64 -Key $Note.AttachmentKey
        $Content = [Convert]::FromBase64String($Content)
    }
    Catch{ Throw "Failed to decrypt attachment`n{0}" -f $_ }


    If(Test-Path -PathType Container $FilePath){
        $FilePath = Join-Path $FilePath $Attachment.FileName
    }

    If(!$Force -and (Test-Path $FilePath)){
        Do{
            Switch(Read-Host ('Overwrite File {0}? (y/N)' -f $FilePath)){
                Y { $Break = $True }
                N { Return }
                '' { Return }
            }
        }While(!$Break)
    }

    Set-Content -Path $FilePath -Value $Content -AsByteStream
    Get-Item $FilePath | Write-Output
}



Function New-Password {
    <#
    .SYNOPSIS
    Generates a new cryptographically random password
 
    .DESCRIPTION
    Uses the Security.Cryptography.RNGCryptoServiceProvider class to generate random characters.
    By default it varies the length of the password to between 19 and 37 characters, to further
    randomize the output. Allows for specifying preset character sets of allowed characters,
    or specifying valid or invalid characters using regular expression set notation. The default
    output is a SecureString object; you can use the -AsPlainText parameter to output a string.
 
    .PARAMETER Length
    The length of the password
    By default, the length will be between 19 and 37 characters
 
    .PARAMETER InvalidCharacters
    The sets of invalid characters.
    Specify a regular expression character set.
 
    .PARAMETER ValidCharacters
    The sets of invalid characters.
    Specify a regular expression character set.
 
    .PARAMETER CharacterSet
    The preset character set of valid characters.
 
    .PARAMETER AsPlainText
    If set to true, the password will be output in plaintext instead of a securestring
 
    .EXAMPLE
    New-Password
 
    Generates a new random password
 
    .EXAMPLE
    New-Password -AsPlainString
 
    Generates a new random password output as a plaintext string
    By default, New-Password outputs a SecureString object
 
    .EXAMPLE
    New-Password -Length 25
 
    Generates a random 25 character password
 
    .EXAMPLE
    New-Password -InvalidCharacters "a-c\[\]\\\-"
 
    Generates a new random password without the characters a, b, c, [, ], \, or -
    This example shows the regex set notation, and the characters that need to be escaped with a
    preceding '\'
 
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
        'PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Does not change system state'
    )]
    [CmdletBinding(DefaultParameterSetName = 'InvalidCharacters')]
    Param(
        [Int] $Length,

        [Parameter(ParameterSetName = 'InvalidCharacters')]
        [String] $InvalidCharacters,

        [Parameter(ParameterSetName = 'ValidCharacters')]
        [String] $ValidCharacters,

        [ValidateSet(
            'Alphanumeric',
            'Alphabetic',
            'UpperCase',
            'LowerCase',
            'Numeric',
            'XML',
            'Base64'
        )]
        [Parameter(ParameterSetName = 'CharacterSet')]
        [String] $CharacterSet,

        [Switch] $AsPlainText
    )

    $ValidCharacterSet = ("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" +
                            "0123456789``~!@#$%^&*()-_=+[{]}\|;:'`",<.>/? ")
    $CharacterSets = @{
        Alphanumeric = '^A-Za-z0-9'
        Alphabetic     = '^A-Za-z'
        UpperCase     = '^A-Z'
        LowerCase     = '^a-z'
        Numeric         = '^0-9'
        Base64         = '^A-Za-z0-9+/='
        XML             = "<>&`"'"
    }
    $RNG = [RNGCryptoServiceProvider]::New()
    $Bytes = [Byte[]]::New(4)

    Switch($PSCmdlet.ParameterSetName){
        InvalidCharacters { $Filter = "[$InvalidCharacters]" }
        ValidCharacters { $Filter = "[^$ValidCharacters]" }
        CharacterSet { $Filter = "[{0}]" -f $CharacterSets[$CharacterSet] }
    }
    If($Filter -notin $Null,'[]'){
        $ValidCharacterSet = $ValidCharacterSet -creplace $Filter
        "ValidCharacterSet: $ValidCharacterSet" | Write-Debug
        If(!$ValidCharacterSet.Length){ Throw 'No valid characters for generating password' }
    }

    If(!$Length){
        # Arbitrary numbers are arbitrary
        $MinLength = 19
        $MaxLength = 37
        $RNG.GetBytes($Bytes);
        $RandomNumber = [BitConverter]::ToUInt32($Bytes,0) % ($MaxLength - $MinLength + 1)
        $Length = $RandomNumber + $MinLength
    }

    $Password = 1..$Length | ForEach {
        $RNG.GetBytes($Bytes)
        $RandomNumber = [BitConverter]::ToUInt32($Bytes,0) % $ValidCharacterSet.Length
        $ValidCharacterSet[$RandomNumber]
    }

    If($AsPlainText){ $Password -join '' | Write-Output }
    Else{
        $SecurePassword = [SecureString]::New()
        0..($Password.Length-1) | ForEach {
            $SecurePassword.AppendChar($Password[$_])
            $Password[$_] = $Null
        }
        Write-Output $SecurePassword
    }

}



<#
New-Account {}
 
 
Remove-Account {}
 
 
New-Note {}
 
 
Remove-Note {}
 
 
New-Folder {}
 
 
Get-Folder {}
 
 
Set-Folder {}
    -Sharing
 
 
Remove-Folder {}
 
 
Reset-MasterPassword {}
 
 
Move-Folder?
 
 
#>


Function Set-Item {
    <#
    .SYNOPSIS
    Updates a Lastpass Item
 
    .DESCRIPTION
    Sets the properties of a Lastpass account, secure note, or folder.
    All of these items are saved as account objects in Lastpass.
    Does a full overwrite (ie. any parameters not included will be
    deleted if they existed as part of the item previously)
 
    .PARAMETER ID
    The ID of the item
 
    .PARAMETER Name
    The name of the item
 
    .PARAMETER SecureNote
    If set, the item is a secure note
 
    .PARAMETER Folder
    The directory path that contains the item
 
    .PARAMETER ShareID
    The ID of the share that contains the item
 
    .PARAMETER URL
    The URL of the item,
    If secure note, this is set to 'http://sn'
 
    .PARAMETER Credential
    The username of the account
 
    .PARAMETER Notes
    The item's notes
 
    .PARAMETER FormFields
    The item's formfields
 
    .PARAMETER PasswordProtect
    Whether to require a password reprompt to access the item
 
    .PARAMETER Favorite
    Whether the item is marked as a favorite
 
    .PARAMETER AutoLogin
    If set, the Lastpass browser plugin will automatically
    fill and submit the login on the account's website
 
    .PARAMETER DisableAutofill
    If set, the Lastpass browser plugin will not autofill the account on the website
 
    .EXAMPLE
    Set-Item -ID 10248 -Name 'NewName'
 
    Sets the account with ID 10248 to have the name 'NewName'.
    Note that any username, password, notes, or other properties of the account will be overwritten.
 
    .EXAMPLE
    Get-Account 'Email' | Set-Item -PasswordProtect
 
    Gets the account named 'Email', and passes it to Set-Item to update the account to require
    a password to access. Passing in an account object will include all of the existing properties,
    so Set-Item will effectively perform an update, only overwriting the parameters explicitly
    passed in.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
        'PSAvoidOverwritingBuiltInCmdlets', '',
        Justification = 'Private function'
    )]
    [CmdletBinding(
        SupportsShouldProcess,
        ConfirmImpact = 'High',
        DefaultParameterSetName = 'Account'
    )]
    Param(
        [Parameter(
            Mandatory,
            ValueFromPipelineByPropertyName
        )]
        [String] $ID,

        [Parameter(
            Mandatory,
            ValueFromPipelineByPropertyName
        )]
        [String] $Name,

        [Parameter(
            ParameterSetName='SecureNote',
            ValueFromPipelineByPropertyName
        )]
        [Switch] $SecureNote,

        [Parameter(ValueFromPipelineByPropertyName)]
        [String] $Folder,

        [Parameter(ValueFromPipelineByPropertyName)]
        [String] $ShareID,

        [Parameter(
            ParameterSetName='Account',
            ValueFromPipelineByPropertyName
        )]
        [String] $URL,

        [Parameter(
            ParameterSetName='Account',
            ValueFromPipelineByPropertyName
        )]
        [PSCredential] $Credential,

        [Parameter(ValueFromPipelineByPropertyName)]
        [Alias('Content','Extra')]
        [String] $Notes,

        [Parameter(
            ParameterSetName='Account',
            ValueFromPipelineByPropertyName
        )]
        [PSTypeName('Lastpass.FormField')] $FormFields,

        [Parameter(ValueFromPipelineByPropertyName)]
        [Switch] $PasswordProtect,

        [Parameter(ValueFromPipelineByPropertyName)]
        [Switch] $Favorite,

        [Parameter(
            ParameterSetName='Account',
            ValueFromPipelineByPropertyName
        )]
        [Switch] $AutoLogin,

        [Parameter(
            ParameterSetName='Account',
            ValueFromPipelineByPropertyName
        )]
        [Switch] $DisableAutofill

    )

    BEGIN {
        $Param = @{
            URI            = 'https://lastpass.com/show_website.php'
            Method        = 'POST'
            WebSession    = $Script:WebSession
        }

        $BodyBase = @{
            extjs    = 1
            token    = $Script:Session.Token
            method    = 'cli'
        }

    }

    PROCESS {

        # If shared
        # If share is Readonly, Throw
        # append share id
        # strip shared folder name from Folder/grouping property
        # Get account
        # Check if editable (IsShared and Share.ReadOnly)
        # Update modified property(ies)
        # generate new encrypted value (with new IV)
        # set unencrypted value
        # update_account
        # show_website.php
        # extjs = 1
        # token = $Token
        # method = 'cli'
        # name = $Account.Name (encrypted)
        # grouping = $Account.Folder.Name (encrypted)
        # pwprotect = 'on'/'off'
        # aid = $Account.ID
        # url = $Account.URL (hex)
        # username = $Account.Username (encrypted)
        # password = $Account.Password (encrypted)
        # extra = $Account.Notes (encrypted)
        # If $Account.SharedFolderID
        # sharedfolderid = $Account > Share.ID
        # save blob

        If($ShareID){
            If(($Blob.SharedFolders | Where ID -eq $ShareID).ReadOnly){
                $Type = If($SecureNote){ 'Note' }Else{ 'Account' }
                Throw ('{0} {1} is in a read-only shared folder' -f ($Type, $Name))
            }
            $Body = @{ sharedfolderid = $ShareID }
            $Folder = $Folder.Substring($Folder.IndexOf('\') + 1)
            $Key = $Blob.SharedFolders | Where ID -eq $ShareID | Select -Expand Key
        }

        If($SecureNote){
            $URL = 'http://sn'
            $VerboseDescription = "secure note '$Name'"
        }
        $Body += @{
            aid         = $ID
            name     = $Name | ConvertTo-LPEncryptedString -Key $Key
            grouping = $Folder | ConvertTo-LPEncryptedString -Key $Key
            url         = ([Byte[]][Char[]] $URL | ForEach { "{0:x2}" -f $_ }) -join ''
            extra     = $Notes | ConvertTo-LPEncryptedString -Key $Key
            <#
            folder = 'user' #, 'none', or name of default folder
            #localupdate = 1 # ?
            #ajax = 1 # ?
            #source = 'vault' # ?
            #urid = 0 # ?
            #auto = 1 # ?
            #iid = '' # ?
            #save_all = 1 # Used for app fields?
            #data = "" # Used for app fields?
            #>

        }
        If($PasswordProtect){ $Body.pwprotect = 'on' }
        If($Favorite){ $Body.fav = 'on' }

        If(!$SecureNote){
            $Body.username = $Credential.Username | ConvertTo-LPEncryptedString -Key $Key
            $Body.password = $Credential.GetNetworkCredential().Password |
                ConvertTo-LPEncryptedString -Key $Key

            # FIXME: This doesn't seem to work. Seems to match lastpass-cli code
            If($FormFields){
                $Body.data = ''
                $Body.data += $FormFields | ForEach {
                    $Field = $_
                    # $Field.Value.FieldType | Out-String | Write-Host
                    $Value = Switch -Regex ($Field.Type){
                        'email|tel|text|password|textarea' { $Field.Value | ConvertTo-LPEncryptedString -Key $Key }
                        'checkbox|radio' { '{0}-{1}' -f $Field.Value, [Int] $Field.Checked }
                        Default { $Field.Value }
                    }

                    "0`t{0}`t{1}`t{2}`n" -f @(
                        [URI]::EscapeDataString($Field.Name)
                        [URI]::EscapeDataString($Field.Type)
                        [URI]::EscapeDataString($Value)
                    )

                }
                $Body.data += "0`taction`t`taction`n0`tmethod`t`tmethod`n"
                # Write-Host $Body.Data
                $Body.data = ([Byte[]][Char[]] $Body.Data | ForEach { "{0:x2}" -f $_ }) -join ''
                # Write-Host $Body.Data
                $Body.save_all = '1'
            }
            If($AutoLogin){ $Body.autologin = 'on' }
            If($DisableAutofill){ $Body.never_autofill = 'on' }
            $VerboseDescription = "account '$Name'"
        }

        "Request Parameters:`n{0}" -f ($Body | Out-String) | Write-Debug
        $Query = "WARNING: update support is currently experimental`n" +
            "DATA LOSS MAY OCCUR (especially if item has form fields or attachments)`n" +
            "Update $VerboseDescription" -f $Name
        $VerboseDescription = "Updating $VerboseDescription"
        If($PSCmdlet.ShouldProcess($VerboseDescription,$Query,'Continue?')){
            Write-Verbose $VerboseDescription
            $Response = Invoke-RestMethod @Param -Body ($BodyBase + $Body)

            $Response.OuterXML | Out-String | Write-Debug
            Switch($Response.XMLResponse.Result.Msg){
                'AccountCreated' {

                }
                'AccountUpdated' {

                }
                Default {
                    Throw ("Failed to update {0}.`n{1}" -f @(
                        $Name
                        $Response.OuterXML)
                    )
                }
            }
        }
    }

    END { Sync-Lastpass -Debug:$False }
}



Function New-Key {
    <#
    .SYNOPSIS
    Generates a decryption key for a Lastpass account
 
    .PARAMETER Credential
    The Lastpass account credential
 
    .PARAMETER Iterations
    The number of hashing iterations
 
    .EXAMPLE
    New-Key -Credential $Credential -Iterations $Iterations
 
    Creates a new Lastpass decryption key using the username and password in the $Credential
    variable, and the number of iterations in the $Iterations variable
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
        'PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Does not change system state'
    )]
    [CmdletBinding()]
    Param(
        [Parameter(Mandatory)]
        [PSCredential] $Credential,

        [Parameter(Mandatory)]
        [Int] $Iterations
    )

    $EncodedUsername = [Byte[]][Char[]] $Credential.Username.ToLower()
    $EncodedPassword = [Byte[]][Char[]] $Credential.GetNetworkCredential().Password

    $Key = Switch($Iterations){
        1 {
            [SHA256Managed]::New().ComputeHash(
                $EncodedUsername + $EncodedPassword
            )
            Break
        }
        {$_ -gt 1} {
            [Rfc2898DeriveBytes]::New(
                $EncodedPassword,
                $EncodedUsername,
                $Iterations,
                [HashAlgorithmName]::SHA256
            ).GetBytes(32)
            Break
        }
        Default { Throw "Invalid Iteration value: '$Iterations'" }
    }
    Write-Debug "Key: $Key"
    Write-Output $Key
}



Function New-LoginHash {
    <#
    .SYNOPSIS
    Generates a hash value used for logging in to Lastpass
 
    .PARAMETER Key
    The decryption key for the Lastpass account
 
    .PARAMETER Credential
    The Lastpass account credential
 
    .PARAMETER Iterations
    The number of hashing iterations
 
    .EXAMPLE
    New-LoginHash -Key $Key -Credential $Credential -Iterations $Iterations
 
    Generates a new hash value used for logging in to Lastpass using the key in the $Key variable,
    the username and password in the $Credential variable, and the number of iterations in the
    $Iterations variable
    #>


    [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
        'PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Does not change system state'
    )]
    [CmdletBinding()]
    Param(
        [Parameter(Mandatory)]
        [Byte[]] $Key,

        [Parameter(Mandatory)]
        [PSCredential] $Credential,

        [Parameter(Mandatory)]
        [Int] $Iterations
    )
    $Password = $Credential.GetNetworkCredential().Password
    $Hash = Switch($Iterations){
                1 {
                    [SHA256Managed]::New().ComputeHash(
                        [Byte[]][Char[]] (
                            (($Key | ForEach { "{0:x2}" -f $_ }) -join '') +
                            $Password
                        )
                    )
                    Break
                }
                {$_ -gt 1} {
                    [Rfc2898DeriveBytes]::New(
                        $Key,
                        ([Byte[]][Char[]] $Password),
                        1,
                        [HashAlgorithmName]::SHA256
                    ).GetBytes(32)
                    Break
                }
                Default { Throw "Invalid Iteration value: '$Iterations'" }
            }
    $Hash = ($Hash | ForEach { "{0:x2}" -f $_ }) -join ''

    Write-Debug "Hash: $Hash"
    Write-Output $Hash
}



Function Read-Item {
    <#
    .SYNOPSIS
    Reads an item from a Lastpass blob
 
    .PARAMETER Blob
    The Lastpass blob
 
    .PARAMETER Index
    The start index into the blob to start reading from
 
    .EXAMPLE
    Read-Item $Blob
 
    Reads an item from Lastpass Blob $Blob, starting from index 0
 
    .EXAMPLE
    Read-Item $Blob $Index
 
    Reads an item from Lastpass Blob $Blob, starting from index $Index
 
    #>


    [CmdletBinding()]
    Param(
        [Parameter(Mandatory)]
        [Byte[]] $Blob,

        [Int] $Index = 0
    )
    Write-Debug "Read-Item start index: $Index"
    "Blob Snippet: {0}" -f ($Blob[$Index..($Index+50)] -join '') | Write-Debug


    $Size = $Blob[$Index..($Index+=3)]
    If([BitConverter]::IsLittleEndian){ [Array]::Reverse($Size) }
    $Size = [BitConverter]::ToUInt32($Size,0)
    Write-Debug "Size: $Size"
    If($Size){
        $Data = $Blob[($Index+=1)..(($Index+=$Size)-1)]
        Write-Debug "Data: $Data"
        Write-Output $Data
    }

}



Function Read-ASN1Item {
    <#
    .SYNOPSIS
    Parses an ASN1 encoded byte array
 
    .DESCRIPTION
    Lastpass' private key is sent using an ASN1 encoded byte array.
    This function does basic parsing of an ASN1 encoded data structure.
 
    .PARAMETER Blob
    The ASN1 encoded byte array
 
    .PARAMETER Index
    The start index into the byte array to start reading from
 
    .EXAMPLE
    Read-ASN1 -Blob $Blob
 
    Reads the ASN1 encoded item from the $Blob byte array, starting at index 0
 
    .EXAMPLE
    Read-ASN1 -Blob $Blob -Index $Index
 
    Reads the ASN1 encoded item from the $Blob byte array, starting at index $Index.
    #>


    [CmdletBinding()]
    Param(
        [Byte[]] $Blob,
        [Int] $Index = 0
    )

    Write-Debug "Read-ASN1Item Blob Length: $($Blob.Length), Index: $Index"
    $Output = @{
        Type = Switch($Blob[$Index] -band 0x1F){
            2        { 'Integer' }
            4        { 'Bytes' }
            5        { 'Null' }
            16        { 'Sequence' }
            Default { $Blob[$Index] -band 0x1F }
        }
    }
    $Size = $Blob[($Index+=1)]
    If(($Size -band 0x80) -ne 0){
        $Length = $Size -band 0x7F
        $Size = 0
        1..$Length | ForEach {
            $Size = $Size * 256 + ($Blob[($Index+=1)])
        }
    }
    $Output.Value = $Blob[($Index+=1)..(($Index+=$Size)-1)]
    $Output.Value -is [Array] | Write-Debug
    $Output.Index = $Index

    $Output | Out-String | Write-Debug
    Write-Output [PSCustomObject] $Output
}



Function ConvertFrom-LPEncryptedData {

    <#
    .SYNOPSIS
    Decrypts Lastpass encrypted strings
 
    .DESCRIPTION
    Decrypts data from Lastpass blob and transmission
    Supports CBC and ECB encryption
    If a SecureString is passed in, the bytes are extracted and then decrypted
    If a string is passed in, it is converted to a byte array and then decrypted
 
    .PARAMETER Data
    The encrypted Lastpass string to decrypt
 
    .PARAMETER SecureString
    The SecureString that holds an encrypted string as a byte array
 
    .PARAMETER Key
    If specified, this key will be used for decryption.
    By default, the account key will be used.
 
    .PARAMETER Base64
    Whether the input is Base64 encoded
 
    .EXAMPLE
    ConvertFrom-LPEncryptedData -Value '!lks;jf90s|fsafj9#IOj893fj'
 
    Decrypts the Lastpass encrypted input string
 
    .EXAMPLE
    $EncryptedAccounts.Name | ConvertFrom-LPEncryptedData
 
    Decrypts the names of the accounts in the $EncryptedAccounts variable
 
    .EXAMPLE
    $Key = [Convert]::FromBase64String('Bg0kRH2p+IC4mjRHlNm/IyNnfudsEXaaPLgHDeU0NTs=')
    'IVdYT0McSfObWOy68igNDsDDSoATbUwNSt/TFEMnu5hV' | ConvertFrom-LPEncryptedData -Key $Key -Base64
 
    Decrypts the Base64 encoded encrypted string using the specified key
    #>

    [CmdletBinding(DefaultParameterSetName='String')]
    [OutputType([String])]
    Param (
        [Parameter(
            ParameterSetName='String',
            ValueFromPipeline,
            ValueFromPipelineByPropertyName,
            Position = 0
        )]
        [Char[]] $Data,

        [Parameter(
            ParameterSetName='SecureString',
            ValueFromPipeline,
            ValueFromPipelineByPropertyName,
            Position = 0
        )]
        [SecureString] $SecureString,

        [Byte[]] $Key,

        [Switch] $Base64
    )

    BEGIN {
        If(!$Key -and !$Session.Key){ Throw 'No decryption key found.' }
        $AES = [AesManaged]::New()
        $AES.KeySize = 256
        $AES.Key = If($Key){
            Write-Debug ('Using custom key {0}...' -f ($Key[0..4] -join ','))
            $Key
        }
        Else{ $Session.Key }
    }

    PROCESS {
        # https://blogs.msdn.microsoft.com/fpintos/2009/06/12/how-to-properly-convert-securestring-to-string/
        If($PSCmdlet.ParameterSetName -eq 'SecureString'){
            $Data = [Char[]]::New($SecureString.Length)

            $Pointer = [Runtime.InteropServices.Marshal]::SecureStringToGlobalAllocUnicode($SecureString)
            Try{ [Runtime.InteropServices.Marshal]::Copy($Pointer, $Data, 0, $SecureString.Length) }
            Finally{ [Runtime.InteropServices.Marshal]::ZeroFreeCoTaskMemUnicode($Pointer) }
        }

        Write-Debug "Encrypted value $($Data.Length):"
        Write-Debug "$($Data -join '')"
        If($Data.length -eq 0){ Return '' }

        If($Base64){
            $Data = If($Data[0] -eq '!'){
                $Index = $Data.IndexOf([Char] '|')
                [Char[]] '!' +
                [Char[]][Convert]::FromBase64CharArray($Data, 1, $Index-1) +
                [Char[]][Convert]::FromBase64CharArray($Data, $Index+1, ($Data.Length-$Index-1))
            }
            Else { [Char[]][Convert]::FromBase64CharArray($Data, 0, $Data.Length)}
        }

        If(($Data[0] -eq '!') -and ($Data.Length -gt 32) -and ($Data.Length % 16 -eq 1)){
            Write-Debug 'CBC'
            $AES.Mode = [CipherMode]::CBC
            $AES.IV = $Data[1..16]
            $Data = $Data[17..($Data.Length-1)]
        }
        Else{
            Write-Debug 'ECB'
            $AES.Mode = [CipherMode]::ECB
            $AES.IV = [Byte[]] '0'*16
        }
        $AES | Out-String | Write-Debug
        $Decryptor = $AES.CreateDecryptor()

        Try{
            [Char[]] $Decryptor.TransformFinalBlock(
                $Data,
                0,
                $Data.length
            ) -join ''
        }
        Catch{
            Write-Error "Decryption failed. Data: $Data"
            Throw
        }
    }
}



Function ConvertTo-LPEncryptedString {

    <#
    .SYNOPSIS
    Encrypts Lastpass encoded strings
 
    .DESCRIPTION
    Encrypts strings for communication with Lastpass and storage
 
    If a string is passed in, it will convert it into a CBC encrypted value in the format Lastpass
    expects for upload or communication.
 
    If a byte array is passed in, it will convert them into a SecureString object. This is useful
    for decryption of the Lastpass account blob without generating a plaintext string
 
    .PARAMETER Value
    The string to encrypt
 
    .PARAMETER Bytes
    The array of characters to convert into a SecureString
 
    .PARAMETER Key
    If specified, this key will be used for encryption.
    By default, the account key will be used.
 
    .EXAMPLE
    ConvertTo-LPEncryptedString -Value 'SecretText'
 
    Encrypts the input string 'SecretText'
 
    .EXAMPLE
    $DecryptedAccounts.Username | ConvertTo-LPEncryptedString
 
    Encrypts the names of the accounts in the $DecryptedAccounts variable
 
    .EXAMPLE
    ConvertTo-LPEncryptedString -Bytes $Bytes
 
    Converts the byte array $Bytes into a SecureString object, suitable for in memory storage
    #>


    [CmdletBinding(DefaultParameterSetName='String')]
    [OutputType([String], ParameterSetName='String')]
    [OutputType([SecureString], ParameterSetName='SecureString')]
    Param (
        [Parameter(
            ParameterSetName='String',
            ValueFromPipeline,
            ValueFromPipelineByPropertyName,
            Position = 0
        )]
        [AllowEmptyString()]
        [String[]] $Value,

        [Parameter(
            ParameterSetName='SecureString',
            Position = 0
        )]
        [AllowEmptyCollection()]
        [Byte[]] $Bytes,

        [Byte[]] $Key
    )

    BEGIN {
        If($PSCmdlet.ParameterSetName -eq 'String'){
            If(!$Key -and !$Session.Key){ Throw 'No decryption key found.' }
            $AES = [AesManaged]::New()
            $AES.KeySize = 256
            $AES.Key = If($Key){
                Write-Debug ('Using custom key {0}...' -f ($Key[0..4] -join ','))
                $Key
            }
            Else{ $Session.Key }
            $AES.Mode = [CipherMode]::CBC
        }
    }

    PROCESS {
        If($PSCmdlet.ParameterSetName -eq 'SecureString'){
            $Output = [SecureString]::New()
            If($Bytes){
                0..($Bytes.Length-1) | ForEach {
                    $Output.AppendChar($Bytes[$_])
                    $Bytes[$_] = $Null
                }
            }
            Return $Output
        }
        $Value | ForEach {
            If(!$Value){ Return ''}
            $AES.GenerateIV()
            $Encryptor = $AES.CreateEncryptor()

            $EncryptedValue = $Encryptor.TransformFinalBlock([Byte[]][Char[]] $_, 0, $_.Length)

            '!{0}|{1}' -f @(
                [Convert]::ToBase64String($AES.IV),
                [Convert]::ToBase64String($EncryptedValue)
            ) | Write-Output
        }
    }
}



Function ConvertFrom-Hex {
    <#
    .SYNOPSIS
    Decodes a hex string
 
    .PARAMETER Value
    The hex encoded string
 
    .EXAMPLE
    ConvertFrom-Hex '56616C7565'
 
    Decodes the hex string to 86,97,108,117,101 ('Value')
 
    .EXAMPLE
    '506970656C696E6556616C7565' | ConvertFrom-Hex
 
    Decodes the hex string to 80,105,112,101,108,105,110,101,86,97,108,117,101 ('PipelineValue')
    #>


    [CmdletBinding()]
    Param(
        [Parameter(
            Mandatory,
            ValueFromPipeline,
            ValueFromPipelineByPropertyName,
            Position = 0
        )]
        [AllowEmptyString()]
        [String[]] $Value
    )

    $Value | ForEach {
        ($_ -split '([a-f0-9]{2})' | ForEach {
            If($_){ [Convert]::ToByte($_,16) }
        })
    }

}



Function Confirm-Password {
    <#
    .SYNOPSIS
    Reprompts and reverifies the master account password
 
    .DESCRIPTION
    Prompts the user for the master password and verifies it is correct
    If the password has been verified within the verification timeout setting, verification is skipped
    If the password entered is incorrect, the function will throw an error
 
    .EXAMPLE
    Confirm-Password
 
    Checks whether the master password has been verified within the timeout setting,
    and if not, prompts the user to re-enter their password and verifies it is correct.
    #>


    [CmdletBinding()]
    Param()

    If($PasswordPrompt -lt [DateTime]::Now.Subtract($PasswordTimeout)){
        #TODO: Should this loop? Possibly for a set number of retries?
        $Password = Read-Host -AsSecureString 'Please confirm your password'
        $Credential = [PSCredential]::New($Script:Session.Username, $Password)
        $Key = New-Key -Credential $Credential -Iterations $Script:Session.Iterations

        $Param = @{
            ReferenceObject     = $Script:Session.Key
            DifferenceObject = $Key
            SyncWindow         = 0
        }
        If(Compare-Object @Param){ Throw 'Password confirmation failed' }
        $Script:PasswordPrompt = [DateTime]::Now
    }


}



Function Get-Session {
    <#
    .SYNOPSIS
    Returns a Lastpass session.
    For Debugging purposes only.
 
    .EXAMPLE
    Get-Session
 
    Gets the Lastpass session object
 
    #>


    Return [PSCustomObject] @{
        PSTypeName = 'Lastpass.Session'
        WebSession = $WebSession
        Session = $Session
        Blob = $Blob
    }
}



Function Set-Session {
    <#
    .SYNOPSIS
    Sets the Lastpass session
    For debugging purposes only
 
    .PARAMETER Session
    The lastpass session oobject
 
    .EXAMPLE
    Set-Session $S
 
    Sets the Lastpass session
 
    .EXAMPLE
    $S | Set-Session
 
    Sets the Lastpass session
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
        'PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Does not change system state'
    )]
    Param(
        [Parameter(
            Mandatory,
            ValueFromPipeline,
            ValueFromPipelineByPropertyName,
            Position = 0
        )]
        [PSTypeName('Lastpass.Session')] $Session
    )

    $Script:WebSession = $Session.WebSession
    $Script:Session = $Session.Session

}



$ExportMethods = @(
    'Connect-Lastpass'
    'Disconnect-Lastpass'
    'Sync-Lastpass'
    'Get-Account'
    'Get-Note'
    'Get-Attachment'
    'New-Password'
)

If($ModuleParameters.ExportWriteCmdlets){
    "Modification cmdlets are currently experimental " +
    "and should not be used for production workloads.`n" +
    "DATA LOSS MAY OCCUR!" | Write-Warning
    $ExportMethods += @(
        'Set-Account'
        'Set-Note'
    )
}

If($ModuleParameters.Debug){
    $ExportMethods = '*'
}
Export-ModuleMember -Function $ExportMethods