SSH 密钥操作手册

生成新密钥(指定标识)

Linux / macOS

mkdir -p ~/.ssh
ssh-keygen -t ed25519 -a 100 -C "your-identifier" -f ~/.ssh/id_ed25519

Windows PowerShell

mkdir -p $HOME/.ssh
ssh-keygen -t ed25519 -a 100 -C "your-identifier" -f $HOME/.ssh/id_ed25519
  • -t ed25519:使用 Ed25519 算法
  • -a 100:KDF 迭代轮数,增强私钥文件抗暴力破解
  • -C "your-identifier":注释标识,在 authorized_keys 中显示
  • -f:指定密钥文件路径
  • mkdir -p:确保 .ssh 目录存在

PowerShell 中 ~ 在原生程序(如 ssh-keygen)中不会展开,需用 $HOME

建议设一个强 passphrase。 passphrase 加密私钥文件本身,即使文件泄露也不可被直接用。

追加新公钥到远程(保留旧 key,安全过渡)

Linux / macOS

ssh-copy-id -i ~/.ssh/id_ed25519.pub user@host

Windows PowerShell

Get-Content $HOME\.ssh\id_ed25519.pub | ssh user@host "mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"
  • cat >>:追加写入,不删除已有 key

Windows 版 OpenSSH 不含 ssh-copy-id,用 PowerShell 管道等效实现。管道可能追加额外换行符,不影响认证。

清空本地 agent 并加入新 key

Linux / macOS

ssh-add -D && ssh-add ~/.ssh/id_ed25519

Windows PowerShell

ssh-add -D; ssh-add $HOME/.ssh/id_ed25519
  • ssh-add -D:清空 agent 中所有已加载的密钥
  • ssh-add:加入新密钥

ssh-add -l 可确认只有新 key 被加载。

验证新 key 能正常连接

Linux / macOS

# 直接测试,走 agent 提供的新 key
ssh user@host
 
# 严格测试:只试指定的私钥文件,不走 agent
ssh -T -o IdentitiesOnly=yes -i ~/.ssh/id_ed25519 -o PreferredAuthentications=publickey user@host echo ok

Windows PowerShell

# 直接测试,走 agent 提供的新 key
ssh user@host
 
# 严格测试:只试指定的私钥文件,不走 agent
ssh -T -o IdentitiesOnly=yes -i $HOME/.ssh/id_ed25519 -o PreferredAuthentications=publickey user@host echo ok
  • IdentitiesOnly=yes:跳过 agent,只尝试 -i 指定的私钥文件
  • -T:禁用伪终端,避免 Windows 客户端执行完命令后卡住
  • 两条命令互补:第一条确认 agent 里的 key 可用,第二条确认磁盘文件本身有效

确认连接正常后,远程只保留新公钥

Linux / macOS

ssh user@host "cat > ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys" < ~/.ssh/id_ed25519.pub

Windows PowerShell

Get-Content $HOME\.ssh\id_ed25519.pub | ssh user@host "cat > ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"

cat >覆盖远程 authorized_keys,清空所有旧 key,只保留新公钥。

注意:若 SSH 连接中途断开,可能造成 authorized_keys 为空或损坏,导致无法登录。 建议执行前确认有控制台或其他备用登录方式。

Bitwarden 管理 SSH Key

