mofelee/yubitouch

★ 2Forks 0GoGitHub ↗Compare

README

YubiTouch

YubiTouch 是一个面向 macOS、YubiKey PIV、YKCS11、OpenSSH 和 age 的本地密钥操作 服务。它在真正发生 PIV 签名或 age X25519 解密时显示原生触摸提示,并在操作返回后自动关闭。 公共入口 ~/.ssh/yubitouch/agent.sock 保持不变;可选的缺卡回退可以在 YubiKey PIV Agent 与 1Password SSH Agent 之间原子切换该入口。 目标 YubiKey 已连接时,还可以由用户显式开启独立的 1Password 临时 SSH 签名会话:当前请求 可在触摸阶段改用软件 key,或预先开启固定 1、3、5 分钟的本地签名窗口。 独立入口 age-plugin-yubitouch 则让 age 使用已有的 PIV X25519 主密钥。发送方使用标准 age1... recipient 和原生 age;接收方日常用 YubiKey 解密,也可显式选择 1Password 中保存的 同一私钥完成仅本次或固定 1、3、5 分钟的软件解密。

YubiTouch 是独立开源项目,与 Yubico 没有关联,也未获得 Yubico 的认可或背书。

当前状态:v0.1 源码构建版。Agent 路由/PIV 代理、LaunchAgent、OpenSSH backend、AskPass、 1Password Go SDK、原生 UI 和主要真实环境兼容性验证已经完成。项目只支持用户从可信 源码在本机完成构建与安装,不计划提供预编译应用、Developer ID 签名/公证或 Homebrew 包。

age 用户可以直接阅读:

YubiTouch SSH 签名触摸提醒

真实签名请求会显示请求方、YubiTouch 和当前触摸状态。

安装与首次使用

项目不提供预编译安装包。请只从你信任的源码 checkout 在本机构建。下面是从一把未配置 的 YubiKey 到首次 SSH 登录的完整步骤。

1. 安装依赖

要求 macOS 13 或更高版本、YubiKey 5.7 或更高固件、Go 1.25 或更高版本,以及 Xcode Command Line Tools。PIV 的 ED25519 支持从 YubiKey 5.7 开始,OpenSSH 的 ED25519 PKCS#11 支持从 10.1 开始。这里安装的 Homebrew OpenSSH 是 YubiTouch 后端 ssh-agent、ssh-add 和 ssh-keygen -D 的依赖,不要求把日常使用的 SSH 客户端换成 Homebrew 版本。

xcode-select -p
brew install age go openssh yubico-piv-tool ykman

ykman --version
"$(brew --prefix openssh)/bin/ssh" -V

如果 xcode-select -p 失败,先运行 xcode-select --install 并完成 Apple 的安装界面。

普通连接可以继续使用 macOS 自带的 /usr/bin/ssh,无需调整 PATH 中 ssh 的优先级; Apple OpenSSH 10.3p1 已完成真实签名登录验证。YubiTouch 会根据配置直接定位 Homebrew 后端工具。以后 Apple 更新系统 OpenSSH 时,仍建议先运行 yubitouch test-sign 和一次真实 SSH 登录再确认兼容性。

1Password 模式还需要 1Password 桌面应用。在 Settings > Developer 中启用 Integrate with other apps,并按需启用 Touch ID。YubiTouch 使用 1Password Go SDK, 不需要 op CLI。

2. 配置 YubiKey PIV 9A

这一节会修改 YubiKey。开始前必须有备用登录方式,例如第二把 key、云主机控制台或另一 个管理员账号。不要猜 PIN;错误 PIN 会消耗设备的有限重试次数。

先检查固件、PIV 状态和 9A 槽位:

ykman info
ykman piv info
ykman piv keys info 9a

如果 9A 已有重要密钥,立即停止,不要执行下面的生成或导入命令。ykman piv reset 会 清空整个 PIV 应用中的密钥和证书,YubiTouch 的安装不需要运行它。

全新的 PIV 应先修改默认 PIN、PUK 和 Management Key。下面的命令会交互式读取旧值和 新值,不要把这些值写进命令行参数:

ykman piv access change-pin
ykman piv access change-puk
ykman piv access change-management-key \
  --algorithm AES192 \
  --generate \
  --protect \
  --touch

把新 PIN 和 PUK 分开保存在可靠的密码管理器或离线恢复记录中。受 PIN 保护的随机 Management Key 留在 YubiKey 内,--touch 使后续 PIV 管理操作也需要触摸。

推荐直接在 YubiKey 内生成新的 ED25519 私钥。私钥从不离开设备:

mkdir -p "$HOME/.ssh"
chmod 700 "$HOME/.ssh"

piv_public_key="$HOME/.ssh/yubikey-piv-9a.pem"

ykman piv keys generate \
  --algorithm ED25519 \
  --pin-policy ONCE \
  --touch-policy ALWAYS \
  9a "$piv_public_key"

ykman piv certificates generate \
  --subject "CN=SSH PIV Authentication" \
  --valid-days 3650 \
  9a "$piv_public_key"

ykman piv keys info 9a

最终元数据必须显示 Algorithm: ED25519、PIN required for use: ONCE 和 Touch required for use: ALWAYS。PIN 策略只在生成或导入时设置;如果 Touch 显示为 NEVER,不能原地修改,必须在确认恢复路径后重新写入 9A。

如果必须保持现有 SSH 公钥,可以把现有 ED25519 私钥以加密 PKCS#8 形式导入 9A:

ykman piv keys import \
  --pin-policy ONCE \
  --touch-policy ALWAYS \
  9a /path/to/encrypted-ed25519-pkcs8.pem

ykman piv certificates import \
  9a /path/to/ssh-piv-certificate.pem

私钥转换、证书创建、指纹核对和迁移文件清理可参考 macOS 使用 YubiKey PIV + YKCS11 保护现有 SSH 密钥 的第 5 至 9 节。ykman 5.9.2 的 ED25519 --verify 存在已知缺口,导入证书时不要添加 该参数。导入方案中的原始私钥副本仍可绕过 YubiKey;只有确认备用和恢复路径后才能处理 这些副本。Gist 后续的自建 Agent、Match exec 和通知脚本已由 YubiTouch 取代,不要安装。

通过项目实际使用的 YKCS11 provider 导出 SSH 公钥:

provider="$(brew --prefix yubico-piv-tool)/lib/libykcs11.dylib"
ssh_keygen="$(brew --prefix openssh)/bin/ssh-keygen"

"$ssh_keygen" -D "$provider"
"$ssh_keygen" -D "$provider" |
  awk '$1 == "ssh-ed25519"' > "$HOME/.ssh/yubikey-piv.pub"

test "$(wc -l < "$HOME/.ssh/yubikey-piv.pub")" -eq 1
chmod 600 "$HOME/.ssh/yubikey-piv.pub"
"$ssh_keygen" -lf "$HOME/.ssh/yubikey-piv.pub"

YKCS11 通常还会列出 RSA PIV Attestation key;它不是 SSH 登录 key,不要删除 F9 Attestation 槽位。如果行数检查失败,说明存在多个或没有 ED25519 key,请删除错误的 yubikey-piv.pub 并人工选择注释为 PIV Authentication 的 9A 公钥。

使用已有且可信的登录方式,把 ~/.ssh/yubikey-piv.pub 加入每台目标服务器对应账户的 ~/.ssh/authorized_keys。服务器只需要公钥,不要复制私钥。导入原有 SSH 私钥且指纹完全 一致时,服务器已有的公钥无需修改。

3. 构建并安装 YubiTouch

首次安装

git clone https://github.com/mofelee/yubitouch.git
cd yubitouch

make test
make vet
make app

ditto dist/YubiTouch.app /Applications/YubiTouch.app
mkdir -p "$HOME/.local/bin"
ln -sfn /Applications/YubiTouch.app/Contents/MacOS/yubitouch \
  "$HOME/.local/bin/yubitouch"
ln -sfn /Applications/YubiTouch.app/Contents/MacOS/age-plugin-yubitouch \
  "$HOME/.local/bin/age-plugin-yubitouch"
export PATH="$HOME/.local/bin:$PATH"
yubitouch version
command -v age-plugin-yubitouch

应用必须先放到稳定位置,再注册 LaunchAgent。注册后移动或删除应用会使登录启动路径失效。 把 export PATH="$HOME/.local/bin:$PATH" 保留在 ~/.zprofile 后,重启终端也可以直接运行 yubitouch 和精确命名的 age-plugin-yubitouch,无需设置临时 $YT 变量。age 按插件名 从 PATH 查找独立可执行文件;只链接 yubitouch 不能启用 age 集成。

覆盖重装或从源码更新

已经安装过 YubiTouch 时,不要用 ditto 直接合并到正在使用的 App。下面的流程会先暂存并 验证新 App,旧 daemon 此时继续运行;验证通过后才停服并切换,同时保留旧 App 供回滚。

进入源码目录。需要更新源码时先执行 git pull;只重装当前 checkout 时跳过它:

cd /path/to/yubitouch
git pull --ff-only

完成构建和测试,三条命令都成功后再继续:

make test
make vet
make app

确认暂存和回滚路径都不存在:

test ! -e /Applications/YubiTouch.app.new
test ! -e /Applications/YubiTouch.app.previous

两个 test 都没有报错时,把新 App 复制到暂存路径并验证:

ditto dist/YubiTouch.app /Applications/YubiTouch.app.new
codesign --verify --strict /Applications/YubiTouch.app.new
codesign --verify --strict \
  /Applications/YubiTouch.app.new/Contents/MacOS/age-plugin-yubitouch
/Applications/YubiTouch.app.new/Contents/MacOS/yubitouch version

如果两个 test 中任意一个失败,说明上次重装留下了暂存或备份 App。先人工核对并处理它, 不要执行后面的 ditto。到这里为止尚未停止旧 daemon;构建、复制或签名验证失败不会中断 现有服务。

在同一个终端中,从当前 LaunchAgent plist 读取本次要继续使用的配置路径:

CONFIG_PATH="$(/usr/libexec/PlistBuddy \
  -c 'Print :ProgramArguments:3' \
  "$HOME/Library/LaunchAgents/com.github.mofelee.yubitouch.plist")"
