TrashCommands.ps1
|
#requires -Version 5.1 $script:MAX_TIMESTAMP = 4102444800 $script:STRING_LENGTH_LIMIT = 100 $script:MAX_RECORDS_LIMIT = 990 $script:FIELD_LABEL_WIDTH = 21 $script:STATUS_SUCCESS = "success" $script:RECORD_VERSION_LEGACY_MIN = 0 $script:RECORD_VERSION_LEGACY_MAX = 2 $script:RECORD_VERSION_V3 = 3 $script:RECORD_VERSION_V4 = 4 $script:RECORD_VERSION_V5 = 5 $script:RECORD_VERSION_V6 = 6 function Get-VaultOrThrow { # Internal: Gets vault instance or throws error $vault = getVault if ($null -eq $vault) { throw "Failed to get vault instance" } return $vault } function Invoke-TaskAndWait { # Internal: Waits for task completion and properly unwraps exceptions param([System.Threading.Tasks.Task]$Task) $Task.Wait() if ($Task.IsFaulted) { if ($Task.Exception.InnerExceptions.Count -eq 1) { throw $Task.Exception.InnerException } throw $Task.Exception } } function Get-RecordMetadata { # Internal: Parses record title and type from decrypted data param([byte[]]$DataUnencrypted) if ($null -eq $DataUnencrypted -or $DataUnencrypted.Length -eq 0) { return @{ Title = ""; Type = "" } } try { $jsonString = [System.Text.Encoding]::UTF8.GetString($DataUnencrypted) $json = $jsonString | ConvertFrom-Json return @{ Title = if ($json.title) { $json.title } else { "" } Type = if ($json.type) { $json.type } else { "" } } } catch { Write-Verbose "Failed to parse record metadata: $($_.Exception.Message)" return @{ Title = "Parse Error"; Type = "Unknown" } } } function New-WildcardRegex { # Internal: Creates regex from wildcard pattern with timeout protection param( [Parameter(Mandatory = $true)] [string]$Pattern, [int]$MaxLength = $script:STRING_LENGTH_LIMIT, [int]$TimeoutSeconds = 1 ) if ($Pattern.Length -gt $MaxLength) { Write-Warning "Pattern too long, truncated to $MaxLength characters" $Pattern = $Pattern.Substring(0, $MaxLength) } try { $escaped = [regex]::Escape($Pattern) -replace "\\\*", ".*" -replace "\\\?", "." $regexPattern = "^$escaped$" return [regex]::new( $regexPattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase, [TimeSpan]::FromSeconds($TimeoutSeconds) ) } catch { Write-Warning "Invalid pattern '$Pattern': $($_.Exception.Message)" return $null } } function Get-KeeperTrashList { <# .SYNOPSIS Lists deleted records in trash .DESCRIPTION Lists all deleted records, orphaned records, and shared folders in the trash. Shows record details including UID, name, type, deletion date, and status. .PARAMETER Pattern Filter records by pattern (supports wildcards * and ?) .PARAMETER Verbose Show detailed information including folder details .OUTPUTS None. Displays formatted table output to console. .EXAMPLE Get-KeeperTrashList Lists all items in trash .EXAMPLE Get-KeeperTrashList -Pattern "test*" Lists only records matching "test*" pattern .EXAMPLE Get-KeeperTrashList -Verbose Shows detailed information including folder details #> [CmdletBinding()] [OutputType([void])] param( [Parameter(Position = 0)] [ValidateLength(0, 100)] [string]$Pattern ) Write-Verbose "Starting trash list operation with pattern: '$Pattern'" try { $vault = Get-VaultOrThrow $loadTask = [KeeperSecurity.Vault.TrashManagement]::EnsureDeletedRecordsLoaded($vault) Invoke-TaskAndWait -Task $loadTask } catch { Write-Error "Failed to load deleted records: $($_.Exception.Message)" return } $deletedRecords = [KeeperSecurity.Vault.TrashManagement]::GetDeletedRecords() $orphanedRecords = [KeeperSecurity.Vault.TrashManagement]::GetOrphanedRecords() $sharedFolders = [KeeperSecurity.Vault.TrashManagement]::GetSharedFolders() if ([KeeperSecurity.Vault.TrashManagement]::IsTrashEmpty()) { Write-Host "Trash is empty" return } $normalizedPattern = if ($Pattern -eq "*") { $null } elseif ($Pattern) { $Pattern.ToLower() } else { $null } $titlePattern = if ($normalizedPattern) { New-WildcardRegex -Pattern $normalizedPattern } else { $null } $recordResults = @( Get-RecordTableData -Records $deletedRecords -IsShared $false -Pattern $normalizedPattern -TitlePattern $titlePattern Get-RecordTableData -Records $orphanedRecords -IsShared $true -Pattern $normalizedPattern -TitlePattern $titlePattern ) $folderResults = @(Get-FolderTableData -SharedFolders $sharedFolders -Pattern $normalizedPattern -TitlePattern $titlePattern) $recordResults = @($recordResults | Sort-Object @{Expression={if ($_.'Deleted At') { $_.'Deleted At' } else { [DateTime]::MinValue }}; Descending=$true}, Name) $folderResults = @($folderResults | Sort-Object @{Expression={if ($_.'Deleted At') { $_.'Deleted At' } else { [DateTime]::MinValue }}; Descending=$true}, Name) $results = @($recordResults) + @($folderResults) if ($results.Count -gt 0) { $results | Format-Table -AutoSize -Wrap } else { Write-Host "No records found matching the specified criteria" } } function Get-RecordTableData { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [ValidateNotNull()] $Records, [Parameter(Mandatory = $true)] [bool]$IsShared, [string]$Pattern, [regex]$TitlePattern ) if ($null -eq $Records -or $null -eq $Records.Values) { Write-Warning "No records provided or records collection is empty" return @() } $results = @() foreach ($record in $Records.Values) { if ($null -eq $record) { Write-Warning "Null record found, skipping" continue } $metadata = Get-RecordMetadata -DataUnencrypted $record.DataUnencrypted if ($null -ne $Pattern -and $null -ne $TitlePattern -and $metadata.Title -notmatch $TitlePattern) { continue } $status = if ($IsShared) { "Share" } else { "Record" } $dateDeleted = if (-not $IsShared) { Get-DeletedDate -Timestamp $record.DateDeleted } else { $null } $results += [PSCustomObject]@{ 'Folder UID' = "" 'Record UID' = $record.RecordUid 'Name' = $metadata.Title 'Record Type' = $metadata.Type 'Deleted At' = $dateDeleted 'Status' = $status } } return $results } function Get-FolderTableData { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [ValidateNotNull()] $SharedFolders, [string]$Pattern, [regex]$TitlePattern ) $results = @() if (-not $SharedFolders -or -not $SharedFolders.Folders -or -not $SharedFolders.Records) { return $results } foreach ($folder in $SharedFolders.Folders.Values) { $folderName = Get-FolderName -Folder $folder -FolderUid $folder.FolderUidString $folderRecords = $SharedFolders.Records.Values | Where-Object { $_.FolderUid -eq $folder.FolderUidString } $folderMatches = Test-FolderMatchesPattern -FolderName $folderName -FolderRecords $folderRecords -Pattern $Pattern -TitlePattern $TitlePattern if ($folderMatches) { if ($VerbosePreference -eq 'Continue') { $results += [PSCustomObject]@{ 'Folder UID' = $folder.FolderUidString 'Record UID' = "" 'Name' = $folderName 'Record Type' = "Shared Folder" 'Deleted At' = $null 'Status' = "Folder" } foreach ($record in $folderRecords) { $metadata = Get-RecordMetadata -DataUnencrypted $record.DataUnencrypted $recordMatches = $null -eq $Pattern -or $metadata.Title -match $TitlePattern if ($recordMatches) { $results += [PSCustomObject]@{ 'Folder UID' = $folder.FolderUidString 'Record UID' = $record.RecordUid 'Name' = $metadata.Title 'Record Type' = $metadata.Type 'Deleted At' = Get-DeletedDate -Timestamp $record.DateDeleted 'Status' = "Share" } } } } else { $recordCount = $folderRecords.Count $results += [PSCustomObject]@{ 'Folder UID' = $folder.FolderUidString 'Record UID' = "" 'Name' = "$folderName ($recordCount records)" 'Record Type' = "Shared Folder" 'Deleted At' = $null 'Status' = "Folder" } } } } return $results } function Test-FolderMatchesPattern { # Internal: Tests if folder name or any record matches the search pattern [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory = $true)] [string]$FolderName, [Parameter(Mandatory = $false)] [AllowNull()] $FolderRecords, [string]$Pattern, [regex]$TitlePattern ) if ([string]::IsNullOrEmpty($Pattern)) { return $true } if ($FolderName -match $TitlePattern) { return $true } if ($null -ne $FolderRecords) { foreach ($record in $FolderRecords) { $metadata = Get-RecordMetadata -DataUnencrypted $record.DataUnencrypted if ($metadata.Title -match $TitlePattern) { return $true } } } return $false } function Get-FolderName { param( $Folder, [string]$FolderUid ) try { if ($Folder.DataUnEncrypted -and $Folder.DataUnEncrypted.Length -gt 0) { $jsonString = [System.Text.Encoding]::UTF8.GetString($Folder.DataUnEncrypted) $jsonObject = $jsonString | ConvertFrom-Json if ($jsonObject.name) { return $jsonObject.name } else { return $FolderUid } } return $FolderUid } catch [System.Text.DecoderFallbackException] { Write-Verbose "Invalid encoding in folder $($FolderUid): $($_.Exception.Message)" return $FolderUid } catch [System.ArgumentException] { Write-Verbose "Invalid JSON data in folder $($FolderUid): $($_.Exception.Message)" return $FolderUid } catch { Write-Verbose "Unexpected error parsing folder data for $($FolderUid): $($_.Exception.Message)" return $FolderUid } } function ConvertFrom-UnixTimestamp { # Internal: Converts Unix timestamp (seconds or milliseconds) to DateTime [CmdletBinding()] [OutputType([DateTime])] param( [Parameter(Mandatory = $true)] [long]$Timestamp, [switch]$AsMilliseconds ) if ($Timestamp -le 0) { return $null } try { $isMilliseconds = $AsMilliseconds -or $Timestamp -gt 9999999999 if ($isMilliseconds) { $seconds = $Timestamp / 1000 if ($seconds -lt 0 -or $seconds -gt $script:MAX_TIMESTAMP) { return $null } return [DateTimeOffset]::FromUnixTimeMilliseconds($Timestamp).DateTime } else { if ($Timestamp -lt 0 -or $Timestamp -gt $script:MAX_TIMESTAMP) { return $null } return [DateTimeOffset]::FromUnixTimeSeconds($Timestamp).DateTime } } catch { Write-Verbose "Failed to convert timestamp $Timestamp`: $($_.Exception.Message)" return $null } } function Get-DeletedDate { param([long]$Timestamp) if ($Timestamp -le 0) { return $null } return ConvertFrom-UnixTimestamp -Timestamp $Timestamp -AsMilliseconds } function Restore-KeeperTrashRecords { <# .SYNOPSIS Restores deleted records from trash .DESCRIPTION Restores deleted records, orphaned records, and shared folders from the trash. Supports restoring by record UID or pattern matching. .PARAMETER Records Array of record UIDs or patterns to restore. Supports wildcards (* and ?). .PARAMETER Force Skip confirmation prompts and restore immediately. .OUTPUTS None. Displays status messages to console. .EXAMPLE Restore-KeeperTrashRecords -Records "NyTgDxKnMRhcgpR_BGkFkw" Restores a specific record by UID .EXAMPLE Restore-KeeperTrashRecords -Records "test*", "MyRecord" Restores records matching "test*" pattern and a specific record .EXAMPLE Restore-KeeperTrashRecords -Records "test*" -Force Restores records matching "test*" pattern without confirmation #> [CmdletBinding()] [OutputType([void])] param( [Parameter(Mandatory = $true, Position = 0)] [ValidateCount(1, 10000)] [ValidateLength(1, 100)] [string[]]$Records, [Parameter()] [switch]$Force ) Write-Verbose "Starting trash restore operation with $($Records.Count) record(s)" $validationResult = Test-RecordParameters -Records $Records if (-not $validationResult.IsValid) { Write-Error $validationResult.ErrorMessage return } if ($validationResult.ValidRecords.Count -eq 0) { Write-Host "No valid records specified for restoration" return } try { $vault = Get-VaultOrThrow $restoreTask = [KeeperSecurity.Vault.TrashManagement]::RestoreTrashRecords($vault, $validationResult.ValidRecords) Invoke-TaskAndWait -Task $restoreTask Write-Host "Successfully initiated restoration of $($validationResult.ValidRecords.Count) record(s)" Write-Host "Use 'Get-KeeperTrashList' to verify the restoration" } catch { Write-Error "Failed to restore records: $($_.Exception.Message)" } } function Test-RecordParameters { <# .SYNOPSIS Validates record parameters for trash operations .PARAMETER Records Array of record identifiers to validate .OUTPUTS PSCustomObject with IsValid, ErrorMessage, and ValidRecords properties #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string[]]$Records ) $validatedRecords = @() $errors = @() if ($Records.Count -gt $script:MAX_RECORDS_LIMIT) { return [PSCustomObject]@{ IsValid = $false ErrorMessage = "Too many records specified (max: $script:MAX_RECORDS_LIMIT)" ValidRecords = @() } } for ($i = 0; $i -lt $Records.Count; $i++) { $record = $Records[$i] if ([string]::IsNullOrWhiteSpace($record)) { $errors += "Record $($record) at index $($i + 1) must not be empty or whitespace" continue } if ($record.Length -gt $script:STRING_LENGTH_LIMIT) { $errors += "Record $($record) at index $($i + 1) exceeds maximum length ($script:STRING_LENGTH_LIMIT characters)" continue } $validatedRecords += $record.Trim() } return [PSCustomObject]@{ IsValid = $validatedRecords.Count -gt 0 ErrorMessage = if ($errors.Count -gt 0) { $errors -join "; " } else { $null } ValidRecords = $validatedRecords } } function Remove-TrashedKeeperRecordShares { <# .SYNOPSIS Removes shares from deleted records in trash .DESCRIPTION Removes all non-owner shares from orphaned records in the trash. This is useful for cleaning up shared records before permanently deleting them. .PARAMETER Records Array of record UIDs or patterns to unshare. Supports wildcards (* and ?). Use "*" to process all orphaned records. .PARAMETER Force Skip confirmation prompts and remove shares immediately. .OUTPUTS None. Displays status messages to console. .EXAMPLE Remove-TrashedKeeperRecordShares -Records "NyTgDxKnMRhcgpR_BGkFkw" Removes shares from a specific orphaned record by UID .EXAMPLE Remove-TrashedKeeperRecordShares -Records "test*", "MyRecord" Removes shares from records matching "test*" pattern and a specific record .EXAMPLE Remove-TrashedKeeperRecordShares -Records "*" -Force Removes shares from all orphaned records without confirmation #> [CmdletBinding()] [OutputType([void])] param( [Parameter(Mandatory = $true, Position = 0)] [ValidateCount(1, 10000)] [ValidateLength(1, 100)] [string[]]$Records, [Parameter()] [switch]$Force ) Write-Verbose "Starting trash unshare operation with $($Records.Count) record(s)" $validationResult = Test-RecordParameters -Records $Records if (-not $validationResult.IsValid) { Write-Error $validationResult.ErrorMessage return } if ($validationResult.ValidRecords.Count -eq 0) { Write-Host "No valid records specified" return } try { $vault = Get-VaultOrThrow $loadTask = [KeeperSecurity.Vault.TrashManagement]::EnsureDeletedRecordsLoaded($vault) Invoke-TaskAndWait -Task $loadTask } catch { Write-Error "Failed to load deleted records: $($_.Exception.Message)" return } $orphanedRecords = [KeeperSecurity.Vault.TrashManagement]::GetOrphanedRecords() if ($null -eq $orphanedRecords -or $orphanedRecords.Count -eq 0) { Write-Host "Trash is empty" return } $recordsToUnshare = Find-RecordsToUnshare -RecordPatterns $validationResult.ValidRecords -OrphanedRecords $orphanedRecords if ($recordsToUnshare.Count -eq 0) { Write-Host "There are no records to unshare" return } if (-not (Confirm-UnshareOperation -Force:$Force -RecordCount $recordsToUnshare.Count)) { Write-Host "Operation cancelled by user" return } Remove-SharesFromRecords -RecordsToUnshare $recordsToUnshare } function Find-RecordsToUnshare { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string[]]$RecordPatterns, [Parameter(Mandatory = $true)] $OrphanedRecords ) $recordsToUnshare = New-Object System.Collections.Generic.HashSet[string] foreach ($pattern in $RecordPatterns) { if ($OrphanedRecords.ContainsKey($pattern)) { [void]$recordsToUnshare.Add($pattern) } else { Add-MatchingRecords -Pattern $pattern -OrphanedRecords $OrphanedRecords -RecordsToUnshare $recordsToUnshare } } return @($recordsToUnshare) } function Add-MatchingRecords { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string]$Pattern, [Parameter(Mandatory = $true)] [ValidateNotNull()] $OrphanedRecords, [Parameter(Mandatory = $false)] [AllowEmptyCollection()] [System.Collections.Generic.HashSet[string]]$RecordsToUnshare ) if ($null -eq $RecordsToUnshare) { Write-Warning "RecordsToUnshare collection is null" return } $titlePattern = New-WildcardRegex -Pattern $Pattern if ($null -eq $titlePattern) { return } foreach ($kvp in $OrphanedRecords.GetEnumerator()) { $recordUid = $kvp.Key $record = $kvp.Value if ($RecordsToUnshare.Contains($recordUid)) { continue } $metadata = Get-RecordMetadata -DataUnencrypted $record.DataUnencrypted if (-not [string]::IsNullOrEmpty($metadata.Title) -and $titlePattern.IsMatch($metadata.Title)) { [void]$RecordsToUnshare.Add($recordUid) } } } function Confirm-UnshareOperation { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [switch]$Force, [Parameter(Mandatory = $true)] [int]$RecordCount ) if ($Force) { return $true } $confirmation = Read-Host "Do you want to remove shares from $RecordCount record(s)? (yes/No)" return ($confirmation -match '^(y|yes)$') } function Remove-SharesFromRecords { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string[]]$RecordsToUnshare ) try { $vault = Get-VaultOrThrow $recordSharesTask = $vault.GetSharesForRecords($RecordsToUnshare) Invoke-TaskAndWait -Task $recordSharesTask $recordShares = $recordSharesTask.Result if ($null -eq $recordShares) { Write-Verbose "No shares found for the specified records" return } $removeShareRequests = Build-RemoveShareRequests -RecordShares $recordShares if ($removeShareRequests.Count -eq 0) { Write-Verbose "No share removal requests to process" return } Invoke-ShareRemovalRequests -RemoveRequests $removeShareRequests } catch { Write-Error "Error getting record shares: $($_.Exception.Message)" } } function Build-RemoveShareRequests { [CmdletBinding()] param( [Parameter(Mandatory = $true)] $RecordShares ) $removeRequests = New-Object System.Collections.Generic.List[Records.SharedRecord] foreach ($recordShare in $recordShares) { if (-not $recordShare.UserPermissions) { continue } foreach ($userPermission in $recordShare.UserPermissions) { if (-not $userPermission.Owner) { $shareRequest = New-Object Records.SharedRecord $shareRequest.ToUsername = $userPermission.Username $shareRequest.RecordUid = [Google.Protobuf.ByteString]::CopyFrom( [KeeperSecurity.Utils.CryptoUtils]::Base64UrlDecode($recordShare.RecordUid) ) $removeRequests.Add($shareRequest) } } } return $removeRequests } function Invoke-ShareRemovalRequests { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [System.Collections.Generic.List[Records.SharedRecord]]$RemoveRequests ) $chunkSize = 100 $processedCount = 0 for ($i = 0; $i -lt $RemoveRequests.Count; $i += $chunkSize) { $remainingCount = $RemoveRequests.Count - $i $currentChunkSize = [Math]::Min($chunkSize, $remainingCount) $chunk = $RemoveRequests.GetRange($i, $currentChunkSize) Invoke-ShareRemovalChunk -Chunk $chunk $processedCount += $chunk.Count Write-Verbose "Processed $processedCount of $($RemoveRequests.Count) share removal requests" } Write-Host "Successfully removed shares from $processedCount record(s)" } function Invoke-ShareRemovalChunk { [CmdletBinding()] param( [Parameter(Mandatory = $true)] $Chunk ) try { $vault = Get-VaultOrThrow $updateRequest = New-Object Records.RecordShareUpdateRequest $updateRequest.RemoveSharedRecord.AddRange($Chunk) $responseTask = $vault.Auth.ExecuteAuthRest( "vault/records_share_update", $updateRequest, [Records.RecordShareUpdateResponse] ) $response = $responseTask.GetAwaiter().GetResult() -as [Records.RecordShareUpdateResponse] Write-ShareRemovalErrors -Response $response } catch { Write-Error "Error removing shares: $($_.Exception.Message)" throw } } function Write-ShareRemovalErrors { [CmdletBinding()] param( [Parameter(Mandatory = $true)] $Response ) foreach ($status in $Response.RemoveSharedRecordStatus) { if ($status.Status -ne $script:STATUS_SUCCESS) { $recordUid = [KeeperSecurity.Utils.CryptoUtils]::Base64UrlEncode($status.RecordUid.ToByteArray()) Write-Warning "Remove share '$($status.Username)' from record UID '$recordUid' error: $($status.Message)" } } } function Get-KeeperTrashedRecordDetails { <# .SYNOPSIS Gets detailed information about a deleted record in trash .DESCRIPTION Displays detailed information about a specific deleted record including all fields, custom fields, and share information if the record is shared. .PARAMETER RecordUid The unique identifier (UID) of the deleted record to retrieve .OUTPUTS None. Displays formatted record details to console. .EXAMPLE Get-KeeperTrashedRecordDetails -RecordUid "QGMaKCr9ksOOkhIMSvIWtg" Displays detailed information about the specified deleted record .EXAMPLE Get-KeeperTrashedRecordDetails "hlPKPNt9rsIqC_mCwwfP5A" Displays details using positional parameter #> [CmdletBinding()] [OutputType([void])] param( [Parameter(Mandatory = $true, Position = 0)] [ValidateNotNullOrEmpty()] [ValidateLength(16, 64)] [ValidatePattern('^[A-Za-z0-9_-]+$')] [string]$RecordUid ) Write-Verbose "Retrieving details for record: $RecordUid" [KeeperSecurity.Vault.VaultOnline]$vault = getVault if ($null -eq $vault) { Write-Error "Failed to get vault instance" return } try { $loadTask = [KeeperSecurity.Vault.TrashManagement]::EnsureDeletedRecordsLoaded($vault) Invoke-TaskAndWait -Task $loadTask } catch { Write-Error "Failed to load deleted records: $($_.Exception.Message)" return } $deletedRecords = [KeeperSecurity.Vault.TrashManagement]::GetDeletedRecords() $orphanedRecords = [KeeperSecurity.Vault.TrashManagement]::GetOrphanedRecords() $record = $null $isShared = $false if ($deletedRecords.ContainsKey($RecordUid)) { $record = $deletedRecords[$RecordUid] $isShared = $false } elseif ($orphanedRecords.ContainsKey($RecordUid)) { $record = $orphanedRecords[$RecordUid] $isShared = $true } else { Write-Error "$RecordUid is not a valid deleted record UID" return } if ($null -eq $record.RecordKeyUnencrypted) { Write-Error "Cannot retrieve record $RecordUid`: no decryption key available" return } try { $recordData = ConvertTo-ParsedRecord -DeletedRecord $record if (-not $recordData) { Write-Error "Cannot parse record $RecordUid" return } Show-RecordDetails -RecordData $recordData if ($isShared) { Show-RecordShares -Vault $vault -RecordUid $RecordUid } } catch { Write-Error "Error displaying record details: $($_.Exception.Message)" Write-Verbose $_.Exception.ToString() } } function Get-DecryptedRecordData { # Internal: Decrypts record data using appropriate AES version [CmdletBinding()] param( [Parameter(Mandatory = $true)] $DeletedRecord ) if ($null -ne $DeletedRecord.DataUnencrypted) { return $DeletedRecord.DataUnencrypted } $encryptedData = [KeeperSecurity.Utils.CryptoUtils]::Base64UrlDecode($DeletedRecord.Data) if ($DeletedRecord.Version -ge $script:RECORD_VERSION_V3) { return [KeeperSecurity.Utils.CryptoUtils]::DecryptAesV2($encryptedData, $DeletedRecord.RecordKeyUnencrypted) } else { return [KeeperSecurity.Utils.CryptoUtils]::DecryptAesV1($encryptedData, $DeletedRecord.RecordKeyUnencrypted) } } function ConvertTo-LegacyPasswordRecord { # Internal: Converts decrypted data (v0-2) to PasswordRecord [CmdletBinding()] [OutputType([KeeperSecurity.Vault.PasswordRecord])] param( [Parameter(Mandatory = $true)] [byte[]]$DecryptedData, [Parameter(Mandatory = $true)] $DeletedRecord ) $recordData = [KeeperSecurity.Utils.JsonUtils]::ParseJson([KeeperSecurity.Commands.RecordData], $DecryptedData) $passwordRecord = New-Object KeeperSecurity.Vault.PasswordRecord $passwordRecord.Uid = $DeletedRecord.RecordUid $passwordRecord.Version = $DeletedRecord.Version $passwordRecord.Title = $recordData.Title $passwordRecord.Login = $recordData.Secret1 $passwordRecord.Password = $recordData.Secret2 $passwordRecord.Link = $recordData.Link $passwordRecord.Notes = $recordData.Notes if ($recordData.Custom) { foreach ($cr in $recordData.Custom) { if ($cr) { $customField = New-Object KeeperSecurity.Vault.CustomField $customField.Name = $cr.Name $customField.Value = $cr.Value $customField.Type = $cr.Type $passwordRecord.Custom.Add($customField) } } } return $passwordRecord } function ConvertTo-ModernTypedRecord { # Internal: Converts decrypted JSON data (v3-6) to TypedRecord [CmdletBinding()] [OutputType([KeeperSecurity.Vault.TypedRecord])] param( [Parameter(Mandatory = $true)] [byte[]]$DecryptedData, [Parameter(Mandatory = $true)] $DeletedRecord ) $jsonString = [System.Text.Encoding]::UTF8.GetString($DecryptedData) $jsonData = $jsonString | ConvertFrom-Json $recordType = if ($jsonData.type) { $jsonData.type } else { "login" } $typedRecord = New-Object KeeperSecurity.Vault.TypedRecord($recordType) $typedRecord.Uid = $DeletedRecord.RecordUid $typedRecord.Version = $DeletedRecord.Version $typedRecord.Title = if ($jsonData.title) { $jsonData.title } else { "" } $typedRecord.Notes = if ($jsonData.notes) { $jsonData.notes } else { "" } if ($jsonData.fields) { foreach ($fieldData in $jsonData.fields) { $field = ConvertTo-TypedField -FieldData $fieldData if ($field) { $typedRecord.Fields.Add($field) } } } if ($jsonData.custom) { foreach ($fieldData in $jsonData.custom) { $field = ConvertTo-TypedField -FieldData $fieldData if ($field) { $typedRecord.Custom.Add($field) } } } return $typedRecord } function ConvertTo-ParsedRecord { # Internal: Decrypts and parses DeletedRecord to TypedRecord (v3+) or PasswordRecord (v0-2) [CmdletBinding()] [OutputType([KeeperSecurity.Vault.TypedRecord], [KeeperSecurity.Vault.PasswordRecord])] param( [Parameter(Mandatory = $true)] [ValidateNotNull()] $DeletedRecord ) try { Write-Verbose "Parsing record $($DeletedRecord.RecordUid) (Version $($DeletedRecord.Version))" $decryptedData = Get-DecryptedRecordData -DeletedRecord $DeletedRecord if ($null -eq $decryptedData) { Write-Error "Failed to decrypt record $($DeletedRecord.RecordUid). The record key may be invalid." return $null } if ($DeletedRecord.Version -ge $script:RECORD_VERSION_LEGACY_MIN -and $DeletedRecord.Version -le $script:RECORD_VERSION_LEGACY_MAX) { return ConvertTo-LegacyPasswordRecord -DecryptedData $decryptedData -DeletedRecord $DeletedRecord } elseif ($DeletedRecord.Version -in $script:RECORD_VERSION_V3, $script:RECORD_VERSION_V4, $script:RECORD_VERSION_V5, $script:RECORD_VERSION_V6) { return ConvertTo-ModernTypedRecord -DecryptedData $decryptedData -DeletedRecord $DeletedRecord } else { Write-Error "Unsupported record version $($DeletedRecord.Version) for record $($DeletedRecord.RecordUid)" return $null } } catch { Write-Error "Error parsing record $($DeletedRecord.RecordUid) (Version $($DeletedRecord.Version)): $($_.Exception.Message)" Write-Verbose $_.Exception.ToString() return $null } } function ConvertTo-TypedField { # Internal: Converts JSON field data to TypedField with proper value formatting [CmdletBinding()] param( [Parameter(Mandatory = $true)] [ValidateNotNull()] $FieldData ) try { $fieldType = if ($FieldData.type) { $FieldData.type } else { "text" } $label = if ($FieldData.label) { $FieldData.label } else { $null } Write-Verbose "Converting field type='$fieldType' label='$label'" $field = New-Object "KeeperSecurity.Vault.TypedField[object]"($fieldType, $label) if ($null -ne $FieldData.value) { if ($FieldData.value -is [array]) { foreach ($val in $FieldData.value) { if ($null -ne $val) { $convertedValue = ConvertTo-FieldValue -Value $val -FieldType $fieldType $field.Values.Add($convertedValue) } } } else { $convertedValue = ConvertTo-FieldValue -Value $FieldData.value -FieldType $fieldType $field.Values.Add($convertedValue) } } return $field } catch { Write-Verbose "Error converting field '$fieldType': $($_.Exception.Message)" Write-Verbose $_.Exception.ToString() return $null } } function ConvertTo-NameFieldValue { # Internal: Formats name field with first, middle, last components param([PSCustomObject]$Value) $parts = @() if ($Value.first) { $parts += $Value.first } if ($Value.middle) { $parts += $Value.middle } if ($Value.last) { $parts += $Value.last } if ($parts.Count -gt 0) { return ($parts -join ' ') } return $null } function ConvertTo-AddressFieldValue { # Internal: Formats address field with street, city, state, zip, country components param([PSCustomObject]$Value) $parts = @() if ($Value.street1) { $parts += $Value.street1 } if ($Value.street2) { $parts += $Value.street2 } if ($Value.city) { $parts += $Value.city } if ($Value.state) { $parts += $Value.state } if ($Value.zip) { $parts += $Value.zip } if ($Value.country) { $parts += $Value.country } if ($parts.Count -gt 0) { return ($parts -join ', ') } return $null } function ConvertTo-PhoneFieldValue { # Internal: Formats phone field with number, extension, and type param([PSCustomObject]$Value) if ($Value.number) { $result = $Value.number if ($Value.ext) { $result += " ext. $($Value.ext)" } if ($Value.type) { $result += " ($($Value.type))" } return $result } return $null } function ConvertTo-DateFieldValue { # Internal: Formats date field from Unix timestamp param($Value) if ($Value -is [long] -or ($Value -match '^\d+$')) { try { $timestamp = [long]$Value $dateTime = ConvertFrom-UnixTimestamp -Timestamp $timestamp if ($null -ne $dateTime) { return $dateTime.ToString('yyyy-MM-dd') } } catch { Write-Warning "Failed to convert date value '$Value': $($_.Exception.Message)" } } return $Value } function ConvertTo-FieldValue { # Internal: Formats field values (dates, names, addresses, phones) to readable strings [CmdletBinding()] param( [Parameter(Mandatory = $true)] $Value, [Parameter(Mandatory = $true)] [string]$FieldType ) if ($Value -is [PSCustomObject]) { $formatted = switch ($FieldType) { 'name' { ConvertTo-NameFieldValue -Value $Value } 'address' { ConvertTo-AddressFieldValue -Value $Value } 'phone' { ConvertTo-PhoneFieldValue -Value $Value } default { $null } } if ($null -ne $formatted) { return $formatted } return ($Value | ConvertTo-Json -Compress -Depth 10) } if ($FieldType -eq 'date') { return ConvertTo-DateFieldValue -Value $Value } return $Value } function Show-RecordDetails { # Internal: Formats and displays TypedRecord or PasswordRecord fields [CmdletBinding()] param( [Parameter(Mandatory = $true)] [ValidateNotNull()] $RecordData ) try { if ($RecordData -is [KeeperSecurity.Vault.TypedRecord]) { Write-Host ("{0,$script:FIELD_LABEL_WIDTH}: {1}" -f "Title", $RecordData.Title) Write-Host ("{0,$script:FIELD_LABEL_WIDTH}: {1}" -f "Type", $RecordData.TypeName) if ($RecordData.Fields.Count -gt 0) { foreach ($field in $RecordData.Fields) { Show-RecordField -Field $field } } if ($RecordData.Custom.Count -gt 0) { foreach ($field in $RecordData.Custom) { Show-RecordField -Field $field } } } elseif ($RecordData -is [KeeperSecurity.Vault.PasswordRecord]) { Write-Host ("{0,$script:FIELD_LABEL_WIDTH}: {1}" -f "Title", $RecordData.Title) Write-Host ("{0,$script:FIELD_LABEL_WIDTH}: {1}" -f "Type", [KeeperSecurity.Vault.VaultExtensions]::KeeperRecordType($RecordData)) if (-not [string]::IsNullOrEmpty($RecordData.Login)) { Write-Host ("{0,$script:FIELD_LABEL_WIDTH}: {1}" -f "Login", $RecordData.Login) } if (-not [string]::IsNullOrEmpty($RecordData.Password)) { Write-Host ("{0,$script:FIELD_LABEL_WIDTH}: {1}" -f "Password", $RecordData.Password) } if (-not [string]::IsNullOrEmpty($RecordData.Link)) { Write-Host ("{0,$script:FIELD_LABEL_WIDTH}: {1}" -f "URL", $RecordData.Link) } if (-not [string]::IsNullOrEmpty($RecordData.Notes)) { Write-Host ("{0,$script:FIELD_LABEL_WIDTH}: {1}" -f "Notes", $RecordData.Notes) } if ($RecordData.Custom.Count -gt 0) { foreach ($custom in $RecordData.Custom) { if (-not [string]::IsNullOrEmpty($custom.Value)) { $name = if (-not [string]::IsNullOrEmpty($custom.Name)) { $custom.Name } else { "Custom" } Write-Host ("{0,$script:FIELD_LABEL_WIDTH}: {1}" -f $name, $custom.Value) } } } } } catch { Write-Error "Error displaying record details: $($_.Exception.Message)" Write-Verbose $_.Exception.ToString() } } function Show-RecordField { # Internal: Displays field name and value(s), handling multi-value fields [CmdletBinding()] param( [Parameter(Mandatory = $true)] [ValidateNotNull()] $Field ) try { $fieldName = if (-not [string]::IsNullOrEmpty($Field.FieldLabel)) { $Field.FieldLabel } elseif (-not [string]::IsNullOrEmpty($Field.FieldName)) { $Field.FieldName } else { try { $name = [KeeperSecurity.Utils.RecordTypesUtils]::GetTypedFieldName($Field) if (-not [string]::IsNullOrEmpty($name)) { $name } else { Write-Verbose "Could not determine field name for field type $($Field.FieldName)" "Unknown" } } catch { Write-Verbose "Could not get field name: $($_.Exception.Message)" "Unknown" } } $valueArray = @() if ($Field.Values.Count -gt 0) { $valueArray = @($Field.Values) } else { try { $values = [KeeperSecurity.Utils.RecordTypesUtils]::GetTypedFieldValues($Field) if ($null -ne $values) { $valueArray = @($values) } } catch { Write-Verbose "Could not get typed field values for '$fieldName': $($_.Exception.Message)" } } for ($i = 0; $i -lt [Math]::Max($valueArray.Count, 1); $i++) { $value = if ($i -lt $valueArray.Count) { $valueArray[$i] } else { "" } if (-not [string]::IsNullOrEmpty($value)) { if ($i -eq 0) { Write-Host ("{0,$script:FIELD_LABEL_WIDTH}: {1}" -f $fieldName, $value) } else { Write-Host ("{0,$script:FIELD_LABEL_WIDTH} {1}" -f "", $value) } } } } catch { Write-Verbose "Error displaying field: $($_.Exception.Message)" } } function Show-RecordShares { # Internal: Retrieves and displays user permissions for a shared record [CmdletBinding()] param( [Parameter(Mandatory = $true)] [ValidateNotNull()] [KeeperSecurity.Vault.VaultOnline]$Vault, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string]$RecordUid ) try { Write-Verbose "Retrieving share information for record $RecordUid" $sharesTask = $Vault.GetSharesForRecords(@($RecordUid)) Invoke-TaskAndWait -Task $sharesTask $shares = $sharesTask.Result $recordShares = $shares | Select-Object -First 1 if ($null -ne $recordShares -and $null -ne $recordShares.UserPermissions -and $recordShares.UserPermissions.Length -gt 0) { $sortedPermissions = $recordShares.UserPermissions | Sort-Object @{Expression={if ($_.Owner) { " 1" } elseif ($_.CanEdit) { " 2" } elseif ($_.CanShare) { " 3" } else { "" }}}, Username $isFirst = $true foreach ($permission in $sortedPermissions) { if ($permission.Owner) { continue } $flags = [System.Collections.Generic.List[string]]::new() if ($permission.CanEdit) { $flags.Add("Can Edit") } if ($permission.CanShare) { $flags.Add($(if ($flags.Count -gt 0) { "& Can Share" } else { "Can Share" })) } $flagsText = if ($flags.Count -gt 0) { $flags -join " " } else { "Read Only" } $selfFlag = if ($null -ne $Vault.Auth -and $permission.Username -eq $Vault.Auth.Username) { "self" } else { "" } $header = if ($isFirst) { "Direct User Shares" } else { "" } Write-Host ("{0,$script:FIELD_LABEL_WIDTH}: {1,-26} ({2}) {3}" -f $header, $permission.Username, $flagsText, $selfFlag) $isFirst = $false } } } catch { Write-Verbose "Error loading share information for record $RecordUid`: $($_.Exception.Message)" } } function Clear-KeeperTrash { <# .SYNOPSIS Permanently deletes all records in trash .DESCRIPTION Permanently deletes all records in the trash. This action cannot be undone. .PARAMETER Force Skip confirmation prompts and purge immediately. .OUTPUTS None. Displays status messages to console. .EXAMPLE Clear-KeeperTrash Purges all trash records with confirmation prompt .EXAMPLE Clear-KeeperTrash -Force Purges all trash records without confirmation #> [CmdletBinding()] [OutputType([void])] param( [Parameter()] [switch]$Force ) if (-not $Force) { $confirmation = Read-Host "Are you sure you want to permanently delete all records in trash? This action cannot be undone. (yes/No)" if ($confirmation -notmatch '^(y|yes)$') { Write-Host "Purge operation cancelled" return } } try { $vault = Get-VaultOrThrow $request = New-Object KeeperSecurity.Commands.PurgeDeletedRecordsCommand $task = [KeeperSecurity.Authentication.AuthExtensions]::ExecuteAuthCommand($vault.Auth, $request) Invoke-TaskAndWait -Task $task Write-Host "Successfully purged all records from trash" } catch { Write-Error "Failed to purge trash: $($_.Exception.Message)" } } Set-Alias -Name ktrash -Value Get-KeeperTrashList Set-Alias -Name ktrash-restore -Value Restore-KeeperTrashRecords Set-Alias -Name ktrash-unshare -Value Remove-TrashedKeeperRecordShares Set-Alias -Name ktrash-get -Value Get-KeeperTrashedRecordDetails Set-Alias -Name ktrash-purge -Value Clear-KeeperTrash # SIG # Begin signature block # MIInvgYJKoZIhvcNAQcCoIInrzCCJ6sCAQExDzANBglghkgBZQMEAgEFADB5Bgor # BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG # KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCCNd9rakskC3fq/ # TeiDwN0ROvywtxLuKOXAG90tyvC7t6CCITswggWNMIIEdaADAgECAhAOmxiO+dAt # 5+/bUOIIQBhaMA0GCSqGSIb3DQEBDAUAMGUxCzAJBgNVBAYTAlVTMRUwEwYDVQQK # EwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xJDAiBgNV # BAMTG0RpZ2lDZXJ0IEFzc3VyZWQgSUQgUm9vdCBDQTAeFw0yMjA4MDEwMDAwMDBa # Fw0zMTExMDkyMzU5NTlaMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2Vy # dCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lD # ZXJ0IFRydXN0ZWQgUm9vdCBHNDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC # ggIBAL/mkHNo3rvkXUo8MCIwaTPswqclLskhPfKK2FnC4SmnPVirdprNrnsbhA3E # MB/zG6Q4FutWxpdtHauyefLKEdLkX9YFPFIPUh/GnhWlfr6fqVcWWVVyr2iTcMKy # unWZanMylNEQRBAu34LzB4TmdDttceItDBvuINXJIB1jKS3O7F5OyJP4IWGbNOsF # xl7sWxq868nPzaw0QF+xembud8hIqGZXV59UWI4MK7dPpzDZVu7Ke13jrclPXuU1 # 5zHL2pNe3I6PgNq2kZhAkHnDeMe2scS1ahg4AxCN2NQ3pC4FfYj1gj4QkXCrVYJB # MtfbBHMqbpEBfCFM1LyuGwN1XXhm2ToxRJozQL8I11pJpMLmqaBn3aQnvKFPObUR # WBf3JFxGj2T3wWmIdph2PVldQnaHiZdpekjw4KISG2aadMreSx7nDmOu5tTvkpI6 # nj3cAORFJYm2mkQZK37AlLTSYW3rM9nF30sEAMx9HJXDj/chsrIRt7t/8tWMcCxB # YKqxYxhElRp2Yn72gLD76GSmM9GJB+G9t+ZDpBi4pncB4Q+UDCEdslQpJYls5Q5S # UUd0viastkF13nqsX40/ybzTQRESW+UQUOsxxcpyFiIJ33xMdT9j7CFfxCBRa2+x # q4aLT8LWRV+dIPyhHsXAj6KxfgommfXkaS+YHS312amyHeUbAgMBAAGjggE6MIIB # NjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTs1+OC0nFdZEzfLmc/57qYrhwP # TzAfBgNVHSMEGDAWgBRF66Kv9JLLgjEtUYunpyGd823IDzAOBgNVHQ8BAf8EBAMC # AYYweQYIKwYBBQUHAQEEbTBrMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdp # Y2VydC5jb20wQwYIKwYBBQUHMAKGN2h0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNv # bS9EaWdpQ2VydEFzc3VyZWRJRFJvb3RDQS5jcnQwRQYDVR0fBD4wPDA6oDigNoY0 # aHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0QXNzdXJlZElEUm9vdENB # LmNybDARBgNVHSAECjAIMAYGBFUdIAAwDQYJKoZIhvcNAQEMBQADggEBAHCgv0Nc # Vec4X6CjdBs9thbX979XB72arKGHLOyFXqkauyL4hxppVCLtpIh3bb0aFPQTSnov # Lbc47/T/gLn4offyct4kvFIDyE7QKt76LVbP+fT3rDB6mouyXtTP0UNEm0Mh65Zy # oUi0mcudT6cGAxN3J0TU53/oWajwvy8LpunyNDzs9wPHh6jSTEAZNUZqaVSwuKFW # juyk1T3osdz9HNj0d1pcVIxv76FQPfx2CWiEn2/K2yCNNWAcAgPLILCsWKAOQGPF # mCLBsln1VWvPJ6tsds5vIy30fnFqI2si/xK4VC0nftg62fC2h5b9W9FcrBjDTZ9z # twGpn1eqXijiuZQwggawMIIEmKADAgECAhAIrUCyYNKcTJ9ezam9k67ZMA0GCSqG # SIb3DQEBDAUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMx # GTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0IFRy # dXN0ZWQgUm9vdCBHNDAeFw0yMTA0MjkwMDAwMDBaFw0zNjA0MjgyMzU5NTlaMGkx # CzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UEAxM4 # RGlnaUNlcnQgVHJ1c3RlZCBHNCBDb2RlIFNpZ25pbmcgUlNBNDA5NiBTSEEzODQg # MjAyMSBDQTEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDVtC9C0Cit # eLdd1TlZG7GIQvUzjOs9gZdwxbvEhSYwn6SOaNhc9es0JAfhS0/TeEP0F9ce2vnS # 1WcaUk8OoVf8iJnBkcyBAz5NcCRks43iCH00fUyAVxJrQ5qZ8sU7H/Lvy0daE6ZM # swEgJfMQ04uy+wjwiuCdCcBlp/qYgEk1hz1RGeiQIXhFLqGfLOEYwhrMxe6TSXBC # Mo/7xuoc82VokaJNTIIRSFJo3hC9FFdd6BgTZcV/sk+FLEikVoQ11vkunKoAFdE3 # /hoGlMJ8yOobMubKwvSnowMOdKWvObarYBLj6Na59zHh3K3kGKDYwSNHR7OhD26j # q22YBoMbt2pnLdK9RBqSEIGPsDsJ18ebMlrC/2pgVItJwZPt4bRc4G/rJvmM1bL5 # OBDm6s6R9b7T+2+TYTRcvJNFKIM2KmYoX7BzzosmJQayg9Rc9hUZTO1i4F4z8ujo # 7AqnsAMrkbI2eb73rQgedaZlzLvjSFDzd5Ea/ttQokbIYViY9XwCFjyDKK05huzU # tw1T0PhH5nUwjewwk3YUpltLXXRhTT8SkXbev1jLchApQfDVxW0mdmgRQRNYmtwm # KwH0iU1Z23jPgUo+QEdfyYFQc4UQIyFZYIpkVMHMIRroOBl8ZhzNeDhFMJlP/2NP # TLuqDQhTQXxYPUez+rbsjDIJAsxsPAxWEQIDAQABo4IBWTCCAVUwEgYDVR0TAQH/ # BAgwBgEB/wIBADAdBgNVHQ4EFgQUaDfg67Y7+F8Rhvv+YXsIiGX0TkIwHwYDVR0j # BBgwFoAU7NfjgtJxXWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMGA1Ud # JQQMMAoGCCsGAQUFBwMDMHcGCCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYYaHR0 # cDovL29jc3AuZGlnaWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2FjZXJ0 # cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNVHR8E # PDA6MDigNqA0hjJodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVz # dGVkUm9vdEc0LmNybDAcBgNVHSAEFTATMAcGBWeBDAEDMAgGBmeBDAEEATANBgkq # hkiG9w0BAQwFAAOCAgEAOiNEPY0Idu6PvDqZ01bgAhql+Eg08yy25nRm95RysQDK # r2wwJxMSnpBEn0v9nqN8JtU3vDpdSG2V1T9J9Ce7FoFFUP2cvbaF4HZ+N3HLIvda # qpDP9ZNq4+sg0dVQeYiaiorBtr2hSBh+3NiAGhEZGM1hmYFW9snjdufE5BtfQ/g+ # lP92OT2e1JnPSt0o618moZVYSNUa/tcnP/2Q0XaG3RywYFzzDaju4ImhvTnhOE7a # brs2nfvlIVNaw8rpavGiPttDuDPITzgUkpn13c5UbdldAhQfQDN8A+KVssIhdXNS # y0bYxDQcoqVLjc1vdjcshT8azibpGL6QB7BDf5WIIIJw8MzK7/0pNVwfiThV9zeK # iwmhywvpMRr/LhlcOXHhvpynCgbWJme3kuZOX956rEnPLqR0kq3bPKSchh/jwVYb # KyP/j7XqiHtwa+aguv06P0WmxOgWkVKLQcBIhEuWTatEQOON8BUozu3xGFYHKi8Q # xAwIZDwzj64ojDzLj4gLDb879M4ee47vtevLt/B3E+bnKD+sEq6lLyJsQfmCXBVm # zGwOysWGw/YmMwwHS6DTBwJqakAwSEs0qFEgu60bhQjiWQ1tygVQK+pKHJ6l/aCn # HwZ05/LWUpD9r4VIIflXO7ScA+2GRfS0YW6/aOImYIbqyK+p/pQd52MbOoZWeE4w # gga0MIIEnKADAgECAhANx6xXBf8hmS5AQyIMOkmGMA0GCSqGSIb3DQEBCwUAMGIx # CzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3 # dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0IFRydXN0ZWQgUm9vdCBH # NDAeFw0yNTA1MDcwMDAwMDBaFw0zODAxMTQyMzU5NTlaMGkxCzAJBgNVBAYTAlVT # MRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UEAxM4RGlnaUNlcnQgVHJ1 # c3RlZCBHNCBUaW1lU3RhbXBpbmcgUlNBNDA5NiBTSEEyNTYgMjAyNSBDQTEwggIi # MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC0eDHTCphBcr48RsAcrHXbo0Zo # dLRRF51NrY0NlLWZloMsVO1DahGPNRcybEKq+RuwOnPhof6pvF4uGjwjqNjfEvUi # 6wuim5bap+0lgloM2zX4kftn5B1IpYzTqpyFQ/4Bt0mAxAHeHYNnQxqXmRinvuNg # xVBdJkf77S2uPoCj7GH8BLuxBG5AvftBdsOECS1UkxBvMgEdgkFiDNYiOTx4OtiF # cMSkqTtF2hfQz3zQSku2Ws3IfDReb6e3mmdglTcaarps0wjUjsZvkgFkriK9tUKJ # m/s80FiocSk1VYLZlDwFt+cVFBURJg6zMUjZa/zbCclF83bRVFLeGkuAhHiGPMvS # GmhgaTzVyhYn4p0+8y9oHRaQT/aofEnS5xLrfxnGpTXiUOeSLsJygoLPp66bkDX1 # ZlAeSpQl92QOMeRxykvq6gbylsXQskBBBnGy3tW/AMOMCZIVNSaz7BX8VtYGqLt9 # MmeOreGPRdtBx3yGOP+rx3rKWDEJlIqLXvJWnY0v5ydPpOjL6s36czwzsucuoKs7 # Yk/ehb//Wx+5kMqIMRvUBDx6z1ev+7psNOdgJMoiwOrUG2ZdSoQbU2rMkpLiQ6bG # RinZbI4OLu9BMIFm1UUl9VnePs6BaaeEWvjJSjNm2qA+sdFUeEY0qVjPKOWug/G6 # X5uAiynM7Bu2ayBjUwIDAQABo4IBXTCCAVkwEgYDVR0TAQH/BAgwBgEB/wIBADAd # BgNVHQ4EFgQU729TSunkBnx6yuKQVvYv1Ensy04wHwYDVR0jBBgwFoAU7NfjgtJx # XWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMGA1UdJQQMMAoGCCsGAQUF # BwMIMHcGCCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGln # aWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2FjZXJ0cy5kaWdpY2VydC5j # b20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNVHR8EPDA6MDigNqA0hjJo # dHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNy # bDAgBgNVHSAEGTAXMAgGBmeBDAEEAjALBglghkgBhv1sBwEwDQYJKoZIhvcNAQEL # BQADggIBABfO+xaAHP4HPRF2cTC9vgvItTSmf83Qh8WIGjB/T8ObXAZz8OjuhUxj # aaFdleMM0lBryPTQM2qEJPe36zwbSI/mS83afsl3YTj+IQhQE7jU/kXjjytJgnn0 # hvrV6hqWGd3rLAUt6vJy9lMDPjTLxLgXf9r5nWMQwr8Myb9rEVKChHyfpzee5kH0 # F8HABBgr0UdqirZ7bowe9Vj2AIMD8liyrukZ2iA/wdG2th9y1IsA0QF8dTXqvcnT # mpfeQh35k5zOCPmSNq1UH410ANVko43+Cdmu4y81hjajV/gxdEkMx1NKU4uHQcKf # ZxAvBAKqMVuqte69M9J6A47OvgRaPs+2ykgcGV00TYr2Lr3ty9qIijanrUR3anzE # wlvzZiiyfTPjLbnFRsjsYg39OlV8cipDoq7+qNNjqFzeGxcytL5TTLL4ZaoBdqbh # OhZ3ZRDUphPvSRmMThi0vw9vODRzW6AxnJll38F0cuJG7uEBYTptMSbhdhGQDpOX # gpIUsWTjd6xpR6oaQf/DJbg3s6KCLPAlZ66RzIg9sC+NJpud/v4+7RWsWCiKi9EO # LLHfMR2ZyJ/+xhCx9yHbxtl5TPau1j/1MIDpMPx0LckTetiSuEtQvLsNz3Qbp7wG # WqbIiOWCnb5WqxL3/BAPvIXKUjPSxyZsq8WhbaM2tszWkPZPubdcMIIG7TCCBNWg # AwIBAgIQCoDvGEuN8QWC0cR2p5V0aDANBgkqhkiG9w0BAQsFADBpMQswCQYDVQQG # EwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xQTA/BgNVBAMTOERpZ2lDZXJ0 # IFRydXN0ZWQgRzQgVGltZVN0YW1waW5nIFJTQTQwOTYgU0hBMjU2IDIwMjUgQ0Ex # MB4XDTI1MDYwNDAwMDAwMFoXDTM2MDkwMzIzNTk1OVowYzELMAkGA1UEBhMCVVMx # FzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMTswOQYDVQQDEzJEaWdpQ2VydCBTSEEy # NTYgUlNBNDA5NiBUaW1lc3RhbXAgUmVzcG9uZGVyIDIwMjUgMTCCAiIwDQYJKoZI # hvcNAQEBBQADggIPADCCAgoCggIBANBGrC0Sxp7Q6q5gVrMrV7pvUf+GcAoB38o3 # zBlCMGMyqJnfFNZx+wvA69HFTBdwbHwBSOeLpvPnZ8ZN+vo8dE2/pPvOx/Vj8Tch # TySA2R4QKpVD7dvNZh6wW2R6kSu9RJt/4QhguSssp3qome7MrxVyfQO9sMx6ZAWj # FDYOzDi8SOhPUWlLnh00Cll8pjrUcCV3K3E0zz09ldQ//nBZZREr4h/GI6Dxb2Uo # yrN0ijtUDVHRXdmncOOMA3CoB/iUSROUINDT98oksouTMYFOnHoRh6+86Ltc5zjP # KHW5KqCvpSduSwhwUmotuQhcg9tw2YD3w6ySSSu+3qU8DD+nigNJFmt6LAHvH3KS # uNLoZLc1Hf2JNMVL4Q1OpbybpMe46YceNA0LfNsnqcnpJeItK/DhKbPxTTuGoX7w # JNdoRORVbPR1VVnDuSeHVZlc4seAO+6d2sC26/PQPdP51ho1zBp+xUIZkpSFA8vW # doUoHLWnqWU3dCCyFG1roSrgHjSHlq8xymLnjCbSLZ49kPmk8iyyizNDIXj//cOg # rY7rlRyTlaCCfw7aSUROwnu7zER6EaJ+AliL7ojTdS5PWPsWeupWs7NpChUk555K # 096V1hE0yZIXe+giAwW00aHzrDchIc2bQhpp0IoKRR7YufAkprxMiXAJQ1XCmnCf # gPf8+3mnAgMBAAGjggGVMIIBkTAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBTkO/zy # Me39/dfzkXFjGVBDz2GM6DAfBgNVHSMEGDAWgBTvb1NK6eQGfHrK4pBW9i/USezL # TjAOBgNVHQ8BAf8EBAMCB4AwFgYDVR0lAQH/BAwwCgYIKwYBBQUHAwgwgZUGCCsG # AQUFBwEBBIGIMIGFMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5j # b20wXQYIKwYBBQUHMAKGUWh0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdp # Q2VydFRydXN0ZWRHNFRpbWVTdGFtcGluZ1JTQTQwOTZTSEEyNTYyMDI1Q0ExLmNy # dDBfBgNVHR8EWDBWMFSgUqBQhk5odHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGln # aUNlcnRUcnVzdGVkRzRUaW1lU3RhbXBpbmdSU0E0MDk2U0hBMjU2MjAyNUNBMS5j # cmwwIAYDVR0gBBkwFzAIBgZngQwBBAIwCwYJYIZIAYb9bAcBMA0GCSqGSIb3DQEB # CwUAA4ICAQBlKq3xHCcEua5gQezRCESeY0ByIfjk9iJP2zWLpQq1b4URGnwWBdEZ # D9gBq9fNaNmFj6Eh8/YmRDfxT7C0k8FUFqNh+tshgb4O6Lgjg8K8elC4+oWCqnU/ # ML9lFfim8/9yJmZSe2F8AQ/UdKFOtj7YMTmqPO9mzskgiC3QYIUP2S3HQvHG1FDu # +WUqW4daIqToXFE/JQ/EABgfZXLWU0ziTN6R3ygQBHMUBaB5bdrPbF6MRYs03h4o # bEMnxYOX8VBRKe1uNnzQVTeLni2nHkX/QqvXnNb+YkDFkxUGtMTaiLR9wjxUxu2h # ECZpqyU1d0IbX6Wq8/gVutDojBIFeRlqAcuEVT0cKsb+zJNEsuEB7O7/cuvTQasn # M9AWcIQfVjnzrvwiCZ85EE8LUkqRhoS3Y50OHgaY7T/lwd6UArb+BOVAkg2oOvol # /DJgddJ35XTxfUlQ+8Hggt8l2Yv7roancJIFcbojBcxlRcGG0LIhp6GvReQGgMgY # xQbV1S3CrWqZzBt1R9xJgKf47CdxVRd/ndUlQ05oxYy2zRWVFjF7mcr4C34Mj3oc # CVccAvlKV9jEnstrniLvUxxVZE/rptb7IRE2lskKPIJgbaP5t2nGj/ULLi49xTcB # ZU8atufk+EMF/cWuiC7POGT75qaL6vdCvHlshtjdNXOCIUjsarfNZzCCB0kwggUx # oAMCAQICEAe0P3SLJmcoVNrErUyxTt0wDQYJKoZIhvcNAQELBQAwaTELMAkGA1UE # BhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMUEwPwYDVQQDEzhEaWdpQ2Vy # dCBUcnVzdGVkIEc0IENvZGUgU2lnbmluZyBSU0E0MDk2IFNIQTM4NCAyMDIxIENB # MTAeFw0yNTEyMzEwMDAwMDBaFw0yOTAxMDIyMzU5NTlaMIHRMRMwEQYLKwYBBAGC # NzwCAQMTAlVTMRkwFwYLKwYBBAGCNzwCAQITCERlbGF3YXJlMR0wGwYDVQQPDBRQ # cml2YXRlIE9yZ2FuaXphdGlvbjEQMA4GA1UEBRMHMzQwNzk4NTELMAkGA1UEBhMC # VVMxETAPBgNVBAgTCElsbGlub2lzMRAwDgYDVQQHEwdDaGljYWdvMR0wGwYDVQQK # ExRLZWVwZXIgU2VjdXJpdHkgSW5jLjEdMBsGA1UEAxMUS2VlcGVyIFNlY3VyaXR5 # IEluYy4wggGiMA0GCSqGSIb3DQEBAQUAA4IBjwAwggGKAoIBgQCUcNMoSVmxAi0a # vG+StFJMNFFTUIOo3HdBZ+0gqA1XpNgUx11vB1vCZrvFsD9m5oA58tdp4gZN3LmQ # aMvCl2ANUT7MilI02Hf1RWlygBzon6iE0GpU3lgRrwrk1dhtLpGsR6dbMKUUHprc # vKpXk90/VN+vhzY1uik1tCTxkDCPu/AYJg7m9+tR2KqvMuYMaMLhii66eWUAGsBC # h/uZxjkGoJF6qZ0DgFd7rW7VYljbfYSNPeZNGTDgB0J/wOsKl0mn612DTseIvAKt # 4vra/FLFukyEyStnfQ8lWYDcLLCMCjNVrzGipmT5E2iyx7Y1RZCIpNwVogp3Ixbk # Gbq5A/41YNOLLd4cFewyB2F037RevBCRsUODZEt1qBf7Jbu3DiYo1G+zTj9E0R1s # FzyijcfdsTm6X5ble+yCJeGkX5XgsyPnZpyz/FX9Fr0N9pMPGWwW2PKyHEnSytXm # 0Dxdq2P4mA4CBUxq7YoV26L2PF6QEh9BQdXTPcnLysUv7SI/a0ECAwEAAaOCAgIw # ggH+MB8GA1UdIwQYMBaAFGg34Ou2O/hfEYb7/mF7CIhl9E5CMB0GA1UdDgQWBBRG # 4H6CH8pvNX632bsdnrda4MtJLDA9BgNVHSAENjA0MDIGBWeBDAEDMCkwJwYIKwYB # BQUHAgEWG2h0dHA6Ly93d3cuZGlnaWNlcnQuY29tL0NQUzAOBgNVHQ8BAf8EBAMC # B4AwEwYDVR0lBAwwCgYIKwYBBQUHAwMwgbUGA1UdHwSBrTCBqjBToFGgT4ZNaHR0 # cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZEc0Q29kZVNpZ25p # bmdSU0E0MDk2U0hBMzg0MjAyMUNBMS5jcmwwU6BRoE+GTWh0dHA6Ly9jcmw0LmRp # Z2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNENvZGVTaWduaW5nUlNBNDA5NlNI # QTM4NDIwMjFDQTEuY3JsMIGUBggrBgEFBQcBAQSBhzCBhDAkBggrBgEFBQcwAYYY # aHR0cDovL29jc3AuZGlnaWNlcnQuY29tMFwGCCsGAQUFBzAChlBodHRwOi8vY2Fj # ZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkRzRDb2RlU2lnbmluZ1JT # QTQwOTZTSEEzODQyMDIxQ0ExLmNydDAJBgNVHRMEAjAAMA0GCSqGSIb3DQEBCwUA # A4ICAQA1Wlq0WzJa3N6DgjgBU7nagIJBab1prPARXZreX1MOv9VjnS5o0CrfQLr6 # z3bmWHw7xT8dt6bcSwRixqvPJtv4q8Rvo80O3eUMvMxQzqmi7z1zf+HG+/3G4F+2 # IYegvPc8Ui151XCV9rjA8tvFWRLRMX0ZRxY1zfT027HMw0iYL20z44+Cky//FAnL # iRwoNDGiRkZiHbB9YOftPAYNMG3gm1z3zOW5RdfKPrqvMuijE+dfyLIAA6Immpzu # FMH+Wgn8NnSlot9b4YKycaqqdjd7wXDjPub/oQ7VShuCSBWj+UNOTVh0vcZGackc # H1DLVgwp2dcKlxJiQKtkHT/T6LloY6LTe6+8wkVkr8EAv1W+q/+M1a4Ao+ykFbIA # 2LBEmA9qdgoLtenAYIiEg+48SjMPgyBbVPE3bhL1vIqjEIxYCfdmi6wx33oYX7HB # +bJ7zitHw4GgtpfPV8y8QRZImKmeDOKyXjQPDmQM/Eglm/Ns0GzBkVXM8h6UI34b # WZrHz9sbLSE20m5Svmxftvw5zju+I3WsmS/stNfWlOkwU0niUgwPHaz21kjXEA5A # g+aqv26wodqZcnGOlChoWDvSJ8KKgdOFbeAYKAMp1NY7iWV315zpGH19RipCR1NH # 0ND8iIubk3WGNf2rzEfqlOi3h2ywqVkU6AKXHdO5JV4otSKKEDGCBdkwggXVAgEB # MH0waTELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMUEwPwYD # VQQDEzhEaWdpQ2VydCBUcnVzdGVkIEc0IENvZGUgU2lnbmluZyBSU0E0MDk2IFNI # QTM4NCAyMDIxIENBMQIQB7Q/dIsmZyhU2sStTLFO3TANBglghkgBZQMEAgEFAKCB # hDAYBgorBgEEAYI3AgEMMQowCKACgAChAoAAMBkGCSqGSIb3DQEJAzEMBgorBgEE # AYI3AgEEMBwGCisGAQQBgjcCAQsxDjAMBgorBgEEAYI3AgEVMC8GCSqGSIb3DQEJ # BDEiBCBUIFkamSm4c01tDWNakSxBhei1Izyna1Vw/6ff3AjytTANBgkqhkiG9w0B # AQEFAASCAYCIAdqUmYeccEgzroQUif7z5K5UOJ4iFz0CtZZGWeFNFIzxqj/y/hx5 # E+Ax09vZtqOBLhh9XcgUvWMTN7vjDly0uxaa/Ow4W9b6W6C60EdGQIUcJnUfJ/iA # SJxN17n56uNHHrQutKV/RqxXvVKOxQnyx5MDGCbJATULFjsJpnVoborZdkCgFpJV # EHXg0ulsqxigFns+saOhoKHY8ObT+WXOX5GchdVD0bpsrQgKDzdw1pTzh/XE3Z6a # Elo0WTEjBaEGXw0DX1oiolfpVxZgcLecjLfm0zfARB6mEpzw+J2/NP2hhOvYOAVQ # GWzWO7sV4OtYYPrXMHV+pmlKtc3GgRC4WiG4/zwyS3Z5u5Om4kw3qk2RRvc9GkWg # dtiAXj2RmeZ3A8KHnY/mi6cDdCNsihjC4lN0ySlhFfeRPSrxyA/VIEgzyLMANeUV # WcVXO1IN/knjfzgSIsqXT8nMdb0qn5dmrX4IkOebVvSyRmAx3pkuA6AJCL91znxE # w4aYB84q16yhggMmMIIDIgYJKoZIhvcNAQkGMYIDEzCCAw8CAQEwfTBpMQswCQYD # VQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xQTA/BgNVBAMTOERpZ2lD # ZXJ0IFRydXN0ZWQgRzQgVGltZVN0YW1waW5nIFJTQTQwOTYgU0hBMjU2IDIwMjUg # Q0ExAhAKgO8YS43xBYLRxHanlXRoMA0GCWCGSAFlAwQCAQUAoGkwGAYJKoZIhvcN # AQkDMQsGCSqGSIb3DQEHATAcBgkqhkiG9w0BCQUxDxcNMjYwMjI1MjIzODA1WjAv # BgkqhkiG9w0BCQQxIgQgrMnmAPcbe8y4D5v2LzZlyBBylwBrwkDlJ4UhF1icLGgw # DQYJKoZIhvcNAQEBBQAEggIAkJYsY+qRuktROOF++YX3DgAzZLcKZa20nswa4aRG # cU58cb5kEkCHgURd2/ZXMn82LLZxWqH2r20Mgz8tgXjTbqJxkmsYJ7qPp8hvMZ1F # irYrlEBMEnLDw647/rg3N8g6QOYrHXRbnsTP67KAEwSz8omdB0Gbb5cIfDKsUG5G # Wgw/STA4rc9+/QYU7XcnwsZVGoBCTjhlV7WexX+lHOgMlkSLc+Qp8kCJ3keBTKCJ # cEz6/eENTGEBu1sjwvsrUf8hbQmr/3Tdwkxr+UjjvBMi4Vt6R/hgFxevgnWhyk6C # cpb/AAj3kDR3wMFrFZolsuN37eRlu+a0UCjHW1Cf5gQANX8GFcAEvN8erVPnrARC # v8oFnoM5CkcJN4QEhQdjVVjTQ2jI8Ez1jL46Qc5snw14Rh8VdFwpNUfeMxZw0pOT # FeqXKXa29zaIf3+3xa9W/KdqAR6RbDYPKdaAyqx7QRv+Zl7N4YY3qPI6/qUFhz4o # M9wobk0U8LhSxgtccssFV/vJklpMZmpBqr67a8BmYsWeQt63M6jYNAmpkQdtnaau # 3XQ1TI52Cuz0oYfIc13LZ9KuVIqd9NQ8pwy1cAVEUXbRXkliLOgqGT8BXq+moXGr # J7tJVc4qT8iv1hTjXMGU2NABAU/7FGKRJGBeo6ikajGkGb3ONYXCa5sigyt/IT0y # SkU= # SIG # End signature block |