注意点

  • 私钥通过桌面端”从剪贴板导入”后 passphrase 会被丢弃(已知行为,参见 bitwarden/clients#13878);从其他密码管理器批量导入时 passphrase 通常保留为隐藏字段。
  • 从 Bitwarden 粘贴私钥到文件时,Windows 会使用 CRLF 换行符导致 SSH/Git 无法识别,需用 dos2unix 转换

方案思路

通过 Bitwarden CLI 提取私钥直接注入 ssh-agent,私钥不落盘,公钥文件(.pub)落盘用于 SSH config 引用:

Bitwarden Vault
    │  bw get item <id>
    ▼
bw-ssh-load 脚本
    │  ┌─ private key → ssh-add -(仅内存)
    │  └─ public key  → ~/.ssh/<name>.pub(落盘,供 SSH config 的 IdentityFile 引用;公钥不敏感)
    ▼
ssh-agent ── SSH 读取 .pub 后自动到 agent 匹配私钥

SSH Config 配置

使用公钥路径 + IdentitiesOnly yes,SSH 会从 agent 中找对应的私钥:

Host github.com
    HostName ssh.github.com
    User git
    Port 443
    IdentityFile ~/.ssh/git_id_ed25519.pub
    IdentitiesOnly yes

SSH 读取 .pub 获取公钥 blob,通过 SSH_AGENTC_REQUEST_IDENTITIES 协议消息查询 ssh-agent 中匹配的私钥,无需私钥文件落盘。IdentitiesOnly yes 确保只使用此 key,避免尝试 agent 中过多 key 导致 “Too many authentication failures”。

Windows 注意:Git for Windows 自带的 ssh 使用 Unix socket 模拟,无法连接 Windows ssh-agent 服务的命名管道 \\.\pipe\openssh-ssh-agent,导致 ssh -T 成功但 git pull 失败。修复:

# 找到系统 ssh 的路径
where.exe ssh
# 设到 Git 配置(示例路径,替换为实际输出)
git config --global core.sshCommand '"C:/Program Files/OpenSSH/ssh.exe"'

安装依赖

Linux

# Bitwarden CLI: https://bitwarden.com/help/cli 下载原生二进制,放 PATH
# jq
apt install jq        # Debian/Ubuntu
yum install jq        # RHEL/CentOS

安装后执行一次 bw login 完成初始登录,详见下方「首次配置」。

Windows(Scoop)

scoop install bitwarden-cli jq
 
# 启用 ssh-agent 服务
Set-Service ssh-agent -StartupType Automatic
Start-Service ssh-agent

安装后执行一次 bw login 完成初始登录,详见下方「首次配置」。

加载脚本

脚本自动管理 Bitwarden session:运行中 unlock → 加载 key → 执行完自动 lock,不残留 session。

两种模式:

  • 无参数 — 加载 Bitwarden 中指定文件夹下的 SSH Key 到 agent,仅对 PUB_KEY_NAMES 列表中的条目生成 .pub 文件。文件夹名通过 FOLDER_NAMES 配置;留空则加载全部
  • "条目名" [文件名] — 按名称查找单个 key 加载到 agent。支持 "文件夹名/条目名" 格式消歧。.pub 文件名默认取条目名(第二个参数覆盖)
  • --pub "条目名" — 仅输出公钥到 stdout,不注入 agent(同样支持 "/" 格式)

条目名为 Bitwarden UI 中看到的名称(如 “GitHub Personal”),不需要查 ID。不同文件夹有同名条目时用 "文件夹名/条目名" 消歧。

前置校验

脚本自动检查:

  • bwjqssh-add 是否已安装
  • ssh-agent 是否运行
  • 自动创建 ~/.ssh 并设权限 700

首次配置

脚本依赖 bw unlock --raw 获取 session token,需要先执行一次 bw login 将登录凭据持久化到本地:

# 设置服务器(自建实例需指定,官方服务跳过此步)
bw config server https://vault.bitwarden.com
 
# 登录(仅需一次,之后靠 bw unlock)
bw login

登录后 CLI 会在本地创建数据文件(Linux: ~/.config/Bitwarden CLI/data.json,Windows: %AppData%\Bitwarden CLI\data.json)。之后每次使用脚本只需 bw unlock 输入主密码即可,无需重新 login。

如果遇到 “Could not find data file … creating it instead” 提示,这是首次运行的正常行为,自动创建即可。

Bitwarden 二次验证

bw unlock --raw 会交互式提示输入主密码,如果启用了 2FA,CLI 会自动提示输入验证码,无需额外配置。

Linux(bash)

保存到 ~/.local/bin/bw-ssh-load

#!/usr/bin/env bash
set -euo pipefail
 
# ============================================================
# 配置区
#
# FOLDER_NAMES: 限制只从这些 Bitwarden 文件夹中加载 SSH Key
#   填入文件夹名称(对应 BW UI 中的文件夹名),留空则加载全部
#
# PUB_KEY_NAMES: 无参数模式下,仅对这些条目生成 .pub 文件
#   格式: "条目名" 或 "文件夹名/条目名"(同名条目在不同文件夹时需指定文件夹)
#   留空则不生成任何 .pub 文件
# ============================================================
FOLDER_NAMES=(
    # "SSH Keys"
    # "Work"
)
 
PUB_KEY_NAMES=(
    # "GitHub Personal"
    # "Work/GitHub"          # 文件夹名/条目名 格式
    # "Company Server"
)
# ============================================================
 
DRY_RUN=false
PUB_MODE=false
 
_pre_check() {
    command -v bw > /dev/null 2>&1 || { echo "✗ 'bw' 未安装。"; exit 1; }
    command -v jq > /dev/null 2>&1 || { echo "✗ 'jq' 未安装。"; exit 1; }
    command -v ssh-add > /dev/null 2>&1 || { echo "✗ 'ssh-add' 未安装。"; exit 1; }
 
    if [ "$DRY_RUN" = false ]; then
        _ret=0
        ssh-add -l > /dev/null 2>&1 || _ret=$?
        if [ $_ret -eq 2 ]; then
            echo "✗ ssh-agent 未运行或不可访问。"
            exit 1
        fi
        mkdir -p "$HOME/.ssh"
        chmod 700 "$HOME/.ssh"
    fi
}
 
_cleanup() {
    if [ -n "${BW_SESSION:-}" ]; then
        BW_SESSION="$BW_SESSION" bw lock > /dev/null 2>&1 || true
    fi
}
trap _cleanup EXIT
 
# ---- 参数解析 ----
ARGS=()
while [ $# -gt 0 ]; do
    case "$1" in
        --dry-run) DRY_RUN=true ;;
        --pub)     PUB_MODE=true ;;
        *)         ARGS+=("$1") ;;
    esac
    shift