test -r "$CONFIG_PATH"
printf 'config=%s\n' "$CONFIG_PATH"
env | awk -F= '/^YUBITOUCH_/ { print $1 }'

CONFIG_PATH 应是当前 plist 中 --config 后面的绝对路径;默认安装通常是 $HOME/.ssh/yubitouch/config.json。最后一条命令通常不应输出内容。如果存在临时 YUBITOUCH_* 变量,请先在干净终端中重新执行上面的 CONFIG_PATH 命令,避免 configure 无意改写现有配置。需要主动切换到另一份配置时,应在停服前明确把 CONFIG_PATH 改为对应的 绝对路径并确认文件可读。

确认暂存 App 和配置路径后,停止旧 daemon,再用同一目录中的重命名切换 App:

/Applications/YubiTouch.app/Contents/MacOS/yubitouch stop &&
mv /Applications/YubiTouch.app /Applications/YubiTouch.app.previous &&
mv /Applications/YubiTouch.app.new /Applications/YubiTouch.app

任一条 mv 失败时不要继续。如果 /Applications/YubiTouch.app 仍然存在,直接重新启动它:

YUBITOUCH_CONFIG="$CONFIG_PATH" \
  /Applications/YubiTouch.app/Contents/MacOS/yubitouch ensure

如果最终路径缺失但 .previous 存在,则先恢复旧 App 再启动:

mv /Applications/YubiTouch.app.previous /Applications/YubiTouch.app &&
YUBITOUCH_CONFIG="$CONFIG_PATH" \
  /Applications/YubiTouch.app/Contents/MacOS/yubitouch ensure

切换成功后,重新建立命令入口:

mkdir -p "$HOME/.local/bin"
ln -sfn /Applications/YubiTouch.app/Contents/MacOS/yubitouch \
  "$HOME/.local/bin/yubitouch"
ln -sfn /Applications/YubiTouch.app/Contents/MacOS/age-plugin-yubitouch \
  "$HOME/.local/bin/age-plugin-yubitouch"

command -v yubitouch
command -v age-plugin-yubitouch

两个 command -v 结果应分别是 $HOME/.local/bin/yubitouch 和 $HOME/.local/bin/age-plugin-yubitouch;否则先修正 PATH。然后核对版本:

git rev-parse --short=12 HEAD
/Applications/YubiTouch.app/Contents/MacOS/yubitouch version

两条版本命令应显示相同的 commit。最后始终用新 App 的绝对路径迁移配置、启动 daemon 并 检查状态:

YUBITOUCH_CONFIG="$CONFIG_PATH" \
  /Applications/YubiTouch.app/Contents/MacOS/yubitouch configure &&
YUBITOUCH_CONFIG="$CONFIG_PATH" \
  /Applications/YubiTouch.app/Contents/MacOS/yubitouch ensure &&
YUBITOUCH_CONFIG="$CONFIG_PATH" \
  /Applications/YubiTouch.app/Contents/MacOS/yubitouch doctor

doctor 通过后,可以把 /Applications/YubiTouch.app.previous 移到废纸篓;在此之前保留它 作为回滚副本。覆盖重装不会删除所选 CONFIG_PATH、age 描述符或 YubiKey 中的 key。只修改 配置、没有替换 App 时,使用 yubitouch reload 即可。

yubitouch version 中的十六进制值是构建时的 Git commit,不是每次 make app 都会变化的 构建序号。同一个 commit 重新构建仍会显示同一个值;可以用 git rev-parse --short=12 HEAD 核对。该命令只验证磁盘上的 CLI,不能证明已经运行的 LaunchAgent daemon 已经重启。 首次安装请继续完成下一步的 configure 和 ensure;已经有配置的覆盖重装在上述命令完成后 即可继续使用。

4. 配置 PIN 来源并启动服务

YubiTouch 只把非敏感配置保存到 ~/.ssh/yubitouch/config.json。不要设置 YUBITOUCH_PIN;PIN 不会保存在配置、环境变量、命令行或日志中。

选择一种 PIN 来源并保存配置,只执行下面两组命令中的一组。

使用系统安全输入框:

YUBITOUCH_PIN_PROVIDER=prompt \
YUBITOUCH_PUBLIC_KEY="$HOME/.ssh/yubikey-piv.pub" \
yubitouch configure

YubiTouch 系统安全 PIN 输入框

系统安全输入框模式:PIN 只用于加载 PIV provider,不会保存。

使用 1Password Desktop App Integration:

YUBITOUCH_PIN_PROVIDER=1password \
YUBITOUCH_1PASSWORD_ACCOUNT='My Account' \
YUBITOUCH_1PASSWORD_REF='op://Personal/YubiKey PIV/pin' \
YUBITOUCH_PUBLIC_KEY="$HOME/.ssh/yubikey-piv.pub" \
yubitouch configure

使用 1Password 时,先创建保存 PIV PIN 的字段,并复制该字段的 op:// secret reference。YUBITOUCH_1PASSWORD_ACCOUNT 可以是 1Password 显示的账户名或账户 UUID; 配置文件保存 reference,不保存其指向的 PIN。

1Password 授权 YubiTouch 读取 PIN

1Password 模式:由桌面应用授权读取 PIN,不显示系统 PIN 输入框。

可选:YubiKey 未插入时使用 1Password SSH Agent

缺卡回退默认关闭,并且与上面的 PIN 来源是两个独立功能。pin_provider=1password 是在 YubiKey 存在时从 1Password 读取 PIV PIN;fallback_agent=1password 是在 YubiKey 明确不存在时,让 OpenSSH 直接使用 1Password SSH Agent 中的 同一把私钥。只有两处公钥指纹完全一致时才能启用。

在 1Password 的 Settings > Developer 中启用 SSH Agent,然后使用 ~/.config/1Password/ssh/agent.toml 把 Agent 可见身份限制为这一把 key。例如:

[[ssh-keys]]
vault = "Private"
item = "YubiKey SSH Key"

vault 和 item 应改为保存原始私钥的实际 vault 和 SSH Key item。该文件的 有效规则不能再暴露其他 key;直接路由不经过 YubiTouch 的 Agent 协议过滤, 因此这是安全前提,而不是可选整理。先检查 1Password Agent 只列出一把 key, 且指纹与 ~/.ssh/yubikey-piv.pub 一致:

onepassword_agent="$HOME/Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock"

SSH_AUTH_SOCK="$onepassword_agent" \
  "$(brew --prefix openssh)/bin/ssh-add" -L |
  "$(brew --prefix openssh)/bin/ssh-keygen" -lf -

"$(brew --prefix openssh)/bin/ssh-keygen" -lf \
  "$HOME/.ssh/yubikey-piv.pub"

第一条命令必须只输出一行指纹,并且与第二条一致。启用回退并重载:

YUBITOUCH_FALLBACK_AGENT=1password yubitouch configure
yubitouch reload
yubitouch doctor

默认使用 1Password 的 macOS Agent socket。只在 1Password 使用非标准路径时,才在 configure 命令上同时设置 YUBITOUCH_FALLBACK_AGENT_SOCKET=/absolute/path/to/agent.sock。 要关闭回退,运行:

YUBITOUCH_FALLBACK_AGENT=none yubitouch configure
yubitouch reload

可选:YubiKey 已连接时使用 1Password 临时 SSH 签名

SSH software fallback 默认关闭,并且独立于缺卡时的 fallback_agent、PIV PIN 来源和 age fallback。它引用 1Password 中与 PIV 9A 完全相同的 Ed25519 私钥;启用它不要求同时启用 1Password SSH Agent,也不会改变公共 Agent 路由。PIV PIN 的 onepassword_ref 与 SSH private-key reference 是两个不同 secret,配置会拒绝复用同一个 reference。

在 1Password SSH Key item 中复制 private key 字段的 op:// reference,然后运行:

YUBITOUCH_1PASSWORD_ACCOUNT='<account name or UUID>' \
YUBITOUCH_SSH_SOFTWARE_FALLBACK_PROVIDER='1password' \
YUBITOUCH_SSH_SOFTWARE_FALLBACK_PRIVATE_KEY_REF='op://<vault>/<item>/<private-key-field>' \
  yubitouch configure
yubitouch reload
yubitouch doctor

配置文件只保存 provider 和 reference,不保存私钥正文。加载时只接受 Ed25519 private key, 并要求派生出的 SSH public-key blob 与配置的 PIV 9A public_key 完全一致。doctor 只报告 private-key reference 的语法是否有效,不读取该字段。status 只报告是否配置及 reference 语法状态,不输出账户、完整 reference、私钥、会话 phase 或 deadline。

要关闭这项能力并结束由重载触发的旧进程状态,运行:

YUBITOUCH_SSH_SOFTWARE_FALLBACK_PROVIDER=off yubitouch configure
yubitouch reload

上述环境变量只在执行 configure 时使用。配置保存后,登录启动的 daemon 直接读取配置 文件,不依赖 shell 环境变量。随后注册当前 GUI 用户的 LaunchAgent 并验收:

yubitouch ensure
yubitouch doctor
yubitouch status
yubitouch test-sign

ensure 创建 ~/Library/LaunchAgents/com.github.mofelee.yubitouch.plist。重新登录后服务会 自动恢复公共 Agent 路由,但不会提前读取 PIN、签名或加载 YKCS11。启用缺卡回退时,daemon 会对 1Password Agent 执行无签名副作用的 identity readiness 查询;该查询不应显示 Touch ID。 只有 PIV 路由上的 test-sign 或 SSH 真实签名请求才会加载 provider。

test-sign 成功时会输出:

Test signature succeeded. Signature data was not retained.

5. 配置并使用 SSH

在 ~/.ssh/config 中让目标主机使用公共 Agent socket 和 PIV 公钥:

Host example-yubikey
    HostName server.example.com
    User your-user
    IdentityAgent ~/.ssh/yubitouch/agent.sock
    IdentityFile ~/.ssh/yubikey-piv.pub
    IdentitiesOnly yes
    ForwardAgent no
    ControlMaster auto
    ControlPersist 10m
    ControlPath ~/.ssh/yubitouch-%C.sock

