Support Bitwarden's new native SSH key type (type 5) introduced in CLI version 2024.12.0. Previously, SSH keys were stored as notes with attachments. This update removes the legacy handling and uses the structured sshKey property instead. - Add version check for Bitwarden CLI (>= 2024.12.0) - Filter items by type 5 (SSH key) in Get-FolderItems - Update Get-PrivatePublicKey to use sshKey.privateKey and sshKey.publicKey - Remove legacy handling of notes and attachments - Update prerequisites check to ensure minimum CLI version Breaking Changes: - Requires Bitwarden CLI version 2024.12.0 or higher - Only works with SSH keys stored as native SSH key items (type 5) - Existing SSH keys stored as notes with attachments must be migrated to the new SSH key item type manually
437 lines
13 KiB
PowerShell
437 lines
13 KiB
PowerShell
<#
|
|
.SYNOPSIS
|
|
Adds SSH keys from Vaultwarden to the SSH agent.
|
|
.DESCRIPTION
|
|
Retrieves SSH keys stored in Vaultwarden and adds them to the running SSH agent.
|
|
Public keys should be stored in the item's notes and private keys as attachments.
|
|
.PARAMETER Debug
|
|
Enables debug output.
|
|
.PARAMETER FolderName
|
|
The name of the Vaultwarden folder containing SSH keys. Defaults to "ssh-agent".
|
|
.PARAMETER ClearSession
|
|
Removes the stored Vaultwarden session from the Windows Credential Manager.
|
|
.EXAMPLE
|
|
.\Vaultwarden_ssh-agent.ps1
|
|
Imports all SSH keys from Vaultwarden to the SSH agent.
|
|
.EXAMPLE
|
|
.\Vaultwarden_ssh-agent.ps1 -FolderName "my-ssh-keys"
|
|
Imports SSH keys from a custom folder name.
|
|
.EXAMPLE
|
|
.\Vaultwarden_ssh-agent.ps1 -ClearSession
|
|
Removes the stored Vaultwarden session from the Windows Credential Manager.
|
|
#>
|
|
|
|
param(
|
|
[switch]$Debug,
|
|
[string]$FolderName = "ssh-agent",
|
|
[switch]$ClearSession
|
|
)
|
|
|
|
# Set debug preference
|
|
if ($Debug) {
|
|
$DebugPreference = 'Continue'
|
|
} else {
|
|
$DebugPreference = 'SilentlyContinue'
|
|
}
|
|
|
|
# Add CredManager type definition at script start
|
|
Add-Type -TypeDefinition @"
|
|
using System;
|
|
using System.Runtime.InteropServices;
|
|
|
|
public class CredManager {
|
|
[DllImport("advapi32.dll", SetLastError=true, CharSet=CharSet.Unicode)]
|
|
public static extern bool CredRead(string target, int type, int reservedFlag, out IntPtr credentialPtr);
|
|
|
|
[DllImport("advapi32.dll", SetLastError=true)]
|
|
public static extern void CredFree(IntPtr buffer);
|
|
|
|
[StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)]
|
|
public struct CREDENTIAL {
|
|
public int Flags;
|
|
public int Type;
|
|
public string TargetName;
|
|
public string Comment;
|
|
public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
|
|
public int CredentialBlobSize;
|
|
public IntPtr CredentialBlob;
|
|
public int Persist;
|
|
public int AttributeCount;
|
|
public IntPtr Attributes;
|
|
public string TargetAlias;
|
|
public string UserName;
|
|
}
|
|
}
|
|
"@
|
|
|
|
function Clear-BWSession {
|
|
[CmdletBinding()]
|
|
param()
|
|
|
|
Write-Debug "Removing stored session from Windows Credential Manager"
|
|
& cmdkey /delete:"Vaultwarden_Session" | Out-Null
|
|
}
|
|
|
|
function Test-Prerequisites {
|
|
# Check if ssh-add exists
|
|
if (-not (Get-Command "ssh-add" -ErrorAction SilentlyContinue)) {
|
|
throw "ssh-add command not found. Please ensure OpenSSH is installed."
|
|
}
|
|
|
|
# Check if SSH agent is running
|
|
$process = Get-Process ssh-agent -ErrorAction SilentlyContinue
|
|
if (-not $process) {
|
|
throw "SSH agent is not running. Please start the SSH agent service."
|
|
}
|
|
|
|
# Check if bw CLI is available and version
|
|
try {
|
|
$bwVersion = & bw --version
|
|
if ($bwVersion -match '(\d{4})\.(\d{1,2})' -and
|
|
("$($matches[1])$($matches[2].PadLeft(2,'0'))" -lt "202412")) {
|
|
throw "Bitwarden CLI version $bwVersion is not supported. Please upgrade to version 2024.12.0 or above to use SSH key features."
|
|
}
|
|
} catch {
|
|
throw "Bitwarden CLI not found or version check failed. Please install Bitwarden CLI 2024.12.0 or above."
|
|
}
|
|
}
|
|
|
|
function Test-VaultwardenConfig {
|
|
[CmdletBinding()]
|
|
param()
|
|
|
|
$ConfigPath = "$env:APPDATA\Bitwarden CLI\data.json"
|
|
if (Test-Path $ConfigPath) {
|
|
$Config = Get-Content $ConfigPath -Raw | ConvertFrom-Json
|
|
if ($Config.global_environment_environment.region -eq "Self-hosted" -and
|
|
$Config.global_environment_environment.urls.base) {
|
|
Write-Debug "Vaultwarden server is configured: $($Config.global_environment_environment.urls.base)"
|
|
return
|
|
}
|
|
}
|
|
|
|
Write-Host "Vaultwarden server is not configured. Please enter the server URL:"
|
|
$ServerUrl = Read-Host
|
|
& bw config server $ServerUrl
|
|
Write-Host "" # Add line break after failed attempt
|
|
Write-Host "Vaultwarden server configured to: $ServerUrl"
|
|
}
|
|
|
|
function Get-BWSession {
|
|
[CmdletBinding()]
|
|
param()
|
|
|
|
Write-Debug "Retrieving session from Windows Credential Manager"
|
|
|
|
$CredPtr = [IntPtr]::Zero
|
|
$Result = [CredManager]::CredRead("Vaultwarden_Session", 1, 0, [ref]$CredPtr)
|
|
|
|
if ($Result) {
|
|
try {
|
|
$Cred = [System.Runtime.InteropServices.Marshal]::PtrToStructure($CredPtr, [Type][CredManager+CREDENTIAL])
|
|
|
|
if (-not $Cred.CredentialBlobSize) {
|
|
Write-Debug "No stored session found"
|
|
return $null
|
|
}
|
|
|
|
$SecretBytes = New-Object byte[] $Cred.CredentialBlobSize
|
|
[System.Runtime.InteropServices.Marshal]::Copy($Cred.CredentialBlob, $SecretBytes, 0, $Cred.CredentialBlobSize)
|
|
$Session = [System.Text.Encoding]::Unicode.GetString($SecretBytes)
|
|
|
|
# Check session status
|
|
Write-Debug "Stored session found"
|
|
# bw status is always locked when using --session
|
|
# https://github.com/bitwarden/clients/issues/9254
|
|
[Environment]::SetEnvironmentVariable("BW_SESSION", $Session, [System.EnvironmentVariableTarget]::Process)
|
|
$StatusResult = & bw status 2>&1
|
|
[Environment]::SetEnvironmentVariable("BW_SESSION", $null, [System.EnvironmentVariableTarget]::Process)
|
|
if ($LASTEXITCODE -eq 0) {
|
|
$Status = $StatusResult | ConvertFrom-Json
|
|
if ($Status.status -eq "unlocked") {
|
|
Write-Debug "Stored session is valid"
|
|
Write-Host "Using stored session"
|
|
return $Session
|
|
}
|
|
}
|
|
|
|
Write-Debug "Stored session is invalid, removing"
|
|
& cmdkey /delete:"Vaultwarden_Session" | Out-Null
|
|
}
|
|
finally {
|
|
[CredManager]::CredFree($CredPtr)
|
|
if ($Session) {
|
|
Clear-SensitiveData $Session
|
|
}
|
|
}
|
|
}
|
|
|
|
Write-Host "Getting new session"
|
|
|
|
# Check current login status
|
|
Write-Debug "Checking login status"
|
|
$Status = & bw status | ConvertFrom-Json
|
|
|
|
if ($Status.status -eq "unauthenticated") {
|
|
Write-Debug "Not logged in, attempting API key login"
|
|
& bw login --apikey
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw "Login failed"
|
|
}
|
|
}
|
|
else {
|
|
Write-Debug "Already logged in as $($Status.userEmail)"
|
|
}
|
|
|
|
Write-Debug "Unlocking vault"
|
|
$NewSession = & bw unlock --raw
|
|
if (-not $NewSession) {
|
|
throw "Failed to unlock vault"
|
|
}
|
|
|
|
# Store session in Windows Credential Manager
|
|
Write-Debug "Storing session in Windows Credential Manager"
|
|
$Result = & cmdkey /generic:"Vaultwarden_Session" /user:$($Status.userEmail) /pass:$NewSession
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw "Failed to store session: $Result"
|
|
}
|
|
|
|
Write-Host "New session established"
|
|
return $NewSession
|
|
}
|
|
|
|
function Clear-SensitiveData {
|
|
[CmdletBinding()]
|
|
param(
|
|
[Parameter(Mandatory=$true)]
|
|
[System.Object]$Variable
|
|
)
|
|
|
|
if ($Variable -is [SecureString]) {
|
|
$Variable.Dispose()
|
|
}
|
|
elseif ($Variable -is [string]) {
|
|
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR(
|
|
[Runtime.InteropServices.Marshal]::StringToBSTR($Variable)
|
|
)
|
|
}
|
|
}
|
|
|
|
function Test-SSHKey {
|
|
[CmdletBinding()]
|
|
param(
|
|
[Parameter(Mandatory=$true)]
|
|
[string]$KeyContent,
|
|
|
|
[Parameter(Mandatory=$true)]
|
|
[ValidateSet('Public', 'Private')]
|
|
[string]$KeyType
|
|
)
|
|
|
|
$Patterns = @{
|
|
Public = '^(ssh-rsa|ssh-dss|ssh-ed25519|ecdsa-sha2-nistp\d+)\s+[A-Za-z0-9+/=]+\s*.*$'
|
|
Private = '(?sm)^-----BEGIN\s+(OPENSSH|RSA|EC|DSA)\s+PRIVATE\s+KEY-----.*-----END\s+(OPENSSH|RSA|EC|DSA)\s+PRIVATE\s+KEY-----$'
|
|
}
|
|
|
|
return $KeyContent -match $Patterns[$KeyType]
|
|
}
|
|
|
|
function Get-FolderId {
|
|
[CmdletBinding()]
|
|
param(
|
|
[Parameter(Mandatory=$true)]
|
|
[string]$Session
|
|
)
|
|
|
|
Write-Debug "Getting folder: $FolderName"
|
|
$Folders = & bw list folders --session $Session --search $FolderName | ConvertFrom-Json
|
|
$Folder = $Folders | Where-Object { $_.name -eq $FolderName }
|
|
|
|
if (-not $Folder) {
|
|
throw "'$FolderName' folder not found"
|
|
}
|
|
return $Folder.id
|
|
}
|
|
|
|
function Get-FolderItems {
|
|
[CmdletBinding()]
|
|
param(
|
|
[Parameter(Mandatory=$true)]
|
|
[string]$Session,
|
|
[Parameter(Mandatory=$true)]
|
|
[string]$FolderId
|
|
)
|
|
|
|
Write-Debug "Getting items from folder: $FolderId"
|
|
# Add filter for SSH key type (type=5)
|
|
$Items = & bw list items --session $Session --folderid $FolderId | ConvertFrom-Json
|
|
$SshKeyItems = $Items | Where-Object { $_.type -eq 5 }
|
|
Write-Debug "Found $($SshKeyItems.Count) SSH key items"
|
|
return $SshKeyItems
|
|
}
|
|
|
|
function Get-PrivatePublicKey {
|
|
[CmdletBinding()]
|
|
param(
|
|
[Parameter(Mandatory=$true)]
|
|
[string]$Session,
|
|
[Parameter(Mandatory=$true)]
|
|
[PSObject]$Item
|
|
)
|
|
|
|
try {
|
|
if ($Item.type -ne 5) {
|
|
throw "Item is not an SSH key type"
|
|
}
|
|
|
|
# Get public key from the sshKey property
|
|
$PublicKey = $Item.sshKey.publicKey
|
|
|
|
if (-not (Test-SSHKey -KeyContent $PublicKey -KeyType 'Public')) {
|
|
throw "Invalid public key format"
|
|
}
|
|
|
|
Write-Debug "Valid public key found for: $($Item.name)"
|
|
|
|
# Get private key from the sshKey property
|
|
$PrivateKey = $Item.sshKey.privateKey
|
|
|
|
if (-not (Test-SSHKey -KeyContent $PrivateKey -KeyType 'Private')) {
|
|
throw "Invalid private key format"
|
|
}
|
|
|
|
Write-Debug "Valid private key found for: $($Item.name)"
|
|
|
|
return @{
|
|
PublicKey = $PublicKey
|
|
PrivateKey = ConvertTo-SecureString $PrivateKey -AsPlainText -Force
|
|
Name = $Item.name
|
|
}
|
|
}
|
|
finally {
|
|
if ($PrivateKey) {
|
|
Clear-SensitiveData $PrivateKey
|
|
}
|
|
}
|
|
}
|
|
|
|
function Add-PrivateKeyToSSHAgent {
|
|
[CmdletBinding()]
|
|
param(
|
|
[Parameter(Mandatory=$true)]
|
|
[ValidateNotNull()]
|
|
[PSCustomObject]$SSHKey
|
|
)
|
|
|
|
try {
|
|
Write-Debug "Adding key: $($SSHKey.Name)"
|
|
|
|
$PrivateKeyPlain = [Runtime.InteropServices.Marshal]::PtrToStringAuto(
|
|
[Runtime.InteropServices.Marshal]::SecureStringToBSTR($SSHKey.PrivateKey)
|
|
)
|
|
|
|
$ProcessInfo = New-Object System.Diagnostics.ProcessStartInfo
|
|
$ProcessInfo.FileName = "ssh-add"
|
|
$ProcessInfo.Arguments = "-"
|
|
$ProcessInfo.RedirectStandardInput = $true
|
|
$ProcessInfo.RedirectStandardOutput = $true
|
|
$ProcessInfo.RedirectStandardError = $true
|
|
$ProcessInfo.UseShellExecute = $false
|
|
|
|
$Process = New-Object System.Diagnostics.Process
|
|
$Process.StartInfo = $ProcessInfo
|
|
|
|
$Process.Start() | Out-Null
|
|
$Process.StandardInput.WriteLine($PrivateKeyPlain)
|
|
$Process.StandardInput.Close()
|
|
$Process.WaitForExit()
|
|
|
|
if ($Process.ExitCode -eq 0) {
|
|
Write-Debug "Key added successfully: $($SSHKey.Name)"
|
|
return $true
|
|
} else {
|
|
Write-Warning "Failed to add key: $($SSHKey.Name)"
|
|
return $false
|
|
}
|
|
}
|
|
catch {
|
|
Write-Error "Exception adding key $($SSHKey.Name): $_"
|
|
return $false
|
|
}
|
|
finally {
|
|
if ($PrivateKeyPlain) {
|
|
Clear-SensitiveData $PrivateKeyPlain
|
|
}
|
|
if ($Process) {
|
|
$Process.Dispose()
|
|
}
|
|
}
|
|
}
|
|
|
|
# Main script execution
|
|
try {
|
|
if ($ClearSession) {
|
|
Clear-BWSession
|
|
Write-Host "Stored session has been cleared"
|
|
return
|
|
}
|
|
|
|
Test-Prerequisites
|
|
|
|
Write-Debug "Checking Vaultwarden configuration"
|
|
Test-VaultwardenConfig
|
|
|
|
# Get session
|
|
$Session = Get-BWSession
|
|
|
|
# Sync vault
|
|
Write-Debug "Syncing vault"
|
|
& bw sync --session $Session --quiet
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw "Failed to sync vault"
|
|
}
|
|
|
|
$FolderId = Get-FolderId -Session $Session
|
|
$Items = Get-FolderItems -Session $Session -FolderId $FolderId
|
|
|
|
$Results = @{
|
|
Success = @()
|
|
Failed = @()
|
|
}
|
|
|
|
Write-Host "Adding SSH keys to agent..."
|
|
foreach ($Item in $Items) {
|
|
try {
|
|
$SSHKey = Get-PrivatePublicKey -Session $Session -Item $Item
|
|
if (Add-PrivateKeyToSSHAgent -SSHKey $SSHKey) {
|
|
$Results.Success += $Item.name
|
|
} else {
|
|
$Results.Failed += $Item.name
|
|
}
|
|
}
|
|
catch {
|
|
Write-Warning "Failed to process $($Item.name): $_"
|
|
$Results.Failed += $Item.name
|
|
}
|
|
}
|
|
|
|
# Report results
|
|
Write-Host "`nResults:"
|
|
Write-Host "Successfully added keys: $($Results.Success.Count)"
|
|
$Results.Success | ForEach-Object { Write-Host " - $_" }
|
|
|
|
if ($Results.Failed.Count -gt 0) {
|
|
Write-Host "`nFailed to add keys: $($Results.Failed.Count)"
|
|
$Results.Failed | ForEach-Object { Write-Host " - $_" }
|
|
}
|
|
}
|
|
catch {
|
|
Write-Error "Critical error: $_"
|
|
exit 1
|
|
}
|
|
finally {
|
|
if ($Session) {
|
|
Clear-SensitiveData $Session
|
|
}
|
|
}
|