Files
vaultwarden-ssh-agent/vaultwarden_ssh-agent.ps1

460 lines
14 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.
.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)
$Status = & bw status | ConvertFrom-Json
[Environment]::SetEnvironmentVariable("BW_SESSION", $null, [System.EnvironmentVariableTarget]::Process)
if ($LASTEXITCODE -eq 0) {
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 | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "Login failed"
}
# Get status again after login to retrieve user email
Write-Debug "Getting user info after login"
$Status = & bw status | ConvertFrom-Json
}
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 for user: $($Status.userEmail)"
$Result = & cmdkey /generic:"Vaultwarden_Session" /user:"$($Status.userEmail)" /pass:"$NewSession" | Out-Null
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"
try {
[Environment]::SetEnvironmentVariable("BW_SESSION", $Session, [System.EnvironmentVariableTarget]::Process)
$Folders = & bw list folders --search $FolderName | ConvertFrom-Json
}
finally {
[Environment]::SetEnvironmentVariable("BW_SESSION", $null, [System.EnvironmentVariableTarget]::Process)
}
$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"
try {
[Environment]::SetEnvironmentVariable("BW_SESSION", $Session, [System.EnvironmentVariableTarget]::Process)
$Items = & bw list items --folderid $FolderId | ConvertFrom-Json
}
finally {
[Environment]::SetEnvironmentVariable("BW_SESSION", $null, [System.EnvironmentVariableTarget]::Process)
}
# Add filter for SSH key type (type=5)
$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 ($SSHKey.PrivateKey) {
Clear-SensitiveData $SSHKey.PrivateKey
}
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"
try {
[Environment]::SetEnvironmentVariable("BW_SESSION", $Session, [System.EnvironmentVariableTarget]::Process)
& bw sync | Out-Null
$SyncExitCode = $LASTEXITCODE
}
finally {
[Environment]::SetEnvironmentVariable("BW_SESSION", $null, [System.EnvironmentVariableTarget]::Process)
}
if ($SyncExitCode -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
}
}