之后直接运行 ssh example-yubikey。YubiKey 存在时,首次建立连接会请求 PIN 或 1Password PIN 授权,并显示 YubiKey 触摸提示;回退路由上则只显示 1Password 的授权界面。 复用已有 ControlMaster 连接时不会产生新签名,因此没有 UI。 即使启用缺卡回退,IdentityFile 也必须是配置的那一把 PIV 公钥,并且必须保留 IdentitiesOnly yes;这会让 OpenSSH 只请求服务器已接受的目标身份。

配置了 SSH software fallback 时,只有目标 YubiKey 明确 connected、公共路由为 piv,且 本地请求正等待 PIV 触摸,触摸浮层才显示“改用 1Password”的“仅本次”“1 分钟”“3 分钟” 和“5 分钟”。选择后当前请求会取消硬件操作、等待 PIV 队列完整释放,再显示切换状态并继续 同一个 Agent 请求;调用方不需要重新运行 ssh、Git 或 test-sign。“仅本次”在当前签名 结束后立即回收软件 Agent,不进入 active。

也可以从菜单栏的“SSH 签名:YubiKey 优先”下预先开启 1、3 或 5 分钟会话。开启时显示 “正在开启临时会话”,active 后持续显示固定绝对剩余时间,并提供“立即结束临时会话”。TTL 从隔离软件 Agent ready 后开始,请求不会续期。active 窗口内,目标 key 的合格本地 SSH、Git 和 test-sign 请求不再读取 PIV PIN、加载 YKCS11 或要求触摸;到期、提前结束、锁屏、睡眠、 当前用户会话失活、设备/路由/配置变化、请求或 helper 异常、daemon reload/stop/restart 都会 销毁会话,下一次请求恢复硬件优先。

SSH 临时会话与缺卡直连回退和 age 临时解密会话使用不同的 controller、helper、私钥和 TTL, 不会互相开启、续期或结束。缺卡确认后的 agent_route=1password 仍直接连接 1Password SSH Agent,不创建临时软件会话。无法可靠分类或具有可信 forwarded session binding 的请求绝不 使用 SSH 临时软件 key;即使本地会话 active,它们仍走 PIV 并要求独立触摸。

下面的 ssh 可以是 macOS 自带的 /usr/bin/ssh,也可以是 Homebrew OpenSSH;只有 YubiTouch 管理的后端固定使用 Homebrew OpenSSH。

检查 OpenSSH 最终采用的配置并连接:

ssh -G example-yubikey |
  awk '$1 ~ /^(identityagent|identityfile|identitiesonly|forwardagent|controlmaster|controlpath|controlpersist)$/ {print}'

ssh example-yubikey

需要强制进行一次新签名时,先关闭复用的 master:

ssh -O exit example-yubikey
ssh example-yubikey

跳板和目标都接受同一 PIV key 时可以直接使用 ProxyJump:

Host bastion
    HostName bastion.example.com
    User your-user

Host internal-target
    HostName target.internal
    User your-user
    ProxyJump bastion

Host bastion internal-target
    IdentityAgent ~/.ssh/yubitouch/agent.sock
    IdentityFile ~/.ssh/yubikey-piv.pub
    IdentitiesOnly yes
    ForwardAgent no
    ControlMaster auto
    ControlPersist 10m
    ControlPath ~/.ssh/yubitouch-%C.sock

运行 ssh internal-target 时,OpenSSH 会从本机分别认证跳板和目标,不需要 Agent Forwarding,因此默认应保持 ForwardAgent no。

确实需要从某台远程主机再发起 SSH 时,可以只对完全可信的 Host 设置 ForwardAgent yes。这会让远程主机在连接存活期间请求当前路由上的 Agent 签名;私钥 不会被复制,但受控的远程主机仍可以利用该能力。PIV 路由上的 forwarded 请求始终要求 YubiKey 触摸,不能使用 active 的 SSH 临时软件会话;binding malformed、冲突或无法可靠分类也按 硬件路径处理。缺卡切到 1Password 直接路由时,可转发的仍是 agent.toml 暴露的身份,这也是 YubiTouch 要求其中只有目标 key 的原因。路由切换只影响新建 Agent 连接;已建立连接继续使用 建立当时的路由。

使用 YubiKey 签署 GitHub Commit

GitHub 的 SSH 登录认证和 Commit 签名是两种独立用途。打开 SSH and GPG keys,把 ~/.ssh/yubikey-piv.pub 的完整 内容添加为 Signing Key。如果同一把 key 已经用于 GitHub SSH 登录,仍需再添加一次并将 类型选择为 Signing Key。Commit 使用的邮箱也必须是 GitHub 账户中已验证的邮箱。

Git 的 gpg.ssh.program 需要调用标准 ssh-keygen,而签名进程还必须连接 YubiTouch Agent socket。为了不替换终端、IDE 或其他程序的默认 SSH_AUTH_SOCK,先创建一个只供 Git 签名使用的本地 wrapper。yubitouch-ssh-sign 不是项目自带命令;下面的命令会生成它, 并在生成时记录当前 Mac 上 Homebrew OpenSSH 的稳定路径:

ssh_keygen="$(brew --prefix openssh)/bin/ssh-keygen"
mkdir -p "$HOME/.local/bin"

cat > "$HOME/.local/bin/yubitouch-ssh-sign" <<EOF
#!/bin/sh
export SSH_AUTH_SOCK="\$HOME/.ssh/yubitouch/agent.sock"
exec "$ssh_keygen" "\$@"
EOF

chmod 700 "$HOME/.local/bin/yubitouch-ssh-sign"

然后配置当前用户的 Git。user.signingkey 使用公钥文件路径;私钥仍留在 YubiKey 中:

git config --global user.signingkey "$HOME/.ssh/yubikey-piv.pub"
git config --global gpg.format ssh
git config --global gpg.ssh.program "$HOME/.local/bin/yubitouch-ssh-sign"
git config --global commit.gpgsign true
git config --global tag.gpgSign true

如果原配置使用 1Password 的 op-ssh-sign,第三条命令会只把 Git Commit/Tag 的签名程序 切换到 YubiTouch wrapper,不会改变 SSH Host 的 IdentityAgent 配置。检查最终配置:

git config --global --get-regexp \
  '^(user.signingkey|gpg.format|gpg.ssh.program|commit.gpgsign|tag.gpgsign)$'

之后正常运行 git commit 或 git tag -s 即可。wrapper 始终连接稳定的 agent.sock,不需要因路由切换而修改 Git 配置。YubiKey 存在时使用 PIV 并显示 YubiTouch 触摸提醒;回退到 1Password 时由 1Password 显示 Touch ID/授权界面,不显示 YubiTouch 触摸浮层。推送到 GitHub 后,正确关联的签名会显示 Verified。

6. 配置并使用 age

先按从零开始使用 YubiTouch 保护 age 文件完成密钥模型选择、初始化 和真实验收。路径选择、session 生命周期和安全限制集中记录在 age 功能参考。

密钥模型 recipient / stanza 缺卡路径 发送端要求
Hardware-only age1... / 一个原生 X25519 无;丢卡或 key 丢失后无法解密 只需原生 age
Hardware + fallback age1... / 一个原生 X25519 1Password 中与硬件相同的私钥 只需原生 age

daemon 只读取和使用用户已经配置好的 PIV X25519 key,不修改 YubiKey。全新 hardware-only key 首选按设备内生成教程创建;同钥 fallback 必须先生成原生 age identity,用 yubitouch age key to-pkcs8 转成新的导入文件,再按 独立导入教程写入明确空闲的槽位。转换命令不写 YubiKey。硬件 key 与 SSH 使用的 9A ED25519 key 是不同槽位和用途;不要把 SSH 私钥当作 age ECDH key。

配置硬件主路径时,把占位值替换为本机读取的目标设备信息,不要把完整 serial 贴到 Issue、 日志或终端共享记录中:

YUBITOUCH_AGE_SERIAL='<decimal-serial>' \
YUBITOUCH_AGE_SLOT='82' \
YUBITOUCH_AGE_ALGORITHM='x25519' \
  yubitouch configure

Hardware-only 不配置任何软件路径。它保持设备内生成私钥不可导出,但 YubiKey 丢失、损坏或 槽位 key 被替换后,对应密文无法解密。

Hardware + fallback 必须把与槽位完全相同的 AGE-SECRET-KEY-1... 保存到 1Password, 配置只保存 reference:

YUBITOUCH_1PASSWORD_ACCOUNT='<account name or UUID>' \
YUBITOUCH_AGE_STANDARD_FALLBACK_PROVIDER='1password' \
YUBITOUCH_AGE_STANDARD_FALLBACK_IDENTITY_REF='op://<vault>/<item>/<field>' \
  yubitouch configure

该模式的私钥不再只存在于硬件;取得 1Password 或其他软件副本即可绕过 YubiKey 解密。启用前 必须按新手教程比较原生 identity、PKCS#8 导入 key 和实际槽位的公钥。要禁用 age 的 1Password 软件路径,使用 YUBITOUCH_AGE_STANDARD_FALLBACK_PROVIDER=off yubitouch configure;none 和 disabled 也具有删除语义,但都不会删除外部私钥副本。

限时授权只适用于 age 解密,并且只在上述 standard 1Password fallback 配置完整有效时可用。 它不会缓存 PIV PIN,不会跳过 SSH 签名触摸,也不会改变 agent.sock 的 SSH 路由或 1Password SSH Agent fallback。

下列 age 环境变量只在显式运行 yubitouch configure 时按“当前环境 变量优先于已有配置,已有配置优先于内置默认值”合并并持久化:

  • YUBITOUCH_AGE_SERIAL
  • YUBITOUCH_AGE_SLOT
  • YUBITOUCH_AGE_ALGORITHM
  • YUBITOUCH_AGE_STANDARD_FALLBACK_PROVIDER
  • YUBITOUCH_AGE_STANDARD_FALLBACK_IDENTITY_REF

daemon 不接受每次解密的临时覆盖。软件私钥本身不得进入配置、命令行或普通环境变量; YUBITOUCH_AGE_STANDARD_FALLBACK_IDENTITY、 YUBITOUCH_AGE_STANDARD_FALLBACK_PRIVATE_KEY 和 YUBITOUCH_AGE_STANDARD_FALLBACK_SECRET,只要非空就会被 configure 明确拒绝。