done
 
_pre_check
 
echo "Unlocking Bitwarden..."
BW_SESSION=$(bw unlock --raw) || { echo "✗ 解锁失败"; exit 1; }
export BW_SESSION
 
if [ "$PUB_MODE" = true ]; then
    ITEM_NAME="${ARGS[0]:-}"
    [ -z "$ITEM_NAME" ] && { echo "✗ --pub 需要指定条目名"; exit 1; }
 
    # 解析 文件夹名/条目名 格式
    if [[ "$ITEM_NAME" == */* ]]; then
        FOLDER_FILTER="${ITEM_NAME/*}"
                pub_item="${pub_entry#*/}"
            else
                pub_folder=""
                pub_item="$pub_entry"
            fi
            [ "$name" != "$pub_item" ] && continue
            [ -n "$pub_folder" ] && [ "$folder_name" != "$pub_folder" ] && continue
            [ -z "$public_key" ] && continue
 
            safe_name=$(echo "$name" | tr ' ' '_' | tr -cd '[:alnum:]_.-')
            if [ -n "${_PUB_WRITTEN[$safe_name]:-}" ]; then
                echo "✗ PUB_KEY_NAMES 冲突: '$safe_name.pub' 匹配到多个条目"
                echo "  - ${_PUB_WRITTEN[$safe_name]}"
                [ -n "$folder_name" ] && echo "  - $folder_name/$name" || echo "  - $name"
                echo "✗ 请用 '文件夹名/条目名' 格式消歧(如 \"$folder_name/$name\")"
                exit 1
            fi
            [ -n "$folder_name" ] && _PUB_WRITTEN[$safe_name]="$folder_name/$name" || _PUB_WRITTEN[$safe_name]="$name"
 
            if [ "$DRY_RUN" = false ]; then
                printf '%s' "$public_key" > "$HOME/.ssh/${safe_name}.pub"
                echo "    → .pub: ~/.ssh/${safe_name}.pub"
            else
                echo "    [DRY RUN] .pub → ~/.ssh/${safe_name}.pub"
            fi
            break
        done
    done < <(echo "$items_json" | jq -c '.[]')
else
    # 单 key 模式:按名称(支持 文件夹名/条目名 格式)查找
    ITEM_NAME="${ARGS[0]}"
    if [[ "$ITEM_NAME" == */* ]]; then
        FOLDER_FILTER="${ITEM_NAME%%/*}"
        ITEM_NAME="${ITEM_NAME#*/}"
    else
        FOLDER_FILTER=""
    fi
    OUTPUT_NAME="${ARGS[1]:-$(echo "$ITEM_NAME" | tr ' ' '_' | tr -cd '[:alnum:]_.-')}"
 
    if [ -n "$FOLDER_FILTER" ]; then
        folder_id=$(BW_SESSION="$BW_SESSION" bw list folders | jq -r --arg name "$FOLDER_FILTER" '.[] | select(.name==$name) | .id')
        [ -z "$folder_id" ] && { echo "✗ 未找到文件夹: $FOLDER_FILTER"; exit 1; }
        matches=$(BW_SESSION="$BW_SESSION" bw list items --folderid "$folder_id" --search "$ITEM_NAME" | jq -c --arg name "$ITEM_NAME" '[.[] | select(.type==5 and .name==$name)]')
    else
        matches=$(BW_SESSION="$BW_SESSION" bw list items --search "$ITEM_NAME" | jq -c --arg name "$ITEM_NAME" '[.[] | select(.type==5 and .name==$name)]')
    fi
 
    count=$(echo "$matches" | jq 'length')
    if [ "$count" -eq 0 ]; then
        [ -n "$FOLDER_FILTER" ] && echo "✗ 文件夹 '$FOLDER_FILTER' 中未找到: $ITEM_NAME" || echo "✗ 未找到: $ITEM_NAME"
        exit 1
    elif [ "$count" -gt 1 ]; then
        echo "✗ 有 $count 个条目匹配 '$ITEM_NAME',请用 '文件夹名/条目名' 格式消歧或修改名称使其唯一:"
        echo "$matches" | jq -r '.[] | "  - \(.name)  [文件夹ID: \(.folderId // "无")]"'
        exit 1
    fi
 
    item=$(echo "$matches" | jq -c '.[0]')
    private_key=$(echo "$item" | jq -r '.sshKey.privateKey // ""')
    public_key=$(echo "$item" | jq -r '.sshKey.publicKey // ""')
    [ -z "$private_key" ] && { echo "✗ 条目缺少私钥"; exit 1; }
 
    if [ "$DRY_RUN" = false ]; then
        printf '%s' "$private_key" | ssh-add - && echo "✔ $ITEM_NAME → agent"
    else
        echo "[DRY RUN] $ITEM_NAME → agent"
    fi
 
    if [ -n "$public_key" ]; then
        if [ "$DRY_RUN" = false ]; then
            printf '%s' "$public_key" > "$HOME/.ssh/${OUTPUT_NAME}.pub"
            echo "✔ .pub → ~/.ssh/${OUTPUT_NAME}.pub"
        else
            echo "[DRY RUN] .pub → ~/.ssh/${OUTPUT_NAME}.pub"
        fi
    fi
fi
 
if [ "$DRY_RUN" = false ]; then
    ssh-add -l
fi
echo "Done."

运行:

chmod +x ~/.local/bin/bw-ssh-load
 
# 无参数——加载所有 SSH Key
bw-ssh-load
 
# 单个 key(自动文件名)
bw-ssh-load "GitHub Personal"
 
# 单个 key(指定文件名:第2个参数覆盖默认的 .pub 文件名)
bw-ssh-load "GitHub Personal" "git_id_ed25519"
 
# 单个 key(指定文件夹/条目名)
bw-ssh-load "SSH Keys/GitHub"
bw-ssh-load "SSH Keys/GitHub" "git_id_ed25519"
 
# 预览模式:不执行任何实际操作
bw-ssh-load --dry-run
bw-ssh-load "GitHub Personal" --dry-run
 
# 仅输出公钥到 stdout(用于分发)
bw-ssh-load --pub "GitHub Personal"
bw-ssh-load --pub "GitHub Personal" --dry-run
bw-ssh-load --pub "Work/GitHub"

Windows(PowerShell)

保存到 PATH 目录(如 $HOME\scoop\shims\bw-ssh-load.ps1):

#Requires -Version 5.1
param(
    [string]$ItemName,
    [string]$KeyName,
    [switch]$DryRun,
    [switch]$Pub
)
 
$ErrorActionPreference = "Stop"
 
# ============================================================
# 配置区
#
# FolderNames: 限制只从这些 Bitwarden 文件夹中加载
# PubKeyNames: 无参数模式下,仅对这些条目生成 .pub 文件
# ============================================================
$script:FolderNames = @(
    # "SSH Keys"
    # "Work"
)
$script:PubKeyNames = @(
    # "GitHub Personal"
    # "Work/GitHub"          # 文件夹名/条目名 格式
    # "Company Server"
)
# ============================================================
 
function PreCheck {
    if (-not (Get-Command bw -ErrorAction SilentlyContinue)) {
        Write-Host "✗ 'bw' 未安装。scoop install bitwarden-cli"; exit 1
    }
    if (-not (Get-Command ssh-add -ErrorAction SilentlyContinue)) {
        Write-Host "✗ 'ssh-add' 未安装"; exit 1
    }
 
    if (-not $DryRun) {
        $null = ssh-add -l 2>&1
        if ($LASTEXITCODE -eq 2) {
            Write-Host "✗ ssh-agent 未运行。请执行: Start-Service ssh-agent"; exit 1
        }
 
        $sshDir = "$env:USERPROFILE\.ssh"
        if (-not (Test-Path $sshDir)) { New-Item -ItemType Directory -Path $sshDir | Out-Null }
    }
}
 
function Cleanup {
    if ($env:BW_SESSION) {
        $null = bw lock 2>&1
        Remove-Item Env:BW_SESSION -ErrorAction SilentlyContinue
    }
}
 
PreCheck
 
Write-Host "Unlocking Bitwarden..."
$env:BW_SESSION = bw unlock --raw
if (-not $env:BW_SESSION) { Write-Host "✗ 解锁失败"; exit 1 }
 
if ($Pub) {
    if (-not $ItemName) { Write-Host "✗ -Pub 需要指定条目名"; exit 1 }
 
    # 解析 文件夹名/条目名 格式
    if ($ItemName -match '/') {
        $parts = $ItemName -split '/', 2
        $FolderName = $parts[0]
        $ItemName   = $parts[1]
    } else {
        $FolderName = ""
    }
 
    try {
        if ($FolderName) {
            $folder = bw list folders | ConvertFrom-Json | Where-Object { $_.name -eq $FolderName } | Select-Object -First 1
            if (-not $folder) { Write-Host "✗ 未找到文件夹: $FolderName"; exit 1 }
            $allItems = bw list items --folderid $folder.id --search $ItemName | ConvertFrom-Json
        } else {
            $allItems = bw list items --search $ItemName | ConvertFrom-Json
        }
        $matches = @($allItems | Where-Object { $_.type -eq 5 -and $_.name -eq $ItemName })
 
        if ($matches.Count -eq 0) {
            if ($FolderName) { Write-Host "✗ 文件夹 '$FolderName' 中未找到: $ItemName" } else { Write-Host "✗ 未找到: $ItemName" }
            exit 1
        }
        if ($matches.Count -gt 1) {
            Write-Host "✗ 有 $($matches.Count) 个条目匹配 '$ItemName',请用 '文件夹名/条目名' 格式消歧:"
            $matches | ForEach-Object { Write-Host "  - $($_.name)  [文件夹ID: $($_.folderId)]" }
            exit 1
        }
 
        $detail = bw get item $matches[0].id | ConvertFrom-Json
        if (-not $detail.sshKey.publicKey) { Write-Host "✗ 条目无公钥"; exit 1 }
        if (-not $DryRun) {
            Write-Output $detail.sshKey.publicKey
        } else {
            Write-Host "[DRY RUN] 将输出 $ItemName 的公钥到 stdout"
        }
    } finally {
        Cleanup
    }
    return
}
 
try {
    if (-not $ItemName) {
        # 无参数模式:按文件夹过滤后加载
        if ($FolderNames.Count -gt 0) {
            $folders = bw list folders | ConvertFrom-Json | Where-Object { $FolderNames -contains $_.name }
            $folderIds = $folders | ForEach-Object { $_.id }
            $items = bw list items | ConvertFrom-Json |
                Where-Object { $_.type -eq 5 -and $_.folderId -in $folderIds }
        } else {
            $items = bw list items | ConvertFrom-Json | Where-Object { $_.type -eq 5 }
        }
        if (-not $items) { Write-Host "没有找到 SSH Key 类型条目。"; return }
 
        # 预先获取文件夹 id → name 映射
        $folderMap = @{}
        bw list folders | ConvertFrom-Json | ForEach-Object { $folderMap[$_.id] = $_.name }
 
        $pubWritten = @{}
 
        foreach ($item in $items) {
            try {
                if (-not $DryRun) {
                    $item.sshKey.privateKey | ssh-add - 2>&1 | Out-Null
                }
                $tag = if ($DryRun) { "[DRY RUN]" } else { "✔" }
                Write-Host "  $tag $($item.name) → agent"
            } catch {
                Write-Host "  ⚠ $($item.name): 加载失败"; continue
            }
 
            if (-not $item.sshKey.publicKey) { continue }
 
            foreach ($pubEntry in $PubKeyNames) {
                if ($pubEntry -match '/') {
                    $parts = $pubEntry -split '/', 2
                    $pubFolder = $parts[0]
                    $pubItem   = $parts[1]
                } else {
                    $pubFolder = ""
                    $pubItem   = $pubEntry
                }
                if ($item.name -ne $pubItem) { continue }
 
                $itemFolderName = if ($item.folderId) { $folderMap[$item.folderId] } else { "" }
                if ($pubFolder -and $itemFolderName -ne $pubFolder) { continue }
 
                $safeName = $item.name -replace '[^\w.-]', '_'
                if ($pubWritten.ContainsKey($safeName)) {
                    Write-Host "✗ PUB_KEY_NAMES 冲突: '$safeName.pub' 匹配到多个条目"
                    Write-Host "  - $($pubWritten[$safeName])"
                    if ($itemFolderName) { Write-Host "  - $itemFolderName/$($item.name)" } else { Write-Host "  - $($item.name)" }
                    Write-Host "✗ 请用 '文件夹名/条目名' 格式消歧(如 '$itemFolderName/$($item.name)')"
                    exit 1
                }
                if ($itemFolderName) { $pubWritten[$safeName] = "$itemFolderName/$($item.name)" } else { $pubWritten[$safeName] = $item.name }
 
                if (-not $DryRun) {
                    $item.sshKey.publicKey | Out-File -Encoding ASCII "$env:USERPROFILE\.ssh\$safeName.pub"
                }
                $tag = if ($DryRun) { "[DRY RUN]" } else { "✔" }
                Write-Host "    $tag .pub → ~/.ssh/$safeName.pub"
                break
            }
        }
    } else {
        # 单 key 模式:按名称(支持 文件夹名/条目名 格式)查找
        if ($ItemName -match '/') {
            $parts = $ItemName -split '/', 2
            $FolderName = $parts[0]
            $ItemName   = $parts[1]
        } else {
            $FolderName = ""
        }
        if (-not $KeyName) { $KeyName = $ItemName -replace '[^\w.-]', '_' }
 
        if ($FolderName) {
            $folder = bw list folders | ConvertFrom-Json | Where-Object { $_.name -eq $FolderName } | Select-Object -First 1
            if (-not $folder) { Write-Host "✗ 未找到文件夹: $FolderName"; exit 1 }
            $allItems = bw list items --folderid $folder.id --search $ItemName | ConvertFrom-Json
        } else {
            $allItems = bw list items --search $ItemName | ConvertFrom-Json
        }
        $matches = @($allItems | Where-Object { $_.type -eq 5 -and $_.name -eq $ItemName })
 
        if ($matches.Count -eq 0) {
            if ($FolderName) { Write-Host "✗ 文件夹 '$FolderName' 中未找到: $ItemName" } else { Write-Host "✗ 未找到: $ItemName" }
            exit 1
        }
        if ($matches.Count -gt 1) {
            Write-Host "✗ 有 $($matches.Count) 个条目匹配 '$ItemName',请用 '文件夹名/条目名' 格式消歧:"
            $matches | ForEach-Object { Write-Host "  - $($_.name)  [文件夹ID: $($_.folderId)]" }
            exit 1
        }
 
        $detail = bw get item $matches[0].id | ConvertFrom-Json
        if (-not $detail.sshKey.privateKey) { Write-Host "✗ 条目缺少私钥"; exit 1 }
 
        if (-not $DryRun) {
            $detail.sshKey.privateKey | ssh-add - | Out-Null
        }
        $tag = if ($DryRun) { "[DRY RUN]" } else { "✔" }
        Write-Host "$tag $ItemName → agent"
 
        if ($detail.sshKey.publicKey) {
            if (-not $DryRun) {
                $detail.sshKey.publicKey | Out-File -Encoding ASCII "$env:USERPROFILE\.ssh\$KeyName.pub"
            }
            Write-Host "$tag .pub → ~/.ssh/$KeyName.pub"
        }
    }
} finally {
    Cleanup
}
 
if (-not $DryRun) { ssh-add -l }
Write-Host "Done."

运行:

# 无参数——加载所有 SSH Key
.\bw-ssh-load.ps1
 
# 单个 key(自动文件名)
.\bw-ssh-load.ps1 -ItemName "GitHub Personal"
 
# 单个 key(指定文件名:-KeyName 覆盖 .pub 文件名,默认取自 -ItemName)
.\bw-ssh-load.ps1 -ItemName "GitHub Personal" -KeyName "git_id_ed25519"
 
# 单个 key(指定文件夹/条目名)
.\bw-ssh-load.ps1 -ItemName "SSH Keys/GitHub"
.\bw-ssh-load.ps1 -ItemName "SSH Keys/GitHub" -KeyName "git_id_ed25519"
 
# 预览模式:不执行实际操作
.\bw-ssh-load.ps1 -DryRun
.\bw-ssh-load.ps1 -ItemName "GitHub Personal" -DryRun
 
# 仅输出公钥到 stdout(用于分发)
.\bw-ssh-load.ps1 -Pub -ItemName "GitHub Personal"
.\bw-ssh-load.ps1 -Pub -ItemName "GitHub Personal" -DryRun
.\bw-ssh-load.ps1 -Pub -ItemName "Work/GitHub"

安全性:ssh-agent 中的私钥可以被导出吗?

标准 SSH agent 协议不支持导出私钥。 agent 是一个 “签名预言机”(signing oracle),只提供签名操作,不透露私钥材料。

风险面ssh-agent(无 passphrase)文件落盘(无 passphrase)
私钥文件被窃❌ 无文件可窃✅ 文件可被复制
agent socket 被访问⚠️ 可用 key 签名,但无法提取私钥N/A
root/ptrace 读进程内存⚠️ 理论上可提取,需高权限⚠️ 同样风险

结论:协议层面 agent 不提供导出私钥的操作(RFC 9987)。但需注意:(1) root 级内存转储可提取私钥 (2) agent forwarding 会暴露签名能力 (3) 选择消息签名攻击可从部分算法恢复私钥。相比无 passphrase 的私钥文件落盘,注入 agent 无文件可被盗,总体更安全。

但仍然建议:用完即删(ssh-add -d <fingerprint>ssh-add -D 清空 agent),下次用再重新加载。

其他说明

  • 无参数模式默认不生成 .pub 文件,需编辑脚本中的 PUB_KEY_NAMES 数组指定需要生成的文件名
  • 单 key 模式始终生成 .pub 文件(第二个参数可指定文件名)
  • --pub 模式仅输出公钥到 stdout,不注入 agent(用于公钥分发或检查)
  • 文件夹名/条目名 格式用于区分不同文件夹中的同名条目;无参数模式的 PUB_KEY_NAMES、单 key 模式、--pub 模式均支持
  • --dry-run 仅预览操作结果,不实际注入 agent 也不写文件
  • 重启后 ssh-agent 中的 key 丢失,需重新执行脚本
  • agent 中的 key 不会随 Bitwarden session 过期而失效,一次加载长期可用

公钥分发到远程服务器

Bitwarden 管理的密钥没有私钥落盘,但公钥可以通过以下方式分发到远程服务器。

方法一:利用脚本生成的 .pub 文件(推荐)

运行加载脚本后 .pub 已落盘,直接用 ssh-copy-id 或 SSH 管道追加:

Linux/macOS:

bw-ssh-load "GitHub Personal"
ssh-copy-id -i ~/.ssh/git_id_ed25519.pub user@host

Windows PowerShell:

.\bw-ssh-load.ps1 -ItemName "GitHub Personal"
Get-Content $HOME\.ssh\git_id_ed25519.pub | ssh user@host "cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"

方法二:通过 --pub 模式直接输出

跳过落盘,从 Bitwarden 提取公钥直接通过管道分发:

Linux/macOS:

# 直接追加到远程
bw-ssh-load --pub "GitHub Personal" | ssh user@host "cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"
 
# 配合 ssh-copy-id(需要进程替换)
ssh-copy-id -i <(bw-ssh-load --pub "GitHub Personal") user@host

Windows PowerShell(无进程替换,两步操作):

$pub = .\bw-ssh-load.ps1 -Pub -ItemName "GitHub Personal"
$pub | ssh user@host "cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"

注意:追加模式(cat >>)保留已有 key,若想只保留新 key 则用 cat > 覆盖。先确认有其他方式可登录再执行覆盖操作。