首次输出 recipient 或 identity 时,如果配置中还没有硬件公钥缓存,必须插入目标 YubiKey。 这次操作只读取并校验槽位公钥,将 32 字节公开值缓存到配置;不会请求 PIN、执行 ECDH 或 显示触摸提示。读取在一次性只读 helper 中完成;超时或取消会终止整个 helper 进程组并等待 回收;父 CLI/daemon 异常退出也会通过生命周期管道立即清理 helper 及其子进程,阻塞的同步 PKCS#11 调用不会成为孤儿。缓存写入会在锁内重新读取最新配置,只合并 age.public_key;并发 configure 修改 fallback 或其他设置不会被旧配置覆盖,读取期间硬件 目标变化则本次命令不缓存也不输出。两个描述符命令成功时 stdout 都严格只有一行,诊断只写 stderr:

yubitouch age recipient > recipient.txt
yubitouch age identity > yubitouch-identity.txt
test "$(wc -l < recipient.txt)" -eq 1
test "$(wc -l < yubitouch-identity.txt)" -eq 1
chmod 600 yubitouch-identity.txt

# 让已经运行的 daemon 读取新配置和刚缓存的硬件公钥。
yubitouch reload

recipient.txt 是公开、可复制的原生 age1... recipient;发送方只需 age v1.3.1, 不安装 YubiTouch 或插件:

age -R recipient.txt -o secret.txt.age secret.txt

接收方使用不含私钥、serial、slot、PIN 或 1Password reference 的 AGE-PLUGIN-YUBITOUCH-1... identity 解密:

age -d -i yubitouch-identity.txt -o secret.txt secret.txt.age

保存 recipient 后,加密只使用其中的公开材料,不连接 daemon、YubiKey 或 1Password;即使 设备已拔出、daemon 已停止仍可加密。发送端完全不运行插件。解密连接私有 ~/.ssh/yubitouch/age.sock。目标 YubiKey 已连接时走硬件路径,并与 SSH 签名共享同一条全局 PIV 队列。daemon 管理一个 隔离的常驻 hardware helper,由它持有 YKCS11 module、已登录 session 和目标 private object, daemon 本体不持有 PKCS#11 session。

第一次硬件解密会启动独立的一次性 PIN resolver。resolver 取得 PIN 后通过有界私有 pipe 交给 hardware helper,关闭输出并退出;hardware helper 必须先等待并回收 resolver,才执行 C_Login,随后立即清零可变 PIN 缓冲区。因此常驻 helper 只保留 YubiKey 的已认证 session, 不保留可再次读取的 PIN 值。成功请求不会销毁该 session;同一 session 内的后续解密不再调用 PIN provider,但仍会重新校验 session/key,并且每次都必须经过独立的 ready_for_touch -> UI -> continue -> CKM_ECDH1_DERIVE 回合。每次 ECDH 创建的派生对象仍在 该次操作结束前独立销毁。

在 PIN 输入或 1Password 授权尚未结束时不显示 YubiTouch 触摸提示。只有 resolver 已退出并 回收、YKCS11 login 和 session/key 验证成功,daemon 才显示“age 解密”触摸提示并允许 helper 继续执行 ECDH。

未配置 standard fallback 时,解密保持硬件优先:目标 key 已连接就走 hardware,明确缺卡则 失败。配置了有效的 1Password fallback 时,没有临时软件会话的请求会先完成可信设备探测。 目标 key 已连接时直接进入 hardware,不显示请求前 provider 面板;等待触摸时可以从同一面板 显式“改用 1Password”,选择“仅本次”“1 分钟”“3 分钟”或“5 分钟”。连续两次明确缺卡时 才显示只包含这些软件选项和取消的 provider 面板。“仅本次”使用一次性 fallback helper, 完成后不保留软件私钥。

选择 1、3 或 5 分钟会在 1Password 授权并验证同钥后启动隔离的软件会话,触发选择的当前请求 就是会话的第一个请求。也可以从 macOS 菜单栏的“开启 1Password 临时会话”预先开启;菜单栏 会显示“硬件优先”、正在开启、临时会话及剩余倒计时或正在结束,并可随时选择“立即结束临时 会话”。会话 active 时,后续 age 请求直接使用软件路径,不再逐个显示 provider 面板,也不 探测或触摸 YubiKey。

TTL 从软件 helper 完成 1Password 解析、公钥绑定并进入 ready 状态后开始,是固定的绝对期限; 解密请求不会续期,重复开启也不会延长。到期、手动结束、屏幕锁定、系统睡眠、当前 macOS 用户会话转为非活动、配置文件变化、已经进入 helper 的请求取消/超时/协议或解密错误,以及 daemon/helper 停止、重载、崩溃或重启都会终止并回收软件会话。会话 phase 和 deadline 不写入 配置或 state.json,daemon 重启后始终恢复“硬件优先”,不会恢复剩余时间。 daemon 返回成功 file key 后还会等待插件在同一连接回写一次性随机 delivery ACK;只有匹配 ACK 才保留会话。插件断开、响应边界取消、ACK 缺失/错误/超时或结果状态不明都会立即回收会话。

插入其他 YubiKey、serial/slot/算法/公钥不匹配、探测失败或状态不明、PIN 错误、PIN provider 失败或取消、触摸取消或超时,以及 YKCS11/ECDH/KDF/AEAD 失败都直接失败,不访问软件路径; PIN 错误和 PIN provider 失败/取消也不会显示触摸提示。 一旦同一请求选择并开始硬件路径,就绝不会切换到软件路径。1Password 授权失败/取消、identity 解析失败或公钥不匹配同样只失败一次,不尝试其他 key。

常驻 hardware helper/session 在以下任一条件出现时销毁并完成进程回收;下一次硬件解密重新 启动一次性 PIN resolver 并登录:

  • IOKit 报告任何 YubiKey 插入或移除事件,包括设备总数未变化的替换;
  • 配置 reload,包括目标 serial、slot、algorithm 或 public key 改变;
  • daemon 停止、重载、崩溃或重启,hardware helper 自身退出或重启;
  • PKCS#11 token、session 或 private object 失效,hardware helper 内的硬件/ECDH/解包或严格帧协议返回错误,或前一次结果状态无法确认;
  • 当前请求被客户端或触摸 UI 取消、断开或超时;
  • helper 的父进程身份、代码身份或生命周期验证失败,或设备事件流异常关闭。

已经进入常驻 helper 的 hardware 请求只有正常解密成功才保留 session;首版没有额外的空闲或 绝对 TTL。设备重插或旧 session 在私钥操作前已明确失效时,下一次请求可以建立新 session; ECDH 已开始或结果状态不明时绝不自动重试。

在 macOS+cgo 构建中,hardware/fallback 私钥 helper 会在读取配置前及执行敏感操作前验证 直接父进程:父子必须属于同一用户,具有相同的完整可执行路径和代码身份,并都启用 Hardened Runtime;未加固、被动态库注入、经 shell 或其他可执行文件直接启动都会失败关闭。其他平台或 未启用 cgo 的构建不提供这项认证,因此私钥 helper 一律拒绝运行。应使用 make app 或 make build 生成经过本地 runtime 签名的源码构建产物,直接 go build 的二进制不能运行私钥 helper。daemon 通过专用生命周期管道持有常驻 hardware helper、一次性 fallback helper 和限时 software-session helper;daemon 崩溃或被强制终止时,helper 会立即终止自己的整个进程组, 不会脱离原生命周期继续持有 scalar、SDK secret 或硬件 session。

启用 1Password fallback 会降低整体安全级别:它直接保存 hardware 私钥的同一副本,任何 取得该软件 identity 的主体都能绕过 YubiKey、PIN 和触摸。安全性取硬件和软件路径中 较弱的一条。1Password SDK 以不可变 Go string 返回 secret,无法可靠原地清零。限时会话的 1Password resolver 在传出经公钥校验的可变 32 字节 scalar 后必须先退出并被回收;daemon 从不接收 identity 或 scalar,常驻会话 helper 只在锁定内存中保留 scalar,并向 daemon 返回每次解密的 16 字节 file key。禁用 core dump、 强制 mlock、清零可变缓冲区和进程隔离只能缩小暴露面,不能让 RAM 中的限时软件私钥具备 YubiKey 私钥不可导出的硬件安全属性。

7. 日常维护、更新和卸载

常用命令:

yubitouch status          查看脱敏状态
yubitouch doctor          检查依赖、权限、设备和 SSH 配置
yubitouch test-sign       独立测试 PIN、触摸和签名链路
yubitouch age recipient   输出公开的原生 age1... recipient
yubitouch age identity    输出本机 age 插件 identity
yubitouch age key to-pkcs8  把原生 age identity 转成新的 PKCS#8 导入文件
yubitouch reload          重启服务并读取配置
yubitouch stop            停止当前用户的 LaunchAgent

从源码更新和覆盖重装请使用 “构建并安装 YubiTouch”中的完整重装步骤。 该步骤会先验证新构建,再停止旧 daemon、替换 App、补全配置并启动新 daemon。

从旧的单 socket 版本升级时,这一次 configure 会补全内部 ~/.ssh/yubitouch/piv-agent.sock 路径。新 daemon 会把旧的 agent.sock 替换为受管符号链接; 不要在服务运行时手工删除或重建该路径。如果同时要启用缺卡回退,把上面的 yubitouch configure 替换为 YUBITOUCH_FALLBACK_AGENT=1password yubitouch configure。

卸载应用和登录服务:

yubitouch stop
rm -f "$HOME/Library/LaunchAgents/com.github.mofelee.yubitouch.plist"
rm -f "$HOME/.local/bin/yubitouch"
rm -f "$HOME/.local/bin/age-plugin-yubitouch"
rm -rf /Applications/YubiTouch.app

最后从 ~/.ssh/config 删除对应 Host 配置。确认不再需要配置和诊断日志后,可以自行删除 ~/.ssh/yubitouch。PIV 私钥始终留在 YubiKey 中,不会随应用卸载而删除。

工作方式

ssh / DebianForm
       |
       v
~/.ssh/yubitouch/agent.sock  (managed symlink)
       |
       +--> piv-agent.sock --> YubiTouch PIV Agent -+-> OpenSSH ssh-agent
       |                                            |   `-> YKCS11 / PIV 9A / touch
       |                                            `-> temporary ssh-agent
       |                                                `-> one-shot 1Password resolver
       |
       `--> 1Password agent.sock --> 1Password SSH Agent

LaunchAgent 登录后启动轻量 daemon 和内部 PIV Agent。YubiKey 存在或缺卡回退未启用 时,公共路径指向 piv-agent.sock。PIV 路由上的密钥列表查询只返回配置的 PIV 9A 公钥,不启动 backend、不读取 PIN,也不显示 UI。只有目标公钥的真实 SignRequest 才加载 YKCS11 provider。

临时 SSH 分支不是第三种公共路由。它只在 PIV proxy 内为合格的本地目标 key 签名动态选择, 并逐连接保留 [email protected] 分类;forwarded 或分类不明的请求仍进入硬件分支。 临时 Agent 从不代理到 1Password SSH Agent。

启用回退后,daemon 通过 IOKit 的 USB 插拔通知维护 YubiKey 存在状态。移除事件会立即 失败闭合到 PIV;只有防抖时间结束后仍明确为 not_detected,且 1Password socket 安全、 可达、包含目标 key 并且没有其他 key 时,才把公共路径切到 1Password。IOKit 错误、 回退检查失败、daemon 启停过程都失败闭合到 PIV,不会在状态不明时暴露 1Password Agent。 YubiKey 重新出现后会由插入事件直接切回 PIV。该监视器只读取 USB registry,不打开 PC/SC/CCID,也不读取或记录设备序列号。

在任何 1Password 路由生效前,daemon 都会先原子写入 0600 的 ~/.ssh/yubitouch/route-guard.json。下次启动会在解析主配置之前读取该 guard 并撤掉上次 记录的 fallback,因此主配置损坏、fallback socket 改名或异常退出后也不会无人监管地保留 1Password 直连。guard 只保存受管 socket 路径,不保存 key、PIN 或签名数据。

路由切换通过临时符号链接加原子 rename 完成,不在两个 Agent 之间转发或改写协议帧。 新连接跟随新路由;切换前已经打开的 Unix socket 连接保持连接到原 Agent。因此 OpenSSH [email protected] 和内核提供的直接对端身份不会被一层回退 proxy 吞掉: PIV 路由继续使用 YubiTouch 已有的 session-bind 重放,1Password 路由则由客户端直接交付。

1Password 路由上的签名完全绕过 YubiTouch PIV Agent。因此授权和 Touch ID 界面由 1Password 拥有,不显示 YubiTouch 触摸浮层,last_sign_event/last_sign_at 也不会记录 这次签名。这是直接路由保留协议和对端语义的结果。

PIV 路由的触摸等待浮层提供取消按钮。按钮绑定到该次请求的内部 ID, 只取消当前显示的请求;取消会 关闭该客户端的 backend 连接、立即关闭浮层且不自动重试,不会取消其他排队或后续请求。 标准 SSH Agent 协议只返回通用失败;test-sign 会从脱敏 state 分类为 canceled 并返回 6。

PIV 路由上每次真实签名的浮层会显示发起程序,例如 Terminal、iTerm2、DebianForm、IDE 或 YubiTouch 自己的 test-sign,并保留直接 Agent 客户端(通常为 ssh)。macOS daemon 在 接受 Unix socket 连接时通过内核 LOCAL_PEERPID 捕获直接客户端,再以固定深度追溯父进程; 进程启动时间在路径解析前后必须一致,避免退出进程或 PID 复用把身份串到另一请求。

应用名称优先取自可执行文件所属且代码签名有效的 app bundle;无 bundle、未签名或解析失败 时稳定降级为可执行文件名或“未知程序”,身份解析失败不会阻断签名。程序身份只作为触摸前 的辅助判断,不是对已经控制当前用户会话的恶意软件的身份认证。

age 使用独立的数据流,不经过 SSH Agent 路由:

age -> age-plugin-yubitouch -> ~/.ssh/yubitouch/age.sock -> daemon
                                                        +-> one-shot public/probe helper
                                                        |      `-> YKCS11 public read
                                                        +-> persistent hardware helper
                                                        |      +-> one-shot PIN resolver
                                                        |      `-> reusable YKCS11 session / PIV X25519 ECDH
                                                        +-> one-shot software fallback helper / 1Password
                                                        `-> persistent software-session helper
                                                               `-> one-shot 1Password scalar resolver

私有 socket 使用 4 字节长度前缀的有界 v2 JSON frame,每个连接只处理一个请求,并校验 macOS 内核报告的对端 EUID。帧大小、连接数和处理时间均有上限;客户端断开会取消对应请求。 IPC 只接受完整、规范化的 profile/stanza,失败响应只能使用预定义分类,不传递底层错误文本。 daemon 的设备 probe 与 CLI 首次公钥读取还分别通过有界私有 pipe 启动只读 helper;serial、slot 和目标公钥不进入 argv、普通环境变量或日志。helper 不继承 PIN、AskPass、Agent 或 age debug 环境,且接口没有登录或私钥操作。同步 PKCS#11 即使永久阻塞,父进程也会在超时、取消或客户端 断开时关闭 pipe、杀死整个独立进程组并完成唯一一次 wait,下一次请求不会复用该 child。

age 插件协议

完整的组件、状态机、协议字段、密钥生命周期和错误分类见 YubiTouch age 功能参考。

yubitouch age recipient 输出原生 age1... X25519 recipient。发送端由原生 age 生成:

-> X25519 <32-byte ephemeral public key>
<32-byte authenticated ciphertext>

格式严格兼容 age v1.3.1:salt 为 ephemeral-public-key || recipient-public-key,HKDF-SHA256 info 为 age-encryption.org/v1/X25519,16 字节 file key 使用 ChaCha20-Poly1305、全零 nonce 和 空关联数据包装。标准 stanza 是匿名的,不携带 path、profile ID、key ID 或 provider 路由信息。 插件只接受 header 中恰好一个 X25519 stanza;多个 stanza 会在 PIN、触摸或 1Password UI 前拒绝。解析器拒绝错误长度、非规范 Base64、低阶 X25519 公钥和认证失败。

AGE-PLUGIN-YUBITOUCH-1... identity payload 包含版本、算法号、两个零保留字节、profile ID 和 hardware key ID,不包含公钥、设备或 fallback 定位信息。两个 ID 使用不同 domain,通过 SHA-256(domain || NUL || public-key) 的前 16 字节得到。daemon 会用配置中的实际硬件公钥 重新计算并校验这两个 ID。

fallback 不增加第二个 stanza;明确缺卡时,它用保存在 1Password 中的同一私钥解开原标准 stanza。因此启用或禁用 fallback 不改变 recipient、identity 或已有密文。

插件使用 age 官方插件 API,协议和插件自动化测试的当前目标版本是 age v1.3.1。仓库中的 固定向量用于锁定 recipient、identity 和标准 stanza 解包行为。不要在真实文件上设置 AGEDEBUG=plugin:这是 age 上游调试开关,其输出可能包含插件协议内容和 file key;YubiTouch 无法替上游过滤或清理这类调试输出。

依赖

brew install age openssh yubico-piv-tool ykman

1Password 模式还需要安装 1Password 桌面应用,并在 Settings > Developer 中启用 Integrate with other apps。按需在安全设置中启用 Touch ID。YubiTouch 使用官方 Go SDK,不依赖 op CLI。

构建需要 Go 1.25 或更高版本、Xcode Command Line Tools 和 CGO。

构建

make test
make test-race
make vet
make app

应用输出到 dist/YubiTouch.app。开发安装时应先把应用移动到稳定位置,再建立 CLI 入口并注册 LaunchAgent;移动已经注册的二进制会使 launchd 路径失效。

ditto dist/YubiTouch.app /Applications/YubiTouch.app
mkdir -p ~/.local/bin
ln -sfn /Applications/YubiTouch.app/Contents/MacOS/yubitouch ~/.local/bin/yubitouch
ln -sfn /Applications/YubiTouch.app/Contents/MacOS/age-plugin-yubitouch ~/.local/bin/age-plugin-yubitouch

make app 只生成当前 Mac 原生架构的本地应用,并以 ad-hoc 签名启用 Hardened Runtime, 使私钥 helper 可以拒绝未加固或被动态库注入的父进程。该签名不是 Developer ID 签名;项目 不分发该产物,也不把它标记为已公证或 universal。其他用户应在自己的目标 Mac 上从源码 运行测试并构建。主程序只保留加载用户所配置 YKCS11 provider 所需的 library-validation 例外; 不允许 DYLD 环境注入或调试授权。

配置

YubiTouch 将非敏感配置保存到 ~/.ssh/yubitouch/config.json,文件权限为 0600, 目录权限为 0700。配置 schema 没有 PIN 字段;设置 YUBITOUCH_PIN 会被明确拒绝。

准备 PIV 9A 公钥

先确认 9A 槽位算法与触摸策略,再通过项目实际使用的 YKCS11 provider 枚举公钥:

ykman piv keys info 9a
mkdir -p ~/.ssh
chmod 700 ~/.ssh
"$(brew --prefix openssh)/bin/ssh-keygen" -D "$(brew --prefix yubico-piv-tool)/lib/libykcs11.dylib"
"$(brew --prefix openssh)/bin/ssh-keygen" -D "$(brew --prefix yubico-piv-tool)/lib/libykcs11.dylib" | awk '$1 == "ssh-ed25519"' > ~/.ssh/yubikey-piv.pub
test "$(wc -l < ~/.ssh/yubikey-piv.pub)" -eq 1
chmod 600 ~/.ssh/yubikey-piv.pub

YubiTouch v0.1 要求该文件是 ssh-ed25519 PIV 9A 公钥。不要使用 YKCS11 返回的 RSA PIV Attestation key。第一条 ssh-keygen -D 命令应显示 9A 的 PIV Authentication 注释; 如果设备还有其他 ED25519 PIV key,停止并人工选择 9A 对应行,不要使用自动过滤结果。 configure 会拒绝其他算法或多行文件,doctor 会确认配置公钥出现在 provider 输出中并 报告被过滤的其他 key 数量。

手动 PIN

export YUBITOUCH_PIN_PROVIDER=prompt
export YUBITOUCH_PUBLIC_KEY="$HOME/.ssh/yubikey-piv.pub"
yubitouch configure
yubitouch ensure
yubitouch test-sign

第一次真实签名时显示 NSSecureTextField 对话框。图形会话不可用时尝试从当前 TTY 安全读取;两者都不可用时快速失败。

1Password

export YUBITOUCH_PIN_PROVIDER=1password
export YUBITOUCH_1PASSWORD_ACCOUNT='My Account'
export YUBITOUCH_1PASSWORD_REF='op://Personal/YubiKey PIV/pin'
export YUBITOUCH_PUBLIC_KEY="$HOME/.ssh/yubikey-piv.pub"
yubitouch configure
yubitouch ensure
yubitouch test-sign

这一节配置的是 PIV PIN 来源,不会自动启用缺卡 SSH Agent 回退。回退的 agent.toml 身份隔离要求和启用命令见上文“可选:YubiKey 未插入时使用 1Password SSH Agent”。SSH software fallback 也有自己的 private-key reference;PIN 来源、 缺卡直连回退和临时 SSH 签名三项能力可以独立或同时使用。

YUBITOUCH_1PASSWORD_ACCOUNT 可以是桌面应用显示的账户名或账户 UUID。v0.1 只支持 Desktop App Integration,不使用 service account token。SDK 返回不可变 Go string, 因此无法形式化保证原地清零;YubiTouch 把解析限制在一次性 AskPass helper 进程中, 写入 OpenSSH AskPass 管道后立即退出。受 Go 垃圾回收、运行库复制和外部 SDK/OpenSSH 行为影响,任何模式都无法形式化证明内存中不存在残留副本。

在 1Password 模式下,yubitouch doctor 会本地校验 secret reference 语法,并初始化 Desktop App Integration client 来验证账户和桌面集成;这可能触发 1Password 自己的授权 界面,但不会解析或读取 PIN,也不会加载 YKCS11。只有显式 yubitouch test-sign 才会通过 一次性 AskPass helper 调用 Secrets().Resolve,验证引用存在性和完整授权链路。

1Password Go SDK v0.4.0 的 macOS Desktop App Integration backend 不响应调用方的 context.Context 取消(上游 #266)。 YubiTouch 超时时会终止自己启动的 ssh-add、一次性 AskPass、age fallback helper 或临时会话 scalar resolver,以及 SSH private-key resolver/importer/临时 Agent,并返回 对应的失败分类;但 1Password 应用拥有的授权窗口可能继续显示,需要用户在 1Password 中 手动取消。YubiTouch 不会通过辅助功能或 UI 自动化操作 1Password 窗口;升级 SDK 前必须 重新验证该上游限制。

SSH 首次加载 provider 时,会先在具有 3 秒 deadline 的隔离进程组中执行一次无 PIN 的 YKCS11/PCSC readiness 检查;检查成功后才允许唯一一次 ssh-add -s/AskPass。1Password PIN resolver 开始后若 15 秒仍无响应,会触发 pin_provider_timeout 截止点;YubiTouch 随后在独立的 有界清理预算内终止并回收自己创建的 AskPass、ssh-add、backend agent 和 ssh-pkcs11-helper。若配置的整体 sign_timeout 更短或排队已耗尽请求预算,整体 timeout 会先 生效。该路径不会回退到系统 PIN 输入框,也不会在同一请求中再次读取 PIN;解锁或重启 1Password 后再发起一次新请求。

其他支持的覆盖变量:

  • YUBITOUCH_CONFIG
  • YUBITOUCH_YKCS11
  • YUBITOUCH_OPENSSH_PREFIX
  • YUBITOUCH_SOCKET
  • YUBITOUCH_PIV_SOCKET
  • YUBITOUCH_BACKEND_SOCKET
  • YUBITOUCH_FALLBACK_AGENT
  • YUBITOUCH_FALLBACK_AGENT_SOCKET
  • YUBITOUCH_SSH_SOFTWARE_FALLBACK_PROVIDER
  • YUBITOUCH_SSH_SOFTWARE_FALLBACK_PRIVATE_KEY_REF
  • YUBITOUCH_SOUND
  • YUBITOUCH_SIGN_TIMEOUT
  • YUBITOUCH_LOG_LEVEL

修改环境变量后再次运行 yubitouch configure,然后运行 yubitouch reload。

configure 时的优先级为:当前环境变量、已有配置文件、内置默认值。除内部 daemon 的 --config 外,v0.1 没有配置字段的命令行覆盖。daemon 不读取交互式 shell 环境;它只读取 ensure 注册时确定的 0600 配置文件。YUBITOUCH_CONFIG 只选择配置文件位置,不写入文件。

配置字段 环境变量 默认值
pin_provider YUBITOUCH_PIN_PROVIDER prompt
onepassword_account YUBITOUCH_1PASSWORD_ACCOUNT 1Password 模式必填
onepassword_ref YUBITOUCH_1PASSWORD_REF 1Password 模式必填 op:// reference
public_key YUBITOUCH_PUBLIC_KEY 必填
ykcs11 YUBITOUCH_YKCS11 Homebrew opt/yubico-piv-tool 自动检测
openssh_prefix YUBITOUCH_OPENSSH_PREFIX Homebrew opt/openssh 自动检测
socket YUBITOUCH_SOCKET ~/.ssh/yubitouch/agent.sock
piv_socket YUBITOUCH_PIV_SOCKET ~/.ssh/yubitouch/piv-agent.sock
backend_socket YUBITOUCH_BACKEND_SOCKET ~/.ssh/yubitouch/backend.sock
fallback_agent YUBITOUCH_FALLBACK_AGENT 关闭;可设为 1password
fallback_agent_socket YUBITOUCH_FALLBACK_AGENT_SOCKET 启用时使用 1Password macOS Agent socket
ssh_software_fallback.provider YUBITOUCH_SSH_SOFTWARE_FALLBACK_PROVIDER 关闭;仅 1password,none/off/disabled 删除配置
ssh_software_fallback.private_key_ref YUBITOUCH_SSH_SOFTWARE_FALLBACK_PRIVATE_KEY_REF 同一 Ed25519 私钥的规范 op:// reference,且不得等于 PIV PIN reference
sound YUBITOUCH_SOUND Glass;none 静音
sign_timeout YUBITOUCH_SIGN_TIMEOUT 60s;必须大于零且不超过 1h
log_level YUBITOUCH_LOG_LEVEL info

启用后的独立配置段为:

"ssh_software_fallback": {
  "provider": "1password",
  "private_key_ref": "op://<vault>/<item>/<private-key-field>"
}

它只复用顶层 onepassword_account 来选择 Desktop App Integration 账户,不依赖 fallback_agent=1password,也不复用 onepassword_ref。configure 不接受私钥正文的环境变量。

可选 age 段严格校验嵌套字段,不接受未知字段:

配置字段 环境变量 约束
age.serial YUBITOUCH_AGE_SERIAL 规范的非零十进制 uint32
age.slot YUBITOUCH_AGE_SLOT PIV 9a/9c/9d/9e 或退休槽 82 到 95
age.algorithm YUBITOUCH_AGE_ALGORITHM 仅 x25519
age.standard_fallback.provider YUBITOUCH_AGE_STANDARD_FALLBACK_PROVIDER 仅 1password;none/off/disabled 删除配置
age.standard_fallback.identity_ref YUBITOUCH_AGE_STANDARD_FALLBACK_IDENTITY_REF 与 hardware 同一私钥的规范 op:// secret reference

age.public_key 是 yubitouch age recipient|identity 首次只读设备后写入的 32 字节公开缓存, 不接受环境变量覆盖。age.sock 从当前配置文件所在目录在运行时派生,也不写入配置。 fallback 复用顶层 onepassword_account。配置只保存 secret reference,并在每次从 1Password 加载 identity 时验证其派生公钥与 hardware 缓存完全一致。缺卡 provider 面板、硬件触摸界面 中的显式切换入口和限时会话菜单只在这份 standard fallback 配置完整有效时启用。

当前版本只能在 serial、slot 或 algorithm 配置变化时自动清除这份缓存;如果在同一设备、同一 槽位内重新生成或替换 X25519 key,它无法只根据配置识别变化。不要继续使用旧 recipient 加密。 先停止 daemon,再用 JSON 工具只删除 age.public_key,保留 age.serial、age.slot、 age.algorithm 和 fallback 配置,然后插入目标设备并重新生成描述符:

(
  set -eu
  yubitouch stop
  config_path="${YUBITOUCH_CONFIG:-$HOME/.ssh/yubitouch/config.json}"
  temporary_path="$(mktemp "${config_path}.XXXXXX")"
  trap 'rm -f "$temporary_path"' EXIT HUP INT TERM
  jq 'del(.age.public_key)' "$config_path" > "$temporary_path"
  chmod 600 "$temporary_path"
  mv "$temporary_path" "$config_path"
  trap - EXIT HUP INT TERM
  yubitouch age recipient > recipient.new.txt
  yubitouch age identity > yubitouch-identity.new.txt
  chmod 600 yubitouch-identity.new.txt
  yubitouch reload
)

在替换依赖旧 key 的 recipient 文件和自动化配置前,先核对新的单行输出。旧私钥一旦不可用, 对应密文就无法解密。

yubitouch ensure 原子写入 ~/Library/LaunchAgents/com.github.mofelee.yubitouch.plist,然后 bootstrap 或 kickstart 当前 GUI 用户的 LaunchAgent。plist 使用 RunAtLoad 和 KeepAlive; 登录启动、ensure 和 reload 都只恢复 daemon/公共受管路由,不加载 provider。

诊断日志

daemon 将有界 JSONL 日志写入 ~/.ssh/yubitouch/yubitouch.log,权限固定为 0600。 日志达到约 1 MiB 后会在原文件内重置,避免后台服务无限占用磁盘。log_level 支持:

  • error:只记录失败和超时分类。
  • info:额外记录 daemon 生命周期、provider 固定初始化阶段与签名结果,默认值。
  • debug:额外记录 age 初始化和等待触摸状态。

日志接口只接受预定义事件和失败分类,不接受任意错误字符串。PIN、PIN 长度、签名请求、 签名结果、请求程序、程序路径、远程主机、设备 serial、fallback identity、完整 1Password secret reference、age file key 和 X25519 shared secret 均不会写入日志。 yubitouch status 显示日志路径、权限和大小;yubitouch doctor 会检查日志是否为普通的 0600 文件。

SSH 配置

SSH 配置只指向标准 Agent socket,不需要 wrapper 或 Match exec:

Host example-yubikey
    HostName server.example.com
    User your-user
    IdentityAgent ~/.ssh/yubitouch/agent.sock
    IdentityFile ~/.ssh/yubikey-piv.pub
    IdentitiesOnly yes
    ForwardAgent no
    ControlMaster no

多个主机可以使用普通 SSH pattern:

Host production-* bastion
    IdentityAgent ~/.ssh/yubitouch/agent.sock
    IdentityFile ~/.ssh/yubikey-piv.pub
    IdentitiesOnly yes
    ForwardAgent no

读取 SSH config 的第三方程序可直接使用同一 socket。ssh -G、密钥列表查询以及没有 新签名的 ControlMaster 复用不会请求 PIN 或显示触摸提示。

DebianForm 是一个通过 OpenSSH 管理 Debian/Ubuntu 主机的声明式配置管理 CLI,不是 SSH 客户端 GUI。它无需 YubiTouch wrapper 或专用集成。 让它继续使用系统 SSH 配置,并让目标 Host 匹配上面的 IdentityAgent、IdentityFile 和 IdentitiesOnly 即可。DebianForm 发起新的 SSH 签名时会出现触摸提示;复用已有连接而没有 新签名时不显示提示是预期行为。

完成本地验证后可直接测试配置与登录:

yubitouch doctor
ssh -G example-yubikey >/dev/null
ssh example-yubikey

命令

yubitouch configure       校验并保存非敏感配置
yubitouch ensure          检查或注册 LaunchAgent,不加载 provider
yubitouch status          显示脱敏状态
yubitouch status --json   输出稳定的机器可读状态
yubitouch reload          重启服务并读取新配置
yubitouch stop            停止当前用户的 LaunchAgent
yubitouch doctor          检查依赖、权限、socket 和配置
yubitouch test-sign       显式运行 PIN、触摸和签名全链路
yubitouch age recipient   输出单行原生 age1... recipient
yubitouch age identity    输出单行 AGE-PLUGIN-YUBITOUCH-1... identity
yubitouch age key to-pkcs8 --identity-file PATH --output PATH
                          把单个原生 age identity 转成新建的 0600 PKCS#8 PEM
yubitouch about           显示项目身份及无关联声明
yubitouch version         显示版本和提交信息

status 只通过 IOKit USB registry 探测设备,并且只输出设备数量,不读取或返回序列号。 设备状态为 connected、not_detected 或 probe_unavailable;该探测不会打开 PC/SC/CCID、 加载 YKCS11、读取 PIN 或显示触摸提示。

status --json 用 agent_route 报告 piv、1password 或失败闭合的 piv_fail_closed,并输出 route_probe_state、route_changed_at、 route_state_stale、route_guard_ready、piv_agent_socket、piv_agent_reachable、fallback_enabled、 fallback_agent、fallback_checked、fallback_agent_reachable、 fallback_key_available 和 fallback_other_keys。fallback_checked=false 表示当前状态 没有主动联系 1Password,并不等于 socket 或 key 检查失败。路由为 1password 时, last_sign_event 和 last_sign_at 仍只表示上一次经过 PIV Agent 的事件,不是 1Password 签名审计记录。PIV 路由上不会主动检查回退身份;要判定回退是否已安全就绪, 应以 yubitouch doctor 的主动检查为准。

age 状态报告 age_configured、age_socket_reachable、age_public_key_cached、 age_standard_fallback_configured、age_standard_key_model,以及上一次请求的预定义 age_backend、age_result 和时间。 age_standard_key_model 为 hardware_only 或 hardware_plus_fallback;hardware-only 状态同时 提醒设备/key 丢失不可恢复。状态不会输出 serial、slot、key ID、公钥、identity reference 或 底层错误。age_backend 还会用 software_session 表示限时会话,但 state.json 不保存 live phase/deadline;当前模式和剩余时间以菜单栏为准。age 软件路径不改变 agent_route;SSH 缺卡 路由与 age 自己的 provider/session 路由是彼此独立的状态机。

SSH 临时签名配置只报告 ssh_software_fallback_configured 和 ssh_software_fallback_reference_valid。CLI 状态不会输出 private-key reference、1Password 账户、私钥材料、软件 Agent endpoint 或 live phase/deadline;当前 SSH 会话和剩余时间只在 菜单栏显示。doctor 对 SSH private-key reference 也只做本地语法校验,不解析该 secret。

test-sign 在 Agent 协议返回通用失败时,只读取 daemon 同步写入 state.json 的预定义 失败分类。设备不可用、PIN/provider 初始化、目标 key 不匹配、超时或取消分别映射到 退出码 3、4、5、6;未知分类返回 1。底层错误文本和被篡改的分类不会回显。 在安全的 1Password 回退路由上,test-sign 不要求 YubiKey 存在,并直接校验目标 key 的签名结果;1Password 拒绝时无法使用 PIV daemon 的失败分类。

退出码:0 成功,1 运行错误,2 配置错误,3 设备不可用,4 PIN provider 失败或取消,5 目标公钥不匹配,6 签名超时或取消。

故障排查

现象 检查与处理
not configured 设置必要环境变量并重新运行 yubitouch configure。
公共 Agent socket 不可达 运行 yubitouch ensure,再检查 yubitouch status 与诊断日志。
doctor 报告 YubiKey 不可用 未启用回退时重新插入设备。已启用回退时,还要确认 agent_route=1password且回退 socket/目标 key/身份隔离检查全部通过。
PIV 9A key 不匹配 重新执行 9A 公钥导出;不要选择 RSA Attestation key。
prompt PIN 被取消或不可显示 在 Aqua 图形会话重试;TTY fallback 也不可用时不会后台等待。
1Password 初始化失败 解锁桌面应用,启用 Integrate with other apps,检查 account/reference 后重新 configure。
provider_readiness YKCS11/PCSC 尚未就绪;重新插拔 YubiKey 后重试。该失败发生在读取 PIN 之前。
pin_provider_timeout 或没有出现触摸提示 请求仍停在 PIN 阶段;解锁或重启 1Password 后重试。YubiTouch 只在 PIN 成功交付、provider 加载并校验目标 key 后显示触摸提示。
回退一直是 piv_fail_closed 运行 yubitouch doctor;检查 1Password SSH Agent 是否启用、socket 父目录是否为当前用户所有且不可被组/其他用户写入,以及 agent.toml 是否只暴露目标 key。
fallback_other_keys 大于 0 缩小 ~/.config/1Password/ssh/agent.toml,直到 1Password Agent 只列出配置的目标 key,然后重载。
缺卡签名没有 YubiTouch 浮层 当 agent_route=1password 时是预期行为;授权 UI 及取消由 1Password 拥有。
1Password 授权超时后窗口仍显示 YubiTouch 已终止自己的 helper;在 1Password 中手动取消该窗口。当前 SDK 上游 #266 不响应 context 取消。
签名超时 保持设备连接,在提示出现后触摸;YubiTouch 不会自动重试该请求。
SSH 临时会话入口不可用 确认 software fallback 已配置且 reference 语法有效、目标 YubiKey 为 connected、public route 为 piv;缺卡或探测不明时不会创建临时会话。
SSH 临时签名报告 key mismatch 1Password 必须保存与配置的 PIV 9A 公钥完全相同的 Ed25519 私钥;失败后不会在同一请求内改走硬件。
SSH 临时会话没有恢复 这是预期行为;live phase/deadline 不持久化,提前结束、生命周期事件或 daemon reload/stop/restart 后都回到 YubiKey 优先。
age 报告找不到插件 确认 command -v age-plugin-yubitouch 指向 App bundle 中精确同名的可执行文件;不要把主 yubitouch 二进制改名代替插件。
age identity 无法连接 daemon 运行 yubitouch ensure,确认 age_socket_reachable=true;加密只需 recipient,不需要 daemon。
recipient 的发送端仍在找插件 分发 yubitouch age recipient 输出的标准 age1...,发送端只需要原生 age。
缺卡时 age 没有自动进入 fallback 当前不会自动读取软件私钥。配置有效 age.standard_fallback 且两次探测明确 not_detected 后,在 provider 面板选择“仅本次”或 1、3、5 分钟;mismatch、探测异常或硬件操作失败仍失败闭合。
age 临时会话没有恢复 这是预期行为;live phase/deadline 不持久化,daemon reload、停止、崩溃或重启后都回到硬件优先。
fallback 报告 key mismatch 1Password 必须保存与 YubiKey 完全相同的原生 identity,另一把 key 不可用。
同一 serial/slot 换 key 后报告 mismatch 当前版本不会自动刷新 age.public_key;按 age 功能参考的更换硬件 key 流程停止 daemon、只删除该缓存字段、重新生成 recipient/identity 并重启。不要继续分发旧 recipient。
重建后版本哈希或界面看起来未更新 哈希是 Git commit,不是构建序号。用 git rev-parse --short=12 HEAD 核对,然后按 yubitouch stop、ditto、yubitouch ensure 的顺序替换运行中的 daemon。
配置或路径修改后行为未变化 重新 configure,再运行 yubitouch reload。
stale socket/backend 错误 先 yubitouch stop,确认受管服务停止后再 yubitouch ensure;不要手工杀死未知 agent。

status --json 适合采集脱敏状态,doctor 适合检查依赖与配置,test-sign 是唯一显式运行 完整 PIN/provider/touch 链路的诊断命令。错误详情按预定义分类记录在 ~/.ssh/yubitouch/yubitouch.log,不应通过开启日志来寻找 PIN 或签名内容。

安全边界

  • PIV 路由的私钥和签名操作留在 YubiKey/YKCS11/OpenSSH 中;回退路由的私钥和签名操作由 1Password SSH Agent 拥有。
  • PIV Agent 只列出配置的目标公钥,拒绝其他 key 的签名,也拒绝 Add、Remove、RemoveAll、Lock 和 Unlock。
  • 1Password 路由是直接路由,YubiTouch 无法过滤其协议响应;只有 agent.toml 使 Agent 恰好暴露目标 key 时才允许切换。
  • 回退 socket 必须是当前用户所有的真实 Unix socket,其直接父目录不能是符号链接,也不能被 group/other 写入。
  • 只有 IOKit 明确报告缺卡且防抖确认后才进入回退;监视错误、身份不匹配、多余身份和 daemon 启停都失败闭合到 PIV。
  • PIV 路由的每个前端连接使用独立 backend Agent 连接;[email protected] 上下文不会跨客户端共享。
  • SSH 临时软件会话只接管 public route 为 piv、目标 YubiKey 明确 connected 且可靠分类为本地的目标 key 请求;可信 forwarded binding 和任何未知/冲突分类都继续走 PIV 硬件。
  • SSH private key 只从一次性 1Password resolver 经匿名 pipe 进入一次性导入进程和隔离 ssh-agent;resolver/importer 在 ready 前退出。临时 Agent 只能包含目标 key,daemon 不接触私钥正文或 seed,session manager 不保存完整 reference。
  • SSH “仅本次”不允许并发请求加入;1/3/5 分钟使用固定绝对 TTL 且不续期。软件签名、协议或进程状态异常会销毁整个会话,本次请求不自动退回硬件或再次读取 1Password。
  • 同一时间只有一个 PIV 签名进入 backend;错误 PIN 不自动重试。
  • UI 取消、客户端断开和超时会关闭当前 backend 连接;旧请求的取消信号不能作用于下一条请求。
  • 公共 Agent frame 在 payload 分配前限制为 1 MiB;session-bind 另限制为 16 条和累计 1 MiB。
  • backend 尚未建立时仅接受并缓存 session-bind;其他 extension 返回标准 unsupported,不触发 backend/PIN/UI。
  • 请求程序身份来自 Unix socket 内核对端信息,不接受 Agent 请求自报的程序名,也不读取 argv。
  • 请求程序名称、bundle identifier 和程序路径不会写入状态文件、诊断日志或命令输出。
  • PIN 不进入命令行参数、配置、普通环境变量、日志或状态文件。
  • 签名请求和签名结果不会写入日志、状态文件或 UI。
  • age recipient/identity 不编码设备 serial、slot、PIN、1Password reference 或私钥;标准 age1... 只含公钥,identity 的稳定 ID 会与配置及实际槽位公钥重新绑定校验。
  • 标准 X25519 stanza 保持匿名,不加入本地 path、profile ID、key ID 或 provider 路由材料;插件只接受恰好一个标准 stanza。
  • age 硬件路径的 PIV X25519 ECDH 与 SSH 签名共用全局串行队列;已选择硬件的请求失败时绝不切换到任何软件路径。
  • age 公钥读取和目标 probe 各自在无 PIN、无私钥能力的一次性 helper 中运行;目标只走有界 pipe,超时/取消会杀进程组并回收,避免同步 PKCS#11 cgo 阻塞 daemon 或 CLI。
  • PIN 只从一次性 resolver 进入 hardware helper;X25519 shared secret 和私钥操作留在 helper/YubiKey。daemon 只接收并转交成功解包的 16 字节 file key,不接收 PIN、shared secret 或私钥。
  • age hardware helper 由 daemon 隔离并常驻,只保留已认证的 YKCS11 session;一次性 PIN resolver 在 C_Login 前退出并被回收,PIN 登录后清零。正常成功复用 session,但每次 ECDH 仍要求 Touch policy: ALWAYS 和独立触摸门控。
  • 任意 YubiKey 插拔、配置 reload、daemon/helper 重启、session/硬件/协议错误、取消、断开、超时或父进程验证失败都会销毁并回收 hardware helper;状态不明时不复用或自动重试。
  • 配置 standard fallback 后,可信 connected probe 直接走硬件,并只在等待触摸时允许显式改用 1Password;可信 missing probe 才显示软件 provider 选择。“仅本次”由一次性 helper 解析唯一 identity、验证同钥公钥并完成解包。限时选择由一次性 resolver 把经验证的 32 字节 scalar 交给隔离 session helper,daemon 不接收 identity/scalar,只转交 file key。
  • age 临时软件会话只影响 age,使用独立的 1/3/5 分钟绝对 TTL 且不续期;菜单可提前结束,锁屏、睡眠、图形会话失活、配置变化、请求错误、客户端 delivery ACK 缺失/错误或进程退出会终止,daemon 重启不恢复。
  • Hardware-only 没有私钥副本,设备/key 丢失后无法解密。fallback 保存同一私钥;启用后整体安全级别不再高于 1Password 路径。SDK 返回的 secret string 无法可靠清零,限时会话还必须在 helper 的锁定内存中保留 scalar;这些隔离措施不等于硬件不可导出。
  • 本地 daemon/plugin/helper 只输出预定义错误分类。上游 AGEDEBUG=plugin 不受此边界约束,可能把协议内容或 file key 写入调试输出。
  • ProxyJump 不需要 Agent Forwarding。ForwardAgent yes 只能对完全可信的 Host 启用,因为远程主机可在连接存活期间使用当前路由上的签名能力;PIV 路由上的 forwarded 请求不会使用 SSH 临时软件会话。

YubiTouch 不能防御已经完全控制当前 macOS 用户或 root 的恶意软件。age socket 只验证对端 属于同一 UID;已认证 hardware session 有效期间,同 UID 恶意进程可以提交一个绑定到已配置 key 的解密请求并等待用户触摸。每次 ECDH 仍需要 YubiKey 触摸,触摸 UI 会显示解析出的请求 程序身份供用户判断,但 session 复用扩大了“不再用 PIN 再次确认”的时间窗口。SSH Agent 同样 不能消除用户触摸期间同权限恶意进程抢用签名能力的风险。临时软件会话 active 时,同 UID 请求不再需要物理触摸;固定绝对 TTL、菜单提前结束和生命周期终止只能限制这一窗口,不能把 它变成逐请求授权。

故障恢复

受管 ssh-agent 异常退出或 backend socket 消失时,下一次签名会在同一次健康检查中重启 YubiTouch 自己持有进程句柄的 agent。已经连接的前端客户端会在签名前通过只读 identity 查询检测失效连接,重建独立 backend 连接,并重放该客户端的 [email protected] 上下文。绑定数据只保存在内存中,限制为 16 条和 1 MiB,并在连接关闭时尽力清零。

YubiTouch 不自动重试已经发给 backend 的签名,因为响应丢失时无法证明签名没有成功; 这样可以避免重复签名和错误 PIN 重试。无法验证为当前 Manager 启动的进程不会被终止。

每个公共 Agent 客户端都有独立的可取消 context。Unix socket HUP/EOF 会取消该客户端: 仍在全局队列中的请求直接丢弃,不启动签名且不覆盖当前触摸 UI;已经开始的请求停止等待、 关闭该客户端的 backend 连接并显示失败状态,但不会自动重试底层签名。

status 只有在公共路由及其目标可达、内部 PIV socket 可达,且 state.json 记录的 daemon PID 仍存活时,才把 provider 状态视为当前状态。崩溃遗留文件会报告 state_stale: true 和 provider_state: unavailable, 不会展示陈旧 PID;最后签名事件和时间仅作为历史信息保留。状态检查不会主动终止 PID。 daemon 只管理当前用户所有且指向已配置目标的公共符号链接;遇到非受管文件会拒绝覆盖。 如果 backend socket 仍由可达进程监听, 新 Manager 会将其视为未受管资源并拒绝接管或终止。

配置保存 Homebrew 的稳定 opt/yubico-piv-tool/lib/libykcs11.dylib 路径,每次加载 provider 前重新解析当前实际 dylib。旧开发版写入的 Apple Silicon/Intel Cellar 版本路径会在读取时 规范化回 opt 路径,下一次 configure 会持久化迁移,因此 Homebrew 升级不会固定到旧版本。

开发验证

自动化测试覆盖配置权限、禁止 PIN 字段、Agent key 过滤、受限操作、SignWithFlags、 session-bind 重放、每客户端 backend、签名串行化、超时、AskPass 一次性 guard、原子路由、 IOKit watcher 生命周期、事件驱动路由、缺卡去抖、回退 key 隔离与 LaunchAgent plist 和脱敏状态。子进程崩溃测试还覆盖公共受管路由恢复、无副作用 identity 查询和 daemon 状态 PID 更新;跨 Manager 测试覆盖可达 backend 的归属边界。真实 Unix socket 与 ssh-agent 生命周期测试在受限沙箱外运行。

真实硬件、LaunchAgent、OpenSSH/ykcs11 版本、ControlMaster 和 DebianForm 的验证步骤与 记录模板见 docs/verification.md。更新矩阵时必须记录版本和结果, 不得附加 PIN、签名内容、设备序列号、账户名或完整 secret reference。

现有 PIV 路由的真实签名、设备拔插、Touch ID、OpenSSH/YKCS11 版本矩阵、DebianForm 和全屏 Space 已完成验收。#20 的 1Password 缺卡路由也已按 docs/verification.md 完成真实 SSH、ControlMaster、ProxyJump、 Agent Forwarding 边界和 Git SSH commit 签名验证。 #21 的 arm64 源码签名 App 已完成公开描述符、离线加密、hardware 成功/触摸取消/触摸超时。 #23 已完成同一 daemon 的 session 复用和设备重插失效验收;#24 已完成标准 X25519 同钥 fallback 的硬件、缺卡和重新插入真机验收。使用 1Password PIN provider 时,授权完成前不会显示 YubiTouch 触摸面板,授权完成后才进入触摸。错误 PIN 因 设备重试次数有限只由自动化故障注入覆盖;1Password 授权窗口取消能力继续由上游 SDK 问题 跟踪,不阻止源码构建使用。#27 的 SSH 临时会话自动化和待执行真机矩阵见 docs/verification.md;pending 项不能由单元测试 结果替代。

Contributors

mofelee

Issues